From cbe112ac9ea2b7d5b637c678feea24eafa83121c Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 6 Oct 2026 09:36:36 +0000 Subject: [PATCH 1/2] Fix installer retries and add download cache cleanup (v0.2.5) --- freevideo_engine/failure_details.py | 15 ++- freevideo_engine/installation_cleanup.py | 164 +++++++++++++++++++++++ freevideo_engine/launcher/Main.qml | 62 +++++++-- freevideo_engine/launcher_session.py | 63 ++++++++- freevideo_engine/launcher_update.py | 48 ++++--- freevideo_engine/modern_launcher.py | 4 + freevideo_engine/release_notes.json | 20 ++- 7 files changed, 326 insertions(+), 50 deletions(-) create mode 100644 freevideo_engine/installation_cleanup.py diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 999214f..8595a48 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -73,6 +73,13 @@ def launcher_failure(value, *, zh=False): return dict(title='', detail='', action='', kind='') text = str(value) summary = text.partition(STEP_OUTPUT)[0] + disk = re.search(r'Insufficient disk space: need ([\d.]+) GiB, available ([\d.]+) GiB, short ([\d.]+) GiB', summary, re.I) + if disk: + needed, available, short = disk.groups() + return dict(title='磁盘空间不足' if zh else 'Not enough disk space', kind='disk', + detail=('需要 %s GiB,可用 %s GiB。再释放 %s GiB 后点击「重新检查」。' % (needed, available, short) + if zh else 'Need %s GiB; %s GiB available. Free another %s GiB, then click “Check again”.' % (needed, available, short)), + action='重新检查' if zh else 'Check again') if text.startswith('ComfyUI could not start.'): return dict(title='ComfyUI 启动失败' if zh else 'ComfyUI could not start', detail='ComfyUI 进程在启动时退出。' if zh else 'The ComfyUI process exited during startup.', @@ -125,11 +132,11 @@ def launcher_failure(value, *, zh=False): '关闭占用内存较多的程序后重试,已下载文件会保留。')), (r'Installation RAM monitoring failed|Cannot read process-tree memory', 'memory-monitor', ('Memory usage could not be read', '暂时无法读取进程内存'), - ('Download progress is saved. Check and continue to retry; copy the details if it happens again.', - '下载进度已保留。点击“检查并继续”重试;若再次出现,请复制详情反馈。')), - (r'No space left on device|WinError 112|disk (?:is )?full', 'disk', + ('Download progress is saved. Retry installation; copy the details if it happens again.', + '下载进度已保留。点击“重试安装”;若再次出现,请复制详情反馈。')), + (r'No space left on device|WinError 112|disk (?:is )?full|Not enough disk space|Insufficient disk space', 'disk', ('Not enough disk space', '磁盘空间不足'), - ('Free up space on the installation drive, then continue.', '清理安装盘空间后,点击继续。')), + ('Free up space on the installation drive, then retry.', '清理安装盘空间后,点击重试。')), (r'Model download paused|unexpected-transfer-size|ConnectionError|ConnectTimeout|ReadTimeout|HTTP probe failed|Could not resolve host|SSL certificate|Every download source failed|All Git sources failed|All package sources failed|Python download failed on every route', 'download', ('Download interrupted', '下载中断了'), ('Check your network or change the source or connection mode in Settings → Downloads, then retry.', '检查网络,或在“设置 → 下载”中切换下载源、连接模式后重试。')), diff --git a/freevideo_engine/installation_cleanup.py b/freevideo_engine/installation_cleanup.py new file mode 100644 index 0000000..6445578 --- /dev/null +++ b/freevideo_engine/installation_cleanup.py @@ -0,0 +1,164 @@ +"""User-requested cleanup of this installation's disposable package downloads. + +Never walks model folders, environments, outputs or imported offline packages. +uv owns its cache format and locking; removal of that cache goes through uv. +""" +import os +from pathlib import Path +import stat +import sys +import threading + +from .locking import runtime_lock +from .storage import fingerprint + + +def local_path(root, path): + """Refuse redirected cache roots, including Windows junctions.""" + try: + path.relative_to(root) + current = path + while current != root: + info = current.lstat() + if stat.S_ISLNK(info.st_mode) or getattr(info, 'st_file_attributes', 0) & 0x400: + return False + current = current.parent + return True + except (OSError, ValueError): + return False + + +def cache_bytes(path): + """Estimate reclaimable storage, counting cache-only hardlinks just once.""" + inodes = {} + def unreadable(error): + raise error + for directory, folders, files in os.walk(path, followlinks=False, onerror=unreadable): + for name in [*folders, *files]: + item = Path(directory) / name + info = item.lstat() + if stat.S_ISLNK(info.st_mode): + # uv uses internal symlinks. External redirects are not ours. + if not item.resolve().is_relative_to(path): + raise ValueError('Cache contains an external link; retained.') + if name in folders: + folders.remove(name) + elif getattr(info, 'st_file_attributes', 0) & 0x400: + raise ValueError('Cache contains a redirected folder; retained.') + elif stat.S_ISREG(info.st_mode): + key = info.st_dev, info.st_ino + row = inodes.setdefault(key, [info.st_size, info.st_nlink, 0]) + row[2] += 1 + return sum(size for size, links, inside in inodes.values() if links <= inside) + + +def uv_path(root): + relative = ('uv.exe' if os.name == 'nt' else 'uv' if sys.platform == 'darwin' + else 'uv-x86_64-unknown-linux-gnu/uv') + path = root / 'tools' / relative + return path if local_path(root, path) and path.is_file() else None + + +def scan(root): + from .torch_download import catalog + root = Path(root).absolute().resolve() + plan = dict(root=str(root), cache=None, wheels=[], bytes=0) + cache = root / 'downloads' / 'uv-cache' + if uv_path(root) and local_path(root, cache) and cache.is_dir(): + try: + size = cache_bytes(cache) + except (OSError, ValueError): + pass # Uncertain ownership is not permission to delete it. + else: + if size: + plan['cache'] = dict(bytes=size, path=str(cache)) + plan['bytes'] += size + for row in catalog(): + path = root / 'downloads' / 'torch-wheels' / row['filename'] + if not local_path(root, path): + continue + info = path.stat() + if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1 or info.st_size != row['bytes']: + continue + stamp = fingerprint(path) + if 'change_time_ns' in stamp and stamp['change_time_ns'] is None: + continue + plan['wheels'].append(dict(path=str(path), stamp=stamp, bytes=info.st_size)) + plan['bytes'] += info.st_size + return plan + + +def clean(plan): + from . import processes + from .torch_download import catalog + root = Path(plan['root']) + released = skipped = 0 + # Other launchers and CLI installers also hold these leases. Do not race + # package installation, generation or a resumed download from another UI. + with runtime_lock(root / 'setup.lock', inherit=False), \ + runtime_lock(root / 'engine.lock', inherit=False), \ + runtime_lock(root / 'launcher' / 'host-setup.lock', inherit=False): + cache = plan.get('cache') + if cache: + path, uv = root / 'downloads' / 'uv-cache', uv_path(root) + if str(path) != cache['path'] or not uv or not local_path(root, path): + raise ValueError('Package cache changed; check storage again.') + before = cache_bytes(path) # Recheck redirects before invoking uv. + result = processes.run([str(uv), 'cache', 'clean', '--cache-dir', str(path)], + capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=120) + if result.returncode: + raise RuntimeError(result.stderr or 'Package cache cleanup did not complete.') + after = cache_bytes(path) if path.exists() else 0 + released += max(0, before - after) + allowed = {str(root / 'downloads' / 'torch-wheels' / row['filename']) + for row in catalog()} + for row in plan['wheels']: + path = Path(row['path']) + try: + if (str(path) not in allowed or not local_path(root, path) or path.stat().st_nlink != 1 + or fingerprint(path) != row['stamp']): + skipped += 1 + continue + path.unlink() + released += row['bytes'] + except OSError: + skipped += 1 + return dict(released_bytes=released, skipped=skipped) + + +class Cleaner: + def __init__(self): + self.state = dict(status='idle', bytes=0, error='') + self.thread = None + self.plan = None + + @property + def busy(self): + return self.thread is not None and self.thread.is_alive() + + def start(self, root, *, remove=False): + if self.busy: + return + root = str(Path(root).absolute().resolve()) + if remove and (not self.plan or self.plan['root'] != root): + raise ValueError('Check storage in the selected installation first.') + plan = self.plan + self.state = dict(status='cleaning' if remove else 'scanning', bytes=0, error='') + def work(): + from .failure_details import redacted_launcher_error + try: + if remove: + result = clean(plan) + self.plan = None + self.state = dict(status='complete', bytes=0, error='', **result) + else: + self.plan = scan(root) + self.state = dict(status='ready', bytes=self.plan['bytes'], error='') + except BlockingIOError: + self.state = dict(status='busy', bytes=0, error='') + except Exception as error: + self.state = dict(status='error', bytes=0, error=redacted_launcher_error(str(error))) + # If the window closes, finish the current tool operation and release + # its leases before Python exits; do not abandon a half-cleaned cache. + self.thread = threading.Thread(target=work, name='freevideo-storage') + self.thread.start() diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index f4235dd..fe5c1da 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -16,6 +16,7 @@ ApplicationWindow { property string modelInfo: "video" property bool modelInfoOpen: false property bool closePending: false + property bool cleanupConfirm: false property bool accepted: false property bool manualUpdate: false property bool releaseNotesOpen: false @@ -53,6 +54,7 @@ ApplicationWindow { if (phase === "restarting") return t("Restarting FreeVideo…", "正在重启 FreeVideo…") if (phase === "engine") return t("Updating the engine…", "正在更新引擎…") if (phase === "checking") return t("Checking for updates…", "正在检查更新…") + if (s.update.status === "error") return t("Update incomplete", "更新未完成") if (s.update.candidate) return t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available", " 可以更新") return t("New engine ", "新版引擎 ") + releaseVersion(currentRelease) + t(" is ready", " 已就绪") } @@ -67,6 +69,7 @@ ApplicationWindow { return t("Installed engine ", "已安装引擎 ") + (s.update.installed || "—") + t(". Updating takes about a minute and keeps your models and settings.", "。更新约需 1 分钟,模型和设置都会保留。") } function primaryText() { + if (s.retry_kind) return retryText() if (needsRuntime) return t("Choose environment package", "选择运行环境包") if (s.page === "comfy") return t("Continue", "继续") if (needsPackages) return t("Choose offline packages", "选择离线包") @@ -75,6 +78,12 @@ ApplicationWindow { if (s.page === "launcher") return s.status === "open" ? t("Open FreeVideo", "打开 FreeVideo") : s.status === "restart-required" ? t("Connect again", "重新连接") : t("Launch FreeVideo", "启动 FreeVideo") return s.status === "review" ? t("Install & launch", "安装并启动") : s.status === "restart-required" ? t("Connect again", "重新连接") : t("Check & resume", "检查并继续") } + function retryText() { + if (s.retry_kind === "check") return t("Check again", "重新检查") + if (s.retry_kind === "launch") return t("Retry launch", "重试启动") + if (s.retry_kind === "import" || s.retry_kind === "prepare") return t("Retry import", "重试导入") + return t("Retry installation", "重试安装") + } onSChanged: { if (s.page !== previousPage) { Qt.callLater(function() { scroll.contentItem.contentY = 0 }) @@ -97,7 +106,7 @@ ApplicationWindow { } onClosing: function(event) { event.accepted = false - if (s.busy) closePending = true + if (s.busy && !s.cleanup.busy) closePending = true else backend.close() } @@ -254,6 +263,8 @@ ApplicationWindow { FText { visible: !!(s.failure.detail || s.failure.action); text: s.failure.detail || s.failure.action; color: theme.text; Layout.fillWidth: true } Flow { Layout.fillWidth: true; spacing: 8 + FButton { objectName: "retryFailureButton"; visible: !!s.retry_kind; enabled: !s.busy; primary: true; text: retryText(); onClicked: backend.action("retry", false) } + FButton { objectName: "diskCleanupButton"; visible: s.failure.kind === "disk"; enabled: s.can_cleanup; text: t("Clean download cache", "清理下载缓存"); onClicked: { settingsTab = "general"; settingsOpen = true; backend.cleanupDownloads(false) } } FButton { visible: s.failure.kind === "download"; text: t("Change source", "切换下载源"); onClicked: { settingsTab = "downloads"; settingsOpen = true } } FButton { objectName: "copyError"; text: t("Copy full details", "复制完整详情"); onClicked: backend.copy(s.error) } FButton { objectName: "showError"; text: errorDetailsOpen ? t("Hide details", "收起详情") : t("Show details", "查看详情"); flat: true; onClicked: errorDetailsOpen = !errorDetailsOpen } @@ -568,7 +579,7 @@ ApplicationWindow { FButton { objectName: "cancelUpdateButton"; visible: s.update.phase === "waiting"; flat: true; text: t("Cancel update", "取消更新"); onClicked: { manualUpdate = false; backend.dismissUpdate() } } FButton { objectName: "engineUpdateButton"; primary: true; visible: !s.update.phase; enabled: !s.busy - text: s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : t("Update now", "立即更新") + text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : t("Update now", "立即更新") onClicked: { manualUpdate = !!s.update.candidate; backend.update("") } } } @@ -636,14 +647,14 @@ ApplicationWindow { anchors.fill: parent; anchors.leftMargin: 24; anchors.rightMargin: 24; spacing: 10 FButton { objectName: "backButton"; text: t("Back", "上一步"); flat: true; visible: s.page === "models" || s.page === "progress"; enabled: !s.busy; onClicked: backend.action("back", false) } Item { Layout.fillWidth: true } - FButton { visible: s.busy; text: t("Pause", "暂停"); onClicked: backend.action("stop", false) } + FButton { visible: s.busy && !s.cleanup.busy; text: t("Pause", "暂停"); onClicked: backend.action("stop", false) } FButton { objectName: "launchInstalledButton"; visible: updateFirst; flat: true; text: t("Launch current version", "启动当前版本"); onClicked: backend.action("primary", accepted) } FButton { objectName: "primaryButton"; primary: true; implicitWidth: Math.max(160, contentItem.implicitWidth+40); implicitHeight: theme.heightLg text: s.busy ? t("Working…", "正在处理…") : primaryText() enabled: !s.busy && !(s.page === "launcher" && s.needs_consent && !accepted) - && !(s.page === "progress" && s.status === "review" && (!accepted || !!s.error)) - onClicked: { if (needsRuntime) { backend.browseRuntimePackage(); return } if (needsPackages) { backend.browsePackages(); return } if (updateFirst) { backend.update(""); return } backend.action(s.page === "launcher" && s.status === "open" ? "browser" : "primary", accepted); if (s.busy && s.page === "launcher") terminalOpen = true } + && !(s.page === "progress" && s.status === "review" && !s.retry_kind && (!accepted || !!s.error)) + onClicked: { if (s.retry_kind) { backend.action("retry", accepted); return } if (needsRuntime) { backend.browseRuntimePackage(); return } if (needsPackages) { backend.browsePackages(); return } if (updateFirst) { backend.update(""); return } backend.action(s.page === "launcher" && s.status === "open" ? "browser" : "primary", accepted); if (s.busy && s.page === "launcher") terminalOpen = true } } } } @@ -785,6 +796,25 @@ ApplicationWindow { } FButton { text: t("Version & release notes", "版本与更新说明") + " · " + releaseVersion(currentRelease); flat: true; Layout.fillWidth: true; onClicked: releaseNotesOpen = true } } + FGroup { + Layout.fillWidth: true; title: t("Download cache", "下载缓存") + FText { text: t("Remove cached installation packages. Models, environments and videos are kept.", "清理已缓存的安装文件,保留模型、运行环境和视频。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } + FText { + objectName: "cleanupStatus"; Layout.fillWidth: true; color: theme.muted; visible: s.cleanup.status !== "idle" + text: s.cleanup.status === "scanning" ? t("Checking…", "正在检查…") : + s.cleanup.status === "cleaning" ? t("Cleaning…", "正在清理…") : + s.cleanup.status === "ready" ? (s.cleanup.bytes > 0 ? t("Can free about ", "预计可清理 ") + bytes(s.cleanup.bytes) : t("No unused download cache found.", "暂无可清理的下载缓存。")) : + s.cleanup.status === "complete" ? t("Freed ", "已释放 ") + bytes(s.cleanup.released_bytes || 0) + (s.cleanup.skipped ? t(". Some files changed; check again.", "。部分文件已变化,请重新检查。") : "") : + s.cleanup.status === "busy" ? t("Another task is using these files. Try again when it finishes.", "其他任务正在使用这些文件,完成后可重试。") : + t("Cleanup could not finish. Check again to retry.", "清理未完成,点击检查重试。") + } + FText { visible: !!s.cleanup.error; text: s.cleanup.error || ""; color: theme.danger; font.pixelSize: theme.micro; Layout.fillWidth: true } + Flow { + Layout.fillWidth: true; spacing: 8 + FButton { objectName: "scanCacheButton"; text: t("Check cache", "检查缓存"); enabled: s.can_cleanup; onClicked: backend.cleanupDownloads(false) } + FButton { objectName: "clearCacheButton"; visible: s.cleanup.status === "ready" && s.cleanup.bytes > 0; text: t("Clean…", "清理…"); enabled: s.can_cleanup; onClicked: cleanupConfirm = true } + } + } FGroup { Layout.fillWidth: true; title: t("Compatibility", "兼容性") RowLayout { @@ -874,8 +904,11 @@ ApplicationWindow { // State changes also close it (an update started from the banner or a // page, or a task began); only a user dismissal counts as "Later". onClosed: { if (!s.busy && (s.update.remind || manualUpdate)) backend.dismissUpdate(); manualUpdate = false } - height: Math.min(updateContents.implicitHeight + padding*2, win.height-48) - contentItem: ScrollView { + height: Math.min(updateContents.implicitHeight + padding*2 + updateActions.implicitHeight + 14, win.height-48) + contentItem: ColumnLayout { + spacing: 14 + ScrollView { + Layout.fillWidth: true; Layout.fillHeight: true id: updateScroll; clip: true; contentWidth: availableWidth ColumnLayout { id: updateContents; width: updateScroll.availableWidth @@ -887,12 +920,13 @@ ApplicationWindow { FButton { text: t("Version & release notes", "版本与更新说明"); flat: true; onClicked: releaseNotesOpen = true } FText { visible: !!s.update.error; text: s.update.error || ""; color: theme.danger; Layout.fillWidth: true; font.pixelSize: theme.micro } FField { id: githubToken; visible: !!s.update.error; Layout.fillWidth: true; echoMode: TextInput.Password; placeholderText: t("GitHub token · optional", "GitHub Token · 可选") } + } + } RowLayout { - Layout.fillWidth: true; Layout.topMargin: 8 + id: updateActions; Layout.fillWidth: true FButton { objectName: "updateLaterButton"; text: s.update.candidate || s.update.engine ? t("Later", "稍后更新") : t("Close", "关闭"); flat: true; onClicked: { manualUpdate = false; backend.dismissUpdate() } } Item { Layout.fillWidth: true } - FButton { objectName: "updateNowButton"; text: s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.candidate || s.update.engine ? t("Update now", "立即更新") : t("Check again", "重新检查"); primary: true; enabled: ["checking","downloading"].indexOf(s.update.status) < 0 && s.update.phase !== "waiting"; onClicked: { manualUpdate = !!s.update.candidate || !s.update.engine; backend.update(githubToken.text) } } - } + FButton { objectName: "updateNowButton"; text: s.update.status === "error" ? t("Retry update", "重试更新") : s.update.status === "ready" && s.update.candidate ? t("Restart & update", "重启并更新") : s.update.candidate || s.update.engine ? t("Update now", "立即更新") : t("Check again", "重新检查"); primary: true; enabled: ["checking","downloading"].indexOf(s.update.status) < 0 && s.update.phase !== "waiting"; onClicked: { manualUpdate = !!s.update.candidate || !s.update.engine; backend.update(githubToken.text) } } } } } @@ -919,6 +953,14 @@ ApplicationWindow { } } + FDialog { + objectName: "cleanupConfirmation"; visible: cleanupConfirm + title: t("Clean download cache?", "清理下载缓存?") + text: t("Future repairs may need to download these installation files again.", "以后修复环境时,可能需要重新下载这些安装文件。") + acceptText: t("Clean", "清理"); rejectText: t("Cancel", "取消") + onAccepted: { cleanupConfirm = false; backend.cleanupDownloads(true) } + onRejected: cleanupConfirm = false + } FDialog { visible: closePending title: t("Pause and close?", "暂停并退出?") diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 6ef8321..f34b447 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -42,8 +42,11 @@ class Session: def __init__(self, source=None, *, controller=None, store=None, updater=None, smoke=False): from .offline_packages import Importer + from .installation_cleanup import Cleaner + self.cleaner = Cleaner() self.importer = Importer() self.imported_batch = None + self.import_retry = None self.source = Path(source or materialize_source()) self.controller = controller or Controller(self.source) self.store = store or Store(launcher_root()) @@ -150,7 +153,7 @@ def edit(self, key, value): raise ValueError(self.t('Pause installation before changing the token.', '请先暂停安装,再修改 Token。')) from .hf_auth import validate self.token = validate(str(value)); return - if key not in self.form or self.controller.busy or self.importer.busy: + if key not in self.form or self.controller.busy or self.importer.busy or self.cleaner.busy: return if key in ('separate', 'repair', 'new_comfy', 'sampling_caches'): value = bool(value) @@ -164,6 +167,9 @@ def edit(self, key, value): if self.form[key] == value: return self.form[key] = value + self.import_retry = None + self.cleaner.plan = None + self.cleaner.state = dict(status='idle', bytes=0, error='') self.controller.selection = None self.controller.state = dict(status='idle') self.browser_attempted = False @@ -188,13 +194,14 @@ def clear_runtime(self): def import_packages(self, paths): - if self.controller.busy or self.importer.busy: + if self.controller.busy or self.importer.busy or self.cleaner.busy: return if not paths: return destination = (Path(self.form['destination']).expanduser().resolve() if self.form['new_comfy'] else self.engine_root().parent) self.importer.start(paths, destination) + self.import_retry = ('import', list(paths), destination) self.imported_batch = None self.error = '' @@ -212,6 +219,8 @@ def activate_offline(self, root): self.persist() def action(self, name, accepted=False): + if self.cleaner.busy: + return if self.importer.busy: if name == 'stop': self.importer.cancelled.set() @@ -221,6 +230,25 @@ def action(self, name, accepted=False): self.controller.cancel() return self.error = '' + if name == 'retry': + kind = self.retry_kind() + if kind == 'import': + _, paths, destination = self.import_retry + self.importer.start(paths, destination) + self.imported_batch = None + elif kind == 'prepare': + self.importer.prepare(self.form['offline_runtime'], self.form['offline_models'], self.source) + self.imported_batch = None + elif kind == 'launch': + self.action('launch', accepted) + elif kind in ('check', 'install'): + # Recompute the plan and live disk space; never install a stale + # failed review or discard completed/resumable downloads. + self.persist() + self.controller.run('inspect', dict(self.form, token=self.token)) + self.page = 'progress' + self.started = time.monotonic() + return if name == 'setup': if getattr(self.controller, 'fixed_environment', False) is True: self.controller.close() @@ -249,6 +277,7 @@ def action(self, name, accepted=False): state = self.controller.state.get('status') if (self.page == 'models' and self.form['new_comfy'] and self.form['environment_method'] == 'manual' and self.form['offline_runtime']): + self.import_retry = ('prepare',) self.importer.prepare(self.form['offline_runtime'], self.form['offline_models'], self.source) return if self.page == 'comfy': @@ -282,6 +311,25 @@ def action(self, name, accepted=False): self.model_groups = [] self.started = time.monotonic() + def retry_kind(self): + if self.importer.state.get('status') == 'error' and self.import_retry: + return self.import_retry[0] + row = self.controller.state + if self.page == 'progress' and row.get('status') == 'review' and row.get('errors'): + return 'check' + if row.get('status') in ('failed', 'cancelled'): + if self.page == 'launcher': + return 'launch' + if self.page == 'progress': + return 'install' + return '' + + def cleanup_downloads(self, remove=False): + if (self.controller.busy or self.importer.busy or self.closing + or self.update_intent or self.engine_updating or self.updater and self.updater.busy): + return + self.cleaner.start(self.engine_root(), remove=remove) + def _browser_url(self, address): """Return the URL used by the browser, with the launcher locale hint.""" if not address or not self.language.startswith('zh'): @@ -421,7 +469,7 @@ def update(self, token='', source='launcher'): """One explicit update: the newest launcher if one is known, otherwise the engine bundled with this launcher. Downloading, waiting for running ComfyUI jobs and restarting continue from tick().""" - if self.controller.busy or self.importer.busy or self.closing: + if self.controller.busy or self.importer.busy or self.cleaner.busy or self.closing: if source == 'browser': self.bridge_pending = True return @@ -673,6 +721,7 @@ def tick(self): imported = self.importer.state if not self.importer.busy and imported is not self.imported_batch: self.imported_batch = imported + retry = self.import_retry for package in imported.get('packages', []): if package['kind'] == 'runtime': self.edit('offline_runtime', package['root']) @@ -686,6 +735,7 @@ def tick(self): self.edit('model_method', 'manual') self.persist() if imported['status'] == 'error': + self.import_retry = retry self.error = imported['error'] elif imported['status'] == 'prepared': self.activate_offline(imported['ready_root']) @@ -810,12 +860,15 @@ def snapshot(self): ok=entry.get('ok', False), rate=speed_text(entry, self.language.startswith('zh')))) from .sampling_assets import total_bytes return dict(sampling_cache_bytes=total_bytes(), version=__version__, zh=self.language.startswith('zh'), form=dict(self.form), - page=self.page, status=row.get('status', 'idle'), busy=self.controller.busy or self.importer.busy, + page=self.page, status=row.get('status', 'idle'), busy=self.controller.busy or self.importer.busy or self.cleaner.busy, + cleanup=dict(self.cleaner.state, busy=self.cleaner.busy), + can_cleanup=not (self.controller.busy or self.importer.busy or self.cleaner.busy or self.closing + or self.update_intent or self.engine_updating or self.updater and self.updater.busy), offline=dict(progress_view(self.importer.state, zh), runtime=bool(self.form['offline_runtime']), runtime_supported=runtime_packages_supported(), models=len(self.form['offline_models']), guide=package_instructions(self.form['new_comfy'], zh)), video_model_guide=video_instructions(zh), - selected=bool(self.selected), error=error, notice=self.notice, compatibility=self.compatibility, + selected=bool(self.selected), error=error, retry_kind=self.retry_kind(), notice=self.notice, compatibility=self.compatibility, report=dict(self.report), models=models, overall=overall, progress=progress, detail=clean(progress.get('detail', '')), progress_text=progress_text(progress, self.language.startswith('zh')), diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py index cfde21d..dbee798 100644 --- a/freevideo_engine/launcher_update.py +++ b/freevideo_engine/launcher_update.py @@ -238,31 +238,39 @@ def download(candidate, root, token='', *, progress=None, cancel=None): if verified(target, asset): return target target.parent.mkdir(parents=True, exist_ok=True) - # Unique staging keeps a cancelled/failed transfer and concurrent downloads - # separate. The original launcher and models are never overwritten. + # Each attempt owns one stage. Failed stages cannot be resumed, so discard + # only ours; never touch another attempt, the original launcher or models. import tempfile import shutil if shutil.disk_usage(target.parent).free < asset['bytes'] + 16*2**20: raise OSError('Not enough disk space to download the launcher update') start, done = time.monotonic(), 0 - with open_download(API + '/releases/assets/' + str(asset['id']), token, binary=True) as response: - with tempfile.NamedTemporaryFile(prefix='download-', suffix='.partial', dir=target.parent, delete=False) as stream: - stage = Path(stream.name) - digest = hashlib.sha256() - for block in _bytes(response, asset['bytes'], cancel, seconds=1800): - stream.write(block); digest.update(block); done += len(block) - if progress: - elapsed = time.monotonic()-start - speed = done/max(.001, elapsed) - progress(dict(done=done, total=asset['bytes'], bytes_per_second=speed, - remaining_seconds=(asset['bytes']-done)/speed)) - stream.flush(); os.fsync(stream.fileno()) - if done != asset['bytes'] or digest.hexdigest() != asset['sha256']: - raise ValueError('Launcher update failed size/content verification; original EXE retained') - if cancel is not None and cancel.is_set(): - raise InterruptedError('Update stopped before activation') - stage.replace(target) - return target + stage = None + try: + with open_download(API + '/releases/assets/' + str(asset['id']), token, binary=True) as response: + with tempfile.NamedTemporaryFile(prefix='download-', suffix='.partial', dir=target.parent, delete=False) as stream: + stage = Path(stream.name) + digest = hashlib.sha256() + for block in _bytes(response, asset['bytes'], cancel, seconds=1800): + stream.write(block); digest.update(block); done += len(block) + if progress: + elapsed = time.monotonic()-start + speed = done/max(.001, elapsed) + progress(dict(done=done, total=asset['bytes'], bytes_per_second=speed, + remaining_seconds=(asset['bytes']-done)/speed)) + stream.flush(); os.fsync(stream.fileno()) + if done != asset['bytes'] or digest.hexdigest() != asset['sha256']: + raise ValueError('Launcher update failed size/content verification; original EXE retained') + if cancel is not None and cancel.is_set(): + raise InterruptedError('Update stopped before activation') + stage.replace(target) + return target + finally: + if stage is not None: + try: + stage.unlink(missing_ok=True) + except OSError: + pass # A cleanup failure must not mask the download error. class DownloadedLauncherUnavailable(ValueError): diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index 7e322e0..7eb0bca 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -120,6 +120,10 @@ def edit(self, key, value): def action(self, name, accepted=False): self.invoke(session.action, name, accepted) + @Slot(bool) + def cleanupDownloads(self, remove=False): + self.invoke(session.cleanup_downloads, remove) + @Slot(str) def browse(self, key): if session.controller.busy: diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 59fcc03..80f0511 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,22 +1,20 @@ { "schema": 1, - "product_version": "0.2.4", + "product_version": "0.2.5", "en": { - "summary": "Prompts with reference images and videos encode faster and no longer stall.", + "summary": "Retry failed installations and updates, and free disk space from Settings.", "changes": [ - "Prompt encoding runs in BF16, as the official MiniMax H3 pipeline does, instead of FP32: it is faster and needs less GPU memory, most noticeably with reference images and videos.", - "Encoding reserves GPU memory for the real length of the prompt and its references and checks what is actually free before it starts; very long references are encoded in smaller blocks. A request with a 15-second reference video and three images that stopped responding on a 16 GB card now encodes in under a minute.", - "If Windows starts moving encoding into shared GPU memory, which makes it many times slower, FreeVideo stops within seconds, leaves more room and retries by itself, and remembers how much room this computer needs.", - "Reference videos and audio are cut to the length of the video being generated, at most 15 seconds, as in the official MiniMax H3 pipeline, and FreeVideo now tells you when a clip was cut and how much was used. Clips longer than 15 seconds are no longer refused, and a short video with a long reference encodes and samples faster." + "After freeing disk space, check the installation plan again without reopening FreeVideo. Existing models and download progress are kept.", + "Clear installation download caches from Settings, with a space estimate before removal. Installed environments, models and videos are kept.", + "Update buttons stay visible with long release notes. Failed update downloads no longer leave temporary files behind." ] }, "zh": { - "summary": "带参考图和参考视频的提示词编码更快,不再卡住。", + "summary": "安装和更新失败后可直接重试,设置中新增下载缓存清理。", "changes": [ - "提示词编码改用 BF16 计算(与官方 MiniMax H3 一致),不再使用 FP32:速度更快、显存占用更少,带参考图和参考视频时最明显。", - "编码会按提示词和参考素材的实际长度预留显存,开始前确认真正可用的显存;参考素材很长时改为分块编码。此前在 16 GB 显卡上会卡住的“15 秒参考视频 + 3 张参考图”请求,现在一分钟内即可完成编码。", - "Windows 上如果编码开始用到共享显存(会慢很多倍),FreeVideo 会在几秒内停下、留出更多显存并自动重试,同时记住这台电脑需要预留多少。", - "参考视频和参考音频按本次生成的时长截取前段(最长 15 秒),与官方 MiniMax H3 管线一致;截取时界面会明确提示用了多长。超过 15 秒的素材不再报错,短视频配长参考时编码和采样也更快。" + "释放磁盘空间后可原地重新检查安装计划,无需重开软件;保留已有模型和下载进度。", + "设置中可查看下载缓存大小并清理,保留已安装的运行环境、模型和视频。", + "更新说明较长时,操作按钮仍固定可见;更新下载失败后自动回收本次临时文件。" ] } } From badbffa39f2b98f0e8ef4bbbc44a3acf2a13cfd3 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Tue, 6 Oct 2026 12:38:10 +0000 Subject: [PATCH 2/2] Keep one retry action and lay out download cache cleanup like the other settings The error card repeated the footer's primary retry; it now offers only the remedies, and the footer keeps the single primary action. The download cache moves into a Storage group with the same row layout as General, one button checks or cleans, and the confirmation says how much will be freed. --- freevideo_engine/failure_details.py | 2 +- freevideo_engine/launcher/Main.qml | 51 ++++++++++++++++++----------- 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 8595a48..9aa201a 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -77,7 +77,7 @@ def launcher_failure(value, *, zh=False): if disk: needed, available, short = disk.groups() return dict(title='磁盘空间不足' if zh else 'Not enough disk space', kind='disk', - detail=('需要 %s GiB,可用 %s GiB。再释放 %s GiB 后点击「重新检查」。' % (needed, available, short) + detail=('需要 %s GiB,可用 %s GiB。再释放 %s GiB 后点击“重新检查”。' % (needed, available, short) if zh else 'Need %s GiB; %s GiB available. Free another %s GiB, then click “Check again”.' % (needed, available, short)), action='重新检查' if zh else 'Check again') if text.startswith('ComfyUI could not start.'): diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index fe5c1da..017084a 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -263,7 +263,6 @@ ApplicationWindow { FText { visible: !!(s.failure.detail || s.failure.action); text: s.failure.detail || s.failure.action; color: theme.text; Layout.fillWidth: true } Flow { Layout.fillWidth: true; spacing: 8 - FButton { objectName: "retryFailureButton"; visible: !!s.retry_kind; enabled: !s.busy; primary: true; text: retryText(); onClicked: backend.action("retry", false) } FButton { objectName: "diskCleanupButton"; visible: s.failure.kind === "disk"; enabled: s.can_cleanup; text: t("Clean download cache", "清理下载缓存"); onClicked: { settingsTab = "general"; settingsOpen = true; backend.cleanupDownloads(false) } } FButton { visible: s.failure.kind === "download"; text: t("Change source", "切换下载源"); onClicked: { settingsTab = "downloads"; settingsOpen = true } } FButton { objectName: "copyError"; text: t("Copy full details", "复制完整详情"); onClicked: backend.copy(s.error) } @@ -797,23 +796,38 @@ ApplicationWindow { FButton { text: t("Version & release notes", "版本与更新说明") + " · " + releaseVersion(currentRelease); flat: true; Layout.fillWidth: true; onClicked: releaseNotesOpen = true } } FGroup { - Layout.fillWidth: true; title: t("Download cache", "下载缓存") - FText { text: t("Remove cached installation packages. Models, environments and videos are kept.", "清理已缓存的安装文件,保留模型、运行环境和视频。"); color: theme.muted; font.pixelSize: theme.micro; Layout.fillWidth: true } - FText { - objectName: "cleanupStatus"; Layout.fillWidth: true; color: theme.muted; visible: s.cleanup.status !== "idle" - text: s.cleanup.status === "scanning" ? t("Checking…", "正在检查…") : - s.cleanup.status === "cleaning" ? t("Cleaning…", "正在清理…") : - s.cleanup.status === "ready" ? (s.cleanup.bytes > 0 ? t("Can free about ", "预计可清理 ") + bytes(s.cleanup.bytes) : t("No unused download cache found.", "暂无可清理的下载缓存。")) : - s.cleanup.status === "complete" ? t("Freed ", "已释放 ") + bytes(s.cleanup.released_bytes || 0) + (s.cleanup.skipped ? t(". Some files changed; check again.", "。部分文件已变化,请重新检查。") : "") : - s.cleanup.status === "busy" ? t("Another task is using these files. Try again when it finishes.", "其他任务正在使用这些文件,完成后可重试。") : - t("Cleanup could not finish. Check again to retry.", "清理未完成,点击检查重试。") + Layout.fillWidth: true; title: t("Storage", "存储") + RowLayout { + Layout.fillWidth: true; spacing: 12 + ColumnLayout { + Layout.fillWidth: true; spacing: 2 + FText { text: t("Download cache", "下载缓存"); Layout.fillWidth: true } + FText { + objectName: "cleanupStatus"; Layout.fillWidth: true; font.pixelSize: theme.micro + color: s.cleanup.status === "error" ? theme.danger : theme.muted + text: s.cleanup.status === "scanning" ? t("Checking…", "正在检查…") : + s.cleanup.status === "cleaning" ? t("Cleaning…", "正在清理…") : + s.cleanup.status === "ready" ? (s.cleanup.bytes > 0 ? t("About ", "约 ") + bytes(s.cleanup.bytes) + t(" can be freed. Models, environments and videos are kept.", " 可清理,保留模型、运行环境和视频。") : t("Nothing to clean.", "暂无可清理的下载缓存。")) : + s.cleanup.status === "complete" ? t("Freed ", "已释放 ") + bytes(s.cleanup.released_bytes || 0) + (s.cleanup.skipped ? t(". Some files changed; check again.", "。部分文件已变化,请重新检查。") : t(".", "。")) : + s.cleanup.status === "busy" ? t("Another task is using these files. Try again when it finishes.", "其他任务正在使用这些文件,完成后可重试。") : + s.cleanup.status === "error" ? t("Cleanup could not finish. Check again to retry.", "清理未完成,可重新检查后再试。") : + t("Installation files kept for repairs. Models, environments and videos are never removed.", "安装时缓存的文件,清理时不会删除模型、运行环境和视频。") + } + } + FButton { + objectName: "scanCacheButton"; visible: !(s.cleanup.status === "ready" && s.cleanup.bytes > 0) + text: s.cleanup.status === "idle" ? t("Check", "检查") : t("Check again", "重新检查") + implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1 + enabled: s.can_cleanup && ["scanning", "cleaning"].indexOf(s.cleanup.status) < 0 + onClicked: backend.cleanupDownloads(false) + } + FButton { + objectName: "clearCacheButton"; visible: s.cleanup.status === "ready" && s.cleanup.bytes > 0 + text: t("Clean…", "清理…"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1 + enabled: s.can_cleanup; onClicked: cleanupConfirm = true + } } FText { visible: !!s.cleanup.error; text: s.cleanup.error || ""; color: theme.danger; font.pixelSize: theme.micro; Layout.fillWidth: true } - Flow { - Layout.fillWidth: true; spacing: 8 - FButton { objectName: "scanCacheButton"; text: t("Check cache", "检查缓存"); enabled: s.can_cleanup; onClicked: backend.cleanupDownloads(false) } - FButton { objectName: "clearCacheButton"; visible: s.cleanup.status === "ready" && s.cleanup.bytes > 0; text: t("Clean…", "清理…"); enabled: s.can_cleanup; onClicked: cleanupConfirm = true } - } } FGroup { Layout.fillWidth: true; title: t("Compatibility", "兼容性") @@ -908,8 +922,7 @@ ApplicationWindow { contentItem: ColumnLayout { spacing: 14 ScrollView { - Layout.fillWidth: true; Layout.fillHeight: true - id: updateScroll; clip: true; contentWidth: availableWidth + id: updateScroll; Layout.fillWidth: true; Layout.fillHeight: true; clip: true; contentWidth: availableWidth ColumnLayout { id: updateContents; width: updateScroll.availableWidth spacing: 14 @@ -956,7 +969,7 @@ ApplicationWindow { FDialog { objectName: "cleanupConfirmation"; visible: cleanupConfirm title: t("Clean download cache?", "清理下载缓存?") - text: t("Future repairs may need to download these installation files again.", "以后修复环境时,可能需要重新下载这些安装文件。") + text: t("About ", "将释放约 ") + bytes(s.cleanup.bytes || 0) + t(" will be freed. Future repairs may need to download these installation files again.", "。以后修复环境时,可能需要重新下载这些安装文件。") acceptText: t("Clean", "清理"); rejectText: t("Cancel", "取消") onAccepted: { cleanupConfirm = false; backend.cleanupDownloads(true) } onRejected: cleanupConfirm = false