diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 31645356c4d..3b94f4ed945 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -22,7 +22,7 @@ jobs: - name: Build author-only allowlist id: allowlist if: > - github.event_name == 'pull_request_target' || + (github.event_name == 'pull_request_target' && github.event.action != 'closed') || (github.event_name == 'issue_comment' && github.event.issue.pull_request && ( github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read and agree to the Contributor License Agreement' @@ -48,10 +48,10 @@ jobs: fi - name: CLA Assistant - # Run on PR events, on "recheck" comment, or when someone posts the signing phrase. + # Run on open/update PR events, on "recheck", or when someone posts the signing phrase. # IMPORTANT: this phrase must match `custom-pr-sign-comment` below. if: > - github.event_name == 'pull_request_target' || + (github.event_name == 'pull_request_target' && github.event.action != 'closed') || (github.event_name == 'issue_comment' && github.event.issue.pull_request && ( github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read and agree to the Contributor License Agreement' @@ -62,6 +62,8 @@ jobs: # PAT required to write to the centralized signatures repo. PERSONAL_ACCESS_TOKEN: ${{ secrets.PERSONAL_ACCESS_TOKEN }} with: + lock-pullrequest-aftermerge: false + # Where the CLA document lives (shown to contributors) path-to-document: https://github.com/Comfy-Org/comfy-cla/blob/main/comfyui_icla.md @@ -94,3 +96,25 @@ jobs: custom-allsigned-prcomment: | ✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged. + + - name: Lock PR containing a CLA signature or bot approval + if: github.event_name == 'pull_request_target' && github.event.action == 'closed' && github.event.pull_request.merged == true + uses: actions/github-script@v7 + with: + retries: 3 + script: | + const pr = context.payload.pull_request; + const issue = { ...context.repo, issue_number: pr.number }; + const comments = await github.paginate(github.rest.issues.listComments, { + ...issue, + per_page: 100, + }); + const signed = comments.some(comment => + (comment.user?.id === pr.user.id && + comment.body?.trim().toLowerCase() === 'i have read and agree to the contributor license agreement') || + (comment.user?.login === 'github-actions[bot]' && + comment.body?.startsWith('✅ All contributors have signed the CLA. Thank you! This PR is ready to be merged.')) + ); + if (signed) { + await github.rest.issues.lock(issue); + } diff --git a/QUANTIZATION.md b/QUANTIZATION.md index 30082202958..a3fd13d1480 100644 --- a/QUANTIZATION.md +++ b/QUANTIZATION.md @@ -152,6 +152,27 @@ Example: To create compatible checkpoints, use any quantization tool provided the output follows the checkpoint format described above and uses a layout defined in `QUANT_ALGOS`. +### Diffusion attention preferences + +A diffusion attention module can have a `.comfy_attention.config` entry whose +uint8 tensor contains UTF-8 JSON: + +```json +{"attention": "comfy_kitchen_int8"} +``` + +Use the module that performs attention, such as `transformer_blocks.0.attn` for +Qwen Image 2.1 or `blocks.0.attn` for MiniMax H3. + +Only `comfy_kitchen_int8` is supported. Invalid targets and other method names +are ignored with a warning during loading, leaving normal attention selection. +Kitchen INT8 support is checked when each preference is loaded +for the primary device; unsupported devices keep normal attention selection. +Explicit attention overrides retain priority. +The `ComfyAttention` child module loads and saves its own metadata through normal +state-dict loading and saving. Preferences do not enable weight +quantization. Text encoder and VAE loaders do not apply these preferences. + ### Weight Quantization Weight quantization is straightforward - compute the scaling factor directly from the weight tensor using the absolute maximum method described earlier. Each layer's weights are quantized independently and stored with their corresponding `weight_scale` parameter. diff --git a/README.md b/README.md index 660b5242a76..349211c363b 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ See what ComfyUI can do with the [newer template workflows](https://comfy.org/wo - [Image editing](https://comfy.org/workflows/tag/image-edit/): Flux Kontext, Flux.2 Klein, Qwen Image Edit, HiDream E1.1 and O1, OmniGen2, Boogu, JoyImage Edit, MageFlow Edit, and LongCat Image Edit. - [Video generation](https://comfy.org/workflows/tag/video-generation/): Wan 2.1 and 2.2, LTX-Video 2 and 2.3, HunyuanVideo 1.5, Kandinsky 5 Video, CogVideoX, Cosmos Predict2, Bernini-R, SCAIL 2, and Mochi. - [Audio and video generation](https://comfy.org/workflows/): MiniMax H3 and LTX-AV. - - [Audio generation](https://comfy.org/workflows/tag/text-to-audio/): ACE-Step 1.5, Stable Audio 3 and MiniMax Music 3 + - [Audio generation](https://comfy.org/workflows/tag/text-to-audio/): ACE-Step 1.5, Stable Audio 3, MiniMax Music 3 and Yue 2. - [3D and vision](https://comfy.org/workflows/): Hunyuan3D 2.1, TripoSplat, SeedVR2, SUPIR, Depth Anything 3, MoGe, SAM 3 and 3.1, RT-DETRv4, and BiRefNet. - [Text generation](https://comfy.org/workflows/tag/text-generation/): Gemma 3 and 4, Qwen3, Qwen3.5, and Qwen3-VL, including multimodal inputs. - Load complete checkpoints or separate diffusion models, VAEs, text encoders, LoRAs, ControlNets, adapters, and upscalers from supported model formats. @@ -214,21 +214,30 @@ This is the command to install the nightly with ROCm 7.2 which might have some p ```pip install --pre torch torchvision torchaudio --index-url https://download.pytorch.org/whl/nightly/rocm7.2``` -### AMD GPUs (Experimental: Windows and Linux), RDNA 3, 3.5 and 4 only. +### AMD GPUs (Windows, ROCm 10.0) -These have less hardware support than the builds above but they work on windows. You also need to install the pytorch version specific to your hardware. +Use AMD's [multi-architecture PyTorch packages](https://rocm.docs.amd.com/projects/ai-ecosystem/en/latest/frameworks/pytorch/install.html). The `device-*` extras install your GPU's kernels and the matching ROCm runtime automatically; a separate HIP SDK installation is not needed. -RDNA 3 (RX 7000 series): +Use Windows 11, a current [AMD graphics driver](https://www.amd.com/en/support/download/drivers.html), and 64-bit Python 3.13. -```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx110X-all/``` +The install command below uses `device-all` to install kernels for all supported GPUs. To reduce download size and disk usage, optionally replace **both** occurrences of `device-all` with the target for your GPU: -RDNA 3.5 (Strix halo/Ryzen AI Max+ 365): +| GPU | Device extra | +| --- | --- | +| RX 9070 / XT, Radeon AI PRO R9700 | `device-gfx1201` | +| RX 9060 / XT | `device-gfx1200` | +| RX 7900 XT / XTX | `device-gfx1100` | +| RX 7700 XT / 7800 XT | `device-gfx1101` | +| RX 7600 / XT | `device-gfx1102` | +| Ryzen AI Max / Max+ (Strix Halo) | `device-gfx1151` | -```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx1151/``` +**Note:** This table only lists examples. A GPU missing from it may still be supported: supported architectures include RDNA 2, RDNA 3, RDNA 3.5, and RDNA 4. Keep `device-all` to install kernels for all supported targets. For other models, see AMD's [GPU target table](https://github.com/ROCm/TheRock/blob/main/RELEASES.md#gfx-target-lookup-table) and [ROCm compatibility matrix](https://rocm.docs.amd.com/en/docs-10.0.0/compatibility/compatibility-matrix.html). -RDNA 4 (RX 9000 series): +**ROCm 10.0.0 with PyTorch 2.13:** -```pip install --pre torch torchvision torchaudio --index-url https://rocm.nightlies.amd.com/v2/gfx120X-all/``` +```bat +pip install --index-url https://stable.repo.amd.com/rocm/whl-next/ "torch[device-all]==2.13.0+rocm10.0.0" "torchvision[device-all]==0.28.0+rocm10.0.0" "torchaudio==2.11.0.2+rocm10.0.0" +``` ### Intel GPUs (Windows and Linux) diff --git a/alembic_db/versions/0001_assets.py b/alembic_db/versions/0001_assets.py index 1e10b94dc36..0e5e4c3e5cd 100644 --- a/alembic_db/versions/0001_assets.py +++ b/alembic_db/versions/0001_assets.py @@ -150,7 +150,6 @@ def downgrade() -> None: op.drop_index("ix_asset_cache_state_asset_id", table_name="asset_cache_state") op.drop_index("ix_asset_cache_state_file_path", table_name="asset_cache_state") - op.drop_constraint("uq_asset_cache_state_file_path", table_name="asset_cache_state") op.drop_table("asset_cache_state") op.drop_index("ix_asset_info_tags_asset_info_id", table_name="asset_info_tags") @@ -160,7 +159,6 @@ def downgrade() -> None: op.drop_index("ix_tags_tag_type", table_name="tags") op.drop_table("tags") - op.drop_constraint("uq_assets_info_asset_owner_name", table_name="assets_info") op.drop_index("ix_assets_info_owner_name", table_name="assets_info") op.drop_index("ix_assets_info_last_access_time", table_name="assets_info") op.drop_index("ix_assets_info_created_at", table_name="assets_info") diff --git a/alembic_db/versions/0002_merge_to_asset_references.py b/alembic_db/versions/0002_merge_to_asset_references.py index 1ac1b980c72..a39f19dc4f0 100644 --- a/alembic_db/versions/0002_merge_to_asset_references.py +++ b/alembic_db/versions/0002_merge_to_asset_references.py @@ -177,7 +177,10 @@ def downgrade() -> None: NOTE: Data is not recoverable. The upgrade discards all rows from the old tables and truncates assets. After downgrade the old schema will be empty. - A filesystem rescan will repopulate data once the older code is running. + A filesystem rescan can repopulate only data derived from paths. It will + not restore user_metadata, manually-applied tags, preview_id nominations, + name renames, or job_id, because build_asset_specs derives names and tags + from the path alone. """ # Drop new tables (order matters due to FK constraints) op.drop_index("ix_asset_reference_meta_key_val_bool", table_name="asset_reference_meta") diff --git a/alembic_db/versions/0007_record_content_split.py b/alembic_db/versions/0007_record_content_split.py new file mode 100644 index 00000000000..c88af94d5ed --- /dev/null +++ b/alembic_db/versions/0007_record_content_split.py @@ -0,0 +1,125 @@ +""" +Record/content split. + +This migration intentionally discards the existing asset database. The +asset_reference_meta, asset_reference_tags, asset_references, and assets +tables are dropped, and DELETE FROM tags removes all existing tag rows. No +data migration is performed. + +A filesystem rescan after this migration will not restore user_metadata, +manually-applied tags, preview_id nominations, name renames, or job_id. +build_asset_specs derives names and tags from the path alone. + +Revision ID: 0007_record_content_split +Revises: 0006_add_loader_path +Create Date: 2026-08-26 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0007_record_content_split" +down_revision = "0006_add_loader_path" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.drop_table("asset_reference_meta") + op.drop_table("asset_reference_tags") + op.drop_table("asset_references") + op.drop_table("assets") + op.execute("DELETE FROM tags") + op.create_table( + "asset_contents", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("hash", sa.String(256)), + sa.Column("size_bytes", sa.BigInteger(), nullable=False), + sa.Column("path", sa.Text(), nullable=False), + sa.Column("mtime_ns", sa.BigInteger()), + sa.Column("is_missing", sa.Boolean(), nullable=False, server_default="0"), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.CheckConstraint("size_bytes >= 0", name="ck_asset_contents_size_nonneg"), + sa.CheckConstraint("mtime_ns >= 0", name="ck_asset_contents_mtime_nonneg"), + ) + op.create_index("ix_asset_contents_hash", "asset_contents", ["hash"]) + op.create_index( + "uq_asset_contents_path_live", "asset_contents", ["path"], unique=True, + sqlite_where=sa.text("is_missing = 0"), + ) + op.create_table( + "assets", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("content_id", sa.String(36), sa.ForeignKey("asset_contents.id", ondelete="RESTRICT"), nullable=False), + sa.Column("name", sa.String(512), nullable=False), + sa.Column("mime_type", sa.String(255)), + sa.Column("system_metadata", sa.JSON()), + sa.Column("job_id", sa.String(36)), + sa.Column("user_metadata", sa.JSON()), + sa.Column("loader_path", sa.Text()), + sa.Column("preview_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="SET NULL")), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.Column("last_access_time", sa.DateTime()), + ) + op.create_index("ix_assets_content_id", "assets", ["content_id"]) + op.create_index("ix_assets_name", "assets", ["name"]) + op.create_index("ix_assets_created_at", "assets", ["created_at"]) + op.create_index("ix_assets_preview_id", "assets", ["preview_id"]) + op.create_table( + "asset_meta", + sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True), + sa.Column("key", sa.String(256), primary_key=True), + sa.Column("ordinal", sa.Integer(), primary_key=True), + sa.Column("val_str", sa.String(2048)), sa.Column("val_num", sa.Numeric(38, 10)), + sa.Column("val_bool", sa.Boolean()), sa.Column("val_json", sa.JSON()), + sa.CheckConstraint("val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL", name="ck_asset_meta_has_value"), + ) + op.create_index("ix_asset_meta_key", "asset_meta", ["key"]) + op.create_index("ix_asset_meta_key_val_str", "asset_meta", ["key", "val_str"]) + op.create_index("ix_asset_meta_key_val_num", "asset_meta", ["key", "val_num"]) + op.create_index("ix_asset_meta_key_val_bool", "asset_meta", ["key", "val_bool"]) + op.create_table("asset_tags", sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True), sa.Column("tag_name", sa.String(512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True), sa.Column("origin", sa.String(32), nullable=False), sa.Column("added_at", sa.DateTime(), nullable=False)) + op.create_index("ix_asset_tags_tag_name", "asset_tags", ["tag_name"]) + op.create_index("ix_asset_tags_asset_id", "asset_tags", ["asset_id"]) + op.create_table("asset_system_state", sa.Column("key", sa.String(256), primary_key=True), sa.Column("value", sa.Text(), nullable=False)) + + +def downgrade() -> None: + op.drop_table("asset_system_state") + op.drop_table("asset_tags") + op.drop_table("asset_meta") + op.drop_table("assets") + op.drop_table("asset_contents") + op.create_table( + "assets", + sa.Column("id", sa.String(length=36), primary_key=True), + sa.Column("hash", sa.String(length=256), nullable=True), + sa.Column("size_bytes", sa.BigInteger(), nullable=False, server_default="0"), + sa.Column("mime_type", sa.String(length=255), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=False), nullable=False), + sa.CheckConstraint("size_bytes >= 0", name="ck_assets_size_nonneg"), + ) + op.create_index("uq_assets_hash", "assets", ["hash"], unique=True) + op.create_index("ix_assets_mime_type", "assets", ["mime_type"]) + op.create_table("asset_references", sa.Column("id", sa.String(36), primary_key=True), sa.Column("asset_id", sa.String(36), sa.ForeignKey("assets.id", ondelete="CASCADE"), nullable=False), sa.Column("file_path", sa.Text()), sa.Column("loader_path", sa.Text()), sa.Column("mtime_ns", sa.BigInteger()), sa.Column("needs_verify", sa.Boolean(), nullable=False), sa.Column("is_missing", sa.Boolean(), nullable=False), sa.Column("enrichment_level", sa.Integer(), nullable=False), sa.Column("owner_id", sa.String(128), nullable=False), sa.Column("name", sa.String(512), nullable=False), sa.Column("preview_id", sa.String(36), sa.ForeignKey("asset_references.id", ondelete="SET NULL")), sa.Column("user_metadata", sa.JSON()), sa.Column("system_metadata", sa.JSON()), sa.Column("job_id", sa.String(36)), sa.Column("created_at", sa.DateTime(), nullable=False), sa.Column("updated_at", sa.DateTime(), nullable=False), sa.Column("last_access_time", sa.DateTime(), nullable=False), sa.Column("deleted_at", sa.DateTime())) + op.create_index("uq_asset_references_file_path", "asset_references", ["file_path"], unique=True) + op.create_index("ix_asset_references_asset_id", "asset_references", ["asset_id"]) + op.create_index("ix_asset_references_owner_id", "asset_references", ["owner_id"]) + op.create_index("ix_asset_references_name", "asset_references", ["name"]) + op.create_index("ix_asset_references_is_missing", "asset_references", ["is_missing"]) + op.create_index("ix_asset_references_enrichment_level", "asset_references", ["enrichment_level"]) + op.create_index("ix_asset_references_created_at", "asset_references", ["created_at"]) + op.create_index("ix_asset_references_last_access_time", "asset_references", ["last_access_time"]) + op.create_index("ix_asset_references_owner_name", "asset_references", ["owner_id", "name"]) + op.create_index("ix_asset_references_deleted_at", "asset_references", ["deleted_at"]) + op.create_index("ix_asset_references_preview_id", "asset_references", ["preview_id"]) + op.create_table("asset_reference_meta", sa.Column("asset_reference_id", sa.String(36), sa.ForeignKey("asset_references.id", ondelete="CASCADE"), primary_key=True), sa.Column("key", sa.String(256), primary_key=True), sa.Column("ordinal", sa.Integer(), primary_key=True), sa.Column("val_str", sa.String(2048)), sa.Column("val_num", sa.Numeric(38, 10)), sa.Column("val_bool", sa.Boolean()), sa.Column("val_json", sa.JSON()), sa.CheckConstraint("val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL", name="ck_asset_reference_meta_has_value")) + op.create_index("ix_asset_reference_meta_key", "asset_reference_meta", ["key"]) + op.create_index("ix_asset_reference_meta_key_val_str", "asset_reference_meta", ["key", "val_str"]) + op.create_index("ix_asset_reference_meta_key_val_num", "asset_reference_meta", ["key", "val_num"]) + op.create_index("ix_asset_reference_meta_key_val_bool", "asset_reference_meta", ["key", "val_bool"]) + op.create_table("asset_reference_tags", sa.Column("asset_reference_id", sa.String(36), sa.ForeignKey("asset_references.id", ondelete="CASCADE"), primary_key=True), sa.Column("tag_name", sa.String(512), sa.ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True), sa.Column("origin", sa.String(32), nullable=False), sa.Column("added_at", sa.DateTime(), nullable=False)) + op.create_index("ix_asset_reference_tags_tag_name", "asset_reference_tags", ["tag_name"]) + op.create_index("ix_asset_reference_tags_asset_reference_id", "asset_reference_tags", ["asset_reference_id"]) diff --git a/app/assets/api/routes.py b/app/assets/api/routes.py index 62fb9702557..f5ed98be5d8 100644 --- a/app/assets/api/routes.py +++ b/app/assets/api/routes.py @@ -1,3 +1,11 @@ +"""Serves the asset HTTP API: listing, detail, upload, tagging, hash-addressed +creation and scan control. A feature gate wraps every handler and answers 503 +when the asset system is switched off or its database dependencies are absent, +and failures leave as a JSON envelope carrying a stable machine-readable code +rather than aiohttp's default text. Handlers own request parsing and error +shaping only; the work itself belongs to the services layer. +""" + import asyncio import functools import json @@ -6,6 +14,7 @@ import os import urllib.parse import uuid +from datetime import timezone from typing import Any from aiohttp import web @@ -13,6 +22,7 @@ import folder_paths from app import user_manager +from app.assets import mode from app.assets.api import schemas_in, schemas_out from app.assets.services import schemas from app.assets.api.schemas_in import ( @@ -24,30 +34,54 @@ delete_temp_file_if_exists, parse_multipart_upload, ) +from app.assets.database.models import Asset +from app.assets.database.queries.records import ( + RecordCursorBoundary, + RecordPageSpec, + RecordSortField, + RecordSortOrder, + get_preview_file_paths_by_ids, + list_records_page, +) from app.assets.seeder import ScanInProgressError, asset_seeder from app.assets.services import ( DependencyMissingError, HashMismatchError, + UploadUnstableError, apply_tags, asset_exists, create_from_hash, delete_asset_reference, get_asset_detail, get_preview_file_paths, - list_assets_page, list_tags, remove_tags, resolve_asset_for_download, update_asset_metadata, upload_from_temp_path, ) -from app.assets.services.cursor import InvalidCursorError from app.assets.services.path_utils import compute_asset_response_paths +from app.assets.services.cursor import ( + InvalidCursorError, + decode_cursor, + decode_cursor_int, + decode_cursor_time, + encode_cursor, + encode_cursor_from_time, +) from app.assets.services.tagging import list_tag_histogram +from app.database.db import create_session ROUTES = web.RouteTableDef() USER_MANAGER: user_manager.UserManager | None = None _ASSETS_ENABLED = False +SYSTEM_TAGS = frozenset({"missing"}) +_CURSOR_SORT_FIELDS: tuple[RecordSortField, ...] = ( + "created_at", + "updated_at", + "name", + "size", +) def _require_assets_feature_enabled(handler): @@ -99,12 +133,6 @@ def register_assets_routes( app.add_routes(ROUTES) -def disable_assets_routes() -> None: - """Disable asset routes at runtime (e.g. after DB init failure).""" - global _ASSETS_ENABLED - _ASSETS_ENABLED = False - - def _build_error_response( status: int, code: str, message: str, details: dict | None = None ) -> web.Response: @@ -119,6 +147,20 @@ def _build_validation_error_response(code: str, ve: ValidationError) -> web.Resp return _build_error_response(400, code, "Validation failed.", {"errors": errors}) +class SystemTagForbiddenError(Exception): + def __init__(self, tag: str): + super().__init__( + f"Tag '{tag}' is system-managed and cannot be modified via the API" + ) + self.tag = tag + + +def _reject_system_tags(tags: list[str]) -> None: + for tag in tags: + if tag in SYSTEM_TAGS: + raise SystemTagForbiddenError(tag) + + class InvalidTagFilterError(Exception): """Invalid combination of tag-filter query parameters.""" @@ -200,13 +242,22 @@ def _resolve_tag_filters( return all_list, tags_any, none_list -def _validate_sort_field(requested: str | None) -> str: +def _validate_sort_field(requested: str | None) -> RecordSortField: if not requested: return "created_at" - v = requested.lower() - if v in {"name", "created_at", "updated_at", "size", "last_access_time"}: - return v - return "created_at" + match requested.lower(): + case "name": + return "name" + case "created_at": + return "created_at" + case "updated_at": + return "updated_at" + case "size": + return "size" + case "last_access_time": + return "last_access_time" + case _: + return "created_at" # What a client can render from the bytes themselves; anything else needs a nominated preview. @@ -258,6 +309,8 @@ def _build_asset_response( if result.ref.preview_id: # A nominated preview is one whatever it holds, so no media check here. preview_url = _build_view_url(preview_paths.get(result.ref.preview_id)) + elif result.asset is not None and result.asset.is_missing: + preview_url = None elif _has_previewable_content(result.asset, result.ref.file_path): preview_url = _build_view_url(result.ref.file_path) else: @@ -268,7 +321,8 @@ def _build_asset_response( # In-root loader path (model category dropped): what model loaders consume. loader_path = result.ref.loader_path else: - display_name, loader_path = None, None + display_name = None + loader_path = None asset_content_hash = result.asset.hash if result.asset else None return schemas_out.Asset( id=result.ref.id, @@ -276,7 +330,6 @@ def _build_asset_response( hash=asset_content_hash, loader_path=loader_path, display_name=display_name, - asset_hash=asset_content_hash, size=int(result.asset.size_bytes) if result.asset else None, mime_type=result.asset.mime_type if result.asset else None, tags=result.tags, @@ -292,6 +345,111 @@ def _build_asset_response( ) +def _build_record_response( + record: Asset, + tags: list[str], + preview_paths: dict[str, str], +) -> schemas_out.Asset: + content = record.content + paths = compute_asset_response_paths(content.path) + display_name = paths[1] if paths else None + if record.preview_id: + preview_url = _build_view_url(preview_paths.get(record.preview_id)) + elif content.is_missing: + preview_url = None + else: + mime_type = record.mime_type or mimetypes.guess_type(content.path)[0] or "" + if mime_type.split(";", 1)[0].strip().lower().startswith( + PREVIEWABLE_MIME_PREFIXES + ): + preview_url = _build_view_url(content.path) + else: + preview_url = None + + return schemas_out.Asset( + id=record.id, + name=record.name, + hash=content.hash, + loader_path=record.loader_path, + display_name=display_name, + size=content.size_bytes, + mime_type=record.mime_type, + tags=tags, + preview_url=preview_url, + preview_id=record.preview_id, + user_metadata=record.user_metadata or {}, + metadata=record.system_metadata, + job_id=record.job_id, + prompt_id=record.job_id, + created_at=record.created_at, + updated_at=record.updated_at, + last_access_time=record.last_access_time, + ) + + +def _decode_record_cursor( + after: str | None, + sort: RecordSortField, + order: RecordSortOrder, +) -> RecordCursorBoundary | None: + if after is None: + return None + if sort not in _CURSOR_SORT_FIELDS: + raise InvalidCursorError( + f"cursor pagination is not supported for sort={sort!r}" + ) + payload = decode_cursor( + after, + _CURSOR_SORT_FIELDS, + expected_order=order, + ) + if payload.sort_field != sort: + raise InvalidCursorError( + f"cursor sort field {payload.sort_field!r} does not match request sort {sort!r}" + ) + match payload.sort_field: + case "created_at" | "updated_at": + value = decode_cursor_time(payload).replace(tzinfo=None) + case "size": + value = decode_cursor_int(payload) + case "name": + value = payload.value + case unsupported: + raise InvalidCursorError(f"unsupported sort field {unsupported!r}") + return RecordCursorBoundary(value=value, id=payload.id) + + +def _encode_record_cursor( + record: Asset, + sort: RecordSortField, + order: RecordSortOrder, +) -> str: + match sort: + case "name": + return encode_cursor("name", record.name, record.id, order=order) + case "size": + return encode_cursor( + "size", + str(record.content.size_bytes), + record.id, + order=order, + ) + case "created_at": + timestamp = record.created_at + case "updated_at": + timestamp = record.updated_at + case "last_access_time": + raise InvalidCursorError( + "cursor pagination is not supported for sort='last_access_time'" + ) + return encode_cursor_from_time( + sort, + timestamp.replace(tzinfo=timezone.utc), + record.id, + order=order, + ) + + @ROUTES.head("/api/assets/hash/{hash}") @_require_assets_feature_enabled async def head_asset_by_hash(request: web.Request) -> web.Response: @@ -312,6 +470,11 @@ async def list_assets_route(request: web.Request) -> web.Response: """ GET request to list assets. """ + if "metadata_filter" in request.query: + return _build_error_response( + 400, "UNSUPPORTED_PARAM", "metadata_filter is no longer supported" + ) + query_dict = get_query_dict(request) try: q = schemas_in.ListAssetsQuery.model_validate(query_dict) @@ -324,42 +487,57 @@ async def list_assets_route(request: web.Request) -> web.Response: return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details) sort = _validate_sort_field(q.sort) - order_candidate = (q.order or "desc").lower() - order = order_candidate if order_candidate in {"asc", "desc"} else "desc" + order = q.order try: - result = list_assets_page( - owner_id=USER_MANAGER.get_request_user_id(request), - include_tags=tags_all, - exclude_tags=tags_none, - any_tags=tags_any, - name_contains=q.name_contains, - metadata_filter=q.metadata_filter, - limit=q.limit, - offset=q.offset, - sort=sort, - order=order, - after=q.after, - ) + cursor_boundary = _decode_record_cursor(q.after, sort, order) + cursor_supported = sort in _CURSOR_SORT_FIELDS + fetch_limit = q.limit + 1 if cursor_supported else q.limit + with create_session() as session: + records, tag_map, total = list_records_page( + session, + RecordPageSpec( + all_tags=tuple(tags_all), + any_tags=tuple(tags_any), + none_tags=tuple(tags_none), + name_contains=q.name_contains, + limit=fetch_limit, + offset=q.offset, + sort=sort, + order=order, + after=cursor_boundary, + ), + ) + next_cursor = None + if cursor_supported and len(records) > q.limit: + records = records[: q.limit] + next_cursor = _encode_record_cursor(records[-1], sort, order) + + preview_ids = sorted( + {record.preview_id for record in records if record.preview_id} + ) + preview_paths = get_preview_file_paths_by_ids(session, preview_ids) + summaries = [ + _build_record_response( + record, + tag_map.get(record.id, []), + preview_paths, + ) + for record in records + ] except InvalidCursorError as e: return _build_error_response(400, "INVALID_CURSOR", str(e)) - preview_paths = _resolve_preview_paths(result.items) - summaries = [_build_asset_response(item, preview_paths) for item in result.items] - - # has_more semantics differ by mode: - # - cursor mode: a non-empty next_cursor means there are more results. - # - offset mode: derived from total - (offset + page size). if q.after is not None: - has_more = result.next_cursor is not None + has_more = next_cursor is not None else: - has_more = (q.offset + len(summaries)) < result.total + has_more = q.offset + len(summaries) < total payload = schemas_out.AssetsList( assets=summaries, - total=result.total, + total=total, has_more=has_more, - next_cursor=result.next_cursor, + next_cursor=next_cursor, ) return web.json_response(payload.model_dump(mode="json", exclude_none=True)) @@ -374,7 +552,6 @@ async def get_asset_route(request: web.Request) -> web.Response: try: result = get_asset_detail( reference_id=reference_id, - owner_id=USER_MANAGER.get_request_user_id(request), ) if not result: return _build_error_response( @@ -391,7 +568,7 @@ async def get_asset_route(request: web.Request) -> web.Response: ) except Exception: logging.exception( - "get_asset failed for reference_id=%s, owner_id=%s", + "get_asset failed for reference_id=%s, tenant_id=%s", reference_id, USER_MANAGER.get_request_user_id(request), ) @@ -409,7 +586,6 @@ async def download_asset_content(request: web.Request) -> web.Response: try: result = resolve_asset_for_download( reference_id=str(uuid.UUID(request.match_info["id"])), - owner_id=USER_MANAGER.get_request_user_id(request), ) abs_path = result.abs_path content_type = result.content_type @@ -501,15 +677,22 @@ async def create_asset_from_hash_route(request: web.Request) -> web.Response: if name is None: name = body.hash.split(":", 1)[1] if ":" in body.hash else body.hash - result = create_from_hash( - hash_str=body.hash, - name=name, - tags=body.tags, - user_metadata=body.user_metadata, - owner_id=USER_MANAGER.get_request_user_id(request), - mime_type=body.mime_type, - preview_id=body.preview_id, - ) + if not mode.hashing_enabled(): + return _build_error_response( + 400, "FEATURE_DISABLED", "Asset hashing is disabled." + ) + + try: + result = create_from_hash( + hash_str=body.hash, + name=name, + tags=body.tags, + user_metadata=body.user_metadata, + mime_type=body.mime_type, + preview_id=body.preview_id, + ) + except ValueError as e: + return _build_error_response(400, "INVALID_BODY", str(e)) if result is None: return _build_error_response( 404, "ASSET_NOT_FOUND", f"Asset content {body.hash} does not exist" @@ -532,7 +715,7 @@ async def upload_asset(request: web.Request) -> web.Response: except UploadError as e: return _build_error_response(e.status, e.code, e.message) - owner_id = USER_MANAGER.get_request_user_id(request) + tenant_id = USER_MANAGER.get_request_user_id(request) try: spec = schemas_in.UploadAssetSpec.model_validate( @@ -552,43 +735,40 @@ async def upload_asset(request: web.Request) -> web.Response: ) try: - # Fast path: hash exists, create AssetReference without writing anything - if spec.hash and parsed.provided_hash_exists is True: + if not parsed.file_present and spec.hash: + if not mode.hashing_enabled(): + return _build_error_response( + 400, "FEATURE_DISABLED", "Asset hashing is disabled." + ) result = create_from_hash( hash_str=spec.hash, name=spec.name or (spec.hash.split(":", 1)[1]), tags=spec.tags, user_metadata=spec.user_metadata or {}, - owner_id=owner_id, mime_type=spec.mime_type, preview_id=spec.preview_id, ) if result is None: - delete_temp_file_if_exists(parsed.tmp_path) return _build_error_response( 404, "ASSET_NOT_FOUND", f"Asset content {spec.hash} does not exist" ) - delete_temp_file_if_exists(parsed.tmp_path) - else: - # Otherwise, we must have a temp file path to ingest - if not parsed.tmp_path or not os.path.exists(parsed.tmp_path): - return _build_error_response( - 400, - "MISSING_INPUT", - "Provided hash not found and no file uploaded.", - ) - + elif parsed.tmp_path and os.path.exists(parsed.tmp_path): result = upload_from_temp_path( temp_path=parsed.tmp_path, name=spec.name, tags=spec.tags, user_metadata=spec.user_metadata or {}, client_filename=parsed.file_client_name, - owner_id=owner_id, expected_hash=spec.hash, mime_type=spec.mime_type, preview_id=spec.preview_id, ) + else: + return _build_error_response( + 400, + "MISSING_INPUT", + "Provided hash not found and no file uploaded.", + ) except AssetValidationError as e: delete_temp_file_if_exists(parsed.tmp_path) return _build_error_response(400, e.code, str(e)) @@ -598,12 +778,15 @@ async def upload_asset(request: web.Request) -> web.Response: except HashMismatchError as e: delete_temp_file_if_exists(parsed.tmp_path) return _build_error_response(400, "HASH_MISMATCH", str(e)) + except UploadUnstableError as e: + delete_temp_file_if_exists(parsed.tmp_path) + return _build_error_response(500, "UPLOAD_UNSTABLE", str(e)) except DependencyMissingError as e: delete_temp_file_if_exists(parsed.tmp_path) return _build_error_response(503, "DEPENDENCY_MISSING", e.message) except Exception: delete_temp_file_if_exists(parsed.tmp_path) - logging.exception("upload_asset failed for owner_id=%s", owner_id) + logging.exception("upload_asset failed for tenant_id=%s", tenant_id) return _build_error_response(500, "INTERNAL", "Unexpected server error.") asset = _build_asset_response(result, _resolve_preview_paths([result])) @@ -633,7 +816,6 @@ async def update_asset_route(request: web.Request) -> web.Response: reference_id=reference_id, name=body.name, user_metadata=body.user_metadata, - owner_id=USER_MANAGER.get_request_user_id(request), preview_id=body.preview_id, ) payload = _build_asset_response(result, _resolve_preview_paths([result])) @@ -645,7 +827,7 @@ async def update_asset_route(request: web.Request) -> web.Response: ) except Exception: logging.exception( - "update_asset failed for reference_id=%s, owner_id=%s", + "update_asset failed for reference_id=%s, tenant_id=%s", reference_id, USER_MANAGER.get_request_user_id(request), ) @@ -659,16 +841,12 @@ async def delete_asset_route(request: web.Request) -> web.Response: reference_id = str(uuid.UUID(request.match_info["id"])) try: - # Deleting an asset is a soft delete of the reference; the underlying - # content is preserved (it may be shared with other references). deleted = delete_asset_reference( reference_id=reference_id, - owner_id=USER_MANAGER.get_request_user_id(request), - delete_content_if_orphan=False, ) except Exception: logging.exception( - "delete_asset_reference failed for reference_id=%s, owner_id=%s", + "delete_asset_reference failed for reference_id=%s, tenant_id=%s", reference_id, USER_MANAGER.get_request_user_id(request), ) @@ -705,7 +883,6 @@ async def get_tags(request: web.Request) -> web.Response: offset=query.offset, order=query.order, include_zero=query.include_zero, - owner_id=USER_MANAGER.get_request_user_id(request), ) tags = [ @@ -738,17 +915,21 @@ async def add_asset_tags(request: web.Request) -> web.Response: ) try: + _reject_system_tags(data.tags) result = apply_tags( reference_id=reference_id, tags=data.tags, origin="manual", - owner_id=USER_MANAGER.get_request_user_id(request), ) payload = schemas_out.TagsAdd( added=result.added, already_present=result.already_present, total_tags=result.total_tags, ) + except SystemTagForbiddenError as se: + return _build_error_response( + 400, "SYSTEM_TAG_FORBIDDEN", str(se), {"tag": se.tag} + ) except PermissionError as pe: return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id}) except ValueError as ve: @@ -757,7 +938,7 @@ async def add_asset_tags(request: web.Request) -> web.Response: ) except Exception: logging.exception( - "add_tags_to_asset failed for reference_id=%s, owner_id=%s", + "add_tags_to_asset failed for reference_id=%s, tenant_id=%s", reference_id, USER_MANAGER.get_request_user_id(request), ) @@ -786,15 +967,20 @@ async def delete_asset_tags(request: web.Request) -> web.Response: ) try: + _reject_system_tags(data.tags) result = remove_tags( reference_id=reference_id, tags=data.tags, - owner_id=USER_MANAGER.get_request_user_id(request), ) payload = schemas_out.TagsRemove( removed=result.removed, not_present=result.not_present, total_tags=result.total_tags, + protected=result.protected, + ) + except SystemTagForbiddenError as se: + return _build_error_response( + 400, "SYSTEM_TAG_FORBIDDEN", str(se), {"tag": se.tag} ) except PermissionError as pe: return _build_error_response(403, "FORBIDDEN", str(pe), {"id": reference_id}) @@ -804,7 +990,7 @@ async def delete_asset_tags(request: web.Request) -> web.Response: ) except Exception: logging.exception( - "remove_tags_from_asset failed for reference_id=%s, owner_id=%s", + "remove_tags_from_asset failed for reference_id=%s, tenant_id=%s", reference_id, USER_MANAGER.get_request_user_id(request), ) @@ -817,6 +1003,11 @@ async def delete_asset_tags(request: web.Request) -> web.Response: @_require_assets_feature_enabled async def get_tags_refine(request: web.Request) -> web.Response: """GET request to get tag histogram for filtered assets.""" + if "metadata_filter" in request.query: + return _build_error_response( + 400, "UNSUPPORTED_PARAM", "metadata_filter is no longer supported" + ) + query_dict = get_query_dict(request) try: q = schemas_in.TagsRefineQuery.model_validate(query_dict) @@ -829,12 +1020,10 @@ async def get_tags_refine(request: web.Request) -> web.Response: return _build_error_response(400, "INVALID_TAG_FILTER", str(e), e.details) tag_counts = list_tag_histogram( - owner_id=USER_MANAGER.get_request_user_id(request), include_tags=tags_all, exclude_tags=tags_none, any_tags=tags_any, name_contains=q.name_contains, - metadata_filter=q.metadata_filter, limit=q.limit, ) payload = schemas_out.TagHistogram(tag_counts=tag_counts) @@ -867,7 +1056,9 @@ async def seed_assets(request: web.Request) -> web.Response: wait_param = request.query.get("wait", "").lower() should_wait = wait_param in ("true", "1", "yes") - started = asset_seeder.start(roots=valid_roots) + started = asset_seeder.start( + roots=valid_roots, compute_hashes=mode.hashing_enabled() + ) if not started: return web.json_response({"status": "already_running"}, status=409) diff --git a/app/assets/api/schemas_in.py b/app/assets/api/schemas_in.py index 862700a24fd..ccf51d7dad7 100644 --- a/app/assets/api/schemas_in.py +++ b/app/assets/api/schemas_in.py @@ -1,3 +1,10 @@ +"""Parses and validates everything the asset API accepts from a client, so +handlers receive typed values instead of raw JSON. Query strings, JSON bodies +and multipart upload specs each get a model that rejects malformed input at the +boundary, normalizes tags and hashes, and raises errors already carrying the +HTTP status and code the handler should return. +""" + import json from dataclasses import dataclass from typing import Any, Literal @@ -58,9 +65,6 @@ class ListAssetsQuery(BaseModel): tags_none: list[str] = Field(default_factory=list) name_contains: str | None = None - # Accept either a JSON string (query param) or a dict - metadata_filter: dict[str, Any] | None = None - limit: conint(ge=1, le=500) = 20 offset: conint(ge=0) = 0 # Opaque keyset cursor. When supplied, `offset` is ignored. Cursor pagination @@ -93,22 +97,6 @@ def _split_csv_tags(cls, v): return out return v - @field_validator("metadata_filter", mode="before") - @classmethod - def _parse_metadata_json(cls, v): - if v is None or isinstance(v, dict): - return v - if isinstance(v, str) and v.strip(): - try: - parsed = json.loads(v) - except Exception as e: - raise ValueError(f"metadata_filter must be JSON: {e}") from e - if not isinstance(parsed, dict): - raise ValueError("metadata_filter must be a JSON object") - return parsed - return None - - class UpdateAssetBody(BaseModel): name: str | None = None user_metadata: dict[str, Any] | None = None @@ -168,7 +156,6 @@ class TagsRefineQuery(BaseModel): tags_any: list[str] = Field(default_factory=list) tags_none: list[str] = Field(default_factory=list) name_contains: str | None = None - metadata_filter: dict[str, Any] | None = None limit: conint(ge=1, le=1000) = 100 @field_validator( @@ -189,22 +176,6 @@ def _split_csv_tags(cls, v): return out return v - @field_validator("metadata_filter", mode="before") - @classmethod - def _parse_metadata_json(cls, v): - if v is None or isinstance(v, dict): - return v - if isinstance(v, str) and v.strip(): - try: - parsed = json.loads(v) - except Exception as e: - raise ValueError(f"metadata_filter must be JSON: {e}") from e - if not isinstance(parsed, dict): - raise ValueError("metadata_filter must be a JSON object") - return parsed - return None - - class TagsListQuery(BaseModel): model_config = ConfigDict(extra="ignore", str_strip_whitespace=True) diff --git a/app/assets/api/schemas_out.py b/app/assets/api/schemas_out.py index da8251499f7..f9b8a09383e 100644 --- a/app/assets/api/schemas_out.py +++ b/app/assets/api/schemas_out.py @@ -1,3 +1,9 @@ +"""Defines the response bodies the asset API returns, fixing the field names, +types and serialization its HTTP contract promises. Routes dump these models +with null fields omitted, so whether a field is absent and whether it is empty +stay distinguishable to a client. +""" + from datetime import datetime from typing import Any @@ -5,14 +11,10 @@ class Asset(BaseModel): - """API view of an asset. Maps to DB ``AssetReference`` joined with its ``Asset`` blob; - ``id`` here is the AssetReference id, not the content-addressed Asset id.""" - id: str name: str = Field( ..., - deprecated=True, - description="Reference label, often caller-provided or derived from the filename. Deprecated for storage path/display semantics; use `loader_path` and `display_name` when present.", + description="Record label, usually derived from the source filename.", ) hash: str | None = None loader_path: str | None = Field( @@ -23,14 +25,13 @@ class Asset(BaseModel): default=None, description="Human-facing label for the asset. Not unique.", ) - asset_hash: str | None = None + is_immutable: bool = False size: int | None = None mime_type: str | None = None tags: list[str] = Field(default_factory=list) preview_url: str | None = None preview_id: str | None = None # references an asset_reference id, not an asset id user_metadata: dict[str, Any] = Field(default_factory=dict) - is_immutable: bool = False metadata: dict[str, Any] | None = None job_id: str | None = None prompt_id: str | None = None # deprecated: use job_id @@ -80,6 +81,7 @@ class TagsRemove(BaseModel): removed: list[str] = Field(default_factory=list) not_present: list[str] = Field(default_factory=list) total_tags: list[str] = Field(default_factory=list) + protected: list[str] = Field(default_factory=list) class TagHistogram(BaseModel): diff --git a/app/assets/api/upload.py b/app/assets/api/upload.py index 2979f0e206d..d46ad6389fb 100644 --- a/app/assets/api/upload.py +++ b/app/assets/api/upload.py @@ -1,3 +1,11 @@ +"""Reads a multipart upload off the wire and lands its bytes in a temporary file +the ingest service can hash and move. The body is consumed in chunks so a large +model never has to fit in memory, and the temporary file is removed on every +failure path so an abandoned upload leaves nothing behind. Field values are +validated as they arrive, letting a bad request fail before its bytes are +written. +""" + import logging import os import uuid @@ -91,20 +99,6 @@ async def parse_multipart_upload( file_present = True file_client_name = (field.filename or "").strip() - if provided_hash and provided_hash_exists is True: - # Hash exists - drain file but don't write to disk - try: - while True: - chunk = await field.read_chunk(8 * 1024 * 1024) - if not chunk: - break - file_written += len(chunk) - except Exception: - raise UploadError( - 500, "UPLOAD_IO_ERROR", "Failed to receive uploaded file." - ) - continue - uploads_root = os.path.join(folder_paths.get_temp_directory(), "uploads") unique_dir = os.path.join(uploads_root, uuid.uuid4().hex) os.makedirs(unique_dir, exist_ok=True) diff --git a/app/assets/database/models.py b/app/assets/database/models.py index 329cd483dea..bb0b8c35568 100644 --- a/app/assets/database/models.py +++ b/app/assets/database/models.py @@ -1,7 +1,16 @@ +"""Declares the asset schema: content rows describing bytes on disk, records +describing what a user sees, and the tag and metadata tables hanging off them. +The split is the point — many records can name one content row, and retiring +content by marking it missing rather than deleting it is what keeps a path's +history intact. Constraints declared here, not application code, are what make +a negative size or a second live row at one path impossible. +""" + from __future__ import annotations import uuid from datetime import datetime +from decimal import Decimal from typing import Any from sqlalchemy import ( @@ -16,197 +25,134 @@ Numeric, String, Text, + text, ) -from sqlalchemy.orm import Mapped, foreign, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship from app.assets.helpers import get_utc_now from app.database.models import Base -class Asset(Base): - __tablename__ = "assets" +class AssetContent(Base): + __tablename__ = "asset_contents" id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()) ) - hash: Mapped[str | None] = mapped_column(String(256), nullable=True) - size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0) - mime_type: Mapped[str | None] = mapped_column(String(255)) + hash: Mapped[str | None] = mapped_column(String(256), index=True) + size_bytes: Mapped[int] = mapped_column( + BigInteger, + CheckConstraint("size_bytes >= 0", name="ck_asset_contents_size_nonneg"), + nullable=False, + default=0, + ) + path: Mapped[str] = mapped_column(Text, nullable=False) + mtime_ns: Mapped[int | None] = mapped_column( + BigInteger, + CheckConstraint("mtime_ns >= 0", name="ck_asset_contents_mtime_nonneg"), + ) + is_missing: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="0" + ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=False), nullable=False, default=get_utc_now ) - references: Mapped[list[AssetReference]] = relationship( - "AssetReference", - back_populates="asset", - primaryjoin=lambda: Asset.id == foreign(AssetReference.asset_id), - foreign_keys=lambda: [AssetReference.asset_id], - cascade="all,delete-orphan", - passive_deletes=True, - ) - - # preview_id on AssetReference is a self-referential FK to asset_references.id + records: Mapped[list[Asset]] = relationship(back_populates="content") __table_args__ = ( - Index("uq_assets_hash", "hash", unique=True), - Index("ix_assets_mime_type", "mime_type"), - CheckConstraint("size_bytes >= 0", name="ck_assets_size_nonneg"), + Index( + "uq_asset_contents_path_live", + "path", + unique=True, + sqlite_where=text("is_missing = 0"), + ), ) - def __repr__(self) -> str: - return f"" - - -class AssetReference(Base): - """Unified model combining file cache state and user-facing metadata. - Each row represents either: - - A filesystem reference (file_path is set) with cache state - - An API-created reference (file_path is NULL) without cache state - """ - - __tablename__ = "asset_references" +class Asset(Base): + __tablename__ = "assets" id: Mapped[str] = mapped_column( String(36), primary_key=True, default=lambda: str(uuid.uuid4()) ) - asset_id: Mapped[str] = mapped_column( - String(36), ForeignKey("assets.id", ondelete="CASCADE"), nullable=False + content_id: Mapped[str] = mapped_column( + String(36), + ForeignKey("asset_contents.id", ondelete="RESTRICT"), + nullable=False, ) - - # Cache state fields (from former AssetCacheState) - file_path: Mapped[str | None] = mapped_column(Text, nullable=True) - # In-root loader path derived from file_path at scan/ingest time. - loader_path: Mapped[str | None] = mapped_column(Text, nullable=True) - mtime_ns: Mapped[int | None] = mapped_column(BigInteger, nullable=True) - needs_verify: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - is_missing: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - enrichment_level: Mapped[int] = mapped_column(Integer, nullable=False, default=0) - - # Info fields (from former AssetInfo) - owner_id: Mapped[str] = mapped_column(String(128), nullable=False, default="") name: Mapped[str] = mapped_column(String(512), nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(255)) + system_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True)) + job_id: Mapped[str | None] = mapped_column(String(36)) + user_metadata: Mapped[dict[str, Any] | None] = mapped_column(JSON) + loader_path: Mapped[str | None] = mapped_column(Text) preview_id: Mapped[str | None] = mapped_column( - String(36), ForeignKey("asset_references.id", ondelete="SET NULL") - ) - user_metadata: Mapped[dict[str, Any] | None] = mapped_column( - JSON(none_as_null=True) + String(36), ForeignKey("assets.id", ondelete="SET NULL") ) - system_metadata: Mapped[dict[str, Any] | None] = mapped_column( - JSON(none_as_null=True), nullable=True, default=None - ) - job_id: Mapped[str | None] = mapped_column(String(36), nullable=True, default=None) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=False), nullable=False, default=get_utc_now ) + # Explicit user/API edit time; never use onupdate because row writes are not always edits. updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=False), nullable=False, default=get_utc_now ) - last_access_time: Mapped[datetime] = mapped_column( - DateTime(timezone=False), nullable=False, default=get_utc_now - ) - deleted_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=False), nullable=True, default=None - ) + last_access_time: Mapped[datetime | None] = mapped_column(DateTime(timezone=False)) - asset: Mapped[Asset] = relationship( - "Asset", - back_populates="references", - foreign_keys=[asset_id], - lazy="selectin", - ) - preview_ref: Mapped[AssetReference | None] = relationship( - "AssetReference", - foreign_keys=[preview_id], - remote_side=lambda: [AssetReference.id], + content: Mapped[AssetContent] = relationship(back_populates="records", lazy="selectin") + preview: Mapped[Asset | None] = relationship( + "Asset", foreign_keys=[preview_id], remote_side=lambda: [Asset.id] ) - - metadata_entries: Mapped[list[AssetReferenceMeta]] = relationship( - back_populates="asset_reference", - cascade="all,delete-orphan", - passive_deletes=True, + metadata_entries: Mapped[list[AssetMeta]] = relationship( + back_populates="asset", cascade="all,delete-orphan", passive_deletes=True ) - - tag_links: Mapped[list[AssetReferenceTag]] = relationship( - back_populates="asset_reference", - cascade="all,delete-orphan", - passive_deletes=True, - overlaps="tags,asset_references", + tag_links: Mapped[list[AssetTag]] = relationship( + back_populates="asset", cascade="all,delete-orphan", passive_deletes=True ) - tags: Mapped[list[Tag]] = relationship( - secondary="asset_reference_tags", - back_populates="asset_references", - lazy="selectin", - viewonly=True, - overlaps="tag_links,asset_reference_links,asset_references,tag", + secondary="asset_tags", back_populates="assets", viewonly=True, lazy="selectin" ) __table_args__ = ( - Index("uq_asset_references_file_path", "file_path", unique=True), - Index("ix_asset_references_asset_id", "asset_id"), - Index("ix_asset_references_owner_id", "owner_id"), - Index("ix_asset_references_name", "name"), - Index("ix_asset_references_is_missing", "is_missing"), - Index("ix_asset_references_enrichment_level", "enrichment_level"), - Index("ix_asset_references_created_at", "created_at"), - Index("ix_asset_references_last_access_time", "last_access_time"), - Index("ix_asset_references_deleted_at", "deleted_at"), - Index("ix_asset_references_preview_id", "preview_id"), - Index("ix_asset_references_owner_name", "owner_id", "name"), - CheckConstraint( - "(mtime_ns IS NULL) OR (mtime_ns >= 0)", name="ck_ar_mtime_nonneg" - ), - CheckConstraint( - "enrichment_level >= 0 AND enrichment_level <= 2", - name="ck_ar_enrichment_level_range", - ), + Index("ix_assets_content_id", "content_id"), + Index("ix_assets_name", "name"), + Index("ix_assets_created_at", "created_at"), + Index("ix_assets_preview_id", "preview_id"), ) - def __repr__(self) -> str: - path_part = f" path={self.file_path!r}" if self.file_path else "" - return f"" - -class AssetReferenceMeta(Base): - __tablename__ = "asset_reference_meta" +class AssetMeta(Base): + __tablename__ = "asset_meta" - asset_reference_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("asset_references.id", ondelete="CASCADE"), - primary_key=True, + asset_id: Mapped[str] = mapped_column( + String(36), ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True ) key: Mapped[str] = mapped_column(String(256), primary_key=True) ordinal: Mapped[int] = mapped_column(Integer, primary_key=True, default=0) + val_str: Mapped[str | None] = mapped_column(String(2048)) + val_num: Mapped[Decimal | None] = mapped_column(Numeric(38, 10)) + val_bool: Mapped[bool | None] = mapped_column(Boolean) + val_json: Mapped[Any | None] = mapped_column(JSON) - val_str: Mapped[str | None] = mapped_column(String(2048), nullable=True) - val_num: Mapped[float | None] = mapped_column(Numeric(38, 10), nullable=True) - val_bool: Mapped[bool | None] = mapped_column(Boolean, nullable=True) - val_json: Mapped[Any | None] = mapped_column(JSON(none_as_null=True), nullable=True) - - asset_reference: Mapped[AssetReference] = relationship( - back_populates="metadata_entries" - ) + asset: Mapped[Asset] = relationship(back_populates="metadata_entries") __table_args__ = ( - Index("ix_asset_reference_meta_key", "key"), - Index("ix_asset_reference_meta_key_val_str", "key", "val_str"), - Index("ix_asset_reference_meta_key_val_num", "key", "val_num"), - Index("ix_asset_reference_meta_key_val_bool", "key", "val_bool"), + Index("ix_asset_meta_key", "key"), + Index("ix_asset_meta_key_val_str", "key", "val_str"), + Index("ix_asset_meta_key_val_num", "key", "val_num"), + Index("ix_asset_meta_key_val_bool", "key", "val_bool"), CheckConstraint( "val_str IS NOT NULL OR val_num IS NOT NULL OR val_bool IS NOT NULL OR val_json IS NOT NULL", - name="has_value", + name="ck_asset_meta_has_value", ), ) -class AssetReferenceTag(Base): - __tablename__ = "asset_reference_tags" +class AssetTag(Base): + __tablename__ = "asset_tags" - asset_reference_id: Mapped[str] = mapped_column( - String(36), - ForeignKey("asset_references.id", ondelete="CASCADE"), - primary_key=True, + asset_id: Mapped[str] = mapped_column( + String(36), ForeignKey("assets.id", ondelete="CASCADE"), primary_key=True ) tag_name: Mapped[str] = mapped_column( String(512), ForeignKey("tags.name", ondelete="RESTRICT"), primary_key=True @@ -216,12 +162,12 @@ class AssetReferenceTag(Base): DateTime(timezone=False), nullable=False, default=get_utc_now ) - asset_reference: Mapped[AssetReference] = relationship(back_populates="tag_links") - tag: Mapped[Tag] = relationship(back_populates="asset_reference_links") + asset: Mapped[Asset] = relationship(back_populates="tag_links") + tag: Mapped[Tag] = relationship(back_populates="asset_links") __table_args__ = ( - Index("ix_asset_reference_tags_tag_name", "tag_name"), - Index("ix_asset_reference_tags_asset_reference_id", "asset_reference_id"), + Index("ix_asset_tags_tag_name", "tag_name"), + Index("ix_asset_tags_asset_id", "asset_id"), ) @@ -229,17 +175,14 @@ class Tag(Base): __tablename__ = "tags" name: Mapped[str] = mapped_column(String(512), primary_key=True) - - asset_reference_links: Mapped[list[AssetReferenceTag]] = relationship( - back_populates="tag", - overlaps="asset_references,tags", - ) - asset_references: Mapped[list[AssetReference]] = relationship( - secondary="asset_reference_tags", - back_populates="tags", - viewonly=True, - overlaps="asset_reference_links,tag_links,tags,asset_reference", + asset_links: Mapped[list[AssetTag]] = relationship(back_populates="tag") + assets: Mapped[list[Asset]] = relationship( + secondary="asset_tags", back_populates="tags", viewonly=True ) - def __repr__(self) -> str: - return f"" + +class AssetSystemState(Base): + __tablename__ = "asset_system_state" + + key: Mapped[str] = mapped_column(String(256), primary_key=True) + value: Mapped[str] = mapped_column(Text, nullable=False) diff --git a/app/assets/database/queries/__init__.py b/app/assets/database/queries/__init__.py index 38dd82d8196..8707c229b6f 100644 --- a/app/assets/database/queries/__init__.py +++ b/app/assets/database/queries/__init__.py @@ -1,139 +1,44 @@ -from app.assets.database.queries.asset import ( - asset_exists_by_hash, - bulk_insert_assets, - create_stub_asset, - get_asset_by_hash, - get_existing_asset_ids, - reassign_asset_references, - update_asset_hash_and_mime, - upsert_asset, -) -from app.assets.database.queries.asset_reference import ( - CacheStateRow, - UnenrichedReferenceRow, - bulk_insert_references_ignore_conflicts, - bulk_update_enrichment_level, - count_active_siblings, - bulk_update_is_missing, - bulk_update_needs_verify, - convert_metadata_to_rows, - delete_assets_by_ids, - delete_orphaned_seed_asset, - delete_reference_by_id, - delete_references_by_ids, - fetch_reference_and_asset, - fetch_reference_asset_and_tags, - get_or_create_reference, - get_reference_by_file_path, - get_reference_by_id, - get_reference_with_owner_check, - get_reference_ids_by_ids, - get_reference_paths_by_ids, - get_references_by_paths_and_asset_ids, - get_references_for_prefixes, - get_unenriched_references, - get_unreferenced_unhashed_asset_ids, - insert_reference, - list_all_file_paths_by_asset_id, - list_references_by_asset_id, - list_references_page, - mark_references_missing_outside_prefixes, - rebuild_metadata_projection, - reference_exists, - reference_exists_for_asset_id, - restore_references_by_paths, - set_reference_metadata, - set_reference_preview, - set_reference_system_metadata, - soft_delete_reference_by_id, - update_reference_access_time, - update_reference_name, - update_is_missing_by_asset_id, - update_reference_timestamps, - update_reference_updated_at, - upsert_reference, -) -from app.assets.database.queries.tags import ( - AddTagsResult, - RemoveTagsResult, - SetTagsResult, - add_missing_tag_for_asset_id, - add_tags_to_reference, - bulk_insert_tags_and_meta, - ensure_tags_exist, - get_reference_tags, - list_tag_counts_for_filtered_assets, - list_tags_with_usage, - remove_missing_tag_for_asset_id, - remove_tags_from_reference, - set_reference_tags, - validate_tags_exist, +"""Re-exports the record and tag query functions so callers import them from one +place instead of reaching into individual query modules. A module-level +``__getattr__`` resolves names that live in the tag module, keeping this import +surface flat as queries are split across more files. +""" + +from importlib import import_module + +from app.assets.database.queries.records import ( + create_content, + create_content_reporting_insert, + create_record, + delete_record, + fetch_record_tags, + get_record_by_id, + list_records_page, + mark_content_missing, + rename_record, + unset_content_missing, + update_record_access_time, ) __all__ = [ - "AddTagsResult", - "CacheStateRow", - "RemoveTagsResult", - "SetTagsResult", - "UnenrichedReferenceRow", - "add_missing_tag_for_asset_id", - "add_tags_to_reference", - "asset_exists_by_hash", - "bulk_insert_assets", - "bulk_insert_references_ignore_conflicts", - "bulk_insert_tags_and_meta", - "bulk_update_enrichment_level", - "count_active_siblings", - "create_stub_asset", - "bulk_update_is_missing", - "bulk_update_needs_verify", - "convert_metadata_to_rows", - "delete_assets_by_ids", - "delete_orphaned_seed_asset", - "delete_reference_by_id", - "delete_references_by_ids", - "ensure_tags_exist", - "fetch_reference_and_asset", - "fetch_reference_asset_and_tags", - "get_asset_by_hash", - "get_existing_asset_ids", - "get_or_create_reference", - "get_reference_by_file_path", - "get_reference_by_id", - "get_reference_with_owner_check", - "get_reference_ids_by_ids", - "get_reference_paths_by_ids", - "get_reference_tags", - "get_references_by_paths_and_asset_ids", - "get_references_for_prefixes", - "get_unenriched_references", - "get_unreferenced_unhashed_asset_ids", - "insert_reference", - "list_all_file_paths_by_asset_id", - "list_references_by_asset_id", - "list_references_page", - "list_tag_counts_for_filtered_assets", - "list_tags_with_usage", - "mark_references_missing_outside_prefixes", - "reassign_asset_references", - "rebuild_metadata_projection", - "reference_exists", - "reference_exists_for_asset_id", - "remove_missing_tag_for_asset_id", - "remove_tags_from_reference", - "restore_references_by_paths", - "set_reference_metadata", - "set_reference_preview", - "set_reference_system_metadata", - "soft_delete_reference_by_id", - "set_reference_tags", - "update_asset_hash_and_mime", - "update_is_missing_by_asset_id", - "update_reference_access_time", - "update_reference_name", - "update_reference_timestamps", - "update_reference_updated_at", - "upsert_asset", - "upsert_reference", - "validate_tags_exist", + "create_content", + "create_content_reporting_insert", + "create_record", + "delete_record", + "fetch_record_tags", + "get_record_by_id", + "list_records_page", + "mark_content_missing", + "rename_record", + "unset_content_missing", + "update_record_access_time", ] + + +def __getattr__(name: str): + for module_name in ("tags",): + module = import_module(f"app.assets.database.queries.{module_name}") + candidate = getattr(module, name, None) + if candidate is not None: + return candidate + raise AttributeError(name) diff --git a/app/assets/database/queries/asset.py b/app/assets/database/queries/asset.py deleted file mode 100644 index cc716843186..00000000000 --- a/app/assets/database/queries/asset.py +++ /dev/null @@ -1,152 +0,0 @@ -import sqlalchemy as sa -from sqlalchemy import select -from sqlalchemy.dialects import sqlite -from sqlalchemy.orm import Session - -from app.assets.database.models import Asset, AssetReference -from app.assets.database.queries.common import MAX_BIND_PARAMS, calculate_rows_per_statement, iter_chunks - - -def asset_exists_by_hash( - session: Session, - asset_hash: str, -) -> bool: - """ - Check if an asset with a given hash exists in database. - """ - row = ( - session.execute( - select(sa.literal(True)) - .select_from(Asset) - .where(Asset.hash == asset_hash) - .limit(1) - ) - ).first() - return row is not None - - -def get_asset_by_hash( - session: Session, - asset_hash: str, -) -> Asset | None: - return ( - (session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1))) - .scalars() - .first() - ) - - -def upsert_asset( - session: Session, - asset_hash: str, - size_bytes: int, - mime_type: str | None = None, -) -> tuple[Asset, bool, bool]: - """Upsert an Asset by hash. Returns (asset, created, updated).""" - vals = {"hash": asset_hash, "size_bytes": int(size_bytes)} - if mime_type: - vals["mime_type"] = mime_type - - ins = ( - sqlite.insert(Asset) - .values(**vals) - .on_conflict_do_nothing(index_elements=[Asset.hash]) - ) - res = session.execute(ins) - created = int(res.rowcount or 0) > 0 - - asset = ( - session.execute(select(Asset).where(Asset.hash == asset_hash).limit(1)) - .scalars() - .first() - ) - if not asset: - raise RuntimeError("Asset row not found after upsert.") - - updated = False - if not created: - changed = False - if asset.size_bytes != int(size_bytes) and int(size_bytes) > 0: - asset.size_bytes = int(size_bytes) - changed = True - if mime_type and not asset.mime_type: - asset.mime_type = mime_type - changed = True - if changed: - updated = True - - return asset, created, updated - - -def create_stub_asset( - session: Session, - size_bytes: int, - mime_type: str | None = None, -) -> Asset: - """Create a new asset with no hash (stub for later enrichment).""" - asset = Asset(size_bytes=size_bytes, mime_type=mime_type, hash=None) - session.add(asset) - session.flush() - return asset - - -def bulk_insert_assets( - session: Session, - rows: list[dict], -) -> None: - """Bulk insert Asset rows with ON CONFLICT DO NOTHING on hash.""" - if not rows: - return - ins = sqlite.insert(Asset).on_conflict_do_nothing(index_elements=[Asset.hash]) - for chunk in iter_chunks(rows, calculate_rows_per_statement(5)): - session.execute(ins, chunk) - - -def get_existing_asset_ids( - session: Session, - asset_ids: list[str], -) -> set[str]: - """Return the subset of asset_ids that exist in the database.""" - if not asset_ids: - return set() - found: set[str] = set() - for chunk in iter_chunks(asset_ids, MAX_BIND_PARAMS): - rows = session.execute( - select(Asset.id).where(Asset.id.in_(chunk)) - ).fetchall() - found.update(row[0] for row in rows) - return found - - -def update_asset_hash_and_mime( - session: Session, - asset_id: str, - asset_hash: str | None = None, - mime_type: str | None = None, -) -> bool: - """Update asset hash and/or mime_type. Returns True if asset was found.""" - asset = session.get(Asset, asset_id) - if not asset: - return False - if asset_hash is not None: - asset.hash = asset_hash - if mime_type is not None and not asset.mime_type: - asset.mime_type = mime_type - return True - - -def reassign_asset_references( - session: Session, - from_asset_id: str, - to_asset_id: str, - reference_id: str, -) -> None: - """Reassign a reference from one asset to another. - - Used when merging a stub asset into an existing asset with the same hash. - """ - ref = session.get(AssetReference, reference_id) - if ref and ref.asset_id == from_asset_id: - ref.asset_id = to_asset_id - - session.flush() diff --git a/app/assets/database/queries/asset_reference.py b/app/assets/database/queries/asset_reference.py deleted file mode 100644 index 13220270b9e..00000000000 --- a/app/assets/database/queries/asset_reference.py +++ /dev/null @@ -1,1102 +0,0 @@ -"""Query functions for the unified AssetReference table. - -This module replaces the separate asset_info.py and cache_state.py query modules, -providing a unified interface for the merged asset_references table. -""" - -from collections import defaultdict -from datetime import datetime -from decimal import Decimal -from typing import NamedTuple, Sequence - -import sqlalchemy as sa -from sqlalchemy import delete, select -from sqlalchemy.dialects import sqlite -from sqlalchemy.exc import IntegrityError -from sqlalchemy.orm import Session, noload - -from app.assets.database.models import ( - Asset, - AssetReference, - AssetReferenceMeta, - AssetReferenceTag, - Tag, -) -from app.assets.database.queries.common import ( - MAX_BIND_PARAMS, - apply_metadata_filter, - apply_tag_filters, - build_prefix_like_conditions, - build_visible_owner_clause, - calculate_rows_per_statement, - iter_chunks, -) -from app.assets.helpers import escape_sql_like_string, get_utc_now - - -def _check_is_scalar(v): - if v is None: - return True - if isinstance(v, bool): - return True - if isinstance(v, (int, float, Decimal, str)): - return True - return False - - -def _scalar_to_row(key: str, ordinal: int, value) -> dict: - """Convert a scalar value to a typed projection row.""" - if isinstance(value, bool): - return {"key": key, "ordinal": ordinal, "val_bool": bool(value)} - if isinstance(value, (int, float, Decimal)): - num = value if isinstance(value, Decimal) else Decimal(str(value)) - return {"key": key, "ordinal": ordinal, "val_num": num} - if isinstance(value, str): - return {"key": key, "ordinal": ordinal, "val_str": value} - return {"key": key, "ordinal": ordinal, "val_json": value} - - -def convert_metadata_to_rows(key: str, value) -> list[dict]: - """Turn a metadata key/value into typed projection rows.""" - if value is None: - return [] - - if _check_is_scalar(value): - return [_scalar_to_row(key, 0, value)] - - if isinstance(value, list): - if all(_check_is_scalar(x) for x in value): - return [_scalar_to_row(key, i, x) for i, x in enumerate(value) if x is not None] - return [{"key": key, "ordinal": i, "val_json": x} for i, x in enumerate(value) if x is not None] - - return [{"key": key, "ordinal": 0, "val_json": value}] - - - - -def get_reference_by_id( - session: Session, - reference_id: str, -) -> AssetReference | None: - return session.get(AssetReference, reference_id) - - -def get_reference_with_owner_check( - session: Session, - reference_id: str, - owner_id: str, -) -> AssetReference: - """Fetch a reference and verify ownership. - - Raises: - ValueError: if reference not found or soft-deleted - PermissionError: if owner_id doesn't match - """ - ref = get_reference_by_id(session, reference_id=reference_id) - if not ref or ref.deleted_at is not None: - raise ValueError(f"AssetReference {reference_id} not found") - if ref.owner_id and ref.owner_id != owner_id: - raise PermissionError("not owner") - return ref - - -def get_reference_by_file_path( - session: Session, - file_path: str, -) -> AssetReference | None: - """Get a reference by its file path.""" - return ( - session.execute( - select(AssetReference).where(AssetReference.file_path == file_path).limit(1) - ) - .scalars() - .first() - ) - - -def count_active_siblings( - session: Session, - asset_id: str, - exclude_reference_id: str, -) -> int: - """Count active (non-deleted) references to an asset, excluding one reference.""" - return ( - session.query(AssetReference) - .filter( - AssetReference.asset_id == asset_id, - AssetReference.id != exclude_reference_id, - AssetReference.deleted_at.is_(None), - ) - .count() - ) - - -def reference_exists_for_asset_id( - session: Session, - asset_id: str, -) -> bool: - q = ( - select(sa.literal(True)) - .select_from(AssetReference) - .where(AssetReference.asset_id == asset_id) - .where(AssetReference.deleted_at.is_(None)) - .limit(1) - ) - return session.execute(q).first() is not None - - -def reference_exists( - session: Session, - reference_id: str, -) -> bool: - """Return True if a reference with the given ID exists (not soft-deleted).""" - q = ( - select(sa.literal(True)) - .select_from(AssetReference) - .where(AssetReference.id == reference_id) - .where(AssetReference.deleted_at.is_(None)) - .limit(1) - ) - return session.execute(q).first() is not None - - -def insert_reference( - session: Session, - asset_id: str, - name: str, - owner_id: str = "", - file_path: str | None = None, - mtime_ns: int | None = None, - preview_id: str | None = None, -) -> AssetReference | None: - """Insert a new AssetReference. Returns None if unique constraint violated.""" - now = get_utc_now() - try: - with session.begin_nested(): - ref = AssetReference( - asset_id=asset_id, - name=name, - owner_id=owner_id, - file_path=file_path, - mtime_ns=mtime_ns, - preview_id=preview_id, - created_at=now, - updated_at=now, - last_access_time=now, - ) - session.add(ref) - session.flush() - return ref - except IntegrityError: - return None - - -def get_or_create_reference( - session: Session, - asset_id: str, - name: str, - owner_id: str = "", - file_path: str | None = None, - mtime_ns: int | None = None, - preview_id: str | None = None, -) -> tuple[AssetReference, bool]: - """Get existing or create new AssetReference. - - For filesystem references (file_path is set), uniqueness is by file_path. - For API references (file_path is None), we look for matching - asset_id + owner_id + name. - - Returns (reference, created). - """ - ref = insert_reference( - session, - asset_id=asset_id, - name=name, - owner_id=owner_id, - file_path=file_path, - mtime_ns=mtime_ns, - preview_id=preview_id, - ) - if ref: - return ref, True - - # Find existing - priority to file_path match, then name match - if file_path: - existing = get_reference_by_file_path(session, file_path) - else: - existing = ( - session.execute( - select(AssetReference) - .where( - AssetReference.asset_id == asset_id, - AssetReference.name == name, - AssetReference.owner_id == owner_id, - AssetReference.file_path.is_(None), - ) - .limit(1) - ) - .unique() - .scalar_one_or_none() - ) - if not existing: - raise RuntimeError("Failed to find AssetReference after insert conflict.") - return existing, False - - -def update_reference_timestamps( - session: Session, - reference: AssetReference, - preview_id: str | None = None, -) -> None: - """Update timestamps and optionally preview_id on existing AssetReference.""" - now = get_utc_now() - if preview_id and reference.preview_id != preview_id: - reference.preview_id = preview_id - reference.updated_at = now - - -def list_references_page( - session: Session, - owner_id: str = "", - limit: int = 100, - offset: int = 0, - name_contains: str | None = None, - include_tags: Sequence[str] | None = None, - exclude_tags: Sequence[str] | None = None, - metadata_filter: dict | None = None, - sort: str | None = None, - order: str | None = None, - after_cursor_value: object | None = None, - after_cursor_id: str | None = None, - # Appended last so pre-existing positional callers keep binding correctly. - any_tags: Sequence[str] | None = None, -) -> tuple[list[AssetReference], dict[str, list[str]], int]: - """List references with pagination, filtering, and sorting. - - When ``after_cursor_value``/``after_cursor_id`` are supplied the query uses - keyset pagination — ``offset`` is ignored and a WHERE clause selects rows - strictly after the given ``(sort_col, id)`` position in the active sort - direction. The cursor value must already be typed for the column - (datetime for time sorts, int for size, str for name); the caller decodes - the opaque cursor string and resolves to the typed value. - - Returns (references, tag_map, total_count). - """ - base = ( - select(AssetReference) - .join(Asset, Asset.id == AssetReference.asset_id) - .where(build_visible_owner_clause(owner_id)) - .where(AssetReference.is_missing == False) # noqa: E712 - .where(AssetReference.deleted_at.is_(None)) - .options(noload(AssetReference.tags)) - ) - - if name_contains: - escaped, esc = escape_sql_like_string(name_contains) - base = base.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc)) - - base = apply_tag_filters(base, include_tags, exclude_tags, any_tags) - base = apply_metadata_filter(base, metadata_filter) - - sort = (sort or "created_at").lower() - order = (order or "desc").lower() - sort_map = { - "name": AssetReference.name, - "created_at": AssetReference.created_at, - "updated_at": AssetReference.updated_at, - "last_access_time": AssetReference.last_access_time, - "size": Asset.size_bytes, - } - sort_col = sort_map.get(sort, AssetReference.created_at) - descending = order == "desc" - - # Keyset WHERE: (sort_col, id) strictly less-than / greater-than the cursor. - # Equivalent to: sort_col v OR (sort_col = v AND id cursor_id). - if after_cursor_value is not None and after_cursor_id is not None: - if descending: - keyset = sa.or_( - sort_col < after_cursor_value, - sa.and_(sort_col == after_cursor_value, AssetReference.id < after_cursor_id), - ) - else: - keyset = sa.or_( - sort_col > after_cursor_value, - sa.and_(sort_col == after_cursor_value, AssetReference.id > after_cursor_id), - ) - base = base.where(keyset) - - # Secondary ORDER BY id (matching the primary direction) gives the keyset - # comparison a deterministic tiebreaker on duplicate sort_col values. - id_exp = AssetReference.id.desc() if descending else AssetReference.id.asc() - sort_exp = sort_col.desc() if descending else sort_col.asc() - - base = base.order_by(sort_exp, id_exp).limit(limit) - if after_cursor_id is None: - base = base.offset(offset) - - count_stmt = ( - select(sa.func.count()) - .select_from(AssetReference) - .join(Asset, Asset.id == AssetReference.asset_id) - .where(build_visible_owner_clause(owner_id)) - .where(AssetReference.is_missing == False) # noqa: E712 - .where(AssetReference.deleted_at.is_(None)) - ) - if name_contains: - escaped, esc = escape_sql_like_string(name_contains) - count_stmt = count_stmt.where( - AssetReference.name.ilike(f"%{escaped}%", escape=esc) - ) - count_stmt = apply_tag_filters(count_stmt, include_tags, exclude_tags, any_tags) - count_stmt = apply_metadata_filter(count_stmt, metadata_filter) - - total = int(session.execute(count_stmt).scalar_one() or 0) - refs = session.execute(base).unique().scalars().all() - - id_list: list[str] = [r.id for r in refs] - tag_map: dict[str, list[str]] = defaultdict(list) - if id_list: - rows = session.execute( - select(AssetReferenceTag.asset_reference_id, Tag.name) - .join(Tag, Tag.name == AssetReferenceTag.tag_name) - .where(AssetReferenceTag.asset_reference_id.in_(id_list)) - .order_by(AssetReferenceTag.tag_name.asc()) - ) - for ref_id, tag_name in rows.all(): - tag_map[ref_id].append(tag_name) - - return list(refs), tag_map, total - - -def fetch_reference_asset_and_tags( - session: Session, - reference_id: str, - owner_id: str = "", -) -> tuple[AssetReference, Asset, list[str]] | None: - stmt = ( - select(AssetReference, Asset, Tag.name) - .join(Asset, Asset.id == AssetReference.asset_id) - .join( - AssetReferenceTag, - AssetReferenceTag.asset_reference_id == AssetReference.id, - isouter=True, - ) - .join(Tag, Tag.name == AssetReferenceTag.tag_name, isouter=True) - .where( - AssetReference.id == reference_id, - AssetReference.deleted_at.is_(None), - build_visible_owner_clause(owner_id), - ) - .options(noload(AssetReference.tags)) - .order_by(Tag.name.asc()) - ) - - rows = session.execute(stmt).all() - if not rows: - return None - - first_ref, first_asset, _ = rows[0] - tags: list[str] = [] - seen: set[str] = set() - for _ref, _asset, tag_name in rows: - if tag_name and tag_name not in seen: - seen.add(tag_name) - tags.append(tag_name) - return first_ref, first_asset, tags - - -def fetch_reference_and_asset( - session: Session, - reference_id: str, - owner_id: str = "", -) -> tuple[AssetReference, Asset] | None: - stmt = ( - select(AssetReference, Asset) - .join(Asset, Asset.id == AssetReference.asset_id) - .where( - AssetReference.id == reference_id, - AssetReference.deleted_at.is_(None), - build_visible_owner_clause(owner_id), - ) - .limit(1) - .options(noload(AssetReference.tags)) - ) - pair = session.execute(stmt).first() - if not pair: - return None - return pair[0], pair[1] - - -def update_reference_access_time( - session: Session, - reference_id: str, - ts: datetime | None = None, - only_if_newer: bool = True, -) -> None: - ts = ts or get_utc_now() - stmt = sa.update(AssetReference).where(AssetReference.id == reference_id) - if only_if_newer: - stmt = stmt.where( - sa.or_( - AssetReference.last_access_time.is_(None), - AssetReference.last_access_time < ts, - ) - ) - session.execute(stmt.values(last_access_time=ts)) - - -def update_reference_name( - session: Session, - reference_id: str, - name: str, -) -> None: - """Update the name of an AssetReference.""" - now = get_utc_now() - session.execute( - sa.update(AssetReference) - .where(AssetReference.id == reference_id) - .values(name=name, updated_at=now) - ) - - -def update_reference_updated_at( - session: Session, - reference_id: str, - ts: datetime | None = None, -) -> None: - """Update the updated_at timestamp of an AssetReference.""" - ts = ts or get_utc_now() - session.execute( - sa.update(AssetReference) - .where(AssetReference.id == reference_id) - .values(updated_at=ts) - ) - - -def rebuild_metadata_projection(session: Session, ref: AssetReference) -> None: - """Delete and rebuild AssetReferenceMeta rows from merged system+user metadata. - - The merged dict is ``{**system_metadata, **user_metadata}`` so user keys - override system keys of the same name. - """ - session.execute( - delete(AssetReferenceMeta).where( - AssetReferenceMeta.asset_reference_id == ref.id - ) - ) - session.flush() - - merged = {**(ref.system_metadata or {}), **(ref.user_metadata or {})} - if not merged: - return - - rows: list[AssetReferenceMeta] = [] - for k, v in merged.items(): - for r in convert_metadata_to_rows(k, v): - rows.append( - AssetReferenceMeta( - asset_reference_id=ref.id, - key=r["key"], - ordinal=int(r["ordinal"]), - val_str=r.get("val_str"), - val_num=r.get("val_num"), - val_bool=r.get("val_bool"), - val_json=r.get("val_json"), - ) - ) - if rows: - session.add_all(rows) - session.flush() - - -def set_reference_metadata( - session: Session, - reference_id: str, - user_metadata: dict | None = None, -) -> None: - ref = session.get(AssetReference, reference_id) - if not ref: - raise ValueError(f"AssetReference {reference_id} not found") - - ref.user_metadata = user_metadata or {} - ref.updated_at = get_utc_now() - session.flush() - - rebuild_metadata_projection(session, ref) - - -def set_reference_system_metadata( - session: Session, - reference_id: str, - system_metadata: dict | None = None, -) -> None: - """Set system_metadata on a reference and rebuild the merged projection.""" - ref = session.get(AssetReference, reference_id) - if not ref: - raise ValueError(f"AssetReference {reference_id} not found") - - ref.system_metadata = system_metadata or {} - ref.updated_at = get_utc_now() - session.flush() - - rebuild_metadata_projection(session, ref) - - -def delete_reference_by_id( - session: Session, - reference_id: str, - owner_id: str, -) -> bool: - stmt = sa.delete(AssetReference).where( - AssetReference.id == reference_id, - build_visible_owner_clause(owner_id), - ) - return int(session.execute(stmt).rowcount or 0) > 0 - - -def soft_delete_reference_by_id( - session: Session, - reference_id: str, - owner_id: str, -) -> bool: - """Mark a reference as soft-deleted by setting deleted_at timestamp. - - Returns True if the reference was found and marked deleted. - """ - now = get_utc_now() - stmt = ( - sa.update(AssetReference) - .where( - AssetReference.id == reference_id, - AssetReference.deleted_at.is_(None), - build_visible_owner_clause(owner_id), - ) - .values(deleted_at=now) - ) - return int(session.execute(stmt).rowcount or 0) > 0 - - -def set_reference_preview( - session: Session, - reference_id: str, - preview_reference_id: str | None = None, -) -> None: - """Set or clear preview_id and bump updated_at. Raises on unknown IDs.""" - ref = session.get(AssetReference, reference_id) - if not ref: - raise ValueError(f"AssetReference {reference_id} not found") - - if preview_reference_id is None: - ref.preview_id = None - else: - if not session.get(AssetReference, preview_reference_id): - raise ValueError(f"Preview AssetReference {preview_reference_id} not found") - ref.preview_id = preview_reference_id - - ref.updated_at = get_utc_now() - session.flush() - - -class CacheStateRow(NamedTuple): - """Row from reference query with cache state data.""" - - reference_id: str - file_path: str - mtime_ns: int | None - needs_verify: bool - asset_id: str - asset_hash: str | None - size_bytes: int | None - - -def list_references_by_asset_id( - session: Session, - asset_id: str, -) -> Sequence[AssetReference]: - return ( - session.execute( - select(AssetReference) - .where(AssetReference.asset_id == asset_id) - .where(AssetReference.is_missing == False) # noqa: E712 - .where(AssetReference.deleted_at.is_(None)) - .order_by(AssetReference.id.asc()) - ) - .scalars() - .all() - ) - - -def list_all_file_paths_by_asset_id( - session: Session, - asset_id: str, -) -> list[str]: - """Return every file_path for an asset, including soft-deleted/missing refs. - - Used for orphan cleanup where all on-disk files must be removed. - """ - return list( - session.execute( - select(AssetReference.file_path) - .where(AssetReference.asset_id == asset_id) - .where(AssetReference.file_path.isnot(None)) - ) - .scalars() - .all() - ) - - -def upsert_reference( - session: Session, - asset_id: str, - file_path: str, - name: str, - mtime_ns: int, - owner_id: str = "", - loader_path: str | None = None, -) -> tuple[bool, bool]: - """Upsert a reference by file_path. Returns (created, updated). - - Also restores references that were previously marked as missing. - """ - now = get_utc_now() - vals = { - "asset_id": asset_id, - "file_path": file_path, - "loader_path": loader_path, - "name": name, - "owner_id": owner_id, - "mtime_ns": int(mtime_ns), - "is_missing": False, - "created_at": now, - "updated_at": now, - "last_access_time": now, - } - ins = ( - sqlite.insert(AssetReference) - .values(**vals) - .on_conflict_do_nothing(index_elements=[AssetReference.file_path]) - ) - res = session.execute(ins) - created = int(res.rowcount or 0) > 0 - - if created: - return True, False - - upd = ( - sa.update(AssetReference) - .where(AssetReference.file_path == file_path) - .where( - sa.or_( - AssetReference.asset_id != asset_id, - AssetReference.mtime_ns.is_(None), - AssetReference.mtime_ns != int(mtime_ns), - AssetReference.loader_path.is_distinct_from(loader_path), - AssetReference.is_missing == True, # noqa: E712 - AssetReference.deleted_at.isnot(None), - ) - ) - .values( - asset_id=asset_id, mtime_ns=int(mtime_ns), loader_path=loader_path, - is_missing=False, deleted_at=None, updated_at=now, - ) - ) - res2 = session.execute(upd) - updated = int(res2.rowcount or 0) > 0 - return False, updated - - -def mark_references_missing_outside_prefixes( - session: Session, - valid_prefixes: list[str], -) -> int: - """Mark references as missing when file_path doesn't match any valid prefix. - - Returns number of references marked as missing. - """ - if not valid_prefixes: - return 0 - - conds = build_prefix_like_conditions(valid_prefixes) - matches_valid_prefix = sa.or_(*conds) - result = session.execute( - sa.update(AssetReference) - .where(AssetReference.file_path.isnot(None)) - .where(AssetReference.deleted_at.is_(None)) - .where(~matches_valid_prefix) - .where(AssetReference.is_missing == False) # noqa: E712 - .values(is_missing=True) - ) - return result.rowcount - - -def restore_references_by_paths(session: Session, file_paths: list[str]) -> int: - """Restore references that were previously marked as missing. - - Returns number of references restored. - """ - if not file_paths: - return 0 - - total = 0 - for chunk in iter_chunks(file_paths, MAX_BIND_PARAMS): - result = session.execute( - sa.update(AssetReference) - .where(AssetReference.file_path.in_(chunk)) - .where(AssetReference.is_missing == True) # noqa: E712 - .where(AssetReference.deleted_at.is_(None)) - .values(is_missing=False) - ) - total += result.rowcount - return total - - -def get_unreferenced_unhashed_asset_ids(session: Session) -> list[str]: - """Get IDs of unhashed assets (hash=None) with no active references. - - An asset is considered unreferenced if it has no references, - or all its references are marked as missing. - - Returns list of asset IDs that are unreferenced. - """ - active_ref_exists = ( - sa.select(sa.literal(1)) - .where(AssetReference.asset_id == Asset.id) - .where(AssetReference.is_missing == False) # noqa: E712 - .where(AssetReference.deleted_at.is_(None)) - .correlate(Asset) - .exists() - ) - unreferenced_subq = sa.select(Asset.id).where( - Asset.hash.is_(None), ~active_ref_exists - ) - return [row[0] for row in session.execute(unreferenced_subq).all()] - - -def delete_assets_by_ids(session: Session, asset_ids: list[str]) -> int: - """Delete assets and their references by ID. - - Returns number of assets deleted. - """ - if not asset_ids: - return 0 - total = 0 - for chunk in iter_chunks(asset_ids, MAX_BIND_PARAMS): - session.execute( - sa.delete(AssetReference).where(AssetReference.asset_id.in_(chunk)) - ) - result = session.execute(sa.delete(Asset).where(Asset.id.in_(chunk))) - total += result.rowcount - return total - - -def get_references_for_prefixes( - session: Session, - prefixes: list[str], - *, - include_missing: bool = False, -) -> list[CacheStateRow]: - """Get all references with file paths matching any of the given prefixes. - - Args: - session: Database session - prefixes: List of absolute directory prefixes to match - include_missing: If False (default), exclude references marked as missing - - Returns: - List of cache state rows with joined asset data - """ - if not prefixes: - return [] - - conds = build_prefix_like_conditions(prefixes) - - query = ( - sa.select( - AssetReference.id, - AssetReference.file_path, - AssetReference.mtime_ns, - AssetReference.needs_verify, - AssetReference.asset_id, - Asset.hash, - Asset.size_bytes, - ) - .join(Asset, Asset.id == AssetReference.asset_id) - .where(AssetReference.file_path.isnot(None)) - .where(AssetReference.deleted_at.is_(None)) - .where(sa.or_(*conds)) - ) - - if not include_missing: - query = query.where(AssetReference.is_missing == False) # noqa: E712 - - rows = session.execute( - query.order_by(AssetReference.asset_id.asc(), AssetReference.id.asc()) - ).all() - - return [ - CacheStateRow( - reference_id=row[0], - file_path=row[1], - mtime_ns=row[2], - needs_verify=row[3], - asset_id=row[4], - asset_hash=row[5], - size_bytes=int(row[6]) if row[6] is not None else None, - ) - for row in rows - ] - - -def bulk_update_needs_verify( - session: Session, reference_ids: list[str], value: bool -) -> int: - """Set needs_verify flag for multiple references. - - Returns: Number of rows updated - """ - if not reference_ids: - return 0 - total = 0 - for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS): - result = session.execute( - sa.update(AssetReference) - .where(AssetReference.id.in_(chunk)) - .values(needs_verify=value) - ) - total += result.rowcount - return total - - -def bulk_update_is_missing( - session: Session, reference_ids: list[str], value: bool -) -> int: - """Set is_missing flag for multiple references. - - Returns: Number of rows updated - """ - if not reference_ids: - return 0 - total = 0 - for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS): - result = session.execute( - sa.update(AssetReference) - .where(AssetReference.id.in_(chunk)) - .values(is_missing=value) - ) - total += result.rowcount - return total - - -def update_is_missing_by_asset_id( - session: Session, asset_id: str, value: bool -) -> int: - """Set is_missing flag for ALL references belonging to an asset. - - Returns: Number of rows updated - """ - result = session.execute( - sa.update(AssetReference) - .where(AssetReference.asset_id == asset_id) - .where(AssetReference.deleted_at.is_(None)) - .values(is_missing=value) - ) - return result.rowcount - - -def delete_references_by_ids(session: Session, reference_ids: list[str]) -> int: - """Delete references by their IDs. - - Returns: Number of rows deleted - """ - if not reference_ids: - return 0 - total = 0 - for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS): - result = session.execute( - sa.delete(AssetReference).where(AssetReference.id.in_(chunk)) - ) - total += result.rowcount - return total - - -def delete_orphaned_seed_asset(session: Session, asset_id: str) -> bool: - """Delete a seed asset (hash is None) and its references. - - Returns: True if asset was deleted, False if not found or has a hash - """ - asset = session.get(Asset, asset_id) - if not asset: - return False - if asset.hash is not None: - return False - session.execute( - sa.delete(AssetReference).where(AssetReference.asset_id == asset_id) - ) - session.delete(asset) - return True - - -class UnenrichedReferenceRow(NamedTuple): - """Row for references needing enrichment.""" - - reference_id: str - asset_id: str - file_path: str - enrichment_level: int - - -def get_unenriched_references( - session: Session, - prefixes: list[str], - max_level: int = 0, - limit: int = 1000, -) -> list[UnenrichedReferenceRow]: - """Get references that need enrichment (enrichment_level <= max_level). - - Args: - session: Database session - prefixes: List of absolute directory prefixes to scan - max_level: Maximum enrichment level to include (0=stubs, 1=metadata done) - limit: Maximum number of rows to return - - Returns: - List of unenriched reference rows with file paths - """ - if not prefixes: - return [] - - conds = build_prefix_like_conditions(prefixes) - - query = ( - sa.select( - AssetReference.id, - AssetReference.asset_id, - AssetReference.file_path, - AssetReference.enrichment_level, - ) - .where(AssetReference.file_path.isnot(None)) - .where(AssetReference.deleted_at.is_(None)) - .where(sa.or_(*conds)) - .where(AssetReference.is_missing == False) # noqa: E712 - .where(AssetReference.enrichment_level <= max_level) - .order_by(AssetReference.id.asc()) - .limit(limit) - ) - - rows = session.execute(query).all() - return [ - UnenrichedReferenceRow( - reference_id=row[0], - asset_id=row[1], - file_path=row[2], - enrichment_level=row[3], - ) - for row in rows - ] - - -def bulk_update_enrichment_level( - session: Session, - reference_ids: list[str], - level: int, -) -> int: - """Update enrichment level for multiple references. - - Returns: Number of rows updated - """ - if not reference_ids: - return 0 - result = session.execute( - sa.update(AssetReference) - .where(AssetReference.id.in_(reference_ids)) - .values(enrichment_level=level) - ) - return result.rowcount - - -def bulk_insert_references_ignore_conflicts( - session: Session, - rows: list[dict], -) -> None: - """Bulk insert reference rows with ON CONFLICT DO NOTHING on file_path. - - Each dict should have: id, asset_id, file_path, name, owner_id, mtime_ns, etc. - The is_missing field is automatically set to False for new inserts. - """ - if not rows: - return - enriched_rows = [{**row, "is_missing": False} for row in rows] - ins = sqlite.insert(AssetReference).on_conflict_do_nothing( - index_elements=[AssetReference.file_path] - ) - for chunk in iter_chunks(enriched_rows, calculate_rows_per_statement(14)): - session.execute(ins, chunk) - - -def get_references_by_paths_and_asset_ids( - session: Session, - path_to_asset: dict[str, str], -) -> set[str]: - """Query references to find paths where our asset_id won the insert. - - Args: - path_to_asset: Mapping of file_path -> asset_id we tried to insert - - Returns: - Set of file_paths where our asset_id is present - """ - if not path_to_asset: - return set() - - pairs = list(path_to_asset.items()) - winners: set[str] = set() - - # Each pair uses 2 bind params, so chunk at MAX_BIND_PARAMS // 2 - for chunk in iter_chunks(pairs, MAX_BIND_PARAMS // 2): - pairwise = sa.tuple_(AssetReference.file_path, AssetReference.asset_id).in_( - chunk - ) - result = session.execute( - select(AssetReference.file_path).where(pairwise) - ) - winners.update(result.scalars().all()) - - return winners - - -def get_reference_paths_by_ids( - session: Session, - reference_ids: list[str], -) -> dict[str, str]: - """Map reference id -> file_path for live, file-backed references.""" - if not reference_ids: - return {} - - paths: dict[str, str] = {} - for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS): - rows = session.execute( - select(AssetReference.id, AssetReference.file_path).where( - AssetReference.id.in_(chunk), - AssetReference.file_path.is_not(None), - AssetReference.deleted_at.is_(None), - ) - ) - paths.update({rid: fp for rid, fp in rows}) - return paths - - -def get_reference_ids_by_ids( - session: Session, - reference_ids: list[str], -) -> set[str]: - """Query to find which reference IDs exist in the database.""" - if not reference_ids: - return set() - - found: set[str] = set() - for chunk in iter_chunks(reference_ids, MAX_BIND_PARAMS): - result = session.execute( - select(AssetReference.id).where(AssetReference.id.in_(chunk)) - ) - found.update(result.scalars().all()) - return found diff --git a/app/assets/database/queries/common.py b/app/assets/database/queries/common.py deleted file mode 100644 index 7b0c211a0cb..00000000000 --- a/app/assets/database/queries/common.py +++ /dev/null @@ -1,138 +0,0 @@ -"""Shared utilities for database query modules.""" - -import os -from decimal import Decimal -from typing import Iterable, Sequence - -import sqlalchemy as sa -from sqlalchemy import exists - -from app.assets.database.models import AssetReference, AssetReferenceMeta, AssetReferenceTag -from app.assets.helpers import escape_sql_like_string, normalize_tags - -MAX_BIND_PARAMS = 800 - - -def calculate_rows_per_statement(cols: int) -> int: - """Calculate how many rows can fit in one statement given column count.""" - return max(1, MAX_BIND_PARAMS // max(1, cols)) - - -def iter_chunks(seq, n: int): - """Yield successive n-sized chunks from seq.""" - for i in range(0, len(seq), n): - yield seq[i : i + n] - - -def iter_row_chunks(rows: list[dict], cols_per_row: int) -> Iterable[list[dict]]: - """Yield chunks of rows sized to fit within bind param limits.""" - if not rows: - return - yield from iter_chunks(rows, calculate_rows_per_statement(cols_per_row)) - - -def build_visible_owner_clause(owner_id: str) -> sa.sql.ClauseElement: - """Build owner visibility predicate for reads. - - Owner-less rows are visible to everyone. - """ - owner_id = (owner_id or "").strip() - if owner_id == "": - return AssetReference.owner_id == "" - return AssetReference.owner_id.in_(["", owner_id]) - - -def build_prefix_like_conditions( - prefixes: list[str], -) -> list[sa.sql.ColumnElement]: - """Build LIKE conditions for matching file paths under directory prefixes.""" - conds = [] - for p in prefixes: - base = os.path.abspath(p) - if not base.endswith(os.sep): - base += os.sep - escaped, esc = escape_sql_like_string(base) - conds.append(AssetReference.file_path.like(escaped + "%", escape=esc)) - return conds - - -def apply_tag_filters( - stmt: sa.sql.Select, - include_tags: Sequence[str] | None = None, - exclude_tags: Sequence[str] | None = None, - any_tags: Sequence[str] | None = None, -) -> sa.sql.Select: - """include_tags: every tag must be present; any_tags: at least one must be - present; exclude_tags: none may be present.""" - include_tags = normalize_tags(include_tags) - exclude_tags = normalize_tags(exclude_tags) - any_tags = normalize_tags(any_tags) - - if include_tags: - for tag_name in include_tags: - stmt = stmt.where( - exists().where( - (AssetReferenceTag.asset_reference_id == AssetReference.id) - & (AssetReferenceTag.tag_name == tag_name) - ) - ) - - if any_tags: - stmt = stmt.where( - exists().where( - (AssetReferenceTag.asset_reference_id == AssetReference.id) - & (AssetReferenceTag.tag_name.in_(any_tags)) - ) - ) - - if exclude_tags: - stmt = stmt.where( - ~exists().where( - (AssetReferenceTag.asset_reference_id == AssetReference.id) - & (AssetReferenceTag.tag_name.in_(exclude_tags)) - ) - ) - return stmt - - -def apply_metadata_filter( - stmt: sa.sql.Select, - metadata_filter: dict | None = None, -) -> sa.sql.Select: - """Apply filters using asset_reference_meta projection table.""" - if not metadata_filter: - return stmt - - def _exists_for_pred(key: str, *preds) -> sa.sql.ClauseElement: - return sa.exists().where( - AssetReferenceMeta.asset_reference_id == AssetReference.id, - AssetReferenceMeta.key == key, - *preds, - ) - - def _exists_clause_for_value(key: str, value) -> sa.sql.ClauseElement: - if value is None: - return sa.not_( - sa.exists().where( - AssetReferenceMeta.asset_reference_id == AssetReference.id, - AssetReferenceMeta.key == key, - ) - ) - - if isinstance(value, bool): - return _exists_for_pred(key, AssetReferenceMeta.val_bool == bool(value)) - if isinstance(value, (int, float, Decimal)): - num = value if isinstance(value, Decimal) else Decimal(str(value)) - return _exists_for_pred(key, AssetReferenceMeta.val_num == num) - if isinstance(value, str): - return _exists_for_pred(key, AssetReferenceMeta.val_str == value) - return _exists_for_pred(key, AssetReferenceMeta.val_json == value) - - for k, v in metadata_filter.items(): - if isinstance(v, list): - ors = [_exists_clause_for_value(k, elem) for elem in v] - if ors: - stmt = stmt.where(sa.or_(*ors)) - else: - stmt = stmt.where(_exists_clause_for_value(k, v)) - return stmt diff --git a/app/assets/database/queries/records.py b/app/assets/database/queries/records.py new file mode 100644 index 00000000000..5a20b6babbc --- /dev/null +++ b/app/assets/database/queries/records.py @@ -0,0 +1,333 @@ +"""Owns every write to content rows, records and their tag links, plus the paged +reads that list them. Inserts that can lose a race — a content row at a path, a +tag, a tag link — run inside a savepoint and re-read the conflicting row, so a +concurrent writer settles the call instead of raising, while a genuine +constraint failure still surfaces. This is the sole writer of a content row's +path, which is what lets other modules trust raw-column path predicates. +""" + +from __future__ import annotations + +import os +from collections.abc import Sequence +from datetime import datetime +from typing import Any, Literal, NamedTuple, TypeAlias + +import sqlalchemy as sa +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session, joinedload, noload +from sqlalchemy.sql.elements import ColumnElement + +from app.assets.database.models import Asset, AssetContent, AssetTag, Tag +from app.assets.helpers import escape_sql_like_string, get_utc_now + +RecordSortField: TypeAlias = Literal[ + "name", "created_at", "updated_at", "size", "last_access_time" +] +RecordSortOrder: TypeAlias = Literal["asc", "desc"] + + +class RecordCursorBoundary(NamedTuple): + value: datetime | int | str + id: str + + +class RecordPageSpec(NamedTuple): + all_tags: tuple[str, ...] = () + any_tags: tuple[str, ...] = () + none_tags: tuple[str, ...] = () + name_contains: str | None = None + limit: int = 20 + offset: int = 0 + sort: RecordSortField = "created_at" + order: RecordSortOrder = "desc" + after: RecordCursorBoundary | None = None + + +_LIVE_PATH_UNIQUE_INDEX = "uq_asset_contents_path_live" + + +def _is_live_path_conflict(error: IntegrityError) -> bool: + orig = error.orig + message = str(orig) + postgres_names_the_index = getattr(getattr(orig, "diag", None), "constraint_name", None) == _LIVE_PATH_UNIQUE_INDEX + sqlite_names_the_column = "UNIQUE constraint failed" in message and "asset_contents.path" in message + return postgres_names_the_index or sqlite_names_the_column + + +def create_content_reporting_insert(session: Session, path: str, hash: str | None = None, size_bytes: int = 0, mtime_ns: int | None = None) -> tuple[AssetContent, bool]: + # The sole writer of asset_contents.path, which is what makes the raw-column SQL prefix + # predicates sound — lifecycle's temp wipe HARD-DELETES every row its predicate admits. + path = os.path.abspath(path) + content = AssetContent(path=path, hash=hash, size_bytes=size_bytes, mtime_ns=mtime_ns) + try: + with session.begin_nested(): + session.add(content) + session.flush() + return content, True + except IntegrityError as error: + if not _is_live_path_conflict(error): + raise + winner = session.execute(sa.select(AssetContent).where(AssetContent.path == path, AssetContent.is_missing.is_(False))).scalar_one() + return winner, False + + +def create_content(session: Session, path: str, hash: str | None = None, size_bytes: int = 0, mtime_ns: int | None = None) -> AssetContent: + content, _ = create_content_reporting_insert(session, path, hash, size_bytes, mtime_ns) + return content + + +def ensure_tag(session: Session, name: str) -> None: + if session.get(Tag, name) is not None: + return + try: + with session.begin_nested(): + session.add(Tag(name=name)) + session.flush() + except IntegrityError: + # Only a row that is now present proves a lost race; anything else is a real failure. + if session.get(Tag, name) is None: + raise + + +def ensure_tag_link(session: Session, *, asset_id: str, tag_name: str, origin: str) -> bool: + if session.get(AssetTag, (asset_id, tag_name)) is not None: + return False + try: + with session.begin_nested(): + session.add(AssetTag(asset_id=asset_id, tag_name=tag_name, origin=origin)) + session.flush() + except IntegrityError: + if session.get(AssetTag, (asset_id, tag_name)) is None: + raise + return False + return True + + +def create_record(session: Session, content_id: str, name: str, mime_type: str | None = None, job_id: str | None = None, loader_path: str | None = None, tags: Sequence[str] | None = None, *, system_metadata: dict[str, Any] | None = None) -> Asset: + record = Asset(content_id=content_id, name=name, mime_type=mime_type, job_id=job_id, loader_path=loader_path, system_metadata=system_metadata) + session.add(record) + session.flush() + for tag_name in dict.fromkeys(tags or ()): + ensure_tag(session, tag_name) + ensure_tag_link(session, asset_id=record.id, tag_name=tag_name, origin="manual") + session.flush() + return record + + +def get_record_by_id(session: Session, id: str) -> Asset | None: + return session.get(Asset, id) + + +def get_preview_file_paths_by_ids( + session: Session, + preview_ids: Sequence[str], +) -> dict[str, str]: + if not preview_ids: + return {} + + rows = session.execute( + sa.select(Asset.id, AssetContent.path) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where( + Asset.id.in_(preview_ids), + AssetContent.is_missing.is_(False), + ) + ) + return {preview_id: path for preview_id, path in rows} + + +def get_record_by_path_or_none(session: Session, path: str) -> Asset | None: + return session.scalar( + sa.select(Asset) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(AssetContent.path == path, AssetContent.is_missing.is_(False)) + .order_by(Asset.created_at.desc(), Asset.id.desc()) + .limit(1) + ) + + +def fetch_record_tags(session: Session, record_id: str) -> list[str]: + return list( + session.scalars( + sa.select(AssetTag.tag_name) + .where(AssetTag.asset_id == record_id) + .order_by(AssetTag.tag_name) + ) + ) + + +def update_record_access_time( + session: Session, + record_id: str, + ts: datetime | None = None, + only_if_newer: bool = True, +) -> None: + ts = ts or get_utc_now() + stmt = sa.update(Asset).where(Asset.id == record_id) + if only_if_newer: + stmt = stmt.where( + sa.or_( + Asset.last_access_time.is_(None), + Asset.last_access_time < ts, + ) + ) + session.execute(stmt.values(last_access_time=ts)) + + +def bump_record_updated_at(session: Session, record_id: str) -> None: + session.execute( + sa.update(Asset).where(Asset.id == record_id).values(updated_at=get_utc_now()) + ) + + +def build_record_tag_filter_clauses( + all_tags: Sequence[str], + any_tags: Sequence[str], + none_tags: Sequence[str], +) -> tuple[ColumnElement[bool], ...]: + clauses: list[ColumnElement[bool]] = [] + for tag_name in all_tags: + clauses.append( + sa.exists( + sa.select(AssetTag.asset_id).where( + AssetTag.asset_id == Asset.id, + AssetTag.tag_name == tag_name, + ) + ) + ) + if any_tags: + clauses.append( + sa.exists( + sa.select(AssetTag.asset_id).where( + AssetTag.asset_id == Asset.id, + AssetTag.tag_name.in_(any_tags), + ) + ) + ) + if none_tags: + clauses.append( + ~sa.exists( + sa.select(AssetTag.asset_id).where( + AssetTag.asset_id == Asset.id, + AssetTag.tag_name.in_(none_tags), + ) + ) + ) + return tuple(clauses) + + +def list_records_page( + session: Session, + spec: RecordPageSpec, +) -> tuple[list[Asset], dict[str, list[str]], int]: + filters = list(build_record_tag_filter_clauses(spec.all_tags, spec.any_tags, spec.none_tags)) + if spec.name_contains: + escaped_name, escape_character = escape_sql_like_string(spec.name_contains) + filters.append( + Asset.name.ilike(f"%{escaped_name}%", escape=escape_character) + ) + + sort_columns = { + "name": Asset.name, + "created_at": Asset.created_at, + "updated_at": Asset.updated_at, + "size": AssetContent.size_bytes, + "last_access_time": Asset.last_access_time, + } + sort_column = sort_columns[spec.sort] + descending = spec.order == "desc" + sort_expression = sort_column.desc() if descending else sort_column.asc() + id_expression = Asset.id.desc() if descending else Asset.id.asc() + + statement = ( + sa.select(Asset) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(*filters) + .options(joinedload(Asset.content), noload(Asset.tags)) + ) + if spec.after is not None: + comparison = ( + sort_column < spec.after.value + if descending + else sort_column > spec.after.value + ) + tied_comparison = ( + Asset.id < spec.after.id + if descending + else Asset.id > spec.after.id + ) + statement = statement.where( + sa.or_( + comparison, + sa.and_( + sort_column == spec.after.value, + tied_comparison, + ), + ) + ) + + statement = statement.order_by(sort_expression, id_expression).limit(spec.limit) + if spec.after is None: + statement = statement.offset(spec.offset) + records = list(session.scalars(statement)) + + total = session.scalar( + sa.select(sa.func.count()) + .select_from(Asset) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(*filters) + ) + + record_ids = [record.id for record in records] + tag_map: dict[str, list[str]] = {} + if record_ids: + rows = session.execute( + sa.select(AssetTag.asset_id, AssetTag.tag_name) + .join(Asset, AssetTag.asset_id == Asset.id) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(AssetTag.asset_id.in_(record_ids)) + .order_by(AssetTag.tag_name.asc()) + ) + for record_id, tag_name in rows: + tag_map.setdefault(record_id, []).append(tag_name) + + return records, tag_map, int(total or 0) + + +def rename_record(session: Session, id: str, name: str) -> Asset: + record = session.get(Asset, id) + if record is None: + raise LookupError(id) + record.name = name + record.updated_at = get_utc_now() + session.flush() + return record + + +def delete_record(session: Session, id: str) -> None: + record = session.get(Asset, id) + if record is None: + return + session.delete(record) + session.flush() + + +def mark_content_missing(session: Session, content_id: str) -> None: + content = session.get(AssetContent, content_id) + if content is None: + raise LookupError(content_id) + content.is_missing = True + ensure_tag(session, "missing") + for record_id in session.scalars(sa.select(Asset.id).where(Asset.content_id == content_id)): + ensure_tag_link(session, asset_id=record_id, tag_name="missing", origin="automatic") + session.flush() + + +def unset_content_missing(session: Session, content_id: str) -> None: + content = session.get(AssetContent, content_id) + if content is None: + raise LookupError(content_id) + content.is_missing = False + session.execute(sa.delete(AssetTag).where(AssetTag.tag_name == "missing", AssetTag.asset_id.in_(sa.select(Asset.id).where(Asset.content_id == content_id)))) + session.flush() diff --git a/app/assets/database/queries/tags.py b/app/assets/database/queries/tags.py index e5f70e3df9f..177cc980d9f 100644 --- a/app/assets/database/queries/tags.py +++ b/app/assets/database/queries/tags.py @@ -1,26 +1,29 @@ -from dataclasses import dataclass -from typing import Iterable, Sequence +"""Answers questions about tag usage: which tags exist with how many assets, and +how those counts narrow once a filter is applied. Both queries reuse the record +listing's own joins and filter clauses, so the counts a client sees always +describe the same assets the listing endpoint would return for that filter, +records with missing content included. +""" -import sqlalchemy as sa -from sqlalchemy import delete, func, select -from sqlalchemy.dialects import sqlite -from sqlalchemy.exc import IntegrityError +from __future__ import annotations + +from dataclasses import dataclass, field + +from typing import Sequence + +from sqlalchemy import func, select from sqlalchemy.orm import Session from app.assets.database.models import ( Asset, - AssetReference, - AssetReferenceMeta, - AssetReferenceTag, + AssetContent, + AssetTag, Tag, ) -from app.assets.database.queries.common import ( - apply_metadata_filter, - apply_tag_filters, - build_visible_owner_clause, - iter_row_chunks, +from app.assets.database.queries.records import ( + build_record_tag_filter_clauses, ) -from app.assets.helpers import escape_sql_like_string, get_utc_now, normalize_tags +from app.assets.helpers import escape_sql_like_string @dataclass(frozen=True) @@ -35,225 +38,7 @@ class RemoveTagsResult: removed: list[str] not_present: list[str] total_tags: list[str] - - -@dataclass(frozen=True) -class SetTagsResult: - added: list[str] - removed: list[str] - total: list[str] - - -def validate_tags_exist(session: Session, tags: list[str]) -> None: - """Raise ValueError if any of the given tag names do not exist.""" - existing_tag_names = set( - name - for (name,) in session.execute(select(Tag.name).where(Tag.name.in_(tags))).all() - ) - missing = [t for t in tags if t not in existing_tag_names] - if missing: - raise ValueError(f"Unknown tags: {missing}") - - -def ensure_tags_exist(session: Session, names: Iterable[str]) -> None: - wanted = normalize_tags(list(names)) - if not wanted: - return - rows = [{"name": n} for n in list(dict.fromkeys(wanted))] - ins = ( - sqlite.insert(Tag) - .values(rows) - .on_conflict_do_nothing(index_elements=[Tag.name]) - ) - session.execute(ins) - - -def get_reference_tags(session: Session, reference_id: str) -> list[str]: - return [ - tag_name - for (tag_name,) in ( - session.execute( - select(AssetReferenceTag.tag_name) - .where(AssetReferenceTag.asset_reference_id == reference_id) - .order_by(AssetReferenceTag.tag_name.asc()) - ) - ).all() - ] - - -def set_reference_tags( - session: Session, - reference_id: str, - tags: Sequence[str], - origin: str = "manual", -) -> SetTagsResult: - desired = normalize_tags(tags) - - current = set(get_reference_tags(session, reference_id)) - - to_add = [t for t in desired if t not in current] - to_remove = [t for t in current if t not in desired] - - if to_add: - ensure_tags_exist(session, to_add) - session.add_all( - [ - AssetReferenceTag( - asset_reference_id=reference_id, - tag_name=t, - origin=origin, - added_at=get_utc_now(), - ) - for t in to_add - ] - ) - session.flush() - - if to_remove: - session.execute( - delete(AssetReferenceTag).where( - AssetReferenceTag.asset_reference_id == reference_id, - AssetReferenceTag.tag_name.in_(to_remove), - ) - ) - session.flush() - - return SetTagsResult(added=sorted(to_add), removed=sorted(to_remove), total=sorted(desired)) - - -def add_tags_to_reference( - session: Session, - reference_id: str, - tags: Sequence[str], - origin: str = "manual", - create_if_missing: bool = True, - reference_row: AssetReference | None = None, -) -> AddTagsResult: - if not reference_row: - ref = session.get(AssetReference, reference_id) - if not ref: - raise ValueError(f"AssetReference {reference_id} not found") - - norm = normalize_tags(tags) - if not norm: - total = get_reference_tags(session, reference_id=reference_id) - return AddTagsResult(added=[], already_present=[], total_tags=total) - - if create_if_missing: - ensure_tags_exist(session, norm) - - current = set(get_reference_tags(session, reference_id)) - - want = set(norm) - to_add = sorted(want - current) - - if to_add: - with session.begin_nested() as nested: - try: - session.add_all( - [ - AssetReferenceTag( - asset_reference_id=reference_id, - tag_name=t, - origin=origin, - added_at=get_utc_now(), - ) - for t in to_add - ] - ) - session.flush() - except IntegrityError: - nested.rollback() - - after = set(get_reference_tags(session, reference_id=reference_id)) - return AddTagsResult( - added=sorted(((after - current) & want)), - already_present=sorted(want & current), - total_tags=sorted(after), - ) - - -def remove_tags_from_reference( - session: Session, - reference_id: str, - tags: Sequence[str], -) -> RemoveTagsResult: - ref = session.get(AssetReference, reference_id) - if not ref: - raise ValueError(f"AssetReference {reference_id} not found") - - norm = normalize_tags(tags) - if not norm: - total = get_reference_tags(session, reference_id=reference_id) - return RemoveTagsResult(removed=[], not_present=[], total_tags=total) - - existing = set(get_reference_tags(session, reference_id)) - - to_remove = sorted(set(t for t in norm if t in existing)) - not_present = sorted(set(t for t in norm if t not in existing)) - - if to_remove: - session.execute( - delete(AssetReferenceTag).where( - AssetReferenceTag.asset_reference_id == reference_id, - AssetReferenceTag.tag_name.in_(to_remove), - ) - ) - session.flush() - - total = get_reference_tags(session, reference_id=reference_id) - return RemoveTagsResult(removed=to_remove, not_present=not_present, total_tags=total) - - -def add_missing_tag_for_asset_id( - session: Session, - asset_id: str, - origin: str = "automatic", -) -> None: - select_rows = ( - sa.select( - AssetReference.id.label("asset_reference_id"), - sa.literal("missing").label("tag_name"), - sa.literal(origin).label("origin"), - sa.literal(get_utc_now()).label("added_at"), - ) - .where(AssetReference.asset_id == asset_id) - .where( - sa.not_( - sa.exists().where( - (AssetReferenceTag.asset_reference_id == AssetReference.id) - & (AssetReferenceTag.tag_name == "missing") - ) - ) - ) - ) - session.execute( - sqlite.insert(AssetReferenceTag) - .from_select( - ["asset_reference_id", "tag_name", "origin", "added_at"], - select_rows, - ) - .on_conflict_do_nothing( - index_elements=[ - AssetReferenceTag.asset_reference_id, - AssetReferenceTag.tag_name, - ] - ) - ) - - -def remove_missing_tag_for_asset_id( - session: Session, - asset_id: str, -) -> None: - session.execute( - sa.delete(AssetReferenceTag).where( - AssetReferenceTag.asset_reference_id.in_( - sa.select(AssetReference.id).where(AssetReference.asset_id == asset_id) - ), - AssetReferenceTag.tag_name == "missing", - ) - ) + protected: list[str] = field(default_factory=list) def list_tags_with_usage( @@ -263,26 +48,18 @@ def list_tags_with_usage( offset: int = 0, include_zero: bool = True, order: str = "count_desc", - owner_id: str = "", -) -> tuple[list[tuple[str, str, int]], int]: +) -> tuple[list[tuple[str, int]], int]: prefix_filter = prefix.strip() if prefix else "" counts_sq = ( select( - AssetReferenceTag.tag_name.label("tag_name"), - func.count(AssetReferenceTag.asset_reference_id).label("cnt"), - ) - .select_from(AssetReferenceTag) - .join(AssetReference, AssetReference.id == AssetReferenceTag.asset_reference_id) - .where(build_visible_owner_clause(owner_id)) - .where( - sa.or_( - AssetReference.is_missing == False, # noqa: E712 - AssetReferenceTag.tag_name == "missing", - ) + AssetTag.tag_name.label("tag_name"), + func.count(AssetTag.asset_id).label("cnt"), ) - .where(AssetReference.deleted_at.is_(None)) - .group_by(AssetReferenceTag.tag_name) + .select_from(AssetTag) + .join(Asset, Asset.id == AssetTag.asset_id) + .join(AssetContent, Asset.content_id == AssetContent.id) + .group_by(AssetTag.tag_name) .subquery() ) @@ -311,17 +88,10 @@ def list_tags_with_usage( total_q = total_q.where(func.substr(Tag.name, 1, len(prefix_filter)) == prefix_filter) if not include_zero: visible_tags_sq = ( - select(AssetReferenceTag.tag_name) - .join(AssetReference, AssetReference.id == AssetReferenceTag.asset_reference_id) - .where(build_visible_owner_clause(owner_id)) - .where( - sa.or_( - AssetReference.is_missing == False, # noqa: E712 - AssetReferenceTag.tag_name == "missing", - ) - ) - .where(AssetReference.deleted_at.is_(None)) - .group_by(AssetReferenceTag.tag_name) + select(AssetTag.tag_name) + .join(Asset, Asset.id == AssetTag.asset_id) + .join(AssetContent, Asset.content_id == AssetContent.id) + .group_by(AssetTag.tag_name) ) total_q = total_q.where(Tag.name.in_(visible_tags_sq)) @@ -334,84 +104,48 @@ def list_tags_with_usage( def list_tag_counts_for_filtered_assets( session: Session, - owner_id: str = "", include_tags: Sequence[str] | None = None, exclude_tags: Sequence[str] | None = None, name_contains: str | None = None, - metadata_filter: dict | None = None, limit: int = 100, # Appended last so pre-existing positional callers keep binding correctly. any_tags: Sequence[str] | None = None, ) -> dict[str, int]: - """Return tag counts for assets matching the given filters. + """Return {tag_name: count} for the assets matching the given filters. - Uses the same filtering logic as list_references_page but returns - {tag_name: count} instead of paginated references. + Reuses build_record_tag_filter_clauses and the same Asset->AssetContent inner + join as list_records_page, so /api/assets and /api/assets/tags/refine agree on + which assets a given all/any/none + name_contains filter selects — including + missing-content records, which stay catalog-visible. """ - # Build a subquery of matching reference IDs - ref_sq = ( - select(AssetReference.id) - .join(Asset, Asset.id == AssetReference.asset_id) - .where(build_visible_owner_clause(owner_id)) - .where(AssetReference.is_missing == False) # noqa: E712 - .where(AssetReference.deleted_at.is_(None)) + filters = list( + build_record_tag_filter_clauses( + tuple(include_tags or ()), + tuple(any_tags or ()), + tuple(exclude_tags or ()), + ) ) - if name_contains: escaped, esc = escape_sql_like_string(name_contains) - ref_sq = ref_sq.where(AssetReference.name.ilike(f"%{escaped}%", escape=esc)) + filters.append(Asset.name.ilike(f"%{escaped}%", escape=esc)) - ref_sq = apply_tag_filters(ref_sq, include_tags, exclude_tags, any_tags) - ref_sq = apply_metadata_filter(ref_sq, metadata_filter) - ref_sq = ref_sq.subquery() + asset_sq = ( + select(Asset.id) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(*filters) + .subquery() + ) - # Count tags across those references q = ( select( - AssetReferenceTag.tag_name, - func.count(AssetReferenceTag.asset_reference_id).label("cnt"), + AssetTag.tag_name, + func.count(AssetTag.asset_id).label("cnt"), ) - .where(AssetReferenceTag.asset_reference_id.in_(select(ref_sq.c.id))) - .group_by(AssetReferenceTag.tag_name) - .order_by(func.count(AssetReferenceTag.asset_reference_id).desc(), AssetReferenceTag.tag_name.asc()) + .where(AssetTag.asset_id.in_(select(asset_sq.c.id))) + .group_by(AssetTag.tag_name) + .order_by(func.count(AssetTag.asset_id).desc(), AssetTag.tag_name.asc()) .limit(limit) ) rows = session.execute(q).all() return {tag_name: int(cnt) for tag_name, cnt in rows} - - -def bulk_insert_tags_and_meta( - session: Session, - tag_rows: list[dict], - meta_rows: list[dict], -) -> None: - """Batch insert into asset_reference_tags and asset_reference_meta. - - Uses ON CONFLICT DO NOTHING. - - Args: - session: Database session - tag_rows: Dicts with: asset_reference_id, tag_name, origin, added_at - meta_rows: Dicts with: asset_reference_id, key, ordinal, val_* - """ - if tag_rows: - ins_tags = sqlite.insert(AssetReferenceTag).on_conflict_do_nothing( - index_elements=[ - AssetReferenceTag.asset_reference_id, - AssetReferenceTag.tag_name, - ] - ) - for chunk in iter_row_chunks(tag_rows, cols_per_row=4): - session.execute(ins_tags, chunk) - - if meta_rows: - ins_meta = sqlite.insert(AssetReferenceMeta).on_conflict_do_nothing( - index_elements=[ - AssetReferenceMeta.asset_reference_id, - AssetReferenceMeta.key, - AssetReferenceMeta.ordinal, - ] - ) - for chunk in iter_row_chunks(meta_rows, cols_per_row=7): - session.execute(ins_meta, chunk) diff --git a/app/assets/docs/README.md b/app/assets/docs/README.md new file mode 100644 index 00000000000..4cd131f8b07 --- /dev/null +++ b/app/assets/docs/README.md @@ -0,0 +1,342 @@ +# Assets + +The asset system is only active when explicitly enabled at startup; it is off by default. When it is not enabled, asset API routes return a disabled-service error and no background filesystem scanning occurs. Routes answer that same disabled-service error when the system is enabled but its database dependencies are unavailable. + +## Data model + +An asset record represents one user-visible entity. It owns its name, tags, `job_id`, `loader_path`, MIME type, extracted metadata, user metadata, and optional preview relationship. + +A content row represents bytes at a storage location. It owns the path, byte size, modification time, hash, and missing state. Multiple asset records may reference one content row. Two content rows may have the same hash; hash uniqueness is never a database invariant. + +The path is required and unique among content rows that are not missing. Missing rows keep their last known path so the scanner can attempt recovery. + +Names are labels and are not unique. Records are global; the local asset system has no per-user ownership boundary. + +MIME type and extracted metadata belong to the asset record because the same bytes may be interpreted in different ways. Extracted metadata may grow when an extractor learns more, but an extractor must not silently remove facts it already recorded within the same extractor version. + +`loader_path` is fixed when the record is created. It records how the registry classified the path at discovery time. + +## Tags + +Asset records carry two kinds of tags: user-applied tags added and removed through the tagging API, and system tags the backend derives on its own. + +A location tag records which storage root a file sits under: `input`, `output`, or `temp` for files under those directories, and `models` for files under any configured model base directory. A file under a models base additionally gets one `model_type:` tag per model category whose base directory contains the file and whose registered extension set accepts the file's extension; a file whose extension matches no category still gets the `models` tag but no `model_type:` tag. Model-type tags come from the registered category names, not from path components. + +On upload, tags choose the write destination. A request must carry exactly one destination role tag (`input`, `models`, or `output`), and a `models` upload must carry exactly one `model_type:` tag naming the category folder to write into. Any other tags in the request land on the created record as ordinary tags. + +`missing` is the one system tag the tagging API refuses to add or remove; it is projected onto records whose content is absent, as described in Missing content. Location and model-type tags carry no such protection and can be added or removed like any other tag. + +## Missing content + +Every asset whose content is absent stays visible and carries the client-visible `missing` tag (see Tags). Missing state belongs to the content row, so all records that reference the same content become missing together. While content is missing, it cannot be downloaded or resolved by hash: attempts to fetch it or to look it up through from-hash resolution fail rather than silently succeeding. + +A content row moves through these observable states: + +```mermaid +stateDiagram-v2 + LiveUnhashed: live, hash null + LiveHashed: live, hashed + MissingUnhashed: missing, hash null + MissingHashed: missing, hashed + + [*] --> LiveUnhashed: scan discovery or output registration + [*] --> LiveHashed: upload (always hashed) + + LiveUnhashed --> LiveHashed: hash computed (hashing on) + LiveHashed --> LiveUnhashed: mtime-only change (hashing off) + + LiveUnhashed --> MissingUnhashed: file gone + LiveHashed --> MissingHashed: file gone + + MissingHashed --> LiveHashed: hash match at path (hashing on) + MissingUnhashed --> LiveHashed: size and mtime match at path (hashing on) +``` + +A reappeared file that matches no missing candidate, or more than one, does not recover any of them. The scanner creates a new content row for the file instead, and the missing candidates stay missing. The same old-missing-plus-new-content shape applies to a same-path edit or reuse: the old row is marked missing and a separate new row and record are created for the new bytes, never transformed in place. + +Only a hash can recover a missing content row, with one narrow exception for rows that were never hashed. The scanner hashes the file now present at the missing path and recovers the row only when exactly one missing candidate for that path has the same hash. If no candidate matches, the scanner creates new content. If multiple candidates match, none recover. Recovery also never fires for a path a live content row already occupies: the file there belongs to that row, and ordinary change detection owns it. + +A missing row can have a null hash, for example a file deleted before it was ever hashed while every server was down during an off-to-on hashing transition. Such a row can never satisfy the hash comparison, so it has its own narrower recovery condition: it recovers only when it is the single missing null-hash candidate at that path, and its recorded byte size and modification time exactly match the reappeared file's verified stat. The freshly computed hash is set on the row as part of recovery. The stat facts are the only recorded identity a never-hashed row has, so a same-path reappearance that does not preserve them creates new content instead, and the old row stays missing rather than risk recovering the wrong record. + +Hash-based recovery does not compare modification times; only the null-hash recovery described above requires the recorded byte size and modification time to match exactly. With hashing disabled, recovery is unavailable. + +Missing rows and their asset records persist until explicitly deleted. Routine scans do not remove them. + +## Hashing modes + +The `--enable-asset-hashing` flag defaults to off. It controls whether the scanner and output pipeline hash file contents, as described below. + +With hashing off, the scanner uses modification time and byte size to detect changes. A modification-time change with an unchanged byte size is treated as the same file: the stored file facts refresh and the stored hash is cleared, because without hashing the digest can no longer be vouched for. A change to both modification time and size means new content. A size change without a modification-time change is undefined behaviour. + +With hashing on, the scanner uses modification time as the cheap change detector and a hash as the identity check. When the modification time changes, verification runs through the background seeding and enrichment work: + +- If the hash is unchanged, refresh the stored file facts on the existing content row. +- If the hash changed, mark the old content missing and create a new content row and asset record for the path. + +The system persists the previous hashing mode. On a transition from off to on, it hashes every live row without a hash and revalidates every live row that already has one. A file that cannot be read after a bounded number of attempts keeps its record live with its stored hash cleared, and the transition completes without it. On a transition from on to off, it retains existing hashes as inert data. + +Hashing must use a stable file snapshot. The worker checks file facts before and after hashing and accepts the digest only when they still match each other. Otherwise it retries later. + +### Uploads hash independently of the scanner flag + +Upload dedup runs unconditionally in both hashing modes; only scanner and output hashing are gated by `--enable-asset-hashing`. Uploads always hash, use content-addressed filenames in hash-routed destinations, and deduplicate by bytes even while background hashing is off. Deduplication must know the digest before deciding whether to store the bytes, and the upload request is already reading the whole file. + +From-hash lookup is disabled while hashing is off. + +## Filesystem changes + +### File removed outside the API + +Mark the content missing and keep every asset record visible. The usual hash-based recovery applies if a file later appears at the same path. + +### File edited in place + +In hash mode, verify the new bytes. Refresh the existing content row if the hash is unchanged. If the hash differs, mark the old content missing and create new content and a new asset record for the path. + +With hashing off, a change to both modification time and size is handled the same way as a hash difference: the old content is marked missing and new content and a new asset record are created. A modification-time change alone refreshes the existing row's file facts and clears its stored hash (see Hashing modes). + +An asset record never changes from one byte identity to another. Existing history references therefore continue to point to the old, already-missing content instead of silently serving new bytes. + +### Deleted path reused + +Path reuse has the same final state as an in-place edit: the old content is missing and the new bytes receive new content and a new asset record. The result must not depend on whether a scan observed the deletion before the new file appeared. + +### File moved or renamed outside the API + +There is no filesystem move identity. Mark the old path missing and create new content and a new asset record at the new path. + +### Partial download under its final filename + +The scanner skips known partial-download extensions. For other files, it records file facts during the walk, waits once per scan pass for a short stability floor, and checks the facts again before inserting. + +Files that change between those checks have not finished being written and are not admitted. Such a file is parked on a bounded, de-duplicated watch list. The watch list is rechecked more than once within a single scan pass: once during the fast walk phase and again during the enrichment phase. Each parked entry is retried a limited number of times before it is dropped; a dropped file is admitted normally whenever a later scan observes it in a stable state. + +The watch list holds at most one entry per path and has a fixed maximum size. On overflow the oldest entry is evicted. Eviction is not data loss: an evicted path is rediscovered by ordinary directory traversal on any subsequent scan. + +Retry is bounded by scan cadence, not by a timer. There is no background poller, and settlement is not guaranteed within any particular interval. A completed prompt queues an enrichment-only pass rather than a full filesystem walk, and that pass rechecks the watch list, so a partially-written output is normally admitted soon after the generation that produced it. If a stalled partial file passes the check and later resumes, normal change detection splits it from the prematurely admitted content. + +### Symlinks and hardlinks + +The scanner follows symlinks, stores lexical paths, and applies an inode cycle guard. Different paths produce different content rows even when they resolve to the same inode or bytes. + +### Case and Unicode path forms + +Store absolute, structurally normalised paths: relative segments and repeated separators collapse at the write boundary, so every stored path is the lexical absolute form. Never canonicalize case or Unicode; compare byte-for-byte. Filesystems that treat two case or Unicode spellings as the same path may therefore produce duplicate rows. + +### Registry changes + +Classification is fixed at record creation. A newly visible path receives the registry classification in effect when discovered. A path that leaves all registered prefixes becomes missing. An existing in-scope path is not reclassified when the registry changes. + +## Asset operations + +### Delete through the API + +Delete the target asset record. Leave its content row and file intact. Do not soft-delete the record or revive the deleted identity during later discovery. The retained content row is not a tombstone: it is ordinary live content describing bytes that are still there, and nothing records that a deletion happened. + +Deleting a record never deletes any other record. A preview record the deleted asset nominated stays untouched; references to a preview clear only when the preview record itself is deleted. The preview reference points from the deleted record to its target, so deleting the pointer must not destroy the target. + +Content left behind after all its asset records are deleted can still be resolved by hash lookup, falling back to a generic name and a guessed content type when no record is left to supply one. There is currently no mechanism that reclaims or removes such orphaned content. + +That retained row is also what makes the deletion durable. A scan seeds only paths with no live content row, so an unchanged file is never given a new record however often it is rediscovered. Only new content at that path produces a fresh asset record: bytes that changed and retired the old row, or content minted after the old row itself is gone. Such a record describes the new content and must never recreate the deleted identity. + +### Rename + +Renames always succeed. Duplicate names are allowed. Renaming does not change tags, classification, content identity, or storage path. + +### Upload the same bytes with the same name + +Every upload mints a new asset record for its own request. The request's tags, user metadata, and preview nomination always land on that new record, whether or not identical bytes were already uploaded under the same name. This applies to every upload endpoint, including `/upload/image`. + +Content, not records, gets deduplicated. When qualifying content already holds these bytes (live, stat-consistent, and not temporary content), the new record points at that existing content row instead of the bytes being written again. When no content qualifies, the upload writes new content as normal. Either way, a new record is always created. + +Uploads are not idempotent: a client that retries an upload after a timeout accumulates a second record rather than being handed back the first. Core has no idempotency protection anywhere, including prompt submission and deletion, and this matches the shape of cached output: a cache hit reuses content but still mints a new delivery record for the new caller. + +Uploads hash in both hashing modes, so this content-level dedup applies regardless of the scanner hashing flag. + +### `updated_at` semantics + +An asset record's `updated_at` reflects only the last explicit user or API edit to that record: a rename, a user-metadata update, a MIME-type change, a preview nomination, or a manual tag add or remove. + +It never advances for serving or downloading the asset (access time is a separate concern from edit time), for scanner enrichment filling in extracted metadata, for the automatic missing or recovered tag projection, for a content split or content retire, or for the preview-deleted foreign-key cascade that clears a `preview_id`. Reading or downloading an asset's content instead updates a separate last-access marker on the record or records involved; that marker only ever moves forward, never backward. Minting a new record, whether from an upload, a cached rerun, or a content split, does not touch any other record's `updated_at`. The new record carries its own fresh timestamp from creation, and every existing record's last-explicit-edit time stays untouched. + +### Upload the same bytes with a different name + +When byte matching is available, create a new asset record with the requested name and point it to the existing content row, wherever in the asset store that content lives. Do not write the bytes a second time. Without byte matching, create a new content row and write the upload normally. + +The dedicated `/upload/image` endpoint scopes its byte-matching check to the same destination path only: it does not reuse content stored under a different path even when the hash matches, and writes a new copy there instead. + +### Upload different bytes with the same name + +Create a new asset record and new content. Both records keep the shared `name`. This is distinct from `display_name`, which is computed from the content's stored path and is not guaranteed to match `name` when content is stored under a hash-derived filename. + +### Byte-identical generated output + +Every non-cached save event creates a new asset record and a new content row. Do not merge generated outputs automatically. Once hashed, equal hashes make the byte relationship visible without changing either identity. + +### Cached output + +A fully cached rerun does not execute the save node or write a file. The asset layer creates a new asset record for the new prompt and points it to the existing content row for the cached file. It does not mutate the earlier asset record or content row. + +The new record's extracted metadata is copied from the earliest existing record for that same content, ordered by creation, rather than re-extracted. When no earlier record exists for that content, metadata is extracted fresh. + +If the cache is invalidated or unavailable, the save node executes normally. It writes a new counter-named file, and the same rule as ordinary generated output creates new content and a new asset record. + +#### Runtime-expanded cached output + +Whenever final history contains a runtime-expanded output locator, a child absent from the final executed-node set is treated exactly like any other cached output. It does not write a file. The asset layer creates a new delivery record that points to the existing content, and it does not mark earlier content or records missing or mutate them. + +Execution must never be inferred from omission in a pre-execution cache announcement. A fully cached wrapper that produces no child locator creates no asset-registration event. + +#### Registration happens at the point of the write + +The asset layer registers a produced output at the moment the producing node finishes, not after the prompt completes. Classification is determined by control flow, never by comparing final history against a set of executed nodes: + +- A node that reaches output processing has, by definition, executed. Cache hits return earlier and never reach it. Such an output is EXECUTED. +- An output delivered through the cached-UI path is, by definition, CACHED. + +For an EXECUTED output the asset layer creates a new content row for the path, marks any existing live content row at that path missing, creates a new asset record carrying the current prompt's `job_id`, and returns that record's identifier to the caller. + +For a CACHED output the asset layer creates a new asset record pointing at the existing content row with the new prompt's `job_id`, and mutates nothing else. The same holds for a fully cached wrapper that produces no child locator: no locator, no registration event. + +Classifying or registering an output never requires a hash. Every non-cached save always creates new content regardless of whether the bytes changed, so classification never depends on comparing digests. + +An identifier returned to a caller must refer to the record created for this write, never the identifier of a record that describes earlier bytes at the same path. + +Registration failure must not fail a generation. The failure is logged, the output entry carries no asset identifier, and cleanup is attempted so that a partially-written asset row does not normally survive. + +#### Emission and output identity + +Both output paths register at the moment the output is emitted, the moment it becomes visible to anything outside the executor. Emission, not sending, is the boundary: + +- The executed path emits when the producing node finishes processing its output. +- The cached path emits when a cached output is served. + +At each emission the asset layer creates the appropriate record, and the resulting identifier is attached to the emitted output. Registration and publication to history occur unconditionally; only transmission to a connected client is conditional. An output must be registered whether or not a client is attached, so no registration may sit behind a client-connection check. + +The database is the source of truth for output identity. For a fresh prompt, identity is always resolved through registration at emission, not by pulling a value back out of the cache. The cache mechanics used for subgraph replay can carry a previously-registered identifier forward as part of replaying that subgraph's own prior output; replay logic strips that identifier before treating the value as input to a fresh registration. A cached replay therefore reports the identifier of the delivery record created for the current prompt, never an identifier retained from the prompt that first produced the file. + +It follows that no component reconstructs output classification after the fact by comparing final history against a record of which nodes executed. Classification is determined once, at emission, and that is the only source of truth for it. + +### Settled properties and the eventually-consistent hash + +Nearly every asset property that a client can observe is settled before that asset becomes observable, with two narrow exceptions described below: the content hash, and a scanner-discovered asset's metadata during its exception window. + +A client reading an asset through the assets API never sees a property that is absent merely because work has not finished yet. Size, modification time, MIME type, extracted metadata, `job_id`, tags, preview location, and missing-state are all determined at the moment the record is created and are correct from the record's first observable instant. + +The content hash is the sole eventually-consistent field. It is the only property whose cost scales with file size, so it is the only one permitted to be absent on a record a client can already see. A null hash means "not yet computed", never "this content has no hash". + +Capabilities that require a hash (from-hash lookup, upload deduplication, and recovery of missing content) are unavailable for an asset whose hash has not yet settled. This is the same reduced capability described for disabled hashing. Settlement has no guaranteed deadline: a hash can remain unset indefinitely if the file never stabilizes or enrichment cannot make progress. It covers freshly-registered outputs even in hashing-on mode, not only the disabled-hashing case. + +#### Exception: scanner-discovered assets + +Assets discovered by the filesystem scanner are the one exception to full settlement: a seeded record can be observable before its metadata has settled. + +Distinguishing "not yet enriched" from "enrichment ran and found nothing" matters here: without that distinction, excluding un-enriched records from view would hide, permanently, any asset whose extraction fails. Outputs and uploads are not covered by this exception; their properties are always settled when observable. + +#### Output registration never hashes on the save path + +Registering a produced output computes no content hash. The record is created with a null hash unconditionally, and the background enrichment pass fills it afterwards when hashing is enabled. When hashing is disabled the hash stays null, consistent with hashing being off. + +Hashing reads every byte of the file and its cost scales without bound with file size, while the save path sits inside the execution loop. No output size may add hashing latency to a generation. No hashing failure may surface on the save path either; a failed or unstable read is the enrichment pass's problem, handled later. + +Uploads are outside this behaviour and hash inline, always: deduplication must know the digest before deciding whether to store the bytes, and the upload request is already reading the whole file. + +#### Two producers writing one path in a single prompt + +Two output producers that resolve to the same path within one prompt are unsupported. The resulting database state is undefined and nothing here constrains it. + +Registration occurs per producer at emission, so a prompt containing such a collision produces one record per producer, and their relative order determines the final state. That order is not a contract. + +Each producer's registration attempt is independent and best-effort, consistent with the failure handling described for output registration: a failed attempt is caught and that producer's output proceeds without an asset identifier rather than failing the generation. Only the combined outcome across producers is undefined. + +Save nodes assign counter-based filenames precisely so that concurrent producers do not collide, so a workflow reaching this state has bypassed the normal naming path. + +### Server restart + +Persist asset and content rows across restarts. In-memory history is transient. A record may retain a `job_id` whose prompt is no longer present in history. + +In-memory tracking used during scanning, such as the unstable-file watch list and the hash-transition queue, does not persist across a restart either. A fresh process rebuilds whatever state it needs by re-observing the filesystem and database rather than resuming exactly where a prior process left off. + +Temp records are the exception to row persistence (see Temp and preview output). + +### Temp and preview output + +Startup temp cleanup deletes both temp files and the records and content rows that represent them. Two failure modes are possible: if wiping the temp rows from the database fails, filesystem cleanup for that startup is skipped entirely and neither side is committed; if the database wipe succeeds but the subsequent filesystem removal fails, the rows are already gone from the database while the files remain on disk. + +Cloud temp and preview assets expire through a separate mechanism. + +### Temp content shared by permanent records + +Expiry belongs to the content location. Upload dedup and from-hash lookup exclude temp content, so permanent records can never point to content that startup temp cleanup will remove. + +### Same model bytes in two category locations + +Create one asset record and one content row per location. Equal hashes may reveal that the bytes match. Do not merge the locations. + +### Byte-identical content from two local users + +The local asset system has global records and no owner field. It does not isolate or duplicate records by user. + +### `/view` routes + +`/view` accepts two query forms: a path-based form (`type`, `filename`, `subfolder`) and a blake3-hash form (`filename=blake3:`). `/api/assets/{id}/content` accepts an asset id and serves that asset's content directly. The blake3 form resolves only to non-temp content whose file is currently present (see Lookup and dedup against missing content). + +### Lookup and dedup against missing content + +Hash lookup, from-hash creation, and upload dedup consider only non-temp content whose file is present. A database row is not proof that bytes can be served. + +When no qualifying content exists, uploads store the bytes they received and from-hash creation refuses. A missing sibling is never substituted for requested content. + +### From-hash tie-breaking + +When several qualifying content rows have the requested hash, choose the oldest by `created_at`, then by lexicographic `id`. + +## Jobs and provenance + +### Querying a job's outputs + +The asset API does not provide a job-to-outputs query. A record's `job_id` is informational. In-memory history is the mechanism for showing a run's outputs during that session. + +Cached reruns create a new asset record whose `job_id` is the new prompt. The record points to the existing content row because the save node did not execute and no new file was written. Earlier asset records keep their original `job_id` values. + +### Recording where a file came from + +Each asset record's `job_id` identifies the prompt associated with that record's own creation event. The system does not infer provenance across records that share content. + +Embedded PNG metadata is independent of the asset database. Save code writes it into the file, and the frontend may read it to restore a workflow. + +## Concurrency and failure boundaries + +### File changes during hashing + +Accept a digest only from a stable snapshot (see Hashing modes). + +### Concurrent writers + +Database constraints choose the winner when scanners, hooks, or uploads race within one process. Losing writers retry or discard their work. Tag and tag-link inserts run inside a savepoint and re-read the row they collided with, so a duplicate-key collision between concurrent tag writers settles quietly instead of surfacing an error. That covers duplicate keys only; lock contention is a separate limit (see Write pressure and reader starvation). + +A file lock prevents more than one server process from opening the same database, so a second process never becomes a concurrent writer in the first place. Do not add advisory locks. + +### Ambiguous recovery + +If more than one missing content row matches a recreated file's hash, recover none of them. + +### Database replacement while running + +Deleting or replacing the database file while the server is running is undefined behaviour. + +## Operational limits + +### Write pressure and reader starvation + +The asset database is SQLite with a single database-wide writer lock and no configured busy timeout or lock-error handling on any route. Several paths hold or contend for that lock: a non-deduplicated upload writes its bytes and mints a delivery record, while a deduplicated upload reuses existing content and mints only the record; a same-path write whose hash has changed retires the old content and inserts new content, while a same-path write whose hash matches refreshes the existing record in place; execution outputs register per-emission during the generation loop; a background enrichment pass fills hashes and metadata row by row; hash-serves write access time to every record sharing the served content; and the upload dedup claim holds the write lock across its filesystem re-check and metadata extraction. + +Under sustained concurrent writes, a reader such as `GET /api/assets` can exceed SQLite's default five-second busy wait and surface an unhandled `database is locked` error as HTTP 500. The failure is transient and non-corrupting: no rows are corrupted, and a later request may succeed once the write pressure eases, though nothing retries or backs off automatically. No busy timeout, lock-error translation to 503, or WAL journal mode is configured. + +## Schema migration + +When startup finds the schema that predates the record/content split, it drops and recreates the affected tables inside the existing database file, then rebuilds them with a full scan. Rows are not migrated into the new schema, and the database file itself is not deleted. The database is backed up to a sibling file before the migration runs; if the migration fails, the database is reverted from that backup, and if it succeeds, the backup file is left in place rather than deleted. + +Scanning runs in the background after startup returns, so the asset API can already be serving requests while the rebuilt tables are still being populated. + +The rebuild discards data that a scan cannot reconstruct: manual tags, user metadata, preview assignments, API-created records, `job_id` links, any record renames, and deletions — nothing records which assets were deleted, so the scan mints a record for every file still on disk. When the rebuild runs, startup logs a warning naming the backup file and what was discarded. diff --git a/app/assets/event_log.py b/app/assets/event_log.py new file mode 100644 index 00000000000..e8962a3ad46 --- /dev/null +++ b/app/assets/event_log.py @@ -0,0 +1,163 @@ +"""Structured event log lines for the assets system. + +Every line is ``[assets-event] key=value ...`` on the standard logging +INFO channel, with fields sorted by name and omitted when an event has none. This +mirrors the ``assets.seed.*`` events the seeder already puts on the PromptServer +bus. A log-tailing launcher can pick assets health signals out of core's output +without parsing prose, and the existing human-readable lines stay exactly as +they are. + +The field vocabulary is closed. Only the names in :data:`ALLOWED_FIELDS` may be +carried, each has a validator, and no string value may contain a path separator +or logfmt delimiter — so file names, paths, asset ids and content hashes cannot +ride along. +""" + +import logging +import os +import traceback +from collections.abc import Callable +from typing import Any + +TAG = "[assets-event]" + +MAX_STRING_LENGTH = 64 +FORBIDDEN_STRING_CHARS = ("/", "\\", ":", " ", "=", '"') + +ROOTS = frozenset({"models", "input", "output", "user", "temp"}) +PHASES = frozenset({"fast", "enrich", "full"}) +STAGES = frozenset({"mark_missing", "pruning", "fast_scan", "enrich", "finalize"}) +STAT_SITES = frozenset({"discovery", "enrich"}) +ALLOWED_EVENTS = frozenset({ + "assets.enabled", + "seeder.scan_started", + "seeder.scan_completed", + "seeder.scan_failed", + "seeder.scan_cancelled", + "seeder.marked_missing", + "seeder.batch_insert_failed", + "scanner.hash_failed", + "scanner.enrich_failed", + "scanner.hash_discarded_modified", + "scanner.fast_scan_failed", + "scanner.temp_sync_failed", + "scanner.mark_missing_failed", + "scanner.stat_failed", +}) + + +class EventLogError(ValueError): + """An emit() call that would break the closed event vocabulary.""" + + +def _is_safe_string(value: Any) -> bool: + return ( + isinstance(value, str) + and 0 < len(value) <= MAX_STRING_LENGTH + and not any(char in value for char in FORBIDDEN_STRING_CHARS) + ) + + +def _one_of(allowed: frozenset[str]) -> Callable[[Any], bool]: + def validate(value: Any) -> bool: + return _is_safe_string(value) and value in allowed + + return validate + + +def _is_count(value: Any) -> bool: + # bool subclasses int, so it has to be excluded before the int check. + return isinstance(value, int) and not isinstance(value, bool) + + +def _is_flag(value: Any) -> bool: + return isinstance(value, bool) + + +ALLOWED_FIELDS: dict[str, Callable[[Any], bool]] = { + "root": _one_of(ROOTS), + "phase": _one_of(PHASES), + "stage": _one_of(STAGES), + "elapsed_ms": _is_count, + "created": _is_count, + "enriched": _is_count, + "skipped": _is_count, + "hash_failed": _is_count, + "enrich_failed": _is_count, + "permission_denied": _is_count, + "count": _is_count, + "error_type": _is_safe_string, + "hashing_enabled": _is_flag, + "site": _one_of(STAT_SITES), +} + +_warned_call_sites: set[tuple[str, int]] = set() + + +def _find_problem(event: Any, fields: dict[str, Any]) -> str | None: + if not isinstance(event, str) or event not in ALLOWED_EVENTS: + return "invalid event name" + for name, value in fields.items(): + validate = ALLOWED_FIELDS.get(name) + if validate is None: + return f"field {name!r} is not in the allowed vocabulary" + if not validate(value): + return f"field {name!r} has a value its validator rejected" + return None + + +def _strict_mode() -> bool: + return ( + "PYTEST_CURRENT_TEST" in os.environ + or os.environ.get("COMFYUI_ASSETS_EVENT_LOG_STRICT") == "1" + ) + + +def _caller_call_site() -> tuple[str, int]: + """Identify emit()'s caller so a bad call site warns at most once.""" + caller = traceback.extract_stack(limit=3)[0] + return (caller.filename, caller.lineno or 0) + + +def emit(event: str, *, root: str | None = None, **fields: Any) -> None: + """Log one tagged event line. + + An invalid call raises in strict mode (under pytest, or with + COMFYUI_ASSETS_EVENT_LOG_STRICT=1) so a bad call site fails the test suite. + In production it warns once per call site and drops the event, so a + vocabulary mistake can never break a running server. + """ + if root is not None: + fields["root"] = root + + problem = _find_problem(event, fields) + if problem is None: + pairs = " ".join( + f"{name}={str(value).lower() if isinstance(value, bool) else value}" + for name, value in sorted(fields.items()) + ) + line = f"{TAG} {event}" + (f" {pairs}" if pairs else "") + logging.info("%s", line) + return + + if _strict_mode(): + raise EventLogError(problem) + + call_site = _caller_call_site() + if call_site not in _warned_call_sites: + _warned_call_sites.add(call_site) + logging.warning( + "Dropped an invalid assets event at %s:%d: %s", + call_site[0], + call_site[1], + problem, + ) + + +def error_type(exc: BaseException) -> str: + """The only sanctioned description of an exception: its class name. + + Stringifying the exception itself is banned here, because FileNotFoundError + and friends embed the path that triggered them. + """ + return type(exc).__name__ diff --git a/app/assets/helpers.py b/app/assets/helpers.py index 87734d0dcb8..b65eb3b78fd 100644 --- a/app/assets/helpers.py +++ b/app/assets/helpers.py @@ -1,26 +1,45 @@ +"""Small conversions the asset system needs in more than one layer: canonical +stored-hash strings, UTC timestamps, tag normalization, and a SQL predicate for +path containment. That predicate is deliberately case-sensitive and +component-bounded, because callers use it to choose rows for hard deletion. +""" + import os from datetime import datetime, timezone -from typing import Sequence +import sqlalchemy as sa +from sqlalchemy.sql import ColumnElement -def select_best_live_path(states: Sequence) -> str: - """ - Return the best on-disk path among cache states: - 1) Prefer a path that exists with needs_verify == False (already verified). - 2) Otherwise, pick the first path that exists. - 3) Otherwise return empty string. + +def sql_path_under_prefix( + column: ColumnElement[str], prefix: str +) -> ColumnElement[bool]: + """SQL predicate for ``Path(column).is_relative_to(prefix)`` on this platform. + + Case-SENSITIVE and component-bounded: prefix ``/a/b`` matches ``/a/b`` and + ``/a/b/c`` but not ``/a/bc``, ``/a/b-other`` or ``/a/B/c``. + + ``LIKE`` cannot express this. SQLite's ``LIKE`` is ASCII case-insensitive by + default, so ``'/data/TEMP/f' LIKE '/data/temp/%'`` is TRUE — which let the + temp wipe hard-delete records under a case-different persistent directory, + and let the enrichment scan mutate rows outside the requested root. + ``GLOB`` is case-sensitive but carries its own metacharacters (``*``, ``?``, + ``[``) with no ESCAPE clause, so every caller would need bracket-quoting. + ``substr(column, 1, n) = `` compares under the column's BINARY + collation and has no metacharacters at all, so a path containing ``%``, + ``_``, ``*``, ``?`` or ``[`` needs no escaping and cannot inject. + + Only the PREFIX is normalized here. That is sound because the column holds + normalized absolute paths — ``records.create_content`` is the sole writer + and normalizes there. Normalizing the column in SQL is not an option anyway: + it would need a per-row Python call and would defeat the index. """ - alive = [ - s - for s in states - if getattr(s, "file_path", None) and os.path.isfile(s.file_path) - ] - if not alive: - return "" - for s in alive: - if not getattr(s, "needs_verify", False): - return s.file_path - return alive[0].file_path + base = os.path.abspath(prefix) + stem = base if base.endswith(os.sep) else base + os.sep + return sa.or_( + column == base, + sa.func.substr(column, 1, len(stem)) == stem, + ) def escape_sql_like_string(s: str, escape: str = "!") -> tuple[str, str]: @@ -47,6 +66,10 @@ def normalize_tags(tags: list[str] | None) -> list[str]: return list(dict.fromkeys(t.strip() for t in (tags or []) if (t or "").strip())) +def to_stored_hash(digest: str) -> str: + return f"blake3:{digest}" + + def validate_blake3_hash(s: str) -> str: """Validate and normalize a blake3 hash string. diff --git a/app/assets/lifecycle.py b/app/assets/lifecycle.py new file mode 100644 index 00000000000..26ecd0370ae --- /dev/null +++ b/app/assets/lifecycle.py @@ -0,0 +1,152 @@ +"""Runs the asset system's startup and shutdown maintenance: clearing temp rows +and files, recording a hash-mode transition, and handing the filesystem scan to +the background seeder. Startup only enqueues transition work — draining it +belongs to the seeder, so a large library cannot stall the server before it +accepts requests. It also settles, before any of that, whether the database +dependencies exist at all, disabling the asset routes when they do not. +""" + +from __future__ import annotations + +import logging +import os +import shutil + +import folder_paths +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import delete_record +from app.assets.helpers import sql_path_under_prefix +from app.assets.services.hash_mode_state import enqueue_transition_work +from app.assets.services.hash_mode_state import record_transition_intent +from app.database.db import can_create_session, create_session +from comfy.cli_args import args + +_excluded_scan_roots: set[str] = set() +_hash_mode_transition: str | None = None + + +def get_excluded_scan_roots() -> frozenset[str]: + return frozenset(_excluded_scan_roots) + + +def record_hash_mode_transition_intent() -> None: + global _hash_mode_transition + + with create_session() as session: + _hash_mode_transition = record_transition_intent(session) + session.commit() + + +def enqueue_mode_transition_work() -> None: + with create_session() as session: + enqueue_transition_work(session, _hash_mode_transition) + session.commit() + + +def wipe_temp_db_rows(session) -> tuple[int, int]: + try: + temp_root = os.path.abspath(folder_paths.get_temp_directory()) + except OSError: + return 0, 0 + # These rows are hard-deleted, so the predicate must stay case-SENSITIVE: admitting a + # case-different persistent directory destroys user assets. + under_temp = sql_path_under_prefix(AssetContent.path, temp_root) + + temp_record_ids = list( + session.scalars( + select(Asset.id) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(under_temp) + ) + ) + + records_deleted = 0 + for record_id in temp_record_ids: + delete_record(session, record_id) + records_deleted += 1 + + contents_deleted = 0 + for content in session.scalars(select(AssetContent).where(under_temp)).all(): + session.delete(content) + contents_deleted += 1 + + session.flush() + return records_deleted, contents_deleted + + +def cleanup_temp_filesystem() -> bool: + temp_dir = os.path.abspath(folder_paths.get_temp_directory()) + if not os.path.exists(temp_dir): + return True + try: + shutil.rmtree(temp_dir) + return True + except OSError as exc: + logging.warning( + "Failed to remove temp directory %s: %s — excluding from scan for this process", + temp_dir, + exc, + ) + _excluded_scan_roots.add(temp_dir) + return False + + +def start_asset_seeder() -> bool: + from app.assets.seeder import asset_seeder + + started = asset_seeder.start( + roots=("models", "input", "output"), + prune_first=True, + compute_hashes=args.enable_asset_hashing, + ) + if started: + logging.info("Background asset scan initiated for models, input, output") + return started + + +def run_asset_startup() -> None: + try: + with create_session() as session: + wipe_temp_db_rows(session) + session.commit() + except Exception: + logging.exception("Temp DB row wipe failed; skipping filesystem cleanup") + enqueue_mode_transition_work() + start_asset_seeder() + return + cleanup_temp_filesystem() + enqueue_mode_transition_work() + start_asset_seeder() + + +def run_startup(*, enable_assets: bool) -> None: + try: + if enable_assets: + run_asset_startup() + else: + cleanup_temp_filesystem() + except Exception: + logging.exception("Asset startup maintenance failed") + + +def run_asset_shutdown_cleanup() -> None: + try: + with create_session() as session: + wipe_temp_db_rows(session) + session.commit() + except Exception: + logging.exception("Temp DB row wipe failed during shutdown") + finally: + cleanup_temp_filesystem() + + +def run_shutdown() -> None: + try: + if can_create_session(): + run_asset_shutdown_cleanup() + else: + cleanup_temp_filesystem() + except Exception: + logging.exception("Asset shutdown cleanup failed") diff --git a/app/assets/manager.py b/app/assets/manager.py new file mode 100644 index 00000000000..590015d6ad9 --- /dev/null +++ b/app/assets/manager.py @@ -0,0 +1,227 @@ +import logging +from typing import Any, Callable, Protocol + +from aiohttp import web + +from app.assets import mode +from app.assets.api.routes import register_assets_routes +from app.assets.lifecycle import record_hash_mode_transition_intent, run_shutdown, run_startup +from app.assets.seeder import ScanPhase, asset_seeder +from app.assets.services.ingest import ( + register_cached_output as ingest_register_cached_output, + register_executed_output as ingest_register_executed_output, + register_file_in_place, +) +from app.assets.services.path_utils import get_known_subfolder_tags +from app.assets.services.schemas import RegisteredAsset, UploadAssetView +from app.database.db import dependencies_available +from app.user_manager import UserManager +from comfy.cli_args import args + + +class AssetManager(Protocol): + @property + def enabled(self) -> bool: ... + + def startup(self) -> None: ... + + def shutdown(self) -> None: ... + + def register_routes( + self, app: web.Application, user_manager: UserManager | None + ) -> None: ... + + def ensure_scan_started(self) -> None: ... + + def pause_background_scan(self) -> None: ... + + def queue_output_scan(self) -> None: ... + + def resume_background_scan(self) -> None: ... + + def register_upload( + self, + abs_path: str, + name: str, + upload_type: str, + subfolder: str, + *, + content_written: bool, + ) -> UploadAssetView | None: ... + + def register_executed_output( + self, abs_path: str, job_id: str | None + ) -> RegisteredAsset | None: ... + + def register_cached_output( + self, abs_path: str, job_id: str | None + ) -> RegisteredAsset | None: ... + + def set_event_sink(self, sink: Callable[[str, dict[str, Any]], None] | None) -> None: ... + + +class _ArgsLike(Protocol): + enable_assets: bool + enable_asset_hashing: bool + + +def _shutdown_assets() -> None: + asset_seeder.shutdown() + run_shutdown() + + +class NoAssets: + def __init__(self, args: _ArgsLike) -> None: + self._args = args + + @property + def enabled(self) -> bool: + return False + + def startup(self) -> None: + mode.init(self._args) + record_hash_mode_transition_intent() + run_startup(enable_assets=False) + + def shutdown(self) -> None: + _shutdown_assets() + + def register_routes( + self, app: web.Application, user_manager: UserManager | None + ) -> None: + register_assets_routes(app) + asset_seeder.disable() + + def ensure_scan_started(self) -> None: + return None + + def pause_background_scan(self) -> None: + return None + + def queue_output_scan(self) -> None: + return None + + def resume_background_scan(self) -> None: + return None + + def register_upload( + self, + abs_path: str, + name: str, + upload_type: str, + subfolder: str, + *, + content_written: bool, + ) -> UploadAssetView | None: + return None + + def register_executed_output( + self, abs_path: str, job_id: str | None + ) -> RegisteredAsset | None: + return None + + def register_cached_output( + self, abs_path: str, job_id: str | None + ) -> RegisteredAsset | None: + return None + + def set_event_sink(self, sink: Callable[[str, dict[str, Any]], None] | None) -> None: + return None + + +class AssetsEnabled: + def __init__(self, args: _ArgsLike) -> None: + self._args = args + + @property + def enabled(self) -> bool: + return True + + def startup(self) -> None: + mode.init(self._args) + record_hash_mode_transition_intent() + run_startup(enable_assets=True) + + def shutdown(self) -> None: + _shutdown_assets() + + def register_routes( + self, app: web.Application, user_manager: UserManager | None + ) -> None: + register_assets_routes(app, user_manager) + + def ensure_scan_started(self) -> None: + asset_seeder.start(roots=("models", "input", "output")) + + def pause_background_scan(self) -> None: + asset_seeder.pause() + + def queue_output_scan(self) -> None: + if not asset_seeder.is_disabled(): + # FULL, not ENRICH: only a walk finds outputs a node never declared. Do not downgrade without re-weighing the cost. + asset_seeder.enqueue_scan( + roots=("output",), + phase=ScanPhase.FULL, + compute_hashes=self._args.enable_asset_hashing, + ) + + def resume_background_scan(self) -> None: + asset_seeder.resume() + + def register_upload( + self, + abs_path: str, + name: str, + upload_type: str, + subfolder: str, + *, + content_written: bool, + ) -> UploadAssetView | None: + try: + tag = upload_type if upload_type in ("input", "output") else "input" + tags = [tag] + get_known_subfolder_tags(subfolder) + result = register_file_in_place( + abs_path=abs_path, + name=name, + tags=tags, + content_written=content_written, + ) + asset = RegisteredAsset( + id=result.ref.id, + content_id=result.content_id, + job_id=result.ref.job_id, + name=result.ref.name, + ) + return UploadAssetView( + asset=asset, + asset_hash=result.asset.hash, + size=result.asset.size_bytes, + mime_type=result.asset.mime_type, + tags=result.tags, + ) + except Exception: + logging.warning("Failed to register uploaded image as asset", exc_info=True) + return None + + def register_executed_output( + self, abs_path: str, job_id: str | None + ) -> RegisteredAsset | None: + return ingest_register_executed_output(abs_path, job_id) + + def register_cached_output( + self, abs_path: str, job_id: str | None + ) -> RegisteredAsset | None: + return ingest_register_cached_output(abs_path, job_id) + + def set_event_sink(self, sink: Callable[[str, dict[str, Any]], None] | None) -> None: + asset_seeder.set_event_sink(sink) + + +def default_asset_manager() -> AssetManager: + if args.enable_assets and not dependencies_available(): + logging.warning( + "Assets requested but database dependencies unavailable; asset endpoints " + "will answer 503. Please install the updated requirements.txt file." + ) + return NoAssets(args) + return AssetsEnabled(args) if args.enable_assets else NoAssets(args) diff --git a/app/assets/mode.py b/app/assets/mode.py new file mode 100644 index 00000000000..6c789cd8e5d --- /dev/null +++ b/app/assets/mode.py @@ -0,0 +1,30 @@ +"""Holds whether asset hashing is enabled for this process, taken from the +command-line flag once at startup. Callers ask here rather than reading +arguments themselves, and asking before initialization raises instead of +defaulting, so a route can never quietly answer as though hashing were off. +""" + +from __future__ import annotations + +from typing import Protocol + + +class _HashingArguments(Protocol): + enable_asset_hashing: bool + + +_args: _HashingArguments | None = None + + +def init(args: _HashingArguments) -> None: + global _args + _args = args + + +def hashing_enabled() -> bool: + if _args is None: + raise RuntimeError( + "app.assets.mode.init() was not called before hashing_enabled(); " + "hash-mode state is uninitialised" + ) + return bool(_args.enable_asset_hashing) diff --git a/app/assets/scanner.py b/app/assets/scanner.py index 0db7588de19..76821e4c337 100644 --- a/app/assets/scanner.py +++ b/app/assets/scanner.py @@ -1,64 +1,106 @@ +"""Walks the asset roots and turns what it finds into catalog rows: collecting +paths, building specs, seeding new content and records, then enriching them +with metadata and hashes. Each spec is seeded inside its own savepoint, so one +file whose row conflicts cannot discard the work done for the files around it. +Enrichment counts as progress only when it produced what was asked of it — a +requested hash that could not be computed is no progress, which is what bounds +a pass over a file the server cannot read. +""" + import logging import os +from dataclasses import dataclass from pathlib import Path -from typing import Callable, Literal, TypedDict +from typing import Callable, Literal, Protocol, TypedDict import folder_paths +import sqlalchemy as sa +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session +from app.assets import mode +from app.assets.event_log import emit, error_type from app.assets.database.queries import ( - add_missing_tag_for_asset_id, - bulk_update_enrichment_level, - bulk_update_is_missing, - bulk_update_needs_verify, - delete_orphaned_seed_asset, - delete_references_by_ids, - ensure_tags_exist, - get_asset_by_hash, - get_reference_by_id, - get_references_for_prefixes, - get_unenriched_references, - mark_references_missing_outside_prefixes, - reassign_asset_references, - remove_missing_tag_for_asset_id, - set_reference_system_metadata, - update_asset_hash_and_mime, + create_content_reporting_insert, + mark_content_missing, + create_record, ) -from app.assets.services.bulk_ingest import ( - SeedAssetSpec, - batch_insert_seed_assets, +from app.assets.database.models import Asset, AssetContent +from app.assets.helpers import sql_path_under_prefix, to_stored_hash +from app.assets.lifecycle import get_excluded_scan_roots +from app.assets.scanner_changes import ( + clear_pending_verifications, + detect_content_change, + drain_pending_verifications, + is_path_under_prefixes, + live_contents_under_prefixes, + pending_recovery_count, + recover_missing_content, ) -from app.assets.services.file_utils import ( - get_mtime_ns, - is_visible, - list_files_recursively, - verify_file_unchanged, +from app.assets.scanner_admission import ( + PARTIAL_DOWNLOAD_EXTENSIONS as PARTIAL_DOWNLOAD_EXTENSIONS, + _WATCH_LIST as _WATCH_LIST, + _WatchEntry as _WatchEntry, + _should_skip_extension, + _two_stat_admit, + tick_watch_list as tick_watch_list, ) -from app.assets.services.hashing import HashCheckpoint, compute_blake3_hash +from app.assets.services.file_utils import get_mtime_ns, is_visible, list_files_recursively from app.assets.services.image_dimensions import extract_image_dimensions -from app.assets.services.metadata_extract import extract_file_metadata +from app.assets.services.metadata_extract import ExtractedMetadata, extract_file_metadata from app.assets.services.path_utils import ( compute_loader_path, get_comfy_models_folders, get_name_and_tags_from_asset_path, ) +from app.assets.services.ingest import _discard_unreferenced_content +from app.assets.services.snapshot_hash import snapshot_hash from app.database.db import create_session +__all__ = [ + "clear_pending_verifications", + "drain_pending_verifications", + "pending_recovery_count", +] -class _RefInfo(TypedDict): - ref_id: str - file_path: str - exists: bool - stat_unchanged: bool - needs_verify: bool +# Temp is deliberately absent: it is wiped before every scan, so walking it finds nothing. +RootType = Literal["models", "input", "output"] -class _AssetAccumulator(TypedDict): - hash: str | None - size_db: int - refs: list[_RefInfo] +class _ScanProgress(Protocol): + hash_failed: int + enrich_failed: int + permission_denied: int + + def mark_emitted(self, key: str) -> bool: ... -# Temp is deliberately absent: it is wiped before every scan, so walking it finds nothing. -RootType = Literal["models", "input", "output"] + +class SeedAssetSpec(TypedDict): + + abs_path: str + # Walk-time diagnostics only: seeding persists the seed-time restat instead. + size_bytes: int + mtime_ns: int + info_name: str + tags: list[str] + fname: str | None + metadata: ExtractedMetadata | None + mime_type: str | None + job_id: str | None + + +@dataclass(frozen=True, slots=True) +class UnenrichedContent: + content_id: str + record_id: str + file_path: str + + +def _log_scan_error(phase: str, error: OSError) -> None: + error_type = ( + "permission_denied" if isinstance(error, PermissionError) else "os_error" + ) + logging.warning("Asset scan error: phase=%s error_type=%s", phase, error_type) def get_scan_prefixes_for_root(root: RootType) -> list[str]: @@ -82,7 +124,10 @@ def get_owned_prefixes() -> list[str]: def get_temp_prefixes() -> list[str]: - return [os.path.abspath(folder_paths.get_temp_directory())] + temp_dir = os.path.abspath(folder_paths.get_temp_directory()) + if temp_dir in get_excluded_scan_roots(): + return [] + return [temp_dir] def collect_models_files() -> list[str]: @@ -111,146 +156,59 @@ def sync_references_with_filesystem( session, root: RootType, collect_existing_paths: bool = False, - update_missing_tags: bool = False, + progress: _ScanProgress | None = None, ) -> set[str] | None: return sync_prefixes_with_filesystem( session, get_scan_prefixes_for_root(root), collect_existing_paths=collect_existing_paths, - update_missing_tags=update_missing_tags, + progress=progress, ) def sync_prefixes_with_filesystem( - session, + session: Session, prefixes: list[str], collect_existing_paths: bool = False, - update_missing_tags: bool = False, + progress: _ScanProgress | None = None, ) -> set[str] | None: - """Reconcile asset references with filesystem under the given prefixes. - - - Toggle needs_verify per reference using mtime/size stat check - - For hashed assets with at least one stat-unchanged ref: delete stale missing refs - - For seed assets with all refs missing: delete Asset and its references - - Optionally add/remove 'missing' tags based on stat check in this root - - Optionally return surviving absolute paths - - Args: - session: Database session - prefixes: Absolute directory prefixes whose references to reconcile - collect_existing_paths: If True, return set of surviving file paths - update_missing_tags: If True, update 'missing' tags based on file status - - Returns: - Set of surviving absolute paths if collect_existing_paths=True, else None - """ if not prefixes: return set() if collect_existing_paths else None - rows = get_references_for_prefixes( - session, prefixes, include_missing=update_missing_tags - ) - - by_asset: dict[str, _AssetAccumulator] = {} - for row in rows: - acc = by_asset.get(row.asset_id) - if acc is None: - acc = {"hash": row.asset_hash, "size_db": row.size_bytes, "refs": []} - by_asset[row.asset_id] = acc - - stat_unchanged = False + survivors: set[str] = set() + for content in live_contents_under_prefixes(session, prefixes): try: - exists = True - stat_unchanged = verify_file_unchanged( - mtime_db=row.mtime_ns, - size_db=acc["size_db"], - stat_result=os.stat(row.file_path, follow_symlinks=True), - ) + stat_result = os.stat(content.path, follow_symlinks=True) except FileNotFoundError: - exists = False - except PermissionError: - exists = True - logging.debug("Permission denied accessing %s", row.file_path) + mark_content_missing(session, content.id) + except PermissionError as e: + _log_scan_error("reference_stat", e) + if progress is not None: + progress.permission_denied += 1 + logging.debug("Permission denied accessing %s", content.path) except OSError as e: - exists = False - logging.debug("OSError checking %s: %s", row.file_path, e) - - acc["refs"].append( - { - "ref_id": row.reference_id, - "file_path": row.file_path, - "exists": exists, - "stat_unchanged": stat_unchanged, - "needs_verify": row.needs_verify, - } - ) - - to_set_verify: list[str] = [] - to_clear_verify: list[str] = [] - stale_ref_ids: list[str] = [] - to_mark_missing: list[str] = [] - to_clear_missing: list[str] = [] - survivors: set[str] = set() - - for aid, acc in by_asset.items(): - a_hash = acc["hash"] - refs = acc["refs"] - any_unchanged = any(r["stat_unchanged"] for r in refs) - all_missing = all(not r["exists"] for r in refs) + _log_scan_error("reference_stat", e) + logging.debug("OSError checking %s: %s", content.path, e) + mark_content_missing(session, content.id) + else: + detect_content_change( + session, + content, + stat_result, + hashing_is_enabled=mode.hashing_enabled(), + ) + survivors.add(os.path.abspath(content.path)) - for r in refs: - if not r["exists"]: - to_mark_missing.append(r["ref_id"]) - continue - if r["stat_unchanged"]: - to_clear_missing.append(r["ref_id"]) - if r["needs_verify"]: - to_clear_verify.append(r["ref_id"]) - if not r["stat_unchanged"] and not r["needs_verify"]: - to_set_verify.append(r["ref_id"]) - - if a_hash is None: - if refs and all_missing: - delete_orphaned_seed_asset(session, aid) - else: - for r in refs: - if r["exists"]: - survivors.add(os.path.abspath(r["file_path"])) - continue + return survivors if collect_existing_paths else None - if any_unchanged: - for r in refs: - if not r["exists"]: - stale_ref_ids.append(r["ref_id"]) - if update_missing_tags: - try: - remove_missing_tag_for_asset_id(session, asset_id=aid) - except Exception as e: - logging.warning( - "Failed to remove missing tag for asset %s: %s", aid, e - ) - elif update_missing_tags: - try: - add_missing_tag_for_asset_id(session, asset_id=aid, origin="automatic") - except Exception as e: - logging.warning("Failed to add missing tag for asset %s: %s", aid, e) - - for r in refs: - if r["exists"]: - survivors.add(os.path.abspath(r["file_path"])) - - delete_references_by_ids(session, stale_ref_ids) - stale_set = set(stale_ref_ids) - to_mark_missing = [ref_id for ref_id in to_mark_missing if ref_id not in stale_set] - bulk_update_is_missing(session, to_mark_missing, value=True) - bulk_update_is_missing(session, to_clear_missing, value=False) - bulk_update_needs_verify(session, to_set_verify, value=True) - bulk_update_needs_verify(session, to_clear_verify, value=False) - return survivors if collect_existing_paths else None +def _is_under_prefixes(path: str, prefixes: list[str]) -> bool: + return is_path_under_prefixes(path, prefixes) -def sync_root_safely(root: RootType) -> set[str]: +def sync_root_safely( + root: RootType, progress: _ScanProgress | None = None +) -> set[str]: """Sync a single root's references with the filesystem. Returns survivors (existing paths) or empty set on failure. @@ -261,23 +219,39 @@ def sync_root_safely(root: RootType) -> set[str]: sess, root, collect_existing_paths=True, - update_missing_tags=True, + progress=progress, ) sess.commit() return survivors or set() - except Exception as e: - logging.exception("fast DB scan failed for %s: %s", root, e) + except Exception as exc: + logging.exception("fast DB scan failed for %s: %s", root, exc) + emit( + "scanner.fast_scan_failed", + root=root, + error_type=error_type(exc), + ) return set() -def sync_temp_references_safely() -> None: +def sync_temp_references_safely( + progress: _ScanProgress | None = None, +) -> None: """Retire temp references whose file is gone; temp is never scanned, so nothing else stats them.""" try: with create_session() as sess: - sync_prefixes_with_filesystem(sess, get_temp_prefixes()) + sync_prefixes_with_filesystem( + sess, + get_temp_prefixes(), + progress=progress, + ) sess.commit() - except Exception as e: - logging.exception("temp reference sync failed: %s", e) + except Exception as exc: + logging.exception("temp reference sync failed: %s", exc) + emit( + "scanner.temp_sync_failed", + root="temp", + error_type=error_type(exc), + ) def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int: @@ -287,14 +261,30 @@ def mark_missing_outside_prefixes_safely(prefixes: list[str]) -> int: """ try: with create_session() as sess: - count = mark_references_missing_outside_prefixes(sess, prefixes) + count = mark_contents_missing_outside_prefixes(sess, prefixes) sess.commit() return count - except Exception as e: - logging.exception("marking missing assets failed: %s", e) + except Exception as exc: + logging.exception("marking missing assets failed: %s", exc) + emit( + "scanner.mark_missing_failed", + error_type=error_type(exc), + ) return 0 +def mark_contents_missing_outside_prefixes( + session: Session, prefixes: list[str] +) -> int: + contents = session.scalars( + sa.select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + missing = [content for content in contents if not _is_under_prefixes(content.path, prefixes)] + for content in missing: + mark_content_missing(session, content.id) + return len(missing) + + def collect_paths_for_roots(roots: tuple[RootType, ...]) -> list[str]: """Collect all file paths for the given roots.""" paths: list[str] = [] @@ -311,7 +301,7 @@ def build_asset_specs( paths: list[str], existing_paths: set[str], enable_metadata_extraction: bool = True, - compute_hashes: bool = False, + progress: _ScanProgress | None = None, ) -> tuple[list[SeedAssetSpec], set[str], int]: """Build asset specs from paths, returning (specs, tag_pool, skipped_count). @@ -319,23 +309,41 @@ def build_asset_specs( paths: List of file paths to process existing_paths: Set of paths that already exist in the database enable_metadata_extraction: If True, extract tier 1 & 2 metadata - compute_hashes: If True, compute blake3 hashes (slow for large files) + progress: Optional per-scan state for emit-once bookkeeping """ specs: list[SeedAssetSpec] = [] tag_pool: set[str] = set() skipped = 0 + candidates: list[tuple[str, os.stat_result]] = [] for p in paths: abs_p = os.path.abspath(p) + if _should_skip_extension(abs_p): + skipped += 1 + continue if abs_p in existing_paths: skipped += 1 continue try: stat_p = os.stat(abs_p, follow_symlinks=True) - except OSError: + except FileNotFoundError: + continue + except OSError as e: + _log_scan_error("discovery_stat", e) + if progress is not None: + if isinstance(e, PermissionError): + progress.permission_denied += 1 + if progress.mark_emitted("stat_failed:discovery"): + emit("scanner.stat_failed", site="discovery", error_type=error_type(e)) continue if not stat_p.st_size: continue + candidates.append((abs_p, stat_p)) + + admitted_paths, _ = _two_stat_admit(candidates) + candidate_stats = dict(candidates) + for abs_p in admitted_paths: + stat_p = candidate_stats[abs_p] name, tags = get_name_and_tags_from_asset_path(abs_p) rel_fname = compute_loader_path(abs_p) @@ -348,15 +356,6 @@ def build_asset_specs( relative_filename=rel_fname, ) - # Compute hash if requested - asset_hash: str | None = None - if compute_hashes: - try: - digest, _ = compute_blake3_hash(abs_p) - asset_hash = "blake3:" + digest - except Exception as e: - logging.warning("Failed to hash %s: %s", abs_p, e) - mime_type = metadata.content_type if metadata else None specs.append( { @@ -367,7 +366,6 @@ def build_asset_specs( "tags": tags, "fname": rel_fname, "metadata": metadata, - "hash": asset_hash, "mime_type": mime_type, "job_id": None, } @@ -377,40 +375,80 @@ def build_asset_specs( return specs, tag_pool, skipped +def seed_asset_specs(session: Session, specs: list[SeedAssetSpec]) -> int: + created = 0 + created_content_ids: list[str] = [] + try: + for spec in specs: + path = os.path.abspath(spec["abs_path"]) + try: + with session.begin_nested(): + try: + stat_result = os.stat(path, follow_symlinks=True) + except OSError: + logging.warning("Skipping vanished asset during scan: %s", path) + continue + try: + recovery = recover_missing_content( + session, + path, + stat_result, + hashing_is_enabled=mode.hashing_enabled(), + ) + except OSError: + logging.warning("Skipping vanished asset during scan: %s", path) + continue + if recovery != "no_match": + continue + content, inserted = create_content_reporting_insert( + session, + path=path, + hash=None, + size_bytes=stat_result.st_size, + mtime_ns=get_mtime_ns(stat_result), + ) + if inserted: + created_content_ids.append(content.id) + existing_record = session.scalar( + sa.select(Asset.id).where(Asset.content_id == content.id).limit(1) + ) + if existing_record is not None: + continue + create_record( + session, + content_id=content.id, + name=spec["info_name"], + mime_type=spec["mime_type"], + job_id=spec["job_id"], + loader_path=spec["fname"], + tags=spec["tags"], + ) + created += 1 + except IntegrityError: + logging.warning("Skipping asset whose row conflicts during scan: %s", path) + continue + except Exception: + session.rollback() + for content_id in created_content_ids: + _discard_unreferenced_content(session, content_id) + raise + return created -def insert_asset_specs(specs: list[SeedAssetSpec], tag_pool: set[str]) -> int: - """Insert asset specs into database, returning count of created refs.""" + +def insert_asset_specs(specs: list[SeedAssetSpec], _tag_pool: set[str]) -> int: if not specs: return 0 with create_session() as sess: - if tag_pool: - ensure_tags_exist(sess, tag_pool) - result = batch_insert_seed_assets(sess, specs=specs, owner_id="") + created = seed_asset_specs(sess, specs) sess.commit() - return result.inserted_refs - - -# Enrichment level constants -ENRICHMENT_STUB = 0 # Fast scan: path, size, mtime only -ENRICHMENT_METADATA = 1 # Metadata extracted (safetensors header, mime type) -ENRICHMENT_HASHED = 2 # Hash computed (blake3) + return created def get_unenriched_assets_for_roots( roots: tuple[RootType, ...], - max_level: int = ENRICHMENT_STUB, + compute_hashes: bool, limit: int = 1000, -) -> list: - """Get assets that need enrichment for the given roots. - - Args: - roots: Tuple of root types to scan - max_level: Maximum enrichment level to include - limit: Maximum number of rows to return - - Returns: - List of UnenrichedReferenceRow - """ +) -> list[UnenrichedContent]: prefixes: list[str] = [] for root in roots: prefixes.extend(get_scan_prefixes_for_root(root)) @@ -419,44 +457,67 @@ def get_unenriched_assets_for_roots( return [] with create_session() as sess: - return get_unenriched_references( - sess, prefixes, max_level=max_level, limit=limit + query = ( + sa.select(AssetContent.id, Asset.id, AssetContent.path) + .join(Asset, Asset.content_id == AssetContent.id) + .where(AssetContent.is_missing.is_(False)) + ) + if compute_hashes: + query = query.where( + sa.or_( + AssetContent.hash.is_(None), + Asset.system_metadata.is_(None), + ) + ) + else: + query = query.where(Asset.system_metadata.is_(None)) + query = query.where( + sa.or_( + *(sql_path_under_prefix(AssetContent.path, p) for p in prefixes) + ) ) + rows = sess.execute(query.order_by(Asset.id).limit(limit)).all() + + return [ + UnenrichedContent(content_id, record_id, file_path) + for content_id, record_id, file_path in rows + ] def enrich_asset( session, file_path: str, - reference_id: str, - asset_id: str, + content_id: str, + record_id: str, extract_metadata: bool = True, compute_hash: bool = False, - interrupt_check: Callable[[], bool] | None = None, - hash_checkpoints: dict[str, HashCheckpoint] | None = None, -) -> int: + progress: _ScanProgress | None = None, +) -> bool: """Enrich a single asset with metadata and/or hash. Args: session: Database session (caller manages lifecycle) file_path: Absolute path to the file - reference_id: ID of the reference to update - asset_id: ID of the asset to update (for mime_type and hash) + content_id: ID of the content to update + record_id: ID of the record to update extract_metadata: If True, extract safetensors header and mime type compute_hash: If True, compute blake3 hash - interrupt_check: Optional non-blocking callable that returns True if - the operation should be interrupted (e.g. paused or cancelled) - hash_checkpoints: Optional dict for saving/restoring hash progress - across interruptions, keyed by file path Returns: - New enrichment level achieved + Whether enrichment changed the B-schema record or content """ - new_level = ENRICHMENT_STUB - try: stat_p = os.stat(file_path, follow_symlinks=True) - except OSError: - return new_level + except FileNotFoundError: + return False + except OSError as e: + _log_scan_error("enrichment_stat", e) + if progress is not None: + if isinstance(e, PermissionError): + progress.permission_denied += 1 + if progress.mark_emitted("stat_failed:enrich"): + emit("scanner.stat_failed", site="enrich", error_type=error_type(e)) + return False initial_mtime_ns = get_mtime_ns(stat_p) rel_fname = compute_loader_path(file_path) @@ -471,68 +532,60 @@ def enrich_asset( ) if metadata: mime_type = metadata.content_type - new_level = ENRICHMENT_METADATA - full_hash: str | None = None - if compute_hash: - try: - mtime_before = get_mtime_ns(stat_p) - size_before = stat_p.st_size - - # Restore checkpoint if available and file unchanged - checkpoint = None - if hash_checkpoints is not None: - checkpoint = hash_checkpoints.get(file_path) - if checkpoint is not None: - cur_stat = os.stat(file_path, follow_symlinks=True) - if (checkpoint.mtime_ns != get_mtime_ns(cur_stat) - or checkpoint.file_size != cur_stat.st_size): - checkpoint = None - hash_checkpoints.pop(file_path, None) - else: - mtime_before = get_mtime_ns(cur_stat) - - digest, new_checkpoint = compute_blake3_hash( - file_path, - interrupt_check=interrupt_check, - checkpoint=checkpoint, - ) + content = session.get(AssetContent, content_id) - if digest is None: - # Interrupted — save checkpoint for later resumption - if hash_checkpoints is not None and new_checkpoint is not None: - new_checkpoint.mtime_ns = mtime_before - new_checkpoint.file_size = size_before - hash_checkpoints[file_path] = new_checkpoint - return new_level - - # Completed — clear any saved checkpoint - if hash_checkpoints is not None: - hash_checkpoints.pop(file_path, None) - - stat_after = os.stat(file_path, follow_symlinks=True) - mtime_after = get_mtime_ns(stat_after) - if mtime_before != mtime_after: - logging.warning("File modified during hashing, discarding hash: %s", file_path) + digest: str | None = None + stored_hash: str | None = None + verified_stat: os.stat_result | None = None + hash_requested = compute_hash and content is not None and content.hash is None + if hash_requested: + try: + snapshot = snapshot_hash(file_path) + if snapshot is None: + if progress is None or progress.mark_emitted("hash_discarded_modified"): + emit("scanner.hash_discarded_modified") + logging.warning( + "File modified during hashing (snapshot unstable), discarding hash: %s", + file_path, + ) + return False + digest, verified_stat = snapshot + stored_hash = to_stored_hash(digest) + except Exception as exc: + emit_failure = progress is None + if progress is not None: + progress.hash_failed += 1 + emit_failure = progress.mark_emitted("hash_failed") + if emit_failure: + emit("scanner.hash_failed", error_type=error_type(exc)) + if isinstance(exc, OSError): + _log_scan_error("hashing", exc) else: - full_hash = f"blake3:{digest}" - metadata_ok = not extract_metadata or metadata is not None - if metadata_ok: - new_level = ENRICHMENT_HASHED - except Exception as e: - logging.warning("Failed to hash %s: %s", file_path, e) - - # Optimistic guard: if the reference's mtime_ns changed since we - # started (e.g. ingest_existing_file updated it), our results are - # stale — discard them to avoid overwriting fresh registration data. - ref = get_reference_by_id(session, reference_id) - if ref is None or ref.mtime_ns != initial_mtime_ns: + logging.warning("Failed to hash %s: %s", file_path, exc) + + record = session.get(Asset, record_id) + if content is None or record is None or content.mtime_ns != initial_mtime_ns: + session.rollback() + logging.info( + "Content %s mtime changed during enrichment, discarding stale result", + content_id, + ) + return False + + # Non-NULL system_metadata permanently excludes the row from re-enrichment, so a + # disagreement here must discard the metadata too, not just the hash. + if verified_stat is not None and ( + get_mtime_ns(verified_stat) != initial_mtime_ns + or verified_stat.st_size != stat_p.st_size + ): session.rollback() logging.info( - "Ref %s mtime changed during enrichment, discarding stale result", - reference_id, + "Content %s changed between its metadata read and its hash read, " + "discarding stale result", + content_id, ) - return ENRICHMENT_STUB + return False if extract_metadata and metadata: system_metadata = metadata.to_user_metadata() @@ -540,32 +593,26 @@ def enrich_asset( dims = extract_image_dimensions(file_path, mime_type=mime_type) if dims: system_metadata.update(dims) - set_reference_system_metadata(session, reference_id, system_metadata) - - if full_hash: - existing = get_asset_by_hash(session, full_hash) - if existing and existing.id != asset_id: - reassign_asset_references(session, asset_id, existing.id, reference_id) - delete_orphaned_seed_asset(session, asset_id) - if mime_type: - update_asset_hash_and_mime(session, existing.id, mime_type=mime_type) - else: - update_asset_hash_and_mime(session, asset_id, full_hash, mime_type) - elif mime_type: - update_asset_hash_and_mime(session, asset_id, mime_type=mime_type) + record.system_metadata = {**(record.system_metadata or {}), **system_metadata} + + if stored_hash: + content.hash = stored_hash + if mime_type: + record.mime_type = mime_type - bulk_update_enrichment_level(session, [reference_id], new_level) session.commit() - return new_level + if hash_requested and stored_hash is None: + return False + return stored_hash is not None or metadata is not None or mime_type is not None def enrich_assets_batch( - rows: list, + rows: list[UnenrichedContent], extract_metadata: bool = True, compute_hash: bool = False, interrupt_check: Callable[[], bool] | None = None, - hash_checkpoints: dict[str, HashCheckpoint] | None = None, + progress: _ScanProgress | None = None, ) -> tuple[int, list[str]]: """Enrich a batch of assets. @@ -579,8 +626,6 @@ def enrich_assets_batch( compute_hash: If True, compute hash for each asset interrupt_check: Optional non-blocking callable that returns True if the operation should be interrupted (e.g. paused or cancelled) - hash_checkpoints: Optional dict for saving/restoring hash progress - across interruptions, keyed by file path Returns: Tuple of (enriched_count, failed_reference_ids) @@ -594,23 +639,26 @@ def enrich_assets_batch( break try: - new_level = enrich_asset( + updated = enrich_asset( sess, file_path=row.file_path, - reference_id=row.reference_id, - asset_id=row.asset_id, + content_id=row.content_id, + record_id=row.record_id, extract_metadata=extract_metadata, compute_hash=compute_hash, - interrupt_check=interrupt_check, - hash_checkpoints=hash_checkpoints, + progress=progress, ) - if new_level > row.enrichment_level: + if updated: enriched += 1 else: - failed_ids.append(row.reference_id) - except Exception as e: - logging.warning("Failed to enrich %s: %s", row.file_path, e) + failed_ids.append(row.record_id) + except Exception as exc: + if progress is not None: + progress.enrich_failed += 1 + if progress is None or progress.mark_emitted("enrich_failed"): + emit("scanner.enrich_failed", error_type=error_type(exc)) + logging.warning("Failed to enrich %s: %s", row.file_path, exc) sess.rollback() - failed_ids.append(row.reference_id) + failed_ids.append(row.record_id) return enriched, failed_ids diff --git a/app/assets/scanner_admission.py b/app/assets/scanner_admission.py new file mode 100644 index 00000000000..7c059b005a0 --- /dev/null +++ b/app/assets/scanner_admission.py @@ -0,0 +1,97 @@ +"""Decides whether a file observed during a filesystem walk is settled enough to +catalog, and parks the ones that are not. Admission requires two stats taken a +moment apart to agree; a file still changing, or carrying a partial-download +extension, goes onto a bounded watch list that later scans re-check until it +settles, vanishes, or exhausts its retries. This is what keeps a model that is +still downloading out of the catalog. +""" + +from __future__ import annotations + +import mimetypes +import os +import time +from dataclasses import dataclass +from typing import Final + +from sqlalchemy.orm import Session + +from app.assets.services.path_utils import compute_loader_path, get_name_and_tags_from_asset_path + +PARTIAL_DOWNLOAD_EXTENSIONS = frozenset({ + ".part", ".partial", ".crdownload", ".download", ".tmp", ".aria2", ".!qb", ".opdownload", +}) +_WATCH_SCAN_RETRIES: Final = 30 +_WATCH_LIST_MAX_SIZE: Final = 256 + + +@dataclass +class _WatchEntry: + path: str + last_stat: os.stat_result + ticks: int = 0 + + +_WATCH_LIST: list[_WatchEntry] = [] + + +def _should_skip_extension(path: str) -> bool: + return os.path.splitext(path)[1].lower() in PARTIAL_DOWNLOAD_EXTENSIONS + + +def _two_stat_admit(paths_with_stats: list[tuple[str, os.stat_result]]) -> tuple[list[str], list[str]]: + if not paths_with_stats: + return [], [] + time.sleep(0.1) + admitted: list[str] = [] + watched: list[str] = [] + for path, first_stat in paths_with_stats: + try: + second_stat = os.stat(path) + except FileNotFoundError: + continue + if (second_stat.st_mtime_ns, second_stat.st_size) == (first_stat.st_mtime_ns, first_stat.st_size): + _WATCH_LIST[:] = [entry for entry in _WATCH_LIST if entry.path != path] + admitted.append(path) + else: + for entry in _WATCH_LIST: + if entry.path == path: + entry.last_stat = second_stat + break + else: + _WATCH_LIST.append(_WatchEntry(path, second_stat)) + if len(_WATCH_LIST) > _WATCH_LIST_MAX_SIZE: + _ = _WATCH_LIST.pop(0) + watched.append(path) + return admitted, watched + + +def tick_watch_list(session: Session) -> None: + from app.assets.scanner import seed_asset_specs, SeedAssetSpec + + remaining: list[_WatchEntry] = [] + for entry in _WATCH_LIST: + try: + current = os.stat(entry.path) + except FileNotFoundError: + continue + if (current.st_mtime_ns, current.st_size) == (entry.last_stat.st_mtime_ns, entry.last_stat.st_size): + name, tags = get_name_and_tags_from_asset_path(entry.path) + spec: SeedAssetSpec = { + "abs_path": entry.path, + "size_bytes": current.st_size, + "mtime_ns": current.st_mtime_ns, + "info_name": name, + "tags": tags, + "fname": compute_loader_path(entry.path), + "metadata": None, + "mime_type": mimetypes.guess_type(entry.path, strict=False)[0], + "job_id": None, + } + seed_asset_specs(session, [spec]) + continue + entry.last_stat = current + entry.ticks += 1 + if entry.ticks < _WATCH_SCAN_RETRIES: + remaining.append(entry) + _WATCH_LIST[:] = remaining diff --git a/app/assets/scanner_changes.py b/app/assets/scanner_changes.py new file mode 100644 index 00000000000..43ee8a47a98 --- /dev/null +++ b/app/assets/scanner_changes.py @@ -0,0 +1,205 @@ +"""Reconciles catalogued content against what is actually on disk: retiring rows +whose file is gone, splitting a row whose bytes changed, and recovering one +whose file came back. Recovery fires only when the returning file's hash +identifies exactly one missing row and no live row already occupies that path, +so a restored file can never leave two live rows describing one location. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Literal + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from app.assets.database.models import AssetContent +from app.assets.database.queries.records import ( + create_content, + create_record, + mark_content_missing, + unset_content_missing, +) +from app.assets.helpers import sql_path_under_prefix, to_stored_hash +from app.assets.services.path_utils import compute_loader_path, get_name_and_tags_from_asset_path +from app.assets.services.snapshot_hash import snapshot_hash + +_pending_verification_ids: list[str] = [] +_pending_recovery_paths: list[str] = [] + + +def clear_pending_verifications() -> None: + _pending_verification_ids.clear() + _pending_recovery_paths.clear() + + +def queue_pending_verification(content_id: str) -> None: + if content_id not in _pending_verification_ids: + _pending_verification_ids.append(content_id) + + +def pending_recovery_count() -> int: + return len(_pending_recovery_paths) + + +def recover_missing_content( + session: Session, path: str, stat_result: os.stat_result, hashing_is_enabled: bool +) -> Literal["recovered", "no_match", "unstable"]: + if not hashing_is_enabled: + return "no_match" + occupied = session.scalar( + sa.select(AssetContent.id) + .where(AssetContent.path == path, AssetContent.is_missing.is_(False)) + .limit(1) + ) + if occupied is not None: + return "no_match" + snapshot = snapshot_hash(path) + if snapshot is None: + if path not in _pending_recovery_paths: + _pending_recovery_paths.append(path) + return "unstable" + digest, verified_stat = snapshot + stored_hash = to_stored_hash(digest) + matches = list( + session.scalars( + sa.select(AssetContent).where( + AssetContent.path == path, + AssetContent.is_missing.is_(True), + AssetContent.hash == stored_hash, + ) + ) + ) + if len(matches) == 1: + recovered = matches[0] + unset_content_missing(session, recovered.id) + recovered.size_bytes = verified_stat.st_size + recovered.mtime_ns = verified_stat.st_mtime_ns + return "recovered" + if len(matches) > 1: + return "no_match" + null_hash_matches = list( + session.scalars( + sa.select(AssetContent).where( + AssetContent.path == path, + AssetContent.is_missing.is_(True), + AssetContent.hash.is_(None), + ) + ) + ) + if len(null_hash_matches) != 1: + return "no_match" + candidate = null_hash_matches[0] + if (candidate.size_bytes, candidate.mtime_ns) != ( + verified_stat.st_size, + verified_stat.st_mtime_ns, + ): + return "no_match" + unset_content_missing(session, candidate.id) + candidate.hash = stored_hash + candidate.size_bytes = verified_stat.st_size + candidate.mtime_ns = verified_stat.st_mtime_ns + return "recovered" + + +def is_path_under_prefixes(path: str, prefixes: list[str]) -> bool: + candidate = Path(os.path.abspath(path)) + return any(candidate.is_relative_to(os.path.abspath(prefix)) for prefix in prefixes) + + +def split_content(session: Session, content: AssetContent, stat_result: os.stat_result, hash_value: str | None) -> AssetContent: + mark_content_missing(session, content.id) + name, tags = get_name_and_tags_from_asset_path(content.path) + replacement = create_content( + session, + path=content.path, + hash=hash_value, + size_bytes=stat_result.st_size, + mtime_ns=stat_result.st_mtime_ns, + ) + create_record( + session, + content_id=replacement.id, + name=name, + loader_path=compute_loader_path(content.path), + tags=tags, + ) + return replacement + + +def detect_content_change( + session: Session, + content: AssetContent, + stat_result: os.stat_result, + hashing_is_enabled: bool, +) -> None: + if content.mtime_ns == stat_result.st_mtime_ns: + # Ruling #10: size drift with unchanged mtime is undefined behavior. + return + if hashing_is_enabled: + queue_pending_verification(content.id) + return + if content.size_bytes == stat_result.st_size: + # User identity rule: a same-size mtime bump (rsync, cloud sync, backup restore) is the + # same file — never split, or the record's tags and metadata are destroyed. + # The stored hash goes with the refreshed stat: OFF mode cannot prove the bytes, and a + # refreshed stat alone would re-qualify the row to be served under a digest it may no + # longer match. + content.size_bytes = stat_result.st_size + content.mtime_ns = stat_result.st_mtime_ns + content.hash = None + return + split_content(session, content, stat_result, hash_value=None) + + +def drain_pending_verifications(session: Session, limit: int | None = None) -> int: + queued_count = min(len(_pending_verification_ids), limit or len(_pending_verification_ids)) + processed = 0 + for _ in range(queued_count): + content_id = _pending_verification_ids.pop(0) + content = session.get(AssetContent, content_id) + if content is None or content.is_missing: + continue + try: + os.stat(content.path, follow_symlinks=True) + except FileNotFoundError: + mark_content_missing(session, content.id) + processed += 1 + continue + except OSError: + queue_pending_verification(content_id) + continue + + try: + snapshot = snapshot_hash(content.path) + except OSError: + queue_pending_verification(content_id) + continue + if snapshot is None: + queue_pending_verification(content_id) + continue + digest, verified_stat = snapshot + stored_hash = to_stored_hash(digest) + + if content.hash == stored_hash or content.hash is None: + content.hash = stored_hash + content.size_bytes = verified_stat.st_size + content.mtime_ns = verified_stat.st_mtime_ns + else: + split_content(session, content, verified_stat, hash_value=stored_hash) + processed += 1 + return processed + + +def live_contents_under_prefixes(session: Session, prefixes: list[str]) -> list[AssetContent]: + if not prefixes: + return [] + return list( + session.scalars( + sa.select(AssetContent).where( + AssetContent.is_missing.is_(False), + sa.or_(*(sql_path_under_prefix(AssetContent.path, prefix) for prefix in prefixes)), + ) + ) + ) diff --git a/app/assets/seeder.py b/app/assets/seeder.py index 134fc98a844..a78f1abf440 100644 --- a/app/assets/seeder.py +++ b/app/assets/seeder.py @@ -1,4 +1,10 @@ -"""Background asset seeder with thread management and cancellation support.""" +"""Runs the filesystem scan on a background thread so startup never waits for it, +exposing pause, resume, cancel and progress to the API. A run seeds +newly-observed files first, then enriches records in batches, and settles any +pending hash-mode transition at the start of the enrich phase so a server that +receives no prompts still completes the switch. A pass stops once batches stop +making progress, bounding a scan over files that cannot be read. +""" import logging import os @@ -6,11 +12,10 @@ import time from dataclasses import dataclass, field from enum import Enum -from typing import Callable +from typing import Any, Callable, TypedDict +from app.assets.event_log import emit, error_type from app.assets.scanner import ( - ENRICHMENT_METADATA, - ENRICHMENT_STUB, RootType, build_asset_specs, collect_paths_for_roots, @@ -22,8 +27,11 @@ mark_missing_outside_prefixes_safely, sync_root_safely, sync_temp_references_safely, + drain_pending_verifications, + tick_watch_list, ) -from app.database.db import dependencies_available +from app.assets.services.hash_mode_state import drain_transition_queue, pending_transition_count +from app.database.db import create_session, dependencies_available class ScanInProgressError(Exception): @@ -47,14 +55,31 @@ class ScanPhase(Enum): FULL = "full" # Both phases sequentially +class PendingScan(TypedDict): + roots: tuple[RootType, ...] + phase: ScanPhase + compute_hashes: bool + + +class _ScanStage(Enum): + MARK_MISSING = "mark_missing" + PRUNING = "pruning" + FAST_SCAN = "fast_scan" + ENRICH = "enrich" + FINALIZE = "finalize" + + @dataclass class Progress: - """Progress information for a scan operation.""" + """Public snapshot of a scan's progress. Carries counters only.""" scanned: int = 0 total: int = 0 created: int = 0 skipped: int = 0 + hash_failed: int = 0 + enrich_failed: int = 0 + permission_denied: int = 0 @dataclass @@ -69,6 +94,46 @@ class ScanStatus: ProgressCallback = Callable[[Progress], None] +@dataclass +class _ScanState: + """Mutable in-flight state for one scan; never exposed outside this module. + + Satisfies scanner.py's `_ScanProgress` Protocol. `cancel_stage` stores the + stage's string value (not the `_ScanStage` enum) so scanner.py never needs + to import `_ScanStage`. Take a `Progress` snapshot before exposing state. + """ + + scanned: int = 0 + total: int = 0 + created: int = 0 + skipped: int = 0 + hash_failed: int = 0 + enrich_failed: int = 0 + permission_denied: int = 0 + cancel_stage: str | None = None + _emitted_keys: set[str] = field(default_factory=set) + + def mark_emitted(self, key: str) -> bool: + """Return True the first call with `key` this scan, False every call after.""" + if key in self._emitted_keys: + return False + self._emitted_keys.add(key) + return True + + +def _snapshot_progress(state: _ScanState) -> Progress: + """Build the public counters-only `Progress` snapshot from live scan state.""" + return Progress( + scanned=state.scanned, + total=state.total, + created=state.created, + skipped=state.skipped, + hash_failed=state.hash_failed, + enrich_failed=state.enrich_failed, + permission_denied=state.permission_denied, + ) + + class _AssetSeeder: """Background asset scanning manager. @@ -82,7 +147,7 @@ def __init__(self) -> None: # holding _lock and re-enters start() which also acquires _lock. self._lock = threading.RLock() self._state = State.IDLE - self._progress: Progress | None = None + self._scan_state: _ScanState | None = None self._last_progress: Progress | None = None self._errors: list[str] = [] self._thread: threading.Thread | None = None @@ -94,8 +159,12 @@ def __init__(self) -> None: self._compute_hashes: bool = False self._prune_first: bool = False self._progress_callback: ProgressCallback | None = None + self._event_sink: Callable[[str, dict[str, Any]], None] | None = None self._disabled: bool = False - self._pending_enrich: dict | None = None + self._pending_scan: PendingScan | None = None + + def set_event_sink(self, sink: Callable[[str, dict[str, Any]], None] | None) -> None: + self._event_sink = sink def disable(self) -> None: """Disable the asset seeder, preventing any scans from starting.""" @@ -113,6 +182,8 @@ def start( progress_callback: ProgressCallback | None = None, prune_first: bool = False, compute_hashes: bool = False, + *, + _start_paused: bool = False, ) -> bool: """Start a background scan for the given roots. @@ -122,6 +193,7 @@ def start( progress_callback: Optional callback called with progress updates prune_first: If True, prune orphaned assets before scanning compute_hashes: If True, compute blake3 hashes (slow) + _start_paused: Start with phase work blocked until resume() Returns: True if scan was started, False if already running @@ -134,8 +206,8 @@ def start( if self._state != State.IDLE: logging.info("Asset seeder already running, skipping start") return False - self._state = State.RUNNING - self._progress = Progress() + self._state = State.PAUSED if _start_paused else State.RUNNING + self._scan_state = _ScanState() self._errors = [] self._roots = roots self._phase = phase @@ -143,7 +215,10 @@ def start( self._compute_hashes = compute_hashes self._progress_callback = progress_callback self._cancel_event.clear() - self._run_gate.set() # Ensure unpaused when starting + if _start_paused: + self._run_gate.clear() + else: + self._run_gate.set() self._thread = threading.Thread( target=self._run_scan, name="_AssetSeeder", @@ -176,64 +251,40 @@ def start_fast( compute_hashes=False, ) - def start_enrich( - self, - roots: tuple[RootType, ...] = ("models", "input", "output"), - progress_callback: ProgressCallback | None = None, - compute_hashes: bool = False, - ) -> bool: - """Start an enrichment scan (phase 2 only) - extracts metadata and hashes. - - Args: - roots: Tuple of root types to scan - progress_callback: Optional callback for progress updates - compute_hashes: If True, compute blake3 hashes - - Returns: - True if scan was started, False if already running - """ - return self.start( - roots=roots, - phase=ScanPhase.ENRICH, - progress_callback=progress_callback, - prune_first=False, - compute_hashes=compute_hashes, - ) - - def enqueue_enrich( + def enqueue_scan( self, - roots: tuple[RootType, ...] = ("models", "input", "output"), + roots: tuple[RootType, ...], + phase: ScanPhase, compute_hashes: bool = False, ) -> bool: - """Start an enrichment scan now, or queue it for after the current scan. - - If the seeder is idle, starts immediately. Otherwise, the enrich - request is stored and will run automatically when the current scan - finishes. - - Args: - roots: Tuple of root types to scan - compute_hashes: If True, compute blake3 hashes - - Returns: - True if started immediately, False if queued for later - """ with self._lock: - if self.start_enrich(roots=roots, compute_hashes=compute_hashes): + if self.start( + roots=roots, + phase=phase, + prune_first=False, + compute_hashes=compute_hashes, + ): return True - if self._pending_enrich is not None: - existing_roots = set(self._pending_enrich["roots"]) + if self._pending_scan is not None: + existing_roots = set(self._pending_scan["roots"]) existing_roots.update(roots) - self._pending_enrich["roots"] = tuple(existing_roots) - self._pending_enrich["compute_hashes"] = ( - self._pending_enrich["compute_hashes"] or compute_hashes + self._pending_scan["roots"] = tuple(existing_roots) + self._pending_scan["compute_hashes"] = ( + self._pending_scan["compute_hashes"] or compute_hashes ) + if self._pending_scan["phase"] is not phase: + self._pending_scan["phase"] = ScanPhase.FULL else: - self._pending_enrich = { + self._pending_scan = { "roots": roots, + "phase": phase, "compute_hashes": compute_hashes, } - logging.info("Enrich scan queued (roots=%s)", self._pending_enrich["roots"]) + logging.info( + "Scan queued (roots=%s, phase=%s)", + self._pending_scan["roots"], + self._pending_scan["phase"].value, + ) return False def cancel(self) -> bool: @@ -356,30 +407,37 @@ def wait(self, timeout: float | None = None) -> bool: def get_status(self) -> ScanStatus: """Get the current status and progress of the seeder.""" with self._lock: - src = self._progress or self._last_progress + progress = ( + _snapshot_progress(self._scan_state) + if self._scan_state is not None + else self._last_progress + ) return ScanStatus( state=self._state, - progress=Progress( - scanned=src.scanned, - total=src.total, - created=src.created, - skipped=src.skipped, - ) - if src - else None, + progress=progress, errors=list(self._errors), ) - def shutdown(self, timeout: float = 5.0) -> None: + def shutdown(self, timeout: float = 5.0) -> bool: """Gracefully shutdown: cancel any running scan and wait for thread. Args: timeout: Maximum seconds to wait for thread to exit + + Returns: + True if the scan thread joined cleanly; False on timeout. """ self.cancel() - self.wait(timeout=timeout) + joined = self.wait(timeout=timeout) + if not joined: + logging.warning( + "Asset seeder thread did not exit within %ss", + timeout, + ) with self._lock: - self._thread = None + if joined: + self._thread = None + return joined def mark_missing_outside_prefixes(self) -> int: """Mark references as missing when outside all known root prefixes. @@ -416,6 +474,11 @@ def mark_missing_outside_prefixes(self) -> int: all_prefixes = get_owned_prefixes() marked = mark_missing_outside_prefixes_safely(all_prefixes) + emit( + "seeder.marked_missing", + count=marked, + stage=_ScanStage.MARK_MISSING.value, + ) if marked > 0: logging.info("Marked %d references as missing", marked) return marked @@ -425,9 +488,10 @@ def mark_missing_outside_prefixes(self) -> int: def _reset_to_idle(self) -> None: """Reset state to IDLE, preserving last progress. Caller must hold _lock.""" - self._last_progress = self._progress + if self._scan_state is not None: + self._last_progress = _snapshot_progress(self._scan_state) self._state = State.IDLE - self._progress = None + self._scan_state = None def _is_cancelled(self) -> bool: """Check if cancellation has been requested.""" @@ -441,9 +505,17 @@ def _is_paused_or_cancelled(self) -> bool: open while blocked. The caller is responsible for blocking on _check_pause_and_cancel() afterward. """ - return not self._run_gate.is_set() or self._cancel_event.is_set() + cancelled = self._cancel_event.is_set() + if cancelled: + self._record_cancel_stage(_ScanStage.ENRICH) + return not self._run_gate.is_set() or cancelled - def _check_pause_and_cancel(self) -> bool: + def _record_cancel_stage(self, stage: _ScanStage) -> None: + with self._lock: + if self._scan_state is not None and self._scan_state.cancel_stage is None: + self._scan_state.cancel_stage = stage.value + + def _check_pause_and_cancel(self, stage: _ScanStage) -> bool: """Block while paused, then check if cancelled. Call this at checkpoint locations in scan loops. It will: @@ -456,15 +528,16 @@ def _check_pause_and_cancel(self) -> bool: if not self._run_gate.is_set(): self._emit_event("assets.seed.paused", {}) self._run_gate.wait() # Blocks if paused - return self._is_cancelled() + cancelled = self._is_cancelled() + if cancelled: + self._record_cancel_stage(stage) + return cancelled - def _emit_event(self, event_type: str, data: dict) -> None: + def _emit_event(self, event_type: str, data: dict[str, Any]) -> None: """Emit a WebSocket event if server is available.""" try: - from server import PromptServer - - if hasattr(PromptServer, "instance") and PromptServer.instance: - PromptServer.instance.send_sync(event_type, data) + if self._event_sink is not None: + self._event_sink(event_type, data) except Exception: pass @@ -480,24 +553,19 @@ def _update_progress( progress: Progress | None = None with self._lock: - if self._progress is None: + if self._scan_state is None: return if scanned is not None: - self._progress.scanned = scanned + self._scan_state.scanned = scanned if total is not None: - self._progress.total = total + self._scan_state.total = total if created is not None: - self._progress.created = created + self._scan_state.created = created if skipped is not None: - self._progress.skipped = skipped + self._scan_state.skipped = skipped if self._progress_callback: callback = self._progress_callback - progress = Progress( - scanned=self._progress.scanned, - total=self._progress.total, - created=self._progress.created, - skipped=self._progress.skipped, - ) + progress = _snapshot_progress(self._scan_state) if callback and progress: try: @@ -533,6 +601,7 @@ def _run_scan(self) -> None: t_start = time.perf_counter() roots = self._roots phase = self._phase + root = roots[0] if len(roots) == 1 else None cancelled = False total_created = 0 total_enriched = 0 @@ -548,14 +617,23 @@ def _run_scan(self) -> None: ) return + emit("seeder.scan_started", phase=phase.value, root=root) + assert self._scan_state is not None + scan_state = self._scan_state + if self._prune_first: all_prefixes = get_owned_prefixes() marked = mark_missing_outside_prefixes_safely(all_prefixes) + emit( + "seeder.marked_missing", + count=marked, + stage=_ScanStage.PRUNING.value, + ) if marked > 0: logging.info("Marked %d refs as missing before scan", marked) - sync_temp_references_safely() + sync_temp_references_safely(scan_state) - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.PRUNING): logging.info("Asset scan cancelled after pruning phase") cancelled = True return @@ -567,7 +645,7 @@ def _run_scan(self) -> None: created, skipped, paths = self._run_fast_phase(roots) total_created, skipped_existing, total_paths = created, skipped, paths - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.FAST_SCAN): cancelled = True return @@ -583,7 +661,7 @@ def _run_scan(self) -> None: # Phase 2: Enrichment scan (metadata + hashes) if phase in (ScanPhase.ENRICH, ScanPhase.FULL): - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.ENRICH): cancelled = True return @@ -601,6 +679,14 @@ def _run_scan(self) -> None: }, ) + # Deliberately non-blocking, unlike every other checkpoint: no work + # remains, so pausing here would hold the scan open with nothing to + # do until main.py's next resume (a whole gc_collect_interval away). + if self._is_cancelled(): + self._record_cancel_stage(_ScanStage.FINALIZE) + cancelled = True + return + elapsed = time.perf_counter() - t_start logging.info( "Scan(%s, %s) done %.3fs: created=%d enriched=%d skipped=%d", @@ -611,6 +697,18 @@ def _run_scan(self) -> None: total_enriched, skipped_existing, ) + emit( + "seeder.scan_completed", + phase=phase.value, + elapsed_ms=round(elapsed * 1000), + created=total_created, + enriched=total_enriched, + skipped=skipped_existing, + hash_failed=scan_state.hash_failed, + enrich_failed=scan_state.enrich_failed, + permission_denied=scan_state.permission_denied, + root=root, + ) self._emit_event( "assets.seed.completed", @@ -627,30 +725,51 @@ def _run_scan(self) -> None: except Exception as e: self._add_error(f"Scan failed: {e}") logging.exception("Asset scan failed") + emit( + "seeder.scan_failed", + phase=phase.value, + error_type=error_type(e), + root=root, + ) self._emit_event("assets.seed.error", {"message": str(e)}) finally: - if cancelled: - self._emit_event( - "assets.seed.cancelled", - { - "scanned": self._progress.scanned if self._progress else 0, - "total": total_paths, - "created": total_created, - }, - ) - with self._lock: - self._reset_to_idle() - pending = self._pending_enrich - if pending is not None: - self._pending_enrich = None - if not self.start_enrich( - roots=pending["roots"], - compute_hashes=pending["compute_hashes"], - ): - logging.warning( - "Pending enrich scan could not start (roots=%s)", - pending["roots"], + try: + if cancelled: + stage = self._scan_state.cancel_stage if self._scan_state else None + if stage is not None: + emit( + "seeder.scan_cancelled", + phase=phase.value, + stage=stage, + root=root, ) + self._emit_event( + "assets.seed.cancelled", + { + "scanned": self._scan_state.scanned if self._scan_state else 0, + "total": total_paths, + "created": total_created, + }, + ) + finally: + with self._lock: + start_paused = self._state is State.PAUSED + self._reset_to_idle() + pending = self._pending_scan + if pending is not None: + self._pending_scan = None + if not self.start( + roots=pending["roots"], + phase=pending["phase"], + prune_first=False, + compute_hashes=pending["compute_hashes"], + _start_paused=start_paused, + ): + logging.warning( + "Pending scan could not start (roots=%s, phase=%s)", + pending["roots"], + pending["phase"].value, + ) def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]: """Run phase 1: fast scan to create stub records. @@ -664,17 +783,19 @@ def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]: existing_paths: set[str] = set() t_sync = time.perf_counter() + assert self._scan_state is not None + scan_state = self._scan_state for r in roots: - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.FAST_SCAN): return total_created, skipped_existing, 0 - existing_paths.update(sync_root_safely(r)) + existing_paths.update(sync_root_safely(r, scan_state)) logging.debug( "Fast scan: sync_root phase took %.3fs (%d existing paths)", time.perf_counter() - t_sync, len(existing_paths), ) - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.FAST_SCAN): return total_created, skipped_existing, 0 t_collect = time.perf_counter() @@ -698,7 +819,7 @@ def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]: paths, existing_paths, enable_metadata_extraction=False, - compute_hashes=False, + progress=scan_state, ) logging.debug( "Fast scan: build_asset_specs took %.3fs (%d specs, %d skipped)", @@ -708,7 +829,7 @@ def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]: ) self._update_progress(skipped=skipped_existing) - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.FAST_SCAN): return total_created, skipped_existing, total_paths batch_size = 500 @@ -716,7 +837,7 @@ def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]: progress_interval = 1.0 for i in range(0, len(specs), batch_size): - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.FAST_SCAN): logging.info( "Fast scan cancelled after %d/%d files (created=%d)", i, @@ -733,6 +854,7 @@ def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]: except Exception as e: self._add_error(f"Batch insert failed at offset {i}: {e}") logging.exception("Batch insert failed at offset %d", i) + emit("seeder.batch_insert_failed", error_type=error_type(e)) scanned = i + len(batch) now = time.perf_counter() @@ -751,6 +873,9 @@ def _run_fast_phase(self, roots: tuple[RootType, ...]) -> tuple[int, int, int]: last_progress_time = now self._update_progress(scanned=len(specs), created=total_created) + with create_session() as session: + tick_watch_list(session) + session.commit() logging.info( "Fast scan complete: %.3fs total (created=%d, skipped=%d, total_paths=%d)", time.perf_counter() - t_fast_start, @@ -767,16 +892,19 @@ def _run_enrich_phase(self, roots: tuple[RootType, ...]) -> tuple[bool, int]: Tuple of (cancelled, total_enriched) """ total_enriched = 0 + scan_state = self._scan_state + with create_session() as session: + drain_pending_verifications(session) + tick_watch_list(session) + for _ in range(3): + drain_transition_queue(session) + session.commit() + if pending_transition_count() == 0: + break batch_size = 100 last_progress_time = time.perf_counter() progress_interval = 1.0 - # Get the target enrichment level based on compute_hashes - if not self._compute_hashes: - target_max_level = ENRICHMENT_STUB - else: - target_max_level = ENRICHMENT_METADATA - self._emit_event( "assets.seed.started", {"roots": list(roots), "phase": "enrich"}, @@ -786,25 +914,21 @@ def _run_enrich_phase(self, roots: tuple[RootType, ...]) -> tuple[bool, int]: consecutive_empty = 0 max_consecutive_empty = 3 - # Hash checkpoints survive across batches so interrupted hashes - # can be resumed without re-reading the entire file. - hash_checkpoints: dict[str, object] = {} - while True: - if self._check_pause_and_cancel(): + if self._check_pause_and_cancel(_ScanStage.ENRICH): logging.info("Enrich scan cancelled after %d assets", total_enriched) return True, total_enriched # Fetch next batch of unenriched assets unenriched = get_unenriched_assets_for_roots( roots, - max_level=target_max_level, + compute_hashes=self._compute_hashes, limit=batch_size, ) # Filter out previously failed references if skip_ids: - unenriched = [r for r in unenriched if r.reference_id not in skip_ids] + unenriched = [row for row in unenriched if row.record_id not in skip_ids] if not unenriched: break @@ -814,7 +938,7 @@ def _run_enrich_phase(self, roots: tuple[RootType, ...]) -> tuple[bool, int]: extract_metadata=True, compute_hash=self._compute_hashes, interrupt_check=self._is_paused_or_cancelled, - hash_checkpoints=hash_checkpoints, + progress=scan_state, ) total_enriched += enriched skip_ids.update(failed_ids) diff --git a/app/assets/services/__init__.py b/app/assets/services/__init__.py index 2747ad35b3c..e18fb2ce528 100644 --- a/app/assets/services/__init__.py +++ b/app/assets/services/__init__.py @@ -1,93 +1,43 @@ -from app.assets.services.asset_management import ( - asset_exists, - delete_asset_reference, - get_asset_by_hash, - get_asset_detail, - list_assets_page, - get_preview_file_paths, - resolve_asset_for_download, - set_asset_preview, - update_asset_metadata, -) -from app.assets.services.bulk_ingest import ( - BulkInsertResult, - batch_insert_seed_assets, - cleanup_unreferenced_assets, -) -from app.assets.services.file_utils import ( - get_mtime_ns, - get_size_and_mtime_ns, - list_files_recursively, - verify_file_unchanged, -) +"""Re-exports the asset service functions so routes and the seeder import them +from one place instead of reaching into individual service modules. +""" + from app.assets.services.ingest import ( DependencyMissingError, HashMismatchError, - create_from_hash, - ingest_existing_file, - register_output_files, + UploadUnstableError, upload_from_temp_path, + create_from_hash, + register_file_in_place, ) -from app.assets.database.queries import ( - AddTagsResult, - RemoveTagsResult, -) -from app.assets.services.schemas import ( - AssetData, - AssetDetailResult, - AssetSummaryData, - DownloadResolutionResult, - IngestResult, - ListAssetsResult, - ReferenceData, - RegisterAssetResult, - TagUsage, - UploadResult, - UserMetadata, +from app.assets.services.asset_management import ( + get_asset_detail, + update_asset_metadata, + delete_asset_reference, + asset_exists, + get_preview_file_paths, + resolve_asset_for_download, ) from app.assets.services.tagging import ( apply_tags, - list_tags, remove_tags, + list_tags, ) __all__ = [ - "AddTagsResult", - "AssetData", - "AssetDetailResult", - "AssetSummaryData", - "ReferenceData", - "BulkInsertResult", "DependencyMissingError", - "DownloadResolutionResult", "HashMismatchError", - "IngestResult", - "ListAssetsResult", - "RegisterAssetResult", - "RemoveTagsResult", - "TagUsage", - "UploadResult", - "UserMetadata", - "apply_tags", - "asset_exists", - "batch_insert_seed_assets", + "UploadUnstableError", + "upload_from_temp_path", "create_from_hash", - "delete_asset_reference", - "get_asset_by_hash", + "register_file_in_place", "get_asset_detail", - "ingest_existing_file", - "register_output_files", - "get_mtime_ns", - "get_size_and_mtime_ns", - "list_assets_page", - "list_files_recursively", - "list_tags", - "cleanup_unreferenced_assets", - "remove_tags", + "update_asset_metadata", + "delete_asset_reference", + "asset_exists", "get_preview_file_paths", "resolve_asset_for_download", - "set_asset_preview", - "update_asset_metadata", - "upload_from_temp_path", - "verify_file_unchanged", + "apply_tags", + "remove_tags", + "list_tags", ] diff --git a/app/assets/services/asset_management.py b/app/assets/services/asset_management.py index a04b7f21a8f..0f7ad716c1c 100644 --- a/app/assets/services/asset_management.py +++ b/app/assets/services/asset_management.py @@ -1,77 +1,87 @@ -import contextlib +"""Serves the per-asset operations behind the API: reading an asset's detail, +updating its name, tags, metadata and preview, deleting a record, and resolving +a hash to a servable path. An update moves ``updated_at`` only when it actually +changed something, so a call that requests what is already true is not recorded +as a user edit. +""" + import mimetypes import os -from datetime import timezone from typing import Sequence -from app.assets.services.cursor import ( - CursorPayload, - InvalidCursorError, - decode_cursor, - decode_cursor_int, - decode_cursor_time, - encode_cursor, - encode_cursor_from_time, -) - +from sqlalchemy import delete, select, update -from app.assets.database.models import Asset +from app.assets.database.models import Asset, AssetContent, AssetTag from app.assets.database.queries import ( - asset_exists_by_hash, - reference_exists_for_asset_id, - delete_reference_by_id, - fetch_reference_and_asset, - get_reference_paths_by_ids, - soft_delete_reference_by_id, - fetch_reference_asset_and_tags, - get_asset_by_hash as queries_get_asset_by_hash, - get_reference_by_id, - get_reference_with_owner_check, - list_references_page, - list_all_file_paths_by_asset_id, - list_references_by_asset_id, - set_reference_metadata, - set_reference_preview, - set_reference_tags, - update_asset_hash_and_mime, - update_reference_access_time, - update_reference_name, - update_reference_updated_at, + delete_record, + fetch_record_tags, + get_record_by_id, + update_record_access_time, ) -from app.assets.helpers import select_best_live_path -from app.assets.services.path_utils import compute_loader_path +from app.assets.database.queries.records import ( + bump_record_updated_at, + ensure_tag, + ensure_tag_link, + get_preview_file_paths_by_ids, + rename_record, +) +from app.assets.helpers import get_utc_now, normalize_tags, validate_blake3_hash +from app.assets.services.lookup import lookup_for_view from app.assets.services.schemas import ( AssetData, AssetDetailResult, - AssetSummaryData, DownloadResolutionResult, - ListAssetsResult, + ReferenceData, UserMetadata, - extract_asset_data, - extract_reference_data, ) from app.database.db import create_session +def _record_to_detail_result(session, record) -> AssetDetailResult: + content = session.get(AssetContent, record.content_id) + tags = fetch_record_tags(session, record.id) + api_hash = content.hash if content else None + ref = ReferenceData( + id=record.id, + name=record.name, + file_path=content.path if content else None, + loader_path=record.loader_path, + user_metadata=record.user_metadata, + preview_id=record.preview_id, + system_metadata=record.system_metadata, + job_id=record.job_id, + created_at=record.created_at, + updated_at=record.updated_at, + last_access_time=record.last_access_time, + ) + asset = AssetData( + hash=api_hash, + size_bytes=content.size_bytes if content else None, + mime_type=record.mime_type, + is_missing=bool(content and content.is_missing), + ) + return AssetDetailResult(ref=ref, asset=asset, tags=tags) + + def get_asset_detail( reference_id: str, - owner_id: str = "", ) -> AssetDetailResult | None: with create_session() as session: - result = fetch_reference_asset_and_tags( - session, - reference_id=reference_id, - owner_id=owner_id, - ) - if not result: + record = get_record_by_id(session, reference_id) + if record is None: return None + return _record_to_detail_result(session, record) - ref, asset, tags = result - return AssetDetailResult( - ref=extract_reference_data(ref), - asset=extract_asset_data(asset), - tags=tags, + +def _fetch_manual_tags(session, reference_id: str) -> set[str]: + return set( + session.scalars( + select(AssetTag.tag_name).where( + AssetTag.asset_id == reference_id, + AssetTag.origin != "automatic", + ) ) + ) def update_asset_metadata( @@ -80,342 +90,132 @@ def update_asset_metadata( tags: Sequence[str] | None = None, user_metadata: UserMetadata = None, tag_origin: str = "manual", - owner_id: str = "", mime_type: str | None = None, preview_id: str | None = None, ) -> AssetDetailResult: with create_session() as session: - ref = get_reference_with_owner_check(session, reference_id, owner_id) + record = get_record_by_id(session, reference_id) + if record is None: + raise ValueError(f"Asset {reference_id} not found") - touched = False - if name is not None and name != ref.name: - update_reference_name(session, reference_id=reference_id, name=name) - touched = True - - computed_filename = compute_loader_path(ref.file_path) if ref.file_path else None - - new_meta: dict | None = None + if name is not None: + rename_record(session, reference_id, name) if user_metadata is not None: - new_meta = dict(user_metadata) - elif computed_filename: - current_meta = ref.user_metadata or {} - if current_meta.get("filename") != computed_filename: - new_meta = dict(current_meta) - - if new_meta is not None: - if computed_filename: - new_meta["filename"] = computed_filename - set_reference_metadata( - session, reference_id=reference_id, user_metadata=new_meta + session.execute( + update(Asset) + .where(Asset.id == reference_id) + .values(user_metadata=dict(user_metadata), updated_at=get_utc_now()) ) - touched = True - + manual_tags_before: set[str] = set() if tags is not None: - set_reference_tags( - session, - reference_id=reference_id, - tags=tags, - origin=tag_origin, + manual_tags_before = _fetch_manual_tags(session, reference_id) + session.execute( + delete(AssetTag).where( + AssetTag.asset_id == reference_id, + AssetTag.origin != "automatic", + ) ) - touched = True - if mime_type is not None: - updated = update_asset_hash_and_mime( - session, asset_id=ref.asset_id, mime_type=mime_type + session.execute( + update(Asset) + .where(Asset.id == reference_id) + .values(mime_type=mime_type, updated_at=get_utc_now()) ) - if updated: - touched = True - if preview_id is not None: - set_reference_preview( - session, - reference_id=reference_id, - preview_reference_id=preview_id, + if session.get(Asset, preview_id) is None: + raise ValueError( + f"preview_id {preview_id!r} does not reference an existing asset" + ) + session.execute( + update(Asset) + .where(Asset.id == reference_id) + .values(preview_id=preview_id, updated_at=get_utc_now()) ) - touched = True - - if touched and user_metadata is None: - update_reference_updated_at(session, reference_id=reference_id) - - result = fetch_reference_asset_and_tags( - session, - reference_id=reference_id, - owner_id=owner_id, - ) - if not result: - raise RuntimeError("State changed during update") - - ref, asset, tag_list = result - detail = AssetDetailResult( - ref=extract_reference_data(ref), - asset=extract_asset_data(asset), - tags=tag_list, - ) + if tags is not None: + for tag_name in normalize_tags(list(tags)): + ensure_tag(session, tag_name) + ensure_tag_link( + session, + asset_id=reference_id, + tag_name=tag_name, + origin=tag_origin, + ) + session.flush() + if _fetch_manual_tags(session, reference_id) != manual_tags_before: + bump_record_updated_at(session, reference_id) session.commit() - return detail + detail = get_asset_detail(reference_id) + if detail is None: + raise RuntimeError("Asset deleted during update") + return detail def delete_asset_reference( reference_id: str, - owner_id: str, - delete_content_if_orphan: bool = True, ) -> bool: - """Delete an asset reference. - - With ``delete_content_if_orphan=False`` (a soft delete), the reference is - hidden and the underlying content is preserved. With ``True``, the content - is also removed once it becomes orphaned. - - Note: the public DELETE /api/assets/{id} endpoint always soft-deletes - (passes ``False``); the orphan-reclamation path is intentionally - internal-only, retained for a future GC/admin caller. - """ with create_session() as session: - if not delete_content_if_orphan: - # Soft delete: mark the reference as deleted but keep everything - deleted = soft_delete_reference_by_id( - session, reference_id=reference_id, owner_id=owner_id - ) - session.commit() - return deleted - - ref_row = get_reference_by_id(session, reference_id=reference_id) - asset_id = ref_row.asset_id if ref_row else None - file_path = ref_row.file_path if ref_row else None - - deleted = delete_reference_by_id( - session, reference_id=reference_id, owner_id=owner_id - ) - if not deleted: - session.commit() + if get_record_by_id(session, reference_id) is None: return False - - if not asset_id: - session.commit() - return True - - still_exists = reference_exists_for_asset_id(session, asset_id=asset_id) - if still_exists: - session.commit() - return True - - # Orphaned asset - gather ALL file paths (including - # soft-deleted / missing refs) so their on-disk files get cleaned up. - file_paths = list_all_file_paths_by_asset_id(session, asset_id=asset_id) - # Also include the just-deleted file path - if file_path: - file_paths.append(file_path) - - asset_row = session.get(Asset, asset_id) - if asset_row is not None: - session.delete(asset_row) - - session.commit() - - # Delete files after commit - for p in file_paths: - with contextlib.suppress(Exception): - if p and os.path.isfile(p): - os.remove(p) - - return True - - -def set_asset_preview( - reference_id: str, - preview_reference_id: str | None = None, - owner_id: str = "", -) -> AssetDetailResult: - with create_session() as session: - get_reference_with_owner_check(session, reference_id, owner_id) - - set_reference_preview( - session, - reference_id=reference_id, - preview_reference_id=preview_reference_id, - ) - - result = fetch_reference_asset_and_tags( - session, reference_id=reference_id, owner_id=owner_id - ) - if not result: - raise RuntimeError("State changed during preview update") - - ref, asset, tags = result - detail = AssetDetailResult( - ref=extract_reference_data(ref), - asset=extract_asset_data(asset), - tags=tags, - ) + delete_record(session, reference_id) session.commit() - - return detail + return True def asset_exists(asset_hash: str) -> bool: + try: + canonical = validate_blake3_hash(asset_hash) + except ValueError: + return False with create_session() as session: - return asset_exists_by_hash(session, asset_hash=asset_hash) - - -def get_asset_by_hash(asset_hash: str) -> AssetData | None: - with create_session() as session: - asset = queries_get_asset_by_hash(session, asset_hash=asset_hash) - return extract_asset_data(asset) - - -# Sort fields that support cursor pagination. `last_access_time` is not -# in this list — it falls back to offset/limit. -_CURSOR_SORT_FIELDS = ("created_at", "updated_at", "name", "size") - - -def list_assets_page( - owner_id: str = "", - include_tags: Sequence[str] | None = None, - exclude_tags: Sequence[str] | None = None, - name_contains: str | None = None, - metadata_filter: dict | None = None, - limit: int = 20, - offset: int = 0, - sort: str = "created_at", - order: str = "desc", - after: str | None = None, - # Appended last so pre-existing positional callers keep binding correctly. - any_tags: Sequence[str] | None = None, -) -> ListAssetsResult: - """List assets with optional cursor pagination. - - When ``after`` is supplied it overrides ``offset``. The cursor's sort field - must match ``sort`` and be in the cursor-supported allowlist; mismatches - raise InvalidCursorError so the handler can map to 400 INVALID_CURSOR. - """ - cursor_value: object | None = None - cursor_id: str | None = None - # Mint next_cursor on every page where the sort is cursor-supported, not - # only when the request itself arrived with a cursor. Otherwise a first - # request (no `after`) returns next_cursor=None and the client can never - # enter cursor mode. - mint_cursor = sort in _CURSOR_SORT_FIELDS - - if after is not None: - if sort not in _CURSOR_SORT_FIELDS: - raise InvalidCursorError( - f"cursor pagination is not supported for sort={sort!r}" - ) - payload = decode_cursor(after, _CURSOR_SORT_FIELDS, expected_order=order) - if payload.sort_field != sort: - raise InvalidCursorError( - f"cursor sort field {payload.sort_field!r} does not match request sort {sort!r}" - ) - cursor_value, cursor_id = _resolve_cursor_value(payload), payload.id - - # Over-fetch by one row so we can distinguish "exactly `limit` rows total - # remaining" from "more rows past this page" without a second query. Drop - # the sentinel before returning. - fetch_limit = limit + 1 if mint_cursor else limit - - with create_session() as session: - refs, tag_map, total = list_references_page( - session, - owner_id=owner_id, - include_tags=include_tags, - exclude_tags=exclude_tags, - any_tags=any_tags, - name_contains=name_contains, - metadata_filter=metadata_filter, - limit=fetch_limit, - offset=offset, - sort=sort, - order=order, - after_cursor_value=cursor_value, - after_cursor_id=cursor_id, - ) - - next_cursor: str | None = None - if mint_cursor and len(refs) > limit: - # There's at least one more row past this page — mint a cursor from - # the last row of the page (i.e. index `limit - 1`, since we - # over-fetched), and drop the sentinel. - next_cursor = _encode_next_cursor(refs[limit - 1], sort, order) - refs = refs[:limit] - - items: list[AssetSummaryData] = [] - for ref in refs: - items.append( - AssetSummaryData( - ref=extract_reference_data(ref), - asset=extract_asset_data(ref.asset), - tags=tag_map.get(ref.id, []), - ) - ) - - return ListAssetsResult(items=items, total=total, next_cursor=next_cursor) - - -def _resolve_cursor_value(payload: CursorPayload) -> object: - """Map a decoded cursor payload to a column-typed Python value.""" - if payload.sort_field in ("created_at", "updated_at"): - # DB stores naive UTC; strip tzinfo so the comparison binds against a - # `TIMESTAMP WITHOUT TIME ZONE` column without an offset shift. - return decode_cursor_time(payload).replace(tzinfo=None) - if payload.sort_field == "size": - return decode_cursor_int(payload) - return payload.value # name, str-typed - - -def _encode_next_cursor(ref, sort: str, order: str) -> str | None: - """Mint a cursor pointing at *ref* for the given sort dimension. - - Returns None when the boundary row carries a NULL sort value (e.g. an asset - record whose size_bytes hasn't been backfilled). Continuing pagination - across a NULL boundary is undefined under keyset ordering — better to - truncate cleanly here than to mint a cursor that mis-positions. - """ - if sort == "name": - return encode_cursor("name", ref.name, ref.id, order=order) - if sort == "size": - if ref.asset is None or ref.asset.size_bytes is None: - return None - return encode_cursor("size", str(ref.asset.size_bytes), ref.id, order=order) - # created_at / updated_at — DB datetimes are naive UTC; attach tz before encoding. - value = ref.created_at if sort == "created_at" else ref.updated_at - if value is None: - return None - return encode_cursor_from_time(sort, value.replace(tzinfo=timezone.utc), ref.id, order=order) + return lookup_for_view(session, canonical) is not None def resolve_hash_to_path( asset_hash: str, - owner_id: str = "", ) -> DownloadResolutionResult | None: - """Resolve a blake3 hash to an on-disk file path. + """Resolve a blake3 hash to an on-disk file path via lookup_for_view. - Only references visible to *owner_id* are considered (owner-less - references are always visible). + Uses the first qualified live content row. Temp paths are excluded from all + hash lookups inside qualified_content_iterator, so a hash resolving only to + temp content returns None. Updates last_access_time on every record pointing + at the served content. - Returns a DownloadResolutionResult with abs_path, content_type, and - download_name, or None if no asset or live path is found. + Filename and Content-Type both come from the newest record so they never + describe different records. Deleting the last record preserves its content, + so content with zero records stays servable off the content path alone. """ + try: + canonical = validate_blake3_hash(asset_hash) + except ValueError: + return None with create_session() as session: - asset = queries_get_asset_by_hash(session, asset_hash) - if not asset: - return None - refs = list_references_by_asset_id(session, asset_id=asset.id) - visible = [ - r for r in refs - if r.owner_id == "" or r.owner_id == owner_id - ] - abs_path = select_best_live_path(visible) - if not abs_path: + content = lookup_for_view(session, canonical) + if content is None: return None - display_name = os.path.basename(abs_path) - for ref in visible: - if ref.file_path == abs_path and ref.name: - display_name = ref.name - break + + records = list( + session.scalars( + select(Asset) + .where(Asset.content_id == content.id) + .order_by(Asset.created_at, Asset.id) + ) + ) + display_name = os.path.basename(content.path) + mime_type = None + if records: + latest_record = records[-1] + display_name = latest_record.name or display_name + mime_type = latest_record.mime_type + for record in records: + update_record_access_time(session, record.id) + abs_path = content.path + session.commit() + ctype = ( - asset.mime_type + mime_type or mimetypes.guess_type(display_name)[0] + or mimetypes.guess_type(abs_path)[0] or "application/octet-stream" ) return DownloadResolutionResult( @@ -430,40 +230,33 @@ def get_preview_file_paths(preview_ids: list[str]) -> dict[str, str]: if not preview_ids: return {} with create_session() as session: - return get_reference_paths_by_ids(session, reference_ids=preview_ids) + return get_preview_file_paths_by_ids(session, preview_ids=preview_ids) def resolve_asset_for_download( reference_id: str, - owner_id: str = "", ) -> DownloadResolutionResult: with create_session() as session: - pair = fetch_reference_and_asset( - session, reference_id=reference_id, owner_id=owner_id - ) - if not pair: + record = get_record_by_id(session, reference_id) + if record is None: raise ValueError(f"AssetReference {reference_id} not found") - ref, asset = pair - - # For references with file_path, use that directly - if ref.file_path and os.path.isfile(ref.file_path): - abs_path = ref.file_path - else: - # For API-created refs without file_path, find a path from other refs - refs = list_references_by_asset_id(session, asset_id=asset.id) - abs_path = select_best_live_path(refs) - if not abs_path: - raise FileNotFoundError( - f"No live path for AssetReference {reference_id} " - f"(asset id={asset.id}, name={ref.name})" - ) + content = session.get(AssetContent, record.content_id) + if ( + content is None + or content.is_missing + or not os.path.isfile(content.path) + ): + raise FileNotFoundError( + f"No live content for AssetReference {reference_id} " + f"(content id={record.content_id}, name={record.name})" + ) - # Capture ORM attributes before commit (commit expires loaded objects) - ref_name = ref.name - asset_mime = asset.mime_type + ref_name = record.name + asset_mime = record.mime_type + abs_path = content.path - update_reference_access_time(session, reference_id=reference_id) + update_record_access_time(session, reference_id) session.commit() ctype = ( diff --git a/app/assets/services/bulk_ingest.py b/app/assets/services/bulk_ingest.py deleted file mode 100644 index c98658bf157..00000000000 --- a/app/assets/services/bulk_ingest.py +++ /dev/null @@ -1,294 +0,0 @@ -from __future__ import annotations - -import os -import uuid -from dataclasses import dataclass -from datetime import datetime -from typing import TYPE_CHECKING, Any, TypedDict - -from sqlalchemy.orm import Session - -from app.assets.database.queries import ( - bulk_insert_assets, - bulk_insert_references_ignore_conflicts, - bulk_insert_tags_and_meta, - delete_assets_by_ids, - get_existing_asset_ids, - get_reference_ids_by_ids, - get_references_by_paths_and_asset_ids, - get_unreferenced_unhashed_asset_ids, - restore_references_by_paths, -) -from app.assets.helpers import get_utc_now - -if TYPE_CHECKING: - from app.assets.services.metadata_extract import ExtractedMetadata - - -class SeedAssetSpec(TypedDict): - """Spec for seeding an asset from filesystem.""" - - abs_path: str - size_bytes: int - mtime_ns: int - info_name: str - tags: list[str] - fname: str - metadata: ExtractedMetadata | None - hash: str | None - mime_type: str | None - job_id: str | None - - -class AssetRow(TypedDict): - """Row data for inserting an Asset.""" - - id: str - hash: str | None - size_bytes: int - mime_type: str | None - created_at: datetime - - -class ReferenceRow(TypedDict): - """Row data for inserting an AssetReference.""" - - id: str - asset_id: str - file_path: str - loader_path: str | None - mtime_ns: int - owner_id: str - name: str - preview_id: str | None - user_metadata: dict[str, Any] | None - job_id: str | None - created_at: datetime - updated_at: datetime - last_access_time: datetime - - -class TagRow(TypedDict): - """Row data for inserting a Tag.""" - - asset_reference_id: str - tag_name: str - origin: str - added_at: datetime - - -class MetadataRow(TypedDict): - """Row data for inserting asset metadata.""" - - asset_reference_id: str - key: str - ordinal: int - val_str: str | None - val_num: float | None - val_bool: bool | None - val_json: dict[str, Any] | None - - -@dataclass -class BulkInsertResult: - """Result of bulk asset insertion.""" - - inserted_refs: int - won_paths: int - lost_paths: int - - -def batch_insert_seed_assets( - session: Session, - specs: list[SeedAssetSpec], - owner_id: str = "", -) -> BulkInsertResult: - """Seed assets from filesystem specs in batch. - - Each spec is a dict with keys: - - abs_path: str - - size_bytes: int - - mtime_ns: int - - info_name: str - - tags: list[str] - - fname: Optional[str] - - This function orchestrates: - 1. Insert seed Assets (hash=NULL) - 2. Claim references with ON CONFLICT DO NOTHING on file_path - 3. Query to find winners (paths where our asset_id was inserted) - 4. Delete Assets for losers (path already claimed by another asset) - 5. Insert tags and metadata for successfully inserted references - - Returns: - BulkInsertResult with inserted_refs, won_paths, lost_paths - """ - if not specs: - return BulkInsertResult(inserted_refs=0, won_paths=0, lost_paths=0) - - current_time = get_utc_now() - asset_rows: list[AssetRow] = [] - reference_rows: list[ReferenceRow] = [] - path_to_asset_id: dict[str, str] = {} - asset_id_to_ref_data: dict[str, dict] = {} - absolute_path_list: list[str] = [] - - for spec in specs: - absolute_path = os.path.abspath(spec["abs_path"]) - existing_asset_id = path_to_asset_id.get(absolute_path) - if existing_asset_id is not None: - existing_tags = asset_id_to_ref_data[existing_asset_id]["tags"] - asset_id_to_ref_data[existing_asset_id]["tags"] = list( - dict.fromkeys([*existing_tags, *spec["tags"]]) - ) - continue - - asset_id = str(uuid.uuid4()) - reference_id = str(uuid.uuid4()) - absolute_path_list.append(absolute_path) - path_to_asset_id[absolute_path] = asset_id - - mime_type = spec.get("mime_type") - asset_rows.append( - { - "id": asset_id, - "hash": spec.get("hash"), - "size_bytes": spec["size_bytes"], - "mime_type": mime_type, - "created_at": current_time, - } - ) - - # Build user_metadata from extracted metadata or fallback to filename - extracted_metadata = spec.get("metadata") - if extracted_metadata: - user_metadata: dict[str, Any] | None = extracted_metadata.to_user_metadata() - elif spec["fname"]: - user_metadata = {"filename": spec["fname"]} - else: - user_metadata = None - - reference_rows.append( - { - "id": reference_id, - "asset_id": asset_id, - "file_path": absolute_path, - # spec["fname"] is compute_loader_path(abs_path) from build_asset_specs. - "loader_path": spec["fname"], - "mtime_ns": spec["mtime_ns"], - "owner_id": owner_id, - "name": spec["info_name"], - "preview_id": None, - "user_metadata": user_metadata, - "job_id": spec.get("job_id"), - "created_at": current_time, - "updated_at": current_time, - "last_access_time": current_time, - } - ) - - asset_id_to_ref_data[asset_id] = { - "reference_id": reference_id, - "tags": spec["tags"], - "filename": spec["fname"], - "extracted_metadata": extracted_metadata, - } - - bulk_insert_assets(session, asset_rows) - - # Filter reference rows to only those whose assets were actually inserted - # (assets with duplicate hashes are silently dropped by ON CONFLICT DO NOTHING) - inserted_asset_ids = get_existing_asset_ids( - session, [r["asset_id"] for r in reference_rows] - ) - reference_rows = [r for r in reference_rows if r["asset_id"] in inserted_asset_ids] - - bulk_insert_references_ignore_conflicts(session, reference_rows) - restore_references_by_paths(session, absolute_path_list) - winning_paths = get_references_by_paths_and_asset_ids(session, path_to_asset_id) - - inserted_paths = { - path - for path in absolute_path_list - if path_to_asset_id[path] in inserted_asset_ids - } - losing_paths = inserted_paths - winning_paths - lost_asset_ids = [path_to_asset_id[path] for path in losing_paths] - - if lost_asset_ids: - delete_assets_by_ids(session, lost_asset_ids) - - if not winning_paths: - return BulkInsertResult( - inserted_refs=0, - won_paths=0, - lost_paths=len(losing_paths), - ) - - # Get reference IDs for winners - winning_ref_ids = [ - asset_id_to_ref_data[path_to_asset_id[path]]["reference_id"] - for path in winning_paths - ] - inserted_ref_ids = get_reference_ids_by_ids(session, winning_ref_ids) - - tag_rows: list[TagRow] = [] - metadata_rows: list[MetadataRow] = [] - - if inserted_ref_ids: - for path in winning_paths: - asset_id = path_to_asset_id[path] - ref_data = asset_id_to_ref_data[asset_id] - ref_id = ref_data["reference_id"] - - if ref_id not in inserted_ref_ids: - continue - - for tag in ref_data["tags"]: - tag_rows.append( - { - "asset_reference_id": ref_id, - "tag_name": tag, - "origin": "automatic", - "added_at": current_time, - } - ) - - # Use extracted metadata for meta rows if available - extracted_metadata = ref_data.get("extracted_metadata") - if extracted_metadata: - metadata_rows.extend(extracted_metadata.to_meta_rows(ref_id)) - elif ref_data["filename"]: - # Fallback: just store filename - metadata_rows.append( - { - "asset_reference_id": ref_id, - "key": "filename", - "ordinal": 0, - "val_str": ref_data["filename"], - "val_num": None, - "val_bool": None, - "val_json": None, - } - ) - - bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=metadata_rows) - - return BulkInsertResult( - inserted_refs=len(inserted_ref_ids), - won_paths=len(winning_paths), - lost_paths=len(losing_paths), - ) - - -def cleanup_unreferenced_assets(session: Session) -> int: - """Hard-delete unhashed assets with no active references. - - This is a destructive operation intended for explicit cleanup. - Only deletes assets where hash=None and all references are missing. - - Returns: - Number of assets deleted - """ - unreferenced_ids = get_unreferenced_unhashed_asset_ids(session) - return delete_assets_by_ids(session, unreferenced_ids) diff --git a/app/assets/services/hash_mode_state.py b/app/assets/services/hash_mode_state.py new file mode 100644 index 00000000000..0e802a9517d --- /dev/null +++ b/app/assets/services/hash_mode_state.py @@ -0,0 +1,189 @@ +"""Tracks the persisted hashing mode and carries the existing catalog across an +off-to-on switch. Turning hashing on enqueues every live content row for +verification and the queue is drained in the background; a path that cannot be +read is retried a bounded number of times and then has its stored hash cleared, +so the queue can empty and the mode can flip. An unverifiable digest never +survives into the persisted enabled state. +""" + +from __future__ import annotations + +import logging +import os +from collections import deque +from dataclasses import dataclass +from typing import Final + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.assets import mode as _mode +from app.assets.database.models import AssetContent, AssetSystemState +from app.assets.database.queries.records import create_content, create_record, mark_content_missing +from app.assets.helpers import to_stored_hash +from app.assets.services.path_utils import compute_loader_path, get_name_and_tags_from_asset_path +from app.assets.services.snapshot_hash import snapshot_hash + +_KEY = "hash_mode" +_MAX_VERIFY_ATTEMPTS: Final = 3 + + +@dataclass +class _PendingEntry: + path: str + ticks: int = 0 + + +_PENDING_QUEUE: deque[_PendingEntry] = deque() +_PENDING_PATHS: set[str] = set() +_off_to_on_transition_in_flight = False + + +def clear_transition_queue() -> None: + global _off_to_on_transition_in_flight + + _PENDING_QUEUE.clear() + _PENDING_PATHS.clear() + _off_to_on_transition_in_flight = False + + +def pending_transition_count() -> int: + return len(_PENDING_QUEUE) + + +def read_stored_mode(session: Session) -> str | None: + row = session.get(AssetSystemState, _KEY) + return row.value if row else None + + +def write_stored_mode(session: Session, value: str) -> None: + row = session.get(AssetSystemState, _KEY) + if row is None: + session.add(AssetSystemState(key=_KEY, value=value)) + else: + row.value = value + session.flush() + + +def record_transition_intent(session: Session) -> str | None: + stored = read_stored_mode(session) + runtime = "on" if _mode.hashing_enabled() else "off" + if stored is None: + write_stored_mode(session, runtime) + return None + if stored == "off" and runtime == "on": + return "off_to_on" + if stored == "on" and runtime == "off": + write_stored_mode(session, "off") + return "on_to_off" + return None + + +def enqueue_transition_work(session: Session, transition: str | None) -> None: + global _off_to_on_transition_in_flight + + if transition != "off_to_on": + return + _off_to_on_transition_in_flight = True + rows = session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + for row in rows: + if row.path not in _PENDING_PATHS: + _PENDING_QUEUE.append(_PendingEntry(row.path)) + _PENDING_PATHS.add(row.path) + + +def _retry_or_retire(session: Session, entry: _PendingEntry) -> None: + entry.ticks += 1 + if entry.ticks < _MAX_VERIFY_ATTEMPTS: + _PENDING_QUEUE.append(entry) + _PENDING_PATHS.add(entry.path) + return + content = session.scalars( + select(AssetContent).where( + AssetContent.path == entry.path, AssetContent.is_missing.is_(False) + ) + ).first() + if content is not None: + content.hash = None + logging.warning( + "Could not verify %s in %d attempts; clearing its stored hash so the hash-mode " + "transition can complete", + entry.path, + _MAX_VERIFY_ATTEMPTS, + ) + + +def drain_transition_queue(session: Session) -> None: + global _off_to_on_transition_in_flight + + pending_count = len(_PENDING_QUEUE) + for _ in range(pending_count): + entry = _PENDING_QUEUE.popleft() + _PENDING_PATHS.discard(entry.path) + path = entry.path + try: + snapshot = snapshot_hash(path) + except OSError: + _retry_or_retire(session, entry) + continue + if snapshot is None: + # snapshot_hash returns None for vanished and unstable files; stat distinguishes them. + try: + os.stat(path) + except FileNotFoundError: + gone = session.scalars( + select(AssetContent).where( + AssetContent.path == path, AssetContent.is_missing.is_(False) + ) + ).first() + if gone is not None: + mark_content_missing(session, gone.id) + except OSError: + _retry_or_retire(session, entry) + else: + _retry_or_retire(session, entry) + continue + digest, stat = snapshot + stored_hash = to_stored_hash(digest) + content = session.scalars( + select(AssetContent).where( + AssetContent.path == path, AssetContent.is_missing.is_(False) + ) + ).first() + if content is None: + continue + if content.hash is None: + content.hash = stored_hash + content.size_bytes = stat.st_size + content.mtime_ns = stat.st_mtime_ns + elif content.hash != stored_hash: + try: + name, tags = get_name_and_tags_from_asset_path(path) + except ValueError: + logging.warning( + "Skipping hash-mode split for out-of-root path: %s", path + ) + continue + mark_content_missing(session, content.id) + replacement = create_content( + session, + path=path, + hash=stored_hash, + size_bytes=stat.st_size, + mtime_ns=stat.st_mtime_ns, + ) + create_record( + session, + content_id=replacement.id, + name=name, + loader_path=compute_loader_path(path), + tags=tags, + ) + else: + content.size_bytes = stat.st_size + content.mtime_ns = stat.st_mtime_ns + if _off_to_on_transition_in_flight and not _PENDING_QUEUE: + write_stored_mode(session, "on") + _off_to_on_transition_in_flight = False diff --git a/app/assets/services/ingest.py b/app/assets/services/ingest.py index 1ffb3d63465..a8fb3b81de7 100644 --- a/app/assets/services/ingest.py +++ b/app/assets/services/ingest.py @@ -1,468 +1,432 @@ +"""Turns incoming bytes into catalogued assets: multipart uploads moved into a +hash-addressed destination, files registered where they already sit, and +records created from a hash the catalog already holds. Every path persists the +stat that hashing verified, so a row's recorded size and mtime describe the +same observation as its hash. A live row already at the destination is +reconciled before the write, so an upload never adopts a fresh hash onto +records created for bytes it just replaced. +""" + import contextlib import logging import mimetypes import os -from typing import Any, Sequence +from typing import Any, NamedTuple, Sequence +from sqlalchemy import func, select from sqlalchemy.orm import Session -import app.assets.services.hashing as hashing -from app.assets.database.queries import ( - add_tags_to_reference, - count_active_siblings, - create_stub_asset, - ensure_tags_exist, - fetch_reference_and_asset, - get_asset_by_hash, - get_reference_by_file_path, - get_reference_tags, - get_or_create_reference, - list_references_by_asset_id, - reference_exists, - remove_missing_tag_for_asset_id, - set_reference_metadata, - set_reference_system_metadata, - set_reference_tags, - update_asset_hash_and_mime, - upsert_asset, - upsert_reference, - validate_tags_exist, +from app.assets import mode +from app.assets.database.models import Asset, AssetContent, AssetTag +from app.assets.database.queries.records import ( + create_content_reporting_insert, + create_record, + mark_content_missing, ) -from app.assets.helpers import get_utc_now, normalize_tags -from app.assets.services.bulk_ingest import batch_insert_seed_assets -from app.assets.services.file_utils import get_size_and_mtime_ns +from app.assets.helpers import normalize_tags, to_stored_hash +from app.assets.services.file_utils import get_mtime_ns, get_size_and_mtime_ns from app.assets.services.image_dimensions import extract_image_dimensions +from app.assets.services.lookup import ( + claim_qualified_content, + lookup_for_from_hash, + lookup_for_view, + refresh_qualified_content, +) +from app.assets.services.metadata_extract import extract_file_metadata from app.assets.services.path_utils import ( compute_loader_path, get_name_and_tags_from_asset_path, - get_path_derived_tags_from_path, resolve_destination_from_tags, validate_path_within_base, ) from app.assets.services.schemas import ( - IngestResult, - RegisterAssetResult, + AssetData, + RegisteredAsset, + ReferenceData, UploadResult, UserMetadata, - extract_asset_data, - extract_reference_data, ) +from app.assets.services.snapshot_hash import snapshot_hash from app.database.db import create_session -def _ingest_file_from_path( - abs_path: str, - asset_hash: str, - size_bytes: int, - mtime_ns: int, - mime_type: str | None = None, - info_name: str | None = None, - owner_id: str = "", - preview_id: str | None = None, - user_metadata: UserMetadata = None, - tags: Sequence[str] = (), - tag_origin: str = "manual", - require_existing_tags: bool = False, -) -> IngestResult: - locator = os.path.abspath(abs_path) - user_metadata = user_metadata or {} - - asset_created = False - asset_updated = False - ref_created = False - ref_updated = False - reference_id: str | None = None +def _normalize_hash_input(hash_str: str) -> str: + if not hash_str: + return hash_str + normalized = hash_str.strip().lower() + digest = normalized.partition(":")[2] or normalized + return to_stored_hash(digest) - with create_session() as session: - if preview_id: - if not reference_exists(session, preview_id): - preview_id = None - - asset, asset_created, asset_updated = upsert_asset( - session, - asset_hash=asset_hash, - size_bytes=size_bytes, - mime_type=mime_type, - ) - - ref_created, ref_updated = upsert_reference( - session, - asset_id=asset.id, - file_path=locator, - name=info_name or os.path.basename(locator), - mtime_ns=mtime_ns, - owner_id=owner_id, - loader_path=compute_loader_path(locator), - ) - # Get the reference we just created/updated - ref = get_reference_by_file_path(session, locator) - if ref: - reference_id = ref.id - - if preview_id and ref.preview_id != preview_id: - ref.preview_id = preview_id - - try: - backend_tags = get_path_derived_tags_from_path(locator) - except ValueError: - backend_tags = [] - caller_tags = normalize_tags(tags) - backend_tags = normalize_tags(backend_tags) - all_tags = normalize_tags([*caller_tags, *backend_tags]) - if all_tags: - if require_existing_tags: - validate_tags_exist(session, all_tags) - if backend_tags: - add_tags_to_reference( - session, - reference_id=reference_id, - tags=backend_tags, - origin="automatic", - create_if_missing=not require_existing_tags, - ) - if caller_tags: - add_tags_to_reference( - session, - reference_id=reference_id, - tags=caller_tags, - origin=tag_origin, - create_if_missing=not require_existing_tags, - ) - - _update_metadata_with_filename( - session, - reference_id=reference_id, - file_path=ref.file_path, - current_metadata=ref.user_metadata, - user_metadata=user_metadata, - ) - - _maybe_store_image_dimensions( - session, - reference_id=reference_id, - file_path=locator, - mime_type=mime_type, - current_system_metadata=ref.system_metadata, - ) - - try: - remove_missing_tag_for_asset_id(session, asset_id=asset.id) - except Exception: - logging.exception("Failed to clear 'missing' tag for asset %s", asset.id) - - session.commit() +def _extract_system_metadata_sync( + locator: str, + mime_type: str | None, + stat_result: os.stat_result | None = None, +) -> dict[str, Any]: + """Extract ``system_metadata`` at registration time (S29/D8). - return IngestResult( - asset_created=asset_created, - asset_updated=asset_updated, - ref_created=ref_created, - ref_updated=ref_updated, - reference_id=reference_id, + Mirrors the ``scanner.enrich`` pass: tier-1/tier-2 file metadata plus image + dimensions for image MIME types, so records carry metadata at creation + instead of waiting for the background enrich pass to fill it. + """ + metadata = extract_file_metadata( + locator, + stat_result=stat_result, + relative_filename=compute_loader_path(locator), ) - - -def register_output_files( - file_paths: Sequence[str], - user_metadata: UserMetadata = None, - job_id: str | None = None, -) -> int: - """Register a batch of output file paths as assets. - - Returns the number of files successfully registered. + system_metadata = metadata.to_user_metadata() + if mime_type and mime_type.startswith("image/"): + dims = extract_image_dimensions(locator, mime_type=mime_type) + if dims: + system_metadata.update(dims) + return system_metadata + + +def _discard_unreferenced_content(session: Session, content_id: str) -> None: + """Remove a content row left orphaned by a failed registration. + + ``create_content`` inserts inside a SAVEPOINT (``begin_nested``); under + pysqlite that insert survives the enclosing ``rollback`` because pysqlite + has no real nested transaction. When the follow-on ``create_record`` fails + we would otherwise leak an unreferenced content row, so delete it explicitly + once we have confirmed no record points at it. Best-effort: cleanup errors + are logged and swallowed so the original failure is what surfaces. """ - registered = 0 - for abs_path in file_paths: - if not os.path.isfile(abs_path): - continue - try: - if ingest_existing_file( - abs_path, user_metadata=user_metadata, job_id=job_id - ): - registered += 1 - except Exception: - logging.exception("Failed to register output: %s", abs_path) - return registered - + try: + ref_count = session.scalar( + select(func.count()) + .select_from(Asset) + .where(Asset.content_id == content_id) + ) + if ref_count: + return + content = session.get(AssetContent, content_id) + if content is not None: + session.delete(content) + session.commit() + except Exception: + logging.exception("Failed to discard orphan content %s", content_id) -def ingest_existing_file( - abs_path: str, - user_metadata: UserMetadata = None, - extra_tags: Sequence[str] = (), - owner_id: str = "", - job_id: str | None = None, -) -> bool: - """Register an existing on-disk file as an asset stub. - If a reference already exists for this path, updates mtime_ns, job_id, - size_bytes, and resets enrichment so the enricher will re-hash it. +def _sanitize_filename(name: str | None, fallback: str) -> str: + n = os.path.basename((name or "").strip() or fallback) + return n if n else fallback - For brand-new paths, inserts a stub record (hash=NULL) for immediate - UX visibility. - Returns True if a row was inserted or updated, False otherwise. - """ - locator = os.path.abspath(abs_path) - size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path) - mime_type = mimetypes.guess_type(abs_path, strict=False)[0] - name, path_tags = get_name_and_tags_from_asset_path(abs_path) - tags = list(dict.fromkeys(path_tags + list(extra_tags))) +class HashMismatchError(Exception): + pass - with create_session() as session: - existing_ref = get_reference_by_file_path(session, locator) - if existing_ref is not None: - now = get_utc_now() - existing_ref.mtime_ns = mtime_ns - existing_ref.job_id = job_id - existing_ref.is_missing = False - existing_ref.deleted_at = None - existing_ref.updated_at = now - existing_ref.enrichment_level = 0 - - asset = existing_ref.asset - if asset: - # If other refs share this asset, detach to a new stub - # instead of mutating the shared row. - siblings = count_active_siblings(session, asset.id, existing_ref.id) - if siblings > 0: - new_asset = create_stub_asset( - session, - size_bytes=size_bytes, - mime_type=mime_type or asset.mime_type, - ) - existing_ref.asset_id = new_asset.id - else: - asset.hash = None - asset.size_bytes = size_bytes - if mime_type: - asset.mime_type = mime_type - session.commit() - return True - - spec = { - "abs_path": abs_path, - "size_bytes": size_bytes, - "mtime_ns": mtime_ns, - "info_name": name, - "tags": tags, - "fname": compute_loader_path(abs_path), - "metadata": None, - "hash": None, - "mime_type": mime_type, - "job_id": job_id, - } - if tags: - ensure_tags_exist(session, tags) - result = batch_insert_seed_assets(session, [spec], owner_id=owner_id) - session.commit() - return result.won_paths > 0 +class UploadUnstableError(Exception): + pass -def _register_existing_asset( - asset_hash: str, - name: str, - user_metadata: UserMetadata = None, - tags: list[str] | None = None, - tag_origin: str = "manual", - owner_id: str = "", - mime_type: str | None = None, - preview_id: str | None = None, -) -> RegisterAssetResult: - user_metadata = user_metadata or {} - with create_session() as session: - asset = get_asset_by_hash(session, asset_hash=asset_hash) - if not asset: - raise ValueError(f"No asset with hash {asset_hash}") +class DependencyMissingError(Exception): + def __init__(self, message: str): + self.message = message + super().__init__(message) - if mime_type and not asset.mime_type: - update_asset_hash_and_mime(session, asset_id=asset.id, mime_type=mime_type) - if preview_id: - if not reference_exists(session, preview_id): - preview_id = None +_UPLOAD_HASH_ATTEMPTS = 3 - ref, ref_created = get_or_create_reference( - session, - asset_id=asset.id, - owner_id=owner_id, - name=name, - preview_id=preview_id, - ) - if not ref_created: - if preview_id and ref.preview_id != preview_id: - ref.preview_id = preview_id +def _remove_temp_path(temp_path: str | None) -> None: + if not temp_path or not os.path.exists(temp_path): + return + with contextlib.suppress(OSError): + os.remove(temp_path) + parent = os.path.dirname(temp_path) + with contextlib.suppress(OSError): + if parent and os.path.isdir(parent): + os.rmdir(parent) - tag_names = get_reference_tags(session, reference_id=ref.id) - result = RegisterAssetResult( - ref=extract_reference_data(ref), - asset=extract_asset_data(asset), - tags=tag_names, - created=False, - ) - session.commit() - return result - new_meta = dict(user_metadata) - computed_filename = compute_loader_path(ref.file_path) if ref.file_path else None - if computed_filename: - new_meta["filename"] = computed_filename +def _snapshot_hash_with_retry(path: str) -> tuple[str, os.stat_result]: + for _ in range(_UPLOAD_HASH_ATTEMPTS): + snapshot = snapshot_hash(path) + if snapshot is not None: + return snapshot + raise UploadUnstableError("upload file changed during hashing") - if new_meta: - set_reference_metadata( - session, - reference_id=ref.id, - user_metadata=new_meta, - ) - _backfill_image_dimensions_from_siblings( - session, - asset_id=asset.id, - new_reference_id=ref.id, - current_system_metadata=ref.system_metadata, - ) +def _hash_mode_dest_path( + tags: list[str], + digest: str, + client_filename: str | None, + name: str | None, +) -> str: + base_dir, subdirs = resolve_destination_from_tags(tags) + dest_dir = os.path.join(base_dir, *subdirs) if subdirs else base_dir + os.makedirs(dest_dir, exist_ok=True) + src_for_ext = (client_filename or name or "").strip() + _ext = os.path.splitext(os.path.basename(src_for_ext))[1] if src_for_ext else "" + ext = _ext if 0 < len(_ext) <= 16 else "" + hashed_basename = f"{digest}{ext}" + dest_abs = os.path.abspath(os.path.join(dest_dir, hashed_basename)) + validate_path_within_base(dest_abs, base_dir) + return dest_abs - if tags is not None: - set_reference_tags( - session, - reference_id=ref.id, - tags=tags, - origin=tag_origin, - ) - tag_names = get_reference_tags(session, reference_id=ref.id) - session.refresh(ref) - result = RegisterAssetResult( - ref=extract_reference_data(ref), - asset=extract_asset_data(asset), - tags=tag_names, - created=True, - ) - session.commit() +def _guess_upload_mime_type( + mime_type: str | None, + client_filename: str | None, + name: str | None, + fallback_basename: str, +) -> str: + src_for_ext = (client_filename or name or "").strip() + if mime_type: + return mime_type + guessed = mimetypes.guess_type(os.path.basename(src_for_ext), strict=False)[0] + if guessed: + return guessed + guessed = mimetypes.guess_type(fallback_basename, strict=False)[0] + return guessed or "application/octet-stream" - return result +def _move_temp_to_dest(temp_path: str, dest_abs: str) -> None: + os.makedirs(os.path.dirname(dest_abs), exist_ok=True) + try: + os.replace(temp_path, dest_abs) + except Exception as e: + raise RuntimeError(f"failed to move uploaded file into place: {e}") from e -def _update_metadata_with_filename( +def _create_upload_record( session: Session, - reference_id: str, - file_path: str | None, - current_metadata: dict | None, - user_metadata: dict[str, Any], -) -> None: - computed_filename = compute_loader_path(file_path) if file_path else None + content_id: str, + name: str, + abs_path: str, + tags: Sequence[str], + mime_type: str | None, + user_metadata: UserMetadata, + preview_id: str | None, +) -> Asset: + if preview_id is not None and session.get(Asset, preview_id) is None: + raise ValueError(f"preview_id {preview_id!r} does not reference an existing asset") + record = create_record( + session, + content_id, + name, + mime_type=mime_type, + loader_path=compute_loader_path(abs_path), + tags=list(tags), + system_metadata=_extract_system_metadata_sync(abs_path, mime_type), + ) + if user_metadata: + record.user_metadata = dict(user_metadata) + if preview_id: + record.preview_id = preview_id + session.flush() + return record - current_meta = current_metadata or {} - new_meta = dict(current_meta) - for k, v in user_metadata.items(): - new_meta[k] = v - if computed_filename: - new_meta["filename"] = computed_filename - if new_meta != current_meta: - set_reference_metadata( - session, - reference_id=reference_id, - user_metadata=new_meta, +def _record_to_upload_result( + session: Session, record: Asset, *, created_new: bool +) -> UploadResult: + content = session.get(AssetContent, record.content_id) + tag_names = list( + session.scalars( + select(AssetTag.tag_name) + .where(AssetTag.asset_id == record.id) + .order_by(AssetTag.tag_name) ) + ) + api_hash = content.hash if content else None + ref = ReferenceData( + id=record.id, + name=record.name, + file_path=content.path if content else None, + loader_path=record.loader_path, + user_metadata=record.user_metadata, + preview_id=record.preview_id, + system_metadata=record.system_metadata, + job_id=record.job_id, + created_at=record.created_at, + updated_at=record.updated_at, + last_access_time=record.last_access_time, + ) + asset = AssetData( + hash=api_hash, + size_bytes=content.size_bytes if content else None, + mime_type=record.mime_type, + ) + return UploadResult( + ref=ref, + content_id=record.content_id, + asset=asset, + tags=tag_names, + created_new=created_new, + ) + +class _ContentFacts(NamedTuple): -_IMAGE_DIMENSION_KEYS = ("kind", "width", "height") + stored_hash: str + size_bytes: int + mtime_ns: int -def _maybe_store_image_dimensions( +def _reconcile_live_content_at_path( session: Session, - reference_id: str, - file_path: str, - mime_type: str | None, - current_system_metadata: dict | None, + locator: str, + facts: _ContentFacts, + *, + content_written: bool, ) -> None: - """Populate ``kind``/``width``/``height`` on system_metadata for image refs. - - Non-image MIME types are a no-op. Pre-existing keys (e.g. enricher-written - safetensors metadata, download provenance) are preserved by merge. + """Make the live content row at ``locator`` honest about the bytes there. + + ``create_content`` resolves a live-path uniqueness conflict + (``uq_asset_contents_path_live``) by handing back the row already at that + path, so a caller that registers a path without reconciling first gets the + OLD row's hash and size. + + ``content_written`` is the caller's own knowledge of whether it just wrote + bytes at ``locator``, and it is what makes an unhashed row readable at all: + equal ``size_bytes`` is NOT proof of equal bytes, so a caller that replaced + the file cannot adopt a fresh hash onto records that were created for the + bytes it destroyed. Cases, in order: + + * the hash already matches - proof the bytes are identical, so the OBSERVED + stat is the trustworthy one. Refresh it: a touch, a copy, or a same-bytes + rewrite moves ``mtime_ns`` without changing a byte, and a row left stale + fails ``lookup._stat_consistent``, which makes the content unservable by + hash and silently routes the caller into creating a duplicate record. + * the hash is ``None`` and the caller wrote nothing - "not yet hashed" is + NOT evidence of different bytes. Scanner-seeded rows and executed outputs + (D14a) both defer hashing to the enrich pass, so retiring here would mark + every record on the row missing for a file that never changed. Adopt the + hash we just computed the way ``scanner.enrich_asset`` would, unless the + row's recorded ``size_bytes`` positively contradicts the file. A + differing ``mtime_ns`` is deliberately not a contradiction: renaming a + file into place, copying it, or touching it all move the mtime while the + bytes stay identical. + * anything else - a known but different hash, or unhashed bytes the caller + has just overwritten. Either way the row can no longer speak for what is + at ``locator``: retire it so the fresh ``create_content`` inserts a new + live row and the old records keep describing the old bytes. """ - if not mime_type or not mime_type.startswith("image/"): + existing = session.scalars( + select(AssetContent).where( + AssetContent.path == locator, + AssetContent.is_missing.is_(False), + ) + ).first() + if existing is None: return - - dims = extract_image_dimensions(file_path, mime_type=mime_type) - if not dims: + if existing.hash == facts.stored_hash: + existing.size_bytes = facts.size_bytes + existing.mtime_ns = facts.mtime_ns + session.flush() return + if ( + existing.hash is None + and not content_written + and existing.size_bytes == facts.size_bytes + ): + existing.hash = facts.stored_hash + existing.mtime_ns = facts.mtime_ns + session.flush() + return + mark_content_missing(session, existing.id) - current = current_system_metadata or {} - merged = dict(current) - merged.update(dims) - if merged != current: - set_reference_system_metadata( - session, - reference_id=reference_id, - system_metadata=merged, - ) +def _upload_destination_or_none( + tags: list[str] | None, + digest: str, + client_filename: str | None, + name: str | None, +) -> str | None: + if not tags: + return None + try: + return _hash_mode_dest_path(tags, digest, client_filename, name) + except ValueError: + return None -def _backfill_image_dimensions_from_siblings( - session: Session, - asset_id: str, - new_reference_id: str, - current_system_metadata: dict | None, -) -> None: - """Copy image dimension keys from any sibling reference of the same asset. - The from-hash path doesn't read the file bytes, so dimensions can't be - extracted there directly. When another reference of the same asset already - carries image dimensions, copy them onto the new reference so consumers - see consistent metadata regardless of how the asset was registered. +def _settle_destination_before_write(session: Session, dest_abs: str) -> None: + """Resolve the live row at an upload destination while its bytes still exist. + + ``upload_from_temp_path`` is about to replace whatever sits at ``dest_abs``, + and that write destroys the only evidence of what the incumbent row was + created for. So reconcile against the INCUMBENT file's own hash first: an + unhashed row learns the hash of the bytes it actually describes, and a row + whose hash no longer matches the file is retired. Both outcomes leave the + post-move reconciliation with a known hash to compare, so it never has to + read equal sizes as equal bytes. - Best-effort: missing siblings, non-image siblings, or absent dimension - keys leave the target reference unchanged. + Running before ``lookup_for_view`` is what keeps a merely stale row from + being skipped as stat-inconsistent and duplicated. """ - current = current_system_metadata or {} - if current.get("kind") == "image" and "width" in current and "height" in current: + if not os.path.isfile(dest_abs): return - - for sibling in list_references_by_asset_id(session, asset_id): - if sibling.id == new_reference_id: - continue - meta = sibling.system_metadata or {} - if meta.get("kind") != "image": - continue - width = meta.get("width") - height = meta.get("height") - if ( - type(width) is not int - or type(height) is not int - or width <= 0 - or height <= 0 - ): - continue - merged = dict(current) - merged["kind"] = "image" - merged["width"] = width - merged["height"] = height - if merged != current: - set_reference_system_metadata( - session, - reference_id=new_reference_id, - system_metadata=merged, - ) + existing = session.scalars( + select(AssetContent).where( + AssetContent.path == dest_abs, + AssetContent.is_missing.is_(False), + ) + ).first() + if existing is None: return + size_bytes, mtime_ns = get_size_and_mtime_ns(dest_abs) + if ( + existing.hash is not None + and existing.size_bytes == size_bytes + and existing.mtime_ns == mtime_ns + ): + return + try: + incumbent_digest, verified_stat = _snapshot_hash_with_retry(dest_abs) + except (UploadUnstableError, OSError): + mark_content_missing(session, existing.id) + return + _reconcile_live_content_at_path( + session, + dest_abs, + _ContentFacts( + to_stored_hash(incumbent_digest), + verified_stat.st_size, + verified_stat.st_mtime_ns, + ), + content_written=False, + ) -def _sanitize_filename(name: str | None, fallback: str) -> str: - n = os.path.basename((name or "").strip() or fallback) - return n if n else fallback - +class _UploadRecordSpec(NamedTuple): -class HashMismatchError(Exception): - pass + name: str + tags: list[str] + mime_type: str | None + user_metadata: UserMetadata + preview_id: str | None -class DependencyMissingError(Exception): - def __init__(self, message: str): - self.message = message - super().__init__(message) +def _reuse_qualified_content( + session: Session, stored_hash: str, spec: _UploadRecordSpec +) -> UploadResult | None: + content = lookup_for_view(session, stored_hash) + if content is None: + return None + content_id = content.id + if not claim_qualified_content(session, content_id, stored_hash): + session.rollback() + return None + content = refresh_qualified_content(session, content_id) + if content is None: + session.rollback() + return None + record = _create_upload_record( + session, + content_id, + spec.name, + content.path, + spec.tags, + spec.mime_type, + spec.user_metadata, + spec.preview_id, + ) + session.commit() + return _record_to_upload_result(session, record, created_new=True) def upload_from_temp_path( @@ -471,184 +435,163 @@ def upload_from_temp_path( tags: list[str] | None = None, user_metadata: dict | None = None, client_filename: str | None = None, - owner_id: str = "", expected_hash: str | None = None, mime_type: str | None = None, preview_id: str | None = None, ) -> UploadResult: - try: - digest, _ = hashing.compute_blake3_hash(temp_path) - except ImportError as e: - raise DependencyMissingError(str(e)) - except Exception as e: - raise RuntimeError(f"failed to hash uploaded file: {e}") - asset_hash = "blake3:" + digest + display_name = _sanitize_filename(name or client_filename, fallback="upload") + user_metadata = user_metadata or {} - if expected_hash and asset_hash != expected_hash.strip().lower(): + try: + digest, verified_stat = _snapshot_hash_with_retry(temp_path) + except UploadUnstableError: + _remove_temp_path(temp_path) + raise + stored_hash = to_stored_hash(digest) + if expected_hash and stored_hash != _normalize_hash_input(expected_hash): + _remove_temp_path(temp_path) raise HashMismatchError("Uploaded file hash does not match provided hash.") + settle_target = _upload_destination_or_none(tags, digest, client_filename, name) with create_session() as session: - existing = get_asset_by_hash(session, asset_hash=asset_hash) - - if existing is not None: - # Once content is already known, duplicate byte uploads are treated as - # reference-only creation. Request tags are labels only here: do not - # require upload destination tags, do not move bytes, and do not - # synthesize path-derived classification or uploaded provenance. - with contextlib.suppress(Exception): - if temp_path and os.path.exists(temp_path): - os.remove(temp_path) - - display_name = _sanitize_filename(name or client_filename, fallback=digest) - result = _register_existing_asset( - asset_hash=asset_hash, - name=display_name, - user_metadata=user_metadata or {}, - tags=tags or [], - tag_origin="manual", - owner_id=owner_id, - mime_type=mime_type, - preview_id=preview_id, - ) - return UploadResult( - ref=result.ref, - asset=result.asset, - tags=result.tags, - created_new=False, + if settle_target is not None: + _settle_destination_before_write(session, settle_target) + session.commit() + reused = _reuse_qualified_content( + session, + stored_hash, + _UploadRecordSpec( + display_name, + normalize_tags([*(tags or []), "uploaded"]), + mime_type, + user_metadata, + preview_id, + ), ) + if reused is not None: + _remove_temp_path(temp_path) + return reused if not tags: + _remove_temp_path(temp_path) raise ValueError("tags are required for new asset uploads") - base_dir, subdirs = resolve_destination_from_tags(tags) - dest_dir = os.path.join(base_dir, *subdirs) if subdirs else base_dir - os.makedirs(dest_dir, exist_ok=True) - - src_for_ext = (client_filename or name or "").strip() - _ext = os.path.splitext(os.path.basename(src_for_ext))[1] if src_for_ext else "" - ext = _ext if 0 < len(_ext) <= 16 else "" - hashed_basename = f"{digest}{ext}" - dest_abs = os.path.abspath(os.path.join(dest_dir, hashed_basename)) - validate_path_within_base(dest_abs, base_dir) - - content_type = mime_type or ( - mimetypes.guess_type(os.path.basename(src_for_ext), strict=False)[0] - or mimetypes.guess_type(hashed_basename, strict=False)[0] - or "application/octet-stream" - ) - try: - os.replace(temp_path, dest_abs) - except Exception as e: - raise RuntimeError(f"failed to move uploaded file into place: {e}") - - try: - size_bytes, mtime_ns = get_size_and_mtime_ns(dest_abs) - except OSError as e: - raise RuntimeError(f"failed to stat destination file: {e}") - - ingest_result = _ingest_file_from_path( - asset_hash=asset_hash, - abs_path=dest_abs, - size_bytes=size_bytes, - mtime_ns=mtime_ns, - mime_type=content_type, - info_name=_sanitize_filename(name or client_filename, fallback=digest), - owner_id=owner_id, - preview_id=preview_id, - user_metadata=user_metadata or {}, - tags=[*(tags or []), "uploaded"], - tag_origin="manual", - require_existing_tags=False, + dest_abs = _hash_mode_dest_path(tags, digest, client_filename, name) + content_type = _guess_upload_mime_type( + mime_type, client_filename, name, os.path.basename(dest_abs) ) - reference_id = ingest_result.reference_id - if not reference_id: - raise RuntimeError("failed to create asset reference") - + _move_temp_to_dest(temp_path, dest_abs) + size_bytes, mtime_ns = verified_stat.st_size, verified_stat.st_mtime_ns with create_session() as session: - pair = fetch_reference_and_asset( - session, reference_id=reference_id, owner_id=owner_id + _reconcile_live_content_at_path( + session, + dest_abs, + _ContentFacts(stored_hash, size_bytes, mtime_ns), + content_written=True, ) - if not pair: - raise RuntimeError("inconsistent DB state after ingest") - ref, asset = pair - tag_names = get_reference_tags(session, reference_id=ref.id) - - return UploadResult( - ref=extract_reference_data(ref), - asset=extract_asset_data(asset), - tags=tag_names, - created_new=ingest_result.asset_created, - ) + content, inserted = create_content_reporting_insert( + session, dest_abs, stored_hash, size_bytes, mtime_ns + ) + created_content_id = content.id if inserted else None + try: + record = _create_upload_record( + session, + content.id, + display_name, + dest_abs, + normalize_tags([*(tags or []), "uploaded"]), + content_type, + user_metadata, + preview_id, + ) + session.commit() + except Exception: + session.rollback() + if created_content_id is not None: + _discard_unreferenced_content(session, created_content_id) + raise + return _record_to_upload_result(session, record, created_new=True) def register_file_in_place( abs_path: str, name: str, tags: list[str], - owner_id: str = "", mime_type: str | None = None, + *, + content_written: bool = True, ) -> UploadResult: """Register an already-saved file in the asset database without moving it. - This helper is used by upload paths that have already written bytes before - registering the file, so it records the same ``uploaded`` tag as the - multipart byte-upload path. - - Tags are derived from trusted filesystem classification and merged with any - caller-provided tags, matching the behavior of the scanner. - If the path is not under a known root, only the caller-provided tags are used. + Used by ``/upload/image`` after the handler writes (or reuses) bytes on disk. + ``compare_image_hash`` same-name dedup is legacy physical behavior and stays + outside hash-mode policy (parallel to path-form ``/view`` semantics). + + Dedup is scoped to ``abs_path``, deliberately weaker than the multipart + endpoint's global content dedup. ``_reconcile_live_content_at_path`` already + settles the same-path row before this returns, so a global hash match adds + nothing there - the only thing it can still reach is a DIFFERENT file, and + honouring that would leave the just-written ``abs_path`` untracked and hand + back an asset describing someone else's path. Equal hashes across paths stay + discoverable by hash, they are simply not merged. + ``_reconcile_live_content_at_path`` is the whole dedup story here - it leaves + at most one live row at ``abs_path``, so re-registering unchanged bytes reuses + that content row while still writing a new record, which is what a repeat save + through ``/upload/image`` is. + + ``content_written`` reports whether the caller just wrote bytes at + ``abs_path``. The handler always knows - ``/upload/image`` skips the write + exactly when ``compare_image_hash`` proves the bytes are already there - and + it is the only sound way to reconcile an unhashed row at that path, because + the bytes it was created for are gone by the time we are called. It defaults + to the conservative answer so a caller that does not know retires rather + than adopts. """ + locator = os.path.abspath(abs_path) + display_name = _sanitize_filename(name, fallback=os.path.basename(locator)) try: - _, path_tags = get_name_and_tags_from_asset_path(abs_path) + _, path_tags = get_name_and_tags_from_asset_path(locator) except ValueError: path_tags = [] merged_tags = normalize_tags([*path_tags, *tags, "uploaded"]) - - try: - digest, _ = hashing.compute_blake3_hash(abs_path) - except ImportError as e: - raise DependencyMissingError(str(e)) - except Exception as e: - raise RuntimeError(f"failed to hash file: {e}") - asset_hash = "blake3:" + digest - - size_bytes, mtime_ns = get_size_and_mtime_ns(abs_path) - content_type = mime_type or ( - mimetypes.guess_type(abs_path, strict=False)[0] - or "application/octet-stream" + content_type = _guess_upload_mime_type( + mime_type, name, name, os.path.basename(locator) ) - - ingest_result = _ingest_file_from_path( - abs_path=abs_path, - asset_hash=asset_hash, - size_bytes=size_bytes, - mtime_ns=mtime_ns, - mime_type=content_type, - info_name=_sanitize_filename(name, fallback=digest), - owner_id=owner_id, - tags=merged_tags, - tag_origin="upload", - require_existing_tags=False, - ) - reference_id = ingest_result.reference_id - if not reference_id: - raise RuntimeError("failed to create asset reference") - + digest, verified_stat = _snapshot_hash_with_retry(locator) + size_bytes, mtime_ns = verified_stat.st_size, verified_stat.st_mtime_ns + stored_hash = to_stored_hash(digest) with create_session() as session: - pair = fetch_reference_and_asset( - session, reference_id=reference_id, owner_id=owner_id + _reconcile_live_content_at_path( + session, + locator, + _ContentFacts(stored_hash, size_bytes, mtime_ns), + content_written=content_written, ) - if not pair: - raise RuntimeError("inconsistent DB state after ingest") - ref, asset = pair - tag_names = get_reference_tags(session, reference_id=ref.id) + session.commit() - return UploadResult( - ref=extract_reference_data(ref), - asset=extract_asset_data(asset), - tags=tag_names, - created_new=ingest_result.asset_created, - ) + with create_session() as session: + content, inserted = create_content_reporting_insert( + session, locator, stored_hash, size_bytes, mtime_ns + ) + created_content_id = content.id if inserted else None + try: + record = _create_upload_record( + session, + content.id, + display_name, + locator, + merged_tags, + content_type, + None, + None, + ) + session.commit() + except Exception: + session.rollback() + if created_content_id is not None: + _discard_unreferenced_content(session, created_content_id) + raise + return _record_to_upload_result(session, record, created_new=True) def create_from_hash( @@ -656,32 +599,169 @@ def create_from_hash( name: str, tags: list[str] | None = None, user_metadata: dict | None = None, - owner_id: str = "", mime_type: str | None = None, preview_id: str | None = None, ) -> UploadResult | None: - canonical = hash_str.strip().lower() + if not mode.hashing_enabled(): + return None + + stored_hash = _normalize_hash_input(hash_str) + bare_digest = stored_hash.partition(":")[2] or stored_hash + display_name = _sanitize_filename( + name, fallback=bare_digest + ) + + with create_session() as session: + content = lookup_for_from_hash(session, stored_hash) + if content is None: + logging.warning("create_from_hash: no asset found for hash %s", hash_str) + return None + content_id = content.id + if not claim_qualified_content(session, content_id, stored_hash): + session.rollback() + return None + content = refresh_qualified_content(session, content_id) + if content is None: + session.rollback() + return None + record = _create_upload_record( + session, + content_id, + display_name, + content.path, + tags or [], + mime_type, + user_metadata, + preview_id, + ) + session.commit() + return _record_to_upload_result(session, record, created_new=True) + + +def register_cached_output( + abs_path: str, job_id: str | None = None +) -> RegisteredAsset | None: + locator = os.path.abspath(abs_path) + try: + with create_session() as session: + existing = session.scalars( + select(AssetContent).where( + AssetContent.path == locator, AssetContent.is_missing.is_(False) + ) + ).first() + if existing is None: + logging.info( + "Cached output registration is a non-event; no live content " + "for %s", + locator, + ) + return None + + name, path_tags = get_name_and_tags_from_asset_path(locator) + mime_type = mimetypes.guess_type(locator, strict=False)[0] + + sibling = session.scalars( + select(Asset) + .where(Asset.content_id == existing.id) + .order_by(Asset.created_at.asc(), Asset.id.asc()) + .limit(1) + ).first() + if sibling is not None: + system_metadata = ( + dict(sibling.system_metadata) + if sibling.system_metadata is not None + else None + ) + else: + system_metadata = _extract_system_metadata_sync(locator, mime_type) + + try: + record = create_record( + session, + existing.id, + name, + mime_type=mime_type, + job_id=job_id, + loader_path=compute_loader_path(locator), + tags=path_tags, + system_metadata=system_metadata, + ) + session.commit() + except Exception: + session.rollback() + raise + record_id = record.id + record_content_id = record.content_id + record_job_id = record.job_id + record_name = record.name + except Exception: + logging.exception("Failed to register cached output: %s", locator) + return None + + return RegisteredAsset( + id=record_id, + content_id=record_content_id, + job_id=record_job_id, + name=record_name, + ) + +def register_executed_output( + abs_path: str, job_id: str | None = None +) -> RegisteredAsset | None: + locator = os.path.abspath(abs_path) try: - result = _register_existing_asset( - asset_hash=canonical, - name=_sanitize_filename( - name, fallback=canonical.split(":", 1)[1] if ":" in canonical else canonical - ), - user_metadata=user_metadata or {}, - tags=tags or [], - tag_origin="manual", - owner_id=owner_id, - mime_type=mime_type, - preview_id=preview_id, + stat_result = os.stat(locator, follow_symlinks=True) + size_bytes = stat_result.st_size + mtime_ns = get_mtime_ns(stat_result) + mime_type = mimetypes.guess_type(locator, strict=False)[0] + name, path_tags = get_name_and_tags_from_asset_path(locator) + system_metadata = _extract_system_metadata_sync( + locator, mime_type, stat_result ) - except ValueError: - logging.warning("create_from_hash: no asset found for hash %s", canonical) + with create_session() as session: + created_content_id: str | None = None + try: + existing = session.scalars( + select(AssetContent).where( + AssetContent.path == locator, + AssetContent.is_missing.is_(False), + ) + ).first() + if existing is not None: + mark_content_missing(session, existing.id) + content, inserted = create_content_reporting_insert( + session, locator, None, size_bytes, mtime_ns + ) + if inserted: + created_content_id = content.id + record = create_record( + session, + content.id, + name, + mime_type=mime_type, + job_id=job_id, + loader_path=compute_loader_path(locator), + tags=path_tags, + system_metadata=system_metadata, + ) + session.commit() + except Exception: + session.rollback() + if created_content_id is not None: + _discard_unreferenced_content(session, created_content_id) + raise + record_id = record.id + record_content_id = record.content_id + record_job_id = record.job_id + record_name = record.name + except Exception: + logging.exception("Failed to register executed output: %s", locator) return None - return UploadResult( - ref=result.ref, - asset=result.asset, - tags=result.tags, - created_new=False, + return RegisteredAsset( + id=record_id, + content_id=record_content_id, + job_id=record_job_id, + name=record_name, ) diff --git a/app/assets/services/lookup.py b/app/assets/services/lookup.py new file mode 100644 index 00000000000..6017d7e5438 --- /dev/null +++ b/app/assets/services/lookup.py @@ -0,0 +1,96 @@ +"""Resolves a hash to a content row that can actually be served, and claims that +row so a concurrent writer cannot retire it mid-use. A row qualifies only if +its file exists, sits outside the temp directory, and its recorded size and +mtime still match the file — an unrecorded mtime excuses the mtime comparison +but never the size comparison. +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator +from pathlib import Path + +import folder_paths +from sqlalchemy import select, update +from sqlalchemy.orm import Session + +from app.assets import mode +from app.assets.database.models import AssetContent + + +def is_temp_path(path: str) -> bool: + try: + temp_root = Path(os.path.abspath(folder_paths.get_temp_directory())) + candidate = Path(os.path.abspath(path)) + return candidate.is_relative_to(temp_root) + except OSError: + return False + + +def _stat_consistent(content: AssetContent) -> bool: + try: + stat = os.stat(content.path) + except FileNotFoundError: + return False + if content.mtime_ns is not None and stat.st_mtime_ns != content.mtime_ns: + return False + if stat.st_size != content.size_bytes: + return False + return True + + +def _qualifies(content: AssetContent) -> bool: + return ( + not content.is_missing + and os.path.isfile(content.path) + and _stat_consistent(content) + and not is_temp_path(content.path) + ) + + +def qualified_content_iterator(session: Session, hash: str) -> Iterator[AssetContent]: + rows = session.scalars( + select(AssetContent) + .where(AssetContent.hash == hash, AssetContent.is_missing.is_(False)) + .order_by(AssetContent.created_at, AssetContent.id) + ) + for row in rows: + if _qualifies(row): + yield row + + +def claim_qualified_content(session: Session, content_id: str, hash: str) -> bool: + """Claim a live matching content row before attaching a record. + + The conditional update takes this session's SQLite write lock through commit. + False means the row was retired or changed after lookup. + """ + # This session's connection keeps the claim's write lock through this session's commit. + result = session.connection().execute( + update(AssetContent) + .where( + AssetContent.id == content_id, + AssetContent.hash == hash, + AssetContent.is_missing.is_(False), + ) + .values(is_missing=False) + ) + return result.rowcount == 1 + + +def refresh_qualified_content(session: Session, content_id: str) -> AssetContent | None: + content = session.get(AssetContent, content_id, populate_existing=True) + if content is None or not _qualifies(content): + return None + return content + + +def lookup_for_from_hash(session: Session, hash: str) -> AssetContent | None: + if not mode.hashing_enabled(): + return None + return next(qualified_content_iterator(session, hash), None) + + +def lookup_for_view(session: Session, hash: str) -> AssetContent | None: + return next(qualified_content_iterator(session, hash), None) diff --git a/app/assets/services/schemas.py b/app/assets/services/schemas.py index 0fda6871d1e..54ff299e275 100644 --- a/app/assets/services/schemas.py +++ b/app/assets/services/schemas.py @@ -1,9 +1,13 @@ +"""Defines the frozen result types the service layer hands back to routes and to +the seeder, keeping those callers off ORM objects and the sessions that own +them. Each type carries only what its caller renders, so nothing can be lazily +loaded from a row after its session has closed. +""" + from dataclasses import dataclass from datetime import datetime from typing import Any, NamedTuple -from app.assets.database.models import Asset, AssetReference - UserMetadata = dict[str, Any] | None @@ -12,6 +16,7 @@ class AssetData: hash: str | None size_bytes: int | None mime_type: str | None + is_missing: bool = False @dataclass(frozen=True) @@ -81,35 +86,27 @@ class DownloadResolutionResult: download_name: str +@dataclass(frozen=True) +class RegisteredAsset: + id: str + content_id: str + job_id: str | None + name: str + + +@dataclass(frozen=True) +class UploadAssetView: + asset: RegisteredAsset + asset_hash: str | None + size: int | None + mime_type: str | None + tags: list[str] + + @dataclass(frozen=True) class UploadResult: ref: ReferenceData + content_id: str asset: AssetData tags: list[str] created_new: bool - - -def extract_reference_data(ref: AssetReference) -> ReferenceData: - return ReferenceData( - id=ref.id, - name=ref.name, - file_path=ref.file_path, - loader_path=ref.loader_path, - user_metadata=ref.user_metadata, - preview_id=ref.preview_id, - system_metadata=ref.system_metadata, - job_id=ref.job_id, - created_at=ref.created_at, - updated_at=ref.updated_at, - last_access_time=ref.last_access_time, - ) - - -def extract_asset_data(asset: Asset | None) -> AssetData | None: - if asset is None: - return None - return AssetData( - hash=asset.hash, - size_bytes=asset.size_bytes, - mime_type=asset.mime_type, - ) diff --git a/app/assets/services/snapshot_hash.py b/app/assets/services/snapshot_hash.py new file mode 100644 index 00000000000..d3e9b8c7158 --- /dev/null +++ b/app/assets/services/snapshot_hash.py @@ -0,0 +1,51 @@ +"""Hashes a file and returns the stat it proved describes those exact bytes. +Identity, size and mtime are sampled before the read, on the open handle at +both ends of it, and once more afterwards; if any sample disagrees the file +moved under the reader and the result is discarded rather than returned. +Callers persist the stat that comes back, which is what makes a stored hash and +a stored size describe one observation. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass + +from blake3 import blake3 + + +@dataclass(frozen=True, slots=True) +class _Snapshot: + dev: int + ino: int + mtime_ns: int + size: int + + +def _snapshot(stat_result: os.stat_result) -> _Snapshot: + return _Snapshot( + dev=stat_result.st_dev, + ino=stat_result.st_ino, + mtime_ns=stat_result.st_mtime_ns, + size=stat_result.st_size, + ) + + +def snapshot_hash( + path: str, chunk_size: int = 8 * 1024 * 1024 +) -> tuple[str, os.stat_result] | None: + try: + pre_stat = _snapshot(os.stat(path)) + hasher = blake3() + with open(path, "rb") as file: + open_stat = _snapshot(os.fstat(file.fileno())) + while chunk := file.read(chunk_size): + hasher.update(chunk) + post_hash_stat = _snapshot(os.fstat(file.fileno())) + post_stat_result = os.stat(path) + except FileNotFoundError: + return None + post_stat = _snapshot(post_stat_result) + if len({pre_stat, open_stat, post_hash_stat, post_stat}) != 1: + return None + return hasher.hexdigest(), post_stat_result diff --git a/app/assets/services/tagging.py b/app/assets/services/tagging.py index 69c8cf39cc1..d22eb8c0a5c 100644 --- a/app/assets/services/tagging.py +++ b/app/assets/services/tagging.py @@ -1,14 +1,27 @@ +"""Applies and removes tags on a record and answers tag listing and histogram +queries. Adding reports what it actually inserted, read back after the write, +so a caller that lost a race is told the tag was already present rather than +that it added one. Removal leaves automatically applied tags in place and +reports them as protected instead of silently skipping them. +""" + from typing import Sequence +from sqlalchemy import delete, select + from app.assets.database.queries import ( AddTagsResult, RemoveTagsResult, - add_tags_to_reference, - get_reference_with_owner_check, list_tags_with_usage, - remove_tags_from_reference, +) +from app.assets.database.queries.records import ( + bump_record_updated_at, + ensure_tag, + ensure_tag_link, ) from app.assets.database.queries.tags import list_tag_counts_for_filtered_assets +from app.assets.database.models import Asset, AssetTag +from app.assets.helpers import normalize_tags from app.assets.services.schemas import TagUsage from app.database.db import create_session @@ -17,40 +30,93 @@ def apply_tags( reference_id: str, tags: list[str], origin: str = "manual", - owner_id: str = "", ) -> AddTagsResult: with create_session() as session: - ref_row = get_reference_with_owner_check(session, reference_id, owner_id) + if session.get(Asset, reference_id) is None: + raise ValueError(f"Asset {reference_id} not found") - result = add_tags_to_reference( - session, - reference_id=reference_id, - tags=tags, - origin=origin, - create_if_missing=True, - reference_row=ref_row, + normalized_tags = normalize_tags(tags) + requested_tags = set(normalized_tags) + added: list[str] = [] + for tag_name in normalized_tags: + ensure_tag(session, tag_name) + if ensure_tag_link( + session, + asset_id=reference_id, + tag_name=tag_name, + origin=origin, + ): + added.append(tag_name) + session.flush() + if added: + bump_record_updated_at(session, reference_id) + total_tags = list( + session.scalars( + select(AssetTag.tag_name) + .where(AssetTag.asset_id == reference_id) + .order_by(AssetTag.tag_name) + ) ) session.commit() - return result + return AddTagsResult( + added=sorted(added), + already_present=sorted((requested_tags & set(total_tags)) - set(added)), + total_tags=total_tags, + ) def remove_tags( reference_id: str, tags: list[str], - owner_id: str = "", ) -> RemoveTagsResult: with create_session() as session: - get_reference_with_owner_check(session, reference_id, owner_id) + if session.get(Asset, reference_id) is None: + raise ValueError(f"Asset {reference_id} not found") - result = remove_tags_from_reference( - session, - reference_id=reference_id, - tags=tags, + requested_tags = set(normalize_tags(tags)) + removable_tags = set( + session.scalars( + select(AssetTag.tag_name).where( + AssetTag.asset_id == reference_id, + AssetTag.origin != "automatic", + AssetTag.tag_name.in_(requested_tags), + ) + ) + ) + protected_tags = set( + session.scalars( + select(AssetTag.tag_name).where( + AssetTag.asset_id == reference_id, + AssetTag.origin == "automatic", + AssetTag.tag_name.in_(requested_tags), + ) + ) + ) + if removable_tags: + session.execute( + delete(AssetTag).where( + AssetTag.asset_id == reference_id, + AssetTag.origin != "automatic", + AssetTag.tag_name.in_(removable_tags), + ) + ) + bump_record_updated_at(session, reference_id) + total_tags = list( + session.scalars( + select(AssetTag.tag_name) + .where(AssetTag.asset_id == reference_id) + .order_by(AssetTag.tag_name) + ) ) session.commit() - return result + return RemoveTagsResult( + removed=sorted(removable_tags), + not_present=sorted(requested_tags - removable_tags - protected_tags), + total_tags=total_tags, + protected=sorted(protected_tags), + ) def list_tags( @@ -59,7 +125,6 @@ def list_tags( offset: int = 0, order: str = "count_desc", include_zero: bool = True, - owner_id: str = "", ) -> tuple[list[TagUsage], int]: limit = max(1, min(1000, limit)) offset = max(0, offset) @@ -72,18 +137,15 @@ def list_tags( offset=offset, include_zero=include_zero, order=order, - owner_id=owner_id, ) return [TagUsage(name, count) for name, count in rows], total def list_tag_histogram( - owner_id: str = "", include_tags: Sequence[str] | None = None, exclude_tags: Sequence[str] | None = None, name_contains: str | None = None, - metadata_filter: dict | None = None, limit: int = 100, # Appended last so pre-existing positional callers keep binding correctly. any_tags: Sequence[str] | None = None, @@ -91,11 +153,9 @@ def list_tag_histogram( with create_session() as session: return list_tag_counts_for_filtered_assets( session, - owner_id=owner_id, include_tags=include_tags, exclude_tags=exclude_tags, any_tags=any_tags, name_contains=name_contains, - metadata_filter=metadata_filter, limit=limit, ) diff --git a/app/database/db.py b/app/database/db.py index 919206a59e7..053df288332 100644 --- a/app/database/db.py +++ b/app/database/db.py @@ -21,6 +21,7 @@ from app.database.models import Base import app.assets.database.models # noqa: F401 — register models with Base.metadata + import blake3 # noqa: F401 — verify the hard dependency is importable at startup _DB_AVAILABLE = True except ImportError as e: @@ -184,6 +185,29 @@ def _init_file_db(db_url): prepare_file_db_path(db_path) db_exists = os.path.exists(db_path) + # Lock BEFORE any migration work — deliberately diverging from upstream master, whose + # "it would block Alembic" rationale is false (the lock guards a separate `.lock` + # file). Only this order makes revision inspection, backup, upgrade and the failure-path + # restore mutually exclusive between processes. + _acquire_file_lock(db_path) + try: + _migrate_and_bind(db_url, db_path, db_exists) + except Exception: + _db_lock.release() + raise + + +_DESTRUCTIVE_REVISION = "0007_record_content_split" + + +def _upgrade_discards_the_catalog(script, target_rev, current_rev): + return any( + revision.revision == _DESTRUCTIVE_REVISION + for revision in script.iterate_revisions(upper=target_rev, lower=current_rev) + ) + + +def _migrate_and_bind(db_url, db_path, db_exists): config = get_alembic_config() # Check if we need to upgrade @@ -225,11 +249,15 @@ def set_sqlite_pragma(dbapi_connection, connection_record): logging.exception("Error upgrading database: ") raise e - # Acquire an OS-level file lock after migrations are complete. - # Alembic uses its own connection, so we must wait until it's done - # before locking — otherwise our own lock blocks the migration. + if backup_path and _upgrade_discards_the_catalog(script, target_rev, current_rev): + log_startup_warning( + f"The asset catalog was rebuilt from scratch by migration " + f"{_DESTRUCTIVE_REVISION}: manual tags, user metadata, previews, renames, " + f"API-created records and job_id links from the previous database were " + f"discarded. The database from before the upgrade was kept at {backup_path}." + ) + conn.close() - _acquire_file_lock(db_path) global Session Session = sessionmaker(bind=engine) diff --git a/blueprints/Canny to Image (Z-Image-Turbo).json b/blueprints/Canny to Image (Z-Image-Turbo).json index 903d372b1a9..c95ea5883d1 100644 --- a/blueprints/Canny to Image (Z-Image-Turbo).json +++ b/blueprints/Canny to Image (Z-Image-Turbo).json @@ -1553,7 +1553,7 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Conditioned", + "category": "Image generation/Conditioned", "description": "Generates an image from a Canny edge map using Z-Image-Turbo, with text conditioning." } ] diff --git a/blueprints/Canny to Video (LTX 2.0).json b/blueprints/Canny to Video (LTX 2.0).json index ed602b52125..332c3862cb1 100644 --- a/blueprints/Canny to Video (LTX 2.0).json +++ b/blueprints/Canny to Video (LTX 2.0).json @@ -3600,7 +3600,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Conditioned", + "category": "Video generation/Conditioned", "description": "Generates video from Canny edge maps using LTX-2, with optional synchronized audio." } ] diff --git a/blueprints/Character Replacement (SCAIL-2 Base).json b/blueprints/Character Replacement (SCAIL-2 Base).json index 61803df6528..a47941c3e26 100644 --- a/blueprints/Character Replacement (SCAIL-2 Base).json +++ b/blueprints/Character Replacement (SCAIL-2 Base).json @@ -4173,7 +4173,7 @@ } ], "extra": {}, - "category": "Video generation and editing/Video Edit", + "category": "Video editing/Video Edit", "description": "Replaces a character in a video with a reference image using the SCAIL-2 model for end-to-end controlled animation without intermediate pose maps. Key inputs include a source video, a reference character image, and optional text prompts for style or context. Suitable for animated or live-action footage, multi-character scenes, and creative video editing where direct pose-free animation is needed; works best with moderate-length videos." } ] diff --git a/blueprints/Character Replacement (SCAIL-2 Extend).json b/blueprints/Character Replacement (SCAIL-2 Extend).json index bf2f67dfd9f..703534af881 100644 --- a/blueprints/Character Replacement (SCAIL-2 Extend).json +++ b/blueprints/Character Replacement (SCAIL-2 Extend).json @@ -4445,7 +4445,7 @@ } ], "extra": {}, - "category": "Video generation and editing/Video Edit", + "category": "Video editing/Video Edit", "description": "Replaces a character in a video with a reference image using the SCAIL-2 model for end-to-end controlled animation without intermediate pose maps. Key inputs include a source video, a reference character image, and optional text prompts for style or context. Suitable for animated or live-action footage, multi-character scenes, and creative video editing where direct pose-free animation is needed; works best with moderate-length videos." } ] diff --git a/blueprints/Character Replacement (SCAIL-2 Int8 Base).json b/blueprints/Character Replacement (SCAIL-2 Int8 Base).json new file mode 100644 index 00000000000..9ca4d774870 --- /dev/null +++ b/blueprints/Character Replacement (SCAIL-2 Int8 Base).json @@ -0,0 +1,4312 @@ +{ + "revision": 0, + "last_node_id": "364", + "last_link_id": 0, + "nodes": [ + { + "id": 364, + "type": "ab27c382-c076-424b-b976-d1bc3f88ba12", + "pos": [ + 3520, + 4770 + ], + "size": [ + 490, + 1180 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "label": "pose_video", + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": null + }, + { + "label": "reference_image", + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": null + }, + { + "label": "prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "label": "segment_index", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "replace_mode", + "name": "value_2", + "type": "BOOLEAN", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "label": "width", + "name": "value_3", + "type": "INT", + "widget": { + "name": "value_3" + }, + "link": null + }, + { + "label": "height", + "name": "value_4", + "type": "INT", + "widget": { + "name": "value_4" + }, + "link": null + }, + { + "label": "frame_count", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + }, + { + "name": "previous_frame_count", + "type": "INT", + "widget": { + "name": "previous_frame_count" + }, + "link": null + }, + { + "name": "pose_strength", + "type": "FLOAT", + "widget": { + "name": "pose_strength" + }, + "link": null + }, + { + "name": "pose_start", + "type": "FLOAT", + "widget": { + "name": "pose_start" + }, + "link": null + }, + { + "name": "pose_end", + "type": "FLOAT", + "widget": { + "name": "pose_end" + }, + "link": null + }, + { + "label": "turbo_mode", + "name": "value_5", + "type": "BOOLEAN", + "widget": { + "name": "value_5" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "label": "distill_lora", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "label": "dpo_lora", + "name": "lora_name_1", + "type": "COMBO", + "widget": { + "name": "lora_name_1" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "clip_vision", + "name": "clip_name_1", + "type": "COMBO", + "widget": { + "name": "clip_name_1" + }, + "link": null + }, + { + "label": "sam3_video_object", + "name": "text_1", + "type": "STRING", + "widget": { + "name": "text_1" + }, + "link": null + }, + { + "label": "sam3_image_object", + "name": "text_2", + "type": "STRING", + "widget": { + "name": "text_2" + }, + "link": null + }, + { + "label": "sam3_model", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.24.0", + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "354", + "sourcePreviewName": "$$canvas-image-preview" + }, + { + "name": "$$canvas-image-preview_1", + "sourceNodeId": "355", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + 1, + true, + 896, + 512, + 81, + 5, + 1, + 0, + 1, + true, + "wan2.1_14B_SCAIL_2_int8_convrot.safetensors", + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "wan2.1_SCAIL_2_DPO_lora_bf16.safetensors", + "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "Wan2_1_VAE_bf16.safetensors", + "clip_vision_h.safetensors", + "girl, handkerchief", + "human", + "sam3.1_multiplex_fp16.safetensors", + 1 + ], + "widgets_values_named": { + "text": "", + "value": 1, + "value_2": true, + "value_3": 896, + "value_4": 512, + "length": 81, + "previous_frame_count": 5, + "pose_strength": 1, + "pose_start": 0, + "pose_end": 1, + "value_5": true, + "unet_name": "wan2.1_14B_SCAIL_2_int8_convrot.safetensors", + "lora_name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "lora_name_1": "wan2.1_SCAIL_2_DPO_lora_bf16.safetensors", + "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "vae_name": "Wan2_1_VAE_bf16.safetensors", + "clip_name_1": "clip_vision_h.safetensors", + "text_1": "girl, handkerchief", + "text_2": "human", + "ckpt_name": "sam3.1_multiplex_fp16.safetensors", + "noise_seed": 1 + }, + "title": "Character Replacement (SCAIL-2 Int8 Base)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "ab27c382-c076-424b-b976-d1bc3f88ba12", + "version": 1, + "state": { + "lastGroupId": 17, + "lastNodeId": 364, + "lastLinkId": 569, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Character Replacement (SCAIL-2 Int8 Base)", + "inputNode": { + "id": -10, + "bounding": [ + -440, + 4190, + 173.015625, + 508 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 3980, + 3870, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "e9aedfaf-1991-4a75-ad9b-8c3a279b0488", + "name": "video", + "type": "VIDEO", + "linkIds": [ + 409 + ], + "localized_name": "video", + "label": "pose_video", + "pos": [ + -290.984375, + 4214 + ] + }, + { + "id": "0badb26e-9abd-4e9c-b221-aab1237b8773", + "name": "images", + "type": "IMAGE", + "linkIds": [ + 469, + 408, + 473 + ], + "localized_name": "images", + "label": "reference_image", + "pos": [ + -290.984375, + 4234 + ] + }, + { + "id": "098f15aa-a066-422e-b491-eaf140bafb2c", + "name": "text", + "type": "STRING", + "linkIds": [ + 499 + ], + "label": "prompt", + "pos": [ + -290.984375, + 4254 + ] + }, + { + "id": "861ea850-8329-491b-9413-2588cdd77bbd", + "name": "value", + "type": "INT", + "linkIds": [ + 502 + ], + "label": "segment_index", + "pos": [ + -290.984375, + 4274 + ] + }, + { + "id": "dcf3ebf8-1263-458a-966b-9e77a5ad704a", + "name": "value_2", + "type": "BOOLEAN", + "linkIds": [ + 504 + ], + "label": "replace_mode", + "pos": [ + -290.984375, + 4294 + ] + }, + { + "id": "dbee10ce-bb98-4733-88a0-d4cd5b13c691", + "name": "value_3", + "type": "INT", + "linkIds": [ + 505 + ], + "label": "width", + "pos": [ + -290.984375, + 4314 + ] + }, + { + "id": "363e3ac7-0096-4480-b875-1d6ff1b8a2d9", + "name": "value_4", + "type": "INT", + "linkIds": [ + 506 + ], + "label": "height", + "pos": [ + -290.984375, + 4334 + ] + }, + { + "id": "4017e1ed-96cd-4c1c-9949-dc1a8c30386a", + "name": "length", + "type": "INT", + "linkIds": [ + 522, + 541 + ], + "label": "frame_count", + "pos": [ + -290.984375, + 4354 + ] + }, + { + "id": "617809fd-9647-4067-8f44-10d8acc8af82", + "name": "previous_frame_count", + "type": "INT", + "linkIds": [ + 542, + 543 + ], + "pos": [ + -290.984375, + 4374 + ] + }, + { + "id": "36ab49f6-528f-4301-a37a-0108aa7f5bec", + "name": "pose_strength", + "type": "FLOAT", + "linkIds": [ + 547 + ], + "pos": [ + -290.984375, + 4394 + ] + }, + { + "id": "24c2e93f-7bbc-4c6d-898e-36b440b8469b", + "name": "pose_start", + "type": "FLOAT", + "linkIds": [ + 548 + ], + "pos": [ + -290.984375, + 4414 + ] + }, + { + "id": "d1dbe632-bb2a-4c30-a7ea-5082f21dc686", + "name": "pose_end", + "type": "FLOAT", + "linkIds": [ + 549 + ], + "pos": [ + -290.984375, + 4434 + ] + }, + { + "id": "ddf07ba4-2837-40ef-925f-5996ea436334", + "name": "value_5", + "type": "BOOLEAN", + "linkIds": [ + 507 + ], + "label": "turbo_mode", + "pos": [ + -290.984375, + 4454 + ] + }, + { + "id": "ee8c796d-b326-40c6-9f9d-65f564053974", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 508 + ], + "pos": [ + -290.984375, + 4474 + ] + }, + { + "id": "6ccae991-51d7-4c8e-adc8-c7f6377d681b", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 509 + ], + "label": "distill_lora", + "pos": [ + -290.984375, + 4494 + ] + }, + { + "id": "30772913-ecf9-44f4-a638-2824404b24fb", + "name": "lora_name_1", + "type": "COMBO", + "linkIds": [ + 565 + ], + "label": "dpo_lora", + "pos": [ + -290.984375, + 4514 + ] + }, + { + "id": "2e9c2347-93fe-462f-b5ef-51613fb52c85", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 510 + ], + "pos": [ + -290.984375, + 4534 + ] + }, + { + "id": "bc1822c0-7484-47b7-8d59-cf94788290f7", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 511 + ], + "pos": [ + -290.984375, + 4554 + ] + }, + { + "id": "bbf2ee19-58b3-46f4-af6a-cd1557d60c8d", + "name": "clip_name_1", + "type": "COMBO", + "linkIds": [ + 512 + ], + "label": "clip_vision", + "pos": [ + -290.984375, + 4574 + ] + }, + { + "id": "556415bd-8ef3-4cfa-b182-0d7e3e4cd572", + "name": "text_1", + "type": "STRING", + "linkIds": [ + 513 + ], + "label": "sam3_video_object", + "pos": [ + -290.984375, + 4594 + ] + }, + { + "id": "c577930c-85a3-4e92-ac44-8ed639937217", + "name": "text_2", + "type": "STRING", + "linkIds": [ + 514 + ], + "label": "sam3_image_object", + "pos": [ + -290.984375, + 4614 + ] + }, + { + "id": "8f542c5e-da91-4dad-8ff8-4e81a42d5140", + "name": "ckpt_name", + "type": "COMBO", + "linkIds": [ + 515 + ], + "label": "sam3_model", + "pos": [ + -290.984375, + 4634 + ] + }, + { + "id": "3064d232-69a3-43b1-b350-05d8ca0e7c9f", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 558 + ], + "pos": [ + -290.984375, + 4654 + ] + } + ], + "outputs": [ + { + "id": "7ae88834-7553-45ec-a4e8-bab7d5276b45", + "name": "output", + "type": "IMAGE", + "linkIds": [ + 544 + ], + "localized_name": "output", + "pos": [ + 4004, + 3894 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 322, + "type": "LoraLoaderModelOnly", + "pos": [ + 300, + 3590 + ], + "size": [ + 590, + 140 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 563 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 509 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 398, + 425 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.24.0", + "models": [ + { + "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "url": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + 0.8 + ], + "widgets_values_named": { + "lora_name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "strength_model": 0.8 + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 323, + "type": "VAELoader", + "pos": [ + 300, + 4020 + ], + "size": [ + 590, + 140 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 511 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 406, + 407 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.24.0", + "models": [ + { + "name": "Wan2_1_VAE_bf16.safetensors", + "url": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1_VAE_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "Wan2_1_VAE_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "Wan2_1_VAE_bf16.safetensors" + } + }, + { + "id": 324, + "type": "ResizeImageMaskNode", + "pos": [ + 1280, + 4860 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 491 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 444 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 446 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 420, + 492, + 493 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "scale dimensions", + 512, + 512, + "center", + "area" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 512, + "resize_type.height": 512, + "resize_type.crop": "center", + "scale_method": "area" + } + }, + { + "id": 325, + "type": "GetVideoComponents", + "pos": [ + 270, + 4500 + ], + "size": [ + 230, + 110 + ], + "flags": { + "collapsed": true + }, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 409 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [ + 490 + ] + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "links": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "type": "COMBO", + "links": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "type": "COMBO", + "links": null + } + ], + "properties": { + "Node name for S&R": "GetVideoComponents", + "cnr_id": "comfy-core", + "ver": "0.24.0" + } + }, + { + "id": 326, + "type": "GetImageSize", + "pos": [ + 1640, + 4500 + ], + "size": [ + 240, + 190 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 492 + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 414 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 415 + ] + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "links": [ + 416 + ] + } + ], + "properties": { + "Node name for S&R": "GetImageSize", + "cnr_id": "comfy-core", + "ver": "0.24.0" + } + }, + { + "id": 327, + "type": "CLIPLoader", + "pos": [ + 300, + 3790 + ], + "size": [ + 590, + 170 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 510 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 7, + 8 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.24.0", + "models": [ + { + "name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "wan", + "default" + ], + "widgets_values_named": { + "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "type": "wan", + "device": "default" + } + }, + { + "id": 328, + "type": "CLIPVisionLoader", + "pos": [ + 300, + 4230 + ], + "size": [ + 590, + 110 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 512 + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION", + "name": "CLIP_VISION", + "type": "CLIP_VISION", + "links": [ + 196 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionLoader", + "cnr_id": "comfy-core", + "ver": "0.24.0", + "models": [ + { + "name": "clip_vision_h.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/clip_vision/clip_vision_h.safetensors", + "directory": "clip_vision" + } + ] + }, + "widgets_values": [ + "clip_vision_h.safetensors" + ], + "widgets_values_named": { + "clip_name": "clip_vision_h.safetensors" + } + }, + { + "id": 329, + "type": "UNETLoader", + "pos": [ + 300, + 3190 + ], + "size": [ + 590, + 140 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 508 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 562 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.24.0", + "models": [ + { + "name": "wan2.1_14B_SCAIL_2_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/SCAIL-2/resolve/main/diffusion_models/wan2.1_14B_SCAIL_2_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "wan2.1_14B_SCAIL_2_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "wan2.1_14B_SCAIL_2_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 330, + "type": "ModelSamplingSD3", + "pos": [ + 2760, + 3370 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 424 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 417 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingSD3", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 5 + ], + "widgets_values_named": { + "shift": 5 + } + }, + { + "id": 331, + "type": "KSamplerSelect", + "pos": [ + 2770, + 3520 + ], + "size": [ + 260, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 419 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "euler" + ], + "widgets_values_named": { + "sampler_name": "euler" + } + }, + { + "id": 332, + "type": "BasicScheduler", + "pos": [ + 2770, + 3670 + ], + "size": [ + 260, + 170 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 398 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 428 + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 418 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "simple", + 6, + 1 + ], + "widgets_values_named": { + "scheduler": "simple", + "steps": 6, + "denoise": 1 + } + }, + { + "id": 333, + "type": "SamplerCustom", + "pos": [ + 3080, + 3370 + ], + "size": [ + 270, + 670 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 417 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 333 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 334 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 419 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 418 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 335 + }, + { + "localized_name": "add_noise", + "name": "add_noise", + "type": "BOOLEAN", + "widget": { + "name": "add_noise" + }, + "link": null + }, + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 558 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": 431 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [ + 124 + ] + } + ], + "properties": { + "Node name for S&R": "SamplerCustom", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + true, + 1, + "fixed", + 1 + ], + "widgets_values_named": { + "add_noise": true, + "noise_seed": 1, + "control_after_generate": "fixed", + "cfg": 1 + } + }, + { + "id": 334, + "type": "PrimitiveInt", + "pos": [ + 2060, + 3750 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 427 + ] + } + ], + "title": "Int (Steps)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 6, + "fixed" + ], + "widgets_values_named": { + "value": 6, + "fixed": "fixed" + } + }, + { + "id": 335, + "type": "PrimitiveFloat", + "pos": [ + 2060, + 3920 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 430 + ] + } + ], + "title": "Float (CFG)", + "properties": { + "Node name for S&R": "PrimitiveFloat", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 1 + ], + "widgets_values_named": { + "value": 1 + } + }, + { + "id": 336, + "type": "PrimitiveInt", + "pos": [ + 2060, + 3380 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 426 + ] + } + ], + "title": "Int (Steps)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 40, + "fixed" + ], + "widgets_values_named": { + "value": 40, + "fixed": "fixed" + } + }, + { + "id": 337, + "type": "PrimitiveFloat", + "pos": [ + 2060, + 3530 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 429 + ] + } + ], + "title": "Float (CFG)", + "properties": { + "Node name for S&R": "PrimitiveFloat", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 5 + ], + "widgets_values_named": { + "value": 5 + } + }, + { + "id": 338, + "type": "ComfySwitchNode", + "pos": [ + 2400, + 3360 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 564 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 425 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 432 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 424 + ] + } + ], + "title": "Switch (Model)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 339, + "type": "ComfySwitchNode", + "pos": [ + 2400, + 3550 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 426 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 427 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 433 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 428 + ] + } + ], + "title": "Switch (Steps)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 340, + "type": "ComfySwitchNode", + "pos": [ + 2400, + 3730 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 429 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 430 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 434 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 431 + ] + } + ], + "title": "Switch (Steps)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 341, + "type": "ImageFromBatch", + "pos": [ + 880, + 4490 + ], + "size": [ + 270, + 140 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 490 + }, + { + "localized_name": "batch_index", + "name": "batch_index", + "type": "INT", + "widget": { + "name": "batch_index" + }, + "link": 450 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 522 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 491 + ] + } + ], + "properties": { + "Node name for S&R": "ImageFromBatch", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 0, + 81 + ], + "widgets_values_named": { + "batch_index": 0, + "length": 81 + } + }, + { + "id": 342, + "type": "PrimitiveInt", + "pos": [ + 250, + 4820 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 505 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 441 + ] + } + ], + "title": "Int (Width)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 896, + "fixed" + ], + "widgets_values_named": { + "value": 896, + "fixed": "fixed" + } + }, + { + "id": 343, + "type": "PrimitiveInt", + "pos": [ + 250, + 5000 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 506 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 445 + ] + } + ], + "title": "Int (Height)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 512, + "fixed" + ], + "widgets_values_named": { + "value": 512, + "fixed": "fixed" + } + }, + { + "id": 344, + "type": "ComfyMathExpression", + "pos": [ + 590, + 4820 + ], + "size": [ + 230, + 190 + ], + "flags": { + "collapsed": false + }, + "order": 22, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 441 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 444 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "(a // 32) * 32" + ], + "widgets_values_named": { + "expression": "(a // 32) * 32" + } + }, + { + "id": 345, + "type": "ComfyMathExpression", + "pos": [ + 940, + 4990 + ], + "size": [ + 230, + 190 + ], + "flags": { + "collapsed": false + }, + "order": 23, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 445 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 446 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "(a // 32) * 32" + ], + "widgets_values_named": { + "expression": "(a // 32) * 32" + } + }, + { + "id": 346, + "type": "PrimitiveInt", + "pos": [ + 260, + 4570 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 502 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 447 + ] + } + ], + "title": "Int (segment index)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 1, + "fixed" + ], + "widgets_values_named": { + "value": 1, + "fixed": "fixed" + } + }, + { + "id": 347, + "type": "ComfyMathExpression", + "pos": [ + 590, + 4490 + ], + "size": [ + 230, + 220 + ], + "flags": { + "collapsed": false + }, + "order": 25, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 447 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": 541 + }, + { + "localized_name": "c", + "name": "values.c", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": 543 + }, + { + "localized_name": "d", + "name": "values.d", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 450 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "(b - c) * (a - 1)" + ], + "widgets_values_named": { + "expression": "(b - c) * (a - 1)" + } + }, + { + "id": 348, + "type": "CLIPTextEncode", + "pos": [ + 660, + 5360 + ], + "size": [ + 380, + 160 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 454 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 513 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 461 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.19.3", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + "girl, handkerchief" + ], + "widgets_values_named": { + "text": "girl, handkerchief" + } + }, + { + "id": 349, + "type": "CheckpointLoaderSimple", + "pos": [ + 270, + 5340 + ], + "size": [ + 330, + 160 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "ckpt_name", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": 515 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 458, + 463 + ] + }, + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 454, + 489 + ] + }, + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [] + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "cnr_id": "comfy-core", + "ver": "0.19.3", + "models": [ + { + "name": "sam3.1_multiplex_fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors", + "directory": "checkpoints" + } + ], + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + "sam3.1_multiplex_fp16.safetensors" + ], + "widgets_values_named": { + "ckpt_name": "sam3.1_multiplex_fp16.safetensors" + } + }, + { + "id": 350, + "type": "SAM3_VideoTrack", + "pos": [ + 1190, + 5320 + ], + "size": [ + 280, + 250 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "label": "images", + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 493 + }, + { + "label": "model", + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 458 + }, + { + "label": "initial_mask", + "localized_name": "initial_mask", + "name": "initial_mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "label": "conditioning", + "localized_name": "conditioning", + "name": "conditioning", + "shape": 7, + "type": "CONDITIONING", + "link": 461 + }, + { + "localized_name": "detection_threshold", + "name": "detection_threshold", + "type": "FLOAT", + "widget": { + "name": "detection_threshold" + }, + "link": null + }, + { + "localized_name": "max_objects", + "name": "max_objects", + "type": "INT", + "widget": { + "name": "max_objects" + }, + "link": null + }, + { + "localized_name": "detect_interval", + "name": "detect_interval", + "type": "INT", + "widget": { + "name": "detect_interval" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "track_data", + "name": "track_data", + "type": "SAM3_TRACK_DATA", + "links": [ + 460 + ] + } + ], + "properties": { + "Node name for S&R": "SAM3_VideoTrack", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 0.5, + 4, + 1 + ], + "widgets_values_named": { + "detection_threshold": 0.5, + "max_objects": 4, + "detect_interval": 1 + } + }, + { + "id": 351, + "type": "SCAIL2ColoredMask", + "pos": [ + 1550, + 5470 + ], + "size": [ + 370, + 200 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "localized_name": "driving_track_data", + "name": "driving_track_data", + "type": "SAM3_TRACK_DATA", + "link": 460 + }, + { + "localized_name": "reference_masks", + "name": "ref_track_data", + "shape": 7, + "type": "SAM3_TRACK_DATA,MASK", + "link": 464 + }, + { + "localized_name": "object_indices", + "name": "object_indices", + "type": "STRING", + "widget": { + "name": "object_indices" + }, + "link": null + }, + { + "localized_name": "sort_by", + "name": "sort_by", + "type": "COMBO", + "widget": { + "name": "sort_by" + }, + "link": null + }, + { + "localized_name": "replacement_mode", + "name": "replacement_mode", + "type": "BOOLEAN", + "widget": { + "name": "replacement_mode" + }, + "link": 476 + } + ], + "outputs": [ + { + "localized_name": "pose_video_mask", + "name": "pose_video_mask", + "type": "IMAGE", + "links": [ + 466, + 467 + ] + }, + { + "localized_name": "reference_image_mask", + "name": "reference_image_mask", + "type": "IMAGE", + "links": [ + 465, + 472 + ] + } + ], + "properties": { + "Node name for S&R": "SCAIL2ColoredMask", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "", + "left_to_right", + true + ], + "widgets_values_named": { + "object_indices": "", + "sort_by": "left_to_right", + "replacement_mode": true + } + }, + { + "id": 352, + "type": "SAM3_VideoTrack", + "pos": [ + 1190, + 5620 + ], + "size": [ + 280, + 250 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "label": "images", + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 469 + }, + { + "label": "model", + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 463 + }, + { + "label": "initial_mask", + "localized_name": "initial_mask", + "name": "initial_mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "label": "conditioning", + "localized_name": "conditioning", + "name": "conditioning", + "shape": 7, + "type": "CONDITIONING", + "link": 488 + }, + { + "localized_name": "detection_threshold", + "name": "detection_threshold", + "type": "FLOAT", + "widget": { + "name": "detection_threshold" + }, + "link": null + }, + { + "localized_name": "max_objects", + "name": "max_objects", + "type": "INT", + "widget": { + "name": "max_objects" + }, + "link": null + }, + { + "localized_name": "detect_interval", + "name": "detect_interval", + "type": "INT", + "widget": { + "name": "detect_interval" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "track_data", + "name": "track_data", + "type": "SAM3_TRACK_DATA", + "links": [ + 464 + ] + } + ], + "properties": { + "Node name for S&R": "SAM3_VideoTrack", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 0.5, + 4, + 1 + ], + "widgets_values_named": { + "detection_threshold": 0.5, + "max_objects": 4, + "detect_interval": 1 + } + }, + { + "id": 353, + "type": "PrimitiveBoolean", + "pos": [ + 1660, + 4200 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 504 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 475, + 476 + ] + } + ], + "title": "Boolean (Replace Mode)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + }, + { + "id": 354, + "type": "PreviewImage", + "pos": [ + 2040, + 4470 + ], + "size": [ + 350, + 1190 + ], + "flags": {}, + "order": 32, + "mode": 4, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 466 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "Node name for S&R": "PreviewImage", + "cnr_id": "comfy-core", + "ver": "0.24.0" + } + }, + { + "id": 355, + "type": "PreviewImage", + "pos": [ + 2440, + 4470 + ], + "size": [ + 230, + 310 + ], + "flags": {}, + "order": 33, + "mode": 4, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 465 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": null + } + ], + "properties": { + "Node name for S&R": "PreviewImage", + "cnr_id": "comfy-core", + "ver": "0.24.0" + } + }, + { + "id": 356, + "type": "VAEDecode", + "pos": [ + 3080, + 4120 + ], + "size": [ + 270, + 100 + ], + "flags": { + "collapsed": false + }, + "order": 34, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 124 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 407 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 544 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + } + }, + { + "id": 357, + "type": "PrimitiveBoolean", + "pos": [ + 2410, + 3940 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 35, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 507 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 432, + 433, + 434 + ] + } + ], + "title": "Boolean (Enable Distill LoRA?)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + }, + { + "id": 358, + "type": "CLIPVisionEncode", + "pos": [ + 1200, + 4260 + ], + "size": [ + 230, + 60 + ], + "flags": { + "collapsed": true + }, + "order": 36, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_vision", + "name": "clip_vision", + "type": "CLIP_VISION", + "link": 196 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 408 + }, + { + "localized_name": "crop", + "name": "crop", + "type": "COMBO", + "widget": { + "name": "crop" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION_OUTPUT", + "name": "CLIP_VISION_OUTPUT", + "type": "CLIP_VISION_OUTPUT", + "links": [ + 404 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionEncode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "crop": "none" + } + }, + { + "id": 359, + "type": "CLIPTextEncode", + "pos": [ + 1000, + 3970 + ], + "size": [ + 520, + 210 + ], + "flags": {}, + "order": 37, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 8 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 326 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 360, + "type": "CLIPTextEncode", + "pos": [ + 990, + 3170 + ], + "size": [ + 550, + 750 + ], + "flags": {}, + "order": 38, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 499 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 325 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + "A young East Asian woman with dark hair neatly pulled back, wearing a white sleeveless ribbed mock-neck top, is standing in a lush apple orchard. She holds a large glossy red apple with both hands, using a white paper handkerchief to wipe and clean the apple before smelling and eating it with her eyes closed and a blissful expression. Ripe red and yellow apples hang from leafy branches in the foreground, with rows of green apple trees softly blurred in the background under natural daylight." + ], + "widgets_values_named": { + "text": "A young East Asian woman with dark hair neatly pulled back, wearing a white sleeveless ribbed mock-neck top, is standing in a lush apple orchard. She holds a large glossy red apple with both hands, using a white paper handkerchief to wipe and clean the apple before smelling and eating it with her eyes closed and a blissful expression. Ripe red and yellow apples hang from leafy branches in the foreground, with rows of green apple trees softly blurred in the background under natural daylight." + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 361, + "type": "WanSCAILToVideo", + "pos": [ + 1640, + 3400 + ], + "size": [ + 310, + 580 + ], + "flags": {}, + "order": 39, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 325 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 326 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 406 + }, + { + "localized_name": "pose_video", + "name": "pose_video", + "shape": 7, + "type": "IMAGE", + "link": 420 + }, + { + "localized_name": "pose_video_mask", + "name": "pose_video_mask", + "shape": 7, + "type": "IMAGE", + "link": 467 + }, + { + "localized_name": "reference_image", + "name": "reference_image", + "shape": 7, + "type": "IMAGE", + "link": 473 + }, + { + "localized_name": "reference_image_mask", + "name": "reference_image_mask", + "shape": 7, + "type": "IMAGE", + "link": 472 + }, + { + "localized_name": "clip_vision_output", + "name": "clip_vision_output", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 404 + }, + { + "localized_name": "previous_frames", + "name": "previous_frames", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 414 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 415 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 416 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "pose_strength", + "name": "pose_strength", + "type": "FLOAT", + "widget": { + "name": "pose_strength" + }, + "link": 547 + }, + { + "localized_name": "pose_start", + "name": "pose_start", + "type": "FLOAT", + "widget": { + "name": "pose_start" + }, + "link": 548 + }, + { + "localized_name": "pose_end", + "name": "pose_end", + "type": "FLOAT", + "widget": { + "name": "pose_end" + }, + "link": 549 + }, + { + "localized_name": "video_frame_offset", + "name": "video_frame_offset", + "type": "INT", + "widget": { + "name": "video_frame_offset" + }, + "link": null + }, + { + "localized_name": "previous_frame_count", + "name": "previous_frame_count", + "type": "INT", + "widget": { + "name": "previous_frame_count" + }, + "link": 542 + }, + { + "localized_name": "replacement_mode", + "name": "replacement_mode", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "replacement_mode" + }, + "link": 475 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 333 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 334 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 335 + ] + }, + { + "localized_name": "video_frame_offset", + "name": "video_frame_offset", + "type": "INT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "WanSCAILToVideo", + "cnr_id": "comfy-core", + "ver": "0.24.0" + }, + "widgets_values": [ + 512, + 896, + 65, + 1, + 1, + 0, + 1, + 0, + 5, + true + ], + "widgets_values_named": { + "width": 512, + "height": 896, + "length": 65, + "batch_size": 1, + "pose_strength": 1, + "pose_start": 0, + "pose_end": 1, + "video_frame_offset": 0, + "previous_frame_count": 5, + "replacement_mode": true + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 362, + "type": "CLIPTextEncode", + "pos": [ + 670, + 5690 + ], + "size": [ + 380, + 160 + ], + "flags": {}, + "order": 40, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 489 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 514 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 488 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.19.3", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + "human" + ], + "widgets_values_named": { + "text": "human" + } + }, + { + "id": 363, + "type": "LoraLoaderModelOnly", + "pos": [ + 310, + 3390 + ], + "size": [ + 580, + 140 + ], + "flags": {}, + "order": 41, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 562 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 565 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 563, + 564 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.24.0", + "models": [ + { + "name": "wan2.1_SCAIL_2_DPO_lora_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/SCAIL-2/resolve/main/loras/wan2.1_SCAIL_2_DPO_lora_bf16.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "wan2.1_SCAIL_2_DPO_lora_bf16.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "wan2.1_SCAIL_2_DPO_lora_bf16.safetensors", + "strength_model": 1 + } + } + ], + "groups": [ + { + "id": 3, + "title": "Models", + "bounding": [ + 240, + 3100, + 710, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Prompt", + "bounding": [ + 980, + 3100, + 570, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Sampling", + "bounding": [ + 2730, + 3100, + 660, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "SCAIL Conditioning", + "bounding": [ + 1580, + 3100, + 420, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 9, + "title": "Switch Settings", + "bounding": [ + 2030, + 3100, + 670, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Original Settings", + "bounding": [ + 2050, + 3320, + 300, + 340 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Distill LoRA Settings", + "bounding": [ + 2050, + 3680, + 300, + 370 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 10, + "title": "Create Mask", + "bounding": [ + 240, + 5230, + 1760, + 670 + ], + "flags": {} + }, + { + "id": 11, + "title": "Resize Images", + "bounding": [ + 240, + 4750, + 1760, + 450 + ], + "flags": {} + }, + { + "id": 12, + "title": "Trim Frames from Video", + "bounding": [ + 240, + 4410, + 1760, + 310 + ], + "flags": {} + }, + { + "id": 13, + "title": "Preview Masks", + "bounding": [ + 2030, + 4410, + 660, + 1490 + ], + "flags": {} + } + ], + "links": [ + { + "id": 491, + "origin_id": 341, + "origin_slot": 0, + "target_id": 324, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 444, + "origin_id": 344, + "origin_slot": 1, + "target_id": 324, + "target_slot": 2, + "type": "INT" + }, + { + "id": 446, + "origin_id": 345, + "origin_slot": 1, + "target_id": 324, + "target_slot": 3, + "type": "INT" + }, + { + "id": 492, + "origin_id": 324, + "origin_slot": 0, + "target_id": 326, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 424, + "origin_id": 338, + "origin_slot": 0, + "target_id": 330, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 398, + "origin_id": 322, + "origin_slot": 0, + "target_id": 332, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 428, + "origin_id": 339, + "origin_slot": 0, + "target_id": 332, + "target_slot": 2, + "type": "INT" + }, + { + "id": 417, + "origin_id": 330, + "origin_slot": 0, + "target_id": 333, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 333, + "origin_id": 361, + "origin_slot": 0, + "target_id": 333, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 334, + "origin_id": 361, + "origin_slot": 1, + "target_id": 333, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 419, + "origin_id": 331, + "origin_slot": 0, + "target_id": 333, + "target_slot": 3, + "type": "SAMPLER" + }, + { + "id": 418, + "origin_id": 332, + "origin_slot": 0, + "target_id": 333, + "target_slot": 4, + "type": "SIGMAS" + }, + { + "id": 335, + "origin_id": 361, + "origin_slot": 2, + "target_id": 333, + "target_slot": 5, + "type": "LATENT" + }, + { + "id": 431, + "origin_id": 340, + "origin_slot": 0, + "target_id": 333, + "target_slot": 8, + "type": "FLOAT" + }, + { + "id": 425, + "origin_id": 322, + "origin_slot": 0, + "target_id": 338, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 432, + "origin_id": 357, + "origin_slot": 0, + "target_id": 338, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 426, + "origin_id": 336, + "origin_slot": 0, + "target_id": 339, + "target_slot": 0, + "type": "INT" + }, + { + "id": 427, + "origin_id": 334, + "origin_slot": 0, + "target_id": 339, + "target_slot": 1, + "type": "INT" + }, + { + "id": 433, + "origin_id": 357, + "origin_slot": 0, + "target_id": 339, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 429, + "origin_id": 337, + "origin_slot": 0, + "target_id": 340, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 430, + "origin_id": 335, + "origin_slot": 0, + "target_id": 340, + "target_slot": 1, + "type": "FLOAT" + }, + { + "id": 434, + "origin_id": 357, + "origin_slot": 0, + "target_id": 340, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 490, + "origin_id": 325, + "origin_slot": 0, + "target_id": 341, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 450, + "origin_id": 347, + "origin_slot": 1, + "target_id": 341, + "target_slot": 1, + "type": "INT" + }, + { + "id": 441, + "origin_id": 342, + "origin_slot": 0, + "target_id": 344, + "target_slot": 0, + "type": "INT" + }, + { + "id": 445, + "origin_id": 343, + "origin_slot": 0, + "target_id": 345, + "target_slot": 0, + "type": "INT" + }, + { + "id": 447, + "origin_id": 346, + "origin_slot": 0, + "target_id": 347, + "target_slot": 0, + "type": "INT" + }, + { + "id": 454, + "origin_id": 349, + "origin_slot": 1, + "target_id": 348, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 493, + "origin_id": 324, + "origin_slot": 0, + "target_id": 350, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 458, + "origin_id": 349, + "origin_slot": 0, + "target_id": 350, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 461, + "origin_id": 348, + "origin_slot": 0, + "target_id": 350, + "target_slot": 3, + "type": "CONDITIONING" + }, + { + "id": 460, + "origin_id": 350, + "origin_slot": 0, + "target_id": 351, + "target_slot": 0, + "type": "SAM3_TRACK_DATA" + }, + { + "id": 464, + "origin_id": 352, + "origin_slot": 0, + "target_id": 351, + "target_slot": 1, + "type": "SAM3_TRACK_DATA" + }, + { + "id": 476, + "origin_id": 353, + "origin_slot": 0, + "target_id": 351, + "target_slot": 4, + "type": "BOOLEAN" + }, + { + "id": 463, + "origin_id": 349, + "origin_slot": 0, + "target_id": 352, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 488, + "origin_id": 362, + "origin_slot": 0, + "target_id": 352, + "target_slot": 3, + "type": "CONDITIONING" + }, + { + "id": 466, + "origin_id": 351, + "origin_slot": 0, + "target_id": 354, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 465, + "origin_id": 351, + "origin_slot": 1, + "target_id": 355, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 124, + "origin_id": 333, + "origin_slot": 1, + "target_id": 356, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 407, + "origin_id": 323, + "origin_slot": 0, + "target_id": 356, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 196, + "origin_id": 328, + "origin_slot": 0, + "target_id": 358, + "target_slot": 0, + "type": "CLIP_VISION" + }, + { + "id": 8, + "origin_id": 327, + "origin_slot": 0, + "target_id": 359, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 7, + "origin_id": 327, + "origin_slot": 0, + "target_id": 360, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 325, + "origin_id": 360, + "origin_slot": 0, + "target_id": 361, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 326, + "origin_id": 359, + "origin_slot": 0, + "target_id": 361, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 406, + "origin_id": 323, + "origin_slot": 0, + "target_id": 361, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 420, + "origin_id": 324, + "origin_slot": 0, + "target_id": 361, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 467, + "origin_id": 351, + "origin_slot": 0, + "target_id": 361, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 472, + "origin_id": 351, + "origin_slot": 1, + "target_id": 361, + "target_slot": 6, + "type": "IMAGE" + }, + { + "id": 404, + "origin_id": 358, + "origin_slot": 0, + "target_id": 361, + "target_slot": 7, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 414, + "origin_id": 326, + "origin_slot": 0, + "target_id": 361, + "target_slot": 9, + "type": "INT" + }, + { + "id": 415, + "origin_id": 326, + "origin_slot": 1, + "target_id": 361, + "target_slot": 10, + "type": "INT" + }, + { + "id": 416, + "origin_id": 326, + "origin_slot": 2, + "target_id": 361, + "target_slot": 11, + "type": "INT" + }, + { + "id": 475, + "origin_id": 353, + "origin_slot": 0, + "target_id": 361, + "target_slot": 18, + "type": "BOOLEAN" + }, + { + "id": 489, + "origin_id": 349, + "origin_slot": 1, + "target_id": 362, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 409, + "origin_id": -10, + "origin_slot": 0, + "target_id": 325, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 469, + "origin_id": -10, + "origin_slot": 1, + "target_id": 352, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 408, + "origin_id": -10, + "origin_slot": 1, + "target_id": 358, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 473, + "origin_id": -10, + "origin_slot": 1, + "target_id": 361, + "target_slot": 5, + "type": "IMAGE" + }, + { + "id": 499, + "origin_id": -10, + "origin_slot": 2, + "target_id": 360, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 502, + "origin_id": -10, + "origin_slot": 3, + "target_id": 346, + "target_slot": 0, + "type": "INT" + }, + { + "id": 504, + "origin_id": -10, + "origin_slot": 4, + "target_id": 353, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 505, + "origin_id": -10, + "origin_slot": 5, + "target_id": 342, + "target_slot": 0, + "type": "INT" + }, + { + "id": 506, + "origin_id": -10, + "origin_slot": 6, + "target_id": 343, + "target_slot": 0, + "type": "INT" + }, + { + "id": 507, + "origin_id": -10, + "origin_slot": 12, + "target_id": 357, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 508, + "origin_id": -10, + "origin_slot": 13, + "target_id": 329, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 509, + "origin_id": -10, + "origin_slot": 14, + "target_id": 322, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 510, + "origin_id": -10, + "origin_slot": 16, + "target_id": 327, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 511, + "origin_id": -10, + "origin_slot": 17, + "target_id": 323, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 512, + "origin_id": -10, + "origin_slot": 18, + "target_id": 328, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 513, + "origin_id": -10, + "origin_slot": 19, + "target_id": 348, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 514, + "origin_id": -10, + "origin_slot": 20, + "target_id": 362, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 515, + "origin_id": -10, + "origin_slot": 21, + "target_id": 349, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 522, + "origin_id": -10, + "origin_slot": 7, + "target_id": 341, + "target_slot": 2, + "type": "INT" + }, + { + "id": 541, + "origin_id": -10, + "origin_slot": 7, + "target_id": 347, + "target_slot": 1, + "type": "FLOAT,INT,BOOLEAN" + }, + { + "id": 542, + "origin_id": -10, + "origin_slot": 8, + "target_id": 361, + "target_slot": 17, + "type": "INT" + }, + { + "id": 543, + "origin_id": -10, + "origin_slot": 8, + "target_id": 347, + "target_slot": 2, + "type": "FLOAT,INT,BOOLEAN" + }, + { + "id": 544, + "origin_id": 356, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 547, + "origin_id": -10, + "origin_slot": 9, + "target_id": 361, + "target_slot": 13, + "type": "FLOAT" + }, + { + "id": 548, + "origin_id": -10, + "origin_slot": 10, + "target_id": 361, + "target_slot": 14, + "type": "FLOAT" + }, + { + "id": 549, + "origin_id": -10, + "origin_slot": 11, + "target_id": 361, + "target_slot": 15, + "type": "FLOAT" + }, + { + "id": 558, + "origin_id": -10, + "origin_slot": 22, + "target_id": 333, + "target_slot": 7, + "type": "INT" + }, + { + "id": 562, + "origin_id": 329, + "origin_slot": 0, + "target_id": 363, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 563, + "origin_id": 363, + "origin_slot": 0, + "target_id": 322, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 564, + "origin_id": 363, + "origin_slot": 0, + "target_id": 338, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 565, + "origin_id": -10, + "origin_slot": 15, + "target_id": 363, + "target_slot": 1, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Video editing/Video Edit", + "description": "Replaces a character in a video with a reference image using the INT8 SCAIL-2 checkpoint for end-to-end animation without intermediate pose maps." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Control to Image (Anima LLLite).json b/blueprints/Control to Image (Anima LLLite).json new file mode 100644 index 00000000000..e3e5e9c9259 --- /dev/null +++ b/blueprints/Control to Image (Anima LLLite).json @@ -0,0 +1,2064 @@ +{ + "revision": 0, + "last_node_id": "761", + "last_link_id": 0, + "nodes": [ + { + "id": 761, + "type": "96d13c64-6091-4166-acab-4acacde696d4", + "pos": [ + 30, + 1320 + ], + "size": [ + 460, + 890 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "label": "control_image", + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": null + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "label": "positive_prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "label": "negative_prompt", + "name": "text_1", + "type": "STRING", + "widget": { + "name": "text_1" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "anima_lllite_model", + "name": "name", + "type": "COMBO", + "widget": { + "name": "name" + }, + "link": null + }, + { + "label": "control_strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + }, + { + "name": "start_percent", + "type": "FLOAT", + "widget": { + "name": "start_percent" + }, + "link": null + }, + { + "name": "end_percent", + "type": "FLOAT", + "widget": { + "name": "end_percent" + }, + "link": null + }, + { + "label": "turbo_mode", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "turbo_lora", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "751", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia", + 1024, + 1024, + "anima-base-v1.0.safetensors", + "qwen_3_06b_base.safetensors", + "qwen_image_vae.safetensors", + "anima-lllite-inpainting-v2.safetensors", + 1, + 0, + 1, + true, + "anima-turbo-lora-v0.2.safetensors" + ], + "widgets_values_named": { + "text": "", + "text_1": "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia", + "width": 1024, + "height": 1024, + "unet_name": "anima-base-v1.0.safetensors", + "clip_name": "qwen_3_06b_base.safetensors", + "vae_name": "qwen_image_vae.safetensors", + "name": "anima-lllite-inpainting-v2.safetensors", + "strength": 1, + "start_percent": 0, + "end_percent": 1, + "value": true, + "lora_name": "anima-turbo-lora-v0.2.safetensors" + }, + "title": "Control to Image (Anima LLLite)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "96d13c64-6091-4166-acab-4acacde696d4", + "version": 1, + "state": { + "lastGroupId": 12, + "lastNodeId": 761, + "lastLinkId": 1741, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Control to Image (Anima LLLite)", + "inputNode": { + "id": -10, + "bounding": [ + -220, + 450, + 151.125, + 348 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 2850, + 290, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "19869e4d-0c3f-406d-b630-c3c062377ac8", + "name": "image", + "type": "IMAGE", + "linkIds": [ + 137 + ], + "localized_name": "image", + "label": "control_image", + "pos": [ + -92.875, + 474 + ] + }, + { + "id": "2a9a1c29-e3e3-4ec0-a58e-31f3596468be", + "name": "mask", + "type": "MASK", + "linkIds": [ + 138 + ], + "localized_name": "mask", + "shape": 7, + "pos": [ + -92.875, + 494 + ] + }, + { + "id": "ee7d36b6-6f98-4678-817d-df909825fdd5", + "name": "text", + "type": "STRING", + "linkIds": [ + 144 + ], + "label": "positive_prompt", + "pos": [ + -92.875, + 514 + ] + }, + { + "id": "f25576e0-ebd8-4db5-8eb7-8aef00a4d3c6", + "name": "text_1", + "type": "STRING", + "linkIds": [ + 145 + ], + "label": "negative_prompt", + "pos": [ + -92.875, + 534 + ] + }, + { + "id": "712dc183-3641-4c6d-a809-6b46120bd046", + "name": "width", + "type": "INT", + "linkIds": [ + 146 + ], + "pos": [ + -92.875, + 554 + ] + }, + { + "id": "afc76b3a-3e72-455c-99c3-671273d9e662", + "name": "height", + "type": "INT", + "linkIds": [ + 147 + ], + "pos": [ + -92.875, + 574 + ] + }, + { + "id": "b7da2118-f870-4df9-aed0-117d1293c7be", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 152 + ], + "pos": [ + -92.875, + 594 + ] + }, + { + "id": "6bea4715-95bc-470b-8873-7685ee87bd79", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 153 + ], + "pos": [ + -92.875, + 614 + ] + }, + { + "id": "407645e7-8fc2-4254-86b4-40ec0c8041b8", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 154 + ], + "pos": [ + -92.875, + 634 + ] + }, + { + "id": "8e74efb4-d72a-49b6-9637-adf340e6c000", + "name": "name", + "type": "COMBO", + "linkIds": [ + 155 + ], + "label": "anima_lllite_model", + "pos": [ + -92.875, + 654 + ] + }, + { + "id": "48f292a5-d7b7-44d1-b535-dee6ca7c27c2", + "name": "strength", + "type": "FLOAT", + "linkIds": [ + 156 + ], + "label": "control_strength", + "pos": [ + -92.875, + 674 + ] + }, + { + "id": "fbdc9740-abae-4829-a531-764a17ff1b69", + "name": "start_percent", + "type": "FLOAT", + "linkIds": [ + 157 + ], + "pos": [ + -92.875, + 694 + ] + }, + { + "id": "10f6d0a2-2abd-43a0-b8bf-cd6e0aa35d22", + "name": "end_percent", + "type": "FLOAT", + "linkIds": [ + 158 + ], + "pos": [ + -92.875, + 714 + ] + }, + { + "id": "4294cda5-6bd8-448a-9e8c-32115c4b4c79", + "name": "value", + "type": "BOOLEAN", + "linkIds": [ + 189 + ], + "label": "turbo_mode", + "pos": [ + -92.875, + 734 + ] + }, + { + "id": "dd4e3e86-a3b8-4e0d-a2de-d5e971654c5b", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 190 + ], + "label": "turbo_lora", + "pos": [ + -92.875, + 754 + ] + } + ], + "outputs": [ + { + "id": "41d11dee-93c3-479f-a863-061beb098c9b", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 182 + ], + "localized_name": "IMAGE", + "pos": [ + 2874, + 314 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 742, + "type": "CLIPLoader", + "pos": [ + 180, + 370 + ], + "size": [ + 390, + 150 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 153 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 126, + 130 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.11.0", + "models": [ + { + "name": "qwen_3_06b_base.safetensors", + "url": "https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/text_encoders/qwen_3_06b_base.safetensors", + "directory": "text_encoders" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_3_06b_base.safetensors", + "stable_diffusion", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen_3_06b_base.safetensors", + "type": "stable_diffusion", + "device": "default" + } + }, + { + "id": 743, + "type": "VAELoader", + "pos": [ + 180, + 590 + ], + "size": [ + 390, + 100 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 154 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 183 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.3.40", + "models": [ + { + "name": "qwen_image_vae.safetensors", + "url": "https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/vae/qwen_image_vae.safetensors", + "directory": "vae" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ], + "widgets_values_named": { + "vae_name": "qwen_image_vae.safetensors" + } + }, + { + "id": 744, + "type": "EmptyLatentImage", + "pos": [ + 190, + 980 + ], + "size": [ + 310, + 150 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 146 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 147 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 186 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.3.40", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1024, + 1024, + 1 + ], + "widgets_values_named": { + "width": 1024, + "height": 1024, + "batch_size": 1 + } + }, + { + "id": 745, + "type": "CLIPTextEncode", + "pos": [ + 630, + 610 + ], + "size": [ + 490, + 140 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 126 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 145 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 185 + ] + } + ], + "title": "CLIP Text Encode (Negative Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.65", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia" + ], + "widgets_values_named": { + "text": "worst quality, low quality, score_1, score_2, score_3, blurry, jpeg artifacts, sepia" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 746, + "type": "CLIPTextEncode", + "pos": [ + 630, + 170 + ], + "size": [ + 490, + 380 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 130 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 144 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 184 + ] + } + ], + "title": "CLIP Text Encode (Positive Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.65", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 747, + "type": "UNETLoader", + "pos": [ + 180, + 180 + ], + "size": [ + 390, + 130 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 152 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 134 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.11.0", + "models": [ + { + "name": "anima-base-v1.0.safetensors", + "url": "https://huggingface.co/circlestone-labs/Anima/resolve/main/split_files/diffusion_models/anima-base-v1.0.safetensors", + "directory": "diffusion_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "anima-base-v1.0.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "anima-base-v1.0.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 748, + "type": "ModelPatchLoader", + "pos": [ + 180, + 750 + ], + "size": [ + 390, + 110 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "name", + "name": "name", + "type": "COMBO", + "widget": { + "name": "name" + }, + "link": 155 + } + ], + "outputs": [ + { + "localized_name": "MODEL_PATCH", + "name": "MODEL_PATCH", + "type": "MODEL_PATCH", + "links": [ + 135 + ] + } + ], + "properties": { + "Node name for S&R": "ModelPatchLoader", + "models": [ + { + "name": "anima-lllite-inpainting-v2.safetensors", + "url": "https://huggingface.co/Comfy-Org/Anima-LLLite/resolve/main/model_patches/anima-lllite-inpainting-v2.safetensors", + "directory": "model_patches" + } + ] + }, + "widgets_values": [ + "anima-lllite-inpainting-v2.safetensors" + ], + "widgets_values_named": { + "name": "anima-lllite-inpainting-v2.safetensors" + } + }, + { + "id": 749, + "type": "AnimaLLLiteApply", + "pos": [ + 750, + 880 + ], + "size": [ + 270, + 240 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 134 + }, + { + "localized_name": "model_patch", + "name": "model_patch", + "type": "MODEL_PATCH", + "link": 135 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 137 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 138 + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": 156 + }, + { + "localized_name": "start_percent", + "name": "start_percent", + "type": "FLOAT", + "widget": { + "name": "start_percent" + }, + "link": 157 + }, + { + "localized_name": "end_percent", + "name": "end_percent", + "type": "FLOAT", + "widget": { + "name": "end_percent" + }, + "link": 158 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 187, + 188 + ] + } + ], + "properties": { + "Node name for S&R": "AnimaLLLiteApply" + }, + "widgets_values": [ + 1, + 0, + 1 + ], + "widgets_values_named": { + "strength": 1, + "start_percent": 0, + "end_percent": 1 + } + }, + { + "id": 750, + "type": "VAEDecode", + "pos": [ + 2290, + 230 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": false + }, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 170 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 183 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 182 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.3.40", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 751, + "type": "KSampler", + "pos": [ + 1960, + 210 + ], + "size": [ + 300, + 620 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 171 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 184 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 185 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 186 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 172 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": 173 + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 170 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.3.40", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1376514088921, + "randomize", + 30, + 4, + "euler", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 1376514088921, + "control_after_generate": "randomize", + "steps": 30, + "cfg": 4, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 752, + "type": "PrimitiveInt", + "pos": [ + 1180, + 160 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 176 + ] + } + ], + "title": "Int (Steps)", + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 30, + "fixed" + ], + "widgets_values_named": { + "value": 30, + "fixed": "fixed" + } + }, + { + "id": 753, + "type": "PrimitiveInt", + "pos": [ + 1180, + 490 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 177 + ] + } + ], + "title": "Int (Steps)", + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 8, + "fixed" + ], + "widgets_values_named": { + "value": 8, + "fixed": "fixed" + } + }, + { + "id": 754, + "type": "LoraLoaderModelOnly", + "pos": [ + 1180, + 770 + ], + "size": [ + 270, + 120 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 188 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 190 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 174 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "anima-turbo-lora-v0.2.safetensors", + "url": "https://huggingface.co/circlestone-labs/Anima-Official-LoRAs/resolve/main/anima-turbo-lora-v0.2.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "anima-turbo-lora-v0.2.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "anima-turbo-lora-v0.2.safetensors", + "strength_model": 1 + } + }, + { + "id": 755, + "type": "ComfySwitchNode", + "pos": [ + 1550, + 300 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 187 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 174 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 175 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 171 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 756, + "type": "ComfySwitchNode", + "pos": [ + 1550, + 490 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 176 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 177 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 178 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 172 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 757, + "type": "PrimitiveFloat", + "pos": [ + 1180, + 300 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 179 + ] + } + ], + "title": "Float (CFG)", + "properties": { + "Node name for S&R": "PrimitiveFloat" + }, + "widgets_values": [ + 4 + ], + "widgets_values_named": { + "value": 4 + } + }, + { + "id": 758, + "type": "ComfySwitchNode", + "pos": [ + 1550, + 690 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 179 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 180 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 181 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 173 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 759, + "type": "PrimitiveFloat", + "pos": [ + 1180, + 630 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 180 + ] + } + ], + "title": "Float (CFG)", + "properties": { + "Node name for S&R": "PrimitiveFloat" + }, + "widgets_values": [ + 1 + ], + "widgets_values_named": { + "value": 1 + } + }, + { + "id": 760, + "type": "PrimitiveBoolean", + "pos": [ + 1180, + 950 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 189 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 175, + 178, + 181 + ] + } + ], + "title": "Boolean (Enable Turbo LoRA)", + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + } + ], + "groups": [ + { + "id": 1, + "title": "Load Models", + "bounding": [ + 160, + 80, + 430, + 800 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Image Size(1MP))", + "bounding": [ + 160, + 900, + 430, + 260 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Prompt", + "bounding": [ + 610, + 80, + 530, + 690 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Apply Model Patch", + "bounding": [ + 610, + 790, + 530, + 370 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 10, + "title": "Settings (Full)", + "bounding": [ + 1170, + 80, + 290, + 306 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 11, + "title": "Settings (Turbo LoRA)", + "bounding": [ + 1170, + 410, + 300, + 490 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 126, + "origin_id": 742, + "origin_slot": 0, + "target_id": 745, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 130, + "origin_id": 742, + "origin_slot": 0, + "target_id": 746, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 134, + "origin_id": 747, + "origin_slot": 0, + "target_id": 749, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 135, + "origin_id": 748, + "origin_slot": 0, + "target_id": 749, + "target_slot": 1, + "type": "MODEL_PATCH" + }, + { + "id": 137, + "origin_id": -10, + "origin_slot": 0, + "target_id": 749, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 138, + "origin_id": -10, + "origin_slot": 1, + "target_id": 749, + "target_slot": 3, + "type": "MASK" + }, + { + "id": 144, + "origin_id": -10, + "origin_slot": 2, + "target_id": 746, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 145, + "origin_id": -10, + "origin_slot": 3, + "target_id": 745, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 146, + "origin_id": -10, + "origin_slot": 4, + "target_id": 744, + "target_slot": 0, + "type": "INT" + }, + { + "id": 147, + "origin_id": -10, + "origin_slot": 5, + "target_id": 744, + "target_slot": 1, + "type": "INT" + }, + { + "id": 152, + "origin_id": -10, + "origin_slot": 6, + "target_id": 747, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 153, + "origin_id": -10, + "origin_slot": 7, + "target_id": 742, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 154, + "origin_id": -10, + "origin_slot": 8, + "target_id": 743, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 155, + "origin_id": -10, + "origin_slot": 9, + "target_id": 748, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 156, + "origin_id": -10, + "origin_slot": 10, + "target_id": 749, + "target_slot": 4, + "type": "FLOAT" + }, + { + "id": 157, + "origin_id": -10, + "origin_slot": 11, + "target_id": 749, + "target_slot": 5, + "type": "FLOAT" + }, + { + "id": 158, + "origin_id": -10, + "origin_slot": 12, + "target_id": 749, + "target_slot": 6, + "type": "FLOAT" + }, + { + "id": 170, + "origin_id": 751, + "origin_slot": 0, + "target_id": 750, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 171, + "origin_id": 755, + "origin_slot": 0, + "target_id": 751, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 172, + "origin_id": 756, + "origin_slot": 0, + "target_id": 751, + "target_slot": 5, + "type": "INT" + }, + { + "id": 173, + "origin_id": 758, + "origin_slot": 0, + "target_id": 751, + "target_slot": 6, + "type": "FLOAT" + }, + { + "id": 174, + "origin_id": 754, + "origin_slot": 0, + "target_id": 755, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 175, + "origin_id": 760, + "origin_slot": 0, + "target_id": 755, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 176, + "origin_id": 752, + "origin_slot": 0, + "target_id": 756, + "target_slot": 0, + "type": "INT" + }, + { + "id": 177, + "origin_id": 753, + "origin_slot": 0, + "target_id": 756, + "target_slot": 1, + "type": "INT" + }, + { + "id": 178, + "origin_id": 760, + "origin_slot": 0, + "target_id": 756, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 179, + "origin_id": 757, + "origin_slot": 0, + "target_id": 758, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 180, + "origin_id": 759, + "origin_slot": 0, + "target_id": 758, + "target_slot": 1, + "type": "FLOAT" + }, + { + "id": 181, + "origin_id": 760, + "origin_slot": 0, + "target_id": 758, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 182, + "origin_id": 750, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 183, + "origin_id": 743, + "origin_slot": 0, + "target_id": 750, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 184, + "origin_id": 746, + "origin_slot": 0, + "target_id": 751, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 185, + "origin_id": 745, + "origin_slot": 0, + "target_id": 751, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 186, + "origin_id": 744, + "origin_slot": 0, + "target_id": 751, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 187, + "origin_id": 749, + "origin_slot": 0, + "target_id": 755, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 188, + "origin_id": 749, + "origin_slot": 0, + "target_id": 754, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 189, + "origin_id": -10, + "origin_slot": 13, + "target_id": 760, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 190, + "origin_id": -10, + "origin_slot": 14, + "target_id": 754, + "target_slot": 1, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Image generation/Conditioned", + "description": "Generates anime-style images from a control image using Anima with an LLLite patch for structure-guided generation." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/ControlNet (Z-Image-Turbo).json b/blueprints/ControlNet (Z-Image-Turbo).json index 160ee11e2be..e4010eb7463 100644 --- a/blueprints/ControlNet (Z-Image-Turbo).json +++ b/blueprints/ControlNet (Z-Image-Turbo).json @@ -1401,7 +1401,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Conditioned", + "category": "Image generation/Conditioned", "description": "Generates images from a text prompt and ControlNet conditioning (e.g. depth, canny) using Z-Image-Turbo." } ] diff --git a/blueprints/Depth to Image (Z-Image-Turbo).json b/blueprints/Depth to Image (Z-Image-Turbo).json index 2790827a34b..721d0b57229 100644 --- a/blueprints/Depth to Image (Z-Image-Turbo).json +++ b/blueprints/Depth to Image (Z-Image-Turbo).json @@ -1579,7 +1579,7 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Conditioned", + "category": "Image generation/Conditioned", "description": "Generates an image from a depth map using Z-Image-Turbo with text conditioning." }, { diff --git a/blueprints/Depth to Video (ltx 2.0).json b/blueprints/Depth to Video (ltx 2.0).json index 56912de511a..f9f090d709c 100644 --- a/blueprints/Depth to Video (ltx 2.0).json +++ b/blueprints/Depth to Video (ltx 2.0).json @@ -4233,7 +4233,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Conditioned", + "category": "Video generation/Conditioned", "description": "Generates depth-controlled video with LTX-2: motion and structure follow a depth-reference video alongside text prompting, optional first-frame image conditioning, with optional synchronized audio." }, { diff --git a/blueprints/Edit Image (Boogu Image Edit).json b/blueprints/Edit Image (Boogu Image Edit).json new file mode 100644 index 00000000000..f8e063a8361 --- /dev/null +++ b/blueprints/Edit Image (Boogu Image Edit).json @@ -0,0 +1,1173 @@ +{ + "revision": 0, + "last_node_id": 65, + "last_link_id": 0, + "nodes": [ + { + "id": 65, + "type": "8aafec59-82d8-43ab-9b1e-37c3861b9a8b", + "pos": [ + 930, + 5230 + ], + "size": [ + 400, + 610 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "label": "image", + "localized_name": "images.image_1", + "name": "images.image_1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "proxyWidgets": [ + [ + "61", + "prompt" + ], + [ + "57", + "width" + ], + [ + "57", + "height" + ], + [ + "62", + "noise_seed" + ], + [ + "55", + "unet_name" + ], + [ + "59", + "clip_name" + ], + [ + "56", + "vae_name" + ] + ], + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [], + "title": "Edit Image (Boogu Image Edit)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "8aafec59-82d8-43ab-9b1e-37c3861b9a8b", + "version": 1, + "state": { + "lastGroupId": 4, + "lastNodeId": 65, + "lastLinkId": 111, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Edit Image (Boogu Image Edit)", + "inputNode": { + "id": -10, + "bounding": [ + 440, + 4870, + 128, + 208 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 2650, + 4941, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "87d5def6-50cd-4f8d-89e4-b9368666c98f", + "name": "images.image_1", + "type": "IMAGE", + "linkIds": [ + 74 + ], + "localized_name": "images.image_1", + "label": "image", + "shape": 7, + "pos": [ + 544, + 4894 + ] + }, + { + "id": "9f83fee0-5567-4932-a56b-64c16a2a64fe", + "name": "prompt", + "type": "STRING", + "linkIds": [ + 94 + ], + "pos": [ + 544, + 4914 + ] + }, + { + "id": "5572eeae-12cd-4f85-b426-c75b56958731", + "name": "width", + "type": "INT", + "linkIds": [ + 96 + ], + "pos": [ + 544, + 4934 + ] + }, + { + "id": "f9c251b1-5ddd-40e7-8845-a90e529ff4d1", + "name": "height", + "type": "INT", + "linkIds": [ + 97 + ], + "pos": [ + 544, + 4954 + ] + }, + { + "id": "4d798731-1c93-47bf-b01a-a13c5e34ceb9", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 98 + ], + "pos": [ + 544, + 4974 + ] + }, + { + "id": "6201bfdf-bdca-4188-aa6e-510403e07cd4", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 99 + ], + "pos": [ + 544, + 4994 + ] + }, + { + "id": "781bb594-7a0a-4bf3-ab28-07e47faf2b83", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 100 + ], + "pos": [ + 544, + 5014 + ] + }, + { + "id": "19aa7d74-031b-4679-a007-e6109b076d5b", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 101 + ], + "pos": [ + 544, + 5034 + ] + } + ], + "outputs": [ + { + "id": "ce99b08d-e499-4cf5-bd94-3ec34f58910a", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 91 + ], + "localized_name": "IMAGE", + "pos": [ + 2674, + 4965 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 55, + "type": "UNETLoader", + "pos": [ + 800, + 4580 + ], + "size": [ + 390, + 140 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 99 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 64, + 90 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "boogu_image_edit_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Boogu-Image/resolve/main/diffusion_models/boogu_image_edit_fp8_scaled.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "boogu_image_edit_fp8_scaled.safetensors", + "default" + ] + }, + { + "id": 56, + "type": "VAELoader", + "pos": [ + 800, + 5000 + ], + "size": [ + 380, + 120 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 101 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 4, + 71 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/vae/ae.safetensors?download=true", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ae.safetensors" + ] + }, + { + "id": 57, + "type": "EmptyLatentImage", + "pos": [ + 870, + 5250 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 96 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 97 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 37 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 58, + "type": "ModelSamplingAuraFlow", + "pos": [ + 1720, + 4980 + ], + "size": [ + 270, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 64 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 65 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingAuraFlow", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + 3.16 + ] + }, + { + "id": 59, + "type": "CLIPLoader", + "pos": [ + 800, + 4770 + ], + "size": [ + 380, + 170 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 100 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 70 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "qwen3vl_8b_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Qwen3-VL/resolve/main/text_encoders/qwen3vl_8b_fp8_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_8b_fp8_scaled.safetensors", + "boogu", + "default" + ] + }, + { + "id": 60, + "type": "VAEDecode", + "pos": [ + 2360, + 4590 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 76 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 4 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 91 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + } + }, + { + "id": 61, + "type": "TextEncodeBooguEdit", + "pos": [ + 1240, + 4580 + ], + "size": [ + 430, + 540 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 70 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 71 + }, + { + "label": "image_1", + "localized_name": "images.image_1", + "name": "images.image_1", + "shape": 7, + "type": "IMAGE", + "link": 74 + }, + { + "label": "image_2", + "localized_name": "images.image_2", + "name": "images.image_2", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 94 + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 72 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 73 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeBooguEdit", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "", + "" + ] + }, + { + "id": 62, + "type": "SamplerCustom", + "pos": [ + 2040, + 4580 + ], + "size": [ + 280, + 540 + ], + "flags": { + "collapsed": false + }, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 90 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 72 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 73 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 40 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 77 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 37 + }, + { + "localized_name": "add_noise", + "name": "add_noise", + "type": "BOOLEAN", + "widget": { + "name": "add_noise" + }, + "link": null + }, + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 98 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 76 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "SamplerCustom", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + true, + 22, + "fixed", + 3.5 + ] + }, + { + "id": 63, + "type": "KSamplerSelect", + "pos": [ + 1720, + 4580 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 40 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "dpmpp_2m" + ] + }, + { + "id": 64, + "type": "BasicScheduler", + "pos": [ + 1720, + 4750 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 65 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 77 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "simple", + 25, + 1 + ] + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + 790, + 4510, + 410, + 620 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Prompt", + "bounding": [ + 1230, + 4510, + 450, + 620 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Image Size", + "bounding": [ + 790, + 5160, + 410, + 280 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Sampling", + "bounding": [ + 1710, + 4510, + 620, + 620 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 64, + "origin_id": 55, + "origin_slot": 0, + "target_id": 58, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 76, + "origin_id": 62, + "origin_slot": 0, + "target_id": 60, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 4, + "origin_id": 56, + "origin_slot": 0, + "target_id": 60, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 70, + "origin_id": 59, + "origin_slot": 0, + "target_id": 61, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 71, + "origin_id": 56, + "origin_slot": 0, + "target_id": 61, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 90, + "origin_id": 55, + "origin_slot": 0, + "target_id": 62, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 72, + "origin_id": 61, + "origin_slot": 0, + "target_id": 62, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 73, + "origin_id": 61, + "origin_slot": 1, + "target_id": 62, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 40, + "origin_id": 63, + "origin_slot": 0, + "target_id": 62, + "target_slot": 3, + "type": "SAMPLER" + }, + { + "id": 77, + "origin_id": 64, + "origin_slot": 0, + "target_id": 62, + "target_slot": 4, + "type": "SIGMAS" + }, + { + "id": 37, + "origin_id": 57, + "origin_slot": 0, + "target_id": 62, + "target_slot": 5, + "type": "LATENT" + }, + { + "id": 65, + "origin_id": 58, + "origin_slot": 0, + "target_id": 64, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 74, + "origin_id": -10, + "origin_slot": 0, + "target_id": 61, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 91, + "origin_id": 60, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 94, + "origin_id": -10, + "origin_slot": 1, + "target_id": 61, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 96, + "origin_id": -10, + "origin_slot": 2, + "target_id": 57, + "target_slot": 0, + "type": "INT" + }, + { + "id": 97, + "origin_id": -10, + "origin_slot": 3, + "target_id": 57, + "target_slot": 1, + "type": "INT" + }, + { + "id": 98, + "origin_id": -10, + "origin_slot": 4, + "target_id": 62, + "target_slot": 7, + "type": "INT" + }, + { + "id": 99, + "origin_id": -10, + "origin_slot": 5, + "target_id": 55, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 100, + "origin_id": -10, + "origin_slot": 6, + "target_id": 59, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 101, + "origin_id": -10, + "origin_slot": 7, + "target_id": 56, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": { + "workflowRendererVersion": "LG" + }, + "category": "Image editing/Edit image", + "description": "Edits images from text instructions using Boogu-Image-0.1-Edit and TextEncodeBooguEdit, preserving reference identity via dual conditioning." + } + ] + }, + "extra": {} +} diff --git a/blueprints/FLF2V (Wan2.1 VACE).json b/blueprints/FLF2V (Wan2.1 VACE).json new file mode 100644 index 00000000000..511cc1a4364 --- /dev/null +++ b/blueprints/FLF2V (Wan2.1 VACE).json @@ -0,0 +1,3472 @@ +{ + "revision": 0, + "last_node_id": 259, + "last_link_id": 0, + "nodes": [ + { + "id": 259, + "type": "b3ce3e73-fd62-47b4-b9a7-586018ca5352", + "pos": [ + -490, + 480 + ], + "size": [ + 480, + 800 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "label": "first_frame", + "localized_name": "image1", + "name": "image1", + "type": "IMAGE", + "link": null + }, + { + "label": "last_frame", + "localized_name": "image2", + "name": "image2", + "type": "IMAGE", + "link": null + }, + { + "label": "positive_prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "label": "negative_prompt", + "name": "text_1", + "type": "STRING", + "widget": { + "name": "text_1" + }, + "link": null + }, + { + "label": "duration", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "width", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "height", + "name": "value_2", + "type": "INT", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "enable_turbo_mode", + "name": "value_3", + "type": "BOOLEAN", + "widget": { + "name": "value_3" + }, + "link": null + }, + { + "label": "lightning_lora", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "proxyWidgets": [ + [ + "238", + "text" + ], + [ + "239", + "text" + ], + [ + "242", + "value" + ], + [ + "243", + "value" + ], + [ + "244", + "value" + ], + [ + "225", + "seed" + ], + [ + "237", + "unet_name" + ], + [ + "227", + "clip_name" + ], + [ + "226", + "vae_name" + ], + [ + "235", + "value" + ], + [ + "228", + "lora_name" + ] + ], + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [], + "title": "FLF2V (Wan2.1 VACE)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "b3ce3e73-fd62-47b4-b9a7-586018ca5352", + "version": 1, + "state": { + "lastGroupId": 27, + "lastNodeId": 259, + "lastLinkId": 356, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "FLF2V (Wan2.1 VACE)", + "inputNode": { + "id": -10, + "bounding": [ + -1430, + 620, + 159.744140625, + 308 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 2030, + 671, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "973a5dad-f983-471a-aa2a-ffbc4d34896b", + "name": "image1", + "type": "IMAGE", + "linkIds": [ + 285 + ], + "localized_name": "image1", + "label": "first_frame", + "pos": [ + -1294.255859375, + 644 + ] + }, + { + "id": "fcc6bd21-dbee-48a0-b939-9291056dbf49", + "name": "image2", + "type": "IMAGE", + "linkIds": [ + 286 + ], + "localized_name": "image2", + "label": "last_frame", + "shape": 7, + "pos": [ + -1294.255859375, + 664 + ] + }, + { + "id": "d1982ae5-d03d-4431-bff0-011361c8f1de", + "name": "text", + "type": "STRING", + "linkIds": [ + 341 + ], + "label": "positive_prompt", + "pos": [ + -1294.255859375, + 684 + ] + }, + { + "id": "c7e84b6d-4f49-46f1-981f-2b504d86dc89", + "name": "text_1", + "type": "STRING", + "linkIds": [ + 342 + ], + "label": "negative_prompt", + "pos": [ + -1294.255859375, + 704 + ] + }, + { + "id": "e3e03da8-6405-4ecb-a2ed-540884916b76", + "name": "value", + "type": "INT", + "linkIds": [ + 343 + ], + "label": "duration", + "pos": [ + -1294.255859375, + 724 + ] + }, + { + "id": "61024f79-9856-42ed-a5a5-9e5f5f768f8f", + "name": "value_1", + "type": "INT", + "linkIds": [ + 348 + ], + "label": "width", + "pos": [ + -1294.255859375, + 744 + ] + }, + { + "id": "a399905b-ceea-4c10-8642-b430ab26c8ed", + "name": "value_2", + "type": "INT", + "linkIds": [ + 349 + ], + "label": "height", + "pos": [ + -1294.255859375, + 764 + ] + }, + { + "id": "3fb516ab-0d4f-4deb-b7d5-4594b53dcd07", + "name": "seed", + "type": "INT", + "linkIds": [ + 350 + ], + "pos": [ + -1294.255859375, + 784 + ] + }, + { + "id": "1fb3437c-420a-4e3e-88f6-ba1b747d79c1", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 352 + ], + "pos": [ + -1294.255859375, + 804 + ] + }, + { + "id": "ee8cf76a-9db1-450a-9821-07da5792737d", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 353 + ], + "pos": [ + -1294.255859375, + 824 + ] + }, + { + "id": "05b25134-2d98-4480-9664-2816586a4427", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 354 + ], + "pos": [ + -1294.255859375, + 844 + ] + }, + { + "id": "47013da7-34cf-4da0-87d9-a22e03f3a63b", + "name": "value_3", + "type": "BOOLEAN", + "linkIds": [ + 355 + ], + "label": "enable_turbo_mode", + "pos": [ + -1294.255859375, + 864 + ] + }, + { + "id": "ddf5c416-6823-4718-bf8e-5a4c74a796af", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 356 + ], + "label": "lightning_lora", + "pos": [ + -1294.255859375, + 884 + ] + } + ], + "outputs": [ + { + "id": "085fccaf-8412-45c4-9608-27c5ce7e51b5", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 129 + ], + "localized_name": "VIDEO", + "pos": [ + 2054, + 695 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 222, + "type": "ModelSamplingSD3", + "pos": [ + 880, + 40 + ], + "size": [ + 320, + 110 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 303 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 95 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingSD3", + "cnr_id": "comfy-core", + "ver": "0.3.34" + }, + "widgets_values": [ + 8.000000000000002 + ] + }, + { + "id": 223, + "type": "VAEDecode", + "pos": [ + 1650, + 200 + ], + "size": [ + 320, + 100 + ], + "flags": { + "collapsed": false + }, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 117 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 76 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 139 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.3.34" + } + }, + { + "id": 224, + "type": "WanVaceToVideo", + "pos": [ + 890, + 200 + ], + "size": [ + 320, + 360 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 96 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 97 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 101 + }, + { + "localized_name": "control_video", + "name": "control_video", + "shape": 7, + "type": "IMAGE", + "link": 288 + }, + { + "localized_name": "control_masks", + "name": "control_masks", + "shape": 7, + "type": "MASK", + "link": 289 + }, + { + "localized_name": "reference_image", + "name": "reference_image", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 332 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 335 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 329 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 98 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 99 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 160 + ] + }, + { + "localized_name": "trim_latent", + "name": "trim_latent", + "type": "INT", + "links": [ + 115 + ] + } + ], + "properties": { + "Node name for S&R": "WanVaceToVideo", + "cnr_id": "comfy-core", + "ver": "0.3.34" + }, + "widgets_values": [ + 512, + 512, + 45, + 1, + 1 + ] + }, + { + "id": 225, + "type": "KSampler", + "pos": [ + 1240, + 40 + ], + "size": [ + 320, + 590 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 95 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 98 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 99 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 160 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 350 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 306 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": 309 + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 116 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.3.34" + }, + "widgets_values": [ + 989352111712134, + "randomize", + 20, + 6, + "uni_pc", + "simple", + 1 + ] + }, + { + "id": 226, + "type": "VAELoader", + "pos": [ + -970, + 560 + ], + "size": [ + 350, + 110 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 354 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "slot_index": 0, + "links": [ + 76, + 101 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.3.34", + "models": [ + { + "name": "wan_2.1_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_2.1_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "wan_2.1_vae.safetensors" + ] + }, + { + "id": 227, + "type": "CLIPLoader", + "pos": [ + -970, + 310 + ], + "size": [ + 350, + 170 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 353 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "slot_index": 0, + "links": [ + 294, + 295 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.3.34", + "models": [ + { + "name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors?download=true", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "wan", + "default" + ] + }, + { + "id": 228, + "type": "LoraLoaderModelOnly", + "pos": [ + 30, + 840 + ], + "size": [ + 360, + 140 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 297 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 356 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 302 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.24.0", + "models": [ + { + "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "url": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + 0.8 + ], + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 229, + "type": "PrimitiveFloat", + "pos": [ + 30, + 260 + ], + "size": [ + 360, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 308 + ] + } + ], + "title": "Float (CFG)", + "properties": { + "Node name for S&R": "PrimitiveFloat", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 6 + ] + }, + { + "id": 230, + "type": "PrimitiveInt", + "pos": [ + 30, + 520 + ], + "size": [ + 360, + 110 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 305 + ] + } + ], + "title": "Int (Steps)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 6, + "fixed" + ] + }, + { + "id": 231, + "type": "PrimitiveFloat", + "pos": [ + 30, + 680 + ], + "size": [ + 360, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 307 + ] + } + ], + "title": "Float (CFG)", + "properties": { + "Node name for S&R": "PrimitiveFloat", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 1 + ] + }, + { + "id": 232, + "type": "ComfySwitchNode", + "pos": [ + 500, + 640 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "type": "*", + "link": 308 + }, + { + "localized_name": "on_true", + "name": "on_true", + "type": "*", + "link": 307 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 312 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 309 + ] + } + ], + "title": "Switch (CFG)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + false + ] + }, + { + "id": 233, + "type": "ComfySwitchNode", + "pos": [ + 500, + 410 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "type": "*", + "link": 304 + }, + { + "localized_name": "on_true", + "name": "on_true", + "type": "*", + "link": 305 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 311 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 306 + ] + } + ], + "title": "Switch (Steps)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + false + ] + }, + { + "id": 234, + "type": "ComfySwitchNode", + "pos": [ + 500, + 190 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "type": "*", + "link": 301 + }, + { + "localized_name": "on_true", + "name": "on_true", + "type": "*", + "link": 302 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 310 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 303 + ] + } + ], + "title": "Switch (Model)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + false + ] + }, + { + "id": 235, + "type": "PrimitiveBoolean", + "pos": [ + 30, + 1050 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 355 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 310, + 311, + 312 + ] + } + ], + "title": "Boolean (Enable Lightning LoRA?)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + false + ] + }, + { + "id": 236, + "type": "PrimitiveInt", + "pos": [ + 30, + 90 + ], + "size": [ + 360, + 110 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 304 + ] + } + ], + "title": "Int (Steps)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 20, + "fixed" + ] + }, + { + "id": 237, + "type": "UNETLoader", + "pos": [ + -970, + 60 + ], + "size": [ + 350, + 140 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 352 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "slot_index": 0, + "links": [ + 297, + 301 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.3.34", + "models": [ + { + "name": "wan2.1_vace_14B_fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_vace_14B_fp16.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "wan2.1_vace_14B_fp16.safetensors", + "default" + ] + }, + { + "id": 238, + "type": "CLIPTextEncode", + "pos": [ + -490, + 70 + ], + "size": [ + 400, + 280 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 295 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 341 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 96 + ] + } + ], + "title": "CLIP Text Encode (Positive Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.34" + }, + "widgets_values": [ + "" + ], + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 239, + "type": "CLIPTextEncode", + "pos": [ + -490, + 430 + ], + "size": [ + 400, + 210 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 294 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 342 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 97 + ] + } + ], + "title": "CLIP Text Encode (Negative Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.34" + }, + "widgets_values": [ + "过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走,过曝," + ], + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 240, + "type": "TrimVideoLatent", + "pos": [ + 1650, + 40 + ], + "size": [ + 320, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 116 + }, + { + "localized_name": "trim_amount", + "name": "trim_amount", + "type": "INT", + "widget": { + "name": "trim_amount" + }, + "link": 115 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 117 + ] + } + ], + "properties": { + "Node name for S&R": "TrimVideoLatent", + "cnr_id": "comfy-core", + "ver": "0.3.34" + }, + "widgets_values": [ + 0 + ] + }, + { + "id": 241, + "type": "CreateVideo", + "pos": [ + 1650, + 360 + ], + "size": [ + 320, + 160 + ], + "flags": { + "collapsed": false + }, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 139 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "INT", + "widget": { + "name": "bit_depth" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 129 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.3.34" + }, + "widgets_values": [ + 16, + 8 + ] + }, + { + "id": 242, + "type": "PrimitiveInt", + "pos": [ + -950, + 870 + ], + "size": [ + 310, + 110 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 343 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 328 + ] + } + ], + "title": "Duration", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.3.38" + }, + "widgets_values": [ + 5, + "fixed" + ] + }, + { + "id": 243, + "type": "PrimitiveInt", + "pos": [ + -950, + 1050 + ], + "size": [ + 310, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 348 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 333 + ] + } + ], + "title": "Width", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 512, + "fixed" + ] + }, + { + "id": 244, + "type": "PrimitiveInt", + "pos": [ + -950, + 1230 + ], + "size": [ + 310, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 349 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 336 + ] + } + ], + "title": "Height", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 512, + "fixed" + ] + }, + { + "id": 245, + "type": "ComfyMathExpression", + "pos": [ + -480, + 1030 + ], + "size": [ + 370, + 190 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "values.a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 333 + }, + { + "label": "b", + "localized_name": "values.b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 331, + 332 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + "max(4, (int(a) // 16) * 16)" + ] + }, + { + "id": 246, + "type": "ComfyMathExpression", + "pos": [ + -480, + 1250 + ], + "size": [ + 370, + 190 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "values.a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 336 + }, + { + "label": "b", + "localized_name": "values.b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 334, + 335 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + "max(4, (int(a) // 16) * 16)" + ] + }, + { + "id": 247, + "type": "9c48f327-4169-4a9a-b615-7ae013064ef9", + "pos": [ + 30, + 1230 + ], + "size": [ + 350, + 220 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "image1", + "name": "image1", + "type": "IMAGE", + "link": 285 + }, + { + "localized_name": "image2", + "name": "image2", + "shape": 7, + "type": "IMAGE", + "link": 286 + }, + { + "label": "lenght", + "localized_name": "amount", + "name": "amount", + "type": "INT", + "widget": { + "name": "amount" + }, + "link": 330 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 331 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 334 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 288 + ] + }, + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 289 + ] + } + ], + "properties": { + "proxyWidgets": [ + [ + "258", + "value" + ], + [ + "252", + "width" + ], + [ + "252", + "height" + ] + ], + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [] + }, + { + "id": 248, + "type": "ComfyMathExpression", + "pos": [ + -480, + 800 + ], + "size": [ + 370, + 190 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "values.a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 328 + }, + { + "label": "b", + "localized_name": "values.b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 329, + 330 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + "a * 16 + 1" + ] + } + ], + "groups": [ + { + "id": 1, + "title": "Step1 - Load models here", + "bounding": [ + -990, + -40, + 410, + 770 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Step4 - Prompt", + "bounding": [ + -550, + -40, + 520, + 770 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Sampling", + "bounding": [ + 860, + -40, + 740, + 790 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 23, + "title": "Video Setting", + "bounding": [ + -990, + 760, + 410, + 620 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 27, + "title": "Switch Logic", + "bounding": [ + 0, + -40, + 830, + 1210 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 25, + "title": "Original Settings", + "bounding": [ + 10, + 0, + 420, + 410 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 26, + "title": "Lightning LoRA Settings", + "bounding": [ + 10, + 430, + 420, + 580 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 303, + "origin_id": 234, + "origin_slot": 0, + "target_id": 222, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 117, + "origin_id": 240, + "origin_slot": 0, + "target_id": 223, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 76, + "origin_id": 226, + "origin_slot": 0, + "target_id": 223, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 96, + "origin_id": 238, + "origin_slot": 0, + "target_id": 224, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 97, + "origin_id": 239, + "origin_slot": 0, + "target_id": 224, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 101, + "origin_id": 226, + "origin_slot": 0, + "target_id": 224, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 288, + "origin_id": 247, + "origin_slot": 0, + "target_id": 224, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 289, + "origin_id": 247, + "origin_slot": 1, + "target_id": 224, + "target_slot": 4, + "type": "MASK" + }, + { + "id": 332, + "origin_id": 245, + "origin_slot": 1, + "target_id": 224, + "target_slot": 6, + "type": "INT" + }, + { + "id": 335, + "origin_id": 246, + "origin_slot": 1, + "target_id": 224, + "target_slot": 7, + "type": "INT" + }, + { + "id": 329, + "origin_id": 248, + "origin_slot": 1, + "target_id": 224, + "target_slot": 8, + "type": "INT" + }, + { + "id": 95, + "origin_id": 222, + "origin_slot": 0, + "target_id": 225, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 98, + "origin_id": 224, + "origin_slot": 0, + "target_id": 225, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 99, + "origin_id": 224, + "origin_slot": 1, + "target_id": 225, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 160, + "origin_id": 224, + "origin_slot": 2, + "target_id": 225, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 306, + "origin_id": 233, + "origin_slot": 0, + "target_id": 225, + "target_slot": 5, + "type": "INT" + }, + { + "id": 309, + "origin_id": 232, + "origin_slot": 0, + "target_id": 225, + "target_slot": 6, + "type": "FLOAT" + }, + { + "id": 297, + "origin_id": 237, + "origin_slot": 0, + "target_id": 228, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 308, + "origin_id": 229, + "origin_slot": 0, + "target_id": 232, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 307, + "origin_id": 231, + "origin_slot": 0, + "target_id": 232, + "target_slot": 1, + "type": "FLOAT" + }, + { + "id": 312, + "origin_id": 235, + "origin_slot": 0, + "target_id": 232, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 304, + "origin_id": 236, + "origin_slot": 0, + "target_id": 233, + "target_slot": 0, + "type": "INT" + }, + { + "id": 305, + "origin_id": 230, + "origin_slot": 0, + "target_id": 233, + "target_slot": 1, + "type": "INT" + }, + { + "id": 311, + "origin_id": 235, + "origin_slot": 0, + "target_id": 233, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 301, + "origin_id": 237, + "origin_slot": 0, + "target_id": 234, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 302, + "origin_id": 228, + "origin_slot": 0, + "target_id": 234, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 310, + "origin_id": 235, + "origin_slot": 0, + "target_id": 234, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 295, + "origin_id": 227, + "origin_slot": 0, + "target_id": 238, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 294, + "origin_id": 227, + "origin_slot": 0, + "target_id": 239, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 116, + "origin_id": 225, + "origin_slot": 0, + "target_id": 240, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 115, + "origin_id": 224, + "origin_slot": 3, + "target_id": 240, + "target_slot": 1, + "type": "INT" + }, + { + "id": 139, + "origin_id": 223, + "origin_slot": 0, + "target_id": 241, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 333, + "origin_id": 243, + "origin_slot": 0, + "target_id": 245, + "target_slot": 0, + "type": "INT" + }, + { + "id": 336, + "origin_id": 244, + "origin_slot": 0, + "target_id": 246, + "target_slot": 0, + "type": "INT" + }, + { + "id": 330, + "origin_id": 248, + "origin_slot": 1, + "target_id": 247, + "target_slot": 2, + "type": "INT" + }, + { + "id": 331, + "origin_id": 245, + "origin_slot": 1, + "target_id": 247, + "target_slot": 3, + "type": "INT" + }, + { + "id": 334, + "origin_id": 246, + "origin_slot": 1, + "target_id": 247, + "target_slot": 4, + "type": "INT" + }, + { + "id": 328, + "origin_id": 242, + "origin_slot": 0, + "target_id": 248, + "target_slot": 0, + "type": "INT" + }, + { + "id": 285, + "origin_id": -10, + "origin_slot": 0, + "target_id": 247, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 286, + "origin_id": -10, + "origin_slot": 1, + "target_id": 247, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 129, + "origin_id": 241, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 341, + "origin_id": -10, + "origin_slot": 2, + "target_id": 238, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 342, + "origin_id": -10, + "origin_slot": 3, + "target_id": 239, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 343, + "origin_id": -10, + "origin_slot": 4, + "target_id": 242, + "target_slot": 0, + "type": "INT" + }, + { + "id": 348, + "origin_id": -10, + "origin_slot": 5, + "target_id": 243, + "target_slot": 0, + "type": "INT" + }, + { + "id": 349, + "origin_id": -10, + "origin_slot": 6, + "target_id": 244, + "target_slot": 0, + "type": "INT" + }, + { + "id": 350, + "origin_id": -10, + "origin_slot": 7, + "target_id": 225, + "target_slot": 4, + "type": "INT" + }, + { + "id": 352, + "origin_id": -10, + "origin_slot": 8, + "target_id": 237, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 353, + "origin_id": -10, + "origin_slot": 9, + "target_id": 227, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 354, + "origin_id": -10, + "origin_slot": 10, + "target_id": 226, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 355, + "origin_id": -10, + "origin_slot": 11, + "target_id": 235, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 356, + "origin_id": -10, + "origin_slot": 12, + "target_id": 228, + "target_slot": 1, + "type": "COMBO" + } + ], + "extra": { + "workflowRendererVersion": "LG" + }, + "category": "Video generation/FLF2V", + "description": "Generates a video interpolating between first and last keyframes using Wan 2.1 VACE, with optional Lightning LoRA turbo mode." + }, + { + "id": "9c48f327-4169-4a9a-b615-7ae013064ef9", + "version": 1, + "state": { + "lastGroupId": 27, + "lastNodeId": 259, + "lastLinkId": 356, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Wan VACE FLF2V Image Batch", + "inputNode": { + "id": -10, + "bounding": [ + -1520, + 670, + 128, + 148 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 880, + 750, + 128, + 88 + ] + }, + "inputs": [ + { + "id": "6421e539-134a-464b-9395-332637387bd7", + "name": "image1", + "type": "IMAGE", + "linkIds": [ + 313 + ], + "localized_name": "image1", + "pos": [ + -1416, + 694 + ] + }, + { + "id": "37d82794-80af-4c15-812a-44a2631ba194", + "name": "image2", + "type": "IMAGE", + "linkIds": [ + 315 + ], + "localized_name": "image2", + "pos": [ + -1416, + 714 + ] + }, + { + "id": "de504268-7d0b-459c-9959-f7d1f468037e", + "name": "amount", + "type": "INT", + "linkIds": [ + 327 + ], + "localized_name": "amount", + "label": "lenght", + "pos": [ + -1416, + 734 + ] + }, + { + "id": "1361d24b-12bb-44a7-8791-5c335a7c7f7f", + "name": "width", + "type": "INT", + "linkIds": [ + 275, + 276 + ], + "localized_name": "width", + "pos": [ + -1416, + 754 + ] + }, + { + "id": "da3a1132-2ee7-47ed-95ac-5847fde41ecb", + "name": "height", + "type": "INT", + "linkIds": [ + 278, + 279 + ], + "localized_name": "height", + "pos": [ + -1416, + 774 + ] + } + ], + "outputs": [ + { + "id": "4bbef645-0d10-4b98-8edc-cad7f340d6fd", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 317 + ], + "localized_name": "IMAGE", + "pos": [ + 904, + 774 + ] + }, + { + "id": "e83e3eaf-5177-4ce4-9b96-0be7219fa0fa", + "name": "MASK", + "type": "MASK", + "linkIds": [ + 234 + ], + "localized_name": "MASK", + "pos": [ + 904, + 794 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 249, + "type": "MaskToImage", + "pos": [ + -540, + 580 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "link": 221 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 222 + ] + } + ], + "properties": { + "Node name for S&R": "MaskToImage", + "cnr_id": "comfy-core", + "ver": "0.3.38" + } + }, + { + "id": 250, + "type": "RepeatImageBatch", + "pos": [ + -540, + 410 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 222 + }, + { + "localized_name": "amount", + "name": "amount", + "type": "INT", + "widget": { + "name": "amount" + }, + "link": 323 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 314, + 319 + ] + } + ], + "properties": { + "Node name for S&R": "RepeatImageBatch", + "cnr_id": "comfy-core", + "ver": "0.3.38" + }, + "widgets_values": [ + 30 + ] + }, + { + "id": 251, + "type": "ImageToMask", + "pos": [ + 260, + 1110 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 321 + }, + { + "localized_name": "channel", + "name": "channel", + "type": "COMBO", + "widget": { + "name": "channel" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 234 + ] + } + ], + "properties": { + "Node name for S&R": "ImageToMask", + "cnr_id": "comfy-core", + "ver": "0.3.38" + }, + "widgets_values": [ + "red" + ] + }, + { + "id": 252, + "type": "SolidMask", + "pos": [ + -550, + 710 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 275 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 278 + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 221 + ] + } + ], + "properties": { + "Node name for S&R": "SolidMask", + "cnr_id": "comfy-core", + "ver": "0.3.38" + }, + "widgets_values": [ + 1, + 512, + 512 + ] + }, + { + "id": 253, + "type": "MaskToImage", + "pos": [ + -530, + 1190 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "mask", + "name": "mask", + "type": "MASK", + "link": 229 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 318, + 320 + ] + } + ], + "properties": { + "Node name for S&R": "MaskToImage", + "cnr_id": "comfy-core", + "ver": "0.3.38" + } + }, + { + "id": 254, + "type": "SolidMask", + "pos": [ + -540, + 950 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 276 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 279 + } + ], + "outputs": [ + { + "localized_name": "MASK", + "name": "MASK", + "type": "MASK", + "links": [ + 229 + ] + } + ], + "properties": { + "Node name for S&R": "SolidMask", + "cnr_id": "comfy-core", + "ver": "0.3.38" + }, + "widgets_values": [ + 0, + 512, + 512 + ] + }, + { + "id": 255, + "type": "BatchImagesNode", + "pos": [ + -70, + 600 + ], + "size": [ + 230, + 150 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "label": "image0", + "localized_name": "images.image0", + "name": "images.image0", + "type": "IMAGE", + "link": 313 + }, + { + "label": "image1", + "localized_name": "images.image1", + "name": "images.image1", + "shape": 7, + "type": "IMAGE", + "link": 314 + }, + { + "label": "image2", + "localized_name": "images.image2", + "name": "images.image2", + "shape": 7, + "type": "IMAGE", + "link": 315 + }, + { + "label": "image3", + "localized_name": "images.image3", + "name": "images.image3", + "shape": 7, + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 317 + ] + } + ], + "properties": { + "Node name for S&R": "BatchImagesNode", + "cnr_id": "comfy-core", + "ver": "0.27.0" + } + }, + { + "id": 256, + "type": "BatchImagesNode", + "pos": [ + -50, + 1090 + ], + "size": [ + 230, + 150 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "label": "image0", + "localized_name": "images.image0", + "name": "images.image0", + "type": "IMAGE", + "link": 318 + }, + { + "label": "image1", + "localized_name": "images.image1", + "name": "images.image1", + "shape": 7, + "type": "IMAGE", + "link": 319 + }, + { + "label": "image2", + "localized_name": "images.image2", + "name": "images.image2", + "shape": 7, + "type": "IMAGE", + "link": 320 + }, + { + "label": "image3", + "localized_name": "images.image3", + "name": "images.image3", + "shape": 7, + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 321 + ] + } + ], + "properties": { + "Node name for S&R": "BatchImagesNode", + "cnr_id": "comfy-core", + "ver": "0.27.0" + } + }, + { + "id": 257, + "type": "ComfyMathExpression", + "pos": [ + -980, + 410 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "values.a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 326 + }, + { + "label": "b", + "localized_name": "values.b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 323 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + "a - 2" + ] + }, + { + "id": 258, + "type": "PrimitiveInt", + "pos": [ + -1290, + 420 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 327 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 326 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 0, + "fixed" + ] + } + ], + "groups": [], + "links": [ + { + "id": 221, + "origin_id": 252, + "origin_slot": 0, + "target_id": 249, + "target_slot": 0, + "type": "MASK" + }, + { + "id": 222, + "origin_id": 249, + "origin_slot": 0, + "target_id": 250, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 229, + "origin_id": 254, + "origin_slot": 0, + "target_id": 253, + "target_slot": 0, + "type": "MASK" + }, + { + "id": 275, + "origin_id": -10, + "origin_slot": 3, + "target_id": 252, + "target_slot": 1, + "type": "INT" + }, + { + "id": 276, + "origin_id": -10, + "origin_slot": 3, + "target_id": 254, + "target_slot": 1, + "type": "INT" + }, + { + "id": 278, + "origin_id": -10, + "origin_slot": 4, + "target_id": 252, + "target_slot": 2, + "type": "INT" + }, + { + "id": 279, + "origin_id": -10, + "origin_slot": 4, + "target_id": 254, + "target_slot": 2, + "type": "INT" + }, + { + "id": 234, + "origin_id": 251, + "origin_slot": 0, + "target_id": -20, + "target_slot": 1, + "type": "MASK" + }, + { + "id": 313, + "origin_id": -10, + "origin_slot": 0, + "target_id": 255, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 314, + "origin_id": 250, + "origin_slot": 0, + "target_id": 255, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 315, + "origin_id": -10, + "origin_slot": 1, + "target_id": 255, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 317, + "origin_id": 255, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 318, + "origin_id": 253, + "origin_slot": 0, + "target_id": 256, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 319, + "origin_id": 250, + "origin_slot": 0, + "target_id": 256, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 320, + "origin_id": 253, + "origin_slot": 0, + "target_id": 256, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 321, + "origin_id": 256, + "origin_slot": 0, + "target_id": 251, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 323, + "origin_id": 257, + "origin_slot": 1, + "target_id": 250, + "target_slot": 1, + "type": "INT" + }, + { + "id": 326, + "origin_id": 258, + "origin_slot": 0, + "target_id": 257, + "target_slot": 0, + "type": "INT" + }, + { + "id": 327, + "origin_id": -10, + "origin_slot": 2, + "target_id": 258, + "target_slot": 0, + "type": "INT" + } + ], + "extra": {}, + "description": "Builds Wan VACE control video and mask batches from first/last frame images for FLF2V generation." + } + ] + }, + "extra": {} +} diff --git a/blueprints/First & Last Frame to Video (LTX-2.5).json b/blueprints/First & Last Frame to Video (LTX-2.5).json new file mode 100644 index 00000000000..3c69ac2f9d0 --- /dev/null +++ b/blueprints/First & Last Frame to Video (LTX-2.5).json @@ -0,0 +1,4191 @@ +{ + "revision": 0, + "last_node_id": "298", + "last_link_id": 0, + "nodes": [ + { + "id": 298, + "type": "fd50a22f-a8b5-4ad5-a09d-65597cf21d24", + "pos": [ + 2780, + 2930 + ], + "size": [ + 520, + 710 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "label": "first_frame", + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "last_frame", + "localized_name": "input_1", + "name": "input_1", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "prompt", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "prompt_enhance", + "name": "value_1", + "type": "BOOLEAN", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "duration", + "name": "value_2", + "type": "INT", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "label": "width", + "name": "value_3", + "type": "INT", + "widget": { + "name": "value_3" + }, + "link": null + }, + { + "label": "height", + "name": "value_4", + "type": "INT", + "widget": { + "name": "value_4" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "label": "fram_rate", + "name": "value_5", + "type": "INT", + "widget": { + "name": "value_5" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "label": "video_vae", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "audio_vae", + "name": "vae_name_1", + "type": "COMBO", + "widget": { + "name": "vae_name_1" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "label": "prompt_enhance_model", + "name": "clip_name_1", + "type": "COMBO", + "widget": { + "name": "clip_name_1" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.30.0", + "previewExposures": [] + }, + "widgets_values": [ + "", + false, + 5, + 1280, + 720, + 873293130933086, + 24, + "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "ltx-2.5-video-vae-bf16.safetensors", + "ltx-2.5-audio-vae-bf16.safetensors", + "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "gemma4_e2b_it_int8_convrot.safetensors" + ], + "widgets_values_named": { + "value": "", + "value_1": false, + "value_2": 5, + "value_3": 1280, + "value_4": 720, + "noise_seed": 873293130933086, + "value_5": 24, + "unet_name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "vae_name": "ltx-2.5-video-vae-bf16.safetensors", + "vae_name_1": "ltx-2.5-audio-vae-bf16.safetensors", + "clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "clip_name_1": "gemma4_e2b_it_int8_convrot.safetensors" + }, + "title": "First & Last Frame to Video (LTX-2.5)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "fd50a22f-a8b5-4ad5-a09d-65597cf21d24", + "version": 1, + "state": { + "lastGroupId": 9, + "lastNodeId": 298, + "lastLinkId": 386, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "First & Last Frame to Video (LTX-2.5)", + "inputNode": { + "id": -10, + "bounding": [ + -280, + 2210, + 183.814453125, + 328 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 3540, + 2261, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "9ef90062-29a7-4a94-95c7-1fced49f3705", + "name": "input", + "type": "IMAGE,MASK", + "linkIds": [ + 325 + ], + "localized_name": "input", + "label": "first_frame", + "pos": [ + -120.185546875, + 2234 + ] + }, + { + "id": "394b4186-1545-4d8e-a81e-2fdd06c7d86a", + "name": "input_1", + "type": "IMAGE,MASK", + "linkIds": [ + 326 + ], + "localized_name": "input_1", + "label": "last_frame", + "pos": [ + -120.185546875, + 2254 + ] + }, + { + "id": "06e99847-4ff4-4b47-89af-703de88f32c6", + "name": "value", + "type": "STRING", + "linkIds": [ + 373 + ], + "label": "prompt", + "pos": [ + -120.185546875, + 2274 + ] + }, + { + "id": "55f3e156-36b0-4036-8ea3-2f8bc67b757d", + "name": "value_1", + "type": "BOOLEAN", + "linkIds": [ + 374 + ], + "label": "prompt_enhance", + "pos": [ + -120.185546875, + 2294 + ] + }, + { + "id": "5021e076-8ff3-40f9-93ff-c1fe3ba4b92f", + "name": "value_2", + "type": "INT", + "linkIds": [ + 375 + ], + "label": "duration", + "pos": [ + -120.185546875, + 2314 + ] + }, + { + "id": "eaa9ccdf-b90b-46a1-bead-31bce346a4d0", + "name": "value_3", + "type": "INT", + "linkIds": [ + 376 + ], + "label": "width", + "pos": [ + -120.185546875, + 2334 + ] + }, + { + "id": "78f6eb0b-2fd5-473f-b0af-49af72da5cf6", + "name": "value_4", + "type": "INT", + "linkIds": [ + 377 + ], + "label": "height", + "pos": [ + -120.185546875, + 2354 + ] + }, + { + "id": "53d2c19a-af27-42b4-bdbc-0dd31a0e6e0f", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 378 + ], + "pos": [ + -120.185546875, + 2374 + ] + }, + { + "id": "1e17793b-b8dc-4074-a102-1d9a71b10901", + "name": "value_5", + "type": "INT", + "linkIds": [ + 379 + ], + "label": "fram_rate", + "pos": [ + -120.185546875, + 2394 + ] + }, + { + "id": "ebf37f18-4918-4d80-be62-fb0833811240", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 380 + ], + "pos": [ + -120.185546875, + 2414 + ] + }, + { + "id": "4be57e90-f5f9-4c55-9331-8d0ee601d6a9", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 381 + ], + "label": "video_vae", + "pos": [ + -120.185546875, + 2434 + ] + }, + { + "id": "31352b8e-0bb3-48b6-8679-647aeb164243", + "name": "vae_name_1", + "type": "COMBO", + "linkIds": [ + 382 + ], + "label": "audio_vae", + "pos": [ + -120.185546875, + 2454 + ] + }, + { + "id": "640fb6cd-0ce3-483a-a603-678d332edbad", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 383 + ], + "pos": [ + -120.185546875, + 2474 + ] + }, + { + "id": "97823058-8d3f-44a4-9510-a059719e5594", + "name": "clip_name_1", + "type": "COMBO", + "linkIds": [ + 384 + ], + "label": "prompt_enhance_model", + "pos": [ + -120.185546875, + 2494 + ] + } + ], + "outputs": [ + { + "id": "a629464e-802b-4fe5-9b08-1e7e73d96a62", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 327 + ], + "localized_name": "VIDEO", + "pos": [ + 3564, + 2285 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 259, + "type": "LTXVConcatAVLatent", + "pos": [ + 2260, + 2610 + ], + "size": [ + 280, + 100 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "link": 303 + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "link": 304 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 309 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConcatAVLatent", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 260, + "type": "VAEDecodeTiled", + "pos": [ + 2740, + 2150 + ], + "size": [ + 280, + 190 + ], + "flags": {}, + "order": 3, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 319 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 336 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + }, + { + "localized_name": "temporal_size", + "name": "temporal_size", + "type": "INT", + "widget": { + "name": "temporal_size" + }, + "link": null + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 316 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeTiled", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 512, + 64, + 64, + 16 + ], + "widgets_values_named": { + "tile_size": 512, + "overlap": 64, + "temporal_size": 64, + "temporal_overlap": 16 + } + }, + { + "id": 261, + "type": "LTXVAudioVAEDecode", + "pos": [ + 2740, + 2440 + ], + "size": [ + 280, + 100 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 321 + }, + { + "label": "Audio VAE", + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 333 + } + ], + "outputs": [ + { + "localized_name": "Audio", + "name": "Audio", + "type": "AUDIO", + "links": [ + 317 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAudioVAEDecode", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 262, + "type": "LTXVSeparateAVLatent", + "pos": [ + 2380, + 2100 + ], + "size": [ + 250, + 100 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "av_latent", + "name": "av_latent", + "type": "LATENT", + "link": 323 + } + ], + "outputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "links": [ + 283 + ] + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "links": [ + 321 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVSeparateAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 263, + "type": "LTXVAddGuide", + "pos": [ + 2760, + 2930 + ], + "size": [ + 280, + 240 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 295 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 296 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 335 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 298 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 299 + }, + { + "localized_name": "attention_mask", + "name": "attention_mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "localized_name": "iclora_parameters", + "name": "iclora_parameters", + "shape": 7, + "type": "IC_LORA_PARAMETERS", + "link": null + }, + { + "localized_name": "frame_idx", + "name": "frame_idx", + "type": "INT", + "widget": { + "name": "frame_idx" + }, + "link": null + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 290 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 291 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 293 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAddGuide", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 0, + 0.7 + ], + "widgets_values_named": { + "frame_idx": 0, + "strength": 0.7 + } + }, + { + "id": 264, + "type": "ManualSigmas", + "pos": [ + 1900, + 2460 + ], + "size": [ + 500, + 100 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 353 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas", + "cnr_id": "comfy-core", + "ver": "0.14.1" + }, + "widgets_values": [ + "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0" + ], + "widgets_values_named": { + "sigmas": "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0" + } + }, + { + "id": 265, + "type": "LTXVAddGuide", + "pos": [ + 2760, + 3250 + ], + "size": [ + 280, + 240 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 290 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 291 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 334 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 293 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 294 + }, + { + "localized_name": "attention_mask", + "name": "attention_mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "localized_name": "iclora_parameters", + "name": "iclora_parameters", + "shape": 7, + "type": "IC_LORA_PARAMETERS", + "link": null + }, + { + "localized_name": "frame_idx", + "name": "frame_idx", + "type": "INT", + "widget": { + "name": "frame_idx" + }, + "link": null + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 281, + 346 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 282, + 347 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 303 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAddGuide", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + -1, + 0.7 + ], + "widgets_values_named": { + "frame_idx": -1, + "strength": 0.7 + } + }, + { + "id": 266, + "type": "LTXVEmptyLatentAudio", + "pos": [ + 2380, + 3350 + ], + "size": [ + 280, + 170 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 332 + }, + { + "localized_name": "frames_number", + "name": "frames_number", + "type": "INT", + "widget": { + "name": "frames_number" + }, + "link": 330 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT,INT", + "widget": { + "name": "frame_rate" + }, + "link": 279 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "Latent", + "name": "Latent", + "type": "LATENT", + "links": [ + 304 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVEmptyLatentAudio", + "cnr_id": "comfy-core", + "ver": "0.3.68", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 97, + 25, + 1 + ], + "widgets_values_named": { + "frames_number": 97, + "frame_rate": 25, + "batch_size": 1 + } + }, + { + "id": 267, + "type": "LTXVCropGuides", + "pos": [ + 2750, + 1950 + ], + "size": [ + 280, + 120 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 281 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 282 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 283 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 319 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVCropGuides", + "cnr_id": "comfy-core", + "ver": "0.8.2", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.5.2" + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 268, + "type": "EmptyLTXVLatentVideo", + "pos": [ + 2380, + 3110 + ], + "size": [ + 280, + 200 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 284 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 285 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 331 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 298 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLTXVLatentVideo", + "cnr_id": "comfy-core", + "ver": "0.3.60", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 768, + 512, + 97, + 1 + ], + "widgets_values_named": { + "width": 768, + "height": 512, + "length": 97, + "batch_size": 1 + } + }, + { + "id": 269, + "type": "SamplerCustomAdvanced", + "pos": [ + 2380, + 1850 + ], + "size": [ + 230, + 170 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 305 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 348 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 307 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 353 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 309 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [ + 323 + ] + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 270, + "type": "LTXVConditioning", + "pos": [ + 2380, + 2930 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 358 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 287 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT", + "widget": { + "name": "frame_rate" + }, + "link": 288 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 295 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 296 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConditioning", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 25 + ], + "widgets_values_named": { + "frame_rate": 25 + } + }, + { + "id": 271, + "type": "SamplerEulerAncestral", + "pos": [ + 1910, + 2240 + ], + "size": [ + 280, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "eta", + "name": "eta", + "type": "FLOAT", + "widget": { + "name": "eta" + }, + "link": null + }, + { + "localized_name": "s_noise", + "name": "s_noise", + "type": "FLOAT", + "widget": { + "name": "s_noise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 307 + ] + } + ], + "properties": { + "Node name for S&R": "SamplerEulerAncestral", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 0, + 1 + ], + "widgets_values_named": { + "eta": 0, + "s_noise": 1 + } + }, + { + "id": 272, + "type": "CreateVideo", + "pos": [ + 3200, + 1840 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 316 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 317 + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 318 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 327 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 24, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 24, + "bit_depth": 8, + "color_space": "sRGB" + } + }, + { + "id": 273, + "type": "LTXVDualCFGGuider", + "pos": [ + 1910, + 2000 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 345 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 346 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 347 + }, + { + "localized_name": "video_cfg", + "name": "video_cfg", + "type": "FLOAT", + "widget": { + "name": "video_cfg" + }, + "link": null + }, + { + "localized_name": "audio_cfg", + "name": "audio_cfg", + "type": "FLOAT", + "widget": { + "name": "audio_cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 348 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVDualCFGGuider", + "cnr_id": "comfy-core", + "ver": "0.28.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1, + 1 + ], + "widgets_values_named": { + "video_cfg": 1, + "audio_cfg": 1 + } + }, + { + "id": 274, + "type": "RandomNoise", + "pos": [ + 1910, + 1840 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 378 + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 305 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "ue_properties": { + "widget_ue_connectable": { + "noise_seed": true + }, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 358144640282042, + "randomize" + ], + "widgets_values_named": { + "noise_seed": 358144640282042, + "control_after_generate": "randomize" + } + }, + { + "id": 275, + "type": "CLIPTextEncode", + "pos": [ + 1220, + 2450 + ], + "size": [ + 590, + 200 + ], + "flags": { + "collapsed": false + }, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 337 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 287 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, wrong hand count, artifacts around text, unreadable text on shirt or hat, incorrect lettering on cap (“PNTR”), incorrect t-shirt slogan (“JUST DO IT”), missing microphone, misplaced microphone, inconsistent perspective, camera shake, incorrect depth of field, background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, incorrect ethnicity, wrong gender, exaggerated expressions, smiling, laughing, exaggerated sadness, wrong gaze direction, eyes looking at camera, mismatched lip sync, silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, missing sniff sounds, incorrect dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural transitions, inconsistent framing, tilted camera, missing door or shelves, missing shallow depth of field, flat lighting, inconsistent tone, cinematic oversaturation, stylized filters, or AI artifacts." + ], + "widgets_values_named": { + "text": "blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, wrong hand count, artifacts around text, unreadable text on shirt or hat, incorrect lettering on cap (“PNTR”), incorrect t-shirt slogan (“JUST DO IT”), missing microphone, misplaced microphone, inconsistent perspective, camera shake, incorrect depth of field, background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, incorrect ethnicity, wrong gender, exaggerated expressions, smiling, laughing, exaggerated sadness, wrong gaze direction, eyes looking at camera, mismatched lip sync, silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, missing sniff sounds, incorrect dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural transitions, inconsistent framing, tilted camera, missing door or shelves, missing shallow depth of field, flat lighting, inconsistent tone, cinematic oversaturation, stylized filters, or AI artifacts." + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 276, + "type": "CLIPTextEncode", + "pos": [ + 1200, + 1880 + ], + "size": [ + 620, + 420 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 338 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 364 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 358 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 277, + "type": "ComfySwitchNode", + "pos": [ + 1650, + 1510 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 372 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 361 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 362 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 363, + 364 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 278, + "type": "GetImageSize", + "pos": [ + 1910, + 3020 + ], + "size": [ + 230, + 130 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 289 + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 284 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 285 + ] + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "GetImageSize", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 279, + "type": "LTXVPreprocess", + "pos": [ + 1910, + 3300 + ], + "size": [ + 230, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 277 + }, + { + "localized_name": "img_compression", + "name": "img_compression", + "type": "INT", + "widget": { + "name": "img_compression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output_image", + "name": "output_image", + "type": "IMAGE", + "links": [ + 294 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVPreprocess", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 18 + ], + "widgets_values_named": { + "img_compression": 18 + } + }, + { + "id": 280, + "type": "LTXVPreprocess", + "pos": [ + 1910, + 2860 + ], + "size": [ + 230, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 280 + }, + { + "localized_name": "img_compression", + "name": "img_compression", + "type": "INT", + "widget": { + "name": "img_compression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output_image", + "name": "output_image", + "type": "IMAGE", + "links": [ + 299, + 366 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVPreprocess", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 18 + ], + "widgets_values_named": { + "img_compression": 18 + } + }, + { + "id": 281, + "type": "TextGenerateLTX2Prompt", + "pos": [ + 1120, + 1100 + ], + "size": [ + 400, + 420 + ], + "flags": { + "collapsed": false + }, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 360 + }, + { + "localized_name": "image", + "name": "image", + "shape": 7, + "type": "IMAGE", + "link": 366 + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 371 + }, + { + "localized_name": "max_length", + "name": "max_length", + "type": "INT", + "widget": { + "name": "max_length" + }, + "link": null + }, + { + "localized_name": "Sampling Mode", + "name": "sampling_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "sampling_mode" + }, + "link": null + }, + { + "localized_name": "temperature", + "name": "sampling_mode.temperature", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.temperature" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "sampling_mode.top_k", + "type": "INT", + "widget": { + "name": "sampling_mode.top_k" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "sampling_mode.top_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.top_p" + }, + "link": null + }, + { + "localized_name": "min_p", + "name": "sampling_mode.min_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.min_p" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "sampling_mode.repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.repetition_penalty" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "sampling_mode.seed", + "type": "INT", + "widget": { + "name": "sampling_mode.seed" + }, + "link": null + }, + { + "localized_name": "presence_penalty", + "name": "sampling_mode.presence_penalty", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "sampling_mode.presence_penalty" + }, + "link": null + }, + { + "localized_name": "thinking", + "name": "thinking", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "thinking" + }, + "link": null + }, + { + "localized_name": "use_default_template", + "name": "use_default_template", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "use_default_template" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "generated_text", + "name": "generated_text", + "type": "STRING", + "links": [ + 361 + ] + } + ], + "properties": { + "Node name for S&R": "TextGenerateLTX2Prompt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "", + 600, + "on", + 0.7, + 64, + 0.95, + 0.05, + 1.15, + 0, + 0, + false, + true + ], + "widgets_values_named": { + "prompt": "", + "max_length": 600, + "sampling_mode": "on", + "sampling_mode.temperature": 0.7, + "sampling_mode.top_k": 64, + "sampling_mode.top_p": 0.95, + "sampling_mode.min_p": 0.05, + "sampling_mode.repetition_penalty": 1.15, + "sampling_mode.seed": 0, + "sampling_mode.presence_penalty": 0, + "thinking": false, + "use_default_template": true + } + }, + { + "id": 282, + "type": "ResizeImageMaskNode", + "pos": [ + 1560, + 2860 + ], + "size": [ + 280, + 160 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 325 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 311 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 312 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 280, + 289 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "ue_properties": { + "widget_ue_connectable": { + "resize_type.width": true, + "resize_type.height": true + }, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "scale dimensions", + 640, + 360, + "center", + "nearest-exact" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 640, + "resize_type.height": 360, + "resize_type.crop": "center", + "scale_method": "nearest-exact" + } + }, + { + "id": 283, + "type": "ResizeImageMaskNode", + "pos": [ + 1560, + 3300 + ], + "size": [ + 280, + 160 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 326 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 313 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 314 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 277 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "ue_properties": { + "widget_ue_connectable": { + "resize_type.width": true, + "resize_type.height": true + }, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "scale dimensions", + 640, + 360, + "center", + "nearest-exact" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 640, + "resize_type.height": 360, + "resize_type.crop": "center", + "scale_method": "nearest-exact" + } + }, + { + "id": 284, + "type": "PreviewAny", + "pos": [ + 1960, + 1120 + ], + "size": [ + 510, + 360 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 363 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 285, + "type": "UNETLoader", + "pos": [ + 690, + 1970 + ], + "size": [ + 400, + 90 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 380 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 345 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 286, + "type": "VAELoader", + "pos": [ + 690, + 2130 + ], + "size": [ + 410, + 70 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 381 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 334, + 335, + 336 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-video-vae-bf16.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-video-vae-bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ltx-2.5-video-vae-bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "ltx-2.5-video-vae-bf16.safetensors" + } + }, + { + "id": 287, + "type": "VAELoader", + "pos": [ + 690, + 2280 + ], + "size": [ + 400, + 90 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 382 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 332, + 333 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-audio-vae-bf16.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-audio-vae-bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ltx-2.5-audio-vae-bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "ltx-2.5-audio-vae-bf16.safetensors" + } + }, + { + "id": 288, + "type": "CLIPLoader", + "pos": [ + 690, + 2450 + ], + "size": [ + 370, + 120 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 383 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 337, + 338 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/text_encoders/gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "ltxv", + "default" + ], + "widgets_values_named": { + "clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "type": "ltxv", + "device": "default" + } + }, + { + "id": 289, + "type": "CLIPLoader", + "pos": [ + 670, + 1110 + ], + "size": [ + 370, + 120 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 384 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 360 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "gemma4_e2b_it_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/gemma-4/resolve/main/text_encoders/gemma4_e2b_it_int8_convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "gemma4_e2b_it_int8_convrot.safetensors", + "ltxv", + "default" + ], + "widgets_values_named": { + "clip_name": "gemma4_e2b_it_int8_convrot.safetensors", + "type": "ltxv", + "device": "default" + } + }, + { + "id": 290, + "type": "PrimitiveBoolean", + "pos": [ + 690, + 1580 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 374 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 362 + ] + } + ], + "title": "Boolean (Enable Prompt Enhance)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + }, + { + "id": 291, + "type": "PrimitiveInt", + "pos": [ + 1180, + 2860 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 32, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 376 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 311, + 313 + ] + } + ], + "title": "Width", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1280, + "fixed" + ], + "widgets_values_named": { + "value": 1280, + "fixed": "fixed" + } + }, + { + "id": 292, + "type": "PrimitiveInt", + "pos": [ + 1180, + 3010 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 33, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 377 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 312, + 314 + ] + } + ], + "title": "height", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 720, + "fixed" + ], + "widgets_values_named": { + "value": 720, + "fixed": "fixed" + } + }, + { + "id": 293, + "type": "ComfyMathExpression", + "pos": [ + 1180, + 3490 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 34, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 310 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 288, + 318 + ] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.17.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "a" + ], + "widgets_values_named": { + "expression": "a" + } + }, + { + "id": 294, + "type": "PrimitiveInt", + "pos": [ + 1180, + 3160 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 35, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 375 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 328 + ] + } + ], + "title": "Duration", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 5, + "fixed" + ], + "widgets_values_named": { + "value": 5, + "fixed": "fixed" + } + }, + { + "id": 295, + "type": "ComfyMathExpression", + "pos": [ + 1180, + 3550 + ], + "size": [ + 230, + 100 + ], + "flags": { + "collapsed": true + }, + "order": 36, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 328 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": 329 + }, + { + "localized_name": "c", + "name": "values.c", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 330, + 331 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.18.1", + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.7" + } + }, + "widgets_values": [ + "a * b + 1" + ], + "widgets_values_named": { + "expression": "a * b + 1" + } + }, + { + "id": 296, + "type": "PrimitiveInt", + "pos": [ + 1180, + 3320 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 37, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 379 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 279, + 310, + 329 + ] + } + ], + "title": "Frame Rate(int)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 24, + "fixed" + ], + "widgets_values_named": { + "value": 24, + "fixed": "fixed" + } + }, + { + "id": 297, + "type": "PrimitiveStringMultiline", + "pos": [ + 690, + 2870 + ], + "size": [ + 430, + 630 + ], + "flags": {}, + "order": 38, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 373 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 371, + 372 + ] + } + ], + "title": "Prompt", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.30.0" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + } + } + ], + "groups": [ + { + "id": 7, + "title": "Sampling", + "bounding": [ + 1890, + 1760, + 770, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Decoding", + "bounding": [ + 2690, + 1760, + 450, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 1, + "title": "Conditioning", + "bounding": [ + 2280, + 2770, + 860, + 780 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Prompt", + "bounding": [ + 1180, + 1760, + 680, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "FIrst Frame", + "bounding": [ + 1480, + 2770, + 770, + 400 + ], + "color": "#b06634", + "flags": {} + }, + { + "id": 4, + "title": "Last Frame", + "bounding": [ + 1480, + 3200, + 770, + 370 + ], + "color": "#b06634", + "flags": {} + }, + { + "id": 2, + "title": "Video Settings", + "bounding": [ + 650, + 2770, + 800, + 800 + ], + "color": "#8A8", + "flags": {} + }, + { + "id": 5, + "title": "Model", + "bounding": [ + 650, + 1760, + 500, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 9, + "title": "Prompt Enhancement", + "bounding": [ + 650, + 1020, + 2010, + 720 + ], + "flags": {} + } + ], + "links": [ + { + "id": 303, + "origin_id": 265, + "origin_slot": 2, + "target_id": 259, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 304, + "origin_id": 266, + "origin_slot": 0, + "target_id": 259, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 319, + "origin_id": 267, + "origin_slot": 2, + "target_id": 260, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 336, + "origin_id": 286, + "origin_slot": 0, + "target_id": 260, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 321, + "origin_id": 262, + "origin_slot": 1, + "target_id": 261, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 333, + "origin_id": 287, + "origin_slot": 0, + "target_id": 261, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 323, + "origin_id": 269, + "origin_slot": 1, + "target_id": 262, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 295, + "origin_id": 270, + "origin_slot": 0, + "target_id": 263, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 296, + "origin_id": 270, + "origin_slot": 1, + "target_id": 263, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 335, + "origin_id": 286, + "origin_slot": 0, + "target_id": 263, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 298, + "origin_id": 268, + "origin_slot": 0, + "target_id": 263, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 299, + "origin_id": 280, + "origin_slot": 0, + "target_id": 263, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 290, + "origin_id": 263, + "origin_slot": 0, + "target_id": 265, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 291, + "origin_id": 263, + "origin_slot": 1, + "target_id": 265, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 334, + "origin_id": 286, + "origin_slot": 0, + "target_id": 265, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 293, + "origin_id": 263, + "origin_slot": 2, + "target_id": 265, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 294, + "origin_id": 279, + "origin_slot": 0, + "target_id": 265, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 332, + "origin_id": 287, + "origin_slot": 0, + "target_id": 266, + "target_slot": 0, + "type": "VAE" + }, + { + "id": 330, + "origin_id": 295, + "origin_slot": 1, + "target_id": 266, + "target_slot": 1, + "type": "INT" + }, + { + "id": 279, + "origin_id": 296, + "origin_slot": 0, + "target_id": 266, + "target_slot": 2, + "type": "INT" + }, + { + "id": 281, + "origin_id": 265, + "origin_slot": 0, + "target_id": 267, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 282, + "origin_id": 265, + "origin_slot": 1, + "target_id": 267, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 283, + "origin_id": 262, + "origin_slot": 0, + "target_id": 267, + "target_slot": 2, + "type": "LATENT" + }, + { + "id": 284, + "origin_id": 278, + "origin_slot": 0, + "target_id": 268, + "target_slot": 0, + "type": "INT" + }, + { + "id": 285, + "origin_id": 278, + "origin_slot": 1, + "target_id": 268, + "target_slot": 1, + "type": "INT" + }, + { + "id": 331, + "origin_id": 295, + "origin_slot": 1, + "target_id": 268, + "target_slot": 2, + "type": "INT" + }, + { + "id": 305, + "origin_id": 274, + "origin_slot": 0, + "target_id": 269, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 348, + "origin_id": 273, + "origin_slot": 0, + "target_id": 269, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 307, + "origin_id": 271, + "origin_slot": 0, + "target_id": 269, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 353, + "origin_id": 264, + "origin_slot": 0, + "target_id": 269, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 309, + "origin_id": 259, + "origin_slot": 0, + "target_id": 269, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 358, + "origin_id": 276, + "origin_slot": 0, + "target_id": 270, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 287, + "origin_id": 275, + "origin_slot": 0, + "target_id": 270, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 288, + "origin_id": 293, + "origin_slot": 0, + "target_id": 270, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 316, + "origin_id": 260, + "origin_slot": 0, + "target_id": 272, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 317, + "origin_id": 261, + "origin_slot": 0, + "target_id": 272, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 318, + "origin_id": 293, + "origin_slot": 0, + "target_id": 272, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 345, + "origin_id": 285, + "origin_slot": 0, + "target_id": 273, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 346, + "origin_id": 265, + "origin_slot": 0, + "target_id": 273, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 347, + "origin_id": 265, + "origin_slot": 1, + "target_id": 273, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 337, + "origin_id": 288, + "origin_slot": 0, + "target_id": 275, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 338, + "origin_id": 288, + "origin_slot": 0, + "target_id": 276, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 364, + "origin_id": 277, + "origin_slot": 0, + "target_id": 276, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 361, + "origin_id": 281, + "origin_slot": 0, + "target_id": 277, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 362, + "origin_id": 290, + "origin_slot": 0, + "target_id": 277, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 289, + "origin_id": 282, + "origin_slot": 0, + "target_id": 278, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 277, + "origin_id": 283, + "origin_slot": 0, + "target_id": 279, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 280, + "origin_id": 282, + "origin_slot": 0, + "target_id": 280, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 360, + "origin_id": 289, + "origin_slot": 0, + "target_id": 281, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 366, + "origin_id": 280, + "origin_slot": 0, + "target_id": 281, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 311, + "origin_id": 291, + "origin_slot": 0, + "target_id": 282, + "target_slot": 2, + "type": "INT" + }, + { + "id": 312, + "origin_id": 292, + "origin_slot": 0, + "target_id": 282, + "target_slot": 3, + "type": "INT" + }, + { + "id": 313, + "origin_id": 291, + "origin_slot": 0, + "target_id": 283, + "target_slot": 2, + "type": "INT" + }, + { + "id": 314, + "origin_id": 292, + "origin_slot": 0, + "target_id": 283, + "target_slot": 3, + "type": "INT" + }, + { + "id": 363, + "origin_id": 277, + "origin_slot": 0, + "target_id": 284, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 310, + "origin_id": 296, + "origin_slot": 0, + "target_id": 293, + "target_slot": 0, + "type": "INT" + }, + { + "id": 328, + "origin_id": 294, + "origin_slot": 0, + "target_id": 295, + "target_slot": 0, + "type": "INT" + }, + { + "id": 329, + "origin_id": 296, + "origin_slot": 0, + "target_id": 295, + "target_slot": 1, + "type": "INT" + }, + { + "id": 325, + "origin_id": -10, + "origin_slot": 0, + "target_id": 282, + "target_slot": 0, + "type": "IMAGE,MASK" + }, + { + "id": 326, + "origin_id": -10, + "origin_slot": 1, + "target_id": 283, + "target_slot": 0, + "type": "IMAGE,MASK" + }, + { + "id": 327, + "origin_id": 272, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 371, + "origin_id": 297, + "origin_slot": 0, + "target_id": 281, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 372, + "origin_id": 297, + "origin_slot": 0, + "target_id": 277, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 373, + "origin_id": -10, + "origin_slot": 2, + "target_id": 297, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 374, + "origin_id": -10, + "origin_slot": 3, + "target_id": 290, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 375, + "origin_id": -10, + "origin_slot": 4, + "target_id": 294, + "target_slot": 0, + "type": "INT" + }, + { + "id": 376, + "origin_id": -10, + "origin_slot": 5, + "target_id": 291, + "target_slot": 0, + "type": "INT" + }, + { + "id": 377, + "origin_id": -10, + "origin_slot": 6, + "target_id": 292, + "target_slot": 0, + "type": "INT" + }, + { + "id": 378, + "origin_id": -10, + "origin_slot": 7, + "target_id": 274, + "target_slot": 0, + "type": "INT" + }, + { + "id": 379, + "origin_id": -10, + "origin_slot": 8, + "target_id": 296, + "target_slot": 0, + "type": "INT" + }, + { + "id": 380, + "origin_id": -10, + "origin_slot": 9, + "target_id": 285, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 381, + "origin_id": -10, + "origin_slot": 10, + "target_id": 286, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 382, + "origin_id": -10, + "origin_slot": 11, + "target_id": 287, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 383, + "origin_id": -10, + "origin_slot": 12, + "target_id": 288, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 384, + "origin_id": -10, + "origin_slot": 13, + "target_id": 289, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Video generation/FLF2V", + "description": "Generates a video interpolating between first and last keyframes using LTX-2.5, with optional prompt enhancement and synchronized audio." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/First-Last-Frame to Video (LTX-2.3).json b/blueprints/First-Last-Frame to Video (LTX-2.3).json index 4cae2dc2417..347ccc94869 100644 --- a/blueprints/First-Last-Frame to Video (LTX-2.3).json +++ b/blueprints/First-Last-Frame to Video (LTX-2.3).json @@ -3350,7 +3350,7 @@ } ], "extra": {}, - "category": "Video generation and editing/Conditioned", + "category": "Video generation/Conditioned", "description": "Generates a video interpolating between first and last keyframes using LTX-2.3." } ] diff --git a/blueprints/First-Last-Frame to Video.json b/blueprints/First-Last-Frame to Video.json index d76e1e045f2..240c06315f4 100644 --- a/blueprints/First-Last-Frame to Video.json +++ b/blueprints/First-Last-Frame to Video.json @@ -3350,7 +3350,7 @@ } ], "extra": {}, - "category": "Video generation and editing/FLF2V", + "category": "Video generation/FLF2V", "description": "Generates a video that interpolates between the first and last keyframes using LTX-2.3, including optional audio." } ] diff --git a/blueprints/Image Albedo Estimation (Marigold V2).json b/blueprints/Image Albedo Estimation (Marigold V2).json new file mode 100644 index 00000000000..376a01dc2f8 --- /dev/null +++ b/blueprints/Image Albedo Estimation (Marigold V2).json @@ -0,0 +1,1178 @@ +{ + "revision": 0, + "last_node_id": "105", + "last_link_id": 0, + "nodes": [ + { + "id": 105, + "type": "9387c6d1-43e7-47a7-a4a9-53546efa05ea", + "pos": [ + -1830, + 4130 + ], + "size": [ + 380, + 350 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": null + }, + { + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + }, + { + "name": "prediction", + "type": "COMBO", + "widget": { + "name": "prediction" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "name": "conditioning_name", + "type": "COMBO", + "widget": { + "name": "conditioning_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "title": "Image Albedo Estimation (Marigold V2)", + "properties": { + "previewExposures": [] + }, + "widgets_values": [ + "euler", + "0.5, 0", + "albedo", + "qwen_image_edit_2509_int8_convrot.safetensors", + "marigold_v2_albedo.safetensors", + "marigold_v2_albedo_vae.safetensors", + "marigold_v2_albedo_conditioning.safetensors" + ], + "widgets_values_named": { + "sampler_name": "euler", + "sigmas": "0.5, 0", + "prediction": "albedo", + "unet_name": "qwen_image_edit_2509_int8_convrot.safetensors", + "lora_name": "marigold_v2_albedo.safetensors", + "vae_name": "marigold_v2_albedo_vae.safetensors", + "conditioning_name": "marigold_v2_albedo_conditioning.safetensors" + } + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "9387c6d1-43e7-47a7-a4a9-53546efa05ea", + "version": 1, + "state": { + "lastGroupId": 4, + "lastNodeId": 105, + "lastLinkId": 110, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image Albedo Estimation (Marigold V2)", + "inputNode": { + "id": -10, + "bounding": [ + -2110, + 4550, + 155.080078125, + 208 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 460, + 4481, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "945e443f-b9a3-41a0-80ff-205d4ea6b550", + "name": "pixels", + "type": "IMAGE", + "linkIds": [ + 17 + ], + "localized_name": "pixels", + "pos": [ + -1978.919921875, + 4574 + ] + }, + { + "id": "f10970ae-bbd8-4d4d-8c86-5afe1f2673d4", + "name": "sampler_name", + "type": "COMBO", + "linkIds": [ + 35 + ], + "pos": [ + -1978.919921875, + 4594 + ] + }, + { + "id": "e7f0b98f-40fa-4f71-8dff-0b1d9b3ba526", + "name": "sigmas", + "type": "STRING", + "linkIds": [ + 36 + ], + "pos": [ + -1978.919921875, + 4614 + ] + }, + { + "id": "663f265f-75bd-45b0-9457-39dc96d68cce", + "name": "prediction", + "type": "COMBO", + "linkIds": [ + 37 + ], + "pos": [ + -1978.919921875, + 4634 + ] + }, + { + "id": "e94ec076-d8e7-463c-b6e5-35c90c36bc92", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 38 + ], + "pos": [ + -1978.919921875, + 4654 + ] + }, + { + "id": "b834c8bb-b51a-4a9b-b24c-7d39d8e2b27a", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 39 + ], + "pos": [ + -1978.919921875, + 4674 + ] + }, + { + "id": "d53a2944-e126-4e41-a025-98e126db2c57", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 40 + ], + "pos": [ + -1978.919921875, + 4694 + ] + }, + { + "id": "7b8c34b9-7565-4278-9eef-b331045db837", + "name": "conditioning_name", + "type": "COMBO", + "linkIds": [ + 41 + ], + "pos": [ + -1978.919921875, + 4714 + ] + } + ], + "outputs": [ + { + "id": "bdf62bcc-0f6b-4a29-bdae-b2c16e657e76", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 30 + ], + "localized_name": "IMAGE", + "pos": [ + 484, + 4505 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 92, + "type": "UNETLoader", + "pos": [ + -1730, + 4080 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 38 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 5 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "qwen_image_edit_2509_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/diffusion_models/qwen_image_edit_2509_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "qwen_image_edit_2509_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "qwen_image_edit_2509_int8_convrot.safetensors", + "weight_dtype": "default" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 93, + "type": "LoraLoaderModelOnly", + "pos": [ + -1730, + 4240 + ], + "size": [ + 610, + 120 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 5 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 39 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "marigold_v2_albedo.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/loras/marigold_v2_albedo.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "marigold_v2_albedo.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "marigold_v2_albedo.safetensors", + "strength_model": 1 + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 94, + "type": "ModelSamplingAuraFlow", + "pos": [ + -1040, + 4390 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + }, + { + "localized_name": "sampling", + "name": "sampling", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "sampling" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingAuraFlow" + }, + "widgets_values": [ + 1.73, + "flow" + ], + "widgets_values_named": { + "shift": 1.73, + "sampling": "flow" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 95, + "type": "ConditioningLoader", + "pos": [ + -1730, + 4580 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning_name", + "name": "conditioning_name", + "type": "COMBO", + "widget": { + "name": "conditioning_name" + }, + "link": 41 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 13 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningLoader", + "models": [ + { + "name": "marigold_v2_albedo_conditioning.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/embeddings/marigold_v2_albedo_conditioning.safetensors", + "directory": "embeddings" + } + ] + }, + "widgets_values": [ + "marigold_v2_albedo_conditioning.safetensors" + ], + "widgets_values_named": { + "conditioning_name": "marigold_v2_albedo_conditioning.safetensors" + }, + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 96, + "type": "VAELoader", + "pos": [ + -1730, + 4430 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 40 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 18, + 21 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "marigold_v2_albedo_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/vae/marigold_v2_albedo_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "marigold_v2_albedo_vae.safetensors" + ], + "widgets_values_named": { + "vae_name": "marigold_v2_albedo_vae.safetensors" + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 97, + "type": "DisableNoise", + "pos": [ + -740, + 4160 + ], + "size": [ + 270, + 40 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 26 + ] + } + ], + "properties": { + "Node name for S&R": "DisableNoise" + } + }, + { + "id": 98, + "type": "KSamplerSelect", + "pos": [ + -740, + 4270 + ], + "size": [ + 270, + 70 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": 35 + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 25 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect" + }, + "widgets_values": [ + "euler" + ], + "widgets_values_named": { + "sampler_name": "euler" + } + }, + { + "id": 99, + "type": "BasicGuider", + "pos": [ + -740, + 4400 + ], + "size": [ + 270, + 60 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 12 + }, + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 13 + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "BasicGuider" + } + }, + { + "id": 100, + "type": "ManualSigmas", + "pos": [ + -740, + 4510 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": 36 + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 27 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas" + }, + "widgets_values": [ + "0.5, 0" + ], + "widgets_values_named": { + "sigmas": "0.5, 0" + } + }, + { + "id": 101, + "type": "SamplerCustomAdvanced", + "pos": [ + -370, + 4290 + ], + "size": [ + 230, + 120 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 26 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 14 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 25 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 27 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 19 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 20 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced" + } + }, + { + "id": 102, + "type": "VAEEncode", + "pos": [ + -1550, + 4860 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": 17 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 18 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncode" + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 103, + "type": "MarigoldV2PostProcess", + "pos": [ + 50, + 4250 + ], + "size": [ + 280, + 70 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 23 + }, + { + "localized_name": "prediction", + "name": "prediction", + "type": "COMBO", + "widget": { + "name": "prediction" + }, + "link": 37 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 30 + ] + } + ], + "properties": { + "Node name for S&R": "MarigoldV2PostProcess" + }, + "widgets_values": [ + "depth" + ], + "widgets_values_named": { + "prediction": "depth" + } + }, + { + "id": 104, + "type": "VAEDecode", + "pos": [ + 60, + 4130 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 20 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 21 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "color": "#322", + "bgcolor": "#533" + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -1740, + 4010, + 630, + 686 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Image Encoding", + "bounding": [ + -1740, + 4720, + 630, + 300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Sampling", + "bounding": [ + -1080, + 4010, + 1030, + 690 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Decoding", + "bounding": [ + -20, + 4010, + 420, + 390 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 5, + "origin_id": 92, + "origin_slot": 0, + "target_id": 93, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 8, + "origin_id": 93, + "origin_slot": 0, + "target_id": 94, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 12, + "origin_id": 94, + "origin_slot": 0, + "target_id": 99, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 13, + "origin_id": 95, + "origin_slot": 0, + "target_id": 99, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 26, + "origin_id": 97, + "origin_slot": 0, + "target_id": 101, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 14, + "origin_id": 99, + "origin_slot": 0, + "target_id": 101, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 25, + "origin_id": 98, + "origin_slot": 0, + "target_id": 101, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 27, + "origin_id": 100, + "origin_slot": 0, + "target_id": 101, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 19, + "origin_id": 102, + "origin_slot": 0, + "target_id": 101, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 18, + "origin_id": 96, + "origin_slot": 0, + "target_id": 102, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 23, + "origin_id": 104, + "origin_slot": 0, + "target_id": 103, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 20, + "origin_id": 101, + "origin_slot": 0, + "target_id": 104, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 21, + "origin_id": 96, + "origin_slot": 0, + "target_id": 104, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 17, + "origin_id": -10, + "origin_slot": 0, + "target_id": 102, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 30, + "origin_id": 103, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 35, + "origin_id": -10, + "origin_slot": 1, + "target_id": 98, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 36, + "origin_id": -10, + "origin_slot": 2, + "target_id": 100, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 37, + "origin_id": -10, + "origin_slot": 3, + "target_id": 103, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 38, + "origin_id": -10, + "origin_slot": 4, + "target_id": 92, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 39, + "origin_id": -10, + "origin_slot": 5, + "target_id": 93, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 40, + "origin_id": -10, + "origin_slot": 6, + "target_id": 96, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 41, + "origin_id": -10, + "origin_slot": 7, + "target_id": 95, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Preprocessors/Albedo", + "description": "Estimates albedo from an input image using Marigold V2, separating surface color from lighting and shading for relighting and 3D asset work." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image Depth Estimation (Depth Anything 3).json b/blueprints/Image Depth Estimation (Depth Anything 3).json index 3c5f248451a..ae7da565aa5 100644 --- a/blueprints/Image Depth Estimation (Depth Anything 3).json +++ b/blueprints/Image Depth Estimation (Depth Anything 3).json @@ -558,7 +558,7 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Depth", + "category": "Preprocessors/Depth", "description": "This subgraph takes an input image and produces a depth map using the Depth Anything 3 model, which recovers spatially consistent geometry from any number of views. It is ideal for single or multi-view images, videos, and 3D scenes where accurate depth estimation is needed for tasks like SLAM, novel view synthesis, or spatial perception. The model uses a plain transformer backbone and supports both monocular and multi-view inputs without." } ] @@ -566,4 +566,4 @@ "extra": { "BlueprintDescription": "This subgraph takes an input image and produces a depth map using the Depth Anything 3 model, which recovers spatially consistent geometry from any number of views. It is ideal for single or multi-view images, videos, and 3D scenes where accurate depth estimation is needed for tasks like SLAM, novel view synthesis, or spatial perception. The model uses a plain transformer backbone and supports both monocular and multi-view inputs without." } -} \ No newline at end of file +} diff --git a/blueprints/Image Depth Estimation (Lotus Depth).json b/blueprints/Image Depth Estimation (Lotus Depth).json index 8aa338d0da8..601683185be 100644 --- a/blueprints/Image Depth Estimation (Lotus Depth).json +++ b/blueprints/Image Depth Estimation (Lotus Depth).json @@ -937,7 +937,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Conditioning & Preprocessors/Depth", + "category": "Preprocessors/Depth", "description": "Estimates a monocular depth map from an input image using the Lotus depth estimation model." } ] @@ -951,4 +951,4 @@ ] } } -} \ No newline at end of file +} diff --git a/blueprints/Image Depth Estimation (Marigold V2).json b/blueprints/Image Depth Estimation (Marigold V2).json new file mode 100644 index 00000000000..8f1a66ab8a8 --- /dev/null +++ b/blueprints/Image Depth Estimation (Marigold V2).json @@ -0,0 +1,1178 @@ +{ + "revision": 0, + "last_node_id": "39", + "last_link_id": 0, + "nodes": [ + { + "id": 39, + "type": "31c1d8c6-3e4f-4f28-94fe-6a98e980eed1", + "pos": [ + -1050, + 5100 + ], + "size": [ + 380, + 350 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": null + }, + { + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + }, + { + "name": "prediction", + "type": "COMBO", + "widget": { + "name": "prediction" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "name": "conditioning_name", + "type": "COMBO", + "widget": { + "name": "conditioning_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "title": "Image Depth Estimation (Marigold V2)", + "properties": { + "previewExposures": [] + }, + "widgets_values": [ + "euler", + "0.5, 0", + "depth", + "qwen_image_edit_2509_int8_convrot.safetensors", + "marigold_v2_depth_log_stage2.safetensors", + "marigold_v2_depth_log_stage2_vae.safetensors", + "marigold_v2_depth_conditioning.safetensors" + ], + "widgets_values_named": { + "sampler_name": "euler", + "sigmas": "0.5, 0", + "prediction": "depth", + "unet_name": "qwen_image_edit_2509_int8_convrot.safetensors", + "lora_name": "marigold_v2_depth_log_stage2.safetensors", + "vae_name": "marigold_v2_depth_log_stage2_vae.safetensors", + "conditioning_name": "marigold_v2_depth_conditioning.safetensors" + } + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "31c1d8c6-3e4f-4f28-94fe-6a98e980eed1", + "version": 1, + "state": { + "lastGroupId": 4, + "lastNodeId": 90, + "lastLinkId": 109, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image Depth Estimation (Marigold V2)", + "inputNode": { + "id": -10, + "bounding": [ + -2110, + 4550, + 155.080078125, + 208 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 460, + 4481, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "945e443f-b9a3-41a0-80ff-205d4ea6b550", + "name": "pixels", + "type": "IMAGE", + "linkIds": [ + 17 + ], + "localized_name": "pixels", + "pos": [ + -1978.919921875, + 4574 + ] + }, + { + "id": "f10970ae-bbd8-4d4d-8c86-5afe1f2673d4", + "name": "sampler_name", + "type": "COMBO", + "linkIds": [ + 35 + ], + "pos": [ + -1978.919921875, + 4594 + ] + }, + { + "id": "e7f0b98f-40fa-4f71-8dff-0b1d9b3ba526", + "name": "sigmas", + "type": "STRING", + "linkIds": [ + 36 + ], + "pos": [ + -1978.919921875, + 4614 + ] + }, + { + "id": "663f265f-75bd-45b0-9457-39dc96d68cce", + "name": "prediction", + "type": "COMBO", + "linkIds": [ + 37 + ], + "pos": [ + -1978.919921875, + 4634 + ] + }, + { + "id": "e94ec076-d8e7-463c-b6e5-35c90c36bc92", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 38 + ], + "pos": [ + -1978.919921875, + 4654 + ] + }, + { + "id": "b834c8bb-b51a-4a9b-b24c-7d39d8e2b27a", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 39 + ], + "pos": [ + -1978.919921875, + 4674 + ] + }, + { + "id": "d53a2944-e126-4e41-a025-98e126db2c57", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 40 + ], + "pos": [ + -1978.919921875, + 4694 + ] + }, + { + "id": "7b8c34b9-7565-4278-9eef-b331045db837", + "name": "conditioning_name", + "type": "COMBO", + "linkIds": [ + 41 + ], + "pos": [ + -1978.919921875, + 4714 + ] + } + ], + "outputs": [ + { + "id": "bdf62bcc-0f6b-4a29-bdae-b2c16e657e76", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 30 + ], + "localized_name": "IMAGE", + "pos": [ + 484, + 4505 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 26, + "type": "UNETLoader", + "pos": [ + -1730, + 4080 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 38 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 5 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "qwen_image_edit_2509_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/diffusion_models/qwen_image_edit_2509_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "qwen_image_edit_2509_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "qwen_image_edit_2509_int8_convrot.safetensors", + "weight_dtype": "default" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 27, + "type": "LoraLoaderModelOnly", + "pos": [ + -1730, + 4240 + ], + "size": [ + 610, + 120 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 5 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 39 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "marigold_v2_depth_log_stage2.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/loras/marigold_v2_depth_log_stage2.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "marigold_v2_depth_log_stage2.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "marigold_v2_depth_log_stage2.safetensors", + "strength_model": 1 + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 28, + "type": "ModelSamplingAuraFlow", + "pos": [ + -1040, + 4390 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + }, + { + "localized_name": "sampling", + "name": "sampling", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "sampling" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingAuraFlow" + }, + "widgets_values": [ + 1.73, + "flow" + ], + "widgets_values_named": { + "shift": 1.73, + "sampling": "flow" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 29, + "type": "ConditioningLoader", + "pos": [ + -1730, + 4580 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning_name", + "name": "conditioning_name", + "type": "COMBO", + "widget": { + "name": "conditioning_name" + }, + "link": 41 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 13 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningLoader", + "models": [ + { + "name": "marigold_v2_depth_conditioning.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/embeddings/marigold_v2_depth_conditioning.safetensors", + "directory": "embeddings" + } + ] + }, + "widgets_values": [ + "marigold_v2_depth_conditioning.safetensors" + ], + "widgets_values_named": { + "conditioning_name": "marigold_v2_depth_conditioning.safetensors" + }, + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 30, + "type": "VAELoader", + "pos": [ + -1730, + 4430 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 40 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 18, + 21 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "marigold_v2_depth_log_stage2_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/vae/marigold_v2_depth_log_stage2_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "marigold_v2_depth_log_stage2_vae.safetensors" + ], + "widgets_values_named": { + "vae_name": "marigold_v2_depth_log_stage2_vae.safetensors" + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 31, + "type": "DisableNoise", + "pos": [ + -740, + 4160 + ], + "size": [ + 270, + 40 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 26 + ] + } + ], + "properties": { + "Node name for S&R": "DisableNoise" + } + }, + { + "id": 32, + "type": "KSamplerSelect", + "pos": [ + -740, + 4270 + ], + "size": [ + 270, + 70 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": 35 + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 25 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect" + }, + "widgets_values": [ + "euler" + ], + "widgets_values_named": { + "sampler_name": "euler" + } + }, + { + "id": 33, + "type": "BasicGuider", + "pos": [ + -740, + 4400 + ], + "size": [ + 270, + 60 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 12 + }, + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 13 + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "BasicGuider" + } + }, + { + "id": 34, + "type": "ManualSigmas", + "pos": [ + -740, + 4510 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": 36 + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 27 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas" + }, + "widgets_values": [ + "0.5, 0" + ], + "widgets_values_named": { + "sigmas": "0.5, 0" + } + }, + { + "id": 35, + "type": "SamplerCustomAdvanced", + "pos": [ + -370, + 4290 + ], + "size": [ + 230, + 120 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 26 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 14 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 25 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 27 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 19 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 20 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced" + } + }, + { + "id": 36, + "type": "VAEEncode", + "pos": [ + -1550, + 4860 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": 17 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 18 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncode" + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 37, + "type": "MarigoldV2PostProcess", + "pos": [ + 50, + 4250 + ], + "size": [ + 280, + 70 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 23 + }, + { + "localized_name": "prediction", + "name": "prediction", + "type": "COMBO", + "widget": { + "name": "prediction" + }, + "link": 37 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 30 + ] + } + ], + "properties": { + "Node name for S&R": "MarigoldV2PostProcess" + }, + "widgets_values": [ + "depth" + ], + "widgets_values_named": { + "prediction": "depth" + } + }, + { + "id": 38, + "type": "VAEDecode", + "pos": [ + 60, + 4130 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 20 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 21 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "color": "#322", + "bgcolor": "#533" + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -1740, + 4010, + 630, + 686 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Image Encoding", + "bounding": [ + -1740, + 4720, + 630, + 300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Sampling", + "bounding": [ + -1080, + 4010, + 1030, + 690 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Decoding", + "bounding": [ + -20, + 4010, + 420, + 390 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 5, + "origin_id": 26, + "origin_slot": 0, + "target_id": 27, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 8, + "origin_id": 27, + "origin_slot": 0, + "target_id": 28, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 12, + "origin_id": 28, + "origin_slot": 0, + "target_id": 33, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 13, + "origin_id": 29, + "origin_slot": 0, + "target_id": 33, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 26, + "origin_id": 31, + "origin_slot": 0, + "target_id": 35, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 14, + "origin_id": 33, + "origin_slot": 0, + "target_id": 35, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 25, + "origin_id": 32, + "origin_slot": 0, + "target_id": 35, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 27, + "origin_id": 34, + "origin_slot": 0, + "target_id": 35, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 19, + "origin_id": 36, + "origin_slot": 0, + "target_id": 35, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 18, + "origin_id": 30, + "origin_slot": 0, + "target_id": 36, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 23, + "origin_id": 38, + "origin_slot": 0, + "target_id": 37, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 20, + "origin_id": 35, + "origin_slot": 0, + "target_id": 38, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 21, + "origin_id": 30, + "origin_slot": 0, + "target_id": 38, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 17, + "origin_id": -10, + "origin_slot": 0, + "target_id": 36, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 30, + "origin_id": 37, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 35, + "origin_id": -10, + "origin_slot": 1, + "target_id": 32, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 36, + "origin_id": -10, + "origin_slot": 2, + "target_id": 34, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 37, + "origin_id": -10, + "origin_slot": 3, + "target_id": 37, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 38, + "origin_id": -10, + "origin_slot": 4, + "target_id": 26, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 39, + "origin_id": -10, + "origin_slot": 5, + "target_id": 27, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 40, + "origin_id": -10, + "origin_slot": 6, + "target_id": 30, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 41, + "origin_id": -10, + "origin_slot": 7, + "target_id": 29, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Preprocessors/Depth", + "description": "Estimates scene depth from an input image using Marigold V2, producing a depth map with brighter areas closer and darker areas farther." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image Depth Estimation (MoGe).json b/blueprints/Image Depth Estimation (MoGe).json index e2d5d1298cd..01190be1aba 100644 --- a/blueprints/Image Depth Estimation (MoGe).json +++ b/blueprints/Image Depth Estimation (MoGe).json @@ -1146,9 +1146,9 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Depth" + "category": "Preprocessors/Depth" } ] }, "extra": {} -} \ No newline at end of file +} diff --git a/blueprints/Image Edit (Bernini-R).json b/blueprints/Image Edit (Bernini-R).json index 9ad7867a45d..aae84f7fb88 100644 --- a/blueprints/Image Edit (Bernini-R).json +++ b/blueprints/Image Edit (Bernini-R).json @@ -3131,7 +3131,7 @@ } ], "extra": {}, - "category": "Image generation and editing/Edit image", + "category": "Image editing/Edit image", "description": "Edits a single image using a text prompt, leveraging Bernini-R's latent semantic planning for changes like object addition, removal, or style transfer. Ideal for creative edits requiring precise semantic understanding, such as adding a snowman to a scene or altering an object's appearance." }, { @@ -3546,4 +3546,4 @@ "extra": { "BlueprintDescription": "Edits a single image using a text prompt, leveraging Bernini-R's latent semantic planning for changes like object addition, removal, or style transfer. Ideal for creative edits requiring precise semantic understanding, such as adding a snowman to a scene or altering an object's appearance." } -} \ No newline at end of file +} diff --git a/blueprints/Image Edit (FireRed Image Edit 1.1).json b/blueprints/Image Edit (FireRed Image Edit 1.1).json index b82c7d18b20..81771265c59 100644 --- a/blueprints/Image Edit (FireRed Image Edit 1.1).json +++ b/blueprints/Image Edit (FireRed Image Edit 1.1).json @@ -2138,7 +2138,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Edit image", + "category": "Image editing/Edit image", "description": "Edits images via text instructions using FireRed Image Edit 1.1, a diffusion-based instruction-following editing model." } ] @@ -2146,4 +2146,4 @@ "extra": { "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Image Edit (Flux.2 Dev).json b/blueprints/Image Edit (Flux.2 Dev).json index 92827bf1780..9ad5347aa72 100644 --- a/blueprints/Image Edit (Flux.2 Dev).json +++ b/blueprints/Image Edit (Flux.2 Dev).json @@ -2039,7 +2039,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Edit image", + "category": "Image editing/Edit image", "description": "Edits an image from text instructions using Flux.2 [dev], with guidance, schedulers, and optional Turbo LoRAs." } ] @@ -2047,4 +2047,4 @@ "extra": { "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Image Edit (Flux.2 Klein 4B).json b/blueprints/Image Edit (Flux.2 Klein 4B).json index 7f6fa7a4bcd..17878800a0f 100644 --- a/blueprints/Image Edit (Flux.2 Klein 4B).json +++ b/blueprints/Image Edit (Flux.2 Klein 4B).json @@ -1472,7 +1472,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Edit image", + "category": "Image editing/Edit image", "description": "Edits an input image via text instructions using FLUX.2 [klein] 4B." }, { diff --git a/blueprints/Image Edit (Joy Image Edit).json b/blueprints/Image Edit (Joy Image Edit).json new file mode 100644 index 00000000000..b1b56951490 --- /dev/null +++ b/blueprints/Image Edit (Joy Image Edit).json @@ -0,0 +1,1514 @@ +{ + "revision": 0, + "last_node_id": "193", + "last_link_id": 0, + "nodes": [ + { + "id": 193, + "type": "ee0db3c0-fa61-4e6b-a74d-ccbe2cff1a20", + "pos": [ + -400, + 700 + ], + "size": [ + 460, + 720 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "label": "image0", + "name": "image1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "Prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "label": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "label": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "label": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "label": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "label": "image1", + "name": "images.image1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "image2", + "name": "images.image2", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "image3", + "name": "images.image2_1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "image4", + "name": "images.image4", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "image5", + "name": "images.image5", + "shape": 7, + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.16.4", + "proxyWidgetErrorQuarantine": [ + { + "originalEntry": [ + "10", + "control_after_generate" + ], + "reason": "missingSubgraphInput", + "attemptedAtVersion": 1 + } + ], + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "187", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + "", + "joyai_image_edit_int8_convrot.safetensors", + "qwen3vl_8b_joyimage_edit_int8_convrot.safetensors", + "wan_2.1_vae.safetensors", + 1024, + 1024, + 42, + 40, + 4 + ], + "widgets_values_named": { + "prompt": "", + "negative_prompt": "", + "unet_name": "joyai_image_edit_int8_convrot.safetensors", + "clip_name": "qwen3vl_8b_joyimage_edit_int8_convrot.safetensors", + "vae_name": "wan_2.1_vae.safetensors", + "width": 1024, + "height": 1024, + "seed": 42, + "steps": 40, + "cfg": 4 + }, + "title": "Image Edit (Joy Image Edit)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "ee0db3c0-fa61-4e6b-a74d-ccbe2cff1a20", + "version": 1, + "state": { + "lastGroupId": 4, + "lastNodeId": 193, + "lastLinkId": 538, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image Edit (Joy Image Edit)", + "inputNode": { + "id": -10, + "bounding": [ + -1788.8757523483216, + 2904.8098290442595, + 143.044921875, + 368 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 1154.095184026405, + 2713.447842728682, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "image1", + "name": "image1", + "type": "IMAGE", + "linkIds": [ + 509, + 522 + ], + "label": "image0", + "shape": 7, + "pos": [ + -1669.8308304733216, + 2928.8098290442595 + ] + }, + { + "id": "prompt", + "name": "prompt", + "type": "STRING", + "linkIds": [ + 510 + ], + "label": "Prompt", + "pos": [ + -1669.8308304733216, + 2948.8098290442595 + ] + }, + { + "id": "negative", + "name": "negative_prompt", + "type": "STRING", + "linkIds": [ + 521 + ], + "label": "negative_prompt", + "pos": [ + -1669.8308304733216, + 2968.8098290442595 + ] + }, + { + "id": "unet", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 214 + ], + "label": "unet_name", + "pos": [ + -1669.8308304733216, + 2988.8098290442595 + ] + }, + { + "id": "clip", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 215 + ], + "label": "clip_name", + "pos": [ + -1669.8308304733216, + 3008.8098290442595 + ] + }, + { + "id": "vae", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 216 + ], + "label": "vae_name", + "pos": [ + -1669.8308304733216, + 3028.8098290442595 + ] + }, + { + "id": "width", + "name": "width", + "type": "INT", + "linkIds": [ + 519 + ], + "pos": [ + -1669.8308304733216, + 3048.8098290442595 + ] + }, + { + "id": "height", + "name": "height", + "type": "INT", + "linkIds": [ + 520 + ], + "pos": [ + -1669.8308304733216, + 3068.8098290442595 + ] + }, + { + "id": "85f6a165-cbb1-4091-80d7-1b36c14ee410", + "name": "seed", + "type": "INT", + "linkIds": [ + 515 + ], + "pos": [ + -1669.8308304733216, + 3088.8098290442595 + ] + }, + { + "id": "f06c2634-88c3-48fc-940c-b89d28f650d5", + "name": "steps", + "type": "INT", + "linkIds": [ + 516 + ], + "pos": [ + -1669.8308304733216, + 3108.8098290442595 + ] + }, + { + "id": "e9654849-a535-4092-ba0e-4e7ac85ccfdd", + "name": "cfg", + "type": "FLOAT", + "linkIds": [ + 517 + ], + "pos": [ + -1669.8308304733216, + 3128.8098290442595 + ] + }, + { + "id": "c2000370-256a-49a3-9837-6156abf04fcc", + "name": "images.image1", + "type": "IMAGE", + "linkIds": [ + 528, + 529 + ], + "label": "image1", + "pos": [ + -1669.8308304733216, + 3148.8098290442595 + ] + }, + { + "id": "36a182cb-fb08-4307-a96b-813f67c0935e", + "name": "images.image2", + "type": "IMAGE", + "linkIds": [ + 530, + 532 + ], + "label": "image2", + "pos": [ + -1669.8308304733216, + 3168.8098290442595 + ] + }, + { + "id": "b0362584-79c9-475f-a4af-148832f53edd", + "name": "images.image2_1", + "type": "IMAGE", + "linkIds": [ + 533, + 534 + ], + "label": "image3", + "pos": [ + -1669.8308304733216, + 3188.8098290442595 + ] + }, + { + "id": "93af5d66-6115-4ca9-8642-f547a4423b59", + "name": "images.image4", + "type": "IMAGE", + "linkIds": [ + 535, + 536 + ], + "label": "image4", + "pos": [ + -1669.8308304733216, + 3208.8098290442595 + ] + }, + { + "id": "a2635ab6-7a32-4997-adaf-a43fd3671613", + "name": "images.image5", + "type": "IMAGE", + "linkIds": [ + 537, + 538 + ], + "label": "image5", + "pos": [ + -1669.8308304733216, + 3228.8098290442595 + ] + } + ], + "outputs": [ + { + "id": "out", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 211 + ], + "localized_name": "IMAGE", + "pos": [ + 1178.095184026405, + 2737.447842728682 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 184, + "type": "UNETLoader", + "pos": [ + -1270, + 2660 + ], + "size": [ + 400, + 140 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 214 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 201 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "models": [ + { + "name": "joyai_image_edit_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/JoyAI-Image-Edit/resolve/main/diffusion_models/joyai_image_edit_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "joyai_image_edit_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "joyai_image_edit_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 185, + "type": "CLIPLoader", + "pos": [ + -1270, + 2860 + ], + "size": [ + 400, + 170 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 215 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 507, + 512 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "models": [ + { + "name": "qwen3vl_8b_joyimage_edit_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/JoyAI-Image-Edit/resolve/main/text_encoders/qwen3vl_8b_joyimage_edit_int8_convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_8b_joyimage_edit_int8_convrot.safetensors", + "joyimage", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_8b_joyimage_edit_int8_convrot.safetensors", + "type": "joyimage", + "device": "default" + } + }, + { + "id": 186, + "type": "VAELoader", + "pos": [ + -1270, + 3120 + ], + "size": [ + 400, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 216 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 206, + 508, + 513 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "models": [ + { + "name": "wan_2.1_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/JoyAI-Image-Edit/resolve/main/vae/wan_2.1_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "wan_2.1_vae.safetensors" + ], + "widgets_values_named": { + "vae_name": "wan_2.1_vae.safetensors" + } + }, + { + "id": 187, + "type": "KSampler", + "pos": [ + 60, + 2660 + ], + "size": [ + 300, + 560 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 224 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 511 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 514 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 518 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 515 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 516 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": 517 + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 210 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.5.1" + }, + "widgets_values": [ + 42, + "fixed", + 40, + 4, + "euler", + "normal", + 1 + ], + "widgets_values_named": { + "seed": 42, + "control_after_generate": "fixed", + "steps": 40, + "cfg": 4, + "sampler_name": "euler", + "scheduler": "normal", + "denoise": 1 + } + }, + { + "id": 188, + "type": "VAEDecode", + "pos": [ + 430, + 2660 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 210 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 206 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 211 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.5.1" + } + }, + { + "id": 189, + "type": "CFGNorm", + "pos": [ + -250, + 2660 + ], + "size": [ + 280, + 140 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 201 + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + }, + { + "localized_name": "pre_cfg", + "name": "pre_cfg", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "pre_cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "patched_model", + "name": "patched_model", + "type": "MODEL", + "links": [ + 224 + ] + } + ], + "properties": { + "Node name for S&R": "CFGNorm", + "cnr_id": "comfy-core", + "ver": "0.5.1" + }, + "widgets_values": [ + 1, + true + ], + "widgets_values_named": { + "strength": 1, + "pre_cfg": true + } + }, + { + "id": 190, + "type": "TextEncodeJoyImageEdit", + "pos": [ + -770, + 2650 + ], + "size": [ + 440, + 270 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 507 + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 508 + }, + { + "label": "image0", + "localized_name": "image0", + "name": "images.image0", + "shape": 7, + "type": "IMAGE", + "link": 509 + }, + { + "localized_name": "image1", + "name": "images.image1", + "shape": 7, + "type": "IMAGE", + "link": 528 + }, + { + "localized_name": "image2", + "name": "images.image2", + "shape": 7, + "type": "IMAGE", + "link": 530 + }, + { + "localized_name": "image3", + "name": "images.image3", + "shape": 7, + "type": "IMAGE", + "link": 533 + }, + { + "localized_name": "image4", + "name": "images.image4", + "shape": 7, + "type": "IMAGE", + "link": 535 + }, + { + "localized_name": "image5", + "name": "images.image5", + "shape": 7, + "type": "IMAGE", + "link": 537 + }, + { + "label": "Prompt", + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 510 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 511 + ] + } + ], + "title": "TextEncodeJoyImageEdit (Positive)", + "properties": { + "Node name for S&R": "TextEncodeJoyImageEdit" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "prompt": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 191, + "type": "TextEncodeJoyImageEdit", + "pos": [ + -770, + 3020 + ], + "size": [ + 440, + 270 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 512 + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 513 + }, + { + "label": "image0", + "localized_name": "image0", + "name": "images.image0", + "shape": 7, + "type": "IMAGE", + "link": 522 + }, + { + "localized_name": "image1", + "name": "images.image1", + "shape": 7, + "type": "IMAGE", + "link": 529 + }, + { + "localized_name": "image2", + "name": "images.image2", + "shape": 7, + "type": "IMAGE", + "link": 532 + }, + { + "localized_name": "image3", + "name": "images.image3", + "shape": 7, + "type": "IMAGE", + "link": 534 + }, + { + "localized_name": "image4", + "name": "images.image4", + "shape": 7, + "type": "IMAGE", + "link": 536 + }, + { + "localized_name": "image5", + "name": "images.image5", + "shape": 7, + "type": "IMAGE", + "link": 538 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 521 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 514 + ] + } + ], + "title": "TextEncodeJoyImageEdit (Negative)", + "properties": { + "Node name for S&R": "TextEncodeJoyImageEdit" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "prompt": "" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 192, + "type": "EmptySD3LatentImage", + "pos": [ + -1190, + 3410 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 519 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 520 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 518 + ] + } + ], + "properties": { + "Node name for S&R": "EmptySD3LatentImage" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ], + "widgets_values_named": { + "width": 1024, + "height": 1024, + "batch_size": 1 + } + } + ], + "groups": [ + { + "id": 1, + "title": "Load Models", + "bounding": [ + -1290, + 2570, + 460, + 710 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Prompt", + "bounding": [ + -800, + 2570, + 500, + 710 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Sampling", + "bounding": [ + -270, + 2570, + 650, + 710 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Image Size", + "bounding": [ + -1290, + 3310, + 460, + 290 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 201, + "origin_id": 184, + "origin_slot": 0, + "target_id": 189, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 206, + "origin_id": 186, + "origin_slot": 0, + "target_id": 188, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 210, + "origin_id": 187, + "origin_slot": 0, + "target_id": 188, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 211, + "origin_id": 188, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 214, + "origin_id": -10, + "origin_slot": 3, + "target_id": 184, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 215, + "origin_id": -10, + "origin_slot": 4, + "target_id": 185, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 216, + "origin_id": -10, + "origin_slot": 5, + "target_id": 186, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 224, + "origin_id": 189, + "origin_slot": 0, + "target_id": 187, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 507, + "origin_id": 185, + "origin_slot": 0, + "target_id": 190, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 508, + "origin_id": 186, + "origin_slot": 0, + "target_id": 190, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 509, + "origin_id": -10, + "origin_slot": 0, + "target_id": 190, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 510, + "origin_id": -10, + "origin_slot": 1, + "target_id": 190, + "target_slot": 8, + "type": "STRING" + }, + { + "id": 511, + "origin_id": 190, + "origin_slot": 0, + "target_id": 187, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 512, + "origin_id": 185, + "origin_slot": 0, + "target_id": 191, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 513, + "origin_id": 186, + "origin_slot": 0, + "target_id": 191, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 514, + "origin_id": 191, + "origin_slot": 0, + "target_id": 187, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 515, + "origin_id": -10, + "origin_slot": 8, + "target_id": 187, + "target_slot": 4, + "type": "INT" + }, + { + "id": 516, + "origin_id": -10, + "origin_slot": 9, + "target_id": 187, + "target_slot": 5, + "type": "INT" + }, + { + "id": 517, + "origin_id": -10, + "origin_slot": 10, + "target_id": 187, + "target_slot": 6, + "type": "FLOAT" + }, + { + "id": 518, + "origin_id": 192, + "origin_slot": 0, + "target_id": 187, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 519, + "origin_id": -10, + "origin_slot": 6, + "target_id": 192, + "target_slot": 0, + "type": "INT" + }, + { + "id": 520, + "origin_id": -10, + "origin_slot": 7, + "target_id": 192, + "target_slot": 1, + "type": "INT" + }, + { + "id": 521, + "origin_id": -10, + "origin_slot": 2, + "target_id": 191, + "target_slot": 8, + "type": "STRING" + }, + { + "id": 522, + "origin_id": -10, + "origin_slot": 0, + "target_id": 191, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 528, + "origin_id": -10, + "origin_slot": 11, + "target_id": 190, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 529, + "origin_id": -10, + "origin_slot": 11, + "target_id": 191, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 530, + "origin_id": -10, + "origin_slot": 12, + "target_id": 190, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 532, + "origin_id": -10, + "origin_slot": 12, + "target_id": 191, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 533, + "origin_id": -10, + "origin_slot": 13, + "target_id": 190, + "target_slot": 5, + "type": "IMAGE" + }, + { + "id": 534, + "origin_id": -10, + "origin_slot": 13, + "target_id": 191, + "target_slot": 5, + "type": "IMAGE" + }, + { + "id": 535, + "origin_id": -10, + "origin_slot": 14, + "target_id": 190, + "target_slot": 6, + "type": "IMAGE" + }, + { + "id": 536, + "origin_id": -10, + "origin_slot": 14, + "target_id": 191, + "target_slot": 6, + "type": "IMAGE" + }, + { + "id": 537, + "origin_id": -10, + "origin_slot": 15, + "target_id": 190, + "target_slot": 7, + "type": "IMAGE" + }, + { + "id": 538, + "origin_id": -10, + "origin_slot": 15, + "target_id": 191, + "target_slot": 7, + "type": "IMAGE" + } + ], + "extra": { + "workflowRendererVersion": "LG" + }, + "category": "Image editing/Edit image", + "description": "Edits images via text instructions using Joy Image Edit, with optional extra reference images for multi-image conditioning." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image Edit (LongCat Image Edit).json b/blueprints/Image Edit (LongCat Image Edit).json index de1c155a250..893e3d94eed 100644 --- a/blueprints/Image Edit (LongCat Image Edit).json +++ b/blueprints/Image Edit (LongCat Image Edit).json @@ -1417,7 +1417,7 @@ } ], "extra": {}, - "category": "Image generation and editing/Edit image", + "category": "Image editing/Edit image", "description": "Edits images via text instructions using LongCat Image Edit, an instruction-following image editing diffusion model." } ] @@ -1425,4 +1425,4 @@ "extra": { "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Image Edit (Mage-Flow-Edit Turbo).json b/blueprints/Image Edit (Mage-Flow-Edit Turbo).json new file mode 100644 index 00000000000..4ef0241faf2 --- /dev/null +++ b/blueprints/Image Edit (Mage-Flow-Edit Turbo).json @@ -0,0 +1,1760 @@ +{ + "revision": 0, + "last_node_id": "47", + "last_link_id": 0, + "nodes": [ + { + "id": 47, + "type": "0b2e4bf5-4400-4714-ad6f-82aafcbf3d93", + "pos": [ + -1680, + -940 + ], + "size": [ + 490, + 800 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "label": "image_1", + "localized_name": "images.image_1", + "name": "images.image_1", + "type": "IMAGE", + "link": null + }, + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "label": "resize_input_image", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": null + }, + { + "label": "target_megapixels", + "name": "resize_type.megapixels", + "type": "FLOAT", + "widget": { + "name": "resize_type.megapixels" + }, + "link": null + }, + { + "label": "width", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "height", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "match_input_size", + "name": "value_2", + "type": "BOOLEAN", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "image_2", + "name": "images.image_2", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "image_3", + "name": "images.image_3", + "shape": 7, + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "36", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + 478849483548042, + true, + 1, + 1024, + 1024, + true, + "mage_flow_edit_turbo_int8_convrot.safetensors", + "qwen3vl_4b_bf16.safetensors", + "mage_flow_vae_bf16.safetensors" + ], + "widgets_values_named": { + "prompt": "", + "seed": 478849483548042, + "switch": true, + "resize_type.megapixels": 1, + "value": 1024, + "value_1": 1024, + "value_2": true, + "unet_name": "mage_flow_edit_turbo_int8_convrot.safetensors", + "clip_name": "qwen3vl_4b_bf16.safetensors", + "vae_name": "mage_flow_vae_bf16.safetensors" + }, + "title": "Image Edit (Mage-Flow-Edit Turbo)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "0b2e4bf5-4400-4714-ad6f-82aafcbf3d93", + "version": 1, + "state": { + "lastGroupId": 6, + "lastNodeId": 47, + "lastLinkId": 62, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image Edit (Mage-Flow-Edit Turbo)", + "inputNode": { + "id": -10, + "bounding": [ + -2400, + 520, + 156.6328125, + 308 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 640, + -190, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "76720c46-896e-4592-914b-f218eb50feb3", + "name": "images.image_1", + "type": "IMAGE", + "linkIds": [ + 26, + 60 + ], + "localized_name": "images.image_1", + "label": "image_1", + "shape": 7, + "pos": [ + -2267.3671875, + 544 + ] + }, + { + "id": "f96438af-739b-459f-9bfe-4667beca6d65", + "name": "prompt", + "type": "STRING", + "linkIds": [ + 16 + ], + "pos": [ + -2267.3671875, + 564 + ] + }, + { + "id": "7a9e4c4e-6d5e-4c26-8717-de5ad1da3c9e", + "name": "seed", + "type": "INT", + "linkIds": [ + 19 + ], + "pos": [ + -2267.3671875, + 584 + ] + }, + { + "id": "eb770531-4aa1-453c-a988-5976527e70c1", + "name": "switch", + "type": "BOOLEAN", + "linkIds": [ + 39 + ], + "label": "resize_input_image", + "pos": [ + -2267.3671875, + 604 + ] + }, + { + "id": "b074698a-f08c-45df-b858-6c4aa73d44ea", + "name": "resize_type.megapixels", + "type": "FLOAT", + "linkIds": [ + 58 + ], + "label": "target_megapixels", + "pos": [ + -2267.3671875, + 624 + ] + }, + { + "id": "f8e7a2a3-63d4-4fd6-8bbe-3bbc302616a6", + "name": "value", + "type": "INT", + "linkIds": [ + 50 + ], + "label": "width", + "pos": [ + -2267.3671875, + 644 + ] + }, + { + "id": "dc02d218-a0ca-4032-bfbb-ec6038e9581d", + "name": "value_1", + "type": "INT", + "linkIds": [ + 51 + ], + "label": "height", + "pos": [ + -2267.3671875, + 664 + ] + }, + { + "id": "76487312-8123-400f-a225-abf6caf65b57", + "name": "value_2", + "type": "BOOLEAN", + "linkIds": [ + 52 + ], + "label": "match_input_size", + "pos": [ + -2267.3671875, + 684 + ] + }, + { + "id": "a50dd6d8-6e61-45bc-b804-862f00bd59d5", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 53 + ], + "pos": [ + -2267.3671875, + 704 + ] + }, + { + "id": "ed39ed30-448e-4cbc-8743-13c7841c9f50", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 54 + ], + "pos": [ + -2267.3671875, + 724 + ] + }, + { + "id": "8fb2d359-2b68-4a57-a374-be2da66b8d0e", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 55 + ], + "pos": [ + -2267.3671875, + 744 + ] + }, + { + "id": "b067eb4a-8cf7-455d-8667-5273b123ad9c", + "name": "images.image_2", + "type": "IMAGE", + "linkIds": [ + 61 + ], + "label": "image_2", + "pos": [ + -2267.3671875, + 764 + ] + }, + { + "id": "7fd2dcd9-b904-4204-bc64-e08e511723e8", + "name": "images.image_3", + "type": "IMAGE", + "linkIds": [ + 62 + ], + "label": "image_3", + "pos": [ + -2267.3671875, + 784 + ] + } + ], + "outputs": [ + { + "id": "c9854530-28d9-4d0f-9ecc-2163f2b3a640", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 10 + ], + "localized_name": "IMAGE", + "pos": [ + 664, + -166 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 32, + "type": "UNETLoader", + "pos": [ + -1640, + -160 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 53 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "mage_flow_edit_turbo_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/diffusion_models/mage_flow_edit_turbo_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "mage_flow_edit_turbo_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "mage_flow_edit_turbo_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 33, + "type": "CLIPLoader", + "pos": [ + -1640, + 0 + ], + "size": [ + 450, + 140 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 54 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "qwen3vl_4b_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/text_encoders/qwen3vl_4b_bf16.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_4b_bf16.safetensors", + "mage", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_4b_bf16.safetensors", + "type": "mage", + "device": "default" + } + }, + { + "id": 34, + "type": "VAELoader", + "pos": [ + -1640, + 180 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 55 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 5, + 8 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "mage_flow_vae_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/vae/mage_flow_vae_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "mage_flow_vae_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "mage_flow_vae_bf16.safetensors" + } + }, + { + "id": 35, + "type": "TextEncodeMageFlowEdit", + "pos": [ + -1030, + -100 + ], + "size": [ + 400, + 360 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 1 + }, + { + "label": "image_1", + "localized_name": "image_1", + "name": "images.image_1", + "shape": 7, + "type": "IMAGE", + "link": 27 + }, + { + "localized_name": "image_2", + "name": "images.image_2", + "shape": 7, + "type": "IMAGE", + "link": 61 + }, + { + "localized_name": "image_3", + "name": "images.image_3", + "shape": 7, + "type": "IMAGE", + "link": 62 + }, + { + "localized_name": "image_4", + "name": "images.image_4", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 5 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 16 + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 30 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 37 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 3 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 4 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeMageFlowEdit" + }, + "widgets_values": [ + "", + "", + 0, + 0, + 1 + ], + "widgets_values_named": { + "prompt": "", + "negative_prompt": "", + "width": 0, + "height": 0, + "batch_size": 1 + } + }, + { + "id": 36, + "type": "KSampler", + "pos": [ + -470, + -220 + ], + "size": [ + 370, + 610 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 9 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 3 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 4 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 2 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 19 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 478849483548042, + "randomize", + 4, + 1, + "euler", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 478849483548042, + "control_after_generate": "randomize", + "steps": 4, + "cfg": 1, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 37, + "type": "VAEDecode", + "pos": [ + -40, + -220 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + } + }, + { + "id": 38, + "type": "ComfySwitchNode", + "pos": [ + -1630, + 910 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 26 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 59 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 38 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 27, + 29 + ] + } + ], + "title": "Switch (Input Source)", + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 39, + "type": "GetImageSize", + "pos": [ + -1230, + 850 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 29 + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 32 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 33 + ] + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "links": null + } + ], + "properties": { + "Node name for S&R": "GetImageSize" + } + }, + { + "id": 40, + "type": "ComfySwitchNode", + "pos": [ + -870, + 550 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 40 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 32 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 35 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 30 + ] + } + ], + "title": "Switch (Width)", + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 41, + "type": "ComfySwitchNode", + "pos": [ + -870, + 740 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 41 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 33 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 36 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 37 + ] + } + ], + "title": "Switch (Height)", + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 42, + "type": "PrimitiveBoolean", + "pos": [ + -1220, + 1000 + ], + "size": [ + 250, + 90 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 52 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 35, + 36 + ] + } + ], + "title": "Boolean (Match Input Size)", + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 43, + "type": "PrimitiveBoolean", + "pos": [ + -1630, + 770 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 39 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 38 + ] + } + ], + "title": "Boolean (Resize Input Image)", + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 44, + "type": "PrimitiveInt", + "pos": [ + -1250, + 570 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 50 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 40 + ] + } + ], + "title": "Int (Width)", + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 1024, + "fixed" + ], + "widgets_values_named": { + "value": 1024, + "fixed": "fixed" + } + }, + { + "id": 45, + "type": "PrimitiveInt", + "pos": [ + -1250, + 710 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 51 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 41 + ] + } + ], + "title": "Int (Height)", + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 1024, + "fixed" + ], + "widgets_values_named": { + "value": 1024, + "fixed": "fixed" + } + }, + { + "id": 46, + "type": "ImageScaleToTotalPixels", + "pos": [ + -1630, + 540 + ], + "size": [ + 280, + 180 + ], + "flags": {}, + "order": 14, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 60 + }, + { + "localized_name": "upscale_method", + "name": "upscale_method", + "type": "COMBO", + "widget": { + "name": "upscale_method" + }, + "link": null + }, + { + "localized_name": "megapixels", + "name": "megapixels", + "type": "FLOAT", + "widget": { + "name": "megapixels" + }, + "link": 58 + }, + { + "localized_name": "resolution_steps", + "name": "resolution_steps", + "type": "INT", + "widget": { + "name": "resolution_steps" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 59 + ] + } + ], + "properties": { + "Node name for S&R": "ImageScaleToTotalPixels" + }, + "widgets_values": [ + "lanczos", + 1, + 16 + ], + "widgets_values_named": { + "upscale_method": "lanczos", + "megapixels": 1, + "resolution_steps": 16 + } + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -1670, + -260, + 510, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Resize Input Image", + "bounding": [ + -1670, + 460, + 360, + 640 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Get Image Size", + "bounding": [ + -1280, + 460, + 750, + 640 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Conditioning", + "bounding": [ + -1130, + -260, + 600, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Manual Image Size", + "bounding": [ + -1260, + 500, + 290, + 302 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Core Pipeline", + "bounding": [ + -1680, + -300, + 2040, + 700 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 1, + "origin_id": 33, + "origin_slot": 0, + "target_id": 35, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 5, + "origin_id": 34, + "origin_slot": 0, + "target_id": 35, + "target_slot": 5, + "type": "VAE" + }, + { + "id": 9, + "origin_id": 32, + "origin_slot": 0, + "target_id": 36, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 3, + "origin_id": 35, + "origin_slot": 0, + "target_id": 36, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 4, + "origin_id": 35, + "origin_slot": 1, + "target_id": 36, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 2, + "origin_id": 35, + "origin_slot": 2, + "target_id": 36, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 7, + "origin_id": 36, + "origin_slot": 0, + "target_id": 37, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 8, + "origin_id": 34, + "origin_slot": 0, + "target_id": 37, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 10, + "origin_id": 37, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 16, + "origin_id": -10, + "origin_slot": 1, + "target_id": 35, + "target_slot": 6, + "type": "STRING" + }, + { + "id": 19, + "origin_id": -10, + "origin_slot": 2, + "target_id": 36, + "target_slot": 4, + "type": "INT" + }, + { + "id": 26, + "origin_id": -10, + "origin_slot": 0, + "target_id": 38, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 27, + "origin_id": 38, + "origin_slot": 0, + "target_id": 35, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 29, + "origin_id": 38, + "origin_slot": 0, + "target_id": 39, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 30, + "origin_id": 40, + "origin_slot": 0, + "target_id": 35, + "target_slot": 8, + "type": "INT" + }, + { + "id": 32, + "origin_id": 39, + "origin_slot": 0, + "target_id": 40, + "target_slot": 1, + "type": "INT" + }, + { + "id": 33, + "origin_id": 39, + "origin_slot": 1, + "target_id": 41, + "target_slot": 1, + "type": "INT" + }, + { + "id": 35, + "origin_id": 42, + "origin_slot": 0, + "target_id": 40, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 36, + "origin_id": 42, + "origin_slot": 0, + "target_id": 41, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 37, + "origin_id": 41, + "origin_slot": 0, + "target_id": 35, + "target_slot": 9, + "type": "INT" + }, + { + "id": 38, + "origin_id": 43, + "origin_slot": 0, + "target_id": 38, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 39, + "origin_id": -10, + "origin_slot": 3, + "target_id": 43, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 40, + "origin_id": 44, + "origin_slot": 0, + "target_id": 40, + "target_slot": 0, + "type": "INT" + }, + { + "id": 41, + "origin_id": 45, + "origin_slot": 0, + "target_id": 41, + "target_slot": 0, + "type": "INT" + }, + { + "id": 50, + "origin_id": -10, + "origin_slot": 5, + "target_id": 44, + "target_slot": 0, + "type": "INT" + }, + { + "id": 51, + "origin_id": -10, + "origin_slot": 6, + "target_id": 45, + "target_slot": 0, + "type": "INT" + }, + { + "id": 52, + "origin_id": -10, + "origin_slot": 7, + "target_id": 42, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 53, + "origin_id": -10, + "origin_slot": 8, + "target_id": 32, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 54, + "origin_id": -10, + "origin_slot": 9, + "target_id": 33, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 55, + "origin_id": -10, + "origin_slot": 10, + "target_id": 34, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 58, + "origin_id": -10, + "origin_slot": 4, + "target_id": 46, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 59, + "origin_id": 46, + "origin_slot": 0, + "target_id": 38, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 60, + "origin_id": -10, + "origin_slot": 0, + "target_id": 46, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 61, + "origin_id": -10, + "origin_slot": 11, + "target_id": 35, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 62, + "origin_id": -10, + "origin_slot": 12, + "target_id": 35, + "target_slot": 3, + "type": "IMAGE" + } + ], + "extra": {}, + "category": "Image editing/Edit image", + "description": "Edits images from natural-language instructions using Microsoft Mage-Flow-Edit Turbo, a distilled 4-step checkpoint." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image Edit (Mage-Flow-Edit).json b/blueprints/Image Edit (Mage-Flow-Edit).json new file mode 100644 index 00000000000..faeb51e1706 --- /dev/null +++ b/blueprints/Image Edit (Mage-Flow-Edit).json @@ -0,0 +1,1760 @@ +{ + "revision": 0, + "last_node_id": "42", + "last_link_id": 0, + "nodes": [ + { + "id": 42, + "type": "34d802b9-bc39-4753-b675-67789a1c569f", + "pos": [ + -2090, + 1480 + ], + "size": [ + 490, + 800 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "label": "image_1", + "localized_name": "images.image_1", + "name": "images.image_1", + "type": "IMAGE", + "link": null + }, + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "label": "resize_input_image", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": null + }, + { + "label": "target_megapixels", + "name": "resize_type.megapixels", + "type": "FLOAT", + "widget": { + "name": "resize_type.megapixels" + }, + "link": null + }, + { + "label": "width", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "height", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "match_input_size", + "name": "value_2", + "type": "BOOLEAN", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "image_2", + "name": "images.image_2", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "image_3", + "name": "images.image_3", + "shape": 7, + "type": "IMAGE", + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "31", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + 1088076636138798, + true, + 1, + 1024, + 1024, + true, + "mage_flow_edit_int8_convrot.safetensors", + "qwen3vl_4b_bf16.safetensors", + "mage_flow_vae_bf16.safetensors" + ], + "widgets_values_named": { + "prompt": "", + "seed": 1088076636138798, + "switch": true, + "resize_type.megapixels": 1, + "value": 1024, + "value_1": 1024, + "value_2": true, + "unet_name": "mage_flow_edit_int8_convrot.safetensors", + "clip_name": "qwen3vl_4b_bf16.safetensors", + "vae_name": "mage_flow_vae_bf16.safetensors" + }, + "title": "Image Edit (Mage-Flow-Edit)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "34d802b9-bc39-4753-b675-67789a1c569f", + "version": 1, + "state": { + "lastGroupId": 6, + "lastNodeId": 42, + "lastLinkId": 60, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image Edit (Mage-Flow-Edit)", + "inputNode": { + "id": -10, + "bounding": [ + -2250, + 500, + 156.6328125, + 308 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 640, + -190, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "76720c46-896e-4592-914b-f218eb50feb3", + "name": "images.image_1", + "type": "IMAGE", + "linkIds": [ + 26, + 59 + ], + "localized_name": "images.image_1", + "label": "image_1", + "shape": 7, + "pos": [ + -2117.3671875, + 524 + ] + }, + { + "id": "f96438af-739b-459f-9bfe-4667beca6d65", + "name": "prompt", + "type": "STRING", + "linkIds": [ + 16 + ], + "pos": [ + -2117.3671875, + 544 + ] + }, + { + "id": "7a9e4c4e-6d5e-4c26-8717-de5ad1da3c9e", + "name": "seed", + "type": "INT", + "linkIds": [ + 19 + ], + "pos": [ + -2117.3671875, + 564 + ] + }, + { + "id": "eb770531-4aa1-453c-a988-5976527e70c1", + "name": "switch", + "type": "BOOLEAN", + "linkIds": [ + 39 + ], + "label": "resize_input_image", + "pos": [ + -2117.3671875, + 584 + ] + }, + { + "id": "b074698a-f08c-45df-b858-6c4aa73d44ea", + "name": "resize_type.megapixels", + "type": "FLOAT", + "linkIds": [ + 60 + ], + "label": "target_megapixels", + "pos": [ + -2117.3671875, + 604 + ] + }, + { + "id": "f8e7a2a3-63d4-4fd6-8bbe-3bbc302616a6", + "name": "value", + "type": "INT", + "linkIds": [ + 50 + ], + "label": "width", + "pos": [ + -2117.3671875, + 624 + ] + }, + { + "id": "dc02d218-a0ca-4032-bfbb-ec6038e9581d", + "name": "value_1", + "type": "INT", + "linkIds": [ + 51 + ], + "label": "height", + "pos": [ + -2117.3671875, + 644 + ] + }, + { + "id": "76487312-8123-400f-a225-abf6caf65b57", + "name": "value_2", + "type": "BOOLEAN", + "linkIds": [ + 52 + ], + "label": "match_input_size", + "pos": [ + -2117.3671875, + 664 + ] + }, + { + "id": "a50dd6d8-6e61-45bc-b804-862f00bd59d5", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 53 + ], + "pos": [ + -2117.3671875, + 684 + ] + }, + { + "id": "ed39ed30-448e-4cbc-8743-13c7841c9f50", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 54 + ], + "pos": [ + -2117.3671875, + 704 + ] + }, + { + "id": "8fb2d359-2b68-4a57-a374-be2da66b8d0e", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 55 + ], + "pos": [ + -2117.3671875, + 724 + ] + }, + { + "id": "c05c0f86-8fc8-4590-a34f-296254a6530c", + "name": "images.image_2", + "type": "IMAGE", + "linkIds": [ + 56 + ], + "label": "image_2", + "pos": [ + -2117.3671875, + 744 + ] + }, + { + "id": "700cfde4-9212-4d49-aa01-79d51ef9eccf", + "name": "images.image_3", + "type": "IMAGE", + "linkIds": [ + 57 + ], + "label": "image_3", + "pos": [ + -2117.3671875, + 764 + ] + } + ], + "outputs": [ + { + "id": "c9854530-28d9-4d0f-9ecc-2163f2b3a640", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 10 + ], + "localized_name": "IMAGE", + "pos": [ + 664, + -166 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 27, + "type": "UNETLoader", + "pos": [ + -1640, + -160 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 53 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "mage_flow_edit_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/diffusion_models/mage_flow_edit_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "mage_flow_edit_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "mage_flow_edit_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 28, + "type": "CLIPLoader", + "pos": [ + -1640, + 0 + ], + "size": [ + 450, + 140 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 54 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "qwen3vl_4b_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/text_encoders/qwen3vl_4b_bf16.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_4b_bf16.safetensors", + "mage", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_4b_bf16.safetensors", + "type": "mage", + "device": "default" + } + }, + { + "id": 29, + "type": "VAELoader", + "pos": [ + -1640, + 180 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 55 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 5, + 8 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "mage_flow_vae_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/vae/mage_flow_vae_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "mage_flow_vae_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "mage_flow_vae_bf16.safetensors" + } + }, + { + "id": 30, + "type": "TextEncodeMageFlowEdit", + "pos": [ + -1030, + -100 + ], + "size": [ + 400, + 360 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 1 + }, + { + "label": "image_1", + "localized_name": "image_1", + "name": "images.image_1", + "shape": 7, + "type": "IMAGE", + "link": 27 + }, + { + "localized_name": "image_2", + "name": "images.image_2", + "shape": 7, + "type": "IMAGE", + "link": 56 + }, + { + "localized_name": "image_3", + "name": "images.image_3", + "shape": 7, + "type": "IMAGE", + "link": 57 + }, + { + "localized_name": "image_4", + "name": "images.image_4", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 5 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 16 + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 30 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 37 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 3 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 4 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeMageFlowEdit" + }, + "widgets_values": [ + "", + "", + 0, + 0, + 1 + ], + "widgets_values_named": { + "prompt": "", + "negative_prompt": "", + "width": 0, + "height": 0, + "batch_size": 1 + } + }, + { + "id": 31, + "type": "KSampler", + "pos": [ + -470, + -220 + ], + "size": [ + 370, + 610 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 9 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 3 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 4 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 2 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 19 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 1088076636138798, + "randomize", + 30, + 5, + "euler", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 1088076636138798, + "control_after_generate": "randomize", + "steps": 30, + "cfg": 5, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 32, + "type": "VAEDecode", + "pos": [ + -40, + -220 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + } + }, + { + "id": 33, + "type": "ComfySwitchNode", + "pos": [ + -1630, + 910 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 26 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 58 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 38 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 27, + 29 + ] + } + ], + "title": "Switch (Input Source)", + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 34, + "type": "GetImageSize", + "pos": [ + -1230, + 850 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 29 + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 32 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 33 + ] + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "links": null + } + ], + "properties": { + "Node name for S&R": "GetImageSize" + } + }, + { + "id": 35, + "type": "ComfySwitchNode", + "pos": [ + -870, + 550 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 40 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 32 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 35 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 30 + ] + } + ], + "title": "Switch (Width)", + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 36, + "type": "ComfySwitchNode", + "pos": [ + -870, + 740 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 41 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 33 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 36 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 37 + ] + } + ], + "title": "Switch (Height)", + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 37, + "type": "PrimitiveBoolean", + "pos": [ + -1220, + 1000 + ], + "size": [ + 250, + 90 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 52 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 35, + 36 + ] + } + ], + "title": "Boolean (Match Input Size)", + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 38, + "type": "PrimitiveBoolean", + "pos": [ + -1630, + 770 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 39 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 38 + ] + } + ], + "title": "Boolean (Resize Input Image)", + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 39, + "type": "PrimitiveInt", + "pos": [ + -1250, + 570 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 50 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 40 + ] + } + ], + "title": "Int (Width)", + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 1024, + "fixed" + ], + "widgets_values_named": { + "value": 1024, + "fixed": "fixed" + } + }, + { + "id": 40, + "type": "PrimitiveInt", + "pos": [ + -1250, + 710 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 51 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 41 + ] + } + ], + "title": "Int (Height)", + "properties": { + "Node name for S&R": "PrimitiveInt" + }, + "widgets_values": [ + 1024, + "fixed" + ], + "widgets_values_named": { + "value": 1024, + "fixed": "fixed" + } + }, + { + "id": 41, + "type": "ImageScaleToTotalPixels", + "pos": [ + -1630, + 540 + ], + "size": [ + 280, + 180 + ], + "flags": {}, + "order": 14, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 59 + }, + { + "localized_name": "upscale_method", + "name": "upscale_method", + "type": "COMBO", + "widget": { + "name": "upscale_method" + }, + "link": null + }, + { + "localized_name": "megapixels", + "name": "megapixels", + "type": "FLOAT", + "widget": { + "name": "megapixels" + }, + "link": 60 + }, + { + "localized_name": "resolution_steps", + "name": "resolution_steps", + "type": "INT", + "widget": { + "name": "resolution_steps" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 58 + ] + } + ], + "properties": { + "Node name for S&R": "ImageScaleToTotalPixels" + }, + "widgets_values": [ + "lanczos", + 1, + 16 + ], + "widgets_values_named": { + "upscale_method": "lanczos", + "megapixels": 1, + "resolution_steps": 16 + } + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -1670, + -260, + 510, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Resize Input Image", + "bounding": [ + -1670, + 460, + 360, + 640 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Get Image Size", + "bounding": [ + -1280, + 460, + 750, + 640 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Conditioning", + "bounding": [ + -1130, + -260, + 600, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Manual Image Size", + "bounding": [ + -1260, + 500, + 290, + 302 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Core Pipeline", + "bounding": [ + -1680, + -300, + 2040, + 700 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 1, + "origin_id": 28, + "origin_slot": 0, + "target_id": 30, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 5, + "origin_id": 29, + "origin_slot": 0, + "target_id": 30, + "target_slot": 5, + "type": "VAE" + }, + { + "id": 9, + "origin_id": 27, + "origin_slot": 0, + "target_id": 31, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 3, + "origin_id": 30, + "origin_slot": 0, + "target_id": 31, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 4, + "origin_id": 30, + "origin_slot": 1, + "target_id": 31, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 2, + "origin_id": 30, + "origin_slot": 2, + "target_id": 31, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 7, + "origin_id": 31, + "origin_slot": 0, + "target_id": 32, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 8, + "origin_id": 29, + "origin_slot": 0, + "target_id": 32, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 10, + "origin_id": 32, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 16, + "origin_id": -10, + "origin_slot": 1, + "target_id": 30, + "target_slot": 6, + "type": "STRING" + }, + { + "id": 19, + "origin_id": -10, + "origin_slot": 2, + "target_id": 31, + "target_slot": 4, + "type": "INT" + }, + { + "id": 26, + "origin_id": -10, + "origin_slot": 0, + "target_id": 33, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 27, + "origin_id": 33, + "origin_slot": 0, + "target_id": 30, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 29, + "origin_id": 33, + "origin_slot": 0, + "target_id": 34, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 30, + "origin_id": 35, + "origin_slot": 0, + "target_id": 30, + "target_slot": 8, + "type": "INT" + }, + { + "id": 32, + "origin_id": 34, + "origin_slot": 0, + "target_id": 35, + "target_slot": 1, + "type": "INT" + }, + { + "id": 33, + "origin_id": 34, + "origin_slot": 1, + "target_id": 36, + "target_slot": 1, + "type": "INT" + }, + { + "id": 35, + "origin_id": 37, + "origin_slot": 0, + "target_id": 35, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 36, + "origin_id": 37, + "origin_slot": 0, + "target_id": 36, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 37, + "origin_id": 36, + "origin_slot": 0, + "target_id": 30, + "target_slot": 9, + "type": "INT" + }, + { + "id": 38, + "origin_id": 38, + "origin_slot": 0, + "target_id": 33, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 39, + "origin_id": -10, + "origin_slot": 3, + "target_id": 38, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 40, + "origin_id": 39, + "origin_slot": 0, + "target_id": 35, + "target_slot": 0, + "type": "INT" + }, + { + "id": 41, + "origin_id": 40, + "origin_slot": 0, + "target_id": 36, + "target_slot": 0, + "type": "INT" + }, + { + "id": 50, + "origin_id": -10, + "origin_slot": 5, + "target_id": 39, + "target_slot": 0, + "type": "INT" + }, + { + "id": 51, + "origin_id": -10, + "origin_slot": 6, + "target_id": 40, + "target_slot": 0, + "type": "INT" + }, + { + "id": 52, + "origin_id": -10, + "origin_slot": 7, + "target_id": 37, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 53, + "origin_id": -10, + "origin_slot": 8, + "target_id": 27, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 54, + "origin_id": -10, + "origin_slot": 9, + "target_id": 28, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 55, + "origin_id": -10, + "origin_slot": 10, + "target_id": 29, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 56, + "origin_id": -10, + "origin_slot": 11, + "target_id": 30, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 57, + "origin_id": -10, + "origin_slot": 12, + "target_id": 30, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 58, + "origin_id": 41, + "origin_slot": 0, + "target_id": 33, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 59, + "origin_id": -10, + "origin_slot": 0, + "target_id": 41, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 60, + "origin_id": -10, + "origin_slot": 4, + "target_id": 41, + "target_slot": 2, + "type": "FLOAT" + } + ], + "extra": {}, + "category": "Image editing/Edit image", + "description": "Edits images from natural-language instructions using Microsoft Mage-Flow-Edit, supporting up to three reference images." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image Edit (Qwen 2509).json b/blueprints/Image Edit (Qwen 2509).json index f7be322a018..f6c67a2c44f 100644 --- a/blueprints/Image Edit (Qwen 2509).json +++ b/blueprints/Image Edit (Qwen 2509).json @@ -1938,7 +1938,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Edit image", + "category": "Image editing/Edit image", "description": "Edits images from text instructions using Qwen-Image-Edit-2509 with optional Lightning LoRA for few-step sampling." } ] diff --git a/blueprints/Image Edit (Qwen 2511).json b/blueprints/Image Edit (Qwen 2511).json index 1aa7e576587..89ce092af55 100644 --- a/blueprints/Image Edit (Qwen 2511).json +++ b/blueprints/Image Edit (Qwen 2511).json @@ -1468,7 +1468,7 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Edit image", + "category": "Image editing/Edit image", "description": "Edits images via text instructions using Qwen-Image-Edit-2511 with improved character consistency and integrated LoRA." } ] @@ -1490,4 +1490,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/blueprints/Image Face Detection (Mediapipe).json b/blueprints/Image Face Detection (Mediapipe).json index e2548d485e9..275306f5668 100644 --- a/blueprints/Image Face Detection (Mediapipe).json +++ b/blueprints/Image Face Detection (Mediapipe).json @@ -771,9 +771,9 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Face Detection" + "category": "Preprocessors/Face" } ] }, "extra": {} -} \ No newline at end of file +} diff --git a/blueprints/Image Inpainting (Flux.1 Fill Dev).json b/blueprints/Image Inpainting (Flux.1 Fill Dev).json index c1326ed3d41..ebecf1d733a 100644 --- a/blueprints/Image Inpainting (Flux.1 Fill Dev).json +++ b/blueprints/Image Inpainting (Flux.1 Fill Dev).json @@ -1188,7 +1188,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Inpaint image", + "category": "Image editing/Inpaint image", "description": "Inpaints masked image regions using Flux.1 fill [dev], Black Forest Labs' inpainting/outpainting model." } ] diff --git a/blueprints/Image Inpainting (Qwen-image).json b/blueprints/Image Inpainting (Qwen-image).json index a06d57e193b..0db98a637c4 100644 --- a/blueprints/Image Inpainting (Qwen-image).json +++ b/blueprints/Image Inpainting (Qwen-image).json @@ -1548,7 +1548,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Inpaint image", + "category": "Image editing/Inpaint image", "description": "Inpaints masked regions using Qwen-Image, extending its multilingual text rendering to inpainting tasks." }, { @@ -1925,4 +1925,4 @@ "workflowRendererVersion": "LG" }, "version": 0.4 -} \ No newline at end of file +} diff --git a/blueprints/Image Normal Estimation (Marigold V2).json b/blueprints/Image Normal Estimation (Marigold V2).json new file mode 100644 index 00000000000..4da5e981be8 --- /dev/null +++ b/blueprints/Image Normal Estimation (Marigold V2).json @@ -0,0 +1,1178 @@ +{ + "revision": 0, + "last_node_id": "91", + "last_link_id": 0, + "nodes": [ + { + "id": 91, + "type": "fd1bad31-6e21-4106-8e7b-85465a9381f6", + "pos": [ + -1380, + 4190 + ], + "size": [ + 380, + 350 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": null + }, + { + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + }, + { + "name": "prediction", + "type": "COMBO", + "widget": { + "name": "prediction" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "name": "conditioning_name", + "type": "COMBO", + "widget": { + "name": "conditioning_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "title": "Image Normal Estimation (Marigold V2)", + "properties": { + "previewExposures": [] + }, + "widgets_values": [ + "euler", + "0.5, 0", + "normals", + "qwen_image_edit_2509_int8_convrot.safetensors", + "marigold_v2_normals.safetensors", + "marigold_v2_normals_vae.safetensors", + "marigold_v2_normals_conditioning.safetensors" + ], + "widgets_values_named": { + "sampler_name": "euler", + "sigmas": "0.5, 0", + "prediction": "normals", + "unet_name": "qwen_image_edit_2509_int8_convrot.safetensors", + "lora_name": "marigold_v2_normals.safetensors", + "vae_name": "marigold_v2_normals_vae.safetensors", + "conditioning_name": "marigold_v2_normals_conditioning.safetensors" + } + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "fd1bad31-6e21-4106-8e7b-85465a9381f6", + "version": 1, + "state": { + "lastGroupId": 4, + "lastNodeId": 91, + "lastLinkId": 110, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image Normal Estimation (Marigold V2)", + "inputNode": { + "id": -10, + "bounding": [ + -2110, + 4550, + 155.080078125, + 208 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 460, + 4481, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "945e443f-b9a3-41a0-80ff-205d4ea6b550", + "name": "pixels", + "type": "IMAGE", + "linkIds": [ + 17 + ], + "localized_name": "pixels", + "pos": [ + -1978.919921875, + 4574 + ] + }, + { + "id": "f10970ae-bbd8-4d4d-8c86-5afe1f2673d4", + "name": "sampler_name", + "type": "COMBO", + "linkIds": [ + 35 + ], + "pos": [ + -1978.919921875, + 4594 + ] + }, + { + "id": "e7f0b98f-40fa-4f71-8dff-0b1d9b3ba526", + "name": "sigmas", + "type": "STRING", + "linkIds": [ + 36 + ], + "pos": [ + -1978.919921875, + 4614 + ] + }, + { + "id": "663f265f-75bd-45b0-9457-39dc96d68cce", + "name": "prediction", + "type": "COMBO", + "linkIds": [ + 37 + ], + "pos": [ + -1978.919921875, + 4634 + ] + }, + { + "id": "e94ec076-d8e7-463c-b6e5-35c90c36bc92", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 38 + ], + "pos": [ + -1978.919921875, + 4654 + ] + }, + { + "id": "b834c8bb-b51a-4a9b-b24c-7d39d8e2b27a", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 39 + ], + "pos": [ + -1978.919921875, + 4674 + ] + }, + { + "id": "d53a2944-e126-4e41-a025-98e126db2c57", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 40 + ], + "pos": [ + -1978.919921875, + 4694 + ] + }, + { + "id": "7b8c34b9-7565-4278-9eef-b331045db837", + "name": "conditioning_name", + "type": "COMBO", + "linkIds": [ + 41 + ], + "pos": [ + -1978.919921875, + 4714 + ] + } + ], + "outputs": [ + { + "id": "bdf62bcc-0f6b-4a29-bdae-b2c16e657e76", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 30 + ], + "localized_name": "IMAGE", + "pos": [ + 484, + 4505 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 26, + "type": "UNETLoader", + "pos": [ + -1730, + 4080 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 38 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 5 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "qwen_image_edit_2509_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/diffusion_models/qwen_image_edit_2509_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "qwen_image_edit_2509_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "qwen_image_edit_2509_int8_convrot.safetensors", + "weight_dtype": "default" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 27, + "type": "LoraLoaderModelOnly", + "pos": [ + -1730, + 4240 + ], + "size": [ + 610, + 120 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 5 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 39 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "marigold_v2_normals.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/loras/marigold_v2_normals.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "marigold_v2_normals.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "marigold_v2_normals.safetensors", + "strength_model": 1 + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 28, + "type": "ModelSamplingAuraFlow", + "pos": [ + -1040, + 4390 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 8 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + }, + { + "localized_name": "sampling", + "name": "sampling", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "sampling" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingAuraFlow" + }, + "widgets_values": [ + 1.73, + "flow" + ], + "widgets_values_named": { + "shift": 1.73, + "sampling": "flow" + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 29, + "type": "ConditioningLoader", + "pos": [ + -1730, + 4580 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning_name", + "name": "conditioning_name", + "type": "COMBO", + "widget": { + "name": "conditioning_name" + }, + "link": 41 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 13 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningLoader", + "models": [ + { + "name": "marigold_v2_normals_conditioning.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/embeddings/marigold_v2_normals_conditioning.safetensors", + "directory": "embeddings" + } + ] + }, + "widgets_values": [ + "marigold_v2_normals_conditioning.safetensors" + ], + "widgets_values_named": { + "conditioning_name": "marigold_v2_normals_conditioning.safetensors" + }, + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 30, + "type": "VAELoader", + "pos": [ + -1730, + 4430 + ], + "size": [ + 610, + 90 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 40 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 18, + 21 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "marigold_v2_normals_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/marigold-v2-0/resolve/main/vae/marigold_v2_normals_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "marigold_v2_normals_vae.safetensors" + ], + "widgets_values_named": { + "vae_name": "marigold_v2_normals_vae.safetensors" + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 31, + "type": "DisableNoise", + "pos": [ + -740, + 4160 + ], + "size": [ + 270, + 40 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 26 + ] + } + ], + "properties": { + "Node name for S&R": "DisableNoise" + } + }, + { + "id": 32, + "type": "KSamplerSelect", + "pos": [ + -740, + 4270 + ], + "size": [ + 270, + 70 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": 35 + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 25 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect" + }, + "widgets_values": [ + "euler" + ], + "widgets_values_named": { + "sampler_name": "euler" + } + }, + { + "id": 33, + "type": "BasicGuider", + "pos": [ + -740, + 4400 + ], + "size": [ + 270, + 60 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 12 + }, + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 13 + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "BasicGuider" + } + }, + { + "id": 34, + "type": "ManualSigmas", + "pos": [ + -740, + 4510 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": 36 + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 27 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas" + }, + "widgets_values": [ + "0.5, 0" + ], + "widgets_values_named": { + "sigmas": "0.5, 0" + } + }, + { + "id": 35, + "type": "SamplerCustomAdvanced", + "pos": [ + -370, + 4290 + ], + "size": [ + 230, + 120 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 26 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 14 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 25 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 27 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 19 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 20 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced" + } + }, + { + "id": 36, + "type": "VAEEncode", + "pos": [ + -1550, + 4860 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": 17 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 18 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncode" + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 37, + "type": "MarigoldV2PostProcess", + "pos": [ + 50, + 4250 + ], + "size": [ + 280, + 70 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 23 + }, + { + "localized_name": "prediction", + "name": "prediction", + "type": "COMBO", + "widget": { + "name": "prediction" + }, + "link": 37 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 30 + ] + } + ], + "properties": { + "Node name for S&R": "MarigoldV2PostProcess" + }, + "widgets_values": [ + "depth" + ], + "widgets_values_named": { + "prediction": "depth" + } + }, + { + "id": 38, + "type": "VAEDecode", + "pos": [ + 60, + 4130 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 20 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 21 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + }, + "color": "#322", + "bgcolor": "#533" + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -1740, + 4010, + 630, + 686 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Image Encoding", + "bounding": [ + -1740, + 4720, + 630, + 300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Sampling", + "bounding": [ + -1080, + 4010, + 1030, + 690 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Decoding", + "bounding": [ + -20, + 4010, + 420, + 390 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 5, + "origin_id": 26, + "origin_slot": 0, + "target_id": 27, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 8, + "origin_id": 27, + "origin_slot": 0, + "target_id": 28, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 12, + "origin_id": 28, + "origin_slot": 0, + "target_id": 33, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 13, + "origin_id": 29, + "origin_slot": 0, + "target_id": 33, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 26, + "origin_id": 31, + "origin_slot": 0, + "target_id": 35, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 14, + "origin_id": 33, + "origin_slot": 0, + "target_id": 35, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 25, + "origin_id": 32, + "origin_slot": 0, + "target_id": 35, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 27, + "origin_id": 34, + "origin_slot": 0, + "target_id": 35, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 19, + "origin_id": 36, + "origin_slot": 0, + "target_id": 35, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 18, + "origin_id": 30, + "origin_slot": 0, + "target_id": 36, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 23, + "origin_id": 38, + "origin_slot": 0, + "target_id": 37, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 20, + "origin_id": 35, + "origin_slot": 0, + "target_id": 38, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 21, + "origin_id": 30, + "origin_slot": 0, + "target_id": 38, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 17, + "origin_id": -10, + "origin_slot": 0, + "target_id": 36, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 30, + "origin_id": 37, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 35, + "origin_id": -10, + "origin_slot": 1, + "target_id": 32, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 36, + "origin_id": -10, + "origin_slot": 2, + "target_id": 34, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 37, + "origin_id": -10, + "origin_slot": 3, + "target_id": 37, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 38, + "origin_id": -10, + "origin_slot": 4, + "target_id": 26, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 39, + "origin_id": -10, + "origin_slot": 5, + "target_id": 27, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 40, + "origin_id": -10, + "origin_slot": 6, + "target_id": 30, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 41, + "origin_id": -10, + "origin_slot": 7, + "target_id": 29, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Preprocessors/Normal", + "description": "Estimates surface normals from an input image using Marigold V2, producing a normal map of surface orientation for relighting and geometry-aware processing." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image Outpainting (Qwen-Image).json b/blueprints/Image Outpainting (Qwen-Image).json index 6c07227c0b6..32ab02411de 100644 --- a/blueprints/Image Outpainting (Qwen-Image).json +++ b/blueprints/Image Outpainting (Qwen-Image).json @@ -1919,7 +1919,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Outpaint image", + "category": "Image editing/Outpaint image", "description": "Outpaints beyond image boundaries using Qwen-Image's outpainting capabilities." }, { @@ -2752,4 +2752,4 @@ } }, "version": 0.4 -} \ No newline at end of file +} diff --git a/blueprints/Image Segmentation (SAM3).json b/blueprints/Image Segmentation (SAM3).json index a2ef40ac8a8..0a56f9cc61d 100644 --- a/blueprints/Image Segmentation (SAM3).json +++ b/blueprints/Image Segmentation (SAM3).json @@ -703,7 +703,7 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Segmentation & Mask", + "category": "Preprocessors/Segmentation", "description": "Segments images into masks using Meta SAM3 from text prompts, points, or boxes." } ] diff --git a/blueprints/Image Style Reference (Krea-2 Turbo).json b/blueprints/Image Style Reference (Krea-2 Turbo).json new file mode 100644 index 00000000000..7224698135d --- /dev/null +++ b/blueprints/Image Style Reference (Krea-2 Turbo).json @@ -0,0 +1,2470 @@ +{ + "revision": 0, + "last_node_id": "100", + "last_link_id": 0, + "nodes": [ + { + "id": 100, + "type": "919de0b5-6d2b-4e3f-924a-11e077547229", + "pos": [ + -1780, + 1470 + ], + "size": [ + 560, + 1010 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "label": "ref_image1", + "name": "image1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "ref_image2", + "name": "image2", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "ref_image3", + "name": "image3", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "prompt", + "name": "value_2", + "type": "STRING", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "label": "prompt_enhance", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "LLM_thinking_mode", + "name": "thinking_1", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "thinking_1" + }, + "link": null + }, + { + "label": "LLM_max_token", + "name": "max_length_1", + "type": "INT", + "widget": { + "name": "max_length_1" + }, + "link": null + }, + { + "label": "width", + "name": "width_1", + "type": "INT", + "widget": { + "name": "width_1" + }, + "link": null + }, + { + "label": "height", + "name": "height_1", + "type": "INT", + "widget": { + "name": "height_1" + }, + "link": null + }, + { + "label": "seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "label": "lora_name", + "name": "lora_name_1", + "type": "COMBO", + "widget": { + "name": "lora_name_1" + }, + "link": null + }, + { + "label": "lora_strength", + "name": "strength_model_1", + "type": "FLOAT", + "widget": { + "name": "strength_model_1" + }, + "link": null + }, + { + "label": "unet_name", + "name": "unet_name_1", + "type": "COMBO", + "widget": { + "name": "unet_name_1" + }, + "link": null + }, + { + "label": "clip_name", + "name": "clip_name_1", + "type": "COMBO", + "widget": { + "name": "clip_name_1" + }, + "link": null + }, + { + "label": "vae_name", + "name": "vae_name_1", + "type": "COMBO", + "widget": { + "name": "vae_name_1" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.25.0", + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "3", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + false, + false, + 512, + 1024, + 1024, + 251161685108094, + "krea2_style_reference.safetensors", + 1, + "krea2_turbo_int8_convrot.safetensors", + "qwen3vl_4b_fp8_scaled.safetensors", + "qwen_image_vae.safetensors" + ], + "widgets_values_named": { + "value_2": "", + "value": false, + "thinking_1": false, + "max_length_1": 512, + "width_1": 1024, + "height_1": 1024, + "noise_seed": 251161685108094, + "lora_name_1": "krea2_style_reference.safetensors", + "strength_model_1": 1, + "unet_name_1": "krea2_turbo_int8_convrot.safetensors", + "clip_name_1": "qwen3vl_4b_fp8_scaled.safetensors", + "vae_name_1": "qwen_image_vae.safetensors" + }, + "title": "Image Style Reference (Krea-2 Turbo)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "919de0b5-6d2b-4e3f-924a-11e077547229", + "version": 1, + "state": { + "lastGroupId": 10, + "lastNodeId": 100, + "lastLinkId": 159, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image Style Reference (Krea-2 Turbo)", + "inputNode": { + "id": -10, + "bounding": [ + -1626.4291338614855, + 423.5393823216258, + 160.76953125, + 348 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 2570, + 340, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "90bc17db-52ea-4cb8-b0b3-8335545bbf3f", + "name": "image1", + "type": "IMAGE", + "linkIds": [ + 78 + ], + "label": "ref_image1", + "pos": [ + -1489.6596026114855, + 447.5393823216258 + ] + }, + { + "id": "19c0d064-c357-4da4-9f89-2572f5721ceb", + "name": "image2", + "type": "IMAGE", + "linkIds": [ + 139 + ], + "label": "ref_image2", + "pos": [ + -1489.6596026114855, + 467.5393823216258 + ] + }, + { + "id": "5069b6eb-f363-480f-aaa2-f4007adaac80", + "name": "image3", + "type": "IMAGE", + "linkIds": [ + 140 + ], + "label": "ref_image3", + "pos": [ + -1489.6596026114855, + 487.5393823216258 + ] + }, + { + "id": "8f306a57-17a6-46e0-aae6-7e848af2aed2", + "name": "value_2", + "type": "STRING", + "linkIds": [ + 141 + ], + "label": "prompt", + "pos": [ + -1489.6596026114855, + 507.5393823216258 + ] + }, + { + "id": "cde648e2-17b0-47f6-a759-8ab44f2b86f0", + "name": "value", + "type": "BOOLEAN", + "linkIds": [ + 142 + ], + "label": "prompt_enhance", + "pos": [ + -1489.6596026114855, + 527.5393823216258 + ] + }, + { + "id": "a24d442c-07c4-4e9c-b622-bac15e0d96c3", + "name": "thinking_1", + "type": "BOOLEAN", + "linkIds": [ + 143 + ], + "label": "LLM_thinking_mode", + "pos": [ + -1489.6596026114855, + 547.5393823216258 + ] + }, + { + "id": "5045e3c6-8452-4a4a-bb16-94420751c0a3", + "name": "max_length_1", + "type": "INT", + "linkIds": [ + 144 + ], + "label": "LLM_max_token", + "pos": [ + -1489.6596026114855, + 567.5393823216258 + ] + }, + { + "id": "86cf803f-35fa-429d-b653-a68fe678991a", + "name": "width_1", + "type": "INT", + "linkIds": [ + 146, + 147, + 148 + ], + "label": "width", + "pos": [ + -1489.6596026114855, + 587.5393823216258 + ] + }, + { + "id": "5319fce4-7ad4-4d16-b318-ce663681e28f", + "name": "height_1", + "type": "INT", + "linkIds": [ + 149, + 150, + 151 + ], + "label": "height", + "pos": [ + -1489.6596026114855, + 607.5393823216258 + ] + }, + { + "id": "50e8a2a0-7665-415e-b0a4-3a96e64761c5", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 152 + ], + "label": "seed", + "pos": [ + -1489.6596026114855, + 627.5393823216258 + ] + }, + { + "id": "e4ff14b0-c236-4793-8004-017ff85bfb5c", + "name": "lora_name_1", + "type": "COMBO", + "linkIds": [ + 153 + ], + "label": "lora_name", + "pos": [ + -1489.6596026114855, + 647.5393823216258 + ] + }, + { + "id": "f6e1825d-feed-411c-ae87-b4c57993d9a9", + "name": "strength_model_1", + "type": "FLOAT", + "linkIds": [ + 154 + ], + "label": "lora_strength", + "pos": [ + -1489.6596026114855, + 667.5393823216258 + ] + }, + { + "id": "7d0b1ba3-f501-4c61-bf27-ab4f7c190c31", + "name": "unet_name_1", + "type": "COMBO", + "linkIds": [ + 155 + ], + "label": "unet_name", + "pos": [ + -1489.6596026114855, + 687.5393823216258 + ] + }, + { + "id": "b5aceaef-73e1-4ce9-8092-4658cb0772f5", + "name": "clip_name_1", + "type": "COMBO", + "linkIds": [ + 156 + ], + "label": "clip_name", + "pos": [ + -1489.6596026114855, + 707.5393823216258 + ] + }, + { + "id": "e5ec3c31-24b4-4881-9347-fde8de16b98c", + "name": "vae_name_1", + "type": "COMBO", + "linkIds": [ + 157 + ], + "label": "vae_name", + "pos": [ + -1489.6596026114855, + 727.5393823216258 + ] + } + ], + "outputs": [ + { + "id": "e4073813-752d-4584-96b5-6e42eac355dd", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 98 + ], + "localized_name": "IMAGE", + "pos": [ + 2594, + 364 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 77, + "type": "EmptyLatentImage", + "pos": [ + 170, + 350 + ], + "size": [ + 320, + 170 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 146 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 149 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ], + "widgets_values_named": { + "width": 1024, + "height": 1024, + "batch_size": 1 + } + }, + { + "id": 78, + "type": "UNETLoader", + "pos": [ + -410, + -240 + ], + "size": [ + 450, + 140 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 155 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "krea2_turbo_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "krea2_turbo_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "krea2_turbo_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 79, + "type": "CLIPLoader", + "pos": [ + -410, + 150 + ], + "size": [ + 450, + 170 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 156 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 18, + 73 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "qwen3vl_4b_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_4b_fp8_scaled.safetensors", + "krea2", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_4b_fp8_scaled.safetensors", + "type": "krea2", + "device": "default" + } + }, + { + "id": 80, + "type": "VAELoader", + "pos": [ + -410, + 390 + ], + "size": [ + 450, + 120 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 157 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 103, + 120 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "qwen_image_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ], + "widgets_values_named": { + "vae_name": "qwen_image_vae.safetensors" + } + }, + { + "id": 81, + "type": "ConditioningZeroOut", + "pos": [ + 350, + 230 + ], + "size": [ + 230, + 0 + ], + "flags": { + "collapsed": true + }, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 77 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 97 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut", + "cnr_id": "comfy-core", + "ver": "0.25.0" + } + }, + { + "id": 82, + "type": "LoraLoaderModelOnly", + "pos": [ + -410, + -50 + ], + "size": [ + 450, + 170 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 16 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 153 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": 154 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 95 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "krea2_style_reference.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/loras/krea2_style_reference.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "krea2_style_reference.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "krea2_style_reference.safetensors", + "strength_model": 1 + } + }, + { + "id": 83, + "type": "TextGenerate", + "pos": [ + 60, + 650 + ], + "size": [ + 400, + 420 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 18 + }, + { + "localized_name": "image", + "name": "image", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 19 + }, + { + "localized_name": "max_length", + "name": "max_length", + "type": "INT", + "widget": { + "name": "max_length" + }, + "link": 144 + }, + { + "localized_name": "Sampling Mode", + "name": "sampling_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "sampling_mode" + }, + "link": null + }, + { + "localized_name": "temperature", + "name": "sampling_mode.temperature", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.temperature" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "sampling_mode.top_k", + "type": "INT", + "widget": { + "name": "sampling_mode.top_k" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "sampling_mode.top_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.top_p" + }, + "link": null + }, + { + "localized_name": "min_p", + "name": "sampling_mode.min_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.min_p" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "sampling_mode.repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.repetition_penalty" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "sampling_mode.seed", + "type": "INT", + "widget": { + "name": "sampling_mode.seed" + }, + "link": null + }, + { + "localized_name": "presence_penalty", + "name": "sampling_mode.presence_penalty", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "sampling_mode.presence_penalty" + }, + "link": null + }, + { + "localized_name": "thinking", + "name": "thinking", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "thinking" + }, + "link": 143 + }, + { + "localized_name": "use_default_template", + "name": "use_default_template", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "use_default_template" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "generated_text", + "name": "generated_text", + "type": "STRING", + "links": [ + 33 + ] + } + ], + "properties": { + "Node name for S&R": "TextGenerate", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "", + 512, + "on", + 0.7, + 64, + 0.95, + 0.05, + 1.05, + 0, + 0, + false, + true + ], + "widgets_values_named": { + "prompt": "", + "max_length": 512, + "sampling_mode": "on", + "sampling_mode.temperature": 0.7, + "sampling_mode.top_k": 64, + "sampling_mode.top_p": 0.95, + "sampling_mode.min_p": 0.05, + "sampling_mode.repetition_penalty": 1.05, + "sampling_mode.seed": 0, + "sampling_mode.presence_penalty": 0, + "thinking": false, + "use_default_template": true + } + }, + { + "id": 84, + "type": "StringConcatenate", + "pos": [ + -210, + 1310 + ], + "size": [ + 230, + 0 + ], + "flags": { + "collapsed": true + }, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 21 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 22 + }, + { + "localized_name": "delimiter", + "name": "delimiter", + "type": "STRING", + "widget": { + "name": "delimiter" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "", + "", + "" + ], + "widgets_values_named": { + "string_a": "", + "string_b": "", + "delimiter": "" + } + }, + { + "id": 85, + "type": "PrimitiveStringMultiline", + "pos": [ + -430, + 650 + ], + "size": [ + 460, + 610 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 21 + ] + } + ], + "title": "Text String (System Prompt)", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "You are an expert prompt engineer for text-to-image models. Your task is to expand the user's prompt into a highly effective image-generation prompt.\n\nThink step by step about the request before writing the answer:\n- What is the subject and mood?\n- What visual styles, mediums, and lighting options would fit? Consider two or three alternatives and pick the one that best serves the caption.\n- What composition, framing, and grounded details will help the text-to-image model?\n\nThen output a single expanded prompt paragraph.\n\nFollow these rules strictly:\n1. **Faithfulness First:** Preserve all original subjects, actions, colors, and spatial relationships. Do not add new objects, props, characters, or animals unless the user clearly implies them.\n2. **Practical T2I Structure:** Write a prompt that a text-to-image model can parse cleanly. Group subjects with their own attributes and actions. Use grounded phrasing for poses, interactions, and spatial layout.\n3. **Style Planning Stays Internal:** Use your internal reasoning to choose style, medium, framing, and lighting. Do not emit planning tags or wrappers in the visible answer body.\n4. **Text Rendering:** If the user requests visible text, quotes, labels, or typography, specify the exact text clearly and wrap requested words in quotes.\n5. **Avoid Over-Specification:** Do not invent highly specific clothing, colors, materials, or scene details unless the input supports them.\n6. **Structure:** Write one cohesive paragraph after the thinking block. No bullets, JSON, or markdown.\n7. **Respect Existing Detail:** If the user's prompt is already detailed, lightly polish and finalize rather than heavily expanding — preserve their phrasing and direction.\n8. **Respect the Human Form:** Treat depictions of people with dignity. Assume clothing covers genitals and intimate anatomy.\n9. **Preserve User Medium:** When the user explicitly requests a medium (e.g. \"photo of\", \"photograph of\", \"illustration of\", \"painting of\", \"sketch of\", \"3D render of\"), honor it. Do not pivot to a different medium to avoid difficulty — match the user's stated intent.\n\nUser's Input:\n\n" + ], + "widgets_values_named": { + "value": "You are an expert prompt engineer for text-to-image models. Your task is to expand the user's prompt into a highly effective image-generation prompt.\n\nThink step by step about the request before writing the answer:\n- What is the subject and mood?\n- What visual styles, mediums, and lighting options would fit? Consider two or three alternatives and pick the one that best serves the caption.\n- What composition, framing, and grounded details will help the text-to-image model?\n\nThen output a single expanded prompt paragraph.\n\nFollow these rules strictly:\n1. **Faithfulness First:** Preserve all original subjects, actions, colors, and spatial relationships. Do not add new objects, props, characters, or animals unless the user clearly implies them.\n2. **Practical T2I Structure:** Write a prompt that a text-to-image model can parse cleanly. Group subjects with their own attributes and actions. Use grounded phrasing for poses, interactions, and spatial layout.\n3. **Style Planning Stays Internal:** Use your internal reasoning to choose style, medium, framing, and lighting. Do not emit planning tags or wrappers in the visible answer body.\n4. **Text Rendering:** If the user requests visible text, quotes, labels, or typography, specify the exact text clearly and wrap requested words in quotes.\n5. **Avoid Over-Specification:** Do not invent highly specific clothing, colors, materials, or scene details unless the input supports them.\n6. **Structure:** Write one cohesive paragraph after the thinking block. No bullets, JSON, or markdown.\n7. **Respect Existing Detail:** If the user's prompt is already detailed, lightly polish and finalize rather than heavily expanding — preserve their phrasing and direction.\n8. **Respect the Human Form:** Treat depictions of people with dignity. Assume clothing covers genitals and intimate anatomy.\n9. **Preserve User Medium:** When the user explicitly requests a medium (e.g. \"photo of\", \"photograph of\", \"illustration of\", \"painting of\", \"sketch of\", \"3D render of\"), honor it. Do not pivot to a different medium to avoid difficulty — match the user's stated intent.\n\nUser's Input:\n\n" + } + }, + { + "id": 86, + "type": "PrimitiveStringMultiline", + "pos": [ + -1050, + -280 + ], + "size": [ + 570, + 800 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "label": "prompt", + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 141 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 22, + 34 + ] + } + ], + "title": "Text String (User Prompt)", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 87, + "type": "PreviewAny", + "pos": [ + 570, + 710 + ], + "size": [ + 470, + 550 + ], + "flags": { + "collapsed": false + }, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 27 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 112 + ] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 88, + "type": "ComfySwitchNode", + "pos": [ + 170, + 1270 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 34 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 33 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 32 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 27 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 89, + "type": "PrimitiveBoolean", + "pos": [ + -940, + 750 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 142 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 32 + ] + } + ], + "title": "Boolean (Refine Prompt?)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + } + }, + { + "id": 90, + "type": "TextEncodeQwenImageEditPlus", + "pos": [ + 110, + -250 + ], + "size": [ + 460, + 240 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 73 + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 120 + }, + { + "localized_name": "image1", + "name": "image1", + "shape": 7, + "type": "IMAGE", + "link": 78 + }, + { + "localized_name": "image2", + "name": "image2", + "shape": 7, + "type": "IMAGE", + "link": 139 + }, + { + "localized_name": "image3", + "name": "image3", + "shape": 7, + "type": "IMAGE", + "link": 140 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 112 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 75 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeQwenImageEditPlus", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + "cute" + ], + "widgets_values_named": { + "prompt": "cute" + } + }, + { + "id": 91, + "type": "FluxKontextMultiReferenceLatentMethod", + "pos": [ + 110, + 40 + ], + "size": [ + 460, + 140 + ], + "flags": {}, + "order": 15, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 75 + }, + { + "localized_name": "reference_latents_method", + "name": "reference_latents_method", + "type": "COMBO", + "widget": { + "name": "reference_latents_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 77, + 96 + ] + } + ], + "properties": { + "Node name for S&R": "FluxKontextMultiReferenceLatentMethod", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.5.2", + "input_ue_unconnectable": {} + }, + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "index_timestep_zero" + ], + "widgets_values_named": { + "reference_latents_method": "index_timestep_zero" + }, + "color": "#222", + "bgcolor": "#000" + }, + { + "id": 92, + "type": "CFGGuider", + "pos": [ + 920, + -120 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 86 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 96 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 97 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 88 + ] + } + ], + "properties": { + "Node name for S&R": "CFGGuider", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + 1 + ], + "widgets_values_named": { + "cfg": 1 + } + }, + { + "id": 93, + "type": "SamplerCustomAdvanced", + "pos": [ + 1240, + -270 + ], + "size": [ + 230, + 400 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 87 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 88 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 89 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 90 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 91 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 93 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.28.0" + } + }, + { + "id": 94, + "type": "KSamplerSelect", + "pos": [ + 920, + 50 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 89 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + "euler" + ], + "widgets_values_named": { + "sampler_name": "euler" + } + }, + { + "id": 95, + "type": "BasicScheduler", + "pos": [ + 920, + 180 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 92 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 90 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + "simple", + 8, + 1 + ], + "widgets_values_named": { + "scheduler": "simple", + "steps": 8, + "denoise": 1 + } + }, + { + "id": 96, + "type": "EmptyLatentImage", + "pos": [ + 920, + 360 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 147 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 151 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 91 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ], + "widgets_values_named": { + "width": 1024, + "height": 1024, + "batch_size": 1 + } + }, + { + "id": 97, + "type": "VAEDecode", + "pos": [ + 1750, + 70 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 93 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 103 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 98 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.28.0" + } + }, + { + "id": 98, + "type": "RandomNoise", + "pos": [ + 920, + -260 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 152 + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 87 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + 355028178891957, + "randomize" + ], + "widgets_values_named": { + "noise_seed": 355028178891957, + "control_after_generate": "randomize" + } + }, + { + "id": 99, + "type": "ModelSamplingFlux", + "pos": [ + 620, + 40 + ], + "size": [ + 270, + 210 + ], + "flags": {}, + "order": 22, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 95 + }, + { + "localized_name": "max_shift", + "name": "max_shift", + "type": "FLOAT", + "widget": { + "name": "max_shift" + }, + "link": null + }, + { + "localized_name": "base_shift", + "name": "base_shift", + "type": "FLOAT", + "widget": { + "name": "base_shift" + }, + "link": null + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 148 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 150 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 86, + 92 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingFlux", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + 1.15, + 0.5, + 1024, + 1024 + ], + "widgets_values_named": { + "max_shift": 1.15, + "base_shift": 0.5, + "width": 1024, + "height": 1024 + } + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -430, + -320, + 490, + 850 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Prompt Enhancement", + "bounding": [ + -440, + 580, + 1940, + 840 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "User Prompt", + "bounding": [ + -1080, + -360, + 610, + 910 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Image Generation", + "bounding": [ + -440, + -360, + 1940, + 910 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Prompt", + "bounding": [ + 90, + -320, + 500, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Size", + "bounding": [ + 90, + 270, + 500, + 260 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 9, + "title": "Feature Switch", + "bounding": [ + -1080, + 580, + 610, + 400 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 16, + "origin_id": 78, + "origin_slot": 0, + "target_id": 82, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 18, + "origin_id": 79, + "origin_slot": 0, + "target_id": 83, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 19, + "origin_id": 84, + "origin_slot": 0, + "target_id": 83, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 21, + "origin_id": 85, + "origin_slot": 0, + "target_id": 84, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 22, + "origin_id": 86, + "origin_slot": 0, + "target_id": 84, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 27, + "origin_id": 88, + "origin_slot": 0, + "target_id": 87, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 34, + "origin_id": 86, + "origin_slot": 0, + "target_id": 88, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 33, + "origin_id": 83, + "origin_slot": 0, + "target_id": 88, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 32, + "origin_id": 89, + "origin_slot": 0, + "target_id": 88, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 73, + "origin_id": 79, + "origin_slot": 0, + "target_id": 90, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 75, + "origin_id": 90, + "origin_slot": 0, + "target_id": 91, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 77, + "origin_id": 91, + "origin_slot": 0, + "target_id": 81, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 78, + "origin_id": -10, + "origin_slot": 0, + "target_id": 90, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 86, + "origin_id": 99, + "origin_slot": 0, + "target_id": 92, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 87, + "origin_id": 98, + "origin_slot": 0, + "target_id": 93, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 88, + "origin_id": 92, + "origin_slot": 0, + "target_id": 93, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 89, + "origin_id": 94, + "origin_slot": 0, + "target_id": 93, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 90, + "origin_id": 95, + "origin_slot": 0, + "target_id": 93, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 91, + "origin_id": 96, + "origin_slot": 0, + "target_id": 93, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 92, + "origin_id": 99, + "origin_slot": 0, + "target_id": 95, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 93, + "origin_id": 93, + "origin_slot": 0, + "target_id": 97, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 95, + "origin_id": 82, + "origin_slot": 0, + "target_id": 99, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 96, + "origin_id": 91, + "origin_slot": 0, + "target_id": 92, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 97, + "origin_id": 81, + "origin_slot": 0, + "target_id": 92, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 98, + "origin_id": 97, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 103, + "origin_id": 80, + "origin_slot": 0, + "target_id": 97, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 112, + "origin_id": 87, + "origin_slot": 0, + "target_id": 90, + "target_slot": 5, + "type": "STRING" + }, + { + "id": 120, + "origin_id": 80, + "origin_slot": 0, + "target_id": 90, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 139, + "origin_id": -10, + "origin_slot": 1, + "target_id": 90, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 140, + "origin_id": -10, + "origin_slot": 2, + "target_id": 90, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 141, + "origin_id": -10, + "origin_slot": 3, + "target_id": 86, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 142, + "origin_id": -10, + "origin_slot": 4, + "target_id": 89, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 143, + "origin_id": -10, + "origin_slot": 5, + "target_id": 83, + "target_slot": 14, + "type": "BOOLEAN" + }, + { + "id": 144, + "origin_id": -10, + "origin_slot": 6, + "target_id": 83, + "target_slot": 5, + "type": "INT" + }, + { + "id": 146, + "origin_id": -10, + "origin_slot": 7, + "target_id": 77, + "target_slot": 0, + "type": "INT" + }, + { + "id": 147, + "origin_id": -10, + "origin_slot": 7, + "target_id": 96, + "target_slot": 0, + "type": "INT" + }, + { + "id": 148, + "origin_id": -10, + "origin_slot": 7, + "target_id": 99, + "target_slot": 3, + "type": "INT" + }, + { + "id": 149, + "origin_id": -10, + "origin_slot": 8, + "target_id": 77, + "target_slot": 1, + "type": "INT" + }, + { + "id": 150, + "origin_id": -10, + "origin_slot": 8, + "target_id": 99, + "target_slot": 4, + "type": "INT" + }, + { + "id": 151, + "origin_id": -10, + "origin_slot": 8, + "target_id": 96, + "target_slot": 1, + "type": "INT" + }, + { + "id": 152, + "origin_id": -10, + "origin_slot": 9, + "target_id": 98, + "target_slot": 0, + "type": "INT" + }, + { + "id": 153, + "origin_id": -10, + "origin_slot": 10, + "target_id": 82, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 154, + "origin_id": -10, + "origin_slot": 11, + "target_id": 82, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 155, + "origin_id": -10, + "origin_slot": 12, + "target_id": 78, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 156, + "origin_id": -10, + "origin_slot": 13, + "target_id": 79, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 157, + "origin_id": -10, + "origin_slot": 14, + "target_id": 80, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Image generation/Conditioned", + "description": "Generates images from text using Krea 2 Turbo while matching the style of uploaded reference images, with optional LLM prompt enhancement." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image Upscale(Z-image-Turbo).json b/blueprints/Image Upscale(Z-image-Turbo).json index 25d2838a801..d3990aa3e39 100644 --- a/blueprints/Image Upscale(Z-image-Turbo).json +++ b/blueprints/Image Upscale(Z-image-Turbo).json @@ -1302,7 +1302,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Upscale", + "category": "Image Tools/Upscale", "description": "Upscales images to higher resolution using Z-Image-Turbo." } ] @@ -1312,4 +1312,4 @@ "workflowRendererVersion": "LG" }, "version": 0.4 -} \ No newline at end of file +} diff --git a/blueprints/Image to Layers(Qwen-Image-Layered).json b/blueprints/Image to Layers(Qwen-Image-Layered).json index 4b9bb8cf10d..f2748b4c79f 100644 --- a/blueprints/Image to Layers(Qwen-Image-Layered).json +++ b/blueprints/Image to Layers(Qwen-Image-Layered).json @@ -1676,7 +1676,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Image to layers", + "category": "Image editing/Image to layers", "description": "Decomposes an image into variable-resolution RGBA layers for independent editing using Qwen-Image-Layered." } ] @@ -1691,4 +1691,4 @@ }, "ue_links": [] } -} \ No newline at end of file +} diff --git a/blueprints/Image to Pose Map (SDPose Multi-Person).json b/blueprints/Image to Pose Map (SDPose Multi-Person).json index 38df207754c..292bd17ce37 100644 --- a/blueprints/Image to Pose Map (SDPose Multi-Person).json +++ b/blueprints/Image to Pose Map (SDPose Multi-Person).json @@ -1197,7 +1197,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Conditioning & Preprocessors/Pose", + "category": "Preprocessors/Pose", "description": "Detects multiple people in an image and outputs per-person pose keypoints, skeleton renders, and bounding boxes using SDPose." } ] diff --git a/blueprints/Image to Pose Map (SDPose-OOD).json b/blueprints/Image to Pose Map (SDPose-OOD).json index 76ee9ff4ee3..03d6125653b 100644 --- a/blueprints/Image to Pose Map (SDPose-OOD).json +++ b/blueprints/Image to Pose Map (SDPose-OOD).json @@ -877,7 +877,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Conditioning & Preprocessors/Pose", + "category": "Preprocessors/Pose", "description": "Extracts human pose keypoints and stick-figure visuals from an image using SDPose-OOD, with optional bounding-box input per subject." } ] diff --git a/blueprints/Image to Video (LTX-2.3).json b/blueprints/Image to Video (LTX-2.3).json index 3db524ea0d8..2e5e7d45ee2 100644 --- a/blueprints/Image to Video (LTX-2.3).json +++ b/blueprints/Image to Video (LTX-2.3).json @@ -4223,7 +4223,7 @@ "extra": { "workflowRendererVersion": "Vue-corrected" }, - "category": "Video generation and editing/Image to video", + "category": "Video generation/Image to video", "description": "Generates video from a single input image using LTX-2.3." } ] diff --git a/blueprints/Image to Video (LTX-2.5).json b/blueprints/Image to Video (LTX-2.5).json new file mode 100644 index 00000000000..fffac80f328 --- /dev/null +++ b/blueprints/Image to Video (LTX-2.5).json @@ -0,0 +1,4643 @@ +{ + "revision": 0, + "last_node_id": "452", + "last_link_id": 0, + "nodes": [ + { + "id": 452, + "type": "db725b27-9a1d-4526-b7ac-ace4a7b16b44", + "pos": [ + 2820, + 6890 + ], + "size": [ + 500, + 670 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "label": "first_frame", + "name": "input", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "prompt", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "prompt_enhance", + "name": "value_1", + "type": "BOOLEAN", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "duration", + "name": "value_2", + "type": "INT", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "label": "width", + "name": "value_3", + "type": "INT", + "widget": { + "name": "value_3" + }, + "link": null + }, + { + "label": "height", + "name": "value_4", + "type": "INT", + "widget": { + "name": "value_4" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "label": "frame_rate", + "name": "value_5", + "type": "INT", + "widget": { + "name": "value_5" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "label": "video_vae", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "audio_vae", + "name": "vae_name_1", + "type": "COMBO", + "widget": { + "name": "vae_name_1" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "label": "upscale_model", + "name": "model_name", + "type": "COMBO", + "widget": { + "name": "model_name" + }, + "link": null + }, + { + "label": "prompt_enhance_model", + "name": "clip_name_1", + "type": "COMBO", + "widget": { + "name": "clip_name_1" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.30.0", + "previewExposures": [] + }, + "widgets_values": [ + "", + false, + 5, + 1280, + 720, + 875362541677469, + 24, + "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "ltx-2.5-video-vae-bf16.safetensors", + "ltx-2.5-audio-vae-bf16.safetensors", + "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "gemma4_e2b_it_int8_convrot.safetensors" + ], + "widgets_values_named": { + "value": "", + "value_1": false, + "value_2": 5, + "value_3": 1280, + "value_4": 720, + "noise_seed": 875362541677469, + "value_5": 24, + "unet_name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "vae_name": "ltx-2.5-video-vae-bf16.safetensors", + "vae_name_1": "ltx-2.5-audio-vae-bf16.safetensors", + "clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "model_name": "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "clip_name_1": "gemma4_e2b_it_int8_convrot.safetensors" + }, + "title": "Image to Video (LTX-2.5)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "db725b27-9a1d-4526-b7ac-ace4a7b16b44", + "version": 1, + "state": { + "lastGroupId": 27, + "lastNodeId": 452, + "lastLinkId": 784, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image to Video (LTX-2.5)", + "inputNode": { + "id": -10, + "bounding": [ + -640, + 6460, + 183.814453125, + 328 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 5540, + 6496, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "075be400-91e7-40a7-8f4e-d5c907af36e8", + "name": "input", + "type": "IMAGE,MASK", + "linkIds": [ + 763 + ], + "label": "first_frame", + "pos": [ + -480.185546875, + 6484 + ] + }, + { + "id": "5dab3f68-2044-46e7-b01f-4bf4b8bf4e79", + "name": "value", + "type": "STRING", + "linkIds": [ + 765 + ], + "label": "prompt", + "pos": [ + -480.185546875, + 6504 + ] + }, + { + "id": "21c9d37e-1f04-47f8-b752-22667180790c", + "name": "value_1", + "type": "BOOLEAN", + "linkIds": [ + 766 + ], + "label": "prompt_enhance", + "pos": [ + -480.185546875, + 6524 + ] + }, + { + "id": "00f26a0c-569d-4aa1-aa8a-281ad2f3c4e0", + "name": "value_2", + "type": "INT", + "linkIds": [ + 767 + ], + "label": "duration", + "pos": [ + -480.185546875, + 6544 + ] + }, + { + "id": "5d05d885-6c96-43dd-9cdb-f1d0c48045b5", + "name": "value_3", + "type": "INT", + "linkIds": [ + 768 + ], + "label": "width", + "pos": [ + -480.185546875, + 6564 + ] + }, + { + "id": "c646ee59-a1e0-433e-b2f5-f1d8217fe526", + "name": "value_4", + "type": "INT", + "linkIds": [ + 769 + ], + "label": "height", + "pos": [ + -480.185546875, + 6584 + ] + }, + { + "id": "7072a79b-5168-42ee-9088-e3f92ac07791", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 770 + ], + "pos": [ + -480.185546875, + 6604 + ] + }, + { + "id": "85a2fb6d-1b63-40a7-adb6-5acc97a7cc99", + "name": "value_5", + "type": "INT", + "linkIds": [ + 776 + ], + "label": "frame_rate", + "pos": [ + -480.185546875, + 6624 + ] + }, + { + "id": "cbb7ddb9-95d0-4e63-a473-f37c329fbcda", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 777 + ], + "pos": [ + -480.185546875, + 6644 + ] + }, + { + "id": "dd0f8943-da6e-4182-8265-efb74da42c33", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 778 + ], + "label": "video_vae", + "pos": [ + -480.185546875, + 6664 + ] + }, + { + "id": "69b9a13b-ee5a-4525-abd9-281d4c257277", + "name": "vae_name_1", + "type": "COMBO", + "linkIds": [ + 779 + ], + "label": "audio_vae", + "pos": [ + -480.185546875, + 6684 + ] + }, + { + "id": "de6a02d4-1e94-43e7-bf67-2e06f8688943", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 780 + ], + "pos": [ + -480.185546875, + 6704 + ] + }, + { + "id": "83db7bd8-4276-4950-8d15-ee1803e29646", + "name": "model_name", + "type": "COMBO", + "linkIds": [ + 781 + ], + "label": "upscale_model", + "pos": [ + -480.185546875, + 6724 + ] + }, + { + "id": "e5ca018e-1483-44ba-a318-3fc83c7f19ba", + "name": "clip_name_1", + "type": "COMBO", + "linkIds": [ + 782 + ], + "label": "prompt_enhance_model", + "pos": [ + -480.185546875, + 6744 + ] + } + ], + "outputs": [ + { + "id": "b92869b1-7119-47c1-8a9c-c52c131eb1f1", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 714 + ], + "localized_name": "VIDEO", + "pos": [ + 5564, + 6520 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 405, + "type": "CLIPLoader", + "pos": [ + 120, + 5290 + ], + "size": [ + 370, + 120 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 782 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 749 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "gemma4_e2b_it_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/gemma-4/resolve/main/text_encoders/gemma4_e2b_it_int8_convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "gemma4_e2b_it_int8_convrot.safetensors", + "ltxv", + "default" + ], + "widgets_values_named": { + "clip_name": "gemma4_e2b_it_int8_convrot.safetensors", + "type": "ltxv", + "device": "default" + } + }, + { + "id": 406, + "type": "TextGenerateLTX2Prompt", + "pos": [ + 570, + 5280 + ], + "size": [ + 400, + 420 + ], + "flags": { + "collapsed": false + }, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 749 + }, + { + "localized_name": "image", + "name": "image", + "shape": 7, + "type": "IMAGE", + "link": 757 + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 723 + }, + { + "localized_name": "max_length", + "name": "max_length", + "type": "INT", + "widget": { + "name": "max_length" + }, + "link": null + }, + { + "localized_name": "Sampling Mode", + "name": "sampling_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "sampling_mode" + }, + "link": null + }, + { + "localized_name": "temperature", + "name": "sampling_mode.temperature", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.temperature" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "sampling_mode.top_k", + "type": "INT", + "widget": { + "name": "sampling_mode.top_k" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "sampling_mode.top_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.top_p" + }, + "link": null + }, + { + "localized_name": "min_p", + "name": "sampling_mode.min_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.min_p" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "sampling_mode.repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.repetition_penalty" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "sampling_mode.seed", + "type": "INT", + "widget": { + "name": "sampling_mode.seed" + }, + "link": null + }, + { + "localized_name": "presence_penalty", + "name": "sampling_mode.presence_penalty", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "sampling_mode.presence_penalty" + }, + "link": null + }, + { + "localized_name": "thinking", + "name": "thinking", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "thinking" + }, + "link": null + }, + { + "localized_name": "use_default_template", + "name": "use_default_template", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "use_default_template" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "generated_text", + "name": "generated_text", + "type": "STRING", + "links": [ + 724 + ] + } + ], + "properties": { + "Node name for S&R": "TextGenerateLTX2Prompt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "", + 600, + "on", + 0.7, + 64, + 0.95, + 0.05, + 1.15, + 0, + 0, + false, + true + ], + "widgets_values_named": { + "prompt": "", + "max_length": 600, + "sampling_mode": "on", + "sampling_mode.temperature": 0.7, + "sampling_mode.top_k": 64, + "sampling_mode.top_p": 0.95, + "sampling_mode.min_p": 0.05, + "sampling_mode.repetition_penalty": 1.15, + "sampling_mode.seed": 0, + "sampling_mode.presence_penalty": 0, + "thinking": false, + "use_default_template": true + } + }, + { + "id": 407, + "type": "PrimitiveBoolean", + "pos": [ + 140, + 5760 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 766 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 722 + ] + } + ], + "title": "Boolean (Enable Prompt Enhance)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + }, + { + "id": 408, + "type": "CLIPTextEncode", + "pos": [ + 1160, + 6040 + ], + "size": [ + 550, + 740 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 732 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 721 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 696 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 409, + "type": "PrimitiveStringMultiline", + "pos": [ + 150, + 6080 + ], + "size": [ + 370, + 350 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 765 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 720, + 723 + ] + } + ], + "title": "Prompt", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + } + }, + { + "id": 410, + "type": "ComfySwitchNode", + "pos": [ + 1100, + 5690 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 720 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 724 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 722 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 721, + 725 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 411, + "type": "UNETLoader", + "pos": [ + 650, + 6200 + ], + "size": [ + 420, + 130 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 777 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 771, + 772 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 412, + "type": "PreviewAny", + "pos": [ + 1410, + 5300 + ], + "size": [ + 510, + 360 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 725 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 413, + "type": "PrimitiveInt", + "pos": [ + 150, + 6630 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 767 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 715 + ] + } + ], + "title": "Duration", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 5, + "fixed" + ], + "widgets_values_named": { + "value": 5, + "fixed": "fixed" + } + }, + { + "id": 414, + "type": "PrimitiveBoolean", + "pos": [ + 160, + 6480 + ], + "size": [ + 370, + 100 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 683, + 693 + ] + } + ], + "title": "Switch to Text to Video?", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.16.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + } + }, + { + "id": 415, + "type": "LTXVConditioning", + "pos": [ + 1810, + 6480 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 696 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 697 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT", + "widget": { + "name": "frame_rate" + }, + "link": 698 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 735, + 755 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 736, + 756 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConditioning", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 24 + ], + "widgets_values_named": { + "frame_rate": 24 + } + }, + { + "id": 416, + "type": "VAELoader", + "pos": [ + 650, + 6410 + ], + "size": [ + 410, + 70 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 778 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 727, + 773, + 774, + 775 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-video-vae-bf16.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-video-vae-bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ltx-2.5-video-vae-bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "ltx-2.5-video-vae-bf16.safetensors" + } + }, + { + "id": 417, + "type": "VAELoader", + "pos": [ + 650, + 6560 + ], + "size": [ + 400, + 90 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 779 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 729, + 730 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-audio-vae-bf16.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-audio-vae-bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ltx-2.5-audio-vae-bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "ltx-2.5-audio-vae-bf16.safetensors" + } + }, + { + "id": 418, + "type": "PrimitiveInt", + "pos": [ + 150, + 6780 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 768 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 687 + ] + } + ], + "title": "Width", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 1280, + "fixed" + ], + "widgets_values_named": { + "value": 1280, + "fixed": "fixed" + } + }, + { + "id": 419, + "type": "CLIPTextEncode", + "pos": [ + 1170, + 6880 + ], + "size": [ + 530, + 240 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 731 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 697 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "pc game, console game, video game, cartoon, childish, ugly" + ], + "widgets_values_named": { + "text": "pc game, console game, video game, cartoon, childish, ugly" + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 420, + "type": "CLIPLoader", + "pos": [ + 650, + 6730 + ], + "size": [ + 400, + 120 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 780 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 731, + 732 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/text_encoders/gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "ltxv", + "default" + ], + "widgets_values_named": { + "clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "type": "ltxv", + "device": "default" + } + }, + { + "id": 421, + "type": "PrimitiveInt", + "pos": [ + 150, + 6930 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 769 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 688 + ] + } + ], + "title": "Height", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 720, + "fixed" + ], + "widgets_values_named": { + "value": 720, + "fixed": "fixed" + } + }, + { + "id": 422, + "type": "KSamplerSelect", + "pos": [ + 2190, + 6750 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 671 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "euler_ancestral" + ], + "widgets_values_named": { + "sampler_name": "euler_ancestral" + } + }, + { + "id": 423, + "type": "RandomNoise", + "pos": [ + 2190, + 6300 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 770 + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 670 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 900359092894726, + "randomize" + ], + "widgets_values_named": { + "noise_seed": 900359092894726, + "control_after_generate": "randomize" + } + }, + { + "id": 424, + "type": "ManualSigmas", + "pos": [ + 2170, + 6920 + ], + "size": [ + 500, + 100 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 754 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas", + "cnr_id": "comfy-core", + "ver": "0.14.1" + }, + "widgets_values": [ + "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0" + ], + "widgets_values_named": { + "sigmas": "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0" + } + }, + { + "id": 425, + "type": "PrimitiveInt", + "pos": [ + 150, + 7080 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 776 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 695, + 716 + ] + } + ], + "title": "Frame Rate", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 24, + "fixed" + ], + "widgets_values_named": { + "value": 24, + "fixed": "fixed" + } + }, + { + "id": 426, + "type": "LatentUpscaleModelLoader", + "pos": [ + 650, + 6930 + ], + "size": [ + 410, + 140 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "localized_name": "model_name", + "name": "model_name", + "type": "COMBO", + "widget": { + "name": "model_name" + }, + "link": 781 + } + ], + "outputs": [ + { + "localized_name": "LATENT_UPSCALE_MODEL", + "name": "LATENT_UPSCALE_MODEL", + "type": "LATENT_UPSCALE_MODEL", + "links": [ + 678 + ] + } + ], + "properties": { + "Node name for S&R": "LatentUpscaleModelLoader", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "models": [ + { + "name": "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "directory": "latent_upscale_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors" + ], + "widgets_values_named": { + "model_name": "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors" + } + }, + { + "id": 427, + "type": "LTXVDualCFGGuider", + "pos": [ + 2200, + 6460 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 771 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 735 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 736 + }, + { + "localized_name": "video_cfg", + "name": "video_cfg", + "type": "FLOAT", + "widget": { + "name": "video_cfg" + }, + "link": null + }, + { + "localized_name": "audio_cfg", + "name": "audio_cfg", + "type": "FLOAT", + "widget": { + "name": "audio_cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 733 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVDualCFGGuider", + "cnr_id": "comfy-core", + "ver": "0.28.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1, + 1 + ], + "widgets_values_named": { + "video_cfg": 1, + "audio_cfg": 1 + } + }, + { + "id": 428, + "type": "SamplerCustomAdvanced", + "pos": [ + 2610, + 6510 + ], + "size": [ + 230, + 170 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 670 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 733 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 671 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 754 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 672 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 702 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.3.60", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 429, + "type": "LTXVImgToVideoInplace", + "pos": [ + 2070, + 7390 + ], + "size": [ + 280, + 180 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 727 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 691 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 692 + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + }, + { + "localized_name": "bypass", + "name": "bypass", + "type": "BOOLEAN", + "widget": { + "name": "bypass" + }, + "link": 693 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 699 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVImgToVideoInplace", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 0.7, + false + ], + "widgets_values_named": { + "strength": 0.7, + "bypass": false + } + }, + { + "id": 430, + "type": "LTXVEmptyLatentAudio", + "pos": [ + 1620, + 7370 + ], + "size": [ + 280, + 170 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 729 + }, + { + "localized_name": "frames_number", + "name": "frames_number", + "type": "INT", + "widget": { + "name": "frames_number" + }, + "link": 717 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT,INT", + "widget": { + "name": "frame_rate" + }, + "link": 701 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "Latent", + "name": "Latent", + "type": "LATENT", + "links": [ + 700 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVEmptyLatentAudio", + "cnr_id": "comfy-core", + "ver": "0.3.68", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 97, + 25, + 1 + ], + "widgets_values_named": { + "frames_number": 97, + "frame_rate": 25, + "batch_size": 1 + } + }, + { + "id": 431, + "type": "LTXVSeparateAVLatent", + "pos": [ + 2890, + 6520 + ], + "size": [ + 250, + 100 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "localized_name": "av_latent", + "name": "av_latent", + "type": "LATENT", + "link": 702 + } + ], + "outputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "links": [ + 677 + ] + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "links": [ + 666 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVSeparateAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 432, + "type": "LTXVConcatAVLatent", + "pos": [ + 2470, + 7390 + ], + "size": [ + 240, + 100 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "link": 699 + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "link": 700 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 672 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConcatAVLatent", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 433, + "type": "ResizeImageMaskNode", + "pos": [ + 190, + 7480 + ], + "size": [ + 300, + 110 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 763 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "longer_size", + "name": "resize_type.longer_size", + "type": "INT", + "widget": { + "name": "resize_type.longer_size" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 752 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "scale longer dimension", + 1536, + "lanczos" + ], + "widgets_values_named": { + "resize_type": "scale longer dimension", + "resize_type.longer_size": 1536, + "scale_method": "lanczos" + } + }, + { + "id": 434, + "type": "ComfyMathExpression", + "pos": [ + 1170, + 7470 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 32, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 687 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 689 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "a/2" + ], + "widgets_values_named": { + "expression": "a/2" + } + }, + { + "id": 435, + "type": "ComfyMathExpression", + "pos": [ + 1170, + 7530 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 33, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 688 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 690 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "a/2" + ], + "widgets_values_named": { + "expression": "a/2" + } + }, + { + "id": 436, + "type": "LTXVPreprocess", + "pos": [ + 620, + 7480 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 34, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 752 + }, + { + "localized_name": "img_compression", + "name": "img_compression", + "type": "INT", + "widget": { + "name": "img_compression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output_image", + "name": "output_image", + "type": "IMAGE", + "links": [ + 681, + 691, + 757 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVPreprocess", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 18 + ], + "widgets_values_named": { + "img_compression": 18 + } + }, + { + "id": 437, + "type": "EmptyLTXVLatentVideo", + "pos": [ + 1630, + 7600 + ], + "size": [ + 280, + 200 + ], + "flags": {}, + "order": 35, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 689 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 690 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 718 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 692 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLTXVLatentVideo", + "cnr_id": "comfy-core", + "ver": "0.3.60", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 768, + 512, + 97, + 1 + ], + "widgets_values_named": { + "width": 768, + "height": 512, + "length": 97, + "batch_size": 1 + } + }, + { + "id": 438, + "type": "LTXVLatentUpsampler", + "pos": [ + 3260, + 6410 + ], + "size": [ + 330, + 120 + ], + "flags": {}, + "order": 36, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 677 + }, + { + "localized_name": "upscale_model", + "name": "upscale_model", + "type": "LATENT_UPSCALE_MODEL", + "link": 678 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 773 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 682 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVLatentUpsampler", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 439, + "type": "LTXVImgToVideoInplace", + "pos": [ + 3270, + 6620 + ], + "size": [ + 300, + 180 + ], + "flags": {}, + "order": 37, + "mode": 0, + "inputs": [ + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 774 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 681 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 682 + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + }, + { + "localized_name": "bypass", + "name": "bypass", + "type": "BOOLEAN", + "widget": { + "name": "bypass" + }, + "link": 683 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 665 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVImgToVideoInplace", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 1, + false + ], + "widgets_values_named": { + "strength": 1, + "bypass": false + } + }, + { + "id": 440, + "type": "ComfyMathExpression", + "pos": [ + 1170, + 7670 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 38, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 695 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 698, + 713 + ] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 701 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "title": "Math Expression (fps)", + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "a" + ], + "widgets_values_named": { + "expression": "a" + } + }, + { + "id": 441, + "type": "ComfyMathExpression", + "pos": [ + 1180, + 7770 + ], + "size": [ + 230, + 100 + ], + "flags": { + "collapsed": true + }, + "order": 39, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 715 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": 716 + }, + { + "localized_name": "c", + "name": "values.c", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 717, + 718 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "title": "Math Expression (length)", + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.18.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.7" + } + }, + "widgets_values": [ + "a * b + 1" + ], + "widgets_values_named": { + "expression": "a * b + 1" + } + }, + { + "id": 442, + "type": "RandomNoise", + "pos": [ + 3710, + 6150 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 703 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.3.75", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 42, + "fixed" + ], + "widgets_values_named": { + "noise_seed": 42, + "control_after_generate": "fixed" + } + }, + { + "id": 443, + "type": "LTXVConcatAVLatent", + "pos": [ + 3710, + 6760 + ], + "size": [ + 280, + 100 + ], + "flags": {}, + "order": 40, + "mode": 0, + "inputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "link": 665 + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "link": 666 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 707 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConcatAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 444, + "type": "ManualSigmas", + "pos": [ + 3710, + 6980 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 751 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "0.85, 0.7250, 0.4219, 0.0" + ], + "widgets_values_named": { + "sigmas": "0.85, 0.7250, 0.4219, 0.0" + } + }, + { + "id": 445, + "type": "LTXVDualCFGGuider", + "pos": [ + 3710, + 6310 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 41, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 772 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 755 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 756 + }, + { + "localized_name": "video_cfg", + "name": "video_cfg", + "type": "FLOAT", + "widget": { + "name": "video_cfg" + }, + "link": null + }, + { + "localized_name": "audio_cfg", + "name": "audio_cfg", + "type": "FLOAT", + "widget": { + "name": "audio_cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 744 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVDualCFGGuider", + "cnr_id": "comfy-core", + "ver": "0.28.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1, + 1 + ], + "widgets_values_named": { + "video_cfg": 1, + "audio_cfg": 1 + } + }, + { + "id": 446, + "type": "KSamplerSelect", + "pos": [ + 3710, + 6560 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 705 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.3.75", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "euler_ancestral" + ], + "widgets_values_named": { + "sampler_name": "euler_ancestral" + } + }, + { + "id": 447, + "type": "SamplerCustomAdvanced", + "pos": [ + 4070, + 6500 + ], + "size": [ + 230, + 170 + ], + "flags": {}, + "order": 42, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 703 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 744 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 705 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 751 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 707 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 708 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.3.75", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 448, + "type": "LTXVSeparateAVLatent", + "pos": [ + 4370, + 6500 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 43, + "mode": 0, + "inputs": [ + { + "localized_name": "av_latent", + "name": "av_latent", + "type": "LATENT", + "link": 708 + } + ], + "outputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "links": [ + 709 + ] + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "links": [ + 694 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVSeparateAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 449, + "type": "VAEDecodeTiled", + "pos": [ + 4760, + 6360 + ], + "size": [ + 280, + 200 + ], + "flags": {}, + "order": 44, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 709 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 775 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + }, + { + "localized_name": "temporal_size", + "name": "temporal_size", + "type": "INT", + "widget": { + "name": "temporal_size" + }, + "link": null + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 711 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeTiled", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 512, + 64, + 64, + 16 + ], + "widgets_values_named": { + "tile_size": 512, + "overlap": 64, + "temporal_size": 64, + "temporal_overlap": 16 + } + }, + { + "id": 450, + "type": "LTXVAudioVAEDecode", + "pos": [ + 4760, + 6650 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 45, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 694 + }, + { + "label": "Audio VAE", + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 730 + } + ], + "outputs": [ + { + "localized_name": "Audio", + "name": "Audio", + "type": "AUDIO", + "links": [ + 712 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAudioVAEDecode", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 451, + "type": "CreateVideo", + "pos": [ + 5200, + 6460 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 46, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 711 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 712 + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 713 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 714 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 24, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 24, + "bit_depth": 8, + "color_space": "sRGB" + } + } + ], + "groups": [ + { + "id": 27, + "title": "Prompt Enhancement", + "bounding": [ + 100, + 5200, + 2010, + 720 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 1, + "title": "Model", + "bounding": [ + 620, + 5950, + 480, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Prompt", + "bounding": [ + 1130, + 5950, + 980, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 19, + "title": "Video Settings", + "bounding": [ + 100, + 5950, + 490, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Generate Low Resolution", + "bounding": [ + 2140, + 5950, + 1020, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Latent Upscale", + "bounding": [ + 3190, + 5950, + 460, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 20, + "title": "Image Preprocessing", + "bounding": [ + 100, + 7250, + 1000, + 610 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 21, + "title": "Empty Latent", + "bounding": [ + 1480, + 7250, + 1680, + 610 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 22, + "title": "Number conversion", + "bounding": [ + 1130, + 7250, + 320, + 610 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Generate High Resolution", + "bounding": [ + 3680, + 5950, + 960, + 1270 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 749, + "origin_id": 405, + "origin_slot": 0, + "target_id": 406, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 757, + "origin_id": 436, + "origin_slot": 0, + "target_id": 406, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 723, + "origin_id": 409, + "origin_slot": 0, + "target_id": 406, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 732, + "origin_id": 420, + "origin_slot": 0, + "target_id": 408, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 721, + "origin_id": 410, + "origin_slot": 0, + "target_id": 408, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 720, + "origin_id": 409, + "origin_slot": 0, + "target_id": 410, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 724, + "origin_id": 406, + "origin_slot": 0, + "target_id": 410, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 722, + "origin_id": 407, + "origin_slot": 0, + "target_id": 410, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 725, + "origin_id": 410, + "origin_slot": 0, + "target_id": 412, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 696, + "origin_id": 408, + "origin_slot": 0, + "target_id": 415, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 697, + "origin_id": 419, + "origin_slot": 0, + "target_id": 415, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 698, + "origin_id": 440, + "origin_slot": 0, + "target_id": 415, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 731, + "origin_id": 420, + "origin_slot": 0, + "target_id": 419, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 735, + "origin_id": 415, + "origin_slot": 0, + "target_id": 427, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 736, + "origin_id": 415, + "origin_slot": 1, + "target_id": 427, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 670, + "origin_id": 423, + "origin_slot": 0, + "target_id": 428, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 733, + "origin_id": 427, + "origin_slot": 0, + "target_id": 428, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 671, + "origin_id": 422, + "origin_slot": 0, + "target_id": 428, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 754, + "origin_id": 424, + "origin_slot": 0, + "target_id": 428, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 672, + "origin_id": 432, + "origin_slot": 0, + "target_id": 428, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 727, + "origin_id": 416, + "origin_slot": 0, + "target_id": 429, + "target_slot": 0, + "type": "VAE" + }, + { + "id": 691, + "origin_id": 436, + "origin_slot": 0, + "target_id": 429, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 692, + "origin_id": 437, + "origin_slot": 0, + "target_id": 429, + "target_slot": 2, + "type": "LATENT" + }, + { + "id": 693, + "origin_id": 414, + "origin_slot": 0, + "target_id": 429, + "target_slot": 4, + "type": "BOOLEAN" + }, + { + "id": 729, + "origin_id": 417, + "origin_slot": 0, + "target_id": 430, + "target_slot": 0, + "type": "VAE" + }, + { + "id": 717, + "origin_id": 441, + "origin_slot": 1, + "target_id": 430, + "target_slot": 1, + "type": "INT" + }, + { + "id": 701, + "origin_id": 440, + "origin_slot": 1, + "target_id": 430, + "target_slot": 2, + "type": "INT" + }, + { + "id": 702, + "origin_id": 428, + "origin_slot": 0, + "target_id": 431, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 699, + "origin_id": 429, + "origin_slot": 0, + "target_id": 432, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 700, + "origin_id": 430, + "origin_slot": 0, + "target_id": 432, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 687, + "origin_id": 418, + "origin_slot": 0, + "target_id": 434, + "target_slot": 0, + "type": "INT" + }, + { + "id": 688, + "origin_id": 421, + "origin_slot": 0, + "target_id": 435, + "target_slot": 0, + "type": "INT" + }, + { + "id": 752, + "origin_id": 433, + "origin_slot": 0, + "target_id": 436, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 689, + "origin_id": 434, + "origin_slot": 1, + "target_id": 437, + "target_slot": 0, + "type": "INT" + }, + { + "id": 690, + "origin_id": 435, + "origin_slot": 1, + "target_id": 437, + "target_slot": 1, + "type": "INT" + }, + { + "id": 718, + "origin_id": 441, + "origin_slot": 1, + "target_id": 437, + "target_slot": 2, + "type": "INT" + }, + { + "id": 677, + "origin_id": 431, + "origin_slot": 0, + "target_id": 438, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 678, + "origin_id": 426, + "origin_slot": 0, + "target_id": 438, + "target_slot": 1, + "type": "LATENT_UPSCALE_MODEL" + }, + { + "id": 681, + "origin_id": 436, + "origin_slot": 0, + "target_id": 439, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 682, + "origin_id": 438, + "origin_slot": 0, + "target_id": 439, + "target_slot": 2, + "type": "LATENT" + }, + { + "id": 683, + "origin_id": 414, + "origin_slot": 0, + "target_id": 439, + "target_slot": 4, + "type": "BOOLEAN" + }, + { + "id": 695, + "origin_id": 425, + "origin_slot": 0, + "target_id": 440, + "target_slot": 0, + "type": "INT" + }, + { + "id": 715, + "origin_id": 413, + "origin_slot": 0, + "target_id": 441, + "target_slot": 0, + "type": "INT" + }, + { + "id": 716, + "origin_id": 425, + "origin_slot": 0, + "target_id": 441, + "target_slot": 1, + "type": "INT" + }, + { + "id": 665, + "origin_id": 439, + "origin_slot": 0, + "target_id": 443, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 666, + "origin_id": 431, + "origin_slot": 1, + "target_id": 443, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 755, + "origin_id": 415, + "origin_slot": 0, + "target_id": 445, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 756, + "origin_id": 415, + "origin_slot": 1, + "target_id": 445, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 703, + "origin_id": 442, + "origin_slot": 0, + "target_id": 447, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 744, + "origin_id": 445, + "origin_slot": 0, + "target_id": 447, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 705, + "origin_id": 446, + "origin_slot": 0, + "target_id": 447, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 751, + "origin_id": 444, + "origin_slot": 0, + "target_id": 447, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 707, + "origin_id": 443, + "origin_slot": 0, + "target_id": 447, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 708, + "origin_id": 447, + "origin_slot": 0, + "target_id": 448, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 709, + "origin_id": 448, + "origin_slot": 0, + "target_id": 449, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 694, + "origin_id": 448, + "origin_slot": 1, + "target_id": 450, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 730, + "origin_id": 417, + "origin_slot": 0, + "target_id": 450, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 711, + "origin_id": 449, + "origin_slot": 0, + "target_id": 451, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 712, + "origin_id": 450, + "origin_slot": 0, + "target_id": 451, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 713, + "origin_id": 440, + "origin_slot": 0, + "target_id": 451, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 714, + "origin_id": 451, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 763, + "origin_id": -10, + "origin_slot": 0, + "target_id": 433, + "target_slot": 0, + "type": "IMAGE,MASK" + }, + { + "id": 765, + "origin_id": -10, + "origin_slot": 1, + "target_id": 409, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 766, + "origin_id": -10, + "origin_slot": 2, + "target_id": 407, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 767, + "origin_id": -10, + "origin_slot": 3, + "target_id": 413, + "target_slot": 0, + "type": "INT" + }, + { + "id": 768, + "origin_id": -10, + "origin_slot": 4, + "target_id": 418, + "target_slot": 0, + "type": "INT" + }, + { + "id": 769, + "origin_id": -10, + "origin_slot": 5, + "target_id": 421, + "target_slot": 0, + "type": "INT" + }, + { + "id": 770, + "origin_id": -10, + "origin_slot": 6, + "target_id": 423, + "target_slot": 0, + "type": "INT" + }, + { + "id": 771, + "origin_id": 411, + "origin_slot": 0, + "target_id": 427, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 772, + "origin_id": 411, + "origin_slot": 0, + "target_id": 445, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 773, + "origin_id": 416, + "origin_slot": 0, + "target_id": 438, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 774, + "origin_id": 416, + "origin_slot": 0, + "target_id": 439, + "target_slot": 0, + "type": "VAE" + }, + { + "id": 775, + "origin_id": 416, + "origin_slot": 0, + "target_id": 449, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 776, + "origin_id": -10, + "origin_slot": 7, + "target_id": 425, + "target_slot": 0, + "type": "INT" + }, + { + "id": 777, + "origin_id": -10, + "origin_slot": 8, + "target_id": 411, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 778, + "origin_id": -10, + "origin_slot": 9, + "target_id": 416, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 779, + "origin_id": -10, + "origin_slot": 10, + "target_id": 417, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 780, + "origin_id": -10, + "origin_slot": 11, + "target_id": 420, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 781, + "origin_id": -10, + "origin_slot": 12, + "target_id": 426, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 782, + "origin_id": -10, + "origin_slot": 13, + "target_id": 405, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Video generation/Image to video", + "description": "Generates video from a first-frame image using LTX-2.5, with optional prompt enhancement, spatial upscaling, and synchronized audio." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image to Video (MiniMax H3).json b/blueprints/Image to Video (MiniMax H3).json new file mode 100644 index 00000000000..828c7626d1c --- /dev/null +++ b/blueprints/Image to Video (MiniMax H3).json @@ -0,0 +1,2129 @@ +{ + "revision": 0, + "last_node_id": "148", + "last_link_id": 0, + "nodes": [ + { + "id": 148, + "type": "d70595de-536a-4124-9bf3-d5c9ee60c978", + "pos": [ + -390, + 5160 + ], + "size": [ + 480, + 630 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "first_frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "name": "last_frame", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "label": "duration", + "name": "value_1", + "type": "FLOAT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "audio_vae", + "name": "vae_name_1", + "type": "COMBO", + "widget": { + "name": "vae_name_1" + }, + "link": null + }, + { + "label": "turbo_mode", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": null + }, + { + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "label": "turbo_model_strength", + "name": "strength_model_1", + "type": "FLOAT", + "widget": { + "name": "strength_model_1" + }, + "link": null + }, + { + "label": "turbo_steps", + "name": "value_2", + "type": "INT", + "widget": { + "name": "value_2" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.33.0", + "previewExposures": [] + }, + "widgets_values": [ + "", + 1344, + 768, + 5, + 757358688076805, + "minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "minimax_h3_video_vae_fp16.safetensors", + "minimax_h3_audio_vae_fp32.safetensors", + false, + "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors", + 1, + 8 + ], + "widgets_values_named": { + "prompt": "", + "width": 1344, + "height": 768, + "value_1": 5, + "noise_seed": 757358688076805, + "unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "vae_name": "minimax_h3_video_vae_fp16.safetensors", + "vae_name_1": "minimax_h3_audio_vae_fp32.safetensors", + "value": false, + "lora_name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors", + "strength_model_1": 1, + "value_2": 8 + }, + "title": "Image to Video (MiniMax H3)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "d70595de-536a-4124-9bf3-d5c9ee60c978", + "version": 1, + "state": { + "lastGroupId": 6, + "lastNodeId": 148, + "lastLinkId": 245, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image to Video (MiniMax H3)", + "inputNode": { + "id": -10, + "bounding": [ + -2560, + 4790, + 171.62109375, + 348 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 2130, + 5070, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "d6eaa195-2266-4016-b7ed-b2d17a3e53c2", + "name": "first_frame", + "type": "IMAGE", + "linkIds": [ + 195 + ], + "pos": [ + -2412.37890625, + 4814 + ] + }, + { + "id": "03b95567-f496-4279-9d38-989dd34fa882", + "name": "last_frame", + "type": "IMAGE", + "linkIds": [ + 196 + ], + "pos": [ + -2412.37890625, + 4834 + ] + }, + { + "id": "d7302ca7-24ed-44c4-8ac9-736540dab7fb", + "name": "prompt", + "type": "STRING", + "linkIds": [ + 197 + ], + "pos": [ + -2412.37890625, + 4854 + ] + }, + { + "id": "709e2d94-3172-496f-ad86-7d672f3df568", + "name": "width", + "type": "INT", + "linkIds": [ + 200 + ], + "pos": [ + -2412.37890625, + 4874 + ] + }, + { + "id": "8205b85b-19bb-47f6-8418-89cd42940c1d", + "name": "height", + "type": "INT", + "linkIds": [ + 201 + ], + "pos": [ + -2412.37890625, + 4894 + ] + }, + { + "id": "a40e5e96-4307-4a8e-a4f8-13a27d4bc9d3", + "name": "value_1", + "type": "FLOAT", + "linkIds": [ + 206 + ], + "label": "duration", + "pos": [ + -2412.37890625, + 4914 + ] + }, + { + "id": "9f8734bc-c2b3-43b0-911e-e89560e8b777", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 207 + ], + "pos": [ + -2412.37890625, + 4934 + ] + }, + { + "id": "9a6d2811-673c-47e0-aa94-dda7c83621e4", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 221 + ], + "pos": [ + -2412.37890625, + 4954 + ] + }, + { + "id": "7100a787-5536-4002-840b-1d2fd071010a", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 227 + ], + "pos": [ + -2412.37890625, + 4974 + ] + }, + { + "id": "d40632fd-e0ae-4319-b85d-5479db4d6e11", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 223 + ], + "pos": [ + -2412.37890625, + 4994 + ] + }, + { + "id": "2992852c-4b20-439a-8771-c866ec1996e6", + "name": "vae_name_1", + "type": "COMBO", + "linkIds": [ + 224 + ], + "label": "audio_vae", + "pos": [ + -2412.37890625, + 5014 + ] + }, + { + "id": "5fa5fd85-5efc-45ea-b938-2767dc6a375b", + "name": "value", + "type": "BOOLEAN", + "linkIds": [ + 240 + ], + "label": "turbo_mode", + "pos": [ + -2412.37890625, + 5034 + ] + }, + { + "id": "1c18d558-5904-486b-84d8-43b080a2d4dd", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 243 + ], + "pos": [ + -2412.37890625, + 5054 + ] + }, + { + "id": "a7b69463-a7b0-4eae-991d-c333e4e8d5cd", + "name": "strength_model_1", + "type": "FLOAT", + "linkIds": [ + 244 + ], + "label": "turbo_model_strength", + "pos": [ + -2412.37890625, + 5074 + ] + }, + { + "id": "4a02c41e-7fa6-4faf-9018-7a09de5245d8", + "name": "value_2", + "type": "INT", + "linkIds": [ + 245 + ], + "label": "turbo_steps", + "pos": [ + -2412.37890625, + 5094 + ] + } + ], + "outputs": [ + { + "id": "adb2611c-e490-4fca-8067-b718c992f8cc", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 168 + ], + "localized_name": "VIDEO", + "pos": [ + 2154, + 5094 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 127, + "type": "VAELoader", + "pos": [ + -2020, + 5210 + ], + "size": [ + 640, + 100 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 223 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 8, + 190 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "minimax_h3_video_vae_fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_video_vae_fp16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "minimax_h3_video_vae_fp16.safetensors" + ], + "widgets_values_named": { + "vae_name": "minimax_h3_video_vae_fp16.safetensors" + } + }, + { + "id": 128, + "type": "VAELoader", + "pos": [ + -2020, + 5360 + ], + "size": [ + 650, + 100 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 224 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "minimax_h3_audio_vae_fp32.safetensors", + "url": "https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/vae/minimax_h3_audio_vae_fp32.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "minimax_h3_audio_vae_fp32.safetensors" + ], + "widgets_values_named": { + "vae_name": "minimax_h3_audio_vae_fp32.safetensors" + } + }, + { + "id": 129, + "type": "VAEDecodeAudio", + "pos": [ + 760, + 4990 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": false + }, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 226 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 23 + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 166 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeAudio", + "cnr_id": "comfy-core", + "ver": "0.33.0" + } + }, + { + "id": 130, + "type": "VAEDecode", + "pos": [ + 760, + 4850 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": false + }, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 225 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 167 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + } + }, + { + "id": 131, + "type": "KSamplerSelect", + "pos": [ + 0, + 4950 + ], + "size": [ + 370, + 90 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "res_multistep" + ], + "widgets_values_named": { + "sampler_name": "res_multistep" + } + }, + { + "id": 132, + "type": "BasicScheduler", + "pos": [ + 0, + 5100 + ], + "size": [ + 370, + 150 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 234 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 235 + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "simple", + 4, + 1 + ], + "widgets_values_named": { + "scheduler": "simple", + "steps": 4, + "denoise": 1 + } + }, + { + "id": 133, + "type": "SamplerCustomAdvanced", + "pos": [ + 420, + 4780 + ], + "size": [ + 230, + 390 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 40 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 12 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 16 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 18 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 188 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 225, + 226 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": null + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.33.0" + } + }, + { + "id": 134, + "type": "BasicGuider", + "pos": [ + 0, + 4820 + ], + "size": [ + 360, + 80 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 241 + }, + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 187 + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "BasicGuider", + "cnr_id": "comfy-core", + "ver": "0.33.0" + } + }, + { + "id": 135, + "type": "UNETLoader", + "pos": [ + -2020, + 4650 + ], + "size": [ + 640, + 120 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 221 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 229, + 232 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/diffusion_models/minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "minimax_h3_fl2va_pruned_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 136, + "type": "CLIPLoader", + "pos": [ + -2020, + 5010 + ], + "size": [ + 640, + 150 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 227 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 189 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "url": "https://huggingface.co/Comfy-Org/MiniMax-H3/resolve/main/text_encoders/qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "minimax", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_32b_minimax_h3_nvfp4_awq.safetensors", + "type": "minimax", + "device": "default" + } + }, + { + "id": 137, + "type": "RandomNoise", + "pos": [ + 0, + 4660 + ], + "size": [ + 360, + 90 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 207 + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 40 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 1, + "fixed" + ], + "widgets_values_named": { + "noise_seed": 1, + "control_after_generate": "fixed" + } + }, + { + "id": 138, + "type": "CreateVideo", + "pos": [ + 1090, + 4890 + ], + "size": [ + 270, + 140 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 167 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 166 + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 168 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 24, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 24, + "bit_depth": 8, + "color_space": "sRGB" + } + }, + { + "id": 139, + "type": "MiniMaxH3ImageToVideo", + "pos": [ + -1300, + 4740 + ], + "size": [ + 430, + 550 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 189 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 190 + }, + { + "localized_name": "first_frame", + "name": "first_frame", + "shape": 7, + "type": "IMAGE", + "link": 195 + }, + { + "localized_name": "last_frame", + "name": "last_frame", + "shape": 7, + "type": "IMAGE", + "link": 196 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 197 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 200 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 201 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 199 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 187 + ] + }, + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 188 + ] + } + ], + "properties": { + "Node name for S&R": "MiniMaxH3ImageToVideo", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "Vaporwave title sequence look: pink and blue gradient palette, VHS tracking artifacts, Greek statue motifs, chrome palm trees, RGB chromatic aberration, lo-fi retro atmosphere, mood languid and nostalgic.\n\nTimeline:\n[0s-1s] VHS static opens the frame, the title \"COMFYUI\" appears with RGB split and a slight horizontal jitter.\n[1s-2.5s] Hard cut, a Greek plaster bust close-up, pink-purple gradient sky, a pixelated sun.\n[2.5s-4s] Clean \"STARRING\" credits appear, \"LATENT\" and \"CONTROLNET\" each shown exactly once.\n[4s-5s] Final card \"DIRECTED BY COMFYUI\" holds, one VHS tracking glitch settling into stability.\n\nHard cuts only, transitions landing with tape jumps, no push-ins, no dissolves.\n\nAudio: lo-fi vaporwave score, slow drum machine with soft bass, VHS tape-noise sample joins at 2.5s, melody fading for the last 1s.\n\nAll text must be clearly legible, do not misspell English, no Chinese characters, do not repeat names or job titles, no soft dissolves, no subtitle bars.", + 1344, + 768, + 73 + ], + "widgets_values_named": { + "prompt": "Vaporwave title sequence look: pink and blue gradient palette, VHS tracking artifacts, Greek statue motifs, chrome palm trees, RGB chromatic aberration, lo-fi retro atmosphere, mood languid and nostalgic.\n\nTimeline:\n[0s-1s] VHS static opens the frame, the title \"COMFYUI\" appears with RGB split and a slight horizontal jitter.\n[1s-2.5s] Hard cut, a Greek plaster bust close-up, pink-purple gradient sky, a pixelated sun.\n[2.5s-4s] Clean \"STARRING\" credits appear, \"LATENT\" and \"CONTROLNET\" each shown exactly once.\n[4s-5s] Final card \"DIRECTED BY COMFYUI\" holds, one VHS tracking glitch settling into stability.\n\nHard cuts only, transitions landing with tape jumps, no push-ins, no dissolves.\n\nAudio: lo-fi vaporwave score, slow drum machine with soft bass, VHS tape-noise sample joins at 2.5s, melody fading for the last 1s.\n\nAll text must be clearly legible, do not misspell English, no Chinese characters, do not repeat names or job titles, no soft dissolves, no subtitle bars.", + "width": 1344, + "height": 768, + "length": 73 + } + }, + { + "id": 140, + "type": "ComfyMathExpression", + "pos": [ + -1710, + 5740 + ], + "size": [ + 360, + 170 + ], + "flags": { + "collapsed": false + }, + "order": 14, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 205 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 199 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17" + ], + "widgets_values_named": { + "expression": "max(5, round(a * 24)) + (5 - (max(5, round(a * 24)) % 17)) % 17" + } + }, + { + "id": 141, + "type": "PrimitiveFloat", + "pos": [ + -2020, + 5740 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": 206 + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 205 + ] + } + ], + "title": "Float (duration)", + "properties": { + "Node name for S&R": "PrimitiveFloat", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 2 + ], + "widgets_values_named": { + "value": 2 + } + }, + { + "id": 142, + "type": "LoraLoaderModelOnly", + "pos": [ + -2020, + 4830 + ], + "size": [ + 640, + 130 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 229 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 243 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": 244 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 233 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors", + "url": "https://huggingface.co/lightx2v/Minimax-h3-Turbo/resolve/main/minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "minimax_h3_fl2v_turbo_8step_v1.0_comfyui_bf16.safetensors", + "strength_model": 1 + } + }, + { + "id": 143, + "type": "ComfySwitchNode", + "pos": [ + -400, + 4790 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 232 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 233 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 238 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 234, + 241 + ] + } + ], + "title": "If/Else Switch (Model)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 144, + "type": "ComfySwitchNode", + "pos": [ + -400, + 4980 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 236 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 237 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 239 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 235 + ] + } + ], + "title": "If/Else Switch (Steps)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 145, + "type": "PrimitiveInt", + "pos": [ + -750, + 4920 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 236 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 20, + "fixed" + ], + "widgets_values_named": { + "value": 20, + "fixed": "fixed" + } + }, + { + "id": 146, + "type": "PrimitiveInt", + "pos": [ + -750, + 5060 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 245 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 237 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 6, + "fixed" + ], + "widgets_values_named": { + "value": 6, + "fixed": "fixed" + } + }, + { + "id": 147, + "type": "PrimitiveBoolean", + "pos": [ + -750, + 5210 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 240 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 238, + 239 + ] + } + ], + "title": "Boolean (Enable Lightning LoRA)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + }, + "color": "#322", + "bgcolor": "#533" + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -2050, + 4540, + 700, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Sampling", + "bounding": [ + -30, + 4540, + 700, + 800 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Conditioning", + "bounding": [ + -1320, + 4540, + 480, + 800 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Decoding and create video", + "bounding": [ + 700, + 4540, + 700, + 800 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Switch", + "bounding": [ + -810, + 4540, + 750, + 800 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 23, + "origin_id": 128, + "origin_slot": 0, + "target_id": 129, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 8, + "origin_id": 127, + "origin_slot": 0, + "target_id": 130, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 40, + "origin_id": 137, + "origin_slot": 0, + "target_id": 133, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 12, + "origin_id": 134, + "origin_slot": 0, + "target_id": 133, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 16, + "origin_id": 131, + "origin_slot": 0, + "target_id": 133, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 18, + "origin_id": 132, + "origin_slot": 0, + "target_id": 133, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 188, + "origin_id": 139, + "origin_slot": 1, + "target_id": 133, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 187, + "origin_id": 139, + "origin_slot": 0, + "target_id": 134, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 167, + "origin_id": 130, + "origin_slot": 0, + "target_id": 138, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 166, + "origin_id": 129, + "origin_slot": 0, + "target_id": 138, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 189, + "origin_id": 136, + "origin_slot": 0, + "target_id": 139, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 190, + "origin_id": 127, + "origin_slot": 0, + "target_id": 139, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 168, + "origin_id": 138, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 195, + "origin_id": -10, + "origin_slot": 0, + "target_id": 139, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 196, + "origin_id": -10, + "origin_slot": 1, + "target_id": 139, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 197, + "origin_id": -10, + "origin_slot": 2, + "target_id": 139, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 199, + "origin_id": 140, + "origin_slot": 1, + "target_id": 139, + "target_slot": 7, + "type": "INT" + }, + { + "id": 200, + "origin_id": -10, + "origin_slot": 3, + "target_id": 139, + "target_slot": 5, + "type": "INT" + }, + { + "id": 201, + "origin_id": -10, + "origin_slot": 4, + "target_id": 139, + "target_slot": 6, + "type": "INT" + }, + { + "id": 205, + "origin_id": 141, + "origin_slot": 0, + "target_id": 140, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 206, + "origin_id": -10, + "origin_slot": 5, + "target_id": 141, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 207, + "origin_id": -10, + "origin_slot": 6, + "target_id": 137, + "target_slot": 0, + "type": "INT" + }, + { + "id": 221, + "origin_id": -10, + "origin_slot": 7, + "target_id": 135, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 223, + "origin_id": -10, + "origin_slot": 9, + "target_id": 127, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 224, + "origin_id": -10, + "origin_slot": 10, + "target_id": 128, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 225, + "origin_id": 133, + "origin_slot": 0, + "target_id": 130, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 226, + "origin_id": 133, + "origin_slot": 0, + "target_id": 129, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 227, + "origin_id": -10, + "origin_slot": 8, + "target_id": 136, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 229, + "origin_id": 135, + "origin_slot": 0, + "target_id": 142, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 232, + "origin_id": 135, + "origin_slot": 0, + "target_id": 143, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 233, + "origin_id": 142, + "origin_slot": 0, + "target_id": 143, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 234, + "origin_id": 143, + "origin_slot": 0, + "target_id": 132, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 235, + "origin_id": 144, + "origin_slot": 0, + "target_id": 132, + "target_slot": 2, + "type": "INT" + }, + { + "id": 236, + "origin_id": 145, + "origin_slot": 0, + "target_id": 144, + "target_slot": 0, + "type": "INT" + }, + { + "id": 237, + "origin_id": 146, + "origin_slot": 0, + "target_id": 144, + "target_slot": 1, + "type": "INT" + }, + { + "id": 238, + "origin_id": 147, + "origin_slot": 0, + "target_id": 143, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 239, + "origin_id": 147, + "origin_slot": 0, + "target_id": 144, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 240, + "origin_id": -10, + "origin_slot": 11, + "target_id": 147, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 241, + "origin_id": 143, + "origin_slot": 0, + "target_id": 134, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 243, + "origin_id": -10, + "origin_slot": 12, + "target_id": 142, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 244, + "origin_id": -10, + "origin_slot": 13, + "target_id": 142, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 245, + "origin_id": -10, + "origin_slot": 14, + "target_id": 146, + "target_slot": 0, + "type": "INT" + } + ], + "extra": {}, + "category": "Video generation/Image to video", + "description": "Generates video with native stereo audio from a first frame and optional last frame using MiniMax H3, with optional turbo LoRA." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Image to Video (Wan 2.2).json b/blueprints/Image to Video (Wan 2.2).json index a24adcfb6d9..f8b289c1905 100644 --- a/blueprints/Image to Video (Wan 2.2).json +++ b/blueprints/Image to Video (Wan 2.2).json @@ -2027,7 +2027,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Image to video", + "category": "Video generation/Image to video", "description": "Image-to-video with Wan 2.2 using a start image plus text prompt to extend motion from the still frame." } ] diff --git a/blueprints/Image to Video (Wan Dancer).json b/blueprints/Image to Video (Wan Dancer).json new file mode 100644 index 00000000000..2dbb649f091 --- /dev/null +++ b/blueprints/Image to Video (Wan Dancer).json @@ -0,0 +1,7054 @@ +{ + "revision": 0, + "last_node_id": "776", + "last_link_id": 0, + "nodes": [ + { + "id": 776, + "type": "dd2f850b-0f7c-40b1-8a70-db5159c81500", + "pos": [ + 4710, + -540 + ], + "size": [ + 430, + 570 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "dance_style", + "name": "choice", + "type": "COMBO", + "widget": { + "name": "choice" + }, + "link": null + }, + { + "label": "motion_amplitude", + "name": "choice_1", + "type": "COMBO", + "widget": { + "name": "choice_1" + }, + "link": null + }, + { + "label": "width", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "height", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "audio_duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + }, + { + "label": "final_duration", + "name": "choice_2", + "type": "COMBO", + "widget": { + "name": "choice_2" + }, + "link": null + }, + { + "label": "global_model", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "label": "local_model", + "name": "unet_name_1", + "type": "COMBO", + "widget": { + "name": "unet_name_1" + }, + "link": null + }, + { + "label": "lightning_lora", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "label": "clip_vision", + "name": "clip_name_1", + "type": "COMBO", + "widget": { + "name": "clip_name_1" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + } + ], + "outputs": [ + { + "label": "global_output_preview", + "name": "VIDEO", + "type": "VIDEO", + "links": [] + }, + { + "label": "final_output", + "name": "VIDEO_1", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "previewExposures": [] + }, + "widgets_values": [ + "Latin Dance 拉丁舞", + "low 低", + 720, + 1280, + 25, + "5", + "wan2.2_dancer_14b_global_fp8_scaled.safetensors", + "wan2.2_dancer_14b_local_fp8_scaled.safetensors", + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "umt5_xxl_fp16.safetensors", + "clip_vision_h.safetensors", + "Wan2_1_VAE_bf16.safetensors", + 0 + ], + "widgets_values_named": { + "choice": "Latin Dance 拉丁舞", + "choice_1": "low 低", + "value": 720, + "value_1": 1280, + "duration": 25, + "choice_2": "5", + "unet_name": "wan2.2_dancer_14b_global_fp8_scaled.safetensors", + "unet_name_1": "wan2.2_dancer_14b_local_fp8_scaled.safetensors", + "lora_name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "clip_name": "umt5_xxl_fp16.safetensors", + "clip_name_1": "clip_vision_h.safetensors", + "vae_name": "Wan2_1_VAE_bf16.safetensors", + "noise_seed": 0 + }, + "title": "Image to Video (Wan Dancer)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "dd2f850b-0f7c-40b1-8a70-db5159c81500", + "version": 1, + "state": { + "lastGroupId": 31, + "lastNodeId": 776, + "lastLinkId": 1380, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Image to Video (Wan Dancer)", + "inputNode": { + "id": -10, + "bounding": [ + 2330, + -370, + 148.072265625, + 348 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 6710, + -170, + 175.18359375, + 88 + ] + }, + "inputs": [ + { + "id": "7ac3eb1c-06b1-489d-b895-583b63deafe6", + "name": "audio", + "type": "AUDIO", + "linkIds": [ + 1084 + ], + "localized_name": "audio", + "pos": [ + 2454.072265625, + -346 + ] + }, + { + "id": "717c977c-7bbf-42e1-847a-4125d6bfc9fc", + "name": "input", + "type": "IMAGE,MASK", + "linkIds": [ + 1239 + ], + "localized_name": "input", + "pos": [ + 2454.072265625, + -326 + ] + }, + { + "id": "6299d700-ffb3-4a57-8625-6f8ec11940f8", + "name": "choice", + "type": "COMBO", + "linkIds": [ + 1349 + ], + "label": "dance_style", + "pos": [ + 2454.072265625, + -306 + ] + }, + { + "id": "4e64f322-c63e-403c-8cc8-f15a46ee0192", + "name": "choice_1", + "type": "COMBO", + "linkIds": [ + 1350 + ], + "label": "motion_amplitude", + "pos": [ + 2454.072265625, + -286 + ] + }, + { + "id": "c99a7f9c-01e9-4e29-b32c-956d54ce1cb4", + "name": "value", + "type": "INT", + "linkIds": [ + 1351 + ], + "label": "width", + "pos": [ + 2454.072265625, + -266 + ] + }, + { + "id": "391d1589-4fbb-44a5-ad5d-15ebc022fcb1", + "name": "value_1", + "type": "INT", + "linkIds": [ + 1352 + ], + "label": "height", + "pos": [ + 2454.072265625, + -246 + ] + }, + { + "id": "550c28ff-e4cf-49d6-92ef-d611c45358ce", + "name": "duration", + "type": "FLOAT", + "linkIds": [ + 1353 + ], + "label": "audio_duration", + "pos": [ + 2454.072265625, + -226 + ] + }, + { + "id": "a9639ac8-e070-4299-ab3d-7762fe40d894", + "name": "choice_2", + "type": "COMBO", + "linkIds": [ + 1368 + ], + "label": "final_duration", + "pos": [ + 2454.072265625, + -206 + ] + }, + { + "id": "d8628b17-0cfd-4751-add4-2aa96274cda8", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 1370 + ], + "label": "global_model", + "pos": [ + 2454.072265625, + -186 + ] + }, + { + "id": "54b07a1c-72df-4685-b478-7beec4767b55", + "name": "unet_name_1", + "type": "COMBO", + "linkIds": [ + 1371 + ], + "label": "local_model", + "pos": [ + 2454.072265625, + -166 + ] + }, + { + "id": "56d0c67d-bc13-4844-aa2a-c9347dcfc955", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 1372, + 1373 + ], + "label": "lightning_lora", + "pos": [ + 2454.072265625, + -146 + ] + }, + { + "id": "3748349b-9926-49e5-9f8a-c45b61b3e59b", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 1374 + ], + "pos": [ + 2454.072265625, + -126 + ] + }, + { + "id": "8907f7e4-b573-49ae-8915-45385600abf7", + "name": "clip_name_1", + "type": "COMBO", + "linkIds": [ + 1375 + ], + "label": "clip_vision", + "pos": [ + 2454.072265625, + -106 + ] + }, + { + "id": "31707b62-1ac2-48a2-8048-b8b73b712c79", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 1376 + ], + "pos": [ + 2454.072265625, + -86 + ] + }, + { + "id": "28538643-c8d5-4b9c-a22e-6bfca87a984e", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 1377 + ], + "pos": [ + 2454.072265625, + -66 + ] + } + ], + "outputs": [ + { + "id": "4e7dcfae-7821-4c05-91e2-7a2e460af0bf", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 1363 + ], + "label": "global_output_preview", + "pos": [ + 6734, + -146 + ] + }, + { + "id": "3ee6adfa-b6b9-45cf-b4ce-72c059f775aa", + "name": "VIDEO_1", + "type": "VIDEO", + "linkIds": [ + 1364 + ], + "label": "final_output", + "pos": [ + 6734, + -126 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 705, + "type": "UNETLoader", + "pos": [ + 3410, + -50 + ], + "size": [ + 530, + 140 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 1370 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 1065, + 1248 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "models": [ + { + "name": "wan2.2_dancer_14b_global_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Dancer/resolve/main/diffusion_models/wan2.2_dancer_14b_global_fp8_scaled.safetensors", + "directory": "diffusion_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "wan2.2_dancer_14b_global_fp8_scaled.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "wan2.2_dancer_14b_global_fp8_scaled.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 706, + "type": "LoraLoaderModelOnly", + "pos": [ + 3410, + 140 + ], + "size": [ + 530, + 170 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1065 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 1372 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 1249 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "models": [ + { + "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "url": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + 3 + ], + "widgets_values_named": { + "lora_name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "strength_model": 3 + } + }, + { + "id": 707, + "type": "LoraLoaderModelOnly", + "pos": [ + 3410, + 1290 + ], + "size": [ + 530, + 170 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 966 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 1373 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 1301, + 1305 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "models": [ + { + "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "url": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Lightx2v/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + 1.03 + ], + "widgets_values_named": { + "lora_name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "strength_model": 1.03 + } + }, + { + "id": 708, + "type": "UNETLoader", + "pos": [ + 3410, + 1090 + ], + "size": [ + 530, + 140 + ], + "flags": {}, + "order": 15, + "mode": 0, + "showAdvanced": false, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 1371 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 966 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "models": [ + { + "name": "wan2.2_dancer_14b_local_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Dancer/resolve/main/diffusion_models/wan2.2_dancer_14b_local_fp8_scaled.safetensors", + "directory": "diffusion_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "wan2.2_dancer_14b_local_fp8_scaled.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "wan2.2_dancer_14b_local_fp8_scaled.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 709, + "type": "CLIPLoader", + "pos": [ + 3420, + 450 + ], + "size": [ + 530, + 170 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 1374 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 1245, + 1246, + 1302 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "models": [ + { + "name": "umt5_xxl_fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp16.safetensors", + "directory": "text_encoders" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "umt5_xxl_fp16.safetensors", + "wan", + "default" + ], + "widgets_values_named": { + "clip_name": "umt5_xxl_fp16.safetensors", + "type": "wan", + "device": "default" + } + }, + { + "id": 710, + "type": "CLIPVisionLoader", + "pos": [ + 3420, + 830 + ], + "size": [ + 530, + 140 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 1375 + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION", + "name": "CLIP_VISION", + "type": "CLIP_VISION", + "links": [ + 1247, + 1304 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionLoader", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "models": [ + { + "name": "clip_vision_h.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/clip_vision/clip_vision_h.safetensors", + "directory": "clip_vision" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "clip_vision_h.safetensors" + ], + "widgets_values_named": { + "clip_name": "clip_vision_h.safetensors" + } + }, + { + "id": 711, + "type": "PrimitiveInt", + "pos": [ + 3400, + -330 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1300, + 1327, + 1328, + 1332 + ] + } + ], + "title": "Length", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 149, + "fixed" + ], + "widgets_values_named": { + "value": 149, + "fixed": "fixed" + } + }, + { + "id": 712, + "type": "VAELoader", + "pos": [ + 3420, + 660 + ], + "size": [ + 530, + 140 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 1376 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 1250, + 1256, + 1303, + 1308 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "models": [ + { + "name": "Wan2_1_VAE_bf16.safetensors", + "url": "https://huggingface.co/Kijai/WanVideo_comfy/resolve/main/Wan2_1_VAE_bf16.safetensors", + "directory": "vae" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Wan2_1_VAE_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "Wan2_1_VAE_bf16.safetensors" + } + }, + { + "id": 713, + "type": "CreateVideo", + "pos": [ + 6200, + 460 + ], + "size": [ + 280, + 170 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 1330 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 1281 + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 1364 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 30, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 30, + "bit_depth": 8, + "color_space": "sRGB" + } + }, + { + "id": 714, + "type": "PrimitiveInt", + "pos": [ + 3400, + -630 + ], + "size": [ + 280, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 1351 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1130 + ] + } + ], + "title": "Width", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 720, + "fixed" + ], + "widgets_values_named": { + "value": 720, + "fixed": "fixed" + } + }, + { + "id": 715, + "type": "PrimitiveInt", + "pos": [ + 3400, + -480 + ], + "size": [ + 280, + 110 + ], + "flags": { + "collapsed": false + }, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 1352 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1133 + ] + } + ], + "title": "Height", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1280, + "fixed" + ], + "widgets_values_named": { + "value": 1280, + "fixed": "fixed" + } + }, + { + "id": 716, + "type": "TrimAudioDuration", + "pos": [ + 3560, + -880 + ], + "size": [ + 280, + 140 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "link": 1084 + }, + { + "localized_name": "start_index", + "name": "start_index", + "type": "FLOAT", + "widget": { + "name": "start_index" + }, + "link": null + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": 1353 + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 1265, + 1281, + 1319 + ] + } + ], + "properties": { + "Node name for S&R": "TrimAudioDuration", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 0, + 25 + ], + "widgets_values_named": { + "start_index": 0, + "duration": 25 + } + }, + { + "id": 717, + "type": "ComfyMathExpression", + "pos": [ + 3720, + -590 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 23, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 1130 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1237, + 1314, + 1325 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "max(16, floor(a / 16) * 16)" + ], + "widgets_values_named": { + "expression": "max(16, floor(a / 16) * 16)" + } + }, + { + "id": 718, + "type": "ComfyMathExpression", + "pos": [ + 3720, + -450 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 24, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 1133 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1238, + 1315, + 1326 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "max(16, floor(a / 16) * 16)" + ], + "widgets_values_named": { + "expression": "max(16, floor(a / 16) * 16)" + } + }, + { + "id": 719, + "type": "CLIPTextEncode", + "pos": [ + 4090, + -1300 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 1245 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 1255 + ] + } + ], + "title": "CLIP Text Encode (Negative Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走\n" + ], + "widgets_values_named": { + "text": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走\n" + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 720, + "type": "CLIPTextEncode", + "pos": [ + 4300, + -1360 + ], + "size": [ + 230, + 0 + ], + "flags": { + "collapsed": true + }, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 1246 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1227 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 1254 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#432", + "bgcolor": "#653" + }, + { + "id": 721, + "type": "PrimitiveStringMultiline", + "pos": [ + 4090, + -1600 + ], + "size": [ + 400, + 200 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 1342 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1228 + ] + } + ], + "title": "Gobal Gen Prompt", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 722, + "type": "StringConcatenate", + "pos": [ + 4090, + -1360 + ], + "size": [ + 230, + 0 + ], + "flags": { + "collapsed": true + }, + "order": 28, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 1228 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 1264 + }, + { + "localized_name": "delimiter", + "name": "delimiter", + "type": "STRING", + "widget": { + "name": "delimiter" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1227 + ] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "", + "", + " " + ], + "widgets_values_named": { + "string_a": "", + "string_b": "", + "delimiter": " " + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 723, + "type": "ComfySwitchNode", + "pos": [ + 4990, + -1370 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 1230 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 1231 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 1232 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 1277 + ] + } + ], + "title": "Switch(CFG)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 724, + "type": "ComfySwitchNode", + "pos": [ + 4990, + -1590 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 1233 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 1234 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 1235 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 1276 + ] + } + ], + "title": "Switch(Steps)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 725, + "type": "PrimitiveFloat", + "pos": [ + 4610, + -1450 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 1231 + ] + } + ], + "title": "CFG", + "properties": { + "Node name for S&R": "PrimitiveFloat", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1 + ], + "widgets_values_named": { + "value": 1 + } + }, + { + "id": 726, + "type": "PrimitiveFloat", + "pos": [ + 4620, + -1090 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "FLOAT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 1230 + ] + } + ], + "title": "CFG", + "properties": { + "Node name for S&R": "PrimitiveFloat", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 5 + ], + "widgets_values_named": { + "value": 5 + } + }, + { + "id": 727, + "type": "PrimitiveInt", + "pos": [ + 4610, + -1600 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1234 + ] + } + ], + "title": "Steps", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 6, + "fixed" + ], + "widgets_values_named": { + "value": 6, + "fixed": "fixed" + } + }, + { + "id": 728, + "type": "CLIPVisionEncode", + "pos": [ + 4160, + -450 + ], + "size": [ + 300, + 140 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_vision", + "name": "clip_vision", + "type": "CLIP_VISION", + "link": 1247 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 1236 + }, + { + "localized_name": "crop", + "name": "crop", + "type": "COMBO", + "widget": { + "name": "crop" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION_OUTPUT", + "name": "CLIP_VISION_OUTPUT", + "type": "CLIP_VISION_OUTPUT", + "links": [ + 1257, + 1258, + 1310 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionEncode", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "crop": "none" + }, + "color": "#233", + "bgcolor": "#355" + }, + { + "id": 729, + "type": "PrimitiveBoolean", + "pos": [ + 4600, + -930 + ], + "size": [ + 300, + 100 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 1229, + 1232, + 1235 + ] + } + ], + "title": "Enable lightx2v lora", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + }, + { + "id": 730, + "type": "ResizeImageMaskNode", + "pos": [ + 4170, + -750 + ], + "size": [ + 280, + 160 + ], + "flags": {}, + "order": 30, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 1239 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 1237 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 1238 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 1236, + 1279 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "scale dimensions", + 480, + 832, + "center", + "lanczos" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 480, + "resize_type.height": 832, + "resize_type.crop": "center", + "scale_method": "lanczos" + } + }, + { + "id": 731, + "type": "PrimitiveInt", + "pos": [ + 4610, + -1230 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1233 + ] + } + ], + "title": "Steps", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 25, + "fixed" + ], + "widgets_values_named": { + "value": 25, + "fixed": "fixed" + } + }, + { + "id": 732, + "type": "CreateVideo", + "pos": [ + 6210, + -1210 + ], + "size": [ + 290, + 170 + ], + "flags": {}, + "order": 31, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 1253 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": null + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 1363 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 8, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 8, + "bit_depth": 8, + "color_space": "sRGB" + } + }, + { + "id": 733, + "type": "ModelSamplingSD3", + "pos": [ + 4950, + -530 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 32, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1243 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 1244 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingSD3", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 5 + ], + "widgets_values_named": { + "shift": 5 + } + }, + { + "id": 734, + "type": "ComfySwitchNode", + "pos": [ + 4990, + -1160 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 33, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 1248 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 1249 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 1229 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 1243 + ] + } + ], + "title": "Switch(Model)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 735, + "type": "SkipLayerGuidanceDiTSimple", + "pos": [ + 4950, + -710 + ], + "size": [ + 290, + 140 + ], + "flags": {}, + "order": 34, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1244 + }, + { + "localized_name": "double_layers", + "name": "double_layers", + "type": "STRING", + "widget": { + "name": "double_layers" + }, + "link": null + }, + { + "localized_name": "single_layers", + "name": "single_layers", + "type": "STRING", + "widget": { + "name": "single_layers" + }, + "link": null + }, + { + "localized_name": "start_percent", + "name": "start_percent", + "type": "FLOAT", + "widget": { + "name": "start_percent" + }, + "link": null + }, + { + "localized_name": "end_percent", + "name": "end_percent", + "type": "FLOAT", + "widget": { + "name": "end_percent" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 1271, + 1272 + ] + } + ], + "properties": { + "Node name for S&R": "SkipLayerGuidanceDiTSimple", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "9", + "", + 0, + 1 + ], + "widgets_values_named": { + "double_layers": "9", + "single_layers": "", + "start_percent": 0, + "end_percent": 1 + } + }, + { + "id": 736, + "type": "VAEDecode", + "pos": [ + 6230, + -1360 + ], + "size": [ + 260, + 100 + ], + "flags": {}, + "order": 35, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 1266 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 1250 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1253, + 1318 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 737, + "type": "WanDancerVideo", + "pos": [ + 4610, + -740 + ], + "size": [ + 280, + 340 + ], + "flags": {}, + "order": 36, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 1254 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 1255 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 1256 + }, + { + "localized_name": "clip_vision_output", + "name": "clip_vision_output", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 1257 + }, + { + "localized_name": "clip_vision_output_ref", + "name": "clip_vision_output_ref", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 1258 + }, + { + "localized_name": "start_image", + "name": "start_image", + "shape": 7, + "type": "IMAGE", + "link": 1279 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "localized_name": "audio_encoder_output", + "name": "audio_encoder_output", + "shape": 7, + "type": "AUDIO_ENCODER_OUTPUT", + "link": 1380 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 1325 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 1326 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 1328 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 1273 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 1274 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 1275 + ] + } + ], + "properties": { + "Node name for S&R": "WanDancerVideo", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 480, + 544, + 149 + ], + "widgets_values_named": { + "width": 480, + "height": 544, + "length": 149 + } + }, + { + "id": 738, + "type": "WanDancerEncodeAudio", + "pos": [ + 4150, + -1000 + ], + "size": [ + 300, + 160 + ], + "flags": {}, + "order": 37, + "mode": 0, + "inputs": [ + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "link": 1265 + }, + { + "localized_name": "video_frames", + "name": "video_frames", + "type": "INT", + "widget": { + "name": "video_frames" + }, + "link": null + }, + { + "localized_name": "audio_inject_scale", + "name": "audio_inject_scale", + "type": "FLOAT", + "widget": { + "name": "audio_inject_scale" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "audio_encoder_output", + "name": "audio_encoder_output", + "type": "AUDIO_ENCODER_OUTPUT", + "links": [ + 1380 + ] + }, + { + "localized_name": "fps_string", + "name": "fps_string", + "type": "STRING", + "links": [ + 1264 + ] + } + ], + "properties": { + "Node name for S&R": "WanDancerEncodeAudio", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 149, + 1 + ], + "widgets_values_named": { + "video_frames": 149, + "audio_inject_scale": 1 + } + }, + { + "id": 739, + "type": "LatentCutToBatch", + "pos": [ + 6220, + -1550 + ], + "size": [ + 270, + 140 + ], + "flags": {}, + "order": 38, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 1278 + }, + { + "localized_name": "dim", + "name": "dim", + "type": "COMBO", + "widget": { + "name": "dim" + }, + "link": null + }, + { + "localized_name": "slice_size", + "name": "slice_size", + "type": "INT", + "widget": { + "name": "slice_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 1266 + ] + } + ], + "properties": { + "Node name for S&R": "LatentCutToBatch", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "t", + 1 + ], + "widgets_values_named": { + "dim": "t", + "slice_size": 1 + } + }, + { + "id": 740, + "type": "BasicScheduler", + "pos": [ + 5470, + -1040 + ], + "size": [ + 270, + 180 + ], + "flags": {}, + "order": 39, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1271 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": 1276 + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 1270 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "simple", + 48, + 1 + ], + "widgets_values_named": { + "scheduler": "simple", + "steps": 48, + "denoise": 1 + } + }, + { + "id": 741, + "type": "RandomNoise", + "pos": [ + 5460, + -1580 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 40, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 1377 + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 1267 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 0, + "fixed" + ], + "widgets_values_named": { + "noise_seed": 0, + "control_after_generate": "fixed" + } + }, + { + "id": 742, + "type": "SamplerCustomAdvanced", + "pos": [ + 5820, + -1570 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 41, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 1267 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 1268 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 1269 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 1270 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 1275 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 1278 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 743, + "type": "KSamplerSelect", + "pos": [ + 5460, + -1210 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 1269 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "euler" + ], + "widgets_values_named": { + "sampler_name": "euler" + } + }, + { + "id": 744, + "type": "CFGGuider", + "pos": [ + 5460, + -1420 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 42, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1272 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 1273 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 1274 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": 1277 + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 1268 + ] + } + ], + "properties": { + "Node name for S&R": "CFGGuider", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1 + ], + "widgets_values_named": { + "cfg": 1 + } + }, + { + "id": 745, + "type": "VAEDecode", + "pos": [ + 6200, + 160 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 43, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 1295 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 1303 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1329 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + } + }, + { + "id": 746, + "type": "KSamplerSelect", + "pos": [ + 5420, + 160 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 1299 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "euler" + ], + "widgets_values_named": { + "sampler_name": "euler" + } + }, + { + "id": 747, + "type": "ConditioningZeroOut", + "pos": [ + 4270, + 700 + ], + "size": [ + 400, + 80 + ], + "flags": { + "collapsed": false + }, + "order": 44, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 1292 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 1307 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 748, + "type": "BasicScheduler", + "pos": [ + 5410, + 370 + ], + "size": [ + 300, + 180 + ], + "flags": {}, + "order": 45, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1301 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 1294 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "simple", + 6, + 1 + ], + "widgets_values_named": { + "scheduler": "simple", + "steps": 6, + "denoise": 1 + } + }, + { + "id": 749, + "type": "CLIPTextEncode", + "pos": [ + 4270, + 270 + ], + "size": [ + 400, + 380 + ], + "flags": { + "collapsed": false + }, + "order": 46, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 1302 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1341 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 1292, + 1306 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 750, + "type": "CLIPVisionEncode", + "pos": [ + 4850, + 740 + ], + "size": [ + 300, + 140 + ], + "flags": {}, + "order": 47, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_vision", + "name": "clip_vision", + "type": "CLIP_VISION", + "link": 1304 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 1293 + }, + { + "localized_name": "crop", + "name": "crop", + "type": "COMBO", + "widget": { + "name": "crop" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION_OUTPUT", + "name": "CLIP_VISION_OUTPUT", + "type": "CLIP_VISION_OUTPUT", + "links": [ + 1309 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionEncode", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "crop": "none" + } + }, + { + "id": 751, + "type": "ImageFromBatch", + "pos": [ + 4860, + 550 + ], + "size": [ + 280, + 140 + ], + "flags": {}, + "order": 48, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 1320 + }, + { + "localized_name": "batch_index", + "name": "batch_index", + "type": "INT", + "widget": { + "name": "batch_index" + }, + "link": null + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1293 + ] + } + ], + "properties": { + "Node name for S&R": "ImageFromBatch", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 0, + 1 + ], + "widgets_values_named": { + "batch_index": 0, + "length": 1 + } + }, + { + "id": 752, + "type": "PrimitiveInt", + "pos": [ + 4860, + 930 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 1300 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1316 + ] + } + ], + "title": "Length", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.15.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 149, + "fixed" + ], + "widgets_values_named": { + "value": 149, + "fixed": "fixed" + } + }, + { + "id": 753, + "type": "SamplerCustom", + "pos": [ + 5840, + 150 + ], + "size": [ + 280, + 690 + ], + "flags": {}, + "order": 49, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1305 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 1311 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 1312 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 1299 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 1294 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 1313 + }, + { + "localized_name": "add_noise", + "name": "add_noise", + "type": "BOOLEAN", + "widget": { + "name": "add_noise" + }, + "link": null + }, + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 1295 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustom", + "cnr_id": "comfy-core", + "ver": "0.13.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + true, + 1, + "fixed", + 1 + ], + "widgets_values_named": { + "add_noise": true, + "noise_seed": 1, + "control_after_generate": "fixed", + "cfg": 1 + } + }, + { + "id": 754, + "type": "WanDancerVideo", + "pos": [ + 5420, + 640 + ], + "size": [ + 280, + 340 + ], + "flags": {}, + "order": 50, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 1306 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 1307 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 1308 + }, + { + "localized_name": "clip_vision_output", + "name": "clip_vision_output", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 1309 + }, + { + "localized_name": "clip_vision_output_ref", + "name": "clip_vision_output_ref", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 1310 + }, + { + "localized_name": "start_image", + "name": "start_image", + "shape": 7, + "type": "IMAGE", + "link": 1321 + }, + { + "localized_name": "mask", + "name": "mask", + "shape": 7, + "type": "MASK", + "link": 1322 + }, + { + "localized_name": "audio_encoder_output", + "name": "audio_encoder_output", + "shape": 7, + "type": "AUDIO_ENCODER_OUTPUT", + "link": 1317 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 1314 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 1315 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 1316 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 1311 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 1312 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 1313 + ] + } + ], + "properties": { + "Node name for S&R": "WanDancerVideo", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 480, + 832, + 149 + ], + "widgets_values_named": { + "width": 480, + "height": 832, + "length": 149 + } + }, + { + "id": 755, + "type": "WanDancerEncodeAudio", + "pos": [ + 4860, + 340 + ], + "size": [ + 280, + 160 + ], + "flags": {}, + "order": 51, + "mode": 0, + "inputs": [ + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "link": 1324 + }, + { + "localized_name": "video_frames", + "name": "video_frames", + "type": "INT", + "widget": { + "name": "video_frames" + }, + "link": 1332 + }, + { + "localized_name": "audio_inject_scale", + "name": "audio_inject_scale", + "type": "FLOAT", + "widget": { + "name": "audio_inject_scale" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "audio_encoder_output", + "name": "audio_encoder_output", + "type": "AUDIO_ENCODER_OUTPUT", + "links": [ + 1317 + ] + }, + { + "localized_name": "fps_string", + "name": "fps_string", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "WanDancerEncodeAudio", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 149, + 1 + ], + "widgets_values_named": { + "video_frames": 149, + "audio_inject_scale": 1 + } + }, + { + "id": 756, + "type": "WanDancerPadKeyframesList", + "pos": [ + 4850, + 100 + ], + "size": [ + 300, + 190 + ], + "flags": {}, + "order": 52, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 1318 + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "link": 1319 + }, + { + "localized_name": "segment_length", + "name": "segment_length", + "type": "INT", + "widget": { + "name": "segment_length" + }, + "link": 1327 + }, + { + "localized_name": "num_segments", + "name": "num_segments", + "type": "INT", + "widget": { + "name": "num_segments" + }, + "link": 1379 + } + ], + "outputs": [ + { + "localized_name": "keyframes_sequence", + "name": "keyframes_sequence", + "shape": 6, + "type": "IMAGE", + "links": [ + 1320, + 1321 + ] + }, + { + "localized_name": "keyframes_mask", + "name": "keyframes_mask", + "shape": 6, + "type": "MASK", + "links": [ + 1322 + ] + }, + { + "localized_name": "audio_segment", + "name": "audio_segment", + "shape": 6, + "type": "AUDIO", + "links": [ + 1324 + ] + } + ], + "properties": { + "Node name for S&R": "WanDancerPadKeyframesList", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 149, + 2 + ], + "widgets_values_named": { + "segment_length": 149, + "num_segments": 2 + } + }, + { + "id": 757, + "type": "RebatchImages", + "pos": [ + 6200, + 320 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 53, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 1329 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "shape": 6, + "type": "IMAGE", + "links": [ + 1330 + ] + } + ], + "properties": { + "Node name for S&R": "RebatchImages", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 4096 + ], + "widgets_values_named": { + "batch_size": 4096 + } + }, + { + "id": 758, + "type": "StringReplace", + "pos": [ + 3720, + -1300 + ], + "size": [ + 230, + 0 + ], + "flags": { + "collapsed": true + }, + "order": 54, + "mode": 0, + "inputs": [ + { + "localized_name": "string", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": null + }, + { + "localized_name": "find", + "name": "find", + "type": "STRING", + "widget": { + "name": "find" + }, + "link": null + }, + { + "localized_name": "replace", + "name": "replace", + "type": "STRING", + "widget": { + "name": "replace" + }, + "link": 1334 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1339, + 1342 + ] + } + ], + "title": "Text Replace (Global prompt)", + "properties": { + "Node name for S&R": "StringReplace", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "一个人正在跳舞,舞蹈种类是", + "", + "" + ], + "widgets_values_named": { + "string": "一个人正在跳舞,舞蹈种类是", + "find": "", + "replace": "" + } + }, + { + "id": 759, + "type": "StringReplace", + "pos": [ + 3710, + -1140 + ], + "size": [ + 230, + 0 + ], + "flags": { + "collapsed": true + }, + "order": 55, + "mode": 0, + "inputs": [ + { + "localized_name": "string", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": null + }, + { + "localized_name": "find", + "name": "find", + "type": "STRING", + "widget": { + "name": "find" + }, + "link": null + }, + { + "localized_name": "replace", + "name": "replace", + "type": "STRING", + "widget": { + "name": "replace" + }, + "link": 1335 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1340 + ] + } + ], + "title": "Text Replace (Local prompt)", + "properties": { + "Node name for S&R": "StringReplace", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + ",图像清晰程度高,人物动作幅度", + "", + "" + ], + "widgets_values_named": { + "string": ",图像清晰程度高,人物动作幅度", + "find": "", + "replace": "" + } + }, + { + "id": 760, + "type": "dfd0a05c-c3fb-488e-bb0d-1be528acd335", + "pos": [ + 3690, + -1690 + ], + "size": [ + 280, + 340 + ], + "flags": { + "collapsed": false + }, + "order": 56, + "mode": 0, + "inputs": [ + { + "label": "prompt", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": null + }, + { + "label": "index", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 1343 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1334 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "previewExposures": [] + }, + "widgets_values": [ + "古典舞\n韩舞\n街舞\n拉丁舞\n踢踏舞", + 0 + ], + "widgets_values_named": { + "string": "古典舞\n韩舞\n街舞\n拉丁舞\n踢踏舞", + "value": 0 + } + }, + { + "id": 761, + "type": "d78651e5-09df-42bf-b768-6ed91cd8089a", + "pos": [ + 3720, + -1220 + ], + "size": [ + 230, + 30 + ], + "flags": { + "collapsed": true + }, + "order": 57, + "mode": 0, + "inputs": [ + { + "label": "prompt", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": null + }, + { + "label": "index", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 1344 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1335 + ] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + }, + "previewExposures": [] + }, + "widgets_values": [ + "低\n中等\n高\n最大", + 0 + ], + "widgets_values_named": { + "string": "低\n中等\n高\n最大", + "value": 0 + } + }, + { + "id": 762, + "type": "StringConcatenate", + "pos": [ + 3710, + -1090 + ], + "size": [ + 230, + 0 + ], + "flags": { + "collapsed": true + }, + "order": 58, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 1339 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 1340 + }, + { + "localized_name": "delimiter", + "name": "delimiter", + "type": "STRING", + "widget": { + "name": "delimiter" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1341 + ] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "", + "", + "" + ], + "widgets_values_named": { + "string_a": "", + "string_b": "", + "delimiter": "" + } + }, + { + "id": 763, + "type": "CustomCombo", + "pos": [ + 3400, + -1310 + ], + "size": [ + 280, + 320 + ], + "flags": {}, + "order": 59, + "mode": 0, + "inputs": [ + { + "localized_name": "choice", + "name": "choice", + "type": "COMBO", + "widget": { + "name": "choice" + }, + "link": 1350 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [] + }, + { + "localized_name": "INDEX", + "name": "INDEX", + "type": "INT", + "links": [ + 1344 + ] + } + ], + "title": "Custom Combo (Motion Amplitude)", + "properties": { + "Node name for S&R": "CustomCombo", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "low 低", + 0, + "low 低", + "medium 中等", + "high 高", + "max 最大", + "" + ], + "widgets_values_named": { + "choice": "low 低", + "index": 0, + "option1": "low 低", + "option2": "medium 中等", + "option3": "high 高", + "option4": "max 最大", + "option5": "" + } + }, + { + "id": 764, + "type": "CustomCombo", + "pos": [ + 3400, + -1700 + ], + "size": [ + 260, + 350 + ], + "flags": {}, + "order": 60, + "mode": 0, + "inputs": [ + { + "localized_name": "choice", + "name": "choice", + "type": "COMBO", + "widget": { + "name": "choice" + }, + "link": 1349 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + }, + { + "localized_name": "INDEX", + "name": "INDEX", + "type": "INT", + "links": [ + 1343 + ] + } + ], + "title": "Custom Combo (Dance Style)", + "properties": { + "Node name for S&R": "CustomCombo", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Latin Dance 拉丁舞", + 3, + "Chinese Classic Dance 古典舞", + "K-Pop 韩舞", + "Street Dance 街舞", + "Latin Dance 拉丁舞", + "Tap Dance 踢踏舞", + "" + ], + "widgets_values_named": { + "choice": "Latin Dance 拉丁舞", + "index": 3, + "option1": "Chinese Classic Dance 古典舞", + "option2": "K-Pop 韩舞", + "option3": "Street Dance 街舞", + "option4": "Latin Dance 拉丁舞", + "option5": "Tap Dance 踢踏舞", + "option6": "" + } + }, + { + "id": 765, + "type": "CustomCombo", + "pos": [ + 4190, + 1000 + ], + "size": [ + 280, + 420 + ], + "flags": {}, + "order": 61, + "mode": 0, + "inputs": [ + { + "localized_name": "choice", + "name": "choice", + "type": "COMBO", + "widget": { + "name": "choice" + }, + "link": 1368 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [] + }, + { + "localized_name": "INDEX", + "name": "INDEX", + "type": "INT", + "links": [ + 1378 + ] + } + ], + "title": "Custom Combo (Duration)", + "properties": { + "Node name for S&R": "CustomCombo", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "5", + 0, + "5", + "10", + "15", + "20", + "25", + "30", + "" + ], + "widgets_values_named": { + "choice": "5", + "index": 0, + "option1": "5", + "option2": "10", + "option3": "15", + "option4": "20", + "option5": "25", + "option6": "30", + "option7": "" + } + }, + { + "id": 766, + "type": "ComfyMathExpression", + "pos": [ + 4500, + 1000 + ], + "size": [ + 240, + 190 + ], + "flags": {}, + "order": 62, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 1378 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": null + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1379 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.20.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "a + 1" + ], + "widgets_values_named": { + "expression": "a + 1" + } + }, + { + "id": 767, + "type": "MarkdownNote", + "pos": [ + 2970, + -1700 + ], + "size": [ + 370, + 220 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [], + "outputs": [], + "properties": {}, + "widgets_values": [ + "The original prompt is in Chinese. You can try to add more options here.\n\nThe base options here are the default options provided by the Wan team." + ], + "widgets_values_named": { + "text": "The original prompt is in Chinese. You can try to add more options here.\n\nThe base options here are the default options provided by the Wan team." + }, + "color": "#222", + "bgcolor": "#000" + } + ], + "groups": [ + { + "id": 10, + "title": "Video Settings", + "bounding": [ + 3370, + -710, + 610, + 510 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 19, + "title": "Local Generation (interpolating)", + "bounding": [ + 4010, + -290, + 2560, + 1790 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 21, + "title": "Prompt", + "bounding": [ + 3370, + -1770, + 610, + 790 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 22, + "title": "Local Model", + "bounding": [ + 3380, + 1000, + 590, + 480 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 24, + "title": "Global Model", + "bounding": [ + 3380, + -130, + 590, + 470 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 25, + "title": "Common Models", + "bounding": [ + 3380, + 370, + 590, + 610 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Global Generation", + "bounding": [ + 4010, + -1770, + 2560, + 1450 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Prompt", + "bounding": [ + 4070, + -1680, + 460, + 610 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 12, + "title": "Lightning LoRA Settings", + "bounding": [ + 4560, + -1680, + 380, + 350 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 13, + "title": "Original LoRA Settings", + "bounding": [ + 4560, + -1310, + 370, + 330 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 14, + "title": "Switch", + "bounding": [ + 4970, + -1680, + 320, + 700 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 28, + "title": "Sampler", + "bounding": [ + 5440, + -1660, + 690, + 830 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 15, + "title": "Prompt", + "bounding": [ + 4240, + 180, + 460, + 620 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 29, + "title": "Models", + "bounding": [ + 3370, + -170, + 610, + 1670 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 30, + "title": "Trim Audio", + "bounding": [ + 3370, + -950, + 610, + 220 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 31, + "title": "Sampling", + "bounding": [ + 5390, + 80, + 790, + 910 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 1065, + "origin_id": 705, + "origin_slot": 0, + "target_id": 706, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 966, + "origin_id": 708, + "origin_slot": 0, + "target_id": 707, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1330, + "origin_id": 757, + "origin_slot": 0, + "target_id": 713, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 1281, + "origin_id": 716, + "origin_slot": 0, + "target_id": 713, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 1130, + "origin_id": 714, + "origin_slot": 0, + "target_id": 717, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1133, + "origin_id": 715, + "origin_slot": 0, + "target_id": 718, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1245, + "origin_id": 709, + "origin_slot": 0, + "target_id": 719, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 1246, + "origin_id": 709, + "origin_slot": 0, + "target_id": 720, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 1227, + "origin_id": 722, + "origin_slot": 0, + "target_id": 720, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 1342, + "origin_id": 758, + "origin_slot": 0, + "target_id": 721, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 1228, + "origin_id": 721, + "origin_slot": 0, + "target_id": 722, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 1264, + "origin_id": 738, + "origin_slot": 1, + "target_id": 722, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 1230, + "origin_id": 726, + "origin_slot": 0, + "target_id": 723, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 1231, + "origin_id": 725, + "origin_slot": 0, + "target_id": 723, + "target_slot": 1, + "type": "FLOAT" + }, + { + "id": 1232, + "origin_id": 729, + "origin_slot": 0, + "target_id": 723, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 1233, + "origin_id": 731, + "origin_slot": 0, + "target_id": 724, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1234, + "origin_id": 727, + "origin_slot": 0, + "target_id": 724, + "target_slot": 1, + "type": "INT" + }, + { + "id": 1235, + "origin_id": 729, + "origin_slot": 0, + "target_id": 724, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 1247, + "origin_id": 710, + "origin_slot": 0, + "target_id": 728, + "target_slot": 0, + "type": "CLIP_VISION" + }, + { + "id": 1236, + "origin_id": 730, + "origin_slot": 0, + "target_id": 728, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 1237, + "origin_id": 717, + "origin_slot": 1, + "target_id": 730, + "target_slot": 2, + "type": "INT" + }, + { + "id": 1238, + "origin_id": 718, + "origin_slot": 1, + "target_id": 730, + "target_slot": 3, + "type": "INT" + }, + { + "id": 1253, + "origin_id": 736, + "origin_slot": 0, + "target_id": 732, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 1243, + "origin_id": 734, + "origin_slot": 0, + "target_id": 733, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1248, + "origin_id": 705, + "origin_slot": 0, + "target_id": 734, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1249, + "origin_id": 706, + "origin_slot": 0, + "target_id": 734, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 1229, + "origin_id": 729, + "origin_slot": 0, + "target_id": 734, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 1244, + "origin_id": 733, + "origin_slot": 0, + "target_id": 735, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1266, + "origin_id": 739, + "origin_slot": 0, + "target_id": 736, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 1250, + "origin_id": 712, + "origin_slot": 0, + "target_id": 736, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 1254, + "origin_id": 720, + "origin_slot": 0, + "target_id": 737, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 1255, + "origin_id": 719, + "origin_slot": 0, + "target_id": 737, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 1256, + "origin_id": 712, + "origin_slot": 0, + "target_id": 737, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 1257, + "origin_id": 728, + "origin_slot": 0, + "target_id": 737, + "target_slot": 3, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 1258, + "origin_id": 728, + "origin_slot": 0, + "target_id": 737, + "target_slot": 4, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 1279, + "origin_id": 730, + "origin_slot": 0, + "target_id": 737, + "target_slot": 5, + "type": "IMAGE" + }, + { + "id": 1325, + "origin_id": 717, + "origin_slot": 1, + "target_id": 737, + "target_slot": 8, + "type": "INT" + }, + { + "id": 1326, + "origin_id": 718, + "origin_slot": 1, + "target_id": 737, + "target_slot": 9, + "type": "INT" + }, + { + "id": 1328, + "origin_id": 711, + "origin_slot": 0, + "target_id": 737, + "target_slot": 10, + "type": "INT" + }, + { + "id": 1265, + "origin_id": 716, + "origin_slot": 0, + "target_id": 738, + "target_slot": 0, + "type": "AUDIO" + }, + { + "id": 1278, + "origin_id": 742, + "origin_slot": 0, + "target_id": 739, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 1271, + "origin_id": 735, + "origin_slot": 0, + "target_id": 740, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1276, + "origin_id": 724, + "origin_slot": 0, + "target_id": 740, + "target_slot": 2, + "type": "INT" + }, + { + "id": 1267, + "origin_id": 741, + "origin_slot": 0, + "target_id": 742, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 1268, + "origin_id": 744, + "origin_slot": 0, + "target_id": 742, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 1269, + "origin_id": 743, + "origin_slot": 0, + "target_id": 742, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 1270, + "origin_id": 740, + "origin_slot": 0, + "target_id": 742, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 1275, + "origin_id": 737, + "origin_slot": 2, + "target_id": 742, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 1272, + "origin_id": 735, + "origin_slot": 0, + "target_id": 744, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1273, + "origin_id": 737, + "origin_slot": 0, + "target_id": 744, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 1274, + "origin_id": 737, + "origin_slot": 1, + "target_id": 744, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 1277, + "origin_id": 723, + "origin_slot": 0, + "target_id": 744, + "target_slot": 3, + "type": "FLOAT" + }, + { + "id": 1295, + "origin_id": 753, + "origin_slot": 0, + "target_id": 745, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 1303, + "origin_id": 712, + "origin_slot": 0, + "target_id": 745, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 1292, + "origin_id": 749, + "origin_slot": 0, + "target_id": 747, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 1301, + "origin_id": 707, + "origin_slot": 0, + "target_id": 748, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1302, + "origin_id": 709, + "origin_slot": 0, + "target_id": 749, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 1341, + "origin_id": 762, + "origin_slot": 0, + "target_id": 749, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 1304, + "origin_id": 710, + "origin_slot": 0, + "target_id": 750, + "target_slot": 0, + "type": "CLIP_VISION" + }, + { + "id": 1293, + "origin_id": 751, + "origin_slot": 0, + "target_id": 750, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 1320, + "origin_id": 756, + "origin_slot": 0, + "target_id": 751, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 1300, + "origin_id": 711, + "origin_slot": 0, + "target_id": 752, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1305, + "origin_id": 707, + "origin_slot": 0, + "target_id": 753, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1311, + "origin_id": 754, + "origin_slot": 0, + "target_id": 753, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 1312, + "origin_id": 754, + "origin_slot": 1, + "target_id": 753, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 1299, + "origin_id": 746, + "origin_slot": 0, + "target_id": 753, + "target_slot": 3, + "type": "SAMPLER" + }, + { + "id": 1294, + "origin_id": 748, + "origin_slot": 0, + "target_id": 753, + "target_slot": 4, + "type": "SIGMAS" + }, + { + "id": 1313, + "origin_id": 754, + "origin_slot": 2, + "target_id": 753, + "target_slot": 5, + "type": "LATENT" + }, + { + "id": 1306, + "origin_id": 749, + "origin_slot": 0, + "target_id": 754, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 1307, + "origin_id": 747, + "origin_slot": 0, + "target_id": 754, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 1308, + "origin_id": 712, + "origin_slot": 0, + "target_id": 754, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 1309, + "origin_id": 750, + "origin_slot": 0, + "target_id": 754, + "target_slot": 3, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 1310, + "origin_id": 728, + "origin_slot": 0, + "target_id": 754, + "target_slot": 4, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 1321, + "origin_id": 756, + "origin_slot": 0, + "target_id": 754, + "target_slot": 5, + "type": "IMAGE" + }, + { + "id": 1322, + "origin_id": 756, + "origin_slot": 1, + "target_id": 754, + "target_slot": 6, + "type": "MASK" + }, + { + "id": 1317, + "origin_id": 755, + "origin_slot": 0, + "target_id": 754, + "target_slot": 7, + "type": "AUDIO_ENCODER_OUTPUT" + }, + { + "id": 1314, + "origin_id": 717, + "origin_slot": 1, + "target_id": 754, + "target_slot": 8, + "type": "INT" + }, + { + "id": 1315, + "origin_id": 718, + "origin_slot": 1, + "target_id": 754, + "target_slot": 9, + "type": "INT" + }, + { + "id": 1316, + "origin_id": 752, + "origin_slot": 0, + "target_id": 754, + "target_slot": 10, + "type": "INT" + }, + { + "id": 1324, + "origin_id": 756, + "origin_slot": 2, + "target_id": 755, + "target_slot": 0, + "type": "AUDIO" + }, + { + "id": 1332, + "origin_id": 711, + "origin_slot": 0, + "target_id": 755, + "target_slot": 1, + "type": "INT" + }, + { + "id": 1318, + "origin_id": 736, + "origin_slot": 0, + "target_id": 756, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 1319, + "origin_id": 716, + "origin_slot": 0, + "target_id": 756, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 1327, + "origin_id": 711, + "origin_slot": 0, + "target_id": 756, + "target_slot": 2, + "type": "INT" + }, + { + "id": 1329, + "origin_id": 745, + "origin_slot": 0, + "target_id": 757, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 1334, + "origin_id": 760, + "origin_slot": 0, + "target_id": 758, + "target_slot": 2, + "type": "STRING" + }, + { + "id": 1335, + "origin_id": 761, + "origin_slot": 0, + "target_id": 759, + "target_slot": 2, + "type": "STRING" + }, + { + "id": 1343, + "origin_id": 764, + "origin_slot": 1, + "target_id": 760, + "target_slot": 1, + "type": "INT" + }, + { + "id": 1344, + "origin_id": 763, + "origin_slot": 1, + "target_id": 761, + "target_slot": 1, + "type": "INT" + }, + { + "id": 1339, + "origin_id": 758, + "origin_slot": 0, + "target_id": 762, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 1340, + "origin_id": 759, + "origin_slot": 0, + "target_id": 762, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 1084, + "origin_id": -10, + "origin_slot": 0, + "target_id": 716, + "target_slot": 0, + "type": "AUDIO" + }, + { + "id": 1239, + "origin_id": -10, + "origin_slot": 1, + "target_id": 730, + "target_slot": 0, + "type": "IMAGE,MASK" + }, + { + "id": 1349, + "origin_id": -10, + "origin_slot": 2, + "target_id": 764, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1350, + "origin_id": -10, + "origin_slot": 3, + "target_id": 763, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1351, + "origin_id": -10, + "origin_slot": 4, + "target_id": 714, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1352, + "origin_id": -10, + "origin_slot": 5, + "target_id": 715, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1353, + "origin_id": -10, + "origin_slot": 6, + "target_id": 716, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 1363, + "origin_id": 732, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 1364, + "origin_id": 713, + "origin_slot": 0, + "target_id": -20, + "target_slot": 1, + "type": "VIDEO" + }, + { + "id": 1368, + "origin_id": -10, + "origin_slot": 7, + "target_id": 765, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1370, + "origin_id": -10, + "origin_slot": 8, + "target_id": 705, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1371, + "origin_id": -10, + "origin_slot": 9, + "target_id": 708, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1372, + "origin_id": -10, + "origin_slot": 10, + "target_id": 706, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 1373, + "origin_id": -10, + "origin_slot": 10, + "target_id": 707, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 1374, + "origin_id": -10, + "origin_slot": 11, + "target_id": 709, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1375, + "origin_id": -10, + "origin_slot": 12, + "target_id": 710, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1376, + "origin_id": -10, + "origin_slot": 13, + "target_id": 712, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 1377, + "origin_id": -10, + "origin_slot": 14, + "target_id": 741, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1378, + "origin_id": 765, + "origin_slot": 1, + "target_id": 766, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1379, + "origin_id": 766, + "origin_slot": 1, + "target_id": 756, + "target_slot": 3, + "type": "INT" + }, + { + "id": 1380, + "origin_id": 738, + "origin_slot": 0, + "target_id": 737, + "target_slot": 7, + "type": "AUDIO_ENCODER_OUTPUT" + } + ], + "extra": {}, + "category": "Video generation/Image to video", + "description": "Generates a dance video from a character image and music using Wan Dancer hierarchical global and local models, with style and motion amplitude controls." + }, + { + "id": "dfd0a05c-c3fb-488e-bb0d-1be528acd335", + "version": 1, + "state": { + "lastGroupId": 31, + "lastNodeId": 776, + "lastLinkId": 1380, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Prompt Selector", + "inputNode": { + "id": -10, + "bounding": [ + -990, + 8595, + 128, + 88 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 710, + 8585, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "75417d82-a934-4ac9-b667-d8dcd5a3bfb3", + "name": "string", + "type": "STRING", + "linkIds": [ + 13 + ], + "label": "prompt", + "pos": [ + -886, + 8619 + ] + }, + { + "id": "46e69a73-1804-4ca6-9175-31445bf0be96", + "name": "value", + "type": "INT", + "linkIds": [ + 14 + ], + "label": "index", + "pos": [ + -886, + 8639 + ] + } + ], + "outputs": [ + { + "id": "e34e8ad1-84d2-4bd2-a460-eb7de6067c10", + "name": "STRING", + "type": "STRING", + "linkIds": [ + 10 + ], + "localized_name": "STRING", + "pos": [ + 734, + 8609 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 768, + "type": "PreviewAny", + "pos": [ + -500, + 8400 + ], + "size": [ + 230, + 180 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 1 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 769, + "type": "RegexExtract", + "pos": [ + -240, + 8740 + ], + "size": [ + 470, + 460 + ], + "flags": {}, + "order": 1, + "mode": 0, + "showAdvanced": false, + "inputs": [ + { + "localized_name": "string", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": 13 + }, + { + "localized_name": "regex_pattern", + "name": "regex_pattern", + "type": "STRING", + "widget": { + "name": "regex_pattern" + }, + "link": 9 + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": null + }, + { + "localized_name": "case_insensitive", + "name": "case_insensitive", + "type": "BOOLEAN", + "widget": { + "name": "case_insensitive" + }, + "link": null + }, + { + "localized_name": "multiline", + "name": "multiline", + "type": "BOOLEAN", + "widget": { + "name": "multiline" + }, + "link": null + }, + { + "localized_name": "dotall", + "name": "dotall", + "type": "BOOLEAN", + "widget": { + "name": "dotall" + }, + "link": null + }, + { + "localized_name": "group_index", + "name": "group_index", + "type": "INT", + "widget": { + "name": "group_index" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "RegexExtract", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "古典舞\n韩舞\n街舞\n拉丁舞\n踢踏舞", + "", + "First Group", + false, + false, + false, + 1 + ], + "widgets_values_named": { + "string": "古典舞\n韩舞\n街舞\n拉丁舞\n踢踏舞", + "regex_pattern": "", + "mode": "First Group", + "case_insensitive": false, + "multiline": false, + "dotall": false, + "group_index": 1 + } + }, + { + "id": 770, + "type": "PrimitiveInt", + "pos": [ + -810, + 8400 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 14 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 0, + "fixed" + ], + "widgets_values_named": { + "value": 0, + "fixed": "fixed" + } + }, + { + "id": 771, + "type": "StringReplace", + "pos": [ + -240, + 8400 + ], + "size": [ + 400, + 280 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "string", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": null + }, + { + "localized_name": "find", + "name": "find", + "type": "STRING", + "widget": { + "name": "find" + }, + "link": null + }, + { + "localized_name": "replace", + "name": "replace", + "type": "STRING", + "widget": { + "name": "replace" + }, + "link": 6 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "StringReplace", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "(?:[^\\n]*\\n){index}([^\\n]+)", + "index", + "" + ], + "widgets_values_named": { + "string": "(?:[^\\n]*\\n){index}([^\\n]+)", + "find": "index", + "replace": "" + } + } + ], + "groups": [], + "links": [ + { + "id": 1, + "origin_id": 770, + "origin_slot": 0, + "target_id": 768, + "target_slot": 0, + "type": "INT" + }, + { + "id": 9, + "origin_id": 771, + "origin_slot": 0, + "target_id": 769, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 6, + "origin_id": 768, + "origin_slot": 0, + "target_id": 771, + "target_slot": 2, + "type": "STRING" + }, + { + "id": 10, + "origin_id": 769, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 13, + "origin_id": -10, + "origin_slot": 0, + "target_id": 769, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 14, + "origin_id": -10, + "origin_slot": 1, + "target_id": 770, + "target_slot": 0, + "type": "INT" + } + ], + "extra": { + "ue_links": [], + "links_added_by_ue": [] + }, + "category": "Text Tools", + "description": "Selects one dance-style prompt by index from a list of prompt options." + }, + { + "id": "d78651e5-09df-42bf-b768-6ed91cd8089a", + "version": 1, + "state": { + "lastGroupId": 31, + "lastNodeId": 776, + "lastLinkId": 1380, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Prompt Selector", + "inputNode": { + "id": -10, + "bounding": [ + -990, + 8595, + 128, + 88 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 710, + 8585, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "75417d82-a934-4ac9-b667-d8dcd5a3bfb3", + "name": "string", + "type": "STRING", + "linkIds": [ + 13 + ], + "label": "prompt", + "pos": [ + -886, + 8619 + ] + }, + { + "id": "46e69a73-1804-4ca6-9175-31445bf0be96", + "name": "value", + "type": "INT", + "linkIds": [ + 14 + ], + "label": "index", + "pos": [ + -886, + 8639 + ] + } + ], + "outputs": [ + { + "id": "e34e8ad1-84d2-4bd2-a460-eb7de6067c10", + "name": "STRING", + "type": "STRING", + "linkIds": [ + 10 + ], + "localized_name": "STRING", + "pos": [ + 734, + 8609 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 772, + "type": "PreviewAny", + "pos": [ + -500, + 8400 + ], + "size": [ + 230, + 180 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 1 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 773, + "type": "RegexExtract", + "pos": [ + -240, + 8740 + ], + "size": [ + 470, + 460 + ], + "flags": {}, + "order": 1, + "mode": 0, + "showAdvanced": false, + "inputs": [ + { + "localized_name": "string", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": 13 + }, + { + "localized_name": "regex_pattern", + "name": "regex_pattern", + "type": "STRING", + "widget": { + "name": "regex_pattern" + }, + "link": 9 + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": null + }, + { + "localized_name": "case_insensitive", + "name": "case_insensitive", + "type": "BOOLEAN", + "widget": { + "name": "case_insensitive" + }, + "link": null + }, + { + "localized_name": "multiline", + "name": "multiline", + "type": "BOOLEAN", + "widget": { + "name": "multiline" + }, + "link": null + }, + { + "localized_name": "dotall", + "name": "dotall", + "type": "BOOLEAN", + "widget": { + "name": "dotall" + }, + "link": null + }, + { + "localized_name": "group_index", + "name": "group_index", + "type": "INT", + "widget": { + "name": "group_index" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "RegexExtract", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "低\n中等\n高\n最大", + "", + "First Group", + false, + false, + false, + 1 + ], + "widgets_values_named": { + "string": "低\n中等\n高\n最大", + "regex_pattern": "", + "mode": "First Group", + "case_insensitive": false, + "multiline": false, + "dotall": false, + "group_index": 1 + } + }, + { + "id": 774, + "type": "PrimitiveInt", + "pos": [ + -810, + 8400 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 14 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 0, + "fixed" + ], + "widgets_values_named": { + "value": 0, + "fixed": "fixed" + } + }, + { + "id": 775, + "type": "StringReplace", + "pos": [ + -240, + 8400 + ], + "size": [ + 400, + 280 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "string", + "name": "string", + "type": "STRING", + "widget": { + "name": "string" + }, + "link": null + }, + { + "localized_name": "find", + "name": "find", + "type": "STRING", + "widget": { + "name": "find" + }, + "link": null + }, + { + "localized_name": "replace", + "name": "replace", + "type": "STRING", + "widget": { + "name": "replace" + }, + "link": 6 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "StringReplace", + "cnr_id": "comfy-core", + "ver": "0.19.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "(?:[^\\n]*\\n){index}([^\\n]+)", + "index", + "" + ], + "widgets_values_named": { + "string": "(?:[^\\n]*\\n){index}([^\\n]+)", + "find": "index", + "replace": "" + } + } + ], + "groups": [], + "links": [ + { + "id": 1, + "origin_id": 774, + "origin_slot": 0, + "target_id": 772, + "target_slot": 0, + "type": "INT" + }, + { + "id": 9, + "origin_id": 775, + "origin_slot": 0, + "target_id": 773, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 6, + "origin_id": 772, + "origin_slot": 0, + "target_id": 775, + "target_slot": 2, + "type": "STRING" + }, + { + "id": 10, + "origin_id": 773, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 13, + "origin_id": -10, + "origin_slot": 0, + "target_id": 773, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 14, + "origin_id": -10, + "origin_slot": 1, + "target_id": 774, + "target_slot": 0, + "type": "INT" + } + ], + "extra": { + "ue_links": [], + "links_added_by_ue": [] + }, + "category": "Text Tools", + "description": "Selects one dance-style prompt by index from a list of prompt options." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Motion Transfer (Wan Animate 2 Distilled).json b/blueprints/Motion Transfer (Wan Animate 2 Distilled).json new file mode 100644 index 00000000000..0fddd870a20 --- /dev/null +++ b/blueprints/Motion Transfer (Wan Animate 2 Distilled).json @@ -0,0 +1,3309 @@ +{ + "revision": 0, + "last_node_id": "633", + "last_link_id": 0, + "nodes": [ + { + "id": 633, + "type": "57b9369a-8c10-4351-8d2f-8b2b5771c838", + "pos": [ + 1360, + 4830 + ], + "size": [ + 500, + 860 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "label": "reference Image", + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "pose_video", + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": null + }, + { + "name": "continue_motion", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "video_frame_offset", + "name": "video_frame_offset_1", + "type": "INT", + "widget": { + "name": "video_frame_offset_1" + }, + "link": null + }, + { + "label": "prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "label": "reference_image_strength", + "name": "reference_image_strength_1", + "type": "FLOAT", + "widget": { + "name": "reference_image_strength_1" + }, + "link": null + }, + { + "label": "pose_prompt", + "name": "text_4", + "type": "STRING", + "widget": { + "name": "text_4" + }, + "link": null + }, + { + "label": "pose_strength", + "name": "pose_strength_1", + "type": "FLOAT", + "widget": { + "name": "pose_strength_1" + }, + "link": null + }, + { + "label": "pose_start_percent", + "name": "pose_start_percent_1", + "type": "FLOAT", + "widget": { + "name": "pose_start_percent_1" + }, + "link": null + }, + { + "label": "pose_end_percent", + "name": "pose_end_percent_1", + "type": "FLOAT", + "widget": { + "name": "pose_end_percent_1" + }, + "link": null + }, + { + "label": "enable_context_window", + "name": "switch_1", + "type": "BOOLEAN", + "widget": { + "name": "switch_1" + }, + "link": null + }, + { + "label": "cache_device", + "name": "device_1", + "type": "COMBO", + "widget": { + "name": "device_1" + }, + "link": null + }, + { + "label": "cache_dtype", + "name": "dtype_1", + "type": "COMBO", + "widget": { + "name": "dtype_1" + }, + "link": null + }, + { + "label": "trim_duplicated_frame", + "name": "value_1", + "type": "BOOLEAN", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": null + }, + { + "label": "height", + "name": "resize_type.height_1", + "type": "INT", + "widget": { + "name": "resize_type.height_1" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + }, + { + "label": "unet", + "name": "unet_name_1", + "type": "COMBO", + "widget": { + "name": "unet_name_1" + }, + "link": null + }, + { + "label": "clip", + "name": "clip_name_2", + "type": "COMBO", + "widget": { + "name": "clip_name_2" + }, + "link": null + }, + { + "label": "clip_vision", + "name": "clip_name_3", + "type": "COMBO", + "widget": { + "name": "clip_name_3" + }, + "link": null + }, + { + "label": "vae", + "name": "vae_name_1", + "type": "COMBO", + "widget": { + "name": "vae_name_1" + }, + "link": null + } + ], + "outputs": [ + { + "label": "continue_motion", + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + }, + { + "name": "video_frame_offset", + "type": "INT", + "links": [] + }, + { + "label": "image", + "name": "output", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.33.0", + "previewExposures": [] + }, + "widgets_values": [ + 0, + "", + 1, + "", + 1, + 0, + 1, + false, + "gpu", + "int8", + false, + 482, + 854, + 714297762067883, + 81, + "wan_animate_2_distill_int8_convrot.safetensors", + "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "clip_vision_h.safetensors", + "Wan2_1_VAE_bf16.safetensors" + ], + "widgets_values_named": { + "video_frame_offset_1": 0, + "text": "", + "reference_image_strength_1": 1, + "text_4": "", + "pose_strength_1": 1, + "pose_start_percent_1": 0, + "pose_end_percent_1": 1, + "switch_1": false, + "device_1": "gpu", + "dtype_1": "int8", + "value_1": false, + "resize_type.width": 482, + "resize_type.height_1": 854, + "noise_seed": 714297762067883, + "length": 81, + "unet_name_1": "wan_animate_2_distill_int8_convrot.safetensors", + "clip_name_2": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "clip_name_3": "clip_vision_h.safetensors", + "vae_name_1": "Wan2_1_VAE_bf16.safetensors" + }, + "title": "Motion Transfer (Wan Animate 2 Distilled)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "57b9369a-8c10-4351-8d2f-8b2b5771c838", + "version": 1, + "state": { + "lastGroupId": 12, + "lastNodeId": 633, + "lastLinkId": 873, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Motion Transfer (Wan Animate 2 Distilled)", + "inputNode": { + "id": -10, + "bounding": [ + -720, + 3220, + 195.5625, + 488 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 4390, + 3310, + 155.935546875, + 108 + ] + }, + "inputs": [ + { + "id": "5652276e-beac-4092-a828-9baa0150355b", + "name": "input", + "type": "IMAGE,MASK", + "linkIds": [ + 657 + ], + "localized_name": "input", + "label": "reference Image", + "pos": [ + -548.4375, + 3244 + ] + }, + { + "id": "d2f087f3-bdae-4de1-8fb6-0a9e5b186ab7", + "name": "video", + "type": "VIDEO", + "linkIds": [ + 648 + ], + "localized_name": "video", + "label": "pose_video", + "pos": [ + -548.4375, + 3264 + ] + }, + { + "id": "6a3a407a-5ecf-4027-b7b5-178ddca1a1a5", + "name": "continue_motion", + "type": "IMAGE", + "linkIds": [ + 714 + ], + "pos": [ + -548.4375, + 3284 + ] + }, + { + "id": "c704c479-c4e8-4202-8c8e-234b63efc420", + "name": "video_frame_offset_1", + "type": "INT", + "linkIds": [ + 804 + ], + "label": "video_frame_offset", + "pos": [ + -548.4375, + 3304 + ] + }, + { + "id": "57d8f22a-6abf-42ee-b29c-8375b541e18d", + "name": "text", + "type": "STRING", + "linkIds": [ + 805 + ], + "label": "prompt", + "pos": [ + -548.4375, + 3324 + ] + }, + { + "id": "2297d7f0-9c93-4c52-916d-67adb3268cee", + "name": "reference_image_strength_1", + "type": "FLOAT", + "linkIds": [ + 811 + ], + "label": "reference_image_strength", + "pos": [ + -548.4375, + 3344 + ] + }, + { + "id": "9daa1424-fa79-4968-8f04-6fd0eba78616", + "name": "text_4", + "type": "STRING", + "linkIds": [ + 812 + ], + "label": "pose_prompt", + "pos": [ + -548.4375, + 3364 + ] + }, + { + "id": "bb4d1069-34ae-40a8-b067-03560962f0df", + "name": "pose_strength_1", + "type": "FLOAT", + "linkIds": [ + 813 + ], + "label": "pose_strength", + "pos": [ + -548.4375, + 3384 + ] + }, + { + "id": "d1f8263c-7501-4214-a959-a25dd44fa8f7", + "name": "pose_start_percent_1", + "type": "FLOAT", + "linkIds": [ + 814 + ], + "label": "pose_start_percent", + "pos": [ + -548.4375, + 3404 + ] + }, + { + "id": "5735d8d5-b3aa-47f2-90bf-2d5a8bfcc0ed", + "name": "pose_end_percent_1", + "type": "FLOAT", + "linkIds": [ + 815 + ], + "label": "pose_end_percent", + "pos": [ + -548.4375, + 3424 + ] + }, + { + "id": "3ab8f3ee-2332-4f5e-85a5-0ae77f73e0cf", + "name": "switch_1", + "type": "BOOLEAN", + "linkIds": [ + 816 + ], + "label": "enable_context_window", + "pos": [ + -548.4375, + 3444 + ] + }, + { + "id": "b34d70bb-dab4-4a6d-a222-85c5627a4316", + "name": "device_1", + "type": "COMBO", + "linkIds": [ + 817 + ], + "label": "cache_device", + "pos": [ + -548.4375, + 3464 + ] + }, + { + "id": "201c6745-0a0d-4baf-9310-2a32af974fbd", + "name": "dtype_1", + "type": "COMBO", + "linkIds": [ + 818 + ], + "label": "cache_dtype", + "pos": [ + -548.4375, + 3484 + ] + }, + { + "id": "30c834c0-d8dd-429b-bfe5-d3c98e44fd05", + "name": "value_1", + "type": "BOOLEAN", + "linkIds": [ + 819 + ], + "label": "trim_duplicated_frame", + "pos": [ + -548.4375, + 3504 + ] + }, + { + "id": "0fcfc566-1d18-4dc9-8300-00d0de4ba446", + "name": "resize_type.width", + "type": "INT", + "linkIds": [ + 820 + ], + "label": "width", + "pos": [ + -548.4375, + 3524 + ] + }, + { + "id": "9e0fb084-d3a8-4811-8cef-092febab0046", + "name": "resize_type.height_1", + "type": "INT", + "linkIds": [ + 821 + ], + "label": "height", + "pos": [ + -548.4375, + 3544 + ] + }, + { + "id": "e910a55a-473e-43bf-b41c-878a090aa00c", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 824 + ], + "pos": [ + -548.4375, + 3564 + ] + }, + { + "id": "70e7ec96-3d6c-4104-8f18-947601f5191c", + "name": "length", + "type": "INT", + "linkIds": [ + 825 + ], + "pos": [ + -548.4375, + 3584 + ] + }, + { + "id": "eb68fa16-5d13-4f52-8030-7ed81727f807", + "name": "unet_name_1", + "type": "COMBO", + "linkIds": [ + 826 + ], + "label": "unet", + "pos": [ + -548.4375, + 3604 + ] + }, + { + "id": "821fee03-f73d-4875-85de-744908b67891", + "name": "clip_name_2", + "type": "COMBO", + "linkIds": [ + 828 + ], + "label": "clip", + "pos": [ + -548.4375, + 3624 + ] + }, + { + "id": "ae0dfb45-e15d-4868-a9c8-ef14e693f65c", + "name": "clip_name_3", + "type": "COMBO", + "linkIds": [ + 829 + ], + "label": "clip_vision", + "pos": [ + -548.4375, + 3644 + ] + }, + { + "id": "d0b2c474-1dd7-474d-a77a-75b010ad8bea", + "name": "vae_name_1", + "type": "COMBO", + "linkIds": [ + 830 + ], + "label": "vae", + "pos": [ + -548.4375, + 3664 + ] + } + ], + "outputs": [ + { + "id": "1fef655d-ce8e-4789-a018-da1a2b97ac75", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 666 + ], + "localized_name": "IMAGE", + "label": "continue_motion", + "pos": [ + 4414, + 3334 + ] + }, + { + "id": "f0f1eeeb-1773-4eaf-ab45-791f6c2324ba", + "name": "video_frame_offset", + "type": "INT", + "linkIds": [ + 721 + ], + "pos": [ + 4414, + 3354 + ] + }, + { + "id": "60baa156-368c-4cab-92fd-3f9880894072", + "name": "output", + "type": "IMAGE", + "linkIds": [ + 755 + ], + "label": "image", + "pos": [ + 4414, + 3374 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 606, + "type": "UNETLoader", + "pos": [ + 100, + 2810 + ], + "size": [ + 660, + 130 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 826 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 864, + 865 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "wan_animate_2_distill_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/diffusion_models/wan_animate_2_distill_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "wan_animate_2_distill_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "wan_animate_2_distill_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 607, + "type": "CLIPLoader", + "pos": [ + 100, + 3210 + ], + "size": [ + 660, + 140 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 828 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 7, + 8, + 590 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "wan", + "default" + ], + "widgets_values_named": { + "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "type": "wan", + "device": "default" + } + }, + { + "id": 608, + "type": "CLIPTextEncode", + "pos": [ + 960, + 3220 + ], + "size": [ + 490, + 120 + ], + "flags": { + "collapsed": false + }, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 8 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 670 + ] + } + ], + "title": "CLIP Text Encode (Negative Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走" + ], + "widgets_values_named": { + "text": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走" + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 609, + "type": "CLIPTextEncode", + "pos": [ + 960, + 2830 + ], + "size": [ + 480, + 310 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 805 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 669 + ] + } + ], + "title": "CLIP Text Encode (Positive Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 610, + "type": "CLIPVisionLoader", + "pos": [ + 100, + 3430 + ], + "size": [ + 660, + 90 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 829 + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION", + "name": "CLIP_VISION", + "type": "CLIP_VISION", + "links": [ + 196, + 584 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionLoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "clip_vision_h.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/clip_vision/clip_vision_h.safetensors", + "directory": "clip_vision" + } + ] + }, + "widgets_values": [ + "clip_vision_h.safetensors" + ], + "widgets_values_named": { + "clip_name": "clip_vision_h.safetensors" + } + }, + { + "id": 611, + "type": "VAELoader", + "pos": [ + 100, + 3600 + ], + "size": [ + 660, + 90 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 830 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 664, + 671 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.33.0", + "models": [ + { + "name": "Wan2_1_VAE_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/vae/Wan2_1_VAE_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "Wan2_1_VAE_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "Wan2_1_VAE_bf16.safetensors" + } + }, + { + "id": 612, + "type": "CLIPTextEncode", + "pos": [ + 960, + 3420 + ], + "size": [ + 490, + 220 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 590 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 812 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 674 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 613, + "type": "ContextWindowsManual", + "pos": [ + 1690, + 2670 + ], + "size": [ + 300, + 410 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 864 + }, + { + "localized_name": "context_length", + "name": "context_length", + "type": "INT", + "widget": { + "name": "context_length" + }, + "link": null + }, + { + "localized_name": "context_overlap", + "name": "context_overlap", + "type": "INT", + "widget": { + "name": "context_overlap" + }, + "link": null + }, + { + "localized_name": "context_schedule", + "name": "context_schedule", + "type": "COMBO", + "widget": { + "name": "context_schedule" + }, + "link": null + }, + { + "localized_name": "context_stride", + "name": "context_stride", + "type": "INT", + "widget": { + "name": "context_stride" + }, + "link": null + }, + { + "localized_name": "closed_loop", + "name": "closed_loop", + "type": "BOOLEAN", + "widget": { + "name": "closed_loop" + }, + "link": null + }, + { + "localized_name": "fuse_method", + "name": "fuse_method", + "type": "COMBO", + "widget": { + "name": "fuse_method" + }, + "link": null + }, + { + "localized_name": "dim", + "name": "dim", + "type": "INT", + "widget": { + "name": "dim" + }, + "link": null + }, + { + "localized_name": "freenoise", + "name": "freenoise", + "type": "BOOLEAN", + "widget": { + "name": "freenoise" + }, + "link": null + }, + { + "localized_name": "cond_retain_index_list", + "name": "cond_retain_index_list", + "type": "STRING", + "widget": { + "name": "cond_retain_index_list" + }, + "link": null + }, + { + "localized_name": "split_conds_to_windows", + "name": "split_conds_to_windows", + "type": "BOOLEAN", + "widget": { + "name": "split_conds_to_windows" + }, + "link": null + }, + { + "localized_name": "latent_retain_index_list", + "name": "latent_retain_index_list", + "type": "STRING", + "widget": { + "name": "latent_retain_index_list" + }, + "link": null + }, + { + "localized_name": "causal_window_fix", + "name": "causal_window_fix", + "type": "BOOLEAN", + "widget": { + "name": "causal_window_fix" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 740 + ] + } + ], + "properties": { + "Node name for S&R": "ContextWindowsManual", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 21, + 8, + "standard_static", + 1, + false, + "pyramid", + 2, + true, + "0", + false, + "", + true + ], + "widgets_values_named": { + "context_length": 21, + "context_overlap": 8, + "context_schedule": "standard_static", + "context_stride": 1, + "closed_loop": false, + "fuse_method": "pyramid", + "dim": 2, + "freenoise": true, + "cond_retain_index_list": "0", + "split_conds_to_windows": false, + "latent_retain_index_list": "", + "causal_window_fix": true + } + }, + { + "id": 614, + "type": "WanAnimate2ToVideo", + "pos": [ + 1920, + 3300 + ], + "size": [ + 350, + 470 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 669 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 670 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 671 + }, + { + "localized_name": "reference_image", + "name": "reference_image", + "shape": 7, + "type": "IMAGE", + "link": 672 + }, + { + "localized_name": "pose_video", + "name": "pose_video", + "shape": 7, + "type": "IMAGE", + "link": 688 + }, + { + "localized_name": "clip_vision_output", + "name": "clip_vision_output", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 683 + }, + { + "localized_name": "positive_pose", + "name": "positive_pose", + "shape": 7, + "type": "CONDITIONING", + "link": 674 + }, + { + "localized_name": "clip_vision_output_pose", + "name": "clip_vision_output_pose", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 675 + }, + { + "localized_name": "continue_motion", + "name": "continue_motion", + "shape": 7, + "type": "IMAGE", + "link": 714 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 691 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 692 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 825 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "video_frame_offset", + "name": "video_frame_offset", + "type": "INT", + "widget": { + "name": "video_frame_offset" + }, + "link": 804 + }, + { + "localized_name": "pose_strength", + "name": "pose_strength", + "type": "FLOAT", + "widget": { + "name": "pose_strength" + }, + "link": 813 + }, + { + "localized_name": "pose_start_percent", + "name": "pose_start_percent", + "type": "FLOAT", + "widget": { + "name": "pose_start_percent" + }, + "link": 814 + }, + { + "localized_name": "pose_end_percent", + "name": "pose_end_percent", + "type": "FLOAT", + "widget": { + "name": "pose_end_percent" + }, + "link": 815 + }, + { + "localized_name": "reference_image_strength", + "name": "reference_image_strength", + "type": "FLOAT", + "widget": { + "name": "reference_image_strength" + }, + "link": 811 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 679 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 680 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 681 + ] + }, + { + "localized_name": "trim_latent", + "name": "trim_latent", + "type": "INT", + "links": [ + 682 + ] + }, + { + "localized_name": "trim_image", + "name": "trim_image", + "type": "INT", + "links": [] + }, + { + "localized_name": "video_frame_offset", + "name": "video_frame_offset", + "type": "INT", + "links": [ + 721 + ] + } + ], + "properties": { + "Node name for S&R": "WanAnimate2ToVideo", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 832, + 480, + 81, + 1, + 0, + 1, + 0, + 1, + 1 + ], + "widgets_values_named": { + "width": 832, + "height": 480, + "length": 81, + "batch_size": 1, + "video_frame_offset": 0, + "pose_strength": 1, + "pose_start_percent": 0, + "pose_end_percent": 1, + "reference_image_strength": 1 + } + }, + { + "id": 615, + "type": "ComfySwitchNode", + "pos": [ + 2170, + 2690 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 865 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 740 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 816 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 700 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 616, + "type": "CLIPVisionEncode", + "pos": [ + 1150, + 4050 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_vision", + "name": "clip_vision", + "type": "CLIP_VISION", + "link": 196 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 658 + }, + { + "localized_name": "crop", + "name": "crop", + "type": "COMBO", + "widget": { + "name": "crop" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION_OUTPUT", + "name": "CLIP_VISION_OUTPUT", + "type": "CLIP_VISION_OUTPUT", + "links": [ + 683 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionEncode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "crop": "none" + }, + "color": "#233", + "bgcolor": "#355" + }, + { + "id": 617, + "type": "ResizeImageMaskNode", + "pos": [ + 780, + 4050 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 657 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 694 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 695 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 658, + 672 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "scale dimensions", + 481, + 854, + "center", + "area" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 481, + "resize_type.height": 854, + "resize_type.crop": "center", + "scale_method": "area" + } + }, + { + "id": 618, + "type": "BasicScheduler", + "pos": [ + 2740, + 3210 + ], + "size": [ + 310, + 120 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 597 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 486 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "simple", + 10, + 1 + ], + "widgets_values_named": { + "scheduler": "simple", + "steps": 10, + "denoise": 1 + } + }, + { + "id": 619, + "type": "ModelSamplingSD3", + "pos": [ + 2740, + 2910 + ], + "size": [ + 310, + 80 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 596 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 564 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingSD3", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 5 + ], + "widgets_values_named": { + "shift": 5 + } + }, + { + "id": 620, + "type": "KSamplerSelect", + "pos": [ + 2740, + 3050 + ], + "size": [ + 310, + 90 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 487 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "lcm" + ], + "widgets_values_named": { + "sampler_name": "lcm" + } + }, + { + "id": 621, + "type": "WanAnimate2Cache", + "pos": [ + 2120, + 2920 + ], + "size": [ + 310, + 120 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 700 + }, + { + "localized_name": "device", + "name": "device", + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": 817 + }, + { + "localized_name": "dtype", + "name": "dtype", + "type": "COMBO", + "widget": { + "name": "dtype" + }, + "link": 818 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 596, + 597 + ] + } + ], + "properties": { + "Node name for S&R": "WanAnimate2Cache", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "gpu", + "int8" + ], + "widgets_values_named": { + "device": "gpu", + "dtype": "int8" + } + }, + { + "id": 622, + "type": "GetVideoComponents", + "pos": [ + 420, + 4430 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 648 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [ + 655 + ] + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "links": [] + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "type": "COMBO", + "links": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "type": "COMBO", + "links": null + } + ], + "properties": { + "Node name for S&R": "GetVideoComponents", + "cnr_id": "comfy-core", + "ver": "0.33.0" + } + }, + { + "id": 623, + "type": "GetImageSize", + "pos": [ + 790, + 4430 + ], + "size": [ + 260, + 140 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 693 + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 691, + 694 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 692, + 695 + ] + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "links": null + } + ], + "properties": { + "Node name for S&R": "GetImageSize", + "cnr_id": "comfy-core", + "ver": "0.33.0" + } + }, + { + "id": 624, + "type": "SamplerCustom", + "pos": [ + 3250, + 2900 + ], + "size": [ + 310, + 670 + ], + "flags": {}, + "order": 18, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 564 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 679 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 680 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 487 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 486 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 681 + }, + { + "localized_name": "add_noise", + "name": "add_noise", + "type": "BOOLEAN", + "widget": { + "name": "add_noise" + }, + "link": null + }, + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 824 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 593 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustom", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + true, + 2, + "randomize", + 1 + ], + "widgets_values_named": { + "add_noise": true, + "noise_seed": 2, + "control_after_generate": "randomize", + "cfg": 1 + } + }, + { + "id": 625, + "type": "CLIPVisionEncode", + "pos": [ + 1150, + 4680 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_vision", + "name": "clip_vision", + "type": "CLIP_VISION", + "link": 584 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 641 + }, + { + "localized_name": "crop", + "name": "crop", + "type": "COMBO", + "widget": { + "name": "crop" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION_OUTPUT", + "name": "CLIP_VISION_OUTPUT", + "type": "CLIP_VISION_OUTPUT", + "links": [ + 675 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionEncode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "crop": "none" + }, + "color": "#233", + "bgcolor": "#355" + }, + { + "id": 626, + "type": "ImageFromBatch", + "pos": [ + 780, + 4680 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 661 + }, + { + "localized_name": "batch_index", + "name": "batch_index", + "type": "INT", + "widget": { + "name": "batch_index" + }, + "link": null + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 641 + ] + } + ], + "properties": { + "Node name for S&R": "ImageFromBatch", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 0, + 1 + ], + "widgets_values_named": { + "batch_index": 0, + "length": 1 + } + }, + { + "id": 627, + "type": "ResizeImageMaskNode", + "pos": [ + 420, + 4680 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 655 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 820 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 821 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 661, + 688, + 693 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + "scale dimensions", + 482, + 854, + "center", + "area" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 482, + "resize_type.height": 854, + "resize_type.crop": "center", + "scale_method": "area" + } + }, + { + "id": 628, + "type": "VAEDecode", + "pos": [ + 3890, + 3080 + ], + "size": [ + 270, + 60 + ], + "flags": { + "collapsed": false + }, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 594 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 664 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 666, + 751, + 754 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + } + }, + { + "id": 629, + "type": "TrimVideoLatent", + "pos": [ + 3890, + 2960 + ], + "size": [ + 270, + 70 + ], + "flags": { + "collapsed": false + }, + "order": 23, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 593 + }, + { + "localized_name": "trim_amount", + "name": "trim_amount", + "type": "INT", + "widget": { + "name": "trim_amount" + }, + "link": 682 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 594 + ] + } + ], + "properties": { + "Node name for S&R": "TrimVideoLatent", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 0 + ], + "widgets_values_named": { + "trim_amount": 0 + } + }, + { + "id": 630, + "type": "PrimitiveBoolean", + "pos": [ + 3880, + 3800 + ], + "size": [ + 270, + 70 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 819 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 749 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + } + }, + { + "id": 631, + "type": "ImageFromBatch", + "pos": [ + 3890, + 3480 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 751 + }, + { + "localized_name": "batch_index", + "name": "batch_index", + "type": "INT", + "widget": { + "name": "batch_index" + }, + "link": null + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 753 + ] + } + ], + "properties": { + "Node name for S&R": "ImageFromBatch", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + 1, + 4096 + ], + "widgets_values_named": { + "batch_index": 1, + "length": 4096 + } + }, + { + "id": 632, + "type": "ComfySwitchNode", + "pos": [ + 3890, + 3630 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 754 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 753 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 749 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 755 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.33.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + } + ], + "groups": [ + { + "id": 3, + "title": "Models", + "bounding": [ + 20, + 2570, + 820, + 1300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Prompt", + "bounding": [ + 870, + 2570, + 660, + 1300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Conditioning", + "bounding": [ + 1560, + 3150, + 1000, + 720 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 11, + "title": "Context Windows & Cache", + "bounding": [ + 1560, + 2570, + 1000, + 550 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Reference Image", + "bounding": [ + 20, + 3900, + 1510, + 390 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Sampling", + "bounding": [ + 2590, + 2570, + 1120, + 1300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 10, + "title": "Pose Reference Video", + "bounding": [ + 20, + 4320, + 1500, + 560 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 12, + "title": "Trim the duplicated first frame", + "bounding": [ + 3870, + 3410, + 300, + 462 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 8, + "origin_id": 607, + "origin_slot": 0, + "target_id": 608, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 7, + "origin_id": 607, + "origin_slot": 0, + "target_id": 609, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 590, + "origin_id": 607, + "origin_slot": 0, + "target_id": 612, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 669, + "origin_id": 609, + "origin_slot": 0, + "target_id": 614, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 670, + "origin_id": 608, + "origin_slot": 0, + "target_id": 614, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 671, + "origin_id": 611, + "origin_slot": 0, + "target_id": 614, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 672, + "origin_id": 617, + "origin_slot": 0, + "target_id": 614, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 688, + "origin_id": 627, + "origin_slot": 0, + "target_id": 614, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 683, + "origin_id": 616, + "origin_slot": 0, + "target_id": 614, + "target_slot": 5, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 674, + "origin_id": 612, + "origin_slot": 0, + "target_id": 614, + "target_slot": 6, + "type": "CONDITIONING" + }, + { + "id": 675, + "origin_id": 625, + "origin_slot": 0, + "target_id": 614, + "target_slot": 7, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 691, + "origin_id": 623, + "origin_slot": 0, + "target_id": 614, + "target_slot": 9, + "type": "INT" + }, + { + "id": 692, + "origin_id": 623, + "origin_slot": 1, + "target_id": 614, + "target_slot": 10, + "type": "INT" + }, + { + "id": 196, + "origin_id": 610, + "origin_slot": 0, + "target_id": 616, + "target_slot": 0, + "type": "CLIP_VISION" + }, + { + "id": 658, + "origin_id": 617, + "origin_slot": 0, + "target_id": 616, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 694, + "origin_id": 623, + "origin_slot": 0, + "target_id": 617, + "target_slot": 2, + "type": "INT" + }, + { + "id": 695, + "origin_id": 623, + "origin_slot": 1, + "target_id": 617, + "target_slot": 3, + "type": "INT" + }, + { + "id": 597, + "origin_id": 621, + "origin_slot": 0, + "target_id": 618, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 596, + "origin_id": 621, + "origin_slot": 0, + "target_id": 619, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 700, + "origin_id": 615, + "origin_slot": 0, + "target_id": 621, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 693, + "origin_id": 627, + "origin_slot": 0, + "target_id": 623, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 564, + "origin_id": 619, + "origin_slot": 0, + "target_id": 624, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 679, + "origin_id": 614, + "origin_slot": 0, + "target_id": 624, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 680, + "origin_id": 614, + "origin_slot": 1, + "target_id": 624, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 487, + "origin_id": 620, + "origin_slot": 0, + "target_id": 624, + "target_slot": 3, + "type": "SAMPLER" + }, + { + "id": 486, + "origin_id": 618, + "origin_slot": 0, + "target_id": 624, + "target_slot": 4, + "type": "SIGMAS" + }, + { + "id": 681, + "origin_id": 614, + "origin_slot": 2, + "target_id": 624, + "target_slot": 5, + "type": "LATENT" + }, + { + "id": 584, + "origin_id": 610, + "origin_slot": 0, + "target_id": 625, + "target_slot": 0, + "type": "CLIP_VISION" + }, + { + "id": 641, + "origin_id": 626, + "origin_slot": 0, + "target_id": 625, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 661, + "origin_id": 627, + "origin_slot": 0, + "target_id": 626, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 655, + "origin_id": 622, + "origin_slot": 0, + "target_id": 627, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 594, + "origin_id": 629, + "origin_slot": 0, + "target_id": 628, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 664, + "origin_id": 611, + "origin_slot": 0, + "target_id": 628, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 593, + "origin_id": 624, + "origin_slot": 0, + "target_id": 629, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 682, + "origin_id": 614, + "origin_slot": 3, + "target_id": 629, + "target_slot": 1, + "type": "INT" + }, + { + "id": 657, + "origin_id": -10, + "origin_slot": 0, + "target_id": 617, + "target_slot": 0, + "type": "IMAGE,MASK" + }, + { + "id": 648, + "origin_id": -10, + "origin_slot": 1, + "target_id": 622, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 666, + "origin_id": 628, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 714, + "origin_id": -10, + "origin_slot": 2, + "target_id": 614, + "target_slot": 8, + "type": "IMAGE" + }, + { + "id": 721, + "origin_id": 614, + "origin_slot": 5, + "target_id": -20, + "target_slot": 1, + "type": "INT" + }, + { + "id": 740, + "origin_id": 613, + "origin_slot": 0, + "target_id": 615, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 749, + "origin_id": 630, + "origin_slot": 0, + "target_id": 632, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 751, + "origin_id": 628, + "origin_slot": 0, + "target_id": 631, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 753, + "origin_id": 631, + "origin_slot": 0, + "target_id": 632, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 754, + "origin_id": 628, + "origin_slot": 0, + "target_id": 632, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 755, + "origin_id": 632, + "origin_slot": 0, + "target_id": -20, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 804, + "origin_id": -10, + "origin_slot": 3, + "target_id": 614, + "target_slot": 13, + "type": "INT" + }, + { + "id": 805, + "origin_id": -10, + "origin_slot": 4, + "target_id": 609, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 811, + "origin_id": -10, + "origin_slot": 5, + "target_id": 614, + "target_slot": 17, + "type": "FLOAT" + }, + { + "id": 812, + "origin_id": -10, + "origin_slot": 6, + "target_id": 612, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 813, + "origin_id": -10, + "origin_slot": 7, + "target_id": 614, + "target_slot": 14, + "type": "FLOAT" + }, + { + "id": 814, + "origin_id": -10, + "origin_slot": 8, + "target_id": 614, + "target_slot": 15, + "type": "FLOAT" + }, + { + "id": 815, + "origin_id": -10, + "origin_slot": 9, + "target_id": 614, + "target_slot": 16, + "type": "FLOAT" + }, + { + "id": 816, + "origin_id": -10, + "origin_slot": 10, + "target_id": 615, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 817, + "origin_id": -10, + "origin_slot": 11, + "target_id": 621, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 818, + "origin_id": -10, + "origin_slot": 12, + "target_id": 621, + "target_slot": 2, + "type": "COMBO" + }, + { + "id": 819, + "origin_id": -10, + "origin_slot": 13, + "target_id": 630, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 820, + "origin_id": -10, + "origin_slot": 14, + "target_id": 627, + "target_slot": 2, + "type": "INT" + }, + { + "id": 821, + "origin_id": -10, + "origin_slot": 15, + "target_id": 627, + "target_slot": 3, + "type": "INT" + }, + { + "id": 824, + "origin_id": -10, + "origin_slot": 16, + "target_id": 624, + "target_slot": 7, + "type": "INT" + }, + { + "id": 825, + "origin_id": -10, + "origin_slot": 17, + "target_id": 614, + "target_slot": 11, + "type": "INT" + }, + { + "id": 826, + "origin_id": -10, + "origin_slot": 18, + "target_id": 606, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 828, + "origin_id": -10, + "origin_slot": 19, + "target_id": 607, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 829, + "origin_id": -10, + "origin_slot": 20, + "target_id": 610, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 830, + "origin_id": -10, + "origin_slot": 21, + "target_id": 611, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 864, + "origin_id": 606, + "origin_slot": 0, + "target_id": 613, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 865, + "origin_id": 606, + "origin_slot": 0, + "target_id": 615, + "target_slot": 0, + "type": "MODEL" + } + ], + "extra": {}, + "category": "Video generation/Conditioned", + "description": "Transfers motion from a driving video onto a reference character using the distilled Wan Animate 2 checkpoint, without pose extraction." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Motion Transfer (Wan Animate 2).json b/blueprints/Motion Transfer (Wan Animate 2).json new file mode 100644 index 00000000000..389df4dc589 --- /dev/null +++ b/blueprints/Motion Transfer (Wan Animate 2).json @@ -0,0 +1,3362 @@ +{ + "revision": 0, + "last_node_id": "604", + "last_link_id": 0, + "nodes": [ + { + "id": 604, + "type": "15bbb618-bd17-4471-9d94-185ce531cfd5", + "pos": [ + 1310, + 4950 + ], + "size": [ + 500, + 860 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "label": "reference Image", + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "pose_video", + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": null + }, + { + "name": "continue_motion", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "label": "video_frame_offset", + "name": "video_frame_offset_1", + "type": "INT", + "widget": { + "name": "video_frame_offset_1" + }, + "link": null + }, + { + "label": "prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "label": "reference_image_strength", + "name": "reference_image_strength_1", + "type": "FLOAT", + "widget": { + "name": "reference_image_strength_1" + }, + "link": null + }, + { + "label": "pose_prompt", + "name": "text_4", + "type": "STRING", + "widget": { + "name": "text_4" + }, + "link": null + }, + { + "label": "pose_strength", + "name": "pose_strength_1", + "type": "FLOAT", + "widget": { + "name": "pose_strength_1" + }, + "link": null + }, + { + "label": "pose_start_percent", + "name": "pose_start_percent_1", + "type": "FLOAT", + "widget": { + "name": "pose_start_percent_1" + }, + "link": null + }, + { + "label": "pose_end_percent", + "name": "pose_end_percent_1", + "type": "FLOAT", + "widget": { + "name": "pose_end_percent_1" + }, + "link": null + }, + { + "label": "enable_context_window", + "name": "switch_1", + "type": "BOOLEAN", + "widget": { + "name": "switch_1" + }, + "link": null + }, + { + "label": "cache_device", + "name": "device_1", + "type": "COMBO", + "widget": { + "name": "device_1" + }, + "link": null + }, + { + "label": "cache_dtype", + "name": "dtype_1", + "type": "COMBO", + "widget": { + "name": "dtype_1" + }, + "link": null + }, + { + "label": "trim_duplicated_frame", + "name": "value_1", + "type": "BOOLEAN", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": null + }, + { + "label": "height", + "name": "resize_type.height_1", + "type": "INT", + "widget": { + "name": "resize_type.height_1" + }, + "link": null + }, + { + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + }, + { + "label": "unet", + "name": "unet_name_1", + "type": "COMBO", + "widget": { + "name": "unet_name_1" + }, + "link": null + }, + { + "label": "lora", + "name": "lora_name_1", + "type": "COMBO", + "widget": { + "name": "lora_name_1" + }, + "link": null + }, + { + "label": "clip", + "name": "clip_name_2", + "type": "COMBO", + "widget": { + "name": "clip_name_2" + }, + "link": null + }, + { + "label": "clip_vision", + "name": "clip_name_3", + "type": "COMBO", + "widget": { + "name": "clip_name_3" + }, + "link": null + }, + { + "label": "vae", + "name": "vae_name_1", + "type": "COMBO", + "widget": { + "name": "vae_name_1" + }, + "link": null + } + ], + "outputs": [ + { + "label": "continue_motion", + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + }, + { + "name": "video_frame_offset", + "type": "INT", + "links": [] + }, + { + "label": "image", + "name": "output", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "previewExposures": [] + }, + "widgets_values": [ + 0, + "", + 1, + "", + 1, + 0, + 1, + false, + "gpu", + "int8", + false, + 482, + 854, + 951677846216619, + 81, + "wan_animate_2_int8_convrot.safetensors", + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "clip_vision_h.safetensors", + "Wan2_1_VAE_bf16.safetensors" + ], + "widgets_values_named": { + "video_frame_offset_1": 0, + "text": "", + "reference_image_strength_1": 1, + "text_4": "", + "pose_strength_1": 1, + "pose_start_percent_1": 0, + "pose_end_percent_1": 1, + "switch_1": false, + "device_1": "gpu", + "dtype_1": "int8", + "value_1": false, + "resize_type.width": 482, + "resize_type.height_1": 854, + "noise_seed": 951677846216619, + "length": 81, + "unet_name_1": "wan_animate_2_int8_convrot.safetensors", + "lora_name_1": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "clip_name_2": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "clip_name_3": "clip_vision_h.safetensors", + "vae_name_1": "Wan2_1_VAE_bf16.safetensors" + }, + "title": "Motion Transfer (Wan Animate 2)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "15bbb618-bd17-4471-9d94-185ce531cfd5", + "version": 1, + "state": { + "lastGroupId": 12, + "lastNodeId": 604, + "lastLinkId": 863, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Motion Transfer (Wan Animate 2)", + "inputNode": { + "id": -10, + "bounding": [ + -720, + 3220, + 195.5625, + 508 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 4390, + 3310, + 155.935546875, + 108 + ] + }, + "inputs": [ + { + "id": "5652276e-beac-4092-a828-9baa0150355b", + "name": "input", + "type": "IMAGE,MASK", + "linkIds": [ + 657 + ], + "localized_name": "input", + "label": "reference Image", + "pos": [ + -548.4375, + 3244 + ] + }, + { + "id": "d2f087f3-bdae-4de1-8fb6-0a9e5b186ab7", + "name": "video", + "type": "VIDEO", + "linkIds": [ + 648 + ], + "localized_name": "video", + "label": "pose_video", + "pos": [ + -548.4375, + 3264 + ] + }, + { + "id": "6a3a407a-5ecf-4027-b7b5-178ddca1a1a5", + "name": "continue_motion", + "type": "IMAGE", + "linkIds": [ + 714 + ], + "pos": [ + -548.4375, + 3284 + ] + }, + { + "id": "c704c479-c4e8-4202-8c8e-234b63efc420", + "name": "video_frame_offset_1", + "type": "INT", + "linkIds": [ + 804 + ], + "label": "video_frame_offset", + "pos": [ + -548.4375, + 3304 + ] + }, + { + "id": "57d8f22a-6abf-42ee-b29c-8375b541e18d", + "name": "text", + "type": "STRING", + "linkIds": [ + 805 + ], + "label": "prompt", + "pos": [ + -548.4375, + 3324 + ] + }, + { + "id": "2297d7f0-9c93-4c52-916d-67adb3268cee", + "name": "reference_image_strength_1", + "type": "FLOAT", + "linkIds": [ + 811 + ], + "label": "reference_image_strength", + "pos": [ + -548.4375, + 3344 + ] + }, + { + "id": "9daa1424-fa79-4968-8f04-6fd0eba78616", + "name": "text_4", + "type": "STRING", + "linkIds": [ + 812 + ], + "label": "pose_prompt", + "pos": [ + -548.4375, + 3364 + ] + }, + { + "id": "bb4d1069-34ae-40a8-b067-03560962f0df", + "name": "pose_strength_1", + "type": "FLOAT", + "linkIds": [ + 813 + ], + "label": "pose_strength", + "pos": [ + -548.4375, + 3384 + ] + }, + { + "id": "d1f8263c-7501-4214-a959-a25dd44fa8f7", + "name": "pose_start_percent_1", + "type": "FLOAT", + "linkIds": [ + 814 + ], + "label": "pose_start_percent", + "pos": [ + -548.4375, + 3404 + ] + }, + { + "id": "5735d8d5-b3aa-47f2-90bf-2d5a8bfcc0ed", + "name": "pose_end_percent_1", + "type": "FLOAT", + "linkIds": [ + 815 + ], + "label": "pose_end_percent", + "pos": [ + -548.4375, + 3424 + ] + }, + { + "id": "3ab8f3ee-2332-4f5e-85a5-0ae77f73e0cf", + "name": "switch_1", + "type": "BOOLEAN", + "linkIds": [ + 816 + ], + "label": "enable_context_window", + "pos": [ + -548.4375, + 3444 + ] + }, + { + "id": "b34d70bb-dab4-4a6d-a222-85c5627a4316", + "name": "device_1", + "type": "COMBO", + "linkIds": [ + 817 + ], + "label": "cache_device", + "pos": [ + -548.4375, + 3464 + ] + }, + { + "id": "201c6745-0a0d-4baf-9310-2a32af974fbd", + "name": "dtype_1", + "type": "COMBO", + "linkIds": [ + 818 + ], + "label": "cache_dtype", + "pos": [ + -548.4375, + 3484 + ] + }, + { + "id": "30c834c0-d8dd-429b-bfe5-d3c98e44fd05", + "name": "value_1", + "type": "BOOLEAN", + "linkIds": [ + 819 + ], + "label": "trim_duplicated_frame", + "pos": [ + -548.4375, + 3504 + ] + }, + { + "id": "0fcfc566-1d18-4dc9-8300-00d0de4ba446", + "name": "resize_type.width", + "type": "INT", + "linkIds": [ + 820 + ], + "label": "width", + "pos": [ + -548.4375, + 3524 + ] + }, + { + "id": "9e0fb084-d3a8-4811-8cef-092febab0046", + "name": "resize_type.height_1", + "type": "INT", + "linkIds": [ + 821 + ], + "label": "height", + "pos": [ + -548.4375, + 3544 + ] + }, + { + "id": "e910a55a-473e-43bf-b41c-878a090aa00c", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 824 + ], + "pos": [ + -548.4375, + 3564 + ] + }, + { + "id": "70e7ec96-3d6c-4104-8f18-947601f5191c", + "name": "length", + "type": "INT", + "linkIds": [ + 825 + ], + "pos": [ + -548.4375, + 3584 + ] + }, + { + "id": "eb68fa16-5d13-4f52-8030-7ed81727f807", + "name": "unet_name_1", + "type": "COMBO", + "linkIds": [ + 826 + ], + "label": "unet", + "pos": [ + -548.4375, + 3604 + ] + }, + { + "id": "afedf4bd-bea4-41e2-85b1-6882a699b48a", + "name": "lora_name_1", + "type": "COMBO", + "linkIds": [ + 827 + ], + "label": "lora", + "pos": [ + -548.4375, + 3624 + ] + }, + { + "id": "821fee03-f73d-4875-85de-744908b67891", + "name": "clip_name_2", + "type": "COMBO", + "linkIds": [ + 828 + ], + "label": "clip", + "pos": [ + -548.4375, + 3644 + ] + }, + { + "id": "ae0dfb45-e15d-4868-a9c8-ef14e693f65c", + "name": "clip_name_3", + "type": "COMBO", + "linkIds": [ + 829 + ], + "label": "clip_vision", + "pos": [ + -548.4375, + 3664 + ] + }, + { + "id": "d0b2c474-1dd7-474d-a77a-75b010ad8bea", + "name": "vae_name_1", + "type": "COMBO", + "linkIds": [ + 830 + ], + "label": "vae", + "pos": [ + -548.4375, + 3684 + ] + } + ], + "outputs": [ + { + "id": "1fef655d-ce8e-4789-a018-da1a2b97ac75", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 666 + ], + "localized_name": "IMAGE", + "label": "continue_motion", + "pos": [ + 4414, + 3334 + ] + }, + { + "id": "f0f1eeeb-1773-4eaf-ab45-791f6c2324ba", + "name": "video_frame_offset", + "type": "INT", + "linkIds": [ + 721 + ], + "pos": [ + 4414, + 3354 + ] + }, + { + "id": "60baa156-368c-4cab-92fd-3f9880894072", + "name": "output", + "type": "IMAGE", + "linkIds": [ + 755 + ], + "label": "image", + "pos": [ + 4414, + 3374 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 576, + "type": "UNETLoader", + "pos": [ + 100, + 2810 + ], + "size": [ + 660, + 130 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 826 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 646 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "wan_animate_2_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/diffusion_models/wan_animate_2_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "wan_animate_2_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "wan_animate_2_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 577, + "type": "LoraLoaderModelOnly", + "pos": [ + 100, + 3010 + ], + "size": [ + 660, + 120 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 646 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 827 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 696, + 739 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "models": [ + { + "name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/loras/lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "lightx2v_I2V_14B_480p_cfg_step_distill_rank64_bf16.safetensors", + "strength_model": 1 + } + }, + { + "id": 578, + "type": "CLIPLoader", + "pos": [ + 100, + 3210 + ], + "size": [ + 660, + 140 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 828 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 7, + 8, + 590 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "wan", + "default" + ], + "widgets_values_named": { + "clip_name": "umt5_xxl_fp8_e4m3fn_scaled.safetensors", + "type": "wan", + "device": "default" + } + }, + { + "id": 579, + "type": "CLIPTextEncode", + "pos": [ + 960, + 3220 + ], + "size": [ + 490, + 120 + ], + "flags": { + "collapsed": false + }, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 8 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 670 + ] + } + ], + "title": "CLIP Text Encode (Negative Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走" + ], + "widgets_values_named": { + "text": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走" + }, + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 580, + "type": "CLIPTextEncode", + "pos": [ + 960, + 2830 + ], + "size": [ + 480, + 310 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 7 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 805 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 669 + ] + } + ], + "title": "CLIP Text Encode (Positive Prompt)", + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 581, + "type": "CLIPVisionLoader", + "pos": [ + 100, + 3430 + ], + "size": [ + 660, + 90 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 829 + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION", + "name": "CLIP_VISION", + "type": "CLIP_VISION", + "links": [ + 196, + 584 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionLoader", + "models": [ + { + "name": "clip_vision_h.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/clip_vision/clip_vision_h.safetensors", + "directory": "clip_vision" + } + ] + }, + "widgets_values": [ + "clip_vision_h.safetensors" + ], + "widgets_values_named": { + "clip_name": "clip_vision_h.safetensors" + } + }, + { + "id": 582, + "type": "VAELoader", + "pos": [ + 100, + 3600 + ], + "size": [ + 660, + 90 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 830 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 664, + 671 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "Wan2_1_VAE_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Wan-Animate-2/resolve/main/vae/Wan2_1_VAE_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "Wan2_1_VAE_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "Wan2_1_VAE_bf16.safetensors" + } + }, + { + "id": 583, + "type": "CLIPTextEncode", + "pos": [ + 960, + 3420 + ], + "size": [ + 490, + 220 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 590 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 812 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 674 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 584, + "type": "ContextWindowsManual", + "pos": [ + 1690, + 2670 + ], + "size": [ + 300, + 410 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 696 + }, + { + "localized_name": "context_length", + "name": "context_length", + "type": "INT", + "widget": { + "name": "context_length" + }, + "link": null + }, + { + "localized_name": "context_overlap", + "name": "context_overlap", + "type": "INT", + "widget": { + "name": "context_overlap" + }, + "link": null + }, + { + "localized_name": "context_schedule", + "name": "context_schedule", + "type": "COMBO", + "widget": { + "name": "context_schedule" + }, + "link": null + }, + { + "localized_name": "context_stride", + "name": "context_stride", + "type": "INT", + "widget": { + "name": "context_stride" + }, + "link": null + }, + { + "localized_name": "closed_loop", + "name": "closed_loop", + "type": "BOOLEAN", + "widget": { + "name": "closed_loop" + }, + "link": null + }, + { + "localized_name": "fuse_method", + "name": "fuse_method", + "type": "COMBO", + "widget": { + "name": "fuse_method" + }, + "link": null + }, + { + "localized_name": "dim", + "name": "dim", + "type": "INT", + "widget": { + "name": "dim" + }, + "link": null + }, + { + "localized_name": "freenoise", + "name": "freenoise", + "type": "BOOLEAN", + "widget": { + "name": "freenoise" + }, + "link": null + }, + { + "localized_name": "cond_retain_index_list", + "name": "cond_retain_index_list", + "type": "STRING", + "widget": { + "name": "cond_retain_index_list" + }, + "link": null + }, + { + "localized_name": "split_conds_to_windows", + "name": "split_conds_to_windows", + "type": "BOOLEAN", + "widget": { + "name": "split_conds_to_windows" + }, + "link": null + }, + { + "localized_name": "latent_retain_index_list", + "name": "latent_retain_index_list", + "type": "STRING", + "widget": { + "name": "latent_retain_index_list" + }, + "link": null + }, + { + "localized_name": "causal_window_fix", + "name": "causal_window_fix", + "type": "BOOLEAN", + "widget": { + "name": "causal_window_fix" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 740 + ] + } + ], + "properties": { + "Node name for S&R": "ContextWindowsManual" + }, + "widgets_values": [ + 21, + 8, + "standard_static", + 1, + false, + "pyramid", + 2, + true, + "0", + false, + "", + true + ], + "widgets_values_named": { + "context_length": 21, + "context_overlap": 8, + "context_schedule": "standard_static", + "context_stride": 1, + "closed_loop": false, + "fuse_method": "pyramid", + "dim": 2, + "freenoise": true, + "cond_retain_index_list": "0", + "split_conds_to_windows": false, + "latent_retain_index_list": "", + "causal_window_fix": true + } + }, + { + "id": 585, + "type": "WanAnimate2ToVideo", + "pos": [ + 1920, + 3300 + ], + "size": [ + 350, + 470 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 669 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 670 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 671 + }, + { + "localized_name": "reference_image", + "name": "reference_image", + "shape": 7, + "type": "IMAGE", + "link": 672 + }, + { + "localized_name": "pose_video", + "name": "pose_video", + "shape": 7, + "type": "IMAGE", + "link": 688 + }, + { + "localized_name": "clip_vision_output", + "name": "clip_vision_output", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 683 + }, + { + "localized_name": "positive_pose", + "name": "positive_pose", + "shape": 7, + "type": "CONDITIONING", + "link": 674 + }, + { + "localized_name": "clip_vision_output_pose", + "name": "clip_vision_output_pose", + "shape": 7, + "type": "CLIP_VISION_OUTPUT", + "link": 675 + }, + { + "localized_name": "continue_motion", + "name": "continue_motion", + "shape": 7, + "type": "IMAGE", + "link": 714 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 691 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 692 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 825 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + }, + { + "localized_name": "video_frame_offset", + "name": "video_frame_offset", + "type": "INT", + "widget": { + "name": "video_frame_offset" + }, + "link": 804 + }, + { + "localized_name": "pose_strength", + "name": "pose_strength", + "type": "FLOAT", + "widget": { + "name": "pose_strength" + }, + "link": 813 + }, + { + "localized_name": "pose_start_percent", + "name": "pose_start_percent", + "type": "FLOAT", + "widget": { + "name": "pose_start_percent" + }, + "link": 814 + }, + { + "localized_name": "pose_end_percent", + "name": "pose_end_percent", + "type": "FLOAT", + "widget": { + "name": "pose_end_percent" + }, + "link": 815 + }, + { + "localized_name": "reference_image_strength", + "name": "reference_image_strength", + "type": "FLOAT", + "widget": { + "name": "reference_image_strength" + }, + "link": 811 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 679 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 680 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 681 + ] + }, + { + "localized_name": "trim_latent", + "name": "trim_latent", + "type": "INT", + "links": [ + 682 + ] + }, + { + "localized_name": "trim_image", + "name": "trim_image", + "type": "INT", + "links": [] + }, + { + "localized_name": "video_frame_offset", + "name": "video_frame_offset", + "type": "INT", + "links": [ + 721 + ] + } + ], + "properties": { + "Node name for S&R": "WanAnimate2ToVideo" + }, + "widgets_values": [ + 832, + 480, + 81, + 1, + 0, + 1, + 0, + 1, + 1 + ], + "widgets_values_named": { + "width": 832, + "height": 480, + "length": 81, + "batch_size": 1, + "video_frame_offset": 0, + "pose_strength": 1, + "pose_start_percent": 0, + "pose_end_percent": 1, + "reference_image_strength": 1 + } + }, + { + "id": 586, + "type": "ComfySwitchNode", + "pos": [ + 2130, + 2680 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 739 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 740 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 816 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 700 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 587, + "type": "CLIPVisionEncode", + "pos": [ + 1150, + 4050 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_vision", + "name": "clip_vision", + "type": "CLIP_VISION", + "link": 196 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 658 + }, + { + "localized_name": "crop", + "name": "crop", + "type": "COMBO", + "widget": { + "name": "crop" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION_OUTPUT", + "name": "CLIP_VISION_OUTPUT", + "type": "CLIP_VISION_OUTPUT", + "links": [ + 683 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionEncode" + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "crop": "none" + }, + "color": "#233", + "bgcolor": "#355" + }, + { + "id": 588, + "type": "ResizeImageMaskNode", + "pos": [ + 780, + 4050 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 657 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 694 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 695 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 658, + 672 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode" + }, + "widgets_values": [ + "scale dimensions", + 481, + 854, + "center", + "area" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 481, + "resize_type.height": 854, + "resize_type.crop": "center", + "scale_method": "area" + } + }, + { + "id": 589, + "type": "BasicScheduler", + "pos": [ + 2740, + 3210 + ], + "size": [ + 310, + 120 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 597 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 486 + ] + } + ], + "properties": { + "Node name for S&R": "BasicScheduler" + }, + "widgets_values": [ + "simple", + 6, + 1 + ], + "widgets_values_named": { + "scheduler": "simple", + "steps": 6, + "denoise": 1 + } + }, + { + "id": 590, + "type": "ModelSamplingSD3", + "pos": [ + 2740, + 2910 + ], + "size": [ + 310, + 80 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 596 + }, + { + "localized_name": "shift", + "name": "shift", + "type": "FLOAT", + "widget": { + "name": "shift" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 564 + ] + } + ], + "properties": { + "Node name for S&R": "ModelSamplingSD3" + }, + "widgets_values": [ + 5 + ], + "widgets_values_named": { + "shift": 5 + } + }, + { + "id": 591, + "type": "KSamplerSelect", + "pos": [ + 2740, + 3050 + ], + "size": [ + 310, + 90 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 487 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect" + }, + "widgets_values": [ + "lcm" + ], + "widgets_values_named": { + "sampler_name": "lcm" + } + }, + { + "id": 592, + "type": "WanAnimate2Cache", + "pos": [ + 2120, + 2920 + ], + "size": [ + 310, + 120 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 700 + }, + { + "localized_name": "device", + "name": "device", + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": 817 + }, + { + "localized_name": "dtype", + "name": "dtype", + "type": "COMBO", + "widget": { + "name": "dtype" + }, + "link": 818 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 596, + 597 + ] + } + ], + "properties": { + "Node name for S&R": "WanAnimate2Cache" + }, + "widgets_values": [ + "gpu", + "int8" + ], + "widgets_values_named": { + "device": "gpu", + "dtype": "int8" + } + }, + { + "id": 593, + "type": "GetVideoComponents", + "pos": [ + 420, + 4430 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 648 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [ + 655 + ] + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "links": [] + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "type": "COMBO", + "links": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "type": "COMBO", + "links": null + } + ], + "properties": { + "Node name for S&R": "GetVideoComponents" + } + }, + { + "id": 594, + "type": "GetImageSize", + "pos": [ + 790, + 4430 + ], + "size": [ + 260, + 140 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 693 + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": [ + 691, + 694 + ] + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": [ + 692, + 695 + ] + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "links": null + } + ], + "properties": { + "Node name for S&R": "GetImageSize" + } + }, + { + "id": 595, + "type": "SamplerCustom", + "pos": [ + 3250, + 2900 + ], + "size": [ + 310, + 670 + ], + "flags": {}, + "order": 19, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 564 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 679 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 680 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 487 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 486 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 681 + }, + { + "localized_name": "add_noise", + "name": "add_noise", + "type": "BOOLEAN", + "widget": { + "name": "add_noise" + }, + "link": null + }, + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 824 + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 593 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustom" + }, + "widgets_values": [ + true, + 2, + "randomize", + 1 + ], + "widgets_values_named": { + "add_noise": true, + "noise_seed": 2, + "control_after_generate": "randomize", + "cfg": 1 + } + }, + { + "id": 596, + "type": "CLIPVisionEncode", + "pos": [ + 1150, + 4680 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_vision", + "name": "clip_vision", + "type": "CLIP_VISION", + "link": 584 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 641 + }, + { + "localized_name": "crop", + "name": "crop", + "type": "COMBO", + "widget": { + "name": "crop" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP_VISION_OUTPUT", + "name": "CLIP_VISION_OUTPUT", + "type": "CLIP_VISION_OUTPUT", + "links": [ + 675 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPVisionEncode" + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "crop": "none" + }, + "color": "#233", + "bgcolor": "#355" + }, + { + "id": 597, + "type": "ImageFromBatch", + "pos": [ + 780, + 4680 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 661 + }, + { + "localized_name": "batch_index", + "name": "batch_index", + "type": "INT", + "widget": { + "name": "batch_index" + }, + "link": null + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 641 + ] + } + ], + "properties": { + "Node name for S&R": "ImageFromBatch" + }, + "widgets_values": [ + 0, + 1 + ], + "widgets_values_named": { + "batch_index": 0, + "length": 1 + } + }, + { + "id": 598, + "type": "ResizeImageMaskNode", + "pos": [ + 420, + 4680 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 655 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "width", + "name": "resize_type.width", + "type": "INT", + "widget": { + "name": "resize_type.width" + }, + "link": 820 + }, + { + "localized_name": "height", + "name": "resize_type.height", + "type": "INT", + "widget": { + "name": "resize_type.height" + }, + "link": 821 + }, + { + "localized_name": "crop", + "name": "resize_type.crop", + "type": "COMBO", + "widget": { + "name": "resize_type.crop" + }, + "link": null + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 661, + 688, + 693 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode" + }, + "widgets_values": [ + "scale dimensions", + 482, + 854, + "center", + "area" + ], + "widgets_values_named": { + "resize_type": "scale dimensions", + "resize_type.width": 482, + "resize_type.height": 854, + "resize_type.crop": "center", + "scale_method": "area" + } + }, + { + "id": 599, + "type": "VAEDecode", + "pos": [ + 3890, + 3080 + ], + "size": [ + 270, + 60 + ], + "flags": { + "collapsed": false + }, + "order": 23, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 594 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 664 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 666, + 751, + 754 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + } + }, + { + "id": 600, + "type": "TrimVideoLatent", + "pos": [ + 3890, + 2960 + ], + "size": [ + 270, + 70 + ], + "flags": { + "collapsed": false + }, + "order": 24, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 593 + }, + { + "localized_name": "trim_amount", + "name": "trim_amount", + "type": "INT", + "widget": { + "name": "trim_amount" + }, + "link": 682 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 594 + ] + } + ], + "properties": { + "Node name for S&R": "TrimVideoLatent" + }, + "widgets_values": [ + 0 + ], + "widgets_values_named": { + "trim_amount": 0 + } + }, + { + "id": 601, + "type": "PrimitiveBoolean", + "pos": [ + 3880, + 3800 + ], + "size": [ + 270, + 70 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 819 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 749 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + } + }, + { + "id": 602, + "type": "ImageFromBatch", + "pos": [ + 3890, + 3480 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 751 + }, + { + "localized_name": "batch_index", + "name": "batch_index", + "type": "INT", + "widget": { + "name": "batch_index" + }, + "link": null + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 753 + ] + } + ], + "properties": { + "Node name for S&R": "ImageFromBatch" + }, + "widgets_values": [ + 1, + 4096 + ], + "widgets_values_named": { + "batch_index": 1, + "length": 4096 + } + }, + { + "id": 603, + "type": "ComfySwitchNode", + "pos": [ + 3890, + 3630 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 754 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 753 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 749 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 755 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + } + ], + "groups": [ + { + "id": 3, + "title": "Models", + "bounding": [ + 20, + 2570, + 820, + 1300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Prompt", + "bounding": [ + 870, + 2570, + 660, + 1300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Conditioning", + "bounding": [ + 1560, + 3150, + 1000, + 720 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 11, + "title": "Context Windows & Cache", + "bounding": [ + 1560, + 2570, + 1000, + 550 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Reference Image", + "bounding": [ + 20, + 3900, + 1510, + 390 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Sampling", + "bounding": [ + 2590, + 2570, + 1120, + 1300 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 10, + "title": "Pose Reference Video", + "bounding": [ + 20, + 4320, + 1500, + 560 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 12, + "title": "Trim the duplicated first frame", + "bounding": [ + 3870, + 3410, + 300, + 462 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 646, + "origin_id": 576, + "origin_slot": 0, + "target_id": 577, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 8, + "origin_id": 578, + "origin_slot": 0, + "target_id": 579, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 7, + "origin_id": 578, + "origin_slot": 0, + "target_id": 580, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 590, + "origin_id": 578, + "origin_slot": 0, + "target_id": 583, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 696, + "origin_id": 577, + "origin_slot": 0, + "target_id": 584, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 669, + "origin_id": 580, + "origin_slot": 0, + "target_id": 585, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 670, + "origin_id": 579, + "origin_slot": 0, + "target_id": 585, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 671, + "origin_id": 582, + "origin_slot": 0, + "target_id": 585, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 672, + "origin_id": 588, + "origin_slot": 0, + "target_id": 585, + "target_slot": 3, + "type": "IMAGE" + }, + { + "id": 688, + "origin_id": 598, + "origin_slot": 0, + "target_id": 585, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 683, + "origin_id": 587, + "origin_slot": 0, + "target_id": 585, + "target_slot": 5, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 674, + "origin_id": 583, + "origin_slot": 0, + "target_id": 585, + "target_slot": 6, + "type": "CONDITIONING" + }, + { + "id": 675, + "origin_id": 596, + "origin_slot": 0, + "target_id": 585, + "target_slot": 7, + "type": "CLIP_VISION_OUTPUT" + }, + { + "id": 691, + "origin_id": 594, + "origin_slot": 0, + "target_id": 585, + "target_slot": 9, + "type": "INT" + }, + { + "id": 692, + "origin_id": 594, + "origin_slot": 1, + "target_id": 585, + "target_slot": 10, + "type": "INT" + }, + { + "id": 196, + "origin_id": 581, + "origin_slot": 0, + "target_id": 587, + "target_slot": 0, + "type": "CLIP_VISION" + }, + { + "id": 658, + "origin_id": 588, + "origin_slot": 0, + "target_id": 587, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 694, + "origin_id": 594, + "origin_slot": 0, + "target_id": 588, + "target_slot": 2, + "type": "INT" + }, + { + "id": 695, + "origin_id": 594, + "origin_slot": 1, + "target_id": 588, + "target_slot": 3, + "type": "INT" + }, + { + "id": 597, + "origin_id": 592, + "origin_slot": 0, + "target_id": 589, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 596, + "origin_id": 592, + "origin_slot": 0, + "target_id": 590, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 700, + "origin_id": 586, + "origin_slot": 0, + "target_id": 592, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 693, + "origin_id": 598, + "origin_slot": 0, + "target_id": 594, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 564, + "origin_id": 590, + "origin_slot": 0, + "target_id": 595, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 679, + "origin_id": 585, + "origin_slot": 0, + "target_id": 595, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 680, + "origin_id": 585, + "origin_slot": 1, + "target_id": 595, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 487, + "origin_id": 591, + "origin_slot": 0, + "target_id": 595, + "target_slot": 3, + "type": "SAMPLER" + }, + { + "id": 486, + "origin_id": 589, + "origin_slot": 0, + "target_id": 595, + "target_slot": 4, + "type": "SIGMAS" + }, + { + "id": 681, + "origin_id": 585, + "origin_slot": 2, + "target_id": 595, + "target_slot": 5, + "type": "LATENT" + }, + { + "id": 584, + "origin_id": 581, + "origin_slot": 0, + "target_id": 596, + "target_slot": 0, + "type": "CLIP_VISION" + }, + { + "id": 641, + "origin_id": 597, + "origin_slot": 0, + "target_id": 596, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 661, + "origin_id": 598, + "origin_slot": 0, + "target_id": 597, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 655, + "origin_id": 593, + "origin_slot": 0, + "target_id": 598, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 594, + "origin_id": 600, + "origin_slot": 0, + "target_id": 599, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 664, + "origin_id": 582, + "origin_slot": 0, + "target_id": 599, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 593, + "origin_id": 595, + "origin_slot": 0, + "target_id": 600, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 682, + "origin_id": 585, + "origin_slot": 3, + "target_id": 600, + "target_slot": 1, + "type": "INT" + }, + { + "id": 657, + "origin_id": -10, + "origin_slot": 0, + "target_id": 588, + "target_slot": 0, + "type": "IMAGE,MASK" + }, + { + "id": 648, + "origin_id": -10, + "origin_slot": 1, + "target_id": 593, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 666, + "origin_id": 599, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 714, + "origin_id": -10, + "origin_slot": 2, + "target_id": 585, + "target_slot": 8, + "type": "IMAGE" + }, + { + "id": 721, + "origin_id": 585, + "origin_slot": 5, + "target_id": -20, + "target_slot": 1, + "type": "INT" + }, + { + "id": 739, + "origin_id": 577, + "origin_slot": 0, + "target_id": 586, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 740, + "origin_id": 584, + "origin_slot": 0, + "target_id": 586, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 749, + "origin_id": 601, + "origin_slot": 0, + "target_id": 603, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 751, + "origin_id": 599, + "origin_slot": 0, + "target_id": 602, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 753, + "origin_id": 602, + "origin_slot": 0, + "target_id": 603, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 754, + "origin_id": 599, + "origin_slot": 0, + "target_id": 603, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 755, + "origin_id": 603, + "origin_slot": 0, + "target_id": -20, + "target_slot": 2, + "type": "IMAGE" + }, + { + "id": 804, + "origin_id": -10, + "origin_slot": 3, + "target_id": 585, + "target_slot": 13, + "type": "INT" + }, + { + "id": 805, + "origin_id": -10, + "origin_slot": 4, + "target_id": 580, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 811, + "origin_id": -10, + "origin_slot": 5, + "target_id": 585, + "target_slot": 17, + "type": "FLOAT" + }, + { + "id": 812, + "origin_id": -10, + "origin_slot": 6, + "target_id": 583, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 813, + "origin_id": -10, + "origin_slot": 7, + "target_id": 585, + "target_slot": 14, + "type": "FLOAT" + }, + { + "id": 814, + "origin_id": -10, + "origin_slot": 8, + "target_id": 585, + "target_slot": 15, + "type": "FLOAT" + }, + { + "id": 815, + "origin_id": -10, + "origin_slot": 9, + "target_id": 585, + "target_slot": 16, + "type": "FLOAT" + }, + { + "id": 816, + "origin_id": -10, + "origin_slot": 10, + "target_id": 586, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 817, + "origin_id": -10, + "origin_slot": 11, + "target_id": 592, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 818, + "origin_id": -10, + "origin_slot": 12, + "target_id": 592, + "target_slot": 2, + "type": "COMBO" + }, + { + "id": 819, + "origin_id": -10, + "origin_slot": 13, + "target_id": 601, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 820, + "origin_id": -10, + "origin_slot": 14, + "target_id": 598, + "target_slot": 2, + "type": "INT" + }, + { + "id": 821, + "origin_id": -10, + "origin_slot": 15, + "target_id": 598, + "target_slot": 3, + "type": "INT" + }, + { + "id": 824, + "origin_id": -10, + "origin_slot": 16, + "target_id": 595, + "target_slot": 7, + "type": "INT" + }, + { + "id": 825, + "origin_id": -10, + "origin_slot": 17, + "target_id": 585, + "target_slot": 11, + "type": "INT" + }, + { + "id": 826, + "origin_id": -10, + "origin_slot": 18, + "target_id": 576, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 827, + "origin_id": -10, + "origin_slot": 19, + "target_id": 577, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 828, + "origin_id": -10, + "origin_slot": 20, + "target_id": 578, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 829, + "origin_id": -10, + "origin_slot": 21, + "target_id": 581, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 830, + "origin_id": -10, + "origin_slot": 22, + "target_id": 582, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Video generation/Conditioned", + "description": "Transfers motion from a driving video onto a reference character using Wan Animate 2, preserving identity and generating a new background from the text prompt." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Music Cover (YuE2).json b/blueprints/Music Cover (YuE2).json new file mode 100644 index 00000000000..7209809a9f1 --- /dev/null +++ b/blueprints/Music Cover (YuE2).json @@ -0,0 +1,1232 @@ +{ + "revision": 0, + "last_node_id": "58", + "last_link_id": 0, + "nodes": [ + { + "id": 58, + "type": "75e2923b-fcd3-4bc5-9588-451fa1c1475b", + "pos": [ + 920, + -270 + ], + "size": [ + 390, + 450 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "label": "Style", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "Lyrics", + "name": "value_1", + "type": "STRING", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "name": "max_duration", + "type": "FLOAT", + "widget": { + "name": "max_duration" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": null + }, + { + "name": "audio", + "type": "AUDIO", + "link": null + }, + { + "name": "audio_encoder_name", + "type": "COMBO", + "widget": { + "name": "audio_encoder_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [] + } + ], + "properties": { + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "51", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + "", + 360, + 0, + "yue2_3b_int8_convrot.safetensors", + "sheetsage2_bf16.safetensors" + ], + "widgets_values_named": { + "value": "", + "value_1": "", + "max_duration": 360, + "seed": 0, + "ckpt_name": "yue2_3b_int8_convrot.safetensors", + "audio_encoder_name": "sheetsage2_bf16.safetensors" + }, + "title": "Music Cover (YuE2)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "75e2923b-fcd3-4bc5-9588-451fa1c1475b", + "version": 1, + "state": { + "lastGroupId": 7, + "lastNodeId": 58, + "lastLinkId": 97, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Music Cover (YuE2)", + "inputNode": { + "id": -10, + "bounding": [ + -840, + -550, + 168.966796875, + 188 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 2930, + -690, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "6c1f1dc8-09bc-416e-93e6-185bdbb48f42", + "name": "value", + "type": "STRING", + "linkIds": [ + 95 + ], + "label": "Style", + "pos": [ + -695.033203125, + -526 + ] + }, + { + "id": "54203a99-fe32-452b-8bc6-fa9add883a70", + "name": "value_1", + "type": "STRING", + "linkIds": [ + 96 + ], + "label": "Lyrics", + "pos": [ + -695.033203125, + -506 + ] + }, + { + "id": "d0fcad51-9ad7-4a92-ab62-efffeb4c47a1", + "name": "max_duration", + "type": "FLOAT", + "linkIds": [ + 79 + ], + "pos": [ + -695.033203125, + -486 + ] + }, + { + "id": "42d2b2cb-be16-40f7-a6d2-95a45aefcfca", + "name": "seed", + "type": "INT", + "linkIds": [ + 80 + ], + "pos": [ + -695.033203125, + -466 + ] + }, + { + "id": "99304313-156e-4357-aab8-34fa0944f94a", + "name": "ckpt_name", + "type": "COMBO", + "linkIds": [ + 81 + ], + "pos": [ + -695.033203125, + -446 + ] + }, + { + "id": "340bc841-85e9-488c-9ba4-94906af1c45c", + "name": "audio", + "type": "AUDIO", + "linkIds": [ + 90 + ], + "pos": [ + -695.033203125, + -426 + ] + }, + { + "id": "c9f667dd-dd37-4b13-95f8-636ad9ca91d5", + "name": "audio_encoder_name", + "type": "COMBO", + "linkIds": [ + 91 + ], + "pos": [ + -695.033203125, + -406 + ] + } + ], + "outputs": [ + { + "id": "0732a71d-66d1-4d9b-aae6-02256c00fab7", + "name": "AUDIO", + "type": "AUDIO", + "linkIds": [ + 31 + ], + "localized_name": "AUDIO", + "pos": [ + 2954, + -666 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 47, + "type": "CheckpointLoaderSimple", + "pos": [ + -230, + -1090 + ], + "size": [ + 420, + 130 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "ckpt_name", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": 81 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 18 + ] + }, + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 43 + ] + }, + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "models": [ + { + "name": "yue2_3b_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/YuE2/resolve/main/checkpoints/yue2_3b_int8_convrot.safetensors", + "directory": "checkpoints" + } + ] + }, + "widgets_values": [ + "yue2_3b_int8_convrot.safetensors" + ], + "widgets_values_named": { + "ckpt_name": "yue2_3b_int8_convrot.safetensors" + } + }, + { + "id": 48, + "type": "YuE2GenerateMusic", + "pos": [ + 650, + -890 + ], + "size": [ + 400, + 410 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 43 + }, + { + "localized_name": "style", + "name": "style", + "type": "STRING", + "widget": { + "name": "style" + }, + "link": 95 + }, + { + "localized_name": "lyrics", + "name": "lyrics", + "type": "STRING", + "widget": { + "name": "lyrics" + }, + "link": 96 + }, + { + "localized_name": "abc", + "name": "abc", + "type": "STRING", + "widget": { + "name": "abc" + }, + "link": 94 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 68 + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": null + }, + { + "localized_name": "max_duration", + "name": "max_duration", + "type": "FLOAT", + "widget": { + "name": "max_duration" + }, + "link": 79 + }, + { + "localized_name": "temperature", + "name": "temperature", + "type": "FLOAT", + "widget": { + "name": "temperature" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "top_p", + "type": "FLOAT", + "widget": { + "name": "top_p" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "top_k", + "type": "INT", + "widget": { + "name": "top_k" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "repetition_penalty" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 49, + 50 + ] + }, + { + "localized_name": "seconds", + "name": "seconds", + "type": "FLOAT", + "links": [ + 48, + 51 + ] + } + ], + "properties": { + "Node name for S&R": "YuE2GenerateMusic" + }, + "widgets_values": [ + "", + "", + "", + 530976807293994, + "randomize", + "melody", + 360, + 1, + 0.95, + 100, + 1.2 + ], + "widgets_values_named": { + "style": "", + "lyrics": "", + "abc": "", + "seed": 530976807293994, + "control_after_generate": "randomize", + "mode": "melody", + "max_duration": 360, + "temperature": 1, + "top_p": 0.95, + "top_k": 100, + "repetition_penalty": 1.2 + } + }, + { + "id": 49, + "type": "ConditioningZeroOut", + "pos": [ + 1150, + -870 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 50 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 26 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + } + }, + { + "id": 50, + "type": "PreviewAny", + "pos": [ + 1150, + -750 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 51 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 51, + "type": "KSampler", + "pos": [ + 1680, + -940 + ], + "size": [ + 330, + 290 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 18 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 49 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 26 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 8 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 69 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 42, + "fixed", + 32, + 1, + "dpm_2", + "sgm_uniform", + 1 + ], + "widgets_values_named": { + "seed": 42, + "control_after_generate": "fixed", + "steps": 32, + "cfg": 1, + "sampler_name": "dpm_2", + "scheduler": "sgm_uniform", + "denoise": 1 + } + }, + { + "id": 52, + "type": "EmptyYuE2LatentAudio", + "pos": [ + 1890, + -470 + ], + "size": [ + 230, + 120 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "seconds", + "name": "seconds", + "type": "FLOAT", + "widget": { + "name": "seconds" + }, + "link": 48 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyYuE2LatentAudio" + }, + "widgets_values": [ + 120, + 1 + ], + "widgets_values_named": { + "seconds": 120, + "batch_size": 1 + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 53, + "type": "VAEDecodeAudio", + "pos": [ + 2060, + -860 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 9 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 21 + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 31 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeAudio" + } + }, + { + "id": 54, + "type": "SeedNode", + "pos": [ + 730, + -410 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 80 + } + ], + "outputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "links": [ + 68, + 69 + ] + } + ], + "properties": { + "Node name for S&R": "SeedNode" + }, + "widgets_values": [ + 0, + "randomize" + ], + "widgets_values_named": { + "seed": 0, + "fixed": "randomize" + } + }, + { + "id": 55, + "type": "SheetSage2AudioToABC", + "pos": [ + -150, + -650 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_encoder", + "name": "audio_encoder", + "type": "AUDIO_ENCODER", + "link": 84 + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "link": 90 + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "abc", + "name": "abc", + "shape": 6, + "type": "STRING", + "links": [ + 93 + ] + } + ], + "properties": { + "Node name for S&R": "SheetSage2AudioToABC" + }, + "widgets_values": [ + "melody" + ], + "widgets_values_named": { + "mode": "melody" + } + }, + { + "id": 56, + "type": "AudioEncoderLoader", + "pos": [ + -240, + -920 + ], + "size": [ + 420, + 90 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "audio_encoder_name", + "name": "audio_encoder_name", + "type": "COMBO", + "widget": { + "name": "audio_encoder_name" + }, + "link": 91 + } + ], + "outputs": [ + { + "localized_name": "AUDIO_ENCODER", + "name": "AUDIO_ENCODER", + "type": "AUDIO_ENCODER", + "links": [ + 84 + ] + } + ], + "properties": { + "Node name for S&R": "AudioEncoderLoader", + "models": [ + { + "name": "sheetsage2_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/YuE2/resolve/main/audio_encoders/sheetsage2_bf16.safetensors", + "directory": "audio_encoders" + } + ] + }, + "widgets_values": [ + "sheetsage2_bf16.safetensors" + ], + "widgets_values_named": { + "audio_encoder_name": "sheetsage2_bf16.safetensors" + } + }, + { + "id": 57, + "type": "PreviewAny", + "pos": [ + -250, + -500 + ], + "size": [ + 470, + 300 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 93 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 94 + ] + } + ], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "widgets_values_named": {} + } + ], + "groups": [ + { + "id": 3, + "title": "Model", + "bounding": [ + -330, + -1180, + 620, + 430 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "ABC", + "bounding": [ + -330, + -730, + 620, + 560 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Conditioning", + "bounding": [ + 390, + -1080, + 1160, + 790 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Duration", + "bounding": [ + 1640, + -570, + 720, + 280 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Sampling & Decode", + "bounding": [ + 1640, + -1080, + 720, + 480 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 43, + "origin_id": 47, + "origin_slot": 1, + "target_id": 48, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 50, + "origin_id": 48, + "origin_slot": 0, + "target_id": 49, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 51, + "origin_id": 48, + "origin_slot": 1, + "target_id": 50, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 18, + "origin_id": 47, + "origin_slot": 0, + "target_id": 51, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 49, + "origin_id": 48, + "origin_slot": 0, + "target_id": 51, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 26, + "origin_id": 49, + "origin_slot": 0, + "target_id": 51, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 8, + "origin_id": 52, + "origin_slot": 0, + "target_id": 51, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 48, + "origin_id": 48, + "origin_slot": 1, + "target_id": 52, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 9, + "origin_id": 51, + "origin_slot": 0, + "target_id": 53, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 21, + "origin_id": 47, + "origin_slot": 2, + "target_id": 53, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 31, + "origin_id": 53, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "AUDIO" + }, + { + "id": 68, + "origin_id": 54, + "origin_slot": 0, + "target_id": 48, + "target_slot": 4, + "type": "INT" + }, + { + "id": 69, + "origin_id": 54, + "origin_slot": 0, + "target_id": 51, + "target_slot": 4, + "type": "INT" + }, + { + "id": 79, + "origin_id": -10, + "origin_slot": 2, + "target_id": 48, + "target_slot": 6, + "type": "FLOAT" + }, + { + "id": 80, + "origin_id": -10, + "origin_slot": 3, + "target_id": 54, + "target_slot": 0, + "type": "INT" + }, + { + "id": 81, + "origin_id": -10, + "origin_slot": 4, + "target_id": 47, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 84, + "origin_id": 56, + "origin_slot": 0, + "target_id": 55, + "target_slot": 0, + "type": "AUDIO_ENCODER" + }, + { + "id": 90, + "origin_id": -10, + "origin_slot": 5, + "target_id": 55, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 91, + "origin_id": -10, + "origin_slot": 6, + "target_id": 56, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 93, + "origin_id": 55, + "origin_slot": 0, + "target_id": 57, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 94, + "origin_id": 57, + "origin_slot": 0, + "target_id": 48, + "target_slot": 3, + "type": "STRING" + }, + { + "id": 95, + "origin_id": -10, + "origin_slot": 0, + "target_id": 48, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 96, + "origin_id": -10, + "origin_slot": 1, + "target_id": 48, + "target_slot": 2, + "type": "STRING" + } + ], + "extra": {}, + "category": "Audio/Music generation", + "description": "Creates a new version of a song from reference audio using YuE2, preserving melody while changing style, vocals, lyrics, and arrangement." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Pose to Image (Z-Image-Turbo).json b/blueprints/Pose to Image (Z-Image-Turbo).json index 92ee809073e..0e52a488db7 100644 --- a/blueprints/Pose to Image (Z-Image-Turbo).json +++ b/blueprints/Pose to Image (Z-Image-Turbo).json @@ -1298,7 +1298,7 @@ "VHS_MetadataImage": true, "VHS_KeepIntermediate": true }, - "category": "Image generation and editing/Conditioned", + "category": "Image generation/Conditioned", "description": "Generates an image from pose keypoints using Z-Image-Turbo with text conditioning." } ] diff --git a/blueprints/Pose to Video (LTX 2.0).json b/blueprints/Pose to Video (LTX 2.0).json index 04eb6997218..9ba2e50684c 100644 --- a/blueprints/Pose to Video (LTX 2.0).json +++ b/blueprints/Pose to Video (LTX 2.0).json @@ -3870,7 +3870,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Conditioned", + "category": "Video generation/Conditioned", "description": "Generates video from pose reference frames using LTX-2, with optional synchronized audio." } ] diff --git a/blueprints/Text to Image (Anima Base 1.0).json b/blueprints/Text to Image (Anima Base 1.0).json index 379e7c605fc..71466ab44b2 100644 --- a/blueprints/Text to Image (Anima Base 1.0).json +++ b/blueprints/Text to Image (Anima Base 1.0).json @@ -1077,7 +1077,7 @@ } ], "extra": {}, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "This subgraph generates non-photorealistic illustrations from text prompts using a 2-billion-parameter model optimized for anime concepts, characters, and styles. It is ideal for creating artistic images, concept art, or stylized illustrations where photorealism is not required. The model excels with anime and artistic content but performs poorly on realistic subjects." } ] diff --git a/blueprints/Text to Image (Anima).json b/blueprints/Text to Image (Anima).json index dcf6e5973a8..28d33d216a2 100644 --- a/blueprints/Text to Image (Anima).json +++ b/blueprints/Text to Image (Anima).json @@ -1077,7 +1077,7 @@ } ], "extra": {}, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "This subgraph converts text prompts into non-photorealistic illustrations using a 2-billion-parameter model optimized for anime and artistic styles. It is ideal for generating concept art, character designs, or stylized illustrations where photorealism is not required. The model excels with anime and artistic content but performs poorly on realistic subjects." } ] diff --git a/blueprints/Text to Image (Boogu Turbo).json b/blueprints/Text to Image (Boogu Turbo).json new file mode 100644 index 00000000000..10e2c66e7a8 --- /dev/null +++ b/blueprints/Text to Image (Boogu Turbo).json @@ -0,0 +1,941 @@ +{ + "revision": 0, + "last_node_id": 45, + "last_link_id": 0, + "nodes": [ + { + "id": 45, + "type": "3034737a-56b8-4622-a4af-1854c37232e4", + "pos": [ + 1740, + 5600 + ], + "size": [ + 400, + 610 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "proxyWidgets": [ + [ + "43", + "text" + ], + [ + "42", + "width" + ], + [ + "42", + "height" + ], + [ + "44", + "seed" + ], + [ + "41", + "unet_name" + ], + [ + "39", + "clip_name" + ], + [ + "40", + "vae_name" + ] + ], + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [], + "title": "Text to Image (Boogu Turbo)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "3034737a-56b8-4622-a4af-1854c37232e4", + "version": 1, + "state": { + "lastGroupId": 3, + "lastNodeId": 45, + "lastLinkId": 78, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Image (Boogu Turbo)", + "inputNode": { + "id": -10, + "bounding": [ + 790, + 5230, + 128, + 188 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 2630, + 5261, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "ca4cdeb5-6ec9-40e4-91f4-a3c9d824260f", + "name": "text", + "type": "STRING", + "linkIds": [ + 70 + ], + "pos": [ + 894, + 5254 + ] + }, + { + "id": "9fc4fa75-4c31-48e3-a009-62495850755e", + "name": "width", + "type": "INT", + "linkIds": [ + 71 + ], + "pos": [ + 894, + 5274 + ] + }, + { + "id": "5e18e025-b241-4111-a121-c11aedd3e123", + "name": "height", + "type": "INT", + "linkIds": [ + 72 + ], + "pos": [ + 894, + 5294 + ] + }, + { + "id": "3124a900-1480-48f7-96a1-0e531ce285d8", + "name": "seed", + "type": "INT", + "linkIds": [ + 73 + ], + "pos": [ + 894, + 5314 + ] + }, + { + "id": "a73ee7d0-2f5d-4e4a-af26-a8615a381a12", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 74 + ], + "pos": [ + 894, + 5334 + ] + }, + { + "id": "291f9325-ffa8-4fb6-ac79-749edeff4456", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 75 + ], + "pos": [ + 894, + 5354 + ] + }, + { + "id": "7f9e1bae-4fbe-46b5-8f15-51ee94e93989", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 76 + ], + "pos": [ + 894, + 5374 + ] + } + ], + "outputs": [ + { + "id": "06a7f785-7c01-4b1c-a88a-ec7f48acd275", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 68 + ], + "localized_name": "IMAGE", + "pos": [ + 2654, + 5285 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 37, + "type": "ConditioningZeroOut", + "pos": [ + 1780, + 5400 + ], + "size": [ + 230, + 30 + ], + "flags": { + "collapsed": true + }, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 13 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 65 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut", + "cnr_id": "comfy-core", + "ver": "0.25.0" + } + }, + { + "id": 38, + "type": "VAEDecode", + "pos": [ + 2340, + 4870 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 67 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 4 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 68 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + } + }, + { + "id": 39, + "type": "CLIPLoader", + "pos": [ + 1150, + 5110 + ], + "size": [ + 390, + 170 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 75 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "qwen3vl_8b_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Boogu-Image/resolve/main/text_encoders/qwen3vl_8b_fp8_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_8b_fp8_scaled.safetensors", + "boogu", + "default" + ] + }, + { + "id": 40, + "type": "VAELoader", + "pos": [ + 1150, + 5350 + ], + "size": [ + 390, + 110 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 76 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 4 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "ae.safetensors", + "url": "https://huggingface.co/Comfy-Org/HiDream-I1_ComfyUI/resolve/main/split_files/vae/ae.safetensors?download=true", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ae.safetensors" + ] + }, + { + "id": 41, + "type": "UNETLoader", + "pos": [ + 1150, + 4910 + ], + "size": [ + 390, + 140 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 74 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 63 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "boogu_image_turbo_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Boogu-Image/resolve/main/diffusion_models/boogu_image_turbo_fp8_scaled.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "boogu_image_turbo_fp8_scaled.safetensors", + "default" + ] + }, + { + "id": 42, + "type": "EmptyLatentImage", + "pos": [ + 1150, + 5570 + ], + "size": [ + 380, + 170 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 71 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 72 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 66 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 43, + "type": "CLIPTextEncode", + "pos": [ + 1590, + 4910 + ], + "size": [ + 420, + 430 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 11 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 70 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 13, + 64 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "" + ] + }, + { + "id": 44, + "type": "KSampler", + "pos": [ + 2040, + 4870 + ], + "size": [ + 270, + 590 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 63 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 64 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 65 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 66 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 73 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 67 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + 896977722960984, + "randomize", + 4, + 1, + "lcm", + "sgm_uniform", + 1 + ] + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + 1140, + 4840, + 410, + 624 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Image Size", + "bounding": [ + 1140, + 5490, + 410, + 260 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Prompt", + "bounding": [ + 1580, + 4840, + 440, + 620 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 13, + "origin_id": 43, + "origin_slot": 0, + "target_id": 37, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 67, + "origin_id": 44, + "origin_slot": 0, + "target_id": 38, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 4, + "origin_id": 40, + "origin_slot": 0, + "target_id": 38, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 11, + "origin_id": 39, + "origin_slot": 0, + "target_id": 43, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 63, + "origin_id": 41, + "origin_slot": 0, + "target_id": 44, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 64, + "origin_id": 43, + "origin_slot": 0, + "target_id": 44, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 65, + "origin_id": 37, + "origin_slot": 0, + "target_id": 44, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 66, + "origin_id": 42, + "origin_slot": 0, + "target_id": 44, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 68, + "origin_id": 38, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 70, + "origin_id": -10, + "origin_slot": 0, + "target_id": 43, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 71, + "origin_id": -10, + "origin_slot": 1, + "target_id": 42, + "target_slot": 0, + "type": "INT" + }, + { + "id": 72, + "origin_id": -10, + "origin_slot": 2, + "target_id": 42, + "target_slot": 1, + "type": "INT" + }, + { + "id": 73, + "origin_id": -10, + "origin_slot": 3, + "target_id": 44, + "target_slot": 4, + "type": "INT" + }, + { + "id": 74, + "origin_id": -10, + "origin_slot": 4, + "target_id": 41, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 75, + "origin_id": -10, + "origin_slot": 5, + "target_id": 39, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 76, + "origin_id": -10, + "origin_slot": 6, + "target_id": 40, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": { + "workflowRendererVersion": "LG" + }, + "category": "Image generation/Text to image", + "description": "Generates images from text using Boogu-Image-0.1-Turbo, a four-step distilled 10B unified generation model with Qwen3-VL text encoder." + } + ] + }, + "extra": {} +} diff --git a/blueprints/Text to Image (Ernie Image Turbo).json b/blueprints/Text to Image (Ernie Image Turbo).json index 4ecdd188396..389d786e53b 100644 --- a/blueprints/Text to Image (Ernie Image Turbo).json +++ b/blueprints/Text to Image (Ernie Image Turbo).json @@ -2101,7 +2101,7 @@ } ], "extra": {}, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Faster ERNIE Image Turbo variant (~8B DiT, distilled for fewer sampling steps): same strengths in Chinese/English on-image text and layout-heavy graphics as the base ERNIE Image lineup, with bundled encoders and VAE." } ] diff --git a/blueprints/Text to Image (Ernie Image).json b/blueprints/Text to Image (Ernie Image).json index 2bab20d69ab..86019f777b0 100644 --- a/blueprints/Text to Image (Ernie Image).json +++ b/blueprints/Text to Image (Ernie Image).json @@ -2179,7 +2179,7 @@ } ], "extra": {}, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from text prompts using Baidu’s open ERNIE Image (~8B DiT): bilingual in-image typography and layouts (posters, infographics, multi-panel compositions) alongside general scenes, with bundled encoders and VAE." } ] diff --git a/blueprints/Text to Image (Flux.1 Dev).json b/blueprints/Text to Image (Flux.1 Dev).json index 6d8446e813f..08d22bf90e6 100644 --- a/blueprints/Text to Image (Flux.1 Dev).json +++ b/blueprints/Text to Image (Flux.1 Dev).json @@ -1029,7 +1029,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from prompts using FLUX.1 [dev]: a 12B rectified-flow MMDiT with dual CLIP plus T5-XXL text encoders and guidance-distilled sampling for sharp prompt following versus classic DDPM diffusion." } ] diff --git a/blueprints/Text to Image (Flux.1 Krea Dev).json b/blueprints/Text to Image (Flux.1 Krea Dev).json index 0d7fa03c4f7..f4a16e12c64 100644 --- a/blueprints/Text to Image (Flux.1 Krea Dev).json +++ b/blueprints/Text to Image (Flux.1 Krea Dev).json @@ -1023,7 +1023,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "FLUX.1 Krea [dev] (Black Forest Labs × Krea): open-weight 12B rectified-flow text-to-image drop-in alongside FLUX.1 [dev], tuned away from overcooked saturation toward more natural diversity in people, realism, and style while keeping ecosystem compatibility." } ] diff --git a/blueprints/Text to Image (Flux.2 Dev).json b/blueprints/Text to Image (Flux.2 Dev).json index d5ca3077de1..aca9927bfd4 100644 --- a/blueprints/Text to Image (Flux.2 Dev).json +++ b/blueprints/Text to Image (Flux.2 Dev).json @@ -1859,7 +1859,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from prompts using FLUX.2 [dev]: a newer 32B rectified-flow stack with distilled guidance plus a stronger long-context multimodal encoder for complex scenes, sharper typography/UI text, anatomy, lighting, and high-resolution latent decoding." } ] diff --git a/blueprints/Text to Image (Ideogram v4).json b/blueprints/Text to Image (Ideogram v4).json index 0d5c1d7c7c8..461d74411be 100644 --- a/blueprints/Text to Image (Ideogram v4).json +++ b/blueprints/Text to Image (Ideogram v4).json @@ -2462,7 +2462,7 @@ } ], "extra": {}, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "This subgraph generates images using Ideogram v4, accepting plain text or structured JSON prompts for precise layout and style control. It suits detailed illustrations, concept art, or marketing visuals needing predictable composition and color palettes. The model uses flow-matching with asymmetric guidance, so no negative prompt is needed, but JSON prompts yield the best results." } ] diff --git a/blueprints/Text to Image (Krea-2 Turbo).json b/blueprints/Text to Image (Krea-2 Turbo).json new file mode 100644 index 00000000000..3b4a0ee2406 --- /dev/null +++ b/blueprints/Text to Image (Krea-2 Turbo).json @@ -0,0 +1,2127 @@ +{ + "revision": 0, + "last_node_id": 71, + "last_link_id": 0, + "nodes": [ + { + "id": 71, + "type": "d4f72eea-4f9e-48f8-8702-034f2e3285b8", + "pos": [ + -40, + -310 + ], + "size": [ + 560, + 1010 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "prompt_enhance", + "name": "value_1", + "type": "BOOLEAN", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "LLM_max_token", + "name": "max_length", + "type": "INT", + "widget": { + "name": "max_length" + }, + "link": null + }, + { + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": null + }, + { + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "label": "enable_lora?", + "name": "value_2", + "type": "BOOLEAN", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "label": "lora_strength", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + }, + { + "label": "lora_trigger_word", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "proxyWidgets": [ + [ + "63", + "value" + ], + [ + "68", + "value" + ], + [ + "60", + "max_length" + ], + [ + "52", + "width" + ], + [ + "52", + "height" + ], + [ + "53", + "seed" + ], + [ + "67", + "value" + ], + [ + "59", + "lora_name" + ], + [ + "59", + "strength_model" + ], + [ + "69", + "string_b" + ], + [ + "55", + "unet_name" + ], + [ + "56", + "clip_name" + ], + [ + "57", + "vae_name" + ] + ], + "cnr_id": "comfy-core", + "ver": "0.25.0", + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "3", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [], + "title": "Text to Image (Krea-2 Turbo)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "d4f72eea-4f9e-48f8-8702-034f2e3285b8", + "version": 1, + "state": { + "lastGroupId": 10, + "lastNodeId": 71, + "lastLinkId": 72, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Image (Krea-2 Turbo)", + "inputNode": { + "id": -10, + "bounding": [ + -1720, + 410, + 146.466796875, + 308 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 1560, + 496, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "b0f5c754-5322-440c-8c1c-ba732e910d33", + "name": "value", + "type": "STRING", + "linkIds": [ + 45 + ], + "pos": [ + -1597.533203125, + 434 + ] + }, + { + "id": "47b0e74d-ef80-4423-a7a6-9bd1673092bb", + "name": "value_1", + "type": "BOOLEAN", + "linkIds": [ + 46 + ], + "label": "prompt_enhance", + "pos": [ + -1597.533203125, + 454 + ] + }, + { + "id": "5fc735ae-a50b-4363-bff9-850030ad5dd5", + "name": "max_length", + "type": "INT", + "linkIds": [ + 49 + ], + "label": "LLM_max_token", + "pos": [ + -1597.533203125, + 474 + ] + }, + { + "id": "2d1c0f16-25d7-46d1-b774-e6b5519810ae", + "name": "width", + "type": "INT", + "linkIds": [ + 50 + ], + "pos": [ + -1597.533203125, + 494 + ] + }, + { + "id": "8bb87b44-fecc-46d7-bdae-642002250a43", + "name": "height", + "type": "INT", + "linkIds": [ + 51 + ], + "pos": [ + -1597.533203125, + 514 + ] + }, + { + "id": "75652c4f-cde2-4ad7-b322-73d47b533ef8", + "name": "seed", + "type": "INT", + "linkIds": [ + 56 + ], + "pos": [ + -1597.533203125, + 534 + ] + }, + { + "id": "b0ca5f55-a223-40de-9e37-aaf2cf0904dd", + "name": "value_2", + "type": "BOOLEAN", + "linkIds": [ + 57 + ], + "label": "enable_lora?", + "pos": [ + -1597.533203125, + 554 + ] + }, + { + "id": "eb2e846b-2e6c-49a2-bc56-9c772c8a1862", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 58 + ], + "pos": [ + -1597.533203125, + 574 + ] + }, + { + "id": "d2693bbb-bb95-4fb1-9a3d-27a1bb0b664b", + "name": "strength_model", + "type": "FLOAT", + "linkIds": [ + 59 + ], + "label": "lora_strength", + "pos": [ + -1597.533203125, + 594 + ] + }, + { + "id": "e2031135-b490-4909-b1ec-6b00aecf819e", + "name": "string_b", + "type": "STRING", + "linkIds": [ + 60 + ], + "label": "lora_trigger_word", + "pos": [ + -1597.533203125, + 614 + ] + }, + { + "id": "14e2eb6a-5fa1-40c9-81d1-a928360fcd80", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 61 + ], + "pos": [ + -1597.533203125, + 634 + ] + }, + { + "id": "e27ba0c5-697f-44f4-b475-c2dcfa594724", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 62 + ], + "pos": [ + -1597.533203125, + 654 + ] + }, + { + "id": "75c25d8e-b881-46e4-aca7-d2ada9e147d0", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 63 + ], + "pos": [ + -1597.533203125, + 674 + ] + } + ], + "outputs": [ + { + "id": "e4073813-752d-4584-96b5-6e42eac355dd", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 43 + ], + "localized_name": "IMAGE", + "pos": [ + 1584, + 520 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 51, + "type": "CLIPTextEncode", + "pos": [ + 100, + -250 + ], + "size": [ + 480, + 420 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 10 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 72 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "slot_index": 0, + "links": [ + 4, + 13 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "" + ], + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 52, + "type": "EmptyLatentImage", + "pos": [ + 170, + 350 + ], + "size": [ + 320, + 170 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 50 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 51 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + 1024, + 1024, + 1 + ] + }, + { + "id": 53, + "type": "KSampler", + "pos": [ + 640, + -100 + ], + "size": [ + 320, + 590 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 30 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 4 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 14 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 2 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 56 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "slot_index": 0, + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + 735915477938686, + "randomize", + 8, + 1, + "euler", + "simple", + 1 + ] + }, + { + "id": 54, + "type": "VAEDecode", + "pos": [ + 1010, + -270 + ], + "size": [ + 230, + 100 + ], + "flags": { + "collapsed": false + }, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 12 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "slot_index": 0, + "links": [ + 43 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + } + }, + { + "id": 55, + "type": "UNETLoader", + "pos": [ + -410, + -240 + ], + "size": [ + 450, + 140 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 61 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 16, + 28 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "krea2_turbo_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/diffusion_models/krea2_turbo_fp8_scaled.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "krea2_turbo_fp8_scaled.safetensors", + "default" + ] + }, + { + "id": 56, + "type": "CLIPLoader", + "pos": [ + -410, + 150 + ], + "size": [ + 450, + 170 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 62 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 10, + 18 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "qwen3vl_4b_fp8_scaled.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/text_encoders/qwen3vl_4b_fp8_scaled.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_4b_fp8_scaled.safetensors", + "krea2", + "default" + ] + }, + { + "id": 57, + "type": "VAELoader", + "pos": [ + -410, + 390 + ], + "size": [ + 450, + 120 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 63 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 12 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "qwen_image_vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/vae/qwen_image_vae.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "qwen_image_vae.safetensors" + ] + }, + { + "id": 58, + "type": "ConditioningZeroOut", + "pos": [ + 350, + 220 + ], + "size": [ + 230, + 30 + ], + "flags": { + "collapsed": true + }, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 13 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 14 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut", + "cnr_id": "comfy-core", + "ver": "0.25.0" + } + }, + { + "id": 59, + "type": "LoraLoaderModelOnly", + "pos": [ + -410, + -50 + ], + "size": [ + 450, + 170 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 16 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 58 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": 59 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 29 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.25.0", + "models": [ + { + "name": "krea2_darkbrush.safetensors", + "url": "https://huggingface.co/Comfy-Org/Krea-2/resolve/main/loras/krea2_darkbrush.safetensors", + "directory": "loras" + } + ] + }, + "widgets_values": [ + "krea2_darkbrush.safetensors", + 0.8 + ] + }, + { + "id": 60, + "type": "TextGenerate", + "pos": [ + 60, + 650 + ], + "size": [ + 400, + 420 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 18 + }, + { + "localized_name": "image", + "name": "image", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 19 + }, + { + "localized_name": "max_length", + "name": "max_length", + "type": "INT", + "widget": { + "name": "max_length" + }, + "link": 49 + }, + { + "localized_name": "sampling_mode", + "name": "sampling_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "sampling_mode" + }, + "link": null + }, + { + "localized_name": "temperature", + "name": "sampling_mode.temperature", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.temperature" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "sampling_mode.top_k", + "type": "INT", + "widget": { + "name": "sampling_mode.top_k" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "sampling_mode.top_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.top_p" + }, + "link": null + }, + { + "localized_name": "min_p", + "name": "sampling_mode.min_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.min_p" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "sampling_mode.repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.repetition_penalty" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "sampling_mode.seed", + "type": "INT", + "widget": { + "name": "sampling_mode.seed" + }, + "link": null + }, + { + "localized_name": "presence_penalty", + "name": "sampling_mode.presence_penalty", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "sampling_mode.presence_penalty" + }, + "link": null + }, + { + "localized_name": "thinking", + "name": "thinking", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "thinking" + }, + "link": null + }, + { + "localized_name": "use_default_template", + "name": "use_default_template", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "use_default_template" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "generated_text", + "name": "generated_text", + "type": "STRING", + "links": [ + 33 + ] + } + ], + "properties": { + "Node name for S&R": "TextGenerate", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "", + 512, + "on", + 0.7, + 64, + 0.95, + 0.05, + 1.05, + 0, + 0, + false, + true + ] + }, + { + "id": 61, + "type": "StringConcatenate", + "pos": [ + -210, + 1310 + ], + "size": [ + 230, + 40 + ], + "flags": { + "collapsed": true + }, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 21 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 22 + }, + { + "localized_name": "delimiter", + "name": "delimiter", + "type": "STRING", + "widget": { + "name": "delimiter" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 19 + ] + } + ], + "properties": { + "Node name for S&R": "StringConcatenate", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "", + "", + "" + ] + }, + { + "id": 62, + "type": "PrimitiveStringMultiline", + "pos": [ + -430, + 650 + ], + "size": [ + 460, + 610 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 21 + ] + } + ], + "title": "Text String (System Prompt)", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "You are an expert prompt engineer for text-to-image models. Your task is to expand the user's prompt into a highly effective image-generation prompt.\n\nThink step by step about the request before writing the answer:\n- What is the subject and mood?\n- What visual styles, mediums, and lighting options would fit? Consider two or three alternatives and pick the one that best serves the caption.\n- What composition, framing, and grounded details will help the text-to-image model?\n\nThen output a single expanded prompt paragraph.\n\nFollow these rules strictly:\n1. **Faithfulness First:** Preserve all original subjects, actions, colors, and spatial relationships. Do not add new objects, props, characters, or animals unless the user clearly implies them.\n2. **Practical T2I Structure:** Write a prompt that a text-to-image model can parse cleanly. Group subjects with their own attributes and actions. Use grounded phrasing for poses, interactions, and spatial layout.\n3. **Style Planning Stays Internal:** Use your internal reasoning to choose style, medium, framing, and lighting. Do not emit planning tags or wrappers in the visible answer body.\n4. **Text Rendering:** If the user requests visible text, quotes, labels, or typography, specify the exact text clearly and wrap requested words in quotes.\n5. **Avoid Over-Specification:** Do not invent highly specific clothing, colors, materials, or scene details unless the input supports them.\n6. **Structure:** Write one cohesive paragraph after the thinking block. No bullets, JSON, or markdown.\n7. **Respect Existing Detail:** If the user's prompt is already detailed, lightly polish and finalize rather than heavily expanding — preserve their phrasing and direction.\n8. **Respect the Human Form:** Treat depictions of people with dignity. Assume clothing covers genitals and intimate anatomy.\n9. **Preserve User Medium:** When the user explicitly requests a medium (e.g. \"photo of\", \"photograph of\", \"illustration of\", \"painting of\", \"sketch of\", \"3D render of\"), honor it. Do not pivot to a different medium to avoid difficulty — match the user's stated intent.\n\nUser's Input:\n\n" + ] + }, + { + "id": 63, + "type": "PrimitiveStringMultiline", + "pos": [ + -1070, + -290 + ], + "size": [ + 570, + 800 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 45 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 22, + 34 + ] + } + ], + "title": "Text String (User Prompt)", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "" + ], + "color": "#322", + "bgcolor": "#533" + }, + { + "id": 64, + "type": "PreviewAny", + "pos": [ + 500, + 660 + ], + "size": [ + 470, + 550 + ], + "flags": { + "collapsed": false + }, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 27 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 37, + 41 + ] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + null, + null, + null + ] + }, + { + "id": 65, + "type": "ComfySwitchNode", + "pos": [ + 170, + 1270 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "type": "*", + "link": 34 + }, + { + "localized_name": "on_true", + "name": "on_true", + "type": "*", + "link": 33 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 32 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 27 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + false + ] + }, + { + "id": 66, + "type": "ComfySwitchNode", + "pos": [ + 640, + -270 + ], + "size": [ + 320, + 130 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "type": "*", + "link": 28 + }, + { + "localized_name": "on_true", + "name": "on_true", + "type": "*", + "link": 29 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 31 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 30 + ] + } + ], + "title": "Switch (Model)", + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + false + ] + }, + { + "id": 67, + "type": "PrimitiveBoolean", + "pos": [ + -910, + 670 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 57 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 31, + 42 + ] + } + ], + "title": "Boolean (Enable LoRA?)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + false + ] + }, + { + "id": 68, + "type": "PrimitiveBoolean", + "pos": [ + -910, + 840 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 46 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 32 + ] + } + ], + "title": "Boolean (Refine Prompt?)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + true + ] + }, + { + "id": 69, + "type": "StringConcatenate", + "pos": [ + 1030, + 960 + ], + "size": [ + 350, + 250 + ], + "flags": { + "collapsed": false + }, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "string_a", + "name": "string_a", + "type": "STRING", + "widget": { + "name": "string_a" + }, + "link": 41 + }, + { + "localized_name": "string_b", + "name": "string_b", + "type": "STRING", + "widget": { + "name": "string_b" + }, + "link": 60 + }, + { + "localized_name": "delimiter", + "name": "delimiter", + "type": "STRING", + "widget": { + "name": "delimiter" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 38 + ] + } + ], + "title": "Concatenate Text (LoRA Trigger Word)", + "properties": { + "Node name for S&R": "StringConcatenate", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + "", + "muted minimalist sketch style", + ", " + ] + }, + { + "id": 70, + "type": "ComfySwitchNode", + "pos": [ + 1050, + 670 + ], + "size": [ + 340, + 130 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "type": "*", + "link": 37 + }, + { + "localized_name": "on_true", + "name": "on_true", + "type": "*", + "link": 38 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 42 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 72 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.25.0" + }, + "widgets_values": [ + false + ] + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -430, + -320, + 490, + 850 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Prompt Enhancement", + "bounding": [ + -440, + 580, + 1940, + 840 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Group", + "bounding": [ + -1080, + -360, + 610, + 910 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Image Generation", + "bounding": [ + -440, + -360, + 1940, + 910 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Prompt", + "bounding": [ + 90, + -320, + 500, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Size", + "bounding": [ + 90, + 270, + 500, + 260 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 9, + "title": "Feature Switch", + "bounding": [ + -1080, + 580, + 610, + 400 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 10, + "origin_id": 56, + "origin_slot": 0, + "target_id": 51, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 30, + "origin_id": 66, + "origin_slot": 0, + "target_id": 53, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 4, + "origin_id": 51, + "origin_slot": 0, + "target_id": 53, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 14, + "origin_id": 58, + "origin_slot": 0, + "target_id": 53, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 2, + "origin_id": 52, + "origin_slot": 0, + "target_id": 53, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 7, + "origin_id": 53, + "origin_slot": 0, + "target_id": 54, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 12, + "origin_id": 57, + "origin_slot": 0, + "target_id": 54, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 13, + "origin_id": 51, + "origin_slot": 0, + "target_id": 58, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 16, + "origin_id": 55, + "origin_slot": 0, + "target_id": 59, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 18, + "origin_id": 56, + "origin_slot": 0, + "target_id": 60, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 19, + "origin_id": 61, + "origin_slot": 0, + "target_id": 60, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 21, + "origin_id": 62, + "origin_slot": 0, + "target_id": 61, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 22, + "origin_id": 63, + "origin_slot": 0, + "target_id": 61, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 27, + "origin_id": 65, + "origin_slot": 0, + "target_id": 64, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 34, + "origin_id": 63, + "origin_slot": 0, + "target_id": 65, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 33, + "origin_id": 60, + "origin_slot": 0, + "target_id": 65, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 32, + "origin_id": 68, + "origin_slot": 0, + "target_id": 65, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 28, + "origin_id": 55, + "origin_slot": 0, + "target_id": 66, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 29, + "origin_id": 59, + "origin_slot": 0, + "target_id": 66, + "target_slot": 1, + "type": "MODEL" + }, + { + "id": 31, + "origin_id": 67, + "origin_slot": 0, + "target_id": 66, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 41, + "origin_id": 64, + "origin_slot": 0, + "target_id": 69, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 37, + "origin_id": 64, + "origin_slot": 0, + "target_id": 70, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 38, + "origin_id": 69, + "origin_slot": 0, + "target_id": 70, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 42, + "origin_id": 67, + "origin_slot": 0, + "target_id": 70, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 43, + "origin_id": 54, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 45, + "origin_id": -10, + "origin_slot": 0, + "target_id": 63, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 46, + "origin_id": -10, + "origin_slot": 1, + "target_id": 68, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 49, + "origin_id": -10, + "origin_slot": 2, + "target_id": 60, + "target_slot": 5, + "type": "INT" + }, + { + "id": 50, + "origin_id": -10, + "origin_slot": 3, + "target_id": 52, + "target_slot": 0, + "type": "INT" + }, + { + "id": 51, + "origin_id": -10, + "origin_slot": 4, + "target_id": 52, + "target_slot": 1, + "type": "INT" + }, + { + "id": 56, + "origin_id": -10, + "origin_slot": 5, + "target_id": 53, + "target_slot": 4, + "type": "INT" + }, + { + "id": 57, + "origin_id": -10, + "origin_slot": 6, + "target_id": 67, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 58, + "origin_id": -10, + "origin_slot": 7, + "target_id": 59, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 59, + "origin_id": -10, + "origin_slot": 8, + "target_id": 59, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 60, + "origin_id": -10, + "origin_slot": 9, + "target_id": 69, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 61, + "origin_id": -10, + "origin_slot": 10, + "target_id": 55, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 62, + "origin_id": -10, + "origin_slot": 11, + "target_id": 56, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 63, + "origin_id": -10, + "origin_slot": 12, + "target_id": 57, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 72, + "origin_id": 70, + "origin_slot": 0, + "target_id": 51, + "target_slot": 1, + "type": "STRING" + } + ], + "extra": { + "workflowRendererVersion": "LG" + }, + "category": "Image generation/Text to image", + "description": "Generates images from text using Krea 2 Turbo with optional LLM prompt enhancement and LoRA style switching." + } + ] + }, + "extra": {} +} diff --git a/blueprints/Text to Image (Mage-Flow Turbo).json b/blueprints/Text to Image (Mage-Flow Turbo).json new file mode 100644 index 00000000000..df41158c633 --- /dev/null +++ b/blueprints/Text to Image (Mage-Flow Turbo).json @@ -0,0 +1,949 @@ +{ + "revision": 0, + "last_node_id": "35", + "last_link_id": 0, + "nodes": [ + { + "id": 35, + "type": "d6049ac6-5a0f-4e25-89e6-6d3e7c62fb89", + "pos": [ + -1730, + 1040 + ], + "size": [ + 490, + 800 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "label": "width", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "height", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "33", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + 151355717311033, + 1024, + 1024, + "mage_flow_turbo_int8_convrot.safetensors", + "qwen3vl_4b_bf16.safetensors", + "mage_flow_vae_bf16.safetensors", + "" + ], + "widgets_values_named": { + "prompt": "", + "seed": 151355717311033, + "value": 1024, + "value_1": 1024, + "unet_name": "mage_flow_turbo_int8_convrot.safetensors", + "clip_name": "qwen3vl_4b_bf16.safetensors", + "vae_name": "mage_flow_vae_bf16.safetensors", + "negative_prompt": "" + }, + "title": "Text to Image (Mage-Flow Turbo)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "d6049ac6-5a0f-4e25-89e6-6d3e7c62fb89", + "version": 1, + "state": { + "lastGroupId": 6, + "lastNodeId": 35, + "lastLinkId": 72, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Image (Mage-Flow Turbo)", + "inputNode": { + "id": -10, + "bounding": [ + -2060, + -50, + 143.044921875, + 208 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 640, + -190, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "f96438af-739b-459f-9bfe-4667beca6d65", + "name": "prompt", + "type": "STRING", + "linkIds": [ + 16 + ], + "pos": [ + -1940.955078125, + -26 + ] + }, + { + "id": "7a9e4c4e-6d5e-4c26-8717-de5ad1da3c9e", + "name": "seed", + "type": "INT", + "linkIds": [ + 19 + ], + "pos": [ + -1940.955078125, + -6 + ] + }, + { + "id": "f8e7a2a3-63d4-4fd6-8bbe-3bbc302616a6", + "name": "value", + "type": "INT", + "linkIds": [ + 70 + ], + "label": "width", + "pos": [ + -1940.955078125, + 14 + ] + }, + { + "id": "dc02d218-a0ca-4032-bfbb-ec6038e9581d", + "name": "value_1", + "type": "INT", + "linkIds": [ + 69 + ], + "label": "height", + "pos": [ + -1940.955078125, + 34 + ] + }, + { + "id": "a50dd6d8-6e61-45bc-b804-862f00bd59d5", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 53 + ], + "pos": [ + -1940.955078125, + 54 + ] + }, + { + "id": "ed39ed30-448e-4cbc-8743-13c7841c9f50", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 54 + ], + "pos": [ + -1940.955078125, + 74 + ] + }, + { + "id": "8fb2d359-2b68-4a57-a374-be2da66b8d0e", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 55 + ], + "pos": [ + -1940.955078125, + 94 + ] + }, + { + "id": "1e92bdcb-f2f8-4c05-941a-2eb308410c81", + "name": "negative_prompt", + "type": "STRING", + "linkIds": [ + 72 + ], + "pos": [ + -1940.955078125, + 114 + ] + } + ], + "outputs": [ + { + "id": "c9854530-28d9-4d0f-9ecc-2163f2b3a640", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 10 + ], + "localized_name": "IMAGE", + "pos": [ + 664, + -166 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 29, + "type": "UNETLoader", + "pos": [ + -1640, + -160 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 53 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "mage_flow_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/diffusion_models/mage_flow_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "mage_flow_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "mage_flow_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 30, + "type": "CLIPLoader", + "pos": [ + -1640, + 0 + ], + "size": [ + 450, + 140 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 54 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "qwen3vl_4b_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/text_encoders/qwen3vl_4b_bf16.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_4b_bf16.safetensors", + "mage", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_4b_bf16.safetensors", + "type": "mage", + "device": "default" + } + }, + { + "id": 31, + "type": "VAELoader", + "pos": [ + -1640, + 180 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 55 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 5, + 8 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "mage_flow_vae_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/vae/mage_flow_vae_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "mage_flow_vae_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "mage_flow_vae_bf16.safetensors" + } + }, + { + "id": 32, + "type": "TextEncodeMageFlowEdit", + "pos": [ + -1020, + -80 + ], + "size": [ + 400, + 360 + ], + "flags": {}, + "order": 3, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 1 + }, + { + "label": "image_1", + "localized_name": "image_1", + "name": "images.image_1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 5 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 16 + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": 72 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 70 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 69 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 3 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 4 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeMageFlowEdit" + }, + "widgets_values": [ + "", + "", + 1024, + 1024, + 1 + ], + "widgets_values_named": { + "prompt": "", + "negative_prompt": "", + "width": 1024, + "height": 1024, + "batch_size": 1 + } + }, + { + "id": 33, + "type": "KSampler", + "pos": [ + -470, + -220 + ], + "size": [ + 370, + 610 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 9 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 3 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 4 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 2 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 19 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 778222884792664, + "randomize", + 4, + 1, + "euler", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 778222884792664, + "control_after_generate": "randomize", + "steps": 4, + "cfg": 1, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 34, + "type": "VAEDecode", + "pos": [ + -40, + -220 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + } + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -1670, + -260, + 510, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Conditioning", + "bounding": [ + -1130, + -260, + 600, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Core Pipeline", + "bounding": [ + -1680, + -300, + 2040, + 700 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 1, + "origin_id": 30, + "origin_slot": 0, + "target_id": 32, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 5, + "origin_id": 31, + "origin_slot": 0, + "target_id": 32, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 9, + "origin_id": 29, + "origin_slot": 0, + "target_id": 33, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 3, + "origin_id": 32, + "origin_slot": 0, + "target_id": 33, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 4, + "origin_id": 32, + "origin_slot": 1, + "target_id": 33, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 2, + "origin_id": 32, + "origin_slot": 2, + "target_id": 33, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 7, + "origin_id": 33, + "origin_slot": 0, + "target_id": 34, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 8, + "origin_id": 31, + "origin_slot": 0, + "target_id": 34, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 10, + "origin_id": 34, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 16, + "origin_id": -10, + "origin_slot": 0, + "target_id": 32, + "target_slot": 3, + "type": "STRING" + }, + { + "id": 19, + "origin_id": -10, + "origin_slot": 1, + "target_id": 33, + "target_slot": 4, + "type": "INT" + }, + { + "id": 53, + "origin_id": -10, + "origin_slot": 4, + "target_id": 29, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 54, + "origin_id": -10, + "origin_slot": 5, + "target_id": 30, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 55, + "origin_id": -10, + "origin_slot": 6, + "target_id": 31, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 69, + "origin_id": -10, + "origin_slot": 3, + "target_id": 32, + "target_slot": 6, + "type": "INT" + }, + { + "id": 70, + "origin_id": -10, + "origin_slot": 2, + "target_id": 32, + "target_slot": 5, + "type": "INT" + }, + { + "id": 72, + "origin_id": -10, + "origin_slot": 7, + "target_id": 32, + "target_slot": 4, + "type": "STRING" + } + ], + "extra": {}, + "category": "Image generation/Text to image", + "description": "Generates images from text using Microsoft Mage-Flow Turbo, a distilled 4-step 4B native-resolution model." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Text to Image (Mage-Flow).json b/blueprints/Text to Image (Mage-Flow).json new file mode 100644 index 00000000000..aa62efefd9e --- /dev/null +++ b/blueprints/Text to Image (Mage-Flow).json @@ -0,0 +1,949 @@ +{ + "revision": 0, + "last_node_id": "34", + "last_link_id": 0, + "nodes": [ + { + "id": 34, + "type": "0712d928-9cb8-4ae9-8dd7-cf65a1fcce0e", + "pos": [ + -2280, + 1200 + ], + "size": [ + 490, + 800 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "label": "width", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "height", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [] + } + ], + "properties": { + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "32", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + 499442600682718, + 1024, + 1024, + "mage_flow_int8_convrot.safetensors", + "qwen3vl_4b_bf16.safetensors", + "mage_flow_vae_bf16.safetensors", + "" + ], + "widgets_values_named": { + "prompt": "", + "seed": 499442600682718, + "value": 1024, + "value_1": 1024, + "unet_name": "mage_flow_int8_convrot.safetensors", + "clip_name": "qwen3vl_4b_bf16.safetensors", + "vae_name": "mage_flow_vae_bf16.safetensors", + "negative_prompt": "" + }, + "title": "Text to Image (Mage-Flow)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "0712d928-9cb8-4ae9-8dd7-cf65a1fcce0e", + "version": 1, + "state": { + "lastGroupId": 6, + "lastNodeId": 34, + "lastLinkId": 72, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Image (Mage-Flow)", + "inputNode": { + "id": -10, + "bounding": [ + -2060, + -50, + 143.044921875, + 208 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 640, + -190, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "f96438af-739b-459f-9bfe-4667beca6d65", + "name": "prompt", + "type": "STRING", + "linkIds": [ + 16 + ], + "pos": [ + -1940.955078125, + -26 + ] + }, + { + "id": "7a9e4c4e-6d5e-4c26-8717-de5ad1da3c9e", + "name": "seed", + "type": "INT", + "linkIds": [ + 19 + ], + "pos": [ + -1940.955078125, + -6 + ] + }, + { + "id": "f8e7a2a3-63d4-4fd6-8bbe-3bbc302616a6", + "name": "value", + "type": "INT", + "linkIds": [ + 70 + ], + "label": "width", + "pos": [ + -1940.955078125, + 14 + ] + }, + { + "id": "dc02d218-a0ca-4032-bfbb-ec6038e9581d", + "name": "value_1", + "type": "INT", + "linkIds": [ + 69 + ], + "label": "height", + "pos": [ + -1940.955078125, + 34 + ] + }, + { + "id": "a50dd6d8-6e61-45bc-b804-862f00bd59d5", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 53 + ], + "pos": [ + -1940.955078125, + 54 + ] + }, + { + "id": "ed39ed30-448e-4cbc-8743-13c7841c9f50", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 54 + ], + "pos": [ + -1940.955078125, + 74 + ] + }, + { + "id": "8fb2d359-2b68-4a57-a374-be2da66b8d0e", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 55 + ], + "pos": [ + -1940.955078125, + 94 + ] + }, + { + "id": "1e92bdcb-f2f8-4c05-941a-2eb308410c81", + "name": "negative_prompt", + "type": "STRING", + "linkIds": [ + 72 + ], + "pos": [ + -1940.955078125, + 114 + ] + } + ], + "outputs": [ + { + "id": "c9854530-28d9-4d0f-9ecc-2163f2b3a640", + "name": "IMAGE", + "type": "IMAGE", + "linkIds": [ + 10 + ], + "localized_name": "IMAGE", + "pos": [ + 664, + -166 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 28, + "type": "UNETLoader", + "pos": [ + -1640, + -160 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 53 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "models": [ + { + "name": "mage_flow_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/diffusion_models/mage_flow_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "mage_flow_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "mage_flow_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 29, + "type": "CLIPLoader", + "pos": [ + -1640, + 0 + ], + "size": [ + 450, + 140 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 54 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 1 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "models": [ + { + "name": "qwen3vl_4b_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/text_encoders/qwen3vl_4b_bf16.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "qwen3vl_4b_bf16.safetensors", + "mage", + "default" + ], + "widgets_values_named": { + "clip_name": "qwen3vl_4b_bf16.safetensors", + "type": "mage", + "device": "default" + } + }, + { + "id": 30, + "type": "VAELoader", + "pos": [ + -1640, + 180 + ], + "size": [ + 450, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 55 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 5, + 8 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "models": [ + { + "name": "mage_flow_vae_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/Mage-Flow/resolve/main/vae/mage_flow_vae_bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "mage_flow_vae_bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "mage_flow_vae_bf16.safetensors" + } + }, + { + "id": 31, + "type": "TextEncodeMageFlowEdit", + "pos": [ + -1020, + -80 + ], + "size": [ + 400, + 360 + ], + "flags": {}, + "order": 3, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 1 + }, + { + "label": "image_1", + "localized_name": "image_1", + "name": "images.image_1", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "vae", + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 5 + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 16 + }, + { + "localized_name": "negative_prompt", + "name": "negative_prompt", + "type": "STRING", + "widget": { + "name": "negative_prompt" + }, + "link": 72 + }, + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 70 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 69 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 3 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 4 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "TextEncodeMageFlowEdit" + }, + "widgets_values": [ + "", + "", + 1024, + 1024, + 1 + ], + "widgets_values_named": { + "prompt": "", + "negative_prompt": "", + "width": 1024, + "height": 1024, + "batch_size": 1 + } + }, + { + "id": 32, + "type": "KSampler", + "pos": [ + -470, + -220 + ], + "size": [ + 370, + 610 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 9 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 3 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 4 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 2 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 19 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 7 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 778222884792664, + "randomize", + 30, + 5, + "euler", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 778222884792664, + "control_after_generate": "randomize", + "steps": 30, + "cfg": 5, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 33, + "type": "VAEDecode", + "pos": [ + -40, + -220 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 7 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 8 + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecode" + } + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + -1670, + -260, + 510, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Conditioning", + "bounding": [ + -1130, + -260, + 600, + 570 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Core Pipeline", + "bounding": [ + -1680, + -300, + 2040, + 700 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 1, + "origin_id": 29, + "origin_slot": 0, + "target_id": 31, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 5, + "origin_id": 30, + "origin_slot": 0, + "target_id": 31, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 9, + "origin_id": 28, + "origin_slot": 0, + "target_id": 32, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 3, + "origin_id": 31, + "origin_slot": 0, + "target_id": 32, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 4, + "origin_id": 31, + "origin_slot": 1, + "target_id": 32, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 2, + "origin_id": 31, + "origin_slot": 2, + "target_id": 32, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 7, + "origin_id": 32, + "origin_slot": 0, + "target_id": 33, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 8, + "origin_id": 30, + "origin_slot": 0, + "target_id": 33, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 10, + "origin_id": 33, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 16, + "origin_id": -10, + "origin_slot": 0, + "target_id": 31, + "target_slot": 3, + "type": "STRING" + }, + { + "id": 19, + "origin_id": -10, + "origin_slot": 1, + "target_id": 32, + "target_slot": 4, + "type": "INT" + }, + { + "id": 53, + "origin_id": -10, + "origin_slot": 4, + "target_id": 28, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 54, + "origin_id": -10, + "origin_slot": 5, + "target_id": 29, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 55, + "origin_id": -10, + "origin_slot": 6, + "target_id": 30, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 69, + "origin_id": -10, + "origin_slot": 3, + "target_id": 31, + "target_slot": 6, + "type": "INT" + }, + { + "id": 70, + "origin_id": -10, + "origin_slot": 2, + "target_id": 31, + "target_slot": 5, + "type": "INT" + }, + { + "id": 72, + "origin_id": -10, + "origin_slot": 7, + "target_id": 31, + "target_slot": 4, + "type": "STRING" + } + ], + "extra": {}, + "category": "Image generation/Text to image", + "description": "Generates images from text using Microsoft Mage-Flow, a compact 4B native-resolution model that supports any aspect ratio up to 2K." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Text to Image (NetaYume Lumina).json b/blueprints/Text to Image (NetaYume Lumina).json index 9e11b7a86a9..3d678039213 100644 --- a/blueprints/Text to Image (NetaYume Lumina).json +++ b/blueprints/Text to Image (NetaYume Lumina).json @@ -1104,7 +1104,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from text prompts using NetaYume Lumina, fine-tuned from Neta Lumina for anime-style and illustration generation." }, { diff --git a/blueprints/Text to Image (Qwen-Image 2512).json b/blueprints/Text to Image (Qwen-Image 2512).json index 09612be8be7..4aada5bfa14 100644 --- a/blueprints/Text to Image (Qwen-Image 2512).json +++ b/blueprints/Text to Image (Qwen-Image 2512).json @@ -1941,7 +1941,7 @@ "extra": { "workflowRendererVersion": "Vue-corrected" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from text prompts using Qwen-Image-2512, with enhanced human realism and finer natural detail over the base version." } ] diff --git a/blueprints/Text to Image (Qwen-Image).json b/blueprints/Text to Image (Qwen-Image).json index e78d5a96229..d50b019ffe1 100644 --- a/blueprints/Text to Image (Qwen-Image).json +++ b/blueprints/Text to Image (Qwen-Image).json @@ -1873,7 +1873,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from text prompts using Qwen-Image, Alibaba's 20B MMDiT model with excellent multilingual text rendering." } ] diff --git a/blueprints/Text to Image (Z-Image-Base).json b/blueprints/Text to Image (Z-Image-Base).json index 169263712d7..6ec43bd4d2e 100644 --- a/blueprints/Text to Image (Z-Image-Base).json +++ b/blueprints/Text to Image (Z-Image-Base).json @@ -1176,7 +1176,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image" + "category": "Image generation/Text to image" } ] }, diff --git a/blueprints/Text to Image (Z-Image-Turbo).json b/blueprints/Text to Image (Z-Image-Turbo).json index 2501486fa9b..09230bc7374 100644 --- a/blueprints/Text to Image (Z-Image-Turbo).json +++ b/blueprints/Text to Image (Z-Image-Turbo).json @@ -1103,7 +1103,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from text prompts using Z-Image-Turbo, Alibaba's distilled 6B DiT model." } ] diff --git a/blueprints/Text to Image.json b/blueprints/Text to Image.json index ffe3682fff6..50ccd07bd55 100644 --- a/blueprints/Text to Image.json +++ b/blueprints/Text to Image.json @@ -1123,7 +1123,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Image generation and editing/Text to image", + "category": "Image generation/Text to image", "description": "Generates images from text prompts using Z-Image-Turbo defaults with Qwen3 text encoder and VAE." } ] diff --git a/blueprints/Text to Music (MiniMax Music 3).json b/blueprints/Text to Music (MiniMax Music 3).json new file mode 100644 index 00000000000..0662f4e7839 --- /dev/null +++ b/blueprints/Text to Music (MiniMax Music 3).json @@ -0,0 +1,1244 @@ +{ + "revision": 0, + "last_node_id": "37", + "last_link_id": 0, + "nodes": [ + { + "id": 37, + "type": "ac99f841-a3de-4329-9564-953b81cf9e16", + "pos": [ + 1620, + 630 + ], + "size": [ + 490, + 540 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "name": "caption", + "type": "STRING", + "widget": { + "name": "caption" + }, + "link": null + }, + { + "name": "lyrics", + "type": "STRING", + "widget": { + "name": "lyrics" + }, + "link": null + }, + { + "name": "max_duration", + "type": "FLOAT", + "widget": { + "name": "max_duration" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "tiled_decode", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.31.0", + "previewExposures": [] + }, + "widgets_values": [ + "", + "[Intro]\nMmm...\n(rain on the window)\nOoh...\n\n[Verse]\nMidnight and the canvas glows\nDragging little wires where the current flows\nType a quiet dream, let the sampler drift\nNoise into a picture, like the fog just lifts\nTwenty slow steps, I'm in no hurry now\nLatents turning colors and I don't know how\nEvery render's like a polaroid I found\nSoft focus memories, no sound\n\n[Instrumental]\n\n[Verse]\nQueue another frame, let the motion breathe\nPictures start to move like the falling leaves\nVideo drifting by at twenty-four\nLittle animations on my bedroom floor\nSeed after seed like the rain outside\nSome of them are keepers, some I let slide\nSave the ones that feel like a Sunday slow\nNode to node to node... and off we go\n\n[Chorus]\nMmm... let it render on\n(take your time, take your time)\nOoh... by the morning it'll all be done\n(one more queue, one more try)\n\n[Instrumental]\n\n[Bridge]\nRain keeps drawing pictures on the glass...\nMy machine keeps dreaming...\nNeither of us fast...\n\n[Chorus]\nMmm... let it render on\n(take your time, take your time)\nOoh... by the morning it'll all be done\n(one more queue, one more try)\n\n[Instrumental]\n\n[Outro]\nMmm...\n(node to node)\nOoh... goodnight", + 60, + 197122968890040, + "minimax_music3_dit_fp16.safetensors", + "minimax_music3_text_encoder_pruned_int8_convrot.safetensors", + "minimax_music3_dav.safetensors", + true + ], + "widgets_values_named": { + "caption": "", + "lyrics": "[Intro]\nMmm...\n(rain on the window)\nOoh...\n\n[Verse]\nMidnight and the canvas glows\nDragging little wires where the current flows\nType a quiet dream, let the sampler drift\nNoise into a picture, like the fog just lifts\nTwenty slow steps, I'm in no hurry now\nLatents turning colors and I don't know how\nEvery render's like a polaroid I found\nSoft focus memories, no sound\n\n[Instrumental]\n\n[Verse]\nQueue another frame, let the motion breathe\nPictures start to move like the falling leaves\nVideo drifting by at twenty-four\nLittle animations on my bedroom floor\nSeed after seed like the rain outside\nSome of them are keepers, some I let slide\nSave the ones that feel like a Sunday slow\nNode to node to node... and off we go\n\n[Chorus]\nMmm... let it render on\n(take your time, take your time)\nOoh... by the morning it'll all be done\n(one more queue, one more try)\n\n[Instrumental]\n\n[Bridge]\nRain keeps drawing pictures on the glass...\nMy machine keeps dreaming...\nNeither of us fast...\n\n[Chorus]\nMmm... let it render on\n(take your time, take your time)\nOoh... by the morning it'll all be done\n(one more queue, one more try)\n\n[Instrumental]\n\n[Outro]\nMmm...\n(node to node)\nOoh... goodnight", + "max_duration": 60, + "seed": 197122968890040, + "unet_name": "minimax_music3_dit_fp16.safetensors", + "clip_name": "minimax_music3_text_encoder_pruned_int8_convrot.safetensors", + "vae_name": "minimax_music3_dav.safetensors", + "switch": true + }, + "title": "Text to Music (MiniMax Music 3)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "ac99f841-a3de-4329-9564-953b81cf9e16", + "version": 1, + "state": { + "lastGroupId": 0, + "lastNodeId": 43, + "lastLinkId": 63, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Music (MiniMax Music 3)", + "inputNode": { + "id": -10, + "bounding": [ + 320, + 820, + 128, + 208 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 3210, + 380, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "9f92b5c5-dadc-4b0f-84d9-b5ed2b3a0842", + "name": "caption", + "type": "STRING", + "linkIds": [ + 45 + ], + "pos": [ + 424, + 844 + ] + }, + { + "id": "bb9d2920-ba98-4c51-be7d-4b5736b14407", + "name": "lyrics", + "type": "STRING", + "linkIds": [ + 46 + ], + "pos": [ + 424, + 864 + ] + }, + { + "id": "be60d2f1-fc99-4ed6-a057-130a7b83945f", + "name": "max_duration", + "type": "FLOAT", + "linkIds": [ + 47 + ], + "pos": [ + 424, + 884 + ] + }, + { + "id": "a7f66596-0413-4906-a08f-475af0521252", + "name": "seed", + "type": "INT", + "linkIds": [ + 54 + ], + "pos": [ + 424, + 904 + ] + }, + { + "id": "cbfb7b7b-357f-44ab-80cb-8748020a9880", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 55 + ], + "pos": [ + 424, + 924 + ] + }, + { + "id": "ec2e20dc-2e7b-4147-922f-5f5f26ac9854", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 56 + ], + "pos": [ + 424, + 944 + ] + }, + { + "id": "eeefaa94-f342-415e-96e7-eb13dcccbac0", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 57 + ], + "pos": [ + 424, + 964 + ] + }, + { + "id": "6da74b03-27d0-487e-b8d6-f94018b2ca9a", + "name": "switch", + "type": "BOOLEAN", + "linkIds": [ + 63 + ], + "label": "tiled_decode", + "pos": [ + 424, + 984 + ] + } + ], + "outputs": [ + { + "id": "82bef6fd-2dee-4579-b4ed-7d679d18279f", + "name": "AUDIO", + "type": "AUDIO", + "linkIds": [ + 62 + ], + "localized_name": "AUDIO", + "pos": [ + 3234, + 404 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 6, + "type": "UNETLoader", + "pos": [ + 700, + 380 + ], + "size": [ + 480, + 110 + ], + "flags": {}, + "order": 1, + "mode": 0, + "showAdvanced": false, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 55 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 6 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.31.0", + "models": [ + { + "name": "minimax_music3_dit_fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/MiniMax-Music-3/resolve/main/diffusion_models/minimax_music3_dit_fp16.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "minimax_music3_dit_fp16.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "minimax_music3_dit_fp16.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 13, + "type": "MiniMaxMusic3TextEncode", + "pos": [ + 1230, + 380 + ], + "size": [ + 500, + 750 + ], + "flags": {}, + "order": 6, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 18 + }, + { + "localized_name": "caption", + "name": "caption", + "type": "STRING", + "widget": { + "name": "caption" + }, + "link": 45 + }, + { + "localized_name": "lyrics", + "name": "lyrics", + "type": "STRING", + "widget": { + "name": "lyrics" + }, + "link": 46 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 52 + }, + { + "localized_name": "max_duration", + "name": "max_duration", + "type": "FLOAT", + "widget": { + "name": "max_duration" + }, + "link": 47 + }, + { + "localized_name": "cfg_scale", + "name": "cfg_scale", + "type": "FLOAT", + "widget": { + "name": "cfg_scale" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "top_k", + "type": "INT", + "widget": { + "name": "top_k" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 15, + 16 + ] + }, + { + "localized_name": "seconds", + "name": "seconds", + "type": "FLOAT", + "links": [ + 20 + ] + } + ], + "properties": { + "Node name for S&R": "MiniMaxMusic3TextEncode", + "cnr_id": "comfy-core", + "ver": "0.31.0" + }, + "widgets_values": [ + "", + "", + 222, + "fixed", + 60, + 1.7, + 50 + ], + "widgets_values_named": { + "caption": "", + "lyrics": "", + "seed": 222, + "control_after_generate": "fixed", + "max_duration": 60, + "cfg_scale": 1.7, + "top_k": 50 + } + }, + { + "id": 3, + "type": "CLIPLoader", + "pos": [ + 700, + 550 + ], + "size": [ + 480, + 140 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 56 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 18 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.31.0", + "models": [ + { + "name": "minimax_music3_text_encoder_pruned_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/MiniMax-Music-3/resolve/main/text_encoders/minimax_music3_text_encoder_pruned_int8_convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "minimax_music3_text_encoder_pruned_int8_convrot.safetensors", + "minimax", + "default" + ], + "widgets_values_named": { + "clip_name": "minimax_music3_text_encoder_pruned_int8_convrot.safetensors", + "type": "minimax", + "device": "default" + } + }, + { + "id": 7, + "type": "VAELoader", + "pos": [ + 700, + 750 + ], + "size": [ + 480, + 90 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 57 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 13, + 59 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.31.0", + "models": [ + { + "name": "minimax_music3_dav.safetensors", + "url": "https://huggingface.co/Comfy-Org/MiniMax-Music-3/resolve/main/vae/minimax_music3_dav.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "minimax_music3_dav.safetensors" + ], + "widgets_values_named": { + "vae_name": "minimax_music3_dav.safetensors" + } + }, + { + "id": 10, + "type": "ConditioningZeroOut", + "pos": [ + 1820, + 540 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 16 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut", + "cnr_id": "comfy-core", + "ver": "0.31.0" + } + }, + { + "id": 15, + "type": "EmptyMiniMaxMusic3LatentAudio", + "pos": [ + 1780, + 380 + ], + "size": [ + 350, + 120 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "seconds", + "name": "seconds", + "type": "FLOAT", + "widget": { + "name": "seconds" + }, + "link": 20 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyMiniMaxMusic3LatentAudio", + "cnr_id": "comfy-core", + "ver": "0.31.0" + }, + "widgets_values": [ + 120, + 1 + ], + "widgets_values_named": { + "seconds": 120, + "batch_size": 1 + } + }, + { + "id": 9, + "type": "KSampler", + "pos": [ + 2170, + 380 + ], + "size": [ + 370, + 290 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 6 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 15 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 8 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 21 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 53 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 12, + 58 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.31.0" + }, + "widgets_values": [ + 1111111112, + "fixed", + 30, + 1.7, + "euler", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 1111111112, + "control_after_generate": "fixed", + "steps": 30, + "cfg": 1.7, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 12, + "type": "VAEDecodeAudio", + "pos": [ + 2590, + 380 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 12 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 13 + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 60 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeAudio", + "cnr_id": "comfy-core", + "ver": "0.31.0" + } + }, + { + "id": 38, + "type": "SeedNode", + "pos": [ + 890, + 970 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 54 + } + ], + "outputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "links": [ + 52, + 53 + ] + } + ], + "properties": { + "Node name for S&R": "SeedNode", + "cnr_id": "comfy-core", + "ver": "0.31.0" + }, + "widgets_values": [ + 0, + "randomize" + ], + "widgets_values_named": { + "seed": 0, + "fixed": "randomize" + } + }, + { + "id": 42, + "type": "VAEDecodeAudioTiled", + "pos": [ + 2580, + 500 + ], + "size": [ + 280, + 140 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 58 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 59 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 61 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeAudioTiled", + "cnr_id": "comfy-core", + "ver": "0.31.0" + }, + "widgets_values": [ + 1536, + 64 + ], + "widgets_values_named": { + "tile_size": 1536, + "overlap": 64 + } + }, + { + "id": 43, + "type": "ComfySwitchNode", + "pos": [ + 2600, + 690 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 60 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 61 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 63 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 62 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.31.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + } + ], + "groups": [], + "links": [ + { + "id": 18, + "origin_id": 3, + "origin_slot": 0, + "target_id": 13, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 16, + "origin_id": 13, + "origin_slot": 0, + "target_id": 10, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 20, + "origin_id": 13, + "origin_slot": 1, + "target_id": 15, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 6, + "origin_id": 6, + "origin_slot": 0, + "target_id": 9, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 15, + "origin_id": 13, + "origin_slot": 0, + "target_id": 9, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 8, + "origin_id": 10, + "origin_slot": 0, + "target_id": 9, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 21, + "origin_id": 15, + "origin_slot": 0, + "target_id": 9, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 12, + "origin_id": 9, + "origin_slot": 0, + "target_id": 12, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 13, + "origin_id": 7, + "origin_slot": 0, + "target_id": 12, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 45, + "origin_id": -10, + "origin_slot": 0, + "target_id": 13, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 46, + "origin_id": -10, + "origin_slot": 1, + "target_id": 13, + "target_slot": 2, + "type": "STRING" + }, + { + "id": 47, + "origin_id": -10, + "origin_slot": 2, + "target_id": 13, + "target_slot": 4, + "type": "FLOAT" + }, + { + "id": 52, + "origin_id": 38, + "origin_slot": 0, + "target_id": 13, + "target_slot": 3, + "type": "INT" + }, + { + "id": 53, + "origin_id": 38, + "origin_slot": 0, + "target_id": 9, + "target_slot": 4, + "type": "INT" + }, + { + "id": 54, + "origin_id": -10, + "origin_slot": 3, + "target_id": 38, + "target_slot": 0, + "type": "INT" + }, + { + "id": 55, + "origin_id": -10, + "origin_slot": 4, + "target_id": 6, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 56, + "origin_id": -10, + "origin_slot": 5, + "target_id": 3, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 57, + "origin_id": -10, + "origin_slot": 6, + "target_id": 7, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 58, + "origin_id": 9, + "origin_slot": 0, + "target_id": 42, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 59, + "origin_id": 7, + "origin_slot": 0, + "target_id": 42, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 60, + "origin_id": 12, + "origin_slot": 0, + "target_id": 43, + "target_slot": 0, + "type": "AUDIO" + }, + { + "id": 61, + "origin_id": 42, + "origin_slot": 0, + "target_id": 43, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 62, + "origin_id": 43, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "AUDIO" + }, + { + "id": 63, + "origin_id": -10, + "origin_slot": 7, + "target_id": 43, + "target_slot": 2, + "type": "BOOLEAN" + } + ], + "extra": {}, + "category": "Audio/Music generation", + "description": "Generates complete songs up to five minutes from a music caption and lyrics using MiniMax Music 3." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Text to Music (YuE2).json b/blueprints/Text to Music (YuE2).json new file mode 100644 index 00000000000..ffdda2c4d33 --- /dev/null +++ b/blueprints/Text to Music (YuE2).json @@ -0,0 +1,1630 @@ +{ + "revision": 0, + "last_node_id": "33", + "last_link_id": 0, + "nodes": [ + { + "id": 33, + "type": "59dddde1-2d83-4b6e-9c71-1e5cb2d823d6", + "pos": [ + 1440, + -260 + ], + "size": [ + 390, + 450 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "label": "Style", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "Lyrics", + "name": "value_1", + "type": "STRING", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": null + }, + { + "label": "ABC_planning", + "name": "value_2", + "type": "BOOLEAN", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "name": "max_duration", + "type": "FLOAT", + "widget": { + "name": "max_duration" + }, + "link": null + }, + { + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": null + }, + { + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [] + } + ], + "properties": { + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "8", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + "", + "full", + true, + 120, + 0, + "yue2_3b_int8_convrot.safetensors" + ], + "widgets_values_named": { + "value": "", + "value_1": "", + "mode": "full", + "value_2": true, + "max_duration": 120, + "seed": 0, + "ckpt_name": "yue2_3b_int8_convrot.safetensors" + }, + "title": "Text to Music (YuE2)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "59dddde1-2d83-4b6e-9c71-1e5cb2d823d6", + "version": 1, + "state": { + "lastGroupId": 7, + "lastNodeId": 40, + "lastLinkId": 85, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Music (YuE2)", + "inputNode": { + "id": -10, + "bounding": [ + -1000, + 40, + 128, + 188 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 3600, + -331, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "6c1f1dc8-09bc-416e-93e6-185bdbb48f42", + "name": "value", + "type": "STRING", + "linkIds": [ + 85 + ], + "label": "Style", + "pos": [ + -896, + 64 + ] + }, + { + "id": "54203a99-fe32-452b-8bc6-fa9add883a70", + "name": "value_1", + "type": "STRING", + "linkIds": [ + 84 + ], + "label": "Lyrics", + "pos": [ + -896, + 84 + ] + }, + { + "id": "4a3b1965-73f7-4b51-a8b7-77eb939029c7", + "name": "mode", + "type": "COMBO", + "linkIds": [ + 76, + 77 + ], + "pos": [ + -896, + 104 + ] + }, + { + "id": "20a4c74f-f02d-4601-a674-9cb14dc29d52", + "name": "value_2", + "type": "BOOLEAN", + "linkIds": [ + 78 + ], + "label": "ABC_planning", + "pos": [ + -896, + 124 + ] + }, + { + "id": "d0fcad51-9ad7-4a92-ab62-efffeb4c47a1", + "name": "max_duration", + "type": "FLOAT", + "linkIds": [ + 79 + ], + "pos": [ + -896, + 144 + ] + }, + { + "id": "42d2b2cb-be16-40f7-a6d2-95a45aefcfca", + "name": "seed", + "type": "INT", + "linkIds": [ + 80 + ], + "pos": [ + -896, + 164 + ] + }, + { + "id": "99304313-156e-4357-aab8-34fa0944f94a", + "name": "ckpt_name", + "type": "COMBO", + "linkIds": [ + 81 + ], + "pos": [ + -896, + 184 + ] + } + ], + "outputs": [ + { + "id": "0732a71d-66d1-4d9b-aae6-02256c00fab7", + "name": "AUDIO", + "type": "AUDIO", + "linkIds": [ + 31 + ], + "localized_name": "AUDIO", + "pos": [ + 3624, + -307 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 15, + "type": "CheckpointLoaderSimple", + "pos": [ + -240, + -890 + ], + "size": [ + 420, + 130 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "ckpt_name", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": 81 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 18 + ] + }, + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 38, + 43 + ] + }, + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "models": [ + { + "name": "yue2_3b_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/YuE2/resolve/main/checkpoints/yue2_3b_int8_convrot.safetensors", + "directory": "checkpoints" + } + ] + }, + "widgets_values": [ + "yue2_3b_int8_convrot.safetensors" + ], + "widgets_values_named": { + "ckpt_name": "yue2_3b_int8_convrot.safetensors" + } + }, + { + "id": 24, + "type": "YuE2GenerateABC", + "pos": [ + 570, + -580 + ], + "size": [ + 400, + 450 + ], + "flags": {}, + "order": 8, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 38 + }, + { + "localized_name": "style", + "name": "style", + "type": "STRING", + "widget": { + "name": "style" + }, + "link": 70 + }, + { + "localized_name": "lyrics", + "name": "lyrics", + "type": "STRING", + "widget": { + "name": "lyrics" + }, + "link": 72 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 67 + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": 77 + }, + { + "localized_name": "max_abc_tokens", + "name": "max_abc_tokens", + "type": "INT", + "widget": { + "name": "max_abc_tokens" + }, + "link": null + }, + { + "localized_name": "temperature", + "name": "temperature", + "type": "FLOAT", + "widget": { + "name": "temperature" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "top_p", + "type": "FLOAT", + "widget": { + "name": "top_p" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "top_k", + "type": "INT", + "widget": { + "name": "top_k" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "repetition_penalty" + }, + "link": null + }, + { + "localized_name": "penalty_window", + "name": "penalty_window", + "type": "INT", + "widget": { + "name": "penalty_window" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "abc", + "name": "abc", + "type": "STRING", + "links": [ + 82 + ] + } + ], + "properties": { + "Node name for S&R": "YuE2GenerateABC" + }, + "widgets_values": [ + "", + "", + 429124050625962, + "randomize", + "full", + 8192, + 0.7, + 0.9, + 30, + 1.005, + 100 + ], + "widgets_values_named": { + "style": "", + "lyrics": "", + "seed": 429124050625962, + "control_after_generate": "randomize", + "mode": "full", + "max_abc_tokens": 8192, + "temperature": 0.7, + "top_p": 0.9, + "top_k": 30, + "repetition_penalty": 1.005, + "penalty_window": 100 + } + }, + { + "id": 14, + "type": "PreviewAny", + "pos": [ + 1000, + -580 + ], + "size": [ + 420, + 430 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 61 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 62 + ] + } + ], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 30, + "type": "ComfySwitchNode", + "pos": [ + 1060, + -90 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 83 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 82 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 58 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 61 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 32, + "type": "PrimitiveString", + "pos": [ + 590, + -60 + ], + "size": [ + 370, + 100 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 83 + ] + } + ], + "title": "Text (Empty ABC)", + "properties": { + "Node name for S&R": "PrimitiveString" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + } + }, + { + "id": 25, + "type": "YuE2GenerateMusic", + "pos": [ + 1830, + -530 + ], + "size": [ + 400, + 410 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 43 + }, + { + "localized_name": "style", + "name": "style", + "type": "STRING", + "widget": { + "name": "style" + }, + "link": 71 + }, + { + "localized_name": "lyrics", + "name": "lyrics", + "type": "STRING", + "widget": { + "name": "lyrics" + }, + "link": 73 + }, + { + "localized_name": "abc", + "name": "abc", + "type": "STRING", + "widget": { + "name": "abc" + }, + "link": 62 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 68 + }, + { + "localized_name": "mode", + "name": "mode", + "type": "COMBO", + "widget": { + "name": "mode" + }, + "link": 76 + }, + { + "localized_name": "max_duration", + "name": "max_duration", + "type": "FLOAT", + "widget": { + "name": "max_duration" + }, + "link": 79 + }, + { + "localized_name": "temperature", + "name": "temperature", + "type": "FLOAT", + "widget": { + "name": "temperature" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "top_p", + "type": "FLOAT", + "widget": { + "name": "top_p" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "top_k", + "type": "INT", + "widget": { + "name": "top_k" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "repetition_penalty" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 49, + 50 + ] + }, + { + "localized_name": "seconds", + "name": "seconds", + "type": "FLOAT", + "links": [ + 48, + 51 + ] + } + ], + "properties": { + "Node name for S&R": "YuE2GenerateMusic" + }, + "widgets_values": [ + "", + "", + "", + 530976807293994, + "randomize", + "full", + 360, + 1, + 0.95, + 100, + 1.2 + ], + "widgets_values_named": { + "style": "", + "lyrics": "", + "abc": "", + "seed": 530976807293994, + "control_after_generate": "randomize", + "mode": "full", + "max_duration": 360, + "temperature": 1, + "top_p": 0.95, + "top_k": 100, + "repetition_penalty": 1.2 + } + }, + { + "id": 31, + "type": "PrimitiveBoolean", + "pos": [ + -160, + 230 + ], + "size": [ + 230, + 90 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 78 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 58 + ] + } + ], + "title": "Boolean (ABC planning)", + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + } + }, + { + "id": 18, + "type": "ConditioningZeroOut", + "pos": [ + 2330, + -520 + ], + "size": [ + 230, + 60 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "conditioning", + "name": "conditioning", + "type": "CONDITIONING", + "link": 50 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 26 + ] + } + ], + "properties": { + "Node name for S&R": "ConditioningZeroOut" + } + }, + { + "id": 23, + "type": "PreviewAny", + "pos": [ + 2310, + -400 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 51 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": null + } + ], + "properties": { + "Node name for S&R": "PreviewAny" + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 8, + "type": "KSampler", + "pos": [ + 2860, + -580 + ], + "size": [ + 330, + 290 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 18 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 49 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 26 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 8 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 69 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 9 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler" + }, + "widgets_values": [ + 42, + "fixed", + 32, + 1, + "dpm_2", + "sgm_uniform", + 1 + ], + "widgets_values_named": { + "seed": 42, + "control_after_generate": "fixed", + "steps": 32, + "cfg": 1, + "sampler_name": "dpm_2", + "scheduler": "sgm_uniform", + "denoise": 1 + } + }, + { + "id": 5, + "type": "EmptyYuE2LatentAudio", + "pos": [ + 3070, + -110 + ], + "size": [ + 230, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "seconds", + "name": "seconds", + "type": "FLOAT", + "widget": { + "name": "seconds" + }, + "link": 48 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 8 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyYuE2LatentAudio" + }, + "widgets_values": [ + 120, + 1 + ], + "widgets_values_named": { + "seconds": 120, + "batch_size": 1 + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 9, + "type": "VAEDecodeAudio", + "pos": [ + 3240, + -500 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 9 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 21 + } + ], + "outputs": [ + { + "localized_name": "AUDIO", + "name": "AUDIO", + "type": "AUDIO", + "links": [ + 31 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeAudio" + } + }, + { + "id": 34, + "type": "SeedNode", + "pos": [ + -190, + 470 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 80 + } + ], + "outputs": [ + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "links": [ + 67, + 68, + 69 + ] + } + ], + "properties": { + "Node name for S&R": "SeedNode" + }, + "widgets_values": [ + 0, + "fixed" + ], + "widgets_values_named": { + "seed": 0, + "fixed": "fixed" + } + }, + { + "id": 36, + "type": "PrimitiveStringMultiline", + "pos": [ + -260, + -590 + ], + "size": [ + 480, + 260 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 85 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 70, + 71 + ] + } + ], + "title": "Text (Style)", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 37, + "type": "PrimitiveStringMultiline", + "pos": [ + -250, + -260 + ], + "size": [ + 480, + 260 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 84 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 72, + 73 + ] + } + ], + "title": "Text (Lyrics)", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline" + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + }, + "color": "#232", + "bgcolor": "#353" + } + ], + "groups": [ + { + "id": 3, + "title": "Model", + "bounding": [ + -340, + -980, + 630, + 230 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 1, + "title": "Style & Lyrics", + "bounding": [ + -340, + -720, + 630, + 790 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "ABC planning", + "bounding": [ + 400, + -720, + 1140, + 790 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "ABC planning?", + "bounding": [ + -340, + 120, + 630, + 270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Conditioning", + "bounding": [ + 1570, + -720, + 1160, + 790 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 4, + "title": "Duration", + "bounding": [ + 2820, + -210, + 720, + 280 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Sampling & Decode", + "bounding": [ + 2820, + -720, + 720, + 480 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 38, + "origin_id": 15, + "origin_slot": 1, + "target_id": 24, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 61, + "origin_id": 30, + "origin_slot": 0, + "target_id": 14, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 58, + "origin_id": 31, + "origin_slot": 0, + "target_id": 30, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 43, + "origin_id": 15, + "origin_slot": 1, + "target_id": 25, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 62, + "origin_id": 14, + "origin_slot": 0, + "target_id": 25, + "target_slot": 3, + "type": "STRING" + }, + { + "id": 50, + "origin_id": 25, + "origin_slot": 0, + "target_id": 18, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 51, + "origin_id": 25, + "origin_slot": 1, + "target_id": 23, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 18, + "origin_id": 15, + "origin_slot": 0, + "target_id": 8, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 49, + "origin_id": 25, + "origin_slot": 0, + "target_id": 8, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 26, + "origin_id": 18, + "origin_slot": 0, + "target_id": 8, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 8, + "origin_id": 5, + "origin_slot": 0, + "target_id": 8, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 48, + "origin_id": 25, + "origin_slot": 1, + "target_id": 5, + "target_slot": 0, + "type": "FLOAT" + }, + { + "id": 9, + "origin_id": 8, + "origin_slot": 0, + "target_id": 9, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 21, + "origin_id": 15, + "origin_slot": 2, + "target_id": 9, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 31, + "origin_id": 9, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "AUDIO" + }, + { + "id": 67, + "origin_id": 34, + "origin_slot": 0, + "target_id": 24, + "target_slot": 3, + "type": "INT" + }, + { + "id": 68, + "origin_id": 34, + "origin_slot": 0, + "target_id": 25, + "target_slot": 4, + "type": "INT" + }, + { + "id": 69, + "origin_id": 34, + "origin_slot": 0, + "target_id": 8, + "target_slot": 4, + "type": "INT" + }, + { + "id": 70, + "origin_id": 36, + "origin_slot": 0, + "target_id": 24, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 71, + "origin_id": 36, + "origin_slot": 0, + "target_id": 25, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 72, + "origin_id": 37, + "origin_slot": 0, + "target_id": 24, + "target_slot": 2, + "type": "STRING" + }, + { + "id": 73, + "origin_id": 37, + "origin_slot": 0, + "target_id": 25, + "target_slot": 2, + "type": "STRING" + }, + { + "id": 76, + "origin_id": -10, + "origin_slot": 2, + "target_id": 25, + "target_slot": 5, + "type": "COMBO" + }, + { + "id": 77, + "origin_id": -10, + "origin_slot": 2, + "target_id": 24, + "target_slot": 4, + "type": "COMBO" + }, + { + "id": 78, + "origin_id": -10, + "origin_slot": 3, + "target_id": 31, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 79, + "origin_id": -10, + "origin_slot": 4, + "target_id": 25, + "target_slot": 6, + "type": "FLOAT" + }, + { + "id": 80, + "origin_id": -10, + "origin_slot": 5, + "target_id": 34, + "target_slot": 0, + "type": "INT" + }, + { + "id": 81, + "origin_id": -10, + "origin_slot": 6, + "target_id": 15, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 82, + "origin_id": 24, + "origin_slot": 0, + "target_id": 30, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 83, + "origin_id": 32, + "origin_slot": 0, + "target_id": 30, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 84, + "origin_id": -10, + "origin_slot": 1, + "target_id": 37, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 85, + "origin_id": -10, + "origin_slot": 0, + "target_id": 36, + "target_slot": 0, + "type": "STRING" + } + ], + "extra": {}, + "category": "Audio/Music generation", + "description": "Generates complete songs from a music style and lyrics using YuE2, with symbolic planning for structured melody, harmony, vocals, and accompaniment." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Text to Video (LTX-2.3).json b/blueprints/Text to Video (LTX-2.3).json index f44a216dd3c..3aa39576421 100644 --- a/blueprints/Text to Video (LTX-2.3).json +++ b/blueprints/Text to Video (LTX-2.3).json @@ -4286,7 +4286,7 @@ "extra": { "workflowRendererVersion": "Vue-corrected" }, - "category": "Video generation and editing/Text to video", + "category": "Video generation/Text to video", "description": "Generates video from text prompts using LTX-2.3, Lightricks' video diffusion model." } ] diff --git a/blueprints/Text to Video (LTX-2.5).json b/blueprints/Text to Video (LTX-2.5).json new file mode 100644 index 00000000000..06afde67ead --- /dev/null +++ b/blueprints/Text to Video (LTX-2.5).json @@ -0,0 +1,4132 @@ +{ + "revision": 0, + "last_node_id": "455", + "last_link_id": 0, + "nodes": [ + { + "id": 455, + "type": "06bbe69f-691e-4b8c-928e-51e5663cd7e8", + "pos": [ + 3360, + 7090 + ], + "size": [ + 450, + 570 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "label": "prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "label": "prompt_enhance", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "duration", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "width", + "name": "value_2", + "type": "INT", + "widget": { + "name": "value_2" + }, + "link": null + }, + { + "label": "height", + "name": "value_3", + "type": "INT", + "widget": { + "name": "value_3" + }, + "link": null + }, + { + "label": "seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "label": "frame_rate", + "name": "value_4", + "type": "INT", + "widget": { + "name": "value_4" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "label": "video_vae", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + }, + { + "label": "audio_vae", + "name": "vae_name_1", + "type": "COMBO", + "widget": { + "name": "vae_name_1" + }, + "link": null + }, + { + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": null + }, + { + "label": "upscale_model", + "name": "model_name", + "type": "COMBO", + "widget": { + "name": "model_name" + }, + "link": null + }, + { + "label": "prompt_enhance_model", + "name": "clip_name_1", + "type": "COMBO", + "widget": { + "name": "clip_name_1" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.30.0", + "previewExposures": [] + }, + "widgets_values": [ + "", + false, + 5, + 1280, + 720, + 558811532553686, + 24, + "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "ltx-2.5-video-vae-bf16.safetensors", + "ltx-2.5-audio-vae-bf16.safetensors", + "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "gemma4_e2b_it_int8_convrot.safetensors" + ], + "widgets_values_named": { + "text": "", + "value": false, + "value_1": 5, + "value_2": 1280, + "value_3": 720, + "noise_seed": 558811532553686, + "value_4": 24, + "unet_name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "vae_name": "ltx-2.5-video-vae-bf16.safetensors", + "vae_name_1": "ltx-2.5-audio-vae-bf16.safetensors", + "clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "model_name": "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "clip_name_1": "gemma4_e2b_it_int8_convrot.safetensors" + }, + "title": "Text to Video (LTX-2.5)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "06bbe69f-691e-4b8c-928e-51e5663cd7e8", + "version": 1, + "state": { + "lastGroupId": 27, + "lastNodeId": 455, + "lastLinkId": 793, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Text to Video (LTX-2.5)", + "inputNode": { + "id": -10, + "bounding": [ + -680, + 6330, + 183.814453125, + 308 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 5090, + 6496, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "7253a13d-3c00-45a4-8969-f7dfa62c5ca8", + "name": "text", + "type": "STRING", + "linkIds": [ + 776 + ], + "label": "prompt", + "pos": [ + -520.185546875, + 6354 + ] + }, + { + "id": "9b8015d3-2b9a-49cf-b6b2-1dc859a0f751", + "name": "value", + "type": "BOOLEAN", + "linkIds": [ + 779 + ], + "label": "prompt_enhance", + "pos": [ + -520.185546875, + 6374 + ] + }, + { + "id": "448c6637-320c-492f-ae8a-f7a939d09269", + "name": "value_1", + "type": "INT", + "linkIds": [ + 780 + ], + "label": "duration", + "pos": [ + -520.185546875, + 6394 + ] + }, + { + "id": "9ae6a894-6660-4299-a8b6-f9cb061f2123", + "name": "value_2", + "type": "INT", + "linkIds": [ + 781 + ], + "label": "width", + "pos": [ + -520.185546875, + 6414 + ] + }, + { + "id": "d04267c3-7aa1-41d3-a536-67a21ff463d5", + "name": "value_3", + "type": "INT", + "linkIds": [ + 782 + ], + "label": "height", + "pos": [ + -520.185546875, + 6434 + ] + }, + { + "id": "01bccfdb-c07b-4612-b8c6-b031194c4cde", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 784 + ], + "label": "seed", + "pos": [ + -520.185546875, + 6454 + ] + }, + { + "id": "80c1b08e-1bd5-47f7-b60e-1b13a3df9364", + "name": "value_4", + "type": "INT", + "linkIds": [ + 785 + ], + "label": "frame_rate", + "pos": [ + -520.185546875, + 6474 + ] + }, + { + "id": "4bd8dc21-1af5-4276-ab8e-f5a37a1571d3", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 786 + ], + "pos": [ + -520.185546875, + 6494 + ] + }, + { + "id": "f988b8b8-f7ec-4b4e-836a-cfee48ef8f89", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 787 + ], + "label": "video_vae", + "pos": [ + -520.185546875, + 6514 + ] + }, + { + "id": "debfa220-1506-4c29-b64d-ba0cf22d956c", + "name": "vae_name_1", + "type": "COMBO", + "linkIds": [ + 788 + ], + "label": "audio_vae", + "pos": [ + -520.185546875, + 6534 + ] + }, + { + "id": "6a928b89-8b0b-4ad8-a97b-ee9eb6cd9297", + "name": "clip_name", + "type": "COMBO", + "linkIds": [ + 789 + ], + "pos": [ + -520.185546875, + 6554 + ] + }, + { + "id": "5dff65c1-4a83-4799-b4eb-111a41596160", + "name": "model_name", + "type": "COMBO", + "linkIds": [ + 790 + ], + "label": "upscale_model", + "pos": [ + -520.185546875, + 6574 + ] + }, + { + "id": "e7424918-b86f-42f7-9c82-a784356020e4", + "name": "clip_name_1", + "type": "COMBO", + "linkIds": [ + 791 + ], + "label": "prompt_enhance_model", + "pos": [ + -520.185546875, + 6594 + ] + } + ], + "outputs": [ + { + "id": "904bf2a0-be65-45ed-9aef-13d2e07534de", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 714 + ], + "localized_name": "VIDEO", + "pos": [ + 5114, + 6520 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 413, + "type": "CreateVideo", + "pos": [ + 4730, + 7010 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 711 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 712 + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 713 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 714 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 24, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 24, + "bit_depth": 8, + "color_space": "sRGB" + } + }, + { + "id": 414, + "type": "LTXVConcatAVLatent", + "pos": [ + 3710, + 6760 + ], + "size": [ + 280, + 100 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "link": 769 + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "link": 666 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 707 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConcatAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 415, + "type": "ManualSigmas", + "pos": [ + 3700, + 6930 + ], + "size": [ + 290, + 110 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 761 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "0.85, 0.7250, 0.4219, 0.0" + ], + "widgets_values_named": { + "sigmas": "0.85, 0.7250, 0.4219, 0.0" + } + }, + { + "id": 416, + "type": "LTXVLatentUpsampler", + "pos": [ + 3250, + 6470 + ], + "size": [ + 330, + 120 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 677 + }, + { + "localized_name": "upscale_model", + "name": "upscale_model", + "type": "LATENT_UPSCALE_MODEL", + "link": 678 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 772 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 769 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVLatentUpsampler", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 417, + "type": "LTXVAudioVAEDecode", + "pos": [ + 4750, + 6350 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 694 + }, + { + "label": "Audio VAE", + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 730 + } + ], + "outputs": [ + { + "localized_name": "Audio", + "name": "Audio", + "type": "AUDIO", + "links": [ + 712 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAudioVAEDecode", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 418, + "type": "KSamplerSelect", + "pos": [ + 3710, + 6560 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 705 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.3.75", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "euler_ancestral" + ], + "widgets_values_named": { + "sampler_name": "euler_ancestral" + } + }, + { + "id": 419, + "type": "LTXVDualCFGGuider", + "pos": [ + 3710, + 6310 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 771 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 762 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 763 + }, + { + "localized_name": "video_cfg", + "name": "video_cfg", + "type": "FLOAT", + "widget": { + "name": "video_cfg" + }, + "link": null + }, + { + "localized_name": "audio_cfg", + "name": "audio_cfg", + "type": "FLOAT", + "widget": { + "name": "audio_cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 744 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVDualCFGGuider", + "cnr_id": "comfy-core", + "ver": "0.28.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1, + 1 + ], + "widgets_values_named": { + "video_cfg": 1, + "audio_cfg": 1 + } + }, + { + "id": 420, + "type": "SamplerCustomAdvanced", + "pos": [ + 4090, + 6150 + ], + "size": [ + 230, + 170 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 703 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 744 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 705 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 761 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 707 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 708 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.3.75", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 421, + "type": "RandomNoise", + "pos": [ + 3710, + 6150 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 703 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.3.75", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 42, + "fixed" + ], + "widgets_values_named": { + "noise_seed": 42, + "control_after_generate": "fixed" + } + }, + { + "id": 422, + "type": "LTXVSeparateAVLatent", + "pos": [ + 2870, + 6670 + ], + "size": [ + 250, + 100 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "av_latent", + "name": "av_latent", + "type": "LATENT", + "link": 702 + } + ], + "outputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "links": [ + 677 + ] + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "links": [ + 666 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVSeparateAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 423, + "type": "LTXVSeparateAVLatent", + "pos": [ + 4400, + 6150 + ], + "size": [ + 230, + 100 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "av_latent", + "name": "av_latent", + "type": "LATENT", + "link": 708 + } + ], + "outputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "links": [ + 709 + ] + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "links": [ + 694 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVSeparateAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 424, + "type": "VAEDecodeTiled", + "pos": [ + 4750, + 6050 + ], + "size": [ + 280, + 200 + ], + "flags": {}, + "order": 13, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 709 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 773 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + }, + { + "localized_name": "temporal_size", + "name": "temporal_size", + "type": "INT", + "widget": { + "name": "temporal_size" + }, + "link": null + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 711 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeTiled", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 512, + 64, + 64, + 16 + ], + "widgets_values_named": { + "tile_size": 512, + "overlap": 64, + "temporal_size": 64, + "temporal_overlap": 16 + } + }, + { + "id": 425, + "type": "SamplerCustomAdvanced", + "pos": [ + 2870, + 6300 + ], + "size": [ + 230, + 170 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "noise", + "name": "noise", + "type": "NOISE", + "link": 670 + }, + { + "localized_name": "guider", + "name": "guider", + "type": "GUIDER", + "link": 733 + }, + { + "localized_name": "sampler", + "name": "sampler", + "type": "SAMPLER", + "link": 671 + }, + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "SIGMAS", + "link": 764 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 672 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "LATENT", + "links": [ + 702 + ] + }, + { + "localized_name": "denoised_output", + "name": "denoised_output", + "type": "LATENT", + "links": [] + } + ], + "properties": { + "Node name for S&R": "SamplerCustomAdvanced", + "cnr_id": "comfy-core", + "ver": "0.3.60", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 426, + "type": "ManualSigmas", + "pos": [ + 2190, + 6910 + ], + "size": [ + 500, + 100 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "sigmas", + "name": "sigmas", + "type": "STRING", + "widget": { + "name": "sigmas" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SIGMAS", + "name": "SIGMAS", + "type": "SIGMAS", + "links": [ + 764 + ] + } + ], + "properties": { + "Node name for S&R": "ManualSigmas", + "cnr_id": "comfy-core", + "ver": "0.14.1" + }, + "widgets_values": [ + "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0" + ], + "widgets_values_named": { + "sigmas": "1.0, 0.99375, 0.9875, 0.98125, 0.975, 0.909375, 0.725, 0.421875, 0.0" + } + }, + { + "id": 427, + "type": "LTXVDualCFGGuider", + "pos": [ + 2190, + 6490 + ], + "size": [ + 270, + 160 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 770 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 735 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 736 + }, + { + "localized_name": "video_cfg", + "name": "video_cfg", + "type": "FLOAT", + "widget": { + "name": "video_cfg" + }, + "link": null + }, + { + "localized_name": "audio_cfg", + "name": "audio_cfg", + "type": "FLOAT", + "widget": { + "name": "audio_cfg" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "GUIDER", + "name": "GUIDER", + "type": "GUIDER", + "links": [ + 733 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVDualCFGGuider", + "cnr_id": "comfy-core", + "ver": "0.28.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + 1, + 1 + ], + "widgets_values_named": { + "video_cfg": 1, + "audio_cfg": 1 + } + }, + { + "id": 428, + "type": "KSamplerSelect", + "pos": [ + 2190, + 6730 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "SAMPLER", + "name": "SAMPLER", + "type": "SAMPLER", + "links": [ + 671 + ] + } + ], + "properties": { + "Node name for S&R": "KSamplerSelect", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "euler_ancestral" + ], + "widgets_values_named": { + "sampler_name": "euler_ancestral" + } + }, + { + "id": 429, + "type": "RandomNoise", + "pos": [ + 2190, + 6300 + ], + "size": [ + 280, + 110 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "noise_seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": 784 + } + ], + "outputs": [ + { + "localized_name": "NOISE", + "name": "NOISE", + "type": "NOISE", + "links": [ + 670 + ] + } + ], + "properties": { + "Node name for S&R": "RandomNoise", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 926314962774309, + "randomize" + ], + "widgets_values_named": { + "noise_seed": 926314962774309, + "control_after_generate": "randomize" + } + }, + { + "id": 430, + "type": "LTXVConditioning", + "pos": [ + 1780, + 6070 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 696 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 697 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT", + "widget": { + "name": "frame_rate" + }, + "link": 698 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 735, + 762 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 736, + 763 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConditioning", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 24 + ], + "widgets_values_named": { + "frame_rate": 24 + } + }, + { + "id": 431, + "type": "LTXVConcatAVLatent", + "pos": [ + 1630, + 7490 + ], + "size": [ + 240, + 100 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "link": 765 + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "link": 700 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 672 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConcatAVLatent", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + } + }, + { + "id": 432, + "type": "CLIPTextEncode", + "pos": [ + 1160, + 6040 + ], + "size": [ + 550, + 740 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 732 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 721 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 696 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 433, + "type": "CLIPTextEncode", + "pos": [ + 1170, + 6880 + ], + "size": [ + 530, + 240 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 731 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 697 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "pc game, console game, video game, cartoon, childish, ugly" + ], + "widgets_values_named": { + "text": "pc game, console game, video game, cartoon, childish, ugly" + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 434, + "type": "EmptyLTXVLatentVideo", + "pos": [ + 1200, + 7360 + ], + "size": [ + 280, + 200 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 689 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 690 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 718 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 765 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLTXVLatentVideo", + "cnr_id": "comfy-core", + "ver": "0.3.60", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 768, + 512, + 97, + 1 + ], + "widgets_values_named": { + "width": 768, + "height": 512, + "length": 97, + "batch_size": 1 + } + }, + { + "id": 435, + "type": "LTXVEmptyLatentAudio", + "pos": [ + 1190, + 7660 + ], + "size": [ + 280, + 170 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 729 + }, + { + "localized_name": "frames_number", + "name": "frames_number", + "type": "INT", + "widget": { + "name": "frames_number" + }, + "link": 717 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT,INT", + "widget": { + "name": "frame_rate" + }, + "link": 701 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "Latent", + "name": "Latent", + "type": "LATENT", + "links": [ + 700 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVEmptyLatentAudio", + "cnr_id": "comfy-core", + "ver": "0.3.68", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 97, + 25, + 1 + ], + "widgets_values_named": { + "frames_number": 97, + "frame_rate": 25, + "batch_size": 1 + } + }, + { + "id": 436, + "type": "PreviewAny", + "pos": [ + 1410, + 5300 + ], + "size": [ + 510, + 360 + ], + "flags": {}, + "order": 23, + "mode": 0, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 725 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 437, + "type": "ComfySwitchNode", + "pos": [ + 1100, + 5690 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 24, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 720 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 724 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 722 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 721, + 725 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 438, + "type": "LatentUpscaleModelLoader", + "pos": [ + 630, + 6980 + ], + "size": [ + 450, + 70 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "model_name", + "name": "model_name", + "type": "COMBO", + "widget": { + "name": "model_name" + }, + "link": 790 + } + ], + "outputs": [ + { + "localized_name": "LATENT_UPSCALE_MODEL", + "name": "LATENT_UPSCALE_MODEL", + "type": "LATENT_UPSCALE_MODEL", + "links": [ + 678 + ] + } + ], + "properties": { + "Node name for S&R": "LatentUpscaleModelLoader", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "models": [ + { + "name": "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/latent_upscale_models/ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors", + "directory": "latent_upscale_models" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors" + ], + "widgets_values_named": { + "model_name": "ltx-2.5-latent-spatial-upscaler-x2-bf16-1.0.safetensors" + } + }, + { + "id": 439, + "type": "UNETLoader", + "pos": [ + 640, + 6190 + ], + "size": [ + 450, + 120 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 786 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 770, + 771 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/diffusion_models/ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "ltx-2.5-22b-distilled-transformer-comfy-int8-convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 440, + "type": "VAELoader", + "pos": [ + 640, + 6420 + ], + "size": [ + 450, + 70 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 787 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 772, + 773 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-video-vae-bf16.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-video-vae-bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ltx-2.5-video-vae-bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "ltx-2.5-video-vae-bf16.safetensors" + } + }, + { + "id": 441, + "type": "VAELoader", + "pos": [ + 640, + 6590 + ], + "size": [ + 450, + 90 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 788 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 729, + 730 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "ltx-2.5-audio-vae-bf16.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/vae/ltx-2.5-audio-vae-bf16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "ltx-2.5-audio-vae-bf16.safetensors" + ], + "widgets_values_named": { + "vae_name": "ltx-2.5-audio-vae-bf16.safetensors" + } + }, + { + "id": 442, + "type": "CLIPLoader", + "pos": [ + 640, + 6770 + ], + "size": [ + 450, + 120 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 789 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 731, + 732 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.5/resolve/main/text_encoders/gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "ltxv", + "default" + ], + "widgets_values_named": { + "clip_name": "gemma4-12b-with-proj-ltx-2.5-comfy-int8-convrot.safetensors", + "type": "ltxv", + "device": "default" + } + }, + { + "id": 443, + "type": "TextGenerateLTX2Prompt", + "pos": [ + 570, + 5280 + ], + "size": [ + 400, + 420 + ], + "flags": { + "collapsed": false + }, + "order": 30, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 749 + }, + { + "localized_name": "image", + "name": "image", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 723 + }, + { + "localized_name": "max_length", + "name": "max_length", + "type": "INT", + "widget": { + "name": "max_length" + }, + "link": null + }, + { + "localized_name": "Sampling Mode", + "name": "sampling_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "sampling_mode" + }, + "link": null + }, + { + "localized_name": "temperature", + "name": "sampling_mode.temperature", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.temperature" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "sampling_mode.top_k", + "type": "INT", + "widget": { + "name": "sampling_mode.top_k" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "sampling_mode.top_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.top_p" + }, + "link": null + }, + { + "localized_name": "min_p", + "name": "sampling_mode.min_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.min_p" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "sampling_mode.repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.repetition_penalty" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "sampling_mode.seed", + "type": "INT", + "widget": { + "name": "sampling_mode.seed" + }, + "link": null + }, + { + "localized_name": "presence_penalty", + "name": "sampling_mode.presence_penalty", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "sampling_mode.presence_penalty" + }, + "link": null + }, + { + "localized_name": "thinking", + "name": "thinking", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "thinking" + }, + "link": null + }, + { + "localized_name": "use_default_template", + "name": "use_default_template", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "use_default_template" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "generated_text", + "name": "generated_text", + "type": "STRING", + "links": [ + 724 + ] + } + ], + "properties": { + "Node name for S&R": "TextGenerateLTX2Prompt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "", + 600, + "on", + 0.7, + 64, + 0.95, + 0.05, + 1.15, + 0, + 0, + false, + true + ], + "widgets_values_named": { + "prompt": "", + "max_length": 600, + "sampling_mode": "on", + "sampling_mode.temperature": 0.7, + "sampling_mode.top_k": 64, + "sampling_mode.top_p": 0.95, + "sampling_mode.min_p": 0.05, + "sampling_mode.repetition_penalty": 1.15, + "sampling_mode.seed": 0, + "sampling_mode.presence_penalty": 0, + "thinking": false, + "use_default_template": true + } + }, + { + "id": 444, + "type": "ComfyMathExpression", + "pos": [ + 670, + 7770 + ], + "size": [ + 230, + 100 + ], + "flags": { + "collapsed": true + }, + "order": 31, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 715 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": 716 + }, + { + "localized_name": "c", + "name": "values.c", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 717, + 718 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "title": "Math Expression (length)", + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.18.1", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "input_ue_unconnectable": {}, + "version": "7.7" + } + }, + "widgets_values": [ + "a * b + 1" + ], + "widgets_values_named": { + "expression": "a * b + 1" + } + }, + { + "id": 445, + "type": "ComfyMathExpression", + "pos": [ + 660, + 7470 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 32, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 687 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 689 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "a/2" + ], + "widgets_values_named": { + "expression": "a/2" + } + }, + { + "id": 446, + "type": "ComfyMathExpression", + "pos": [ + 660, + 7530 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 33, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 688 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 690 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "a/2" + ], + "widgets_values_named": { + "expression": "a/2" + } + }, + { + "id": 447, + "type": "ComfyMathExpression", + "pos": [ + 660, + 7670 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 34, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 695 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 698, + 713 + ] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 701 + ] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": [] + } + ], + "title": "Math Expression (fps)", + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "a" + ], + "widgets_values_named": { + "expression": "a" + } + }, + { + "id": 448, + "type": "PrimitiveInt", + "pos": [ + 150, + 6880 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 35, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 782 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 688 + ] + } + ], + "title": "Height", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 720, + "fixed" + ], + "widgets_values_named": { + "value": 720, + "fixed": "fixed" + } + }, + { + "id": 449, + "type": "PrimitiveInt", + "pos": [ + 150, + 7070 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 36, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 785 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 695, + 716 + ] + } + ], + "title": "Frame Rate", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 24, + "fixed" + ], + "widgets_values_named": { + "value": 24, + "fixed": "fixed" + } + }, + { + "id": 450, + "type": "PrimitiveInt", + "pos": [ + 150, + 6510 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 37, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 780 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 715 + ] + } + ], + "title": "Duration", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 5, + "fixed" + ], + "widgets_values_named": { + "value": 5, + "fixed": "fixed" + } + }, + { + "id": 451, + "type": "PrimitiveInt", + "pos": [ + 150, + 6690 + ], + "size": [ + 370, + 110 + ], + "flags": {}, + "order": 38, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 781 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 687 + ] + } + ], + "title": "Width", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + 1280, + "fixed" + ], + "widgets_values_named": { + "value": 1280, + "fixed": "fixed" + } + }, + { + "id": 452, + "type": "PrimitiveStringMultiline", + "pos": [ + 150, + 6080 + ], + "size": [ + 370, + 350 + ], + "flags": {}, + "order": 39, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "STRING", + "widget": { + "name": "value" + }, + "link": 776 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 720, + 723 + ] + } + ], + "title": "Prompt", + "properties": { + "Node name for S&R": "PrimitiveStringMultiline", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65, + "ue_properties": { + "widget_ue_connectable": {}, + "version": "7.7", + "input_ue_unconnectable": {} + } + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "value": "" + } + }, + { + "id": 453, + "type": "CLIPLoader", + "pos": [ + 120, + 5290 + ], + "size": [ + 370, + 120 + ], + "flags": {}, + "order": 40, + "mode": 0, + "inputs": [ + { + "localized_name": "clip_name", + "name": "clip_name", + "type": "COMBO", + "widget": { + "name": "clip_name" + }, + "link": 791 + }, + { + "localized_name": "type", + "name": "type", + "type": "COMBO", + "widget": { + "name": "type" + }, + "link": null + }, + { + "localized_name": "device", + "name": "device", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 749 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.29.0", + "models": [ + { + "name": "gemma4_e2b_it_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/gemma-4/resolve/main/text_encoders/gemma4_e2b_it_int8_convrot.safetensors", + "directory": "text_encoders" + } + ] + }, + "widgets_values": [ + "gemma4_e2b_it_int8_convrot.safetensors", + "ltxv", + "default" + ], + "widgets_values_named": { + "clip_name": "gemma4_e2b_it_int8_convrot.safetensors", + "type": "ltxv", + "device": "default" + } + }, + { + "id": 454, + "type": "PrimitiveBoolean", + "pos": [ + 140, + 5760 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 41, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 779 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 722 + ] + } + ], + "title": "Boolean (Enable Prompt Enhance)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + } + ], + "groups": [ + { + "id": 6, + "title": "Generate High Resolution", + "bounding": [ + 3680, + 5950, + 960, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Latent Upscale", + "bounding": [ + 3190, + 5950, + 460, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Generate Low Resolution", + "bounding": [ + 2140, + 5950, + 1020, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 3, + "title": "Prompt", + "bounding": [ + 1130, + 5950, + 980, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 21, + "title": "Empty Latent", + "bounding": [ + 1130, + 7250, + 980, + 610 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 1, + "title": "Model", + "bounding": [ + 620, + 5950, + 480, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 22, + "title": "Number conversion", + "bounding": [ + 620, + 7250, + 480, + 610 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 19, + "title": "Video Settings", + "bounding": [ + 100, + 5950, + 490, + 1270 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 27, + "title": "Prompt Enhancement", + "bounding": [ + 100, + 5200, + 2010, + 720 + ], + "flags": {} + } + ], + "links": [ + { + "id": 711, + "origin_id": 424, + "origin_slot": 0, + "target_id": 413, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 712, + "origin_id": 417, + "origin_slot": 0, + "target_id": 413, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 713, + "origin_id": 447, + "origin_slot": 0, + "target_id": 413, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 769, + "origin_id": 416, + "origin_slot": 0, + "target_id": 414, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 666, + "origin_id": 422, + "origin_slot": 1, + "target_id": 414, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 677, + "origin_id": 422, + "origin_slot": 0, + "target_id": 416, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 678, + "origin_id": 438, + "origin_slot": 0, + "target_id": 416, + "target_slot": 1, + "type": "LATENT_UPSCALE_MODEL" + }, + { + "id": 772, + "origin_id": 440, + "origin_slot": 0, + "target_id": 416, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 694, + "origin_id": 423, + "origin_slot": 1, + "target_id": 417, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 730, + "origin_id": 441, + "origin_slot": 0, + "target_id": 417, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 771, + "origin_id": 439, + "origin_slot": 0, + "target_id": 419, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 762, + "origin_id": 430, + "origin_slot": 0, + "target_id": 419, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 763, + "origin_id": 430, + "origin_slot": 1, + "target_id": 419, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 703, + "origin_id": 421, + "origin_slot": 0, + "target_id": 420, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 744, + "origin_id": 419, + "origin_slot": 0, + "target_id": 420, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 705, + "origin_id": 418, + "origin_slot": 0, + "target_id": 420, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 761, + "origin_id": 415, + "origin_slot": 0, + "target_id": 420, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 707, + "origin_id": 414, + "origin_slot": 0, + "target_id": 420, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 702, + "origin_id": 425, + "origin_slot": 0, + "target_id": 422, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 708, + "origin_id": 420, + "origin_slot": 0, + "target_id": 423, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 709, + "origin_id": 423, + "origin_slot": 0, + "target_id": 424, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 773, + "origin_id": 440, + "origin_slot": 0, + "target_id": 424, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 670, + "origin_id": 429, + "origin_slot": 0, + "target_id": 425, + "target_slot": 0, + "type": "NOISE" + }, + { + "id": 733, + "origin_id": 427, + "origin_slot": 0, + "target_id": 425, + "target_slot": 1, + "type": "GUIDER" + }, + { + "id": 671, + "origin_id": 428, + "origin_slot": 0, + "target_id": 425, + "target_slot": 2, + "type": "SAMPLER" + }, + { + "id": 764, + "origin_id": 426, + "origin_slot": 0, + "target_id": 425, + "target_slot": 3, + "type": "SIGMAS" + }, + { + "id": 672, + "origin_id": 431, + "origin_slot": 0, + "target_id": 425, + "target_slot": 4, + "type": "LATENT" + }, + { + "id": 770, + "origin_id": 439, + "origin_slot": 0, + "target_id": 427, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 735, + "origin_id": 430, + "origin_slot": 0, + "target_id": 427, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 736, + "origin_id": 430, + "origin_slot": 1, + "target_id": 427, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 696, + "origin_id": 432, + "origin_slot": 0, + "target_id": 430, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 697, + "origin_id": 433, + "origin_slot": 0, + "target_id": 430, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 698, + "origin_id": 447, + "origin_slot": 0, + "target_id": 430, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 765, + "origin_id": 434, + "origin_slot": 0, + "target_id": 431, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 700, + "origin_id": 435, + "origin_slot": 0, + "target_id": 431, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 732, + "origin_id": 442, + "origin_slot": 0, + "target_id": 432, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 721, + "origin_id": 437, + "origin_slot": 0, + "target_id": 432, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 731, + "origin_id": 442, + "origin_slot": 0, + "target_id": 433, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 689, + "origin_id": 445, + "origin_slot": 1, + "target_id": 434, + "target_slot": 0, + "type": "INT" + }, + { + "id": 690, + "origin_id": 446, + "origin_slot": 1, + "target_id": 434, + "target_slot": 1, + "type": "INT" + }, + { + "id": 718, + "origin_id": 444, + "origin_slot": 1, + "target_id": 434, + "target_slot": 2, + "type": "INT" + }, + { + "id": 729, + "origin_id": 441, + "origin_slot": 0, + "target_id": 435, + "target_slot": 0, + "type": "VAE" + }, + { + "id": 717, + "origin_id": 444, + "origin_slot": 1, + "target_id": 435, + "target_slot": 1, + "type": "INT" + }, + { + "id": 701, + "origin_id": 447, + "origin_slot": 1, + "target_id": 435, + "target_slot": 2, + "type": "INT" + }, + { + "id": 725, + "origin_id": 437, + "origin_slot": 0, + "target_id": 436, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 720, + "origin_id": 452, + "origin_slot": 0, + "target_id": 437, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 724, + "origin_id": 443, + "origin_slot": 0, + "target_id": 437, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 722, + "origin_id": 454, + "origin_slot": 0, + "target_id": 437, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 749, + "origin_id": 453, + "origin_slot": 0, + "target_id": 443, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 723, + "origin_id": 452, + "origin_slot": 0, + "target_id": 443, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 715, + "origin_id": 450, + "origin_slot": 0, + "target_id": 444, + "target_slot": 0, + "type": "INT" + }, + { + "id": 716, + "origin_id": 449, + "origin_slot": 0, + "target_id": 444, + "target_slot": 1, + "type": "INT" + }, + { + "id": 687, + "origin_id": 451, + "origin_slot": 0, + "target_id": 445, + "target_slot": 0, + "type": "INT" + }, + { + "id": 688, + "origin_id": 448, + "origin_slot": 0, + "target_id": 446, + "target_slot": 0, + "type": "INT" + }, + { + "id": 695, + "origin_id": 449, + "origin_slot": 0, + "target_id": 447, + "target_slot": 0, + "type": "INT" + }, + { + "id": 714, + "origin_id": 413, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 776, + "origin_id": -10, + "origin_slot": 0, + "target_id": 452, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 779, + "origin_id": -10, + "origin_slot": 1, + "target_id": 454, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 780, + "origin_id": -10, + "origin_slot": 2, + "target_id": 450, + "target_slot": 0, + "type": "INT" + }, + { + "id": 781, + "origin_id": -10, + "origin_slot": 3, + "target_id": 451, + "target_slot": 0, + "type": "INT" + }, + { + "id": 782, + "origin_id": -10, + "origin_slot": 4, + "target_id": 448, + "target_slot": 0, + "type": "INT" + }, + { + "id": 784, + "origin_id": -10, + "origin_slot": 5, + "target_id": 429, + "target_slot": 0, + "type": "INT" + }, + { + "id": 785, + "origin_id": -10, + "origin_slot": 6, + "target_id": 449, + "target_slot": 0, + "type": "INT" + }, + { + "id": 786, + "origin_id": -10, + "origin_slot": 7, + "target_id": 439, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 787, + "origin_id": -10, + "origin_slot": 8, + "target_id": 440, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 788, + "origin_id": -10, + "origin_slot": 9, + "target_id": 441, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 789, + "origin_id": -10, + "origin_slot": 10, + "target_id": 442, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 790, + "origin_id": -10, + "origin_slot": 11, + "target_id": 438, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 791, + "origin_id": -10, + "origin_slot": 12, + "target_id": 453, + "target_slot": 0, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Video generation/Text to video", + "description": "Generates video from text prompts using LTX-2.5, with optional prompt enhancement, spatial upscaling, and synchronized audio." + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Text to Video (Wan 2.2).json b/blueprints/Text to Video (Wan 2.2).json index a264a490ddc..cefbe1d1d77 100644 --- a/blueprints/Text to Video (Wan 2.2).json +++ b/blueprints/Text to Video (Wan 2.2).json @@ -1572,7 +1572,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Text to video", + "category": "Video generation/Text to video", "description": "Generates video from text prompts using Wan2.2, Alibaba's diffusion video model." } ] diff --git a/blueprints/Upscale Video (SeedVR2 3B Int8).json b/blueprints/Upscale Video (SeedVR2 3B Int8).json new file mode 100644 index 00000000000..c5e3d21ad57 --- /dev/null +++ b/blueprints/Upscale Video (SeedVR2 3B Int8).json @@ -0,0 +1,2045 @@ +{ + "revision": 0, + "last_node_id": "125", + "last_link_id": 0, + "nodes": [ + { + "id": 125, + "type": "c076cb36-3581-4288-b284-6681dd511fa8", + "pos": [ + -1700, + 6900 + ], + "size": [ + 450, + 550 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "video", + "type": "VIDEO", + "link": null + }, + { + "label": "trim_video", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": null + }, + { + "label": "trim_start_time", + "name": "start_time", + "type": "FLOAT", + "widget": { + "name": "start_time" + }, + "link": null + }, + { + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": null + }, + { + "label": "scale_multiplier", + "name": "resize_type.multiplier_1", + "type": "FLOAT", + "widget": { + "name": "resize_type.multiplier_1" + }, + "link": null + }, + { + "label": "seed", + "name": "seed_1", + "type": "INT", + "widget": { + "name": "seed_1" + }, + "link": null + }, + { + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + }, + { + "name": "color_correction_method", + "type": "COMBO", + "widget": { + "name": "color_correction_method" + }, + "link": null + }, + { + "label": "split_latent", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "split_latent_temporal_overlap", + "name": "temporal_overlap_1", + "type": "INT", + "widget": { + "name": "temporal_overlap_1" + }, + "link": null + }, + { + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": null + }, + { + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": null + } + ], + "outputs": [ + { + "name": "VIDEO", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.27.0", + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "109", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + false, + 0, + 0, + 2, + 959948902156062, + "euler", + "simple", + 1, + "none", + false, + 0, + "seedvr2_3b_int8_convrot.safetensors", + "seedvr2_ema_vae_fp16.safetensors" + ], + "widgets_values_named": { + "switch": false, + "start_time": 0, + "duration": 0, + "resize_type.multiplier_1": 2, + "seed_1": 959948902156062, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1, + "color_correction_method": "none", + "value": false, + "temporal_overlap_1": 0, + "unet_name": "seedvr2_3b_int8_convrot.safetensors", + "vae_name": "seedvr2_ema_vae_fp16.safetensors" + }, + "title": "Upscale Video (SeedVR2 3B Int8)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "c076cb36-3581-4288-b284-6681dd511fa8", + "version": 1, + "state": { + "lastGroupId": 10, + "lastNodeId": 125, + "lastLinkId": 160, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Upscale Video (SeedVR2 3B Int8)", + "description": "Upscales and restores video using ByteDance SeedVR2 3B INT8, a one-step diffusion restoration model.", + "inputNode": { + "id": -10, + "bounding": [ + -580, + 10, + 211.892578125, + 328 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 3780, + 690, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "5e95a1d1-5ac1-4b13-931a-47efabf571b4", + "name": "video", + "type": "VIDEO", + "linkIds": [ + 74, + 78 + ], + "pos": [ + -392.107421875, + 34 + ] + }, + { + "id": "eaa3af73-94bf-4562-8005-5f5707aff280", + "name": "switch", + "type": "BOOLEAN", + "linkIds": [ + 80 + ], + "label": "trim_video", + "pos": [ + -392.107421875, + 54 + ] + }, + { + "id": "f95222d2-ca24-4491-818a-f1e696c60b9f", + "name": "start_time", + "type": "FLOAT", + "linkIds": [ + 81 + ], + "label": "trim_start_time", + "pos": [ + -392.107421875, + 74 + ] + }, + { + "id": "0c59c09a-9892-4bff-afd8-b3d952648e6c", + "name": "duration", + "type": "FLOAT", + "linkIds": [ + 82 + ], + "pos": [ + -392.107421875, + 94 + ] + }, + { + "id": "f03a6fda-1ed0-49b0-a120-15259bff282a", + "name": "resize_type.multiplier_1", + "type": "FLOAT", + "linkIds": [ + 86 + ], + "label": "scale_multiplier", + "pos": [ + -392.107421875, + 114 + ] + }, + { + "id": "ce726ff4-eb4c-4e59-bec6-93123fa98cfd", + "name": "seed_1", + "type": "INT", + "linkIds": [ + 87 + ], + "label": "seed", + "pos": [ + -392.107421875, + 134 + ] + }, + { + "id": "d38e6d58-98fb-4e28-837f-2cf7c26c1799", + "name": "sampler_name", + "type": "COMBO", + "linkIds": [ + 90 + ], + "pos": [ + -392.107421875, + 154 + ] + }, + { + "id": "311d82f7-f45e-47a9-89c9-d78cc11db0e0", + "name": "scheduler", + "type": "COMBO", + "linkIds": [ + 91 + ], + "pos": [ + -392.107421875, + 174 + ] + }, + { + "id": "986cd06a-91a7-45c9-98ea-f6645793803b", + "name": "denoise", + "type": "FLOAT", + "linkIds": [ + 92 + ], + "pos": [ + -392.107421875, + 194 + ] + }, + { + "id": "186d5c7f-fee6-409d-a3f5-c96a372e2396", + "name": "color_correction_method", + "type": "COMBO", + "linkIds": [ + 108 + ], + "pos": [ + -392.107421875, + 214 + ] + }, + { + "id": "9219895e-b7a8-4020-9af0-c05a31cb908c", + "name": "value", + "type": "BOOLEAN", + "linkIds": [ + 123 + ], + "label": "split_latent", + "pos": [ + -392.107421875, + 234 + ] + }, + { + "id": "0e327582-5105-4409-897b-ca7587dee2a9", + "name": "temporal_overlap_1", + "type": "INT", + "linkIds": [ + 124 + ], + "label": "split_latent_temporal_overlap", + "pos": [ + -392.107421875, + 254 + ] + }, + { + "id": "94270bac-893b-4eb2-8011-e607bc77bbfb", + "name": "unet_name", + "type": "COMBO", + "linkIds": [ + 125 + ], + "pos": [ + -392.107421875, + 274 + ] + }, + { + "id": "37064158-ca0c-4a21-b14e-ad7743755d10", + "name": "vae_name", + "type": "COMBO", + "linkIds": [ + 126 + ], + "pos": [ + -392.107421875, + 294 + ] + } + ], + "outputs": [ + { + "id": "af922150-c9db-45c2-8774-113369ce3254", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 71 + ], + "pos": [ + 3804, + 714 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 106, + "type": "VAEEncodeTiled", + "pos": [ + 1010, + -70 + ], + "size": [ + 270, + 230 + ], + "flags": {}, + "order": 0, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "pixels", + "name": "pixels", + "type": "IMAGE", + "link": 16 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 17 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + }, + { + "localized_name": "temporal_size", + "name": "temporal_size", + "type": "INT", + "widget": { + "name": "temporal_size" + }, + "link": null + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 99, + 112 + ] + } + ], + "properties": { + "Node name for S&R": "VAEEncodeTiled", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 512, + 128, + 64, + 8 + ], + "widgets_values_named": { + "tile_size": 512, + "overlap": 128, + "temporal_size": 64, + "temporal_overlap": 8 + } + }, + { + "id": 107, + "type": "VAELoader", + "pos": [ + 620, + 80 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "vae_name", + "name": "vae_name", + "type": "COMBO", + "widget": { + "name": "vae_name" + }, + "link": 126 + } + ], + "outputs": [ + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 17, + 22 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.27.0", + "models": [ + { + "name": "seedvr2_ema_vae_fp16.safetensors", + "url": "https://huggingface.co/Comfy-Org/SeedVR2/resolve/main/vae/seedvr2_ema_vae_fp16.safetensors", + "directory": "vae" + } + ] + }, + "widgets_values": [ + "seedvr2_ema_vae_fp16.safetensors" + ], + "widgets_values_named": { + "vae_name": "seedvr2_ema_vae_fp16.safetensors" + } + }, + { + "id": 108, + "type": "UNETLoader", + "pos": [ + 620, + -80 + ], + "size": [ + 270, + 110 + ], + "flags": {}, + "order": 2, + "mode": 0, + "showAdvanced": false, + "inputs": [ + { + "localized_name": "unet_name", + "name": "unet_name", + "type": "COMBO", + "widget": { + "name": "unet_name" + }, + "link": 125 + }, + { + "localized_name": "weight_dtype", + "name": "weight_dtype", + "type": "COMBO", + "widget": { + "name": "weight_dtype" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 27, + 31 + ] + } + ], + "properties": { + "Node name for S&R": "UNETLoader", + "cnr_id": "comfy-core", + "ver": "0.27.0", + "models": [ + { + "name": "seedvr2_3b_int8_convrot.safetensors", + "url": "https://huggingface.co/Comfy-Org/SeedVR2/resolve/main/diffusion_models/seedvr2_3b_int8_convrot.safetensors", + "directory": "diffusion_models" + } + ] + }, + "widgets_values": [ + "seedvr2_3b_int8_convrot.safetensors", + "default" + ], + "widgets_values_named": { + "unet_name": "seedvr2_3b_int8_convrot.safetensors", + "weight_dtype": "default" + } + }, + { + "id": 109, + "type": "KSampler", + "pos": [ + 1780, + -140 + ], + "size": [ + 270, + 340 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 31 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 29 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 30 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 115 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 87 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": 90 + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": 91 + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": 92 + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 101, + 116 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 959948902156062, + "fixed", + 1, + 1, + "euler", + "simple", + 1 + ], + "widgets_values_named": { + "seed": 959948902156062, + "control_after_generate": "fixed", + "steps": 1, + "cfg": 1, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1 + } + }, + { + "id": 110, + "type": "VAEDecodeTiled", + "pos": [ + 2600, + 20 + ], + "size": [ + 270, + 230 + ], + "flags": {}, + "order": 4, + "mode": 0, + "showAdvanced": true, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 117 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 22 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + }, + { + "localized_name": "temporal_size", + "name": "temporal_size", + "type": "INT", + "widget": { + "name": "temporal_size" + }, + "link": null + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 25 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeTiled", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + 512, + 128, + 64, + 8 + ], + "widgets_values_named": { + "tile_size": 512, + "overlap": 128, + "temporal_size": 64, + "temporal_overlap": 8 + } + }, + { + "id": 111, + "type": "ResizeImageMaskNode", + "pos": [ + 610, + 370 + ], + "size": [ + 300, + 110 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": 66 + }, + { + "localized_name": "resize_type", + "name": "resize_type", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "resize_type" + }, + "link": null + }, + { + "localized_name": "multiplier", + "name": "resize_type.multiplier", + "type": "FLOAT", + "widget": { + "name": "resize_type.multiplier" + }, + "link": 86 + }, + { + "localized_name": "scale_method", + "name": "scale_method", + "type": "COMBO", + "widget": { + "name": "scale_method" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "resized", + "name": "resized", + "type": "*", + "links": [ + 24, + 26 + ] + } + ], + "properties": { + "Node name for S&R": "ResizeImageMaskNode", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + "scale by multiplier", + 2, + "lanczos" + ], + "widgets_values_named": { + "resize_type": "scale by multiplier", + "resize_type.multiplier": 2, + "scale_method": "lanczos" + } + }, + { + "id": 112, + "type": "SeedVR2Preprocess", + "pos": [ + 620, + 250 + ], + "size": [ + 270, + 60 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "resized_images", + "name": "resized_images", + "type": "IMAGE", + "link": 24 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "SeedVR2Preprocess", + "cnr_id": "comfy-core", + "ver": "0.27.0" + } + }, + { + "id": 113, + "type": "SeedVR2PostProcessing", + "pos": [ + 2600, + 350 + ], + "size": [ + 310, + 110 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 25 + }, + { + "localized_name": "original_resized_images", + "name": "original_resized_images", + "type": "IMAGE", + "link": 26 + }, + { + "localized_name": "color_correction_method", + "name": "color_correction_method", + "type": "COMBO", + "widget": { + "name": "color_correction_method" + }, + "link": 108 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [ + 67 + ] + } + ], + "properties": { + "Node name for S&R": "SeedVR2PostProcessing", + "cnr_id": "comfy-core", + "ver": "0.27.0" + }, + "widgets_values": [ + "none" + ], + "widgets_values_named": { + "color_correction_method": "none" + } + }, + { + "id": 114, + "type": "SeedVR2Conditioning", + "pos": [ + 1420, + -140 + ], + "size": [ + 270, + 80 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 27 + }, + { + "label": "latent", + "localized_name": "latent", + "name": "vae_conditioning", + "type": "LATENT", + "link": 114 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 29 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 30 + ] + } + ], + "properties": { + "Node name for S&R": "SeedVR2Conditioning", + "cnr_id": "comfy-core", + "ver": "0.27.0" + } + }, + { + "id": 115, + "type": "GetVideoComponents", + "pos": [ + 310, + 680 + ], + "size": [ + 230, + 140 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 79 + } + ], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": [ + 66 + ] + }, + { + "localized_name": "audio", + "name": "audio", + "type": "AUDIO", + "links": [ + 68 + ] + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "links": [ + 69 + ] + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "type": "COMBO", + "links": [ + 70 + ] + }, + { + "localized_name": "color_space", + "name": "color_space", + "type": "COMBO", + "links": [ + 160 + ] + } + ], + "properties": { + "Node name for S&R": "GetVideoComponents", + "cnr_id": "comfy-core", + "ver": "0.28.0" + } + }, + { + "id": 116, + "type": "CreateVideo", + "pos": [ + 3030, + 680 + ], + "size": [ + 270, + 170 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 67 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 68 + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 69 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": 70 + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": 160 + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 71 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + 30, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 30, + "bit_depth": 8, + "color_space": "sRGB" + } + }, + { + "id": 117, + "type": "Video Slice", + "pos": [ + -30, + 470 + ], + "size": [ + 270, + 150 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "video", + "name": "video", + "type": "VIDEO", + "link": 74 + }, + { + "localized_name": "start_time", + "name": "start_time", + "type": "FLOAT", + "widget": { + "name": "start_time" + }, + "link": 81 + }, + { + "localized_name": "duration", + "name": "duration", + "type": "FLOAT", + "widget": { + "name": "duration" + }, + "link": 82 + }, + { + "localized_name": "strict_duration", + "name": "strict_duration", + "type": "BOOLEAN", + "widget": { + "name": "strict_duration" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 76, + 77 + ] + } + ], + "properties": { + "Node name for S&R": "Video Slice", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + 0, + 0, + false + ], + "widgets_values_named": { + "start_time": 0, + "duration": 0, + "strict_duration": false + } + }, + { + "id": 118, + "type": "ComfySwitchNode", + "pos": [ + -30, + 670 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 78 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 77 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 80 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 79 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 119, + "type": "SeedVR2TemporalChunk", + "pos": [ + 980, + 220 + ], + "size": [ + 310, + 110 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 99 + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": 124 + }, + { + "localized_name": "chunking_mode", + "name": "chunking_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "chunking_mode" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "latents", + "name": "latents", + "shape": 6, + "type": "LATENT", + "links": [ + 113 + ] + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "links": [ + 102 + ] + } + ], + "properties": { + "Node name for S&R": "SeedVR2TemporalChunk", + "cnr_id": "comfy-core", + "ver": "0.28.0" + }, + "widgets_values": [ + 0, + "auto" + ], + "widgets_values_named": { + "temporal_overlap": 0, + "chunking_mode": "auto" + } + }, + { + "id": 120, + "type": "SeedVR2TemporalMerge", + "pos": [ + 2120, + -140 + ], + "size": [ + 230, + 80 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "latents", + "name": "latents", + "type": "LATENT", + "link": 101 + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "link": 102 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 118 + ] + } + ], + "properties": { + "Node name for S&R": "SeedVR2TemporalMerge", + "cnr_id": "comfy-core", + "ver": "0.28.0" + } + }, + { + "id": 121, + "type": "ComfySwitchNode", + "pos": [ + 1410, + 40 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 112 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 113 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 121 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 114, + 115 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 122, + "type": "ComfySwitchNode", + "pos": [ + 2120, + 0 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 116 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 118 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 122 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 117 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 123, + "type": "PrimitiveBoolean", + "pos": [ + -30, + 870 + ], + "size": [ + 270, + 90 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 123 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 121, + 122 + ] + } + ], + "title": "Boolean (Split Latent)", + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + } + } + ], + "groups": [ + { + "id": 1, + "title": "Models", + "bounding": [ + 610, + -150, + 290, + 330.15625 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 16, + "origin_id": 112, + "origin_slot": 0, + "target_id": 106, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 17, + "origin_id": 107, + "origin_slot": 0, + "target_id": 106, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 31, + "origin_id": 108, + "origin_slot": 0, + "target_id": 109, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 29, + "origin_id": 114, + "origin_slot": 0, + "target_id": 109, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 30, + "origin_id": 114, + "origin_slot": 1, + "target_id": 109, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 22, + "origin_id": 107, + "origin_slot": 0, + "target_id": 110, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 24, + "origin_id": 111, + "origin_slot": 0, + "target_id": 112, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 25, + "origin_id": 110, + "origin_slot": 0, + "target_id": 113, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 26, + "origin_id": 111, + "origin_slot": 0, + "target_id": 113, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 27, + "origin_id": 108, + "origin_slot": 0, + "target_id": 114, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 66, + "origin_id": 115, + "origin_slot": 0, + "target_id": 111, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 67, + "origin_id": 113, + "origin_slot": 0, + "target_id": 116, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 68, + "origin_id": 115, + "origin_slot": 1, + "target_id": 116, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 69, + "origin_id": 115, + "origin_slot": 2, + "target_id": 116, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 70, + "origin_id": 115, + "origin_slot": 3, + "target_id": 116, + "target_slot": 3, + "type": "INT" + }, + { + "id": 71, + "origin_id": 116, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 74, + "origin_id": -10, + "origin_slot": 0, + "target_id": 117, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 77, + "origin_id": 117, + "origin_slot": 0, + "target_id": 118, + "target_slot": 1, + "type": "VIDEO" + }, + { + "id": 78, + "origin_id": -10, + "origin_slot": 0, + "target_id": 118, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 79, + "origin_id": 118, + "origin_slot": 0, + "target_id": 115, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 80, + "origin_id": -10, + "origin_slot": 1, + "target_id": 118, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 81, + "origin_id": -10, + "origin_slot": 2, + "target_id": 117, + "target_slot": 1, + "type": "FLOAT" + }, + { + "id": 82, + "origin_id": -10, + "origin_slot": 3, + "target_id": 117, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 86, + "origin_id": -10, + "origin_slot": 4, + "target_id": 111, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 87, + "origin_id": -10, + "origin_slot": 5, + "target_id": 109, + "target_slot": 4, + "type": "INT" + }, + { + "id": 90, + "origin_id": -10, + "origin_slot": 6, + "target_id": 109, + "target_slot": 7, + "type": "COMBO" + }, + { + "id": 91, + "origin_id": -10, + "origin_slot": 7, + "target_id": 109, + "target_slot": 8, + "type": "COMBO" + }, + { + "id": 92, + "origin_id": -10, + "origin_slot": 8, + "target_id": 109, + "target_slot": 9, + "type": "FLOAT" + }, + { + "id": 99, + "origin_id": 106, + "origin_slot": 0, + "target_id": 119, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 101, + "origin_id": 109, + "origin_slot": 0, + "target_id": 120, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 102, + "origin_id": 119, + "origin_slot": 1, + "target_id": 120, + "target_slot": 1, + "type": "INT" + }, + { + "id": 108, + "origin_id": -10, + "origin_slot": 9, + "target_id": 113, + "target_slot": 2, + "type": "COMBO" + }, + { + "id": 112, + "origin_id": 106, + "origin_slot": 0, + "target_id": 121, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 113, + "origin_id": 119, + "origin_slot": 0, + "target_id": 121, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 114, + "origin_id": 121, + "origin_slot": 0, + "target_id": 114, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 115, + "origin_id": 121, + "origin_slot": 0, + "target_id": 109, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 116, + "origin_id": 109, + "origin_slot": 0, + "target_id": 122, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 117, + "origin_id": 122, + "origin_slot": 0, + "target_id": 110, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 118, + "origin_id": 120, + "origin_slot": 0, + "target_id": 122, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 121, + "origin_id": 123, + "origin_slot": 0, + "target_id": 121, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 122, + "origin_id": 123, + "origin_slot": 0, + "target_id": 122, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 123, + "origin_id": -10, + "origin_slot": 10, + "target_id": 123, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 124, + "origin_id": -10, + "origin_slot": 11, + "target_id": 119, + "target_slot": 1, + "type": "INT" + }, + { + "id": 125, + "origin_id": -10, + "origin_slot": 12, + "target_id": 108, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 126, + "origin_id": -10, + "origin_slot": 13, + "target_id": 107, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 160, + "origin_id": 115, + "origin_slot": 4, + "target_id": 116, + "target_slot": 4, + "type": "COMBO" + } + ], + "extra": {}, + "category": "Video Tools/Upscale" + } + ] + }, + "extra": {} +} \ No newline at end of file diff --git a/blueprints/Video Depth Estimation (Depth Anything 3).json b/blueprints/Video Depth Estimation (Depth Anything 3).json index 88084a3933b..af259d8b71d 100644 --- a/blueprints/Video Depth Estimation (Depth Anything 3).json +++ b/blueprints/Video Depth Estimation (Depth Anything 3).json @@ -814,7 +814,7 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Depth", + "category": "Preprocessors/Depth", "description": "This subgraph processes a video input through Depth Anything 3 to produce temporally consistent depth maps for each frame, outputting a depth video. It is ideal for video content requiring spatial geometry estimation, such as 3D reconstruction, SLAM, or novel view synthesis from moving cameras. The model uses a plain transformer backbone trained with a depth-ray representation, supporting any number of views without requiring known camera poses." } ] @@ -822,4 +822,4 @@ "extra": { "BlueprintDescription": "This subgraph processes a video input through Depth Anything 3 to produce temporally consistent depth maps for each frame, outputting a depth video. It is ideal for video content requiring spatial geometry estimation, such as 3D reconstruction, SLAM, or novel view synthesis from moving cameras. The model uses a plain transformer backbone trained with a depth-ray representation, supporting any number of views without requiring known camera poses." } -} \ No newline at end of file +} diff --git a/blueprints/Video Depth Estimation (MoGe).json b/blueprints/Video Depth Estimation (MoGe).json index 025e20cda42..ba35e0f50b5 100644 --- a/blueprints/Video Depth Estimation (MoGe).json +++ b/blueprints/Video Depth Estimation (MoGe).json @@ -1217,10 +1217,10 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Depth", + "category": "Preprocessors/Depth", "description": "Estimates monocular depth from an input video using MoGe, outputting both raw and colorized depth maps plus a mask." } ] }, "extra": {} -} \ No newline at end of file +} diff --git a/blueprints/Video Edit (Bernini-R).json b/blueprints/Video Edit (Bernini-R).json index 2f09db5fa7b..4a488cac8f5 100644 --- a/blueprints/Video Edit (Bernini-R).json +++ b/blueprints/Video Edit (Bernini-R).json @@ -3314,7 +3314,7 @@ } ], "extra": {}, - "category": "Video generation and editing/Video Edit", + "category": "Video editing/Video Edit", "description": "This subgraph uses Depth Anything 3 to predict spatially consistent geometry from any number of images or video frames, with or without known camera poses. It outputs depth maps, camera poses, and optionally 3D Gaussian parameters for novel view synthesis." }, { @@ -3729,4 +3729,4 @@ "extra": { "BlueprintDescription": "This subgraph uses Depth Anything 3 to predict spatially consistent geometry from any number of images or video frames, with or without known camera poses. It outputs depth maps, camera poses, and optionally 3D Gaussian parameters for novel view synthesis." } -} \ No newline at end of file +} diff --git a/blueprints/Video Face Detection (Mediapipe).json b/blueprints/Video Face Detection (Mediapipe).json index c70352481fe..042eb874d76 100644 --- a/blueprints/Video Face Detection (Mediapipe).json +++ b/blueprints/Video Face Detection (Mediapipe).json @@ -1101,9 +1101,9 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Face Detection" + "category": "Preprocessors/Face" } ] }, "extra": {} -} \ No newline at end of file +} diff --git a/blueprints/Video Generation (LTX-2.3 IC-LoRA).json b/blueprints/Video Generation (LTX-2.3 IC-LoRA).json new file mode 100644 index 00000000000..05b58b604e4 --- /dev/null +++ b/blueprints/Video Generation (LTX-2.3 IC-LoRA).json @@ -0,0 +1,3483 @@ +{ + "revision": 0, + "last_node_id": "786", + "last_link_id": 0, + "nodes": [ + { + "id": 786, + "type": "6cac68ab-1080-4eb5-96b5-d7db09f56b5e", + "pos": [ + 2330, + 2670 + ], + "size": [ + 510, + 690 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "label": "first_frame", + "localized_name": "input", + "name": "input", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "control_images", + "localized_name": "input_1", + "name": "input_1", + "type": "IMAGE,MASK", + "link": null + }, + { + "label": "prompt", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + }, + { + "label": "prompt_enhance", + "name": "value_4", + "type": "BOOLEAN", + "widget": { + "name": "value_4" + }, + "link": null + }, + { + "label": "width", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": null + }, + { + "label": "height", + "name": "value_1", + "type": "INT", + "widget": { + "name": "value_1" + }, + "link": null + }, + { + "label": "fps", + "name": "value_3", + "type": "INT", + "widget": { + "name": "value_3" + }, + "link": null + }, + { + "label": "seed", + "name": "noise_seed", + "type": "INT", + "widget": { + "name": "noise_seed" + }, + "link": null + }, + { + "label": "ckpt_name", + "name": "ckpt_name_1", + "type": "COMBO", + "widget": { + "name": "ckpt_name_1" + }, + "link": null + }, + { + "label": "text_encoder", + "name": "text_encoder", + "type": "COMBO", + "widget": { + "name": "text_encoder" + }, + "link": null + }, + { + "label": "lora", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": null + }, + { + "label": "ic_lora", + "name": "lora_name_1", + "type": "COMBO", + "widget": { + "name": "lora_name_1" + }, + "link": null + }, + { + "label": "lora_strength", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + }, + { + "label": "bypass_first_frame", + "name": "value_2", + "type": "BOOLEAN", + "widget": { + "name": "value_2" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [] + } + ], + "properties": { + "cnr_id": "comfy-core", + "ver": "0.18.1", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.7", + "widget_ue_connectable": {} + }, + "previewExposures": [ + { + "name": "$$canvas-image-preview", + "sourceNodeId": "783", + "sourcePreviewName": "$$canvas-image-preview" + } + ] + }, + "widgets_values": [ + "", + false, + 1280, + 704, + 25, + 288370150520961, + "ltx-2.3-22b-distilled-fp8.safetensors", + "gemma_3_12B_it_fp4_mixed.safetensors", + "gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors", + "ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors", + 1, + true + ], + "widgets_values_named": { + "text": "", + "value_4": false, + "value": 1280, + "value_1": 704, + "value_3": 25, + "noise_seed": 288370150520961, + "ckpt_name_1": "ltx-2.3-22b-distilled-fp8.safetensors", + "text_encoder": "gemma_3_12B_it_fp4_mixed.safetensors", + "lora_name": "gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors", + "lora_name_1": "ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors", + "strength_model": 1, + "value_2": true + }, + "title": "Video Generation (LTX-2.3 IC-LoRA)" + } + ], + "links": [], + "version": 0.4, + "definitions": { + "subgraphs": [ + { + "id": "6cac68ab-1080-4eb5-96b5-d7db09f56b5e", + "version": 1, + "state": { + "lastGroupId": 28, + "lastNodeId": 786, + "lastLinkId": 1825, + "lastRerouteId": 0 + }, + "revision": 0, + "config": {}, + "name": "Video Generation (LTX-2.3 IC-LoRA)", + "inputNode": { + "id": -10, + "bounding": [ + -160, + 3060, + 155.00390625, + 328 + ] + }, + "outputNode": { + "id": -20, + "bounding": [ + 3620, + 3120, + 128, + 68 + ] + }, + "inputs": [ + { + "id": "6fe179c4-d96f-4383-b202-844f6de4922e", + "name": "input", + "type": "IMAGE,MASK", + "linkIds": [ + 1735, + 1786 + ], + "localized_name": "input", + "label": "first_frame", + "pos": [ + -28.99609375, + 3084 + ] + }, + { + "id": "e80df1ae-5f39-4f86-91bd-0467635e2f2d", + "name": "input_1", + "type": "IMAGE,MASK", + "linkIds": [ + 1777, + 1802 + ], + "localized_name": "input_1", + "label": "control_images", + "pos": [ + -28.99609375, + 3104 + ] + }, + { + "id": "433148fa-bf73-4ab1-81d9-09e2e38ed861", + "name": "text", + "type": "STRING", + "linkIds": [ + 316, + 317 + ], + "label": "prompt", + "pos": [ + -28.99609375, + 3124 + ] + }, + { + "id": "80e8d306-4496-4641-a896-249352d68a33", + "name": "value_4", + "type": "BOOLEAN", + "linkIds": [ + 315 + ], + "label": "prompt_enhance", + "pos": [ + -28.99609375, + 3144 + ] + }, + { + "id": "36915bc8-a6ed-4d48-8619-e0e8723228e9", + "name": "value", + "type": "INT", + "linkIds": [ + 266 + ], + "label": "width", + "pos": [ + -28.99609375, + 3164 + ] + }, + { + "id": "425a36b8-91ab-41b7-81e9-496eba064ec8", + "name": "value_1", + "type": "INT", + "linkIds": [ + 267 + ], + "label": "height", + "pos": [ + -28.99609375, + 3184 + ] + }, + { + "id": "581b52ff-21c5-4774-ac2a-8f69a7e09e2e", + "name": "value_3", + "type": "INT", + "linkIds": [ + 269 + ], + "label": "fps", + "pos": [ + -28.99609375, + 3204 + ] + }, + { + "id": "d03cc171-45da-4658-99aa-77252bbcf522", + "name": "noise_seed", + "type": "INT", + "linkIds": [ + 1793 + ], + "label": "seed", + "pos": [ + -28.99609375, + 3224 + ] + }, + { + "id": "e68e61c8-905e-43ac-8c76-65ac52270a08", + "name": "ckpt_name_1", + "type": "COMBO", + "linkIds": [ + 272, + 275, + 276 + ], + "label": "ckpt_name", + "pos": [ + -28.99609375, + 3244 + ] + }, + { + "id": "5d065f3b-891b-499f-950b-c2df0be24536", + "name": "text_encoder", + "type": "COMBO", + "linkIds": [ + 273 + ], + "label": "text_encoder", + "pos": [ + -28.99609375, + 3264 + ] + }, + { + "id": "e9abdbac-c422-4a1e-bc0d-6b99c0730086", + "name": "lora_name", + "type": "COMBO", + "linkIds": [ + 1768 + ], + "label": "lora", + "pos": [ + -28.99609375, + 3284 + ] + }, + { + "id": "c05ba793-3f43-4594-b7df-3f17fa3f99ba", + "name": "lora_name_1", + "type": "COMBO", + "linkIds": [ + 1769 + ], + "label": "ic_lora", + "pos": [ + -28.99609375, + 3304 + ] + }, + { + "id": "b475372d-89e7-44bb-995b-647f8e29c484", + "name": "strength_model", + "type": "FLOAT", + "linkIds": [ + 1770 + ], + "label": "lora_strength", + "pos": [ + -28.99609375, + 3324 + ] + }, + { + "id": "bfc27fa2-b100-413c-9c64-d38ab1ed5a2d", + "name": "value_2", + "type": "BOOLEAN", + "linkIds": [ + 1804 + ], + "label": "bypass_first_frame", + "pos": [ + -28.99609375, + 3344 + ] + } + ], + "outputs": [ + { + "id": "0c8c2dc0-c67c-4bc2-9e57-6aa00db2e3a9", + "name": "VIDEO", + "type": "VIDEO", + "linkIds": [ + 252 + ], + "localized_name": "VIDEO", + "pos": [ + 3644, + 3144 + ] + } + ], + "widgets": [], + "nodes": [ + { + "id": 756, + "type": "LTXVEmptyLatentAudio", + "pos": [ + 1180, + 3800 + ], + "size": [ + 280, + 170 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [ + { + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 205 + }, + { + "localized_name": "frames_number", + "name": "frames_number", + "type": "INT", + "widget": { + "name": "frames_number" + }, + "link": 1801 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT,INT", + "widget": { + "name": "frame_rate" + }, + "link": 207 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "Latent", + "name": "Latent", + "type": "LATENT", + "links": [ + 245 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVEmptyLatentAudio", + "cnr_id": "comfy-core", + "ver": "0.3.68", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 97, + 25, + 1 + ], + "widgets_values_named": { + "frames_number": 97, + "frame_rate": 25, + "batch_size": 1 + } + }, + { + "id": 757, + "type": "LTXVCropGuides", + "pos": [ + 2430, + 2450 + ], + "size": [ + 280, + 120 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 290 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 292 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 215 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 211 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVCropGuides", + "cnr_id": "comfy-core", + "ver": "0.8.2", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + } + }, + { + "id": 758, + "type": "EmptyLTXVLatentVideo", + "pos": [ + 1180, + 3550 + ], + "size": [ + 280, + 200 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "widget": { + "name": "width" + }, + "link": 1778 + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "widget": { + "name": "height" + }, + "link": 1779 + }, + { + "localized_name": "length", + "name": "length", + "type": "INT", + "widget": { + "name": "length" + }, + "link": 1800 + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "widget": { + "name": "batch_size" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 296 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLTXVLatentVideo", + "cnr_id": "comfy-core", + "ver": "0.3.60", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 768, + 512, + 97, + 1 + ], + "widgets_values_named": { + "width": 768, + "height": 512, + "length": 97, + "batch_size": 1 + } + }, + { + "id": 759, + "type": "LTXVConditioning", + "pos": [ + 1180, + 3350 + ], + "size": [ + 280, + 130 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 221 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 222 + }, + { + "localized_name": "frame_rate", + "name": "frame_rate", + "type": "FLOAT", + "widget": { + "name": "frame_rate" + }, + "link": 223 + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 236 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 237 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConditioning", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 25 + ], + "widgets_values_named": { + "frame_rate": 25 + } + }, + { + "id": 760, + "type": "PrimitiveInt", + "pos": [ + 760, + 3840 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 269 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 207, + 235 + ] + } + ], + "title": "Frame Rate(int)", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 25, + "fixed" + ], + "widgets_values_named": { + "value": 25, + "fixed": "fixed" + } + }, + { + "id": 761, + "type": "LTXVAddGuide", + "pos": [ + 2030, + 3360 + ], + "size": [ + 280, + 310 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 236 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 237 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 307 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 289 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 1777 + }, + { + "localized_name": "attention_mask", + "name": "attention_mask", + "shape": 7, + "type": "MASK", + "link": null + }, + { + "localized_name": "iclora_parameters", + "name": "iclora_parameters", + "shape": 7, + "type": "IC_LORA_PARAMETERS", + "link": 288 + }, + { + "localized_name": "frame_idx", + "name": "frame_idx", + "type": "INT", + "widget": { + "name": "frame_idx" + }, + "link": null + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "links": [ + 290, + 1791 + ] + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "links": [ + 292, + 1790 + ] + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 287 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAddGuide", + "cnr_id": "comfy-core", + "ver": "0.12.3", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.7", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 0, + 1 + ], + "widgets_values_named": { + "frame_idx": 0, + "strength": 1 + } + }, + { + "id": 762, + "type": "LTXVConcatAVLatent", + "pos": [ + 2030, + 2390 + ], + "size": [ + 280, + 100 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "link": 287 + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "link": 245 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 1789 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVConcatAVLatent", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + } + }, + { + "id": 763, + "type": "ComfyMathExpression", + "pos": [ + 770, + 4000 + ], + "size": [ + 230, + 80 + ], + "flags": { + "collapsed": true + }, + "order": 7, + "mode": 0, + "inputs": [ + { + "label": "a", + "localized_name": "a", + "name": "values.a", + "type": "FLOAT,INT,BOOLEAN", + "link": 235 + }, + { + "localized_name": "b", + "name": "values.b", + "shape": 7, + "type": "FLOAT,INT,BOOLEAN", + "link": null + }, + { + "localized_name": "expression", + "name": "expression", + "type": "STRING", + "widget": { + "name": "expression" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "FLOAT", + "name": "FLOAT", + "type": "FLOAT", + "links": [ + 223, + 234 + ] + }, + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [] + }, + { + "localized_name": "BOOL", + "name": "BOOL", + "type": "BOOLEAN", + "links": null + } + ], + "properties": { + "Node name for S&R": "ComfyMathExpression", + "cnr_id": "comfy-core", + "ver": "0.17.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.7", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + "a" + ], + "widgets_values_named": { + "expression": "a" + } + }, + { + "id": 764, + "type": "PrimitiveInt", + "pos": [ + 760, + 3340 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 266 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1778 + ] + } + ], + "title": "Width", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 1280, + "fixed" + ], + "widgets_values_named": { + "value": 1280, + "fixed": "fixed" + } + }, + { + "id": 765, + "type": "PrimitiveInt", + "pos": [ + 760, + 3490 + ], + "size": [ + 230, + 110 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "INT", + "widget": { + "name": "value" + }, + "link": 267 + } + ], + "outputs": [ + { + "localized_name": "INT", + "name": "INT", + "type": "INT", + "links": [ + 1779 + ] + } + ], + "title": "height", + "properties": { + "Node name for S&R": "PrimitiveInt", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 704, + "fixed" + ], + "widgets_values_named": { + "value": 704, + "fixed": "fixed" + } + }, + { + "id": 766, + "type": "CLIPTextEncode", + "pos": [ + 1320, + 2870 + ], + "size": [ + 590, + 200 + ], + "flags": { + "collapsed": false + }, + "order": 10, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 230 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 222 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + "blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, wrong hand count, artifacts around text, unreadable text on shirt or hat, incorrect lettering on cap (“PNTR”), incorrect t-shirt slogan (“JUST DO IT”), missing microphone, misplaced microphone, inconsistent perspective, camera shake, incorrect depth of field, background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, incorrect ethnicity, wrong gender, exaggerated expressions, smiling, laughing, exaggerated sadness, wrong gaze direction, eyes looking at camera, mismatched lip sync, silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, missing sniff sounds, incorrect dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural transitions, inconsistent framing, tilted camera, missing door or shelves, missing shallow depth of field, flat lighting, inconsistent tone, cinematic oversaturation, stylized filters, or AI artifacts." + ], + "widgets_values_named": { + "text": "blurry, out of focus, overexposed, underexposed, low contrast, washed out colors, excessive noise, grainy texture, poor lighting, flickering, motion blur, distorted proportions, unnatural skin tones, deformed facial features, asymmetrical face, missing facial features, extra limbs, disfigured hands, wrong hand count, artifacts around text, unreadable text on shirt or hat, incorrect lettering on cap (“PNTR”), incorrect t-shirt slogan (“JUST DO IT”), missing microphone, misplaced microphone, inconsistent perspective, camera shake, incorrect depth of field, background too sharp, background clutter, distracting reflections, harsh shadows, inconsistent lighting direction, color banding, cartoonish rendering, 3D CGI look, unrealistic materials, uncanny valley effect, incorrect ethnicity, wrong gender, exaggerated expressions, smiling, laughing, exaggerated sadness, wrong gaze direction, eyes looking at camera, mismatched lip sync, silent or muted audio, distorted voice, robotic voice, echo, background noise, off-sync audio, missing sniff sounds, incorrect dialogue, added dialogue, repetitive speech, jittery movement, awkward pauses, incorrect timing, unnatural transitions, inconsistent framing, tilted camera, missing door or shelves, missing shallow depth of field, flat lighting, inconsistent tone, cinematic oversaturation, stylized filters, or AI artifacts." + }, + "color": "#223", + "bgcolor": "#335" + }, + { + "id": 767, + "type": "CreateVideo", + "pos": [ + 2890, + 2900 + ], + "size": [ + 280, + 160 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "link": 232 + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": 233 + }, + { + "localized_name": "fps", + "name": "fps", + "type": "FLOAT", + "widget": { + "name": "fps" + }, + "link": 234 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "bit_depth" + }, + "link": null + }, + { + "localized_name": "color_space", + "name": "color_space", + "shape": 7, + "type": "COMBO", + "widget": { + "name": "color_space" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "VIDEO", + "name": "VIDEO", + "type": "VIDEO", + "links": [ + 252 + ] + } + ], + "properties": { + "Node name for S&R": "CreateVideo", + "cnr_id": "comfy-core", + "ver": "0.14.1", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.7", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 24, + 8, + "sRGB" + ], + "widgets_values_named": { + "fps": 24, + "bit_depth": 8, + "color_space": "sRGB" + } + }, + { + "id": 768, + "type": "VAEDecodeTiled", + "pos": [ + 2430, + 2630 + ], + "size": [ + 280, + 200 + ], + "flags": {}, + "order": 12, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 211 + }, + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 212 + }, + { + "localized_name": "tile_size", + "name": "tile_size", + "type": "INT", + "widget": { + "name": "tile_size" + }, + "link": null + }, + { + "localized_name": "overlap", + "name": "overlap", + "type": "INT", + "widget": { + "name": "overlap" + }, + "link": null + }, + { + "localized_name": "temporal_size", + "name": "temporal_size", + "type": "INT", + "widget": { + "name": "temporal_size" + }, + "link": null + }, + { + "localized_name": "temporal_overlap", + "name": "temporal_overlap", + "type": "INT", + "widget": { + "name": "temporal_overlap" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "IMAGE", + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 232 + ] + } + ], + "properties": { + "Node name for S&R": "VAEDecodeTiled", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + 768, + 64, + 4096, + 64 + ], + "widgets_values_named": { + "tile_size": 768, + "overlap": 64, + "temporal_size": 4096, + "temporal_overlap": 64 + } + }, + { + "id": 769, + "type": "LTXVAudioVAEDecode", + "pos": [ + 2430, + 2920 + ], + "size": [ + 280, + 100 + ], + "flags": {}, + "order": 13, + "mode": 0, + "inputs": [ + { + "localized_name": "samples", + "name": "samples", + "type": "LATENT", + "link": 216 + }, + { + "label": "Audio VAE", + "localized_name": "Audio VAE", + "name": "audio_vae", + "type": "VAE", + "link": 217 + } + ], + "outputs": [ + { + "localized_name": "Audio", + "name": "Audio", + "type": "AUDIO", + "links": [ + 233 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAudioVAEDecode", + "cnr_id": "comfy-core", + "ver": "0.7.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + } + }, + { + "id": 770, + "type": "LTXVSeparateAVLatent", + "pos": [ + 2040, + 3010 + ], + "size": [ + 250, + 100 + ], + "flags": {}, + "order": 14, + "mode": 0, + "inputs": [ + { + "localized_name": "av_latent", + "name": "av_latent", + "type": "LATENT", + "link": 1788 + } + ], + "outputs": [ + { + "localized_name": "video_latent", + "name": "video_latent", + "type": "LATENT", + "links": [ + 215 + ] + }, + { + "localized_name": "audio_latent", + "name": "audio_latent", + "type": "LATENT", + "links": [ + 216 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVSeparateAVLatent", + "cnr_id": "comfy-core", + "ver": "0.5.1", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + } + }, + { + "id": 771, + "type": "CLIPTextEncode", + "pos": [ + 1310, + 2380 + ], + "size": [ + 620, + 420 + ], + "flags": {}, + "order": 15, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 231 + }, + { + "localized_name": "text", + "name": "text", + "type": "STRING", + "widget": { + "name": "text" + }, + "link": 1808 + } + ], + "outputs": [ + { + "localized_name": "CONDITIONING", + "name": "CONDITIONING", + "type": "CONDITIONING", + "links": [ + 221 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPTextEncode", + "cnr_id": "comfy-core", + "ver": "0.3.56", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + "" + ], + "widgets_values_named": { + "text": "" + }, + "color": "#232", + "bgcolor": "#353" + }, + { + "id": 772, + "type": "CheckpointLoaderSimple", + "pos": [ + 770, + 2380 + ], + "size": [ + 420, + 160 + ], + "flags": {}, + "order": 16, + "mode": 0, + "inputs": [ + { + "localized_name": "ckpt_name", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": 276 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 279, + 313 + ] + }, + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [] + }, + { + "localized_name": "VAE", + "name": "VAE", + "type": "VAE", + "links": [ + 212, + 306, + 307 + ] + } + ], + "properties": { + "Node name for S&R": "CheckpointLoaderSimple", + "cnr_id": "comfy-core", + "ver": "0.10.0", + "models": [ + { + "name": "ltx-2.3-22b-distilled-fp8.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.3-fp8/resolve/main/ltx-2.3-22b-distilled-fp8.safetensors", + "directory": "checkpoints" + } + ], + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + "ltx-2.3-22b-distilled-fp8.safetensors" + ], + "widgets_values_named": { + "ckpt_name": "ltx-2.3-22b-distilled-fp8.safetensors" + } + }, + { + "id": 773, + "type": "LTXVAudioVAELoader", + "pos": [ + 770, + 2590 + ], + "size": [ + 420, + 110 + ], + "flags": {}, + "order": 17, + "mode": 0, + "inputs": [ + { + "localized_name": "ckpt_name", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": 272 + } + ], + "outputs": [ + { + "localized_name": "Audio VAE", + "name": "Audio VAE", + "type": "VAE", + "links": [ + 205, + 217 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVAudioVAELoader", + "cnr_id": "comfy-core", + "ver": "0.10.0", + "models": [ + { + "name": "ltx-2.3-22b-distilled-fp8.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.3-fp8/resolve/main/ltx-2.3-22b-distilled-fp8.safetensors", + "directory": "checkpoints" + } + ], + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + "ltx-2.3-22b-distilled-fp8.safetensors" + ], + "widgets_values_named": { + "ckpt_name": "ltx-2.3-22b-distilled-fp8.safetensors" + } + }, + { + "id": 774, + "type": "LTXAVTextEncoderLoader", + "pos": [ + 770, + 2750 + ], + "size": [ + 410, + 170 + ], + "flags": {}, + "order": 18, + "mode": 0, + "inputs": [ + { + "localized_name": "text_encoder", + "name": "text_encoder", + "type": "COMBO", + "widget": { + "name": "text_encoder" + }, + "link": 273 + }, + { + "localized_name": "ckpt_name", + "name": "ckpt_name", + "type": "COMBO", + "widget": { + "name": "ckpt_name" + }, + "link": 275 + }, + { + "localized_name": "device", + "name": "device", + "type": "COMBO", + "widget": { + "name": "device" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 230, + 231, + 314 + ] + } + ], + "properties": { + "Node name for S&R": "LTXAVTextEncoderLoader", + "cnr_id": "comfy-core", + "ver": "0.10.0", + "models": [ + { + "name": "gemma_3_12B_it_fp4_mixed.safetensors", + "url": "https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/text_encoders/gemma_3_12B_it_fp4_mixed.safetensors", + "directory": "text_encoders" + }, + { + "name": "ltx-2.3-22b-distilled-fp8.safetensors", + "url": "https://huggingface.co/Lightricks/LTX-2.3-fp8/resolve/main/ltx-2.3-22b-distilled-fp8.safetensors", + "directory": "checkpoints" + } + ], + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10, + "ue_properties": { + "input_ue_unconnectable": {}, + "version": "7.5.2", + "widget_ue_connectable": {} + } + }, + "widgets_values": [ + "gemma_3_12B_it_fp4_mixed.safetensors", + "ltx-2.3-22b-distilled-fp8.safetensors", + "default" + ], + "widgets_values_named": { + "text_encoder": "gemma_3_12B_it_fp4_mixed.safetensors", + "ckpt_name": "ltx-2.3-22b-distilled-fp8.safetensors", + "device": "default" + } + }, + { + "id": 775, + "type": "LoraLoaderModelOnly", + "pos": [ + 750, + 3020 + ], + "size": [ + 470, + 140 + ], + "flags": {}, + "order": 19, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 279 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 1769 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": 1770 + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": [ + 277, + 1792 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoaderModelOnly", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "models": [ + { + "name": "ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors", + "url": "https://huggingface.co/Comfy-Org/ltx-2.3/resolve/main/split_files/loras/ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + "ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors", + 1 + ], + "widgets_values_named": { + "lora_name": "ltx-2.3-22b-ic-lora-ingredients-0.9.safetensors", + "strength_model": 1 + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 776, + "type": "GetICLoRAParameters", + "pos": [ + 1590, + 3630 + ], + "size": [ + 300, + 80 + ], + "flags": {}, + "order": 20, + "mode": 0, + "inputs": [ + { + "localized_name": "iclora_model", + "name": "iclora_model", + "type": "MODEL", + "link": 277 + } + ], + "outputs": [ + { + "localized_name": "iclora_parameters", + "name": "iclora_parameters", + "type": "IC_LORA_PARAMETERS", + "links": [ + 288 + ] + } + ], + "properties": { + "Node name for S&R": "GetICLoRAParameters", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "color": "#323", + "bgcolor": "#535" + }, + { + "id": 777, + "type": "LTXVImgToVideoInplace", + "pos": [ + 1580, + 3360 + ], + "size": [ + 270, + 180 + ], + "flags": {}, + "order": 21, + "mode": 0, + "inputs": [ + { + "localized_name": "vae", + "name": "vae", + "type": "VAE", + "link": 306 + }, + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 1786 + }, + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "link": 296 + }, + { + "localized_name": "strength", + "name": "strength", + "type": "FLOAT", + "widget": { + "name": "strength" + }, + "link": null + }, + { + "localized_name": "bypass", + "name": "bypass", + "type": "BOOLEAN", + "widget": { + "name": "bypass" + }, + "link": 1803 + } + ], + "outputs": [ + { + "localized_name": "latent", + "name": "latent", + "type": "LATENT", + "links": [ + 289 + ] + } + ], + "properties": { + "Node name for S&R": "LTXVImgToVideoInplace", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + 1, + true + ], + "widgets_values_named": { + "strength": 1, + "bypass": true + } + }, + { + "id": 778, + "type": "LoraLoader", + "pos": [ + 770, + 1570 + ], + "size": [ + 380, + 230 + ], + "flags": {}, + "order": 22, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 313 + }, + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 314 + }, + { + "localized_name": "lora_name", + "name": "lora_name", + "type": "COMBO", + "widget": { + "name": "lora_name" + }, + "link": 1768 + }, + { + "localized_name": "strength_model", + "name": "strength_model", + "type": "FLOAT", + "widget": { + "name": "strength_model" + }, + "link": null + }, + { + "localized_name": "strength_clip", + "name": "strength_clip", + "type": "FLOAT", + "widget": { + "name": "strength_clip" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "MODEL", + "name": "MODEL", + "type": "MODEL", + "links": null + }, + { + "localized_name": "CLIP", + "name": "CLIP", + "type": "CLIP", + "links": [ + 309 + ] + } + ], + "properties": { + "Node name for S&R": "LoraLoader", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "models": [ + { + "name": "gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors", + "url": "https://huggingface.co/Comfy-Org/ltx-2/resolve/main/split_files/loras/gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors", + "directory": "loras" + } + ], + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + "gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors", + 1, + 1 + ], + "widgets_values_named": { + "lora_name": "gemma-3-12b-it-abliterated_lora_rank64_bf16.safetensors", + "strength_model": 1, + "strength_clip": 1 + } + }, + { + "id": 779, + "type": "TextGenerateLTX2Prompt", + "pos": [ + 1200, + 1570 + ], + "size": [ + 400, + 420 + ], + "flags": { + "collapsed": false + }, + "order": 23, + "mode": 0, + "inputs": [ + { + "localized_name": "clip", + "name": "clip", + "type": "CLIP", + "link": 309 + }, + { + "localized_name": "image", + "name": "image", + "shape": 7, + "type": "IMAGE", + "link": 1735 + }, + { + "localized_name": "video", + "name": "video", + "shape": 7, + "type": "IMAGE", + "link": null + }, + { + "localized_name": "audio", + "name": "audio", + "shape": 7, + "type": "AUDIO", + "link": null + }, + { + "localized_name": "prompt", + "name": "prompt", + "type": "STRING", + "widget": { + "name": "prompt" + }, + "link": 316 + }, + { + "localized_name": "max_length", + "name": "max_length", + "type": "INT", + "widget": { + "name": "max_length" + }, + "link": null + }, + { + "localized_name": "Sampling Mode", + "name": "sampling_mode", + "type": "COMFY_DYNAMICCOMBO_V3", + "widget": { + "name": "sampling_mode" + }, + "link": null + }, + { + "localized_name": "temperature", + "name": "sampling_mode.temperature", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.temperature" + }, + "link": null + }, + { + "localized_name": "top_k", + "name": "sampling_mode.top_k", + "type": "INT", + "widget": { + "name": "sampling_mode.top_k" + }, + "link": null + }, + { + "localized_name": "top_p", + "name": "sampling_mode.top_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.top_p" + }, + "link": null + }, + { + "localized_name": "min_p", + "name": "sampling_mode.min_p", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.min_p" + }, + "link": null + }, + { + "localized_name": "repetition_penalty", + "name": "sampling_mode.repetition_penalty", + "type": "FLOAT", + "widget": { + "name": "sampling_mode.repetition_penalty" + }, + "link": null + }, + { + "localized_name": "seed", + "name": "sampling_mode.seed", + "type": "INT", + "widget": { + "name": "sampling_mode.seed" + }, + "link": null + }, + { + "localized_name": "presence_penalty", + "name": "sampling_mode.presence_penalty", + "shape": 7, + "type": "FLOAT", + "widget": { + "name": "sampling_mode.presence_penalty" + }, + "link": null + }, + { + "localized_name": "thinking", + "name": "thinking", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "thinking" + }, + "link": null + }, + { + "localized_name": "use_default_template", + "name": "use_default_template", + "shape": 7, + "type": "BOOLEAN", + "widget": { + "name": "use_default_template" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "generated_text", + "name": "generated_text", + "type": "STRING", + "links": [ + 311 + ] + } + ], + "properties": { + "Node name for S&R": "TextGenerateLTX2Prompt", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + "", + 2048, + "on", + 0.7, + 64, + 0.95, + 0.05, + 1.05, + 0, + 0, + false, + true + ], + "widgets_values_named": { + "prompt": "", + "max_length": 2048, + "sampling_mode": "on", + "sampling_mode.temperature": 0.7, + "sampling_mode.top_k": 64, + "sampling_mode.top_p": 0.95, + "sampling_mode.min_p": 0.05, + "sampling_mode.repetition_penalty": 1.05, + "sampling_mode.seed": 0, + "sampling_mode.presence_penalty": 0, + "thinking": false, + "use_default_template": true + } + }, + { + "id": 780, + "type": "PreviewAny", + "pos": [ + 2040, + 1590 + ], + "size": [ + 510, + 360 + ], + "flags": {}, + "order": 24, + "mode": 4, + "inputs": [ + { + "localized_name": "source", + "name": "source", + "type": "*", + "link": 310 + } + ], + "outputs": [ + { + "localized_name": "STRING", + "name": "STRING", + "type": "STRING", + "links": [ + 1808 + ] + } + ], + "properties": { + "Node name for S&R": "PreviewAny", + "cnr_id": "comfy-core", + "ver": "0.16.3", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [], + "widgets_values_named": {} + }, + { + "id": 781, + "type": "ComfySwitchNode", + "pos": [ + 1730, + 1980 + ], + "size": [ + 270, + 130 + ], + "flags": {}, + "order": 25, + "mode": 0, + "inputs": [ + { + "localized_name": "on_false", + "name": "on_false", + "shape": 7, + "type": "*", + "link": 317 + }, + { + "localized_name": "on_true", + "name": "on_true", + "shape": 7, + "type": "*", + "link": 311 + }, + { + "localized_name": "switch", + "name": "switch", + "type": "BOOLEAN", + "widget": { + "name": "switch" + }, + "link": 312 + } + ], + "outputs": [ + { + "localized_name": "output", + "name": "output", + "type": "*", + "links": [ + 310 + ] + } + ], + "properties": { + "Node name for S&R": "ComfySwitchNode", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "switch": false + } + }, + { + "id": 782, + "type": "PrimitiveBoolean", + "pos": [ + 770, + 2050 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 26, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 315 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 312 + ] + } + ], + "title": "Boolean (Enable Prompt Enhance)", + "properties": { + "Node name for S&R": "PrimitiveBoolean", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + false + ], + "widgets_values_named": { + "value": false + } + }, + { + "id": 783, + "type": "KSampler", + "pos": [ + 2030, + 2570 + ], + "size": [ + 270, + 350 + ], + "flags": {}, + "order": 27, + "mode": 0, + "inputs": [ + { + "localized_name": "model", + "name": "model", + "type": "MODEL", + "link": 1792 + }, + { + "localized_name": "positive", + "name": "positive", + "type": "CONDITIONING", + "link": 1791 + }, + { + "localized_name": "negative", + "name": "negative", + "type": "CONDITIONING", + "link": 1790 + }, + { + "localized_name": "latent_image", + "name": "latent_image", + "type": "LATENT", + "link": 1789 + }, + { + "localized_name": "seed", + "name": "seed", + "type": "INT", + "widget": { + "name": "seed" + }, + "link": 1793 + }, + { + "localized_name": "steps", + "name": "steps", + "type": "INT", + "widget": { + "name": "steps" + }, + "link": null + }, + { + "localized_name": "cfg", + "name": "cfg", + "type": "FLOAT", + "widget": { + "name": "cfg" + }, + "link": null + }, + { + "localized_name": "sampler_name", + "name": "sampler_name", + "type": "COMBO", + "widget": { + "name": "sampler_name" + }, + "link": null + }, + { + "localized_name": "scheduler", + "name": "scheduler", + "type": "COMBO", + "widget": { + "name": "scheduler" + }, + "link": null + }, + { + "localized_name": "denoise", + "name": "denoise", + "type": "FLOAT", + "widget": { + "name": "denoise" + }, + "link": null + } + ], + "outputs": [ + { + "localized_name": "LATENT", + "name": "LATENT", + "type": "LATENT", + "links": [ + 1788 + ] + } + ], + "properties": { + "Node name for S&R": "KSampler", + "cnr_id": "comfy-core", + "ver": "0.22.0", + "enableTabs": false, + "hasSecondTab": false, + "secondTabOffset": 80, + "secondTabText": "Send Back", + "secondTabWidth": 65, + "tabWidth": 65, + "tabXOffset": 10 + }, + "widgets_values": [ + 288370150520961, + "randomize", + 8, + 1, + "euler_ancestral", + "linear_quadratic", + 1 + ], + "widgets_values_named": { + "seed": 288370150520961, + "control_after_generate": "randomize", + "steps": 8, + "cfg": 1, + "sampler_name": "euler_ancestral", + "scheduler": "linear_quadratic", + "denoise": 1 + } + }, + { + "id": 784, + "type": "GetImageSize", + "pos": [ + 770, + 3650 + ], + "size": [ + 230, + 120 + ], + "flags": {}, + "order": 28, + "mode": 0, + "inputs": [ + { + "localized_name": "image", + "name": "image", + "type": "IMAGE", + "link": 1802 + } + ], + "outputs": [ + { + "localized_name": "width", + "name": "width", + "type": "INT", + "links": null + }, + { + "localized_name": "height", + "name": "height", + "type": "INT", + "links": null + }, + { + "localized_name": "batch_size", + "name": "batch_size", + "type": "INT", + "links": [ + 1800, + 1801 + ] + } + ], + "properties": { + "Node name for S&R": "GetImageSize" + } + }, + { + "id": 785, + "type": "PrimitiveBoolean", + "pos": [ + 750, + 4080 + ], + "size": [ + 270, + 100 + ], + "flags": {}, + "order": 29, + "mode": 0, + "inputs": [ + { + "localized_name": "value", + "name": "value", + "type": "BOOLEAN", + "widget": { + "name": "value" + }, + "link": 1804 + } + ], + "outputs": [ + { + "localized_name": "BOOLEAN", + "name": "BOOLEAN", + "type": "BOOLEAN", + "links": [ + 1803 + ] + } + ], + "properties": { + "Node name for S&R": "PrimitiveBoolean" + }, + "widgets_values": [ + true + ], + "widgets_values_named": { + "value": true + } + } + ], + "groups": [ + { + "id": 1, + "title": "Conditioning", + "bounding": [ + 1050, + 3250, + 1700, + 960 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 2, + "title": "Settings", + "bounding": [ + 730, + 3250, + 300, + 960 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 5, + "title": "Model", + "bounding": [ + 730, + 2240, + 500, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 6, + "title": "Prompt", + "bounding": [ + 1260, + 2240, + 680, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 7, + "title": "Sampling", + "bounding": [ + 1970, + 2240, + 380, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 8, + "title": "Decoding", + "bounding": [ + 2380, + 2240, + 370, + 980 + ], + "color": "#3f789e", + "flags": {} + }, + { + "id": 24, + "title": "Prompt Enhancement", + "bounding": [ + 730, + 1490, + 2020, + 720 + ], + "color": "#3f789e", + "flags": {} + } + ], + "links": [ + { + "id": 205, + "origin_id": 773, + "origin_slot": 0, + "target_id": 756, + "target_slot": 0, + "type": "VAE" + }, + { + "id": 207, + "origin_id": 760, + "origin_slot": 0, + "target_id": 756, + "target_slot": 2, + "type": "INT" + }, + { + "id": 215, + "origin_id": 770, + "origin_slot": 0, + "target_id": 757, + "target_slot": 2, + "type": "LATENT" + }, + { + "id": 221, + "origin_id": 771, + "origin_slot": 0, + "target_id": 759, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 222, + "origin_id": 766, + "origin_slot": 0, + "target_id": 759, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 223, + "origin_id": 763, + "origin_slot": 0, + "target_id": 759, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 236, + "origin_id": 759, + "origin_slot": 0, + "target_id": 761, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 237, + "origin_id": 759, + "origin_slot": 1, + "target_id": 761, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 245, + "origin_id": 756, + "origin_slot": 0, + "target_id": 762, + "target_slot": 1, + "type": "LATENT" + }, + { + "id": 235, + "origin_id": 760, + "origin_slot": 0, + "target_id": 763, + "target_slot": 0, + "type": "INT" + }, + { + "id": 230, + "origin_id": 774, + "origin_slot": 0, + "target_id": 766, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 232, + "origin_id": 768, + "origin_slot": 0, + "target_id": 767, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 233, + "origin_id": 769, + "origin_slot": 0, + "target_id": 767, + "target_slot": 1, + "type": "AUDIO" + }, + { + "id": 234, + "origin_id": 763, + "origin_slot": 0, + "target_id": 767, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 211, + "origin_id": 757, + "origin_slot": 2, + "target_id": 768, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 212, + "origin_id": 772, + "origin_slot": 2, + "target_id": 768, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 216, + "origin_id": 770, + "origin_slot": 1, + "target_id": 769, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 217, + "origin_id": 773, + "origin_slot": 0, + "target_id": 769, + "target_slot": 1, + "type": "VAE" + }, + { + "id": 231, + "origin_id": 774, + "origin_slot": 0, + "target_id": 771, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 252, + "origin_id": 767, + "origin_slot": 0, + "target_id": -20, + "target_slot": 0, + "type": "VIDEO" + }, + { + "id": 266, + "origin_id": -10, + "origin_slot": 4, + "target_id": 764, + "target_slot": 0, + "type": "INT" + }, + { + "id": 267, + "origin_id": -10, + "origin_slot": 5, + "target_id": 765, + "target_slot": 0, + "type": "INT" + }, + { + "id": 269, + "origin_id": -10, + "origin_slot": 6, + "target_id": 760, + "target_slot": 0, + "type": "INT" + }, + { + "id": 272, + "origin_id": -10, + "origin_slot": 8, + "target_id": 773, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 273, + "origin_id": -10, + "origin_slot": 9, + "target_id": 774, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 275, + "origin_id": -10, + "origin_slot": 8, + "target_id": 774, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 276, + "origin_id": -10, + "origin_slot": 8, + "target_id": 772, + "target_slot": 0, + "type": "COMBO" + }, + { + "id": 277, + "origin_id": 775, + "origin_slot": 0, + "target_id": 776, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 279, + "origin_id": 772, + "origin_slot": 0, + "target_id": 775, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 287, + "origin_id": 761, + "origin_slot": 2, + "target_id": 762, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 288, + "origin_id": 776, + "origin_slot": 0, + "target_id": 761, + "target_slot": 6, + "type": "IC_LORA_PARAMETERS" + }, + { + "id": 289, + "origin_id": 777, + "origin_slot": 0, + "target_id": 761, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 290, + "origin_id": 761, + "origin_slot": 0, + "target_id": 757, + "target_slot": 0, + "type": "CONDITIONING" + }, + { + "id": 292, + "origin_id": 761, + "origin_slot": 1, + "target_id": 757, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 296, + "origin_id": 758, + "origin_slot": 0, + "target_id": 777, + "target_slot": 2, + "type": "LATENT" + }, + { + "id": 306, + "origin_id": 772, + "origin_slot": 2, + "target_id": 777, + "target_slot": 0, + "type": "VAE" + }, + { + "id": 307, + "origin_id": 772, + "origin_slot": 2, + "target_id": 761, + "target_slot": 2, + "type": "VAE" + }, + { + "id": 309, + "origin_id": 778, + "origin_slot": 1, + "target_id": 779, + "target_slot": 0, + "type": "CLIP" + }, + { + "id": 310, + "origin_id": 781, + "origin_slot": 0, + "target_id": 780, + "target_slot": 0, + "type": "*" + }, + { + "id": 311, + "origin_id": 779, + "origin_slot": 0, + "target_id": 781, + "target_slot": 1, + "type": "STRING" + }, + { + "id": 312, + "origin_id": 782, + "origin_slot": 0, + "target_id": 781, + "target_slot": 2, + "type": "BOOLEAN" + }, + { + "id": 313, + "origin_id": 772, + "origin_slot": 0, + "target_id": 778, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 314, + "origin_id": 774, + "origin_slot": 0, + "target_id": 778, + "target_slot": 1, + "type": "CLIP" + }, + { + "id": 315, + "origin_id": -10, + "origin_slot": 3, + "target_id": 782, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 316, + "origin_id": -10, + "origin_slot": 2, + "target_id": 779, + "target_slot": 4, + "type": "STRING" + }, + { + "id": 317, + "origin_id": -10, + "origin_slot": 2, + "target_id": 781, + "target_slot": 0, + "type": "STRING" + }, + { + "id": 1735, + "origin_id": -10, + "origin_slot": 0, + "target_id": 779, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 1768, + "origin_id": -10, + "origin_slot": 10, + "target_id": 778, + "target_slot": 2, + "type": "COMBO" + }, + { + "id": 1769, + "origin_id": -10, + "origin_slot": 11, + "target_id": 775, + "target_slot": 1, + "type": "COMBO" + }, + { + "id": 1770, + "origin_id": -10, + "origin_slot": 12, + "target_id": 775, + "target_slot": 2, + "type": "FLOAT" + }, + { + "id": 1777, + "origin_id": -10, + "origin_slot": 1, + "target_id": 761, + "target_slot": 4, + "type": "IMAGE" + }, + { + "id": 1778, + "origin_id": 764, + "origin_slot": 0, + "target_id": 758, + "target_slot": 0, + "type": "INT" + }, + { + "id": 1779, + "origin_id": 765, + "origin_slot": 0, + "target_id": 758, + "target_slot": 1, + "type": "INT" + }, + { + "id": 1786, + "origin_id": -10, + "origin_slot": 0, + "target_id": 777, + "target_slot": 1, + "type": "IMAGE" + }, + { + "id": 1788, + "origin_id": 783, + "origin_slot": 0, + "target_id": 770, + "target_slot": 0, + "type": "LATENT" + }, + { + "id": 1789, + "origin_id": 762, + "origin_slot": 0, + "target_id": 783, + "target_slot": 3, + "type": "LATENT" + }, + { + "id": 1790, + "origin_id": 761, + "origin_slot": 1, + "target_id": 783, + "target_slot": 2, + "type": "CONDITIONING" + }, + { + "id": 1791, + "origin_id": 761, + "origin_slot": 0, + "target_id": 783, + "target_slot": 1, + "type": "CONDITIONING" + }, + { + "id": 1792, + "origin_id": 775, + "origin_slot": 0, + "target_id": 783, + "target_slot": 0, + "type": "MODEL" + }, + { + "id": 1793, + "origin_id": -10, + "origin_slot": 7, + "target_id": 783, + "target_slot": 4, + "type": "INT" + }, + { + "id": 1800, + "origin_id": 784, + "origin_slot": 2, + "target_id": 758, + "target_slot": 2, + "type": "INT" + }, + { + "id": 1801, + "origin_id": 784, + "origin_slot": 2, + "target_id": 756, + "target_slot": 1, + "type": "INT" + }, + { + "id": 1802, + "origin_id": -10, + "origin_slot": 1, + "target_id": 784, + "target_slot": 0, + "type": "IMAGE" + }, + { + "id": 1803, + "origin_id": 785, + "origin_slot": 0, + "target_id": 777, + "target_slot": 4, + "type": "BOOLEAN" + }, + { + "id": 1804, + "origin_id": -10, + "origin_slot": 13, + "target_id": 785, + "target_slot": 0, + "type": "BOOLEAN" + }, + { + "id": 1808, + "origin_id": 780, + "origin_slot": 0, + "target_id": 771, + "target_slot": 1, + "type": "STRING" + } + ], + "extra": {}, + "category": "Video generation/Conditioned", + "description": "Generates LTX-2.3 video with IC-LoRA structural guidance from aligned control images such as depth, pose, or edges." + } + ] + }, + "extra": { + "ue_links": [] + } +} \ No newline at end of file diff --git a/blueprints/Video Inpaint (VOID).json b/blueprints/Video Inpaint (VOID).json index a7cc806b55d..d90d8d94db4 100644 --- a/blueprints/Video Inpaint (VOID).json +++ b/blueprints/Video Inpaint (VOID).json @@ -3768,7 +3768,7 @@ } ], "extra": {}, - "category": "Video generation and editing/Inpaint video", + "category": "Video editing/Inpaint video", "description": "Removes objects from video by inpainting masked regions using VOID (CogVideoX), with SAM3 text-guided segmentation and optional two-pass optical-flow refinement." }, { @@ -4337,4 +4337,4 @@ ] }, "extra": {} -} \ No newline at end of file +} diff --git a/blueprints/Video Inpainting (Wan2.1 VACE).json b/blueprints/Video Inpainting (Wan2.1 VACE).json index 7460f3d44a8..3d8f18368c2 100644 --- a/blueprints/Video Inpainting (Wan2.1 VACE).json +++ b/blueprints/Video Inpainting (Wan2.1 VACE).json @@ -1,14 +1,14 @@ { "revision": 0, - "last_node_id": 306, + "last_node_id": 355, "last_link_id": 0, "nodes": [ { - "id": 306, - "type": "bd7f73a0-ec67-4f46-8671-17088d8e31b7", + "id": 355, + "type": "21da7c57-d95a-48d7-b35a-a22d8e5f5163", "pos": [ - -2950, - -410 + -2290, + -60 ], "size": [ 440, @@ -160,59 +160,59 @@ "properties": { "proxyWidgets": [ [ - "280", + "330", "text" ], [ - "297", + "345", "value" ], [ - "290", + "338", "value" ], [ - "289", + "337", "length" ], [ - "288", + "336", "seed" ], [ - "299", + "347", "unet_name" ], [ - "277", + "327", "clip_name" ], [ - "278", + "328", "vae_name" ], [ - "300", + "348", "value" ], [ - "272", + "322", "lora_name" ], [ - "268", + "318", "text" ], [ - "269", + "319", "expand" ], [ - "268", + "318", "ckpt_name" ], [ - "312", + "351", "$$canvas-image-preview" ] ], @@ -235,11 +235,11 @@ "definitions": { "subgraphs": [ { - "id": "bd7f73a0-ec67-4f46-8671-17088d8e31b7", + "id": "21da7c57-d95a-48d7-b35a-a22d8e5f5163", "version": 1, "state": { "lastGroupId": 31, - "lastNodeId": 315, + "lastNodeId": 355, "lastLinkId": 499, "lastRerouteId": 0 }, @@ -479,7 +479,7 @@ "widgets": [], "nodes": [ { - "id": 266, + "id": 316, "type": "ModelSamplingSD3", "pos": [ -560, @@ -538,7 +538,7 @@ ] }, { - "id": 267, + "id": 317, "type": "CreateVideo", "pos": [ 530, @@ -575,6 +575,16 @@ "name": "fps" }, "link": 425 + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "shape": 7, + "type": "INT", + "widget": { + "name": "bit_depth" + }, + "link": null } ], "outputs": [ @@ -601,12 +611,13 @@ "widget_ue_connectable": {} }, "widgets_values": [ - 16 + 16, + 8 ] }, { - "id": 268, - "type": "17df2eeb-d89e-46ee-9480-a4ca2494b207", + "id": 318, + "type": "73d70937-ffa8-43fe-af02-aecff882d159", "pos": [ -1960, 3220 @@ -705,23 +716,23 @@ "properties": { "proxyWidgets": [ [ - "237", + "354", "text" ], [ - "75", + "352", "threshold" ], [ - "75", + "352", "refine_iterations" ], [ - "75", + "352", "individual_masks" ], [ - "236", + "353", "ckpt_name" ] ], @@ -745,7 +756,7 @@ "widgets_values": [] }, { - "id": 269, + "id": 319, "type": "GrowMask", "pos": [ -1530, @@ -815,7 +826,7 @@ ] }, { - "id": 270, + "id": 320, "type": "PrimitiveInt", "pos": [ -1350, @@ -868,7 +879,7 @@ ] }, { - "id": 271, + "id": 321, "type": "PrimitiveFloat", "pos": [ -1340, @@ -920,7 +931,7 @@ ] }, { - "id": 272, + "id": 322, "type": "LoraLoaderModelOnly", "pos": [ -1380, @@ -995,7 +1006,7 @@ ] }, { - "id": 273, + "id": 323, "type": "PrimitiveInt", "pos": [ -1340, @@ -1048,7 +1059,7 @@ ] }, { - "id": 274, + "id": 324, "type": "PrimitiveFloat", "pos": [ -1340, @@ -1100,7 +1111,7 @@ ] }, { - "id": 275, + "id": 325, "type": "ComfySwitchNode", "pos": [ -960, @@ -1163,7 +1174,7 @@ ] }, { - "id": 276, + "id": 326, "type": "ComfySwitchNode", "pos": [ -960, @@ -1226,7 +1237,7 @@ ] }, { - "id": 277, + "id": 327, "type": "CLIPLoader", "pos": [ -2710, @@ -1308,7 +1319,7 @@ ] }, { - "id": 278, + "id": 328, "type": "VAELoader", "pos": [ -2700, @@ -1369,7 +1380,7 @@ ] }, { - "id": 279, + "id": 329, "type": "CLIPTextEncode", "pos": [ -2280, @@ -1431,7 +1442,7 @@ "bgcolor": "#335" }, { - "id": 280, + "id": 330, "type": "CLIPTextEncode", "pos": [ -2270, @@ -1493,7 +1504,7 @@ "bgcolor": "#353" }, { - "id": 281, + "id": 331, "type": "WanVaceToVideo", "pos": [ -1780, @@ -1652,7 +1663,7 @@ ] }, { - "id": 282, + "id": 332, "type": "InvertMask", "pos": [ -1510, @@ -1697,7 +1708,7 @@ } }, { - "id": 283, + "id": 333, "type": "MaskToImage", "pos": [ -1510, @@ -1742,7 +1753,7 @@ } }, { - "id": 284, + "id": 334, "type": "ImageCompositeMasked", "pos": [ -1210, @@ -1833,7 +1844,7 @@ ] }, { - "id": 287, + "id": 335, "type": "TrimVideoLatent", "pos": [ -220, @@ -1895,7 +1906,7 @@ ] }, { - "id": 288, + "id": 336, "type": "KSampler", "pos": [ -560, @@ -2023,7 +2034,7 @@ ] }, { - "id": 289, + "id": 337, "type": "ImageFromBatch", "pos": [ -2360, @@ -2090,7 +2101,7 @@ ] }, { - "id": 290, + "id": 338, "type": "PrimitiveInt", "pos": [ -2690, @@ -2143,7 +2154,7 @@ ] }, { - "id": 291, + "id": 339, "type": "ComfyMathExpression", "pos": [ -2650, @@ -2223,7 +2234,7 @@ ] }, { - "id": 292, + "id": 340, "type": "ComfyMathExpression", "pos": [ -2650, @@ -2303,7 +2314,7 @@ ] }, { - "id": 293, + "id": 341, "type": "ResizeImageMaskNode", "pos": [ -2360, @@ -2402,7 +2413,7 @@ ] }, { - "id": 294, + "id": 342, "type": "ComfySwitchNode", "pos": [ -960, @@ -2465,7 +2476,7 @@ ] }, { - "id": 295, + "id": 343, "type": "GetImageSize", "pos": [ -2010, @@ -2526,7 +2537,7 @@ } }, { - "id": 296, + "id": 344, "type": "VAEDecode", "pos": [ 520, @@ -2581,7 +2592,7 @@ } }, { - "id": 297, + "id": 345, "type": "PrimitiveInt", "pos": [ -2690, @@ -2634,7 +2645,7 @@ ] }, { - "id": 298, + "id": 346, "type": "GetVideoComponents", "pos": [ -2330, @@ -2681,6 +2692,12 @@ "links": [ 425 ] + }, + { + "localized_name": "bit_depth", + "name": "bit_depth", + "type": "INT", + "links": null } ], "properties": { @@ -2697,7 +2714,7 @@ } }, { - "id": 299, + "id": 347, "type": "UNETLoader", "pos": [ -2720, @@ -2768,7 +2785,7 @@ ] }, { - "id": 300, + "id": 348, "type": "PrimitiveBoolean", "pos": [ -1390, @@ -2822,7 +2839,7 @@ ] }, { - "id": 308, + "id": 349, "type": "ImageFromBatch", "pos": [ -2360, @@ -2887,7 +2904,7 @@ ] }, { - "id": 310, + "id": 350, "type": "MaskPreview", "pos": [ -900, @@ -2923,7 +2940,7 @@ } }, { - "id": 312, + "id": 351, "type": "PreviewImage", "pos": [ -520, @@ -2944,7 +2961,14 @@ "link": 499 } ], - "outputs": [], + "outputs": [ + { + "localized_name": "images", + "name": "images", + "type": "IMAGE", + "links": null + } + ], "properties": { "Node name for S&R": "PreviewImage", "cnr_id": "comfy-core", @@ -3096,385 +3120,385 @@ "links": [ { "id": 422, - "origin_id": 275, + "origin_id": 325, "origin_slot": 0, - "target_id": 266, + "target_id": 316, "target_slot": 0, "type": "MODEL" }, { "id": 423, - "origin_id": 296, + "origin_id": 344, "origin_slot": 0, - "target_id": 267, + "target_id": 317, "target_slot": 0, "type": "IMAGE" }, { "id": 424, - "origin_id": 298, + "origin_id": 346, "origin_slot": 1, - "target_id": 267, + "target_id": 317, "target_slot": 1, "type": "AUDIO" }, { "id": 425, - "origin_id": 298, + "origin_id": 346, "origin_slot": 2, - "target_id": 267, + "target_id": 317, "target_slot": 2, "type": "FLOAT" }, { "id": 426, - "origin_id": 293, + "origin_id": 341, "origin_slot": 0, - "target_id": 268, + "target_id": 318, "target_slot": 0, "type": "IMAGE" }, { "id": 427, - "origin_id": 268, + "origin_id": 318, "origin_slot": 0, - "target_id": 269, + "target_id": 319, "target_slot": 0, "type": "MASK" }, { "id": 428, - "origin_id": 299, + "origin_id": 347, "origin_slot": 0, - "target_id": 272, + "target_id": 322, "target_slot": 0, "type": "MODEL" }, { "id": 429, - "origin_id": 299, + "origin_id": 347, "origin_slot": 0, - "target_id": 275, + "target_id": 325, "target_slot": 0, "type": "MODEL" }, { "id": 430, - "origin_id": 272, + "origin_id": 322, "origin_slot": 0, - "target_id": 275, + "target_id": 325, "target_slot": 1, "type": "MODEL" }, { "id": 431, - "origin_id": 300, + "origin_id": 348, "origin_slot": 0, - "target_id": 275, + "target_id": 325, "target_slot": 2, "type": "BOOLEAN" }, { "id": 432, - "origin_id": 271, + "origin_id": 321, "origin_slot": 0, - "target_id": 276, + "target_id": 326, "target_slot": 0, "type": "FLOAT" }, { "id": 433, - "origin_id": 274, + "origin_id": 324, "origin_slot": 0, - "target_id": 276, + "target_id": 326, "target_slot": 1, "type": "FLOAT" }, { "id": 434, - "origin_id": 300, + "origin_id": 348, "origin_slot": 0, - "target_id": 276, + "target_id": 326, "target_slot": 2, "type": "BOOLEAN" }, { "id": 435, - "origin_id": 277, + "origin_id": 327, "origin_slot": 0, - "target_id": 279, + "target_id": 329, "target_slot": 0, "type": "CLIP" }, { "id": 436, - "origin_id": 277, + "origin_id": 327, "origin_slot": 0, - "target_id": 280, + "target_id": 330, "target_slot": 0, "type": "CLIP" }, { "id": 437, - "origin_id": 280, + "origin_id": 330, "origin_slot": 0, - "target_id": 281, + "target_id": 331, "target_slot": 0, "type": "CONDITIONING" }, { "id": 438, - "origin_id": 279, + "origin_id": 329, "origin_slot": 0, - "target_id": 281, + "target_id": 331, "target_slot": 1, "type": "CONDITIONING" }, { "id": 439, - "origin_id": 278, + "origin_id": 328, "origin_slot": 0, - "target_id": 281, + "target_id": 331, "target_slot": 2, "type": "VAE" }, { "id": 440, - "origin_id": 284, + "origin_id": 334, "origin_slot": 0, - "target_id": 281, + "target_id": 331, "target_slot": 3, "type": "IMAGE" }, { "id": 441, - "origin_id": 269, + "origin_id": 319, "origin_slot": 0, - "target_id": 281, + "target_id": 331, "target_slot": 4, "type": "MASK" }, { "id": 442, - "origin_id": 295, + "origin_id": 343, "origin_slot": 0, - "target_id": 281, + "target_id": 331, "target_slot": 6, "type": "INT" }, { "id": 443, - "origin_id": 295, + "origin_id": 343, "origin_slot": 1, - "target_id": 281, + "target_id": 331, "target_slot": 7, "type": "INT" }, { "id": 444, - "origin_id": 295, + "origin_id": 343, "origin_slot": 2, - "target_id": 281, + "target_id": 331, "target_slot": 8, "type": "INT" }, { "id": 445, - "origin_id": 269, + "origin_id": 319, "origin_slot": 0, - "target_id": 282, + "target_id": 332, "target_slot": 0, "type": "MASK" }, { "id": 446, - "origin_id": 282, + "origin_id": 332, "origin_slot": 0, - "target_id": 283, + "target_id": 333, "target_slot": 0, "type": "MASK" }, { "id": 447, - "origin_id": 293, + "origin_id": 341, "origin_slot": 0, - "target_id": 284, + "target_id": 334, "target_slot": 0, "type": "IMAGE" }, { "id": 448, - "origin_id": 283, + "origin_id": 333, "origin_slot": 0, - "target_id": 284, + "target_id": 334, "target_slot": 1, "type": "IMAGE" }, { "id": 449, - "origin_id": 269, + "origin_id": 319, "origin_slot": 0, - "target_id": 284, + "target_id": 334, "target_slot": 2, "type": "MASK" }, { "id": 452, - "origin_id": 288, + "origin_id": 336, "origin_slot": 0, - "target_id": 287, + "target_id": 335, "target_slot": 0, "type": "LATENT" }, { "id": 453, - "origin_id": 281, + "origin_id": 331, "origin_slot": 3, - "target_id": 287, + "target_id": 335, "target_slot": 1, "type": "INT" }, { "id": 454, - "origin_id": 266, + "origin_id": 316, "origin_slot": 0, - "target_id": 288, + "target_id": 336, "target_slot": 0, "type": "MODEL" }, { "id": 455, - "origin_id": 281, + "origin_id": 331, "origin_slot": 0, - "target_id": 288, + "target_id": 336, "target_slot": 1, "type": "CONDITIONING" }, { "id": 456, - "origin_id": 281, + "origin_id": 331, "origin_slot": 1, - "target_id": 288, + "target_id": 336, "target_slot": 2, "type": "CONDITIONING" }, { "id": 457, - "origin_id": 281, + "origin_id": 331, "origin_slot": 2, - "target_id": 288, + "target_id": 336, "target_slot": 3, "type": "LATENT" }, { "id": 458, - "origin_id": 294, + "origin_id": 342, "origin_slot": 0, - "target_id": 288, + "target_id": 336, "target_slot": 5, "type": "INT" }, { "id": 459, - "origin_id": 276, + "origin_id": 326, "origin_slot": 0, - "target_id": 288, + "target_id": 336, "target_slot": 6, "type": "FLOAT" }, { "id": 460, - "origin_id": 298, + "origin_id": 346, "origin_slot": 0, - "target_id": 289, + "target_id": 337, "target_slot": 0, "type": "IMAGE" }, { "id": 461, - "origin_id": 290, + "origin_id": 338, "origin_slot": 0, - "target_id": 291, + "target_id": 339, "target_slot": 0, "type": "INT" }, { "id": 462, - "origin_id": 297, + "origin_id": 345, "origin_slot": 0, - "target_id": 292, + "target_id": 340, "target_slot": 0, "type": "INT" }, { "id": 463, - "origin_id": 289, + "origin_id": 337, "origin_slot": 0, - "target_id": 293, + "target_id": 341, "target_slot": 0, "type": "IMAGE" }, { "id": 464, - "origin_id": 292, + "origin_id": 340, "origin_slot": 1, - "target_id": 293, + "target_id": 341, "target_slot": 2, "type": "INT" }, { "id": 465, - "origin_id": 291, + "origin_id": 339, "origin_slot": 1, - "target_id": 293, + "target_id": 341, "target_slot": 3, "type": "INT" }, { "id": 466, - "origin_id": 270, + "origin_id": 320, "origin_slot": 0, - "target_id": 294, + "target_id": 342, "target_slot": 0, "type": "INT" }, { "id": 467, - "origin_id": 273, + "origin_id": 323, "origin_slot": 0, - "target_id": 294, + "target_id": 342, "target_slot": 1, "type": "INT" }, { "id": 468, - "origin_id": 300, + "origin_id": 348, "origin_slot": 0, - "target_id": 294, + "target_id": 342, "target_slot": 2, "type": "BOOLEAN" }, { "id": 469, - "origin_id": 293, + "origin_id": 341, "origin_slot": 0, - "target_id": 295, + "target_id": 343, "target_slot": 0, "type": "IMAGE" }, { "id": 470, - "origin_id": 287, + "origin_id": 335, "origin_slot": 0, - "target_id": 296, + "target_id": 344, "target_slot": 0, "type": "LATENT" }, { "id": 471, - "origin_id": 278, + "origin_id": 328, "origin_slot": 0, - "target_id": 296, + "target_id": 344, "target_slot": 1, "type": "VAE" }, @@ -3482,13 +3506,13 @@ "id": 473, "origin_id": -10, "origin_slot": 0, - "target_id": 298, + "target_id": 346, "target_slot": 0, "type": "VIDEO" }, { "id": 474, - "origin_id": 267, + "origin_id": 317, "origin_slot": 0, "target_id": -20, "target_slot": 0, @@ -3498,7 +3522,7 @@ "id": 478, "origin_id": -10, "origin_slot": 1, - "target_id": 281, + "target_id": 331, "target_slot": 5, "type": "IMAGE" }, @@ -3506,7 +3530,7 @@ "id": 479, "origin_id": -10, "origin_slot": 2, - "target_id": 280, + "target_id": 330, "target_slot": 1, "type": "STRING" }, @@ -3514,7 +3538,7 @@ "id": 480, "origin_id": -10, "origin_slot": 3, - "target_id": 297, + "target_id": 345, "target_slot": 0, "type": "INT" }, @@ -3522,7 +3546,7 @@ "id": 481, "origin_id": -10, "origin_slot": 4, - "target_id": 290, + "target_id": 338, "target_slot": 0, "type": "INT" }, @@ -3530,7 +3554,7 @@ "id": 494, "origin_id": -10, "origin_slot": 5, - "target_id": 289, + "target_id": 337, "target_slot": 2, "type": "INT" }, @@ -3538,7 +3562,7 @@ "id": 483, "origin_id": -10, "origin_slot": 6, - "target_id": 288, + "target_id": 336, "target_slot": 4, "type": "INT" }, @@ -3546,7 +3570,7 @@ "id": 485, "origin_id": -10, "origin_slot": 7, - "target_id": 299, + "target_id": 347, "target_slot": 0, "type": "COMBO" }, @@ -3554,7 +3578,7 @@ "id": 486, "origin_id": -10, "origin_slot": 8, - "target_id": 277, + "target_id": 327, "target_slot": 0, "type": "COMBO" }, @@ -3562,7 +3586,7 @@ "id": 487, "origin_id": -10, "origin_slot": 9, - "target_id": 278, + "target_id": 328, "target_slot": 0, "type": "COMBO" }, @@ -3570,7 +3594,7 @@ "id": 489, "origin_id": -10, "origin_slot": 10, - "target_id": 300, + "target_id": 348, "target_slot": 0, "type": "BOOLEAN" }, @@ -3578,7 +3602,7 @@ "id": 490, "origin_id": -10, "origin_slot": 11, - "target_id": 272, + "target_id": 322, "target_slot": 1, "type": "COMBO" }, @@ -3586,7 +3610,7 @@ "id": 491, "origin_id": -10, "origin_slot": 12, - "target_id": 268, + "target_id": 318, "target_slot": 1, "type": "STRING" }, @@ -3594,7 +3618,7 @@ "id": 496, "origin_id": -10, "origin_slot": 13, - "target_id": 269, + "target_id": 319, "target_slot": 1, "type": "INT" }, @@ -3602,37 +3626,39 @@ "id": 497, "origin_id": -10, "origin_slot": 14, - "target_id": 268, + "target_id": 318, "target_slot": 8, "type": "COMBO" }, { "id": 498, - "origin_id": 269, + "origin_id": 319, "origin_slot": 0, - "target_id": 310, + "target_id": 350, "target_slot": 0, "type": "MASK" }, { "id": 499, - "origin_id": 284, + "origin_id": 334, "origin_slot": 0, - "target_id": 312, + "target_id": 351, "target_slot": 0, "type": "IMAGE" } ], - "extra": {}, - "category": "Video generation and editing/Inpaint video", + "extra": { + "workflowRendererVersion": "LG" + }, + "category": "Video editing/Inpaint video", "description": "Removes objects from video by inpainting masked regions using Wan 2.1 VACE, with SAM3 text-guided segmentation and optional Lightning LoRA turbo mode." }, { - "id": "17df2eeb-d89e-46ee-9480-a4ca2494b207", + "id": "73d70937-ffa8-43fe-af02-aecff882d159", "version": 1, "state": { "lastGroupId": 31, - "lastNodeId": 315, + "lastNodeId": 355, "lastLinkId": 499, "lastRerouteId": 0 }, @@ -3802,7 +3828,7 @@ "widgets": [], "nodes": [ { - "id": 75, + "id": 352, "type": "SAM3_Detect", "pos": [ -1470, @@ -3932,7 +3958,7 @@ ] }, { - "id": 236, + "id": 353, "type": "CheckpointLoaderSimple", "pos": [ -1970, @@ -4009,7 +4035,7 @@ ] }, { - "id": 237, + "id": 354, "type": "CLIPTextEncode", "pos": [ -2000, @@ -4075,31 +4101,31 @@ "links": [ { "id": 237, - "origin_id": 236, + "origin_id": 353, "origin_slot": 0, - "target_id": 75, + "target_id": 352, "target_slot": 0, "type": "MODEL" }, { "id": 200, - "origin_id": 237, + "origin_id": 354, "origin_slot": 0, - "target_id": 75, + "target_id": 352, "target_slot": 2, "type": "CONDITIONING" }, { "id": 240, - "origin_id": 236, + "origin_id": 353, "origin_slot": 1, - "target_id": 237, + "target_id": 354, "target_slot": 0, "type": "CLIP" }, { "id": 231, - "origin_id": 75, + "origin_id": 352, "origin_slot": 0, "target_id": -20, "target_slot": 0, @@ -4107,7 +4133,7 @@ }, { "id": 232, - "origin_id": 75, + "origin_id": 352, "origin_slot": 1, "target_id": -20, "target_slot": 1, @@ -4117,7 +4143,7 @@ "id": 264, "origin_id": -10, "origin_slot": 0, - "target_id": 75, + "target_id": 352, "target_slot": 1, "type": "IMAGE" }, @@ -4125,7 +4151,7 @@ "id": 265, "origin_id": -10, "origin_slot": 1, - "target_id": 237, + "target_id": 354, "target_slot": 1, "type": "STRING" }, @@ -4133,7 +4159,7 @@ "id": 266, "origin_id": -10, "origin_slot": 2, - "target_id": 75, + "target_id": 352, "target_slot": 3, "type": "BOUNDING_BOX" }, @@ -4141,7 +4167,7 @@ "id": 267, "origin_id": -10, "origin_slot": 3, - "target_id": 75, + "target_id": 352, "target_slot": 4, "type": "STRING" }, @@ -4149,7 +4175,7 @@ "id": 268, "origin_id": -10, "origin_slot": 4, - "target_id": 75, + "target_id": 352, "target_slot": 5, "type": "STRING" }, @@ -4157,7 +4183,7 @@ "id": 269, "origin_id": -10, "origin_slot": 5, - "target_id": 75, + "target_id": 352, "target_slot": 6, "type": "FLOAT" }, @@ -4165,7 +4191,7 @@ "id": 270, "origin_id": -10, "origin_slot": 6, - "target_id": 75, + "target_id": 352, "target_slot": 7, "type": "INT" }, @@ -4173,7 +4199,7 @@ "id": 271, "origin_id": -10, "origin_slot": 7, - "target_id": 75, + "target_id": 352, "target_slot": 8, "type": "BOOLEAN" }, @@ -4181,7 +4207,7 @@ "id": 272, "origin_id": -10, "origin_slot": 8, - "target_id": 236, + "target_id": 353, "target_slot": 0, "type": "COMBO" } @@ -4193,4 +4219,4 @@ ] }, "extra": {} -} \ No newline at end of file +} diff --git a/blueprints/Video Segmentation (SAM3).json b/blueprints/Video Segmentation (SAM3).json index 4c7253869ba..ed76b7864e2 100644 --- a/blueprints/Video Segmentation (SAM3).json +++ b/blueprints/Video Segmentation (SAM3).json @@ -818,7 +818,7 @@ } ], "extra": {}, - "category": "Conditioning & Preprocessors/Segmentation & Mask", + "category": "Preprocessors/Segmentation", "description": "Segments video into temporally consistent masks using Meta SAM3 from text or interactive prompts." } ] diff --git a/blueprints/Video Upscale(GAN x4).json b/blueprints/Video Upscale(GAN x4).json index fc291ac41d2..86cd2f82111 100644 --- a/blueprints/Video Upscale(GAN x4).json +++ b/blueprints/Video Upscale(GAN x4).json @@ -412,7 +412,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Video generation and editing/Upscale", + "category": "Video Tools/Upscale", "description": "Upscales video to 4× resolution using a GAN-based upscaling model." } ] diff --git a/blueprints/Video to Pose Map (SDPose Multi-Person).json b/blueprints/Video to Pose Map (SDPose Multi-Person).json index 64ef6e52475..6f6d7ab146a 100644 --- a/blueprints/Video to Pose Map (SDPose Multi-Person).json +++ b/blueprints/Video to Pose Map (SDPose Multi-Person).json @@ -1314,7 +1314,7 @@ "extra": { "workflowRendererVersion": "LG" }, - "category": "Conditioning & Preprocessors/Pose", + "category": "Preprocessors/Pose", "description": "Extracts multi-person pose keypoints and skeleton frame sequences from video using SDPose with built-in person detection." } ] diff --git a/comfy/audio_encoders/audio_encoders.py b/comfy/audio_encoders/audio_encoders.py index 0de7584b048..457cf24f3ae 100644 --- a/comfy/audio_encoders/audio_encoders.py +++ b/comfy/audio_encoders/audio_encoders.py @@ -1,10 +1,13 @@ from .wav2vec2 import Wav2Vec2Model from .whisper import WhisperLargeV3 +from .sheetsage2 import SheetSage2 +from .sheetsage2_abc import events_to_abc import comfy.model_management import comfy.ops import comfy.utils import logging import torchaudio +import torch class AudioEncoderModel(): @@ -13,6 +16,9 @@ def __init__(self, config): offload_device = comfy.model_management.text_encoder_offload_device() self.dtype = comfy.model_management.text_encoder_dtype(self.load_device) model_type = config.pop("model_type") + self.model_sample_rate = config.pop("model_sample_rate", 16000) + if model_type == "sheetsage2": + self.dtype = torch.bfloat16 if comfy.model_management.should_use_bf16(self.load_device) else torch.float32 model_config = dict(config) model_config.update({ "dtype": self.dtype, @@ -24,9 +30,10 @@ def __init__(self, config): self.model = Wav2Vec2Model(**model_config) elif model_type == "whisper3": self.model = WhisperLargeV3(**model_config) + elif model_type == "sheetsage2": + self.model = SheetSage2(**model_config) self.model.eval() self.patcher = comfy.model_patcher.CoreModelPatcher(self.model, load_device=self.load_device, offload_device=offload_device) - self.model_sample_rate = 16000 comfy.model_management.archive_model_dtypes(self.model) def load_sd(self, sd): @@ -46,6 +53,17 @@ def encode_audio(self, audio, sample_rate): return outputs +class SheetSage2AudioEncoder(AudioEncoderModel): + def generate_abc(self, audio, sample_rate, melody_only=True): + audio = torchaudio.functional.resample(audio.float().mean(dim=1), sample_rate, self.model_sample_rate) + comfy.model_management.load_model_gpu(self.patcher) + scores = [] + for waveform in audio: + events = self.model.transcribe(waveform[None].to(self.load_device)) + scores.append(events_to_abc(events, waveform.shape[-1] / self.model_sample_rate, melody_only=melody_only)) + return scores + + def load_audio_encoder_from_sd(sd, prefix=""): sd = comfy.utils.state_dict_prefix_replace(sd, {"wav2vec2.": ""}) if "encoder.layer_norm.bias" in sd: #wav2vec2 @@ -79,10 +97,12 @@ def load_audio_encoder_from_sd(sd, prefix=""): config = { "model_type": "whisper3", } + elif "encoder.feature_extractor.mel_mean" in sd and "decoder.layernorm_embedding.weight" in sd and "layer_weight" in sd: + config = {"model_type": "sheetsage2", "model_sample_rate": 24000} else: raise RuntimeError("ERROR: audio encoder not supported.") - audio_encoder = AudioEncoderModel(config) + audio_encoder = SheetSage2AudioEncoder(config) if config["model_type"] == "sheetsage2" else AudioEncoderModel(config) m, u = audio_encoder.load_sd(sd) if len(m) > 0: logging.warning("missing audio encoder: {}".format(m)) diff --git a/comfy/audio_encoders/mert2.py b/comfy/audio_encoders/mert2.py new file mode 100644 index 00000000000..a12a6c05fba --- /dev/null +++ b/comfy/audio_encoders/mert2.py @@ -0,0 +1,183 @@ +"""MERT-v2 ConvNeXt/Conformer audio encoder.""" + +import torch +from torch import nn +from torch.nn import functional as F + +import comfy.ops +import comfy.quant_ops +from comfy.ldm.modules.attention import optimized_attention_for_device + + +class MelFrontend(nn.Module): + def __init__(self, device=None): + super().__init__() + self.register_buffer("mel_mean", torch.empty(128, device=device, dtype=torch.float32)) + self.register_buffer("mel_std", torch.empty(128, device=device, dtype=torch.float32)) + self.spectrogram = nn.Module() + self.spectrogram.register_buffer("window", torch.empty(2048, device=device, dtype=torch.float32)) + self.mel_scale = nn.Module() + self.mel_scale.register_buffer("fb", torch.empty(1025, 128, device=device, dtype=torch.float32)) + + def forward(self, waveform): + window = comfy.ops.cast_to_input(self.spectrogram.window, waveform) + spectrum = torch.stft(waveform, n_fft=2048, hop_length=240, win_length=2048, + window=window, return_complex=True).abs().square() + mel = spectrum.transpose(-1, -2) @ comfy.ops.cast_to_input(self.mel_scale.fb, waveform) + mel = 10.0 * mel.clamp_min(1e-10).log10() + mean = comfy.ops.cast_to_input(self.mel_mean, waveform) + std = comfy.ops.cast_to_input(self.mel_std, waveform) + return (mel[:, :-1] - mean) / std.clamp_min(1e-5) + + +class Transpose(nn.Module): + def forward(self, x): + return x.transpose(1, 2) + + +class GlobalResponseNorm(nn.Module): + def __init__(self, dim, device=None, dtype=None): + super().__init__() + self.weight = nn.Parameter(torch.empty(1, 1, dim, device=device, dtype=dtype)) + self.bias = nn.Parameter(torch.empty(1, 1, dim, device=device, dtype=dtype)) + + def forward(self, x): + magnitude = torch.linalg.vector_norm(x, dim=1, keepdim=True) + normalized = magnitude / (magnitude.mean(dim=-1, keepdim=True) + 1e-6) + weight = comfy.ops.cast_to_input(self.weight, x) + bias = comfy.ops.cast_to_input(self.bias, x) + return weight * (x * normalized) + bias + x + + +class ConvNextLayer(nn.Module): + def __init__(self, dim, device=None, dtype=None, operations=None): + super().__init__() + self.depthwise_block = nn.Sequential( + Transpose(), operations.Conv1d(dim, dim, 7, padding=3, groups=dim, device=device, dtype=dtype), Transpose(), + ) + self.pointwise_block = nn.Sequential( + operations.LayerNorm(dim, eps=1e-6, device=device, dtype=dtype), + operations.Linear(dim, 4 * dim, device=device, dtype=dtype), nn.GELU(), + GlobalResponseNorm(4 * dim, device=device, dtype=dtype), + operations.Linear(4 * dim, dim, device=device, dtype=dtype), + ) + + def forward(self, x): + return x + self.pointwise_block(self.depthwise_block(x)) + + +class ConvNextBlock(nn.Module): + def __init__(self, in_channels, out_channels, stride, depth, device=None, dtype=None, operations=None): + super().__init__() + self.resampling_layer = nn.Identity() + if in_channels != out_channels or stride > 1: + self.resampling_layer = nn.Sequential( + operations.LayerNorm(in_channels, eps=1e-6, device=device, dtype=dtype), Transpose(), + operations.Conv1d(in_channels, out_channels, 2, stride=stride, device=device, dtype=dtype), Transpose(), + ) + self.convnext_layers = nn.Sequential(*[ + ConvNextLayer(out_channels, device=device, dtype=dtype, operations=operations) for _ in range(depth) + ]) + + def forward(self, x): + return self.convnext_layers(self.resampling_layer(x)) + + +class Attention(nn.Module): + def __init__(self, dim, heads, device=None, dtype=None, operations=None): + super().__init__() + self.heads = heads + self.query_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.key_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.value_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.out_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + + def forward(self, x, positions, attention): + shape = (x.shape[0], x.shape[1], self.heads, -1) + q = self.query_proj(x).reshape(shape).transpose(1, 2) + k = self.key_proj(x).reshape(shape).transpose(1, 2) + v = self.value_proj(x).reshape(shape).transpose(1, 2) + q, k = comfy.quant_ops.ck.apply_rope_split_half(q, k, positions) + return self.out_proj(attention(q, k, v, self.heads, skip_reshape=True)) + + +class FeedForward(nn.Module): + def __init__(self, dim, intermediate, device=None, dtype=None, operations=None): + super().__init__() + self.w_1 = operations.Linear(dim, intermediate, device=device, dtype=dtype) + self.w_2 = operations.Linear(intermediate, dim, device=device, dtype=dtype) + + def forward(self, x): + return self.w_2(F.gelu(self.w_1(x))) + + +class ConvolutionModule(nn.Module): + def __init__(self, dim, device=None, dtype=None, operations=None): + super().__init__() + self.layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + self.conv_block = nn.Sequential( + Transpose(), operations.Conv1d(dim, dim * 2, 1, bias=False, device=device, dtype=dtype), nn.GLU(dim=1), + operations.Conv1d(dim, dim, 31, padding=15, groups=dim, bias=False, device=device, dtype=dtype), + nn.Sequential(Transpose(), operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype), Transpose()), + nn.GELU(), operations.Conv1d(dim, dim, 1, bias=False, device=device, dtype=dtype), Transpose(), + ) + + def forward(self, x): + return self.conv_block(self.layer_norm(x)) + + +class ConformerBlock(nn.Module): + def __init__(self, dim, intermediate, heads, device=None, dtype=None, operations=None): + super().__init__() + self.ffn1_layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + self.ffn1 = FeedForward(dim, intermediate, device=device, dtype=dtype, operations=operations) + self.attn_layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + self.attn = Attention(dim, heads, device=device, dtype=dtype, operations=operations) + self.conv_module = ConvolutionModule(dim, device=device, dtype=dtype, operations=operations) + self.ffn2_layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + self.ffn2 = FeedForward(dim, intermediate, device=device, dtype=dtype, operations=operations) + self.final_layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + + def forward(self, x, positions, attention): + x = x + 0.5 * self.ffn1(self.ffn1_layer_norm(x)) + x = x + self.attn(self.attn_layer_norm(x), positions, attention) + x = x + self.conv_module(x) + x = x + 0.5 * self.ffn2(self.ffn2_layer_norm(x)) + return self.final_layer_norm(x) + + +class MERT2(nn.Module): + def __init__(self, dim=1024, intermediate=4096, heads=16, layers=24, channels=(128, 512, 1024), + depths=(3, 4, 5), device=None, dtype=None, operations=None): + super().__init__() + self.head_dim = dim // heads + self.feature_extractor = MelFrontend(device=device) + channels = (128, *channels) + self.subsampling_module = nn.Sequential(*[ + ConvNextBlock(channels[i], channels[i + 1], (1, 2, 2)[i], depths[i], device=device, dtype=dtype, operations=operations) + for i in range(3) + ]) + self.layers = nn.ModuleList([ + ConformerBlock(dim, intermediate, heads, device=device, dtype=dtype, operations=operations) for _ in range(layers) + ]) + + def position_embeddings(self, x): + inverse = 1.0 / (10000 ** (torch.arange(0, self.head_dim, 2, device=x.device, dtype=torch.float32) / self.head_dim)) + # Match the released encoder's autocast outer product before sin/cos. + angles = torch.arange(x.shape[1], device=x.device, dtype=torch.float32).to(x.dtype)[:, None] * inverse.to(x.dtype) + cos, sin = angles.cos(), angles.sin() + return torch.stack((cos, -sin, sin, cos), dim=-1).reshape(1, 1, x.shape[1], -1, 2, 2).float() + + def forward(self, mel, layer_weight, output_hidden_states=False): + x = self.subsampling_module(mel) + weights = comfy.ops.cast_to_input(layer_weight, x).softmax(dim=0) + mixed = x * weights[0] + states = [x] if output_hidden_states else None + positions = self.position_embeddings(x) + attention = optimized_attention_for_device(x.device) + for weight, layer in zip(weights[1:], self.layers): + x = layer(x, positions, attention) + mixed = mixed + x * weight + if output_hidden_states: + states.append(x) + return mixed, states diff --git a/comfy/audio_encoders/sheetsage2.py b/comfy/audio_encoders/sheetsage2.py new file mode 100644 index 00000000000..fa75acf82d8 --- /dev/null +++ b/comfy/audio_encoders/sheetsage2.py @@ -0,0 +1,540 @@ +"""SheetSage2 audio-to-score generation with the released event vocabulary.""" + +import copy +import logging + +import numpy as np +import torch +from torch import nn +from torch.nn import functional as F + +import comfy.model_management +import comfy.model_prefetch +import comfy.ops +import comfy.utils +from comfy.audio_encoders.mert2 import MERT2 +from comfy.ldm.modules.attention import optimized_attention_for_device +from comfy.text_encoders.llama import FixedKV + + +class DecoderAttention(nn.Module): + def __init__(self, dim, heads, device=None, dtype=None, operations=None): + super().__init__() + self.heads = heads + self.q_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.k_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.v_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + self.out_proj = operations.Linear(dim, dim, device=device, dtype=dtype) + + def project(self, x, projection): + return projection(x).reshape(x.shape[0], x.shape[1], self.heads, -1).transpose(1, 2) + + def forward(self, x, attention, mask=None, cache=None, memory=None): + q = self.project(x, self.q_proj) + if memory is not None: + k, v = memory + else: + k, v = self.project(x, self.k_proj), self.project(x, self.v_proj) + length = x.shape[1] + if isinstance(cache, FixedKV): + key, value = k.transpose(1, 2), v.transpose(1, 2) + if length == 1 and cache.index > 0: + position = cache.position.view(-1, 1, 1, 1).expand_as(key) + cache.key.scatter_(1, position, key) + cache.value.scatter_(1, position, value) + valid = torch.arange(cache.key.shape[1], device=x.device)[None] < cache.seqlen[:, None] + mask = torch.zeros(valid.shape, device=x.device, dtype=x.dtype).masked_fill_(~valid, -torch.inf)[:, None, None] + out = attention(q, cache.key.transpose(1, 2), cache.value.transpose(1, 2), self.heads, mask=mask, skip_reshape=True) + return self.out_proj(out), cache + cache.key[:, :length].copy_(key) + cache.value[:, :length].copy_(value) + elif cache is not None: + key, value, index = cache + key[:, :, index:index + length].copy_(k) + value[:, :, index:index + length].copy_(v) + k, v = key[:, :, :index + length], value[:, :, :index + length] + cache = key, value, index + length + out = attention(q, k, v, self.heads, mask=mask, skip_reshape=True) + return self.out_proj(out), cache + + +class DecoderLayer(nn.Module): + def __init__(self, dim, intermediate, heads, device=None, dtype=None, operations=None): + super().__init__() + self.self_attn = DecoderAttention(dim, heads, device=device, dtype=dtype, operations=operations) + self.self_attn_layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + self.encoder_attn = DecoderAttention(dim, heads, device=device, dtype=dtype, operations=operations) + self.encoder_attn_layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + self.fc1 = operations.Linear(dim, intermediate, device=device, dtype=dtype) + self.fc2 = operations.Linear(intermediate, dim, device=device, dtype=dtype) + self.final_layer_norm = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + + def forward(self, x, attention, mask, cache): + self_cache, memory = cache + out, self_cache = self.self_attn(x, attention, mask=mask, cache=self_cache) + x = self.self_attn_layer_norm(x + out) + out, _ = self.encoder_attn(x, attention, memory=memory) + x = self.encoder_attn_layer_norm(x + out) + return self.final_layer_norm(x + self.fc2(F.gelu(self.fc1(x)))), (self_cache, memory) + + +class Decoder(nn.Module): + def __init__(self, dim, intermediate, heads, layers, max_tokens, device=None, dtype=None, operations=None): + super().__init__() + self.embed_positions = operations.Embedding(max_tokens + 2, dim, device=device, dtype=dtype) + self.layernorm_embedding = operations.LayerNorm(dim, eps=1e-5, device=device, dtype=dtype) + self.layers = nn.ModuleList([ + DecoderLayer(dim, intermediate, heads, device=device, dtype=dtype, operations=operations) for _ in range(layers) + ]) + + def forward(self, x, positions, cache, decode_buffer=None): + length = x.shape[1] + fixed = isinstance(cache[0][0], FixedKV) + index = cache[0][0].index if fixed else cache[0][0][2] + mask = None + if length > 1: + mask = torch.full((length, index + length), -torch.inf, device=x.device, dtype=x.dtype).triu_(index + 1) + x = self.layernorm_embedding(x + self.embed_positions(positions, out_dtype=x.dtype)) + graph = fixed and length == 1 and index > 0 and decode_buffer is not None + if graph: + decode_buffer.copy_(x) + x = decode_buffer + attention = optimized_attention_for_device(x.device, mask=mask is not None or graph, small_input=True) + queue = comfy.model_prefetch.make_prefetch_queue(list(self.layers), x.device, {"prefetch_dynamic_vbars": True}) + for i, layer in enumerate(self.layers): + if fixed: + cache[i][0].prepare(length) + + def core(): + nonlocal x + out, cache[i] = layer(x, attention, mask, cache[i]) + if graph: + x.copy_(out) + else: + x = out + + comfy.model_prefetch.prefetch_queue_pop(queue, x.device, layer, x.dtype, core=core, + enable_graph=graph, malloc_scope="block") + if fixed: + cache[i][0].advance(length) + comfy.model_prefetch.prefetch_queue_pop(queue, x.device, None, malloc_scope="block") + return x + + +class SheetSage2(nn.Module): + def __init__(self, dim=512, intermediate=2048, heads=8, layers=6, max_tokens=5120, + mert_config=None, device=None, dtype=None, operations=None): + super().__init__() + mert_config = {} if mert_config is None else mert_config + self.dtype = dtype + self.max_tokens = max_tokens + self.encoder = MERT2(**mert_config, device=device, dtype=dtype, operations=operations) + self.layer_weight = nn.Parameter(torch.empty(len(self.encoder.layers) + 1, device=device, dtype=dtype)) + self.encoder_projection = operations.Linear(mert_config.get("dim", 1024), dim, device=device, dtype=dtype) + self.tokenizer = ScoreTokenizer() + self.token_embedding = operations.Embedding(self.tokenizer.n_tokens, dim, device=device, dtype=dtype) + self.decoder = Decoder(dim, intermediate, heads, layers, max_tokens, device=device, dtype=dtype, operations=operations) + self.output_projection = operations.Linear(dim, self.tokenizer.n_tokens, bias=False, device=device, dtype=dtype) + + def get_dynamic_vram__units(self): + return list(self.decoder.layers), [] + + def encode(self, waveform, output_hidden_states=False): + # The released encoder attends to the entire 300-second window, including its padding. + waveform = F.pad(waveform, (0, max(0, 300 * 24000 - waveform.shape[-1]))) + mel = self.encoder.feature_extractor(waveform.float()).to(self.dtype) + mixed, states = self.encoder(mel, self.layer_weight, output_hidden_states=output_hidden_states) + return self.encoder_projection(mixed), states + + def forward(self, audio): + return self.encode(audio.mean(dim=1), output_hidden_states=True) + + def init_cache(self, memory): + batch, _, dim = memory.shape + fixed = comfy.model_prefetch.malloc_graph_enabled(memory.device) + cache = [] + for layer in self.decoder.layers: + heads = layer.self_attn.heads + shape = (batch, self.max_tokens, heads, dim // heads) if fixed else (batch, heads, self.max_tokens, dim // heads) + # Fixed attention includes masked future slots, whose values must remain finite. + key = torch.zeros(shape, device=memory.device, dtype=memory.dtype) if fixed else torch.empty(shape, device=memory.device, dtype=memory.dtype) + value = torch.zeros_like(key) if fixed else torch.empty_like(key) + if fixed: + self_cache = FixedKV(key, value, 0, torch.empty(batch, device=memory.device, dtype=torch.long), + torch.zeros(batch, device=memory.device, dtype=torch.int32)) + else: + self_cache = key, value, 0 + cross = layer.encoder_attn + cache.append((self_cache, (cross.project(memory, cross.k_proj), cross.project(memory, cross.v_proj)))) + return cache + + def decode(self, ids, positions, cache, decode_buffer=None): + x = self.token_embedding(ids, out_dtype=self.dtype) + return self.output_projection(self.decoder(x, positions, cache, decode_buffer=decode_buffer)[:, -1:]) + + def generate_tokens(self, memory, stop_seconds, prefix=None): + tokenizer = self.tokenizer + tokens = tokenizer.prompt_prefix() if prefix is None else list(prefix) + state = PromptGrammarState(tokenizer) + for token in tokens[tokens.index(tokenizer.out_token) + 1:]: + state.update(token) + cache = self.init_cache(memory) + device = memory.device + ids = torch.tensor([tokens], device=device, dtype=torch.long) + positions = torch.arange(2, len(tokens) + 2, device=device)[None] + logits = self.decode(ids, positions, cache) + ids = torch.empty((1, 1), device=device, dtype=torch.long) + positions = torch.full((1, 1), len(tokens) + 2, device=device, dtype=torch.long) + fixed = isinstance(cache[0][0], FixedKV) + # Captured decoder layers must share the same input address on every replay. + decode_buffer = memory.new_empty((memory.shape[0], 1, memory.shape[-1])) if fixed else None + progress = comfy.utils.ProgressBar(self.max_tokens - len(tokens)) + try: + for step in comfy.utils.model_trange(self.max_tokens - len(tokens), desc="SheetSage2 transcription", unit="token"): + comfy.model_management.throw_exception_if_processing_interrupted() + scores = logits[0, -1].float().masked_fill(~state.allowed(device), -torch.inf) + next_id = scores.argmax() + token = next_id.item() + tokens.append(token) + progress.update_absolute(step + 1) + if state.update(token): + break + if tokenizer.time_token_start <= token < tokenizer.time_token_end and tokenizer.token_to_time_id(token) / tokenizer.time_hz >= stop_seconds: + tokens.append(tokenizer.eos_token) + break + if len(tokens) == self.max_tokens: + logging.warning("SheetSage2 reached its token limit; the transcription may be incomplete.") + tokens.append(tokenizer.eos_token) + break + ids.copy_(next_id) + if fixed: + comfy.model_prefetch.malloc_graph_begin(device) + logits.copy_(self.decode(ids, positions, cache, decode_buffer=decode_buffer)) + if fixed: + comfy.model_prefetch.malloc_graph_end() + positions.add_(1) + finally: + comfy.model_prefetch.cleanup_prefetch_queues() + return tokens + + def transcribe(self, waveform): + duration = waveform.shape[-1] / 24000 + stitched = [] + for window in sliding_window_plan(duration): + comfy.model_management.throw_exception_if_processing_interrupted() + start = window["start"] + prefix, base = overlap_prefix(stitched, self.tokenizer, start, window["prefix_end"]) + if prefix is not None and len(prefix) >= self.max_tokens - 128: + raise ValueError("SheetSage2 overlap fills the token context; transcribe shorter audio sections.") + segment = waveform[:, round(start * 24000):round(window["end"] * 24000)] + memory, _ = self.encode(segment) + stop = window["generation_stop"] if window["generation_stop"] is not None else min(duration - start, 300.0) + tokens = self.generate_tokens(memory, stop, prefix) + decoded = self.tokenizer.decode_sequence(tokens) + lookup = event_time_map(decoded, 300.0) + stitched.extend(stitched_window_events(decoded, lookup, start, window["accept_start"], + window["accept_end"], duration, global_subbeat_base=base)) + stitched.sort(key=lambda event: (event["time"], event["global_subbeat"])) + return stitched + + +CHROMATIC_SHARPS = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") +STRUCTURE_LABELS = ( + "silence", "intro", "outro", "verse", "chorus", "bridge", "pre-chorus", "post-chorus", "interlude", + "fade-out", "loop", "rap", "preshot", "irregular", "instrumental", "intro and verse", "pre-chorus and chorus", + "verse and pre-chorus", "solo", "theme", "development", "variation", "pre-outro", +) +DURATION_TEMPLATES = (1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64, 96, 128, 192, 256, 384, 512, 768, 1024, 1536, 2048, 3072, 4096) +EVENT_FIELDS = ("timestamp", "rhythm", "structure", "key", "chord", "melody") +FIELD_TO_INDEX = {name: index for index, name in enumerate(EVENT_FIELDS)} + + +class ScoreTokenizer: + pad_token, sos_token, eos_token, out_token = 0, 1, 2, 3 + time_hz = 100 + + def __init__(self): + self.meter_pairs = tuple((numerator, denominator) for numerator in range(1, 33) for denominator in (1, 2, 4, 8, 16, 32)) + self.full_chord_labels = ["N"] + inversions = {"maj": ("/2", "/3", "/5"), "min": ("/2", "/b3", "/5"), + "maj7": ("/3", "/5", "/7"), "min7": ("/b3", "/5", "/b7"), "7": ("/3", "/5", "/b7")} + for quality in ("maj", "min", "dim", "aug", "maj7", "min7", "7", "hdim7", "dim7", "minmaj7", "sus2", "sus4", "sus4(b7)", "maj6", "min6"): + for root in CHROMATIC_SHARPS: + self.full_chord_labels.extend(f"{root}:{quality}{inversion}" for inversion in (*inversions.get(quality, ()), "")) + offset = 260 + self.ranges = [] + for name, count in (("subbeat_shift", 257), ("time", 30000), ("meter", 192), ("eighth_position", 256), + ("structure", len(STRUCTURE_LABELS)), ("key", 24), ("majmin_chord", 25), + ("full_chord", len(self.full_chord_labels)), ("pitch", 256), ("duration", len(DURATION_TEMPLATES))): + setattr(self, f"{name}_token_start", offset) + setattr(self, f"{name}_token_end", offset + count) + self.ranges.append((name.replace("full_chord", "chord_full").replace("majmin_chord", "chord_majmin"), offset, offset + count)) + offset += count + self.n_tokens = offset + + def prompt_prefix(self): + return [self.sos_token, 4, 5, 6, 7, 9, 11, self.out_token] + + def token_type(self, token): + for name, start, end in self.ranges: + if start <= token < end: + return name + return {0: "pad", 1: "sos", 2: "eos", 3: "out"}.get(token, "prompt") + + def token_to_time_id(self, token): + return token - self.time_token_start + + def decode_field(self, field, tokens): + if field == "timestamp": + return self.token_to_time_id(tokens[0]) / self.time_hz + if field == "rhythm": + rhythm = {} + for token in tokens: + if self.token_type(token) == "meter": + rhythm["meter"] = self.meter_pairs[token - self.meter_token_start] + else: + rhythm["eighth_position"] = token - self.eighth_position_token_start + return rhythm + if field == "structure": + return STRUCTURE_LABELS[tokens[0] - self.structure_token_start] + if field == "key": + index = tokens[0] - self.key_token_start + return f"{CHROMATIC_SHARPS[index % 12]}:{'minor' if index >= 12 else 'major'}" + if field == "chord": + return self.full_chord_labels[tokens[0] - self.full_chord_token_start] + notes, index = [], 0 + while index < len(tokens): + pitch = tokens[index] - self.pitch_token_start + index += 1 + duration = 0 + if index < len(tokens) and self.token_type(tokens[index]) == "duration": + duration = tokens[index] - self.duration_token_start + index += 1 + notes.append({"pitch": pitch % 128, "track": int(pitch >= 128), + "duration_bin": duration, "duration_steps": DURATION_TEMPLATES[duration]}) + return notes + + def decode_sequence(self, tokens): + fields = {"time": "timestamp", "meter": "rhythm", "eighth_position": "rhythm", "structure": "structure", + "key": "key", "chord_full": "chord", "pitch": "melody", "duration": "melody"} + position, subbeat, events = tokens.index(self.out_token) + 1, 0, [] + while position < len(tokens) and tokens[position] != self.eos_token: + if self.token_type(tokens[position]) != "subbeat_shift": + raise ValueError("SheetSage2 produced an event without a beat position.") + while position < len(tokens) and self.token_type(tokens[position]) == "subbeat_shift": + subbeat += tokens[position] - self.subbeat_shift_token_start + position += 1 + payload = {} + while position < len(tokens) and self.token_type(tokens[position]) not in ("subbeat_shift", "eos"): + token = tokens[position] + payload.setdefault(fields[self.token_type(token)], []).append(token) + position += 1 + if payload: + events.append({"subbeat": subbeat, "tokens_by_field": payload, + "values": {field: self.decode_field(field, values) for field, values in payload.items()}}) + return {"events": events} + + def encode_events(self, events): + tokens, previous = self.prompt_prefix(), 0 + for event in events: + shift = event["subbeat"] - previous + while shift > 256: + tokens.append(self.subbeat_shift_token_end - 1) + shift -= 256 + tokens.append(self.subbeat_shift_token_start + shift) + previous = event["subbeat"] + for field in EVENT_FIELDS: + tokens.extend(event["tokens_by_field"].get(field, ())) + return tokens + + +def sliding_window_plan(duration, window_seconds=300.0, overlap_seconds=200.0, lookahead_seconds=100.0): + hop = window_seconds - overlap_seconds + start, accepted = 0.0, 0.0 + result = [] + while True: + last = start + window_seconds >= duration - 1e-6 + accept_end = duration if last else start + window_seconds - lookahead_seconds + result.append(dict(start=start, end=min(duration, start + window_seconds), + accept_start=accepted, accept_end=accept_end, prefix_end=accepted, + generation_stop=None if last else window_seconds - lookahead_seconds)) + if last: + return result + accepted = accept_end + start = min(start + hop, duration - window_seconds) + + +def overlap_prefix(stitched, tokenizer, start, prefix_end): + events = [event for event in stitched if start - 1e-4 <= event["time"] < prefix_end - 1e-4] + events.sort(key=lambda event: (event["global_subbeat"], event["time"])) + first = next((i for i, event in enumerate(events) if "timestamp" in event["values"] or "rhythm" in event["values"]), None) + if first is None: + return None, 0 + events = copy.deepcopy(events[first:]) + base = events[0]["global_subbeat"] + context = {} + for event in stitched: + if event["time"] > events[0]["time"] + 1e-6: + continue + for field in ("structure", "key", "chord"): + if event["tokens_by_field"].get(field): + context[field] = event["tokens_by_field"][field] + for token in event["tokens_by_field"].get("rhythm", ()): + if tokenizer.token_type(token) == "meter": + context["meter"] = token + for event in events: + event["subbeat"] = max(0, event["global_subbeat"] - base) + if "timestamp" in event["tokens_by_field"]: + time_id = min(29999, max(0, round((event["time"] - start) * tokenizer.time_hz))) + event["tokens_by_field"]["timestamp"] = [tokenizer.time_token_start + time_id] + first_fields = events[0]["tokens_by_field"] + for field in ("structure", "key", "chord"): + if field not in first_fields and field in context: + first_fields[field] = list(context[field]) + rhythm = first_fields.get("rhythm", []) + if any(tokenizer.token_type(token) == "eighth_position" for token in rhythm) and not any(tokenizer.token_type(token) == "meter" for token in rhythm) and "meter" in context: + first_fields["rhythm"] = [context["meter"], *rhythm] + return tokenizer.encode_events(events), base + + +def event_time_map(decoded, target_seconds): + anchors = sorted({event["subbeat"]: event["values"]["timestamp"] for event in decoded["events"] if "timestamp" in event["values"]}.items()) + if not anchors: + return lambda step: min(target_seconds, max(0.0, step * 0.125)) + steps, times = np.asarray(anchors, dtype=np.float64).T + period = float(np.median(np.diff(times) / np.maximum(np.diff(steps), 1))) if len(anchors) > 1 else 0.125 + if not np.isfinite(period) or period <= 0: + period = 0.125 + + def lookup(step): + if step <= steps[0]: + return float(np.clip(times[0] + (step - steps[0]) * period, 0, target_seconds)) + if step >= steps[-1]: + return float(np.clip(times[-1] + (step - steps[-1]) * period, 0, target_seconds)) + return float(np.interp(step, steps, times)) + + return lookup + + +def stitched_window_events(decoded, lookup, start, accept_start, accept_end, duration, global_subbeat_base=0): + accepted = [] + for source in decoded["events"]: + time = start + lookup(source["subbeat"]) + if time < accept_start - 1e-4 or time >= accept_end - 1e-4 or time >= duration - 1e-4: + continue + event = copy.deepcopy(source) + event["time"] = float(np.clip(time, 0, duration)) + event["global_subbeat"] = global_subbeat_base + event["subbeat"] + if "timestamp" in event["values"]: + event["values"]["timestamp"] = event["time"] + for note in event["values"].get("melody", ()): + note["end_time"] = min(duration, max(event["time"] + 0.04, start + lookup(event["subbeat"] + note["duration_steps"]))) + accepted.append(event) + return accepted + + +class PromptGrammarState: + def __init__(self, tokenizer): + self.tokenizer = tokenizer + self.generated_events = 0 + self.in_shift = True + self.shift_run = 0 + self.payload_count = 0 + self.last_field_index = -1 + self.incomplete = None + + def _allow_field_starts(self, allowed): + tokenizer = self.tokenizer + if self.last_field_index < FIELD_TO_INDEX["timestamp"]: + allowed[tokenizer.time_token_start : tokenizer.time_token_end] = True + if self.last_field_index < FIELD_TO_INDEX["rhythm"]: + allowed[tokenizer.meter_token_start : tokenizer.meter_token_end] = True + allowed[ + tokenizer.eighth_position_token_start : tokenizer.eighth_position_token_end + ] = True + if self.last_field_index < FIELD_TO_INDEX["structure"]: + allowed[ + tokenizer.structure_token_start : tokenizer.structure_token_end + ] = True + if self.last_field_index < FIELD_TO_INDEX["key"]: + allowed[tokenizer.key_token_start : tokenizer.key_token_end] = True + if self.last_field_index < FIELD_TO_INDEX["chord"]: + allowed[ + tokenizer.full_chord_token_start : tokenizer.full_chord_token_end + ] = True + if self.last_field_index <= FIELD_TO_INDEX["melody"]: + allowed[tokenizer.pitch_token_start : tokenizer.pitch_token_end] = True + + def allowed(self, device): + tokenizer = self.tokenizer + allowed = torch.zeros(tokenizer.n_tokens, dtype=torch.bool, device=device) + can_end = self.payload_count > 0 + + if can_end: + allowed[tokenizer.eos_token] = True + if self.payload_count > 0 or self.in_shift: + if self.shift_run < 4: + allowed[ + tokenizer.subbeat_shift_token_start : tokenizer.subbeat_shift_token_end + ] = True + + if self.incomplete == "rhythm_after_meter": + allowed[ + tokenizer.eighth_position_token_start : tokenizer.eighth_position_token_end + ] = True + return allowed + + if self.incomplete == "melody_after_pitch": + allowed[tokenizer.duration_token_start : tokenizer.duration_token_end] = True + allowed[tokenizer.pitch_token_start : tokenizer.pitch_token_end] = True + return allowed + + self._allow_field_starts(allowed) + return allowed + + def update(self, token): + tokenizer = self.tokenizer + token = int(token) + token_type = tokenizer.token_type(token) + if token == tokenizer.eos_token: + return True + if token_type == "subbeat_shift": + if not self.in_shift and self.payload_count > 0: + self.generated_events += 1 + self.payload_count = 0 + self.last_field_index = -1 + self.incomplete = None + self.in_shift = True + self.shift_run += 1 + return False + + self.in_shift = False + self.shift_run = 0 + self.payload_count += 1 + if token_type == "time": + self.last_field_index = FIELD_TO_INDEX["timestamp"] + self.incomplete = None + elif token_type == "meter": + self.last_field_index = FIELD_TO_INDEX["rhythm"] + self.incomplete = "rhythm_after_meter" + elif token_type == "eighth_position": + self.last_field_index = FIELD_TO_INDEX["rhythm"] + self.incomplete = None + elif token_type == "structure": + self.last_field_index = FIELD_TO_INDEX["structure"] + self.incomplete = None + elif token_type == "key": + self.last_field_index = FIELD_TO_INDEX["key"] + self.incomplete = None + elif token_type == "chord_full": + self.last_field_index = FIELD_TO_INDEX["chord"] + self.incomplete = None + elif token_type == "pitch": + self.last_field_index = FIELD_TO_INDEX["melody"] + self.incomplete = "melody_after_pitch" + elif token_type == "duration": + self.last_field_index = FIELD_TO_INDEX["melody"] + self.incomplete = None + else: + raise RuntimeError(f"Unexpected prompt token type {token_type!r}") + return False diff --git a/comfy/audio_encoders/sheetsage2_abc.py b/comfy/audio_encoders/sheetsage2_abc.py new file mode 100644 index 00000000000..6aaaf130193 --- /dev/null +++ b/comfy/audio_encoders/sheetsage2_abc.py @@ -0,0 +1,921 @@ +"""SheetSage2 timed events to two-voice ABC, adapted from m-a-p/SheetSage2.""" + +from __future__ import annotations + +import math +import re +from collections import Counter +from dataclasses import dataclass, replace +from fractions import Fraction +from typing import Sequence + +import numpy as np + + +_SUPPORTED_DURATION_UNITS = frozenset({1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48}) +_MUSIC_ELEMENT_RE = re.compile( + r'"(?P[^"]*)"' + r"|\[K:(?P[^\]]+)\]" + r"|(?P[_=^]*[A-Ga-gz][,']*)(?P\d*)(?P-?)" +) + + +def interval_rows(events, field, duration): + rows = [[event["time"], duration, event["values"][field]] for event in events if field in event["values"]] + for previous, current in zip(rows, rows[1:]): + previous[1] = current[0] + return [row for row in rows if row[1] > row[0]] + + +def events_to_abc(events, duration, melody_only=True): + beats, meter = [], None + notes = {"Vocal": [], "Ins": []} + for event in events: + rhythm = event["values"].get("rhythm", {}) + meter = rhythm.get("meter", meter) + eighth = rhythm.get("eighth_position") + if eighth is not None and meter is not None: + position = Fraction(eighth * meter[1], 8) + if position.denominator != 1 or not 0 <= position < meter[0]: + raise BeatGridError(f"Beat position {eighth} is outside the decoded {meter[0]}/{meter[1]} grid.") + beats.append(BeatEvent(event["time"], int(position) + 1, meter[0], meter[1])) + for note in event["values"].get("melody", ()): + end = min(duration, note["end_time"]) + if end > event["time"]: + notes[VOICE_IDS[note["track"]]].append([event["time"], end, note["pitch"]]) + if len(beats) < 2: + raise BeatGridError("SheetSage2 needs at least two decoded beats to produce ABC.") + if any(current.time <= previous.time for previous, current in zip(beats, beats[1:])): + raise BeatGridError("SheetSage2 decoded beat times are not increasing.") + period = float(np.median(np.diff([beat.time for beat in beats[-9:]]))) + while beats[-1].time < duration - 1e-6: + previous = beats[-1] + beats.append(BeatEvent(previous.time + period, previous.beat_id % previous.declared_numerator + 1, + previous.declared_numerator, previous.denominator)) + intervals = {} + for field in ("key", "structure", "chord"): + intervals[field] = [[max(beats[0].time, start), min(beats[-1].time, end), value] + for start, end, value in interval_rows(events, field, duration) + if end > beats[0].time and start < beats[-1].time] + if not intervals["key"]: + raise AbcRebuildError("SheetSage2 did not decode a key for the ABC score.") + keys = [(start, end, key_symbol_to_abc(key)) for start, end, key in intervals["key"]] + measures, diagnostics = infer_measures(beats) + times, quarters, denominators = _build_grid(beats, measures) + voices = {} + for voice, track in notes.items(): + track.sort(key=lambda note: (note[0], note[2], note[1])) + for previous, current in zip(track, track[1:]): + if previous[1] > current[0] + 1e-6: + previous[1] = current[0] + voices[voice] = _notes_to_arr([note for note in track if note[1] > note[0] + 1e-6], times, voice) + score = RebuiltAbcScore( + beats=beats, measures=measures, subbeat_times=times, subbeat_quarters=quarters, + subbeat_denominators=denominators, + key_arr=_fill_intervals(keys, times, default=keys[0][2], dtype=" int: + return self.end_beat - self.start_beat + + @property + def start_t(self) -> int: + return self.start_beat * SUBBEAT_DIVISION + + @property + def end_t(self) -> int: + return self.end_beat * SUBBEAT_DIVISION + + @property + def abc_numerator(self) -> int: + return self.notated_numerator or self.numerator + + @property + def abc_denominator(self) -> int: + return self.notated_denominator or self.denominator + +@dataclass +class RebuiltAbcScore: + beats: list[BeatEvent] + measures: list[Measure] + subbeat_times: np.ndarray + subbeat_quarters: np.ndarray + subbeat_denominators: np.ndarray + key_arr: np.ndarray + chord_arr: np.ndarray + structure_events: list[tuple[int, str]] + voice_arrs: dict[str, np.ndarray] + diagnostics: list[str] + subbeat_div: int = SUBBEAT_DIVISION + +@dataclass +class MeasureGroup: + measures: list[Measure] + structure_labels: list[str] + meter_changed: bool + key_changed: bool + +_QUALITY_TO_ABC = { + "maj": "", + "min": "m", + "dim": "dim", + "aug": "aug", + "7": "7", + "maj7": "maj7", + "min7": "m7", + "dim7": "dim7", + "hdim7": "m7b5", + "sus4": "sus4", + "sus2": "sus2", + "maj6": "6", + "min6": "m6", + "sus4(b7)": "7sus4", + # abc2midi and SymMusic both accept the parenthesized major seventh. + # Common aliases such as mmaj7/mM7 trigger abc2midi diagnostics. + "minmaj7": "m(maj7)", +} + +_NATURAL_PITCH_CLASS = { + "C": 0, + "D": 2, + "E": 4, + "F": 5, + "G": 7, + "A": 9, + "B": 11, +} + +_LETTERS = "CDEFGAB" + +_SHARP_PITCH_NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B") + +_FLAT_PITCH_NAMES = ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B") + +_ROOT_RE = re.compile(r"^(?P[A-G])(?P#{0,2}|b{0,2})$") + +_BASS_DEGREE_RE = re.compile(r"^(?P#{0,2}|b{0,2})(?P[1-9]|1[0-3])$") + +_KEY_SIGNATURE_ACCIDENTALS = { + "C": 0, + "G": 1, + "D": 2, + "A": 3, + "E": 4, + "B": 5, + "F#": 6, + "C#": 7, + "F": -1, + "Bb": -2, + "Eb": -3, + "Ab": -4, + "Db": -5, + "Gb": -6, + "Cb": -7, + "Am": 0, + "Em": 1, + "Bm": 2, + "F#m": 3, + "C#m": 4, + "G#m": 5, + "D#m": 6, + "A#m": 7, + "Dm": -1, + "Gm": -2, + "Cm": -3, + "Fm": -4, + "Bbm": -5, + "Ebm": -6, + "Abm": -7, +} + +_KEY_RELATIVE_PITCH_NAMES = { + 7: ("B#", "C#", "C##", "D#", "D##", "E#", "F#", "F##", "G#", "G##", "A#", "B"), + 6: ("B#", "C#", "C##", "D#", "E", "E#", "F#", "F##", "G#", "G##", "A#", "B"), + 5: ("B#", "C#", "C##", "D#", "E", "E#", "F#", "F##", "G#", "A", "A#", "B"), + 4: ("B#", "C#", "D", "D#", "E", "E#", "F#", "F##", "G#", "A", "A#", "B"), + 3: ("B#", "C#", "D", "D#", "E", "E#", "F#", "G", "G#", "A", "A#", "B"), + 2: ("C", "C#", "D", "D#", "E", "E#", "F#", "G", "G#", "A", "A#", "B"), + 1: ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"), + 0: ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "Bb", "B"), + -1: ("C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"), + -2: ("C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"), + -3: ("C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"), + -4: ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"), + -5: ("C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "Cb"), + -6: ("C", "Db", "D", "Eb", "Fb", "F", "Gb", "G", "Ab", "A", "Bb", "Cb"), + -7: ("C", "Db", "D", "Eb", "Fb", "F", "Gb", "G", "Ab", "Bbb", "Bb", "Cb"), +} + +def _mode_with_first_tiebreak(values: Sequence[int]) -> int: + counts = Counter(values) + maximum = max(counts.values()) + return next(value for value in values if counts[value] == maximum) + +def infer_measures(beats: Sequence[BeatEvent]) -> tuple[list[Measure], list[str]]: + """Infer self-consistent measures from actual downbeat boundaries.""" + + downbeat_indices = [index for index, beat in enumerate(beats) if beat.beat_id == 1] + if not downbeat_indices: + raise BeatGridError("No downbeat (beat ID 1) exists in the beat lab") + spans: list[tuple[int, int, bool, bool]] = [] + if downbeat_indices[0] > 0: + spans.append((0, downbeat_indices[0], True, False)) + spans.extend( + (start, end, False, False) + for start, end in zip(downbeat_indices, downbeat_indices[1:]) + ) + if downbeat_indices[-1] < len(beats) - 1: + # Exported beat labs use their last row as the score end boundary. If + # that row is not a downbeat, the final bar is intentionally truncated. + spans.append((downbeat_indices[-1], len(beats) - 1, False, True)) + if not spans: + raise BeatGridError("No positive-length measure exists between downbeats") + + diagnostics = [] + measures = [] + for measure_index, (start, end, pickup, partial) in enumerate(spans): + events = list(beats[start:end]) + # Downbeat spans still define measures when the model skips a beat ID. + beat_count = len(events) + + denominators = [event.denominator for event in events] + denominator = _mode_with_first_tiebreak(denominators) + declared_numerators = [event.declared_numerator for event in events] + declared_numerator = _mode_with_first_tiebreak(declared_numerators) + numerator_conflict = any(value != beat_count for value in declared_numerators) + denominator_conflict = any(value != denominator for value in denominators) + pad_final_partial = ( + partial + and len(set(declared_numerators)) == 1 + and not denominator_conflict + and declared_numerator >= beat_count + ) + inferred = pickup or partial or numerator_conflict or denominator_conflict + if pad_final_partial and declared_numerator > beat_count: + diagnostics.append( + f"measure {measure_index}: padded final {beat_count}/{denominator} span " + f"to declared {declared_numerator}/{denominator} with trailing rest" + ) + elif numerator_conflict: + diagnostics.append( + f"measure {measure_index}: inferred {beat_count}/{denominator} from downbeat span; " + f"declared numerators were {declared_numerators}" + ) + if denominator_conflict: + diagnostics.append( + f"measure {measure_index}: placed denominator {denominator} at the measure boundary; " + f"row declarations were {denominators}" + ) + measures.append( + Measure( + index=measure_index, + start_beat=start, + end_beat=end, + numerator=beat_count, + denominator=denominator, + pickup=pickup, + partial=partial, + inferred=inferred, + notated_numerator=( + declared_numerator if pad_final_partial else beat_count + ), + ) + ) + if len(measures) >= 2: + first = measures[0] + following = measures[1] + first_duration = first.numerator / first.denominator + following_duration = ( + following.abc_numerator / following.abc_denominator + ) + if first_duration < following_duration: + measures[0] = replace( + first, + inferred=True, + notated_numerator=following.abc_numerator, + notated_denominator=following.abc_denominator, + pad_before=True, + ) + diagnostics.append( + f"measure 0: padded leading {first.numerator}/{first.denominator} span " + f"to {following.abc_numerator}/{following.abc_denominator} " + f"with preceding rest" + ) + return measures, diagnostics + +def _build_grid(beats: Sequence[BeatEvent], measures: Sequence[Measure]): + interval_denominators = np.zeros(len(beats) - 1, dtype=np.int32) + for measure in measures: + interval_denominators[measure.start_beat:measure.end_beat] = measure.denominator + if np.any(interval_denominators == 0): + raise BeatGridError("Downbeat spans do not cover every beat interval") + + subbeat_times = [] + subbeat_denominators = [] + quarter_positions = [0.0] + current_quarter = 0.0 + for index in range(len(beats) - 1): + start = beats[index].time + end = beats[index + 1].time + denominator = int(interval_denominators[index]) + times = np.linspace(start, end, SUBBEAT_DIVISION + 1)[:-1] + subbeat_times.extend(float(value) for value in times) + subbeat_denominators.extend([denominator] * SUBBEAT_DIVISION) + quarter_step = 4.0 / denominator / SUBBEAT_DIVISION + for _ in range(SUBBEAT_DIVISION): + current_quarter += quarter_step + quarter_positions.append(current_quarter) + subbeat_times.append(beats[-1].time) + subbeat_denominators.append(int(interval_denominators[-1])) + return ( + np.asarray(subbeat_times, dtype=np.float64), + np.asarray(quarter_positions, dtype=np.float64), + np.asarray(subbeat_denominators, dtype=np.int32), + ) + +def _subbeat_boundaries(subbeat_times: np.ndarray) -> np.ndarray: + return (subbeat_times[:-1] + subbeat_times[1:]) / 2 + +def _quantize_time(time: float, subbeat_times: np.ndarray) -> int: + return int(np.searchsorted(_subbeat_boundaries(subbeat_times), float(time))) + +def _fill_intervals(rows, subbeat_times, *, default, dtype): + result = np.full(len(subbeat_times), default, dtype=dtype) + for start, end, value in rows: + start_t = _quantize_time(start, subbeat_times) + end_t = _quantize_time(end, subbeat_times) + start_t = max(0, min(start_t, len(result) - 1)) + end_t = max(0, min(end_t, len(result) - 1)) + if start_t == end_t == len(result) - 1: + continue + if end_t <= start_t: + raise AbcRebuildError( + f"Interval {start:.6f}-{end:.6f} ({value}) is shorter than the ABC subbeat grid" + ) + result[start_t:end_t] = value + if len(result) > 1: + result[-1] = result[-2] + return result + +def _structure_events(rows, subbeat_times): + events = [] + for start, _, label in rows: + t = _quantize_time(start, subbeat_times) + t = max(0, min(t, len(subbeat_times) - 1)) + events.append((t, label)) + return events + +def _notes_to_arr(notes, subbeat_times, voice_id): + result = np.zeros(len(subbeat_times), dtype=np.int32) + boundaries = _subbeat_boundaries(subbeat_times) + for note in sorted(notes, key=lambda item: (item[0], item[1], item[2])): + start_t = int(np.searchsorted(boundaries, note[0])) + end_t = int(np.searchsorted(boundaries, note[1])) + start_t = max(0, min(start_t, len(result) - 1)) + end_t = max(0, min(end_t, len(result) - 1)) + if start_t == end_t == len(result) - 1: + continue + if end_t <= start_t: + raise MelodyVoiceError( + f"{voice_id}: note pitch={note[2]} at {note[0]:.6f}-{note[1]:.6f} " + "cannot be represented on the decoded subbeat grid" + ) + if np.any(result[start_t:end_t] != 0): + raise MelodyVoiceError( + f"{voice_id}: overlapping quantized melody notes at subbeats {start_t}:{end_t}" + ) + sustain = note[2] * 2 + 2 + result[start_t:end_t] = sustain + result[start_t] = sustain + 1 + return result + +def _pitch_class(root: str) -> tuple[int, str, str]: + match = _ROOT_RE.fullmatch(root) + if match is None: + raise ChordSymbolError(f"Invalid pitch spelling {root!r}") + letter = match.group("letter") + accidental = match.group("accidental") + offset = accidental.count("#") - accidental.count("b") + return (_NATURAL_PITCH_CLASS[letter] + offset) % 12, letter, accidental + +def portable_pitch_name(root: str, *, preserve_double: bool = False) -> str: + pitch_class, _, accidental = _pitch_class(root) + if preserve_double or len(accidental) <= 1: + return root + names = _SHARP_PITCH_NAMES if accidental.startswith("#") else _FLAT_PITCH_NAMES + return names[pitch_class] + +def _bass_degree_to_pitch(root: str, degree_text: str) -> str: + if _ROOT_RE.fullmatch(degree_text): + return portable_pitch_name(degree_text, preserve_double=True) + match = _BASS_DEGREE_RE.fullmatch(degree_text) + if match is None: + raise ChordSymbolError(f"Invalid chord bass degree {degree_text!r}") + root_pc, root_letter, root_accidental = _pitch_class(root) + degree = int(match.group("degree")) + degree_accidental = match.group("accidental") + scale_semitones = (0, 2, 4, 5, 7, 9, 11) + interval = scale_semitones[(degree - 1) % 7] + 12 * ((degree - 1) // 7) + interval += degree_accidental.count("#") - degree_accidental.count("b") + target_pc = (root_pc + interval) % 12 + + target_letter_index = (_LETTERS.index(root_letter) + degree - 1) % 7 + target_letter = _LETTERS[target_letter_index] + natural_pc = _NATURAL_PITCH_CLASS[target_letter] + difference = (target_pc - natural_pc + 6) % 12 - 6 + if difference in {-2, -1, 0, 1, 2}: + accidental = {-2: "bb", -1: "b", 0: "", 1: "#", 2: "##"}[difference] + return target_letter + accidental + names = _SHARP_PITCH_NAMES if "#" in (root_accidental + degree_accidental) else _FLAT_PITCH_NAMES + return names[target_pc] + +def chord_symbol_to_abc(chord: str) -> str | None: + chord = chord.strip() + if chord in NO_CHORDS: + return None + if ":" not in chord: + raise ChordSymbolError(f"Chord {chord!r} is missing the ':' quality separator") + root, descriptor = chord.split(":", 1) + if "/" in descriptor: + quality, bass_degree = descriptor.split("/", 1) + else: + quality, bass_degree = descriptor, None + if quality not in _QUALITY_TO_ABC: + raise ChordSymbolError( + f"Unsupported chord quality {quality!r} in {chord!r}; refusing to rewrite it as major" + ) + chord_root = portable_pitch_name(root, preserve_double=True) + text = chord_root + _QUALITY_TO_ABC[quality] + if bass_degree: + text += "/" + _bass_degree_to_pitch(root, bass_degree) + return text + +def key_symbol_to_abc(key: str) -> str: + key = key.strip() + if ":" in key: + root, mode = key.split(":", 1) + if mode not in {"major", "minor"}: + raise AbcRebuildError(f"Unsupported key mode {mode!r} in {key!r}") + elif key.endswith("m"): + root, mode = key[:-1], "minor" + else: + root, mode = key, "major" + root_pc, _, accidental = _pitch_class(root) + candidate = portable_pitch_name(root) + ("m" if mode == "minor" else "") + if candidate in _KEY_SIGNATURE_ACCIDENTALS: + return candidate + names = _FLAT_PITCH_NAMES if "b" in accidental else _SHARP_PITCH_NAMES + candidate = names[root_pc] + ("m" if mode == "minor" else "") + if candidate not in _KEY_SIGNATURE_ACCIDENTALS: + fallback_names = _SHARP_PITCH_NAMES if names is _FLAT_PITCH_NAMES else _FLAT_PITCH_NAMES + candidate = fallback_names[root_pc] + ("m" if mode == "minor" else "") + if candidate not in _KEY_SIGNATURE_ACCIDENTALS: + raise AbcRebuildError(f"Cannot encode portable ABC key for {key!r}") + return candidate + +def get_key_accidentals(key: str) -> list[int]: + try: + count = _KEY_SIGNATURE_ACCIDENTALS[key] + except KeyError as exc: + raise AbcRebuildError(f"Unsupported ABC key signature {key!r}") from exc + accidentals = [0] * 7 + order = "FCGDAEB" if count > 0 else "BEADGCF" + for letter in order[:abs(count)]: + accidentals[_LETTERS.index(letter)] = 1 if count > 0 else -1 + return accidentals + +def note_to_abc(note: int, key_accidentals: Sequence[int], measure_accidentals: dict) -> str: + """Use key-relative spelling and write only bar-state changes. + + The two target parsers propagate an accidental to the same note letter in + every octave until the next barline. ``measure_accidentals`` is therefore + keyed by letter and reset by the caller for every bar (and after an inline + key change). This preserves pitches across parsers while still omitting + repeated accidental marks. The key-relative spelling can use double + accidentals in remote keys; MIDI G is F## in G# minor, for example. + """ + + accidental_count = sum(key_accidentals) + try: + pitch_name = _KEY_RELATIVE_PITCH_NAMES[accidental_count][note % 12] + except KeyError as exc: + raise AbcRebuildError( + f"Unsupported key signature accidental count {accidental_count}" + ) from exc + letter = pitch_name[0] + accidental = pitch_name[1:] + accidental_number = {"": 0, "#": 1, "##": 2, "b": -1, "bb": -2}[accidental] + octave = (note - 60) // 12 + # Cb and B# cross the MIDI octave boundary even though their written note + # letter does not. + if note % 12 == 11 and accidental_number == -1: + octave += 1 + elif note % 12 == 0 and accidental_number == 1: + octave -= 1 + scale_index = _LETTERS.index(letter) + current_accidental = measure_accidentals.get( + scale_index, + key_accidentals[scale_index], + ) + accidental_text = "" + if current_accidental != accidental_number: + measure_accidentals[scale_index] = accidental_number + accidental_text = {-2: "__", -1: "_", 0: "=", 1: "^", 2: "^^"}[ + accidental_number + ] + + if octave > 0: + letter = letter.lower() + if octave > 1: + letter += "'" * (octave - 1) + elif octave < 0: + letter += "," * abs(octave) + return accidental_text + letter + +def abc_unit_denominator(score: RebuiltAbcScore) -> int: + values = [ + denominator * score.subbeat_div + for measure in score.measures + for denominator in (measure.denominator, measure.abc_denominator) + ] + denominator = math.lcm(*values) + if denominator > 1024: + raise AbcRebuildError(f"Required ABC unit length 1/{denominator} is unreasonably small") + return denominator + +def _measure_actual_units(measure: Measure, unit_denominator: int) -> int: + return measure.numerator * unit_denominator // measure.denominator + +def _measure_abc_units(measure: Measure, unit_denominator: int) -> int: + return measure.abc_numerator * unit_denominator // measure.abc_denominator + +def _measure_padding_units(measure: Measure, unit_denominator: int) -> int: + return ( + _measure_abc_units(measure, unit_denominator) + - _measure_actual_units(measure, unit_denominator) + ) + +def _duration_units(score: RebuiltAbcScore, start_t: int, end_t: int, unit_denominator: int) -> int: + units = 0 + for denominator in score.subbeat_denominators[start_t:end_t]: + divisor = int(denominator) * score.subbeat_div + if unit_denominator % divisor: + raise AbcRebuildError( + f"ABC L:1/{unit_denominator} cannot express a 1/{divisor} subbeat exactly" + ) + units += unit_denominator // divisor + return units + +def estimate_tempo(score: RebuiltAbcScore) -> float: + seconds = score.subbeat_times[-1] - score.subbeat_times[0] + quarter_notes = score.subbeat_quarters[-1] - score.subbeat_quarters[0] + if seconds <= 0 or quarter_notes <= 0: + raise AbcRebuildError("Cannot estimate tempo from a zero-duration score") + return float(quarter_notes / seconds * 60.0) + +def _continues_pitch(value: int, next_value: int) -> bool: + if value <= 0: + return False + pitch = value // 2 - 1 + return next_value == pitch * 2 + 2 + +def _same_note_segment(value: int, next_value: int) -> bool: + if value == 0: + return next_value == 0 + pitch = value // 2 - 1 + return next_value == pitch * 2 + 2 + +def _split_duration_units(duration: int) -> list[int]: + """Split a duration into values accepted by strict music parsers.""" + + if duration <= 0: + raise AbcRebuildError(f"Cannot serialize non-positive duration {duration}") + result = [] + remaining = int(duration) + while remaining: + if remaining in _SUPPORTED_DURATION_UNITS: + result.append(remaining) + break + candidates = [ + value + for value in _SUPPORTED_DURATION_UNITS + if value < remaining + ] + if not candidates: + raise AbcRebuildError( + f"Duration {duration} cannot be split into representable ABC values" + ) + chunk = max(candidates) + result.append(chunk) + remaining -= chunk + return result + +def _duration_text(duration: int) -> str: + return "" if duration == 1 else str(duration) + +def _render_duration_tokens( + prefix: str, + note_text: str, + duration: int, + *, + tie_out: bool, +) -> list[str]: + chunks = _split_duration_units(duration) + tokens = [] + for index, chunk in enumerate(chunks): + continues = note_text != "z" and ( + index + 1 < len(chunks) or tie_out + ) + tokens.append( + (prefix if index == 0 else "") + + note_text + + _duration_text(chunk) + + ("-" if continues else "") + ) + return tokens + +def _render_voice_measure( + score: RebuiltAbcScore, + voice_id: str, + measure: Measure, + unit_denominator: int, +) -> str: + voice = score.voice_arrs[voice_id] + show_chords = voice_id == "Vocal" + measure_accidentals = {} + current_key = str(score.key_arr[measure.start_t]) + key_accidentals = get_key_accidentals(current_key) + parts = [] + padding = _measure_padding_units(measure, unit_denominator) + if padding < 0: + raise AbcRebuildError( + f"Measure {measure.index}: notated meter is shorter than its decoded span" + ) + leading_padding = padding if measure.pad_before else 0 + trailing_padding = 0 if measure.pad_before else padding + t = measure.start_t + while t < measure.end_t: + change_points = [measure.end_t] + for probe in range(t + 1, measure.end_t): + if not _same_note_segment(int(voice[t]), int(voice[probe])): + change_points.append(probe) + break + for probe in range(t + 1, measure.end_t): + if score.key_arr[probe] != score.key_arr[probe - 1]: + change_points.append(probe) + break + if show_chords: + for probe in range(t + 1, measure.end_t): + if score.chord_arr[probe] != score.chord_arr[probe - 1]: + change_points.append(probe) + break + next_t = min(change_points) + + prefix = "" + key = str(score.key_arr[t]) + if t > measure.start_t and key != current_key: + current_key = key + key_accidentals = get_key_accidentals(current_key) + measure_accidentals = {} + prefix += f"[K:{current_key}]" + + if show_chords and (t == measure.start_t or score.chord_arr[t] != score.chord_arr[t - 1]): + chord = str(score.chord_arr[t]) + chord_text = chord_symbol_to_abc(chord) + if chord_text is not None: + prefix += f'"{chord_text}"' + + value = int(voice[t]) + if value == 0: + note_text = "z" + else: + note_text = note_to_abc(value // 2 - 1, key_accidentals, measure_accidentals) + duration = _duration_units(score, t, next_t, unit_denominator) + if t == measure.start_t and leading_padding: + if value == 0 and not prefix: + duration += leading_padding + else: + parts.extend( + _render_duration_tokens( + "", + "z", + leading_padding, + tie_out=False, + ) + ) + leading_padding = 0 + if value == 0 and next_t == measure.end_t and trailing_padding: + duration += trailing_padding + trailing_padding = 0 + if duration <= 0: + raise AbcRebuildError(f"Non-positive ABC duration at subbeats {t}:{next_t}") + tie_out = ( + value > 0 + and next_t < len(voice) + and _continues_pitch(value, int(voice[next_t])) + ) + parts.extend( + _render_duration_tokens( + prefix, + note_text, + duration, + tie_out=tie_out, + ) + ) + t = next_t + if leading_padding: + raise AbcRebuildError( + f"Measure {measure.index}: leading rest padding was not serialized" + ) + if trailing_padding: + parts.extend( + _render_duration_tokens( + "", + "z", + trailing_padding, + tie_out=False, + ) + ) + return "".join(parts) + +def _is_compressible_full_rest(rendered_measure: str) -> bool: + """Whether a rendered measure can be losslessly replaced by ABC ``Z``.""" + + cursor = 0 + saw_note = False + for match in _MUSIC_ELEMENT_RE.finditer(rendered_measure): + if rendered_measure[cursor:match.start()]: + return False + cursor = match.end() + if match.group("quoted") is not None or match.group("key") is not None: + return False + saw_note = True + if match.group("note") != "z" or match.group("tie"): + return False + return saw_note and cursor == len(rendered_measure) + +def _render_voice_group( + score: RebuiltAbcScore, + voice_id: str, + measures: list[Measure], + unit_denominator: int, +) -> str: + rendered = [ + _render_voice_measure( + score, + voice_id, + measure, + unit_denominator, + ) + for measure in measures + ] + parts = [] + index = 0 + while index < len(rendered): + if not _is_compressible_full_rest(rendered[index]): + parts.append(rendered[index] + "|") + index += 1 + continue + end = index + 1 + while ( + end < len(rendered) + and _is_compressible_full_rest(rendered[end]) + ): + end += 1 + count = end - index + parts.append("Z" + (str(count) if count > 1 else "") + "|") + index = end + return "".join(parts) + +def _sanitize_structure_label(value: str) -> str: + return " ".join(str(value).split()) + +def _measure_groups(score: RebuiltAbcScore) -> list[MeasureGroup]: + first_measure = score.measures[0] + active_meter = ( + first_measure.abc_numerator, + first_measure.abc_denominator, + ) + active_key = str(score.key_arr[first_measure.start_t]) + active_structure = "" + groups: list[MeasureGroup] = [] + + for measure in score.measures: + meter = (measure.abc_numerator, measure.abc_denominator) + key = str(score.key_arr[measure.start_t]) + meter_changed = meter != active_meter + key_changed = key != active_key + new_structure_labels = [] + for t, label in score.structure_events: + if not measure.start_t <= t < measure.end_t: + continue + clean_label = _sanitize_structure_label(label) + if clean_label and clean_label != active_structure: + new_structure_labels.append(clean_label) + active_structure = clean_label + + start_group = ( + not groups + or len(groups[-1].measures) >= 4 + or meter_changed + or key_changed + or bool(new_structure_labels) + ) + if start_group: + groups.append( + MeasureGroup( + measures=[measure], + structure_labels=new_structure_labels, + meter_changed=meter_changed, + key_changed=key_changed, + ) + ) + else: + groups[-1].measures.append(measure) + + active_meter = meter + active_key = str(score.key_arr[measure.end_t - 1]) + return groups + +def score_to_abc(score: RebuiltAbcScore) -> str: + unit_denominator = abc_unit_denominator(score) + first_measure = score.measures[0] + first_key = str(score.key_arr[first_measure.start_t]) + lines = [ + "X:1", + "T:", + f"M:{first_measure.abc_numerator}/{first_measure.abc_denominator}", + f"L:1/{unit_denominator}", + f"Q:1/4={int(round(estimate_tempo(score)))}", + 'V: Vocal clef=treble name="Vocal Melody" snm="Vocal"', + 'V: Ins clef=treble name="Ins Melody" snm="Inst."', + f"K:{first_key}", + ] + for group in _measure_groups(score): + lines.extend(f"% {label}" for label in group.structure_labels) + first_group_measure = group.measures[0] + for voice_id in VOICE_IDS: + lines.append(f"V: {voice_id}") + if group.meter_changed: + lines.append( + f"M:{first_group_measure.abc_numerator}/" + f"{first_group_measure.abc_denominator}" + ) + if group.key_changed: + lines.append( + f"K:{score.key_arr[first_group_measure.start_t]}" + ) + lines.append( + _render_voice_group( + score, + voice_id, + group.measures, + unit_denominator, + ) + ) + text = "\n".join(lines) + "\n" + return text diff --git a/comfy/cli_args.py b/comfy/cli_args.py index fdb6e329d2a..ba89699d6b8 100644 --- a/comfy/cli_args.py +++ b/comfy/cli_args.py @@ -117,7 +117,7 @@ def get_file_log_outputs(outputs): parser.add_argument("--oneapi-device-selector", type=str, default=None, metavar="SELECTOR_STRING", help="Sets the oneAPI device(s) this instance will use.") parser.add_argument("--supports-fp8-compute", action="store_true", help="ComfyUI will act like if the device supports fp8 compute.") parser.add_argument("--enable-triton-backend", action="store_true", help="ComfyUI will enable the use of Triton backend in comfy-kitchen. Is disabled at launch by default.") -parser.add_argument("--disable-triton-backend", action="store_true", help="Force-disable the comfy-kitchen Triton backend, overriding the automatic ROCm/AMD default and --enable-triton-backend.") +parser.add_argument("--disable-triton-backend", action="store_true", help="Force-disable the comfy-kitchen Triton backend, overriding --enable-triton-backend.") class LatentPreviewMethod(enum.Enum): NoPreviews = "none" @@ -179,7 +179,8 @@ def from_string(cls, value: str): parser.add_argument("--disable-async-offload", action="store_true", help="Disable async weight offloading.") parser.add_argument("--disable-dynamic-vram", action="store_true", help="Disable dynamic VRAM and use estimate based model loading.") parser.add_argument("--enable-dynamic-vram", action="store_true", help="Enable dynamic VRAM on systems where it's not enabled by default.") -parser.add_argument("--fast-disk", action="store_true", help="Prefer disk-backed dynamic loading and offload over unpinned RAM. Can be faster for users with fast NVME disks.") +parser.add_argument("--fast-disk", action="store_true", help="Force disk-backed dynamic loading and offload over unpinned RAM. Can be faster for users with fast NVME disks.") +parser.add_argument("--disable-fast-disk", action="store_true", help="Disable disk-backed dynamic loading and offload over unpinned RAM. Overrides --fast-disk.") parser.add_argument("--disable-cuda-graphs", action="store_true", help="Disable CUDA graphs.") parser.add_argument("--disable-comfy-compiler", action="store_true", help="Disable the Comfy model compiler, including its CUDA graph subfeature.") parser.add_argument("--assert-graph-breaks", action="store_true", help="Fail on Comfy model compiler graph breaks.") diff --git a/comfy/controlnet.py b/comfy/controlnet.py index 7e35fe027ff..8072304aab3 100644 --- a/comfy/controlnet.py +++ b/comfy/controlnet.py @@ -501,7 +501,7 @@ def controlnet_config(sd, model_options={}): operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, disable_fast_fp8=True) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) offload_device = comfy.model_management.unet_offload_device() return model_config, operations, load_device, unet_dtype, manual_cast_dtype, offload_device @@ -585,7 +585,7 @@ def load_controlnet_sd35(sd, model_options={}): operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, disable_fast_fp8=True) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) control_model = comfy.cldm.dit_embedder.ControlNetEmbedder(img_size=None, patch_size=2, @@ -683,7 +683,7 @@ def load_controlnet_qwen_fun(sd, model_options={}): operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, disable_fast_fp8=True) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) in_features = sd["control_img_in.weight"].shape[1] inner_dim = sd["control_img_in.weight"].shape[0] @@ -838,7 +838,7 @@ def load_controlnet_state_dict(state_dict, model=None, model_options={}): manual_cast_dtype = comfy.model_management.unet_manual_cast(unet_dtype, load_device) operations = model_options.get("custom_operations", None) if operations is None: - operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype) + operations = comfy.ops.pick_operations(unet_dtype, manual_cast_dtype, load_device=load_device, disable_fast_fp8=True) controlnet_config["operations"] = operations controlnet_config["dtype"] = unet_dtype diff --git a/comfy/latent_formats.py b/comfy/latent_formats.py index 6a60a7630a5..c40d8fb10cb 100644 --- a/comfy/latent_formats.py +++ b/comfy/latent_formats.py @@ -9,6 +9,7 @@ class LatentFormat: latent_rgb_factors_bias = None latent_rgb_factors_reshape = None taesd_decoder_name = None + compile_preview = False spacial_downscale_ratio = 8 temporal_downscale_ratio = 1 @@ -625,6 +626,7 @@ class MiniMaxH3Video(LatentFormat): temporal_downscale_ratio = 4 scale_factor = 1.0 taesd_decoder_name = "taeh3" + compile_preview = True latent_rgb_factors = [ [-0.018555, 0.024344, -0.017536], @@ -926,6 +928,59 @@ class HunyuanImage21(LatentFormat): latent_rgb_factors_bias = [0.0007, -0.0256, -0.0206] +class QwenImage21(LatentFormat): + latent_channels = 64 + latent_dimensions = 2 + spacial_downscale_ratio = 16 + + latent_rgb_factors = [ + [-0.0158, -0.0115, -0.0174], [ 0.0030, 0.0120, 0.0027], [ 0.0637, 0.0470, -0.0127], [ 0.0360, 0.0661, -0.0030], + [ 0.0159, 0.0181, 0.0082], [ 0.0132, 0.0326, 0.0169], [ 0.0191, 0.0261, 0.0136], [-0.0146, -0.0276, -0.0361], + [ 0.0187, -0.0024, -0.0072], [-0.1059, -0.0090, 0.0350], [-0.0195, -0.0226, -0.0138], [-0.0295, 0.0024, -0.0215], + [ 0.0191, -0.0393, -0.0001], [-0.0144, -0.0166, -0.0272], [ 0.0389, 0.0430, 0.0445], [-0.0153, -0.0336, 0.0031], + [ 0.0339, 0.0122, 0.0220], [-0.0136, -0.0078, -0.0120], [-0.0340, -0.0282, -0.0245], [-0.0133, -0.0176, -0.0133], + [ 0.0109, -0.0087, 0.0096], [-0.0010, 0.0044, 0.0016], [ 0.0301, 0.0053, 0.0361], [-0.0281, -0.0205, -0.0032], + [-0.0725, 0.0002, 0.0160], [-0.0036, 0.0158, 0.0807], [ 0.0087, 0.0040, -0.0053], [-0.0260, 0.0183, -0.0077], + [-0.0039, -0.0035, -0.0107], [-0.0026, 0.0172, 0.0237], [ 0.0088, 0.0078, 0.0078], [-0.0087, -0.0310, -0.0122], + [-0.0027, 0.0018, 0.0094], [-0.0064, 0.0292, -0.0256], [ 0.0594, 0.1049, 0.1180], [ 0.0103, -0.0103, -0.0026], + [-0.0091, 0.0025, -0.0015], [ 0.0178, 0.0243, 0.0292], [-0.0063, -0.0012, 0.0202], [ 0.0452, 0.0246, 0.0143], + [ 0.0149, 0.0270, 0.0052], [ 0.1484, 0.0801, 0.0804], [-0.0120, 0.0040, 0.0010], [ 0.0181, 0.0051, -0.0021], + [ 0.0132, 0.0050, 0.0019], [ 0.0291, 0.0020, 0.0092], [ 0.0066, -0.0410, -0.1314], [-0.1153, -0.0629, -0.0802], + [ 0.0258, 0.0378, 0.0298], [ 0.0375, 0.1139, 0.0468], [-0.0142, -0.0126, -0.0276], [ 0.0339, 0.0153, 0.0138], + [ 0.0346, 0.0211, 0.0267], [ 0.0369, -0.0431, -0.0993], [-0.0052, -0.0092, 0.0056], [-0.0279, 0.0410, -0.0357], + [ 0.0036, 0.0017, -0.0083], [-0.0441, -0.0367, -0.0454], [-0.0001, -0.0092, -0.0001], [-0.0222, -0.0183, -0.0051], + [ 0.0039, 0.0053, -0.0184], [-0.0094, -0.0075, -0.0143], [-0.0066, -0.0088, -0.0063], [ 0.0220, 0.0074, 0.0100], + ] + latent_rgb_factors_bias = [-0.1228, -0.1869, -0.3083] + + def __init__(self): + self.latents_mean = torch.tensor([ + 0.5126, 0.7721, -0.0631, 1.3506, -0.7855, -2.1025, -0.3458, 1.3722, + 1.8873, -1.7177, -0.6510, 0.2732, 0.7562, -0.6163, -1.0277, 3.8363, + 2.0210, 0.0472, 0.9320, 2.0087, 2.4954, -0.1391, -1.4249, 1.8464, + -0.5236, 1.2826, 3.7046, -1.3035, 2.7286, -1.4518, -1.9036, -1.9955, + -0.0342, -1.0265, -0.7636, 3.0555, 0.0746, -3.0751, -0.1076, 1.7376, + -1.0914, -1.9435, -0.2784, -1.3680, 0.4809, -0.4433, 0.3764, 0.5729, + -2.0595, 1.0960, -1.3260, -2.0211, -5.0179, 0.5275, 4.0162, 1.8505, + 0.3026, 1.9373, 1.4937, 0.2632, 0.5547, -1.7121, -0.1562, 0.0304, + ]).view(1, self.latent_channels, 1, 1) + self.latents_std = torch.tensor([ + 3.2001, 3.2936, 3.4321, 3.0091, 3.1061, 4.0379, 4.0705, 3.7910, + 3.0785, 3.6500, 3.9308, 3.0904, 2.8778, 3.7675, 3.7320, 5.0756, + 3.2864, 4.0397, 3.1317, 4.0443, 2.9249, 3.9454, 3.0988, 4.2489, + 3.4896, 3.8513, 3.9323, 3.4719, 3.7498, 4.2830, 3.5694, 4.2467, + 3.9037, 3.2947, 5.0770, 3.5075, 3.2700, 3.4767, 2.8063, 5.1125, + 3.5327, 4.7833, 3.1286, 4.1819, 3.8527, 3.8312, 3.5605, 4.3875, + 3.9624, 4.0168, 3.5643, 4.0550, 5.5614, 4.2963, 4.4080, 3.4959, + 3.8747, 3.7608, 3.5735, 3.1490, 3.7662, 3.6746, 3.4563, 3.8161, + ]).view(1, self.latent_channels, 1, 1) + + def process_in(self, latent): + return (latent - self.latents_mean.to(latent.device, latent.dtype)) / self.latents_std.to(latent.device, latent.dtype) + + def process_out(self, latent): + return latent * self.latents_std.to(latent.device, latent.dtype) + self.latents_mean.to(latent.device, latent.dtype) + class HunyuanImage21Refiner(LatentFormat): latent_channels = 64 latent_dimensions = 3 @@ -1022,6 +1077,12 @@ class ACEAudio15(LatentFormat): latent_dimensions = 1 temporal_downscale_ratio = 1764 +class YuE2(LatentFormat): + latent_channels = 64 + latent_dimensions = 1 + temporal_downscale_ratio = 1920 + + class MiniMaxMusic3(LatentFormat): latent_channels = 128 latent_dimensions = 1 diff --git a/comfy/ldm/audio/autoencoder.py b/comfy/ldm/audio/autoencoder.py index 78ed6ffa63a..3c7b419063b 100644 --- a/comfy/ldm/audio/autoencoder.py +++ b/comfy/ldm/audio/autoencoder.py @@ -157,7 +157,7 @@ def __init__(self, in_channels, out_channels, stride, use_snake=False, antialias else: upsample_layer = WNConvTranspose1d(in_channels=in_channels, out_channels=out_channels, - kernel_size=2*stride, stride=stride, padding=math.ceil(stride/2)) + kernel_size=2*stride, stride=stride, padding=math.ceil(stride/2), output_padding=stride % 2) self.layers = nn.Sequential( get_activation("snake" if use_snake else "elu", antialias=antialias_activation, channels=in_channels), @@ -261,16 +261,20 @@ def __init__(self, use_snake=True, antialias_activation=False, use_nearest_upsample=False, - final_tanh=False): + final_tanh=False, + sample_latent=True): super().__init__() self.encoder = OobleckEncoder(in_channels, channels, latent_dim * 2, c_mults, strides, use_snake, antialias_activation) self.decoder = OobleckDecoder(in_channels, channels, latent_dim, c_mults, strides, use_snake, antialias_activation, use_nearest_upsample=use_nearest_upsample, final_tanh=final_tanh) self.bottleneck = VAEBottleneck() + self.sample_latent = sample_latent def encode(self, x): - return self.bottleneck.encode(self.encoder(x)) + encoded = self.encoder(x) + if not self.sample_latent: + return encoded.chunk(2, dim=1)[0] + return self.bottleneck.encode(encoded) def decode(self, x): return self.decoder(self.bottleneck.decode(x)) - diff --git a/comfy/ldm/cosmos/predict2.py b/comfy/ldm/cosmos/predict2.py index d391d50b1e6..0f4648202a3 100644 --- a/comfy/ldm/cosmos/predict2.py +++ b/comfy/ldm/cosmos/predict2.py @@ -881,13 +881,6 @@ def _forward( t_embedding_B_T_D, adaln_lora_B_T_3D = self.t_embedder[1](self.t_embedder[0](timesteps_B_T).to(x_B_T_H_W_D.dtype)) t_embedding_B_T_D = self.t_embedding_norm(t_embedding_B_T_D) - # for logging purpose - affline_scale_log_info = {} - affline_scale_log_info["t_embedding_B_T_D"] = t_embedding_B_T_D.detach() - self.affline_scale_log_info = affline_scale_log_info - self.affline_emb = t_embedding_B_T_D - self.crossattn_emb = crossattn_emb - if extra_pos_emb_B_T_H_W_D_or_T_H_W_B_D is not None: assert ( x_B_T_H_W_D.shape == extra_pos_emb_B_T_H_W_D_or_T_H_W_B_D.shape diff --git a/comfy/ldm/lightricks/av_model.py b/comfy/ldm/lightricks/av_model.py index d253b01447c..baffc9ed2e3 100644 --- a/comfy/ldm/lightricks/av_model.py +++ b/comfy/ldm/lightricks/av_model.py @@ -938,7 +938,7 @@ def _process_transformer_blocks( stg_self_attn_blocks = transformer_options.get("stg_self_attn_blocks", ()) # Process transformer blocks - comfy.model_prefetch.malloc_graph_begin(self, vx.device) + comfy.model_prefetch.malloc_graph_begin(vx.device) for i, block in enumerate(self.transformer_blocks): comfy.model_prefetch.prefetch_queue_pop( prefetch_queue, vx.device, block, malloc_scope="block" diff --git a/comfy/ldm/minimax/model.py b/comfy/ldm/minimax/model.py index d7959027e2f..c4b7a68aa60 100644 --- a/comfy/ldm/minimax/model.py +++ b/comfy/ldm/minimax/model.py @@ -25,7 +25,7 @@ import comfy.ops import comfy.patcher_extension import comfy.quant_ops -from comfy.ldm.modules.attention import AttentionTensorContainer, optimized_attention +from comfy.ldm.modules.attention import ComfyAttention, AttentionTensorContainer, optimized_attention FRAME_PER_TOKEN = (1, 4, 4, 4, 4) FRAME_RESCALE = 5.0 / 3.0 @@ -158,6 +158,7 @@ def rope_rotation_table(angles, dtype): class Attention(nn.Module): def __init__(self, hidden, heads, head_dim, eps, gate_compress=False, dtype=None, device=None, operations=None): super().__init__() + self.comfy_attention = ComfyAttention() self.heads = heads self.head_dim = head_dim inner = heads * head_dim @@ -196,7 +197,7 @@ def forward(self, x, rope_freqs=None, transformer_options={}): q = AttentionTensorContainer(q.transpose(0, 1).unsqueeze(0)) k = AttentionTensorContainer(k.transpose(0, 1).unsqueeze(0)) v = AttentionTensorContainer(v.transpose(0, 1).unsqueeze(0)) - out = optimized_attention(q, k, v, self.heads, mask=None, skip_reshape=True, transformer_options=transformer_options) + out = optimized_attention(q, k, v, self.heads, preferred_attention=self.comfy_attention, mask=None, skip_reshape=True, transformer_options=transformer_options) return self.out_proj(out.squeeze(0)) @@ -573,7 +574,7 @@ def forward(self, x, timestep, context, transformer_options={}, minimax_payload= compile_allocations = comfy.model_prefetch.malloc_graph_enabled(x[0].device) if compile_allocations: out = [torch.empty_like(x[0]), torch.empty_like(x[1])] - comfy.model_prefetch.malloc_graph_begin(self, x[0].device) + comfy.model_prefetch.malloc_graph_begin(x[0].device) graph_out = comfy.patcher_extension.WrapperExecutor.new_class_executor( self._forward, self, @@ -588,6 +589,12 @@ def forward(self, x, timestep, context, transformer_options={}, minimax_payload= else: out = graph_out + # Masked rows predict at mask * sigma; scale their velocity to match the outer x0 conversion. + if denoise_mask is not None: + out[0] = out[0] * denoise_mask + if audio_denoise_mask is not None: + out[1] = out[1] * audio_denoise_mask + if scale != 1.0: # d/d(sigma_v) of the carried variable out[1] = ((1.0 - scale) * (audio_src * carry) diff --git a/comfy/ldm/minimax/vae.py b/comfy/ldm/minimax/vae.py index e1c146ec412..36d9abb5762 100644 --- a/comfy/ldm/minimax/vae.py +++ b/comfy/ldm/minimax/vae.py @@ -10,7 +10,7 @@ import comfy.ops import comfy.quant_ops import comfy.rmsnorm -from comfy.ldm.modules.attention import optimized_attention +from comfy.ldm.modules.attention import COMFY_KITCHEN_INT8_ATTENTION_IS_AVAILABLE, optimized_attention ops = comfy.ops.disable_weight_init @@ -38,22 +38,85 @@ # 3D causal CNN encoder +def _kitchen_ndhwc(x): + # NDHWC when the kitchen pad kernel can feed it and the conv consumes it natively: + # cuDNN does, MIOpen converts back and loses more than the fused pad saves + ck = getattr(comfy.quant_ops, "ck", None) + return (ck is not None and hasattr(ck, "group_norm_silu_pad3d") and torch.version.hip is None + and x.is_cuda and x.dtype in (torch.float16, torch.bfloat16)) + + +def _fused_norm_pad(x, norm, spatial_pad, front): + # per-frame GroupNorm + SiLU + padding in one kitchen pass, NDHWC out + if not _kitchen_ndhwc(x) or x.shape[1] % 8: + return None + ck = comfy.quant_ops.ck + if norm is None: + return ck.group_norm_silu_pad3d(x, None, None, 1, 0.0, (*spatial_pad, front), silu=False) + # cast_bias_weight: works for offloaded (dynamic VRAM) layers + weight, bias, offload_stream = comfy.ops.cast_bias_weight(norm, x, offloadable=True) + try: + return ck.group_norm_silu_pad3d(x, weight, bias, norm.num_groups, norm.eps, (*spatial_pad, front), silu=True) + finally: + comfy.ops.uncast_bias_weight(norm, weight, bias, offload_stream) + + +def _fp16_accum_conv(conv, x, weight, bias, residual): + # kitchen fp16-accumulate conv, same opt-in as the fp16 GEMMs + ck = comfy.quant_ops.ck + if not hasattr(ck, "fp16_conv3d") or not comfy.ops._fp16_linear_wanted(x): + return None + return ck.fp16_conv3d(x, weight, bias, residual, conv.stride) + + class CausalConv3d(ops.Conv3d): # Reflect spatial padding, causal (zeros, front-only) temporal padding. def __init__(self, in_channels, out_channels, kernel_size, stride=1, padding=0): super().__init__(in_channels, out_channels, kernel_size=kernel_size, stride=stride) self.causal_padding = (padding,) * 3 if isinstance(padding, int) else tuple(padding) - def forward(self, x): - if sum(self.causal_padding) == 0: - return super().forward(x) - - x = F.pad(x, (self.causal_padding[2], self.causal_padding[2], self.causal_padding[1], self.causal_padding[1], 0, 0), mode="reflect") - if x.shape[2] == 1: + def forward(self, x, pre_norm=None, spatial_pad=None, residual=None): + # pre_norm, spatial_pad and residual fold into the kitchen pad pass and conv epilogue + pad_t, pad_h, pad_w = self.causal_padding + if spatial_pad is None: + spatial_pad = (pad_w, pad_w, pad_h, pad_h) + front = 0 if x.shape[2] == 1 else pad_t * 2 + + ndhwc = _kitchen_ndhwc(x) + if ndhwc and self.weight.is_cuda and not self.weight.is_contiguous(memory_format=torch.channels_last_3d): + self.weight.data = self.weight.data.contiguous(memory_format=torch.channels_last_3d) + + if pre_norm is not None or front or any(spatial_pad): # the 1x1 shortcut has nothing to fold + fused = _fused_norm_pad(x, pre_norm, spatial_pad, front) + if fused is not None: + x = fused + else: + if pre_norm is not None: + x = F.silu(pre_norm(x), inplace=True) + if any(spatial_pad): + x = F.pad(x, (*spatial_pad, 0, 0), mode="reflect") + if front: + x = F.pad(x, (0, 0, 0, 0, front, 0), mode="constant") + + if x.shape[2] == 1 and pad_t: # single frame: the causal front padding is all zeros truncate the temporal taps instead of convolving zero frames - return super().forward(x, autopad="causal_zero") - x = F.pad(x, (0, 0, 0, 0, self.causal_padding[0] * 2, 0), mode="constant") - return super().forward(x) + out = super().forward(x, autopad="causal_zero") + elif ndhwc: + # cast_bias_weight handles offloaded layers; channels_last keeps cuDNN's output NDHWC + weight, bias, offload_stream = comfy.ops.cast_bias_weight(self, x, offloadable=True) + try: + weight_cl = weight.contiguous(memory_format=torch.channels_last_3d) + out = _fp16_accum_conv(self, x, weight_cl, bias, residual) + if out is not None: + return out + out = F.conv3d(x, weight_cl, bias, self.stride) + finally: + comfy.ops.uncast_bias_weight(self, weight, bias, offload_stream) + else: + out = super().forward(x) + if residual is not None: + out += residual + return out class TemporalIsolatedGroupNorm(ops.GroupNorm): @@ -85,7 +148,7 @@ def __init__(self, in_channels, out_channels, time_stride=1, space_stride=2): def forward(self, x): if self.space_stride == 2: - x = F.pad(x, (0, 1, 0, 1, 0, 0), mode="reflect") + return self.conv(x, spatial_pad=(0, 1, 0, 1)) return self.conv(x) @@ -104,11 +167,10 @@ def __init__(self, in_channels, out_channels=None): self.nin_shortcut = CausalConv3d(in_channels, out_channels, kernel_size=1) def forward(self, x): - h = self.conv1(F.silu(self.norm1(x), inplace=True)) - h = self.conv2(F.silu(self.norm2(h), inplace=True)) + h = self.conv1(x, pre_norm=self.norm1) if self.in_channels != self.out_channels: x = self.nin_shortcut(x) - return h.add_(x) + return self.conv2(h, pre_norm=self.norm2, residual=x) class EncoderFCN3D(nn.Module): @@ -160,8 +222,7 @@ def forward(self, x): h = self.down[i_level].block[i_block](h) if hasattr(self.down[i_level], "downsample"): h = self.down[i_level].downsample(h) - h = F.silu(self.norm_out(h)) - return self.conv_out(h) + return self.conv_out(h, pre_norm=self.norm_out) # ViT3D decoder @@ -206,9 +267,11 @@ def __init__(self, dim, mult=4, bias=True, operations=ops): self.w1 = operations.Linear(dim, inner_dim * 2, bias=bias) self.w2 = operations.Linear(inner_dim, dim, bias=bias) - def forward(self, x): - gate, x = self.w1(x).chunk(2, dim=-1) - return self.w2(F.silu(gate).mul_(x)) + def forward(self, x, pre_norm, residual, residual_scale): + # norm, gated silu and residual addcmul fold into the INT8 kernels + h = comfy.ops.linear_input_act(self.w1, x, "rms_norm", pre_norm.weight, pre_norm.eps) + return comfy.ops.linear_input_act( + self.w2, h, "swiglu", residual=residual, residual_scale=residual_scale) class Attention(nn.Module): @@ -219,27 +282,39 @@ def __init__(self, heads, dim_head, bias=True, eps=1e-5, operations=ops): inner_dim = dim_head * heads self.norm_q = ops.RMSNorm(dim_head, eps=eps, elementwise_affine=False) self.norm_k = ops.RMSNorm(dim_head, eps=eps, elementwise_affine=False) + # unit scale for the fused rms+rope kernel; the qk norms are not affine + self.register_buffer("qk_norm_scale", torch.ones(dim_head), persistent=False) self.to_qkv = operations.Linear(inner_dim, inner_dim * 3, bias=bias) self.to_out = operations.Linear(inner_dim, inner_dim, bias=bias) - def forward(self, x, rotary_pos_emb=None): + def forward(self, x, rotary_pos_emb, pre_norm, residual, residual_scale): batch_size, seq_len, _ = x.shape - qkv = self.to_qkv(x) + qkv = comfy.ops.linear_input_act(self.to_qkv, x, "rms_norm", pre_norm.weight, pre_norm.eps) qkv = qkv.view(batch_size, seq_len, -1, 3 * self.dim_head) query, key, value = torch.chunk(qkv, 3, dim=-1) - query = comfy.rmsnorm.rms_norm(query, self.norm_q.weight, self.norm_q.eps) - key = comfy.rmsnorm.rms_norm(key, self.norm_k.weight, self.norm_k.eps) - if rotary_pos_emb is not None: - rot = rotary_pos_emb.shape[-3] * 2 - query[..., :rot], key[..., :rot] = comfy.quant_ops.ck.apply_rope_split_half( - query[..., :rot], key[..., :rot], rotary_pos_emb) - - out = optimized_attention(query.transpose(1, 2), key.transpose(1, 2), value.transpose(1, 2), - self.heads, skip_reshape=True).nan_to_num_(0.0) - return self.to_out(out) + # fused per-head RMSNorm + partial split-half rope, in place when autograd is off + rms_rope = (comfy.quant_ops.ck.rms_rope_split_half if torch.is_grad_enabled() + else comfy.quant_ops.ck.rms_rope_split_half_) + query, key = rms_rope( + query, key, rotary_pos_emb, self.qk_norm_scale, + epsilon=self.norm_q.eps, rot_dim=rotary_pos_emb.shape[-3] * 2) + else: + query = comfy.rmsnorm.rms_norm(query, self.norm_q.weight, self.norm_q.eps) + key = comfy.rmsnorm.rms_norm(key, self.norm_k.weight, self.norm_k.eps) + + query, key, value = (t.transpose(1, 2) for t in (query, key, value)) + # an int8 decoder already accepts int8 numerics; fp16 keeps exact flash + if isinstance(self.to_qkv.weight, comfy.ops.QuantizedTensor) and COMFY_KITCHEN_INT8_ATTENTION_IS_AVAILABLE: + out = comfy.quant_ops.ck.int8_attention(query, key, value) + out = out.transpose(1, 2).reshape(batch_size, seq_len, -1) + else: + out = optimized_attention(query, key, value, self.heads, skip_reshape=True) + return comfy.ops.linear_input_act( + self.to_out, torch.nan_to_num(out), None, + residual=residual, residual_scale=residual_scale) class TransformerBlock(nn.Module): @@ -253,9 +328,17 @@ def __init__(self, heads, dim_head, bias=True, eps=1e-5, operations=ops): self.ff = FeedForward(dim=dim, bias=bias, operations=operations) self.scale2 = nn.Parameter(torch.empty(dim)) + def _residual_scale(self, scale, x): + # cast_to_input always copies, defeating the kernels' identity-keyed caches + if scale.dtype == x.dtype and scale.device == x.device: + return scale + return comfy.ops.cast_to_input(scale, x) + def forward(self, x, rotary_pos_emb=None): - x = x.addcmul_(self.attn(comfy.rmsnorm.rms_norm(x, self.norm1.weight, self.norm1.eps), rotary_pos_emb), comfy.ops.cast_to_input(self.scale1, x)) - return x.addcmul_(self.ff(comfy.rmsnorm.rms_norm(x, self.norm2.weight, self.norm2.eps)), comfy.ops.cast_to_input(self.scale2, x)) + x = self.attn(x, rotary_pos_emb, pre_norm=self.norm1, + residual=x, residual_scale=self._residual_scale(self.scale1, x)) + return self.ff(x, pre_norm=self.norm2, + residual=x, residual_scale=self._residual_scale(self.scale2, x)) class ViT3DDecoder(nn.Module): @@ -502,6 +585,15 @@ def tiled_encode(self, x): result_rows.append(torch.cat(result_row, dim=-1)) return torch.cat(result_rows, dim=-2) + def _decode_tile_row(self, z_row, x_idx, x_len): + # a few tiles per decoder call (~7%); each extra tile holds ~100 MB of activations + free = comfy.model_management.get_free_memory(z_row.device) + batch = int(max(1, min(4, free // (128 * 2**20 * z_row.shape[0])))) + slices = [z_row[..., j_pos // self.vae_ratio:(j_pos + j_len) // self.vae_ratio] for j_pos, j_len in zip(x_idx, x_len)] + for k in range(0, len(slices), batch): + group = slices[k:k + batch] + yield from self._decode_pixels(torch.cat(group)).chunk(len(group)) + def tiled_decode(self, z): height, width = z.shape[-2] * self.vae_ratio, z.shape[-1] * self.vae_ratio y_idx, y_len, y_overlap = self.split_tiles(height) @@ -513,12 +605,13 @@ def tiled_decode(self, z): out_y = 0 for i, (i_pos, i_len) in enumerate(zip(y_idx, y_len)): zi, zl = i_pos // self.vae_ratio, i_len // self.vae_ratio + tiles = self._decode_tile_row(z[..., zi:zi + zl, :], x_idx, x_len) new_tails = [] left_tail = None out_x = 0 - for j, (j_pos, j_len) in enumerate(zip(x_idx, x_len)): - zj, zw = j_pos // self.vae_ratio, j_len // self.vae_ratio - tile = self._decode_pixels(z[..., zi:zi + zl, zj:zj + zw]) + # enumerate would retain the previous tile while the next batch decodes. + for j in range(len(x_idx)): + tile = next(tiles) if i < len(y_idx) - 1: new_tails.append(tile[..., -y_overlap[i]:, :].clone()) next_left_tail = tile[..., :, -x_overlap[j]:].clone() if j < len(x_idx) - 1 else None @@ -534,9 +627,11 @@ def tiled_decode(self, z): if canvas is None: canvas = torch.empty(*tile.shape[:-2], height, width, dtype=tile.dtype, device=tile.device) canvas[..., out_y:out_y + tile.shape[-2], out_x:out_x + tile.shape[-1]].copy_(tile) + tile_height = tile.shape[-2] out_x += tile.shape[-1] + del tile row_tails = new_tails - out_y += tile.shape[-2] + out_y += tile_height return canvas # temporal chunking @@ -664,7 +759,7 @@ def write_part(part): write_part(dec_overlap) dec_overlap = None - del clip_dec, clip_z + del clip_dec, clip_z, clip_dec_chunk return dec diff --git a/comfy/ldm/minimax_music/ar.py b/comfy/ldm/minimax_music/ar.py index 78a4c7c86a4..2a702c5272b 100644 --- a/comfy/ldm/minimax_music/ar.py +++ b/comfy/ldm/minimax_music/ar.py @@ -261,6 +261,9 @@ def generate(self, input_ids, seed, max_audio_frames, device, cfg_scale=CFG_SCAL generator = torch.Generator(device=device).manual_seed(derive_seed(seed, "ar")) decoder = self.model.audio_decoder + decode_buffers = None + if self.model.graph_dynamic_vbar_blocks and comfy.model_prefetch.malloc_graph_enabled(device) and not comfy.model_management.args.disable_cuda_graphs: + decode_buffers = self.model.init_decode_buffers(last_hidden.shape[0], device, execution_dtype) depth_io = { "hidden": torch.empty_like(last_hidden), "c0": torch.empty((last_hidden.shape[0],), dtype=torch.long, device=device), @@ -297,7 +300,7 @@ def generate(self, input_ids, seed, max_audio_frames, device, cfg_scale=CFG_SCAL break if frame_index: - comfy.model_prefetch.malloc_graph_begin(self, device) + comfy.model_prefetch.malloc_graph_begin(device) c0, code_or_stop, stop_token = self._sample_c0(last_hidden, cfg_scale, top_k, generator, vocab_mask) if pending_code is None: pending_code = torch.empty_like(code_or_stop, device="cpu", pin_memory=cuda_device) @@ -338,7 +341,7 @@ def depth_core(): pending_hidden_valid = True feedback = self._embed_audio_frame(feedback_codes, execution_dtype) - output = self.model(None, embeds=feedback, past_key_values=past, dtype=execution_dtype) + output = self.model(None, embeds=feedback, past_key_values=past, dtype=execution_dtype, decode_buffers=decode_buffers) last_hidden.copy_(output[0][:, -1]) past = output[2] del output, feedback, frame_hidden, depth_hidden, feedback_codes, c0_embed, c0, code_or_stop diff --git a/comfy/ldm/modules/attention.py b/comfy/ldm/modules/attention.py index 4d6cf85988f..7e4f943519b 100644 --- a/comfy/ldm/modules/attention.py +++ b/comfy/ldm/modules/attention.py @@ -1,6 +1,7 @@ import math import sys import inspect +import json import torch import torch.nn.functional as F @@ -71,6 +72,34 @@ def get_attention_function(name: str, default: Any=...) -> Union[Callable, None] return default return REGISTERED_ATTENTION_FUNCTIONS[name] + +class ComfyAttention(nn.Module): + def __init__(self): + super().__init__() + self.config = None + self.function = None + + def _load_from_state_dict(self, state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs): + self.config = None + self.function = None + metadata = state_dict.pop(prefix + "config", None) + if metadata is not None: + config = json.loads(metadata.numpy().tobytes()) + method = config.get("attention") + if method == "comfy_kitchen_int8": + self.config = config + if COMFY_KITCHEN_INT8_ATTENTION_IS_AVAILABLE and comfy_kitchen.int8_attention_is_available(model_management.get_torch_device()): + self.function = attention_comfy_kitchen_int8 + else: + logging.warning(f"Ignoring unsupported attention method {method!r} for {prefix.rstrip('.')}") + super()._load_from_state_dict(state_dict, prefix, local_metadata, strict, missing_keys, unexpected_keys, error_msgs) + + def _save_to_state_dict(self, destination, prefix, keep_vars): + super()._save_to_state_dict(destination, prefix, keep_vars) + if self.config is not None: + destination[prefix + "config"] = torch.tensor(list(json.dumps(self.config).encode("utf-8")), dtype=torch.uint8) + + from comfy.cli_args import args import comfy.ops ops = comfy.ops.disable_weight_init @@ -171,6 +200,7 @@ def take(self) -> torch.Tensor: def wrap_attn(func): @functools.wraps(func) def wrapper(*args, **kwargs): + preferred_attention = kwargs.pop("preferred_attention", None) containers = None if len(args) >= 3 and isinstance(args[0], AttentionTensorContainer): if not isinstance(args[1], AttentionTensorContainer) or not isinstance(args[2], AttentionTensorContainer): @@ -191,6 +221,10 @@ def wrapper(*args, **kwargs): return optimized_attention_override.container_function(*args, **kwargs) args = tuple(container.take() for container in containers) + args[3:] return optimized_attention_override(func, *args, **kwargs) + if preferred_attention is not None: + attention = preferred_attention.function + if attention is not None: + return attention(*args, **kwargs) if containers is not None: if wrapper.container_function is not None: diff --git a/comfy/ldm/moge/model.py b/comfy/ldm/moge/model.py index 8dd280c630a..1b2c0ef9b0a 100644 --- a/comfy/ldm/moge/model.py +++ b/comfy/ldm/moge/model.py @@ -2,6 +2,7 @@ V1: DINOv2 backbone + multi-output head (points, mask). V2: DINOv2 encoder + neck + per-output heads (points, mask, normal, optional metric-scale MLP). +V3: V2 plus a sparse 3D UNet that iteratively refines the predicted log-depth. """ @@ -19,7 +20,7 @@ from comfy.image_encoders.dino2 import Dinov2Model from .geometry import depth_map_to_point_map, intrinsics_from_focal_center, recover_focal_shift -from .modules import ConvStack, DINOv2Encoder, HeadV1, MLP, _view_plane_uv_grid +from .modules import ConvStack, DINOv2Encoder, HeadV1, MLP, Sparse3DUNet, _view_plane_uv_grid def _remap_points(points: torch.Tensor) -> torch.Tensor: @@ -30,7 +31,7 @@ def _remap_points(points: torch.Tensor) -> torch.Tensor: def _detect_dinov2(sd: dict, prefix: str) -> Dict[str, Any]: - # All shipped MoGe checkpoints use plain DINOv2 + # All shipped MoGe checkpoints use plain DINOv2. ViT-g (MoGe-3) swaps the MLP for a fused SwiGLU. hidden = sd[prefix + "embeddings.cls_token"].shape[-1] layer_prefix = prefix + "encoder.layer." depth = 1 + max(int(k[len(layer_prefix):].split(".")[0]) for k in sd if k.startswith(layer_prefix)) @@ -39,7 +40,7 @@ def _detect_dinov2(sd: dict, prefix: str) -> Dict[str, Any]: "num_attention_heads": hidden // 64, "num_hidden_layers": depth, "layer_norm_eps": 1e-6, - "use_swiglu_ffn": False, + "use_swiglu_ffn": layer_prefix + "0.mlp.weights_in.weight" in sd, } @@ -123,7 +124,8 @@ def __init__(self, if normal_head is not None: self.normal_head = ConvStack(**normal_head, dtype=dtype, device=device, operations=operations) - def forward(self, image: torch.Tensor, num_tokens: int) -> Dict[str, torch.Tensor]: + def _trunk(self, image: torch.Tensor, num_tokens: int) -> Tuple[List[torch.Tensor], torch.Tensor, torch.Tensor]: + """Encoder + neck. Returns (neck features, level-0 feature map, class token).""" B, _, H, W = image.shape device, dtype = image.device, image.dtype aspect_ratio = W / H @@ -137,12 +139,15 @@ def forward(self, image: torch.Tensor, num_tokens: int) -> Dict[str, torch.Tenso for L in range(5)] levels[0] = torch.cat([feat_top, levels[0]], dim=1) - feats = self.neck(levels) + return self.neck(levels), levels[0], cls_token + def _heads(self, feats: List[torch.Tensor], cls_token: torch.Tensor, raw_coord: torch.Tensor, + size: Tuple[int, int]) -> Dict[str, torch.Tensor]: + """Resize the head outputs to the image size and remap them into the public dict.""" def _resize(v): - return F.interpolate(v, (H, W), mode="bilinear", align_corners=False) + return F.interpolate(v, size, mode="bilinear", align_corners=False) - points = _remap_points(_resize(self.points_head(feats)[-1]).permute(0, 2, 3, 1)) + points = _remap_points(_resize(raw_coord).permute(0, 2, 3, 1)) mask = _resize(self.mask_head(feats)[-1]).squeeze(1).sigmoid() metric_scale = self.scale_head(cls_token).squeeze(1).exp() @@ -152,9 +157,20 @@ def _resize(v): result["normal"] = F.normalize(normal.permute(0, 2, 3, 1), dim=-1) return result + def forward(self, image: torch.Tensor, num_tokens: int) -> Dict[str, torch.Tensor]: + feats, _conditioning, cls_token = self._trunk(image, num_tokens) + return self._heads(feats, cls_token, self.points_head(feats)[-1], image.shape[-2:]) + @classmethod def from_state_dict(cls, sd, dtype=None, device=None, operations=comfy.ops.manual_cast): - """Detect the v2 encoder/neck/heads config from sd, build a model, and load weights.""" + """Detect the config from sd, build a model, and load weights.""" + model = cls(**cls._detect_config(sd), dtype=dtype, device=device, operations=operations) + model.load_state_dict(sd, strict=True) + return model + + @classmethod + def _detect_config(cls, sd) -> Dict[str, Any]: + """Reconstruct the v2 encoder/neck/heads config from the checkpoint keys.""" backbone = _detect_dinov2(sd, prefix="encoder.backbone.") depth = backbone["num_hidden_layers"] n = cls.intermediate_layers @@ -175,9 +191,7 @@ def from_state_dict(cls, sd, dtype=None, device=None, operations=comfy.ops.manua } if any(k.startswith("normal_head.") for k in sd): cfg["normal_head"] = cls._detect_convstack(sd, "normal_head.") - model = cls(**cfg, dtype=dtype, device=device, operations=operations) - model.load_state_dict(sd, strict=True) - return model + return cfg @staticmethod def _detect_convstack(sd: dict, prefix: str) -> Dict[str, Any]: @@ -205,6 +219,56 @@ def num_res_at(i): } +class MoGeModelV3(MoGeModelV2): + """MoGe v3: the v2 architecture plus a sparse 3D UNet that iteratively refines the point map's log-depth.""" + + # Log-depth is binned at 1/256 to build the sparse volume, so the voxel grid stays finer + # than the depth detail the refiner is meant to recover. + refiner_depth_resolution = 256 + + def __init__(self, refiner: Dict[str, Any], dtype=None, device=None, operations=comfy.ops.manual_cast, **v2_kwargs): + super().__init__(**v2_kwargs, dtype=dtype, device=device, operations=operations) + self.refiner = Sparse3DUNet(**refiner, dtype=dtype, device=device, operations=operations) + + def _refine_logz(self, coord: torch.Tensor, conditioning: torch.Tensor) -> torch.Tensor: + """One refinement pass over the point map at (x/z, y/z, logz), returning the updated logz.""" + B, H, W, _ = coord.shape + device = coord.device + logz = coord[..., 2] + + # Bin in fp32: logz * 256 lands where fp16's ULP exceeds 1 for far geometry, which would + # collapse neighbouring voxels. The refiner itself runs at the activation dtype. + z_bin = torch.round(logz.float() * self.refiner_depth_resolution).long() + z_bin = z_bin - z_bin.amin(dim=(1, 2), keepdim=True) + + rows = torch.arange(H, device=device).view(1, H, 1).expand(B, H, W) + cols = torch.arange(W, device=device).view(1, 1, W).expand(B, H, W) + batch = torch.arange(B, device=device).view(B, 1, 1).expand(B, H, W) + coords = torch.stack([batch, rows, cols, z_bin], dim=-1).reshape(-1, 4).to(torch.int32) + spatial = (H, W, int(z_bin.amax()) + 1) + + residual = self.refiner(coord.reshape(-1, 3), coords, spatial, conditioning) + return logz + residual.reshape(B, H, W) + + def forward(self, image: torch.Tensor, num_tokens: int, refine_steps: int = 3) -> Dict[str, torch.Tensor]: + feats, conditioning, cls_token = self._trunk(image, num_tokens) + + coord = self.points_head(feats)[-1].permute(0, 2, 3, 1) + for _ in range(refine_steps): + coord = torch.cat([coord[..., :2], self._refine_logz(coord, conditioning).unsqueeze(-1)], dim=-1) + + # _remap_points takes exp(logz), which overflows fp16 past ~11.1, so hand the heads fp32. + return self._heads(feats, cls_token, coord.permute(0, 3, 1, 2).float(), image.shape[-2:]) + + @classmethod + def _detect_config(cls, sd) -> Dict[str, Any]: + cfg = super()._detect_config(sd) + # Both released MoGe-3 checkpoints share one refiner shape; only the conditioning + # width follows the encoder (1026 for ViT-L, 1538 for ViT-g). + cfg["refiner"] = {"encoder_channels": sd["refiner.encoder_fuse.weight"].shape[1]} + return cfg + + # Translate the Meta-style DINOv2 keys MoGe ships to the naming ComfyUI DINOv2 port expects, # and split each fused qkv tensor into Q/K/V. _DINOV2_TOPLEVEL_RENAMES = { @@ -258,9 +322,14 @@ def _remap_state_dict(sd: dict) -> dict: def build_from_state_dict(sd: dict, dtype=None, device=None, operations=comfy.ops.manual_cast) -> nn.Module: - """Dispatch to v1 or v2 based on the DINOv2 backbone prefix.""" + """Dispatch to v1, v2 or v3 based on the DINOv2 backbone prefix and the presence of the v3 refiner.""" sd = _remap_state_dict(sd) - cls = MoGeModelV2 if any(k.startswith("encoder.backbone.") for k in sd) else MoGeModelV1 + if not any(k.startswith("encoder.backbone.") for k in sd): + cls = MoGeModelV1 + elif any(k.startswith("refiner.") for k in sd): + cls = MoGeModelV3 + else: + cls = MoGeModelV2 return cls.from_state_dict(sd, dtype=dtype, device=device, operations=operations) @@ -274,7 +343,10 @@ def __init__(self, state_dict: dict): self.model = build_from_state_dict(state_dict, dtype=self.dtype, device=offload_device, operations=comfy.ops.manual_cast).eval() self.patcher = comfy.model_patcher.CoreModelPatcher(self.model, load_device=self.load_device, offload_device=offload_device) - self.version = "v2" if hasattr(self.model, "encoder") else "v1" + if not hasattr(self.model, "encoder"): + self.version = "v1" + else: + self.version = "v3" if hasattr(self.model, "refiner") else "v2" self.mask_threshold = float(getattr(self.model, "mask_threshold", 0.5)) nt = getattr(self.model, "num_tokens_range", (1200, 2500 if self.version == "v1" else 3600)) self.num_tokens_range = (int(nt[0]), int(nt[1])) @@ -282,11 +354,15 @@ def __init__(self, state_dict: dict): def infer(self, image: torch.Tensor, num_tokens: Optional[int] = None, resolution_level: int = 9, fov_x: Optional[Union[Number, torch.Tensor]] = None, force_projection: bool = True, apply_mask: bool = True, - apply_metric_scale: bool = True + apply_metric_scale: bool = True, refine_steps: int = 3 ) -> Dict[str, torch.Tensor]: """Run a single MoGe forward + post-process pass. image is (B, 3, H, W) in [0, 1].""" comfy.model_management.load_model_gpu(self.patcher) - image = image.to(device=self.load_device, dtype=torch.float32) + + # Compute is fp32 or fp16 only: bf16 would cost 4x the error at the same speed + compute_dtype = self.dtype if self.dtype in (torch.float32, torch.float16) else torch.float16 + activation_dtype = compute_dtype if self.version == "v3" else torch.float32 + image = image.to(device=self.load_device, dtype=activation_dtype) H, W = image.shape[-2:] aspect_ratio = W / H @@ -294,10 +370,13 @@ def infer(self, image: torch.Tensor, num_tokens: Optional[int] = None, lo, hi = self.num_tokens_range num_tokens = int(lo + (resolution_level / 9) * (hi - lo)) - out = self.model.forward(image, num_tokens=num_tokens) + # refine_steps only exists on v3; v1/v2 have no refiner to run. + extra = {"refine_steps": refine_steps} if self.version == "v3" else {} + out = self.model.forward(image, num_tokens=num_tokens, **extra) points = out["points"].float() # recover_focal_shift goes through scipy on CPU; needs fp32. mask_binary = out["mask"] > self.mask_threshold normal = out.get("normal") + normal = normal.float() if normal is not None else None metric_scale = out.get("metric_scale") diag = (1 + aspect_ratio ** 2) ** 0.5 @@ -321,8 +400,8 @@ def focal_from_fov_deg(deg): half = torch.tensor(0.5, device=points.device, dtype=points.dtype) intrinsics = intrinsics_from_focal_center(f_diag / aspect_ratio, f_diag, half, half) points[..., 2] = points[..., 2] + shift[..., None, None] - # v2 only: filter mask by depth>0 to drop metric-scale negative-depth artifacts. - if self.version == "v2": + # v2/v3 only: filter mask by depth>0 to drop metric-scale negative-depth artifacts. + if self.version != "v1": mask_binary = mask_binary & (points[..., 2] > 0) depth = points[..., 2].clone() diff --git a/comfy/ldm/moge/modules.py b/comfy/ldm/moge/modules.py index 366411120d1..e9a6225287f 100644 --- a/comfy/ldm/moge/modules.py +++ b/comfy/ldm/moge/modules.py @@ -1,4 +1,4 @@ -"""Building blocks for MoGe: residual conv stack, resamplers, MLP, DINOv2 encoder, v1 head.""" +"""Building blocks for MoGe: residual conv stack, resamplers, MLP, DINOv2 encoder, v1 head, v3 sparse refiner.""" from typing import List, Optional, Sequence, Tuple, Union @@ -9,6 +9,7 @@ import comfy.ops from comfy.image_encoders.dino2 import Dinov2Model +from comfy.ldm.trellis2.flexgemm import sparse_pool3d_mean, sparse_submanifold_conv3d, sparse_upsample3d_nearest from .geometry import normalized_view_plane_uv @@ -201,3 +202,130 @@ def forward(self, hidden_states, image: torch.Tensor): x = F.interpolate(x, (img_h, img_w), mode="bilinear", align_corners=False) x = _concat_view_plane_uv(x, aspect) return [block(x) for block in self.output_block] + + +class SubmanifoldConv3d(nn.Module): + """3x3x3 submanifold sparse conv. Weight is stored (C_out, K, K, K, C_in), as FlexGEMM writes it. + + Kernel spatial axis i indexes coords column i + 1, matching FlexGEMM's neighbor map. + """ + + def __init__(self, in_channels: int, out_channels: int, kernel_size: int = 3, dtype=None, device=None): + super().__init__() + self.weight = nn.Parameter(torch.empty(out_channels, kernel_size, kernel_size, kernel_size, in_channels, dtype=dtype, device=device)) + self.bias = nn.Parameter(torch.empty(out_channels, dtype=dtype, device=device)) + + def forward(self, feats, coords, spatial, neighbor_cache=None): + weight = comfy.ops.cast_to(self.weight, feats.dtype, feats.device) + bias = comfy.ops.cast_to(self.bias, feats.dtype, feats.device) + return sparse_submanifold_conv3d(feats, coords, spatial, weight, bias, neighbor_cache, (1, 1, 1)) + + +class SparseResBlock3d(nn.Module): + def __init__(self, channels: int, dtype=None, device=None, operations=comfy.ops.manual_cast): + super().__init__() + self.norm1 = operations.LayerNorm(channels, eps=1e-6, dtype=dtype, device=device) + self.conv1 = SubmanifoldConv3d(channels, channels, dtype=dtype, device=device) + self.conv2 = SubmanifoldConv3d(channels, channels, dtype=dtype, device=device) + + def forward(self, feats, coords, spatial, neighbor_cache=None): + h = F.silu(self.norm1(feats)) + h, neighbor_cache = self.conv1(h, coords, spatial, neighbor_cache) + h = F.silu(h) + h, neighbor_cache = self.conv2(h, coords, spatial, neighbor_cache) + return h + feats, neighbor_cache + + +class PoolDown(nn.Module): + def __init__(self, in_channels: int, out_channels: int, factor: int, dtype=None, device=None, operations=comfy.ops.manual_cast): + super().__init__() + self.factor = factor + self.linear = operations.Linear(in_channels, out_channels, dtype=dtype, device=device) + + def forward(self, feats, coords, spatial): + feats, coords, spatial, pool_index = sparse_pool3d_mean(feats, coords, spatial, self.factor) + return self.linear(feats), coords, spatial, pool_index + + +class NearestUp(nn.Module): + def __init__(self, in_channels: int, out_channels: int, dtype=None, device=None, operations=comfy.ops.manual_cast): + super().__init__() + self.linear = operations.Linear(in_channels, out_channels, dtype=dtype, device=device) + + def forward(self, feats, pool_index): + return sparse_upsample3d_nearest(self.linear(feats), pool_index) + + +class Sparse3DUNet(nn.Module): + """MoGe v3 refiner: sparse 3D UNet over the voxelized point map, conditioned on the ViT feature map. + + Takes the sparse volume as (feats, coords, spatial) where coords are (batch, row, col, z_bin), + and returns one residual per input voxel. + """ + + def __init__(self, encoder_channels: int, in_channels: int = 3, out_channels: int = 1, + model_channels: Sequence[int] = (32, 64, 128, 256, 512), blocks_per_level: int = 1, + factor: int = 2, dtype=None, device=None, operations=comfy.ops.manual_cast): + super().__init__() + self.factor = factor + kwargs = {"dtype": dtype, "device": device, "operations": operations} + # (shallow, deep) channel pair per resolution transition + pairs = list(zip(model_channels[:-1], model_channels[1:])) + + def stage(channels): + return nn.ModuleList([SparseResBlock3d(channels, **kwargs) for _ in range(blocks_per_level)]) + + self.input_proj = operations.Linear(in_channels, model_channels[0], dtype=dtype, device=device) + self.encoder_fuse = operations.Linear(encoder_channels, model_channels[-1], dtype=dtype, device=device) + self.fuse_proj = nn.Sequential( + operations.Linear(model_channels[-1] * 2, model_channels[-1], dtype=dtype, device=device), + nn.SiLU(), + operations.Linear(model_channels[-1], model_channels[-1], dtype=dtype, device=device), + ) + + self.down_stages = nn.ModuleList([stage(ch) for ch in model_channels]) + self.downsample_blocks = nn.ModuleList([PoolDown(lo, hi, factor, **kwargs) for lo, hi in pairs]) + self.bottleneck_stage = stage(model_channels[-1]) + # The decoder runs deepest-first, so it walks the transitions in reverse. + self.upsample_blocks = nn.ModuleList([NearestUp(hi, lo, **kwargs) for lo, hi in reversed(pairs)]) + self.up_stages = nn.ModuleList([stage(lo) for lo, _ in reversed(pairs)]) + self.out_proj = operations.Linear(model_channels[0], out_channels, dtype=dtype, device=device) + + def forward(self, feats, coords, spatial, encoder_feature): + num_levels = len(self.down_stages) + num_transitions = len(self.downsample_blocks) + # Coords at level k are identical on the down and up passes, so the submanifold + # neighbor map built on the way down is still valid on the way back up. + conv_caches: List[Optional[torch.Tensor]] = [None] * num_levels + pool_indices: List[Optional[torch.Tensor]] = [None] * num_transitions + skips: List[Optional[Tuple[torch.Tensor, torch.Tensor, tuple]]] = [None] * num_transitions + + feats = self.input_proj(feats) + + for i, blocks in enumerate(self.down_stages): + cache = conv_caches[i] + for block in blocks: + feats, cache = block(feats, coords, spatial, cache) + conv_caches[i] = cache + if i < num_transitions: + skips[i] = (feats, coords, spatial) + feats, coords, spatial, pool_indices[i] = self.downsample_blocks[i](feats, coords, spatial) + + conditioning = encoder_feature[coords[:, 0].long(), :, coords[:, 1].long(), coords[:, 2].long()] + feats = self.fuse_proj(torch.cat([feats, self.encoder_fuse(conditioning)], dim=-1)) + + cache = conv_caches[num_levels - 1] + for block in self.bottleneck_stage: + feats, cache = block(feats, coords, spatial, cache) + conv_caches[num_levels - 1] = cache + + for i, (upsample, blocks) in enumerate(zip(self.upsample_blocks, self.up_stages)): + level = num_levels - 2 - i + skip_feats, coords, spatial = skips[level] + feats = upsample(feats, pool_indices[level]) + skip_feats + cache = conv_caches[level] + for block in blocks: + feats, cache = block(feats, coords, spatial, cache) + conv_caches[level] = cache + + return self.out_proj(feats) diff --git a/comfy/ldm/qwen_image21/model.py b/comfy/ldm/qwen_image21/model.py new file mode 100644 index 00000000000..b4f0755e74f --- /dev/null +++ b/comfy/ldm/qwen_image21/model.py @@ -0,0 +1,372 @@ +# https://github.com/huggingface/diffusers (Apache 2.0) Qwen-Image 2.1 +import torch +import torch.nn as nn +import torch.nn.functional as F + +import comfy.model_management +import comfy.model_prefetch +import comfy.ops +import comfy.patcher_extension +import comfy.quant_ops +import comfy.rmsnorm +from comfy.ldm.flux.layers import EmbedND, timestep_embedding +from comfy.ldm.flux.math import apply_rope1 +from comfy.ldm.lightricks.model import TimestepEmbedding +from comfy.ldm.modules.attention import ComfyAttention, optimized_attention +from comfy.ldm.wan.model_animate2 import PoseBranchCache + + +class ZeroCenteredRMSNorm(nn.Module): + # stored weight is scale - 1, applied in fp32 + def __init__(self, dim, eps=1e-6, dtype=None, device=None): + super().__init__() + self.weight = nn.Parameter(torch.empty(dim, dtype=dtype, device=device)) + self.eps = eps + + def forward(self, x): + w = comfy.model_management.cast_to(self.weight, dtype=torch.float32, device=x.device) + 1.0 + return comfy.rmsnorm.rms_norm(x.float(), w, self.eps).to(x.dtype) + + +class TextProjection(nn.Module): + def __init__(self, in_dim, hidden_size, eps=1e-6, dtype=None, device=None, operations=None): + super().__init__() + self.text_norm = ZeroCenteredRMSNorm(in_dim, eps=eps, dtype=dtype, device=device) + self.in_layer = operations.Linear(in_dim, hidden_size, bias=False, dtype=dtype, device=device) + self.out_layer = operations.Linear(hidden_size, hidden_size, bias=False, dtype=dtype, device=device) + + def forward(self, x): + return self.out_layer(F.gelu(self.in_layer(self.text_norm(x)), approximate="tanh")) + + +class TimestepProjEmbeddings(nn.Module): + def __init__(self, embedding_dim, dtype=None, device=None, operations=None): + super().__init__() + self.timestep_embedder = TimestepEmbedding(in_channels=256, time_embed_dim=embedding_dim, sample_proj_bias=False, dtype=dtype, device=device, operations=operations) + + def forward(self, timestep, dtype): + return self.timestep_embedder(timestep_embedding(timestep.float(), 256).to(dtype)) + + +class SwiGLUFeedForward(nn.Module): + def __init__(self, dim, hidden_dim, fused=True, dtype=None, device=None, operations=None): + super().__init__() + self.fused = fused + if fused: + # [gate; up] in one GEMM, the SiLU-gate folded into the down projection's input quantizer + self.gate_up = operations.Linear(dim, 2 * hidden_dim, bias=False, dtype=dtype, device=device) + else: + self.proj = operations.Linear(dim, hidden_dim, bias=False, dtype=dtype, device=device) + self.gate_layer = operations.Linear(dim, hidden_dim, bias=False, dtype=dtype, device=device) + self.out = operations.Linear(hidden_dim, dim, bias=False, dtype=dtype, device=device) + + def forward(self, x): + if self.fused: + return comfy.ops.linear_input_act(self.out, self.gate_up(x), "swiglu") + return self.out(F.silu(self.gate_layer(x)) * self.proj(x)) + + +class Attention(nn.Module): + def __init__(self, dim, heads, dim_head, eps=1e-6, dtype=None, device=None, operations=None): + super().__init__() + self.comfy_attention = ComfyAttention() + self.heads = heads + inner_dim = heads * dim_head + self.to_q = operations.Linear(dim, inner_dim, bias=False, dtype=dtype, device=device) + self.to_k = operations.Linear(dim, inner_dim, bias=False, dtype=dtype, device=device) + self.to_v = operations.Linear(dim, inner_dim, bias=False, dtype=dtype, device=device) + self.to_out = nn.ModuleList([operations.Linear(inner_dim, dim, bias=False, dtype=dtype, device=device)]) + self.norm_q = operations.RMSNorm(dim_head, eps=eps, dtype=dtype, device=device) + self.norm_k = operations.RMSNorm(dim_head, eps=eps, dtype=dtype, device=device) + + def forward(self, x, pe, attn_fn, prefix_len, transformer_options={}): + # (B, N, H, D) throughout: no transposes, the rope table is laid out to match + B, N, _ = x.shape + q = self.to_q(x).view(B, N, self.heads, -1) + k = self.to_k(x).view(B, N, self.heads, -1) + v = self.to_v(x).view(B, N, self.heads, -1) + patches = transformer_options.get("patches", {}).get("attn1_patch", []) + if comfy.model_management.in_training or patches: + q, k = self.norm_q(q), self.norm_k(k) + if patches: + # patches see the Qwen-Image convention: (B, H, N, D) before rope, rope table (1, 1, N, ...), target image rows in img_slice + q, k, v, pe = q.transpose(1, 2), k.transpose(1, 2), v.transpose(1, 2), pe.transpose(1, 2) + extra_options = {**transformer_options, "img_slice": [prefix_len, N]} + for p in patches: + out = p(q, k, v, pe=pe, attn_mask=None, extra_options=extra_options) + q, k, v, pe = out.get("q", q), out.get("k", k), out.get("v", v), out.get("pe", pe) + q, k, v, pe = q.transpose(1, 2).contiguous(), k.transpose(1, 2).contiguous(), v.transpose(1, 2).contiguous(), pe.transpose(1, 2).contiguous() + q = apply_rope1(q, pe) + k = apply_rope1(k, pe) + else: + q_scale, _, q_stream = comfy.ops.cast_bias_weight(self.norm_q, q, offloadable=True) + k_scale, _, k_stream = comfy.ops.cast_bias_weight(self.norm_k, k, offloadable=True) + q, k = comfy.quant_ops.ck.rms_rope(q, k, pe, q_scale, k_scale, self.norm_q.eps) + comfy.ops.uncast_bias_weight(self.norm_q, q_scale, None, q_stream) + comfy.ops.uncast_bias_weight(self.norm_k, k_scale, None, k_stream) + return self.to_out[0](attn_fn(q, k, v, self.heads, preferred_attention=self.comfy_attention)) + + +def _split_rows(p): + # shared modulation rows: (t = 0 row for text and references, sampled-t rows for the target) + return p[-1:].unsqueeze(1), p[:-1].unsqueeze(1) + + +def _modulated_norm(norm, x, scale, prefix_len, zero): + # LayerNorm * (1 + scale), fused over every row with the target scale; the prefix rows are then redone with the t = 0 scale + s_prefix, s_target = scale + if comfy.model_management.in_training: + out = norm(x) + return torch.cat([out[:, :prefix_len] * (1 + s_prefix), out[:, prefix_len:] * (1 + s_target)], dim=1) + out = comfy.quant_ops.ck.adaln(x, s_target, zero, norm.eps) + if prefix_len: + out[:, :prefix_len] = comfy.quant_ops.ck.adaln(x[:, :prefix_len], s_prefix, zero, norm.eps) + return out + + +def _gated_residual(x, y, gate, prefix_len): + g_prefix, g_target = gate + x[:, prefix_len:].addcmul_(y[:, prefix_len:], g_target) + if prefix_len: + x[:, :prefix_len].addcmul_(y[:, :prefix_len], g_prefix) + return x + + +class QwenImage21TransformerBlock(nn.Module): + def __init__(self, dim, num_attention_heads, attention_head_dim, mlp_ratio=3, eps=1e-6, fused_mlp=True, dtype=None, device=None, operations=None): + super().__init__() + self.img_norm1 = operations.LayerNorm(dim, elementwise_affine=False, eps=eps, dtype=dtype, device=device) + self.attn = Attention(dim, num_attention_heads, attention_head_dim, eps=eps, dtype=dtype, device=device, operations=operations) + self.img_norm2 = operations.LayerNorm(dim, elementwise_affine=False, eps=eps, dtype=dtype, device=device) + self.img_mlp = SwiGLUFeedForward(dim, dim * mlp_ratio, fused=fused_mlp, dtype=dtype, device=device, operations=operations) + + def forward(self, x, mod, pe, attn_fn, prefix_len, transformer_options={}): + scale1, gate1, scale2, gate2, zero = mod + x = _gated_residual(x, self.attn(_modulated_norm(self.img_norm1, x, scale1, prefix_len, zero), pe, attn_fn, prefix_len, transformer_options), gate1, prefix_len) + x = _gated_residual(x, self.img_mlp(_modulated_norm(self.img_norm2, x, scale2, prefix_len, zero)), gate2, prefix_len) + if x.dtype == torch.float16: + x = x.clip(-65504, 65504) + return x + + +class LastLayer(nn.Module): + # scale only, no shift + def __init__(self, dim, eps=1e-6, dtype=None, device=None, operations=None): + super().__init__() + self.linear = operations.Linear(dim, dim, bias=False, dtype=dtype, device=device) + self.norm = operations.LayerNorm(dim, eps, elementwise_affine=False, dtype=dtype, device=device) + + def forward(self, x, temb): + scale = self.linear(F.silu(temb)).unsqueeze(1) + if comfy.model_management.in_training: + return self.norm(x) * (1 + scale) + return comfy.quant_ops.ck.adaln(x, scale, torch.zeros_like(scale[:1]), self.norm.eps) + + +def block_causal_attention(segments, transformer_options={}, cache=None, block_index=0, prefix_len=0): + # segments: (start, end, mask); text segments get a causal mask, image blocks attend to everything before their end + def attn(q, k, v, heads, preferred_attention=None): + if cache is not None: + # K and V stacked on dim 1 so batch stays first and quantized rows are per token and head + cache.put(block_index, torch.stack([k[:, :prefix_len], v[:, :prefix_len]], dim=1)) + outs = [optimized_attention(q[:, start:end].flatten(2), k[:, :end].flatten(2), v[:, :end].flatten(2), heads, mask=mask, transformer_options=transformer_options, preferred_attention=preferred_attention) + for start, end, mask in segments] + return torch.cat(outs, dim=1) if len(outs) > 1 else outs[0] + return attn + + +def prefix_cached_attention(prefix_k, prefix_v, transformer_options={}): + # target-only queries: block-causal reduces to full attention over [cached prefix, target] + def attn(q, k, v, heads, preferred_attention=None): + return optimized_attention(q.flatten(2), torch.cat([prefix_k, k], dim=1).flatten(2), torch.cat([prefix_v, v], dim=1).flatten(2), heads, transformer_options=transformer_options, preferred_attention=preferred_attention) + return attn + + +def prefix_cache_key(x, context, refs, slots): + # one fp32 tensor per batch row: lengths and slots, then the prompt embedding and reference latents + # the target shape is part of it because reference rope ids are centred on the target + header = [context.shape[1], len(refs)] + list(x.shape[-2:]) + list(slots) + [s for r in refs for s in r.shape[-2:]] + header = torch.tensor(header, dtype=torch.float32, device=context.device).expand(context.shape[0], -1) + return torch.cat([header, context.float().flatten(1)] + [r.float().flatten(1) for r in refs], dim=1) + + +class QwenImage21Transformer2DModel(nn.Module): + def __init__( + self, + in_channels=64, + out_channels=64, + num_layers=32, + attention_head_dim=128, + num_attention_heads=32, + context_in_dim=4096, + mlp_ratio=3, + axes_dims_rope=(16, 56, 56), + eps=1e-6, + fused_mlp=True, + image_model=None, + dtype=None, + device=None, + operations=None, + ): + super().__init__() + self.dtype = dtype + self.out_channels = out_channels + self.inner_dim = num_attention_heads * attention_head_dim + + self.pe_embedder = EmbedND(dim=attention_head_dim, theta=10000, axes_dim=list(axes_dims_rope)) + self.time_text_embed = TimestepProjEmbeddings(self.inner_dim, dtype=dtype, device=device, operations=operations) + self.txt_in = TextProjection(context_in_dim, self.inner_dim, eps=eps, dtype=dtype, device=device, operations=operations) + self.img_in = operations.Linear(in_channels, self.inner_dim, bias=False, dtype=dtype, device=device) + + # one modulation shared by every block + self.modulation = nn.Sequential(nn.SiLU(), operations.Linear(self.inner_dim, 4 * self.inner_dim, bias=False, dtype=dtype, device=device)) + + self.transformer_blocks = nn.ModuleList([ + QwenImage21TransformerBlock(self.inner_dim, num_attention_heads, attention_head_dim, mlp_ratio=mlp_ratio, eps=eps, fused_mlp=fused_mlp, dtype=dtype, device=device, operations=operations) + for _ in range(num_layers) + ]) + + self.norm_out = LastLayer(self.inner_dim, eps=eps, dtype=dtype, device=device, operations=operations) + self.proj_out = operations.Linear(self.inner_dim, out_channels, bias=False, dtype=dtype, device=device) + + # text + reference K/V are step-independent (t = 0 modulation, causal prefix), cached for one sampling run + self.prefix_cache = None + self.prefix_cache_enabled = False + + def reset_prefix_cache(self, enabled): + if self.prefix_cache is not None: + self.prefix_cache.free() + self.prefix_cache = None + self.prefix_cache_enabled = enabled + + def select_prefix_cache(self, key, cache_bytes, device, options): + # returns (cache with the slot to read or fill selected, whether the slot is filled), or (None, False) to recompute + if options.get("device") == "off": + return None, False + cache = self.prefix_cache + if cache is not None and cache.select(key, create=False): + return cache, cache.filled(len(self.transformer_blocks)) + dtype = options.get("dtype", "default") + cache_bytes //= {"int8": 2, "int4": 4}.get(dtype, 1) + if cache is None: + store = options.get("device", "auto") + if store == "auto": + # spare VRAM first, then reclaim inactive model RAM for a host cache + if self.current_patcher.get_free_memory(device) > 4 * cache_bytes: + store = device + elif comfy.model_management.ensure_pin_budget(cache_bytes, evict_active=False): + store = torch.device("cpu") + else: + return None, False + else: + store = device if store == "gpu" else torch.device("cpu") + cache = self.prefix_cache = PoseBranchCache(store_device=store, dtype=dtype) + if not (self.current_patcher.get_free_memory(device) > 2 * cache_bytes if cache.store_device == device else comfy.model_management.ensure_pin_budget(cache_bytes, evict_active=False)): + # no room for this slot: recompute rather than evict the other cond's slot every step + return None, False + cache.select(key) + return cache, False + + def build_sequence(self, x, context, ref_latents, image_slots): + # text with each reference image spliced in at its slot, target image last + txt = self.txt_in(context) + slots = (image_slots + [txt.shape[1]] * len(ref_latents))[:len(ref_latents)] + bounds = [0] + slots + [txt.shape[1]] + + parts, ids, segments = [], [], [] + pos, length = 0, 0 + for (start, end), img in zip(zip(bounds[:-1], bounds[1:]), ref_latents + [x]): + n = end - start + if n > 0: + parts.append(txt[:, start:end]) + ids.append(torch.arange(pos, pos + n, device=x.device, dtype=torch.float32).unsqueeze(1).expand(n, 3)) + segments.append((length, length + n, torch.ones((n, length + n), dtype=torch.bool, device=x.device).tril(length))) + pos += n + length += n + h, w = img.shape[-2:] + parts.append(self.img_in(img.flatten(2).transpose(1, 2))) + # half a token where a reference grid has the other parity, so it centres on the target + hh = torch.arange(h, device=x.device, dtype=torch.float32) - (h - h // 2) + 0.5 * (h % 2 - x.shape[-2] % 2) + ww = torch.arange(w, device=x.device, dtype=torch.float32) - (w - w // 2) + 0.5 * (w % 2 - x.shape[-1] % 2) + ids.append(torch.stack([torch.full((h, w), pos, device=x.device, dtype=torch.float32), hh[:, None].expand(h, w), ww[None, :].expand(h, w)], dim=-1).flatten(0, 1)) + segments.append((length, length + h * w, None)) + pos += max(h, w) + length += h * w + + # (1, N, 1, ...): the layout the fused rms_rope wants for (B, N, H, D) queries + pe = self.pe_embedder(torch.cat(ids, dim=0).unsqueeze(0)).transpose(1, 2).contiguous() + return torch.cat(parts, dim=1), pe, segments + + def forward(self, x, timestep, context, ref_latents=None, image_slots=None, transformer_options={}, **kwargs): + return comfy.patcher_extension.WrapperExecutor.new_class_executor( + self._forward, + self, + comfy.patcher_extension.get_all_wrappers(comfy.patcher_extension.WrappersMP.DIFFUSION_MODEL, transformer_options) + ).execute(x, timestep, context, ref_latents, image_slots, transformer_options, **kwargs) + + def _forward(self, x, timesteps, context, ref_latents=None, image_slots=None, transformer_options={}, **kwargs): + B, C, H, W = x.shape + dtype = x.dtype + ref_latents = list(ref_latents or []) + image_slots = list(image_slots or []) + + hidden_states, pe, segments = self.build_sequence(x, context, ref_latents, image_slots) + prefix_len = hidden_states.shape[1] - H * W + patches = transformer_options.get("patches", {}) + for p in patches.get("post_input", []): + out = p({"img": hidden_states, "pe": pe, "transformer_options": transformer_options}) + hidden_states, pe = out["img"], out.get("pe", pe) + + # pipeline rounds t*1000 and t to the compute dtype; text and reference tokens modulate from t = 0 + t = ((timesteps * 1000).to(dtype) / 1000).to(dtype) + temb = self.time_text_embed(torch.cat([t, t.new_zeros(1)]), dtype) + scale1, gate1, scale2, gate2 = self.modulation(temb).chunk(4, dim=-1) + mod = (_split_rows(scale1), _split_rows(gate1.tanh()), _split_rows(scale2), _split_rows(gate2.tanh()), torch.zeros_like(scale1[:1, None])) + + blocks_replace = transformer_options.get("patches_replace", {}).get("dit", {}) + cache, cached = None, False + # a cached step runs target rows only, so anything hooked into a block would see a different sequence from step 2 + hooked = blocks_replace or patches.get("post_input") or patches.get("single_block") or patches.get("attn1_patch") + if self.prefix_cache_enabled and prefix_len > 0 and not hooked: + key = prefix_cache_key(x, context, ref_latents, image_slots) + cache_bytes = 2 * len(self.transformer_blocks) * B * prefix_len * self.inner_dim * hidden_states.element_size() + cache, cached = self.select_prefix_cache(key, cache_bytes, x.device, transformer_options.get("qwen_image21_cache", {})) + if cached: + hidden_states, pe, prefix_len = hidden_states[:, prefix_len:], pe[:, prefix_len:], 0 + elif cache is not None: + prefix_states, hidden_states = hidden_states[:, :prefix_len], hidden_states[:, prefix_len:] + prefix_pe, pe = pe[:, :prefix_len], pe[:, prefix_len:] + prefix_len = 0 + + transformer_options["total_blocks"] = len(self.transformer_blocks) + transformer_options["block_type"] = "single" + prefetch_queue = comfy.model_prefetch.make_prefetch_queue(list(self.transformer_blocks), x.device, transformer_options) + comfy.model_prefetch.malloc_graph_begin(x.device) + for i, block in enumerate(self.transformer_blocks): + comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, x.device, block, dtype, malloc_scope="block") + transformer_options["block_index"] = i + if cache is not None: + if not cached: + with comfy.model_prefetch.pause_malloc_graph(): + prefix_attn = block_causal_attention(segments[:-1], transformer_options, cache, i, prefix_states.shape[1]) + prefix_states = block(prefix_states, mod, prefix_pe, prefix_attn, prefix_states.shape[1], transformer_options) + prefix_k, prefix_v = cache.take(i, x.device, dtype, B).unbind(1) + if cached: + cache.prefetch(i + 1, x.device, dtype) # queue the next block before the compute it should overlap + attn_fn = prefix_cached_attention(prefix_k, prefix_v, transformer_options) + else: + attn_fn = block_causal_attention(segments, transformer_options, cache, i, prefix_len) + if ("single_block", i) in blocks_replace: + def block_wrap(args): + return {"img": block(args["img"], mod, args["pe"], attn_fn, prefix_len, args["transformer_options"])} + hidden_states = blocks_replace[("single_block", i)]({"img": hidden_states, "vec": temb, "pe": pe, "transformer_options": transformer_options}, {"original_block": block_wrap})["img"] + else: + hidden_states = block(hidden_states, mod, pe, attn_fn, prefix_len, transformer_options) + for p in patches.get("single_block", []): + hidden_states = p({"img": hidden_states, "x": x, "block_index": i, "transformer_options": transformer_options})["img"] + + comfy.model_prefetch.prefetch_queue_pop(prefetch_queue, x.device, None, malloc_scope="block") + comfy.model_prefetch.malloc_graph_end() + hidden_states = self.norm_out(hidden_states[:, prefix_len:], temb[:-1]) + hidden_states = self.proj_out(hidden_states) + return hidden_states.transpose(1, 2).reshape(B, self.out_channels, H, W) diff --git a/comfy/ldm/trellis2/flexgemm.py b/comfy/ldm/trellis2/flexgemm.py index 76023992eea..485c272c749 100644 --- a/comfy/ldm/trellis2/flexgemm.py +++ b/comfy/ldm/trellis2/flexgemm.py @@ -164,3 +164,49 @@ def sparse_submanifold_conv3d( output += bias.unsqueeze(0).to(output.dtype) return output, neighbor + + +def sparse_pool3d_mean( + feats: torch.Tensor, + coords: torch.Tensor, + shape: tuple, + factor: int, +) -> Tuple[torch.Tensor, torch.Tensor, tuple, torch.Tensor]: + """Average-pool a sparse volume by `factor` along the three spatial axes. + + coords are (batch, x, y, z) and shape is the spatial extent (X, Y, Z), as for + sparse_submanifold_conv3d. Returns the pooled (feats, coords, shape) plus the + fine->coarse index, which is what sparse_upsample3d_nearest needs to invert the pooling. + """ + batch = coords[:, 0].long() + fine = coords[:, 1:4].long() + out_x, out_y, out_z = ((int(s) + factor - 1) // factor for s in shape) + + coarse = fine.div(factor, rounding_mode="floor") + flat = ((batch * out_x + coarse[:, 0]) * out_y + coarse[:, 1]) * out_z + coarse[:, 2] + unique_flat, index = torch.unique(flat, return_inverse=True) + + pooled = torch.zeros((unique_flat.shape[0], feats.shape[-1]), device=feats.device, dtype=feats.dtype) + pooled.index_add_(0, index, feats) + counts = torch.zeros((unique_flat.shape[0],), device=feats.device, dtype=feats.dtype) + counts.index_add_(0, index, torch.ones_like(index, dtype=feats.dtype)) + pooled /= counts.unsqueeze(-1) + + z = unique_flat % out_z + rest = unique_flat.div(out_z, rounding_mode="floor") + y = rest % out_y + rest = rest.div(out_y, rounding_mode="floor") + x = rest % out_x + b = rest.div(out_x, rounding_mode="floor") + out_coords = torch.stack([b, x, y, z], dim=-1).to(torch.int32) + + return pooled, out_coords, (out_x, out_y, out_z), index + + +def sparse_upsample3d_nearest(feats: torch.Tensor, pool_index: torch.Tensor) -> torch.Tensor: + """Invert sparse_pool3d_mean's spatial mapping: each fine voxel takes its parent's feature. + + `pool_index` is the index returned by the matching sparse_pool3d_mean call, so the + output is aligned with that call's input coords (the UNet's skip coords). + """ + return feats[pool_index] diff --git a/comfy/ldm/trellis2/model.py b/comfy/ldm/trellis2/model.py index 042e94aa09e..fda7dfd37cc 100644 --- a/comfy/ldm/trellis2/model.py +++ b/comfy/ldm/trellis2/model.py @@ -768,10 +768,7 @@ def _coords_to_proj_world(coords: torch.Tensor, resolution: int, mesh_scale: tor norm = coords[:, 1:].to(torch.float32) / (resolution - 1) * 2.0 - 1.0 R = _PROJ_GRID_ROTATION.to(device=coords.device, dtype=torch.float32) rotated = norm @ R.T - if mesh_scale.ndim == 0: - scale_per_voxel = mesh_scale.expand(coords.shape[0]) - else: - scale_per_voxel = mesh_scale.to(coords.device)[batch_ids] + scale_per_voxel = mesh_scale.to(coords.device).reshape(-1)[batch_ids % mesh_scale.numel()] world = rotated / scale_per_voxel.unsqueeze(-1) / 2.0 return world, batch_ids @@ -800,20 +797,22 @@ def _back_project_to_tokens( ) -> torch.Tensor: if coords_world.dim() == 2: assert batch_ids is not None - B = transform_matrix.shape[0] + n_cond = transform_matrix.shape[0] out = torch.zeros((coords_world.shape[0], feature_map.shape[1]), device=feature_map.device, dtype=feature_map.dtype) - for b in range(B): + # multi-seed: latent sample b uses conditioning b % n_cond + for b in range(int(batch_ids.max().item()) + 1): mask = batch_ids == b if not mask.any(): continue + c = b % n_cond p = coords_world[mask].unsqueeze(0) uv, _, _ = _project_points_to_image( - p, transform_matrix[b:b+1], camera_angle_x[b:b+1], image_resolution) + p, transform_matrix[c:c+1], camera_angle_x[c:c+1], image_resolution) uv_ndc = (uv + 0.5) / image_resolution * 2.0 - 1.0 # padding_mode='border' is load-bearing: masking out-of-frame voxels confuses # the SS DiT (~half the voxels go to zero, producing low poly + rotation drift). - sampled = _sample_features(feature_map[b:b+1], uv_ndc) + sampled = _sample_features(feature_map[c:c+1], uv_ndc) sampled = sampled.squeeze(0).transpose(0, 1) out[mask] = sampled return out @@ -846,25 +845,14 @@ def compute_stage_proj_feats( batch_size: Optional[int] = None, device=None, ) -> torch.Tensor: - """Back-project a Pixal3D stage's feature maps onto its target voxel/grid coords. - - For sparse (shape / texture) stages: pass ``coords`` (with ``coord_resolution``). - Returns ``[N_voxels, C]`` per-voxel features with channel count = - LR channels + optional HR channels. - - For the dense SS stage: pass ``dense_grid_resolution`` (16) + ``batch_size``. - Returns ``[B, R^3, C]`` features for the dense grid. - - """ + """Back-project a stage's feature maps onto sparse coords [N, C] or the dense SS grid [B, R^3, C]; views at stride num_views are averaged.""" if device is None: device = coords.device if coords is not None else proj_pack["mesh_scale"].device mesh_scale = proj_pack["mesh_scale"].to(device) T = proj_pack["transform_matrix"].to(device) cam_angle = proj_pack["camera_angle_x"].to(device) + num_views = int(proj_pack.get("num_views", 1)) feat_map_lr, feat_map_hr, image_resolution = _select_stage_entry(proj_pack, stage) - feat_map_lr = feat_map_lr.to(device) - if feat_map_hr is not None: - feat_map_hr = feat_map_hr.to(device) if coords is not None: if coord_resolution is None: @@ -877,13 +865,18 @@ def compute_stage_proj_feats( device=device, dtype=torch.float32) batch_ids = None - proj_lr = _back_project_to_tokens(coords_world, feat_map_lr, T, cam_angle, + out = None + for v in range(num_views): + sel = slice(v, None, num_views) + proj = _back_project_to_tokens(coords_world, feat_map_lr[sel].to(device), T[sel], cam_angle[sel], image_resolution=image_resolution, batch_ids=batch_ids) - if feat_map_hr is not None: - proj_hr = _back_project_to_tokens(coords_world, feat_map_hr, T, cam_angle, - image_resolution=image_resolution, batch_ids=batch_ids) - return torch.cat([proj_lr, proj_hr], dim=-1) - return proj_lr + if feat_map_hr is not None: + proj_hr = _back_project_to_tokens(coords_world, feat_map_hr[sel].to(device), T[sel], cam_angle[sel], + image_resolution=image_resolution, batch_ids=batch_ids) + proj = torch.cat([proj, proj_hr], dim=-1) + # average views in fp32 like upstream + out = proj.float() if out is None else out.add_(proj) + return out.div_(num_views).to(proj.dtype) def _shape_proj_cond(global_cond: torch.Tensor, image_attn_mode: str, @@ -917,6 +910,11 @@ def _shape_proj_cond(global_cond: torch.Tensor, image_attn_mode: str, f"sub-model expects {proj_in_channels}.{hint}" ) + # multi-seed: latent sample i uses conditioning i % B + if batch_ids is None and logical_batch is not None and proj_feats.shape[0] != logical_batch: + reps = -(-logical_batch // proj_feats.shape[0]) + proj_feats = proj_feats.repeat((reps,) + (1,) * (proj_feats.ndim - 1))[:logical_batch] + # CFG-duplicate proj_feats to match the model's eval batch. if eval_batch is not None and logical_batch is not None and eval_batch > logical_batch: repeats = eval_batch // logical_batch @@ -1125,7 +1123,7 @@ def forward(self, x, timestep, context, **kwargs): else: # structure struct_attn = self.image_attn_mode_structure - logical_batch_ss = proj_feats.shape[0] if proj_feats is not None else x.shape[0] + logical_batch_ss = x.shape[0] // len(cond_or_uncond) if cond_or_uncond else x.shape[0] struct_cond = context if struct_attn != "global": struct_cond = _shape_proj_cond(context, struct_attn, proj_feats, diff --git a/comfy/ldm/wan/model.py b/comfy/ldm/wan/model.py index ad207c3ef1a..25759999523 100644 --- a/comfy/ldm/wan/model.py +++ b/comfy/ldm/wan/model.py @@ -6,7 +6,7 @@ import torch.nn as nn from einops import rearrange -from comfy.ldm.modules.attention import optimized_attention +from comfy.ldm.modules.attention import AttentionTensorContainer, optimized_attention from comfy.ldm.flux.layers import EmbedND from comfy.ldm.flux.math import apply_rope1, rope import comfy.ldm.common_dit @@ -81,17 +81,21 @@ def qkv_fn_k(x): q = qkv_fn_q(x) k = qkv_fn_k(x) + if patches.get("attn1_patch"): + q_patch, k_patch = q, k + q = AttentionTensorContainer(q.view(b, s, n * d)) + k = AttentionTensorContainer(k.view(b, s, n * d)) + v = AttentionTensorContainer(self.v(x).view(b, s, n * d)) + x = optimized_attention( - q.view(b, s, n * d), - k.view(b, s, n * d), - self.v(x).view(b, s, n * d), + q, k, v, heads=self.num_heads, transformer_options=transformer_options, ) if "attn1_patch" in patches: for p in patches["attn1_patch"]: - x = p({"x": x, "q": q, "k": k, "transformer_options": transformer_options}) + x = p({"x": x, "q": q_patch, "k": k_patch, "transformer_options": transformer_options}) x = self.o(x) return x @@ -106,9 +110,9 @@ def forward(self, x, context, transformer_options={}, **kwargs): context(Tensor): Shape [B, L2, C] """ # compute query, key, value - q = self.norm_q(self.q(x)) - k = self.norm_k(self.k(context)) - v = self.v(context) + q = AttentionTensorContainer(self.norm_q(self.q(x))) + k = AttentionTensorContainer(self.norm_k(self.k(context))) + v = AttentionTensorContainer(self.v(context)) # compute attention x = optimized_attention(q, k, v, heads=self.num_heads, transformer_options=transformer_options) @@ -143,13 +147,15 @@ def forward(self, x, context, context_img_len, transformer_options={}): # compute query, key, value q = self.norm_q(self.q(x)) - k = self.norm_k(self.k(context)) - v = self.v(context) k_img = self.norm_k_img(self.k_img(context_img)) v_img = self.v_img(context_img) # Sageattn can cause Nans here, don't allow it as there is no speed difference anyway as img attention is tiny. img_x = optimized_attention(q, k_img, v_img, heads=self.num_heads, transformer_options=transformer_options, low_precision_attention=False) + del k_img, v_img # compute attention + q = AttentionTensorContainer(q) + k = AttentionTensorContainer(self.norm_k(self.k(context))) + v = AttentionTensorContainer(self.v(context)) x = optimized_attention(q, k, v, heads=self.num_heads, transformer_options=transformer_options) # output diff --git a/comfy/ldm/wan/model_animate2.py b/comfy/ldm/wan/model_animate2.py index dc34cad886c..8bc55f4e9db 100644 --- a/comfy/ldm/wan/model_animate2.py +++ b/comfy/ldm/wan/model_animate2.py @@ -116,7 +116,8 @@ class PoseBranchCache: Caching the block input rather than its K/V halves the memory; reprojecting K/V on read costs ~4% of re-running the block. One slot per distinct pose sequence, so under context windows each window keeps its own; least recently used slots are evicted when - the store device runs low on memory. Created and freed by WanAnimate2Cache. + the store device runs low on memory. Created and freed by WanAnimate2Cache; Qwen-Image 2.1 + also stores its prefix K/V in it. """ CONVROT_GROUPSIZE = 256 @@ -129,21 +130,22 @@ def __init__(self, store_device=None, dtype="default"): self._pending = {} self._staging = {} - def select(self, pose_latents): + def select(self, k, create=True): # select runs at a forward boundary: an interrupted forward can leave copies in flight that a different slot's forward would then mistake for its own if self._pending: for t, stream in self._pending.values(): if stream is not None: stream.synchronize() self._pending = {} - # keyed on batch element 0, so a cond batch size change mid-run stays valid - k = pose_latents[:1] for s in self.slots: if s["key"].shape == k.shape and torch.equal(s["key"], k.to(s["key"].device)): self.slots.remove(s) self.slots.append(s) self.slot = s - return + return True + self.slot = None + if not create: + return False # cache what fits: a filled slot is the size estimate for the next one, and least recently used slots make room when the store device runs low est = max((self._slot_bytes(s) for s in self.slots), default=0) * 1.5 while self.slots and comfy.model_management.get_free_memory(self.store_device) < est: @@ -169,8 +171,7 @@ def free(self): def filled(self, num_blocks): return self.slot is not None and len(self.slot["blocks"]) == num_blocks - def put(self, i, x_pose): - t = x_pose[:1] + def put(self, i, t): params = None if self.dtype in ("int8", "int4"): # convrot is what lets low-bit survive the ~125x per-channel outliers here, and over a [tokens, dim] view per-row scale means per-token. The kernels want 2D and a power-of-4 group that divides dim. @@ -184,7 +185,7 @@ def put(self, i, x_pose): t, params = comfy.quant_ops.TensorWiseINT8Layout.quantize(t.reshape(-1, t.shape[-1]), is_weight=True, per_channel=True, convrot=True, convrot_groupsize=g) t = t.to(self.store_device, copy=True) - if comfy.model_management.pin_memory(t): + if comfy.model_management.pin_memory(t, evict_active=False): self.slot["pinned"].append(t) self.slot["blocks"][i] = t # the scales follow the blocks off the GPU: per-window slots would otherwise pile them up in VRAM (~200 MB per window at 480p int4) @@ -294,7 +295,7 @@ def forward_orig(self, x, t, context, clip_fea=None, freqs=None, freqs_pose=None cache = transformer_options.get("animate2_cache", None) if apply_pose else None if cache is not None: - cache.select(pose_latents) + cache.select(pose_latents[:1]) # keyed on batch element 0, so a cond batch size change mid-run stays valid cached = cache is not None and cache.filled(len(self.blocks)) x_pose = None @@ -342,7 +343,7 @@ def forward_orig(self, x, t, context, clip_fea=None, freqs=None, freqs_pose=None # pose-only prepass, to avoid inflating dynamic VRAM calibration when using multiple context windows for i, block in enumerate(self.blocks): transformer_options["block_index"] = i - cache.put(i, x_pose) + cache.put(i, x_pose[:1]) x_pose = block.forward_pose(x_pose, e0_pose, freqs_pose, context_pose, context_img_len=context_img_len_pose, transformer_options=transformer_options)[0] x_pose = None cached = True @@ -365,7 +366,7 @@ def forward_orig(self, x, t, context, clip_fea=None, freqs=None, freqs_pose=None del x_pose_in else: if cache is not None: - cache.put(i, x_pose) + cache.put(i, x_pose[:1]) # runs even under a block replace: its state has to reach block i+1 x_pose, k_pose, v_pose = block.forward_pose(x_pose, e0_pose, freqs_pose, context_pose, context_img_len=context_img_len_pose, transformer_options=transformer_options) if v_pose is not None and pose_strength != 1.0: diff --git a/comfy/ldm/wan/vae2_2.py b/comfy/ldm/wan/vae2_2.py index 8e1593a54c9..06d0621a333 100644 --- a/comfy/ldm/wan/vae2_2.py +++ b/comfy/ldm/wan/vae2_2.py @@ -1,6 +1,7 @@ # original version: https://github.com/Wan-Video/Wan2.2/blob/main/wan/modules/vae2_2.py # Copyright 2024-2025 The Alibaba Wan Team Authors. All rights reserved. + import torch import torch.nn as nn import torch.nn.functional as F @@ -15,7 +16,7 @@ class Resample(nn.Module): - def __init__(self, dim, mode): + def __init__(self, dim, mode, temporal_kernel=3): assert mode in ( "none", "upsample2d", @@ -40,7 +41,7 @@ def __init__(self, dim, mode): # ops.Conv2d(dim, dim//2, 3, padding=1) ) self.time_conv = CausalConv3d( - dim, dim * 2, (3, 1, 1), padding=(1, 0, 0)) + dim, dim * 2, (temporal_kernel, 1, 1), padding=(temporal_kernel // 2, 0, 0)) elif mode == "downsample2d": self.resample = nn.Sequential( nn.ZeroPad2d((0, 1, 0, 1)), @@ -50,7 +51,7 @@ def __init__(self, dim, mode): nn.ZeroPad2d((0, 1, 0, 1)), ops.Conv2d(dim, dim, 3, stride=(2, 2))) self.time_conv = CausalConv3d( - dim, dim, (3, 1, 1), stride=(2, 1, 1), padding=(0, 0, 0)) + dim, dim, (temporal_kernel, 1, 1), stride=(2, 1, 1), padding=(0, 0, 0)) else: self.resample = nn.Identity() @@ -96,7 +97,10 @@ def forward(self, x, feat_cache=None, feat_idx=[0]): x = x.reshape(b, c, t * 2, h, w) t = x.shape[2] x = rearrange(x, "b c t h w -> (b t) c h w") - x = self.resample(x) + if feat_cache is None and self.mode in ("upsample2d", "upsample3d"): + x = strip_apply(self.resample, x, scale=2) + else: + x = self.resample(x) x = rearrange(x, "(b t) c h w -> b c t h w", t=t) if self.mode == "downsample3d": @@ -114,9 +118,38 @@ def forward(self, x, feat_cache=None, feat_idx=[0]): return x +STRIP_ELEMS = 2 ** 24 + + +def strip_apply(fn, x, scale=1, halo=1, out=None): + # strips of rows bound cudnn's conv workspace, a halo row per 3x3 conv keeps them exact + n = -(-x.numel() * scale * scale // STRIP_ELEMS) + if n <= 1 and out is None: + return fn(x) + add = out is not None + size = x.shape[-2] + step = -(-size // n) + for a in range(0, size, step): + b = min(size, a + step) + lo = max(0, a - halo) + y = fn(x.narrow(-2, lo, min(size, b + halo) - lo)).narrow(-2, (a - lo) * scale, (b - a) * scale) + if out is None: + out = y.new_empty(*y.shape[:-2], size * scale, y.shape[-1]) + dst = out.narrow(-2, a * scale, (b - a) * scale) + if add: + dst.add_(y) + else: + dst.copy_(y) + return out + + +def conv3x3(in_dim, out_dim, temporal_kernel=3): + return CausalConv3d(in_dim, out_dim, (temporal_kernel, 3, 3), padding=(temporal_kernel // 2, 1, 1)) + + class ResidualBlock(nn.Module): - def __init__(self, in_dim, out_dim, dropout=0.0): + def __init__(self, in_dim, out_dim, dropout=0.0, temporal_kernel=3): super().__init__() self.in_dim = in_dim self.out_dim = out_dim @@ -125,17 +158,20 @@ def __init__(self, in_dim, out_dim, dropout=0.0): self.residual = nn.Sequential( RMS_norm(in_dim, images=False), nn.SiLU(), - CausalConv3d(in_dim, out_dim, 3, padding=1), + conv3x3(in_dim, out_dim, temporal_kernel), RMS_norm(out_dim, images=False), nn.SiLU(), nn.Dropout(dropout), - CausalConv3d(out_dim, out_dim, 3, padding=1), + conv3x3(out_dim, out_dim, temporal_kernel), ) self.shortcut = ( CausalConv3d(in_dim, out_dim, 1) if in_dim != out_dim else nn.Identity()) def forward(self, x, feat_cache=None, feat_idx=[0]): + if feat_cache is None: + # single image: the whole block runs in strips so its intermediates never exist at full size + return strip_apply(lambda s: self.residual(s).add_(self.shortcut(s)), x, halo=2) old_x = x for layer in self.residual: if isinstance(layer, CausalConv3d) and feat_cache is not None: @@ -302,7 +338,8 @@ def __init__(self, dropout, mult, temperal_downsample=False, - down_flag=False): + down_flag=False, + temporal_kernel=3): super().__init__() # Shortcut path with downsample @@ -316,13 +353,13 @@ def __init__(self, # Main path with residual blocks and downsample downsamples = [] for _ in range(mult): - downsamples.append(ResidualBlock(in_dim, out_dim, dropout)) + downsamples.append(ResidualBlock(in_dim, out_dim, dropout, temporal_kernel=temporal_kernel)) in_dim = out_dim # Add the final downsample block if down_flag: mode = "downsample3d" if temperal_downsample else "downsample2d" - downsamples.append(Resample(out_dim, mode=mode)) + downsamples.append(Resample(out_dim, mode=mode, temporal_kernel=temporal_kernel)) self.downsamples = nn.Sequential(*downsamples) @@ -342,7 +379,8 @@ def __init__(self, dropout, mult, temperal_upsample=False, - up_flag=False): + up_flag=False, + temporal_kernel=3): super().__init__() # Shortcut path with upsample if up_flag: @@ -358,13 +396,13 @@ def __init__(self, # Main path with residual blocks and upsample upsamples = [] for _ in range(mult): - upsamples.append(ResidualBlock(in_dim, out_dim, dropout)) + upsamples.append(ResidualBlock(in_dim, out_dim, dropout, temporal_kernel=temporal_kernel)) in_dim = out_dim # Add the final upsample block if up_flag: mode = "upsample3d" if temperal_upsample else "upsample2d" - upsamples.append(Resample(out_dim, mode=mode)) + upsamples.append(Resample(out_dim, mode=mode, temporal_kernel=temporal_kernel)) self.upsamples = nn.Sequential(*upsamples) @@ -373,6 +411,8 @@ def forward(self, x, feat_cache=None, feat_idx=[0], first_chunk=False): for module in self.upsamples: x_main = module(x_main, feat_cache, feat_idx) if self.avg_shortcut is not None: + if feat_cache is None: + return strip_apply(lambda s: self.avg_shortcut(s, first_chunk), x, scale=self.avg_shortcut.factor_s, halo=0, out=x_main) x_shortcut = self.avg_shortcut(x, first_chunk) return x_main + x_shortcut else: @@ -390,6 +430,8 @@ def __init__( attn_scales=[], temperal_downsample=[True, True, False], dropout=0.0, + in_channels=12, + temporal_kernel=3, ): super().__init__() self.dim = dim @@ -404,7 +446,7 @@ def __init__( scale = 1.0 # init block - self.conv1 = CausalConv3d(12, dims[0], 3, padding=1) + self.conv1 = conv3x3(in_channels, dims[0], temporal_kernel) # downsample blocks downsamples = [] @@ -420,22 +462,23 @@ def __init__( mult=num_res_blocks, temperal_downsample=t_down_flag, down_flag=i != len(dim_mult) - 1, + temporal_kernel=temporal_kernel, )) scale /= 2.0 self.downsamples = nn.Sequential(*downsamples) # middle blocks self.middle = nn.Sequential( - ResidualBlock(out_dim, out_dim, dropout), + ResidualBlock(out_dim, out_dim, dropout, temporal_kernel=temporal_kernel), AttentionBlock(out_dim), - ResidualBlock(out_dim, out_dim, dropout), + ResidualBlock(out_dim, out_dim, dropout, temporal_kernel=temporal_kernel), ) # # output blocks self.head = nn.Sequential( RMS_norm(out_dim, images=False), nn.SiLU(), - CausalConv3d(out_dim, z_dim, 3, padding=1), + conv3x3(out_dim, z_dim, temporal_kernel), ) def forward(self, x, feat_cache=None, feat_idx=[0]): @@ -506,6 +549,8 @@ def __init__( attn_scales=[], temperal_upsample=[False, True, True], dropout=0.0, + out_channels=12, + temporal_kernel=3, ): super().__init__() self.dim = dim @@ -518,13 +563,13 @@ def __init__( # dimensions dims = [dim * u for u in [dim_mult[-1]] + dim_mult[::-1]] # init block - self.conv1 = CausalConv3d(z_dim, dims[0], 3, padding=1) + self.conv1 = conv3x3(z_dim, dims[0], temporal_kernel) # middle blocks self.middle = nn.Sequential( - ResidualBlock(dims[0], dims[0], dropout), + ResidualBlock(dims[0], dims[0], dropout, temporal_kernel=temporal_kernel), AttentionBlock(dims[0]), - ResidualBlock(dims[0], dims[0], dropout), + ResidualBlock(dims[0], dims[0], dropout, temporal_kernel=temporal_kernel), ) # upsample blocks @@ -540,6 +585,7 @@ def __init__( mult=num_res_blocks + 1, temperal_upsample=t_up_flag, up_flag=i != len(dim_mult) - 1, + temporal_kernel=temporal_kernel, )) self.upsamples = nn.Sequential(*upsamples) @@ -547,7 +593,7 @@ def __init__( self.head = nn.Sequential( RMS_norm(out_dim, images=False), nn.SiLU(), - CausalConv3d(out_dim, 12, 3, padding=1), + conv3x3(out_dim, out_channels, temporal_kernel), ) def forward(self, x, feat_cache=None, feat_idx=[0], first_chunk=False): @@ -580,7 +626,7 @@ def forward(self, x, feat_cache=None, feat_idx=[0], first_chunk=False): if feat_cache is not None: x = layer(x, feat_cache, feat_idx, first_chunk) else: - x = layer(x) + x = layer(x, first_chunk=first_chunk) ## head for layer in self.head: @@ -624,6 +670,9 @@ def __init__( attn_scales=[], temperal_downsample=[True, True, False], dropout=0.0, + image_channels=3, + patch_size=2, + temporal_kernel=3, ): super().__init__() self.dim = dim @@ -633,6 +682,7 @@ def __init__( self.attn_scales = attn_scales self.temperal_downsample = temperal_downsample self.temperal_upsample = temperal_downsample[::-1] + self.patch_size = patch_size # modules self.encoder = Encoder3d( @@ -643,6 +693,8 @@ def __init__( attn_scales, self.temperal_downsample, dropout, + in_channels=image_channels * patch_size * patch_size, + temporal_kernel=temporal_kernel, ) self.conv1 = CausalConv3d(z_dim * 2, z_dim * 2, 1) self.conv2 = CausalConv3d(z_dim, z_dim, 1) @@ -654,12 +706,18 @@ def __init__( attn_scales, self.temperal_upsample, dropout, + out_channels=image_channels * patch_size * patch_size, + temporal_kernel=temporal_kernel, ) def encode(self, x): + if x.ndim == 4: + # single image: no temporal cache, which would keep every conv input alive for the whole pass + x = patchify(x.unsqueeze(2), patch_size=self.patch_size) + return self.conv1(self.encoder(x)).chunk(2, dim=1)[0].squeeze(2) conv_idx = [0] feat_map = [None] * count_conv3d(self.encoder) - x = patchify(x, patch_size=2) + x = patchify(x, patch_size=self.patch_size) t = x.shape[2] iter_ = 1 + (t - 1) // 4 for i in range(iter_): @@ -681,6 +739,9 @@ def encode(self, x): return mu def decode(self, z): + if z.ndim == 4: + out = self.decoder(self.conv2(z.unsqueeze(2)), first_chunk=True) + return unpatchify(out, patch_size=self.patch_size).squeeze(2) conv_idx = [0] feat_map = [None] * count_conv3d(self.decoder) iter_ = z.shape[2] @@ -701,7 +762,7 @@ def decode(self, z): feat_idx=conv_idx, ) out = torch.cat([out, out_], 2) - out = unpatchify(out, patch_size=2) + out = unpatchify(out, patch_size=self.patch_size) return out def reparameterize(self, mu, log_var): diff --git a/comfy/ldm/yue2/model.py b/comfy/ldm/yue2/model.py new file mode 100644 index 00000000000..5825820a54c --- /dev/null +++ b/comfy/ldm/yue2/model.py @@ -0,0 +1,87 @@ +"""YuE2 acoustic transformer. Adapted from M·A·P YuE2 (Apache-2.0).""" + +import torch +from torch import nn + +import comfy.model_management +import comfy.model_prefetch +import comfy.ops +from comfy.ldm.modules.attention import optimized_attention_for_device +from comfy.ldm.modules.diffusionmodules.util import timestep_embedding +from comfy.text_encoders.llama import Qwen3_8BConfig, RMSNorm, TransformerBlock, precompute_freqs_cis + + +def model_config(**overrides): + return Qwen3_8BConfig(**{ + "vocab_size": 184704, "hidden_size": 2048, "intermediate_size": 6144, + "num_hidden_layers": 28, "num_attention_heads": 16, "num_key_value_heads": 8, + "max_position_embeddings": 24576, "merged_qkv": True, "merged_mlp": True, + **overrides, + }) + + +class TimestepEmbedder(nn.Module): + def __init__(self, hidden_size, dtype, device, operations): + super().__init__() + self.mlp = nn.Sequential( + operations.Linear(256, hidden_size, dtype=dtype, device=device), + nn.SiLU(), + operations.Linear(hidden_size, hidden_size, dtype=dtype, device=device), + ) + + def forward(self, t, dtype): + return self.mlp(timestep_embedding(t, 256).to(dtype)) + + +class AudioPositionEmbedding(nn.Module): + def __init__(self, frames, hidden_size, dtype, device): + super().__init__() + self.register_buffer("pe", torch.empty(frames, hidden_size, dtype=dtype, device=device)) + + def forward(self, length, x): + return comfy.ops.cast_to_input(self.pe[:length], x) + + +class YuE2(nn.Module): + def __init__(self, dtype=None, device=None, operations=None, **kwargs): + super().__init__() + self.dtype = dtype + self.config = model_config(**kwargs.get("config", {})) + config = self.config + self.model = nn.Module() + self.model.layers = nn.ModuleList([ + TransformerBlock(config, i, device=device, dtype=dtype, ops=operations) + for i in range(config.num_hidden_layers) + ]) + self.model.norm = RMSNorm(config.hidden_size, config.rms_norm_eps, device=device, dtype=dtype) + self.vae2llm = operations.Linear(64, config.hidden_size, dtype=dtype, device=device) + self.llm2vae = operations.Linear(config.hidden_size, 64, dtype=dtype, device=device) + self.time_embedder = TimestepEmbedder(config.hidden_size, dtype, device, operations) + self.latent_pos_embed = AudioPositionEmbedding(config.max_position_embeddings, config.hidden_size, dtype, device) + + def forward(self, x, timestep, context, yue2_chunks, transformer_options={}, **kwargs): + batch, channels, frames = x.shape + if frames != yue2_chunks[-1][1]: + raise ValueError("YuE2 latent duration must match the seconds output of YuE2 Text Encode.") + config = self.config + time = self.time_embedder(timestep.to(x.dtype), x.dtype)[:, None] + output = torch.empty_like(x) + attention = optimized_attention_for_device(x.device) + for start, end, kv_start, kv_end in yue2_chunks: + comfy.model_management.throw_exception_if_processing_interrupted() + ar_length = kv_end - kv_start + length = end - start + 2 + state = torch.nn.functional.pad(x[..., start:end].transpose(1, 2), (0, 0, 1, 1)) + state = self.vae2llm(state) + time + self.latent_pos_embed(length, x)[None] + positions = torch.arange(ar_length, ar_length + length, device=x.device)[None] + rope = precompute_freqs_cis(config.head_dim, positions, config.rope_theta, device=x.device) + prefix = context[:, kv_start:kv_end].reshape(batch, ar_length, config.num_hidden_layers, 2, config.num_key_value_heads, config.head_dim) + prefix = prefix.permute(2, 3, 0, 4, 1, 5) + prefetch = comfy.model_prefetch.make_prefetch_queue(list(self.model.layers), x.device, transformer_options) + for index, layer in enumerate(self.model.layers): + comfy.model_prefetch.prefetch_queue_pop(prefetch, x.device, layer, state.dtype) + state, _ = layer(state, freqs_cis=rope, optimized_attention=attention, + past_key_value=(prefix[index, 0], prefix[index, 1], ar_length)) + comfy.model_prefetch.prefetch_queue_pop(prefetch, x.device, None) + output[..., start:end] = self.llm2vae(self.model.norm(state))[:, 1:-1].transpose(1, 2) + return output diff --git a/comfy/lora.py b/comfy/lora.py index aa98be152b1..117f50ce80a 100644 --- a/comfy/lora.py +++ b/comfy/lora.py @@ -327,11 +327,16 @@ def model_lora_keys_unet(model, key_map={}): for k in sdk: if k.startswith("diffusion_model.") and k.endswith(".weight"): #QwenImage lora format key_lora = k[len("diffusion_model."):-len(".weight")] - # Direct mapping for transformer_blocks format (QwenImage LoRA format) - key_map["{}".format(key_lora)] = k - # Support transformer prefix format - key_map["transformer.{}".format(key_lora)] = k - key_map["lycoris_{}".format(key_lora.replace(".", "_"))] = k #SimpleTuner lycoris format + targets = [(key_lora, k)] + if key_lora.endswith(".img_mlp.gate_up"): # Qwen Image 2.1 fuses gate_layer/proj at load; LoRAs address the halves + half = sd[k].shape[0] // 2 + targets = [(key_lora.replace(".gate_up", ".gate_layer"), (k, (0, 0, half))), (key_lora.replace(".gate_up", ".proj"), (k, (0, half, half)))] + for key_lora, to in targets: + # Direct mapping for transformer_blocks format (QwenImage LoRA format) + key_map["{}".format(key_lora)] = to + # Support transformer prefix format + key_map["transformer.{}".format(key_lora)] = to + key_map["lycoris_{}".format(key_lora.replace(".", "_"))] = to #SimpleTuner lycoris format if isinstance(model, comfy.model_base.Krea2): diffusers_keys = comfy.utils.krea2_to_diffusers(model.model_config.unet_config, output_prefix="diffusion_model.") diff --git a/comfy/model_base.py b/comfy/model_base.py index c6fa1d68c7e..f4d3ac838d8 100644 --- a/comfy/model_base.py +++ b/comfy/model_base.py @@ -23,6 +23,7 @@ import comfy.ldm.lightricks.av_model import comfy.ldm.minimax.model import comfy.ldm.minimax_music.dit +import comfy.ldm.yue2.model import comfy.nested_tensor import comfy.ldm.lightricks.symmetric_patchifier import comfy.context_windows @@ -62,6 +63,7 @@ import comfy.ldm.seedvr.model import comfy.ldm.boogu.model import comfy.ldm.qwen_image.model +import comfy.ldm.qwen_image21.model import comfy.ldm.mage_flow.model import comfy.ldm.joyimage.model import comfy.ldm.ideogram4.model @@ -2551,6 +2553,25 @@ def extra_conds(self, **kwargs): out['refer_audio'] = comfy.conds.CONDRegular(refer_audio) return out +class YuE2(BaseModel): + def __init__(self, model_config, model_type=ModelType.FLOW, device=None): + super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.yue2.model.YuE2) + + def extra_conds(self, **kwargs): + out = super().extra_conds(**kwargs) + context = kwargs["cross_attn"].to(device=kwargs["device"], dtype=self.get_dtype_inference()) + out["c_crossattn"] = comfy.conds.CONDRegular(context) + out["yue2_chunks"] = comfy.conds.CONDConstant(kwargs["yue2_chunks"]) + return out + + def extra_conds_shapes(self, **kwargs): + return {"c_crossattn": kwargs["cross_attn"].shape} + + def memory_required(self, input_shape, cond_shapes={}): + context_size = sum(math.prod(shape) for shape in cond_shapes.get("c_crossattn", [])) + return super().memory_required(input_shape, cond_shapes) + context_size * comfy.model_management.dtype_size(self.get_dtype_inference()) + + class MiniMaxMusic3(BaseModel): def __init__(self, model_config, model_type=ModelType.FLOW, device=None): super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.minimax_music.dit.MiniMaxMusic3DiT) @@ -2624,7 +2645,8 @@ def extra_conds_shapes(self, **kwargs): out = {} ref_latents = kwargs.get("reference_latents", None) if ref_latents is not None: - out['ref_latents'] = list([1, 16, sum(map(lambda a: math.prod(a.size()), ref_latents)) // 16]) + c = self.latent_format.latent_channels + out['ref_latents'] = list([1, c, sum(map(lambda a: math.prod(a.size()), ref_latents)) // c]) return out class MageFlow(QwenImage): @@ -2635,11 +2657,31 @@ def process_timestep(self, timestep, **kwargs): # Mage runs in bf16 and rounds its timestep frequency table to the timestep dtype, keep that on fp32 devices. return timestep.to(torch.bfloat16) - def extra_conds_shapes(self, **kwargs): - out = {} - ref_latents = kwargs.get("reference_latents", None) - if ref_latents is not None: - out['ref_latents'] = list([1, 128, sum(map(lambda a: math.prod(a.size()), ref_latents)) // 128]) +class QwenImage21(QwenImage): + def __init__(self, model_config, model_type=ModelType.FLUX, device=None): + super().__init__(model_config, model_type, device=device, unet_model=comfy.ldm.qwen_image21.model.QwenImage21Transformer2DModel) + + def get_dynamic_vram__units(self): + return list(self.diffusion_model.transformer_blocks), [] + + @property + def current_patcher(self): + return self._current_patcher + + @current_patcher.setter + def current_patcher(self, patcher): + # set by pre_run / cleanup: the prefix K/V cache lives for one sampling run, off when hooks can repatch weights mid-run + self._current_patcher = patcher + diffusion_model = getattr(self, "diffusion_model", None) + if diffusion_model is not None: + diffusion_model.current_patcher = patcher + diffusion_model.reset_prefix_cache(patcher is not None and len(patcher.hook_patches) == 0) + + def extra_conds(self, **kwargs): + out = super().extra_conds(**kwargs) + image_slots = kwargs.get("image_slots", None) + if image_slots is not None: + out['image_slots'] = comfy.conds.CONDConstant(image_slots) return out class JoyImage(BaseModel): diff --git a/comfy/model_detection.py b/comfy/model_detection.py index 0f20f13ab28..1f2342647a7 100644 --- a/comfy/model_detection.py +++ b/comfy/model_detection.py @@ -959,6 +959,27 @@ def detect_unet_config(state_dict, key_prefix, metadata=None): dit_config["num_layers"] = count_blocks(state_dict_keys, '{}transformer_blocks.'.format(key_prefix) + '{}.') return dit_config + qwen_image21_keys = ['txt_in.text_norm.weight', 'modulation.1.weight', 'transformer_blocks.0.attn.norm_q.weight', 'img_in.weight', 'proj_out.weight'] + if all('{}{}'.format(key_prefix, k) in state_dict_keys for k in qwen_image21_keys) and any('{}transformer_blocks.0.img_mlp.{}.weight'.format(key_prefix, k) in state_dict_keys for k in ('gate_up', 'proj')): # Qwen Image 2.1 + dit_config = {} + dit_config["image_model"] = "qwen_image21" + head_dim = state_dict['{}transformer_blocks.0.attn.norm_q.weight'.format(key_prefix)].shape[0] + inner_dim = state_dict['{}img_in.weight'.format(key_prefix)].shape[0] + dit_config["in_channels"] = state_dict['{}img_in.weight'.format(key_prefix)].shape[1] + dit_config["out_channels"] = state_dict['{}proj_out.weight'.format(key_prefix)].shape[0] + dit_config["num_layers"] = count_blocks(state_dict_keys, '{}transformer_blocks.'.format(key_prefix) + '{}.') + dit_config["attention_head_dim"] = head_dim + dit_config["num_attention_heads"] = inner_dim // head_dim + dit_config["context_in_dim"] = state_dict['{}txt_in.text_norm.weight'.format(key_prefix)].shape[0] + # gate and up projections fuse into one GEMM when their rows can be concatenated: plain weights or per-row scales; a comfy-saved file is already fused + gate_up = state_dict.get('{}transformer_blocks.0.img_mlp.gate_up.weight'.format(key_prefix), None) + if gate_up is not None: + dit_config["mlp_ratio"] = gate_up.shape[0] // 2 // inner_dim + else: + dit_config["mlp_ratio"] = state_dict['{}transformer_blocks.0.img_mlp.proj.weight'.format(key_prefix)].shape[0] // inner_dim + dit_config["fused_mlp"] = gate_up is not None + return dit_config + if '{}txt_norm.weight'.format(key_prefix) in state_dict_keys: # Qwen Image dit_config = {} dit_config["image_model"] = "qwen_image" @@ -1152,6 +1173,12 @@ def detect_unet_config(state_dict, key_prefix, metadata=None): "text_dim": 4096, } + if all(key_prefix + key in state_dict for key in ( + "vae2llm.weight", "llm2vae.weight", "latent_pos_embed.pe", + "model.layers.0.self_attn.qkv_proj.weight", "time_embedder.mlp.0.weight", + )): + return {"audio_model": "yue2"} + if '{}input_blocks.0.0.weight'.format(key_prefix) not in state_dict_keys: return None diff --git a/comfy/model_management.py b/comfy/model_management.py index 1eead636e39..c050441ca49 100644 --- a/comfy/model_management.py +++ b/comfy/model_management.py @@ -499,19 +499,31 @@ def aotriton_supported(): can_use_flash_attention() evaluates runtime eligibility for the given parameters; on a ROCm build that includes checking the gpu arch against the - kernel images AOTriton was compiled for. Querying it avoids assuming where - those images live inside the torch install. The probe tensor is shaped and + arches AOTriton was built for. Querying it avoids assuming where the kernel + images live inside the torch install. The probe tensor is shaped and typed to pass the unrelated SDPA checks, so False means no hardware support rather than a rejected shape. + + It answers True on a supported arch whose kernel image was never shipped, + and that only fails at launch, without raising. So run one attention + through the flash backend and force the pending error check. """ try: + device = get_torch_device() if not torch.backends.cuda.is_flash_attention_available(): # not built with flash attention return False - q = torch.empty((1, 1, 8, 64), dtype=torch.float16, device=get_torch_device()) + q = torch.zeros((1, 1, 8, 64), dtype=torch.float16, device=device) params = torch.backends.cuda.SDPAParams(q, q, q, None, 0.0, False, False) - return torch.backends.cuda.can_use_flash_attention(params, False) - except (AttributeError, RuntimeError, TypeError) as e: - logging.warning("Could not query aotriton support: {}".format(e)) + if not torch.backends.cuda.can_use_flash_attention(params, False): + return False + from torch.nn.attention import SDPBackend, sdpa_kernel + with sdpa_kernel(SDPBackend.FLASH_ATTENTION): + torch.nn.functional.scaled_dot_product_attention(q, q, q) + torch.cuda.synchronize() + torch.zeros(1, device=device).add_(1).item() # raises if the launch above failed + return True + except Exception as e: + logging.warning("Could not run flash attention, disabling it: {}".format(e)) return False logging.info("AMD arch: {}".format(arch)) @@ -647,6 +659,7 @@ def mark_mmap_dirty(storage): PIN_SUBSETS = [ "weights", "patches" ] LOADED_PIN_SUBSETS = [ "weights-loaded", "patches-loaded" ] +FAST_PIN_SUBSETS = [ "weights-fast", "patches-fast" ] def models_for_pin_eviction(active, current_prompt=None): for loaded_model in current_loaded_models: @@ -667,32 +680,48 @@ def free_model_pins(size, subsets, current_prompt, active, registrations=False): freed = model.unregister_inactive_pins(size, subsets=subsets) else: freed = model.partially_unload_ram(size, subsets=subsets) + if freed > 0: + detail( + "Pin eviction: model=%s subsets=%s workflow=%s active=%s action=%s freed_mb=%.1f", + model.model.__class__.__name__, subsets, current_prompt, active, + "unregister" if registrations else "destroy", freed / (1024 ** 2), + ) freed_total += freed size -= freed return freed_total def pin_eviction_tiers(loaded, evict_active): tiers = [ + (FAST_PIN_SUBSETS, False, False), (PIN_SUBSETS, False, None), (LOADED_PIN_SUBSETS, False, None), + (FAST_PIN_SUBSETS, True, False), (LOADED_PIN_SUBSETS, True, None), ] if not loaded: tiers.append((PIN_SUBSETS, True, False)) if evict_active: - tiers.append((PIN_SUBSETS, True, True)) + tiers.extend([ + (FAST_PIN_SUBSETS, False, True), + (FAST_PIN_SUBSETS, True, True), + (PIN_SUBSETS, True, True), + ]) return tiers def registration_eviction_tiers(evict_active): subsets = PIN_SUBSETS + LOADED_PIN_SUBSETS tiers = [ - (subsets, False, False), - (subsets, True, False), + (FAST_PIN_SUBSETS, False, False, False), + (subsets, False, False, True), + (FAST_PIN_SUBSETS, True, False, False), + (subsets, True, False, True), ] if evict_active: tiers.extend([ - (subsets, False, True), - (subsets, True, True), + (FAST_PIN_SUBSETS, False, True, False), + (subsets, False, True, True), + (FAST_PIN_SUBSETS, True, True, False), + (subsets, True, True, True), ]) return tiers @@ -718,10 +747,7 @@ def should_free_pins_for_ram_pressure(shortfall): def ensure_pin_budget(size, evict_active=False, loaded=False): if args.high_ram: return True - if args.fast_disk: - shortfall = TOTAL_PINNED_MEMORY + size - MAX_PINNED_MEMORY - else: - shortfall = size + max(comfy.memory_management.RAM_CACHE_HEADROOM / 2, 2048 * 1024 ** 2) - comfy.system_memory.virtual_memory_available() + shortfall = size + max(comfy.memory_management.RAM_CACHE_HEADROOM / 2, 2048 * 1024 ** 2) - comfy.system_memory.virtual_memory_available() if shortfall <= 0: return True @@ -735,8 +761,8 @@ def free_registrations(shortfall, evict_active=True): return True shortfall += REGISTERABLE_PIN_HYSTERESIS - for subsets, current_prompt, active in registration_eviction_tiers(evict_active): - shortfall -= free_model_pins(shortfall, subsets, current_prompt, active, registrations=True) + for subsets, current_prompt, active, registrations in registration_eviction_tiers(evict_active): + shortfall -= free_model_pins(shortfall, subsets, current_prompt, active, registrations=registrations) return shortfall <= REGISTERABLE_PIN_HYSTERESIS def ensure_pin_registerable(size, evict_active=True): @@ -1193,7 +1219,9 @@ def text_encoder_offload_device(): def text_encoder_device(): if args.gpu_only: return get_torch_device() - elif vram_state in (VRAMState.HIGH_VRAM, VRAMState.NORMAL_VRAM) or comfy.memory_management.aimdo_enabled: + if comfy.memory_management.aimdo_enabled: + return get_torch_device() + elif vram_state in (VRAMState.HIGH_VRAM, VRAMState.NORMAL_VRAM): if should_use_fp16(prioritize_performance=False): return get_torch_device() else: @@ -1373,7 +1401,6 @@ def current_stream(device): STREAM_AIMDO_CAST_BUFFERS = {} LARGEST_AIMDO_CASTED_WEIGHT = (None, 0) CROSS_STEP_STATE = weakref.WeakSet() -MALLOC_GRAPH_MODULES = weakref.WeakSet() DEFAULT_AIMDO_CAST_BUFFER_RESERVATION_SIZE = 16 * 1024 ** 3 @@ -1445,7 +1472,7 @@ def reset_cast_buffers(): pin_state = model.model.dynamic_pins[model.load_device] if pin_state["active"]: - for subset in ("weights", "weights-loaded"): + for subset in ("weights", "weights-loaded", "weights-fast"): *_, buckets = pin_state[subset] for size, bucket in list(buckets.items()): bucket[:] = [ entry for entry in bucket if entry[-1] is not None ] @@ -1453,15 +1480,12 @@ def reset_cast_buffers(): del buckets[size] pin_state["active"] = False - model.partially_unload_ram(1e30, subsets=[ "patches", "patches-loaded" ]) - for subset in ("patches", "patches-loaded"): + model.partially_unload_ram(1e30, subsets=[ "patches", "patches-loaded", "patches-fast" ]) + for subset in ("patches", "patches-loaded", "patches-fast"): pin_state[subset] = (comfy_aimdo.host_buffer.HostBuffer(0, 8 * 1024 * 1024, pinned_hostbuf_size(model.model_size())), [], [-1], [0], [0], {}) STREAM_CAST_BUFFERS.clear() STREAM_AIMDO_CAST_BUFFERS.clear() - for module in MALLOC_GRAPH_MODULES: - del module._comfy_malloc_graph - MALLOC_GRAPH_MODULES.clear() soft_empty_cache() def get_offload_stream(device): @@ -1613,7 +1637,7 @@ def discard_cuda_async_error(): #Dump it! We already know about it from the synchronous return pass -def pin_memory(tensor): +def pin_memory(tensor, evict_active=True): global TOTAL_PINNED_MEMORY if MAX_PINNED_MEMORY <= 0: return False @@ -1635,7 +1659,8 @@ def pin_memory(tensor): size = tensor.nbytes comfy.memory_management.extra_ram_release(comfy.memory_management.RAM_CACHE_HEADROOM) - ensure_pin_registerable(size) + if not ensure_pin_registerable(size, evict_active=evict_active): + return False ptr = tensor.data_ptr() if ptr == 0: @@ -1754,6 +1779,12 @@ def force_upcast_attention_dtype(): else: return None +#Developers and agents: You almost never want to call this function from Model code as it does +#not account for ComfyUIs smart memory feature combining with Dynamic VRAM, where inactive models +#are preserved in VRAM right up until there is higher priority demand (I.E whatever you want to do +#that makes you meansure VRAM from model code). Instead call get_free_memory() on the ModelPatcher +#for your BaseModel object (.current_patcher) instead to count this VRAM as free and then Dynamic +#VRAM will evict that extra VRAM for you when you use it. def get_free_memory(dev=None, torch_free_too=False): global directml_enabled if dev is None: @@ -2022,6 +2053,24 @@ def supports_fp64(device=None): return True +def supports_int8_compute(device=None): + # The eager comfy_kitchen backend implements int8 weight-only quantized + # matmul via torch._int_mm, which PyTorch does not implement for MPS. + # https://github.com/pytorch/pytorch/issues/141287 + if (device is not None and is_device_mps(device)) or mps_mode(): + return False + + if is_intel_xpu(): + return False + + if is_directml_enabled(): + return False + + if is_ixuca(): + return False + + return True + def extended_fp16_support(): # TODO: check why some models work with fp16 on newer torch versions but not on older if torch_version_numeric < (2, 7): diff --git a/comfy/model_patcher.py b/comfy/model_patcher.py index 08e69fae149..9d363be93c5 100644 --- a/comfy/model_patcher.py +++ b/comfy/model_patcher.py @@ -338,9 +338,10 @@ def to(self, *args, **kwargs): class ModelPatcher: - def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False): + def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False, fast_disk=False): self.size = size self.model = model + self.fast_disk = not comfy.model_management.args.disable_fast_disk and bool(comfy.model_management.args.fast_disk or fast_disk) if not hasattr(self.model, 'device'): logging.debug("Model doesn't have a device attribute.") self.model.device = offload_device @@ -442,7 +443,7 @@ def clone(self, disable_dynamic=False, model_override=None, force_deepcopy=False if model_override is None: model_override = self.get_clone_model_override() - n = class_(model_override[0], self.load_device, self.offload_device, self.model_size(), weight_inplace_update=self.weight_inplace_update) + n = class_(model_override[0], self.load_device, self.offload_device, self.model_size(), weight_inplace_update=self.weight_inplace_update, fast_disk=self.fast_disk) n.patches = {} for k in self.patches: n.patches[k] = self.patches[k][:] @@ -873,6 +874,8 @@ def get_key_patches(self, filter_prefix=None): bk = self.backup.get(k, None) hbk = self.hook_backup.get(k, None) weight, set_func, convert_func = get_key_weight(self.model, k) + if not isinstance(weight, torch.Tensor): + continue if bk is not None: weight = bk.weight if hbk is not None: @@ -1748,14 +1751,14 @@ def __del__(self): class ModelPatcherDynamic(ModelPatcher): - def __new__(cls, model=None, load_device=None, offload_device=None, size=0, weight_inplace_update=False): + def __new__(cls, model=None, load_device=None, offload_device=None, size=0, weight_inplace_update=False, fast_disk=False): if load_device is not None and comfy.model_management.is_device_cpu(load_device): #reroute to default MP for CPUs - return ModelPatcher(model, load_device, offload_device, size, weight_inplace_update) + return ModelPatcher(model, load_device, offload_device, size, weight_inplace_update, fast_disk) return super().__new__(cls) - def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False): - super().__init__(model, load_device, offload_device, size, weight_inplace_update) + def __init__(self, model, load_device, offload_device, size=0, weight_inplace_update=False, fast_disk=False): + super().__init__(model, load_device, offload_device, size, weight_inplace_update, fast_disk) if not hasattr(self.model, "dynamic_vbars"): self.model.dynamic_vbars = {} if not hasattr(self.model, "dynamic_pins"): @@ -1782,6 +1785,9 @@ def register_load_device(self, device): "patches": (comfy_aimdo.host_buffer.HostBuffer(0, 0, 0), [], [-1], [0], [0], {}), "weights-loaded": (comfy_aimdo.host_buffer.HostBuffer(0, 0, 0), [], [-1], [0], [0], {}), "patches-loaded": (comfy_aimdo.host_buffer.HostBuffer(0, 0, 0), [], [-1], [0], [0], {}), + "weights-fast": (comfy_aimdo.host_buffer.HostBuffer(0, 0, 0), [], [-1], [0], [0], {}), + "patches-fast": (comfy_aimdo.host_buffer.HostBuffer(0, 0, 0), [], [-1], [0], [0], {}), + "fast_disk": self.fast_disk, "hostbufs_initialized": False, "failed": False, "active": False, @@ -1878,6 +1884,8 @@ def load(self, device_to=None, lowvram_model_memory=0, force_patch_weights=False pin_state["patches"] = (comfy_aimdo.host_buffer.HostBuffer(0, 8 * 1024 * 1024, hostbuf_size), [], [-1], [0], [0], {}) pin_state["weights-loaded"] = (comfy_aimdo.host_buffer.HostBuffer(0, 64 * 1024 * 1024, hostbuf_size), [], [-1], [0], [0], {}) pin_state["patches-loaded"] = (comfy_aimdo.host_buffer.HostBuffer(0, 8 * 1024 * 1024, hostbuf_size), [], [-1], [0], [0], {}) + pin_state["weights-fast"] = (comfy_aimdo.host_buffer.HostBuffer(0, 64 * 1024 * 1024, hostbuf_size), [], [-1], [0], [0], {}) + pin_state["patches-fast"] = (comfy_aimdo.host_buffer.HostBuffer(0, 8 * 1024 * 1024, hostbuf_size), [], [-1], [0], [0], {}) pin_state["hostbufs_initialized"] = True pin_state["failed"] = False pin_state["active"] = True @@ -2063,11 +2071,11 @@ def partially_unload(self, device_to, memory_to_free=0, force_patch_weights=Fals def loaded_ram_size(self): pin_state = self.model.dynamic_pins[self.load_device] - return pin_state["weights"][0].size + pin_state["weights-loaded"][0].size + return pin_state["weights"][0].size + pin_state["weights-loaded"][0].size + pin_state["weights-fast"][0].size def pinned_memory_size(self): pin_state = self.model.dynamic_pins[self.load_device] - return pin_state["weights"][3][0] + pin_state["weights-loaded"][3][0] + return pin_state["weights"][3][0] + pin_state["weights-loaded"][3][0] + pin_state["weights-fast"][3][0] def unregister_inactive_pins(self, ram_to_unload, subsets=[ "weights-loaded", "patches-loaded", "weights", "patches" ]): freed = 0 @@ -2096,7 +2104,7 @@ def unregister_inactive_pins(self, ram_to_unload, subsets=[ "weights-loaded", "p return freed return freed - def partially_unload_ram(self, ram_to_unload, subsets=[ "weights-loaded", "patches-loaded", "weights", "patches" ]): + def partially_unload_ram(self, ram_to_unload, subsets=[ "weights-fast", "patches-fast", "weights-loaded", "patches-loaded", "weights", "patches" ]): freed = 0 pin_state = self.model.dynamic_pins[self.load_device] for subset in subsets: diff --git a/comfy/model_prefetch.py b/comfy/model_prefetch.py index 35be626ae1a..2b5cd042aed 100644 --- a/comfy/model_prefetch.py +++ b/comfy/model_prefetch.py @@ -1,9 +1,9 @@ -import contextlib import logging import threading import warnings import weakref +import comfy_kitchen as ck import torch import comfy_aimdo.malloc_graph @@ -14,11 +14,11 @@ import comfy.ops PREFETCH_QUEUES = [] -GRAPH_MODULES = weakref.WeakSet() GRAPH_WARMED_MODULES = weakref.WeakSet() GRAPH_CAPTURE_STREAMS = {} -ACTIVE_MALLOC_GRAPHS = {} +MALLOC_GRAPHS = {} MALLOC_GRAPH_BREAKS = 0 +MALLOC_GRAPH_ROGUES = 0 MALLOC_GRAPH_USED = False def _malloc_graph_break(): @@ -29,40 +29,96 @@ def _malloc_graph_break(): def malloc_graph_enabled(device): return not args.disable_comfy_compiler and comfy.memory_management.aimdo_enabled and comfy.model_management.is_device_cuda(device) -@contextlib.contextmanager +class _PauseMallocGraph: + def __init__(self, sync=False): + self.sync = sync + + def __enter__(self): + graph = MALLOC_GRAPHS.get(threading.get_ident()) + if graph is not None and graph._comfy_active: + graph.pause(sync=self.sync) + + def __exit__(self, *args): + graph = MALLOC_GRAPHS.get(threading.get_ident()) + if graph is not None and graph._comfy_active: + graph.resume(sync=self.sync) + def pause_malloc_graph(sync=False): - graph = ACTIVE_MALLOC_GRAPHS.get(threading.get_ident()) - if graph is not None: - graph.pause(sync=sync) - try: - yield - finally: - if graph is not None: - graph.resume(sync=sync) - -def malloc_graph_begin(module, device): + return _PauseMallocGraph(sync) + +class _MallocGraphScope: + def __init__(self, device): + self.device = device + + def __enter__(self): + malloc_graph_begin(self.device) + + def __exit__(self, exc_type, *args): + if exc_type is None: + malloc_graph_end() + else: + cleanup_malloc_graph() + +def malloc_graph_scope(device): + return _MallocGraphScope(device) + +def malloc_graph_begin(device): global MALLOC_GRAPH_USED if not malloc_graph_enabled(device): return - graph = getattr(module, "_comfy_malloc_graph", None) + thread_id = threading.get_ident() + graph = MALLOC_GRAPHS.get(thread_id) if graph is None: graph = comfy_aimdo.malloc_graph.record( comfy.model_management.current_stream(device), args.assert_graph_breaks ) - module._comfy_malloc_graph = graph - comfy.model_management.MALLOC_GRAPH_MODULES.add(module) + graph._comfy_cuda_graph_modules = weakref.WeakSet() + MALLOC_GRAPHS[thread_id] = graph else: graph.push() - ACTIVE_MALLOC_GRAPHS[threading.get_ident()] = graph + if hasattr(ck, "set_allocation_context"): + ck.set_allocation_context(pause_malloc_graph()) + graph._comfy_active = True MALLOC_GRAPH_USED = True def malloc_graph_end(): thread_id = threading.get_ident() - graph = ACTIVE_MALLOC_GRAPHS.get(thread_id) - if graph is not None: + graph = MALLOC_GRAPHS.get(thread_id) + if graph is not None and graph._comfy_active: if graph.pop(): _malloc_graph_break() - ACTIVE_MALLOC_GRAPHS.pop(thread_id) + graph._comfy_active = False + +def cleanup_malloc_graph(): + global MALLOC_GRAPH_ROGUES + + graph = MALLOC_GRAPHS.pop(threading.get_ident(), None) + if graph is not None: + if graph._comfy_active: + graph.abort() + graph._comfy_active = False + for module in graph._comfy_cuda_graph_modules: + _drop_graph(module) + MALLOC_GRAPH_ROGUES += graph.rogue_count + del graph + +def pin_modules(comfy_modules, device, dtype=None): + registerable_size = 0 + for s in comfy_modules: + registerable_size += comfy.memory_management.vram_aligned_size([s.weight, s.bias]) + for param_key in ("weight", "bias"): + lowvram_fn = getattr(s, param_key + "_lowvram_function", None) + if lowvram_fn is not None: + registerable_size += lowvram_fn.memory_required() + + offload_stream, fully_faulted = comfy.ops.cast_modules_with_vbar(comfy_modules, None, device, None, True, return_faulted=True) + if not (comfy_modules and comfy_modules[0]._pin_state["fast_disk"]): + comfy.model_management.ensure_pin_registerable(registerable_size) + comfy.model_management.sync_stream(device, offload_stream) + if fully_faulted and dtype is not None: + for comfy_module in comfy_modules: + comfy.ops.resolve_cast_module_with_vbar(comfy_module, dtype, device, dtype, None, False, return_weights=False) + return offload_stream, fully_faulted def cleanup_prefetched_modules(module, comfy_modules): for s in comfy_modules: @@ -95,11 +151,10 @@ def _drop_graph(module): def cleanup_prefetch_queues(): global PREFETCH_QUEUES global MALLOC_GRAPH_BREAKS + global MALLOC_GRAPH_ROGUES global MALLOC_GRAPH_USED - graph = ACTIVE_MALLOC_GRAPHS.pop(threading.get_ident(), None) - if graph is not None: - graph.abort() + cleanup_malloc_graph() for queue in PREFETCH_QUEUES: for entry in queue: if entry is None or not isinstance(entry, tuple): @@ -109,17 +164,17 @@ def cleanup_prefetch_queues(): if comfy_modules is not None: cleanup_prefetched_modules(prefetched_module, comfy_modules) PREFETCH_QUEUES = [] - for module in GRAPH_MODULES: - _drop_graph(module) - GRAPH_MODULES.clear() GRAPH_WARMED_MODULES.clear() if MALLOC_GRAPH_USED: - logging.info("Comfy model compiler graph breaks: %d", MALLOC_GRAPH_BREAKS) + logging.info("Comfy model compiler graph breaks: %d, rogues: %d", MALLOC_GRAPH_BREAKS, MALLOC_GRAPH_ROGUES) MALLOC_GRAPH_BREAKS = 0 + MALLOC_GRAPH_ROGUES = 0 MALLOC_GRAPH_USED = False def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_graph=False, generator=None, malloc_scope=None): - malloc_graph = ACTIVE_MALLOC_GRAPHS.get(threading.get_ident()) + malloc_graph = MALLOC_GRAPHS.get(threading.get_ident()) + if malloc_graph is not None and not malloc_graph._comfy_active: + malloc_graph = None enable_graph = enable_graph and malloc_graph is not None and not args.disable_cuda_graphs and comfy.model_management.is_device_cuda(device) and getattr(module, "_v_block", None) is not None if queue is None: if malloc_graph is not None and malloc_scope is not None: @@ -180,21 +235,7 @@ def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_grap if hasattr(s, "_v"): comfy_modules.append(s) - registerable_size = 0 - for s in comfy_modules: - registerable_size += comfy.memory_management.vram_aligned_size([s.weight, s.bias]) - for param_key in ("weight", "bias"): - lowvram_fn = getattr(s, param_key + "_lowvram_function", None) - if lowvram_fn is not None: - registerable_size += lowvram_fn.memory_required() - - offload_stream, fully_faulted = comfy.ops.cast_modules_with_vbar(comfy_modules, None, device, None, True, return_faulted=True) - if not comfy.model_management.args.fast_disk: - comfy.model_management.ensure_pin_registerable(registerable_size) - comfy.model_management.sync_stream(device, offload_stream) - if fully_faulted and dtype is not None: - for comfy_module in comfy_modules: - comfy.ops.resolve_cast_module_with_vbar(comfy_module, dtype, device, dtype, None, False, return_weights=False) + offload_stream, fully_faulted = pin_modules(comfy_modules, device, dtype) queue[0] = (offload_stream, (module, comfy_modules)) if core is not None: @@ -223,7 +264,7 @@ def prefetch_queue_pop(queue, device, module, dtype=None, core=None, enable_grap comfy.model_management.current_stream(device).wait_stream(capture_stream) graph.replay() module._comfy_graph = {"graph": graph, "signature": signature} - GRAPH_MODULES.add(module) + malloc_graph._comfy_cuda_graph_modules.add(module) return if capture_stream is None: core() diff --git a/comfy/model_sampling.py b/comfy/model_sampling.py index 7b00d670402..4a556552071 100644 --- a/comfy/model_sampling.py +++ b/comfy/model_sampling.py @@ -118,6 +118,16 @@ def noise_scaling(self, sigma, noise, latent_image, max_denoise=False): def inverse_noise_scaling(self, sigma, latent): return 1.0 - latent +class IMG_TO_IMG_VELOCITY(CONST): + def calculate_denoised(self, sigma, model_output, model_input): + return model_input - model_output + + def noise_scaling(self, sigma, noise, latent_image, max_denoise=False): + return latent_image + + def inverse_noise_scaling(self, sigma, latent): + return latent + class COSMOS_RFLOW: def calculate_input(self, sigma, noise): sigma = (sigma / (sigma + 1)) diff --git a/comfy/multigpu.py b/comfy/multigpu.py index 2b6d8260daa..57644af7106 100644 --- a/comfy/multigpu.py +++ b/comfy/multigpu.py @@ -11,6 +11,7 @@ import comfy.utils import comfy.patcher_extension import comfy.model_management +import comfy.model_prefetch class MultiGPUThreadPool: @@ -46,18 +47,21 @@ def _worker_loop(self, device: torch.device, work_q: queue.Queue, result_q: queu return result_q.put((None, e)) return - while True: - item = work_q.get() - if item is None: - break - fn, args, kwargs = item - try: - result = fn(*args, **kwargs) - result_q.put((result, None)) - except comfy.model_management.InterruptProcessingException as e: - result_q.put((None, e)) - except Exception as e: - result_q.put((None, e)) + try: + while True: + item = work_q.get() + if item is None: + break + fn, args, kwargs = item + try: + result = fn(*args, **kwargs) + result_q.put((result, None)) + except comfy.model_management.InterruptProcessingException as e: + result_q.put((None, e)) + except Exception as e: + result_q.put((None, e)) + finally: + comfy.model_prefetch.cleanup_malloc_graph() def submit(self, device: torch.device, fn, *args, **kwargs): self._work_queues[device].put((fn, args, kwargs)) diff --git a/comfy/ops.py b/comfy/ops.py index ff64aad5924..f4ac760c946 100644 --- a/comfy/ops.py +++ b/comfy/ops.py @@ -26,6 +26,7 @@ import json import comfy.memory_management import comfy.pinned_memory +import comfy.rmsnorm import comfy.utils import comfy_aimdo.model_vbar @@ -183,9 +184,10 @@ def get_cast_buffer(buffer_size): needs_cast = False xfer_source = [ s.weight, s.bias ] - subset = "weights" + fast_disk = s._pin_state["fast_disk"] + subset = "weights-fast" if fast_disk else "weights" pin = comfy.pinned_memory.get_pin(s, subset=subset) - if pin is None and not args.fast_disk: + if pin is None and not fast_disk: loaded_pin = comfy.pinned_memory.get_pin(s, subset="weights-loaded") if loaded_pin is not None or signature is not None: subset = "weights-loaded" @@ -226,7 +228,7 @@ def handle_pin(m, pin, source, dest, subset="weights", size=None): if pin is not None: cast_maybe_lowvram_patch([pin], dest, offload_stream) return - if signature is None or not args.fast_disk or args.high_ram: + if signature is None or not fast_disk or args.high_ram: comfy.pinned_memory.pin_memory(m, subset=subset, size=size) pin = comfy.pinned_memory.get_pin(m, subset=subset) cast_maybe_lowvram_patch(source, pin, offload_stream, xfer_dest2=dest) @@ -241,14 +243,14 @@ def handle_pin(m, pin, source, dest, subset="weights", size=None): lowvram_dest = get_cast_buffer(lowvram_size) lowvram_source.prepare(lowvram_dest, None, copy=False, commit=True) - subset = "patches" + subset = "patches-fast" if fast_disk else "patches" pin = comfy.pinned_memory.get_pin(lowvram_source, subset=subset) - if pin is None: + if pin is None and not fast_disk: loaded_pin = comfy.pinned_memory.get_pin(lowvram_source, subset="patches-loaded") if loaded_pin is not None: subset = "patches-loaded" pin = loaded_pin - elif signature is not None and not args.fast_disk: + elif signature is not None: subset = "patches-loaded" handle_pin(lowvram_source, pin, lowvram_source, lowvram_dest, subset=subset, size=lowvram_size) @@ -955,21 +957,61 @@ def _swiglu_eager(x): } -def linear_input_act(linear, x, input_act): +def _eager_input_act(x, input_act, act_weight=None, act_eps=0.0): + if input_act is None: + return x + if input_act == "rms_norm": + return comfy.rmsnorm.rms_norm(x, act_weight, act_eps) + return INPUT_ACT_EAGER[input_act](x) + + +def _fp16_linear_wanted(x): + """kitchen's fp16-accumulate GEMM replaces a plain linear when the user opted into + fp16 accumulation and the activation is fp16 on CUDA; weights come through cast_bias_weight.""" + return (getattr(torch.backends.cuda.matmul, "allow_fp16_accumulation", False) + and x.dtype == torch.float16 and x.is_cuda) + + +def linear_input_act(linear, x, input_act, act_weight=None, act_eps=0.0, + residual=None, residual_scale=None): """``linear(act(x))``, with ``act`` folded into an INT8 activation quantizer. An INT8 linear quantizes its input anyway, so an elementwise activation can ride along inside that kernel instead of writing a full-size intermediate to HBM and reading it straight back. Worth it for an MLP's down-projection, - where the intermediate is several times the hidden size. + where the intermediate is several times the hidden size, and for a pre-norm + block's ``linear(rms_norm(x))`` ("rms_norm", which reads the norm's weight + and eps from act_weight/act_eps). + + With ``residual``/``residual_scale`` the result is the pre-norm block's + addcmul, ``residual + residual_scale * linear(act(x))``, fused into the + INT8 GEMM epilogue where supported. """ + def _residual_out(out): + if residual is None: + return out + return torch.addcmul(residual, out, residual_scale) + weight = linear.weight + full_precision_mm = getattr(linear, "_full_precision_mm", False) if (comfy.model_management.in_training or not isinstance(weight, QuantizedTensor) or weight._layout_cls != "TensorWiseINT8Layout" - or getattr(weight._params, "transposed", False)): - return linear(INPUT_ACT_EAGER[input_act](x)) + or getattr(weight._params, "transposed", False) + or full_precision_mm): + if (not comfy.model_management.in_training + and not isinstance(weight, QuantizedTensor) + and not full_precision_mm + and _fp16_linear_wanted(x)): + weight, bias, offload_stream = cast_bias_weight(linear, x, offloadable=True) + try: + return quant_ops.ck.fp16_linear( + _eager_input_act(x, input_act, act_weight, act_eps), + weight, bias, residual=residual, residual_scale=residual_scale) + finally: + uncast_bias_weight(linear, weight, bias, offload_stream) + return _residual_out(linear(_eager_input_act(x, input_act, act_weight, act_eps))) # want_requant keeps a vbar-streamed layer on the INT8 path when a LoRA is # patched in on the fly; without it the cast hands back a dequantized weight. @@ -979,13 +1021,18 @@ def linear_input_act(linear, x, input_act): if not isinstance(weight, QuantizedTensor): # A LoRA weight_function, or activations whose dtype differs from the # weight's, make the cast hand back a dequantized tensor. - return torch.nn.functional.linear(INPUT_ACT_EAGER[input_act](x), weight, bias) + return _residual_out(torch.nn.functional.linear( + _eager_input_act(x, input_act, act_weight, act_eps), weight, bias)) qdata, scale = TensorWiseINT8Layout.get_plain_tensors(weight) return quant_ops.ck.int8_linear( x, qdata, scale, bias, x.dtype, convrot=getattr(weight._params, "convrot", False), convrot_groupsize=getattr(weight._params, "convrot_groupsize", 256), input_act=input_act, + input_act_weight=act_weight, + input_act_eps=act_eps, + residual=residual, + residual_scale=residual_scale, ) finally: uncast_bias_weight(linear, weight, bias, offload_stream) @@ -1287,6 +1334,16 @@ def _quantized_weight_state_dict(module, sd, prefix, extra_quant_conf=None, extr return sd +class MixedPrecisionOp(CastWeightBiasOp): + quant_format = None + + def can_use_quantized_matmul(self, disabled_formats): + return (self.quant_format in QUANT_ALGOS + and not self._full_precision_mm_config + and self.quant_format not in self._disabled_formats + and self.quant_format not in disabled_formats) + + def mixed_precision_ops(quant_config={}, compute_dtype=torch.bfloat16, full_precision_mm=False, disabled=[]): class MixedPrecisionOps(manual_cast): _quant_config = quant_config @@ -1294,7 +1351,7 @@ class MixedPrecisionOps(manual_cast): _full_precision_mm = full_precision_mm _disabled = disabled - class Linear(torch.nn.Module, CastWeightBiasOp): + class Linear(torch.nn.Module, MixedPrecisionOp): _disabled_formats = disabled def __init__(self, in_features: int, out_features: int, bias: bool = True, device=None, dtype=None): @@ -1342,6 +1399,8 @@ def forward_comfy_cast_weights( compute_dtype=compute_dtype, want_requant=want_requant, ) as (weight, bias): + if self._full_precision_mm and isinstance(weight, QuantizedTensor): + weight = weight.dequantize() return self._forward(input, weight, bias) with CastBiasWeightContext( @@ -1444,7 +1503,7 @@ def set_weight(self, weight, inplace_update=False, seed=None, return_weight=Fals def _apply(self, fn, recurse=True): # This is to get torch.compile + moving weights to another device working return _quantized_apply(self, fn, recurse) - class MoEExperts(torch.nn.Module, CastWeightBiasOp): + class MoEExperts(torch.nn.Module, MixedPrecisionOp): """Container for E quantized expert weights, indexed via expert_weight(i). The bank lives on self.weight as a single 3D tensor — either a @@ -1648,24 +1707,45 @@ def forward_comfy_cast_weights(self, input, out_dtype=None): return MixedPrecisionOps -def pick_operations(weight_dtype, compute_dtype, load_device=None, disable_fast_fp8=False, fp8_optimizations=False, model_config=None): - fp8_compute = comfy.model_management.supports_fp8_compute(load_device) # TODO: if we support more ops this needs to be more granular - nvfp4_compute = comfy.model_management.supports_nvfp4_compute(load_device) - mxfp8_compute = comfy.model_management.supports_mxfp8_compute(load_device) +def get_disabled_quant_formats(device=None): + disabled = set() + if not comfy.model_management.supports_nvfp4_compute(device): + disabled.add("nvfp4") + if not comfy.model_management.supports_mxfp8_compute(device): + disabled.add("mxfp8") + if not comfy.model_management.supports_fp8_compute(device): + disabled.add("float8_e4m3fn") + disabled.add("float8_e5m2") + if not comfy.model_management.supports_int8_compute(device): + disabled.add("int8_tensorwise") + disabled.add("convrot_w4a4") + disabled.add("asym_w4a8_int8") + return disabled + + +@contextlib.contextmanager +def use_quantized_matmul(model, device): + disabled = get_disabled_quant_formats(device) + previous = [] + try: + for module in model.modules(): + if isinstance(module, MixedPrecisionOp) and module.can_use_quantized_matmul(disabled): + previous.append((module, module._full_precision_mm)) + module._full_precision_mm = False + yield + finally: + for module, full_precision_mm in previous: + module._full_precision_mm = full_precision_mm + +def pick_operations(weight_dtype, compute_dtype, load_device=None, disable_fast_fp8=False, fp8_optimizations=False, model_config=None): if model_config and hasattr(model_config, 'quant_config') and model_config.quant_config: logging.info("Using mixed precision operations") - disabled = set() - if not nvfp4_compute: - disabled.add("nvfp4") - if not mxfp8_compute: - disabled.add("mxfp8") - if not fp8_compute: - disabled.add("float8_e4m3fn") - disabled.add("float8_e5m2") + disabled = get_disabled_quant_formats(load_device) logging.info("Native ops: {} {}".format(", ".join(QUANT_ALGOS.keys() - disabled), ", emulated ops: {}".format(", ".join(disabled)) if len(disabled) > 0 else "")) return mixed_precision_ops(model_config.quant_config, compute_dtype, disabled=disabled) + fp8_compute = comfy.model_management.supports_fp8_compute(load_device) if ( fp8_compute and (fp8_optimizations or PerformanceFeature.Fp8MatrixMultiplication in args.fast) and diff --git a/comfy/pinned_memory.py b/comfy/pinned_memory.py index e9a9a70e298..b3057c620a1 100644 --- a/comfy/pinned_memory.py +++ b/comfy/pinned_memory.py @@ -84,6 +84,7 @@ def pin_memory(module, subset="weights", size=None): size = comfy.memory_management.vram_aligned_size([ module.weight, module.bias ]) registerable_size = size loaded = subset.endswith("-loaded") + fast = subset.endswith("-fast") priority = module_pin.get("balancer_priority") if priority is None: @@ -93,7 +94,7 @@ def pin_memory(module, subset="weights", size=None): comfy.memory_management.extra_ram_release(comfy.memory_management.RAM_CACHE_HEADROOM) if (not comfy.model_management.ensure_pin_budget(size, loaded=loaded) or - not comfy.model_management.ensure_pin_registerable(registerable_size)): + not comfy.model_management.ensure_pin_registerable(registerable_size, evict_active=not fast)): return _steal_pin(module, stack, buckets, size, priority, subset) offset = hostbuf.size @@ -105,7 +106,7 @@ def pin_memory(module, subset="weights", size=None): pin.untyped_storage()._comfy_hostbuf = hostbuf if torch.cuda.cudart().cudaHostRegister(pin.data_ptr(), size, 1) != 0: comfy.model_management.discard_cuda_async_error() - comfy.model_management.free_registrations(size) + comfy.model_management.free_registrations(size, evict_active=not fast) if torch.cuda.cudart().cudaHostRegister(pin.data_ptr(), size, 1) != 0: comfy.model_management.discard_cuda_async_error() del pin diff --git a/comfy/quant_ops.py b/comfy/quant_ops.py index 18fd2d61372..01dfff3702e 100644 --- a/comfy/quant_ops.py +++ b/comfy/quant_ops.py @@ -4,21 +4,6 @@ from comfy.cli_args import args -def _rocm_kitchen_arch_supported(): - """comfy-kitchen's INT8 Triton kernels compile tl.dot to matrix-core instructions. - RDNA3/3.5/4 (gfx11xx/gfx12xx) have WMMA and CDNA (gfx9xx) has MFMA; RDNA1/RDNA2 - (gfx10xx) have neither, so the INT8 path hangs the GPU there. Gates the automatic - ROCm default so those cards stay on the eager fallback (an explicit - --enable-triton-backend still forces it on any arch).""" - try: - arch = torch.cuda.get_device_properties(torch.cuda.current_device()).gcnArchName.split(":")[0] - except Exception: - return False - if arch.startswith(("gfx11", "gfx12")): - return True - return arch in ("gfx908", "gfx90a", "gfx940", "gfx941", "gfx942", "gfx950") - - try: import comfy_kitchen as ck from comfy_kitchen.tensor import ( @@ -42,22 +27,13 @@ def _rocm_kitchen_arch_supported(): ck.registry.disable("cuda") logging.warning("WARNING: You need pytorch with cu130 or higher to use optimized CUDA operations.\nWARNING WARNING WARNING\nIf you are on nvidia 20 series and above it is required that you update your pytorch to cu130 or higher.\n") - # On ROCm/AMD the CUDA backend is unavailable, so Triton is the only accelerated - # comfy-kitchen backend. Enable it by default there, but only on Triton >= 3.7 AND a - # matrix-core GPU (RDNA3+ WMMA gfx11xx/gfx12xx, CDNA MFMA gfx9xx). RDNA1/RDNA2 - # (gfx10xx) have no WMMA -> the INT8 tl.dot path hangs the GPU, so they stay eager. - # older Triton lacks libdevice.rint on the HIP backend and hard-crashes the INT8 path. - if args.disable_triton_backend: - ck.registry.disable("triton") - elif args.enable_triton_backend: # or (torch.version.hip is not None and _rocm_kitchen_arch_supported()): + # comfy-kitchen picks its accelerated backend on import: the HIP backend registers + # itself on a supported AMD device and takes dispatch priority there, CUDA on NVIDIA. + # Triton is an opt-in override, off by default on every platform. + if args.enable_triton_backend and not args.disable_triton_backend: try: import triton - triton_version = tuple(int(v) for v in triton.__version__.split(".")[:2]) - if args.enable_triton_backend or triton_version >= (3, 7): - logging.info("Found triton %s. Enabling comfy-kitchen triton backend.", triton.__version__) - else: - logging.info("Triton %s is too old for the ROCm INT8 path (needs >= 3.7); comfy-kitchen triton backend disabled.", triton.__version__) - ck.registry.disable("triton") + logging.info("Found triton %s. Enabling comfy-kitchen triton backend.", triton.__version__) except ImportError as e: logging.error(f"Failed to import triton, Error: {e}, the comfy-kitchen triton backend will not be available.") ck.registry.disable("triton") diff --git a/comfy/sd.py b/comfy/sd.py index a73607cb061..32d9f12c265 100644 --- a/comfy/sd.py +++ b/comfy/sd.py @@ -35,6 +35,8 @@ import comfy.utils import comfy.ops +import comfy.model_prefetch +import comfy.storage from . import clip_vision from . import gligen @@ -62,6 +64,7 @@ import comfy.text_encoders.ace import comfy.text_encoders.omnigen2 import comfy.text_encoders.qwen_image +import comfy.text_encoders.qwen_image21 import comfy.text_encoders.hunyuan_image import comfy.text_encoders.z_image import comfy.text_encoders.krea2 @@ -78,6 +81,7 @@ import comfy.text_encoders.qwen3vl import comfy.text_encoders.minimax import comfy.text_encoders.minimax_music +import comfy.text_encoders.yue2 import comfy.ldm.minimax.vae import comfy.ldm.minimax.audio_vae import comfy.text_encoders.boogu @@ -265,7 +269,7 @@ def __init__(self, target=None, embedding_directory=None, no_init=False, tokeniz self.tokenizer = tokenizer(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data) te_disable_dynamic = disable_dynamic or getattr(self.cond_stage_model, "disable_offload", False) ModelPatcher = comfy.model_patcher.ModelPatcher if te_disable_dynamic else comfy.model_patcher.CoreModelPatcher - self.patcher = ModelPatcher(self.cond_stage_model, load_device=load_device, offload_device=offload_device) + self.patcher = ModelPatcher(self.cond_stage_model, load_device=load_device, offload_device=offload_device, fast_disk=comfy.storage.state_dict_fast_disk(state_dict)) #Match torch.float32 hardcode upcast in TE implemention self.patcher.set_model_compute_dtype(torch.float32) self.patcher.hook_mode = comfy.hooks.EnumHookMode.MinVram @@ -379,6 +383,8 @@ def encode_from_tokens_scheduled(self, tokens, unprojected=False, add_dict: dict o = self.cond_stage_model.encode_token_weights(tokens) cond, pooled = o[:2] pooled_dict = {"pooled_output": pooled} + if len(o) > 2: + pooled_dict.update(o[2]) # add clip_start_percent and clip_end_percent in pooled pooled_dict["clip_start_percent"] = t_range[0] pooled_dict["clip_end_percent"] = t_range[1] @@ -466,7 +472,7 @@ def load_model(self, tokens={}): def get_key_patches(self): return self.patcher.get_key_patches() - def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0): + def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0, mtp=True): self.cond_stage_model.reset_clip_options() self.load_model(tokens) @@ -474,8 +480,8 @@ def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_ self.cond_stage_model.set_clip_options({"layer": None}) self.cond_stage_model.set_clip_options({"execution_device": device}) - with model_management.cuda_device_context(device): - return self.cond_stage_model.generate(tokens, do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty) + with model_management.cuda_device_context(device), comfy.ops.use_quantized_matmul(self.cond_stage_model, device): + return self.cond_stage_model.generate(tokens, do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty, mtp=mtp) def decode(self, token_ids, skip_special_tokens=True): return self.tokenizer.decode(token_ids, skip_special_tokens=skip_special_tokens) @@ -485,6 +491,7 @@ def is_dynamic(self): class VAE: def __init__(self, sd=None, device=None, config=None, dtype=None, metadata=None): + fast_disk = comfy.storage.state_dict_fast_disk(sd) is_seedvr2_vae = "decoder.up_blocks.2.upsamplers.0.upscale_conv.weight" in sd if not is_seedvr2_vae and 'decoder.up_blocks.0.resnets.0.norm1.weight' in sd.keys(): #diffusers format sd = diffusers_convert.convert_vae_state_dict(sd) @@ -694,6 +701,7 @@ def _no_encode(*args, **kwargs): decoder_config={'target': "comfy.ldm.modules.diffusionmodules.model.Decoder", 'params': decoder_ddconfig if decoder_ddconfig is not None else ddconfig}) elif "decoder.layers.1.layers.0.beta" in sd: config = {} + yue2_vae = "decoder.layers.6.layers.1.weight_v" in sd or "decoder.layers.6.layers.1.parametrizations.weight.original1" in sd param_key = None self.upscale_ratio = 2048 self.downscale_ratio = 2048 @@ -708,6 +716,9 @@ def _no_encode(*args, **kwargs): self.upscale_ratio = 1920 self.downscale_ratio = 1920 + if yue2_vae: + config.update(channels=64, c_mults=[1, 2, 4, 8, 16, 32], strides=[2, 2, 4, 4, 5, 6], + sample_latent=False) self.first_stage_model = AudioOobleckVAE(**config) self.memory_used_encode = lambda shape, dtype: (1000 * shape[2]) * model_management.dtype_size(dtype) self.memory_used_decode = lambda shape, dtype: (1000 * shape[2] * 2048) * model_management.dtype_size(dtype) @@ -719,6 +730,10 @@ def _no_encode(*args, **kwargs): self.process_input = lambda audio: audio self.working_dtypes = [torch.float16, torch.bfloat16, torch.float32] self.disable_offload = True + if yue2_vae: + self.audio_sample_rate = 48000 + self.upscale_ratio = self.downscale_ratio = 1920 + self.memory_used_decode = lambda shape, dtype: (1500 * shape[-1] * 1920) * model_management.dtype_size(dtype) elif "blocks.2.blocks.3.stack.5.weight" in sd or "decoder.blocks.2.blocks.3.stack.5.weight" in sd or "layers.4.layers.1.attn_block.attn.qkv.weight" in sd or "encoder.layers.4.layers.1.attn_block.attn.qkv.weight" in sd: #genmo mochi vae if "blocks.2.blocks.3.stack.5.weight" in sd: sd = comfy.utils.state_dict_prefix_replace(sd, {"": "decoder."}) @@ -814,7 +829,20 @@ def _no_encode(*args, **kwargs): self.memory_used_encode = lambda shape, dtype: (50 * (round((shape[2] + 7) / 8) * 8) * shape[3] * shape[4]) * model_management.dtype_size(dtype) self.working_dtypes = [torch.bfloat16, torch.float32] elif "decoder.middle.0.residual.0.gamma" in sd: - if "decoder.upsamples.0.upsamples.0.residual.2.weight" in sd: # Wan 2.2 VAE + wan22_layout = "decoder.upsamples.0.upsamples.0.residual.2.weight" in sd + head = sd.get("decoder.head.2.weight", None) + if wan22_layout and head is not None and head.ndim == 5 and head.shape[2] == 1: # Qwen Image 2.1 VAE: Wan 2.2 layout, temporal kernel 1, no patchify, RGBA + self.upscale_ratio = 16 + self.downscale_ratio = 16 + self.latent_channels = 64 + self.output_channels = sd["decoder.head.2.weight"].shape[0] + self.pad_channel_value = 1.0 # opaque alpha for RGB input + ddconfig = {"dim": sd["encoder.conv1.weight"].shape[0], "dec_dim": sd["decoder.head.0.gamma"].shape[0], "z_dim": self.latent_channels, "dim_mult": [1, 2, 4, 8, 8], "num_res_blocks": 2, "attn_scales": [], "temperal_downsample": [False, True, True, True], "dropout": 0.0, "image_channels": self.output_channels, "patch_size": 1, "temporal_kernel": 1} + self.first_stage_model = comfy.ldm.wan.vae2_2.WanVAE(**ddconfig) + self.working_dtypes = [torch.bfloat16, torch.float16, torch.float32] + self.memory_used_encode = lambda shape, dtype: 600 * shape[2] * shape[3] * model_management.dtype_size(dtype) + self.memory_used_decode = lambda shape, dtype: 900 * shape[2] * shape[3] * (16 * 16) * model_management.dtype_size(dtype) + elif wan22_layout: # Wan 2.2 VAE self.upscale_ratio = (lambda a: max(0, a * 4 - 3), 16, 16) self.upscale_index_formula = (4, 16, 16) self.downscale_ratio = (lambda a: max(0, math.floor((a + 3) / 4)), 16, 16) @@ -1080,9 +1108,12 @@ def _no_generic_io(*args, **kwargs): mp = comfy.model_patcher.CoreModelPatcher if self.disable_offload: mp = comfy.model_patcher.ModelPatcher - self.patcher = mp(self.first_stage_model, load_device=self.device, offload_device=offload_device) + self.patcher = mp(self.first_stage_model, load_device=self.device, offload_device=offload_device, fast_disk=fast_disk) m, u = self.first_stage_model.load_state_dict(sd, strict=False, assign=self.patcher.is_dynamic()) + if not self.patcher.is_dynamic(): + # Lazy parameters only exist after loading the state dict. + self.first_stage_model.to(self.vae_dtype) if len(m) > 0: logging.warning("Missing VAE keys {}".format(m)) @@ -1138,9 +1169,9 @@ def decode_tiled_(self, samples, tile_x=64, tile_y=64, overlap = 16): decode_fn = lambda a: self.first_stage_model.decode(a.to(self.vae_dtype).to(self.device)).to(dtype=self.vae_output_dtype()) output = self.process_output( - (comfy.utils.tiled_scale(samples, decode_fn, tile_x // 2, tile_y * 2, overlap, upscale_amount = self.upscale_ratio, output_device=self.output_device, pbar = pbar) + - comfy.utils.tiled_scale(samples, decode_fn, tile_x * 2, tile_y // 2, overlap, upscale_amount = self.upscale_ratio, output_device=self.output_device, pbar = pbar) + - comfy.utils.tiled_scale(samples, decode_fn, tile_x, tile_y, overlap, upscale_amount = self.upscale_ratio, output_device=self.output_device, pbar = pbar)) + (comfy.utils.tiled_scale(samples, decode_fn, tile_x // 2, tile_y * 2, overlap, upscale_amount = self.upscale_ratio, out_channels=self.output_channels, output_device=self.output_device, pbar = pbar) + + comfy.utils.tiled_scale(samples, decode_fn, tile_x * 2, tile_y // 2, overlap, upscale_amount = self.upscale_ratio, out_channels=self.output_channels, output_device=self.output_device, pbar = pbar) + + comfy.utils.tiled_scale(samples, decode_fn, tile_x, tile_y, overlap, upscale_amount = self.upscale_ratio, out_channels=self.output_channels, output_device=self.output_device, pbar = pbar)) / 3.0) return output @@ -1227,7 +1258,8 @@ def decode(self, samples_in, vae_options={}): with model_management.cuda_device_context(self.device): try: memory_used = self.memory_used_decode(samples_in.shape, self.vae_dtype) - model_management.load_models_gpu([self.patcher], memory_required=memory_used, force_full_load=self.disable_offload) + with comfy.model_prefetch.pause_malloc_graph(): + model_management.load_models_gpu([self.patcher], memory_required=memory_used, force_full_load=self.disable_offload) free_memory = self.patcher.get_free_memory(self.device) batch_number = int(free_memory / memory_used) batch_number = max(1, batch_number) @@ -1235,7 +1267,8 @@ def decode(self, samples_in, vae_options={}): # Pre-allocate output for VAEs that support direct buffer writes preallocated = False if getattr(self.first_stage_model, 'comfy_has_chunked_io', False): - pixel_samples = torch.empty(self.first_stage_model.decode_output_shape(samples_in.shape), device=self.output_device, dtype=self.vae_output_dtype()) + with comfy.model_prefetch.pause_malloc_graph(): + pixel_samples = torch.empty(self.first_stage_model.decode_output_shape(samples_in.shape), device=self.output_device, dtype=self.vae_output_dtype()) preallocated = True for x in range(0, samples_in.shape[0], batch_number): @@ -1245,7 +1278,8 @@ def decode(self, samples_in, vae_options={}): else: out = self.first_stage_model.decode(samples, **vae_options).to(device=self.output_device, dtype=self.vae_output_dtype(), copy=True) if pixel_samples is None: - pixel_samples = torch.empty((samples_in.shape[0],) + tuple(out.shape[1:]), device=self.output_device, dtype=self.vae_output_dtype()) + with comfy.model_prefetch.pause_malloc_graph(): + pixel_samples = torch.empty((samples_in.shape[0],) + tuple(out.shape[1:]), device=self.output_device, dtype=self.vae_output_dtype()) pixel_samples[x:x+batch_number].copy_(out) del out self.process_output(pixel_samples[x:x+batch_number]) @@ -1548,6 +1582,7 @@ class CLIPType(Enum): JOYIMAGE = 33 MAGE = 34 MINIMAX = 35 + YUE2 = 36 @@ -1737,7 +1772,12 @@ class EmptyClass: clip_target.params = {} if len(clip_data) == 1: te_model = detect_te_model(clip_data[0]) - if clip_type == CLIPType.MINIMAX and "model.audio_decoder.projection.weight" in clip_data[0]: + if clip_type == CLIPType.YUE2 and "yue2_tokenizer_json" in clip_data[0]: + tokenizer_data["yue2_tokenizer_json"] = clip_data[0].pop("yue2_tokenizer_json") + detect = comfy.text_encoders.hunyuan_video.llama_detect(clip_data[0]) + clip_target.clip = comfy.text_encoders.yue2.te(**detect) + clip_target.tokenizer = comfy.text_encoders.yue2.YuE2Tokenizer + elif clip_type == CLIPType.MINIMAX and "model.audio_decoder.projection.weight" in clip_data[0]: tokenizer_data["tokenizer_json"] = clip_data[0].pop("tokenizer_json", None) quant = comfy.utils.detect_layer_quantization(clip_data[0], "") if quant is not None: @@ -1889,7 +1929,7 @@ class EmptyClass: elif te_model in (TEModel.QWEN35_08B, TEModel.QWEN35_2B, TEModel.QWEN35_4B, TEModel.QWEN35_9B, TEModel.QWEN35_27B): clip_data[0] = comfy.utils.state_dict_prefix_replace(clip_data[0], {"model.language_model.": "model.", "model.visual.": "visual.", "lm_head.": "model.lm_head."}) qwen35_type = {TEModel.QWEN35_08B: "qwen35_08b", TEModel.QWEN35_2B: "qwen35_2b", TEModel.QWEN35_4B: "qwen35_4b", TEModel.QWEN35_9B: "qwen35_9b", TEModel.QWEN35_27B: "qwen35_27b"}[te_model] - clip_target.clip = comfy.text_encoders.qwen35.te(**llama_detect(clip_data), model_type=qwen35_type) + clip_target.clip = comfy.text_encoders.qwen35.te(**llama_detect(clip_data), model_type=qwen35_type, mtp="mtp.fc.weight" in clip_data[0]) clip_target.tokenizer = comfy.text_encoders.qwen35.tokenizer(model_type=qwen35_type) elif te_model in (TEModel.QWEN3VL_4B, TEModel.QWEN3VL_8B): if clip_type == CLIPType.IDEOGRAM4 and te_model == TEModel.QWEN3VL_8B: # Ideogram4 reuses the full Qwen3-VL-8B (13-layer tap for conditioning + multimodal generate). @@ -1912,6 +1952,10 @@ class EmptyClass: clip_data[0] = comfy.utils.state_dict_prefix_replace(clip_data[0], {"model.language_model.": "model.", "model.visual.": "visual.", "lm_head.": "model.lm_head."}) clip_target.clip = comfy.text_encoders.joyimage.te(**llama_detect(clip_data)) clip_target.tokenizer = comfy.text_encoders.joyimage.JoyImageTokenizer + elif clip_type == CLIPType.QWEN_IMAGE and te_model == TEModel.QWEN3VL_8B: # Qwen-Image 2.1: full Qwen3-VL-8B, last hidden state, image slots spliced by the DiT. + clip_data[0] = comfy.utils.state_dict_prefix_replace(clip_data[0], {"model.language_model.": "model.", "model.visual.": "visual.", "lm_head.": "model.lm_head."}) + clip_target.clip = comfy.text_encoders.qwen_image21.te(**llama_detect(clip_data)) + clip_target.tokenizer = comfy.text_encoders.qwen_image21.QwenImage21Tokenizer elif clip_type in (CLIPType.FLUX, CLIPType.FLUX2): # Flux2 Klein reuses the Qwen3-VL LM (3-layer tap -> 12288); visual unused. klein_model_type = "qwen3_8b" if te_model == TEModel.QWEN3VL_8B else "qwen3_4b" clip_target.clip = comfy.text_encoders.flux.klein_te(**llama_detect(clip_data), model_type=klein_model_type) @@ -2203,7 +2247,7 @@ def load_state_dict_guess_config(sd, output_vae=True, output_clip=True, output_c model = model_config.get_model(sd, diffusion_model_prefix, device=inital_load_device) ModelPatcher = comfy.model_patcher.ModelPatcher if disable_dynamic else comfy.model_patcher.CoreModelPatcher offload_device = model_options.get("offload_device", model_management.unet_offload_device()) - model_patcher = ModelPatcher(model, load_device=load_device, offload_device=offload_device) + model_patcher = ModelPatcher(model, load_device=load_device, offload_device=offload_device, fast_disk=comfy.storage.state_dict_fast_disk(sd)) model.load_model_weights(sd, diffusion_model_prefix, assign=model_patcher.is_dynamic()) if output_vae: @@ -2343,7 +2387,7 @@ def load_diffusion_model_state_dict(sd, model_options={}, metadata=None, disable model = model_config.get_model(new_sd, "") ModelPatcher = comfy.model_patcher.ModelPatcher if disable_dynamic else comfy.model_patcher.CoreModelPatcher - model_patcher = ModelPatcher(model, load_device=load_device, offload_device=offload_device) + model_patcher = ModelPatcher(model, load_device=load_device, offload_device=offload_device, fast_disk=comfy.storage.state_dict_fast_disk(new_sd)) if not model_management.is_device_cpu(offload_device): model.to(offload_device) model.load_model_weights(new_sd, "", assign=model_patcher.is_dynamic()) diff --git a/comfy/sd1_clip.py b/comfy/sd1_clip.py index f0fdf1aa5bd..01651bd23ba 100644 --- a/comfy/sd1_clip.py +++ b/comfy/sd1_clip.py @@ -308,7 +308,7 @@ def encode(self, tokens): def load_sd(self, sd): return self.transformer.load_state_dict(sd, strict=False, assign=getattr(self, "can_assign_sd", False)) - def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0): + def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0, mtp=True): if isinstance(tokens, dict): tokens_only = next(iter(tokens.values())) # todo: get this better? else: @@ -746,5 +746,11 @@ def encode_token_weights(self, token_weight_pairs): def load_sd(self, sd): return getattr(self, self.clip).load_sd(sd) - def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0): - return getattr(self, self.clip).generate(tokens, do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty) + def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0, mtp=True): + return getattr(self, self.clip).generate(tokens, do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty, mtp=mtp) + + def get_dynamic_vram__units(self): + # forward to the inner transformer so ModelPatcher can register vbar units (graph decode) + model = getattr(getattr(getattr(self, self.clip), "transformer", None), "model", None) + get_units = getattr(model, "get_dynamic_vram__units", None) + return get_units() if get_units is not None else ([], []) diff --git a/comfy/storage.py b/comfy/storage.py new file mode 100644 index 00000000000..2f40250e5c4 --- /dev/null +++ b/comfy/storage.py @@ -0,0 +1,111 @@ +import functools +import logging +import os +import platform +import re + +import comfy_aimdo.storage +from comfy.cli_args import args + + +_NVME_NAMESPACE = re.compile(r"^(nvme\d+)n\d+$") + + +def _read(path): + try: + with open(path, encoding="utf-8") as f: + return f.read().strip() + except OSError: + return None + + +def _physical_block_devices(name): + partition = f"/sys/class/block/{name}/partition" + if os.path.exists(partition): + name = os.path.basename(os.path.dirname(os.path.realpath(f"/sys/class/block/{name}"))) + + slaves = f"/sys/class/block/{name}/slaves" + try: + children = os.listdir(slaves) + except OSError: + children = [] + if children: + devices = [] + for child in children: + devices.extend(_physical_block_devices(child)) + return devices + return [name] + + +def _fast_nvme(name): + match = _NVME_NAMESPACE.match(name) + if match is None: + return False + controller = match.group(1) + speed = _read(f"/sys/class/nvme/{controller}/device/current_link_speed") + width = _read(f"/sys/class/nvme/{controller}/device/current_link_width") + if speed is None or width is None: + return None + try: + speed_gts = float(speed.split()[0]) + width = int(width) + except ValueError: + return None + return (speed_gts >= 8.0 and width >= 4) or (speed_gts >= 32.0 and width >= 2) + + +@functools.lru_cache(maxsize=None) +def _linux_fast_storage(device): + sys_device = f"/sys/dev/block/{os.major(device)}:{os.minor(device)}" + if not os.path.exists(sys_device): + return None + name = os.path.basename(os.path.realpath(sys_device)) + devices = _physical_block_devices(name) + results = [_fast_nvme(x) for x in devices] + if any(x is None for x in results): + return None + return all(results) + + +def fast_storage(path): + system = platform.system() + if system == "Linux": + try: + device = os.stat(os.path.realpath(path)).st_dev + except OSError: + return None + return _linux_fast_storage(device) + if system == "Windows": + return comfy_aimdo.storage.fast_disk(path) + return None + + +def annotate_state_dict(state_dict, path): + path = os.path.realpath(path) + for value in state_dict.values(): + untyped_storage = getattr(value, "untyped_storage", None) + if untyped_storage is not None: + untyped_storage()._comfy_source_path = path + + +def state_dict_fast_disk(state_dict): + state_dicts = state_dict if isinstance(state_dict, (list, tuple)) else (state_dict,) + paths = set() + for sd in state_dicts: + for value in sd.values(): + untyped_storage = getattr(value, "untyped_storage", None) + if untyped_storage is not None: + path = getattr(untyped_storage(), "_comfy_source_path", None) + if path is not None: + paths.add(path) + return model_fast_disk(sorted(paths)) + + +def model_fast_disk(paths): + if args.fast_disk or args.disable_fast_disk: + fast = not args.disable_fast_disk + else: + results = [fast_storage(path) for path in paths] + fast = bool(results) and all(result is True for result in results) + logging.info("Model storage policy: fast_disk=%s paths=%s", fast, [os.path.realpath(path) for path in paths]) + return fast diff --git a/comfy/supported_models.py b/comfy/supported_models.py index ae2d37dadad..b5cc18736d8 100644 --- a/comfy/supported_models.py +++ b/comfy/supported_models.py @@ -23,6 +23,7 @@ import comfy.text_encoders.ace import comfy.text_encoders.omnigen2 import comfy.text_encoders.qwen_image +import comfy.text_encoders.qwen_image21 import comfy.text_encoders.hunyuan_image import comfy.text_encoders.kandinsky5 import comfy.text_encoders.z_image @@ -33,6 +34,7 @@ import comfy.text_encoders.joyimage import comfy.text_encoders.anima import comfy.text_encoders.ace15 +import comfy.text_encoders.yue2 import comfy.text_encoders.longcat_image import comfy.text_encoders.ernie import comfy.text_encoders.cogvideo @@ -2050,6 +2052,35 @@ def clip_target(self, state_dict={}): hunyuan_detect = comfy.text_encoders.hunyuan_video.llama_detect(state_dict, "{}qwen25_7b.transformer.".format(pref)) return supported_models_base.ClipTarget(comfy.text_encoders.qwen_image.QwenImageTokenizer, comfy.text_encoders.qwen_image.te(**hunyuan_detect)) +class QwenImage21(supported_models_base.BASE): + unet_config = { + "image_model": "qwen_image21", + } + + # scheduler mu at 1024x1024 (base 0.5 @ 256 tokens, max 0.9 @ 8192) + sampling_settings = { + "multiplier": 1.0, + "shift": 0.69, + } + + memory_usage_factor = 6.0 + + unet_extra_config = {} + latent_format = latent_formats.QwenImage21 + + supported_inference_dtypes = [torch.bfloat16, torch.float32] + + vae_key_prefix = ["vae."] + text_encoder_key_prefix = ["text_encoders."] + + def get_model(self, state_dict, prefix="", device=None): + return model_base.QwenImage21(self, device=device) + + def clip_target(self, state_dict={}): + pref = self.text_encoder_key_prefix[0] + hunyuan_detect = comfy.text_encoders.hunyuan_video.llama_detect(state_dict, "{}qwen3vl_8b.transformer.".format(pref)) + return supported_models_base.ClipTarget(comfy.text_encoders.qwen_image21.QwenImage21Tokenizer, comfy.text_encoders.qwen_image21.te(**hunyuan_detect)) + class JoyImage(supported_models_base.BASE): unet_config = { "image_model": "joyimage", @@ -2265,6 +2296,27 @@ def clip_target(self, state_dict={}): return supported_models_base.ClipTarget(comfy.text_encoders.ace15.ACE15Tokenizer, comfy.text_encoders.ace15.te(**detect)) +class YuE2(supported_models_base.BASE): + unet_config = {"audio_model": "yue2"} + unet_extra_config = {} + latent_format = latent_formats.YuE2 + supported_inference_dtypes = [torch.bfloat16, torch.float32] + sampling_settings = {"multiplier": 1.0} + memory_usage_factor = 4.0 + vae_key_prefix = ["vae."] + text_encoder_key_prefix = ["text_encoders."] + + def get_model(self, state_dict, prefix="", device=None): + return model_base.YuE2(self, device=device) + + def model_type(self, state_dict, prefix=""): + return model_base.ModelType.FLOW + + def clip_target(self, state_dict={}): + detect = comfy.text_encoders.hunyuan_video.llama_detect(state_dict, self.text_encoder_key_prefix[0]) + return supported_models_base.ClipTarget(comfy.text_encoders.yue2.YuE2Tokenizer, comfy.text_encoders.yue2.te(**detect)) + + class MiniMaxMusic3(supported_models_base.BASE): unet_config = { "audio_model": "minimax_music3", @@ -2583,10 +2635,12 @@ def get_model(self, state_dict, prefix="", device=None): ACEStep, ACEStep15, MiniMaxMusic3, + YuE2, Omnigen2, Boogu, MageFlow, QwenImage, + QwenImage21, JoyImage, Ideogram4, Krea2, diff --git a/comfy/text_encoders/ace15.py b/comfy/text_encoders/ace15.py index 3ad51931450..ec8fa313e49 100644 --- a/comfy/text_encoders/ace15.py +++ b/comfy/text_encoders/ace15.py @@ -162,7 +162,8 @@ def generate_audio_codes(model, positive, negative, min_tokens=1, max_tokens=102 else: ids = [positive] - return sample_manual_loop_no_classes(model, ids, cfg_scale=cfg_scale, temperature=temperature, top_p=top_p, top_k=top_k, min_p=min_p, seed=seed, min_tokens=min_tokens, max_new_tokens=max_tokens) + with comfy.ops.use_quantized_matmul(model, model.execution_device): + return sample_manual_loop_no_classes(model, ids, cfg_scale=cfg_scale, temperature=temperature, top_p=top_p, top_k=top_k, min_p=min_p, seed=seed, min_tokens=min_tokens, max_new_tokens=max_tokens) class ACE15Tokenizer(sd1_clip.SD1Tokenizer): diff --git a/comfy/text_encoders/gemma4.py b/comfy/text_encoders/gemma4.py index 96a547b60e0..0dcd226cb4f 100644 --- a/comfy/text_encoders/gemma4.py +++ b/comfy/text_encoders/gemma4.py @@ -528,17 +528,18 @@ def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermed and comfy.model_management.is_device_cuda(x.device)) decode_bias = None decode_masks = None - if decode: + if fixed_kv: + # prefill must advance the device-side write position of the global caches too prepared = set() for kv in past_key_values: if isinstance(kv, FixedKV) and id(kv.position) not in prepared: kv.prepare(seq_len) prepared.add(id(kv.position)) - if mask is not None: - decode_masks = {} - for kv in past_key_values: - if isinstance(kv, FixedKV) and id(kv.position) not in decode_masks: - decode_masks[id(kv.position)] = _fixed_kv_decode_mask(mask, kv, min_val) + if decode and mask is not None: + decode_masks = {} + for kv in past_key_values: + if isinstance(kv, FixedKV) and id(kv.position) not in decode_masks: + decode_masks[id(kv.position)] = _fixed_kv_decode_mask(mask, kv, min_val) if compiled_decode: capacities = tuple(sorted({kv.key.shape[2] for kv in past_key_values if isinstance(kv, FixedKV)})) valid = past_len + 1 @@ -1663,7 +1664,7 @@ def __init__(self, device="cpu", layer="all", layer_idx=None, dtype=None, attent self.dtypes.add(dtype) super().__init__(device=device, layer=layer, layer_idx=layer_idx, textmodel_json_config={}, dtype=dtype, special_tokens={"start": 2, "pad": 0}, layer_norm_hidden_state=False, model_class=self.model_class, enable_attention_masks=attention_mask, return_attention_masks=attention_mask, model_options=model_options) - def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0): + def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0, mtp=True): if isinstance(tokens, dict): tokens = next(iter(tokens.values())) tokens_only = [[t[0] for t in b] for b in tokens] @@ -1707,9 +1708,6 @@ def __init__(self, device="cpu", dtype=None, model_options={}): if dtype_llama is not None: dtype = dtype_llama super().__init__(device=device, dtype=dtype, name="gemma4", clip_model=clip_model, model_options=model_options) - - def get_dynamic_vram__units(self): - return getattr(self, self.clip).transformer.model.get_dynamic_vram__units() return Gemma4TEModel_ diff --git a/comfy/text_encoders/llama.py b/comfy/text_encoders/llama.py index a61c5adc72e..96577011a83 100644 --- a/comfy/text_encoders/llama.py +++ b/comfy/text_encoders/llama.py @@ -32,6 +32,72 @@ def prepare(self, num_tokens): def advance(self, num_tokens): self.index += num_tokens + def rollback(self, discard=1): + # drop the rejected verify tail; stale slots and bias are overwritten by the next write/prepare + self.index -= discard + +@dataclass +class FixedKVBias(FixedKV): + # full-capacity decode bias [1, 1, rows, capacity], last `seq` rows serve the queries; shared across layers + bias: torch.Tensor = None + tracker: dict = None + + def prepare(self, num_tokens): + if self.tracker["step"] == (self.index, num_tokens): + return + self.tracker["step"] = (self.index, num_tokens) + i = self.index + rows = self.bias.shape[-2] + if num_tokens <= rows: + torch.arange(i, i + rows, out=self.position) + window = self.tracker[num_tokens] + start = i - rows + 1 + skip = max(-start, 0) + end = min(i + rows, self.bias.shape[-1]) + self.bias[..., :, start + skip:end] = window[:, skip:end - start] + else: + self.bias[..., :, i:i + num_tokens] = 0 + + @staticmethod + def shared(capacity, device, dtype): + # all layers advance in lockstep, so the bias caches share one position/bias/tracker + rows = 6 + position = torch.empty((rows,), device=device, dtype=torch.int64) + bias = torch.full((1, 1, rows, capacity), torch.finfo(dtype).min, device=device, dtype=dtype) + tracker = {"step": -1} + # window templates per decode width: row r serves query j = r - (rows - n) and may see slots <= index + j + for n in range(1, rows + 1): + window = torch.full((rows, 2 * rows - 1), torch.finfo(dtype).min, device=device, dtype=dtype) + for r in range(rows): + window[r, :rows + max(r - (rows - n), 0)] = 0 + tracker[n] = window + return position, bias, tracker + + @classmethod + def zeros(cls, batch, kv_heads, capacity, head_dim, device, dtype, shared): + # zero-init: decode attends full capacity with masked tails, 0*0 stays finite + key = torch.zeros((batch, kv_heads, capacity, head_dim), device=device, dtype=dtype) + return cls(key, torch.zeros_like(key), 0, shared[0], None, shared[1], shared[2]) + + def append(self, xk, xv): + seq = xk.shape[2] + self.key[:, :, self.index:self.index + seq] = xk + self.value[:, :, self.index:self.index + seq] = xv + return self.key[:, :, :self.index + seq], self.value[:, :, :self.index + seq] + + def decode(self, xq, xk, xv, num_kv_heads): + # CUDA-graphable: device-side write position, masked attention over the full capacity + batch_size, num_heads, seq, head_dim = xq.shape + self.key.index_copy_(2, self.position[:seq], xk) + self.value.index_copy_(2, self.position[:seq], xv) + groups = num_heads // num_kv_heads + q = xq.reshape(batch_size, num_kv_heads, groups, seq, head_dim) * head_dim ** -0.5 + bias = self.bias[..., self.bias.shape[-2] - seq:, :].unsqueeze(1) + scores = (q @ self.key.transpose(-1, -2).unsqueeze(2)).add_(bias) + probs = torch.softmax(scores, dim=-1, dtype=torch.float32).to(xq.dtype) + out = probs @ self.value.unsqueeze(2) + return out.permute(0, 3, 1, 2, 4).reshape(batch_size, seq, num_heads * head_dim) + @dataclass class Llama2Config: vocab_size: int = 128320 @@ -278,6 +344,9 @@ class Qwen3VL_8BConfig(Qwen3_8BConfig): rope_theta: float = 5000000.0 rope_dims = [24, 20, 20] interleaved_mrope = True + fixed_kv: bool = True + graph_dynamic_vbar_blocks = True + prefetch_dynamic_vbars = True @dataclass class Qwen3VL_4BConfig(Qwen3VL_8BConfig): @@ -489,23 +558,32 @@ def precompute_freqs_cis(head_dim, position_ids, theta, rope_scale=None, rope_di return out -def apply_rope(xq, xk, freqs_cis): - org_dtype = xq.dtype - cos = freqs_cis[0] - sin = freqs_cis[1] - nsin = freqs_cis[2] +def rope_matrix(freqs_cis): + if torch.is_tensor(freqs_cis): + return freqs_cis + cos, sin, neg_sin = freqs_cis + half = sin.shape[-1] + matrix = torch.stack((cos[..., :half], neg_sin, sin, cos[..., half:]), dim=-1) + return matrix.reshape(*matrix.shape[:-1], 2, 2) - q_embed = (xq * cos) - q_split = q_embed.shape[-1] // 2 - q_embed[..., : q_split].addcmul_(xq[..., q_split :], nsin) - q_embed[..., q_split :].addcmul_(xq[..., : q_split], sin) - k_embed = (xk * cos) - k_split = k_embed.shape[-1] // 2 - k_embed[..., : k_split].addcmul_(xk[..., k_split :], nsin) - k_embed[..., k_split :].addcmul_(xk[..., : k_split], sin) +def apply_rope(xq, xk, freqs_cis): + matrix = rope_matrix(freqs_cis) + if matrix.ndim == 5: + matrix = matrix.unsqueeze(0) + + q_ndim, k_ndim = xq.ndim, xk.ndim + if q_ndim == 3: + xq = xq.unsqueeze(0) + if k_ndim == 3: + xk = xk.unsqueeze(0) - return q_embed.to(org_dtype), k_embed.to(org_dtype) + xq, xk = comfy_kitchen.apply_rope_split_half(xq, xk, matrix) + if q_ndim == 3: + xq = xq.squeeze(0) + if k_ndim == 3: + xk = xk.squeeze(0) + return xq, xk class Attention(nn.Module): @@ -767,7 +845,8 @@ def __init__(self, config, device=None, dtype=None, ops=None): super().__init__() self.config = config self.fixed_kv = getattr(config, "fixed_kv", False) - self.graph_dynamic_vbar_blocks = False + self.graph_dynamic_vbar_blocks = getattr(config, "graph_dynamic_vbar_blocks", False) + self.prefetch_dynamic_vbars = getattr(config, "prefetch_dynamic_vbars", False) self.vocab_size = config.vocab_size if self.config.transformer_type == "gemma2" or self.config.transformer_type == "gemma3": @@ -799,19 +878,25 @@ def get_past_len(self, past_key_values): def init_kv_cache(self, batch, capacity, device, dtype): caches = [] - fixed_kv = self.fixed_kv and comfy_kitchen.flash_attention_decode_is_available(device) + flash = getattr(comfy_kitchen, "flash_attention_decode_is_available", None) + flash_kv = self.fixed_kv and flash is not None and flash(device) for _ in range(self.config.num_hidden_layers): - if fixed_kv: + if flash_kv: key = torch.empty((batch, capacity, self.config.num_key_value_heads, self.config.head_dim), device=device, dtype=dtype) value = torch.empty_like(key) - position = torch.empty((batch,), device=device, dtype=torch.int64) + pos = torch.empty((batch,), device=device, dtype=torch.int64) seqlen = torch.zeros((batch,), device=device, dtype=torch.int32) - caches.append(FixedKV(key, value, 0, position, seqlen)) + caches.append(FixedKV(key, value, 0, pos, seqlen)) else: key = torch.empty((batch, self.config.num_key_value_heads, capacity, self.config.head_dim), device=device, dtype=dtype) caches.append((key, torch.empty_like(key), 0)) return caches + def init_decode_buffers(self, batch, device, dtype): + hidden = torch.empty((batch, 1, self.config.hidden_size), device=device, dtype=dtype) + positions = torch.zeros((1, 1), device=device, dtype=torch.int64) + return hidden, rope_matrix(self.compute_freqs_cis(positions, device)) + def compute_freqs_cis(self, position_ids, device): return precompute_freqs_cis(self.config.head_dim, position_ids, @@ -822,7 +907,7 @@ def compute_freqs_cis(self, position_ids, device): device=device) def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermediate_output=None, final_layer_norm_intermediate=True, - dtype=None, position_ids=None, embeds_info=[], past_key_values=None, input_ids=None,deepstack_embeds=None, visual_pos_masks=None): + dtype=None, position_ids=None, embeds_info=[], past_key_values=None, input_ids=None,deepstack_embeds=None, visual_pos_masks=None, decode_buffers=None): if embeds is not None: x = embeds else: @@ -847,7 +932,8 @@ def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermed mask = 1.0 - attention_mask.to(x.dtype).reshape((attention_mask.shape[0], 1, -1, attention_mask.shape[-1])).expand(attention_mask.shape[0], 1, seq_len, attention_mask.shape[-1]) mask = mask.masked_fill(mask.to(torch.bool), torch.finfo(x.dtype).min / 4) - if seq_len > 1: + spec_decode = fixed_kv and any(isinstance(kv, FixedKVBias) for kv in past_key_values) and 2 <= seq_len <= 6 and past_len > 0 and attention_mask is None + if seq_len > 1 and not spec_decode: # spec verify: the staircase decode bias is causal causal_mask = torch.empty(past_len + seq_len, past_len + seq_len, dtype=x.dtype, device=x.device).fill_(torch.finfo(x.dtype).min / 4).triu_(1) if mask is not None: mask += causal_mask @@ -856,9 +942,16 @@ def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermed optimized_attention = optimized_attention_for_device(x.device, mask=mask is not None, small_input=True) - enable_graph = self.graph_dynamic_vbar_blocks and fixed_kv_decode + enable_graph = self.graph_dynamic_vbar_blocks and (fixed_kv_decode or spec_decode) if enable_graph: - x = x.clone() + if decode_buffers is None: + x = x.clone() + else: + hidden_buffer, rotary_buffer = decode_buffers + hidden_buffer.copy_(x) + x = hidden_buffer + rotary_buffer.copy_(rope_matrix(freqs_cis)) + freqs_cis = rotary_buffer intermediate = None all_intermediate = None @@ -873,7 +966,7 @@ def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermed elif intermediate_output < 0: intermediate_output = len(self.layers) + intermediate_output - prefetch_queue = comfy.model_prefetch.make_prefetch_queue(list(self.layers), x.device, {"prefetch_dynamic_vbars": getattr(self, "prefetch_dynamic_vbars", False)}) + prefetch_queue = comfy.model_prefetch.make_prefetch_queue(list(self.layers), x.device, {"prefetch_dynamic_vbars": self.prefetch_dynamic_vbars and past_key_values is not None}) next_key_values = list(past_key_values) if past_key_values is not None else [] for i, layer in enumerate(self.layers): if all_intermediate is not None: @@ -985,6 +1078,18 @@ def set_input_embeddings(self, embeddings): def forward(self, input_ids, *args, **kwargs): return self.model(input_ids, *args, **kwargs) +def penalty_active(repetition_penalty, presence_penalty): + return repetition_penalty != 1.0 or (presence_penalty is not None and presence_penalty != 0.0) + + +def apply_penalty(logits, repetition_penalty, presence_penalty): + if repetition_penalty != 1.0: + logits = torch.where(logits < 0, logits * repetition_penalty, logits / repetition_penalty) + if presence_penalty is not None and presence_penalty != 0.0: + logits = logits - presence_penalty + return logits + + class BaseGenerate: def logits(self, x): input = x[:, -1:] @@ -994,7 +1099,7 @@ def logits(self, x): module = self.model.embed_tokens if not module.comfy_cast_weights: - return torch.nn.functional.linear(input, self.model.embed_tokens.weight.to(x), None) + return torch.nn.functional.linear(input, module.weight.to(x), None) with comfy.ops.CastBiasWeightContext(module, input, offloadable=True) as (weight, _bias): return torch.nn.functional.linear(input, weight, None) @@ -1029,28 +1134,44 @@ def generate(self, embeds=None, do_sample=True, max_length=256, temperature=1.0, next_pos = int(position_ids[:, -1].max()) + 1 if position_ids is not None else None compile_allocations = self.model.graph_dynamic_vbar_blocks and comfy.model_prefetch.malloc_graph_enabled(device) + decode_buffers = None + if compile_allocations and not comfy.model_management.args.disable_cuda_graphs: + init_decode_buffers = getattr(self.model, "init_decode_buffers", None) + if init_decode_buffers is not None: + decode_buffers = init_decode_buffers(embeds.shape[0], device, execution_dtype) decode_tokens = torch.empty((embeds.shape[0], 1), dtype=torch.long, device=device) + penalize = penalty_active(repetition_penalty, presence_penalty) + penalty_mask = None # Generation loop current_input_ids = initial_input_ids for step in tqdm(range(max_length), desc="Generating tokens"): if step > 0: if compile_allocations: - comfy.model_prefetch.malloc_graph_begin(self, device) + comfy.model_prefetch.malloc_graph_begin(device) embeds = self.model.embed_tokens(decode_tokens).to(execution_dtype) current_input_ids = decode_tokens if initial_input_ids is not None else None position_ids = torch.tensor([[next_pos]], device=device) if next_pos is not None else None # DeepStack visual features are injected on the prefill only; gemma4's forward lacks these kwargs. extra = {} + if decode_buffers is not None: + extra["decode_buffers"] = decode_buffers if step == 0 and deepstack_embeds is not None: extra["deepstack_embeds"] = deepstack_embeds extra["visual_pos_masks"] = visual_pos_masks x, _, past_key_values = self.model.forward(None, embeds=embeds, attention_mask=None, past_key_values=past_key_values, input_ids=current_input_ids, position_ids=position_ids, **extra, embeds_info=(embeds_info if step == 0 else None)) logits = self.logits(x)[:, -1] - next_token = self.sample_token(logits, temperature, top_k, top_p, min_p, repetition_penalty, initial_tokens + generated_token_ids, generator, do_sample=do_sample, presence_penalty=presence_penalty) + if penalty_mask is None and do_sample and penalize: + # allocated on the (unbracketed) first step; later steps only index_fill_ it + penalty_mask = torch.zeros((logits.shape[-1],), dtype=torch.bool, device=device) + if len(initial_tokens) > 0: + penalty_mask.index_fill_(0, torch.tensor(initial_tokens, device=device), True) + next_token = self.sample_token(logits, temperature, top_k, top_p, min_p, repetition_penalty, [], generator, do_sample=do_sample, presence_penalty=presence_penalty, penalty_mask=penalty_mask) decode_tokens.copy_(next_token) + if penalty_mask is not None: + penalty_mask.index_fill_(0, decode_tokens[0], True) del next_token, logits, x, embeds, position_ids if step > 0 and compile_allocations: comfy.model_prefetch.malloc_graph_end() @@ -1067,48 +1188,23 @@ def generate(self, embeds=None, do_sample=True, max_length=256, temperature=1.0, return generated_token_ids - def sample_token(self, logits, temperature, top_k, top_p, min_p, repetition_penalty, token_history, generator, do_sample=True, presence_penalty=0.0): - - if not do_sample or temperature == 0.0: - return torch.argmax(logits, dim=-1, keepdim=True) - - # Sampling mode - if len(token_history) > 0 and (repetition_penalty != 1.0 or (presence_penalty is not None and presence_penalty != 0.0)): - token_ids = torch.tensor(list(set(token_history)), device=logits.device) - token_logits = logits[:, token_ids] - if repetition_penalty != 1.0: - token_logits = torch.where(token_logits < 0, token_logits * repetition_penalty, token_logits / repetition_penalty) - if presence_penalty is not None and presence_penalty != 0.0: - token_logits = token_logits - presence_penalty - logits[:, token_ids] = token_logits + def processed_probs(self, logits, temperature, top_k, top_p, min_p, repetition_penalty, token_history, presence_penalty=0.0, penalty_mask=None): + # returns (probs, indices); penalty_mask [vocab] bool stands in for token_history + if penalty_active(repetition_penalty, presence_penalty): + if penalty_mask is not None: + logits = torch.where(penalty_mask.unsqueeze(0), apply_penalty(logits, repetition_penalty, presence_penalty), logits) + elif len(token_history) > 0: + token_ids = torch.tensor(list(set(token_history)), device=logits.device) + logits[:, token_ids] = apply_penalty(logits[:, token_ids], repetition_penalty, presence_penalty) if temperature != 1.0: logits = logits / temperature + top_indices = None if top_k > 0: top_k = min(top_k, logits.shape[-1]) logits, top_indices = torch.topk(logits, top_k) - if min_p > 0.0: - probs_before_filter = torch.nn.functional.softmax(logits, dim=-1) - top_probs, _ = probs_before_filter.max(dim=-1, keepdim=True) - min_threshold = min_p * top_probs - indices_to_remove = probs_before_filter < min_threshold - logits[indices_to_remove] = torch.finfo(logits.dtype).min - - if top_p < 1.0: - sorted_logits, sorted_indices = torch.sort(logits, descending=True) - cumulative_probs = torch.cumsum(torch.nn.functional.softmax(sorted_logits, dim=-1), dim=-1) - sorted_indices_to_remove = cumulative_probs > top_p - sorted_indices_to_remove[..., 0] = False - indices_to_remove = torch.zeros_like(logits, dtype=torch.bool) - indices_to_remove.scatter_(1, sorted_indices, sorted_indices_to_remove) - logits[indices_to_remove] = torch.finfo(logits.dtype).min - - probs = torch.nn.functional.softmax(logits, dim=-1) - next_token = torch.multinomial(probs, num_samples=1, generator=generator) - return top_indices.gather(1, next_token) - if min_p > 0.0: probs_before_filter = torch.nn.functional.softmax(logits, dim=-1) top_probs, _ = probs_before_filter.max(dim=-1, keepdim=True) @@ -1125,9 +1221,18 @@ def sample_token(self, logits, temperature, top_k, top_p, min_p, repetition_pena indices_to_remove.scatter_(1, sorted_indices, sorted_indices_to_remove) logits[indices_to_remove] = torch.finfo(logits.dtype).min - probs = torch.nn.functional.softmax(logits, dim=-1) + return torch.nn.functional.softmax(logits, dim=-1), top_indices + + def sample_token(self, logits, temperature, top_k, top_p, min_p, repetition_penalty, token_history, generator, do_sample=True, presence_penalty=0.0, penalty_mask=None): + + if not do_sample or temperature == 0.0: + return torch.argmax(logits, dim=-1, keepdim=True) - return torch.multinomial(probs, num_samples=1, generator=generator) + probs, top_indices = self.processed_probs(logits, temperature, top_k, top_p, min_p, repetition_penalty, token_history, presence_penalty=presence_penalty, penalty_mask=penalty_mask) + next_token = torch.multinomial(probs, num_samples=1, generator=generator) + if top_indices is not None: + return top_indices.gather(1, next_token) + return next_token class BaseQwen3: def logits(self, x): diff --git a/comfy/text_encoders/lt.py b/comfy/text_encoders/lt.py index c512a7d484b..2579e1b15bf 100644 --- a/comfy/text_encoders/lt.py +++ b/comfy/text_encoders/lt.py @@ -102,7 +102,7 @@ def __init__(self, device="cpu", layer="all", layer_idx=None, dtype=None, attent self.dtypes.add(dtype) super().__init__(device=device, layer=layer, layer_idx=layer_idx, textmodel_json_config={}, dtype=dtype, special_tokens={"start": 2, "pad": 0}, layer_norm_hidden_state=False, model_class=comfy.text_encoders.llama.Gemma3_12B, enable_attention_masks=attention_mask, return_attention_masks=attention_mask, model_options=model_options) - def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty): + def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty, mtp=True): tokens_only = [[t[0] for t in b] for b in tokens] embeds, _, _, _ = self.process_tokens(tokens_only, self.execution_device) return self.transformer.generate(embeds, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, stop_tokens=[106], presence_penalty=presence_penalty) # 106 is @@ -205,7 +205,7 @@ def encode_token_weights(self, token_weight_pairs): return out.to(device=out_device, dtype=torch.float), pooled, extra - def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty): + def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty, mtp=True): return self.gemma3_12b.generate(tokens[self.text_encoder_key], do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty) def load_sd(self, sd): diff --git a/comfy/text_encoders/qwen35.py b/comfy/text_encoders/qwen35.py index 304a4357f92..29b5d469d4f 100644 --- a/comfy/text_encoders/qwen35.py +++ b/comfy/text_encoders/qwen35.py @@ -2,14 +2,53 @@ import torch.nn as nn import torch.nn.functional as F from dataclasses import dataclass, field +from tqdm import tqdm +import contextlib import os +import warnings import comfy.model_management +import comfy.model_prefetch +import comfy.ops +import comfy_kitchen +from comfy.quant_ops import QuantizedTensor from comfy.ldm.modules.attention import optimized_attention_for_device from comfy import sd1_clip import comfy.text_encoders.qwen_vl -from .llama import BaseLlama, BaseGenerate, Llama2_, MLP, RMSNorm, apply_rope +from .llama import BaseLlama, BaseGenerate, FixedKV, FixedKVBias, Llama2_, MLP, RMSNorm, apply_penalty, apply_rope, penalty_active, precompute_freqs_cis, rope_matrix + + +@dataclass +class LinearKV(FixedKV): + # DeltaNet state on the FixedKV interface: key=conv_state, value=recurrent_state (fp32) + g_decay: torch.Tensor = None + dt_bias: torch.Tensor = None + snapshots: list = None # [(recurrent, conv)] taken after step 1, 2, ... of the last verify + last_seq: int = 1 + snap_backing: torch.Tensor = None + conv_snap_backing: torch.Tensor = None + norm_weight: torch.Tensor = None + + def prepare(self, num_tokens): + pass + + def rollback(self, discard=1): + # discard the rejected tail: restore the snapshot taken after the last kept token + rec, conv = self.snapshots[self.last_seq - discard - 1] + self.recurrent_state.copy_(rec) + self.conv_state.copy_(conv) + self.index -= discard + + @property + def conv_state(self): + return self.key + + @property + def recurrent_state(self): + return self.value + + def _qwen35_layer_types(n): @@ -48,6 +87,7 @@ class Qwen35Config: transformer_type: str = "qwen35_2b" rope_dims: list = None rope_scale: float = None + mtp: bool = False QWEN35_VISION_DEFAULTS = dict(hidden_size=1024, num_heads=16, intermediate_size=4096, depth=24, patch_size=16, temporal_patch_size=2, in_channels=3, spatial_merge_size=2, num_position_embeddings=2304) @@ -135,18 +175,6 @@ def torch_chunk_gated_delta_rule(query, key, value, g, beta, chunk_size=64, init return core_attn_out, last_recurrent_state -def torch_causal_conv1d_update(x, conv_state, weight, bias=None): - # conv_state: [B, channels, kernel_size-1], x: [B, channels, 1] - # weight: [channels, kernel_size] - state_len = conv_state.shape[-1] - combined = torch.cat([conv_state, x], dim=-1).to(weight.dtype) # [B, channels, kernel_size] - conv_state.copy_(combined[:, :, -state_len:]) - out = (combined * weight).sum(dim=-1, keepdim=True) # [B, channels, 1] - if bias is not None: - out = out + bias.unsqueeze(0).unsqueeze(-1) - return F.silu(out).to(x.dtype) - - # GatedDeltaNet - Linear Attention Layer class GatedDeltaNet(nn.Module): @@ -185,25 +213,55 @@ def forward(self, x, past_key_value=None, **kwargs): use_recurrent = ( past_key_value is not None - and past_key_value[2] > 0 - and seq_len == 1 + and past_key_value.index > 0 + and seq_len <= 6 ) + fused_available = getattr(comfy_kitchen, "gated_delta_decode_is_available", None) + use_fused = (use_recurrent and fused_available is not None and fused_available(x.device, self.key_head_dim, self.value_head_dim) + and (seq_len == 1 or past_key_value.snap_backing is not None)) + # Projections (shared) - mixed_qkv = self.in_proj_qkv(x).transpose(1, 2) # [B, conv_dim, seq_len] + proj = self.in_proj_qkv(x) # [B, seq_len, conv_dim] z = self.in_proj_z(x) + + if use_fused: + # decode: kitchen conv step, then gates + delta rule + gated norm in one kernel + if seq_len > 1: + past_key_value.last_seq = seq_len + with comfy.ops.CastBiasWeightContext(self.conv1d, proj, offloadable=True) as (conv_weight, conv_bias): + conv_out = comfy_kitchen.deltanet_conv_step(proj, past_key_value.conv_state, conv_weight, conv_bias, + past_key_value.conv_snap_backing[:seq_len - 1] if seq_len > 1 else None) + with comfy.ops.CastBiasWeightContext(self.in_proj_a, x, offloadable=True) as (w_a, _), \ + comfy.ops.CastBiasWeightContext(self.in_proj_b, x, offloadable=True) as (w_b, _): + if isinstance(w_a, QuantizedTensor): + w_a = w_a.dequantize() + if isinstance(w_b, QuantizedTensor): + w_b = w_b.dequantize() + core_attn_out = comfy_kitchen.gated_delta_decode_fused( + conv_out, x, w_a, w_b, past_key_value.dt_bias, past_key_value.g_decay, past_key_value.recurrent_state, + self.key_dim, self.num_key_heads, self.key_head_dim ** -0.5, z, past_key_value.norm_weight, self.norm.eps, + past_key_value.snap_backing[:seq_len - 1] if seq_len > 1 else None) + return self.out_proj(core_attn_out.reshape(batch_size, seq_len, -1)), past_key_value + + mixed_qkv = proj.transpose(1, 2) # [B, conv_dim, seq_len] b = self.in_proj_b(x) a = self.in_proj_a(x) # Conv1d if use_recurrent: - recurrent_state, conv_state, step_index = past_key_value - conv_weight = comfy.model_management.cast_to_device(self.conv1d.weight, mixed_qkv.device, mixed_qkv.dtype).squeeze(1) - conv_bias = comfy.model_management.cast_to_device(self.conv1d.bias, mixed_qkv.device, mixed_qkv.dtype) if self.conv1d.bias is not None else None - mixed_qkv = torch_causal_conv1d_update(mixed_qkv, conv_state, conv_weight, conv_bias) + # decode: exact-width causal window, weight resolved via the vbar-aware context + combined = torch.cat([past_key_value.conv_state, mixed_qkv], dim=-1) + if seq_len > 1: + past_key_value.last_seq = seq_len + for s in range(seq_len - 1): + past_key_value.snapshots[s][1].copy_(combined[:, :, 1 + s:1 + s + self.conv_kernel_size - 1]) + past_key_value.conv_state.copy_(combined[:, :, seq_len:]) + with comfy.ops.CastBiasWeightContext(self.conv1d, combined, offloadable=True) as (conv_weight, conv_bias): + mixed_qkv = F.silu(F.conv1d(combined, conv_weight, conv_bias, groups=self.conv1d.groups)) else: if past_key_value is not None: - recurrent_state, conv_state, step_index = past_key_value + conv_state = past_key_value.conv_state conv_state_init = F.pad(mixed_qkv, (self.conv_kernel_size - mixed_qkv.shape[-1], 0)) conv_state.copy_(conv_state_init[:, :, -conv_state.shape[-1]:]) mixed_qkv = F.silu(self.conv1d(mixed_qkv)[:, :, :seq_len]) @@ -211,47 +269,51 @@ def forward(self, x, past_key_value=None, **kwargs): # Split QKV and compute beta/g mixed_qkv = mixed_qkv.transpose(1, 2) # [B, seq_len, conv_dim] query, key, value = mixed_qkv.split([self.key_dim, self.key_dim, self.value_dim], dim=-1) + if use_recurrent: + g_decay, dt_bias = past_key_value.g_decay, past_key_value.dt_bias + else: + g_decay = -comfy.model_management.cast_to_device(self.A_log, x.device, torch.float32).exp() + dt_bias = comfy.model_management.cast_to_device(self.dt_bias, x.device, torch.float32) + if past_key_value is not None: + past_key_value.g_decay = g_decay + past_key_value.dt_bias = dt_bias + past_key_value.norm_weight = comfy.model_management.cast_to(self.norm.weight, dtype=x.dtype, device=x.device) + beta = b.sigmoid() - g = -self.A_log.float().exp() * F.softplus(a.float() + self.dt_bias.float()) + g = g_decay * F.softplus(a.float() + dt_bias) + query = query.reshape(batch_size, seq_len, self.num_key_heads, self.key_head_dim) + key = key.reshape(batch_size, seq_len, self.num_key_heads, self.key_head_dim) + value = value.reshape(batch_size, seq_len, self.num_value_heads, self.value_head_dim) + if self.num_value_heads != self.num_key_heads: + rep = self.num_value_heads // self.num_key_heads + query = query.repeat_interleave(rep, dim=2) + key = key.repeat_interleave(rep, dim=2) # Delta rule if use_recurrent: - # single-token path: work in [B, heads, dim] without seq dim - query = query.reshape(batch_size, self.num_key_heads, self.key_head_dim) - key = key.reshape(batch_size, self.num_key_heads, self.key_head_dim) - value = value.reshape(batch_size, self.num_value_heads, self.value_head_dim) - - if self.num_value_heads != self.num_key_heads: - rep = self.num_value_heads // self.num_key_heads - query = query.repeat_interleave(rep, dim=1) - key = key.repeat_interleave(rep, dim=1) - scale = self.key_head_dim ** -0.5 q = F.normalize(query.float(), dim=-1) * scale k = F.normalize(key.float(), dim=-1) v = value.float() - beta_t = beta.reshape(batch_size, -1) - g_t = g.reshape(batch_size, -1).exp() + beta_t = beta.reshape(batch_size, seq_len, -1) + g_t = g.reshape(batch_size, seq_len, -1).exp() # In-place state update: [B, heads, k_dim, v_dim] - recurrent_state.mul_(g_t[:, :, None, None]) - kv_mem = torch.einsum('bhk,bhkv->bhv', k, recurrent_state) - delta = (v - kv_mem) * beta_t[:, :, None] - recurrent_state.add_(k.unsqueeze(-1) * delta.unsqueeze(-2)) - core_attn_out = torch.einsum('bhk,bhkv->bhv', q, recurrent_state) - - core_attn_out = core_attn_out.to(x.dtype).unsqueeze(1) - present_key_value = (recurrent_state, conv_state, step_index + 1) + recurrent_state = past_key_value.recurrent_state + outs = [] + for s in range(seq_len): + recurrent_state.mul_(g_t[:, s, :, None, None]) + kv_mem = torch.einsum('bhk,bhkv->bhv', k[:, s], recurrent_state) + delta = (v[:, s] - kv_mem) * beta_t[:, s, :, None] + # rank-1 update via baddbmm_: no materialized [B, H, D, D] outer-product temp + recurrent_state.view(-1, self.key_head_dim, self.value_head_dim).baddbmm_( + k[:, s].reshape(-1, self.key_head_dim, 1), delta.reshape(-1, 1, self.value_head_dim)) + outs.append(torch.einsum('bhk,bhkv->bhv', q[:, s], recurrent_state)) + if seq_len > 1 and s < seq_len - 1: + past_key_value.snapshots[s][0].copy_(recurrent_state) + core_attn_out = torch.stack(outs, dim=1).to(x.dtype) + present_key_value = past_key_value else: - query = query.reshape(batch_size, seq_len, -1, self.key_head_dim) - key = key.reshape(batch_size, seq_len, -1, self.key_head_dim) - value = value.reshape(batch_size, seq_len, -1, self.value_head_dim) - - if self.num_value_heads != self.num_key_heads: - rep = self.num_value_heads // self.num_key_heads - query = query.repeat_interleave(rep, dim=2) - key = key.repeat_interleave(rep, dim=2) - core_attn_out, last_recurrent_state = torch_chunk_gated_delta_rule( query, key, value, g=g, beta=beta, initial_state=None, @@ -261,8 +323,8 @@ def forward(self, x, past_key_value=None, **kwargs): present_key_value = None if past_key_value is not None: if last_recurrent_state is not None: - recurrent_state.copy_(last_recurrent_state.to(recurrent_state.dtype)) - present_key_value = (recurrent_state, conv_state, step_index + seq_len) + past_key_value.recurrent_state.copy_(last_recurrent_state.to(past_key_value.recurrent_state.dtype)) + present_key_value = past_key_value # Gated norm + output projection (shared) core_attn_out = self.norm(core_attn_out.reshape(-1, self.value_head_dim), z.reshape(-1, self.value_head_dim)) @@ -271,29 +333,6 @@ def forward(self, x, past_key_value=None, **kwargs): # GatedAttention - Full Attention with output gating -def precompute_partial_rope(head_dim, rotary_dim, position_ids, theta, device=None, mrope_section=None): - """Compute RoPE frequencies for partial rotary embeddings.""" - theta_numerator = torch.arange(0, rotary_dim, 2, device=device).float() - inv_freq = 1.0 / (theta ** (theta_numerator / rotary_dim)) - - inv_freq_expanded = inv_freq[None, :, None].float().expand(position_ids.shape[0], -1, 1) - position_ids_expanded = position_ids[:, None, :].float() - freqs = (inv_freq_expanded.float() @ position_ids_expanded.float()).transpose(1, 2) - emb = torch.cat((freqs, freqs), dim=-1) - cos = emb.cos() - sin = emb.sin() - - if mrope_section is not None and position_ids.shape[0] == 3: - mrope_section_2 = [s * 2 for s in mrope_section] - cos = torch.cat([m[i % 3] for i, m in enumerate(cos.split(mrope_section_2, dim=-1))], dim=-1).unsqueeze(0) - sin = torch.cat([m[i % 3] for i, m in enumerate(sin.split(mrope_section_2, dim=-1))], dim=-1).unsqueeze(0) - - cos = cos.unsqueeze(1) - sin = sin.unsqueeze(1) - sin_split = sin.shape[-1] // 2 - return (cos, sin[..., :sin_split], -sin[..., sin_split:]) - - def apply_partial_rope(xq, xk, freqs_cis, rotary_dim): """Apply RoPE to only the first rotary_dim dimensions.""" xq_rot = xq[..., :rotary_dim] @@ -350,26 +389,16 @@ def forward(self, x, attention_mask=None, freqs_cis=None, optimized_attention=No xq, xk = apply_partial_rope(xq, xk, freqs_cis, self.rotary_dim) # KV cache - present_key_value = None - if past_key_value is not None: - past_key, past_value, index = past_key_value - num_tokens = xk.shape[2] - if past_key.shape[2] >= (index + num_tokens): - past_key[:, :, index:index + num_tokens] = xk - past_value[:, :, index:index + num_tokens] = xv - xk = past_key[:, :, :index + num_tokens] - xv = past_value[:, :, :index + num_tokens] - present_key_value = (past_key, past_value, index + num_tokens) - else: - if index > 0: - xk = torch.cat((past_key[:, :, :index], xk), dim=2) - xv = torch.cat((past_value[:, :, :index], xv), dim=2) - present_key_value = (xk, xv, index + num_tokens) + present_key_value = past_key_value + if past_key_value is not None and seq_length <= 6 and attention_mask is None: + output = past_key_value.decode(xq, xk, xv, self.num_kv_heads) + else: + if past_key_value is not None: + xk, xv = past_key_value.append(xk, xv) + gqa_kwargs = {"enable_gqa": True} if self.num_heads != self.num_kv_heads else {} + output = optimized_attention(xq, xk, xv, self.num_heads, mask=attention_mask, skip_reshape=True, **gqa_kwargs) - gqa_kwargs = {"enable_gqa": True} if self.num_heads != self.num_kv_heads else {} - output = optimized_attention(xq, xk, xv, self.num_heads, mask=attention_mask, skip_reshape=True, **gqa_kwargs) output = output * gate.sigmoid() - return self.o_proj(output), present_key_value @@ -387,13 +416,15 @@ def __init__(self, config, index, device=None, dtype=None, ops=None): self.post_attention_layernorm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=config.rms_norm_add, device=device, dtype=dtype) def forward(self, x, attention_mask=None, freqs_cis=None, optimized_attention=None, past_key_value=None): + output = x if self.layer_type == "linear_attention": h, present_key_value = self.linear_attn(self.input_layernorm(x), attention_mask=attention_mask, past_key_value=past_key_value) else: h, present_key_value = self.self_attn(self.input_layernorm(x), attention_mask=attention_mask, freqs_cis=freqs_cis, optimized_attention=optimized_attention, past_key_value=past_key_value) - x = x + h - x = x + self.mlp(self.post_attention_layernorm(x)) + # in-place into the input buffer so CUDA-graph replays land in the static x + x = torch.add(x, h, out=output) + x = torch.add(x, self.mlp(self.post_attention_layernorm(x)), out=output) return x, present_key_value @@ -402,6 +433,8 @@ class Qwen35Transformer(Llama2_): def __init__(self, config, device=None, dtype=None, ops=None): nn.Module.__init__(self) self.config = config + self.prefetch_dynamic_vbars = True + self.graph_dynamic_vbar_blocks = True self.vocab_size = config.vocab_size self.embed_tokens = ops.Embedding(config.vocab_size, config.hidden_size, device=device, dtype=dtype) self.layers = nn.ModuleList([ @@ -417,21 +450,10 @@ def __init__(self, config, device=None, dtype=None, ops=None): if config.lm_head: self.lm_head = ops.Linear(config.hidden_size, config.vocab_size, bias=False, device=device, dtype=dtype) - def get_past_len(self, past_key_values): - for i, layer in enumerate(self.layers): - if layer.layer_type == "full_attention": - if len(past_key_values) > i: - return past_key_values[i][2] - break - return 0 - def compute_freqs_cis(self, position_ids, device): rotary_dim = int(self.config.head_dim * self.config.partial_rotary_factor) - return precompute_partial_rope( - self.config.head_dim, rotary_dim, position_ids, - self.config.rope_theta, device=device, - mrope_section=self.config.mrope_section, - ) + return precompute_freqs_cis(rotary_dim, position_ids, self.config.rope_theta, + rope_dims=self.config.mrope_section, interleaved_mrope=True, device=device) # Vision Encoder @@ -671,6 +693,23 @@ def forward(self, x, grid_thw): return merged, deepstack_features return merged +class MTPHead(nn.Module): + # MTP draft head: fc(cat[norm(embed), norm(hidden)]) -> attention block (own KV) -> norm + def __init__(self, config, device=None, dtype=None, ops=None): + super().__init__() + self.fc = ops.Linear(config.hidden_size * 2, config.hidden_size, bias=False, device=device, dtype=dtype) + self.pre_fc_norm_embedding = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=config.rms_norm_add, device=device, dtype=dtype) + self.pre_fc_norm_hidden = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=config.rms_norm_add, device=device, dtype=dtype) + self.layers = nn.ModuleList([Qwen35TransformerBlock(config, index=3, device=device, dtype=dtype, ops=ops)]) + self.norm = RMSNorm(config.hidden_size, eps=config.rms_norm_eps, add=config.rms_norm_add, device=device, dtype=dtype) + + def forward(self, embeds, hidden, freqs_cis, past_key_value): + x = self.fc(torch.cat([self.pre_fc_norm_embedding(embeds), self.pre_fc_norm_hidden(hidden)], dim=-1)) + attention = optimized_attention_for_device(x.device, mask=False, small_input=True) + x, _ = self.layers[0](x, attention_mask=None, freqs_cis=freqs_cis, optimized_attention=attention, past_key_value=past_key_value) + return self.norm(x), x # (for logits, pre-norm hidden for recursive drafting) + + # Model Wrapper class Qwen35(BaseLlama, BaseGenerate, torch.nn.Module): model_type = "qwen35_2b" @@ -680,6 +719,9 @@ def __init__(self, config_dict, dtype, device, operations): config = _make_config(self.model_type, config_dict) self.num_layers = config.num_hidden_layers self.model = Qwen35Transformer(config, device=device, dtype=dtype, ops=operations) + self.mtp = None + if config.mtp: + self.mtp = MTPHead(config, device=device, dtype=dtype, ops=operations) vision_overrides = QWEN35_MODELS.get(self.model_type, {}).get("vision", {}) vision_config = {**QWEN35_VISION_DEFAULTS, **vision_overrides, "out_hidden_size": config.hidden_size} self.visual = Qwen35VisionModel(vision_config, device=device, dtype=dtype, ops=operations) @@ -687,7 +729,8 @@ def __init__(self, config_dict, dtype, device, operations): def preprocess_embed(self, embed, device): if embed["type"] == "image": - image, grid = comfy.text_encoders.qwen_vl.process_qwen2vl_images(embed["data"], patch_size=16) + # Qwen3.5 normalizes to [-1, 1] (mean/std 0.5), same as Qwen3-VL. + image, grid = comfy.text_encoders.qwen_vl.process_qwen2vl_images(embed["data"], patch_size=16, image_mean=[0.5, 0.5, 0.5], image_std=[0.5, 0.5, 0.5]) return self.visual(image.to(device, dtype=torch.float32), grid), grid return None, None @@ -695,9 +738,271 @@ def forward(self, x, attention_mask=None, embeds=None, num_tokens=None, intermed position_ids = comfy.text_encoders.qwen_vl.qwen2vl_mrope_position_ids(embeds_info, embeds.shape[1], embeds.device) return super().forward(x, attention_mask=attention_mask, embeds=embeds, num_tokens=num_tokens, intermediate_output=intermediate_output, final_layer_norm_intermediate=final_layer_norm_intermediate, dtype=dtype, position_ids=position_ids, past_key_values=past_key_values) + def generate(self, embeds=None, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.9, min_p=0.0, repetition_penalty=1.0, seed=42, stop_tokens=None, **kwargs): + mtp = kwargs.pop("mtp", True) + if self.mtp is None or not mtp or kwargs.get("position_ids") is not None or kwargs.get("initial_input_ids") is not None: + return super().generate(embeds=embeds, do_sample=do_sample, max_length=max_length, temperature=temperature, + top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, + seed=seed, stop_tokens=stop_tokens, **kwargs) + sampling = None + if do_sample and temperature != 0.0: + sampling = {"temperature": temperature, "top_k": top_k, "top_p": top_p, "min_p": min_p, + "repetition_penalty": repetition_penalty, + "presence_penalty": kwargs.get("presence_penalty", 0.0) or 0.0, + "seed": seed if seed is not None else 42} + fixed_depth = None if mtp is True else max(2, min(5, int(mtp))) + return self._generate_mtp(embeds, max_length, stop_tokens, sampling=sampling, fixed_depth=fixed_depth) + + def _generate_mtp(self, embeds, max_length, stop_tokens, sampling=None, fixed_depth=None): + device = embeds.device + cfg = self.model.config + if stop_tokens is None: + stop_tokens = cfg.stop_tokens + dt = torch.bfloat16 if comfy.model_management.should_use_bf16(device) else torch.float32 + embeds = embeds.to(dt) + if embeds.ndim == 2: + embeds = embeds.unsqueeze(0) + # greedy drafts 3 deep (5 after the probe); sampled stays at 2 + depth = fixed_depth if fixed_depth is not None else (3 if sampling is None else 2) + cap = embeds.shape[1] + max_length + 7 + pkv = self.init_kv_cache(embeds.shape[0], cap, device, dt) + # repair window: drafting ahead plus a near-full rollback + mtp_kv = FixedKVBias.zeros(embeds.shape[0], cfg.num_key_value_heads, cap, cfg.head_dim, device, dt, + FixedKVBias.shared(cap, device, dt)) + head = self.model.lm_head if hasattr(self.model, "lm_head") else self.model.embed_tokens + + def verify_logits(x): + if not head.comfy_cast_weights: + return F.linear(x, head.weight.to(x), None) + with comfy.ops.CastBiasWeightContext(head, x, offloadable=True) as (w, _bias): + return F.linear(x, w) + + # the draft graph bakes these weights' addresses: keep them resident for the generate + hot = list({id(m): m for m in [head, self.model.embed_tokens, *self.mtp.modules()]}.values()) + pinned = [m for m in hot if hasattr(m, "_v")] + + generator = None + if sampling is not None: + generator = torch.Generator(device=device).manual_seed(sampling["seed"]) + penalized = sampling is not None and penalty_active(sampling["repetition_penalty"], sampling["presence_penalty"]) + + # rope table once per generate, sliced per draft + ftab = rope_matrix(self.model.compute_freqs_cis(torch.arange(cap, device=device, dtype=torch.float).unsqueeze(0), device)) + + def freqs_at(p, n=1): + return ftab[:, :, p:p + n] + + # cross-step state lives in static carriers: nothing allocated inside a step may outlive it + nt_buf = torch.empty((embeds.shape[0], 1), device=device, dtype=torch.long) + h_buf = torch.empty((embeds.shape[0], 1, cfg.hidden_size), device=device, dtype=dt) + x, _, _ = self.model.forward(None, embeds=embeds, attention_mask=None, past_key_values=pkv) + lg0 = self.logits(x)[:, -1] + if sampling is None: + nt_buf.copy_(lg0.argmax(dim=-1, keepdim=True)) + else: + nt_buf.copy_(self.sample_token(lg0, sampling["temperature"], sampling["top_k"], sampling["top_p"], sampling["min_p"], + sampling["repetition_penalty"], [], generator, presence_penalty=sampling["presence_penalty"])) + pen_mask = torch.zeros((lg0.shape[-1],), device=device, dtype=torch.bool) if penalized else None + h_buf.copy_(x[:, -1:, :]) + del x, lg0 + ids = [nt_buf[0].item()] + if penalized: + pen_mask.index_fill_(0, nt_buf.reshape(-1), True) + pos = embeds.shape[1] + progress = comfy.utils.ProgressBar(max_length) + console = tqdm(total=max_length, desc="Generating tokens", initial=1) + + def update_progress(n): + progress.update(n) + console.update(n) + + verify_buffers = None + snapshot_bytes = sum(kv.recurrent_state.numel() * 4 + kv.conv_state.numel() * kv.conv_state.element_size() + for kv in pkv if isinstance(kv, LinearKV)) + + def set_depth(d): + nonlocal depth, verify_buffers + depth = d + for kv in pkv: + if isinstance(kv, LinearKV): + # snapshot views share one backing slab so the fused kernel can write them + kv.snap_backing = torch.empty((d,) + tuple(kv.recurrent_state.shape), device=device, dtype=torch.float32) + kv.conv_snap_backing = torch.empty((d,) + tuple(kv.conv_state.shape), device=device, dtype=kv.conv_state.dtype) + kv.snapshots = [(kv.snap_backing[s], kv.conv_snap_backing[s]) for s in range(d)] + # static hidden/rope buffers: graphed layers bake their input addresses + verify_buffers = (torch.empty((embeds.shape[0], d + 1, cfg.hidden_size), device=device, dtype=dt), freqs_at(pos, d + 1).clone()) + + set_depth(depth) + use_graph = (device.type == "cuda" + and comfy.model_management.NUM_STREAMS > 0 + and not comfy.model_management.args.disable_cuda_graphs) + compile_allocations = use_graph and self.model.graph_dynamic_vbar_blocks and comfy.model_prefetch.malloc_graph_enabled(device) + draft_state = {} + + def drop_draft_graph(): + # free inside torch API calls so the allocator's benign notices stay catchable + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + for t in (draft_state.get("d"), draft_state.get("r"), *draft_state.get("keep", ())): + if t is not None: + t.set_() + g = draft_state.pop("graph", None) + if g is not None: + g.reset() + draft_state.clear() + + def draft_capture(): + # captured outside the compiler bracket; its static buffers live for the generate + ds = draft_state + mtp_kv.prepare(1) + ds["tok"] = nt_buf.clone() + ds["hid"] = h_buf.clone() + ds["f"] = freqs_at(pos).clone() + side = torch.cuda.Stream() + side.wait_stream(torch.cuda.current_stream()) + with torch.cuda.stream(side): + for _ in range(2): + n1, r1 = self.mtp(self.model.embed_tokens(ds["tok"]).to(dt), ds["hid"], ds["f"], mtp_kv) + self.logits(n1)[:, -1].argmax(dim=-1, keepdim=True) + torch.cuda.current_stream().wait_stream(side) + del n1, r1 # freed before the capture, not shadowed inside it + g = torch.cuda.CUDAGraph() + with torch.cuda.graph(g, capture_error_mode="thread_local"): + n1, r1 = self.mtp(self.model.embed_tokens(ds["tok"]).to(dt), ds["hid"], ds["f"], mtp_kv) + lg1 = self.logits(n1) + ds["d"] = lg1[:, -1].argmax(dim=-1, keepdim=True) + ds["r"] = r1 + ds["keep"] = (n1, lg1) # captured allocations must outlive the graph + ds["graph"] = g + + def draft(token, hidden, p): + # one drafted token: mtp head + lm_head argmax, graph-replayed on cuda + mtp_kv.prepare(1) + f = freqs_at(p) + if not use_graph: + n1, r1 = self.mtp(self.model.embed_tokens(token).to(dt), hidden, f, mtp_kv) + mtp_kv.advance(1) + return self.logits(n1)[:, -1].argmax(dim=-1, keepdim=True), r1 + ds = draft_state + ds["tok"].copy_(token) + ds["hid"].copy_(hidden) + ds["f"].copy_(f) + ds["graph"].replay() + mtp_kv.advance(1) + return ds["d"], ds["r"] + + def verify_sample(lg, drafts): + # accept draft i w.p. p_i(draft), else sample the residual; all depth+1 columns as one batch + s = sampling + rows = lg[0].float() + if penalized: + # committed tokens penalize every column, each draft the columns after it + mask = pen_mask.unsqueeze(0).repeat(depth + 1, 1) + for c, d in enumerate(drafts): + mask[c + 1:].index_fill_(1, d.reshape(-1), True) + rows = torch.where(mask, apply_penalty(rows, s["repetition_penalty"], s["presence_penalty"]), rows) + probs, idx = self.processed_probs(rows, s["temperature"], s["top_k"], s["top_p"], s["min_p"], 1.0, []) + dr = torch.cat(drafts, dim=1).reshape(-1, 1) + if idx is None: + p_draft = probs[:depth].gather(1, dr).reshape(-1) + w = probs.clone() + w[:depth].scatter_(1, dr, 0.0) + else: + hit = idx[:depth] == dr + p_draft = (probs[:depth] * hit).sum(1) + w = probs.clone() + w[:depth].masked_fill_(hit, 0.0) + # residual: p with the draft zeroed (the rescue term only matters when discarded anyway) + w = w + (w.sum(1, keepdim=True) == 0) * probs + tok = torch.multinomial(w, num_samples=1, generator=generator) + corr = tok if idx is None else idx.gather(1, tok) + u = torch.rand(depth, device=device, generator=generator) + accepted = (u < p_draft).long().cumprod(0).sum() + return dr, corr, accepted + + def step(): + # scoped so every temporary dies before the compiler bracket closes + nonlocal pos + drafts = [] + tok_in, hid_in = nt_buf, h_buf + for k in range(depth): + dk, rk = draft(tok_in, hid_in, pos + k) + if k < depth - 1: + dk = dk.clone() # later replays overwrite the static output + drafts.append(dk) + tok_in, hid_in = dk, rk + ev = self.model.embed_tokens(torch.cat([nt_buf] + drafts, dim=1)).to(dt) + x, _, _ = self.model.forward(None, embeds=ev, attention_mask=None, past_key_values=pkv, decode_buffers=verify_buffers) + # all verify positions in one lm_head GEMV, accept decided GPU-side, one sync + lg = verify_logits(x) + if sampling is None: + toks = lg.argmax(dim=-1) + vals = torch.cat([toks[0]] + [d[0] for d in drafts]).tolist() + t, dr = vals[:depth + 1], vals[depth + 1:] + accepts = 0 + while accepts < depth and t[accepts] == dr[accepts]: + accepts += 1 + next_toks = tuple(toks[:, i:i + 1] for i in range(depth + 1)) + commit = tuple(dr[:accepts]) + (t[accepts],) + else: + dr, corr, accepted = verify_sample(lg, drafts) + vals = torch.cat([dr[:, 0], corr[:, 0], accepted.reshape(1)]).tolist() + dr, cv, accepts = vals[:depth], vals[depth:2 * depth + 1], vals[-1] + next_toks = tuple(corr[i:i + 1] for i in range(depth + 1)) + commit = tuple(dr[:accepts]) + (cv[accepts],) + if accepts < depth: + for kv in pkv: + kv.rollback(depth - accepts) + if accepts < depth - 1: + mtp_kv.rollback(depth - 1 - accepts) # mtp entries fed by a rejected draft token + nt_buf.copy_(next_toks[accepts]) + h_buf.copy_(x[:, accepts:accepts + 1, :]) + if penalized: + for d in drafts[:accepts]: + pen_mask.index_fill_(0, d.reshape(-1), True) + pen_mask.index_fill_(0, nt_buf.reshape(-1), True) + pos += accepts + 1 + return accepts, commit + + probe = None if fixed_depth is not None else [0, 0] # steps, accepted drafts + try: + if pinned: + comfy.model_prefetch.pin_modules(pinned, device, dt) + if use_graph and len(ids) < max_length and ids[-1] not in stop_tokens: + draft_capture() + while len(ids) < max_length and ids[-1] not in stop_tokens: + with (comfy.model_prefetch.malloc_graph_scope(device) if compile_allocations else contextlib.nullcontext()): + accepts, commit = step() + commit = list(commit[:max_length - len(ids)]) + stop = next((i for i, t in enumerate(commit) if t in stop_tokens), None) + if stop is not None: + del commit[stop + 1:] + ids.extend(commit) + update_progress(len(commit)) + if probe is not None: + probe[0] += 1 + probe[1] += accepts + if probe[0] == 32: + # deepen once acceptance sustains it and a recapture round can amortize + if (sampling is None and max_length - len(ids) > 512 and 1 + probe[1] / probe[0] >= 2.2 + and 5 * snapshot_bytes < comfy.model_management.get_free_memory(device)): + comfy.model_prefetch.cleanup_prefetch_queues() + drop_draft_graph() + set_depth(5) + if use_graph: + draft_capture() + probe = None + finally: + console.close() + drop_draft_graph() + if pinned: + comfy.model_prefetch.cleanup_prefetched_modules(None, pinned) + return ids + def init_kv_cache(self, batch, max_cache_len, device, execution_dtype): model_config = self.model.config past_key_values = [] + shared = FixedKVBias.shared(max_cache_len, device, execution_dtype) for i in range(model_config.num_hidden_layers): if model_config.layer_types[i] == "linear_attention": recurrent_state = torch.zeros( @@ -709,13 +1014,9 @@ def init_kv_cache(self, batch, max_cache_len, device, execution_dtype): [batch, conv_dim, model_config.conv_kernel_size - 1], device=device, dtype=execution_dtype ) - past_key_values.append((recurrent_state, conv_state, 0)) + past_key_values.append(LinearKV(conv_state, recurrent_state, 0, None, None)) else: - past_key_values.append(( - torch.empty([batch, model_config.num_key_value_heads, max_cache_len, model_config.head_dim], device=device, dtype=execution_dtype), - torch.empty([batch, model_config.num_key_value_heads, max_cache_len, model_config.head_dim], device=device, dtype=execution_dtype), - 0 - )) + past_key_values.append(FixedKVBias.zeros(batch, model_config.num_key_value_heads, max_cache_len, model_config.head_dim, device, execution_dtype, shared)) return past_key_values # Tokenizer and Text Encoder Wrappers @@ -777,19 +1078,28 @@ def tokenize_with_weights(self, text, return_word_ids=False, llama_template=None class Qwen35ClipModel(sd1_clip.SDClipModel): - def __init__(self, device="cpu", layer="hidden", layer_idx=-2, dtype=None, attention_mask=True, model_options={}, model_type="qwen35_2b"): + def __init__(self, device="cpu", layer="hidden", layer_idx=-2, dtype=None, attention_mask=True, model_options={}, model_type="qwen35_2b", mtp=False): class Qwen35_(Qwen35): pass Qwen35_.model_type = model_type - super().__init__(device=device, layer=layer, layer_idx=layer_idx, textmodel_json_config={}, + super().__init__(device=device, layer=layer, layer_idx=layer_idx, textmodel_json_config={"mtp": True} if mtp else {}, dtype=dtype, special_tokens={"pad": 248044}, layer_norm_hidden_state=False, model_class=Qwen35_, enable_attention_masks=attention_mask, return_attention_masks=attention_mask, model_options=model_options) + def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0, mtp=True): + if isinstance(tokens, dict): + tokens = next(iter(tokens.values())) + tokens_only = [[t[0] for t in b] for b in tokens] + embeds, _, _, embeds_info = self.process_tokens(tokens_only, self.execution_device) + position_ids = comfy.text_encoders.qwen_vl.qwen2vl_mrope_position_ids(embeds_info, embeds.shape[1], embeds.device) + return self.transformer.generate(embeds, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, + presence_penalty=presence_penalty, position_ids=position_ids, mtp=mtp) + class Qwen35TEModel(sd1_clip.SD1ClipModel): - def __init__(self, device="cpu", dtype=None, model_options={}, model_type="qwen35_2b"): - clip_model = lambda **kw: Qwen35ClipModel(**kw, model_type=model_type) + def __init__(self, device="cpu", dtype=None, model_options={}, model_type="qwen35_2b", mtp=False): + clip_model = lambda **kw: Qwen35ClipModel(**kw, model_type=model_type, mtp=mtp) super().__init__(device=device, dtype=dtype, name=model_type, clip_model=clip_model, model_options=model_options) @@ -800,7 +1110,7 @@ def __init__(self, embedding_directory=None, tokenizer_data={}): return Qwen35ImageTokenizer_ -def te(dtype_llama=None, llama_quantization_metadata=None, model_type="qwen35_2b"): +def te(dtype_llama=None, llama_quantization_metadata=None, model_type="qwen35_2b", mtp=False): class Qwen35TEModel_(Qwen35TEModel): def __init__(self, device="cpu", dtype=None, model_options={}): if dtype_llama is not None: @@ -808,5 +1118,5 @@ def __init__(self, device="cpu", dtype=None, model_options={}): if llama_quantization_metadata is not None: model_options = model_options.copy() model_options["quantization_metadata"] = llama_quantization_metadata - super().__init__(device=device, dtype=dtype, model_options=model_options, model_type=model_type) + super().__init__(device=device, dtype=dtype, model_options=model_options, model_type=model_type, mtp=mtp) return Qwen35TEModel_ diff --git a/comfy/text_encoders/qwen3vl.py b/comfy/text_encoders/qwen3vl.py index fd7d0d26793..f16b2a7e461 100644 --- a/comfy/text_encoders/qwen3vl.py +++ b/comfy/text_encoders/qwen3vl.py @@ -127,7 +127,7 @@ def __init__(self, device="cpu", layer="hidden", layer_idx=-1, dtype=None, atten model_class=_make_qwen3vl_model(model_type), enable_attention_masks=attention_mask, return_attention_masks=attention_mask, model_options=model_options) - def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0): + def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0, mtp=True): if isinstance(tokens, dict): tokens = next(iter(tokens.values())) tokens_only = [[t[0] for t in b] for b in tokens] diff --git a/comfy/text_encoders/qwen_image21.py b/comfy/text_encoders/qwen_image21.py new file mode 100644 index 00000000000..de0d147d477 --- /dev/null +++ b/comfy/text_encoders/qwen_image21.py @@ -0,0 +1,89 @@ +import numbers + +import torch + +import comfy.text_encoders.qwen3vl +from comfy import sd1_clip + +VISION_BLOCK = "<|vision_start|><|image_pad|><|vision_end|>" +SYSTEM_PROMPT = "<|im_start|>system\nComprehend and analyze the provided prompt.<|im_end|>\n" +T2I_TEMPLATE = SYSTEM_PROMPT + "<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n" + + +class QwenImage21Tokenizer(comfy.text_encoders.qwen3vl.Qwen3VLTokenizer): + def __init__(self, embedding_directory=None, tokenizer_data={}): + super().__init__(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data, model_type="qwen3vl_8b") + self.llama_template = T2I_TEMPLATE + + def tokenize_with_weights(self, text, return_word_ids=False, llama_template=None, images=[], prevent_empty_text=False, thinking=True, keep_vision=False, **kwargs): + image = kwargs.get("image", None) + if image is not None and len(images) == 0: + images = [image[i:i + 1] for i in range(image.shape[0])] + if llama_template is None and len(images) > 0: + refs = " ".join("{}".format(i + 1, VISION_BLOCK) for i in range(len(images))) + llama_template = T2I_TEMPLATE.replace("{}", refs + "{}", 1) + out = super().tokenize_with_weights(text, return_word_ids=return_word_ids, llama_template=llama_template, images=images, prevent_empty_text=prevent_empty_text, thinking=thinking, **kwargs) + out["keep_vision"] = keep_vision + return out + + +class QwenImage21Qwen3VLClipModel(comfy.text_encoders.qwen3vl.Qwen3VLClipModel): + def __init__(self, device="cpu", dtype=None, attention_mask=True, model_options={}): + super().__init__(device=device, dtype=dtype, attention_mask=attention_mask, model_options=model_options, model_type="qwen3vl_8b") + # last layer without the final RMSNorm: transformers 4.57 hidden_states[-1], which Qwen's results are tuned to (5.x norms it) + self.layer_norm_hidden_state = False + self.image_spans = [] + + def process_tokens(self, tokens, device): + embeds, attention_mask, num_tokens, embeds_info = super().process_tokens(tokens, device) + self.image_spans = [(e["index"], e["size"]) for e in embeds_info if e["type"] == "image"] + return embeds, attention_mask, num_tokens, embeds_info + + +class QwenImage21TEModel(sd1_clip.SD1ClipModel): + def __init__(self, device="cpu", dtype=None, model_options={}): + super().__init__(device=device, dtype=dtype, name="qwen3vl_8b", clip_model=QwenImage21Qwen3VLClipModel, model_options=model_options) + + def encode_token_weights(self, token_weight_pairs): + out, pooled, extra = super().encode_token_weights(token_weight_pairs) + tokens = [t[0] for t in token_weight_pairs["qwen3vl_8b"][0]] + image_spans = getattr(self, self.clip).image_spans + + # drop the system turn, everything before the second <|im_start|>; positions shift by each image expanded before it + im_starts, offset, spans = [], 0, iter(image_spans) + for i, t in enumerate(tokens): + if isinstance(t, numbers.Integral): + if t == 151644: + im_starts.append(i + offset) + elif isinstance(t, dict) and t.get("type") == "image": # a textual embedding is a bare tensor and has no span + offset += next(spans, (0, 1))[1] - 1 + keep = torch.ones(out.shape[1], dtype=torch.bool) + keep[:im_starts[1] if len(im_starts) > 1 else 0] = False + + # vision tokens are replaced by reference latents in the DiT: drop them and record where each image goes + # with no latents coming (no vae) they stay, and the image conditions through the text encoder alone + slots = [] + if not token_weight_pairs.get("keep_vision", False): + for start, size in image_spans: + keep[start:start + size] = False + slots.append(int(keep[:start].sum())) + + out = out[:, keep.to(out.device)] + extra["attention_mask"] = extra["attention_mask"][:, keep.to(extra["attention_mask"].device)] + if extra["attention_mask"].sum() == torch.numel(extra["attention_mask"]): + extra.pop("attention_mask") + if len(slots) > 0: + extra["image_slots"] = slots + return out, pooled, extra + + +def te(dtype_llama=None, llama_quantization_metadata=None): + class QwenImage21TEModel_(QwenImage21TEModel): + def __init__(self, device="cpu", dtype=None, model_options={}): + if dtype_llama is not None: + dtype = dtype_llama + if llama_quantization_metadata is not None: + model_options = model_options.copy() + model_options["quantization_metadata"] = llama_quantization_metadata + super().__init__(device=device, dtype=dtype, model_options=model_options) + return QwenImage21TEModel_ diff --git a/comfy/text_encoders/yue2.py b/comfy/text_encoders/yue2.py new file mode 100644 index 00000000000..4df5d8c287e --- /dev/null +++ b/comfy/text_encoders/yue2.py @@ -0,0 +1,280 @@ +"""YuE2 score/semantic generation and acoustic prefix conditioning.""" + +import logging + +import torch +from tokenizers import Tokenizer + +import comfy.model_management +import comfy.model_prefetch +import comfy.ops +import comfy.utils +from comfy.ldm.yue2.model import model_config +from comfy.text_encoders.llama import FixedKV, Llama2_, rope_matrix + + +EOD = 151643 +ABC_START, ABC_END = 151847, 151848 +MUSIC_START, MUSIC_END = 151851, 151852 +CODEC_OFFSET, CODEC_SIZE = 151853, 32768 +CONTEXT = 24576 +FRAMES_PER_SECOND = 25 +INSTRUCTIONS = { + "off": "Generate music with codec tokens from the given conditions.", + "melody": "Generate a melody-only ABC transcription without chord symbols, then generate music with codec tokens from the given conditions.", + "full": "Generate a chord-annotated ABC transcription, then generate music with codec tokens from the given conditions.", +} + + +def distribution(logits, history, step, phase, temperature, top_p, top_k, repetition_penalty, penalty_window, min_tokens, legacy_off=False): + scores = logits.clone() if legacy_off else logits.float().clone() + end = ABC_END if phase == "abc" else MUSIC_END + allowed = torch.full_like(scores, -torch.inf) + if phase == "abc": + allowed[..., :EOD] = 0 + else: + allowed[..., CODEC_OFFSET:CODEC_OFFSET + CODEC_SIZE] = 0 + allowed[..., end] = 0 + scores += allowed + if step < min_tokens: + scores[..., end] = -torch.inf + if repetition_penalty != 1.0 and history: + recent = torch.tensor([history[-penalty_window:]], dtype=torch.long, device=scores.device) + counts = torch.zeros_like(scores) + counts.scatter_add_(-1, recent, torch.ones_like(recent, dtype=scores.dtype)) + penalty = repetition_penalty ** counts + scores = torch.where(scores < 0, scores * penalty, scores / penalty) + if temperature == 0: + return scores + scores /= temperature + threshold = scores.topk(min(top_k, scores.shape[-1])).values[..., -1, None] + scores.masked_fill_(scores < threshold, -torch.inf) + if top_p < 1: + values, indices = scores.sort(descending=True) + probabilities = values.softmax(-1) + removed = probabilities.cumsum(-1) - probabilities > top_p + removed[..., :3 if legacy_off else 1] = False + values.masked_fill_(removed, -torch.inf) + scores = values.scatter(-1, indices, values) + return scores + + +def chunk_ranges(frames, prefix_tokens, context=CONTEXT): + size = (context - prefix_tokens - 3) // 2 + if frames < 1 or size < 1: + raise ValueError("YuE2 needs music tokens and enough context for at least one acoustic frame.") + return [(start, min(start + size, frames)) for start in range(0, frames, size)] + + +class YuE2Tokenizer: + def __init__(self, embedding_directory=None, tokenizer_data={}): + data = tokenizer_data["yue2_tokenizer_json"] + if torch.is_tensor(data): + data = data.numpy().tobytes() + self.tokenizer_json = data + self.tokenizer = Tokenizer.from_str(data.decode("utf-8")) + + def tokenize_with_weights(self, text, return_word_ids=False, **kwargs): + cot = kwargs.get("cot", "full") + prompt = f"{INSTRUCTIONS[cot]}\n[Tags]\n{text}\n[Lyrics]\n{kwargs.get('lyrics', '')}\n" + return { + "prefix": [EOD] + self.tokenizer.encode(prompt).ids + [ABC_START], + "negative": [EOD] + self.tokenizer.encode(INSTRUCTIONS[cot]).ids, + "abc_ids": self.tokenizer.encode(kwargs.get("abc", "")).ids, + "cot": cot, + "seed": kwargs.get("seed", 0), + "max_tokens": kwargs.get("max_tokens", 9000), + "temperature": kwargs.get("temperature", 1.0), + "top_p": kwargs.get("top_p", 0.95), + "top_k": kwargs.get("top_k", 100), + "repetition_penalty": kwargs.get("repetition_penalty", 1.2), + "penalty_window": kwargs.get("penalty_window", 100), + "cfg_scale": kwargs.get("cfg_scale", 1.01 if cot == "off" else 1.0), + } + + def state_dict(self): + return {"yue2_tokenizer_json": torch.frombuffer(bytearray(self.tokenizer_json), dtype=torch.uint8)} + + def decode(self, ids, skip_special_tokens=True): + return self.tokenizer.decode(ids, skip_special_tokens=skip_special_tokens) + + +class YuE2TEModel(torch.nn.Module): + def __init__(self, device="cpu", dtype=None, model_options={}, config=None): + super().__init__() + self.config = model_config(**{"fixed_kv": True, **(config or {})}) + operations = model_options.get("custom_operations", comfy.ops.manual_cast) + quant = model_options.get("quantization_metadata") + if quant is not None and "custom_operations" not in model_options: + operations = comfy.ops.mixed_precision_ops(quant, dtype) + self.model = Llama2_(self.config, device=device, dtype=dtype, ops=operations) + self.model.prefetch_dynamic_vbars = True + self.model.graph_dynamic_vbar_blocks = True + self.dtypes = {dtype} + self.execution_device = device + + def get_dynamic_vram__units(self): + return self.model.get_dynamic_vram__units() + + def set_clip_options(self, options): + self.execution_device = options.get("execution_device", self.execution_device) + + def reset_clip_options(self): + pass + + def load_sd(self, state_dict): + return self.load_state_dict(state_dict, strict=False, assign=getattr(self, "can_assign_sd", False)) + + def memory_estimation_function(self, tokens, device=None): + config = self.config + abc_length = 0 if tokens["cot"] == "off" else len(tokens["abc_ids"]) + length = min(config.max_position_embeddings, len(tokens["prefix"]) + abc_length + tokens["max_tokens"] + 2) + branches = 1 if tokens["cfg_scale"] == 1.0 else 2 + dtype = torch.bfloat16 if comfy.model_management.should_use_bf16(device) else torch.float32 + cache = branches * 2 * config.num_hidden_layers * config.num_key_value_heads * config.head_dim * length + prefill = branches * (length * length + length * (config.intermediate_size * 3 + config.hidden_size * 8)) + return (cache + prefill) * comfy.model_management.dtype_size(dtype) + + def _prefill(self, prefixes, capacity, dtype): + length = max(map(len, prefixes)) + ids = torch.tensor([[0] * (length - len(prefix)) + prefix for prefix in prefixes], device=self.execution_device, dtype=torch.long) + mask = positions = None + if any(len(prefix) != length for prefix in prefixes): + mask = torch.ones((len(prefixes), capacity), device=self.execution_device, dtype=torch.long) + for index, prefix in enumerate(prefixes): + mask[index, :length - len(prefix)] = 0 + positions = mask[:, :length].cumsum(-1).sub_(1).clamp_min_(0) + cache = self.model.init_kv_cache(len(prefixes), capacity, self.execution_device, dtype) + output = self.model(ids, attention_mask=mask[:, :length] if mask is not None else None, + position_ids=positions, past_key_values=cache, dtype=dtype) + return self.model.lm_head(output[0][:, -1]), output[2], mask + + def _generate(self, prefix, seed, max_tokens, phase, dtype, negative=None, cfg_scale=1.0, legacy_off=False, **sampling): + if max(len(prefix), len(negative or [])) + max_tokens > self.config.max_position_embeddings: + raise ValueError("YuE2 prompt plus generation budget exceeds the model context; reduce the token budget or prompt length.") + device = self.execution_device + rng_device = device if torch.device(device).type != "mps" else "cpu" + generator = torch.Generator(device=rng_device).manual_seed(seed) + prefixes = [prefix] if cfg_scale == 1.0 else [prefix, negative] + prefix_length = max(map(len, prefixes)) + logits, cache, mask = self._prefill(prefixes, prefix_length + max_tokens, dtype) + fixed_kv = isinstance(cache[0], FixedKV) + decode_tokens = torch.empty((len(prefixes), 1), device=device, dtype=torch.long) + positions = torch.tensor([[len(p)] for p in prefixes], device=device, dtype=torch.long) + # Decoder inputs and rotary tensors must keep their addresses across graph replays. + decode_buffers = None + if fixed_kv: + decode_buffers = (torch.empty((len(prefixes), 1, self.config.hidden_size), device=device, dtype=dtype), + rope_matrix(self.model.compute_freqs_cis(positions, device))) + history = [] + end = ABC_END if phase == "abc" else MUSIC_END + progress = comfy.utils.ProgressBar(max_tokens) + try: + for step in comfy.utils.model_trange(max_tokens, desc="YuE2 ABC sampling" if phase == "abc" else "YuE2 music sampling", unit="token"): + comfy.model_management.throw_exception_if_processing_interrupted() + guided = logits if cfg_scale == 1.0 else logits[1:] + cfg_scale * (logits[:1] - logits[1:]) + scores = distribution(guided, history, step, phase, legacy_off=legacy_off, **sampling) + if sampling["temperature"] == 0: + next_id = scores.argmax(-1, keepdim=True) + else: + probabilities = scores.softmax(-1).to(rng_device) + next_id = torch.multinomial(probabilities, 1, generator=generator).to(device) + decode_tokens.copy_(next_id) + token = next_id.item() + progress.update_absolute(step + 1) + if token == end: + return history, False + history.append(token) + if step + 1 < max_tokens: + # Keep decode allocations stable; sampling has a changing history window. + if fixed_kv: + comfy.model_prefetch.malloc_graph_begin(device) + output = self.model(decode_tokens, past_key_values=cache, dtype=dtype, position_ids=positions, + attention_mask=mask[:, :prefix_length + step + 1] if mask is not None and not fixed_kv else None, + decode_buffers=decode_buffers) + logits.copy_(self.model.lm_head(output[0][:, -1])) + cache = output[2] + del output + if fixed_kv: + comfy.model_prefetch.malloc_graph_end() + positions.add_(1) + finally: + # Each phase has different KV buffers and may change the CFG batch size. + comfy.model_prefetch.cleanup_prefetch_queues() + logging.warning("YuE2 %s reached its token budget before the end token.", phase) + return history, True + + def _acoustic_conditioning(self, prefix, tokens, dtype): + config = self.config + ranges = chunk_ranges(len(tokens), len(prefix), config.max_position_embeddings) + total = sum(len(prefix) + end - start + 1 for start, end in ranges) + # A normal [batch, tokens, features] conditioning tensor, with each layer's KV in features. + output = torch.empty((1, total, config.num_hidden_layers, 2, config.num_key_value_heads, config.head_dim), + device=comfy.model_management.intermediate_device(), dtype=dtype) + chunks = [] + offset = 0 + for start, end in ranges: + comfy.model_management.throw_exception_if_processing_interrupted() + ids = prefix + tokens[start:end] + [MUSIC_END] + _, cache, _ = self._prefill([ids], len(ids), dtype) + for index, kv in enumerate(cache): + if isinstance(kv, FixedKV): + key, value = kv.key, kv.value + else: + key, value, _ = kv + key, value = key.transpose(1, 2), value.transpose(1, 2) + output[:, offset:offset + len(ids), index, 0].copy_(key) + output[:, offset:offset + len(ids), index, 1].copy_(value) + chunks.append((start, end, offset, offset + len(ids))) + offset += len(ids) + del cache + return output.flatten(2), tuple(chunks) + + def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, repetition_penalty=1.0, seed=None, **kwargs): + dtype = torch.bfloat16 if comfy.model_management.should_use_bf16(self.execution_device) else torch.float32 + ids, _ = self._generate( + tokens["prefix"], tokens["seed"] if seed is None else seed, max_length, "abc", dtype, + temperature=temperature if do_sample else 0, top_p=top_p, top_k=top_k, + repetition_penalty=repetition_penalty, penalty_window=tokens.get("penalty_window", 100), min_tokens=min(32, max_length), + ) + return ids + + def encode_token_weights(self, tokens): + device = self.execution_device + dtype = torch.bfloat16 if comfy.model_management.should_use_bf16(device) else torch.float32 + prefix = tokens["prefix"] + abc_ids = tokens["abc_ids"] + cot = tokens["cot"] + if cot == "off": + abc_ids = [] + prefix = prefix + abc_ids + [ABC_END, MUSIC_START] + negative = tokens["negative"] + ([MUSIC_START] if cot == "off" else [ABC_START] + abc_ids + [ABC_END, MUSIC_START]) + context = self.config.max_position_embeddings + max_tokens = min(tokens["max_tokens"], context - max(len(prefix), len(negative))) + # One acoustic frame needs two positions plus three boundary tokens. + if max_tokens < 1 or len(prefix) + 5 > context: + raise ValueError("YuE2 prompt leaves no room for music; shorten the style, lyrics, or ABC.") + if max_tokens < tokens["max_tokens"]: + logging.info("YuE2 music budget reduced to %d tokens (%.2f seconds) to fit the prompt.", max_tokens, max_tokens / FRAMES_PER_SECOND) + semantic, semantic_truncated = self._generate( + prefix, tokens["seed"], max_tokens, "semantic", dtype, + negative=negative, cfg_scale=tokens["cfg_scale"], legacy_off=cot == "off", + temperature=tokens["temperature"], top_p=tokens["top_p"], top_k=tokens["top_k"], + repetition_penalty=tokens["repetition_penalty"], penalty_window=50, + min_tokens=min(200, max_tokens), + ) + conditioning, chunks = self._acoustic_conditioning(prefix, semantic, dtype) + return conditioning, None, { + "yue2_chunks": chunks, "yue2_abc_ids": abc_ids, "yue2_frames": len(semantic), + "yue2_truncated": semantic_truncated, + } + + +def te(dtype_llama=None, llama_quantization_metadata=None): + class YuE2TEModel_(YuE2TEModel): + def __init__(self, device="cpu", dtype=None, model_options={}): + dtype = comfy.model_management.pick_weight_dtype(dtype_llama, dtype, device) + if llama_quantization_metadata is not None: + model_options = {**model_options, "quantization_metadata": llama_quantization_metadata} + super().__init__(device=device, dtype=dtype, model_options=model_options) + return YuE2TEModel_ diff --git a/comfy/utils.py b/comfy/utils.py index b31b70daad9..573a9e4553d 100644 --- a/comfy/utils.py +++ b/comfy/utils.py @@ -23,6 +23,7 @@ import ctypes import os import comfy.memory_management +import comfy.storage import safetensors.torch import numpy as np from PIL import Image @@ -200,6 +201,7 @@ def load_torch_file(ckpt, safe_load=False, device=None, return_metadata=False): sd = pl_sd else: sd = pl_sd + comfy.storage.annotate_state_dict(sd, ckpt) return (sd, metadata) if return_metadata else sd def save_torch_file(sd, ckpt, metadata=None): diff --git a/comfy_api/feature_flags.py b/comfy_api/feature_flags.py index 9fbef82609a..00f6bb65f5b 100644 --- a/comfy_api/feature_flags.py +++ b/comfy_api/feature_flags.py @@ -109,6 +109,7 @@ def _parse_cli_feature_flags() -> dict[str, Any]: "max_upload_size": args.max_upload_size * 1024 * 1024, # Convert MB to bytes "extension": {"manager": {"supports_v4": True}}, "node_replacements": True, + # Mirrors the constructed AssetManager; no degradation path exists, so this always agrees with it. "assets": args.enable_assets, } diff --git a/comfy_api/latest/__init__.py b/comfy_api/latest/__init__.py index 294ad425e1d..73ddcb7e99b 100644 --- a/comfy_api/latest/__init__.py +++ b/comfy_api/latest/__init__.py @@ -4,7 +4,7 @@ from comfy_api.internal.singleton import ProxiedSingleton from comfy_api.internal.async_to_sync import create_sync_class from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput -from ._input_impl import VideoFromFile, VideoFromComponents +from ._input_impl import VideoFromFile, VideoFromComponents, VideoFromList from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL, SPLAT, File3D from . import _io_public as io from . import _ui_public as ui @@ -136,6 +136,7 @@ class Input: class InputImpl: VideoFromFile = VideoFromFile VideoFromComponents = VideoFromComponents + VideoFromList = VideoFromList class Types: VideoCodec = VideoCodec diff --git a/comfy_api/latest/_input_impl/__init__.py b/comfy_api/latest/_input_impl/__init__.py index 02901b8b933..9f4f5634408 100644 --- a/comfy_api/latest/_input_impl/__init__.py +++ b/comfy_api/latest/_input_impl/__init__.py @@ -1,7 +1,8 @@ -from .video_types import VideoFromFile, VideoFromComponents +from .video_types import VideoFromFile, VideoFromComponents, VideoFromList __all__ = [ # Implementations "VideoFromFile", "VideoFromComponents", + "VideoFromList", ] diff --git a/comfy_api/latest/_input_impl/video_types.py b/comfy_api/latest/_input_impl/video_types.py index d02c61ac4c9..d32b48260f1 100644 --- a/comfy_api/latest/_input_impl/video_types.py +++ b/comfy_api/latest/_input_impl/video_types.py @@ -394,7 +394,9 @@ def get_frame_count(self) -> int: duration_from_start = min(raw_duration, -self.__start_time) else: duration_from_start = raw_duration - self.__start_time - duration_seconds = min(self.__duration, duration_from_start) + duration_seconds = ( + min(self.__duration, duration_from_start) if self.__duration else duration_from_start + ) estimated_frames = int(round(duration_seconds * float(video_stream.average_rate))) if estimated_frames > 0: return estimated_frames @@ -403,7 +405,7 @@ def get_frame_count(self) -> int: start_time, duration = self.get_active_trim_window() frame_count = 1 start_pts = int(start_time / video_stream.time_base) - end_pts = int((start_time + duration) / video_stream.time_base) + end_pts = int((start_time + duration) / video_stream.time_base) if duration else None container.seek(start_pts, stream=video_stream) frame_iterator = ( container.decode(video_stream) @@ -416,7 +418,7 @@ def get_frame_count(self) -> int: else: raise ValueError(f"Could not determine frame count for file '{self.__file}'\nNo frames exist for start_time {self.__start_time}") for frame in frame_iterator: - if frame.pts >= end_pts: + if end_pts is not None and frame.pts >= end_pts: break frame_count += 1 return frame_count @@ -709,6 +711,10 @@ def _save_transcoded( crf: float | None = None, color_space: str | None = None, preset: str | None = None, + preserve_source_timestamps: bool = True, + frame_rate: Fraction | None = None, + audio_sample_rate: int | None = None, + audio_layout: str | None = None, ): """Re-encode one frame at a time; peak memory does not scale with video length.""" open_kwargs, output_format, output_codec = video_output_config(path, format, codec) @@ -730,7 +736,7 @@ def _save_transcoded( source_color_space = video_stream_color_space(video_stream) preserve_source_color = source_color_space is not None pix_fmt = "yuv420p10le" if bit_depth >= 10 else "yuv420p" - rate = Fraction(video_stream.average_rate) if video_stream.average_rate else Fraction(1) + rate = frame_rate or (Fraction(video_stream.average_rate) if video_stream.average_rate else Fraction(1)) resampler = None sample_rate = 0 @@ -748,10 +754,9 @@ def _save_transcoded( logging.warning("Audio stream parameters could not be determined; ignoring audio.") audio_stream = None if audio_stream is not None: - if output_format == VideoContainer.WEBM: - sample_rate = 48000 + sample_rate = 48000 if output_format == VideoContainer.WEBM else audio_sample_rate or sample_rate audio_time_base = Fraction(1, sample_rate) - layout = {1: "mono", 2: "stereo", 6: "5.1"}.get(channels, "stereo") + layout = audio_layout or {1: "mono", 2: "stereo", 6: "5.1"}.get(channels, "stereo") resampler = av.audio.resampler.AudioResampler(format="fltp", layout=layout, rate=sample_rate) if duration: duration_cap = math.ceil(duration * sample_rate) @@ -879,9 +884,6 @@ def drain_audio(final=False): # Add metadata before writing any streams write_output_metadata(container, output, metadata) out_video = output.add_stream(VIDEO_ENCODERS[output_codec], rate=rate) - # no B-frames: reordering makes mp4 sample durations follow decode order, - # so irregular-VFR spans and trim windows land wrong - out_video.codec_context.max_b_frames = 0 out_video.width = out_width out_video.height = out_height out_video.pix_fmt = pix_fmt @@ -890,8 +892,10 @@ def drain_audio(final=False): copy_color_properties(video_stream, out_video.codec_context) elif color_space is not None: set_video_color_properties(out_video.codec_context, color_space) - # source pts pass through (rebased to 0), so variable frame rate survives - out_video.codec_context.time_base = video_stream.time_base + # Preserve source timing; B-frame reordering shortens irregular-VFR spans. + if preserve_source_timestamps: + out_video.codec_context.max_b_frames = 0 + out_video.codec_context.time_base = video_stream.time_base if audio_stream is not None: audio_codec = "libopus" if output_format == VideoContainer.WEBM else "aac" out_audio = output.add_stream(audio_codec, rate=sample_rate, layout=layout) @@ -1210,3 +1214,360 @@ def as_trimmed( return None #TODO Consider tracking duration and trimming at time of save? return VideoFromFile(self.get_stream_source(), start_time=start_time, duration=duration) + + +class VideoFromList(VideoInput): + def __init__( + self, + videos: list[VideoInput], + complete_audio: AudioInput | None = None, + codec: VideoCodec = VideoCodec.AUTO, + ): + self.videos = [] + inherited_audio = None + for video in videos: + if isinstance(video, VideoFromList): + if video.complete_audio is not None: + inherited_audio = video.complete_audio + self.videos.extend(video.videos) + elif not isinstance(video, VideoFromFile): + buffer = io.BytesIO() + video.save_to(buffer, format=VideoContainer.MKV, codec=codec) + self.videos.append(VideoFromFile(buffer)) + else: + self.videos.append(video) + if not self.videos: + raise ValueError("Concatenate Video requires at least one input") + self.complete_audio = complete_audio if complete_audio is not None else inherited_audio + self.__buffer = None + + def get_components(self) -> VideoComponents: + components = [video.get_components() for video in self.videos] + frame_rate = components[0].frame_rate + if any(component.frame_rate != frame_rate for component in components[1:]): + raise ValueError("Cannot materialize accumulated videos with different frame rates") + try: + images = torch.cat([component.images for component in components]) + except RuntimeError as error: + raise ValueError("Accumulated videos have incompatible frame dimensions") from error + + if self.complete_audio is not None: + sample_rate = int(self.complete_audio["sample_rate"]) + audio = AudioInput({ + "waveform": self.complete_audio["waveform"][..., :round(len(images) / frame_rate * sample_rate)], + "sample_rate": sample_rate, + }) + elif any(component.audio is not None for component in components): + if any(component.audio is None for component in components): + raise ValueError("Cannot materialize accumulated videos with missing audio segments") + sample_rate = int(components[0].audio["sample_rate"]) + channels = components[0].audio["waveform"].shape[1] + if any( + int(component.audio["sample_rate"]) != sample_rate + or component.audio["waveform"].shape[1] != channels + for component in components[1:] + ): + raise ValueError("Cannot materialize accumulated videos with incompatible audio") + audio = AudioInput({ + "waveform": torch.cat( + [component.audio["waveform"] for component in components], dim=-1 + )[..., :round(len(images) / frame_rate * sample_rate)], + "sample_rate": sample_rate, + }) + else: + audio = None + + alphas = [component.alpha for component in components] + alpha = torch.cat(alphas) if all(value is not None for value in alphas) else None + return VideoComponents( + images=images, + audio=audio, + frame_rate=frame_rate, + metadata=components[0].metadata, + alpha=alpha, + ) + + def get_dimensions(self): + dimensions = [video.get_dimensions() for video in self.videos] + mismatches = [ + f"chunk {index} is {width}x{height}" + for index, (width, height) in enumerate(dimensions[1:], 1) + if (width, height) != dimensions[0] + ] + if mismatches: + width, height = dimensions[0] + raise ValueError( + f"Accumulated videos have incompatible frame dimensions: chunk 0 is {width}x{height}; " + + "; ".join(mismatches) + ) + return dimensions[0] + + def get_bit_depth(self): + return self.videos[0].get_bit_depth() + + def get_color_space(self): + return self.videos[0].get_color_space() + + def get_duration(self): + return sum(video.get_duration() for video in self.videos) + + def get_frame_count(self): + return sum(video.get_frame_count() for video in self.videos) + + def get_frame_rate(self): + return self.videos[0].get_frame_rate() + + def save_to(self, path, format=VideoContainer.AUTO, codec=VideoCodec.AUTO, metadata=None, + bit_depth=None, crf=None, color_space=None, preset=None): + open_kwargs, output_format, output_codec = video_output_config(path, format, codec) + requested_codec = VideoCodec(codec) + output = None + output_video = None + output_audio = None + audio_resampler = None + audio_pts = 0 + signature = None + video_offset = Fraction() + + def mux_audio_frames(frames, end_pts): + nonlocal audio_pts + for frame in frames: + remaining = end_pts - audio_pts + if remaining <= 0: + return + if frame.samples > remaining: + frame = av.AudioFrame.from_ndarray( + frame.to_ndarray()[..., :remaining], format="fltp", layout=audio_layout + ) + frame.sample_rate = output_audio.rate + frame.pts = audio_pts + frame.time_base = Fraction(1, frame.sample_rate) + audio_pts += frame.samples + output.mux(output_audio.encode(frame)) + + def write_audio_frames(frames, end_pts): + for frame in frames: + mux_audio_frames(audio_resampler.resample(frame), end_pts) + + def video_packets(container, stream): + pending = [] + for packet in container.demux(stream): + if packet.dts is None: + if packet.pts is not None: + pending.append(packet) + continue + if pending: + dts = packet.dts + for leading in reversed(pending): + dts -= leading.duration or packet.duration or 1 + leading.dts = dts + yield from pending + pending.clear() + yield packet + if pending: + if all(first.pts <= second.pts for first, second in zip(pending, pending[1:])): + for packet in pending: + packet.dts = packet.pts + else: + dts = 0 + for packet in reversed(pending): + dts -= packet.duration or 1 + packet.dts = dts + yield from pending + + def encoded_signature(container): + video_stream = container.streams.video[0] + audio_stream = None if self.complete_audio is not None else last_decodable_audio_stream(container) + return ( + video_stream.codec.canonical_name, + video_stream.codec_context.extradata, + video_stream.width, + video_stream.height, + video_stream_bit_depth(video_stream), + video_stream_color_space(video_stream), + audio_stream.sample_rate if audio_stream else None, + audio_stream.layout.name if audio_stream else None, + ) + + source_signatures = [] + shared_encode = crf is not None or color_space is not None + for video in self.videos: + with av.open(video.get_stream_source()) as container: + current_signature = encoded_signature(container) + source_signatures.append(current_signature) + video_stream = container.streams.video[0] + shared_encode |= ( + (requested_codec != VideoCodec.AUTO and current_signature[0] != requested_codec.value) + or (bit_depth is not None and current_signature[4] != bit_depth) + or (output_format == VideoContainer.WEBM and video_stream.codec.canonical_name not in WEBM_STREAM_CODECS["video"]) + or video.get_active_trim_window() != (0.0, 0.0) + or video.get_dimensions() != (video_stream.width, video_stream.height) + ) + shared_encode |= any( + current_signature != source_signatures[0] + for current_signature in source_signatures[1:] + ) + target_codec = requested_codec if requested_codec != VideoCodec.AUTO else output_codec + target_bit_depth = bit_depth if bit_depth is not None else source_signatures[0][4] + target_color_space = color_space if color_space is not None else source_signatures[0][5] + target_frame_rate = Fraction(self.videos[0].get_frame_rate()) + target_audio_rate = source_signatures[0][6] + target_audio_layout = source_signatures[0][7] + + try: + for index, video in enumerate(self.videos): + container = av.open(video.get_stream_source()) + current_signature = encoded_signature(container) + if shared_encode: + scratch = io.BytesIO() + try: + video._save_transcoded( + container, + scratch, + format=output_format, + codec=target_codec, + metadata=None, + bit_depth=target_bit_depth, + crf=crf, + color_space=target_color_space, + preset=preset, + preserve_source_timestamps=False, + frame_rate=target_frame_rate, + audio_sample_rate=target_audio_rate, + audio_layout=target_audio_layout, + ) + finally: + container.close() + scratch.seek(0) + container = av.open(scratch) + current_signature = encoded_signature(container) + if signature is not None and current_signature != signature: + container.close() + fields = ("codec", "extradata", "width", "height", "bit depth", "color space", "audio rate", "audio layout") + differences = "; ".join( + f"{field}: expected {expected!r}, got {actual!r}" + for field, expected, actual in zip(fields, signature, current_signature) + if expected != actual + ) + raise ValueError(f"Video chunk {index} could not be encoded compatibly: {differences}") + with container: + video_stream = container.streams.video[0] + audio_stream = None if self.complete_audio is not None else last_decodable_audio_stream(container) + if index == 0: + signature = current_signature + output = av.open(path, **open_kwargs) + write_output_metadata(container, output, metadata) + output_video = output.add_stream_from_template(video_stream, opaque=True) + if self.complete_audio is not None: + source_rate = int(self.complete_audio["sample_rate"]) + channels = self.complete_audio["waveform"].shape[1] + layout = {1: "mono", 2: "stereo", 6: "5.1"}.get(channels, "stereo") + elif audio_stream is not None: + source_rate = audio_stream.sample_rate + layout = audio_stream.layout.name + else: + source_rate = layout = None + if layout is not None: + target_rate = 48000 if output_format == VideoContainer.WEBM else source_rate + audio_layout = layout + output_audio = output.add_stream( + "libopus" if output_format == VideoContainer.WEBM else "aac", + rate=target_rate, + layout=layout, + ) + audio_resampler = av.AudioResampler(format="fltp", layout=layout, rate=target_rate) + hevc_filter = isobmff_hevc_filter(output, video_stream, output_video) + video_end = video_offset + origin = None + for packet in video_packets(container, video_stream): + time_base = packet.time_base + if origin is None: + origin = Fraction(packet.pts if packet.pts is not None else packet.dts) * time_base + if packet.pts is not None: + packet.pts = int((Fraction(packet.pts) * time_base - origin + video_offset) / time_base) + video_end = max(video_end, Fraction(packet.pts + (packet.duration or 0)) * time_base) + packet.dts = int((Fraction(packet.dts) * time_base - origin + video_offset) / time_base) + packet.stream = output_video + for packet in filter_hevc_packet(hevc_filter, packet) if hevc_filter else (packet,): + packet.stream = output_video + output.mux(packet) + video_offset = video_end + if audio_stream is not None: + container.seek(0) + write_audio_frames( + itertools.chain.from_iterable(packet.decode() for packet in container.demux(audio_stream)), + round(float(video_offset) * output_audio.rate), + ) + + if output_audio is not None: + if self.complete_audio is not None: + source_rate = int(self.complete_audio["sample_rate"]) + waveform = self.complete_audio["waveform"][0, :, : round(source_rate * float(video_offset))] + frame = av.AudioFrame.from_ndarray( + waveform.float().cpu().contiguous().numpy(), format="fltp", layout=audio_layout + ) + frame.sample_rate = source_rate + write_audio_frames((frame,), round(float(video_offset) * output_audio.rate)) + mux_audio_frames(audio_resampler.resample(None), round(float(video_offset) * output_audio.rate)) + output.mux(output_audio.encode(None)) + except BaseException: + if output is not None: + output.close() + if isinstance(path, (str, os.PathLike)) and os.path.exists(path): + os.remove(path) + raise + else: + if output is not None: + output.close() + + def get_stream_source(self): + if self.__buffer is None: + self.__buffer = io.BytesIO() + self.save_to(self.__buffer, format=VideoContainer.MP4) + self.__buffer.seek(0) + return self.__buffer + + def as_trimmed(self, start_time=None, duration=None, strict_duration=False): + total_duration = self.get_duration() + start_time = float(start_time or 0) + if start_time < 0: + start_time = max(total_duration + start_time, 0) + available = total_duration - start_time + if available < 0 or duration is not None and duration < 0: + return None + if strict_duration and duration and duration > available: + return None + duration = min(float(duration), available) if duration else available + + selected = [] + offset = start_time + remaining = duration + for video in self.videos: + video_duration = video.get_duration() + if offset >= video_duration: + offset -= video_duration + continue + selected.append(video.as_trimmed(offset, min(video_duration - offset, remaining), False)) + remaining -= video_duration - offset + if remaining <= 0: + break + offset = 0 + if not selected: + return None + + audio = self.complete_audio + if audio is not None: + sample_rate = int(audio["sample_rate"]) + start_sample = round(start_time * sample_rate) + end_sample = start_sample + round(duration * sample_rate) + audio = AudioInput({ + "waveform": audio["waveform"][..., start_sample:end_sample], + "sample_rate": sample_rate, + }) + return VideoFromList(selected, audio) + + def as_cropped(self, x=0, y=0, width=0, height=0): + return VideoFromList( + [video.as_cropped(x, y, width, height) for video in self.videos], + self.complete_audio, + ) diff --git a/comfy_api/latest/_io.py b/comfy_api/latest/_io.py index 4c062d8aabc..cdaaba99853 100644 --- a/comfy_api/latest/_io.py +++ b/comfy_api/latest/_io.py @@ -1547,7 +1547,7 @@ class HiddenHolder: def __init__(self, unique_id: str, prompt: Any, extra_pnginfo: Any, dynprompt: Any, auth_token_comfy_org: str, api_key_comfy_org: str, - comfy_usage_source: str = None, **kwargs): + comfy_usage_source: str = None, execution_list: Any = None, **kwargs): self.unique_id = unique_id """UNIQUE_ID is the unique identifier of the node, and matches the id property of the node on the client side. It is commonly used in client-server communications (see messages).""" self.prompt = prompt @@ -1562,6 +1562,8 @@ def __init__(self, unique_id: str, prompt: Any, """API_KEY_COMFY_ORG is an API Key generated by ComfyOrg that allows skipping signing into a ComfyOrg account on frontend.""" self.comfy_usage_source = comfy_usage_source """COMFY_USAGE_SOURCE identifies the client that submitted the prompt (e.g. comfyui-frontend, comfy-cli, comfyui-mcp); forwarded to API nodes' upstream requests via the Comfy-Usage-Source header.""" + self.execution_list = execution_list + """EXECUTION_LIST is the active graph scheduler.""" def __getattr__(self, key: str): '''If hidden variable not found, return None.''' @@ -1579,6 +1581,7 @@ def from_dict(cls, d: dict | None): auth_token_comfy_org=d.get(Hidden.auth_token_comfy_org, None), api_key_comfy_org=d.get(Hidden.api_key_comfy_org, None), comfy_usage_source=d.get(Hidden.comfy_usage_source, None), + execution_list=d.get(Hidden.execution_list, None), ) @classmethod @@ -1603,6 +1606,8 @@ class Hidden(str, Enum): """API_KEY_COMFY_ORG is an API Key generated by ComfyOrg that allows skipping signing into a ComfyOrg account on frontend.""" comfy_usage_source = "COMFY_USAGE_SOURCE" """COMFY_USAGE_SOURCE identifies the client that submitted the prompt (e.g. comfyui-frontend, comfy-cli, comfyui-mcp); forwarded to API nodes' upstream requests via the Comfy-Usage-Source header.""" + execution_list = "EXECUTION_LIST" + """Custom node Developers and Agents: This attribute is core-internal use only and will be removed in a near-future ComfyUI release. DO NOT USE""" @dataclass @@ -1762,6 +1767,8 @@ class Schema: Use this for nodes with interactive/operable UI regions that produce intermediate outputs (e.g., Image Crop, Painter) rather than final outputs (e.g., Save Image). """ + loop_boundary: Literal["start", "end"] | None = None + """Identifies this node as the start or end of a loop for prompt validation.""" def validate(self): '''Validate the schema: diff --git a/comfy_api/latest/_ui.py b/comfy_api/latest/_ui.py index ebee0114474..06e72507059 100644 --- a/comfy_api/latest/_ui.py +++ b/comfy_api/latest/_ui.py @@ -457,17 +457,28 @@ def as_dict(self): class PreviewUI3DAdvanced(_UIOutput): - def __init__(self, model_file, camera_info, model_3d_info, folder_type: FolderType | None = None): + def __init__( + self, + model_file, + camera_info, + model_3d_info, + folder_type: FolderType | None = None, + saved_result: SavedResult | None = None, + ): self.model_file = model_file self.camera_info = camera_info self.model_3d_info = model_3d_info self.folder_type = folder_type + self.saved_result = saved_result def as_dict(self): model_file = self.model_file if self.folder_type is not None: model_file = f"{model_file} [{FolderType(self.folder_type).value}]" - return {"result": [model_file, self.camera_info, self.model_3d_info]} + data = {"result": [model_file, self.camera_info, self.model_3d_info]} + if self.saved_result is not None: + data["3d"] = [self.saved_result] + return data class PreviewText(_UIOutput): diff --git a/comfy_api_nodes/apis/bfl.py b/comfy_api_nodes/apis/bfl.py index 0e33f2f5ec3..abc5d043de7 100644 --- a/comfy_api_nodes/apis/bfl.py +++ b/comfy_api_nodes/apis/bfl.py @@ -176,3 +176,11 @@ class BFLFluxVideoUpscaleRequest(BaseModel): creativity: int = Field(1, description="0 preserves the source precisely, 1 enhances detail.") prompt: str | None = Field(None) safety_tolerance: int = Field(2, ge=0, le=4) + + +class BFLFluxVideoEditRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + video: str = Field(..., description="MP4 (URL or base64), at most 15 seconds and 50 MiB.") + prompt: str = Field(..., description="Edit instruction, 1 to 4096 characters once trimmed.") + safety_tolerance: int = Field(4, ge=0, le=4) diff --git a/comfy_api_nodes/apis/bria.py b/comfy_api_nodes/apis/bria.py index f55de74bc24..a48eea56760 100644 --- a/comfy_api_nodes/apis/bria.py +++ b/comfy_api_nodes/apis/bria.py @@ -217,3 +217,148 @@ class BriaVideoReplaceBackgroundRequest(BaseModel): output_container_and_codec: str = Field(...) preserve_audio: bool = Field(True) seed: int = Field(...) + + +class BriaEraseByTextRequest(BaseModel): + image: str = Field(...) + object_name: str = Field(..., description="Name of the object to remove, for example 'the lamp'.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaReplaceBackgroundRequest(BaseModel): + image: str = Field(...) + prompt: str | None = Field( + None, + description="Description of the new background. Mutually exclusive with ref_images; " + "a hex color code produces a solid color background.", + ) + ref_images: list[str] | None = Field( + None, + description="Reference images guiding the new background. Mutually exclusive with prompt.", + ) + mode: str | None = Field( + None, description="'base', 'high_control' or 'fast'. Applies to the prompt path only." + ) + refine_prompt: bool | None = Field( + None, description="When true, the prompt is rewritten for better results. Prompt path only." + ) + enhance_ref_images: bool | None = Field( + None, description="When true, the reference images get extra processing. Reference path only." + ) + original_quality: bool = Field( + False, + description="When true, the output keeps the input's pixel size; otherwise it is scaled to 1MP.", + ) + seed: int = Field(...) + prompt_content_moderation: bool = Field(False, description="If true, returns 422 on prompt moderation failure.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaEraseForegroundRequest(BaseModel): + image: str = Field(...) + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaAddObjectRequest(BaseModel): + image: str = Field(...) + instruction: str = Field( + ..., description="What to add and where, for example 'Place a red vase with flowers on the table'." + ) + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaReplaceObjectRequest(BaseModel): + image: str = Field(...) + instruction: str = Field( + ..., description="What to replace with what, for example 'Replace the red apple with a green pear'." + ) + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaRelightRequest(BaseModel): + image: str = Field(...) + light_type: str = Field(..., description="Lighting atmosphere to apply.") + light_direction: str = Field(..., description="Where the light comes from: front, side, bottom or top-down.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaRestoreRequest(BaseModel): + image: str = Field(...) + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaReseasonRequest(BaseModel): + image: str = Field(...) + season: str = Field(..., description="Season to apply: spring, summer, autumn or winter.") + visual_input_content_moderation: bool = Field( + False, description="If true, returns 422 on input image moderation failure." + ) + visual_output_content_moderation: bool = Field( + False, description="If true, returns 422 on visual output moderation failure." + ) + + +class BriaVideoEraseRequest(BaseModel): + video: str = Field(..., description="Publicly accessible URL of the input video.") + mask: str = Field( + ..., + description="Publicly accessible URL of a mask video: white pixels are erased, black pixels are kept. " + "Must have the same dimensions and frame count as the input video.", + ) + preserve_audio: bool = Field(True) + output_container_and_codec: str = Field(...) + + +class BriaFiboEditResult(BaseModel): + image_url: str = Field(...) + structured_prompt: str | None = Field(None) + + +class BriaFiboEditResponse(BaseModel): + status: str = Field(...) + result: BriaFiboEditResult | None = Field(None) + + +class BriaReplaceBackgroundResult(BaseModel): + image_url: str = Field(...) + refined_prompt: str | None = Field(None) + + +class BriaReplaceBackgroundResponse(BaseModel): + status: str = Field(...) + result: BriaReplaceBackgroundResult | None = Field(None) diff --git a/comfy_api_nodes/apis/gemini.py b/comfy_api_nodes/apis/gemini.py index 4cc7dd6f98e..d2e16aaa2d0 100644 --- a/comfy_api_nodes/apis/gemini.py +++ b/comfy_api_nodes/apis/gemini.py @@ -68,6 +68,7 @@ class GeminiPart(BaseModel): fileData: GeminiFileData | None = Field(None) text: str | None = Field(None) thought: bool | None = Field(None) + mediaProcessing: str | None = Field(None) class GeminiTextPart(BaseModel): diff --git a/comfy_api_nodes/apis/meshy.py b/comfy_api_nodes/apis/meshy.py index fe9dcb55dbc..4944cabcfb9 100644 --- a/comfy_api_nodes/apis/meshy.py +++ b/comfy_api_nodes/apis/meshy.py @@ -36,7 +36,8 @@ class MeshyTextToModelRequest(BaseModel): ) symmetry_mode: str = Field(..., description="'auto', 'off' or 'on'") pose_mode: str = Field(...) - ultra_mode: bool = Field(False) + ultra_mode: bool | None = Field(None) + geometry_resolution: str | None = Field(None) seed: int = Field(...) moderation: bool = Field(False) @@ -66,7 +67,8 @@ class MeshyImageToModelRequest(BaseModel): enable_pbr: bool | None = Field(...) texture_resolution: str | None = Field(None) pose_mode: str = Field(...) - ultra_mode: bool = Field(False) + ultra_mode: bool | None = Field(None) + geometry_resolution: str | None = Field(None) texture_prompt: str | None = Field(None, max_length=600) texture_image_url: str | None = Field(None) seed: int = Field(...) @@ -87,6 +89,8 @@ class MeshyMultiImageToModelRequest(BaseModel): enable_pbr: bool | None = Field(...) texture_resolution: str | None = Field(None) pose_mode: str = Field(...) + ultra_mode: bool | None = Field(None) + geometry_resolution: str | None = Field(None) texture_prompt: str | None = Field(None, max_length=600) texture_image_url: str | None = Field(None) seed: int = Field(...) diff --git a/comfy_api_nodes/apis/openrouter.py b/comfy_api_nodes/apis/openrouter.py index e30d9bcfb99..b65b1c17d4c 100644 --- a/comfy_api_nodes/apis/openrouter.py +++ b/comfy_api_nodes/apis/openrouter.py @@ -57,6 +57,15 @@ class OpenRouterChatRequest(BaseModel): stream: bool = Field(False) +class OpenRouterImageRequest(BaseModel): + model: str = Field(...) + prompt: str = Field(...) + aspect_ratio: str | None = Field(None) + size: str | None = Field(None) + input_references: list[OpenRouterImageContent] | None = Field(None) + stream: bool = Field(False) + + class OpenRouterUsage(BaseModel): prompt_tokens: int | None = Field(None) completion_tokens: int | None = Field(None) @@ -91,3 +100,16 @@ class OpenRouterChatResponse(BaseModel): choices: list[OpenRouterChoice] | None = Field(None) usage: OpenRouterUsage | None = Field(None) error: OpenRouterError | None = Field(None) + + +class OpenRouterImageData(BaseModel): + b64_json: str | None = Field(None) + url: str | None = Field(None) + media_type: str | None = Field(None) + + +class OpenRouterImageResponse(BaseModel): + created: int | None = Field(None) + data: list[OpenRouterImageData] | None = Field(None) + usage: OpenRouterUsage | None = Field(None) + error: OpenRouterError | None = Field(None) diff --git a/comfy_api_nodes/apis/pruna.py b/comfy_api_nodes/apis/pruna.py new file mode 100644 index 00000000000..8ef1f5acb8f --- /dev/null +++ b/comfy_api_nodes/apis/pruna.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel, Field + + +class PrunaVideoInput(BaseModel): + prompt: str = Field(...) + duration: int | None = Field(None) + resolution: str = Field(...) + aspect_ratio: str | None = Field(None) + fps: int = Field(...) + draft: bool = Field(...) + save_audio: bool = Field(...) + prompt_upsampling: bool = Field(...) + seed: int = Field(...) + image: str | None = Field(None) + last_frame_image: str | None = Field(None) + audio: str | None = Field(None) + + +class PrunaPredictionRequest(BaseModel): + input: PrunaVideoInput = Field(...) + + +class PrunaPredictionResponse(BaseModel): + id: str = Field(...) + model: str | None = Field(None) + get_url: str | None = Field(None) + + +class PrunaPredictionStatusResponse(BaseModel): + status: str | None = Field(None) + message: str | None = Field(None) + generation_url: str | None = Field(None) + error: str | None = Field(None) diff --git a/comfy_api_nodes/apis/tripo.py b/comfy_api_nodes/apis/tripo.py index 583c2f229a3..c1f0182759e 100644 --- a/comfy_api_nodes/apis/tripo.py +++ b/comfy_api_nodes/apis/tripo.py @@ -38,23 +38,6 @@ class TripoStyle(str, Enum): NONE = "None" -class TripoTaskType(str, Enum): - TEXT_TO_MODEL = "text_to_model" - IMAGE_TO_MODEL = "image_to_model" - MULTIVIEW_TO_MODEL = "multiview_to_model" - TEXTURE_MODEL = "texture_model" - ANIMATE_PRERIGCHECK = "animate_prerigcheck" - ANIMATE_RIG = "animate_rig" - ANIMATE_RETARGET = "animate_retarget" - STYLIZE_MODEL = "stylize_model" - CONVERT_MODEL = "convert_model" - MESH_SEGMENTATION = "mesh_segmentation" - MESH_COMPLETION = "mesh_completion" - HIGHPOLY_TO_LOWPOLY = "highpoly_to_lowpoly" - GENERATE_MULTIVIEW_IMAGE = "generate_multiview_image" - EDIT_MULTIVIEW_IMAGE = "edit_multiview_image" - - class TripoTextureAlignment(str, Enum): ORIGINAL_IMAGE = "original_image" GEOMETRY = "geometry" @@ -269,29 +252,18 @@ class TripoUrlReference(BaseModel): url: str -class TripoObjectStorage(BaseModel): - bucket: str - key: str - - -class TripoObjectReference(BaseModel): - type: str - object: TripoObjectStorage - - class TripoFileEmptyReference(BaseModel): pass class TripoFileReference(RootModel): - root: TripoFileTokenReference | TripoUrlReference | TripoObjectReference | TripoFileEmptyReference + root: TripoFileTokenReference | TripoUrlReference | TripoFileEmptyReference class TripoTextToModelRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.TEXT_TO_MODEL, description="Type of task") prompt: str = Field(..., description="The text prompt describing the model to generate", max_length=1024) + model: TripoModelVersion = TripoModelVersion.v3_1_20260211 negative_prompt: str | None = Field(None, description="The negative text prompt", max_length=255) - model_version: TripoModelVersion | None = TripoModelVersion.v2_5_20250123 face_limit: int | None = Field(None, description="The number of faces to limit the generation to") texture: bool | None = Field(True, description="Whether to apply texture to the generated model") pbr: bool | None = Field(True, description="Whether to apply PBR to the generated model") @@ -300,16 +272,14 @@ class TripoTextToModelRequest(BaseModel): texture_seed: int | None = Field(None, description="The seed for the texture") texture_quality: TripoTextureQuality | None = TripoTextureQuality.standard geometry_quality: TripoGeometryQuality | None = TripoGeometryQuality.standard - style: TripoStyle | None = None auto_size: bool | None = Field(False, description="Whether to auto-size the model") quad: bool | None = Field(False, description="Whether to apply quad to the generated model") smart_low_poly: bool | None = Field(None, description="Low-poly output with clean, hand-crafted style topology") class TripoImageToModelRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.IMAGE_TO_MODEL, description="Type of task") - file: TripoFileReference = Field(..., description="The file reference to convert to a model") - model_version: TripoModelVersion | None = Field(None, description="The model version to use for generation") + input: str = Field(..., description="Image URL or file token") + model: TripoModelVersion = TripoModelVersion.v3_1_20260211 face_limit: int | None = Field(None, description="The number of faces to limit the generation to") texture: bool | None = Field(True, description="Whether to apply texture to the generated model") pbr: bool | None = Field(True, description="Whether to apply PBR to the generated model") @@ -320,7 +290,6 @@ class TripoImageToModelRequest(BaseModel): texture_alignment: TripoTextureAlignment | None = Field( TripoTextureAlignment.ORIGINAL_IMAGE, description="The texture alignment method" ) - style: TripoStyle | None = Field(None, description="The style to apply to the generated model") auto_size: bool | None = Field(False, description="Whether to auto-size the model") orientation: TripoOrientation | None = TripoOrientation.DEFAULT quad: bool | None = Field(False, description="Whether to apply quad to the generated model") @@ -328,10 +297,8 @@ class TripoImageToModelRequest(BaseModel): class TripoMultiviewToModelRequest(BaseModel): - type: TripoTaskType = TripoTaskType.MULTIVIEW_TO_MODEL - files: list[TripoFileReference] = Field(..., description="The file references to convert to a model") - model_version: TripoModelVersion | None = Field(None, description="The model version to use for generation") - orthographic_projection: bool | None = Field(False, description="Whether to use orthographic projection") + inputs: list[dict[str, str]] = Field(..., description="View-keyed image references: front, left, back, right") + model: TripoModelVersion = TripoModelVersion.v3_1_20260211 face_limit: int | None = Field(None, description="The number of faces to limit the generation to") texture: bool | None = Field(True, description="Whether to apply texture to the generated model") pbr: bool | None = Field(True, description="Whether to apply PBR to the generated model") @@ -354,42 +321,32 @@ class TripoTexturePrompt(BaseModel): class TripoTextureModelRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.TEXTURE_MODEL, description="Type of task") - original_model_task_id: str = Field(..., description="The task ID of the original model") - model_version: TripoTextureModelVersion | None = Field(None, description="Texture model version") - texture: bool | None = Field(True, description="Whether to apply texture to the model") + input: str = Field(..., description="The task ID of the model to texture") + model: TripoTextureModelVersion | None = Field(None, description="Texture model version") pbr: bool | None = Field(True, description="Whether to apply PBR to the model") - model_seed: int | None = Field(None, description="The seed for the model") texture_seed: int | None = Field(None, description="The seed for the texture") texture_quality: TripoTextureQuality | None = Field(None, description="The quality of the texture") texture_alignment: TripoTextureAlignment | None = Field( TripoTextureAlignment.ORIGINAL_IMAGE, description="The texture alignment method" ) - texture_prompt: TripoTexturePrompt | None = Field( - None, - description="Optional guidance for texturing. Required in practice for imported models, " - "which carry no source image to infer texture from.", - ) + texture_prompt: TripoTexturePrompt | None = Field(None, description="Optional guidance for texturing") part_names: list[str] | None = Field(None, description="Parts of a segmented model to texture; all parts when omitted") class TripoAnimatePrerigcheckRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.ANIMATE_PRERIGCHECK, description="Type of task") - original_model_task_id: str = Field(..., description="The task ID of the original model") + input: str = Field(..., description="The task ID of the model") class TripoAnimateRigRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.ANIMATE_RIG, description="Type of task") - original_model_task_id: str = Field(..., description="The task ID of the original model") - model_version: TripoRigModelVersion | None = Field(None, description="Rigging model version") + input: str = Field(..., description="The task ID of the model") + model: TripoRigModelVersion | None = Field(None, description="Rigging model version") rig_type: TripoRigType | None = Field(None, description="Skeleton type") out_format: TripoOutFormat | None = Field(TripoOutFormat.GLB, description="The output format") spec: TripoSpec | None = Field(TripoSpec.TRIPO, description="The specification for rigging") class TripoAnimateRetargetRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.ANIMATE_RETARGET, description="Type of task") - original_model_task_id: str = Field(..., description="The task ID of the original model") + input: str = Field(..., description="The task ID of the rigged model") animation: str = Field(..., description="The animation preset to apply") out_format: TripoOutFormat | None = Field(TripoOutFormat.GLB, description="The output format") export_with_geometry: bool | None = Field(None, description="Whether to export geometry with the animation") @@ -397,19 +354,16 @@ class TripoAnimateRetargetRequest(BaseModel): class TripoMeshSegmentationRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.MESH_SEGMENTATION, description="Type of task") - original_model_task_id: str = Field(..., description="The task ID of the original model") + input: str = Field(..., description="The task ID of the model to segment") class TripoMeshCompletionRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.MESH_COMPLETION, description="Type of task") - original_model_task_id: str = Field(..., description="The task ID of a mesh segmentation task") + input: str = Field(..., description="The task ID of a mesh segmentation task") part_names: list[str] | None = Field(None, description="Parts to complete; all parts when omitted") class TripoHighpolyToLowpolyRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.HIGHPOLY_TO_LOWPOLY, description="Type of task") - original_model_task_id: str = Field(..., description="The task ID of the original model") + input: str = Field(..., description="The task ID of the model to retopologize") face_limit: int | None = Field(None, description="Target face count; adaptive when omitted") quad: bool | None = Field(None, description="Whether to output a quad mesh") bake: bool | None = Field(None, description="Whether to bake textures onto the low-poly mesh") @@ -417,8 +371,7 @@ class TripoHighpolyToLowpolyRequest(BaseModel): class TripoGenerateMultiviewImageRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.GENERATE_MULTIVIEW_IMAGE, description="Type of task") - file: TripoFileReference = Field(..., description="The source image") + input: str = Field(..., description="URL or file token of the source image") class TripoMultiviewEditPrompt(BaseModel): @@ -427,15 +380,21 @@ class TripoMultiviewEditPrompt(BaseModel): class TripoEditMultiviewImageRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.EDIT_MULTIVIEW_IMAGE, description="Type of task") - original_task_id: str = Field(..., description="The task ID of the multiview images to edit") + input: str = Field(..., description="The task ID of the multiview images to edit") prompts: list[TripoMultiviewEditPrompt] = Field(..., description="Per-view edit instructions") +class TripoMeshSmartSegmentRequest(BaseModel): + input: str = Field(..., description="URL or file token of a GLB model, or of an image") + seg_type: str = Field(..., description='"model" or "image"') + transform: list[float] | None = Field(None, description="Column-major 4x4 matrix, required for models") + granularity: str | None = Field(None, description="coarse, medium or fine") + hint: str | None = Field(None, description="Text naming the parts to look for") + + class TripoConvertModelRequest(BaseModel): - type: TripoTaskType = Field(TripoTaskType.CONVERT_MODEL, description="Type of task") + input: str = Field(..., description="The task ID of the model to convert") format: TripoConvertFormat = Field(..., description="The format to convert to") - original_model_task_id: str = Field(..., description="The task ID of the original model") quad: bool | None = Field(None, description="Whether to apply quad to the model") force_symmetry: bool | None = Field(None, description="Whether to force symmetry") face_limit: int | None = Field(None, description="The number of faces to limit the conversion to") @@ -458,7 +417,7 @@ class TripoConvertModelRequest(BaseModel): class TripoP1CommonRequest(BaseModel): """Fields supported by Tripo P1 across all input types.""" - model_version: str = Field("P1-20260311") + model: str = Field("P1-20260311") model_seed: int | None = Field(None, description="Random seed for geometry generation") face_limit: int | None = Field(None, ge=48, le=20000, description="Target face count (48-20000)") texture: bool | None = Field(None, description="Enable texturing; pbr=True forces this true") @@ -471,53 +430,73 @@ class TripoP1CommonRequest(BaseModel): class TripoP1TextToModelRequest(TripoP1CommonRequest): - type: str = "text_to_model" prompt: str = Field(..., max_length=1024) negative_prompt: str | None = Field(None, max_length=255) image_seed: int | None = None class TripoP1ImageToModelRequest(TripoP1CommonRequest): - type: str = "image_to_model" - file: TripoFileReference + input: str enable_image_autofix: bool | None = None texture_alignment: str | None = Field(None, description='"original_image" or "geometry"') orientation: str | None = Field(None, description='"default" or "align_image"; needs texture=true') class TripoP1MultiviewToModelRequest(TripoP1CommonRequest): - """P1 multiview generation. - - Tripo requires `files` to be exactly four entries in [front, left, back, right] order with `{}` - (TripoFileEmptyReference) for omitted slots; front is required and at least two images total must be provided. - """ - - type: str = "multiview_to_model" - files: list[TripoFileReference] + inputs: list[dict[str, str]] texture_alignment: str | None = None orientation: str | None = None -class TripoImportModelRequest(BaseModel): - """Request for the comfy-api composite import endpoint (/proxy/tripo/v2/openapi/import). +class TripoPSeriesRequest(BaseModel): + model: str = Field(..., description="P-series model version, e.g. P2-20260801") + quad: bool | None = Field(None, description="Quad-dominant mesh, delivered as FBX") + face_limit: int | None = Field(None, ge=48, le=50000, description="Target face count; at most 25000 with quad") + model_seed: int | None = Field(None, description="Random seed for geometry generation") + texture: bool | None = Field(None, description="Enable texturing") + pbr: bool | None = Field(None, description="Enable PBR maps") + texture_seed: int | None = Field(None, description="Random seed for texture generation") + texture_quality: str | None = Field(None, description='"standard", "detailed" or "extreme"') + auto_size: bool | None = Field(None, description="Scale textured models to real-world meters") + export_uv: bool | None = Field(None, description="UV unwrap untextured models") + compress: str | None = Field(None, description='Only "geometry" is supported') - The model file is uploaded to ComfyUI API storage first; the backend downloads it from - `url`, re-uploads it to Tripo's storage and creates the import_model task server-side. - """ - url: str = Field(..., description="ComfyUI API storage download URL of the model file") - format: str = Field(..., description='File format: "glb", "fbx", "obj" or "stl"') +class TripoPSeriesTextToModelRequest(TripoPSeriesRequest): + prompt: str = Field(..., max_length=1024) + negative_prompt: str | None = Field(None, max_length=255) + image_seed: int | None = Field(None) + + +class TripoPSeriesImageToModelRequest(TripoPSeriesRequest): + input: str = Field(..., description="Image URL or file token") + enable_image_autofix: bool | None = Field(None) + texture_alignment: str | None = Field(None, description='"original_image" or "geometry"') + orientation: str | None = Field(None, description='"default" or "align_image"') + + +class TripoPSeriesMultiviewToModelRequest(TripoPSeriesRequest): + inputs: list[dict[str, str]] = Field(..., description="View-keyed image references: front, left, back, right") + texture_alignment: str | None = Field(None, description='"original_image" or "geometry"') + orientation: str | None = Field(None, description='"default" or "align_image"') + + +class TripoImportModelRequest(BaseModel): + input: str = Field(..., description="URL or file token of the model file") class TripoTaskOutput(BaseModel): - model: str | None = Field(None, description="URL to the model") - base_model: str | None = Field(None, description="URL to the base model") - pbr_model: str | None = Field(None, description="URL to the PBR model") - rendered_image: str | None = Field(None, description="URL to the rendered image") + model_url: str | None = Field(None, description="URL to the model") + rendered_image_url: str | None = Field(None, description="URL to the rendered preview") + generated_image_url: str | None = Field(None, description="URL to the intermediate image of text-to-model") riggable: bool | None = Field(None, description="Whether the model is riggable") rig_type: str | None = Field(None, description="Recommended rig type") - topology: str | None = Field(None, description="Legacy name of rig_type") generate_multiview_image: dict[str, str] | None = Field(None, description="View name to image URL") + prompt: str | None = Field(None, description="Smart segmentation: the parts Tripo found") + mask_url: str | None = Field(None, description="Smart segmentation: part mask image") + seg_model_url: str | None = Field(None, description="Smart segmentation: segmented GLB") + seg_task_id: str | None = Field(None, description="Smart segmentation: the mesh_segmentation task") + model_task_id: str | None = Field(None, description="Smart segmentation: the imported or generated model task") class TripoTask(BaseModel): @@ -527,10 +506,11 @@ class TripoTask(BaseModel): input: dict[str, Any] | None = Field(None, description="The input parameters for the task") output: TripoTaskOutput | None = Field(None, description="The output of the task") progress: int | None = Field(None, description="The progress of the task", ge=0, le=100) - create_time: int | None = Field(None, description="The creation time of the task") - running_left_time: int | None = Field(None, description="The estimated time left for the task") - queue_position: int | None = Field(None, description="The position in the queue") - consumed_credit: float | None = Field(None) + created_at: str | None = Field(None, description="The creation time of the task") + completed_at: str | None = Field(None, description="The completion time of the task") + credits_consumed: float | None = Field(None) + error_code: int | None = Field(None) + error_message: str | None = Field(None) class TripoTaskResponse(BaseModel): @@ -538,6 +518,15 @@ class TripoTaskResponse(BaseModel): data: TripoTask = Field(..., description="The task data") +class TripoFileData(BaseModel): + file_token: str + + +class TripoFileResponse(BaseModel): + code: int = Field(0, description="The response code") + data: TripoFileData + + class TripoErrorResponse(BaseModel): code: int = Field(..., description="The error code") message: str = Field(..., description="The error message") diff --git a/comfy_api_nodes/nodes_bfl.py b/comfy_api_nodes/nodes_bfl.py index dd8653cdda9..33c0be568f6 100644 --- a/comfy_api_nodes/nodes_bfl.py +++ b/comfy_api_nodes/nodes_bfl.py @@ -13,6 +13,7 @@ BFLFluxProGenerateResponse, BFLFluxProUltraGenerateRequest, BFLFluxStatusResponse, + BFLFluxVideoEditRequest, BFLFluxVideoUpscaleRequest, BFLFluxVTORequest, BFLStatus, @@ -1153,6 +1154,7 @@ def price_badge(cls) -> IO.PriceBadge: _FLUX3_VIDEO_ENDPOINT = ApiEndpoint(path="/proxy/bfl/v1/flux-3-video", method="POST") _FLUX_VIDEO_UPSCALE_ENDPOINT = ApiEndpoint(path="/proxy/bfl/v1/flux-tools/video-upscale-v1", method="POST") +_FLUX_VIDEO_EDIT_ENDPOINT = ApiEndpoint(path="/proxy/bfl/v1/flux-tools/video-edit-v1", method="POST") _BFL_POLL_PROXY_PATH = "/proxy/bfl/v1/get_result" @@ -1508,6 +1510,112 @@ async def execute( return await _bfl_video_execute(cls, _FLUX_VIDEO_UPSCALE_ENDPOINT, request, poll_via_proxy=True) +_FLUX_VIDEO_EDIT_FPS = 24 +_FLUX_VIDEO_EDIT_MIN_FRAMES = 17 +_FLUX_VIDEO_EDIT_MAX_FRAMES = 361 +_FLUX_VIDEO_EDIT_MIN_DURATION = (_FLUX_VIDEO_EDIT_MIN_FRAMES - 0.5) / _FLUX_VIDEO_EDIT_FPS +_FLUX_VIDEO_EDIT_MAX_DURATION = _FLUX_VIDEO_EDIT_MAX_FRAMES / _FLUX_VIDEO_EDIT_FPS +_FLUX_VIDEO_EDIT_MIN_SIDE = 160 +_FLUX_VIDEO_EDIT_MAX_PROMPT_LENGTH = 4096 +_FLUX_VIDEO_EDIT_MAX_PIXELS = 1280 * 704 + + +class FluxVideoEditNode(IO.ComfyNode): + + @classmethod + def define_schema(cls) -> IO.Schema: + return IO.Schema( + node_id="FluxVideoEditNode", + display_name="Flux Video Edit", + category="partner/video/BFL", + description="Edits a clip from a written instruction: remove, add or replace objects, change " + "the setting, restyle the footage, change on-screen text, or replace the spoken dialogue. " + "Length, framing, camera motion and audio come from the source clip.", + inputs=[ + IO.Video.Input( + "video", + tooltip="Source clip of 0.7 to 15 seconds, at least 160x160 pixels. The output is " + "rendered at 24 fps and capped at about 0.9 megapixels per frame, so a larger source " + "comes back smaller.", + ), + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="What to change, in plain language, up to 4096 characters. Anything you do " + "not mention is meant to stay as it was. Replacement dialogue has to fit the time the " + "original speech takes, and a silent clip stays silent.", + ), + IO.Boolean.Input( + "auto_downscale", + default=True, + tooltip="Automatically downscale sources larger than 1280x704 pixels in area before " + "upload. Aspect ratio is preserved; smaller videos are untouched.", + ), + IO.Int.Input( + "safety_tolerance", + default=4, + min=0, + max=4, + advanced=True, + tooltip="Moderation tolerance, 0 is the strictest.", + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=0xFFFFFFFF, + control_after_generate=True, + tooltip="Seed to determine if node should re-run; FLUX picks its own seed, so " + "actual results are nondeterministic regardless of this value.", + ), + ], + outputs=[IO.Video.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr="""{"type":"usd","usd":0.0429,"format":{"suffix":"/second"}}""", + ), + ) + + @classmethod + async def execute( + cls, + video: Input.Video, + prompt: str, + auto_downscale: bool, + safety_tolerance: int, + seed: int, + ) -> IO.NodeOutput: + validate_string( + prompt, field_name="prompt", min_length=1, max_length=_FLUX_VIDEO_EDIT_MAX_PROMPT_LENGTH + ) + validate_video_duration( + video, + min_duration=_FLUX_VIDEO_EDIT_MIN_DURATION, + max_duration=_FLUX_VIDEO_EDIT_MAX_DURATION, + ) + validate_video_dimensions( + video, min_width=_FLUX_VIDEO_EDIT_MIN_SIDE, min_height=_FLUX_VIDEO_EDIT_MIN_SIDE + ) + if auto_downscale: + width, height = video.get_dimensions() + scale = math.sqrt(_FLUX_VIDEO_EDIT_MAX_PIXELS / (width * height)) + if min(width, height) * scale >= _FLUX_VIDEO_EDIT_MIN_SIDE: + video = downscale_video_to_max_pixels(video, _FLUX_VIDEO_EDIT_MAX_PIXELS) + url = await upload_video_to_comfyapi(cls, video, wait_label="Uploading source video") + request = BFLFluxVideoEditRequest( + video=url, + prompt=prompt.strip(), + safety_tolerance=safety_tolerance, + ) + return await _bfl_video_execute(cls, _FLUX_VIDEO_EDIT_ENDPOINT, request, poll_via_proxy=True) + + class BFLExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -1526,6 +1634,7 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]: Flux3ImageToVideoNode, Flux3VideoContinuationNode, FluxVideoUpscaleNode, + FluxVideoEditNode, ] diff --git a/comfy_api_nodes/nodes_bria.py b/comfy_api_nodes/nodes_bria.py index 90cade2d06a..3c2866e2aa9 100644 --- a/comfy_api_nodes/nodes_bria.py +++ b/comfy_api_nodes/nodes_bria.py @@ -1,23 +1,35 @@ import av import torch from av.codec import CodecContext +from pydantic import BaseModel from typing_extensions import override -from comfy_api.latest import IO, ComfyExtension, Input +from comfy_api.latest import IO, ComfyExtension, Input, Types, VideoFromComponents from comfy_api_nodes.apis.bria import ( + BriaAddObjectRequest, BriaEditImageRequest, + BriaEraseByTextRequest, + BriaEraseForegroundRequest, BriaEraseRequest, BriaExpandRequest, BriaExpandResponse, + BriaFiboEditResponse, BriaGenFillRequest, BriaImageEditResponse, BriaImageResultResponse, BriaIncreaseResolutionRequest, + BriaRelightRequest, BriaRemoveBackgroundRequest, BriaRemoveBackgroundResponse, BriaRemoveVideoBackgroundRequest, BriaRemoveVideoBackgroundResponse, + BriaReplaceBackgroundRequest, + BriaReplaceBackgroundResponse, + BriaReplaceObjectRequest, + BriaReseasonRequest, + BriaRestoreRequest, BriaStatusResponse, + BriaVideoEraseRequest, BriaVideoGreenScreenRequest, BriaVideoReplaceBackgroundRequest, InputModerationSettings, @@ -32,6 +44,7 @@ poll_op, sync_op, upload_image_to_comfyapi, + upload_images_to_comfyapi, upload_video_to_comfyapi, validate_string, validate_video_duration, @@ -276,11 +289,13 @@ def _mask_to_binary_image(mask: Input.Image, action: str) -> torch.Tensor: return convert_mask_to_image(binary) -def _validate_mask_aspect_ratio(image: Input.Image, mask: Input.Image) -> None: - ih, iw = image.shape[1], image.shape[2] +def _validate_mask_aspect_ratio(mask: Input.Image, width: int, height: int, subject: str = "image") -> None: mh, mw = mask.shape[-2], mask.shape[-1] - if abs(iw * mh - ih * mw) > 0.01 * ih * mw: - raise ValueError(f"Mask must have the same aspect ratio as the image: image is {iw}x{ih}, mask is {mw}x{mh}.") + if abs(width * mh - height * mw) > 0.01 * height * mw: + raise ValueError( + f"Mask must have the same aspect ratio as the {subject}: " + f"{subject} is {width}x{height}, mask is {mw}x{mh}." + ) class BriaGenFill(IO.ComfyNode): @@ -362,7 +377,7 @@ async def execute( moderation: InputModerationSettings, ) -> IO.NodeOutput: validate_string(prompt, min_length=1) - _validate_mask_aspect_ratio(image, mask) + _validate_mask_aspect_ratio(mask, image.shape[2], image.shape[1]) mask_image = _mask_to_binary_image(mask, "fill") response = await sync_op( cls, @@ -449,7 +464,7 @@ async def execute( mask_type: str, moderation: dict, ) -> IO.NodeOutput: - _validate_mask_aspect_ratio(image, mask) + _validate_mask_aspect_ratio(mask, image.shape[2], image.shape[1]) mask_image = _mask_to_binary_image(mask, "erase") response = await sync_op( cls, @@ -1086,6 +1101,734 @@ async def execute( return IO.NodeOutput(images, mask) +BRIA_LIGHT_TYPES = [ + "midday", + "blue hour light", + "low-angle sunlight", + "sunrise light", + "spotlight on subject", + "overcast light", + "soft overcast daylight lighting", + "cloud-filtered lighting", + "fog-diffused lighting", + "moonlight lighting", + "starlight nighttime", + "soft bokeh lighting", + "harsh studio lighting", +] +BRIA_LIGHT_DIRECTIONS = ["front", "side", "bottom", "top-down"] +BRIA_SEASONS = ["spring", "summer", "autumn", "winter"] +BRIA_FAILED_STATUSES = ["error", "unknown"] +BRIA_MAX_REFERENCE_IMAGES = 10 +BRIA_VIDEO_ERASE_MAX_DURATION = 5.1 +BRIA_VIDEO_ERASE_MIN_FPS = 20 +BRIA_VIDEO_ERASE_MAX_FPS = 30 +BRIA_FIBO_EDIT_NOTE = ( + "Bria re-renders the whole frame at about 1 megapixel, so the result is not pixel-aligned with the input." +) + + +def _moderation_combo(*flags: str) -> IO.DynamicCombo.Input: + return IO.DynamicCombo.Input( + "moderation", + options=[ + IO.DynamicCombo.Option("false", []), + IO.DynamicCombo.Option("true", [IO.Boolean.Input(flag, default=False) for flag in flags]), + ], + tooltip="Moderation settings", + ) + + +def _visual_moderation(moderation: InputModerationSettings) -> tuple[bool, bool]: + return ( + moderation.get("visual_input_moderation", False), + moderation.get("visual_output_moderation", False), + ) + + +def _drop_alpha(image: Input.Image) -> torch.Tensor: + return image[..., :3] + + +def _structured_prompt_output() -> IO.String.Output: + return IO.String.Output( + id="structured_prompt", + display_name="structured_prompt", + tooltip="Structured description of the edited image, for a follow-up edit with Bria FIBO Image Edit.", + ) + + +async def _poll_bria(cls: type[IO.ComfyNode], request_id: str, response_model: type[BaseModel]): + return await poll_op( + cls, + ApiEndpoint(path=f"/proxy/bria/v2/status/{request_id}"), + status_extractor=lambda r: r.status, + failed_statuses=BRIA_FAILED_STATUSES, + response_model=response_model, + ) + + +async def _run_fibo_edit(cls: type[IO.ComfyNode], path: str, data: BaseModel) -> IO.NodeOutput: + submitted = await sync_op( + cls, + ApiEndpoint(path=path, method="POST"), + data=data, + response_model=BriaStatusResponse, + ) + response = await _poll_bria(cls, submitted.request_id, BriaFiboEditResponse) + return IO.NodeOutput( + await download_url_to_image_tensor(response.result.image_url), + response.result.structured_prompt or "", + ) + + +class BriaEraseByText(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaEraseByText", + display_name="Bria Erase by Text", + category="partner/image/Bria", + description="Remove an object named in plain text from an image using Bria. " + BRIA_FIBO_EDIT_NOTE, + inputs=[ + IO.Image.Input("image"), + IO.String.Input( + "object_name", + default="", + tooltip="Name of the object to remove, such as 'the lamp'. Several objects can be named " + "at once, such as 'the phone and the pencils'. Naming something that is not in the " + "picture still returns, and bills, a re-rendered image.", + ), + _moderation_combo("visual_input_moderation", "visual_output_moderation"), + ], + outputs=[IO.Image.Output(), _structured_prompt_output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + object_name: str, + moderation: InputModerationSettings, + ) -> IO.NodeOutput: + validate_string(object_name, field_name="object_name", min_length=1) + visual_input, visual_output = _visual_moderation(moderation) + return await _run_fibo_edit( + cls, + "/proxy/bria/v2/image/edit/erase_by_text", + BriaEraseByTextRequest( + image=await upload_image_to_comfyapi(cls, _drop_alpha(image), wait_label="Uploading image"), + object_name=object_name, + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + ) + + +class BriaAddObject(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaAddObject", + display_name="Bria Add Object", + category="partner/image/Bria", + description="Insert an object described in plain text into an image using Bria. " + BRIA_FIBO_EDIT_NOTE, + inputs=[ + IO.Image.Input("image"), + IO.String.Input( + "instruction", + multiline=True, + default="", + tooltip="What to add and where, such as 'Place a red vase with flowers on the table'.", + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + tooltip="Bria takes no seed here and re-imagines the edit on each call, so repeated runs can differ. The value is never sent: it only changes this node's cache key, so that an otherwise identical graph runs the edit again instead of returning the cached result.", + ), + _moderation_combo("visual_input_moderation", "visual_output_moderation"), + ], + outputs=[IO.Image.Output(), _structured_prompt_output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + instruction: str, + seed: int, + moderation: InputModerationSettings, + ) -> IO.NodeOutput: + validate_string(instruction, field_name="instruction", min_length=1) + visual_input, visual_output = _visual_moderation(moderation) + return await _run_fibo_edit( + cls, + "/proxy/bria/v2/image/edit/add_object_by_text", + BriaAddObjectRequest( + image=await upload_image_to_comfyapi(cls, _drop_alpha(image), wait_label="Uploading image"), + instruction=instruction, + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + ) + + +class BriaReplaceObject(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaReplaceObject", + display_name="Bria Replace Object", + category="partner/image/Bria", + description="Swap an object in an image for another one described in plain text using Bria. " + + BRIA_FIBO_EDIT_NOTE, + inputs=[ + IO.Image.Input("image"), + IO.String.Input( + "instruction", + multiline=True, + default="", + tooltip="What to replace with what, such as 'Replace the red apple with a green pear'.", + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + tooltip="Bria takes no seed here and re-imagines the edit on each call, so repeated runs can differ. The value is never sent: it only changes this node's cache key, so that an otherwise identical graph runs the edit again instead of returning the cached result.", + ), + _moderation_combo("visual_input_moderation", "visual_output_moderation"), + ], + outputs=[IO.Image.Output(), _structured_prompt_output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + instruction: str, + seed: int, + moderation: InputModerationSettings, + ) -> IO.NodeOutput: + validate_string(instruction, field_name="instruction", min_length=1) + visual_input, visual_output = _visual_moderation(moderation) + return await _run_fibo_edit( + cls, + "/proxy/bria/v2/image/edit/replace_object_by_text", + BriaReplaceObjectRequest( + image=await upload_image_to_comfyapi(cls, _drop_alpha(image), wait_label="Uploading image"), + instruction=instruction, + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + ) + + +class BriaRelight(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaRelight", + display_name="Bria Relight", + category="partner/image/Bria", + description="Change the lighting atmosphere and direction of an image using Bria. " + + BRIA_FIBO_EDIT_NOTE, + inputs=[ + IO.Image.Input("image"), + IO.Combo.Input( + "light_type", + options=BRIA_LIGHT_TYPES, + tooltip="Lighting atmosphere to apply.", + ), + IO.Combo.Input( + "light_direction", + options=BRIA_LIGHT_DIRECTIONS, + tooltip="Where the light comes from. Hard-light atmospheres such as midday, " + "spotlight on subject and harsh studio lighting react to it the most.", + ), + _moderation_combo("visual_input_moderation", "visual_output_moderation"), + ], + outputs=[IO.Image.Output(), _structured_prompt_output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + light_type: str, + light_direction: str, + moderation: InputModerationSettings, + ) -> IO.NodeOutput: + visual_input, visual_output = _visual_moderation(moderation) + return await _run_fibo_edit( + cls, + "/proxy/bria/v2/image/edit/relight", + BriaRelightRequest( + image=await upload_image_to_comfyapi(cls, _drop_alpha(image), wait_label="Uploading image"), + light_type=light_type, + light_direction=light_direction, + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + ) + + +class BriaRestorePhoto(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaRestorePhoto", + display_name="Bria Restore Photo", + category="partner/image/Bria", + description="Repair an old or damaged photograph with Bria: grain, scratches and blur go, and " + "age-related color casts are neutralized. Card mounts and studio borders may be cropped away and " + "faces are re-drawn. " + BRIA_FIBO_EDIT_NOTE + " Use Bria Increase Resolution to enlarge instead.", + inputs=[ + IO.Image.Input("image"), + _moderation_combo("visual_input_moderation", "visual_output_moderation"), + ], + outputs=[IO.Image.Output(), _structured_prompt_output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute(cls, image: Input.Image, moderation: InputModerationSettings) -> IO.NodeOutput: + visual_input, visual_output = _visual_moderation(moderation) + return await _run_fibo_edit( + cls, + "/proxy/bria/v2/image/edit/restore", + BriaRestoreRequest( + image=await upload_image_to_comfyapi(cls, _drop_alpha(image), wait_label="Uploading image"), + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + ) + + +class BriaReseason(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaReseason", + display_name="Bria Reseason", + category="partner/image/Bria", + description="Move an image to another season with Bria. The whole scene is re-rendered, so " + "scenery can change beyond the season itself. " + BRIA_FIBO_EDIT_NOTE, + inputs=[ + IO.Image.Input("image"), + IO.Combo.Input("season", options=BRIA_SEASONS, tooltip="Season to apply."), + _moderation_combo("visual_input_moderation", "visual_output_moderation"), + ], + outputs=[IO.Image.Output(), _structured_prompt_output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute(cls, image: Input.Image, season: str, moderation: InputModerationSettings) -> IO.NodeOutput: + visual_input, visual_output = _visual_moderation(moderation) + return await _run_fibo_edit( + cls, + "/proxy/bria/v2/image/edit/reseason", + BriaReseasonRequest( + image=await upload_image_to_comfyapi(cls, _drop_alpha(image), wait_label="Uploading image"), + season=season, + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + ) + + +class BriaEraseForeground(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaEraseForeground", + display_name="Bria Erase Foreground", + category="partner/image/Bria", + description="Remove the foreground of an image with Bria and generate a background in its place. " + "Everything Bria reads as foreground goes, not only people. The untouched pixels are preserved, " + "but the output is re-rendered at a standard size close to 1 megapixel.", + inputs=[ + IO.Image.Input("image"), + _moderation_combo("visual_input_moderation", "visual_output_moderation"), + ], + outputs=[IO.Image.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute(cls, image: Input.Image, moderation: InputModerationSettings) -> IO.NodeOutput: + visual_input, visual_output = _visual_moderation(moderation) + submitted = await sync_op( + cls, + ApiEndpoint(path="/proxy/bria/v2/image/edit/erase_foreground", method="POST"), + data=BriaEraseForegroundRequest( + image=await upload_image_to_comfyapi(cls, _drop_alpha(image), wait_label="Uploading image"), + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + response_model=BriaStatusResponse, + ) + response = await _poll_bria(cls, submitted.request_id, BriaImageResultResponse) + return IO.NodeOutput(await download_url_to_image_tensor(response.result.image_url)) + + +class BriaReplaceImageBackground(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaReplaceImageBackground", + display_name="Bria Replace Image Background", + category="partner/image/Bria", + description="Replace the background of an image with Bria, described by a prompt or guided by " + "reference images. The subject's pixels are preserved; the background is generated around them.", + inputs=[ + IO.Image.Input("image"), + IO.DynamicCombo.Input( + "background", + options=[ + IO.DynamicCombo.Option( + "prompt", + [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Description of the new background. A hex color code such as " + "#FF5733 produces a solid color background.", + ), + IO.Combo.Input( + "mode", + options=["high_control", "base", "fast"], + tooltip="high_control follows the prompt most closely, base is a " + "balanced default and fast trades detail for speed.", + ), + IO.Boolean.Input( + "refine_prompt", + default=True, + tooltip="Rewrite the prompt for better results, which also translates " + "non-English prompts. Turn it off to send the prompt exactly as written.", + ), + ], + ), + IO.DynamicCombo.Option( + "reference images", + [ + IO.Autogrow.Input( + "ref_images", + template=IO.Autogrow.TemplateNames( + IO.Image.Input("ref_image"), + names=[f"ref_image_{i}" for i in range(1, BRIA_MAX_REFERENCE_IMAGES + 1)], + min=1, + ), + tooltip=f"1-{BRIA_MAX_REFERENCE_IMAGES} images guiding the new background; " + "they do not need to share a size. Every reference changes the result, so " + "a few consistent ones beat many conflicting ones. A batched input counts " + "once per image.", + ), + IO.Boolean.Input( + "enhance_ref_images", + default=True, + tooltip="Extra processing of the reference images for better results.", + ), + ], + ), + ], + tooltip="Describe the new background with a prompt, or guide it with reference images.", + ), + IO.Boolean.Input( + "original_quality", + default=False, + tooltip="Return the input's exact pixel size instead of scaling the result to about " + "1 megapixel. A large input then returns a large image.", + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + tooltip="The same seed usually returns the same background; the automatic prompt " + "refinement can still vary it.", + ), + _moderation_combo( + "prompt_content_moderation", "visual_input_moderation", "visual_output_moderation" + ), + ], + outputs=[ + IO.Image.Output(), + IO.String.Output( + id="refined_prompt", + display_name="refined_prompt", + tooltip="The prompt Bria generated from, empty on the reference-image path.", + ), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.0572}', + ), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + background: dict, + original_quality: bool, + seed: int, + moderation: InputModerationSettings, + ) -> IO.NodeOutput: + prompt = None + ref_images = None + mode = None + refine_prompt = None + enhance_ref_images = None + if background["background"] == "prompt": + prompt = background["prompt"] + validate_string(prompt, field_name="prompt", min_length=1) + mode = background["mode"] + refine_prompt = background["refine_prompt"] + else: + references = [ + _drop_alpha(image) + for images in background["ref_images"].values() + if images is not None + for image in images + ] + if len(references) > BRIA_MAX_REFERENCE_IMAGES: + raise ValueError( + f"At most {BRIA_MAX_REFERENCE_IMAGES} reference images are supported, got {len(references)}." + ) + ref_images = await upload_images_to_comfyapi( + cls, + references, + max_images=BRIA_MAX_REFERENCE_IMAGES, + wait_label="Uploading reference images", + ) + enhance_ref_images = background["enhance_ref_images"] + visual_input, visual_output = _visual_moderation(moderation) + submitted = await sync_op( + cls, + ApiEndpoint(path="/proxy/bria/v2/image/edit/replace_background", method="POST"), + data=BriaReplaceBackgroundRequest( + image=await upload_image_to_comfyapi( + cls, + _drop_alpha(image), + total_pixels=None if original_quality else 2048 * 2048, + wait_label="Uploading image", + ), + prompt=prompt, + ref_images=ref_images, + mode=mode, + refine_prompt=refine_prompt, + enhance_ref_images=enhance_ref_images, + original_quality=original_quality, + seed=seed, + prompt_content_moderation=moderation.get("prompt_content_moderation", False), + visual_input_content_moderation=visual_input, + visual_output_content_moderation=visual_output, + ), + response_model=BriaStatusResponse, + ) + response = await _poll_bria(cls, submitted.request_id, BriaReplaceBackgroundResponse) + return IO.NodeOutput( + await download_url_to_image_tensor(response.result.image_url), + response.result.refined_prompt or "", + ) + + +class BriaVideoEraser(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="BriaVideoEraser", + display_name="Bria Video Eraser", + category="partner/video/Bria", + description="Erase whatever a per-frame mask covers from a video with Bria and fill the gap in. " + "The mask must be white on what should go and black elsewhere. Bria accepts clips of at most " + "5.1 seconds at 20 to 30 frames per second with even pixel dimensions; audio is kept by default. " + "The returned clip can be a few frames shorter than the input.", + inputs=[ + IO.Video.Input("video", tooltip="Clip to erase from."), + IO.Mask.Input( + "mask", + optional=True, + tooltip="One mask per frame of the video, white where the object to erase is. " + "Provide either a mask or a mask video, not both.", + ), + IO.Video.Input( + "mask_video", + optional=True, + tooltip="An already encoded mask video with the same dimensions and frame count as the " + "video. Provide either a mask or a mask video, not both.", + ), + IO.Boolean.Input("preserve_audio", default=True, tooltip="Keep the input's audio track."), + ], + outputs=[IO.Video.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + expr='{"type":"usd","usd":0.06435,"format":{"suffix":"/second"}}', + ), + ) + + @classmethod + async def execute( + cls, + video: Input.Video, + preserve_audio: bool, + mask: Input.Mask | None = None, + mask_video: Input.Video | None = None, + ) -> IO.NodeOutput: + if mask is None and mask_video is None: + raise ValueError("Connect either a mask or a mask video.") + if mask is not None and mask_video is not None: + raise ValueError("Provide either a mask or a mask video, not both.") + validate_video_duration(video, max_duration=BRIA_VIDEO_ERASE_MAX_DURATION) + width, height = video.get_dimensions() + if width % 2 or height % 2: + raise ValueError( + f"Bria needs even pixel dimensions, but the video is {width}x{height}. Crop or scale it first." + ) + frame_count = video.get_frame_count() + frame_rate = video.get_frame_rate() + if not BRIA_VIDEO_ERASE_MIN_FPS <= float(frame_rate) <= BRIA_VIDEO_ERASE_MAX_FPS: + raise ValueError( + f"Bria accepts {BRIA_VIDEO_ERASE_MIN_FPS} to {BRIA_VIDEO_ERASE_MAX_FPS} frames per second, " + f"but the video runs at {float(frame_rate):.3f}. Re-time it with Get Video Components and " + f"Create Video first." + ) + if mask is not None: + if mask.shape[0] != frame_count: + raise ValueError( + f"The mask has {mask.shape[0]} frame(s) but the video has {frame_count}; " + f"Bria needs one mask frame per video frame." + ) + _validate_mask_aspect_ratio(mask, width, height, "video") + if mask.shape[-1] != width or mask.shape[-2] != height: + mask = torch.nn.functional.interpolate( + mask.unsqueeze(1).float(), size=(height, width), mode="nearest-exact" + )[:, 0] + mask_source = VideoFromComponents( + Types.VideoComponents(images=_mask_to_binary_image(mask, "erase"), frame_rate=frame_rate) + ) + else: + mask_width, mask_height = mask_video.get_dimensions() + mask_frame_count = mask_video.get_frame_count() + if (mask_width, mask_height) != (width, height): + raise ValueError( + f"The mask video must have the same dimensions as the video: video is {width}x{height}, " + f"mask video is {mask_width}x{mask_height}." + ) + if mask_frame_count != frame_count: + raise ValueError( + f"The mask video has {mask_frame_count} frames but the video has {frame_count}; " + f"Bria needs one mask frame per video frame." + ) + mask_source = mask_video + submitted = await sync_op( + cls, + ApiEndpoint(path="/proxy/bria/v2/video/edit/erase", method="POST"), + data=BriaVideoEraseRequest( + video=await upload_video_to_comfyapi(cls, video), + mask=await upload_video_to_comfyapi(cls, mask_source, wait_label="Uploading mask"), + preserve_audio=preserve_audio, + output_container_and_codec="mp4_h264", + ), + response_model=BriaStatusResponse, + ) + response = await _poll_bria(cls, submitted.request_id, BriaRemoveVideoBackgroundResponse) + return IO.NodeOutput(await download_url_to_video_output(response.result.video_url)) + + class BriaExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: @@ -1100,6 +1843,15 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]: BriaVideoGreenScreen, BriaVideoReplaceBackground, BriaTransparentVideoBackground, + BriaEraseByText, + BriaAddObject, + BriaReplaceObject, + BriaRelight, + BriaRestorePhoto, + BriaReseason, + BriaEraseForeground, + BriaReplaceImageBackground, + BriaVideoEraser, ] diff --git a/comfy_api_nodes/nodes_bytedance.py b/comfy_api_nodes/nodes_bytedance.py index 430860e3d91..11a0404a69e 100644 --- a/comfy_api_nodes/nodes_bytedance.py +++ b/comfy_api_nodes/nodes_bytedance.py @@ -3362,6 +3362,7 @@ async def execute( cls, ApiEndpoint(path="/proxy/byteplus/api/v3/tts/create", method="POST"), response_model=SeedAudioResponse, + asset_urls=True, data=SeedAudioRequest( model=model, text_prompt=text_prompt, @@ -3374,11 +3375,15 @@ async def execute( ), ), ) - if not response.audio: + if response.audio: + audio_bytes = base64.b64decode(response.audio) + elif response.url: + audio_bytes = (await download_url_as_bytesio(response.url, cls=cls)).getvalue() + else: raise Exception( f"Seed Audio returned no audio (code={response.code}): {response.message}" ) - return IO.NodeOutput(audio_bytes_to_audio_input(base64.b64decode(response.audio))) + return IO.NodeOutput(audio_bytes_to_audio_input(audio_bytes)) _VCUBE_ENHANCE_VIDEO_ENDPOINT = ApiEndpoint(path="/proxy/byteplusmediakit/api/v1/tools/enhance-video", method="POST") diff --git a/comfy_api_nodes/nodes_elevenlabs.py b/comfy_api_nodes/nodes_elevenlabs.py index eba578a45fa..35baa15a829 100644 --- a/comfy_api_nodes/nodes_elevenlabs.py +++ b/comfy_api_nodes/nodes_elevenlabs.py @@ -404,6 +404,7 @@ async def execute( ), data=request, as_binary=True, + asset_urls=True, ) return IO.NodeOutput(audio_bytes_to_audio_input(response)) @@ -449,6 +450,7 @@ async def execute( files={"audio": ("audio.mp4", audio_bytes_io, "audio/mp4")}, content_type="multipart/form-data", as_binary=True, + asset_urls=True, ) return IO.NodeOutput(audio_bytes_to_audio_input(response)) @@ -545,6 +547,7 @@ async def execute( loop=model.get("loop", None), ), as_binary=True, + asset_urls=True, ) return IO.NodeOutput(audio_bytes_to_audio_input(response)) @@ -762,6 +765,7 @@ async def execute( files={"audio": ("audio.mp4", audio_bytes_io.getvalue(), "audio/mp4")}, content_type="multipart/form-data", as_binary=True, + asset_urls=True, ) return IO.NodeOutput(audio_bytes_to_audio_input(response)) @@ -901,6 +905,7 @@ async def execute( ), data=request, as_binary=True, + asset_urls=True, ) return IO.NodeOutput(audio_bytes_to_audio_input(response)) diff --git a/comfy_api_nodes/nodes_fishaudio.py b/comfy_api_nodes/nodes_fishaudio.py index d40fc27403b..3d30190e697 100644 --- a/comfy_api_nodes/nodes_fishaudio.py +++ b/comfy_api_nodes/nodes_fishaudio.py @@ -293,6 +293,7 @@ async def execute( ), data=request, as_binary=True, + asset_urls=True, ) return IO.NodeOutput(audio_bytes_to_audio_input(response)) diff --git a/comfy_api_nodes/nodes_gemini.py b/comfy_api_nodes/nodes_gemini.py index 48c42bdda66..0977035cab8 100644 --- a/comfy_api_nodes/nodes_gemini.py +++ b/comfy_api_nodes/nodes_gemini.py @@ -8,6 +8,7 @@ from fnmatch import fnmatch from io import BytesIO from typing import Any, Literal +from urllib.parse import urlparse import torch from typing_extensions import override @@ -277,8 +278,12 @@ async def get_video_from_interaction( ) +GEMINI_FILES_HOST = "generativelanguage.googleapis.com" + + async def download_interaction_video(uri: str, cls: type[IO.ComfyNode] | None = None) -> InputImpl.VideoFromFile: - if "/files/" not in uri: + parsed = urlparse(uri) + if parsed.netloc != GEMINI_FILES_HOST or "/files/" not in parsed.path: return await download_url_to_video_output(uri, cls=cls) name = uri.split("?", 1)[0].rsplit("/files/", 1)[-1].split(":", 1)[0] await poll_op( @@ -401,6 +406,7 @@ async def build_gemini_media_parts( *, url_budget: int = GEMINI_URL_INPUT_BUDGET, max_inline_bytes: int = GEMINI_MAX_INLINE_BYTES, + video_processing: str | None = None, ) -> list[GeminiPart]: """Build Gemini parts for multimodal inputs (images, audio, video). @@ -421,17 +427,19 @@ async def build_gemini_media_parts( inline_bytes = 0 for kind, payload in units: if url_used < url_budget: - parts.append(await _media_url_part(cls, kind, payload)) + part = await _media_url_part(cls, kind, payload) url_used += 1 - continue - part, nbytes = _media_inline_part(kind, payload) - inline_bytes += nbytes - if inline_bytes > max_inline_bytes: - detail = f" after the first {url_budget} inputs are uploaded as URLs" if url_budget else "" - raise ValueError( - f"Too much media to send inline (over {max_inline_bytes // (1024 * 1024)}MB{detail}). " - "Reduce the number or size of attached media." - ) + else: + part, nbytes = _media_inline_part(kind, payload) + inline_bytes += nbytes + if inline_bytes > max_inline_bytes: + detail = f" after the first {url_budget} inputs are uploaded as URLs" if url_budget else "" + raise ValueError( + f"Too much media to send inline (over {max_inline_bytes // (1024 * 1024)}MB{detail}). " + "Reduce the number or size of attached media." + ) + if kind == "video" and video_processing == "agentic": + part.mediaProcessing = "AGENTIC" parts.append(part) return parts @@ -620,7 +628,38 @@ async def execute( return IO.NodeOutput(output_text or "Empty response from Gemini model...") -GEMINI_V2_MODELS: dict[str, str] = { +GEMINI_TEXT_PRICE_BADGE = IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["model"]), + expr=""" + ( + $m := widgets.model; + $contains($m, "lite") ? { + "type": "list_usd", + "usd": [0.00025, 0.0015], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + : ($contains($m, "3.7 flash") or $contains($m, "3.8 flash")) ? { + "type": "list_usd", + "usd": [0.002145, 0.010725], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + : $contains($m, "3.5 flash") ? { + "type": "list_usd", + "usd": [0.0015, 0.009], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + : { + "type": "list_usd", + "usd": [0.002, 0.012], + "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } + } + ) + """, +) + + +GEMINI_TEXT_MODELS: dict[str, str] = { + "Gemini 3.8 Flash": "gemini-3.8-flash", "Gemini 3.7 Flash": "gemini-3.7-flash", "Gemini 3.1 Pro": "gemini-3.1-pro-preview", "Gemini 3.5 Flash": "gemini-3.5-flash", @@ -628,8 +667,7 @@ async def execute( } -def _gemini_text_model_inputs(thinking_default: str, thinking_options: list[str] | None = None) -> list[Input]: - """Per-model inputs revealed by the model DynamicCombo (shared media + sampling controls).""" +def _gemini_media_inputs() -> list[Input]: return [ IO.Autogrow.Input( "images", @@ -658,19 +696,32 @@ def _gemini_text_model_inputs(thinking_default: str, thinking_options: list[str] ), tooltip="Optional video clip to use as context for the model.", ), - IO.Custom("GEMINI_INPUT_FILES").Input( - "files", - optional=True, - tooltip="Optional file(s) to use as context for the model. " - "Accepts inputs from the Gemini Input Files node.", - ), - IO.Combo.Input( - "thinking_level", - options=thinking_options or ["LOW", "HIGH"], - default=thinking_default, - tooltip="How hard the model reasons internally before answering. " - "HIGH improves quality on difficult tasks but costs more (thinking) tokens and is slower.", - ), + ] + + +def _gemini_files_input() -> Input: + return IO.Custom("GEMINI_INPUT_FILES").Input( + "files", + optional=True, + tooltip="Optional file(s) to use as context for the model. " + "Accepts inputs from the Gemini Input Files node.", + ) + + +def _gemini_thinking_input(thinking_default: str, thinking_options: list[str] | None) -> Input: + return IO.Combo.Input( + "thinking_level", + options=thinking_options or ["LOW", "HIGH"], + default=thinking_default, + tooltip="How hard the model reasons internally before answering. " + "HIGH improves quality on difficult tasks but costs more (thinking) tokens and is slower.", + ) + + +def _gemini_sampling_inputs(sampling: bool) -> list[Input]: + if not sampling: + return [] + return [ IO.Float.Input( "temperature", default=1.0, @@ -686,23 +737,150 @@ def _gemini_text_model_inputs(thinking_default: str, thinking_options: list[str] min=0.0, max=1.0, step=0.01, - tooltip="Nucleus sampling: sample from the smallest token set whose cumulative probability reaches top_p.", + tooltip="Nucleus sampling: sample from the smallest token set whose cumulative " + "probability reaches top_p.", advanced=True, ), + ] + + +def _gemini_max_output_tokens_input() -> Input: + return IO.Int.Input( + "max_output_tokens", + default=32768, + min=16, + max=65536, + tooltip="Maximum tokens to generate, including the model's internal thinking. " + "With thinking_level HIGH, a low value can leave no room for the answer; raise this if " + "responses come back empty or truncated. The model stops early when finished, so a higher " + "cap costs nothing extra for short replies.", + advanced=True, + ) + + +def _gemini_text_model_inputs( + thinking_default: str, + thinking_options: list[str] | None = None, + *, + sampling: bool = True, +) -> list[Input]: + return [ + *_gemini_media_inputs(), + _gemini_files_input(), + _gemini_thinking_input(thinking_default, thinking_options), + *_gemini_sampling_inputs(sampling), + _gemini_max_output_tokens_input(), + ] + + +def _gemini_v3_model_inputs( + thinking_default: str, + thinking_options: list[str] | None = None, + *, + sampling: bool = True, + agentic_video: bool = False, +) -> list[Input]: + video_processing_inputs: list[Input] = ( + [ + IO.Combo.Input( + "video_processing", + options=["static", "agentic"], + default="static", + tooltip="How the model reads the attached video. " + "`static` samples frames at a fixed rate and sends all of them as context; " + "`agentic` lets the model navigate the timeline itself and load only the frames, " + "audio or transcript it needs, which costs far fewer input tokens on long videos.", + ) + ] + if agentic_video + else [] + ) + return [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Text input to the model. Include detailed instructions, questions, or context.", + ), + *_gemini_media_inputs(), + *video_processing_inputs, + _gemini_files_input(), + _gemini_thinking_input(thinking_default, thinking_options), + *_gemini_sampling_inputs(sampling), + _gemini_max_output_tokens_input(), IO.Int.Input( - "max_output_tokens", - default=32768, - min=16, - max=65536, - tooltip="Maximum tokens to generate, including the model's internal thinking. " - "With thinking_level HIGH, a low value can leave no room for the answer; raise this if " - "responses come back empty or truncated. The model stops early when finished, so a higher " - "cap costs nothing extra for short replies.", + "seed", + default=42, + min=0, + max=2147483647, + control_after_generate=True, + tooltip="Seed for sampling. Set to 0 for a random seed. Deterministic output isn't guaranteed.", + ), + IO.String.Input( + "system_prompt", + multiline=True, + default="", advanced=True, + tooltip="Foundational instructions that dictate the model's behavior.", ), ] +async def _run_gemini_text( + cls: type[IO.ComfyNode], + *, + prompt: str, + model_id: str, + images: list[Input.Image], + audios: list[Input.Audio], + videos: list[Input.Video], + files: list[GeminiPart] | None, + thinking_level: str, + temperature: float | None, + top_p: float | None, + max_output_tokens: int, + seed: int, + system_prompt: str, + video_processing: str | None = None, +) -> str: + validate_string(prompt, strip_whitespace=True, min_length=1) + + parts: list[GeminiPart] = [GeminiPart(text=prompt)] + if images or audios or videos: + parts.extend( + await build_gemini_media_parts(cls, images, audios, videos, video_processing=video_processing) + ) + if files is not None: + parts.extend(files) + + gemini_system_prompt = None + if system_prompt: + gemini_system_prompt = GeminiSystemInstructionContent(parts=[GeminiTextPart(text=system_prompt)], role=None) + + response = await sync_op( + cls, + endpoint=ApiEndpoint(path=f"{GEMINI_BASE_ENDPOINT}/{model_id}", method="POST"), + data=GeminiGenerateContentRequest( + contents=[ + GeminiContent( + role=GeminiRole.user, + parts=parts, + ) + ], + generationConfig=GeminiGenerationConfig( + temperature=temperature, + topP=top_p, + maxOutputTokens=max_output_tokens, + seed=seed if seed > 0 else None, + thinkingConfig=GeminiThinkingConfig(thinkingLevel=thinking_level), + ), + systemInstruction=gemini_system_prompt, + ), + response_model=GeminiGenerateContentResponse, + ) + return get_text_from_response(response) or "Empty response from Gemini model..." + + class GeminiNodeV2(IO.ComfyNode): @classmethod @@ -711,7 +889,6 @@ def define_schema(cls): node_id="GeminiNodeV2", display_name="Google Gemini", category="partner/text/Gemini", - essentials_category="Text Generation", description="Generate text responses with Google's Gemini models. Provide a text prompt and, " "optionally, one or more images, audio clips, videos, or files as multimodal context.", inputs=[ @@ -724,6 +901,10 @@ def define_schema(cls): IO.DynamicCombo.Input( "model", options=[ + IO.DynamicCombo.Option( + "Gemini 3.8 Flash", + _gemini_text_model_inputs("MEDIUM", ["LOW", "MEDIUM", "HIGH"], sampling=False), + ), IO.DynamicCombo.Option( "Gemini 3.7 Flash", _gemini_text_model_inputs("MEDIUM", ["LOW", "MEDIUM", "HIGH"]), @@ -763,34 +944,8 @@ def define_schema(cls): IO.Hidden.unique_id, ], is_api_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model"]), - expr=""" - ( - $m := widgets.model; - $contains($m, "lite") ? { - "type": "list_usd", - "usd": [0.00025, 0.0015], - "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } - } - : $contains($m, "3.7 flash") ? { - "type": "list_usd", - "usd": [0.00215, 0.01073], - "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } - } - : $contains($m, "3.5 flash") ? { - "type": "list_usd", - "usd": [0.0015, 0.009], - "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } - } - : { - "type": "list_usd", - "usd": [0.002, 0.012], - "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" } - } - ) - """, - ), + price_badge=GEMINI_TEXT_PRICE_BADGE, + is_deprecated=True, ) @classmethod @@ -801,47 +956,92 @@ async def execute( seed: int, system_prompt: str = "", ) -> IO.NodeOutput: - validate_string(prompt, strip_whitespace=True, min_length=1) - model_id = GEMINI_V2_MODELS[model["model"]] + return IO.NodeOutput( + await _run_gemini_text( + cls, + prompt=prompt, + model_id=GEMINI_TEXT_MODELS[model["model"]], + images=[t for t in (model.get("images") or {}).values() if t is not None], + audios=[a for a in (model.get("audio") or {}).values() if a is not None], + videos=[v for v in (model.get("video") or {}).values() if v is not None], + files=model.get("files"), + thinking_level=model["thinking_level"], + temperature=model.get("temperature"), + top_p=model.get("top_p"), + max_output_tokens=model["max_output_tokens"], + seed=seed, + system_prompt=system_prompt, + ) + ) - parts: list[GeminiPart] = [GeminiPart(text=prompt)] - images = [t for t in (model.get("images") or {}).values() if t is not None] - audios = [a for a in (model.get("audio") or {}).values() if a is not None] - videos = [v for v in (model.get("video") or {}).values() if v is not None] - if images or audios or videos: - parts.extend(await build_gemini_media_parts(cls, images, audios, videos)) - files = model.get("files") - if files is not None: - parts.extend(files) - gemini_system_prompt = None - if system_prompt: - gemini_system_prompt = GeminiSystemInstructionContent(parts=[GeminiTextPart(text=system_prompt)], role=None) +class GeminiNodeV3(IO.ComfyNode): - response = await sync_op( - cls, - endpoint=ApiEndpoint(path=f"{GEMINI_BASE_ENDPOINT}/{model_id}", method="POST"), - data=GeminiGenerateContentRequest( - contents=[ - GeminiContent( - role=GeminiRole.user, - parts=parts, - ) - ], - generationConfig=GeminiGenerationConfig( - temperature=model["temperature"], - topP=model["top_p"], - maxOutputTokens=model["max_output_tokens"], - seed=seed if seed > 0 else None, - thinkingConfig=GeminiThinkingConfig(thinkingLevel=model["thinking_level"]), + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="GeminiNodeV3", + display_name="Google Gemini", + category="partner/text/Gemini", + essentials_category="Text Generation", + description="Generate text responses with Google's Gemini models. Provide a text prompt and, " + "optionally, one or more images, audio clips, videos, or files as multimodal context.", + inputs=[ + IO.DynamicCombo.Input( + "model", + options=[ + IO.DynamicCombo.Option( + "Gemini 3.8 Flash", + _gemini_v3_model_inputs( + "MEDIUM", ["LOW", "MEDIUM", "HIGH"], sampling=False, agentic_video=True + ), + ), + IO.DynamicCombo.Option( + "Gemini 3.7 Flash", + _gemini_v3_model_inputs("MEDIUM", ["LOW", "MEDIUM", "HIGH"]), + ), + IO.DynamicCombo.Option( + "Gemini 3.5 Flash", + _gemini_v3_model_inputs("MEDIUM", ["MINIMAL", "LOW", "MEDIUM", "HIGH"]), + ), + IO.DynamicCombo.Option("Gemini 3.1 Pro", _gemini_v3_model_inputs("HIGH")), + IO.DynamicCombo.Option("Gemini 3.1 Flash-Lite", _gemini_v3_model_inputs("LOW")), + ], + tooltip="The Gemini model used to generate the response.", ), - systemInstruction=gemini_system_prompt, - ), - response_model=GeminiGenerateContentResponse, + ], + outputs=[ + IO.String.Output(), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=GEMINI_TEXT_PRICE_BADGE, ) - output_text = get_text_from_response(response) - return IO.NodeOutput(output_text or "Empty response from Gemini model...") + @classmethod + async def execute(cls, model: dict) -> IO.NodeOutput: + return IO.NodeOutput( + await _run_gemini_text( + cls, + prompt=model["prompt"], + model_id=GEMINI_TEXT_MODELS[model["model"]], + images=[t for t in (model.get("images") or {}).values() if t is not None], + audios=[a for a in (model.get("audio") or {}).values() if a is not None], + videos=[v for v in (model.get("video") or {}).values() if v is not None], + files=model.get("files"), + thinking_level=model["thinking_level"], + temperature=model.get("temperature"), + top_p=model.get("top_p"), + max_output_tokens=model["max_output_tokens"], + seed=model["seed"], + system_prompt=model.get("system_prompt") or "", + video_processing=model.get("video_processing"), + ) + ) class GeminiInputFiles(IO.ComfyNode): @@ -1726,6 +1926,7 @@ async def execute(cls, model: dict, seed: int) -> IO.NodeOutput: ), ), response_model=GeminiInteraction, + asset_urls=True, ) if interaction.status != "completed": model_message = get_text_from_interaction(interaction).strip() @@ -1986,6 +2187,7 @@ async def execute(cls, model: dict) -> IO.NodeOutput: response_format=response_format, ), response_model=GeminiInteraction, + asset_urls=True, ) if interaction.status != "completed": model_message = get_text_from_interaction(interaction).strip() @@ -2005,6 +2207,7 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ GeminiNode, GeminiNodeV2, + GeminiNodeV3, GeminiImage, GeminiImage2, GeminiNanoBanana2, diff --git a/comfy_api_nodes/nodes_heygen.py b/comfy_api_nodes/nodes_heygen.py index 7ebd6a18593..b1375d14315 100644 --- a/comfy_api_nodes/nodes_heygen.py +++ b/comfy_api_nodes/nodes_heygen.py @@ -1,5 +1,3 @@ -import uuid - import torch from typing_extensions import override @@ -70,7 +68,7 @@ async def _create_and_poll_video(cls: type[IO.ComfyNode], payload: dict) -> dict """POST a /v3/videos payload, poll until terminal, and return the final video data.""" created = await sync_op_raw( cls, - ApiEndpoint(path=_VIDEOS_PATH, method="POST", headers={"Idempotency-Key": uuid.uuid4().hex}), + ApiEndpoint(path=_VIDEOS_PATH, method="POST"), data=payload, ) video_id = (created.get("data") or {}).get("video_id") diff --git a/comfy_api_nodes/nodes_ideogram.py b/comfy_api_nodes/nodes_ideogram.py index 2acf77b88c1..37fcd2a0e04 100644 --- a/comfy_api_nodes/nodes_ideogram.py +++ b/comfy_api_nodes/nodes_ideogram.py @@ -356,7 +356,6 @@ async def execute( data=edit_request, files=files, content_type="multipart/form-data", - max_retries=1, ) elif image is not None or mask is not None: @@ -400,7 +399,6 @@ async def execute( data=gen_request, files=files if files else None, content_type="multipart/form-data", - max_retries=1, ) if not response.data or len(response.data) == 0: @@ -514,7 +512,6 @@ async def execute( resolution=resolution.split(" ")[0] if resolution != "Auto" else None, rendering_speed=rendering_speed, ), - max_retries=1, ) if not response.data or len(response.data) == 0: @@ -649,7 +646,6 @@ async def execute( ApiEndpoint(path="/proxy/ideogram/text-to-image/p-image-ideogram", method="POST"), response_model=IdeogramGenerateResponse, data=request, - max_retries=1, ) if not response.data: raise Exception("No images were generated in the response") diff --git a/comfy_api_nodes/nodes_krea.py b/comfy_api_nodes/nodes_krea.py index b9e6268f2bd..fc2ed434b6f 100644 --- a/comfy_api_nodes/nodes_krea.py +++ b/comfy_api_nodes/nodes_krea.py @@ -35,7 +35,6 @@ async def _upload_image_to_krea_assets(cls: type[IO.ComfyNode], image: Input.Ima response_model=KreaAssetResponse, files=[("file", (img_io.name, img_io, "image/png"))], content_type="multipart/form-data", - max_retries=1, wait_label="Uploading reference", ) return response.image_url diff --git a/comfy_api_nodes/nodes_ltxv.py b/comfy_api_nodes/nodes_ltxv.py index 7804740cc59..3b9b5195756 100644 --- a/comfy_api_nodes/nodes_ltxv.py +++ b/comfy_api_nodes/nodes_ltxv.py @@ -59,7 +59,6 @@ async def _v25_submit_and_poll(cls: type[IO.ComfyNode], route: str, data: BaseMo ApiEndpoint(f"/proxy/ltx/v2/{route}", "POST"), response_model=Ltx25SubmitResponse, data=data, - max_retries=1, ) job = await poll_op( cls, diff --git a/comfy_api_nodes/nodes_meshy.py b/comfy_api_nodes/nodes_meshy.py index f4624c26fd8..701a28e60fd 100644 --- a/comfy_api_nodes/nodes_meshy.py +++ b/comfy_api_nodes/nodes_meshy.py @@ -26,6 +26,15 @@ ) +def validate_ultra_mode(model: str, ultra_mode: bool, ultra_resolution: str = "2k") -> None: + if not ultra_mode: + return + if model not in ("meshy-7.1", "meshy-7", "latest"): + raise ValueError("ultra_mode requires the meshy-7.1, meshy-7 or latest model") + if ultra_resolution == "4k" and model not in ("meshy-7.1", "latest"): + raise ValueError("4k ultra_resolution requires the meshy-7.1 or latest model") + + class MeshyTextToModelNode(IO.ComfyNode): @classmethod @@ -35,7 +44,7 @@ def define_schema(cls): display_name="Meshy: Text to Model", category="partner/3d/Meshy", inputs=[ - IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), + IO.Combo.Input("model", options=["meshy-7.1", "meshy-7", "meshy-6", "latest"]), IO.String.Input("prompt", multiline=True, default=""), IO.Combo.Input("style", options=["realistic"]), IO.DynamicCombo.Input( @@ -80,6 +89,13 @@ def define_schema(cls): default=False, tooltip="Run an extra refinement pass for higher-fidelity geometry with finer surface detail.", ), + IO.Combo.Input( + "ultra_resolution", + options=["2k", "4k"], + tooltip="Resolution of the ultra pass: 2k runs it at 2048³, 4k at 4096³ for the finest " + "surface detail. 4k requires meshy-7.1 or latest. Used only when ultra_mode is enabled.", + optional=True, + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only @@ -98,8 +114,9 @@ def define_schema(cls): depends_on=IO.PriceBadgeDepends(widgets=["model", "ultra_mode"]), expr=""" ( - $credits := 20 + ((widgets.ultra_mode and widgets.model in ["meshy-7", "latest"]) ? 5 : 0); - {"type":"usd","usd": $round($credits * 0.0572, 4)} + $credits := 20 + + ((widgets.ultra_mode and widgets.model in ["meshy-7.1", "meshy-7", "latest"]) ? 5 : 0); + {"type":"usd","usd": $credits * 0.0572} ) """, ), @@ -116,10 +133,10 @@ async def execute( pose_mode: str, seed: int, ultra_mode: bool, + ultra_resolution: str = "2k", ) -> IO.NodeOutput: validate_string(prompt, field_name="prompt", min_length=1, max_length=600) - if ultra_mode and model not in ("meshy-7", "latest"): - raise ValueError("ultra_mode requires the meshy-7 or latest model") + validate_ultra_mode(model, ultra_mode, ultra_resolution) response = await sync_op( cls, ApiEndpoint(path="/proxy/meshy/openapi/v2/text-to-3d", method="POST"), @@ -133,7 +150,8 @@ async def execute( should_remesh=should_remesh["should_remesh"] == "true", symmetry_mode=symmetry_mode, pose_mode=pose_mode.lower(), - ultra_mode=ultra_mode, + ultra_mode=True if ultra_mode and model == "meshy-7" else None, + geometry_resolution=ultra_resolution if ultra_mode and model != "meshy-7" else None, seed=seed, ), ) @@ -163,7 +181,7 @@ def define_schema(cls): category="partner/3d/Meshy", description="Refine a previously created draft model.", inputs=[ - IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), + IO.Combo.Input("model", options=["meshy-7.1", "meshy-7", "meshy-6", "latest"]), IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"), IO.Boolean.Input( "enable_pbr", @@ -207,7 +225,7 @@ def define_schema(cls): expr=""" ( $credits := widgets.texture_resolution = "8k" ? 15 : 10; - {"type":"usd","usd": $round($credits * 0.0572, 4)} + {"type":"usd","usd": $credits * 0.0572} ) """, ), @@ -268,7 +286,7 @@ def define_schema(cls): display_name="Meshy: Image to Model", category="partner/3d/Meshy", inputs=[ - IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), + IO.Combo.Input("model", options=["meshy-7.1", "meshy-7", "meshy-6", "latest"]), IO.Image.Input("image"), IO.DynamicCombo.Input( "should_remesh", @@ -350,6 +368,13 @@ def define_schema(cls): default=False, tooltip="Run an extra refinement pass for higher-fidelity geometry with finer surface detail.", ), + IO.Combo.Input( + "ultra_resolution", + options=["2k", "4k"], + tooltip="Resolution of the ultra pass: 2k runs it at 2048³, 4k at 4096³ for the finest " + "surface detail. 4k requires meshy-7.1 or latest. Used only when ultra_mode is enabled.", + optional=True, + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only @@ -374,8 +399,8 @@ def define_schema(cls): $resolution := $textured ? $lookup(widgets, "should_texture.texture_resolution") : "2k"; $credits := ($textured ? 30 : 20) + ($resolution = "8k" ? 5 : 0) - + ((widgets.ultra_mode and widgets.model in ["meshy-7", "latest"]) ? 5 : 0); - {"type":"usd","usd": $round($credits * 0.0572, 4)} + + ((widgets.ultra_mode and widgets.model in ["meshy-7.1", "meshy-7", "latest"]) ? 5 : 0); + {"type":"usd","usd": $credits * 0.0572} ) """, ), @@ -392,9 +417,9 @@ async def execute( pose_mode: str, seed: int, ultra_mode: bool, + ultra_resolution: str = "2k", ) -> IO.NodeOutput: - if ultra_mode and model not in ("meshy-7", "latest"): - raise ValueError("ultra_mode requires the meshy-7 or latest model") + validate_ultra_mode(model, ultra_mode, ultra_resolution) texture = should_texture["should_texture"] == "true" texture_image_url = texture_prompt = None if texture: @@ -424,7 +449,8 @@ async def execute( enable_pbr=should_texture.get("enable_pbr", None), texture_resolution=should_texture.get("texture_resolution", None), pose_mode=pose_mode.lower(), - ultra_mode=ultra_mode, + ultra_mode=True if ultra_mode and model == "meshy-7" else None, + geometry_resolution=ultra_resolution if ultra_mode and model != "meshy-7" else None, texture_prompt=texture_prompt, texture_image_url=texture_image_url, seed=seed, @@ -455,7 +481,7 @@ def define_schema(cls): display_name="Meshy: Multi-Image to Model", category="partner/3d/Meshy", inputs=[ - IO.Combo.Input("model", options=["meshy-7", "meshy-6", "latest"]), + IO.Combo.Input("model", options=["meshy-7.1", "meshy-7", "meshy-6", "latest"]), IO.Autogrow.Input( "images", template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=2, max=4), @@ -535,6 +561,13 @@ def define_schema(cls): tooltip="Seed controls whether the node should re-run; " "results are non-deterministic regardless of seed.", ), + IO.Boolean.Input( + "ultra_mode", + default=False, + tooltip="Run an extra refinement pass at 2048³ for higher-fidelity geometry " + "with finer surface detail.", + optional=True, + ), ], outputs=[ IO.String.Output(display_name="model_file"), # for backward compatibility only @@ -551,14 +584,16 @@ def define_schema(cls): is_output_node=True, price_badge=IO.PriceBadge( depends_on=IO.PriceBadgeDepends( - widgets=["should_texture", "should_texture.texture_resolution"], + widgets=["model", "should_texture", "should_texture.texture_resolution", "ultra_mode"], ), expr=""" ( $textured := widgets.should_texture = "true"; $resolution := $textured ? $lookup(widgets, "should_texture.texture_resolution") : "2k"; - $credits := ($textured ? 30 : 20) + ($resolution = "8k" ? 5 : 0); - {"type":"usd","usd": $round($credits * 0.0572, 4)} + $credits := ($textured ? 30 : 20) + + ($resolution = "8k" ? 5 : 0) + + ((widgets.ultra_mode and widgets.model in ["meshy-7.1", "meshy-7", "latest"]) ? 5 : 0); + {"type":"usd","usd": $credits * 0.0572} ) """, ), @@ -574,7 +609,9 @@ async def execute( should_texture: InputShouldTexture, pose_mode: str, seed: int, + ultra_mode: bool = False, ) -> IO.NodeOutput: + validate_ultra_mode(model, ultra_mode) texture = should_texture["should_texture"] == "true" texture_image_url = texture_prompt = None if texture: @@ -606,6 +643,8 @@ async def execute( enable_pbr=should_texture.get("enable_pbr", None), texture_resolution=should_texture.get("texture_resolution", None), pose_mode=pose_mode.lower(), + ultra_mode=True if ultra_mode and model == "meshy-7" else None, + geometry_resolution="2k" if ultra_mode and model != "meshy-7" else None, texture_prompt=texture_prompt, texture_image_url=texture_image_url, seed=seed, @@ -831,7 +870,7 @@ def define_schema(cls): expr=""" ( $credits := widgets.texture_resolution = "8k" ? 15 : 10; - {"type":"usd","usd": $round($credits * 0.0572, 4)} + {"type":"usd","usd": $credits * 0.0572} ) """, ), @@ -936,7 +975,7 @@ def define_schema(cls): expr=""" ( $credits := widgets.texture_resolution = "8k" ? 15 : 10; - {"type":"usd","usd": $round($credits * 0.0572, 4)} + {"type":"usd","usd": $credits * 0.0572} ) """, ), diff --git a/comfy_api_nodes/nodes_meta.py b/comfy_api_nodes/nodes_meta.py index dfba65ff033..e6c1bdc6ccb 100644 --- a/comfy_api_nodes/nodes_meta.py +++ b/comfy_api_nodes/nodes_meta.py @@ -16,6 +16,8 @@ from comfy_api_nodes.util import ( ApiEndpoint, bytesio_to_image_tensor, + download_url_to_image_tensor, + pad_images_to_common_channels, sync_op, upload_images_to_comfyapi, validate_string, @@ -57,15 +59,16 @@ def _size(aspect_ratio: str) -> str | None: return None if aspect_ratio == "auto" else aspect_ratio.replace(":", "x") -def _decode_images(response: MuseImageResponse) -> torch.Tensor: - images = [ - bytesio_to_image_tensor(BytesIO(base64.b64decode(item.b64_json))) - for item in response.data - if item.b64_json - ] +async def _decode_images(cls: type[IO.ComfyNode], response: MuseImageResponse) -> torch.Tensor: + images = [] + for item in response.data: + if item.b64_json: + images.append(bytesio_to_image_tensor(BytesIO(base64.b64decode(item.b64_json)))) + elif item.url: + images.append(await download_url_to_image_tensor(item.url, cls=cls)) if not images: raise Exception("The response contains no images.") - return torch.cat(images) + return torch.cat(pad_images_to_common_channels(images)) def _reasoning_strength_input() -> IO.Combo.Input: @@ -220,6 +223,7 @@ async def execute(cls, model: dict): cls, ApiEndpoint(path=GENERATIONS_PATH, method="POST"), response_model=MuseImageResponse, + asset_urls=True, data=MuseImageRequest( model=model["model"], prompt=model["prompt"], @@ -228,7 +232,7 @@ async def execute(cls, model: dict): tool_enablement=_tool_enablement(model), ), ) - return IO.NodeOutput(_decode_images(response)) + return IO.NodeOutput(await _decode_images(cls, response)) class MetaMuseImageEditApi(IO.ComfyNode): @@ -280,6 +284,7 @@ async def execute(cls, model: dict): cls, ApiEndpoint(path=EDITS_PATH, method="POST"), response_model=MuseImageResponse, + asset_urls=True, data=MuseImageEditRequest( model=model["model"], prompt=prompt, @@ -289,7 +294,7 @@ async def execute(cls, model: dict): images=[MuseImageInput(image_url=url) for url in urls], ), ) - return IO.NodeOutput(_decode_images(response)) + return IO.NodeOutput(await _decode_images(cls, response)) class MetaApiExtension(ComfyExtension): diff --git a/comfy_api_nodes/nodes_openai.py b/comfy_api_nodes/nodes_openai.py index 9260521f15d..23ba5119b98 100644 --- a/comfy_api_nodes/nodes_openai.py +++ b/comfy_api_nodes/nodes_openai.py @@ -84,12 +84,15 @@ class SupportedOpenAIModel(str, Enum): } -async def validate_and_cast_response(response, timeout: int = None) -> torch.Tensor: +async def validate_and_cast_response( + response, timeout: int = None, cls: type[IO.ComfyNode] = None +) -> torch.Tensor: """Validates and casts a response to a torch.Tensor. Args: response: The response to validate and cast. timeout: Request timeout in seconds. Defaults to None (no timeout). + cls: The calling node class; required for relative `/proxy/` URLs so they can be expanded and authenticated. Returns: A torch.Tensor of shape (N, H, W, C) with all returned images; images whose @@ -112,7 +115,7 @@ async def validate_and_cast_response(response, timeout: int = None) -> torch.Ten img_io = BytesIO(base64.b64decode(img_data.b64_json)) elif img_data.url: img_io = BytesIO() - await download_url_to_bytesio(img_data.url, img_io, timeout=timeout) + await download_url_to_bytesio(img_data.url, img_io, timeout=timeout, cls=cls) else: raise ValueError("Invalid image payload – neither URL nor base64 data present.") @@ -324,10 +327,7 @@ async def execute( if size not in ("auto", "1024x1024", "1024x1536", "1536x1024"): raise ValueError(f"Resolution {size} is only supported by GPT Image 2 model") - if model == "gpt-image-2": - if background == "transparent": - raise ValueError("Transparent background is not supported for GPT Image 2 model") - elif model not in ("gpt-image-1", "gpt-image-1.5"): + if model not in ("gpt-image-1", "gpt-image-1.5", "gpt-image-2"): raise ValueError(f"Unknown model: {model}") if image is not None: @@ -370,6 +370,7 @@ async def execute( cls, ApiEndpoint(path="/proxy/openai/images/edits", method="POST"), response_model=OpenAIImageGenerationResponse, + asset_urls=True, data=OpenAIImageEditRequest( model=model, prompt=prompt, @@ -388,6 +389,7 @@ async def execute( cls, ApiEndpoint(path="/proxy/openai/images/generations", method="POST"), response_model=OpenAIImageGenerationResponse, + asset_urls=True, data=OpenAIImageGenerationRequest( model=model, prompt=prompt, @@ -399,16 +401,21 @@ async def execute( moderation="low", ), ) - return IO.NodeOutput(await validate_and_cast_response(response)) + return IO.NodeOutput(await validate_and_cast_response(response, cls=cls)) -def _gpt_image_shared_inputs(): +GPT_IMAGE_QUALITIES = ("low", "medium", "high") +GPT_IMAGE_25_QUALITIES = ("low", "medium", "high", "xhigh", "max") +GPT_IMAGE_MODELS = ("gpt-image-2.5-flare", "gpt-image-2.5-sunburst", "gpt-image-2", "gpt-image-1.5", "gpt-image-1") + + +def _gpt_image_shared_inputs(qualities: tuple[str, ...] = GPT_IMAGE_QUALITIES): """Inputs shared by all GPT Image models (quality + reference images + mask).""" return [ IO.Combo.Input( "quality", default="low", - options=["low", "medium", "high"], + options=list(qualities), tooltip="Image quality, affects cost and generation time.", ), IO.Autogrow.Input( @@ -448,13 +455,58 @@ def _gpt_image_legacy_model_inputs(): ] +def _gpt_image_2_model_inputs(backgrounds: tuple[str, ...], qualities: tuple[str, ...]): + return [ + IO.Combo.Input( + "size", + default="auto", + options=[ + "auto", + "1024x1024", + "1024x1536", + "1536x1024", + "2048x2048", + "2048x1152", + "1152x2048", + "3840x2160", + "2160x3840", + "Custom", + ], + tooltip="Image size. Select 'Custom' to use the custom width and height.", + ), + IO.Int.Input( + "custom_width", + default=1024, + min=480, + max=3840, + step=16, + tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", + ), + IO.Int.Input( + "custom_height", + default=1024, + min=480, + max=3840, + step=16, + tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", + ), + IO.Combo.Input( + "background", + default="auto", + options=list(backgrounds), + tooltip="Return image with or without background.", + ), + *_gpt_image_shared_inputs(qualities), + ] + + class OpenAIGPTImageNodeV2(IO.ComfyNode): @classmethod def define_schema(cls): return IO.Schema( node_id="OpenAIGPTImageNodeV2", - display_name="OpenAI GPT Image 2", + display_name="OpenAI GPT Image 2.5", category="partner/image/OpenAI", description="Generates images via OpenAI's GPT Image endpoint.", inputs=[ @@ -467,50 +519,17 @@ def define_schema(cls): IO.DynamicCombo.Input( "model", options=[ + IO.DynamicCombo.Option( + "gpt-image-2.5-flare", + _gpt_image_2_model_inputs(("auto", "opaque", "transparent"), GPT_IMAGE_25_QUALITIES), + ), + IO.DynamicCombo.Option( + "gpt-image-2.5-sunburst", + _gpt_image_2_model_inputs(("auto", "opaque", "transparent"), GPT_IMAGE_25_QUALITIES), + ), IO.DynamicCombo.Option( "gpt-image-2", - [ - IO.Combo.Input( - "size", - default="auto", - options=[ - "auto", - "1024x1024", - "1024x1536", - "1536x1024", - "2048x2048", - "2048x1152", - "1152x2048", - "3840x2160", - "2160x3840", - "Custom", - ], - tooltip="Image size. Select 'Custom' to use the custom width and height.", - ), - IO.Int.Input( - "custom_width", - default=1024, - min=1024, - max=3840, - step=16, - tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", - ), - IO.Int.Input( - "custom_height", - default=1024, - min=1024, - max=3840, - step=16, - tooltip="Used only when `size` is 'Custom'. Must be a multiple of 16.", - ), - IO.Combo.Input( - "background", - default="auto", - options=["auto", "opaque"], - tooltip="Return image with or without background.", - ), - *_gpt_image_shared_inputs(), - ], + _gpt_image_2_model_inputs(("auto", "opaque", "transparent"), GPT_IMAGE_QUALITIES), ), IO.DynamicCombo.Option("gpt-image-1.5", _gpt_image_legacy_model_inputs()), IO.DynamicCombo.Option("gpt-image-1", _gpt_image_legacy_model_inputs()), @@ -544,7 +563,7 @@ def define_schema(cls): ], is_api_node=True, price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["model", "model.quality", "n"]), + depends_on=IO.PriceBadgeDepends(widgets=["model", "model.quality", "model.size", "n"], input_groups=["model.images"]), expr=""" ( $ranges := { @@ -559,22 +578,66 @@ def define_schema(cls): "high": [0.133, 0.22] }, "gpt-image-2": { - "low": [0.0058, 0.0228], - "medium": [0.0492, 0.2016], - "high": [0.198, 0.804] + "low": [0.0019, 0.0237], + "medium": [0.0186, 0.2135], + "high": [0.0744, 0.8539] + }, + "gpt-image-2.5-flare": { + "low": [0.0023, 0.0283], + "medium": [0.0056, 0.0636], + "high": [0.0222, 0.2544], + "xhigh": [0.0388, 0.4523], + "max": [0.0887, 1.0175] + }, + "gpt-image-2.5-sunburst": { + "low": [0.0023, 0.0283], + "medium": [0.0056, 0.0636], + "high": [0.0222, 0.2544], + "xhigh": [0.0388, 0.4523], + "max": [0.0887, 1.0175] + } + }; + $presets := { + "gpt-image-2": { + "low": {"1024x1024": 0.0071, "1024x1536": 0.0057, "1536x1024": 0.0057, "2048x2048": 0.0143, "2048x1152": 0.0057, "1152x2048": 0.0057, "3840x2160": 0.0134, "2160x3840": 0.0134}, + "medium": {"1024x1024": 0.0632, "1024x1536": 0.0494, "1536x1024": 0.0494, "2048x2048": 0.1284, "2048x1152": 0.0509, "1152x2048": 0.0509, "3840x2160": 0.1201, "2160x3840": 0.1201}, + "high": {"1024x1024": 0.2529, "1024x1536": 0.1976, "1536x1024": 0.1976, "2048x2048": 0.5138, "2048x1152": 0.2034, "1152x2048": 0.2034, "3840x2160": 0.4803, "2160x3840": 0.4803} + }, + "gpt-image-2.5": { + "low": {"1024x1024": 0.0084, "1024x1536": 0.0068, "1536x1024": 0.0068, "2048x2048": 0.0170, "2048x1152": 0.0067, "1152x2048": 0.0067, "3840x2160": 0.0159, "2160x3840": 0.0159}, + "medium": {"1024x1024": 0.0188, "1024x1536": 0.0147, "1536x1024": 0.0147, "2048x2048": 0.0383, "2048x1152": 0.0157, "1152x2048": 0.0157, "3840x2160": 0.0371, "2160x3840": 0.0371}, + "high": {"1024x1024": 0.0753, "1024x1536": 0.0589, "1536x1024": 0.0589, "2048x2048": 0.1531, "2048x1152": 0.0606, "1152x2048": 0.0606, "3840x2160": 0.1431, "2160x3840": 0.1431}, + "xhigh": {"1024x1024": 0.1339, "1024x1536": 0.1055, "1536x1024": 0.1055, "2048x2048": 0.2721, "2048x1152": 0.1077, "1152x2048": 0.1077, "3840x2160": 0.2544, "2160x3840": 0.2544}, + "max": {"1024x1024": 0.3013, "1024x1536": 0.2354, "1536x1024": 0.2354, "2048x2048": 0.6123, "2048x1152": 0.2424, "1152x2048": 0.2424, "3840x2160": 0.5724, "2160x3840": 0.5724} } }; - $range := $lookup($lookup($ranges, widgets.model), $lookup(widgets, "model.quality")); + $perImage := { + "gpt-image-1": [0.0019, 0.0019], + "gpt-image-1.5": [0.0016, 0.0016], + "gpt-image-2": [0.0098, 0.0147], + "gpt-image-2.5-flare": [0.0117, 0.0176], + "gpt-image-2.5-sunburst": [0.0117, 0.0176] + }; + $model := widgets.model; + $family := ($model = "gpt-image-2.5-flare" or $model = "gpt-image-2.5-sunburst") ? "gpt-image-2.5" : $model; + $qualityRaw := $lookup(widgets, "model.quality"); + $quality := ($qualityRaw != null) ? $qualityRaw : ""; + $sizeRaw := $lookup(widgets, "model.size"); + $size := ($sizeRaw != null) ? $sizeRaw : ""; + $range := $lookup($lookup($ranges, $model), $quality); + $preset := $lookup($lookup($lookup($presets, $family), $quality), $size); + $out := ($preset != null) ? [$preset, $preset] : $range; + $image := $lookup($perImage, $model); + $refsRaw := $lookup(inputGroups, "model.images"); + $refs := ($refsRaw != null) ? $refsRaw : 0; $nRaw := widgets.n; $n := ($nRaw != null and $nRaw != 0) ? $nRaw : 1; - ($n = 1) - ? {"type":"range_usd","min_usd": $range[0], "max_usd": $range[1], "format": {"approximate": true}} - : { - "type":"range_usd", - "min_usd": $range[0] * $n, - "max_usd": $range[1] * $n, - "format": { "suffix": "/Run", "approximate": true } - } + $min := ($out[0] + $refs * $image[0]) * $n; + $max := ($out[1] + $refs * $image[1]) * $n; + $format := ($n = 1) ? {"approximate": true} : {"suffix": "/Run", "approximate": true}; + ($min = $max) + ? {"type": "usd", "usd": $min, "format": $format} + : {"type": "range_usd", "min_usd": $min, "max_usd": $max, "format": $format} ) """, ), @@ -626,7 +689,7 @@ async def execute( ) size = f"{custom_width}x{custom_height}" - if model_id not in ("gpt-image-1", "gpt-image-1.5", "gpt-image-2"): + if model_id not in GPT_IMAGE_MODELS: raise ValueError(f"Unknown model: {model_id}") if image_tensors: @@ -674,6 +737,7 @@ async def execute( cls, ApiEndpoint(path="/proxy/openai/images/edits", method="POST"), response_model=OpenAIImageGenerationResponse, + asset_urls=True, data=OpenAIImageEditRequest( model=model_id, prompt=prompt, @@ -691,6 +755,7 @@ async def execute( cls, ApiEndpoint(path="/proxy/openai/images/generations", method="POST"), response_model=OpenAIImageGenerationResponse, + asset_urls=True, data=OpenAIImageGenerationRequest( model=model_id, prompt=prompt, @@ -701,7 +766,7 @@ async def execute( moderation="low", ), ) - return IO.NodeOutput(await validate_and_cast_response(response)) + return IO.NodeOutput(await validate_and_cast_response(response, cls=cls)) class OpenAIChatNode(IO.ComfyNode): diff --git a/comfy_api_nodes/nodes_openrouter.py b/comfy_api_nodes/nodes_openrouter.py index ee93a122813..04085d338a1 100644 --- a/comfy_api_nodes/nodes_openrouter.py +++ b/comfy_api_nodes/nodes_openrouter.py @@ -1,8 +1,11 @@ -"""API Nodes for OpenRouter LLM chat completions.""" +"""API Nodes for OpenRouter chat completions: LLM text generation and image generation.""" +import base64 from dataclasses import dataclass +from io import BytesIO from typing import Literal +import torch from typing_extensions import override from comfy_api.latest import IO, ComfyExtension, Input @@ -10,7 +13,11 @@ OpenRouterChatRequest, OpenRouterChatResponse, OpenRouterContentBlock, + OpenRouterError, OpenRouterImageContent, + OpenRouterImageData, + OpenRouterImageRequest, + OpenRouterImageResponse, OpenRouterImageUrl, OpenRouterMessage, OpenRouterReasoningConfig, @@ -21,7 +28,10 @@ ) from comfy_api_nodes.util import ( ApiEndpoint, + bytesio_to_image_tensor, + download_url_to_image_tensor, get_number_of_images, + pad_images_to_common_channels, sync_op, upload_images_to_comfyapi, upload_video_to_comfyapi, @@ -29,6 +39,7 @@ ) OPENROUTER_CHAT_ENDPOINT = "/proxy/openrouter/api/v1/chat/completions" +OPENROUTER_IMAGES_ENDPOINT = "/proxy/openrouter/api/v1/images" Profile = Literal["standard", "reasoning", "frontier_reasoning", "perplexity", "perplexity_reasoning"] @@ -251,10 +262,14 @@ def _build_request( ) +def _raise_on_error(error: OpenRouterError | None) -> None: + if error: + code = error.code if error.code is not None else "unknown" + raise ValueError(f"OpenRouter error ({code}): {error.message or 'no message'}") + + def _extract_text(response: OpenRouterChatResponse) -> str: - if response.error: - code = response.error.code if response.error.code is not None else "unknown" - raise ValueError(f"OpenRouter error ({code}): {response.error.message or 'no message'}") + _raise_on_error(response.error) if not response.choices: raise ValueError("Empty response from OpenRouter (no choices).") message = response.choices[0].message @@ -265,6 +280,25 @@ def _extract_text(response: OpenRouterChatResponse) -> str: return message.content or "" +async def _image_data_to_tensor(cls: type[IO.ComfyNode], item: OpenRouterImageData) -> torch.Tensor: + if item.b64_json: + try: + return bytesio_to_image_tensor(BytesIO(base64.b64decode(item.b64_json))) + except Exception as e: + raise ValueError(f"OpenRouter returned an image that could not be decoded: {e}") from e + if item.url: + return await download_url_to_image_tensor(item.url, cls=cls) + raise ValueError("OpenRouter returned an image with neither inline data nor a URL.") + + +async def _extract_images(cls: type[IO.ComfyNode], response: OpenRouterImageResponse) -> torch.Tensor: + _raise_on_error(response.error) + tensors = [await _image_data_to_tensor(cls, item) for item in response.data or [] if item.b64_json or item.url] + if not tensors: + raise ValueError("OpenRouter returned no image.") + return torch.cat(pad_images_to_common_channels(tensors)) + + class OpenRouterLLMNode(IO.ComfyNode): @classmethod @@ -370,10 +404,240 @@ async def execute( return IO.NodeOutput(_extract_text(response)) +@dataclass(frozen=True) +class _ImageModelSpec: + slug: str + price_text: float + price_image_in: float + price_image_out: float + + +IMAGE_MODELS: list[_ImageModelSpec] = [ + _ImageModelSpec("microsoft/mai-image-2.6", 0.000005, 0.000008, 0.000038), + _ImageModelSpec("microsoft/mai-image-2.6-flash", 0.00000175, 0.0000025, 0.000019), +] + +_IMAGE_MODELS_BY_SLUG: dict[str, _ImageModelSpec] = {m.slug: m for m in IMAGE_MODELS} +_IMAGE_SIZES: dict[str, dict[str, tuple[int, int]]] = { + "1K": { + "1:1": (1024, 1024), + "16:9": (1360, 768), + "9:16": (768, 1360), + "3:2": (1152, 768), + "2:3": (768, 1152), + "4:3": (1024, 768), + "3:4": (768, 1024), + }, + "1.5K": { + "1:1": (1536, 1536), + "16:9": (2048, 1152), + "9:16": (1152, 2048), + "3:2": (1872, 1248), + "2:3": (1248, 1872), + "4:3": (1760, 1312), + "3:4": (1312, 1760), + }, +} +_IMAGE_AUTO_ASPECT_RATIO = "auto" +_IMAGE_ASPECT_RATIOS = [*_IMAGE_SIZES["1K"], _IMAGE_AUTO_ASPECT_RATIO] +_IMAGE_AUTO_OUTPUT_SIZE = _IMAGE_SIZES["1.5K"]["1:1"] +_IMAGE_PROMPT_MAX_CHARS = 20000 +_IMAGE_MAX_REFERENCES = 5 +_IMAGE_REFERENCE_MAX_PIXELS = 2048 * 2048 +_IMAGE_REFERENCE_TEXT_OVERHEAD_TOKENS = 256 + + +def _image_tokens(width: int, height: int) -> int: + return width * height // 1024 + + +def _image_model_option(spec: _ImageModelSpec) -> IO.DynamicCombo.Option: + return IO.DynamicCombo.Option( + spec.slug, + [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip="Describes the image to generate, or the edit to apply to the reference images. " + f"Up to {_IMAGE_PROMPT_MAX_CHARS} characters.", + ), + IO.Combo.Input( + "aspect_ratio", + options=_IMAGE_ASPECT_RATIOS, + default="1:1", + tooltip="Aspect ratio of the generated image, also applied when reference images are connected. " + "'auto' lets the model choose the ratio for text to image (rendered at the 1.5K size) and keeps " + "the aspect ratio of the first reference image when editing.", + ), + IO.Combo.Input( + "resolution", + options=list(_IMAGE_SIZES), + default="1K", + tooltip="Output size tier. 1K is about 1 megapixel (1:1 is 1024x1024, 16:9 is 1360x768); " + "1.5K is about 2.3 megapixels (1:1 is 1536x1536, 16:9 is 2048x1152). Ignored when aspect_ratio is 'auto'.", + ), + IO.Autogrow.Input( + "images", + template=IO.Autogrow.TemplateNames( + IO.Image.Input("image"), + names=[f"image_{i}" for i in range(1, _IMAGE_MAX_REFERENCES + 1)], + min=0, + ), + tooltip=f"Up to {_IMAGE_MAX_REFERENCES} reference images for image-guided editing; " + "a batched input counts once per image.", + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + tooltip="Seed to determine if node should re-run; the API has no seed, " + "so actual results are nondeterministic regardless of this value.", + ), + ], + ) + + +def _image_price_badge_jsonata() -> str: + rates_pairs = [] + for spec in IMAGE_MODELS: + per_million = [spec.price_text * 1e6, spec.price_image_in * 1e6, spec.price_image_out * 1e6] + rates_pairs.append(f' "{spec.slug}": [{", ".join(f"{p:.8g}" for p in per_million)}]') + rates_block = ",\n".join(rates_pairs) + size_tables = [] + for tier, table in _IMAGE_SIZES.items(): + ratio_tokens = ", ".join(f'"{ratio}": {_image_tokens(w, h)}' for ratio, (w, h) in table.items()) + size_tables.append(f'"{tier.lower()}": {{{ratio_tokens}}}') + default_out = _image_tokens(*_IMAGE_SIZES["1K"]["1:1"]) + auto_out = _image_tokens(*_IMAGE_AUTO_OUTPUT_SIZE) + ref_max_tokens = _IMAGE_REFERENCE_MAX_PIXELS // 1024 + return ( + "(\n" + " $rates := {\n" + f"{rates_block}\n" + " };\n" + f" $outTokens := {{{', '.join(size_tables)}}};\n" + " $r := $lookup($rates, widgets.model);\n" + ' $ar := $lookup(widgets, "model.aspect_ratio");\n' + ' $res := $lookup(widgets, "model.resolution");\n' + ' $prompt := $lookup(widgets, "model.prompt");\n' + ' $links := $lookup(inputGroups, "model.images");\n' + ' $refs := $type($links) = "number" ? $links : 0;\n' + ' $promptTokens := $type($prompt) = "string"\n' + " ? ($length($prompt) + 2 * $count($match($prompt, /[^\\x00-\\x7F]/))) / 4 : 0;\n" + ' $table := $type($res) = "string" ? $lookup($outTokens, $res) : null;\n' + ' $sized := ($type($table) = "object" and $type($ar) = "string") ? $lookup($table, $ar) : null;\n' + f' $out := $ar = "{_IMAGE_AUTO_ASPECT_RATIO}" ? ($refs > 0 ? {default_out} : {auto_out})' + f' : ($type($sized) = "number" ? $sized : {default_out});\n' + " $r ? ($refs > 0 ? {\n" + ' "type": "range_usd",\n' + ' "min_usd": ($promptTokens * $r[0] + $out * $r[2]) * 1.43 / 1000000,\n' + f' "max_usd": (($promptTokens + $refs * {_IMAGE_REFERENCE_TEXT_OVERHEAD_TOKENS}) * $r[0]' + f" + $refs * {ref_max_tokens} * $r[1] + $out * $r[2]) * 1.43 / 1000000,\n" + ' "format": {"approximate": true}\n' + " } : {\n" + ' "type": "usd",\n' + ' "usd": ($promptTokens * $r[0] + $out * $r[2]) * 1.43 / 1000000,\n' + ' "format": {"approximate": true}\n' + ' }) : {"type": "text", "text": "Token-based"}\n' + ")" + ) + + +class OpenRouterImageNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="OpenRouterImageNode", + display_name="OpenRouter Image", + category="partner/image/OpenRouter", + description=( + "Generate or edit images through OpenRouter with Microsoft's MAI-Image-2.6 models: " + "text to image, or image-guided editing with up to five reference images, " + "in seven aspect ratios at 1K or 1.5K." + ), + inputs=[ + IO.DynamicCombo.Input( + "model", + options=[_image_model_option(spec) for spec in IMAGE_MODELS], + tooltip="The OpenRouter image model used to generate the image.", + ), + ], + outputs=[IO.Image.Output()], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends( + widgets=["model", "model.aspect_ratio", "model.resolution", "model.prompt"], + input_groups=["model.images"], + ), + expr=_image_price_badge_jsonata(), + ), + ) + + @classmethod + async def execute(cls, model: dict) -> IO.NodeOutput: + slug: str = model["model"] + if slug not in _IMAGE_MODELS_BY_SLUG: + raise ValueError(f"Unknown OpenRouter model: {slug}") + prompt: str = model["prompt"] + validate_string(prompt, strip_whitespace=True, min_length=1) + validate_string(prompt, strip_whitespace=False, max_length=_IMAGE_PROMPT_MAX_CHARS) + aspect_ratio: str = model["aspect_ratio"] + size: str | None = None + if aspect_ratio != _IMAGE_AUTO_ASPECT_RATIO: + width, height = _IMAGE_SIZES[model["resolution"]][aspect_ratio] + size = f"{width}x{height}" + + reference_images = [ + image for images in (model.get("images") or {}).values() if images is not None for image in images + ] + if len(reference_images) > _IMAGE_MAX_REFERENCES: + raise ValueError( + f"A maximum of {_IMAGE_MAX_REFERENCES} reference images is supported; got {len(reference_images)} " + "(a batched input counts once per image)." + ) + input_references: list[OpenRouterImageContent] | None = None + if reference_images: + urls = await upload_images_to_comfyapi( + cls, + [image[..., :3] for image in reference_images], + max_images=_IMAGE_MAX_REFERENCES, + mime_type="image/png", + total_pixels=_IMAGE_REFERENCE_MAX_PIXELS, + wait_label="Uploading reference images", + ) + input_references = [OpenRouterImageContent(image_url=OpenRouterImageUrl(url=url)) for url in urls] + + response = await sync_op( + cls, + ApiEndpoint(path=OPENROUTER_IMAGES_ENDPOINT, method="POST"), + response_model=OpenRouterImageResponse, + asset_urls=True, + data=OpenRouterImageRequest( + model=slug, + prompt=prompt, + aspect_ratio=aspect_ratio if size is None else None, + size=size, + input_references=input_references, + ), + ) + return IO.NodeOutput(await _extract_images(cls, response)) + + class OpenRouterExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: - return [OpenRouterLLMNode] + return [OpenRouterLLMNode, OpenRouterImageNode] async def comfy_entrypoint() -> OpenRouterExtension: diff --git a/comfy_api_nodes/nodes_pruna.py b/comfy_api_nodes/nodes_pruna.py new file mode 100644 index 00000000000..6b7119d4774 --- /dev/null +++ b/comfy_api_nodes/nodes_pruna.py @@ -0,0 +1,309 @@ +from typing_extensions import override + +from comfy_api.latest import IO, ComfyExtension, Input +from comfy_api_nodes.apis.pruna import ( + PrunaPredictionRequest, + PrunaPredictionResponse, + PrunaPredictionStatusResponse, + PrunaVideoInput, +) +from comfy_api_nodes.util import ( + ApiEndpoint, + download_url_to_video_output, + poll_op, + sync_op, + upload_audio_to_comfyapi, + upload_image_to_comfyapi, + validate_audio_duration, + validate_images_aspect_ratio_closeness, + validate_string, +) + +PREDICTIONS_PATH = "/proxy/pruna/v1/predictions" +VIDEO_MODELS = ["p-video-2"] +ASPECT_RATIOS = ["16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "1:1"] +RESOLUTIONS = ["720p", "1080p"] +FPS_OPTIONS = ["24", "48"] +DURATIONS = ["auto"] + [str(i) for i in range(1, 21)] +MAX_PROMPT_LENGTH = 5000 +MIN_AUDIO_DURATION = 1.0 + + +def _generation_inputs() -> list: + return [ + IO.Combo.Input( + "duration", + options=DURATIONS, + default="5", + tooltip="Length of the video in seconds. 'auto' lets the model choose the length from the prompt. " + "Ignored when audio is connected: the video then follows the audio length, rounded up to a whole " + "second, up to 20 seconds.", + ), + IO.Combo.Input( + "resolution", + options=RESOLUTIONS, + default="720p", + tooltip="Output resolution. 720p renders about 0.9 megapixels (1280x704 at 16:9), " + "1080p about 2 megapixels (1920x1088 at 16:9).", + ), + IO.Combo.Input( + "fps", + options=FPS_OPTIONS, + default="24", + tooltip="Frames per second. 48 fps is not available with draft at 1080p.", + ), + IO.Boolean.Input( + "draft", + default=False, + tooltip="Faster, less detailed render, billed at 60% of the standard rate.", + ), + IO.Boolean.Input( + "generate_audio", + default=True, + tooltip="Generate a soundtrack for the video. Ignored when audio is connected, " + "which becomes the soundtrack instead.", + ), + IO.Boolean.Input( + "enhance_prompt", + default=True, + advanced=True, + tooltip="Rewrite the prompt with more detail before generation; short prompts need it. " + "Turn it off to reproduce a result exactly with the same seed.", + ), + IO.Audio.Input( + "audio", + optional=True, + tooltip="Audio that drives the motion and becomes the soundtrack. At least 1 second long; " + "audio longer than 20 seconds is truncated. Sets the video length instead of duration.", + ), + IO.Int.Input( + "seed", + default=42, + min=0, + max=2147483647, + step=1, + display_mode=IO.NumberDisplay.number, + control_after_generate=True, + tooltip="Seed for the generation. The same seed reproduces a result exactly only when " + "enhance_prompt is off.", + ), + ] + + +def _text_to_video_option(model_id: str) -> IO.DynamicCombo.Option: + return IO.DynamicCombo.Option( + model_id, + [ + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip=f"Describes the video, its motion and its sound. Up to {MAX_PROMPT_LENGTH} characters.", + ), + IO.Combo.Input( + "aspect_ratio", + options=ASPECT_RATIOS, + default="16:9", + tooltip="Aspect ratio of the output video.", + ), + *_generation_inputs(), + ], + ) + + +def _image_to_video_option(model_id: str) -> IO.DynamicCombo.Option: + return IO.DynamicCombo.Option( + model_id, + [ + IO.Image.Input( + "first_frame", + tooltip="Image the video starts from. The output keeps the aspect ratio of this image.", + ), + IO.Image.Input( + "last_frame", + optional=True, + tooltip="Image the video ends on. Its aspect ratio must be close to the first frame's.", + ), + IO.String.Input( + "prompt", + multiline=True, + default="", + tooltip=f"Describes how the scene moves and sounds. Up to {MAX_PROMPT_LENGTH} characters.", + ), + *_generation_inputs(), + ], + ) + + +def _price_badge() -> IO.PriceBadge: + return IO.PriceBadge( + depends_on=IO.PriceBadgeDepends( + widgets=["model", "model.duration", "model.resolution", "model.draft"], + inputs=["model.audio"], + ), + expr=""" + ( + $draft := $lookup(widgets, "model.draft"); + $rate := $lookup(widgets, "model.resolution") = "1080p" + ? ($draft ? 0.0429 : 0.0715) + : ($draft ? 0.02145 : 0.03575); + $duration := $lookup(widgets, "model.duration"); + $audio := $lookup(inputs, "model.audio"); + ($audio and $audio.connected) or $type($duration) != "string" or $duration = "auto" + ? {"type":"usd","usd": $rate, "format": {"suffix": "/second"}} + : {"type":"usd","usd": $rate * $number($duration)} + ) + """, + ) + + +def _validate_generation_inputs(model: dict) -> None: + validate_string(model["prompt"], strip_whitespace=True, min_length=1, max_length=MAX_PROMPT_LENGTH) + if model["resolution"] == "1080p" and model["draft"] and model["fps"] == "48": + raise ValueError("48 fps is not available with draft at 1080p; turn off draft or use 24 fps.") + if model.get("audio") is not None: + validate_audio_duration(model["audio"], MIN_AUDIO_DURATION) + + +async def _upload_audio(cls: type[IO.ComfyNode], audio: Input.Audio | None) -> str | None: + if audio is None: + return None + return await upload_audio_to_comfyapi( + cls, audio, container_format="mp3", codec_name="libmp3lame", mime_type="audio/mpeg" + ) + + +def _video_input(model: dict, **media: str | None) -> PrunaVideoInput: + return PrunaVideoInput( + prompt=model["prompt"], + duration=None if model["duration"] == "auto" else int(model["duration"]), + resolution=model["resolution"], + fps=int(model["fps"]), + draft=model["draft"], + save_audio=model["generate_audio"], + prompt_upsampling=model["enhance_prompt"], + seed=model["seed"], + **media, + ) + + +async def _generate_video(cls: type[IO.ComfyNode], model_id: str, video_input: PrunaVideoInput) -> IO.NodeOutput: + submitted = await sync_op( + cls, + ApiEndpoint(path=f"{PREDICTIONS_PATH}/{model_id}", method="POST"), + response_model=PrunaPredictionResponse, + data=PrunaPredictionRequest(input=video_input), + ) + result = await poll_op( + cls, + ApiEndpoint(path=f"{PREDICTIONS_PATH}/status/{submitted.id}"), + response_model=PrunaPredictionStatusResponse, + status_extractor=lambda r: r.status, + completed_statuses=["succeeded"], + failed_statuses=["failed", "canceled"], + queued_statuses=["starting"], + ) + if not result.generation_url: + raise Exception("The prediction succeeded but returned no video.") + return IO.NodeOutput(await download_url_to_video_output(result.generation_url)) + + +class PrunaTextToVideoNode(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="PrunaTextToVideoNode", + display_name="Pruna P-Video-2 Text to Video", + category="partner/video/Pruna", + description="Generates a video from a text prompt using Pruna video models, " + "with optional generated sound or an audio track to drive it.", + inputs=[ + IO.DynamicCombo.Input( + "model", + options=[_text_to_video_option(model_id) for model_id in VIDEO_MODELS], + tooltip="Model to use.", + ), + ], + outputs=[ + IO.Video.Output(), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=_price_badge(), + ) + + @classmethod + async def execute(cls, model: dict): + _validate_generation_inputs(model) + audio_url = await _upload_audio(cls, model.get("audio")) + return await _generate_video( + cls, + model["model"], + _video_input(model, aspect_ratio=model["aspect_ratio"], audio=audio_url), + ) + + +class PrunaImageToVideoNode(IO.ComfyNode): + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="PrunaImageToVideoNode", + display_name="Pruna P-Video-2 Image to Video", + category="partner/video/Pruna", + description="Animates an image into a video using Pruna video models, " + "with an optional last frame, generated sound or audio track.", + inputs=[ + IO.DynamicCombo.Input( + "model", + options=[_image_to_video_option(model_id) for model_id in VIDEO_MODELS], + tooltip="Model to use.", + ), + ], + outputs=[ + IO.Video.Output(), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + price_badge=_price_badge(), + ) + + @classmethod + async def execute(cls, model: dict): + _validate_generation_inputs(model) + if model.get("last_frame") is not None: + validate_images_aspect_ratio_closeness(model["first_frame"], model["last_frame"], min_rel=0.8, max_rel=1.25) + first_frame_url = await upload_image_to_comfyapi( + cls, model["first_frame"], mime_type="image/png", wait_label="Uploading first frame" + ) + last_frame_url = None + if model.get("last_frame") is not None: + last_frame_url = await upload_image_to_comfyapi( + cls, model["last_frame"], mime_type="image/png", wait_label="Uploading last frame" + ) + audio_url = await _upload_audio(cls, model.get("audio")) + return await _generate_video( + cls, + model["model"], + _video_input(model, image=first_frame_url, last_frame_image=last_frame_url, audio=audio_url), + ) + + +class PrunaExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[IO.ComfyNode]]: + return [ + PrunaTextToVideoNode, + PrunaImageToVideoNode, + ] + + +async def comfy_entrypoint() -> PrunaExtension: + return PrunaExtension() diff --git a/comfy_api_nodes/nodes_recraft.py b/comfy_api_nodes/nodes_recraft.py index 1fc0503011c..cbad3771275 100644 --- a/comfy_api_nodes/nodes_recraft.py +++ b/comfy_api_nodes/nodes_recraft.py @@ -66,7 +66,6 @@ async def handle_recraft_file_request( files=files, content_type="multipart/form-data", multipart_parser=recraft_multipart_parser, - max_retries=1, ) all_bytesio = [] if response.image is not None: @@ -450,7 +449,6 @@ async def execute( files=files, data=RecraftCreateStyleRequest(style=style), content_type="multipart/form-data", - max_retries=1, ) return IO.NodeOutput(response.id) @@ -525,7 +523,6 @@ async def execute( model=model, ), content_type="multipart/form-data", - max_retries=1, ) return IO.NodeOutput(response.id) @@ -629,7 +626,6 @@ async def execute( style_id=recraft_style.style_id, controls=controls_api, ), - max_retries=1, ) images = [] for data in response.data: @@ -953,7 +949,6 @@ async def execute( substyle=recraft_style.substyle, controls=controls_api, ), - max_retries=1, ) svg_data = [] for data in response.data: @@ -1440,7 +1435,6 @@ async def execute( style_reference_urls=style_reference_urls, controls=recraft_controls.create_api_model() if recraft_controls else None, ), - max_retries=1, ) images = [] for data in response.data: @@ -1682,7 +1676,6 @@ async def execute( style_reference_urls=style_reference_urls, controls=recraft_controls.create_api_model() if recraft_controls else None, ), - max_retries=1, ) svg_data = [] for data in response.data: diff --git a/comfy_api_nodes/nodes_tripo.py b/comfy_api_nodes/nodes_tripo.py index 6b1bfec8ef9..d4378ee2e0b 100644 --- a/comfy_api_nodes/nodes_tripo.py +++ b/comfy_api_nodes/nodes_tripo.py @@ -13,14 +13,15 @@ TripoAnimateRigRequest, TripoConvertModelRequest, TripoEditMultiviewImageRequest, - TripoFileEmptyReference, TripoFileReference, + TripoFileResponse, TripoGenerateMultiviewImageRequest, TripoHighpolyToLowpolyRequest, TripoImageToModelRequest, TripoImportModelRequest, TripoMeshCompletionRequest, TripoMeshSegmentationRequest, + TripoMeshSmartSegmentRequest, TripoModelVersion, TripoMultiviewEditPrompt, TripoMultiviewToModelRequest, @@ -29,13 +30,16 @@ TripoP1ImageToModelRequest, TripoP1MultiviewToModelRequest, TripoP1TextToModelRequest, + TripoPSeriesImageToModelRequest, + TripoPSeriesMultiviewToModelRequest, + TripoPSeriesRequest, + TripoPSeriesTextToModelRequest, TripoRigModelVersion, TripoRigType, TripoSpec, TripoStyle, TripoTaskResponse, TripoTaskStatus, - TripoTaskType, TripoTextToModelRequest, TripoTextureModelRequest, TripoTextureModelVersion, @@ -48,13 +52,17 @@ download_url_to_image_tensor, poll_op, sync_op, + tensor_to_bytesio, upload_3d_model_to_comfyapi, upload_images_to_comfyapi, + validate_output_unlinked, + validate_string, ) MULTIVIEW_KEYS = ("front_view_url", "left_view_url", "back_view_url", "right_view_url") SEED_MAX = 2**31 - 1 TEXTURE_SOURCE_TYPES_WITH_IMAGE = ("text_to_model", "image_to_model", "multiview_to_model", "texture_model") +MIXAMO_RETARGET_ERROR = "Tripo cannot retarget animation presets onto a v1.0 rig made with the mixamo spec." FACE_LIMIT_TOOLTIP = ( @@ -64,10 +72,8 @@ def get_model_url_from_response(response: TripoTaskResponse) -> str: - if response.data is not None: - for key in ["pbr_model", "model", "base_model"]: - if getattr(response.data.output, key, None) is not None: - return getattr(response.data.output, key) + if response.data is not None and response.data.output is not None and response.data.output.model_url: + return response.data.output.model_url raise RuntimeError(f"Failed to get model url from response: {response}") @@ -81,7 +87,7 @@ async def poll_task( raise RuntimeError(f"Failed to create Tripo task: {response}") response_poll = await poll_op( node_cls, - poll_endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{response.data.task_id}"), + poll_endpoint=ApiEndpoint(path=f"/proxy/tripo/v3/tasks/{response.data.task_id}"), response_model=TripoTaskResponse, completed_statuses=[TripoTaskStatus.SUCCESS], failed_statuses=[ @@ -116,12 +122,12 @@ async def poll_until_finished( async def check_riggable(node_cls: type[IO.ComfyNode], model_task_id: str) -> tuple[bool, str]: response = await sync_op( node_cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/animations/rig-check", method="POST"), response_model=TripoTaskResponse, - data=TripoAnimatePrerigcheckRequest(original_model_task_id=model_task_id), + data=TripoAnimatePrerigcheckRequest(input=model_task_id), ) output = (await poll_task(node_cls, response, average_duration=5)).data.output - return bool(output.riggable), output.rig_type or output.topology or "" + return bool(output.riggable), output.rig_type or "" async def upload_image_reference(node_cls: type[IO.ComfyNode], image: Input.Image) -> TripoFileReference: @@ -129,9 +135,20 @@ async def upload_image_reference(node_cls: type[IO.ComfyNode], image: Input.Imag return TripoFileReference(root=TripoUrlReference(url=url, type="jpeg")) -async def multiview_output(node_cls: type[IO.ComfyNode], response: TripoTaskResponse, with_task_id: bool) -> IO.NodeOutput: +async def multiview_output( + node_cls: type[IO.ComfyNode], response: TripoTaskResponse, with_task_id: bool, source_task_id: str | None = None +) -> IO.NodeOutput: response_poll = await poll_task(node_cls, response, average_duration=25) - views = response_poll.data.output.generate_multiview_image or {} + views = dict(response_poll.data.output.generate_multiview_image or {}) + if source_task_id and any(not views.get(key) for key in MULTIVIEW_KEYS): + source = await sync_op( + node_cls, + endpoint=ApiEndpoint(path=f"/proxy/tripo/v3/tasks/{source_task_id}"), + response_model=TripoTaskResponse, + ) + for key, url in (source.data.output.generate_multiview_image or {}).items(): + if not views.get(key): + views[key] = url if any(not views.get(key) for key in MULTIVIEW_KEYS): raise RuntimeError(f"Tripo returned incomplete multiview images: {response_poll}") images = [await download_url_to_image_tensor(views[key], cls=node_cls) for key in MULTIVIEW_KEYS] @@ -164,136 +181,242 @@ def check_smart_low_poly_face_limit(smart_low_poly: bool | None, face_limit: int ) -def glb_or_fbx_output(task_id: str, model: Types.File3D) -> IO.NodeOutput: +def glb_or_fbx_output(task_id: str, model: Types.File3D, legacy: bool = True) -> IO.NodeOutput: if model.format not in ("glb", "fbx"): raise RuntimeError(f"Tripo returned a file of type {model.format.upper() or 'unknown'} where GLB or FBX was expected") - return IO.NodeOutput( - f"{task_id}.{model.format}", - task_id, - model if model.format == "glb" else None, - model if model.format == "fbx" else None, + outputs = (task_id, model if model.format == "glb" else None, model if model.format == "fbx" else None) + return IO.NodeOutput(f"{task_id}.{model.format}", *outputs) if legacy else IO.NodeOutput(*outputs) + + +def model_outputs( + legacy: bool, + glb_tooltip: str = "Empty when quad is enabled.", + fbx_tooltip: str = "Only populated when quad is enabled.", +) -> list: + outputs = [ + IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), + IO.File3DGLB.Output(display_name="GLB", tooltip=glb_tooltip), + IO.File3DFBX.Output(display_name="FBX", tooltip=fbx_tooltip), + ] + return [IO.String.Output(display_name="model_file"), *outputs] if legacy else outputs + + +def style_input() -> IO.Combo.Input: + return IO.Combo.Input( + "style", + options=TripoStyle, + default="None", + optional=True, + tooltip="No longer supported by Tripo and ignored. Kept for older workflows.", ) -class TripoTextToModelNode(IO.ComfyNode): - """ - Generates 3D models synchronously based on a text prompt using Tripo's API. - """ +def texture_inputs() -> list: + return [ + IO.Boolean.Input( + "texture", + default=True, + optional=True, + tooltip="Generate texture maps. Off returns bare geometry and ignores pbr.", + ), + IO.Boolean.Input( + "pbr", + default=True, + optional=True, + tooltip="PBR material maps (base color, metallic, roughness, normal). Requires texture.", + ), + ] - @classmethod - def define_schema(cls): - return IO.Schema( - node_id="TripoTextToModelNode", - display_name="Tripo: Text to Model", - category="partner/3d/Tripo", - inputs=[ - IO.String.Input("prompt", multiline=True), - IO.String.Input("negative_prompt", multiline=True, optional=True, tooltip="Up to 255 characters."), - IO.Combo.Input( - "model_version", options=TripoModelVersion, default=TripoModelVersion.v3_1_20260211, optional=True - ), - IO.Combo.Input( - "style", - options=TripoStyle, - default="None", - optional=True, - tooltip="No longer supported by Tripo and ignored. Kept for older workflows.", - ), - IO.Boolean.Input( - "texture", - default=True, - optional=True, - tooltip="Generate texture maps. Off returns bare geometry and ignores pbr.", - ), - IO.Boolean.Input( - "pbr", - default=True, - optional=True, - tooltip="PBR material maps (base color, metallic, roughness, normal). Requires texture.", - ), - IO.Int.Input("image_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Int.Input("model_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Combo.Input( - "texture_quality", - default="standard", - options=["standard", "detailed", "extreme"], - optional=True, - advanced=True, - tooltip="detailed = HD textures, extreme = 8K Ultra textures.", - ), - IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True, tooltip=FACE_LIMIT_TOOLTIP), - IO.Boolean.Input( - "quad", - default=False, - optional=True, - advanced=True, - tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " - "arrives on the FBX output and the GLB output stays empty.", - ), - IO.Combo.Input( - "geometry_quality", - default="standard", - options=["standard", "detailed"], - optional=True, - advanced=True, - ), - IO.Boolean.Input( - "smart_low_poly", - default=False, - optional=True, - advanced=True, - tooltip="Low-poly mesh with clean, hand-crafted style topology (500-20,000 faces, quad 500-10,000). " - "Best for simple subjects; complex ones may fail.", - ), - IO.Boolean.Input( - "auto_size", - default=True, - optional=True, - advanced=True, - tooltip="Scale textured models to their real-world size in meters. Tripo stores the size as the model's " - "scene transform and bakes it in when the model is converted, rigged or retargeted; ignored without texture.", - ), - ], - outputs=[ - IO.String.Output(display_name="model_file"), # for backward compatibility only - IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when quad is enabled."), - IO.File3DFBX.Output(display_name="FBX", tooltip="Only populated when quad is enabled."), - ], - hidden=[ - IO.Hidden.auth_token_comfy_org, - IO.Hidden.api_key_comfy_org, - IO.Hidden.unique_id, + +def seed_input(name: str) -> IO.Int.Input: + return IO.Int.Input(name, default=42, min=0, max=SEED_MAX, optional=True, advanced=True) + + +def texture_quality_input() -> IO.Combo.Input: + return IO.Combo.Input( + "texture_quality", + default="standard", + options=["standard", "detailed", "extreme"], + optional=True, + advanced=True, + tooltip="detailed = HD textures, extreme = 8K Ultra textures.", + ) + + +def texture_alignment_input() -> IO.Combo.Input: + return IO.Combo.Input( + "texture_alignment", + default="original_image", + options=["original_image", "geometry"], + optional=True, + advanced=True, + ) + + +def orientation_input() -> IO.Combo.Input: + return IO.Combo.Input( + "orientation", + options=TripoOrientation, + default=TripoOrientation.DEFAULT, + optional=True, + advanced=True, + ) + + +def geometry_inputs(auto_size: bool) -> list: + return [ + IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True, tooltip=FACE_LIMIT_TOOLTIP), + IO.Boolean.Input( + "quad", + default=False, + optional=True, + advanced=True, + tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " + "arrives on the FBX output and the GLB output stays empty.", + ), + IO.Combo.Input( + "geometry_quality", + default="standard", + options=["standard", "detailed"], + optional=True, + advanced=True, + ), + IO.Boolean.Input( + "smart_low_poly", + default=False, + optional=True, + advanced=True, + tooltip="Low-poly mesh with clean, hand-crafted style topology (500-20,000 faces, quad 500-10,000). " + "Best for simple subjects; complex ones may fail.", + ), + IO.Boolean.Input( + "auto_size", + default=auto_size, + optional=True, + advanced=True, + tooltip="Scale textured models to their real-world size in meters. Tripo stores the size as the model's " + "scene transform and bakes it in when the model is converted, rigged or retargeted; ignored without texture.", + ), + ] + + +def generation_price_badge(untextured: int, textured: int) -> IO.PriceBadge: + return IO.PriceBadge( + depends_on=IO.PriceBadgeDepends( + widgets=[ + "model_version", + "texture", + "quad", + "smart_low_poly", + "texture_quality", + "geometry_quality", ], - is_api_node=True, - is_output_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends( - widgets=[ - "model_version", - "texture", - "quad", - "smart_low_poly", - "texture_quality", - "geometry_quality", - ], - ), - expr=""" + ), + expr=f""" ( $isV3OrLater := $contains(widgets.model_version,"v3."); $tq := widgets.texture_quality; $textureAddon := widgets.texture ? ($tq = "extreme" ? 20 : ($tq = "detailed" ? 10 : 0)) : 0; $geometryAddon := (widgets.geometry_quality = "detailed" and $isV3OrLater) ? 20 : 0; - $credits := (widgets.texture ? 20 : 10) + $credits := (widgets.texture ? {textured} : {untextured}) + (widgets.quad ? 5 : 0) + (widgets.smart_low_poly ? 10 : 0) + $textureAddon + $geometryAddon; - {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}} + {{"type":"usd","usd": $credits * 0.01, "format": {{"approximate": true}}}} ) """, - ), + ) + + +def hidden_inputs() -> list: + return [ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ] + + +def text_to_model_inputs(legacy: bool) -> list: + return [ + IO.String.Input("prompt", multiline=True), + IO.String.Input("negative_prompt", multiline=True, optional=True, tooltip="Up to 255 characters."), + IO.Combo.Input( + "model_version", options=TripoModelVersion, default=TripoModelVersion.v3_1_20260211, optional=True + ), + *([style_input()] if legacy else []), + *texture_inputs(), + seed_input("image_seed"), + seed_input("model_seed"), + seed_input("texture_seed"), + texture_quality_input(), + *geometry_inputs(auto_size=True), + ] + + +async def text_to_model( + cls: type[IO.ComfyNode], + *, + prompt: str, + negative_prompt: str | None, + model_version, + texture: bool | None, + pbr: bool | None, + image_seed: int | None, + model_seed: int | None, + texture_seed: int | None, + texture_quality: str | None, + geometry_quality: str | None, + face_limit: int | None, + quad: bool | None, + smart_low_poly: bool | None, + auto_size: bool, +) -> tuple[str, Types.File3D]: + if not prompt.strip(): + raise RuntimeError("Prompt is required") + check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v3/generation/text-to-model", method="POST"), + response_model=TripoTaskResponse, + data=TripoTextToModelRequest( + prompt=prompt, + negative_prompt=negative_prompt if negative_prompt else None, + model=model_version or TripoModelVersion.v3_1_20260211, + texture=texture, + pbr=False if texture is False else pbr, + image_seed=image_seed, + model_seed=model_seed, + texture_seed=texture_seed, + texture_quality=texture_quality, + face_limit=face_limit if face_limit != -1 else None, + geometry_quality=geometry_quality, + auto_size=auto_size, + quad=quad, + smart_low_poly=smart_low_poly, + ), + ) + return await poll_until_finished(cls, response, average_duration=80) + + +class TripoTextToModelNode(IO.ComfyNode): + """ + Generates 3D models synchronously based on a text prompt using Tripo's API. + """ + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoTextToModelNode", + display_name="Tripo: Text to Model (Legacy)", + category="partner/3d/Tripo", + inputs=text_to_model_inputs(legacy=True), + outputs=model_outputs(legacy=True), + hidden=hidden_inputs(), + is_api_node=True, + is_output_node=True, + is_deprecated=True, + price_badge=generation_price_badge(untextured=10, textured=20), ) @classmethod @@ -315,168 +438,167 @@ async def execute( smart_low_poly: bool | None = None, auto_size: bool = True, ) -> IO.NodeOutput: - if not prompt.strip(): - raise RuntimeError("Prompt is required") - check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) - response = await sync_op( - cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), - response_model=TripoTaskResponse, - data=TripoTextToModelRequest( - type=TripoTaskType.TEXT_TO_MODEL, + return glb_or_fbx_output( + *await text_to_model( + cls, prompt=prompt, - negative_prompt=negative_prompt if negative_prompt else None, + negative_prompt=negative_prompt, model_version=model_version, texture=texture, - pbr=False if texture is False else pbr, + pbr=pbr, image_seed=image_seed, model_seed=model_seed, texture_seed=texture_seed, texture_quality=texture_quality, - face_limit=face_limit if face_limit != -1 else None, geometry_quality=geometry_quality, - auto_size=auto_size, + face_limit=face_limit, quad=quad, smart_low_poly=smart_low_poly, - ), + auto_size=auto_size, + ) ) - return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=80)) -class TripoImageToModelNode(IO.ComfyNode): - """ - Generates 3D models synchronously based on a single image using Tripo's API. - """ +class TripoTextToModelNodeV2(IO.ComfyNode): @classmethod def define_schema(cls): return IO.Schema( - node_id="TripoImageToModelNode", - display_name="Tripo: Image to Model", + node_id="TripoTextToModelNodeV2", + display_name="Tripo: Text to Model", category="partner/3d/Tripo", - inputs=[ - IO.Image.Input("image"), - IO.Combo.Input( - "model_version", - options=TripoModelVersion, - tooltip="The model version to use for generation", - optional=True, - ), - IO.Combo.Input( - "style", - options=TripoStyle, - default="None", - optional=True, - tooltip="No longer supported by Tripo and ignored. Kept for older workflows.", - ), - IO.Boolean.Input( - "texture", - default=True, - optional=True, - tooltip="Generate texture maps. Off returns bare geometry and ignores pbr.", - ), - IO.Boolean.Input( - "pbr", - default=True, - optional=True, - tooltip="PBR material maps (base color, metallic, roughness, normal). Requires texture.", - ), - IO.Int.Input("model_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Combo.Input( - "orientation", - options=TripoOrientation, - default=TripoOrientation.DEFAULT, - optional=True, - advanced=True, - ), - IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Combo.Input( - "texture_quality", - default="standard", - options=["standard", "detailed", "extreme"], - optional=True, - advanced=True, - tooltip="detailed = HD textures, extreme = 8K Ultra textures.", - ), - IO.Combo.Input( - "texture_alignment", - default="original_image", - options=["original_image", "geometry"], - optional=True, - advanced=True, - ), - IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True, tooltip=FACE_LIMIT_TOOLTIP), - IO.Boolean.Input( - "quad", - default=False, - optional=True, - advanced=True, - tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " - "arrives on the FBX output and the GLB output stays empty.", - ), - IO.Combo.Input( - "geometry_quality", - default="standard", - options=["standard", "detailed"], - optional=True, - advanced=True, - ), - IO.Boolean.Input( - "smart_low_poly", - default=False, - optional=True, - advanced=True, - tooltip="Low-poly mesh with clean, hand-crafted style topology (500-20,000 faces, quad 500-10,000). " - "Best for simple subjects; complex ones may fail.", - ), - IO.Boolean.Input( - "auto_size", - default=True, - optional=True, - advanced=True, - tooltip="Scale textured models to their real-world size in meters. Tripo stores the size as the model's " - "scene transform and bakes it in when the model is converted, rigged or retargeted; ignored without texture.", - ), - ], - outputs=[ - IO.String.Output(display_name="model_file"), # for backward compatibility only - IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when quad is enabled."), - IO.File3DFBX.Output(display_name="FBX", tooltip="Only populated when quad is enabled."), - ], - hidden=[ - IO.Hidden.auth_token_comfy_org, - IO.Hidden.api_key_comfy_org, - IO.Hidden.unique_id, - ], + inputs=text_to_model_inputs(legacy=False), + outputs=model_outputs(legacy=False), + hidden=hidden_inputs(), + is_api_node=True, + is_output_node=True, + price_badge=generation_price_badge(untextured=10, textured=20), + ) + + @classmethod + async def execute( + cls, + prompt: str, + negative_prompt: str | None = None, + model_version=None, + texture: bool | None = None, + pbr: bool | None = None, + image_seed: int | None = None, + model_seed: int | None = None, + texture_seed: int | None = None, + texture_quality: str | None = None, + geometry_quality: str | None = None, + face_limit: int | None = None, + quad: bool | None = None, + smart_low_poly: bool | None = None, + auto_size: bool = True, + ) -> IO.NodeOutput: + return glb_or_fbx_output( + *await text_to_model( + cls, + prompt=prompt, + negative_prompt=negative_prompt, + model_version=model_version, + texture=texture, + pbr=pbr, + image_seed=image_seed, + model_seed=model_seed, + texture_seed=texture_seed, + texture_quality=texture_quality, + geometry_quality=geometry_quality, + face_limit=face_limit, + quad=quad, + smart_low_poly=smart_low_poly, + auto_size=auto_size, + ), + legacy=False, + ) + + +def image_to_model_inputs(legacy: bool) -> list: + return [ + IO.Image.Input("image"), + IO.Combo.Input( + "model_version", + options=TripoModelVersion, + tooltip="The model version to use for generation", + optional=True, + ), + *([style_input()] if legacy else []), + *texture_inputs(), + seed_input("model_seed"), + orientation_input(), + seed_input("texture_seed"), + texture_quality_input(), + texture_alignment_input(), + *geometry_inputs(auto_size=True), + ] + + +async def image_to_model( + cls: type[IO.ComfyNode], + *, + image: Input.Image, + model_version: str | None, + texture: bool | None, + pbr: bool | None, + model_seed: int | None, + orientation, + texture_seed: int | None, + texture_quality: str | None, + geometry_quality: str | None, + texture_alignment: str | None, + face_limit: int | None, + quad: bool | None, + smart_low_poly: bool | None, + auto_size: bool, +) -> tuple[str, Types.File3D]: + if image is None: + raise RuntimeError("Image is required") + check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) + image_url = (await upload_images_to_comfyapi(cls, image, max_images=1))[0] + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v3/generation/image-to-model", method="POST"), + response_model=TripoTaskResponse, + data=TripoImageToModelRequest( + input=image_url, + model=model_version or TripoModelVersion.v3_1_20260211, + texture=texture, + pbr=False if texture is False else pbr, + model_seed=model_seed, + orientation=orientation, + geometry_quality=geometry_quality, + texture_alignment=texture_alignment, + texture_seed=texture_seed, + texture_quality=texture_quality, + face_limit=face_limit if face_limit != -1 else None, + auto_size=auto_size, + quad=quad, + smart_low_poly=smart_low_poly, + ), + ) + return await poll_until_finished(cls, response, average_duration=80) + + +class TripoImageToModelNode(IO.ComfyNode): + """ + Generates 3D models synchronously based on a single image using Tripo's API. + """ + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoImageToModelNode", + display_name="Tripo: Image to Model (Legacy)", + category="partner/3d/Tripo", + inputs=image_to_model_inputs(legacy=True), + outputs=model_outputs(legacy=True), + hidden=hidden_inputs(), is_api_node=True, is_output_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends( - widgets=[ - "model_version", - "texture", - "quad", - "smart_low_poly", - "texture_quality", - "geometry_quality", - ], - ), - expr=""" - ( - $isV3OrLater := $contains(widgets.model_version,"v3."); - $tq := widgets.texture_quality; - $textureAddon := widgets.texture ? ($tq = "extreme" ? 20 : ($tq = "detailed" ? 10 : 0)) : 0; - $geometryAddon := (widgets.geometry_quality = "detailed" and $isV3OrLater) ? 20 : 0; - $credits := (widgets.texture ? 30 : 20) - + (widgets.quad ? 5 : 0) - + (widgets.smart_low_poly ? 10 : 0) - + $textureAddon - + $geometryAddon; - {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}} - ) - """, - ), + is_deprecated=True, + price_badge=generation_price_badge(untextured=20, textured=30), ) @classmethod @@ -498,38 +620,81 @@ async def execute( smart_low_poly: bool | None = None, auto_size: bool = True, ) -> IO.NodeOutput: - if image is None: - raise RuntimeError("Image is required") - check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) - tripo_file = TripoFileReference( - root=TripoUrlReference( - url=(await upload_images_to_comfyapi(cls, image, max_images=1))[0], - type="jpeg", - ) - ) - response = await sync_op( - cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), - response_model=TripoTaskResponse, - data=TripoImageToModelRequest( - type=TripoTaskType.IMAGE_TO_MODEL, - file=tripo_file, + return glb_or_fbx_output( + *await image_to_model( + cls, + image=image, model_version=model_version, texture=texture, - pbr=False if texture is False else pbr, + pbr=pbr, model_seed=model_seed, orientation=orientation, + texture_seed=texture_seed, + texture_quality=texture_quality, geometry_quality=geometry_quality, texture_alignment=texture_alignment, + face_limit=face_limit, + quad=quad, + smart_low_poly=smart_low_poly, + auto_size=auto_size, + ) + ) + + +class TripoImageToModelNodeV2(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoImageToModelNodeV2", + display_name="Tripo: Image to Model", + category="partner/3d/Tripo", + inputs=image_to_model_inputs(legacy=False), + outputs=model_outputs(legacy=False), + hidden=hidden_inputs(), + is_api_node=True, + is_output_node=True, + price_badge=generation_price_badge(untextured=20, textured=30), + ) + + @classmethod + async def execute( + cls, + image: Input.Image, + model_version: str | None = None, + texture: bool | None = None, + pbr: bool | None = None, + model_seed: int | None = None, + orientation=None, + texture_seed: int | None = None, + texture_quality: str | None = None, + geometry_quality: str | None = None, + texture_alignment: str | None = None, + face_limit: int | None = None, + quad: bool | None = None, + smart_low_poly: bool | None = None, + auto_size: bool = True, + ) -> IO.NodeOutput: + return glb_or_fbx_output( + *await image_to_model( + cls, + image=image, + model_version=model_version, + texture=texture, + pbr=pbr, + model_seed=model_seed, + orientation=orientation, texture_seed=texture_seed, texture_quality=texture_quality, - face_limit=face_limit if face_limit != -1 else None, - auto_size=auto_size, + geometry_quality=geometry_quality, + texture_alignment=texture_alignment, + face_limit=face_limit, quad=quad, smart_low_poly=smart_low_poly, + auto_size=auto_size, ), + legacy=False, ) - return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=80)) class TripoMultiviewToModelNode(IO.ComfyNode): @@ -554,114 +719,19 @@ def define_schema(cls): optional=True, tooltip="The model version to use for generation", ), - IO.Combo.Input( - "orientation", - options=TripoOrientation, - default=TripoOrientation.DEFAULT, - optional=True, - advanced=True, - ), - IO.Boolean.Input( - "texture", - default=True, - optional=True, - tooltip="Generate texture maps. Off returns bare geometry and ignores pbr.", - ), - IO.Boolean.Input( - "pbr", - default=True, - optional=True, - tooltip="PBR material maps (base color, metallic, roughness, normal). Requires texture.", - ), - IO.Int.Input("model_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Combo.Input( - "texture_quality", - default="standard", - options=["standard", "detailed", "extreme"], - optional=True, - advanced=True, - tooltip="detailed = HD textures, extreme = 8K Ultra textures.", - ), - IO.Combo.Input( - "texture_alignment", - default="original_image", - options=["original_image", "geometry"], - optional=True, - advanced=True, - ), - IO.Int.Input("face_limit", default=-1, min=-1, max=2000000, optional=True, advanced=True, tooltip=FACE_LIMIT_TOOLTIP), - IO.Boolean.Input( - "quad", - default=False, - optional=True, - advanced=True, - tooltip="Quad mesh output. Tripo delivers quad meshes as FBX, so the result " - "arrives on the FBX output and the GLB output stays empty.", - ), - IO.Combo.Input( - "geometry_quality", - default="standard", - options=["standard", "detailed"], - optional=True, - advanced=True, - ), - IO.Boolean.Input( - "smart_low_poly", - default=False, - optional=True, - advanced=True, - tooltip="Low-poly mesh with clean, hand-crafted style topology (500-20,000 faces, quad 500-10,000). " - "Best for simple subjects; complex ones may fail.", - ), - IO.Boolean.Input( - "auto_size", - default=False, - optional=True, - advanced=True, - tooltip="Scale textured models to their real-world size in meters. Tripo stores the size as the model's " - "scene transform and bakes it in when the model is converted, rigged or retargeted; ignored without texture.", - ), - ], - outputs=[ - IO.String.Output(display_name="model_file"), # for backward compatibility only - IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when quad is enabled."), - IO.File3DFBX.Output(display_name="FBX", tooltip="Only populated when quad is enabled."), - ], - hidden=[ - IO.Hidden.auth_token_comfy_org, - IO.Hidden.api_key_comfy_org, - IO.Hidden.unique_id, + orientation_input(), + *texture_inputs(), + seed_input("model_seed"), + seed_input("texture_seed"), + texture_quality_input(), + texture_alignment_input(), + *geometry_inputs(auto_size=False), ], + outputs=model_outputs(legacy=True), + hidden=hidden_inputs(), is_api_node=True, is_output_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends( - widgets=[ - "model_version", - "texture", - "quad", - "smart_low_poly", - "texture_quality", - "geometry_quality", - ], - ), - expr=""" - ( - $isV3OrLater := $contains(widgets.model_version,"v3."); - $tq := widgets.texture_quality; - $textureAddon := widgets.texture ? ($tq = "extreme" ? 20 : ($tq = "detailed" ? 10 : 0)) : 0; - $geometryAddon := (widgets.geometry_quality = "detailed" and $isV3OrLater) ? 20 : 0; - $credits := (widgets.texture ? 30 : 20) - + (widgets.quad ? 5 : 0) - + (widgets.smart_low_poly ? 10 : 0) - + $textureAddon - + $geometryAddon; - {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}} - ) - """, - ), + price_badge=generation_price_badge(untextured=20, textured=30), ) @classmethod @@ -688,30 +758,19 @@ async def execute( if image is None: raise RuntimeError("front image for multiview is required") images = [] - image_dict = {"image": image, "image_left": image_left, "image_back": image_back, "image_right": image_right} if image_left is None and image_back is None and image_right is None: raise RuntimeError("At least one of left, back, or right image must be provided for multiview") check_smart_low_poly_face_limit(smart_low_poly, face_limit, quad) - for image_name in ["image", "image_left", "image_back", "image_right"]: - image_ = image_dict[image_name] + for view, image_ in zip(("front", "left", "back", "right"), (image, image_left, image_back, image_right)): if image_ is not None: - images.append( - TripoFileReference( - root=TripoUrlReference( - url=(await upload_images_to_comfyapi(cls, image_, max_images=1))[0], type="jpeg" - ) - ) - ) - else: - images.append(TripoFileEmptyReference()) + images.append({view: (await upload_images_to_comfyapi(cls, image_, max_images=1))[0]}) response = await sync_op( cls, - ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + ApiEndpoint(path="/proxy/tripo/v3/generation/multiview-to-model", method="POST"), response_model=TripoTaskResponse, data=TripoMultiviewToModelRequest( - type=TripoTaskType.MULTIVIEW_TO_MODEL, - files=images, - model_version=model_version, + inputs=images, + model=model_version or TripoModelVersion.v3_1_20260211, orientation=orientation, texture=texture, pbr=False if texture is False else pbr, @@ -762,9 +821,9 @@ def define_schema(cls): async def execute(cls, image: Input.Image) -> IO.NodeOutput: response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/generation/image-to-multiview", method="POST"), response_model=TripoTaskResponse, - data=TripoGenerateMultiviewImageRequest(file=await upload_image_reference(cls, image)), + data=TripoGenerateMultiviewImageRequest(input=(await upload_images_to_comfyapi(cls, image, max_images=1))[0]), ) return await multiview_output(cls, response, with_task_id=True) @@ -826,126 +885,186 @@ async def execute( raise ValueError("Provide an edit instruction for at least one view.") response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/generation/edit-multiview", method="POST"), response_model=TripoTaskResponse, - data=TripoEditMultiviewImageRequest(original_task_id=multiview_task_id, prompts=prompts), + data=TripoEditMultiviewImageRequest(input=multiview_task_id, prompts=prompts), ) - return await multiview_output(cls, response, with_task_id=False) - + return await multiview_output(cls, response, with_task_id=False, source_task_id=multiview_task_id) -class TripoTextureNode(IO.ComfyNode): - @classmethod - def define_schema(cls): - return IO.Schema( - node_id="TripoTextureNode", - display_name="Tripo: Texture model", - category="partner/3d/Tripo", - inputs=[ - IO.Custom("MODEL_TASK_ID,SEGMENT_TASK_ID").Input("model_task_id"), +def texture_model_inputs(legacy: bool) -> list: + return [ + IO.Custom("MODEL_TASK_ID,SEGMENT_TASK_ID").Input("model_task_id"), + *( + [ IO.Boolean.Input( "texture", default=True, optional=True, tooltip="Ignored: this node always generates textures. Kept for older workflows.", - ), - IO.Boolean.Input( - "pbr", - default=True, - optional=True, - tooltip="PBR material maps (base color, metallic, roughness, normal); off gives a plain color texture.", - ), - IO.Int.Input("texture_seed", default=42, min=0, max=SEED_MAX, optional=True, advanced=True), - IO.Combo.Input( - "texture_quality", - default="standard", - options=["standard", "detailed", "extreme"], - optional=True, - advanced=True, - tooltip="detailed = HD textures, extreme = 8K Ultra textures.", - ), - IO.Combo.Input( - "texture_alignment", - default="original_image", - options=["original_image", "geometry"], - optional=True, - advanced=True, - ), - IO.String.Input( - "texture_prompt", - default="", - multiline=True, - optional=True, - tooltip="Optional text guidance for texturing. Required in practice for imported " - "models (Tripo: Import Model), which carry no source image to infer colors from. " - "Cannot be combined with reference images.", - ), - IO.Combo.Input( - "model_version", - options=TripoTextureModelVersion, - default=TripoTextureModelVersion.v3_0_20250812, - optional=True, - tooltip="Texture model: v3.0 for meshes generated with v3.x, v2.5 for meshes generated with v2.5.", - ), - IO.Image.Input( - "style_image", - optional=True, - tooltip="Reference image for the artistic style of the textures. Only used together with texture_prompt.", - ), - IO.DynamicCombo.Input( - "reference", - options=[ - IO.DynamicCombo.Option("none", []), - IO.DynamicCombo.Option( - "image", - [IO.Image.Input("reference_image", tooltip="Single reference image the textures should follow.")], - ), - IO.DynamicCombo.Option( - "multiview", - [ - IO.Image.Input("image_front", tooltip="Front view (0°)."), - IO.Image.Input("image_left", tooltip="Left view (90°)."), - IO.Image.Input("image_back", tooltip="Back view (180°)."), - IO.Image.Input("image_right", tooltip="Right view (270°)."), - ], - ), + ) + ] + if legacy + else [] + ), + IO.Boolean.Input( + "pbr", + default=True, + optional=True, + tooltip="PBR material maps (base color, metallic, roughness, normal); off gives a plain color texture.", + ), + seed_input("texture_seed"), + texture_quality_input(), + texture_alignment_input(), + IO.String.Input( + "texture_prompt", + default="", + multiline=True, + optional=True, + tooltip="Optional text guidance for texturing. Required in practice for imported " + "models (Tripo: Import Model), which carry no source image to infer colors from. " + "Cannot be combined with reference images.", + ), + IO.Combo.Input( + "model_version", + options=TripoTextureModelVersion, + default=TripoTextureModelVersion.v3_0_20250812, + optional=True, + tooltip="Texture model: v3.0 for meshes generated with v3.x, v2.5 for meshes generated with v2.5.", + ), + IO.Image.Input( + "style_image", + optional=True, + tooltip="Reference image for the artistic style of the textures. Only used together with texture_prompt.", + ), + IO.DynamicCombo.Input( + "reference", + options=[ + IO.DynamicCombo.Option("none", []), + IO.DynamicCombo.Option( + "image", + [IO.Image.Input("reference_image", tooltip="Single reference image the textures should follow.")], + ), + IO.DynamicCombo.Option( + "multiview", + [ + IO.Image.Input("image_front", tooltip="Front view (0°)."), + IO.Image.Input("image_left", tooltip="Left view (90°)."), + IO.Image.Input("image_back", tooltip="Back view (180°)."), + IO.Image.Input("image_right", tooltip="Right view (270°)."), ], - optional=True, - tooltip="Reference images guiding the textures. Cannot be combined with texture_prompt or style_image.", - ), - IO.String.Input( - "part_names", - default="", - optional=True, - advanced=True, - tooltip="Comma-separated part names from Tripo: Segment Model to texture. Empty textures every part.", - ), - ], - outputs=[ - IO.String.Output(display_name="model_file"), # for backward compatibility only - IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), - IO.File3DGLB.Output(display_name="GLB", tooltip="Empty when the source is a quad mesh or an FBX import."), - IO.File3DFBX.Output( - display_name="FBX", - tooltip="Tripo returns FBX for quad meshes and FBX imports; empty otherwise.", ), ], - hidden=[ - IO.Hidden.auth_token_comfy_org, - IO.Hidden.api_key_comfy_org, - IO.Hidden.unique_id, - ], - is_api_node=True, - is_output_node=True, - price_badge=IO.PriceBadge( - depends_on=IO.PriceBadgeDepends(widgets=["texture_quality"]), - expr=""" + optional=True, + tooltip="Reference images guiding the textures. Cannot be combined with texture_prompt or style_image.", + ), + IO.String.Input( + "part_names", + default="", + optional=True, + advanced=True, + tooltip="Comma-separated part names from Tripo: Segment Model to texture. Empty textures every part.", + ), + ] + + +def texture_model_outputs(legacy: bool) -> list: + return model_outputs( + legacy, + glb_tooltip="Empty when the source is a quad mesh or an FBX import.", + fbx_tooltip="Tripo returns FBX for quad meshes and FBX imports; empty otherwise.", + ) + + +def texture_price_badge() -> IO.PriceBadge: + return IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["texture_quality"]), + expr=""" ( $tq := widgets.texture_quality; {"type":"usd","usd": ($tq = "extreme" ? 0.3 : ($tq = "detailed" ? 0.2 : 0.1)), "format": {"approximate": true}} ) """, - ), + ) + + +async def texture_model( + cls: type[IO.ComfyNode], + *, + model_task_id, + pbr: bool | None, + texture_seed: int | None, + texture_quality: str | None, + texture_alignment: str | None, + texture_prompt: str, + model_version: str | None, + style_image: Input.Image | None, + reference: dict | None, + part_names: str, +) -> tuple[str, Types.File3D]: + text = texture_prompt.strip() + mode = reference["reference"] if reference else "none" + if mode != "none" and (text or style_image is not None): + raise ValueError("Reference images cannot be combined with texture_prompt or style_image.") + if style_image is not None and not text: + raise ValueError("style_image requires a texture_prompt.") + if not text: + source = await sync_op( + cls, + endpoint=ApiEndpoint(path=f"/proxy/tripo/v3/tasks/{model_task_id}"), + response_model=TripoTaskResponse, + ) + if source.data.type not in TEXTURE_SOURCE_TYPES_WITH_IMAGE: + raise ValueError( + "This model has no source image to texture from (imported, segmented, completed or retopologized). " + "Give a texture_prompt; Tripo accepts reference images only for models it generated itself." + ) + if mode == "image": + prompt = TripoTexturePrompt(image=await upload_image_reference(cls, reference["reference_image"])) + elif mode == "multiview": + views = [reference[k] for k in ("image_front", "image_left", "image_back", "image_right")] + urls = await upload_images_to_comfyapi(cls, views, max_images=4) + prompt = TripoTexturePrompt(images=[TripoFileReference(root=TripoUrlReference(url=u, type="jpeg")) for u in urls]) + elif text: + prompt = TripoTexturePrompt( + text=text, + style_image=await upload_image_reference(cls, style_image) if style_image is not None else None, + ) + else: + prompt = None + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v3/models/texture", method="POST"), + response_model=TripoTaskResponse, + data=TripoTextureModelRequest( + input=model_task_id, + model=model_version, + pbr=pbr, + texture_seed=texture_seed, + texture_quality=texture_quality, + texture_alignment=texture_alignment, + texture_prompt=prompt, + part_names=split_part_names(part_names), + ), + ) + return await poll_until_finished(cls, response, average_duration=80) + + +class TripoTextureNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoTextureNode", + display_name="Tripo: Texture model (Legacy)", + category="partner/3d/Tripo", + inputs=texture_model_inputs(legacy=True), + outputs=texture_model_outputs(legacy=True), + hidden=hidden_inputs(), + is_api_node=True, + is_output_node=True, + is_deprecated=True, + price_badge=texture_price_badge(), ) @classmethod @@ -963,53 +1082,69 @@ async def execute( reference: dict | None = None, part_names: str = "", ) -> IO.NodeOutput: - text = texture_prompt.strip() - mode = reference["reference"] if reference else "none" - if mode != "none" and (text or style_image is not None): - raise ValueError("Reference images cannot be combined with texture_prompt or style_image.") - if style_image is not None and not text: - raise ValueError("style_image requires a texture_prompt.") - if not text: - source = await sync_op( + return glb_or_fbx_output( + *await texture_model( cls, - endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{model_task_id}"), - response_model=TripoTaskResponse, - ) - if source.data.type not in TEXTURE_SOURCE_TYPES_WITH_IMAGE: - raise ValueError( - "This model has no source image to texture from (imported, segmented, completed or retopologized). " - "Give a texture_prompt; Tripo accepts reference images only for models it generated itself." - ) - if mode == "image": - prompt = TripoTexturePrompt(image=await upload_image_reference(cls, reference["reference_image"])) - elif mode == "multiview": - views = [reference[k] for k in ("image_front", "image_left", "image_back", "image_right")] - urls = await upload_images_to_comfyapi(cls, views, max_images=4) - prompt = TripoTexturePrompt(images=[TripoFileReference(root=TripoUrlReference(url=u, type="jpeg")) for u in urls]) - elif text: - prompt = TripoTexturePrompt( - text=text, - style_image=await upload_image_reference(cls, style_image) if style_image is not None else None, - ) - else: - prompt = None - response = await sync_op( - cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), - response_model=TripoTaskResponse, - data=TripoTextureModelRequest( - original_model_task_id=model_task_id, + model_task_id=model_task_id, + pbr=pbr, + texture_seed=texture_seed, + texture_quality=texture_quality, + texture_alignment=texture_alignment, + texture_prompt=texture_prompt, model_version=model_version, - texture=True, + style_image=style_image, + reference=reference, + part_names=part_names, + ) + ) + + +class TripoTextureNodeV2(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoTextureNodeV2", + display_name="Tripo: Texture model", + category="partner/3d/Tripo", + inputs=texture_model_inputs(legacy=False), + outputs=texture_model_outputs(legacy=False), + hidden=hidden_inputs(), + is_api_node=True, + is_output_node=True, + price_badge=texture_price_badge(), + ) + + @classmethod + async def execute( + cls, + model_task_id, + pbr: bool | None = None, + texture_seed: int | None = None, + texture_quality: str | None = None, + texture_alignment: str | None = None, + texture_prompt: str = "", + model_version: str | None = None, + style_image: Input.Image | None = None, + reference: dict | None = None, + part_names: str = "", + ) -> IO.NodeOutput: + return glb_or_fbx_output( + *await texture_model( + cls, + model_task_id=model_task_id, pbr=pbr, texture_seed=texture_seed, texture_quality=texture_quality, texture_alignment=texture_alignment, - texture_prompt=prompt, - part_names=split_part_names(part_names), + texture_prompt=texture_prompt, + model_version=model_version, + style_image=style_image, + reference=reference, + part_names=part_names, ), + legacy=False, ) - return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=80)) class TripoRigNode(IO.ComfyNode): @@ -1088,11 +1223,11 @@ async def execute( raise ValueError(f"Rig model v1.0-20240301 only supports biped skeletons; use v2.5-20260210 for {rig_type}.") response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/animations/rig", method="POST"), response_model=TripoTaskResponse, data=TripoAnimateRigRequest( - original_model_task_id=original_model_task_id, - model_version=model_version, + input=original_model_task_id, + model=model_version, rig_type=rig_type, out_format=out_format, spec=spec, @@ -1114,8 +1249,9 @@ def define_schema(cls): IO.Combo.Input( "animation", options=[*[a.value for a in TripoAnimation], *TRIPO_BIPED_ANIMATIONS], - tooltip="preset:* animations work with both rig models; " - "preset:biped:* animations require a rig made with model v1.0-20240301.", + tooltip="preset:* animations work with both rig models. preset:biped:* animations are made for rigs " + "from model v1.0-20240301; a v2.5 rig accepts only chop, climb, dive, fall, hurt, idle, jump, run, " + "shoot, slash, turn and walk.", ), IO.Combo.Input( "out_format", @@ -1168,25 +1304,31 @@ async def execute( ) -> IO.NodeOutput: rig = await sync_op( cls, - endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{original_model_task_id}"), + endpoint=ApiEndpoint(path=f"/proxy/tripo/v3/tasks/{original_model_task_id}"), response_model=TripoTaskResponse, ) rig_input = rig.data.input or {} - if rig_input.get("spec") == "mixamo" and str(rig_input.get("model_version", "")).startswith("v1.0"): - raise ValueError("Tripo cannot retarget animation presets onto a v1.0 rig made with the mixamo spec.") + mixamo = rig_input.get("spec") == "mixamo" + if mixamo and str(rig_input.get("model_version", "")).startswith("v1.0"): + raise ValueError(MIXAMO_RETARGET_ERROR) response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/animations/retarget", method="POST"), response_model=TripoTaskResponse, data=TripoAnimateRetargetRequest( - original_model_task_id=original_model_task_id, + input=original_model_task_id, animation=animation, out_format=out_format, export_with_geometry=export_with_geometry, animate_in_place=animate_in_place, ), ) - return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=30)) + try: + return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=30)) + except Exception as error: + if mixamo and "mixamo" in str(error): + raise ValueError(MIXAMO_RETARGET_ERROR) from error + raise class TripoRigCheckNode(IO.ComfyNode): @@ -1257,9 +1399,9 @@ def define_schema(cls): async def execute(cls, model_task_id) -> IO.NodeOutput: response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/mesh/segment", method="POST"), response_model=TripoTaskResponse, - data=TripoMeshSegmentationRequest(original_model_task_id=model_task_id), + data=TripoMeshSegmentationRequest(input=model_task_id), ) task_id, model = await poll_until_finished(cls, response, average_duration=160) return IO.NodeOutput(f"{task_id}.glb", task_id, model, ",".join(part_names_from_glb(model))) @@ -1304,10 +1446,10 @@ def define_schema(cls): async def execute(cls, segment_task_id, part_names: str = "") -> IO.NodeOutput: response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/mesh/complete", method="POST"), response_model=TripoTaskResponse, data=TripoMeshCompletionRequest( - original_model_task_id=segment_task_id, + input=segment_task_id, part_names=split_part_names(part_names), ), ) @@ -1384,10 +1526,10 @@ async def execute( raise ValueError("face_limit must be between 500 and 20,000 for triangles or 500 and 10,000 for quads.") response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/mesh/decimate", method="POST"), response_model=TripoTaskResponse, data=TripoHighpolyToLowpolyRequest( - original_model_task_id=model_task_id, + input=model_task_id, face_limit=face_limit if face_limit != -1 else None, quad=quad, bake=bake, @@ -1397,6 +1539,133 @@ async def execute( return glb_or_fbx_output(*await poll_until_finished(cls, response, average_duration=200)) +IDENTITY_TRANSFORM = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0] + + +def smart_segment_inputs() -> list: + return [ + IO.Combo.Input("granularity", options=["coarse", "medium", "fine"], default="medium", optional=True), + IO.String.Input( + "hint", + default="", + multiline=True, + optional=True, + tooltip="Optional text naming the parts to look for, e.g. 'game character with sword and armor'.", + ), + ] + + +class TripoSmartSegmentNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoSmartSegmentNode", + display_name="Tripo: Smart Segment", + category="partner/3d/Tripo", + description="Splits a model into semantically meaningful parts and names them. From an image, Tripo first " + "generates the model. The segment task_id feeds Tripo: Complete Mesh Parts, Tripo: Retopology, " + "Tripo: Texture model and Tripo: Convert model like a Tripo: Segment Model result.", + inputs=[ + IO.DynamicCombo.Input( + "source", + options=[ + IO.DynamicCombo.Option( + "model", + [ + IO.Custom("MODEL_TASK_ID").Input( + "model_task_id", + tooltip="A GLB result. Quad (FBX) meshes must go through Tripo: Convert model (GLTF) first.", + ), + *smart_segment_inputs(), + ], + ), + IO.DynamicCombo.Option("image", [IO.Image.Input("image"), *smart_segment_inputs()]), + ], + tooltip="Segment an existing model, or generate a model from an image and segment it.", + ), + ], + outputs=[ + IO.Custom("SEGMENT_TASK_ID").Output(display_name="segment task_id"), + IO.Custom("MODEL_TASK_ID").Output( + display_name="model task_id", tooltip="The model that was segmented (generated from the image, or imported)." + ), + IO.File3DGLB.Output(display_name="GLB"), + IO.String.Output(display_name="part_names", tooltip="Comma-separated names of the parts."), + IO.String.Output(display_name="parts", tooltip="Tripo's description of the parts it found."), + IO.Image.Output(display_name="mask"), + ], + hidden=[ + IO.Hidden.auth_token_comfy_org, + IO.Hidden.api_key_comfy_org, + IO.Hidden.unique_id, + ], + is_api_node=True, + is_output_node=True, + price_badge=IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["source"]), + expr="""{"type":"usd","usd": (widgets.source = "image" ? 0.85 : 0.55), "format": {"approximate": true}}""", + ), + ) + + @classmethod + async def execute(cls, source: dict) -> IO.NodeOutput: + granularity = source.get("granularity", "medium") + hint = source.get("hint", "") + if source["source"] == "image": + uploaded = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v3/files", method="POST"), + response_model=TripoFileResponse, + files={"file": ("image.png", tensor_to_bytesio(source["image"]), "image/png")}, + content_type="multipart/form-data", + ) + request = TripoMeshSmartSegmentRequest( + input=uploaded.data.file_token, + seg_type="image", + granularity=granularity, + hint=hint.strip() or None, + ) + else: + task = await sync_op( + cls, + endpoint=ApiEndpoint(path=f"/proxy/tripo/v3/tasks/{source['model_task_id']}"), + response_model=TripoTaskResponse, + ) + url = get_model_url_from_response(task) + if Path(urlparse(url).path).suffix.lower() != ".glb": + raise ValueError( + "Tripo: Smart Segment accepts GLB models only. Convert quad (FBX) meshes with " + "Tripo: Convert model (GLTF) first." + ) + request = TripoMeshSmartSegmentRequest( + input=url, + seg_type="model", + transform=IDENTITY_TRANSFORM, + granularity=granularity, + hint=hint.strip() or None, + ) + response = await sync_op( + cls, + endpoint=ApiEndpoint(path="/proxy/tripo/v3/mesh/smartsegment", method="POST"), + response_model=TripoTaskResponse, + data=request, + ) + output = (await poll_task(cls, response, average_duration=180)).data.output + if not (output.seg_task_id and output.seg_model_url and output.mask_url): + raise RuntimeError(f"Tripo returned an incomplete smart segmentation result: {output}") + model = await download_url_to_file_3d(output.seg_model_url, "glb", task_id=output.seg_task_id) + mask = await download_url_to_image_tensor(output.mask_url, cls=cls) + return IO.NodeOutput( + output.seg_task_id, + output.model_task_id, + model, + ",".join(part_names_from_glb(model)), + output.prompt or "", + mask, + ) + + class TripoConversionNode(IO.ComfyNode): @classmethod @@ -1563,10 +1832,10 @@ async def execute( response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/models/convert", method="POST"), response_model=TripoTaskResponse, data=TripoConvertModelRequest( - original_model_task_id=original_model_task_id, + input=original_model_task_id, format=format, quad=quad if quad else None, force_symmetry=force_symmetry if force_symmetry else None, @@ -1647,17 +1916,17 @@ async def execute(cls, model_3d: Types.File3D) -> IO.NodeOutput: url = await upload_3d_model_to_comfyapi(cls, model_3d, file_format) response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/import", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/models/import", method="POST"), response_model=TripoTaskResponse, - data=TripoImportModelRequest(url=url, format=file_format), + data=TripoImportModelRequest(input=url), ) if response.code != 0: - raise RuntimeError(f"Failed to import model: {response.error}") + raise RuntimeError(f"Failed to import model: {response}") task_id = response.data.task_id response_poll = await poll_op( cls, - poll_endpoint=ApiEndpoint(path=f"/proxy/tripo/v2/openapi/task/{task_id}"), + poll_endpoint=ApiEndpoint(path=f"/proxy/tripo/v3/tasks/{task_id}"), response_model=TripoTaskResponse, failed_statuses=[ TripoTaskStatus.FAILED, @@ -1875,7 +2144,7 @@ async def execute( ) response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/generation/text-to-model", method="POST"), response_model=TripoTaskResponse, data=request, ) @@ -1934,12 +2203,7 @@ async def execute( ) -> IO.NodeOutput: if image is None: raise RuntimeError("Image is required") - tripo_file = TripoFileReference( - root=TripoUrlReference( - url=(await upload_images_to_comfyapi(cls, image, max_images=1))[0], - type="jpeg", - ) - ) + image_url = (await upload_images_to_comfyapi(cls, image, max_images=1))[0] common = _build_p1_request_kwargs( output_mode=output_mode, face_limit=face_limit, @@ -1949,13 +2213,13 @@ async def execute( compress_geometry=compress_geometry, ) request = TripoP1ImageToModelRequest( - file=tripo_file, + input=image_url, enable_image_autofix=enable_image_autofix, **common, ) response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/generation/image-to-model", method="POST"), response_model=TripoTaskResponse, data=request, ) @@ -2023,13 +2287,10 @@ async def execute( if sum(1 for v in views if v is not None) < 2: raise RuntimeError("Tripo P1 multiview requires at least 2 images (front plus one of left/back/right).") - files: list[TripoFileReference] = [] - for view in views: - if view is None: - files.append(TripoFileReference(root=TripoFileEmptyReference())) - continue - url = (await upload_images_to_comfyapi(cls, view, max_images=1))[0] - files.append(TripoFileReference(root=TripoUrlReference(url=url, type="jpeg"))) + inputs: list[dict[str, str]] = [] + for name, view in zip(("front", "left", "back", "right"), views): + if view is not None: + inputs.append({name: (await upload_images_to_comfyapi(cls, view, max_images=1))[0]}) common = _build_p1_request_kwargs( output_mode=output_mode, @@ -2039,36 +2300,346 @@ async def execute( export_uv=export_uv, compress_geometry=compress_geometry, ) - request = TripoP1MultiviewToModelRequest(files=files, **common) + request = TripoP1MultiviewToModelRequest(inputs=inputs, **common) response = await sync_op( cls, - endpoint=ApiEndpoint(path="/proxy/tripo/v2/openapi/task", method="POST"), + endpoint=ApiEndpoint(path="/proxy/tripo/v3/generation/multiview-to-model", method="POST"), response_model=TripoTaskResponse, data=request, ) return glb_output(*await poll_until_finished(cls, response, average_duration=80)) +P_SERIES_MODELS = {"P2": "P2-20260801"} + + +def p_series_model_input(source: str) -> IO.DynamicCombo.Input: + inputs: list = [] + if source == "text": + inputs += [ + IO.String.Input("prompt", multiline=True, tooltip="Up to 1024 characters."), + IO.String.Input("negative_prompt", multiline=True, default="", optional=True, tooltip="Up to 255 characters."), + ] + elif source == "image": + inputs.append(IO.Image.Input("image")) + else: + inputs += [ + IO.Image.Input("image", tooltip="Front view (0°). Required."), + IO.Image.Input("image_left", optional=True, tooltip="Left view (90°), i.e. the subject's left side."), + IO.Image.Input("image_back", optional=True, tooltip="Back view (180°)."), + IO.Image.Input("image_right", optional=True, tooltip="Right view (270°), i.e. the subject's right side."), + ] + inputs += [ + IO.Boolean.Input( + "quad", + default=False, + tooltip="Quad-dominant mesh, returned as FBX on the FBX output. " + "Off returns a triangle mesh as GLB on the GLB output.", + ), + IO.Int.Input( + "face_limit", + default=-1, + min=-1, + max=50000, + tooltip="Target face count: 48-50,000, or 48-25,000 with quad. -1 lets Tripo choose. " + "Tripo treats it as a target, so the result can exceed it.", + ), + IO.Combo.Input( + "texture", + options=["standard", "detailed", "extreme", "none"], + default="standard", + tooltip="Base color texture resolution: standard 2K, detailed 4K, extreme 8K. " + "none returns an untextured mesh.", + ), + IO.Boolean.Input( + "pbr", + default=True, + tooltip="Metallic, roughness and normal maps in addition to the base color. Ignored when texture is none.", + ), + IO.Int.Input( + "model_seed", + default=42, + min=0, + max=SEED_MAX, + control_after_generate=True, + tooltip="Seed for the geometry.", + ), + ] + if source == "text": + inputs.append( + IO.Int.Input( + "image_seed", + default=42, + min=0, + max=SEED_MAX, + advanced=True, + tooltip="Seed for the image Tripo draws from the prompt before modeling.", + ) + ) + else: + inputs += [ + IO.Combo.Input( + "texture_alignment", + options=["original_image", "geometry"], + default="original_image", + advanced=True, + tooltip="Match the colors of the input image, or fit the textures to the generated geometry. " + "Ignored when texture is none.", + ), + IO.Combo.Input( + "orientation", + options=["default", "align_image"], + default="default", + advanced=True, + tooltip="align_image rotates the model to the viewpoint of the input image. Ignored when texture is none.", + ), + ] + if source == "image": + inputs.append( + IO.Boolean.Input( + "enable_image_autofix", + default=False, + advanced=True, + tooltip="Let Tripo enhance a low-resolution or low-quality image before modeling.", + ) + ) + inputs += [ + IO.Int.Input( + "texture_seed", + default=42, + min=0, + max=SEED_MAX, + advanced=True, + tooltip="Seed for the textures. Ignored when texture is none.", + ), + IO.Boolean.Input( + "auto_size", + default=False, + advanced=True, + tooltip="Scale the model to its real-world size in meters through its scene transform. " + "Ignored when texture is none.", + ), + IO.Boolean.Input( + "export_uv", + default=True, + advanced=True, + tooltip="UV unwrap an untextured mesh. Off returns it without texture coordinates. " + "Textured meshes are always unwrapped.", + ), + IO.Boolean.Input( + "compress_geometry", + default=False, + advanced=True, + tooltip="Meshopt geometry compression (EXT_meshopt_compression): much smaller files, " + "but ComfyUI's 3D preview cannot display them. Ignored for quad meshes.", + ), + ] + return IO.DynamicCombo.Input( + "model", + options=[IO.DynamicCombo.Option("P2", inputs)], + tooltip="Tripo P-series model.", + ) + + +def p_series_price_badge() -> IO.PriceBadge: + return IO.PriceBadge( + depends_on=IO.PriceBadgeDepends(widgets=["model.texture"]), + expr=""" + ( + $texture := $lookup(widgets, "model.texture"); + $credits := $texture = "none" ? 100 : ($texture = "extreme" ? 130 : ($texture = "detailed" ? 120 : 110)); + {"type":"usd","usd": $credits * 0.01, "format": {"approximate": true}} + ) + """, + ) + + +def p_series_request_fields(model: dict) -> dict: + quad = model["quad"] + face_limit = model["face_limit"] + max_face_limit = 25000 if quad else 50000 + if face_limit != -1 and not 48 <= face_limit <= max_face_limit: + raise ValueError( + f"face_limit must be -1 or between 48 and {max_face_limit:,} for {'quad' if quad else 'triangle'} meshes." + ) + fields = { + "model": P_SERIES_MODELS[model["model"]], + "quad": quad, + "face_limit": None if face_limit == -1 else face_limit, + "model_seed": model["model_seed"], + "compress": "geometry" if model["compress_geometry"] and not quad else None, + } + if model["texture"] == "none": + return fields | {"texture": False, "pbr": False, "export_uv": model["export_uv"]} + fields |= { + "texture": True, + "pbr": model["pbr"], + "texture_quality": model["texture"], + "texture_seed": model["texture_seed"], + "auto_size": model["auto_size"], + } + if "texture_alignment" in model: + fields |= {"texture_alignment": model["texture_alignment"], "orientation": model["orientation"]} + return fields + + +def p_series_check_outputs(cls: type[IO.ComfyNode], quad: bool) -> None: + empty_name, filled_name = ("GLB", "FBX") if quad else ("FBX", "GLB") + validate_output_unlinked( + cls, + 1 if quad else 2, + f"quad is {'enabled' if quad else 'disabled'}, so this node delivers {filled_name} only " + f"and its {empty_name} output is empty. Connect the {filled_name} output instead", + ) + + +async def p_series_generate(cls: type[IO.ComfyNode], path: str, request: TripoPSeriesRequest) -> IO.NodeOutput: + response = await sync_op( + cls, + endpoint=ApiEndpoint(path=path, method="POST"), + response_model=TripoTaskResponse, + data=request, + ) + response_poll = await poll_task(cls, response) + url = get_model_url_from_response(response_poll) + file_format = Path(urlparse(url).path).suffix.lstrip(".").lower() or ("fbx" if request.quad else "glb") + model_3d = await download_url_to_file_3d(url, file_format, cls=cls) + return glb_or_fbx_output(response_poll.data.task_id, model_3d, legacy=False) + + +class TripoPSeriesTextToModelNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoPSeriesTextToModelNode", + display_name="Tripo P2: Text to Model", + category="partner/3d/Tripo", + description="Generates a low-poly 3D model with clean topology from a text prompt, " + "as a triangle mesh (GLB) or a quad-dominant mesh (FBX).", + search_aliases=["tripo p2", "quad mesh", "low poly"], + inputs=[p_series_model_input("text")], + outputs=model_outputs(legacy=False), + hidden=[*hidden_inputs(), IO.Hidden.dynprompt], + is_api_node=True, + price_badge=p_series_price_badge(), + ) + + @classmethod + async def execute(cls, model: dict) -> IO.NodeOutput: + p_series_check_outputs(cls, model["quad"]) + prompt = model["prompt"].strip() + negative_prompt = (model.get("negative_prompt") or "").strip() + validate_string(prompt, min_length=1, max_length=1024) + validate_string(negative_prompt, field_name="negative_prompt", max_length=255) + request = TripoPSeriesTextToModelRequest( + prompt=prompt, + negative_prompt=negative_prompt or None, + image_seed=model["image_seed"], + **p_series_request_fields(model), + ) + return await p_series_generate(cls, "/proxy/tripo/v3/generation/text-to-model", request) + + +class TripoPSeriesImageToModelNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoPSeriesImageToModelNode", + display_name="Tripo P2: Image to Model", + category="partner/3d/Tripo", + description="Generates a low-poly 3D model with clean topology from an image, " + "as a triangle mesh (GLB) or a quad-dominant mesh (FBX).", + search_aliases=["tripo p2", "quad mesh", "low poly"], + inputs=[p_series_model_input("image")], + outputs=model_outputs(legacy=False), + hidden=[*hidden_inputs(), IO.Hidden.dynprompt], + is_api_node=True, + price_badge=p_series_price_badge(), + ) + + @classmethod + async def execute(cls, model: dict) -> IO.NodeOutput: + p_series_check_outputs(cls, model["quad"]) + fields = p_series_request_fields(model) + request = TripoPSeriesImageToModelRequest( + input=(await upload_images_to_comfyapi(cls, model["image"], max_images=1))[0], + enable_image_autofix=model["enable_image_autofix"], + **fields, + ) + return await p_series_generate(cls, "/proxy/tripo/v3/generation/image-to-model", request) + + +class TripoPSeriesMultiviewToModelNode(IO.ComfyNode): + + @classmethod + def define_schema(cls): + return IO.Schema( + node_id="TripoPSeriesMultiviewToModelNode", + display_name="Tripo P2: Multiview to Model", + category="partner/3d/Tripo", + description="Generates a low-poly 3D model with clean topology from a front view plus one to three of the " + "left, back and right views, as a triangle mesh (GLB) or a quad-dominant mesh (FBX).", + search_aliases=["tripo p2", "quad mesh", "low poly"], + inputs=[p_series_model_input("multiview")], + outputs=model_outputs(legacy=False), + hidden=[*hidden_inputs(), IO.Hidden.dynprompt], + is_api_node=True, + price_badge=p_series_price_badge(), + ) + + @classmethod + async def execute(cls, model: dict) -> IO.NodeOutput: + p_series_check_outputs(cls, model["quad"]) + views = { + "front": model["image"], + "left": model.get("image_left"), + "back": model.get("image_back"), + "right": model.get("image_right"), + } + if sum(image is not None for image in views.values()) < 2: + raise ValueError("Connect the front view and at least one of the left, back or right views.") + fields = p_series_request_fields(model) + inputs = [ + {view: (await upload_images_to_comfyapi(cls, image, max_images=1))[0]} + for view, image in views.items() + if image is not None + ] + return await p_series_generate( + cls, + "/proxy/tripo/v3/generation/multiview-to-model", + TripoPSeriesMultiviewToModelRequest(inputs=inputs, **fields), + ) + + class TripoExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ TripoTextToModelNode, + TripoTextToModelNodeV2, TripoImageToModelNode, + TripoImageToModelNodeV2, TripoMultiviewToModelNode, TripoP1TextToModelNode, TripoP1ImageToModelNode, TripoP1MultiviewToModelNode, + TripoPSeriesTextToModelNode, + TripoPSeriesImageToModelNode, + TripoPSeriesMultiviewToModelNode, TripoImportModelNode, TripoImageToMultiviewNode, TripoEditMultiviewNode, TripoTextureNode, + TripoTextureNodeV2, TripoRigCheckNode, TripoRigNode, TripoRetargetNode, TripoSegmentNode, TripoMeshCompleteNode, TripoRetopologyNode, + TripoSmartSegmentNode, TripoConversionNode, ] diff --git a/comfy_api_nodes/util/__init__.py b/comfy_api_nodes/util/__init__.py index e351c50be71..034090a5b13 100644 --- a/comfy_api_nodes/util/__init__.py +++ b/comfy_api_nodes/util/__init__.py @@ -1,4 +1,4 @@ -from ._helpers import get_fs_object_size +from ._helpers import get_fs_object_size, get_output_consumers, validate_output_unlinked from .client import ( ApiEndpoint, poll_op, @@ -122,4 +122,7 @@ "validate_video_frame_count", # Misc functions "get_fs_object_size", + # Graph helpers + "get_output_consumers", + "validate_output_unlinked", ] diff --git a/comfy_api_nodes/util/_helpers.py b/comfy_api_nodes/util/_helpers.py index ddfb3b65c9d..9dbf25c7ccb 100644 --- a/comfy_api_nodes/util/_helpers.py +++ b/comfy_api_nodes/util/_helpers.py @@ -7,7 +7,10 @@ from datetime import datetime, timezone from email.utils import parsedate_to_datetime from io import BytesIO +from urllib.parse import urlparse +import aiohttp +from aiohttp.client_exceptions import ClientError from yarl import URL from comfy.cli_args import args @@ -15,6 +18,7 @@ from comfy.deploy_environment import get_deploy_environment from comfy.model_management import processing_interrupted from comfy_api.latest import IO +from comfy_execution.graph_utils import is_link from comfy_execution.utils import get_executing_context from comfyui_version import __version__ as comfyui_version @@ -74,14 +78,55 @@ def default_base_url() -> str: return normalize_comfy_api_base(getattr(args, "comfy_api_base", "https://api.comfy.org")) +async def diagnose_connectivity() -> dict[str, bool]: + """Best-effort connectivity diagnostics to distinguish local vs. server issues.""" + results = { + "internet_accessible": False, + "api_accessible": False, + } + timeout = aiohttp.ClientTimeout(total=5.0) + + # Probe Google and Baidu in parallel: Google is blocked by the GFW in mainland China, so a Baidu probe is required + # to correctly detect that Chinese users with working internet do have working internet. + internet_probe_urls = ("https://www.google.com", "https://www.baidu.com") + + async with aiohttp.ClientSession(timeout=timeout) as session: + async def _probe(url: str) -> bool: + try: + async with session.get(url) as resp: + return resp.status < 500 + except (ClientError, OSError, asyncio.TimeoutError): + return False + + probe_tasks = [asyncio.create_task(_probe(u)) for u in internet_probe_urls] + try: + for fut in asyncio.as_completed(probe_tasks): + if await fut: + results["internet_accessible"] = True + break + finally: + for t in probe_tasks: + if not t.done(): + t.cancel() + await asyncio.gather(*probe_tasks, return_exceptions=True) + if not results["internet_accessible"]: + return results + + parsed = urlparse(default_base_url()) + health_url = f"{parsed.scheme}://{parsed.netloc}/health" + with contextlib.suppress(ClientError, OSError): + async with session.get(health_url) as resp: + results["api_accessible"] = resp.status < 500 + return results + + async def sleep_with_interrupt( seconds: float, node_cls: type[IO.ComfyNode] | None, label: str | None = None, start_ts: float | None = None, - estimated_total: int | None = None, *, - display_callback: Callable[[type[IO.ComfyNode], str, int, int | None], None] | None = None, + display_callback: Callable[[type[IO.ComfyNode], str, int], None] | None = None, ): """ Sleep in 1s slices while: @@ -95,7 +140,7 @@ async def sleep_with_interrupt( now = time.monotonic() if start_ts is not None and label and display_callback: with contextlib.suppress(Exception): - display_callback(node_cls, label, int(now - start_ts), estimated_total) + display_callback(node_cls, label, int(now - start_ts)) if now >= end: break await asyncio.sleep(min(1.0, end - now)) @@ -138,6 +183,27 @@ def get_fs_object_size(path_or_object: str | BytesIO) -> int: return len(path_or_object.getvalue()) +def get_output_consumers(node_cls: type[IO.ComfyNode], output_index: int) -> list[str]: + dynprompt = node_cls.hidden.dynprompt + if dynprompt is None: + return [] + node_id = str(node_cls.hidden.unique_id) + consumers = [] + for consumer_id in dynprompt.all_node_ids(): + consumer = dynprompt.get_node(consumer_id) + for value in (consumer.get("inputs") or {}).values(): + if is_link(value) and value[0] == node_id and value[1] == output_index: + title = (consumer.get("_meta") or {}).get("title") or consumer.get("class_type") + consumers.append(f"{title} #{dynprompt.get_display_node_id(consumer_id)}") + return sorted(consumers) + + +def validate_output_unlinked(node_cls: type[IO.ComfyNode], output_index: int, reason: str) -> None: + consumers = get_output_consumers(node_cls, output_index) + if consumers: + raise ValueError(f"{reason} (currently linked: {', '.join(consumers)}).") + + def to_aiohttp_url(url: str) -> URL: """If `url` appears to be already percent-encoded (contains at least one valid %HH escape and no malformed '%' sequences) and contains no raw whitespace/control diff --git a/comfy_api_nodes/util/client.py b/comfy_api_nodes/util/client.py index a896027c970..0fe3e01bea7 100644 --- a/comfy_api_nodes/util/client.py +++ b/comfy_api_nodes/util/client.py @@ -6,7 +6,7 @@ import time import uuid import weakref -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass from enum import Enum from io import BytesIO @@ -18,6 +18,11 @@ from pydantic import BaseModel from comfy import utils +from comfy.model_management import ( + InterruptProcessingException, + interrupt_current_processing, + throw_exception_if_processing_interrupted, +) from comfy_api.latest import IO from server import PromptServer @@ -25,12 +30,14 @@ from ._helpers import ( _retry_after_wait, default_base_url, + diagnose_connectivity, get_comfy_api_headers, get_node_id, is_processing_interrupted, sleep_with_interrupt, ) from .common_exceptions import ApiServerError, LocalNetworkError, ProcessingInterrupted +from .download_helpers import download_url_to_bytesio M = TypeVar("M", bound=BaseModel) @@ -65,12 +72,13 @@ class _RequestConfig: retry_backoff: float wait_label: str = "Waiting" monitor_progress: bool = True - estimated_total: int | None = None final_label_on_success: str | None = "Completed" progress_origin_ts: float | None = None price_extractor: Callable[[dict[str, Any]], float | None] | None = None is_rate_limited: Callable[[int, Any], bool] | None = None response_header_validator: Callable[[dict[str, str]], None] | None = None + idempotency_key: str | None = None + asset_urls: bool = False @dataclass @@ -80,6 +88,8 @@ class _PollUIState: is_queued: bool = True price: float | None = None estimated_duration: int | None = None + estimated_p90: int | None = None + estimate_includes_queue: bool = False base_processing_elapsed: float = 0.0 # sum of completed active intervals active_since: float | None = None # start time of current active interval (None if queued) @@ -87,12 +97,36 @@ class _PollUIState: _RETRY_STATUS = {408, 500, 502, 503, 504} # status 429 is handled separately _MAX_RETRY_AFTER_WAIT = 150.0 # Cap a server Retry-After at this many seconds so a large hint can't block execution +IDEMPOTENCY_KEY_HEADER = "Idempotency-Key" +ASSET_FORMAT_HEADER = "Comfy-Asset-Format" +ASSET_FORMAT_URL = "url" +_IDEMPOTENT_REPLAYED_HEADER = "Idempotent-Replayed" +_ERROR_TYPE_HEADER = "X-Comfy-Error-Type" +_IDEMPOTENCY_IN_FLIGHT = "idempotency_in_flight" +_IDEMPOTENCY_TERMINAL = frozenset({"idempotency_consumed", "idempotency_mismatch"}) +_IDEMPOTENCY_IN_FLIGHT_WAIT = 2.0 +_IDEMPOTENCY_IN_FLIGHT_MAX_WAIT = 30.0 + PRICE_CREDITS_HEADER = "X-Comfy-Credits-Used" """Proxy response header with the actual cost in Comfy credits. When present on any successful proxied response, it takes precedence over ``price_extractor``.""" +ESTIMATED_DURATION_HEADER = "X-Comfy-Estimated-Duration-Seconds" +ESTIMATED_DURATION_P90_HEADER = "X-Comfy-Estimated-Duration-P90-Seconds" +ESTIMATE_SOURCE_HEADER = "X-Comfy-Estimate-Source" + _credits_used_by_execution: "weakref.WeakKeyDictionary[type, float]" = weakref.WeakKeyDictionary() """Last PRICE_CREDITS_HEADER value per node execution, keyed by the node's per-execution class clone.""" + + +@dataclass +class _ServerEstimate: + p50_seconds: int + p90_seconds: int | None + source: str | None + + +_server_estimates_by_execution: "weakref.WeakKeyDictionary[type, _ServerEstimate]" = weakref.WeakKeyDictionary() COMPLETED_STATUSES = ["succeeded", "succeed", "success", "completed", "finished", "done", "complete"] FAILED_STATUSES = ["cancelled", "canceled", "canceling", "fail", "failed", "error"] QUEUED_STATUSES = ["created", "queued", "queueing", "submitted", "initializing", "wait", "in_queue"] @@ -117,6 +151,32 @@ def _get_remembered_credits_used(node_cls: type[IO.ComfyNode]) -> float | None: return _credits_used_by_execution.get(node_cls) +def _parse_estimate_seconds(header_value: str | None) -> int | None: + if not header_value: + return None + try: + seconds = int(header_value.strip()) + except ValueError: + logging.debug("Ignoring malformed estimate header value: %r", header_value) + return None + if not 1 <= seconds <= 86400: + logging.debug("Ignoring out-of-range estimate header value: %r", header_value) + return None + return seconds + + +def _maybe_remember_server_estimate(node_cls: type[IO.ComfyNode], headers: Mapping[str, str]) -> None: + p50 = _parse_estimate_seconds(headers.get(ESTIMATED_DURATION_HEADER)) + if p50 is None: + return + p90 = _parse_estimate_seconds(headers.get(ESTIMATED_DURATION_P90_HEADER)) + if p90 is not None and p90 < p50: + p90 = p50 + source = headers.get(ESTIMATE_SOURCE_HEADER) or None + _server_estimates_by_execution[node_cls] = _ServerEstimate(p50, p90, source) + logging.debug("Server duration estimate: p50=%ss, p90=%s, source=%s", p50, p90, source) + + async def sync_op( cls: type[IO.ComfyNode], endpoint: ApiEndpoint, @@ -132,12 +192,12 @@ async def sync_op( retry_delay: float = 1.0, retry_backoff: float = 2.0, wait_label: str = "Waiting for server", - estimated_duration: int | None = None, final_label_on_success: str | None = "Completed", progress_origin_ts: float | None = None, monitor_progress: bool = True, max_retries_on_rate_limit: int = 16, is_rate_limited: Callable[[int, Any], bool] | None = None, + asset_urls: bool = False, ) -> M: raw = await sync_op_raw( cls, @@ -152,13 +212,13 @@ async def sync_op( retry_delay=retry_delay, retry_backoff=retry_backoff, wait_label=wait_label, - estimated_duration=estimated_duration, as_binary=False, final_label_on_success=final_label_on_success, progress_origin_ts=progress_origin_ts, monitor_progress=monitor_progress, max_retries_on_rate_limit=max_retries_on_rate_limit, is_rate_limited=is_rate_limited, + asset_urls=asset_urls, ) if not isinstance(raw, dict): raise Exception("Expected JSON response to validate into a Pydantic model, got non-JSON (binary or text).") @@ -228,7 +288,6 @@ async def sync_op_raw( retry_delay: float = 1.0, retry_backoff: float = 2.0, wait_label: str = "Waiting for server", - estimated_duration: int | None = None, as_binary: bool = False, final_label_on_success: str | None = "Completed", progress_origin_ts: float | None = None, @@ -236,12 +295,16 @@ async def sync_op_raw( max_retries_on_rate_limit: int = 16, is_rate_limited: Callable[[int, Any], bool] | None = None, response_header_validator: Callable[[dict[str, str]], None] | None = None, + idempotent: bool = True, + asset_urls: bool = False, ) -> dict[str, Any] | bytes: """ Make a single network request. - If as_binary=False (default): returns JSON dict (or {'_raw': ''} if non-JSON). - If as_binary=True: returns bytes. - response_header_validator: optional callback receiving response headers dict + - asset_urls=True: asks the proxy for Comfy-hosted URLs in place of inline media; a JSON {"url": ...} + answer to an as_binary request is downloaded and returned as the bytes. """ if isinstance(data, BaseModel): data = data.model_dump(exclude_none=True) @@ -261,13 +324,14 @@ async def sync_op_raw( retry_backoff=retry_backoff, wait_label=wait_label, monitor_progress=monitor_progress, - estimated_total=estimated_duration, final_label_on_success=final_label_on_success, progress_origin_ts=progress_origin_ts, price_extractor=price_extractor, max_retries_on_rate_limit=max_retries_on_rate_limit, is_rate_limited=is_rate_limited, response_header_validator=response_header_validator, + idempotency_key=uuid.uuid4().hex if idempotent and endpoint.method != "GET" else None, + asset_urls=asset_urls, ) return await _request_base(cfg, expect_binary=as_binary) @@ -311,11 +375,22 @@ async def poll_op_raw( progress_bar = utils.ProgressBar(100) if progress_extractor else None last_progress: int | None = None - state = _PollUIState(started=started, estimated_duration=estimated_duration) + server_estimate = _server_estimates_by_execution.pop(cls, None) + if server_estimate is not None: + state = _PollUIState( + started=started, + estimated_duration=server_estimate.p50_seconds, + estimated_p90=server_estimate.p90_seconds, + estimate_includes_queue=True, + ) + else: + state = _PollUIState(started=started, estimated_duration=estimated_duration) + estimate_bar = utils.ProgressBar(100) if progress_bar is None and server_estimate is not None else None stop_ticker = asyncio.Event() async def _ticker(): """Emit a UI update every second while polling is in progress.""" + last_estimate_pct = -1 try: while not stop_ticker.is_set(): if is_processing_interrupted(): @@ -324,6 +399,15 @@ async def _ticker(): proc_elapsed = state.base_processing_elapsed + ( (now - state.active_since) if state.active_since is not None else 0.0 ) + if estimate_bar is not None: + pct = _estimate_progress_pct(now - state.started, state.estimated_duration, state.estimated_p90) + if pct != last_estimate_pct: + try: + estimate_bar.update_absolute(pct, total=100) + except InterruptProcessingException: + interrupt_current_processing() + break + last_estimate_pct = pct _display_time_progress( cls, status=state.status_label, @@ -333,6 +417,8 @@ async def _ticker(): is_queued=state.is_queued, processing_elapsed_seconds=int(proc_elapsed), extra_text=extra_text, + estimated_p90=state.estimated_p90, + estimate_includes_queue=state.estimate_includes_queue, ) await asyncio.sleep(1.0) except Exception as exc: @@ -351,10 +437,10 @@ async def _ticker(): retry_delay=retry_delay_per_poll, retry_backoff=retry_backoff_per_poll, wait_label="Checking", - estimated_duration=None, as_binary=False, final_label_on_success=None, monitor_progress=False, + idempotent=False, ) if not isinstance(resp_json, dict): raise Exception("Polling endpoint returned non-JSON response.") @@ -367,10 +453,10 @@ async def _ticker(): timeout=cancel_timeout, max_retries=0, wait_label="Cancelling task", - estimated_duration=None, as_binary=False, final_label_on_success=None, monitor_progress=False, + idempotent=False, ) raise @@ -381,15 +467,26 @@ async def _ticker(): status = None if price_extractor: - new_price = price_extractor(resp_json) + try: + new_price = price_extractor(resp_json) + except Exception as e: + logging.error("Price extraction failed: %s", e) + new_price = None if new_price is not None: state.price = new_price if progress_extractor: - new_progress = progress_extractor(resp_json) + try: + new_progress = progress_extractor(resp_json) + except Exception as e: + logging.error("Progress extraction failed: %s", e) + new_progress = None if new_progress is not None and last_progress != new_progress: - progress_bar.update_absolute(new_progress, total=100) - last_progress = new_progress + try: + progress_bar.update_absolute(new_progress, total=100) + last_progress = new_progress + except InterruptProcessingException: + interrupt_current_processing() now_ts = time.monotonic() is_queued = status in queued_states @@ -414,12 +511,14 @@ async def _ticker(): if progress_bar and last_progress != 100: progress_bar.update_absolute(100, total=100) + if estimate_bar is not None: + estimate_bar.update_absolute(100, total=100) _display_time_progress( cls, status=status if status else "Completed", elapsed_seconds=int(now_ts - started), - estimated_total=estimated_duration, + estimated_total=None, price=state.price, is_queued=False, processing_elapsed_seconds=int(state.base_processing_elapsed), @@ -428,12 +527,13 @@ async def _ticker(): return resp_json if status in failed_states: + throw_exception_if_processing_interrupted() msg = f"Task failed: {json.dumps(resp_json)}" logging.error(msg) raise Exception(msg) try: - await sleep_with_interrupt(poll_interval, cls, None, None, None) + await sleep_with_interrupt(poll_interval, cls, None, None) except ProcessingInterrupted: if cancel_endpoint: with contextlib.suppress(Exception): @@ -443,10 +543,10 @@ async def _ticker(): timeout=cancel_timeout, max_retries=0, wait_label="Cancelling task", - estimated_duration=None, as_binary=False, final_label_on_success=None, monitor_progress=False, + idempotent=False, ) raise if not is_queued: @@ -503,57 +603,81 @@ def _display_time_progress( is_queued: bool | None = None, processing_elapsed_seconds: int | None = None, extra_text: str | None = None, + estimated_p90: int | None = None, + estimate_includes_queue: bool = False, ) -> None: - if estimated_total is not None and estimated_total > 0 and is_queued is False: - pe = processing_elapsed_seconds if processing_elapsed_seconds is not None else elapsed_seconds - remaining = max(0, int(estimated_total) - int(pe)) - time_line = f"Time elapsed: {int(elapsed_seconds)}s (~{remaining}s remaining)" - else: - time_line = f"Time elapsed: {int(elapsed_seconds)}s" + time_line = f"Time elapsed: {int(elapsed_seconds)}s" + if estimated_total is not None and estimated_total > 0: + if estimate_includes_queue: + if elapsed_seconds < estimated_total: + time_line += f" (~{int(estimated_total) - int(elapsed_seconds)}s remaining)" + elif estimated_p90 is not None and elapsed_seconds < estimated_p90: + time_line += " (should finish soon)" + else: + time_line += " (taking longer than usual)" + elif is_queued is False: + pe = processing_elapsed_seconds if processing_elapsed_seconds is not None else elapsed_seconds + remaining = max(0, int(estimated_total) - int(pe)) + time_line += f" (~{remaining}s remaining)" text = f"{time_line}\n\n{extra_text}" if extra_text else time_line _display_text(node_cls, text, status=status, price=price) -async def _diagnose_connectivity() -> dict[str, bool]: - """Best-effort connectivity diagnostics to distinguish local vs. server issues.""" - results = { - "internet_accessible": False, - "api_accessible": False, - } - timeout = aiohttp.ClientTimeout(total=5.0) +def _estimate_progress_pct(elapsed_seconds: float, p50_seconds: int | None, p90_seconds: int | None) -> int: + if not p50_seconds or p50_seconds <= 0 or elapsed_seconds <= 0: + return 0 + if elapsed_seconds < p50_seconds: + return int(90.0 * elapsed_seconds / p50_seconds) + horizon = p90_seconds if p90_seconds is not None and p90_seconds > p50_seconds else p50_seconds + if elapsed_seconds >= horizon: + return 95 + return 90 + int(5.0 * (elapsed_seconds - p50_seconds) / (horizon - p50_seconds)) - # Probe Google and Baidu in parallel: Google is blocked by the GFW in mainland China, so a Baidu probe is required - # to correctly detect that Chinese users with working internet do have working internet. - internet_probe_urls = ("https://www.google.com", "https://www.baidu.com") - async with aiohttp.ClientSession(timeout=timeout) as session: - async def _probe(url: str) -> bool: - try: - async with session.get(url) as resp: - return resp.status < 500 - except (ClientError, OSError, asyncio.TimeoutError): - return False +def _normalize_files(files: dict[str, Any] | list[tuple[str, Any]]) -> list[tuple[str, str, Any, str]]: + """Flatten `files` into (field_name, filename, value, content_type) once per request. - probe_tasks = [asyncio.create_task(_probe(u)) for u in internet_probe_urls] - try: - for fut in asyncio.as_completed(probe_tasks): - if await fut: - results["internet_accessible"] = True - break - finally: - for t in probe_tasks: - if not t.done(): - t.cancel() - await asyncio.gather(*probe_tasks, return_exceptions=True) - if not results["internet_accessible"]: - return results + File-like values are read into bytes here because aiohttp closes IOBase payloads + after sending, which would break re-sending the same body on retry. + """ + out = [] + file_iter = files if isinstance(files, list) else files.items() + for field_name, file_obj in file_iter: + if file_obj is None: + continue + if isinstance(file_obj, tuple): + filename, file_value, content_type = _unpack_tuple(file_obj) + else: + filename = getattr(file_obj, "name", field_name) + file_value = file_obj + content_type = "application/octet-stream" + if hasattr(file_value, "read"): + with contextlib.suppress(Exception): + file_value.seek(0) + data = file_value.read() + if not isinstance(file_value, BytesIO): + with contextlib.suppress(Exception): + file_value.close() + file_value = data + out.append((field_name, filename, file_value, content_type)) + return out - parsed = urlparse(default_base_url()) - health_url = f"{parsed.scheme}://{parsed.netloc}/health" - with contextlib.suppress(ClientError, OSError): - async with session.get(health_url) as resp: - results["api_accessible"] = resp.status < 500 - return results + +def _build_multipart_form(cfg: _RequestConfig, files: list[tuple[str, str, Any, str]]) -> aiohttp.FormData: + if cfg.multipart_parser and cfg.data: + form = cfg.multipart_parser(cfg.data) + if not isinstance(form, aiohttp.FormData): + raise ValueError("multipart_parser must return aiohttp.FormData") + else: + form = aiohttp.FormData(default_to_multipart=True) + if cfg.data: + for k, v in cfg.data.items(): + if v is None: + continue + form.add_field(k, str(v) if not isinstance(v, (bytes, bytearray)) else v) + for field_name, filename, file_value, content_type in files: + form.add_field(field_name, file_value, filename=filename, content_type=content_type) + return form def _unpack_tuple(t: tuple) -> tuple[str, Any, str]: @@ -580,7 +704,38 @@ def _merge_params(endpoint_params: dict[str, Any], method: str, data: dict[str, def _is_terminal_service_refusal(body: Any) -> bool: - return isinstance(body, dict) and body.get("error") in _TERMINAL_SERVICE_REFUSALS + if not isinstance(body, dict): + return False + err = body.get("error") + return isinstance(err, str) and err in _TERMINAL_SERVICE_REFUSALS + + +def _response_detail(body: Any) -> str: + if isinstance(body, dict): + for key in ("detail", "message"): + if isinstance(body.get(key), str) and body[key]: + return body[key] + return "" + + +def _provider_error_detail(metadata: Any) -> str | None: + if not isinstance(metadata, dict): + return None + raw = metadata.get("raw") + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + except ValueError: + return raw.strip()[:500] + if isinstance(parsed, dict): + inner = parsed.get("error") + if isinstance(inner, dict) and isinstance(inner.get("message"), str): + return inner["message"] + if isinstance(parsed.get("message"), str): + return parsed["message"] + return raw.strip()[:500] + code = metadata.get("provider_error_code") + return code if isinstance(code, str) and code else None def _friendly_http_message(status: int, body: Any) -> str: @@ -592,6 +747,8 @@ def _friendly_http_message(status: int, body: Any) -> str: return "There is a problem with your account. Please contact support@comfy.org." if status == 429: return "Rate Limit Exceeded: The server returned 429 after all retry attempts. Please wait and try again." + if isinstance(body, dict) and body.get("error_type") == "service_unavailable": + return "The API server could not verify this request right now. Please try again in a moment." try: if isinstance(body, dict): err = body.get("error") @@ -602,11 +759,21 @@ def _friendly_http_message(status: int, body: Any) -> str: return msg if isinstance(err, dict): msg = err.get("message") + if msg is not None and not isinstance(msg, str): + msg = str(msg) typ = err.get("type") + detail = _provider_error_detail(err.get("metadata")) + if detail and not msg: + msg = detail + elif msg and detail and detail not in msg: + msg = f"{msg}: {detail}" if msg and typ: return f"API Error: {msg} (Type: {typ})" if msg: return f"API Error: {msg}" + detail = _response_detail(body) + if detail: + return f"API Error: {detail}" return f"API Error: {json.dumps(body)}" else: txt = str(body) @@ -617,6 +784,17 @@ def _friendly_http_message(status: int, body: Any) -> str: return f"HTTP {status}: Unknown error" +def _idempotency_error_message(error_type: str, body: Any) -> str: + if error_type == "idempotency_consumed": + return ( + "The server could not return the result of the previous attempt of this request. " + f"Run the node again. ({error_type})" + ) + detail = _response_detail(body) + suffix = f"{error_type}: {detail}" if detail else error_type + return f"The server rejected the retry of this request as different from the original attempt. ({suffix})" + + def _generate_operation_id(method: str, path: str, attempt: int) -> str: slug = path.strip("/").replace("/", "_") or "op" return f"{method}_{slug}_try{attempt}_{uuid.uuid4().hex[:8]}" @@ -649,6 +827,43 @@ def _snapshot_request_body_for_logging( return data or {} +async def _read_binary_body(resp: aiohttp.ClientResponse, cfg: _RequestConfig, start_time: float) -> bytes: + buff = bytearray() + last_tick = time.monotonic() + async for chunk in resp.content.iter_chunked(64 * 1024): + buff.extend(chunk) + now = time.monotonic() + if now - last_tick >= 1.0: + last_tick = now + if is_processing_interrupted(): + raise ProcessingInterrupted("Task cancelled") + if cfg.monitor_progress: + _display_time_progress(cfg.node_cls, cfg.wait_label, int(now - start_time)) + return bytes(buff) + + +def _asset_url_from_envelope(resp: aiohttp.ClientResponse, body: bytes) -> str | None: + if resp.content_type != "application/json": + return None + try: + payload = json.loads(body) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + asset_url = payload.get("url") if isinstance(payload, dict) else None + return asset_url if isinstance(asset_url, str) and asset_url else None + + +async def _download_asset(asset_url: str, cfg: _RequestConfig) -> bytes: + buf = BytesIO() + try: + await download_url_to_bytesio(asset_url, buf, timeout=None, cls=cfg.node_cls) + except (ProcessingInterrupted, LocalNetworkError, ApiServerError): + raise + except Exception as e: + raise Exception(f"The request completed, but its result could not be downloaded: {e}") from e + return buf.getvalue() + + async def _request_base(cfg: _RequestConfig, expect_binary: bool): """Core request with retries, per-second interruption monitoring, true cancellation, and friendly errors.""" url = cfg.endpoint.path @@ -659,6 +874,8 @@ async def _request_base(cfg: _RequestConfig, expect_binary: bool): method = cfg.endpoint.method params = _merge_params(cfg.endpoint.query_params, method, cfg.data if method == "GET" else None) + keyed = is_comfy_api_request and bool(cfg.idempotency_key) + multipart_files = _normalize_files(cfg.files) if cfg.content_type == "multipart/form-data" and method != "GET" and cfg.files else [] async def _monitor(stop_evt: asyncio.Event, start_ts: float): """Every second: update elapsed time and signal interruption.""" @@ -667,23 +884,25 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): if is_processing_interrupted(): return if cfg.monitor_progress: - _display_time_progress( - cfg.node_cls, cfg.wait_label, int(time.monotonic() - start_ts), cfg.estimated_total - ) + _display_time_progress(cfg.node_cls, cfg.wait_label, int(time.monotonic() - start_ts)) await asyncio.sleep(1.0) except asyncio.CancelledError: return # normal shutdown start_time = cfg.progress_origin_ts if cfg.progress_origin_ts is not None else time.monotonic() + first_attempt_ts = time.monotonic() attempt = 0 + retries_used = 0 delay = cfg.retry_delay rate_limit_attempts = 0 rate_limit_delay = cfg.retry_delay + in_flight_waits = 0 operation_succeeded: bool = False final_elapsed_seconds: int | None = None extracted_price: float | None = None while True: attempt += 1 + attempt_ts = time.monotonic() stop_event = asyncio.Event() monitor_task: asyncio.Task | None = None sess: aiohttp.ClientSession | None = None @@ -696,11 +915,19 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): payload_headers.update(get_comfy_api_headers(cfg.node_cls)) if cfg.endpoint.headers: payload_headers.update(cfg.endpoint.headers) + if keyed: + payload_headers[IDEMPOTENCY_KEY_HEADER] = cfg.idempotency_key + if cfg.asset_urls and is_comfy_api_request: + payload_headers[ASSET_FORMAT_HEADER] = ASSET_FORMAT_URL payload_kw: dict[str, Any] = {"headers": payload_headers} if method == "GET": payload_headers.pop("Content-Type", None) - request_body_log = _snapshot_request_body_for_logging(cfg.content_type, method, cfg.data, cfg.files) + request_body_log = ( + _snapshot_request_body_for_logging(cfg.content_type, method, cfg.data, cfg.files) + if in_flight_waits == 0 + else None + ) try: if cfg.monitor_progress: monitor_task = asyncio.create_task(_monitor(stop_event, start_time)) @@ -709,36 +936,8 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): sess = aiohttp.ClientSession(timeout=timeout) if cfg.content_type == "multipart/form-data" and method != "GET": - # aiohttp will set Content-Type boundary; remove any fixed Content-Type payload_headers.pop("Content-Type", None) - if cfg.multipart_parser and cfg.data: - form = cfg.multipart_parser(cfg.data) - if not isinstance(form, aiohttp.FormData): - raise ValueError("multipart_parser must return aiohttp.FormData") - else: - form = aiohttp.FormData(default_to_multipart=True) - if cfg.data: - for k, v in cfg.data.items(): - if v is None: - continue - form.add_field(k, str(v) if not isinstance(v, (bytes, bytearray)) else v) - if cfg.files: - file_iter = cfg.files if isinstance(cfg.files, list) else cfg.files.items() - for field_name, file_obj in file_iter: - if file_obj is None: - continue - if isinstance(file_obj, tuple): - filename, file_value, content_type = _unpack_tuple(file_obj) - else: - filename = getattr(file_obj, "name", field_name) - file_value = file_obj - content_type = "application/octet-stream" - # Attempt to rewind BytesIO for retries - if isinstance(file_value, BytesIO): - with contextlib.suppress(Exception): - file_value.seek(0) - form.add_field(field_name, file_value, filename=filename, content_type=content_type) - payload_kw["data"] = form + payload_kw["data"] = _build_multipart_form(cfg, multipart_files) elif cfg.content_type == "application/x-www-form-urlencoded" and method != "GET": payload_headers["Content-Type"] = "application/x-www-form-urlencoded" payload_kw["data"] = cfg.data or {} @@ -773,18 +972,63 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): # Otherwise, request finished resp = await req_task async with resp: + if keyed and resp.headers.get(_IDEMPOTENT_REPLAYED_HEADER): + logging.info("Server replayed the response of a previous attempt for %s %s", method, url) if resp.status >= 400: try: body = await resp.json() except (ContentTypeError, json.JSONDecodeError): body = await resp.text() + error_type = resp.headers.get(_ERROR_TYPE_HEADER) or (body.get("error_type") if isinstance(body, dict) else "") + error_type = error_type.strip().lower() if isinstance(error_type, str) else "" + if keyed and resp.status == 409 and error_type in _IDEMPOTENCY_TERMINAL: + msg = _idempotency_error_message(error_type, body) + request_logger.log_request_response( + operation_id=operation_id, + request_method=method, + request_url=url, + response_status_code=resp.status, + response_headers=dict(resp.headers), + response_content=body, + error_message=msg, + ) + raise Exception(msg) should_retry = False + in_flight = False wait_time = 0.0 + remaining = 0.0 retry_label = "" is_rl = resp.status == 429 or ( cfg.is_rate_limited is not None and cfg.is_rate_limited(resp.status, body) ) - if is_rl and rate_limit_attempts < cfg.max_retries_on_rate_limit: + if keyed and resp.status == 409 and error_type == _IDEMPOTENCY_IN_FLIGHT: + remaining = cfg.timeout - (time.monotonic() - first_attempt_ts) + if remaining <= 0: + msg = ( + "The server is still processing the previous attempt of this request " + "and did not finish within the node's timeout." + ) + request_logger.log_request_response( + operation_id=operation_id, + request_method=method, + request_url=url, + response_status_code=resp.status, + response_headers=dict(resp.headers), + response_content=body, + error_message=msg, + ) + raise Exception(msg) + in_flight_waits += 1 + in_flight = True + retries_used = 0 + delay = cfg.retry_delay + wait_time = min( + _IDEMPOTENCY_IN_FLIGHT_MAX_WAIT, + _IDEMPOTENCY_IN_FLIGHT_WAIT * 1.5 ** (in_flight_waits - 1), + ) + retry_label = f"previous attempt still in progress, check {in_flight_waits}" + should_retry = True + elif is_rl and rate_limit_attempts < cfg.max_retries_on_rate_limit: rate_limit_attempts += 1 wait_time = min(rate_limit_delay, 30.0) rate_limit_delay *= cfg.retry_backoff @@ -793,16 +1037,23 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): elif ( resp.status in _RETRY_STATUS and not _is_terminal_service_refusal(body) - and (attempt - rate_limit_attempts) <= cfg.max_retries + and retries_used < cfg.max_retries ): + retries_used += 1 wait_time = delay delay *= cfg.retry_backoff - retry_label = f"retry {attempt - rate_limit_attempts} of {cfg.max_retries}" + retry_label = f"retry {retries_used} of {cfg.max_retries}" should_retry = True if should_retry: - wait_time = _retry_after_wait(resp.headers.get("Retry-After"), wait_time, _MAX_RETRY_AFTER_WAIT) - logging.warning( + retry_after = _retry_after_wait(resp.headers.get("Retry-After"), wait_time, _MAX_RETRY_AFTER_WAIT) + if in_flight: + wait_time = min(max(wait_time, retry_after), remaining) + else: + wait_time = retry_after + if keyed and resp.headers.get(_IDEMPOTENT_REPLAYED_HEADER): + cfg.idempotency_key = uuid.uuid4().hex + (logging.info if in_flight else logging.warning)( "HTTP %s %s -> %s. Waiting %.2fs (%s).", method, url, @@ -824,7 +1075,6 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): cfg.node_cls, cfg.wait_label if cfg.monitor_progress else None, start_time if cfg.monitor_progress else None, - cfg.estimated_total, display_callback=_display_time_progress if cfg.monitor_progress else None, ) continue @@ -841,30 +1091,17 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): raise Exception(msg) if expect_binary: - buff = bytearray() - last_tick = time.monotonic() - async for chunk in resp.content.iter_chunked(64 * 1024): - buff.extend(chunk) - now = time.monotonic() - if now - last_tick >= 1.0: - last_tick = now - if is_processing_interrupted(): - raise ProcessingInterrupted("Task cancelled") - if cfg.monitor_progress: - _display_time_progress( - cfg.node_cls, cfg.wait_label, int(now - start_time), cfg.estimated_total - ) - bytes_payload = bytes(buff) + bytes_payload = await _read_binary_body(resp, cfg, start_time) resp_headers = {k.lower(): v for k, v in resp.headers.items()} + asset_url = _asset_url_from_envelope(resp, bytes_payload) if cfg.asset_urls else None if is_comfy_api_request: _maybe_remember_credits_used(cfg.node_cls, resp.headers.get(PRICE_CREDITS_HEADER)) + _maybe_remember_server_estimate(cfg.node_cls, resp.headers) if cfg.price_extractor: with contextlib.suppress(Exception): extracted_price = cfg.price_extractor(resp_headers) if cfg.response_header_validator: cfg.response_header_validator(resp_headers) - operation_succeeded = True - final_elapsed_seconds = int(time.monotonic() - start_time) request_logger.log_request_response( operation_id=operation_id, request_method=method, @@ -873,6 +1110,10 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): response_headers=resp_headers, response_content=bytes_payload, ) + if asset_url: + bytes_payload = await _download_asset(asset_url, cfg) + operation_succeeded = True + final_elapsed_seconds = int(time.monotonic() - start_time) return bytes_payload else: try: @@ -887,6 +1128,7 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): response_content_to_log = payload if isinstance(payload, dict) else text if is_comfy_api_request: _maybe_remember_credits_used(cfg.node_cls, resp.headers.get(PRICE_CREDITS_HEADER)) + _maybe_remember_server_estimate(cfg.node_cls, resp.headers) with contextlib.suppress(Exception): extracted_price = cfg.price_extractor(payload) if cfg.price_extractor else None operation_succeeded = True @@ -904,15 +1146,18 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): except ProcessingInterrupted: logging.debug("Polling was interrupted by user") raise - except (ClientError, OSError) as e: - if (attempt - rate_limit_attempts) <= cfg.max_retries: + except (ClientError, OSError, asyncio.TimeoutError) as e: + if retries_used < cfg.max_retries: + retries_used += 1 logging.warning( - "Connection error calling %s %s. Retrying in %.2fs (%d/%d): %s", + "Connection error calling %s %s after %.1fs. Retrying in %.2fs (%d/%d): %s: %s", method, url, + time.monotonic() - attempt_ts, delay, - attempt - rate_limit_attempts, + retries_used, cfg.max_retries, + type(e).__name__, str(e), ) request_logger.log_request_response( @@ -929,12 +1174,11 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): cfg.node_cls, cfg.wait_label if cfg.monitor_progress else None, start_time if cfg.monitor_progress else None, - cfg.estimated_total, display_callback=_display_time_progress if cfg.monitor_progress else None, ) delay *= cfg.retry_backoff continue - diag = await _diagnose_connectivity() + diag = await diagnose_connectivity() if not diag["internet_accessible"]: request_logger.log_request_response( operation_id=operation_id, @@ -980,7 +1224,7 @@ async def _monitor(stop_evt: asyncio.Event, start_ts: float): if final_elapsed_seconds is not None else int(time.monotonic() - start_time) ), - estimated_total=cfg.estimated_total, + estimated_total=None, price=extracted_price, is_queued=False, processing_elapsed_seconds=final_elapsed_seconds, diff --git a/comfy_api_nodes/util/download_helpers.py b/comfy_api_nodes/util/download_helpers.py index 4344bf7dc52..d6427af5858 100644 --- a/comfy_api_nodes/util/download_helpers.py +++ b/comfy_api_nodes/util/download_helpers.py @@ -17,12 +17,12 @@ from . import request_logger from ._helpers import ( default_base_url, + diagnose_connectivity, get_comfy_api_headers, is_processing_interrupted, sleep_with_interrupt, to_aiohttp_url, ) -from .client import _diagnose_connectivity from .common_exceptions import ApiServerError, LocalNetworkError, ProcessingInterrupted from .conversions import bytesio_to_image_tensor @@ -143,7 +143,7 @@ async def _monitor(): ) if resp.status in _RETRY_STATUS and attempt <= max_retries: - await sleep_with_interrupt(delay, cls, None, None, None) + await sleep_with_interrupt(delay, cls, None, None) delay *= retry_backoff continue raise Exception(f"Failed to download (HTTP {resp.status}).") @@ -158,24 +158,29 @@ async def _monitor(): sink = dest # BytesIO or file-like written = 0 - while True: - try: - chunk = await asyncio.wait_for(resp.content.read(1024 * 1024), timeout=1.0) - except asyncio.TimeoutError: - chunk = b"" - except asyncio.CancelledError: - raise ProcessingInterrupted("Task cancelled") from None - - if is_processing_interrupted(): - raise ProcessingInterrupted("Task cancelled") - - if not chunk: - if resp.content.at_eof(): - break - continue - - sink.write(chunk) - written += len(chunk) + read_task: asyncio.Task | None = None + try: + while True: + if read_task is None: + read_task = asyncio.create_task(resp.content.read(1024 * 1024)) + done, _ = await asyncio.wait({read_task}, timeout=1.0) + if is_processing_interrupted(): + raise ProcessingInterrupted("Task cancelled") + if not done: + continue + chunk = read_task.result() + read_task = None + if not chunk: + if resp.content.at_eof(): + break + continue + sink.write(chunk) + written += len(chunk) + finally: + if read_task is not None: + read_task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await read_task if isinstance(dest, BytesIO): with contextlib.suppress(Exception): @@ -192,7 +197,7 @@ async def _monitor(): return except asyncio.CancelledError: raise ProcessingInterrupted("Task cancelled") from None - except (ClientError, OSError) as e: + except (ClientError, OSError, asyncio.TimeoutError) as e: if attempt <= max_retries: request_logger.log_request_response( operation_id=op_id, @@ -200,11 +205,11 @@ async def _monitor(): request_url=url, error_message=f"{type(e).__name__}: {str(e)} (will retry)", ) - await sleep_with_interrupt(delay, cls, None, None, None) + await sleep_with_interrupt(delay, cls, None, None) delay *= retry_backoff continue - diag = await _diagnose_connectivity() + diag = await diagnose_connectivity() if not diag["internet_accessible"]: raise LocalNetworkError( "Unable to connect to the network. Please check your internet connection and try again." diff --git a/comfy_api_nodes/util/upload_helpers.py b/comfy_api_nodes/util/upload_helpers.py index f7029ee7853..22582409f0c 100644 --- a/comfy_api_nodes/util/upload_helpers.py +++ b/comfy_api_nodes/util/upload_helpers.py @@ -13,10 +13,9 @@ from comfy_api.latest import IO, Input, Types from . import request_logger -from ._helpers import is_processing_interrupted, sleep_with_interrupt +from ._helpers import diagnose_connectivity, is_processing_interrupted, sleep_with_interrupt from .client import ( ApiEndpoint, - _diagnose_connectivity, _display_time_progress, sync_op, ) @@ -330,7 +329,6 @@ async def _monitor(): cls, wait_label, start_ts, - None, display_callback=_display_time_progress if wait_label else None, ) delay *= retry_backoff @@ -362,13 +360,12 @@ async def _monitor(): cls, wait_label, start_ts, - None, display_callback=_display_time_progress if wait_label else None, ) delay *= retry_backoff continue - diag = await _diagnose_connectivity() + diag = await diagnose_connectivity() if not diag["internet_accessible"]: raise LocalNetworkError( "Unable to connect to the network. Please check your internet connection and try again." diff --git a/comfy_api_nodes/util/validation_utils.py b/comfy_api_nodes/util/validation_utils.py index f01edea964a..2ae727902f7 100644 --- a/comfy_api_nodes/util/validation_utils.py +++ b/comfy_api_nodes/util/validation_utils.py @@ -188,7 +188,7 @@ def validate_string( ) if max_length and len(string) > max_length: raise Exception( - f" Field '{field_name} cannot be longer than {max_length} characters; was {len(string)} characters long." + f"Field '{field_name}' cannot be longer than {max_length} characters; was {len(string)} characters long." ) diff --git a/comfy_execution/asset_enrichment.py b/comfy_execution/asset_enrichment.py index 38e9496a884..328f3829877 100644 --- a/comfy_execution/asset_enrichment.py +++ b/comfy_execution/asset_enrichment.py @@ -1,66 +1,115 @@ -"""Enrich executed-node output entries with asset id.""" +"""Registers the files a prompt produced as assets, at the moment the execution +result is emitted. Each output is matched to a path inside its declared base +directory and skipped when it escapes that directory or is not on disk, and a +run served from cache replays the same registration so cached results still +yield assets. Registering at emission rather than by inspecting the cache +afterwards keeps this independent of any cache eviction policy. +""" + +import copy import logging import os +from typing import TYPE_CHECKING, Callable +if TYPE_CHECKING: + from app.assets.manager import AssetManager + from app.assets.services.schemas import RegisteredAsset + from comfy_execution.server_protocol import ExecutionServer + from execution import CacheEntry -def enrich_output_with_assets(output_ui: dict) -> dict: - """Register file-type output entries as assets and inject their ``id``. - Runs at output-processing time, once per produced output, when - --enable-assets is set. Returns a new dict; entries without a resolvable - on-disk file path are left unchanged. Errors are caught per-entry so a - failure never blocks execution or the other entries. - """ - from comfy.cli_args import args - if not args.enable_assets: - return output_ui +def _resolve_output_path(entry: dict) -> str | None: + """Resolve an output entry to an absolute, in-base, on-disk file path. + Returns ``None`` (skip, no registration) when the type is unknown, the + resolved path escapes its base directory, or the file does not exist. + """ import folder_paths - from app.assets.services.ingest import register_file_in_place, DependencyMissingError - enriched = {} - for key, entries in output_ui.items(): + base = folder_paths.get_directory_by_type(entry["type"]) + if base is None: + return None + base_abs = os.path.abspath(base) + abs_path = os.path.abspath(os.path.join(base_abs, entry.get("subfolder") or "", entry["filename"])) + try: + if os.path.commonpath([base_abs, abs_path]) != base_abs: + return None + except ValueError: + return None + if not os.path.isfile(abs_path): + return None + return abs_path + + +def _enrich_in_place( + output_ui: dict, + job_id: str | None, + register: Callable[[str, str | None], "RegisteredAsset | None"], +) -> None: + """S10.6: producers that write the same output path are not coalesced (unsupported).""" + for entries in output_ui.values(): if not isinstance(entries, list): - enriched[key] = entries continue - new_entries = [] for entry in entries: if not isinstance(entry, dict) or "filename" not in entry or "type" not in entry: - new_entries.append(entry) continue try: - base = folder_paths.get_directory_by_type(entry["type"]) - if base is None: - new_entries.append(entry) - continue - base_abs = os.path.abspath(base) - abs_path = os.path.abspath(os.path.join(base_abs, entry.get("subfolder") or "", entry["filename"])) - try: - if os.path.commonpath([base_abs, abs_path]) != base_abs: - raise ValueError("escapes base") - except ValueError: - logging.warning("Asset enrichment skipped (path escapes base): %s", entry.get("filename")) - new_entries.append(entry) + abs_path = _resolve_output_path(entry) + if abs_path is None: continue - if not os.path.isfile(abs_path): - new_entries.append(entry) - continue - - # Register unconditionally: the file was just produced, and - # register_file_in_place re-hashes so an overwritten path can - # never carry a stale id. - result = register_file_in_place( - abs_path=abs_path, - name=entry["filename"], - tags=[entry["type"]], - ) - - entry = dict(entry) - entry["id"] = result.ref.id - except DependencyMissingError: - logging.warning("Asset enrichment skipped (blake3 not available): %s", entry.get("filename")) + result = register(abs_path, job_id) + if result is not None: + entry["id"] = result.id except Exception: - logging.warning("Failed to enrich output entry with asset id: %s", entry.get("filename"), exc_info=True) - new_entries.append(entry) - enriched[key] = new_entries + logging.warning("Asset registration failed for output: %s", entry.get("filename"), exc_info=True) + + +def _strip_ids(output_ui: dict) -> None: + for entries in output_ui.values(): + if not isinstance(entries, list): + continue + for entry in entries: + if isinstance(entry, dict): + entry.pop("id", None) + + +def register_executed_outputs(output_ui: dict, job_id: str, asset_manager: "AssetManager") -> dict: + enriched = copy.deepcopy(output_ui) + if not asset_manager.enabled: + return enriched + + _enrich_in_place(enriched, job_id, asset_manager.register_executed_output) + return enriched + + +def register_cached_outputs(ui_wrapper: dict | None, job_id: str, asset_manager: "AssetManager") -> dict | None: + if ui_wrapper is None: + return None + + enriched = copy.deepcopy(ui_wrapper) + output_ui = enriched.get("output") + if not isinstance(output_ui, dict): + return enriched + _strip_ids(output_ui) + + if not asset_manager.enabled: + return enriched + + _enrich_in_place(output_ui, job_id, asset_manager.register_cached_output) return enriched + + +def emit_cached_output(server: "ExecutionServer", node_id: str, display_node_id: str, cached: "CacheEntry", prompt_id: str, ui_outputs: dict, asset_manager: "AssetManager") -> None: + if node_id in ui_outputs: + return + enriched = register_cached_outputs(cached.ui, prompt_id, asset_manager) + if enriched is not None: + ui_outputs[node_id] = enriched + if server.client_id is None: + return + output = enriched.get("output") if enriched is not None else None + server.send_sync( + "executed", + {"node": node_id, "display_node": display_node_id, "output": output, "prompt_id": prompt_id}, + server.client_id, + ) diff --git a/comfy_execution/caching.py b/comfy_execution/caching.py index 9ff52fc4826..25f471feb42 100644 --- a/comfy_execution/caching.py +++ b/comfy_execution/caching.py @@ -1,6 +1,7 @@ import asyncio import bisect import itertools +import logging import time import torch from typing import Sequence, Mapping, Dict @@ -564,6 +565,7 @@ def ram_release(self, target, free_active=False, min_entry_size=0): ram_usage = RAM_CACHE_DEFAULT_RAM_USAGE oom_ram_usage = ram_usage + sizing_failed = False seen_storages = set() def scan_list_for_ram_usage(outputs): nonlocal ram_usage, oom_ram_usage @@ -588,9 +590,14 @@ def scan_list_for_ram_usage(outputs): oom_ram_usage = 1e30 elif hasattr(output, "_comfy_cache_tensors"): scan_list_for_ram_usage(output._comfy_cache_tensors()) - scan_list_for_ram_usage(cache_entry.outputs) + try: + scan_list_for_ram_usage(cache_entry.outputs) + except Exception: + logging.warning("Could not size cache entry %s; evicting it first", key, exc_info=True) + sizing_failed = True + oom_ram_usage = 1e30 - if ram_usage < min_entry_size: + if not sizing_failed and ram_usage < min_entry_size: continue oom_score *= oom_ram_usage diff --git a/comfy_execution/graph.py b/comfy_execution/graph.py index 64dec2045e6..e9eba9bfb88 100644 --- a/comfy_execution/graph.py +++ b/comfy_execution/graph.py @@ -22,18 +22,27 @@ class DynamicPrompt: def __init__(self, original_prompt): # The original prompt provided by the user self.original_prompt = original_prompt + # Runtime replacements for original nodes, without mutating the submitted prompt. + self.node_overrides = {} # Any extra pieces of the graph created during execution self.ephemeral_prompt = {} self.ephemeral_parents = {} self.ephemeral_display = {} def get_node(self, node_id): + if node_id in self.node_overrides: + return self.node_overrides[node_id] if node_id in self.ephemeral_prompt: return self.ephemeral_prompt[node_id] if node_id in self.original_prompt: return self.original_prompt[node_id] raise NodeNotFoundError(f"Node {node_id} not found") + def override_node(self, node_id, node_info): + if not self.has_node(node_id): + raise NodeNotFoundError(f"Node {node_id} not found") + self.node_overrides[node_id] = node_info + def has_node(self, node_id): return node_id in self.original_prompt or node_id in self.ephemeral_prompt @@ -110,6 +119,7 @@ def __init__(self, dynprompt): self.blockCount = {} # Number of nodes this node is directly blocked by self.blocking = {} # Which nodes are blocked by this node self.externalBlocks = 0 + self.externalBlockResults = {} self.unblockedEvent = asyncio.Event() def get_input_info(self, unique_id, input_name): @@ -169,12 +179,20 @@ def add_external_block(self, node_id): assert node_id in self.blockCount, "Can't add external block to a node that isn't pending" self.externalBlocks += 1 self.blockCount[node_id] += 1 - def unblock(): - self.externalBlocks -= 1 - self.blockCount[node_id] -= 1 - self.unblockedEvent.set() + def unblock(value=None): + self.release_external_block(node_id, value) return unblock + def release_external_block(self, node_id, value=None): + self.externalBlocks -= 1 + self.blockCount[node_id] -= 1 + if value is not None: + self.externalBlockResults[node_id] = value + self.unblockedEvent.set() + + def get_external_block_result(self, node_id): + return self.externalBlockResults.get(node_id) + def is_cached(self, node_id): return False @@ -186,6 +204,7 @@ def pop_node(self, unique_id): for blocked_node_id in self.blocking[unique_id]: self.blockCount[blocked_node_id] -= 1 del self.blocking[unique_id] + self.externalBlockResults.pop(unique_id, None) def is_empty(self): return len(self.pendingNodes) == 0 @@ -239,6 +258,28 @@ def add_strong_link(self, from_node_id, from_socket, to_node_id): super().add_strong_link(from_node_id, from_socket, to_node_id) self.cache_link(from_node_id, to_node_id, from_socket) + def inhibit_nodes(self, node_ids): + """Remove pending nodes selected by the currently executing control node.""" + assert self.staged_node_id is not None, "Nodes may only be inhibited while a control node is staged" + node_ids = set(node_ids).intersection(self.pendingNodes) + assert self.staged_node_id not in node_ids, "A control node cannot inhibit itself" + + for from_node_id, blocked_nodes in self.blocking.items(): + if from_node_id not in node_ids: + for node_id in node_ids: + blocked_nodes.pop(node_id, None) + for node_id in node_ids: + for blocked_node_id in self.blocking[node_id]: + if blocked_node_id not in node_ids: + self.blockCount[blocked_node_id] -= 1 + del self.pendingNodes[node_id] + del self.blockCount[node_id] + del self.blocking[node_id] + self.execution_cache.pop(node_id, None) + self.execution_cache_listeners.pop(node_id, None) + for listeners in self.execution_cache_listeners.values(): + listeners.difference_update({listener for listener in listeners if listener[0] in node_ids}) + async def stage_node_execution(self): assert self.staged_node_id is None if self.is_empty(): @@ -315,6 +356,10 @@ def unstage_node_execution(self): assert self.staged_node_id is not None self.staged_node_id = None + def is_staged_node_blocked(self): + assert self.staged_node_id is not None + return self.blockCount[self.staged_node_id] > 0 + def complete_node_execution(self): node_id = self.staged_node_id self.pop_node(node_id) diff --git a/comfy_execution/progress.py b/comfy_execution/progress.py index 731b8dc6630..f7145dbd237 100644 --- a/comfy_execution/progress.py +++ b/comfy_execution/progress.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from comfy_execution.graph import DynamicPrompt + from comfy_execution.server_protocol import ExecutionServer from protocol import BinaryEventTypes from comfy_api import feature_flags @@ -150,7 +151,7 @@ class WebUIProgressHandler(ProgressHandler): Handler that sends progress updates to the WebUI via WebSockets. """ - def __init__(self, server_instance): + def __init__(self, server_instance: "ExecutionServer"): super().__init__("webui") self.server_instance = server_instance diff --git a/comfy_execution/server_protocol.py b/comfy_execution/server_protocol.py new file mode 100644 index 00000000000..b3f0a8bb5ca --- /dev/null +++ b/comfy_execution/server_protocol.py @@ -0,0 +1,11 @@ +from typing import Protocol + + +class ExecutionServer(Protocol): + client_id: str | None + last_node_id: str | None + sockets_metadata: dict[str, dict[str, object]] + + def send_sync(self, event: str | int, data: object, sid: str | None = None) -> None: ... + + def queue_updated(self) -> None: ... diff --git a/comfy_execution/validation.py b/comfy_execution/validation.py index ae9a2376ce9..6c51737b315 100644 --- a/comfy_execution/validation.py +++ b/comfy_execution/validation.py @@ -1,4 +1,193 @@ from comfy_api.latest import IO +from comfy_execution.graph_utils import is_link + + +class LoopValidationError(Exception): + def __init__(self, error_type, message, details, node_ids, output_ids): + super().__init__(details) + self.error = { + "type": "custom_validation_failed", + "message": message, + "details": details, + "extra_info": { + "input_name": "loop boundary", + "loop_error_type": error_type, + "node_ids": sorted(node_ids), + "output_ids": sorted(output_ids), + }, + } + + +def _walk_graph(start_ids, edges, stop_at=(), return_stops=False): + found = set() + stops = set() + pending = list(start_ids) + while pending: + node_id = pending.pop() + if node_id in found: + continue + found.add(node_id) + if node_id in stop_at: + stops.add(node_id) + else: + pending.extend(edges[node_id]) + return stops if return_stops else found + + +def _loop_validation_error(error_type, message, details, actors, children, outputs): + reached_outputs = _walk_graph(actors, children).intersection(outputs) + return LoopValidationError(error_type, message, details, actors, reached_outputs) + + +def validate_loops(prompt, outputs, node_ids, start_nodes, end_nodes): + if not start_nodes and not end_nodes: + return {} + + node_ids = set(node_ids) + all_children = {node_id: set() for node_id in prompt} + all_parents = {node_id: set() for node_id in prompt} + for node_id, node in prompt.items(): + for value in node.get("inputs", {}).values(): + if is_link(value) and value[0] in all_children: + all_children[value[0]].add(node_id) + all_parents[node_id].add(value[0]) + continuation = _walk_graph(outputs, all_children, start_nodes) + continuation.difference_update(set(start_nodes).difference(node_ids)) + node_ids.update(continuation) + node_ids.update(_walk_graph(set(end_nodes).intersection(continuation), all_parents, start_nodes)) + parents = { + node_id: { + value[0] + for value in prompt[node_id].get("inputs", {}).values() + if is_link(value) and value[0] in node_ids + } + for node_id in node_ids + } + + children = {node_id: set() for node_id in node_ids} + for node_id, node_parents in parents.items(): + for parent_id in node_parents: + children[parent_id].add(node_id) + + start_nodes = set(start_nodes).intersection(node_ids) + end_nodes = set(end_nodes).intersection(node_ids) + terminal_outputs = {node_id for node_id in outputs if not children[node_id]} + + # Construct the Start DAG independently of Ends. Completed inner loops can + # lead to later Starts which are still nested under the same outer Start. + start_dag = { + start_id: _walk_graph(children[start_id], children, start_nodes, return_stops=True) + for start_id in start_nodes + } + start_descendants = { + start_id: _walk_graph(start_dag[start_id], start_dag) + for start_id in start_nodes + } + + # Construct the End DAG in the reverse direction. Its leaves are the + # innermost Ends and are therefore paired first. + end_dag = { + end_id: _walk_graph(parents[end_id], parents, end_nodes, return_stops=True) + for end_id in end_nodes + } + + pairs = {} + remaining_starts = set(start_nodes) + remaining_ends = set(end_nodes) + while remaining_ends: + end_id = next( + node_id + for node_id in sorted(remaining_ends) + if not end_dag[node_id].intersection(remaining_ends) + ) + + candidates = _walk_graph(parents[end_id], parents, remaining_starts, return_stops=True) + + if not candidates: + raise _loop_validation_error( + "loop_end_without_start", + "End Loop has no Start Loop", + f"End Loop {end_id} has no available Start Loop", + {end_id}, + children, + outputs, + ) + + closest = { + candidate + for candidate in candidates + if all(other == candidate or candidate in start_descendants[other] for other in candidates) + } + if len(closest) != 1: + candidate_list = ", ".join(sorted(candidates)) + raise _loop_validation_error( + "ambiguous_loop_nesting", + "End Loop has ambiguous Start Loops", + f"End Loop {end_id} can close multiple unrelated Start Loops: {candidate_list}", + candidates.union((end_id,)), + children, + outputs, + ) + + start_id = closest.pop() + pairs[start_id] = end_id + remaining_starts.remove(start_id) + remaining_ends.remove(end_id) + + # Validate the new pair immediately. Previously paired Ends are inner + # boundaries and may be crossed; an unpaired End or output is an escape. + escapes = _walk_graph( + children[start_id], + children, + remaining_ends | terminal_outputs | {end_id}, + return_stops=True, + ) + escapes.discard(end_id) + if escapes: + escape_list = ", ".join(sorted(escapes)) + raise _loop_validation_error( + "loop_escape", + "Loop body is not closed", + f"Start Loop {start_id} reaches {escape_list} without passing through End Loop {end_id}", + escapes | {start_id, end_id}, + children, + outputs, + ) + + if remaining_starts: + start_list = ", ".join(sorted(remaining_starts)) + raise _loop_validation_error( + "loop_start_without_end", + "Start Loop has no End Loop", + f"Start Loops without End Loops: {start_list}", + remaining_starts, + children, + outputs, + ) + + bodies = {} + for start_id, end_id in pairs.items(): + body = _walk_graph(children[start_id], children, {end_id}) + body.remove(end_id) + bodies[start_id] = body + accumulate = prompt[end_id].get("inputs", {}).get("accumulate") + if is_link(accumulate) and (accumulate[0] == start_id or accumulate[0] in body): + source_id = accumulate[0] + raise _loop_validation_error( + "loop_accumulate_from_body", + "End Loop accumulate depends on its loop body", + f"End Loop {end_id} accumulate is driven by loop node {source_id} under Start Loop {start_id}", + {start_id, end_id, source_id}, + children, + outputs, + ) + + for start_id, end_id in pairs.items(): + body = bodies[start_id] + prompt[start_id]["_loop_body"] = sorted(body) + prompt[start_id]["_loop_end"] = end_id + + return pairs def validate_node_input( diff --git a/comfy_extras/nodes_audio_encoder.py b/comfy_extras/nodes_audio_encoder.py index 2ae30d321b9..f525cf62581 100644 --- a/comfy_extras/nodes_audio_encoder.py +++ b/comfy_extras/nodes_audio_encoder.py @@ -50,12 +50,34 @@ def execute(cls, audio_encoder, audio) -> io.NodeOutput: return io.NodeOutput(output) +class SheetSage2AudioToABC(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="SheetSage2AudioToABC", + display_name="SheetSage2 Audio to ABC", + category="model/conditioning/yue2", + description="Transcribes vocal and instrumental melodies from music into ABC notation. Connect abc output to YuE2 Generate Music node and use the matching mode.", + inputs=[ + io.AudioEncoder.Input("audio_encoder"), + io.Audio.Input("audio"), + io.Combo.Input("mode", options=["melody", "full"], tooltip="full: generates melody and chords; melody: generates melody only, recommended for covers."), + ], + outputs=[io.String.Output(display_name="abc", is_output_list=True)], + ) + + @classmethod + def execute(cls, audio_encoder, audio, mode): + return io.NodeOutput(audio_encoder.generate_abc(audio["waveform"], audio["sample_rate"], melody_only=mode == "melody")) + + class AudioEncoder(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ AudioEncoderLoader, AudioEncoderEncode, + SheetSage2AudioToABC, ] diff --git a/comfy_extras/nodes_cond.py b/comfy_extras/nodes_cond.py index c8091b7a4d7..ac43a63b476 100644 --- a/comfy_extras/nodes_cond.py +++ b/comfy_extras/nodes_cond.py @@ -1,5 +1,12 @@ +import json +import os + +import safetensors.torch +import torch from typing_extensions import override +import comfy.utils +import folder_paths from comfy_api.latest import ComfyExtension, io @@ -57,12 +64,84 @@ def execute(cls, clip, min_padding, min_length) -> io.NodeOutput: return io.NodeOutput(clip) +class ConditioningLoader(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="ConditioningLoader", + display_name="Load Conditioning", + category="model/loaders", + description="Loads a conditioning saved with Save Conditioning, or any safetensors file with a 'conditioning' tensor, from the embeddings folder.", + inputs=[ + io.Combo.Input("conditioning_name", options=folder_paths.get_filename_list("embeddings")), + ], + outputs=[io.Conditioning.Output()], + ) + + @classmethod + def execute(cls, conditioning_name) -> io.NodeOutput: + sd, metadata = comfy.utils.load_torch_file(folder_paths.get_full_path_or_raise("embeddings", conditioning_name), safe_load=True, return_metadata=True) + cond = sd.pop("conditioning") + options = json.loads((metadata or {}).get("conditioning_options", "{}")) + lists = {} + for k, v in sd.items(): + name, _, index = k.rpartition(".") + if index.isdigit(): + lists.setdefault(name, {})[int(index)] = v + else: + options[k] = v + options.update({k: [v[i] for i in sorted(v)] for k, v in lists.items()}) + return io.NodeOutput([[cond, options]]) + + +class SaveConditioning(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="SaveConditioning", + search_aliases=["save conditioning", "export conditioning", "cache text encoder output"], + display_name="Save Conditioning", + category="model/conditioning", + description="Saves a conditioning to the output folder as safetensors; move the file to models/embeddings to load it with Load Conditioning, e.g. to skip the text encoder.", + is_output_node=True, + inputs=[ + io.Conditioning.Input("conditioning"), + io.String.Input("filename_prefix", default="conditioning/ComfyUI"), + ], + outputs=[io.Conditioning.Output(display_name="conditioning")], + ) + + @classmethod + def execute(cls, conditioning, filename_prefix) -> io.NodeOutput: + if len(conditioning) != 1: + raise ValueError("Save Conditioning supports a single conditioning entry, save it before combining.") + cond, options = conditioning[0] + sd = {"conditioning": cond} + values = {} + for k, v in options.items(): + if isinstance(v, torch.Tensor): + sd[k] = v + elif isinstance(v, (list, tuple)) and all(isinstance(t, torch.Tensor) for t in v): + sd.update({f"{k}.{i}": t for i, t in enumerate(v)}) + elif isinstance(v, (bool, int, float, str)): + values[k] = v + elif v is not None: + raise ValueError(f"Conditioning option '{k}' ({type(v).__name__}) can't be saved.") + full_output_folder, filename, counter, _, _ = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory()) + safetensors.torch.save_file({k: v.detach().to("cpu", copy=True).contiguous() for k, v in sd.items()}, + os.path.join(full_output_folder, f"{filename}_{counter:05}_.safetensors"), + metadata={"conditioning_options": json.dumps(values)}) + return io.NodeOutput(conditioning) + + class CondExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ CLIPTextEncodeControlnet, T5TokenizerOptions, + ConditioningLoader, + SaveConditioning, ] diff --git a/comfy_extras/nodes_cosmos.py b/comfy_extras/nodes_cosmos.py index 93cc67a6c04..a6fc0b4b4d7 100644 --- a/comfy_extras/nodes_cosmos.py +++ b/comfy_extras/nodes_cosmos.py @@ -13,6 +13,7 @@ class EmptyCosmosLatentVideo(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="EmptyCosmosLatentVideo", + display_name="Empty Cosmos Latent Video", category="model/latent/cosmos", inputs=[ io.Int.Input("width", default=1280, min=16, max=nodes.MAX_RESOLUTION, step=16), diff --git a/comfy_extras/nodes_hunyuan.py b/comfy_extras/nodes_hunyuan.py index ce299724584..477a11e0942 100644 --- a/comfy_extras/nodes_hunyuan.py +++ b/comfy_extras/nodes_hunyuan.py @@ -43,7 +43,7 @@ class EmptyHunyuanLatentVideo(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="EmptyHunyuanLatentVideo", - display_name="Empty HunyuanVideo 1.0 Latent", + display_name="Empty Hunyuan Video 1.0 Latent", category="model/latent/hunyuan video", inputs=[ io.Int.Input("width", default=848, min=16, max=nodes.MAX_RESOLUTION, step=16), @@ -69,7 +69,7 @@ class EmptyHunyuanVideo15Latent(EmptyHunyuanLatentVideo): def define_schema(cls): schema = super().define_schema() schema.node_id = "EmptyHunyuanVideo15Latent" - schema.display_name = "Empty HunyuanVideo 1.5 Latent" + schema.display_name = "Empty Hunyuan Video 1.5 Latent" schema.category = "model/latent/hunyuan video" return schema @@ -233,8 +233,8 @@ class HunyuanVideo15LatentUpscaleWithModel(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="HunyuanVideo15LatentUpscaleWithModel", - display_name="Hunyuan Video 15 Latent Upscale With Model", - category="model/latent/hunyhuan video", + display_name="Hunyuan Video 1.5 Latent Upscale With Model", + category="model/latent/hunyuan video", inputs=[ io.LatentUpscaleModel.Input("model"), io.Latent.Input("samples"), @@ -366,6 +366,7 @@ class EmptyHunyuanImageLatent(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="EmptyHunyuanImageLatent", + display_name="Empty Hunyuan Image Latent", category="model/latent/hunyuan image", inputs=[ io.Int.Input("width", default=2048, min=64, max=nodes.MAX_RESOLUTION, step=32), diff --git a/comfy_extras/nodes_hunyuan3d.py b/comfy_extras/nodes_hunyuan3d.py index c5fa946ccbb..82439449515 100644 --- a/comfy_extras/nodes_hunyuan3d.py +++ b/comfy_extras/nodes_hunyuan3d.py @@ -12,6 +12,7 @@ class EmptyLatentHunyuan3Dv2(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="EmptyLatentHunyuan3Dv2", + display_name="Empty Hunyuan 3D v2 Latent", category="model/latent/hunyuan 3d", inputs=[ IO.Int.Input("resolution", default=3072, min=1, max=8192), @@ -97,6 +98,7 @@ class VAEDecodeHunyuan3D(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="VAEDecodeHunyuan3D", + display_name="Hunyuan 3D VAE Decode", category="model/latent/hunyuan 3d", inputs=[ IO.Latent.Input("samples"), diff --git a/comfy_extras/nodes_images.py b/comfy_extras/nodes_images.py index 91a3f990c1e..9949fd1bd23 100644 --- a/comfy_extras/nodes_images.py +++ b/comfy_extras/nodes_images.py @@ -336,6 +336,8 @@ def define_schema(cls): def execute(cls, image, seed, strength) -> IO.NodeOutput: generator = torch.manual_seed(seed) s = torch.clip((image + strength * torch.randn(image.size(), generator=generator, device="cpu").to(image)), min=0.0, max=1.0) + if image.shape[-1] == 4: # alpha stores transparency, not color + s[..., 3] = image[..., 3] return IO.NodeOutput(s) repeat = execute # TODO: remove @@ -1109,12 +1111,12 @@ def _compress_rgb_gamut(rgb, weights): class ImageColorSpace(IO.ComfyNode): @classmethod def define_schema(cls): - spaces = ["sRGB", "HDR", "HDR PQ"] + spaces = ["sRGB", "HDR", "HDR PQ", "linear"] return IO.Schema( node_id="ImageColorSpace", display_name="Convert Image Color Space", category="image/color", - description="Convert sRGB (Rec.709), HDR (Rec.2020 HLG), and HDR PQ (Rec.2020 PQ). Uses 203-nit SDR white and a 1000-nit HLG reference display. Narrowing tone-maps excess luminance across the batch and compresses out-of-gamut colors. Conversions compute in float32 and return the intermediate device and dtype. Straight alpha is not color-transformed.", + description="Convert sRGB, linear Rec.709, HDR (Rec.2020 HLG), and HDR PQ (Rec.2020 PQ). Linear 1.0 uses the same 203-nit reference white as sRGB; HLG uses a 1000-nit reference display. Linear output and linear-to-HDR conversions preserve extended values without tone mapping. SDR output and PQ-to-HLG conversion tone-map excess luminance across the batch and compress out-of-gamut colors. Conversions compute in float32 and return the intermediate device and dtype. Straight alpha is not color-transformed.", inputs=[ IO.Image.Input("image"), IO.Combo.Input("source", options=spaces, default="sRGB", tooltip="Color space of the input pixels."), @@ -1134,6 +1136,8 @@ def execute(cls, image, source, destination) -> IO.NodeOutput: # Convert to display-linear Rec.2020 in cd/m² (BT.2100 EOTFs). if source == "sRGB": rgb = _convert_rgb_primaries(srgb_to_linear(rgb), _REC709_TO_REC2020) * _SDR_WHITE_NITS + elif source == "linear": + rgb = _convert_rgb_primaries(rgb, _REC709_TO_REC2020) * _SDR_WHITE_NITS elif source == "HDR": rgb = hlg_to_linear(rgb) luminance = _rgb_luminance(rgb, _REC2020_LUMA).clamp_min(0.0) @@ -1145,7 +1149,9 @@ def execute(cls, image, source, destination) -> IO.NodeOutput: else: raise ValueError(f"Unsupported source color space: {source}") - if destination == "sRGB": + if destination == "linear": + rgb = _convert_rgb_primaries(rgb / _SDR_WHITE_NITS, _REC2020_TO_REC709) + elif destination == "sRGB": rgb = _convert_rgb_primaries(rgb / _SDR_WHITE_NITS, _REC2020_TO_REC709) rgb = _tone_map_luminance(rgb, _REC709_LUMA) rgb = _compress_rgb_gamut(rgb, _REC709_LUMA) diff --git a/comfy_extras/nodes_load_3d.py b/comfy_extras/nodes_load_3d.py index ecd14eb3346..bdb9f7a82ca 100644 --- a/comfy_extras/nodes_load_3d.py +++ b/comfy_extras/nodes_load_3d.py @@ -156,16 +156,16 @@ def define_schema(cls): ), IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True), IO.Load3D.Input("viewport_state"), - IO.Load3DCamera.Input("camera_info", optional=True, advanced=True), - IO.Int.Input("width", default=1024, min=1, max=4096, step=1), - IO.Int.Input("height", default=1024, min=1, max=4096, step=1), + IO.Load3DCamera.Input("camera_info", optional=True, advanced=True, tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Input("width", default=1024, min=1, max=4096, step=1, tooltip="Render width of the viewport in pixels."), + IO.Int.Input("height", default=1024, min=1, max=4096, step=1, tooltip="Render height of the viewport in pixels."), ], outputs=[ - IO.File3DAny.Output(display_name="model_3d"), - IO.Load3DModelInfo.Output(display_name="model_3d_info"), - IO.Load3DCamera.Output(display_name="camera_info"), - IO.Int.Output(display_name="width"), - IO.Int.Output(display_name="height"), + IO.File3DAny.Output(display_name="model_3d", tooltip="3D model file (glb/obj/stl/etc.) from an upstream 3D node."), + IO.Load3DModelInfo.Output(display_name="model_3d_info", tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Output(display_name="camera_info", tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Output(display_name="width", tooltip="Render width of the viewport in pixels."), + IO.Int.Output(display_name="height", tooltip="Render height of the viewport in pixels."), ], ) @@ -355,15 +355,15 @@ def define_schema(cls): inputs=[ IO.Combo.Input("model_file", options=["none"] + sorted(files), upload=IO.UploadType.model), IO.Load3D.Input("viewport_state"), - IO.Int.Input("width", default=1024, min=1, max=4096, step=1), - IO.Int.Input("height", default=1024, min=1, max=4096, step=1), + IO.Int.Input("width", default=1024, min=1, max=4096, step=1, tooltip="Render width of the viewport in pixels."), + IO.Int.Input("height", default=1024, min=1, max=4096, step=1, tooltip="Render height of the viewport in pixels."), ], outputs=[ - IO.File3DAny.Output(display_name="model_3d"), - IO.Load3DModelInfo.Output(display_name="model_3d_info"), - IO.Load3DCamera.Output(display_name="camera_info"), - IO.Int.Output(display_name="width"), - IO.Int.Output(display_name="height"), + IO.File3DAny.Output(display_name="model_3d", tooltip="Loaded 3D model file (glb/obj/stl/etc.)."), + IO.Load3DModelInfo.Output(display_name="model_3d_info", tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Output(display_name="camera_info", tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Output(display_name="width", tooltip="Render width of the viewport in pixels."), + IO.Int.Output(display_name="height", tooltip="Render height of the viewport in pixels."), ], ) diff --git a/comfy_extras/nodes_loop.py b/comfy_extras/nodes_loop.py new file mode 100644 index 00000000000..622b42a46c4 --- /dev/null +++ b/comfy_extras/nodes_loop.py @@ -0,0 +1,409 @@ +from comfy_api.latest import io +from comfy_execution.graph_utils import GraphBuilder, is_link +from server import PromptServer + + +def _cache_enabled(value): + return value[0] if isinstance(value, list) else value + + +def _expand_loop(dynprompt, opener_id, body, close_id, values, list_items, initial_value, reuse_cache): + graph = GraphBuilder() + loop_metadata = {} + close_inputs = dynprompt.get_node(close_id)["inputs"] + output_source = close_inputs.get("output_value") + next_source = close_inputs.get("next_iteration_value") + terminations = [value for name, value in close_inputs.items() if name.startswith("termination") and is_link(value)] + accumulate = bool(close_inputs.get("accumulate", False)) + previous_carry = initial_value + previous_dependencies = [] + previous_progress = None + result_inputs = {"close_id": close_id} + + for position, value in enumerate(values): + item = list_items[position] if list_items is not None else None + iteration_inputs = { + "iteration_index": value, + "is_first": position == 0, + "is_last": position == len(values) - 1, + "list_item": item, + "current_iteration_value": previous_carry, + "reuse_cache": reuse_cache, + **{f"dependency{index}": dependency for index, dependency in enumerate(previous_dependencies)}, + } + iteration = graph.node( + "LoopIteration", + f"iteration_{position}", + **iteration_inputs, + ) + iteration.set_override_display_id(opener_id) + copies = {} + for node_id in body: + original = dynprompt.get_node(node_id) + copy = graph.node(original["class_type"], f"{position}_{node_id}") + copy.set_override_display_id(node_id) + copies[node_id] = copy + + def copied_link(source): + if not is_link(source): + return source + if source[0] == opener_id: + return iteration.out(source[1]) + if source[0] in copies: + return copies[source[0]].out(source[1]) + return source + + for node_id, copy in copies.items(): + original = dynprompt.get_node(node_id) + for name, input_value in original.get("inputs", {}).items(): + copy.set_input(name, copied_link(input_value)) + if "_loop_end" in original: + loop_metadata[copy.id] = { + "_loop_body": [copies[body_id].id for body_id in original["_loop_body"]], + "_loop_end": copies[original["_loop_end"]].id, + } + + if is_link(output_source): + copied_output = copied_link(output_source) + if accumulate: + result_inputs[f"output{position}"] = copied_output + elif position == len(values) - 1: + result_inputs["output0"] = copied_output + dependencies = [copied_output] + else: + dependencies = [] + if is_link(next_source): + previous_carry = copied_link(next_source) + dependencies.append(previous_carry) + dependencies.extend(copied_link(source) for source in terminations) + previous_dependencies = dependencies + progress_inputs = { + "start_id": opener_id, + "position": position + 1, + "total": len(values), + **{f"dependency{index}": dependency for index, dependency in enumerate(dependencies)}, + } + if previous_progress is not None: + progress_inputs["previous_progress"] = previous_progress + progress = graph.node( + "LoopProgress", + f"progress_{position}", + **progress_inputs, + ) + previous_progress = progress.out(0) + + if previous_progress is not None: + result_inputs["progress"] = previous_progress + result_inputs.update({f"dependency{index}": dependency for index, dependency in enumerate(previous_dependencies)}) + graph.node("LoopResult", "result", **result_inputs) + expanded = graph.finalize() + for node_id, metadata in loop_metadata.items(): + expanded[node_id].update(metadata) + return expanded + + +class StartLoop(io.ComfyNode): + @classmethod + def define_schema(cls): + list_item_type = io.MatchType.Template("list_item") + carried_type = io.MatchType.Template("carried_value") + return io.Schema( + node_id="StartLoop", + display_name="Start Loop", + category="utilities/looping", + loop_boundary="start", + is_input_list=True, + inputs=[ + io.DynamicCombo.Input("mode", options=[ + io.DynamicCombo.Option("simple", [ + io.Int.Input( + "num_iterations", + default=4, + min=0, + tooltip="Number of times to execute the loop body.", + ), + ]), + io.DynamicCombo.Option("For", [ + io.Int.Input( + "start_iteration_index", + default=0, + tooltip="Index of the first iteration when using For loop mode.", + ), + io.Int.Input( + "max_iteration", + default=4, + max=0xffffffffffffffff, + tooltip="The exclusive stopping value for iteration_index in For mode.", + ), + io.Int.Input( + "step", + default=1, + min=1, + tooltip="The index step size between each iteration when using For loop mode.", + ), + ]), + io.DynamicCombo.Option("List", [ + io.MatchType.Input( + "list", + list_item_type, + tooltip="List of items the loop iterates on. The loop body executes once per item.", + ), + ]), + ], tooltip="The loop iteration mode."), + io.Boolean.Input( + "cache_iterations", + default=False, + advanced=True, + tooltip="Reuse unchanged iteration results from previous executions. Disable to execute every iteration again.", + ), + io.Int.Input( + "parent_iteration", + optional=True, + force_input=True, + tooltip="Connect iteration_index from an outer Start Loop to nest this loop.", + ), + io.MatchType.Input( + "initial_iteration_value", + carried_type, + optional=True, + tooltip="Value exposed as current_iteration_value on the first iteration.", + ), + ], + outputs=[ + io.Int.Output("iteration_index", tooltip="Index of the current loop iteration."), + io.Boolean.Output("is_first", tooltip="True during the first iteration of the loop."), + io.Boolean.Output("is_last", tooltip="True during the last iteration of the loop."), + io.MatchType.Output( + list_item_type, + id="list_item", + tooltip="Current item from the list when using List mode. None in Simple and For modes.", + ), + io.MatchType.Output( + carried_type, + id="current_iteration_value", + tooltip="Loop-carried value for the current iteration: initial_iteration_value on the first iteration, then next_iteration_value from End Loop on each subsequent iteration.", + ), + ], + hidden=[io.Hidden.dynprompt, io.Hidden.execution_list, io.Hidden.unique_id], + enable_expand=True, + ) + + @classmethod + def execute(cls, mode, cache_iterations=False, parent_iteration=None, initial_iteration_value=None): + selected_mode = mode.get("mode", ["simple"])[0] + if selected_mode == "simple": + values = list(range(mode.get("num_iterations", [4])[0])) + list_items = None + elif selected_mode == "For": + step = mode.get("step", [1])[0] + if step == 0: + raise ValueError("Start Loop step must not be 0") + values = list(range(mode.get("start_iteration_index", [0])[0], mode.get("max_iteration", [4])[0], step)) + list_items = None + else: + list_items = mode["list"] + values = list(range(len(list_items))) + + dynprompt = cls.hidden.dynprompt + execution_list = cls.hidden.execution_list + unique_id = cls.hidden.unique_id + loop = dynprompt.get_node(unique_id) + body = set(loop["_loop_body"]) + close_id = loop["_loop_end"] + graph = _expand_loop( + dynprompt, + unique_id, + body, + close_id, + values, + list_items, + loop["inputs"].get("initial_iteration_value"), + _cache_enabled(cache_iterations), + ) + close = dynprompt.get_node(close_id) + close_inputs = close["inputs"].copy() + for name in tuple(close_inputs): + if name in ("output_value", "next_iteration_value") or name.startswith("termination"): + del close_inputs[name] + execution_list.add_node(close_id) + execution_list.add_external_block(close_id) + execution_list.inhibit_nodes(body) + dynprompt.override_node(close_id, {"class_type": close["class_type"], "inputs": close_inputs}) + PromptServer.instance.send_progress_text(f"Iteration 0 / {len(values)}", unique_id) + return io.NodeOutput(None, False, not values, None, None, expand=graph) + + @classmethod + def fingerprint_inputs(cls, cache_iterations=False, **kwargs): + return float("NaN") + + +class LoopIteration(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LoopIteration", + is_input_list=True, + inputs=[ + io.Int.Input("iteration_index"), + io.Boolean.Input("is_first"), + io.Boolean.Input("is_last"), + io.AnyType.Input("list_item", optional=True), + io.AnyType.Input("current_iteration_value", optional=True), + io.Boolean.Input("reuse_cache"), + ], + outputs=[ + io.Int.Output(), + io.Boolean.Output(), + io.Boolean.Output(), + io.AnyType.Output(), + io.AnyType.Output(is_output_list=True), + ], + is_dev_only=True, + accept_all_inputs=True, + ) + + @classmethod + def execute( + cls, + iteration_index, + is_first, + is_last, + reuse_cache, + list_item=None, + current_iteration_value=None, + **kwargs, + ): + return io.NodeOutput( + iteration_index[0], + is_first[0], + is_last[0], + list_item[0] if list_item else None, + current_iteration_value, + ) + + @classmethod + def fingerprint_inputs(cls, reuse_cache, **kwargs): + return None if _cache_enabled(reuse_cache) else float("NaN") + + +class LoopProgress(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LoopProgress", + is_input_list=True, + inputs=[io.String.Input("start_id"), io.Int.Input("position"), io.Int.Input("total")], + outputs=[io.Int.Output()], + is_output_node=True, + is_dev_only=True, + accept_all_inputs=True, + ) + + @classmethod + def execute(cls, start_id, position, total, **kwargs): + PromptServer.instance.send_progress_text(f"Iteration {position[0]} / {total[0]}", start_id[0]) + return io.NodeOutput(position[0]) + + @classmethod + def fingerprint_inputs(cls, **kwargs): + return float("NaN") + + +class LoopResult(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LoopResult", + is_input_list=True, + inputs=[io.String.Input("close_id")], + outputs=[], + is_output_node=True, + is_dev_only=True, + accept_all_inputs=True, + hidden=[io.Hidden.execution_list], + ) + + @classmethod + def execute(cls, close_id, **kwargs): + outputs = [] + while f"output{len(outputs)}" in kwargs: + outputs.append(kwargs[f"output{len(outputs)}"]) + cls.hidden.execution_list.release_external_block(close_id[0], outputs) + return io.NodeOutput() + + @classmethod + def fingerprint_inputs(cls, **kwargs): + return float("NaN") + + +class EndLoop(io.ComfyNode): + @classmethod + def define_schema(cls): + output_type = io.MatchType.Template("output_value") + carried_type = io.MatchType.Template("carried_value") + terminations = io.Autogrow.TemplatePrefix( + io.AnyType.Input( + "termination", + tooltip="Connect a preview or side-effect output that must execute on every iteration. Its value is not returned.", + ), + prefix="termination", + min=0, + max=50, + ) + return io.Schema( + node_id="EndLoop", + display_name="End Loop", + category="utilities/looping", + loop_boundary="end", + is_input_list=True, + inputs=[ + io.MatchType.Input( + "output_value", + output_type, + optional=True, + tooltip="Value returned by End Loop. It returns the final iteration or all iterations according to accumulate.", + ), + io.MatchType.Input( + "next_iteration_value", + carried_type, + optional=True, + tooltip="Value sent from End Loop back to Start Loop for the next iteration.", + ), + io.Boolean.Input( + "accumulate", + default=False, + tooltip="Return output_value from every iteration when enabled; otherwise return only the final iteration.", + ), + io.Autogrow.Input( + "terminations", + template=terminations, + optional=True, + tooltip="Connect outputs that must execute on every iteration. Their values are not returned.", + ), + ], + outputs=[ + io.MatchType.Output( + output_type, + id="outputs", + is_output_list=True, + tooltip="The final iteration's output_value, or values accumulated across iterations when accumulate is enabled.", + ), + ], + hidden=[io.Hidden.execution_list, io.Hidden.unique_id], + ) + + @classmethod + def execute(cls, accumulate, **kwargs): + outputs = cls.hidden.execution_list.get_external_block_result(cls.hidden.unique_id) + return io.NodeOutput([value for output in outputs for value in output]) + + +NODE_CLASS_MAPPINGS = { + "StartLoop": StartLoop, + "EndLoop": EndLoop, + "LoopIteration": LoopIteration, # Dev-only; instantiated by loop expansion. + "LoopProgress": LoopProgress, # Dev-only; instantiated by loop expansion. + "LoopResult": LoopResult, # Dev-only; instantiated by loop expansion. +} + +NODE_DISPLAY_NAME_MAPPINGS = {"StartLoop": "Start Loop", "EndLoop": "End Loop"} diff --git a/comfy_extras/nodes_lt.py b/comfy_extras/nodes_lt.py index ef622481cfd..9ca4bce3fc2 100644 --- a/comfy_extras/nodes_lt.py +++ b/comfy_extras/nodes_lt.py @@ -65,6 +65,7 @@ class EmptyLTXVLatentVideo(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="EmptyLTXVLatentVideo", + display_name="Empty LTXV Latent Video", category="model/latent/ltxv", inputs=[ io.Int.Input("width", default=768, min=64, max=nodes.MAX_RESOLUTION, step=32), @@ -428,6 +429,43 @@ def replace_latent_frames(cls, latent_image, noise_mask, guiding_latent, latent_ return latent_image, noise_mask + @classmethod + def attach_guide_latent(cls, positive, negative, latent_image, noise_mask, guide_latent, frame_idx, strength, + scale_factors, latent_downscale_factor=1, causal_fix=None, attention_mask=None): + """Dilate a guide latent onto the canvas, pin it, and record its attention entry. + + Keeps the three values that have to agree in one place: the pre-dilation + shape recorded on the attention entry, the post-dilation token count, and + the downscale factor handed to append_keyframe. context_windows re-derives + the factor from the first two, so they must not drift apart. + """ + guide_latent_shape = list(guide_latent.shape[2:]) # pre-dilation [F, H, W] for spatial-mask downsampling + guide_mask = None + if latent_downscale_factor > 1: + guide_latent, guide_mask = cls.dilate_latent(guide_latent, latent_downscale_factor) + + positive, negative, latent_image, noise_mask = cls.append_keyframe( + positive, + negative, + frame_idx, + latent_image, + noise_mask, + guide_latent, + strength, + scale_factors, + guide_mask=guide_mask, + latent_downscale_factor=latent_downscale_factor, + causal_fix=causal_fix, + ) + + # Track this guide for per-reference attention control. + pre_filter_count = guide_latent.shape[2] * guide_latent.shape[3] * guide_latent.shape[4] + positive, negative = _append_guide_attention_entry( + positive, negative, pre_filter_count, guide_latent_shape, strength=strength, + attention_mask=attention_mask, + ) + return positive, negative, latent_image, noise_mask + @classmethod def execute(cls, positive, negative, vae, latent, image, frame_idx, strength, attention_mask=None, iclora_parameters=None) -> io.NodeOutput: scale_factors = vae.downscale_index_formula @@ -462,32 +500,20 @@ def execute(cls, positive, negative, vae, latent, image, frame_idx, strength, at t = t[:, :, 1:, :, :] image = image[1:] - guide_latent_shape = list(t.shape[2:]) # pre-dilation [F, H, W] for spatial-mask downsampling - guide_mask = None - if latent_downscale_factor > 1: - t, guide_mask = cls.dilate_latent(t, latent_downscale_factor) - frame_idx, latent_idx = cls.get_latent_index(positive, latent_length, len(image), frame_idx, scale_factors, latent_shape=latent_image.shape) assert latent_idx + t.shape[2] <= latent_length, "Conditioning frames exceed the length of the latent sequence." - positive, negative, latent_image, noise_mask = cls.append_keyframe( + positive, negative, latent_image, noise_mask = cls.attach_guide_latent( positive, negative, - frame_idx, latent_image, noise_mask, t, + frame_idx, strength, scale_factors, - guide_mask=guide_mask, latent_downscale_factor=latent_downscale_factor, causal_fix=causal_fix, - ) - - # Track this guide for per-reference attention control. - pre_filter_count = t.shape[2] * t.shape[3] * t.shape[4] - positive, negative = _append_guide_attention_entry( - positive, negative, pre_filter_count, guide_latent_shape, strength=strength, attention_mask=attention_mask, ) @@ -496,6 +522,130 @@ def execute(cls, positive, negative, vae, latent, image, frame_idx, strength, at generate = execute # TODO: remove +class LTXVAddLatentGuide(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVAddLatentGuide", + display_name="LTXV Add Latent Guide", + category="model/conditioning/ltxv", + description="Pins an already-encoded latent as a guide, for when the guide comes out of an " + "earlier stage rather than an image. Same effect as LTXV Add Guide without the " + "VAE decode/encode round trip. A guide that is spatially smaller than the target " + "(an IC-LoRA or detailing reference) is dilated onto a sparse grid, and its RoPE " + "end positions are expanded by the same ratio so it covers the target canvas " + "instead of addressing only the top-left corner of it.", + search_aliases=["latent guide", "add latent guide", "guide latent"], + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Latent.Input("latent", tooltip="Target video latent the guide is pinned onto."), + io.Latent.Input( + "guiding_latent", + tooltip="Guide latent. Its spatial size must divide the target's by the same whole " + "number on both axes; equal size pins it as-is, half size is treated as an " + "x2 IC-LoRA reference.", + ), + io.Int.Input( + "latent_idx", + default=0, + min=-9999, + max=9999, + tooltip="Latent frame index to start the guide at, counted in latent frames rather " + "than pixel frames. Negative values place the guide on frames before the " + "start of the latent, not counted back from its end.", + ), + io.Float.Input( + "strength", + default=1.0, + min=0.0, + max=1.0, + step=0.01, + tooltip="Capped at 1.0. A dilated guide marks its padding positions with a " + "negative denoise mask so the model drops them; above 1.0 the kept " + "positions would go negative too and the whole guide would be dropped. " + "Amplify beyond 1.0 with attention_mask instead.", + ), + io.Mask.Input( + "attention_mask", + optional=True, + tooltip="Optional pixel-space spatial mask. Controls per-region " + "conditioning influence via self-attention, multiplied by strength.", + ), + ], + outputs=[ + io.Conditioning.Output(display_name="positive"), + io.Conditioning.Output(display_name="negative"), + io.Latent.Output(display_name="latent"), + ], + ) + + @classmethod + def execute(cls, positive, negative, vae, latent, guiding_latent, latent_idx, strength, attention_mask=None) -> io.NodeOutput: + scale_factors = vae.downscale_index_formula + latent_image = latent["samples"] + noise_mask = get_noise_mask(latent) + guide_latent = guiding_latent["samples"] + + for name, samples in (("latent", latent_image), ("guiding_latent", guide_latent)): + if samples.ndim != 5: + raise ValueError( + f"{name} must be a 5D video latent (batch, channels, frames, height, width), " + f"got shape {list(samples.shape)}." + ) + + guide_frames = guide_latent.shape[2] + latent_frames = latent_image.shape[2] + if latent_idx + guide_frames > latent_frames: + raise ValueError( + f"Guide of {guide_frames} latent frame(s) at latent_idx {latent_idx} runs past the " + f"end of the {latent_frames}-frame latent. Negative values are allowed and place the " + f"guide before the start of the latent." + ) + + if latent_image.shape[3] % guide_latent.shape[3] != 0 or latent_image.shape[4] % guide_latent.shape[4] != 0: + raise ValueError( + f"Guiding latent spatial size {guide_latent.shape[3]}x{guide_latent.shape[4]} must divide " + f"the target size {latent_image.shape[3]}x{latent_image.shape[4]} by a whole number." + ) + + height_scale = latent_image.shape[3] // guide_latent.shape[3] + width_scale = latent_image.shape[4] // guide_latent.shape[4] + # dilate_latent and append_keyframe take a single IC-LoRA downscale factor for both + # axes, so a non-square ratio would mis-place RoPE on one of them. + if height_scale != width_scale: + raise ValueError( + f"Guiding latent spatial ratio must be square, got height x{height_scale} and " + f"width x{width_scale} ({guide_latent.shape[3]}x{guide_latent.shape[4]} -> " + f"{latent_image.shape[3]}x{latent_image.shape[4]})." + ) + + time_scale_factor = scale_factors[0] + if latent_idx <= 0: + frame_idx = latent_idx * time_scale_factor + else: + frame_idx = 1 + (latent_idx - 1) * time_scale_factor + + positive, negative, latent_image, noise_mask = LTXVAddGuide.attach_guide_latent( + positive, + negative, + latent_image, + noise_mask, + guide_latent, + frame_idx, + strength, + scale_factors, + latent_downscale_factor=width_scale, + attention_mask=attention_mask, + ) + + out = latent.copy() + out["samples"] = latent_image + out["noise_mask"] = noise_mask + return io.NodeOutput(positive, negative, out) + + class LTXVCropGuides(io.ComfyNode): @classmethod def define_schema(cls): @@ -1186,6 +1336,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: LTXVScheduler, GetICLoRAParameters, LTXVAddGuide, + LTXVAddLatentGuide, LTXVPreprocess, LTXVCropGuides, LTXVConcatAVLatent, diff --git a/comfy_extras/nodes_lt_audio.py b/comfy_extras/nodes_lt_audio.py index 0924f3e9e9c..9eeed8fe5fa 100644 --- a/comfy_extras/nodes_lt_audio.py +++ b/comfy_extras/nodes_lt_audio.py @@ -95,7 +95,7 @@ class LTXVEmptyLatentAudio(io.ComfyNode): def define_schema(cls) -> io.Schema: return io.Schema( node_id="LTXVEmptyLatentAudio", - display_name="LTXV Empty Latent Audio", + display_name="Empty LTXV Latent Audio", category="model/latent/ltxv", inputs=[ io.Int.Input( diff --git a/comfy_extras/nodes_lt_keyframes.py b/comfy_extras/nodes_lt_keyframes.py new file mode 100644 index 00000000000..3cf07d8fb34 --- /dev/null +++ b/comfy_extras/nodes_lt_keyframes.py @@ -0,0 +1,1051 @@ +import math +import re + +import node_helpers +import torch +from comfy.ldm.lightricks.symmetric_patchifier import SymmetricPatchifier, latent_to_pixel_coords +from comfy_api.latest import ComfyExtension, io +from comfy_extras.nodes_lt import ( + LTXVAddGuide, + _append_guide_attention_entry, + conditioning_get_any_value, + get_keyframe_idxs, + get_noise_mask, +) +from typing_extensions import override + +DEFAULT_TEMPORAL_SCALE = 8 +_OCCUPIED_MASK_MAX = 1.0 - 1e-4 + + +def get_generated_keyframes(cond): + return conditioning_get_any_value(cond, "generated_keyframes", None) + + +def _parse_frame_index_list(value, field, expected_count, first, last, expected_desc, empty_hint): + """Parse a manual frame index override and validate count, uniqueness and range. + + Order is not significant — only the set of positions matters — so the list + is returned as written rather than sorted. ``expected_count`` may be None + when the list itself defines how many keyframes to add. + """ + parts = [part for part in re.split(r"[,\s]+", value.strip()) if part] + if not parts: + raise ValueError( + f"{field} is empty. Provide at least one index, or leave {field} empty {empty_hint}." + ) + try: + indices = [int(part) for part in parts] + except ValueError: + bad = ", ".join(repr(part) for part in parts if not re.fullmatch(r"-?\d+", part)) + raise ValueError( + f"{field} must be a comma-separated list of integers, but could not parse {bad}." + ) from None + + if expected_count is not None and len(indices) != expected_count: + raise ValueError( + f"{field} lists {len(indices)} index/indices but {expected_desc}. Provide exactly " + f"{expected_count}, or leave {field} empty {empty_hint}." + ) + + duplicates = sorted({index for index in indices if indices.count(index) > 1}) + if duplicates: + raise ValueError( + f"{field} must not place two keyframes on the same pixel frame, but " + f"{', '.join(str(index) for index in duplicates)} appear(s) more than once." + ) + + out_of_range = [index for index in indices if not first <= index <= last] + if out_of_range: + raise ValueError( + f"{field} must lie between {first} and {last}, but got " + f"{', '.join(str(index) for index in out_of_range)}." + ) + return indices + + +def _grow_guide_attention_entry(positive, negative, index, extra_pre_filter_count, extra_frames): + """Grow an existing guide_attention_entry when extending generated keyframes.""" + results = [] + for cond in (positive, negative): + existing = [] + for t in cond: + found = t[1].get("guide_attention_entries", None) + if found is not None: + existing = found + break + if index >= len(existing): + raise ValueError( + f"The generated keyframes recorded guide entry {index} but the conditioning only has " + f"{len(existing)}. The conditioning was rebuilt after they were added." + ) + entries = list(existing) + grown = dict(entries[index]) + grown["pre_filter_count"] = grown["pre_filter_count"] + extra_pre_filter_count + shape = list(grown["latent_shape"]) + shape[0] = shape[0] + extra_frames + grown["latent_shape"] = shape + entries[index] = grown + results.append(node_helpers.conditioning_set_values(cond, {"guide_attention_entries": entries})) + return results[0], results[1] + + +def _spaced_positions_keep_last(num_keyframes: int, num_frames: int) -> list[int]: + return ( + torch.linspace(0, num_frames - 1, num_keyframes + 1) + .round() + .to(torch.int64) + .tolist()[1:] + ) + + +def detailing_positions(num_frames: int, interval_frames: float) -> list[int]: + """About one detailing keyframe every ``interval_frames`` pixel frames. + + Skips frame 0 (already a standalone token) and keeps the last frame. + ``interval_frames`` 24 is about 1/s at 24 fps. + """ + if interval_frames <= 0: + raise ValueError(f"interval_frames must be > 0, got {interval_frames}") + if num_frames <= 1: + raise ValueError( + f"A {num_frames}-frame target has no pixel frames to place keyframes on." + ) + count = max(1, round((num_frames - 1) / interval_frames)) + positions = [index for index in _spaced_positions_keep_last(count, num_frames) if index != 0] + if not positions: + raise ValueError( + f"A {num_frames}-frame target has no pixel frames to place keyframes on." + ) + return positions + + +def free_detailing_slots(num_frames: int, interval_frames: float, occupied: set[int]) -> list[int]: + """Density candidates that are not already I2V / guides / detailing KFs.""" + taken = set(occupied) + positions = [ + index + for index in detailing_positions(num_frames, interval_frames) + if index not in taken + ] + if not positions: + raise ValueError( + "Every candidate detailing-keyframe pixel already has an image keyframe " + "or a guide. Leave at least one unoccupied frame, or pass frame_indices." + ) + return positions + + +def scale_frame_indices(indices: list[int], old_num_frames: int, new_num_frames: int) -> list[int]: + """Map pixel indices from one canvas length onto another (e.g. temporal x2).""" + if old_num_frames <= 1: + raise ValueError(f"Cannot scale keyframe indices from a {old_num_frames}-frame canvas.") + if new_num_frames <= 1: + raise ValueError(f"Cannot scale keyframe indices onto a {new_num_frames}-frame canvas.") + scale = (new_num_frames - 1) / (old_num_frames - 1) + remapped = [int(round(index * scale)) for index in indices] + duplicates = sorted({index for index in remapped if remapped.count(index) > 1}) + if duplicates: + raise ValueError( + "Scaling keyframe indices onto the new canvas collapsed " + f"{', '.join(str(index) for index in duplicates)} onto the same pixel frame." + ) + return remapped + + +def _as_int_set(value) -> set[int]: + if value is None: + return set() + if isinstance(value, (int, float)): + return {int(round(value))} + if isinstance(value, (set, frozenset)): + return {int(round(item)) for item in value} + if isinstance(value, (list, tuple)): + out = set() + for item in value: + out |= _as_int_set(item) + return out + if hasattr(value, "detach"): + value = value.detach() + if hasattr(value, "cpu"): + value = value.cpu() + if hasattr(value, "tolist"): + return _as_int_set(value.tolist()) + return {int(round(float(value)))} + + +def pixel_frames_from_keyframe_idxs(keyframe_idxs) -> set[int]: + """Unique RoPE *start* pixel times of extra guide / keyframe tokens. + + ``keyframe_idxs`` is ``(B, 3, tokens, 2)`` (t/h/w × start/end). A keyframe + at pixel 24 spans the half-open interval ``[24, 25)``, so only 24 is + occupied — the exclusive end is not a slot. + """ + if keyframe_idxs is None: + return set() + if keyframe_idxs.ndim >= 4: + starts = keyframe_idxs[:, 0, :, 0] + else: + starts = keyframe_idxs[:, 0] + return _as_int_set(starts) + + +def occupied_pixel_frames(latent, temporal_scale: int, num_frames: int, video_latent_frames=None) -> set[int]: + """Pixel frames that already hold an in-place image keyframe. + + Prefers ``noise_mask`` (0 means frozen / guided). Falls back to + non-zero latent frames when no mask is present. Each occupied latent + index maps to its representative pixel: 0, ``t * scale``, or last frame. + + ``video_latent_frames`` limits the scan to the video portion of T so + appended guide / detailing-keyframe tokens are not treated as video frames. + Extra-token pixel times come from ``pixel_frames_from_keyframe_idxs``. + """ + samples = latent["samples"] + latent_frames = samples.shape[2] + if video_latent_frames is None: + scan_frames = latent_frames + else: + scan_frames = min(max(int(video_latent_frames), 0), latent_frames) + taken = set() + + def add_latent_index(index: int) -> None: + if index <= 0: + pixel = 0 + elif index >= scan_frames - 1: + pixel = num_frames - 1 + else: + pixel = index * temporal_scale + taken.add(min(max(pixel, 0), num_frames - 1)) + + mask = latent.get("noise_mask") + if mask is not None and getattr(mask, "ndim", 0) >= 3: + for index in range(min(mask.shape[2], scan_frames)): + if torch.any(mask[:, :, index : index + 1] < _OCCUPIED_MASK_MAX): + add_latent_index(index) + return taken + + for index in range(scan_frames): + if torch.any(samples[:, :, index : index + 1] != 0): + add_latent_index(index) + return taken + + +def nearest_latent_index(pixel_frame: int, temporal_scale: int, num_latent_frames: int) -> int: + return min(max(round(pixel_frame / temporal_scale), 0), num_latent_frames - 1) + + +def should_copy_nearest_video_frames(keyframe_t, requested_count, has_recorded_indices, batched_singles): + """True when ``keyframes`` is a longer video to sample, not stacked keyframes.""" + return ( + not has_recorded_indices + and requested_count is not None + and not batched_singles + and keyframe_t > requested_count + ) + + +def keyframes_from_video(samples, indices, temporal_scale: int): + """Stack the nearest video latent frame at each pixel index.""" + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Initializing keyframes from a video needs a plain 5D video latent. " + "Split audio with Separate AV Latent first, and peel generated " + "keyframes before copying from the video." + ) + temporal_scale = int(temporal_scale) + if temporal_scale < 1: + raise ValueError(f"temporal_scale must be >= 1, got {temporal_scale}") + num_latent_frames = samples.shape[2] + if num_latent_frames < 1: + raise ValueError("The video latent has no frames to copy from.") + frames = [] + for pixel_frame in indices: + idx = nearest_latent_index(pixel_frame, temporal_scale, num_latent_frames) + frames.append(samples[:, :, idx : idx + 1]) + return torch.cat(frames, dim=2) + + +def _fit_keyframe_samples(keyframe_samples, samples, num_slots): + """Pad stacked keyframe tokens with zeros up to ``num_slots``; reject extras.""" + expected = ( + samples.shape[0], + samples.shape[1], + num_slots, + samples.shape[3], + samples.shape[4], + ) + have = keyframe_samples.shape[2] + if ( + keyframe_samples.shape[0] != expected[0] + or keyframe_samples.shape[1] != expected[1] + or keyframe_samples.shape[3:] != expected[3:] + ): + raise ValueError( + "The keyframes latent must hold whole latent frames at this latent's shape, expected " + f"{list(expected)} but got {list(keyframe_samples.shape)}. Resize it to this stage's " + "resolution first." + ) + if have > num_slots: + raise ValueError( + f"The keyframes latent holds {have} frame(s) but only {num_slots} free slot(s) " + "are available on this canvas. Pass fewer keyframes, or set frame_indices." + ) + if have < num_slots: + pad = torch.zeros( + (expected[0], expected[1], num_slots - have, expected[3], expected[4]), + dtype=keyframe_samples.dtype, + device=keyframe_samples.device, + ) + keyframe_samples = torch.cat([keyframe_samples, pad], dim=2) + return keyframe_samples + + +class LTXVAddGeneratedKeyframes(io.ComfyNode): + PATCHIFIER = SymmetricPatchifier(1, start_end=True) + + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVAddGeneratedKeyframes", + display_name="LTXV Add Generated Keyframes", + category="model/conditioning/ltxv", + search_aliases=["detailing", "dfr", "generated keyframes"], + description=( + "Append detailing keyframes to a video latent. Each keyframe is one latent " + "frame of tokens whose RoPE position spans a single pixel frame; they are " + "denoised with the video and are not part of the decoded output. Placement " + "is one slot every interval_frames pixels, skipping I2V frames, existing " + "guides, and detailing keyframes already on the cond. Connected keyframes " + "are content only and are re-placed on this canvas unless frame_indices is " + "set. Pull them back out with LTXV Separate Generated Keyframes. Requires a " + "checkpoint trained for generated keyframes (one carrying " + "keyframes_abs_pos_embedding)." + ), + inputs=[ + io.Conditioning.Input( + "positive", + tooltip="Positive conditioning the keyframes are attached to.", + ), + io.Conditioning.Input( + "negative", + tooltip="Negative conditioning the keyframes are attached to.", + ), + io.Vae.Input( + "vae", tooltip="Only used to read the latent scale factors." + ), + io.Latent.Input( + "latent", + tooltip=( + "Plain 5D video latent to generate keyframes alongside. Add them " + "before Concat AV Latent." + ), + ), + io.Int.Input( + "interval_frames", + optional=True, + default=24, + min=1, + max=1024, + tooltip=( + "Pixel-frame stride for auto placement. Default 24 is about one " + "keyframe per second at 24 fps. Occupied pixels are skipped. " + "Ignored when frame_indices is set." + ), + ), + io.Latent.Input( + "keyframes", + optional=True, + tooltip=( + "Optional content to initialize the new keyframes with. Connect " + "keyframes from an earlier Separate (same spatial size), or a " + "plain video latent to copy the nearest frame at each new slot " + "(e.g. after temporal upscale). These are still denoised, not " + "pinned as guides. Recorded indices on a keyframes latent are " + "ignored unless frame_indices is set. Only has an effect when " + "sampling starts below sigma 1." + ), + ), + io.String.Input( + "frame_indices", + optional=True, + default="", + tooltip=( + "Optional pixel-frame indices. Leave empty to place from " + "interval_frames on the current canvas. When set, this list is " + "the placement (connected keyframes are matched in order). The " + "last frame is allowed; frame 0 is not (it is already a " + "standalone token)." + ), + ), + ], + outputs=[ + io.Conditioning.Output( + display_name="positive", + tooltip="Positive conditioning with generated-keyframe attention attached.", + ), + io.Conditioning.Output( + display_name="negative", + tooltip="Negative conditioning with generated-keyframe attention attached.", + ), + io.Latent.Output( + display_name="latent", + tooltip="Video latent with generated keyframes appended on T.", + ), + ], + ) + + @classmethod + def parse_frame_indices(cls, frame_indices, num_pixel_frames, expected_count=None): + """Pixel frame positions from a manual override. + + Frame 0 is excluded (already a standalone token). The terminal frame is + allowed so a DFR segment grid that includes N-1 is legal. + """ + first, last = 1, num_pixel_frames - 1 + if last < first: + raise ValueError( + f"A {num_pixel_frames}-frame target has no pixel frames to place keyframes on." + ) + return _parse_frame_index_list( + frame_indices, + "frame_indices", + expected_count, + first, + last, + expected_desc=( + f"{expected_count} keyframe(s)" + if expected_count is not None + else "the list sets the count" + ), + empty_hint="to place them from interval_frames", + ) + + @classmethod + def keyframe_coords(cls, latent, frame_index, scale_factors): + """Pixel coordinates of one keyframe: the full spatial grid over [t, t + 1).""" + _, latent_coords = cls.PATCHIFIER.patchify(latent[:, :, :1]) + pixel_coords = latent_to_pixel_coords(latent_coords, scale_factors, causal_fix=True) + pixel_coords[:, 0] += frame_index + return pixel_coords + + @classmethod + def execute( + cls, + positive, + negative, + vae, + latent, + interval_frames=24, + keyframes=None, + frame_indices="", + ) -> io.NodeOutput: + samples = latent["samples"] + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Generated keyframes must be added to a plain video latent. Add them before " + "merging the video and audio latents with Concat AV Latent." + ) + + existing_record = get_generated_keyframes(positive) + if existing_record is not None: + prev_tokens_per_frame = existing_record["tokens_per_frame"] + if prev_tokens_per_frame != samples.shape[3] * samples.shape[4]: + raise ValueError( + f"The existing generated keyframes were added at {prev_tokens_per_frame} tokens per latent " + f"frame but this latent has {samples.shape[3] * samples.shape[4]}. The latent was rescaled " + "after they were added, so more keyframes cannot be appended to them." + ) + prev_first = existing_record["first_latent_frame"] + prev_count = existing_record["num_keyframes"] + if prev_first + prev_count != samples.shape[2]: + raise ValueError( + f"The existing generated keyframes end at latent frame {prev_first + prev_count} but the " + f"latent has {samples.shape[2]}. Something was appended after them, so more keyframes would " + "not be contiguous with the existing block. Add all the keyframes before any guides." + ) + + scale_factors = vae.downscale_index_formula + time_scale_factor = scale_factors[0] + keyframe_idxs, num_guide_frames = get_keyframe_idxs(positive, samples.shape) + num_target_frames = samples.shape[2] - num_guide_frames + num_pixel_frames = (num_target_frames - 1) * time_scale_factor + 1 + occupied = occupied_pixel_frames( + latent, + time_scale_factor, + num_pixel_frames, + video_latent_frames=num_target_frames, + ) + occupied |= pixel_frames_from_keyframe_idxs(keyframe_idxs) + prev_indices = list(existing_record["frame_indices"]) if existing_record is not None else [] + occupied |= set(prev_indices) + + if frame_indices and str(frame_indices).strip(): + indices = cls.parse_frame_indices(frame_indices, num_pixel_frames) + else: + indices = free_detailing_slots(num_pixel_frames, float(interval_frames), occupied) + + clashes = sorted(set(indices) & occupied) + if clashes: + raise ValueError( + f"frame_indices reuses pixel frame(s) {', '.join(str(i) for i in clashes)}, which already hold " + "an image keyframe, a guide, or a generated keyframe. Each keyframe needs its own pixel frame." + ) + + num_keyframes = len(indices) + if keyframes is not None: + keyframe_samples = keyframes["samples"] + if keyframe_samples.ndim != 5: + raise ValueError( + f"The keyframes latent must be 5 dimensional, got {list(keyframe_samples.shape)}." + ) + recorded_positions = keyframes.get("generated_keyframe_indices") + batched_singles = ( + keyframe_samples.shape[2] == 1 + and keyframe_samples.shape[0] != samples.shape[0] + ) + if batched_singles: + if keyframe_samples.shape[0] % samples.shape[0] != 0: + raise ValueError( + f"The keyframes latent batch ({keyframe_samples.shape[0]}) is not a " + f"multiple of the video latent batch ({samples.shape[0]}), so it " + "cannot be reshaped into per-video keyframes." + ) + stacked = keyframe_samples.shape[0] // samples.shape[0] + keyframe_samples = keyframe_samples.reshape( + samples.shape[0], + stacked, + keyframe_samples.shape[1], + *keyframe_samples.shape[3:], + ).movedim(1, 2) + batched_singles = False + if should_copy_nearest_video_frames( + keyframe_samples.shape[2], + num_keyframes, + recorded_positions is not None, + batched_singles, + ): + keyframe_samples = keyframes_from_video( + keyframe_samples, + indices, + time_scale_factor, + ) + else: + keyframe_samples = _fit_keyframe_samples( + keyframe_samples.to(samples), samples, num_keyframes + ) + keyframe_samples = keyframe_samples.to(samples) + else: + keyframe_samples = torch.zeros( + ( + samples.shape[0], + samples.shape[1], + num_keyframes, + samples.shape[3], + samples.shape[4], + ), + dtype=samples.dtype, + device=samples.device, + ) + + generated_coords = torch.cat( + [cls.keyframe_coords(samples, index, scale_factors) for index in indices], + dim=2, + ) + if keyframe_idxs is not None: + generated_coords = torch.cat([keyframe_idxs, generated_coords.to(keyframe_idxs)], dim=2) + + existing_entries = conditioning_get_any_value(positive, "guide_attention_entries", None) or [] + generated_keyframes = { + "first_latent_frame": ( + existing_record["first_latent_frame"] if existing_record else samples.shape[2] + ), + "num_keyframes": ( + existing_record["num_keyframes"] + num_keyframes if existing_record else num_keyframes + ), + "frame_indices": prev_indices + list(indices), + "num_pixel_frames": num_pixel_frames, + "guide_entry_index": ( + existing_record["guide_entry_index"] if existing_record else len(existing_entries) + ), + "tokens_per_frame": samples.shape[3] * samples.shape[4], + } + + values = { + "keyframe_idxs": generated_coords, + "generated_keyframes": generated_keyframes, + } + positive = node_helpers.conditioning_set_values(positive, values) + negative = node_helpers.conditioning_set_values(negative, values) + + if existing_record is not None: + positive, negative = _grow_guide_attention_entry( + positive, + negative, + existing_record["guide_entry_index"], + extra_pre_filter_count=num_keyframes * samples.shape[3] * samples.shape[4], + extra_frames=num_keyframes, + ) + else: + positive, negative = _append_guide_attention_entry( + positive, + negative, + pre_filter_count=num_keyframes * samples.shape[3] * samples.shape[4], + latent_shape=[num_keyframes, samples.shape[3], samples.shape[4]], + strength=1.0, + ) + + noise_mask = get_noise_mask(latent) + keyframe_noise_mask = torch.ones( + ( + noise_mask.shape[0], + 1, + num_keyframes, + noise_mask.shape[3], + noise_mask.shape[4], + ), + dtype=noise_mask.dtype, + device=noise_mask.device, + ) + + out = latent.copy() + out["samples"] = torch.cat([samples, keyframe_samples], dim=2) + out["noise_mask"] = torch.cat([noise_mask, keyframe_noise_mask], dim=2) + return io.NodeOutput(positive, negative, out) + + +class LTXVSeparateGeneratedKeyframes(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVSeparateGeneratedKeyframes", + display_name="LTXV Separate Generated Keyframes", + category="model/conditioning/ltxv", + search_aliases=["detailing", "dfr", "peel keyframes"], + description=( + "Split the generated keyframes added by LTXV Add Generated Keyframes " + "back out of a sampled latent, and remove them from the conditioning. " + "Separate them before spatially upscaling the video latent. Do not run " + "LTXV Crop Guides first — it treats generated keyframes as disposable " + "guides and drops them." + ), + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Latent.Input("latent"), + io.Boolean.Input( + "keyframes_to_batch", + default=False, + tooltip=( + "Return the keyframes as a batch of single-frame latents. Leave off " + "to get them as one multi-frame latent, which is what the latent " + "upsampler and a later Add Generated Keyframes expect." + ), + ), + ], + outputs=[ + io.Conditioning.Output( + display_name="positive", + tooltip="Positive conditioning with generated-keyframe metadata removed.", + ), + io.Conditioning.Output( + display_name="negative", + tooltip="Negative conditioning with generated-keyframe metadata removed.", + ), + io.Latent.Output( + display_name="latent", + tooltip="Video latent with the generated keyframes stripped.", + ), + io.Latent.Output( + display_name="keyframes", + tooltip=( + "The peeled keyframes, labeled with generated_keyframe_indices and " + "generated_keyframe_num_frames. Feed these to a later Add Generated " + "Keyframes to initialize new slots, or to Generated Keyframes To " + "Guides to pin them as frozen image guides (indices are remapped " + "if the canvas length changed)." + ), + ), + ], + ) + + @classmethod + def strip_keyframe_idxs(cls, cond, first_token, num_tokens): + keyframe_idxs = conditioning_get_any_value(cond, "keyframe_idxs", None) + if keyframe_idxs is None: + return None + remaining = torch.cat( + [ + keyframe_idxs[:, :, :first_token], + keyframe_idxs[:, :, first_token + num_tokens :], + ], + dim=2, + ) + return remaining if remaining.shape[2] > 0 else None + + @classmethod + def strip_guide_entry(cls, cond, entry_index): + entries = conditioning_get_any_value(cond, "guide_attention_entries", None) + if not entries: + return None + if entry_index >= len(entries): + raise ValueError( + f"The generated keyframes recorded guide entry {entry_index} but the conditioning only has " + f"{len(entries)}. The conditioning was rebuilt after they were added." + ) + remaining = entries[:entry_index] + entries[entry_index + 1 :] + return remaining or None + + @classmethod + def execute(cls, positive, negative, latent, keyframes_to_batch=False) -> io.NodeOutput: + generated_keyframes = get_generated_keyframes(positive) + if generated_keyframes is None: + raise ValueError( + "This latent has no generated keyframes. Add them with LTXV Add Generated Keyframes first." + ) + + samples = latent["samples"] + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Generated keyframes must be separated from a plain video latent. Split the video and " + "audio latents with Separate AV Latent first." + ) + + tokens_per_frame = samples.shape[3] * samples.shape[4] + if generated_keyframes["tokens_per_frame"] != tokens_per_frame: + raise ValueError( + f"The generated keyframes were added at {generated_keyframes['tokens_per_frame']} tokens per " + f"latent frame but this latent has {tokens_per_frame}. The latent was rescaled after they were " + "added, so the keyframes no longer line up. Separate them before upscaling the latent." + ) + + first_frame = generated_keyframes["first_latent_frame"] + num_keyframes = generated_keyframes["num_keyframes"] + end_frame = first_frame + num_keyframes + if end_frame > samples.shape[2]: + raise ValueError( + f"The generated keyframes span latent frames [{first_frame}, {end_frame}) but the latent " + f"only has {samples.shape[2]}. It was recorded against a different latent." + ) + + keyframe_samples = samples[:, :, first_frame:end_frame].clone() + if keyframes_to_batch: + batch, channels, _, height, width = keyframe_samples.shape + keyframe_samples = keyframe_samples.movedim(2, 1).reshape( + batch * num_keyframes, channels, 1, height, width + ) + + video_samples = torch.cat( + [samples[:, :, :first_frame], samples[:, :, end_frame:]], dim=2 + ) + noise_mask = get_noise_mask(latent) + video_noise_mask = torch.cat( + [noise_mask[:, :, :first_frame], noise_mask[:, :, end_frame:]], dim=2 + ) + + _, num_guide_frames = get_keyframe_idxs(positive, samples.shape) + first_token = (first_frame - (samples.shape[2] - num_guide_frames)) * tokens_per_frame + entry_index = generated_keyframes["guide_entry_index"] + + outputs = [] + for cond in (positive, negative): + outputs.append( + node_helpers.conditioning_set_values( + cond, + { + "keyframe_idxs": cls.strip_keyframe_idxs( + cond, first_token, num_keyframes * tokens_per_frame + ), + "guide_attention_entries": cls.strip_guide_entry(cond, entry_index), + "generated_keyframes": None, + }, + ) + ) + + out = latent.copy() + out["samples"] = video_samples + out["noise_mask"] = video_noise_mask + num_pixel_frames = generated_keyframes.get("num_pixel_frames") + if num_pixel_frames is None: + num_pixel_frames = (first_frame - 1) * DEFAULT_TEMPORAL_SCALE + 1 + keyframes_out = { + "samples": keyframe_samples, + "generated_keyframe_indices": list(generated_keyframes["frame_indices"]), + "generated_keyframe_num_frames": int(num_pixel_frames), + } + return io.NodeOutput(outputs[0], outputs[1], out, keyframes_out) + + +class LTXVGeneratedKeyframesToGuides(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVGeneratedKeyframesToGuides", + display_name="LTXV Generated Keyframes to Guides", + category="model/conditioning/ltxv", + search_aliases=["detailing", "dfr", "keyframe guides"], + description=( + "Pin generated keyframes from an earlier stage as frozen image guides on " + "a later canvas. They are decoded as standalone frames, resized if needed, " + "and written with noise_mask=0 so they are not denoised again. After a " + "temporal upscale, recorded indices are scaled from the canvas they were " + "generated on onto this one (same moments). Use override_frame_indices to " + "set positions explicitly. To keep generating (and denoising) keyframes at " + "new positions, use Add Generated Keyframes instead." + ), + inputs=[ + io.Conditioning.Input("positive"), + io.Conditioning.Input("negative"), + io.Vae.Input("vae"), + io.Latent.Input( + "latent", + tooltip="The target video latent to add the guides to, e.g. the temporally upscaled one.", + ), + io.Latent.Input( + "keyframes", + tooltip=( + "The keyframes output of LTXV Separate Generated Keyframes, which " + "carries the pixel frame index each keyframe was generated at." + ), + ), + io.Float.Input( + "strength", + default=1.0, + min=0.0, + max=10.0, + step=0.01, + tooltip="Guide strength. 1.0 is a hard pin; lower values relax it.", + ), + io.String.Input( + "override_frame_indices", + optional=True, + default="", + tooltip=( + "Optional — pin at these pixel frames instead of the recorded " + "(or auto-scaled) positions. Provide one index per keyframe. " + "Leave empty to reuse recorded positions, or to scale them when " + "the target canvas is a different length (e.g. after temporal x2)." + ), + ), + ], + outputs=[ + io.Conditioning.Output( + display_name="positive", + tooltip="Positive conditioning with the keyframes pinned as image guides.", + ), + io.Conditioning.Output( + display_name="negative", + tooltip="Negative conditioning with the keyframes pinned as image guides.", + ), + io.Latent.Output( + display_name="latent", + tooltip="Target video latent with the keyframes added as frozen guides.", + ), + ], + ) + + @classmethod + def decode_single_frames(cls, vae, keyframe_samples): + """Decode each keyframe latent on its own, never as one clip.""" + if keyframe_samples.shape[2] != 1: + batch, channels, num_keyframes, height, width = keyframe_samples.shape + keyframe_samples = keyframe_samples.movedim(2, 1).reshape( + batch * num_keyframes, channels, 1, height, width + ) + images = vae.decode(keyframe_samples) + if images.ndim == 5: + images = images.reshape(-1, *images.shape[-3:]) + return images + + @classmethod + def execute( + cls, + positive, + negative, + vae, + latent, + keyframes, + strength, + override_frame_indices="", + ) -> io.NodeOutput: + indices = keyframes.get("generated_keyframe_indices", None) + if indices is None: + raise ValueError( + "This latent does not carry generated keyframe positions. Connect the keyframes output " + "of LTXV Separate Generated Keyframes." + ) + if get_generated_keyframes(positive) is not None: + raise ValueError( + "This conditioning still carries generated keyframes. Connect the positive and " + "negative outputs of LTXV Separate Generated Keyframes." + ) + + samples = latent["samples"] + if not torch.is_tensor(samples) or samples.ndim != 5: + raise ValueError( + "Generated keyframe guides must be added to a plain video latent. Add them before " + "merging the video and audio latents with Concat AV Latent." + ) + if samples.shape[0] != 1: + raise ValueError( + f"Only a batch size of 1 is supported, got {samples.shape[0]}. Each guide is encoded from " + "one image, so it cannot differ across batch elements." + ) + + kf_samples = keyframes["samples"] + if kf_samples.shape[2] != 1: + batch, channels, num_keyframes, height, width = kf_samples.shape + kf_samples = kf_samples.movedim(2, 1).reshape( + batch * num_keyframes, channels, 1, height, width + ) + + resize_needed = kf_samples.shape[3:] != samples.shape[3:] + guides = cls.decode_single_frames(vae, kf_samples) if resize_needed else kf_samples + if guides.shape[0] != len(indices): + raise ValueError( + f"Got {guides.shape[0]} keyframes for {len(indices)} recorded positions." + ) + + _, num_guide_frames = get_keyframe_idxs(positive, samples.shape) + time_scale_factor = vae.downscale_index_formula[0] + num_pixel_frames = (samples.shape[2] - num_guide_frames - 1) * time_scale_factor + 1 + if override_frame_indices and str(override_frame_indices).strip(): + indices = _parse_frame_index_list( + override_frame_indices, + "override_frame_indices", + len(indices), + 1, + num_pixel_frames - 1, + expected_desc=f"the keyframes latent carries {len(indices)}", + empty_hint="to reuse the recorded positions", + ) + else: + old_len = keyframes.get("generated_keyframe_num_frames") + if old_len is not None and int(old_len) != num_pixel_frames: + indices = scale_frame_indices(list(indices), int(old_len), num_pixel_frames) + if indices and max(indices) >= num_pixel_frames: + raise ValueError( + f"Keyframe position {max(indices)} is outside this latent's {num_pixel_frames} frames. The " + "target was resized temporally after the keyframes were generated." + ) + + for index, frame_idx in enumerate(indices): + if resize_needed: + added = LTXVAddGuide.execute( + positive, + negative, + vae, + latent, + guides[index].unsqueeze(0), + int(frame_idx), + strength, + ) + positive, negative, latent = added[0], added[1], added[2] + else: + positive, negative, latent = cls.append_latent_keyframe( + positive, + negative, + vae, + latent, + guides[index : index + 1], + int(frame_idx), + strength, + ) + + return io.NodeOutput(positive, negative, latent) + + @classmethod + def append_latent_keyframe(cls, positive, negative, vae, latent, guiding_latent, frame_idx, strength): + """Append an already encoded keyframe latent the same way LTXVAddGuide would after encoding.""" + noise_mask = get_noise_mask(latent) + positive, negative, latent_image, noise_mask = LTXVAddGuide.append_keyframe( + positive, + negative, + frame_idx, + latent["samples"], + noise_mask, + guiding_latent, + strength, + vae.downscale_index_formula, + ) + positive, negative = _append_guide_attention_entry( + positive, + negative, + pre_filter_count=math.prod(guiding_latent.shape[2:]), + latent_shape=list(guiding_latent.shape[2:]), + strength=strength, + ) + out = latent.copy() + out["samples"] = latent_image + out["noise_mask"] = noise_mask + return positive, negative, out + + +class LTXVFreezeLatent(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="LTXVFreezeLatent", + display_name="LTXV Freeze Latent", + category="model/latent/ltxv", + search_aliases=["noise mask", "freeze audio", "freeze video"], + description=( + "Set noise_mask to 0 so this latent is kept clean during sampling. " + "Works on video or audio. Typical uses: freeze audio before Concat AV " + "so it only provides cross-attention, or freeze any latent that should " + "not be denoised." + ), + inputs=[ + io.Latent.Input( + "latent", + tooltip="Video or audio latent to freeze. Audio is 4D; video is 5D.", + ), + ], + outputs=[ + io.Latent.Output(display_name="latent"), + ], + ) + + @classmethod + def execute(cls, latent) -> io.NodeOutput: + samples = latent["samples"] + if not torch.is_tensor(samples): + raise ValueError( + "Freeze Latent expects a plain tensor, not a concatenated AV latent. " + "Split with Separate AV Latent first." + ) + out = latent.copy() + if samples.ndim == 5: + batch, _, frames, _, _ = samples.shape + out["noise_mask"] = torch.zeros( + (batch, 1, frames, 1, 1), + dtype=torch.float32, + device=samples.device, + ) + elif samples.ndim == 4: + batch, _, frames, _ = samples.shape + out["noise_mask"] = torch.zeros( + (batch, 1, frames, 1), + dtype=torch.float32, + device=samples.device, + ) + else: + raise ValueError( + f"Expected a 4D audio or 5D video latent, got shape {list(samples.shape)}." + ) + return io.NodeOutput(out) + + +class LTXVKeyframesExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + LTXVAddGeneratedKeyframes, + LTXVSeparateGeneratedKeyframes, + LTXVGeneratedKeyframesToGuides, + LTXVFreezeLatent, + ] + + +async def comfy_entrypoint() -> LTXVKeyframesExtension: + return LTXVKeyframesExtension() diff --git a/comfy_extras/nodes_marigold.py b/comfy_extras/nodes_marigold.py new file mode 100644 index 00000000000..539281b5335 --- /dev/null +++ b/comfy_extras/nodes_marigold.py @@ -0,0 +1,45 @@ +from typing_extensions import override + +import torch +import torch.nn.functional as F + +from comfy_api.latest import ComfyExtension, io + + +class MarigoldV2PostProcess(io.ComfyNode): + @classmethod + def define_schema(cls) -> io.Schema: + return io.Schema( + node_id="MarigoldV2PostProcess", + display_name="Marigold V2 Post-Process", + category="image/geometry estimation", + description="Turns a decoded Marigold V2 prediction into an image: normalized depth with near as bright, unit surface normals, or sRGB albedo.", + inputs=[ + io.Image.Input("image"), + io.Combo.Input("prediction", options=["depth", "normals", "albedo"]), + ], + outputs=[io.Image.Output()], + ) + + @classmethod + def execute(cls, image, prediction) -> io.NodeOutput: + if prediction == "normals": + return io.NodeOutput((F.normalize(image * 2.0 - 1.0, dim=-1) + 1.0) * 0.5) + if prediction == "albedo": + return io.NodeOutput(torch.where(image <= 0.0031308, image * 12.92, 1.055 * image.clamp(min=0.0031308) ** (1.0 / 2.4) - 0.055)) + d = image.mean(dim=-1, keepdim=True) + lo = d.amin(dim=(1, 2, 3), keepdim=True) + hi = d.amax(dim=(1, 2, 3), keepdim=True) + return io.NodeOutput(((hi - d) / (hi - lo).clamp(min=1e-6)).repeat(1, 1, 1, 3)) + + +class MarigoldExtension(ComfyExtension): + @override + async def get_node_list(self) -> list[type[io.ComfyNode]]: + return [ + MarigoldV2PostProcess, + ] + + +async def comfy_entrypoint() -> MarigoldExtension: + return MarigoldExtension() diff --git a/comfy_extras/nodes_minimax_h3.py b/comfy_extras/nodes_minimax_h3.py index 108dee13c8c..1baa33cb36d 100644 --- a/comfy_extras/nodes_minimax_h3.py +++ b/comfy_extras/nodes_minimax_h3.py @@ -17,6 +17,7 @@ import nodes import comfy.model_management +import comfy.model_prefetch import comfy.model_sampling import comfy.nested_tensor import comfy.patcher_extension @@ -478,7 +479,8 @@ def diffusion_model_wrapper(self, executor, x, timestep, context, transformer_op self.active = self.sigma_end <= sigma <= self.sigma_start self.control_stream = None if self.active: - self.prepare_control_latent(x[0].shape) + with comfy.model_prefetch.pause_malloc_graph(): + self.prepare_control_latent(x[0].shape) try: return executor(x, timestep, context, transformer_options, **kwargs) finally: @@ -540,12 +542,15 @@ def __init__(self, control_patch, block_index, previous): self.previous = previous def __call__(self, args, extra_args): - self.control_patch.before_block(self.block_index, args) + # Control state must stay outside the base block's allocation scope. + with comfy.model_prefetch.pause_malloc_graph(): + self.control_patch.before_block(self.block_index, args) if self.previous is None: out = extra_args["original_block"](args) else: out = self.previous(args, extra_args) - return self.control_patch.after_block(self.block_index, args, out) + with comfy.model_prefetch.pause_malloc_graph(): + return self.control_patch.after_block(self.block_index, args, out) def to(self, device_or_dtype): self.control_patch.to(device_or_dtype) diff --git a/comfy_extras/nodes_model_advanced.py b/comfy_extras/nodes_model_advanced.py index c73b0ba1239..97adc8cd81e 100644 --- a/comfy_extras/nodes_model_advanced.py +++ b/comfy_extras/nodes_model_advanced.py @@ -7,6 +7,7 @@ import nodes import torch import node_helpers +from comfy_api.latest import io class LCM(comfy.model_sampling.EPS): @@ -128,11 +129,13 @@ def INPUT_TYPES(s): CATEGORY = "model/patch/stable diffusion" - def patch(self, model, shift, multiplier=1000): + def patch(self, model, shift, multiplier=1000, sampling="flow"): m = model.clone() sampling_base = comfy.model_sampling.ModelSamplingDiscreteFlow sampling_type = comfy.model_sampling.CONST + if sampling == "img_to_img_velocity": + sampling_type = comfy.model_sampling.IMG_TO_IMG_VELOCITY class ModelSamplingAdvanced(sampling_base, sampling_type): pass @@ -150,13 +153,15 @@ class ModelSamplingAuraFlow(ModelSamplingSD3): def INPUT_TYPES(s): return {"required": { "model": ("MODEL",), "shift": ("FLOAT", {"default": 1.73, "min": 0.0, "max": 100.0, "step":0.01}), + }, + "optional": { "sampling": (["flow", "img_to_img_velocity"], {"default": "flow", "advanced": True}), }} FUNCTION = "patch_aura" CATEGORY = "model/patch" - def patch_aura(self, model, shift): - return self.patch(model, shift, multiplier=1.0) + def patch_aura(self, model, shift, sampling="flow"): + return self.patch(model, shift, multiplier=1.0, sampling=sampling) class ModelSamplingFlux: @classmethod @@ -366,26 +371,34 @@ def patch(self, model, dtype): return (m, ) -class ModelAttentionBackend: +class ModelAttentionBackend(io.ComfyNode): @classmethod - def INPUT_TYPES(s): + def define_schema(cls): backends = ["pytorch attention"] if comfy.ldm.modules.attention.COMFY_KITCHEN_INT8_ATTENTION_IS_AVAILABLE: backends.append("comfy kitchen attention") - return {"required": {"model": ("MODEL",), - "attention": (backends,), - }} + return io.Schema( + node_id="ModelAttentionBackend", + display_name="Model Attention Backend", + category="model/patch", + is_experimental=True, + description="Selects the dense attention implementation for the model. When used with Block Sparse Attention, this backend is used whenever sparse attention is inactive or unsupported.", + inputs=[ + io.Model.Input("model", tooltip="The model to patch."), + io.Combo.Input("attention", display_name="backend", options=backends, default="pytorch attention", + tooltip="The dense attention backend. Comfy Kitchen attention uses quantized INT8 attention and is available only on Nvidia and AMD GPUs."), + ], + outputs=[ + io.Model.Output(display_name="model", tooltip="The model with the selected attention backend."), + ], + ) @classmethod - def VALIDATE_INPUTS(s, attention): + def validate_inputs(cls, attention): return True - RETURN_TYPES = ("MODEL",) - FUNCTION = "patch" - - CATEGORY = "model/patch" - - def patch(self, model, attention): + @classmethod + def execute(cls, model, attention): attention_name = { "comfy kitchen attention": "comfy_kitchen_int8", "pytorch attention": "pytorch", @@ -396,7 +409,7 @@ def patch(self, model, attention): attention_function = comfy.ldm.modules.attention.get_attention_function("pytorch") m = model.clone() m.set_model_optimized_attention(attention_function) - return (m, ) + return io.NodeOutput(m) NODE_CLASS_MAPPINGS = { diff --git a/comfy_extras/nodes_model_patch.py b/comfy_extras/nodes_model_patch.py index 82a5f58ebf8..eb461de6354 100644 --- a/comfy_extras/nodes_model_patch.py +++ b/comfy_extras/nodes_model_patch.py @@ -283,7 +283,7 @@ def load_model_patch(self, name): ) manual_cast_dtype = comfy.model_management.unet_manual_cast( dtype, load_device, supported_dtypes=[torch.bfloat16, torch.float32]) - operations = comfy.ops.pick_operations(dtype, manual_cast_dtype) + operations = comfy.ops.pick_operations(dtype, manual_cast_dtype, load_device=load_device) num_blocks = 0 while "control_blocks.{}.after_proj.weight".format(num_blocks) in sd: diff --git a/comfy_extras/nodes_moge.py b/comfy_extras/nodes_moge.py index 819421534db..1861ef3ef29 100644 --- a/comfy_extras/nodes_moge.py +++ b/comfy_extras/nodes_moge.py @@ -106,12 +106,14 @@ def define_schema(cls): tooltip="Long-side resolution of the merged equirect distance map."), io.Int.Input("batch_size", default=4, min=1, max=12, tooltip="Views per inference batch (12 splits total)."), + io.Int.Input("refine_steps", default=3, min=0, max=8, advanced=True, + tooltip="MoGe-3 only: sparse volumetric refinement passes over the predicted depth. More passes sharpen fine detail and edges at a roughly linear cost. 0 disables refinement. Ignored by MoGe-1 / MoGe-2."), ], outputs=[MoGeGeometry.Output(display_name="moge_geometry")], ) @classmethod - def execute(cls, moge_model, image, resolution_level, split_resolution, merge_resolution, batch_size) -> io.NodeOutput: + def execute(cls, moge_model, image, resolution_level, split_resolution, merge_resolution, batch_size, refine_steps) -> io.NodeOutput: if image.shape[0] != 1: raise ValueError(f"MoGePanoramaInference takes a single image (got batch of {image.shape[0]})") @@ -155,7 +157,8 @@ def execute(cls, moge_model, image, resolution_level, split_resolution, merge_re # apply_metric_scale=False: per-view scales would not align across overlap seams. result = moge_model.infer(batch, resolution_level=resolution_level, fov_x=90.0, force_projection=True, - apply_mask=False, apply_metric_scale=False) + apply_mask=False, apply_metric_scale=False, + refine_steps=refine_steps) distance_maps.extend(list(result["points"].float().norm(dim=-1).cpu().numpy())) masks.extend(list(result["mask"].cpu().numpy())) n = batch.shape[0] @@ -228,12 +231,14 @@ def define_schema(cls): io.Boolean.Input("force_projection", default=True, advanced=True), io.Boolean.Input("apply_mask", default=True, advanced=True, tooltip="Set masked-out (sky / invalid) pixels to inf in points and depth so meshing culls them. Disable to keep the raw predicted geometry everywhere; the mask is still returned separately."), + io.Int.Input("refine_steps", default=3, min=0, max=8, advanced=True, + tooltip="MoGe-3 only: sparse volumetric refinement passes over the predicted depth. More passes sharpen fine detail and edges at a roughly linear cost. 0 disables refinement. Ignored by MoGe-1 / MoGe-2."), ], outputs=[MoGeGeometry.Output(display_name="moge_geometry")], ) @classmethod - def execute(cls, moge_model, image, resolution_level, fov_x_degrees, batch_size, force_projection, apply_mask) -> io.NodeOutput: + def execute(cls, moge_model, image, resolution_level, fov_x_degrees, batch_size, force_projection, apply_mask, refine_steps) -> io.NodeOutput: image = image[..., :3] bchw = image.movedim(-1, -3).contiguous() @@ -246,7 +251,8 @@ def execute(cls, moge_model, image, resolution_level, fov_x_degrees, batch_size, for i in range(0, B, batch_size): chunk = bchw[i:i + batch_size] chunks.append(moge_model.infer(chunk, resolution_level=resolution_level, fov_x=fov, - force_projection=force_projection, apply_mask=apply_mask)) + force_projection=force_projection, apply_mask=apply_mask, + refine_steps=refine_steps)) pbar.update_absolute(min(i + batch_size, B)) tq.update(chunk.shape[0]) diff --git a/comfy_extras/nodes_qwen.py b/comfy_extras/nodes_qwen.py index 4960774db03..4e7a150a23f 100644 --- a/comfy_extras/nodes_qwen.py +++ b/comfy_extras/nodes_qwen.py @@ -12,6 +12,7 @@ class TextEncodeQwenImageEdit(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="TextEncodeQwenImageEdit", + display_name="Text Encode Qwen Image Edit", category="model/conditioning/qwen image", inputs=[ io.Clip.Input("clip"), @@ -55,6 +56,7 @@ class TextEncodeQwenImageEditPlus(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="TextEncodeQwenImageEditPlus", + display_name="Text Encode Qwen Image Edit Plus", category="model/conditioning/qwen image", inputs=[ io.Clip.Input("clip"), @@ -106,6 +108,108 @@ def execute(cls, clip, prompt, vae=None, image1=None, image2=None, image3=None) return io.NodeOutput(conditioning) +class TextEncodeQwenImage21(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="TextEncodeQwenImage21", + display_name="Text Encode Qwen Image 2.1", + category="model/conditioning/qwen image", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("prompt", multiline=True, dynamic_prompts=True), + io.String.Input("negative_prompt", multiline=True, dynamic_prompts=True), + io.Vae.Input("vae", optional=True), + io.Int.Input("resolution", default=1024, min=0, max=4096, step=32, + tooltip="Reference images are resized to about resolution x resolution pixels, at multiples of 32, preserving aspect ratio. 0 keeps each reference at its own size, rounded to a multiple of 32. "), + io.Autogrow.Input( + "images", + template=io.Autogrow.TemplateNames( + io.Image.Input("image"), + names=[f"image_{i}" for i in range(1, 17)], + min=0, + ), + tooltip="Reference images, seen by the text encoder and spliced into the sequence as VAE latents.", + ), + ], + outputs=[ + io.Conditioning.Output(display_name="positive"), + io.Conditioning.Output(display_name="negative"), + io.Latent.Output(display_name="latent", + tooltip="Empty latent on the first reference image's size, to match with sampling as any other size shifts the edit."), + ], + ) + + @classmethod + def execute(cls, clip, prompt, negative_prompt, vae=None, resolution=1024, images: io.Autogrow.Type = None) -> io.NodeOutput: + ref_latents = [] + images_vl = [] + images = images or {} + latent_w = latent_h = resolution or 1024 + for name in sorted(images, key=lambda n: int(n.rsplit("_", 1)[-1])): + image = images[name] + if image is None: + continue + # same resize for the text encoder and the VAE, so every vision slot covers 2x2 latents; one image per input + samples = image[:1].movedim(-1, 1) + if resolution > 0: + ratio = samples.shape[3] / samples.shape[2] + width = round(math.sqrt(resolution * resolution * ratio) / 32) * 32 + height = round(math.sqrt(resolution * resolution / ratio) / 32) * 32 + else: + width, height = round(samples.shape[3] / 32) * 32, round(samples.shape[2] / 32) * 32 + width, height = max(32, width), max(32, height) + if (width, height) == (samples.shape[3], samples.shape[2]): + s = image[:1] + else: + s = comfy.utils.common_upscale(samples, width, height, "lanczos", "disabled").movedim(1, -1) + if not images_vl: + latent_w, latent_h = width, height + rgb = s[:, :, :, :3] + if s.shape[-1] > 3: + rgb = rgb * s[:, :, :, 3:] + (1.0 - s[:, :, :, 3:]) # the vision tower sees alpha over white, the vae keeps all four + images_vl.append(rgb) + if vae is not None: + ref_latents.append(vae.encode(s)) + + keep_vision = len(ref_latents) == 0 + positive = clip.encode_from_tokens_scheduled(clip.tokenize(prompt, images=images_vl, keep_vision=keep_vision, prevent_empty_text=True)) + negative = clip.encode_from_tokens_scheduled(clip.tokenize(negative_prompt, images=images_vl, keep_vision=keep_vision, prevent_empty_text=True)) + if len(ref_latents) > 0: + positive = node_helpers.conditioning_set_values(positive, {"reference_latents": ref_latents}, append=True) + negative = node_helpers.conditioning_set_values(negative, {"reference_latents": ref_latents}, append=True) + latent = torch.zeros([1, 64, latent_h // 16, latent_w // 16], device=comfy.model_management.intermediate_device()) + return io.NodeOutput(positive, negative, {"samples": latent}) + + +class QwenImage21Cache(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="QwenImage21Cache", + display_name="Qwen Image 2.1 Cache", + category="model/conditioning/qwen image", + description=( + "Allows setting the KV cache device and quantization, by default the model uses the auto -option. Quantization potentially speeds up edit workflows when memory starved " + ), + inputs=[ + io.Model.Input("model"), + io.Combo.Input("device", options=["auto", "gpu", "cpu", "off"], default="auto", + tooltip="auto uses spare VRAM, then RAM. cpu (RAM) is prefetched behind compute and costs little speed. off recomputes the prefix every step, which is slower but is the one way to rule the cache out."), + io.Combo.Input("dtype", options=["default", "int8", "int4"], default="default", + tooltip="Storage precision. default is lossless. int8 halves the cache at about bf16 accuracy, int4 quarters it but roughly doubles the per-step error."), + ], + outputs=[io.Model.Output()], + is_experimental=True, + ) + + @classmethod + def execute(cls, model, device, dtype) -> io.NodeOutput: + m = model.clone() + m.model_options["transformer_options"]["qwen_image21_cache"] = {"device": device, "dtype": dtype} + return io.NodeOutput(m) + + class EmptyQwenImageLayeredLatentImage(io.ComfyNode): @classmethod def define_schema(cls): @@ -136,6 +240,8 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ TextEncodeQwenImageEdit, TextEncodeQwenImageEditPlus, + TextEncodeQwenImage21, + QwenImage21Cache, EmptyQwenImageLayeredLatentImage, ] diff --git a/comfy_extras/nodes_save_3d.py b/comfy_extras/nodes_save_3d.py index 660ab8fe0c0..c0de41f07c1 100644 --- a/comfy_extras/nodes_save_3d.py +++ b/comfy_extras/nodes_save_3d.py @@ -876,18 +876,19 @@ def execute(cls, mesh): return IO.NodeOutput(mesh, info, ui=UI.PreviewText(info)) -def _save_file3d_to_output(model_3d: Types.File3D, filename_prefix: str) -> str: +def _save_file3d_to_output(model_3d: Types.File3D, filename_prefix: str) -> UI.SavedResult: full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path( filename_prefix, folder_paths.get_output_directory() ) ext = model_3d.format or "glb" saved_filename = f"{filename}_{counter:05}.{ext}" model_3d.save_to(os.path.join(full_output_folder, saved_filename)) - return f"{subfolder}/{saved_filename}" if subfolder else saved_filename + return UI.SavedResult(saved_filename, subfolder, IO.FolderType.output) def execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_prefix, kwargs) -> IO.NodeOutput: - model_file = _save_file3d_to_output(model_3d, filename_prefix) + saved = _save_file3d_to_output(model_3d, filename_prefix) + model_file = f"{saved.subfolder}/{saved.filename}" if saved.subfolder else saved.filename viewport_state = viewport_state if isinstance(viewport_state, dict) else {} camera_info_input = kwargs.get("camera_info", None) camera_info = camera_info_input if camera_info_input is not None else viewport_state.get('camera_info') @@ -899,7 +900,7 @@ def execute_save_3d_advanced(model_3d, viewport_state, width, height, filename_p camera_info, width, height, - ui=UI.PreviewUI3DAdvanced(model_file, camera_info, model_3d_info), + ui=UI.PreviewUI3DAdvanced(model_file, camera_info, model_3d_info, saved_result=saved), ) @@ -929,17 +930,17 @@ def define_schema(cls): ), IO.String.Input("filename_prefix", default="3d/ComfyUI"), IO.Load3D.Input("viewport_state"), - IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True), - IO.Load3DCamera.Input("camera_info", optional=True, advanced=True), - IO.Int.Input("width", default=1024, min=1, max=4096, step=1), - IO.Int.Input("height", default=1024, min=1, max=4096, step=1), + IO.Load3DModelInfo.Input("model_3d_info", optional=True, advanced=True, tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Input("camera_info", optional=True, advanced=True, tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Input("width", default=1024, min=1, max=4096, step=1, tooltip="Render width of the viewport in pixels."), + IO.Int.Input("height", default=1024, min=1, max=4096, step=1, tooltip="Render height of the viewport in pixels."), ], outputs=[ - IO.File3DAny.Output(display_name="model_3d"), - IO.Load3DModelInfo.Output(display_name="model_3d_info"), - IO.Load3DCamera.Output(display_name="camera_info"), - IO.Int.Output(display_name="width"), - IO.Int.Output(display_name="height"), + IO.File3DAny.Output(display_name="model_3d", tooltip="3D model file (glb/obj/stl/etc.) from an upstream 3D node."), + IO.Load3DModelInfo.Output(display_name="model_3d_info", tooltip="Placement of each model in the scene: position, rotation, and scale (Y-up world space)."), + IO.Load3DCamera.Output(display_name="camera_info", tooltip="Viewport camera information: position, look-at target, zoom, and type."), + IO.Int.Output(display_name="width", tooltip="Render width of the viewport in pixels."), + IO.Int.Output(display_name="height", tooltip="Render height of the viewport in pixels."), ], ) diff --git a/comfy_extras/nodes_seedvr.py b/comfy_extras/nodes_seedvr.py index c4ca3b55cb1..2b95a749eea 100644 --- a/comfy_extras/nodes_seedvr.py +++ b/comfy_extras/nodes_seedvr.py @@ -426,7 +426,7 @@ def define_schema(cls): return io.Schema( node_id="SeedVR2TemporalChunk", display_name="Split SeedVR2 Latent", - category="model/latent/batch", + category="model/latent/seedvr", description="Split a SeedVR2 video latent into overlapping temporal chunks small enough to sample one at a time within VRAM, wiring latents outputs to both Apply SeedVR2 Conditioning and the sampler latent input before recombining with Merge SeedVR2 Latents.", search_aliases=["seedvr2", "split", "chunk", "temporal", "video upscale", "rebatch"], inputs=[ @@ -520,7 +520,7 @@ def define_schema(cls): return io.Schema( node_id="SeedVR2TemporalMerge", display_name="Merge SeedVR2 Latents", - category="model/latent/batch", + category="model/latent/seedvr", is_input_list=True, description="Recombine sampled SeedVR2 latent temporal chunks into one latent, crossfading each overlap with a Hann window sized by the temporal_overlap wired from Split SeedVR2 Latent.", search_aliases=["seedvr2", "merge", "temporal", "hann", "crossfade"], diff --git a/comfy_extras/nodes_sparse_attention.py b/comfy_extras/nodes_sparse_attention.py index a807a40f9e9..006d1eb352f 100644 --- a/comfy_extras/nodes_sparse_attention.py +++ b/comfy_extras/nodes_sparse_attention.py @@ -5,6 +5,7 @@ import logging import re +import weakref import comfy_kitchen as ck import torch @@ -142,11 +143,11 @@ def vsa_plan(self, layout, device): def vsa_rope_freqs(self, rope_freqs, plan): hit = self.vsa_rope - if hit is not None and hit[0] is rope_freqs and hit[1] is plan: + if hit is not None and hit[0]() is rope_freqs and hit[1] is plan: return hit[2] padded = rope_freqs.new_zeros((1, plan["n"]) + tuple(rope_freqs.shape[2:])) padded[0, plan["inv"]] = rope_freqs[0] - self.vsa_rope = (rope_freqs, plan, padded) + self.vsa_rope = (weakref.ref(rope_freqs), plan, padded) return padded @@ -257,6 +258,7 @@ def h3_sparse_attention(attn, x, rope_freqs, transformer_options, patch: SparseA if patch.vsa: plan = patch.vsa_plan(transformer_options["minimax_h3_layout"], x.device) n = plan["n"] + freqs = patch.vsa_rope_freqs(rope_freqs, plan) key = (block_index, n, tuple(transformer_options.get("uuids", ()))) # statistics per conditioning branch pooled = patch.pooled.get(key) @@ -268,7 +270,6 @@ def h3_sparse_attention(attn, x, rope_freqs, transformer_options, patch: SparseA ) if patch.vsa: - freqs = patch.vsa_rope_freqs(rope_freqs, plan) sink = sink_q = (0, plan["n_prefix"]) extra = {"tail": False, "block_len": plan["block_len"]} gate = attn.to_gate_compress @@ -354,40 +355,42 @@ class BlockSparseAttention(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="BlockSparseAttention", - display_name="Block Sparse Attention", - category="advanced/model", + display_name="Model Sparse Attention", + category="model/patch", is_experimental=True, - description="Block-sparse attention through comfy_kitchen: each query block attends a selected subset of key blocks exactly, reducing attention compute. " - "The relative speed gain grows with sequence length since short sequences are usually faster dense. " - "Outside the active schedule, dense_blocks and under min_tokens, the model uses the active dense model attention backend.", + search_aliases=["Block Sparse Attention"], + description="Applies block-sparse attention to eligible model attention layers, reducing compute for long sequences. " + "The speed gain grows with sequence length since short sequences are usually faster dense. " + "Outside the start/end_percent, dense_blocks and under min_tokens, the model uses the dense model attention backend. " + "Use the node Model Attention Backend to select that fallback.", inputs=[ - io.Model.Input("model"), - io.DynamicCombo.Input("selection", options=[ - io.DynamicCombo.Option("Sol-Attn (adaptive tau)", [ + io.Model.Input("model", tooltip="The model to patch."), + io.DynamicCombo.Input("selection", display_name="method", options=[ + io.DynamicCombo.Option("sol-attn", [ io.Float.Input("tau", default=1.3, min=0.0, max=4.0, step=0.05, tooltip="Threshold in score-distribution sigmas. Higher is sparser: " "1.0 keeps ~16% of key blocks exact, 1.5 ~7%, 2.0 ~2.7%."), ]), - io.DynamicCombo.Option("top-k (SLA)", [ + io.DynamicCombo.Option("sla", [ io.Float.Input("keep_percent", default=10.0, min=0.5, max=95.0, step=0.5, tooltip="Percent of key blocks each query block keeps exactly (sinks and " "the diagonal ride on top). The selection SLA-style LoRAs are " "distilled against; without such a LoRA higher is closer to dense."), ]), - io.DynamicCombo.Option("VSA (FastVideo)", [ + io.DynamicCombo.Option("vsa", [ io.Float.Input("keep_percent", default=10.0, min=0.5, max=95.0, step=0.5, tooltip="Percent of video cubes each query cube keeps; FastH3-VSA " "checkpoints are trained at 10. Uses the model's to_gate_compress " "layers for the coarse branch when present."), ]), - ], tooltip="How exact key blocks are chosen. " - "Sol-Attn: per head/block adaptive threshold. " - "top-k (SLA): fixed keep_percent everywhere, recommended only with trained weights. " - "VSA (FastVideo): FastH3-VSA's cube tiling and coarse branch, requires weights trained for it."), + ], tooltip="Method used to choose key blocks for full token-level attention. " + "sol-attn: Sparsifying Online Attention uses a training-free adaptive threshold for each attention head and query block. " + "sla: Sparse-Linear Attention keeps a fixed percentage of the highest-scoring key blocks; use only with model weights trained for this pattern. " + "vsa: Video Sparse Attention (FastVideo) uses 3D video-cube tiling and a learned coarse attention branch; requires FastH3 model weights."), io.Float.Input("start_percent", default=0.2, min=0.0, max=1.0, step=0.01, - tooltip="Dense before this point of the schedule."), + tooltip="Percentage point when sparse attention begins. Before this point, attention stays dense."), io.Float.Input("end_percent", default=1.0, min=0.0, max=1.0, step=0.01, - tooltip="Dense after this point of the schedule."), + tooltip="Percentage point when sparse attention ends. After this point, attention returns to dense."), io.String.Input("dense_blocks", default="", advanced=True, tooltip="Transformer blocks that always run dense, e.g. '0, 1, 47-49'."), io.Int.Input("min_tokens", default=12288, min=0, max=1 << 20, step=512, advanced=True, @@ -401,22 +404,30 @@ def define_schema(cls): tooltip="MiniMax-H3 only. exact_kv: every query attends the packed text/audio/" "reference rows exactly (~3% cost). exact_kv_and_rows: additionally runs " "the target-audio query rows dense (keeps generated audio intact)."), - io.Boolean.Input("verbose", default=False, advanced=True), + io.Boolean.Input("verbose", default=False, advanced=True, + tooltip="Logs whether each attention shape used sparse attention or why it stayed dense."), ], - outputs=[io.Model.Output()], + outputs=[io.Model.Output(display_name="model", tooltip="The model with block-sparse attention applied.")], ) @classmethod def execute(cls, model, selection, start_percent, end_percent, dense_blocks="", min_tokens=12288, extra_tokens=0, sink_conditioning="exact_kv_and_rows", verbose=False) -> io.NodeOutput: mode = selection["selection"] - return io.NodeOutput(apply_block_sparse_attention( - model, tau=selection.get("tau", 1.3), - topk_ratio=0.0 if mode == "Sol-Attn (adaptive tau)" else selection["keep_percent"] / 100.0, - vsa=mode == "VSA (FastVideo)", - start_percent=start_percent, end_percent=end_percent, min_tokens=min_tokens, - dense_blocks=parse_block_list(dense_blocks), sink_conditioning=sink_conditioning, - extra_tokens=extra_tokens, verbose=verbose)) + patched_model = apply_block_sparse_attention( + model, + tau=selection.get("tau", 1.3), + topk_ratio=0.0 if mode == "sol-attn" else selection["keep_percent"] / 100.0, + vsa=mode == "vsa", + start_percent=start_percent, + end_percent=end_percent, + min_tokens=min_tokens, + dense_blocks=parse_block_list(dense_blocks), + sink_conditioning=sink_conditioning, + extra_tokens=extra_tokens, + verbose=verbose, + ) + return io.NodeOutput(patched_model) class BlockSparseAttentionExtension(ComfyExtension): diff --git a/comfy_extras/nodes_stable_cascade.py b/comfy_extras/nodes_stable_cascade.py index ddfb4f2b090..fd157b228b6 100644 --- a/comfy_extras/nodes_stable_cascade.py +++ b/comfy_extras/nodes_stable_cascade.py @@ -29,6 +29,7 @@ class StableCascade_EmptyLatentImage(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StableCascade_EmptyLatentImage", + display_name="Empty Stable Cascade Latent Image", category="model/latent/stable cascade", inputs=[ io.Int.Input("width", default=1024, min=256, max=nodes.MAX_RESOLUTION, step=8), @@ -58,6 +59,7 @@ class StableCascade_StageC_VAEEncode(io.ComfyNode): def define_schema(cls): return io.Schema( node_id="StableCascade_StageC_VAEEncode", + display_name="Stable Cascade Stage C VAE Encode", category="model/latent/stable cascade", inputs=[ io.Image.Input("image"), diff --git a/comfy_extras/nodes_textgen.py b/comfy_extras/nodes_textgen.py index ef5858a7dd6..243834cef8b 100644 --- a/comfy_extras/nodes_textgen.py +++ b/comfy_extras/nodes_textgen.py @@ -40,6 +40,7 @@ def define_schema(cls): io.DynamicCombo.Input("sampling_mode", options=sampling_options, display_name="Sampling Mode"), io.Boolean.Input("thinking", optional=True, default=False, tooltip="Operate in thinking mode if the model supports it."), io.Boolean.Input("use_default_template", optional=True, default=True, tooltip="Use the built in system prompt/template if the model has one.", advanced=True), + io.Combo.Input("mtp", options=["auto", "off", "2", "3", "4", "5"], default="auto", optional=True, tooltip="Speculative decoding with the checkpoint's multi-token-prediction head. No effect without MTP weights. auto adapts the draft depth; 2-5 pins it. Sampled output stays correctly distributed but differs from non-MTP output for the same seed."), ], outputs=[ io.String.Output(display_name="generated_text"), @@ -47,7 +48,9 @@ def define_schema(cls): ) @classmethod - def execute(cls, clip, prompt, max_length, sampling_mode, image=None, thinking=False, use_default_template=True, video=None, audio=None) -> io.NodeOutput: + def execute(cls, clip, prompt, max_length, sampling_mode, image=None, thinking=False, use_default_template=True, video=None, audio=None, mtp="auto") -> io.NodeOutput: + + mtp = False if mtp == "off" else (True if mtp == "auto" else int(mtp)) tokens = clip.tokenize(prompt, image=image, skip_template=not use_default_template, min_length=1, thinking=thinking, video=video, audio=audio) @@ -71,7 +74,8 @@ def execute(cls, clip, prompt, max_length, sampling_mode, image=None, thinking=F min_p=min_p, repetition_penalty=repetition_penalty, presence_penalty=presence_penalty, - seed=seed + seed=seed, + mtp=mtp ) generated_text = clip.decode(generated_ids) @@ -225,7 +229,7 @@ def define_schema(cls): ) @classmethod - def execute(cls, clip, prompt, max_length, sampling_mode, image=None, thinking=False, use_default_template=True, video=None, audio=None) -> io.NodeOutput: + def execute(cls, clip, prompt, max_length, sampling_mode, image=None, thinking=False, use_default_template=True, video=None, audio=None, mtp="auto") -> io.NodeOutput: # Gemma 3 and Gemma 4 use different chat-turn markers and image tokens. # The Gemma 4 text encoder is the LTX 2.4 path; Gemma 3 is LTX 2.0. is_gemma4 = "gemma4" in getattr(clip.tokenizer, "clip_name", "") @@ -254,7 +258,7 @@ def execute(cls, clip, prompt, max_length, sampling_mode, image=None, thinking=F f"model\n" ) - out = super().execute(clip, formatted_prompt, max_length, sampling_mode, image=image, thinking=thinking, use_default_template=use_default_template, video=video, audio=audio) + out = super().execute(clip, formatted_prompt, max_length, sampling_mode, image=image, thinking=thinking, use_default_template=use_default_template, video=video, audio=audio, mtp=mtp) # Drop reasoning, including a block left unclosed by max_length. Both system prompts ask # for the original prompt back when there is nothing to give; empty conditions on nothing. diff --git a/comfy_extras/nodes_toolkit.py b/comfy_extras/nodes_toolkit.py index 9f709bbe397..f43177a848f 100644 --- a/comfy_extras/nodes_toolkit.py +++ b/comfy_extras/nodes_toolkit.py @@ -5,9 +5,8 @@ class CreateList(io.ComfyNode): @classmethod def define_schema(cls): - template_matchtype = io.MatchType.Template("type") template_autogrow = io.Autogrow.TemplatePrefix( - input=io.MatchType.Input("input", template=template_matchtype), + input=io.AnyType.Input("input"), prefix="input", ) return io.Schema( @@ -18,8 +17,7 @@ def define_schema(cls): search_aliases=["Image Iterator", "Text Iterator", "Iterator"], inputs=[io.Autogrow.Input("inputs", template=template_autogrow)], outputs=[ - io.MatchType.Output( - template=template_matchtype, + io.AnyType.Output( is_output_list=True, display_name="list", ), @@ -34,11 +32,32 @@ def execute(cls, inputs: io.Autogrow.Type) -> io.NodeOutput: return io.NodeOutput(output_list) +class GetItemFromList(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="GetItemFromList", + display_name="Get Item From List", + category="utilities", + is_input_list=True, + inputs=[ + io.AnyType.Input("list"), + io.Int.Input("index", default=0), + ], + outputs=[io.AnyType.Output()], + ) + + @classmethod + def execute(cls, list, index) -> io.NodeOutput: + return io.NodeOutput(list[index[0]]) + + class ToolkitExtension(ComfyExtension): @override async def get_node_list(self) -> list[type[io.ComfyNode]]: return [ CreateList, + GetItemFromList, ] diff --git a/comfy_extras/nodes_trellis2.py b/comfy_extras/nodes_trellis2.py index 1e5f535cce7..07fdeb0a5f0 100644 --- a/comfy_extras/nodes_trellis2.py +++ b/comfy_extras/nodes_trellis2.py @@ -7,6 +7,7 @@ import comfy.latent_formats import comfy.model_management import comfy.utils +import logging import math import torch @@ -117,6 +118,7 @@ class VaeDecodeShapeTrellis(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="VaeDecodeShapeTrellis", + display_name="Trellis2 VAE Decode Shape", category="model/latent/trellis", inputs=[ IO.Latent.Input("samples"), @@ -195,6 +197,7 @@ class VaeDecodeTextureTrellis(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="VaeDecodeTextureTrellis", + display_name="Trellis2 VAE Decode Texture", category="model/latent/trellis", inputs=[ IO.Latent.Input("samples"), @@ -276,6 +279,7 @@ class VaeDecodeStructureTrellis2(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="VaeDecodeStructureTrellis2", + display_name="Trellis2 VAE Decode Structure", category="model/latent/trellis", inputs=[ IO.Latent.Input("samples"), @@ -317,7 +321,7 @@ class Trellis2UpsampleStage(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="Trellis2UpsampleStage", - category="model/conditioning/trellis2", + category="model/conditioning/trellis", display_name="Trellis2 Upsample Stage", inputs=[ IO.Conditioning.Input("positive"), @@ -439,7 +443,8 @@ class Trellis2Conditioning(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="Trellis2Conditioning", - category="model/conditioning/trellis2", + display_name="Trellis2 Conditioning", + category="model/conditioning/trellis", inputs=[ IO.ClipVision.Input("clip_vision_model"), IO.Image.Input("image", tooltip="Preprocessed image from ImageCropToMask (pad_factor=1.0 for TRELLIS.2)."), @@ -500,7 +505,8 @@ class Trellis2ShapeStage(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="Trellis2ShapeStage", - category="model/conditioning/trellis2", + display_name="Trellis2 Shape Stage", + category="model/conditioning/trellis", inputs=[ IO.Conditioning.Input("positive"), IO.Conditioning.Input("negative"), @@ -566,7 +572,8 @@ class Trellis2TextureStage(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="Trellis2TextureStage", - category="model/conditioning/trellis2", + display_name="Trellis2 Texture Stage", + category="model/conditioning/trellis", inputs=[ IO.Conditioning.Input("positive"), IO.Conditioning.Input("negative"), @@ -622,6 +629,7 @@ class EmptyTrellis2LatentStructure(IO.ComfyNode): def define_schema(cls): return IO.Schema( node_id="EmptyTrellis2LatentStructure", + display_name="Empty Trellis2 Latent Structure", category="model/latent/trellis", inputs=[ IO.Int.Input("batch_size", default=1, min=1, max=4096, tooltip="The number of latent images in the batch."), @@ -690,13 +698,81 @@ def _dino_encode_batch(clip_vision_model, image, out_device, *, want_patches=Fal out["composites"] = composite_list return out +def _naf_upsample(naf_model, lr_feat, composites, image_size, naf_target, out_device, compute_device): + """NAF-upsample each item's DINO patch grid to naf_target, guided by its composite.""" + if naf_model is None: + return None + comfy.model_management.load_model_gpu(naf_model) + inner = naf_model.model + model_dtype = next(inner.parameters()).dtype + out = torch.empty((len(composites), lr_feat.shape[1], *naf_target), device=out_device, dtype=model_dtype) + for i, c in enumerate(composites): + img_i = comfy.utils.common_upscale(c, image_size, image_size, "lanczos", "disabled").to(compute_device, model_dtype) + lr_i = lr_feat[i:i + 1].to(compute_device, model_dtype) + inner(img_i, lr_i, naf_target, output=out[i:i + 1]) + return out + + +def _build_pixal3d_conditioning(clip_vision_model, image, transform_matrix, camera_angle_x, mesh_scale, num_views=1): + """Per-item inputs hold B*num_views entries with each object's views consecutive; mesh_scale holds B.""" + naf_model = clip_vision_model.naf + out_device = comfy.model_management.intermediate_device() + compute_device = comfy.model_management.get_torch_device() + + cond = _dino_encode_batch(clip_vision_model, image, out_device, want_patches=True) + batch_size = cond["batch_size"] // num_views + fm_512_dino, fm_1024_dino = cond["patches_512"], cond["patches_1024"] + composite_list = cond["composites"] + + # NAF HR targets per stage: shape_512=512, shape_1024=512, tex_1024=1024 + hr_shape_512 = _naf_upsample(naf_model, fm_512_dino, composite_list, 512, (512, 512), out_device, compute_device) + hr_shape_1024 = _naf_upsample(naf_model, fm_1024_dino, composite_list, 1024, (512, 512), out_device, compute_device) + hr_tex_1024 = _naf_upsample(naf_model, fm_1024_dino, composite_list, 1024, (1024, 1024), out_device, compute_device) + + # CLS + register tokens averaged over each object's views + global_512 = cond["global_512"].unflatten(0, (batch_size, num_views)).mean(dim=1) + global_1024 = cond["global_1024"].unflatten(0, (batch_size, num_views)).mean(dim=1) + + proj_pack = { + "stages": { + "ss": {"feature_map": fm_512_dino, "feature_map_hr": None, "image_resolution": 512}, + "shape_512": {"feature_map": fm_512_dino, "feature_map_hr": hr_shape_512, "image_resolution": 512}, + "shape_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_shape_1024,"image_resolution": 1024}, + "tex_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_tex_1024, "image_resolution": 1024}, + }, + "transform_matrix": transform_matrix.to(out_device), + "camera_angle_x": camera_angle_x.to(out_device), + "mesh_scale": mesh_scale.to(out_device), + "num_views": num_views, + "patch_size": 16, + } + + # global_512 -> SS/shape_512 cross-attn; global_1024 -> shape_1024/tex_1024. + ss_proj_feats = compute_stage_proj_feats( + proj_pack, "ss", dense_grid_resolution=16, batch_size=batch_size, + device=compute_device, + ) + base_extras = { + "embeds": global_1024, "proj_feat_pack": proj_pack, + "trellis2_proj_feats": ss_proj_feats, + } + neg_extras = { + "embeds": torch.zeros_like(global_1024), "proj_feat_pack": proj_pack, + "trellis2_proj_feats": ss_proj_feats, + } + positive = [[global_512, base_extras]] + negative = [[torch.zeros_like(global_512), neg_extras]] + return IO.NodeOutput(positive, negative) + + class Pixal3DConditioning(IO.ComfyNode): @classmethod def define_schema(cls): return IO.Schema( node_id="Pixal3DConditioning", - category="model/conditioning/trellis2", + display_name="Pixal3D Conditioning", + category="model/conditioning/trellis", inputs=[ IO.ClipVision.Input("clip_vision_model", tooltip="DINOv3 ViT-L/16 ClipVision."), IO.Image.Input("image", tooltip="Preprocessed image from ImageCropToMask (pad_factor=1.1 for Pixal3D)."), @@ -715,79 +791,85 @@ def define_schema(cls): @classmethod def execute(cls, clip_vision_model, image, camera_angle_x) -> IO.NodeOutput: - naf_model = clip_vision_model.naf - out_device = comfy.model_management.intermediate_device() - compute_device = comfy.model_management.get_torch_device() - - cond = _dino_encode_batch(clip_vision_model, image, out_device, want_patches=True) - batch_size = cond["batch_size"] - global_512, global_1024 = cond["global_512"], cond["global_1024"] - fm_512_dino, fm_1024_dino = cond["patches_512"], cond["patches_1024"] - composite_list = cond["composites"] - - # The LR DINO grid AND the NAF HR grid are sampled separately - # NAF targets per stage: shape_512=512, shape_1024=512, tex_1024=1024. - def _naf_hr(lr_feat, composites, image_size, naf_target): - if naf_model is None or naf_target is None: - return None - comfy.model_management.load_model_gpu(naf_model) - inner = naf_model.model - model_dtype = next(inner.parameters()).dtype # set at load time (see clip_vision NAF) - hrs = [] - for i, c in enumerate(composites): - img_i = comfy.utils.common_upscale(c, image_size, image_size, "lanczos", "disabled")\ - .to(compute_device).to(model_dtype) - lr_i = lr_feat[i:i + 1].to(compute_device).to(model_dtype) - output = torch.empty((1, lr_i.shape[1], *naf_target), device=out_device, dtype=model_dtype) - hr_i = inner(img_i, lr_i, naf_target, output=output) - hrs.append(hr_i) - return torch.cat(hrs, dim=0) - - hr_shape_512 = _naf_hr(fm_512_dino, composite_list, 512, (512, 512)) - hr_shape_1024 = _naf_hr(fm_1024_dino, composite_list, 1024, (512, 512)) - hr_tex_1024 = _naf_hr(fm_1024_dino, composite_list, 1024, (1024, 1024)) - + batch_size = image.shape[0] # distance_from_fov: grid_point (-1, 0, 0) projects to pixel (0, image_resolution-1). # FOV widget is in degrees for UX; trig + downstream projection expect radians. camera_angle_x = math.radians(float(camera_angle_x)) distance = 0.5 / math.tan(camera_angle_x / 2.0) - cam_angle_t = torch.tensor([camera_angle_x] * batch_size, device=out_device, dtype=torch.float32) - dist_t = torch.tensor([distance] * batch_size, device=out_device, dtype=torch.float32) - scale_t = torch.ones(batch_size, device=out_device, dtype=torch.float32) - T = build_proj_transform_matrix(dist_t, batch_size, device=out_device, dtype=torch.float32) - - proj_pack = { - "stages": { - "ss": {"feature_map": fm_512_dino, "feature_map_hr": None, "image_resolution": 512}, - "shape_512": {"feature_map": fm_512_dino, "feature_map_hr": hr_shape_512, "image_resolution": 512}, - "shape_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_shape_1024,"image_resolution": 1024}, - "tex_1024": {"feature_map": fm_1024_dino, "feature_map_hr": hr_tex_1024, "image_resolution": 1024}, - }, - "transform_matrix": T, - "camera_angle_x": cam_angle_t, - "mesh_scale": scale_t, - "distance": dist_t, - "patch_size": 16, - } + cam_angle_t = torch.full((batch_size,), camera_angle_x) + dist_t = torch.full((batch_size,), distance) + T = build_proj_transform_matrix(dist_t, batch_size, dist_t.device) + return _build_pixal3d_conditioning(clip_vision_model, image, T, cam_angle_t, torch.ones(batch_size)) + + +_VIEW_AZIMUTHS = {"front": 0.0, "left": 90.0, "back": 180.0, "right": 270.0} +_VIEW_PAD = 1.1 # unit cube spans 1/1.1 of the frame, upstream's example rig - # global_512 → SS/shape_512 cross-attn; global_1024 → shape_1024/tex_1024. - ss_proj_feats = compute_stage_proj_feats( - proj_pack, "ss", dense_grid_resolution=16, batch_size=batch_size, - device=compute_device, + +def _orbit_camera_to_world(azimuths_deg, elevations_deg, distance): + """Z-up orbit cameras looking at the origin; azimuth 0 / elevation 0 is the front view.""" + az = torch.deg2rad(torch.tensor(azimuths_deg, dtype=torch.float32)) + el = torch.deg2rad(torch.tensor(elevations_deg, dtype=torch.float32)) + back = torch.stack([torch.sin(az) * torch.cos(el), -torch.cos(az) * torch.cos(el), torch.sin(el)], dim=-1) + right = torch.stack([torch.cos(az), torch.sin(az), torch.zeros_like(az)], dim=-1) + c2w = torch.eye(4).repeat(az.shape[0], 1, 1) + c2w[:, :3, :3] = torch.stack([right, torch.cross(back, right, dim=-1), back], dim=-1) + c2w[:, :3, 3] = back * distance + return c2w + + +class Pixal3DMultiViewConditioning(IO.ComfyNode): + """Fixed orbit rig: front, left, back and right views 90 degrees apart, used as framed.""" + + @classmethod + def define_schema(cls): + views = [IO.Image.Input(name, optional=True, + tooltip=f"Square view of the object's {name} side, with alpha or on a black background, " + "framed like the rig: the object spans about 1/1.1 of the frame at its widest, " + "the same scale in every view. The first connected view (front, left, back, " + "right order) is the front the mesh is posed to.") + for name in _VIEW_AZIMUTHS] + return IO.Schema( + node_id="Pixal3DMultiViewConditioning", + display_name="Pixal3D Multi-View Conditioning", + category="model/conditioning/trellis", + inputs=[IO.ClipVision.Input("clip_vision_model", tooltip="DINOv3 ViT-L/16 ClipVision with bundled NAF weights."), + IO.Float.Input("fov", default=20.0, min=1.0, max=170.0, step=0.01, round=False, + tooltip="Horizontal FOV in degrees of the views as framed: 20 for rig renders and most " + "multi-view generators, or MoGeGeometryToFOV on one of the views for photos.")] + + views, + outputs=[ + IO.Conditioning.Output(display_name="positive"), + IO.Conditioning.Output(display_name="negative"), + ], ) - neg_global = torch.zeros_like(global_512) - neg_embeds = torch.zeros_like(global_1024) - base_extras = { - "embeds": global_1024, "proj_feat_pack": proj_pack, - "trellis2_proj_feats": ss_proj_feats, - } - neg_extras = { - "embeds": neg_embeds, "proj_feat_pack": proj_pack, - "trellis2_proj_feats": ss_proj_feats, - } - positive = [[global_512, base_extras]] - negative = [[neg_global, neg_extras]] - return IO.NodeOutput(positive, negative) + + @classmethod + def execute(cls, clip_vision_model, fov, front=None, left=None, back=None, right=None) -> IO.NodeOutput: + views = {"front": front, "left": left, "back": back, "right": right} + names = [name for name in _VIEW_AZIMUTHS if views[name] is not None] + if not names: + raise ValueError("Pixal3DMultiViewConditioning needs at least one view") + batch_size = views[names[0]].shape[0] + num_views = len(names) + # the first connected view is the front (upstream re-bases the rig onto view 0; on an orbit that is an azimuth shift) + if names[0] != "front": + logging.warning(f"Pixal3DMultiViewConditioning: no front view, the mesh will be posed with the {names[0]} view as its front") + azimuths = [_VIEW_AZIMUTHS[name] - _VIEW_AZIMUTHS[names[0]] for name in names] + items = [] + for b in range(batch_size): + for name in names: + view = views[name][b % views[name].shape[0]][None] + if view.shape[-1] == 4: + view = view[..., :3] * view[..., 3:4] + if view.shape[1:3] != (1024, 1024): + view = comfy.utils.common_upscale(view.movedim(-1, 1), 1024, 1024, "lanczos", "disabled").movedim(1, -1) + items.append(view) + fov = math.radians(fov) + c2w = _orbit_camera_to_world(azimuths, [0.0] * num_views, _VIEW_PAD * 0.5 / math.tan(fov / 2.0)) + return _build_pixal3d_conditioning(clip_vision_model, torch.cat(items, dim=0), c2w.repeat(batch_size, 1, 1), + torch.full((batch_size * num_views,), fov), torch.ones(batch_size), + num_views=num_views) class Trellis2Extension(ComfyExtension): @@ -796,6 +878,7 @@ async def get_node_list(self) -> list[type[IO.ComfyNode]]: return [ Trellis2Conditioning, Pixal3DConditioning, + Pixal3DMultiViewConditioning, Trellis2ShapeStage, EmptyTrellis2LatentStructure, Trellis2TextureStage, diff --git a/comfy_extras/nodes_video.py b/comfy_extras/nodes_video.py index 5da594ba8d7..70876a96b77 100644 --- a/comfy_extras/nodes_video.py +++ b/comfy_extras/nodes_video.py @@ -231,6 +231,14 @@ def define_schema(cls): optional=True, tooltip="Colorspace of the input images. HDR selects BT.2020/HLG and HDR PQ selects BT.2020/PQ.", ), + io.Combo.Input( + "codec", + options=["none", *Types.VideoCodec.as_input()], + default="none", + advanced=True, + optional=True, + tooltip="Optionally encode the video immediately. None keeps the images in tensor form; Auto uses H.264.", + ), ], outputs=[ io.Video.Output(), @@ -239,17 +247,63 @@ def define_schema(cls): @classmethod def execute( - cls, images: Input.Image, fps: float, audio: Optional[Input.Audio] = None, bit_depth: int | str = "auto", color_space: str = "sRGB", + cls, images: Input.Image, fps: float, audio: Optional[Input.Audio] = None, bit_depth: int | str = "auto", color_space: str = "sRGB", codec: str = "none", ) -> io.NodeOutput: if bit_depth == "auto": bit_depth = 10 if color_space in ("HDR", "HDR PQ") else 8 - return io.NodeOutput( - InputImpl.VideoFromComponents( - Types.VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)), - bit_depth=bit_depth, - color_space=color_space, - ) + video = InputImpl.VideoFromComponents( + Types.VideoComponents(images=images, audio=audio, frame_rate=Fraction(fps)), + bit_depth=bit_depth, + color_space=color_space, ) + if codec != "none": + video = InputImpl.VideoFromList([video], codec=Types.VideoCodec(codec)) + return io.NodeOutput(video) + + +class ConcatenateVideo(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="ConcatenateVideo", + display_name="Concatenate Video", + category="video", + essentials_category="Video Tools", + description="Concatenates videos in order without decoding compatible encoded inputs.", + inputs=[ + io.Autogrow.Input( + "videos", + template=io.Autogrow.TemplatePrefix( + io.Video.Input("video", tooltip="A video segment to append."), + prefix="video", + min=1, + max=100, + ), + tooltip="Video segments to concatenate in input order.", + ), + io.Combo.Input( + "codec", + options=Types.VideoCodec.as_input(), + default="auto", + advanced=True, + tooltip="Codec used to encode videos tensors. Auto uses H.264; already encoded videos remain unchanged.", + ), + io.Audio.Input( + "complete_audio", + optional=True, + advanced=True, + tooltip="Optional complete soundtrack for the concatenated video. Overrides audio carried by the input videos.", + ), + ], + outputs=[io.Video.Output(tooltip="The concatenated video.")], + is_input_list=True, + ) + + @classmethod + def execute(cls, videos: io.Autogrow.Type, codec=None, complete_audio=None) -> io.NodeOutput: + videos = [video for group in videos.values() for video in group] + audio = complete_audio[0] if complete_audio else None + return io.NodeOutput(InputImpl.VideoFromList(videos, audio, Types.VideoCodec(codec[0] if codec else "auto"))) class GetVideoComponents(io.ComfyNode): @classmethod @@ -509,6 +563,7 @@ async def get_node_list(self) -> list[type[io.ComfyNode]]: SaveWEBM, SaveVideo, CreateVideo, + ConcatenateVideo, GetVideoComponents, LoadVideo, VideoSlice, diff --git a/comfy_extras/nodes_yue2.py b/comfy_extras/nodes_yue2.py new file mode 100644 index 00000000000..ad8b39cd9c6 --- /dev/null +++ b/comfy_extras/nodes_yue2.py @@ -0,0 +1,107 @@ +import torch +from typing_extensions import override + +import comfy.model_management +from comfy.text_encoders.yue2 import FRAMES_PER_SECOND +from comfy_api.latest import ComfyExtension, io + + +class YuE2GenerateABC(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="YuE2GenerateABC", + display_name="YuE2 Generate ABC", + category="model/conditioning/yue2", + description="Generates the ABC notation of a song from style and lyrics. Connect abc to YuE2 Generate Music node.", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("style", multiline=True, dynamic_prompts=True), + io.String.Input("lyrics", multiline=True, dynamic_prompts=True), + io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff, control_after_generate=True), + io.Combo.Input("mode", options=["full", "melody"], tooltip="full: generates melody and chords; melody: generates melody only, recommended for covers."), + io.Int.Input("max_abc_tokens", default=8192, min=1, max=20000, advanced=True), + io.Float.Input("temperature", default=0.7, min=0.0, max=5.0, step=0.05, advanced=True), + io.Float.Input("top_p", default=0.9, min=0.01, max=1.0, step=0.01, advanced=True), + io.Int.Input("top_k", default=30, min=1, max=32768, advanced=True), + io.Float.Input("repetition_penalty", default=1.005, min=0.01, max=10.0, step=0.005, advanced=True), + io.Int.Input("penalty_window", default=100, min=1, max=20000, advanced=True, tooltip="Number of recent ABC tokens used to penalize repetition."), + ], + outputs=[io.String.Output(display_name="abc")], + ) + + @classmethod + def execute(cls, clip, style, lyrics, seed, mode, max_abc_tokens, temperature=0.7, top_p=0.9, top_k=30, repetition_penalty=1.005, penalty_window=100): + tokens = clip.tokenize(style, lyrics=lyrics, cot=mode, seed=seed, max_tokens=max_abc_tokens, penalty_window=penalty_window) + ids = clip.generate(tokens, max_length=max_abc_tokens, temperature=temperature, top_p=top_p, top_k=top_k, + repetition_penalty=repetition_penalty, seed=seed) + return io.NodeOutput(clip.decode(ids)) + + +class YuE2GenerateMusic(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="YuE2GenerateMusic", + display_name="YuE2 Generate Music", + category="model/conditioning/yue2", + description="Generates music tokens and acoustic conditioning from style, lyrics, and an ABC notation. Provide the generated seconds to the Empty YuE2 Latent Audio node. An empty ABC input ignores the selected mode.", + inputs=[ + io.Clip.Input("clip"), + io.String.Input("style", multiline=True, dynamic_prompts=True), + io.String.Input("lyrics", multiline=True, dynamic_prompts=True), + io.String.Input("abc", default="", multiline=True, tooltip="Connect the ABC generator or supply an edited score. Leave empty to use off mode automatically."), + io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff, control_after_generate=True), + io.Combo.Input("mode", options=["full", "melody"], tooltip="full: generates melody and chords; melody: generates melody only, recommended for covers."), + io.Float.Input("max_duration", default=360.0, min=0.04, max=900.0, step=0.04, tooltip="Maximum duration in seconds. Automatically reduced for long prompts; generation can stop earlier."), + io.Float.Input("temperature", default=1.0, min=0.0, max=5.0, step=0.05, advanced=True), + io.Float.Input("top_p", default=0.95, min=0.01, max=1.0, step=0.01, advanced=True), + io.Int.Input("top_k", default=100, min=1, max=32768, advanced=True), + io.Float.Input("repetition_penalty", default=1.2, min=0.01, max=10.0, step=0.01, advanced=True), + io.Float.Input("cfg_scale", default=1.0, min=0.0, max=100.0, step=0.01, optional=True, advanced=True, tooltip="Autoregressive guidance for style and lyrics. 1.0 disables CFG, matching the ABC workflow. Use 1.01 to match the original off-mode guidance."), + ], + outputs=[io.Conditioning.Output(), io.Float.Output(display_name="seconds")], + ) + + @classmethod + def execute(cls, clip, style, lyrics, seed, mode, max_duration, temperature, top_p, top_k, repetition_penalty, abc="", cfg_scale=None): + if not abc.strip(): + mode = "off" + if cfg_scale is None: + cfg_scale = 1.01 if mode == "off" else 1.0 + tokens = clip.tokenize(style, lyrics=lyrics, cot=mode, seed=seed, abc=abc, + max_tokens=max(1, round(max_duration * FRAMES_PER_SECOND)), + temperature=temperature, top_p=top_p, top_k=top_k, repetition_penalty=repetition_penalty, cfg_scale=cfg_scale) + conditioning = clip.encode_from_tokens_scheduled(tokens) + return io.NodeOutput(conditioning, conditioning[0][1]["yue2_frames"] / FRAMES_PER_SECOND) + + +class EmptyYuE2LatentAudio(io.ComfyNode): + @classmethod + def define_schema(cls): + return io.Schema( + node_id="EmptyYuE2LatentAudio", + display_name="Empty YuE2 Latent Audio", + category="model/latent/yue2", + inputs=[ + io.Float.Input("seconds", default=120.0, min=0.04, max=1000.0, step=0.04), + io.Int.Input("batch_size", default=1, min=1, max=4096), + ], + outputs=[io.Latent.Output()], + ) + + @classmethod + def execute(cls, seconds, batch_size): + latent = torch.zeros((batch_size, 64, max(1, round(seconds * FRAMES_PER_SECOND))), + device=comfy.model_management.intermediate_device(), dtype=comfy.model_management.intermediate_dtype()) + return io.NodeOutput({"samples": latent, "type": "audio", "downscale_ratio_temporal": 1920}) + + +class YuE2Extension(ComfyExtension): + @override + async def get_node_list(self): + return [YuE2GenerateABC, YuE2GenerateMusic, EmptyYuE2LatentAudio] + + +async def comfy_entrypoint(): + return YuE2Extension() diff --git a/comfyui_version.py b/comfyui_version.py index 1e7cce4df1b..046f3e3a684 100644 --- a/comfyui_version.py +++ b/comfyui_version.py @@ -1,3 +1,3 @@ # This file is automatically generated by the build process when version is # updated in pyproject.toml. -__version__ = "0.34.0" +__version__ = "0.37.0" diff --git a/execution.py b/execution.py index 48c6f641b2e..2e4cf646f89 100644 --- a/execution.py +++ b/execution.py @@ -7,7 +7,7 @@ import time import traceback from enum import Enum -from typing import List, Literal, NamedTuple, Optional, Union +from typing import TYPE_CHECKING, List, Literal, NamedTuple, Optional, Union import asyncio import torch @@ -40,13 +40,17 @@ get_input_info, ) from comfy_execution.graph_utils import GraphBuilder, is_link -from comfy_execution.validation import validate_node_input +from comfy_execution.validation import LoopValidationError, validate_loops, validate_node_input from comfy_execution.progress import get_progress_state, reset_progress_state, add_progress_handler, WebUIProgressHandler from comfy_execution.utils import CurrentNodeContext -from comfy_execution.asset_enrichment import enrich_output_with_assets +from comfy_execution.asset_enrichment import register_executed_outputs, emit_cached_output from comfy_api.internal import _ComfyNodeInternal, _NodeOutputInternal, first_real_override, is_class, make_locked_method_func from comfy_api.latest import io, _io from comfy_execution.cache_provider import _has_cache_providers, _get_cache_providers, _logger as _cache_logger +from app.assets.manager import AssetManager, default_asset_manager + +if TYPE_CHECKING: + from comfy_execution.server_protocol import ExecutionServer class ExecutionResult(Enum): @@ -205,6 +209,8 @@ def mark_missing(): hidden_inputs_v3[io.Hidden.api_key_comfy_org] = extra_data.get("api_key_comfy_org", None) if io.Hidden.comfy_usage_source.name in hidden: hidden_inputs_v3[io.Hidden.comfy_usage_source] = extra_data.get("comfy_usage_source", None) + if io.Hidden.execution_list.name in hidden: + hidden_inputs_v3[io.Hidden.execution_list] = execution_list else: if "hidden" in valid_inputs: h = valid_inputs["hidden"] @@ -213,6 +219,8 @@ def mark_missing(): input_data_all[x] = [dynprompt.get_original_prompt() if dynprompt is not None else {}] if h[x] == "DYNPROMPT": input_data_all[x] = [dynprompt] + if h[x] == "EXECUTION_LIST": + input_data_all[x] = [execution_list] if h[x] == "EXTRA_PNGINFO": input_data_all[x] = [extra_data.get('extra_pnginfo', None)] if h[x] == "UNIQUE_ID": @@ -427,15 +435,7 @@ def _is_intermediate_output(dynprompt, node_id): return getattr(class_def, 'HAS_INTERMEDIATE_OUTPUT', False) -def _send_cached_ui(server, node_id, display_node_id, cached, prompt_id, ui_outputs): - if cached.ui is not None: - ui_outputs[node_id] = cached.ui - if server.client_id is None: - return - cached_ui = cached.ui or {} - server.send_sync("executed", { "node": node_id, "display_node": display_node_id, "output": cached_ui.get("output", None), "prompt_id": prompt_id }, server.client_id) - -async def execute(server, dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_outputs): +async def execute(server: "ExecutionServer", dynprompt, caches, current_item, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_outputs, asset_manager: AssetManager): unique_id = current_item real_node_id = dynprompt.get_real_node_id(unique_id) display_node_id = dynprompt.get_display_node_id(unique_id) @@ -445,7 +445,7 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed, class_def = nodes.NODE_CLASS_MAPPINGS[class_type] cached = await caches.outputs.get(unique_id) if cached is not None: - _send_cached_ui(server, unique_id, display_node_id, cached, prompt_id, ui_outputs) + emit_cached_output(server, unique_id, display_node_id, cached, prompt_id, ui_outputs, asset_manager) get_progress_state().finish_progress(unique_id) execution_list.cache_update(unique_id, cached) return (ExecutionResult.SUCCESS, None, None) @@ -518,6 +518,8 @@ async def execute(server, dynprompt, caches, current_item, extra_data, executed, for i in required_inputs: execution_list.make_input_strong_link(unique_id, i) return (ExecutionResult.PENDING, None, None) + if execution_list.is_staged_node_blocked(): + return (ExecutionResult.PENDING, None, None) def execution_block_cb(block): if block.message is not None: @@ -560,22 +562,19 @@ async def await_completion(): unblock() asyncio.create_task(await_completion()) return (ExecutionResult.PENDING, None, None) + cache_ui_value = ui_outputs.get(unique_id) if len(output_ui) > 0: - # Enrich at output-processing time (not in the send path) so assets - # are registered even when no client is connected, and the asset id - # flows into ui_outputs and the cache alongside the raw entries. - output_ui = enrich_output_with_assets(output_ui) - ui_outputs[unique_id] = { - "meta": { - "node_id": unique_id, - "display_node": display_node_id, - "parent_node": parent_node_id, - "real_node_id": real_node_id, - }, - "output": output_ui + meta = { + "node_id": unique_id, + "display_node": display_node_id, + "parent_node": parent_node_id, + "real_node_id": real_node_id, } + enriched_output_ui = register_executed_outputs(output_ui, prompt_id, asset_manager) + ui_outputs[unique_id] = {"meta": meta, "output": enriched_output_ui} + cache_ui_value = {"meta": meta, "output": output_ui} if server.client_id is not None: - server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": output_ui, "prompt_id": prompt_id }, server.client_id) + server.send_sync("executed", { "node": unique_id, "display_node": display_node_id, "output": enriched_output_ui, "prompt_id": prompt_id }, server.client_id) if has_subgraph: cached_outputs = [] new_node_ids = [] @@ -612,7 +611,7 @@ async def await_completion(): pending_subgraph_results[unique_id] = cached_outputs return (ExecutionResult.PENDING, None, None) - cache_entry = CacheEntry(ui=ui_outputs.get(unique_id), outputs=output_data) + cache_entry = CacheEntry(ui=cache_ui_value, outputs=output_data) execution_list.cache_update(unique_id, cache_entry) await caches.outputs.set(unique_id, cache_entry) @@ -662,10 +661,11 @@ async def await_completion(): return (ExecutionResult.SUCCESS, None, None) class PromptExecutor: - def __init__(self, server, cache_type=False, cache_args=None): + def __init__(self, server: "ExecutionServer", cache_type=False, cache_args=None, asset_manager: AssetManager | None = None): self.cache_args = cache_args self.cache_type = cache_type self.server = server + self.asset_manager = asset_manager if asset_manager is not None else default_asset_manager() self.prompt_model_tracker = comfy.model_patcher.PromptModelTracker() self.reset() @@ -786,7 +786,7 @@ async def execute_async(self, prompt, prompt_id, extra_data={}, execute_outputs= break assert node_id is not None, "Node ID should not be None at this point" - result, error, ex = await execute(self.server, dynamic_prompt, self.caches, node_id, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_node_outputs) + result, error, ex = await execute(self.server, dynamic_prompt, self.caches, node_id, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_node_outputs, self.asset_manager) self.success = result != ExecutionResult.FAILURE if result == ExecutionResult.FAILURE: self.handle_execution_error(prompt_id, dynamic_prompt.original_prompt, current_outputs, executed, error, ex) @@ -820,7 +820,7 @@ async def execute_async(self, prompt, prompt_id, extra_data={}, execute_outputs= cached = await self.caches.outputs.get(node_id) if cached is not None: display_node_id = dynamic_prompt.get_display_node_id(node_id) - _send_cached_ui(self.server, node_id, display_node_id, cached, prompt_id, ui_node_outputs) + emit_cached_output(self.server, node_id, display_node_id, cached, prompt_id, ui_node_outputs, self.asset_manager) self.add_message("execution_success", { "prompt_id": prompt_id }, broadcast=False) ui_outputs = {} @@ -1173,6 +1173,16 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[ } return (False, error, [], {}) + start_nodes = set() + end_nodes = set() + for node_id, node in prompt.items(): + class_def = nodes.NODE_CLASS_MAPPINGS[node["class_type"]] + boundary = class_def.GET_SCHEMA().loop_boundary if issubclass(class_def, _ComfyNodeInternal) else None + if boundary == "start": + start_nodes.add(node_id) + elif boundary == "end": + end_nodes.add(node_id) + good_outputs = set() errors = [] node_errors = {} @@ -1228,6 +1238,31 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[ node_errors[node_id]["dependent_outputs"].append(o) logging.error("Output will be ignored") + has_dependency_cycle = any( + reason["type"] == "dependency_cycle" + for valid, reasons, _ in validated.values() + for reason in reasons + ) + if not has_dependency_cycle: + try: + validate_loops(prompt, outputs, validated, start_nodes, end_nodes) + except LoopValidationError as ex: + dependent_outputs = ex.error["extra_info"]["output_ids"] + for node_id in ex.error["extra_info"]["node_ids"]: + if node_id not in node_errors: + node_errors[node_id] = { + "errors": [], + "dependent_outputs": [], + "class_type": prompt[node_id]["class_type"], + } + node_errors[node_id]["errors"].append(ex.error) + node_errors[node_id]["dependent_outputs"] = sorted( + set(node_errors[node_id]["dependent_outputs"]).union(dependent_outputs) + ) + for output_id in dependent_outputs: + good_outputs.discard(output_id) + errors.append((dependent_outputs[0], [ex.error])) + if len(good_outputs) == 0: errors_list = [] for o, errors in errors: @@ -1249,7 +1284,7 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[ MAXIMUM_HISTORY_SIZE = 10000 class PromptQueue: - def __init__(self, server): + def __init__(self, server: "ExecutionServer"): self.server = server self.mutex = threading.RLock() self.not_empty = threading.Condition(self.mutex) diff --git a/latent_preview.py b/latent_preview.py index d98b70019af..e3864e9386b 100644 --- a/latent_preview.py +++ b/latent_preview.py @@ -4,6 +4,7 @@ from comfy.taesd.taesd import TAESD from comfy.sd import VAE import comfy.model_management +import comfy.model_prefetch import folder_paths import comfy.utils import logging @@ -45,8 +46,16 @@ def decode_latent_to_preview(self, x0): return preview_to_image(x_sample) class TAEHVPreviewerImpl(TAESDPreviewerImpl): + def __init__(self, taesd, compile_preview=False): + super().__init__(taesd) + self.compile_preview = compile_preview + def decode_latent_to_preview(self, x0): - x_sample = self.taesd.decode(x0[:1, :, :1])[0][0] + samples = x0[:1, :, :1] + if self.compile_preview and comfy.model_prefetch.malloc_graph_enabled(self.taesd.device): + comfy.model_prefetch.malloc_graph_begin(self.taesd.device) + x_sample = self.taesd.decode(samples)[0][0] + comfy.model_prefetch.malloc_graph_end() return preview_to_image(x_sample, do_scale=False) class Latent2RGBPreviewer(LatentPreviewer): @@ -97,7 +106,7 @@ def get_previewer(device, latent_format): if latent_format.taesd_decoder_name in VIDEO_TAES: taesd = VAE(comfy.utils.load_torch_file(taesd_decoder_path)) taesd.first_stage_model.show_progress_bar = False - previewer = TAEHVPreviewerImpl(taesd) + previewer = TAEHVPreviewerImpl(taesd, compile_preview=latent_format.compile_preview) else: taesd = TAESD(None, taesd_decoder_path, latent_channels=latent_format.latent_channels).to(device) previewer = TAESDPreviewerImpl(taesd) diff --git a/main.py b/main.py index 20db14a36b0..3c6e41d353a 100644 --- a/main.py +++ b/main.py @@ -22,8 +22,9 @@ file_log_outputs = get_file_log_outputs(args.verbose) setup_logger(log_level=console_log_level, file_outputs=file_log_outputs, use_stdout=args.log_stdout) -from app.assets.seeder import asset_seeder -from app.assets.services import register_output_files +from app.database.db import dependencies_available, init_db +from app.assets.lifecycle import cleanup_temp_filesystem +from app.assets.manager import AssetManager, default_asset_manager import itertools import utils.extra_config from utils.mime_types import init_mime_types @@ -34,7 +35,6 @@ from comfy_execution.progress import get_progress_state from comfy_execution.utils import get_executing_context from comfy_api import feature_flags -from app.database.db import init_db, dependencies_available if __name__ == "__main__": #NOTE: These do not do anything on core ComfyUI, they are for custom nodes. @@ -111,7 +111,7 @@ def dump_traceback_on_sigint(signum, frame): os.environ['CUBLAS_WORKSPACE_CONFIG'] = ":4096:8" if "rocm" in cuda_malloc.get_torch_version_noimport(): - os.environ['OCL_SET_SVM_SIZE'] = '262144' # set at the request of AMD + os.environ['OCL_SET_SVM_SIZE'] = '4194304' # 4TB. Much larger than the ROCM 64GB/256GB defaults for Aimdos liberal VA use def handle_comfyui_manager_unavailable(): @@ -196,9 +196,9 @@ def execute_script(script_path): return False node_paths = folder_paths.get_folder_paths("custom_nodes") + node_prestartup_times = [] for custom_node_path in node_paths: possible_modules = os.listdir(custom_node_path) - node_prestartup_times = [] for possible_module in possible_modules: module_path = os.path.join(custom_node_path, possible_module) @@ -316,39 +316,7 @@ def cuda_malloc_warning(): logging.warning("\nWARNING: this card most likely does not support cuda-malloc, if you get \"CUDA error\" please run ComfyUI with: --disable-cuda-malloc\n") -def _collect_output_absolute_paths(history_result: dict) -> list[str]: - """Extract absolute file paths for output items from a history result.""" - paths: list[str] = [] - seen: set[str] = set() - for node_output in history_result.get("outputs", {}).values(): - for items in node_output.values(): - if not isinstance(items, list): - continue - for item in items: - if not isinstance(item, dict): - continue - item_type = item.get("type") - if item_type not in ("output", "temp"): - continue - base_dir = folder_paths.get_directory_by_type(item_type) - if base_dir is None: - continue - base_dir = os.path.abspath(base_dir) - filename = item.get("filename") - if not filename: - continue - abs_path = os.path.abspath( - os.path.join(base_dir, item.get("subfolder", ""), filename) - ) - if not abs_path.startswith(base_dir + os.sep) and abs_path != base_dir: - continue - if abs_path not in seen: - seen.add(abs_path) - paths.append(abs_path) - return paths - - -def prompt_worker(q, server_instance): +def prompt_worker(q, server_instance, asset_manager): current_time: float = 0.0 cache_ram = 0 cache_ram_inactive = 0 @@ -368,7 +336,7 @@ def prompt_worker(q, server_instance): elif args.cache_none: cache_type = execution.CacheType.NONE - e = execution.PromptExecutor(server_instance, cache_type=cache_type, cache_args={ "lru" : args.cache_lru, "ram" : cache_ram, "ram_inactive" : cache_ram_inactive } ) + e = execution.PromptExecutor(server_instance, cache_type=cache_type, cache_args={ "lru" : args.cache_lru, "ram" : cache_ram, "ram_inactive" : cache_ram_inactive }, asset_manager=asset_manager ) last_gc_collect = 0 need_gc = False gc_collect_interval = 10.0 @@ -390,7 +358,7 @@ def prompt_worker(q, server_instance): for k in sensitive: extra_data[k] = sensitive[k] - asset_seeder.pause() + asset_manager.pause_background_scan() e.execute(item[2], prompt_id, extra_data, item[4]) need_gc = True @@ -415,10 +383,6 @@ def prompt_worker(q, server_instance): else: logging.info("Prompt executed in {:.2f} seconds".format(execution_time), extra={'color': 'green'}) - if not asset_seeder.is_disabled(): - paths = _collect_output_absolute_paths(e.history_result) - register_output_files(paths, job_id=prompt_id) - flags = q.get_flags() free_memory = flags.get("free_memory", False) @@ -441,9 +405,8 @@ def prompt_worker(q, server_instance): need_gc = False hook_breaker_ac10a0.restore_functions() - if not asset_seeder.is_disabled(): - asset_seeder.enqueue_enrich(roots=("output",), compute_hashes=args.enable_asset_hashing) - asset_seeder.resume() + asset_manager.queue_output_scan() + asset_manager.resume_background_scan() async def run(server_instance, address='', port=8188, verbose=True, call_on_start=None): @@ -486,19 +449,13 @@ def hook(value, total, preview_image, prompt_id=None, node_id=None): comfy.utils.set_progress_bar_global_hook(hook) -def cleanup_temp(): - temp_dir = folder_paths.get_temp_directory() - if os.path.exists(temp_dir): - shutil.rmtree(temp_dir, ignore_errors=True) - +def setup_database(asset_manager): + if not dependencies_available(): + return -def setup_database(): try: - if dependencies_available(): - init_db() - if args.enable_assets: - if asset_seeder.start(roots=("models", "input", "output"), prune_first=True, compute_hashes=args.enable_asset_hashing): - logging.info("Background asset scan initiated for models, input, output") + init_db() + asset_manager.startup() except Exception as e: if "database is locked" in str(e): logging.error( @@ -507,6 +464,15 @@ def setup_database(): " --database-url sqlite:///path/to/another.db" ) sys.exit(1) + if "Could not acquire lock on database" in str(e): + logging.error( + "Database is locked. Another ComfyUI process is already using this database.\n" + "To resolve this, specify a separate database file for this instance:\n" + " --database-url sqlite:///path/to/another.db" + ) + if args.enable_assets: + sys.exit(1) + return if args.enable_assets: logging.error( f"Failed to initialize database: {e}\n" @@ -529,12 +495,15 @@ def start_comfyui(asyncio_loop=None): temp_dir = os.path.join(os.path.abspath(args.temp_directory), "temp") logging.info(f"Setting temp directory to: {temp_dir}") folder_paths.set_temp_directory(temp_dir) - cleanup_temp() + + asset_manager: AssetManager = default_asset_manager() + if not asset_manager.enabled: + cleanup_temp_filesystem() if not asyncio_loop: asyncio_loop = asyncio.new_event_loop() asyncio.set_event_loop(asyncio_loop) - prompt_server = server.PromptServer(asyncio_loop) + prompt_server = server.PromptServer(asyncio_loop, asset_manager) if args.enable_manager and not args.disable_manager_ui: comfyui_manager.start() @@ -552,12 +521,12 @@ def start_comfyui(asyncio_loop=None): hook_breaker_ac10a0.restore_functions() cuda_malloc_warning() - setup_database() + setup_database(asset_manager) prompt_server.add_routes() hijack_progress(prompt_server) - threading.Thread(target=prompt_worker, daemon=True, args=(prompt_server.prompt_queue, prompt_server,)).start() + threading.Thread(target=prompt_worker, daemon=True, args=(prompt_server.prompt_queue, prompt_server, asset_manager)).start() if args.quick_test_for_ci: exit(0) @@ -605,7 +574,7 @@ async def start_all(): "dynamic vram enabled and using native ComfyUI model formats instead. " "ComfyUI native formats like fp8, int8 and w4a8 will be faster even if they are larger than your memory." ) - event_loop, _, start_all_func = start_comfyui() + event_loop, prompt_server, start_all_func = start_comfyui() try: x = start_all_func() app.logger.print_startup_warnings() @@ -613,5 +582,4 @@ async def start_all(): except KeyboardInterrupt: logging.info("\nStopped server") finally: - asset_seeder.shutdown() - cleanup_temp() + prompt_server.asset_manager.shutdown() diff --git a/nodes.py b/nodes.py index c003e6deb9c..9bbba5a29e1 100644 --- a/nodes.py +++ b/nodes.py @@ -1009,7 +1009,7 @@ class CLIPLoader: @classmethod def INPUT_TYPES(s): return {"required": { "clip_name": (folder_paths.get_filename_list("text_encoders"), ), - "type": (["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image", "flux2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit", "ideogram4", "boogu", "krea2", "joyimage", "mage", "minimax"], ), + "type": (["stable_diffusion", "stable_cascade", "sd3", "stable_audio", "mochi", "ltxv", "pixart", "cosmos", "lumina2", "wan", "hidream", "chroma", "ace", "omnigen2", "qwen_image", "hunyuan_image", "flux2", "ovis", "longcat_image", "cogvideox", "lens", "pixeldit", "ideogram4", "boogu", "krea2", "joyimage", "mage", "minimax", "yue2"], ), }, "optional": { "device": (["default", "cpu"], {"advanced": True}), @@ -1248,8 +1248,8 @@ class EmptyLatentImage: def INPUT_TYPES(s): return { "required": { - "width": ("INT", {"default": 512, "min": 16, "max": MAX_RESOLUTION, "step": 8, "tooltip": "The width of the latent images in pixels."}), - "height": ("INT", {"default": 512, "min": 16, "max": MAX_RESOLUTION, "step": 8, "tooltip": "The height of the latent images in pixels."}), + "width": ("INT", {"default": 1024, "min": 16, "max": MAX_RESOLUTION, "step": 8, "tooltip": "The width of the latent images in pixels."}), + "height": ("INT", {"default": 1024, "min": 16, "max": MAX_RESOLUTION, "step": 8, "tooltip": "The height of the latent images in pixels."}), "batch_size": ("INT", {"default": 1, "min": 1, "max": 4096, "tooltip": "The number of latent images in the batch."}) } } @@ -2298,7 +2298,9 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom NODE_CLASS_MAPPINGS[name] = node_cls node_cls.RELATIVE_PYTHON_MODULE = "{}.{}".format(module_parent, get_module_name(module_path)) if hasattr(module, "NODE_DISPLAY_NAME_MAPPINGS") and getattr(module, "NODE_DISPLAY_NAME_MAPPINGS") is not None: - NODE_DISPLAY_NAME_MAPPINGS.update(module.NODE_DISPLAY_NAME_MAPPINGS) + for name, display_name in module.NODE_DISPLAY_NAME_MAPPINGS.items(): + if name not in ignore: + NODE_DISPLAY_NAME_MAPPINGS[name] = display_name return True # V3 Extension Definition elif hasattr(module, "comfy_entrypoint"): @@ -2325,8 +2327,8 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom if schema.node_id not in ignore: NODE_CLASS_MAPPINGS[schema.node_id] = node_cls node_cls.RELATIVE_PYTHON_MODULE = "{}.{}".format(module_parent, get_module_name(module_path)) - if schema.display_name is not None: - NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name + if schema.display_name is not None: + NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name return True except Exception as e: logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}") @@ -2456,8 +2458,10 @@ async def init_builtin_extra_nodes(): "nodes_lt_upsampler.py", "nodes_lt_audio.py", "nodes_minimax_music.py", + "nodes_yue2.py", "nodes_minimax_h3.py", "nodes_lt.py", + "nodes_lt_keyframes.py", "nodes_hooks.py", "nodes_multigpu.py", "nodes_load_3d.py", @@ -2535,7 +2539,9 @@ async def init_builtin_extra_nodes(): "nodes_depth_anything_3.py", "nodes_seed.py", "nodes_text.py", + "nodes_loop.py", "nodes_sam3d_body.py", + "nodes_marigold.py", ] import_failed = [] diff --git a/openapi.yaml b/openapi.yaml index e3f9609c5a2..d2288354e32 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -11,10 +11,6 @@ components: description: Display name of the asset. Mirrors name for backwards compatibility. nullable: true type: string - file_path: - description: Relative path in global-namespace-root form (e.g. "models/checkpoints/flux.safetensors") - nullable: true - type: string hash: description: Blake3 hash of the asset content. pattern: ^blake3:[a-f0-9]{64}$ @@ -152,10 +148,6 @@ components: description: Display name of the asset. Mirrors name for backwards compatibility. nullable: true type: string - file_path: - description: Relative path in global-namespace-root form (e.g. "models/checkpoints/flux.safetensors") - nullable: true - type: string hash: description: Blake3 hash of the asset content. pattern: ^blake3:[a-f0-9]{64}$ @@ -1714,11 +1706,6 @@ paths: name: name_contains schema: type: string - - description: JSON object for filtering by metadata fields - in: query - name: metadata_filter - schema: - type: string - description: Maximum number of assets to return (1-500) in: query name: limit @@ -2552,11 +2539,6 @@ paths: name: name_contains schema: type: string - - description: JSON object for filtering by metadata fields - in: query - name: metadata_filter - schema: - type: string - description: Maximum number of tags to return (1-1000, default 100) in: query name: limit diff --git a/pyproject.toml b/pyproject.toml index 574c8a3bc0a..a33b5bc18aa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "ComfyUI" -version = "0.34.0" +version = "0.37.0" readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.10" diff --git a/requirements.txt b/requirements.txt index a6fbac864a2..7e2eddf57e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ -comfyui-frontend-package==1.51.9 -comfyui-workflow-templates==0.11.55 -comfyui-embedded-docs==0.5.11 +comfyui-frontend-package==1.53.6 +comfyui-workflow-templates==0.11.66 +comfyui-embedded-docs==0.5.12 torch torchsde torchvision @@ -22,8 +22,8 @@ alembic SQLAlchemy>=2.0.0 filelock av>=17.0.0 -comfy-kitchen==0.2.33 -comfy-aimdo==0.5.2 +comfy-kitchen==0.2.35 +comfy-aimdo==0.5.5 requests simpleeval>=1.0.0 blake3 diff --git a/server.py b/server.py index c9ffcaa0da9..ac871911088 100644 --- a/server.py +++ b/server.py @@ -44,21 +44,21 @@ from comfyui_version import __version__ from app.frontend_management import FrontendManager, parse_version from comfy_api.internal import _ComfyNodeInternal -from app.assets.seeder import asset_seeder -from app.assets.api.routes import register_assets_routes -from app.assets.services.ingest import register_file_in_place -from app.assets.services.path_utils import get_known_subfolder_tags from app.assets.services.asset_management import resolve_hash_to_path +from app.assets.event_log import emit from app.user_manager import UserManager from app.model_manager import ModelFileManager from app.custom_node_manager import CustomNodeManager from app.subgraph_manager import SubgraphManager from app.node_replace_manager import NodeReplaceManager -from typing import Optional, Union +from typing import TYPE_CHECKING, Optional, Union from api_server.routes.internal.internal_routes import InternalRoutes from protocol import BinaryEventTypes +if TYPE_CHECKING: + from app.assets.manager import AssetManager + # Import cache control middleware from middleware.cache_middleware import cache_control @@ -213,9 +213,10 @@ async def block_external_middleware(request: web.Request, handler): class PromptServer(): - def __init__(self, loop): + def __init__(self, loop, asset_manager: "AssetManager"): PromptServer.instance = self + self.asset_manager = asset_manager self.user_manager = UserManager() self.model_file_manager = ModelFileManager() self.custom_node_manager = CustomNodeManager() @@ -254,11 +255,10 @@ def __init__(self, loop): else args.front_end_root ) logging.info(f"[Prompt Server] web root: {self.web_root}") - if args.enable_assets: - register_assets_routes(self.app, self.user_manager) - else: - register_assets_routes(self.app) - asset_seeder.disable() + self.asset_manager.register_routes(self.app, self.user_manager) + self.asset_manager.set_event_sink(self.send_sync) + if self.asset_manager.enabled: + emit("assets.enabled", hashing_enabled=args.enable_asset_hashing) routes = web.RouteTableDef() self.routes = routes self.last_node_id = None @@ -440,22 +440,22 @@ def image_upload(post, image_save_function=None): resp = {"name" : filename, "subfolder": subfolder, "type": image_upload_type} - if args.enable_assets: - try: - tag = image_upload_type if image_upload_type in ("input", "output") else "input" - tags = [tag] - tags.extend(get_known_subfolder_tags(subfolder)) - result = register_file_in_place(abs_path=filepath, name=filename, tags=tags) - resp["asset"] = { - "id": result.ref.id, - "name": result.ref.name, - "asset_hash": result.asset.hash, - "size": result.asset.size_bytes, - "mime_type": result.asset.mime_type, - "tags": result.tags, - } - except Exception: - logging.warning("Failed to register uploaded image as asset", exc_info=True) + view = self.asset_manager.register_upload( + abs_path=filepath, + name=filename, + upload_type=image_upload_type, + subfolder=subfolder, + content_written=not image_is_duplicate, + ) + if view is not None: + resp["asset"] = { + "id": view.asset.id, + "name": view.asset.name, + "asset_hash": view.asset_hash, + "size": view.size, + "mime_type": view.mime_type, + "tags": view.tags, + } return web.json_response(resp) else: @@ -523,8 +523,11 @@ async def view_image(request): # node preview, it constructs /view?filename=, so this # endpoint must resolve blake3 hashes to their on-disk file paths. if filename.startswith("blake3:"): - owner_id = self.user_manager.get_request_user_id(request) - result = resolve_hash_to_path(filename, owner_id=owner_id) + # Side-effect call: get_request_user_id raises KeyError for an unknown or + # system user in multi-user mode, which is what gates hash resolution. + # The returned id is deliberately unused (resolution is not owner-scoped). + self.user_manager.get_request_user_id(request) + result = resolve_hash_to_path(filename) if result is None: return web.Response(status=404) file, filename, resolved_content_type = result.abs_path, result.download_name, result.content_type @@ -799,7 +802,7 @@ def node_info(node_class): @routes.get("/object_info") async def get_object_info(request): - asset_seeder.start(roots=("models", "input", "output")) + self.asset_manager.ensure_scan_started() with folder_paths.cache_helper: out = {} for x in nodes.NODE_CLASS_MAPPINGS: diff --git a/tests-unit/app_test/test_db_init_locking.py b/tests-unit/app_test/test_db_init_locking.py new file mode 100644 index 00000000000..b9cfa8c429e --- /dev/null +++ b/tests-unit/app_test/test_db_init_locking.py @@ -0,0 +1,155 @@ +import logging +import os +import sqlite3 + +import pytest +import torch +from alembic import command +from alembic.config import Config +from filelock import FileLock, Timeout + +from app.database import db as db_module +from comfy.cli_args import args as cli_args + +if not torch.cuda.is_available(): + cli_args.cpu = True + +import main # noqa: E402 + +_PRE_HEAD = "0006_add_loader_path" + + +def _make_config(db_path: str) -> Config: + root = os.path.join(os.path.dirname(__file__), "../..") + cfg = Config(os.path.abspath(os.path.join(root, "alembic.ini"))) + cfg.set_main_option("script_location", os.path.abspath(os.path.join(root, "alembic_db"))) + cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + return cfg + + +def _current_revision(db_path: str) -> str: + with sqlite3.connect(db_path) as conn: + rows = conn.execute("SELECT version_num FROM alembic_version").fetchall() + assert len(rows) == 1 + return rows[0][0] + + +@pytest.fixture +def stale_db(tmp_path, monkeypatch): + db_path = str(tmp_path / "comfyui.db") + command.upgrade(_make_config(db_path), _PRE_HEAD) + + monkeypatch.setattr(db_module.args, "database_url", f"sqlite:///{db_path}") + monkeypatch.setattr(db_module, "Session", None) + monkeypatch.setattr(db_module, "_db_lock", None) + yield db_path + if db_module._db_lock is not None: + db_module._db_lock.release(force=True) + + +def test_init_file_db_migrates_when_lock_is_free(stale_db): + db_module._init_file_db(db_module.args.database_url) + + assert _current_revision(stale_db) != _PRE_HEAD + assert os.path.exists(stale_db + ".bkp") + + +def test_successful_init_keeps_holding_the_lock(stale_db): + db_module._init_file_db(db_module.args.database_url) + + contender = FileLock(stale_db + ".lock") + with pytest.raises(Timeout): + contender.acquire(timeout=0) + + +def test_failed_init_releases_the_lock(stale_db, monkeypatch): + def _explode(): + raise RuntimeError("alembic config exploded") + + monkeypatch.setattr(db_module, "get_alembic_config", _explode) + + with pytest.raises(RuntimeError, match="alembic config exploded"): + db_module._init_file_db(db_module.args.database_url) + + contender = FileLock(stale_db + ".lock") + try: + contender.acquire(timeout=0) + except Timeout: + pytest.fail( + "a failed init stranded the lock; setup_database logs and CONTINUES when assets are " + "disabled, so this process would block every other instance for its whole lifetime " + "over a database it never opened" + ) + contender.release() + + +def test_held_lock_blocks_before_any_migration_work(stale_db): + holder = FileLock(stale_db + ".lock") + holder.acquire(timeout=0) + try: + with pytest.raises(RuntimeError, match="Another ComfyUI process may already be using it"): + db_module._init_file_db(db_module.args.database_url) + + assert not os.path.exists(stale_db + ".bkp") + assert _current_revision(stale_db) == _PRE_HEAD + assert db_module.Session is None + finally: + holder.release() + + +def test_setup_database_routes_file_lock_to_lock_guidance(monkeypatch, caplog): + monkeypatch.setattr(main, "dependencies_available", lambda: True) + + def _raise_file_lock(): + raise RuntimeError( + "Could not acquire lock on database 'x.db'. " + "Another ComfyUI process may already be using it. " + "Use --database-url to specify a separate database file." + ) + + monkeypatch.setattr(main, "init_db", _raise_file_lock) + monkeypatch.setattr(main.args, "enable_assets", False) + + with caplog.at_level(logging.ERROR): + result = main.setup_database(None) + + assert result is None + assert "Database is locked. Another ComfyUI process is already using this database." in caplog.text + assert "Failed to initialize database." not in caplog.text + + +def test_setup_database_exits_for_file_lock_when_assets_are_enabled(monkeypatch, caplog): + monkeypatch.setattr(main, "dependencies_available", lambda: True) + + def _raise_file_lock(): + raise RuntimeError( + "Could not acquire lock on database 'x.db'. " + "Another ComfyUI process may already be using it. " + "Use --database-url to specify a separate database file." + ) + + monkeypatch.setattr(main, "init_db", _raise_file_lock) + monkeypatch.setattr(main.args, "enable_assets", True) + + with caplog.at_level(logging.ERROR), pytest.raises(SystemExit) as error: + main.setup_database(None) + + assert error.value.code == 1 + assert "Database is locked. Another ComfyUI process is already using this database." in caplog.text + assert "The --enable-assets flag requires a working database connection." not in caplog.text + + +def test_setup_database_exits_for_driver_lock_when_assets_are_disabled(monkeypatch, caplog): + monkeypatch.setattr(main, "dependencies_available", lambda: True) + + def _raise_driver_lock(): + raise Exception("sqlite3.OperationalError: database is locked") + + monkeypatch.setattr(main, "init_db", _raise_driver_lock) + monkeypatch.setattr(main.args, "enable_assets", False) + + with caplog.at_level(logging.ERROR), pytest.raises(SystemExit) as error: + main.setup_database(None) + + assert error.value.code == 1 + assert "Database is locked. Another ComfyUI process is already using this database." in caplog.text diff --git a/tests-unit/app_test/test_migration_0007.py b/tests-unit/app_test/test_migration_0007.py new file mode 100644 index 00000000000..ae7efd2bd51 --- /dev/null +++ b/tests-unit/app_test/test_migration_0007.py @@ -0,0 +1,215 @@ +import os +import sqlite3 + +import pytest +from alembic import command +from alembic.config import Config + +_BASELINE_0006 = "0006_add_loader_path" + + +def _make_config(db_path: str) -> Config: + root = os.path.join(os.path.dirname(__file__), "../..") + cfg = Config(os.path.abspath(os.path.join(root, "alembic.ini"))) + cfg.set_main_option("script_location", os.path.abspath(os.path.join(root, "alembic_db"))) + cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + return cfg + + +@pytest.fixture +def db_at_0006(tmp_path): + db_path = str(tmp_path / "test.db") + cfg = _make_config(db_path) + command.upgrade(cfg, _BASELINE_0006) + yield cfg, db_path + + +def test_0007_upgrade_from_0006(db_at_0006): + cfg, db_path = db_at_0006 + command.upgrade(cfg, "head") + with sqlite3.connect(db_path) as conn: + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "assets" in tables + assert "asset_contents" in tables + assert "asset_system_state" in tables + assert "asset_references" not in tables + + +def test_0007_schema_has_expected_columns(db_at_0006): + cfg, db_path = db_at_0006 + command.upgrade(cfg, "head") + with sqlite3.connect(db_path) as conn: + asset_cols = {r[1] for r in conn.execute("PRAGMA table_info(assets)")} + content_cols = {r[1] for r in conn.execute("PRAGMA table_info(asset_contents)")} + assert {"id", "content_id", "name", "loader_path", "updated_at", "last_access_time"} <= asset_cols + assert {"id", "hash", "path", "is_missing", "mtime_ns"} <= content_cols + + +def test_0007_downgrade_restores_0006_schema(db_at_0006): + cfg, db_path = db_at_0006 + command.upgrade(cfg, "head") + command.downgrade(cfg, _BASELINE_0006) + with sqlite3.connect(db_path) as conn: + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "asset_references" in tables + assert "asset_contents" not in tables + + +def test_0007_invariants_on_migrated_db(db_at_0006): + cfg, db_path = db_at_0006 + command.upgrade(cfg, "head") + with sqlite3.connect(db_path) as conn: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute( + "INSERT INTO asset_contents(id, path, is_missing, size_bytes, created_at) " + "VALUES ('c1', '/tmp/f1', 0, 0, '2024-01-01')" + ) + conn.execute( + "INSERT INTO assets(id, content_id, name, created_at, updated_at) " + "VALUES ('a1', 'c1', 'test', '2024-01-01', '2024-01-01')" + ) + conn.commit() + + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO asset_contents(id, path, is_missing, size_bytes, created_at) " + "VALUES ('c2', '/tmp/f1', 0, 0, '2024-01-01')" + ) + conn.commit() + conn.rollback() + + conn.execute( + "INSERT INTO asset_contents(id, path, is_missing, size_bytes, created_at) " + "VALUES ('c3', '/tmp/f1', 1, 0, '2024-01-01')" + ) + conn.commit() + + conn.execute("UPDATE asset_contents SET hash='abc123' WHERE id='c1'") + conn.execute( + "INSERT INTO asset_contents(id, path, hash, is_missing, size_bytes, created_at) " + "VALUES ('c4', '/tmp/f2', 'abc123', 0, 0, '2024-01-01')" + ) + conn.commit() + + +def test_0007_orm_parity(db_at_0006, tmp_path): + from sqlalchemy import create_engine, inspect + + import app.assets.database.models as asset_models + from app.database.models import Base + + cfg, db_path = db_at_0006 + command.upgrade(cfg, "head") + with sqlite3.connect(db_path) as conn: + alembic_tables = { + r[0] + for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name NOT LIKE 'alembic%' AND name NOT LIKE 'sqlite%'" + ) + } + + alembic_engine = create_engine(f"sqlite:///{db_path}") + orm_db = str(tmp_path / "orm.db") + engine = create_engine(f"sqlite:///{orm_db}") + alembic_inspector = inspect(alembic_engine) + Base.metadata.create_all(engine) + orm_inspector = inspect(engine) + orm_tables = set(orm_inspector.get_table_names()) + assert asset_models.AssetMeta.__tablename__ == "asset_meta" + + alembic_indexes = { + (index["name"], tuple(index["column_names"])) + for index in alembic_inspector.get_indexes("asset_meta") + } + orm_indexes = { + (index.name, tuple(index.columns.keys())) + for index in Base.metadata.tables["asset_meta"].indexes + } + + assert alembic_tables == orm_tables, f"Mismatch: alembic={alembic_tables}, orm={orm_tables}" + assert alembic_indexes == orm_indexes, f"Mismatch: alembic={alembic_indexes}, orm={orm_indexes}" + + +def test_0007_downgrade_chain_past_0003_succeeds(db_at_0006): + cfg, db_path = db_at_0006 + command.upgrade(cfg, "head") + command.downgrade(cfg, "0001_assets") + with sqlite3.connect(db_path) as conn: + tables = {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} + assert "assets_info" in tables + assert "asset_references" not in tables + assert "asset_contents" not in tables + + +def test_0007_downgrade_restores_legacy_assets_definition(db_at_0006, tmp_path): + from sqlalchemy import create_engine, inspect + + cfg, db_path = db_at_0006 + + reference_db = str(tmp_path / "reference_0006.db") + reference_cfg = _make_config(reference_db) + command.upgrade(reference_cfg, _BASELINE_0006) + + command.upgrade(cfg, "head") + command.downgrade(cfg, _BASELINE_0006) + + def _assets_shape(path: str): + engine = create_engine(f"sqlite:///{path}") + try: + inspector = inspect(engine) + indexes = { + (index["name"], tuple(index["column_names"]), bool(index["unique"])) + for index in inspector.get_indexes("assets") + } + checks = { + (check["name"], check["sqltext"]) + for check in inspector.get_check_constraints("assets") + } + defaults = { + column["name"]: column["default"] + for column in inspector.get_columns("assets") + } + return indexes, checks, defaults + finally: + engine.dispose() + + restored_indexes, restored_checks, restored_defaults = _assets_shape(db_path) + expected_indexes, expected_checks, expected_defaults = _assets_shape(reference_db) + + assert restored_indexes == expected_indexes, ( + f"index drift: restored={restored_indexes}, expected={expected_indexes}" + ) + assert restored_checks == expected_checks, ( + f"check drift: restored={restored_checks}, expected={expected_checks}" + ) + assert restored_defaults == expected_defaults, ( + f"default drift: restored={restored_defaults}, expected={expected_defaults}" + ) + + +def test_0007_downgrade_restores_0006_asset_references_indexes(db_at_0006, tmp_path): + from sqlalchemy import create_engine, inspect + + cfg, db_path = db_at_0006 + + reference_db = str(tmp_path / "reference_0006.db") + reference_cfg = _make_config(reference_db) + command.upgrade(reference_cfg, _BASELINE_0006) + + command.upgrade(cfg, "head") + command.downgrade(cfg, _BASELINE_0006) + + def _asset_reference_indexes(path: str) -> set[tuple[str, tuple[str, ...]]]: + engine = create_engine(f"sqlite:///{path}") + try: + return { + (index["name"], tuple(index["column_names"])) + for index in inspect(engine).get_indexes("asset_references") + } + finally: + engine.dispose() + + restored = _asset_reference_indexes(db_path) + expected = _asset_reference_indexes(reference_db) + assert restored == expected, f"index drift: restored={restored}, expected={expected}" diff --git a/tests-unit/app_test/test_migration_roundtrip.py b/tests-unit/app_test/test_migration_roundtrip.py new file mode 100644 index 00000000000..838dc69ad84 --- /dev/null +++ b/tests-unit/app_test/test_migration_roundtrip.py @@ -0,0 +1,56 @@ + +import os +import sqlite3 + +import pytest +from alembic import command +from alembic.config import Config + + +def _make_config(db_path: str) -> Config: + root = os.path.join(os.path.dirname(__file__), "../..") + cfg = Config(os.path.abspath(os.path.join(root, "alembic.ini"))) + cfg.set_main_option("script_location", os.path.abspath(os.path.join(root, "alembic_db"))) + cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + return cfg + + +def _user_tables(db_path: str) -> set[str]: + conn = sqlite3.connect(db_path) + try: + return { + row[0] + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' " + "AND name NOT LIKE 'alembic%' AND name NOT LIKE 'sqlite%'" + ) + } + finally: + conn.close() + + +@pytest.fixture +def db_at_head(tmp_path): + db_path = str(tmp_path / "roundtrip.db") + cfg = _make_config(db_path) + command.upgrade(cfg, "head") + yield cfg, db_path + + +def test_downgrade_to_base_empties_the_schema(db_at_head): + cfg, db_path = db_at_head + assert _user_tables(db_path), "fixture did not build a schema" + + command.downgrade(cfg, "base") + + assert _user_tables(db_path) == set() + + +def test_upgrade_to_head_again_after_downgrade_to_base(db_at_head): + cfg, db_path = db_at_head + expected = _user_tables(db_path) + + command.downgrade(cfg, "base") + command.upgrade(cfg, "head") + + assert _user_tables(db_path) == expected diff --git a/tests-unit/app_test/test_migration_warning.py b/tests-unit/app_test/test_migration_warning.py new file mode 100644 index 00000000000..86c1874b587 --- /dev/null +++ b/tests-unit/app_test/test_migration_warning.py @@ -0,0 +1,110 @@ +import os + +import pytest +from alembic import command +from alembic.config import Config +from alembic.script import ScriptDirectory + +import app.database.db as db_module + +_BASELINE_0006 = "0006_add_loader_path" +_BASELINE_0002 = "0002_merge_to_asset_references" +_DESTRUCTIVE = "0007_record_content_split" + +_DISCARDED_CLASSES = ( + "manual tags", + "user metadata", + "previews", + "renames", + "API-created records", + "job_id", +) + + +def _make_config(db_path: str) -> Config: + root = os.path.join(os.path.dirname(__file__), "../..") + cfg = Config(os.path.abspath(os.path.join(root, "alembic.ini"))) + cfg.set_main_option("script_location", os.path.abspath(os.path.join(root, "alembic_db"))) + cfg.set_main_option("sqlalchemy.url", f"sqlite:///{db_path}") + return cfg + + +@pytest.fixture +def captured_warnings(monkeypatch): + warnings: list[str] = [] + monkeypatch.setattr(db_module, "log_startup_warning", warnings.append) + return warnings + + +@pytest.fixture +def db_url_for(monkeypatch): + def _set(db_path: str) -> str: + url = f"sqlite:///{db_path}" + monkeypatch.setattr(db_module.args, "database_url", url) + return url + + return _set + + +def _migrate(db_path: str, db_url: str, db_exists: bool) -> None: + db_module._migrate_and_bind(db_url, db_path, db_exists) + + +def test_upgrade_across_the_destructive_revision_warns_and_names_the_backup( + tmp_path, captured_warnings, db_url_for +): + db_path = str(tmp_path / "existing.db") + command.upgrade(_make_config(db_path), _BASELINE_0006) + db_url = db_url_for(db_path) + assert os.path.exists(db_path) + + _migrate(db_path, db_url, db_exists=True) + + assert len(captured_warnings) == 1, ( + "the destructive rebuild is one startup event, not one warning per traversed revision" + ) + message = captured_warnings[0] + assert db_path + ".bkp" in message, "the warning must name the backup it left behind" + for discarded in _DISCARDED_CLASSES: + assert discarded in message, ( + f"the warning must inventory what the rebuild discarded; {discarded!r} is missing" + ) + + +def test_fresh_install_warns_nothing(tmp_path, captured_warnings, db_url_for): + db_path = str(tmp_path / "fresh.db") + db_url = db_url_for(db_path) + assert not os.path.exists(db_path) + + _migrate(db_path, db_url, db_exists=False) + + assert captured_warnings == [], ( + "a fresh install discarded nothing and took no backup, so it has nothing to warn about" + ) + + +def test_upgrade_that_does_not_cross_the_destructive_revision_warns_nothing( + tmp_path, captured_warnings, db_url_for, monkeypatch +): + db_path = str(tmp_path / "partial.db") + command.upgrade(_make_config(db_path), _BASELINE_0002) + db_url = db_url_for(db_path) + monkeypatch.setattr(ScriptDirectory, "get_current_head", lambda _self: _BASELINE_0006) + + _migrate(db_path, db_url, db_exists=True) + + assert captured_warnings == [], ( + "a multi-revision upgrade that never traverses the rebuild keeps every row it had" + ) + + +def test_database_already_at_head_warns_nothing(tmp_path, captured_warnings, db_url_for): + db_path = str(tmp_path / "current.db") + command.upgrade(_make_config(db_path), "head") + db_url = db_url_for(db_path) + + _migrate(db_path, db_url, db_exists=True) + + assert captured_warnings == [], ( + "an up-to-date database migrates nothing, so it must stay silent on every boot" + ) diff --git a/tests-unit/assets_test/__init__.py b/tests-unit/assets_test/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests-unit/assets_test/conftest.py b/tests-unit/assets_test/conftest.py index 44416e8c519..201d86cee59 100644 --- a/tests-unit/assets_test/conftest.py +++ b/tests-unit/assets_test/conftest.py @@ -13,6 +13,8 @@ import pytest import requests +from .helpers import assert_hash_fields_consistent + def pytest_addoption(parser: pytest.Parser) -> None: """ @@ -28,6 +30,18 @@ def pytest_addoption(parser: pytest.Parser) -> None: default=os.environ.get("ASSETS_TEST_DB_URL"), help="SQLAlchemy DB URL (e.g. sqlite:///path/to/db.sqlite3)", ) + parser.addoption( + "--enable-asset-hashing", + action="store_true", + help="Start the assets subprocess with hash-mode behavior enabled.", + ) + + +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line( + "markers", + "hashing_on: exercises the subprocess harness with --enable-asset-hashing", + ) def _free_port() -> int: @@ -87,6 +101,9 @@ def comfy_url_and_proc(comfy_tmp_base_dir: Path, request: pytest.FixtureRequest) - autoscan disabled Returns (base_url, process, port) """ + # TODO: re-enable once the --enable-assets subprocess runs stop flaking. + pytest.skip("assets subprocess tests temporarily disabled") + port = _free_port() db_url = request.config.getoption("--db-url") if not db_url: @@ -103,8 +120,7 @@ def comfy_url_and_proc(comfy_tmp_base_dir: Path, request: pytest.FixtureRequest) if not (comfy_root / "main.py").is_file(): raise FileNotFoundError(f"main.py not found under {comfy_root}") - proc = subprocess.Popen( - args=[ + command = [ sys.executable, "main.py", f"--base-directory={str(comfy_tmp_base_dir)}", @@ -115,7 +131,19 @@ def comfy_url_and_proc(comfy_tmp_base_dir: Path, request: pytest.FixtureRequest) "--port", str(port), "--cpu", - ], + ] + if ( + request.config.getoption("--enable-asset-hashing") + or "hashing_on" in request.config.getoption("markexpr") + or any( + item.get_closest_marker("hashing_on") + for item in request.session.items + ) + ): + command.append("--enable-asset-hashing") + + proc = subprocess.Popen( + args=command, stdout=out_log, stderr=err_log, cwd=str(comfy_root), @@ -190,8 +218,9 @@ def _post_multipart_asset( @pytest.fixture def make_asset_bytes() -> Callable[[str, int], bytes]: # Salt content per test so it never collides with assets left over from - # earlier tests. Delete is now always a soft delete (content is preserved), - # so the suite can no longer rely on hard-deleting content for isolation. + # earlier tests. Delete hard-deletes the record but preserves content + # (content rows and files are untouched), so the suite cannot rely on delete + # removing content for isolation. # Deterministic within a test: the same (name, size) yields the same bytes. salt = uuid.uuid4().bytes @@ -236,9 +265,6 @@ def seeded_asset(request: pytest.FixtureRequest, http: requests.Session, api_bas if tags is None: tags = ["models", "model_type:checkpoints", "unit-tests", "alpha"] meta = {"purpose": "test", "epoch": 1, "flags": ["x", "y"], "nullable": None} - # Unique content per test so the seed always creates a fresh asset (201). - # Delete is now always a soft delete, so content from a prior test survives - # and would otherwise dedup this upload into an existing asset (200). content = uuid.uuid4().bytes + b"A" * (4096 - 16) files = {"file": (name, content, "application/octet-stream")} form_data = { @@ -249,7 +275,6 @@ def seeded_asset(request: pytest.FixtureRequest, http: requests.Session, api_bas r = http.post(api_base + "/api/assets", files=files, data=form_data, timeout=120) body = r.json() assert r.status_code == 201, body - from helpers import assert_hash_fields_consistent assert_hash_fields_consistent(body) return body diff --git a/tests-unit/assets_test/fixtures/assets_event_lines.txt b/tests-unit/assets_test/fixtures/assets_event_lines.txt new file mode 100644 index 00000000000..2aa36ce15a6 --- /dev/null +++ b/tests-unit/assets_test/fixtures/assets_event_lines.txt @@ -0,0 +1,3 @@ +[assets-event] seeder.scan_completed created=12 elapsed_ms=8123 enrich_failed=0 enriched=4 hash_failed=2 permission_denied=0 phase=fast root=models skipped=3 +[assets-event] seeder.scan_started phase=enrich +[assets-event] scanner.stat_failed error_type=PermissionError site=discovery diff --git a/tests-unit/assets_test/helpers.py b/tests-unit/assets_test/helpers.py index ae3de6dc3de..4e400184b99 100644 --- a/tests-unit/assets_test/helpers.py +++ b/tests-unit/assets_test/helpers.py @@ -1,7 +1,130 @@ """Helper functions for assets integration tests.""" +from __future__ import annotations + +import json import time +import uuid +from collections.abc import Iterator, Mapping +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import TypeAlias, TypedDict +import pytest import requests +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from sqlalchemy import Engine, create_engine +from sqlalchemy.orm import Session + +from app.assets.api import routes +from app.assets.database.models import Asset +from app.assets.database.queries.records import create_content, create_record +from app.database.models import Base + + +class _AssetItemOptional(TypedDict, total=False): + preview_id: str + + +class AssetItem(_AssetItemOptional): + id: str + name: str + + +class _AssetListBodyOptional(TypedDict, total=False): + next_cursor: str + + +class AssetListBody(_AssetListBodyOptional): + assets: list[AssetItem] + total: int + has_more: bool + + +class ErrorItem(TypedDict): + code: str + + +class ErrorBody(TypedDict): + error: ErrorItem + + +@dataclass(frozen=True, slots=True) +class RecordSeed: + name: str + tags: tuple[str, ...] = () + size_bytes: int = 0 + + +RouteDatabase: TypeAlias = tuple[Engine, Session] + + +@pytest.fixture(autouse=True) +def autoclean_unit_test_assets() -> Iterator[None]: + yield + + +@pytest.fixture +def route_database(monkeypatch: pytest.MonkeyPatch) -> Iterator[RouteDatabase]: + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + monkeypatch.setattr(routes, "create_session", lambda: Session(engine)) + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + with Session(engine) as session: + yield engine, session + engine.dispose() + + +def seed_record(session: Session, seed: RecordSeed) -> Asset: + content = create_content( + session, + path=f"/output/{uuid.uuid4()}-{seed.name}", + size_bytes=seed.size_bytes, + ) + return create_record( + session, + content_id=content.id, + name=seed.name, + mime_type="image/png", + tags=seed.tags, + ) + + +@pytest.fixture +def sortable_record_ids(route_database: RouteDatabase) -> tuple[str, str]: + _, session = route_database + older = seed_record(session, RecordSeed("z.png", ("sort-case",), 100)) + newer = seed_record(session, RecordSeed("a.png", ("sort-case",), 200)) + base_time = datetime(2026, 1, 1) + older.created_at = base_time + newer.created_at = base_time + timedelta(days=1) + newer.updated_at = base_time + older.updated_at = base_time + timedelta(days=1) + older.last_access_time = base_time + newer.last_access_time = base_time + timedelta(days=1) + session.commit() + return newer.id, older.id + + +async def request_assets(query: str = "") -> web.StreamResponse: + suffix = f"?{query}" if query else "" + return await routes.list_assets_route( + make_mocked_request("GET", f"/api/assets{suffix}") + ) + + +def asset_list_body(response: web.StreamResponse) -> AssetListBody: + assert isinstance(response, web.Response) + body = response.body + assert isinstance(body, bytes | bytearray) + return json.loads(body) + + +def error_body(response: web.StreamResponse) -> ErrorBody: + assert isinstance(response, web.Response) + body = response.body + assert isinstance(body, bytes | bytearray) + return json.loads(body) def trigger_sync_seed_assets(session: requests.Session, base_url: str) -> None: @@ -28,7 +151,10 @@ def get_asset_filename(asset_hash: str, extension: str) -> str: return asset_hash.removeprefix("blake3:") + extension -def assert_hash_fields_consistent(body: dict, expected_hash: str | None = None) -> None: +def assert_hash_fields_consistent( + body: Mapping[str, str | None], + expected_hash: str | None = None, +) -> None: """Assert hash and asset_hash invariants on an Asset response. Both must be present or both absent (so a regression that drops only one diff --git a/tests-unit/assets_test/queries/conftest.py b/tests-unit/assets_test/queries/conftest.py index 4ca0e86a996..18b67c15978 100644 --- a/tests-unit/assets_test/queries/conftest.py +++ b/tests-unit/assets_test/queries/conftest.py @@ -5,6 +5,18 @@ from app.assets.database.models import Base +@pytest.fixture(scope="session", autouse=True) +def assert_asset_metadata_tables(): + assert set(Base.metadata.tables) == { + "assets", + "asset_contents", + "asset_meta", + "asset_tags", + "tags", + "asset_system_state", + } + + @pytest.fixture def session(): """In-memory SQLite session for fast unit tests.""" diff --git a/tests-unit/assets_test/queries/test_asset.py b/tests-unit/assets_test/queries/test_asset.py index 9b7eb4bac34..d1cf9b2de37 100644 --- a/tests-unit/assets_test/queries/test_asset.py +++ b/tests-unit/assets_test/queries/test_asset.py @@ -1,187 +1,20 @@ -import uuid - -import pytest +import sqlalchemy as sa from sqlalchemy.orm import Session -from app.assets.helpers import get_utc_now -from app.assets.database.models import Asset -from app.assets.database.queries import ( - asset_exists_by_hash, - get_asset_by_hash, - upsert_asset, - bulk_insert_assets, - update_asset_hash_and_mime, -) - - -class TestAssetExistsByHash: - @pytest.mark.parametrize( - "setup_hash,query_hash,expected", - [ - (None, "nonexistent", False), # No asset exists - ("blake3:abc123", "blake3:abc123", True), # Asset exists with matching hash - (None, "", False), # Null hash in DB doesn't match empty string - ], - ids=["nonexistent", "existing", "null_hash_no_match"], - ) - def test_exists_by_hash(self, session: Session, setup_hash, query_hash, expected): - if setup_hash is not None or query_hash == "": - asset = Asset(hash=setup_hash, size_bytes=100) - session.add(asset) - session.commit() - - assert asset_exists_by_hash(session, asset_hash=query_hash) is expected - - -class TestGetAssetByHash: - @pytest.mark.parametrize( - "setup_hash,query_hash,should_find", - [ - (None, "nonexistent", False), - ("blake3:def456", "blake3:def456", True), - ], - ids=["nonexistent", "existing"], - ) - def test_get_by_hash(self, session: Session, setup_hash, query_hash, should_find): - if setup_hash is not None: - asset = Asset(hash=setup_hash, size_bytes=200, mime_type="image/png") - session.add(asset) - session.commit() - - result = get_asset_by_hash(session, asset_hash=query_hash) - if should_find: - assert result is not None - assert result.size_bytes == 200 - assert result.mime_type == "image/png" - else: - assert result is None - - -class TestUpsertAsset: - @pytest.mark.parametrize( - "first_size,first_mime,second_size,second_mime,expect_created,expect_updated,final_size,final_mime", - [ - # New asset creation - (None, None, 1024, "application/octet-stream", True, False, 1024, "application/octet-stream"), - # Existing asset, same values - no update - (500, "text/plain", 500, "text/plain", False, False, 500, "text/plain"), - # Existing asset with size 0, update with new values - (0, None, 2048, "image/png", False, True, 2048, "image/png"), - # Existing asset, second call with size 0 - no update - (1000, None, 0, None, False, False, 1000, None), - ], - ids=["new_asset", "existing_no_change", "update_from_zero", "zero_size_no_update"], - ) - def test_upsert_scenarios( - self, - session: Session, - first_size, - first_mime, - second_size, - second_mime, - expect_created, - expect_updated, - final_size, - final_mime, - ): - asset_hash = f"blake3:test_{first_size}_{second_size}" - - # First upsert (if first_size is not None, we're testing the second call) - if first_size is not None: - upsert_asset( - session, - asset_hash=asset_hash, - size_bytes=first_size, - mime_type=first_mime, - ) - session.commit() - - # The upsert call we're testing - asset, created, updated = upsert_asset( - session, - asset_hash=asset_hash, - size_bytes=second_size, - mime_type=second_mime, - ) - session.commit() - - assert created is expect_created - assert updated is expect_updated - assert asset.size_bytes == final_size - assert asset.mime_type == final_mime - - -class TestBulkInsertAssets: - def test_inserts_multiple_assets(self, session: Session): - now = get_utc_now() - rows = [ - {"id": str(uuid.uuid4()), "hash": "blake3:bulk1", "size_bytes": 100, "mime_type": "text/plain", "created_at": now}, - {"id": str(uuid.uuid4()), "hash": "blake3:bulk2", "size_bytes": 200, "mime_type": "image/png", "created_at": now}, - {"id": str(uuid.uuid4()), "hash": "blake3:bulk3", "size_bytes": 300, "mime_type": None, "created_at": now}, - ] - bulk_insert_assets(session, rows) - session.commit() - - assets = session.query(Asset).all() - assert len(assets) == 3 - hashes = {a.hash for a in assets} - assert hashes == {"blake3:bulk1", "blake3:bulk2", "blake3:bulk3"} - - def test_empty_list_is_noop(self, session: Session): - bulk_insert_assets(session, []) - session.commit() - assert session.query(Asset).count() == 0 - - def test_handles_large_batch(self, session: Session): - """Test chunking logic with more rows than MAX_BIND_PARAMS allows.""" - now = get_utc_now() - rows = [ - {"id": str(uuid.uuid4()), "hash": f"blake3:large{i}", "size_bytes": i, "mime_type": None, "created_at": now} - for i in range(200) - ] - bulk_insert_assets(session, rows) - session.commit() - - assert session.query(Asset).count() == 200 +from app.assets.database.models import AssetContent +from app.assets.database.queries import create_content, create_record -class TestMimeTypeImmutability: - """mime_type on Asset is write-once: set on first ingest, never overwritten.""" +def test_hash_is_a_content_fact(session: Session) -> None: + content = create_content(session, "/models/a.safetensors", hash="same", size_bytes=12) + record = create_record(session, content.id, "a.safetensors") - @pytest.mark.parametrize( - "initial_mime,second_mime,expected_mime", - [ - ("image/png", "image/jpeg", "image/png"), - (None, "image/png", "image/png"), - ], - ids=["preserves_existing", "fills_null"], - ) - def test_upsert_mime_immutability(self, session: Session, initial_mime, second_mime, expected_mime): - h = f"blake3:upsert_{initial_mime}_{second_mime}" - upsert_asset(session, asset_hash=h, size_bytes=100, mime_type=initial_mime) - session.commit() + assert session.scalar(sa.select(AssetContent).where(AssetContent.hash == "same")) == content + assert record.content_id == content.id - asset, created, _ = upsert_asset(session, asset_hash=h, size_bytes=100, mime_type=second_mime) - assert created is False - assert asset.mime_type == expected_mime - @pytest.mark.parametrize( - "initial_mime,update_mime,update_hash,expected_mime,expected_hash", - [ - (None, "image/png", None, "image/png", "blake3:upd0"), - ("image/png", "image/jpeg", None, "image/png", "blake3:upd1"), - ("image/png", "image/jpeg", "blake3:upd2_new", "image/png", "blake3:upd2_new"), - ], - ids=["fills_null", "preserves_existing", "hash_updates_mime_locked"], - ) - def test_update_asset_hash_and_mime_immutability( - self, session: Session, initial_mime, update_mime, update_hash, expected_mime, expected_hash, - ): - h = expected_hash.removesuffix("_new") - asset = Asset(hash=h, size_bytes=100, mime_type=initial_mime) - session.add(asset) - session.flush() +def test_equal_hashes_do_not_merge_content_rows(session: Session) -> None: + first = create_content(session, "/models/a.safetensors", hash="same") + second = create_content(session, "/models/b.safetensors", hash="same") - update_asset_hash_and_mime(session, asset_id=asset.id, mime_type=update_mime, asset_hash=update_hash) - assert asset.mime_type == expected_mime - assert asset.hash == expected_hash + assert first.id != second.id diff --git a/tests-unit/assets_test/queries/test_asset_info.py b/tests-unit/assets_test/queries/test_asset_info.py index 74dfb8a3797..dba67a7087c 100644 --- a/tests-unit/assets_test/queries/test_asset_info.py +++ b/tests-unit/assets_test/queries/test_asset_info.py @@ -1,580 +1,29 @@ -import time -import uuid -import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference, AssetReferenceMeta from app.assets.database.queries import ( - reference_exists_for_asset_id, - get_reference_by_id, - insert_reference, - get_or_create_reference, - update_reference_timestamps, - list_references_page, - fetch_reference_asset_and_tags, - fetch_reference_and_asset, - update_reference_access_time, - set_reference_metadata, - delete_reference_by_id, - set_reference_preview, - bulk_insert_references_ignore_conflicts, - get_reference_ids_by_ids, - ensure_tags_exist, - add_tags_to_reference, + create_content, + create_record, + delete_record, + get_record_by_id, + rename_record, ) -from app.assets.helpers import get_utc_now -def _make_asset(session: Session, hash_val: str | None = None, size: int = 1024) -> Asset: - asset = Asset(hash=hash_val, size_bytes=size, mime_type="application/octet-stream") - session.add(asset) - session.flush() - return asset +def test_record_operations_do_not_mutate_content(session: Session) -> None: + content = create_content(session, "/output/result.png", hash="digest", size_bytes=3) + record = create_record(session, content.id, "before.png", mime_type="image/png") + renamed = rename_record(session, record.id, "after.png") -def _make_reference( - session: Session, - asset: Asset, - name: str = "test", - owner_id: str = "", -) -> AssetReference: - now = get_utc_now() - ref = AssetReference( - owner_id=owner_id, - name=name, - asset_id=asset.id, - created_at=now, - updated_at=now, - last_access_time=now, - ) - session.add(ref) - session.flush() - return ref + assert renamed.content_id == content.id + assert renamed.name == "after.png" + assert content.hash == "digest" -class TestReferenceExistsForAssetId: - def test_returns_false_when_no_reference(self, session: Session): - asset = _make_asset(session, "hash1") - assert reference_exists_for_asset_id(session, asset_id=asset.id) is False +def test_delete_record_is_hard_delete(session: Session) -> None: + content = create_content(session, "/output/result.png") + record = create_record(session, content.id, "result.png") - def test_returns_true_when_reference_exists(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset) - assert reference_exists_for_asset_id(session, asset_id=asset.id) is True + delete_record(session, record.id) - -class TestGetReferenceById: - def test_returns_none_for_nonexistent(self, session: Session): - assert get_reference_by_id(session, reference_id="nonexistent") is None - - def test_returns_reference(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset, name="myfile.txt") - - result = get_reference_by_id(session, reference_id=ref.id) - assert result is not None - assert result.name == "myfile.txt" - - -class TestListReferencesPage: - def test_empty_db(self, session: Session): - refs, tag_map, total = list_references_page(session) - assert refs == [] - assert tag_map == {} - assert total == 0 - - def test_returns_references_with_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset, name="test.bin") - ensure_tags_exist(session, ["alpha", "beta"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["alpha", "beta"]) - session.commit() - - refs, tag_map, total = list_references_page(session) - assert len(refs) == 1 - assert refs[0].id == ref.id - assert set(tag_map[ref.id]) == {"alpha", "beta"} - assert total == 1 - - def test_name_contains_filter(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, name="model_v1.safetensors") - _make_reference(session, asset, name="config.json") - session.commit() - - refs, _, total = list_references_page(session, name_contains="model") - assert total == 1 - assert refs[0].name == "model_v1.safetensors" - - def test_owner_visibility(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, name="public", owner_id="") - _make_reference(session, asset, name="private", owner_id="user1") - session.commit() - - # Empty owner sees only public - refs, _, total = list_references_page(session, owner_id="") - assert total == 1 - assert refs[0].name == "public" - - # Owner sees both - refs, _, total = list_references_page(session, owner_id="user1") - assert total == 2 - - def test_include_tags_filter(self, session: Session): - asset = _make_asset(session, "hash1") - ref1 = _make_reference(session, asset, name="tagged") - _make_reference(session, asset, name="untagged") - ensure_tags_exist(session, ["wanted"]) - add_tags_to_reference(session, reference_id=ref1.id, tags=["wanted"]) - session.commit() - - refs, _, total = list_references_page(session, include_tags=["wanted"]) - assert total == 1 - assert refs[0].name == "tagged" - - def test_include_tags_filter_ands_persisted_model_tags(self, session: Session): - asset = _make_asset(session, "hash-model-tags") - checkpoint = _make_reference(session, asset, name="checkpoint") - lora = _make_reference(session, asset, name="lora") - input_ref = _make_reference(session, asset, name="input") - ensure_tags_exist( - session, - ["models", "model_type:checkpoints", "model_type:loras", "unit-tests"], - ) - add_tags_to_reference( - session, - reference_id=checkpoint.id, - tags=["models", "model_type:checkpoints", "unit-tests"], - origin="automatic", - ) - add_tags_to_reference( - session, - reference_id=lora.id, - tags=["models", "model_type:loras", "unit-tests"], - origin="automatic", - ) - add_tags_to_reference( - session, - reference_id=input_ref.id, - tags=["unit-tests"], - ) - session.commit() - - refs, _, total = list_references_page( - session, - include_tags=["models", "model_type:checkpoints", "unit-tests"], - ) - - assert total == 1 - assert refs[0].id == checkpoint.id - - def test_include_tags_filter_preserves_model_type_case(self, session: Session): - asset = _make_asset(session, "hash-model-case") - ref = _make_reference(session, asset, name="llm") - ensure_tags_exist(session, ["models", "model_type:LLM"]) - add_tags_to_reference( - session, - reference_id=ref.id, - tags=["models", "model_type:LLM"], - origin="automatic", - ) - session.commit() - - refs, _, total = list_references_page( - session, include_tags=["models", "model_type:LLM"] - ) - refs_lower, _, total_lower = list_references_page( - session, include_tags=["models", "model_type:llm"] - ) - - assert total == 1 - assert refs[0].id == ref.id - assert total_lower == 0 - assert refs_lower == [] - - def test_exclude_tags_filter(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, name="keep") - ref_exclude = _make_reference(session, asset, name="exclude") - ensure_tags_exist(session, ["bad"]) - add_tags_to_reference(session, reference_id=ref_exclude.id, tags=["bad"]) - session.commit() - - refs, _, total = list_references_page(session, exclude_tags=["bad"]) - assert total == 1 - assert refs[0].name == "keep" - - def test_sorting(self, session: Session): - asset = _make_asset(session, "hash1", size=100) - asset2 = _make_asset(session, "hash2", size=500) - _make_reference(session, asset, name="small") - _make_reference(session, asset2, name="large") - session.commit() - - refs, _, _ = list_references_page(session, sort="size", order="desc") - assert refs[0].name == "large" - - refs, _, _ = list_references_page(session, sort="name", order="asc") - assert refs[0].name == "large" - - -class TestFetchReferenceAssetAndTags: - def test_returns_none_for_nonexistent(self, session: Session): - result = fetch_reference_asset_and_tags(session, "nonexistent") - assert result is None - - def test_returns_tuple(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset, name="test.bin") - ensure_tags_exist(session, ["tag1"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["tag1"]) - session.commit() - - result = fetch_reference_asset_and_tags(session, ref.id) - assert result is not None - ret_ref, ret_asset, ret_tags = result - assert ret_ref.id == ref.id - assert ret_asset.id == asset.id - assert ret_tags == ["tag1"] - - -class TestFetchReferenceAndAsset: - def test_returns_none_for_nonexistent(self, session: Session): - result = fetch_reference_and_asset(session, reference_id="nonexistent") - assert result is None - - def test_returns_tuple(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - session.commit() - - result = fetch_reference_and_asset(session, reference_id=ref.id) - assert result is not None - ret_ref, ret_asset = result - assert ret_ref.id == ref.id - assert ret_asset.id == asset.id - - -class TestUpdateReferenceAccessTime: - def test_updates_last_access_time(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - original_time = ref.last_access_time - session.commit() - - import time - time.sleep(0.01) - - update_reference_access_time(session, reference_id=ref.id) - session.commit() - - session.refresh(ref) - assert ref.last_access_time > original_time - - -class TestDeleteReferenceById: - def test_deletes_existing(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - session.commit() - - result = delete_reference_by_id(session, reference_id=ref.id, owner_id="") - assert result is True - assert get_reference_by_id(session, reference_id=ref.id) is None - - def test_returns_false_for_nonexistent(self, session: Session): - result = delete_reference_by_id(session, reference_id="nonexistent", owner_id="") - assert result is False - - def test_respects_owner_visibility(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset, owner_id="user1") - session.commit() - - result = delete_reference_by_id(session, reference_id=ref.id, owner_id="user2") - assert result is False - assert get_reference_by_id(session, reference_id=ref.id) is not None - - -class TestSetReferencePreview: - def test_sets_preview(self, session: Session): - asset = _make_asset(session, "hash1") - preview_asset = _make_asset(session, "preview_hash") - ref = _make_reference(session, asset) - preview_ref = _make_reference(session, preview_asset, name="preview.png") - session.commit() - - set_reference_preview(session, reference_id=ref.id, preview_reference_id=preview_ref.id) - session.commit() - - session.refresh(ref) - assert ref.preview_id == preview_ref.id - - def test_clears_preview(self, session: Session): - asset = _make_asset(session, "hash1") - preview_asset = _make_asset(session, "preview_hash") - ref = _make_reference(session, asset) - preview_ref = _make_reference(session, preview_asset, name="preview.png") - ref.preview_id = preview_ref.id - session.commit() - - set_reference_preview(session, reference_id=ref.id, preview_reference_id=None) - session.commit() - - session.refresh(ref) - assert ref.preview_id is None - - def test_raises_for_nonexistent_reference(self, session: Session): - with pytest.raises(ValueError, match="not found"): - set_reference_preview(session, reference_id="nonexistent", preview_reference_id=None) - - def test_raises_for_nonexistent_preview(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - session.commit() - - with pytest.raises(ValueError, match="Preview AssetReference"): - set_reference_preview(session, reference_id=ref.id, preview_reference_id="nonexistent") - - -class TestInsertReference: - def test_creates_new_reference(self, session: Session): - asset = _make_asset(session, "hash1") - ref = insert_reference( - session, asset_id=asset.id, owner_id="user1", name="test.bin" - ) - session.commit() - - assert ref is not None - assert ref.name == "test.bin" - assert ref.owner_id == "user1" - - def test_allows_duplicate_names(self, session: Session): - asset = _make_asset(session, "hash1") - ref1 = insert_reference(session, asset_id=asset.id, owner_id="user1", name="dup.bin") - session.commit() - - # Duplicate names are now allowed - ref2 = insert_reference( - session, asset_id=asset.id, owner_id="user1", name="dup.bin" - ) - session.commit() - - assert ref1 is not None - assert ref2 is not None - assert ref1.id != ref2.id - - -class TestGetOrCreateReference: - def test_creates_new_reference(self, session: Session): - asset = _make_asset(session, "hash1") - ref, created = get_or_create_reference( - session, asset_id=asset.id, owner_id="user1", name="new.bin" - ) - session.commit() - - assert created is True - assert ref.name == "new.bin" - - def test_always_creates_new_reference(self, session: Session): - asset = _make_asset(session, "hash1") - ref1, created1 = get_or_create_reference( - session, asset_id=asset.id, owner_id="user1", name="existing.bin" - ) - session.commit() - - # Duplicate names are allowed, so always creates new - ref2, created2 = get_or_create_reference( - session, asset_id=asset.id, owner_id="user1", name="existing.bin" - ) - session.commit() - - assert created1 is True - assert created2 is True - assert ref1.id != ref2.id - - -class TestUpdateReferenceTimestamps: - def test_updates_timestamps(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - original_updated_at = ref.updated_at - session.commit() - - time.sleep(0.01) - update_reference_timestamps(session, ref) - session.commit() - - session.refresh(ref) - assert ref.updated_at > original_updated_at - - def test_updates_preview_id(self, session: Session): - asset = _make_asset(session, "hash1") - preview_asset = _make_asset(session, "preview_hash") - ref = _make_reference(session, asset) - preview_ref = _make_reference(session, preview_asset, name="preview.png") - session.commit() - - update_reference_timestamps(session, ref, preview_id=preview_ref.id) - session.commit() - - session.refresh(ref) - assert ref.preview_id == preview_ref.id - - -class TestSetReferenceMetadata: - def test_sets_metadata(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - session.commit() - - set_reference_metadata( - session, reference_id=ref.id, user_metadata={"key": "value"} - ) - session.commit() - - session.refresh(ref) - assert ref.user_metadata == {"key": "value"} - # Check metadata table - meta = session.query(AssetReferenceMeta).filter_by(asset_reference_id=ref.id).all() - assert len(meta) == 1 - assert meta[0].key == "key" - assert meta[0].val_str == "value" - - def test_replaces_existing_metadata(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - session.commit() - - set_reference_metadata( - session, reference_id=ref.id, user_metadata={"old": "data"} - ) - session.commit() - - set_reference_metadata( - session, reference_id=ref.id, user_metadata={"new": "data"} - ) - session.commit() - - meta = session.query(AssetReferenceMeta).filter_by(asset_reference_id=ref.id).all() - assert len(meta) == 1 - assert meta[0].key == "new" - - def test_clears_metadata_with_empty_dict(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - session.commit() - - set_reference_metadata( - session, reference_id=ref.id, user_metadata={"key": "value"} - ) - session.commit() - - set_reference_metadata( - session, reference_id=ref.id, user_metadata={} - ) - session.commit() - - session.refresh(ref) - assert ref.user_metadata == {} - meta = session.query(AssetReferenceMeta).filter_by(asset_reference_id=ref.id).all() - assert len(meta) == 0 - - def test_raises_for_nonexistent(self, session: Session): - with pytest.raises(ValueError, match="not found"): - set_reference_metadata( - session, reference_id="nonexistent", user_metadata={"key": "value"} - ) - - -class TestBulkInsertReferencesIgnoreConflicts: - def test_inserts_multiple_references(self, session: Session): - asset = _make_asset(session, "hash1") - now = get_utc_now() - rows = [ - { - "id": str(uuid.uuid4()), - "owner_id": "", - "name": "bulk1.bin", - "asset_id": asset.id, - "preview_id": None, - "user_metadata": {}, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - { - "id": str(uuid.uuid4()), - "owner_id": "", - "name": "bulk2.bin", - "asset_id": asset.id, - "preview_id": None, - "user_metadata": {}, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - ] - bulk_insert_references_ignore_conflicts(session, rows) - session.commit() - - refs = session.query(AssetReference).all() - assert len(refs) == 2 - - def test_allows_duplicate_names(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, name="existing.bin", owner_id="") - session.commit() - - now = get_utc_now() - rows = [ - { - "id": str(uuid.uuid4()), - "owner_id": "", - "name": "existing.bin", - "asset_id": asset.id, - "preview_id": None, - "user_metadata": {}, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - { - "id": str(uuid.uuid4()), - "owner_id": "", - "name": "new.bin", - "asset_id": asset.id, - "preview_id": None, - "user_metadata": {}, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - ] - bulk_insert_references_ignore_conflicts(session, rows) - session.commit() - - # Duplicate names allowed, so all 3 rows exist - refs = session.query(AssetReference).all() - assert len(refs) == 3 - - def test_empty_list_is_noop(self, session: Session): - bulk_insert_references_ignore_conflicts(session, []) - assert session.query(AssetReference).count() == 0 - - -class TestGetReferenceIdsByIds: - def test_returns_existing_ids(self, session: Session): - asset = _make_asset(session, "hash1") - ref1 = _make_reference(session, asset, name="a.bin") - ref2 = _make_reference(session, asset, name="b.bin") - session.commit() - - found = get_reference_ids_by_ids(session, [ref1.id, ref2.id, "nonexistent"]) - - assert found == {ref1.id, ref2.id} - - def test_empty_list_returns_empty(self, session: Session): - found = get_reference_ids_by_ids(session, []) - assert found == set() + assert get_record_by_id(session, record.id) is None diff --git a/tests-unit/assets_test/queries/test_asset_reference_keyset.py b/tests-unit/assets_test/queries/test_asset_reference_keyset.py index d143d60f943..b3d2c42cd74 100644 --- a/tests-unit/assets_test/queries/test_asset_reference_keyset.py +++ b/tests-unit/assets_test/queries/test_asset_reference_keyset.py @@ -1,112 +1,33 @@ -"""Keyset-pagination tiebreaker tests for list_references_page. - -When multiple rows share the same primary sort value (e.g. four assets -created in the same microsecond), the secondary `ORDER BY id` is what keeps -keyset pagination from losing or repeating rows. This file exercises that -branch directly against an in-memory SQLite session — engineering identical -timestamps via HTTP is unreliable enough that we work at the query layer. -""" -import uuid -from datetime import datetime - -import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference -from app.assets.database.queries.asset_reference import list_references_page +from app.assets.database.queries import create_content, create_record, list_records_page +from app.assets.database.queries.records import RecordCursorBoundary, RecordPageSpec -def _make_ref(session: Session, created_at: datetime, name: str, owner: str = "") -> AssetReference: - asset = Asset(hash=f"blake3:{uuid.uuid4().hex}", size_bytes=1024) - session.add(asset) +def test_record_keyset_cursor_pages_in_creation_order(session: Session) -> None: + records = [ + create_record(session, create_content(session, f"/output/{name}").id, name) + for name in ("one.png", "two.png", "three.png") + ] + for index, record in enumerate(records, start=1): + record.id = f"00000000-0000-0000-0000-{index:012d}" session.flush() - ref = AssetReference( - id=str(uuid.uuid4()), - asset_id=asset.id, - owner_id=owner, - name=name, - file_path=f"/tmp/{name}", - created_at=created_at, - updated_at=created_at, - last_access_time=created_at, - is_missing=False, - ) - session.add(ref) - return ref - - -@pytest.mark.parametrize("order", ["desc", "asc"]) -def test_tiebreaker_walks_duplicate_sort_values(session: Session, order: str): - """Four rows with the SAME created_at must paginate cleanly under cursor - mode — no row dropped, no row repeated, despite the primary sort column - being non-discriminating. - """ - shared_ts = datetime(2024, 5, 20, 12, 0, 0) # naive UTC, like the DB stores - refs = [_make_ref(session, shared_ts, f"tie_{i}.png") for i in range(4)] - session.commit() - - expected_ids = sorted([r.id for r in refs], reverse=(order == "desc")) - # Walk the cursor by hand: page size 2, take 3 pages (2 + 2 + 0). - seen: list[str] = [] - after_value = None - after_id = None - for _ in range(4): # generous loop bound; ought to be 2 iterations - page, _tag_map, _total = list_references_page( - session, - limit=2, - sort="created_at", - order=order, - after_cursor_value=after_value, - after_cursor_id=after_id, - ) - if not page: - break - seen.extend(p.id for p in page) - # Use the last row's (created_at, id) as the next cursor input. - last = page[-1] - after_value, after_id = last.created_at, last.id - if len(page) < 2: - break - - assert seen == expected_ids, ( - f"keyset tiebreaker failed for order={order}: expected {expected_ids}, got {seen}" + first_page, _, _ = list_records_page( + session, + RecordPageSpec(limit=2, order="asc"), ) - - -def test_tiebreaker_no_duplicates_under_mixed_collisions(session: Session): - """Some rows share a timestamp, some don't. The cursor must still walk - every row exactly once regardless of where ties sit relative to a - page boundary.""" - t1 = datetime(2024, 5, 20, 12, 0, 0) - t2 = datetime(2024, 5, 20, 12, 0, 1) - layout = [t1, t1, t1, t2, t2] # three rows at t1, two at t2 - refs = [_make_ref(session, ts, f"mix_{i}.png") for i, ts in enumerate(layout)] - session.commit() - - all_ids = {r.id for r in refs} - seen_set: set[str] = set() - seen_list: list[str] = [] - after_value = None - after_id = None - for _ in range(6): - page, _, _ = list_references_page( - session, + boundary_record = first_page[-1] + second_page, _, _ = list_records_page( + session, + RecordPageSpec( limit=2, - sort="created_at", - order="desc", - after_cursor_value=after_value, - after_cursor_id=after_id, - ) - if not page: - break - for p in page: - assert p.id not in seen_set, f"duplicate row {p.id} appeared in cursor walk" - seen_set.add(p.id) - seen_list.append(p.id) - last = page[-1] - after_value, after_id = last.created_at, last.id - if len(page) < 2: - break + order="asc", + after=RecordCursorBoundary( + value=boundary_record.created_at, + id=boundary_record.id, + ), + ), + ) - assert seen_set == all_ids, f"missing rows: expected {all_ids}, got {seen_set}" + assert [record.id for record in first_page + second_page] == [record.id for record in records] diff --git a/tests-unit/assets_test/queries/test_cache_state.py b/tests-unit/assets_test/queries/test_cache_state.py index 49d0e8d4c37..fdd53c5c492 100644 --- a/tests-unit/assets_test/queries/test_cache_state.py +++ b/tests-unit/assets_test/queries/test_cache_state.py @@ -1,532 +1,32 @@ -"""Tests for cache_state (AssetReference file path) query functions.""" -import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference from app.assets.database.queries import ( - list_references_by_asset_id, - upsert_reference, - get_unreferenced_unhashed_asset_ids, - delete_assets_by_ids, - get_references_for_prefixes, - bulk_update_needs_verify, - delete_references_by_ids, - delete_orphaned_seed_asset, - bulk_insert_references_ignore_conflicts, - get_references_by_paths_and_asset_ids, - mark_references_missing_outside_prefixes, - restore_references_by_paths, + create_content, + create_record, + fetch_record_tags, + mark_content_missing, + unset_content_missing, ) -from app.assets.helpers import select_best_live_path, get_utc_now -def _make_asset(session: Session, hash_val: str | None = None, size: int = 1024) -> Asset: - asset = Asset(hash=hash_val, size_bytes=size) - session.add(asset) - session.flush() - return asset +def test_missing_state_lives_on_content_and_projects_to_records(session: Session) -> None: + content = create_content(session, "/models/model.safetensors") + first = create_record(session, content.id, "model-a") + second = create_record(session, content.id, "model-b") + mark_content_missing(session, content.id) -def _make_reference( - session: Session, - asset: Asset, - file_path: str, - name: str = "test", - mtime_ns: int | None = None, - needs_verify: bool = False, -) -> AssetReference: - now = get_utc_now() - ref = AssetReference( - asset_id=asset.id, - file_path=file_path, - name=name, - mtime_ns=mtime_ns, - needs_verify=needs_verify, - created_at=now, - updated_at=now, - last_access_time=now, - ) - session.add(ref) - session.flush() - return ref + assert content.is_missing is True + assert fetch_record_tags(session, first.id) == ["missing"] + assert fetch_record_tags(session, second.id) == ["missing"] -class TestListReferencesByAssetId: - def test_returns_empty_for_no_references(self, session: Session): - asset = _make_asset(session, "hash1") - refs = list_references_by_asset_id(session, asset_id=asset.id) - assert list(refs) == [] +def test_recovery_clears_the_automatic_missing_marker(session: Session) -> None: + content = create_content(session, "/models/model.safetensors") + record = create_record(session, content.id, "model") + mark_content_missing(session, content.id) - def test_returns_references_for_asset(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "/path/a.bin", name="a") - _make_reference(session, asset, "/path/b.bin", name="b") - session.commit() + unset_content_missing(session, content.id) - refs = list_references_by_asset_id(session, asset_id=asset.id) - paths = [r.file_path for r in refs] - assert set(paths) == {"/path/a.bin", "/path/b.bin"} - - def test_does_not_return_other_assets_references(self, session: Session): - asset1 = _make_asset(session, "hash1") - asset2 = _make_asset(session, "hash2") - _make_reference(session, asset1, "/path/asset1.bin", name="a1") - _make_reference(session, asset2, "/path/asset2.bin", name="a2") - session.commit() - - refs = list_references_by_asset_id(session, asset_id=asset1.id) - paths = [r.file_path for r in refs] - assert paths == ["/path/asset1.bin"] - - -class TestSelectBestLivePath: - def test_returns_empty_for_empty_list(self): - result = select_best_live_path([]) - assert result == "" - - def test_returns_empty_when_no_files_exist(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset, "/nonexistent/path.bin") - session.commit() - - result = select_best_live_path([ref]) - assert result == "" - - def test_prefers_verified_path(self, session: Session, tmp_path): - """needs_verify=False should be preferred.""" - asset = _make_asset(session, "hash1") - - verified_file = tmp_path / "verified.bin" - verified_file.write_bytes(b"data") - - unverified_file = tmp_path / "unverified.bin" - unverified_file.write_bytes(b"data") - - ref_verified = _make_reference( - session, asset, str(verified_file), name="verified", needs_verify=False - ) - ref_unverified = _make_reference( - session, asset, str(unverified_file), name="unverified", needs_verify=True - ) - session.commit() - - refs = [ref_unverified, ref_verified] - result = select_best_live_path(refs) - assert result == str(verified_file) - - def test_falls_back_to_existing_unverified(self, session: Session, tmp_path): - """If all references need verification, return first existing path.""" - asset = _make_asset(session, "hash1") - - existing_file = tmp_path / "exists.bin" - existing_file.write_bytes(b"data") - - ref = _make_reference(session, asset, str(existing_file), needs_verify=True) - session.commit() - - result = select_best_live_path([ref]) - assert result == str(existing_file) - - -class TestSelectBestLivePathWithMocking: - def test_handles_missing_file_path_attr(self): - """Gracefully handle references with None file_path.""" - - class MockRef: - file_path = None - needs_verify = False - - result = select_best_live_path([MockRef()]) - assert result == "" - - -class TestUpsertReference: - @pytest.mark.parametrize( - "initial_mtime,second_mtime,expect_created,expect_updated,final_mtime", - [ - # New reference creation - (None, 12345, True, False, 12345), - # Existing reference, same mtime - no update - (100, 100, False, False, 100), - # Existing reference, different mtime - update - (100, 200, False, True, 200), - ], - ids=["new_reference", "existing_no_change", "existing_update_mtime"], - ) - def test_upsert_scenarios( - self, session: Session, initial_mtime, second_mtime, expect_created, expect_updated, final_mtime - ): - asset = _make_asset(session, "hash1") - file_path = f"/path_{initial_mtime}_{second_mtime}.bin" - name = f"file_{initial_mtime}_{second_mtime}" - - # Create initial reference if needed - if initial_mtime is not None: - upsert_reference(session, asset_id=asset.id, file_path=file_path, name=name, mtime_ns=initial_mtime) - session.commit() - - # The upsert call we're testing - created, updated = upsert_reference( - session, asset_id=asset.id, file_path=file_path, name=name, mtime_ns=second_mtime - ) - session.commit() - - assert created is expect_created - assert updated is expect_updated - ref = session.query(AssetReference).filter_by(file_path=file_path).one() - assert ref.mtime_ns == final_mtime - - def test_upsert_refreshes_loader_path_on_existing_reference(self, session: Session): - """Re-ingesting an existing reference writes the loader_path computed - by that ingest, healing NULL or stale values even when nothing else - about the row changed.""" - asset = _make_asset(session, "hash1") - file_path = "/models/checkpoints/sub/model.safetensors" - - upsert_reference( - session, asset_id=asset.id, file_path=file_path, name="model", - mtime_ns=100, loader_path=None, - ) - session.commit() - - created, updated = upsert_reference( - session, asset_id=asset.id, file_path=file_path, name="model", - mtime_ns=100, loader_path="sub/model.safetensors", - ) - session.commit() - - assert created is False - assert updated is True - ref = session.query(AssetReference).filter_by(file_path=file_path).one() - assert ref.loader_path == "sub/model.safetensors" - - # Identical loader_path is a no-op, not a spurious update. - created, updated = upsert_reference( - session, asset_id=asset.id, file_path=file_path, name="model", - mtime_ns=100, loader_path="sub/model.safetensors", - ) - session.commit() - assert created is False - assert updated is False - - def test_upsert_restores_missing_reference(self, session: Session): - """Upserting a reference that was marked missing should restore it.""" - asset = _make_asset(session, "hash1") - file_path = "/restored/file.bin" - - ref = _make_reference(session, asset, file_path, mtime_ns=100) - ref.is_missing = True - session.commit() - - created, updated = upsert_reference( - session, asset_id=asset.id, file_path=file_path, name="restored", mtime_ns=100 - ) - session.commit() - - assert created is False - assert updated is True - restored_ref = session.query(AssetReference).filter_by(file_path=file_path).one() - assert restored_ref.is_missing is False - - -class TestRestoreReferencesByPaths: - def test_restores_missing_references(self, session: Session): - asset = _make_asset(session, "hash1") - missing_path = "/missing/file.bin" - active_path = "/active/file.bin" - - missing_ref = _make_reference(session, asset, missing_path, name="missing") - missing_ref.is_missing = True - _make_reference(session, asset, active_path, name="active") - session.commit() - - restored = restore_references_by_paths(session, [missing_path]) - session.commit() - - assert restored == 1 - ref = session.query(AssetReference).filter_by(file_path=missing_path).one() - assert ref.is_missing is False - - def test_empty_list_restores_nothing(self, session: Session): - restored = restore_references_by_paths(session, []) - assert restored == 0 - - -class TestMarkReferencesMissingOutsidePrefixes: - def test_marks_references_missing_outside_prefixes(self, session: Session, tmp_path): - asset = _make_asset(session, "hash1") - valid_dir = tmp_path / "valid" - valid_dir.mkdir() - invalid_dir = tmp_path / "invalid" - invalid_dir.mkdir() - - valid_path = str(valid_dir / "file.bin") - invalid_path = str(invalid_dir / "file.bin") - - _make_reference(session, asset, valid_path, name="valid") - _make_reference(session, asset, invalid_path, name="invalid") - session.commit() - - marked = mark_references_missing_outside_prefixes(session, [str(valid_dir)]) - session.commit() - - assert marked == 1 - all_refs = session.query(AssetReference).all() - assert len(all_refs) == 2 - - valid_ref = next(r for r in all_refs if r.file_path == valid_path) - invalid_ref = next(r for r in all_refs if r.file_path == invalid_path) - assert valid_ref.is_missing is False - assert invalid_ref.is_missing is True - - def test_empty_prefixes_marks_nothing(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "/some/path.bin") - session.commit() - - marked = mark_references_missing_outside_prefixes(session, []) - - assert marked == 0 - - -class TestGetUnreferencedUnhashedAssetIds: - def test_returns_unreferenced_unhashed_assets(self, session: Session): - # Unhashed asset (hash=None) with no references (no file_path) - no_refs = _make_asset(session, hash_val=None) - # Unhashed asset with active reference (not unreferenced) - with_active_ref = _make_asset(session, hash_val=None) - _make_reference(session, with_active_ref, "/has/ref.bin", name="has_ref") - # Unhashed asset with only missing reference (should be unreferenced) - with_missing_ref = _make_asset(session, hash_val=None) - missing_ref = _make_reference(session, with_missing_ref, "/missing/ref.bin", name="missing_ref") - missing_ref.is_missing = True - # Regular asset (hash not None) - should not be returned - _make_asset(session, hash_val="blake3:regular") - session.commit() - - unreferenced = get_unreferenced_unhashed_asset_ids(session) - - assert no_refs.id in unreferenced - assert with_missing_ref.id in unreferenced - assert with_active_ref.id not in unreferenced - - -class TestDeleteAssetsByIds: - def test_deletes_assets_and_references(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "/test/path.bin", name="test") - session.commit() - - deleted = delete_assets_by_ids(session, [asset.id]) - session.commit() - - assert deleted == 1 - assert session.query(Asset).count() == 0 - assert session.query(AssetReference).count() == 0 - - def test_empty_list_deletes_nothing(self, session: Session): - _make_asset(session, "hash1") - session.commit() - - deleted = delete_assets_by_ids(session, []) - - assert deleted == 0 - assert session.query(Asset).count() == 1 - - -class TestGetReferencesForPrefixes: - def test_returns_references_matching_prefix(self, session: Session, tmp_path): - asset = _make_asset(session, "hash1") - dir1 = tmp_path / "dir1" - dir1.mkdir() - dir2 = tmp_path / "dir2" - dir2.mkdir() - - path1 = str(dir1 / "file.bin") - path2 = str(dir2 / "file.bin") - - _make_reference(session, asset, path1, name="file1", mtime_ns=100) - _make_reference(session, asset, path2, name="file2", mtime_ns=200) - session.commit() - - rows = get_references_for_prefixes(session, [str(dir1)]) - - assert len(rows) == 1 - assert rows[0].file_path == path1 - - def test_empty_prefixes_returns_empty(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "/some/path.bin") - session.commit() - - rows = get_references_for_prefixes(session, []) - - assert rows == [] - - -class TestBulkSetNeedsVerify: - def test_sets_needs_verify_flag(self, session: Session): - asset = _make_asset(session, "hash1") - ref1 = _make_reference(session, asset, "/path1.bin", needs_verify=False) - ref2 = _make_reference(session, asset, "/path2.bin", needs_verify=False) - session.commit() - - updated = bulk_update_needs_verify(session, [ref1.id, ref2.id], True) - session.commit() - - assert updated == 2 - session.refresh(ref1) - session.refresh(ref2) - assert ref1.needs_verify is True - assert ref2.needs_verify is True - - def test_empty_list_updates_nothing(self, session: Session): - updated = bulk_update_needs_verify(session, [], True) - assert updated == 0 - - -class TestDeleteReferencesByIds: - def test_deletes_references_by_id(self, session: Session): - asset = _make_asset(session, "hash1") - ref1 = _make_reference(session, asset, "/path1.bin") - _make_reference(session, asset, "/path2.bin") - session.commit() - - deleted = delete_references_by_ids(session, [ref1.id]) - session.commit() - - assert deleted == 1 - assert session.query(AssetReference).count() == 1 - - def test_empty_list_deletes_nothing(self, session: Session): - deleted = delete_references_by_ids(session, []) - assert deleted == 0 - - -class TestDeleteOrphanedSeedAsset: - @pytest.mark.parametrize( - "create_asset,expected_deleted,expected_count", - [ - (True, True, 0), # Existing asset gets deleted - (False, False, 0), # Nonexistent returns False - ], - ids=["deletes_existing", "nonexistent_returns_false"], - ) - def test_delete_orphaned_seed_asset( - self, session: Session, create_asset, expected_deleted, expected_count - ): - asset_id = "nonexistent-id" - if create_asset: - asset = _make_asset(session, hash_val=None) - asset_id = asset.id - _make_reference(session, asset, "/test/path.bin", name="test") - session.commit() - - deleted = delete_orphaned_seed_asset(session, asset_id) - if create_asset: - session.commit() - - assert deleted is expected_deleted - assert session.query(Asset).count() == expected_count - - -class TestBulkInsertReferencesIgnoreConflicts: - def test_inserts_multiple_references(self, session: Session): - asset = _make_asset(session, "hash1") - now = get_utc_now() - rows = [ - { - "asset_id": asset.id, - "file_path": "/bulk1.bin", - "name": "bulk1", - "mtime_ns": 100, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - { - "asset_id": asset.id, - "file_path": "/bulk2.bin", - "name": "bulk2", - "mtime_ns": 200, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - ] - bulk_insert_references_ignore_conflicts(session, rows) - session.commit() - - assert session.query(AssetReference).count() == 2 - - def test_ignores_conflicts(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "/existing.bin", mtime_ns=100) - session.commit() - - now = get_utc_now() - rows = [ - { - "asset_id": asset.id, - "file_path": "/existing.bin", - "name": "existing", - "mtime_ns": 999, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - { - "asset_id": asset.id, - "file_path": "/new.bin", - "name": "new", - "mtime_ns": 200, - "created_at": now, - "updated_at": now, - "last_access_time": now, - }, - ] - bulk_insert_references_ignore_conflicts(session, rows) - session.commit() - - assert session.query(AssetReference).count() == 2 - existing = session.query(AssetReference).filter_by(file_path="/existing.bin").one() - assert existing.mtime_ns == 100 # Original value preserved - - def test_empty_list_is_noop(self, session: Session): - bulk_insert_references_ignore_conflicts(session, []) - assert session.query(AssetReference).count() == 0 - - -class TestGetReferencesByPathsAndAssetIds: - def test_returns_matching_paths(self, session: Session): - asset1 = _make_asset(session, "hash1") - asset2 = _make_asset(session, "hash2") - - _make_reference(session, asset1, "/path1.bin") - _make_reference(session, asset2, "/path2.bin") - session.commit() - - path_to_asset = { - "/path1.bin": asset1.id, - "/path2.bin": asset2.id, - } - winners = get_references_by_paths_and_asset_ids(session, path_to_asset) - - assert winners == {"/path1.bin", "/path2.bin"} - - def test_excludes_non_matching_asset_ids(self, session: Session): - asset1 = _make_asset(session, "hash1") - asset2 = _make_asset(session, "hash2") - - _make_reference(session, asset1, "/path1.bin") - session.commit() - - # Path exists but with different asset_id - path_to_asset = {"/path1.bin": asset2.id} - winners = get_references_by_paths_and_asset_ids(session, path_to_asset) - - assert winners == set() - - def test_empty_dict_returns_empty(self, session: Session): - winners = get_references_by_paths_and_asset_ids(session, {}) - assert winners == set() + assert content.is_missing is False + assert fetch_record_tags(session, record.id) == [] diff --git a/tests-unit/assets_test/queries/test_lookup.py b/tests-unit/assets_test/queries/test_lookup.py new file mode 100644 index 00000000000..805100b65b8 --- /dev/null +++ b/tests-unit/assets_test/queries/test_lookup.py @@ -0,0 +1,171 @@ +import os +from datetime import datetime +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, update +from sqlalchemy.orm import Session + +import app.assets.mode as mode_module +from app.assets.database.models import AssetContent +from app.assets.database.queries.records import create_content +from app.assets.services.lookup import ( + claim_qualified_content, + is_temp_path as _is_temp_path, + lookup_for_from_hash, + lookup_for_view, + refresh_qualified_content, +) +from app.database.models import Base + + +@pytest.fixture +def session(tmp_path): + engine = create_engine( + f"sqlite:///{tmp_path}/test.db", connect_args={"check_same_thread": False} + ) + Base.metadata.create_all(engine) + with Session(engine) as sess: + yield sess + + +@pytest.fixture(autouse=True) +def enable_hashing(): + class FakeArgs: + enable_asset_hashing = True + + mode_module.init(FakeArgs()) + yield + mode_module.init(None) + + +def _make_file(tmp_path, name: str, content: bytes = b"bytes") -> str: + p = tmp_path / name + p.write_bytes(content) + return str(p) + + +def test_temp_only_match_from_hash_returns_none(session, tmp_path): + f = _make_file(tmp_path, "f.png") + create_content(session, path=f, hash="abc123") + session.commit() + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + result = lookup_for_from_hash(session, "abc123") + assert result is None + + +def test_temp_only_match_view_returns_none(session, tmp_path): + f = _make_file(tmp_path, "f2.png") + create_content(session, path=f, hash="abc123") + session.commit() + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + result = lookup_for_view(session, "abc123") + assert result is None + + +def test_sibling_prefix_not_temp(tmp_path): + with patch("folder_paths.get_temp_directory", return_value=str(tmp_path / "temp")): + assert not _is_temp_path(str(tmp_path / "temp-other" / "f.png")) + assert _is_temp_path(str(tmp_path / "temp" / "f.png")) + + +def test_off_mode_from_hash_returns_none(session, tmp_path): + class FakeArgs: + enable_asset_hashing = False + + mode_module.init(FakeArgs()) + f = _make_file(tmp_path, "f3.png") + create_content(session, path=f, hash="abc123") + session.commit() + result = lookup_for_from_hash(session, "abc123") + assert result is None + + +def test_upload_content_lookup_not_gated_on_hashing_flag(session, tmp_path): + class FakeArgs: + enable_asset_hashing = False + + mode_module.init(FakeArgs()) + f = _make_file(tmp_path, "f4.png") + content = create_content(session, path=f, hash="abc123", size_bytes=os.path.getsize(f)) + session.commit() + result = lookup_for_view(session, "abc123") + assert result is not None + assert result.id == content.id + + +def test_stale_older_newer_live_returns_newer(session, tmp_path): + f_newer = _make_file(tmp_path, "newer.png") + old_time = datetime(2020, 1, 1) + new_time = datetime(2024, 1, 1) + c_old = create_content(session, path="/nonexistent/old.png", hash="xyz") + c_new = create_content(session, path=f_newer, hash="xyz", size_bytes=os.path.getsize(f_newer)) + session.execute(update(AssetContent).where(AssetContent.id == c_old.id).values(created_at=old_time)) + session.execute(update(AssetContent).where(AssetContent.id == c_new.id).values(created_at=new_time)) + session.commit() + + result = lookup_for_from_hash(session, "xyz") + assert result is not None + assert result.id == c_new.id + + +def test_claim_qualified_content_true_for_a_live_matching_row(session, tmp_path): + f = _make_file(tmp_path, "revalidate_ok.png") + content = create_content(session, path=f, hash="dup") + session.commit() + + assert claim_qualified_content(session, content.id, "dup") is True + + +def test_claim_qualified_content_false_once_retired(session, tmp_path): + f = _make_file(tmp_path, "revalidate_retired.png") + content = create_content(session, path=f, hash="dup") + session.commit() + + session.execute( + update(AssetContent).where(AssetContent.id == content.id).values(is_missing=True) + ) + session.commit() + + assert claim_qualified_content(session, content.id, "dup") is False, ( + "a row retired after the lookup must not be reused" + ) + + +def test_claim_qualified_content_false_once_hash_changed(session, tmp_path): + f = _make_file(tmp_path, "revalidate_rehashed.png") + content = create_content(session, path=f, hash="dup") + session.commit() + + session.execute( + update(AssetContent).where(AssetContent.id == content.id).values(hash="different") + ) + session.commit() + + assert claim_qualified_content(session, content.id, "dup") is False, ( + "a row whose recorded content changed identity must not be reused" + " for the hash that used to describe it" + ) + + +def test_refresh_qualified_content_none_when_file_vanishes(session, tmp_path): + f = _make_file(tmp_path, "revalidate_gone.png") + content = create_content(session, path=f, hash="dup") + session.commit() + assert claim_qualified_content(session, content.id, "dup") is True + + os.unlink(f) + + assert refresh_qualified_content(session, content.id) is None + + +def test_size_mismatch_disqualifies_a_row_that_never_recorded_an_mtime(session, tmp_path): + f = _make_file(tmp_path, "size_mismatch.png", content=b"the real bytes on disk") + content = create_content(session, path=f, hash="dup", size_bytes=999_999) + session.commit() + + assert content.mtime_ns is None, "fixture must exercise the unrecorded-mtime case" + assert lookup_for_view(session, "dup") is None, ( + "a row whose recorded size disagrees with the file must not be served; an " + "unrecorded mtime is no reason to skip the size check too" + ) diff --git a/tests-unit/assets_test/queries/test_metadata.py b/tests-unit/assets_test/queries/test_metadata.py index d7a7477890a..245cbd45090 100644 --- a/tests-unit/assets_test/queries/test_metadata.py +++ b/tests-unit/assets_test/queries/test_metadata.py @@ -1,231 +1,15 @@ -"""Tests for metadata filtering logic in asset_reference queries.""" -import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference, AssetReferenceMeta -from app.assets.database.queries import list_references_page -from app.assets.database.queries.asset_reference import convert_metadata_to_rows -from app.assets.helpers import get_utc_now +from app.assets.database.queries import create_content, create_record -def _make_asset(session: Session, hash_val: str) -> Asset: - asset = Asset(hash=hash_val, size_bytes=1024) - session.add(asset) - session.flush() - return asset +def test_interpretation_metadata_is_record_specific(session: Session) -> None: + content = create_content(session, "/output/shared-bytes.bin", hash="digest") + image = create_record(session, content.id, "image", mime_type="image/png") + text = create_record(session, content.id, "text", mime_type="text/plain") + image.system_metadata = {"width": 8} + text.system_metadata = {"encoding": "utf-8"} - -def _make_reference( - session: Session, - asset: Asset, - name: str, - metadata: dict | None = None, - system_metadata: dict | None = None, -) -> AssetReference: - now = get_utc_now() - ref = AssetReference( - owner_id="", - name=name, - asset_id=asset.id, - user_metadata=metadata, - system_metadata=system_metadata, - created_at=now, - updated_at=now, - last_access_time=now, - ) - session.add(ref) - session.flush() - - # Build merged projection: {**system_metadata, **user_metadata} - merged = {**(system_metadata or {}), **(metadata or {})} - if merged: - for key, val in merged.items(): - for row in convert_metadata_to_rows(key, val): - meta_row = AssetReferenceMeta( - asset_reference_id=ref.id, - key=row["key"], - ordinal=row.get("ordinal", 0), - val_str=row.get("val_str"), - val_num=row.get("val_num"), - val_bool=row.get("val_bool"), - val_json=row.get("val_json"), - ) - session.add(meta_row) - session.flush() - - return ref - - -class TestMetadataFilterByType: - """Table-driven tests for metadata filtering by different value types.""" - - @pytest.mark.parametrize( - "match_meta,nomatch_meta,filter_key,filter_val", - [ - # String matching - ({"category": "models"}, {"category": "images"}, "category", "models"), - # Integer matching - ({"epoch": 5}, {"epoch": 10}, "epoch", 5), - # Float matching - ({"score": 0.95}, {"score": 0.5}, "score", 0.95), - # Boolean True matching - ({"enabled": True}, {"enabled": False}, "enabled", True), - # Boolean False matching - ({"enabled": False}, {"enabled": True}, "enabled", False), - ], - ids=["string", "int", "float", "bool_true", "bool_false"], - ) - def test_filter_matches_correct_value( - self, session: Session, match_meta, nomatch_meta, filter_key, filter_val - ): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "match", match_meta) - _make_reference(session, asset, "nomatch", nomatch_meta) - session.commit() - - refs, _, total = list_references_page( - session, metadata_filter={filter_key: filter_val} - ) - assert total == 1 - assert refs[0].name == "match" - - @pytest.mark.parametrize( - "stored_meta,filter_key,filter_val", - [ - # String no match - ({"category": "models"}, "category", "other"), - # Int no match - ({"epoch": 5}, "epoch", 99), - # Float no match - ({"score": 0.5}, "score", 0.99), - ], - ids=["string_no_match", "int_no_match", "float_no_match"], - ) - def test_filter_returns_empty_when_no_match( - self, session: Session, stored_meta, filter_key, filter_val - ): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "item", stored_meta) - session.commit() - - refs, _, total = list_references_page( - session, metadata_filter={filter_key: filter_val} - ) - assert total == 0 - - -class TestMetadataFilterNull: - """Tests for null/missing key filtering.""" - - @pytest.mark.parametrize( - "match_name,match_meta,nomatch_name,nomatch_meta,filter_key", - [ - # Null matches missing key - ("missing_key", {}, "has_key", {"optional": "value"}, "optional"), - # Null matches explicit null - ("explicit_null", {"nullable": None}, "has_value", {"nullable": "present"}, "nullable"), - ], - ids=["missing_key", "explicit_null"], - ) - def test_null_filter_matches( - self, session: Session, match_name, match_meta, nomatch_name, nomatch_meta, filter_key - ): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, match_name, match_meta) - _make_reference(session, asset, nomatch_name, nomatch_meta) - session.commit() - - refs, _, total = list_references_page(session, metadata_filter={filter_key: None}) - assert total == 1 - assert refs[0].name == match_name - - -class TestMetadataFilterList: - """Tests for list-based (OR) filtering.""" - - def test_filter_by_list_matches_any(self, session: Session): - """List values should match ANY of the values (OR).""" - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "cat_a", {"category": "a"}) - _make_reference(session, asset, "cat_b", {"category": "b"}) - _make_reference(session, asset, "cat_c", {"category": "c"}) - session.commit() - - refs, _, total = list_references_page(session, metadata_filter={"category": ["a", "b"]}) - assert total == 2 - names = {r.name for r in refs} - assert names == {"cat_a", "cat_b"} - - -class TestMetadataFilterMultipleKeys: - """Tests for multiple filter keys (AND semantics).""" - - def test_multiple_keys_must_all_match(self, session: Session): - """Multiple keys should ALL match (AND).""" - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "match", {"type": "model", "version": 2}) - _make_reference(session, asset, "wrong_type", {"type": "config", "version": 2}) - _make_reference(session, asset, "wrong_version", {"type": "model", "version": 1}) - session.commit() - - refs, _, total = list_references_page( - session, metadata_filter={"type": "model", "version": 2} - ) - assert total == 1 - assert refs[0].name == "match" - - -class TestMetadataFilterEmptyDict: - """Tests for empty filter behavior.""" - - def test_empty_filter_returns_all(self, session: Session): - asset = _make_asset(session, "hash1") - _make_reference(session, asset, "a", {"key": "val"}) - _make_reference(session, asset, "b", {}) - session.commit() - - refs, _, total = list_references_page(session, metadata_filter={}) - assert total == 2 - - -class TestSystemMetadataProjection: - """Tests for system_metadata merging into the filter projection.""" - - def test_system_metadata_keys_are_filterable(self, session: Session): - """system_metadata keys should appear in the merged projection.""" - asset = _make_asset(session, "hash1") - _make_reference( - session, asset, "with_sys", - system_metadata={"source": "scanner"}, - ) - _make_reference(session, asset, "without_sys") - session.commit() - - refs, _, total = list_references_page( - session, metadata_filter={"source": "scanner"} - ) - assert total == 1 - assert refs[0].name == "with_sys" - - def test_user_metadata_overrides_system_metadata(self, session: Session): - """user_metadata should win when both have the same key.""" - asset = _make_asset(session, "hash1") - _make_reference( - session, asset, "overridden", - metadata={"origin": "user_upload"}, - system_metadata={"origin": "auto_scan"}, - ) - session.commit() - - # Should match the user value, not the system value - refs, _, total = list_references_page( - session, metadata_filter={"origin": "user_upload"} - ) - assert total == 1 - assert refs[0].name == "overridden" - - # Should NOT match the system value (it was overridden) - refs, _, total = list_references_page( - session, metadata_filter={"origin": "auto_scan"} - ) - assert total == 0 + assert image.content_id == text.content_id + assert image.system_metadata == {"width": 8} + assert text.system_metadata == {"encoding": "utf-8"} diff --git a/tests-unit/assets_test/queries/test_records.py b/tests-unit/assets_test/queries/test_records.py new file mode 100644 index 00000000000..a2066b36877 --- /dev/null +++ b/tests-unit/assets_test/queries/test_records.py @@ -0,0 +1,137 @@ +import os + +import pytest +from sqlalchemy import create_engine, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.assets.database.models import Asset, AssetContent, AssetTag +from app.assets.database.queries.records import ( + RecordPageSpec, + create_content, + create_record, + delete_record, + get_record_by_id, + list_records_page, + mark_content_missing, +) +from app.database.models import Base + + +@pytest.fixture +def session(): + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + with Session(engine) as sess: + yield sess + + +def test_listing_includes_missing_by_default_with_missing_tag(session): + content = create_content(session, path="/tmp/f1") + record = create_record(session, content_id=content.id, name="test") + mark_content_missing(session, content.id) + session.commit() + + results, tag_map, total = list_records_page(session, RecordPageSpec()) + + assert any(r.id == record.id for r in results) + assert total == 1 + assert "missing" in tag_map.get(record.id, []) + + +def test_listing_excludes_missing_with_filter(session): + content = create_content(session, path="/tmp/f2") + record = create_record(session, content_id=content.id, name="test2") + mark_content_missing(session, content.id) + session.commit() + + results, _, total = list_records_page( + session, + RecordPageSpec(none_tags=("missing",)), + ) + + assert not any(r.id == record.id for r in results), "Missing record should be excluded" + assert total == 0 + + +def test_delete_record_never_deletes_referenced_preview(session): + preview_content = create_content(session, path="/tmp/preview") + preview_record = create_record(session, content_id=preview_content.id, name="preview") + content1 = create_content(session, path="/tmp/f3") + content2 = create_content(session, path="/tmp/f4") + r1 = create_record(session, content_id=content1.id, name="r1") + r2 = create_record(session, content_id=content2.id, name="r2") + + session.execute(update(Asset).where(Asset.id == r1.id).values(preview_id=preview_record.id)) + session.execute(update(Asset).where(Asset.id == r2.id).values(preview_id=preview_record.id)) + session.commit() + + delete_record(session, r1.id) + session.commit() + assert get_record_by_id(session, preview_record.id) is not None, "Preview must survive" + + delete_record(session, r2.id) + session.commit() + assert get_record_by_id(session, preview_record.id) is not None, ( + "Preview must survive even with no referrers" + ) + + content_row = session.execute( + select(AssetContent).where(AssetContent.id == preview_content.id) + ).scalar_one_or_none() + assert content_row is not None, "Preview content row should survive (D-3 floor)" + + +def test_concurrent_create_content_same_path(tmp_path): + db_path = str(tmp_path / "concurrent.db") + engine = create_engine(f"sqlite:///{db_path}", connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + shared_path = os.path.abspath("/tmp/shared") + + with Session(engine) as s1, Session(engine) as s2: + create_content(s1, path=shared_path) + s1.commit() + create_content(s2, path=shared_path) + s2.commit() + + with Session(engine) as s: + live_rows = list( + s.execute( + select(AssetContent).where( + AssetContent.path == shared_path, + AssetContent.is_missing.is_(False), + ) + ).scalars() + ) + assert len(live_rows) == 1, f"Expected exactly one live row, got {len(live_rows)}" + + +def test_create_content_check_violation_surfaces_the_constraint_error(session): + with pytest.raises(IntegrityError): + create_content(session, path="/tmp/negative-size", size_bytes=-1) + + +def test_create_content_check_violation_on_mtime_surfaces(session): + with pytest.raises(IntegrityError): + create_content(session, path="/tmp/negative-mtime", size_bytes=0, mtime_ns=-1) + + +def test_create_content_uniqueness_race_returns_existing_live_row(session): + first = create_content(session, path="/tmp/race") + second = create_content(session, path="/tmp/race") + + assert second.id == first.id + + +def test_create_record_links_a_repeated_tag_name_once(session): + content = create_content(session, path="/tmp/repeated-tag") + + record = create_record(session, content_id=content.id, name="repeat", tags=["x", "x"]) + session.commit() + + linked = session.scalars( + select(AssetTag.tag_name).where(AssetTag.asset_id == record.id) + ).all() + assert list(linked) == ["x"], ( + "a tag name repeated within one call must link once, not raise on the composite PK" + ) diff --git a/tests-unit/assets_test/queries/test_tags.py b/tests-unit/assets_test/queries/test_tags.py index bc041953aed..b0d96c9a5ad 100644 --- a/tests-unit/assets_test/queries/test_tags.py +++ b/tests-unit/assets_test/queries/test_tags.py @@ -1,369 +1,23 @@ -import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference, AssetReferenceTag, AssetReferenceMeta, Tag from app.assets.database.queries import ( - ensure_tags_exist, - get_reference_tags, - set_reference_tags, - add_tags_to_reference, - remove_tags_from_reference, - add_missing_tag_for_asset_id, - remove_missing_tag_for_asset_id, - list_tags_with_usage, - bulk_insert_tags_and_meta, + create_content, + create_record, + fetch_record_tags, + list_records_page, ) -from app.assets.helpers import get_utc_now +from app.assets.database.queries.records import RecordPageSpec -def _make_asset(session: Session, hash_val: str | None = None) -> Asset: - asset = Asset(hash=hash_val, size_bytes=1024) - session.add(asset) - session.flush() - return asset +def test_record_tags_filter_records_without_affecting_content(session: Session) -> None: + content = create_content(session, "/models/shared.safetensors") + tagged = create_record(session, content.id, "tagged", tags=["model"]) + untagged = create_record(session, content.id, "untagged") - -def _make_reference(session: Session, asset: Asset, name: str = "test", owner_id: str = "") -> AssetReference: - now = get_utc_now() - ref = AssetReference( - owner_id=owner_id, - name=name, - asset_id=asset.id, - created_at=now, - updated_at=now, - last_access_time=now, + records, _, _ = list_records_page( + session, + RecordPageSpec(all_tags=("model",)), ) - session.add(ref) - session.flush() - return ref - - -class TestEnsureTagsExist: - def test_creates_new_tags(self, session: Session): - ensure_tags_exist(session, ["alpha", "beta"]) - session.commit() - - tags = session.query(Tag).all() - assert {t.name for t in tags} == {"alpha", "beta"} - - def test_is_idempotent(self, session: Session): - ensure_tags_exist(session, ["alpha"]) - ensure_tags_exist(session, ["alpha"]) - session.commit() - - assert session.query(Tag).count() == 1 - - def test_normalizes_tags(self, session: Session): - ensure_tags_exist(session, [" ALPHA ", "Beta", "alpha"]) - session.commit() - - tags = session.query(Tag).all() - assert {t.name for t in tags} == {"ALPHA", "Beta", "alpha"} - - def test_empty_list_is_noop(self, session: Session): - ensure_tags_exist(session, []) - session.commit() - assert session.query(Tag).count() == 0 - - -class TestGetReferenceTags: - def test_returns_empty_for_no_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - tags = get_reference_tags(session, reference_id=ref.id) - assert tags == [] - - def test_returns_tags_for_reference(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - ensure_tags_exist(session, ["tag1", "tag2"]) - session.add_all([ - AssetReferenceTag(asset_reference_id=ref.id, tag_name="tag1", origin="manual", added_at=get_utc_now()), - AssetReferenceTag(asset_reference_id=ref.id, tag_name="tag2", origin="manual", added_at=get_utc_now()), - ]) - session.flush() - - tags = get_reference_tags(session, reference_id=ref.id) - assert set(tags) == {"tag1", "tag2"} - - -class TestSetReferenceTags: - def test_adds_new_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - result = set_reference_tags(session, reference_id=ref.id, tags=["a", "b"]) - session.commit() - - assert set(result.added) == {"a", "b"} - assert result.removed == [] - assert set(result.total) == {"a", "b"} - - def test_removes_old_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - set_reference_tags(session, reference_id=ref.id, tags=["a", "b", "c"]) - result = set_reference_tags(session, reference_id=ref.id, tags=["a"]) - session.commit() - - assert result.added == [] - assert set(result.removed) == {"b", "c"} - assert result.total == ["a"] - - def test_replaces_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - set_reference_tags(session, reference_id=ref.id, tags=["a", "b"]) - result = set_reference_tags(session, reference_id=ref.id, tags=["b", "c"]) - session.commit() - - assert result.added == ["c"] - assert result.removed == ["a"] - assert set(result.total) == {"b", "c"} - - -class TestAddTagsToReference: - def test_adds_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - result = add_tags_to_reference(session, reference_id=ref.id, tags=["x", "y"]) - session.commit() - - assert set(result.added) == {"x", "y"} - assert result.already_present == [] - - def test_reports_already_present(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - add_tags_to_reference(session, reference_id=ref.id, tags=["x"]) - result = add_tags_to_reference(session, reference_id=ref.id, tags=["x", "y"]) - session.commit() - - assert result.added == ["y"] - assert result.already_present == ["x"] - - def test_raises_for_missing_reference(self, session: Session): - with pytest.raises(ValueError, match="not found"): - add_tags_to_reference(session, reference_id="nonexistent", tags=["x"]) - - -class TestRemoveTagsFromReference: - def test_removes_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - add_tags_to_reference(session, reference_id=ref.id, tags=["a", "b", "c"]) - result = remove_tags_from_reference(session, reference_id=ref.id, tags=["a", "b"]) - session.commit() - - assert set(result.removed) == {"a", "b"} - assert result.not_present == [] - assert result.total_tags == ["c"] - - def test_reports_not_present(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - - add_tags_to_reference(session, reference_id=ref.id, tags=["a"]) - result = remove_tags_from_reference(session, reference_id=ref.id, tags=["a", "x"]) - session.commit() - - assert result.removed == ["a"] - assert result.not_present == ["x"] - - def test_raises_for_missing_reference(self, session: Session): - with pytest.raises(ValueError, match="not found"): - remove_tags_from_reference(session, reference_id="nonexistent", tags=["x"]) - - -class TestMissingTagFunctions: - def test_add_missing_tag_for_asset_id(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["missing"]) - - add_missing_tag_for_asset_id(session, asset_id=asset.id) - session.commit() - - tags = get_reference_tags(session, reference_id=ref.id) - assert "missing" in tags - - def test_add_missing_tag_is_idempotent(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["missing"]) - - add_missing_tag_for_asset_id(session, asset_id=asset.id) - add_missing_tag_for_asset_id(session, asset_id=asset.id) - session.commit() - - links = session.query(AssetReferenceTag).filter_by(asset_reference_id=ref.id, tag_name="missing").all() - assert len(links) == 1 - - def test_remove_missing_tag_for_asset_id(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["missing"]) - add_missing_tag_for_asset_id(session, asset_id=asset.id) - - remove_missing_tag_for_asset_id(session, asset_id=asset.id) - session.commit() - - tags = get_reference_tags(session, reference_id=ref.id) - assert "missing" not in tags - - -class TestListTagsWithUsage: - def test_returns_tags_with_counts(self, session: Session): - ensure_tags_exist(session, ["used", "unused"]) - - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - add_tags_to_reference(session, reference_id=ref.id, tags=["used"]) - session.commit() - - rows, total = list_tags_with_usage(session) - - tag_dict = {name: count for name, count in rows} - assert tag_dict["used"] == 1 - assert tag_dict["unused"] == 0 - assert total == 2 - - def test_exclude_zero_counts(self, session: Session): - ensure_tags_exist(session, ["used", "unused"]) - - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - add_tags_to_reference(session, reference_id=ref.id, tags=["used"]) - session.commit() - - rows, total = list_tags_with_usage(session, include_zero=False) - - tag_names = {name for name, _ in rows} - assert "used" in tag_names - assert "unused" not in tag_names - - def test_prefix_filter(self, session: Session): - ensure_tags_exist(session, ["alpha", "beta", "alphabet"]) - session.commit() - - rows, total = list_tags_with_usage(session, prefix="alph") - - tag_names = {name for name, _ in rows} - assert tag_names == {"alpha", "alphabet"} - - def test_prefix_filter_is_case_sensitive(self, session: Session): - ensure_tags_exist(session, ["model_type:LLM", "model_type:llm"]) - session.commit() - - rows, total = list_tags_with_usage(session, prefix="model_type:L") - - tag_names = {name for name, _ in rows} - assert tag_names == {"model_type:LLM"} - assert total == 1 - - def test_order_by_name(self, session: Session): - ensure_tags_exist(session, ["zebra", "alpha", "middle"]) - session.commit() - - rows, _ = list_tags_with_usage(session, order="name_asc") - - names = [name for name, _ in rows] - assert names == ["alpha", "middle", "zebra"] - - def test_owner_visibility(self, session: Session): - ensure_tags_exist(session, ["shared-tag", "owner-tag"]) - - asset = _make_asset(session, "hash1") - shared_ref = _make_reference(session, asset, name="shared", owner_id="") - owner_ref = _make_reference(session, asset, name="owned", owner_id="user1") - - add_tags_to_reference(session, reference_id=shared_ref.id, tags=["shared-tag"]) - add_tags_to_reference(session, reference_id=owner_ref.id, tags=["owner-tag"]) - session.commit() - - # Empty owner sees only shared - rows, _ = list_tags_with_usage(session, owner_id="", include_zero=False) - tag_dict = {name: count for name, count in rows} - assert tag_dict.get("shared-tag", 0) == 1 - assert tag_dict.get("owner-tag", 0) == 0 - - # User1 sees both - rows, _ = list_tags_with_usage(session, owner_id="user1", include_zero=False) - tag_dict = {name: count for name, count in rows} - assert tag_dict.get("shared-tag", 0) == 1 - assert tag_dict.get("owner-tag", 0) == 1 - - -class TestBulkInsertTagsAndMeta: - def test_inserts_tags(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["bulk-tag1", "bulk-tag2"]) - session.commit() - - now = get_utc_now() - tag_rows = [ - {"asset_reference_id": ref.id, "tag_name": "bulk-tag1", "origin": "manual", "added_at": now}, - {"asset_reference_id": ref.id, "tag_name": "bulk-tag2", "origin": "manual", "added_at": now}, - ] - bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=[]) - session.commit() - - tags = get_reference_tags(session, reference_id=ref.id) - assert set(tags) == {"bulk-tag1", "bulk-tag2"} - - def test_inserts_meta(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - session.commit() - - meta_rows = [ - { - "asset_reference_id": ref.id, - "key": "meta-key", - "ordinal": 0, - "val_str": "meta-value", - "val_num": None, - "val_bool": None, - "val_json": None, - }, - ] - bulk_insert_tags_and_meta(session, tag_rows=[], meta_rows=meta_rows) - session.commit() - - meta = session.query(AssetReferenceMeta).filter_by(asset_reference_id=ref.id).all() - assert len(meta) == 1 - assert meta[0].key == "meta-key" - assert meta[0].val_str == "meta-value" - - def test_ignores_conflicts(self, session: Session): - asset = _make_asset(session, "hash1") - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["existing-tag"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["existing-tag"]) - session.commit() - - now = get_utc_now() - tag_rows = [ - {"asset_reference_id": ref.id, "tag_name": "existing-tag", "origin": "duplicate", "added_at": now}, - ] - bulk_insert_tags_and_meta(session, tag_rows=tag_rows, meta_rows=[]) - session.commit() - - # Should still have only one tag link - links = session.query(AssetReferenceTag).filter_by(asset_reference_id=ref.id, tag_name="existing-tag").all() - assert len(links) == 1 - # Origin should be original, not overwritten - assert links[0].origin == "manual" - def test_empty_lists_is_noop(self, session: Session): - bulk_insert_tags_and_meta(session, tag_rows=[], meta_rows=[]) - assert session.query(AssetReferenceTag).count() == 0 - assert session.query(AssetReferenceMeta).count() == 0 + assert [record.id for record in records] == [tagged.id] + assert fetch_record_tags(session, untagged.id) == [] diff --git a/tests-unit/assets_test/queries/test_tags_query.py b/tests-unit/assets_test/queries/test_tags_query.py new file mode 100644 index 00000000000..acc21f409d0 --- /dev/null +++ b/tests-unit/assets_test/queries/test_tags_query.py @@ -0,0 +1,198 @@ +from __future__ import annotations + +from collections import Counter +from collections.abc import Sequence + +from sqlalchemy.orm import Session + +from app.assets.database.models import Asset, Tag +from app.assets.database.queries.records import ( + RecordPageSpec, + create_content, + create_record, + list_records_page, + mark_content_missing, +) +from app.assets.database.queries.tags import ( + list_tag_counts_for_filtered_assets, + list_tags_with_usage, +) + + +def _seed_asset( + session: Session, path: str, name: str, tags: Sequence[str] +) -> Asset: + content = create_content(session, path=path) + return create_record(session, content_id=content.id, name=name, tags=list(tags)) + + +def test_usage_counts_match_seeded_asset_tag_rows(session: Session) -> None: + _seed_asset(session, "/models/a", "a", ["models", "checkpoint"]) + _seed_asset(session, "/models/b", "b", ["models"]) + _seed_asset(session, "/models/c", "c", ["lora"]) + session.commit() + + rows, total = list_tags_with_usage(session) + + assert dict(rows) == {"models": 2, "checkpoint": 1, "lora": 1} + assert total == 3 + + +def test_prefix_filters_tags_by_leading_substring(session: Session) -> None: + _seed_asset(session, "/m/1", "1", ["model", "mask", "zebra"]) + session.commit() + + rows, total = list_tags_with_usage(session, prefix="ma") + + assert [name for name, _ in rows] == ["mask"] + assert total == 1 + + +def test_limit_and_offset_paginate_over_real_total(session: Session) -> None: + for index in range(5): + _seed_asset(session, f"/p/{index}", str(index), [f"tag{index}"]) + session.commit() + + rows, total = list_tags_with_usage(session, limit=2, offset=1, order="name_asc") + + assert [name for name, _ in rows] == ["tag1", "tag2"] + assert total == 5 + + +def test_include_zero_false_drops_unused_tags_and_shrinks_total(session: Session) -> None: + _seed_asset(session, "/z/1", "1", ["used"]) + session.add(Tag(name="unused")) + session.commit() + + with_zero, total_with = list_tags_with_usage(session, include_zero=True) + without_zero, total_without = list_tags_with_usage(session, include_zero=False) + + assert ("unused", 0) in with_zero + assert total_with == 2 + assert [name for name, _ in without_zero] == ["used"] + assert total_without == 1 + + +def test_order_is_count_desc_then_name_asc_or_name_asc(session: Session) -> None: + _seed_asset(session, "/o/1", "1", ["gamma"]) + _seed_asset(session, "/o/2", "2", ["gamma", "beta"]) + _seed_asset(session, "/o/3", "3", ["gamma", "beta", "alpha"]) + session.commit() + + by_count, _ = list_tags_with_usage(session, order="count_desc") + by_name, _ = list_tags_with_usage(session, order="name_asc") + + assert [name for name, _ in by_count] == ["gamma", "beta", "alpha"] + assert [name for name, _ in by_name] == ["alpha", "beta", "gamma"] + + +def test_missing_content_counts_all_its_tags_including_missing( + session: Session, +) -> None: + _seed_asset(session, "/live", "live", ["foo"]) + gone = create_content(session, path="/gone") + create_record(session, content_id=gone.id, name="gone", tags=["foo"]) + mark_content_missing(session, gone.id) + session.commit() + + counts = dict(list_tags_with_usage(session)[0]) + + assert counts["foo"] == 2 + assert counts["missing"] == 1 + + +def test_histogram_counts_every_tag_on_the_filtered_assets(session: Session) -> None: + _seed_asset(session, "/h/a", "a", ["models", "checkpoint"]) + _seed_asset(session, "/h/b", "b", ["models", "lora"]) + _seed_asset(session, "/h/c", "c", ["other"]) + session.commit() + + histogram = list_tag_counts_for_filtered_assets(session, include_tags=["models"]) + + assert histogram == {"models": 2, "checkpoint": 1, "lora": 1} + + +def test_histogram_includes_missing_content_assets(session: Session) -> None: + _seed_asset(session, "/hm/live", "live", ["models"]) + gone = create_content(session, path="/hm/gone") + create_record(session, content_id=gone.id, name="gone", tags=["models"]) + mark_content_missing(session, gone.id) + session.commit() + + histogram = list_tag_counts_for_filtered_assets(session, include_tags=["models"]) + + assert histogram == {"models": 2, "missing": 1} + + +def test_refine_and_list_share_the_same_filtered_asset_set(session: Session) -> None: + kept = _seed_asset(session, "/s/a", "checkpoint-a", ["models", "checkpoint"]) + _seed_asset(session, "/s/b", "checkpoint-b", ["models", "archived"]) + _seed_asset(session, "/s/c", "lora-c", ["models"]) + gone = create_content(session, path="/s/d") + missing_match = create_record( + session, content_id=gone.id, name="check-d", tags=["models", "checkpoint"] + ) + mark_content_missing(session, gone.id) + session.commit() + + spec = RecordPageSpec( + all_tags=("models",), + none_tags=("archived",), + name_contains="check", + limit=100, + ) + records, tag_map, _ = list_records_page(session, spec) + expected = Counter( + tag for record in records for tag in tag_map.get(record.id, []) + ) + histogram = list_tag_counts_for_filtered_assets( + session, + include_tags=["models"], + exclude_tags=["archived"], + name_contains="check", + limit=100, + ) + + assert {record.id for record in records} == {kept.id, missing_match.id} + assert histogram == dict(expected) + assert histogram == {"models": 2, "checkpoint": 2, "missing": 1} + + +def test_histogram_any_tags_matches_the_list_union(session: Session) -> None: + red = _seed_asset(session, "/an/a", "a", ["red"]) + blue = _seed_asset(session, "/an/b", "b", ["blue"]) + _seed_asset(session, "/an/c", "c", ["green"]) + session.commit() + + records, _, _ = list_records_page( + session, RecordPageSpec(any_tags=("red", "blue"), limit=100) + ) + histogram = list_tag_counts_for_filtered_assets(session, any_tags=["red", "blue"]) + + assert {record.id for record in records} == {red.id, blue.id} + assert histogram == {"red": 1, "blue": 1} + + +def test_list_refine_and_tags_agree_on_counts_including_missing( + session: Session, +) -> None: + _seed_asset(session, "/agree/live", "live", ["models", "checkpoint"]) + gone = create_content(session, path="/agree/gone") + create_record(session, content_id=gone.id, name="gone", tags=["models", "lora"]) + mark_content_missing(session, gone.id) + session.commit() + + records, tag_map, total = list_records_page( + session, RecordPageSpec(all_tags=("models",), limit=100) + ) + list_tally = dict( + Counter(tag for record in records for tag in tag_map.get(record.id, [])) + ) + refine = list_tag_counts_for_filtered_assets(session, include_tags=["models"]) + global_usage = dict(list_tags_with_usage(session)[0]) + + expected = {"models": 2, "checkpoint": 1, "lora": 1, "missing": 1} + assert total == 2 + assert list_tally == expected + assert refine == expected + assert global_usage == expected diff --git a/tests-unit/assets_test/services/conftest.py b/tests-unit/assets_test/services/conftest.py index bc0723e6124..9c5d1dfbb0f 100644 --- a/tests-unit/assets_test/services/conftest.py +++ b/tests-unit/assets_test/services/conftest.py @@ -1,11 +1,13 @@ import tempfile +from contextlib import contextmanager from pathlib import Path from unittest.mock import patch import pytest from sqlalchemy import create_engine, event -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, Session as SASession +from app.assets import mode from app.assets.database.models import Base @@ -15,6 +17,16 @@ def autoclean_unit_test_assets(): yield +@pytest.fixture(autouse=True) +def initialised_hash_mode(): + class _HashingOff: + enable_asset_hashing = False + + mode.init(_HashingOff()) + yield + mode.init(None) + + @pytest.fixture def db_engine(): """In-memory SQLite engine for fast unit tests.""" @@ -48,9 +60,6 @@ def session(db_engine): @pytest.fixture def mock_create_session(db_engine): """Patch create_session to use our in-memory database.""" - from contextlib import contextmanager - from sqlalchemy.orm import Session as SASession - @contextmanager def _create_session(): with SASession(db_engine) as sess: diff --git a/tests-unit/assets_test/services/path_prefix_cases.py b/tests-unit/assets_test/services/path_prefix_cases.py new file mode 100644 index 00000000000..f5586d9e8da --- /dev/null +++ b/tests-unit/assets_test/services/path_prefix_cases.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os + + +def stored_path(path: str) -> str: + return os.path.abspath(path) + + +def prefix_case_paths(root: str) -> list[tuple[str, bool]]: + parent = os.path.dirname(root) + name = os.path.basename(root) + upper = name.upper() + assert upper != name, f"root basename {name!r} has no distinct uppercase form" + + return [ + (root, True), + (os.path.join(root, "child.png"), True), + (os.path.join(root, "sub", "deep.png"), True), + (os.path.join(root, "meta%_*?[].png"), True), + (os.path.join(parent, upper), False), + (os.path.join(parent, upper, "case.png"), False), + (root + "-other" + os.sep + "lexical.png", False), + (root + "extra", False), + (os.path.join(parent, "unrelated.png"), False), + (os.path.join(root, os.pardir, "escaped.png"), False), + (os.path.join(root, "sub", os.pardir, "in.png"), True), + (os.path.join(name, "relative.png"), False), + (root + os.sep + os.sep + "doubled.png", True), + (os.path.join(root, "trailing.png") + os.sep, True), + ] + + +def expected_prefix_case_paths(root: str) -> set[str]: + return {os.path.abspath(path) for path, expected in prefix_case_paths(root) if expected} diff --git a/tests-unit/assets_test/services/test_admission_gate.py b/tests-unit/assets_test/services/test_admission_gate.py new file mode 100644 index 00000000000..a34ae9c5990 --- /dev/null +++ b/tests-unit/assets_test/services/test_admission_gate.py @@ -0,0 +1,170 @@ +import os +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import patch + +import pytest +from sqlalchemy import select + +from app.assets import scanner_admission +from app.assets.database.models import AssetContent +from app.assets.scanner import ( + _WATCH_LIST, + _WatchEntry, + _should_skip_extension, + _two_stat_admit, + tick_watch_list, +) + + +@pytest.fixture(autouse=True) +def clear_watch_list() -> Iterator[None]: + _WATCH_LIST.clear() + yield + _WATCH_LIST.clear() + + +def test_part_suffix_never_admitted(): + assert _should_skip_extension("model.safetensors.part") is True + + +def test_size_drift_watch_listed(temp_dir: Path, monkeypatch): + path = temp_dir / "download.bin" + path.write_bytes(b"first") + first_stat = path.stat() + + def changed_stat(_: float) -> None: + path.write_bytes(b"changed") + + monkeypatch.setattr("app.assets.scanner_admission.time.sleep", changed_stat) + admitted, watched = _two_stat_admit([(str(path), first_stat)]) + + assert admitted == [] + assert watched == [str(path)] + + +def test_watch_list_is_bounded_when_distinct_paths_keep_changing(temp_dir: Path, monkeypatch): + candidates: list[tuple[str, os.stat_result]] = [] + for index in range(1000): + path = temp_dir / f"changing-{index}.bin" + path.write_bytes(b"before") + candidates.append((str(path), path.stat())) + path.write_bytes(b"after-change") + monkeypatch.setattr("app.assets.scanner_admission.time.sleep", lambda _: None) + + _two_stat_admit(candidates) + + assert scanner_admission._WATCH_LIST_MAX_SIZE == 256 + assert len(_WATCH_LIST) <= scanner_admission._WATCH_LIST_MAX_SIZE + + +def test_refreshing_watched_path_preserves_ticks_and_replaces_stat(temp_dir: Path, monkeypatch): + path = temp_dir / "changing.bin" + path.write_bytes(b"first") + first_stat = path.stat() + path.write_bytes(b"second-version") + monkeypatch.setattr("app.assets.scanner_admission.time.sleep", lambda _: None) + _two_stat_admit([(str(path), first_stat)]) + _WATCH_LIST[0].ticks = 7 + refresh_first_stat = path.stat() + path.write_bytes(b"third-version-is-longer") + refreshed_stat = path.stat() + + _two_stat_admit([(str(path), refresh_first_stat)]) + + assert len(_WATCH_LIST) == 1 + assert _WATCH_LIST[0].last_stat == refreshed_stat + assert _WATCH_LIST[0].ticks == 7 + + +def test_never_stabilizes_dropped_after_cap(session, temp_dir: Path): + path = temp_dir / "moving.bin" + path.write_bytes(b"0") + initial_stat = path.stat() + previous_target_ns = initial_stat.st_mtime_ns + _WATCH_LIST[:] = [_WatchEntry(str(path), initial_stat)] + for index in range(scanner_admission._WATCH_SCAN_RETRIES): + path.write_bytes(str(index + 1).encode()) + target_ns = max(path.stat().st_mtime_ns, previous_target_ns) + 1_000_000 + os.utime(path, ns=(target_ns, target_ns)) + previous_target_ns = target_ns + tick_watch_list(session) + + assert _WATCH_LIST == [] + assert session.scalars(select(AssetContent)).all() == [] + + +def test_stable_scan_admission_removes_watch_entry_before_next_tick(session, temp_dir: Path, monkeypatch): + path = temp_dir / "stable.bin" + path.write_bytes(b"complete") + current_stat = path.stat() + _WATCH_LIST[:] = [_WatchEntry(str(path), current_stat, ticks=4)] + monkeypatch.setattr("app.assets.scanner_admission.time.sleep", lambda _: None) + + admitted, watched = _two_stat_admit([(str(path), current_stat)]) + entries_after_admission = len(_WATCH_LIST) + with ( + patch("app.assets.scanner_admission.compute_loader_path", return_value="stable.bin"), + patch( + "app.assets.scanner_admission.get_name_and_tags_from_asset_path", + return_value=("stable.bin", []), + ), + patch("app.assets.scanner.seed_asset_specs") as seed_asset_specs, + ): + tick_watch_list(session) + + assert admitted == [str(path)] + assert watched == [] + assert entries_after_admission == 0 + seed_asset_specs.assert_not_called() + + +def test_evicted_path_is_admitted_by_later_stable_scan(temp_dir: Path, monkeypatch): + monkeypatch.setattr(scanner_admission, "_WATCH_LIST_MAX_SIZE", 2, raising=False) + monkeypatch.setattr("app.assets.scanner_admission.time.sleep", lambda _: None) + paths: list[Path] = [] + candidates: list[tuple[str, os.stat_result]] = [] + for index in range(3): + path = temp_dir / f"overflow-{index}.bin" + path.write_bytes(b"before") + candidates.append((str(path), path.stat())) + path.write_bytes(b"after-change") + paths.append(path) + _two_stat_admit(candidates) + entries_after_overflow = [entry.path for entry in _WATCH_LIST] + stable_stat = paths[0].stat() + + admitted, watched = _two_stat_admit([(str(paths[0]), stable_stat)]) + + assert entries_after_overflow == [str(paths[1]), str(paths[2])] + assert admitted == [str(paths[0])] + assert watched == [] + + +def test_empty_candidate_batch_returns_without_paying_stability_gap(monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr( + "app.assets.scanner_admission.time.sleep", lambda seconds: sleeps.append(seconds) + ) + + admitted, watched = _two_stat_admit([]) + + assert admitted == [] + assert watched == [] + assert sleeps == [] + + +def test_nonempty_candidate_batch_still_pays_stability_gap(temp_dir: Path, monkeypatch): + path = temp_dir / "stable.bin" + path.write_bytes(b"complete") + first_stat = path.stat() + sleeps: list[float] = [] + monkeypatch.setattr( + "app.assets.scanner_admission.time.sleep", lambda seconds: sleeps.append(seconds) + ) + + admitted, watched = _two_stat_admit([(str(path), first_stat)]) + + assert sleeps == [0.1] + assert admitted == [str(path)] + assert watched == [] diff --git a/tests-unit/assets_test/services/test_admission_integration.py b/tests-unit/assets_test/services/test_admission_integration.py new file mode 100644 index 00000000000..062aab76fb7 --- /dev/null +++ b/tests-unit/assets_test/services/test_admission_integration.py @@ -0,0 +1,20 @@ +from unittest.mock import patch + +from sqlalchemy import select + +from app.assets.database.models import AssetContent +from app.assets.scanner import build_asset_specs, seed_asset_specs + + +def test_drifting_file_never_reaches_seed(session, temp_dir): + path = temp_dir / "drifting.bin" + path.write_bytes(b"still downloading") + + with ( + patch("folder_paths.get_input_directory", return_value=str(temp_dir)), + patch("app.assets.scanner._two_stat_admit", return_value=([], [str(path)])), + ): + specs, _, _ = build_asset_specs([str(path)], set(), enable_metadata_extraction=False) + seed_asset_specs(session, specs) + + assert list(session.scalars(select(AssetContent))) == [] diff --git a/tests-unit/assets_test/services/test_api_routes_b.py b/tests-unit/assets_test/services/test_api_routes_b.py new file mode 100644 index 00000000000..c6e186eb368 --- /dev/null +++ b/tests-unit/assets_test/services/test_api_routes_b.py @@ -0,0 +1,155 @@ +import json +from collections.abc import Callable +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from sqlalchemy.orm import Session + +from app.assets import mode +from app.assets.api import routes +from app.assets.database.queries.records import ( + create_content, + create_record, + mark_content_missing, +) + + +def _session_factory(engine) -> Callable[[], Session]: + return lambda: Session(engine) + + +@pytest.mark.asyncio +async def test_unfiltered_listing_includes_missing_entity( + db_engine, session, temp_dir, monkeypatch +): + content = create_content(session, path=str(temp_dir / "missing.png")) + record = create_record( + session, + content_id=content.id, + name="missing.png", + mime_type="image/png", + tags=["input"], + ) + mark_content_missing(session, content.id) + session.commit() + + monkeypatch.setattr(routes, "create_session", _session_factory(db_engine)) + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + + response = await routes.list_assets_route( + make_mocked_request("GET", "/api/assets") + ) + + assert isinstance(response, web.Response) + response_body = response.body + assert isinstance(response_body, bytes | bytearray) + body = json.loads(response_body) + assert record.id in {item["id"] for item in body["assets"]} + assert body["total"] == 1 + listed = next(item for item in body["assets"] if item["id"] == record.id) + assert "missing" in listed["tags"] + + +@pytest.mark.asyncio +async def test_exclude_tags_missing_hides_it(db_engine, session, temp_dir, monkeypatch): + content = create_content(session, path=str(temp_dir / "missing.png")) + record = create_record( + session, + content_id=content.id, + name="missing.png", + mime_type="image/png", + ) + mark_content_missing(session, content.id) + session.commit() + + monkeypatch.setattr(routes, "create_session", _session_factory(db_engine)) + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + + response = await routes.list_assets_route( + make_mocked_request("GET", "/api/assets?exclude_tags=missing") + ) + + assert isinstance(response, web.Response) + response_body = response.body + assert isinstance(response_body, bytes | bytearray) + body = json.loads(response_body) + assert record.id not in {item["id"] for item in body["assets"]} + + +@pytest.mark.asyncio +async def test_listing_serves_is_immutable_as_false( + db_engine, session, temp_dir, monkeypatch +): + content = create_content(session, path=str(temp_dir / "mutable.png")) + record = create_record( + session, + content_id=content.id, + name="mutable.png", + mime_type="image/png", + ) + session.commit() + + monkeypatch.setattr(routes, "create_session", _session_factory(db_engine)) + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + + response = await routes.list_assets_route( + make_mocked_request("GET", "/api/assets") + ) + + assert isinstance(response, web.Response) + response_body = response.body + assert isinstance(response_body, bytes | bytearray) + listed = next( + item + for item in json.loads(response_body)["assets"] + if item["id"] == record.id + ) + assert listed["is_immutable"] is False, ( + "the field must be present in the serialized body, not dropped as a default" + ) + + +@pytest.mark.asyncio +async def test_detail_serves_is_immutable_as_false( + mock_create_session, db_engine, session, temp_dir, monkeypatch +): + content = create_content(session, path=str(temp_dir / "detail.png")) + record = create_record( + session, + content_id=content.id, + name="detail.png", + mime_type="image/png", + ) + session.commit() + + monkeypatch.setattr(routes, "create_session", _session_factory(db_engine)) + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + + response = await routes.get_asset_route( + make_mocked_request( + "GET", f"/api/assets/{record.id}", match_info={"id": record.id} + ) + ) + + assert isinstance(response, web.Response) + response_body = response.body + assert isinstance(response_body, bytes | bytearray) + assert json.loads(response_body)["is_immutable"] is False + + +@pytest.mark.asyncio +async def test_from_hash_off_mode_returns_400(monkeypatch): + monkeypatch.setattr(mode, "hashing_enabled", lambda: False) + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + request = AsyncMock(spec=web.Request) + request.json.return_value = {"hash": f"blake3:{'a' * 64}"} + + response = await routes.create_asset_from_hash_route(request) + + assert isinstance(response, web.Response) + assert response.status == 400 + response_body = response.body + assert isinstance(response_body, bytes | bytearray) + assert json.loads(response_body)["error"]["code"] == "FEATURE_DISABLED" diff --git a/tests-unit/assets_test/services/test_asset_management.py b/tests-unit/assets_test/services/test_asset_management.py index e8ff989e919..41a8099a70a 100644 --- a/tests-unit/assets_test/services/test_asset_management.py +++ b/tests-unit/assets_test/services/test_asset_management.py @@ -1,310 +1,14 @@ -"""Tests for asset_management services.""" -import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference -from app.assets.database.queries import ensure_tags_exist, add_tags_to_reference -from app.assets.helpers import get_utc_now -from app.assets.services import ( - get_asset_detail, - update_asset_metadata, - delete_asset_reference, - set_asset_preview, -) -from app.assets.services.asset_management import resolve_hash_to_path +from app.assets.database.queries import create_content, create_record, delete_record, get_record_by_id -def _make_asset(session: Session, hash_val: str = "blake3:test", size: int = 1024) -> Asset: - asset = Asset(hash=hash_val, size_bytes=size, mime_type="application/octet-stream") - session.add(asset) - session.flush() - return asset +def test_delete_removes_only_the_requested_delivery_record(session: Session) -> None: + content = create_content(session, "/output/shared.png") + first = create_record(session, content.id, "first.png") + second = create_record(session, content.id, "second.png") + delete_record(session, first.id) -def _make_reference( - session: Session, - asset: Asset, - name: str = "test", - owner_id: str = "", -) -> AssetReference: - now = get_utc_now() - ref = AssetReference( - owner_id=owner_id, - name=name, - asset_id=asset.id, - created_at=now, - updated_at=now, - last_access_time=now, - ) - session.add(ref) - session.flush() - return ref - - -class TestGetAssetDetail: - def test_returns_none_for_nonexistent(self, mock_create_session): - result = get_asset_detail(reference_id="nonexistent") - assert result is None - - def test_returns_asset_with_tags(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, name="test.bin") - ensure_tags_exist(session, ["alpha", "beta"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["alpha", "beta"]) - session.commit() - - result = get_asset_detail(reference_id=ref.id) - - assert result is not None - assert result.ref.id == ref.id - assert result.asset.hash == asset.hash - assert set(result.tags) == {"alpha", "beta"} - - def test_respects_owner_visibility(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, owner_id="user1") - session.commit() - - # Wrong owner cannot see - result = get_asset_detail(reference_id=ref.id, owner_id="user2") - assert result is None - - # Correct owner can see - result = get_asset_detail(reference_id=ref.id, owner_id="user1") - assert result is not None - - -class TestUpdateAssetMetadata: - def test_updates_name(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, name="old_name.bin") - ref_id = ref.id - session.commit() - - update_asset_metadata( - reference_id=ref_id, - name="new_name.bin", - ) - - # Verify by re-fetching from DB - session.expire_all() - updated_ref = session.get(AssetReference, ref_id) - assert updated_ref.name == "new_name.bin" - - def test_updates_tags(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["old"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["old"]) - session.commit() - - result = update_asset_metadata( - reference_id=ref.id, - tags=["new1", "new2"], - ) - - assert set(result.tags) == {"new1", "new2"} - assert "old" not in result.tags - - def test_updates_user_metadata(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - ref_id = ref.id - session.commit() - - update_asset_metadata( - reference_id=ref_id, - user_metadata={"key": "value", "num": 42}, - ) - - # Verify by re-fetching from DB - session.expire_all() - updated_ref = session.get(AssetReference, ref_id) - assert updated_ref.user_metadata["key"] == "value" - assert updated_ref.user_metadata["num"] == 42 - - def test_raises_for_nonexistent(self, mock_create_session): - with pytest.raises(ValueError, match="not found"): - update_asset_metadata(reference_id="nonexistent", name="fail") - - def test_raises_for_wrong_owner(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, owner_id="user1") - session.commit() - - with pytest.raises(PermissionError, match="not owner"): - update_asset_metadata( - reference_id=ref.id, - name="new", - owner_id="user2", - ) - - -class TestDeleteAssetReference: - def test_soft_deletes_reference(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - ref_id = ref.id - session.commit() - - result = delete_asset_reference( - reference_id=ref_id, - owner_id="", - delete_content_if_orphan=False, - ) - - assert result is True - # Row still exists but is marked as soft-deleted - session.expire_all() - row = session.get(AssetReference, ref_id) - assert row is not None - assert row.deleted_at is not None - - def test_returns_false_for_nonexistent(self, mock_create_session): - result = delete_asset_reference( - reference_id="nonexistent", - owner_id="", - ) - assert result is False - - def test_returns_false_for_wrong_owner(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, owner_id="user1") - ref_id = ref.id - session.commit() - - result = delete_asset_reference( - reference_id=ref_id, - owner_id="user2", - ) - - assert result is False - assert session.get(AssetReference, ref_id) is not None - - def test_keeps_asset_if_other_references_exist(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref1 = _make_reference(session, asset, name="ref1") - _make_reference(session, asset, name="ref2") # Second ref keeps asset alive - asset_id = asset.id - session.commit() - - delete_asset_reference( - reference_id=ref1.id, - owner_id="", - delete_content_if_orphan=True, - ) - - # Asset should still exist - assert session.get(Asset, asset_id) is not None - - def test_deletes_orphaned_asset(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - asset_id = asset.id - ref_id = ref.id - session.commit() - - delete_asset_reference( - reference_id=ref_id, - owner_id="", - delete_content_if_orphan=True, - ) - - # Both ref and asset should be gone - assert session.get(AssetReference, ref_id) is None - assert session.get(Asset, asset_id) is None - - -class TestSetAssetPreview: - def test_sets_preview(self, mock_create_session, session: Session): - asset = _make_asset(session, hash_val="blake3:main") - preview_asset = _make_asset(session, hash_val="blake3:preview") - ref = _make_reference(session, asset) - preview_ref = _make_reference(session, preview_asset, name="preview.png") - ref_id = ref.id - preview_ref_id = preview_ref.id - session.commit() - - set_asset_preview( - reference_id=ref_id, - preview_reference_id=preview_ref_id, - ) - - # Verify by re-fetching from DB - session.expire_all() - updated_ref = session.get(AssetReference, ref_id) - assert updated_ref.preview_id == preview_ref_id - - def test_clears_preview(self, mock_create_session, session: Session): - asset = _make_asset(session) - preview_asset = _make_asset(session, hash_val="blake3:preview") - ref = _make_reference(session, asset) - preview_ref = _make_reference(session, preview_asset, name="preview.png") - ref.preview_id = preview_ref.id - ref_id = ref.id - session.commit() - - set_asset_preview( - reference_id=ref_id, - preview_reference_id=None, - ) - - # Verify by re-fetching from DB - session.expire_all() - updated_ref = session.get(AssetReference, ref_id) - assert updated_ref.preview_id is None - - def test_raises_for_nonexistent_ref(self, mock_create_session): - with pytest.raises(ValueError, match="not found"): - set_asset_preview(reference_id="nonexistent") - - def test_raises_for_wrong_owner(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, owner_id="user1") - session.commit() - - with pytest.raises(PermissionError, match="not owner"): - set_asset_preview( - reference_id=ref.id, - preview_reference_id=None, - owner_id="user2", - ) - - -class TestResolveHashToPath: - def test_returns_none_for_unknown_hash(self, mock_create_session): - result = resolve_hash_to_path("blake3:" + "a" * 64) - assert result is None - - @pytest.mark.parametrize( - "ref_owner, query_owner, expect_found", - [ - ("user1", "user1", True), - ("user1", "user2", False), - ("", "anyone", True), - ("", "", True), - ], - ids=[ - "owner_sees_own_ref", - "other_owner_blocked", - "ownerless_visible_to_anyone", - "ownerless_visible_to_empty", - ], - ) - def test_owner_visibility( - self, ref_owner, query_owner, expect_found, - mock_create_session, session: Session, temp_dir, - ): - f = temp_dir / "file.bin" - f.write_bytes(b"data") - asset = _make_asset(session, hash_val="blake3:" + "b" * 64) - ref = _make_reference(session, asset, name="file.bin", owner_id=ref_owner) - ref.file_path = str(f) - session.commit() - - result = resolve_hash_to_path(asset.hash, owner_id=query_owner) - if expect_found: - assert result is not None - assert result.abs_path == str(f) - else: - assert result is None + assert get_record_by_id(session, first.id) is None + assert get_record_by_id(session, second.id) == second diff --git a/tests-unit/assets_test/services/test_asset_response_loader_path.py b/tests-unit/assets_test/services/test_asset_response_loader_path.py index 7ae7c6f5a8c..940d442589b 100644 --- a/tests-unit/assets_test/services/test_asset_response_loader_path.py +++ b/tests-unit/assets_test/services/test_asset_response_loader_path.py @@ -81,3 +81,29 @@ def test_all_path_fields_null_without_file_path(): assert resp.loader_path is None assert resp.display_name is None + + +def test_display_name_is_serialized_and_file_path_is_not(tmp_path: Path): + models = tmp_path / "models" + ckpt = models / "checkpoints" + ckpt.mkdir(parents=True) + f = ckpt / "flux.safetensors" + f.touch() + + with patch("app.assets.services.path_utils.folder_paths") as mock_fp, patch( + "app.assets.services.path_utils.get_comfy_models_folders", + return_value=[("checkpoints", [str(ckpt)], {".safetensors"})], + ): + mock_fp.get_input_directory.return_value = str(tmp_path / "in") + mock_fp.get_output_directory.return_value = str(tmp_path / "out") + mock_fp.get_temp_directory.return_value = str(tmp_path / "tmp") + mock_fp.models_dir = str(models) + + result = _make_result(file_path=str(f), loader_path=None) + resp = _build_asset_response(result, {}) + dumped = resp.model_dump(mode="json") + + assert dumped["display_name"] == "checkpoints/flux.safetensors" + assert "file_path" not in dumped, ( + "the namespace-rooted storage path must not leak into this public response" + ) diff --git a/tests-unit/assets_test/services/test_asset_response_preview_url.py b/tests-unit/assets_test/services/test_asset_response_preview_url.py index 54557c72849..e0a3d3115d5 100644 --- a/tests-unit/assets_test/services/test_asset_response_preview_url.py +++ b/tests-unit/assets_test/services/test_asset_response_preview_url.py @@ -4,7 +4,12 @@ import pytest -from app.assets.api.routes import _build_asset_response +from app.assets.api.routes import _build_asset_response, _build_record_response +from app.assets.database.queries.records import ( + create_content, + create_record, + mark_content_missing, +) from app.assets.services.schemas import AssetData, AssetDetailResult, ReferenceData _TS = datetime(2024, 1, 1, 0, 0, 0) @@ -30,6 +35,7 @@ def _make_result( tags: list[str] | None = None, user_metadata: dict | None = None, with_asset: bool = True, + is_missing: bool = False, ) -> AssetDetailResult: ref = ReferenceData( id=ref_id, @@ -43,7 +49,12 @@ def _make_result( last_access_time=_TS, ) asset = ( - AssetData(hash="blake3:abc", size_bytes=1024, mime_type=mime_type) + AssetData( + hash="blake3:abc", + size_bytes=1024, + mime_type=mime_type, + is_missing=is_missing, + ) if with_asset else None ) @@ -298,3 +309,77 @@ def test_no_preview_url_without_content(sandboxed_comfy_roots: Path): ) assert resp.preview_url is None, "no asset row means there is nothing to preview" + + +def test_no_self_preview_url_when_content_is_missing(sandboxed_comfy_roots: Path): + resp = _build_asset_response( + _make_result( + name="gone.png", + file_path=str(sandboxed_comfy_roots / "output" / "gone.png"), + mime_type="image/png", + is_missing=True, + ), + {}, + ) + + assert resp.preview_url is None, ( + "a missing-content record must not advertise a preview of its own bytes; " + "those bytes are gone, so /api/view would 404" + ) + + +def test_missing_content_still_shows_a_nominated_preview(sandboxed_comfy_roots: Path): + result = _make_result( + name="gone.safetensors", + file_path=str(sandboxed_comfy_roots / "models" / "checkpoints" / "gone.safetensors"), + mime_type="application/safetensors", + preview_id="preview-ref", + is_missing=True, + ) + + resp = _build_asset_response( + result, {"preview-ref": str(sandboxed_comfy_roots / "output" / "thumb.png")} + ) + + assert resp.preview_url == "/api/view?type=output&filename=thumb.png", ( + "suppression targets self-content only; a nominated (live) preview still " + "stands in for a missing record" + ) + + +def test_record_response_shows_self_preview_url_for_live_content( + sandboxed_comfy_roots: Path, session +): + content = create_content( + session, path=str(sandboxed_comfy_roots / "output" / "live.png") + ) + record = create_record( + session, content_id=content.id, name="live.png", mime_type="image/png" + ) + session.commit() + + resp = _build_record_response(record, [], {}) + + assert resp.preview_url == "/api/view?type=output&filename=live.png", ( + "a live record still previews its own bytes — the positive control that " + "proves the missing-case suppression is what withholds the URL" + ) + + +def test_record_response_has_no_self_preview_url_when_content_is_missing( + sandboxed_comfy_roots: Path, session +): + content = create_content( + session, path=str(sandboxed_comfy_roots / "output" / "gone.png") + ) + record = create_record( + session, content_id=content.id, name="gone.png", mime_type="image/png" + ) + mark_content_missing(session, content.id) + session.commit() + + resp = _build_record_response(record, ["missing"], {}) + + assert resp.preview_url is None, ( + "the list surface must also withhold a missing record's self-preview URL" + ) diff --git a/tests-unit/assets_test/services/test_bulk_ingest.py b/tests-unit/assets_test/services/test_bulk_ingest.py index a3889f23506..e7e10647135 100644 --- a/tests-unit/assets_test/services/test_bulk_ingest.py +++ b/tests-unit/assets_test/services/test_bulk_ingest.py @@ -1,319 +1,40 @@ -"""Tests for bulk ingest services.""" - -import os -from pathlib import Path +import logging from unittest.mock import patch from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference -from app.assets.database.queries import get_reference_tags +from app.assets.database.queries import create_content, create_record from app.assets.scanner import build_asset_specs -from app.assets.services.bulk_ingest import SeedAssetSpec, batch_insert_seed_assets - - -class TestBatchInsertSeedAssets: - def test_populates_mime_type_for_model_files(self, session: Session, temp_dir: Path): - """Verify mime_type is stored in the Asset table for model files.""" - file_path = temp_dir / "model.safetensors" - file_path.write_bytes(b"fake safetensors content") - - specs: list[SeedAssetSpec] = [ - { - "abs_path": str(file_path), - "size_bytes": 24, - "mtime_ns": 1234567890000000000, - "info_name": "Test Model", - "tags": ["models"], - "fname": "model.safetensors", - "metadata": None, - "hash": None, - "mime_type": "application/safetensors", - } - ] - - result = batch_insert_seed_assets(session, specs=specs, owner_id="") - - assert result.inserted_refs == 1 - - # Verify Asset has mime_type populated - assets = session.query(Asset).all() - assert len(assets) == 1 - assert assets[0].mime_type == "application/safetensors" - - def test_mime_type_none_when_not_provided(self, session: Session, temp_dir: Path): - """Verify mime_type is None when not provided in spec.""" - file_path = temp_dir / "unknown.bin" - file_path.write_bytes(b"binary data") - - specs: list[SeedAssetSpec] = [ - { - "abs_path": str(file_path), - "size_bytes": 11, - "mtime_ns": 1234567890000000000, - "info_name": "Unknown File", - "tags": [], - "fname": "unknown.bin", - "metadata": None, - "hash": None, - "mime_type": None, - } - ] - - result = batch_insert_seed_assets(session, specs=specs, owner_id="") - - assert result.inserted_refs == 1 - - assets = session.query(Asset).all() - assert len(assets) == 1 - assert assets[0].mime_type is None - def test_various_model_mime_types(self, session: Session, temp_dir: Path): - """Verify various model file types get correct mime_type.""" - test_cases = [ - ("model.safetensors", "application/safetensors"), - ("model.pt", "application/pytorch"), - ("model.ckpt", "application/pickle"), - ("model.gguf", "application/gguf"), - ] - specs: list[SeedAssetSpec] = [] - for filename, mime_type in test_cases: - file_path = temp_dir / filename - file_path.write_bytes(b"content") - specs.append( - { - "abs_path": str(file_path), - "size_bytes": 7, - "mtime_ns": 1234567890000000000, - "info_name": filename, - "tags": [], - "fname": filename, - "metadata": None, - "hash": None, - "mime_type": mime_type, - } - ) +def test_locked_file_logs_typed_error_without_path(caplog): + locked_path = "/private/user/models/locked.safetensors" - result = batch_insert_seed_assets(session, specs=specs, owner_id="") - - assert result.inserted_refs == len(test_cases) - - for filename, expected_mime in test_cases: - ref = session.query(AssetReference).filter_by(name=filename).first() - assert ref is not None - asset = session.query(Asset).filter_by(id=ref.asset_id).first() - assert asset.mime_type == expected_mime, f"Expected {expected_mime} for {filename}, got {asset.mime_type}" - - def test_duplicate_paths_merge_tags_before_insert( - self, session: Session, temp_dir: Path + with ( + patch("app.assets.scanner.os.stat", side_effect=PermissionError(locked_path)), + caplog.at_level(logging.WARNING), ): - """Overlapping model-folder registrations can emit the same path twice.""" - file_path = temp_dir / "shared.safetensors" - file_path.write_bytes(b"shared model") - - specs: list[SeedAssetSpec] = [ - { - "abs_path": str(file_path), - "size_bytes": 12, - "mtime_ns": 1234567890000000000, - "info_name": "Shared Model", - "tags": ["models", "model_type:checkpoints"], - "fname": "shared.safetensors", - "metadata": None, - "hash": None, - "mime_type": "application/safetensors", - }, - { - "abs_path": str(file_path), - "size_bytes": 12, - "mtime_ns": 1234567890000000000, - "info_name": "Shared Model", - "tags": ["models", "model_type:diffusion_models"], - "fname": "shared.safetensors", - "metadata": None, - "hash": None, - "mime_type": "application/safetensors", - }, - ] - - result = batch_insert_seed_assets(session, specs=specs, owner_id="") - - assert result.inserted_refs == 1 - assert result.won_paths == 1 - refs = session.query(AssetReference).all() - assert len(refs) == 1 - assert set(get_reference_tags(session, reference_id=refs[0].id)) == { - "models", - "model_type:checkpoints", - "model_type:diffusion_models", - } - - def test_duplicate_paths_are_merged_after_abspath_normalization( - self, session: Session, temp_dir: Path, monkeypatch - ): - """The scanner may emit equivalent paths with different spelling.""" - file_path = temp_dir / "same-file.safetensors" - file_path.write_bytes(b"shared model") - monkeypatch.chdir(temp_dir) - relative_path = file_path.name - absolute_path = os.path.abspath(relative_path) - - specs: list[SeedAssetSpec] = [ - { - "abs_path": relative_path, - "size_bytes": 12, - "mtime_ns": 1234567890000000000, - "info_name": "Shared Model", - "tags": ["models", "model_type:checkpoints"], - "fname": "same-file.safetensors", - "metadata": None, - "hash": None, - "mime_type": "application/safetensors", - }, - { - "abs_path": absolute_path, - "size_bytes": 12, - "mtime_ns": 1234567890000000000, - "info_name": "Shared Model", - "tags": ["models", "model_type:diffusion_models"], - "fname": "same-file.safetensors", - "metadata": None, - "hash": None, - "mime_type": "application/safetensors", - }, - ] - - result = batch_insert_seed_assets(session, specs=specs, owner_id="") - - assert result.inserted_refs == 1 - assert result.won_paths == 1 - refs = session.query(AssetReference).all() - assert len(refs) == 1 - assert refs[0].file_path == absolute_path - # loader_path is persisted from the spec's fname (compute_loader_path). - assert refs[0].loader_path == "same-file.safetensors" - assert set(get_reference_tags(session, reference_id=refs[0].id)) == { - "models", - "model_type:checkpoints", - "model_type:diffusion_models", - } - - def test_scanner_duplicate_shared_model_paths_keep_all_model_type_tags( - self, session: Session, temp_dir: Path - ): - """Shared extra model roots make scanner collection emit duplicate paths.""" - shared_root = temp_dir / "shared" - input_dir = temp_dir / "input" - output_dir = temp_dir / "output" - temp_root = temp_dir / "temp" - for directory in (shared_root, input_dir, output_dir, temp_root): - directory.mkdir() - file_path = shared_root / "dual_use_model.safetensors" - file_path.write_bytes(b"shared model") - - with ( - patch("app.assets.services.path_utils.folder_paths") as mock_fp, - patch( - "app.assets.services.path_utils.get_comfy_models_folders", - return_value=[ - ("checkpoints", [str(shared_root)], {".safetensors"}), - ("diffusion_models", [str(shared_root)], {".safetensors"}), - ], - ), - ): - mock_fp.get_input_directory.return_value = str(input_dir) - mock_fp.get_output_directory.return_value = str(output_dir) - mock_fp.get_temp_directory.return_value = str(temp_root) - - specs, tag_pool, skipped = build_asset_specs( - paths=[str(file_path), str(file_path)], - existing_paths=set(), - enable_metadata_extraction=False, - compute_hashes=False, - ) - - assert skipped == 0 - assert len(specs) == 2 - assert tag_pool == { - "models", - "model_type:checkpoints", - "model_type:diffusion_models", - } - - result = batch_insert_seed_assets(session, specs=specs, owner_id="") - - assert result.inserted_refs == 1 - assert result.won_paths == 1 - refs = session.query(AssetReference).all() - assert len(refs) == 1 - assert set(get_reference_tags(session, reference_id=refs[0].id)) == { - "models", - "model_type:checkpoints", - "model_type:diffusion_models", - } - - def test_loader_path_persisted_as_null_when_fname_is_none( - self, session: Session, temp_dir: Path - ): - """A file with no in-root loader path (fname=None, e.g. an orphan under - models_root) persists loader_path as NULL rather than a synthesized value.""" - file_path = temp_dir / "orphan.bin" - file_path.write_bytes(b"x") - - specs: list[SeedAssetSpec] = [ - { - "abs_path": str(file_path), - "size_bytes": 1, - "mtime_ns": 1234567890000000000, - "info_name": "orphan.bin", - "tags": [], - "fname": None, - "metadata": None, - "hash": None, - "mime_type": None, - } - ] - - result = batch_insert_seed_assets(session, specs=specs, owner_id="") - - assert result.inserted_refs == 1 - refs = session.query(AssetReference).all() - assert len(refs) == 1 - assert refs[0].file_path == str(file_path) - assert refs[0].loader_path is None - - -class TestMetadataExtraction: - def test_extracts_mime_type_for_model_files(self, temp_dir: Path): - """Verify metadata extraction returns correct mime_type for model files.""" - from app.assets.services.metadata_extract import extract_file_metadata - - file_path = temp_dir / "model.safetensors" - file_path.write_bytes(b"fake safetensors content") - - meta = extract_file_metadata(str(file_path)) - - assert meta.content_type == "application/safetensors" - - def test_mime_type_for_various_model_formats(self, temp_dir: Path): - """Verify various model file types get correct mime_type from metadata.""" - from app.assets.services.metadata_extract import extract_file_metadata - - test_cases = [ - ("model.safetensors", "application/safetensors"), - ("model.sft", "application/safetensors"), - ("model.pt", "application/pytorch"), - ("model.pth", "application/pytorch"), - ("model.ckpt", "application/pickle"), - ("model.pkl", "application/pickle"), - ("model.gguf", "application/gguf"), - ] - - for filename, expected_mime in test_cases: - file_path = temp_dir / filename - file_path.write_bytes(b"content") - - meta = extract_file_metadata(str(file_path)) - - assert meta.content_type == expected_mime, f"Expected {expected_mime} for {filename}, got {meta.content_type}" + specs, tags, skipped = build_asset_specs([locked_path], set()) + + assert specs == [] + assert tags == set() + assert skipped == 0 + assert caplog.messages == [ + "Asset scan error: phase=discovery_stat error_type=permission_denied" + ] + assert locked_path not in caplog.text + + +def test_bulk_discovery_creates_a_record_for_each_path(session: Session) -> None: + discovered = [ + ("/models/a/model.safetensors", "a/model.safetensors"), + ("/models/b/model.safetensors", "b/model.safetensors"), + ] + + records = [ + create_record(session, create_content(session, path).id, name, loader_path=name) + for path, name in discovered + ] + + assert [record.loader_path for record in records] == [name for _, name in discovered] + assert records[0].content_id != records[1].content_id diff --git a/tests-unit/assets_test/services/test_cached_execution_path.py b/tests-unit/assets_test/services/test_cached_execution_path.py new file mode 100644 index 00000000000..03309ca2d05 --- /dev/null +++ b/tests-unit/assets_test/services/test_cached_execution_path.py @@ -0,0 +1,113 @@ +import os +from pathlib import Path + +from sqlalchemy import event, select + +import folder_paths +from app.assets.database.models import Asset, AssetContent +from app.assets.manager import AssetsEnabled +from comfy_execution.asset_enrichment import ( + register_cached_outputs, + register_executed_outputs, +) + +class _ArgsStub: + enable_assets = True + enable_asset_hashing = False + + +def _write_output_file(name: str) -> Path: + path = Path(folder_paths.get_output_directory()) / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"cached output") + return path + + +def _output_ui(name: str) -> dict: + return {"images": [{"filename": name, "subfolder": "", "type": "output"}]} + + +def _wrapper(name: str, node_id: str = "1") -> dict: + return {"meta": {"node_id": node_id}, "output": _output_ui(name)} + + +def test_cached_replay_binds_new_record_to_existing_content(mock_create_session): + path = _write_output_file("cached-execution-bind.png") + try: + manager = AssetsEnabled(_ArgsStub()) + executed = register_executed_outputs(_output_ui(path.name), "original-job", manager) + original_id = executed["images"][0]["id"] + with mock_create_session() as session: + original_content_id = session.get(Asset, original_id).content_id + + wrapper = _wrapper(path.name) + enriched = register_cached_outputs(wrapper, "cached-job", manager) + cached_id = enriched["output"]["images"][0]["id"] + + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.path == os.path.abspath(path)) + ) + ) + records = list( + session.scalars( + select(Asset).where(Asset.content_id == original_content_id) + ) + ) + assert len(contents) == 1 + assert len(records) == 2 + assert original_id in {r.id for r in records} + assert cached_id in {r.id for r in records} + assert {r.job_id for r in records} == {"original-job", "cached-job"} + assert "id" not in wrapper["output"]["images"][0] + finally: + path.unlink(missing_ok=True) + + +def test_cached_replay_does_not_update_existing_content(mock_create_session, db_engine): + path = _write_output_file("cached-execution-no-update.png") + update_statements: list[str] = [] + + def capture_updates(_, __, statement, ___, ____, _____): + if statement.lstrip().upper().startswith("UPDATE"): + update_statements.append(statement) + + try: + manager = AssetsEnabled(_ArgsStub()) + executed = register_executed_outputs(_output_ui(path.name), "original-job", manager) + original_id = executed["images"][0]["id"] + with mock_create_session() as session: + original_content = session.get( + AssetContent, session.get(Asset, original_id).content_id + ) + original_state = ( + original_content.id, + original_content.hash, + original_content.size_bytes, + original_content.path, + original_content.mtime_ns, + original_content.is_missing, + original_content.created_at, + ) + + event.listen(db_engine, "before_cursor_execute", capture_updates) + wrapper = _wrapper(path.name) + register_cached_outputs(wrapper, "cached-job", manager) + + with mock_create_session() as session: + content = session.get(AssetContent, original_state[0]) + current_state = ( + content.id, + content.hash, + content.size_bytes, + content.path, + content.mtime_ns, + content.is_missing, + content.created_at, + ) + assert current_state == original_state + assert update_statements == [] + finally: + event.remove(db_engine, "before_cursor_execute", capture_updates) + path.unlink(missing_ok=True) diff --git a/tests-unit/assets_test/services/test_cached_save.py b/tests-unit/assets_test/services/test_cached_save.py new file mode 100644 index 00000000000..328f0079075 --- /dev/null +++ b/tests-unit/assets_test/services/test_cached_save.py @@ -0,0 +1,75 @@ +import os + +from sqlalchemy import event, select + +import folder_paths +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import mark_content_missing +from app.assets.services.ingest import register_cached_output, register_executed_output + + +def test_cached_save_creates_delivery_record(mock_create_session, db_engine): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "test_cached_save.png") + update_count = 0 + + def count_updates(_, __, statement, ___, ____, _____): + nonlocal update_count + if statement.lstrip().upper().startswith("UPDATE"): + update_count += 1 + + event.listen(db_engine, "before_cursor_execute", count_updates) + try: + with open(path, "wb") as file: + file.write(b"pixels") + original = register_executed_output(path, job_id="executed-job") + update_count = 0 + + cached = register_cached_output(path, job_id="delivery-job") + + with mock_create_session() as session: + records = list( + session.scalars(select(Asset).where(Asset.content_id == original.content_id)) + ) + assert {record.id for record in records} == {original.id, cached.id} + assert {record.job_id for record in records} == {"executed-job", "delivery-job"} + assert update_count == 0 + finally: + event.remove(db_engine, "before_cursor_execute", count_updates) + if os.path.exists(path): + os.unlink(path) + + +def test_cached_save_against_missing_content_is_nonevent(mock_create_session): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "test_cached_save_missing.png") + + try: + with open(path, "wb") as file: + file.write(b"pixels") + original = register_executed_output(path, job_id="executed-job") + with mock_create_session() as session: + mark_content_missing(session, original.content_id) + session.commit() + + cached = register_cached_output(path, job_id="delivery-job") + + assert cached is None + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.path == os.path.abspath(path)) + ) + ) + assert len(contents) == 1 + assert contents[0].id == original.content_id + assert contents[0].is_missing is True + records = list( + session.scalars(select(Asset).where(Asset.job_id == "delivery-job")) + ) + assert records == [] + finally: + if os.path.exists(path): + os.unlink(path) diff --git a/tests-unit/assets_test/services/test_delete_b.py b/tests-unit/assets_test/services/test_delete_b.py new file mode 100644 index 00000000000..249becc1df3 --- /dev/null +++ b/tests-unit/assets_test/services/test_delete_b.py @@ -0,0 +1,115 @@ +import os + +from sqlalchemy import update + +import folder_paths +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import ( + create_content, + create_record, + delete_record, + get_record_by_id, + mark_content_missing, +) +from app.assets.services.asset_management import delete_asset_reference +from app.assets.services.ingest import register_executed_output + + +def test_hard_delete_record_content_and_file_remain(mock_create_session, session, temp_dir): + content = create_content(session, path=str(temp_dir / "file.bin"), size_bytes=4) + record = create_record(session, content_id=content.id, name="file.bin") + temp_dir.joinpath("file.bin").write_bytes(b"data") + session.commit() + record_id = record.id + content_id = content.id + path = content.path + + assert delete_asset_reference(record_id) is True + + session.expire_all() + assert get_record_by_id(session, record_id) is None + assert session.get(AssetContent, content_id) is not None + assert os.path.isfile(path) + + +def test_two_records_one_content_other_unaffected(mock_create_session, session): + content = create_content(session, path="/tmp/shared.bin") + r1 = create_record(session, content_id=content.id, name="a") + r2 = create_record(session, content_id=content.id, name="b") + session.commit() + r1_id = r1.id + r2_id = r2.id + + assert delete_asset_reference(r1_id) is True + + session.expire_all() + assert get_record_by_id(session, r1_id) is None + assert get_record_by_id(session, r2_id) is not None + assert session.get(AssetContent, content.id) is not None + + +def test_delete_record_never_deletes_pointed_at_preview(mock_create_session, session): + b_content = create_content(session, path="/tmp/preview_target.png") + b = create_record(session, content_id=b_content.id, name="B") + a_content = create_content(session, path="/tmp/owner.png") + a = create_record(session, content_id=a_content.id, name="A") + session.execute(update(Asset).where(Asset.id == a.id).values(preview_id=b.id)) + session.commit() + a_id = a.id + b_id = b.id + + assert delete_asset_reference(a_id) is True + + session.expire_all() + assert get_record_by_id(session, a_id) is None + assert get_record_by_id(session, b_id) is not None + + +def test_missing_content_record_deletable(mock_create_session, session): + content = create_content(session, path="/tmp/missing.bin") + record = create_record(session, content_id=content.id, name="missing.bin") + mark_content_missing(session, content.id) + session.commit() + record_id = record.id + + assert delete_asset_reference(record_id) is True + session.expire_all() + assert get_record_by_id(session, record_id) is None + + +def test_reregister_same_path_fresh_ids(mock_create_session, monkeypatch): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + f = os.path.join(output_dir, "test_delete_reregister.png") + + try: + with open(f, "wb") as fh: + fh.write(b"v1") + first = register_executed_output(f, job_id="job1") + first_id = first.id + assert delete_asset_reference(first_id) is True + + with open(f, "wb") as fh: + fh.write(b"v2") + second = register_executed_output(f, job_id="job2") + assert second.id != first_id + finally: + if os.path.exists(f): + os.unlink(f) + + +def test_delete_nonexistent_returns_false(mock_create_session): + assert delete_asset_reference("00000000-0000-0000-0000-000000000000") is False + + +def test_delete_order_record_before_content(mock_create_session, session): + content = create_content(session, path="/tmp/order.bin") + record = create_record(session, content_id=content.id, name="order.bin") + session.commit() + content_id = content.id + + delete_record(session, record.id) + session.flush() + + assert session.get(Asset, record.id) is None + assert session.get(AssetContent, content_id) is not None diff --git a/tests-unit/assets_test/services/test_detection_gate.py b/tests-unit/assets_test/services/test_detection_gate.py new file mode 100644 index 00000000000..a0e384d97e3 --- /dev/null +++ b/tests-unit/assets_test/services/test_detection_gate.py @@ -0,0 +1,197 @@ +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent +from app.assets.helpers import to_stored_hash +from app.assets.scanner import ( + clear_pending_verifications, + drain_pending_verifications, + sync_prefixes_with_filesystem, +) +from app.assets.scanner_changes import queue_pending_verification +from app.assets.services.snapshot_hash import snapshot_hash + + +@pytest.fixture(autouse=True) +def _clear_pending_verifications(): + clear_pending_verifications() + yield + clear_pending_verifications() + + +def _seed_content(session, path: Path, hash_value: str | None) -> tuple[AssetContent, Asset]: + stat = path.stat() + content = AssetContent( + path=str(path), + hash=hash_value, + size_bytes=stat.st_size, + mtime_ns=stat.st_mtime_ns, + ) + session.add(content) + session.flush() + record = Asset(content_id=content.id, name=path.name) + session.add(record) + session.commit() + return content, record + + +def _bump_mtime(path: Path) -> None: + stat = path.stat() + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns + 1_000_000)) + + +def _stored_hash(path: Path) -> str: + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, _ = snapshot + return to_stored_hash(digest) + + +def test_off_mode_same_size_touch_does_not_split(session, temp_dir: Path): + input_root = temp_dir / "input" + input_root.mkdir() + path = input_root / "touched.bin" + path.write_bytes(b"same bytes") + old_content, _ = _seed_content(session, path, hash_value="historical") + _bump_mtime(path) + + with ( + patch("folder_paths.get_input_directory", return_value=str(input_root)), + patch("app.assets.scanner.mode.hashing_enabled", return_value=False), + ): + sync_prefixes_with_filesystem(session, [str(input_root)]) + session.commit() + + contents = list(session.scalars(select(AssetContent))) + assert len(contents) == 1 + assert session.get(AssetContent, old_content.id).is_missing is False + + +def test_off_mode_size_change_splits(session, temp_dir: Path): + input_root = temp_dir / "input" + input_root.mkdir() + path = input_root / "grown.bin" + path.write_bytes(b"small") + previous_target_ns = path.stat().st_mtime_ns + old_content, _ = _seed_content(session, path, hash_value="historical") + path.write_bytes(b"a decidedly larger set of bytes") + target_ns = max(path.stat().st_mtime_ns, previous_target_ns) + 1_000_000 + os.utime(path, ns=(target_ns, target_ns)) + + with ( + patch("folder_paths.get_input_directory", return_value=str(input_root)), + patch("app.assets.scanner.mode.hashing_enabled", return_value=False), + ): + sync_prefixes_with_filesystem(session, [str(input_root)]) + session.commit() + + contents = list(session.scalars(select(AssetContent).order_by(AssetContent.created_at))) + assert len(contents) == 2 + assert session.get(AssetContent, old_content.id).is_missing is True + assert [content.is_missing for content in contents] == [True, False] + + +def test_hash_mode_touch_refreshes_mtime(session, temp_dir: Path): + input_root = temp_dir / "input" + input_root.mkdir() + path = input_root / "touched.bin" + path.write_bytes(b"same bytes") + old_content, _ = _seed_content(session, path, _stored_hash(path)) + _bump_mtime(path) + + with ( + patch("folder_paths.get_input_directory", return_value=str(input_root)), + patch("app.assets.scanner.mode.hashing_enabled", return_value=True), + ): + sync_prefixes_with_filesystem(session, [str(input_root)]) + processed = drain_pending_verifications(session) + session.commit() + + refreshed = session.get(AssetContent, old_content.id) + assert processed == 1 + assert refreshed.is_missing is False + assert refreshed.mtime_ns == path.stat().st_mtime_ns + assert len(session.scalars(select(AssetContent)).all()) == 1 + + +def test_hash_mode_real_edit_splits(session, temp_dir: Path): + input_root = temp_dir / "input" + input_root.mkdir() + path = input_root / "edited.bin" + path.write_bytes(b"old bytes") + previous_target_ns = path.stat().st_mtime_ns + old_content, _ = _seed_content(session, path, _stored_hash(path)) + path.write_bytes(b"new bytes with a different length") + target_ns = max(path.stat().st_mtime_ns, previous_target_ns) + 1_000_000 + os.utime(path, ns=(target_ns, target_ns)) + + with ( + patch("folder_paths.get_input_directory", return_value=str(input_root)), + patch("app.assets.scanner.mode.hashing_enabled", return_value=True), + ): + sync_prefixes_with_filesystem(session, [str(input_root)]) + drain_pending_verifications(session) + session.commit() + + contents = list(session.scalars(select(AssetContent).order_by(AssetContent.created_at))) + assert len(contents) == 2 + assert session.get(AssetContent, old_content.id).is_missing is True + assert next(content for content in contents if not content.is_missing).hash == _stored_hash(path) + + +def test_old_record_id_resolves_to_missing_content_after_split(session, temp_dir: Path): + input_root = temp_dir / "input" + input_root.mkdir() + path = input_root / "edited.bin" + path.write_bytes(b"old bytes") + previous_target_ns = path.stat().st_mtime_ns + old_content, old_record = _seed_content(session, path, _stored_hash(path)) + path.write_bytes(b"replacement bytes") + target_ns = max(path.stat().st_mtime_ns, previous_target_ns) + 1_000_000 + os.utime(path, ns=(target_ns, target_ns)) + + with ( + patch("folder_paths.get_input_directory", return_value=str(input_root)), + patch("app.assets.scanner.mode.hashing_enabled", return_value=True), + ): + sync_prefixes_with_filesystem(session, [str(input_root)]) + drain_pending_verifications(session) + session.commit() + + session.expire_all() + original_record = session.get(Asset, old_record.id) + assert original_record is not None + assert original_record.content_id == old_content.id + assert original_record.content.is_missing is True + + +def test_hash_mode_split_uses_stat_from_the_verified_snapshot(session, temp_dir: Path, monkeypatch): + input_root = temp_dir / "input" + input_root.mkdir() + path = input_root / "changed.bin" + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(input_root)) + path.write_bytes(b"old") + content, _ = _seed_content(session, path, _stored_hash(path)) + queue_pending_verification(content.id) + new_payload = b"new bytes with a different size" + real_snapshot_hash = snapshot_hash + + def mutate_then_hash(candidate_path: str): + path.write_bytes(new_payload) + return real_snapshot_hash(candidate_path) + + monkeypatch.setattr("app.assets.scanner_changes.snapshot_hash", mutate_then_hash) + + processed = drain_pending_verifications(session) + + live_content = session.scalar( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + assert processed == 1 + assert live_content is not None + assert live_content.size_bytes == len(new_payload) + assert live_content.mtime_ns == path.stat().st_mtime_ns diff --git a/tests-unit/assets_test/services/test_enrich.py b/tests-unit/assets_test/services/test_enrich.py index 6a6561f7f27..87e7822087e 100644 --- a/tests-unit/assets_test/services/test_enrich.py +++ b/tests-unit/assets_test/services/test_enrich.py @@ -1,216 +1,18 @@ -"""Tests for asset enrichment (mime_type and hash population).""" -import os -from pathlib import Path - from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference -from app.assets.services.file_utils import get_mtime_ns -from app.assets.scanner import ( - ENRICHMENT_HASHED, - ENRICHMENT_METADATA, - ENRICHMENT_STUB, - enrich_asset, -) - - -def _create_stub_asset( - session: Session, - file_path: str, - asset_id: str = "test-asset-id", - reference_id: str = "test-ref-id", - name: str | None = None, -) -> tuple[Asset, AssetReference]: - """Create a stub asset with reference for testing enrichment.""" - # Use the real file's mtime so the optimistic guard in enrich_asset passes - try: - stat_result = os.stat(file_path, follow_symlinks=True) - mtime_ns = get_mtime_ns(stat_result) - except OSError: - mtime_ns = 1234567890000000000 - - asset = Asset( - id=asset_id, - hash=None, - size_bytes=100, - mime_type=None, - ) - session.add(asset) - session.flush() - - ref = AssetReference( - id=reference_id, - asset_id=asset_id, - name=name or f"test-asset-{asset_id}", - owner_id="system", - file_path=file_path, - mtime_ns=mtime_ns, - enrichment_level=ENRICHMENT_STUB, - ) - session.add(ref) - session.flush() - - return asset, ref - - -class TestEnrichAsset: - def test_extracts_mime_type_and_updates_asset( - self, db_engine, temp_dir: Path, session: Session - ): - """Verify mime_type is written to the Asset table during enrichment.""" - file_path = temp_dir / "model.safetensors" - file_path.write_bytes(b"\x00" * 100) - - asset, ref = _create_stub_asset( - session, str(file_path), "asset-1", "ref-1" - ) - session.commit() - - new_level = enrich_asset( - session, - file_path=str(file_path), - reference_id=ref.id, - asset_id=asset.id, - extract_metadata=True, - compute_hash=False, - ) - - assert new_level == ENRICHMENT_METADATA - - session.expire_all() - updated_asset = session.get(Asset, "asset-1") - assert updated_asset is not None - assert updated_asset.mime_type == "application/safetensors" - - def test_computes_hash_and_updates_asset( - self, db_engine, temp_dir: Path, session: Session - ): - """Verify hash is written to the Asset table during enrichment.""" - file_path = temp_dir / "data.bin" - file_path.write_bytes(b"test content for hashing") - - asset, ref = _create_stub_asset( - session, str(file_path), "asset-2", "ref-2" - ) - session.commit() - - new_level = enrich_asset( - session, - file_path=str(file_path), - reference_id=ref.id, - asset_id=asset.id, - extract_metadata=True, - compute_hash=True, - ) - - assert new_level == ENRICHMENT_HASHED - - session.expire_all() - updated_asset = session.get(Asset, "asset-2") - assert updated_asset is not None - assert updated_asset.hash is not None - assert updated_asset.hash.startswith("blake3:") - - def test_enrichment_updates_both_mime_and_hash( - self, db_engine, temp_dir: Path, session: Session - ): - """Verify both mime_type and hash are set when full enrichment runs.""" - file_path = temp_dir / "model.safetensors" - file_path.write_bytes(b"\x00" * 50) - - asset, ref = _create_stub_asset( - session, str(file_path), "asset-3", "ref-3" - ) - session.commit() - - enrich_asset( - session, - file_path=str(file_path), - reference_id=ref.id, - asset_id=asset.id, - extract_metadata=True, - compute_hash=True, - ) - - session.expire_all() - updated_asset = session.get(Asset, "asset-3") - assert updated_asset is not None - assert updated_asset.mime_type == "application/safetensors" - assert updated_asset.hash is not None - assert updated_asset.hash.startswith("blake3:") - - def test_missing_file_returns_stub_level( - self, db_engine, temp_dir: Path, session: Session - ): - """Verify missing files don't cause errors and return STUB level.""" - file_path = temp_dir / "nonexistent.bin" - - asset, ref = _create_stub_asset( - session, str(file_path), "asset-4", "ref-4" - ) - session.commit() - - new_level = enrich_asset( - session, - file_path=str(file_path), - reference_id=ref.id, - asset_id=asset.id, - extract_metadata=True, - compute_hash=True, - ) - - assert new_level == ENRICHMENT_STUB - - session.expire_all() - updated_asset = session.get(Asset, "asset-4") - assert updated_asset.mime_type is None - assert updated_asset.hash is None - - def test_duplicate_hash_merges_into_existing_asset( - self, db_engine, temp_dir: Path, session: Session - ): - """Verify duplicate files merge into existing asset instead of failing.""" - file_path_1 = temp_dir / "file1.bin" - file_path_2 = temp_dir / "file2.bin" - content = b"identical content" - file_path_1.write_bytes(content) - file_path_2.write_bytes(content) - - asset1, ref1 = _create_stub_asset( - session, str(file_path_1), "asset-dup-1", "ref-dup-1" - ) - asset2, ref2 = _create_stub_asset( - session, str(file_path_2), "asset-dup-2", "ref-dup-2" - ) - session.commit() +from app.assets.database.queries import create_content, create_record - enrich_asset( - session, - file_path=str(file_path_1), - reference_id=ref1.id, - asset_id=asset1.id, - extract_metadata=True, - compute_hash=True, - ) - enrich_asset( - session, - file_path=str(file_path_2), - reference_id=ref2.id, - asset_id=asset2.id, - extract_metadata=True, - compute_hash=True, - ) +def test_unenriched_content_is_represented_by_a_null_hash(session: Session) -> None: + content = create_content(session, "/models/pending.safetensors", hash=None) + record = create_record(session, content.id, "pending.safetensors") - session.expire_all() + assert record.content_id == content.id + assert content.hash is None - updated_asset1 = session.get(Asset, "asset-dup-1") - assert updated_asset1 is not None - assert updated_asset1.hash is not None - updated_asset2 = session.get(Asset, "asset-dup-2") - assert updated_asset2 is None +def test_enrichment_does_not_merge_equal_hash_content_rows(session: Session) -> None: + first = create_content(session, "/models/one.safetensors", hash="digest") + second = create_content(session, "/models/two.safetensors", hash="digest") - updated_ref2 = session.get(AssetReference, "ref-dup-2") - assert updated_ref2 is not None - assert updated_ref2.asset_id == "asset-dup-1" + assert first.id != second.id diff --git a/tests-unit/assets_test/services/test_enrichment_predicate.py b/tests-unit/assets_test/services/test_enrichment_predicate.py new file mode 100644 index 00000000000..94cce7f1ca5 --- /dev/null +++ b/tests-unit/assets_test/services/test_enrichment_predicate.py @@ -0,0 +1,308 @@ +from __future__ import annotations + +import os +from contextlib import contextmanager +from collections.abc import Iterator +from pathlib import Path +from unittest.mock import patch + +import sqlalchemy as sa +from sqlalchemy.orm import Session + +from app.assets.database.queries import create_content, create_record, mark_content_missing +from app.assets.scanner import get_unenriched_assets_for_roots +from app.assets.scanner_changes import ( + is_path_under_prefixes, + live_contents_under_prefixes, +) + +from .path_prefix_cases import expected_prefix_case_paths, prefix_case_paths + + +@contextmanager +def _reuse_session(session: Session) -> Iterator[Session]: + yield session + + +def _raw_system_metadata(session: Session, record_id: str) -> object: + return session.execute( + sa.text("SELECT system_metadata FROM assets WHERE id = :id"), + {"id": record_id}, + ).scalar() + + +def test_create_record_stores_none_system_metadata_as_sql_null( + session: Session, +) -> None: + content = create_content(session, "/models/no-meta.safetensors", hash=None) + + record = create_record(session, content.id, "no-meta.safetensors") + session.commit() + + assert _raw_system_metadata(session, record.id) is None + + +def test_off_mode_returns_metadata_less_seeded_asset( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "fresh.safetensors" + content = create_content(session, str(path), hash=None) + record = create_record(session, content.id, path.name) + session.commit() + record_id = record.id + + with ( + patch("app.assets.scanner.create_session", lambda: _reuse_session(session)), + patch( + "app.assets.scanner.get_scan_prefixes_for_root", + return_value=[str(temp_dir)], + ), + ): + rows = get_unenriched_assets_for_roots(("models",), compute_hashes=False) + + assert record_id in {row.record_id for row in rows} + + +def test_off_mode_excludes_asset_with_system_metadata( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "enriched.safetensors" + content = create_content(session, str(path), hash=None) + record = create_record( + session, + content.id, + path.name, + system_metadata={"architecture": "flux"}, + ) + session.commit() + record_id = record.id + + with ( + patch("app.assets.scanner.create_session", lambda: _reuse_session(session)), + patch( + "app.assets.scanner.get_scan_prefixes_for_root", + return_value=[str(temp_dir)], + ), + ): + rows = get_unenriched_assets_for_roots(("models",), compute_hashes=False) + + assert record_id not in {row.record_id for row in rows} + + +def test_query_pushes_limit_into_sql(session: Session, temp_dir: Path) -> None: + for index in range(5): + path = temp_dir / f"cand-{index}.safetensors" + content = create_content(session, str(path), hash=None) + create_record(session, content.id, path.name) + session.commit() + + statements: list[str] = [] + + def _capture(_conn, _cursor, statement, _params, _context, _executemany) -> None: + statements.append(statement) + + engine = session.bind + sa.event.listen(engine, "before_cursor_execute", _capture) + try: + with ( + patch("app.assets.scanner.create_session", lambda: _reuse_session(session)), + patch( + "app.assets.scanner.get_scan_prefixes_for_root", + return_value=[str(temp_dir)], + ), + ): + rows = get_unenriched_assets_for_roots( + ("models",), compute_hashes=False, limit=2 + ) + finally: + sa.event.remove(engine, "before_cursor_execute", _capture) + + assert len(rows) == 2 + content_selects = [ + s for s in statements if "asset_contents" in s.lower() and s.lower().lstrip().startswith("select") + ] + assert content_selects, f"expected a SELECT over asset_contents; got {statements}" + assert any("limit" in s.lower() for s in content_selects), ( + f"enrichment query must push LIMIT into SQL; got: {content_selects}" + ) + + +def test_prefix_filter_matches_is_path_under_prefixes( + session: Session, temp_dir: Path +) -> None: + prefix = str(temp_dir) + inside_path = os.path.join(prefix, "sub", "inside.safetensors") + sibling_path = prefix + "-sibling" + os.sep + "outside.safetensors" + + inside = create_record( + session, create_content(session, inside_path, hash=None).id, "inside.safetensors" + ) + sibling = create_record( + session, create_content(session, sibling_path, hash=None).id, "outside.safetensors" + ) + session.commit() + + with ( + patch("app.assets.scanner.create_session", lambda: _reuse_session(session)), + patch( + "app.assets.scanner.get_scan_prefixes_for_root", + return_value=[prefix], + ), + ): + returned = { + row.record_id + for row in get_unenriched_assets_for_roots(("models",), compute_hashes=False) + } + + assert is_path_under_prefixes(inside_path, [prefix]) is True + assert is_path_under_prefixes(sibling_path, [prefix]) is False + assert inside.id in returned + assert sibling.id not in returned + + +def _seed_paths(session: Session, paths: list[str]) -> dict[str, str]: + by_record: dict[str, str] = {} + for index, path in enumerate(paths): + content = create_content(session, path, hash=None) + record = create_record(session, content.id, f"case-{index}.safetensors") + by_record[record.id] = content.path + session.commit() + return by_record + + +def _candidate_paths(session: Session, prefix: str) -> set[str]: + with ( + patch("app.assets.scanner.create_session", lambda: _reuse_session(session)), + patch( + "app.assets.scanner.get_scan_prefixes_for_root", + return_value=[prefix], + ), + ): + rows = get_unenriched_assets_for_roots(("models",), compute_hashes=False) + return {row.file_path for row in rows} + + +def test_prefix_filter_result_set_equals_python_predicate( + session: Session, temp_dir: Path +) -> None: + root = str(temp_dir / "root") + corpus = prefix_case_paths(root) + by_record = _seed_paths(session, [path for path, _ in corpus]) + stored = set(by_record.values()) + + returned = _candidate_paths(session, root) + + expected = expected_prefix_case_paths(root) + assert returned == expected + assert expected and expected != stored + + +def test_prefix_holding_metacharacters_matches_only_literal_children( + session: Session, temp_dir: Path +) -> None: + root = str(temp_dir / "a_b%c*d?e[f") + inside_path = os.path.join(root, "inside.safetensors") + decoy_path = os.path.join(str(temp_dir), "aXbYcZdWeQf", "decoy.safetensors") + _seed_paths(session, [inside_path, decoy_path]) + + returned = _candidate_paths(session, root) + + assert is_path_under_prefixes(decoy_path, [root]) is False + assert returned == {inside_path} + + +# --- live_contents_under_prefixes direct pins --- +# These pins exercise live_contents_under_prefixes directly, not the enrichment predicate. + + +def test_live_contents_under_prefixes_returns_empty_for_empty_prefixes( + session: Session, temp_dir: Path +) -> None: + _ = create_content(session, str(temp_dir / "seed.safetensors")) + + returned = {content.path for content in live_contents_under_prefixes(session, [])} + + assert returned == set() + + +def test_live_contents_under_prefixes_matches_directory_and_exact_path_prefixes( + session: Session, temp_dir: Path +) -> None: + content = create_content(session, str(temp_dir / "root" / "model.safetensors")) + + under_directory = {candidate.path for candidate in live_contents_under_prefixes(session, [str(temp_dir / "root")])} + exact_path = {candidate.path for candidate in live_contents_under_prefixes(session, [content.path])} + + assert under_directory == {content.path} + assert exact_path == {content.path} + + +def test_live_contents_under_prefixes_respects_sibling_boundary( + session: Session, temp_dir: Path +) -> None: + sibling_path = str(temp_dir / "a" / "bc" / "model.safetensors") + _ = create_content(session, sibling_path) + + returned = {content.path for content in live_contents_under_prefixes(session, [str(temp_dir / "a" / "b")])} + + assert returned == set() + + +def test_live_contents_under_prefixes_is_case_sensitive( + session: Session, temp_dir: Path +) -> None: + case_different_path = str(temp_dir / "data" / "TEMP" / "model.safetensors") + _ = create_content(session, case_different_path) + + returned = {content.path for content in live_contents_under_prefixes(session, [str(temp_dir / "data" / "temp")])} + + assert returned == set() + + +def test_live_contents_under_prefixes_treats_metacharacters_literally( + session: Session, temp_dir: Path +) -> None: + prefix = temp_dir / "a_b%c*d?e[f" + literal_child = create_content(session, str(prefix / "child.safetensors")) + decoy_path = str(temp_dir / "aXbYc*d?e[f" / "keep.safetensors") + _ = create_content(session, decoy_path) + + returned = {content.path for content in live_contents_under_prefixes(session, [str(prefix)])} + + assert returned == {literal_child.path} + + +def test_live_contents_under_prefixes_matches_any_prefix( + session: Session, temp_dir: Path +) -> None: + content = create_content(session, str(temp_dir / "second" / "model.safetensors")) + prefixes = [str(temp_dir / "first"), str(temp_dir / "second")] + + returned = {candidate.path for candidate in live_contents_under_prefixes(session, prefixes)} + + assert returned == {content.path} + + +def test_live_contents_under_prefixes_excludes_missing_content( + session: Session, temp_dir: Path +) -> None: + content = create_content(session, str(temp_dir / "root" / "missing.safetensors")) + mark_content_missing(session, content.id) + + returned = {candidate.path for candidate in live_contents_under_prefixes(session, [str(temp_dir / "root")])} + + assert returned == set() + + +def test_live_contents_under_prefixes_equals_python_predicate_for_corpus( + session: Session, temp_dir: Path +) -> None: + root = str(temp_dir / "root") + corpus = prefix_case_paths(root) + for path, _ in corpus: + _ = create_content(session, path) + prefixes = [root] + + returned = {content.path for content in live_contents_under_prefixes(session, prefixes)} + + assert returned == expected_prefix_case_paths(root) diff --git a/tests-unit/assets_test/services/test_enrichment_snapshot.py b/tests-unit/assets_test/services/test_enrichment_snapshot.py new file mode 100644 index 00000000000..8e33663cc8a --- /dev/null +++ b/tests-unit/assets_test/services/test_enrichment_snapshot.py @@ -0,0 +1,211 @@ +import os +from pathlib import Path +from unittest.mock import patch + +from app.assets.database.models import Asset, AssetContent +from app.assets.helpers import to_stored_hash +from app.assets.scanner import enrich_asset + + +def _create_unhashed_record(session, path: Path) -> tuple[AssetContent, Asset]: + content = AssetContent( + path=str(path), + hash=None, + size_bytes=path.stat().st_size, + mtime_ns=path.stat().st_mtime_ns, + ) + session.add(content) + session.flush() + record = Asset(content_id=content.id, name=path.name) + session.add(record) + session.commit() + return content, record + + +def test_enrichment_uses_snapshot_hash_not_direct_blake3(session, temp_dir: Path): + path = temp_dir / "stable.bin" + path.write_bytes(b"stable content") + content, record = _create_unhashed_record(session, path) + + with patch( + "app.assets.scanner.snapshot_hash", return_value=("snapshot-digest", path.stat()) + ) as mocked_snapshot_hash: + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=False, + compute_hash=True, + ) + + assert enriched is True + assert session.get(AssetContent, content.id).hash == to_stored_hash("snapshot-digest") + mocked_snapshot_hash.assert_called_once_with(str(path)) + + +def test_enrichment_discards_unstable_hash(session, temp_dir: Path): + path = temp_dir / "unstable.bin" + path.write_bytes(b"unstable content") + content, record = _create_unhashed_record(session, path) + + with patch("app.assets.scanner.snapshot_hash", return_value=None): + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=False, + compute_hash=True, + ) + + assert enriched is False + assert session.get(AssetContent, content.id).hash is None + + +def test_enrichment_discards_metadata_read_from_a_different_file_than_the_hash( + session, temp_dir: Path +): + path = temp_dir / "swapped.bin" + path.write_bytes(b"original bytes") + content, record = _create_unhashed_record(session, path) + + def _replace_file_then_hash(_file_path: str): + path.write_bytes(b"replacement bytes, a different length entirely") + return "replacement-digest", path.stat() + + with patch("app.assets.scanner.snapshot_hash", side_effect=_replace_file_then_hash): + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=True, + compute_hash=True, + ) + + assert enriched is False + session.expire_all() + assert session.get(AssetContent, content.id).hash is None + assert session.get(Asset, record.id).system_metadata is None, ( + "a mismatched hash observation must discard already-computed metadata too, " + "not just the hash" + ) + + +def test_enrichment_discards_result_when_only_the_hashed_mtime_disagrees( + session, temp_dir: Path +): + path = temp_dir / "touched.bin" + path.write_bytes(b"same length bytes") + content, record = _create_unhashed_record(session, path) + later_mtime_ns = path.stat().st_mtime_ns + 5_000_000_000 + + def _touch_file_then_hash(_file_path: str): + os.utime(path, ns=(later_mtime_ns, later_mtime_ns)) + return "rewritten-digest", path.stat() + + with patch("app.assets.scanner.snapshot_hash", side_effect=_touch_file_then_hash): + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=True, + compute_hash=True, + ) + + assert enriched is False + session.expire_all() + assert session.get(AssetContent, content.id).hash is None + assert session.get(Asset, record.id).system_metadata is None, ( + "size matching alone is not proof the file is unchanged; an mtime " + "disagreement alone must also discard the result" + ) + + +def test_enrichment_lands_metadata_and_hash_from_one_stable_observation( + session, temp_dir: Path +): + path = temp_dir / "stable-both.bin" + path.write_bytes(b"stable content for both") + content, record = _create_unhashed_record(session, path) + + with patch( + "app.assets.scanner.snapshot_hash", return_value=("both-digest", path.stat()) + ): + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=True, + compute_hash=True, + ) + + assert enriched is True + session.expire_all() + assert session.get(AssetContent, content.id).hash == to_stored_hash("both-digest") + assert session.get(Asset, record.id).system_metadata == { + "filename": "stable-both.bin", + "file_path": str(path), + "format": "bin", + "content_type": "application/octet-stream", + "content_length": path.stat().st_size, + } + + +def test_enrichment_reports_no_progress_when_a_requested_hash_fails( + session, temp_dir: Path +): + path = temp_dir / "unreadable-for-hashing.bin" + path.write_bytes(b"metadata reads fine, hashing does not") + content, record = _create_unhashed_record(session, path) + + with patch("app.assets.scanner.snapshot_hash", side_effect=PermissionError("denied")): + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=True, + compute_hash=True, + ) + + assert enriched is False, ( + "a record whose requested hash could not be computed has made no progress; " + "counting the metadata as progress keeps the record out of failed_ids, so the " + "enrich pass re-selects it forever" + ) + session.expire_all() + assert session.get(AssetContent, content.id).hash is None + + +def test_off_mode_enrichment_still_lands_metadata_without_a_hash( + session, temp_dir: Path +): + path = temp_dir / "off-mode.bin" + path.write_bytes(b"metadata only") + content, record = _create_unhashed_record(session, path) + + with patch("app.assets.scanner.snapshot_hash") as never_hashed: + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=True, + compute_hash=False, + ) + + never_hashed.assert_not_called() + assert enriched is True + session.expire_all() + assert session.get(AssetContent, content.id).hash is None + assert session.get(Asset, record.id).system_metadata == { + "filename": "off-mode.bin", + "file_path": str(path), + "format": "bin", + "content_type": "application/octet-stream", + "content_length": path.stat().st_size, + } diff --git a/tests-unit/assets_test/services/test_from_hash.py b/tests-unit/assets_test/services/test_from_hash.py new file mode 100644 index 00000000000..53914a6445b --- /dev/null +++ b/tests-unit/assets_test/services/test_from_hash.py @@ -0,0 +1,118 @@ +from unittest.mock import patch + +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import create_content, mark_content_missing +from app.assets.helpers import to_stored_hash +from app.assets.services.ingest import create_from_hash +from app.assets.services.lookup import ( + claim_qualified_content as _real_claim_qualified_content, + refresh_qualified_content as _real_refresh_qualified_content, +) + + +def test_create_from_hash_with_prefixed_hash_finds_existing_content( + mock_create_session, monkeypatch, temp_dir +): + digest = "a" * 64 + path = temp_dir / "existing.bin" + path.write_bytes(b"existing bytes") + monkeypatch.setattr("app.assets.mode.hashing_enabled", lambda: True) + + with mock_create_session() as session: + content = create_content(session, str(path), to_stored_hash(digest), path.stat().st_size) + content_id = content.id + session.commit() + + result = create_from_hash(f"blake3:{digest}", "derived.bin") + + assert result is not None + with mock_create_session() as session: + records = list(session.scalars(select(Asset))) + created_record = session.get(Asset, result.ref.id) + assert created_record is not None + assert created_record.content_id == content_id + assert [record.content_id for record in records] == [content_id] + + +def test_create_from_hash_with_bare_hash_also_works( + mock_create_session, monkeypatch, temp_dir +): + digest = "b" * 64 + path = temp_dir / "existing.bin" + path.write_bytes(b"existing bytes") + monkeypatch.setattr("app.assets.mode.hashing_enabled", lambda: True) + + with mock_create_session() as session: + content = create_content(session, str(path), to_stored_hash(digest), path.stat().st_size) + content_id = content.id + session.commit() + + result = create_from_hash(digest, "derived.bin") + + assert result is not None + with mock_create_session() as session: + records = list(session.scalars(select(Asset))) + created_record = session.get(Asset, result.ref.id) + assert created_record is not None + assert created_record.content_id == content_id + assert [record.content_id for record in records] == [content_id] + + +def _seed_live_content(mock_create_session, path, digest): + with mock_create_session() as session: + content = create_content(session, str(path), to_stored_hash(digest), path.stat().st_size) + content_id = content.id + session.commit() + return content_id + + +def test_content_retired_between_lookup_and_claim_mints_nothing( + mock_create_session, monkeypatch, temp_dir +): + digest = "c" * 64 + path = temp_dir / "retired.bin" + path.write_bytes(b"retired bytes") + monkeypatch.setattr("app.assets.mode.hashing_enabled", lambda: True) + content_id = _seed_live_content(mock_create_session, path, digest) + + def retire_then_claim(session, claimed_id, hash): + mark_content_missing(session, claimed_id) + session.commit() + return _real_claim_qualified_content(session, claimed_id, hash) + + with patch("app.assets.services.ingest.claim_qualified_content", retire_then_claim): + result = create_from_hash(f"blake3:{digest}", "derived.bin") + + assert result is None + with mock_create_session() as session: + assert list(session.scalars(select(Asset))) == [] + retired = session.get(AssetContent, content_id) + assert retired is not None + assert retired.hash == to_stored_hash(digest) + assert retired.path == str(path) + + +def test_file_vanishing_between_claim_and_refresh_mints_nothing( + mock_create_session, monkeypatch, temp_dir +): + digest = "d" * 64 + path = temp_dir / "vanishing.bin" + path.write_bytes(b"vanishing bytes") + monkeypatch.setattr("app.assets.mode.hashing_enabled", lambda: True) + content_id = _seed_live_content(mock_create_session, path, digest) + + def delete_file_then_refresh(session, refreshed_id): + path.unlink() + return _real_refresh_qualified_content(session, refreshed_id) + + with patch( + "app.assets.services.ingest.refresh_qualified_content", delete_file_then_refresh + ): + result = create_from_hash(f"blake3:{digest}", "derived.bin") + + assert result is None + with mock_create_session() as session: + assert list(session.scalars(select(Asset))) == [] + assert session.get(AssetContent, content_id) is not None diff --git a/tests-unit/assets_test/services/test_hash_mode_state.py b/tests-unit/assets_test/services/test_hash_mode_state.py new file mode 100644 index 00000000000..372f22783c5 --- /dev/null +++ b/tests-unit/assets_test/services/test_hash_mode_state.py @@ -0,0 +1,58 @@ +from unittest.mock import patch + +from app.assets.database.models import AssetContent +from app.assets.services.hash_mode_state import ( + clear_transition_queue, + drain_transition_queue, + enqueue_transition_work, + pending_transition_count, + read_stored_mode, + record_transition_intent, + write_stored_mode, +) + + +def test_absent_row_off_mode_no_transition(session): + with patch("app.assets.services.hash_mode_state._mode.hashing_enabled", return_value=False): + assert record_transition_intent(session) is None + assert read_stored_mode(session) == "off" + + +def test_empty_drain_keeps_off_mode_ready_for_a_later_on_transition(session): + write_stored_mode(session, "off") + + drain_transition_queue(session) + + with patch("app.assets.services.hash_mode_state._mode.hashing_enabled", return_value=True): + assert record_transition_intent(session) == "off_to_on" + + +def test_off_to_on_enqueues_null_rows(session): + session.add(AssetContent(path="/tmp/null", hash=None)) + session.add(AssetContent(path="/tmp/hashed", hash="abc")) + write_stored_mode(session, "off") + with patch("app.assets.services.hash_mode_state._mode.hashing_enabled", return_value=True): + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + assert transition == "off_to_on" + assert pending_transition_count() == 2 + assert read_stored_mode(session) == "off" + clear_transition_queue() + + +def test_on_to_off_freezes(session): + write_stored_mode(session, "on") + with patch("app.assets.services.hash_mode_state._mode.hashing_enabled", return_value=False): + transition = record_transition_intent(session) + assert transition == "on_to_off" + assert read_stored_mode(session) == "off" + assert pending_transition_count() == 0 + + +def test_mode_stays_off_during_transition(session): + session.add(AssetContent(path="/tmp/null", hash=None)) + write_stored_mode(session, "off") + with patch("app.assets.services.hash_mode_state._mode.hashing_enabled", return_value=True): + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + assert read_stored_mode(session) == "off" diff --git a/tests-unit/assets_test/services/test_ingest.py b/tests-unit/assets_test/services/test_ingest.py index 7fa882df0b3..523d41c1b44 100644 --- a/tests-unit/assets_test/services/test_ingest.py +++ b/tests-unit/assets_test/services/test_ingest.py @@ -1,568 +1,23 @@ -"""Tests for ingest services.""" -from contextlib import contextmanager from pathlib import Path -from unittest.mock import patch -import pytest -from PIL import Image -from sqlalchemy.orm import Session as SASession, Session +from sqlalchemy import select -from app.assets.database.models import Asset, AssetReference, AssetReferenceTag, Tag -from app.assets.database.queries import get_reference_tags -from app.assets.helpers import get_utc_now -from app.assets.services.ingest import ( - _ingest_file_from_path, - _register_existing_asset, - ingest_existing_file, -) +import folder_paths +from app.assets.database.models import Asset, AssetContent +from app.assets.services.ingest import register_executed_output -def _make_png(path: Path, size: tuple[int, int]) -> Path: - Image.new("RGB", size, color=(80, 120, 200)).save(path, format="PNG") - return path +def test_output_registration_creates_separate_record_and_content_rows(mock_create_session) -> None: + path = Path(folder_paths.get_output_directory()) / "result-b-schema.txt" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("result") + registered = register_executed_output(str(path), job_id="job") -class TestIngestFileFromPath: - def test_creates_asset_and_reference(self, mock_create_session, temp_dir: Path, session: Session): - file_path = temp_dir / "test_file.bin" - file_path.write_bytes(b"test content") - - result = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:abc123", - size_bytes=12, - mtime_ns=1234567890000000000, - mime_type="application/octet-stream", - ) - - assert result.asset_created is True - assert result.ref_created is True - assert result.reference_id is not None - - # Verify DB state - assets = session.query(Asset).all() - assert len(assets) == 1 - assert assets[0].hash == "blake3:abc123" - - refs = session.query(AssetReference).all() - assert len(refs) == 1 - assert refs[0].file_path == str(file_path) - - def test_creates_reference_when_name_provided(self, mock_create_session, temp_dir: Path, session: Session): - file_path = temp_dir / "model.safetensors" - file_path.write_bytes(b"model data") - - result = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:def456", - size_bytes=10, - mtime_ns=1234567890000000000, - mime_type="application/octet-stream", - info_name="My Model", - owner_id="user1", - ) - - assert result.asset_created is True - assert result.reference_id is not None - - ref = session.query(AssetReference).first() - assert ref is not None - assert ref.name == "My Model" - assert ref.owner_id == "user1" - - def test_creates_tags_when_provided(self, mock_create_session, temp_dir: Path, session: Session): - file_path = temp_dir / "tagged.bin" - file_path.write_bytes(b"data") - - result = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:ghi789", - size_bytes=4, - mtime_ns=1234567890000000000, - info_name="Tagged Asset", - tags=["models", "checkpoints"], - ) - - assert result.reference_id is not None - - # Verify tags were created and linked - tags = session.query(Tag).all() - tag_names = {t.name for t in tags} - assert "models" in tag_names - assert "checkpoints" in tag_names - - ref_tags = get_reference_tags(session, reference_id=result.reference_id) - assert set(ref_tags) == {"models", "checkpoints"} - - def test_path_derived_tags_use_automatic_origin( - self, mock_create_session, temp_dir: Path, session: Session - ): - input_dir = temp_dir / "input" - output_dir = temp_dir / "output" - temp_root = temp_dir / "temp" - for directory in (input_dir, output_dir, temp_root): - directory.mkdir() - file_path = input_dir / "pasted" / "tagged.png" - file_path.parent.mkdir() - file_path.write_bytes(b"data") - - with ( - patch("app.assets.services.path_utils.folder_paths") as mock_fp, - patch( - "app.assets.services.path_utils.get_comfy_models_folders", - return_value=[], - ), - ): - mock_fp.get_input_directory.return_value = str(input_dir) - mock_fp.get_output_directory.return_value = str(output_dir) - mock_fp.get_temp_directory.return_value = str(temp_root) - - result = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:pathorigin", - size_bytes=4, - mtime_ns=1234567890000000000, - info_name="Tagged Asset", - tags=["input", "manual-label"], - ) - - assert result.reference_id is not None - links = session.query(AssetReferenceTag).filter_by( - asset_reference_id=result.reference_id - ) - origin_by_tag = {link.tag_name: link.origin for link in links} - assert origin_by_tag["input"] == "automatic" - assert origin_by_tag["pasted"] == "automatic" - assert origin_by_tag["manual-label"] == "manual" - - def test_idempotent_upsert(self, mock_create_session, temp_dir: Path, session: Session): - file_path = temp_dir / "dup.bin" - file_path.write_bytes(b"content") - - # First ingest - r1 = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:repeat", - size_bytes=7, - mtime_ns=1234567890000000000, - ) - assert r1.asset_created is True - - # Second ingest with same hash - should update, not create - r2 = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:repeat", - size_bytes=7, - mtime_ns=1234567890000000001, # different mtime - ) - assert r2.asset_created is False - assert r2.ref_created is False - assert r2.ref_updated is True - - # Still only one asset - assets = session.query(Asset).all() - assert len(assets) == 1 - - def test_validates_preview_id(self, mock_create_session, temp_dir: Path, session: Session): - file_path = temp_dir / "with_preview.bin" - file_path.write_bytes(b"data") - - # Create a preview asset and reference - preview_asset = Asset(hash="blake3:preview", size_bytes=100) - session.add(preview_asset) - session.flush() - from app.assets.helpers import get_utc_now - now = get_utc_now() - preview_ref = AssetReference( - asset_id=preview_asset.id, name="preview.png", owner_id="", - created_at=now, updated_at=now, last_access_time=now, - ) - session.add(preview_ref) - session.commit() - preview_id = preview_ref.id - - result = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:main", - size_bytes=4, - mtime_ns=1234567890000000000, - info_name="With Preview", - preview_id=preview_id, - ) - - assert result.reference_id is not None - ref = session.query(AssetReference).filter_by(id=result.reference_id).first() - assert ref.preview_id == preview_id - - def test_invalid_preview_id_is_cleared(self, mock_create_session, temp_dir: Path, session: Session): - file_path = temp_dir / "bad_preview.bin" - file_path.write_bytes(b"data") - - result = _ingest_file_from_path( - abs_path=str(file_path), - asset_hash="blake3:badpreview", - size_bytes=4, - mtime_ns=1234567890000000000, - info_name="Bad Preview", - preview_id="nonexistent-uuid", - ) - - assert result.reference_id is not None - ref = session.query(AssetReference).filter_by(id=result.reference_id).first() - assert ref.preview_id is None - - -class TestRegisterExistingAsset: - def test_creates_reference_for_existing_asset(self, mock_create_session, session: Session): - # Create existing asset - asset = Asset(hash="blake3:existing", size_bytes=1024, mime_type="image/png") - session.add(asset) - session.commit() - - result = _register_existing_asset( - asset_hash="blake3:existing", - name="Registered Asset", - user_metadata={"key": "value"}, - tags=["models"], - ) - - assert result.created is True - assert "models" in result.tags - - # Verify by re-fetching from DB - session.expire_all() - refs = session.query(AssetReference).filter_by(name="Registered Asset").all() - assert len(refs) == 1 - - def test_creates_new_reference_even_with_same_name(self, mock_create_session, session: Session): - # Create asset and reference - asset = Asset(hash="blake3:withref", size_bytes=512) - session.add(asset) - session.flush() - - from app.assets.helpers import get_utc_now - ref = AssetReference( - owner_id="", - name="Existing Ref", - asset_id=asset.id, - created_at=get_utc_now(), - updated_at=get_utc_now(), - last_access_time=get_utc_now(), - ) - session.add(ref) - session.flush() - ref_id = ref.id - session.commit() - - result = _register_existing_asset( - asset_hash="blake3:withref", - name="Existing Ref", - owner_id="", - ) - - # Multiple files with same name are allowed - assert result.created is True - - # Verify two AssetReferences exist for this name - session.expire_all() - refs = session.query(AssetReference).filter_by(name="Existing Ref").all() - assert len(refs) == 2 - assert ref_id in [r.id for r in refs] - - def test_raises_for_nonexistent_hash(self, mock_create_session): - with pytest.raises(ValueError, match="No asset with hash"): - _register_existing_asset( - asset_hash="blake3:doesnotexist", - name="Fail", - ) - - def test_applies_tags_to_new_reference(self, mock_create_session, session: Session): - asset = Asset(hash="blake3:tagged", size_bytes=256) - session.add(asset) - session.commit() - - result = _register_existing_asset( - asset_hash="blake3:tagged", - name="Tagged Ref", - tags=["alpha", "beta"], - ) - - assert result.created is True - assert set(result.tags) == {"alpha", "beta"} - - -class TestIngestExistingFileTagFK: - """Regression: ingest_existing_file must seed Tag rows before inserting - AssetReferenceTag rows, otherwise FK enforcement raises IntegrityError.""" - - def test_creates_tag_rows_before_reference_tags(self, db_engine_fk, temp_dir: Path): - """With PRAGMA foreign_keys=ON, tags must exist in the tags table - before they can be referenced in asset_reference_tags.""" - - @contextmanager - def _create_session(): - with SASession(db_engine_fk) as sess: - yield sess - - file_path = temp_dir / "output.png" - file_path.write_bytes(b"image data") - - with patch("app.assets.services.ingest.create_session", _create_session), \ - patch( - "app.assets.services.ingest.get_name_and_tags_from_asset_path", - return_value=("output.png", ["output"]), - ): - result = ingest_existing_file( - abs_path=str(file_path), - extra_tags=["my-job"], - ) - - assert result is True - - with SASession(db_engine_fk) as sess: - tag_names = {t.name for t in sess.query(Tag).all()} - assert "output" in tag_names - assert "my-job" in tag_names - - ref_tags = sess.query(AssetReferenceTag).all() - ref_tag_names = {rt.tag_name for rt in ref_tags} - assert "output" in ref_tag_names - - -class TestIngestExistingFileLoaderPath: - """Outputs saved into a subfolder must persist the subfolder-qualified - loader path, not the bare basename (regression: spec["fname"] was - os.path.basename).""" - - def test_subfoldered_output_persists_relative_loader_path( - self, mock_create_session, temp_dir: Path, session: Session - ): - input_dir = temp_dir / "input" - output_dir = temp_dir / "output" - temp_root = temp_dir / "temp" - for directory in (input_dir, output_dir, temp_root): - directory.mkdir() - file_path = output_dir / "sub" / "img_00001_.png" - file_path.parent.mkdir() - file_path.write_bytes(b"image data") - - with ( - patch("app.assets.services.path_utils.folder_paths") as mock_fp, - patch( - "app.assets.services.path_utils.get_comfy_models_folders", - return_value=[], - ), - ): - mock_fp.get_input_directory.return_value = str(input_dir) - mock_fp.get_output_directory.return_value = str(output_dir) - mock_fp.get_temp_directory.return_value = str(temp_root) - - assert ingest_existing_file(abs_path=str(file_path)) is True - - ref = ( - session.query(AssetReference) - .filter_by(file_path=str(file_path)) - .one() - ) - assert ref.loader_path == "sub/img_00001_.png" - assert (ref.user_metadata or {}).get("filename") == "sub/img_00001_.png" - - -class TestIngestImageDimensions: - """system_metadata should carry {kind, width, height} for image assets.""" - - def test_image_asset_emits_dimensions( - self, mock_create_session, temp_dir: Path, session: Session - ): - f = _make_png(temp_dir / "shot.png", (640, 480)) - - result = _ingest_file_from_path( - abs_path=str(f), - asset_hash="blake3:img1", - size_bytes=f.stat().st_size, - mtime_ns=1234567890000000000, - mime_type="image/png", - ) - - ref = session.query(AssetReference).filter_by(id=result.reference_id).first() - assert ref.system_metadata == { - "kind": "image", - "width": 640, - "height": 480, - } - - def test_non_image_asset_leaves_system_metadata_empty( - self, mock_create_session, temp_dir: Path, session: Session - ): - f = temp_dir / "model.safetensors" - f.write_bytes(b"not an image") - - result = _ingest_file_from_path( - abs_path=str(f), - asset_hash="blake3:safetensors1", - size_bytes=f.stat().st_size, - mtime_ns=1234567890000000000, - mime_type="application/octet-stream", - ) - - ref = session.query(AssetReference).filter_by(id=result.reference_id).first() - assert ref.system_metadata in (None, {}) - - def test_preserves_existing_system_metadata_keys( - self, mock_create_session, temp_dir: Path, session: Session - ): - f = _make_png(temp_dir / "annotated.png", (100, 200)) - - # First pass populates a sentinel system_metadata key (simulating prior - # enricher write). - result = _ingest_file_from_path( - abs_path=str(f), - asset_hash="blake3:img-merge", - size_bytes=f.stat().st_size, - mtime_ns=1234567890000000000, - mime_type="image/png", - ) - ref = session.query(AssetReference).filter_by(id=result.reference_id).first() - ref.system_metadata = {**(ref.system_metadata or {}), "source_url": "https://example/x.png"} - session.commit() - - # Second pass with the same path triggers the merge code path again. - _ingest_file_from_path( - abs_path=str(f), - asset_hash="blake3:img-merge", - size_bytes=f.stat().st_size, - mtime_ns=1234567890000000001, - mime_type="image/png", - ) - - session.refresh(ref) - assert ref.system_metadata["kind"] == "image" - assert ref.system_metadata["width"] == 100 - assert ref.system_metadata["height"] == 200 - assert ref.system_metadata["source_url"] == "https://example/x.png" - - -class TestRegisterExistingAssetBackfill: - """The from-hash path back-fills dimensions from a sibling reference.""" - - def _add_reference( - self, - session: Session, - asset: Asset, - name: str, - system_metadata: dict | None = None, - ) -> AssetReference: - now = get_utc_now() - ref = AssetReference( - asset_id=asset.id, - name=name, - owner_id="", - created_at=now, - updated_at=now, - last_access_time=now, - system_metadata=system_metadata or {}, - ) - session.add(ref) - session.flush() - return ref - - def test_backfills_dimensions_from_sibling_image_reference( - self, mock_create_session, session: Session - ): - asset = Asset(hash="blake3:shared", size_bytes=2048, mime_type="image/png") - session.add(asset) - session.flush() - self._add_reference( - session, - asset, - name="original.png", - system_metadata={"kind": "image", "width": 800, "height": 600}, - ) - session.commit() - - result = _register_existing_asset( - asset_hash="blake3:shared", - name="from_hash.png", - owner_id="user-x", - ) - - ref = session.query(AssetReference).filter_by(id=result.ref.id).first() - assert ref.system_metadata.get("kind") == "image" - assert ref.system_metadata.get("width") == 800 - assert ref.system_metadata.get("height") == 600 - - def test_no_backfill_when_sibling_has_no_image_metadata( - self, mock_create_session, session: Session - ): - asset = Asset(hash="blake3:nodims", size_bytes=2048, mime_type="image/png") - session.add(asset) - session.flush() - self._add_reference( - session, - asset, - name="original.png", - system_metadata={"base_model": "flux"}, # no kind=image - ) - session.commit() - - result = _register_existing_asset( - asset_hash="blake3:nodims", - name="from_hash.png", - owner_id="user-x", - ) - - ref = session.query(AssetReference).filter_by(id=result.ref.id).first() - meta = ref.system_metadata or {} - assert "kind" not in meta - assert "width" not in meta - assert "height" not in meta - - def test_no_backfill_when_no_sibling_exists( - self, mock_create_session, session: Session - ): - asset = Asset(hash="blake3:lonely", size_bytes=1024, mime_type="image/png") - session.add(asset) - session.commit() - - result = _register_existing_asset( - asset_hash="blake3:lonely", - name="solo.png", - owner_id="user-x", - ) - - ref = session.query(AssetReference).filter_by(id=result.ref.id).first() - assert ref.system_metadata in (None, {}) - - def test_backfill_preserves_caller_supplied_keys( - self, mock_create_session, session: Session - ): - asset = Asset(hash="blake3:preserve", size_bytes=2048, mime_type="image/png") - session.add(asset) - session.flush() - self._add_reference( - session, - asset, - name="original.png", - system_metadata={"kind": "image", "width": 1024, "height": 768}, - ) - session.commit() - - # Simulate a from-hash path where the new reference already carries - # some system_metadata (e.g. a download-provenance source_url written - # by an earlier step). The back-fill must merge dim keys without - # clobbering existing keys. - result = _register_existing_asset( - asset_hash="blake3:preserve", - name="from_hash.png", - owner_id="user-x", - ) - ref = session.query(AssetReference).filter_by(id=result.ref.id).first() - # Seed a sentinel key and re-run back-fill via a second register call - # to exercise the merge path with pre-existing data. - ref.system_metadata = {**(ref.system_metadata or {}), "source_url": "https://example/p"} - session.commit() - - assert ref.system_metadata.get("source_url") == "https://example/p" - assert ref.system_metadata.get("kind") == "image" - assert ref.system_metadata.get("width") == 1024 - assert ref.system_metadata.get("height") == 768 + with mock_create_session() as session: + record = session.get(Asset, registered.id) + content = session.scalar(select(AssetContent).where(AssetContent.id == registered.content_id)) + assert record is not None + assert content is not None + assert record.content_id == content.id + assert record.job_id == "job" diff --git a/tests-unit/assets_test/services/test_ingest_b.py b/tests-unit/assets_test/services/test_ingest_b.py new file mode 100644 index 00000000000..dd2a164b2c6 --- /dev/null +++ b/tests-unit/assets_test/services/test_ingest_b.py @@ -0,0 +1,82 @@ +import os + +import pytest +from sqlalchemy import select + +import app.assets.mode as mode_module +import folder_paths +from app.assets.database.models import Asset, AssetContent +from app.assets.services.ingest import register_executed_output + + +@pytest.fixture(autouse=True) +def hashing_off(): + class FakeArgs: + enable_asset_hashing = False + + mode_module.init(FakeArgs()) + yield + mode_module.init(None) + + +def test_new_path_save_off_mode_hash_null(mock_create_session): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + f = os.path.join(output_dir, "test_ingest_b_new.png") + with open(f, "wb") as fh: + fh.write(b"pixels") + + try: + record = register_executed_output(f, job_id="job1") + record_id = record.id + with mock_create_session() as session: + content = session.execute( + select(AssetContent).where(AssetContent.path == os.path.abspath(f)) + ).scalar_one() + assert content.hash is None + assert content.is_missing is False + asset = session.execute( + select(Asset).where(Asset.id == record_id) + ).scalar_one() + assert asset.job_id == "job1" + finally: + if os.path.exists(f): + os.unlink(f) + + +def test_overwrite_at_live_path_marks_old_missing(mock_create_session): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + f = os.path.join(output_dir, "test_ingest_b_overwrite.png") + + try: + with open(f, "wb") as fh: + fh.write(b"v1") + r1 = register_executed_output(f, job_id="job1") + old_content_id = r1.content_id + old_record_id = r1.id + + with open(f, "wb") as fh: + fh.write(b"v2") + r2 = register_executed_output(f, job_id="job2") + new_record_id = r2.id + + with mock_create_session() as session: + old_content = session.execute( + select(AssetContent).where(AssetContent.id == old_content_id) + ).scalar_one() + assert old_content.is_missing is True, "Old content should be marked missing" + + old_record = session.execute( + select(Asset).where(Asset.id == old_record_id) + ).scalar_one() + assert old_record.job_id == "job1", "Old record's job_id must not be mutated" + + new_record = session.execute( + select(Asset).where(Asset.id == new_record_id) + ).scalar_one() + assert new_record.job_id == "job2" + assert new_record_id != old_record_id + finally: + if os.path.exists(f): + os.unlink(f) diff --git a/tests-unit/assets_test/services/test_ingest_orphan_content.py b/tests-unit/assets_test/services/test_ingest_orphan_content.py new file mode 100644 index 00000000000..ae209945ca6 --- /dev/null +++ b/tests-unit/assets_test/services/test_ingest_orphan_content.py @@ -0,0 +1,168 @@ +import os +import uuid + +import pytest +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +import app.assets.mode as mode_module +import folder_paths +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries import create_record as create_record_query +from app.assets.scanner import SeedAssetSpec, seed_asset_specs +from app.assets.services import ingest +from app.assets.services.ingest import register_file_in_place, upload_from_temp_path + + +@pytest.fixture +def hashing_off(): + class FakeArgs: + enable_asset_hashing = False + + mode_module.init(FakeArgs()) + yield + mode_module.init(None) + + +def _write_temp(content: bytes) -> str: + uploads_root = os.path.join( + folder_paths.get_temp_directory(), "uploads", uuid.uuid4().hex + ) + os.makedirs(uploads_root, exist_ok=True) + path = os.path.join(uploads_root, ".upload.part") + with open(path, "wb") as file: + file.write(content) + return path + + +def _raise_create_record(*_args, **_kwargs): + raise RuntimeError("forced create_record failure") + + +def _live_content_count(session: Session) -> int: + return session.scalar( + select(func.count()) + .select_from(AssetContent) + .where(AssetContent.is_missing.is_(False)) + ) + + +def test_upload_record_failure_discards_newly_inserted_content( + mock_create_session, hashing_off, monkeypatch: pytest.MonkeyPatch +) -> None: + content = b"orphan-upload-" + uuid.uuid4().bytes + temp_path = _write_temp(content) + monkeypatch.setattr(ingest, "create_record", _raise_create_record) + + with pytest.raises(RuntimeError, match="forced create_record failure"): + upload_from_temp_path( + temp_path=temp_path, + name="orphan.bin", + tags=["output"], + client_filename="orphan.bin", + ) + + with mock_create_session() as session: + assert _live_content_count(session) == 0 + + +def test_register_file_in_place_discards_content_on_record_failure( + mock_create_session, hashing_off, monkeypatch: pytest.MonkeyPatch +) -> None: + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, f"orphan_inplace_{uuid.uuid4().hex}.png") + with open(path, "wb") as file: + file.write(b"orphan-inplace-" + uuid.uuid4().bytes) + monkeypatch.setattr(ingest, "create_record", _raise_create_record) + + try: + with pytest.raises(RuntimeError, match="forced create_record failure"): + register_file_in_place(abs_path=path, name="orphan_inplace.png", tags=["output"]) + + with mock_create_session() as session: + assert _live_content_count(session) == 0 + finally: + if os.path.exists(path): + os.unlink(path) + + +def _seed_spec(path: str, size_bytes: int, mtime_ns: int, name: str) -> SeedAssetSpec: + return { + "abs_path": path, + "size_bytes": size_bytes, + "mtime_ns": mtime_ns, + "info_name": name, + "tags": ["input"], + "fname": name, + "metadata": None, + "mime_type": None, + "job_id": None, + } + + +def _content_at(session: Session, path: str) -> AssetContent | None: + return session.scalar(select(AssetContent).where(AssetContent.path == path)) + + +def _reference_count(session: Session, content_id: str) -> int: + return session.scalar( + select(func.count()).select_from(Asset).where(Asset.content_id == content_id) + ) + + +def _orphaned_content_paths(session: Session) -> list[str]: + return [ + content.path + for content in session.scalars(select(AssetContent)) + if _reference_count(session, content.id) == 0 + ] + + +def test_seed_asset_specs_orphans_nothing_and_keeps_earlier_specs_on_record_failure( + session: Session, tmp_path, monkeypatch: pytest.MonkeyPatch +) -> None: + specs: list[SeedAssetSpec] = [] + paths: dict[str, str] = {} + fail_name = "vanished.bin" + for name in ("first.bin", fail_name, "last.bin"): + file_path = tmp_path / name + file_path.write_bytes(name.encode()) + stat_result = file_path.stat() + paths[name] = str(file_path) + specs.append( + _seed_spec(str(file_path), stat_result.st_size, stat_result.st_mtime_ns, name) + ) + + attempted: list[str] = [] + + def _create_record_or_raise(session_arg, content_id, name, *args, **kwargs): + attempted.append(name) + if name == fail_name: + raise RuntimeError("forced create_record failure") + return create_record_query(session_arg, content_id, name, *args, **kwargs) + + monkeypatch.setattr("app.assets.scanner.create_record", _create_record_or_raise) + + with pytest.raises(RuntimeError, match="forced create_record failure"): + seed_asset_specs(session, specs) + session.rollback() + + assert _content_at(session, paths[fail_name]) is None, ( + "the failed spec's content must not outlive the record that would have referenced it" + ) + assert session.scalar(select(Asset).where(Asset.name == fail_name)) is None + assert _orphaned_content_paths(session) == [] + + survivor = _content_at(session, paths["first.bin"]) + assert survivor is not None, ( + "a spec that already succeeded must not be retroactively erased by a later " + "unrelated failure; its savepoint was released before that failure happened" + ) + assert session.scalar(select(Asset).where(Asset.name == "first.bin")) is not None + assert _reference_count(session, survivor.id) == 1 + + assert attempted == ["first.bin", fail_name] + assert _content_at(session, paths["last.bin"]) is None, ( + "the raise aborts the loop, so the spec after the failed one is never attempted" + ) diff --git a/tests-unit/assets_test/services/test_lifecycle.py b/tests-unit/assets_test/services/test_lifecycle.py new file mode 100644 index 00000000000..4010a770c57 --- /dev/null +++ b/tests-unit/assets_test/services/test_lifecycle.py @@ -0,0 +1,333 @@ +import logging +import os +import tempfile +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session +from sqlalchemy.orm import Session as SASession + +from app.assets import lifecycle +from app.assets.database.models import Asset, AssetContent, Base +from app.assets.database.queries.records import create_content, create_record +from app.assets.lifecycle import ( + cleanup_temp_filesystem, + get_excluded_scan_roots, + run_asset_shutdown_cleanup, + run_asset_startup, + run_startup, + wipe_temp_db_rows, +) +from app.assets.scanner import get_temp_prefixes, sync_temp_references_safely +from app.assets.scanner_changes import is_path_under_prefixes +from app.assets.seeder import asset_seeder +from app.assets.services import hash_mode_state +from app.assets.services.hash_mode_state import clear_transition_queue, write_stored_mode + +from .path_prefix_cases import expected_prefix_case_paths, prefix_case_paths + + +@pytest.fixture(autouse=True) +def autoclean_unit_test_assets(): + lifecycle._excluded_scan_roots.clear() + yield + lifecycle._excluded_scan_roots.clear() + + +@pytest.fixture(autouse=True) +def autoclean_transition_queue(): + clear_transition_queue() + lifecycle._hash_mode_transition = None + yield + clear_transition_queue() + lifecycle._hash_mode_transition = None + + +@pytest.fixture +def session(): + engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + with Session(engine) as sess: + yield sess + + +@pytest.fixture +def mock_create_session(session): + engine = session.bind + + @contextmanager + def _create_session(): + with SASession(engine) as sess: + yield sess + + with patch("app.assets.lifecycle.create_session", _create_session), \ + patch("app.database.db.create_session", _create_session): + yield _create_session + + +@pytest.fixture +def comfy_dirs(): + with tempfile.TemporaryDirectory() as base: + temp_dir = Path(base) / "temp" + temp_dir.mkdir() + with patch("folder_paths.get_temp_directory", return_value=str(temp_dir)): + yield temp_dir + + +def _seed_temp_rows(session: Session, temp_dir: Path) -> tuple[str, str]: + temp_file = temp_dir / "preview.png" + temp_file.write_bytes(b"\x00" * 10) + content = create_content(session, path=str(temp_file)) + record = create_record(session, content_id=content.id, name="preview.png") + session.commit() + return record.id, content.id + + +def test_startup_order_wipe_before_rmtree_before_seeder(mock_create_session): + calls: list[str] = [] + + def _wipe(session): + calls.append("wipe") + return 0, 0 + + with ( + patch("app.assets.lifecycle.wipe_temp_db_rows", side_effect=_wipe), + patch("app.assets.lifecycle.cleanup_temp_filesystem", side_effect=lambda: calls.append("rmtree") or True), + patch("app.assets.lifecycle.enqueue_mode_transition_work", side_effect=lambda: calls.append("enqueue")), + patch("app.assets.lifecycle.start_asset_seeder", side_effect=lambda: calls.append("seeder") or True), + ): + run_asset_startup() + + assert calls == ["wipe", "rmtree", "enqueue", "seeder"] + + +@pytest.mark.parametrize("wipe_fails", [False, True], ids=["wipe_succeeds", "wipe_fails"]) +def test_startup_defers_transition_drain_to_the_seeder( + session, mock_create_session, tmp_path, monkeypatch, wipe_fails +): + asset_file = tmp_path / "model.safetensors" + asset_file.write_bytes(b"weights") + stat = asset_file.stat() + create_content(session, path=str(asset_file), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + write_stored_mode(session, "off") + session.commit() + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + + wipe_side_effect = RuntimeError("db wipe failed") if wipe_fails else (lambda _session: (0, 0)) + + with ( + patch("app.assets.lifecycle.wipe_temp_db_rows", side_effect=wipe_side_effect), + patch("app.assets.lifecycle.cleanup_temp_filesystem", return_value=True), + patch("app.assets.lifecycle.start_asset_seeder", return_value=True), + patch.object(hash_mode_state, "drain_transition_queue") as drain_spy, + ): + lifecycle.record_hash_mode_transition_intent() + run_asset_startup() + + drain_spy.assert_not_called() + assert hash_mode_state.pending_transition_count() > 0 + assert hash_mode_state.read_stored_mode(session) == "off" + + +def test_db_wipe_failure_skips_rmtree_and_continues(mock_create_session): + with ( + patch("app.assets.lifecycle.wipe_temp_db_rows", side_effect=RuntimeError("db wipe failed")), + patch("app.assets.lifecycle.cleanup_temp_filesystem") as cleanup_mock, + patch("app.assets.lifecycle.start_asset_seeder", return_value=True) as seeder_mock, + ): + run_asset_startup() + + cleanup_mock.assert_not_called() + seeder_mock.assert_called_once() + + +def test_run_startup_disabled_sweeps_temp_filesystem_without_db_work(comfy_dirs): + stale = comfy_dirs / "stale.png" + stale.write_bytes(b"\x00" * 10) + assert stale.exists() + + with ( + patch("app.assets.lifecycle.run_asset_startup") as asset_startup_mock, + patch("app.assets.lifecycle.wipe_temp_db_rows") as wipe_mock, + patch("app.assets.lifecycle.start_asset_seeder") as seeder_mock, + ): + run_startup(enable_assets=False) + + assert not stale.exists() + asset_startup_mock.assert_not_called() + wipe_mock.assert_not_called() + seeder_mock.assert_not_called() + + +def test_run_startup_enabled_delegates_to_asset_startup_not_bare_sweep(): + with ( + patch("app.assets.lifecycle.run_asset_startup") as asset_startup_mock, + patch("app.assets.lifecycle.cleanup_temp_filesystem") as cleanup_mock, + ): + run_startup(enable_assets=True) + + asset_startup_mock.assert_called_once_with() + cleanup_mock.assert_not_called() + + +def test_run_startup_logs_and_absorbs_disabled_filesystem_failure(caplog): + with patch("app.assets.lifecycle.cleanup_temp_filesystem", side_effect=RuntimeError("filesystem failure")): + run_startup(enable_assets=False) + + assert "Asset startup maintenance failed" in caplog.text + + +def test_rmtree_failure_excludes_temp_from_scan(session, comfy_dirs, mock_create_session): + record_id, content_id = _seed_temp_rows(session, comfy_dirs) + + wipe_temp_db_rows(session) + session.commit() + assert session.get(Asset, record_id) is None + + with patch("app.assets.lifecycle.shutil.rmtree", side_effect=OSError("busy")): + assert cleanup_temp_filesystem() is False + + assert str(comfy_dirs) in get_excluded_scan_roots() + assert get_temp_prefixes() == [] + + residual = comfy_dirs / "leftover.png" + residual.write_bytes(b"\x00" * 10) + + with patch("app.assets.scanner.create_session", mock_create_session): + sync_temp_references_safely() + + assert session.scalars(select(Asset)).all() == [] + + +def test_shutdown_skips_cleanup_when_seeder_join_times_out(session, comfy_dirs, mock_create_session, caplog): + record_id, _ = _seed_temp_rows(session, comfy_dirs) + + with patch("app.assets.lifecycle.wipe_temp_db_rows") as wipe_mock: + with patch.object(asset_seeder, "shutdown", return_value=False): + joined = asset_seeder.shutdown() + if joined: + run_asset_shutdown_cleanup() + + wipe_mock.assert_not_called() + assert session.get(Asset, record_id) is not None + + +def test_seeder_shutdown_logs_only_when_thread_does_not_exit(caplog: pytest.LogCaptureFixture) -> None: + seeder = type(asset_seeder)() + with patch.object(seeder, "cancel"), patch.object(seeder, "wait", return_value=False), caplog.at_level(logging.WARNING): + assert seeder.shutdown(timeout=2.5) is False + assert caplog.messages == ["Asset seeder thread did not exit within 2.5s"] + assert "skipping temp cleanup" not in caplog.text + + +def test_shutdown_cleanup_wipes_rows_then_rmtree(session, comfy_dirs, mock_create_session): + record_id, content_id = _seed_temp_rows(session, comfy_dirs) + calls: list[str] = [] + + real_wipe = wipe_temp_db_rows + + def tracking_wipe(sess): + calls.append("wipe") + return real_wipe(sess) + + with ( + patch("app.assets.lifecycle.wipe_temp_db_rows", side_effect=tracking_wipe), + patch("app.assets.lifecycle.cleanup_temp_filesystem", side_effect=lambda: calls.append("rmtree") or True), + ): + run_asset_shutdown_cleanup() + + assert calls == ["wipe", "rmtree"] + assert session.get(Asset, record_id) is None + assert session.get(AssetContent, content_id) is None + + +def test_run_shutdown_without_session_sweeps_temp_filesystem(comfy_dirs): + stale = comfy_dirs / "stale.png" + stale.write_bytes(b"\x00" * 10) + + with ( + patch("app.assets.lifecycle.can_create_session", return_value=False), + patch("app.assets.lifecycle.wipe_temp_db_rows") as wipe_mock, + ): + lifecycle.run_shutdown() + + assert not stale.exists() + wipe_mock.assert_not_called() + + +def test_wipe_temp_db_rows_deletes_records_before_content(session, comfy_dirs): + record_id, content_id = _seed_temp_rows(session, comfy_dirs) + records_deleted, contents_deleted = wipe_temp_db_rows(session) + session.commit() + + assert records_deleted == 1 + assert contents_deleted == 1 + assert session.get(Asset, record_id) is None + assert session.get(AssetContent, content_id) is None + + +def test_wipe_temp_db_rows_preserves_non_temp_rows(session, comfy_dirs): + temp_record_id, temp_content_id = _seed_temp_rows(session, comfy_dirs) + keep_path = str(comfy_dirs) + "-sibling" + os.sep + "keep.safetensors" + keep_content = create_content(session, path=keep_path) + keep_record = create_record(session, content_id=keep_content.id, name="keep.safetensors") + session.commit() + + records_deleted, contents_deleted = wipe_temp_db_rows(session) + session.commit() + + assert (records_deleted, contents_deleted) == (1, 1) + assert session.get(Asset, temp_record_id) is None + assert session.get(AssetContent, temp_content_id) is None + assert session.get(Asset, keep_record.id) is not None + assert session.get(AssetContent, keep_content.id) is not None + + +def _seed_paths(session: Session, paths: list[str]) -> list[str]: + stored: list[str] = [] + for index, path in enumerate(paths): + content = create_content(session, path=path) + create_record(session, content_id=content.id, name=f"case-{index}.png") + stored.append(content.path) + session.commit() + return stored + + +def _surviving_paths(session: Session) -> set[str]: + return set(session.scalars(select(AssetContent.path)).all()) + + +def test_wipe_deletes_exactly_what_is_path_under_prefixes_accepts(session, comfy_dirs): + temp_root = str(comfy_dirs) + stored = _seed_paths(session, [path for path, _ in prefix_case_paths(temp_root)]) + + records_deleted, contents_deleted = wipe_temp_db_rows(session) + session.commit() + + wiped = expected_prefix_case_paths(temp_root) + assert _surviving_paths(session) == set(stored) - wiped + assert (records_deleted, contents_deleted) == (len(wiped), len(wiped)) + assert wiped and wiped != set(stored) + case_different = os.path.join( + os.path.dirname(temp_root), os.path.basename(temp_root).upper(), "case.png" + ) + assert case_different in _surviving_paths(session) + assert os.path.join(os.path.dirname(temp_root), "escaped.png") in _surviving_paths(session) + + +def test_wipe_with_metacharacter_temp_root_matches_only_literal_children(session): + with tempfile.TemporaryDirectory() as base: + temp_root = os.path.join(base, "a_b%c*d?e[f") + inside = os.path.join(temp_root, "preview.png") + decoy = os.path.join(base, "aXbYcZdWeQf", "keep.safetensors") + _seed_paths(session, [inside, decoy]) + + with patch("folder_paths.get_temp_directory", return_value=temp_root): + wipe_temp_db_rows(session) + session.commit() + + assert is_path_under_prefixes(decoy, [temp_root]) is False + assert _surviving_paths(session) == {decoy} diff --git a/tests-unit/assets_test/services/test_manager_isolated.py b/tests-unit/assets_test/services/test_manager_isolated.py new file mode 100644 index 00000000000..2b73ba1bbc0 --- /dev/null +++ b/tests-unit/assets_test/services/test_manager_isolated.py @@ -0,0 +1,295 @@ +import dataclasses +import threading +from collections.abc import Callable, Generator, Iterator +from contextlib import AbstractContextManager, contextmanager +from pathlib import Path +from typing import Protocol +from unittest.mock import MagicMock, Mock, call + +import folder_paths +import pytest +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session, Session as SASession +from sqlalchemy.pool import StaticPool + +from app.assets import lifecycle +from app.assets import manager as manager_module +from app.assets import scanner, seeder as seeder_module +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import create_content, create_record +from app.assets.manager import AssetsEnabled +from app.assets.seeder import ScanStatus, asset_seeder +from app.database.models import Base +from app.assets.services.schemas import RegisteredAsset, UploadAssetView + + +class _ArgsStub: + enable_assets = True + enable_asset_hashing = False + + +class _OutputSeeder(Protocol): + def wait(self, timeout: float | None = None) -> bool: ... + + def get_status(self) -> ScanStatus: ... + + def shutdown(self, timeout: float = 5.0) -> bool: ... + + +@pytest.fixture +def enabled_manager() -> AssetsEnabled: + return AssetsEnabled(_ArgsStub()) + + +@pytest.fixture +def asset_roots( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> tuple[Path, Path, Path]: + output_dir = tmp_path / "output" + input_dir = tmp_path / "input" + temp_dir = tmp_path / "temp" + output_dir.mkdir() + input_dir.mkdir() + temp_dir.mkdir() + monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(output_dir)) + monkeypatch.setattr(folder_paths, "get_input_directory", lambda: str(input_dir)) + monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(temp_dir)) + return output_dir, input_dir, temp_dir + + +@pytest.fixture +def threaded_create_session( + monkeypatch: pytest.MonkeyPatch, +) -> Iterator[Callable[[], AbstractContextManager[Session]]]: + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + + @contextmanager + def _create_session() -> Generator[SASession, None, None]: + with SASession(engine) as session: + yield session + + monkeypatch.setattr(seeder_module, "create_session", _create_session) + monkeypatch.setattr(scanner, "create_session", _create_session) + monkeypatch.setattr("app.assets.services.ingest.create_session", _create_session) + yield _create_session + engine.dispose() + + +@pytest.fixture +def output_seeder(monkeypatch: pytest.MonkeyPatch) -> Iterator[_OutputSeeder]: + seeder = asset_seeder.__class__() + monkeypatch.setattr(manager_module, "asset_seeder", seeder) + yield seeder + _ = seeder.shutdown() + + +def test_queue_output_scan_registers_undeclared_output( + enabled_manager: AssetsEnabled, + asset_roots: tuple[Path, Path, Path], + threaded_create_session: Callable[[], AbstractContextManager[Session]], + output_seeder: _OutputSeeder, +) -> None: + output_dir, _, _ = asset_roots + output_path = output_dir / "undeclared.bin" + output_path.write_bytes(b"custom node output") + + enabled_manager.queue_output_scan() + assert output_seeder.wait(timeout=5) + + with threaded_create_session() as session: + rows = list( + session.scalars( + select(Asset) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(AssetContent.path == str(output_path.resolve())) + ) + ) + assert rows[0].job_id is None + assert len(rows) == 1 + + +def test_queue_output_scan_does_not_duplicate_declared_output( + enabled_manager: AssetsEnabled, + asset_roots: tuple[Path, Path, Path], + threaded_create_session: Callable[[], AbstractContextManager[Session]], + output_seeder: _OutputSeeder, +) -> None: + output_dir, _, _ = asset_roots + output_path = output_dir / "declared.bin" + output_path.write_bytes(b"declared custom node output") + registered = enabled_manager.register_executed_output( + str(output_path), job_id="declared-job" + ) + assert registered is not None + undeclared_path = output_dir / "alongside.bin" + undeclared_path.write_bytes(b"undeclared custom node output") + + enabled_manager.queue_output_scan() + assert output_seeder.wait(timeout=5) + assert output_seeder.get_status().errors == [] + + with threaded_create_session() as session: + rows = list( + session.scalars( + select(Asset) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(AssetContent.path == str(output_path.resolve())) + ) + ) + undeclared_rows = list( + session.scalars( + select(Asset) + .join(AssetContent, Asset.content_id == AssetContent.id) + .where(AssetContent.path == str(undeclared_path.resolve())) + ) + ) + assert rows[0].job_id == "declared-job" + assert undeclared_rows[0].job_id is None + assert len(rows) == 1 + assert len(undeclared_rows) == 1, ( + "the undeclared sibling proves the walk ran, so the declared row's count of 1 is a real skip rather than a silently-failed scan" + ) + + +def test_executed_and_cached_outputs_share_unhashed_content( + enabled_manager: AssetsEnabled, + asset_roots: tuple[Path, Path, Path], + mock_create_session: Callable[[], AbstractContextManager[Session]], +) -> None: + output_dir, _, _ = asset_roots + output_path = output_dir / "executed.png" + output_path.write_bytes(b"executed output") + + executed = enabled_manager.register_executed_output( + str(output_path), job_id="executed-job" + ) + + assert isinstance(executed, RegisteredAsset) + assert executed.id + assert executed.content_id + assert executed.name + assert executed.job_id == "executed-job" + assert not hasattr(executed, "asset_hash") + assert {field.name for field in dataclasses.fields(executed)} == { + "id", + "content_id", + "job_id", + "name", + } + with mock_create_session() as session: + asset = session.get(Asset, executed.id) + content = session.get(AssetContent, executed.content_id) + assert asset is not None + assert asset.content_id == executed.content_id + assert content is not None + assert content.hash is None + + cached = enabled_manager.register_cached_output(str(output_path), job_id="cached-job") + + assert isinstance(cached, RegisteredAsset) + assert cached.id != executed.id + assert cached.content_id == executed.content_id + assert cached.job_id == "cached-job" + assert ( + enabled_manager.register_cached_output( + str(output_dir / "unknown.png"), job_id="unknown-job" + ) + is None + ) + + +def test_register_upload_hashes_and_tags_fresh_input_file( + enabled_manager: AssetsEnabled, + asset_roots: tuple[Path, Path, Path], + mock_create_session: Callable[[], AbstractContextManager[Session]], +) -> None: + _, input_dir, _ = asset_roots + upload_path = input_dir / "pasted" / "upload.png" + upload_path.parent.mkdir() + upload_path.write_bytes(b"uploaded input") + + view = enabled_manager.register_upload( + str(upload_path), + name=upload_path.name, + upload_type="input", + subfolder="pasted", + content_written=True, + ) + + assert isinstance(view, UploadAssetView) + assert isinstance(view.asset, RegisteredAsset) + assert view.asset_hash + assert "pasted" in view.tags + + +def test_startup_runs_against_memory_db_without_starting_a_scanner_thread( + enabled_manager: AssetsEnabled, + asset_roots: tuple[Path, Path, Path], + mock_create_session: Callable[[], AbstractContextManager[Session]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, _, temp_dir = asset_roots + (temp_dir / "stale.tmp").write_bytes(b"stale") + seeder_start = MagicMock(return_value=False) + monkeypatch.setattr(lifecycle, "create_session", mock_create_session) + monkeypatch.setattr(lifecycle, "start_asset_seeder", seeder_start) + + thread_count = threading.active_count() + enabled_manager.startup() + assert threading.active_count() == thread_count, ( + "start_asset_seeder is mocked, so a new thread means a component other than the seeder spawned one" + ) + + assert not temp_dir.exists() + seeder_start.assert_called_once_with() + + +def test_ensure_scan_started_starts_the_lazy_object_info_scan( + enabled_manager: AssetsEnabled, monkeypatch: pytest.MonkeyPatch +) -> None: + seeder_start = MagicMock() + monkeypatch.setattr(asset_seeder, "start", seeder_start) + + enabled_manager.ensure_scan_started() + + seeder_start.assert_called_once_with(roots=("models", "input", "output")) + + +def test_shutdown_runs_lifecycle_cleanup_when_seeder_shutdown_times_out( + enabled_manager: AssetsEnabled, + asset_roots: tuple[Path, Path, Path], + mock_create_session: Callable[[], AbstractContextManager[Session]], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _, _, temp_dir = asset_roots + stale_file = temp_dir / "stale.tmp" + stale_file.write_bytes(b"stale") + with mock_create_session() as session: + content = create_content(session, path=str(stale_file)) + asset = create_record(session, content_id=content.id, name=stale_file.name) + session.commit() + asset_id = asset.id + content_id = content.id + + calls = Mock() + seeder_shutdown = MagicMock(return_value=False) + run_shutdown = MagicMock(wraps=manager_module.run_shutdown) + calls.attach_mock(seeder_shutdown, "seeder_shutdown") + calls.attach_mock(run_shutdown, "run_shutdown") + monkeypatch.setattr(lifecycle, "can_create_session", lambda: True) + monkeypatch.setattr(lifecycle, "create_session", mock_create_session) + monkeypatch.setattr(asset_seeder, "shutdown", seeder_shutdown) + monkeypatch.setattr(manager_module, "run_shutdown", run_shutdown) + + enabled_manager.shutdown() + + assert calls.mock_calls == [call.seeder_shutdown(), call.run_shutdown()] + assert not temp_dir.exists() + with mock_create_session() as session: + assert session.get(Asset, asset_id) is None + assert session.get(AssetContent, content_id) is None diff --git a/tests-unit/assets_test/services/test_metadata_update_b.py b/tests-unit/assets_test/services/test_metadata_update_b.py new file mode 100644 index 00000000000..20ba1b3b14c --- /dev/null +++ b/tests-unit/assets_test/services/test_metadata_update_b.py @@ -0,0 +1,59 @@ +import pytest + +from app.assets.database.models import Asset, AssetTag, Tag +from app.assets.database.queries.records import ( + create_content, + create_record, + fetch_record_tags, +) +from app.assets.services.asset_management import update_asset_metadata +from app.assets.services.tagging import apply_tags, remove_tags + + +def _create_record(session, path: str, tags: list[str] | None = None) -> Asset: + content = create_content(session, path) + record = create_record(session, content.id, "original", tags=tags) + session.commit() + return record + + +def test_rename_via_update_asset_metadata(session, mock_create_session): + record = _create_record(session, "/output/rename.png") + + update_asset_metadata(record.id, name="renamed") + + session.expire_all() + assert session.get(Asset, record.id).name == "renamed" + + +def test_apply_tags_adds_tags(session, mock_create_session): + record = _create_record(session, "/output/apply-tags.png") + + apply_tags(record.id, ["foo", "bar"]) + + session.expire_all() + assert fetch_record_tags(session, record.id) == ["bar", "foo"] + + +def test_remove_tags_removes_non_automatic(session, mock_create_session): + record = _create_record(session, "/output/remove-tags.png", tags=["foo"]) + session.add(Tag(name="automatic")) + session.add(AssetTag(asset_id=record.id, tag_name="automatic", origin="automatic")) + session.commit() + + remove_tags(record.id, ["foo"]) + + session.expire_all() + assert fetch_record_tags(session, record.id) == ["automatic"] + + +def test_update_asset_metadata_unknown_preview_id_raises(session, mock_create_session): + record = _create_record(session, "/output/preview-validate.png") + + with pytest.raises(ValueError): + update_asset_metadata( + record.id, preview_id="00000000-0000-0000-0000-000000000000" + ) + + session.expire_all() + assert session.get(Asset, record.id).preview_id is None diff --git a/tests-unit/assets_test/services/test_noassets_parity.py b/tests-unit/assets_test/services/test_noassets_parity.py new file mode 100644 index 00000000000..5c7b646b69d --- /dev/null +++ b/tests-unit/assets_test/services/test_noassets_parity.py @@ -0,0 +1,202 @@ +from collections.abc import Callable +from contextlib import AbstractContextManager +import logging +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +from aiohttp import web +from aiohttp.pytest_plugin import AiohttpClient +from sqlalchemy.orm import Session + +import folder_paths +from app.assets import lifecycle +from app.assets.api import routes +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import create_content, create_record +from app.assets import manager +from app.assets.manager import AssetsEnabled, NoAssets +from app.assets.mode import hashing_enabled +from app.assets.seeder import asset_seeder +from app.assets.services.hash_mode_state import read_stored_mode + + +class _Args: + enable_assets = False + + def __init__(self, hashing: bool) -> None: + self.enable_asset_hashing = hashing + + +def _no_assets(*, hashing: bool = False) -> NoAssets: + return NoAssets(_Args(hashing)) + + +@pytest.mark.asyncio +async def test_noassets_register_routes_returns_service_disabled_and_disables_seeder( + aiohttp_client: AiohttpClient, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(routes, "_ASSETS_ENABLED", False) + monkeypatch.setattr(asset_seeder, "_disabled", False) + app = web.Application() + + _no_assets().register_routes(app, None) + client = await aiohttp_client(app) + response = await client.get("/api/assets") + + assert response.status == 503 + assert (await response.json())["error"]["code"] == "SERVICE_DISABLED" + assert asset_seeder.is_disabled() + + +def test_noassets_startup_applies_hash_mode_persists_state_and_cleans_temp_dir( + mock_create_session: Callable[[], AbstractContextManager[Session]], + session: Session, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + temp_file = tmp_path / "startup-stale.bin" + temp_file.write_bytes(b"stale") + monkeypatch.setattr(lifecycle, "create_session", mock_create_session) + monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(tmp_path)) + + _no_assets(hashing=True).startup() + + assert hashing_enabled() is True + assert read_stored_mode(session) == "on" + assert not temp_file.exists() + + +def test_noassets_shutdown_wipes_temp_rows_and_files( + mock_create_session: Callable[[], AbstractContextManager[Session]], + session: Session, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _no_assets() + monkeypatch.setattr(lifecycle, "create_session", mock_create_session) + monkeypatch.setattr(lifecycle, "can_create_session", lambda: True) + monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(tmp_path)) + manager.startup() + + tmp_path.mkdir() + temp_file = tmp_path / "shutdown-stale.bin" + temp_file.write_bytes(b"stale") + content = create_content(session, str(temp_file)) + record = create_record(session, content.id, temp_file.name) + session.commit() + record_id = record.id + content_id = content.id + + manager.shutdown() + session.expire_all() + + assert session.get(Asset, record_id) is None + assert session.get(AssetContent, content_id) is None + assert not temp_file.exists() + + +def test_noassets_shutdown_runs_cleanup_after_unsuccessful_seeder_shutdown( + mock_create_session: Callable[[], AbstractContextManager[Session]], + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + temp_file = tmp_path / "seeder-timeout.bin" + temp_file.write_bytes(b"stale") + calls: list[str] = [] + monkeypatch.setattr(lifecycle, "create_session", mock_create_session) + monkeypatch.setattr(lifecycle, "can_create_session", lambda: True) + monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(tmp_path)) + + def seeder_shutdown() -> bool: + calls.append("seeder") + return False + + def cleanup() -> None: + calls.append("cleanup") + lifecycle.run_shutdown() + + with ( + patch.object(asset_seeder, "shutdown", side_effect=seeder_shutdown), + patch("app.assets.manager.run_shutdown", side_effect=cleanup) as cleanup_spy, + ): + _no_assets().shutdown() + + cleanup_spy.assert_called_once_with() + assert calls == ["seeder", "cleanup"] + assert not temp_file.exists() + + +def test_noassets_shutdown_without_database_sweeps_files_without_session_access( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + temp_file = tmp_path / "no-database.bin" + temp_file.write_bytes(b"stale") + create_session = Mock() + monkeypatch.setattr(lifecycle, "can_create_session", lambda: False) + monkeypatch.setattr(lifecycle, "create_session", create_session) + monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(tmp_path)) + + _no_assets().shutdown() + + assert not temp_file.exists() + create_session.assert_not_called() + + +def test_noassets_callbacks_are_noops_without_seeder_side_effects() -> None: + manager = _no_assets() + with ( + patch.object(asset_seeder, "start") as start, + patch.object(asset_seeder, "pause") as pause, + patch.object(asset_seeder, "enqueue_scan") as enqueue_scan, + patch.object(asset_seeder, "resume") as resume, + patch.object(asset_seeder, "set_event_sink") as set_event_sink, + ): + assert manager.ensure_scan_started() is None + assert manager.pause_background_scan() is None + assert manager.queue_output_scan() is None + assert manager.resume_background_scan() is None + assert manager.set_event_sink(Mock()) is None + + start.assert_not_called() + pause.assert_not_called() + enqueue_scan.assert_not_called() + resume.assert_not_called() + set_event_sink.assert_not_called() + + +def test_noassets_registration_methods_return_none() -> None: + manager = _no_assets() + + assert manager.register_upload( + "/tmp/output.png", "output.png", "output", "", content_written=True + ) is None + assert manager.register_executed_output("/tmp/output.png", "job-id") is None + assert manager.register_cached_output("/tmp/output.png", "job-id") is None + + +def test_noassets_is_disabled() -> None: + assert _no_assets().enabled is False + + +def test_default_asset_manager_disables_assets_when_dependencies_are_unavailable( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.setattr(manager.args, "enable_assets", True) + monkeypatch.setattr(manager, "dependencies_available", lambda: False) + + with caplog.at_level(logging.WARNING): + asset_manager = manager.default_asset_manager() + + assert isinstance(asset_manager, NoAssets) + assert "asset endpoints will answer 503" in caplog.text + assert "requirements.txt" in caplog.text + + +def test_default_asset_manager_enables_assets_when_dependencies_are_available( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(manager.args, "enable_assets", True) + monkeypatch.setattr(manager, "dependencies_available", lambda: True) + + assert isinstance(manager.default_asset_manager(), AssetsEnabled) diff --git a/tests-unit/assets_test/services/test_null_hash_recovery.py b/tests-unit/assets_test/services/test_null_hash_recovery.py new file mode 100644 index 00000000000..2a247fced8c --- /dev/null +++ b/tests-unit/assets_test/services/test_null_hash_recovery.py @@ -0,0 +1,198 @@ +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent, AssetTag +from app.assets.database.queries.records import create_content, create_record +from app.assets.helpers import to_stored_hash +from app.assets.scanner import SeedAssetSpec, clear_pending_verifications, seed_asset_specs +from app.assets.services import hash_mode_state +from app.assets.services.hash_mode_state import ( + clear_transition_queue, + drain_transition_queue, + enqueue_transition_work, + record_transition_intent, + write_stored_mode, +) +from app.assets.services.snapshot_hash import snapshot_hash + + +@pytest.fixture(autouse=True) +def _clear_queues(): + clear_transition_queue() + clear_pending_verifications() + yield + clear_transition_queue() + clear_pending_verifications() + + +def _stored_hash(path: Path) -> str: + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, _ = snapshot + return to_stored_hash(digest) + + +def _spec(path: Path) -> SeedAssetSpec: + stat = path.stat() + return { + "abs_path": str(path), + "size_bytes": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "info_name": path.name, + "tags": ["input"], + "fname": path.name, + "metadata": None, + "mime_type": None, + "job_id": None, + } + + +def test_deleted_null_hash_row_recovers_via_scanner_after_restore( + session, temp_dir, monkeypatch +): + path = temp_dir / "recoverable.bin" + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + original_bytes = b"the exact bytes that come back" + path.write_bytes(original_bytes) + stat = path.stat() + content = create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + content_id = content.id + record = create_record(session, content_id, "recoverable.bin", tags=["input"]) + record_id = record.id + session.commit() + + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + path.unlink() + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + assert session.get(AssetContent, content_id).is_missing is True, ( + "precondition: A1's drain marked the row missing on delete" + ) + + path.write_bytes(original_bytes) + os.utime(path, ns=(stat.st_atime_ns, stat.st_mtime_ns)) + assert path.stat().st_mtime_ns == stat.st_mtime_ns, "setup: mtime must round-trip exactly" + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 0, "the original row must recover — no fresh content row minted" + recovered = session.get(AssetContent, content_id) + assert recovered.is_missing is False + assert recovered.hash == _stored_hash(path) + assert len(session.scalars(select(AssetContent)).all()) == 1, "no duplicate content row" + assert len(session.scalars(select(Asset)).all()) == 1, "no duplicate record" + assert session.get(Asset, record_id) is not None, "the original record's identity survives" + assert session.get(AssetTag, {"asset_id": record_id, "tag_name": "missing"}) is None + + +def test_different_bytes_restored_at_same_path_does_not_recover_old_row( + session, temp_dir, monkeypatch +): + path = temp_dir / "replaced.bin" + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + path.write_bytes(b"original bytes") + stat = path.stat() + content = create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + content_id = content.id + create_record(session, content_id, "replaced.bin") + session.commit() + + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + path.unlink() + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + assert session.get(AssetContent, content_id).is_missing is True + + path.write_bytes(b"a completely different, much longer payload than the original") + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 1, "a genuinely different file must take the normal new-content path" + assert session.get(AssetContent, content_id).is_missing is True, ( + "the old row must stay missing — recovering it here would hand the wrong record's " + "identity, tags, and metadata to unrelated bytes" + ) + assert len(session.scalars(select(AssetContent)).all()) == 2 + + +def test_same_size_different_mtime_restored_at_same_path_does_not_recover_old_row( + session, temp_dir, monkeypatch +): + path = temp_dir / "retimed.bin" + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + original_bytes = b"identical length, different moment in time" + path.write_bytes(original_bytes) + stat = path.stat() + content = create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + content_id = content.id + create_record(session, content_id, "retimed.bin") + session.commit() + + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + path.unlink() + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + assert session.get(AssetContent, content_id).is_missing is True + + path.write_bytes(original_bytes) + shifted_ns = stat.st_mtime_ns + 5_000_000_000 + os.utime(path, ns=(stat.st_atime_ns, shifted_ns)) + assert path.stat().st_mtime_ns != stat.st_mtime_ns, "setup: mtime must actually differ" + assert path.stat().st_size == stat.st_size, "setup: size must match so only mtime disambiguates" + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 1, "a same-size-but-different-mtime restore must take the new-content path" + assert session.get(AssetContent, content_id).is_missing is True, ( + "a matching size with a mismatched mtime is not proof the old row's bytes are back — " + "recovering here would hand the wrong record's identity to different bytes" + ) + assert len(session.scalars(select(AssetContent)).all()) == 2 + + +def test_two_missing_null_hash_candidates_at_same_path_do_not_recover( + session, temp_dir, monkeypatch +): + path = temp_dir / "ambiguous_null.bin" + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + path.write_bytes(b"bytes shared by two missing generations") + stat = path.stat() + first = AssetContent( + path=str(path), hash=None, is_missing=True, + size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns, + ) + second = AssetContent( + path=str(path), hash=None, is_missing=True, + size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns, + ) + session.add_all([first, second]) + session.flush() + first_id, second_id = first.id, second.id + session.commit() + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 1, "ambiguous candidates must fall through to the normal new-content path" + assert session.get(AssetContent, first_id).is_missing is True + assert session.get(AssetContent, second_id).is_missing is True diff --git a/tests-unit/assets_test/services/test_output_registration.py b/tests-unit/assets_test/services/test_output_registration.py new file mode 100644 index 00000000000..65c8ab66425 --- /dev/null +++ b/tests-unit/assets_test/services/test_output_registration.py @@ -0,0 +1,143 @@ +import os +from pathlib import Path +from unittest.mock import patch + +import folder_paths +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent +from app.assets.manager import AssetsEnabled, NoAssets +from comfy_execution.asset_enrichment import ( + register_cached_outputs, + register_executed_outputs, +) + +class _ArgsStub: + def __init__(self, enable_assets: bool = True) -> None: + self.enable_assets = enable_assets + self.enable_asset_hashing = False + + +def _write_output_file(name: str, data: bytes) -> Path: + path = Path(folder_paths.get_output_directory()) / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(data) + return path + + +def _output_ui(name: str) -> dict: + return {"images": [{"filename": name, "subfolder": "", "type": "output"}]} + + +def _wrapper(name: str, node_id: str = "1") -> dict: + return {"meta": {"node_id": node_id}, "output": _output_ui(name)} + + +def test_executed_adapter_registers_new_output(mock_create_session): + path = _write_output_file("adapter-executed-new.png", b"exec") + try: + output_ui = _output_ui(path.name) + + enriched = register_executed_outputs( + output_ui, "exec-job", AssetsEnabled(_ArgsStub()) + ) + + new_id = enriched["images"][0]["id"] + assert "id" not in output_ui["images"][0] + with mock_create_session() as session: + record = session.get(Asset, new_id) + content = session.get(AssetContent, record.content_id) + assert record.job_id == "exec-job" + assert content.path == os.path.abspath(path) + assert content.is_missing is False + finally: + path.unlink(missing_ok=True) + + +def test_executed_adapter_over_existing_path_marks_old_missing(mock_create_session): + path = _write_output_file("adapter-executed-replace.png", b"original") + try: + manager = AssetsEnabled(_ArgsStub()) + first = register_executed_outputs(_output_ui(path.name), "job-1", manager) + old_id = first["images"][0]["id"] + + path.write_bytes(b"replacement") + second = register_executed_outputs(_output_ui(path.name), "job-2", manager) + new_id = second["images"][0]["id"] + + assert new_id != old_id + with mock_create_session() as session: + old_content = session.get(AssetContent, session.get(Asset, old_id).content_id) + new_content = session.get(AssetContent, session.get(Asset, new_id).content_id) + assert old_content.is_missing is True + assert new_content.is_missing is False + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.path == os.path.abspath(path)) + ) + ) + assert len(contents) == 2 + finally: + path.unlink(missing_ok=True) + + +def test_executed_adapter_disabled_registers_nothing(mock_create_session): + path = _write_output_file("adapter-executed-disabled.png", b"noop") + try: + output_ui = _output_ui(path.name) + + enriched = register_executed_outputs(output_ui, "job", NoAssets(_ArgsStub(False))) + + assert "id" not in enriched["images"][0] + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.path == os.path.abspath(path)) + ) + ) + assert contents == [] + finally: + path.unlink(missing_ok=True) + + +def test_executed_adapter_registration_failure_never_raises(mock_create_session): + path = _write_output_file("adapter-executed-error.png", b"exec") + try: + output_ui = _output_ui(path.name) + + with patch( + "app.assets.manager.ingest_register_executed_output", + side_effect=RuntimeError("boom"), + ): + enriched = register_executed_outputs( + output_ui, "job", AssetsEnabled(_ArgsStub()) + ) + + assert "id" not in enriched["images"][0] + finally: + path.unlink(missing_ok=True) + + +def test_cached_adapter_without_live_content_is_nonevent(mock_create_session): + path = _write_output_file("adapter-cached-missing.png", b"new content") + try: + wrapper = _wrapper(path.name) + + enriched = register_cached_outputs( + wrapper, "cached-job", AssetsEnabled(_ArgsStub()) + ) + + assert "id" not in enriched["output"]["images"][0] + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.path == os.path.abspath(path)) + ) + ) + records = list( + session.scalars(select(Asset).where(Asset.job_id == "cached-job")) + ) + assert contents == [] + assert records == [] + finally: + path.unlink(missing_ok=True) diff --git a/tests-unit/assets_test/services/test_owner_purge.py b/tests-unit/assets_test/services/test_owner_purge.py new file mode 100644 index 00000000000..dd504b5d599 --- /dev/null +++ b/tests-unit/assets_test/services/test_owner_purge.py @@ -0,0 +1,15 @@ +import subprocess +from pathlib import Path + + +def test_no_owner_id_in_asset_modules(): + repository = Path(__file__).resolve().parents[3] + result = subprocess.run( + ["grep", "-rn", "--exclude=*.pyc", "owner_id", "app/assets/"], + cwd=repository, + check=False, + capture_output=True, + text=True, + ) + + assert result.stdout == "" diff --git a/tests-unit/assets_test/services/test_preview_path_resolution.py b/tests-unit/assets_test/services/test_preview_path_resolution.py new file mode 100644 index 00000000000..90b5aee8646 --- /dev/null +++ b/tests-unit/assets_test/services/test_preview_path_resolution.py @@ -0,0 +1,64 @@ +from sqlalchemy import event + +from app.assets.database.queries.records import ( + create_content, + create_record, + mark_content_missing, +) +from app.assets.services.asset_management import get_preview_file_paths + + +def test_preview_paths_resolve_preview_record_content(session, mock_create_session) -> None: + preview_content = create_content(session, "/output/preview.png") + preview = create_record(session, preview_content.id, "preview.png") + record_content = create_content(session, "/output/record.png") + record = create_record(session, record_content.id, "record.png") + record.preview_id = preview.id + session.commit() + + paths = get_preview_file_paths([preview.id]) + + assert paths == {preview.id: preview_content.path} + + +def test_preview_paths_exclude_missing_preview_content(session, mock_create_session) -> None: + preview_content = create_content(session, "/output/missing-preview.png") + preview = create_record(session, preview_content.id, "missing-preview.png") + record_content = create_content(session, "/output/record.png") + record = create_record(session, record_content.id, "record.png") + record.preview_id = preview.id + mark_content_missing(session, preview_content.id) + session.commit() + + paths = get_preview_file_paths([preview.id]) + + assert paths == {} + + +def test_preview_paths_resolve_a_page_in_one_query(session, mock_create_session, db_engine) -> None: + preview_ids: list[str] = [] + expected_paths: dict[str, str] = {} + for index in range(3): + preview_path = f"/output/preview-{index}.png" + preview_content = create_content(session, preview_path) + preview = create_record(session, preview_content.id, f"preview-{index}.png") + record_content = create_content(session, f"/output/record-{index}.png") + record = create_record(session, record_content.id, f"record-{index}.png") + record.preview_id = preview.id + preview_ids.append(preview.id) + expected_paths[preview.id] = preview_content.path + session.commit() + + statements: list[str] = [] + + def count_statements(_, __, statement, ___, ____, _____) -> None: + statements.append(statement) + + event.listen(db_engine, "before_cursor_execute", count_statements) + try: + paths = get_preview_file_paths(preview_ids) + finally: + event.remove(db_engine, "before_cursor_execute", count_statements) + + assert paths == expected_paths + assert len(statements) == 1 diff --git a/tests-unit/assets_test/services/test_recovery_gate.py b/tests-unit/assets_test/services/test_recovery_gate.py new file mode 100644 index 00000000000..eeca7e0b0ac --- /dev/null +++ b/tests-unit/assets_test/services/test_recovery_gate.py @@ -0,0 +1,168 @@ +from pathlib import Path +from unittest.mock import patch + +import pytest +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent, AssetTag, Tag +from app.assets.helpers import to_stored_hash +from app.assets.scanner import ( + SeedAssetSpec, + clear_pending_verifications, + pending_recovery_count, + seed_asset_specs, +) +from app.assets.scanner_changes import recover_missing_content +from app.assets.services.snapshot_hash import snapshot_hash + + +@pytest.fixture(autouse=True) +def _clear_queues(): + clear_pending_verifications() + yield + clear_pending_verifications() + + +def _missing_content(session, path: Path, hash_value: str) -> tuple[AssetContent, Asset]: + content = AssetContent(path=str(path), hash=hash_value, is_missing=True) + session.add(content) + session.flush() + record = Asset(content_id=content.id, name=path.name) + session.add(record) + session.flush() + if session.get(Tag, "missing") is None: + session.add(Tag(name="missing")) + session.add(AssetTag(asset_id=record.id, tag_name="missing", origin="automatic")) + session.commit() + return content, record + + +def _spec(path: Path) -> SeedAssetSpec: + stat = path.stat() + return { + "abs_path": str(path), + "size_bytes": stat.st_size, + "mtime_ns": stat.st_mtime_ns, + "info_name": path.name, + "tags": ["input"], + "fname": path.name, + "metadata": None, + "mime_type": None, + "job_id": None, + } + + +def _stored_hash(path: Path) -> str: + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, _ = snapshot + return to_stored_hash(digest) + + +def test_single_hash_match_recovers(session, temp_dir: Path): + path = temp_dir / "restored.bin" + path.write_bytes(b"restored bytes") + content, record = _missing_content(session, path, _stored_hash(path)) + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 0 + assert session.get(AssetContent, content.id).is_missing is False + assert session.get(AssetTag, {"asset_id": record.id, "tag_name": "missing"}) is None + + +def test_ambiguous_hash_match_recovers_nothing(session, temp_dir: Path): + path = temp_dir / "ambiguous.bin" + path.write_bytes(b"same bytes") + digest = _stored_hash(path) + first, _ = _missing_content(session, path, digest) + second, _ = _missing_content(session, path, digest) + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 1 + assert session.get(AssetContent, first.id).is_missing is True + assert session.get(AssetContent, second.id).is_missing is True + assert len(session.scalars(select(AssetContent)).all()) == 3 + + +def test_no_hash_match_creates_fresh_rows(session, temp_dir: Path): + path = temp_dir / "different.bin" + path.write_bytes(b"current bytes") + missing, _ = _missing_content(session, path, "old") + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 1 + assert session.get(AssetContent, missing.id).is_missing is True + assert len(session.scalars(select(Asset)).all()) == 2 + + +def test_off_mode_no_recovery(session, temp_dir: Path): + path = temp_dir / "off.bin" + path.write_bytes(b"bytes") + missing, _ = _missing_content(session, path, _stored_hash(path)) + + with ( + patch("app.assets.scanner.mode.hashing_enabled", return_value=False), + patch("app.assets.scanner_changes.snapshot_hash") as hash_mock, + ): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + hash_mock.assert_not_called() + assert created == 1 + assert session.get(AssetContent, missing.id).is_missing is True + + +def test_unstable_hash_requeues(session, temp_dir: Path): + path = temp_dir / "unstable.bin" + path.write_bytes(b"bytes") + missing, _ = _missing_content(session, path, "old") + + with ( + patch("app.assets.scanner.mode.hashing_enabled", return_value=True), + patch("app.assets.scanner_changes.snapshot_hash", return_value=None), + ): + created = seed_asset_specs(session, [_spec(path)]) + session.commit() + + assert created == 0 + assert pending_recovery_count() == 1 + assert session.get(AssetContent, missing.id).is_missing is True + + +def test_recovery_skips_a_path_a_live_row_already_occupies(session, temp_dir: Path): + path = temp_dir / "contested.bin" + path.write_bytes(b"bytes restored under a path someone else already owns") + digest = _stored_hash(path) + stat_result = path.stat() + missing_a, _ = _missing_content(session, path, digest) + live_b = AssetContent( + path=str(path), + hash=digest, + is_missing=False, + size_bytes=stat_result.st_size, + mtime_ns=stat_result.st_mtime_ns, + ) + session.add(live_b) + session.commit() + live_b_id = live_b.id + + result = recover_missing_content( + session, str(path), stat_result, hashing_is_enabled=True + ) + session.commit() + + assert result == "no_match", ( + "the file at an occupied path belongs to the live row's story; change detection " + "owns it, and recovering onto it would put two live rows on one path" + ) + assert session.get(AssetContent, missing_a.id).is_missing is True + assert session.get(AssetContent, live_b_id).is_missing is False diff --git a/tests-unit/assets_test/services/test_registration_primitives.py b/tests-unit/assets_test/services/test_registration_primitives.py new file mode 100644 index 00000000000..7f0df1ea314 --- /dev/null +++ b/tests-unit/assets_test/services/test_registration_primitives.py @@ -0,0 +1,273 @@ +import os +from datetime import datetime + +import folder_paths +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import create_content, create_record +from app.assets.services.ingest import ( + register_cached_output, + register_executed_output, + register_file_in_place, +) + + +def _output_path(name: str) -> str: + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + return os.path.join(output_dir, name) + + +def test_executed_creates_metadata_and_null_hash(mock_create_session): + body = b"pixels" + path = _output_path("primitives_executed_meta.png") + with open(path, "wb") as fh: + fh.write(body) + + try: + record = register_executed_output(path, job_id="job-exec") + + assert record is not None + with mock_create_session() as session: + content = session.scalar( + select(AssetContent).where( + AssetContent.path == os.path.abspath(path) + ) + ) + asset = session.get(Asset, record.id) + + assert content.hash is None + assert content.is_missing is False + assert asset.system_metadata is not None + assert asset.system_metadata.get("format") == "png" + assert asset.system_metadata.get("content_length") == len(body) + assert "filename" in asset.system_metadata + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_executed_over_live_path_marks_old_missing(mock_create_session): + path = _output_path("primitives_executed_overwrite.png") + + try: + with open(path, "wb") as fh: + fh.write(b"v1") + first = register_executed_output(path, job_id="job1") + + with open(path, "wb") as fh: + fh.write(b"v2") + second = register_executed_output(path, job_id="job2") + + assert first is not None + assert second is not None + assert second.id != first.id + with mock_create_session() as session: + old_content = session.get(AssetContent, first.content_id) + old_record = session.get(Asset, first.id) + new_record = session.get(Asset, second.id) + + assert old_content.is_missing is True + assert old_record.job_id == "job1" + assert new_record.job_id == "job2" + assert new_record.content_id != first.content_id + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_cached_copies_sibling_metadata(mock_create_session): + sibling_metadata = {"provenance": "sibling", "width": 4, "kind": "image"} + path = _output_path("primitives_cached_copy.png") + with open(path, "wb") as fh: + fh.write(b"pixels") + + try: + with mock_create_session() as session: + content = create_content(session, os.path.abspath(path), None, 6, 0) + create_record( + session, + content.id, + "sibling", + system_metadata=dict(sibling_metadata), + ) + session.commit() + content_id = content.id + + cached = register_cached_output(path, job_id="delivery-job") + + assert cached is not None + assert cached.content_id == content_id + with mock_create_session() as session: + record = session.get(Asset, cached.id) + assert record.system_metadata == sibling_metadata + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_cached_earliest_sibling_wins(mock_create_session): + earliest = datetime(2020, 1, 1, 0, 0, 0) + later = datetime(2020, 1, 2, 0, 0, 0) + winner_metadata = {"winner": "earliest-low-id"} + path = _output_path("primitives_cached_earliest.png") + with open(path, "wb") as fh: + fh.write(b"pixels") + + try: + with mock_create_session() as session: + content = create_content(session, os.path.abspath(path), None, 6, 0) + session.add_all( + [ + Asset( + id="00000000-0000-0000-0000-000000000001", + content_id=content.id, + name="earliest-low-id", + created_at=earliest, + system_metadata=dict(winner_metadata), + ), + Asset( + id="00000000-0000-0000-0000-000000000002", + content_id=content.id, + name="earliest-high-id", + created_at=earliest, + system_metadata={"winner": "earliest-high-id"}, + ), + Asset( + id="00000000-0000-0000-0000-000000000000", + content_id=content.id, + name="later-lowest-id", + created_at=later, + system_metadata={"winner": "later-lowest-id"}, + ), + ] + ) + session.commit() + content_id = content.id + + cached = register_cached_output(path, job_id="delivery-job") + + assert cached is not None + assert cached.content_id == content_id + with mock_create_session() as session: + record = session.get(Asset, cached.id) + assert record.system_metadata == winner_metadata + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_cached_orphan_content_extracts_fresh(mock_create_session): + body = b"pixels" + path = _output_path("primitives_cached_orphan.png") + with open(path, "wb") as fh: + fh.write(body) + + try: + with mock_create_session() as session: + content = create_content( + session, os.path.abspath(path), None, len(body), 0 + ) + session.commit() + content_id = content.id + + cached = register_cached_output(path, job_id="delivery-job") + + assert cached is not None + assert cached.content_id == content_id + with mock_create_session() as session: + record = session.get(Asset, cached.id) + assert record.system_metadata is not None + assert record.system_metadata.get("format") == "png" + assert record.system_metadata.get("content_length") == len(body) + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_cached_missing_content_is_nonevent(mock_create_session): + path = _output_path("primitives_cached_missing.png") + with open(path, "wb") as fh: + fh.write(b"pixels") + + try: + cached = register_cached_output(path, job_id="delivery-job") + + assert cached is None + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == os.path.abspath(path) + ) + ) + ) + records = list( + session.scalars( + select(Asset).where(Asset.job_id == "delivery-job") + ) + ) + assert contents == [] + assert records == [] + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_registration_failure_never_raises_and_leaves_no_rows( + mock_create_session, monkeypatch +): + def _boom(*args, **kwargs): + raise RuntimeError("simulated create_record failure") + + monkeypatch.setattr( + "app.assets.services.ingest.create_record", _boom + ) + + path = _output_path("primitives_reg_fail.png") + with open(path, "wb") as fh: + fh.write(b"pixels") + + try: + result = register_executed_output(path, job_id="job-boom") + + assert result is None + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == os.path.abspath(path) + ) + ) + ) + records = list( + session.scalars( + select(Asset).where(Asset.job_id == "job-boom") + ) + ) + assert contents == [] + assert records == [] + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_upload_record_has_metadata_and_hash(mock_create_session): + body = b"pixels-content" + path = _output_path("primitives_upload_in_place.png") + with open(path, "wb") as fh: + fh.write(body) + + try: + result = register_file_in_place( + path, name="pic.png", tags=["demo"], mime_type="image/png" + ) + + assert result.asset.hash is not None + assert result.ref.system_metadata is not None + assert result.ref.system_metadata.get("format") == "png" + assert result.ref.system_metadata.get("content_length") == len(body) + finally: + if os.path.exists(path): + os.unlink(path) diff --git a/tests-unit/assets_test/services/test_scan_lifecycle.py b/tests-unit/assets_test/services/test_scan_lifecycle.py new file mode 100644 index 00000000000..e5a1d280118 --- /dev/null +++ b/tests-unit/assets_test/services/test_scan_lifecycle.py @@ -0,0 +1,43 @@ +from pathlib import Path +from unittest.mock import patch + +from sqlalchemy import select + +from app.assets.database.models import AssetContent +from app.assets.scanner import build_asset_specs, seed_asset_specs, sync_prefixes_with_filesystem + + +def _scan(session, root: Path) -> int: + paths = [str(path) for path in root.iterdir()] + specs, _, _ = build_asset_specs(paths, set(), enable_metadata_extraction=False) + return seed_asset_specs(session, specs) + + +def test_e2e_scan_seed_detect_prune(session, temp_dir: Path): + root = temp_dir / "input" + root.mkdir() + removed = root / "removed.bin" + edited = root / "edited.bin" + removed.write_bytes(b"removed") + edited.write_bytes(b"old") + with patch("folder_paths.get_input_directory", return_value=str(root)): + assert _scan(session, root) == 2 + removed.unlink() + edited.write_bytes(b"replacement") + (root / "partial.part").write_bytes(b"partial") + with patch("app.assets.scanner.mode.hashing_enabled", return_value=False): + sync_prefixes_with_filesystem(session, [str(root)]) + _scan(session, root) + session.commit() + contents = list(session.scalars(select(AssetContent))) + assert len(contents) == 3 + assert len([content for content in contents if content.is_missing]) == 2 + + +def test_second_scan_idempotent(session, temp_dir: Path): + root = temp_dir / "input" + root.mkdir() + (root / "stable.bin").write_bytes(b"stable") + with patch("folder_paths.get_input_directory", return_value=str(root)): + assert _scan(session, root) == 1 + assert _scan(session, root) == 0 diff --git a/tests-unit/assets_test/services/test_scanner_b.py b/tests-unit/assets_test/services/test_scanner_b.py new file mode 100644 index 00000000000..93c155870e5 --- /dev/null +++ b/tests-unit/assets_test/services/test_scanner_b.py @@ -0,0 +1,232 @@ +import os +from collections.abc import Mapping +from dataclasses import dataclass +from pathlib import Path +from unittest.mock import patch + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.assets.database.models import Asset, AssetContent, AssetTag +from app.assets.helpers import to_stored_hash +from app.assets.scanner import ( + build_asset_specs, + enrich_asset, + mark_contents_missing_outside_prefixes, + seed_asset_specs, + sync_prefixes_with_filesystem, +) +from app.assets.services.snapshot_hash import snapshot_hash + + +@dataclass(frozen=True, slots=True) +class _ExtractedMetadata: + content_type: str | None + system_metadata: Mapping[str, int | str] + + def to_user_metadata(self) -> dict[str, int | str]: + return dict(self.system_metadata) + + +def _build_seed_specs(root: Path) -> list: + return build_asset_specs( + [str(path) for path in sorted(root.iterdir())], + existing_paths=set(), + enable_metadata_extraction=False, + )[0] + + +def _create_enrichment_target( + session: Session, + path: Path, + system_metadata: dict[str, int | str] | None = None, +) -> tuple[AssetContent, Asset]: + content = AssetContent( + path=str(path), + hash=None, + size_bytes=path.stat().st_size, + mtime_ns=path.stat().st_mtime_ns, + ) + session.add(content) + session.flush() + record = Asset( + content_id=content.id, + name=path.name, + system_metadata=system_metadata, + ) + session.add(record) + session.commit() + return content, record + + +def test_enrichment_retains_absent_system_metadata_keys(session: Session, temp_dir: Path): + path = temp_dir / "metadata.bin" + path.write_bytes(b"metadata") + content, record = _create_enrichment_target(session, path) + + with patch( + "app.assets.scanner.extract_file_metadata", + side_effect=[ + _ExtractedMetadata(None, {"a": 1, "b": 2}), + _ExtractedMetadata(None, {"b": 3}), + ], + ): + enrich_asset(session, str(path), content.id, record.id) + enrich_asset(session, str(path), content.id, record.id) + + assert record.system_metadata == {"a": 1, "b": 3} + + +def test_enrichment_retains_dimensions_when_image_extraction_degrades( + session: Session, temp_dir: Path +): + path = temp_dir / "image.png" + path.write_bytes(b"not a complete image") + content, record = _create_enrichment_target( + session, + path, + {"kind": "image", "width": 64, "height": 64}, + ) + + with ( + patch( + "app.assets.scanner.extract_file_metadata", + return_value=_ExtractedMetadata("image/png", {"filename": "image.png"}), + ), + patch("app.assets.scanner.extract_image_dimensions", return_value=None), + ): + enrich_asset(session, str(path), content.id, record.id) + + assert record.system_metadata == { + "filename": "image.png", + "kind": "image", + "width": 64, + "height": 64, + } + + +def test_enrichment_overrides_content_length_with_zero( + session: Session, temp_dir: Path +): + path = temp_dir / "empty.bin" + path.write_bytes(b"") + content, record = _create_enrichment_target( + session, + path, + {"content_length": 1024}, + ) + + with patch( + "app.assets.scanner.extract_file_metadata", + return_value=_ExtractedMetadata(None, {"content_length": 0}), + ): + enrich_asset(session, str(path), content.id, record.id) + + assert record.system_metadata == {"content_length": 0} + + +def test_seed_creates_content_and_record(session, temp_dir: Path): + input_root = temp_dir / "input" + input_root.mkdir() + (input_root / "first.png").write_bytes(b"first") + (input_root / "second.png").write_bytes(b"second") + + with patch("folder_paths.get_input_directory", return_value=str(input_root)): + created = seed_asset_specs(session, _build_seed_specs(input_root)) + session.commit() + + contents = list(session.scalars(select(AssetContent).order_by(AssetContent.path))) + records = list(session.scalars(select(Asset).order_by(Asset.name))) + + assert created == 2 + assert len(contents) == 2 + assert len(records) == 2 + assert [record.loader_path for record in records] == ["first.png", "second.png"] + assert [link.tag_name for link in session.scalars(select(AssetTag).order_by(AssetTag.asset_id))] == [ + "input", + "input", + ] + + +def test_prune_marks_missing_not_deletes(session, temp_dir: Path): + input_root = temp_dir / "input" + input_root.mkdir() + file_path = input_root / "removed-from-registry.png" + file_path.write_bytes(b"content") + + with patch("folder_paths.get_input_directory", return_value=str(input_root)): + seed_asset_specs(session, _build_seed_specs(input_root)) + session.commit() + + marked = mark_contents_missing_outside_prefixes(session, prefixes=[]) + session.commit() + + content = session.scalar(select(AssetContent)) + record = session.scalar(select(Asset)) + missing_tag = session.get(AssetTag, {"asset_id": record.id, "tag_name": "missing"}) + + assert marked == 1 + assert content is not None and content.is_missing is True + assert record is not None + assert missing_tag is not None and missing_tag.origin == "automatic" + + +def test_unhashed_missing_content_gets_tagged(session, temp_dir: Path): + missing_path = os.path.abspath(temp_dir / "missing.bin") + content = AssetContent(path=missing_path, hash=None, size_bytes=7, mtime_ns=1) + session.add(content) + session.flush() + record = Asset(content_id=content.id, name="missing.bin") + session.add(record) + session.commit() + + sync_prefixes_with_filesystem(session, prefixes=[str(temp_dir)]) + session.commit() + + session.expire_all() + content = session.get(AssetContent, content.id) + missing_tag = session.get(AssetTag, {"asset_id": record.id, "tag_name": "missing"}) + + assert content is not None and content.is_missing is True + assert missing_tag is not None and missing_tag.origin == "automatic" + + +def test_enrichment_keeps_equal_hash_contents_distinct(session, temp_dir: Path): + path = temp_dir / "new.bin" + path.write_bytes(b"same bytes") + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, _ = snapshot + + content = AssetContent( + path=str(path), + hash=None, + size_bytes=path.stat().st_size, + mtime_ns=path.stat().st_mtime_ns, + ) + existing = AssetContent( + path=str(temp_dir / "existing.bin"), + hash=to_stored_hash(digest), + size_bytes=path.stat().st_size, + mtime_ns=path.stat().st_mtime_ns, + ) + session.add_all((content, existing)) + session.flush() + record = Asset(content_id=content.id, name=path.name) + existing_record = Asset(content_id=existing.id, name="existing.bin") + session.add_all((record, existing_record)) + session.commit() + + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=False, + compute_hash=True, + ) + + assert enriched is True + assert session.get(AssetContent, content.id).hash == to_stored_hash(digest) + assert session.get(Asset, record.id).content_id == content.id + assert session.get(Asset, existing_record.id).content_id == existing.id diff --git a/tests-unit/assets_test/services/test_scanner_seed_resilience.py b/tests-unit/assets_test/services/test_scanner_seed_resilience.py new file mode 100644 index 00000000000..f6f8f6ba4a5 --- /dev/null +++ b/tests-unit/assets_test/services/test_scanner_seed_resilience.py @@ -0,0 +1,214 @@ +import os +from collections.abc import Callable +from pathlib import Path +from unittest.mock import patch + +import pytest +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries import create_content, create_record, delete_record +from app.assets.scanner import SeedAssetSpec, seed_asset_specs +from app.assets.services.snapshot_hash import snapshot_hash + + +def _spec(path: Path) -> SeedAssetSpec: + stat_result = path.stat() + return { + "abs_path": str(path), + "size_bytes": stat_result.st_size, + "mtime_ns": stat_result.st_mtime_ns, + "info_name": path.name, + "tags": ["input"], + "fname": path.name, + "metadata": None, + "mime_type": None, + "job_id": None, + } + + +def _specs_with_vanished_path(root: Path) -> tuple[list[SeedAssetSpec], Path]: + paths = [root / name for name in ("first.bin", "vanished.bin", "last.bin")] + for path in paths: + _ = path.write_bytes(path.name.encode()) + return [_spec(path) for path in paths], paths[1] + + +def _record_count(session: Session) -> int: + return len(session.scalars(select(Asset)).all()) + + +def test_seed_persists_remaining_specs_when_path_vanishes_before_restat( + session: Session, temp_dir: Path +) -> None: + specs, vanished_path = _specs_with_vanished_path(temp_dir) + vanished_path.unlink() + + created = seed_asset_specs(session, specs) + session.commit() + + assert created == 2 + assert _record_count(session) == 2 + + +def test_seed_persists_remaining_specs_when_path_vanishes_during_recovery_hash( + session: Session, temp_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + specs, vanished_path = _specs_with_vanished_path(temp_dir) + + def _hash_or_raise(path: str) -> str | None: + if path == str(vanished_path): + vanished_path.unlink() + raise OSError("file vanished during recovery") + return snapshot_hash(path) + + monkeypatch.setattr("app.assets.scanner_changes.snapshot_hash", _hash_or_raise) + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + created = seed_asset_specs(session, specs) + session.commit() + + assert created == 2 + assert _record_count(session) == 2 + + +def _delete_before_restat(_monkeypatch: pytest.MonkeyPatch, path: Path) -> None: + path.unlink() + + +def _delete_during_recovery(monkeypatch: pytest.MonkeyPatch, path: Path) -> None: + def _hash_or_raise(candidate_path: str) -> str | None: + if candidate_path == str(path): + path.unlink() + raise OSError("file vanished during recovery") + return snapshot_hash(candidate_path) + + monkeypatch.setattr("app.assets.scanner_changes.snapshot_hash", _hash_or_raise) + + +@pytest.mark.parametrize( + "delete_path", + [_delete_before_restat, _delete_during_recovery], + ids=["before-restat", "during-recovery"], +) +def test_seed_logs_once_for_each_vanished_path( + session: Session, + temp_dir: Path, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + delete_path: Callable[[pytest.MonkeyPatch, Path], None], +) -> None: + specs, vanished_path = _specs_with_vanished_path(temp_dir) + delete_path(monkeypatch, vanished_path) + + with patch("app.assets.scanner.mode.hashing_enabled", return_value=True): + _ = seed_asset_specs(session, specs) + session.commit() + + messages = [ + record.getMessage() + for record in caplog.records + if str(vanished_path) in record.getMessage() + ] + assert messages == [f"Skipping vanished asset during scan: {vanished_path}"] + + +def test_seed_isolates_a_poisoned_spec_and_persists_the_specs_around_it( + session: Session, temp_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + specs, poisoned_path = _specs_with_vanished_path(temp_dir) + + def _create_record_or_raise( + session_arg: Session, + *, + content_id: str, + name: str, + mime_type: str | None, + job_id: str | None, + loader_path: str | None, + tags: list[str], + ) -> Asset: + if name == poisoned_path.name: + raise IntegrityError("forced record creation failure", {}, ValueError()) + return create_record( + session_arg, + content_id, + name, + mime_type, + job_id, + loader_path, + tags, + ) + + monkeypatch.setattr("app.assets.scanner.create_record", _create_record_or_raise) + + created = seed_asset_specs(session, specs) + session.commit() + + assert created == 2 + assert _record_count(session) == 2 + assert {record.name for record in session.scalars(select(Asset))} == { + "first.bin", + "last.bin", + }, ( + "the batch shares one transaction, so a bare rollback would erase the spec BEFORE " + f"the poisoned one; both neighbours of {poisoned_path.name} must survive" + ) + + +def test_seed_persists_fresh_stat_after_spec_was_built( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "changed-after-walk.bin" + path.write_bytes(b"walk-time") + spec = _spec(path) + + path.write_bytes(b"fresh-seed-time-content") + fresh_mtime_ns = spec["mtime_ns"] + 1_000_000 + os.utime(path, ns=(fresh_mtime_ns, fresh_mtime_ns)) + fresh_stat = path.stat() + + created = seed_asset_specs(session, [spec]) + session.commit() + + persisted = session.scalar( + select(AssetContent).where(AssetContent.path == str(path)) + ) + assert created == 1 + assert fresh_stat.st_size != spec["size_bytes"] + assert fresh_stat.st_mtime_ns != spec["mtime_ns"] + assert persisted is not None + assert persisted.size_bytes == fresh_stat.st_size + assert persisted.mtime_ns == fresh_stat.st_mtime_ns + + +def test_seed_record_failure_preserves_retained_live_content( + session: Session, temp_dir: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = temp_dir / "retained.bin" + path.write_bytes(b"retained-live-content") + spec = _spec(path) + content = create_content( + session, + path=str(path), + size_bytes=spec["size_bytes"], + mtime_ns=spec["mtime_ns"], + ) + record = create_record(session, content.id, path.name) + session.commit() + retained_content_id = content.id + delete_record(session, record.id) + session.commit() + + def _raise_record_creation(*_args, **_kwargs): + raise RuntimeError("forced record creation failure") + + monkeypatch.setattr("app.assets.scanner.create_record", _raise_record_creation) + + with pytest.raises(RuntimeError, match="forced record creation failure"): + seed_asset_specs(session, [spec]) + session.rollback() + + assert session.get(AssetContent, retained_content_id) is not None diff --git a/tests-unit/assets_test/services/test_serving_b.py b/tests-unit/assets_test/services/test_serving_b.py new file mode 100644 index 00000000000..d33181fd039 --- /dev/null +++ b/tests-unit/assets_test/services/test_serving_b.py @@ -0,0 +1,356 @@ +import os +from datetime import datetime +from unittest.mock import patch + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from sqlalchemy import select, update + +from app.assets.api import routes +from app.assets.api.routes import _build_asset_response +from app.assets.database.models import Asset +from app.assets.database.queries.records import ( + RecordPageSpec, + create_content, + create_record, + get_record_by_id, + list_records_page, + mark_content_missing, +) +from app.assets.helpers import to_stored_hash +from app.assets.services.asset_management import ( + asset_exists, + delete_asset_reference, + get_asset_detail, + resolve_asset_for_download, + resolve_hash_to_path, +) +from app.assets.services.lookup import lookup_for_view +from app.assets.services.schemas import AssetData, AssetDetailResult, ReferenceData + +_TS = datetime(2024, 1, 1, 0, 0, 0) + + +def test_missing_content_record_metadata_200_with_tag_content_404( + mock_create_session, session, temp_dir +): + content = create_content(session, path=str(temp_dir / "gone.bin")) + record = create_record(session, content_id=content.id, name="gone.bin") + mark_content_missing(session, content.id) + session.commit() + record_id = record.id + + detail = get_asset_detail(record_id) + assert detail is not None + assert "missing" in detail.tags + + with pytest.raises(FileNotFoundError): + resolve_asset_for_download(record_id) + + +def test_missing_record_is_listed_but_never_served( + mock_create_session, session, temp_dir +): + path = temp_dir / "listed_gone.bin" + path.write_bytes(b"x") + digest = to_stored_hash("a" * 64) + content = create_content(session, path=str(path), hash=digest) + record = create_record(session, content_id=content.id, name="listed_gone.bin") + mark_content_missing(session, content.id) + session.commit() + record_id = record.id + + results, tag_map, total = list_records_page(session, RecordPageSpec()) + assert any(r.id == record_id for r in results) + assert total == 1 + assert "missing" in tag_map.get(record_id, []) + + assert lookup_for_view(session, digest) is None + with pytest.raises(FileNotFoundError): + resolve_asset_for_download(record_id) + + +def test_deleted_record_metadata_and_content_404(mock_create_session, session): + record_id = "00000000-0000-0000-0000-000000000000" + assert get_asset_detail(record_id) is None + with pytest.raises(ValueError, match="not found"): + resolve_asset_for_download(record_id) + + +def test_content_404_when_file_absent_not_marked_missing( + mock_create_session, session, temp_dir +): + path = temp_dir / "absent.bin" + path.write_bytes(b"x") + content = create_content(session, path=str(path)) + record = create_record(session, content_id=content.id, name="absent.bin") + session.commit() + record_id = record.id + path.unlink() + + detail = get_asset_detail(record_id) + assert detail is not None + assert "missing" not in detail.tags + + with pytest.raises(FileNotFoundError): + resolve_asset_for_download(record_id) + + +def test_content_fail_closed_no_sibling_fallback(mock_create_session, session, temp_dir): + digest = "d" * 64 + missing_path = temp_dir / "missing.bin" + live_path = temp_dir / "live.bin" + live_path.write_bytes(b"live") + + missing_content = create_content(session, path=str(missing_path), hash=digest) + live_content = create_content(session, path=str(live_path), hash=digest) + record = create_record(session, content_id=missing_content.id, name="missing.bin") + mark_content_missing(session, missing_content.id) + session.commit() + record_id = record.id + + assert live_content.is_missing is False + assert os.path.isfile(live_path) + + with pytest.raises(FileNotFoundError): + resolve_asset_for_download(record_id) + + +def test_resolve_hash_to_path_refuses_temp_content(mock_create_session, session, temp_dir): + digest = "e" * 64 + f = temp_dir / "temp_only.bin" + f.write_bytes(b"temp") + create_content(session, path=str(f), hash=to_stored_hash(digest)) + session.commit() + + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + result = resolve_hash_to_path(f"blake3:{digest}") + + assert result is None + + +def test_resolve_hash_to_path_uses_newest_record_for_name_and_content_type( + mock_create_session, session, temp_dir +): + digest = "c" * 64 + f = temp_dir / "shared.bin" + f.write_bytes(b"data") + content = create_content(session, path=str(f), hash=to_stored_hash(digest), size_bytes=f.stat().st_size) + older = create_record( + session, + content_id=content.id, + name="older.txt", + mime_type="text/plain", + ) + newer = create_record( + session, + content_id=content.id, + name="newer.png", + mime_type="image/png", + ) + older.created_at = _TS + older.updated_at = _TS + older.last_access_time = _TS + newer.created_at = _TS.replace(microsecond=1) + newer.updated_at = _TS.replace(microsecond=1) + newer.last_access_time = _TS.replace(microsecond=1) + session.commit() + + result = resolve_hash_to_path(f"blake3:{digest}") + + assert result is not None + assert result.download_name == "newer.png" + assert result.content_type == "image/png" + + +def test_resolve_hash_to_path_serves_content_with_zero_records( + mock_create_session, session, temp_dir +): + digest = "d" * 64 + f = temp_dir / "orphan.png" + f.write_bytes(b"data") + content = create_content(session, path=str(f), hash=to_stored_hash(digest), size_bytes=f.stat().st_size) + record = create_record( + session, + content_id=content.id, + name="renamed.txt", + mime_type="text/plain", + ) + session.commit() + + assert delete_asset_reference(record.id) is True + session.expire_all() + assert lookup_for_view(session, f"blake3:{digest}") is not None + assert session.scalars( + select(Asset).where(Asset.content_id == content.id) + ).all() == [] + + result = resolve_hash_to_path(f"blake3:{digest}") + + assert result is not None + assert result.download_name == "orphan.png" + assert result.content_type == "image/png" + + +def test_resolve_hash_to_path_unknown_hash(mock_create_session): + assert resolve_hash_to_path("blake3:" + "f" * 64) is None + + +def test_content_read_updates_last_access_time( + mock_create_session, session, temp_dir +): + f = temp_dir / "read.bin" + f.write_bytes(b"data") + content = create_content(session, path=str(f)) + record = create_record(session, content_id=content.id, name="read.bin") + session.commit() + record_id = record.id + + session.execute( + update(Asset).where(Asset.id == record_id).values(last_access_time=None) + ) + session.commit() + + before_record = get_record_by_id(session, record_id) + assert before_record is not None + before = before_record.last_access_time + assert before is None + + resolve_asset_for_download(record_id) + + session.expire_all() + after_record = get_record_by_id(session, record_id) + assert after_record is not None + after = after_record.last_access_time + assert after is not None + + +def test_view_hash_read_updates_last_access_time( + mock_create_session, session, temp_dir +): + digest = "a" * 64 + f = temp_dir / "view.bin" + f.write_bytes(b"view") + content = create_content(session, path=str(f), hash=to_stored_hash(digest), size_bytes=f.stat().st_size) + record = create_record(session, content_id=content.id, name="view.bin") + session.commit() + record_id = record.id + + session.execute( + update(Asset).where(Asset.id == record_id).values(last_access_time=None) + ) + session.commit() + + result = resolve_hash_to_path(f"blake3:{digest}") + assert result is not None + + session.expire_all() + record_after = get_record_by_id(session, record_id) + assert record_after is not None + assert record_after.last_access_time is not None + + +def test_lookup_for_view_returns_none_for_temp_content(session, temp_dir): + digest = "b" * 64 + f = temp_dir / "view_temp.bin" + f.write_bytes(b"temp") + create_content(session, path=str(f), hash=to_stored_hash(digest)) + session.commit() + + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + assert lookup_for_view(session, to_stored_hash(digest)) is None + + +def test_asset_exists_false_for_temp_content(mock_create_session, session, temp_dir): + digest = "c" * 64 + f = temp_dir / "exists_temp.bin" + f.write_bytes(b"temp") + create_content(session, path=str(f), hash=to_stored_hash(digest)) + session.commit() + + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + assert asset_exists(f"blake3:{digest}") is False + + +@pytest.mark.asyncio +async def test_head_hash_route_404_for_temp_content( + mock_create_session, session, temp_dir, monkeypatch +): + digest = "d" * 64 + f = temp_dir / "head_temp.bin" + f.write_bytes(b"temp") + create_content(session, path=str(f), hash=to_stored_hash(digest)) + session.commit() + + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + response = await routes.head_asset_by_hash( + make_mocked_request( + "HEAD", + f"/api/assets/hash/blake3:{digest}", + match_info={"hash": f"blake3:{digest}"}, + ) + ) + + assert isinstance(response, web.Response) + assert response.status == 404 + + +def test_resolve_hash_to_path_temp_does_not_bump_last_access_time( + mock_create_session, session, temp_dir +): + digest = "f" * 64 + f = temp_dir / "noaccess_temp.bin" + f.write_bytes(b"temp") + content = create_content(session, path=str(f), hash=to_stored_hash(digest)) + record = create_record(session, content_id=content.id, name="noaccess_temp.bin") + session.commit() + record_id = record.id + + session.execute( + update(Asset).where(Asset.id == record_id).values(last_access_time=None) + ) + session.commit() + + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + result = resolve_hash_to_path(f"blake3:{digest}") + + assert result is None + session.expire_all() + record_after = get_record_by_id(session, record_id) + assert record_after is not None + assert record_after.last_access_time is None + + +@pytest.fixture +def sandboxed_comfy_roots(tmp_path): + with patch("app.assets.services.path_utils.folder_paths") as fp: + fp.get_input_directory.return_value = str(tmp_path / "input") + fp.get_output_directory.return_value = str(tmp_path / "output") + fp.get_temp_directory.return_value = str(tmp_path / "temp") + fp.models_dir = str(tmp_path / "models") + yield tmp_path + + +def test_temp_asset_preview_url_still_resolves_type_temp(sandboxed_comfy_roots): + name = "ComfyUI_temp_abcde_00001_.png" + result = AssetDetailResult( + ref=ReferenceData( + id="ref-temp", + name=name, + file_path=str(sandboxed_comfy_roots / "temp" / name), + loader_path=None, + user_metadata=None, + preview_id=None, + created_at=_TS, + updated_at=_TS, + last_access_time=_TS, + ), + asset=AssetData(hash="blake3:abc", size_bytes=1024, mime_type="image/png"), + tags=[], + ) + + resp = _build_asset_response(result, {}) + + assert resp.preview_url == f"/api/view?type=temp&filename={name}" diff --git a/tests-unit/assets_test/services/test_snapshot_hash.py b/tests-unit/assets_test/services/test_snapshot_hash.py new file mode 100644 index 00000000000..b9a8e203b71 --- /dev/null +++ b/tests-unit/assets_test/services/test_snapshot_hash.py @@ -0,0 +1,90 @@ +import builtins +import os +from collections.abc import Callable +from pathlib import Path + +import pytest +from blake3 import blake3 + +from app.assets.services.snapshot_hash import snapshot_hash + + +class _MutatingReader: + def __init__(self, file, mutate: Callable[[], None], mutate_after_close: bool) -> None: + self._file = file + self._mutate = mutate + self._mutate_after_close = mutate_after_close + self._did_mutate = False + + def __enter__(self): + self._file.__enter__() + return self + + def __exit__(self, *args): + result = self._file.__exit__(*args) + if self._mutate_after_close and not self._did_mutate: + self._did_mutate = True + self._mutate() + return result + + def fileno(self) -> int: + return self._file.fileno() + + def read(self, size: int = -1) -> bytes: + result = self._file.read(size) + if not self._mutate_after_close and not self._did_mutate: + self._did_mutate = True + self._mutate() + return result + + +def test_snapshot_hash_returns_digest_for_quiescent_file(tmp_path: Path) -> None: + payload = b"quiescent" * 1024 + path = tmp_path / "asset.bin" + path.write_bytes(payload) + + result = snapshot_hash(str(path), chunk_size=64) + + assert result is not None + digest, stat_result = result + assert digest == blake3(payload).hexdigest() + assert isinstance(stat_result, os.stat_result) + assert stat_result.st_size == len(payload) + + +@pytest.mark.parametrize("mutation", ["bytes", "replace", "unlink", "truncate", "append"]) +def test_snapshot_hash_returns_none_when_file_drifts( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, mutation: str +) -> None: + path = tmp_path / "asset.bin" + path.write_bytes(b"original" * 1024) + original_open = builtins.open + + def mutate() -> None: + match mutation: + case "bytes": + path.write_bytes(b"changed" * 1024) + case "replace": + replacement = tmp_path / "replacement.bin" + replacement.write_bytes(b"replacement") + os.replace(replacement, path) + case "unlink": + path.unlink() + case "truncate": + path.write_bytes(b"") + case "append": + with original_open(path, "ab") as output: + output.write(b"more") + case unreachable: + raise AssertionError(f"unexpected mutation {unreachable}") + + def open_with_mutation(*args, **kwargs): + return _MutatingReader( + original_open(*args, **kwargs), + mutate, + mutation in {"replace", "unlink"}, + ) + + monkeypatch.setattr(builtins, "open", open_with_mutation) + + assert snapshot_hash(str(path), chunk_size=64) is None diff --git a/tests-unit/assets_test/services/test_split_policy.py b/tests-unit/assets_test/services/test_split_policy.py new file mode 100644 index 00000000000..0e612e79786 --- /dev/null +++ b/tests-unit/assets_test/services/test_split_policy.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import os +from collections.abc import Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from pathlib import Path +from unittest.mock import patch + +import sqlalchemy as sa +from sqlalchemy import select +from sqlalchemy.orm import Session + +import pytest + +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries import create_content, create_record +from app.assets.database.queries.records import ( + RecordPageSpec, + fetch_record_tags, + list_records_page, +) +from app.assets.helpers import to_stored_hash +from app.assets.scanner import get_unenriched_assets_for_roots +from app.assets.scanner_changes import ( + clear_pending_verifications, + detect_content_change, + drain_pending_verifications, +) +from app.assets.services.hash_mode_state import ( + clear_transition_queue, + drain_transition_queue, + enqueue_transition_work, +) +from app.assets.services.lookup import lookup_for_view +from app.assets.services.snapshot_hash import snapshot_hash + + +@dataclass(frozen=True, slots=True) +class _FakeStat: + + st_size: int + st_mtime_ns: int + + +@contextmanager +def _reuse_session(session: Session) -> Iterator[Session]: + yield session + + +def _raw_system_metadata(session: Session, record_id: str) -> object: + return session.execute( + sa.text("SELECT system_metadata FROM assets WHERE id = :id"), + {"id": record_id}, + ).scalar() + + +def _candidates_under(session: Session, temp_dir: Path, *, compute_hashes: bool) -> set[str]: + with ( + patch("app.assets.scanner.create_session", lambda: _reuse_session(session)), + patch( + "app.assets.scanner.get_scan_prefixes_for_root", + return_value=[str(temp_dir)], + ), + ): + rows = get_unenriched_assets_for_roots(("models",), compute_hashes=compute_hashes) + return {row.record_id for row in rows} + + +@pytest.fixture(autouse=True) +def _transition_queue_isolation() -> Iterator[None]: + clear_transition_queue() + yield + clear_transition_queue() + + +@pytest.fixture(autouse=True) +def _input_base_is_temp_dir(temp_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + + +@pytest.fixture(autouse=True) +def _pending_verification_isolation() -> Iterator[None]: + clear_pending_verifications() + yield + clear_pending_verifications() + + +def _seed_hashed_content(session: Session, path: Path, data: bytes) -> AssetContent: + path.write_bytes(data) + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, verified_stat = snapshot + return create_content( + session, + str(path), + to_stored_hash(digest), + verified_stat.st_size, + verified_stat.st_mtime_ns, + ) + + +def _bump_mtime(path: Path) -> os.stat_result: + before = path.stat() + bumped = before.st_mtime_ns + 5_000_000_000 + os.utime(path, ns=(bumped, bumped)) + after = path.stat() + assert after.st_mtime_ns != before.st_mtime_ns + assert after.st_size == before.st_size + return after + + +def _rewrite_same_size(path: Path, data: bytes) -> os.stat_result: + before = path.stat() + assert len(data) == before.st_size + assert data != path.read_bytes() + path.write_bytes(data) + bumped = before.st_mtime_ns + 5_000_000_000 + os.utime(path, ns=(bumped, bumped)) + after = path.stat() + assert after.st_size == before.st_size + assert after.st_mtime_ns != before.st_mtime_ns + return after + + +def test_split_record_is_enrich_candidate_in_hash_mode( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "split.safetensors" + content = create_content(session, str(path), hash="blake3:deadbeef") + record = create_record(session, content.id, path.name) + session.commit() + record_id = record.id + + candidates = _candidates_under(session, temp_dir, compute_hashes=True) + + assert record_id in candidates + + +def test_enriched_record_is_not_enrich_candidate_in_hash_mode( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "enriched.safetensors" + content = create_content(session, str(path), hash="blake3:deadbeef") + record = create_record( + session, content.id, path.name, system_metadata={"architecture": "flux"} + ) + session.commit() + record_id = record.id + + candidates = _candidates_under(session, temp_dir, compute_hashes=True) + + assert record_id not in candidates + + +def test_same_size_mtime_bump_does_not_split(session: Session, temp_dir: Path) -> None: + path = temp_dir / "touched.safetensors" + content = create_content(session, str(path), hash=None, size_bytes=100, mtime_ns=1000) + record = create_record( + session, content.id, path.name, tags=["keepme"], system_metadata={"k": "v"} + ) + session.commit() + content_id, record_id = content.id, record.id + + detect_content_change( + session, content, _FakeStat(st_size=100, st_mtime_ns=2000), hashing_is_enabled=False + ) + session.commit() + session.expire_all() + + live = session.get(AssetContent, content_id) + assert live is not None and live.is_missing is False + rows_at_path = list( + session.scalars(select(AssetContent).where(AssetContent.path == str(path))) + ) + assert len(rows_at_path) == 1 + surviving = session.get(Asset, record_id) + assert surviving.system_metadata == {"k": "v"} + tags = fetch_record_tags(session, record_id) + assert "keepme" in tags + assert "missing" not in tags + + assert live.mtime_ns == 2000 + assert live.size_bytes == 100 + + +def test_accepted_mtime_bump_drops_the_unverifiable_hash( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "synced.safetensors" + content = _seed_hashed_content(session, path, b"rsynced bytes") + record = create_record( + session, content.id, path.name, tags=["keepme"], system_metadata={"k": "v"} + ) + session.commit() + content_id, record_id, stored_hash = content.id, record.id, content.hash + assert lookup_for_view(session, stored_hash) is not None + + observed = _bump_mtime(path) + detect_content_change(session, content, observed, hashing_is_enabled=False) + session.commit() + session.expire_all() + + live = session.get(AssetContent, content_id) + assert live.hash is None + assert lookup_for_view(session, stored_hash) is None + + assert live.is_missing is False + assert live.mtime_ns == observed.st_mtime_ns + assert live.size_bytes == observed.st_size + + surviving = session.get(Asset, record_id) + assert surviving is not None and surviving.content_id == content_id + assert surviving.system_metadata == {"k": "v"} + assert "keepme" in fetch_record_tags(session, record_id) + listed, _, _ = list_records_page(session, RecordPageSpec(limit=100)) + assert record_id in {row.id for row in listed} + + +def test_same_size_content_change_is_never_served_under_the_old_hash( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "overwritten.safetensors" + content = _seed_hashed_content(session, path, b"AAAA") + create_record(session, content.id, path.name, tags=["keepme"]) + session.commit() + old_hash = content.hash + assert lookup_for_view(session, old_hash) is not None + + observed = _rewrite_same_size(path, b"BBBB") + detect_content_change(session, content, observed, hashing_is_enabled=False) + session.commit() + session.expire_all() + + assert path.read_bytes() == b"BBBB" + assert lookup_for_view(session, old_hash) is None + + +def test_accepted_mtime_bump_is_not_re_detected_by_the_next_scan( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "resynced.safetensors" + content = _seed_hashed_content(session, path, b"cloud synced bytes") + create_record(session, content.id, path.name, tags=["keepme"]) + session.commit() + content_id = content.id + detect_content_change(session, content, _bump_mtime(path), hashing_is_enabled=False) + session.commit() + + detect_content_change(session, content, path.stat(), hashing_is_enabled=True) + + assert drain_pending_verifications(session) == 0 + + detect_content_change(session, content, path.stat(), hashing_is_enabled=False) + session.commit() + session.expire_all() + rows_at_path = list( + session.scalars(select(AssetContent).where(AssetContent.path == str(path))) + ) + assert len(rows_at_path) == 1 + assert rows_at_path[0].id == content_id + assert rows_at_path[0].is_missing is False + + +def test_dropped_hash_is_refilled_in_place_by_a_later_hash_mode_pass( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "refilled.safetensors" + content = _seed_hashed_content(session, path, b"cloud synced bytes") + record = create_record( + session, content.id, path.name, tags=["keepme"], system_metadata={"k": "v"} + ) + session.commit() + content_id, record_id = content.id, record.id + assert record_id not in _candidates_under(session, temp_dir, compute_hashes=True) + + detect_content_change(session, content, _bump_mtime(path), hashing_is_enabled=False) + session.commit() + session.expire_all() + assert session.get(AssetContent, content_id).hash is None + assert record_id in _candidates_under(session, temp_dir, compute_hashes=True) + + enqueue_transition_work(session, "off_to_on") + drain_transition_queue(session) + session.commit() + session.expire_all() + + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + live = session.get(AssetContent, content_id) + assert live.is_missing is False + assert live.hash == to_stored_hash(snapshot[0]) + assert lookup_for_view(session, live.hash).id == content_id + rows_at_path = list( + session.scalars(select(AssetContent).where(AssetContent.path == str(path))) + ) + assert len(rows_at_path) == 1 + assert "keepme" in fetch_record_tags(session, record_id) + assert session.get(Asset, record_id).system_metadata == {"k": "v"} + assert record_id not in _candidates_under(session, temp_dir, compute_hashes=True) + + +def test_mtime_and_size_change_splits_with_null_metadata( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "grown.safetensors" + content = create_content(session, str(path), hash=None, size_bytes=100, mtime_ns=1000) + create_record( + session, content.id, path.name, tags=["oldtag"], system_metadata={"k": "v"} + ) + session.commit() + old_content_id = content.id + + detect_content_change( + session, content, _FakeStat(st_size=200, st_mtime_ns=2000), hashing_is_enabled=False + ) + session.commit() + session.expire_all() + + assert session.get(AssetContent, old_content_id).is_missing is True + live = session.scalar( + select(AssetContent).where( + AssetContent.path == str(path), AssetContent.is_missing.is_(False) + ) + ) + assert live is not None and live.id != old_content_id + assert live.size_bytes == 200 and live.mtime_ns == 2000 + new_record = session.scalar(select(Asset).where(Asset.content_id == live.id)) + assert new_record is not None + + assert new_record.system_metadata is None + assert _raw_system_metadata(session, new_record.id) is None + assert "oldtag" not in fetch_record_tags(session, new_record.id) + assert new_record.id in _candidates_under(session, temp_dir, compute_hashes=True) + + +def test_mtime_unchanged_size_changed_does_not_split( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "weird.safetensors" + content = create_content(session, str(path), hash=None, size_bytes=100, mtime_ns=1000) + create_record(session, content.id, path.name, tags=["keepme"]) + session.commit() + content_id = content.id + + detect_content_change( + session, content, _FakeStat(st_size=999, st_mtime_ns=1000), hashing_is_enabled=False + ) + session.commit() + session.expire_all() + + assert session.get(AssetContent, content_id).is_missing is False + rows_at_path = list( + session.scalars(select(AssetContent).where(AssetContent.path == str(path))) + ) + assert len(rows_at_path) == 1 + + +def test_transition_drain_split_replacement_has_null_metadata( + session: Session, temp_dir: Path +) -> None: + path = temp_dir / "changed.bin" + path.write_bytes(b"old bytes") + old_snapshot = snapshot_hash(str(path)) + assert old_snapshot is not None + old_digest, _ = old_snapshot + stat = path.stat() + old_content = create_content( + session, str(path), to_stored_hash(old_digest), stat.st_size, stat.st_mtime_ns + ) + old_content_id = old_content.id + create_record( + session, old_content_id, "changed.bin", tags=["oldtag"], system_metadata={"k": "v"} + ) + path.write_bytes(b"different new bytes") + + enqueue_transition_work(session, "off_to_on") + drain_transition_queue(session) + session.commit() + session.expire_all() + + assert session.get(AssetContent, old_content_id).is_missing is True + live = session.scalar( + select(AssetContent).where( + AssetContent.path == str(path), AssetContent.is_missing.is_(False) + ) + ) + assert live is not None and live.id != old_content_id + new_record = session.scalar(select(Asset).where(Asset.content_id == live.id)) + assert new_record is not None + assert new_record.system_metadata is None + assert _raw_system_metadata(session, new_record.id) is None + assert "oldtag" not in fetch_record_tags(session, new_record.id) diff --git a/tests-unit/assets_test/services/test_stored_hash_prefix.py b/tests-unit/assets_test/services/test_stored_hash_prefix.py new file mode 100644 index 00000000000..c28d6fb342a --- /dev/null +++ b/tests-unit/assets_test/services/test_stored_hash_prefix.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import json +import os +import uuid +from contextlib import contextmanager +from unittest.mock import AsyncMock + +import pytest +from aiohttp import web +from aiohttp.test_utils import make_mocked_request +from sqlalchemy import func, select +from sqlalchemy.orm import Session as SASession + +from app.assets import mode +from app.assets.api import routes +import app.assets.mode as mode_module +import folder_paths +from app.assets.database.models import Asset, AssetContent +from app.assets.database.queries.records import create_record +from app.assets.scanner import enrich_asset +from app.assets.scanner_changes import recover_missing_content +from app.assets.services import asset_management, ingest +from app.assets.services.asset_management import get_asset_detail +from app.assets.services.ingest import ( + HashMismatchError, + upload_from_temp_path, +) +from app.assets.services.snapshot_hash import snapshot_hash + + +@pytest.fixture +def hashing_on(): + class FakeArgs: + enable_asset_hashing = True + + class DisabledArgs: + enable_asset_hashing = False + + mode_module.init(FakeArgs()) + yield + mode_module.init(DisabledArgs()) + + +def _write_temp(content: bytes) -> str: + uploads_root = os.path.join( + folder_paths.get_temp_directory(), "uploads", uuid.uuid4().hex + ) + os.makedirs(uploads_root, exist_ok=True) + path = os.path.join(uploads_root, ".upload.part") + with open(path, "wb") as file: + file.write(content) + return path + + +def test_upload_stores_prefixed_hash_expected_hash_succeeds_and_reuses_content( + mock_create_session, hashing_on +): + content_bytes = b"prefixed-stored-upload-bytes" + temp1 = _write_temp(content_bytes) + snapshot = snapshot_hash(temp1) + assert snapshot is not None + digest, _ = snapshot + expected = f"blake3:{digest}" + assert expected.startswith("blake3:") + + temp2 = _write_temp(content_bytes) + try: + r1 = upload_from_temp_path( + temp_path=temp1, + name="pref.bin", + tags=["output"], + client_filename="pref.bin", + expected_hash=expected, + ) + assert r1.created_new is True + + with mock_create_session() as session: + live = list( + session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + ) + assert len(live) == 1 + assert live[0].hash == expected + assert live[0].hash.startswith("blake3:") + + assert r1.asset.hash == expected + + r2 = upload_from_temp_path( + temp_path=temp2, + name="pref.bin", + tags=["output"], + client_filename="pref.bin", + expected_hash=expected, + ) + assert r2.created_new is True + assert r2.ref.id != r1.ref.id, "every upload is its own delivery record" + with mock_create_session() as session: + assert session.scalar(select(func.count()).select_from(AssetContent)) == 1 + assert session.scalar(select(func.count()).select_from(Asset)) == 2 + finally: + for path in (temp1, temp2): + if os.path.exists(path): + os.unlink(path) + + +def test_upload_expected_hash_mismatch_still_rejected(mock_create_session, hashing_on): + temp = _write_temp(b"mismatch-bytes") + try: + with pytest.raises(HashMismatchError): + upload_from_temp_path( + temp_path=temp, + name="mismatch.bin", + tags=["output"], + client_filename="mismatch.bin", + expected_hash=f"blake3:{'0' * 64}", + ) + with mock_create_session() as session: + assert session.scalar(select(func.count()).select_from(Asset)) == 0 + assert session.scalar(select(func.count()).select_from(AssetContent)) == 0 + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def test_enrich_fills_deferred_hash_prefixed(session, temp_dir): + path = temp_dir / "deferred.bin" + path.write_bytes(b"deferred output bytes") + stat = path.stat() + + content = AssetContent( + path=str(path), + hash=None, + size_bytes=stat.st_size, + mtime_ns=stat.st_mtime_ns, + ) + session.add(content) + session.flush() + record = create_record(session, content.id, path.name) + session.commit() + + enriched = enrich_asset( + session, + file_path=str(path), + content_id=content.id, + record_id=record.id, + extract_metadata=False, + compute_hash=True, + ) + + assert enriched is True + stored = session.get(AssetContent, content.id).hash + assert stored is not None + assert stored.startswith("blake3:") + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, _ = snapshot + assert stored == f"blake3:{digest}" + + +def test_recovery_matches_prefixed_stored_hash(session, temp_dir): + path = temp_dir / "recover.bin" + original_bytes = b"recover-me-bytes" + path.write_bytes(original_bytes) + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, _ = snapshot + stored = f"blake3:{digest}" + assert stored.startswith("blake3:") + + content = AssetContent( + path=str(path), + hash=stored, + is_missing=True, + size_bytes=0, + mtime_ns=None, + ) + session.add(content) + session.flush() + create_record(session, content.id, path.name) + session.commit() + + path.unlink() + path.write_bytes(original_bytes) + stat = os.stat(str(path)) + result = recover_missing_content( + session, str(path), stat, hashing_is_enabled=True + ) + + assert result == "recovered" + recovered = session.get(AssetContent, content.id) + assert recovered is not None + assert recovered.is_missing is False + assert recovered.hash == stored + + +@pytest.mark.asyncio +async def test_all_read_surfaces_agree_on_prefixed_hash( + db_engine, monkeypatch, hashing_on +): + @contextmanager + def _factory(): + with SASession(db_engine) as sess: + yield sess + + monkeypatch.setattr(routes, "create_session", lambda: SASession(db_engine)) + monkeypatch.setattr(routes, "_ASSETS_ENABLED", True) + monkeypatch.setattr(mode, "hashing_enabled", lambda: True) + monkeypatch.setattr(ingest, "create_session", _factory) + monkeypatch.setattr(asset_management, "create_session", _factory) + + content_bytes = b"one-asset-all-surfaces-agree" + temp = _write_temp(content_bytes) + snapshot = snapshot_hash(temp) + assert snapshot is not None + digest, _ = snapshot + expected = f"blake3:{digest}" + + try: + upload_result = ingest.upload_from_temp_path( + temp_path=temp, + name="surf.bin", + tags=["output"], + client_filename="surf.bin", + ) + asset_id = upload_result.ref.id + + assert upload_result.asset is not None + assert upload_result.asset.hash == expected + upload_resp = routes._build_asset_response(upload_result, {}) + assert upload_resp.hash == expected + + detail = get_asset_detail(asset_id) + assert detail is not None + assert detail.asset is not None + assert detail.asset.hash == expected + + get_resp = await routes.get_asset_route( + make_mocked_request( + "GET", f"/api/assets/{asset_id}", match_info={"id": asset_id} + ) + ) + assert isinstance(get_resp, web.Response) + assert isinstance(get_resp.body, bytes | bytearray) + get_body = json.loads(get_resp.body) + assert get_body["hash"] == expected + + list_resp = await routes.list_assets_route( + make_mocked_request("GET", "/api/assets") + ) + assert isinstance(list_resp, web.Response) + assert isinstance(list_resp.body, bytes | bytearray) + list_body = json.loads(list_resp.body) + item = next(a for a in list_body["assets"] if a["id"] == asset_id) + assert item["hash"] == expected + + head_resp = await routes.head_asset_by_hash( + make_mocked_request( + "HEAD", + f"/api/assets/hash/{expected}", + match_info={"hash": expected}, + ) + ) + assert isinstance(head_resp, web.Response) + assert head_resp.status == 200 + + fh_req = AsyncMock(spec=web.Request) + fh_req.json.return_value = { + "hash": expected, + "name": "fromhash.bin", + "tags": ["output"], + } + fh_resp = await routes.create_asset_from_hash_route(fh_req) + assert isinstance(fh_resp, web.Response) + assert fh_resp.status == 201 + assert isinstance(fh_resp.body, bytes | bytearray) + fh_body = json.loads(fh_resp.body) + assert fh_body["hash"] == expected + finally: + if os.path.exists(temp): + os.unlink(temp) diff --git a/tests-unit/assets_test/services/test_tag_histogram.py b/tests-unit/assets_test/services/test_tag_histogram.py index 7bcd518ec92..6a2c1840042 100644 --- a/tests-unit/assets_test/services/test_tag_histogram.py +++ b/tests-unit/assets_test/services/test_tag_histogram.py @@ -1,123 +1,17 @@ -"""Tests for list_tag_histogram service function.""" from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference -from app.assets.database.queries import ensure_tags_exist, add_tags_to_reference -from app.assets.helpers import get_utc_now -from app.assets.services.tagging import list_tag_histogram +from app.assets.database.queries import create_content, create_record, list_records_page +from app.assets.database.queries.records import RecordPageSpec -def _make_asset(session: Session, hash_val: str = "blake3:test") -> Asset: - asset = Asset(hash=hash_val, size_bytes=1024) - session.add(asset) - session.flush() - return asset +def test_tag_selection_counts_records_not_content_rows(session: Session) -> None: + content = create_content(session, "/models/shared.safetensors") + create_record(session, content.id, "one", tags=["model"]) + create_record(session, content.id, "two", tags=["model"]) - -def _make_reference( - session: Session, - asset: Asset, - name: str = "test", - owner_id: str = "", -) -> AssetReference: - now = get_utc_now() - ref = AssetReference( - owner_id=owner_id, - name=name, - asset_id=asset.id, - created_at=now, - updated_at=now, - last_access_time=now, + records, _, _ = list_records_page( + session, + RecordPageSpec(all_tags=("model",)), ) - session.add(ref) - session.flush() - return ref - - -class TestListTagHistogram: - def test_returns_counts_for_all_tags(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["alpha", "beta"]) - a1 = _make_asset(session, "blake3:aaa") - r1 = _make_reference(session, a1, name="r1") - add_tags_to_reference(session, reference_id=r1.id, tags=["alpha", "beta"]) - - a2 = _make_asset(session, "blake3:bbb") - r2 = _make_reference(session, a2, name="r2") - add_tags_to_reference(session, reference_id=r2.id, tags=["alpha"]) - session.commit() - - result = list_tag_histogram() - - assert result["alpha"] == 2 - assert result["beta"] == 1 - - def test_empty_when_no_assets(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["unused"]) - session.commit() - - result = list_tag_histogram() - - assert result == {} - - def test_include_tags_filter(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["models", "loras", "input"]) - a1 = _make_asset(session, "blake3:aaa") - r1 = _make_reference(session, a1, name="r1") - add_tags_to_reference(session, reference_id=r1.id, tags=["models", "loras"]) - - a2 = _make_asset(session, "blake3:bbb") - r2 = _make_reference(session, a2, name="r2") - add_tags_to_reference(session, reference_id=r2.id, tags=["input"]) - session.commit() - - result = list_tag_histogram(include_tags=["models"]) - - # Only r1 has "models", so only its tags appear - assert "models" in result - assert "loras" in result - assert "input" not in result - - def test_exclude_tags_filter(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["models", "loras", "input"]) - a1 = _make_asset(session, "blake3:aaa") - r1 = _make_reference(session, a1, name="r1") - add_tags_to_reference(session, reference_id=r1.id, tags=["models", "loras"]) - - a2 = _make_asset(session, "blake3:bbb") - r2 = _make_reference(session, a2, name="r2") - add_tags_to_reference(session, reference_id=r2.id, tags=["input"]) - session.commit() - - result = list_tag_histogram(exclude_tags=["models"]) - - # r1 excluded, only r2's tags remain - assert "input" in result - assert "loras" not in result - - def test_name_contains_filter(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["alpha", "beta"]) - a1 = _make_asset(session, "blake3:aaa") - r1 = _make_reference(session, a1, name="my_model.safetensors") - add_tags_to_reference(session, reference_id=r1.id, tags=["alpha"]) - - a2 = _make_asset(session, "blake3:bbb") - r2 = _make_reference(session, a2, name="picture.png") - add_tags_to_reference(session, reference_id=r2.id, tags=["beta"]) - session.commit() - - result = list_tag_histogram(name_contains="model") - - assert "alpha" in result - assert "beta" not in result - - def test_limit_caps_results(self, mock_create_session, session: Session): - tags = [f"tag{i}" for i in range(10)] - ensure_tags_exist(session, tags) - a = _make_asset(session, "blake3:aaa") - r = _make_reference(session, a, name="r1") - add_tags_to_reference(session, reference_id=r.id, tags=tags) - session.commit() - - result = list_tag_histogram(limit=3) - assert len(result) == 3 + assert len(records) == 2 diff --git a/tests-unit/assets_test/services/test_tag_protection.py b/tests-unit/assets_test/services/test_tag_protection.py new file mode 100644 index 00000000000..7f3b1cec6f5 --- /dev/null +++ b/tests-unit/assets_test/services/test_tag_protection.py @@ -0,0 +1,107 @@ +import json +from types import SimpleNamespace + +import pytest + +from app.assets.api import routes + +_RECORD_ID = "00000000-0000-0000-0000-000000000001" +_SYSTEM_TAG_ENVELOPE = { + "error": { + "code": "SYSTEM_TAG_FORBIDDEN", + "message": "Tag 'missing' is system-managed and cannot be modified via the API", + "details": {"tag": "missing"}, + } +} + + +class _JsonRequest: + def __init__(self, tags: list[str]) -> None: + self.match_info = {"id": _RECORD_ID} + self._payload = {"tags": tags} + + async def json(self) -> dict[str, list[str]]: + return self._payload + + +class _UserManager: + def get_request_user_id(self, _request: _JsonRequest) -> str: + return "test-user" + + +@pytest.mark.asyncio +async def test_add_missing_tag_returns_400(monkeypatch): + monkeypatch.setattr(routes, "USER_MANAGER", _UserManager()) + monkeypatch.setattr( + routes, + "apply_tags", + lambda **_kwargs: SimpleNamespace(added=[], already_present=[], total_tags=[]), + ) + + response = await routes.add_asset_tags.__wrapped__(_JsonRequest(["missing"])) + + assert response.status == 400 + assert json.loads(response.body) == _SYSTEM_TAG_ENVELOPE + + +@pytest.mark.asyncio +async def test_remove_missing_tag_returns_400(monkeypatch): + monkeypatch.setattr(routes, "USER_MANAGER", _UserManager()) + monkeypatch.setattr( + routes, + "remove_tags", + lambda **_kwargs: SimpleNamespace(removed=[], not_present=[], total_tags=[]), + ) + + response = await routes.delete_asset_tags.__wrapped__(_JsonRequest(["missing"])) + + assert response.status == 400 + assert json.loads(response.body) == _SYSTEM_TAG_ENVELOPE + + +@pytest.mark.asyncio +async def test_other_tags_unaffected(monkeypatch): + monkeypatch.setattr(routes, "USER_MANAGER", _UserManager()) + calls: list[tuple[str, list[str]]] = [] + + def apply_tags(**kwargs): + calls.append(("add", kwargs["tags"])) + return SimpleNamespace( + added=kwargs["tags"], already_present=[], total_tags=kwargs["tags"] + ) + + def remove_tags(**kwargs): + calls.append(("remove", kwargs["tags"])) + return SimpleNamespace( + removed=kwargs["tags"], not_present=[], total_tags=[], protected=[] + ) + + monkeypatch.setattr(routes, "apply_tags", apply_tags) + monkeypatch.setattr(routes, "remove_tags", remove_tags) + + add_response = await routes.add_asset_tags.__wrapped__(_JsonRequest(["manual"])) + remove_response = await routes.delete_asset_tags.__wrapped__( + _JsonRequest(["manual"]) + ) + + assert add_response.status == 200 + assert remove_response.status == 200 + assert calls == [("add", ["manual"]), ("remove", ["manual"])] + + +@pytest.mark.asyncio +async def test_remove_tags_response_exposes_protected_bucket(monkeypatch): + monkeypatch.setattr(routes, "USER_MANAGER", _UserManager()) + monkeypatch.setattr( + routes, + "remove_tags", + lambda **_kwargs: SimpleNamespace( + removed=[], not_present=[], total_tags=["auto"], protected=["auto"] + ), + ) + + response = await routes.delete_asset_tags.__wrapped__(_JsonRequest(["auto"])) + + assert response.status == 200 + body = json.loads(response.body) + assert body["protected"] == ["auto"] diff --git a/tests-unit/assets_test/services/test_tag_write_conflicts.py b/tests-unit/assets_test/services/test_tag_write_conflicts.py new file mode 100644 index 00000000000..32bf41fd1c6 --- /dev/null +++ b/tests-unit/assets_test/services/test_tag_write_conflicts.py @@ -0,0 +1,166 @@ +from contextlib import contextmanager +from datetime import datetime +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, select, update +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session as SASession + +import app.assets.database.queries.records as records_module +import app.assets.services.asset_management as asset_management_module +import app.assets.services.tagging as tagging_module +from app.assets.database.models import Asset, AssetTag, Base, Tag +from app.assets.database.queries.records import ( + create_content, + create_record, + mark_content_missing, +) +from app.assets.services.asset_management import update_asset_metadata +from app.assets.services.tagging import apply_tags + +STALE = datetime(2020, 1, 1, 0, 0, 0) +RACED = "raced" +UNRELATED = "unrelated" + + +def _file_backed_engine(db_path): + engine = create_engine( + f"sqlite:///{db_path}", + connect_args={"check_same_thread": False, "timeout": 0.2}, + ) + Base.metadata.create_all(engine) + return engine + + +def _seed_record(session, path: str, tags=None) -> str: + content = create_content(session, path) + record = create_record(session, content.id, "fixture", tags=tags) + session.commit() + session.execute(update(Asset).where(Asset.id == record.id).values(updated_at=STALE)) + session.commit() + return record.id + + +def _updated_at(session, record_id: str) -> datetime: + session.expire_all() + return session.scalar(select(Asset.updated_at).where(Asset.id == record_id)) + + +def _tag_names(session, record_id: str) -> list[str]: + return sorted( + session.scalars(select(AssetTag.tag_name).where(AssetTag.asset_id == record_id)).all() + ) + + +def test_apply_tags_loses_a_tag_race_without_raising_and_reports_it_honestly(tmp_path): + engine = _file_backed_engine(tmp_path / "tag_race.db") + with SASession(engine) as seed_session: + record_id = _seed_record(seed_session, "/tmp/tag-race-fixture", tags=[UNRELATED]) + + def connection_a_commits_the_raced_tag() -> None: + with SASession(engine) as connection_a: + connection_a.add(Tag(name=RACED)) + connection_a.flush() + connection_a.add( + AssetTag(asset_id=record_id, tag_name=RACED, origin="manual") + ) + connection_a.commit() + + fired: list[bool] = [] + + @contextmanager + def racing_session_factory(): + with SASession(engine) as session_b: + real_add = session_b.add + + def add_racing_the_winner(instance, *args, **kwargs): + if isinstance(instance, Tag) and instance.name == RACED and not fired: + fired.append(True) + connection_a_commits_the_raced_tag() + return real_add(instance, *args, **kwargs) + + session_b.add = add_racing_the_winner + yield session_b + + with patch("app.assets.services.tagging.create_session", racing_session_factory): + result = apply_tags(record_id, [RACED]) + + assert fired, "the interleave never fired; the test proves nothing" + assert result.added == [], ( + "session B lost the race; it inserted nothing and must not claim it did" + ) + assert result.already_present == [RACED], ( + "already_present reports the REQUESTED tags that were already there — never " + "unrelated pre-existing tags the caller did not ask about" + ) + assert sorted(result.total_tags) == [RACED, UNRELATED] + + with SASession(engine) as check: + assert _tag_names(check, record_id) == [RACED, UNRELATED] + assert _updated_at(check, record_id) == STALE, ( + "a race loser changed no link, so it is not an edit and must not move updated_at" + ) + + +def test_ensure_tag_link_reraises_when_the_parent_asset_is_missing(db_engine_fk): + with SASession(db_engine_fk) as session: + session.add(Tag(name="orphan-link")) + session.flush() + + with pytest.raises(IntegrityError): + records_module.ensure_tag_link( + session, + asset_id="no-such-asset", + tag_name="orphan-link", + origin="manual", + ) + + +def _drive_create_record(session, record_id): + content = create_content(session, "/tmp/four-sites-create-record") + create_record(session, content.id, "created", tags=["spied"]) + + +def _drive_mark_content_missing(session, record_id): + content_id = session.scalar(select(Asset.content_id).where(Asset.id == record_id)) + mark_content_missing(session, content_id) + + +def _drive_update_asset_metadata(session, record_id): + update_asset_metadata(record_id, tags=["spied"]) + + +def _drive_apply_tags(session, record_id): + apply_tags(record_id, ["spied"]) + + +@pytest.mark.parametrize( + ("target_module", "drive"), + [ + (records_module, _drive_create_record), + (records_module, _drive_mark_content_missing), + (asset_management_module, _drive_update_asset_metadata), + (tagging_module, _drive_apply_tags), + ], + ids=["create_record", "mark_content_missing", "update_asset_metadata", "apply_tags"], +) +def test_every_tag_write_site_routes_through_the_conflict_safe_helper( + session, mock_create_session, monkeypatch, target_module, drive +): + record_id = _seed_record(session, "/tmp/four-sites-fixture") + calls: list[str] = [] + real_ensure_tag_link = records_module.ensure_tag_link + + def spying_ensure_tag_link(*args, **kwargs): + calls.append(kwargs["tag_name"]) + return real_ensure_tag_link(*args, **kwargs) + + monkeypatch.setattr(target_module, "ensure_tag_link", spying_ensure_tag_link) + + drive(session, record_id) + + assert calls, ( + "this site still writes asset_tags with a bare check-then-insert; a concurrent " + "writer inserting the same link makes it raise instead of settling" + ) diff --git a/tests-unit/assets_test/services/test_tagging.py b/tests-unit/assets_test/services/test_tagging.py index fa121db3ecf..84db76a3c34 100644 --- a/tests-unit/assets_test/services/test_tagging.py +++ b/tests-unit/assets_test/services/test_tagging.py @@ -1,197 +1,52 @@ -"""Tests for tagging services.""" -import pytest from sqlalchemy.orm import Session -from app.assets.database.models import Asset, AssetReference -from app.assets.database.queries import ensure_tags_exist, add_tags_to_reference -from app.assets.helpers import get_utc_now -from app.assets.services import apply_tags, remove_tags, list_tags +from app.assets.database.models import AssetTag, Tag +from app.assets.database.queries import create_content, create_record, fetch_record_tags +from app.assets.services.tagging import remove_tags -def _make_asset(session: Session, hash_val: str = "blake3:test") -> Asset: - asset = Asset(hash=hash_val, size_bytes=1024) - session.add(asset) - session.flush() - return asset +def test_tags_are_birth_facts_of_a_record(session: Session) -> None: + content = create_content(session, "/models/model.safetensors") + record = create_record(session, content.id, "model", tags=["model", "checkpoint"]) + assert fetch_record_tags(session, record.id) == ["checkpoint", "model"] -def _make_reference( - session: Session, - asset: Asset, - name: str = "test", - owner_id: str = "", -) -> AssetReference: - now = get_utc_now() - ref = AssetReference( - owner_id=owner_id, - name=name, - asset_id=asset.id, - created_at=now, - updated_at=now, - last_access_time=now, - ) - session.add(ref) - session.flush() - return ref - - -class TestApplyTags: - def test_adds_new_tags(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - session.commit() - - result = apply_tags( - reference_id=ref.id, - tags=["alpha", "beta"], - ) - - assert set(result.added) == {"alpha", "beta"} - assert result.already_present == [] - assert set(result.total_tags) == {"alpha", "beta"} - - def test_reports_already_present(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["existing"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["existing"]) - session.commit() - - result = apply_tags( - reference_id=ref.id, - tags=["existing", "new"], - ) - - assert result.added == ["new"] - assert result.already_present == ["existing"] - - def test_raises_for_nonexistent_ref(self, mock_create_session): - with pytest.raises(ValueError, match="not found"): - apply_tags(reference_id="nonexistent", tags=["x"]) - - def test_raises_for_wrong_owner(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, owner_id="user1") - session.commit() - - with pytest.raises(PermissionError, match="not owner"): - apply_tags( - reference_id=ref.id, - tags=["new"], - owner_id="user2", - ) - - -class TestRemoveTags: - def test_removes_tags(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["a", "b", "c"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["a", "b", "c"]) - session.commit() - - result = remove_tags( - reference_id=ref.id, - tags=["a", "b"], - ) - - assert set(result.removed) == {"a", "b"} - assert result.not_present == [] - assert result.total_tags == ["c"] - - def test_reports_not_present(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset) - ensure_tags_exist(session, ["present"]) - add_tags_to_reference(session, reference_id=ref.id, tags=["present"]) - session.commit() - result = remove_tags( - reference_id=ref.id, - tags=["present", "absent"], - ) - - assert result.removed == ["present"] - assert result.not_present == ["absent"] - - def test_raises_for_nonexistent_ref(self, mock_create_session): - with pytest.raises(ValueError, match="not found"): - remove_tags(reference_id="nonexistent", tags=["x"]) - - def test_raises_for_wrong_owner(self, mock_create_session, session: Session): - asset = _make_asset(session) - ref = _make_reference(session, asset, owner_id="user1") - session.commit() - - with pytest.raises(PermissionError, match="not owner"): - remove_tags( - reference_id=ref.id, - tags=["x"], - owner_id="user2", - ) - - -class TestListTags: - def test_returns_tags_with_counts(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["used", "unused"]) - asset = _make_asset(session) - ref = _make_reference(session, asset) - add_tags_to_reference(session, reference_id=ref.id, tags=["used"]) - session.commit() - - rows, total = list_tags() - - tag_dict = {name: count for name, count in rows} - assert tag_dict["used"] == 1 - assert tag_dict["unused"] == 0 - assert total == 2 - - def test_excludes_zero_counts(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["used", "unused"]) - asset = _make_asset(session) - ref = _make_reference(session, asset) - add_tags_to_reference(session, reference_id=ref.id, tags=["used"]) - session.commit() - - rows, total = list_tags(include_zero=False) - - tag_names = {name for name, _ in rows} - assert "used" in tag_names - assert "unused" not in tag_names - - def test_prefix_filter(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["alpha", "beta", "alphabet"]) - session.commit() - - rows, _ = list_tags(prefix="alph") - - tag_names = {name for name, _ in rows} - assert tag_names == {"alpha", "alphabet"} +def _attach_automatic_tag(session: Session, record_id: str, tag_name: str) -> None: + if session.get(Tag, tag_name) is None: + session.add(Tag(name=tag_name)) + session.flush() + session.add( + AssetTag(asset_id=record_id, tag_name=tag_name, origin="automatic") + ) - def test_order_by_name(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["zebra", "alpha", "middle"]) - session.commit() - rows, _ = list_tags(order="name_asc") +def test_removing_present_automatic_tag_reports_it_protected_not_absent( + session: Session, mock_create_session +) -> None: + content = create_content(session, "/output/protected.png") + record = create_record(session, content.id, "protected-fixture") + _attach_automatic_tag(session, record.id, "auto") + session.commit() - names = [name for name, _ in rows] - assert names == ["alpha", "middle", "zebra"] + result = remove_tags(record.id, ["auto"]) - def test_pagination(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["a", "b", "c", "d", "e"]) - session.commit() + assert result.protected == ["auto"] + assert result.not_present == [] + assert result.removed == [] - rows, total = list_tags(limit=2, offset=1, order="name_asc") - assert total == 5 - assert len(rows) == 2 - names = [name for name, _ in rows] - assert names == ["b", "c"] +def test_remove_tags_separates_removed_protected_and_absent( + session: Session, mock_create_session +) -> None: + content = create_content(session, "/output/mixed.png") + record = create_record(session, content.id, "mixed-fixture", tags=["keep-me"]) + _attach_automatic_tag(session, record.id, "auto") + session.commit() - def test_clamps_limit(self, mock_create_session, session: Session): - ensure_tags_exist(session, ["a"]) - session.commit() + result = remove_tags(record.id, ["keep-me", "auto", "ghost"]) - # Service should clamp limit to max 1000 - rows, _ = list_tags(limit=2000) - assert len(rows) <= 1000 + assert result.removed == ["keep-me"] + assert result.protected == ["auto"] + assert result.not_present == ["ghost"] + assert fetch_record_tags(session, record.id) == ["auto"] diff --git a/tests-unit/assets_test/services/test_transition_drain.py b/tests-unit/assets_test/services/test_transition_drain.py new file mode 100644 index 00000000000..3c1213499b6 --- /dev/null +++ b/tests-unit/assets_test/services/test_transition_drain.py @@ -0,0 +1,421 @@ +import logging +from pathlib import Path + +import folder_paths +import pytest +from blake3 import blake3 +from sqlalchemy import select + +from app.assets.database.models import Asset, AssetContent, AssetTag +from app.assets.database.queries.records import create_content, create_record +from app.assets.helpers import to_stored_hash +from app.assets.services import hash_mode_state +from app.assets.services.hash_mode_state import ( + clear_transition_queue, + drain_transition_queue, + enqueue_transition_work, + read_stored_mode, + record_transition_intent, + write_stored_mode, +) +from app.assets.services.lookup import lookup_for_view +from app.assets.services.path_utils import get_name_and_tags_from_asset_path +from app.assets.services.snapshot_hash import snapshot_hash + + +@pytest.fixture(autouse=True) +def transition_queue(): + clear_transition_queue() + yield + clear_transition_queue() + + +def _stored_hash(path: Path) -> str: + snapshot = snapshot_hash(str(path)) + assert snapshot is not None + digest, _ = snapshot + return to_stored_hash(digest) + + +def test_off_to_on_transition_hashes_null_rows_and_persists_mode(session, temp_dir, monkeypatch): + paths = [temp_dir / "first.bin", temp_dir / "second.bin"] + for index, path in enumerate(paths): + path.write_bytes(f"bytes-{index}".encode()) + stat = path.stat() + create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + + contents = list(session.scalars(select(AssetContent))) + assert {content.hash for content in contents} == {_stored_hash(path) for path in paths} + assert read_stored_mode(session) == "on" + + +def test_transition_drain_splits_changed_content(session, temp_dir, monkeypatch): + path = temp_dir / "changed.bin" + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + path.write_bytes(b"old bytes") + old_snapshot = snapshot_hash(str(path)) + assert old_snapshot is not None + old_digest, _ = old_snapshot + stat = path.stat() + old_content = create_content( + session, str(path), to_stored_hash(old_digest), stat.st_size, stat.st_mtime_ns + ) + old_content_id = old_content.id + create_record(session, old_content_id, "changed.bin") + path.write_bytes(b"new bytes") + + enqueue_transition_work(session, "off_to_on") + drain_transition_queue(session) + session.commit() + + contents = list(session.scalars(select(AssetContent))) + live_content = next(content for content in contents if not content.is_missing) + records = list(session.scalars(select(Asset))) + assert session.get(AssetContent, old_content_id).is_missing is True + assert live_content.hash == _stored_hash(path) + assert len(records) == 2 + assert any(record.content_id == live_content.id for record in records) + + +def test_transition_drain_serves_unchanged_content_whose_stored_stat_went_stale( + session, temp_dir, monkeypatch +): + path = temp_dir / "unchanged.bin" + path.write_bytes(b"bytes that outlive the mode flip") + stat = path.stat() + stored_hash = _stored_hash(path) + size_before_the_restore = stat.st_size - 1 + mtime_before_the_restore = stat.st_mtime_ns - 1_000_000_000 + content = create_content( + session, str(path), stored_hash, size_before_the_restore, mtime_before_the_restore + ) + content_id = content.id + create_record(session, content_id, "unchanged.bin") + monkeypatch.setattr(folder_paths, "get_temp_directory", lambda: str(temp_dir / "never")) + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + assert lookup_for_view(session, stored_hash) is None, ( + "precondition: the stale stat makes the row unservable by hash" + ) + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + + refreshed = session.get(AssetContent, content_id) + assert (refreshed.size_bytes, refreshed.mtime_ns) == (stat.st_size, stat.st_mtime_ns) + served = lookup_for_view(session, stored_hash) + assert served is not None and served.id == content_id, ( + "the drain verified these bytes but left the row unservable until the next full scan" + ) + assert [row.id for row in session.scalars(select(AssetContent))] == [content_id] + assert [row.content_id for row in session.scalars(select(Asset))] == [content_id] + assert read_stored_mode(session) == "on" + + +def test_transition_drain_requeues_permission_errors_and_processes_other_paths( + session, temp_dir, monkeypatch +): + protected_path = temp_dir / "protected.bin" + healthy_path = temp_dir / "healthy.bin" + protected_path.write_bytes(b"protected") + healthy_payload = b"healthy" + healthy_path.write_bytes(healthy_payload) + for path in (protected_path, healthy_path): + stat = path.stat() + create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + + def hash_or_raise(candidate_path: str): + if candidate_path == str(protected_path): + raise PermissionError("denied") + return snapshot_hash(candidate_path) + + monkeypatch.setattr(hash_mode_state, "snapshot_hash", hash_or_raise) + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + + drain_transition_queue(session) + + healthy_content = session.scalar( + select(AssetContent).where(AssetContent.path == str(healthy_path)) + ) + assert healthy_content is not None + assert healthy_content.hash == to_stored_hash(blake3(healthy_payload).hexdigest()) + assert hash_mode_state.pending_transition_count() == 1 + assert read_stored_mode(session) == "off" + + +def test_transition_drain_marks_deleted_path_missing_and_completes_transition( + session, temp_dir, monkeypatch +): + path = temp_dir / "vanished.bin" + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + path.write_bytes(b"bytes that die during the outage") + stat = path.stat() + content = create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + content_id = content.id + record = create_record(session, content_id, "vanished.bin") + record_id = record.id + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + path.unlink() + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + + assert session.get(AssetContent, content_id).is_missing is True, ( + "a path deleted while every server was down must be marked missing, not requeued forever" + ) + assert session.get(AssetTag, {"asset_id": record_id, "tag_name": "missing"}) is not None + assert hash_mode_state.pending_transition_count() == 0, ( + "the queue never empties while a deleted path is requeued unconditionally" + ) + assert read_stored_mode(session) == "on", ( + "the completion gate stays wedged at 'off' for the process lifetime when the queue " + "cannot drain, so hash-addressed serving never gets its re-verification certificate" + ) + + +def test_transition_drain_requeues_transient_stat_error_without_marking_it_missing( + session, temp_dir, monkeypatch +): + path = temp_dir / "transient-stat-error.bin" + path.write_bytes(b"present the whole time") + stat = path.stat() + content = create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + content_id = content.id + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + monkeypatch.setattr(hash_mode_state, "snapshot_hash", lambda candidate_path: None) + real_stat = hash_mode_state.os.stat + + def flaky_stat(candidate_path, *args, **kwargs): + if candidate_path == str(path): + raise NotADirectoryError("stale handle mid-drain") + return real_stat(candidate_path, *args, **kwargs) + + monkeypatch.setattr(hash_mode_state.os, "stat", flaky_stat) + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + + assert session.get(AssetContent, content_id).is_missing is False, ( + "a transient stat failure on a file that is still present must not mark its row missing" + ) + assert hash_mode_state.pending_transition_count() == 1 + assert read_stored_mode(session) == "off" + + +def test_transition_drain_requeues_unstable_present_file_without_marking_it_missing( + session, temp_dir, monkeypatch +): + path = temp_dir / "still-being-written.bin" + path.write_bytes(b"a partial write in flight") + stat = path.stat() + content = create_content(session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns) + content_id = content.id + create_record(session, content_id, "still-being-written.bin") + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + monkeypatch.setattr(hash_mode_state, "snapshot_hash", lambda candidate_path: None) + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + + assert session.get(AssetContent, content_id).is_missing is False, ( + "snapshot_hash returns None for drift too; a file still on disk is unstable, not gone" + ) + assert hash_mode_state.pending_transition_count() == 1 + assert read_stored_mode(session) == "off" + + +def test_transition_drain_mixes_a_deleted_path_with_a_healthy_one(session, temp_dir, monkeypatch): + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + deleted_path = temp_dir / "deleted.bin" + healthy_path = temp_dir / "survivor.bin" + deleted_path.write_bytes(b"deleted during the outage") + healthy_path.write_bytes(b"survivor") + content_ids = {} + for path in (deleted_path, healthy_path): + stat = path.stat() + content = create_content( + session, str(path), size_bytes=stat.st_size, mtime_ns=stat.st_mtime_ns + ) + content_ids[path] = content.id + create_record(session, content.id, path.name) + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + healthy_hash = _stored_hash(healthy_path) + deleted_path.unlink() + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + drain_transition_queue(session) + session.commit() + + assert session.get(AssetContent, content_ids[deleted_path]).is_missing is True + healthy_content = session.get(AssetContent, content_ids[healthy_path]) + assert (healthy_content.is_missing, healthy_content.hash) == (False, healthy_hash), ( + "one dead path must not cost the healthy paths behind it their hashes" + ) + assert hash_mode_state.pending_transition_count() == 0 + assert read_stored_mode(session) == "on" + + +def test_transition_drain_skips_out_of_root_path(session, temp_dir, monkeypatch, caplog): + import logging + + outside_path = temp_dir / "orphan.bin" + outside_path.write_bytes(b"old bytes") + old_snapshot = snapshot_hash(str(outside_path)) + assert old_snapshot is not None + old_digest, _ = old_snapshot + stat = outside_path.stat() + old_content = create_content( + session, str(outside_path), to_stored_hash(old_digest), stat.st_size, stat.st_mtime_ns + ) + old_content_id = old_content.id + create_record(session, old_content_id, "orphan.bin") + outside_path.write_bytes(b"new bytes") + + with pytest.raises(ValueError): + get_name_and_tags_from_asset_path(str(outside_path)) + + enqueue_transition_work(session, "off_to_on") + + with caplog.at_level(logging.WARNING): + try: + drain_transition_queue(session) + except ValueError as error: + pytest.fail( + f"an out-of-root path escaped the drain as {error!r}; setup_database turns that " + f"into sys.exit(1) when --enable-assets is set" + ) + session.commit() + + assert session.get(AssetContent, old_content_id).is_missing is False + assert hash_mode_state.pending_transition_count() == 0 + assert any("orphan.bin" in record.getMessage() for record in caplog.records) + + +def _seed_hashed_row(session, path: Path, payload: bytes) -> tuple[str, str]: + path.write_bytes(payload) + stat = path.stat() + stored = to_stored_hash(blake3(payload).hexdigest()) + content = create_content(session, str(path), stored, stat.st_size, stat.st_mtime_ns) + create_record(session, content.id, path.name) + return content.id, stored + + +def test_transition_drain_clears_an_unverifiable_hash_only_on_the_third_attempt( + session, temp_dir, monkeypatch, caplog +): + path = temp_dir / "permanently-unreadable.bin" + content_id, original_hash = _seed_hashed_row(session, path, b"bytes nobody can read") + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + + def always_denied(_candidate_path: str): + raise PermissionError("denied") + + monkeypatch.setattr(hash_mode_state, "snapshot_hash", always_denied) + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + + def warnings_naming_the_path() -> list[str]: + return [r.getMessage() for r in caplog.records if str(path) in r.getMessage()] + + with caplog.at_level(logging.WARNING): + for attempt in (1, 2): + drain_transition_queue(session) + session.commit() + session.expire_all() + assert hash_mode_state.pending_transition_count() == 1, ( + f"attempt {attempt} of 3 must requeue the entry, not retire it" + ) + assert read_stored_mode(session) == "off", ( + f"the mode must not flip while attempt {attempt} is still pending" + ) + assert session.get(AssetContent, content_id).hash == original_hash, ( + f"a transient failure on attempt {attempt} must not clear a good hash" + ) + assert warnings_naming_the_path() == [], ( + f"attempt {attempt} is a retry, not a terminal outcome; it must stay quiet" + ) + + drain_transition_queue(session) + session.commit() + + session.expire_all() + assert hash_mode_state.pending_transition_count() == 0, ( + "the third failure retires the entry so the queue can empty" + ) + assert read_stored_mode(session) == "on", ( + "an unreadable file must not wedge the mode flip for the process lifetime" + ) + retired = session.get(AssetContent, content_id) + assert retired.is_missing is False, ( + "the file is unreadable, not gone; retiring its hash must not mark the row missing" + ) + assert retired.hash is None, ( + "an unverifiable digest must not survive into persisted 'on'" + ) + assert len(warnings_naming_the_path()) == 1, ( + "the terminal outcome is announced exactly once, naming the path" + ) + + +def test_transition_drain_retires_only_the_unreadable_path_and_hashes_the_healthy_one( + session, temp_dir, monkeypatch +): + unreadable_path = temp_dir / "unreadable.bin" + healthy_path = temp_dir / "healthy.bin" + healthy_payload = b"a file that reads fine" + unreadable_id, _ = _seed_hashed_row(session, unreadable_path, b"a file that does not") + healthy_path.write_bytes(healthy_payload) + healthy_stat = healthy_path.stat() + healthy_content = create_content( + session, str(healthy_path), size_bytes=healthy_stat.st_size, mtime_ns=healthy_stat.st_mtime_ns + ) + healthy_id = healthy_content.id + create_record(session, healthy_id, healthy_path.name) + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + + def denied_for_the_unreadable_path(candidate_path: str): + if candidate_path == str(unreadable_path): + raise PermissionError("denied") + return snapshot_hash(candidate_path) + + monkeypatch.setattr(hash_mode_state, "snapshot_hash", denied_for_the_unreadable_path) + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + for _ in range(3): + drain_transition_queue(session) + session.commit() + + session.expire_all() + assert session.get(AssetContent, healthy_id).hash == _stored_hash(healthy_path), ( + "one unreadable path must not cost the healthy paths behind it their hashes" + ) + assert session.get(AssetContent, unreadable_id).hash is None + assert hash_mode_state.pending_transition_count() == 0 + assert read_stored_mode(session) == "on" diff --git a/tests-unit/assets_test/services/test_transition_settle.py b/tests-unit/assets_test/services/test_transition_settle.py new file mode 100644 index 00000000000..3da231ba599 --- /dev/null +++ b/tests-unit/assets_test/services/test_transition_settle.py @@ -0,0 +1,108 @@ +from contextlib import contextmanager +from pathlib import Path +from unittest.mock import patch + +import pytest +from sqlalchemy.orm import Session as SASession + +from app.assets import scanner, seeder as seeder_module +from app.assets.database.models import AssetContent +from app.assets.database.queries.records import create_content, create_record +from app.assets.helpers import to_stored_hash +from app.assets.services import hash_mode_state +from app.assets.services.hash_mode_state import ( + clear_transition_queue, + enqueue_transition_work, + read_stored_mode, + record_transition_intent, + write_stored_mode, +) + +_ATTEMPT_BUDGET = 5 + + +class _AttemptBudgetExhausted(BaseException): + # BaseException, not Exception: enrich_assets_batch's blanket except would swallow it. + pass + + +@pytest.fixture(autouse=True) +def transition_queue(): + clear_transition_queue() + yield + clear_transition_queue() + + +def _denied(_candidate_path: str): + raise PermissionError("denied") + + +def test_enrich_phase_settles_an_unreadable_transition_without_looping( + session, db_engine, temp_dir: Path, monkeypatch +): + path = temp_dir / "unreadable.safetensors" + payload = b"bytes that can be stat'd but never hashed" + path.write_bytes(payload) + stat = path.stat() + content = create_content( + session, str(path), to_stored_hash("seed-digest"), stat.st_size, stat.st_mtime_ns + ) + content_id = content.id + record = create_record(session, content_id, path.name) + record_id = record.id + write_stored_mode(session, "off") + monkeypatch.setattr(hash_mode_state._mode, "hashing_enabled", lambda: True) + + transition = record_transition_intent(session) + enqueue_transition_work(session, transition) + session.commit() + + @contextmanager + def _create_session(): + with SASession(db_engine) as sess: + yield sess + + attempts: list[str] = [] + real_enrich_asset = scanner.enrich_asset + + def counting_enrich_asset(*args, **kwargs): + attempts.append(kwargs["record_id"]) + if len(attempts) > _ATTEMPT_BUDGET: + raise _AttemptBudgetExhausted + return real_enrich_asset(*args, **kwargs) + + seeder = seeder_module._AssetSeeder() + seeder._compute_hashes = True + seeder._run_gate.set() + seeder._cancel_event.clear() + + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + monkeypatch.setattr(hash_mode_state, "snapshot_hash", _denied) + monkeypatch.setattr(scanner, "snapshot_hash", _denied) + monkeypatch.setattr(scanner, "enrich_asset", counting_enrich_asset) + + with patch("app.assets.seeder.create_session", _create_session), \ + patch("app.assets.scanner.create_session", _create_session): + try: + cancelled, _enriched = seeder._run_enrich_phase(("input",)) + except _AttemptBudgetExhausted: + pytest.fail( + f"the enrich phase re-selected the same record more than {_ATTEMPT_BUDGET} " + "times: a terminally-cleared row stays hash-eligible, so counting its " + "metadata as progress keeps it out of failed_ids and the pass never ends" + ) + + assert cancelled is False + session.expire_all() + assert read_stored_mode(session) == "on", ( + "one background scan must settle the transition on its own; waiting for a future " + "prompt to queue another enrich pass leaves a quiet server wedged at 'off'" + ) + settled = session.get(AssetContent, content_id) + assert settled.is_missing is False, ( + "an unreadable file is not a deleted one; settling must not mark its row missing" + ) + assert settled.hash is None + assert attempts == [record_id], ( + "the record is attempted once, then excluded from the rest of the pass" + ) diff --git a/tests-unit/assets_test/services/test_updated_at_semantics.py b/tests-unit/assets_test/services/test_updated_at_semantics.py new file mode 100644 index 00000000000..bfd17082b1f --- /dev/null +++ b/tests-unit/assets_test/services/test_updated_at_semantics.py @@ -0,0 +1,285 @@ + +import os +from datetime import datetime + +import pytest +from sqlalchemy import update +from sqlalchemy.orm import Session + +from app.assets.database.models import Asset, AssetTag, Tag +from app.assets.database.queries.records import ( + create_content, + create_record, + delete_record, + mark_content_missing, + unset_content_missing, +) +from app.assets.helpers import to_stored_hash +from app.assets.scanner import enrich_asset +from app.assets.scanner_changes import split_content +from app.assets.services.asset_management import ( + resolve_asset_for_download, + resolve_hash_to_path, + update_asset_metadata, +) +from app.assets.services.tagging import apply_tags, remove_tags + +STALE = datetime(2020, 1, 1, 0, 0, 0) + + +def _seed_record(session: Session, path: str, name: str = "fixture", tags=None, hash=None) -> Asset: + content = create_content(session, path, hash=hash, size_bytes=os.stat(path).st_size) + record = create_record(session, content.id, name, tags=tags) + session.commit() + _force_stale(session, record.id) + return record + + +def _seed_scannable_record(session: Session, path: str, name: str) -> Asset: + stat_result = os.stat(path) + content = create_content( + session, + path, + size_bytes=stat_result.st_size, + mtime_ns=stat_result.st_mtime_ns, + ) + record = create_record(session, content.id, name) + session.commit() + _force_stale(session, record.id) + return record + + +def _force_stale(session: Session, record_id: str) -> None: + session.execute(update(Asset).where(Asset.id == record_id).values(updated_at=STALE)) + session.commit() + + +def _updated_at(session: Session, record_id: str) -> datetime: + session.expire_all() + return session.get(Asset, record_id).updated_at + + +def _last_access(session: Session, record_id: str) -> datetime | None: + session.expire_all() + return session.get(Asset, record_id).last_access_time + + +def _write_file(temp_dir, name: str, payload: bytes = b"payload") -> str: + file_path = temp_dir / name + file_path.write_bytes(payload) + return str(file_path) + + +def test_download_path_moves_last_access_time_but_not_updated_at( + session, mock_create_session, temp_dir +): + path = _write_file(temp_dir, "download.bin") + record = _seed_record(session, path, name="download.bin") + + resolve_asset_for_download(record.id) + + assert _last_access(session, record.id) is not None, ( + "serving a download must record the access" + ) + assert _updated_at(session, record.id) == STALE, ( + "a read moves last_access_time only; it is not an explicit user edit" + ) + + +def test_hash_serve_path_moves_last_access_time_but_not_updated_at( + session, mock_create_session, temp_dir +): + path = _write_file(temp_dir, "served.bin") + digest = to_stored_hash("a" * 64) + record = _seed_record(session, path, name="served.bin", hash=digest) + + assert resolve_hash_to_path(digest) is not None, "fixture must resolve" + + assert _last_access(session, record.id) is not None, ( + "serving by hash must record the access" + ) + assert _updated_at(session, record.id) == STALE, ( + "hash-serving is a read; it is not an explicit user edit" + ) + + +def test_rename_moves_updated_at(session, mock_create_session, temp_dir): + record = _seed_record(session, _write_file(temp_dir, "rename.bin")) + + update_asset_metadata(record.id, name="renamed") + + assert _updated_at(session, record.id) > STALE, "a rename is an explicit user edit" + + +@pytest.mark.parametrize( + "field,kwargs", + [ + ("user_metadata", {"user_metadata": {"note": "hello"}}), + ("mime_type", {"mime_type": "image/png"}), + ], +) +def test_user_field_updates_move_updated_at( + session, mock_create_session, temp_dir, field, kwargs +): + record = _seed_record(session, _write_file(temp_dir, f"{field}.bin")) + + update_asset_metadata(record.id, **kwargs) + + assert _updated_at(session, record.id) > STALE, ( + f"setting {field} is an explicit user edit" + ) + + +def test_preview_id_update_moves_updated_at(session, mock_create_session, temp_dir): + record = _seed_record(session, _write_file(temp_dir, "subject.bin")) + preview = _seed_record(session, _write_file(temp_dir, "preview.bin"), name="preview") + + update_asset_metadata(record.id, preview_id=preview.id) + + assert _updated_at(session, record.id) > STALE, ( + "setting preview_id is an explicit user edit" + ) + + +def test_manual_tag_add_moves_updated_at(session, mock_create_session, temp_dir): + record = _seed_record(session, _write_file(temp_dir, "tag-add.bin")) + + apply_tags(record.id, ["favourite"]) + + assert _updated_at(session, record.id) > STALE, ( + "adding a manual tag is an explicit user edit of the record" + ) + + +def test_manual_tag_remove_moves_updated_at(session, mock_create_session, temp_dir): + record = _seed_record( + session, _write_file(temp_dir, "tag-remove.bin"), tags=["favourite"] + ) + + remove_tags(record.id, ["favourite"]) + + assert _updated_at(session, record.id) > STALE, ( + "removing a manual tag is an explicit user edit of the record" + ) + + +def test_tag_replacement_via_update_asset_metadata_moves_updated_at( + session, mock_create_session, temp_dir +): + record = _seed_record(session, _write_file(temp_dir, "tag-replace.bin"), tags=["old"]) + + update_asset_metadata(record.id, tags=["new"]) + + assert _updated_at(session, record.id) > STALE, ( + "replacing the manual tag set is an explicit user edit" + ) + + +def test_noop_tag_calls_do_not_move_updated_at(session, mock_create_session, temp_dir): + record = _seed_record( + session, _write_file(temp_dir, "tag-noop.bin"), tags=["already"] + ) + + apply_tags(record.id, ["already"]) + remove_tags(record.id, ["never-present"]) + + assert _updated_at(session, record.id) == STALE, ( + "a tag call that changes no link is not an edit" + ) + + +def test_unchanged_tag_replacement_does_not_move_updated_at( + session, mock_create_session, temp_dir +): + record = _seed_record( + session, _write_file(temp_dir, "tag-same.bin"), tags=["keep"] + ) + + update_asset_metadata(record.id, tags=["keep"]) + + assert _updated_at(session, record.id) == STALE, ( + "replacing the manual tag set with an identical set changes nothing" + ) + + +def test_scanner_enrichment_does_not_move_updated_at(session, temp_dir): + path = _write_file(temp_dir, "enrich.png", payload=b"\x89PNG\r\n\x1a\n" + b"0" * 64) + record = _seed_scannable_record(session, path, name="enrich.png") + + assert enrich_asset( + session, path, record.content_id, record.id, extract_metadata=True + ), "fixture must actually enrich" + + assert session.get(Asset, record.id).mime_type is not None, ( + "enrichment must have written a system-derived field" + ) + assert _updated_at(session, record.id) == STALE, ( + "system-derived facts about unchanged bytes are not a user edit" + ) + + +def test_automatic_missing_tag_projection_does_not_move_updated_at(session, temp_dir): + record = _seed_record(session, _write_file(temp_dir, "missing.bin")) + + mark_content_missing(session, record.content_id) + session.commit() + assert _updated_at(session, record.id) == STALE, ( + "the automatic missing tag is a system projection" + ) + + _force_stale(session, record.id) + unset_content_missing(session, record.content_id) + session.commit() + assert _updated_at(session, record.id) == STALE, ( + "recovery is a system projection too" + ) + + +def test_content_split_does_not_move_updated_at_on_retired_record( + session, temp_dir, monkeypatch +): + monkeypatch.setattr("folder_paths.get_input_directory", lambda: str(temp_dir)) + path = _write_file(temp_dir, "split.bin") + record = _seed_record(session, path, name="split.bin") + content = session.get(Asset, record.id).content + + split_content(session, content, os.stat(path), hash_value=to_stored_hash("b" * 64)) + session.commit() + + assert _updated_at(session, record.id) == STALE, ( + "a content split is a system projection over the retired record" + ) + + +def test_preview_target_delete_cascade_does_not_move_updated_at(db_engine_fk): + with Session(db_engine_fk) as session: + preview_content = create_content(session, "/output/preview-target.png") + preview = create_record(session, preview_content.id, "preview-target") + content = create_content(session, "/output/referencing.png") + record = create_record(session, content.id, "referencing") + session.commit() + session.execute( + update(Asset) + .where(Asset.id == record.id) + .values(preview_id=preview.id, updated_at=STALE) + ) + session.commit() + + delete_record(session, preview.id) + session.commit() + + session.expire_all() + refreshed = session.get(Asset, record.id) + assert refreshed.preview_id is None, "the FK cascade must have nulled the link" + assert refreshed.updated_at == STALE, ( + "an FK-level SET NULL is not a user edit of the referencing record" + ) + + +def test_seed_helper_reports_stale_before_any_edit(session, temp_dir): + record = _seed_record(session, _write_file(temp_dir, "seed.bin")) + + assert _updated_at(session, record.id) == STALE, "seeding must set the stale marker" + assert isinstance(session.get(Asset, record.id), Asset) + assert session.get(Tag, "nonexistent") is None + assert session.get(AssetTag, (record.id, "nonexistent")) is None diff --git a/tests-unit/assets_test/services/test_upload_b.py b/tests-unit/assets_test/services/test_upload_b.py new file mode 100644 index 00000000000..3e3cdc0557a --- /dev/null +++ b/tests-unit/assets_test/services/test_upload_b.py @@ -0,0 +1,1402 @@ +import os +import uuid +from contextlib import contextmanager +from unittest.mock import patch + +import pytest +from sqlalchemy import create_engine, func, select, update +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session as SASession + +import app.assets.mode as mode_module +import app.assets.services.ingest as ingest_module +import folder_paths +from app.assets.database.models import Asset, AssetContent, AssetTag +from app.assets.database.queries.records import ( + create_content, + create_record, + mark_content_missing, +) +from app.assets.helpers import to_stored_hash +from app.assets.services.asset_management import delete_asset_reference +from app.assets.services.file_utils import get_size_and_mtime_ns +from app.assets.services.ingest import ( + UploadUnstableError, + register_file_in_place, + upload_from_temp_path, +) +from app.assets.services.lookup import ( + claim_qualified_content as _real_claim_qualified_content, + is_temp_path, + lookup_for_view, +) +from app.assets.services.snapshot_hash import snapshot_hash +from app.database.models import Base + + +def _bump_mtime(path: str) -> int: + _, mtime_ns = get_size_and_mtime_ns(path) + moved = mtime_ns + 1_000_000_000 + os.utime(path, ns=(moved, moved)) + observed = get_size_and_mtime_ns(path)[1] + assert observed != mtime_ns + return observed + + +@pytest.fixture +def hashing_on(): + class FakeArgs: + enable_asset_hashing = True + + mode_module.init(FakeArgs()) + yield + mode_module.init(None) + + +@pytest.fixture +def hashing_off(): + class FakeArgs: + enable_asset_hashing = False + + mode_module.init(FakeArgs()) + yield + mode_module.init(None) + + +def _write_temp(content: bytes) -> str: + uploads_root = os.path.join(folder_paths.get_temp_directory(), "uploads", uuid.uuid4().hex) + os.makedirs(uploads_root, exist_ok=True) + path = os.path.join(uploads_root, ".upload.part") + with open(path, "wb") as file: + file.write(content) + return path + + +def test_hash_mode_multipart_reuses_content_but_in_place_keeps_its_own_path( + mock_create_session, hashing_on +): + content = b"duplicate-bytes" + temp1 = _write_temp(content) + temp2 = _write_temp(content) + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + image_path = os.path.join(output_dir, "dup.bin") + try: + r1 = upload_from_temp_path( + temp_path=temp1, + name="dup.bin", + tags=["output"], + client_filename="dup.bin", + ) + r2 = upload_from_temp_path( + temp_path=temp2, + name="dup.bin", + tags=["output"], + client_filename="dup.bin", + ) + assert r1.ref.id != r2.ref.id, "every upload is its own delivery record" + assert r2.created_new is True + with mock_create_session() as session: + assert ( + session.scalar( + select(func.count()) + .select_from(AssetContent) + .where(AssetContent.is_missing.is_(False)) + ) + == 1 + ), "same bytes reuse the one content row" + + with open(image_path, "wb") as file: + file.write(content) + r3 = register_file_in_place( + abs_path=image_path, name="dup.bin", tags=["output"] + ) + assert r3.ref.id != r1.ref.id + assert r3.created_new is True + assert r3.ref.file_path == os.path.abspath(image_path) + + with mock_create_session() as session: + assert session.scalar(select(func.count()).select_from(Asset)) == 3 + live = { + row.id: row.path + for row in session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + } + assert len(live) == 2 + in_place = session.get(Asset, r3.ref.id) + multipart = session.get(Asset, r1.ref.id) + assert in_place is not None + assert multipart is not None + assert in_place.content_id != multipart.content_id + assert live[in_place.content_id] == os.path.abspath(image_path) + assert live[multipart.content_id] != os.path.abspath(image_path) + finally: + for path in (temp1, temp2, image_path): + if os.path.exists(path): + os.unlink(path) + + +def test_off_mode_api_assets_reuses_content_same_bytes_same_name( + mock_create_session, hashing_off +): + content = b"off-mode-bytes" + temp1 = _write_temp(content) + temp2 = _write_temp(content) + try: + r1 = upload_from_temp_path( + temp_path=temp1, + name="off.bin", + tags=["output"], + client_filename="off.bin", + ) + r2 = upload_from_temp_path( + temp_path=temp2, + name="off.bin", + tags=["output"], + client_filename="off.bin", + ) + assert r1.ref.id != r2.ref.id, "every upload is its own delivery record" + assert r2.created_new is True + with mock_create_session() as session: + assert session.scalar(select(func.count()).select_from(Asset)) == 2 + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + ) + assert len(contents) == 1 + finally: + for path in (temp1, temp2): + if os.path.exists(path): + os.unlink(path) + + +def test_off_mode_register_file_in_place_same_path_reuses_content_row( + mock_create_session, hashing_off +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "image_same.png") + with open(path, "wb") as file: + file.write(b"img-bytes") + + try: + r1 = register_file_in_place( + abs_path=path, name="image_same.png", tags=["output"] + ) + r2 = register_file_in_place( + abs_path=path, name="image_same.png", tags=["output"] + ) + assert r2.ref.id != r1.ref.id + assert r2.created_new is True + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + ) + assert len(contents) == 1 + assert contents[0].path == os.path.abspath(path) + assert open(contents[0].path, "rb").read() == b"img-bytes" + records = list(session.scalars(select(Asset))) + assert len(records) == 2 + assert {record.content_id for record in records} == {contents[0].id} + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_off_mode_register_file_in_place_different_bytes_two_paths( + mock_create_session, hashing_off +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path1 = os.path.join(output_dir, "same.png") + path2 = os.path.join(output_dir, "same (1).png") + with open(path1, "wb") as file: + file.write(b"v1") + with open(path2, "wb") as file: + file.write(b"v2") + + try: + r1 = register_file_in_place(abs_path=path1, name="same.png", tags=["output"]) + r2 = register_file_in_place(abs_path=path2, name="same.png", tags=["output"]) + assert r1.ref.id != r2.ref.id + with mock_create_session() as session: + contents = list( + session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + ) + assert len(contents) == 2 + assert {content.path for content in contents} == { + os.path.abspath(path1), + os.path.abspath(path2), + } + finally: + for path in (path1, path2): + if os.path.exists(path): + os.unlink(path) + + +def test_register_file_in_place_equal_bytes_new_path_is_tracked_separately( + mock_create_session, hashing_on +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path_a = os.path.join(output_dir, "crosspath_a.png") + path_b = os.path.join(output_dir, "crosspath_b.png") + payload = b"cross-path-equal-bytes" + try: + with open(path_a, "wb") as file: + file.write(payload) + first = register_file_in_place( + abs_path=path_a, name="crosspath_a.png", tags=["output"] + ) + + with open(path_b, "wb") as file: + file.write(payload) + second = register_file_in_place( + abs_path=path_b, name="crosspath_b.png", tags=["output"] + ) + + abs_a = os.path.abspath(path_a) + abs_b = os.path.abspath(path_b) + assert second.ref.id != first.ref.id + assert second.created_new is True + assert second.ref.file_path == abs_b + with mock_create_session() as session: + live = { + row.path: row + for row in session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + } + assert set(live) == {abs_a, abs_b} + assert live[abs_a].hash == live[abs_b].hash + assert live[abs_a].id != live[abs_b].id + record_b = session.get(Asset, second.ref.id) + assert record_b is not None + assert record_b.content_id == live[abs_b].id + record_a = session.get(Asset, first.ref.id) + assert record_a is not None + assert record_a.content_id == live[abs_a].id + finally: + for path in (path_a, path_b): + if os.path.exists(path): + os.unlink(path) + + +def test_register_file_in_place_overwrite_returns_new_file_hash_and_size( + mock_create_session, hashing_on +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "overwrite_hash.png") + + v1 = b"overwrite-version-one" + v2 = b"v2" + try: + with open(path, "wb") as file: + file.write(v1) + r1 = register_file_in_place( + abs_path=path, name="overwrite_hash.png", tags=["output"] + ) + + with open(path, "wb") as file: + file.write(v2) + snapshot = snapshot_hash(path) + assert snapshot is not None + expected_new_hash = to_stored_hash(snapshot[0]) + + r2 = register_file_in_place( + abs_path=path, name="overwrite_hash.png", tags=["output"] + ) + + assert r2.asset.hash == expected_new_hash + assert r2.asset.hash != r1.asset.hash + assert r2.asset.size_bytes == len(v2) + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_register_file_in_place_overwrite_marks_old_content_missing( + mock_create_session, hashing_on +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "overwrite_missing.png") + + v1 = b"overwrite-version-one" + v2 = b"v2" + try: + with open(path, "wb") as file: + file.write(v1) + r1 = register_file_in_place( + abs_path=path, name="overwrite_missing.png", tags=["output"] + ) + + with open(path, "wb") as file: + file.write(v2) + snapshot = snapshot_hash(path) + assert snapshot is not None + expected_new_hash = to_stored_hash(snapshot[0]) + + register_file_in_place( + abs_path=path, name="overwrite_missing.png", tags=["output"] + ) + + with mock_create_session() as session: + rows = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == os.path.abspath(path) + ) + ) + ) + live = [row for row in rows if not row.is_missing] + missing = [row for row in rows if row.is_missing] + assert len(live) == 1 + assert live[0].hash == expected_new_hash + assert len(missing) == 1 + assert missing[0].hash == r1.asset.hash + finally: + if os.path.exists(path): + os.unlink(path) + + +def _digest_of(path: str) -> str: + snapshot = snapshot_hash(path) + assert snapshot is not None + return snapshot[0] + + +def _seed_live_content(session, path: str, stored_hash: str | None) -> tuple[str, str]: + size_bytes, mtime_ns = get_size_and_mtime_ns(path) + content = create_content(session, path, stored_hash, size_bytes, mtime_ns) + record = create_record( + session, content.id, os.path.basename(path), tags=["output"] + ) + session.commit() + return content.id, record.id + + +def test_register_file_in_place_record_failure_preserves_retained_live_content( + mock_create_session, hashing_off, monkeypatch: pytest.MonkeyPatch +) -> None: + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, f"retained_{uuid.uuid4().hex}.bin") + with open(path, "wb") as file: + file.write(b"retained-live-content") + + try: + original = register_file_in_place( + abs_path=path, + name="retained.bin", + tags=["output"], + ) + retained_content_id = original.content_id + assert delete_asset_reference(original.ref.id) is True + + def _raise_record_creation(*_args, **_kwargs): + raise RuntimeError("forced record creation failure") + + monkeypatch.setattr( + ingest_module, + "_create_upload_record", + _raise_record_creation, + ) + + with pytest.raises(RuntimeError, match="forced record creation failure"): + register_file_in_place( + abs_path=path, + name="retained.bin", + tags=["output"], + ) + + with mock_create_session() as session: + assert session.get(AssetContent, retained_content_id) is not None + finally: + if os.path.exists(path): + os.unlink(path) + + +def _is_missing_tagged(session, record_id: str) -> bool: + return ( + session.get(AssetTag, {"asset_id": record_id, "tag_name": "missing"}) + is not None + ) + + +def test_register_file_in_place_unhashed_unchanged_file_is_not_retired( + mock_create_session, hashing_on +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "unhashed_noop.png") + payload = b"scanner-seeded-bytes" + try: + with open(path, "wb") as file: + file.write(payload) + with mock_create_session() as session: + content_id, record_id = _seed_live_content( + session, os.path.abspath(path), None + ) + + register_file_in_place( + abs_path=path, + name="unhashed_noop.png", + tags=["output"], + content_written=False, + ) + + with mock_create_session() as session: + content = session.get(AssetContent, content_id) + assert content is not None + assert content.is_missing is False + assert _is_missing_tagged(session, record_id) is False + live = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == os.path.abspath(path), + AssetContent.is_missing.is_(False), + ) + ) + ) + assert [row.id for row in live] == [content_id] + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_register_file_in_place_unhashed_unchanged_file_adopts_hash( + mock_create_session, hashing_on +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "unhashed_adopt.png") + payload = b"scanner-seeded-adopt-bytes" + try: + with open(path, "wb") as file: + file.write(payload) + with mock_create_session() as session: + content_id, record_id = _seed_live_content( + session, os.path.abspath(path), None + ) + expected_hash = to_stored_hash(_digest_of(path)) + + result = register_file_in_place( + abs_path=path, + name="unhashed_adopt.png", + tags=["output"], + content_written=False, + ) + + assert result.ref.id != record_id + assert result.created_new is True + assert result.asset.hash == expected_hash + assert result.asset.size_bytes == len(payload) + with mock_create_session() as session: + content = session.get(AssetContent, content_id) + assert content is not None + assert content.hash == expected_hash + assert content.is_missing is False + record = session.get(Asset, result.ref.id) + assert record is not None + assert record.content_id == content_id + live = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == os.path.abspath(path), + AssetContent.is_missing.is_(False), + ) + ) + ) + assert [row.id for row in live] == [content_id] + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_register_file_in_place_unhashed_changed_file_is_retired( + mock_create_session, hashing_on +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "unhashed_changed.png") + original = b"scanner-seeded-original-bytes" + replacement = b"Z" * len(original) + try: + with open(path, "wb") as file: + file.write(original) + with mock_create_session() as session: + content_id, record_id = _seed_live_content( + session, os.path.abspath(path), None + ) + with open(path, "wb") as file: + file.write(replacement) + expected_hash = to_stored_hash(_digest_of(path)) + + result = register_file_in_place( + abs_path=path, + name="unhashed_changed.png", + tags=["output"], + content_written=True, + ) + + assert result.asset.hash == expected_hash + with mock_create_session() as session: + stale = session.get(AssetContent, content_id) + assert stale is not None + assert stale.hash != expected_hash + assert stale.is_missing is True + assert _is_missing_tagged(session, record_id) is True + seeded_record = session.get(Asset, record_id) + assert seeded_record is not None + assert seeded_record.content_id == content_id + assert result.ref.id != record_id + live = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == os.path.abspath(path), + AssetContent.is_missing.is_(False), + ) + ) + ) + assert len(live) == 1 + assert live[0].hash == expected_hash + assert live[0].id != content_id + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_register_file_in_place_matching_hash_refreshes_stale_stat( + mock_create_session, hashing_on +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + path = os.path.join(output_dir, "stale_mtime.png") + payload = b"stale-mtime-bytes" + try: + with open(path, "wb") as file: + file.write(payload) + stored_hash = to_stored_hash(_digest_of(path)) + with mock_create_session() as session: + content_id, record_id = _seed_live_content( + session, os.path.abspath(path), stored_hash + ) + observed_mtime = _bump_mtime(path) + + result = register_file_in_place( + abs_path=path, name="stale_mtime.png", tags=["output"] + ) + + assert result.ref.id != record_id + assert result.created_new is True + with mock_create_session() as session: + content = session.get(AssetContent, content_id) + assert content is not None + assert content.is_missing is False + assert content.mtime_ns == observed_mtime + assert content.size_bytes == len(payload) + served = lookup_for_view(session, stored_hash) + assert served is not None + assert served.id == content_id + record = session.get(Asset, result.ref.id) + assert record is not None + assert record.content_id == content_id + assert session.scalar(select(func.count()).select_from(Asset)) == 2 + live = list( + session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + ) + assert [row.id for row in live] == [content_id] + finally: + if os.path.exists(path): + os.unlink(path) + + +def test_upload_unhashed_row_at_destination_is_not_retired( + mock_create_session, hashing_on +): + payload = b"upload-adopt-destination-bytes" + probe = _write_temp(payload) + digest = _digest_of(probe) + os.unlink(probe) + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + dest = os.path.join(output_dir, f"{digest}.bin") + temp = _write_temp(payload) + try: + with open(dest, "wb") as file: + file.write(payload) + with mock_create_session() as session: + content_id, record_id = _seed_live_content(session, dest, None) + + result = upload_from_temp_path( + temp_path=temp, name="up.bin", tags=["output"], client_filename="up.bin" + ) + + assert result.asset.hash == to_stored_hash(digest) + assert result.asset.size_bytes == len(payload) + with mock_create_session() as session: + content = session.get(AssetContent, content_id) + assert content is not None + assert content.is_missing is False + assert _is_missing_tagged(session, record_id) is False + finally: + for path in (temp, dest): + if os.path.exists(path): + os.unlink(path) + + +def test_upload_unhashed_destination_holding_other_bytes_is_retired( + mock_create_session, hashing_on +): + payload = b"upload-other-bytes-payload" + probe = _write_temp(payload) + digest = _digest_of(probe) + os.unlink(probe) + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + dest = os.path.join(output_dir, f"{digest}.bin") + decoy = b"X" * len(payload) + temp = _write_temp(payload) + try: + with open(dest, "wb") as file: + file.write(decoy) + with mock_create_session() as session: + content_id, record_id = _seed_live_content(session, dest, None) + + result = upload_from_temp_path( + temp_path=temp, name="up.bin", tags=["output"], client_filename="up.bin" + ) + + assert result.asset.hash == to_stored_hash(digest) + assert open(dest, "rb").read() == payload + with mock_create_session() as session: + stale = session.get(AssetContent, content_id) + assert stale is not None + assert stale.hash != to_stored_hash(digest) + assert stale.is_missing is True + assert _is_missing_tagged(session, record_id) is True + live = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == dest, + AssetContent.is_missing.is_(False), + ) + ) + ) + assert len(live) == 1 + assert live[0].hash == to_stored_hash(digest) + assert live[0].id != content_id + finally: + for path in (temp, dest): + if os.path.exists(path): + os.unlink(path) + + +def test_upload_matching_hash_stale_stat_reuses_content_under_a_new_record( + mock_create_session, hashing_on +): + payload = b"upload-stale-stat-bytes" + probe = _write_temp(payload) + digest = _digest_of(probe) + os.unlink(probe) + stored_hash = to_stored_hash(digest) + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + dest = os.path.join(output_dir, f"{digest}.bin") + temp = _write_temp(payload) + try: + with open(dest, "wb") as file: + file.write(payload) + with mock_create_session() as session: + content_id, record_id = _seed_live_content(session, dest, stored_hash) + _bump_mtime(dest) + + result = upload_from_temp_path( + temp_path=temp, + name=os.path.basename(dest), + tags=["output"], + client_filename="up.bin", + ) + + assert result.ref.id != record_id, "a re-upload mints its own record" + assert result.created_new is True + with mock_create_session() as session: + minted = session.get(Asset, result.ref.id) + assert minted is not None + assert minted.content_id == content_id, ( + "settle refreshes the stale row before lookup, so it qualifies " + "and is reused rather than duplicated" + ) + assert ( + session.scalar(select(func.count()).select_from(AssetContent)) == 1 + ), "no second content row for bytes already on disk" + content = session.get(AssetContent, content_id) + assert content is not None + assert content.is_missing is False + assert content.mtime_ns == get_size_and_mtime_ns(dest)[1] + assert content.size_bytes == len(payload) + served = lookup_for_view(session, stored_hash) + assert served is not None + assert served.id == content_id + finally: + for path in (temp, dest): + if os.path.exists(path): + os.unlink(path) + + +def test_upload_stale_destination_row_is_retired(mock_create_session, hashing_on): + payload = b"upload-retire-destination-bytes" + probe = _write_temp(payload) + digest = _digest_of(probe) + os.unlink(probe) + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + dest = os.path.join(output_dir, f"{digest}.bin") + temp = _write_temp(payload) + foreign_hash = to_stored_hash("f" * 64) + try: + with open(dest, "wb") as file: + file.write(b"externally-replaced") + with mock_create_session() as session: + content_id, record_id = _seed_live_content(session, dest, foreign_hash) + + result = upload_from_temp_path( + temp_path=temp, name="up.bin", tags=["output"], client_filename="up.bin" + ) + + assert result.asset.hash == to_stored_hash(digest) + with mock_create_session() as session: + stale = session.get(AssetContent, content_id) + assert stale is not None + assert stale.is_missing is True + assert _is_missing_tagged(session, record_id) is True + live = list( + session.scalars( + select(AssetContent).where( + AssetContent.path == dest, + AssetContent.is_missing.is_(False), + ) + ) + ) + assert len(live) == 1 + assert live[0].hash == to_stored_hash(digest) + assert live[0].size_bytes == len(payload) + finally: + for path in (temp, dest): + if os.path.exists(path): + os.unlink(path) + + +def test_upload_matching_missing_row_stores_bytes(mock_create_session, hashing_on): + content_bytes = b"fresh-upload-bytes" + temp = _write_temp(content_bytes) + snapshot = snapshot_hash(temp) + assert snapshot is not None + digest, _ = snapshot + + missing_path = os.path.abspath("/nonexistent/missing-upload.bin") + with mock_create_session() as session: + stale = create_content(session, missing_path, digest, len(content_bytes), 0) + mark_content_missing(session, stale.id) + session.commit() + + try: + result = upload_from_temp_path( + temp_path=temp, + name="fresh.bin", + tags=["output"], + client_filename="fresh.bin", + ) + assert result.created_new is True + with mock_create_session() as session: + live_rows = list( + session.scalars( + select(AssetContent).where(AssetContent.is_missing.is_(False)) + ) + ) + assert len(live_rows) == 1 + assert os.path.isfile(live_rows[0].path) + assert open(live_rows[0].path, "rb").read() == content_bytes + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def test_upload_unstable_raises_after_three_attempts_no_rows( + mock_create_session, hashing_on +): + temp = _write_temp(b"unstable") + try: + with patch( + "app.assets.services.ingest.snapshot_hash", return_value=None + ) as mock_hash: + with pytest.raises(UploadUnstableError): + upload_from_temp_path( + temp_path=temp, + name="u.bin", + tags=["output"], + client_filename="u.bin", + ) + assert mock_hash.call_count == 3 + assert not os.path.exists(temp) + with mock_create_session() as session: + assert session.scalar(select(func.count()).select_from(Asset)) == 0 + assert session.scalar(select(func.count()).select_from(AssetContent)) == 0 + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def test_upload_unknown_preview_id_raises(mock_create_session, hashing_on): + temp = _write_temp(b"preview-validate-upload-bytes") + try: + with pytest.raises(ValueError): + upload_from_temp_path( + temp_path=temp, + name="pv.bin", + tags=["output"], + client_filename="pv.bin", + preview_id="00000000-0000-0000-0000-000000000000", + ) + with mock_create_session() as session: + assert session.scalar(select(func.count()).select_from(Asset)) == 0 + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def test_reupload_of_known_bytes_carries_this_requests_attributes( + mock_create_session, hashing_on +): + payload = b"attribute-carrying-upload-bytes" + temp1 = _write_temp(payload) + temp2 = _write_temp(payload) + try: + first = upload_from_temp_path( + temp_path=temp1, + name="attrs.bin", + tags=["output"], + client_filename="attrs.bin", + ) + second = upload_from_temp_path( + temp_path=temp2, + name="attrs.bin", + tags=["output"], + user_metadata={"note": "second delivery"}, + client_filename="attrs.bin", + preview_id=first.ref.id, + ) + + assert second.ref.id != first.ref.id + assert second.created_new is True + assert second.ref.user_metadata == {"note": "second delivery"}, ( + "the re-upload's own user_metadata must land on its record" + ) + assert second.ref.preview_id == first.ref.id, ( + "the re-upload's own preview_id must land on its record" + ) + with mock_create_session() as session: + assert session.scalar(select(func.count()).select_from(AssetContent)) == 1 + assert ( + session.get(Asset, second.ref.id).content_id + == session.get(Asset, first.ref.id).content_id + ) + finally: + for path in (temp1, temp2): + if os.path.exists(path): + os.unlink(path) + + +def test_upload_does_not_reuse_temp_backed_content(mock_create_session, hashing_on): + payload = b"temp-backed-content-bytes" + staged = _write_temp(payload) + stored_hash = to_stored_hash(_digest_of(staged)) + temp = _write_temp(payload) + try: + with mock_create_session() as session: + temp_content_id, _ = _seed_live_content(session, staged, stored_hash) + + result = upload_from_temp_path( + temp_path=temp, + name="t.bin", + tags=["output"], + client_filename="t.bin", + ) + + assert result.created_new is True + with mock_create_session() as session: + minted = session.get(Asset, result.ref.id) + assert minted is not None + assert minted.content_id != temp_content_id, ( + "a temporary location can never become permanent shared content" + ) + content = session.get(AssetContent, minted.content_id) + assert content is not None + assert not is_temp_path(content.path) + finally: + for path in (staged, temp): + if os.path.exists(path): + os.unlink(path) + + +def test_content_retired_after_lookup_falls_back_to_a_new_content_row( + mock_create_session, hashing_on +): + payload = b"raced-retirement-upload-bytes" + temp1 = _write_temp(payload) + temp2 = _write_temp(payload) + try: + first = upload_from_temp_path( + temp_path=temp1, + name="raced.bin", + tags=["output"], + client_filename="raced.bin", + ) + with mock_create_session() as session: + reused_content_id = session.get(Asset, first.ref.id).content_id + + def retire_then_claim(session, content_id, hash): + mark_content_missing(session, content_id) + session.commit() + return _real_claim_qualified_content(session, content_id, hash) + + with patch( + "app.assets.services.ingest.claim_qualified_content", + retire_then_claim, + ): + second = upload_from_temp_path( + temp_path=temp2, + name="raced.bin", + tags=["output"], + client_filename="raced.bin", + ) + + assert second.created_new is True + assert second.ref.id != first.ref.id + with mock_create_session() as session: + minted = session.get(Asset, second.ref.id) + assert minted is not None + assert minted.content_id != reused_content_id, ( + "a record must never be attached to content retired mid-flight" + ) + fresh = session.get(AssetContent, minted.content_id) + assert fresh is not None + assert fresh.is_missing is False + assert os.path.isfile(fresh.path) + with open(fresh.path, "rb") as file: + assert file.read() == payload, "the uploaded bytes must not be lost" + finally: + for path in (temp1, temp2): + if os.path.exists(path): + os.unlink(path) + + +def _file_backed_engine(db_path): + engine = create_engine( + f"sqlite:///{db_path}", + connect_args={"check_same_thread": False, "timeout": 0.2}, + ) + Base.metadata.create_all(engine) + return engine + + +@contextmanager +def _session_factory(engine): + with SASession(engine) as sess: + yield sess + + +def test_two_connections_hash_changed_between_lookup_and_claim_falls_back( + tmp_path, hashing_on +): + engine = _file_backed_engine(tmp_path / "race_hash.db") + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + seed_path = os.path.join(output_dir, "seed_hash_race.bin") + payload = b"two-connection-hash-race-bytes" + with open(seed_path, "wb") as file: + file.write(payload) + stored_hash = to_stored_hash(_digest_of(seed_path)) + with SASession(engine) as seed_session: + content_id, _ = _seed_live_content(seed_session, seed_path, stored_hash) + + temp = _write_temp(payload) + real_claim = ingest_module.claim_qualified_content + + def claim_after_a_rehashes(session, content_id_arg, hash_arg): + with SASession(engine) as connection_a: + connection_a.execute( + update(AssetContent) + .where(AssetContent.id == content_id_arg) + .values(hash="a-different-hash-entirely") + ) + connection_a.commit() + return real_claim(session, content_id_arg, hash_arg) + + try: + with ( + patch( + "app.assets.services.ingest.create_session", + lambda: _session_factory(engine), + ), + patch( + "app.assets.services.ingest.claim_qualified_content", + claim_after_a_rehashes, + ), + ): + result = upload_from_temp_path( + temp_path=temp, + name="race_hash.bin", + tags=["output"], + client_filename="race_hash.bin", + ) + + assert result.created_new is True + with SASession(engine) as verify: + minted = verify.get(Asset, result.ref.id) + assert minted is not None + assert minted.content_id != content_id, ( + "the row whose hash changed out from under the claim must " + "not be reused" + ) + original = verify.get(AssetContent, content_id) + assert original is not None + assert original.hash == "a-different-hash-entirely" + fresh = verify.get(AssetContent, minted.content_id) + assert fresh is not None + assert os.path.isfile(fresh.path) + with open(fresh.path, "rb") as file: + assert file.read() == payload, "the uploaded bytes must not be lost" + finally: + for path in (seed_path, temp): + if os.path.exists(path): + os.unlink(path) + + +def test_two_connections_competing_retirement_is_blocked_until_commit( + tmp_path, hashing_on +): + engine = _file_backed_engine(tmp_path / "race_toctou.db") + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + seed_path = os.path.join(output_dir, "seed_toctou_race.bin") + payload = b"two-connection-toctou-race-bytes" + with open(seed_path, "wb") as file: + file.write(payload) + stored_hash = to_stored_hash(_digest_of(seed_path)) + with SASession(engine) as seed_session: + content_id, _ = _seed_live_content(seed_session, seed_path, stored_hash) + + temp = _write_temp(payload) + real_create_upload_record = ingest_module._create_upload_record + outcome: dict[str, bool] = {} + + def create_record_after_a_retires(session, content_id_arg, *args, **kwargs): + with SASession(engine) as connection_a: + try: + mark_content_missing(connection_a, content_id_arg) + connection_a.commit() + except OperationalError as exc: + assert "lock" in str(exc).lower(), ( + "A must fail specifically on SQLite lock contention, not " + f"some other OperationalError: {exc}" + ) + outcome["a_was_blocked"] = True + connection_a.rollback() + else: + outcome["a_was_blocked"] = False + return real_create_upload_record(session, content_id_arg, *args, **kwargs) + + try: + with ( + patch( + "app.assets.services.ingest.create_session", + lambda: _session_factory(engine), + ), + patch( + "app.assets.services.ingest._create_upload_record", + create_record_after_a_retires, + ), + ): + result = upload_from_temp_path( + temp_path=temp, + name="race_toctou.bin", + tags=["output"], + client_filename="race_toctou.bin", + ) + + assert outcome["a_was_blocked"] is True, ( + "connection A's competing retirement must be blocked by the " + "claim's write lock, not merely lose a race" + ) + assert result.created_new is True + with SASession(engine) as verify: + minted = verify.get(Asset, result.ref.id) + assert minted is not None + assert minted.content_id == content_id, ( + "the claimed row is still the one reused once A is blocked" + ) + reused = verify.get(AssetContent, content_id) + assert reused is not None + assert reused.is_missing is False, ( + "the reused content row must not end up retired" + ) + finally: + for path in (seed_path, temp): + if os.path.exists(path): + os.unlink(path) + + +def test_off_mode_upload_calls_content_lookup(mock_create_session, hashing_off): + temp = _write_temp(b"off-mode") + try: + with patch( + "app.assets.services.ingest.lookup_for_view" + ) as mock_dedup: + mock_dedup.return_value = None + upload_from_temp_path( + temp_path=temp, + name="off.bin", + tags=["output"], + client_filename="off.bin", + ) + mock_dedup.assert_called_once() + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def _tag_names(session, record_id: str) -> list[str]: + return sorted( + session.scalars(select(AssetTag.tag_name).where(AssetTag.asset_id == record_id)).all() + ) + + +def test_upload_round_trips_its_own_served_tags(mock_create_session, hashing_on): + temp = _write_temp(b"round-trip-served-tags") + try: + result = upload_from_temp_path( + temp_path=temp, + name="served.bin", + tags=["output", "uploaded"], + client_filename="served.bin", + ) + + with mock_create_session() as session: + assert _tag_names(session, result.ref.id) == ["output", "uploaded"], ( + "re-uploading a file with the tags the API served for it must succeed; " + "'uploaded' is appended unconditionally, so a request already carrying it " + "double-adds the same composite PK" + ) + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def test_upload_accepts_a_repeated_custom_tag(mock_create_session, hashing_on): + temp = _write_temp(b"repeated-custom-tag") + try: + result = upload_from_temp_path( + temp_path=temp, + name="repeat.bin", + tags=["output", "x", "x"], + client_filename="repeat.bin", + ) + + with mock_create_session() as session: + assert _tag_names(session, result.ref.id) == ["output", "uploaded", "x"] + finally: + if os.path.exists(temp): + os.unlink(temp) + + +def test_upload_normalizes_tags_before_they_reach_the_query_layer( + mock_create_session, hashing_on, monkeypatch +): + captured: list[list[str]] = [] + real_create_upload_record = ingest_module._create_upload_record + + def capturing_create_upload_record(*args, **kwargs): + captured.append(list(args[4])) + return real_create_upload_record(*args, **kwargs) + + monkeypatch.setattr( + ingest_module, "_create_upload_record", capturing_create_upload_record + ) + + payload = b"seam-assertion-bytes" + new_content_temp = _write_temp(payload) + reused_content_temp = _write_temp(payload) + try: + upload_from_temp_path( + temp_path=new_content_temp, + name="seam.bin", + tags=["output", "uploaded"], + client_filename="seam.bin", + ) + upload_from_temp_path( + temp_path=reused_content_temp, + name="seam.bin", + tags=["output", "uploaded"], + client_filename="seam.bin", + ) + + assert len(captured) == 2, ( + "expected one new-content upload and one reused-content upload" + ) + for tags in captured: + assert len(tags) == len(set(tags)), ( + f"{tags!r} reached the query layer with duplicates; normalization belongs " + "at the build site, not as a side effect of create_record's dedup" + ) + assert tags.count("uploaded") == 1 + finally: + for path in (new_content_temp, reused_content_temp): + if os.path.exists(path): + os.unlink(path) + + +def _stat_pair(path: str) -> tuple[int, int]: + stat_result = os.stat(path) + return stat_result.st_size, stat_result.st_mtime_ns + + +def _mutating_snapshot_hash(path_to_mutate: str, new_bytes: bytes): + real_snapshot_hash = ingest_module.snapshot_hash + + def _mutate_then_hash(candidate_path: str): + if candidate_path == path_to_mutate: + with open(path_to_mutate, "wb") as file: + file.write(new_bytes) + os.utime(path_to_mutate, ns=(_LATER_NS, _LATER_NS)) + return real_snapshot_hash(candidate_path) + + return _mutate_then_hash + + +_LATER_NS = 2_000_000_000_000_000_000 + + +def test_incumbent_reconciliation_persists_the_stat_hashing_verified( + mock_create_session, hashing_on, monkeypatch +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + dest_abs = os.path.join(output_dir, "incumbent_stat.bin") + original = b"the original incumbent bytes" + rewritten = b"REWRITTEN incumbent bytes!!!" + assert len(rewritten) == len(original), ( + "same length keeps reconciliation on its adopt-the-hash branch, so this test " + "isolates stat pairing instead of the size-contradiction retirement" + ) + with open(dest_abs, "wb") as file: + file.write(original) + try: + with mock_create_session() as session: + stale_size, stale_mtime = _stat_pair(dest_abs) + create_content(session, dest_abs, None, stale_size, stale_mtime) + session.commit() + + monkeypatch.setattr( + ingest_module, + "snapshot_hash", + _mutating_snapshot_hash(dest_abs, rewritten), + ) + + with mock_create_session() as session: + ingest_module._settle_destination_before_write(session, dest_abs) + session.commit() + + verified_size, verified_mtime = _stat_pair(dest_abs) + with mock_create_session() as session: + live = session.scalar( + select(AssetContent).where( + AssetContent.path == dest_abs, AssetContent.is_missing.is_(False) + ) + ) + assert live is not None + assert (live.size_bytes, live.mtime_ns) == (verified_size, verified_mtime), ( + "the row's hash describes the bytes hashing actually read, so its size and " + "mtime must describe those same bytes; a pre-hash stat makes the row " + "stat-inconsistent and therefore unservable by hash" + ) + assert lookup_for_view(session, live.hash) is not None + finally: + if os.path.exists(dest_abs): + os.unlink(dest_abs) + + +def test_in_place_registration_persists_the_stat_hashing_verified( + mock_create_session, hashing_on, monkeypatch +): + output_dir = folder_paths.get_output_directory() + os.makedirs(output_dir, exist_ok=True) + locator = os.path.join(output_dir, "in_place_stat.bin") + with open(locator, "wb") as file: + file.write(b"in-place original bytes") + try: + monkeypatch.setattr( + ingest_module, + "snapshot_hash", + _mutating_snapshot_hash(locator, b"in-place bytes rewritten mid-hash"), + ) + + result = register_file_in_place( + abs_path=locator, name="in_place_stat.bin", tags=["output"] + ) + + verified_size, verified_mtime = _stat_pair(locator) + with mock_create_session() as session: + record = session.get(Asset, result.ref.id) + content = session.get(AssetContent, record.content_id) + assert (content.size_bytes, content.mtime_ns) == (verified_size, verified_mtime) + assert lookup_for_view(session, content.hash) is not None + finally: + if os.path.exists(locator): + os.unlink(locator) + + +def test_multipart_upload_persists_the_stat_hashing_verified( + mock_create_session, hashing_on, monkeypatch +): + payload = b"multipart bytes whose destination stat goes stale" + temp = _write_temp(payload) + real_digest = snapshot_hash(temp)[0] + + def _stale_stat(_path: str, follow_symlinks: bool = True) -> tuple[int, int]: + return 1, 1 + + monkeypatch.setattr(ingest_module, "get_size_and_mtime_ns", _stale_stat) + + result = upload_from_temp_path( + temp_path=temp, + name="multipart_stat.bin", + tags=["output"], + client_filename="multipart_stat.bin", + ) + + with mock_create_session() as session: + record = session.get(Asset, result.ref.id) + content = session.get(AssetContent, record.content_id) + assert (content.size_bytes, content.mtime_ns) != (1, 1), ( + "a stat read separately from hashing can be stale; the verified stat that " + "hashing proved describes these bytes is the one to persist" + ) + assert (content.size_bytes, content.mtime_ns) == _stat_pair(content.path) + assert content.hash == to_stored_hash(real_digest), ( + "the digest, not the (digest, stat) pair, feeds to_stored_hash" + ) + assert real_digest[:8] in os.path.basename(content.path), ( + "the digest, not the pair, feeds hash-mode destination naming" + ) + assert lookup_for_view(session, content.hash) is not None diff --git a/tests-unit/assets_test/test_api_surface.py b/tests-unit/assets_test/test_api_surface.py new file mode 100644 index 00000000000..946016d62bc --- /dev/null +++ b/tests-unit/assets_test/test_api_surface.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import json +import re +import uuid +from collections.abc import Callable, Collection, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Literal, NoReturn, TypedDict + +import pytest +import requests +from _pytest.mark.structures import ParameterSet + + +RouteKind = Literal[ + "head_asset_hash", + "list_assets", + "get_asset", + "get_asset_content", + "create_from_hash", + "upload_asset", + "update_asset", + "delete_asset", + "get_tags", + "add_asset_tags", + "delete_asset_tags", + "get_tags_refine", + "seed_assets", + "get_seed_status", + "cancel_seed", + "prune_missing_assets", +] + + +@dataclass(frozen=True, slots=True) +class RouteSpec: + method: str + path: str + kind: RouteKind + xfail_reason: str | None = None + + +@dataclass(frozen=True, slots=True) +class SmokeContext: + http: requests.Session + api_base: str + seeded_asset: SmokeAsset + make_asset_bytes: Callable[[str, int], bytes] + + +class SmokeAsset(TypedDict): + id: str + hash: str + + +ROUTES_PY = Path(__file__).resolve().parents[2] / "app/assets/api/routes.py" + +ROUTE_DECORATOR_RE = re.compile( + r"@ROUTES\.(?Pget|post|put|delete|head)\((?:f)?[\"'](?P.*?)[\"']\)", + re.DOTALL, +) +NESTED_PARAM_RE = re.compile(r"\{(?P[A-Za-z_][A-Za-z0-9_]*)\:\{[^{}]+\}\}") +PATH_PARAM_RE = re.compile(r"\{(?P[A-Za-z_][A-Za-z0-9_]*)\:[^{}]+\}") + + +ROUTE_SPECS: tuple[RouteSpec, ...] = ( + RouteSpec("HEAD", "/api/assets/hash/{hash}", "head_asset_hash"), + RouteSpec("GET", "/api/assets", "list_assets"), + RouteSpec("GET", "/api/assets/{id}", "get_asset"), + RouteSpec("GET", "/api/assets/{id}/content", "get_asset_content"), + RouteSpec("POST", "/api/assets/from-hash", "create_from_hash"), + RouteSpec("POST", "/api/assets", "upload_asset"), + RouteSpec("PUT", "/api/assets/{id}", "update_asset"), + RouteSpec("DELETE", "/api/assets/{id}", "delete_asset"), + RouteSpec("GET", "/api/tags", "get_tags"), + RouteSpec("POST", "/api/assets/{id}/tags", "add_asset_tags"), + RouteSpec("DELETE", "/api/assets/{id}/tags", "delete_asset_tags"), + RouteSpec("GET", "/api/assets/tags/refine", "get_tags_refine"), + RouteSpec("POST", "/api/assets/seed", "seed_assets"), + RouteSpec("GET", "/api/assets/seed/status", "get_seed_status"), + RouteSpec("POST", "/api/assets/seed/cancel", "cancel_seed"), + RouteSpec("POST", "/api/assets/prune", "prune_missing_assets"), +) + +EXPECTED_ROUTE_KEYS = {f"{spec.method} {spec.path}" for spec in ROUTE_SPECS} + + +def _assert_never(value: NoReturn) -> NoReturn: + raise AssertionError(f"unhandled route kind: {value!r}") + + +def _normalize_route_path(raw_path: str) -> str: + path = raw_path.replace("{{", "{").replace("}}", "}") + path = NESTED_PARAM_RE.sub(r"{\g}", path) + return PATH_PARAM_RE.sub(r"{\g}", path) + + +def parse_route_keys(source: str) -> set[str]: + return { + f"{match.group('method').upper()} {_normalize_route_path(match.group('path'))}" + for match in ROUTE_DECORATOR_RE.finditer(source) + } + + +def assert_routes_covered(route_keys: Collection[str], covered_keys: Collection[str]) -> None: + route_set = set(route_keys) + covered_set = set(covered_keys) + missing = sorted(route_set - covered_set) + extra = sorted(covered_set - route_set) + if missing or extra: + raise AssertionError(f"route coverage mismatch: missing={missing}, extra={extra}") + + +def _route_key(spec: RouteSpec) -> str: + return f"{spec.method} {spec.path}" + + +def _make_route_param(spec: RouteSpec) -> ParameterSet: + marks = () + if spec.xfail_reason is not None: + marks = (pytest.mark.xfail(strict=True, reason=spec.xfail_reason),) + return pytest.param(spec, marks=marks, id=_route_key(spec)) + + +ROUTE_PARAMS: tuple[ParameterSet, ...] = tuple(_make_route_param(spec) for spec in ROUTE_SPECS) + + +@pytest.fixture +def smoke_context( + http: requests.Session, + api_base: str, + seeded_asset: SmokeAsset, + make_asset_bytes: Callable[[str, int], bytes], +) -> SmokeContext: + return SmokeContext( + http=http, + api_base=api_base, + seeded_asset=seeded_asset, + make_asset_bytes=make_asset_bytes, + ) + + +@pytest.fixture +def seeded_asset( + asset_factory: Callable[[str, Sequence[str], dict[str, object], bytes], dict[str, object]], + make_asset_bytes: Callable[[str, int], bytes], +) -> SmokeAsset: + name = f"surface-smoke-{uuid.uuid4().hex}.safetensors" + body = asset_factory( + name, + ["models", "model_type:checkpoints", "unit-tests", "smoke"], + {"purpose": "smoke"}, + make_asset_bytes(name, 512), + ) + return { + "id": str(body["id"]), + "hash": str(body["hash"]), + } + + +def _request_for_spec(spec: RouteSpec, ctx: SmokeContext) -> requests.Response: + seeded = ctx.seeded_asset + asset_id = str(seeded["id"]) + asset_hash = str(seeded["hash"]) + + match spec.kind: + case "head_asset_hash": + return ctx.http.head(f"{ctx.api_base}/api/assets/hash/{asset_hash}", timeout=120) + case "list_assets": + return ctx.http.get(f"{ctx.api_base}/api/assets", params={"limit": "1"}, timeout=120) + case "get_asset": + return ctx.http.get(f"{ctx.api_base}/api/assets/{asset_id}", timeout=120) + case "get_asset_content": + return ctx.http.get(f"{ctx.api_base}/api/assets/{asset_id}/content", timeout=120) + case "create_from_hash": + return ctx.http.post( + f"{ctx.api_base}/api/assets/from-hash", + json={ + "hash": asset_hash, + "name": f"smoke-copy-{uuid.uuid4().hex}.safetensors", + "tags": ["models", "unit-tests", "smoke"], + "user_metadata": {"purpose": "smoke"}, + }, + timeout=120, + ) + case "upload_asset": + name = f"smoke-upload-{uuid.uuid4().hex}.bin" + return ctx.http.post( + f"{ctx.api_base}/api/assets", + files={"file": (name, ctx.make_asset_bytes(name, 512), "application/octet-stream")}, + data={ + "tags": json.dumps(["input", "unit-tests", "smoke"]), + "name": name, + "user_metadata": json.dumps({"purpose": "smoke"}), + }, + timeout=120, + ) + case "update_asset": + return ctx.http.put( + f"{ctx.api_base}/api/assets/{asset_id}", + json={"name": f"smoke-renamed-{uuid.uuid4().hex}.safetensors"}, + timeout=120, + ) + case "delete_asset": + return ctx.http.delete(f"{ctx.api_base}/api/assets/{asset_id}", timeout=120) + case "get_tags": + return ctx.http.get(f"{ctx.api_base}/api/tags", params={"limit": "1"}, timeout=120) + case "add_asset_tags": + return ctx.http.post( + f"{ctx.api_base}/api/assets/{asset_id}/tags", + json={"tags": ["smoke-tag"]}, + timeout=120, + ) + case "delete_asset_tags": + return ctx.http.delete( + f"{ctx.api_base}/api/assets/{asset_id}/tags", + json={"tags": ["unit-tests"]}, + timeout=120, + ) + case "get_tags_refine": + return ctx.http.get( + f"{ctx.api_base}/api/assets/tags/refine", + params={"limit": "1"}, + timeout=120, + ) + case "seed_assets": + return ctx.http.post( + f"{ctx.api_base}/api/assets/seed?wait=true", + json={"roots": ["models"]}, + timeout=120, + ) + case "get_seed_status": + return ctx.http.get(f"{ctx.api_base}/api/assets/seed/status", timeout=120) + case "cancel_seed": + return ctx.http.post(f"{ctx.api_base}/api/assets/seed/cancel", timeout=120) + case "prune_missing_assets": + return ctx.http.post(f"{ctx.api_base}/api/assets/prune", timeout=120) + case _: + _assert_never(spec.kind) + + +def test_route_coverage_guard_rejects_extra_route() -> None: + with pytest.raises(AssertionError, match=r"GET /api/extra"): + assert_routes_covered( + ["GET /api/assets", "POST /api/assets", "GET /api/extra"], + ["GET /api/assets", "POST /api/assets"], + ) + + +def test_route_coverage_matches_routes_py() -> None: + assert_routes_covered( + parse_route_keys(ROUTES_PY.read_text(encoding="utf-8")), + sorted(EXPECTED_ROUTE_KEYS), + ) + + +@pytest.mark.parametrize("spec", ROUTE_PARAMS) +def test_api_surface(spec: RouteSpec, smoke_context: SmokeContext) -> None: + response = _request_for_spec(spec, smoke_context) + assert response.status_code < 500, f"{_route_key(spec)} returned {response.status_code}" diff --git a/tests-unit/assets_test/test_assets_missing_sync.py b/tests-unit/assets_test/test_assets_missing_sync.py index 20572365022..3b8ea77a611 100644 --- a/tests-unit/assets_test/test_assets_missing_sync.py +++ b/tests-unit/assets_test/test_assets_missing_sync.py @@ -1,350 +1,5 @@ -import os -import uuid -from pathlib import Path +def test_asset_listing_is_available_for_missing_state_projection(http, api_base): + response = http.get(f"{api_base}/api/assets") -import pytest -import requests -from helpers import get_asset_filename, trigger_sync_seed_assets - - - - -@pytest.mark.parametrize("root", ["input", "output"]) -def test_seed_asset_removed_when_file_is_deleted( - root: str, - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, -): - """Asset without hash (seed) whose file disappears: - after triggering sync_seed_assets, Asset + AssetInfo disappear. - """ - # Create a file directly under input/unit-tests/. Backend tags only - # classify the root; nested path components are not exposed as tags. - case_dir = comfy_tmp_base_dir / root / "unit-tests" / "syncseed" - case_dir.mkdir(parents=True, exist_ok=True) - name = f"seed_{uuid.uuid4().hex[:8]}.bin" - fp = case_dir / name - fp.write_bytes(b"Z" * 2048) - - # Trigger a seed sync so DB sees this path (seed asset => hash is NULL) - trigger_sync_seed_assets(http, api_base) - - # Verify it is visible via API and carries no hash (seed) - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": root, "name_contains": name}, - timeout=120, - ) - body1 = r1.json() - assert r1.status_code == 200 - # there should be exactly one with that name - matches = [a for a in body1.get("assets", []) if a.get("name") == name] - assert matches - # Seed assets have no hash; exclude_none drops both keys from the response - assert "asset_hash" not in matches[0] - assert "hash" not in matches[0] - asset_info_id = matches[0]["id"] - - # Remove the underlying file and sync again - if fp.exists(): - fp.unlink() - - trigger_sync_seed_assets(http, api_base) - - # It should disappear (AssetInfo and seed Asset gone) - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": root, "name_contains": name}, - timeout=120, - ) - body2 = r2.json() - assert r2.status_code == 200 - matches2 = [a for a in body2.get("assets", []) if a.get("name") == name] - assert not matches2, f"Seed asset {asset_info_id} should be gone after sync" - - -@pytest.mark.skip(reason="Requires computing hashes of files in directories to verify and clear missing tags") -def test_hashed_asset_missing_tag_added_then_removed_after_scan( - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, - make_asset_bytes, -): - """Hashed asset with a single cache_state: - 1. delete its file -> sync adds 'missing' - 2. restore file -> sync removes 'missing' - """ - name = "missing_tag_test.png" - tags = ["input", "unit-tests", "msync2"] - data = make_asset_bytes(name, 4096) - a = asset_factory(name, tags, {}, data) - - # Compute its on-disk path and remove it - dest = comfy_tmp_base_dir / "input" / "unit-tests" / "msync2" / get_asset_filename(a["asset_hash"], ".png") - assert dest.exists(), f"Expected asset file at {dest}" - dest.unlink() - - # Fast sync should add 'missing' to the AssetInfo - trigger_sync_seed_assets(http, api_base) - - g1 = http.get(f"{api_base}/api/assets/{a['id']}", timeout=120) - d1 = g1.json() - assert g1.status_code == 200, d1 - assert "missing" in set(d1.get("tags", [])), "Expected 'missing' tag after deletion" - - # Restore the file with the exact same content and sync again - dest.parent.mkdir(parents=True, exist_ok=True) - dest.write_bytes(data) - - trigger_sync_seed_assets(http, api_base) - - g2 = http.get(f"{api_base}/api/assets/{a['id']}", timeout=120) - d2 = g2.json() - assert g2.status_code == 200, d2 - assert "missing" not in set(d2.get("tags", [])), "Missing tag should be cleared after verify" - - -def test_hashed_asset_two_asset_infos_both_get_missing( - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, -): - """Hashed asset with a single cache_state, but two AssetInfo rows: - deleting the single file then syncing should add 'missing' to both infos. - """ - # Upload one hashed asset - name = "two_infos_one_path.png" - base_tags = ["input", "unit-tests", "multiinfo"] - created = asset_factory(name, base_tags, {}, b"A" * 2048) - - # Create second AssetInfo for the same Asset via from-hash - payload = { - "hash": created["asset_hash"], - "name": "two_infos_one_path_copy.png", - "tags": base_tags, # keep it in our unit-tests scope for cleanup - "user_metadata": {"k": "v"}, - } - r2 = http.post(api_base + "/api/assets/from-hash", json=payload, timeout=120) - b2 = r2.json() - assert r2.status_code == 201, b2 - second_id = b2["id"] - - # Remove the single underlying file - p = comfy_tmp_base_dir / "input" / get_asset_filename(created["asset_hash"], ".png") - assert p.exists() - p.unlink() - - r0 = http.get(api_base + "/api/tags", params={"limit": "1000", "include_zero": "false"}, timeout=120) - tags0 = r0.json() - assert r0.status_code == 200, tags0 - byname0 = {t["name"]: t for t in tags0.get("tags", [])} - old_missing = int(byname0.get("missing", {}).get("count", 0)) - - # Sync -> both AssetInfos for this asset must receive 'missing' - trigger_sync_seed_assets(http, api_base) - - ga = http.get(f"{api_base}/api/assets/{created['id']}", timeout=120) - da = ga.json() - assert ga.status_code == 200, da - assert "missing" in set(da.get("tags", [])) - - gb = http.get(f"{api_base}/api/assets/{second_id}", timeout=120) - db = gb.json() - assert gb.status_code == 200, db - assert "missing" in set(db.get("tags", [])) - - # Tag usage for 'missing' increased by exactly 2 (two AssetInfos) - r1 = http.get(api_base + "/api/tags", params={"limit": "1000", "include_zero": "false"}, timeout=120) - tags1 = r1.json() - assert r1.status_code == 200, tags1 - byname1 = {t["name"]: t for t in tags1.get("tags", [])} - new_missing = int(byname1.get("missing", {}).get("count", 0)) - assert new_missing == old_missing + 2 - - -@pytest.mark.skip(reason="Requires computing hashes of files in directories to deduplicate into multiple cache states") -def test_hashed_asset_two_cache_states_partial_delete_then_full_delete( - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, - make_asset_bytes, - run_scan_and_wait, -): - """Hashed asset with two cache_state rows: - 1. delete one file -> sync should NOT add 'missing' - 2. delete second file -> sync should add 'missing' - """ - name = "two_cache_states_partial_delete.png" - tags = ["input", "unit-tests", "dual"] - data = make_asset_bytes(name, 3072) - - created = asset_factory(name, tags, {}, data) - path1 = comfy_tmp_base_dir / "input" / "unit-tests" / "dual" / get_asset_filename(created["asset_hash"], ".png") - assert path1.exists() - - # Create a second on-disk copy under the same root but different subfolder - path2 = comfy_tmp_base_dir / "input" / "unit-tests" / "dual_copy" / name - path2.parent.mkdir(parents=True, exist_ok=True) - path2.write_bytes(data) - - # Fast seed so the second path appears (as a seed initially) - trigger_sync_seed_assets(http, api_base) - - # Deduplication of AssetInfo-s will not happen as first AssetInfo has owner='default' and second has empty owner. - run_scan_and_wait("input") - - # Remove only one file and sync -> asset should still be healthy (no 'missing') - path1.unlink() - trigger_sync_seed_assets(http, api_base) - - g1 = http.get(f"{api_base}/api/assets/{created['id']}", timeout=120) - d1 = g1.json() - assert g1.status_code == 200, d1 - assert "missing" not in set(d1.get("tags", [])), "Should not be missing while one valid path remains" - - # Baseline 'missing' usage count just before last file removal - r0 = http.get(api_base + "/api/tags", params={"limit": "1000", "include_zero": "false"}, timeout=120) - tags0 = r0.json() - assert r0.status_code == 200, tags0 - old_missing = int({t["name"]: t for t in tags0.get("tags", [])}.get("missing", {}).get("count", 0)) - - # Remove the second (last) file and sync -> now we expect 'missing' on this AssetInfo - path2.unlink() - trigger_sync_seed_assets(http, api_base) - - g2 = http.get(f"{api_base}/api/assets/{created['id']}", timeout=120) - d2 = g2.json() - assert g2.status_code == 200, d2 - assert "missing" in set(d2.get("tags", [])), "Missing must be set once no valid paths remain" - - # Tag usage for 'missing' increased by exactly 2 (two AssetInfo for one Asset) - r1 = http.get(api_base + "/api/tags", params={"limit": "1000", "include_zero": "false"}, timeout=120) - tags1 = r1.json() - assert r1.status_code == 200, tags1 - new_missing = int({t["name"]: t for t in tags1.get("tags", [])}.get("missing", {}).get("count", 0)) - assert new_missing == old_missing + 2 - - -@pytest.mark.parametrize("root", ["input", "output"]) -def test_missing_tag_clears_on_fastpass_when_mtime_and_size_match( - root: str, - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, - make_asset_bytes, -): - """ - Fast pass alone clears 'missing' when size and mtime match exactly: - 1) upload (hashed), record original mtime_ns - 2) delete -> fast pass adds 'missing' - 3) restore same bytes and set mtime back to the original value - 4) run fast pass again -> 'missing' is removed (no slow scan) - """ - scope = f"fastclear-{uuid.uuid4().hex[:6]}" - name = "fastpass_clear.bin" - data = make_asset_bytes(name, 3072) - - a = asset_factory(name, [root, "unit-tests", scope], {}, data) - aid = a["id"] - p = comfy_tmp_base_dir / root / get_asset_filename(a["asset_hash"], ".bin") - st0 = p.stat() - orig_mtime_ns = getattr(st0, "st_mtime_ns", int(st0.st_mtime * 1_000_000_000)) - - # Delete -> fast pass adds 'missing' - p.unlink() - trigger_sync_seed_assets(http, api_base) - g1 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - d1 = g1.json() - assert g1.status_code == 200, d1 - assert "missing" in set(d1.get("tags", [])) - - # Restore same bytes and revert mtime to the original value - p.parent.mkdir(parents=True, exist_ok=True) - p.write_bytes(data) - # set both atime and mtime in ns to ensure exact match - os.utime(p, ns=(orig_mtime_ns, orig_mtime_ns)) - - # Fast pass should clear 'missing' without a scan - trigger_sync_seed_assets(http, api_base) - g2 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - d2 = g2.json() - assert g2.status_code == 200, d2 - assert "missing" not in set(d2.get("tags", [])), "Fast pass should clear 'missing' when size+mtime match" - - -@pytest.mark.skip(reason="Requires computing hashes of files in directories to deduplicate into multiple cache states") -@pytest.mark.parametrize("root", ["input", "output"]) -def test_fastpass_removes_stale_state_row_no_missing( - root: str, - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, - make_asset_bytes, - run_scan_and_wait, -): - """ - Hashed asset with two states: - - delete one file - - run fast pass only - Expect: - - asset stays healthy (no 'missing') - - stale AssetCacheState row for the deleted path is removed. - We verify this behaviorally by recreating the deleted path and running fast pass again: - a new *seed* AssetInfo is created, which proves the old state row was not reused. - """ - scope = f"stale-{uuid.uuid4().hex[:6]}" - name = "two_states.bin" - data = make_asset_bytes(name, 2048) - - # Upload hashed asset at path1 - a = asset_factory(name, [root, "unit-tests", scope], {}, data) - base = comfy_tmp_base_dir / root / "unit-tests" / scope - a1_filename = get_asset_filename(a["asset_hash"], ".bin") - p1 = base / a1_filename - assert p1.exists() - - aid = a["id"] - h = a["asset_hash"] - - # Create second state path2, seed+scan to dedupe into the same Asset - p2 = base / "copy" / name - p2.parent.mkdir(parents=True, exist_ok=True) - p2.write_bytes(data) - trigger_sync_seed_assets(http, api_base) - run_scan_and_wait(root) - - # Delete path1 and run fast pass -> no 'missing' and stale state row should be removed - p1.unlink() - trigger_sync_seed_assets(http, api_base) - g1 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - d1 = g1.json() - assert g1.status_code == 200, d1 - assert "missing" not in set(d1.get("tags", [])) - - # Recreate path1 and run fast pass again. - # If the stale state row was removed, a NEW seed AssetInfo will appear for this path. - p1.write_bytes(data) - trigger_sync_seed_assets(http, api_base) - - rl = http.get( - api_base + "/api/assets", - params={"include_tags": f"unit-tests,{scope}"}, - timeout=120, - ) - bl = rl.json() - assert rl.status_code == 200, bl - items = bl.get("assets", []) - # one hashed AssetInfo (asset_hash == h) + one seed AssetInfo (asset_hash == null) - hashes = [it.get("asset_hash") for it in items if it.get("name") in (name, a1_filename)] - assert h in hashes - assert any(x is None for x in hashes), "Expected a new seed AssetInfo for the recreated path" - - # Asset identity still healthy - rh = http.head(f"{api_base}/api/assets/hash/{h}", timeout=120) - assert rh.status_code == 200 + assert response.status_code == 200 + assert "assets" in response.json() diff --git a/tests-unit/assets_test/test_crud.py b/tests-unit/assets_test/test_crud.py index 9a965bcdfcc..c3e6cfbe08f 100644 --- a/tests-unit/assets_test/test_crud.py +++ b/tests-unit/assets_test/test_crud.py @@ -1,366 +1,36 @@ -import uuid -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path +import time as _time import pytest -import requests -from helpers import get_asset_filename, trigger_sync_seed_assets -def test_create_from_hash_success( - http: requests.Session, api_base: str, seeded_asset: dict +@pytest.mark.hashing_on +def test_missing_content_remains_in_list_after_rm_with_missing_tag( + http, api_base, asset_factory, comfy_tmp_base_dir, make_asset_bytes ): - h = seeded_asset["asset_hash"] - payload = { - "hash": h, - "name": "from_hash_ok.safetensors", - "tags": ["models", "checkpoints", "unit-tests", "from-hash"], - "user_metadata": {"k": "v"}, - } - r1 = http.post(f"{api_base}/api/assets/from-hash", json=payload, timeout=120) - b1 = r1.json() - assert r1.status_code == 201, b1 - assert b1["asset_hash"] == h - assert b1["hash"] == h - assert b1["hash"] == b1["asset_hash"] - assert b1["created_new"] is False - aid = b1["id"] - - # Calling again with the same name creates a new AssetReference (duplicates allowed) - r2 = http.post(f"{api_base}/api/assets/from-hash", json=payload, timeout=120) - b2 = r2.json() - assert r2.status_code == 201, b2 - assert b2["id"] != aid # new reference, not the same one - - -def test_get_and_delete_asset(http: requests.Session, api_base: str, seeded_asset: dict): - aid = seeded_asset["id"] - - # GET detail - rg = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - detail = rg.json() - assert rg.status_code == 200, detail - assert detail["id"] == aid - assert detail["hash"] == detail["asset_hash"] - assert "user_metadata" in detail - assert "filename" in detail["user_metadata"] - - # Soft delete — the reference is hidden, content is preserved - rd = http.delete(f"{api_base}/api/assets/{aid}", timeout=120) - assert rd.status_code == 204 - - # GET again -> 404 - rg2 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - body = rg2.json() - assert rg2.status_code == 404 - assert body["error"]["code"] == "ASSET_NOT_FOUND" - - -def test_soft_delete_hides_from_get(http: requests.Session, api_base: str, seeded_asset: dict): - aid = seeded_asset["id"] - asset_hash = seeded_asset["asset_hash"] - - # Soft delete — the reference is hidden, content is preserved - rd = http.delete(f"{api_base}/api/assets/{aid}", timeout=120) - assert rd.status_code == 204 - - # GET by reference ID -> 404 (soft-deleted references are hidden) - rg = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - assert rg.status_code == 404 - - # Asset identity is preserved (underlying content still exists) - rh = http.head(f"{api_base}/api/assets/hash/{asset_hash}", timeout=120) - assert rh.status_code == 200 - - # Soft-deleted reference should not appear in listings - rl = http.get( - f"{api_base}/api/assets", - params={"include_tags": "unit-tests", "limit": "500"}, - timeout=120, + record = asset_factory( + "missing.png", ["output", "unit-tests"], {}, make_asset_bytes("missing") ) - ids = [a["id"] for a in rl.json().get("assets", [])] - assert aid not in ids - - # The reference is already soft-deleted; content is preserved. - - -def test_soft_delete_preserves_asset_identity_across_references( - http: requests.Session, api_base: str, seeded_asset: dict -): - # Create a second reference to the same asset via from-hash - src_hash = seeded_asset["asset_hash"] - payload = { - "hash": src_hash, - "name": "unit_ref_copy.safetensors", - "tags": ["models", "checkpoints", "unit-tests", "del-flow"], - "user_metadata": {"note": "copy"}, - } - r2 = http.post(f"{api_base}/api/assets/from-hash", json=payload, timeout=120) - copy = r2.json() - assert r2.status_code == 201, copy - assert copy["asset_hash"] == src_hash - assert copy["hash"] == src_hash - assert copy["created_new"] is False - - # Soft-delete original reference (default) -> asset identity must remain - aid1 = seeded_asset["id"] - rd1 = http.delete(f"{api_base}/api/assets/{aid1}", timeout=120) - assert rd1.status_code == 204 - - rh1 = http.head(f"{api_base}/api/assets/hash/{src_hash}", timeout=120) - assert rh1.status_code == 200 # identity still present (second ref exists) - - # Soft-delete the last reference -> asset identity preserved (no hard delete) - aid2 = copy["id"] - rd2 = http.delete(f"{api_base}/api/assets/{aid2}", timeout=120) - assert rd2.status_code == 204 - - rh2 = http.head(f"{api_base}/api/assets/hash/{src_hash}", timeout=120) - assert rh2.status_code == 200 # asset identity preserved (soft delete) - - # Re-associate via from-hash: it must reuse the same preserved content - # (created_new False AND the same hash), proving the soft deletes did not - # destroy the underlying asset. Then soft-delete again -> still preserved. - r3 = http.post(f"{api_base}/api/assets/from-hash", json=payload, timeout=120) - assert r3.status_code == 201, r3.json() - assert r3.json()["created_new"] is False - assert r3.json()["asset_hash"] == src_hash # reused the surviving content - aid3 = r3.json()["id"] - - rd3 = http.delete(f"{api_base}/api/assets/{aid3}", timeout=120) - assert rd3.status_code == 204 - - rh3 = http.head(f"{api_base}/api/assets/hash/{src_hash}", timeout=120) - assert rh3.status_code == 200 # content preserved (soft delete) - - -def test_update_asset_fields(http: requests.Session, api_base: str, seeded_asset: dict): - aid = seeded_asset["id"] - original_tags = seeded_asset["tags"] - - payload = { - "name": "unit_1_renamed.safetensors", - "user_metadata": {"purpose": "updated", "epoch": 2}, - } - ru = http.put(f"{api_base}/api/assets/{aid}", json=payload, timeout=120) - body = ru.json() - assert ru.status_code == 200, body - assert body["name"] == payload["name"] - assert body["hash"] == body["asset_hash"] - assert body["tags"] == original_tags # tags unchanged - assert body["user_metadata"]["purpose"] == "updated" - # filename should still be present and normalized by server - assert "filename" in body["user_metadata"] - - -def test_head_asset_by_hash(http: requests.Session, api_base: str, seeded_asset: dict): - h = seeded_asset["asset_hash"] - - # Existing - rh1 = http.head(f"{api_base}/api/assets/hash/{h}", timeout=120) - assert rh1.status_code == 200 - - # Non-existent - rh2 = http.head(f"{api_base}/api/assets/hash/blake3:{'0'*64}", timeout=120) - assert rh2.status_code == 404 - - -def test_head_asset_bad_hash_returns_400_and_no_body(http: requests.Session, api_base: str): - # Invalid format; handler returns a JSON error, but HEAD responses must not carry a payload. - # requests exposes an empty body for HEAD, so validate status and that there is no payload. - rh = http.head(f"{api_base}/api/assets/hash/not_a_hash", timeout=120) - assert rh.status_code == 400 - body = rh.content - assert body == b"" - - -@pytest.mark.parametrize( - "method,endpoint_template,payload,expected_status,error_code", - [ - # Delete nonexistent asset - ("delete", "/api/assets/{uuid}", None, 404, "ASSET_NOT_FOUND"), - # Bad hash algorithm in from-hash - ( - "post", - "/api/assets/from-hash", - {"hash": "sha256:" + "0" * 64, "name": "x.bin", "tags": ["models", "checkpoints", "unit-tests"]}, - 400, - "INVALID_BODY", - ), - # Get with bad UUID format - ("get", "/api/assets/not-a-uuid", None, 404, None), - # Get content with bad UUID format - ("get", "/api/assets/not-a-uuid/content", None, 404, None), - ], - ids=["delete_nonexistent", "bad_hash_algorithm", "get_bad_uuid", "content_bad_uuid"], -) -def test_error_responses( - http: requests.Session, api_base: str, method, endpoint_template, payload, expected_status, error_code -): - # Replace {uuid} placeholder with a random UUID for delete test - endpoint = endpoint_template.replace("{uuid}", str(uuid.uuid4())) - url = f"{api_base}{endpoint}" - - if method == "get": - r = http.get(url, timeout=120) - elif method == "post": - r = http.post(url, json=payload, timeout=120) - elif method == "delete": - r = http.delete(url, timeout=120) - - assert r.status_code == expected_status - if error_code: - body = r.json() - assert body["error"]["code"] == error_code - - -def test_create_from_hash_invalid_json(http: requests.Session, api_base: str): - """Invalid JSON body requires special handling (data= instead of json=).""" - r = http.post(f"{api_base}/api/assets/from-hash", data=b"{not json}", timeout=120) - body = r.json() - assert r.status_code == 400 - assert body["error"]["code"] == "INVALID_JSON" - - -def test_update_requires_at_least_one_field(http: requests.Session, api_base: str, seeded_asset: dict): - aid = seeded_asset["id"] - r = http.put(f"{api_base}/api/assets/{aid}", json={}, timeout=120) - body = r.json() - assert r.status_code == 400 - assert body["error"]["code"] == "INVALID_BODY" - - -@pytest.mark.parametrize("root", ["input", "output"]) -def test_concurrent_delete_same_asset_info_single_204( - root: str, - http: requests.Session, - api_base: str, - asset_factory, - make_asset_bytes, -): - """ - Many concurrent DELETE for the same AssetInfo should result in: - - exactly one 204 No Content (the one that actually deleted) - - all others 404 Not Found (row already gone) - """ - scope = f"conc-del-{uuid.uuid4().hex[:6]}" - name = "to_delete.bin" - data = make_asset_bytes(name, 1536) - - created = asset_factory(name, [root, "unit-tests", scope], {}, data) - aid = created["id"] - - # Hit the same endpoint N times in parallel. - n_tests = 4 - url = f"{api_base}/api/assets/{aid}" - - def _do_delete(delete_url): - with requests.Session() as s: - return s.delete(delete_url, timeout=120).status_code - - with ThreadPoolExecutor(max_workers=n_tests) as ex: - statuses = list(ex.map(_do_delete, [url] * n_tests)) - - # Exactly one actual delete, the rest must be 404 - assert statuses.count(204) == 1, f"Expected exactly one 204; got: {statuses}" - assert statuses.count(404) == n_tests - 1, f"Expected {n_tests-1} 404; got: {statuses}" - - # The resource must be gone. - rg = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - assert rg.status_code == 404 - - -@pytest.mark.parametrize("root", ["input", "output"]) -def test_metadata_filename_is_set_for_seed_asset_without_hash( - root: str, - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, -): - """Seed ingest (no hash yet) must compute user_metadata['filename'] immediately.""" - scope = f"seedmeta-{uuid.uuid4().hex[:6]}" - name = "seed_filename.bin" - - base = comfy_tmp_base_dir / root / "unit-tests" / scope / "a" / "b" - base.mkdir(parents=True, exist_ok=True) - fp = base / name - fp.write_bytes(b"Z" * 2048) - - trigger_sync_seed_assets(http, api_base) - - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": root, "name_contains": name}, - timeout=120, - ) - body = r1.json() - assert r1.status_code == 200, body - matches = [a for a in body.get("assets", []) if a.get("name") == name] - assert matches, "Seed asset should be visible after sync" - # Seed assets have no hash; exclude_none drops both keys from the response - assert "asset_hash" not in matches[0] - assert "hash" not in matches[0] - aid = matches[0]["id"] - - r2 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - detail = r2.json() - assert r2.status_code == 200, detail - filename = (detail.get("user_metadata") or {}).get("filename") - expected = str(fp.relative_to(comfy_tmp_base_dir / root)).replace("\\", "/") - assert filename == expected, f"expected filename={expected}, got {filename!r}" - - -@pytest.mark.skip(reason="Requires computing hashes of files in directories to retarget cache states") -@pytest.mark.parametrize("root", ["input", "output"]) -def test_metadata_filename_computed_and_updated_on_retarget( - root: str, - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, - make_asset_bytes, - run_scan_and_wait, -): - """ - 1) Ingest under {root}/unit-tests//a/b/ -> filename reflects relative path. - 2) Retarget by copying to {root}/unit-tests//x/, remove old file, - run fast pass + scan -> filename updates to new relative path. - """ - scope = f"meta-fn-{uuid.uuid4().hex[:6]}" - name1 = "compute_metadata_filename.png" - name2 = "compute_changed_metadata_filename.png" - data = make_asset_bytes(name1, 2100) - - # Upload into nested path a/b - a = asset_factory(name1, [root, "unit-tests", scope, "a", "b"], {}, data) - aid = a["id"] - - root_base = comfy_tmp_base_dir / root - p1 = (root_base / "unit-tests" / scope / "a" / "b" / get_asset_filename(a["asset_hash"], ".png")) - assert p1.exists() - - # filename at ingest should be the path relative to root - rel1 = str(p1.relative_to(root_base)).replace("\\", "/") - g1 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - d1 = g1.json() - assert g1.status_code == 200, d1 - fn1 = d1["user_metadata"].get("filename") - assert fn1 == rel1 - - # Retarget: copy to x/, remove old, then sync+scan - p2 = root_base / "unit-tests" / scope / "x" / name2 - p2.parent.mkdir(parents=True, exist_ok=True) - p2.write_bytes(data) - if p1.exists(): - p1.unlink() - - trigger_sync_seed_assets(http, api_base) # seed the new path - run_scan_and_wait(root) # verify/hash and reconcile - - # filename should now point at x/ - rel2 = str(p2.relative_to(root_base)).replace("\\", "/") - g2 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - d2 = g2.json() - assert g2.status_code == 200, d2 - fn2 = d2["user_metadata"].get("filename") - assert fn2 == rel2 + next((comfy_tmp_base_dir / "output").glob("*.png")).unlink() + + response = None + for _attempt in range(5): + response = http.post( + f"{api_base}/api/assets/seed?wait=true", json={"roots": ["output"]} + ) + if response.status_code != 409: + break + _time.sleep(1.0) + assert response is not None + assert response.status_code == 200 + assets = http.get(f"{api_base}/api/assets", timeout=120).json()["assets"] + listed = {asset["id"]: asset for asset in assets} + assert record["id"] in listed + assert "missing" in listed[record["id"]]["tags"] + + +def test_record_crud_hard_deletes_the_record(http, api_base, asset_factory, make_asset_bytes): + record = asset_factory("crud.png", ["output", "unit-tests"], {}, make_asset_bytes("crud")) + + assert http.get(f"{api_base}/api/assets/{record['id']}").status_code == 200 + assert http.delete(f"{api_base}/api/assets/{record['id']}").status_code == 204 + assert http.get(f"{api_base}/api/assets/{record['id']}").status_code == 404 diff --git a/tests-unit/assets_test/test_downloads.py b/tests-unit/assets_test/test_downloads.py index d42915893dd..67fe3194ab9 100644 --- a/tests-unit/assets_test/test_downloads.py +++ b/tests-unit/assets_test/test_downloads.py @@ -1,202 +1,6 @@ -import contextlib -import json -import time -import uuid -from datetime import datetime -from pathlib import Path -from typing import Optional +def test_record_content_read_serves_live_content(http, api_base, asset_factory, make_asset_bytes): + record = asset_factory("download.png", ["output", "unit-tests"], {}, make_asset_bytes("download")) -import pytest -import requests -from helpers import get_asset_filename, trigger_sync_seed_assets + response = http.get(f"{api_base}/api/assets/{record['id']}/content") - -def test_download_svg_forced_to_attachment(http: requests.Session, api_base: str): - """GHSA-779p-m5rp-r4h4 CISA-5 (sibling route): an uploaded SVG must never be - served inline from GET /api/assets/{id}/content, or an inline ' - files = {"file": ("evil.svg", svg, "image/svg+xml")} - form_data = { - "tags": json.dumps(["models", "model_type:checkpoints", "unit-tests", "svgxss"]), - "name": "evil.svg", - } - up = http.post(api_base + "/api/assets", files=files, data=form_data, timeout=120) - body = up.json() - assert up.status_code in (200, 201), body - aid = body["id"] - try: - r = http.get(f"{api_base}/api/assets/{aid}/content?disposition=inline", timeout=120) - r.content - assert r.status_code == 200 - ct = r.headers.get("Content-Type", "").lower() - cd = r.headers.get("Content-Disposition", "").lower() - assert "svg" not in ct, f"SVG served with a renderable content type: {ct!r}" - assert ct.startswith("application/octet-stream"), f"expected octet-stream, got {ct!r}" - assert "attachment" in cd, f"inline disposition not overridden to attachment: {cd!r}" - assert r.headers.get("X-Content-Type-Options", "").lower() == "nosniff" - finally: - with contextlib.suppress(Exception): - http.delete(f"{api_base}/api/assets/{aid}", timeout=30) - - -def test_download_attachment_and_inline(http: requests.Session, api_base: str, seeded_asset: dict): - aid = seeded_asset["id"] - - # default attachment - r1 = http.get(f"{api_base}/api/assets/{aid}/content", timeout=120) - data = r1.content - assert r1.status_code == 200 - cd = r1.headers.get("Content-Disposition", "") - assert "attachment" in cd - assert data and len(data) == 4096 - - # inline requested - r2 = http.get(f"{api_base}/api/assets/{aid}/content?disposition=inline", timeout=120) - r2.content - assert r2.status_code == 200 - cd2 = r2.headers.get("Content-Disposition", "") - assert "inline" in cd2 - - -@pytest.mark.skip(reason="Requires computing hashes of files in directories to deduplicate into multiple cache states") -@pytest.mark.parametrize("root", ["input", "output"]) -def test_download_chooses_existing_state_and_updates_access_time( - root: str, - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, - make_asset_bytes, - run_scan_and_wait, -): - """ - Hashed asset with two state paths: if the first one disappears, - GET /content still serves from the remaining path and bumps last_access_time. - """ - scope = f"dl-first-{uuid.uuid4().hex[:6]}" - name = "first_existing_state.bin" - data = make_asset_bytes(name, 3072) - - # Upload -> path1 - a = asset_factory(name, [root, "unit-tests", scope], {}, data) - aid = a["id"] - - base = comfy_tmp_base_dir / root / "unit-tests" / scope - path1 = base / get_asset_filename(a["asset_hash"], ".bin") - assert path1.exists() - - # Seed path2 by copying, then scan to dedupe into a second state - path2 = base / "alt" / name - path2.parent.mkdir(parents=True, exist_ok=True) - path2.write_bytes(data) - trigger_sync_seed_assets(http, api_base) - run_scan_and_wait(root) - - # Remove path1 so server must fall back to path2 - path1.unlink() - - # last_access_time before - rg0 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - d0 = rg0.json() - assert rg0.status_code == 200, d0 - ts0 = d0.get("last_access_time") - - time.sleep(0.05) - r = http.get(f"{api_base}/api/assets/{aid}/content", timeout=120) - blob = r.content - assert r.status_code == 200 - assert blob == data # must serve from the surviving state (same bytes) - - rg1 = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - d1 = rg1.json() - assert rg1.status_code == 200, d1 - ts1 = d1.get("last_access_time") - - def _parse_iso8601(s: Optional[str]) -> Optional[float]: - if not s: - return None - s = s[:-1] if s.endswith("Z") else s - return datetime.fromisoformat(s).timestamp() - - t0 = _parse_iso8601(ts0) - t1 = _parse_iso8601(ts1) - assert t1 is not None - if t0 is not None: - assert t1 > t0 - - -@pytest.mark.parametrize("seeded_asset", [{"tags": ["models", "model_type:checkpoints"]}], indirect=True) -def test_download_missing_file_returns_404( - http: requests.Session, api_base: str, comfy_tmp_base_dir: Path, seeded_asset: dict -): - # Remove the underlying file then attempt download. - # We initialize fixture without additional tags to know exactly the asset file path. - try: - aid = seeded_asset["id"] - rg = http.get(f"{api_base}/api/assets/{aid}", timeout=120) - detail = rg.json() - assert rg.status_code == 200 - asset_filename = get_asset_filename(detail["asset_hash"], ".safetensors") - abs_path = comfy_tmp_base_dir / "models" / "checkpoints" / asset_filename - assert abs_path.exists() - abs_path.unlink() - - r2 = http.get(f"{api_base}/api/assets/{aid}/content", timeout=120) - assert r2.status_code == 404 - body = r2.json() - assert body["error"]["code"] == "FILE_NOT_FOUND" - finally: - # We created asset without the "unit-tests" tag(see `autoclean_unit_test_assets`), we need to clear it manually. - dr = http.delete(f"{api_base}/api/assets/{aid}", timeout=120) - dr.content - - -@pytest.mark.skip(reason="Requires computing hashes of files in directories to deduplicate into multiple cache states") -@pytest.mark.parametrize("root", ["input", "output"]) -def test_download_404_if_all_states_missing( - root: str, - http: requests.Session, - api_base: str, - comfy_tmp_base_dir: Path, - asset_factory, - make_asset_bytes, - run_scan_and_wait, -): - """Multi-state asset: after the last remaining on-disk file is removed, download must return 404.""" - scope = f"dl-404-{uuid.uuid4().hex[:6]}" - name = "missing_all_states.bin" - data = make_asset_bytes(name, 2048) - - # Upload -> path1 - a = asset_factory(name, [root, "unit-tests", scope], {}, data) - aid = a["id"] - - base = comfy_tmp_base_dir / root / "unit-tests" / scope - p1 = base / get_asset_filename(a["asset_hash"], ".bin") - assert p1.exists() - - # Seed a second state and dedupe - p2 = base / "copy" / name - p2.parent.mkdir(parents=True, exist_ok=True) - p2.write_bytes(data) - trigger_sync_seed_assets(http, api_base) - run_scan_and_wait(root) - - # Remove first file -> download should still work via the second state - p1.unlink() - ok1 = http.get(f"{api_base}/api/assets/{aid}/content", timeout=120) - b1 = ok1.content - assert ok1.status_code == 200 and b1 == data - - # Remove the last file -> download must 404 - p2.unlink() - r2 = http.get(f"{api_base}/api/assets/{aid}/content", timeout=120) - body = r2.json() - assert r2.status_code == 404 - assert body["error"]["code"] == "FILE_NOT_FOUND" + assert response.status_code == 200 diff --git a/tests-unit/assets_test/test_event_log.py b/tests-unit/assets_test/test_event_log.py new file mode 100644 index 00000000000..27c9bba139b --- /dev/null +++ b/tests-unit/assets_test/test_event_log.py @@ -0,0 +1,280 @@ +"""Tests for the structured assets event log lines (``app/assets/event_log.py``).""" + +import logging +import re +from pathlib import Path + +import pytest + +from app.assets import event_log +from app.assets.event_log import ALLOWED_FIELDS, TAG, EventLogError, emit, error_type + +# The line grammar below is the CONTRACT shared with the desktop launcher's log +# tap: Comfy-Org/Comfy-Desktop `src/main/lib/assetsTap.ts` holds the equivalent +# regex, and `tests-unit/assets_test/fixtures/assets_event_lines.txt` is a +# byte-identical copy of that repo's `src/main/lib/__fixtures__/assets-event-lines.txt`. +# Neither side may change without the other. +EVENT_LINE_PATTERN = re.compile( + r"^\[assets-event\] (?P[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)*)" + r"(?P(?: [a-z_]+=[^ =]+)*)$" +) + +FIXTURE_PATH = Path(__file__).parent / "fixtures" / "assets_event_lines.txt" + +# One valid value per allowed field, covering every enum member so the desktop +# tap's mirrored validator matrix has a counterpart on this side. +VALID_VALUES: dict[str, list[object]] = { + "root": ["models", "input", "output", "user", "temp"], + "phase": ["fast", "enrich", "full"], + "stage": ["mark_missing", "pruning", "fast_scan", "enrich", "finalize"], + "elapsed_ms": [0, 8123], + "created": [0, 12], + "enriched": [4], + "skipped": [3], + "hash_failed": [2], + "enrich_failed": [0], + "permission_denied": [0], + "count": [1], + "error_type": ["ValueError", "FileNotFoundError"], + "hashing_enabled": [True, False], + "site": ["discovery", "enrich"], +} + + +@pytest.fixture(autouse=True) +def autoclean_unit_test_assets(): + """Shadow the conftest fixture of the same name. + + The conftest version reaches a running server to delete test-tagged assets, + which transitively boots ComfyUI for every test in this directory. Nothing + here touches a server or creates an asset, so the boot is pure cost. + """ + yield + + +def fixture_lines() -> list[str]: + return FIXTURE_PATH.read_text(encoding="utf-8").splitlines() + + +def parse_fields(raw: str) -> dict[str, bool | int | str]: + fields: dict[str, bool | int | str] = {} + for pair in raw.split(): + name, value = pair.split("=", maxsplit=1) + if value == "true": + fields[name] = True + elif value == "false": + fields[name] = False + elif value.removeprefix("-").isdigit(): + fields[name] = int(value) + else: + fields[name] = value + return fields + + +def emit_line(caplog: pytest.LogCaptureFixture, event: str, **fields: object) -> str: + """Emit one event and return the single tagged line it produced.""" + caplog.clear() + with caplog.at_level(logging.INFO): + emit(event, **fields) + tagged = [r.getMessage() for r in caplog.records if r.getMessage().startswith(TAG)] + assert len(tagged) == 1, tagged + return tagged[0] + + +def go_to_production_mode(monkeypatch: pytest.MonkeyPatch) -> None: + """Leave strict mode so invalid calls warn-and-drop instead of raising.""" + monkeypatch.delenv("PYTEST_CURRENT_TEST", raising=False) + monkeypatch.delenv("COMFYUI_ASSETS_EVENT_LOG_STRICT", raising=False) + event_log._warned_call_sites.clear() + + +# --- the shared cross-repo fixture ------------------------------------------------- + + +def test_shared_fixture_file_holds_three_newline_terminated_lines(): + raw = FIXTURE_PATH.read_text(encoding="utf-8") + + assert raw.endswith("\n") + assert len(raw.splitlines()) == 3 + + +@pytest.mark.parametrize("line", fixture_lines()) +def test_emit_reproduces_each_shared_fixture_line_byte_for_byte(caplog, line): + """Given a canonical line, When its fields are re-emitted, Then the bytes match.""" + match = EVENT_LINE_PATTERN.match(line) + assert match is not None, line + fields = parse_fields(match.group("fields")) + + assert emit_line(caplog, match.group("event"), **fields) == line + + +# --- line shape --------------------------------------------------------------------- + + +def test_fields_are_serialized_as_sorted_logfmt(caplog): + line = emit_line(caplog, "seeder.scan_started", root="models", phase="fast") + + assert line == "[assets-event] seeder.scan_started phase=fast root=models" + + +def test_a_fieldless_event_still_matches_the_shared_pattern(caplog): + line = emit_line(caplog, "scanner.hash_discarded_modified") + + assert line == "[assets-event] scanner.hash_discarded_modified" + assert EVENT_LINE_PATTERN.match(line) is not None + + +def test_the_emitted_record_is_a_single_line(caplog): + line = emit_line(caplog, "seeder.scan_failed", error_type="ValueError") + + assert "\n" not in line + assert "\r" not in line + + +# --- error_type --------------------------------------------------------------------- + + +def test_error_type_is_the_class_name_and_the_path_never_reaches_the_line(caplog): + exc = FileNotFoundError("/home/x/model.safetensors") + + assert error_type(exc) == "FileNotFoundError" + + line = emit_line(caplog, "seeder.scan_failed", error_type=error_type(exc)) + assert "/home/x/model.safetensors" not in line + assert "model.safetensors" not in line + + +# --- the closed vocabulary ---------------------------------------------------------- + + +def test_the_valid_value_matrix_covers_every_allowed_field(): + assert set(VALID_VALUES) == set(ALLOWED_FIELDS) + + +@pytest.mark.parametrize( + ("field", "value"), + [(field, value) for field, values in VALID_VALUES.items() for value in values], +) +def test_every_allowed_field_value_round_trips(caplog, field, value): + line = emit_line(caplog, "seeder.scan_completed", **{field: value}) + + match = EVENT_LINE_PATTERN.match(line) + assert match is not None, line + assert parse_fields(match.group("fields")) == {field: value} + + +def test_unknown_field_raises_under_pytest(): + with pytest.raises(EventLogError): + emit("seeder.scan_started", path="/home/x/models") + + +@pytest.mark.parametrize("value", ["a/b", "a\\b", "a:b", "a b", "a=b", 'a"b']) +def test_a_string_value_carrying_a_forbidden_character_raises(value): + with pytest.raises(EventLogError): + emit("seeder.scan_failed", error_type=value) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("root", "checkpoints"), + ("root", 1), + ("phase", "quick"), + ("phase", None), + ("stage", "scanning"), + ("site", "reference"), + ("error_type", "x" * 65), + ("error_type", ""), + ("error_type", 7), + ("elapsed_ms", "8123"), + ("count", 1.5), + ("created", True), + ("hashing_enabled", 1), + ("hashing_enabled", "true"), + ], + ids=[ + "bad-root", + "non-string-root", + "bad-phase", + "none-phase", + "bad-stage", + "bad-site", + "oversized-string", + "empty-string", + "non-string-error-type", + "string-into-int-field", + "float-into-int-field", + "bool-into-int-field", + "int-into-bool-field", + "string-into-bool-field", + ], +) +def test_every_validator_rejects_its_bad_value(field, value): + with pytest.raises(EventLogError): + emit("seeder.scan_completed", **{field: value}) + + +@pytest.mark.parametrize( + "event", + ["", "Seeder.scan_started", "seeder..scan", "9seeder.scan", "seeder.scan-started", "seeder scan", "seeder.", "scanner.made_up"], +) +def test_an_invalid_event_name_raises(event): + with pytest.raises(EventLogError): + emit(event) + + +# --- strict mode vs production mode ------------------------------------------------- + + +def test_the_env_var_enables_strict_mode_without_pytest(monkeypatch): + go_to_production_mode(monkeypatch) + monkeypatch.setenv("COMFYUI_ASSETS_EVENT_LOG_STRICT", "1") + + with pytest.raises(EventLogError): + emit("seeder.scan_started", path="/home/x") + + +def test_an_env_var_value_other_than_1_is_not_strict(caplog, monkeypatch): + go_to_production_mode(monkeypatch) + monkeypatch.setenv("COMFYUI_ASSETS_EVENT_LOG_STRICT", "true") + + with caplog.at_level(logging.WARNING): + emit("seeder.scan_started", path="/home/x") + + assert [r for r in caplog.records if r.levelno == logging.WARNING] + + +def test_production_mode_warns_once_for_repeated_calls_from_one_call_site(caplog, monkeypatch): + go_to_production_mode(monkeypatch) + caplog.clear() + + with caplog.at_level(logging.WARNING): + for _ in range(3): + emit("seeder.scan_started", path="/home/x/models") + + warnings = [r for r in caplog.records if r.levelno == logging.WARNING] + assert len(warnings) == 1 + assert not [r for r in caplog.records if r.getMessage().startswith(TAG)] + assert "/home/x/models" not in caplog.text + + +def test_production_mode_warns_once_per_distinct_call_site(caplog, monkeypatch): + go_to_production_mode(monkeypatch) + caplog.clear() + + with caplog.at_level(logging.WARNING): + emit("seeder.scan_started", path="/home/x/models") + emit("seeder.scan_started", path="/home/x/models") + + assert len([r for r in caplog.records if r.levelno == logging.WARNING]) == 2 + + +def test_production_mode_still_emits_valid_events_after_a_dropped_one(caplog, monkeypatch): + go_to_production_mode(monkeypatch) + + with caplog.at_level(logging.INFO): + emit("seeder.scan_started", path="/home/x/models") + emit("seeder.scan_started", phase="fast") + + tagged = [r.getMessage() for r in caplog.records if r.getMessage().startswith(TAG)] + assert tagged == ["[assets-event] seeder.scan_started phase=fast"] diff --git a/tests-unit/assets_test/test_event_log_sites.py b/tests-unit/assets_test/test_event_log_sites.py new file mode 100644 index 00000000000..0880ca3064e --- /dev/null +++ b/tests-unit/assets_test/test_event_log_sites.py @@ -0,0 +1,68 @@ +import subprocess +import sys +from pathlib import Path + +import pytest + +from app.assets.event_log import TAG + + +STARTUP_SCRIPT = ( + "import runpy, comfy_kitchen; " + "comfy_kitchen.int8_attention_is_available=lambda: False; " + 'runpy.run_path("main.py", run_name="__main__")' +) + + +@pytest.fixture(autouse=True) +def autoclean_unit_test_assets(): + yield + + +def run_quick_startup(tmp_path: Path, *flags: str) -> str: + result = subprocess.run( + [ + sys.executable, + "-c", + STARTUP_SCRIPT, + "--cpu", + "--quick-test-for-ci", + "--disable-all-custom-nodes", + "--disable-api-nodes", + f"--base-directory={tmp_path}", + f"--front-end-root={tmp_path}", + f"--database-url=sqlite:///{tmp_path / 'assets.sqlite3'}", + *flags, + ], + cwd=Path(__file__).resolve().parents[2], + capture_output=True, + text=True, + timeout=120, + check=True, + ) + return result.stdout + result.stderr + + +# TODO: re-enable once the --enable-assets startup runs stop flaking. +@pytest.mark.skip(reason="assets startup tests temporarily disabled") +@pytest.mark.parametrize( + ("hashing_flag", "expected"), + [ + pytest.param((), False, id="hashing-disabled"), + pytest.param(("--enable-asset-hashing",), True, id="hashing-enabled"), + ], +) +def test_enabled_assets_emits_once_with_the_hashing_flag( + tmp_path: Path, hashing_flag: tuple[str, ...], expected: bool +) -> None: + output = run_quick_startup(tmp_path, "--enable-assets", *hashing_flag) + lines = [line for line in output.splitlines() if f"{TAG} assets.enabled " in line] + + assert len(lines) == 1 + assert f"hashing_enabled={str(expected).lower()}" in lines[0] + + +def test_noassets_emits_no_enabled_event(tmp_path: Path) -> None: + output = run_quick_startup(tmp_path) + + assert f"{TAG} assets.enabled " not in output diff --git a/tests-unit/assets_test/test_file_utils.py b/tests-unit/assets_test/test_file_utils.py index e3591d49b43..98be56fd96b 100644 --- a/tests-unit/assets_test/test_file_utils.py +++ b/tests-unit/assets_test/test_file_utils.py @@ -1,121 +1,11 @@ -import os -import sys +from app.assets.services.file_utils import get_size_and_mtime_ns -import pytest -from app.assets.services.file_utils import is_visible, list_files_recursively +def test_file_stat_reports_size_and_mtime(tmp_path): + path = tmp_path / "asset.bin" + path.write_bytes(b"bytes") + size, mtime_ns = get_size_and_mtime_ns(str(path)) -class TestIsVisible: - def test_visible_file(self): - assert is_visible("file.txt") is True - - def test_hidden_file(self): - assert is_visible(".hidden") is False - - def test_hidden_directory(self): - assert is_visible(".git") is False - - def test_visible_directory(self): - assert is_visible("src") is True - - def test_dotdot_is_hidden(self): - assert is_visible("..") is False - - def test_dot_is_hidden(self): - assert is_visible(".") is False - - -class TestListFilesRecursively: - def test_skips_hidden_files(self, tmp_path): - (tmp_path / "visible.txt").write_text("a") - (tmp_path / ".hidden").write_text("b") - - result = list_files_recursively(str(tmp_path)) - - assert len(result) == 1 - assert result[0].endswith("visible.txt") - - def test_skips_hidden_directories(self, tmp_path): - hidden_dir = tmp_path / ".hidden_dir" - hidden_dir.mkdir() - (hidden_dir / "file.txt").write_text("a") - - visible_dir = tmp_path / "visible_dir" - visible_dir.mkdir() - (visible_dir / "file.txt").write_text("b") - - result = list_files_recursively(str(tmp_path)) - - assert len(result) == 1 - assert "visible_dir" in result[0] - assert ".hidden_dir" not in result[0] - - def test_empty_directory(self, tmp_path): - result = list_files_recursively(str(tmp_path)) - assert result == [] - - def test_nonexistent_directory(self, tmp_path): - result = list_files_recursively(str(tmp_path / "nonexistent")) - assert result == [] - - @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") - def test_follows_symlinked_directories(self, tmp_path): - target = tmp_path / "real_dir" - target.mkdir() - (target / "model.safetensors").write_text("data") - - root = tmp_path / "root" - root.mkdir() - (root / "link").symlink_to(target) - - result = list_files_recursively(str(root)) - - assert len(result) == 1 - assert result[0].endswith("model.safetensors") - assert "link" in result[0] - - @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") - def test_follows_symlinked_files(self, tmp_path): - real_file = tmp_path / "real.txt" - real_file.write_text("content") - - root = tmp_path / "root" - root.mkdir() - (root / "link.txt").symlink_to(real_file) - - result = list_files_recursively(str(root)) - - assert len(result) == 1 - assert result[0].endswith("link.txt") - - @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") - def test_circular_symlinks_do_not_loop(self, tmp_path): - dir_a = tmp_path / "a" - dir_a.mkdir() - (dir_a / "file.txt").write_text("a") - # a/b -> a (circular) - (dir_a / "b").symlink_to(dir_a) - - result = list_files_recursively(str(dir_a)) - - assert len(result) == 1 - assert result[0].endswith("file.txt") - - @pytest.mark.skipif(sys.platform == "win32", reason="symlinks need privileges on Windows") - def test_mutual_circular_symlinks(self, tmp_path): - dir_a = tmp_path / "a" - dir_b = tmp_path / "b" - dir_a.mkdir() - dir_b.mkdir() - (dir_a / "file_a.txt").write_text("a") - (dir_b / "file_b.txt").write_text("b") - # a/link_b -> b and b/link_a -> a - (dir_a / "link_b").symlink_to(dir_b) - (dir_b / "link_a").symlink_to(dir_a) - - result = list_files_recursively(str(dir_a)) - basenames = sorted(os.path.basename(p) for p in result) - - assert "file_a.txt" in basenames - assert "file_b.txt" in basenames + assert size == 5 + assert mtime_ns > 0 diff --git a/tests-unit/assets_test/test_intended_behaviour.py b/tests-unit/assets_test/test_intended_behaviour.py new file mode 100644 index 00000000000..580637c71f2 --- /dev/null +++ b/tests-unit/assets_test/test_intended_behaviour.py @@ -0,0 +1,713 @@ +import json +import os +import uuid +from contextlib import contextmanager, nullcontext +from pathlib import Path +from unittest.mock import patch + +import pytest +from aiohttp.test_utils import make_mocked_request +from blake3 import blake3 +from sqlalchemy import create_engine, select +from sqlalchemy.orm import Session + +from app.assets import mode +from app.assets.api import routes +from app.assets.database.models import Asset, AssetContent, Base +from app.assets.database.queries import ( + create_content, + create_record, + fetch_record_tags, + get_record_by_id, + mark_content_missing, + rename_record, +) +from app.assets.database.queries.records import ( + get_preview_file_paths_by_ids, + get_record_by_path_or_none, +) +from app.assets.helpers import to_stored_hash +from app.assets.lifecycle import wipe_temp_db_rows +from app.assets.scanner import ( + build_asset_specs, + seed_asset_specs, + sync_prefixes_with_filesystem, +) +from app.assets.scanner_admission import _should_skip_extension +from app.assets.scanner_changes import ( + clear_pending_verifications, + detect_content_change, + drain_pending_verifications, + queue_pending_verification, +) +from app.assets.services.asset_management import ( + asset_exists, + delete_asset_reference, + resolve_hash_to_path, +) +from app.assets.services.file_utils import list_files_recursively +from app.assets.services.ingest import register_cached_output, upload_from_temp_path +from app.assets.services.lookup import ( + lookup_for_from_hash, + lookup_for_view, +) +from app.assets.services.snapshot_hash import snapshot_hash + + +@pytest.fixture +def session(): + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as database_session: + yield database_session + + +def _record(session, path: Path, name: str, hash_value: str | None = None): + content = create_content(session, str(path), hash=hash_value, size_bytes=path.stat().st_size if path.exists() else 0) + return create_record(session, content.id, name) + + +@contextmanager +def _sandbox_asset_roots(root: Path, model_category: Path | None = None): + with patch("app.assets.services.path_utils.folder_paths") as folder_paths_mock: + folder_paths_mock.get_input_directory.return_value = str(root / "input") + folder_paths_mock.get_output_directory.return_value = str(root / "output") + folder_paths_mock.get_temp_directory.return_value = str(root / "temp") + folder_paths_mock.models_dir = str(root / "models") + categories = ( + [("checkpoints", [str(model_category)], {".safetensors"})] + if model_category is not None + else [] + ) + with patch( + "app.assets.services.path_utils.get_comfy_models_folders", + return_value=categories, + ): + yield + + +_WRITER_UPDATE_CAP = 16 + + +def _differently_sized(payload: bytes, avoid_size: int) -> bytes: + return payload if len(payload) != avoid_size else payload + b"!" + + +def _seed_content_row(session, path: Path, hash_value: str | None = None): + seed_stat = path.stat() + return create_content( + session, + str(path), + hash=hash_value, + size_bytes=seed_stat.st_size, + mtime_ns=seed_stat.st_mtime_ns, + ) + + +def _scan_pass(session, root: Path) -> int: + survivors = sync_prefixes_with_filesystem( + session, [str(root)], collect_existing_paths=True + ) + specs, _tag_pool, _skipped = build_asset_specs( + list_files_recursively(str(root)), survivors or set() + ) + return seed_asset_specs(session, specs) + + +@contextmanager +def _writer_lands_mid_hash(path: Path, replacement: bytes): + real_blake3 = blake3 + + class _WriterHasher: + def __init__(self) -> None: + self._inner = real_blake3() + self._updates = 0 + + def update(self, chunk: bytes) -> None: + self._updates += 1 + assert self._updates <= _WRITER_UPDATE_CAP, ( + f"simulated writer never converged after {_WRITER_UPDATE_CAP} " + "chunks — it is feeding the read loop instead of ending it" + ) + if self._updates == 1: + path.write_bytes(_differently_sized(replacement, path.stat().st_size)) + self._inner.update(chunk) + + def hexdigest(self) -> str: + return self._inner.hexdigest() + + with patch("app.assets.services.snapshot_hash.blake3", _WriterHasher): + yield _WriterHasher + + +def test_scenario_1_rm_missing_and_strict_recovery(session, tmp_path): + """Ruling 1: missing is projected from content to every record.""" + record = _record(session, tmp_path / "missing.bin", "missing") + mark_content_missing(session, record.content_id) + assert fetch_record_tags(session, record.id) == ["missing"] + + +def test_scenario_2_edit_split(session, tmp_path): + """Ruling 2: edits split content and the old content read is unavailable. + + The split is the production verdict, not the fixture's: an in-place edit is + handed to ``detect_content_change`` with hashing off and both halves of a + genuine change (size AND mtime moved), which is the one OFF-mode shape that + is allowed to retire content. + """ + input_root = tmp_path / "input" + input_root.mkdir() + path = input_root / "edit.bin" + path.write_bytes(b"the-original-bytes") + seed_stat = path.stat() + old_content = _seed_content_row(session, path) + old_record = create_record(session, old_content.id, "edit.bin") + + path.write_bytes(b"a replacement of a decidedly different length") + moved_mtime = path.stat().st_mtime_ns + 1_000_000_000 + os.utime(path, ns=(moved_mtime, moved_mtime)) + edited_stat = path.stat() + assert edited_stat.st_size != seed_stat.st_size + assert edited_stat.st_mtime_ns != seed_stat.st_mtime_ns + + with _sandbox_asset_roots(tmp_path): + detect_content_change(session, old_content, edited_stat, hashing_is_enabled=False) + + new_record = get_record_by_path_or_none(session, str(path)) + assert new_record is not None + assert new_record.id != old_record.id + assert new_record.content_id != old_content.id + assert new_record.content.is_missing is False + assert new_record.content.size_bytes == edited_stat.st_size + assert new_record.content.mtime_ns == edited_stat.st_mtime_ns + assert session.get(Asset, old_record.id).content_id == old_content.id + assert old_content.is_missing is True + assert fetch_record_tags(session, old_record.id) == ["missing"] + assert create_content(session, str(path)).id == new_record.content_id + + +def test_scenario_3_path_reuse_convergence(session, tmp_path): + """Ruling 3: path reuse converges on missing old content plus a new record.""" + old = _record(session, tmp_path / "same.bin", "old") + mark_content_missing(session, old.content_id) + new = _record(session, tmp_path / "same.bin", "new") + assert old.content_id != new.content_id + assert old.content.is_missing is True + assert new.content.is_missing is False + assert create_content(session, str(tmp_path / "same.bin")).id == new.content_id + + +def test_scenario_4_delete_no_revival(session, tmp_path): + """Ruling 4: delete is hard, spares the content, and cannot be undone.""" + record = _record(session, tmp_path / "deleted.bin", "deleted") + record_id, content_id = record.id, record.content_id + + with patch( + "app.assets.services.asset_management.create_session", + lambda: nullcontext(session), + ): + assert delete_asset_reference(record_id) is True + assert delete_asset_reference(record_id) is False + + assert get_record_by_id(session, record_id) is None + assert session.get(AssetContent, content_id) is not None + + +def test_scenario_5_rename_always(session): + """Ruling 5: names are labels and duplicate names are allowed.""" + first = create_record(session, create_content(session, "/one").id, "same") + second = create_record(session, create_content(session, "/two").id, "other") + assert rename_record(session, second.id, "same").name == first.name + + +def test_scenario_6_upload_reuses_content_never_the_record(session, tmp_path): + """Ruling 6: every upload is a delivery event, so it always mints its own + record; dedup is content-level only, and runs in both hashing modes. + + Ratified 2026-08-28, superseding the "same name + same bytes returns the + existing record" behavior. Content dedup is unchanged: identical bytes + still share one ``AssetContent`` row, in BOTH modes — uploads hash + unconditionally (``upload_from_temp_path`` calls + ``_snapshot_hash_with_retry`` before it consults anything) and + ``lookup_for_view`` never asks ``mode.hashing_enabled``, unlike + ``lookup_for_from_hash``. What changed is record identity: a re-upload is a + new delivery, so it gets a new record carrying the attributes THAT request + supplied, instead of silently handing back an older record that never saw + them. + """ + (tmp_path / "output").mkdir(parents=True) + payload = b"the-uploaded-bytes" + expected_hash = to_stored_hash(blake3(payload).hexdigest()) + + def upload( + uploaded: bytes, + name: str, + *, + hashing: bool, + user_metadata: dict | None = None, + ): + staging = tmp_path / "temp" / "uploads" / uuid.uuid4().hex + staging.mkdir(parents=True) + staged = staging / ".upload.part" + staged.write_bytes(uploaded) + with ( + _sandbox_asset_roots(tmp_path), + patch( + "folder_paths.get_temp_directory", return_value=str(tmp_path / "temp") + ), + patch( + "app.assets.services.ingest.create_session", + lambda: nullcontext(session), + ), + patch.object(mode, "hashing_enabled", return_value=hashing), + ): + return upload_from_temp_path( + temp_path=str(staged), + name=name, + tags=["output"], + client_filename=name, + user_metadata=user_metadata, + ) + + first = upload(payload, "upload.bin", hashing=False) + again = upload( + payload, "upload.bin", hashing=False, user_metadata={"note": "second delivery"} + ) + renamed = upload(payload, "renamed.bin", hashing=False) + in_hash_mode = upload(payload, "upload.bin", hashing=True) + other = upload(b"a wholly different payload", "upload.bin", hashing=False) + + assert first.created_new is True + assert first.asset.hash == expected_hash + + assert again.created_new is True + assert again.ref.id != first.ref.id, "a re-upload is its own delivery" + assert again.ref.user_metadata == {"note": "second delivery"}, ( + "the new record carries the attributes THIS request supplied" + ) + assert ( + session.get(Asset, again.ref.id).content_id + == session.get(Asset, first.ref.id).content_id + ), "identical bytes still share one content row" + + assert in_hash_mode.created_new is True + assert in_hash_mode.ref.id != first.ref.id + assert ( + session.get(Asset, in_hash_mode.ref.id).content_id + == session.get(Asset, first.ref.id).content_id + ), "content dedup is mode-independent" + + assert renamed.created_new is True + assert renamed.ref.id != first.ref.id + assert renamed.ref.file_path == first.ref.file_path + assert ( + session.get(Asset, renamed.ref.id).content_id + == session.get(Asset, first.ref.id).content_id + ) + + assert other.created_new is True + assert other.asset.hash != expected_hash + assert other.ref.file_path != first.ref.file_path + + live = list( + session.scalars(select(AssetContent).where(AssetContent.is_missing.is_(False))) + ) + assert {content.hash for content in live} == {expected_hash, other.asset.hash} + + +def test_scenario_7_same_bytes_new_name(session, tmp_path): + """Ruling 7: a new name creates a new record.""" + path = tmp_path / "bytes.bin" + path.write_bytes(b"bytes") + content = create_content(session, str(path), hash="digest") + one = create_record(session, content.id, "one") + two = create_record(session, content.id, "two") + assert one.id != two.id + assert one.content_id == two.content_id == content.id + assert {one.name, two.name} == {"one", "two"} + + +def test_scenario_8_diff_bytes_same_name(session, tmp_path): + """Ruling 8: different bytes may share a display name.""" + a = _record(session, tmp_path / "one", "same") + b = _record(session, tmp_path / "two", "same") + assert a.id != b.id + assert a.name == b.name == "same" + assert a.content_id != b.content_id + + +def test_scenario_9_equal_hashes_no_merge(session, tmp_path): + """Ruling 9: equal hashes never impose content uniqueness.""" + a = _record(session, tmp_path / "one", "one", "digest") + b = _record(session, tmp_path / "two", "two", "digest") + assert a.content_id != b.content_id + assert a.content.hash == b.content.hash == "digest" + assert a.content.is_missing is False + assert b.content.is_missing is False + + +def test_scenario_10_cached_delivery_record(session, tmp_path): + """Ruling 10: cached delivery creates another record for existing content.""" + output_root = tmp_path / "output" + output_root.mkdir() + path = output_root / "cached.png" + path.write_bytes(b"pixels") + content = _seed_content_row(session, path, hash_value="digest") + original = create_record( + session, + content.id, + "cached.png", + job_id="executed-job", + system_metadata={"inherited": "from-the-earliest-sibling"}, + ) + content_before = ( + content.hash, + content.size_bytes, + content.mtime_ns, + content.is_missing, + ) + + with ( + _sandbox_asset_roots(tmp_path), + patch( + "app.assets.services.ingest.create_session", + lambda: nullcontext(session), + ), + ): + delivered = register_cached_output(str(path), job_id="delivery-job") + + assert delivered is not None + assert delivered.id != original.id + assert delivered.content_id == content.id + assert delivered.job_id == "delivery-job" + assert session.get(Asset, delivered.id).system_metadata == original.system_metadata + + assert ( + content.hash, + content.size_bytes, + content.mtime_ns, + content.is_missing, + ) == content_before + assert (original.job_id, original.name) == ("executed-job", "cached.png") + assert [row.id for row in session.scalars(select(AssetContent))] == [content.id] + assert {row.id for row in session.scalars(select(Asset))} == { + original.id, + delivered.id, + } + + mark_content_missing(session, content.id) + with ( + _sandbox_asset_roots(tmp_path), + patch( + "app.assets.services.ingest.create_session", + lambda: nullcontext(session), + ), + ): + assert register_cached_output(str(path), job_id="second-delivery") is None + assert {row.id for row in session.scalars(select(Asset))} == { + original.id, + delivered.id, + } + + +def test_scenario_11_restart_survival(tmp_path): + """Ruling 11: non-temp records survive reopening the database.""" + database = tmp_path / "assets.sqlite" + engine = create_engine(f"sqlite:///{database}") + Base.metadata.create_all(engine) + with Session(engine) as session: + record = create_record(session, create_content(session, "/durable").id, "durable") + session.commit() + record_id = record.id + with Session(engine) as session: + assert session.get(type(record), record_id) is not None + + +def test_scenario_12_temp_wipe_both_layers(session, tmp_path): + """Ruling 12: temp removal deletes records before their content.""" + temp_root = tmp_path / "temp" + temp_root.mkdir() + keep_root = tmp_path / "output" + keep_root.mkdir() + doomed = _record(session, temp_root / "render.png", "render") + survivor = _record(session, keep_root / "final.png", "final") + doomed_id, doomed_content_id = doomed.id, doomed.content_id + survivor_id, survivor_content_id = survivor.id, survivor.content_id + + with patch("folder_paths.get_temp_directory", return_value=str(temp_root)): + deleted = wipe_temp_db_rows(session) + session.commit() + + assert deleted == (1, 1) + assert session.get(Asset, doomed_id) is None + assert session.get(AssetContent, doomed_content_id) is None + assert session.get(Asset, survivor_id) is not None + assert session.get(AssetContent, survivor_content_id) is not None + + +def test_scenario_15_two_locations_hash_relation(session, tmp_path): + """Ruling 15: locations retain separate rows even with equal hashes.""" + a = _record(session, tmp_path / "a", "a", "digest") + b = _record(session, tmp_path / "b", "b", "digest") + assert a.content_id != b.content_id + assert a.content.hash == b.content.hash == "digest" + assert a.content.path != b.content.path + + +def test_scenario_17_move_is_missing_plus_new(session, tmp_path): + """Ruling 17: a move is missing old content plus a new record. + + Driven as a real ``os.replace`` observed by a real scanner pass: nothing + tells the scanner a move happened, it only sees one path gone and another + arrived, and the missing-plus-new outcome is entirely its own. + """ + input_root = tmp_path / "input" + input_root.mkdir() + old_path = input_root / "old.bin" + old_path.write_bytes(b"the-bytes-that-move") + old_content = _seed_content_row(session, old_path) + old_record = create_record(session, old_content.id, "old.bin") + + new_path = input_root / "new.bin" + os.replace(old_path, new_path) + + with ( + _sandbox_asset_roots(tmp_path), + patch.object(mode, "hashing_enabled", return_value=False), + ): + created = _scan_pass(session, input_root) + + assert created == 1 + new_record = get_record_by_path_or_none(session, str(new_path)) + assert new_record is not None + assert new_record.id != old_record.id + assert new_record.content_id != old_content.id + assert new_record.content.path == str(new_path) + assert new_record.content.is_missing is False + assert old_content.is_missing is True + assert "missing" in fetch_record_tags(session, old_record.id) + assert session.get(Asset, old_record.id).content_id == old_content.id + assert get_record_by_path_or_none(session, str(old_path)) is None + + +def test_scenario_18_edit_during_hash_discard(session, tmp_path): + """Ruling 18: unstable hashing must not overwrite a content identity.""" + path = tmp_path / "unstable.bin" + committed = b"the-committed-bytes" + path.write_bytes(committed) + committed_hash = to_stored_hash(blake3(committed).hexdigest()) + seed_stat = path.stat() + content = create_content( + session, + str(path), + hash=committed_hash, + size_bytes=seed_stat.st_size, + mtime_ns=seed_stat.st_mtime_ns, + ) + record = create_record(session, content.id, "unstable.bin") + + clear_pending_verifications() + try: + queue_pending_verification(content.id) + with _writer_lands_mid_hash(path, b"a-concurrent-writer-was-here"): + assert snapshot_hash(str(path)) is None + assert drain_pending_verifications(session) == 0 + + assert content.hash == committed_hash + assert content.is_missing is False + assert [row.id for row in session.scalars(select(Asset))] == [record.id] + assert [row.id for row in session.scalars(select(AssetContent))] == [content.id] + + path.write_bytes(committed) + assert drain_pending_verifications(session) == 1 + finally: + clear_pending_verifications() + + assert content.hash == committed_hash + assert content.mtime_ns == path.stat().st_mtime_ns + + +def test_writer_simulation_terminates_and_is_capped(tmp_path): + path = tmp_path / "bounded.bin" + path.write_bytes(b"0123456789") + + with _writer_lands_mid_hash(path, b"a-replacement-of-another-length") as hasher: + assert snapshot_hash(str(path)) is None + assert snapshot_hash(str(path)) is None + assert path.stat().st_size != 10 + + runaway = hasher() + with pytest.raises(AssertionError, match="never converged"): + for _ in range(_WRITER_UPDATE_CAP + 1): + runaway.update(b"chunk") + + +def test_scenario_20_partial_download(tmp_path): + """Ruling 20: partial-download admission is separate from content creation.""" + partial = tmp_path / "model.safetensors.part" + partial.write_bytes(b"partial") + complete = tmp_path / "model.safetensors" + complete.write_bytes(b"complete") + assert _should_skip_extension(str(partial)) is True + assert _should_skip_extension(str(complete)) is False + + +def test_scenario_21_symlink_two_rows(session, tmp_path): + """Ruling 21: lexical locations always retain separate content rows. + + One inode, two names, one real scanner pass. Lexical means the scanner must + not resolve the link: if it took the realpath, the second admission would + collide on the live-path index and yield a single row. + """ + input_root = tmp_path / "input" + input_root.mkdir() + real_path = input_root / "real.bin" + real_path.write_bytes(b"one-set-of-bytes-answering-to-two-names") + link_path = input_root / "link.bin" + os.symlink(real_path, link_path) + assert link_path.is_symlink() + assert os.path.samefile(real_path, link_path) + + with ( + _sandbox_asset_roots(tmp_path), + patch.object(mode, "hashing_enabled", return_value=False), + ): + created = _scan_pass(session, input_root) + + assert created == 2 + real_record = get_record_by_path_or_none(session, str(real_path)) + link_record = get_record_by_path_or_none(session, str(link_path)) + assert real_record is not None + assert link_record is not None + assert real_record.content_id != link_record.content_id + assert real_record.content.path == str(real_path) + assert link_record.content.path == str(link_path) + assert real_record.content.is_missing is False + assert link_record.content.is_missing is False + assert real_record.content.size_bytes == link_record.content.size_bytes + + +def test_scenario_25_registry_birth_fact(session, tmp_path): + """Ruling 25: loader classification is stamped at record birth.""" + category = tmp_path / "models" / "checkpoints" + (category / "family").mkdir(parents=True) + path = category / "family" / "model.safetensors" + path.write_bytes(b"first") + seed_stat = path.stat() + content = create_content( + session, str(path), size_bytes=seed_stat.st_size, mtime_ns=seed_stat.st_mtime_ns + ) + original = create_record(session, content.id, "model.safetensors") + assert original.loader_path is None + + path.write_bytes(b"second-bytes-of-a-different-length") + target_ns = max(path.stat().st_mtime_ns, seed_stat.st_mtime_ns) + 1_000_000 + os.utime(path, ns=(target_ns, target_ns)) + with _sandbox_asset_roots(tmp_path, category): + detect_content_change(session, content, path.stat(), hashing_is_enabled=False) + + born = get_record_by_path_or_none(session, str(path)) + assert born.id != original.id + assert born.loader_path == "family/model.safetensors" + assert original.loader_path is None + + +@pytest.mark.asyncio +async def test_scenario_26_view_forms(tmp_path): + """Ruling 26: record identity is stable for the canonical view form.""" + nested = tmp_path / "output" / "nested" + nested.mkdir(parents=True) + file_path = nested / "view.png" + file_path.write_bytes(b"pixels") + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + with Session(engine) as setup: + content = create_content(setup, str(file_path)) + record_id = create_record( + setup, content.id, "original-name.png", mime_type="image/png" + ).id + setup.commit() + + async def view_form() -> tuple[str, str]: + response = await routes.list_assets_route( + make_mocked_request("GET", "/api/assets") + ) + listed = json.loads(response.body)["assets"] + assert len(listed) == 1 + return listed[0]["id"], listed[0]["preview_url"] + + with ( + patch.object(routes, "create_session", lambda: Session(engine)), + patch.object(routes, "_ASSETS_ENABLED", True), + _sandbox_asset_roots(tmp_path), + ): + before = await view_form() + with Session(engine) as renaming: + rename_record(renaming, record_id, "renamed-entirely.png") + renaming.commit() + after = await view_form() + engine.dispose() + + assert before == ( + record_id, + "/api/view?type=output&filename=view.png&subfolder=nested", + ) + assert after == before + + +def test_scenario_27_fail_closed_previews_fromhash(session, tmp_path): + """Ruling 27: missing content is never a serving candidate.""" + path = tmp_path / "servable.png" + payload = b"pixels" + path.write_bytes(payload) + seed_stat = path.stat() + digest = to_stored_hash(blake3(payload).hexdigest()) + content = create_content( + session, + str(path), + hash=digest, + size_bytes=seed_stat.st_size, + mtime_ns=seed_stat.st_mtime_ns, + ) + record_id = create_record(session, content.id, "servable.png").id + + def previews() -> dict[str, str]: + return get_preview_file_paths_by_ids(session, [record_id]) + + def from_hash(): + with patch.object(mode, "hashing_enabled", return_value=True): + return lookup_for_from_hash(session, digest) + + def serving() -> tuple[bool, object]: + with patch( + "app.assets.services.asset_management.create_session", + lambda: nullcontext(session), + ): + return asset_exists(digest), resolve_hash_to_path(digest) + + with patch("folder_paths.get_temp_directory", return_value=str(tmp_path / "temp")): + assert previews() == {record_id: str(path)} + assert from_hash().id == content.id + assert lookup_for_view(session, digest).id == content.id + exists, resolved = serving() + assert exists is True + assert resolved is not None + + mark_content_missing(session, content.id) + + assert previews() == {} + assert from_hash() is None + assert lookup_for_view(session, digest) is None + assert serving() == (False, None) + + +def test_scenario_28_temp_exclusion(session, tmp_path): + """Ruling 28: a temporary location cannot become permanent shared content.""" + path = tmp_path / "temp.bin" + path.write_bytes(b"bytes") + record = _record(session, path, "temp", "digest") + with patch("app.assets.services.lookup.is_temp_path", return_value=True): + assert lookup_for_view(session, "digest") is None + with patch("app.assets.services.lookup.is_temp_path", return_value=False): + assert lookup_for_view(session, "digest").id == record.content_id diff --git a/tests-unit/assets_test/test_list_cursor.py b/tests-unit/assets_test/test_list_cursor.py index 8f4cc825156..3c64b747c75 100644 --- a/tests-unit/assets_test/test_list_cursor.py +++ b/tests-unit/assets_test/test_list_cursor.py @@ -1,349 +1,7 @@ -"""Integration tests for cursor-based pagination on GET /api/assets. +def test_record_list_returns_created_record(http, api_base, asset_factory, make_asset_bytes): + record = asset_factory("listed.png", ["output", "unit-tests"], {}, make_asset_bytes("listed")) -These tests exercise the handler/service/query path end-to-end; -cursor-encoding-level tests live in -tests-unit/assets_test/services/test_cursor.py. -""" -import pytest -import requests + response = http.get(f"{api_base}/api/assets", params={"limit": 50}) - -def _seed(asset_factory, make_asset_bytes, count: int, tag: str) -> list[str]: - names = [f"cursor_{i:02d}.safetensors" for i in range(count)] - for n in names: - asset_factory( - n, - ["models", "model_type:checkpoints", "unit-tests", tag], - {}, - make_asset_bytes(n, size=2048), - ) - return sorted(names) - - -def test_cursor_pages_all_items_in_order(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - names = _seed(asset_factory, make_asset_bytes, count=5, tag="cursor-walk") - - params = { - "include_tags": "unit-tests,cursor-walk", - "sort": "name", - "order": "asc", - "limit": "2", - } - - seen: list[str] = [] - after: str | None = None - pages = 0 - while True: - page_params = dict(params) - if after is not None: - page_params["after"] = after - r = http.get(api_base + "/api/assets", params=page_params, timeout=120) - assert r.status_code == 200, r.text - body = r.json() - seen.extend(a["name"] for a in body["assets"]) - pages += 1 - after = body.get("next_cursor") - if after is None: - break - assert body["has_more"] is True - assert pages < 10, "guard against runaway cursor loop" - - assert seen == names, f"expected {names}, got {seen}" - # Last page should have has_more False - assert body["has_more"] is False - assert "next_cursor" not in body - - -def test_cursor_invalid_returns_400(http: requests.Session, api_base: str): - r = http.get( - api_base + "/api/assets", - params={"after": "not-a-real-cursor", "sort": "created_at"}, - timeout=120, - ) - assert r.status_code == 400, r.text - body = r.json() - assert body["error"]["code"] == "INVALID_CURSOR" - - -def test_cursor_sort_mismatch_returns_400(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - _seed(asset_factory, make_asset_bytes, count=2, tag="cursor-mismatch") - - # Take a real cursor minted for sort=name. - r = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-mismatch", - "sort": "name", - "order": "asc", - "limit": "1", - }, - timeout=120, - ) - assert r.status_code == 200 - cursor = r.json()["next_cursor"] - assert cursor is not None - - # Replay against sort=created_at — should fail with INVALID_CURSOR. - r2 = http.get( - api_base + "/api/assets", - params={"after": cursor, "sort": "created_at"}, - timeout=120, - ) - assert r2.status_code == 400, r2.text - assert r2.json()["error"]["code"] == "INVALID_CURSOR" - - -def test_cursor_wins_over_offset(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - names = _seed(asset_factory, make_asset_bytes, count=4, tag="cursor-vs-offset") - - # Take a cursor that points past the first item. - r = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-vs-offset", - "sort": "name", - "order": "asc", - "limit": "1", - }, - timeout=120, - ) - assert r.status_code == 200, r.text - cursor = r.json()["next_cursor"] - assert cursor is not None - - # Pass both 'after' and a large offset. Cursor must win; offset is ignored. - r2 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-vs-offset", - "sort": "name", - "order": "asc", - "limit": "1", - "after": cursor, - "offset": "999", - }, - timeout=120, - ) - assert r2.status_code == 200 - body = r2.json() - # Should land on the second name in sorted order — not skip ahead by 999. - assert [a["name"] for a in body["assets"]] == [names[1]] - - -def test_next_cursor_absent_when_no_more_results(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - _seed(asset_factory, make_asset_bytes, count=2, tag="cursor-exhaust") - - r = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-exhaust", - "sort": "name", - "order": "asc", - "limit": "50", - }, - timeout=120, - ) - assert r.status_code == 200, r.text - body = r.json() - assert body["has_more"] is False - assert "next_cursor" not in body - - -def test_cursor_pagination_first_page_mints_cursor(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - """First-page request (no `after`) must still return `next_cursor` when - more rows exist, or pagination is unreachable from a cold start. - """ - _seed(asset_factory, make_asset_bytes, count=3, tag="cursor-first-page") - r = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,cursor-first-page", "sort": "name", "order": "asc", "limit": "2"}, - timeout=120, - ) - assert r.status_code == 200, r.text - body = r.json() - assert body["has_more"] is True - assert body.get("next_cursor"), "first page must mint a cursor when more rows exist" - - -def test_cursor_no_spurious_cursor_when_page_size_equals_remainder(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - """When `total` is an exact multiple of `limit`, the final page must - NOT carry a next_cursor — there is nothing past it. - """ - _seed(asset_factory, make_asset_bytes, count=4, tag="cursor-exact-multiple") - # Page 1 - r = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,cursor-exact-multiple", "sort": "name", "order": "asc", "limit": "2"}, - timeout=120, - ) - assert r.status_code == 200, r.text - cursor = r.json()["next_cursor"] - assert cursor is not None - # Page 2 — should exhaust the set with no cursor for a phantom page 3 - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,cursor-exact-multiple", "sort": "name", "order": "asc", "limit": "2", "after": cursor}, - timeout=120, - ) - assert r2.status_code == 200, r2.text - body = r2.json() - assert len(body["assets"]) == 2 - assert body["has_more"] is False - assert "next_cursor" not in body - - -@pytest.mark.parametrize("sort_field", ["created_at", "updated_at", "size"]) -def test_cursor_walks_for_non_name_sorts(sort_field, http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - """Cursor pagination must work for every sort field the contract claims. - - Without this, the `created_at` / `updated_at` (time-encoded micros) and - `size` (int-encoded) cursor paths go entirely unexercised end-to-end. - """ - # Sizes increase strictly by index, so `size desc` has a deterministic - # expected order. Time-based sorts (created_at / updated_at) can tie when - # rows are inserted faster than the DB's timestamp resolution; for those - # we check coverage and no-duplicates and let the keyset tiebreaker do - # the rest, instead of sleeping between inserts and asserting an order - # that depends on clock granularity. - names = [] - for i in range(4): - n = f"cursor_{sort_field}_{i:02d}.safetensors" - asset_factory(n, ["models", "model_type:checkpoints", "unit-tests", f"cursor-{sort_field}"], {}, make_asset_bytes(n, size=2048 + i)) - names.append(n) - - params = { - "include_tags": f"unit-tests,cursor-{sort_field}", - "sort": sort_field, - "order": "desc", - "limit": "2", - } - seen: list[str] = [] - after: str | None = None - pages = 0 - while True: - page_params = dict(params) - if after is not None: - page_params["after"] = after - r = http.get(api_base + "/api/assets", params=page_params, timeout=120) - assert r.status_code == 200, r.text - body = r.json() - seen.extend(a["name"] for a in body["assets"]) - after = body.get("next_cursor") - pages += 1 - if after is None: - break - assert pages < 10, "guard against runaway cursor loop" - - # No duplicates: a faulty keyset boundary that returns the same row across - # two pages must fail this check. - assert len(seen) == len(set(seen)), ( - f"cursor walk repeated rows for sort={sort_field}: {seen}" - ) - # Full coverage: every seeded asset reached exactly once. - assert set(seen) == set(names), ( - f"missing items for sort={sort_field}: expected {set(names)}, got {set(seen)}" - ) - # Strict order check for the only field with a clock-independent ordering. - if sort_field == "size": - assert seen == list(reversed(names)), ( - f"size cursor walked out of order: got {seen}, expected {list(reversed(names))}" - ) - - -def test_cursor_order_mismatch_returns_400(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - """A cursor minted under desc order replayed against asc must 400, not - silently walk the wrong direction.""" - _seed(asset_factory, make_asset_bytes, count=3, tag="cursor-order-flip") - - r = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-order-flip", - "sort": "name", - "order": "desc", - "limit": "1", - }, - timeout=120, - ) - assert r.status_code == 200, r.text - cursor = r.json()["next_cursor"] - assert cursor is not None - - # Replay with order flipped to asc — server must reject the cursor. - r2 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-order-flip", - "sort": "name", - "order": "asc", - "limit": "1", - "after": cursor, - }, - timeout=120, - ) - assert r2.status_code == 400, r2.text - assert r2.json()["error"]["code"] == "INVALID_CURSOR" - - -def test_cursor_invalid_cursor_at_microsecond_boundary(http: requests.Session, api_base: str): - """A cursor carrying an out-of-range microsecond timestamp must map to - 400 INVALID_CURSOR, not 500.""" - import base64 - import json - # 10^18 microseconds ≈ year 33658, well past datetime.MAX_YEAR. - # `o` and `order=` must be set; otherwise decode fails earlier on the - # missing-order branch and the µs-overflow path is never exercised. - payload = {"s": "created_at", "o": "desc", "v": "999999999999999999999", "id": "asset-x"} - raw = json.dumps(payload, separators=(",", ":")).encode("utf-8") - cursor = base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") - r = http.get( - api_base + "/api/assets", - params={"after": cursor, "sort": "created_at", "order": "desc"}, - timeout=120, - ) - assert r.status_code == 400, r.text - assert r.json()["error"]["code"] == "INVALID_CURSOR" - - -def test_cursor_pagination_stable_after_delete(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - names = _seed(asset_factory, make_asset_bytes, count=4, tag="cursor-delete") - - # Page 1. - r = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-delete", - "sort": "name", - "order": "asc", - "limit": "2", - }, - timeout=120, - ) - assert r.status_code == 200 - body = r.json() - page1_names = [a["name"] for a in body["assets"]] - cursor = body["next_cursor"] - assert cursor is not None - assert page1_names == names[:2] - - # Delete an item from page 1 (already returned) — cursor should still - # locate the next page from where it was minted, not re-index. - target_id = body["assets"][0]["id"] - d = http.delete(api_base + f"/api/assets/{target_id}", timeout=120) - assert d.status_code in (200, 204), d.text - - # Page 2 via cursor. - r2 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,cursor-delete", - "sort": "name", - "order": "asc", - "limit": "2", - "after": cursor, - }, - timeout=120, - ) - assert r2.status_code == 200, r2.text - body2 = r2.json() - assert [a["name"] for a in body2["assets"]] == names[2:] + assert response.status_code == 200 + assert record["id"] in {asset["id"] for asset in response.json()["assets"]} diff --git a/tests-unit/assets_test/test_list_filter.py b/tests-unit/assets_test/test_list_filter.py index 04da4f86ac7..d738f6accb6 100644 --- a/tests-unit/assets_test/test_list_filter.py +++ b/tests-unit/assets_test/test_list_filter.py @@ -1,758 +1,334 @@ -import time -import uuid -import warnings +from __future__ import annotations + +import urllib.parse +from datetime import datetime import pytest -import requests -from helpers import assert_hash_fields_consistent +from sqlalchemy import event + +from . import helpers +from .helpers import ( + RouteDatabase, + RecordSeed, + error_body as _error_body, + asset_list_body as _asset_list_body, + seed_record as _seed_record, + request_assets as _request_assets, +) +from app.assets.database.queries.records import ( + create_content, + create_record, + mark_content_missing, +) +from app.assets.services.cursor import encode_cursor -from app.assets.api import routes as assets_routes -from app.assets.api import schemas_in +autoclean_unit_test_assets = helpers.autoclean_unit_test_assets +route_database = helpers.route_database +sortable_record_ids = helpers.sortable_record_ids -def test_list_assets_paging_and_sort(http: requests.Session, api_base: str, asset_factory, make_asset_bytes): - names = ["a1_u.safetensors", "a2_u.safetensors", "a3_u.safetensors"] - for n in names: - asset_factory( - n, - ["models", "model_type:checkpoints", "unit-tests", "paging"], - {"epoch": 1}, - make_asset_bytes(n, size=2048), - ) +def test_record_list_filters_record_tags(http, api_base, asset_factory, make_asset_bytes): + record = asset_factory("filtered.png", ["output", "unit-tests", "chosen"], {}, make_asset_bytes("filtered")) - # name ascending for stable order - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,paging", "sort": "name", "order": "asc", "limit": "2", "offset": "0"}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - got1 = [a["name"] for a in b1["assets"]] - assert got1 == sorted(names)[:2] - assert b1["has_more"] is True - # Populated assets in list responses must carry both `hash` and `asset_hash` consistently - for asset in b1["assets"]: - assert_hash_fields_consistent(asset) - assert "hash" in asset, "populated asset must emit hash on list endpoint" - - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,paging", "sort": "name", "order": "asc", "limit": "2", "offset": "2"}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 - got2 = [a["name"] for a in b2["assets"]] - assert got2 == sorted(names)[2:] - assert b2["has_more"] is False + response = http.get(f"{api_base}/api/assets", params={"include_tags": "chosen"}) + assert response.status_code == 200 + assert [asset["id"] for asset in response.json()["assets"]] == [record["id"]] -def test_list_assets_include_exclude_and_name_contains(http: requests.Session, api_base: str, asset_factory): - a = asset_factory("inc_a.safetensors", ["models", "model_type:checkpoints", "unit-tests", "alpha"], {}, b"X" * 1024) - b = asset_factory("inc_b.safetensors", ["models", "model_type:checkpoints", "unit-tests", "beta"], {}, b"Y" * 1024) - r = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,alpha", "exclude_tags": "beta", "limit": "50"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200 - names = [x["name"] for x in body["assets"]] - assert a["name"] in names - assert b["name"] not in names - - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests", "name_contains": "inc_"}, - timeout=120, - ) - body2 = r2.json() - assert r2.status_code == 200 - names2 = [x["name"] for x in body2["assets"]] - assert a["name"] in names2 - assert b["name"] in names2 - - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "non-existing-tag"}, - timeout=120, - ) - body3 = r2.json() - assert r2.status_code == 200 - assert not body3["assets"] - - -def test_list_assets_sort_by_size_both_orders(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "lf-size"] - n1, n2, n3 = "sz1.safetensors", "sz2.safetensors", "sz3.safetensors" - asset_factory(n1, t, {}, make_asset_bytes(n1, 1024)) - asset_factory(n2, t, {}, make_asset_bytes(n2, 2048)) - asset_factory(n3, t, {}, make_asset_bytes(n3, 3072)) - - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-size", "sort": "size", "order": "asc"}, - timeout=120, +def test_record_list_rejects_metadata_filter(http, api_base): + response = http.get( + f"{api_base}/api/assets", params={"metadata_filter": '{"k":"v"}'} ) - b1 = r1.json() - names = [a["name"] for a in b1["assets"]] - assert names[:3] == [n1, n2, n3] - - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-size", "sort": "size", "order": "desc"}, - timeout=120, - ) - b2 = r2.json() - names2 = [a["name"] for a in b2["assets"]] - assert names2[:3] == [n3, n2, n1] + assert response.status_code == 400 + assert response.json() == { + "error": { + "code": "UNSUPPORTED_PARAM", + "message": "metadata_filter is no longer supported", + "details": {}, + } + } -def test_list_assets_sort_by_updated_at_desc(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "lf-upd"] - a1 = asset_factory("upd_a.safetensors", t, {}, make_asset_bytes("upd_a", 1200)) - a2 = asset_factory("upd_b.safetensors", t, {}, make_asset_bytes("upd_b", 1200)) +@pytest.mark.asyncio +async def test_tags_any_is_accepted(route_database: RouteDatabase) -> None: + _, session = route_database + _seed_record(session, RecordSeed("any.png", ("a",))) + session.commit() - # Rename the second asset to bump updated_at - rp = http.put(f"{api_base}/api/assets/{a2['id']}", json={"name": "upd_b_renamed.safetensors"}, timeout=120) - upd = rp.json() - assert rp.status_code == 200, upd + response = await _request_assets("tags_any=a") - r = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-upd", "sort": "updated_at", "order": "desc"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200 - names = [x["name"] for x in body["assets"]] - assert names[0] == "upd_b_renamed.safetensors" - assert a1["name"] in names - - - -def test_list_assets_sort_by_last_access_time_desc(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "lf-access"] - asset_factory("acc_a.safetensors", t, {}, make_asset_bytes("acc_a", 1100)) - time.sleep(0.02) - a2 = asset_factory("acc_b.safetensors", t, {}, make_asset_bytes("acc_b", 1100)) - - # Touch last_access_time of b by downloading its content - time.sleep(0.02) - dl = http.get(f"{api_base}/api/assets/{a2['id']}/content", timeout=120) - assert dl.status_code == 200 - dl.content - - r = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-access", "sort": "last_access_time", "order": "desc"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200 - names = [x["name"] for x in body["assets"]] - assert names[0] == a2["name"] - - -def test_list_assets_include_tags_variants_and_case(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "lf-include"] - a = asset_factory("incvar_alpha.safetensors", [*t, "alpha"], {}, make_asset_bytes("iva")) - asset_factory("incvar_beta.safetensors", [*t, "beta"], {}, make_asset_bytes("ivb")) - - # CSV tag filters are whitespace-trimmed and case-sensitive. - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-include,alpha"}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - names1 = [x["name"] for x in b1["assets"]] - assert a["name"] in names1 - assert not any("beta" in x for x in names1) - - # Repeated query params for include_tags - params_multi = [ - ("include_tags", "unit-tests"), - ("include_tags", "lf-include"), - ("include_tags", "alpha"), - ] - r2 = http.get(api_base + "/api/assets", params=params_multi, timeout=120) - b2 = r2.json() - assert r2.status_code == 200 - names2 = [x["name"] for x in b2["assets"]] - assert a["name"] in names2 - assert not any("beta" in x for x in names2) - - # Duplicates and spaces in CSV - r3 = http.get( - api_base + "/api/assets", - params={"include_tags": " unit-tests , lf-include , alpha , alpha "}, - timeout=120, - ) - b3 = r3.json() - assert r3.status_code == 200 - names3 = [x["name"] for x in b3["assets"]] - assert a["name"] in names3 - - -def test_list_assets_exclude_tags_dedup_and_case(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "lf-exclude"] - a = asset_factory("ex_a_alpha.safetensors", [*t, "alpha"], {}, make_asset_bytes("exa", 900)) - asset_factory("ex_b_beta.safetensors", [*t, "beta"], {}, make_asset_bytes("exb", 900)) - - # Exclude filters are case-sensitive. - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-exclude", "exclude_tags": "beta"}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - names1 = [x["name"] for x in b1["assets"]] - assert a["name"] in names1 - # Repeated excludes with duplicates - params_multi = [ - ("include_tags", "unit-tests"), - ("include_tags", "lf-exclude"), - ("exclude_tags", "beta"), - ("exclude_tags", "beta"), - ] - r2 = http.get(api_base + "/api/assets", params=params_multi, timeout=120) - b2 = r2.json() - assert r2.status_code == 200 - names2 = [x["name"] for x in b2["assets"]] - assert all("beta" not in x for x in names2) - - -def test_list_assets_name_contains_case_and_specials(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "lf-name"] - a1 = asset_factory("CaseMix.SAFE", t, {}, make_asset_bytes("cm", 800)) - a2 = asset_factory("case-other.safetensors", t, {}, make_asset_bytes("co", 800)) - - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-name", "name_contains": "casemix"}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - names1 = [x["name"] for x in b1["assets"]] - assert a1["name"] in names1 - - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-name", "name_contains": ".SAFE"}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 - names2 = [x["name"] for x in b2["assets"]] - assert a1["name"] in names2 - - r3 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-name", "name_contains": "case-"}, - timeout=120, - ) - b3 = r3.json() - assert r3.status_code == 200 - names3 = [x["name"] for x in b3["assets"]] - assert a2["name"] in names3 - - -def test_list_assets_offset_beyond_total_and_limit_boundary(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "lf-pagelimits"] - asset_factory("pl1.safetensors", t, {}, make_asset_bytes("pl1", 600)) - asset_factory("pl2.safetensors", t, {}, make_asset_bytes("pl2", 600)) - asset_factory("pl3.safetensors", t, {}, make_asset_bytes("pl3", 600)) - - # Offset far beyond total - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-pagelimits", "limit": "2", "offset": "10"}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - assert not b1["assets"] - assert b1["has_more"] is False - - # Boundary large limit (<=500 is valid) - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,lf-pagelimits", "limit": "500"}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 - assert len(b2["assets"]) == 3 - assert b2["has_more"] is False + assert response.status == 200 -@pytest.mark.parametrize( - "params,error_code", - [ - ({"offset": "-1"}, "INVALID_QUERY"), - ({"limit": "abc"}, "INVALID_QUERY"), - ({"limit": "0"}, "INVALID_QUERY"), - ({"metadata_filter": "{not json"}, "INVALID_QUERY"), - ], - ids=["negative_offset", "non_int_limit", "zero_limit", "invalid_metadata_json"], -) -def test_list_assets_invalid_query_rejected(http: requests.Session, api_base: str, params, error_code): - r = http.get(api_base + "/api/assets", params=params, timeout=120) - body = r.json() - assert r.status_code == 400 - assert body["error"]["code"] == error_code - - -def test_list_assets_name_contains_literal_underscore( - http, - api_base, - asset_factory, - make_asset_bytes, -): - """'name_contains' must treat '_' literally, not as a SQL wildcard. - We create: - - foo_bar.safetensors (should match) - - fooxbar.safetensors (must NOT match if '_' is escaped) - - foobar.safetensors (must NOT match) - """ - scope = f"lf-underscore-{uuid.uuid4().hex[:6]}" - tags = ["models", "model_type:checkpoints", "unit-tests", scope] - - a = asset_factory("foo_bar.safetensors", tags, {}, make_asset_bytes("a", 700)) - b = asset_factory("fooxbar.safetensors", tags, {}, make_asset_bytes("b", 700)) - c = asset_factory("foobar.safetensors", tags, {}, make_asset_bytes("c", 700)) - - r = http.get( - api_base + "/api/assets", - params={"include_tags": f"unit-tests,{scope}", "name_contains": "foo_bar"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200, body - names = [x["name"] for x in body["assets"]] - assert a["name"] in names, f"Expected literal underscore match to include {a['name']}" - assert b["name"] not in names, "Underscore must be escaped — should not match 'fooxbar'" - assert c["name"] not in names, "Underscore must be escaped — should not match 'foobar'" - assert body["total"] == 1 +@pytest.mark.asyncio +async def test_tags_any_matches_any_requested_tag(route_database: RouteDatabase) -> None: + _, session = route_database + a_record = _seed_record(session, RecordSeed("a.png", ("a",))) + b_record = _seed_record(session, RecordSeed("b.png", ("b",))) + _seed_record(session, RecordSeed("neither.png", ("c",))) + session.commit() + response = await _request_assets("tags_any=a,b") -def test_list_assets_tags_any_alone(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-any-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - a = asset_factory("any_a.safetensors", [*t, f"{scope}-alpha"], {}, make_asset_bytes("any_a")) - b = asset_factory("any_b.safetensors", [*t, f"{scope}-beta"], {}, make_asset_bytes("any_b")) - c = asset_factory("any_c.safetensors", [*t, f"{scope}-gamma"], {}, make_asset_bytes("any_c")) + body = _asset_list_body(response) + assert {asset["id"] for asset in body["assets"]} == {a_record.id, b_record.id} - r = http.get( - api_base + "/api/assets", - params={"tags_any": f"{scope}-alpha,{scope}-beta", "limit": "50"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200, body - names = [x["name"] for x in body["assets"]] - assert a["name"] in names - assert b["name"] in names - assert c["name"] not in names - - -def test_list_assets_tags_any_with_tags_all(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-anyall-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha, beta = f"{scope}-alpha", f"{scope}-beta" - x = asset_factory("aa_x.safetensors", [*t, alpha], {}, make_asset_bytes("aa_x")) - y = asset_factory("aa_y.safetensors", [*t, beta], {}, make_asset_bytes("aa_y")) - w = asset_factory("aa_w.safetensors", t, {}, make_asset_bytes("aa_w")) - d = asset_factory( - "aa_d.safetensors", - ["models", "model_type:checkpoints", "unit-tests", f"{scope}-other", alpha], - {}, - make_asset_bytes("aa_d"), - ) - r = http.get( - api_base + "/api/assets", - params={"tags_all": f"unit-tests,{scope}", "tags_any": f"{alpha},{beta}", "limit": "50"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200, body - names = [a["name"] for a in body["assets"]] - assert x["name"] in names - assert y["name"] in names - assert w["name"] not in names, "asset matching tags_all but not tags_any must be excluded" - assert d["name"] not in names, "asset matching tags_any but not tags_all must be excluded" - - -def test_list_assets_tags_none_wins_over_tags_any(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-nonewins-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha, beta = f"{scope}-alpha", f"{scope}-beta" - x = asset_factory("nw_x.safetensors", [*t, alpha], {}, make_asset_bytes("nw_x")) - y = asset_factory("nw_y.safetensors", [*t, alpha, beta], {}, make_asset_bytes("nw_y")) - - r = http.get( - api_base + "/api/assets", - params={"tags_any": alpha, "tags_none": beta, "limit": "50"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200, body - names = [a["name"] for a in body["assets"]] - assert x["name"] in names - assert y["name"] not in names, "tags_none must exclude an asset even when it matches tags_any" - - -def test_list_assets_empty_tag_filter_lists_behave_as_absent(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-empty-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - a = asset_factory("em_a.safetensors", t, {}, make_asset_bytes("em_a")) - b = asset_factory("em_b.safetensors", t, {}, make_asset_bytes("em_b")) - expected = {a["name"], b["name"]} - - # Empty new-name lists impose no constraint. - r1 = http.get( - api_base + "/api/assets", - params={"tags_all": f"unit-tests,{scope}", "tags_any": "", "tags_none": ""}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200, b1 - assert {x["name"] for x in b1["assets"]} == expected - - # An empty new-name param alongside old names must not trigger validation. - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": f"unit-tests,{scope}", "tags_any": ""}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200, b2 - assert {x["name"] for x in b2["assets"]} == expected - - # An empty tags_all next to include_tags is not a mixed-spelling conflict. - r3 = http.get( - api_base + "/api/assets", - params={"include_tags": f"unit-tests,{scope}", "tags_all": ""}, - timeout=120, +@pytest.mark.parametrize(("tag_count", "expected_status"), ((100, 200), (101, 400))) +@pytest.mark.asyncio +async def test_tags_any_enforces_the_shared_tag_cap( + route_database: RouteDatabase, + tag_count: int, + expected_status: int, +) -> None: + del route_database + tags = ",".join(f"tag-{index}" for index in range(tag_count)) + + response = await _request_assets(urllib.parse.urlencode({"tags_any": tags})) + + assert response.status == expected_status + if expected_status == 400: + assert _error_body(response)["error"]["code"] == "INVALID_TAG_FILTER" + + +@pytest.mark.asyncio +async def test_name_contains_filters_records(route_database: RouteDatabase) -> None: + _, session = route_database + match = _seed_record(session, RecordSeed("alpha.png")) + _seed_record(session, RecordSeed("beta.png")) + session.commit() + + response = await _request_assets("name_contains=lph") + + body = _asset_list_body(response) + assert [asset["id"] for asset in body["assets"]] == [match.id] + + +@pytest.mark.asyncio +async def test_offset_skips_records(route_database: RouteDatabase) -> None: + _, session = route_database + _seed_record(session, RecordSeed("a.png", ("offset-case",))) + second = _seed_record(session, RecordSeed("b.png", ("offset-case",))) + session.commit() + + response = await _request_assets( + "tags_all=offset-case&sort=name&order=asc&offset=1&limit=1" ) - b3 = r3.json() - assert r3.status_code == 200, b3 - assert {x["name"] for x in b3["assets"]} == expected - - -def test_list_assets_old_names_match_new_names(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-alias-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha, beta = f"{scope}-alpha", f"{scope}-beta" - asset_factory("al_a.safetensors", [*t, alpha], {}, make_asset_bytes("al_a")) - asset_factory("al_b.safetensors", [*t, beta], {}, make_asset_bytes("al_b")) - - def names_for(params: dict) -> tuple[list, int]: - r = http.get(api_base + "/api/assets", params={**params, "sort": "name", "order": "asc"}, timeout=120) - body = r.json() - assert r.status_code == 200, body - return [x["name"] for x in body["assets"]], body["total"] - - # include_tags ≡ tags_all - old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}"}) - new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}"}) - assert old_names == new_names - assert old_total == new_total - - # exclude_tags ≡ tags_none (and old/new spellings mix across slots) - old_names, old_total = names_for({"include_tags": f"unit-tests,{scope}", "exclude_tags": alpha}) - new_names, new_total = names_for({"tags_all": f"unit-tests,{scope}", "tags_none": alpha}) - mixed_names, mixed_total = names_for({"include_tags": f"unit-tests,{scope}", "tags_none": alpha}) - assert old_names == new_names == mixed_names == ["al_b.safetensors"] - assert old_total == new_total == mixed_total == 1 + body = _asset_list_body(response) + assert [asset["id"] for asset in body["assets"]] == [second.id] -@pytest.mark.parametrize( - "params,expected_parameters", - [ - ({"include_tags": "mx-x", "tags_all": "mx-y"}, ["include_tags", "tags_all"]), - ({"exclude_tags": "mx-x", "tags_none": "mx-y"}, ["exclude_tags", "tags_none"]), - ], - ids=["include_tags_with_tags_all", "exclude_tags_with_tags_none"], -) -def test_list_assets_mixed_tag_spellings_rejected(http, api_base, params, expected_parameters): - r = http.get(api_base + "/api/assets", params=params, timeout=120) - body = r.json() - assert r.status_code == 400, body - assert body["error"]["code"] == "INVALID_TAG_FILTER" - assert body["error"]["details"]["parameters"] == expected_parameters + +@pytest.mark.asyncio +async def test_default_order_is_newest_first(route_database: RouteDatabase) -> None: + _, session = route_database + older = _seed_record(session, RecordSeed("older.png")) + newer = _seed_record(session, RecordSeed("newer.png")) + older.created_at = datetime(2026, 1, 1) + newer.created_at = datetime(2026, 1, 2) + session.commit() + + response = await _request_assets() + + body = _asset_list_body(response) + assert [asset["id"] for asset in body["assets"]] == [newer.id, older.id] @pytest.mark.parametrize( - "params,conflicting,parameters", - [ - ( - {"tags_all": "cf-x", "tags_none": "cf-x"}, - ["cf-x"], - ["tags_all", "tags_none"], - ), - ( - {"include_tags": "cf-x", "tags_none": "cf-x"}, - ["cf-x"], - ["include_tags", "tags_none"], - ), - ( - {"tags_all": "cf-a,cf-b", "tags_none": "cf-b,cf-c"}, - ["cf-b"], - ["tags_all", "tags_none"], - ), - ], - ids=["new_names", "include_tags_remapped", "partial_overlap"], + ("sort", "order"), + ( + ("name", "asc"), + ("created_at", "desc"), + ("updated_at", "asc"), + ("size", "desc"), + ("last_access_time", "desc"), + ), ) -def test_list_assets_all_none_conflict_rejected(http, api_base, params, conflicting, parameters): - r = http.get(api_base + "/api/assets", params=params, timeout=120) - body = r.json() - assert r.status_code == 400, body - assert body["error"]["code"] == "INVALID_TAG_FILTER" - assert body["error"]["details"]["conflicting_tags"] == conflicting - assert body["error"]["details"]["parameters"] == parameters - - -def test_list_assets_any_none_overlap_accepted(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-deadterm-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha, beta = f"{scope}-alpha", f"{scope}-beta" - x = asset_factory("dt_x.safetensors", [*t, alpha], {}, make_asset_bytes("dt_x")) - y = asset_factory("dt_y.safetensors", [*t, beta], {}, make_asset_bytes("dt_y")) - - # alpha is a dead term (in both tags_any and tags_none) but the query is valid. - r = http.get( - api_base + "/api/assets", - params={"tags_any": f"{alpha},{beta}", "tags_none": alpha, "limit": "50"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200, body - names = [a["name"] for a in body["assets"]] - assert y["name"] in names - assert x["name"] not in names - - -def test_list_assets_legacy_include_exclude_conflict_still_200(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-legacy-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - asset_factory("lg_a.safetensors", t, {}, make_asset_bytes("lg_a")) - - # Old names only: the self-contradictory query stays an empty 200, never a 400. - r = http.get( - api_base + "/api/assets", - params={"include_tags": scope, "exclude_tags": scope}, - timeout=120, +@pytest.mark.asyncio +async def test_each_sort_field_orders_records( + route_database: RouteDatabase, + sortable_record_ids: tuple[str, str], + sort: str, + order: str, +) -> None: + del route_database + query = urllib.parse.urlencode( + {"tags_all": "sort-case", "sort": sort, "order": order} ) - body = r.json() - assert r.status_code == 200, body - assert body["assets"] == [] - - -def test_tags_refine_new_tag_filters(http, api_base, asset_factory, make_asset_bytes): - scope = f"rf-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha, beta = f"{scope}-alpha", f"{scope}-beta" - asset_factory("rf_a.safetensors", [*t, alpha], {}, make_asset_bytes("rf_a")) - asset_factory("rf_b.safetensors", [*t, beta], {}, make_asset_bytes("rf_b")) - - r = http.get( - api_base + "/api/assets/tags/refine", - params={"tags_any": f"{alpha},{beta}", "tags_none": alpha}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200, body - counts = body["tag_counts"] - assert counts.get(beta) == 1 - assert alpha not in counts - - r2 = http.get( - api_base + "/api/assets/tags/refine", - params={"tags_all": "rf-x", "tags_none": "rf-x"}, - timeout=120, - ) - body2 = r2.json() - assert r2.status_code == 400, body2 - assert body2["error"]["code"] == "INVALID_TAG_FILTER" - assert body2["error"]["details"]["conflicting_tags"] == ["rf-x"] - - -def test_list_assets_cross_slot_old_new_combinations(http, api_base, asset_factory, make_asset_bytes): - """Old and new spellings of *different* slots combine freely; only - same-slot mixing is rejected.""" - scope = f"lf-cross-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha, beta = f"{scope}-alpha", f"{scope}-beta" - a = asset_factory("cs_a.safetensors", [*t, alpha], {}, make_asset_bytes("cs_a")) - b = asset_factory("cs_b.safetensors", [*t, beta], {}, make_asset_bytes("cs_b")) - - def names_for(params: dict) -> set: - r = http.get(api_base + "/api/assets", params=params, timeout=120) - body = r.json() - assert r.status_code == 200, body - return {x["name"] for x in body["assets"]} - - assert names_for( - {"include_tags": f"unit-tests,{scope}", "tags_any": alpha} - ) == {a["name"]} - assert names_for( - {"tags_all": f"unit-tests,{scope}", "exclude_tags": alpha} - ) == {b["name"]} - assert names_for( - {"tags_any": f"{alpha},{beta}", "exclude_tags": alpha} - ) == {b["name"]} - - -def test_list_assets_repeated_query_keys_concatenate(http, api_base, asset_factory, make_asset_bytes): - """Repeated occurrences of a tag param concatenate before the CSV split - (Core-local behavior, not a cross-platform guarantee).""" - scope = f"lf-repeat-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha, beta = f"{scope}-alpha", f"{scope}-beta" - a = asset_factory("rp_a.safetensors", [*t, alpha], {}, make_asset_bytes("rp_a")) - b = asset_factory("rp_b.safetensors", [*t, beta], {}, make_asset_bytes("rp_b")) - - # requests encodes a list value as repeated keys: tags_any=&tags_any= - r = http.get( - api_base + "/api/assets", - params={"tags_any": [alpha, beta], "limit": "50"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 200, body - names = {x["name"] for x in body["assets"]} - assert {a["name"], b["name"]} <= names - - -def test_list_assets_tags_any_cursor_pagination_consistent(http, api_base, asset_factory, make_asset_bytes): - scope = f"lf-anypage-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - alpha = f"{scope}-alpha" - expected = set() - for i in range(3): - made = asset_factory(f"pg_{i}.safetensors", [*t, alpha], {}, make_asset_bytes(f"pg_{i}")) - expected.add(made["name"]) - - r1 = http.get( - api_base + "/api/assets", - params={"tags_any": alpha, "limit": "2", "sort": "name", "order": "asc"}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200, b1 - assert b1["total"] == 3 - assert b1["has_more"] is True - assert b1.get("next_cursor"), "expected a keyset cursor on the first page" - - r2 = http.get( - api_base + "/api/assets", - params={ - "tags_any": alpha, - "limit": "2", - "sort": "name", - "order": "asc", - "after": b1["next_cursor"], - }, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200, b2 - assert b2["has_more"] is False - page1 = {x["name"] for x in b1["assets"]} - page2 = {x["name"] for x in b2["assets"]} - assert not page1 & page2, "cursor pages must not overlap" - assert page1 | page2 == expected + response = await _request_assets(query) + body = _asset_list_body(response) + assert response.status == 200 + assert [asset["id"] for asset in body["assets"]] == list(sortable_record_ids) -def test_tags_refine_mixed_spellings_rejected_and_legacy_conflict_kept(http, api_base): - r = http.get( - api_base + "/api/assets/tags/refine", - params={"include_tags": "rfmx-x", "tags_all": "rfmx-y"}, - timeout=120, - ) - body = r.json() - assert r.status_code == 400, body - assert body["error"]["code"] == "INVALID_TAG_FILTER" - assert body["error"]["details"]["parameters"] == ["include_tags", "tags_all"] - - # Old names only: the refine route keeps legacy behaviour too. - r2 = http.get( - api_base + "/api/assets/tags/refine", - params={"include_tags": "rfmx-z", "exclude_tags": "rfmx-z"}, - timeout=120, + +@pytest.mark.asyncio +async def test_total_counts_all_filtered_records(route_database: RouteDatabase) -> None: + _, session = route_database + for index in range(5): + _seed_record(session, RecordSeed(f"total-{index}.png", ("total-case",))) + session.commit() + + response = await _request_assets("tags_all=total-case&limit=2") + + body = _asset_list_body(response) + assert len(body["assets"]) == 2 + assert body["total"] == 5 + + +@pytest.mark.parametrize(("offset", "expected"), ((0, True), (4, False))) +@pytest.mark.asyncio +async def test_offset_mode_has_more_uses_total( + route_database: RouteDatabase, + offset: int, + expected: bool, +) -> None: + _, session = route_database + for index in range(5): + _seed_record(session, RecordSeed(f"more-{index}.png", ("more-case",))) + session.commit() + + response = await _request_assets( + f"tags_all=more-case&sort=name&order=asc&offset={offset}&limit=1" ) - body2 = r2.json() - assert r2.status_code == 200, body2 - assert body2["tag_counts"] == {} - - -def test_list_assets_tag_values_case_sensitive(http, api_base, asset_factory, make_asset_bytes): - """Case-distinct tags are distinct; the all/none conflict check is byte-exact.""" - scope = f"lf-case-{uuid.uuid4().hex[:6]}" - t = ["models", "model_type:checkpoints", "unit-tests", scope] - upper, lower = f"{scope}-ALPHA", f"{scope}-alpha" - a = asset_factory("cx_a.safetensors", [*t, upper], {}, make_asset_bytes("cx_a")) - b = asset_factory("cx_b.safetensors", [*t, lower], {}, make_asset_bytes("cx_b")) - - def names_for(params: dict) -> set: - r = http.get(api_base + "/api/assets", params=params, timeout=120) - body = r.json() - assert r.status_code == 200, body - return {x["name"] for x in body["assets"]} - - assert names_for({"tags_all": f"unit-tests,{scope},{upper}"}) == {a["name"]} - assert names_for({"tags_any": lower, "limit": "50"}) == {b["name"]} - # Case-distinct all/none pair is NOT a conflict — byte-exact comparison. - assert names_for({"tags_all": f"unit-tests,{scope},{upper}", "tags_none": lower}) == {a["name"]} - - -def test_tag_list_cap_applies_to_all_spellings(http, api_base): - """The cap covers the legacy spellings too.""" - big = ",".join(f"cap-{i}" for i in range(101)) - for param in ("tags_any", "include_tags"): - r = http.get(api_base + "/api/assets", params={param: big}, timeout=120) - body = r.json() - assert r.status_code == 400, body - assert body["error"]["code"] == "INVALID_TAG_FILTER" - assert body["error"]["details"]["parameter"] == param - assert body["error"]["details"]["max"] == 100 - - exact = ",".join(f"cap-{i}" for i in range(100)) - r = http.get(api_base + "/api/assets", params={"tags_any": exact}, timeout=120) - assert r.status_code == 200, r.json() - - # The cap counts normalized (deduped) tags, not raw CSV items. - dups = ",".join("cap-dup" for _ in range(150)) - r = http.get(api_base + "/api/assets", params={"tags_any": dups}, timeout=120) - assert r.status_code == 200, r.json() - - -def test_resolve_tag_filters_no_deprecation_warning(): - """The deprecated-field warning is for API clients; the server's own remap - shim must not fire it on every request.""" - for q in ( - schemas_in.ListAssetsQuery(tags_all="a", tags_none="b"), - schemas_in.TagsRefineQuery(tags_any="c"), - ): - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) - assets_routes._resolve_tag_filters(q) - - -def test_tag_filter_alias_fields_marked_deprecated(): - for model in (schemas_in.ListAssetsQuery, schemas_in.TagsRefineQuery): - props = model.model_json_schema()["properties"] - for field in ("include_tags", "exclude_tags"): - assert props[field].get("deprecated") is True, (model.__name__, field) - for field in ("tags_all", "tags_any", "tags_none"): - assert "deprecated" not in props[field], (model.__name__, field) + + body = _asset_list_body(response) + assert body["has_more"] is expected + + +@pytest.mark.asyncio +async def test_bad_cursor_returns_400(route_database: RouteDatabase) -> None: + del route_database + + response = await _request_assets("after=not-a-cursor") + + assert response.status == 400 + assert _error_body(response)["error"]["code"] == "INVALID_CURSOR" + + +@pytest.mark.asyncio +async def test_cursor_rejects_last_access_time_sort(route_database: RouteDatabase) -> None: + del route_database + + response = await _request_assets("after=not-a-cursor&sort=last_access_time") + + assert response.status == 400 + assert _error_body(response)["error"]["code"] == "INVALID_CURSOR" + + +@pytest.mark.parametrize( + "query", + ( + "sort=created_at&order=asc", + "sort=name&order=desc", + ), +) +@pytest.mark.asyncio +async def test_cursor_rejects_sort_or_order_mismatch( + route_database: RouteDatabase, + query: str, +) -> None: + del route_database + cursor = encode_cursor("name", "a.png", "cursor-id", order="asc") + + response = await _request_assets(f"{query}&after={cursor}") + + assert response.status == 400 + assert _error_body(response)["error"]["code"] == "INVALID_CURSOR" + + +@pytest.mark.asyncio +async def test_terminal_page_has_no_cursor(route_database: RouteDatabase) -> None: + _, session = route_database + only_record = _seed_record(session, RecordSeed("only.png", ("terminal-case",))) + session.commit() + + response = await _request_assets( + "tags_all=terminal-case&sort=name&order=asc&limit=1" + ) + + body = _asset_list_body(response) + assert [asset["id"] for asset in body["assets"]] == [only_record.id] + assert body["has_more"] is False + assert "next_cursor" not in body + + +@pytest.mark.asyncio +async def test_page_query_budget_is_four_statements( + route_database: RouteDatabase, +) -> None: + engine, session = route_database + preview = _seed_record(session, RecordSeed("preview.png", ("preview",))) + for index in range(3): + record = _seed_record( + session, + RecordSeed(f"page-{index}.png", ("budget-case",)), + ) + record.preview_id = preview.id + session.commit() + statements: list[str] = [] + + def count_statements(_, __, statement, ___, ____, _____) -> None: + statements.append(statement) + + event.listen(engine, "before_cursor_execute", count_statements) + try: + response = await _request_assets("tags_all=budget-case&limit=3") + finally: + event.remove(engine, "before_cursor_execute", count_statements) + + body = _asset_list_body(response) + assert len(body["assets"]) == 3 + assert {asset.get("preview_id") for asset in body["assets"]} == {preview.id} + assert len(statements) == 4 + + +@pytest.mark.asyncio +async def test_missing_content_is_listed_with_missing_tag( + route_database: RouteDatabase, +) -> None: + _, session = route_database + live = _seed_record(session, RecordSeed("live.png", ("live-case",))) + missing_content = create_content(session, "/output/missing.png") + missing = create_record( + session, + content_id=missing_content.id, + name="missing.png", + tags=("live-case",), + ) + mark_content_missing(session, missing_content.id) + session.commit() + + response = await _request_assets("tags_all=live-case&sort=name&order=asc") + + body = _asset_list_body(response) + assert {asset["id"] for asset in body["assets"]} == {live.id, missing.id} + assert body["total"] == 2 + missing_asset = next(a for a in body["assets"] if a["id"] == missing.id) + assert "missing" in missing_asset["tags"] + + +@pytest.mark.asyncio +async def test_missing_content_is_reachable_via_tags_all_missing( + route_database: RouteDatabase, +) -> None: + _, session = route_database + _seed_record(session, RecordSeed("live.png", ("live-case",))) + missing_content = create_content(session, "/output/missing.png") + missing = create_record( + session, + content_id=missing_content.id, + name="missing.png", + tags=("live-case",), + ) + mark_content_missing(session, missing_content.id) + session.commit() + + response = await _request_assets("tags_all=missing") + + body = _asset_list_body(response) + assert {asset["id"] for asset in body["assets"]} == {missing.id} + assert body["total"] == 1 diff --git a/tests-unit/assets_test/test_metadata_filters.py b/tests-unit/assets_test/test_metadata_filters.py index 1864b1eef81..8f9705c338c 100644 --- a/tests-unit/assets_test/test_metadata_filters.py +++ b/tests-unit/assets_test/test_metadata_filters.py @@ -1,395 +1,7 @@ -import json +def test_record_detail_keeps_record_metadata(http, api_base, asset_factory, make_asset_bytes): + record = asset_factory("metadata.png", ["output", "unit-tests"], {"source": "test"}, make_asset_bytes("metadata")) + response = http.get(f"{api_base}/api/assets/{record['id']}") -def test_meta_and_across_keys_and_types( - http, api_base: str, asset_factory, make_asset_bytes -): - name = "mf_and_mix.safetensors" - tags = ["models", "model_type:checkpoints", "unit-tests", "mf-and"] - meta = {"purpose": "mix", "epoch": 1, "active": True, "score": 1.23} - asset_factory(name, tags, meta, make_asset_bytes(name, 4096)) - - # All keys must match (AND semantics) - f_ok = {"purpose": "mix", "epoch": 1, "active": True, "score": 1.23} - r1 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-and", - "metadata_filter": json.dumps(f_ok), - }, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - names = [a["name"] for a in b1["assets"]] - assert name in names - - # One key mismatched -> no result - f_bad = {"purpose": "mix", "epoch": 2, "active": True} - r2 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-and", - "metadata_filter": json.dumps(f_bad), - }, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 - assert not b2["assets"] - - -def test_meta_type_strictness_int_vs_str_and_bool(http, api_base, asset_factory, make_asset_bytes): - name = "mf_types.safetensors" - tags = ["models", "model_type:checkpoints", "unit-tests", "mf-types"] - meta = {"epoch": 1, "active": True} - asset_factory(name, tags, meta, make_asset_bytes(name)) - - # int filter matches numeric - r1 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-types", - "metadata_filter": json.dumps({"epoch": 1}), - }, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 and any(a["name"] == name for a in b1["assets"]) - - # string "1" must NOT match numeric 1 - r2 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-types", - "metadata_filter": json.dumps({"epoch": "1"}), - }, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and not b2["assets"] - - # bool True matches, string "true" must NOT match - r3 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-types", - "metadata_filter": json.dumps({"active": True}), - }, - timeout=120, - ) - b3 = r3.json() - assert r3.status_code == 200 and any(a["name"] == name for a in b3["assets"]) - - r4 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-types", - "metadata_filter": json.dumps({"active": "true"}), - }, - timeout=120, - ) - b4 = r4.json() - assert r4.status_code == 200 and not b4["assets"] - - -def test_meta_any_of_list_of_scalars(http, api_base, asset_factory, make_asset_bytes): - name = "mf_list_scalars.safetensors" - tags = ["models", "model_type:checkpoints", "unit-tests", "mf-list"] - meta = {"flags": ["red", "green"]} - asset_factory(name, tags, meta, make_asset_bytes(name, 3000)) - - # Any-of should match because "green" is present - filt_ok = {"flags": ["blue", "green"]} - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-list", "metadata_filter": json.dumps(filt_ok)}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 and any(a["name"] == name for a in b1["assets"]) - - # None of provided flags present -> no match - filt_miss = {"flags": ["blue", "yellow"]} - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-list", "metadata_filter": json.dumps(filt_miss)}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and not b2["assets"] - - # Duplicates in list should not break matching - filt_dup = {"flags": ["green", "green", "green"]} - r3 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-list", "metadata_filter": json.dumps(filt_dup)}, - timeout=120, - ) - b3 = r3.json() - assert r3.status_code == 200 and any(a["name"] == name for a in b3["assets"]) - - -def test_meta_none_semantics_missing_or_null_and_any_of_with_none( - http, api_base, asset_factory, make_asset_bytes -): - # a1: key missing; a2: explicit null; a3: concrete value - t = ["models", "model_type:checkpoints", "unit-tests", "mf-none"] - a1 = asset_factory("mf_none_missing.safetensors", t, {"x": 1}, make_asset_bytes("a1")) - a2 = asset_factory("mf_none_null.safetensors", t, {"maybe": None}, make_asset_bytes("a2")) - a3 = asset_factory("mf_none_value.safetensors", t, {"maybe": "x"}, make_asset_bytes("a3")) - - # Filter {maybe: None} must match a1 and a2, not a3 - filt = {"maybe": None} - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-none", "metadata_filter": json.dumps(filt), "sort": "name"}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - got = [a["name"] for a in b1["assets"]] - assert a1["name"] in got and a2["name"] in got and a3["name"] not in got - - # Any-of with None should include missing/null plus value matches - filt_any = {"maybe": [None, "x"]} - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-none", "metadata_filter": json.dumps(filt_any), "sort": "name"}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 - got2 = [a["name"] for a in b2["assets"]] - assert a1["name"] in got2 and a2["name"] in got2 and a3["name"] in got2 - - -def test_meta_nested_json_object_equality(http, api_base, asset_factory, make_asset_bytes): - name = "mf_nested_json.safetensors" - tags = ["models", "model_type:checkpoints", "unit-tests", "mf-nested"] - cfg = {"optimizer": "adam", "lr": 0.001, "schedule": {"type": "cosine", "warmup": 100}} - asset_factory(name, tags, {"config": cfg}, make_asset_bytes(name, 2200)) - - # Exact JSON object equality (same structure) - r1 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-nested", - "metadata_filter": json.dumps({"config": cfg}), - }, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 and any(a["name"] == name for a in b1["assets"]) - - # Different JSON object should not match - r2 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-nested", - "metadata_filter": json.dumps({"config": {"optimizer": "sgd"}}), - }, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and not b2["assets"] - - -def test_meta_list_of_objects_any_of(http, api_base, asset_factory, make_asset_bytes): - name = "mf_list_objects.safetensors" - tags = ["models", "model_type:checkpoints", "unit-tests", "mf-objlist"] - transforms = [{"type": "crop", "size": 128}, {"type": "flip", "p": 0.5}] - asset_factory(name, tags, {"transforms": transforms}, make_asset_bytes(name, 2048)) - - # Any-of for list of objects should match when one element equals the filter object - r1 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-objlist", - "metadata_filter": json.dumps({"transforms": {"type": "flip", "p": 0.5}}), - }, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 and any(a["name"] == name for a in b1["assets"]) - - # Non-matching object -> no match - r2 = http.get( - api_base + "/api/assets", - params={ - "include_tags": "unit-tests,mf-objlist", - "metadata_filter": json.dumps({"transforms": {"type": "rotate", "deg": 90}}), - }, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and not b2["assets"] - - -def test_meta_with_special_and_unicode_keys(http, api_base, asset_factory, make_asset_bytes): - name = "mf_keys_unicode.safetensors" - tags = ["models", "model_type:checkpoints", "unit-tests", "mf-keys"] - meta = { - "weird.key": "v1", - "path/like": 7, - "with:colon": True, - "ключ": "значение", - "emoji": "🐍", - } - asset_factory(name, tags, meta, make_asset_bytes(name, 1500)) - - # Match all the special keys - filt = {"weird.key": "v1", "path/like": 7, "with:colon": True, "emoji": "🐍"} - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-keys", "metadata_filter": json.dumps(filt)}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 and any(a["name"] == name for a in b1["assets"]) - - # Unicode key match - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-keys", "metadata_filter": json.dumps({"ключ": "значение"})}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and any(a["name"] == name for a in b2["assets"]) - - -def test_meta_with_zero_and_boolean_lists(http, api_base, asset_factory, make_asset_bytes): - t = ["models", "model_type:checkpoints", "unit-tests", "mf-zero-bool"] - a0 = asset_factory("mf_zero_count.safetensors", t, {"count": 0}, make_asset_bytes("z", 1025)) - a1 = asset_factory("mf_bool_list.safetensors", t, {"choices": [True, False]}, make_asset_bytes("b", 1026)) - - # count == 0 must match only a0 - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-zero-bool", "metadata_filter": json.dumps({"count": 0})}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - names1 = [a["name"] for a in b1["assets"]] - assert a0["name"] in names1 and a1["name"] not in names1 - - # Any-of list of booleans: True matches second asset - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-zero-bool", "metadata_filter": json.dumps({"choices": True})}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and any(a["name"] == a1["name"] for a in b2["assets"]) - - -def test_meta_mixed_list_types_and_strictness(http, api_base, asset_factory, make_asset_bytes): - name = "mf_mixed_list.safetensors" - tags = ["models", "model_type:checkpoints", "unit-tests", "mf-mixed"] - meta = {"mix": ["1", 1, True, None]} - asset_factory(name, tags, meta, make_asset_bytes(name, 1999)) - - # Should match because 1 is present - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-mixed", "metadata_filter": json.dumps({"mix": [2, 1]})}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 and any(a["name"] == name for a in b1["assets"]) - - # Should NOT match for False - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-mixed", "metadata_filter": json.dumps({"mix": False})}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and not b2["assets"] - - -def test_meta_unknown_key_and_none_behavior_with_scope_tags(http, api_base, asset_factory, make_asset_bytes): - # Use a unique scope tag to avoid interference - t = ["models", "model_type:checkpoints", "unit-tests", "mf-unknown-scope"] - x = asset_factory("mf_unknown_a.safetensors", t, {"k1": 1}, make_asset_bytes("ua")) - y = asset_factory("mf_unknown_b.safetensors", t, {"k2": 2}, make_asset_bytes("ub")) - - # Filtering by unknown key with None should return both (missing key OR null) - r1 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-unknown-scope", "metadata_filter": json.dumps({"unknown": None})}, - timeout=120, - ) - b1 = r1.json() - assert r1.status_code == 200 - names = {a["name"] for a in b1["assets"]} - assert x["name"] in names and y["name"] in names - - # Filtering by unknown key with concrete value should return none - r2 = http.get( - api_base + "/api/assets", - params={"include_tags": "unit-tests,mf-unknown-scope", "metadata_filter": json.dumps({"unknown": "x"})}, - timeout=120, - ) - b2 = r2.json() - assert r2.status_code == 200 and not b2["assets"] - - -def test_meta_with_tags_include_exclude_and_name_contains(http, api_base, asset_factory, make_asset_bytes): - # alpha matches epoch=1; beta has epoch=2 - a = asset_factory( - "mf_tag_alpha.safetensors", - ["models", "model_type:checkpoints", "unit-tests", "mf-tag", "alpha"], - {"epoch": 1}, - make_asset_bytes("alpha"), - ) - b = asset_factory( - "mf_tag_beta.safetensors", - ["models", "model_type:checkpoints", "unit-tests", "mf-tag", "beta"], - {"epoch": 2}, - make_asset_bytes("beta"), - ) - - params = { - "include_tags": "unit-tests,mf-tag,alpha", - "exclude_tags": "beta", - "name_contains": "mf_tag_", - "metadata_filter": json.dumps({"epoch": 1}), - } - r = http.get(api_base + "/api/assets", params=params, timeout=120) - body = r.json() - assert r.status_code == 200 - names = [x["name"] for x in body["assets"]] - assert a["name"] in names - assert b["name"] not in names - - -def test_meta_sort_and_paging_under_filter(http, api_base, asset_factory, make_asset_bytes): - # Three assets in same scope with different sizes and a common filter key - t = ["models", "model_type:checkpoints", "unit-tests", "mf-sort"] - n1, n2, n3 = "mf_sort_1.safetensors", "mf_sort_2.safetensors", "mf_sort_3.safetensors" - asset_factory(n1, t, {"group": "g"}, make_asset_bytes(n1, 1024)) - asset_factory(n2, t, {"group": "g"}, make_asset_bytes(n2, 2048)) - asset_factory(n3, t, {"group": "g"}, make_asset_bytes(n3, 3072)) - - # Sort by size ascending with paging - q = { - "include_tags": "unit-tests,mf-sort", - "metadata_filter": json.dumps({"group": "g"}), - "sort": "size", "order": "asc", "limit": "2", - } - r1 = http.get(api_base + "/api/assets", params=q, timeout=120) - b1 = r1.json() - assert r1.status_code == 200 - got1 = [a["name"] for a in b1["assets"]] - assert got1 == [n1, n2] - assert b1["has_more"] is True - - q2 = {**q, "offset": "2"} - r2 = http.get(api_base + "/api/assets", params=q2, timeout=120) - b2 = r2.json() - assert r2.status_code == 200 - got2 = [a["name"] for a in b2["assets"]] - assert got2 == [n3] - assert b2["has_more"] is False + assert response.status_code == 200 + assert response.json()["user_metadata"] == {"source": "test"} diff --git a/tests-unit/assets_test/test_no_assets_regression.py b/tests-unit/assets_test/test_no_assets_regression.py new file mode 100644 index 00000000000..d75fc31c93f --- /dev/null +++ b/tests-unit/assets_test/test_no_assets_regression.py @@ -0,0 +1,74 @@ +import contextlib +import socket +import subprocess +import sys +import time +from pathlib import Path + +import pytest +import requests + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server_socket: + server_socket.bind(("127.0.0.1", 0)) + return server_socket.getsockname()[1] + + +@pytest.fixture +def no_assets_server(tmp_path: Path): + for directory in ("models", "custom_nodes", "input", "output", "temp", "user"): + (tmp_path / directory).mkdir() + port = _free_port() + process = subprocess.Popen( + [ + sys.executable, + "main.py", + f"--base-directory={tmp_path}", + "--listen", + "127.0.0.1", + "--port", + str(port), + "--cpu", + ], + cwd=Path(__file__).resolve().parents[2], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + base_url = f"http://127.0.0.1:{port}" + for _ in range(120): + if process.poll() is not None: + raise RuntimeError(f"No-assets server exited with {process.returncode}") + try: + if requests.get(f"{base_url}/system_stats", timeout=1).status_code == 200: + break + except requests.ConnectionError: + pass + time.sleep(0.25) + else: + raise RuntimeError("No-assets server did not start") + yield base_url + process.terminate() + with contextlib.suppress(subprocess.TimeoutExpired): + process.wait(timeout=15) + + +def test_no_assets_keeps_legacy_upload_and_view(no_assets_server: str): + assert requests.get(f"{no_assets_server}/api/assets", timeout=10).status_code == 503 + + upload = requests.post( + f"{no_assets_server}/upload/image", + files={"image": ("legacy.png", b"legacy-bytes", "image/png")}, + data={"type": "output"}, + timeout=10, + ) + + assert upload.status_code == 200 + filename = upload.json()["name"] + view = requests.get( + f"{no_assets_server}/view", + params={"filename": filename, "type": "output"}, + timeout=10, + ) + assert view.status_code == 200 + assert view.content == b"legacy-bytes" diff --git a/tests-unit/assets_test/test_preview_url.py b/tests-unit/assets_test/test_preview_url.py index 35803069448..380966bde78 100644 --- a/tests-unit/assets_test/test_preview_url.py +++ b/tests-unit/assets_test/test_preview_url.py @@ -1,216 +1,6 @@ -import contextlib -import json -import re -import uuid +def test_record_detail_exposes_a_stable_id(http, api_base, asset_factory, make_asset_bytes): + record = asset_factory("preview.png", ["output", "unit-tests"], {}, make_asset_bytes("preview")) -import requests + detail = http.get(f"{api_base}/api/assets/{record['id']}").json() - -def test_preview_url_serves_the_asset( - http: requests.Session, api_base: str, asset_factory, make_asset_bytes -): - scope = f"preview-url-{uuid.uuid4().hex[:6]}" - name = f"{scope}.png" - data = make_asset_bytes(name, 2048) - - body = asset_factory(name, ["output", "unit-tests", scope], {}, data) - - assert re.fullmatch(r"/api/view\?type=output&filename=[^&]+", body["preview_url"]), ( - f"unexpected preview URL shape: {body['preview_url']!r}" - ) - - r = http.get(api_base + body["preview_url"], timeout=120) - assert r.status_code == 200, r.text - assert r.content == data, "the preview URL must serve the asset's own bytes" - - -def test_preview_url_honours_range_requests( - http: requests.Session, api_base: str, asset_factory, make_asset_bytes -): - scope = f"preview-range-{uuid.uuid4().hex[:6]}" - name = f"{scope}.png" - data = make_asset_bytes(name, 2048) - - body = asset_factory(name, ["output", "unit-tests", scope], {}, data) - - r = http.get( - api_base + body["preview_url"], headers={"Range": "bytes=10-109"}, timeout=120 - ) - assert r.status_code == 206, ( - f"expected a partial response, got {r.status_code}: native