diff --git a/__init__.py b/__init__.py index 3f20b504..2ce8c69b 100644 --- a/__init__.py +++ b/__init__.py @@ -49,5 +49,6 @@ async def comfy_entrypoint(): except Exception as error: import logging logging.warning('[FreeVideo] Agent routes not registered: %s', error) - from .freevideo_engine.comfy_nodes import FreeVideoExtension + from .freevideo_engine.comfy_nodes import FreeVideoExtension, register_prompt_cleanup + register_prompt_cleanup() return FreeVideoExtension() diff --git a/freevideo_engine/comfy_continuation.py b/freevideo_engine/comfy_continuation.py index 4af4becd..9a26d556 100644 --- a/freevideo_engine/comfy_continuation.py +++ b/freevideo_engine/comfy_continuation.py @@ -22,6 +22,25 @@ def optional_text(value, default=''): return value +def node_frames(value): + """The node's Total frames, or 0 (follow Duration) for anything that is not a count the model makes. + + Workflows saved by earlier versions and restored tabs can leave '', None, a word or another + widget's number in this optional input. Such a value is not a request for that length, so it + is read as "not given" instead of refusing the whole request. + """ + if isinstance(value, str): + try: + value = int(value.strip()) + except ValueError: + return 0 + if isinstance(value, float) and value.is_integer(): + value = int(value) + if type(value) is not int or value < 39 or (value - 5) % 17: + return 0 + return value + + def request_frames(value): """Translate the node's duration sentinel into the bridge's optional count.""" if type(value) is not int or value != 0 and (value < 39 or (value - 5) % 17): diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index f78f155e..d3be9e32 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -5,7 +5,7 @@ from comfy_api.latest import ComfyExtension, InputImpl, io, ui from . import comfy_bridge -from .comfy_continuation import optional_text, request_options +from .comfy_continuation import node_frames, optional_text, request_options from .default_prompt import DEFAULT_PROMPT References = io.Custom('FREEVIDEO_REFERENCES') @@ -15,6 +15,34 @@ STEP_CACHE_OPTIONS = ('none', 'easycache-av', 'fbcache') +def clean_prompt(json_data): + """POST /prompt hook: read Total frames before ComfyUI converts it. + + ComfyUI rejects '' or None for an integer input before validate_inputs runs, so the value is + read here the same way the node reads it (node_frames): anything that is not a count the model + makes follows Duration. Links and other nodes are left as they are. + """ + prompt = json_data.get('prompt') if isinstance(json_data, dict) else None + for node in prompt.values() if isinstance(prompt, dict) else (): + inputs = node.get('inputs') if isinstance(node, dict) and node.get('class_type') == 'FreeVideoGenerate' else None + if isinstance(inputs, dict) and 'frames' in inputs and not isinstance(inputs['frames'], list): + frames = node_frames(inputs['frames']) + if frames != inputs['frames'] or type(frames) is not type(inputs['frames']): + logging.info('[FreeVideo] Total frames %r is not a frame count; the length follows Duration.', + str(inputs['frames'])[:40]) + inputs['frames'] = frames + return json_data + + +def register_prompt_cleanup(): + from server import PromptServer + server = PromptServer.instance + if server is None or getattr(server, '_freevideo_prompt_cleanup', False): + return + server._freevideo_prompt_cleanup = True + server.add_on_prompt_handler(clean_prompt) + + def step_cache_option(value): """Bridge keyword for the Step cache widget; a workflow without it, with 'none' or with a value that is not an option adds nothing. Workflows saved before the widget existed hold '' in its position.""" @@ -97,7 +125,7 @@ def validate_inputs(cls, text, width, height, seconds, seed, continue_from='', f # and execute samples in full for any value that is not an option. refine_from = optional_text(refine_from) try: - options = request_options(continue_from, frames) + options = request_options(continue_from, node_frames(frames)) if options.get('continue_from'): # A continued part turns on two passes, skips the preview and takes keyframes as pictures (execute). two_pass, preview = True, False @@ -136,7 +164,7 @@ def execute(cls, text, width, height, seconds, seed, first=None, last=None, refine_from, continue_from = optional_text(refine_from), optional_text(continue_from) node_id = cls.hidden.unique_id chain = block_context(PromptServer.instance, node_id) - options = request_options(continue_from, frames) + options = request_options(continue_from, node_frames(frames)) notes = [] if options.get('continue_from') and not two_pass: two_pass = True diff --git a/freevideo_engine/lora_cache.py b/freevideo_engine/lora_cache.py index 0f16db2f..c523783d 100644 --- a/freevideo_engine/lora_cache.py +++ b/freevideo_engine/lora_cache.py @@ -27,6 +27,10 @@ from . import disk_space +# Per-block AdaLN projections that the int8 package keeps only as precomputed tables. +MODULATION = re.compile(r'transformer_blocks\.\d+\.adaln_proj\.') + + def _targets(name): prefixes = ('base_model.model.', 'diffusion_model.', 'model.diffusion_model.', 'transformer.') while any(name.startswith(prefix) for prefix in prefixes): @@ -50,7 +54,7 @@ def _targets(name): return [(name, None, swap)] -def index_adapter(row, targets): +def index_adapter(row, targets, skipped=None): pairs = {} full = [] with open_tensors(row['path']) as source: @@ -94,6 +98,9 @@ def index_adapter(row, targets): for target, split, swap in _targets(name): if target not in targets and '.attn.' in target: target = target.replace('.attn.', '.attn.orig.', 1) + if skipped is not None and target not in targets and MODULATION.match(target): + skipped.append(target) + continue expected = targets.get(target) rows = bshape[0] if split is None else bshape[0] // 3 if (expected is None or (split is not None and bshape[0] % 3) diff --git a/freevideo_engine/lora_online_cache.py b/freevideo_engine/lora_online_cache.py index 3e5b3902..2126994c 100644 --- a/freevideo_engine/lora_online_cache.py +++ b/freevideo_engine/lora_online_cache.py @@ -9,14 +9,14 @@ import torch from safetensors.torch import save_file -from .lora_cache import _targets, index_adapter, prepare as prepare_fused +from .lora_cache import MODULATION, _targets, index_adapter, prepare as prepare_fused from .media_request import digest, verify_files from .monitoring import save from .storage import fingerprint from .tensor_io import open_tensors -def _supported(adapters, linears): +def _supported(adapters, linears, skip_modulation=False): for adapter in adapters: with open_tensors(adapter['path']) as source: for key in source.keys(): @@ -25,6 +25,8 @@ def _supported(adapters, linears): for target, _, _ in _targets(name): if target not in linears and '.attn.' in target: target = target.replace('.attn.', '.attn.orig.', 1) + if skip_modulation and target not in linears and MODULATION.match(target): + continue if target not in linears or not target.startswith(('transformer_blocks.', 'token_refiner.refiner_blocks.')): return False return True @@ -43,7 +45,10 @@ def prepare(cache, adapters, output_root=None): precision = manifest.get('precision') # Low-rank branches run beside the base projection, so they need no change # to FP8 or ConvRot int8 weights. - if precision not in ('fp8', 'int8') or not _supported(adapters, manifest.get('linears', {})): + # The int8 package cannot merge AdaLN projections, so those adapters alone + # are skipped and counted in the report; every other patch still applies. + skip_modulation = precision == 'int8' + if precision not in ('fp8', 'int8') or not _supported(adapters, manifest.get('linears', {}), skip_modulation): # Full-difference, bias and modulation patches retain the existing # exact preparation path. Never silently omit an unsupported patch. path, report = prepare_fused(cache, adapters, output_root) @@ -74,9 +79,9 @@ def prepare(cache, adapters, output_root=None): save(marker, saved) return output, dict(enabled=True, mode='online', cache_hit=True, **saved['online_lora']['summary']) targets = {name: dict(shape=spec['weight_shape']) for name, spec in manifest['linears'].items()} - groups = {} + groups, skipped = {}, [] for adapter in adapters: - for patch in index_adapter(adapter, targets): + for patch in index_adapter(adapter, targets, skipped if skip_modulation else None): block = (patch['target'].split('.')[1] if patch['target'].startswith('transformer_blocks.') else 'root') groups.setdefault(block, []).append(patch) output.mkdir(parents=True, exist_ok=True) @@ -134,7 +139,7 @@ def prepare(cache, adapters, output_root=None): verify_files({'loras': adapters}) root_bytes = block_bytes.get('root', 0) summary = dict(targets=sum(len(spec['modules']) for spec in specs.values()), - adapter_count=len(adapters), factor_bytes=sum(block_bytes.values()), + adapter_count=len(adapters), skipped_modulation=len(skipped), factor_bytes=sum(block_bytes.values()), root_bytes=root_bytes, max_block_bytes=max((v for k, v in block_bytes.items() if k != 'root'), default=0)) online = dict(version=1, blocks=specs, block_bytes=block_bytes, summary=summary) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 743394c9..c8acc051 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,35 +1,52 @@ { "schema": 1, - "product_version": "0.4.10", + "product_version": "0.4.11", "en": { - "summary": "High on Mac uses the step cache and finishes much sooner; in the editor, audio clips can move to another audio track, and several notices and narrow-window layouts are clearer; workflows saved by an earlier version generate again.", + "summary": "Fixes requests being rejected with a frame count error, and LoRAs with extra parts generating on the int8 build.", "changes": [ - "Mac: High uses the step cache on the first pass and finishes much sooner, and the starting time estimate is based on the chip, so it is closer from the first step.", - "Editor: an audio clip dragged from the library onto the second audio track lands there, and a single audio clip on the timeline can be dragged to another audio track.", - "Editor: pressing split or trim while the playhead is outside the selected part, or asking for full screen where the browser refuses it, now says why in one line.", - "Character mentions written back to back, with no space between the names, are each recognised, and the character list opens again right after a name.", - "Autopilot: while a task is running, the Autopilot button in the header stays whole and clickable in narrow windows.", - "Chat: on phones and other narrow windows, button names quoted in notices stay on one line.", - "Diagnostic reports for failed image and video requests record the available memory when the request started and when the generation process ended, and whether the system appears to have ended the process.", - "Nodes: the English Width, Height and Seed labels are capitalised like the other fields.", - "Generating from a workflow saved by an earlier version, or from a restored tab, no longer fails with an invalid step cache setting." + "Generating from a workflow or a restored tab where Total frames holds a number that is not a frame count is no longer rejected with a frame count error; the number is read as the duration.", + "int8: a LoRA that also contains modulation parts no longer stops the generation; those parts are skipped one by one and the number skipped appears in the report." ] }, "zh": { - "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流又能正常生成。", + "summary": "修复请求被帧数错误拒绝的问题,并让带额外部分的 LoRA 能在 int8 版本上正常生成。", "changes": [ - "Mac:精细档第一遍使用步间缓存,快得多;开始时的预计时间按芯片给出,从第一步起就更接近实际。", - "编辑器:从素材库把音频拖到第二条音频轨会放在该轨;时间线上单个音频片段可以拖到另一条音频轨。", - "编辑器:播放头不在所选片段上时按切分或修剪,或在浏览器不允许全屏的地方点全屏,会用一行字说明原因。", - "连着写、名字之间没有空格的角色提及都能被识别,写完一个名字后接着输入也会弹出角色列表。", - "AI 全自动:有任务在跑时,窄窗口里顶栏的“AI 全自动”按钮也始终完整可点。", - "对话:手机等窄窗口里,提示中引号内的按钮名不再被拆到两行。", - "图像和视频失败请求的诊断报告现在记录请求开始和生成进程结束时的可用内存,并标明进程是否疑似被系统结束。", - "节点:英文界面里 Width、Height、Seed 的大小写与其他项一致。", - "修复:用旧版本保存的工作流或恢复的标签页生成时提示步间缓存参数无效。" + "用 Total frames 里填的不是帧数的工作流或恢复的标签页生成时,不再被帧数错误拒绝,这个数会按时长读取。", + "int8:带调制部分的 LoRA 不再让生成中断,这些部分会逐个跳过,跳过的数量写进报告。" ] }, "history": [ + { + "product_version": "0.4.10", + "en": { + "summary": "High on Mac uses the step cache and finishes much sooner; in the editor, audio clips can move to another audio track, and several notices and narrow-window layouts are clearer; workflows saved by an earlier version generate again.", + "changes": [ + "Mac: High uses the step cache on the first pass and finishes much sooner, and the starting time estimate is based on the chip, so it is closer from the first step.", + "Editor: an audio clip dragged from the library onto the second audio track lands there, and a single audio clip on the timeline can be dragged to another audio track.", + "Editor: pressing split or trim while the playhead is outside the selected part, or asking for full screen where the browser refuses it, now says why in one line.", + "Character mentions written back to back, with no space between the names, are each recognised, and the character list opens again right after a name.", + "Autopilot: while a task is running, the Autopilot button in the header stays whole and clickable in narrow windows.", + "Chat: on phones and other narrow windows, button names quoted in notices stay on one line.", + "Diagnostic reports for failed image and video requests record the available memory when the request started and when the generation process ended, and whether the system appears to have ended the process.", + "Nodes: the English Width, Height and Seed labels are capitalised like the other fields.", + "Generating from a workflow saved by an earlier version, or from a restored tab, no longer fails with an invalid step cache setting." + ] + }, + "zh": { + "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流又能正常生成。", + "changes": [ + "Mac:精细档第一遍使用步间缓存,快得多;开始时的预计时间按芯片给出,从第一步起就更接近实际。", + "编辑器:从素材库把音频拖到第二条音频轨会放在该轨;时间线上单个音频片段可以拖到另一条音频轨。", + "编辑器:播放头不在所选片段上时按切分或修剪,或在浏览器不允许全屏的地方点全屏,会用一行字说明原因。", + "连着写、名字之间没有空格的角色提及都能被识别,写完一个名字后接着输入也会弹出角色列表。", + "AI 全自动:有任务在跑时,窄窗口里顶栏的“AI 全自动”按钮也始终完整可点。", + "对话:手机等窄窗口里,提示中引号内的按钮名不再被拆到两行。", + "图像和视频失败请求的诊断报告现在记录请求开始和生成进程结束时的可用内存,并标明进程是否疑似被系统结束。", + "节点:英文界面里 Width、Height、Seed 的大小写与其他项一致。", + "修复:用旧版本保存的工作流或恢复的标签页生成时提示步间缓存参数无效。" + ] + } + }, { "product_version": "0.4.9", "en": { diff --git a/web/optional_inputs.js b/web/optional_inputs.js index b089de3e..3bf7416a 100644 --- a/web/optional_inputs.js +++ b/web/optional_inputs.js @@ -16,8 +16,14 @@ export function optionalText(value) { return !text || EMPTY_WORDS.has(text.toLowerCase()) ? '' : value; } +// Total frames is 0 (follow Duration) or 5 + 17n from 39 on, as the engine reads it (node_frames). +// Any other number came from somewhere else, such as a moved widget, and is not a requested length. +export function frameCount(value) { + return Number.isSafeInteger(value) && value >= 39 && (value - 5) % 17 === 0 ? value : 0; +} + export function optionalInputValue(name, value) { - if (name === 'frames') return Number.isSafeInteger(value) && value >= 0 ? value : 0; + if (name === 'frames') return frameCount(value); if (name === 'refine_from' || name === 'continue_from') return optionalText(value); if (name === 'first_frame_reference') return value === true; if (name === 'step_cache') return STEP_CACHE_OPTIONS.includes(value) ? value : 'none';