Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
19 changes: 19 additions & 0 deletions freevideo_engine/comfy_continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
34 changes: 31 additions & 3 deletions freevideo_engine/comfy_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
9 changes: 8 additions & 1 deletion freevideo_engine/lora_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
17 changes: 11 additions & 6 deletions freevideo_engine/lora_online_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
59 changes: 38 additions & 21 deletions freevideo_engine/release_notes.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
8 changes: 7 additions & 1 deletion web/optional_inputs.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down