Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1566e22
Look past reserved port blocks when moving the local engine address
KBRASK Oct 19, 2026
3376816
Say when ports are reserved by the system and translate the local add…
KBRASK Oct 19, 2026
4c7f798
Mac: put the old app back when the new one dies right after opening
KBRASK Oct 19, 2026
ed5a9cd
Mac: match the new app's process by an escaped path
KBRASK Oct 19, 2026
b1983f9
Mac: keep the old app in a .noindex folder during an update so Spotli…
KBRASK Oct 19, 2026
206d7fd
Background kernel compile also covers the Medium level's larger first…
KBRASK Oct 19, 2026
2306766
Fall back to Torch attention when MLX fails, and settle the Mac memor…
KBRASK Oct 19, 2026
550b102
Show the reason and the buttons of a failed update above the long rel…
KBRASK Oct 19, 2026
d559fea
Point to a nearby ComfyUI folder when the chosen folder is not one
KBRASK Oct 19, 2026
0fa925e
Name the nearby ComfyUI folder on the wrong-folder card
KBRASK Oct 19, 2026
406a175
Keep the phone page working on browsers without AbortSignal.any
KBRASK Oct 20, 2026
ddc54c8
Allow LAN access when it is switched on in the launcher
KBRASK Oct 20, 2026
c8a6025
Keep a vanished saved installation off the wrong-folder card
KBRASK Oct 20, 2026
4107d06
Name the changed checkpoint file fields in the failure text
KBRASK Oct 19, 2026
dd1d452
Keep the queue and a running loop through a short outage, and explain…
KBRASK Oct 20, 2026
95108b0
Open image generation and editing on Macs with Apple GPUs
KBRASK Oct 19, 2026
741d429
Release 0.4.17
KBRASK Oct 20, 2026
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
11 changes: 9 additions & 2 deletions freevideo_engine/agent/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,16 @@ def peer_address(request):

def lan_enabled():
from ..comfy_bridge import installation_root
from ..launcher_settings import Store
from ..desktop_runtime import launcher_root
from ..launcher_settings import Store, read
# The launcher's switch saves into the launcher folder, the page's switch into the installation. Access needs
# every copy of one of the two to say yes, and the most recently saved copy anywhere to say yes too, so a switch
# turned off in one place stays off even when another place still holds an older yes.
try:
return Store(installation_root()).currently_allows('lan_access')
stores = [Store(root()) for root in (installation_root, launcher_root)]
copies = [read(path) for store in stores for path in store.paths]
newest = max(copies, key=lambda saved: saved.get('saved_at_ns', 0))
return newest.get('lan_access') is True and any(store.currently_allows('lan_access') for store in stores)
except (OSError, ValueError):
return False

Expand Down
49 changes: 42 additions & 7 deletions freevideo_engine/comfy_launcher_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,12 +158,35 @@ def disk_review(plan, engine, comfy, *, separate, new_comfy):
return extra, list(disks.values()), errors(disks.values())


def _is_comfy_root(path):
return (path / 'main.py').is_file() and (path / 'folder_paths.py').is_file()


def nearby_comfy(selected):
"""Name of a ComfyUI folder just above or one level below the chosen one, else ''.

Only a name is returned: the launcher hides absolute paths in its messages.
"""
try:
for parent in (selected, *list(selected.parents)[:3]):
if _is_comfy_root(parent):
return parent.name
for child in sorted(p for p in selected.iterdir() if p.is_dir())[:200]:
if _is_comfy_root(child) or _is_comfy_root(child / 'ComfyUI'):
return child.name
except OSError:
pass
return ''


def layout(path):
try:
selected = resolved(Path(path).expanduser())
root = selected / 'ComfyUI' if (selected / 'ComfyUI' / 'main.py').is_file() else selected
if not (root / 'main.py').is_file() or not (root / 'folder_paths.py').is_file():
raise ValueError('Select the ComfyUI folder, or the portable folder containing ComfyUI.')
if not _is_comfy_root(root):
found = nearby_comfy(selected)
raise ValueError('Select the ComfyUI folder, or the portable folder containing ComfyUI.'
+ (f' Found one at "{found}"; select that folder.' if found else ''))
if not (root / 'comfy_api' / 'latest').is_dir():
raise ValueError('This ComfyUI is too old for native VIDEO / V3 nodes. Update ComfyUI first; its files have not been changed.')
candidates = []
Expand Down Expand Up @@ -418,14 +441,26 @@ def lan_access_links(engine, port, *, password_set=None):
return [address(host, port) + suffix for host in hosts]


def reserved_port(error):
"""Whether no program may listen on this port at all. Windows with WSL or Hyper-V reserves
whole blocks of ports (often 100 or more in a row), and binding one fails with WSAEACCES."""
return error.errno == errno.EACCES or getattr(error, 'winerror', None) == 10013


def next_free_address(url, count=12):
"""The next local address after this one that nothing listens or answers on, as setup.sh chooses."""
"""The next local address after this one that nothing listens or answers on, as setup.sh chooses.
Reserved ports are skipped without counting, so a reserved block does not end the search."""
parsed = urlsplit(url)
for port in range((parsed.port or 80) + 1, min((parsed.port or 80) + 1 + count, 65536)):
port = parsed.port or 80
while count > 0 and port < 65535:
port += 1
try:
bind_check(parsed.hostname, port)
except OSError:
except OSError as error:
if not reserved_port(error):
count -= 1
continue
count -= 1
candidate = address(parsed.hostname, port)
if server_info(loopback_url(candidate))['status'] == 'offline':
return candidate
Expand Down Expand Up @@ -1122,7 +1157,7 @@ def _move_address(self, selected):
"""Take the next free port for this ComfyUI and keep it in the selection the launcher saves."""
url = next_free_address(selected['url'])
if url is None:
raise ValueError('Other programs use this port and the next ones. Set a different local ComfyUI address.')
raise ValueError('This port and the next ones are in use or reserved by the system. Set a different local ComfyUI address.')
if self.port_move is None:
self.port_move = urlsplit(selected['url']).port or 80
selected = self.selection = dict(selected, url=url)
Expand Down Expand Up @@ -1197,7 +1232,7 @@ def _start_access_proxy(self):
port = (urlsplit(selected['url']).port or 80) + 1
candidate = next_free_address(address('0.0.0.0', port - 1)) if port <= 65535 else None
if candidate is None:
raise ValueError('The ports for LAN access are in use. Choose another ComfyUI port.')
raise ValueError('The ports for LAN access are in use or reserved by the system. Choose another ComfyUI port.')
port = urlsplit(candidate).port
self.selection = selected = dict(selected, lan_port=port)
self.state = dict(self.state, selection=dict(selected))
Expand Down
38 changes: 38 additions & 0 deletions freevideo_engine/failure_details.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,22 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep
return dict(title='这块硬盘没有连接' if zh else 'The disk is not connected', kind='disk-missing', detail='',
action=('请接上“%s”后点击“重新检查”' % disk_name + (',或者改装到其他硬盘。' if other_disk else '。') if zh else
'Connect "%s" and click Check again' % disk_name + (', or install on another disk.' if other_disk else '.')))
folder = 'Select the ComfyUI folder, or the portable folder containing ComfyUI.'
# A saved installation that vanished raises the same sentence after its own message; the
# user chose no folder then, so it keeps its own path.
if folder in summary and not re.search(r'Locate or repair this installation|重新定位或修复这份安装', summary):
# The retained error carries a traceback whose source lines quote the message
# (and its Found part) too; only the exception line itself names the folder.
named = list(re.finditer(re.escape(folder) + r'(?: Found one at "([^"\r\n]+)"; select that folder\.)?[ \t\r]*$',
summary, re.M))
found = named[-1].group(1) if named else None
if found:
action = ('请改选“%s”文件夹。' if zh else 'Select the "%s" folder instead.') % found
else:
action = ('请选择包含 main.py 的 ComfyUI 文件夹,或包含它的便携包文件夹。' if zh else
'Select the ComfyUI folder (it contains main.py), or the portable folder that contains it.')
return dict(title='这不是 ComfyUI 文件夹' if zh else "This isn't a ComfyUI folder", detail='',
action=action, kind='comfy-folder')
for pattern, kind, title, action in PLACE_RULES:
if re.search(pattern, summary):
return dict(title=title[zh], detail='', action=action[zh], kind=kind)
Expand Down Expand Up @@ -631,6 +647,25 @@ def _report(run):
return report if isinstance(report, dict) else {}


def _checkpoint_change(report):
"""The file-identity fields a streamed checkpoint check found changed, or ''.

Only the fixed names kept by the diagnostics: no paths or timestamp values.
"""
from .diagnostic_resources import checkpoint_change
encoding, attempts = report.get('encoding_failure'), report.get('resource_attempts')
found = [report, encoding.get('failure') if isinstance(encoding, dict) else None]
found += [row.get('failure') for row in attempts if isinstance(row, dict)] if isinstance(attempts, list) else []
for holder in found:
rows = holder.get('exception') if isinstance(holder, dict) else None
for row in rows if isinstance(rows, list) else [rows]:
if isinstance(row, dict) and row.get('category') == 'checkpoint_changed':
change = checkpoint_change(row.get('checkpoint_change'))
if change.get('source') and change['fields']:
return 'Checkpoint changed: %s (%s)' % (change['source'], ', '.join(change['fields']))
return ''


# Stands in for the repair sentence until the report text is redacted: the
# redactor reads "Install / repair" as a path.
REPAIR_MARK = 'FREEVIDEO_REPAIR_SENTENCE'
Expand Down Expand Up @@ -669,6 +704,9 @@ def generation_failure(run, exit_code=None, *, repair=None):
explained = _runtime_explanation(report, REPAIR_MARK)
if explained:
lines.append(explained)
changed = _checkpoint_change(report)
if changed:
lines.append(changed)
error = report.get('error_message') or report.get('error')
if isinstance(error, str):
lines.append(error[:8192])
Expand Down
10 changes: 7 additions & 3 deletions freevideo_engine/image_gen/failure.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
from ..failure_details import Redactor


ERRORS = frozenset(('no_memory', 'busy_video', 'busy', 'pack_missing', 'model_unreadable',
ERRORS = frozenset(('no_memory', 'small_memory', 'busy_video', 'busy', 'pack_missing', 'model_unreadable',
'device_unavailable', 'worker_exit', 'timeout', 'cancelled',
'invalid_request', 'invalid_media', 'invalid_points', 'segment_not_installed', 'generation_failed'))
NO_REPORT = frozenset(('busy', 'invalid_request', 'invalid_media', 'invalid_points', 'segment_not_installed', 'cancelled'))
NO_REPORT = frozenset(('small_memory', 'busy', 'invalid_request', 'invalid_media', 'invalid_points', 'segment_not_installed', 'cancelled'))
STAGES = ('start', 'load', 'text', 'encode', 'denoise', 'decode')
KINDS = ('generate', 'edit', 'cast', 'compose', 'download', 'install')
MESSAGE_ZH = {
'no_memory': '显存不够,最小尺寸也没能生成。关掉其他占用显卡的程序后再试。',
'small_memory': '这台 Mac 的内存不足 16 GB,无法生成图片。',
'busy_video': '视频正在生成,完成后再试。',
'busy': '已有一张图在生成,请等它完成。',
'pack_missing': '请先在启动器里下载“图像生成模型”。',
Expand All @@ -47,7 +48,10 @@
r'safetensors?|(?:deserializ|invalid|incomplete|too large).*header|'
r'(?:checkpoint|weights?).*(?:cannot|could not|unable|failed|corrupt|unreadable|mismatch)|'
r'pytorchstreamreader|unexpected (?:end of file|eof)', re.I)
_MPS_MEMORY = re.compile(r'MPS backend out of memory', re.I)
_LIMIT = 16 * 1024
# On a Mac the GPU's memory is the machine's: the same failure, in its words.
UNIFIED_MESSAGE_ZH = {'no_memory': '内存不够,最小尺寸也没能生成。关掉其他程序后再试。'}


class FailureError(ValueError):
Expand All @@ -70,7 +74,7 @@ def classify_exception(error, *, stage='start', cancelled=False):
error = error.__cause__ or (None if error.__suppress_context__ else error.__context__)
for item in chain:
names = {cls.__name__ for cls in type(item).__mro__}
if isinstance(item, MemoryError) or 'OutOfMemoryError' in names:
if isinstance(item, MemoryError) or 'OutOfMemoryError' in names or _MPS_MEMORY.search(str(item)):
return 'no_memory'
for item in chain:
cuda_init = any(frame.f_globals.get('__name__') == 'torch.cuda'
Expand Down
13 changes: 13 additions & 0 deletions freevideo_engine/image_gen/image_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,19 @@ def size(layer):
MARGIN_FRACTION = 0.06
MIN_HOST_WORKING = 4 * GiB # beyond the weights: images, tokenizer, the process itself

# Apple GPUs draw on the host's memory, where the weights are mapped file pages the
# system can drop. Planning against half of it kept a 24 GB M5 with a browser open
# out of heavy swapping; planning against what was free at the time let the second
# request keep 16 encoder layers resident and swap 4 GB. A Mac below 16 GB cannot
# plan even with every block streamed (4 GiB), and closing programs does not change it.
UNIFIED_SHARE = 0.5
UNIFIED_MIN_TOTAL = 15 * GiB # 16 GB Macs report exactly 16 GiB


def unified_budget(total_bytes):
"""What a request on an Apple GPU plans against, from the machine's total memory."""
return int(total_bytes * UNIFIED_SHARE)


@dataclass(frozen=True)
class ImagePlan:
Expand Down
33 changes: 25 additions & 8 deletions freevideo_engine/image_gen/image_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,12 @@ def leave(self, stage):
runner.dit_stack.set_resident(_kept(runner.last_plan))
self._trim()

@staticmethod
def _trim():
def _trim(self):
# Stage peaks are planned on allocated bytes; give the cached blocks back so the
# next stage's workspace is not fragmented by the previous one's.
if torch.cuda.is_available():
if self.runner.device.type == 'mps':
torch.mps.empty_cache()
elif torch.cuda.is_available():
torch.cuda.empty_cache()


Expand All @@ -117,6 +118,7 @@ class ImageRunner:
"""

full_card = False
mapped = False

def __init__(self, pack_dir, *, device='cuda', gpu_budget_bytes=None, log=None, progress=None):
from .qwen_pipeline import QwenImageGenerator
Expand All @@ -136,9 +138,12 @@ def path(directory, name):
loading = _LoadProgress(progress or (lambda stage, fraction: None))
started = time.monotonic()
# A card that holds every weight plus a request's working memory keeps no host copy.
self.full_card = self._fits_whole_model()
# An Apple GPU shares the host's memory: its layers stay views of the mapped files (mapped.py).
self.mapped = self.device.type == 'mps'
self.full_card = not self.mapped and self._fits_whole_model()
self.transformer = load_dit(self.pack_dir / _DIT_FILE, device=self.device if self.full_card else 'cpu',
progress=lambda done, total: loading.report('dit', done, total))
progress=lambda done, total: loading.report('dit', done, total),
**({'mapped': True} if self.mapped else {}))
self.load_seconds['dit'] = time.monotonic() - started
loading.complete('dit')
started = time.monotonic()
Expand All @@ -150,7 +155,8 @@ def path(directory, name):
config_dir = self.pack_dir / 'text_encoder'
processor_dir = self.pack_dir / 'processor'
text = load_text_encoder(self.pack_dir / _TE_FILE, config_path=config_dir / 'config.json', device='cpu',
progress=lambda done, total: loading.report('text', done, total))
progress=lambda done, total: loading.report('text', done, total),
**({'mapped': True} if self.mapped else {}))
from transformers import Qwen3VLProcessor
processor = Qwen3VLProcessor.from_pretrained(str(processor_dir), local_files_only=True)
self.load_seconds['text'] = time.monotonic() - started
Expand Down Expand Up @@ -183,7 +189,15 @@ def pin_progress(index):
return lambda done, total: loading.report(
'pin', offset + stack_bytes[index] * (done / total if total else 0.), pin_total)

if self.full_card:
if self.mapped:
from .mapped import MappedStack
self.dit_stack = MappedStack(stacks[0], name='dit', device=self.device)
self.te_stack = MappedStack(stacks[1], name='text_encoder', device=self.device)
from . import mlx_attention, qwen_dit
if mlx_attention.available() and not getattr(qwen_dit.attention, 'mlx', False):
qwen_dit.attention = mlx_attention.routed(qwen_dit.attention)
qwen_dit.attention.mlx = True
elif self.full_card:
# Move the encoder layers over one at a time so the host copy is freed as it goes.
for layer in stacks[1]:
layer.to(self.device)
Expand Down Expand Up @@ -246,6 +260,9 @@ def _to_streamed(self):
def budget(self):
if self.gpu_budget_bytes is not None:
return int(self.gpu_budget_bytes)
if self.device.type == 'mps':
from ..system import system_memory
return image_policy.unified_budget(system_memory()['total_bytes'])
free, _ = torch.cuda.mem_get_info(self.device)
return int(free + torch.cuda.memory_reserved(self.device))

Expand All @@ -267,7 +284,7 @@ def plan(self, width, height, reference_images=None):
memory = system_memory()
free = memory.get('physical_available_bytes') or memory['available_bytes']
host_available = free + self.dit_stack.host_bytes + self.te_stack.host_bytes
if self.full_card:
if self.full_card or self.mapped:
host_available = None
except Exception:
host_available = None
Expand Down
Loading