From 7f3c2ec7cba13423613d4ff520b533d940659d63 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 24 Oct 2026 23:30:11 +0000 Subject: [PATCH 01/10] Name the portable folder when the chosen folder is inside it, such as python_embeded --- freevideo_engine/comfy_launcher_runtime.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 5a9dbed4..fd1b6740 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -169,7 +169,7 @@ def nearby_comfy(selected): """ try: for parent in (selected, *list(selected.parents)[:3]): - if _is_comfy_root(parent): + if _is_comfy_root(parent) or _is_comfy_root(parent / 'ComfyUI'): 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'): From 6ee3826c8e5439ce9f5d8426374364b4950e8545 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 24 Oct 2026 23:30:11 +0000 Subject: [PATCH 02/10] Editor: name the cause of a failed save and accept an https page behind a TLS-terminating proxy --- freevideo_engine/access_proxy.py | 10 ++++++---- freevideo_engine/agent/access.py | 6 ++++-- freevideo_engine/comfy_editor.py | 8 +++++--- freevideo_engine/origin_scheme.py | 16 ++++++++++++++++ web/editor.js | 10 ++++++++-- 5 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 freevideo_engine/origin_scheme.py diff --git a/freevideo_engine/access_proxy.py b/freevideo_engine/access_proxy.py index 1040bf6f..1e645478 100644 --- a/freevideo_engine/access_proxy.py +++ b/freevideo_engine/access_proxy.py @@ -23,6 +23,7 @@ from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, WSMsgType, client_exceptions, web from yarl import URL from . import proxy_identity, lan_password +from .origin_scheme import effective_scheme from .lan_auth import PasswordAuth, DEVICE_COOKIE, REMEMBER_SECONDS, LOGIN_DELAY PHONE_WEB = Path(__file__).resolve().parents[1] / 'web' @@ -81,17 +82,18 @@ def same_origin(request): try: if len(request.headers.getall('Host', [])) != 1 or len(request.headers.getall('Origin', [])) != 1: return False - host = urlsplit(request.scheme + '://' + request.host) origin = urlsplit(request.headers['Origin']) + scheme = effective_scheme(request, origin.scheme) + host = urlsplit(scheme + '://' + request.host) socket = request.transport.get_extra_info('sockname') allowed = {socket[0].split('%')[0]} if ipaddress.ip_address(socket[0].split('%')[0]).is_loopback: allowed.update(('localhost', '127.0.0.1', '::1')) - own_port = host.port or (443 if request.scheme == 'https' else 80) + own_port = host.port or (443 if scheme == 'https' else 80) return (host.hostname in allowed and own_port == socket[1] and origin.hostname == host.hostname and (origin.port or (443 if origin.scheme == 'https' else 80)) == own_port - and origin.scheme == request.scheme and not any((origin.path, origin.query, origin.fragment, - origin.username, origin.password))) + and origin.scheme == scheme and not any((origin.path, origin.query, origin.fragment, + origin.username, origin.password))) except (ValueError, TypeError, IndexError, KeyError): return False diff --git a/freevideo_engine/agent/access.py b/freevideo_engine/agent/access.py index 2c5f9e02..a46a3d07 100644 --- a/freevideo_engine/agent/access.py +++ b/freevideo_engine/agent/access.py @@ -4,6 +4,7 @@ from .jobs import JobError from .. import proxy_identity +from ..origin_scheme import effective_scheme def peer_address(request): @@ -63,8 +64,9 @@ def check(request, *, local=False, lan=False): if origin is not None: parsed = urlsplit(origin) port = parsed.port or (443 if parsed.scheme == 'https' else 80) - own_port = host.port or (443 if scheme == 'https' else 80) - if (parsed.scheme != scheme or parsed.hostname != host.hostname or port != own_port + wanted = scheme if proxied else effective_scheme(request, parsed.scheme) + own_port = host.port or (443 if wanted == 'https' else 80) + if (parsed.scheme != wanted or parsed.hostname != host.hostname or port != own_port or parsed.path or parsed.query or parsed.fragment or parsed.username or parsed.password): raise ValueError except (ValueError, TypeError, IndexError): diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index 281fc84e..3c6b1995 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -23,6 +23,7 @@ from pathlib import Path, PurePosixPath, PureWindowsPath from urllib.parse import unquote, urlencode, urlsplit +from .origin_scheme import effective_scheme from .comfy_library import _ID, _plain, _video from .system import windows from .editor_frames import frame_not_after, last_frame_end @@ -1198,10 +1199,11 @@ def _local_page(request): try: parsed = urlsplit(origin) host = urlsplit('//' + request.host) - return (parsed.scheme in ('http', 'https') and parsed.scheme == request.scheme and not parsed.username + scheme = effective_scheme(request, parsed.scheme) + return (parsed.scheme in ('http', 'https') and parsed.scheme == scheme and not parsed.username and not parsed.password and not parsed.path and not parsed.query and not parsed.fragment and parsed.hostname == host.hostname - and (parsed.port or (443 if parsed.scheme == 'https' else 80)) == (host.port or (443 if request.scheme == 'https' else 80))) + and (parsed.port or (443 if parsed.scheme == 'https' else 80)) == (host.port or (443 if scheme == 'https' else 80))) except ValueError: return False @@ -1215,7 +1217,7 @@ def _same_origin_audio(request): if referer: try: page = urlsplit(referer) - origin = urlsplit(request.scheme + '://' + request.host) + origin = urlsplit(effective_scheme(request, page.scheme) + '://' + request.host) return (page.scheme == origin.scheme and page.hostname == origin.hostname and (page.port or (443 if page.scheme == 'https' else 80)) == (origin.port or (443 if origin.scheme == 'https' else 80)) diff --git a/freevideo_engine/origin_scheme.py b/freevideo_engine/origin_scheme.py new file mode 100644 index 00000000..6ec5f846 --- /dev/null +++ b/freevideo_engine/origin_scheme.py @@ -0,0 +1,16 @@ +"""The one rule for comparing a page's Origin protocol with the protocol the server saw.""" + + +def effective_scheme(request, origin_scheme): + """The protocol to compare an Origin with. + + A TLS-terminating proxy turns https into http before the request arrives. Only when the request carries a single + X-Forwarded-Proto equal to the Origin's own protocol is that difference accepted; host and port are still compared + by the caller, and no other forwarding header is read. + """ + headers = request.headers + forwarded = headers.getall('X-Forwarded-Proto', []) if hasattr(headers, 'getall') else [headers['X-Forwarded-Proto']] if 'X-Forwarded-Proto' in headers else [] + if (origin_scheme in ('http', 'https') and len(forwarded) == 1 + and forwarded[0].strip().lower() == origin_scheme): + return origin_scheme + return request.scheme diff --git a/web/editor.js b/web/editor.js index ddf8e52a..1c55f65b 100644 --- a/web/editor.js +++ b/web/editor.js @@ -91,9 +91,15 @@ function exportErrorText(value, t) { } // The save route's own messages are internal English, so only a full disk is named. function saveErrorText(value, t) { + if (isNetworkTypeError(value)) return connectionLostText(t); return value?.reason === 'disk_full' ? t('Saving changes failed: the disk is full. Free some space, then export again.', '保存改动时出错:磁盘空间不够,空出空间后再导出。') : t('Saving changes failed. Please export again.', '保存改动时出错,请再导出一次。'); } +// Names the failed save for the report: the server's reason, else the HTTP status, a lost connection, or no failure recorded yet. +function saveReason(failure) { + if (!failure) return 'unsaved'; + return failure.reason || (failure.status ? 'http_' + failure.status : isNetworkTypeError(failure) ? 'network' : undefined); +} function restoreGeneration(record, before, prepared) { const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); const entries = value => new Map(value.tracks.flatMap(track => track.clips.map(clip => [clip.id, {track: track.id, clip}]))); @@ -491,7 +497,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given await queue.flush(); } if (disposed || closing || epoch !== projectEpoch) return false; - if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE, failureKind('save', {reason: queue.failure?.reason})); return false; } + if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE, failureKind('save', {reason: saveReason(queue.failure)})); return false; } return !!state.record.id; } function updateTop() { @@ -708,7 +714,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const words = deleteEditText(t); if (jobActive()) throw {reason: 'generation_active'}; await queue.flush(); releaseStamp(); - if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE, failureKind('save', {reason: queue.failure?.reason})); return; } + if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE, failureKind('save', {reason: saveReason(queue.failure)})); return; } // Load the replacement before deleting, so a read failure keeps the edit open. const rows = (await store.projects()).filter(row => row.id !== state.record.id); // Most recently used: the order the edits were opened, then the last save. From e559b3de6e29b47f5c819697149c7dfe98aacdf9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 24 Oct 2026 23:30:11 +0000 Subject: [PATCH 03/10] Page check: say why an open creative workspace cannot be seen in the report details --- web/health.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/web/health.js b/web/health.js index 55ba9eff..e206293e 100644 --- a/web/health.js +++ b/web/health.js @@ -344,6 +344,15 @@ function onScreen(element) { // canvas: inside the window, sized, and invisible. Look at what is on top of // its view switch; a dialog, menu, toast or this card only passes over it. const OVERLAYS = 'dialog, [role="dialog"], [role="menu"], .p-dialog-mask, .p-overlay-mask, .p-toast, .litecontextmenu, .fv-health'; +// Why a dialog that is open does not count as on screen, for the details of the report: which test it fails +// and the box and window it was measured against (no page content). +function whyOffScreen(element) { + const style = getComputedStyle(element), box = element.getBoundingClientRect(); + const reason = !element.isConnected ? 'not in the page' : element.hidden ? 'hidden attribute' + : style.display === 'none' ? 'display: none' : style.visibility === 'hidden' ? 'visibility: hidden' + : Number(style.opacity) === 0 ? 'opacity: 0' : !(box.width > 0 && box.height > 0) ? 'no size' : 'outside the window'; + return `${reason}; box ${Math.round(box.left)},${Math.round(box.top)} ${Math.round(box.width)}x${Math.round(box.height)}; window ${innerWidth}x${innerHeight}`; +} // A layer hides the toolbar only if it paints over it. ComfyUI's dimming // backdrops and click-away layers (a dialog's shade, the help panel's) leave it // visible underneath, so they are not a fault. @@ -397,7 +406,8 @@ function workspaceProblem(app) { // The editor shares the studio shell; either view replaces the floating toolbar. const studio = document.querySelector('dialog.fv-editor[open]') || document.querySelector('dialog.fv-studio[open]'); if (studio) return onScreen(studio) ? null - : {text: t('The creative workspace is open but cannot be seen on the page.', '创作面板已经打开,但页面上看不到。'), advice: 'reload'}; + : {text: t('The creative workspace is open but cannot be seen on the page.', '创作面板已经打开,但页面上看不到。'), advice: 'reload', + state: `${nameOf(studio)}: ${whyOffScreen(studio)}`}; const bar = document.querySelector('.fv-view-navigation'); if (!bar) return {text: t('The FreeVideo toolbar that opens the creative workspace is missing from the page.', '页面上没有打开创作面板的 FreeVideo 工具栏。'), advice: 'reload'}; const cover = onScreen(bar) ? coveredBy(bar) : null; @@ -477,7 +487,7 @@ function details(problems) { `ComfyUI frontend: ${window.__COMFYUI_FRONTEND_VERSION__ || 'unknown'}`, `FreeVideo UI: ${JSON.stringify(window.FreeVideoUI || {})}`, `Automatic reload: ${retried ? new Date(Number(retryRecord.at)).toISOString() : retryRecord ? 'not needed' : 'unavailable (no session storage)'}`, - '', 'Problems:', ...problems.flatMap(p => [`- ${p.text}`, ...(p.cover ? [` covered by: ${p.cover}`] : [])]), + '', 'Problems:', ...problems.flatMap(p => [`- ${p.text}`, ...(p.cover ? [` covered by: ${p.cover}`] : []), ...(p.state ? [` state: ${p.state}`] : [])]), ...(checks.length ? ['', 'Load check:', ...checks.map(row => `- ${row}`)] : []), ...(cache.length ? ['', 'Browser cache:', ...cache.map(row => `- ${row}`)] : []), ...(before.length ? ['', 'Browser cache before the automatic reload:', ...before.map(row => `- ${row}`)] : []), From d25e2288acd504a7fe9d3008b575ab88e6f78145 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 24 Oct 2026 23:30:11 +0000 Subject: [PATCH 04/10] Record the RAM FreeVideo itself holds in resource refusals and feedback --- freevideo_engine/diagnostic_resources.py | 4 ++ freevideo_engine/exit_diagnostics.py | 52 ++++++++++++++++++++++++ freevideo_engine/feedback.py | 5 +++ freevideo_engine/generate.py | 9 ++++ freevideo_engine/policy.py | 16 ++++++++ 5 files changed, 86 insertions(+) diff --git a/freevideo_engine/diagnostic_resources.py b/freevideo_engine/diagnostic_resources.py index a4614597..c53a7657 100644 --- a/freevideo_engine/diagnostic_resources.py +++ b/freevideo_engine/diagnostic_resources.py @@ -751,6 +751,10 @@ def planning(rows): item[key] = numbers(row.get(key), HARDWARE_MEMORY) item['system_memory'] = numbers(row.get('system_memory'), ('total_bytes', 'available_bytes', 'physical_available_bytes', 'commit_available_bytes')) + from .exit_diagnostics import OWN_MEMORY_KEYS + own = numbers(row.get('own_memory'), OWN_MEMORY_KEYS) + if own: + item['own_memory'] = own for key in ('budget_bytes', 'reserve_bytes'): item[key] = numbers(row.get(key), ('gpu', 'ram')) item['resource_error'] = numbers(row.get('resource_error'), tuple(resource + '_' + field diff --git a/freevideo_engine/exit_diagnostics.py b/freevideo_engine/exit_diagnostics.py index a7be152e..644582b6 100644 --- a/freevideo_engine/exit_diagnostics.py +++ b/freevideo_engine/exit_diagnostics.py @@ -19,6 +19,8 @@ } _CRASH_PREFIXES = (0xC0000000, 0x80000000) _FIELDS = ('physical_available_bytes', 'commit_available_bytes', 'commit_limit_bytes', 'total_bytes') +OWN_MEMORY_KEYS = ('own_private_working_set_bytes', 'own_processes', 'own_unreadable_processes', + 'host_private_working_set_bytes', 'resident_private_working_set_bytes') def _count(value): @@ -58,6 +60,56 @@ def memory_sample(pid=None): return {key: sample[key] for key in sample if sample[key] is not None} +def own_memory(pid=None): + """Physical RAM held by this process tree and by the largest process above it (Windows). + + A refusal says how much memory the system had left; this says how much of + what was taken is FreeVideo's. The numbers are private working sets, which + exclude mapped files, so they can be compared with free physical RAM. The + tree is the generation process and its children; the resident worker is + started by the host and is added by the caller from its own status. The + host figure is the largest of up to four ancestors (a launcher gate is + small, the program that started it is not). Processes that cannot be read + are counted, never taken as zero; if the process itself cannot be read + nothing is returned. Other platforms give nothing; the function never raises. + """ + if not windows(): + return {} + pid = os.getpid() if pid is None else pid + result = {} + try: + from .win32 import ProcessMemory, processes, process_memory + reading = ProcessMemory().sample(pid) + unreadable = reading.get('unreadable_memory_pids') or [] + if not reading.get('processes') or pid in unreadable: + return {} + result.update(own_private_working_set_bytes=reading['private_working_set_bytes'], + own_processes=reading['processes']) + if unreadable: + result['own_unreadable_processes'] = len(unreadable) + except Exception: + return {} + try: + parents = processes() + seen, node, largest = {pid}, parents.get(pid), None + for _ in range(4): + if not node or node in seen: + break + seen.add(node) + try: + row = process_memory(node) + except Exception: + row = None + if row and _count(row.get('private_working_set_bytes')) is not None: + largest = max(largest or 0, row['private_working_set_bytes']) + node = parents.get(node) + if largest is not None: + result['host_private_working_set_bytes'] = largest + except Exception: + pass + return {key: value for key, value in result.items() if _count(value) is not None} + + def exit_reading(code, *, requested_stop=False, system=None): """The exit code as given, a fixed-word class and whether the system probably ended it. diff --git a/freevideo_engine/feedback.py b/freevideo_engine/feedback.py index d34d065d..6acb6b68 100644 --- a/freevideo_engine/feedback.py +++ b/freevideo_engine/feedback.py @@ -6,6 +6,8 @@ import json import re +from .exit_diagnostics import OWN_MEMORY_KEYS + # The summary travels in a GitHub page address, which has to stay short. MAX_DIAGNOSTICS = 3000 # Anything that looks like a file location or a long secret-like token is replaced. @@ -45,6 +47,9 @@ def summary(report, machine=None): memory = planning[-1].get('system_memory') if isinstance(planning, list) and planning and isinstance(planning[-1], dict) else None if isinstance(memory, dict): memory = {k: memory[k] for k in MEMORY_KEYS if type(memory.get(k)) is int} + own = planning[-1].get('own_memory') + if isinstance(own, dict): + memory.update({k: own[k] for k in OWN_MEMORY_KEYS if type(own.get(k)) is int}) if memory: out['memory'] = memory errors = analysis.get('errors') diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index ff599ceb..3355497f 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -287,6 +287,15 @@ def automatic_profile(args, canvas, *, stage, evidence, descriptor=None, environ mapped_bytes=mapped if type(mapped) is int else None, commit_available_bytes=commit if type(commit) is int else None) except ResourceBudgetError as error: + from .exit_diagnostics import own_memory + own = dict(own_memory()) + resident = ((state or {}).get('memory') or {}) + if (resident.get('guard_metric') == 'tree private working set' + and type(resident.get('guard_bytes')) is int and resident['guard_bytes'] >= 0): + own['resident_private_working_set_bytes'] = resident['guard_bytes'] + if own: + error.add_own_memory(own) + row['own_memory'] = own row.update(status='rejected', resource_error=error.details) error.resource_planning = evidence impossible_capacity = any(error.details[name+'_capacity_bytes'] - error.details[name+'_reserve_bytes'] diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 01d4b231..d7ea6248 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -622,6 +622,22 @@ def __init__(self, hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_g lines.append('Available memory includes validated idle-cache credit, if present. Resolution, frames and steps were not reduced.') super().__init__('\n'.join(lines)) + def add_own_memory(self, own): + """Say how much of the physical RAM already taken is FreeVideo's own.""" + self.details.update(own) + parts = [] + if 'own_private_working_set_bytes' in own: + partial = own.get('own_unreadable_processes', 0) + parts.append('FreeVideo\'s generation processes hold %s%.2f GiB of private physical RAM (%d processes%s)' % + ('at least ' if partial else '', own['own_private_working_set_bytes'] / GiB, + own.get('own_processes', 0), ', %d unreadable' % partial if partial else '')) + if 'resident_private_working_set_bytes' in own: + parts.append('FreeVideo\'s resident worker holds %.2f GiB' % (own['resident_private_working_set_bytes'] / GiB)) + if 'host_private_working_set_bytes' in own: + parts.append('the largest process above the generation process holds %.2f GiB' % (own['host_private_working_set_bytes'] / GiB)) + if parts: + parts[0] = parts[0][0].upper() + parts[0][1:] + self.args = ('%s\n%s.' % (self.args[0], '; '.join(parts)),) def feasible_alternatives(hardware, canvas, **options): From 5cbeb08f4181035305d19f96654e1d331cfdb32c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 24 Oct 2026 07:16:40 +0000 Subject: [PATCH 05/10] Mac setup fetches its own Git from conda-forge when the system has none --- freevideo_engine/bootstrap.py | 24 +++++- freevideo_engine/failure_details.py | 4 + freevideo_engine/git_macos.json | 126 ++++++++++++++++++++++++++++ freevideo_engine/macos_bootstrap.py | 5 +- freevideo_engine/macos_setup.py | 18 +++- freevideo_engine/network.py | 13 ++- pyproject.toml | 2 +- 7 files changed, 180 insertions(+), 12 deletions(-) create mode 100644 freevideo_engine/git_macos.json diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index b2637fef..b3975267 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -5,6 +5,7 @@ from contextlib import ExitStack, contextmanager import hashlib import json +import shlex import logging import os from pathlib import Path @@ -798,12 +799,16 @@ def plan(args, *, local_progress=None, stage=None): if reuse_cache and not cache_compatible(reuse_cache, **cache_format): errors.append('--cache must contain a prepared FP8 cache compatible with this GPU scale format.') from .download_settings import read as download_preferences + def native_git_available(): + from .macos_bootstrap import available_git + return available_git() is not None if stage: stage('Testing download sources') networking = network.plan(json.loads((PACKAGE / 'dependencies.json').read_text(encoding='utf-8')), bootstrap_versions(json.loads((PACKAGE / 'bootstrap_versions.json').read_text(encoding='utf-8')), system), layout, mode=getattr(args, 'network', 'auto'), timeout=getattr(args, 'network_timeout', 5), offline=bool(args.hardware_json or errors), + git_available=not mac_target or bool(args.hardware_json) or native_git_available(), env=dict(os.environ, FREEVIDEO_HOME=str(root)), proxy_mode=download_preferences(root / 'download-settings.json')['proxy_mode'], progress=(lambda message: stage('Testing download sources', message['done'], message['total'])) if stage else None) @@ -1916,19 +1921,22 @@ def install_git(self): def install_git_linux(self): """Git from conda-forge, pinned by package hashes, when the system has none new enough.""" - spec = json.loads((PACKAGE / 'git_linux.json').read_text(encoding='utf-8')) + self.install_git_conda('git_linux.json', 'git-linux', self.fetch_git_linux) + + def install_git_conda(self, filename, folder, fetch, wrapper=False): + spec = json.loads((PACKAGE / filename).read_text(encoding='utf-8')) prefix = self.root / 'tools' / ('git-' + spec['version']) executable = prefix / 'bin' / 'git' receipt, content = prefix / '.freevideo-git.json', {k: spec[k] for k in ('version', 'packages')} if not (receipt.is_file() and json.loads(receipt.read_text(encoding='utf-8')) == content and executable.is_file()): - micromamba, packages = self.fetch_git_linux(spec) + micromamba, packages = fetch(spec) # conda writes the prefix into the installed files, so the # environment is created in place; an interrupted one is replaced. if prefix.is_symlink(): prefix.unlink() elif prefix.exists(): shutil.rmtree(prefix) - explicit = self.root / 'downloads' / 'git-linux' / 'explicit.txt' + explicit = self.root / 'downloads' / folder / 'explicit.txt' explicit.write_text('@EXPLICIT\n' + ''.join(path.resolve().as_uri() + '\n' for path in packages), encoding='utf-8') cache = self.root / 'tools' / 'micromamba-root' env = {k: v for k, v in self.env.items() if not k.upper().startswith(('CONDA', 'MAMBA'))} @@ -1943,7 +1951,15 @@ def install_git_linux(self): # the other tools of the environment. links = self.root / 'tools' / 'git-bin' links.mkdir(parents=True, exist_ok=True) - if not (links / 'git').is_symlink() or Path(os.readlink(links / 'git')) != executable: + if wrapper: + # macOS resolves @loader_path from the path it was started by, so a script + # starts the real executable instead of a link to it. + text = '#!/bin/sh\nexec %s "$@"\n' % shlex.quote(str(executable)) + if not (links / 'git').is_file() or (links / 'git').is_symlink() or (links / 'git').read_text(encoding='utf-8') != text: + (links / 'git').unlink(missing_ok=True) + (links / 'git').write_text(text, encoding='utf-8') + (links / 'git').chmod(0o755) + elif not (links / 'git').is_symlink() or Path(os.readlink(links / 'git')) != executable: (links / 'git').unlink(missing_ok=True) (links / 'git').symlink_to(executable) self.env['PATH'] = str(links) + os.pathsep + self.env['PATH'] diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index b7826938..6797aeee 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -221,6 +221,10 @@ def environment_python_removed(args, pythons): # Checked before free space: freeing space cannot fix a disk that cannot hold the installation. PLACE_RULES = ( + (r'Git is unavailable\. Check the network connection', 'git-unavailable', + ('Git could not be set up', '无法准备 Git'), + ('Check the network connection and click Check again, or install the Apple Command Line Tools.', + '请检查网络连接后点击“重新检查”,或者安装 Apple 命令行工具。')), (r'The installation folder is on a FAT32 disk', 'disk-format', ('This disk cannot hold the models', '这块硬盘放不下模型'), ('It is formatted as FAT32, which stores files up to 4 GB; the prompt model alone is 15.7 GB. ' diff --git a/freevideo_engine/git_macos.json b/freevideo_engine/git_macos.json new file mode 100644 index 00000000..656aa09d --- /dev/null +++ b/freevideo_engine/git_macos.json @@ -0,0 +1,126 @@ +{ + "version": "2.56.0", + "micromamba": { + "version": "2.9.0-0", + "url": "https://github.com/mamba-org/micromamba-releases/releases/download/2.9.0-0/micromamba-osx-arm64", + "sha256": "ec2a072f028e1a7cf20f3e2e74d5a8127cf5a5f27636375b5359811565f4e5be", + "bytes": 14596096 + }, + "channels": { + "official": "https://conda.anaconda.org/conda-forge", + "tuna": "https://mirrors.tuna.tsinghua.edu.cn/anaconda/cloud/conda-forge", + "nju": "https://mirror.nju.edu.cn/anaconda/cloud/conda-forge" + }, + "packages": [ + { + "path": "osx-arm64/libexpat-2.8.5-h6111c0a_0.conda", + "sha256": "50aa761b83fc7a116ca88fe0cb4cefce5abababb10a926bf8afbafd5c298d102", + "bytes": 69565 + }, + { + "path": "osx-arm64/perl-5.44.0-1_hb001647_perl5.conda", + "sha256": "d68a8d9a8f329e46920d7750b495f613e093fa40736d00a227b727f5898804bb", + "bytes": 10327598 + }, + { + "path": "osx-arm64/libiconv-1.18-he4c29f2_3.conda", + "sha256": "689a14968267f2f97c07112fca5636e7d756036b9aee969911267c20bd3eea1f", + "bytes": 750816 + }, + { + "path": "osx-arm64/libzlib-1.3.2-h8088a28_3.conda", + "sha256": "a18fa5d5bac452401459f966cf0d872224e8080c4ff93c77e168d43ab42ef9d7", + "bytes": 47822 + }, + { + "path": "osx-arm64/bzip2-1.0.8-h4e30115_10.conda", + "sha256": "8ec22f0ba25cbfc2e64d70cf29459eccd7ffdf6436f6a6ff15bbfef799f7d4f6", + "bytes": 124965 + }, + { + "path": "osx-arm64/libcxx-23.1.4-h55c6f16_2.conda", + "sha256": "9c390b4f403db32346754ccef6b84b7e5d9b70cb6d76258fd23b05f2db2ab216", + "bytes": 577255 + }, + { + "path": "osx-arm64/libev-4.33-h1a92334_3.conda", + "sha256": "c0100064506ae8abb432c5a506d474f10af2cf48c33d62bc221fb28b6d6ff6ac", + "bytes": 39991 + }, + { + "path": "osx-arm64/c-ares-1.34.9-h74c22ad_0.conda", + "sha256": "74b52d784b3576643da3aea1240a4120c1f25ee057f14ae5a89e8a7b3fb2e69f", + "bytes": 201979 + }, + { + "path": "osx-arm64/ncurses-6.6-he64c551_1.conda", + "sha256": "7024a48c8c0d0114ed4ab53c76bf9275d50e91ba7cea367a9aead638d3c29c68", + "bytes": 804298 + }, + { + "path": "osx-arm64/icu-78.3-py310h579977c_2.conda", + "sha256": "6cdb5dee54c72e56ab189fb3ad33cb28533553d42590e7e831160248f4416a43", + "bytes": 14070242 + }, + { + "path": "osx-arm64/libintl-0.25.1-hae62183_2.conda", + "sha256": "b43c6645e668f86d04d5c06d6dc17efe921bbd9244be4b77946d56fb950a3fcc", + "bytes": 90024 + }, + { + "path": "osx-arm64/zstd-1.5.7-hf451053_7.conda", + "sha256": "da867f5092eb0cb746d353694f0098031fd9817a4ce7d5743121209ae0f406ca", + "bytes": 433687 + }, + { + "path": "osx-arm64/pcre2-10.47-he63d830_1.conda", + "sha256": "f8c415329b542e1fca1ff2917b80e687c18302dfa0353530668b90cb225b494f", + "bytes": 851704 + }, + { + "path": "osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda", + "sha256": "5692a969e49b200c7874d1e869d281753361980b2ecfe9191b571d7e550f8b92", + "bytes": 108512 + }, + { + "path": "osx-arm64/libpsl-0.23.1-hdb0c161_1.conda", + "sha256": "c8c3153df39abedf3413483f672151ad70f1adb0c06c192bdf7ec432c3616b07", + "bytes": 72673 + }, + { + "path": "noarch/ca-certificates-2026.10.23-hbd8a1cb_0.conda", + "sha256": "95619b9c1184be7175392eeb0ee7eeb5ccf25d280951f4225f2d62e725790be3", + "bytes": 130057 + }, + { + "path": "osx-arm64/openssl-3.6.5-h55eecbc_0.conda", + "sha256": "44fbc526456571a9fb8fa18157f6f269d4d4bd40eef20183a93b9c9296f94497", + "bytes": 3111573 + }, + { + "path": "osx-arm64/libssh2-1.11.1-hbf2880b_1.conda", + "sha256": "a056e518c3d2d09fbb1778cea80c0c95338fb24adf1a817bbf90fb484850a304", + "bytes": 280492 + }, + { + "path": "osx-arm64/krb5-1.22.2-h34f8a20_2.conda", + "sha256": "aaea1d42b07769920db2af7471ece9399d2613448863da64ad8272998db5db34", + "bytes": 1165740 + }, + { + "path": "osx-arm64/libnghttp2-1.68.1-h435687b_1.conda", + "sha256": "445440d8cccccb6585f71d1eb7c949d64a2af2ced5481d924621424ff3b6a398", + "bytes": 567024 + }, + { + "path": "osx-arm64/libcurl-8.23.0-h6651222_0.conda", + "sha256": "d9f60525b665e50da15c84fcb4e74e6b1f0a56ea4c89a02d4258f23c3fe1f02c", + "bytes": 425637 + }, + { + "path": "osx-arm64/git-2.56.0-pl5440h66f49b9_0.conda", + "sha256": "2faaa571f7da2dd0b17f54ddb8a0437c9bde290af2202257c75ba73889819975", + "bytes": 13480930 + } + ] +} diff --git a/freevideo_engine/macos_bootstrap.py b/freevideo_engine/macos_bootstrap.py index f9987bbd..2f1b0ef1 100644 --- a/freevideo_engine/macos_bootstrap.py +++ b/freevideo_engine/macos_bootstrap.py @@ -38,6 +38,9 @@ def versions(value): uv=dict(UV, url=UV['sources']['official']), github_probe_url=value['uv']['url']) +GIT_UNAVAILABLE = 'Git is unavailable. Check the network connection, or install Git (Apple Command Line Tools), then retry.' + + def available_git(): executable = shutil.which('git') if not executable: @@ -86,8 +89,6 @@ def preflight(args, snapshot=None, *, ram_gib=None, vram_gib=None): errors.append('CUDA device and SageAttention build options do not apply to Mac.') if vram_gib is not None: errors.append('Mac has one unified memory pool; use --ram-gib instead of a separate VRAM limit.') - if not snapshot.get('git'): - errors.append('Git is unavailable. Install or repair Git (Apple Command Line Tools), then retry.') total, available = hardware['ram_total'], hardware['ram_available'] if any(type(n) is not int or n < 0 for n in (total, available)) or not 0 < total or available > total: raise ValueError('Invalid unified-memory observation') diff --git a/freevideo_engine/macos_setup.py b/freevideo_engine/macos_setup.py index 9f6b4c55..cb010837 100644 --- a/freevideo_engine/macos_setup.py +++ b/freevideo_engine/macos_setup.py @@ -20,6 +20,19 @@ def device_configuration(self): def prepare_tools(self): return native.prepare_uv(self.root, self.network, self.env, self.fetch) + def install_git(self): + """The system Git when it runs; otherwise Git from conda-forge in FreeVideo's own folder.""" + git = native.available_git() + if git: + return + try: + self.install_git_conda('git_macos.json', 'git-macos', + lambda spec: self.fetch_conda_lock(spec, 'git-macos', 'Git'), wrapper=True) + except Exception as error: + if self.cancel.is_set(): + raise + raise RuntimeError(native.GIT_UNAVAILABLE) from error + def component_tasks(self, uv): tasks = {} def task(name, run, after=(), writes=()): @@ -27,6 +40,7 @@ def task(name, run, after=(), writes=()): python = self.pythons['engine'] writer = ('environment:unified',) task('python', lambda: self.python_install(uv)) + task('git', self.install_git) task('unified-venv', lambda: None if python.is_file() else self.command('unified-venv', [uv, 'venv', '--python', native.PYTHON_VERSION, self.root / 'envs/unified']), ('python',), writer) @@ -36,12 +50,12 @@ def task(name, run, after=(), writes=()): task('engine', lambda: self.packages('unified-engine', [uv, 'pip', 'install', '--python', python, '--no-deps', '-e', SOURCE]), ('runtime',), writer) task('vdn-source', lambda: self.command('vdn-source', [python, '-c', - 'from freevideo_engine.install import install; install()']), ('unified-venv',)) + 'from freevideo_engine.install import install; install()']), ('unified-venv', 'git')) task('vdn-install', lambda: self.packages('vdn', [uv, 'pip', 'install', '--python', python, '--no-deps', '-e', self.root / 'vendor/vdn' / self.spec['vdn']['diffusers_subdirectory']]), ('engine', 'vdn-source'), writer) task('encoder-source', lambda: self.clone('h3-text-encoder', - self.spec['encoder']['comfy_url'], self.spec['encoder']['comfy_commit'])) + self.spec['encoder']['comfy_url'], self.spec['encoder']['comfy_commit']), ('git',)) task('models', lambda: self.command('models', [python, '-m', 'freevideo_engine.provision', '--plan', self.run_dir / 'plan.json']), ('engine',)) return tasks, {'unified': python}, self.root / 'vendor/h3-text-encoder' diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index ea3000a0..4b69f818 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -395,14 +395,14 @@ def probe_score(row, family): def plan(spec, versions, layout='unified', *, mode='auto', timeout=5, offline=False, env=None, - model_only=False, measure_speed=True, progress=None, proxy_mode='auto'): + model_only=False, measure_speed=True, progress=None, proxy_mode='auto', git_available=True): from .environments import ENVIRONMENTS, environment_names env = proxy_environment(env) started = time.monotonic() if proxy_mode not in proxy.MODES: raise ValueError('Unknown download connection mode') proxy_configured = proxy.configured(env) - git_keys = [] if offline or model_only else proxy.git_proxy_keys(env) + git_keys = [] if offline or model_only or not git_available else proxy.git_proxy_keys(env) families = ['pypi', 'models', 'vdn-models', 'image-models', 'github', 'git', 'cuda'] if spec['models'].get('edge_modelscope'): families.insert(3, 'edge-models') @@ -422,8 +422,15 @@ def plan(spec, versions, layout='unified', *, mode='auto', timeout=5, offline=Fa if mode != 'official' and configured_source(group, env): names.insert(0, 'user') selections[family] = [] - routes = proxy.routes(env, git=family == 'git', mode=proxy_mode) + routes = proxy.routes(env, git=family == 'git' and git_available, mode=proxy_mode) jobs += [(family, name, route) for name in names for route in routes] + # Without a usable Git, any `git` on PATH may be Apple's tool launcher, + # which opens an installation dialog when run. Leave those sources unmeasured. + skipped = [job for job in jobs if job[0] == 'git' and not git_available and not offline] + if skipped: + jobs = [job for job in jobs if job not in skipped] + for family, name, route in skipped: + selections[family].append({'id': name, 'route': route, 'ok': None, 'score': 0, 'scope': 'Git not available yet; not measured'}) if offline: for family, name, route in jobs: selections[family].append({'id': name, 'route': route, 'ok': None, 'scope': 'Offline fixture; not measured'}) diff --git a/pyproject.toml b/pyproject.toml index 2e898f61..0fbe352f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,4 +36,4 @@ include = ["freevideo_engine*"] [tool.setuptools.package-data] "freevideo_engine.prompt_vlm" = ["model.json"] "freevideo_engine.image_gen" = ["image_models.json", "licenses/NOTICE.txt"] -freevideo_engine = ["stage_estimate_params.json", "build-version.txt", "build-identity.json", "release_notes.json", "dependencies.json", "model_files.json", "model_shares.json", "prepared_models.json", "macos_reference_catalog.json", "superseded_tables.json", "bootstrap_versions.json", "curl_windows.json", "git_linux.json", "gcc_linux.json", "uv_downloads.json", "torch_downloads.json", "test_prompts.json", "windows_bootstrap.ps1", "assets/*.png", "assets/*.ico", "launcher/*.qml", "launcher/licenses/*.txt", "licenses/*.txt", "licenses/runtime/*.txt", "subtitle_pack.json", "frame_preview_weights.json"] +freevideo_engine = ["stage_estimate_params.json", "build-version.txt", "build-identity.json", "release_notes.json", "dependencies.json", "model_files.json", "model_shares.json", "prepared_models.json", "macos_reference_catalog.json", "superseded_tables.json", "bootstrap_versions.json", "curl_windows.json", "git_linux.json", "git_macos.json", "gcc_linux.json", "uv_downloads.json", "torch_downloads.json", "test_prompts.json", "windows_bootstrap.ps1", "assets/*.png", "assets/*.ico", "launcher/*.qml", "launcher/licenses/*.txt", "licenses/*.txt", "licenses/runtime/*.txt", "subtitle_pack.json", "frame_preview_weights.json"] From 318f29a3a18de60cb439311636cacbe776f4edb8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 23 Oct 2026 13:25:37 +0000 Subject: [PATCH 06/10] Remove incomplete Git checkouts after a failed download --- freevideo_engine/comfy_source.py | 3 +-- freevideo_engine/network.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/freevideo_engine/comfy_source.py b/freevideo_engine/comfy_source.py index f57fb014..74f95587 100644 --- a/freevideo_engine/comfy_source.py +++ b/freevideo_engine/comfy_source.py @@ -52,8 +52,7 @@ def download(target, *, plan, env, run): if target.exists(): return network.clone(target, spec['url'], spec['commit'], network=plan, env=env, run=run) stage = target.with_name(target.name + '.freevideo-download') - # A finished checkout survives cancellation before publication. Git fetch's - # own incomplete attempts are retained beside this directory by network.clone. + # A finished checkout survives cancellation before publication. network.clone(stage, spec['url'], spec['commit'], network=plan, env=env, run=run) if not all((stage / name).is_file() for name in ('main.py', 'folder_paths.py', 'requirements.txt')) or not (stage / 'comfy_api/latest').is_dir(): raise ValueError('The downloaded ComfyUI application is incomplete; files retained at ' + str(stage)) diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 4b69f818..b17120c7 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -1399,6 +1399,12 @@ def execute(label, command): io_path(target.parent).mkdir(parents=True, exist_ok=True) candidates = urls(dict(network, sources=dict(network.get('sources', {}), github=network.get('sources', {}).get('git', [{'id': 'official'}]))), 'github', url, env) configured_env = env + # A depth-1 fetch cannot continue an earlier attempt, so what a past run left beside the target is only waste. + for stale in io_path(target.parent).glob(target.name + '.partial-*'): + try: + remove_tree(stale) + except OSError: + pass # Held by another program; the download goes on and a later run tries again. for name, endpoint, route in [(name, endpoint, route) for name, endpoint in candidates for route in route_order(network, 'git', name, configured_env)]: env = route_environment(network, 'git', name, configured_env, route) @@ -1446,6 +1452,12 @@ def execute(label, command): source_health(network, 'git', name, False) event(network, category='git', source=name, route=route, file=target.name, action='fallback', reason='network-fetch-failed') finally: - if scratch and scratch.exists(): - remove_tree(scratch) - raise RuntimeError('All Git sources failed. Check your network or connection mode in Downloads. Incomplete checkouts retained beside ' + str(target)) + # Best effort: a file still held by antivirus or git.exe must not replace the + # error being handled or stop the fallback to the next source. + for leftover in (scratch, io_path(temporary)): + try: + if leftover and leftover.exists(): + remove_tree(leftover) + except OSError: + pass + raise RuntimeError('All Git sources failed. Check your network or connection mode in Downloads.') From de26d30eed550e6adf71bdfb35219d39be2b7d03 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 24 Oct 2026 07:32:40 +0000 Subject: [PATCH 07/10] The uv download is put in place with a few retries when Windows denies the replacement --- freevideo_engine/uv_bootstrap.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/freevideo_engine/uv_bootstrap.py b/freevideo_engine/uv_bootstrap.py index 1ea42338..6fc7c320 100644 --- a/freevideo_engine/uv_bootstrap.py +++ b/freevideo_engine/uv_bootstrap.py @@ -4,6 +4,7 @@ from pathlib import Path import re import shutil +import time import zipfile from . import network @@ -27,6 +28,30 @@ def verified(path, sha, size): return path.is_file() and path.stat().st_size == size and network.hash_file(path) == sha +def publish(temporary, destination, delays=(0.25, 0.5, 1, 2, 2, 2, 2, 2)): + """Replace the tool with the extracted copy. Windows refuses the replacement while a scanner or a running + tool holds the file, so it is retried for a few seconds; an identical tool already in place is kept.""" + error = None + for delay in (*delays, None): + try: + os.replace(temporary, destination) + return destination + except PermissionError as denied: + error = denied + if delay is None: + break + time.sleep(delay) + try: + same = destination.is_file() and destination.stat().st_size == temporary.stat().st_size \ + and network.hash_file(destination) == network.hash_file(temporary) + except OSError: + same = False + if not same: + raise error + temporary.unlink(missing_ok=True) + return destination + + def extract(archive, destination, member, size=None): """Only extract the pinned executable, with bounded memory and atomic replacement.""" destination.parent.mkdir(parents=True, exist_ok=True) @@ -37,8 +62,7 @@ def extract(archive, destination, member, size=None): raise ValueError('Unexpected uv executable size in the verified archive') with source.open(info) as incoming, temporary.open('wb') as outgoing: shutil.copyfileobj(incoming, outgoing, 1024 * 1024) - os.replace(temporary, destination) - return destination + return publish(temporary, destination) def prepare_windows(root, uv, networking, env, fetch): From 38ea40d48a2d349cb234c19c1daeed36b40d06fc Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Sat, 17 Oct 2026 16:16:09 +0000 Subject: [PATCH 08/10] Add reference image attachments to agent chat --- freevideo_engine/agent/attachments.py | 66 ++++++++++++++++ freevideo_engine/agent/guide.py | 3 + freevideo_engine/agent/routes.py | 105 +++++++++++++++++++++++--- freevideo_engine/agent/schema.py | 45 ++++++++++- freevideo_engine/agent/spec.py | 2 + 5 files changed, 207 insertions(+), 14 deletions(-) create mode 100644 freevideo_engine/agent/attachments.py diff --git a/freevideo_engine/agent/attachments.py b/freevideo_engine/agent/attachments.py new file mode 100644 index 00000000..2767b45b --- /dev/null +++ b/freevideo_engine/agent/attachments.py @@ -0,0 +1,66 @@ +"""Chat images in the retained input assets directory, with server-owned filenames.""" +import json +import os +from pathlib import Path +import re +import threading +import uuid + +from . import media, schema + +FOLDER = 'freevideo/agent/assets' +_LOCK = threading.RLock() + + +def path(input_root, name): + if not isinstance(name, str) or not re.fullmatch(schema.CHAT_ATTACHMENT_NAME['pattern'], name): + raise ValueError('Invalid attachment name') + root = Path(input_root).resolve() + target = root / FOLDER / name + # Reject links, including links to another location inside the input directory. + for part in (target, *target.parents): + if part == root: + break + if part.is_symlink(): + raise ValueError('Attachment paths cannot contain links') + if target.resolve() != target: + raise ValueError('Attachment leaves assets') + return target + + +def _metadata(target): + result = target.with_name('.' + target.name + '.json') + if result.is_symlink(): + raise ValueError('Invalid attachment metadata') + return result + + +def publish(input_root, temporary, name, original_name): + """Publish decoded bytes atomically; the first filename for identical content wins.""" + with _LOCK: + target = path(input_root, name) + target.parent.mkdir(parents=True, exist_ok=True) + metadata = _metadata(target) + if not metadata.exists(): + pending = metadata.with_name('.attachment-' + uuid.uuid4().hex + '.tmp') + try: + pending.write_text(json.dumps(dict(original_name=original_name), ensure_ascii=False), encoding='utf-8') + os.replace(pending, metadata) + finally: + pending.unlink(missing_ok=True) + os.replace(temporary, target) + return json.loads(metadata.read_text(encoding='utf-8'))['original_name'] + + +def read(input_root, name): + """Reinspect stored bytes; never use dimensions or filenames sent with a message.""" + with _LOCK: + target = path(input_root, name) + if not target.is_file() or target.stat().st_size > schema.MAX_CHAT_ATTACHMENT_BYTES: + raise ValueError('No saved attachment within the upload limit') + info, suffix = media.validate_upload(target) + if suffix not in ('.png', '.jpg', '.webp') or info['kind'] != 'image': + raise ValueError('Not a chat image') + metadata = _metadata(target) + original_name = json.loads(metadata.read_text(encoding='utf-8'))['original_name'] if metadata.exists() else name + return dict(name=name, original_name=original_name, width=info['width'], height=info['height']) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 09ed9a1d..2a4268da 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -69,6 +69,7 @@ 'itself; read the project afterwards and check that one clip covers the whole film.', ], 'references': [ + "User message attachments are reference images by default. Use each name directly in media.references, media.first or media.last according to the user's words; if the intended role is unclear, ask one question. Text can be empty when a message has attachments.", 'Tags are numbered per kind in reference order: , ,