diff --git a/MANIFEST.in b/MANIFEST.in index 357098c5..3b2bfbcd 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -27,3 +27,5 @@ prune benchmarks prune distribution prune .github exclude AGENTS.md CHANGELOG.md + +include freevideo_source_bootstrap.py freevideo_ed25519.py freevideo_update_keys.py diff --git a/docs/community-results.md b/docs/community-results.md index f38e977a..16b17660 100644 --- a/docs/community-results.md +++ b/docs/community-results.md @@ -86,6 +86,10 @@ Thanks to the community members who sent diagnostic reports. FreeVideo already r | 4070 Laptop | 8 GB | 34 GB | 1344×768 | 15 s | Light⁵ | 0.4.8 | 1444 s | 0.4.8 | Already optimal¹² | | 5060 Ti | 16 GB | 56 GB | 960×544 | 10 s | Light | 0.4.6 | 904 s | 0.4.9 | 805 s (−99 s) | | 3070 Laptop | 8 GB | 64 GB | 1344×768 | 10 s | Light | 0.4.9 | 1267 s | 0.4.9 | Already optimal | +| 3060 | 12 GB | 64 GB | 768×1344 | 5 s | Light | 0.4.12 | 639 s | 0.4.12 | Already optimal | +| 5080 | 16 GB | 64 GB | 768×1344 | 3 s | Max | 0.3.20 | 216 s | 0.3.20 | Already optimal | +| 4060 Ti | 8 GB | 32 GB | 960×544 | 5 s | Light | 0.4.12 | 333 s | 0.4.12 | Already optimal | +| 5070 Ti | 16 GB | 64 GB | 1184×672 | 15 s | Medium | 0.4.13 | 487 s | 0.4.13 | Already optimal | - Time: end to end, from submission until the video is saved. - After: the estimated time, derived from the report's stage times and from complete planned runs on our test machines (RTX 3090 and RTX 5060 Ti on Windows, RTX PRO 6000 on Linux) under the reporting machine's VRAM and RAM limits, not from reruns on that machine. diff --git a/docs/zh-CN/community-results.md b/docs/zh-CN/community-results.md index db791ab7..40db55d1 100644 --- a/docs/zh-CN/community-results.md +++ b/docs/zh-CN/community-results.md @@ -86,6 +86,10 @@ | 4070 Laptop | 8 GB | 34 GB | 1344×768 | 15 s | 轻⁠量⁵ | 0.4.8 | 1444 s | 0.4.8 | 已⁠是⁠最⁠优¹² | | 5060 Ti | 16 GB | 56 GB | 960×544 | 10 s | 轻⁠量 | 0.4.6 | 904 s | 0.4.9 | 805 s (−99 s) | | 3070 Laptop | 8 GB | 64 GB | 1344×768 | 10 s | 轻⁠量 | 0.4.9 | 1267 s | 0.4.9 | 已⁠是⁠最⁠优 | +| 3060 | 12 GB | 64 GB | 768×1344 | 5 s | 轻⁠量 | 0.4.12 | 639 s | 0.4.12 | 已⁠是⁠最⁠优 | +| 5080 | 16 GB | 64 GB | 768×1344 | 3 s | 极⁠致 | 0.3.20 | 216 s | 0.3.20 | 已⁠是⁠最⁠优 | +| 4060 Ti | 8 GB | 32 GB | 960×544 | 5 s | 轻⁠量 | 0.4.12 | 333 s | 0.4.12 | 已⁠是⁠最⁠优 | +| 5070 Ti | 16 GB | 64 GB | 1184×672 | 15 s | 标⁠准 | 0.4.13 | 487 s | 0.4.13 | 已⁠是⁠最⁠优 | - 用时:从提交到视频保存完成的端到端用时。 - 优化后:预估用时,根据报告中的分段用时,结合测试机(Windows RTX 3090 和 RTX 5060 Ti、Linux RTX PRO 6000)在报告机器的显存和内存上限下运行完整规划的实测推算,不是在报告机器上重跑。 diff --git "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" index 79955d80..12580964 100644 --- "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -47,7 +47,7 @@ ### 限制 - 两种做法一样,一条视频最多 5 段(第一段加 4 次接着生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 -- 接着生成使用轻量档,需要 NVIDIA 显卡,也不能和“继续二采”同时使用。 +- 接着生成使用轻量档,需要 NVIDIA 显卡或 Mac,也不能和“继续二采”同时使用。 - 单次生成的最长时长随画幅和显卡而不同,在创作面板的“可生成范围”中可查看各尺寸的最长时长和预计用时。 -- 分段生成同样需要 NVIDIA 显卡,不支持 Mac。 +- 分段生成同样需要 NVIDIA 显卡或 Mac。 - 分段生成过程中取消,只会停止当前这一段,已经完成的段落会保留,之后可以从第一个未完成的段落继续。 diff --git a/freevideo_ed25519.py b/freevideo_ed25519.py new file mode 100644 index 00000000..5aa0754d --- /dev/null +++ b/freevideo_ed25519.py @@ -0,0 +1,95 @@ +"""Dependency-free Ed25519 (RFC 8032), with strict encodings/subgroup checks. + +Verification handles public inputs. Signing is an offline release utility, not +constant-time: keep signing seeds on a trusted release machine. +""" +import hashlib + +P = 2**255 - 19 +L = 2**252 + 27742317777372353535851937790883648493 +D = -121665 * pow(121666, P-2, P) % P +I = pow(2, (P-1)//4, P) +IDENTITY = (0, 1, 1, 0) + + +def _decode(data): + if len(data) != 32: + raise ValueError('Invalid point length') + encoded = int.from_bytes(data, 'little') + y, sign = encoded & (2**255-1), encoded >> 255 + if y >= P: + raise ValueError('Noncanonical point') + x2 = (y*y-1) * pow(D*y*y+1, P-2, P) % P + x = pow(x2, (P+3)//8, P) + if x*x % P != x2: + x = x*I % P + if x*x % P != x2 or (x == 0 and sign): + raise ValueError('Invalid point') + if x & 1 != sign: + x = P-x + return x, y, 1, x*y % P + + +def _add(a, b): + x, y, z, t = a + u, v, w, s = b + aa, bb = (y-x)*(v-u) % P, (y+x)*(v+u) % P + cc, dd = 2*D*t*s % P, 2*z*w % P + e, f, g, h = bb-aa, dd-cc, dd+cc, bb+aa + return e*f % P, g*h % P, f*g % P, e*h % P + + +def _mul(point, scalar): + result = IDENTITY + while scalar: + if scalar & 1: + result = _add(result, point) + point = _add(point, point) + scalar >>= 1 + return result + + +def _encode(point): + x, y, z, _ = point + inverse = pow(z, P-2, P) + return ((y*inverse % P) | ((x*inverse % P & 1) << 255)).to_bytes(32, 'little') + + +B = _decode(bytes.fromhex('58' + '66'*31)) + + +def verify(public_key, message, signature): + try: + if len(public_key) != 32 or len(signature) != 64: + return False + a, r = _decode(public_key), _decode(signature[:32]) + scalar = int.from_bytes(signature[32:], 'little') + neutral = _encode(IDENTITY) + if scalar >= L or any(_encode(q) == neutral or _encode(_mul(q, L)) != neutral for q in (a, r)): + return False + challenge = int.from_bytes(hashlib.sha512(signature[:32]+public_key+message).digest(), 'little') % L + return _encode(_mul(B, scalar)) == _encode(_add(r, _mul(a, challenge))) + except (ValueError, TypeError, OverflowError): + return False + + +def _secret(seed): + if len(seed) != 32: + raise ValueError('Ed25519 seed must contain 32 bytes') + hashed = hashlib.sha512(seed).digest() + scalar = (int.from_bytes(hashed[:32], 'little') & (2**254-8)) | 2**254 + return scalar, hashed[32:] + + +def public_key(seed): + scalar, _ = _secret(seed) + return _encode(_mul(B, scalar)) + + +def sign(seed, message): + scalar, prefix = _secret(seed) + public = _encode(_mul(B, scalar)) + nonce = int.from_bytes(hashlib.sha512(prefix+message).digest(), 'little') % L + r = _encode(_mul(B, nonce)) + challenge = int.from_bytes(hashlib.sha512(r+public+message).digest(), 'little') % L + return r + ((nonce+challenge*scalar) % L).to_bytes(32, 'little') diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index c274f10b..e5dabc94 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -235,6 +235,10 @@ 'continuing again and again is not recommended. One video has at most 5 parts (the first and 4 ' 'continuations), each continued part at least 10 seconds, about 70 seconds in all; filling that is not ' 'recommended. Past it, cut to a new shot.', + 'A question is not a request. When the user asks about progress or status ("is it working?", "what happened?") ' + 'answer it in one sentence from what you know or from the job and white-model status, and start nothing; never ' + 'use a question as a prompt. When a row carries "whitebox", the user was starting a white-model scene that is ' + 'still being prepared or failed: say which, and wait for the answer.', ] @@ -265,6 +269,9 @@ '默认切镜头。能切镜头的地方就切:新镜头直接用对应的参考图(人物卡、场景图)生成,不接上一段视频。' '只有不能切镜的长镜头、必须连贯的动作、必须连贯的声音,才接着上一段生成,而且不建议一段接一段地连续接。' '一条视频最多 5 段(第一段加 4 次接着生成),接着生成的每一段至少 10 秒,总长约 70 秒;不建议接满,超过就切镜头。', + '问话不是要求。用户问进度或状态(“在做吗”“怎么了”)时,用一句话照实回答,依据是你所知道的、任务和白模的状态,' + '什么都不要开始;不要把问句当提示词。某条 inbox 带有 whitebox 时,说明用户刚启动了白模场景,' + '它还在准备或已经失败:照实说是哪一种,再等用户回话。', ] @@ -480,7 +487,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): '不要停下来问用户——你一停,用户在 FreeVideo 里说的话就没人听了。' '用命令行调接口时,每次等待单独发一条命令(一条命令里不写循环,超时设到 60 秒以上),' '返回后接着发下一条。' - '收到话(inbox 里每条的 text)先回一句再动手,例如“好的,开始生成。”:只一句,不复述尺寸、' + '收到要求(inbox 里每条的 text)先回一句再动手,例如“好的,开始生成。”:只一句,不复述尺寸、' '时长这些参数,也不说预计时间(任务卡上都有)。做完再说一句短的,例如“好了。”,' '或者一句用户需要知道的话,发回同一个 session,再接着等。先把话发出去,再调等待接口;不要写成“等待返回后才发话”,那样这句话会晚到将近一分钟。中文用全角标点。' '做完一条视频也不是结尾:这句话就是汇报,不要再在自己窗口里写总结(一写总结这一轮就结束了,' @@ -498,7 +505,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'user, because once you stop nobody hears what the user says in FreeVideo. From a command line, ' 'each wait is its own command (no loop inside one command, timeout above 60 seconds); when it ' 'returns, run the next one. ' - 'When words arrive (text of each inbox row), answer with one sentence before you start, for example ' + 'When a request arrives (text of each inbox row; a question about progress is answered, not started), answer with one sentence before you start, for example ' '"OK, starting.": one sentence only, without repeating size or length and without an estimate (the ' 'job card shows them). When it is done say one short sentence, for example "Done.", or one thing the ' 'user needs to know, to the same session, and wait again. Post your words first and call the wait after; never wait and then post, or they reach the user up to a minute late. Chinese text takes full-width punctuation. ' diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index 6d25a5a4..8328b1c6 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -1841,6 +1841,9 @@ def start_image(self, row): else: action = 'generate' value.update(prompt=spec['prompt'], images=[image_reference(self.port, name) for name in spec['images']]) + metadata = spec.get('metadata') or {} + if metadata.get('cast_card') and metadata.get('cast_part') == 'back': + value['cast_view'] = 'back' if row.get('_cancel'): raise JobError('cancelled', IMAGE_ERRORS['cancelled']) row['_image'] = self.port.image_start(action, value) diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 74f7eb73..1b5995c9 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -45,6 +45,10 @@ RESUMED = ('This conversation is already under way; its last lines are in "before". Do not introduce yourself again: ' 'say nothing until the user speaks, then carry on from there.') BEFORE_LINES, BEFORE_TEXT = 8, 600 +# The user started a white-model scene and then spoke: the words may be about it, not a new request. +WHITEBOX_PENDING = ('The user started a white-model scene in this conversation ("whitebox" lists it). If the words ask about ' + 'it or about progress, answer from its status in one sentence (waiting or rendering: still being made; ' + 'failed: say it was not made, and why) and start nothing. A question is never a prompt.') def local_address(request): @@ -1074,12 +1078,24 @@ def write(): raise JobError('not_found', 'No conversation') # Work in progress still writes into its conversation; finished videos stay in the library. # New work is refused first, so what is counted below cannot grow before the file goes. + def busy(leaving=()): + return (any(r.get('session') == sid and r['status'] in ACTIVE and r['status'] not in leaving for r in jobs.rows()) + or any(f['session'] == sid and not f['paused'] + for f in jobs.productions.list(status='active', limit=None))) + stop = value.get('stop') is True + if stop: # The user confirmed: stop its jobs and pause its films first, as the stop button does. + jobs.stop_session(sid) + for _ in range(20): # A running job takes a moment to reach its cancelled state. + with jobs.lock: + if not busy(): + break + time.sleep(0.25) store.begin_delete(sid) try: with jobs.lock: # Productions share it. - if (any(r.get('session') == sid and r['status'] in ACTIVE for r in jobs.rows()) - or any(f['session'] == sid and not f['paused'] - for f in jobs.productions.list(status='active', limit=None))): + # After the user's stop, a job still winding down no longer holds the conversation: its + # writes are refused once deletion begins, so a stuck one cannot block deleting forever. + if busy(('cancelling',) if stop else ()): raise JobError('conflict', 'Work is in progress in this conversation') store.delete(sid) except BaseException: @@ -1725,6 +1741,10 @@ async def inbox(request): follow = store is not None and not request.headers.get('X-FreeVideo-Session', '').strip() def answer(value): told, chat['told'][client] = chat['told'].get(client), value['session'] + scenes = jobs.whitebox.open_for(value['session']) if value['inbox'] else [] + if scenes: + value['whitebox'] = scenes + value['next'] = (WHITEBOX_PENDING + ' ' + value.get('next', '')).strip() if follow and told != value['session']: first = value['inbox'][0]['seq'] if value['inbox'] else store.get(value['session'])['last_seq'] + 1 if first > 1: diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index 79fa2b4d..d49a475d 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -437,7 +437,7 @@ def route(summary, body=None, query=None, **kw): ('/activity/cancel', 'POST', obj({'id': JOB}, ('id',))), ('/connect', 'GET', None), ('/connect/reset', 'POST', EMPTY), ('/chat', 'GET', None), ('/chat/now', 'GET', None), ('/chat/video', 'GET', None), ('/chat/list', 'GET', None), ('/chat/new', 'POST', EMPTY), - ('/chat/delete', 'POST', obj({'session': SESSION}, ('session',))), + ('/chat/delete', 'POST', obj({'session': SESSION, 'stop': {'type': 'boolean'}}, ('session',))), ('/chat', 'POST', obj({'text': string(8000), 'id': CHAT_ID, 'session': SESSION, 'whitebox': {'type': 'boolean'}}, ('text', 'id'))), ('/chat/confirm', 'POST', obj({'id': string(12, pattern=r'^cf[0-9a-f]{10}$'), 'approve': {'type': 'boolean'}, 'session': SESSION}, ('id', 'approve')))): diff --git a/freevideo_engine/agent/whitebox.py b/freevideo_engine/agent/whitebox.py index 95a157bd..16b6fb9d 100644 --- a/freevideo_engine/agent/whitebox.py +++ b/freevideo_engine/agent/whitebox.py @@ -127,6 +127,18 @@ def create(self, code, summary, session=None, notice=None): self._save(record) return self.view(record) + def open_for(self, session): + """The scenes this conversation started that are still being made, or failed without the user being told.""" + with self.lock: + rows = [] + for record in sorted(self.records.values(), key=lambda r: r['created_at']): + self._expire(record) + if record.get('session') == session and record.get('notice') and record['status'] != 'ready' \ + and self.clock() - record['created_at'] < 1800: + rows.append(dict(id=record['id'], status=record['status'], summary=record['summary'], + **(dict(error=record['error']) if record['status'] == 'failed' else {}))) + return rows + def take_notice(self, identity): """What the server should tell the user's conversation once the scene is ready; handed out once.""" with self.lock: diff --git a/freevideo_engine/agent/whitebox_presets.py b/freevideo_engine/agent/whitebox_presets.py index 5ea8f0d1..450d1465 100644 --- a/freevideo_engine/agent/whitebox_presets.py +++ b/freevideo_engine/agent/whitebox_presets.py @@ -1,24 +1,9 @@ """Ready-made white-model scenes (scene code written to the scene prompt); 'try' and the built-in AI render one without a model writing code.""" PRESETS = ( - dict(id='plane', name='纸飞机飞出教室窗外', shot='一架纸飞机从教室后排飞过一排排课桌,穿过敞开的窗户飞向窗外的操场,镜头跟在纸飞机后面。', - prompt='A realistic classroom scene: a white paper airplane glides from the back of the classroom over rows of wooden desks and blue chairs, passes through the open window in the far wall and flies out over a sunny sports field with a running track, while the camera follows just behind the plane. Natural daylight, realistic materials, smooth camera motion. Soft ambient classroom sounds fading into a light outdoor breeze.', - code="export function build(THREE) {\n const scene = new THREE.Scene();\n scene.background = new THREE.Color(0xb7d7f0);\n const camera = new THREE.PerspectiveCamera(65, 960 / 544, 0.1, 200);\n const std = c => new THREE.MeshStandardMaterial({ color: c, roughness: 1, side: THREE.DoubleSide });\n const add = (geo, mat, x, y, z, name) => {\n const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.name = name || '';\n m.castShadow = true; m.receiveShadow = true; scene.add(m); return m;\n };\n scene.add(new THREE.HemisphereLight(0xffffff, 0x8a7f70, 1.2));\n const sun = new THREE.DirectionalLight(0xfff4e0, 2.0);\n sun.position.set(12, 10, -4); sun.castShadow = true;\n sun.shadow.camera.left = -15; sun.shadow.camera.right = 15; sun.shadow.camera.top = 15; sun.shadow.camera.bottom = -15;\n sun.shadow.mapSize.set(2048, 2048); scene.add(sun);\n // room x -4..4, z 0..14 (window wall at z = 0), height 3\n add(new THREE.PlaneGeometry(8, 14).rotateX(-Math.PI / 2), std(0xb08a5e), 0, 0, 7, 'floor');\n add(new THREE.PlaneGeometry(8, 14).rotateX(Math.PI / 2), std(0xffffff), 0, 3, 7, 'ceiling');\n add(new THREE.BoxGeometry(0.2, 3, 14), std(0xe6dfc8), -4, 1.5, 7, 'wall_left');\n add(new THREE.BoxGeometry(0.2, 3, 14), std(0xe6dfc8), 4, 1.5, 7, 'wall_right');\n add(new THREE.BoxGeometry(8, 3, 0.2), std(0xe6dfc8), 0, 1.5, 14, 'wall_back');\n // window wall with a 2.4 x 1.4 opening, sill at 1.0\n add(new THREE.BoxGeometry(8, 1.0, 0.2), std(0xe6dfc8), 0, 0.5, 0, 'wall_below');\n add(new THREE.BoxGeometry(8, 0.6, 0.2), std(0xe6dfc8), 0, 2.7, 0, 'wall_above');\n add(new THREE.BoxGeometry(2.8, 1.4, 0.2), std(0xe6dfc8), -2.6, 1.7, 0, 'wall_l');\n add(new THREE.BoxGeometry(2.8, 1.4, 0.2), std(0xe6dfc8), 2.6, 1.7, 0, 'wall_r');\n add(new THREE.BoxGeometry(2.4, 0.05, 0.3), std(0xffffff), 0, 1.0, 0, 'sill');\n add(new THREE.BoxGeometry(5.5, 1.2, 0.05), std(0x2f5b43), -1.5, 1.6, 13.85, 'blackboard');\n // desks: 3 columns x 4 rows\n for (let c = 0; c < 3; c++) for (let r = 0; r < 4; r++) {\n const x = -2.4 + c * 2.4, z = 4 + r * 2.2;\n add(new THREE.BoxGeometry(1.1, 0.06, 0.6), std(0xc98f4f), x, 0.75, z, 'desk_top');\n add(new THREE.BoxGeometry(0.06, 0.75, 0.06), std(0x777777), x - 0.5, 0.375, z - 0.25, 'desk_leg');\n add(new THREE.BoxGeometry(0.06, 0.75, 0.06), std(0x777777), x + 0.5, 0.375, z + 0.25, 'desk_leg');\n add(new THREE.BoxGeometry(0.45, 0.05, 0.45), std(0x4d7cc9), x, 0.45, z + 0.7, 'chair_seat');\n add(new THREE.BoxGeometry(0.45, 0.45, 0.05), std(0x4d7cc9), x, 0.7, z + 0.92, 'chair_back');\n }\n // outside: grass, running track, goal\n add(new THREE.PlaneGeometry(120, 120).rotateX(-Math.PI / 2), std(0x6fae56), 0, -0.02, -50, 'field');\n add(new THREE.PlaneGeometry(30, 4).rotateX(-Math.PI / 2), std(0xc4654a), 0, 0.0, -12, 'track');\n add(new THREE.BoxGeometry(6, 3, 0.2), std(0xffffff), 0, 1.5, -36, 'goal');\n const plane = new THREE.Group(); plane.name = 'hero_paper_plane'; scene.add(plane);\n const wing = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.5, 3).rotateX(-Math.PI / 2).scale(1.6, 0.35, 1), std(0xffffff));\n wing.castShadow = true; plane.add(wing);\n const ss = x => x * x * (3 - 2 * x);\n const pts = [[0.4, 1.7, 11.5], [0.2, 1.55, 8.5], [-0.3, 1.5, 6.0], [0.0, 1.55, 2.5], [0.0, 1.6, 0.0], [0.3, 1.5, -4], [0.8, 1.2, -10], [1.4, 0.9, -16]]\n .map(p => new THREE.Vector3(...p));\n const curve = new THREE.CatmullRomCurve3(pts, false, 'centripetal');\n const at = t => curve.getPoint(ss(Math.min(1, t / 5)) * 0.6 + Math.min(1, t / 5) * 0.4);\n return {\n scene, camera,\n update(t) {\n const p = at(t), q = at(t + 0.05);\n plane.position.copy(p); plane.lookAt(q); plane.rotateZ(0.25 * Math.sin(t * 2));\n const behind = at(Math.max(0, t - 0.45));\n camera.position.set(behind.x * 0.5, behind.y + 0.4, behind.z + 1.5);\n camera.lookAt(p.x, p.y, p.z - 2);\n },\n };\n}\n"), - dict(id='coaster', name='第一人称过山车俯冲', shot='第一人称过山车:先爬到最高点停顿,然后俯冲而下,穿过一个大回环,最后冲向远处的游乐园。', - prompt='A first-person roller coaster ride at a real amusement park: the cart climbs a steep red steel track to the very top, pauses for a heartbeat, then plunges down in a steep drop, whips through a large vertical loop, and races out toward the colourful rides and a Ferris wheel in the distance. Bright sunny day, green trees, realistic steel and paint, strong sense of speed, smooth camera. Rushing wind and the clatter of the wheels on the track.', - code='export function build(THREE) {\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(65, 960 / 544, 0.1, 300);\n const std = c => new THREE.MeshStandardMaterial({ color: c, roughness: 1 });\n const add = (geo, mat, x, y, z, name, parent) => {\n const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.name = name || "";\n m.castShadow = true; m.receiveShadow = true; (parent || scene).add(m); return m;\n };\n const ss = x => x * x * (3 - 2 * x);\n const lerp = (a, b, u) => a + (b - a) * u;\n const clamp01 = x => Math.min(1, Math.max(0, x));\n const sun = new THREE.DirectionalLight(0xffffff, 1.8);\n sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);\n sun.shadow.camera.left = -60; sun.shadow.camera.right = 60; sun.shadow.camera.top = 60; sun.shadow.camera.bottom = -60; sun.shadow.camera.far = 200;\n scene.add(sun);\n\n scene.background = new THREE.Color(0xbfdcf0);\n scene.add(new THREE.HemisphereLight(0xdfeeff, 0x6a7a5a, 1.1)); sun.position.set(-40, 80, 30);\n add(new THREE.PlaneGeometry(600, 600).rotateX(-Math.PI / 2), std(0x7fa86a), 0, 0, 0, "ground");\n // track as a curve: climb, crest, drop, loop, run-out\n const pts = [[0,2,60],[0,12,40],[0,30,20],[0,44,6],[0,46,-4],[0,40,-12],[0,18,-24],[0,4,-34],[0,3,-46],[0,10,-56],[0,18,-50],[0,14,-40],[0,3,-48],[0,2,-70],[0,2,-110]];\n const curve = new THREE.CatmullRomCurve3(pts.map(p => new THREE.Vector3(...p)));\n const N = 120;\n for (let i = 0; i < N; i++) {\n const p = curve.getPoint(i / N), q = curve.getPoint((i + 1) / N);\n const seg = add(new THREE.BoxGeometry(2.6, 0.25, 1.1 * p.distanceTo(q) + 0.2), std(0xd23b2e), p.x, p.y, p.z, "track");\n seg.lookAt(q);\n if (i % 6 === 0 && p.y > 2.5) add(new THREE.BoxGeometry(0.35, p.y, 0.35), std(0xe8e8e8), p.x, p.y / 2 - 0.2, p.z, "pillar");\n }\n for (let i = 0; i < 60; i++) { const a = i * 2.399, r = 20 + (i * 13) % 70; add(new THREE.ConeGeometry(2.2, 7, 6), std(0x3f7a3a), Math.cos(a) * r, 3.5, -40 + Math.sin(a) * r * 1.6, "tree"); }\n // distant park\n const cols = [0xf2c14e, 0x4e9bd6, 0xd65e8b, 0x8bd64e];\n for (let i = 0; i < 14; i++) add(new THREE.BoxGeometry(5 + i % 3, 3 + (i * 5) % 6, 5), std(cols[i % 4]), -30 + i * 5, 2, -150 - (i % 3) * 8, "park");\n const wheel = add(new THREE.TorusGeometry(12, 0.5, 6, 20), std(0xf0f0f0), 30, 14, -140, "ferris");\n add(new THREE.ConeGeometry(2.5, 5, 8), std(0xe8412c), -20, 6, -125, "tent");\n const hero = new THREE.Group(); hero.name = "hero_cart"; scene.add(hero);\n add(new THREE.BoxGeometry(1.6, 0.6, 2.4), std(0xf2c14e), 0, 0, 0, "hero_cart_body", hero);\n const ease = u => u < 0.28 ? 0.55 * (u / 0.28) * 0.5 * 0 + ss(u / 0.28) * 0.46 : u < 0.36 ? 0.46 + (u - 0.28) / 0.08 * 0.02 : 0.48 + ss((u - 0.36) / 0.64) * 0.52;\n return { scene, camera, update(t) {\n const u = ease(clamp01(t / 5));\n wheel.rotation.z = t * 0.2;\n const p = curve.getPoint(u * 0.98), look = curve.getPoint(Math.min(1, u * 0.98 + 0.03));\n hero.position.copy(p); hero.lookAt(look);\n const dir = look.clone().sub(p).normalize(); dir.y *= 0.55; camera.position.set(p.x, p.y + 1.1, p.z); camera.lookAt(p.x + dir.x * 10, p.y + 1.1 + dir.y * 10, p.z + dir.z * 10);\n camera.rotation.z = 0;\n } };\n}\n'), - dict(id='drone', name='无人机穿过楼间再拉升', shot='无人机贴着两排高楼之间的街道低空飞行,随后拉升冲出楼顶,镜头跟在无人机后上方。', - prompt="A cinematic drone shot in a real city: a small red quadcopter drone flies low along a narrow street between two rows of tall concrete and glass buildings, then climbs steeply and bursts out above the rooftops into the open blue sky, while the camera follows behind and above the drone. Natural sunlight, soft shadows, realistic materials, smooth camera motion. Ambient city wind sound and the soft hum of the drone's rotors.", - code="export function build(THREE) {\n const scene = new THREE.Scene();\n scene.background = new THREE.Color(0xbcd6ea);\n const camera = new THREE.PerspectiveCamera(60, 960 / 544, 0.1, 200);\n const std = c => new THREE.MeshStandardMaterial({ color: c, roughness: 1 });\n const add = (geo, mat, x, y, z, name) => {\n const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.name = name || '';\n m.castShadow = true; m.receiveShadow = true; scene.add(m); return m;\n };\n scene.add(new THREE.HemisphereLight(0xdfeeff, 0x666a70, 1.1));\n const sun = new THREE.DirectionalLight(0xffffff, 1.8);\n sun.position.set(-30, 50, 20); sun.castShadow = true;\n sun.shadow.camera.left = -60; sun.shadow.camera.right = 60; sun.shadow.camera.top = 60; sun.shadow.camera.bottom = -60;\n sun.shadow.camera.far = 150; sun.shadow.mapSize.set(2048, 2048);\n scene.add(sun);\n add(new THREE.PlaneGeometry(200, 200).rotateX(-Math.PI / 2), std(0x5d6168), 0, 0, 0, 'ground');\n add(new THREE.PlaneGeometry(9, 200).rotateX(-Math.PI / 2), std(0x3b3e44), 0, 0.02, 0, 'street');\n const palette = [0xc9b8a0, 0x9fb1c4, 0xd6cfc4, 0xa8a39a, 0xb9c4b0];\n for (let side of [-1, 1]) {\n for (let i = 0; i < 9; i++) {\n const z = -80 + i * 17 + (side > 0 ? 6 : 0);\n const h = 14 + ((i * 7 + (side > 0 ? 3 : 0)) % 5) * 5;\n add(new THREE.BoxGeometry(14, h, 14), std(palette[(i + (side > 0 ? 2 : 0)) % 5]), side * 12.5, h / 2, z, 'building');\n }\n }\n const hero = new THREE.Group(); hero.name = 'hero_drone'; scene.add(hero);\n const body = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.25, 0.9), std(0xe8412c)); body.castShadow = true; hero.add(body);\n for (const [x, z] of [[-0.7, -0.7], [0.7, -0.7], [-0.7, 0.7], [0.7, 0.7]]) {\n const rotor = new THREE.Mesh(new THREE.CylinderGeometry(0.4, 0.4, 0.03, 12), std(0x222222));\n rotor.position.set(x, 0.17, z); hero.add(rotor);\n }\n const ss = x => x * x * (3 - 2 * x);\n const lerp = (a, b, u) => a + (b - a) * u;\n const heroAt = t => {\n const u = Math.min(1, t / 5);\n const z = lerp(60, -60, u);\n const rise = ss(Math.min(1, Math.max(0, (u - 0.55) / 0.35)));\n return new THREE.Vector3(1.5 * Math.sin(u * 6), lerp(3.2, 42, rise), z);\n };\n return {\n scene, camera,\n update(t) {\n const p = heroAt(t);\n hero.position.copy(p);\n const ahead = heroAt(t + 0.1);\n hero.rotation.set(0.25 * Math.min(1, (ahead.z - p.z) / -0.1 * 0.02), 0, 0);\n hero.rotation.x = -0.18; hero.rotation.z = 0.12 * Math.sin(t * 1.3);\n const u = Math.min(1, t / 5);\n const lift = ss(Math.min(1, Math.max(0, (u - 0.55) / 0.35)));\n camera.position.set(p.x * 0.4, p.y + 2.2 + 3 * lift, p.z + 9 + 2 * lift);\n camera.lookAt(p.x, p.y + 0.3, p.z - 4);\n },\n };\n}\n"), dict(id='cat', name='橘猫跳上书架', shot='一只橘猫在客厅地板上蹲下蓄力,沿着错落的书架层板一级一级跳上去,最后蹲在最高一层,镜头侧面平移并略微上摇跟随。', prompt='A ginger cat crouches on a living room floor, gathers itself and leaps up a staggered wooden bookshelf, jumping from one shelf to the next between rows of books, and finally settles on the top shelf, while the camera pans sideways and tilts up slightly to follow. Warm indoor daylight, realistic fur and wood, soft shadows, smooth camera motion. Soft room tone and the light thud of paws on wood.', code='export function build(THREE) {\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(65, 960 / 544, 0.1, 300);\n const std = c => new THREE.MeshStandardMaterial({ color: c, roughness: 1 });\n const add = (geo, mat, x, y, z, name, parent) => {\n const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.name = name || "";\n m.castShadow = true; m.receiveShadow = true; (parent || scene).add(m); return m;\n };\n const ss = x => x * x * (3 - 2 * x);\n const lerp = (a, b, u) => a + (b - a) * u;\n const clamp01 = x => Math.min(1, Math.max(0, x));\n const sun = new THREE.DirectionalLight(0xffffff, 1.8);\n sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);\n sun.shadow.camera.left = -60; sun.shadow.camera.right = 60; sun.shadow.camera.top = 60; sun.shadow.camera.bottom = -60; sun.shadow.camera.far = 200;\n scene.add(sun);\n\n scene.background = new THREE.Color(0xe9e2d6);\n scene.add(new THREE.HemisphereLight(0xfff4e6, 0x8a7f72, 1.2)); sun.position.set(6, 10, 8);\n add(new THREE.PlaneGeometry(14, 10).rotateX(-Math.PI / 2), std(0xb88f65), 0, 0, 0, "floor");\n add(new THREE.BoxGeometry(14, 4, 0.2), std(0xe9e2d6), 0, 2, -1.2, "wall");\n // bookshelf: two side boards, staggered shelves\n add(new THREE.BoxGeometry(0.1, 2.6, 0.5), std(0x7a5232), -1.4, 1.3, -0.9, "side");\n add(new THREE.BoxGeometry(0.1, 2.6, 0.5), std(0x7a5232), 1.4, 1.3, -0.9, "side");\n const shelves = [[0.6, -0.9], [1.2, -0.4], [1.8, 0.2], [2.4, 0.6]];\n const stepsX = [-0.9, -0.2, 0.5, 1.0], ys = [0.55, 1.05, 1.55, 2.1];\n for (let i = 0; i < 4; i++) {\n add(new THREE.BoxGeometry(1.0, 0.06, 0.5), std(0x9a6b3f), stepsX[i], ys[i] - 0.03, -0.9, "shelf");\n for (let k = 0; k < 3; k++) add(new THREE.BoxGeometry(0.12, 0.28, 0.3), std([0x3c6ea8, 0xb04a4a, 0x4a8a5a][(i + k) % 3]), stepsX[i] - 0.3 + k * 0.2, ys[i] + 0.14, -1.0, "book");\n }\n add(new THREE.BoxGeometry(0.9, 0.5, 0.5), std(0x6b7b8c), -2.6, 0.25, 0.6, "sofa_block");\n const cat = new THREE.Group(); cat.name = "hero_cat"; scene.add(cat);\n const orange = std(0xe58a2e);\n const body = add(new THREE.BoxGeometry(0.42, 0.22, 0.2), orange, 0, 0.18, 0, "hero_cat_body", cat);\n add(new THREE.SphereGeometry(0.11, 10, 8), orange, 0.26, 0.28, 0, "hero_cat_head", cat);\n add(new THREE.ConeGeometry(0.035, 0.08, 4), orange, 0.28, 0.4, 0.06, "hero_cat_ear", cat);\n add(new THREE.ConeGeometry(0.035, 0.08, 4), orange, 0.28, 0.4, -0.06, "hero_cat_ear", cat);\n const tail = add(new THREE.CylinderGeometry(0.03, 0.03, 0.4, 6), orange, -0.34, 0.3, 0, "hero_cat_tail", cat);\n tail.rotation.z = 0.8;\n // waypoints: floor crouch, jump to each shelf\n const wp = [[-1.9, 0.0, 0.1], [-1.9, 0.0, 0.1], [stepsX[0], ys[0], -0.9], [stepsX[1], ys[1], -0.9], [stepsX[2], ys[2], -0.9], [stepsX[3], ys[3], -0.9], [stepsX[3], ys[3], -0.9]];\n const times = [0, 0.7, 1.4, 2.3, 3.2, 4.1, 5.0];\n const pos = t => { let i = 0; while (i < 5 && t > times[i + 1]) i++;\n const u = clamp01((t - times[i]) / (times[i + 1] - times[i])); const a = wp[i], b = wp[i + 1];\n const arc = (i >= 1 && i <= 4) ? Math.sin(Math.PI * u) * 0.45 : 0;\n const e = ss(u); return new THREE.Vector3(lerp(a[0], b[0], e), lerp(a[1], b[1], u) + arc, lerp(a[2], b[2], e)); };\n return { scene, camera, update(t) {\n const p = pos(t); cat.position.copy(p);\n cat.scale.y = (t < 0.7) ? 1 - 0.35 * Math.sin(Math.PI * clamp01(t / 0.7)) : 1;\n cat.rotation.z = (t > 0.7 && t < 4.1) ? 0.35 : 0;\n camera.position.set(p.x * 0.6, 0.9 + p.y * 0.5, 2.7);\n camera.lookAt(p.x * 0.85, 0.35 + p.y * 0.85, -0.9);\n } };\n}\n'), - dict(id='car', name='赛车入弯漂移环绕', shot='一辆红色赛车在直道尽头高速入弯,车尾漂移甩出,镜头围着赛车做半圈环绕,最后跟着车冲出弯道。', - prompt='A red race car hits the end of a straight at full speed and throws itself into a corner, the rear sliding out in a controlled drift with a little tyre smoke, while the camera orbits half a circle around the car and then follows it out of the bend. Sunny circuit with red and white kerbs, grandstands and green grass, realistic paint and tarmac, strong sense of speed, smooth camera. Roaring engine and the squeal of tyres.', - code='export function build(THREE) {\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(65, 960 / 544, 0.1, 300);\n const std = c => new THREE.MeshStandardMaterial({ color: c, roughness: 1 });\n const add = (geo, mat, x, y, z, name, parent) => {\n const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.name = name || "";\n m.castShadow = true; m.receiveShadow = true; (parent || scene).add(m); return m;\n };\n const ss = x => x * x * (3 - 2 * x);\n const lerp = (a, b, u) => a + (b - a) * u;\n const clamp01 = x => Math.min(1, Math.max(0, x));\n const sun = new THREE.DirectionalLight(0xffffff, 1.8);\n sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);\n sun.shadow.camera.left = -60; sun.shadow.camera.right = 60; sun.shadow.camera.top = 60; sun.shadow.camera.bottom = -60; sun.shadow.camera.far = 200;\n scene.add(sun);\n\n scene.background = new THREE.Color(0xbdd8ee);\n scene.add(new THREE.HemisphereLight(0xdfeeff, 0x6a7a5a, 1.1)); sun.position.set(-30, 60, 25);\n add(new THREE.PlaneGeometry(400, 400).rotateX(-Math.PI / 2), std(0x6f9a58), 0, 0, 0, "grass");\n // road: straight along -z then curving right (to +x)\n const R = 22, cz = -30;\n add(new THREE.BoxGeometry(10, 0.05, 80), std(0x3d4046), 0, 0.03, 10, "road");\n const arc = []; for (let i = 0; i <= 24; i++) { const a = Math.PI * 0.5 * i / 24; arc.push(i); \n add(new THREE.BoxGeometry(10, 0.05, 2 * R * Math.PI / 2 / 24 + 0.6), std(0x3d4046), R - R * Math.cos(a), 0.03, cz - R * Math.sin(a), "bend").rotation.y = -a; }\n add(new THREE.BoxGeometry(10, 0.05, 80), std(0x3d4046), R + 40, 0.03, cz - R, "road2").rotation.y = Math.PI / 2;\n for (let i = 0; i < 16; i++) { const a = Math.PI * 0.5 * i / 15; add(new THREE.BoxGeometry(0.6, 0.6, 0.6), std(i % 2 ? 0xffffff : 0xe8412c), (R + 6) - (R + 6) * Math.cos(a) , 0.3, cz - (R + 6) * Math.sin(a), "kerb"); }\n for (let i = 0; i < 8; i++) add(new THREE.BoxGeometry(3, 5, 3), std(0xc9b8a0), -25 + (i % 2) * 4, 2.5, -60 + i * 6, "stand");\n const hero = new THREE.Group(); hero.name = "hero_car"; scene.add(hero);\n add(new THREE.BoxGeometry(1.8, 0.55, 4.2), std(0xd9261c), 0, 0.5, 0, "hero_car_body", hero);\n add(new THREE.BoxGeometry(1.3, 0.45, 1.6), std(0x20242a), 0, 0.95, -0.2, "hero_car_cabin", hero);\n for (const [x, z] of [[-1, -1.4], [1, -1.4], [-1, 1.4], [1, 1.4]]) { const w = add(new THREE.CylinderGeometry(0.4, 0.4, 0.3, 12), std(0x111111), x, 0.4, z, "hero_car_wheel", hero); w.rotation.z = Math.PI / 2; }\n const path = t => { const u = clamp01(t / 5); const s = u * (80 + R * Math.PI / 2 + 30) - 40; // arclength from z=40\n if (s < 40 - 0) return { x: 0, z: 40 - s, h: Math.PI }; // heading -z (rotation.y = PI points model -z? model faces -z when rotation 0)\n return null; };\n const pos = t => { const u = clamp01(t / 5), L1 = 70, L2 = R * Math.PI / 2, L3 = 40; const s = ss(u * 0.2 + u * 0.8) * (L1 + L2 + L3);\n if (s < L1) return { x: 0, z: 40 - s, yaw: 0 };\n if (s < L1 + L2) { const a = (s - L1) / R; return { x: R - R * Math.cos(a), z: cz - R * Math.sin(a) + 0, yaw: -a }; }\n const d = s - L1 - L2; return { x: R + d, z: cz - R, yaw: -Math.PI / 2 }; };\n return { scene, camera, update(t) {\n const p = pos(t); const drift = Math.sin(Math.PI * clamp01((t - 1.2) / 2.4)) * 0.55;\n hero.position.set(p.x, 0, p.z); hero.rotation.y = p.yaw - drift;\n const orbit = Math.PI * 0.9 * ss(clamp01((t - 1.0) / 3.0));\n const ang = Math.PI * 0.85 - orbit + p.yaw * -0.0;\n const rad = 9 - 2 * Math.sin(Math.PI * clamp01(t / 5));\n camera.position.set(p.x + Math.sin(ang + (p.yaw)) * rad * -1 * -1, 2.2 + 1.5 * Math.sin(Math.PI * clamp01(t / 5)), p.z + Math.cos(ang + p.yaw) * rad);\n camera.lookAt(p.x, 0.8, p.z);\n } };\n}\n'), - dict(id='door', name='推门长镜头', shot='短剧长镜头:镜头从走廊里缓缓推向一扇半掩的木门,推开门进入昏黄的客厅,一直推到沙发后的落地窗前停住。', - prompt='A short-drama long take: the camera glides down a dim hallway toward a half-open wooden door, pushes the door open and enters a warmly lit living room, and keeps moving forward past a sofa until it stops in front of the tall window at the far end. Warm tungsten light, soft evening glow from the window, realistic wood, fabric and plaster, smooth steady camera. Quiet room tone and a faint creak of the door.', - code='export function build(THREE) {\n const scene = new THREE.Scene();\n const camera = new THREE.PerspectiveCamera(65, 960 / 544, 0.1, 300);\n const std = c => new THREE.MeshStandardMaterial({ color: c, roughness: 1 });\n const add = (geo, mat, x, y, z, name, parent) => {\n const m = new THREE.Mesh(geo, mat); m.position.set(x, y, z); m.name = name || "";\n m.castShadow = true; m.receiveShadow = true; (parent || scene).add(m); return m;\n };\n const ss = x => x * x * (3 - 2 * x);\n const lerp = (a, b, u) => a + (b - a) * u;\n const clamp01 = x => Math.min(1, Math.max(0, x));\n const sun = new THREE.DirectionalLight(0xffffff, 1.8);\n sun.castShadow = true; sun.shadow.mapSize.set(2048, 2048);\n sun.shadow.camera.left = -60; sun.shadow.camera.right = 60; sun.shadow.camera.top = 60; sun.shadow.camera.bottom = -60; sun.shadow.camera.far = 200;\n scene.add(sun);\n\n scene.background = new THREE.Color(0x2b2622);\n scene.add(new THREE.HemisphereLight(0xffe9c8, 0x6a5a48, 1.8)); sun.position.set(-6, 8, -4); sun.intensity = 1.6;\n const wall = std(0xcfc4b0), floor = std(0x8a6a4a);\n // corridor z 6 .. 0, living room z 0 .. -9\n add(new THREE.PlaneGeometry(4, 6).rotateX(-Math.PI / 2), floor, 0, 0, 3, "corr_floor");\n add(new THREE.PlaneGeometry(7, 9).rotateX(-Math.PI / 2), floor, 0, 0, -4.5, "room_floor");\n add(new THREE.PlaneGeometry(7, 9).rotateX(Math.PI / 2), std(0xe4dccc), 0, 2.8, -4.5, "room_ceiling");\n add(new THREE.PlaneGeometry(4, 6).rotateX(Math.PI / 2), std(0xe4dccc), 0, 2.8, 3, "corr_ceiling");\n for (const x of [-2, 2]) add(new THREE.BoxGeometry(0.15, 2.8, 6), wall, x, 1.4, 3, "corr_wall");\n for (const x of [-3.5, 3.5]) add(new THREE.BoxGeometry(0.15, 2.8, 9), wall, x, 1.4, -4.5, "room_wall");\n add(new THREE.BoxGeometry(1.0, 0.7, 0.15), wall, 0, 2.45, 0, "door_lintel");\n add(new THREE.BoxGeometry(1.5, 2.8, 0.15), wall, -1.25, 1.4, 0, "door_wall_l");\n add(new THREE.BoxGeometry(1.5, 2.8, 0.15), wall, 1.25, 1.4, 0, "door_wall_r");\n // wall with window at far end\n add(new THREE.BoxGeometry(3.4, 2.8, 0.15), wall, -1.8, 1.4, -9, "far_l"); add(new THREE.BoxGeometry(3.4, 2.8, 0.15), wall, 1.8, 1.4, -9, "far_r");\n add(new THREE.BoxGeometry(0.2, 2.8, 0.15), wall, 0, 1.4, -9, "far_mid");\n add(new THREE.PlaneGeometry(0.1, 0.1), std(0xbfd8f0), 0, 1.4, -9.1, "sky_glow").scale.set(70, 28, 1);\n const hinge = new THREE.Group(); hinge.position.set(-0.5, 0, 0); scene.add(hinge);\n add(new THREE.BoxGeometry(1.0, 2.1, 0.06), std(0x6b4a2e), 0.5, 1.05, 0, "door", hinge);\n hinge.rotation.y = 0.3;\n add(new THREE.BoxGeometry(2.2, 0.8, 0.9), std(0x6b7f94), 0, 0.4, -6.2, "sofa");\n add(new THREE.BoxGeometry(2.2, 0.5, 0.2), std(0x6b7f94), 0, 0.95, -6.6, "sofa_back");\n add(new THREE.BoxGeometry(1.2, 0.35, 0.7), std(0x5a3a22), 0, 0.18, -4.6, "table");\n add(new THREE.CylinderGeometry(0.25, 0.25, 1.6, 10), std(0x3d6b45), 2.6, 0.8, -7.5, "plant");\n add(new THREE.BoxGeometry(0.9, 1.0, 0.1), std(0xb05a3a), -3.4, 1.6, -3, "picture");\n add(new THREE.BoxGeometry(0.5, 1.7, 0.4), std(0x2b2b30), -3.0, 0.85, -1.5, "hero_figure");\n return { scene, camera, update(t) {\n const u = clamp01(t / 5);\n hinge.rotation.y = 0.3 + 1.1 * ss(clamp01((t - 1.0) / 1.2));\n const z = lerp(5.5, -4.6, ss(u * 0.85 + 0.15 * u));\n camera.position.set(0.15 * Math.sin(u * 3), 1.55 - 0.1 * ss(clamp01((u - 0.6) / 0.4)), z);\n camera.lookAt(0.1 * Math.sin(u * 3 + 0.5), 1.4, z - 4);\n } };\n}\n'), ) diff --git a/freevideo_engine/comfy_launcher.py b/freevideo_engine/comfy_launcher.py index 345fd9ce..d3111c38 100644 --- a/freevideo_engine/comfy_launcher.py +++ b/freevideo_engine/comfy_launcher.py @@ -96,7 +96,7 @@ def __init__(self, window, source=None, controller=None, update_client=None): from .launcher_update import current_build, UpdateClient identity = current_build() if identity: - update_client = UpdateClient(identity, launcher_root()) + update_client = UpdateClient(identity, launcher_root(), language='zh' if self.zh else 'en') if update_client is not None: from .launcher_update_ui import UpdateUI self.updates = UpdateUI(self, update_client, self.update_row) @@ -587,9 +587,10 @@ def refresh(self): row['shortcut']['error'], '安装已完成,但无法创建桌面快捷方式。可继续使用启动器。\n' + row['shortcut']['error']) if self.error_text: self.copy_error_button.pack(side='left', padx=6) + self.report_error_button.pack(side='left', padx=6) self.review_card.pack(fill='x', pady=12) else: - self.copy_error_button.pack_forget(); self.review_card.pack_forget() + self.copy_error_button.pack_forget(); self.report_error_button.pack_forget(); self.review_card.pack_forget() self.review.configure(state='normal'); self.review.delete('1.0', 'end') self.review.insert('1.0', self.error_text) # Only as tall as the retained text, so a one-line failure is one line. @@ -691,6 +692,12 @@ def copy_error(self): self.window.clipboard_clear() self.window.clipboard_append(self.error_text) + def report_error(self): + if self.error_text: + from .launcher_issue import issue_url + from .windows_ux import open_browser + open_browser(issue_url(self.error_text, __version__, None, zh=self.zh)) + def show_details(self): dialog = self.tk.Toplevel(self.window); dialog.title(self.t('FreeVideo · Details', 'FreeVideo · 详情')) dialog.configure(bg=branding.BACKGROUND) diff --git a/freevideo_engine/comfy_media.py b/freevideo_engine/comfy_media.py index 2f8b8890..d9899926 100644 --- a/freevideo_engine/comfy_media.py +++ b/freevideo_engine/comfy_media.py @@ -2,6 +2,7 @@ import logging from pathlib import Path from .comfy_assets import REFERENCE_INPUTS +from .media_request import validate_early_steps def _image(value, path): @@ -127,6 +128,8 @@ def export(run, canvas, *, first=None, last=None, references=None, loras=None, c media['references'] = list(media.get('references', [])) for index, reference in enumerate(references): kind, value = reference['kind'], reference['value'] + if 'early_steps' in reference: + validate_early_steps(reference) path = run / ('reference-%02d' % index) if kind == 'image': path = path.with_suffix('.png') @@ -142,7 +145,10 @@ def export(run, canvas, *, first=None, last=None, references=None, loras=None, c _audio(value, path) else: raise ValueError('Unknown reference type') - media['references'].append({'kind': kind, 'path': str(path)}) + row = {'kind': kind, 'path': str(path)} + if 'early_steps' in reference: + row['early_steps'] = reference['early_steps'] + media['references'].append(row) if loras: media['loras'] = [dict(row) for row in loras] if conditioning is not None: diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index e1665b6a..c1326297 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -4,7 +4,7 @@ from comfy_api.latest import ComfyExtension, InputImpl, io, ui -from . import comfy_bridge +from . import comfy_bridge, workflow_repairs from .comfy_continuation import node_frames, optional_text, part_steps, request_options from .default_prompt import DEFAULT_PROMPT @@ -20,9 +20,12 @@ def clean_prompt(json_data): ComfyUI rejects '' or None for an integer input before validate_inputs runs, so the value is read here the same way the node reads it (node_frames): anything that is not a count the model - makes follows Duration. Links and other nodes are left as they are. + makes follows Duration. The optional inputs an older workflow holds in a form the node refuses + (workflow_repairs) fall back to their defaults and are noted for the diagnostic report. Links and + other nodes are left as they are. """ prompt = json_data.get('prompt') if isinstance(json_data, dict) else None + repaired = [] for node in prompt.values() if isinstance(prompt, dict) else (): inputs = node.get('inputs') if isinstance(node, dict) and node.get('class_type') == 'FreeVideoGenerate' else None if isinstance(inputs, dict) and 'frames' in inputs and not isinstance(inputs['frames'], list): @@ -31,6 +34,15 @@ def clean_prompt(json_data): logging.info('[FreeVideo] Total frames %r is not a frame count; the length follows Duration.', str(inputs['frames'])[:40]) inputs['frames'] = frames + if isinstance(inputs, dict): + repaired += workflow_repairs.repair_inputs(inputs) + if repaired: + logging.info('[FreeVideo] Optional inputs from an older workflow were reset to their defaults: %s.', + ', '.join(sorted(set(repaired)))) + try: + workflow_repairs.record(comfy_bridge.installation_root(), 'request', repaired) + except (OSError, ValueError) as error: + logging.warning('[FreeVideo] Could not note the reset inputs: %s', error) return json_data diff --git a/freevideo_engine/continuation.py b/freevideo_engine/continuation.py index 69730c5b..d3c285c7 100644 --- a/freevideo_engine/continuation.py +++ b/freevideo_engine/continuation.py @@ -34,7 +34,7 @@ SIZE_MISMATCH = 'Use the same aspect ratio as the previous video.' # Every refusal starts with UNSUPPORTED, then says what to change. UNSUPPORTED = 'Continue from video cannot be used with these settings.' -UNSUPPORTED_DEVICE = UNSUPPORTED + ' Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".' +UNSUPPORTED_DEVICE = UNSUPPORTED + ' Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".' UNSUPPORTED_PASSES = UNSUPPORTED + ' Turn on "Two-pass acceleration" and turn off the first-pass preview.' UNSUPPORTED_REFINE = UNSUPPORTED + ' Clear either "Second pass of preview" or "Continue from video".' UNSUPPORTED_MEDIA = UNSUPPORTED + ' Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.' @@ -75,8 +75,6 @@ def audio_context(): def supported(): """Platform capability, without loading Torch or probing a GPU.""" - if sys.platform == 'darwin': - return False, 'macos' from .paths import data_root try: machine = json.loads((data_root() / 'machine.json').read_text(encoding='utf-8')) @@ -84,7 +82,7 @@ def supported(): machine = {} if not isinstance(machine, dict): machine = {} - if machine.get('device_backend', 'cuda') != 'cuda': + if machine.get('device_backend', 'cuda') not in ('cuda', 'mps'): return False, 'unsupported_device' return True, None @@ -108,7 +106,7 @@ def validate_route(*, task='t2va', two_pass=True, preview=False, refine_from=Non valid_task = task == ('ref2va' if anchor else 't2va') if sound: valid_task = valid_reference and task in (('ref2va', 'ref2va_av') if anchor else ('t2va', 'ref2va', 'ref2va_audio')) - if not supported()[0] or backend not in (None, 'cuda'): + if not supported()[0] or backend not in (None, 'cuda', 'mps'): raise ValueError(UNSUPPORTED_DEVICE) if refine_from: raise ValueError(UNSUPPORTED_REFINE) diff --git a/freevideo_engine/desktop_runtime.py b/freevideo_engine/desktop_runtime.py index aa62606c..f03c7683 100644 --- a/freevideo_engine/desktop_runtime.py +++ b/freevideo_engine/desktop_runtime.py @@ -53,7 +53,10 @@ def source_files(root): 'scripts/bootstrap_linux.sh', 'scripts/windows_app.py', 'scripts/build_windows.py', 'scripts/check_installation.py', 'scripts/build_launcher_notices.py'] # Older deployed Windows payloads may predate the optional Mac entry points. - files_optional = ('scripts/build_macos.py', 'scripts/macos_app.py') + files_optional = ('scripts/build_macos.py', 'scripts/macos_app.py', + 'freevideo_source_bootstrap.py', 'freevideo_ed25519.py', 'freevideo_update_keys.py', + 'scripts/source_build.py', 'scripts/build_source_delta.py', 'scripts/sign_update_manifest.py', + 'scripts/prune_qt_bundle.py', 'scripts/deduplicate_bundle_data.py', 'scripts/pyi_rth_bundle_data.py') names += [name for name in files_optional if (root / name).is_file()] # Notice and licence files are carried when present; a launcher never fails to start without one. names += [name for name in NOTICE_FILES if (root / name).is_file()] @@ -62,7 +65,7 @@ def source_files(root): files += [p for folder in ('freevideo_engine', 'constraints', 'web', 'example_workflows') for p in (root / folder).rglob('*') if p.is_file() and (p.suffix in ('.py', '.json', '.txt', '.ps1', '.sh', '.js', '.css', '.svg', '.png', '.ico', '.qml', - '.woff2', '.webp') + '.woff2', '.otf', '.webp') or folder == 'web' and p.parent.name == 'tutorial' and p.suffix == '.mp4')] return sorted(files) @@ -168,6 +171,11 @@ def check_launcher_payload(source, destination): def materialize_source(bundle=None, destination=None): """A one-file executable's temporary extraction must never back an editable install.""" + if bundle is None: + from .top_level_modules import update_modules + active = update_modules()[2].ACTIVE + if active: + return Path(plain(active['source'])) if bundle is None and not getattr(sys, 'frozen', False): return Path(plain(resolved(__file__).parents[1])) bundle = io_path(bundle or Path(sys._MEIPASS) / 'engine-source') diff --git a/freevideo_engine/diagnostic_resources.py b/freevideo_engine/diagnostic_resources.py index 6738492f..a4614597 100644 --- a/freevideo_engine/diagnostic_resources.py +++ b/freevideo_engine/diagnostic_resources.py @@ -342,6 +342,49 @@ def encoder_discard(value): return {'encoder_discarded_without_cpu_copy': all(modes)} if modes else {} +ROOM_READINGS = ('cuda_free_bytes', 'local_budget_bytes', 'local_usage_bytes', 'process_budget_bytes', + 'allocated_bytes', 'reserved_bytes') +RESIDENT_ACTIONS = ('admit', 'reuse', 'evict', 'park', 'unpark', 'keep_declined') +RESIDENT_ROLES = ('encoder', 'engine', 'video_vae', 'audio_vae', 'input_video_vae', 'input_audio_vae', 'latent_upscaler') +RESIDENT_BYTES = ('gpu_bytes', 'estimated_peak_bytes', 'released_gpu_bytes', 'parked_bytes', 'moved_bytes', + 'device_ceiling_bytes') + + +def room_readings(value): + value = mapping(value) + result = numbers(value, ROOM_READINGS) + if value.get('bound') in ('cuda_free', 'local_budget', 'process_budget'): + result['bound'] = value['bound'] + return result + + +def encoder_workspace(value): + """Text-encode room before and after making space: each reading and the bound that decided.""" + rows = [] + for raw in sequence(value)[:6]: + raw = mapping(raw) + row = numbers(raw, ('whole_need_bytes', 'blocks_need_bytes', 'capacity_bytes', 'need_bytes', + 'room_before_bytes', 'room_after_bytes', 'loaded_before_bytes', 'loaded_after_bytes', + 'unloaded_bytes')) + if raw.get('mode') in ('whole', 'blocks'): + row['mode'] = raw['mode'] + for key in ('readings', 'readings_after'): + if isinstance(raw.get(key), dict): + row[key] = room_readings(raw[key]) + rows.append(row) + return rows + + +def resident_decisions(value): + """Which models were resident, admitted, kept or released around this request.""" + rows = [] + for raw in sequence(value)[-24:]: + raw = mapping(raw) + if raw.get('action') in RESIDENT_ACTIONS and raw.get('role') in RESIDENT_ROLES: + rows.append(dict(numbers(raw, RESIDENT_BYTES), action=raw['action'], role=raw['role'])) + return rows + + def sampling_checkpoint(value): """Keep completion and cleanup evidence without paths or free-form text.""" value = mapping(value) diff --git a/freevideo_engine/diagnostic_summary.py b/freevideo_engine/diagnostic_summary.py index dd9b2327..cac5df20 100644 --- a/freevideo_engine/diagnostic_summary.py +++ b/freevideo_engine/diagnostic_summary.py @@ -202,6 +202,8 @@ def summary_report(report): retry = diagnostic.mapping(encoding.get('diagnostic_retry')) if type(retry.get('index')) is int and 1 <= retry['index'] <= 3: result['encoder_resources']['retry'] = dict(index=retry['index'], final_attempt=retry.get('final_attempt') is True) + result['encoder_resources']['workspace'] = diagnostic.encoder_workspace(encoding.get('encoder_workspace')) + result['encoder_resources']['resident_models'] = diagnostic.resident_decisions(encoding.get('resident_admission')) result['encoder_resources']['oom_attempts'] = [] for row in diagnostic.sequence(encoding.get('encoder_attempts'))[:3]: row = diagnostic.mapping(row) @@ -220,6 +222,7 @@ def summary_report(report): result['worker_resources'] = diagnostic.resources(diagnostic.mapping(report.get('memory')).get('video')) result['worker_resources'].update(diagnostic.encoder_discard(engine)) result['worker_resources'].update(diagnostic.sampling_checkpoint(engine)) + result['worker_resources']['resident_models'] = diagnostic.resident_decisions(engine.get('resident_admission')) if 'failure_cleanup' in engine: result['worker_resources']['failure_cleanup'] = diagnostic.failure_cleanup(engine['failure_cleanup']) # Keep the driver activity next to the kernel/backend receipt so a diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index 5c732154..41170e4e 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -403,7 +403,7 @@ def add_path(name, path): # What the browser page found (web/health.js), and the page files of the # source ComfyUI served: a missing or changed file explains a page # without FreeVideo's styles or workspace. - for name in ('page-check.json', 'page-checks.jsonl'): + for name in ('page-check.json', 'page-checks.jsonl', 'workflow-repairs.jsonl'): if (root / 'logs' / name).is_file(): add_path('logs/' + name, root / 'logs' / name) add('source/web-files.json', json.dumps(web_files(root, machine)).encode()) diff --git a/freevideo_engine/encode_worker.py b/freevideo_engine/encode_worker.py index 67046393..af346347 100644 --- a/freevideo_engine/encode_worker.py +++ b/freevideo_engine/encode_worker.py @@ -514,7 +514,7 @@ def timed_load(*values, **kwargs): guard.arm() from .encoder_memory import snapshot phase('encoder_compute', load_seconds=load_seconds, device_load_seconds=transfer_seconds[0], - gpu=snapshot(torch, clip, memory)) + gpu=snapshot(torch, clip, memory, windows_memory=True)) print(json.dumps({'event': 'encoder_compute_start'}), flush=True) if not encoder_cache_hit: start_layer_window(trace, clip, path) @@ -580,7 +580,7 @@ def attempt(index, failure): torch.cuda.synchronize() from .encoder_memory import release_cast_buffers, snapshot loading['cast_buffers'] = release_cast_buffers(memory, torch) - phase('encoder_conditioning_pack', gpu=snapshot(torch, clip, memory)) + phase('encoder_conditioning_pack', gpu=snapshot(torch, clip, memory, windows_memory=True)) finally: del clip.load_model stop_layer_window(trace) diff --git a/freevideo_engine/encoder_workspace.py b/freevideo_engine/encoder_workspace.py index 4589494e..31e53926 100644 --- a/freevideo_engine/encoder_workspace.py +++ b/freevideo_engine/encoder_workspace.py @@ -152,24 +152,38 @@ def learn(self, mode, size, used, spilled=False): pass # Learning only refines the bound; the encode itself does not depend on it. -def dedicated_room(torch, reader=None, budget=None): +def dedicated_room(torch, reader=None, budget=None, detail=None): """Bytes a new allocation can use without leaving dedicated VRAM or this request's budget. PyTorch's cached free blocks count. cudaMemGetInfo is not the WDDM budget: after a load that left 1.9 GiB by its count, the 4060 Ti above reported no free memory at all, so on Windows the DXGI budget bounds it as well. `budget` is what this process may hold in total under FreeVideo's plan. + `detail`, when given, receives each reading and which bound decided. """ free = torch.cuda.mem_get_info()[0] + bound = 'cuda_free' + if detail is not None: + detail['cuda_free_bytes'] = int(free) if reader is not None: try: local = reader.sample()['local'] - free = min(free, max(0, local['budget_bytes'] - local['usage_bytes'])) + window = max(0, local['budget_bytes'] - local['usage_bytes']) + if detail is not None: + detail.update(local_budget_bytes=int(local['budget_bytes']), local_usage_bytes=int(local['usage_bytes'])) + if window < free: + free, bound = window, 'local_budget' except (OSError, KeyError): pass - room = free + torch.cuda.memory_reserved() - torch.cuda.memory_allocated() + allocated, reserved = torch.cuda.memory_allocated(), torch.cuda.memory_reserved() + room = free + reserved - allocated if budget is not None: - room = min(room, budget - torch.cuda.memory_allocated()) + if budget - allocated < room: + room, bound = budget - allocated, 'process_budget' + if detail is not None: + detail['process_budget_bytes'] = int(budget) + if detail is not None: + detail.update(allocated_bytes=int(allocated), reserved_bytes=int(reserved), bound=bound) return int(max(0, room)) @@ -185,12 +199,15 @@ def adapter_reader(): def make_room(patcher, torch, need, reader=None, budget=None): """Move loaded weights back to host memory until `need` bytes of dedicated VRAM are free.""" - before = dedicated_room(torch, reader, budget) - result = dict(need_bytes=int(need), room_before_bytes=before, loaded_before_bytes=int(patcher.loaded_size())) + readings = {} + before = dedicated_room(torch, reader, budget, readings) + result = dict(need_bytes=int(need), room_before_bytes=before, loaded_before_bytes=int(patcher.loaded_size()), + readings=readings) if before < need: unloaded = patcher.partially_unload(patcher.offload_device, need - before) - result.update(unloaded_bytes=int(unloaded or 0), room_after_bytes=dedicated_room(torch, reader, budget), - loaded_after_bytes=int(patcher.loaded_size())) + after = {} + result.update(unloaded_bytes=int(unloaded or 0), room_after_bytes=dedicated_room(torch, reader, budget, after), + loaded_after_bytes=int(patcher.loaded_size()), readings_after=after) return result diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 3b770a74..f2dc172f 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -460,7 +460,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep '请使用 39、56、73、90 等每次增加 17 的帧数;接续时,这个数包含开头重复的 22 帧。')), (r'Continue from video cannot be used with these settings\. Continuing a video needs an NVIDIA', 'continuation-route', ("This computer can't continue a video", '这台电脑无法接续视频'), - ('Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡。要生成新视频,请清空“接着生成”。')), + ('Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“接着生成”。')), (r'Continue from video cannot be used with these settings\. Turn on "Two-pass', 'continuation-passes', ('Continuing needs two-pass acceleration', '接续需要二次采样加速'), ('Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。')), diff --git a/freevideo_engine/image_gen/back_view.py b/freevideo_engine/image_gen/back_view.py new file mode 100644 index 00000000..4dd3e1d2 --- /dev/null +++ b/freevideo_engine/image_gen/back_view.py @@ -0,0 +1,146 @@ +"""Optional local body-orientation check for cast backs; never downloads weights. + +The installation includes torchvision, but not pose weights. Without a local +checkpoint this check is skipped. Face visibility is deliberately not a proxy +for torso direction: a backwards head on a frontal body is still rejected. +""" +from dataclasses import dataclass +import math +from pathlib import Path +import secrets +import time + +MAX_RETRIES = 2 +_POSE_FILE = 'keypointrcnn_resnet50_fpn_coco-fc266e95.pth' + + +@dataclass(frozen=True) +class Assessment: + body: str + back_score: float + + def __post_init__(self): + if (self.body not in ('front', 'back', 'side', 'unknown') + or not math.isfinite(self.back_score) or not 0 <= self.back_score <= 1): + raise ValueError('invalid body assessment') + + +def assess_keypoints(points, scores, box): + """COCO anatomical left/right shoulders and hips, independent of the head. + + Low-confidence, occluded, overlapping or conflicting joints abstain. This + cannot judge garment fronts or guarantee that a hallucinated pose is sound. + """ + width = box[2] - box[0] + joints = (5, 6, 11, 12) # left/right shoulder, left/right hip + if (not math.isfinite(width) or width <= 0 or len(points) < 17 or len(scores) < 17 + or any(points[i][2] <= 0 or scores[i] < 2. for i in joints)): + return Assessment('unknown', .5) + shoulders = (points[6][0] - points[5][0]) / width + hips = (points[12][0] - points[11][0]) / width + if not all(math.isfinite(v) for v in (shoulders, hips)): + return Assessment('unknown', .5) + score = .5 + .5 * max(-1., min(1., (shoulders + hips) / 2)) + if shoulders > .1 and hips > .1: + return Assessment('back', score) + if shoulders < -.1 and hips < -.1: + return Assessment('front', score) + return Assessment('side', .5) + + +def load_detector(pack_dir): + """Use an already present torchvision checkpoint, on CPU; absent means skip. + + A pack may supply detectors/, or an existing Torch Hub cache may + supply it. No package installation, network access, GPU residency or eager + torch import on installations without that checkpoint. + """ + import os + cache = Path(os.environ.get('TORCH_HOME') or + Path(os.environ.get('XDG_CACHE_HOME') or Path.home() / '.cache') / 'torch') + try: + checkpoint = next((p for p in (Path(pack_dir) / 'detectors' / _POSE_FILE, + cache / 'hub' / 'checkpoints' / _POSE_FILE) if p.is_file()), None) + except OSError: + return None + if checkpoint is None: + return None + try: + import torch + from torchvision.models.detection import keypointrcnn_resnet50_fpn + from torchvision.transforms.functional import pil_to_tensor + model = keypointrcnn_resnet50_fpn(weights=None, weights_backbone=None, + min_size=512, max_size=768) + model.load_state_dict(torch.load(checkpoint, map_location='cpu', weights_only=True)) + model.eval() + except Exception: + # An optional cache/dependency failure must not fail image generation. + return None + + def detect(image): + with torch.inference_mode(): + prediction = model([pil_to_tensor(image.convert('RGB')).float() / 255])[0] + people = [i for i, score in enumerate(prediction['scores'].tolist()) if score >= .9] + if len(people) != 1: + return Assessment('unknown', .5) + i = people[0] + return assess_keypoints(prediction['keypoints'][i].tolist(), + prediction['keypoints_scores'][i].tolist(), prediction['boxes'][i].tolist()) + return detect + + +def generate_back(generate, seed, detector, *, cancel=None): + """At most three candidates, retaining the highest BODY back score. + + ``generate(seed)`` uses the unchanged front reference, dimensions and steps. + A detector failure/abstention returns the best existing image. Diagnostics + contain fixed labels, seeds and timings, never prompts or user paths. + """ + cancelled = cancel or (lambda: False) + + def check_cancel(): + if cancelled(): + raise InterruptedError('cancelled') + + started = time.monotonic() + check_cancel() + initial = seed if seed is not None or detector is None else secrets.randbits(64) + best = generate(initial) + diagnostics = dict(retries=0, selected_seed=initial, checks=[], extra_generation_seconds=0., + check_seconds=0., skipped='detector_unavailable' if detector is None else '') + if detector is None: + return best, diagnostics + best_score = -1. + image = best + current_seed = initial + for attempt in range(MAX_RETRIES + 1): + check_cancel() + check_started = time.monotonic() + try: + assessment = detector(image) + if not isinstance(assessment, Assessment): + raise ValueError('invalid body assessment') + except InterruptedError: + raise + except Exception: + diagnostics['skipped'] = 'detector_failed' + # Retain the earlier assessed best; the first image if no check worked. + diagnostics['check_seconds'] += time.monotonic() - check_started + break + diagnostics['check_seconds'] += time.monotonic() - check_started + check_cancel() + diagnostics['checks'].append(dict(seed=current_seed, body=assessment.body, + back_score=assessment.back_score)) + if assessment.back_score > best_score: + best, best_score = image, assessment.back_score + diagnostics['selected_seed'] = current_seed + if assessment.body != 'front' or attempt == MAX_RETRIES: + break + current_seed = (initial + (attempt + 1) * 104729) % 2**64 + check_cancel() + retry_started = time.monotonic() + image = generate(current_seed) + diagnostics['extra_generation_seconds'] += time.monotonic() - retry_started + diagnostics['retries'] += 1 + diagnostics['seconds'] = time.monotonic() - started + return best, diagnostics diff --git a/freevideo_engine/image_gen/cast_views.py b/freevideo_engine/image_gen/cast_views.py index a316d036..012b99c6 100644 --- a/freevideo_engine/image_gen/cast_views.py +++ b/freevideo_engine/image_gen/cast_views.py @@ -29,8 +29,9 @@ 'with both eyes visible.'), 'side': ('将图1中的同一个人完全转向画面左边,呈现90度正侧面。鼻尖、胸口、脚尖朝向画面左边,' '只能看见一只眼睛,近侧肩膀遮住远侧肩膀。'), - 'back': ('Turn the person in image 1 all the way around. Show the back of the head and body. ' - 'The face is hidden. Show the back of the clothes, without front buttons or a shirt opening.'), + 'back': ('将图1中的同一个人整个身体转过180度,完全背对镜头。' + '头、肩膀、躯干、臀部和双脚一起转过去,后脑勺、发旋、颈后、背部、肩胛骨、臀部和脚后跟朝向镜头。' + '头和身体方向一致。看不到脸、胸口、衣服前襟、前面的扣子或衣领开口,耳朵只露边缘。只展示衣服背面。'), 'face': 'front-facing face close-up, framed from shoulders upward, centered head, eyes looking at the camera, neutral expression', } diff --git a/freevideo_engine/image_gen/image_policy.py b/freevideo_engine/image_gen/image_policy.py index c25b72d0..32914af0 100644 --- a/freevideo_engine/image_gen/image_policy.py +++ b/freevideo_engine/image_gen/image_policy.py @@ -52,13 +52,17 @@ def size(layer): _ENCODE_BASE = 2.7 * GiB # prompt length, not image size, drives this stage _DENOISE_BASE, _DENOISE_PER_MP = 1.06 * GiB, 0.554 * GiB _DECODE_BASE, _DECODE_PER_MP = 2.10 * GiB, 0.15 * GiB -_UNTILED_DECODE_PER_MP = 6.5 * GiB # decoding the whole image at once instead of in tiles +# Decoding the whole image at once instead of in tiles. A single frame keeps no +# causal cache: the whole-image decoder workspace is 3.49 GiB per megapixel +# (RTX PRO 6000, 0.26 to 4.2 MP, linear) where the cached one took 6.25. +_UNTILED_DECODE_PER_MP = 3.7 * GiB # Decoding in 512-pixel tiles (256 overlap) keeps the decoder's padded tile edges # out of tall pictures, where 256-pixel tiles left coloured vertical lines and a -# grid texture. A fixed-latent B200 sweep measured +1.43 GiB of decoder workspace; -# reserve 1.6 GiB. A card that cannot afford it decodes in 256-pixel tiles as before. +# grid texture. Without the cache it takes 0.68-0.72 GiB more than 256-pixel tiles +# (1.22-1.28 with it, where a B200 measured 1.43); reserve 1.0 GiB. A card that +# cannot afford it decodes in 256-pixel tiles as before. SMALL_VAE_TILE, LARGE_VAE_TILE = 256, 512 -_LARGE_TILE_DECODE_EXTRA = 1.6 * GiB +_LARGE_TILE_DECODE_EXTRA = 1.0 * GiB # Reference images, about 1 MP each after resizing. Their tokens are recomputed at # every step (the pipeline keeps no prefix key/value cache: it would hold about # 2 GiB per reference on the card, and the extra compute is about 1 s per image on @@ -81,7 +85,7 @@ def size(layer): @dataclass(frozen=True) class ImagePlan: - dit_resident_layers: int + dit_resident_layers: int # on the card while denoising te_resident_layers: int vae_tiling: bool host_mode: str # 'pinned' | 'pageable' | 'insufficient' @@ -94,19 +98,26 @@ class ImagePlan: link_floor_seconds: object reasons: list = field(default_factory=list) vae_tile_size: int = SMALL_VAE_TILE + # Of those, kept on the card through the other stages and between requests; + # the rest are released after denoising and filled again by the next one. + dit_kept_layers: int = None def to_dict(self): return dict(self.__dict__) def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pixels=0, vae_tiling=True, - vae_tile_size=SMALL_VAE_TILE): - """Largest allocation of each stage for a given residency (bytes).""" + vae_tile_size=SMALL_VAE_TILE, dit_kept=None): + """Largest allocation of each stage for a given residency (bytes). + + ``dit_kept`` blocks stay on the card outside denoising (default: all of them). + """ mp = width * height / 1e6 ref_mp = reference_pixels / 1e6 - resident = dit_resident * shape.dit_block_bytes + te_resident * shape.te_layer_bytes + kept = dit_resident if dit_kept is None else min(dit_kept, dit_resident) + resident = kept * shape.dit_block_bytes + te_resident * shape.te_layer_bytes encode = _ENCODE_BASE + resident - denoise = _DENOISE_BASE + _DENOISE_PER_MP * mp + resident + denoise = _DENOISE_BASE + _DENOISE_PER_MP * mp + resident + (dit_resident - kept) * shape.dit_block_bytes decode = _DECODE_BASE + _DECODE_PER_MP * mp + resident if not vae_tiling: decode += _UNTILED_DECODE_PER_MP * mp @@ -124,11 +135,16 @@ def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pi def plan_image(*, gpu_budget_bytes, host_available_bytes=None, width=1024, height=1024, reference_pixels=0, shape=None, link_bytes_per_second=None): - """Choose residency for one request. - - DiT blocks come first: each is read every step (8 times per image), a - text-encoder layer once. ``host_available_bytes`` is what the host can lock - or hold now (``offload.live_pin_capacity``); None means it was not read and + """Choose the decoding and the residency for one request. + + The decoding comes first: whole-image decoding is several times faster than + 512-pixel tiles, and those than 256-pixel ones, far more than a resident + block saves. Then DiT blocks for denoising, where each is read every step + (8 times per image); of those, the ones the other stages leave room for stay + on the card between requests, the rest are filled again on the next first + step. Text-encoder layers, read once per request, come last and only when + every block stays. ``host_available_bytes`` is what the host can lock or + hold now (``offload.live_pin_capacity``); None means it was not read and the plan assumes it fits. """ shape = shape or ImageShape() @@ -137,36 +153,37 @@ def plan_image(*, gpu_budget_bytes, host_available_bytes=None, width=1024, heigh reasons = [] kwargs = dict(width=width, height=height, reference_pixels=reference_pixels) - tile_size = LARGE_VAE_TILE + def peaks(kd, kt, tiling, tile_size, kept=None): + return stage_peaks(shape, kd, kt, vae_tiling=tiling, vae_tile_size=tile_size, dit_kept=kept, **kwargs) - def peak(kd, kt, tiling=True): - return max(stage_peaks(shape, kd, kt, vae_tiling=tiling, vae_tile_size=tile_size, **kwargs).values()) + def fits(kd, kt, tiling, tile_size, kept=None): + return max(peaks(kd, kt, tiling, tile_size, kept).values()) <= limit - kd = kt = 0 - tiling = True - if peak(0, 0) > limit: - tile_size = SMALL_VAE_TILE - feasible = peak(0, 0) <= limit + kd = kt = kept = 0 + tiling, tile_size = True, SMALL_VAE_TILE + for option in ((False, LARGE_VAE_TILE), (True, LARGE_VAE_TILE), (True, SMALL_VAE_TILE)): + if fits(0, 0, *option): + tiling, tile_size = option + break + feasible = fits(0, 0, tiling, tile_size) if not feasible: reasons.append('even full streaming with a tiled VAE needs %.1f GiB beyond the %.1f GiB budget' % ( - (peak(0, 0) + margin) / GiB, gpu_budget_bytes / GiB)) + (max(peaks(0, 0, tiling, tile_size).values()) + margin) / GiB, gpu_budget_bytes / GiB)) else: - # Largest DiT residency that fits, then text-encoder layers with what is left. - kd = max(k for k in range(shape.dit_blocks + 1) if peak(k, 0) <= limit) + kd = max(k for k in range(shape.dit_blocks + 1) if fits(k, 0, tiling, tile_size, kept=0)) + kept = max(k for k in range(kd + 1) if fits(kd, 0, tiling, tile_size, kept=k)) # Encoder layers are read once per request, blocks eight times: only - # when every block is resident does leftover room go to the encoder. - if kd == shape.dit_blocks: - kt = max(k for k in range(shape.te_layers + 1) if peak(kd, k) <= limit) - # Untiled decoding is faster; take it only when it fits beside the same residency. - if peak(kd, kt, tiling=False) <= limit: - tiling = False - peaks = stage_peaks(shape, kd, kt, vae_tiling=tiling, vae_tile_size=tile_size, **kwargs) + # when every block stays does leftover room go to the encoder. + if kept == shape.dit_blocks: + kt = max(k for k in range(shape.te_layers + 1) if fits(kd, k, tiling, tile_size)) + peaks_now = peaks(kd, kt, tiling, tile_size, kept) streamed_dit = (shape.dit_blocks - kd) * shape.dit_block_bytes streamed_te = (shape.te_layers - kt) * shape.te_layer_bytes + refilled = (kd - kept) * shape.dit_block_bytes floor = None if link_bytes_per_second: - floor = (streamed_dit * shape.steps + streamed_te) / link_bytes_per_second + floor = (streamed_dit * shape.steps + refilled + streamed_te) / link_bytes_per_second host_mode = 'pinned' if host_available_bytes is not None: @@ -181,6 +198,6 @@ def peak(kd, kt, tiling=True): reasons.append('host memory cannot hold the weights (%.1f GiB needed)' % (need / GiB)) return ImagePlan(dit_resident_layers=kd, te_resident_layers=kt, vae_tiling=tiling, host_mode=host_mode, feasible=feasible and host_mode != 'insufficient', - peak_allocated_bytes=max(peaks.values()), stage_peaks=peaks, margin_bytes=margin, + peak_allocated_bytes=max(peaks_now.values()), stage_peaks=peaks_now, margin_bytes=margin, dit_streamed_bytes_per_step=streamed_dit, te_streamed_bytes=streamed_te, - link_floor_seconds=floor, reasons=reasons, vae_tile_size=tile_size) + link_floor_seconds=floor, reasons=reasons, vae_tile_size=tile_size, dit_kept_layers=kept) diff --git a/freevideo_engine/image_gen/image_runner.py b/freevideo_engine/image_gen/image_runner.py index 07546fdf..b73d0214 100644 --- a/freevideo_engine/image_gen/image_runner.py +++ b/freevideo_engine/image_gen/image_runner.py @@ -14,7 +14,7 @@ import torch from . import image_policy -from .image_stream import StreamedStack +from .image_stream import ResidentStack, StreamedStack GiB = 2**30 _DIT_FILE = 'diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors' @@ -58,6 +58,11 @@ def _non_block_children(module, skip): yield child +def _kept(plan): + kept = getattr(plan, 'dit_kept_layers', None) + return plan.dit_resident_layers if kept is None else kept + + class _Placement: """Stage hooks for QwenImageGenerator: nothing but moves between host and card.""" @@ -66,7 +71,9 @@ def __init__(self, runner): def enter(self, stage): runner = self.runner - if stage == 'text': + if stage == 'denoise': + runner.dit_stack.set_resident(runner.last_plan.dit_resident_layers) + elif stage == 'text': visual = getattr(runner.text_model.model, 'visual', None) if visual is not None: visual.to(runner.device) @@ -84,6 +91,10 @@ def leave(self, stage): runner.vae.to('cpu') self._trim() elif stage == 'denoise': + # Blocks the other stages have no room for go back; the next denoise fills them again. + # A pass that failed between layers is left as it is (the runner is not reused). + if runner.dit_stack.offloader.at_boundary: + runner.dit_stack.set_resident(_kept(runner.last_plan)) self._trim() @staticmethod @@ -100,8 +111,13 @@ class ImageRunner: ``gpu_budget_bytes`` caps what a request may plan against (None reads the card: free memory plus what this process already holds). Residency and VAE tiling are chosen per request by ``image_policy.plan_image``. + + ``full_card`` is set when the whole model sits on the card with no host copy; the first request + that no longer plans everything resident puts the weights back on the host and streams them. """ + full_card = False + def __init__(self, pack_dir, *, device='cuda', gpu_budget_bytes=None, log=None, progress=None): from .qwen_pipeline import QwenImageGenerator from .qwen_te_weights import load_text_encoder @@ -119,7 +135,9 @@ def path(directory, name): loading = _LoadProgress(progress or (lambda stage, fraction: None)) started = time.monotonic() - self.transformer = load_dit(self.pack_dir / _DIT_FILE, device='cpu', + # A card that holds every weight plus a request's working memory keeps no host copy. + self.full_card = 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)) self.load_seconds['dit'] = time.monotonic() - started loading.complete('dit') @@ -156,7 +174,8 @@ def path(directory, name): from ..offload import cpu_weights stacks = [self.transformer.transformer_blocks, language.layers] stack_bytes = [sum(value.numel() * value.element_size() for layer in layers - for _, value in cpu_weights(layer)) for layers in stacks] + for _, value in (list(layer.named_parameters()) + list(layer.named_buffers()) + if self.full_card else cpu_weights(layer))) for layers in stacks] pin_total = sum(stack_bytes) def pin_progress(index): @@ -164,16 +183,25 @@ def pin_progress(index): return lambda done, total: loading.report( 'pin', offset + stack_bytes[index] * (done / total if total else 0.), pin_total) - with torch.no_grad(): # LayerOffloader refuses to be built with autograd on - self.dit_stack = StreamedStack(stacks[0], name='dit', device=self.device, progress=pin_progress(0)) - loading.report('pin', stack_bytes[0], pin_total) - self.te_stack = StreamedStack(stacks[1], name='text_encoder', device=self.device, progress=pin_progress(1)) + if 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) + self.dit_stack = ResidentStack(stacks[0], name='dit', device=self.device) + self.te_stack = ResidentStack(stacks[1], name='text_encoder', device=self.device) + else: + with torch.no_grad(): # LayerOffloader refuses to be built with autograd on + self.dit_stack = StreamedStack(stacks[0], name='dit', device=self.device, + progress=pin_progress(0)) + loading.report('pin', stack_bytes[0], pin_total) + self.te_stack = StreamedStack(stacks[1], name='text_encoder', device=self.device, + progress=pin_progress(1)) self.load_seconds['pin'] = time.monotonic() - started loading.complete('pin') self.shape = image_policy.ImageShape.measure( list(self.transformer.transformer_blocks), list(language.layers), - total_weight_bytes=self.dit_stack.host_bytes + self.te_stack.host_bytes) + total_weight_bytes=sum(stack_bytes)) self.generator = QwenImageGenerator( None, placement=_Placement(self), device=self.device, transformer=self.transformer, vae=self.vae, text_encoder=text, processor=processor, sample_sigmas=None, vae_tile=True) @@ -193,6 +221,28 @@ def to_card(module, args, output): return [embedding.register_forward_pre_hook(to_host), embedding.register_forward_hook(to_card)] + def _fits_whole_model(self): + """True when a 1024x1024 request plans every DiT block and encoder layer on the card.""" + try: + plan = image_policy.plan_image(gpu_budget_bytes=self.budget(), width=1024, height=1024) + except Exception: + return False + shape = image_policy.ImageShape() + return plan.feasible and plan.dit_resident_layers == shape.dit_blocks == plan.dit_kept_layers \ + and plan.te_resident_layers == shape.te_layers + + def _to_streamed(self): + """A request no longer fits the whole model on the card: put the host copy back and stream.""" + try: + with torch.no_grad(): + if hasattr(self.dit_stack, 'to_streamed'): + self.dit_stack = self.dit_stack.to_streamed() + if hasattr(self.te_stack, 'to_streamed'): + self.te_stack = self.te_stack.to_streamed() + finally: + torch.cuda.empty_cache() + # Only now: a stack that stopped is still whole-model, so the next request tries again. + self.full_card = False def budget(self): if self.gpu_budget_bytes is not None: return int(self.gpu_budget_bytes) @@ -217,6 +267,8 @@ 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: + host_available = None except Exception: host_available = None return image_policy.plan_image( @@ -226,10 +278,13 @@ def plan(self, width, height, reference_images=None): def generate(self, prompt, *, width, height, seed=None, reference_images=None, edit_mask=None, progress=None, cancel=None): plan = self.plan(width, height, reference_images) + if self.full_card and not self._all_on_card(plan): + self._to_streamed() + plan = self.plan(width, height, reference_images) self.last_plan = plan if not plan.feasible: raise MemoryError('; '.join(plan.reasons) or 'the image model does not fit this machine') - self.dit_stack.set_resident(plan.dit_resident_layers) + self.dit_stack.set_resident(_kept(plan)) self.te_stack.set_resident(plan.te_resident_layers) self.generator.vae_tile = plan.vae_tiling self.generator.vae_tile_size = plan.vae_tile_size @@ -237,6 +292,10 @@ def generate(self, prompt, *, width, height, seed=None, reference_images=None, e reference_images=reference_images, edit_mask=edit_mask, progress=progress, cancel=cancel) + def _all_on_card(self, plan): + return (plan.feasible and _kept(plan) == len(self.dit_stack.layers) + and plan.te_resident_layers == len(self.te_stack.layers)) + def at_boundary(self): """False when a failure left a weights streamer inside a layer pass; the next request cannot use it.""" return self.dit_stack.offloader.at_boundary and self.te_stack.offloader.at_boundary diff --git a/freevideo_engine/image_gen/image_stream.py b/freevideo_engine/image_gen/image_stream.py index ee8db9bb..9a9da267 100644 --- a/freevideo_engine/image_gen/image_stream.py +++ b/freevideo_engine/image_gen/image_stream.py @@ -61,3 +61,58 @@ def stats(self): def close(self): self.offloader.close() + + +class ResidentStack: + """Every layer lives on the card and nothing is kept on the host. + + Stands in for ``StreamedStack`` while the planned residency is the whole + stack, so the host holds no second copy of the weights. ``to_streamed`` + brings the layers back for a request whose budget no longer fits them all. + """ + + def __init__(self, layers, *, name, device='cuda'): + self.name = name + self.layers = list(layers) + if not self.layers: + raise ValueError('ResidentStack needs at least one layer') + self.device = device + self.host_bytes = 0 + self.layer_bytes = max(sum(tensor.numel() * tensor.element_size() + for tensor in list(layer.parameters()) + list(layer.buffers())) + for layer in self.layers) + self.resident = len(self.layers) + + at_boundary = True + cpu = () # no host weights + + @property + def offloader(self): + return self + + def set_resident(self, count): + return self.resident + + def stats(self): + return dict(name=self.name, layers=len(self.layers), layer_bytes=self.layer_bytes, pinned_layers=0, + pinned_bytes=0, resident_layers=self.resident, cached_bytes=0, host_copy=False) + + def to_streamed(self, **options): + """Copy the layers back to the host and return a ``StreamedStack`` over them.""" + moved = [] + try: + for layer in self.layers: + layer.to('cpu') + moved.append(layer) + except BaseException: + for layer in moved: + layer.to(self.device) + raise + try: + return StreamedStack(self.layers, name=self.name, device=self.device, **options) + except Exception: + # Locking pages failed: stream from ordinary memory instead. + return StreamedStack(self.layers, name=self.name, device=self.device, **{**options, 'pin': False}) + + def close(self): + pass diff --git a/freevideo_engine/image_gen/qwen_vae.py b/freevideo_engine/image_gen/qwen_vae.py index e96861c5..80748eae 100644 --- a/freevideo_engine/image_gen/qwen_vae.py +++ b/freevideo_engine/image_gen/qwen_vae.py @@ -28,6 +28,17 @@ CACHE_T = 2 +def _frame_cache(feat_map, num_frames): + """The causal cache of the encoder or decoder, or None for a single frame. + + The first frame never reads the cache (every entry is still empty), it only + fills it with a copy of each convolution's input for the next frame. A + picture has no next frame, so those copies were dead weight: about 5 GiB + at 1024x1024 untiled. Without them the arithmetic is the same. + """ + return feat_map if num_frames > 1 else None + + # Copied from diffusers.models.autoencoders.autoencoder_kl_wan.AvgDown3D with AvgDown3D->QwenImage21AvgDown3D class QwenImage21AvgDown3D(nn.Module): def __init__( @@ -1265,7 +1276,8 @@ def _encode(self, x: torch.Tensor): for i in range(iter_): self._enc_conv_idx = [0] if i == 0: - out = self.encoder(x[:, :, :1, :, :], feat_cache=self._enc_feat_map, feat_idx=self._enc_conv_idx) + out = self.encoder(x[:, :, :1, :, :], feat_cache=_frame_cache(self._enc_feat_map, iter_), + feat_idx=self._enc_conv_idx) else: out_ = self.encoder( x[:, :, 1 + 4 * (i - 1) : 1 + 4 * i, :, :], @@ -1326,7 +1338,8 @@ def _decode(self, z: torch.Tensor, return_dict: bool = True): self._conv_idx = [0] if i == 0: out = self.decoder( - x[:, :, i : i + 1, :, :], feat_cache=self._feat_map, feat_idx=self._conv_idx, first_chunk=True + x[:, :, i : i + 1, :, :], feat_cache=_frame_cache(self._feat_map, num_frame), + feat_idx=self._conv_idx, first_chunk=True ) else: out_ = self.decoder(x[:, :, i : i + 1, :, :], feat_cache=self._feat_map, feat_idx=self._conv_idx) @@ -1452,7 +1465,8 @@ def tiled_encode(self, x: torch.Tensor) -> AutoencoderKLOutput: i : i + self.tile_sample_min_height, j : j + self.tile_sample_min_width, ] - tile = self.encoder(tile, feat_cache=self._enc_feat_map, feat_idx=self._enc_conv_idx) + tile = self.encoder(tile, feat_cache=_frame_cache(self._enc_feat_map, frame_range), + feat_idx=self._enc_conv_idx) tile = self.quant_conv(tile) time.append(tile) row.append(torch.cat(time, dim=2)) @@ -1526,7 +1540,8 @@ def tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> DecoderOutp tile = z[:, :, k : k + 1, i : i + tile_latent_min_height, j : j + tile_latent_min_width] tile = self.post_quant_conv(tile) decoded = self.decoder( - tile, feat_cache=self._feat_map, feat_idx=self._conv_idx, first_chunk=(k == 0) + tile, feat_cache=_frame_cache(self._feat_map, num_frames), feat_idx=self._conv_idx, + first_chunk=(k == 0) ) time.append(decoded) row.append(torch.cat(time, dim=2)) diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 852cfaa5..c351c588 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -23,6 +23,11 @@ CANCEL_GRACE = 5. MAX_IMAGES = 4 REPORT_LIMIT = 16 +HISTORY_NAME = re.compile(r'image-\d{8}-\d{6}-\d{2}\.png') +LEGACY_NAME = re.compile(r'[0-9a-f]{32}\.png') +HISTORY_LIMIT = 500 +# The 高清 size is offered from this much card memory; smaller cards keep one size. +HIGH_SIZE_MIN_TOTAL = 6 * 1024 ** 3 INFERENCE_TIMEOUT = 600. QUEUE_TIMEOUT = 600. QUEUE_POLL = .1 @@ -147,12 +152,32 @@ def info(self): checking=not ready and image_pack.checking(machine['model_root']), downloading=not ready and image_pack.downloading(machine['model_root'], engine_root), bytes=sum(row['bytes'] for row in spec['groups']['image']), busy=self.busy(), - max_images=MAX_IMAGES, install=self.pack_state(ready), + max_images=MAX_IMAGES, sizes=self.size_tiers(machine), install=self.pack_state(ready), segment=dict(ready=image_pack.ready(machine['model_root'], group='segment'), max_points=16, device='cpu', install=self.segment_installer.state() if self.segment_installer else None), bytes_per_second=None if ready else observed_speed(engine_root)) + def size_tiers(self, machine): + """The sizes this card offers: the standard one always, the high one only with enough card memory.""" + total = None + if machine.get('device_backend', 'cuda') == 'cuda': + from ..background_room import Gpu + from ..gpu_budget import open_bounded + + def read(): + gpu = Gpu(machine.get('gpu_uuid')) + try: + memory = gpu.memory() + return memory[1] if memory else None + finally: + gpu.close() + try: + total = open_bounded(read, .5) + except (OSError, RuntimeError, AttributeError, ValueError): + total = None + return ['standard', 'high'] if total and total >= HIGH_SIZE_MIN_TOTAL else ['standard'] + def pack_state(self, ready): """Progress of the download this page started, in both languages for a failure.""" installer = self.pack_installer @@ -200,13 +225,84 @@ def media_path(self, name): raise ValueError('invalid_media') return str(path) - def output_path(self): + def output_directory(self): directory = Path(self.input_directory()).resolve() output = directory / 'freevideo-image' if output.is_symlink() or output.resolve().parent != directory: raise ValueError('invalid_media') output.mkdir(exist_ok=True) - return str(output / (uuid.uuid4().hex + '.png')) + return output + + def output_path(self, named=False): + """A new picture's path. Generated pictures get a readable name that the history lists.""" + output = self.output_directory() + if not named: + return str(output / (uuid.uuid4().hex + '.png')) + stamp = time.strftime('image-%Y%m%d-%H%M%S') + for number in range(1, 100): + path = output / ('%s-%02d.png' % (stamp, number)) + if not path.exists(): + return str(path) + raise ValueError('invalid_media') + + def legacy_picture(self, path): + """A picture from before generated names: a bare random name whose PNG is colour, since masks are grey.""" + if not LEGACY_NAME.fullmatch(path.name) or path.is_symlink() or not path.is_file(): + return False + try: + with path.open('rb') as handle: + head = handle.read(26) + except OSError: + return False + return head[:8] == b'\x89PNG\r\n\x1a\n' and head[25] in (2, 6) + + def history(self): + """Every picture generated here, newest first; the folder is the record, so it outlives sessions.""" + output = self.output_directory() + named, older = [], [] + for entry in output.iterdir(): + if HISTORY_NAME.fullmatch(entry.name) and entry.is_file() and not entry.is_symlink(): + named.append(entry.name) + elif self.legacy_picture(entry): + try: + older.append((entry.stat().st_mtime, entry.name)) + except OSError: + pass + names = sorted(named, reverse=True) + [name for _, name in sorted(older, reverse=True)] + return dict(items=[dict(file='freevideo-image/' + name, name=name) for name in names[:HISTORY_LIMIT]]) + + def history_delete(self, name): + leaf = name.rpartition('/')[2] if isinstance(name, str) else '' + if not (HISTORY_NAME.fullmatch(leaf) or LEGACY_NAME.fullmatch(leaf)) or name != 'freevideo-image/' + leaf: + raise ValueError('invalid_media') + path = self.output_directory() / leaf + if path.is_symlink() or not path.is_file() or (LEGACY_NAME.fullmatch(leaf) and not self.legacy_picture(path)): + raise ValueError('invalid_media') + path.unlink() + return self.history() + + def history_open(self): + """Show the folder in the file manager of this machine.""" + folder = str(self.output_directory()) + if os.name == 'nt': + # The shell can block for good without a desktop session (SSH, service); never wait on it here. + failure = [] + + def show(): + try: + os.startfile(folder) + except OSError as error: + failure.append(error) + thread = threading.Thread(target=show, name='history-open', daemon=True) + thread.start() + thread.join(2) + if failure: + raise failure[0] + else: + from .. import processes + processes.popen(['xdg-open', folder], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, start_new_session=True) + return dict(opened=True) def close_segment_worker(self): from .. import processes @@ -348,9 +444,12 @@ def request(self, value): raise ValueError('invalid_media') except OSError: raise ValueError('invalid_media') from None + cast_view = value.get('cast_view') + if cast_view is not None and (cast_view != 'back' or len(paths) != 1 or mask_path is not None): + raise ValueError('invalid_request') return dict(prompt=prompt, width=dimensions[0] // 32 * 32, height=dimensions[1] // 32 * 32, steps=8, seed=seed, images=paths, **(dict(mask=mask_path) if mask_path else {}), - path=self.output_path()) + **(dict(cast_view='back') if cast_view == 'back' else {}), path=self.output_path(named=True)) def start(self, action, value): if action not in ('generate', 'compose'): @@ -1065,6 +1164,14 @@ async def action(request): result = service.pack_action(name.split('-')[1], group='segment') elif name == 'segment': result = await asyncio.to_thread(service.segment, value) + elif name == 'history': + result = service.history() + elif name == 'history-delete': + result = service.history_delete(value.get('file')) + elif name == 'history-open': + if request.remote not in ('127.0.0.1', '::1'): + raise ValueError('invalid_request') + result = await asyncio.to_thread(service.history_open) else: result = service.start(name, value) return web.json_response(result) diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index d97d807f..f45022d8 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -90,6 +90,8 @@ def send(**row): inbox = Inbox(stream, send) runner = None + back_detector = None + back_detector_loaded = False try: while not inbox.closed.is_set(): try: @@ -124,11 +126,22 @@ def progress(stage, fraction): if value.get('mask'): region['edit_mask'] = stack.enter_context(Image.open(value['mask'])) region['edit_mask'].load() - image = runner.generate( - value['prompt'], width=value['width'], height=value['height'], - seed=value.get('seed'), reference_images=references, **region, - progress=progress, - cancel=inbox.cancel.is_set) + back_check = None + def generate(seed): + return runner.generate( + value['prompt'], width=value['width'], height=value['height'], + seed=seed, reference_images=references, **region, + progress=progress, cancel=inbox.cancel.is_set) + + if value.get('cast_view') == 'back' and len(references) == 1 and not region: + from .back_view import generate_back, load_detector + if not back_detector_loaded: + back_detector = load_detector(pack_dir) + back_detector_loaded = True + image, back_check = generate_back(generate, value.get('seed'), back_detector, + cancel=inbox.cancel.is_set) + else: + image = generate(value.get('seed')) if inbox.cancel.is_set(): raise InterruptedError('cancelled') # The parent chooses a fresh path under ComfyUI input. @@ -136,6 +149,8 @@ def progress(stage, fraction): with Path(value['path']).open('xb') as target: image.save(target, format='PNG') plan = runner.stats().get('plan') + if back_check is not None: + plan = dict(plan or {}, back_check=back_check) result = dict(phase='result', path=value['path'], seconds=time.monotonic() - started, plan=plan) except Exception as error: code = classify_exception(error, stage=context.timing.stage, cancelled=inbox.cancel.is_set()) diff --git a/freevideo_engine/launcher/FButton.qml b/freevideo_engine/launcher/FButton.qml index 0df36216..e5217e24 100644 --- a/freevideo_engine/launcher/FButton.qml +++ b/freevideo_engine/launcher/FButton.qml @@ -6,6 +6,7 @@ Button { property bool primary: false property bool selected: false property bool danger: false + property bool singleLine: false // A text link: accent text, no frame. property bool link: false implicitHeight: Math.max(theme.height, contentItem.implicitHeight + 14) @@ -21,7 +22,8 @@ Button { : control.link ? (control.hovered ? theme.accentHover : theme.accent) : control.flat && !control.hovered ? theme.muted : theme.text horizontalAlignment: Text.AlignHCenter; verticalAlignment: Text.AlignVCenter - wrapMode: Text.Wrap + wrapMode: control.singleLine ? Text.NoWrap : Text.Wrap + elide: control.singleLine ? Text.ElideRight : Text.ElideNone Behavior on color { ColorAnimation { duration: 110 } } } background: Rectangle { diff --git a/freevideo_engine/launcher/FNav.qml b/freevideo_engine/launcher/FNav.qml index 3ab190f7..da72f17d 100644 --- a/freevideo_engine/launcher/FNav.qml +++ b/freevideo_engine/launcher/FNav.qml @@ -7,18 +7,22 @@ Button { id: control property string glyph: "play" property bool selected: false + property bool iconOnly: false implicitHeight: theme.height + 2 - leftPadding: 12; rightPadding: 12 + implicitWidth: iconOnly ? 44 : 0 + leftPadding: iconOnly ? 0 : 12; rightPadding: iconOnly ? 0 : 12 hoverEnabled: true opacity: enabled ? 1 : 0.45 contentItem: RowLayout { - spacing: 11 + spacing: control.iconOnly ? 0 : 11 FIcon { kind: control.glyph; ink: control.selected ? theme.accent : theme.muted knockout: control.selected ? theme.raised : control.hovered ? theme.surface : theme.bg Layout.preferredWidth: 18; Layout.preferredHeight: 18 + Layout.alignment: control.iconOnly ? Qt.AlignHCenter : Qt.AlignVCenter } FText { + visible: !control.iconOnly text: control.text; Layout.fillWidth: true; elide: Text.ElideRight; maximumLineCount: 1 font.weight: control.selected ? Font.DemiBold : Font.Medium color: control.selected ? theme.text : control.hovered ? theme.text : theme.muted @@ -32,4 +36,12 @@ Button { Rectangle { visible: control.selected; width: 3; height: 18; radius: 2; color: theme.accent; x: 0; anchors.verticalCenter: parent.verticalCenter } } Accessible.name: text + ToolTip { + objectName: "navToolTip" + visible: control.iconOnly && control.hovered + text: control.text + // Open into the content area instead of centring outside the left edge. + x: control.width + 8 + y: (control.height - height) / 2 + } } diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index e8578e28..200b7d12 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -34,6 +34,7 @@ ApplicationWindow { property string previousStatus: "" property string previousReview: "" property bool compact: width < 1000 + property bool narrow: width < 640 property bool shortWindow: height < 700 onModelInfoOpenChanged: if (modelInfoOpen) backend.prepareDownloadList() readonly property bool manualEnvironment: s.offline.runtime_supported !== false && s.form.new_comfy && s.form.environment_method === "manual" @@ -258,29 +259,30 @@ ApplicationWindow { Rectangle { id: sidebar objectName: "sidebar" - width: win.compact ? 200 : 232 + width: win.narrow ? 64 : win.compact ? 200 : 232 anchors.top: parent.top; anchors.bottom: parent.bottom; anchors.left: parent.left color: win.color Rectangle { width: 1; color: theme.border; anchors.right: parent.right; height: parent.height } ColumnLayout { - anchors.fill: parent; anchors.margins: 16; spacing: 4 + anchors.fill: parent; anchors.margins: win.narrow ? 8 : 16; spacing: 4 // The FreeVideo wordmark, as in the creative workspace header. Image { objectName: "brandWordmark" source: "../assets/wordmark.png"; fillMode: Image.PreserveAspectFit + visible: !win.narrow Layout.preferredWidth: 150; Layout.preferredHeight: 25 Layout.topMargin: 14; Layout.leftMargin: 6; Layout.bottomMargin: 31 smooth: true; mipmap: true Accessible.role: Accessible.Graphic; Accessible.name: "FreeVideo" } - FNav { objectName: "navLauncher"; glyph: "play"; text: t("Launch", "启动"); Layout.fillWidth: true; selected: s.page === "launcher"; enabled: s.selected && !s.busy; onClicked: backend.action("launcher", false) } - FNav { objectName: "navSetup"; glyph: "download"; text: t("Installation", "安装"); Layout.fillWidth: true; selected: s.page !== "launcher"; enabled: s.can_manage && !s.portable; onClicked: backend.action("setup", false) } + FNav { objectName: "navLauncher"; glyph: "play"; text: t("Launch", "启动"); iconOnly: win.narrow; Layout.fillWidth: true; selected: s.page === "launcher"; enabled: s.selected && !s.busy; onClicked: backend.action("launcher", false) } + FNav { objectName: "navSetup"; glyph: "download"; text: t("Installation", "安装"); iconOnly: win.narrow; Layout.fillWidth: true; selected: s.page !== "launcher"; enabled: s.can_manage && !s.portable; onClicked: backend.action("setup", false) } Item { Layout.fillHeight: true } Rectangle { Layout.fillWidth: true; Layout.bottomMargin: 8; implicitHeight: busyBody.implicitHeight + 28 radius: theme.radiusMd; color: theme.surface; border.color: theme.border // Waiting for the person to confirm the plan is not work in progress. - opacity: s.working ? 1 : 0; visible: opacity > 0 + opacity: s.working ? 1 : 0; visible: !win.narrow && opacity > 0 Behavior on opacity { NumberAnimation { duration: 240; easing.type: Easing.OutCubic } } ColumnLayout { id: busyBody; x: 14; y: 14; width: parent.width - 28; spacing: 8 @@ -293,10 +295,11 @@ ApplicationWindow { FText { text: s.overall.label || t("Preparing…", "正在准备…"); font.pixelSize: theme.micro; color: theme.muted; Layout.fillWidth: true; maximumLineCount: 1; elide: Text.ElideRight } } } - FNav { glyph: "terminal"; text: t("Terminal", "终端"); Layout.fillWidth: true; selected: terminalOpen; onClicked: terminalOpen = !terminalOpen } - FNav { objectName: "settingsButton"; glyph: "settings"; text: t("Settings", "设置"); Layout.fillWidth: true; onClicked: settingsOpen = true } + FNav { glyph: "terminal"; text: t("Terminal", "终端"); iconOnly: win.narrow; Layout.fillWidth: true; selected: terminalOpen; onClicked: terminalOpen = !terminalOpen } + FNav { objectName: "settingsButton"; glyph: "settings"; text: t("Settings", "设置"); iconOnly: win.narrow; Layout.fillWidth: true; onClicked: settingsOpen = true } FButton { id: versionInfoButton; objectName: "versionInfoButton" + visible: !win.narrow text: (separateVersions ? t("Launcher ", "启动器 ") : "") + releaseVersion(currentRelease) flat: true; font.pixelSize: 11; Layout.topMargin: 12 Accessible.name: t("Version & release notes", "版本与更新说明") @@ -324,16 +327,16 @@ ApplicationWindow { id: steps objectName: "setupSteps" visible: s.page !== "launcher" - spacing: win.compact ? 8 : 12 + spacing: win.narrow ? 4 : win.compact ? 8 : 12 anchors.verticalCenter: parent.verticalCenter - x: Math.max(20, Math.min((parent.width - width) / 2, headerTools.x - width - 16)) + x: win.narrow ? 12 : Math.max(20, Math.min((parent.width - width) / 2, headerTools.x - width - 16)) Repeater { model: [t("ComfyUI", "ComfyUI"), t("Models", "模型"), t("Install", "安装")] delegate: Row { required property string modelData; required property int index - spacing: win.compact ? 8 : 12 + spacing: win.narrow ? 4 : win.compact ? 8 : 12 Item { - visible: index > 0; width: win.compact ? 14 : 32; height: 2; anchors.verticalCenter: parent.verticalCenter + visible: index > 0; width: win.narrow ? 8 : win.compact ? 14 : 32; height: 2; anchors.verticalCenter: parent.verticalCenter Rectangle { anchors.verticalCenter: parent.verticalCenter; width: parent.width; height: 1; color: theme.border } Rectangle { anchors.verticalCenter: parent.verticalCenter; height: 1; color: theme.accentDim @@ -342,7 +345,7 @@ ApplicationWindow { } } Row { - spacing: 8; anchors.verticalCenter: parent.verticalCenter + spacing: win.narrow ? 4 : 8; anchors.verticalCenter: parent.verticalCenter Rectangle { width: 20; height: 20; radius: 10; anchors.verticalCenter: parent.verticalCenter color: index < win.step ? theme.accentSubtle : index === win.step ? theme.accent : "transparent" @@ -353,7 +356,7 @@ ApplicationWindow { FText { anchors.centerIn: parent; text: index < win.step ? "✓" : String(index + 1); font.pixelSize: 11; font.weight: Font.DemiBold color: index === win.step ? theme.bg : index < win.step ? theme.accent : theme.muted } } - FText { anchors.verticalCenter: parent.verticalCenter; text: modelData; font.pixelSize: theme.micro + 1 + FText { visible: !win.narrow || index === win.step; anchors.verticalCenter: parent.verticalCenter; text: modelData; font.pixelSize: theme.micro + 1 font.weight: index === win.step ? Font.DemiBold : Font.Normal; color: index === win.step ? theme.text : theme.muted } } } @@ -361,7 +364,7 @@ ApplicationWindow { } Row { id: headerTools - anchors.right: parent.right; anchors.rightMargin: 20; anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right; anchors.rightMargin: win.narrow ? 12 : 20; anchors.verticalCenter: parent.verticalCenter spacing: 12 Rectangle { visible: s.status === "open"; anchors.verticalCenter: parent.verticalCenter @@ -393,7 +396,7 @@ ApplicationWindow { ScrollBar.horizontal.policy: ScrollBar.AlwaysOff ColumnLayout { id: page - width: Math.min(scroll.availableWidth - (win.compact ? 36 : 64), 820) + width: Math.min(scroll.availableWidth - (win.narrow ? 24 : win.compact ? 36 : 64), 820) x: (scroll.availableWidth-width)/2 spacing: shortWindow ? 14 : 20 transform: Translate { id: pageShift } @@ -446,6 +449,7 @@ ApplicationWindow { text: t("Download the latest ↗", "下载最新版 ↗"); onClicked: backend.link("https://github.com/FlashML-org/FreeVideo/releases/latest") } FButton { objectName: "copyError"; visible: !diskFailure; text: t("Copy details", "复制详情"); onClicked: backend.copy(s.error) } FButton { objectName: "showError"; text: errorDetailsOpen ? t("Hide details", "收起详情") : t("Show details", "查看详情"); flat: true; onClicked: errorDetailsOpen = !errorDetailsOpen } + FButton { objectName: "reportProblem"; visible: !diskFailure; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(s.error, s.failure.title || "") } FButton { objectName: "exportError"; visible: !diskFailure; text: t("Export report", "导出报告"); enabled: s.report.status !== "running"; onClicked: backend.exportReport() } } ScrollView { @@ -735,11 +739,11 @@ ApplicationWindow { ColumnLayout { visible: s.status === "review"; Layout.fillWidth: true; spacing: 12 GridLayout { - Layout.fillWidth: true; columns: 2; columnSpacing: 16; rowSpacing: 8 + Layout.fillWidth: true; columns: win.narrow ? 1 : 2; columnSpacing: 16; rowSpacing: win.narrow ? 2 : 8 FText { text: "ComfyUI"; color: theme.muted; font.pixelSize: theme.micro + 1 } - FText { text: s.review.comfy; font.pixelSize: theme.micro + 1; Layout.fillWidth: true; elide: Text.ElideMiddle; maximumLineCount: 1 } + FText { text: s.review.comfy; font.pixelSize: theme.micro + 1; Layout.fillWidth: true; elide: Text.ElideMiddle; maximumLineCount: 1; wrapMode: Text.NoWrap } FText { text: "FreeVideo"; color: theme.muted; font.pixelSize: theme.micro + 1 } - FText { text: s.review.engine; font.pixelSize: theme.micro + 1; Layout.fillWidth: true; elide: Text.ElideMiddle; maximumLineCount: 1 } + FText { text: s.review.engine; font.pixelSize: theme.micro + 1; Layout.fillWidth: true; elide: Text.ElideMiddle; maximumLineCount: 1; wrapMode: Text.NoWrap } } Rectangle { Layout.fillWidth: true; height: 1; color: theme.border } ColumnLayout { @@ -754,7 +758,7 @@ ApplicationWindow { Layout.fillWidth: true; spacing: 4 FText { objectName: "replaceModel-" + replacement.index - text: replacement.modelData.text; font.pixelSize: theme.micro + 1; Layout.fillWidth: true + text: replacement.modelData.text.replace(/(\d) (GiB|MiB)/g, "$1\u00a0$2"); font.pixelSize: theme.micro + 1; Layout.fillWidth: true } FText { objectName: "replaceModelPath-" + replacement.index @@ -795,7 +799,7 @@ ApplicationWindow { FText { text: modelData.detail; font.pixelSize: theme.micro; Layout.fillWidth: true; color: modelData.state === "ready" ? theme.success : theme.muted } } // What this group downloads, as in the plan; nothing for one already here. - FText { objectName: "modelSize-" + modelData.id; visible: modelData.download > 0; text: bytes(modelData.download); font.pixelSize: theme.micro; color: theme.muted; font.features: { "tnum": 1 } } + FText { objectName: "modelSize-" + modelData.id; visible: modelData.download > 0; text: bytes(modelData.download).replace(" ", "\u00a0"); font.pixelSize: theme.micro; color: theme.muted; font.features: { "tnum": 1 } } FButton { objectName: "modelFiles-" + modelData.id; text: downloads.copy.title + " ›"; implicitHeight: theme.heightSm; flat: true; font.pixelSize: theme.micro + 1; visible: s.form.model_method === "manual"; onClicked: modelInfoOpen = true } } } @@ -830,6 +834,7 @@ ApplicationWindow { FText { visible: !s.update.phase && !!text; text: releaseSummary(availableRelease); Layout.fillWidth: true; color: theme.muted; font.pixelSize: theme.micro } FButton { text: t("What's new", "更新内容"); flat: true; visible: !s.update.phase && !!availableRelease; implicitHeight: theme.heightSm; onClicked: releaseNotesOpen = true } FButton { objectName: "updateBannerManualDownload"; text: t("Download from the release page ↗", "去发布页手动下载 ↗"); flat: true; visible: s.update.status === "error" && !!s.update.release_page; implicitHeight: theme.heightSm; onClicked: backend.link(s.update.release_page) } + FButton { objectName: "updateBannerReport"; text: t("Report a problem", "提交问题"); flat: true; visible: s.update.status === "error" && !!s.update.error; implicitHeight: theme.heightSm; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } } FCard { objectName: "modelUpgradeCard" @@ -848,6 +853,7 @@ ApplicationWindow { FButton { objectName: "modelUpgradeLater"; visible: ["available", "low-disk", "releasable", "complete", "failed"].indexOf(upgrade.status) >= 0; flat: true text: upgrade.status === "complete" || upgrade.status === "low-disk" ? t("Close", "关闭") : t("Later", "稍后"); onClicked: backend.dismissModelUpgrade() } FButton { objectName: "modelUpgradeStop"; visible: upgrade.status === "downloading"; flat: true; text: t("Stop download", "停止下载"); onClicked: backend.cancelModelUpgrade() } + FButton { objectName: "modelUpgradeReport"; visible: upgrade.status === "failed"; flat: true; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(upgradeExplanation(), t("Model upgrade", "模型升级")) } FButton { objectName: "modelUpgradeButton"; primary: true visible: upgrade.status === "available" || upgrade.status === "releasable" @@ -951,6 +957,7 @@ ApplicationWindow { FButton { objectName: "imagePackRemove"; visible: s.image_pack.removable_bytes > 0 && !s.image_pack.busy; text: t("Remove", "删除"); flat: true; enabled: s.image_pack.can_act; onClicked: backend.imagePackAction("remove") } } FText { objectName: "imagePackFailureReason"; visible: s.image_pack.install.state === "failed"; text: s.image_pack.detail; color: theme.danger; Layout.fillWidth: true } + FButton { objectName: "imagePackReport"; visible: s.image_pack.install.state === "failed"; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(s.image_pack.detail || "", t("Image pack", "图像模型包")) } Flow { visible: s.image_pack.install.state === "failed" && !!s.image_pack.report_id Layout.fillWidth: true; spacing: 8 @@ -965,7 +972,7 @@ ApplicationWindow { objectName: "promptModelCard" visible: !!s.prompt_model && !s.prompt_model.ready && (s.prompt_model.suggest || s.prompt_model.busy || s.prompt_model.checking || ["failed", "cancelled"].indexOf(s.prompt_model.install.state) >= 0) - Layout.fillWidth: true; padding: 16; spacing: 10 + Layout.fillWidth: true; padding: 22; spacing: 10 FText { objectName: "promptModelText"; Layout.fillWidth: true; wrapMode: Text.WordWrap text: win.promptModelLine() @@ -980,6 +987,7 @@ ApplicationWindow { text: s.prompt_model.suggest ? t("Download", "下载") : t("Resume", "继续"); enabled: !s.busy; onClicked: backend.promptModelAction("install") } FButton { objectName: "promptModelPause"; visible: s.prompt_model.busy; text: t("Pause", "暂停"); onClicked: backend.promptModelAction("cancel") } FButton { objectName: "promptModelDismiss"; visible: s.prompt_model.suggest; text: t("Don't ask again", "不再提示"); flat: true; onClicked: backend.dismissPromptHint() } + FButton { objectName: "promptModelReport"; visible: s.prompt_model.install.state === "failed"; text: t("Report a problem", "提交问题"); flat: true; onClicked: backend.reportProblem(win.promptModelLine(), t("Prompt model", "提示词模型")) } } } } @@ -988,18 +996,62 @@ ApplicationWindow { } } Rectangle { - id: footer; height: shortWindow ? 64 : 76; anchors.bottom: parent.bottom; width: parent.width; color: theme.canvas + id: footer; objectName: "footer" + height: win.narrow ? (narrowActions.twoRows ? 112 : 64) : shortWindow ? 64 : 76 + anchors.bottom: parent.bottom; width: parent.width; color: theme.canvas Rectangle { height: 1; width: parent.width; color: theme.border } RowLayout { + visible: !win.narrow 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) } + FButton { objectName: win.narrow ? "" : "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 && !s.cleanup.busy; text: t("Pause", "暂停"); onClicked: backend.action("stop", false) } + FButton { objectName: win.narrow ? "" : "launchInstalledButton"; visible: updateFirst && s.retry_kind !== "launch"; flat: true; text: t("Launch current version", "启动当前版本"); onClicked: backend.action("primary", accepted) } + FButton { + objectName: win.narrow ? "" : "primaryButton"; primary: !s.repair_offered && !(s.retry_kind === "launch" && engineUpdate.visible) && !manualFailure; implicitWidth: Math.max(160, contentItem.implicitWidth+40); implicitHeight: theme.heightLg + text: s.setup_pending ? s.setup_wait_text : s.busy ? t("Working…", "正在处理…") : primaryText() + enabled: !s.busy && !s.setup_pending && !(s.page === "launcher" && s.needs_consent && !accepted) + && !(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) } + } + } + Item { + id: narrowActions + visible: win.narrow + anchors.fill: parent; anchors.leftMargin: 12; anchors.rightMargin: 12 + // Reserve another row only when the optional launch action needs it. + readonly property bool twoRows: launchInstalled.visible && + (backButton.visible ? backButton.width + 8 : 0) + + (pauseButton.visible ? pauseButton.width + 8 : 0) + + launchInstalled.width + 8 + primaryButton.implicitWidth > width + FButton { + id: backButton; objectName: win.narrow ? "backButton" : "" + anchors.left: parent.left; y: narrowActions.twoRows ? 10 : (parent.height - height) / 2 + text: t("Back", "上一步"); flat: true; visible: s.page === "models" || s.page === "progress"; enabled: !s.busy; onClicked: backend.action("back", false) + } + FButton { + id: pauseButton; anchors.left: backButton.visible ? backButton.right : parent.left; anchors.leftMargin: backButton.visible ? 8 : 0 + y: (parent.height - height) / 2 + visible: s.busy && !s.cleanup.busy; text: t("Pause", "暂停"); onClicked: backend.action("stop", false) + } // After a failed start, "Retry launch" already starts the installed version: one button, and the update banner leads. // Other retries (an import) start something else, so this one stays. - FButton { objectName: "launchInstalledButton"; visible: updateFirst && s.retry_kind !== "launch"; flat: true; text: t("Launch current version", "启动当前版本"); onClicked: backend.action("primary", accepted) } FButton { - objectName: "primaryButton"; primary: !s.repair_offered && !(s.retry_kind === "launch" && engineUpdate.visible) && !manualFailure; implicitWidth: Math.max(160, contentItem.implicitWidth+40); implicitHeight: theme.heightLg + id: launchInstalled; objectName: win.narrow ? "launchInstalledButton" : "" + anchors.right: narrowActions.twoRows ? parent.right : primaryButton.left; anchors.rightMargin: narrowActions.twoRows ? 0 : 8 + y: narrowActions.twoRows ? 10 : (parent.height - height) / 2 + visible: updateFirst && s.retry_kind !== "launch"; flat: true; text: t("Launch current version", "启动当前版本"); onClicked: backend.action("primary", accepted) + } + FButton { + id: primaryButton; objectName: win.narrow ? "primaryButton" : "" + anchors.right: parent.right; y: narrowActions.twoRows ? parent.height - height - 10 : (parent.height - height) / 2 + primary: !s.repair_offered && !(s.retry_kind === "launch" && engineUpdate.visible) && !manualFailure + leftPadding: 12; rightPadding: 12 + singleLine: true + implicitWidth: Math.max(120, contentItem.implicitWidth + leftPadding + rightPadding); implicitHeight: theme.heightLg + width: Math.min(implicitWidth, Math.max(0, narrowActions.width - (narrowActions.twoRows ? 0 : + (backButton.visible ? backButton.width + 8 : 0) + (pauseButton.visible ? pauseButton.width + 8 : 0) + + (launchInstalled.visible ? launchInstalled.width + 8 : 0)))) text: s.setup_pending ? s.setup_wait_text : s.busy ? t("Working…", "正在处理…") : primaryText() enabled: !s.busy && !s.setup_pending && !(s.page === "launcher" && s.needs_consent && !accepted) && !(s.page === "progress" && s.status === "review" && !s.retry_kind && (!accepted || !!s.error)) @@ -1399,6 +1451,7 @@ ApplicationWindow { Layout.fillWidth: true; spacing: 8 FButton { text: t("Copy details", "复制详情"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: backend.copy(s.error) } FButton { text: t("Export report", "导出报告"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; enabled: s.report.status !== "running"; onClicked: backend.exportReport() } + FButton { objectName: "settingsReportProblem"; text: t("Report a problem", "提交问题"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: backend.reportProblem(s.error, s.failure.title || "") } } FText { visible: s.report.status !== "idle"; Layout.fillWidth: true; font.pixelSize: theme.micro; color: theme.muted @@ -1659,6 +1712,7 @@ 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 } FButton { objectName: "updateManualDownloadButton"; text: t("Download from the release page ↗", "去发布页手动下载 ↗"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.release_page; onClicked: backend.link(s.update.release_page) } + FButton { objectName: "updateDialogReport"; text: t("Report a problem", "提交问题"); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.error; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } } } RowLayout { diff --git a/freevideo_engine/launcher_issue.py b/freevideo_engine/launcher_issue.py new file mode 100644 index 00000000..f0e393ed --- /dev/null +++ b/freevideo_engine/launcher_issue.py @@ -0,0 +1,41 @@ +"""The launcher's "Report a problem" page: the installation/launcher form with the failure already in it. + +Only the sentence the failure card shows travels. It has gone through the launcher's redaction already; +paths and long tokens are removed once more here, and the end of a long log gives way to fit the address. +""" +from urllib.parse import urlencode + +from .feedback import scrub + +ISSUE_FORM = 'https://github.com/FlashML-org/FreeVideo/issues/new' +# GitHub refuses very long addresses. +MAX_ADDRESS = 7000 +MAX_PROBLEM = 600 +MAX_REPORT = 2500 + + +def _lines(error): + return [line for line in scrub(error).splitlines() if line.strip()] + + +def issue_url(error, version=None, title=None, zh=False): + lines = _lines(error) + first = (lines[-1] if lines and lines[0].startswith('Traceback') else (lines[0] if lines else '')).strip() + place = '启动器' if zh else 'Launcher' + problem = ('FreeVideo 启动器显示了一个错误。\n' if zh else 'The FreeVideo launcher showed an error.\n') + first[:300] + report = '\n'.join(lines) + + def fit(limit): + return report if len(report) <= limit else report[:limit // 3] + '\n...\n' + report[-(limit - limit // 3 - 5):] + + def build(body): + params = {'template': 'bug_launcher.yml', 'title': f'[{place}] ' + (str(title).strip() if title else place), + 'problem': problem[:MAX_PROBLEM], 'report': body} + if version: + params['version'] = 'v' + str(version).lstrip('v') + return ISSUE_FORM + '?' + urlencode(params) + + limit = MAX_REPORT + while len(build(fit(limit))) > MAX_ADDRESS and limit > 50: + limit = int(limit * 0.8) + return build(fit(limit)) diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 15f24273..e16d0eea 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -172,7 +172,7 @@ def __init__(self, source=None, *, controller=None, store=None, updater=None, sm from .launcher_update import current_build, UpdateClient identity = current_build() if identity: - self.updater = UpdateClient(identity, launcher_root()) + self.updater = UpdateClient(identity, launcher_root(), language=self.language) if self.updater and not self.updater.busy: self.updater.run('check') self.update_checked = time.monotonic() @@ -252,7 +252,10 @@ def edit(self, key, value): self.explain_busy() return if key == 'language': - self.language = str(value); self.persist(); return + self.language = str(value) + if self.updater: + self.updater.language = self.language + self.persist(); return if key == 'token': if self.controller.busy: raise ValueError(self.t('Pause installation before changing the token.', '请先暂停安装,再修改 Token。')) diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py index 8598c7c8..8f684d44 100644 --- a/freevideo_engine/launcher_update.py +++ b/freevideo_engine/launcher_update.py @@ -5,6 +5,8 @@ """ import errno import hashlib +import logging +from concurrent.futures import ThreadPoolExecutor, as_completed from email.utils import parsedate_to_datetime import json import os @@ -19,11 +21,35 @@ import urllib.request from . import execution_paths +from .top_level_modules import load from .monitoring import save from .long_paths import io_path, resolved, plain, plain_errors -from . import disk_space +from . import disk_space, network +from .launcher_settings import default_language +from .system import windows + +ed25519, keys = load('freevideo_ed25519'), load('freevideo_update_keys') REPOSITORY = 'FlashML-org/FreeVideo' +# Mirror repository on the ModelScope domestic site; the release script uploads each release there. +MIRROR_BASE = 'https://modelscope.cn/models/BowenXue/FreeVideo-releases/resolve/master' +# ModelScope redirects large files to its regional CDN: cdn-lfs-cn-.modelscope.cn. +MIRROR_CDN = re.compile(r'cdn-lfs-cn-[0-9]{1,2}\.modelscope\.cn') +UPDATE_STALL_SECONDS = 30 +MANIFEST_CHECK_SECONDS = 20 +_metadata_budget = threading.local() +log = logging.getLogger(__name__) + + +def mirror_url(relative): + return MIRROR_BASE + '/' + urllib.parse.quote(relative, safe='/') + + +def source_order(language=None): + language = default_language() if language is None else language + return ('mirror', 'github') if language.lower().startswith('zh') else ('github', 'mirror') + + # The channel names the platform; launchers from before combined releases read # these tags, which the release workflow keeps up to date for them. CHANNEL = 'windows-preview' @@ -42,6 +68,10 @@ MAC_CHANNEL: ('FreeVideo-Mac-arm64.dmg', 'update-macos.json'), LINUX_CHANNEL: ('FreeVideo-Linux-x86_64.AppImage', 'update-linux.json')} CHANNELS = tuple(RELEASE_ASSETS) +MANIFEST_DOMAIN = b'FreeVideo update manifest v1\x00' +# The mirror is only a source for packages whose own signature the launcher can +# check after download; DMG and AppImage have none, so they stay on GitHub. +MIRROR_CHANNELS = (CHANNEL,) RETRY_SECONDS = 60 MAX_RETRY_SECONDS = 15 * 60 MAX_DOWNLOAD_RETRIES = 3 @@ -178,6 +208,9 @@ def current_build(): if not getattr(sys, 'frozen', False): return None try: + import freevideo_source_bootstrap + if freevideo_source_bootstrap.ACTIVE: + return build_identity(freevideo_source_bootstrap.ACTIVE['manifest']['build']) return build_identity(json.loads((io_path(sys._MEIPASS) / 'launcher-build.json').read_text(encoding='utf-8'))) except (OSError, ValueError, TypeError): return None @@ -222,40 +255,62 @@ def newer(candidate, current): def _trusted_url(url): + if any(ord(character) <= 32 for character in url): + raise ValueError('Invalid update URL') parsed = urllib.parse.urlsplit(url) - if (parsed.scheme != 'https' or parsed.username or parsed.password or parsed.port not in (None, 443) - or parsed.hostname not in ('api.github.com', 'github.com', 'release-assets.githubusercontent.com', - 'objects.githubusercontent.com', 'github-releases.githubusercontent.com')): - raise ValueError('Update redirect is not an approved HTTPS GitHub download') + if (parsed.scheme != 'https' or parsed.username is not None or parsed.password is not None or parsed.port not in (None, 443) + or not (parsed.hostname in ('api.github.com', 'github.com', 'release-assets.githubusercontent.com', + 'objects.githubusercontent.com', 'github-releases.githubusercontent.com', + 'modelscope.cn', 'www.modelscope.cn') + or MIRROR_CDN.fullmatch(parsed.hostname or ''))): + raise ValueError('Update redirect is not an approved HTTPS download') + + +def _github_url(url): + _trusted_url(url) + if urllib.parse.urlsplit(url).hostname not in ( + 'api.github.com', 'github.com', 'release-assets.githubusercontent.com', + 'objects.githubusercontent.com', 'github-releases.githubusercontent.com'): + raise ValueError('Source trust requires GitHub HTTPS') class UpdateRedirect(urllib.request.HTTPRedirectHandler): + def __init__(self, *, github_only=False): + super().__init__() + self.github_only = github_only + def redirect_request(self, request, fp, code, msg, headers, newurl): - _trusted_url(newurl) + (_github_url if self.github_only else _trusted_url)(newurl) + if time.monotonic() >= getattr(_metadata_budget, 'deadline', float('inf')): + raise TimeoutError('Update metadata deadline exceeded') redirected = super().redirect_request(request, fp, code, msg, headers, newurl) if urllib.parse.urlsplit(newurl).hostname != 'api.github.com': redirected.remove_header('Authorization') return redirected -def open_download(url, token='', *, binary=False): +def open_download(url, token='', *, binary=False, github_only=False): # One inherited route, then one direct route. Do not mutate shell, Git or - # registry proxies. Never retry a slow-but-active binary on another source. + # registry proxies. Package bodies use network.download instead. global _api_retry_at is_api = url.startswith(API + '/') if not is_api and not (url.startswith(WEB + '/releases/download/') - or url.startswith(RELEASE_PAGE + '/download/')): + or url.startswith(RELEASE_PAGE + '/download/') + or url.startswith(MIRROR_BASE + '/')): raise ValueError('Unexpected update download address') - _trusted_url(url) + (_github_url if github_only else _trusted_url)(url) if is_api and time.time() < _api_retry_at: raise UpdateUnavailable('rate_limited', _api_retry_at - time.time()) for direct in (False, True): - opener = urllib.request.build_opener(urllib.request.ProxyHandler({} if direct else None), UpdateRedirect()) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({} if direct else None), UpdateRedirect(github_only=github_only)) headers = {'User-Agent': 'FreeVideo-launcher', 'Accept': 'application/octet-stream' if binary else 'application/vnd.github+json'} if token and is_api: headers['Authorization'] = 'Bearer ' + token try: - return opener.open(urllib.request.Request(url, headers=headers), timeout=5) + remaining = getattr(_metadata_budget, 'deadline', time.monotonic()+5) - time.monotonic() + if remaining <= 0: + raise TimeoutError('Update metadata deadline exceeded') + return opener.open(urllib.request.Request(url, headers=headers), timeout=min(5, remaining)) except urllib.error.HTTPError as error: if failure_kind(error) == 'rate_limited': if is_api: @@ -269,7 +324,7 @@ def open_download(url, token='', *, binary=False): def _bytes(response, limit, cancel=None, seconds=15): - deadline = time.monotonic() + seconds + deadline = min(time.monotonic() + seconds, getattr(_metadata_budget, 'deadline', float('inf'))) done = 0 read = getattr(response, 'read1', response.read) while True: @@ -350,7 +405,7 @@ def _selection(candidate, channel, track): raise ValueError('Update metadata belongs to a different release track') -def latest_release(token='', *, channel=CHANNEL, track='stable'): +def _github_release(token='', *, channel=CHANNEL, track='stable'): """Read and validate the published build without downloading an executable.""" if channel not in CHANNELS: raise ValueError('Unknown launcher update channel') @@ -378,9 +433,104 @@ def latest_release(token='', *, channel=CHANNEL, track='stable'): return candidate -def check(current, token=''): +def manifest_signature_ok(data, signature): + """Whether `signature` (hex) is the release key's signature over the manifest bytes.""" + try: + return (keys.ED25519_RELEASE_KEY_ENABLED and + keys.ED25519_PUBLIC_KEY != keys.PLACEHOLDER_PUBLIC_KEY and + ed25519.verify(keys.ED25519_PUBLIC_KEY, MANIFEST_DOMAIN + data, + bytes.fromhex(signature.decode('ascii').strip()))) + except (TypeError, ValueError, UnicodeDecodeError): + return False + + +def _mirror_bytes(url, limit): + with open_download(url, '', binary=True) as response: + return b''.join(_bytes(response, limit)) + + +def _mirror_release(channel, track): + if channel not in MIRROR_CHANNELS: + raise ValueError('No mirror for this platform') + name = RELEASE_ASSETS[channel][1] + data = _mirror_bytes(mirror_url('channels/stable/' + name), MAX_MANIFEST_BYTES) + # A mirror is not the publisher: its manifest counts only with the release + # signature, otherwise this source is skipped and GitHub still decides. + if not manifest_signature_ok(data, _mirror_bytes(mirror_url('channels/stable/' + name + '.sig'), 256)): + raise ValueError('Mirror manifest signature is not valid') + candidate = manifest(json.loads(data)) + _selection(candidate, channel, track) + if not candidate.get('product_version'): + raise ValueError('Mirror updates require a product version') + return candidate + + +def _choose_release(found, order): + github, mirror = found.get('github'), found.get('mirror') + conflict = (github is not None and mirror is not None + and github.get('product_version') == mirror.get('product_version') + and github['asset']['sha256'] != mirror['asset']['sha256']) + if conflict: + selected = github + else: + selected = next(found[name] for name in order if name in found) + for candidate in found.values(): + if newer(candidate, selected): + selected = candidate + # Local provenance, never read from remote JSON. Persist it with the reminder + # so a mirror-only check still requires the same verification after restart. + result = dict(selected) + if mirror is None: + return result + result['_update_sources'] = dict(github='github' in found, mirror='mirror' in found, + mirror_allowed=not conflict) + return result + + +def latest_release(token='', *, channel=CHANNEL, track='stable', language=None, on_candidate=None): + """Race bounded metadata requests; reconcile both before explicit download. + + A first valid result can immediately display a reminder. The background + check settles the other small request before allowing activation/download, + so neither a stale mirror nor a digest conflict can win on latency alone. + """ + if channel not in CHANNELS or track not in TRACKS: + raise ValueError('Unknown launcher update channel or track') + if track != 'stable': + return _github_release(token, channel=channel, track=track) + order = [name for name in source_order(language) if name == 'github' or channel in MIRROR_CHANNELS] + calls = dict(github=lambda: _github_release(token, channel=channel, track=track), + mirror=lambda: _mirror_release(channel, track)) + def fetch(name): + _metadata_budget.deadline = time.monotonic() + MANIFEST_CHECK_SECONDS + try: + return calls[name]() + finally: + del _metadata_budget.deadline + found, failures = {}, {} + with ThreadPoolExecutor(max_workers=2, thread_name_prefix='launcher-manifest') as pool: + pending = {pool.submit(fetch, name): name for name in order} + for future in as_completed(pending): + name = pending[future] + try: + found[name] = future.result() + except (OSError, ValueError, TypeError, AttributeError) as error: + failures[name] = error + if found and on_candidate: + on_candidate(_choose_release(found, order)) + if not found: + raise failures['github'] + return _choose_release(found, order) + + +def check(current, token='', *, language=None, on_candidate=None): current = build_identity(current) - candidate = latest_release(token, channel=current['channel'], track=build_track(current)) + options = {} + if language is not None: + options['language'] = language + if on_candidate is not None: + options['on_candidate'] = on_candidate + candidate = latest_release(token, channel=current['channel'], track=build_track(current), **options) return candidate if newer(candidate, current) else None @@ -408,52 +558,107 @@ def executable_path(root, candidate): return Path(plain(resolved(root))) / 'updates' / candidate['asset']['sha256'] / filename +def authenticode(path): + """Use Windows PowerShell 5.1's WinTrust-backed check, with a literal path. + + Only an exit code leaves PowerShell; certificate/path/provider diagnostics + and user data are never logged. The script contains no interpolated path. + """ + from .windows_ux import external_python, hidden_console + system = Path(os.environ.get('SystemRoot', r'C:\Windows')) + executable = system / 'System32/WindowsPowerShell/v1.0/powershell.exe' + script = ("$ErrorActionPreference='Stop'; try {" + "$s=Get-AuthenticodeSignature -LiteralPath $env:FREEVIDEO_SIGNATURE_PATH;" + "if ($s.Status -eq 'Valid' -and $s.SignerCertificate -and " + "$s.SignerCertificate.Subject -cmatch '(^|,\\s*)CN=FlashML LLC(,|$)' -and " + "$s.SignerCertificate.GetNameInfo([System.Security.Cryptography.X509Certificates.X509NameType]::SimpleName,$false) -ceq 'FlashML LLC') " + "{exit 0}; exit 1} catch {exit 2}") + env = dict(os.environ, FREEVIDEO_SIGNATURE_PATH=str(io_path(resolved(path)))) + try: + with external_python(): + result = execution_paths.run([str(executable), '-NoLogo', '-NoProfile', '-NonInteractive', + '-Command', script], env=env, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=20, **hidden_console()) + return {0: True, 1: False}.get(result.returncode) # None: the check itself could not be completed + except (OSError, subprocess.SubprocessError): + return None + + +def _signature_verifier(candidate, provenance): + if not windows() or build_target(candidate) != 'windows-x86_64' or provenance.get('github', True): + return None + current = authenticode(sys.executable) + enforced = current is not False # A check that could not run is treated as signed: fail closed, switch source. + def verify(path): + valid = authenticode(path) is True + (log.info if valid else log.warning)('Launcher Authenticode: current=%s enforced=%s valid=%s', + current, enforced, valid) + return valid or not enforced + return verify + + +def download_sources(candidate, language=None): + sources = {'github': _asset_url(candidate)} + provenance = candidate.get('_update_sources', {}) + if (build_track(candidate) == 'stable' and candidate.get('product_version') + and candidate.get('channel') in MIRROR_CHANNELS and provenance.get('mirror_allowed', True)): + sources['mirror'] = mirror_url('releases/v' + candidate['product_version'] + '/' + + RELEASE_ASSETS[candidate['channel']][0]) + return [(name, sources[name]) for name in source_order(language) if name in sources] + + +def download_fallbacks(candidate, token=''): + if time.time() < _api_retry_at: + return {} + headers = ('Accept: application/octet-stream',) + if token: + headers += ('Authorization: Bearer ' + token,) + return {'github': (API + '/releases/assets/' + str(candidate['asset']['id']), headers)} + + @plain_errors -def download(candidate, root, token='', *, progress=None, cancel=None): - candidate = manifest(candidate) +def download(candidate, root, token='', *, progress=None, cancel=None, language=None): + provenance = candidate.get('_update_sources', {}) + candidate = dict(manifest(candidate), _update_sources=provenance) + from .source_update import try_download + overlay = try_download(candidate, root, token, progress=progress, cancel=cancel, language=language) + if overlay is not None: + return overlay asset = candidate['asset'] target = executable_path(root, candidate) - if verified(target, asset): + def cancelled(): + if cancel is not None and cancel.is_set(): + raise InterruptedError('Update stopped before activation') + cancelled() + verify_signature = _signature_verifier(candidate, provenance) + if verified(target, asset) and (verify_signature is None or verify_signature(target)): + cancelled() return target io_path(target.parent).mkdir(parents=True, exist_ok=True) - # 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 - if disk_space.free_bytes(target.parent) < asset['bytes'] + 16*2**20: + # Only the selected cache artifact is ours. Other update versions/attempts + # and the original launcher remain untouched. + io_path(target).unlink(missing_ok=True) + partial = io_path(str(target) + '.partial') + retained = min(asset['bytes'], partial.stat().st_size) if partial.is_file() else 0 + if disk_space.free_bytes(target.parent) < asset['bytes'] - retained + 16*2**20: raise UpdateUnavailable('disk') - start, done = time.monotonic(), 0 - stage = None + def report(done, total, speed): + cancelled() + if progress: + progress(dict(done=done, total=total, bytes_per_second=speed, + remaining_seconds=(total-done)/speed if speed > 0 else 0)) try: - try: - response = open_download(_asset_url(candidate), '', binary=True) - except OSError as error: - response = _fallback(error, lambda: open_download(API + '/releases/assets/' + str(asset['id']), token, binary=True)) - with response: - with tempfile.NamedTemporaryFile(prefix='download-', suffix='.partial', dir=io_path(target.parent), delete=False) as stream: - stage = Path(plain(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') - if build_target(candidate) == 'linux-x86_64': - io_path(stage).chmod(0o755) # An AppImage runs itself. - io_path(stage).replace(io_path(target)) - return target - finally: - if stage is not None: - try: - io_path(stage).unlink(missing_ok=True) - except OSError: - pass # A cleanup failure must not mask the download error. + network.download(download_sources(candidate, language), io_path(target), asset['sha256'], report, + network=dict(quiet=True, resource_check=cancelled), size=asset['bytes'], + stall_seconds=UPDATE_STALL_SECONDS, low_speed_limit=0, cycles=1, + category='launcher-update', validate_url=_trusted_url, verify=verify_signature, + fallbacks=download_fallbacks(candidate, token)) + except network.DownloadError as error: + raise UpdateUnavailable('invalid' if getattr(error, 'integrity', False) else 'connection') from error + cancelled() + if build_target(candidate) == 'linux-x86_64': + io_path(target).chmod(0o755) + return target class DownloadedLauncherUnavailable(ValueError): @@ -463,6 +668,10 @@ class DownloadedLauncherUnavailable(ValueError): @plain_errors def launch_download(candidate, root, *, token='', popen=None): candidate = manifest(candidate) + from . import source_update + overlay = source_update.prepared(candidate, root) + if overlay is not None: + return source_update.launch(candidate, root, overlay, token=token, popen=popen) if build_target(candidate) == 'macos-arm64': raise ValueError('Mac update activation is not available in this development build') path = executable_path(root, candidate) @@ -545,18 +754,22 @@ def resumed_update(current, root, *, seconds=600): class UpdateClient: @plain_errors - def __init__(self, current, root, token=None): + def __init__(self, current, root, token=None, *, language=None): # Python 3.9 on Windows can leave a nonexistent relative path relative # in resolve(). Updates must survive changing cwd to the EXE directory. self.current, self.root = build_identity(current), Path(plain(resolved(root))) self.token = token if token is not None else os.environ.get('GITHUB_TOKEN') or os.environ.get('GH_TOKEN', '') + self.language = language self.state = dict(status='idle') self.available = None # Older launchers saved this as a mandatory update. Read it only as a # cached reminder; a known newer version never prevents using this one. for name in ('available.json', 'required.json'): try: - candidate = manifest(json.loads((io_path(self.root) / 'updates' / name).read_text(encoding='utf-8'))) + cached = json.loads((io_path(self.root) / 'updates' / name).read_text(encoding='utf-8')) + candidate = manifest(cached) + if isinstance(cached.get('_update_sources'), dict): + candidate['_update_sources'] = cached['_update_sources'] if newer(candidate, self.current) and (self.available is None or newer(candidate, self.available)): self.available = candidate except (OSError, ValueError, TypeError): @@ -607,10 +820,20 @@ def work(): selected = candidate try: if operation == 'check': - found = check(self.current, self.token) + def noticed(value): + if not self.cancelled.is_set() and newer(value, self.current): + shown = self.available + if shown is None or newer(value, shown): + self.state = dict(status='available', candidate=value) + found = check(self.current, self.token, language=self.language, on_candidate=noticed) if self.cancelled.is_set(): raise InterruptedError('Update check cancelled') - if found and (self.available is None or newer(found, self.available)): + if found and (self.available is None or newer(found, self.available) + or (found['revision'] == self.available['revision'] + and found['built_at'] == self.available['built_at'] + and found.get('product_version') == self.available.get('product_version')) + or (found.get('product_version') == self.available.get('product_version') + and found.get('_update_sources', {}).get('mirror_allowed') is False)): self.available = found save(self.root / 'updates/available.json', found) self.state = dict(status='available' if self.available else 'current', candidate=self.available) @@ -618,12 +841,12 @@ def work(): if _retry and (not candidate['asset'].get('url') or build_track(candidate) == 'nightly' or candidate['asset']['url'] == asset_url(candidate['channel'], RELEASE_ASSETS[candidate['channel']][0])): # A rolling release or a legacy latest URL may have moved. - found = check(self.current, self.token) + found = check(self.current, self.token, language=self.language) if found and newer(found, candidate): selected = found def progress(value): self.state = dict(status='downloading', candidate=selected, progress=value) - download(selected, self.root, self.token, progress=progress, cancel=self.cancelled) + download(selected, self.root, self.token, progress=progress, cancel=self.cancelled, language=self.language) self.state = dict(status='ready', candidate=selected) self.failures = 0 except Exception as error: diff --git a/freevideo_engine/launcher_view.py b/freevideo_engine/launcher_view.py index 2309c2ba..9ce17936 100644 --- a/freevideo_engine/launcher_view.py +++ b/freevideo_engine/launcher_view.py @@ -198,6 +198,8 @@ def build(app): app.detail_button.configure(style='Quiet.TButton'); app.detail_button.pack(side='left') app.copy_error_button = app.button(tools, 'Copy error', '复制错误', app.copy_error) app.copy_error_button.configure(style='Quiet.TButton') + app.report_error_button = app.button(tools, 'Report a problem', '提交问题', app.report_error) + app.report_error_button.configure(style='Quiet.TButton') from . import __version__ ttk.Label(tools, text=__version__, style='Meta.TLabel').pack(side='right') app.update_row = tools diff --git a/freevideo_engine/lora_check.py b/freevideo_engine/lora_check.py index cba3a759..c031fac5 100644 --- a/freevideo_engine/lora_check.py +++ b/freevideo_engine/lora_check.py @@ -145,6 +145,8 @@ def assess(shapes, *, int8_targets=None, skipped=None): if int8_targets is not None: target = resolve_target(target, int8_targets) reason = int8_skip_reason(target, int8_targets) + if reason == 'other_layer' and family(keys): + return _bad('other-model', keys, layer=name) if reason: skipped.append((reason, target)) continue @@ -167,6 +169,8 @@ def assess(shapes, *, int8_targets=None, skipped=None): for target, split, swap in _targets(name): if int8_targets is not None: target = resolve_target(target + ('#bias' if bias else ''), int8_targets) + if int8_skip_reason(target, int8_targets, full=True) == 'other_layer' and family(keys): + return _bad('other-model', keys, layer=name) skipped.append((int8_skip_reason(target, int8_targets, full=True), target)) continue found = _find(target) diff --git a/freevideo_engine/macos_compute.py b/freevideo_engine/macos_compute.py index 2bdbea33..fcd0bf7c 100644 --- a/freevideo_engine/macos_compute.py +++ b/freevideo_engine/macos_compute.py @@ -82,6 +82,10 @@ def sequence_tokens(canvas, prompt_rows): audio=MINIMAX_H3_AUDIO_CHANNELS * audio_latent_num_frames(canvas['frames']), reference_video=canvas.get('reference_video_tokens', 0), reference_audio=canvas.get('reference_audio_tokens', 0)) + if 'continuation_tokens' in canvas: + # Additional resident prefix copies and row-time workspace; the prefix + # itself occupies generated rows rather than changing the packed layout. + rows['continuation_context'] = canvas['continuation_tokens'] if any(type(value) is not int or value < 0 for value in rows.values()): raise ValueError('Packed row counts must be nonnegative integers') return dict(rows, total=sum(rows.values())) @@ -135,7 +139,7 @@ def generated_audio_tokens(frames): def request_admission(resource, sampling, *, preview=False, working_set_bytes=None, reference_tokens=0, - text_tokens=None): + text_tokens=None, continuation=None): """Before any model loads: does this request's largest sampling pass fit? Each sampling stage plans against the request's allocator capacity, live @@ -159,7 +163,19 @@ def request_admission(resource, sampling, *, preview=False, working_set_bytes=No canvas = sampling['second' if phase == 'refinement' else 'first'] shape = geometry(canvas['width'], canvas['height'], frames=canvas['frames']) conditioning_tokens = 1024 if text_tokens is None else text_tokens + generated_audio_tokens(canvas['frames']) - tokens, frames = shape['video_tokens'] + conditioning_tokens + reference_tokens, shape['latent_frames'] + continuation_tokens = 0 + if continuation is not None: + from .continuation import LATENT_FRAMES + continuation_tokens = (LATENT_FRAMES * (canvas['width'] // 32) * (canvas['height'] // 32) + + 2 * continuation['audio_latents']) + if 'tail' in continuation: + # Encoding both canvases and the 15 s sound window precedes model loading. + from .continuation_tail import AUDIO_WINDOW_TICKS + first = sampling['first'] + continuation_tokens += (LATENT_FRAMES * (first['width'] // 32) * (first['height'] // 32) + + 2 * AUDIO_WINDOW_TICKS) + tokens, frames = (shape['video_tokens'] + conditioning_tokens + reference_tokens + continuation_tokens, + shape['latent_frames']) needed = bounded_working_bytes(tokens, frames=frames) + GiB + LAYER_BYTES + PREFETCH_BYTES capacity, allowance = resource['allocator_capacity_bytes'], resource['allocator_bytes'] if working_set_bytes is not None: @@ -168,7 +184,7 @@ def request_admission(resource, sampling, *, preview=False, working_set_bytes=No return dict(state=state, phase=phase, needed_bytes=int(needed), allowance_bytes=int(allowance), capacity_bytes=int(capacity), tokens=tokens, width=canvas['width'], height=canvas['height'], frames=canvas['frames'], working_set_bytes=working_set_bytes, reference_tokens=reference_tokens, - text_tokens=text_tokens, + text_tokens=text_tokens, continuation_tokens=continuation_tokens, scope='Smallest grouped-projection plan of the largest sampling pass, before model load.') diff --git a/freevideo_engine/macos_continuation.py b/freevideo_engine/macos_continuation.py new file mode 100644 index 00000000..e53efc7d --- /dev/null +++ b/freevideo_engine/macos_continuation.py @@ -0,0 +1,151 @@ +"""Native process wiring for the shared continuation contract (no tensor imports).""" +from pathlib import Path + +from . import continuation as chain +from . import continuation_tail as encoded + + +def prepare(args, canvas, media, artifacts): + """Admit source context and optional film-wide references before encoding.""" + source = getattr(args, 'continue_from', None) + anchor = getattr(args, 'continue_anchor', None) + if anchor is not None and source is None: + raise ValueError('--continue-anchor requires --continue-from') + if source is None: + return None, media, {} + if anchor is not None: + media = chain.anchor_media(media, anchor) + pictures = chain.continuation_pictures(media) if anchor is None else [] + anchored = anchor is not None or bool(pictures) + def validate(): + chain.validate_route(task=args.task or ('ref2va' if anchored else 't2va'), backend='mps', + anchor=anchored, two_pass=args.two_pass, preview=getattr(args, 'preview', False), + refine_from=getattr(args, 'refine_from', None), media=media) + validate() + if getattr(args, 'continuation_snapshot', None): + request = chain.restore_snapshot(args.continuation_snapshot, + getattr(args, 'continuation_snapshot_sha256', None), canvas) + else: + try: + request = chain.prepare(source, canvas) + except ValueError as error: + if str(error) != chain.MISSING: + raise + request = encoded.pending(source, canvas) + request = dict(request, audio_context=chain.audio_context(), source_video=str(source)) + details = {} + if getattr(args, 'first_frame_reference', False): + if anchor is not None: + raise ValueError('Choose --first-frame-reference or --continue-anchor, not both') + first = details['first_frame_reference'] = dict(requested=True, used=False) + if pictures: + first['reason'] = 'pictures' + else: + try: + kept = None if 'tail' in request else chain.chain_first_frame_picture(request, source) + except ValueError: + kept = None + first['reason'] = 'unreadable' + if kept is None: + first.setdefault('reason', 'source_predates') + else: + raw, width, height = kept + anchor = artifacts / 'first-frame.png' + anchor.write_bytes(raw) + media = chain.anchor_media(media, anchor) + anchored = True + validate() + first.update(used=True, width=width, height=height) + if anchored: + request['anchor'] = media['references'][0] + if pictures: + request['pictures'] = len(pictures) + details['continue_pictures'] = len(pictures) + if anchor is not None: + details['continue_anchor'] = request['anchor'] + if getattr(args, 'voice_reference', False): + from .generate import dialogue_reason + reason = ('first_frame' if 'first_frame_reference' in details and not pictures else + 'anchor' if anchor is not None else 'conditioning' if args.conditioning else + dialogue_reason(Path(args.prompt_file).read_text(encoding='utf-8') if args.prompt_file else None)) + details['voice_reference'] = dict(requested=True, used=False, reason=reason) + return request, media, details + + +def freeze(request, canvas, sampling, cache, base, artifacts): + """Check final weights/first-pass size, freeze fallback files, then persist once.""" + if request is None: + return None + import json + manifest = json.loads((Path(cache) / 'manifest.json').read_text(encoding='utf-8')) + if 'tail' not in request: + try: + request = chain.validate_snapshot(request, canvas, manifest, sampling) + except ValueError as error: + if str(error) != chain.MISSING: + raise + request = dict(encoded.pending(request['source_video'], canvas), + **{key: request[key] for key in ('audio_context', 'source_video', 'anchor', 'pictures', 'sound') if key in request}) + if 'tail' in request: + return encoded.freeze(request, canvas, sampling['first'], artifacts, base) + if 'snapshot' not in request: + return chain.persist_snapshot(request, artifacts / 'continuation-source.safetensors') + return request + + +def voice(request, media, details, artifacts): + """Use the film's opening sound only after final context compatibility checks.""" + report = details.get('voice_reference') + if report is None or report['reason'] is not None: + return media + if 'tail' in request: + report['reason'] = 'source_predates' + return media + try: + kept = chain.chain_first_sound(request, request.get('source_video')) + raw = chain.unpack_first_sound(kept) if kept is not None else None + except ValueError: + raw = None + if raw is None: + report['reason'] = 'unreadable' + return media + from .media_request import digest, task_for + path = artifacts / 'first-sound.wav' + path.write_bytes(raw) + row = dict(kind='audio', path=str(path), sha256=digest(path), bytes=path.stat().st_size) + result = dict(media, version=1, references=[*media.get('references', []), row]) + chain.validate_route(task=task_for(result), backend='mps', anchor=bool(request.get('anchor')), + sound=True, media=result) + request['sound'] = row + report['used'] = True + return result + + +def store(output, data, request, result): + """Carry film metadata and save the same bounded record the CUDA worker writes.""" + if request is not None: + for key, read in (('first_frame', chain.chain_first_frame), ('first_sound', chain.chain_first_sound)): + try: + kept = read(request, request.get('source_video')) + if kept is not None: + data['metadata'][key] = kept + except Exception as error: + storage_failure(error, result, key + '_kept') + try: + from .continuation_store import save + result['continuation_store'] = dict(save(output, data), saved=True) + if request is not None and 'audio_lead_in' in data: + result['continuation']['audio_lead_in_samples'] = chain.LEAD_IN_SAMPLES + except Exception as error: + storage_failure(error, result) + + +def storage_failure(error, result, field='continuation_store'): + # Optional storage failures may not swallow memory exhaustion or cancellation. + from .adaptive import classify_failure + if (isinstance(error, MemoryError) or type(error).__name__ in + ('OutOfMemoryError', 'CancelledError', 'InterruptProcessingException') + or classify_failure(error)['kind'] in ('gpu_oom', 'cancelled') + or 'MPS backend out of memory' in str(error)): + raise error + result[field] = dict(saved=False, error=type(error).__name__) diff --git a/freevideo_engine/macos_decode.py b/freevideo_engine/macos_decode.py index 4b329bdd..f73cf8aa 100644 --- a/freevideo_engine/macos_decode.py +++ b/freevideo_engine/macos_decode.py @@ -28,8 +28,25 @@ def tile_group_size(allocator_bytes, *, cache_weights=True): return 4 if cache_weights and allocator_bytes >= 4 * 2**30 and not limits['resident_bytes'] else 0 +def trim_decoded(frames, audio, continuation=None, *, hide_tail_frames=0, sample_rate=32000, + audio_lead_in_callback=None): + """The shared seam clocks, applied before saving either decoded artifact.""" + from .continuation import validate_hide_tail, capture_audio_lead_in, trim_output + validate_hide_tail(hide_tail_frames) + if continuation is not None and audio_lead_in_callback is not None: + audio_lead_in_callback(capture_audio_lead_in(audio, continuation, sample_rate)) + if hide_tail_frames: + kept_frames = len(frames) - hide_tail_frames + frames = frames[:kept_frames] + audio = audio[:, :round(kept_frames * 40 / 24) * 800] + return trim_output(frames, audio, continuation) + + def decode_to_file(latents, audio_latents, output, *, base, artifacts_dir, - budget_bytes=4 * 2**30, reserve_bytes=2 * 2**30, cache_weights=True, output_size=None): + budget_bytes=4 * 2**30, reserve_bytes=2 * 2**30, cache_weights=True, output_size=None, + continuation=None, hide_tail_frames=0, audio_lead_in_callback=None): + from .continuation import validate_hide_tail + validate_hide_tail(hide_tail_frames) if type(cache_weights) is not bool: raise ValueError('Decoder weight caching must be explicitly enabled or disabled') from .macos_vdn import activate @@ -53,6 +70,7 @@ def decode_to_file(latents, audio_latents, output, *, base, artifacts_dir, started = time.monotonic() vae = video_decoder = audio_vae = frames = mapping = None handles, specs = [], [] + completed = False def phase(text): print(json.dumps(dict(event='decode_phase', phase=text)), flush=True) def guard(module, inputs): @@ -118,6 +136,10 @@ def progress(done, total, elapsed): if not bool(audio.isfinite().all()): raise ValueError('MPS audio decoder produced nonfinite samples') rate = audio_vae.config.sampling_rate + frames, audio = trim_decoded(frames, audio, continuation, hide_tail_frames=hide_tail_frames, + sample_rate=rate, audio_lead_in_callback=audio_lead_in_callback) + if continuation is not None or hide_tail_frames: + np.save(artifacts_dir / 'rgb-new.npy', frames, allow_pickle=False) np.save(artifacts_dir / 'audio.npy', audio.numpy(), allow_pickle=False) audio_vae = None del mean, std @@ -134,6 +156,7 @@ def progress(done, total, elapsed): else: encode_video(frames, fps=FPS, output_path=str(temporary), audio=audio, audio_sample_rate=rate) temporary.replace(output) + completed = True return dict(device_backend='mps', output=str(output), frames=len(frames), audio_sample_rate=rate, audio_shape=list(audio.shape), elapsed_seconds=time.monotonic()-started, policy=policy, video_residency=video_residency, audio_loading=audio_loading, timings=timings, @@ -146,5 +169,7 @@ def progress(done, total, elapsed): vae = video_decoder = audio_vae = frames = None if mapping is not None: mapping._mmap.close() + if completed and (continuation is not None or hide_tail_frames): + (artifacts_dir / 'rgb-new.npy').replace(artifacts_dir / 'rgb.npy') gc.collect() backend.empty_cache() diff --git a/freevideo_engine/macos_generate.py b/freevideo_engine/macos_generate.py index 358caf8c..ccd5ebf4 100644 --- a/freevideo_engine/macos_generate.py +++ b/freevideo_engine/macos_generate.py @@ -146,9 +146,31 @@ def interrupted(signum, frame): report.update(runtime_hardware=hardware, runtime_code=code_identity()) print(json.dumps(dict(event='compute_device', backend='MPS', name=hardware['gpu_name'], memory_model='unified', ram_total_bytes=hardware['ram_total'])), flush=True) + from .continuation import validate_frames, summary as continuation_summary + from . import macos_continuation + from .generate import hide_tail_geometry + if getattr(args, 'continue_from', None) is not None and args.frames is not None: + validate_frames(args.frames) canvas = geometry(args.width, args.height, frames=args.frames, seconds=args.seconds) + canvas, hide_tail = hide_tail_geometry(canvas, + Path(args.prompt_file).read_text(encoding='utf-8') if getattr(args, 'hide_tail', False) and args.prompt_file else None, + getattr(args, 'hide_tail', False), preview=bool(preview or refine_source)) + hide_tail_frames = hide_tail['frames'] if hide_tail and hide_tail['used'] else 0 from .media_request import read as read_media, task_for, cache_key, TASKS media = read_media(args.media) + from .two_pass import sampling_canvas + canvas, output_size = sampling_canvas(canvas, staged=bool(preview or refine_source), media=media, + conditioned=bool(args.conditioning)) + continuation, media, continuation_details = macos_continuation.prepare(args, canvas, media, artifacts) + report.update(continuation_details) + if hide_tail is not None: + report['hide_tail'] = hide_tail + if continuation is not None: + report['continuation'] = continuation_summary(continuation, hide_tail_frames=hide_tail_frames) + report['audio_context'] = continuation['audio_context'] + if hide_tail_frames: + report['output_frames'] = (report['continuation']['new_frames'] if continuation is not None + else canvas['frames'] - hide_tail_frames) structural = any(media.get(k) for k in ('first', 'last', 'references', 'conditioning_info')) task = args.task or task_for(media) if (args.task and structural and args.task != task_for(media) @@ -163,13 +185,11 @@ def interrupted(signum, frame): raise ValueError('Preencoded conditioning already includes media; use its conditioning_info') if args.prompt_file and task != 't2va' and not raw_media: raise ValueError('Native media encoding requires its input files') - # Too small to sample cleanly: render larger, scale the picture down. - from .two_pass import sampling_canvas - canvas, output_size = sampling_canvas(canvas, staged=bool(preview or refine_source), media=media, - conditioned=bool(args.conditioning)) from .generate import upscale_factor sampling = plan(canvas, args.two_pass, task, base_steps=args.base_steps or 8, refine_steps=args.refine_steps, staged=bool(preview or refine_source), scale=upscale_factor(args)) + if continuation is not None and not sampling['enabled'] and sampling['base_steps'] != 8: + raise ValueError(sampling['reason'] + ' A continuation needs two-pass generation.') from .paths import base_path, checkpoint_path base = str(Path(args.base or base_path()).resolve()) adapters = [row for row in media.get('loras', []) if row['strength'] != 0] @@ -189,7 +209,7 @@ def interrupted(signum, frame): + conditioning_info.get('reference_audio_tokens', 0)) admission = report['memory_admission'] = request_admission(resource, sampling, preview=preview, working_set_bytes=working_set, - reference_tokens=reference_tokens) + reference_tokens=reference_tokens, continuation=continuation) _check_admission(admission, sampling) if preview: report['preview'] = dict(geometry=dict(sampling['first'], fps=canvas['fps'])) @@ -217,6 +237,20 @@ def execute(value, name, module='freevideo_engine.macos_generate'): prepared = json.loads(result_path.read_text(encoding='utf-8')) prepared_cache = Path(prepared['cache']).resolve() report['lora'] = prepared['report'] + continuation = macos_continuation.freeze(continuation, canvas, sampling, prepared_cache, base, artifacts) + if continuation is not None: + report['continuation'] = continuation_summary(continuation, hide_tail_frames=hide_tail_frames) + if 'tail' in continuation: + report['continuation']['context'] = 'encoded' + refreshed = request_admission(resource, sampling, working_set_bytes=working_set, + reference_tokens=reference_tokens, continuation=continuation) + if refreshed['needed_bytes'] > admission['needed_bytes']: + admission = report['memory_admission'] = refreshed + _check_admission(admission, sampling) + media = macos_continuation.voice(continuation, media, continuation_details, artifacts) + task = (task_for(media) if continuation_details.get('voice_reference', {}).get('used') + else args.task or task_for(media)) + report['profile']['engine']['task'] = task conditioning = Path(args.conditioning).resolve() if args.conditioning else artifacts / 'conditioning.pt' if preview and args.conditioning: # Keep the preview self-contained for its later upscale. @@ -238,6 +272,12 @@ def execute(value, name, module='freevideo_engine.macos_generate'): prompt = Path(args.prompt_file).read_text(encoding='utf-8') if not prompt.strip(): raise ValueError('A nonempty prompt is required') + prompt_file = Path(args.prompt_file).resolve() + if report.get('first_frame_reference', {}).get('used'): + from .continuation import first_frame_prompt + prompt = first_frame_prompt(prompt) + prompt_file = artifacts / 'encoder-prompt.txt' + prompt_file.write_text(prompt, encoding='utf-8') checkpoint = encoder_checkpoint(args) library = Path(args.comfy_root or data_root() / 'vendor/h3-text-encoder').resolve() cache = None if args.no_tuning else conditioning_cache(checkpoint, library) @@ -245,7 +285,7 @@ def execute(value, name, module='freevideo_engine.macos_generate'): metrics = cache.get(key, conditioning) if cache else None if metrics is None: report['encoding_resources'] = execute(dict(phase='encoding', root=str(data_root()), - prompt_file=str(Path(args.prompt_file).resolve()), output=str(conditioning), + prompt_file=str(prompt_file), output=str(conditioning), report=str(output.with_suffix('.encoding.json')), checkpoint=str(checkpoint), library=str(library), resources=resource, media=media, canvas=canvas, base=base), 'encoding') metrics = json.loads(output.with_suffix('.encoding.json').read_text()) @@ -286,7 +326,8 @@ def execute(value, name, module='freevideo_engine.macos_generate'): if conditioning_tokens + reference_tokens > 1024 + admission['reference_tokens']: report['memory_admission_before_encoding'] = admission admission = report['memory_admission'] = request_admission(resource, sampling, preview=preview, - working_set_bytes=working_set, reference_tokens=reference_tokens, text_tokens=text_tokens) + working_set_bytes=working_set, reference_tokens=reference_tokens, text_tokens=text_tokens, + continuation=continuation) _check_admission(admission, sampling) report['phase'] = 'engine' save(output.with_suffix('.request.json'), report) @@ -296,6 +337,10 @@ def execute(value, name, module='freevideo_engine.macos_generate'): base=base, task=task, checkpoint=str(Path(args.checkpoint or checkpoint_path()).resolve()), seed=args.seed, canvas=canvas, sampling=sampling, resources=resource) + if continuation is not None: + engine['continuation'] = continuation + if hide_tail_frames: + engine['hide_tail_frames'] = hide_tail_frames if getattr(args, 'step_cache', None) == 'easycache-av': engine['step_cache'] = args.step_cache if output_size: @@ -312,6 +357,8 @@ def execute(value, name, module='freevideo_engine.macos_generate'): raise RuntimeError('Native worker did not complete the requested video') if refine_source: report['upscaled_preview'].update(metrics.get('upscaled_preview') or {}) + if continuation is not None: + report['continuation']['audio_lead_in_samples'] = metrics.get('continuation', {}).get('audio_lead_in_samples', 0) report.update(success=True, phase='complete', video=metrics) return output except BaseException as error: diff --git a/freevideo_engine/macos_runtime.py b/freevideo_engine/macos_runtime.py index 5ce24ccf..694e5a01 100644 --- a/freevideo_engine/macos_runtime.py +++ b/freevideo_engine/macos_runtime.py @@ -136,7 +136,7 @@ def __init__(self, cache, *, base, checkpoint, steps=8, budget_bytes=4 * 2**30, weight_decoder='cpu', budget_ceiling_bytes=None, attention_chunk=None, task='t2va', canvas=None, head_chunk=4, window_batch=1, attention_impl='torch', fast_kernels=False, resident_bytes=0, attention_batch_bytes=1 << 29, plan_compute=False, memory_retry=False, - step_cache=None): + step_cache=None, adaln_task=None): if step_cache not in (None, 'easycache-av'): raise ValueError('MPS supports the easycache-av step cache only') self.step_cache = step_cache @@ -172,6 +172,7 @@ def __init__(self, cache, *, base, checkpoint, steps=8, budget_bytes=4 * 2**30, if task != 't2va' and canvas is None: raise ValueError('Native media generation requires its full target canvas') self.task, self.canvas = task, dict(canvas) if canvas is not None else None + self.adaln_task = schedule_task = adaln_task or task from .macos_vdn import activate self.overlay = activate() import torch @@ -254,10 +255,10 @@ def path(name): exclude=('transformer_blocks.', 'token_refiner.refiner_blocks.', 'rope.'), decoder=self.weight_decoder) model.rope.to('mps') - times = schedule_timesteps(steps, device='mps', task=task) + times = schedule_timesteps(steps, device='mps', task=schedule_task) self.cursor = ScheduleCursor(times) tables = TableCache(self.cache, manifest['source_id'], steps, manifest=manifest, - timesteps=times, channels=model.config.hidden_size, device='cpu', task=task) + timesteps=times, channels=model.config.hidden_size, device='cpu', task=schedule_task) embeddings = None delivery_checked = False for index, layer in enumerate(model.token_refiner.refiner_blocks): @@ -286,7 +287,7 @@ def path(name): load_group(layer.adaln_proj, path(f'adaln/{index:02d}'), self.linears, prefix=f'transformer_blocks.{index}.adaln_proj.', decoder=self.weight_decoder) if embeddings is None: - _, embeddings = schedule_embeddings(model, steps, device='mps', task=task) + _, embeddings = schedule_embeddings(model, steps, device='mps', task=schedule_task) table = [layer.adaln_proj(value) for value in embeddings] layer.adaln_proj = CachedModulation(table, self.cursor) tables.save(index, layer.adaln_proj, producer_device='mps') @@ -494,13 +495,17 @@ def sample(self, *args, **kwargs): return result def _sample(self, conditioning, seed, *, width, height, frames, progress_offset=0, progress_total=None, - initial_latents=None, refine_steps=None, refine_schedule=None): + initial_latents=None, refine_steps=None, refine_schedule=None, continuation=None): import torch from .geometry import geometry, sampler_for_canvas from .system import system_memory from src.inference.render import generate_latents if self.closed: raise RuntimeError('MPS engine is closed') + if continuation is not None: + from .continuation import SIZE_MISMATCH + if tuple(continuation['video'].shape[-2:]) != (height // 16, width // 16): + raise ValueError(SIZE_MISMATCH) shared = getattr(self, '_shared_residency', None) previous_residency = shared.stats() if shared is not None else None if self.budget_ceiling_bytes is not None: @@ -529,8 +534,11 @@ def _sample(self, conditioning, seed, *, width, height, frames, progress_offset= sampler = sampler_for_canvas(generate_latents, width, height) progress = MacSamplingProgress(active_steps, len(self.model.transformer_blocks), offset=progress_offset, total=progress_total) + prefix = None class StepTimes(list): def append(owner, duration): + if prefix is not None: + prefix.restore_video() super().append(duration) progress.complete(duration) durations = StepTimes() @@ -543,9 +551,21 @@ def host_guard(module, inputs): guards = [module.register_forward_pre_hook(host_guard, prepend=True) for module, _, _, _ in self.specs] try: with torch.no_grad(): - prompt, tags, conditions = load_conditioning(conditioning, 'mps', task=self.task, + prompt, tags, conditions = load_conditioning(conditioning, self.backend.device, task=self.task, canvas=canvas, target=self.canvas) canvas.update(conditioning_tokens(self.task, canvas, conditions)) + if continuation is not None: + from .continuation import validate_route, encoded_references, UNSUPPORTED_MEDIA, LATENT_FRAMES + anchor = bool(continuation.get('anchor')) + sound = False + if self.task.startswith('ref2va'): + sound = encoded_references(conditions, self.task, anchor) + elif conditions: + raise ValueError(UNSUPPORTED_MEDIA) + validate_route(task=self.task, backend='mps', anchor=anchor, sound=sound, + media=dict(references=[dict(kind=row['kind']) for row in conditions]) if conditions else None) + canvas['continuation_tokens'] = (LATENT_FRAMES * (width // 32) * (height // 32) + + 2 * continuation['audio'].shape[2]) self._plan_compute(canvas, prompt.shape[0]) previous_block_calls = self._block_metrics() self.cursor.reset(self.steps - refine_steps if refining and refine_schedule is None else 0) @@ -562,7 +582,11 @@ def host_guard(module, inputs): text_residency = self._refine_prompt(prompt) sampling_specs = [row for row in self.specs if not row[2].startswith('token_refiner.refiner_blocks.')] - with self._sampling_weights(sampling_specs, retention) as residency, \ + from .continuation import sampling + with self._sampling_weights(sampling_specs, retention) as residency, sampling( + self.model, continuation, seed, self.backend.device, + video_offset=canvas.get('reference_video_tokens', 0), + audio_offset=canvas.get('reference_audio_tokens', 0)) as prefix, \ progress.layers(self.model.transformer_blocks), \ frame_preview.observing(latent_frames=canvas['latent_frames'], frame_h=height // 32, frame_w=width // 32, step_offset=progress_offset, @@ -574,12 +598,14 @@ def host_guard(module, inputs): begin_pass(self.model, active_steps, self.step_cache) try: video, audio = sampler(self.model, prompt, tags, canvas['frames'], self.steps, - seed, 'mps', step_seconds=durations, + seed, self.backend.device, step_seconds=durations, **(dict(conditions=conditions) if conditions else {}), **(dict(initial_latents=initial_latents, refine_steps=refine_steps, refine_schedule=refine_schedule) if refining else {})) finally: step_cache = end_pass(self.model) + if prefix is not None: + video, audio = prefix.finish(video, audio) self.backend.synchronize() if not bool(video.isfinite().all()) or not bool(audio.isfinite().all()): raise ValueError('MPS sampling produced nonfinite video or audio latents') @@ -619,7 +645,7 @@ def host_guard(module, inputs): def generate(self, conditioning, seed, *, width, height, frames, two_pass=True, refine_steps=3, upscaler_checkpoint=None, first_pass_saved=None, reuse_weights=False, - refinement_resident_bytes=None, phase_changed=None, staged=False, scale=2): + refinement_resident_bytes=None, phase_changed=None, staged=False, scale=2, continuation=None): """Optional cross-pass weight lifetime, isolated to this one request. Product admission remains separate. Retention obeys each sample's live @@ -636,7 +662,8 @@ def generate(self, conditioning, seed, *, width, height, frames, two_pass=True, options = dict(width=width, height=height, frames=frames, two_pass=two_pass, refine_steps=refine_steps, upscaler_checkpoint=upscaler_checkpoint, first_pass_saved=first_pass_saved, phase_changed=phase_changed, - refinement_resident_bytes=refinement_resident_bytes, staged=staged, scale=scale) + refinement_resident_bytes=refinement_resident_bytes, staged=staged, scale=scale, + **(dict(continuation=continuation) if continuation is not None else {})) if getattr(self, '_reuse_sampling_weights', False) or getattr(self, '_shared_residency', None) is not None: raise RuntimeError('Native sampling weight lifetimes cannot overlap') if not reuse_weights: @@ -654,7 +681,7 @@ def generate(self, conditioning, seed, *, width, height, frames, two_pass=True, def _generate(self, conditioning, seed, *, width, height, frames, two_pass=True, refine_steps=3, upscaler_checkpoint=None, first_pass_saved=None, - refinement_resident_bytes=None, phase_changed=None, staged=False, scale=2): + refinement_resident_bytes=None, phase_changed=None, staged=False, scale=2, continuation=None): """Use the shared canvas/schedule, including the original audio clock. Single-pass behavior remains explicit; memory pressure never disables @@ -671,8 +698,10 @@ def _generate(self, conditioning, seed, *, width, height, frames, two_pass=True, base_steps=self.steps, refine_steps=refine_steps, staged=staged, scale=scale) if phase_changed is not None: phase_changed('first-pass') + from .continuation import load video, audio, first = self.sample(conditioning, seed, **sampling['first'], - progress_total=sampling['total_steps']) + **(dict(continuation=load(continuation, 'first')) if continuation is not None else {}), + progress_total=sampling['total_steps']) metrics = dict(sampling_plan=sampling, sampling_passes=[first]) if not sampling['enabled']: return video, audio, metrics @@ -693,6 +722,7 @@ def _generate(self, conditioning, seed, *, width, height, frames, two_pass=True, result, sound, second = self.sample(conditioning, seed + sampling['restart_seed_offset'], **sampling['second'], initial_latents=(lifted, audio), refine_steps=refine_steps, refine_schedule=sampling.get('refine_schedule'), + **(dict(continuation=load(continuation, 'final')) if continuation is not None else {}), progress_offset=self.steps, progress_total=sampling['total_steps']) if not torch.equal(sound, audio): raise ValueError('MPS refinement changed first-pass audio') diff --git a/freevideo_engine/macos_stages.py b/freevideo_engine/macos_stages.py index ef90112f..8f5dffc4 100644 --- a/freevideo_engine/macos_stages.py +++ b/freevideo_engine/macos_stages.py @@ -49,6 +49,10 @@ def memory_exhausted(stage, observed): def joined_admission(engine, value): """Decide in the GPU worker, using its enforced cap and validated packed rows.""" mode = sampling_lifetime(value['resources']) + if value.get('continuation') is not None: + # Context encoding and its VAE never share weights with sampling. Keep + # joined residency off until its continuation workspace is calibrated. + return dict(selected=False, mode=mode, reason='continuation-separate-stages') if mode == 'phase' or not value['sampling']['enabled']: return dict(selected=False, mode=mode, reason='separate-or-single-pass') task = value.get('task', 't2va') @@ -250,6 +254,9 @@ def interrupted(signum, frame): elif stage == 'upscale': result['latent_upscale'] = observed['upscale'] else: + for key in ('continuation', 'continuation_store', 'first_frame_kept', 'first_sound_kept'): + if key in observed: + result[key] = observed[key] result['decode'] = observed['decode'] result['decode_save_seconds'] = observed['work_seconds'] # Keep completed work visible if a later stage fails or the @@ -325,6 +332,50 @@ def worker(value): started = time.monotonic() try: sampling_lifetime(resource) + from . import continuation as chain + from .macos_continuation import storage_failure + hidden = chain.validate_hide_tail(value.get('hide_tail_frames', 0)) + continuation = value.get('continuation') + if continuation is not None: + chain.validate_route(task=value.get('task', 't2va'), backend='mps', + anchor=bool(continuation.get('anchor')), + sound=bool(continuation.get('sound')), + media=dict(references=[ + *([dict(kind='image')] if continuation.get('anchor') else []), + *([continuation['sound']] if continuation.get('sound') else [])]), + two_pass=bool(sampling.get('requested')), preview=value.get('preview', False), + refine_from=value.get('refine_from')) + continuation = dict(continuation, audio_context=chain.validate_audio_context( + continuation.get('audio_context', chain.AUDIO_CONTEXT_DEFAULT))) + if 'tail' in continuation: + completed = artifacts / 'continuation-completed.json' + if completed.is_file(): + continuation = json.loads(completed.read_text(encoding='utf-8')) + elif phase in ('first-pass', 'refinement'): + from .macos_vdn import activate + from .backends import get_backend + from .continuation_tail import complete + activate() + backend = get_backend('mps') + result['continuation_encode_policy'] = backend.configure_budget( + resource['allocator_capacity_bytes'], reserve_bytes=resource['reserve_bytes']) + manifest = json.loads((Path(value['cache']) / 'manifest.json').read_text(encoding='utf-8')) + continuation, result['continuation_encode'] = complete( + continuation, value['canvas'], manifest, sampling, device='mps') + save(completed, continuation) + else: + raise ValueError('Continuation encoding did not complete before this stage') + result['continuation'] = chain.summary(continuation, hide_tail_frames=hidden) + result['audio_context'] = continuation['audio_context'] + continuable = bool(sampling.get('enabled') or sampling.get('requested')) and not value.get('preview') + first_tail = None + def capture_first(video): + nonlocal first_tail + if continuable: + try: + first_tail = chain.tail(video, hide_tail_frames=hidden) + except Exception as error: + storage_failure(error, result) if phase in ('first-pass', 'refinement'): from .macos_runtime import Engine from . import frame_preview @@ -355,7 +406,9 @@ def worker(value): attention_batch_bytes=compute.get('attention_batch_bytes', 1 << 29), plan_compute=bool(compute), memory_retry=retry and bool(compute), task=value.get('task', 't2va'), canvas=value['canvas'], - step_cache=None if refining else value.get('step_cache')) as engine: + step_cache=None if refining else value.get('step_cache'), + **(dict(adaln_task=chain.ANCHOR_ADALN_TASK if continuation.get('anchor') else chain.ADALN_TASK) + if continuation is not None else {})) as engine: result['load_seconds'] = engine.load_seconds tick = time.monotonic() # A preview stops after the first pass; joining would also refine. @@ -370,7 +423,9 @@ def changed(stage): save(Path(value['report']), result) def first_saved(video, audio, metrics): save_started = time.monotonic() - torch.save(dict(video=video, audio=audio), artifacts / 'first-pass.pt') + capture_first(video) + torch.save(dict(video=video, audio=audio, **( + dict(continuation_first=first_tail) if first_tail is not None else {})), artifacts / 'first-pass.pt') save(artifacts / 'first-pass.json', metrics) result['latent_save_seconds'] += time.monotonic() - save_started result['sampling_passes'] = list(metrics['sampling_passes']) @@ -381,7 +436,8 @@ def first_saved(video, audio, metrics): frames=value['canvas']['frames'], two_pass=True, refine_steps=sampling['refine_steps'], reuse_weights=True, refinement_resident_bytes=admission['refinement_resident_bytes'], first_pass_saved=first_saved, phase_changed=changed, - staged=sampling.get('staged', False), scale=sampling.get('scale', 2)) + staged=sampling.get('staged', False), scale=sampling.get('scale', 2), + **(dict(continuation=continuation) if continuation is not None else {})) result.update(sampling_passes=measured['sampling_passes'], shared_sampling_residency=measured['shared_sampling_residency'], latent_upscale=measured['latent_upscale'], sampling_phase='latent-save') @@ -391,6 +447,8 @@ def first_saved(video, audio, metrics): video, audio, measured = engine.sample(value['conditioning'], value['seed'] + (sampling['restart_seed_offset'] if refining else 0), **sampling['second' if refining else 'first'], + **(dict(continuation=chain.load(continuation, 'final' if refining else 'first')) + if continuation is not None else {}), progress_total=sampling['base_steps'] if value.get('preview') is True else sampling['total_steps'], progress_offset=sampling['base_steps'] if refining else 0, **(dict(initial_latents=(initial['video'], initial['audio']), refine_steps=sampling['refine_steps'], @@ -401,6 +459,10 @@ def first_saved(video, audio, metrics): if measured.get('compute') is not None: result.update(initial_compute=compute, compute=measured['compute']) result.update(sampling=measured, work_seconds=time.monotonic() - tick) + if refining: + first_tail = initial.get('continuation_first') + elif not result.get('joined_sampling'): + capture_first(video) name = ('first-pass.pt' if not refining and sampling['enabled'] and not result.get('joined_sampling') else 'latents.pt') elif phase == 'upscale': @@ -414,6 +476,7 @@ def first_saved(video, audio, metrics): initial = torch.load(artifacts / 'first-pass.pt', map_location='cpu', weights_only=True) video, stats = upscale_latents(initial['video'], sampling, value['base'], backend) audio = initial['audio'] + first_tail = initial.get('continuation_first') result.update(upscale=stats, work_seconds=stats['stage_seconds']) name = 'upscaled.pt' elif phase == 'decode': @@ -422,10 +485,23 @@ def first_saved(video, audio, metrics): source = 'first-pass.pt' if value.get('preview') is True else 'latents.pt' initial = torch.load(artifacts / source, map_location='cpu', weights_only=True) tick = time.monotonic() + data = initial.get('continuation_tail') + def keep_audio_lead_in(lead): + if lead is not None and data is not None: + data['audio_lead_in'] = lead + data['metadata']['source_id'] = continuation['source_id'] result['decode'] = decode_to_file(initial['video'], initial['audio'], value['output'], base=value['base'], artifacts_dir=artifacts, budget_bytes=resource['allocator_capacity_bytes'], reserve_bytes=resource['reserve_bytes'], - output_size=value.get('output_size')) + output_size=value.get('output_size'), + **(dict(continuation=continuation, audio_lead_in_callback=keep_audio_lead_in) + if continuation is not None else {}), + **(dict(hide_tail_frames=hidden) if hidden else {})) + if continuable and data is not None: + from .macos_continuation import store + store(value['output'], data, continuation, result) + elif continuable: + result['continuation_store'] = dict(saved=False, reason='checkpoint_missing') result.update(work_seconds=time.monotonic() - tick, success=True) return else: @@ -433,7 +509,18 @@ def first_saved(video, audio, metrics): if not bool(video.isfinite().all()) or not bool(audio.isfinite().all()): raise ValueError('Native stage produced nonfinite latents') tick = time.monotonic() - torch.save(dict(video=video, audio=audio), artifacts / name) + saved = dict(video=video, audio=audio) + if first_tail is not None: + if name in ('first-pass.pt', 'upscaled.pt'): + saved['continuation_first'] = first_tail + elif continuable: + try: + manifest = json.loads((Path(value['cache']) / 'manifest.json').read_text(encoding='utf-8')) + saved['continuation_tail'] = chain.tail_data(first_tail, video, audio, value['canvas'], + sampling, manifest, continuation, hide_tail_frames=hidden) + except Exception as error: + storage_failure(error, result) + torch.save(saved, artifacts / name) result.update(latent_save_seconds=result.get('latent_save_seconds', 0.) + time.monotonic() - tick, success=True) except BaseException as error: diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index e3890af8..744bf27c 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -291,6 +291,15 @@ def link(self, value): else: self.invoke(open_browser, value) + @Slot(str, str) + def reportProblem(self, error, title): + from . import __version__ + from .launcher_issue import issue_url + from .windows_ux import open_browser + url = issue_url(error, session.installed_product_version() or __version__, title, + zh=session.language.startswith('zh')) + self.invoke(open_browser, url) + @Slot() def openDownloadFolder(self): from PySide6.QtGui import QDesktopServices diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index d6e81fc4..3fdd6a6a 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -826,12 +826,61 @@ def measure(row): return None + +def _redirect_fields(text): + """curl's http_code and redirect_url output as (status, url); Windows curl ends its lines with CRLF.""" + status, _, destination = text.partition('\n') + return status.strip(), destination.strip() + + def download(candidates, path, expected, progress=None, *, network=None, env=None, size=None, algorithm='sha256', git_blob=False, headers_for=None, stall_seconds=None, category='download', - low_speed_limit=1024, max_seconds=None, cycles=2, slow_seconds=None, keep_partial=False): - """Resume across ranked sources; hash before accepting, retain rejected bytes.""" + low_speed_limit=1024, max_seconds=None, cycles=2, slow_seconds=None, keep_partial=False, + validate_url=None, verify=None, fallbacks=None): + """Resume ranked sources; defaults retain rejected bytes. + + validate_url checks each redirect before curl can follow it; verify runs + after the digest and before publication. fallbacks maps a source name to + an alternate (url, headers) within the same source/route retry round. + """ network = network or {} path = Path(path) + updating = category == 'launcher-update' + integrity_failed = False + failed_sources = set() + transferred, high_water, last_rate = 0, 0, 0. + transfer_samples = deque([(time.monotonic(), 0)]) + + def update_progress(done, added): + nonlocal transferred, high_water, last_rate + now = time.monotonic() + transferred += added + transfer_samples.append((now, transferred)) + while len(transfer_samples) > 2 and transfer_samples[1][0] < now - 5: + transfer_samples.popleft() + seconds = now - transfer_samples[0][0] + last_rate = (transferred-transfer_samples[0][1]) / max(.001, seconds) + # A clean retry keeps the visible bar. Completion is reported only + # after the hash and optional caller verification have both succeeded. + high_water = max(high_water, min(done, size-1) if size else done) + if progress: + progress(high_water, size or 0, last_rate) + + def reject(candidate, reason): + if updating: + candidate.unlink(missing_ok=True) + else: + retain_partial(candidate, reason) + + def publish(candidate): + check = network.get('resource_check') + if updating and check: + check() + from .file_ops import publish as move + move(candidate, path) + if updating and progress: + progress(path.stat().st_size, size or 0, last_rate) + model = category in ('models', 'vdn-models', 'edge-models', 'image-models') stall_seconds = stall_seconds if stall_seconds is not None else 120 if model else 20 @@ -839,7 +888,7 @@ def matches(candidate, source='local'): if size is not None and candidate.stat().st_size != size: return False event(network, category=category, source=source, file=path.name, action='verifying', algorithm=algorithm) - check = network.get('resource_check') if keep_partial else None + check = network.get('resource_check') if keep_partial or updating else None from .install_activity import byte_progress report = byte_progress('verify_progress', path.name, report=lambda **row: event(network, category=category, source=source, action='verify-progress', **row)) @@ -848,8 +897,11 @@ def verification(done, total): check() if model: report(done, total) - return hash_file(candidate, algorithm, git_blob, discard_cache=category in ('models', 'vdn-models', 'image-models'), - progress=verification if model or check else None) == expected + matched = hash_file(candidate, algorithm, git_blob, discard_cache=category in ('models', 'vdn-models', 'image-models'), + progress=verification if model or check else None) == expected + if matched and verify is not None: + matched = verify(candidate) + return matched if path.is_file(): if matches(path): return @@ -858,15 +910,16 @@ def verification(done, total): partial = path.with_suffix(path.suffix + '.partial') if partial.is_file() and (size is None or partial.stat().st_size == size): if matches(partial): - from .file_ops import publish - publish(partial, path) + publish(partial) return if size is not None: # A complete-but-corrupt file is not a resumable prefix. - retain_partial(partial, 'hash-rejected') + reject(partial, 'hash-rejected') timeout = network.get('timeout_seconds', 5) affinity = partial.with_name(partial.name + '.source.json') - candidates = [(name, url, route) for name, url in candidates + candidates = [(name, address, route, headers) for name, url in candidates + for address, headers in ([(url, ())] + + ([(fallbacks[name][0], tuple(fallbacks[name][1]))] if fallbacks and name in fallbacks else [])) for route in route_order(network, category, name, env)] if not candidates: raise RuntimeError('No download sources configured; existing files retained') @@ -885,18 +938,30 @@ def verification(done, total): # if the user's entire connection is slow. Do not oscillate forever. if cycle and measured_rates: candidates.sort(key=lambda row: -measured_rates.get((row[0], row[2]), 0)) - for candidate_index, (name, url, route) in enumerate(candidates): - for restart in range(3 if model else 2): + for candidate_index, (name, original_url, route, extra_headers) in enumerate(candidates): + if name in failed_sources: + continue + url, redirects, restart = original_url, 0, 0 + # Redirects are individual curl requests when a caller supplies a + # policy. Never let curl contact an unchecked redirect destination. + for attempt in range(16 if validate_url else (3 if model else 2)): + if updating and network.get('resource_check'): + network['resource_check']() + if validate_url: + try: + validate_url(url) + except ValueError: + failed_sources.add(name) + break offset = partial.stat().st_size if partial.exists() else 0 if keep_partial and size is not None and offset == size: if matches(partial, name): - from .file_ops import publish - publish(partial, path) + publish(partial) return - retain_partial(partial, 'hash-rejected') + reject(partial, 'hash-rejected') offset = 0 if size and offset > size: - retain_partial(partial, 'oversized') + reject(partial, 'oversized') offset = 0 remembered = False def remember_source(): @@ -910,7 +975,11 @@ def remember_source(): event(network, category=category, source=name, route=route, file=path.name, action='attempt', method='curl', cycle=cycle, resume_bytes=offset) attempt_env = route_environment(network, category, name, env, route) command = curl_command(timeout, attempt_env) + ['--output', str(partial), '--write-out', '%{http_code}'] - if not model and slow_seconds is None: + if validate_url: + command += ['--disable', '--max-redirs', '0', '--write-out', '%{http_code}\n%{redirect_url}'] + # --disable must be the first option to suppress .curlrc. + command.insert(1, command.pop(command.index('--disable'))) + if not model and slow_seconds is None and not updating: command += ['--speed-limit', str(low_speed_limit), '--speed-time', str(stall_seconds)] if max_seconds is not None: command += ['--max-time', str(max_seconds)] @@ -934,11 +1003,14 @@ def remember_source(): slow = False faster = None compare = dict(thread=None, rates={}, checks=0, at=started + SWITCH_AFTER_SECONDS, current=0.) - alternatives_ahead = [row for row in candidates[candidate_index+1:] if row[0] != name] + alternatives_ahead = [row[:3] for row in candidates[candidate_index+1:] if row[0] != name] last_body, previous_done = started, offset + reported_done = offset recent = deque([(started, offset)]) try: - process.stdin.write(curl_config(url, headers_for(name) if headers_for else ())) + process.stdin.write(curl_config(url, + tuple(headers_for(name) if headers_for else ()) + extra_headers + if urlsplit(url).netloc == urlsplit(original_url).netloc else ())) process.stdin.close() while process.poll() is None: if network.get('resource_check'): @@ -966,15 +1038,21 @@ def remember_source(): and (done - recent[0][1]) / (now - recent[0][0]) < low_speed_limit): slow = True break - if (cycle == 0 and size and alternatives_ahead and compare['checks'] < SWITCH_MAX_CHECKS + # Speed samples follow redirects themselves; guarded updates + # must keep every request inside the caller's URL policy. + if (not validate_url and cycle == 0 and size and alternatives_ahead and compare['checks'] < SWITCH_MAX_CHECKS and not network.get('manual_source_switch')): faster = _faster_source(compare, now, started, offset, done, size, alternatives_ahead, timeout, env, network, category, headers_for) if faster is not None: break if progress: - sample = next((row for row in recent if row[0] >= now - 5), recent[0]) - progress(done, size or 0, max(0, done - sample[1]) / max(.001, now - sample[0])) + if updating: + update_progress(done, max(0, done-reported_done)) + reported_done = done + else: + sample = next((row for row in recent if row[0] >= now - 5), recent[0]) + progress(done, size or 0, max(0, done - sample[1]) / max(.001, now - sample[0])) try: process.wait(timeout=.25) except subprocess.TimeoutExpired: @@ -990,11 +1068,22 @@ def remember_source(): except subprocess.TimeoutExpired: process.kill() process.wait() - status = process.stdout.read(16).decode(errors='replace') + if not process.stdin.closed: + process.stdin.close() + status = process.stdout.read(65536 if validate_url else 16).decode(errors='replace') process.stdout.close() # --silent --show-error keeps this to a line or two. curl_message = _package_failure(SimpleNamespace(stderr=process.stderr.read(2048)))[1] if process.returncode else '' process.stderr.close() + if validate_url: + status, destination = _redirect_fields(status) + if process.returncode == 47 and status in ('301', '302', '303', '307', '308'): + redirects += 1 + if not destination or redirects > 5: + failed_sources.add(name) + break + url = urljoin(url, destination) + continue current_size = partial.stat().st_size if partial.exists() else 0 if slow_seconds is not None: measured_rates[name, route] = (max(0, current_size - offset) / @@ -1003,21 +1092,26 @@ def remember_source(): measured_rates[name, route] = compare['current'] measured_rates.update(compare['rates']) # The faster source is tried next, with the retained prefix. - position = candidates.index(faster) + position = next(i for i, row in enumerate(candidates) if row[:3] == faster) candidates.insert(candidate_index + 1, candidates.pop(position)) if model and current_size > offset: remember_source() - if oversized is not None: - retain_partial(partial, 'oversized-response') + if oversized is not None or (updating and process.returncode == 63): + if partial.exists(): + reject(partial, 'oversized-response') + if updating: + integrity_failed = True + failed_sources.add(name) source_health(network, category, name, False) event(network, category=category, source=name, file=path.name, action='fallback', method='curl', reason='response-size-exceeded', observed_bytes=oversized, expected_bytes=size, resume_bytes=offset) break + if updating and (size is None or current_size <= size): + update_progress(current_size, max(0, current_size-reported_done)) if process.returncode == 0: if matches(partial, name): - from .file_ops import publish - publish(partial, path) + publish(partial) if network.get('download_settings_path'): from .download_settings import record_speed record_speed(Path(network['download_settings_path']).parent, @@ -1026,7 +1120,15 @@ def remember_source(): route_health(network, category, name, route) event(network, category=category, source=name, route=route, file=path.name, action='verified', method='curl', bytes=path.stat().st_size) return - retain_partial(partial, 'hash-rejected') + reject(partial, 'hash-rejected') + if updating: + integrity_failed = True + # A corrupt retained prefix must not poison the good + # source. Give this source one clean fetch, then move on. + if offset and restart == 0: + restart += 1 + continue + failed_sources.add(name) source_health(network, category, name, False) event(network, category=category, source=name, file=path.name, action='fallback', reason='integrity-mismatch') break @@ -1036,7 +1138,8 @@ def remember_source(): action='resume-refused', resume_bytes=offset, reason='source-does-not-support-range') break # A mirror lacking Range must start over, with previous bytes kept. - retain_partial(partial, 'resume-unavailable') + reject(partial, 'resume-unavailable') + restart += 1 continue # A connection that was moving bytes and then ended with an # unclassified exit is an interruption, not a refusal. @@ -1046,7 +1149,10 @@ def remember_source(): if model and transient and restart < 2 and candidate_index + 1 == len(candidates) and process.returncode not in (5, 7): retry_wait(network, restart, category=category, source=name, file=path.name, resume_bytes=current_size, reason='no-progress' if stalled else 'connection-interrupted') + restart += 1 continue + if updating and stalled: + failed_sources.add(name) reason = 'no-progress' if stalled else 'slow-transfer' if slow else 'faster-source' if faster else 'curl-' + str(process.returncode) alternatives = route_order(network, category, name, env) if alternatives.index(route) + 1 < len(alternatives): @@ -1063,7 +1169,9 @@ def remember_source(): event(network, category=category, source=name, file=path.name, action='restart-approved', retained_bytes=partial.stat().st_size, reason='resume-unavailable') retain_partial(partial, 'restart-approved') - raise DownloadError('Every download source failed for %s. Check your network or choose another connection mode in Downloads. Partial files retained' % path.name) + error = DownloadError('Every download source failed for %s. Check your network or choose another connection mode in Downloads. Partial files retained' % path.name) + error.integrity = integrity_failed + raise error # clone() passes the remote through GIT_CONFIG_PARAMETERS and writes the sparse diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 83bc2230..46078073 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -913,7 +913,14 @@ def _choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto' # Scale the existing activation estimate with output and reference rows. # Keep the decoder's fixed workspace funded; neither weights nor its # temporal tile shrink in proportion to the number of output frames. - fixed_workspace = decoder_workspace(canvas) + # The decoder runs after the transformer is released, and with it the + # schedule tables, LoRA workspace and step cache: it is funded from the + # request budget, as its resident blocks are below. gpu_budget has those + # sampling allocations taken out already, so charge the decoder only what + # they do not cover. Sampling still has to fit its own working set beside + # them. Charging both refused full-size requests whose sampling and decode + # each fit the card. + fixed_workspace = max(0, decoder_workspace(canvas) - placement_workspace) # The staging estimate was measured on per-tensor FP8, whose FF keeps every # SwiGLU row for one scale (1.94 GiB at 72576 tokens). Int8 rows carry their # own scales and stash nothing. At 1344x768x243 on int8, with a 4.70 GiB @@ -948,7 +955,11 @@ def _choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto' # WDDM, and the two bounds above: the head group may not exceed its # measured requirement, and the no-weights path stays on the platform # whose margin was measured. - if not desktop and gpu_reserve_gib is None and free_gpu < 10 * GiB and gpu_budget < residual_need: + # The trigger keeps the decoder's whole workspace. The credit above is for + # admission; the cards this reduction already covered spend the headroom + # keeping attention outputs on the GPU, which is worth more than the margin. + if (not desktop and gpu_reserve_gib is None and free_gpu < 10 * GiB + and gpu_budget < max(residual_need, decoder_workspace(canvas))): reserve_gpu = MIN_GPU_RESERVE_GIB request_gpu_budget = free_gpu - int(reserve_gpu * GiB) gpu_budget = request_gpu_budget - placement_workspace @@ -1307,6 +1318,22 @@ def held(group): head, reserve = 8, wider else: cpu_outputs = not no_weights and gpu_budget < reserve + GPU_ATTENTION_OUTPUT_BYTES + if (cpu_outputs and hardware.system == 'Linux' and not int8 and gpu_reserve_gib is None and free_gpu < 7.5 * GiB + and reserve_gpu > MIN_GPU_RESERVE_GIB): + # Host outputs plus staging cost 70 to 100 times the sampling + # time, so a shortfall of a few MiB must not select them while + # the minimum growth reserve would cover the outputs. A 6 GiB + # card at 20 steps missed by 13 MiB (the modulation tables + # grow with steps) and the same request one step count lower + # ran the plain route in 211 s. The plain route peaked 5312 MiB + # of a 6246 MiB cap at 6.1 GiB, so the reduced reserve is the + # same trade the residual admission above already makes. + reduced = free_gpu - int(MIN_GPU_RESERVE_GIB * GiB) - placement_workspace + if reduced >= reserve + GPU_ATTENTION_OUTPUT_BYTES: + reserve_gpu = MIN_GPU_RESERVE_GIB + request_gpu_budget = free_gpu - int(reserve_gpu * GiB) + gpu_budget = reduced + cpu_outputs = False if not cpu_outputs: reserve += GPU_ATTENTION_OUTPUT_BYTES # Outputs in host memory means the budget could not cover them on the diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 66aca191..9afb1d0a 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,37 +1,74 @@ { "schema": 1, - "product_version": "0.4.14", + "product_version": "0.4.15", "en": { - "summary": "Max is faster, a picture can now be changed only inside a region you select, agents can import their own clips into the timeline, and long videos can take more requests into the queue while they generate.", + "summary": "Image generation keeps a history of every picture and needs less memory on both small and large graphics cards, error messages everywhere now come with a button to report the problem, and Windows updates download faster in mainland China.", "changes": [ - "Quality levels: Max now uses 20 steps with a guarded step cache and finishes sooner; High uses 16 steps with the step cache. The level names stay the same.", - "Picture editing: when changing a picture, drag a box, click an object, or draw a closed outline to select a region. Shift adds to the selection and Alt removes from it. Only the selected region changes, and everything outside it stays exactly the same. Click selection downloads a 184 MB model from ModelScope the first time it is used.", - "Studio: while a long or multi-part video is generating, the button stays available as \"Add to queue\". Up to five requests can wait, and each starts when the current video finishes or is stopped.", - "Agent: agents can import clips they rendered through HTTP, MCP and the command line, place them on the timeline by frame, and export. Before an agent is connected, the autopilot page says what connecting one is for.", - "Editing view: a reference picture that cannot be read is flagged right away so another one can be chosen.", - "Upgrade: when an old FreeVideo tab is replaced, two-pass sampling, first- and second-pass steps, the step cache and the upscale factor are carried over.", - "Prompt enhancement: after the model folder is copied, an offline package is unpacked, or antivirus software scans the files, FreeVideo no longer asks to download 8.9 GB again when the files are unchanged. It now also works on 6 GB graphics cards, finishing in under a minute instead of timing out.", - "Chinese interface: browser translation no longer changes FreeVideo's own labels, so the size menu keeps showing MP.", - "Feedback: feedback sent for a failed generation now includes the engine's error message.", - "Install: the Windows library checks also handle files in deeply nested folders." + "Image generation: every image generated on this machine is kept in a history, newest first, and can be placed on the canvas, used as a reference, deleted, or opened in its folder. Blank canvases offer Standard and HD sizes.", + "Image generation: on 6-8 GB graphics cards, decoding uses less graphics memory and runs faster. On cards that can hold the whole model, the weights load straight to the card and no second copy stays in system memory, which cuts memory use after loading from about 15 GB to about 2 GB.", + "Reporting problems: wherever an error or failure message appears, including generation, editing and export, models and environment, image generation, the agent connection, the library, prompt enhancement, sharing, updates, and the launcher, a Report a problem button next to it opens the feedback form with the context filled in.", + "AI Autopilot: when a chat cannot be deleted, it now says why and offers Stop and delete; other failures are reported with a button to submit the problem.", + "White models: only the cat ready-made scene remains, and one click starts it. A white model that was not made now says why and what to do next, and the page shows rendering progress. A question about progress is answered instead of being started as a new request.", + "Character sheets: in the back view, the head now turns away together with the body.", + "Mac: long videos can now be generated in parts.", + "Launcher and updates: Windows updates download faster in mainland China. When GitHub is slow, the launcher can fetch the update from a domestic mirror, and every file from the mirror is checked against the release signature before use. The install confirmation page now fits narrow windows: the sidebar collapses to icons and the buttons stay visible.", + "Workflows: workflows saved by older versions run again. Options added in later versions that are empty or invalid in an old file are set back to their defaults.", + "Video generation: the High quality level generates faster. On 8 GB graphics cards, 1920x1088 videos that were refused for falling just short of graphics memory now run. Changing the early steps setting of a video reference now produces a new result instead of reusing the earlier one.", + "LoRA: a LoRA trained for Wan 2.x now says so when it is selected for the int8 model, instead of only saying it cannot be used.", + "Linux: on computers with less memory, generating again right after a generation no longer runs out of memory near the end of sampling. On 6 GB graphics cards, Max no longer takes hours to finish." ] }, "zh": { - "summary": "极致档更快;改图时可以只改选中的区域;Agent 可以把自己渲染的片段导入时间线;长视频生成中可以继续加入队列。", + "summary": "生图工具会保存所有生成过的图片,小显存和大显存的显卡生图都更省内存;所有错误提示旁都有“提交问题”按钮;Windows 版在中国大陆更新更快。", "changes": [ - "质量档位:极致档改为 20 步加带检查的步缓存,更快完成;精细档改为 16 步加步缓存。档位名称不变。", - "改图:改一张图时,可以拖框、点选物体或画闭合曲线来选区;按住 Shift 添加,按住 Alt 减去。只改选区里的内容,选区外完全不变。点选第一次使用时从魔搭下载 184 MB 的模型。", - "创作面板:长视频或分段视频生成中,按钮保持可用,显示“加入队列”。最多排 5 条,当前视频完成或停止后自动开始下一条。", - "Agent:Agent 可以通过 HTTP、MCP 和命令行导入自己渲染的片段,按帧放上时间线并导出。未连接时,自动页说明连上之后能做什么。", - "剪辑视图:参考图读不出来时会当场提示,可以换一张。", - "升级:替换旧版本的 FreeVideo 标签页时,二次采样、第一遍和第二遍步数、步缓存和放大倍数都会带过来。", - "提示词增强:复制模型文件夹、解压离线包或被杀毒软件扫描之后,只要文件内容没变,就不再要求重新下载 8.9 GB。6 GB 显卡上现在也能用,一分钟内完成,不再超时。", - "中文界面:浏览器翻译不再改动 FreeVideo 自己的文字,尺寸菜单保持显示 MP。", - "反馈:为失败的生成发送反馈时,会附上引擎给出的报错信息。", - "安装:Windows 上的库文件检查也支持层级很深的文件夹。" + "生图:本机生成过的图片都保存在历史记录里,按时间从新到旧排列,可放到画布、用作参考图、删除或打开所在文件夹。空白画布可选“标准 / 高清”尺寸。", + "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存放得下整个模型的显卡,权重直接加载到显卡,不再在内存里留一份副本,加载后内存占用从约 15 GB 降到约 2 GB。", + "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点一下带上情况打开问题反馈。", + "AI 全自动:对话删不掉时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。", + "白模:现成场景只保留“橘猫跳上书架”,点一下直接开始。白模没做成时会说明原因和下一步,渲染中页面显示进度。问白模进度时会回答进度,不再当成新要求。", + "人物设定图:背面图里头和身体一起转向背后。", + "Mac:支持分段生成长视频。", + "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收成图标,按钮始终可见。", + "工作流:旧版本存的工作流可以直接运行。旧文件里后加的选项为空或不合法时,改回默认值。", + "视频生成:“精细”档生成更快。8 GB 显存的显卡上,以前因显存差一点而被拒绝的 1920×1088 视频,现在可以生成。改动视频参考的前期步数设置后,会生成新的结果,不再沿用之前的结果。", + "LoRA:为 Wan 2.x 训练的 LoRA 选进 int8 模型时,会说明这是给 Wan 2.x 训练的 LoRA,不再只说无法使用。", + "Linux:内存较小的电脑接着再生成一次时,不再在采样快结束时内存不足。6 GB 显存的显卡选“极致”时,不再要等好几个小时。" ] }, "history": [ + { + "product_version": "0.4.14", + "en": { + "summary": "Max is faster, a picture can now be changed only inside a region you select, agents can import their own clips into the timeline, and long videos can take more requests into the queue while they generate.", + "changes": [ + "Quality levels: Max now uses 20 steps with a guarded step cache and finishes sooner; High uses 16 steps with the step cache. The level names stay the same.", + "Picture editing: when changing a picture, drag a box, click an object, or draw a closed outline to select a region. Shift adds to the selection and Alt removes from it. Only the selected region changes, and everything outside it stays exactly the same. Click selection downloads a 184 MB model from ModelScope the first time it is used.", + "Studio: while a long or multi-part video is generating, the button stays available as \"Add to queue\". Up to five requests can wait, and each starts when the current video finishes or is stopped.", + "Agent: agents can import clips they rendered through HTTP, MCP and the command line, place them on the timeline by frame, and export. Before an agent is connected, the autopilot page says what connecting one is for.", + "Editing view: a reference picture that cannot be read is flagged right away so another one can be chosen.", + "Upgrade: when an old FreeVideo tab is replaced, two-pass sampling, first- and second-pass steps, the step cache and the upscale factor are carried over.", + "Prompt enhancement: after the model folder is copied, an offline package is unpacked, or antivirus software scans the files, FreeVideo no longer asks to download 8.9 GB again when the files are unchanged. It now also works on 6 GB graphics cards, finishing in under a minute instead of timing out.", + "Chinese interface: browser translation no longer changes FreeVideo's own labels, so the size menu keeps showing MP.", + "Feedback: feedback sent for a failed generation now includes the engine's error message.", + "Install: the Windows library checks also handle files in deeply nested folders." + ] + }, + "zh": { + "summary": "极致档更快;改图时可以只改选中的区域;Agent 可以把自己渲染的片段导入时间线;长视频生成中可以继续加入队列。", + "changes": [ + "质量档位:极致档改为 20 步加带检查的步缓存,更快完成;精细档改为 16 步加步缓存。档位名称不变。", + "改图:改一张图时,可以拖框、点选物体或画闭合曲线来选区;按住 Shift 添加,按住 Alt 减去。只改选区里的内容,选区外完全不变。点选第一次使用时从魔搭下载 184 MB 的模型。", + "创作面板:长视频或分段视频生成中,按钮保持可用,显示“加入队列”。最多排 5 条,当前视频完成或停止后自动开始下一条。", + "Agent:Agent 可以通过 HTTP、MCP 和命令行导入自己渲染的片段,按帧放上时间线并导出。未连接时,自动页说明连上之后能做什么。", + "剪辑视图:参考图读不出来时会当场提示,可以换一张。", + "升级:替换旧版本的 FreeVideo 标签页时,二次采样、第一遍和第二遍步数、步缓存和放大倍数都会带过来。", + "提示词增强:复制模型文件夹、解压离线包或被杀毒软件扫描之后,只要文件内容没变,就不再要求重新下载 8.9 GB。6 GB 显卡上现在也能用,一分钟内完成,不再超时。", + "中文界面:浏览器翻译不再改动 FreeVideo 自己的文字,尺寸菜单保持显示 MP。", + "反馈:为失败的生成发送反馈时,会附上引擎给出的报错信息。", + "安装:Windows 上的库文件检查也支持层级很深的文件夹。" + ] + } + }, { "product_version": "0.4.13", "en": { @@ -250,39 +287,6 @@ "进度与出错提示:说法更短、更直白。" ] } - }, - { - "product_version": "0.4.6", - "en": { - "summary": "Long videos keep the same voices and people from part to part and no longer pause where parts join, and a new character library lets you create characters, generate their pictures and bring them into a prompt with @. A small preview appears while a video is generated, a waiting video can be moved to the front of the queue, and image generation, the editing view and the launcher get many fixes.", - "changes": [ - "Long videos: voices keep the same sound and loudness from part to part, the pause and the return to the same pose where parts with dialogue join are gone, and continued parts also receive the characters' pictures. A long video made in one request gives each part its own share of the description, and each part's description can be read and edited; if the description cannot be split, every part uses the whole description and the reason is shown. Agent can also write one description per part.", - "Characters: the material section has a character row where you add, edit and delete characters, each with a front picture and up to five more views. The AI tab of the character dialog generates the front, three-quarter, side, back and face pictures one by one, with progress, stop and retry. Type @ in the prompt to bring a character's pictures in as references. In Agent shots, continuing parts carry the characters' pictures too: two when one person is in the shot, one each when there are more.", - "Continue from video: first and last frame images are used as reference pictures, and settings that cannot continue a video are adjusted automatically with a short note instead of stopping with an error. In the editing view, a part that continues the one before keeps your reference pictures, so people stay the same; first and last frames apply only to a part that starts fresh.", - "Generation: from the third sampling step, a small picture of the frames being made appears in the progress block, in both passes. The creation panel and the bottom bar show the same estimated time, and once a run goes past it both say it is taking longer than expected. The main button stays on Add to queue while a video is running. Above Generate, the creation panel lists what the current settings will still download and which LoRA files cannot be found.", - "Queue: from the second waiting video on, each row has a Move to front button. The row is greyed until the server confirms; the running video and the parts of a continuous chain are never moved. The waiting list is shown in queue order and stays inside narrow windows.", - "Image generation: when graphics memory runs out, the picture is retried one size smaller until it fits, and the size made is shown. After the image pack is moved or copied it is checked again instead of being reported as missing and downloaded again. A page refresh resumes the running image task at its exact canvas size, and a request that arrives while the idle image worker is closing runs on a fresh worker instead of failing.", - "Editing view: a subtitle look can be saved as a style and applied to every subtitle in one press; a range drawn on the timeline starts exactly where it is pressed; a queued part that is cancelled, or left unstarted when a run fails, shows as stopped, and after a cancel is accepted the bar reads Stopping; a failed save before export names the reason, and choosing media without sound for Replace audio says so.", - "Install and launcher: Settings show right away what an installation check or install is doing, and the launcher shows counts and speed while it finds, checks and copies existing models. The one-time background kernel compile after an install or update starts about 5 seconds after ComfyUI opens instead of 30. Reopening the creation panel from the launcher keeps the previous project's media and connections. On macOS, the update reminder says to download the new version and replace the current one, and its button opens the download page.", - "AI autopilot: every button responds at once and retries before giving up, and a message sent while the agent is not listening goes out when it listens again. Failed tasks, pictures, short dramas and character cards name the step that failed and offer the same report download and submit dialog as video. The character library keeps every picture of cards without a front picture, tells characters that share a name apart, and stays hidden while the Agent interface is off.", - "Optional inputs left empty or written as \"None\" or \"null\" by older workflows or scripts are no longer treated as real values, so an ordinary generation is no longer sent down the second-pass route." - ] - }, - "zh": { - "summary": "长视频各段的人声和人物保持一致,段与段相接处不再停顿;新增角色库,可以新建角色、用 AI 生成角色图片,并在描述里用 @ 引用。生成视频时进度块里会显示正在生成的画面小预览,等待中的视频可以排到最前;图像生成、剪辑视图和启动器也修复了多处问题。", - "changes": [ - "长视频:人声在各段之间保持同样的音色和音量;有对话的段相接处不再停顿,人物也不再每段回到同一个姿势;接续的段也会带上人物参考图。一次请求生成的长视频,每段只用描述里属于自己的部分,每段的描述可以查看和修改;描述无法拆分时,各段都用完整描述,并说明原因。Agent 也可以为每段分别写描述。", - "角色:素材区新增“角色”一行,可以新建、编辑和删除角色,每个角色有一张正面图和最多五张其他角度图。角色对话框的“AI 生成”会逐张生成正面、四分之三、侧面、背面和脸部五张图,可看进度、停止和重试。在描述里输入 @ 可以引用角色,它的图片自动加入参考素材。Agent 拍片时,接续的镜头也带上人物图片:只有一个人时带两张,多人时每人一张。", - "视频接续:首帧和尾帧图会用作参考图;不能用于接续的设置会自动调整并附一句说明,不再直接报错停止。剪辑视图里接着上一段生成的段会保留参考图,人物保持一致;首尾帧只用于重新开始的段。", - "生成:从采样第 3 步起,进度块里会显示一张正在生成的画面小预览,两遍采样都有。创作面板和底栏显示同一个预计时间,超过预计后两处都改为“比预计久”。视频生成中主按钮保持“加入队列”。“生成”按钮上方会列出当前设置还需要下载的内容,以及找不到的 LoRA 文件。", - "队列:从第 2 个等待中的视频起,每行有“排到最前”按钮。点击后这一行置灰,服务端确认后按新顺序显示;正在生成的视频和连续生成的各段不会被移动。等待列表按队列顺序显示,在窄窗口里也不会被截掉。", - "图像生成:显存不足时自动缩小一档重试,直到能出图,并显示实际尺寸。图像包被移动或复制后会先重新校验,不再误报缺失、要求重新下载。刷新页面后会按原画布尺寸接回正在进行的图像任务;空闲的图像进程正在关闭时发来的请求会在新进程上运行,不再失败。", - "剪辑视图:字幕样式可以存为预设,一键套用到所有字幕;在时间线上画范围,起点就是按下的位置;取消排队中的段,或整条失败时还没开始的段,会显示为已停止,取消生效后底栏显示“正在停止”;导出前保存失败会说明原因;替换声音时选了没有声音的素材会提示。", - "安装和启动器:设置里检查安装、开始安装时马上显示正在做什么;启动器复用已有模型时,显示查找、校验和复制的项数与速度。安装或更新后的一次性后台内核编译在 ComfyUI 打开约 5 秒后开始(原来 30 秒)。从启动器重新打开创作面板时,上次工程的素材和连线会保留。Mac 上的更新提醒写明“下载新版后替换当前版本”,按钮会打开下载页。", - "AI 全自动:每个按钮点了都会马上响应,失败会先重试;Agent 没在接收时发出的消息,会在它恢复后发出。任务、图片、短剧和角色卡失败时写明是哪一步出的错,并和视频一样可以下载和提交诊断报告。角色库里没有正面图的角色卡会保留全部图片,同名角色可以区分,Agent 界面关闭时角色库也隐藏。", - "旧工作流或脚本把可选输入留空、写成 \"None\" 或 \"null\" 时,不再被当成真实值,普通生成也不会再被误当作第二遍处理。" - ] - } } ] } diff --git a/freevideo_engine/resident_models.py b/freevideo_engine/resident_models.py index a85c9d48..c6fb1858 100644 --- a/freevideo_engine/resident_models.py +++ b/freevideo_engine/resident_models.py @@ -2,6 +2,7 @@ import gc import json from pathlib import Path +import sys import time import torch @@ -95,19 +96,21 @@ def parked_bytes(model): def release_encoder(clip, manager): - """Discard our Windows mapped encoder without a full GPU-to-CPU copy. + """Discard our mapped encoder without a full GPU-to-CPU copy. Native unpatching still releases pins, hooks and loaded-model registrations. Once the bank drops its references, unused tensors can die where they are; any other owner keeps valid tensors. Future encoding uses the mmap loader. """ from .encoder_checkpoint import load_clip_model_patcher - from .system import windows def owned(patcher): factory = getattr(patcher, 'cached_patcher_init', None) return isinstance(factory, (tuple, list)) and bool(factory) and factory[0] is load_clip_model_patcher patcher = getattr(clip, 'patcher', None) - targets = [patcher] if windows() and owned(patcher) else [] + # A native unpatch copies every weight to the CPU first: 13 GiB for the video text encoder, + # enough to exceed a machine's memory in the moment it was meant to make room. The Mac + # releases its encoder its own way (macos_encoder) and keeps the native path here. + targets = [patcher] if owned(patcher) and sys.platform != 'darwin' else [] if targets: for loaded in manager.current_loaded_models: candidate = loaded.model diff --git a/freevideo_engine/source_update.py b/freevideo_engine/source_update.py new file mode 100644 index 00000000..292ac469 --- /dev/null +++ b/freevideo_engine/source_update.py @@ -0,0 +1,239 @@ +"""Explicit, authenticated small updates; unavailable deltas fall back to EXEs.""" +import logging +import os +from pathlib import Path +import re +import shutil +import sys + +from . import disk_space, execution_paths, network +from . import launcher_update as update +from .top_level_modules import update_modules + +ed25519, keys, source = update_modules() + +log = logging.getLogger(__name__) +DOMAIN = b'FreeVideo source manifest v1\x00' + + +def signed(value): + try: + signature = bytes.fromhex(value.get('signature', '')) + payload = DOMAIN + source.canonical({k: v for k, v in value.items() if k != 'signature'}) + return (keys.ED25519_RELEASE_KEY_ENABLED and + keys.ED25519_PUBLIC_KEY != keys.PLACEHOLDER_PUBLIC_KEY and + ed25519.verify(keys.ED25519_PUBLIC_KEY, payload, signature)) + except (TypeError, ValueError): + return False + + +def metadata_name(candidate): + version = candidate.get('product_version') + if not source.VERSION.fullmatch(str(version)): + raise ValueError('Source updates require a product version') + return 'source-%s-%s.json' % (version, update.build_target(candidate)) + + +def read_metadata(url, token, *, github=False, limit=source.MAX_MANIFEST): + with update.open_download(url, token, binary=True, github_only=github) as response: + return b''.join(update._bytes(response, limit)) + + +def same_build(first, second): + first, second = update.build_identity(first), update.build_identity(second) + # publish_release may trim older notes to stay under the 64 KiB legacy cap. + return all(first.get(key, default) == second.get(key, default) for key, default in ( + ('schema', None), ('repository', None), ('channel', None), ('target', 'windows-x86_64'), + ('packaging', None), ('track', 'stable'), ('revision', None), ('built_at', None), + ('version', None), ('product_version', None))) + + +def fetch_manifest(candidate, token=''): + """Never infer source trust from cached/remote _update_sources flags.""" + name = metadata_name(candidate) + github = update.asset_url('v' + candidate['product_version'], name) + try: + digest = read_metadata(github + '.sha256', token, github=True, limit=65).decode('ascii').strip() + if not source.HEX.fullmatch(digest): + raise ValueError('Invalid GitHub source digest') + data = read_metadata(github, token, github=True) + if source.sha(data) != digest: + raise ValueError('GitHub source digest mismatch') + value = source.manifest(source.decode(data)) + except (OSError, ValueError, TypeError): + # Mirrors may host a signed manifest, but a public RFC test key cannot + # authorize anything. Fail quickly before fetching when still disabled. + if not keys.ED25519_RELEASE_KEY_ENABLED or keys.ED25519_PUBLIC_KEY == keys.PLACEHOLDER_PUBLIC_KEY: + raise ValueError('No authenticated source manifest') from None + data = read_metadata(update.mirror_url('releases/v' + candidate['product_version'] + '/' + name), '') + value = source.manifest(source.decode(data)) + if not signed(value): + raise ValueError('Invalid source signature') + if not same_build(value['build'], candidate): + raise ValueError('Source manifest belongs to another release') + return value + + +def pending_path(home, candidate): + return source.io_path(home) / 'updates' / ('source-' + candidate['revision'] + '.json') + + +def try_download(candidate, home, token='', *, progress=None, cancel=None, language=None): + context = source.bundle_context() + if context is None or update.build_track(candidate) != 'stable': + return None + base, bundle = context + active = source.ACTIVE + previous = active['manifest'] if active else base + current_source = active['source'] if active else bundle + try: + def cancelled(): + if cancel is not None and cancel.is_set(): + raise InterruptedError('Source update cancelled') + cancelled() + source.storage(source.io_path(home)) + value = fetch_manifest(candidate, token) + delta = value.get('delta') + if (not update.newer(value['build'], previous['build']) or not source.compatible(value, base) or + not delta or delta['from'] != previous['identity'] or + (not value['gui'] and value.get('launcher_files_changed', True))): + return None + target = source.io_path(home) / 'updates' / delta['sha256'] / delta['name'] + # Sparse overlay + complete composed tree + unzip buffer + download. + needed = delta['bytes'] + 3*sum(row['bytes'] for row in value['files'].values()) + 16*2**20 + if disk_space.free_bytes(target.parent) < needed: + raise update.UpdateUnavailable('disk') + sources = {'github': update.asset_url('v' + candidate['product_version'], delta['name']), + 'mirror': update.mirror_url('releases/v' + candidate['product_version'] + '/' + delta['name'])} + candidates = [(name, sources[name]) for name in update.source_order(language)] + def report(done, total, speed): + cancelled() + if progress: + progress(dict(done=done, total=total, bytes_per_second=speed, + remaining_seconds=(total-done)/speed if speed > 0 else 0)) + network.download(candidates, target, delta['sha256'], report, + network=dict(quiet=True, resource_check=cancelled), size=delta['bytes'], + category='launcher-update', cycles=1, stall_seconds=update.UPDATE_STALL_SECONDS, + low_speed_limit=0, validate_url=update._trusted_url) + cancelled() + folder = source.prepare(value, previous, bundle, current_source, target, home, base) + cancelled() + source.atomic_json(pending_path(home, candidate), {'overlay': folder.name, 'identity': value['identity']}) + return folder + except InterruptedError: + raise + except Exception as error: + # No paths, metadata, response bodies or credentials enter diagnostics. + log.info('Source update unavailable; using full package (%s)', type(error).__name__) + try: + pending_path(home, candidate).unlink(missing_ok=True) + except OSError: + pass + return None + + +def prepared(candidate, home): + context = source.bundle_context() + if context is None: + return None + base, bundle = context + path = pending_path(home, candidate) + if not path.exists(): + return None + try: + receipt = source.read_json(path) + name = receipt['overlay'] + if not re.fullmatch(r'\d+\.\d+\.\d+-[0-9a-f]{16}', name): + raise ValueError('Invalid overlay name') + folder = source.io_path(home) / 'overlay' / name + value = source.validate_overlay(folder, base) + if (receipt['identity'] != value['identity'] or + not same_build(value['build'], candidate)): + raise ValueError('Prepared source identity changed') + source.compose(bundle, folder, value, source.io_path(home) / 'source-overlay') + return folder + except (OSError, ValueError, KeyError, TypeError): + path.unlink(missing_ok=True) + raise update.DownloadedLauncherUnavailable('Prepared source changed; download again') from None + + +def cleanup(home, keep): + """Only remove validated, updater-owned obsolete directories; never user trees.""" + home = source.io_path(home) + for folder in (home / 'overlay').iterdir(): + if folder.name in keep or not re.fullmatch(r'\d+\.\d+\.\d+-[0-9a-f]{16}', folder.name): + continue + # Another launcher may still be running from this version. + if source.in_use(home, folder.name): + continue + try: + if folder.is_symlink() or {p.name for p in folder.iterdir()} != {'receipt.json', 'files'}: + continue + receipt = source.read_json(folder / 'receipt.json') + value = source.manifest(receipt['manifest']) + if source.overlay_name(value) != folder.name or receipt['sha256'] != source.sha(source.canonical(value)): + continue + composed = home / 'source-overlay' / folder.name + if composed.exists(): + source.verify_tree(composed, value['files']) + shutil.rmtree(composed) + # An edited overlay is retained for inspection, never executed. + files = folder / 'files' + for path in files.rglob('*'): + if path.is_symlink(): + raise ValueError('Changed overlay') + if path.is_file(): + name = path.relative_to(files).as_posix() + source.verify_file(path, value['files'][name]) + shutil.rmtree(folder) + except (OSError, ValueError, KeyError, TypeError): + continue + + +def launch(candidate, home, folder, *, token='', popen=None): + home = source.io_path(home) + active_path = home / 'overlay' / 'active.json' + try: + old = source.read_json(active_path) + except (OSError, ValueError): + old = {'entries': []} + # Last actually running overlay is the rollback target, even after recovery. + previous = source.ACTIVE['folder'].name if source.ACTIVE else None + entries = [folder.name] + ([previous] if previous and previous != folder.name else []) + from .linux_bundle import child_environment, appimage + env = dict(child_environment(), FREEVIDEO_LAUNCHER_HOME=str(home), PYINSTALLER_RESET_ENVIRONMENT='1') + if token: + env['GITHUB_TOKEN'] = token + executable = str(appimage() or sys.executable) + kwargs = dict(cwd=Path(executable).parent, env=env, close_fds=True) + if os.name == 'nt': + kwargs['creationflags'] = subprocess_flags() + else: + kwargs['start_new_session'] = True + executable_receipt = home / 'updates' / 'active.json' + try: + old_executable = source.read_json(executable_receipt) + except (OSError, ValueError): + old_executable = None + source.atomic_json(active_path, {'entries': entries}) + try: + # Let the established handoff/resume path finish installing the engine. + update.save(home / 'updates' / 'active.json', dict(approved=True, candidate=update.manifest(candidate))) + process = (popen or execution_paths.popen)([executable], **kwargs) + except BaseException: + source.atomic_json(active_path, old) + if old_executable is None: + executable_receipt.unlink(missing_ok=True) + else: + source.atomic_json(executable_receipt, old_executable) + raise + try: + cleanup(home, set(entries)) + except OSError: + pass + return process + + +def subprocess_flags(): + import subprocess + return subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP diff --git a/freevideo_engine/step_cache.py b/freevideo_engine/step_cache.py index c54f734c..a0335173 100644 --- a/freevideo_engine/step_cache.py +++ b/freevideo_engine/step_cache.py @@ -52,6 +52,8 @@ GUARD = 1 # reports of the guarded easycache-av carry this; earlier ones reused steps unchecked MAX_SHARE = .4 # at most this share of a pass's steps may reuse the cache (measured median is about 40%) LAST_FULL = 3 # the last steps always run the model +SHORT_PASS = 16 # a configured easycache-av pass of at most this many steps (精细) reuses at this looser threshold, +SHORT_THRESHOLD = .30 # measured: 5 of 16 steps reused, 26 dB against the same 16 steps run in full (0.2 reused 3) def requested(config=None): @@ -199,6 +201,8 @@ def _easycache(self, video_in, audio_in): def begin_pass(transformer, steps, config=None, *, offloader=None, disabled_reason=None): """Attach fresh state for this sampling pass; refinement never calls this.""" wanted = requested(config) + if wanted and wanted[2] == 'config' and wanted[0] == 'easycache-av' and steps <= SHORT_PASS: + wanted = (wanted[0], SHORT_THRESHOLD, wanted[2]) transformer._freevideo_step_cache = (StepCache(wanted[0], wanted[1], steps, source=wanted[2], offloader=offloader, disabled_reason=disabled_reason) if wanted else None) return transformer._freevideo_step_cache diff --git a/freevideo_engine/top_level_modules.py b/freevideo_engine/top_level_modules.py new file mode 100644 index 00000000..99ed7aed --- /dev/null +++ b/freevideo_engine/top_level_modules.py @@ -0,0 +1,33 @@ +"""Import the source-update modules that sit beside the engine package. + +The launcher puts them on sys.path, but ComfyUI loads this folder as a package by file location and the +folder is not on sys.path, so they are loaded from the folder next to freevideo_engine under their own names. +""" +import importlib +import importlib.util +import sys +from pathlib import Path + + +def load(name): + if name in sys.modules: + return sys.modules[name] + try: + return importlib.import_module(name) + except ModuleNotFoundError as error: + if error.name != name: + raise + spec = importlib.util.spec_from_file_location(name, Path(__file__).resolve().parents[1] / (name + '.py')) + module = importlib.util.module_from_spec(spec) + sys.modules[name] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(name, None) + raise + return module + + +def update_modules(): + """(ed25519, update keys, source bootstrap); the bootstrap imports the first two by their own names.""" + return load('freevideo_ed25519'), load('freevideo_update_keys'), load('freevideo_source_bootstrap') diff --git a/freevideo_engine/workflow_repairs.py b/freevideo_engine/workflow_repairs.py new file mode 100644 index 00000000..37c2b2f6 --- /dev/null +++ b/freevideo_engine/workflow_repairs.py @@ -0,0 +1,65 @@ +"""Optional Generate inputs that a workflow from an older version can hold in a form the node refuses. + +The inputs below were appended after the first public workflow, so an older save holds an empty or +missing value in their place and ComfyUI refuses the whole request before the node runs. Each one +falls back to its default. Prompt, size, duration, seed and step counts are not here: a wrong value +in those is a real error and keeps its message. +""" +import json +import time + +from .long_paths import io_path + +# name -> (kind, default, options); kept equal to the node's inputs by tests/test_workflow_repairs.py. +TOLERATED = { + 'force_regenerate': ('bool', False, None), + 'preview': ('bool', False, None), + 'refine_from': ('text', '', None), + 'continue_from': ('text', '', None), + 'first_frame_reference': ('bool', False, None), + 'voice_reference': ('bool', False, None), + 'hide_tail': ('bool', False, None), + 'step_cache': ('choice', 'none', ('none', 'easycache-av', 'fbcache')), + 'upscale_factor': ('choice', '2', ('2', '1.5', '1.2')), +} +KEPT = 50 + + +def tolerated_value(name, value): + kind, default, options = TOLERATED[name] + if kind == 'bool': + return value if isinstance(value, bool) else default + if kind == 'text': + return value if isinstance(value, str) else default + if isinstance(value, (int, float)) and not isinstance(value, bool): + value = '%g' % value + return value if value in options else default + + +def repair_inputs(inputs): + """Reset the tolerated inputs that hold an unusable value; returns the names changed. + + A value that is a link (a list) or an input the request does not have is left as it is.""" + changed = [] + for name in TOLERATED: + if name not in inputs or isinstance(inputs[name], list): + continue + value = tolerated_value(name, inputs[name]) + if value != inputs[name] or type(value) is not type(inputs[name]): + was_number = isinstance(inputs[name], (int, float)) and not isinstance(inputs[name], bool) + if not (was_number and value == '%g' % inputs[name]): + changed.append(name) + inputs[name] = value + return changed + + +def record(engine_root, origin, names): + """Keep a short history of what was reset for the diagnostic report (logs/workflow-repairs.jsonl).""" + if not names: + return + logs = io_path(engine_root) / 'logs' + logs.mkdir(parents=True, exist_ok=True) + history = logs / 'workflow-repairs.jsonl' + line = json.dumps(dict(at=time.strftime('%Y-%m-%dT%H:%M:%S%z'), origin=origin, reset=sorted(set(names)))) + rows = history.read_text(encoding='utf-8').splitlines()[-(KEPT - 1):] if history.is_file() else [] + history.write_text('\n'.join([*rows, line]) + '\n', encoding='utf-8') diff --git a/freevideo_source_bootstrap.py b/freevideo_source_bootstrap.py new file mode 100644 index 00000000..a50c8c8b --- /dev/null +++ b/freevideo_source_bootstrap.py @@ -0,0 +1,479 @@ +"""Trusted source-overlay bootstrap; intentionally outside freevideo_engine. + +Entry points call activate() BEFORE importing that package. This module and its +trust anchors remain frozen, and are part of the runtime compatibility key. +No network, widgets or third-party imports are needed to recover a broken update. +""" +import hashlib +import importlib.machinery +import json +import os +from pathlib import Path +import re +import shutil +import stat +import sys +import uuid +import zipfile + +# Freeze the trust anchors before any source directory enters sys.path. +import freevideo_ed25519 +import freevideo_update_keys + +MAX_MANIFEST = 2 * 2**20 +MAX_FILES = 4096 +MAX_FILE = 32 * 2**20 +MAX_SOURCE = 128 * 2**20 +MAX_ZIP = 64 * 2**20 +HEX = re.compile(r'[0-9a-f]{64}') +VERSION = re.compile(r'\d+\.\d+\.\d+') +TOP_FILES = {'pyproject.toml', 'README.md', 'LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md', + 'MANIFEST.in', '__init__.py', 'freevideo_source_bootstrap.py', 'freevideo_ed25519.py', + 'freevideo_update_keys.py', 'freevideo', 'freevideo.ps1', 'freevideo.cmd', + 'setup.sh', 'setup.ps1', 'setup.cmd', 'test.sh', 'test.ps1', 'test.cmd'} +EXTENSIONS = {'.py', '.json', '.txt', '.ps1', '.sh', '.js', '.css', '.svg', '.png', '.ico', + '.qml', '.woff2', '.otf', '.webp'} +ACTIVE = None + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(',', ':'), ensure_ascii=True).encode('ascii') + + +def sha(data): + return hashlib.sha256(data).hexdigest() + + +def digest(path): + result = hashlib.sha256() + with path.open('rb') as stream: + for chunk in iter(lambda: stream.read(256*1024), b''): + result.update(chunk) + return result.hexdigest() + + +def io_path(path): + path = Path(os.path.abspath(path)) + if os.name == 'nt' and not str(path).startswith('\\\\?\\'): + return Path('\\\\?\\UNC\\' + str(path)[2:] if str(path).startswith('\\\\') else '\\\\?\\' + str(path)) + return path + + +def root(): + if os.environ.get('FREEVIDEO_LAUNCHER_HOME'): + return io_path(os.environ['FREEVIDEO_LAUNCHER_HOME']) + if sys.platform == 'darwin': + return Path.home() / 'Library/Application Support/FreeVideo/launcher' + return io_path(Path(os.environ.get('LOCALAPPDATA') or Path.home()) / 'FreeVideo-launcher') + + +def safe_name(name): + if not isinstance(name, str) or not 1 <= len(name) <= 240 or '\\' in name or ':' in name: + raise ValueError('Invalid source path') + parts = name.split('/') + for part in parts: + if (part in ('', '.', '..') or part.endswith((' ', '.')) or + any(ord(c) < 32 or c in '<>"|?*' for c in part) or + re.fullmatch(r'(CON|PRN|AUX|NUL|COM[0-9]|LPT[0-9])(?:\..*)?', part, re.I)): + raise ValueError('Invalid source path') + if any(part == '__pycache__' for part in parts): + raise ValueError('Bytecode caches are not source') + if name not in TOP_FILES and name != 'web/vendor/three/LICENSE' and not ( + parts[0] == 'web' and len(parts) > 2 and parts[-2] == 'tutorial' and Path(name).suffix == '.mp4') and not (len(parts) > 1 and parts[0] in + ('freevideo_engine', 'web', 'constraints', 'example_workflows', 'scripts') and + Path(name).suffix in EXTENSIONS): + raise ValueError('Source path is outside the payload') + return name + + +def _pairs(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError('Duplicate JSON key') + result[key] = value + return result + + +def decode(data): + if len(data) > MAX_MANIFEST: + raise ValueError('Source manifest too large') + return json.loads(data, object_pairs_hook=_pairs) + + +def read_json(path): + with path.open('rb') as stream: + return decode(stream.read(MAX_MANIFEST+1)) + + +def atomic_json(path, value): + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name('.' + path.name + '-' + uuid.uuid4().hex) + try: + with temporary.open('xb') as stream: + stream.write(canonical(value)) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +def source_identity(value): + return sha(canonical({k: value[k] for k in ('base', 'build', 'files', 'gui')})) + + +def manifest(value): + if not isinstance(value, dict) or value.get('schema') != 1 or not HEX.fullmatch(str(value.get('base', ''))): + raise ValueError('Invalid source manifest') + build, files = value.get('build'), value.get('files') + if (not isinstance(build, dict) or not VERSION.fullmatch(str(build.get('product_version', ''))) or + not isinstance(build.get('version'), str) or not 1 <= len(build['version']) <= 64 or + not re.fullmatch(r'[0-9a-f]{40,64}', str(build.get('revision', ''))) or + type(build.get('built_at')) is not int or build['built_at'] <= 0 or + build.get('repository') != 'FlashML-org/FreeVideo' or build.get('schema') != 1 or + build.get('channel') not in ('windows-preview', 'macos-preview', 'linux-preview') or + build.get('track', 'stable') != 'stable' or type(value.get('gui')) is not bool or + not isinstance(files, dict) or not 1 <= len(files) <= MAX_FILES or + # A source tree without its root package would let the older one load. + 'freevideo_engine/__init__.py' not in files): + raise ValueError('Invalid source build') + total, seen = 0, set() + for name, row in files.items(): + safe_name(name) + if name.casefold() in seen or not isinstance(row, dict): + raise ValueError('Duplicate source path') + seen.add(name.casefold()) + if (type(row.get('bytes')) is not int or not 0 <= row['bytes'] <= MAX_FILE or + not HEX.fullmatch(str(row.get('sha256', '')))): + raise ValueError('Invalid source file') + total += row['bytes'] + if total > MAX_SOURCE or value.get('identity') != source_identity(value): + raise ValueError('Invalid source identity or size') + # No file may be an ancestor of another, on case-insensitive systems either. + if any('/'.join(name.casefold().split('/')[:i]) in seen + for name in files for i in range(1, len(name.split('/')))): + raise ValueError('Conflicting source paths') + delta = value.get('delta') + if delta is not None: + if (not isinstance(delta, dict) or not HEX.fullmatch(str(delta.get('from', ''))) or + not isinstance(delta.get('files'), list) or not isinstance(delta.get('deleted'), list) or + len(delta['files']) + len(delta['deleted']) > 2*MAX_FILES or + type(delta.get('bytes')) is not int or not 1 <= delta['bytes'] <= MAX_ZIP or + not HEX.fullmatch(str(delta.get('sha256', ''))) or + not re.fullmatch(r'delta-[0-9a-f]{16}-to-[0-9a-f]{16}\.zip', str(delta.get('name', '')))): + raise ValueError('Invalid source delta') + for field in ('files', 'deleted'): + names = delta[field] + if len(set(names)) != len(names): + raise ValueError('Duplicate delta path') + for name in names: + safe_name(name) + if (name in files) != (field == 'files'): + raise ValueError('Inconsistent delta path') + return value + + +def read_manifest(path): + return manifest(read_json(path)) + + +def regular(root_path, relative): + current = root_path + if current.is_symlink(): + raise ValueError('Symlink source directory') + for part in relative.split('/'): + current = current / part + if current.is_symlink() or (hasattr(current, 'is_junction') and current.is_junction()): + raise ValueError('Symlink source path') + if not stat.S_ISREG(current.stat().st_mode): + raise ValueError('Nonregular source file') + return current + + +def verify_file(path, row): + if path.stat().st_size != row['bytes'] or digest(path) != row['sha256']: + raise ValueError('Source file changed') + + +def verify_tree(folder, files): + for name, row in files.items(): + verify_file(regular(folder, name), row) + found = set() + for path in folder.rglob('*'): + if path.is_symlink() or (hasattr(path, 'is_junction') and path.is_junction()): + raise ValueError('Symlink in source tree') + if path.is_file(): + found.add(path.relative_to(folder).as_posix()) + if found != set(files): + raise ValueError('Unexpected source files') + + +def compatible(value, base): + a, b = value['build'], base['build'] + return (value['base'] == base['base'] and value['gui'] == base['gui'] and + all(a.get(k, default) == b.get(k, default) for k, default in + (('target', 'windows-x86_64'), ('channel', None), ('packaging', None), ('track', 'stable')))) + + +def overlay_name(value): + return value['build']['product_version'] + '-' + value['identity'][:16] + + +def validate_overlay(folder, base): + if {p.name for p in folder.iterdir()} != {'receipt.json', 'files'}: + raise ValueError('Unexpected overlay files') + receipt = read_json(regular(folder, 'receipt.json')) + value = manifest(receipt['manifest']) + if (receipt.get('sha256') != sha(canonical(value)) or not compatible(value, base) or + folder.name != overlay_name(value)): + raise ValueError('Overlay receipt changed') + changed = {n: r for n, r in value['files'].items() if base['files'].get(n) != r} + verify_tree(folder / 'files', changed) + return value + + +def compose(bundle, folder, value, destination): + """Publish a complete verified source tree; sparse packages never enter sys.path.""" + target = destination / overlay_name(value) + if target.exists(): + verify_tree(target, value['files']) + return target + stage = destination / ('.source-' + uuid.uuid4().hex) + stage.mkdir(parents=True) + try: + for name, row in value['files'].items(): + origin = folder / 'files' / name + origin = regular(folder / 'files', name) if origin.exists() else regular(bundle, name) + verify_file(origin, row) + output = stage / name + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(origin, output) + verify_tree(stage, value['files']) + try: + stage.rename(target) + except OSError: + # Another launcher published the same version first; it may already + # be running from it, so verify and reuse it, never replace or delete. + if not target.exists(): + raise + verify_tree(target, value['files']) + return target + finally: + shutil.rmtree(stage, ignore_errors=True) + + +def storage(home): + for name in ('overlay', 'source-overlay', 'updates'): + path = home / name + if path.is_symlink() or (hasattr(path, 'is_junction') and path.is_junction()): + raise ValueError('Symlink update directory') + + +def prepare(value, previous, bundle, current_source, archive, home, base): + """Validate a single hop, then save independent cumulative sparse overlay.""" + value, previous = manifest(value), manifest(previous) + delta = value.get('delta') or {} + if (not compatible(value, base) or delta.get('from') != previous['identity'] or + (not value['gui'] and value.get('launcher_files_changed', True))): + raise ValueError('Incompatible source update') + expected = {n for n, row in value['files'].items() if previous['files'].get(n) != row} + deleted = set(previous['files']) - set(value['files']) + if set(delta['files']) != expected or set(delta['deleted']) != deleted: + raise ValueError('Delta inventory mismatch') + verify_file(archive, delta) + home = io_path(home) + storage(home) + parent = home / 'overlay' + parent.mkdir(parents=True, exist_ok=True) + target = parent / overlay_name(value) + if target.exists(): + validate_overlay(target, base) + compose(bundle, target, value, home / 'source-overlay') + return target + stage = parent / ('.stage-' + uuid.uuid4().hex) + stage.mkdir() + try: + changed = {n: r for n, r in value['files'].items() if base['files'].get(n) != r} + (stage / 'files').mkdir() + with zipfile.ZipFile(archive) as zipped: + rows = zipped.infolist() + if len(rows) != len(expected) or len(rows) > MAX_FILES: + raise ValueError('Invalid ZIP entry count') + seen, expanded = set(), 0 + for item in rows: + name = safe_name(item.filename) + mode = item.external_attr >> 16 + if (name in seen or name not in expected or item.is_dir() or item.flag_bits & 1 or + stat.S_IFMT(mode) not in (0, stat.S_IFREG) or item.file_size != value['files'][name]['bytes'] or + item.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)): + raise ValueError('Invalid ZIP entry') + seen.add(name) + expanded += item.file_size + if expanded > MAX_SOURCE or item.file_size > MAX_FILE: + raise ValueError('ZIP expansion limit') + # Read no more than the authenticated size, even with misleading headers. + with zipped.open(item) as source: + data = source.read(item.file_size+1) + if len(data) != item.file_size or sha(data) != value['files'][name]['sha256']: + raise ValueError('ZIP file checksum mismatch') + if name.endswith('.py'): + compile(data, name, 'exec') + if name in changed: + output = stage / 'files' / name + output.parent.mkdir(parents=True, exist_ok=True) + output.write_bytes(data) + if seen != expected: + raise ValueError('Missing ZIP files') + for name, row in changed.items(): + if name not in expected: + origin = regular(current_source, name) + verify_file(origin, row) + output = stage / 'files' / name + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(origin, output) + # Verify all reused base bytes as well; corrupt bases cannot be activated. + for name, row in value['files'].items(): + if name not in changed: + verify_file(regular(bundle, name), row) + verify_tree(stage / 'files', changed) + atomic_json(stage / 'receipt.json', {'manifest': value, 'sha256': sha(canonical(value))}) + try: + stage.rename(target) + except OSError: + # A concurrent launcher published the same version; reuse its copy. + if not target.exists(): + raise + validate_overlay(target, base) + compose(bundle, target, value, home / 'source-overlay') + return target + finally: + # Only this attempt's stage is ours. A published (verified) overlay may + # already be shared with another launcher, so it is never removed here. + shutil.rmtree(stage, ignore_errors=True) + + +LEASES = {} + + +def _lock(handle): + """Non-blocking exclusive lock; raises OSError while another process holds it.""" + if os.name == 'nt': + import msvcrt + handle.seek(0) + msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + import fcntl + fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB) + + +def lease(home, name): + """Mark a source version as running for the life of this process.""" + key = (str(home), name) + if key not in LEASES: + folder = home / 'leases' + folder.mkdir(parents=True, exist_ok=True) + handle = open(folder / ('%s.%d.lock' % (name, os.getpid())), 'a+b') + try: + _lock(handle) + except BaseException: + handle.close() + raise + LEASES[key] = handle + return LEASES[key] + + +def release_leases(): + while LEASES: + LEASES.popitem()[1].close() + + +def in_use(home, name): + """Whether any live launcher holds a lease on this source version.""" + for path in (home / 'leases').glob(name + '.*.lock'): + try: + handle = open(path, 'a+b') + except OSError: + return True + try: + _lock(handle) + except OSError: + handle.close() + return True + handle.close() + try: + path.unlink() # its owner has exited + except OSError: + pass + return False + + +def bundle_context(): + if not getattr(sys, 'frozen', False): + return None + bundle = io_path(sys._MEIPASS) + try: + base = read_manifest(bundle / 'source-base.json') + return base, bundle / 'engine-source' + except Exception: + return None + + +def activate(home=None, context=None): + """Fail closed to the last good source or bundled GUI, before package import.""" + global ACTIVE + ACTIVE = None + context = context or bundle_context() + if context is None: + return None + base, bundle = context + home = io_path(home or root()) + try: + storage(home) + entries = read_json(home / 'overlay' / 'active.json')['entries'] + if not isinstance(entries, list) or len(entries) > 2: + return None + except Exception: + return None + for name in entries: + try: + if not isinstance(name, str) or not re.fullmatch(r'\d+\.\d+\.\d+-[0-9a-f]{16}', name): + continue + folder = home / 'overlay' / name + value = validate_overlay(folder, base) + if value['build']['built_at'] <= base['build']['built_at']: + continue + # Held before the tree is used so no cleanup can remove it from under us. + lease(home, name) + source = compose(bundle, folder, value, home / 'source-overlay') + if value['gui']: + if 'freevideo_engine' in sys.modules: + return None # Never create a half-old, half-new GUI. + # Only the regular source package is redirected; third-party + # modules, stdlib and bootstrap remain in the frozen runtime. + sys.meta_path.insert(0, SourceFinder(source)) + sys.path.insert(0, str(source)) + sys.dont_write_bytecode = True + os.environ['PYTHONDONTWRITEBYTECODE'] = '1' + ACTIVE = {'manifest': value, 'source': source, 'folder': folder} + return ACTIVE + except Exception: + continue + return None + + +class SourceFinder: + """Explicit package-only precedence also handles older meta-path freezers.""" + def __init__(self, source): + self.source = source + + def find_spec(self, fullname, path=None, target=None): + if fullname == 'freevideo_engine': + return importlib.machinery.PathFinder.find_spec(fullname, [str(self.source)]) + if fullname.startswith('freevideo_engine.'): + spec = importlib.machinery.PathFinder.find_spec(fullname, path) + if spec is None: + # Deleted modules must not resurrect from the older PYZ. + raise ModuleNotFoundError(fullname) + return spec + return None diff --git a/freevideo_update_keys.py b/freevideo_update_keys.py new file mode 100644 index 00000000..70dfd7dd --- /dev/null +++ b/freevideo_update_keys.py @@ -0,0 +1,7 @@ +"""Release trust anchor: the public half of the update-signing key. Never ship a private key.""" + +# RFC 8032 test key; it never authorizes anything and only marks an unreplaced key. +PLACEHOLDER_PUBLIC_KEY = bytes.fromhex('d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a') +# Public key; SHA-256 7d3681f25aec9e54f249d0e877abdabb50de82af8ff0348d5c80fa124a748912. +ED25519_PUBLIC_KEY = bytes.fromhex('4db79633766fe1fbe01b53a0700021a001051466f03a4de86d2719dd7d44424c') +ED25519_RELEASE_KEY_ENABLED = True diff --git a/pyproject.toml b/pyproject.toml index 530629bd..2e898f61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ runtime = [ [project.scripts] freevideo = "freevideo_engine.cli:main" +[tool.setuptools] +py-modules = ["freevideo_source_bootstrap", "freevideo_ed25519", "freevideo_update_keys"] + [tool.setuptools.packages.find] include = ["freevideo_engine*"] diff --git a/scripts/build_linux.py b/scripts/build_linux.py index 5d4e4ba3..97122569 100644 --- a/scripts/build_linux.py +++ b/scripts/build_linux.py @@ -224,7 +224,14 @@ def main(): '--exclude-module', 'torch', '--exclude-module', 'triton', '--exclude-module', 'numpy', '--exclude-module', 'transformers', '--exclude-module', 'tkinter', str(ROOT / 'scripts/linux_app.py')] - subprocess.run(command, cwd=ROOT, check=True) + from scripts.source_build import run_pyinstaller + # These files are added after PyInstaller; they belong to the same base key. + extra_runtime = {'appimage-runtime': str(runtime), 'appimagetool': str(appimagetool)} + for name in SYSTEM_LIBRARIES: + path = system_library(name, args.libraries.resolve() if args.libraries else None) + if path is not None: + extra_runtime[name] = str(path.resolve()) + run_pyinstaller(command, ROOT, staged, build_file, extra_runtime) dist = out / 'dist' / 'FreeVideo' write_prune_report(out, prune_qt_bundle(dist)) bundled = bundle_libraries(dist / '_internal', args.libraries.resolve() if args.libraries else None) diff --git a/scripts/build_macos.py b/scripts/build_macos.py index ab95e10d..791c7709 100644 --- a/scripts/build_macos.py +++ b/scripts/build_macos.py @@ -18,6 +18,7 @@ from freevideo_engine.desktop_runtime import check_launcher_dependencies, package_data_files, source_files from freevideo_engine.launcher_update import MAC_CHANNEL, build_identity from scripts.build_windows import build_info, stamp_source +from scripts.source_build import spec_hook from freevideo_engine.release_notes import markdown from scripts.build_launcher_notices import collect as collect_notices @@ -162,6 +163,7 @@ def main(): hiddenimports=['psutil', 'PySide6.QtQml', 'PySide6.QtQuick', 'PySide6.QtQuickControls2', 'PySide6.QtWidgets', 'freevideo_engine.managed'], excludes=['torch', 'triton', 'numpy', 'transformers', 'tkinter']) +{spec_hook(ROOT, staged, build_file)} pyz = PYZ(a.pure) exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name='FreeVideo', debug=False, strip=False, upx=False, console=False, target_arch='arm64') diff --git a/scripts/build_source_delta.py b/scripts/build_source_delta.py new file mode 100644 index 00000000..0e8368ea --- /dev/null +++ b/scripts/build_source_delta.py @@ -0,0 +1,79 @@ +"""Generate a bounded, adjacent-release source delta from a staged source tree. + +Use the source-base.json emitted by the SAME platform's PyInstaller build. +No signing seed is needed here; sign_update_manifest.py is a separate step. +""" +import argparse +import json +from pathlib import Path +import sys +import zipfile + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) +import freevideo_source_bootstrap as source + + +def inventory(tree): + files = {} + for path in sorted(tree.rglob('*')): + if path.is_symlink(): + raise ValueError('Symlinks are not source files') + if path.is_file(): + name = source.safe_name(path.relative_to(tree).as_posix()) + files[name] = {'bytes': path.stat().st_size, 'sha256': source.digest(path)} + return files + + +def manifest_name(value): + return 'source-%s-%s.json' % (value['build']['product_version'], + value['build'].get('target', 'windows-x86_64')) + + +def generate(previous, tree, current, output): + current = source.manifest(dict(current)) + if inventory(tree) != current['files']: + raise ValueError('Current tree differs from its build inventory') + output.mkdir(parents=True, exist_ok=True) + value = {k: v for k, v in current.items() if k not in ('delta', 'signature')} + if previous is not None: + previous = source.manifest(previous) + changed = sorted(n for n, row in value['files'].items() if previous['files'].get(n) != row) + deleted = sorted(set(previous['files']) - set(value['files'])) + launcher_files = set(current.get('launcher_files', [])) | set(previous.get('launcher_files', [])) + value['launcher_files_changed'] = bool(launcher_files.intersection(changed + deleted)) + name = 'delta-%s-to-%s.zip' % (previous['identity'][:16], value['identity'][:16]) + archive = output / name + with zipfile.ZipFile(archive, 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=6) as zipped: + for relative in changed: + info = zipfile.ZipInfo(relative, date_time=(2020, 1, 1, 0, 0, 0)) + info.create_system = 3 + info.external_attr = 0o100644 << 16 + info.compress_type = zipfile.ZIP_DEFLATED + zipped.writestr(info, (tree / relative).read_bytes()) + value['delta'] = {'from': previous['identity'], 'files': changed, 'deleted': deleted, + 'name': name, 'bytes': archive.stat().st_size, 'sha256': source.digest(archive)} + source.manifest(value) + target = output / manifest_name(value) + data = source.canonical(value) + if len(data) > source.MAX_MANIFEST: + raise ValueError('Source manifest is too large') + target.write_bytes(data) + target.with_suffix('.json.sha256').write_text(source.sha(data) + '\n', encoding='ascii') + return target + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--previous', type=Path, help='Previous release source manifest; omit for initial baseline') + parser.add_argument('--tree', type=Path, required=True, help='The build output engine-source directory') + parser.add_argument('--current', type=Path, required=True, help='The build output source-base.json') + parser.add_argument('--out', type=Path, required=True) + args = parser.parse_args() + target = generate(source.read_manifest(args.previous) if args.previous else None, + args.tree, source.read_manifest(args.current), args.out) + print(json.dumps({'manifest': str(target)})) + + +if __name__ == '__main__': + main() diff --git a/scripts/build_windows.py b/scripts/build_windows.py index bfb5d6f7..27638d08 100644 --- a/scripts/build_windows.py +++ b/scripts/build_windows.py @@ -116,8 +116,10 @@ def build_pyinstaller(command, out, *, onefolder): options += ['--runtime-hook', str(ROOT / 'scripts/pyi_rth_bundle_data.py')] subprocess.run([sys.executable, '-m', 'PyInstaller.utils.cliutils.makespec', *options], cwd=ROOT, check=True) spec = out / 'FreeVideo.spec' + from scripts.source_build import spec_hook + injection = spec_hook(ROOT, out / 'engine-source', out / 'launcher-build.json') if not onefolder: - injection = f''' + pruning = f''' import sys sys.path.insert(0, {str(ROOT)!r}) from scripts.prune_qt_bundle import prune_qt_toc, write_prune_report @@ -126,13 +128,15 @@ def build_pyinstaller(command, out, *, onefolder): a.datas, removed_data = prune_qt_toc(a.datas) write_prune_report({str(out)!r}, dict(files=removed_binaries['files'] + removed_data['files'], bytes=removed_binaries['bytes'] + removed_data['bytes'])) +''' + injection = pruning + injection + f''' a.datas, deduplicated = deduplicate_toc(a.datas, {str(out)!r}) write_data_report({str(out)!r}, deduplicated) ''' - text = spec.read_text(encoding='utf-8') - if text.count('pyz = PYZ(') != 1: - raise ValueError('Unexpected PyInstaller spec layout') - spec.write_text(text.replace('pyz = PYZ(', injection + '\npyz = PYZ('), encoding='utf-8') + text = spec.read_text(encoding='utf-8') + if text.count('pyz = PYZ(') != 1: + raise ValueError('Unexpected PyInstaller spec layout') + spec.write_text(text.replace('pyz = PYZ(', injection + '\npyz = PYZ('), encoding='utf-8') subprocess.run([sys.executable, '-m', 'PyInstaller', '--noconfirm', '--distpath', str(out / 'dist'), '--workpath', str(out / 'build'), str(spec)], cwd=ROOT, check=True) diff --git a/scripts/editor_test_hooks.mjs b/scripts/editor_test_hooks.mjs new file mode 100644 index 00000000..261c1298 --- /dev/null +++ b/scripts/editor_test_hooks.mjs @@ -0,0 +1,6 @@ +// ComfyUI host-module doubles shared by the synchronous and Node 22.11 loaders. +export function resolve(specifier, context, nextResolve) { + if (specifier === '../../scripts/app.js') return {url: 'data:text/javascript,export const app={graph:{_nodes:[]}};', shortCircuit: true}; + if (specifier === '../../scripts/api.js') return {url: 'data:text/javascript,export const api={};', shortCircuit: true}; + return nextResolve(specifier, context); +} diff --git a/scripts/linux_app.py b/scripts/linux_app.py index 5bb69979..158413bb 100644 --- a/scripts/linux_app.py +++ b/scripts/linux_app.py @@ -1,4 +1,7 @@ """PyInstaller entry point for the Linux AppImage; model modules stay out of the launcher.""" +import freevideo_source_bootstrap +freevideo_source_bootstrap.activate() + from freevideo_engine.modern_launcher import main if __name__ == '__main__': diff --git a/scripts/macos_app.py b/scripts/macos_app.py index 7fafd746..8d2ad33d 100644 --- a/scripts/macos_app.py +++ b/scripts/macos_app.py @@ -1,4 +1,7 @@ """Native Apple Silicon application; the interface is shared with Windows.""" +import freevideo_source_bootstrap +freevideo_source_bootstrap.activate() + from freevideo_engine.modern_launcher import main if __name__ == '__main__': diff --git a/scripts/sign_update_manifest.py b/scripts/sign_update_manifest.py new file mode 100644 index 00000000..bc2954f2 --- /dev/null +++ b/scripts/sign_update_manifest.py @@ -0,0 +1,58 @@ +"""Sign a source manifest using a private 32-byte Ed25519 seed file (raw or hex). + +The private file stays outside the repository. Output replaces only the public +manifest and its SHA-256 sidecar. No secret or private pathname is printed. +""" +import argparse +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import freevideo_ed25519 as ed25519 +import freevideo_source_bootstrap as source + +DOMAIN = b'FreeVideo source manifest v1\x00' + + +def signing_bytes(value): + return DOMAIN + source.canonical({k: v for k, v in value.items() if k != 'signature'}) + + +def sign_manifest(path, private_key): + value = source.read_manifest(path) + raw = private_key.read_bytes() + seed = raw if len(raw) == 32 else bytes.fromhex(raw.decode('ascii').strip()) + value['signature'] = ed25519.sign(seed, signing_bytes(value)).hex() + data = source.canonical(value) + path.write_bytes(data) + path.with_suffix('.json.sha256').write_text(source.sha(data) + '\n', encoding='ascii') + + +UPDATE_DOMAIN = b'FreeVideo update manifest v1\x00' + + +def sign_update_manifest(path, private_key): + """Write .sig (hex) beside an update-*.json; the manifest stays byte-identical.""" + raw = private_key.read_bytes() + seed = raw if len(raw) == 32 else bytes.fromhex(raw.decode('ascii').strip()) + signature = ed25519.sign(seed, UPDATE_DOMAIN + path.read_bytes()).hex() + path.with_name(path.name + '.sig').write_text(signature + '\n', encoding='ascii') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--manifest', type=Path, required=True) + parser.add_argument('--private-key', type=Path, required=True) + parser.add_argument('--update-manifest', action='store_true', + help='the manifest is an update-.json: write a detached .sig') + args = parser.parse_args() + if args.update_manifest: + sign_update_manifest(args.manifest, args.private_key) + print('Wrote the detached signature for the update manifest.') + return + sign_manifest(args.manifest, args.private_key) + print('Signed source manifest and refreshed SHA-256 sidecar.') + + +if __name__ == '__main__': + main() diff --git a/scripts/source_build.py b/scripts/source_build.py new file mode 100644 index 00000000..de860936 --- /dev/null +++ b/scripts/source_build.py @@ -0,0 +1,120 @@ +"""Bind source deltas to the actual PyInstaller Analysis, before packaging.""" +import importlib.metadata +import json +from pathlib import Path +import platform +import shutil +import subprocess +import sys +import zipfile + +from scripts.build_source_delta import inventory +import freevideo_source_bootstrap as source + + +def runtime_digest(path): + if path.name != 'base_library.zip': + return source.digest(path) + # PyInstaller iterates a module set when writing this archive. Entry order + # is not deterministic; every byte of every importable pyc still is hashed. + with zipfile.ZipFile(path) as archive: + names = archive.namelist() + if len(names) != len(set(names)): + raise ValueError('Duplicate standard-library ZIP entries') + return source.sha(source.canonical({name: source.sha(archive.read(name)) for name in sorted(names)})) + + +def finalize(analysis, root, staged, build_file, extra_runtime=None): + root, staged, build_file = Path(root), Path(staged), Path(build_file) + build = json.loads(build_file.read_text(encoding='utf-8')) + # Nightly has mutable tags/identities and intentionally stays on full updates. + if build.get('track', 'stable') != 'stable': + return + runtime, launcher_files = {}, set() + # Source resources must follow __file__ when the package is loaded from disk. + for name, filename, kind in list(analysis.datas): + name = name.replace('\\', '/') + if name.startswith('freevideo_engine/') and Path(name).suffix in source.EXTENSIONS: + output = staged / name + if not output.exists(): + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(filename, output) + launcher_files.add(name) + files = inventory(staged) + for group in ('pure', 'binaries', 'datas', 'scripts'): + for name, filename, kind in getattr(analysis, group): + normalized = name.replace('\\', '/') + if normalized.startswith('engine-source/') or normalized in ('launcher-build.json', 'source-base.json'): + continue + if group == 'pure' and Path(filename).is_relative_to(root): + local = Path(filename).relative_to(root).as_posix() + if local in files and source.digest(Path(filename)) != files[local]['sha256']: + raise ValueError('Source changed during PyInstaller analysis; rebuild from a clean snapshot') + if group == 'pure' and (name == 'freevideo_engine' or name.startswith('freevideo_engine.')): + relative = Path(filename).relative_to(root).as_posix() + if relative not in files: + raise ValueError('Frozen application module missing from engine-source') + launcher_files.add(relative) + continue + if group == 'datas' and normalized in files: + continue + path = Path(filename) + if path.is_file(): + runtime[group + ':' + normalized] = runtime_digest(path) + else: + # Symlink TOCs contain their link target, not a source file. + runtime[group + ':' + normalized] = str(filename) + for name, filename in (extra_runtime or {}).items(): + runtime['packaging:' + name] = source.digest(Path(filename)) + # Include the PyInstaller bootloader and build policy, not just package versions. + import PyInstaller + for path in sorted((Path(PyInstaller.__file__).parent / 'bootloader').rglob('*')): + if path.is_file(): + runtime['bootloader:' + path.relative_to(Path(PyInstaller.__file__).parent).as_posix()] = source.digest(path) + for name in ('scripts/source_build.py', 'scripts/build_windows.py', 'scripts/build_macos.py', 'scripts/build_linux.py'): + runtime[name] = source.digest(root / name) + for name in ('scripts/prune_qt_bundle.py', 'scripts/deduplicate_bundle_data.py', 'scripts/pyi_rth_bundle_data.py'): + if (root / name).is_file(): + runtime[name] = source.digest(root / name) + descriptor = {'python': sys.version, 'platform': platform.system(), 'machine': platform.machine(), + 'target': build.get('target', 'windows-x86_64'), 'packaging': build.get('packaging'), + 'pyinstaller': importlib.metadata.version('pyinstaller'), + 'optimization': getattr(analysis, 'optimize', 0), 'files': runtime} + base = source.sha(source.canonical(descriptor)) + value = {'schema': 1, 'base': base, 'build': build, 'files': files, 'gui': True, + 'launcher_files': sorted(launcher_files), 'launcher_files_changed': False} + value['identity'] = source.source_identity(value) + source.manifest(value) + output = build_file.with_name('source-base.json') + output.write_bytes(source.canonical(value)) + build_file.with_name('source-runtime.json').write_bytes(source.canonical(descriptor)) + analysis.datas.append(('source-base.json', str(output), 'DATA')) + present = {name.replace('\\', '/') for name, _, _ in analysis.datas} + for name in files: + destination = 'engine-source/' + name + if destination not in present: + analysis.datas.append((destination, str(staged / name), 'DATA')) + + +def spec_hook(root, staged, build_file, extra_runtime=None): + return (f'\nimport sys\nsys.path.insert(0, {str(root)!r})\n' + 'from scripts.source_build import finalize as finalize_source\n' + f'finalize_source(a, {str(root)!r}, {str(staged)!r}, {str(build_file)!r}, {extra_runtime!r})\n') + + +def run_pyinstaller(command, root, staged, build_file, extra_runtime=None): + """Keep the established CLI options, inserting a hook after Analysis.""" + from PyInstaller.building.makespec import main as make_spec + # Let PyInstaller's parser consume exactly the existing CLI arguments. + from PyInstaller.__main__ import generate_parser + parser = generate_parser() + options = vars(parser.parse_args(command[3:])) + filenames = options.pop('filenames') + spec_path = Path(make_spec(filenames, **options)) + text = spec_path.read_text(encoding='utf-8') + marker = '\npyz = PYZ(' + if marker not in text: + raise ValueError('Unknown PyInstaller spec layout') + spec_path.write_text(text.replace(marker, spec_hook(root, staged, build_file, extra_runtime) + marker, 1), encoding='utf-8') + subprocess.run([*command[:3], '--noconfirm', '--distpath', options['distpath'], + '--workpath', options['workpath'], str(spec_path)], cwd=root, check=True) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index dc1ee8a7..8a289573 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -1,6 +1,10 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import test from 'node:test'; +import * as modules from 'node:module'; +import {resolve} from './editor_test_hooks.mjs'; +if (modules.registerHooks) modules.registerHooks({resolve}); +else modules.register('./editor_test_hooks.mjs', import.meta.url); import {uploadMedia} from '../web/media_upload.js'; import {outputDownloadURL} from '../web/output_download.js'; import {regenerateResult, upscaleResult, createSecondPassQueue, enableNode} from '../web/studio_queue.js'; @@ -14,7 +18,14 @@ class Element { this.classList = {toggle() {}, add() {}}; this.style = {setProperty(name, value) { this[name] = value; }}; this.offsetHeight = 66; } - append(...children) { this.children.push(...children); } + append(...children) { for (const child of children) { child.parentNode = this; this.children.push(child); } } + after(...children) { + if (!this.parentNode) return; + const parent = this.parentNode, index = parent.children.indexOf(this); + for (const child of children) { child.remove?.(); child.parentNode = parent; } + parent.children.splice(index + 1, 0, ...children); + } + remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; } prepend(...children) { this.children.unshift(...children); } replaceChildren(...children) { this.children = [...children]; } setAttribute(name, value) { this.attributes[name] = value; } @@ -24,23 +35,27 @@ class Element { getClientRects() { return this.unrendered ? [] : [{}]; } get childElementCount() { return this.children.length; } click() { this.clicked = true; } + showModal() { this.open = true; } + close() { this.open = false; this.onclose?.(); } } test('main browser entry registers both views and preserves node hooks', async t => { const extensions = [], opened = [], installed = [], events = new EventTarget(); + const issueURLs = []; let refreshed = 0, compatibilityChecks = 0, updateChecks = 0, doneNotifyStarts = 0, rememberedView = null; const openingEvents = []; let restoreReleases = 0; let launchReady = null; const opening = {setName: name => openingEvents.push(['name', name]), cover: () => openingEvents.push(['cover']), finish: () => openingEvents.push(['finish'])}; const casts = []; - let progressView, progressOptions, realProgressFactory; + let progressView, progressOptions, realProgressFactory, studioView; const app = { registerExtension(extension) { extensions.push(extension); }, graph: {extra: {freevideo_studio: true}, _nodes: [], getNodeById() {}}, }; const api = Object.assign(events, {apiURL: value => value}); - const document = {head: new Element('head'), createElement: tag => new Element(tag), + const document = {head: new Element('head'), body: new Element('body'), createElement: tag => new Element(tag), + createElementNS: (namespaceURI, tag) => Object.assign(new Element(tag), {namespaceURI}), createRange: () => ({createContextualFragment: () => new Element('span')})}; const dependencies = { app, api, uploadMedia, outputDownloadURL, regenerateResult, upscaleResult, createSecondPassQueue, enableNode, referenceInputs, mediaRows, expandCast, cleanOptionalPrompt, cleanOptionalWidgets, optionalInputValue, @@ -52,7 +67,7 @@ test('main browser entry registers both views and preserves node hooks', async t createErrorPanel: () => ({element: new Element('error'), show() {}, clear() {}}), createErrorReport: () => ({}), adviceFor: () => ({advice: {}}), errorText: value => String(value), - openStudio: node => opened.push(node), + openStudio: node => { opened.push(node); return studioView; }, loraPanel: () => () => {}, loraWarning: () => 'LoRA warning', promptGuide: () => new Element('guide'), createGenerationProgress: (_t, _now, options) => { if (realProgressFactory) { @@ -85,7 +100,7 @@ test('main browser entry registers both views and preserves node hooks', async t }; const previous = new Map(); for (const [name, value] of Object.entries({ - document, navigator: {language: 'en'}, window: events, addEventListener: (type, fn, options) => events.addEventListener(type, fn, options), + document, navigator: {language: 'en'}, window: events, open: url => issueURLs.push(new URL(url)), addEventListener: (type, fn, options) => events.addEventListener(type, fn, options), getComputedStyle: element => ({marginTop: '8px', marginBottom: '8px', display: element.hidden ? 'none' : 'block', ...element.style}), CustomEvent: class extends Event { constructor(name, options) { super(name); this.detail = options?.detail; } }, requestAnimationFrame: callback => callback(), __freevideoEntryTest: dependencies, @@ -97,6 +112,7 @@ test('main browser entry registers both views and preserves node hooks', async t let source = await readFile(new URL('../web/freevideo.js', import.meta.url), 'utf8'); source = source.replace("import { beginWorkspaceRestore, finishWorkspaceRestore, waitForLauncherRestore } from './workspace_restore.js';", 'const {beginWorkspaceRestore,finishWorkspaceRestore,waitForLauncherRestore} = globalThis.__freevideoEntryTest;'); source = source.replace("import { openEditorView } from './editor_loader.js';", 'const openEditorView = (...args) => globalThis.__freevideoEntryTest.openEditorView?.(...args);'); + source = source.replace("from './issue_button.js'", `from '${new URL('../web/issue_button.js', import.meta.url).href}'`); source = source .replace("import { createErrorPanel, errorText, createErrorReport, adviceFor } from './error_panel.js';", 'const {createErrorPanel,errorText,createErrorReport,adviceFor} = globalThis.__freevideoEntryTest;') .replace('import { app } from "../../scripts/app.js";', 'const {app} = globalThis.__freevideoEntryTest;') @@ -258,6 +274,18 @@ test('main browser entry registers both views and preserves node hooks', async t app.graph._nodes = nodes; await entry.function(); assert.equal(workflows, 1); assert.equal(opened.length, before + 2); + app.graph._nodes = []; + dependencies.openFreeVideoWorkflow = async () => { throw new Error('workflow fixture unavailable'); }; + const warn = console.warn; console.warn = () => {}; + try { await entry.function(); } finally { console.warn = warn; } + const menuFailure = document.body.children.find(child => child.className?.includes('fv-menu-failure')); + assert.ok(menuFailure.open); + const menuIssue = menuFailure.children[1].children[0]; + assert.ok(menuIssue.className.includes('fv-issue-button')); assert.notEqual(menuIssue.hidden, true); + await menuIssue.onclick(); + assert.match(issueURLs.at(-1).searchParams.get('feedback'), /Error: Could not complete\. Try again\./); + app.graph._nodes = nodes; await entry.function(); + assert.equal(document.body.children.includes(menuFailure), false, 'successful opening clears the failure and its report button'); rememberedView = 'editor'; const restored = []; let finishLaunch, finishEditor; @@ -304,10 +332,19 @@ test('main browser entry registers both views and preserves node hooks', async t app.graph._nodes = nodes; }); // The lazy loader owns failed imports: it notifies, reports through onFailure and resolves null. + const studioStatus = new Element('div'); studioView = {querySelector: () => studioStatus}; dependencies.openEditorView = async (node, options) => { options.onFailure(new Error('editor unavailable')); return null; }; - extension.afterConfigureGraph(); await Promise.resolve(); + extension.afterConfigureGraph(); for (let i = 0; i < 10; i++) await Promise.resolve(); assert.ok(window.__freevideoHealth.some(row => row.where === 'editor')); assert.equal(opened.at(-1), node, 'editor failure leaves the restored studio available'); + const editorIssue = studioStatus.children.find(child => child.className?.includes('fv-issue-button')); + assert.ok(editorIssue); assert.notEqual(editorIssue.hidden, true); + await editorIssue.onclick(); + assert.match(issueURLs.at(-1).searchParams.get('feedback'), /Where: Open editor\nError: Could not complete\. Try again\./); + studioStatus.replaceChildren(); dependencies.openEditorView = async () => ({ready: Promise.resolve()}); + extension.afterConfigureGraph(); for (let i = 0; i < 10; i++) await Promise.resolve(); + assert.equal(studioStatus.children.length, 0, 'successful editor opening adds no issue button'); + studioView = null; rememberedView = 'nodes'; const count = opened.length; extension.afterConfigureGraph(); assert.equal(opened.length, count, 'remembered Nodes does not open either dialog'); diff --git a/scripts/windows_app.py b/scripts/windows_app.py index f86ea7c2..5610bad5 100644 --- a/scripts/windows_app.py +++ b/scripts/windows_app.py @@ -1,4 +1,7 @@ """PyInstaller entry point; model modules stay out of the GUI executable.""" +import freevideo_source_bootstrap +freevideo_source_bootstrap.activate() + from freevideo_engine.modern_launcher import main if __name__ == '__main__': diff --git a/web/agent_activity.js b/web/agent_activity.js index 4c60ddf9..37c75079 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -1,4 +1,5 @@ import {retryAgentPost} from './agent_session.js'; +import {issueButton} from './issue_button.js'; // What agents connected to this FreeVideo are doing. Their requests share the // studio's queue, so people can see them, follow progress and stop them. const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./agent_activity.css', import.meta.url).href; document.head.append(css); @@ -82,6 +83,8 @@ function row(job, t, api, refresh, open, cancellationErrors) { const details = el('p', 'fv-agent-meta', meta.join(' · ')); if (reason && job.error?.message) details.title = job.error.message; foot.append(details); + const issue = issueButton(t, () => ({where: ['Agent activity', 'Agent 任务'], message: details.textContent})); + issue.hidden = !cancellationErrors.has(job.id); foot.append(issue); if (['waiting', 'queued', 'running'].includes(job.status)) { const stop = el('button', 'fv-agent-stop', t('Stop', '停止')); stop.type = 'button'; stop.setAttribute('aria-label', t('Stop ', '停止 ') + title(job, t)); @@ -93,7 +96,8 @@ function row(job, t, api, refresh, open, cancellationErrors) { const message = t('Could not stop. Click again.', '没能停止,请再点一次。'); details.textContent = message; cancellationErrors.set(job.id, message); - } else cancellationErrors.delete(job.id); + issue.hidden = false; + } else { cancellationErrors.delete(job.id); issue.hidden = true; } } finally { refresh(); } stop.disabled = false; stop.textContent = t('Stop', '停止'); }; diff --git a/web/agent_connect.js b/web/agent_connect.js index 085c7133..1bee48ba 100644 --- a/web/agent_connect.js +++ b/web/agent_connect.js @@ -1,3 +1,4 @@ +import {issueButton} from './issue_button.js'; function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } // The one place a page says whether an agent is connected: its name while it is, never a client id. @@ -41,6 +42,7 @@ export function agentConnectCard(t, api, shown = () => true) { const actions = el('div', 'fv-agent-connect-actions'); const button = el('button', 'fv-primary'); button.type = 'button'; button.disabled = true; const label = el('span'); button.append(icon('copy'), label); + let issue = null; const arrow = el('span', 'fv-agent-connect-arrow', '→'); arrow.setAttribute('aria-hidden', 'true'); actions.append(button, arrow, el('span', 'fv-agent-connect-hint', t('Paste to your agent', '粘贴给 Agent'))); const sentence = el('p', 'fv-agent-sentence'); sentence.hidden = true; @@ -52,6 +54,9 @@ export function agentConnectCard(t, api, shown = () => true) { const active = () => body.isConnected && shown(); const render = () => { label.textContent = !text && failures >= 3 ? t('Could not get address. Retry', '获取失败,重试') : feedback ? t('Copied', '已复制') : t('Copy', '复制'); + if (!text && failures >= 3) { + if (!issue) { issue = issueButton(t, () => ({where: ['Agent connection', 'Agent 连接'], message: label.textContent})); button.after(issue); } + } else if (issue) { issue.remove(); issue = null; } button.disabled = !text && failures < 3; button.title = !text && failures < 3 ? t('Getting the connection address…', '正在获取连接地址…') : ''; status.hidden = !!text; status.textContent = !text && failures < 3 ? t('Getting the connection address…', '正在获取连接地址…') : ''; diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index dab04daf..8e5edad3 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -125,13 +125,14 @@ .fv-studio .fv-chat-down{position:absolute;right:max(40px,calc(50% - 360px));bottom:96px;width:34px;height:34px;min-height:0;padding:0;border-radius:50%;background:var(--fv-raised);box-shadow:0 6px 18px #0008} .fv-chat svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-linecap:round;stroke-linejoin:round} .fv-chat-input{box-sizing:border-box;justify-self:center;width:100%;max-width:720px;min-height:54px;display:grid;grid-template-columns:minmax(0,1fr) auto auto auto;gap:10px;align-items:end;border:1px solid var(--fv-border);background:#0e151e;border-radius:14px;padding:8px 8px 8px 16px;transition:border-color var(--fv-quick),box-shadow var(--fv-quick)} -.fv-chat-notice{justify-self:center;width:100%;max-width:720px;margin:6px 0;font-size:14px;color:var(--fv-ink)} +.fv-chat-notice-row{justify-self:center;width:100%;max-width:720px;display:flex;align-items:center;flex-wrap:wrap;gap:4px 12px}.fv-chat-notice-row>.fv-issue-button{margin:6px 0} +.fv-chat-notice{flex:0 1 auto;margin:6px 0;font-size:14px;color:var(--fv-ink)} .fv-chat-preview[data-loading=true] .fv-chat-play::before{border-color:var(--fv-accent);animation:fv-chat-breathe 1s infinite} .fv-chat-job:is([data-mode=failed],[data-mode=stopped]){display:block;width:100%}.fv-chat-job:is([data-mode=failed],[data-mode=stopped]) .fv-chat-actions{margin-top:10px;flex-wrap:wrap} .fv-studio .fv-chat-job:is([data-mode=failed],[data-mode=stopped]) .fv-result-links{justify-content:flex-start} .fv-chat-job .fv-report-tip{max-width:100%} .fv-chat-main{grid-template-rows:auto minmax(0,1fr) auto auto} -.fv-chat-tools{grid-row:1}.fv-chat-scroll{grid-row:2}.fv-chat-notice{grid-row:3}.fv-chat-input{grid-row:4} +.fv-chat-tools{grid-row:1}.fv-chat-scroll{grid-row:2}.fv-chat-notice-row{grid-row:3}.fv-chat-input{grid-row:4} .fv-chat-input:focus-within{border-color:var(--fv-accent-dim);box-shadow:0 0 0 3px color-mix(in srgb,var(--fv-accent) 13%,transparent)} .fv-studio.fv-autopilot .fv-chat-input textarea,.fv-chat-input textarea{box-sizing:border-box;width:100%;height:36px;min-height:36px;max-height:none;padding:7px 0;border:0;border-radius:0;background:none;color:var(--fv-ink);font-size:14.5px;line-height:22px;resize:none;outline:none;overflow-y:auto} .fv-chat-input textarea::placeholder{color:var(--fv-disabled)} @@ -164,6 +165,9 @@ /* Request errors use the card's existing actions and retry button. */ .fv-chat-error{color:var(--fv-danger);font-size:13px;line-height:1.5;overflow-wrap:anywhere;margin:0} .fv-chat-new-error{padding:0 var(--fv-chats-pad) 6px} +.fv-chat-delete-error{padding:0 var(--fv-chats-pad) 6px} +.fv-chat-delete-error p{margin:0 0 4px} +.fv-chat-delete-actions{display:flex;flex-wrap:wrap;gap:4px} .fv-chat-me>.fv-chat-actions{margin-top:6px;flex-wrap:wrap;justify-content:flex-end} .fv-chat-me .fv-chat-error{flex:1;min-width:0} .fv-chat-keep{white-space:nowrap} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 75f53b55..78e5a80e 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -8,10 +8,12 @@ import { estimatedFraction, remainingAbove, remainingShown, createDiagnosticRepo import {createErrorReport} from './error_panel.js'; import {canDeleteConversation, conversationMenu, emptyConversations, hasConversations, mergeConversations, removeConversation, runningElsewhere, selectConversation} from './autopilot_conversations.js'; import {renderImageFailure} from './image_generation.js'; +import {issueButton} from './issue_button.js'; import {about} from './request_assets.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./autopilot_chat.css', import.meta.url).href; document.head.append(css); +const CHAT_ISSUE = ['Chat', '对话']; function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } function glyph(path, width = 2.2) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); @@ -291,27 +293,39 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () let sceneRows = null, entryScene = initialScene || null, sceneWaiting = false; function toggleScenes(open = sceneMenu.hidden) { sceneMenu.hidden = !open; scenesButton.setAttribute('aria-expanded', String(open)); - if (!open || sceneRows) return; + if (!open) return; + // One ready-made scene needs no choosing: the button starts it. + if (sceneRows?.length === 1) { toggleScenes(false); void runScene(sceneRows[0].id); return; } + if (sceneRows) return; sceneRows = []; void sceneList(api).then(rows => { - sceneRows = rows; sceneMenu.replaceChildren(...rows.map(row => { + sceneRows = rows; + if (rows.length === 1 && !sceneMenu.hidden) { toggleScenes(false); void runScene(rows[0].id); return; } + sceneMenu.replaceChildren(...rows.map(row => { const item = press('fv-chat-scene-item', row.name, () => { toggleScenes(false); void runScene(row.id); }); item.setAttribute('role', 'menuitem'); return item; })); }).catch(() => { sceneRows = null; toggleScenes(false); say(t(...SCENE_TEXT.failed) + t('Connection lost. Try again.', '连接中断,请重试。')); }); } async function runScene(preset, session = state.session) { - if (!session || stopped) return; + if (!session || stopped || sceneWaiting) return; say(t(...SCENE_TEXT.preparing)); sceneWaiting = true; try { const id = await startScene(api, preset, session); - const result = id ? await watchScene(api, id, {stopped: () => stopped}) : {status: 'ready'}; - if (result.status === 'failed') { sceneWaiting = false; say(t(...SCENE_TEXT.failed) + (result.error || '')); } - else if (result.status === 'timeout') { sceneWaiting = false; say(''); } + const result = id ? await watchScene(api, id, {stopped: () => stopped, onState: value => { if (value.status === 'rendering') say(t(...SCENE_TEXT.rendering)); }}) : {status: 'ready'}; + if (result.status === 'failed') { + sceneWaiting = false; const why = (result.error || '').replace(/[.。]+$/, ''); + say(why ? t(...SCENE_TEXT.failed) + why + t(...SCENE_TEXT.retry) : t(...SCENE_TEXT.bare)); + } + else if (result.status === 'timeout') { + sceneWaiting = false; + say(t(...(result.last === 'rendering' ? SCENE_TEXT.timeout : result.page_open ? SCENE_TEXT.notStarted : SCENE_TEXT.noPage))); + } } catch (failure) { sceneWaiting = false; say(t(...SCENE_TEXT.failed) + reason(failure)); } } form.append(input, scenesButton, whiteboxButton, sendButton); scroller.append(log); main.append(scroller, down, sceneMenu, form); element.append(main); - const notice = el('p', 'fv-chat-notice'); notice.hidden = true; notice.setAttribute('role', 'status'); main.insertBefore(notice, form); + const notice = el('p', 'fv-chat-notice'); notice.hidden = true; notice.setAttribute('role', 'status'); + const noticeRow = el('div', 'fv-chat-notice-row'); noticeRow.append(notice); main.insertBefore(noticeRow, form); // What the next video would download first, the studio's line, above the box the request is typed in. if (hint) main.insertBefore(hint.element, form); // A quoted button name stays on one line, so “新对话” never wraps in the middle. @@ -320,7 +334,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () node.textContent = text; if (parts.length > 1) node.replaceChildren(...parts.flatMap((part, index) => part ? [el('span', index % 2 ? 'fv-chat-keep' : '', part)] : [])); } - function say(text) { quoted(notice, text); notice.hidden = !text; } + // `failure`: the line reports a fault, so the button sits beside it. + const noticeIssue = issueButton(t, () => ({where: CHAT_ISSUE, message: notice.textContent})); noticeIssue.hidden = true; + noticeRow.append(noticeIssue); + function say(text, failure = false) { quoted(notice, text); notice.hidden = !text; noticeIssue.hidden = !text || !failure; } let dismissImage = null; let state = emptyChat(), pending = [], known = false, stopped = false, timer = null, busy = false, failures = 0; @@ -367,12 +384,21 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const newChat = press('fv-chat-new', t('New chat', '新对话'), () => void startChat()); newChat.prepend(glyph('M12 5v14M5 12h14')); newChat.title = t('New chat', '新对话'); const newError = el('p', 'fv-chat-error fv-chat-new-error'); newError.hidden = true; newError.setAttribute('role', 'alert'); + const newIssue = issueButton(t, () => ({where: CHAT_ISSUE, message: newError.textContent})); newIssue.hidden = true; + // Why a deletion did not happen, with what can be done next; never left silent. + const deleteError = el('div', 'fv-chat-error fv-chat-delete-error'); deleteError.hidden = true; deleteError.setAttribute('role', 'alert'); + function deleteFailed(session, error, stop = false, report = true) { + const text = el('p', null, error), actions = el('div', 'fv-chat-delete-actions'); + if (stop) actions.append(press('fv-chat-menu-item', t('Stop and delete', '停止并删除'), () => { deleteError.hidden = true; void deleteChat(session, true, true); })); + if (report) actions.append(issueButton(t, () => ({where: CHAT_ISSUE, message: text.textContent}))); + deleteError.replaceChildren(text, actions); deleteError.hidden = false; + } const chats = press('fv-chat-chats', null, () => drawer(chats.getAttribute('aria-expanded') !== 'true')); const chatsDot = el('i', 'fv-chat-running'); chatsDot.setAttribute('aria-hidden', 'true'); chatsDot.hidden = true; chats.append(glyph('M4 5h16v12H9l-5 4V5M8 9h8M8 13h5'), chatsDot); chats.title = t('Chats', '对话'); chats.setAttribute('aria-label', chats.title); chats.setAttribute('aria-controls', sidebar.id); chats.setAttribute('aria-expanded', 'false'); - tools.hidden = true; tools.append(chats); main.prepend(tools); sidebar.append(newChat, newError, list); element.prepend(sidebar); + tools.hidden = true; tools.append(chats); main.prepend(tools); sidebar.append(newChat, newError, newIssue, deleteError, list); element.prepend(sidebar); const listNodes = new Map(), narrow = () => window.matchMedia('(max-width: 720px)').matches; let menuState = null, changingMenu = false; const deleting = new Set(), deleted = new Set(); @@ -502,15 +528,16 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () updateMenu(); if (lostFocus && !menuState) (listNodes.get(previous.session)?.button || (visible ? newChat : input)).focus({preventScroll: true}); } - async function deleteChat(session) { - if (!menuState || deleting.has(session) || menuState.session !== session || menuState.stage !== 'confirm' || !active()) return; + async function deleteChat(session, stop = false, again = false) { + if (!again && (!menuState || deleting.has(session) || menuState.session !== session || menuState.stage !== 'confirm') || deleting.has(session) || !active()) return; + deleteError.hidden = true; const ids = conversations.conversations.map(row => row.id), index = ids.indexOf(session); const neighbor = ids[index + 1] || ids[index - 1]; deleting.add(session); // A disabled focused button can lose native focus. Keep it on the origin while awaiting the request. menuOrigin()?.focus({preventScroll: true}); updateMenu(); try { - const response = await agentPost(api, '/freevideo/agent/chat/delete', {session}); + const response = await agentPost(api, '/freevideo/agent/chat/delete', stop ? {session, stop: true} : {session}); if (stopped) return; const data = await response.json().catch(() => ({})); if (stopped) return; @@ -531,9 +558,13 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } else if (response.status === 409) { if (menuState?.session === session) closeMenu(); if (data.error?.code === 'not_connected') { state = {...state, agent: {connected: false, name: null}}; render(); report(); } - else void readConversations(true); - } - } catch { /* Keep the row available for another try, without interrupting the conversation. */ } + else { + void readConversations(true); + if (stop) deleteFailed(session, t('Still stopping. Try deleting again in a moment.', '正在停止,请稍后再删除。'), false, false); + else deleteFailed(session, t('This chat still has work in progress, so it was not deleted.', '这段对话里还有在生成的内容,没有删除。'), true); + } + } else deleteFailed(session, t('Could not delete: ', '没有删除成功:') + (data.error?.message || data.error?.code || `HTTP ${response.status}`)); + } catch (error) { if (!stopped) deleteFailed(session, t('Could not delete: ', '没有删除成功:') + reason(error)); } finally { deleting.delete(session); if (menuState?.session === session) closeMenu(); @@ -541,7 +572,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } } function chooseChat(session, focus = false) { - closeMenu(); say(''); + closeMenu(); say(''); deleteError.hidden = true; showDraft(session); rememberSelection(session); selectedSession = session; selection++; pinned = true; away = null; conversations = selectConversation(conversations, session); renderConversations(); @@ -554,7 +585,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () async function startChat(from = null) { closeMenu(); if (creating || !active() || conversations.supported !== true) return; - creating = true; newError.hidden = true; renderConversations(); render(); + creating = true; newError.hidden = newIssue.hidden = true; renderConversations(); render(); try { const response = await agentPost(api, '/freevideo/agent/chat/new', {}); if (stopped) return; @@ -565,7 +596,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } else throw await requestError(response); } catch (error) { if (!stopped) { - newError.textContent = t('Could not start a new chat: ', '没有新建成功:') + reason(error); newError.hidden = false; + newError.textContent = t('Could not start a new chat: ', '没有新建成功:') + reason(error); newError.hidden = newIssue.hidden = false; if (from) from.error = newError.textContent; } } @@ -664,7 +695,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () ids.forEach(id => stopping.add(id)); render(); report(); const results = await Promise.all(ids.map(id => retryAgentPost(api, '/freevideo/agent/activity/cancel', {id}))); results.forEach((response, index) => { if (!response?.ok) stopping.delete(ids[index]); }); - if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '没能停止,请再点一次。')); + if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '没能停止,请再点一次。'), true); else say(''); if (!stopped) { render(); report(); void poll(); } } @@ -678,7 +709,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // ---- one entry of the conversation each ---- function message(role) { const item = el('li', role === 'user' ? 'fv-chat-me' : 'fv-chat-ai'), text = el('p'); item.append(text); - let status = null, error = null, retry = null; + let status = null, error = null, retry = null, report = null; return {element: item, update(entry) { text.textContent = entry.text; if (role !== 'user') return; @@ -693,9 +724,12 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (entry.errorCode === 'session_full') { void startChat(original || entry); return; } if (original) void send(original.text, original); }); - status.append(error, retry); item.append(status); + report = issueButton(t, () => ({where: CHAT_ISSUE, message: error.textContent})); + status.append(error, retry, report); item.append(status); } quoted(error, entry.error || ''); error.hidden = !entry.error; + // Waiting for the agent to come back is not a fault. + report.hidden = !entry.error || !!entry.automatic; const full = entry.errorCode === 'session_full'; retry.textContent = full ? t('New chat', '新对话') : t('Retry', '重试'); retry.disabled = entry.sending || (full ? creating : !sendingAllowed()); @@ -984,7 +1018,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const question = el('p', null, entry.question); const estimate = el('p', 'fv-chat-meta', entry.estimate_seconds > 0 ? about(entry.estimate_seconds, t) : ''); estimate.hidden = !estimate.textContent; item.replaceChildren(question, estimate, buttons); - if (error) { const notice = el('p', 'fv-chat-error', error); notice.setAttribute('role', 'alert'); item.append(notice); } + if (error) { + const notice = el('p', 'fv-chat-error', error); notice.setAttribute('role', 'alert'); + item.append(notice, issueButton(t, () => ({where: CHAT_ISSUE, message: error}))); + } }}; } // Before anything is said: what the agent can do, and sentences that send themselves when clicked. @@ -1155,7 +1192,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (entry.session === state.session) { if (waiting && error?.code === 'not_connected') state = {...state, agent: {connected: false, name: null}}; if (waiting && error?.code === 'not_listening') state = {...state, agent: {...state.agent, listening: false}}; - say(entry.errorCode === 'session_full' ? '' : explanation); + say(entry.errorCode === 'session_full' ? '' : explanation); // the failed bubble carries the report button } return false; } finally { diff --git a/web/cast_generate.js b/web/cast_generate.js index c9597873..0d861dc1 100644 --- a/web/cast_generate.js +++ b/web/cast_generate.js @@ -31,7 +31,7 @@ export function createCastGenerator({client, onChange = () => {}, schedule = set async function runOne(index, alive) { const view = plan[index], front = state.pictures[0].file; const row = await client.generate({prompt: view.prompt, width: view.width, height: view.height, seed, - images: view.from_front ? [front] : []}); + images: view.from_front ? [front] : [], ...(view.view === 'back' ? {cast_view: 'back'} : {})}); if (!row.job) throw Object.assign(new Error(row.error || 'generation_failed'), {code: row.error, report_id: row.report_id}); if (!alive()) { await cancelJob(row.job); return null; } job = row.job; diff --git a/web/cast_library.css b/web/cast_library.css index 51a833d1..ccab799f 100644 --- a/web/cast_library.css +++ b/web/cast_library.css @@ -38,6 +38,7 @@ .fv-cast-dialog .fv-cast-bad .fv-cast-choose{border:1px solid var(--fv-danger)} .fv-cast-slot-error{position:absolute;left:1px;right:1px;bottom:1px;padding:4px 3px;border-radius:0 0 var(--fv-r-sm,8px) var(--fv-r-sm,8px);background:var(--fv-bg);color:var(--fv-danger);font-size:12px;line-height:1.4;word-break:keep-all;overflow:hidden;pointer-events:none} .fv-cast-slot-line{display:block} +.fv-cast-slot-error .fv-issue-button{pointer-events:auto;font-size:11px;min-height:24px;padding:2px 5px;max-width:100%} .fv-cast-message{margin-top:12px;color:var(--fv-danger);white-space:nowrap;text-overflow:ellipsis;overflow:hidden} .fv-cast-message.fv-cast-notice{color:var(--fv-muted)} .fv-cast-dialog .fv-cast-delete.fv-cast-confirm{border-color:#c9414f;background:#c9414f;color:#fff} diff --git a/web/cast_library.js b/web/cast_library.js index ead6cb38..65a47f49 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -1,5 +1,7 @@ import {agentGet, agentPost, agentSend} from './agent_session.js'; import {agentInterfaceEnabled} from './agent_connect.js'; +import {issueButton} from './issue_button.js'; +const CAST_ISSUE = ['Character library', '角色库']; // The optional generator owns its UI and job lifecycle; keep its contract here. async function mountGenerator(options) { @@ -119,12 +121,14 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const cancel = button('', text('Cancel', '取消'), () => close()); const save = button('fv-cast-save', text('Save', '保存'), () => saveCard()); actions.append(remove, cancel, save); - dialog.append(top, name, tabs, uploadPane, aiPane, status, actions); tabs.append(uploadTab, aiTab); + const issue = issueButton(text, () => ({where: CAST_ISSUE, message: status.textContent})); issue.hidden = true; + dialog.append(top, name, tabs, uploadPane, aiPane, status, issue, actions); tabs.append(uploadTab, aiTab); // A refresh notice is plain secondary text; only failures are shown as errors. const message = (content, notice = false) => { if (closed) return; status.textContent = content; status.hidden = !content; status.classList.toggle('fv-cast-notice', notice); + issue.hidden = !content || notice; }; const aiBusy = () => aiState.busy || !!generator?.isBusy(); // What is still missing before Save works ('' when nothing is); the button says so on hover and when pressed. @@ -153,9 +157,11 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () function mark(index, content = '') { const {slot, problem} = slotUI[index]; // Clear the line only while it still holds this tile's words, not a later message. - if (markedBelow === index) { markedBelow = -1; if (status.textContent === problem.textContent) message(''); } + if (markedBelow === index) { markedBelow = -1; if (status.textContent === problem.dataset.message) message(''); } + problem.dataset.message = content; // One line per clause, so "这张图片" is never split across lines. problem.replaceChildren(...content.replace(/([,。]|\. )/g, '$1\n').split('\n').filter(Boolean).map(part => el('span', 'fv-cast-slot-line', part))); + if (content) problem.append(issueButton(text, () => ({where: CAST_ISSUE, message: content}))); problem.hidden = !content; slot.classList.toggle('fv-cast-bad', !!content); const width = slot.clientWidth; if (content && width > 0 && (width < 100 || problem.scrollWidth > problem.clientWidth || problem.offsetHeight > slot.clientHeight / 2)) { @@ -260,8 +266,9 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () label: picture.label || (index ? 'other' : 'front'), })); const frontNeeded = active === 'ai' || !frontless; - if (!pictures.length || pictures.length > 6 || (frontNeeded && !pictures.some(picture => picture.label === 'front'))) - throw new Error(text('Choose one front image and up to 5 more angles', '选一张正面图片,再加最多 5 张其他角度的图片')); + if (!pictures.length || pictures.length > 6 || (frontNeeded && !pictures.some(picture => picture.label === 'front'))) { + message(text('Choose one front image and up to 5 more angles', '选一张正面图片,再加最多 5 张其他角度的图片'), true); return; + } const images = []; for (const picture of pictures) { if (closed) return; diff --git a/web/editor.css b/web/editor.css index f796eee9..1cb06064 100644 --- a/web/editor.css +++ b/web/editor.css @@ -44,6 +44,7 @@ .fv-ed-audio-icon{font-size:26px;color:var(--fv-muted)} .fv-editor .fv-ed-import{width:100%;height:auto;aspect-ratio:16/9;border:1px dashed var(--fv-border);border-radius:6px;background:transparent;color:var(--fv-muted);padding:0} .fv-ed-library-message{font-size:12px;color:var(--fv-muted);line-height:1.6} +.fv-ed-source-card .fv-issue-button{font-size:11px;min-height:28px;max-width:100%} .fv-editor .fv-ed-preview{grid-column:2;grid-row:2;min-width:0;min-height:0;display:grid;place-items:center;padding:16px;overflow:hidden;background:var(--fv-bg);container-type:size} .fv-ed-picture{position:relative;width:min(100cqw,calc(100cqh * var(--fv-ed-width,16) / var(--fv-ed-height,9)));aspect-ratio:var(--fv-ed-width,16)/var(--fv-ed-height,9);background:#000;border-radius:8px;overflow:hidden} .fv-ed-picture[data-empty=true]{background:var(--fv-card);border:1px dashed color-mix(in srgb,var(--fv-ink) 28%,transparent)} @@ -75,6 +76,8 @@ .fv-ed-genbar[data-mode=running]>button:not(.fv-ed-generate){order:2} .fv-ed-gen-note{font-size:13px;color:var(--fv-ink);flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-ed-gen-note[hidden]{display:none} +.fv-ed-gen-note:has(.fv-issue-button){white-space:normal;overflow:visible} +.fv-ed-gen-note .fv-issue-button,.fv-ed-library-message .fv-issue-button{margin-left:8px} .fv-ed-genbar .fv-request-assets{order:-2;flex:1 1 100%;min-width:0;font-size:12px;line-height:1.5} .fv-ed-genbar .fv-request-assets[hidden]{display:none} .fv-editor:has(.fv-ed-gen-failure:not([hidden])){--fv-ed-generation-row:max-content} @@ -100,6 +103,7 @@ .fv-ed-refs[hidden],.fv-ed-ref-menu[hidden]{display:none} .fv-ed-ref-notice{position:absolute;bottom:calc(100% + 8px);max-width:min(560px,calc(100% - 24px));border:1px solid var(--fv-border);background:var(--fv-card);padding:10px 14px;border-radius:8px;z-index:9;color:var(--fv-ink);pointer-events:none} .fv-ed-ref-notice[hidden]{display:none} +.fv-ed-ref-notice .fv-issue-button{pointer-events:auto;margin-left:8px} .fv-ed-ref-chips{display:flex;align-items:center;gap:6px;max-width:min(240px,32vw);overflow-x:auto;scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent;padding:1px} .fv-ed-ref-chips:empty{display:none} /* A strip that scrolls fades at the hidden end(s); one that fits is untouched. */ @@ -170,6 +174,7 @@ .fv-editor .fv-ed-name-menu .fv-ed-delete-confirm:not(:disabled):is(:hover,:focus-visible){background:var(--fv-danger-subtle)} .fv-ed-delete-question{margin:6px 10px 10px;line-height:1.5;overflow-wrap:anywhere;white-space:normal} .fv-ed-notice{position:absolute;bottom:340px;right:16px;max-width:min(560px,calc(100vw - 32px));border:1px solid var(--fv-border);background:var(--fv-card);padding:10px 14px;border-radius:8px;z-index:9;color:var(--fv-ink)} +.fv-ed-notice .fv-issue-button{margin-left:12px;vertical-align:middle;pointer-events:auto} @media(max-width:680px){ .fv-studio.fv-editor{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(max(0px,calc(180px - var(--fv-ed-description-growth,0px))),1fr) 40px var(--fv-ed-generation-row,auto) 40px 236px;overflow:hidden} .fv-editor .fv-header{overflow-x:auto} diff --git a/web/editor.js b/web/editor.js index 44230bfb..58359916 100644 --- a/web/editor.js +++ b/web/editor.js @@ -6,6 +6,7 @@ import {viewSwitch, viewChanged} from './view_navigation.js'; import {wordmark} from './wordmark.js'; import {openSetup} from './setup.js'; import {feedbackButton} from './feedback.js'; +import {issueButton} from './issue_button.js'; import * as model from './editor_model.js'; import {discardEmptyEdit, hiddenEmptyEdit, deleteEditText} from './editor_lifecycle.js'; import {createEditorState} from './editor_state.js'; @@ -271,7 +272,15 @@ export function openEditor({api, app, from = null, panel = {}, components: given return true; } const content = record => JSON.stringify({...record, id: null, revision: 0}); - function notify(text) { if (disposed) return; if (!text) { clearTimeout(noticeTimer); notice.hidden = true; return; } notice.textContent = text; notice.hidden = false; clearTimeout(noticeTimer); noticeTimer = setTimeout(() => { notice.hidden = true; }, 9000); } + // `where` marks a failure: the notice then carries the "Report a problem" button and stays longer to be reached. + function notify(text, where = null) { + if (disposed) return; + if (!text) { clearTimeout(noticeTimer); notice.hidden = true; return; } + notice.textContent = text; + if (where) notice.append(issueButton(t, () => ({where, message: text}))); + notice.hidden = false; clearTimeout(noticeTimer); noticeTimer = setTimeout(() => { notice.hidden = true; }, where ? 20000 : 9000); + } + const EDITOR_ISSUE = ['Editor', '编辑器'], EXPORT_ISSUE = ['Export', '导出']; // `step` names what was being done, so a failure there says which step went wrong; `out` is where the line is said (the bar's own line for a reference picture). function error(value, step = null, out = notify) { const code = value?.reason || value?.code || value?.message; @@ -298,7 +307,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given return {ok: false, reason: code}; } const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '没有完成,请重试'); - out(text); return {ok: false, reason: code, say: text}; + out(text, step === 'export' ? EXPORT_ISSUE : EDITOR_ISSUE); return {ok: false, reason: code, say: text}; } const store = createEditorStore(api, {t, notify, standIn: given?.generationStandIn, onGenerationReady: ready => { if (disposed) return; @@ -471,7 +480,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)); return false; } + if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE); return false; } return !!state.record.id; } function updateTop() { @@ -684,7 +693,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)); return; } + if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE); 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. @@ -695,7 +704,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given await discardProject(); await replaceProject(record); menu.hidden = true; title.setAttribute('aria-expanded', 'false'); title.focus(); } catch (value) { - const words = deleteEditText(t); notify(words[value?.reason] || words.delete_failed); + const words = deleteEditText(t); notify(words[value?.reason] || words.delete_failed, EDITOR_ISSUE); } finally { switching = false; element.inert = false; updateTop(); } } function confirmDelete() { @@ -1067,7 +1076,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given function exportLost() { if (disposed) return; exportStatus = null; exportCancel.hidden = true; updateTop(); - notify(t('Lost the connection to FreeVideo, so the export progress cannot be read. Export again once it is back.', '与 FreeVideo 的连接已断开,无法读取导出进度。恢复后请重新导出。')); + notify(t('Lost the connection to FreeVideo, so the export progress cannot be read. Export again once it is back.', '与 FreeVideo 的连接已断开,无法读取导出进度。恢复后请重新导出。'), EXPORT_ISSUE); } function updateExport(value) { if (!value) return false; @@ -1081,7 +1090,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given else notify(value.reason === 'not_writable' ? t('Export failed: the output folder cannot be written to. Check that the folder can be written to, then retry.', '导出时出错:输出目录不可写。请检查这个文件夹的写入权限后重试。') : value.reason === 'disk_full' ? t('Export failed: the disk is full. Free some space, then export again.', '导出时出错:磁盘空间不够,空出空间后再导出。') : value.reason === 'unreadable' ? t('Export failed: a source file cannot be read. Import it again from the library.', '导出时出错:有素材读不出来,请在素材库重新导入。') - : t('Export failed. Try again.', '导出时出错,请再试一次。')); + : t('Export failed. Try again.', '导出时出错,请再试一次。'), EXPORT_ISSUE); } if (value.state === 'cancelled') notify(t('Export cancelled. No unfinished file is kept.', '已取消导出,没有留下不完整的文件。')); window.FreeVideoNotifyTaskDone?.({kind: 'export', id: value.id, ok: value.state === 'done'}); @@ -1310,7 +1319,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!queue.dirty && emptyEdit()) { try { await discardProject({automatic: true}); } catch (value) { - notify(deleteEditText(t)[value?.reason] || deleteEditText(t).delete_failed); + notify(deleteEditText(t)[value?.reason] || deleteEditText(t).delete_failed, EDITOR_ISSUE); closing = false; element.inert = false; closePromise = null; return false; } } diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 0ee29048..59cf5e7e 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -3,6 +3,7 @@ import {createFirstFrameSwitch} from './editor_first_frame.js'; import {createEditorReferences} from './editor_references.js'; import {createExpandButton} from './editor_expand.js'; import {createRequestAssets} from './request_assets.js'; +import {issueButton} from './issue_button.js'; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; export const timeEstimate = (seconds, t) => seconds == null ? '' : seconds < 60 ? t(`about ${Math.max(1, Math.ceil(seconds))} s`, `约 ${Math.max(1, Math.ceil(seconds))} 秒`) @@ -62,12 +63,13 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { // What goes wrong with a reference picture is said above the "+", where the eyes are, not in the page-wide line. const refNotice = el('div', 'fv-ed-ref-notice'); refNotice.hidden = true; refNotice.setAttribute('role', 'status'); let refNoticeTimer = null; - const sayAtRefs = text => { + const sayAtRefs = (text, where = null) => { clearTimeout(refNoticeTimer); if (!text || disposed) { refNotice.hidden = true; return; } refNotice.textContent = text; refNotice.hidden = false; + if (where) refNotice.append(issueButton(t, () => ({where, message: text}))); refNotice.style.left = Math.max(0, (references?.element.getBoundingClientRect().left || 0) - host.getBoundingClientRect().left) + 'px'; - refNoticeTimer = setTimeout(() => { refNotice.hidden = true; }, 9000); + refNoticeTimer = setTimeout(() => { refNotice.hidden = true; }, where ? 20000 : 9000); }; const references = actions.references ? createEditorReferences({t, source: actions.references.source, size: actions.references.size, error: value => actions.error(value, null, sayAtRefs), notify: sayAtRefs}) : null; if (references) { description.before(references.element); host.append(refNotice); } @@ -83,15 +85,16 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { await actions.downloadFailureReport?.(id, {bar: failureBar, anchor: report.hidden ? failureReason : report, t, current: () => !disposed && failureId === id && !failure.hidden}); } catch (value) { - say(t(`Downloading the diagnostic report failed: ${value.message || value}`, `下载诊断报告时出错:${value.message || value}`)); + actions.notify?.(t(`Downloading the diagnostic report failed: ${value.message || value}`, `下载诊断报告时出错:${value.message || value}`), ['Editor diagnostic report', '剪辑器诊断报告']); } finally { downloading = false; report.disabled = false; } } report.onclick = () => downloadFailure(); - function say(text, queued = false) { + function say(text, queued = false, where = null) { if (disposed) return; queuedAt = text && queued ? progressKey() : null; clearTimeout(noteTimer); note.textContent = text || ''; note.hidden = !text; - if (text) noteTimer = setTimeout(() => { note.hidden = true; }, 6000); + if (text && where) note.append(issueButton(t, () => ({where, message: text}))); + if (text) noteTimer = setTimeout(() => { note.hidden = true; }, where ? 20000 : 6000); } async function render() { if (disposed) return; @@ -210,8 +213,8 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { } // A specific reason is said once; the planning limits are already shown above. catch (error) { const result = actions.error(error, 'generate'); reason = result?.say || ''; - if (result?.say && !['chain_too_long', 'chain_part_too_short'].includes(result.reason)) say(result.say); - else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '没能开始,请重试。'); say(reason); } } + if (result?.say && !['chain_too_long', 'chain_part_too_short'].includes(result.reason)) say(result.say, false, ['Editor generation', '剪辑器生成']); + else if (!result && !note.textContent) { reason = t('Could not start. Try again.', '没能开始,请重试。'); say(reason, false, ['Editor generation', '剪辑器生成']); } } finally { pressing.delete(key); render(); } return reason; }; diff --git a/web/editor_library.js b/web/editor_library.js index 0a35fdfc..cac1cf80 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -1,5 +1,7 @@ import {editorIcon} from './editor_icons.js'; import {sourceKey} from './editor_store.js'; +import {issueButton} from './issue_button.js'; +const LIBRARY_ISSUE = ['Editor media library', '剪辑器素材库']; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; const stamp = frames => { const seconds = Math.floor((frames || 0) / 24); return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; }; const FORMATS = 'MP4, MOV, WebM, MKV, M4V, WAV, MP3, FLAC, OGG, M4A, AAC, Opus'; @@ -43,7 +45,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { function requestAdd(row, kind, plus = false) { if (disposed) return; if (kind === 'work' || row.state === 'ready') return add(row, kind, plus).catch(actions.error); - if (row.state === 'failed') { actions.notify?.(cannotOpen()); return; } + if (row.state === 'failed') { actions.notify?.(cannotOpen(), LIBRARY_ISSUE); return; } const key = `${kind}:${row.id}`; if (!pendingAdds.has(key)) pendingAdds.set(key, {plus, project: actions.projectToken?.()}); actions.notify?.(preparing()); @@ -56,7 +58,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { if (row.state === 'ready') { pendingAdds.delete(key); add(row, 'import', pending.plus, {...pending, queued: true}).catch(actions.error); - } else if (row.state === 'failed') { pendingAdds.delete(key); actions.notify?.(cannotOpen()); } + } else if (row.state === 'failed') { pendingAdds.delete(key); actions.notify?.(cannotOpen(), LIBRARY_ISSUE); } } } function card(row, kind) { @@ -95,6 +97,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { plus.ondblclick = event => { event.preventDefault(); event.stopPropagation(); }; plus.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') insert(event); }; container.append(button, plus); + if (kind !== 'work' && row.state === 'failed') container.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: t('Cannot open', '打不开')}))); return container; } function updateUsed() { @@ -120,6 +123,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { updateUsed(); message.textContent = ''; // Empty states stay silent. for (const row of shown) if (row.state === 'failed') message.textContent = t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS; + if (message.textContent) message.append(issueButton(t, () => ({where: LIBRARY_ISSUE, message: cannotOpen()}))); if (scroll > 0) { const replacement = anchor && [...grid.querySelectorAll('.fv-ed-source')].find(node => node.dataset.key === anchor.dataset.key); host.scrollTop = replacement ? scroll + replacement.getBoundingClientRect().top - anchorTop : scroll; @@ -221,7 +225,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { if (place) { placing.push({id: item.id, place}); placeReady(imported); } else startPending([item]); } - } catch { actions.notify(cannotOpen()); } + } catch { actions.notify(cannotOpen(), LIBRARY_ISSUE); } finally { pendingAdds.delete(`import:${id}`); uploading.delete(id); render(); } } } diff --git a/web/editor_references.js b/web/editor_references.js index df8044f6..35c00876 100644 --- a/web/editor_references.js +++ b/web/editor_references.js @@ -89,7 +89,7 @@ export function createEditorReferences({t, source, size = () => null, error = () const result = await source.upload(file); write([...source.rows(), {file: result.file, role: 'reference', enabled: true}]); } - if (skipped && !disposed) notify(t('Could not read this picture. Please choose another one.', '无法读取这张图片,请换一张。')); + if (skipped && !disposed) notify(t('Could not read this picture. Please choose another one.', '无法读取这张图片,请换一张。'), ['Editor reference images', '剪辑器参考图']); } catch (value) { error(value); } finally { working = false; picker.value = ''; update(); } } diff --git a/web/editor_text.js b/web/editor_text.js index bef65039..71e8403a 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -8,6 +8,8 @@ import {loadCoverage, loadTextFonts, textFontStack, missingGlyphs, missingHint, import {createSelectionActions, isTyping} from './editor_select.js'; import {plan} from './editor_geometry.js'; import {isolateDialogKeys} from './dialog_keys.js'; +import {issueButton} from './issue_button.js'; +const SPEECH_ISSUE = ['Auto subtitles', '自动字幕'], FONT_ISSUE = ['Editor text fonts', '剪辑器文字字体']; const el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text!=null)e.textContent=text;return e;}; const clamp=(v,lo,hi)=>Math.max(lo,Math.min(hi,v)); const PRESET_KEY='freevideo.subtitle-presets',PRESET_LIMIT=6; @@ -37,6 +39,8 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const marker=toolbar.querySelector('.fv-ed-marker, .fv-ed-add-marker'); if(marker) marker.after(buttons); else toolbar.insertBefore(buttons,toolbar.querySelector('.fv-ed-zoom')); const notice=el('span');notice.setAttribute('role','button');notice.tabIndex=0;notice.onclick=()=>jumpMissing();notice.onkeydown=e=>{if(['Enter',' '].includes(e.key)){e.preventDefault();jumpMissing();}}; notice.classList.add('fv-ed-text-notice'); notice.hidden=true; exportBox.prepend(notice); + const fontFailure=()=>t('Text fonts could not be loaded','文字字体没能加载'); + let fontIssue=null; const menu=el('div','fv-ed-text-menu'); menu.hidden=true; menu.setAttribute('role','menu'); element.append(menu); const names={sans:t('Sans','黑体'),serif:t('Serif','宋体'),kai:t('Kai','楷体'),mono:t('Mono','等宽')}; let disposed=false,ready=false,table=null,loadError=false,draft=null,fresh=null,typing=null,lastStyle={},editing=null,drag=null,raf=null,layouts=new Map(),mapped={x:0,y:0,w:1,h:1}; @@ -63,16 +67,16 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, // `need` is what has to be downloaded before the first use, in bytes; the same press downloads it and goes on. const speech={buttons:new Set(),available:false,need:0,busy:false,loading:false,shown:null,job:null,stop:null,run:0}; function speechLabel(fraction){speech.shown=fraction;const p=Math.round(Math.min(1,Math.max(0,Number(fraction)||0))*100),size=Math.max(1,Math.round(speech.need/1e6)),text=fraction==null?(speech.need?t(`Auto subtitles · Download ${size} MB`,`自动字幕 · 下载 ${size} MB`):t('Auto subtitles','自动字幕')):speech.loading?t(`Cancel · Downloading ${p}%`,`取消 · 下载 ${p}%`):t(`Cancel · ${p}%`,`取消 · ${p}%`);for(const b of speech.buttons){b.textContent=text;b.hidden=!(speech.available||speech.need);}} - function speechEnd(text){speech.stop?.();speech.stop=null;speech.job=null;speech.busy=false;speech.loading=false;speechLabel(null);if(text)actions.notify?.(text);} + function speechEnd(text,where=null){speech.stop?.();speech.stop=null;speech.job=null;speech.busy=false;speech.loading=false;speechLabel(null);if(text)actions.notify?.(text,where);} const speechFailed=()=>t('Could not create subtitles','没能生成字幕'),speechSilent=()=>t('No speech found','没有听到说话声'); const speechReason=reason=>reason==='no_sound'?speechSilent():reason==='language'?t('Language not supported yet','暂不支持这种语言'):speechFailed(); function speechResult(job){ if(job.state==='running'){speechLabel(job.fraction);return true;} - if(job.state!=='done'){speechEnd(job.state==='cancelled'?null:speechReason(job.reason));return false;} + if(job.state!=='done'){speechEnd(job.state==='cancelled'?null:speechReason(job.reason),job.state==='cancelled'||['no_sound','language'].includes(job.reason)?null:SPEECH_ISSUE);return false;} const cues=(Array.isArray(job.result?.lines)?job.result.lines:[]).filter(l=>typeof l?.text==='string'&&l.text.trim()&&l.end>l.start).map(l=>({start:l.start,frames:l.end-l.start,text:l.text.trim()})); speechEnd();if(!cues.length){actions.notify?.(speechSilent());return false;} finishInline();commitDraft(); - if(!apply(t('Auto subtitles','自动字幕'),r=>addSubtitles(r,cues,{replace:true})))actions.notify?.(speechFailed()); + if(!apply(t('Auto subtitles','自动字幕'),r=>addSubtitles(r,cues,{replace:true})))actions.notify?.(speechFailed(),SPEECH_ISSUE); else{const missing=projectMissing(state.record);if(missing.length)actions.notify?.(missingHint([...new Set(missing.flatMap(r=>r.chars))],t));} return false; } @@ -87,17 +91,17 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const ability=done?await actions.speech.ability():null; if(run!==speech.run)return; if(ability){speech.available=ability.available;speech.need=ability.need;} - if(!done||speech.need){speechEnd(t('Could not download. Try again.','没能下载,请再试一次。'));return;} - if(!speech.available){speechEnd(speechFailed());return;} + if(!done||speech.need){speechEnd(t('Could not download. Try again.','没能下载,请再试一次。'),SPEECH_ISSUE);return;} + if(!speech.available){speechEnd(speechFailed(),SPEECH_ISSUE);return;} speech.loading=false; } speechLabel(0); const job=await actions.speech.start(); if(run!==speech.run){if(job?.id)Promise.resolve(actions.speech.cancel(job.id)).catch(()=>{});return;} - if(!job){speechEnd(speechFailed());return;} + if(!job){speechEnd(speechFailed(),SPEECH_ISSUE);return;} speech.job=job.id??null; - if(speechResult(job))speech.stop=actions.speech.watch(job.id,value=>{if(run!==speech.run)return false;if(!value){speechEnd(speechFailed());return false;}return speechResult(value);}); - }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','没能下载,请再试一次。'):speechFailed());} + if(speechResult(job))speech.stop=actions.speech.watch(job.id,value=>{if(run!==speech.run)return false;if(!value){speechEnd(speechFailed(),SPEECH_ISSUE);return false;}return speechResult(value);}); + }catch{if(run===speech.run)speechEnd(speech.loading?t('Could not download. Try again.','没能下载,请再试一次。'):speechFailed(),SPEECH_ISSUE);} } function commitDraft(){if(draft){const change=draft.change;apply(t('Text','文字'),change);const c=selectedText();if(c?.text_kind==='text')lastStyle={...styleOf(state.record,c)};}} // Editing a text ended. One left without words is taken away; one that never had any leaves no step behind. @@ -227,13 +231,18 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, function updateWarnings(){ const r=record(),c=selectedText(),s=c&&styleOf(r,model.find(r,c.id)); const missing=projectMissing(r),chars=[...new Set(missing.flatMap(r=>r.chars))]; - notice.hidden=!chars.length&&!loadError;notice.textContent=loadError?t('Text fonts could not be loaded','文字字体没能加载'):missingHint(chars,t);notice.title=notice.textContent; + notice.hidden=!chars.length&&!loadError;notice.textContent=loadError?fontFailure():missingHint(chars,t);notice.title=notice.textContent; for(const [kind,p]of panels){ if(c?.text_kind!==kind)continue; const entry=model.find(r,c.id),absent=table?missingGlyphs(entry.text,s.font,!!s.bold,table):[]; - p.warning.hidden=!absent.length&&!loadError;p.warning.textContent=loadError?t('Text fonts could not be loaded','文字字体没能加载'):missingHint(absent,t); + p.warning.hidden=!absent.length&&!loadError;p.warning.textContent=loadError?fontFailure():missingHint(absent,t); + if(loadError)p.warning.append(issueButton(t,()=>({where:FONT_ISSUE,message:fontFailure()}))); p.overflow.hidden=kind!=='subtitle'||!ready||!metrics||!textLayout(metrics,entry.text,s,kind,r.width,r.height).overTwo; } + // Both messages can be on screen at once; the button stays with the one nearest the text panel. + const panelShowing=loadError&&[...panels.values()].some(p=>p.warning.offsetParent!==null); + if(loadError&&!panelShowing&&!fontIssue){fontIssue=issueButton(t,()=>({where:FONT_ISSUE,message:fontFailure()}));notice.after(fontIssue);} + if((!loadError||panelShowing)&&fontIssue){fontIssue.remove();fontIssue=null;} } function jumpMissing(){const first=projectMissing(state.record)[0];if(first){const c=model.find(state.record,first.id);state.select({kind:'clip',ids:[c.id]});state.seek(c.start);adjust.open();}} function updateClip(item,r){ @@ -368,14 +377,14 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, async function beforeExport(){ if(!state.record.tracks.some(isTextTrack))return true;finishInline();commitDraft();await ensureFonts(); const chars=[...new Set(projectMissing(state.record).flatMap(r=>r.chars))];if(!chars.length&&!loadError)return true; - const dialog=el('dialog','fv-studio fv-ed-text-export-dialog'),title=el('h3','',t('Export','导出')),message=el('p','',loadError?t('Text fonts could not be loaded','文字字体没能加载'):missingHint(chars,t)); - const close=button(t('Back to edit','返回修改'),{onClick:()=>dialog.close()});dialog.append(title,message,close);document.body.append(dialog); - const stop=isolateDialogKeys(dialog,(e)=>{if(e.type==='keydown'&&e.key==='Escape'){e.preventDefault();dialog.close();}else if(e.type==='keydown'&&e.key==='Enter'){e.preventDefault();dialog.close();}}); + const dialog=el('dialog','fv-studio fv-ed-text-export-dialog'),title=el('h3','',t('Export','导出')),message=el('p','',loadError?fontFailure():missingHint(chars,t)); + const close=button(t('Back to edit','返回修改'),{onClick:()=>dialog.close()});dialog.append(title,message);if(loadError)dialog.append(issueButton(t,()=>({where:FONT_ISSUE,message:fontFailure()})));dialog.append(close);document.body.append(dialog); + const stop=isolateDialogKeys(dialog,(e)=>{if(e.type==='keydown'&&e.key==='Escape'){e.preventDefault();dialog.close();}else if(e.type==='keydown'&&e.key==='Enter'&&!e.target.closest?.('.fv-issue-button')){e.preventDefault();dialog.close();}}); dialog.onclose=()=>{stop();dialog.remove();jumpMissing();};dialog.showModal();close.focus();return false; } return {get ready(){return ensureFonts();},beforeExport,speech(value){speech.available=value?.available===true;speech.need=Number(value?.need)||0;if(!speech.busy)speechLabel(null);},flush(){finishInline();commitDraft();},keyboard(e){ if(isTyping(e.target)||e.ctrlKey||e.metaKey||e.altKey||e.isComposing)return false; if(e.key.toLowerCase()==='t'){e.preventDefault();e.stopPropagation();add('text');return true;} if(e.key==='Escape'&&(drag||!menu.hidden)){e.preventDefault();if(drag)endDrag(e,true);menu.hidden=true;return true;}return false; - },dispose(){disposed=true;if(speech.busy){const id=speech.job;speech.run++;speechEnd();if(id)Promise.resolve(actions.speech.cancel(id)).catch(()=>{});}speech.buttons.clear();player.setTextRenderer?.(null);unsubscribe();resize?.disconnect();cancelAnimationFrame(raf);tracksHost.removeEventListener('contextmenu',contextMenu);element.removeEventListener('pointerdown',outside);frame.removeEventListener('seeked',seeked,true);frame.removeEventListener('loadeddata',seeked,true);canvas.remove();boundsLayer.remove();buttons.remove();notice.remove();menu.remove();}}; + },dispose(){disposed=true;if(speech.busy){const id=speech.job;speech.run++;speechEnd();if(id)Promise.resolve(actions.speech.cancel(id)).catch(()=>{});}speech.buttons.clear();player.setTextRenderer?.(null);unsubscribe();resize?.disconnect();cancelAnimationFrame(raf);tracksHost.removeEventListener('contextmenu',contextMenu);element.removeEventListener('pointerdown',outside);frame.removeEventListener('seeked',seeked,true);frame.removeEventListener('loadeddata',seeked,true);canvas.remove();boundsLayer.remove();buttons.remove();notice.remove();fontIssue?.remove();menu.remove();}}; } diff --git a/web/error_panel.js b/web/error_panel.js index b8034922..c268f973 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -27,9 +27,9 @@ const PLAIN_ERRORS = [ {key: 'continuation-size', message: 'Use the same aspect ratio as the previous video.', title: ['The two videos have different aspect ratios', '两段视频的画面比例不同'], summary: ['Choose a video size with the same aspect ratio as the previous video.', '请选择与上一段视频比例相同的画面大小。']}, - {key: 'continuation-route', message: 'Continue from video cannot be used with these settings. Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', + {key: 'continuation-route', message: 'Continue from video cannot be used with these settings. Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', title: ['This computer can\'t continue a video', '这台电脑无法接续视频'], - summary: ['Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡。要生成新视频,请清空“接着生成”。']}, + summary: ['Continuing a video needs an NVIDIA GPU or a Mac. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡或 Mac。要生成新视频,请清空“接着生成”。']}, {key: 'continuation-passes', message: 'Continue from video cannot be used with these settings. Turn on "Two-pass acceleration" and turn off the first-pass preview.', title: ['Continuing needs two-pass acceleration', '接续需要二次采样加速'], summary: ['Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。']}, @@ -484,7 +484,7 @@ export function createErrorPanel(t) { const download = document.createElement('button'); download.type = 'button'; download.textContent = t('Export report', '导出报告'); // A generation that stopped offers two buttons: the report file, or the same facts as a GitHub issue. const github = document.createElement('button'); github.type = 'button'; github.className = 'fv-failure-github'; - github.textContent = t('Submit to GitHub', '提交到 GitHub'); + github.textContent = t('Report a problem', '提交问题'); // Shown only for a browser extension, whose steps end with a reload. const reload = document.createElement('button'); reload.type = 'button'; reload.className = 'fv-failure-reload'; reload.textContent = t('Reload page', '刷新页面'); reload.hidden = true; reload.onclick = () => globalThis.location?.reload(); @@ -607,7 +607,7 @@ export function createErrorPanel(t) { const lora = advice.kind === 'lora-unusable' || advice.kind === 'lora-int8'; // An int8 limit is not a judgement, so there is no mistake to report. github.hidden = !generated || advice.kind === 'lora-int8'; githubLora = lora; - github.textContent = lora ? t('Report a mistake', '报告误判') : t('Submit to GitHub', '提交到 GitHub'); + github.textContent = lora ? t('Report a mistake', '报告误判') : t('Report a problem', '提交问题'); if (generated && typeof window !== 'undefined') loadIssueModules(); if (popup) showFailureDialog(report, t); return report; diff --git a/web/freevideo.js b/web/freevideo.js index 5f5de117..25ba0a31 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -29,6 +29,7 @@ import { openImageEditor, icon } from './image_editor.js'; import { imageGenerationSupported } from './image_generation.js'; import { createCastLibrary } from './cast_library.js'; import { uploadMedia } from './media_upload.js'; +import { issueButton } from './issue_button.js'; const languageOverride = typeof location !== 'undefined' ? new URLSearchParams(location.search).get('freevideo_lang') : null; @@ -47,6 +48,7 @@ style.textContent = ` .fv-panel[data-freevideo=result]{display:flex;flex-direction:column;justify-content:center}.fv-panel[data-freevideo=result]:has(>.fv-generation-progress:not([hidden])){justify-content:flex-start}.fv-panel .fv-connected-card{text-align:center;padding:28px 12px;border:1px solid var(--fv-border);border-radius:var(--fv-r-md,12px);color:var(--fv-muted);background:var(--fv-raised)} .fv-panel button,.fv-panel select,.fv-panel a{box-sizing:border-box;min-height:var(--fv-h-sm,32px);border:1px solid var(--fv-border);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised);color:var(--fv-ink);padding:5px 10px;font:inherit;font-weight:var(--fv-medium,500);cursor:pointer;text-decoration:none} .fv-panel button:hover,.fv-panel a:hover{background:var(--fv-hover)}.fv-panel button:disabled{opacity:.45;cursor:wait} +.fv-menu-failure{width:min(460px,calc(100vw - 32px));height:auto}.fv-menu-failure::backdrop{background:#0009} .fv-panel select{max-width:170px}.fv-note{color:var(--fv-muted);font-size:var(--fv-micro,12px);margin:8px 0;white-space:pre-wrap}.fv-error{color:var(--fv-danger)} .fv-mode{font-size:var(--fv-micro,12px);color:var(--fv-accent)} .fv-panel[data-freevideo=media]>.fv-mode{display:inline-block;margin:4px 0 2px;padding:2px 8px;border-radius:var(--fv-r-xs,3px);background:var(--fv-selected);font-weight:var(--fv-medium,500)}.fv-card{border:1px solid var(--fv-border);border-radius:var(--fv-r-md,12px);padding:10px;margin-top:10px;background:var(--fv-raised)} @@ -657,14 +659,24 @@ const launchVisit = isLaunchVisit(globalThis.location?.search, globalThis.sessio let userActed = false; for (const type of ['pointerdown', 'keydown']) globalThis.addEventListener?.(type, () => { userActed = true; }, {capture: true, once: true}); +let menuFailure; async function openFromMenu() { + menuFailure?.close(); try { const node = (app.graph?._nodes || []).find(isGenerate) || await openFreeVideoWorkflow(); if (!node) throw new Error('FreeVideo workflow unavailable'); showStudio(node); } catch (error) { console.warn('FreeVideo menu:', error); - app.extensionManager?.toast?.add({severity: 'error', summary: 'FreeVideo', detail: text('Could not complete. Try again.', '没有完成,请重试'), life: 9000}); + const message = text('Could not complete. Try again.', '没有完成,请重试'); + const dialog = el('dialog', undefined, 'fv-panel fv-menu-failure'); menuFailure = dialog; + dialog.setAttribute('aria-label', 'FreeVideo'); + const actions = el('div', undefined, 'fv-row'); + actions.append(issueButton(text, () => ({where: ['Open FreeVideo', '打开 FreeVideo'], message})), + button(text('Close', '关闭'), '', () => dialog.close())); + dialog.append(el('p', message), actions); + dialog.onclose = () => { dialog.remove(); if (menuFailure === dialog) menuFailure = null; }; + document.body.append(dialog); dialog.showModal(); } } @@ -819,7 +831,11 @@ app.registerExtension({ if (handle) { opening.cover(); await handle.ready; } else { const studio = showStudio(node), status = studio?.querySelector?.('.fv-status'); - if (status) { status.dataset.error = 'true'; status.textContent = text('Could not complete. Try again.', '没有完成,请重试'); } + if (status) { + const message = text('Could not complete. Try again.', '没有完成,请重试'); + status.dataset.error = 'true'; status.textContent = message; + status.append(issueButton(text, () => ({where: ['Open editor', '打开剪辑'], message}))); + } } } finally { if (epoch === configuration) opening.finish(); } }); diff --git a/web/generation_progress.css b/web/generation_progress.css index bd3c9745..2485fd5d 100644 --- a/web/generation_progress.css +++ b/web/generation_progress.css @@ -71,3 +71,4 @@ @keyframes fv-generation-mini-sweep{0%{left:-40px}100%{left:100%}} @keyframes fv-generation-mini-light{0%{transform:translateX(-40px)}100%{transform:translateX(160px)}} @media (prefers-reduced-motion:reduce){.fv-generation-mini[data-mode=indeterminate] .fv-generation-mini-fill{left:60px;animation:none}.fv-generation-mini[data-shimmer=true]::after{animation:none;opacity:0}} +.fv-generation-progress>.fv-issue-button{margin-top:6px;align-self:center} diff --git a/web/generation_progress.js b/web/generation_progress.js index 1547c4d3..31df724b 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -1,5 +1,6 @@ // One whole-video estimate and the same five rows in Studio and the node view. import { saveReport, reportIssue, showReportTip } from './report_issue.js'; +import { issueButton } from './issue_button.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./generation_progress.css', import.meta.url).href; document.head.append(css); @@ -370,15 +371,17 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t } } const report = el('button', 'fv-report-download fv-quiet'); report.type = 'button'; report.hidden = true; + let problem; // A finished video's report is fetched once and kept: the file saved and the one sent are the same. let reportId = null, revision = 0, pinned = false, pinnedBlob = null; report.textContent = label(); function updateReport(message) { if (message.new_request === true || message.overall?.reset === true || message.reset === true && !message.phase) { + problem?.remove(); reportId = null; revision++; report.hidden = true; pinned = false; pinnedBlob = null; } if (typeof message.report_id === 'string' && /^[a-f0-9]{32}$/.test(message.report_id)) { - if (reportId !== message.report_id) { revision++; pinned = false; pinnedBlob = null; report.disabled = false; report.textContent = label(); } + if (reportId !== message.report_id) { problem?.remove(); revision++; pinned = false; pinnedBlob = null; report.disabled = false; report.textContent = label(); } reportId = message.report_id; } report.hidden = !reportId && !fallback; @@ -400,6 +403,7 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t async function downloadReport() { await saveReport(await fetchReport()); } report.onclick = async () => { if (report.disabled || report.hidden) return; + problem?.remove(); const current = revision; report.disabled = true; report.textContent = t('Preparing report…', '正在整理报告…'); try { @@ -410,7 +414,11 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t submit: () => reportIssue(fetchReport, typeof version === 'function' ? version() : version, {save: false})}); } } catch { - if (current === revision) report.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); + if (current === revision) { + report.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); + problem = issueButton(t, () => ({where: ['Diagnostic report', '诊断报告'], message: report.textContent})); + report.after(problem); + } } finally { if (current === revision) report.disabled = false; } }; return {report, updateReport, fetchReport, downloadReport, hasReport: () => reportId !== null, pin: () => { pinned = true; }}; diff --git a/web/health.js b/web/health.js index 050c2f65..e1bf094d 100644 --- a/web/health.js +++ b/web/health.js @@ -16,7 +16,7 @@ const launchVisit = query.get('freevideo') === 'launch'; export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_lifecycle.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_expand.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_header_fit.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'failure_issue.js', 'feedback.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launch_visit.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'lora_check.js', 'library.js', 'motion.js', 'native_errors.js', 'translate_guard.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', - 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'studio_recent.js', + 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js', 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js', 'workspace_restore.js', 'cast_library.js', 'media_upload.js', 'whitebox_encode.js', 'whitebox_mp4.js', 'whitebox_recording.js', 'whitebox_render.js', 'whitebox_scenes.js', 'whitebox_worker.js']; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); @@ -134,8 +134,8 @@ async function probe(url) { const ADVICE = { files: t('FreeVideo’s page files are missing or unreadable. In the FreeVideo launcher, open Settings and click Repair, then open FreeVideo again. Without the launcher, update the FreeVideo node and restart ComfyUI.', 'FreeVideo 的网页文件缺失或无法读取。请在 FreeVideo 启动器的“设置”里点击“修复”,完成后重新打开。没有使用启动器时,请更新 FreeVideo 节点并重启 ComfyUI。'), - type: t('ComfyUI sent the file with the wrong type, so the browser refused it. Restart ComfyUI. If it persists, copy the details and submit them in a GitHub issue; we will look into it and work on a fix for you.', - 'ComfyUI 发给浏览器的文件类型不对,浏览器拒绝使用。请重启 ComfyUI;仍然出现时,请复制详情并提交到 GitHub issue,我们会专门排查,为您解决这个问题。'), + type: t('ComfyUI sent the file with the wrong type, so the browser refused it. Restart ComfyUI. If it persists, use Report a problem.', + 'ComfyUI 发给浏览器的文件类型不对,浏览器拒绝使用。请重启 ComfyUI;仍然出现时,请点“提交问题”。'), browser: t('This browser cannot run FreeVideo. Open the same address in a current Chrome or Edge.', '当前浏览器无法运行 FreeVideo。请用最新版 Chrome 或 Edge 打开同一个地址。'), mixed: t('The browser mixed files from two FreeVideo versions. Press Ctrl+F5 to reload without the cache.', @@ -144,10 +144,10 @@ const ADVICE = { '请保留 custom_nodes/FreeVideo,把其他 FreeVideo 文件夹移出 custom_nodes,然后重启 ComfyUI。'), plugin: t('Another ComfyUI extension reported an error at the same time. Turn it off in ComfyUI Settings › Extensions, then reload.', '同时有其他 ComfyUI 插件报错。可在 ComfyUI“设置 › 扩展”里暂时停用它,然后刷新页面。'), - reload: t('Reload the page. If it happens again, copy the details and submit them in a GitHub issue; we will look into it and work on a fix for you.', - '请刷新页面。仍然出现时,请复制详情并提交到 GitHub issue,我们会专门排查,为您解决这个问题。'), - cache: t('FreeVideo already reloaded this page once with fresh copies of its files. Press Ctrl+F5. If it still fails, turn off browser extensions that block scripts on this page or open the same address in another browser. If that does not help, copy the details and submit them in a GitHub issue; we will look into it and work on a fix for you.', - 'FreeVideo 已经用重新下载的文件自动刷新过一次页面。请按 Ctrl+F5 强制刷新;仍然出现时,请停用会拦截此页面脚本的浏览器插件,或换一个浏览器打开同一地址。仍未解决时,请复制详情并提交到 GitHub issue,我们会专门排查,为您解决这个问题。'), + reload: t('Reload the page. If it happens again, use Report a problem.', + '请刷新页面。仍然出现时,请点“提交问题”。'), + cache: t('FreeVideo already reloaded this page once with fresh copies of its files. Press Ctrl+F5. If it still fails, turn off browser extensions that block scripts on this page or open the same address in another browser. If that does not help, use Report a problem.', + 'FreeVideo 已经用重新下载的文件自动刷新过一次页面。请按 Ctrl+F5 强制刷新;仍然出现时,请停用会拦截此页面脚本的浏览器插件,或换一个浏览器打开同一地址。仍未解决时,请点“提交问题”。'), relaunch: t('Click Start in the FreeVideo launcher again.', '请在 FreeVideo 启动器里重新点击“启动”。'), }; @@ -458,6 +458,25 @@ function details(problems) { '', 'Errors seen while loading:', ...(seen.length ? seen.map(row => `- [${row.kind}] ${row.text}`) : ['- none'])].join('\n'); } +// "Report a problem": the feedback form with the failed checks in it. This card runs when other files +// fail to load, so it builds the address itself (no shared module) and removes places and addresses. +const ISSUE_FORM = 'https://github.com/FlashML-org/FreeVideo/issues/new'; +const forgetPlaces = text => String(text) + .replace(/https?:\/\/[^\s'"<>)]+/g, '') + .replace(/\b\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?\b/g, '
') + .replace(/(?:[A-Za-z]:[\\/]|\\\\|~[\\/])[^'"<>|\r\n]+/g, '') + .replace(/(^|[\s('"])\/[\w.-]+(?:\/[^\s'"<>)]*)+/g, '$1'); +function issueAddress(problems) { + const summary = forgetPlaces(details(problems)).split('\n').filter(line => !line.startsWith('Page:')).join('\n'); + const feedback = ['FreeVideo showed an error.', 'Where: Page check', + `Error: ${forgetPlaces(problems[0]?.text || '').slice(0, 300)}`].join('\n'); + const build = text => `${ISSUE_FORM}?${new URLSearchParams({template: 'feedback.yml', title: '[Feedback] Page check', + feedback, diagnostics: text})}`; + let text = summary.slice(0, 2500); + while (build(text).length > 7000 && text) text = text.slice(0, Math.floor(text.length * 0.8)); + return build(text); +} + // The automatic reload: fresh copies into the HTTP cache, then the address the // page was opened with, before launcher.js removed its launch flag. Reported to // the launcher only if it fails again, so its card does not flash. @@ -527,6 +546,7 @@ function show(problems) { } copy.textContent = t('Copied', '已复制'); }); + action(t('Report a problem', '提交问题'), () => window.open(issueAddress(problems), '_blank', 'noopener')); action(t('Close', '关闭'), () => { card?.remove(); card = null; }); if (extensionAdvice) { const first = document.createElement('p'); first.textContent = extensionAdvice.summary; diff --git a/web/image_editor.css b/web/image_editor.css index 013cf4ea..52e0ffc3 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -78,7 +78,22 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-gen-ref-add .fv-ie-menu-list{top:calc(100% + 6px);right:auto;bottom:auto;left:0} .fv-ie-options.fv-ie-generation[data-empty=true]{grid-template-columns:auto auto minmax(60px,1fr) auto auto} .fv-ie-generation[data-empty=true]>.fv-ie-button{grid-column:5} -.fv-ie-generation>#fv-ie-gen-ratio{min-height:32px} +.fv-ie-gen-shape{display:flex;gap:8px} +.fv-ie-gen-shape[hidden]{display:none} +.fv-ie-gen-shape>.fv-ie-select{min-height:32px} +.fv-ie-gen-shape>.fv-ie-select[hidden],.fv-ie-gen-shape>.fv-ie-seg[hidden]{display:none} +.fv-ie-gen-history{flex:none} +.fv-ie-gen-history summary{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border:1px solid var(--fv-border);border-radius:6px} +.fv-ie-gen-history .fv-ie-menu-list{top:calc(100% + 6px);right:0;bottom:auto;left:auto;width:min(320px,calc(100vw - 16px));max-height:min(420px,70vh)} +.fv-ie-gen-history .fv-ie-menu-list.fv-ie-gen-history-up{top:auto;bottom:calc(100% + 6px)} +.fv-ie-gen-history-confirm{opacity:1!important;width:auto!important;padding:0 6px;background:#c0392b!important;color:#fff!important;font-size:var(--fv-micro)} +.fv-ie-gen-history-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(88px,1fr));gap:6px} +.fv-ie-gen-history-tile{position:relative} +.fv-ie-gen-history-pick{display:block;width:100%;padding:0;border:0;background:none;cursor:pointer} +.fv-ie-gen-history-pick img{display:block;width:100%;aspect-ratio:1;object-fit:cover;border-radius:6px;background:var(--fv-raised)} +.fv-ie-gen-history-use{position:absolute;top:4px;left:4px;width:20px;height:20px;padding:0;border:0;border-radius:10px;background:rgba(0,0,0,.7);color:#fff;font-size:16px;line-height:18px;opacity:0;cursor:pointer} +.fv-ie-gen-history-tile:hover>.fv-ie-gen-history-use,.fv-ie-gen-history-tile:hover>.fv-ie-gen-history-delete,.fv-ie-gen-history-use:focus-visible,.fv-ie-gen-history-delete:focus-visible{opacity:1} +.fv-ie-gen-history-delete{opacity:0;top:4px;right:4px;width:20px;height:20px;border-radius:10px;font-size:16px;line-height:18px;background:rgba(0,0,0,.7);color:#fff} .fv-image-editor .fv-ie-gen-prompt{position:absolute;inset:0 0 auto;width:100%;height:32px;min-height:32px;max-height:80px;resize:none;padding:3px 9px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);line-height:24px;overflow-y:auto} .fv-ie-gen-prompt:disabled{opacity:.5} .fv-ie-gen-note{align-self:center;min-width:0;overflow-wrap:anywhere;font-variant-numeric:tabular-nums} @@ -239,6 +254,7 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-confirm span{flex:1 1 220px} .fv-ie-status{padding:0 16px;color:var(--fv-muted);font-size:var(--fv-micro)} .fv-ie-status:empty{display:none} +.fv-ie-status .fv-issue-button{pointer-events:auto;margin-left:8px} .fv-ie-status:not(:empty){position:absolute;left:0;right:0;bottom:0;padding:7px 16px;border-top:1px solid var(--fv-border);background:var(--fv-panel,#0f1620);z-index:3;pointer-events:none} @media (max-width:900px){ @@ -261,7 +277,7 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-generation>.fv-ie-seg{grid-column:1/-1;justify-self:start} .fv-ie-gen-field{grid-column:1;grid-row:2;flex-wrap:wrap;height:auto} .fv-ie-gen-refs{flex:0 0 100%;overflow-x:auto;height:36px;padding:4px 5px 0} - .fv-ie-gen-box{flex:1 0 100%} + .fv-ie-gen-box{flex:1 0 calc(100% - 40px)} .fv-image-editor .fv-ie-gen-prompt{font-size:16px} .fv-ie-generation>.fv-ie-button{grid-column:2;grid-row:2;align-self:end} .fv-ie-gen-note{grid-column:1/-1;grid-row:3} @@ -273,7 +289,7 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-generation[data-pack=true]>.fv-ie-gen-note{text-align:right} .fv-ie-options.fv-ie-generation[data-empty=true]{grid-template-columns:minmax(0,1fr) auto} .fv-ie-generation[data-empty=true]>.fv-ie-seg{grid-column:1;grid-row:1} - .fv-ie-generation[data-empty=true]>#fv-ie-gen-ratio{grid-column:2;grid-row:1} + .fv-ie-generation[data-empty=true]>.fv-ie-gen-shape{grid-column:2;grid-row:1} .fv-ie-generation[data-empty=true]>.fv-ie-button{grid-column:2;grid-row:2} .fv-ie-generation[data-empty=true]>.fv-ie-gen-note{grid-column:1/-1;grid-row:3} .fv-ie-generation[data-failed=true]>.fv-ie-gen-note{grid-row:3} diff --git a/web/image_editor.js b/web/image_editor.js index c15efc3d..4cd1efc8 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -10,6 +10,7 @@ import {FONT_KEYS, fontStack, fontLoaded, loadFont, loadFonts} from './fonts.js' import {api} from '../../scripts/api.js'; import {createImageGenClient, createImageGeneration, imageGenerationSupported, blankCanvasSize, blankCanvasRatio, segmentAt} from './image_generation.js'; import {productVersion} from './updates.js'; +import {issueButton} from './issue_button.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./image_editor.css', import.meta.url).href; @@ -52,6 +53,7 @@ export const ICONS = { link: '', unlink: '', plus: '', + history: '', image: '', copy: '', trash: '', @@ -134,7 +136,7 @@ export async function openImageEditor({source = null, name = source || '', targe const baseImage = hasOriginal ? await loadImage(resolve(source)) : null; const renderer = new Renderer(resolve); const fresh = !edit; - const blankSizeForRatio = ratio => blankCanvasSize(ratio, null); + const blankSizeForRatio = (ratio, tier = 'standard') => blankCanvasSize(ratio, null, tier); const blankSize = edit || (target?.width > 0 && target?.height > 0 ? target : blankSizeForRatio('16:9')); const original = () => { if (!hasOriginal) return blankDocument(blankSize, edit?.ratio || blankCanvasRatio(target)); @@ -229,7 +231,7 @@ export async function openImageEditor({source = null, name = source || '', targe const genClient = imageGenClient || createImageGenClient(api.fetchApi.bind(api)); const generation = createImageGeneration({client: genClient, renderer, t, getDoc: () => doc, getSelection: () => selection, commit: commitInserted, el, button, segmented, - library, resolve, icon, onRatioChange: setBlankCanvasRatio, productVersion, fetchApi: (path, options) => api.fetchApi(path, options), + library, resolve, icon, onRatioChange: setBlankCanvasRatio, onPlace: (file, label) => addImageLayer(file, label), productVersion, fetchApi: (path, options) => api.fetchApi(path, options), resumeKey: `${role}|${source || ''}|${name}`}); // ----- tools ---------------------------------------------------------------- @@ -920,14 +922,14 @@ export async function openImageEditor({source = null, name = source || '', targe commitInserted(commands.addImageLayer(doc, {src: file, nw: img.naturalWidth, nh: img.naturalHeight, name: label || file.split('/').pop()})); select('move'); - } catch { message(t('The image could not be added. Choose another one.', '图片没能添加,请换一张。')); } + } catch { message(t('The image could not be added. Choose another one.', '图片没能添加,请换一张。'), ['Image editor', '图片编辑器']); } } filePicker.onchange = async () => { const file = filePicker.files?.[0]; filePicker.value = ''; if (!file || !upload) return; message(t('Uploading the image…', '正在上传图片…')); try { const result = await upload(file); message(''); await addImageLayer(result.file, file.name); } - catch (error) { message(error.message || t('Upload failed.', '上传失败。')); } + catch (error) { message(error.message || t('Upload failed.', '上传失败。'), ['Image editor upload', '图片编辑器上传']); } }; function duplicateLayer() { const layer = active(); @@ -956,9 +958,9 @@ export async function openImageEditor({source = null, name = source || '', targe } // ----- canvas: shape, orientation, size ---------------------------------------------- - function setBlankCanvasRatio(ratio, resumed = false) { + function setBlankCanvasRatio(ratio, tier = 'standard', resumed = false) { if (doc.layers.length || (!resumed && generation.state().running)) return; - const next = blankDocument(blankSizeForRatio(ratio), ratio); + const next = blankDocument(blankSizeForRatio(ratio, tier), ratio); followSize(doc, next); deselectQuiet(); commit(next); } function aspectOf(id, r = rect()) { @@ -1253,7 +1255,7 @@ export async function openImageEditor({source = null, name = source || '', targe addPart({shape: 'mask', layer: src.id, file, upto: src.ops.length}, how); message(''); }).catch(error => { message(error?.code === 'busy_video' ? t('A video is generating. Try again when it finishes.', '视频正在生成,完成后再试。') - : t('Could not find an object here. Try again.', '没能在这里找出物体,请再试一次。')); + : t('Could not find an object here. Try again.', '没能在这里找出物体,请再试一次。'), error?.code === 'busy_video' ? null : ['Image object selection', '图片智能抠图']); }).finally(() => { pointBusy = false; }); return null; } @@ -1547,6 +1549,7 @@ export async function openImageEditor({source = null, name = source || '', targe const mod = event.ctrlKey || event.metaKey, key = event.key.toLowerCase(); // Nothing typed here reaches ComfyUI's canvas shortcuts behind the dialog. if (event.key !== 'Escape' && event.key !== 'Tab') event.stopPropagation(); + if (event.target.closest?.('.fv-issue-button')) return; if (typing) { if (mod && key === 'enter') { event.preventDefault(); finish(); } return; } if (mod && key === 'z') { event.preventDefault(); step(event.shiftKey ? 1 : -1); return; } if (mod && key === 'y') { event.preventDefault(); step(1); return; } @@ -1607,7 +1610,12 @@ export async function openImageEditor({source = null, name = source || '', targe let resolveResult; const result = new Promise(r => { resolveResult = r; }); let messageTimer = null; - function message(text) { status.textContent = text; clearTimeout(messageTimer); if (text) messageTimer = setTimeout(() => { status.textContent = ''; }, 5000); } + function message(text, where = null) { + status.textContent = text; + if (text && where) status.append(issueButton(t, () => ({where, message: text}))); + clearTimeout(messageTimer); + if (text) messageTimer = setTimeout(() => { status.textContent = ''; }, where ? 20000 : 5000); + } async function leave() { void generation.cancel(); if (healing) await healing; @@ -1627,7 +1635,7 @@ export async function openImageEditor({source = null, name = source || '', targe const {blob, width, height} = await renderer.export(doc, extension === 'jpg' ? 'image/jpeg' : 'image/png'); close({blob, name: name ? `${stem}-edit.${extension}` : `${stem}.${extension}`, edit: doc, width, height}); } catch { - message(t('The image could not be saved. Try a smaller canvas or reload the page.', '图片没能保存,请缩小画布或刷新页面后重试。')); + message(t('The image could not be saved. Try a smaller canvas or reload the page.', '图片没能保存,请缩小画布或刷新页面后重试。'), ['Image editor save', '图片编辑器保存']); save.disabled = cancel.disabled = false; } } diff --git a/web/image_generation.js b/web/image_generation.js index 811f5344..2481c6c2 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -3,6 +3,7 @@ import * as commands from './image_commands.js'; import {orientMatrix, rasterKinds, scaleSelection} from './image_editor_engine.js'; import {saveReport, reportIssue, showReportTip} from './report_issue.js'; +import {issueButton} from './issue_button.js'; import {about} from './request_assets.js'; export const IMAGE_GEN_PROTOCOL = Object.freeze({ @@ -26,14 +27,17 @@ export function blankCanvasRatio(target) { return target?.width > 0 && target?.height > 0 && target.width * h === target.height * w; }) || BLANK_CANVAS_RATIOS[0]; } -export function blankCanvasSize(ratio, sizes = null) { +// 高清 is a square side of 1280 against 1024 (about 1.6 times the pixels). +export const BLANK_CANVAS_AREAS = Object.freeze({standard: 1024, high: 1280}); +export function blankCanvasSize(ratio, sizes = null, tier = 'standard') { if (!BLANK_CANVAS_RATIOS.includes(ratio)) ratio = BLANK_CANVAS_RATIOS[0]; if (sizes?.[ratio]) { const [width, height] = sizes[ratio]; return {width, height}; } const [w, h] = ratio.split(':').map(Number); - const units = Math.floor(Math.sqrt(1024 ** 2 / (w * h * 32 ** 2))); + const side = BLANK_CANVAS_AREAS[tier] || BLANK_CANVAS_AREAS.standard; + const units = Math.floor(Math.sqrt(side ** 2 / (w * h * 32 ** 2))); return {width: w * units * 32, height: h * units * 32}; } const failure = (code, info = {}) => Object.assign(new Error(code || 'generation_failed'), { @@ -69,9 +73,10 @@ export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThi } return { info, - async generate({prompt, width, height, images = [], mask, seed}) { + async generate({prompt, width, height, images = [], mask, seed, cast_view}) { const value = await post('generate', {[p.prompt]: prompt, [p.width]: width, [p.height]: height, - [p.images]: images, ...(mask ? {[p.mask]: mask} : {}), ...(seed === undefined ? {} : {[p.seed]: seed})}); + [p.images]: images, ...(mask ? {[p.mask]: mask} : {}), ...(seed === undefined ? {} : {[p.seed]: seed}), + ...(cast_view === 'back' ? {cast_view} : {})}); return {...value, job: value[p.job]}; }, async status(job) { @@ -91,6 +96,10 @@ export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThi async segment({image, points}) { return post('segment', {image, points}); }, segmentInstall: () => post('segment-install', {}), segmentCancel: () => post('segment-cancel', {}), + // Every picture generated on this machine, newest first; the engine keeps them in a folder. + async history() { return (await post('history', {})).items || []; }, + async historyDelete(file) { return (await post('history-delete', {file})).items || []; }, + historyOpen: () => post('history-open', {}), // The prompts and sizes of a new character's five pictures (the Agent's cards use the same ones). castViews: async description => (await post('cast-views', {description})).views, async upload(file) { @@ -214,16 +223,19 @@ export function renderImageFailure(host, {error, report_id, stage, fitted} = {}, const anchor = doc.createElement('button'); anchor.type = 'button'; anchor.className = 'fv-action fv-action-secondary fv-action-report'; anchor.textContent = t(...REPORT_LABELS.download); anchor.title = t(...REPORT_LABELS.description); + const issue = issueButton(t, () => ({where: ['Image diagnostic report', '生图诊断报告'], message: t(...REPORT_LABELS.unavailable)})); + issue.hidden = true; anchor.onclick = async () => { if (anchor.disabled) return; const restoreFocus = doc.activeElement === anchor; + issue.hidden = true; anchor.disabled = true; anchor.textContent = t(...REPORT_LABELS.preparing); try { await saveReport(await fetchReport()); if (card.isConnected) showReportTip(bar, anchor, t, {mode: 'downloaded', submit: () => reportIssue(fetchReport, version(), {save: false}), onHide: () => anchor.focus()}); anchor.textContent = t(...REPORT_LABELS.download); - } catch { anchor.textContent = t(...REPORT_LABELS.unavailable); } + } catch { anchor.textContent = t(...REPORT_LABELS.unavailable); issue.hidden = false; } finally { anchor.disabled = false; if (restoreFocus && card.isConnected && doc.activeElement === doc.body) anchor.focus({preventScroll: true}); @@ -234,7 +246,7 @@ export function renderImageFailure(host, {error, report_id, stage, fitted} = {}, card.addEventListener('keydown', event => { if (event.key === 'Enter' || event.key === ' ') event.stopPropagation(); }); - bar.append(anchor); card.append(bar); + bar.append(anchor, issue); card.append(bar); return card; } export function imagePackUnavailableMessage(info, t) { @@ -517,8 +529,8 @@ export function imageJobStore(key, storage = globalThis.sessionStorage, now = Da } export function createImageGeneration({client, renderer, t, getDoc, getSelection, commit, el, button, segmented, - library = [], resolve = file => file, icon = () => '', onRatioChange, productVersion, fetchApi, resumeKey = null}) { - let mode = 'edit', prompt = '', mounted = null, references = []; + library = [], resolve = file => file, icon = () => '', onRatioChange, onPlace, productVersion, fetchApi, resumeKey = null}) { + let mode = 'edit', prompt = '', mounted = null, references = [], tier = 'standard', past = []; let promptRequired = false, attentionTimer = null; const copies = new Map(); // A reference is sent no larger than the largest picture the service makes. @@ -616,9 +628,62 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection } menu.append(summary, choices); refs.append(menu); } + // Every picture made on this machine: place one on the canvas, use it as a reference, delete it, + // or show the folder that holds them. + function historyMenu() { + const menu = el('details', null, 'fv-ie-menu fv-ie-gen-history'); + const summary = el('summary'); summary.innerHTML = icon('history', 16); + summary.title = t('History', '历史'); summary.setAttribute('aria-label', summary.title); + const list = el('div', null, 'fv-ie-menu-list'); + menu.append(summary, list); + const draw = () => { + list.replaceChildren(); + if (!past.length) { list.append(el('div', t('No images generated yet', '还没有生成过图片'), 'fv-ie-menu-label')); return; } + const grid = el('div', null, 'fv-ie-gen-history-grid'); + for (const item of past) { + const tile = el('div', null, 'fv-ie-gen-history-tile'); + const place = button('', item.name, () => { menu.open = false; onPlace?.(item.file, item.name); }, 'fv-ie-gen-history-pick'); + const picture = el('img'); picture.src = resolve(item.file); picture.alt = item.name; picture.loading = 'lazy'; + place.append(picture); tile.append(place); + if (mode === 'edit' && getDoc().layers.length && limit() > 1) { + const use = button('+', t('Use as reference', '用作参考图'), () => { + references = addReference(references, {file: item.file, label: item.name}, limit()); menu.open = false; update(); + }, 'fv-ie-gen-history-use'); + use.setAttribute('aria-label', use.title); tile.append(use); + } + let armed = null; + const remove = button('×', t('Delete', '删除'), async () => { + if (!armed) { + remove.textContent = t('Delete', '删除'); remove.classList.add('fv-ie-gen-history-confirm'); + armed = setTimeout(() => { armed = null; remove.textContent = '×'; remove.classList.remove('fv-ie-gen-history-confirm'); }, 3000); + return; + } + clearTimeout(armed); + try { past = await client.historyDelete(item.file); } catch { return; } + draw(); + }, 'fv-ie-gen-ref-remove fv-ie-gen-history-delete'); + remove.setAttribute('aria-label', remove.title); tile.append(remove); + grid.append(tile); + } + list.append(grid); + if (['localhost', '127.0.0.1', '[::1]'].includes(location.hostname)) + list.append(button(t('Open folder', '打开文件夹'), null, () => { void client.historyOpen().catch(() => {}); }, 'fv-ie-menu-item')); + }; + menu.ontoggle = async () => { + if (!menu.open) return; + try { past = await client.history(); } catch { past = []; } + draw(); + // Open upward when the list does not fit below, and keep it inside the window. + list.classList.remove('fv-ie-gen-history-up'); list.style.right = ''; + const box = list.getBoundingClientRect(); + if (box.bottom > innerHeight - 8 && menu.getBoundingClientRect().top > box.height) list.classList.add('fv-ie-gen-history-up'); + if (box.left < 8) list.style.right = (box.left - 8) + 'px'; + }; + return menu; + } function update() { if (!mounted?.bar.isConnected) return; - const {input, action, note, meter, fill, group, bar, refs, ratio} = mounted, state = session.state(); + const {input, action, note, meter, fill, group, bar, refs, ratio, size} = mounted, state = session.state(); const missing = !state.running && state.info && !state.info.installed; const disabled = state.running || state.checking || !state.info?.installed; const empty = !getDoc().layers.length; @@ -627,6 +692,10 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection if (ratio) { ratio.hidden = !empty; ratio.disabled = state.running; ratio.value = BLANK_CANVAS_RATIOS.includes(getDoc().ratio) ? getDoc().ratio : blankCanvasRatio(getDoc()); + const tiers = state.info?.sizes?.includes('high') ? ['standard', 'high'] : ['standard']; + if (!tiers.includes(tier)) tier = 'standard'; + size.hidden = !empty || tiers.length < 2; size.set(tier); + size.querySelectorAll('button').forEach(b => { b.disabled = state.running; }); } group.querySelectorAll('button').forEach(b => { b.disabled = disabled || (empty && b.dataset.value === 'edit'); @@ -673,24 +742,30 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection bar.classList.add('fv-ie-generation'); const group = segmented([['edit', t('Edit this', '改这张')], ['new', t('New image', '生成一张')]], mode, value => { mode = value; update(); }, t('Generate with AI', 'AI 生成')).group; - let ratio = null; + let ratio = null, size = null, shape = null; if (!getDoc().layers.length) { + shape = el('div', null, 'fv-ie-gen-shape'); ratio = el('select', null, 'fv-ie-select'); ratio.id = 'fv-ie-gen-ratio'; ratio.setAttribute('aria-label', t('Shape', '比例')); for (const value of BLANK_CANVAS_RATIOS) { const option = el('option', value); option.value = value; ratio.append(option); } - ratio.onchange = () => { - if (!getDoc().layers.length && !session.state().running) onRatioChange?.(ratio.value); + const seg = segmented([['standard', t('Standard', '标准')], ['high', t('HD', '高清')]], tier, + value => { tier = value; changed(); }, t('Size', '尺寸')); + size = seg.group; size.id = 'fv-ie-gen-size'; size.set = seg.set; + const changed = () => { + if (!getDoc().layers.length && !session.state().running) onRatioChange?.(ratio.value, tier); update(); }; + ratio.onchange = changed; + shape.append(ratio, size); } const field = el('div', null, 'fv-ie-gen-field'); const refs = el('div', null, 'fv-ie-gen-refs'); refs.setAttribute('role', 'list'); refs.setAttribute('aria-label', t('Reference images', '参考图')); const box = el('div', null, 'fv-ie-gen-box'); const input = el('textarea', null, 'fv-ie-gen-prompt'); input.rows = 1; input.maxLength = 2000; input.value = prompt; - box.append(input); field.append(refs, box); + box.append(input); field.append(refs, box, historyMenu()); const note = el('div', '', 'fv-ie-opt-note fv-ie-gen-note'); note.setAttribute('role', 'status'); const action = button(t('Generate', '生成'), null, () => { const now = session.state(), pack = !now.running && now.info && !now.info.installed ? packView(now.info, t) : null; @@ -701,9 +776,9 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection meter.setAttribute('role', 'progressbar'); meter.setAttribute('aria-label', t('Generate with AI', 'AI 生成')); meter.setAttribute('aria-valuemin', '0'); meter.setAttribute('aria-valuemax', '100'); bar.append(group); - if (ratio) bar.append(ratio); + if (shape) bar.append(shape); bar.append(field, note, action, meter); - mounted = {bar, input, action, note, meter, fill, group, refs, ratio}; + mounted = {bar, input, action, note, meter, fill, group, refs, ratio, size}; const grow = () => { input.style.height = '0px'; input.style.height = (prompt.trim() ? Math.min(80, Math.max(32, input.scrollHeight + 2)) : 32) + 'px'; diff --git a/web/issue_button.js b/web/issue_button.js new file mode 100644 index 00000000..0eeaf64b --- /dev/null +++ b/web/issue_button.js @@ -0,0 +1,69 @@ +// The "Report a problem" button that sits beside a failure message: one component for every place that +// shows one. A click opens the GitHub feedback form with the error sentence already in it; the report +// and typed text never travel (the message goes through the same redaction as the failure card's report). +import {createErrorReport} from './error_panel.js'; + +const MESSAGE = 300, TAIL = 1500; +const ISSUE_FORM = 'https://github.com/FlashML-org/FreeVideo/issues/new'; + +/** + * Title, text and summary for the prefilled form. `where` is [English, Chinese] words for the place + * ("Export", "导出"); `message` is the sentence the user sees. + */ +export function issueDraft({where, message, kind = null}, t = en => en) { + const place = Array.isArray(where) ? where : [String(where || 'FreeVideo'), String(where || 'FreeVideo')]; + const clean = createErrorReport({exception_message: String(message || '')}).message; + const lines = clean.split(/\r?\n/).filter(line => line.trim()); + // A traceback ends with the line that names the error; any other message starts with it. + const first = (/^Traceback/.test(lines[0] || '') ? lines[lines.length - 1] : lines[0]) || ''; + const text = [t('FreeVideo showed an error.', 'FreeVideo 显示了一个错误。'), + `Where: ${place[0]}`, `Error: ${first.trim().slice(0, MESSAGE)}`, ...(kind ? [`Kind: ${kind}`] : [])].join('\n'); + const tail = clean.length <= TAIL ? clean : clean.slice(0, 500) + '\n...\n' + clean.slice(-(TAIL - 505)); + return {title: `[Feedback] ${t(place[0], place[1])}`, text, summary: JSON.stringify({where: place[0], kind, tail})}; +} + +// Browser-only modules, loaded as the button appears so the click opens the page at once +// (a page opened after waiting can be blocked as a pop-up). +let modules = null; +const load = () => modules || (modules = Promise.all([import('./feedback.js'), import('./updates.js')]) + .then(([feedback, updates]) => ({feedback, updates})).catch(() => null)); + +function icon() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('focusable', 'false'); + const path = document.createElementNS(svg.namespaceURI, 'path'); + path.setAttribute('d', 'M13.5 2.5h-11a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h2v3l3.5-3h5.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1Z'); + svg.append(path); + return svg; +} + +let sheet = null; +function style() { + if (sheet?.parentNode) return; + sheet = document.createElement('style'); sheet.id = 'freevideo-issue-button-style'; + sheet.textContent = `.fv-issue-button{display:inline-flex;align-items:center;gap:6px;min-height:var(--fv-h-sm,32px);box-sizing:border-box;border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-sm,8px);background:transparent;color:var(--fv-muted,#bbb);padding:4px 11px;font:inherit;font-size:13px;cursor:pointer} +.fv-issue-button:hover{color:var(--fv-ink,#eee);border-color:var(--fv-ink,#eee)}.fv-issue-button svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linejoin:round} +.fv-issue-button[hidden]{display:none!important}`; + document.head.append(sheet); +} + +/** The button. `source` is {where, message, kind} or a function returning it when clicked (null: nothing to send). */ +export function issueButton(t, source, {className = '', open = (url, features) => globalThis.open?.(url, '_blank', features)} = {}) { + style(); + const button = document.createElement('button'); button.type = 'button'; + button.className = `fv-issue-button ${className}`.trim(); + const label = t('Report a problem', '提交问题'); + const text = document.createElement('span'); text.textContent = label; + button.append(icon(), text); + load(); + button.onclick = async () => { + const value = typeof source === 'function' ? source() : source; + if (!value) return; + const ready = await load(); + const draft = issueDraft(value, t); + // Modules that did not load still leave the blank form reachable. + if (!ready) return open(`${ISSUE_FORM}?${new URLSearchParams({template: 'feedback.yml', title: draft.title, feedback: draft.text})}`, 'noopener'); + open(ready.feedback.feedbackIssueURL(draft.text, ready.updates.productVersion(), draft.summary, draft.title), 'noopener'); + }; + return button; +} diff --git a/web/library.css b/web/library.css index bebe158d..c3474247 100644 --- a/web/library.css +++ b/web/library.css @@ -72,3 +72,4 @@ .fv-library .fv-library-undo button{min-height:28px;padding:2px 10px;font-size:13px;color:var(--fv-accent)} .fv-library .fv-library-skipped{flex-basis:100%;font-size:12px;color:var(--fv-muted)} @media(max-width:680px){.fv-library-tools{gap:0}.fv-library .fv-library-header{grid-template-columns:auto minmax(0,1fr) auto}.fv-library-header h2{text-align:center}.fv-library-header button{white-space:nowrap}.fv-studio.fv-library .fv-library-header button{padding-left:6px;padding-right:6px}} +.fv-library-message .fv-issue-button{margin:4px 0 4px 8px;vertical-align:middle} diff --git a/web/library.js b/web/library.js index 98814f01..957bc7d0 100644 --- a/web/library.js +++ b/web/library.js @@ -4,6 +4,7 @@ import { resultActions, previewBadge, icon } from './result_actions.js'; import { videoUnavailableText } from './studio_extend.js'; import { topDialog } from './dialog_keys.js'; import { isNetworkTypeError, connectionLostText } from './browser_extension.js'; +import { issueButton } from './issue_button.js'; const style = document.createElement('link'); style.rel = 'stylesheet'; style.href = new URL('./library.css', import.meta.url).href; document.head.append(style); @@ -165,6 +166,10 @@ async function workflowBackfill(collection, t) { export function openLibrary(t, {upscale = null, selectedWork = null, extend = null, back: backLabel = null} = {}) { if (opened?.open) { if (selectedWork) opened.close(); else { opened.focus(); return; } } const dialog = el('dialog', null, 'fv-studio fv-library'); opened = dialog; + const fault = (target, text, where = ['Creations', '作品库']) => { + target.textContent = text; + target.append(issueButton(t, () => ({where, message: text}))); + }; dialog.setAttribute('aria-label', t('Your creations', '我的作品')); const header = el('header', null, 'fv-library-header'); // Closing returns to the view this was opened from; that view names itself on the button. @@ -243,6 +248,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu player.addEventListener('error', event => { if (!disposed && selected === row.video && event.currentTarget === player) { const note = el('p', videoUnavailableText(t), 'fv-library-message'); + fault(note, videoUnavailableText(t)); frame.replaceChildren(note); } }, {once: true}); @@ -264,7 +270,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu if (editor && !disposed) { dialog.close(); editor.element?.focus({preventScroll: true}); } return editor; } - catch (error) { notice.textContent = error.message; } + catch (error) { fault(notice, error.message, ['Continue video', '延长视频']); } }, secondPass: row.preview && upscale ? () => secondPass(row) : null, remove: () => remove(row)})); @@ -297,7 +303,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu await upscale(row); if (!disposed && selected === row.video) notice.textContent = t('Queued. It appears in "Creations" when done.', '已加入队列,完成后在“作品”里。'); } catch (error) { - if (!disposed && selected === row.video) notice.textContent = t('Could not start the second pass: ', '无法继续二采:') + error.message; + if (!disposed && selected === row.video) fault(notice, t('Could not start the second pass: ', '无法继续二采:') + error.message, ['Second pass', '二采']); throw error; } } @@ -314,13 +320,15 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu if (!response.ok || !value.deleted) throw new Error(value.reason || 'failed'); } catch (error) { if (!disposed && selected === row.video) mountPlayer(row); - throw new Error(error.message === 'foreign-files' + throw Object.assign(new Error(error.message === 'foreign-files' ? t('Not deleted: the folder also holds files FreeVideo did not write: ', '未删除:文件夹里还有不是 FreeVideo 生成的文件:') + (value.kept || []).join(', ') : error.message === 'editor-in-use' ? t('Not deleted: these edits use it: ', '未删除:这些剪辑正在使用它:') + (value.kept || []).join(', ') : error.message === 'editor-unavailable' ? t('Not deleted: saved edits could not be checked. Try again in a moment.', '未删除:暂时无法检查已保存的剪辑,请稍后再试。') : error.message === 'continuation-in-use' ? t('Not deleted: a video continuing from it is running or queued. Try again after it finishes.', '未删除:接在这条视频后面的生成正在运行或排队,请等它完成后再试。') : error.message === 'in-use' ? t('Not deleted: its second pass is running, or the video is still open. Try again in a moment.', '未删除:正在用它跑二采,或视频仍被占用,请稍后再试。') - : t('Could not delete this creation.', '无法删除这条作品。')); + : t('Could not delete this creation.', '无法删除这条作品。')), { + reportable: !['foreign-files', 'editor-in-use', 'continuation-in-use', 'in-use'].includes(error.message), + }); } // Other views of the library (the studio's Recent videos) drop it too. window.dispatchEvent(new CustomEvent('freevideo-library-deleted', {detail: {video: row.video}})); @@ -470,6 +478,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu if (disposed) return; const live = offeredUndoRows(), deletedCount = live.reduce((sum, row) => sum + row.count, 0); const notes = [undoNote ? el('span', undoNote, 'fv-library-skipped') : null, undoFailure ? el('span', undoFailure, 'fv-library-skipped') : null].filter(Boolean); + if (undoFailure) notes.push(issueButton(t, () => ({where: ['Delete or restore creations', '删除或恢复作品'], message: undoFailure}))); const settle = () => { if (undoRows().length) { showUndo(); return; } undoNote = null; undoFailure = ''; undoBar.replaceChildren(); undoBar.hidden = true; }; undoBar.replaceChildren(); undoBar.hidden = !deletedCount && !notes.length; if (!deletedCount) { undoBar.append(...notes); return; } @@ -500,6 +509,10 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu undoFailure = ''; undoBar.replaceChildren(el('span', expired ? t('The undo time has passed.', '已过了撤销时间。') : lost ? t(`Restored, but ${lost} could not be brought back.`, `已恢复,其中 ${lost} 个没能恢复。`) : t('Restored.', '已恢复。'))); + if (lost && !expired) { + const message = undoBar.firstElementChild.textContent; + undoBar.append(issueButton(t, () => ({where: ['Restore creations', '恢复作品'], message}))); + } undoTimer = setTimeout(settle, 4000); load(false); }, 'fv-quiet'); @@ -661,7 +674,12 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu } else select(rows.get(selected), false); ok = true; regroup(); if (selectedWork) { const row = [...rows.values()].find(row => row.id === selectedWork || row.video === selectedWork); if (row) { select(row); selectedWork = null; } else if (next) queueMicrotask(() => load(true)); } - } catch (error) { if (!disposed && error.name !== 'AbortError') message.textContent = isNetworkTypeError(error) ? connectionLostText(t) : error.message; } + } catch (error) { + if (!disposed && error.name !== 'AbortError') { + if (error.restart) message.textContent = error.message; + else fault(message, isNetworkTypeError(error) ? connectionLostText(t) : error.message); + } + } finally { if (!disposed) { loading = false; refresh.disabled = more.disabled = false; } } return ok; } diff --git a/web/optional_inputs.js b/web/optional_inputs.js index b145858a..7f3404a1 100644 --- a/web/optional_inputs.js +++ b/web/optional_inputs.js @@ -2,12 +2,14 @@ // shape. A workflow restored from an older version, or a widget list that moved, // can leave an empty or missing value in them, and Comfy then rejects the whole // request. Keep each of them to a value the server accepts. -const DEFAULTS = {refine_from: '', continue_from: '', frames: 0, first_frame_reference: false, step_cache: 'none', upscale_factor: '2'}; +const DEFAULTS = {refine_from: '', continue_from: '', frames: 0, first_frame_reference: false, force_regenerate: false, preview: false, voice_reference: false, hide_tail: false, step_cache: 'none', upscale_factor: '2'}; // A workflow saved before Step cache existed holds '' where it now sits. const STEP_CACHE_OPTIONS = ['none', 'easycache-av', 'fbcache']; // Likewise for Upscale factor, appended after Step cache. const UPSCALE_OPTIONS = ['2', '1.5', '1.2']; +const BOOLEANS = new Set(['first_frame_reference', 'force_regenerate', 'preview', 'voice_reference', 'hide_tail']); + // The words a script, an older workflow or String(null) leaves behind for "not given". const EMPTY_WORDS = new Set(['none', 'null', 'undefined', 'nan']); @@ -27,7 +29,7 @@ export function frameCount(value) { export function optionalInputValue(name, value) { if (name === 'frames') return frameCount(value); if (name === 'refine_from' || name === 'continue_from') return optionalText(value); - if (name === 'first_frame_reference') return value === true; + if (BOOLEANS.has(name)) return value === true; if (name === 'step_cache') return STEP_CACHE_OPTIONS.includes(value) ? value : 'none'; if (name === 'upscale_factor') return UPSCALE_OPTIONS.includes(value) ? value : '2'; return value; diff --git a/web/prompt_enhance.css b/web/prompt_enhance.css index 30e08f2a..4c243910 100644 --- a/web/prompt_enhance.css +++ b/web/prompt_enhance.css @@ -17,6 +17,7 @@ .fv-enhance-status{display:none;padding:9px 12px 10px;line-height:1.6;border-top:1px solid #ffffff08;font-size:11px} .fv-enhance-status:is([data-state=progress],[data-state=error],[data-state=note]){display:block} .fv-enhance-status[data-state=error]{color:var(--fv-danger,#ff8a80);background:color-mix(in srgb,var(--fv-danger,#ff8a80) 7%,transparent)} +.fv-enhance-status .fv-issue-button{min-height:26px;margin-top:7px;padding:3px 9px;font-size:11px} .fv-enhance-meter{position:relative;height:3px;margin-top:7px;border-radius:2px;background:#ffffff12;overflow:hidden} .fv-enhance-meter>i{position:absolute;inset:0 auto 0 0;width:0;border-radius:inherit;background:var(--fv-accent,#6db8fa);transition:width .4s ease-out} .fv-enhance-meter[data-indeterminate=true]>i{width:32%;animation:fv-enhance-sweep 1.4s ease-in-out infinite} diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 28d21683..3193315c 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -65,7 +65,7 @@ export function promptPrestart(api, nodeId, enabled, alive = () => true) { // An empty box is never rewritten, the untouched starter prompt included: // enhancement waits for the user's own scene. // `prepare` turns the user's text into what the model reads (character mentions written out with their pictures). -export function createPromptEnhancer({node, input, editor, api, context, setText, t, starter = '', prepare = text => text, onReport = () => {}, onChange = () => {}}) { +export function createPromptEnhancer({node, input, editor, api, context, setText, t, starter = '', prepare = text => text, onReport = () => {}, onChange = () => {}, issueButton = null}) { const saved = node.properties?.freevideo_prompt_versions; // Drafts saved before the starter became a placeholder hold it as the original. const versions = new PromptVersions(starter && saved?.original === starter ? {...saved, original: ''} : saved, input.value); @@ -91,11 +91,15 @@ export function createPromptEnhancer({node, input, editor, api, context, setText const statusText = el('span', null, 'fv-enhance-status-text'); const meter = el('div', null, 'fv-enhance-meter'), fill = el('i'); meter.setAttribute('role', 'progressbar'); meter.setAttribute('aria-valuemin', '0'); meter.setAttribute('aria-valuemax', '100'); - meter.append(fill); status.append(statusText, meter); + // The shared "Report a problem" button, handed in by the page (this file loads without imports). + const report = issueButton ? issueButton(t, () => ({where: ['Prompt enhancement', '提示词增强'], message: statusText.textContent})) : document.createElement('span'); + report.hidden = true; + meter.append(fill); status.append(statusText, meter, report); // One place sets what the status line says and how it looks. function show(text, state = '', fraction = null) { statusText.textContent = text || ''; status.dataset.state = text ? state : ''; + report.hidden = !text || state !== 'error'; meter.hidden = state !== 'progress'; meter.dataset.indeterminate = String(fraction === null); if (fraction === null) { meter.removeAttribute('aria-valuenow'); fill.style.width = ''; } diff --git a/web/result_actions.js b/web/result_actions.js index bfa1eb62..2a22572c 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -2,6 +2,7 @@ import { api } from '../../scripts/api.js'; import { outputDownloadURL } from './output_download.js'; import { shareButton } from './share.js'; import { canExtend, extendText, extendSourceExists } from './studio_extend.js'; +import { issueButton } from './issue_button.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./result_actions.css', import.meta.url).href; document.head.append(css); @@ -62,16 +63,20 @@ function popover(menu, summary, onClose = () => {}) { export function secondPassAction(t, run) { const button = action('button', t('Run second pass', '继续二采'), 'expand', 'fv-action-primary fv-action-second-pass'); const label = button.lastElementChild; + let problem; button.title = t('Continue from this preview to the full-resolution video.', '从这个预览继续生成全分辨率视频。'); button.onclick = async () => { if (button.disabled) return; + problem?.remove(); button.disabled = true; button.setAttribute('aria-busy', 'true'); label.textContent = button.title = t('Adding…', '正在添加…'); try { await run(); label.textContent = button.title = t('Queued', '已加入队列'); } catch { button.disabled = false; label.textContent = button.title = t('Second pass did not start · Retry', '二采没有开始 · 重试'); + problem = issueButton(t, () => ({where: ['Second pass', '二采'], message: label.textContent})); + button.after(problem); } finally { button.removeAttribute('aria-busy'); } }; return button; @@ -89,20 +94,28 @@ function deleteAction(t, remove) { hint.textContent = t('The video and its files will be deleted.', '视频和相关文件都会删除。'); const problem = document.createElement('small'); problem.className = 'fv-confirm-problem'; problem.hidden = true; + let problemIssue; const row = document.createElement('div'); row.className = 'fv-confirm-actions'; const cancel = action('button', t('Cancel', '取消'), null, 'fv-action-secondary'); const confirm = action('button', t('Delete', '删除'), 'trash', 'fv-action-danger'); cancel.onclick = () => { menu.open = false; summary.focus(); }; confirm.onclick = async () => { + problem.hidden = true; problemIssue?.remove(); confirm.disabled = cancel.disabled = true; confirm.lastElementChild.textContent = t('Deleting…', '正在删除…'); try { await remove(); menu.open = false; } - catch (error) { problem.textContent = error.message; problem.hidden = false; } + catch (error) { + problem.textContent = error.message; problem.hidden = false; + if (error.reportable !== false) { + problemIssue = issueButton(t, () => ({where: ['Delete creation', '删除作品'], message: problem.textContent})); + problem.after(problemIssue); + } + } finally { confirm.disabled = cancel.disabled = false; confirm.lastElementChild.textContent = t('Delete', '删除'); } }; row.append(cancel, confirm); panel.append(title, hint, problem, row); menu.append(summary, panel); - popover(menu, summary, () => { problem.hidden = true; }); + popover(menu, summary, () => { problem.hidden = true; problemIssue?.remove(); }); menu.addEventListener('toggle', () => { if (menu.open) cancel.focus(); }); return menu; } @@ -133,13 +146,21 @@ export function resultActions(record, t, {diagnostic = null, secondPass = null, if (extend && canExtend(record)) { const button = action('button', extendText(t, 'action'), 'extend', 'fv-action-secondary fv-action-extend'); const label = button.lastElementChild; + let problem; + const fault = () => { + problem?.remove(); + problem = issueButton(t, () => ({where: ['Continue video', '延长视频'], message: label.textContent})); + button.after(problem); + }; const available = exists => { label.textContent = exists ? extendText(t, 'action') : t('Video unavailable · retry', '视频不可用 · 重试'); button.title = ''; + if (exists) problem?.remove(); else fault(); }; extendSourceExists(api, record).then(available); button.onclick = async () => { if (button.disabled) return; + problem?.remove(); button.disabled = true; button.title = t('Checking video…', '正在检查视频…'); try { @@ -149,6 +170,7 @@ export function resultActions(record, t, {diagnostic = null, secondPass = null, } catch (error) { label.textContent = error.message || t('Could not open the editor. Try again.', '无法打开剪辑,请重试。'); button.title = label.textContent; + fault(); } finally { button.disabled = false; } }; bar.append(button); @@ -163,11 +185,17 @@ export function resultActions(record, t, {diagnostic = null, secondPass = null, const report = action('button', reportLabel, 'report', 'fv-action-secondary fv-action-report'); report.title = t('Diagnostic report: hardware, settings and timings', '诊断报告:硬件、设置和耗时'); const text = report.lastElementChild; + let problem; report.onclick = async () => { if (report.disabled) return; + problem?.remove(); report.disabled = true; text.textContent = t('Preparing…', '正在整理…'); try { await diagnostic(); text.textContent = reportLabel; } - catch { text.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); } + catch { + text.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); + problem = issueButton(t, () => ({where: ['Diagnostic report', '诊断报告'], message: text.textContent})); + report.after(problem); + } finally { report.disabled = false; } }; bar.append(report); diff --git a/web/setup.css b/web/setup.css index 2fff248b..db8bed7a 100644 --- a/web/setup.css +++ b/web/setup.css @@ -97,3 +97,4 @@ .fv-setup .fv-folder-save .fv-folder-path{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .fv-setup .fv-folder-save p{flex:1 1 240px;min-width:0;margin:0;padding-top:7px;line-height:1.6} @media(max-width:480px){.fv-setup{padding:0 16px 18px}.fv-setup-head{margin:0 -16px 8px;padding:14px 12px 12px 16px}.fv-setup-head .fv-wordmark{width:92px}.fv-setup>details,.fv-setup>section{padding:14px}} +.fv-setup p .fv-issue-button{margin:4px 0 4px 8px;vertical-align:middle} diff --git a/web/setup.js b/web/setup.js index 05d27033..e775cfa4 100644 --- a/web/setup.js +++ b/web/setup.js @@ -3,6 +3,7 @@ import { wordmark } from './wordmark.js'; import { openCompatibility } from './compatibility.js'; import { animateDetails, closeDialog } from './motion.js'; import { createGuideReplayEntry } from './first_run_guide.js'; +import { issueButton } from './issue_button.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./setup.css', import.meta.url).href; @@ -16,6 +17,7 @@ const t = (en, zh) => cn ? zh : en; const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好。要重装,请先点“检测并预览”。'}; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; const SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".'; +const reportable = value => !/The installation plan changed\. Detect configuration again|MODEL_MISMATCH_REPAIR:/.test(String(value || '')); const failureText = value => { const text = String(value || ''), matches = [...text.matchAll(/MODEL_MISMATCH_REPAIR: ([^\r\n]+)/g)]; if (!matches.length && text.includes('The installation plan changed. Detect configuration again')) { @@ -40,6 +42,10 @@ export async function openSetup() { dialog.className = "fv-setup"; const node = (tag, label) => { const e = document.createElement(tag); if (label) e.textContent = label; return e; }; const note = label => { const e = node("p", label); e.className = "fv-muted"; return e; }; + const fault = (target, message) => { + const text = failureText(message); target.textContent = text; + if (text && reportable(message)) target.append(issueButton(t, () => ({where: ['Settings / installation', '设置/安装'], message: text}))); + }; const heading = node('header'); heading.className = 'fv-setup-head'; const dismiss = node('button', '×'); dismiss.type = 'button'; dismiss.className = 'fv-close'; dismiss.setAttribute('aria-label', t('Close', '关闭')); dismiss.onclick = () => closeDialog(dialog); @@ -167,7 +173,7 @@ export async function openSetup() { let token, planId, busy = false, probeBusy = false, pollTimer, lastPlanId, planErrors = false, planStale = false, ready = false, waitSince; // A click names what is happening at once, before the server has answered. const doing = (en, zh) => { waitSince ??= Date.now(); state.className = ''; state.textContent = t(en, zh); }; - const fail = error => { waitSince = undefined; installation.open = true; state.textContent = failureText(error.message); state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; + const fail = error => { waitSince = undefined; installation.open = true; fault(state, error.message); state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; const request = async (action, value) => { const response = await api.fetchApi("/freevideo/setup" + (action ? "/" + action : root.value ? '?root='+encodeURIComponent(root.value) : ""), action ? { method: "POST", headers: { "Content-Type": "application/json", "X-FreeVideo-Setup": token }, body: JSON.stringify(value || {}), @@ -205,7 +211,7 @@ export async function openSetup() { cancel.scrollIntoView({block: 'center'}); } catch (error) { calibrateStatus.className = 'fv-failure'; - calibrateStatus.textContent = failureText(error.message); + fault(calibrateStatus, error.message); } finally { calibrateButton.disabled = false; } }; saveReserve.onclick = async () => { @@ -221,7 +227,7 @@ export async function openSetup() { try { showResources(await request('resources', {[reserveField]: reserveAuto.checked ? null : Number(reserve.value)})); reserveStatus.textContent = t('Saved · applies to the next request', '已保存 · 下次生成生效'); - } catch (error) { reserveStatus.className = 'fv-failure'; reserveStatus.textContent = failureText(error.message); } + } catch (error) { reserveStatus.className = 'fv-failure'; fault(reserveStatus, error.message); } finally { saveReserve.disabled = false; } }; function showDownloads(value) { @@ -237,6 +243,7 @@ export async function openSetup() { sourceStatus.textContent = failureText(value.probe?.error) || (probeBusy ? t('Testing sources… ','正在测速… ') + `${progress?.done || 0}/${progress?.total || '…'}` : ''); const measured = value.probe?.status === 'complete' ? value.probe : value.preferences?.probe; if (measured?.network_unavailable && !probeBusy) sourceStatus.textContent = t('No sources reachable. Check your network or connection mode.', '下载源均无法连接,请检查网络或切换连接模式。'); + if (value.probe?.error || measured?.network_unavailable && !probeBusy) fault(sourceStatus, sourceStatus.textContent); speeds.replaceChildren(); if (!measured?.sources) return; const table = node('table'); table.className = 'fv-speed'; @@ -342,6 +349,7 @@ export async function openSetup() { const p = task.progress || {}; state.className = task.status === "failed" ? "fv-failure" : task.ready && !savedMissing ? "fv-ready" : ""; state.textContent = failureText(task.error) || (task.ready ? t("Ready to generate.", "准备就绪,可以开始生成。") : busy ? p.label || t("Checking configuration and local models…", "正在检查配置和本地模型…") : task.status === "complete" && task.action === "plan" ? (planId ? t("Review the plan, then install.", "请确认计划后安装。") : "") : task.status === "cancelled" ? t("Stopped. Files retained for resuming.", "已停止,文件保留,可继续安装。") : ""); + if (task.error) fault(state, task.error); const phases = task.phase_progress || {}, valid = Number.isFinite(p.total) && p.total > 0 && Number.isFinite(p.done) && p.done >= 0 && p.done <= p.total; overall.hidden = !busy; overallBar.max = phases.total > 0 ? phases.total : 1; overallBar.value = phases.done || 0; @@ -381,10 +389,10 @@ export async function openSetup() { } saveFolders.onclick = async () => { invalidate(true); busy = true; controls(); - const before = [state.textContent, state.className]; doing('Saving model folders…', '正在保存模型目录…'); + const before = [[...state.childNodes], state.className]; doing('Saving model folders…', '正在保存模型目录…'); try { await saveModelFolders(); } - catch (error) { savedFolders = ''; savedMissing = false; folderStatus.textContent = failureText(error.message); folderStatus.className = 'fv-failure'; } - finally { busy = false; waitSince = undefined; [state.textContent, state.className] = before; controls(); } + catch (error) { savedFolders = ''; savedMissing = false; fault(folderStatus, error.message); folderStatus.className = 'fv-failure'; } + finally { busy = false; waitSince = undefined; state.replaceChildren(...before[0]); state.className = before[1]; controls(); } }; inspect.onclick = async () => { invalidate(); busy = true; controls(); doing('Checking installation…', '正在检查安装…'); diff --git a/web/share.css b/web/share.css index 67fc8a47..9105e639 100644 --- a/web/share.css +++ b/web/share.css @@ -12,7 +12,7 @@ .fv-share-preview{position:relative;align-self:start;width:auto;max-width:100%;max-height:calc(100dvh - 256px);box-shadow:0 18px 50px #0007,0 0 0 1px #ffffff14;overflow:hidden;border-radius:14px} .fv-share-preview canvas{position:relative;display:block;max-width:100%;max-height:calc(100dvh - 256px);width:auto;height:auto;pointer-events:none} .fv-share-preview video{position:absolute;object-fit:contain;background:#000}.fv-share-preview video[hidden]{display:none} -.fv-share-footer{display:flex;flex-shrink:0;align-items:center;justify-content:flex-end;gap:12px;padding:18px 26px;border-top:1px solid var(--fv-border)} +.fv-share-footer{display:flex;flex-wrap:wrap;flex-shrink:0;align-items:center;justify-content:flex-end;gap:12px;padding:18px 26px;border-top:1px solid var(--fv-border)} .fv-share-status{font-size:12px;color:var(--fv-muted);margin-right:auto;max-width:65%} .fv-share[data-busy=true] .fv-share-status:before{content:'';display:inline-block;width:10px;height:10px;margin-right:8px;border:1.5px solid #fff3;border-top-color:var(--fv-accent);border-radius:50%;animation:fv-share-spin .8s linear infinite} .fv-share[data-error=true] .fv-share-status{color:var(--fv-danger)} diff --git a/web/share.js b/web/share.js index cbe45c21..7b66491a 100644 --- a/web/share.js +++ b/web/share.js @@ -4,6 +4,7 @@ import {effortName, resultEffort} from './sampling_effort.js'; import {fontCSS as bundledFontCSS, fontLoaded, loadFont} from './fonts.js'; import {shareLayout} from './share_layout.js'; import {videoUnavailableText} from './studio_extend.js'; +import {issueButton} from './issue_button.js'; const css=document.createElement('link'); css.rel='stylesheet'; css.href=new URL('./share.css',import.meta.url).href; document.head.append(css); const el=(tag,cls,text)=>{const e=document.createElement(tag);if(cls)e.className=cls;if(text)e.textContent=text;return e;}; @@ -104,13 +105,15 @@ export async function openShare(record,t){ player.src=api.apiURL('/view?'+new URLSearchParams({filename:record.video.slice(split+1),subfolder:record.video.slice(0,split),type:'output'})); preview.append(player,canvas);panel.append(preview); const footer=el('footer','fv-share-footer'),message=el('span','fv-share-status');message.setAttribute('role','status'); + const problem=issueButton(t,()=>({where:['Share','分享'],message:message.textContent}));problem.hidden=true; + const say=(text,fault=false)=>{message.textContent=text;problem.hidden=!fault||!text;if(fault&&text)dialog.dataset.error='true';else delete dialog.dataset.error;}; const save=button(t('Save image','保存分享图'),download,'fv-primary');save.disabled=true; const cancel=button(t('Cancel','取消'),()=>exportRequest?.abort());cancel.hidden=true; - footer.append(message,cancel,save);heading.append(title,tabs,close);dialog.append(heading,panel,footer); + footer.append(message,problem,cancel,save);heading.append(title,tabs,close);dialog.append(heading,panel,footer); const videoError=()=>{ if(disposed)return; videoFailed=true; - if(kind==='video'&&!busy){message.textContent=videoUnavailableText(t);dialog.dataset.error='true';} + if(kind==='video'&&!busy)say(videoUnavailableText(t),true); }; player.addEventListener('error',videoError); const choose=next=>{ @@ -121,8 +124,7 @@ export async function openShare(record,t){ save.textContent=kind==='image'?t('Save image','保存分享图'):t('Save video','保存分享视频'); slide(); if(!ready)return; - message.textContent=kind==='video'&&videoFailed?videoUnavailableText(t):''; - if(kind==='video'&&videoFailed)dialog.dataset.error='true';else delete dialog.dataset.error; + say(kind==='video'&&videoFailed?videoUnavailableText(t):'',kind==='video'&&videoFailed); drawShareCard(canvas,kind==='image'?first:null,logo,metadata,t); player.hidden=kind!=='video'; if(kind==='video')player.play().catch(error=>{if(!['AbortError','NotAllowedError'].includes(error.name))videoError();});else player.pause(); @@ -138,7 +140,7 @@ export async function openShare(record,t){ async function download(){ if(!ready||busy)return; busy=true;save.disabled=true;cancel.hidden=kind==='image';tabs.querySelectorAll('button').forEach(b=>b.disabled=true); - message.textContent=kind==='image'?t('Saving the share image…','正在保存分享图…'):t('Exporting the share video…','正在导出分享视频…'); + say(kind==='image'?t('Saving the share image…','正在保存分享图…'):t('Exporting the share video…','正在导出分享视频…')); dialog.dataset.busy='true';delete dialog.dataset.error; exportRequest=new AbortController(); try{ @@ -151,21 +153,19 @@ export async function openShare(record,t){ if(disposed)return; const url=URL.createObjectURL(blob),a=el('a'); a.href=url;a.download=`FreeVideo_share_${id.split('/')[1].slice(0,12)}.png`;a.click(); - setTimeout(()=>URL.revokeObjectURL(url),60000);message.textContent='';return; + setTimeout(()=>URL.revokeObjectURL(url),60000);say('');return; } if(!exported){ const response=await api.fetchApi('/freevideo/share/video',{method:'POST',headers:{'Content-Type':'application/json'},signal:exportRequest.signal, body:JSON.stringify({id,template,rect:shape.rect})}); - if(response.status===409)throw new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后再试。')); + if(response.status===409)throw Object.assign(new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后再试。')),{reportable:false}); if(!response.ok)throw new Error(t('Export could not complete. Try again.','分享视频导出未完成,请重试。')); exported=await response.json(); } if(disposed)return; - const a=el('a');a.href=api.apiURL('/freevideo/share/video?'+new URLSearchParams(exported));a.download='';a.click();message.textContent=''; + const a=el('a');a.href=api.apiURL('/freevideo/share/video?'+new URLSearchParams(exported));a.download='';a.click();say(''); }catch(error){if(!disposed){ - message.textContent=kind==='image'?t('Could not save the share image. Try again.','分享图没有保存成功,请重试。') - :error.name==='AbortError'?'':error.message; - if(message.textContent)dialog.dataset.error='true'; + say(error.name==='AbortError'?'':kind==='image'?t('Could not save the share image. Try again.','分享图没有保存成功,请重试。'):error.message,error.reportable!==false); }} finally{busy=false;if(!disposed){save.disabled=false;cancel.hidden=true;delete dialog.dataset.busy;tabs.querySelectorAll('button').forEach(b=>b.disabled=false);}} } @@ -184,5 +184,5 @@ export async function openShare(record,t){ preview.style.aspectRatio=`${shape.width}/${shape.height}`; player.style.cssText=`left:${shape.rect[0]/shape.width*100}%;top:0;width:${shape.rect[2]/shape.width*100}%;height:${shape.rect[3]/shape.height*100}%`; ready=true;choose(kind);save.disabled=false; - }catch(error){if(!disposed){message.textContent=error.message||t('Preview unavailable. Close and try again.','预览暂时无法读取,请关闭后重试。');dialog.dataset.error='true';}} + }catch(error){if(!disposed)say(error.message||t('Preview unavailable. Close and try again.','预览暂时无法读取,请关闭后重试。'),true);} } diff --git a/web/studio.css b/web/studio.css index 360665d3..3eb8aefe 100644 --- a/web/studio.css +++ b/web/studio.css @@ -134,6 +134,7 @@ .fv-studio .fv-queue-chip{flex:none;color:var(--fv-accent)} .fv-studio .fv-queue-item button{flex:none} .fv-studio .fv-status{min-height:22px;text-align:center;font-size:var(--fv-micro);color:var(--fv-muted);overflow-wrap:anywhere}.fv-studio .fv-status[data-error=true]{color:var(--fv-danger)} +.fv-studio .fv-status>.fv-issue-button{display:flex;margin:8px auto 0} .fv-studio .fv-progress{width:100%;height:4px;accent-color:var(--fv-accent);border:0;margin:0 0 10px}.fv-studio .fv-budget,.fv-studio .fv-prewarm{text-align:center;font-size:var(--fv-micro);color:var(--fv-muted)} .fv-studio .fv-panel{height:auto;padding:0;border:none;border-radius:0;background:transparent;color:var(--fv-ink);font-size:var(--fv-micro);overflow:visible} .fv-studio .fv-panel .fv-toolbar{justify-content:flex-start}.fv-studio .fv-panel .fv-note{font-size:var(--fv-micro);margin:8px 0;text-align:left} diff --git a/web/studio.js b/web/studio.js index 3e732d49..ecc8c57e 100644 --- a/web/studio.js +++ b/web/studio.js @@ -13,6 +13,7 @@ import { agentInterfaceEnabled } from './agent_connect.js'; import { createUpdateNotice, createVersionInfo, productVersion } from './updates.js'; import { createPortNotice } from './port_notice.js'; import { reportIssue, reportTipClosed, showReportTip } from './report_issue.js'; +import { issueButton } from './issue_button.js'; import { createPreviewScene } from './preview_scene.js?v=20260929-swell'; import { animateDetails, closeDialog } from './motion.js'; import { openLibrary } from './library.js'; @@ -675,7 +676,7 @@ export function openStudio(node, {extendFrom = null} = {}) { target.field.value = JSON.stringify(withCharacter(rows, card, typed)); target.owner.graph?.change?.(); window.dispatchEvent(new CustomEvent('freevideo-media', {detail: target.owner.id})); - }).catch(() => { if (!disposed) { status.dataset.error = 'true'; status.textContent = t('Could not add the character\'s pictures', '无法加入角色的图片'); } }) + }).catch(() => { if (!disposed) fault(t('Could not add the character\'s pictures', '无法加入角色的图片')); }) .finally(() => castPending.delete(person.id)); return true; } @@ -705,7 +706,7 @@ export function openStudio(node, {extendFrom = null} = {}) { openCastLibrary({api, text: t, viewURL: view, uploadOne: castUpload, onChange: () => { castOverlay.refresh(); references.refresh(); }}) .then(library => { if (disposed) library?.destroy?.(); else castLibrary = library; }); let requestAssets = null; - const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, + const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, issueButton, prepare: text => expandCast(text, mediaRows(node), {continued: Boolean(extension.source)}).text, // The rewrite has its own report; the video's report entry is not touched. onReport: row => { node.freevideoPromptReportId = row.rewrite_report_id || row.report_id; }, @@ -1170,6 +1171,9 @@ export function openStudio(node, {extendFrom = null} = {}) { progress.update(message); } const status = el('div', '', 'fv-status'); status.setAttribute('role', 'status'); + // A fault in the status line carries the report button; the next text written to the line removes it. + const statusIssue = issueButton(t, () => ({where: ['Generate', '生成'], message: status.firstChild?.textContent}), {className: 'fv-status-issue'}); + function fault(text) { status.dataset.error = 'true'; status.textContent = text; status.append(statusIssue); } const stats = el('div', null, 'fv-stats'), budget = el('div', '', 'fv-budget'), links = el('div', null, 'fv-result-links'); stats.hidden = true; const metrics = [], metricLabels = []; @@ -1190,7 +1194,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // A browser extension gets the shared card; other failures stay in the status line. function resultActionFailed(error) { if (extensionCause(error)) { showQueueError(error); return; } - status.dataset.error = 'true'; status.textContent = shownMessage(error); + fault(shownMessage(error)); } const trimNote = el('div', '', 'fv-note fv-trim-note'); trimNote.hidden = true; trimNote.setAttribute('role', 'status'); // Run second pass is the preview's primary action in the result bar. @@ -1614,7 +1618,7 @@ export function openStudio(node, {extendFrom = null} = {}) { held = turnNodeOn(node); const snapshot = singleRunSnapshot(extendSnapshot(cleanOptionalPrompt(structuredClone(await app.graphToPrompt())), node.id, source, {multiple: lengthPlan?.count > 1}), node.id, lengthPlan); - if (!snapshot) { status.textContent = t('Could not prepare this run. Try again.', '没能准备这次生成,请重试。'); return; } + if (!snapshot) { fault(t('Could not prepare this run. Try again.', '没能准备这次生成,请重试。')); return; } const seedIndex = node.widgets.findIndex(w => w.name === 'seed'); capturing = false; for (const section of controls.querySelectorAll('.fv-section')) section.inert = false; diff --git a/web/studio_length.js b/web/studio_length.js index 05344b8f..5cfa1c35 100644 --- a/web/studio_length.js +++ b/web/studio_length.js @@ -1,4 +1,5 @@ import {capabilityPoints, interpolate} from './chain_support.js'; +import {issueButton} from './issue_button.js'; import {buildPlanRequest, readLengthPlan, classifyPlanError, singleLimitSeconds, defaultBlockSeconds} from './length_plan.js?v=20261011-length'; export const LENGTH_PROPERTIES = Object.freeze({seconds: 'freevideo_seconds', single: 'freevideo_single', block: 'freevideo_block_seconds'}); @@ -82,6 +83,7 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie hint.hidden = absent || (noParts && Boolean(error)); note.hidden = absent || !error; note.textContent = error ? errorMessage(error) : ''; + if (error === 'other') note.append(issueButton(t, () => ({where: ['Video length planning', '视频分段规划'], message: errorMessage('other')}))); note.style.margin = noParts && error === 'continuation_unavailable' ? '12px 0' : ''; // as spaced as the line it replaces } function errorMessage(kind) { diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index e21057c6..635ce7fa 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -33,8 +33,8 @@ export const TUTORIAL_CHAPTERS = [ en: ['Enter a longer duration. It is made in parts automatically. "Available sizes and lengths" shows how long each size can be and about how long it takes.', 'Choose "Generate video".', 'Choose "Extend" on a finished video to continue it.', 'Parts can also be added one by one in "Edit".'], }, limits: { - zh: ['一条视频最多 5 段,创作面板中时长最长 60 秒。', '分段生成和接着生成需要 NVIDIA 显卡,不支持 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], - en: ['A video has at most 5 parts, and the creation panel accepts up to 60 seconds.', 'Generating in parts and Extend need an NVIDIA graphics card and are not available on Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], + zh: ['一条视频最多 5 段,创作面板中时长最长 60 秒。', '分段生成和接着生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], + en: ['A video has at most 5 parts, and the creation panel accepts up to 60 seconds.', 'Generating in parts and Extend need an NVIDIA graphics card or a Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], }, }, { @@ -51,8 +51,8 @@ export const TUTORIAL_CHAPTERS = [ en: ['Select the part to continue.', 'Click + next to the prompt to upload or generate images, up to 3.', 'Describe it, then choose "Generate".'], }, limits: { - zh: ['接续生成最多使用 3 张参考图,不使用首帧和尾帧。', '剪辑视图只能添加图片;参考视频和音频在创作面板的“素材”中添加。', '同样最多 5 段,需要 NVIDIA 显卡,不支持 Mac。'], - en: ['A part that continues the previous one uses up to 3 reference images and no first or last frames.', 'Edit takes images only. Add reference videos and audio under "Media" in the creation panel.', 'The same 5-part limit applies, and an NVIDIA graphics card is required. Not available on Mac.'], + zh: ['接续生成最多使用 3 张参考图,不使用首帧和尾帧。', '剪辑视图只能添加图片;参考视频和音频在创作面板的“素材”中添加。', '同样最多 5 段,需要 NVIDIA 显卡或 Mac。'], + en: ['A part that continues the previous one uses up to 3 reference images and no first or last frames.', 'Edit takes images only. Add reference videos and audio under "Media" in the creation panel.', 'The same 5-part limit applies, and an NVIDIA graphics card or a Mac is required.'], }, }, ]; diff --git a/web/updates.js b/web/updates.js index 84d55cf8..c9e7081d 100644 --- a/web/updates.js +++ b/web/updates.js @@ -1,5 +1,6 @@ import { api } from '../../scripts/api.js'; import { setLauncherPresent } from './repair_hint.js'; +import { issueButton } from './issue_button.js'; // Every platform downloads from one release: the latest vX.Y.Z. const releasePage = 'https://github.com/FlashML-org/FreeVideo/releases/latest'; @@ -116,6 +117,7 @@ export function createUpdateNotice(cn) { const element = document.createElement('aside'); element.className = 'fv-update-notice'; element.hidden = true; element.setAttribute('role', 'status'); const label = document.createElement('span'); + let problem; const summary = document.createElement('small'); summary.className = 'fv-update-summary'; const details = document.createElement('button'); details.type = 'button'; details.textContent = t("What's new", '更新内容'); @@ -178,6 +180,12 @@ export function createUpdateNotice(cn) { element.hidden = !text; element.classList.toggle('fv-update-working', busy); label.textContent = text; + const failed = failure === 'cancel' || failure === 'launcher' && !busy && phase !== 'review' + || actions && launcher?.status === 'error' && !!launcher.error; + if (failed) { + problem ||= issueButton(t, () => ({where: ['Update FreeVideo', '更新 FreeVideo'], message: label.textContent})); + element.append(problem); + } else problem?.remove(); const skipped = actions ? earlierNotes(candidate, productVersion()).length : 0; summary.textContent = actions ? (localizedNotes(candidate, cn)?.summary || '') + (skipped ? ' ' + t(`Includes the changes of ${skipped} earlier version${skipped > 1 ? 's' : ''}.`, `另含此前 ${skipped} 个版本的更新。`) : '') : ''; @@ -233,7 +241,11 @@ function showReleaseNotes(cn) { } body.append(section); } - if (!body.childElementCount) body.textContent = t('Version information is not available yet.', '暂时无法获取版本信息。'); + if (!body.childElementCount) { + const message = t('Version information is not available yet.', '暂时无法获取版本信息。'); + body.textContent = message; + body.append(issueButton(t, () => ({where: ['Version information', '版本信息'], message}))); + } }; const dispose = () => { listeners.delete(render); dialog.remove(); }; dialog.addEventListener('close', dispose, {once: true}); diff --git a/web/whitebox_scenes.js b/web/whitebox_scenes.js index 495d8184..0a770759 100644 --- a/web/whitebox_scenes.js +++ b/web/whitebox_scenes.js @@ -6,6 +6,11 @@ export const SCENE_TEXT = Object.freeze({ icon: 'M4 5h7v6H4zM13 5h7v6h-7zM4 13h7v6H4zM13 13h7v6h-7z', label: ['Ready-made', '现成场景'], name: ['Start from a ready-made white-model scene', '用现成的白模场景开始'], preparing: ['Preparing the white model…', '正在准备白模…'], failed: ['The white model could not be made: ', '白模没有做成:'], + rendering: ['Rendering the white model…', '正在渲染白模…'], retry: ['. Try again.', '。请重试。'], + bare: ['The white model could not be made. Try again.', '白模没有做成。请重试。'], + noPage: ['The white model could not be made: the renderer is not running in this page. Reload the page and try again.', '白模没有做成:这个页面里的渲染没有运行。请刷新页面后重试。'], + notStarted: ['The white model could not be made: rendering did not start within 3 minutes. Try again.', '白模没有做成:三分钟内渲染没有开始。请重试。'], + timeout: ['The white model could not be made: rendering did not finish within 3 minutes. Try again.', '白模没有做成:渲染三分钟内没有完成。请重试。'], }); export async function sceneList(api) { @@ -22,17 +27,21 @@ export async function startScene(api, preset, session) { return value?.id || null; } -// Resolves with the scene's last state: 'ready' (the card follows), 'failed' (with error), or 'timeout'. -export async function watchScene(api, id, {every = 3000, limit = 180000, stopped = () => false, wait = ms => new Promise(done => setTimeout(done, ms))} = {}) { +// Resolves with the scene's last state: 'ready' (the card follows), 'failed' (with error), or 'timeout' (with the +// last state seen: 'waiting' nobody claimed it, 'rendering' it did not finish, page_open false no page was asking). +// onState hears every state seen, so the page can say what is happening. +export async function watchScene(api, id, {every = 3000, limit = 180000, stopped = () => false, onState = () => {}, wait = ms => new Promise(done => setTimeout(done, ms))} = {}) { + let last = {}; for (let waited = 0; waited < limit && !stopped(); waited += every) { try { const response = await agentGet(api, `/freevideo/agent/whitebox/${encodeURIComponent(id)}`); if (response.ok) { const value = await response.json(); if (value?.status === 'ready' || value?.status === 'failed') return value; + last = value || {}; onState(last); } } catch { /* Asked again on the next turn. */ } await wait(every); } - return {status: 'timeout'}; + return {status: 'timeout', last: last.status || '', page_open: last.page_open !== false}; }