diff --git a/MANIFEST.in b/MANIFEST.in index e6ec5836..0e84846c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -94,5 +94,8 @@ include web/assets/tour/ca-record-wave.webp include web/assets/tour/cc-speech.mp4 include web/assets/tour/cc-speech.webp include web/assets/tour/cc-speech-wave.webp +include web/assets/tour/cd-narration.mp4 +include web/assets/tour/cd-narration.webp +include web/assets/tour/cd-narration-wave.webp include web/assets/tour/c10-source.webp include web/assets/tour/fox-sheet.webp diff --git a/__init__.py b/__init__.py index 50ac057a..72647257 100644 --- a/__init__.py +++ b/__init__.py @@ -53,6 +53,8 @@ async def comfy_entrypoint(): editor_export_routes() from .freevideo_engine.comfy_capabilities import register as capabilities_routes capabilities_routes() + from .freevideo_engine.comfy_monitor import register as monitor_routes + monitor_routes() from .freevideo_engine.subtitle_ears import register as subtitle_ears_routes subtitle_ears_routes() from .freevideo_engine.editor_music import register as editor_music_routes diff --git a/docs/community-results.md b/docs/community-results.md index 5d0ad35f..4a4e7105 100644 --- a/docs/community-results.md +++ b/docs/community-results.md @@ -124,6 +124,20 @@ Thanks to the community members who sent diagnostic reports. FreeVideo already r | 5060 | 8 GB | 48 GB | 768×1344 | 14 s | Medium | 0.3.25 | 3474 s | 0.3.25 | Already optimal | | 3060 | 12 GB | 32 GB | 896×1120 | 10 s | Light | 0.3.25 | 1045 s | 0.4.0 | 1025 s (−20 s) | | 3060 Laptop | 6 GB | 40 GB | 960×544 | 15 s | Light⁵ | 0.4.24 | 713 s | 0.4.24 | Thermal‑limited¹ | +| 5060 | 8 GB | 64 GB | 544×960 | 11.5 s | Light | 0.4.26 | 743 s¹³ | 0.4.26 | Already optimal¹² | +| 3050 Laptop | 6 GB | 16 GB | 1184×672 | 10 s | High | 0.4.23 | 5773 s | 0.4.31 | 4300 s (−1470 s) | +| 5080 | 16 GB | 32 GB | 1920×1088 | 10 s | Light | 0.4.18 | 1850 s | 0.4.21 | 860 s (−990 s) | +| 4060 Ti | 8 GB | 32 GB | 960×544 | 6 s | Light | 0.4.27 | 241 s | 0.4.28 | 190 s (−50 s) | +| 3060 | 12 GB | 32 GB | 672×1184 | 3 s | Light | 0.4.28 | 469 s | 0.4.28 | 430 s (−40 s) | +| 5080 Laptop | 16 GB | 64 GB | 1344×768 | 10 s | Light | 0.4.29 | 647 s | 0.4.29 | Already optimal | +| 4060 Laptop | 8 GB | 16 GB | 1344×768 | 10 s | Light | 0.4.30 | 1056 s | 0.4.30 | Already optimal | +| 5070 Laptop | 8 GB | 32 GB | 768×1344 | 2.3 s | Max¹² | 0.4.30 | 2195 s | 0.4.30 | Compatibility level 3¹⁴ | +| 5080 Laptop | 16 GB | 64 GB | 2496×832 | 8 s | Light⁵ | 0.4.30 | 777 s | 0.4.30 | Already optimal | +| 4060 Laptop | 8 GB | 32 GB | 1344×768 | 9.4 s | Light | 0.4.31 | 806 s | 0.4.31 | Already optimal | +| 4060 Laptop | 8 GB | 32 GB | 1344×768 | 10 s | Light | 0.4.32 | 802 s | 0.4.32 | Already optimal | +| 3050 Laptop | 8 GB | 40 GB | 960×544 | 5 s | Light | 0.4.33 | 890 s | 0.4.33 | Already optimal | +| 3070 Laptop | 8 GB | 64 GB | 960×544 | 5 s | Light | 0.4.33 | 260 s | 0.4.33 | Already optimal | +| 3070 | 8 GB | 64 GB | 736×736 | 10 s | Light | 0.4.35 | 471 s | 0.4.35 | 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. @@ -141,3 +155,4 @@ Thanks to the community members who sent diagnostic reports. FreeVideo already r - ¹¹ With 16 GB of RAM, the text encoder and part of the video model cannot stay in memory and are read from the drive; text encoding and the first pass, which wait on these reads, took about 460 s of the 887 s. - ¹² Task with a reference: every step also processes the reference video, images or audio, so each step takes longer than a plain request of the same size, up to about twice as long with a long reference. - ¹³ Preview (first pass only). +- ¹⁴ Compatibility level 3 was set manually; it is the slowest level and made each step about 2.4 times slower than the automatic setting for this request, and the GPU core clock also stayed at 900 MHz against the 1815 MHz it reached. With the level off, the same request takes about 1100 s. diff --git a/docs/zh-CN/community-results.md b/docs/zh-CN/community-results.md index fbcbf252..ba8429b3 100644 --- a/docs/zh-CN/community-results.md +++ b/docs/zh-CN/community-results.md @@ -124,6 +124,20 @@ | 5060 | 8 GB | 48 GB | 768×1344 | 14 s | 标⁠准 | 0.3.25 | 3474 s | 0.3.25 | 已⁠是⁠最⁠优 | | 3060 | 12 GB | 32 GB | 896×1120 | 10 s | 轻⁠量 | 0.3.25 | 1045 s | 0.4.0 | 1025 s⁠(−20 s⁠) | | 3060 笔⁠记⁠本 | 6 GB | 40 GB | 960×544 | 15 s | 轻⁠量⁵ | 0.4.24 | 713 s | 0.4.24 | 散⁠热⁠受⁠限¹ | +| 5060 | 8 GB | 64 GB | 544×960 | 11.5 s | 轻⁠量 | 0.4.26 | 743 s¹³ | 0.4.26 | 已⁠是⁠最⁠优¹² | +| 3050 笔⁠记⁠本 | 6 GB | 16 GB | 1184×672 | 10 s | 精⁠细 | 0.4.23 | 5773 s | 0.4.31 | 4300 s⁠(−1470 s⁠) | +| 5080 | 16 GB | 32 GB | 1920×1088 | 10 s | 轻⁠量 | 0.4.18 | 1850 s | 0.4.21 | 860 s⁠(−990 s⁠) | +| 4060 Ti | 8 GB | 32 GB | 960×544 | 6 s | 轻⁠量 | 0.4.27 | 241 s | 0.4.28 | 190 s⁠(−50 s⁠) | +| 3060 | 12 GB | 32 GB | 672×1184 | 3 s | 轻⁠量 | 0.4.28 | 469 s | 0.4.28 | 430 s⁠(−40 s⁠) | +| 5080 笔⁠记⁠本 | 16 GB | 64 GB | 1344×768 | 10 s | 轻⁠量 | 0.4.29 | 647 s | 0.4.29 | 已⁠是⁠最⁠优 | +| 4060 笔⁠记⁠本 | 8 GB | 16 GB | 1344×768 | 10 s | 轻⁠量 | 0.4.30 | 1056 s | 0.4.30 | 已⁠是⁠最⁠优 | +| 5070 笔⁠记⁠本 | 8 GB | 32 GB | 768×1344 | 2.3 s | 极⁠致¹² | 0.4.30 | 2195 s | 0.4.30 | 兼⁠容⁠等⁠级⁠3¹⁴ | +| 5080 笔⁠记⁠本 | 16 GB | 64 GB | 2496×832 | 8 s | 轻⁠量⁵ | 0.4.30 | 777 s | 0.4.30 | 已⁠是⁠最⁠优 | +| 4060 笔⁠记⁠本 | 8 GB | 32 GB | 1344×768 | 9.4 s | 轻⁠量 | 0.4.31 | 806 s | 0.4.31 | 已⁠是⁠最⁠优 | +| 4060 笔⁠记⁠本 | 8 GB | 32 GB | 1344×768 | 10 s | 轻⁠量 | 0.4.32 | 802 s | 0.4.32 | 已⁠是⁠最⁠优 | +| 3050 笔⁠记⁠本 | 8 GB | 40 GB | 960×544 | 5 s | 轻⁠量 | 0.4.33 | 890 s | 0.4.33 | 已⁠是⁠最⁠优 | +| 3070 笔⁠记⁠本 | 8 GB | 64 GB | 960×544 | 5 s | 轻⁠量 | 0.4.33 | 260 s | 0.4.33 | 已⁠是⁠最⁠优 | +| 3070 | 8 GB | 64 GB | 736×736 | 10 s | 轻⁠量 | 0.4.35 | 471 s | 0.4.35 | 已⁠是⁠最⁠优 | - 用时:从提交到视频保存完成的端到端用时。 - 优化后:预估用时,根据报告中的分段用时,结合测试机(Windows RTX 3090 和 RTX 5060 Ti、Linux RTX PRO 6000)在报告机器的显存和内存上限下运行完整规划的实测推算,不是在报告机器上重跑。 @@ -141,3 +155,4 @@ - ¹¹ 16 GB 内存留不下文本编码器和视频模型的一部分,需要从硬盘读取;887 秒里约 460 秒是等待这些读取的文本编码和第一遍。 - ¹² 带参考的任务:每一步还要处理参考视频、参考图片或参考音频,所以每步耗时比相同尺寸的普通请求长;参考较长时约为两倍。 - ¹³ 预览(只做第一遍)。 +- ¹⁴ 兼容等级 3 为手动设置,是最慢的等级,对这个请求每步比自动设置慢约 2.4 倍;显卡核心时钟同时固定在 900 MHz(本次运行曾达到 1815 MHz)。关闭该等级后,同样的请求约需 1100 秒。 diff --git a/freevideo_engine/activation_staging.py b/freevideo_engine/activation_staging.py index 48c8c09d..8a3e1d58 100644 --- a/freevideo_engine/activation_staging.py +++ b/freevideo_engine/activation_staging.py @@ -1,7 +1,17 @@ """Contiguous D2H head groups with double-buffered CPU projection tiles.""" import collections +import contextlib import torch +# The first copies through a freshly locked buffer, and the first tiles of its +# first projection, are followed by a wait for the device. A deferred CUDA +# "invalid argument" from a queued locked copy is otherwise reported by +# whichever later call happens to run (reports on the second pass's first layer +# name three different statements), outside the handler that continues in +# pageable memory. Waiting here raises it inside that handler, with the stage +# named. A handful of waits per buffer set, none afterwards. +CHECKED_COPIES = 3 + # Every refused locked-page copy of this process, residual stream and head # readouts alike, so a report shows whether the fallback fired, which copy was # refused and what CUDA said. The newest entries are kept; the count is exact. @@ -9,13 +19,29 @@ _refused_total = 0 -def record_refusal(source, copy, error): +def record_refusal(source, copy, error, checked=False): global _refused_total lines = str(error).strip().splitlines() - _REFUSED.append(dict(source=source, copy=copy, error=(lines[0] if lines else '')[:200])) + row = dict(source=source, copy=copy, error=(lines[0] if lines else '')[:200]) + if checked: + row['checked'] = True # raised by the wait after one of the first copies, not by the copy call + _REFUSED.append(row) _refused_total += 1 +@contextlib.contextmanager +def checked(active, stage, **fields): + """Wait for the device after `stage` while `active`; name the stage on a CUDA error.""" + try: + yield + if active and torch.cuda.is_initialized(): + torch.cuda.synchronize() + except RuntimeError as error: + if active and not isinstance(error, torch.cuda.OutOfMemoryError) and hasattr(error, 'add_note'): + error.add_note('checked stage: %s %s' % (stage, ' '.join('%s=%s' % item for item in fields.items()))) + raise + + def refusals_mark(): return _refused_total @@ -38,36 +64,55 @@ class LockedCopies: and the buffers stay locked. """ - def __init__(self): + def __init__(self, checked_copies=CHECKED_COPIES): self.locked = True self.fallbacks = 0 + # One budget per direction: the host-to-device copies (the report's refused statement) are not + # used up by the device-to-host copies that come first in a layer. + self.unchecked = dict(to_host=checked_copies, to_device=checked_copies) - def _leave(self, device, copy, error): - record_refusal('readout', copy, error) + def _leave(self, device, copy, error, waited=False): + record_refusal('readout', copy, error, checked=waited) if device.type == 'cuda': torch.cuda.synchronize(device) self.locked = False self.fallbacks += 1 + def _wait(self, device, copy): + # Inside the handler of the copy that was just queued. + if self.unchecked[copy] > 0: + self.unchecked[copy] -= 1 + if device.type == 'cuda': + torch.cuda.synchronize(device) + return True + return False + def to_host(self, destination, source): if self.locked: + waited = False try: destination.copy_(source, non_blocking=True) + waited = True # from here on an error comes from the wait + self._wait(source.device, 'to_host') return except torch.cuda.OutOfMemoryError: raise except RuntimeError as error: - self._leave(source.device, 'to_host', error) + self._leave(source.device, 'to_host', error, waited) destination.copy_(source.to('cpu')) def to_device(self, source, device): if self.locked: + waited = False try: - return source.to(device, non_blocking=True) + value = source.to(device, non_blocking=True) + waited = True # from here on an error comes from the wait + self._wait(torch.device(device), 'to_device') + return value except torch.cuda.OutOfMemoryError: raise except RuntimeError as error: - self._leave(torch.device(device), 'to_device', error) + self._leave(torch.device(device), 'to_device', error, waited) return source.clone().to(device) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 2ffceea7..5694be09 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -262,8 +262,9 @@ 'otherwise make its reference pictures with POST /cast/{id}/generate. ' 'Before shooting, tell the user the casting in one sentence. Later shots use the card\'s current pictures. ' 'Keep cards you made so later films can reuse them.', - 'Pictures. When the user wants a picture, or a first frame or reference picture that does not exist yet, make ' - 'it with freevideo_generate_image; to change a picture, pass it in images. The result is an input file: use ' + 'Pictures. When the user wants a picture, make it with freevideo_generate_image and stop there: do not read the video ' + 'prompt guide, plan or start a video, and tell the user where the picture is. The same tool makes a first frame or ' + 'reference picture that does not exist yet; to change a picture, pass it in images. The result is an input file: use ' 'result.file as a first frame, as a reference, or in images again. When where people stand in the shot matters, ' 'make the first frame with scene_image and people (cast card IDs or pictures, x, height, facing left/right, ' 'framing full/half), then generate the video from result.file. Use dimensions divisible by 32. ' @@ -306,7 +307,8 @@ '先选角,再拍摄。先搜索角色库,使用用户指定的卡片或明确符合要求的卡片;拿不准时询问用户。' '只有找不到合适卡片时才新建:用户提供了照片就用照片,否则通过 POST /cast/{id}/generate 生成参考图。' '开拍前用一句话告诉用户选角结果。后续镜头使用卡片当前的图片。保留你创建的卡片,方便以后拍片复用。', - '图片。用户要一张图,或者缺一张首帧、参考图时,用 freevideo_generate_image 生成;要改一张图,把它放进 images。' + '图片。用户要一张图时,用 freevideo_generate_image 生成,到此为止:不读视频提示词指南,不做视频规划,也不生成视频,告诉用户图片在哪里。' + '缺一张首帧、参考图时也用它生成;要改一张图,把它放进 images。' '结果是一个输入文件:result.file 可以当首帧、当参考图,也可以再放进 images。镜头里人物站位要紧时,' '先用 people(角色图和各自的位置)做出首帧,再用它生成视频。', '默认切镜头。能切镜头的地方就切:新镜头直接用对应的参考图(人物卡、场景图)生成,不接上一段视频。' @@ -458,7 +460,9 @@ def connection_discovery(root, base, lang=None): def introduction(zh, offers): """What the agent tells the user first: only what this installation is known to carry through.""" rows = ['按描述生成视频,可以给首帧图片或参考图' if zh else - 'Make a video from a description, optionally from a first-frame picture or reference pictures'] + 'Make a video from a description, optionally from a first-frame picture or reference pictures', + '按描述生成图片,也可以修改、合成已有的图片;只要图片时不会生成视频' if zh else + 'Make a picture from a description, or change and combine pictures; a request for a picture alone makes no video'] if offers.get('editing'): rows.append('剪辑:把片段排上时间线,分割、裁剪、删除,再导出成片' if zh else 'Edit: put clips on a timeline, split, trim and remove them, then export the finished video') @@ -489,6 +493,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): '# client is the name the user sees as connected: put your own, for example "Claude Code" or "Codex".', 'call("POST", "/connect/hello", {"client": "Local Agent", "via": "http"})', 'print(call("GET", ""))', + '# A request for a picture alone: POST /jobs {"kind": "image", "prompt": ..., "label": ..., "ref": ...} and no video job; the result is result.file.', '# Only after the user asks for a video:', '# label is the short name the user sees on the job, in their language: what the video shows, 2 to 8 characters.', '# seconds and aspect are what the user asked for (10 seconds and 16:9 when they said nothing): send them the first time.', @@ -557,7 +562,7 @@ def connection_guide(root, base, lang=None, offers=None, resumed=False): 'without id). Its "session" is the conversation the user is in (it changes when the user opens or returns to another one; always post to the session the latest wait named); POST ' + base + PREFIX + '/sessions/{session}/events ' 'with {"type":"message","text":"..."} (MCP: freevideo_session_post). Say only one short sentence ' 'there first, at most 12 words, no list and no examples (the page already shows examples), for example ' - '"Connected. Describe the video you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' + '"Connected. Describe the video or picture you want.", in the language the user writes to you in. Give the introduction in your own window as usual, but it ' 'does not end your turn: right after it, call the wait. Then stay in this conversation: call the wait again; it answers within about 50 seconds, and ' '"again": true means the user has not spoken yet, so call it again at once, time after time, with no ' 'limit. Nothing new is not a reason to finish: do not end your reply and do not stop to ask the ' diff --git a/freevideo_engine/agent/mcp.py b/freevideo_engine/agent/mcp.py index 41d51d51..e56c6ae7 100644 --- a/freevideo_engine/agent/mcp.py +++ b/freevideo_engine/agent/mcp.py @@ -23,7 +23,7 @@ JOB_WAIT = 45. # freevideo_wait only; the other waits keep 25 s. Fewer empty turns, each of which carries the whole context again. PROTOCOLS = ('2025-06-18', '2025-03-26', '2024-11-05') -INSTRUCTIONS = """FreeVideo generates videos (MiniMax H3) on this computer's GPU. +INSTRUCTIONS = """FreeVideo generates videos (MiniMax H3) and pictures on this computer's GPU. A request for a picture alone is one freevideo_generate_image call: no video prompt guide, no plan, no video job. Start with freevideo_status and freevideo_capabilities. Picture editing, storyboards (submit_batch), editor operations and plan are not listed in full: freevideo_tool_help {name} gives the schema, freevideo_call runs them. If a parameter is unclear, read the schema or check with freevideo_plan; do not submit a test job. Write prompts as the guide says (freevideo_prompt_guide): English sections integrated_multimodal_description / overall_soundscape / non_diegetic_music, [Shot N] cuts, (S1) [Language] ... dialogue. Generation uses continuous chains and takes minutes: submit, then call freevideo_wait (up to 45 seconds; it returns as soon as the job ends) @@ -51,7 +51,7 @@ def tool_list(): dict(name='freevideo_schema', title='Job JSON Schemas', description='JSON Schemas of every job kind and of image edit recipes.', annotations=read), dict(name='freevideo_plan', title='Plan a job', description="Validate a job without running it: actual frames/seconds, sampling passes, warnings and a time estimate from this computer's history. Same fields as freevideo_generate.", annotations=read), dict(name='freevideo_generate', title='Generate a video', description='Generate one video from an H3 prompt, up to 60 seconds. Under 30 s defaults to one generation if the device supports this size and quality; unknown limits use 15 s. Otherwise the video is cut into as few parts as possible, each continuing part 10 s or longer where the length allows and never under 5 s, and how many parts depends on how long this computer can generate in one run; the parts continue and join automatically. Prefer a new shot with its reference pictures wherever the film can cut; continue only a take, action or sound that must stay unbroken. Use this for any single video, also after freevideo_enhance_prompt (pass its job id as prompt, {from_job: id}); the drama tools are only for a film of several separately chosen shots. Optionally from first/last frames or references (files, local paths, or frames of earlier jobs). Returns the job; with wait, its result (video path and URL).', annotations=write), - dict(name='freevideo_generate_image', title='Generate a picture', description='Generate a picture from a prompt; with images, change or combine input pictures; with people, make a first frame with them near the places you say, in the scene from scene_image or the prompt. The result is an input file to use as a first frame, as a reference, or in images again.', annotations=write), + dict(name='freevideo_generate_image', title='Generate a picture', description='Generate a picture from a prompt, on its own or as a first frame; with images, change or combine input pictures; with people, make a first frame with them near the places you say, in the scene from scene_image or the prompt. The result is an input file to use as a first frame, as a reference, or in images again.', annotations=write), dict(name='freevideo_submit_batch', title='Submit a storyboard', description='Submit several jobs at once. Each row is a job object with "kind" (generate default, assemble, vlm, enhance, image) and an optional "ref"; later rows use "@ref" wherever a job id is expected (from_job, after, clips, refine_from). FreeVideo runs them in dependency order.', annotations=write), dict(name='freevideo_job', title='Job status', description='Status, progress, result or error of a job.', annotations=read), dict(name='freevideo_wait', title='Wait for a job or the user', description="Wait up to 45 seconds for a job (with id) or, without id, at most 50 seconds for what the user says in FreeVideo (inbox). again=true means nothing new yet: call it again. A job wait returns early when the user says something. If the job is still active, call it again with the same ID; do not resubmit.", annotations=read), diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index b61b593e..8b8cb71a 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -873,14 +873,26 @@ def activity_item(row, *, card=False): progress = view.get('progress') or {} value['progress'] = {k: progress[k] for k in ('phase', 'step', 'fraction', 'remaining_seconds', 'queue_position') + (('block',) if card else ()) if progress.get(k) is not None} - if 'fraction' not in value['progress'] and view.get('kind') in ('export', 'listen'): - # An export or a subtitle job reports its share of the work as a small JSON detail. + if view.get('kind') in ('export', 'listen', 'music', 'stems', 'finish_sound', 'voice_lines', 'voice', 'voice_say'): + # A job that runs outside ComfyUI reports its share of the work as a small JSON detail; dubbing also + # reports the line it is on and the time left. try: - share = float(json.loads(progress.get('detail') or '{}').get('fraction')) - except (TypeError, ValueError, AttributeError): + detail = json.loads(progress.get('detail') or '{}') + except ValueError: + detail = {} + detail = detail if isinstance(detail, dict) else {} + try: + share = float(detail.get('fraction')) + except (TypeError, ValueError): share = None - if share is not None and 0 <= share <= 1: + if 'fraction' not in value['progress'] and share is not None and 0 <= share <= 1: value['progress']['fraction'] = share + done, total = detail.get('index'), detail.get('count') + if 'step' not in value['progress'] and type(done) is int and type(total) is int and 0 < done <= total: + value['progress']['step'] = dict(done=done, total=total) + remaining = detail.get('remaining_seconds') + if 'remaining_seconds' not in value['progress'] and type(remaining) in (int, float) and 0 < remaining < math.inf: + value['progress']['remaining_seconds'] = remaining if view.get('waiting_for'): value['waiting_for'] = len(view['waiting_for']) if view.get('error'): diff --git a/freevideo_engine/agent/skill/freevideo/SKILL.txt b/freevideo_engine/agent/skill/freevideo/SKILL.txt index 4b747e6a..f303d1b6 100644 --- a/freevideo_engine/agent/skill/freevideo/SKILL.txt +++ b/freevideo_engine/agent/skill/freevideo/SKILL.txt @@ -1,6 +1,6 @@ --- name: freevideo -description: Make videos with the user's local FreeVideo through its MCP tools or the `freevideo agent` command line. Use when the user asks to generate or continue a video, edit a picture, cut a timeline or make a multi-shot film in FreeVideo. +description: Make videos and pictures with the user's local FreeVideo through its MCP tools or the `freevideo agent` command line. Use when the user asks to generate or continue a video, make or edit a picture, cut a timeline or make a multi-shot film in FreeVideo. --- # FreeVideo @@ -10,12 +10,13 @@ FreeVideo runs on the user's own computer. Everything below goes through its MCP ## Before anything else 1. Call `freevideo_status`. If it is not ready, tell the user what is missing; do not retry in a loop. -2. Read `freevideo_prompt_guide` once before writing a prompt. Prompts are English sections; dialogue and on-screen text stay in the user's language. -3. Call `freevideo_plan` with the job you intend to run. It returns the real frame count, the number of parts and a time estimate for this computer. Tell the user the estimate before a long run. +2. Before writing a video prompt, read `freevideo_prompt_guide` once (not needed for a picture alone). Prompts are English sections; dialogue and on-screen text stay in the user's language. +3. Before a video, call `freevideo_plan` with the job you intend to run (it plans videos only). It returns the real frame count, the number of parts and a time estimate for this computer. Tell the user the estimate before a long run. 4. The user sees the same work in the FreeVideo window. A tool may answer `needs_confirmation`: wait for the user (`freevideo_wait` without an id), then repeat the identical call once approved. If the user declines, stop and say so; do not retry with a changed reference. ## Pick the right task +- Only a picture asked for (a drawing, a poster, a character, a scene, no motion): one `freevideo_generate_image` call and stop. No video prompt guide, no plan, no video job; report where the picture is. Several pictures are several calls, or one `freevideo_submit_batch`. - One video, up to 60 seconds: `freevideo_generate`. Longer than the device can do in one run is cut into parts and joined automatically. The device limit is a hard cap, not a target: aim for 15 to 20 seconds per generation. - Start from or end on a picture: pass it as first or last frame. Make the picture first with `freevideo_generate_image`. - A story, scene or short drama: follow "Making a short drama" below. Several shots to choose between: the `freevideo_drama_*` tools; see `references/film.md`. diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index e9f68f0e..f8e6b927 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -27,7 +27,7 @@ from .hardware import Hardware, GiB, cgroup_capacity from . import __version__, subprocess_output from .policy import resource_budget -from .monitoring import save +from .monitoring import save, load_ledger from .install_tuning import build_parallelism, required_models, cache_compatible, wheel_key from .terminal_ui import TerminalUI, LogProgress from .locking import runtime_lock, LOCK_ENV @@ -716,7 +716,7 @@ def plan(args, *, local_progress=None, stage=None): reserved = set() check_download_list = bool(getattr(args, 'download_list', False)) stamps_path = root / 'verified-models.json' - stamps = json.loads(stamps_path.read_text(encoding='utf-8')) if check_download_list and stamps_path.is_file() else {} + stamps = load_ledger(stamps_path, retain=False) if check_download_list else {} # The check leaves the installer's ledger alone and keeps what it hashed in a file of its own. checked_path = root / 'download-list-checks.json' try: diff --git a/freevideo_engine/chain_join.py b/freevideo_engine/chain_join.py index b7e4b1fd..6e1af901 100644 --- a/freevideo_engine/chain_join.py +++ b/freevideo_engine/chain_join.py @@ -163,7 +163,7 @@ def _exact_tail(source, target, wanted, cancelled=None): def join_files(sources, target, cancelled=None, frames=None, leads=None, expected=None, speaking=None, *, - original_audio=None): + original_audio=None, progress=None): """Write `target` from `sources` in order; returns the joined frame and sample counts. `frames`: stop at this length when a compatible short tail can close it exactly, @@ -175,24 +175,25 @@ def join_files(sources, target, cancelled=None, frames=None, leads=None, expecte without source paths or exception messages. `original_audio`: mux the original selection and close the picture on its source-sample clock; a rounded completed prefix keeps its existing frames. + `progress`: optional `progress(part, count)` called as each part starts being written. """ sources = list(sources) try: - return _join_ending(sources, target, cancelled, frames, leads, expected, speaking, original_audio) + return _join_ending(sources, target, cancelled, frames, leads, expected, speaking, original_audio, progress) except _VoiceFailed: logging.warning('[FreeVideo] The voice of the parts could not be evened out; they are joined as they are', exc_info=True) - return _join_ending(sources, target, cancelled, frames, leads, expected, None, original_audio) + return _join_ending(sources, target, cancelled, frames, leads, expected, None, original_audio, progress) -def _join_ending(sources, target, cancelled, frames, leads, expected, speaking, original_audio=None): +def _join_ending(sources, target, cancelled, frames, leads, expected, speaking, original_audio=None, progress=None): try: return _join_files(sources, target, cancelled, frames, leads=leads, expected=expected, speaking=speaking, - original_audio=original_audio) + original_audio=original_audio, progress=progress) except _ExactEndFailed: # A late mux or validation error must also leave the original ending available. return _join_files(sources, target, cancelled, frames, exact=False, leads=leads, expected=expected, - speaking=speaking, original_audio=original_audio) + speaking=speaking, original_audio=original_audio, progress=progress) def _difference(number, shape, other): @@ -257,7 +258,7 @@ def _conform(source, shape, directory, cancelled): def _join_files(sources, target, cancelled, frames, exact=True, leads=None, expected=None, speaking=None, - original_audio=None): + original_audio=None, progress=None): import av from .comfy_metadata import _copy_stream audio_lead_in = apply_audio_lead_in = None @@ -327,6 +328,11 @@ def audio_lead_in(video, previous=None): _check(cancelled) if wanted is not None and frames >= wanted and picture is not None: break + if progress is not None: + try: + progress(index + 1, len(sources)) + except Exception: + logging.debug('[FreeVideo] Join progress could not be reported', exc_info=True) last = _last_kept(source, wanted - frames) if wanted is not None else None with av.open(str(source)) as container: if len(container.streams.video) != 1 or _shape(container.streams.video[0]) != shape: @@ -754,7 +760,7 @@ def _replace_target(root, replace, identity, sources): def join_outputs(outputs, identity, cancelled=None, frames=None, replace=None, leads=None, expected_seams=None, - speaking=None, *, original_audio=None): + speaking=None, *, original_audio=None, progress=None): """Join relative block outputs into a saved video, replacing only this chain's earlier join.""" import folder_paths root = Path(folder_paths.get_output_directory()).resolve() @@ -786,7 +792,7 @@ def join_outputs(outputs, identity, cancelled=None, frames=None, replace=None, l def kept(following, previous): return leads(originals.get(str(following)), originals.get(str(previous)) if previous is not None else None) joined = join_files(sources, staged, cancelled, frames, leads=kept, expected=expected_seams, speaking=speaking, - **({'original_audio': original_audio} if original_audio is not None else {})) + progress=progress, **({'original_audio': original_audio} if original_audio is not None else {})) # The original selection replaces every block's sound, so there are no seam sounds to repair. if original_audio is None and expected_seams is not None and joined['seams_repaired'] < expected_seams: # A seam sound left unrepaired is never reported as a finished video. diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index b45624b6..50c74d70 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -127,6 +127,8 @@ def __init__(self, store_dir, host, clock=time.monotonic, wall=time.time, estima chain_leads.prune(store_dir, self.records) self.starts, self.success, self.cancel_sent, self.progress_values = {}, {}, set(), {} self.progress_messages = {} + # While a chain's parts are joined: the part being written (0 before the first) and how many there are. + self.joins = {} self.estimates, self.queue, self.active = {}, [], None self.concurrent = bool(getattr(host, 'concurrent', False)) if self.concurrent: @@ -258,6 +260,9 @@ def _view(self, record, detailed=True, client_id=None): value['last_heartbeat_at'] = record.get('last_heartbeat_at') value['elapsed_seconds'] = max(elapsed, remembered.get('elapsed_seconds', 0.)) value['joining'] = record['output_state'] == 'joining' + joined = self.joins.get(record['id']) if value['joining'] else None + if joined is not None: + value['joining_part'] = dict(joined) if client_id is not None: # The owner's ID stays in the record: a page only learns whether a chain is its own. value['mine'] = record['client_id'] == client_id @@ -674,25 +679,32 @@ def finished(self, prompt_id, node, output, report_id, continuation=None): def _keep_lead(self, identity, index, output, previous, continues): """Copy the block's seam sound out of the shared store before newer records push it out.""" + return self._lead_failure(identity, index, output, previous, continues) is None + + def _lead_failure(self, identity, index, output, previous, continues): + """None once the seam sound is kept, else the short reason it was not (never a path or a text).""" reader = getattr(self.host, 'lead', None) - if not continues or reader is None or not getattr(self.host, 'repairs_seams', True) or not isinstance(output, str) or not isinstance(previous, str): - return False + if not continues or not isinstance(output, str) or not isinstance(previous, str): + return 'not_adjacent' + if reader is None or not getattr(self.host, 'repairs_seams', True): + return 'no_reader' try: - lead = reader(output, previous) + check = getattr(self.host, 'lead_check', None) + lead, reason = check(output, previous) if check is not None else (reader(output, previous), 'not_read') if lead is not None: chain_leads.save(self.store_dir, identity, index, lead, previous, output) - return True - except Exception: - pass + return None + except Exception as error: + reason = 'save_failed:' + type(error).__name__ logging.warning('[FreeVideo] A chain block\'s seam sound could not be kept: %s', identity) - return False + return reason @staticmethod def _continues(row): """A block generated from the one before it: its seam must be repaired, whatever its receipt promised.""" return isinstance(row['after'], str) and bool(row['continuation']) - def _seams_kept(self, snapshot): + def _seams_kept(self, snapshot, reasons=None): """(indexes of the seams that must be repaired, how many of them have their sound), keeping late any a shared record still has. A seam is the pair of neighbours in the joined video, so a block that continues from a block other than its neighbour cannot be repaired and counts as lost.""" @@ -707,13 +719,29 @@ def _seams_kept(self, snapshot): wanted.add(row['index']) neighbour = blocks[position - 1] if row['after'] != neighbour['id']: + if reasons is not None: + reasons.append((row['index'], 'not_adjacent')) continue if chain_leads.load(self.store_dir, identity, row['index'], neighbour['output'], row['output']) is not None: kept += 1 - elif self._keep_lead(identity, row['index'], row['output'], neighbour['output'], True): - kept += 1 + else: + reason = self._lead_failure(identity, row['index'], row['output'], neighbour['output'], True) + if reason is None: + kept += 1 + elif reasons is not None: + reasons.append((row['index'], reason)) return wanted, kept + @staticmethod + def _kept_reasons(reasons): + """' (seam 2: no_record; seam 3: no_reader; and 1 more)' for the seams whose sound was not kept, at most three named.""" + if not reasons: + return '' + named = ['seam %d: %s' % row for row in reasons[:3]] + if len(reasons) > 3: + named.append('and %d more' % (len(reasons) - 3)) + return ' (' + '; '.join(named) + ')' + def _finish_blocks(self, record, final_state='done'): if not record['join']: return final_state @@ -983,6 +1011,12 @@ def cancelled(): return self.closed.is_set() or (final_state != 'cancelled' and self.records[identity]['state'] == 'cancelling') index_by_output = {row['output']: row['index'] for row in snapshot['blocks'] if row['output']} + with self.lock: + self.joins[identity] = dict(part=0, count=len(store.leading_blocks(snapshot))) + def join_progress(part, count): + with self.lock: + if identity in self.joins: + self.joins[identity].update(part=part, count=count) wanted = set() def lead_reader(following, previous): # Only a seam that must be repaired gets a sound; another seam is never counted for it. @@ -996,13 +1030,15 @@ def lead_reader(following, previous): failure = None promised = kept = 0 try: - found, kept = self._seams_kept(snapshot) + reasons = [] + found, kept = self._seams_kept(snapshot, reasons) wanted.update(found) promised = len(wanted) - snapshot = dict(snapshot, lead_reader=lead_reader, expected_seams=promised) + snapshot = dict(snapshot, lead_reader=lead_reader, expected_seams=promised, join_progress=join_progress) if kept < promised: # A sound seam that cannot be repaired is never published as a finished video. - raise ValueError('%d of %d seam sounds were not kept' % (promised - kept, promised)) + raise ValueError('%d of %d seam sounds were not kept' % (promised - kept, promised) + + self._kept_reasons(reasons)) output = self.host.join(snapshot, cancelled) if not isinstance(output, str) or not output: raise ValueError('Joining did not return a video path') @@ -1013,6 +1049,7 @@ def lead_reader(following, previous): part in message for part in (value, repr(value)[1:-1], json.dumps(value)[1:-1])) for value in texts): failure = 'Joining failed' with self.lock: + self.joins.pop(identity, None) record = self.records[identity] if self.closed.is_set(): return diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py index 5d1207fa..8d909812 100644 --- a/freevideo_engine/comfy_chain.py +++ b/freevideo_engine/comfy_chain.py @@ -190,6 +190,7 @@ def join(self, record, cancelled): raise RuntimeError('Joining is not available in this version') from None planned = record.get('plan') blocks = chain_store.leading_blocks(record) + shown = {'progress': record['join_progress']} if record.get('join_progress') is not None else {} # A planned chain shows exactly the requested length: what the last block made beyond it is left out. frames = planned['frames'] if planned and not planned['single'] and len(blocks) == len(record['blocks']) else None if record.get('original_audio') is not None: @@ -200,14 +201,15 @@ def join(self, record, cancelled): if len(blocks) < len(record['blocks']): frames = sum(block['new_frames'] for block in blocks) return join_outputs([block['output'] for block in blocks], record['id'], cancelled, - frames=frames, replace=record.get('output'), original_audio=info) + frames=frames, replace=record.get('output'), original_audio=info, **shown) kept = {'leads': record['lead_reader'], 'expected_seams': record.get('expected_seams')} \ if record.get('lead_reader') is not None else {} from .chain_level import voiced # Parts with spoken lines keep their voice level and fullness to the first part's. speaking = [voiced(block.get('inputs', {}).get('text')) for block in blocks] return join_outputs([block['output'] for block in blocks], record['id'], cancelled, - frames=frames, replace=record.get('output'), speaking=speaking, **kept) + frames=frames, replace=record.get('output'), speaking=speaking, + **shown, **kept) def split_texts(self, value, cancelled): """One description per part from the local prompt model: (parts, None) or (None, reason).""" @@ -262,6 +264,11 @@ def lead(self, output, previous): from .comfy_continuation import audio_lead_in return audio_lead_in(output, previous=previous) + def lead_check(self, output, previous): + """(the seam sound, None), or (None, why it could not be read), for the message of a lost seam.""" + from .comfy_continuation import audio_lead_in_checked + return audio_lead_in_checked(output, previous=previous) + def continuation_directions(): """'after' and 'before', each once FreeVideoGenerate has the input that carries it.""" diff --git a/freevideo_engine/comfy_continuation.py b/freevideo_engine/comfy_continuation.py index 035bd8d5..055bf119 100644 --- a/freevideo_engine/comfy_continuation.py +++ b/freevideo_engine/comfy_continuation.py @@ -123,12 +123,18 @@ def audio_lead_in(video, previous=None): start are untrimmed and the blocks are adjacent on the track. Apply before adjusting volume or resampling. """ + return audio_lead_in_checked(video, previous)[0] + + +def audio_lead_in_checked(video, previous=None): + """(the seam audio, None) as audio_lead_in reads it, or (None, why it could not be read): no_video, + no_record, old_record, source_changed or read_failed:. Never a path or a text.""" try: import numpy as np from av import FFmpegError from .continuation_store import load, video_id except ImportError: - return None + return None, 'read_failed:ImportError' try: try: import folder_paths @@ -144,21 +150,29 @@ def audio_lead_in(video, previous=None): def resolve(value): return resolve_continue_from(str(value), folder_paths.get_output_directory(), folder_paths.get_input_directory()) - path = resolve(video) + def located(name): + # A video that is gone raises ValueError from the resolvers rather than returning None. + try: + return resolve(name) + except ValueError: + return None + path = located(video) if path is None: - return None + return None, 'no_video' value = load(path, directory=directory) - if value is None or 'audio_lead_in' not in value: - return None + if value is None: + return None, 'no_record' + if 'audio_lead_in' not in value: + return None, 'old_record' if previous is not None: - source = resolve(previous) + source = located(previous) if source is None or value['metadata'].get('source_id') != video_id(source): - return None + return None, 'source_changed' return dict(samples=value['audio_lead_in'].astype(np.float32), before=LEAD_IN_SAMPLES, - fade=LEAD_IN_FADE, sample_rate=LEAD_IN_RATE) + fade=LEAD_IN_FADE, sample_rate=LEAD_IN_RATE), None except (OSError, ValueError, TypeError, KeyError, AttributeError, RuntimeError, - ImportError, OverflowError, FFmpegError): - return None + ImportError, OverflowError, FFmpegError) as error: + return None, 'read_failed:' + type(error).__name__ def apply_audio_lead_in(previous_samples, samples, lead): diff --git a/freevideo_engine/comfy_front.py b/freevideo_engine/comfy_front.py index 590c66b1..f042659f 100644 --- a/freevideo_engine/comfy_front.py +++ b/freevideo_engine/comfy_front.py @@ -97,6 +97,8 @@ def __init__(self, server, scheduler, workers, *, envelope=None, validate=None, self.dispatch_lock = asyncio.Lock() self.loop = getattr(server, 'loop', None) self.closed = False + # Set by the top-bar stop: pending requests stay queued and nothing new is sent to a worker. + self.paused = False self.background = set() # Devices whose executor exited. Their work moves to the others until the control process restarts. self.unavailable = set() @@ -382,7 +384,7 @@ async def pump(self): self._reap_exited() auxiliary_busy = self.auxiliary_busy() with self.lock: - assignments = self.scheduler.dispatch() if not self.closed and not auxiliary_busy else [] + assignments = self.scheduler.dispatch() if not self.closed and not self.paused and not auxiliary_busy else [] workers = {worker.device.uuid: worker for worker in self.workers()} selected, moved = [], False for task, device in assignments: diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 265b9080..52299bc5 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -1146,7 +1146,14 @@ def _inspect(self, values): get_folder_paths=lambda _: host.get('libraries', [])) validator = Setup(self.source, folders, LauncherRunner) validator.validate_root(str(engine)) - source = materialize_source(self.source, engine / 'launcher' / 'source') + try: + source = materialize_source(self.source, engine / 'launcher' / 'source') + except OSError as error: + from .denied_access import engine_folder_error + explained = engine_folder_error(error, engine) + if explained is None: + raise + raise explained from error self.setup = Setup(source, folders, LauncherRunner) from .hf_auth import validate self.setup.runner.token = validate(values.get('token', '')) diff --git a/freevideo_engine/comfy_monitor.py b/freevideo_engine/comfy_monitor.py new file mode 100644 index 00000000..5056b1d5 --- /dev/null +++ b/freevideo_engine/comfy_monitor.py @@ -0,0 +1,171 @@ +"""Top-bar system status: GPU memory, load and temperature, free disk, and an immediate stop. + +Stopping ends what is generating and pauses the waiting requests; it deletes none of them. +Readings that the driver cannot give are left out of the answer rather than reported as unknown. +""" +import ctypes +import logging +import os +import threading +import time + +from .monitoring import Memory, Utilization, nvml_device + + +class Reader: + """One open NVML handle, read on demand; a machine without NVML answers with no GPU rows.""" + def __init__(self, open_device=nvml_device): + self.open_device, self.lock, self.state, self.failed = open_device, threading.Lock(), None, False + + def _open(self): + if self.state is None and not self.failed: + try: + lib, handle, _ = self.open_device(0) + name = ctypes.create_string_buffer(96) + label = name.value.decode('utf-8', 'replace') if lib.nvmlDeviceGetName(handle, name, 96) == 0 else '' + self.state = (lib, handle, label) + except (OSError, RuntimeError, ValueError, AttributeError): + self.failed = True + return self.state + + def read(self): + with self.lock: + state = self._open() + if state is None: + return {} + lib, handle, label = state + result = {} + if label: + result['name'] = label + memory = Memory() + if lib.nvmlDeviceGetMemoryInfo(handle, ctypes.byref(memory)) == 0 and memory.total and memory.used <= memory.total: + result['memory_used'], result['memory_total'] = int(memory.used), int(memory.total) + load = Utilization() + if lib.nvmlDeviceGetUtilizationRates(handle, ctypes.byref(load)) == 0 and load.gpu <= 100: + result['utilization'] = int(load.gpu) + degrees = ctypes.c_uint() + if lib.nvmlDeviceGetTemperature(handle, 0, ctypes.byref(degrees)) == 0 and 0 < degrees.value < 150: + result['temperature'] = int(degrees.value) + return result + + +class Gate: + """Holds the waiting requests back while paused; the running one is not touched.""" + def __init__(self): + self.paused = False + self.changed = threading.Event() + + def pause(self): + self.paused = True + + def resume(self): + self.paused = False + self.changed.set() + self.changed.clear() + + def hold(self, timeout): + """True when a paused queue spent the wait, so the caller reports nothing to run.""" + if not self.paused: + return False + self.changed.wait(1.0 if timeout is None else min(timeout, 1.0)) + return True + + +def install_gate(queue, gate): + """Make a Comfy PromptQueue take no new request while the gate is paused.""" + if getattr(queue, '_freevideo_gate', None) is not None: + return queue._freevideo_gate + original = queue.get + + def get(timeout=None): + deadline = None if timeout is None else time.monotonic() + timeout + while gate.hold(timeout): + if deadline is not None and time.monotonic() >= deadline: + return None + return original(timeout) + + queue.get = get + queue._freevideo_gate = gate + return gate + + +def disk_free(path): + from .disk_space import free_bytes + try: + free = free_bytes(path) + except (OSError, ValueError): + return None + return int(free) if isinstance(free, (int, float)) and free >= 0 else None + + +def snapshot(reader, root, busy, paused): + result = {'busy': bool(busy), 'paused': bool(paused)} + result.update(reader.read()) + free = disk_free(root) + if free is not None: + result['disk_free'] = free + return result + + +def register(): + from aiohttp import web + import asyncio + import folder_paths + from server import PromptServer + server = PromptServer.instance + if server is None or getattr(server, '_freevideo_monitor', None) is not None: + return + reader, gate = Reader(), Gate() + server._freevideo_monitor = gate + install_gate(server.prompt_queue, gate) + root = folder_paths.base_path + + def front(): + return getattr(server, '_freevideo_front', None) + + def running(): + found = front() + if found is not None: + return found.current_queue()[0] + return server.prompt_queue.get_current_queue()[0] + + def busy(): + return bool(running()) + + def reply(value, status=200): + return web.json_response(value, status=status, headers={'Cache-Control': 'no-store'}) + + @server.routes.get('/freevideo/monitor') + async def read(request): + return reply(await asyncio.to_thread(snapshot, reader, root, busy(), gate.paused)) + + @server.routes.post('/freevideo/monitor/stop') + async def stop(request): + gate.pause() + found = front() + if found is not None: + found.paused = True + for row in running(): + found.cancel(row[1]) + else: + import nodes + nodes.interrupt_processing() + from . import comfy_chain + runner = comfy_chain.RUNNER + if runner is not None: + with runner.lock: + active = [key for key, row in runner.records.items() if row['state'] in ('queued', 'running')] + for key in active: + try: + runner.cancel(key) + except Exception: + logging.getLogger(__name__).warning('Could not stop chain %s', key, exc_info=True) + return reply({'paused': True}) + + @server.routes.post('/freevideo/monitor/resume') + async def resume(request): + gate.resume() + found = front() + if found is not None: + found.paused = False + return reply({'paused': False}) diff --git a/freevideo_engine/comfy_share.py b/freevideo_engine/comfy_share.py index ac1b2eca..830fcd02 100644 --- a/freevideo_engine/comfy_share.py +++ b/freevideo_engine/comfy_share.py @@ -107,6 +107,10 @@ def layout(data): return template, (x, y, w, h), hashlib.sha256(encoded + json.dumps(rect).encode()).hexdigest()[:20] +# The frame being written and the total (0 when unknown) for each export running now, read by the sharing dialog. +PROGRESS = {} + + def video_export(root, identity, data, stop): import av from PIL import Image @@ -141,6 +145,8 @@ def video_export(root, identity, data, stop): audio = writer.add_stream_from_template(audio_source) if audio_source else None streams = [source] + ([audio_source] if audio_source else []) base = Image.new('RGB', template.size, '#111720'); base.paste(template, mask=template.getchannel('A')) + total = source.frames or (round(float(source.duration * source.time_base * rate)) if source.duration else 0) + PROGRESS[identity] = (0, total) count = 0 for packet in reader.demux(streams): if stop.is_set(): @@ -154,6 +160,7 @@ def video_export(root, identity, data, stop): rendered.pts, rendered.time_base = frame.pts, frame.time_base for output in video.encode(rendered): writer.mux(output) count += 1 + PROGRESS[identity] = (count, max(total, count)) elif packet.dts is not None and audio is not None: packet.stream = audio; writer.mux(packet) for packet in video.encode(): writer.mux(packet) @@ -162,6 +169,7 @@ def video_export(root, identity, data, stop): temporary.replace(target) return target, key finally: + PROGRESS.pop(identity, None) temporary.unlink(missing_ok=True) @@ -191,6 +199,11 @@ async def preview(request): raise web.HTTPNotFound(text='First frame unavailable') from None return web.Response(body=data, content_type='image/jpeg', headers={'Cache-Control': 'private, no-store'}) + @server.routes.get('/freevideo/share/progress') + async def progress(request): + frame, total = PROGRESS.get(request.query.get('id'), (None, 0)) + return web.json_response({} if frame is None else {'frame': frame, 'total': total}, headers={'Cache-Control': 'no-store'}) + @server.routes.post('/freevideo/share/video') async def export(request): if exports.locked(): raise web.HTTPConflict(text='Another sharing export is running') diff --git a/freevideo_engine/decode.py b/freevideo_engine/decode.py index 2f315440..e054f312 100644 --- a/freevideo_engine/decode.py +++ b/freevideo_engine/decode.py @@ -88,6 +88,8 @@ def room(): converted_store=getattr(model_cache, 'converted_store', None)) vae, compute_cache_metrics = load_video_decoder(base, resident_blocks=count, weight_source=weight_source, linear_fp16=linear_compute_cache) + from .ram import release_freed_heap + release_freed_heap() vae.eval().requires_grad_(False) if type(resident_blocks) is not int or not 0 <= resident_blocks <= len(vae.decoder.transformer_blocks): raise ValueError('VAE resident block count is outside this decoder') diff --git a/freevideo_engine/denied_access.py b/freevideo_engine/denied_access.py index 890e75b9..d4ebefdb 100644 --- a/freevideo_engine/denied_access.py +++ b/freevideo_engine/denied_access.py @@ -10,6 +10,8 @@ import re import tempfile +from .system import windows + DENIED_ERRNO = (errno.EACCES, errno.EPERM) # Folders that stay in the description when the denied path is inside one of them. OWN_NAME = re.compile(r'^(?:freevideo|fv$)', re.I) @@ -156,3 +158,37 @@ def card_text(info, zh=False): value = re.sub(r'(?= 0) @@ -479,7 +480,7 @@ ApplicationWindow { text: t("Download the latest ↗", "下载最新版 ↗"); onClicked: backend.link("https://github.com/FlashML-org/FreeVideo/releases/latest") } FButton { objectName: "copyError"; visible: !diskFailure && !notAFault; text: t("Copy details", "复制详情"); onClicked: backend.copy(s.error) } FButton { objectName: "showError"; visible: !notAFault; text: errorDetailsOpen ? t("Hide details", "收起详情") : t("Show details", "查看详情"); flat: true; onClicked: errorDetailsOpen = !errorDetailsOpen } - FButton { objectName: "reportProblem"; visible: !diskFailure && !notAFault && s.failure.kind !== "port-taken" && s.failure.kind !== "comfy-restart" && s.failure.kind !== "comfy-folder"; text: t("Report a problem", "提交问题"); onClicked: backend.reportProblem(s.error, s.failure.title || "") } + FButton { objectName: "reportProblem"; visible: !diskFailure && !notAFault && s.failure.kind !== "port-taken" && s.failure.kind !== "comfy-restart" && s.failure.kind !== "comfy-folder" && s.failure.kind !== "engine-folder-denied"; text: reportOpened(s.error, s.failure.title || "") ? t("Opened", "已打开") : t("Report a problem", "提交问题"); widthText: t("Report a problem", "提交问题"); enabled: !reportOpened(s.error, s.failure.title || ""); onClicked: backend.reportProblem(s.error, s.failure.title || "") } FButton { objectName: "exportError"; visible: !diskFailure && !notAFault; text: t("Export report", "导出报告"); enabled: s.report.status !== "running"; onClicked: backend.exportReport() } } ScrollView { @@ -865,7 +866,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", "更新")) } + FButton { objectName: "updateBannerReport"; text: reportOpened(s.update.error, t("Update", "更新")) ? t("Opened", "已打开") : t("Report a problem", "提交问题"); widthText: t("Report a problem", "提交问题"); enabled: !reportOpened(s.update.error, t("Update", "更新")); flat: true; visible: s.update.status === "error" && !!s.update.error; implicitHeight: theme.heightSm; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } } FCard { objectName: "modelUpgradeCard" @@ -884,7 +885,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: "modelUpgradeReport"; visible: upgrade.status === "failed"; flat: true; text: reportOpened(upgradeExplanation(), t("Model upgrade", "模型升级")) ? t("Opened", "已打开") : t("Report a problem", "提交问题"); widthText: t("Report a problem", "提交问题"); enabled: !reportOpened(upgradeExplanation(), t("Model upgrade", "模型升级")); onClicked: backend.reportProblem(upgradeExplanation(), t("Model upgrade", "模型升级")) } FButton { objectName: "modelUpgradeButton"; primary: true visible: upgrade.status === "available" || upgrade.status === "releasable" @@ -988,7 +989,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", "图像模型包")) } + FButton { objectName: "imagePackReport"; visible: s.image_pack.install.state === "failed"; text: reportOpened(s.image_pack.detail || "", t("Image pack", "图像模型包")) ? t("Opened", "已打开") : t("Report a problem", "提交问题"); widthText: t("Report a problem", "提交问题"); enabled: !reportOpened(s.image_pack.detail || "", t("Image pack", "图像模型包")); 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 @@ -1018,7 +1019,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", "提示词模型")) } + FButton { objectName: "promptModelReport"; visible: s.prompt_model.install.state === "failed"; text: reportOpened(win.promptModelLine(), t("Prompt model", "提示词模型")) ? t("Opened", "已打开") : t("Report a problem", "提交问题"); widthText: t("Report a problem", "提交问题"); enabled: !reportOpened(win.promptModelLine(), t("Prompt model", "提示词模型")); flat: true; onClicked: backend.reportProblem(win.promptModelLine(), t("Prompt model", "提示词模型")) } } } } @@ -1485,7 +1486,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"; visible: s.failure.kind !== "comfy-folder"; text: t("Report a problem", "提交问题"); implicitHeight: theme.heightSm; font.pixelSize: theme.micro + 1; onClicked: backend.reportProblem(s.error, s.failure.title || "") } + FButton { objectName: "settingsReportProblem"; visible: s.failure.kind !== "comfy-folder" && s.failure.kind !== "engine-folder-denied"; text: reportOpened(s.error, s.failure.title || "") ? t("Opened", "已打开") : t("Report a problem", "提交问题"); widthText: t("Report a problem", "提交问题"); enabled: !reportOpened(s.error, s.failure.title || ""); 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 @@ -1747,7 +1748,7 @@ ApplicationWindow { FText { objectName: "updateDialogText"; text: s.update.phase === "waiting" ? t("A video is still generating. FreeVideo restarts and updates as soon as it finishes.", "还有视频正在生成,完成后会自动重启并更新。") : s.update.status === "ready" && s.update.candidate ? t("Downloaded. Restart to finish; models and settings are kept.", "下载完成,重启可完成更新,模型和设置都会保留。") : s.update.candidate && s.update.manual ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Download it and replace this version; models and settings are kept.", ")。下载新版后替换当前版本,模型和设置会保留。") : s.update.candidate ? t("FreeVideo ", "FreeVideo ") + releaseVersion(s.update.candidate) + t(" is available (current ", " 已发布(当前 ") + releaseVersion(currentRelease) + t("). Updating keeps your models and settings. FreeVideo restarts after the download; running videos finish first.", ")。更新会保留模型和设置,下载完成后自动重启;正在生成的视频会先完成。") : s.update.engine ? t("This launcher already includes engine ", "启动器已带有新版引擎 ") + releaseVersion(currentRelease) + t(" (installed: ", "(已安装 ") + engineVersion + (s.status === "open" ? t("). Updating takes about a minute, restarts ComfyUI once and keeps your models and settings.", ")。更新约需 1 分钟,会重启一次 ComfyUI,模型和设置都会保留。") : t("). Updating takes about a minute, then FreeVideo starts; models and settings are kept.", ")。更新约需 1 分钟,完成后自动启动,模型和设置都会保留。")) : s.update.status === "current" ? t("You're up to date.", "已是最新版本。") : s.update.status === "development" ? t("Running from source. Update with Git.", "当前从源码运行,请通过 Git 更新。") : s.update.status === "error" ? t("Could not check for updates.", "暂时无法检查更新。") : t("Checking the latest release…", "正在检查最新版本…"); color: theme.muted; Layout.fillWidth: 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", "更新")) } + FButton { objectName: "updateDialogReport"; text: reportOpened(s.update.error, t("Update", "更新")) ? t("Opened", "已打开") : t("Report a problem", "提交问题"); widthText: t("Report a problem", "提交问题"); enabled: !reportOpened(s.update.error, t("Update", "更新")); flat: true; Layout.fillWidth: true; visible: s.update.status === "error" && !!s.update.error; onClicked: backend.reportProblem(s.update.error, t("Update", "更新")) } FMeter { Layout.fillWidth: true; visible: s.update.status === "downloading"; active: true; fraction: s.update.progress && s.update.progress.total ? s.update.progress.done/s.update.progress.total : -1 } FReleaseNotes { objectName: "updateReleaseNotes"; visible: !!availableRelease; Layout.fillWidth: true; release: availableRelease; earlier: availableEarlier; zh: s.zh; heading: t("What's new", "更新内容") + " · " + releaseVersion(availableRelease) } FButton { text: t("Version & release notes", "版本与更新说明"); flat: true; onClicked: releaseNotesOpen = true } diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index fe1a5123..c168add8 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -113,6 +113,8 @@ class Session: old_versions_retired = None archives_busy = False action_notice = '' + # The report form opened last: (key, when). The same report is not opened again within REPORT_COOLDOWN. + report_opened = ('', 0.) pending_update = None pending_check = None upgrade = image_installer = prompt_installer = None @@ -1180,7 +1182,7 @@ def _raw_error(self): from .failure_details import launcher_failure # Windows words many refusals alike and often names no file; where it happened travels with the message. # Only a permission failure takes the line: the other cards read the first lines of the message. - if launcher_failure(message)['kind'] == 'permission': + if launcher_failure(message)['kind'] in ('permission', 'engine-folder-denied'): message = plain_text(text(row['denied'], self.language.startswith('zh')) + '\n' + message) return message @@ -2217,6 +2219,24 @@ def change_lan_password(self, text=None): # The links change with the password; do not wait for the next poll. self.controller.access_polled = 0 + REPORT_COOLDOWN = 60. + + @staticmethod + def report_key(error, title): + return f'{error}\x01{title}' + + def report_cooling(self, key): + opened, at = self.report_opened + return bool(opened) and opened == key and time.monotonic() - at < self.REPORT_COOLDOWN + + def start_report(self, error, title): + """True when the form is to be opened now; a second click on the same report within the cooldown is not.""" + key = self.report_key(error, title) + if self.report_cooling(key): + return False + self.report_opened = (key, time.monotonic()) + return True + def snapshot(self): from .failure_details import REPAIR_KINDS, launcher_failure, redacted_launcher_error, display_launcher_error from .launcher_copy import display, progress_view, source_name @@ -2392,7 +2412,7 @@ def snapshot(self): if line.startswith(MISMATCH_PATH + ': ') and line[len(MISMATCH_PATH) + 2:]: failure['path'] = line[len(MISMATCH_PATH) + 2:] break - if failure.get('kind') == 'permission' and row.get('error') and row.get('denied'): + if failure.get('kind') in ('permission', 'engine-folder-denied') and row.get('error') and row.get('denied'): from .denied_access import card_text # The card shows the detail instead of the action, so the action goes under the location. location = card_text(row['denied'], zh) @@ -2466,7 +2486,7 @@ def snapshot(self): # The elapsed row is only shown while working. An idle clock would # keep publishing changed snapshots after a launch has failed. elapsed=duration(time.monotonic()-self.started) if self.started and busy else '', summary=' · '.join(estimate), - failure=failure, + failure=failure, report_opened=self.report_opened[0] if self.report_cooling(self.report_opened[0]) else '', source=source, source_name=source_name(source, zh), proxy_mode=preferences['proxy_mode'], probe=probe, speeds=speeds, token_set=bool(self.token), log=self.tail.text, logs=[dict(label=display(n, zh), path=str(p)) for n, p in self.controller.terminal_sources()], diff --git a/freevideo_engine/macos_bootstrap.py b/freevideo_engine/macos_bootstrap.py index 2fb7f192..ea8a9192 100644 --- a/freevideo_engine/macos_bootstrap.py +++ b/freevideo_engine/macos_bootstrap.py @@ -76,6 +76,18 @@ def probe_links(root): GIT_UNAVAILABLE = 'Git is unavailable. Check the network connection, or install Git (Apple Command Line Tools), then retry.' +def git_unavailable(error): + """The standard message plus why FreeVideo's own Git could not be prepared. + + The message keeps its opening sentence, which the failure card matches on; + the cause follows on its own line, redacted and bounded, so a report shows which step failed. + """ + from .diagnostics import Redactor + text = Redactor().text(' '.join(str(error).split())) + text = (type(error).__name__ + ': ' + text)[:400] if text else type(error).__name__ + return GIT_UNAVAILABLE + '\nCause: ' + text + + def available_git(): executable = shutil.which('git') if not executable: diff --git a/freevideo_engine/macos_setup.py b/freevideo_engine/macos_setup.py index 5f409ee8..ab7f10b1 100644 --- a/freevideo_engine/macos_setup.py +++ b/freevideo_engine/macos_setup.py @@ -37,7 +37,7 @@ def install_git(self): except Exception as error: if self.cancel.is_set(): raise - raise RuntimeError(native.GIT_UNAVAILABLE) from error + raise RuntimeError(native.git_unavailable(error)) from error def component_tasks(self, uv): tasks = {} diff --git a/freevideo_engine/model_transfer.py b/freevideo_engine/model_transfer.py index 7feaee86..cc9a85ea 100644 --- a/freevideo_engine/model_transfer.py +++ b/freevideo_engine/model_transfer.py @@ -798,7 +798,7 @@ def _download(row, path, plan, progress_callback, headers_for, *, transfer_compl if path.is_file() and fragments['bytes'] == row['bytes']: return plan = dict(plan, manual_ranges=fragments['ranges']) - elif not plan.get('allow_model_restart') or resource_failure: + elif not plan.get('allow_model_restart'): network.pause_download(networking, name, path, retained, 'xet-or-legacy-resume-unavailable' if unresumable else reason, details=getattr(error, 'details', None)) else: diff --git a/freevideo_engine/modern_launcher.py b/freevideo_engine/modern_launcher.py index a730092c..b3ce4d9e 100644 --- a/freevideo_engine/modern_launcher.py +++ b/freevideo_engine/modern_launcher.py @@ -306,6 +306,13 @@ def link(self, value): @Slot(str, str) def reportProblem(self, error, title): + # The button shows "opened" at once and the form follows on the next turn, so a slow browser is not met with a second click. + if not session.start_report(error, title): + return + self.refresh() + QTimer.singleShot(0, lambda: self.openReport(error, title)) + + def openReport(self, error, title): from . import __version__ from .launcher_issue import environment_line, issue_url, machine_hardware, product_version from .windows_ux import open_browser diff --git a/freevideo_engine/monitoring.py b/freevideo_engine/monitoring.py index e0f60efb..e47604e9 100644 --- a/freevideo_engine/monitoring.py +++ b/freevideo_engine/monitoring.py @@ -12,7 +12,31 @@ from .long_paths import io_path, plain_data -def save(path, value, *, staging=None, plain_paths=True): +def load_ledger(path, *, retain=True): + """Read a verification ledger; a missing, unreadable or non-object file counts as empty. + + A ledger only caches what was verified, so damage (for example a file zero-filled + by a power loss) must lead to a fresh check, never to a failed install or run. + A damaged file is renamed to ``.corrupt-`` so it stays available for + diagnosis; ``retain=False`` leaves it in place for read-only callers.""" + path = io_path(path) + try: + value = json.loads(path.read_text(encoding='utf-8')) if path.is_file() else {} + if isinstance(value, dict): + return value + except (OSError, ValueError): + pass + if retain and path.is_file(): + try: + kept = path.with_name(path.name + '.corrupt-' + time.strftime('%Y%m%d')) + if not kept.exists(): + path.replace(kept) + except OSError: + pass + return {} + + +def save(path, value, *, staging=None, plain_paths=True, durable=False): path = io_path(path) path.parent.mkdir(parents=True, exist_ok=True) # Unique, closed staging file; Windows readers/antivirus may briefly hold @@ -24,6 +48,10 @@ def save(path, value, *, staging=None, plain_paths=True): dir=path.parent, delete=False) as stream: temporary = Path(stream.name) stream.write(json.dumps(plain_data(value) if plain_paths else value, ensure_ascii=False, indent=2) + '\n') + if durable: + # Without this, a power loss after the replace can leave a zero-filled file. + stream.flush() + os.fsync(stream.fileno()) for attempt in range(8): try: temporary.replace(path) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 0ab19354..fc9a13b0 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -359,6 +359,14 @@ def interpolate(group): # request's budget. Cached or preencoded conditioning skips the encoder # entirely and this does not apply. ENCODER_HOST_BYTES = int(3.6 * GiB) +# The host floor for the video stage is 4 GiB. On Linux, freed VAE-load +# temporaries are returned to the OS (ram.release_freed_heap), which took the +# measured 1344x768/10 s anonymous peak from 4.8 to 3.5 GiB; full runs passed +# with 4.0, 4.5 and 5.0 GiB of RAM. There the floor is the encoder floor, so one +# request is not admitted for the video stage and then refused for the encoder +# in front of it. Windows and macOS do not run that trim and keep 4 GiB. +VIDEO_HOST_BYTES = 4*GiB +LINUX_VIDEO_HOST_BYTES = ENCODER_HOST_BYTES # Residual placement keeps the same head/FF/projection kernels. Full-capacity # qualification and its platform scope are recorded in docs/POLICY.md. This @@ -955,7 +963,8 @@ def _choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto' # The encoding phase already has a measured host floor. Do not discard a # preloaded encoder because the later video stage needs a larger minimum; # automatic_profile replans generation after conditioning has been saved. - ram_minimum = ENCODER_HOST_BYTES if stage == 'encoding' else 4*GiB + ram_minimum = (ENCODER_HOST_BYTES if stage == 'encoding' else + LINUX_VIDEO_HOST_BYTES if hardware.system == 'Linux' else VIDEO_HOST_BYTES) # Let a Windows foreground request spend RAM growth headroom before # rejecting it, as the GPU's is spent below: the default 2 GiB is taken # from live available memory, so a machine whose free RAM sits a few diff --git a/freevideo_engine/provision.py b/freevideo_engine/provision.py index ac65ae42..196709ed 100644 --- a/freevideo_engine/provision.py +++ b/freevideo_engine/provision.py @@ -9,7 +9,7 @@ from .bootstrap import PACKAGE, model_target, installation_model_files from .locking import runtime_lock -from .monitoring import save +from .monitoring import save, load_ledger from .install_tuning import hf_receipt, unchanged_before from . import network from .storage import record_download, compact_sources, fingerprint @@ -80,7 +80,7 @@ def _models(plan, retained): # ownership checks intact; a different spelling must not lose the receipt. root = Path(plan['root']).expanduser().resolve() ledger = root / 'verified-models.json' - stamps = json.loads(ledger.read_text(encoding='utf-8')) if ledger.is_file() else {} + stamps = load_ledger(ledger) model_dir, encoder_dir = (Path(plan[name]).expanduser().resolve() for name in ('model_dir', 'encoder_dir')) prepared = plan.get('prepared_model') files = installation_model_files(prepared, plan.get('reuse_cache'), plan.get('sampling_caches')) @@ -151,7 +151,7 @@ def complete(row, path, stamp, operation, origin=None): save(path.with_suffix('.json'), dict(bytes=row['bytes'], sha256=row['sha256'])) if origin: record_download(root, path, row, origin=origin) - save(ledger, stamps) + save(ledger, stamps, durable=True) ready[0] += 1 emit('model_ready', index=ready[0], total=len(files), file=str(path), verification=stamp.get('method'), operation=operation) @@ -421,7 +421,7 @@ def verify_cache(plan, cache): or len(groups) != len(expected) or {r.get('group') for r in groups} != expected): raise ValueError('Prepared cache is not complete; original model files retained') ledger = Path(plan['root']) / 'verified-cache.json' - stamps = json.loads(ledger.read_text(encoding='utf-8')) if ledger.is_file() else {} + stamps = load_ledger(ledger) assets = list(validate_catalog(manifest, count)) total = len(groups) + len(assets) step('Model files verifying', 0, total) @@ -440,7 +440,7 @@ def verify_cache(plan, cache): raise ValueError('AdaLN model asset failed integrity check: ' + row['file']) check_table(path, row, identity, verify_hash=False) step('Model files verifying', index, total) - save(ledger, stamps) + save(ledger, stamps, durable=True) return manifest diff --git a/freevideo_engine/ram.py b/freevideo_engine/ram.py index 1df1d7f1..1516ef26 100644 --- a/freevideo_engine/ram.py +++ b/freevideo_engine/ram.py @@ -4,6 +4,25 @@ from .system import windows +def release_freed_heap(): + """Return freed heap pages to the OS; glibc keeps them in the arena otherwise. + + Loading a checkpoint one converted tensor at a time frees every read buffer, + but the allocator keeps those pages as anonymous memory, about half of the + bytes uploaded. Elsewhere (Windows, musl, macOS) there is nothing to trim. + """ + import gc + gc.collect() + if sys.platform != 'linux': + return False + try: + import ctypes + ctypes.CDLL('libc.so.6').malloc_trim(0) + except (OSError, AttributeError): + return False + return True + + def inference_working_pss(rollup, protected_file_bytes=None): """Keep anonymous/shared/dirty pages; clean file pages may be reclaimed. diff --git a/freevideo_engine/refine.py b/freevideo_engine/refine.py index 094cd503..67ca2050 100644 --- a/freevideo_engine/refine.py +++ b/freevideo_engine/refine.py @@ -147,6 +147,11 @@ def generate_latents(transformer, prompt_embeds, text_token_tags, num_frames, nu encoder_hidden_states=prompt_embeds[None], timestep=times, timestep_indices=time_ids, token_tags=tags, position_ids=positions, video_indices=video_ids, audio_indices=audio_ids, text_indices=text_ids, return_dict=False) + if refine_schedule is not None: + # This schedule's scheduler is the upstream class, which does not look at + # the frame preview itself. The call only reads; the update below is unchanged. + from .frame_preview import observe + observe(prediction[0, condition_rows:], video_t, video_rows[condition_rows:]) video_rows[condition_rows:] = video_scheduler.step( prediction[0, condition_rows:].float(), video_t, video_rows[condition_rows:], return_dict=False)[0] if step_seconds is not None: diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index cfea3dc5..ab5b1839 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,78 @@ { "schema": 1, - "product_version": "0.4.37", + "product_version": "0.4.38", "en": { - "summary": "Generation previews are a frame sheet again with an optional loop, the editor can narrate a line in a chosen voice, Replace character takes two people, and Windows installs download less.", + "summary": "Agent connections handle picture-only requests, long-video and download failures name their cause, the editor and tutorials are tidier, dropdowns, checkboxes and failure dialogs share one look, and an optional system status panel is added.", "changes": [ - "The preview shown while a video is generated is a sheet of frames again. A new setting, Preview during generation, plays it as a loop of the video in progress instead. The preview is also shown when the window is short.", - "In the editor, right-click an empty spot on the dialogue or recording track and choose Narration: write a line, pick a voice, and it is generated to the length of the selection. New tutorial chapter and tour card.", - "Replace character takes a second person: the first picture replaces the person on the left, the second the person on the right (up to two people).", - "Generated music follows the written description and avoids an instrument it rules out, such as \"no piano\". The first-use download of the music files shows its percent on the generation bar.", - "Windows: when the graphics driver reports an error in the second pass, only the second pass is redone from the finished first pass instead of ending the request.", - "Windows installs download about 469 MB less: the ComfyUI workflow-template packages are no longer installed.", - "For multi-segment generation, segment progress now covers decoding and reaches 100% when finished.", - "Export and subtitle jobs show their own names and progress in the Agent activity list; the creation panel shows planning and adding counts.", - "Editor: the lines panel takes at most 40% of the editing area and scrolls inside, the five tracks stay visible, the resample menu is hidden while narrating, the audio button is now \"Separate original sound\", and characters, scenes and props sit in one row of equal squares.", - "Tutorials add Text and subtitles, Replace two people and Your own LoRA. Tour: \"Take me there\" opens the right view first, the media tour starts with first and last frames, and the \"Continue generating\" card puts the start video on the timeline.", - "The launcher points out a Windows PowerShell that cannot start; diagnostic reports on Windows record the lowest available memory and page-fault counts during sampling; in the update dialog, old and new waveforms are easier to tell apart and the ruler's last label is complete.", - "Download report is also available in reopened windows, the library and segment results, and the Reuse settings and Copy path buttons are centered; the top bar no longer overlaps in a narrow window." + "Agent: a request for one picture is handled as a single picture task, without video planning or a video. The activity list names picture, edit, music, track, sound, lines and dubbing jobs and shows their share, line count and time left, and keeps its scroll position.", + "Connection card: when FreeVideo on this computer cannot be reached, the card says so and the feedback report names the kind of failure. Report a problem buttons show that the form has opened and do not open it twice.", + "Launcher: a graphics card below the hardware floor shows the unsupported-card notice even when its driver is also old; on Windows, a component blocked by application control shows a card with how to allow it, and a folder that Windows does not let the account create shows a card asking for another folder; on Mac, a failed Git setup names the step and its cause; a newer ComfyUI front end no longer leaves a rejection when the launch workflow loads.", + "Long videos: when seam sounds were not kept before parts are joined, the error names the seams and the reason, and while the parts are joined the panel shows which part is being written.", + "Model download: after a pause for low free memory, an agreed re-download now proceeds with a streamed download instead of pausing again; a damaged verification record is treated as empty and rewritten.", + "Linux: machines with 8 GB of RAM and about 3.6 GiB free can now generate instead of being refused.", + "Music: generated music follows the written description over the style and mood, avoids an instrument it rules out, and a default prompt that asks for no background music gets no music.", + "Editor: with the lines panel open it takes at most 40% of the height and scrolls inside, the re-dub button stays visible, track names are no longer cut off, a second sound track can be removed from its row, the Creations list follows deletes, undos and new results, and the Media section lays characters, scenes and props in one row.", + "Tutorial and tour: new chapters for adding a spoken line, scenes and props, and changing part of a picture; a first-and-last-frame card first on the media tour; Take me there opens the right view before pointing at a control.", + "System status: a new optional setting shows graphics memory and temperature in the top bar, with a panel for the graphics card, memory, utilization, temperature and free disk space, and a two-step Stop now.", + "Waiting prompts: the image editor keeps its upload, font, object-finding and save status visible until each task finishes, a shared video export shows the frame reached and the time used, and an upload that never answers is cut off so the panels unlock and show the failure line.", + "Interface: dropdowns, checkboxes and settings rows share one style, advanced settings sit in one card of flat rows, the top-right entries form one tidy text group, failure dialogs and Autopilot failure messages show one reason line with the details folded, and close buttons and the tutorial layout are consistent." ] }, "zh": { - "summary": "生成预览回到多帧拼图并可选循环播放,剪辑页可用所选音色配音,替换人物支持两个人,Windows 安装下载更少。", + "summary": "Agent 连接能处理只要一张图的请求,长视频和下载失败时写明原因,剪辑与教程更整齐,下拉框、勾选框和失败对话框样式统一,并新增可选的运行状态面板。", "changes": [ - "生成中的预览默认回到多帧拼图;设置里新增“生成中的预览”,勾选“循环播放”后在原位循环播放。窗口再矮也会显示预览。", - "剪辑页可在人声轨或录音轨的空处右键“配音”,写一句话并选一个音色按选区长度生成;新增对应教程章和导览卡。", - "替换人物可添加第二张人物图,请求写明第一张替换左侧、第二张替换右侧的人;最多 2 个人。", - "生成配乐按所写描述来,写“不要钢琴”之类的排除时会避开该乐器;首次下载配乐文件时生成条显示百分比。", - "Windows:第二遍遇到显卡驱动报错时,用已完成的第一遍只重做第二遍,不再让整次请求结束。", - "Windows 安装少下载约 469 MB:不再安装 ComfyUI 自带的工作流模板包。", - "多段生成的段进度贯穿解码,完成时为 100%,段宽按帧数。", - "AI 任务列表里的导出和字幕任务显示自己的名字和进度;创作面板显示“正在规划分段…”“正在添加 3/10…”。", - "剪辑:台词面板最高占编辑区 40% 并在内部滚动、五条轨保持可见,配音时不再显示二次采样菜单,音频按钮改称“分离原声”;素材页角色、场景、道具排成一行等大方块。", - "教程新增“文字与字幕”“替换两个人”“使用自己的 LoRA”章;导览“带我去”先打开章节所在的视图,素材导览先出“首帧 + 尾帧”,“接着生成”卡把起始视频放上时间线。", - "启动器会指出无法启动的 Windows PowerShell;诊断报告在 Windows 上记录每个采样步的可用内存低点和缺页数;更新弹窗中新旧波形区分更明显,标尺末端标签完整显示。", - "重新打开的窗口、作品库和分段结果也有“下载报告”,“复用设置”“复制路径”按钮居中;窗口较窄时顶栏不再重叠。" + "Agent:只要一张图的请求作为单次图片任务处理,不做视频规划、不生成视频;活动列表为图片、编辑图片、配乐、音轨、声音、台词、配音任务命名,并显示份额、行数和剩余时间,轮询时保持滚动位置。", + "连接卡:连不上本机 FreeVideo 时,卡片给出提示,反馈报告写明故障类型;“提交问题”按钮点击后显示已打开,不会重复打开。", + "启动器:显卡低于硬件下限时,即使驱动也过旧,仍显示不支持的显卡提示;Windows 上,应用控制拦截运行组件时显示放行指引,所选文件夹不允许当前账户创建时提示改选其他文件夹;Mac 上,Git 准备失败时写明失败步骤和原因;加载启动工作流时,新版 ComfyUI 前端不再留下拒绝记录。", + "长视频:合并前接缝声音没有保住时,错误文字写出是哪几个接缝、什么原因;接合过程中,面板显示正在写第几段。", + "模型下载:因可用内存不足暂停后,已同意的重下现在改走流式下载,不再再次暂停;校验记录损坏时按空记录处理并重写。", + "Linux:内存 8 GB、可用约 3.6 GiB 的电脑现在能生成,不再被拒绝。", + "配乐:生成的配乐以所写描述为准,不再被风格和情绪盖过,会避开描述排除的乐器;默认提示词里写了“不要背景音乐”时不再生成配乐。", + "剪辑:打开台词面板时,面板最高占编辑器高度的 40%,列表在内部滚动,“重新配音”始终可见;轨道名不再被截断,存在声音 2 时可在其行里移除;创作列表随删除、撤销和新结果更新;创作面板素材区的角色、场景、道具排成一行。", + "教程与导览:新增“为一句话配音”“场景和道具”“局部改图”教程章节;素材导览先介绍首帧 + 尾帧;“带我去”先打开章节所在的视图,再指向控件。", + "运行状态:设置新增可选的“运行状态”,顶栏显示显存和温度,面板列出显卡、显存、使用率、温度和硬盘剩余空间,并提供两步确认的“立即停止”。", + "等待提示:图片编辑器的上传、字体、找出物体和保存提示显示到任务完成并显示已用时间;分享视频导出显示已到的帧和用时;一直没有回应的上传会被中止,各处面板解锁并显示失败提示。", + "界面:下拉框、勾选框和设置项样式统一,高级设置收进一张卡片逐行排列,右上角入口排成一组文字,失败对话框和 AI 全自动的失败消息只写一行原因并把详情折叠,关闭按钮和教程排版一致。" ] }, "history": [ + { + "product_version": "0.4.37", + "en": { + "summary": "Generation previews are a frame sheet again with an optional loop, the editor can narrate a line in a chosen voice, Replace character takes two people, and Windows installs download less.", + "changes": [ + "The preview shown while a video is generated is a sheet of frames again. A new setting, Preview during generation, plays it as a loop of the video in progress instead. The preview is also shown when the window is short.", + "In the editor, right-click an empty spot on the dialogue or recording track and choose Narration: write a line, pick a voice, and it is generated to the length of the selection. New tutorial chapter and tour card.", + "Replace character takes a second person: the first picture replaces the person on the left, the second the person on the right (up to two people).", + "Generated music follows the written description and avoids an instrument it rules out, such as \"no piano\". The first-use download of the music files shows its percent on the generation bar.", + "Windows: when the graphics driver reports an error in the second pass, only the second pass is redone from the finished first pass instead of ending the request.", + "Windows installs download about 469 MB less: the ComfyUI workflow-template packages are no longer installed.", + "For multi-segment generation, segment progress now covers decoding and reaches 100% when finished.", + "Export and subtitle jobs show their own names and progress in the Agent activity list; the creation panel shows planning and adding counts.", + "Editor: the lines panel takes at most 40% of the editing area and scrolls inside, the five tracks stay visible, the resample menu is hidden while narrating, the audio button is now \"Separate original sound\", and characters, scenes and props sit in one row of equal squares.", + "Tutorials add Text and subtitles, Replace two people and Your own LoRA. Tour: \"Take me there\" opens the right view first, the media tour starts with first and last frames, and the \"Continue generating\" card puts the start video on the timeline.", + "The launcher points out a Windows PowerShell that cannot start; diagnostic reports on Windows record the lowest available memory and page-fault counts during sampling; in the update dialog, old and new waveforms are easier to tell apart and the ruler's last label is complete.", + "Download report is also available in reopened windows, the library and segment results, and the Reuse settings and Copy path buttons are centered; the top bar no longer overlaps in a narrow window." + ] + }, + "zh": { + "summary": "生成预览回到多帧拼图并可选循环播放,剪辑页可用所选音色配音,替换人物支持两个人,Windows 安装下载更少。", + "changes": [ + "生成中的预览默认回到多帧拼图;设置里新增“生成中的预览”,勾选“循环播放”后在原位循环播放。窗口再矮也会显示预览。", + "剪辑页可在人声轨或录音轨的空处右键“配音”,写一句话并选一个音色按选区长度生成;新增对应教程章和导览卡。", + "替换人物可添加第二张人物图,请求写明第一张替换左侧、第二张替换右侧的人;最多 2 个人。", + "生成配乐按所写描述来,写“不要钢琴”之类的排除时会避开该乐器;首次下载配乐文件时生成条显示百分比。", + "Windows:第二遍遇到显卡驱动报错时,用已完成的第一遍只重做第二遍,不再让整次请求结束。", + "Windows 安装少下载约 469 MB:不再安装 ComfyUI 自带的工作流模板包。", + "多段生成的段进度贯穿解码,完成时为 100%,段宽按帧数。", + "AI 任务列表里的导出和字幕任务显示自己的名字和进度;创作面板显示“正在规划分段…”“正在添加 3/10…”。", + "剪辑:台词面板最高占编辑区 40% 并在内部滚动、五条轨保持可见,配音时不再显示二次采样菜单,音频按钮改称“分离原声”;素材页角色、场景、道具排成一行等大方块。", + "教程新增“文字与字幕”“替换两个人”“使用自己的 LoRA”章;导览“带我去”先打开章节所在的视图,素材导览先出“首帧 + 尾帧”,“接着生成”卡把起始视频放上时间线。", + "启动器会指出无法启动的 Windows PowerShell;诊断报告在 Windows 上记录每个采样步的可用内存低点和缺页数;更新弹窗中新旧波形区分更明显,标尺末端标签完整显示。", + "重新打开的窗口、作品库和分段结果也有“下载报告”,“复用设置”“复制路径”按钮居中;窗口较窄时顶栏不再重叠。" + ] + } + }, { "product_version": "0.4.36", "en": { @@ -384,41 +421,6 @@ "Mac:安装开始前,启动器先检查所选文件夹所在磁盘是否支持 Python 运行环境所需的文件操作。不支持硬链接时改用复制继续安装;不支持链接或改名时,在安装任何内容之前提示选择 Mac 内置硬盘上的文件夹。" ] } - }, - { - "product_version": "0.4.25", - "en": { - "summary": "An audio clip can be used as is, so the video carries that clip as its sound; a tutorial and feature examples explain each feature with an example that can be filled in directly; after a video finishes, the model stays loaded and the next video starts without reloading.", - "changes": [ - "Studio: audio cards offer \"Reference\" or \"Use as is\". With \"Use as is\", the clip is written into the video unchanged as its sound, the picture follows it, and the duration follows the clip and is locked. \"Use as is\" is not available with \"Preview first\" or when extending a video. Replace person downloads its model when it is missing.", - "Guides: \"Tutorial\" in Settings has chapters with pictures for each feature, and \"Try it\" fills a chapter's example into the creation panel. \"Feature examples\" shows each feature as a looping preview of the input and the result, with \"Use this example\". The examples are shown once on first use, and after an update only the new ones are shown.", - "Memory and speed: after a video finishes, the model stays on the graphics card and in memory with no time limit, so the next video starts without reloading. It is released when memory runs low, when another program starts using more video memory, or before the picture or prompt model starts; when memory is tight, as much of it is kept as still leaves room for the next request.", - "Speed: the first video of a new length or size starts sooner, because three of the model's kernels are compiled once for every length, size and head split. Requests with LoRA adapters run faster, because each adapter's input projection is computed once per layer instead of once for every group of attention heads. A slowdown switched on automatically after the app was closed or stopped unexpectedly now lifts after one completed generation instead of three; a slowdown caused by a reported GPU error still needs three.", - "Generation: pressing Generate with an empty prompt shows \"The prompt is empty.\" next to the button, with \"Generate anyway\". In the editor, broken characters left in a description by a cut or a paste are removed before the part is sent. On a computer with several graphics cards, queued videos run at the same time, one on each card.", - "Workflows: a saved workflow opens ready to run when a value on its FreeVideo node is no longer accepted; that value returns to its default and the rest of the workflow is unchanged.", - "Creations: a video can be marked as a favorite with the heart button, and \"All\" and \"Favorites\" at the top switch between every video and favorites only; the choice is kept for the next time Creations opens.", - "Autopilot: a chat can be renamed with \"Rename\" in its menu. The name is kept after a restart, and clearing it shows the first message as the title again.", - "Install: on Linux, setup from the launcher downloads Git and a C++ compiler privately when they are missing instead of asking for sudo; only basic tools such as curl still come from the system, and the launcher shows the command to install them. A graphics driver older than 580 gets its notice with the detected card even when nvidia-smi cannot answer the setup query. When the system is older than 2022 or lacks the graphics libraries its window needs, the Linux desktop app shows the reason and what to do instead of closing without a message.", - "Updates: on every platform, update downloads can use the mirror in China, and a slow source is switched automatically, continuing the download.", - "Diagnostics: on Mac, a generation refused at the start for lack of free memory has its own row in the error panel, and the report records the memory at that moment. Feedback for a video in Creations that cannot be opened includes the player's error code and the file's response. Diagnostic reports record which graphics card the run used." - ] - }, - "zh": { - "summary": "音频可以原样使用,视频直接以这段音频为声音;新增教程和功能示例,逐项介绍功能,示例可以直接填入;视频完成后模型保留,下一条视频无需重新加载。", - "changes": [ - "工作室:音频卡片可选“参考”或“原样使用”。选择“原样使用”时,这段音频原样写入视频作为声音,画面随之生成,时长跟随音频并锁定。开启“先出预览”或接着生成时,不能选择“原样使用”。替换人物缺少模型时会自动下载。", - "指引:设置中的“教程”按功能分章,配有图片,点“试一试”可把该章的示例填入创作面板。“功能示例”逐项以循环预览展示输入和成片,并可点“用这个示例”。首次使用时会依次展示一遍,更新后只展示新增的示例。", - "内存与速度:视频完成后,模型会一直保留在显卡和内存中,不再有时间限制,下一条视频无需重新加载。内存不足、其他程序占用的显存增加,或图片和提示词模型启动前,模型会被释放;内存紧张时,在保证下一次请求放得下的前提下尽量多保留。", - "速度:换新的时长或尺寸后,第一条视频更早开始,模型的三个内核对所有时长、尺寸和分头方式只编译一次。挂 LoRA 的请求更快,每个适配器的输入降维乘法每层只算一次,不再每个注意力头组各算一次。应用被意外关闭或结束后自动开启的放慢,完成 1 次生成就解除,不再需要 3 次;显卡报告错误引起的放慢仍需 3 次。", - "生成:提示词为空时点生成,按钮旁会显示“提示词为空。”,并可点“继续生成”。剪辑视图中,剪切或粘贴在描述里留下的残缺字符会在发送前去掉。有多张显卡的电脑上,排队的视频会同时生成,每张显卡一条。", - "工作流:另存的工作流中,FreeVideo 节点上有不再接受的取值时,打开即可运行;该取值恢复为默认值,工作流其余内容不变。", - "作品库:可以用心形按钮收藏视频,顶部的“全部”和“收藏”可切换显示全部视频或只显示收藏;下次打开作品库时保留所选。", - "AI 全自动:对话可以在菜单中“重命名”。名称在重启后保留,清空名称后重新以第一句话作为标题。", - "安装:Linux 上从启动器安装时,缺少 Git 和 C++ 编译器会改为私有下载,不再需要 sudo;只有 curl 等基础工具仍需系统提供,启动器会给出安装命令。即使 nvidia-smi 无法响应安装检查的查询,低于 580 的显卡驱动也会显示带检测到显卡的提示。系统早于 2022 年或缺少窗口所需的图形库时,Linux 桌面版会说明原因和处理办法,不再无提示地退出。", - "更新:所有平台的更新下载都可走国内镜像,下载源过慢时自动换源续传。", - "诊断:Mac 上因可用内存不足而在开始前被拒绝的生成,在错误面板中单独一行说明,报告会记录当时的内存。作品库中视频打不开时,反馈会附上播放器错误码和文件响应。诊断报告会记录这次运行使用的显卡。" - ] - } } ] } diff --git a/freevideo_engine/sampling_assets.py b/freevideo_engine/sampling_assets.py index 06bdf4b4..7ec08bb8 100644 --- a/freevideo_engine/sampling_assets.py +++ b/freevideo_engine/sampling_assets.py @@ -276,7 +276,7 @@ def prepare(root, machine, sampling, task, *, progress, interrupted=None, enviro import shutil import threading from . import adaln_assets as assets, network, provision - from .monitoring import save + from .monitoring import save, load_ledger cache = Path(machine['cache']) if not (cache / 'manifest.json').is_file(): return dict(seconds=0., downloaded_bytes=0) # Generation reports an invalid installation. @@ -286,7 +286,7 @@ def prepare(root, machine, sampling, task, *, progress, interrupted=None, enviro root = Path(root) shared = cache_root(machine['model_root']) ledger = root / 'verified-models.json' - stamps = json.loads(ledger.read_text(encoding='utf-8')) if ledger.is_file() else {} + stamps = load_ledger(ledger) stop = threading.Event() def check(): if stop.is_set(): @@ -313,7 +313,7 @@ def check(): network.retain_partial(path.with_suffix('.json'), 'rejected') missing.append((table, row, path)) if len(stamps) != recorded: - save(ledger, stamps) + save(ledger, stamps, durable=True) if not missing: retire_superseded(machine) return dict(seconds=0., downloaded_bytes=0) @@ -365,7 +365,7 @@ def moved(current, size, speed, **_): with lock: done[index], speeds[index] = row['bytes'], 0. stamps[str(path)] = provision.file_identity(path, row) - save(ledger, stamps) + save(ledger, stamps, durable=True) emit(force=True) emit(force=True) # Each table is fifty small files; several transfers hide per-file latency. diff --git a/freevideo_engine/vae_weights.py b/freevideo_engine/vae_weights.py index 83a91edd..498fff2d 100644 --- a/freevideo_engine/vae_weights.py +++ b/freevideo_engine/vae_weights.py @@ -80,6 +80,11 @@ def _owns_storage(tensor): return tensor.storage_offset() == 0 and tensor.untyped_storage().nbytes() == tensor.numel() * tensor.element_size() +# Freed read buffers stay in the allocator's arena; handing them back this often +# keeps the loading peak near the live weights instead of half the bytes read. +TRIM_INTERVAL_BYTES = 256 << 20 + + def load_state(model, paths, *, device_for_name, skip=(), linear_dtype=None, mapped=False): """Read one tensor at a time, with no retained full state dictionary. @@ -123,6 +128,7 @@ def load_state(model, paths, *, device_for_name, skip=(), linear_dtype=None, map if set(sources) != set(expected): raise ValueError('Missing VAE weights: ' + ', '.join(sorted(set(expected) - set(sources)))) converted, loaded, mapped_bytes, streamed_count, streamed_bytes = 0, 0, 0, 0, 0 + since_trim = trimmed = 0 for path, stamp in files.items(): names = [name for name in _file_order(path) if sources.get(name) == path] if not names: @@ -165,12 +171,20 @@ def load_state(model, paths, *, device_for_name, skip=(), linear_dtype=None, map loaded += target.numel() * target.element_size() mapped_bytes += target.numel() * target.element_size() if view and target is source else 0 del source, value + since_trim += loaded - trimmed + trimmed = loaded + if since_trim >= TRIM_INTERVAL_BYTES: + from .ram import release_freed_heap + release_freed_heap() + since_trim = 0 if parameter: owner._parameters[leaf] = torch.nn.Parameter(target, requires_grad=False) else: owner._buffers[leaf] = target del target del views + from .ram import release_freed_heap + release_freed_heap() current = path.stat() if (current.st_size, current.st_mtime_ns, current.st_ctime_ns) != stamp: raise ValueError('VAE checkpoint changed during preparation: ' + str(path)) diff --git a/freevideo_engine/worker.py b/freevideo_engine/worker.py index 954a086c..e1670f6e 100644 --- a/freevideo_engine/worker.py +++ b/freevideo_engine/worker.py @@ -651,6 +651,8 @@ def refined_complete(video, sound, receipt): else: engine.close() metrics['transformer_released_before_decode'] = released(engine) + from .ram import release_freed_heap + release_freed_heap() # Keep the bounded file reader alive through transformer teardown. # Releasing DiT mappings is independent of the decoder checkpoint, # so both operations can run concurrently and give slow storage diff --git a/freevideo_source_bootstrap.py b/freevideo_source_bootstrap.py index 11d23113..9b7e6dd4 100644 --- a/freevideo_source_bootstrap.py +++ b/freevideo_source_bootstrap.py @@ -131,6 +131,9 @@ def root(): 'web/assets/tour/cc-speech.mp4', 'web/assets/tour/cc-speech.webp', 'web/assets/tour/cc-speech-wave.webp', + 'web/assets/tour/cd-narration.mp4', + 'web/assets/tour/cd-narration.webp', + 'web/assets/tour/cd-narration-wave.webp', 'web/assets/tour/c10-source.webp', 'web/assets/tour/fox-sheet.webp', )) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index c22d5dc8..0d317ef9 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -118,6 +118,7 @@ test('main browser entry registers both views and preserves node hooks', async t 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("from './prompt_sections.js'", `from '${new URL('../web/prompt_sections.js', import.meta.url).href}'`); + source = source.replace("from './fv_select.js'", `from '${new URL('../web/fv_select.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;') @@ -555,8 +556,9 @@ test('main browser entry registers both views and preserves node hooks', async t assert.match(note.textContent, /Choose keyframes or references/); media.widgets[0].value = '[]'; media.inputs = []; media.onConnectionsChange(); await Promise.resolve(); assert.equal(audioHelp.hidden, true); - assert.match(mode.textContent, /Text to video/); - const imageRole = toolbar.children.find(e => e.tag === 'select'); + assert.equal(mode.textContent, ''); + assert.equal(mode.hidden, true); + const imageRole = toolbar.children.find(e => e.tag === 'div' && e.className === 'fv-select')?.children.find(e => e.tag === 'select'); const imagePicker = toolbar.children.find(e => e.tag === 'input' && e.accept.startsWith('.png')); assert.equal(imageRole.value, 'reference'); api.fetchApi = async () => ({ok: true, json: async () => ({file: 'image.png'})}); diff --git a/web/agent_activity.js b/web/agent_activity.js index 9087b4cc..c4a06fbd 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -11,8 +11,18 @@ const KINDS = { export: ['Exporting', '导出中', 'M12 3v12m0 0 4-4m-4 4-4-4M4 17v3h16v-3'], listen: ['Transcribing', '识别中', 'M4 6h16M4 12h10M4 18h13'], enhance: ['Enhancing prompt', '增强提示词', 'M4 20l4-1 10-10-3-3L5 16zM14 7l3 3M18 3l1 2 2 1-2 1-1 2-1-2-2-1 2-1z'], + image: ['Generating image', '生成图片', 'M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm-1 12 5-5 4 4 3-3 5 5M16 9h.01'], + image_edit: ['Saving image', '保存图片', 'M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1zm-1 12 5-5 4 4 3-3 5 5M15 4l3 3'], + music: ['Composing music', '生成配乐', 'M9 18V6l10-2v12M9 18a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0zm10-2a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z'], + stems: ['Separating tracks', '分离音轨', 'M3 12h6m0 0 4-6h8M9 12l4 6h8M9 12h12'], + finish_sound: ['Finishing sound', '声音收尾', 'M4 10v4h3l4 4V6l-4 4H4zm11-1a4 4 0 0 1 0 6m2.5-9a8 8 0 0 1 0 12'], + voice_lines: ['Finding the lines', '识别台词', 'M4 5h16v10H9l-5 4V5zm4 3h8M8 11h5'], + voice: ['Redubbing', '重新配音', 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM6 12a6 6 0 0 0 12 0M12 18v3m-3 0h6'], + voice_say: ['Dubbing', '配音中', 'M12 3a3 3 0 0 1 3 3v6a3 3 0 0 1-6 0V6a3 3 0 0 1 3-3zM6 12a6 6 0 0 0 12 0M12 18v3m-3 0h6M19 3v4m-2-2h4'], }; -const NAMES = {generate: ['Video', '视频'], assemble: ['Sequence', '成片'], vlm: ['Check', '检查'], enhance: ['Prompt', '提示词'], export: ['Export', '导出'], listen: ['Subtitles', '字幕']}; +const NAMES = {generate: ['Video', '视频'], assemble: ['Sequence', '成片'], vlm: ['Check', '检查'], enhance: ['Prompt', '提示词'], export: ['Export', '导出'], listen: ['Subtitles', '字幕'], + image: ['Image', '图片'], image_edit: ['Edit image', '编辑图片'], music: ['Music', '配乐'], stems: ['Tracks', '音轨'], finish_sound: ['Sound', '声音'], + voice_lines: ['Lines', '台词'], voice: ['Voice', '配音'], voice_say: ['New line', '新台词']}; // Short reasons for the failures people can act on; the engine's own message stays in the tooltip. const REASONS = { @@ -33,16 +43,33 @@ function glyph(path) { // The same two wordings as the conversation's cards. const left = (s, t) => s >= 60 ? t(`About ${Math.ceil(s / 60)} min`, `约 ${Math.ceil(s / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); // The engine's phases in the reader's language. -function phaseText(p, t) { +function phaseText(p, t, kind) { const step = p.step; // The first step is still getting ready: the line does not count from zero. A row here does not know which // part of a long video it is on, and a later part loads nothing, so it does not say the model is loading. if (p.phase === 'sampling' && step?.total) return step.done >= 1 ? t(`Step ${step.done} / ${step.total}`, `生成 ${step.done} / ${step.total}`) : t('Preparing', '准备中'); + // Dubbing counts lines, not steps. + if (p.phase === 'dubbing' && step?.total) return t(`Line ${step.done} / ${step.total}`, `第 ${step.done} / ${step.total} 句`); + // The sound finish separates each source clip in turn. + const clip = /^separating (\d+)\/(\d+)$/.exec(p.phase || ''); + if (clip) return t(`Separating tracks · clip ${clip[1]} / ${clip[2]}`, `分离音轨 · 第 ${clip[1]} / ${clip[2]} 段`); + // A picture's stages share names with a video's, but it saves no sound. + if (kind === 'image' || kind === 'image_edit') { + const own = {text: t('Reading prompt', '读取提示词'), encode: t('Preparing images', '准备图片'), + denoise: t('Drawing', '绘制中'), decode: t('Saving the image', '保存图片')}[p.phase]; + if (own) return own; + } return {encoding: t('Reading prompt and media', '读取提示词和素材'), load: t('Loading the model', '加载模型'), - decode: t('Saving video and audio', '保存视频与音频'), starting: t('Starting', '启动中'), + decode: t('Saving video and audio', '保存视频与音频'), starting: t('Starting', '启动中'), start: t('Starting', '启动中'), loading: t('Loading the model', '加载模型'), answering: t('Answering', '回答中'), rewriting: t('Enhancing prompt', '增强提示词'), assembling: t('Joining clips', '拼接片段'), 'preparing images': t('Preparing images', '准备图片'), - finishing: t('Finishing', '收尾中')}[p.phase] || ''; + finishing: t('Finishing', '收尾中'), + // Sound jobs: music, tracks, the sound finish and dubbing. + downloading: t('Downloading the model', '下载模型'), waiting: t('Waiting for the GPU', '等待显卡'), + composing: t('Composing music', '生成配乐'), separating: t('Separating tracks', '分离音轨'), + recognizing: t('Finding the lines', '识别台词'), preparing: t('Preparing', '准备中'), dubbing: t('Dubbing', '配音中'), + 'measuring loudness': t('Measuring loudness', '测量响度'), 'balancing loudness': t('Balancing loudness', '统一响度'), + 'filling silence': t('Adding room tone', '补环境声'), placing: t('Placing on the timeline', '放入时间线')}[p.phase] || ''; } function status(job, t) { @@ -75,7 +102,9 @@ function row(job, t, api, refresh, open, cancellationErrors) { body.append(bar); } const meta = []; - if (job.status === 'running' && phaseText(p, t)) meta.push(phaseText(p, t)); + // A job's main phase is already its state; only the other phases add a line. + const phase = job.status === 'running' ? phaseText(p, t, job.kind) : ''; + if (phase && phase !== status(job, t)) meta.push(phase); if (job.status === 'running' && p.remaining_seconds > 0) meta.push(left(p.remaining_seconds, t)); const reason = job.status === 'failed' && REASONS[job.error?.code]; if (reason) meta.push(t(...reason)); @@ -165,8 +194,11 @@ export function createAgentActivity(t, api, host, {interval = [1500, 8000], open const skipped = el('div', 'fv-agent-unreadable', unreadable > 0 ? t(`${unreadable} ${unreadable === 1 ? 'job' : 'jobs'} could not be read`, `另有 ${unreadable} 个任务无法读取`) : ''); skipped.hidden = !unreadable; + // A poll must not move a list the reader has scrolled. + const scrolled = panel.querySelector('ol')?.scrollTop || 0; panel.replaceChildren(); panel.append(header, list, skipped); + list.scrollTop = scrolled; if (key) (panel.querySelector(`[data-job="${key[0]}"] .${key[1].split(' ')[0]}`) || panel).focus?.(); } function render() { diff --git a/web/agent_connect.css b/web/agent_connect.css index 9c79d91c..bd611a33 100644 --- a/web/agent_connect.css +++ b/web/agent_connect.css @@ -37,10 +37,8 @@ .fv-autopilot-line[data-on=false]{opacity:0} /* Running jobs share the right column; only the title gives up space before fields hide. */ .fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){grid-template-columns:minmax(0,1fr) auto minmax(0,1fr)} -.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{justify-self:stretch;min-width:0;justify-content:flex-end;container:fv-autopilot-actions / inline-size;gap:4px} +.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{justify-self:stretch;min-width:0;justify-content:flex-end;container:fv-autopilot-actions / inline-size} .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-header-actions>button{flex:none} -/* Compact spacing only once the column is short of room, so wide headers keep their idle rhythm. */ -@container fv-autopilot-actions (max-width:671px){.fv-header:has(.fv-autopilot-now:not([hidden])) .fv-header-actions>button{padding-inline:5px}} @container fv-autopilot-actions (max-width:632px){.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-lamp:lang(zh){width:30px;height:30px;padding:0;justify-content:center}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-lamp:lang(zh)>span{display:none}} .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-now{flex:0 1 auto;min-width:0} .fv-studio .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-autopilot-now-go{flex:0 1 auto;min-width:0;gap:5px;padding-left:8px} @@ -49,14 +47,22 @@ .fv-autopilot-now-go>em{overflow:hidden} /* Four-digit minutes; thresholds intentionally stay the same when feedback becomes an icon. */ @container fv-autopilot-actions (max-width:561px){:lang(zh) .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}:lang(zh) .fv-autopilot-lamp>span{display:none}} -@container fv-autopilot-actions (max-width:671px){:lang(en) .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}:lang(en) .fv-autopilot-lamp>span{display:none}} -@container fv-autopilot-actions (max-width:370px){:lang(zh) .fv-autopilot-now-go>em{display:none}} -@container fv-autopilot-actions (max-width:425px){:lang(en) .fv-autopilot-now-go>em{display:none}} +@container fv-autopilot-actions (max-width:720px){:lang(en) .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}:lang(en) .fv-autopilot-lamp>span{display:none}} +@container fv-autopilot-actions (max-width:460px){:lang(zh) .fv-autopilot-now-go>em{display:none}} +@container fv-autopilot-actions (max-width:600px){:lang(en) .fv-autopilot-now-go>em{display:none}} @container fv-autopilot-actions (max-width:289px){:lang(zh) .fv-autopilot-now-go>span{display:none}} @container fv-autopilot-actions (max-width:322px){:lang(en) .fv-autopilot-now-go>span{display:none}} /* Feedback retreats after the phone entry, so the running bar keeps its full content. */ +@container fv-autopilot-actions (max-width:425px){:lang(zh) .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-lan{display:none}} +@container fv-autopilot-actions (max-width:363px){:lang(zh) .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-feedback-entry:not(.fv-guide-entry){display:none}} +@container fv-autopilot-actions (max-width:305px){:lang(zh) .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-guide-entry{display:none}} +@container fv-autopilot-actions (max-width:545px){:lang(en) .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-lan{display:none}} +@container fv-autopilot-actions (max-width:458px){:lang(en) .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-feedback-entry:not(.fv-guide-entry){display:none}} +@container fv-autopilot-actions (max-width:365px){:lang(en) .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-guide-entry{display:none}} +@container fv-autopilot-actions (max-width:430px){:lang(zh) .fv-autopilot-now-go>i{display:none}} +@container fv-autopilot-actions (max-width:540px){:lang(en) .fv-autopilot-now-go>i{display:none}} @container fv-autopilot-actions (max-width:260px){.fv-header:has(.fv-autopilot-now:not([hidden])) .fv-feedback-entry:not(.fv-guide-entry){display:none}} @media(max-width:1050px){.fv-autopilot-now-name{max-width:120px}} -@media(max-width:825px){.fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){height:auto;min-height:98px;grid-template-columns:auto minmax(0,1fr);padding-block:10px}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{grid-column:2;grid-row:1}} +@media(max-width:1180px){.fv-studio.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])){height:auto;min-height:98px;grid-template-columns:auto minmax(0,1fr);padding-block:10px}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden]))>.fv-header-actions{grid-column:2;grid-row:1}} @media(max-width:480px){.fv-studio.fv-autopilot .fv-header{grid-template-columns:auto minmax(0,1fr)}.fv-autopilot-now{font-size:12.5px}.fv-autopilot-now-name{display:none}.fv-autopilot .fv-header-actions>.fv-quiet{flex:none;white-space:nowrap}.fv-studio .fv-autopilot-now-go{padding:5px 2px 5px 10px}.fv-studio .fv-autopilot-lamp{width:30px;height:30px;padding:0;justify-content:center}.fv-autopilot-lamp>span{display:none}.fv-agent-connect-body{padding:22px}} @media(prefers-reduced-motion:reduce){.fv-autopilot-line{transition:none}} diff --git a/web/agent_connect.js b/web/agent_connect.js index e1fe3ecd..e841d2fd 100644 --- a/web/agent_connect.js +++ b/web/agent_connect.js @@ -23,6 +23,13 @@ export async function agentInterfaceState(api) { } catch { return 'unknown'; } } +// What to tell the user when the lookup got no usable answer at all (no refusal came back): the request failed, the +// reply could not be read, or it held no address. The page cannot tell these apart for the user, and in each the +// way back is the same. +export function noAnswerHint(kind, t) { + return kind ? t('Could not reach FreeVideo on this computer. Make sure it is still running, then retry.', '无法连接本机的 FreeVideo 服务,请确认它仍在运行后重试。') : ''; +} + // What to tell the user when the address lookup is refused: only refusals that say what to do are named; any other // failure keeps the plain "could not get address". export function refusalHint(code, t) { @@ -65,17 +72,17 @@ export function agentConnectCard(t, api, shown = () => true, changed = () => {}) const status = el('p', 'fv-agent-connect-status', t('Getting the connection address…', '正在获取连接地址…')); status.setAttribute('role', 'status'); body.append(mark, title, intro, subtitle, actions, sentence, status); - let feedback = false, text = '', failures = 0, refusal = null; + let feedback = false, text = '', failures = 0, refusal = null, noAnswer = null; let timer, feedbackTimer, controller, generation = 0; 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: refusal ? `${label.textContent} (HTTP ${refusal.status}${refusal.code ? ' ' + refusal.code : ''})` : label.textContent, kind: refusal?.code || null})); button.after(issue); } + if (!issue) { issue = issueButton(t, () => ({where: ['Agent connection', 'Agent 连接'], message: refusal ? `${label.textContent} (HTTP ${refusal.status}${refusal.code ? ' ' + refusal.code : ''})` : label.textContent, kind: refusal?.code || noAnswer})); 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…', '正在获取连接地址…') : !text ? refusalHint(refusal?.code, t) : ''; + status.hidden = !!text; status.textContent = !text && failures < 3 ? t('Getting the connection address…', '正在获取连接地址…') : !text ? refusalHint(refusal?.code, t) || noAnswerHint(noAnswer, t) : ''; }; render(); @@ -101,7 +108,9 @@ export function agentConnectCard(t, api, shown = () => true, changed = () => {}) // Only the sentence is read here, until it is known; who is connected comes from the surface that holds the card. async function load() { const current = generation; - const request = new AbortController(); controller = request; refusal = null; + const request = new AbortController(); controller = request; refusal = null; noAnswer = null; + // What the lookup lacked if it fails without a refusal: the request itself fails first, then the reply is read. + let lacked = 'unreachable'; try { // The sentence carries no parameter: the server gives the agent the language this card showed. const response = await api.fetchApi('/freevideo/agent/connect?lang=' + t('en', 'zh'), {cache: 'no-store', signal: request.signal}); @@ -109,12 +118,13 @@ export function agentConnectCard(t, api, shown = () => true, changed = () => {}) if (current !== generation) return; if (!response.ok || !connect?.address) { refusal = !response.ok ? {status: response.status, code: String(connect?.error?.code || '').slice(0, 40) || null} : null; + lacked = connect ? 'no_address' : 'unreadable'; throw new Error('Missing address'); } text = connectionSentence(connect.address, t); - failures = 0; refusal = null; render(); + failures = 0; refusal = null; noAnswer = null; render(); if (await state === 'unknown') { state = agentInterfaceState(api); changed(await state); } - } catch { if (current === generation) { failures++; render(); } } + } catch { if (current === generation) { noAnswer = refusal ? null : lacked; failures++; render(); } } finally { if (current === generation) { controller = null; diff --git a/web/assets/tour/cd-narration-wave.webp b/web/assets/tour/cd-narration-wave.webp new file mode 100644 index 00000000..0f5f42a9 Binary files /dev/null and b/web/assets/tour/cd-narration-wave.webp differ diff --git a/web/assets/tour/cd-narration.mp4 b/web/assets/tour/cd-narration.mp4 new file mode 100644 index 00000000..75a75d92 Binary files /dev/null and b/web/assets/tour/cd-narration.mp4 differ diff --git a/web/assets/tour/cd-narration.webp b/web/assets/tour/cd-narration.webp new file mode 100644 index 00000000..1e07671e Binary files /dev/null and b/web/assets/tour/cd-narration.webp differ diff --git a/web/autopilot.js b/web/autopilot.js index 9eb380e3..35527f31 100644 --- a/web/autopilot.js +++ b/web/autopilot.js @@ -6,7 +6,7 @@ import { about, agentChat, jobName } from './autopilot_chat.js'; import { createRequestAssets } from './request_assets.js'; import { openEditorView } from './editor_loader.js'; import { closeDialog } from './motion.js'; -import { productVersion } from './updates.js'; +import { createVersionInfo, productVersion } from './updates.js'; import { openSetup } from './setup.js'; import { feedbackButton, guideButton } from './feedback.js'; import { openExtendEditor, openStudio, showNodes } from './studio.js'; @@ -37,7 +37,8 @@ export function openAutopilotView(node, {studio = null, initialPrompt = '', init halt.title = t('Stop all', '全部停止'); halt.setAttribute('aria-label', halt.title); const line = el('i', 'fv-autopilot-line'); const phoneAccess = createLanAccess(t, api); - tools.append(now, lamp, phoneAccess.element, guideButton(t), feedbackButton(t), settings); + const versionInfo = createVersionInfo(cn); + tools.append(now, lamp, phoneAccess.element, versionInfo.element, guideButton(t), feedbackButton(t), settings); // The view chosen next takes over once this page has closed; Escape goes back to what is underneath. let next = () => studio?.open ? viewChanged('studio', node) : viewChanged('nodes', node); const leave = action => { next = action; closeDialog(dialog); }; @@ -108,7 +109,7 @@ export function openAutopilotView(node, {studio = null, initialPrompt = '', init dialog.append(header, body); dialog.addEventListener('cancel', event => { event.preventDefault(); closeDialog(dialog); }); dialog.onclose = () => { - phoneAccess.dispose(); card.stop(); chat.stop(); assets.dispose(); dialog.remove(); + phoneAccess.dispose(); versionInfo.dispose(); card.stop(); chat.stop(); assets.dispose(); dialog.remove(); if (current === dialog) current = null; next(); }; diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 4b27c035..62c6356a 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -136,6 +136,9 @@ .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:is([data-mode=failed],[data-mode=stopped])>.fv-failure-fold{margin-top:8px;max-width:440px}.fv-chat-job:is([data-mode=failed],[data-mode=stopped])>.fv-failure-fold[hidden]{display:none} +.fv-chat-job:is([data-mode=failed],[data-mode=stopped])>.fv-failure-fold>.fv-failure-cause{margin:2px 0 8px;font-size:12.5px;line-height:1.5;color:var(--fv-muted);white-space:pre-wrap;overflow-wrap:anywhere} +.fv-studio .fv-chat-log .fv-chat-job:is([data-mode=failed],[data-mode=stopped]) .fv-chat-actions>button{min-height:var(--fv-h-sm,32px);box-sizing:border-box;margin:0;padding:5px 14px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised);color:var(--fv-ink);font-size:13px;font-weight:var(--fv-medium,500)} .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-row{grid-row:3}.fv-chat-input{grid-row:4} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 3e366474..829ec2e3 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -8,7 +8,7 @@ export {emptyChat, chatMerge, chatEntries, canSend} from './chat_data.js'; import {isolateDialogKeys, listenDialogKey, routeDialogKey} from './dialog_keys.js'; import {closeDialog} from './motion.js'; import { estimatedFraction, remainingAbove, remainingShown, createDiagnosticReportControl } from './generation_progress.js'; -import {createErrorReport} from './error_panel.js'; +import {createErrorReport, failureFold} from './error_panel.js'; import {canDeleteConversation, canRenameConversation, conversationMenu, emptyConversations, hasConversations, mergeConversations, removeConversation, runningElsewhere, selectConversation} from './autopilot_conversations.js'; import {renderImageFailure} from './image_generation.js'; import {issueButton} from './issue_button.js'; @@ -67,6 +67,14 @@ function failureReason(value, t) { return t(reasonEn || error?.message || 'Did not finish', reason || GENERIC_FAILURE_ZH); } +// A failed job's one reason line. The engine's classified sentence (both languages) is for people; anything else is +// program text and goes to the technical details, so the card then says only that the generation did not finish. +function failureCardReason(value, t) { + const error = value.error, zh = error?.error_zh || value.error_zh, en = error?.error_en || value.error_en; + const said = zh && en && zh !== GENERIC_FAILURE_ZH && en !== 'Did not finish' && zh !== '生成没有完成'; + return said || error?.code === 'session_full' ? failureReason(value, t) : t('Generation did not finish', '生成未完成'); +} + export {about}; export function clock(seconds) { const s = Math.max(0, Math.round(seconds)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`; } export const jobName = (job, t) => job.label || (job.kind === 'image' ? t('Picture', '图片') : t('Video', '视频')); @@ -1130,10 +1138,14 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () label: () => t('Download diagnostic report', '下载诊断报告'), fallback: () => new Blob([JSON.stringify(createErrorReport({error: current.error, exception_type: current.error?.code, details: current.error?.request_id}, {stage: 'execution'}), null, 2)], {type: 'application/json'})}); - actions.append(diagnostic.report); + // The shared failure style: one reason line, the fold row, then the secondary buttons. + const fold = failureFold(t); if (retry) actions.append(...again()); - links.append(actions); item.replaceChildren(reason, links); - apply = next => { current = next; put(reason, failureReason(next, t)); + actions.append(diagnostic.report); + links.append(actions); item.replaceChildren(reason, fold, links); + apply = next => { current = next; put(reason, failureCardReason(next, t)); + const raw = [next.error?.code, next.error?.message, next.error?.request_id].filter(Boolean).join('\n'); + fold.querySelector('p').textContent = raw; fold.hidden = !raw; diagnostic.updateReport({report_id: next.report_id}); }; } else { item.replaceChildren(); apply = null; } } diff --git a/web/cast_generate.css b/web/cast_generate.css index d8eafdcb..0fa2a270 100644 --- a/web/cast_generate.css +++ b/web/cast_generate.css @@ -3,10 +3,12 @@ .fv-cg [hidden]{display:none!important} .fv-cg-side{display:flex;flex-direction:column;gap:12px;min-width:0;align-self:stretch} /* The LoRA list opens in the column, under the description, so it never leaves the dialog; its controls take the panel's look. */ -.fv-cg-side .fv-ie-lora summary{display:inline-grid;box-sizing:border-box;height:32px;padding:0 9px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);white-space:nowrap;font-variant-numeric:tabular-nums} +.fv-cg-side .fv-ie-lora summary{display:inline-grid;box-sizing:border-box;height:32px;padding:0 12px;align-items:center;border:1px solid var(--fv-border,#ffffff1f);border-radius:8px;cursor:pointer;white-space:nowrap;font-variant-numeric:tabular-nums} .fv-cg-side .fv-ie-lora-trigger::placeholder{color:var(--fv-disabled)} .fv-cg-side .fv-ie-lora .fv-ie-lora-panel{position:static;width:auto;max-height:none;margin-top:8px;padding:0;border:0;background:none;box-shadow:none} .fv-cg-side .fv-ie-lora select,.fv-cg-side .fv-ie-lora input:not([type=checkbox]),.fv-cg-side .fv-ie-lora button{box-sizing:border-box;min-height:30px;padding:3px 8px;border:1px solid var(--fv-border,#ffffff1f);border-radius:6px;background:var(--fv-raised,#202a38);color:inherit;font:inherit} +.fv-cg-row{display:flex;align-items:flex-start;gap:8px} +.fv-cg-row .fv-ie-lora{flex:1;min-width:0} .fv-cg-foot{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:center;gap:8px} .fv-cg-text{flex:1;box-sizing:border-box;width:100%;min-height:160px;resize:none;padding:12px;border:1px solid var(--fv-border,#ffffff15);border-radius:8px;background:var(--fv-card,#202a38);color:inherit;font:inherit;line-height:1.6;outline:none;transition:border-color .2s} .fv-cg-text:focus{border-color:color-mix(in srgb,var(--fv-accent,#6db8fa) 60%,transparent)} @@ -22,7 +24,7 @@ .fv-cg .fv-actions .fv-action-report:hover{background:#ffffff0d} .fv-cg-note[data-failed=true]{color:var(--fv-danger,#ff8a80)} .fv-cg-note .fv-image-failure-reason{color:var(--fv-danger,#ff8a80)} -.fv-cg-action{grid-column:3;justify-self:end;min-height:36px;padding:6px 16px;border:1px solid var(--fv-border,#ffffff1f);border-radius:8px;background:transparent;color:inherit;font:inherit;cursor:pointer;transition:background .18s,border-color .18s} +.fv-cg-action{flex:none;margin-left:auto;box-sizing:border-box;height:32px;min-height:32px;padding:6px 16px;border:1px solid var(--fv-border,#ffffff1f);border-radius:8px;background:transparent;color:inherit;font:inherit;cursor:pointer;transition:background .18s,border-color .18s} .fv-cg-action:hover{background:#ffffff0d} .fv-cg-action[data-primary=true]{border-color:transparent;background:var(--fv-accent,#6db8fa);color:var(--fv-bg,#111720)} .fv-cg-action[data-primary=true]:hover{background:var(--fv-accent-hover,#91cbff)} diff --git a/web/cast_generate.js b/web/cast_generate.js index dfb2a067..6323ade9 100644 --- a/web/cast_generate.js +++ b/web/cast_generate.js @@ -145,8 +145,11 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt // A caller that passes a fixed list owns the choice; otherwise the panel offers the same picker as image generation. const picker = loras === undefined ? createImageLoras({t: tr, fetchApi: fetcher, getPrompt: () => field.value, setPrompt: text => { field.value = text; }, onChange: () => render()}) : null; - foot.append(note, action); - side.append(field, ...(picker ? [picker.element] : []), foot); + // The LoRA capsule and the generate button share one row, at its left and right ends. + const row = el('div', 'fv-cg-row'); + row.append(...(picker ? [picker.element] : []), action); + foot.append(note); + side.append(field, row, foot); const grid = el('div', 'fv-cg-views'); const cells = names.map((view, index) => { const cell = el('div', 'fv-cg-view'); diff --git a/web/cast_library.css b/web/cast_library.css index 9c37d638..e8047172 100644 --- a/web/cast_library.css +++ b/web/cast_library.css @@ -17,7 +17,6 @@ .fv-cast-dialog button:hover{background:var(--fv-hover)} .fv-cast-dialog button:disabled,.fv-cast-dialog button[aria-disabled=true]{opacity:.45;cursor:default} .fv-cast-dialog :focus-visible,.fv-cast-library :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} -.fv-cast-dialog .fv-cast-close{min-height:28px;width:28px;padding:0;border:0;background:transparent;color:var(--fv-muted);font-size:22px} .fv-cast-name{display:block;width:100%;height:36px;margin:0 0 14px;padding:6px 10px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised);color:var(--fv-ink);font:inherit} .fv-cast-name::placeholder{color:var(--fv-disabled)} .fv-cast-tabs{display:flex;gap:3px;margin-bottom:16px;padding:3px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm,8px);background:var(--fv-bg)} @@ -30,7 +29,7 @@ .fv-cast-dialog .fv-cast-choose{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;width:100%;height:100%;min-height:0;padding:0;border:1px dashed var(--fv-sheen);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised);color:var(--fv-muted);overflow:hidden} .fv-cast-choose:has(img){border-style:solid} .fv-cast-slot-plus{font-size:24px;line-height:1} -.fv-cast-choose img{display:block;width:100%;height:100%;object-fit:contain} +.fv-cast-choose img{display:block;width:100%;height:100%;object-fit:cover} .fv-cast-dialog .fv-cast-remove{position:absolute;right:5px;top:5px;width:24px;min-height:24px;padding:0;border-color:var(--fv-sheen);border-radius:50%;background:var(--fv-bg);font-size:18px;line-height:1} .fv-cast-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:18px} .fv-cast-dialog .fv-cast-delete{margin-right:auto;color:var(--fv-danger)} diff --git a/web/cast_library.js b/web/cast_library.js index 20ebdaa5..a54b968e 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -110,9 +110,8 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const top = el('div', 'fv-cast-top'); const title = el('h2', '', id ? word(text('Edit character', '编辑角色'), text('Edit scene', '编辑场景'), text('Edit prop', '编辑道具')) : word(text('New character', '新建角色'), text('New scene', '新建场景'), text('New prop', '新建道具'))); dialog.setAttribute('aria-label', title.textContent); - const closeButton = button('fv-cast-close', '×', () => close()); - closeButton.setAttribute('aria-label', text('Close', '关闭')); top.append(title, closeButton); - const name = el('input', 'fv-cast-name'); name.type = 'text'; name.maxLength = 120; + top.append(title); + const name = el('input', 'fv-cast-name'); name.type = 'text'; name.maxLength = 120; name.autofocus = true; name.placeholder = word(text('Name', '名字'), text('Name', '名称')); name.setAttribute('aria-label', name.placeholder); name.oninput = () => sync(); name.onkeydown = event => { if (event.key === 'Enter') event.preventDefault(); }; diff --git a/web/chain_support.js b/web/chain_support.js index ee82d2c4..364b35c4 100644 --- a/web/chain_support.js +++ b/web/chain_support.js @@ -15,6 +15,13 @@ export function waitingText(waiting, t) { return t(`Queued · starts in ${time}`, `排队中 · ${time}后开始`); } +// The line while a long video's parts are joined: which part is being written once that is known. +export function joiningText(join, t) { + const part = join?.part, count = join?.count; + if (!Number.isInteger(part) || !Number.isInteger(count) || count < 2 || part < 1 || part > count) return t('Joining the parts', '正在接合各段'); + return t(`Joining · Part ${part} of ${count}`, `正在接合 · 第 ${part} 段,共 ${count} 段`); +} + // No overlapping polls and no delivery after disposal, including a slow response. export function createPoller(read, deliver, {clock = clockDefault, interval = 2000, onError = () => {}} = {}) { let active = true, timer = null, running = null; diff --git a/web/editor.css b/web/editor.css index 52862af0..67dd77fd 100644 --- a/web/editor.css +++ b/web/editor.css @@ -107,7 +107,7 @@ .fv-ed-say-voice[hidden],.fv-ed-say-voice [hidden]{display:none} .fv-ed-say-label{flex:none;min-width:2.5em;font-size:13px;color:var(--fv-muted)} /* Under the studio's own select and button rules, so the voice keeps its width and the play sign its size. */ -.fv-ed-genbar .fv-ed-say-select{flex:0 1 auto;width:auto;min-width:0;max-width:min(100%,22em);min-height:var(--fv-h-sm);padding:4px 8px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);color:inherit;font:inherit;text-overflow:ellipsis} +.fv-ed-say-voice>.fv-select{flex:0 1 auto;min-width:0;max-width:min(100%,22em)} .fv-ed-genbar .fv-ed-say-listen{flex:none;width:var(--fv-h-sm);height:var(--fv-h-sm);min-width:0;display:inline-flex;align-items:center;justify-content:center;padding:0;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);color:inherit;cursor:pointer} .fv-ed-genbar .fv-ed-say-listen:disabled{opacity:.5;cursor:default} .fv-ed-genbar .fv-ed-say-listen svg{flex:none;width:12px;height:12px;fill:currentColor} @@ -208,7 +208,7 @@ .fv-editor .fv-ed-toolbar .fv-ed-more-panel button[aria-pressed=true] svg{color:var(--fv-accent)} .fv-editor .fv-ed-toolbar .fv-ed-more-panel button:not(:disabled):hover,.fv-editor .fv-ed-toolbar .fv-ed-more-panel button:focus-visible{background:color-mix(in srgb,var(--fv-accent) 12%,transparent)} /* The tracks' basic fallback rises from the bottom; Blake supplies the waves. */ -.fv-editor .fv-ed-progress-fill{width:100%;height:calc(var(--fv-ed-progress)*100%);inset:auto 0 0} +.fv-editor .fv-ed-progress-fill{width:100%;height:calc(var(--fv-ed-progress)*100%);inset:auto 0 0;box-shadow:inset 0 2px 0 var(--fv-accent)} .fv-ed-drawer-toggle{display:none!important} .fv-ed-name-menu{position:absolute;top:100%;left:0;width:300px;max-width:calc(100vw - 32px);padding:6px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:8px;z-index:8;display:flex;flex-direction:column;gap:0;font:13px var(--fv-font)} /* Rows look like the right-click menu (editor_menu.css). */ @@ -264,7 +264,7 @@ .fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center} .fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-header-actions{grid-column:2;grid-row:1} .fv-studio.fv-editor .fv-header[data-fit~=wrap] .fv-ed-name{max-width:320px} -.fv-studio.fv-editor .fv-header[data-fit~=lean] .fv-feedback-entry:not(.fv-guide-entry){display:none} +.fv-studio.fv-editor .fv-header[data-fit~=lean] .fv-lan,.fv-studio.fv-editor .fv-header[data-fit~=lean] .fv-feedback-entry:not(.fv-guide-entry){display:none} .fv-studio.fv-editor .fv-header[data-fit~=least] .fv-guide-entry{display:none} .fv-ed-volume-popover{position:absolute;top:calc(100% + 8px);left:0;display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--fv-border);border-radius:8px;background:var(--fv-card);white-space:nowrap;z-index:8} .fv-ed-volume{width:90px}.fv-ed-volume-value{font-variant-numeric:tabular-nums;font-size:12px;min-width:34px} @@ -292,7 +292,8 @@ .fv-studio.fv-editor .fv-ed-track-head[data-open=true]>[class$=-track-controls]{display:flex;position:absolute;left:calc(100% + 4px);top:50%;transform:translateY(-50%);gap:2px;padding:2px 4px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:8px} .fv-studio.fv-editor .fv-ed-track-heads:has(>[data-open=true]){z-index:9} .fv-studio.fv-editor .fv-ed-add-audio{padding:0;font-size:0} - .fv-studio.fv-editor .fv-ed-add-audio::before{content:attr(data-sign);font-size:16px} + .fv-studio.fv-editor .fv-ed-add-audio::before{content:attr(data-sign);font-size:16px;line-height:18px;display:inline-block;width:20px;height:20px;border:1px solid var(--fv-border);border-radius:6px} + .fv-studio.fv-editor .fv-ed-add-audio:disabled{display:none} .fv-editor .fv-ed-playhead-handle{left:0} .fv-editor .fv-ed-library{grid-area:auto;top:116px;bottom:0;z-index:8} .fv-editor:has(.fv-ed-library[data-open=true]) .fv-ed-drawer-toggle{position:fixed;top:128px;right:20px;z-index:9} @@ -300,7 +301,7 @@ /* "Generate · about 4 minutes" in English does not fit beside the seconds field below 550 px: it keeps a line of its own, and the line above it stays whether the field shows or not, so choosing a clip or a draft does not move the timeline. */ @media(max-width:549px){ - .fv-ed-genbar:lang(en)[data-mode=selected]:not(:has(>.fv-ed-gen-failure:not([hidden]))){row-gap:0;padding-top:2px;align-content:space-between;min-height:calc(146px + var(--fv-ed-description-growth,0px))} + .fv-ed-genbar:lang(en)[data-mode=selected]:not(:has(>.fv-ed-gen-failure:not([hidden]))){row-gap:0;padding-top:2px;align-content:space-between} .fv-ed-genbar:lang(en)[data-mode=selected]:not(:has(>.fv-ed-gen-failure:not([hidden])))>*{margin-top:8px} .fv-ed-genbar:lang(en)[data-mode=selected]:not(:has(>.fv-ed-gen-failure:not([hidden])))>.fv-ed-genbar-break{display:block;flex:0 0 100%;height:0;margin:0} } diff --git a/web/editor_adjust.css b/web/editor_adjust.css index eb40976b..f25f233e 100644 --- a/web/editor_adjust.css +++ b/web/editor_adjust.css @@ -14,7 +14,7 @@ .fv-editor .fv-ed-adjust-button{height:32px;padding:0 8px} .fv-editor .fv-ed-adjust-segmented{width:100%;min-width:0} .fv-editor .fv-ed-adjust-segmented button{flex:1;padding:0 6px} -.fv-editor .fv-ed-adjust-segmented button[aria-pressed=true]{background:var(--fv-raised);color:var(--fv-ink)} +.fv-editor .fv-ed-adjust-segmented button[aria-pressed=true]{background:var(--fv-raised);color:var(--fv-ink);font-weight:600;box-shadow:0 1px 3px #0006} .fv-ed-adjust-toggle-control{display:flex;align-items:center;gap:8px;font-size:13px;cursor:pointer} .fv-ed-adjust-toggle-control input{accent-color:var(--fv-accent);width:15px;height:15px;margin:0} .fv-ed-adjust-controls:disabled :is(.fv-ed-adjust-label,output,.fv-ed-adjust-toggle-control span){opacity:.4} diff --git a/web/editor_audio.css b/web/editor_audio.css index bc7c5170..499a9d53 100644 --- a/web/editor_audio.css +++ b/web/editor_audio.css @@ -1,7 +1,7 @@ .fv-ed-audio-panel{box-sizing:border-box;width:100%;color:var(--fv-ink);font:13px/1.4 var(--fv-font,system-ui)} .fv-ed-audio-form,.fv-ed-audio-cross{display:flex;flex-direction:column;gap:12px} .fv-ed-audio-panel [hidden]{display:none!important} -.fv-ed-track-view .fv-ed-track-head{justify-content:space-between;gap:2px;padding:0 4px} +.fv-ed-track-view .fv-ed-track-head{justify-content:space-between;gap:2px;padding:0 4px 0 16px} .fv-ed-track-head>svg{display:none} .fv-ed-track-head>span:not(.fv-ed-audio-track-controls){white-space:nowrap;min-width:0;overflow:hidden;text-overflow:ellipsis} .fv-ed-audio-track-controls{display:flex;gap:1px;flex:none} diff --git a/web/editor_genbar.js b/web/editor_genbar.js index cc1d0a0f..d903528a 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -25,7 +25,7 @@ export const CLIP_FINALIZATION_COPY = { decodeAudio: ['Rendering the audio', '正在输出音频'], saveVideo: ['Saving the video', '正在保存视频'], openClip: ['Adding it to the timeline', '正在放到时间线上'], - joinClips: ['Joining the parts', '正在拼接各段'], + joinClips: ['Joining the parts', '正在接合各段'], }; export function clipFinalization(job) { if (!job || !['running', 'queued'].includes(job.state)) return null; diff --git a/web/editor_header_fit.js b/web/editor_header_fit.js index c7083064..3d2da45f 100644 --- a/web/editor_header_fit.js +++ b/web/editor_header_fit.js @@ -1,7 +1,7 @@ // The view switch stays centred in the editor's header while the project name next to the logo stays readable. // When it would not, the logo first takes its phone size, then gives its place to the name; if that is still too tight, the switch moves to a second row. // The feedback entry leaves before the switch moves down, and on two rows when the name is still tight. -// The Tutorial entry stays on two rows, and leaves after the feedback entry only to keep the switch on one row. +// The Tutorial entry stays on two rows, and leaves after the feedback entry only to keep the switch on one row, or the name readable. // About six Chinese characters or ten Latin letters of the name itself, without the menu arrow. const READABLE = 120, COMPACT_MARK = 90; @@ -38,7 +38,7 @@ export function fitEditorHeader(header, brand, title, navigation, actions) { // Then the feedback entry steps aside too, rather than let the switch drop to a second row. const fitsLean = markWidth => spare && side >= need - away - spare && side - markWidth - spacing >= wanted; const fit = fits(mark.offsetWidth) ? '' : fits(compact) ? 'compact' : away && fitsWithout(mark.offsetWidth) ? 'tight' : away && fitsWithout(compact) ? 'compact tight' : away && fitsLean(mark.offsetWidth) ? 'tight lean' : away && fitsLean(compact) ? 'compact tight lean' : bare(need) ? 'bare' : away && bare(need - away) ? 'bare tight' : away && spare && bare(need - away - spare) ? 'bare tight lean' : away && spare && room && bare(need - away - spare - room) ? 'bare tight lean least' : spare && bare(need - spare) ? 'bare lean' - : row(mark.offsetWidth) >= wanted ? 'wrap' : row(compact) >= wanted || !spare ? 'wrap compact' : 'wrap compact lean'; + : row(mark.offsetWidth) >= wanted ? 'wrap' : row(compact) >= wanted || !spare ? 'wrap compact' : row(compact) + spare >= wanted || !room ? 'wrap compact lean' : 'wrap compact lean least'; if (fit) header.dataset.fit = fit; } const observer = new ResizeObserver(check); diff --git a/web/editor_library.js b/web/editor_library.js index c0215c54..2ad1d9ef 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -190,6 +190,16 @@ export function createEditorLibrary(host, {store, t, actions, state}) { })().finally(() => { refreshTask = null; }); return refreshTask; } + // The Creations panel shares this page: what it generates, deletes or restores changes this list too. + const generated = event => { const video = event.detail?.value?.video; if (video) refresh({outputs: [video]}); }; + const deleted = event => { + const video = event.detail?.video; if (!video || disposed) return; + if (rows.some(row => row.video === video)) { rows = rows.filter(row => row.video !== video); render(); } + }; + const restored = () => { refresh(); }; + window.addEventListener('freevideo-result', generated); + window.addEventListener('freevideo-library-deleted', deleted); + window.addEventListener('freevideo-library-restored', restored); function choose(value) { tab = value; render(); scheduleMore(); } works.onclick = () => choose('work'); imports.onclick = () => choose('import'); tabs.onkeydown = event => { if (['ArrowLeft', 'ArrowRight'].includes(event.key)) { event.preventDefault(); choose(tab === 'work' ? 'import' : 'work'); (tab === 'work' ? works : imports).focus(); } }; @@ -253,5 +263,5 @@ export function createEditorLibrary(host, {store, t, actions, state}) { }); const stopUsed = state?.subscribe(event => { if (event.type === 'record') updateUsed(); }); render(); load(); - return {element: host, importFiles, refresh, dispose() { disposed = true; pendingAdds.clear(); host.removeEventListener('scroll', scheduleMore); resize.disconnect(); cancelAnimationFrame(moreFrame); stop(); stopUsed?.(); clearTimeout(retryTimer); finishRetry?.(); }}; + return {element: host, importFiles, refresh, dispose() { disposed = true; pendingAdds.clear(); window.removeEventListener('freevideo-result', generated); window.removeEventListener('freevideo-library-deleted', deleted); window.removeEventListener('freevideo-library-restored', restored); host.removeEventListener('scroll', scheduleMore); resize.disconnect(); cancelAnimationFrame(moreFrame); stop(); stopUsed?.(); clearTimeout(retryTimer); finishRetry?.(); }}; } diff --git a/web/editor_narration.js b/web/editor_narration.js index 6ecc1635..6ee809cc 100644 --- a/web/editor_narration.js +++ b/web/editor_narration.js @@ -5,6 +5,7 @@ import * as said from './editor_narration_model.js'; import {EditError, FPS} from './editor_model.js'; import {registerMenuItem} from './editor_menu.js'; +import {menuSelect} from './fv_select.js'; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; const NBSP = ' '; @@ -107,7 +108,7 @@ export function createEditorNarration({state, store, t, sources = () => new Map( if (clip.source.kind !== 'import' || typeof name !== 'string' || !name) continue; const take = RECORDING.test(name) ? ++takes : 0; const label = take ? t(`Recording ${take}`, `录音 ${take}`) : name; - list.push({key: `clip:${clip.id}`, kind: 'clip', label, reference: {clip_id: clip.id}, + list.push({key: `clip:${clip.id}`, kind: 'clip', recording: Boolean(take), label, reference: {clip_id: clip.id}, listen: {clip: clip.id, start: 0, end: Math.min(clip.frames / FPS, 10)}}); } const picked = pickedClip && state.record.tracks.some(track => track.clips.some(clip => clip.id === pickedClip.id && clip.source)) ? pickedClip : null; @@ -335,30 +336,31 @@ export function createNarrationRow(host, controller, {t}) { const icon = document.createElementNS(SVG, 'svg'); icon.setAttribute('viewBox', '0 0 16 16'); icon.setAttribute('aria-hidden', 'true'); const shape = document.createElementNS(SVG, 'path'); icon.append(shape); listen.append(icon); const none = el('span', 'fv-ed-say-none', t('No voice available. Record or import a voice clip first.', '没有可用的音色。请先录音或导入一段人声。')); - element.append(label, select, listen, none); element.hidden = true; host.append(element); + const menu = menuSelect(select, {groups: {speaker: t('Speakers', '说话人'), recording: t('Recordings', '录音'), clip: t('Clips', '片段')}}); + element.append(label, menu.wrap, listen, none); element.hidden = true; host.append(element); const PICK = '__pick__'; select.onchange = () => { if (select.value === PICK) { controller.pick(); return; } controller.choose(select.value); }; listen.onclick = () => controller.listen(); - function option(value, text) { const item = el('option', null, text); item.value = value; return item; } + function option(value, text, group) { const item = el('option', null, text); item.value = value; if (group) item.dataset.group = group; return item; } function update(shown, busy = false) { element.hidden = !shown; if (!shown) return; - select.disabled = busy; + select.disabled = busy; menu.refresh(); const all = controller.voices(), current = controller.voice(); const empty = !all.speakers.length && !all.clips.length && !all.finding; - select.hidden = listen.hidden = empty; none.hidden = !empty; label.hidden = empty; + menu.wrap.hidden = listen.hidden = empty; none.hidden = !empty; label.hidden = empty; if (empty) return; // Nothing shown as chosen while the speakers are still being found and no clip is a voice. const blank = option('', ''); blank.hidden = blank.disabled = true; - const items = [blank, ...all.speakers.map(voice => option(voice.key, voice.label))]; + const items = [blank, ...all.speakers.map(voice => option(voice.key, voice.label, 'speaker'))]; if (all.speakers.length && all.clips.length) { const line = el('hr'); items.push(line); } - items.push(...all.clips.map(voice => option(voice.key, voice.label)), option(PICK, t('Pick a sound clip…', '选一个声音片段…'))); + items.push(...all.clips.map(voice => option(voice.key, voice.label, voice.recording ? 'recording' : 'clip')), option(PICK, t('Pick a sound clip…', '选一个声音片段…'))); const signature = items.map(item => item.tagName === 'HR' ? '-' : `${item.value}=${item.textContent}`).join('\n'); if (select.dataset.signature !== signature) { select.replaceChildren(...items); select.dataset.signature = signature; } - select.value = controller.picking ? PICK : current?.key ?? ''; + select.value = controller.picking ? PICK : current?.key ?? ''; menu.refresh(); listen.disabled = !current || busy; const playing = controller.listening(); listen.setAttribute('aria-pressed', String(playing)); diff --git a/web/editor_record.css b/web/editor_record.css index a0c1b057..caca3a03 100644 --- a/web/editor_record.css +++ b/web/editor_record.css @@ -3,7 +3,8 @@ .fv-ed-record-side{display:flex;min-width:0} .fv-ed-record-center{display:flex;align-items:center;gap:14px;min-width:0} .fv-ed-record-close{justify-self:end} -.fv-ed-record-device{appearance:none;flex:0 1 auto;width:auto;max-width:min(100%,260px);height:32px;padding:0 12px;border:1px solid var(--fv-border);border-radius:16px;background:var(--fv-card);color:var(--fv-muted);font:inherit;text-overflow:ellipsis;cursor:pointer} +.fv-ed-record-device{appearance:none;flex:0 1 auto;width:auto;max-width:100%;height:32px;padding:0 12px;border:1px solid var(--fv-border);border-radius:16px;background:var(--fv-card);color:var(--fv-muted);font:inherit;text-overflow:ellipsis;cursor:pointer} +.fv-ed-record-side>.fv-select{flex:0 1 auto;max-width:min(100%,260px)} .fv-ed-record-device:hover:not(:disabled){color:var(--fv-ink)} .fv-ed-record-device:disabled{opacity:.5;cursor:default} /* The level is a row of dots that is always drawn, so it reads as a meter before anything is said; the lit part follows the voice. */ diff --git a/web/editor_record.js b/web/editor_record.js index cc0bc8a0..c228c45e 100644 --- a/web/editor_record.js +++ b/web/editor_record.js @@ -1,6 +1,7 @@ // Recording from the microphone onto a sound track. A small bar above the tracks holds the microphone choice, the level, // and the start/stop button; the take is saved as a WAV and placed like any imported sound, never over what is already there. import {registerMenuItem} from './editor_menu.js'; +import {skinSelect} from './fv_select.js'; import {COUNTDOWN_SECONDS, elapsedText, deviceName, recordable, recordRange, landingTrack, replacesTake, secondsOf, takeName, level, mixdown, encodeWav} from './editor_record_model.js'; const el = (tag, cls, text) => { const node = document.createElement(tag); if (cls) node.className = cls; if (text != null) node.textContent = text; return node; }; let styled = false; @@ -19,7 +20,7 @@ export function createEditorRecord({state, player, library, t, error = () => {}, const note = el('span', 'fv-ed-record-note'); note.setAttribute('role', 'status'); const close = el('button', 'fv-ed-record-close', '×'); close.type = 'button'; close.setAttribute('aria-label', t('Close', '关闭')); const side = el('div', 'fv-ed-record-side'), center = el('div', 'fv-ed-record-center'); - side.append(devices); center.append(main, meter, note); bar.append(side, center, close); + side.append(skinSelect(devices)); center.append(main, meter, note); bar.append(side, center, close); let phase = 'idle', denied = false, stream = null, recorder = null, chunks = [], context = null, frame = null, timers = [], disposed = false, session = 0, ticker = null, listening = null, startedAt = 0; const clear = () => { timers.forEach(clearTimeout); timers = []; clearInterval(ticker); ticker = null; }; diff --git a/web/editor_text.css b/web/editor_text.css index d1831521..fb2ad46e 100644 --- a/web/editor_text.css +++ b/web/editor_text.css @@ -11,13 +11,14 @@ .fv-editor .fv-ed-text-colors button{height:20px;width:20px;padding:0;border-radius:3px;border:1px solid var(--fv-border)} .fv-editor .fv-ed-text-colors button.fv-ed-text-custom{width:auto;padding:0 5px;font-size:11px} .fv-editor .fv-ed-text-colors input{width:25px;height:22px;padding:0;border:0} +.fv-editor .fv-select:has(>.fv-ed-text-font){width:100%;max-width:166px} .fv-editor .fv-ed-text-font{width:100%;max-width:166px;height:30px;background:var(--fv-card);color:var(--fv-ink);border:1px solid var(--fv-border);border-radius:4px} .fv-ed-text-warning{color:var(--fv-warning);font-size:12px;line-height:1.5;overflow-wrap:anywhere;margin:0} .fv-ed-subtitle-style-title{font-size:13px;font-weight:600}.fv-ed-adjust-controls hr{width:100%;border:0;border-top:1px solid var(--fv-border);margin:0} .fv-ed-text-label{padding:0 10px;display:flex;align-items:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px;line-height:32px;color:var(--fv-ink)} .fv-ed-clip[data-text-kind]{background:color-mix(in srgb,var(--fv-accent) 16%,var(--fv-card));border-color:color-mix(in srgb,var(--fv-accent) 35%,var(--fv-border))} .fv-ed-text-overflow{position:absolute;right:3px;top:2px;padding:1px 3px;border:1px solid var(--fv-warning);border-radius:3px;background:var(--fv-bg);color:var(--fv-warning);font-size:10px} -.fv-ed-track-head[data-text-track=true]{padding-left:8px;padding-right:4px}.fv-ed-text-track-controls{margin-left:auto;display:flex;gap:2px} +.fv-ed-track-head[data-text-track=true]{padding-left:16px;padding-right:4px}.fv-ed-text-track-controls{margin-left:auto;display:flex;gap:2px} .fv-editor .fv-ed-text-track-controls button{padding:0;width:20px;height:22px;min-width:0;opacity:.5}.fv-editor .fv-ed-text-track-controls button[aria-pressed=true]{opacity:1;color:var(--fv-accent)} .fv-ed-track-row[data-hidden=true] .fv-ed-clip{opacity:.45}.fv-ed-track-row[data-locked=true] .fv-ed-clip{cursor:not-allowed} .fv-ed-track-row[data-locked=true]{background-image:repeating-linear-gradient(135deg,color-mix(in srgb,var(--fv-ink) 8%,transparent) 0 2px,transparent 2px 8px)} diff --git a/web/editor_text.js b/web/editor_text.js index 8117316c..e89a942f 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -9,6 +9,7 @@ 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'; +import {skinSelect} from './fv_select.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)); @@ -143,7 +144,7 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, } const font=el('select','fv-ed-text-font');font.setAttribute('aria-label',t('Font','字体')); for(const key of FONT_KEYS){const option=el('option','',names[key]);option.value=key;option.style.fontFamily=textFontStack(key);font.append(option);} - font.onchange=()=>modifyStyle({font:font.value});host.append(row(t('Font','字体'),font)); + font.onchange=()=>modifyStyle({font:font.value});host.append(row(t('Font','字体'),skinSelect(font))); const sizeNumber=number({min:16,max:200,step:1,label:t('Size','大小'),onChange:v=>modifyStyle({size:v/1080})}); const size=slider({min:16,max:200,value:kind==='text'?64:44,onInput:v=>{sizeNumber.value=Math.round(v);modifyStyle({size:v/1080},true);},onChange:v=>modifyStyle({size:v/1080})}); host.append(row(t('Size','大小'),size,sizeNumber)); diff --git a/web/editor_tracks.css b/web/editor_tracks.css index c222421d..692d230b 100644 --- a/web/editor_tracks.css +++ b/web/editor_tracks.css @@ -41,7 +41,7 @@ .fv-ed-waveform{width:100%;height:100%;display:block;stroke:color-mix(in srgb,var(--fv-accent) 55%,transparent);stroke-width:1;fill:none} .fv-ed-clip[data-state=planned]{border:1px dashed color-mix(in srgb,var(--fv-ink) 28%,transparent);background:color-mix(in srgb,var(--fv-ink) 3%,transparent)} .fv-ed-clip[data-state=running]{background:var(--fv-card)} -.fv-ed-progress-fill{display:none;right:auto;width:calc(var(--fv-ed-progress) * 100%);background:color-mix(in srgb,var(--fv-accent) 55%,transparent)} +.fv-ed-progress-fill{display:none;right:auto;width:calc(var(--fv-ed-progress) * 100%);background:color-mix(in srgb,var(--fv-accent) 45%,transparent);box-shadow:inset -2px 0 0 var(--fv-accent)} .fv-ed-clip[data-state=running] .fv-ed-progress-fill:not([hidden]){display:block} .fv-ed-block-progress{display:none} .fv-ed-clip[data-state=running] .fv-ed-block-progress,.fv-ed-block-progress[data-queued=true]{display:block} @@ -84,7 +84,7 @@ .fv-ed-clip-popover button+button{margin-left:6px} .fv-ed-clip-popover{max-height:calc(100% - 16px);overflow:auto} .fv-ed-track-view [hidden]{display:none!important} -@media(max-width:520px){.fv-ed-track-view,.fv-studio.fv-editor .fv-ed-track-view[data-track-switches],.fv-studio.fv-editor[data-text-tracks] .fv-ed-track-view{--fv-ed-head-width:52px}.fv-studio.fv-editor .fv-ed-track-head>svg{display:none}.fv-ed-track-head{padding:0 2px;box-sizing:border-box}.fv-ed-track-head>span:first-of-type{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}.fv-ed-track-zoom{right:8px}.fv-ed-clip-status{left:5px;font-size:11px}} +@media(max-width:520px){.fv-ed-track-view,.fv-ed-track-view[data-track-switches=true],.fv-studio.fv-editor .fv-ed-track-view[data-track-switches],.fv-studio.fv-editor[data-text-tracks] .fv-ed-track-view{--fv-ed-head-width:64px}.fv-studio.fv-editor .fv-ed-track-head>svg{display:none}.fv-ed-track-view .fv-ed-track-heads .fv-ed-track-head,.fv-ed-track-view .fv-ed-track-heads .fv-ed-track-head[data-text-track=true]{padding:0 2px 0 12px;box-sizing:border-box}.fv-ed-track-head>span:first-of-type{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px}.fv-ed-track-zoom{right:8px}.fv-ed-clip-status{left:5px;font-size:11px}} @media(forced-colors:active){.fv-ed-clip,.fv-ed-selection,.fv-ed-gesture-preview{border-color:Highlight}.fv-ed-playhead,.fv-ed-snap-line{background:CanvasText}} /* Gesture affordances; track slots and heads are owned by the track layout. */ diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 5ab3c8a9..022175d5 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -592,8 +592,8 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = for (const [id, item] of clips) if (!retained.has(id)) { item.component?.dispose(); item.element.remove(); clips.delete(id); progresses.delete(id); } heads.append(addAudio); addAudio.style.top = `${top}px`; addAudio.disabled = tracks.find(track => track.id === 'a2')?.clips.some(busy) || false; - addAudio.textContent = twoAudio ? t('− Audio track', '- 声音轨') : t('+ Audio track', '+ 声音轨'); - addAudio.dataset.sign = addAudio.textContent.slice(0, 1); // Phone widths show the sign alone. + addAudio.textContent = twoAudio ? t('Remove Audio 2', '移除声音 2') : t('+ Audio track', '+ 声音轨'); + addAudio.dataset.sign = twoAudio ? '×' : '+'; // Phone widths show the sign alone. addAudio.title = ''; addAudio.setAttribute('aria-label', addAudio.textContent); // Without Audio 2 the guide points at the button that adds it. diff --git a/web/error_panel.js b/web/error_panel.js index 7d7e6aed..e251e68a 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -476,21 +476,21 @@ function showFailureDialog(report, t) { const panel = createErrorPanel(t); panel.show(report, false); // The dialog has no run's report to download; submitting stays on the failure card. if (diagnosticReports.has(report)) panel.element.querySelector('.fv-failure-github:not([data-lora])')?.remove(); - const close = document.createElement('button'); close.type = 'button'; - close.textContent = t('Got it', '知道了'); close.autofocus = true; - close.onclick = () => dialog.close(); + const close = document.createElement('button'); close.type = 'button'; close.className = 'fv-failure-primary'; + close.textContent = t('Close', '关闭'); close.onclick = () => dialog.close(); + // The title has its own line; the secondary buttons sit at the left of the bottom row, the main button at the right. + const footer = document.createElement('div'); footer.className = 'fv-failure-footer'; + const actions = panel.element.querySelector('.fv-failure-actions'); + actions.classList.add('fv-failure-buttons'); + footer.append(actions, close); if (panel.element.freevideoKind === 'browser-extension') { - // The steps end with a reload: that is the main button. Copy and export - // sit at the left of the same row, so a long title keeps its own line. - const footer = document.createElement('div'); footer.className = 'fv-failure-footer'; - const actions = panel.element.querySelector('.fv-failure-actions'); + // The steps end with a reload: that is the main button. actions.querySelector('.fv-failure-reload')?.remove(); const reload = document.createElement('button'); reload.type = 'button'; reload.className = 'fv-failure-primary'; reload.textContent = t('Reload page', '刷新页面'); reload.autofocus = true; reload.onclick = () => globalThis.location?.reload(); - close.textContent = t('Close', '关闭'); close.autofocus = false; - footer.append(actions, close, reload); - dialog.append(panel.element, footer); - } else dialog.append(panel.element, close); + close.className = ''; footer.append(reload); + } else close.autofocus = true; + dialog.append(panel.element, footer); dialog.onclose = () => { if (activeDialog === dialog) activeDialog = null; dialog.remove(); }; document.body.append(dialog); if (typeof dialog.showModal !== 'function') { dialog.remove(); return; } @@ -535,7 +535,7 @@ export function adviceFor(report, t, value) { // route: where a generation's diagnostic report comes from ({download, fetch, version, has}); without it the // button opens the feedback form. -export function createErrorPanel(t, route = null) { +function ensureFailureStyle() { if (!document.getElementById('freevideo-error-style')) { const style = document.createElement('style'); style.id = 'freevideo-error-style'; style.textContent = `.fv-failure{border:1px solid var(--fv-danger,#dc7c7c);border-radius:var(--fv-r-md,12px);padding:12px 14px;margin:10px 0;text-align:left;min-width:0} @@ -547,9 +547,29 @@ export function createErrorPanel(t, route = null) { .fv-failure-dialog{box-sizing:border-box;font-size:14px;width:min(560px,94vw);max-height:88vh;overflow:auto;padding:22px 24px;border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-lg,16px);background:var(--fv-bg,#202126);color:var(--fv-ink,#eee);box-shadow:0 24px 80px #0008} .fv-failure-dialog::backdrop{background:#070a0db8;backdrop-filter:blur(6px)}.fv-failure-dialog>.fv-failure{border:0;margin:0 0 16px;padding:0}.fv-failure-dialog>button,.fv-failure-dialog .fv-failure-primary{display:block;margin-left:auto;background:var(--fv-accent,#6db8fa);border-color:var(--fv-accent,#6db8fa);color:var(--fv-bg,#111720);font-weight:var(--fv-semibold,600)} .fv-failure .fv-extension-steps{font-size:14px;line-height:1.6}.fv-failure-reload{order:-1}.fv-failure .fv-failure-copy{margin-top:8px}.fv-failure-footer{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.fv-failure-footer>.fv-failure-actions{margin-right:auto} -.fv-failure-footer button{min-height:var(--fv-h-sm,32px);border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised,#303138);color:var(--fv-ink,#eee);padding:5px 12px;font:inherit;font-weight:var(--fv-medium,500);cursor:pointer}.fv-failure-footer .fv-failure-primary{margin-left:0}`; +.fv-failure-fold{border-top:1px solid var(--fv-border,#45454a);margin-top:12px;padding-top:2px}.fv-failure-fold>summary{display:flex;align-items:center;gap:8px;min-height:var(--fv-h-sm,32px);margin:0;list-style:none;cursor:pointer;color:var(--fv-muted,#bbb);font-size:13px}.fv-failure-fold>summary::-webkit-details-marker{display:none} +.fv-failure-chevron{flex:none;width:7px;height:7px;border-right:1.5px solid currentColor;border-bottom:1.5px solid currentColor;transform:rotate(-45deg);transition:transform .2s}.fv-failure-fold[open]>summary>.fv-failure-chevron{transform:rotate(45deg)} +.fv-failure-dialog .fv-failure-head{display:block;margin-bottom:6px}.fv-failure-dialog .fv-failure-head>strong{font-size:16px;line-height:1.5} +.fv-failure-buttons{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.fv-failure-buttons>button{min-height:var(--fv-h-sm,32px);box-sizing:border-box;padding:5px 14px;border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised,#303138);color:var(--fv-ink,#eee);font:inherit;font-size:13px;font-weight:var(--fv-medium,500);cursor:pointer;white-space:nowrap} +.fv-failure-footer button{min-height:var(--fv-h-sm,32px);border:1px solid var(--fv-border,#45454a);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised,#303138);color:var(--fv-ink,#eee);padding:5px 12px;font:inherit;font-weight:var(--fv-medium,500);cursor:pointer}.fv-failure-footer .fv-failure-primary{margin-left:auto}`; document.head.append(style); } +} + +// The fold row shared by every failure message (card, dialog, Agent chat): a chevron and "Technical details". +export function failureFold(t, text = '') { + ensureFailureStyle(); + const disclosure = document.createElement('details'), summary = document.createElement('summary'); + disclosure.className = 'fv-failure-fold'; + const chevron = document.createElement('span'); chevron.className = 'fv-failure-chevron'; chevron.setAttribute('aria-hidden', 'true'); + summary.append(chevron, t('Technical details', '技术详情')); + const body = document.createElement('p'); body.className = 'fv-failure-cause'; body.textContent = text; + disclosure.append(summary, body); + return disclosure; +} + +export function createErrorPanel(t, route = null) { + ensureFailureStyle(); const element = document.createElement('section'); element.className = 'fv-failure'; element.hidden = true; const head = document.createElement('div'); head.className = 'fv-failure-head'; const title = document.createElement('strong'); title.textContent = t('Generation failed', '生成失败'); @@ -574,7 +594,9 @@ export function createErrorPanel(t, route = null) { const downloadStatus = document.createElement('p'); downloadStatus.setAttribute('role', 'status'); let currentReport = null, githubLora = false; const disclosure = document.createElement('details'), summary = document.createElement('summary'); - summary.textContent = t('Technical details', '技术详情'); + disclosure.className = 'fv-failure-fold'; + const chevron = document.createElement('span'); chevron.className = 'fv-failure-chevron'; chevron.setAttribute('aria-hidden', 'true'); + summary.append(chevron, t('Technical details', '技术详情')); const detailLocation = document.createElement('p'); detailLocation.className = 'fv-failure-location'; detailLocation.hidden = true; const detailCause = document.createElement('p'); detailCause.className = 'fv-failure-cause'; detailCause.hidden = true; const details = document.createElement('textarea'); details.readOnly = true; @@ -673,7 +695,8 @@ export function createErrorPanel(t, route = null) { if (causeInDetails) location.textContent = ''; cause.hidden = causeInDetails; detailCause.hidden = !causeInDetails; cause.textContent = causeInDetails ? '' : causeText; - detailCause.textContent = causeInDetails ? causeText : ''; + // The same text is already in the box below; showing it twice only repeats the Traceback. + detailCause.textContent = ''; detailCause.hidden = true; details.value = report.details; disclosure.open = false; element.hidden = false; downloadStatus.textContent = ''; download.hidden = diagnosticReports.has(report); diff --git a/web/feedback.css b/web/feedback.css index 7890c83c..e9592bff 100644 --- a/web/feedback.css +++ b/web/feedback.css @@ -6,14 +6,18 @@ .fv-feedback .fv-feedback-actions{display:flex;align-items:center;justify-content:flex-end;gap:14px;margin-top:20px} .fv-feedback .fv-feedback-status{flex:1;margin:0;min-width:0;color:#a5b3c6;font-size:13px;line-height:1.45;text-decoration:none} .fv-feedback .fv-feedback-actions button{flex:none;min-width:88px} -/* Keep the entry beside Settings; compact headers use the same labelled button with a line icon. */ -.fv-header-actions>button{white-space:nowrap} -.fv-feedback-entry>svg{display:none;flex:none;width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round} -@media(max-width:1500px){.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-feedback-entry>svg{display:block}.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-feedback-label{display:none}} -@media(max-width:1040px){.fv-autopilot .fv-header:has(.fv-autopilot-now:not([hidden])) .fv-lan{display:none}} -@media(max-width:1040px){.fv-autopilot .fv-feedback-entry>svg{display:block}.fv-autopilot .fv-feedback-label{display:none}} -@media(max-width:1040px){.fv-editor .fv-guide-entry>svg{display:block}.fv-editor .fv-guide-entry .fv-feedback-label{display:none}} -@media(max-width:800px){.fv-editor .fv-feedback-entry>svg{display:block}.fv-editor .fv-feedback-label{display:none}} -@media(max-width:680px){.fv-studio .fv-feedback-entry>svg,.fv-view-navigation .fv-feedback-entry>svg{display:block}.fv-studio .fv-feedback-label,.fv-view-navigation .fv-feedback-label{display:none}} +/* One group of header entries in every view: text only, 14 px, 32 px high, 8 px apart. */ +.fv-studio .fv-header .fv-header-actions{gap:8px} +.fv-studio .fv-header .fv-header-actions>button,.fv-studio .fv-header .fv-header-actions .fv-lan-trigger,.fv-studio .fv-header .fv-header-actions .fv-version-info{box-sizing:border-box;display:inline-flex;align-items:center;justify-content:center;width:auto;min-width:0;height:32px;min-height:32px;padding:0 6px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--fv-muted);font:inherit;font-size:14px;font-weight:400;line-height:1;white-space:nowrap} +.fv-studio .fv-header .fv-header-actions>button:hover,.fv-studio .fv-header .fv-header-actions .fv-lan-trigger:hover,.fv-studio .fv-header .fv-header-actions .fv-lan-trigger[aria-expanded=true],.fv-studio .fv-header .fv-header-actions .fv-version-info:hover{background:var(--fv-hover)!important;border-color:transparent;color:var(--fv-ink)!important} +.fv-header .fv-header-actions .fv-lan{flex:none} +.fv-header .fv-header-actions .fv-lan-dot{right:3px;top:3px;border:0;background:var(--fv-success)} +.fv-version-dot{position:absolute;right:3px;top:3px;width:7px;height:7px;border-radius:50%;background:var(--fv-accent)} +.fv-header .fv-header-actions .fv-version-info{position:relative} +@media(pointer:coarse){.fv-header .fv-header-actions .fv-lan-trigger{width:auto;height:44px;padding:0 10px}} +/* Narrow Autopilot header: the same collapse order as the other views, phone first, then Feedback and Tutorial. */ +@media(max-width:720px){.fv-autopilot .fv-header .fv-lan{display:none}} +@media(max-width:560px){.fv-studio.fv-autopilot .fv-header .fv-header-actions .fv-feedback-entry{display:none}} +@media(max-width:480px){.fv-studio.fv-autopilot .fv-header .fv-header-actions .fv-guide-entry,.fv-studio.fv-autopilot .fv-header .fv-header-actions .fv-version-info{display:none}} /* On a narrow screen the line gets the whole width above the button. */ @media(max-width:480px){.fv-feedback .fv-feedback-actions{flex-wrap:wrap;row-gap:10px}.fv-feedback .fv-feedback-status{flex:1 0 100%}.fv-feedback .fv-feedback-status:empty{display:none}} diff --git a/web/feedback.js b/web/feedback.js index 32c5ebe9..a90389bc 100644 --- a/web/feedback.js +++ b/web/feedback.js @@ -215,30 +215,22 @@ export function openFeedback(t, { open = openPage, version = productVersion, return dialog; } -// The header entry, in the style of the Settings button beside it. +// The header entry: text, in the same group as Settings beside it. export function feedbackButton(t, className = 'fv-quiet') { const label = t('Feedback', '反馈'); const button = el('button', `${className} fv-feedback-entry`); button.type = 'button'; button.setAttribute('aria-label', label); button.title = label; - const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - icon.setAttribute('viewBox', '0 0 16 16'); icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('focusable', 'false'); - const path = document.createElementNS(icon.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'); - icon.append(path); button.append(icon, el('span', 'fv-feedback-label', label)); + button.append(el('span', 'fv-feedback-label', label)); button.onclick = () => openFeedback(t); return button; } -// The tutorial entry, left of Feedback: same style, icon only where Feedback is. +// The tutorial entry, left of Feedback: same style. export function guideButton(t, className = 'fv-quiet') { const label = t('Tutorial', '教程'); const button = el('button', `${className} fv-feedback-entry fv-guide-entry`); button.type = 'button'; button.setAttribute('aria-label', label); button.title = label; - const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); - icon.setAttribute('viewBox', '0 0 16 16'); icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('focusable', 'false'); - const path = document.createElementNS(icon.namespaceURI, 'path'); - path.setAttribute('d', 'M2.5 3.2c1.9-.5 3.7-.3 5.5.8v9c-1.8-1.1-3.6-1.3-5.5-.8ZM13.5 3.2c-1.9-.5-3.7-.3-5.5.8v9c1.8-1.1 3.6-1.3 5.5-.8Z'); - icon.append(path); button.append(icon, el('span', 'fv-feedback-label', label)); + button.append(el('span', 'fv-feedback-label', label)); button.onclick = () => import('./tutorial_book.js').then(({openTutorialBook}) => openTutorialBook({chapter: 'quick-start'})).catch(() => {}); return button; } diff --git a/web/freevideo.js b/web/freevideo.js index f04200da..54b403d4 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -5,6 +5,7 @@ import { app } from "../../scripts/app.js"; import { api } from "../../scripts/api.js"; import { openEditorView } from './editor_loader.js'; import { beginWorkspaceRestore, finishWorkspaceRestore, waitForLauncherRestore } from './workspace_restore.js'; +import { skinSelect } from './fv_select.js'; import { openStudio, loraPanel, loraWarning, promptGuide } from "./studio.js"; import { createGenerationProgress, referenceTrimText, thermalText, sharedGpuText, keepSoundText, encoderFileText, encoderFilePath } from './generation_progress.js'; import { createProgressConnection } from './progress_connection.js'; @@ -55,9 +56,9 @@ style.textContent = ` .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-panel .fv-depth-only input,.fv-panel .fv-sound input{min-height:0;height:auto}.fv-panel .fv-depth-only{display:flex;align-items:center;gap:6px;margin:0 0 8px;white-space:nowrap;font-size:var(--fv-micro);cursor:pointer} +.fv-panel select{max-width:170px}.fv-panel .fv-toolbar>.fv-select{width:auto}.fv-note[hidden]{display:none}.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-panel .fv-depth-only input,.fv-panel .fv-sound input{min-height:0;height:16px}.fv-panel .fv-depth-only{display:flex;align-items:center;gap:6px;margin:0 0 8px;white-space:nowrap;font-size:var(--fv-micro);cursor:pointer} .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)} +.fv-panel[data-freevideo=media]>.fv-mode:not([hidden]){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)} .fv-card[data-enabled=false]{opacity:.5}.fv-file{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:60px} .fv-preview{display:block;width:100%;max-height:160px;object-fit:contain;margin:8px 0;border-radius:var(--fv-r-sm,8px);background:var(--fv-bg)} .fv-card audio{width:100%;height:36px;margin:8px 0}.fv-empty{padding:35px 12px;text-align:center;color:var(--fv-muted);border:1px dashed var(--fv-border);border-radius:var(--fv-r-md,12px);margin-top:12px} @@ -149,7 +150,6 @@ function mediaPanel(node, mount = null) { picker.accept = acceptList(["image", "video", "audio"]); picker.hidden = true; const add = button(text("Upload files", "上传文件"), text("Upload images, video or audio", "上传图片、视频或音频"), () => picker.click()); const generateImage = button(text('Generate with AI', 'AI 生成'), text('Generate with AI', 'AI 生成'), () => newImage()); - generateImage.prepend(document.createRange().createContextualFragment(icon('generate'))); generateImage.hidden = !imageGenerationSupported(); const audioPicker = el('input'); audioPicker.type = 'file'; audioPicker.multiple = true; audioPicker.accept = acceptList(["audio"]); audioPicker.hidden = true; @@ -161,7 +161,7 @@ function mediaPanel(node, mount = null) { const option = el("option", label); option.value = value; role.append(option); } role.value = 'reference'; - toolbar.append(add, generateImage, addAudio, role, picker, audioPicker); panel.append(toolbar, mode, note, audioHelp, connections, cards); + toolbar.append(add, generateImage, addAudio, skinSelect(role), picker, audioPicker); panel.append(toolbar, mode, note, audioHelp, connections, cards); const cast = createCastLibrary({api, text, viewURL, uploadOne}); const places = createCastLibrary({api, text, viewURL, uploadOne, kind: 'setting'}); const props = createCastLibrary({api, text, viewURL, uploadOne, kind: 'prop'}); @@ -172,7 +172,7 @@ function mediaPanel(node, mount = null) { if (!Array.isArray(rows) || rows.length > 32) throw new Error(text("Use at most 32 media items", "最多使用 32 个素材")); return rows; }; - const message = (value, error = false) => { note.textContent = value; note.classList.toggle("fv-error", error); }; + const message = (value, error = false) => { note.textContent = value; note.hidden = !value; note.classList.toggle("fv-error", error); }; const update = rows => { serialized.value = JSON.stringify(rows); node.graph?.change(); window.dispatchEvent(new CustomEvent('freevideo-media', {detail: node.id})); }; const changed = event => { if (String(event.detail) === String(node.id)) render(); }; window.addEventListener('freevideo-media', changed); @@ -332,9 +332,10 @@ function mediaPanel(node, mount = null) { const chip = el('span', `${label} · ${text('Connected', '已连接')}`, 'fv-connection'); chip.title = source?.title || source?.type || label; connections.append(chip); } - mode.textContent = refs ? text("Reference video · experimental", "参考生成 · 实验性") : first && last ? text("First + last frame", "首尾帧生成") : first ? text("Image to video", "图生视频") : last ? text("Last frame to video", "尾帧生成") : text("Text to video · media is optional", "文生视频 · 素材可留空"); - message(refs > 32 ? text('There are more than 32 references; remove some before generating.', '参考素材超过 32 个,可移除部分后再生成。') : continuing && active.filter(row => row.role === 'reference' && mediaKind(row.file) === 'image').length > 3 ? text('When continuing, only the first 3 images are used.', '接续时只用前 3\u00a0张图片。') : (first || last) && continuing ? text("When continuing a video, the first and last frames are used as references.", "接续视频时,首尾帧会用作参考图。") : refs && (first || last) ? text("Choose keyframes or references. Disable unused cards.", "首尾帧与参考模式不能混用,请关闭本次不用的素材。") : first > 1 || last > 1 ? text("Keep only one first frame and one last frame enabled.", "首帧和尾帧各只能启用一张图片。") : text("Drop or paste files, or connect images and audio on the left.", "可拖入、粘贴文件,或连接左侧的图片和音频。"), Boolean(refs > 32 || refs && (first || last) && !continuing || first > 1 || last > 1)); - if (!rows.length && !connections.childElementCount) cards.append(el("div", text("Leave empty for text to video.", "留空则为文生视频。"), "fv-empty")); + mode.textContent = refs ? text("Reference video · experimental", "参考生成 · 实验性") : first && last ? text("First + last frame", "首尾帧生成") : first ? text("Image to video", "图生视频") : last ? text("Last frame to video", "尾帧生成") : ""; + mode.hidden = !mode.textContent; + message(refs > 32 ? text('There are more than 32 references; remove some before generating.', '参考素材超过 32 个,可移除部分后再生成。') : continuing && active.filter(row => row.role === 'reference' && mediaKind(row.file) === 'image').length > 3 ? text('When continuing, only the first 3 images are used.', '接续时只用前 3\u00a0张图片。') : (first || last) && continuing ? text("When continuing a video, the first and last frames are used as references.", "接续视频时,首尾帧会用作参考图。") : refs && (first || last) ? text("Choose keyframes or references. Disable unused cards.", "首尾帧与参考模式不能混用,请关闭本次不用的素材。") : first > 1 || last > 1 ? text("Keep only one first frame and one last frame enabled.", "首帧和尾帧各只能启用一张图片。") : "", Boolean(refs > 32 || refs && (first || last) && !continuing || first > 1 || last > 1)); + if (!rows.length && !connections.childElementCount) cards.append(el("div", text("Drop images or video here", "拖入图片或视频"), "fv-empty")); if (!rows.length && connections.childElementCount) cards.append(el('div', text('Media comes from the connected inputs; no upload is needed.', '素材来自已连接的输入,无需上传。'), 'fv-connected-card')); rows.forEach((row, index) => { const card = el("div", undefined, "fv-card"); card.dataset.enabled = String(row.enabled !== false); diff --git a/web/fv_select.js b/web/fv_select.js new file mode 100644 index 00000000..e9fd27a5 --- /dev/null +++ b/web/fv_select.js @@ -0,0 +1,95 @@ +// The one dropdown of every page: the face of the Resolution select (raised ground, 1 px border, 8 px corners, a drawn chevron). +// skinSelect puts the face on a native select, which stays the source of truth; menuSelect also opens a menu in the +// editor's context-menu style, with group headings and a tick on the chosen item, in place of the system list. +const el = (tag, cls, label) => { const e = document.createElement(tag); if (cls) e.className = cls; if (label) e.textContent = label; return e; }; + +export function skinSelect(select) { + const wrap = el('div', 'fv-select'); + wrap.append(select); + return wrap; +} + +const CHECK = ''; + +// `groups` maps an option's data-group to the heading above its run; an option without a group stands alone below a line. +export function menuSelect(select, {groups = {}} = {}) { + const wrap = skinSelect(select); + wrap.classList.add('fv-select-menu-host'); + select.classList.add('fv-select-native'); select.tabIndex = -1; select.setAttribute('aria-hidden', 'true'); + const trigger = el('button', 'fv-select-trigger'); trigger.type = 'button'; + trigger.setAttribute('aria-haspopup', 'listbox'); trigger.setAttribute('aria-expanded', 'false'); + const face = el('span', 'fv-select-face'); trigger.append(face); + const menu = el('div', 'fv-select-menu'); menu.hidden = true; menu.setAttribute('role', 'listbox'); menu.tabIndex = -1; + wrap.append(trigger, menu); + let items = []; + const refresh = () => { + const label = select.getAttribute('aria-label'); + if (label) trigger.setAttribute('aria-label', label); + face.textContent = select.selectedOptions[0]?.textContent || ''; + trigger.disabled = select.disabled; + }; + const close = (restore = false) => { + if (menu.hidden) return; + menu.hidden = true; trigger.setAttribute('aria-expanded', 'false'); menu.replaceChildren(); items = []; + document.removeEventListener('pointerdown', outside, true); + window.removeEventListener('resize', closeNow); window.removeEventListener('blur', closeNow); + if (restore) trigger.focus({preventScroll: true}); + }; + const closeNow = () => close(); + function outside(event) { if (!wrap.contains(event.target)) close(); } + function choose(value) { + close(true); + if (select.value === value) return; + select.value = value; refresh(); + select.dispatchEvent(new Event('input', {bubbles: true})); select.dispatchEvent(new Event('change', {bubbles: true})); + } + function open() { + if (select.disabled || !menu.hidden) return; + let previous = null, started = false; + for (const child of select.children) { + if (child.tagName === 'HR') { if (started) menu.append(Object.assign(el('div'), {role: 'separator'})); previous = null; continue; } + if (child.hidden || child.disabled) continue; + const group = child.dataset.group || ''; + if (started && group !== (previous ?? group)) menu.append(Object.assign(el('div'), {role: 'separator'})); + if (group && group !== previous && groups[group]) menu.append(el('div', 'fv-select-group', groups[group])); + previous = group; started = true; + const item = el('button', 'fv-select-item'); item.type = 'button'; item.tabIndex = -1; item.setAttribute('role', 'option'); + const mark = el('span', 'fv-select-mark'); mark.innerHTML = CHECK; + const chosen = child.value === select.value; item.setAttribute('aria-selected', String(chosen)); + item.append(el('span', 'fv-select-label', child.textContent), mark); + item.onclick = () => choose(child.value); + items.push(item); menu.append(item); + } + // Leave a separator only between two things. + for (const line of [...menu.querySelectorAll('[role=separator]')]) if (!line.nextElementSibling || line.nextElementSibling.matches('[role=separator]')) line.remove(); + menu.hidden = false; trigger.setAttribute('aria-expanded', 'true'); + const box = trigger.getBoundingClientRect(), height = menu.offsetHeight, room = window.innerHeight - box.bottom - 8; + menu.style.minWidth = `${Math.round(box.width)}px`; + menu.style.left = `${Math.max(8, Math.min(box.left, window.innerWidth - menu.offsetWidth - 8))}px`; + menu.style.top = `${Math.max(8, height <= room || room >= box.top - 8 ? box.bottom + 4 : box.top - height - 4)}px`; + menu.style.maxHeight = `${Math.max(120, Math.max(room, box.top - 8))}px`; + document.addEventListener('pointerdown', outside, true); + window.addEventListener('resize', closeNow); window.addEventListener('blur', closeNow); + (items.find(item => item.getAttribute('aria-selected') === 'true') || items[0])?.focus({preventScroll: true}); + } + trigger.onclick = () => menu.hidden ? open() : close(true); + const keys = event => { + const move = {ArrowDown: 1, ArrowUp: -1}[event.key]; + if (menu.hidden) { + if (move) { event.preventDefault(); event.stopPropagation(); open(); } + return; + } + event.stopPropagation(); + if (event.key === 'Escape') { event.preventDefault(); close(true); } + else if (event.key === 'Tab') close(); + else if (move || event.key === 'Home' || event.key === 'End') { + event.preventDefault(); + const at = items.indexOf(document.activeElement); + items[event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 : (at + move + items.length) % items.length]?.focus({preventScroll: true}); + } + }; + wrap.addEventListener('keydown', keys); + select.addEventListener('change', refresh); + refresh(); + return {wrap, trigger, refresh, close}; +} diff --git a/web/health.js b/web/health.js index 507a00d0..474110f1 100644 --- a/web/health.js +++ b/web/health.js @@ -13,12 +13,12 @@ const t = (en, zh) => cn ? zh : en; // launcher.js removes this flag during setup; read it before that. const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. -export const MODULES = ['audio_original.js', 'chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.js', 'agent_confirm_settings.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'chat_attachments.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'chain_rules.js', 'timed_prompt.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'engine_exit.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', +export const MODULES = ['audio_original.js', 'chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'monitor_panel.js', 'qrcodegen.js', 'agent_activity.js', 'agent_confirm_settings.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'chat_attachments.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'chain_rules.js', 'timed_prompt.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'engine_exit.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_duration.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_stems.js', 'editor_record.js', 'editor_record_model.js', 'editor_voice.js', 'editor_voice_install.js', 'editor_voice_model.js', 'editor_voice_store.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_music.js', 'editor_music_model.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_header_fit.js', 'studio_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', 'image_lora.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_sections.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js', 'task_groups.js', 'result_actions.js', 'result_reuse.js', 'sampling_effort.js', 'pass_mode.js','setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'replace_person.js', 'replace_state.js', 'optional_inputs.js', 'other_usage.js', 'studio_queue.js', 'studio_size.js', - 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'whatsnew.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', + 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'whatsnew.js', 'whatsnew_pages.js', 'whatsnew_peaks.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', 'empty_prompt_notice.js', 'feature_tour.js', 'feature_tour_content.js', 'howto.js', 'swap_lora.js', 'sound_bubble.js', 'tour_goto.js', 'tour_try.js', 'tutorial_book.js', 'tutorial_book_content.js']; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); let visibleAt = 0; @@ -88,7 +88,9 @@ function install() { }, true); addEventListener('unhandledrejection', event => { const reason = event.reason; - if (String(reason?.stack || '').includes(base.href)) note('script', describe(reason)); + if (!String(reason?.stack || '').includes(base.href)) return; + // A script that handled the rejection (preventDefault) has dealt with it: decide after every listener has run. + setTimeout(() => { if (!event.defaultPrevented) note('script', describe(reason)); }); }); console.error = function (...args) { try { diff --git a/web/image_editor.css b/web/image_editor.css index 50b21fd4..58e69830 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -208,6 +208,9 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-field input[type=color]{width:44px;height:28px;padding:0 2px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-raised);cursor:pointer} .fv-ie-field input[type=checkbox]{accent-color:var(--fv-accent);margin:0} .fv-ie-field .fv-ie-select{flex:1;min-width:0} +.fv-ie-field>.fv-select{flex:1;min-width:0} +.fv-ie-gen-shape>.fv-select{flex:1} +.fv-ie-options>.fv-select{flex:0 0 auto;min-width:132px} .fv-ie-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center} .fv-ie-end{justify-content:flex-end} .fv-ie-pair{display:grid;grid-template-columns:minmax(0,1.4fr) minmax(0,1fr);gap:8px} diff --git a/web/image_editor.js b/web/image_editor.js index af8bc958..4c0d29ba 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -11,6 +11,7 @@ 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'; +import {skinSelect} from './fv_select.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./image_editor.css', import.meta.url).href; @@ -577,7 +578,7 @@ export async function openImageEditor({source = null, name = source || '', targe const ratio = el('select', null, 'fv-ie-select'); ratio.id = 'fv-ie-ratio'; ratio.setAttribute('aria-label', t('Shape', '比例')); for (const [id] of RATIOS) { if (id === 'video' && !target) continue; const o = el('option', ratioLabel(id)); o.value = id; ratio.append(o); } ratio.value = doc.ratio; ratio.onchange = () => setRatio(ratio.value); - add(label(t('Shape', '比例')), ratio, sep(), + add(label(t('Shape', '比例')), skinSelect(ratio), sep(), iconButton('ccw', t('Rotate left 90°', '向左旋转 90°'), () => turn('ccw')), iconButton('cw', t('Rotate right 90°', '向右旋转 90°'), () => turn('cw')), iconButton('flipX', t('Flip horizontally', '水平翻转'), () => turn('flipX')), iconButton('flipY', t('Flip vertically', '垂直翻转'), () => turn('flipY')), sep()); if (target) add(button(t('Pad to video shape', '补边到视频比例'), t('Extend the canvas with the background color instead of cropping', '用背景色补边到视频比例,不裁掉画面'), () => padToVideo())); @@ -673,7 +674,7 @@ export async function openImageEditor({source = null, name = source || '', targe const blendNames = () => ({'source-over': t('Normal', '正常'), multiply: t('Multiply', '正片叠底'), screen: t('Screen', '滤色'), overlay: t('Overlay', '叠加'), 'soft-light': t('Soft light', '柔光'), darken: t('Darken', '变暗'), lighten: t('Lighten', '变亮'), 'color-dodge': t('Color dodge', '颜色减淡'), 'color-burn': t('Color burn', '颜色加深'), difference: t('Difference', '差值')}); - const field = (text, input) => { const f = el('div', null, 'fv-ie-field'); const l = el('label', text); l.htmlFor = input.id; f.append(l, input); return f; }; + const field = (text, input) => { const f = el('div', null, 'fv-ie-field'); const l = el('label', text); l.htmlFor = (input.querySelector?.('select') || input).id; f.append(l, input); return f; }; function renderProps() { clearPanel(props); const layer = active(); @@ -687,7 +688,7 @@ export async function openImageEditor({source = null, name = source || '', targe const blend = el('select', null, 'fv-ie-select'); blend.id = 'fv-ie-blend'; for (const [value, text] of Object.entries(blendNames())) { const o = el('option', text); o.value = value; blend.append(o); } blend.value = layer.blend || 'source-over'; blend.onchange = () => commit(commands.setLayer(doc, layer.id, {blend: blend.value})); - const basics = el('div', null, 'fv-ie-basics'); basics.append(opacity.row, field(t('Blend', '混合'), blend)); + const basics = el('div', null, 'fv-ie-basics'); basics.append(opacity.row, field(t('Blend', '混合'), skinSelect(blend))); props.append(basics); if (layer.kind === 'text') props.append(textProps(layer)); else if (layer.kind === 'shape') props.append(shapeProps(layer)); @@ -762,10 +763,10 @@ export async function openImageEditor({source = null, name = source || '', targe if (!layer) return; const next = {...layer.text, ...patch}; if (!fontLoaded(next.font, next.weight)) { - message(t('Preparing the font…', '正在准备字体…')); + const done = working('Preparing the font', '正在准备字体'); try { await loadFont(next.font, next.weight); } catch (error) { console.warn('FreeVideo image editor font unavailable; using fallback fonts.', error); } - message(''); + done(); } if (dialog.open && doc.layers.some(l => l.id === id)) commit(updateText(id, patch)); } @@ -927,8 +928,8 @@ export async function openImageEditor({source = null, name = source || '', targe 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); } + const done = working('Uploading the image', '正在上传图片'); + try { const result = await upload(file); done(); await addImageLayer(result.file, file.name); } catch (error) { message(error.message || t('Upload failed.', '上传失败。'), ['Image editor upload', '图片编辑器上传']); } }; function duplicateLayer() { @@ -1249,10 +1250,11 @@ export async function openImageEditor({source = null, name = source || '', targe if (!pointInfo?.ready) { flashPointDownload(); return null; } if (pointBusy) return null; const how = mode || opts.point.mode; - pointBusy = true; message(t('Finding the object…', '正在找出物体……')); + pointBusy = true; const found = working('Finding the object', '正在找出物体'); segmentAt(renderer, genClient, doc, src, [u, v]).then(file => { + found(); if (doc.layers.indexOf(src) < 0) return; - addPart({shape: 'mask', layer: src.id, file, upto: src.ops.length}, how); message(''); + addPart({shape: 'mask', layer: src.id, file, upto: src.ops.length}, how); }).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.', '没能在这里找出物体,请再试一次。'), error?.code === 'busy_video' ? null : ['Image object selection', '图片智能抠图']); @@ -1330,12 +1332,12 @@ export async function openImageEditor({source = null, name = source || '', targe if (maskTarget && layer.mask) op.tool = kind === 'paint' ? 'hide' : kind === 'erase' ? 'reveal' : kind; if (kind === 'heal') { // Healing a large stroke takes a moment: say so, and keep the stroke on screen until it is done. - liveStroke = held; message(t('Healing…', '正在修复…')); draw(); + liveStroke = held; const healed = working('Healing', '正在修复'); draw(); healing = new Promise(resolve => setTimeout(() => { if (liveStroke === held) liveStroke = null; const current = dialog.open && doc.layers.find(l => l.id === layer.id); if (current) pushOp(current, op, doc); - healing = null; message(''); resolve(); + healing = null; healed(); resolve(); }, 40)); return; } @@ -1609,13 +1611,30 @@ export async function openImageEditor({source = null, name = source || '', targe // ----- leave / save ---------------------------------------------------------------------------- let resolveResult; const result = new Promise(r => { resolveResult = r; }); - let messageTimer = null; + let messageTimer = null, workingTimer = null; function message(text, where = null) { + clearInterval(workingTimer); workingTimer = 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); } + // A running task keeps its line, with the time so far, until it ends or another message replaces it. + function working(en, zh) { + message(''); + const since = performance.now(); + // Only the label is announced; the ticking time is hidden from screen readers. + const time = document.createElement('span'); time.setAttribute('aria-hidden', 'true'); + status.textContent = t(en, zh); status.append(time); + const show = () => { + const s = Math.floor((performance.now() - since) / 1000), m = Math.floor(s / 60), r = s % 60; + time.textContent = s < 1 ? '…' : s < 60 ? t(` · ${s} s`, ` · ${s} 秒`) + : t(` · ${m} min${r ? ` ${r} s` : ''}`, ` · ${m} 分${r ? ` ${r} 秒` : ''}`); + }; + show(); + const timer = workingTimer = setInterval(show, 1000); + return () => { if (workingTimer === timer) message(''); }; + } async function leave() { void generation.cancel(); if (healing) await healing; @@ -1630,7 +1649,7 @@ export async function openImageEditor({source = null, name = source || '', targe commitText(); if (strip(doc) === strip(original())) { close(fresh ? null : {restore: true}); return; } if (!changed() && !fresh) { close(null); return; } - save.disabled = cancel.disabled = true; message(t('Saving the image…', '正在保存图片…')); + save.disabled = cancel.disabled = true; working('Saving the image', '正在保存图片'); try { 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}); @@ -1642,7 +1661,7 @@ export async function openImageEditor({source = null, name = source || '', targe function close(value) { window.removeEventListener('beforeunload', beforeUnload); unloadWarning = false; void generation.dispose(); - clearInterval(antsTimer); clearTimeout(messageTimer); + clearInterval(antsTimer); clearInterval(workingTimer); clearTimeout(messageTimer); observer.disconnect(); dialog.removeEventListener('keydown', keys); dialog.removeEventListener('keyup', keyup); window.removeEventListener('keydown', stray, true); window.removeEventListener('keyup', stray, true); diff --git a/web/image_generation.js b/web/image_generation.js index 94cdc3ff..0eb63bdb 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -4,6 +4,7 @@ import * as commands from './image_commands.js'; import {orientMatrix, rasterKinds, scaleSelection} from './image_editor_engine.js'; import {saveReport, reportIssue, showReportTip, focusReportTip, REPORT_GONE, reportFetchError} from './report_issue.js'; import {issueButton} from './issue_button.js'; +import {skinSelect} from './fv_select.js'; import {about} from './request_assets.js'; import {createImageLoras} from './image_lora.js'; import {createOtherUsageNotice, otherUsageText} from './other_usage.js'; @@ -860,7 +861,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection update(); }; ratio.onchange = changed; - shape.append(ratio, size); + shape.append(skinSelect(ratio), size); } const field = el('div', null, 'fv-ie-gen-field'); const refs = el('div', null, 'fv-ie-gen-refs'); refs.setAttribute('role', 'list'); diff --git a/web/image_lora.js b/web/image_lora.js index b036be52..e40a0d29 100644 --- a/web/image_lora.js +++ b/web/image_lora.js @@ -3,6 +3,7 @@ // graph node to hold it) and puts trigger words into the prompt the way the video panel does. import {reconcileLoraTriggers} from './lora_triggers.js'; import {createVerdicts} from './lora_check.js'; +import {skinSelect} from './fv_select.js'; export const IMAGE_LORA_LIMIT = 8, IMAGE_LORA_STRENGTH = 2; const STORE = 'freevideo_image_loras'; @@ -135,7 +136,7 @@ export function createImageLoras({t, fetchApi, el = domEl, getPrompt = () => '', catch (error) { note.textContent = error.message; } }; const check = () => verdicts.get(row.name).then(() => { if (!disposed && wrap.isConnected && select.value === row.name) { show(); drawSummary(); syncPrompt(); onChange(); } }); - wrap.append(on, select, strength, remove, trigger, problem); + wrap.append(on, skinSelect(select), strength, remove, trigger, problem); list.append(wrap); show(); if (!verdicts.peek(row.name)) check(); triggerWords(row.name).then(() => { diff --git a/web/lan_access.js b/web/lan_access.js index aecb3490..00c20b48 100644 --- a/web/lan_access.js +++ b/web/lan_access.js @@ -12,10 +12,8 @@ function drawQr(canvas, link) { const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; const button = (cls, label, action) => { const b = el('button', cls, label); b.type = 'button'; b.onclick = action; return b; }; export function createLanAccess(t, api) { - const element = el('div', 'fv-lan'), trigger = button('fv-lan-trigger', '', toggle), panel = el('section', 'fv-lan-panel'); + const element = el('div', 'fv-lan'), trigger = button('fv-lan-trigger', t('Phone', '手机'), toggle), panel = el('section', 'fv-lan-panel'); const label = t('Phone access', '手机连接'); trigger.setAttribute('aria-label', label); trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-haspopup', 'dialog'); - const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); - const path = document.createElementNS(svg.namespaceURI || 'http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M7 2h10a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM10 18h4'); svg.append(path); trigger.append(svg); const dot = el('i', 'fv-lan-dot'); dot.hidden = true; dot.setAttribute('aria-hidden', 'true'); trigger.append(dot); panel.hidden = true; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', label); const head = el('div', 'fv-lan-heading'), toggleButton = button('fv-lan-switch', '', changeEnabled); diff --git a/web/launcher.js b/web/launcher.js index ed1eafac..2a66dd95 100644 --- a/web/launcher.js +++ b/web/launcher.js @@ -177,6 +177,19 @@ async function keepInFront(workflow, opened) { } } +// While extensions are still starting, ComfyUI has not set its canvas store yet. Frontends from 1.57 on +// then reject one internal step of loadGraphData (the address hash) with this error, un-awaited, after +// the graph itself has loaded. Nothing in the page depends on that step; absorb exactly this rejection +// for the duration of the call instead of leaving it unhandled. The listener runs before the page +// check's own, which therefore never mistakes it for a FreeVideo failure. +const canvasNotReady = reason => reason instanceof Error && reason.message === 'getCanvas: canvas is null'; +async function loadWhileStarting(graph, name) { + const absorb = event => { if (canvasNotReady(event.reason)) { event.preventDefault(); event.stopImmediatePropagation(); } }; + addEventListener('unhandledrejection', absorb, true); + try { return await app.loadGraphData(graph, true, true, name); } + finally { setTimeout(() => removeEventListener('unhandledrejection', absorb, true), 2000); } +} + // Explicit launcher navigation only; ordinary ComfyUI visits restore their own // graph. Remove the flag before loading so refreshing never resets user edits. app.registerExtension({ @@ -198,7 +211,7 @@ app.registerExtension({ // Opening the bundled graph now would overwrite the cached project's // media and connections before ComfyUI restores them; keepInFront // brings the restored tab forward instead. - if (!cached) await app.loadGraphData(structuredClone(workflow), true, true, 'FreeVideo'); + if (!cached) await loadWhileStarting(structuredClone(workflow), 'FreeVideo'); } catch (error) { console.error('FreeVideo launcher:', error); health({type: 'launch', error}); diff --git a/web/media_upload.js b/web/media_upload.js index b849ff2a..c3c11927 100644 --- a/web/media_upload.js +++ b/web/media_upload.js @@ -14,16 +14,20 @@ const failures = () => ({ invalid: text('This file name cannot be used. Rename the file and try again.', '这个文件名不能使用,请改名后再试。'), }); const generic = () => text('The upload did not finish. Try again.', '上传没有完成,请再试一次。'); -export async function uploadMedia(api, file) { +// A request that never answers is cut off, so the pickers unlock and the failure line shows: two minutes plus four seconds per MiB, so a slow link is not mistaken for a hang. +export const uploadTimeoutMs = size => 120000 + Math.ceil(size / 1024 ** 2) * 4000; +export async function uploadMedia(api, file, {timeoutMs = uploadTimeoutMs(file.size)} = {}) { const found = failures(); if (file.size > MAX_UPLOAD_BYTES) throw new Error(found.too_large); if (file.size === 0) throw new Error(found.empty); const body = new FormData(); body.append('file', file); let response, result = {}; + const abort = new AbortController(), timer = setTimeout(() => abort.abort(), timeoutMs); try { - response = await api.fetchApi('/freevideo/media/upload', {method: 'POST', body}); + response = await api.fetchApi('/freevideo/media/upload', {method: 'POST', body, signal: abort.signal}); result = await response.json(); } catch { if (!response || response.ok) throw new Error(generic()); } + finally { clearTimeout(timer); } if (!response.ok) throw new Error(found[result?.code] || generic()); return result; } diff --git a/web/monitor_panel.css b/web/monitor_panel.css new file mode 100644 index 00000000..a6516129 --- /dev/null +++ b/web/monitor_panel.css @@ -0,0 +1,18 @@ +.fv-monitor{position:relative;display:inline-flex;flex:none;font-size:var(--fv-body);color:var(--fv-ink)}.fv-monitor[hidden]{display:none} +.fv-monitor button{font:inherit;cursor:pointer;color:inherit;border:0;touch-action:manipulation}.fv-monitor button:disabled{opacity:.45;cursor:default} +.fv-monitor .fv-monitor-trigger{display:inline-flex;align-items:center;gap:6px;height:32px;padding:0 10px 0 8px;border-radius:99px;background:var(--fv-raised);border:1px solid var(--fv-border);white-space:nowrap} +.fv-monitor-trigger:hover,.fv-monitor-trigger[aria-expanded=true]{background:var(--fv-hover)!important} +.fv-monitor svg{width:16px;height:16px;fill:none;stroke:var(--fv-muted);stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;display:block;flex:none} +.fv-monitor-text[hidden]{display:none}.fv-monitor .fv-monitor-warn{color:var(--fv-danger)} +.fv-monitor-panel{position:fixed;z-index:1000;box-sizing:border-box;width:280px;max-width:calc(100vw - 16px);padding:16px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:var(--fv-r-md);box-shadow:0 12px 36px #0006;text-align:left} +.fv-monitor-name{display:block;font-size:calc(var(--fv-body) + 1px);font-weight:var(--fv-semibold)}.fv-monitor-name[hidden]{display:none} +.fv-monitor-rows{display:flex;flex-direction:column;gap:12px;margin-top:12px}.fv-monitor-rows:empty{display:none} +.fv-monitor-line{display:flex;justify-content:space-between;gap:12px}.fv-monitor-title{color:var(--fv-muted)} +.fv-monitor-bar{height:4px;margin-top:6px;border-radius:99px;background:var(--fv-hover);overflow:hidden}.fv-monitor-bar i{display:block;height:100%;background:var(--fv-accent)}.fv-monitor-bar i.fv-monitor-warn{background:var(--fv-danger)} +.fv-monitor-stopped button{white-space:nowrap;flex:none}.fv-monitor-stopped{display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:14px;color:var(--fv-muted)}.fv-monitor-stopped[hidden]{display:none} +.fv-monitor .fv-monitor-resume{background:none;color:var(--fv-accent);height:32px;padding:0 4px} +.fv-monitor .fv-monitor-stop{display:block;width:100%;height:36px;margin-top:14px;border-radius:var(--fv-r-sm);background:transparent;border:1px solid var(--fv-danger);color:var(--fv-danger);font-weight:var(--fv-semibold)} +.fv-monitor .fv-monitor-stop:disabled{border-color:var(--fv-border);color:var(--fv-muted);opacity:1} +.fv-monitor .fv-monitor-stop.fv-monitor-confirm{background:var(--fv-danger);color:var(--fv-bg)} +.fv-monitor :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} +@media(pointer:coarse){.fv-monitor .fv-monitor-trigger{height:44px}.fv-monitor-panel button{min-height:44px}} diff --git a/web/monitor_panel.js b/web/monitor_panel.js new file mode 100644 index 00000000..b2928142 --- /dev/null +++ b/web/monitor_panel.js @@ -0,0 +1,109 @@ +const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./monitor_panel.css', import.meta.url).href; document.head.append(css); +const shownKey = 'freevideo.monitor.shown', changed = 'freevideo-monitor-shown'; +const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; +const GIB = 2 ** 30, memoryLimit = 95, temperatureLimit = 85; +export function monitorShown() { try { return localStorage.getItem(shownKey) === '1'; } catch { return false; } } +export function setMonitorShown(value) { + try { localStorage.setItem(shownKey, value ? '1' : '0'); } catch {} + window.dispatchEvent(new Event(changed)); +} +export function onMonitorShown(listener) { + const storage = event => { if (event.key === shownKey) listener(); }; + window.addEventListener(changed, listener); window.addEventListener('storage', storage); + return () => { window.removeEventListener(changed, listener); window.removeEventListener('storage', storage); }; +} +const gib = bytes => (bytes / GIB).toFixed(1); +const svg = d => { + const root = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), path = document.createElementNS(root.namespaceURI, 'path'); + root.setAttribute('viewBox', '0 0 24 24'); root.setAttribute('aria-hidden', 'true'); path.setAttribute('d', d); root.append(path); return root; +}; +// The top-bar capsule and its panel. Nothing is read from the server while the setting is off. +export function createMonitor(t, api) { + const element = el('div', 'fv-monitor'), trigger = el('button', 'fv-monitor-trigger'), text = el('span', 'fv-monitor-text'), panel = el('section', 'fv-monitor-panel'); + const label = t('System status', '运行状态'); + trigger.type = 'button'; trigger.setAttribute('aria-label', label); trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-haspopup', 'dialog'); + trigger.append(svg('M3 12h4l3-8 4 16 3-8h4'), text); + panel.hidden = true; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', label); + const name = el('strong', 'fv-monitor-name'), rows = el('div', 'fv-monitor-rows'), stop = el('button', 'fv-monitor-stop', t('Stop now', '立即停止')); + stop.type = 'button'; + const stopped = el('div', 'fv-monitor-stopped'), stoppedText = el('span', '', t('Stopped. The queue is paused.', '已停止,队列已暂停。')), resume = el('button', 'fv-monitor-resume', t('Resume queue', '继续队列')); + resume.type = 'button'; stopped.append(stoppedText, resume); stopped.hidden = true; + panel.append(name, rows, stopped, stop); element.append(trigger, panel); element.hidden = true; + let state = {}, timer = null, reading = false, disposed = false, confirming = null, active = false; + const row = (title, value, bar, warn) => { + const line = el('div', 'fv-monitor-row'), head = el('div', 'fv-monitor-line'); + const number = el('span', warn ? 'fv-monitor-value fv-monitor-warn' : 'fv-monitor-value', value); + head.append(el('span', 'fv-monitor-title', title), number); line.append(head); + if (bar != null) { const track = el('div', 'fv-monitor-bar'), fill = el('i'); fill.style.width = `${Math.min(100, bar)}%`; if (warn) fill.className = 'fv-monitor-warn'; track.append(fill); line.append(track); } + return line; + }; + const percent = value => state.memory_total ? Math.round(100 * state.memory_used / state.memory_total) : null; + function render() { + const memory = state.memory_total ? percent() : null, hot = state.temperature >= temperatureLimit, full = memory != null && memory >= memoryLimit; + text.replaceChildren(); + const parts = []; + if (memory != null) parts.push([`${t('VRAM', '显存')} `, `${memory}%`, full]); + if (state.temperature != null) parts.push(['', `${state.temperature} °C`, hot]); + parts.forEach(([label, value, warn], index) => { + if (index) text.append(document.createTextNode(' · ')); + if (label) text.append(document.createTextNode(label)); + text.append(el('span', warn ? 'fv-monitor-warn' : '', value)); + }); + text.hidden = !parts.length; + name.textContent = state.name || ''; name.hidden = !state.name; + const list = []; + if (memory != null) list.push(row(t('VRAM', '显存'), `${gib(state.memory_used)} / ${gib(state.memory_total)} GiB`, memory, full)); + if (state.utilization != null) list.push(row(t('Utilization', '使用率'), `${state.utilization}%`)); + if (state.temperature != null) list.push(row(t('Temperature', '温度'), `${state.temperature} °C`, null, hot)); + if (state.disk_free != null) list.push(row(t('Disk', '硬盘'), t(`${gib(state.disk_free)} GiB free`, `剩余 ${gib(state.disk_free)} GiB`))); + rows.replaceChildren(...list); + stopped.hidden = !state.paused; + stop.disabled = !state.busy; + if (!confirming) stop.textContent = t('Stop now', '立即停止'); + } + async function read() { + clearTimeout(timer); + if (disposed || !active || reading) return; + reading = true; + try { + const response = await api.fetchApi('/freevideo/monitor', {signal: AbortSignal.timeout(10000)}); + if (response.ok && !disposed && active) { state = await response.json(); render(); } + } catch {} + finally { reading = false; if (!disposed && active) timer = setTimeout(read, document.hidden ? 10000 : 2000); } + } + function position() { + if (panel.hidden) return; + const box = trigger.getBoundingClientRect(); panel.style.top = `${box.bottom + 8}px`; + panel.style.left = `${Math.max(8, Math.min(box.left, window.innerWidth - 288))}px`; + } + function revert() { clearTimeout(confirming); confirming = null; stop.classList.remove('fv-monitor-confirm'); stop.textContent = t('Stop now', '立即停止'); } + function close() { panel.hidden = true; trigger.setAttribute('aria-expanded', 'false'); revert(); } + trigger.onclick = () => { if (panel.hidden) { panel.hidden = false; trigger.setAttribute('aria-expanded', 'true'); position(); } else close(); }; + stop.onclick = async () => { + if (stop.disabled) return; + if (!confirming) { + stop.classList.add('fv-monitor-confirm'); stop.textContent = t('Confirm stop', '确认停止'); + confirming = setTimeout(revert, 4000); return; + } + revert(); stop.disabled = true; + try { await api.fetchApi('/freevideo/monitor/stop', {method: 'POST'}); } catch {} + state = {...state, paused: true, busy: false}; render(); read(); + }; + resume.onclick = async () => { + resume.disabled = true; + try { await api.fetchApi('/freevideo/monitor/resume', {method: 'POST'}); state = {...state, paused: false}; } catch {} + resume.disabled = false; render(); read(); + }; + const outside = event => { if (!element.contains(event.target)) close(); }; + // Escape reaches the panel only through the dialog's cancel: the studio routes its keys itself. + const dismiss = () => { if (panel.hidden) return false; close(); trigger.focus(); return true; }; + document.addEventListener('pointerdown', outside, true); window.addEventListener('resize', position); + function apply() { + const wanted = monitorShown(); + if (wanted === active) return; + active = wanted; element.hidden = !wanted; + if (wanted) read(); else { clearTimeout(timer); close(); state = {}; } + } + const stopWatching = onMonitorShown(apply); apply(); + return {element, dismiss, dispose() { disposed = true; active = false; clearTimeout(timer); revert(); stopWatching(); document.removeEventListener('pointerdown', outside, true); window.removeEventListener('resize', position); }}; +} diff --git a/web/prompt_references.css b/web/prompt_references.css index 24bacdd6..5beffe31 100644 --- a/web/prompt_references.css +++ b/web/prompt_references.css @@ -10,7 +10,7 @@ .fv-reference-option{display:flex;align-items:center;gap:10px;min-height:59px;padding:7px;border-radius:9px;cursor:pointer} .fv-reference-option[aria-selected=true]{background:var(--fv-selected,#2d3949)} .fv-reference-thumb{display:grid;place-items:center;width:40px;height:40px;flex:none;object-fit:cover;border-radius:7px;background:var(--fv-raised,#252e39);font-size:21px;color:var(--fv-muted,#a9b3c4)} -.fv-reference-option>span:not(.fv-reference-thumb){flex:1;min-width:0}.fv-reference-option strong,.fv-reference-option small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fv-reference-option>span:not(.fv-reference-thumb):not(.fv-reference-face){flex:1;min-width:0}.fv-reference-option strong,.fv-reference-option small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-reference-option strong{font-size:13px;font-weight:550}.fv-reference-option small{font-size:11px;color:var(--fv-muted,#a9b3c4);margin-top:3px}.fv-reference-option code{font:11px var(--fv-font,system-ui);color:var(--fv-muted,#a9b3c4);white-space:nowrap} .fv-reference-empty{padding:18px 12px;color:var(--fv-muted,#a9b3c4)}.fv-reference-picker button{margin:0 12px 10px;padding:5px 10px;border:1px solid var(--fv-border,#3a414c);border-radius:8px;background:var(--fv-raised,#252e39);color:inherit;cursor:pointer;font:inherit} @keyframes fv-reference-appear{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:translateY(0)}} @@ -22,6 +22,7 @@ .fv-reference-group{padding:6px 8px 3px;font-size:11px;color:var(--fv-muted,#a9b3c4)} .fv-reference-person{min-height:36px;padding:5px 8px;gap:10px} .fv-reference-face{width:24px;height:24px;flex:none;border-radius:50%;object-fit:cover;object-position:50% 15%;object-view-box:inset(0 20% 40% 20%);background:var(--fv-raised,#252e39)} +.fv-reference-face[data-initial]{display:grid;place-items:center;background:var(--fv-raised,#252e39);color:var(--fv-ink,#eee);font-size:12px;font-weight:550;line-height:1} .fv-reference-face[data-place]{width:40px;height:24px;border-radius:4px;object-position:50% 50%;object-view-box:none} .fv-reference-face[data-prop]{width:24px;height:24px;border-radius:4px;object-position:50% 50%;object-view-box:none} .fv-reference-person strong{font-size:13px;font-weight:550;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} diff --git a/web/prompt_references.js b/web/prompt_references.js index 3940b7c3..cb3e7f44 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -239,8 +239,10 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a const option = document.createElement('div'); option.id = `${list.id}-${index}`; option.className = 'fv-reference-option'; option.setAttribute('role', 'option'); if (item.character) { - const person = item.character, face = document.createElement('img'); - face.className = 'fv-reference-face'; face.alt = ''; if (person.setting) face.dataset.place = ''; else if (person.prop) face.dataset.prop = ''; face.src = person.thumb || ''; + const person = item.character, face = document.createElement(person.thumb ? 'img' : 'span'); + face.className = 'fv-reference-face'; + if (face.tagName === 'SPAN') { face.textContent = [...String(person.name || '')][0] || ''; face.dataset.initial = ''; } + else face.alt = ''; if (person.setting) face.dataset.place = ''; else if (person.prop) face.dataset.prop = ''; if (face.tagName === 'IMG') face.src = person.thumb; const name = document.createElement('strong'); name.textContent = person.name; option.className += ' fv-reference-person'; option.append(face, name); if (person.blocked) { option.setAttribute('aria-disabled', 'true'); if (typeof person.blocked === 'string') option.title = person.blocked; } @@ -255,8 +257,7 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a const label = document.createElement('span'), name = document.createElement('strong'), detail = document.createElement('small'); name.textContent = t({image:'Image',video:'Video',audio:'Audio'}[item.kind], {image:'图片',video:'视频',audio:'音频'}[item.kind]) + ` ${item.number}`; detail.textContent = item.name; label.append(name, detail); - const token = document.createElement('code'); token.textContent = item.token; - option.append(thumb, label, token); option.title = item.name; + option.append(thumb, label); option.title = item.name; option.onpointerdown = e => e.preventDefault(); option.onclick = () => choose(index); option.onpointermove = () => select(index); list.append(option); } diff --git a/web/setup.css b/web/setup.css index 1bcb8070..13a52873 100644 --- a/web/setup.css +++ b/web/setup.css @@ -39,12 +39,26 @@ .fv-setup details[open]>summary{margin-bottom:12px} .fv-setup details details{margin:12px 0 0;padding:12px 14px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-bg)} .fv-setup details details>summary{font-weight:var(--fv-medium);font-size:var(--fv-body)} +.fv-setup details.fv-adv{padding-block:12px 4px} +.fv-setup details.fv-adv>summary{padding-bottom:10px} +.fv-setup details.fv-adv>details{margin:0;padding:0;border:0;border-top:1px solid var(--fv-border);border-radius:0;background:transparent} +.fv-setup details.fv-adv>details>summary{padding:12px 0;font-weight:var(--fv-medium)} +.fv-setup details.fv-adv .fv-unit{display:flex;align-items:center;gap:8px;margin-top:6px} +.fv-setup details.fv-adv .fv-unit>input.fv-number{width:96px;margin:0} +.fv-setup details.fv-adv .fv-unit>span{white-space:nowrap} +.fv-setup details.fv-adv>details[open]>summary{margin-bottom:0} +.fv-setup details.fv-adv>details[open]{padding-bottom:12px} +.fv-setup details.fv-adv summary>span{margin-left:auto;font-weight:var(--fv-regular,400);transition:opacity .4s} +.fv-setup details.fv-adv summary>span.fv-fade{opacity:0} +.fv-setup details.fv-adv>button.fv-row{display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;margin:0;padding:12px 0;min-height:0;border:0;border-top:1px solid var(--fv-border);border-radius:0;background:transparent;color:inherit;font:inherit;font-size:var(--fv-body);font-weight:var(--fv-medium);text-align:left;cursor:pointer} +.fv-setup details.fv-adv>button.fv-row[hidden]{display:none} +.fv-setup details.fv-adv>button.fv-row::after{content:'';flex:none;width:6px;height:6px;margin-right:4px;border-right:1.5px solid var(--fv-muted);border-top:1.5px solid var(--fv-muted);transform:rotate(45deg)} .fv-setup details.fv-token{margin:12px 0 0;padding:12px 0 0;border:0;border-top:1px solid var(--fv-border);border-radius:0;background:transparent} .fv-setup details.fv-token>summary{font-size:13px;font-weight:500;color:var(--fv-muted)} .fv-setup .fv-source-row{display:flex;align-items:center;gap:10px;margin-top:6px} -.fv-setup .fv-source-row>select{flex:1;margin:0;height:36px;min-height:36px;padding-block:0} +.fv-setup .fv-source-row>.fv-select{flex:1;min-width:0}.fv-setup .fv-source-row>.fv-select>select{margin:0;height:36px;min-height:36px;padding-block:0} .fv-setup .fv-source-row>button{flex:none;height:36px;min-height:36px;padding:0 14px;white-space:nowrap} -@media (max-width:480px){.fv-setup .fv-source-row{flex-wrap:wrap}.fv-setup .fv-source-row>select{flex-basis:100%}.fv-setup .fv-source-row>button{margin-left:auto}} +@media (max-width:480px){.fv-setup .fv-source-row{flex-wrap:wrap}.fv-setup .fv-source-row>.fv-select{flex-basis:100%}.fv-setup .fv-source-row>button{margin-left:auto}} /* Fields. */ .fv-setup label{display:block;margin:12px 0 6px;font-size:var(--fv-body);color:var(--fv-muted)} diff --git a/web/setup.js b/web/setup.js index 47a64c8f..2bc54710 100644 --- a/web/setup.js +++ b/web/setup.js @@ -1,4 +1,5 @@ import { api } from "../../scripts/api.js"; +import { skinSelect } from './fv_select.js'; import { wordmark } from './wordmark.js'; import { openCompatibility } from './compatibility.js'; import { animateDetails, closeDialog } from './motion.js'; @@ -6,6 +7,7 @@ import { createGuideReplayEntry } from './first_run_guide.js'; import { issueButton } from './issue_button.js'; import { agentConfirmSettings } from './agent_confirm_settings.js'; import { previewStyle, loadPreviewStyle, applyPreviewStyle, onPreviewStyle } from './preview_style.js'; +import { monitorShown, setMonitorShown, onMonitorShown } from './monitor_panel.js'; const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./setup.css', import.meta.url).href; @@ -115,7 +117,7 @@ export async function openSetup(options) { for (const [id, name] of [['auto', t('Auto (recommended)', '自动(推荐)')], ['proxy', t('Proxy only', '仅代理')], ['direct', t('Direct only', '仅直连')]]) { const option = node('option', name); option.value = id; proxyMode.append(option); } - modeLabel.append(proxyMode); + modeLabel.append(skinSelect(proxyMode)); downloads.append(modeLabel); const sourceLabel = node('label', t('Download source', '下载源')); const source = node('select'); @@ -123,7 +125,7 @@ export async function openSetup(options) { const option = node('option',name); option.value = id; source.append(option); } const probeButton = node('button', t('Test source speeds', '重新测速')); - const sourceRow = node('div'); sourceRow.className = 'fv-source-row'; sourceRow.append(source, probeButton); + const sourceRow = node('div'); sourceRow.className = 'fv-source-row'; sourceRow.append(skinSelect(source), probeButton); sourceLabel.append(sourceRow); downloads.append(sourceLabel); const sourceStatus = note(''), speeds = node('div'); downloads.append(sourceStatus, speeds); @@ -140,8 +142,8 @@ export async function openSetup(options) { tokenLink.href = 'https://huggingface.co/settings/tokens'; tokenLink.target = '_blank'; tokenLink.rel = 'noopener noreferrer'; authActions.append(applyToken, clearToken, tokenLink); auth.append(authActions); const authStatus = note(''); auth.append(authStatus); downloads.append(auth); - const advanced = node('details'); advanced.append(node('summary', t('Advanced settings', '高级设置'))); - const compatibility = node('button', t('Compatibility settings', '兼容性设置')); + const advanced = node('details'); advanced.className = 'fv-adv'; advanced.append(node('summary', t('Advanced settings', '高级设置'))); + const compatibility = node('button', t('Compatibility settings', '兼容性设置')); compatibility.className = 'fv-row'; compatibility.onclick = openCompatibility; advanced.append(compatibility); const resources = node('details'); @@ -152,12 +154,14 @@ export async function openSetup(options) { const autoLabel = node('label'); autoLabel.className = 'fv-check'; autoLabel.append(reserveAuto, node('span', t('Automatic optimization', '自动优化'))); const reserve = node('input'); reserve.type = 'number'; reserve.min = '0.2'; reserve.step = '0.1'; reserve.value = '1.0'; reserve.disabled = true; reserve.className = 'fv-number'; - reserveLabel.append(reserve, document.createTextNode('GiB')); - const saveReserve = node('button', t('Save', '保存')), reserveStatus = note(''); + const reserveUnit = node('span'); reserveUnit.className = 'fv-unit'; reserveUnit.append(reserve, node('span', 'GiB')); reserveLabel.append(reserveUnit); + const saveReserve = node('button', t('Save', '保存')), reserveStatus = node('span'); reserveStatus.className = 'fv-muted fv-saved'; const reserveHelp = note(t('Extra VRAM to keep free. Applies from the next generation.', '额外保留的空闲显存,下次生成起生效。')); - resources.append(autoLabel, reserveLabel, reserveHelp, saveReserve, reserveStatus); + resources.append(autoLabel, reserveLabel, reserveHelp, saveReserve); + reserveSummary.append(reserveStatus); advanced.append(resources); - const clearReserveStatus = () => { reserveStatus.textContent = ''; reserveStatus.className = 'fv-muted'; }; + let savedTimer; + const clearReserveStatus = () => { clearTimeout(savedTimer); reserveStatus.textContent = ''; reserveStatus.className = 'fv-muted fv-saved'; }; reserveStatus.setAttribute('aria-live', 'polite'); reserve.oninput = clearReserveStatus; reserveAuto.onchange = () => { reserve.disabled = reserveAuto.checked; clearReserveStatus(); }; @@ -230,13 +234,22 @@ export async function openSetup(options) { dialog.addEventListener('close', stopWatching); loadPreviewStyle(api).then(paintPreview); preview.append(loopLabel, previewStatus); + const status = node('section'); status.dataset.section = 'status'; + status.append(node('h3', t('System status', '运行状态'))); + const statusLabel = node('label'); statusLabel.className = 'fv-check'; + const statusSwitch = node('input'); statusSwitch.type = 'checkbox'; statusSwitch.checked = monitorShown(); + statusLabel.append(statusSwitch, node('span', t('Show system status in the top bar', '在顶栏显示运行状态'))); + statusSwitch.onchange = () => setMonitorShown(statusSwitch.checked); + const stopStatusWatching = onMonitorShown(() => { statusSwitch.checked = monitorShown(); }); + dialog.addEventListener('close', stopStatusWatching); + status.append(statusLabel); const showSection = name => { if (name !== 'preview') return; preview.scrollIntoView({block: 'center'}); loopSwitch.focus({preventScroll: true}); }; dialog.addEventListener('fv-setup-section', event => showSection(event.detail)); - dialog.append(heading, installation, downloads, preview, agentConfirm.element, advanced, guideReplay); + dialog.append(heading, installation, downloads, preview, status, agentConfirm.element, advanced, guideReplay); const motions = [...dialog.querySelectorAll('details')].map(animateDetails); dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); document.body.append(dialog); dialog.showModal(); @@ -257,10 +270,10 @@ export async function openSetup(options) { const showResources = value => { const unified = value && Object.hasOwn(value, 'ram_reserve_gib'); reserveField = unified ? 'ram_reserve_gib' : 'gpu_reserve_gib'; - reserveSummary.textContent = unified ? t('Reserved unified memory', '预留统一内存') : t('Reserved VRAM', '预留显存'); + reserveSummary.firstChild.textContent = unified ? t('Reserved unified memory', '预留统一内存') : t('Reserved VRAM', '预留显存'); // Mac generation shares unified memory and has no compatibility levels. compatibility.hidden = unified; - reserveLabel.firstChild.textContent = reserveSummary.textContent; + reserveLabel.firstChild.textContent = reserveSummary.firstChild.textContent; reserve.min = unified ? '1' : '0.2'; reserveAuto.checked = value?.[reserveField] == null; reserve.disabled = reserveAuto.checked; @@ -304,6 +317,8 @@ export async function openSetup(options) { try { showResources(await request('resources', {[reserveField]: reserveAuto.checked ? null : Number(reserve.value)})); reserveStatus.textContent = t('Saved · applies to the next request', '已保存 · 下次生成生效'); + // The state sits at the row's end and fades after two seconds. + savedTimer = setTimeout(() => { reserveStatus.classList.add('fv-fade'); savedTimer = setTimeout(clearReserveStatus, 400); }, 2000); } catch (error) { reserveStatus.className = 'fv-failure'; fault(reserveStatus, error.message); } finally { saveReserve.disabled = false; } }; diff --git a/web/share.css b/web/share.css index 9105e639..4e26bbbb 100644 --- a/web/share.css +++ b/web/share.css @@ -21,5 +21,5 @@ .fv-studio .fv-share-footer .fv-primary:active:not(:disabled){transform:translateY(0) scale(.98)} @keyframes fv-share-spin{to{transform:rotate(360deg)}} @keyframes fv-share-enter{from{opacity:0;transform:translateY(8px) scale(.99)}to{opacity:1;transform:translateY(0) scale(1)}} -@media(max-width:600px){.fv-studio.fv-share{width:calc(100vw - 20px);max-height:calc(100dvh - 20px)}.fv-share-header{padding:16px;gap:12px}.fv-share-body{padding:14px}.fv-share-footer{padding:14px}.fv-share-status{max-width:50%}.fv-share-tabs button{min-width:54px;padding:6px 10px}} +@media(max-width:600px){.fv-studio.fv-share{width:calc(100vw - 20px);max-height:calc(100dvh - 20px)}.fv-share-header{padding:16px;gap:12px}.fv-share-body{padding:14px}.fv-share-footer{padding:14px}.fv-share-status{max-width:50%}.fv-share-status:not(:empty){max-width:none;flex:1 0 100%}.fv-share-tabs button{min-width:54px;padding:6px 10px}} @media(prefers-reduced-motion:reduce){.fv-studio.fv-share[open]{animation:none}.fv-share *{transition:none!important}.fv-share[data-busy=true] .fv-share-status:before{animation:none}} diff --git a/web/share.js b/web/share.js index 100a2df4..2235838f 100644 --- a/web/share.js +++ b/web/share.js @@ -81,6 +81,15 @@ export function drawShareCard(canvas,frame,logo,record,t) { return shape; } +// The line while the share video is written: the frame reached and the time used, once the first frame is known. +export function exportingText(progress,seconds,t){ + const frame=progress?.frame,total=progress?.total; + if(!Number.isInteger(frame)||frame<1)return t('Exporting the share video…','正在导出分享视频…'); + const time=`${Math.floor(seconds/60)}:${String(Math.floor(seconds%60)).padStart(2,'0')}`; + return Number.isInteger(total)&&total>=frame + ?t(`Exporting the share video · Frame\u00a0${frame}\u00a0of\u00a0${total} · ${time}\u00a0elapsed`,`正在导出分享视频 · 第\u00a0${frame}\u00a0帧,共\u00a0${total}\u00a0帧 · 已用\u00a0${time}`) + :t(`Exporting the share video · Frame\u00a0${frame} · ${time}\u00a0elapsed`,`正在导出分享视频 · 第\u00a0${frame}\u00a0帧 · 已用\u00a0${time}`); +} export function shareButton(record,t){ const b=button(t('Share','分享'),()=>openShare(record,t),'fv-quiet fv-share-trigger'); b.hidden=!identity(record);return b; @@ -96,7 +105,7 @@ export async function openShare(record,t){ const indicator=el('span','fv-share-indicator');indicator.setAttribute('aria-hidden','true');tabs.append(indicator); const slide=()=>{const b=tabs.querySelector('[aria-selected=true]');if(b)indicator.style.cssText=`transform:translateX(${b.offsetLeft-3}px);width:${b.offsetWidth}px`;}; let kind='image',ready=false,busy=false,disposed=false,videoFailed=false,metadata,shape,template,first,logo,exported; - let exportRequest; + let exportRequest,watch; const abort=new AbortController(); const panel=el('div','fv-share-body'),preview=el('div','fv-share-preview'),canvas=el('canvas'),player=el('video'); canvas.setAttribute('aria-label',t('Sharing image preview','分享图预览')); @@ -156,6 +165,13 @@ export async function openShare(record,t){ setTimeout(()=>URL.revokeObjectURL(url),60000);say('');return; } if(!exported){ + const started=Date.now();let latest=null,reading=false; + watch=setInterval(async()=>{ + if(reading)return;reading=true; + try{const r=await api.fetchApi('/freevideo/share/progress?'+new URLSearchParams({id}),{signal:exportRequest.signal});if(r.ok)latest=await r.json();}catch{} + reading=false; + if(busy&&!disposed)say(exportingText(latest,(Date.now()-started)/1000,t)); + },500); 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 Object.assign(new Error(t('Another export is running. Try again shortly.','已有分享视频正在导出,请稍后再试。')),{reportable:false}); @@ -168,10 +184,10 @@ export async function openShare(record,t){ }catch(error){if(!disposed){ 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);}} + finally{clearInterval(watch);busy=false;if(!disposed){save.disabled=false;cancel.hidden=true;delete dialog.dataset.busy;tabs.querySelectorAll('button').forEach(b=>b.disabled=false);}} } dialog.addEventListener('cancel',event=>{event.preventDefault();closeDialog(dialog);}); - dialog.onclose=()=>{disposed=true;abort.abort();exportRequest?.abort();player.pause();player.removeAttribute('src');player.load();dialog.remove();if(opened===dialog)opened=null;}; + dialog.onclose=()=>{disposed=true;clearInterval(watch);abort.abort();exportRequest?.abort();player.pause();player.removeAttribute('src');player.load();dialog.remove();if(opened===dialog)opened=null;}; // Start focus on the dialog itself, not with a focus ring on the first tab. document.body.append(dialog);dialog.showModal();dialog.focus();slide();message.textContent=t('Preparing preview…','正在准备预览…'); try{ diff --git a/web/studio.css b/web/studio.css index 178bb573..95ca51e2 100644 --- a/web/studio.css +++ b/web/studio.css @@ -157,7 +157,7 @@ .fv-studio .fv-preview-column>.fv-note:not([hidden])+.fv-note{margin-top:calc(0px - 19px)} .fv-studio .fv-panel .fv-empty{display:grid;place-items:center;padding:22px 15px;min-height:92px;margin-top:12px;font-size:var(--fv-micro);white-space:pre-line;border-color:var(--fv-border);background:var(--fv-card)} .fv-studio .fv-panel .fv-preview{max-height:170px;margin:10px auto;object-fit:contain}.fv-studio .fv-panel .fv-card{background:var(--fv-card);border-color:var(--fv-border)} -.fv-studio .fv-panel select{min-height:var(--fv-h-sm);padding:5px 8px;max-width:145px;width:auto}.fv-studio .fv-panel button{font-size:var(--fv-micro);padding:5px 10px;min-height:var(--fv-h-sm)} +.fv-studio .fv-panel select{min-height:var(--fv-h-sm);padding:5px 8px;max-width:145px;width:auto}.fv-studio .fv-panel .fv-toolbar>.fv-select>select{min-width:84px}.fv-studio .fv-panel button{font-size:var(--fv-micro);padding:5px 10px;min-height:var(--fv-h-sm)} .fv-lora-list{display:flex;flex-direction:column;gap:8px;margin:12px 0}.fv-lora-row{display:grid;grid-template-columns:18px minmax(0,1fr) 64px 30px;align-items:center;gap:7px}.fv-lora-row[data-sortable=true]{grid-template-columns:12px 18px minmax(0,1fr) 64px 30px;--lora-lead:3} .fv-lora-row select{min-width:0!important;max-width:100%!important;width:100%!important}.fv-lora-row input[type=number]{width:64px!important;padding:6px!important;font-variant-numeric:tabular-nums}.fv-lora-row input[type=checkbox]{accent-color:var(--fv-accent);width:15px;min-height:15px} .fv-lora-range{grid-column:var(--lora-lead,2)/span 3;grid-row:3;justify-self:end;font-size:12px;line-height:16px;white-space:nowrap;color:var(--fv-muted)}.fv-lora-range[hidden]{display:none} @@ -304,8 +304,13 @@ @media(prefers-reduced-motion:reduce){.fv-studio .fv-recent-card.fv-task-member{animation:none}} /* studio_header_fit.js: the phone entry, then the feedback entry, then the switch's place in the first row make way for the actions. */ .fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=tight] .fv-lan{display:none} -.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=lean] .fv-feedback-entry:not(.fv-guide-entry){display:none} +.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=lean] .fv-lan,.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=lean] .fv-feedback-entry:not(.fv-guide-entry){display:none} .fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=least] .fv-guide-entry{display:none} .fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=wrap]{height:auto;min-height:98px;grid-template-columns:minmax(0,1fr) auto;padding-block:10px} .fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=wrap]>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center} .fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=wrap]>.fv-header-actions{grid-column:2;grid-row:1} +.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=small] .fv-wordmark{width:150px} +.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=compact] .fv-monitor-text{display:none} +.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=shift]{grid-template-columns:minmax(0,1fr) auto auto} +.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=gone] .fv-monitor{display:none} +.fv-studio:not(.fv-editor):not(.fv-autopilot) .fv-header[data-fit~=compact] .fv-monitor .fv-monitor-trigger{padding:0 8px} diff --git a/web/studio.js b/web/studio.js index d6262beb..6f97a2b7 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1,4 +1,5 @@ import {createLanAccess} from './lan_access.js'; +import {createMonitor} from './monitor_panel.js'; import { createErrorPanel, errorText, createErrorReport } from './error_panel.js'; import {createExpandButton} from './editor_expand.js'; import {allowEmptyPrompt, cleanOptionalPrompt, clonePrompt} from './optional_inputs.js'; @@ -16,6 +17,7 @@ import { createUpdateNotice, createVersionInfo, productVersion } from './updates import { createPortNotice } from './port_notice.js'; import { focusReportTip, hideReportTip, reportIssue, reportTipClosed, showReportTip } from './report_issue.js'; import { issueButton } from './issue_button.js'; +import { skinSelect } from './fv_select.js'; import { createPreviewScene } from './preview_scene.js?v=20260929-swell'; import { animateDetails, closeDialog } from './motion.js'; import { openLibrary } from './library.js'; @@ -55,7 +57,7 @@ import { swapState, swapMissing, effectiveRows, prepareSwapLora, releaseSwapLora import { swapLoraDownload } from './swap_lora.js'; import { createStudioChain, chainPosition, chainParts, chainSplit, composePartText, readChainResult, sumPartEstimates, singleRunSnapshot } from './studio_chain.js?v=20261019-words'; import { activeOriginal, checkOriginal, lockMark, blockOriginal, originalBlocked, usesOriginal, referenceRow } from './audio_original.js'; -import { waitingText } from './chain_support.js'; +import { joiningText, waitingText } from './chain_support.js'; import { createExtendState, extendText, extendSourceProblem, extendSourceState, readExtendRecord, extendSnapshot, mountExtendThumbnail, videoUnavailableText } from './studio_extend.js'; import { topDialog, isolateDialogKeys, listenDialogKey, routeDialogKey } from './dialog_keys.js'; import { createPassMode } from './pass_mode.js'; @@ -486,7 +488,7 @@ export function loraPanel(node, panel, {promptNode = null} = {}) { }; select.disabled = strength.disabled = trigger.disabled = row.enabled === false; const remove = button('×', () => { rows.splice(index, 1); write(rows); }); remove.setAttribute('aria-label', t('Remove LoRA', '移除 LoRA')); - wrap.append(enabled, select, strength, remove, trigger, strengthMark); + wrap.append(enabled, skinSelect(select), strength, remove, trigger, strengthMark); wrap.dataset.index = String(index); wrap.dataset.sortable = String(rows.length > 1); if (rows.length > 1) wrap.prepend(sortGrip(wrap, index)); const problem = el('div', null, 'fv-lora-problem'); problem.hidden = true; wrap.append(problem); @@ -667,6 +669,7 @@ export function openStudio(node, {extendFrom = null} = {}) { brand.append(wordmark()); const tools = el('div', null, 'fv-header-actions'); const agents = createAgentActivity(t, api, dialog); tools.append(agents.element); cleanup.push(agents.dispose); + const monitor = createMonitor(t, api); tools.append(monitor.element); cleanup.push(monitor.dispose); const phoneAccess = createLanAccess(t, api); tools.append(phoneAccess.element); cleanup.push(phoneAccess.dispose); const versionInfo = createVersionInfo(cn); tools.append(versionInfo.element); cleanup.push(versionInfo.dispose); tools.append(guideButton(t), feedbackButton(t), button(t('Settings', '设置'), openSetup, 'fv-quiet')); @@ -1075,7 +1078,7 @@ export function openStudio(node, {extendFrom = null} = {}) { for (const name of widget(loraNode, 'lora').options?.values || ['None']) { const o = el('option', name); o.value = name; select.append(o); } select.value = value(loraNode, 'lora'); select.onchange = () => { set(loraNode, 'lora', select.value); requestAssets?.refresh(); }; const {input: strength, mark: strengthMark} = loraStrengthBox(value(loraNode, 'strength'), () => { set(loraNode, 'strength', Number(strength.value)); requestAssets?.refresh(); }); - loraMount.append(field(t('LoRA', 'LoRA'), select), field(t('Strength', '强度'), strength), strengthMark, el('p', loraWarning(), 'fv-lora-note')); + loraMount.append(field(t('LoRA', 'LoRA'), skinSelect(select)), field(t('Strength', '强度'), strength), strengthMark, el('p', loraWarning(), 'fv-lora-note')); } else loraMount.append(el('p', loraWarning(), 'fv-lora-note')); const [, advanced] = expand(t('Advanced', '高级选项')); const custom = el('div', null, 'fv-fields'); @@ -1285,7 +1288,7 @@ export function openStudio(node, {extendFrom = null} = {}) { seed.onchange = () => { if (seed.value && seed.reportValidity()) set(node, 'seed', Number(seed.value)); }; const mode = el('select'); for (const [v, label] of [['randomize', t('New each run', '每次随机')], ['fixed', t('Keep fixed', '保持固定')], ['increment', t('Increment', '递增')], ['decrement', t('Decrement', '递减')]]) { const o = el('option', label); o.value = v; mode.append(o); } mode.value = value(node, 'control_after_generate') || 'fixed'; mode.onchange = () => set(node, 'control_after_generate', mode.value); - seedFields.append(field(t('Seed', '种子'), seed), field(t('After generation', '生成后'), mode)); advanced.append(seedFields); + seedFields.append(field(t('Seed', '种子'), seed), field(t('After generation', '生成后'), skinSelect(mode))); advanced.append(seedFields); const previewHead = el('div', null, 'fv-preview-head'); previewHead.append(el('strong', t('Preview', '预览'))); const previewTools = el('div', null, 'fv-preview-tools'); const worksButton = button(t('Creations', '作品'), () => { @@ -1597,7 +1600,7 @@ export function openStudio(node, {extendFrom = null} = {}) { chainController = createStudioChain({api, node, onProgress: (message, chain) => { const position = chainPosition(chain, message); const joining = readChainResult(chain).joining, waiting = chain.state === 'queued' && message.phase === 'prepare'; - setChainLine(joining ? t('Joining the parts.', '正在接合各段。') + setChainLine(joining ? joiningText(chain.joining_part, t) : waiting ? (chain.waiting ? waitingText(chain.waiting, t) : t(`Waiting in queue · Part ${position.index + 1} of ${position.count}`, `正在排队 · 第 ${position.index + 1} 段,共 ${position.count} 段`)) : t(`Part ${position.index + 1} of ${position.count}`, `第 ${position.index + 1} 段,共 ${position.count} 段`)); progress.element.querySelector('.fv-generation-label').hidden = joining || waiting; @@ -1616,7 +1619,7 @@ export function openStudio(node, {extendFrom = null} = {}) { partList.update(chain, active, Boolean(result?.request_id) && result.request_id === chain?.id); busy = active || Boolean(queueState?.running || queueState?.pending.length); queued = chain?.state === 'queued'; chainLine.hidden = !active; - setChainLine(outcome?.joining ? t('Joining the parts.', '正在接合各段。') + setChainLine(outcome?.joining ? joiningText(chain?.joining_part, t) : t(`Part ${position.index + 1} of ${position.count}`, `第 ${position.index + 1} 段,共 ${position.count} 段`)); if (active && node.properties.freevideo_chain?.width) previewSize(node.properties.freevideo_chain.width, node.properties.freevideo_chain.height); if (!active) { cancelling = false; hideProgress(); } @@ -1770,7 +1773,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // straight to the busy GPU, so its button waits like a full queue does. const chainWait = Boolean(chainController?.active && (!chainController.canQueue || !multiple)); const chainReason = !chainWait ? '' : readChainResult(chain)?.joining - ? t('Joining parts…', '正在拼接…') + ? t('Joining the parts…', '正在接合各段…') : chain?.state === 'queued' ? (chain.waiting ? waitingText(chain.waiting, t) : t('Queued…', '排队中…')) : t(`Generating part ${position.index + 1}…`, `正在生成第 ${position.index + 1} 段…`); generate.disabled = submitting || chainWait || lengthControl.blocked || (runMode === 'loop' && queueState?.looping); @@ -2183,7 +2186,7 @@ export function openStudio(node, {extendFrom = null} = {}) { cleanup.push(isolateDialogKeys(dialog, event => routeDialogKey(event, dialog))); // Escape first closes the recent videos panel of a narrower window, then the studio. - dialog.addEventListener('cancel', e => { e.preventDefault(); if (!recent.dismiss()) closeDialog(dialog); }); + dialog.addEventListener('cancel', e => { e.preventDefault(); if (!monitor.dismiss() && !recent.dismiss()) closeDialog(dialog); }); dialog.onclose = () => { disposed = true; blockOriginal('extend', false); blockOriginal('preview', false); for (const f of cleanup) f(); stage.querySelector('video')?.pause(); dialog.remove(); if (current?.dialog === dialog) { current = null; viewChanged('nodes', node); } }; capabilityView = createCapabilityView({node, api, t, getState: () => ({ratio, custom: selected === 'custom', steps: Number(baseSteps.value), twoPass: twoPass.checked, diff --git a/web/studio_header_fit.js b/web/studio_header_fit.js index b3fdb6f6..fd19129c 100644 --- a/web/studio_header_fit.js +++ b/web/studio_header_fit.js @@ -1,5 +1,5 @@ // The creation panel's header keeps its view switch centred. When the actions beside it would not fit, the phone entry steps -// aside first, then the feedback entry, then the switch moves to a second row; on two rows the same entries still leave +// aside first (after the status capsule gives way: first its words, then the capsule), then the feedback entry, then the switch moves to a second row; on two rows the same entries still leave // in that order, and the Tutorial entry last. export function fitStudioHeader(header, brand, navigation, actions) { if (typeof ResizeObserver !== 'function' || typeof getComputedStyle !== 'function') return {check() {}, dispose() {}}; @@ -13,13 +13,26 @@ export function fitStudioHeader(header, brand, navigation, actions) { const need = shown.reduce((sum, child) => sum + Math.max(child.getBoundingClientRect().width, child.scrollWidth), 0) + apart * Math.max(0, shown.length - 1); const width = selector => { const found = actions.querySelector(selector); return found && found.offsetWidth > 0 ? found.offsetWidth + apart : 0; }; const away = width('.fv-lan'), spare = width('.fv-feedback-entry:not(.fv-guide-entry)'), last = width('.fv-guide-entry'); - const items = [...brand.children].filter(child => child.offsetWidth > 0), mark = items.reduce((sum, child) => sum + child.getBoundingClientRect().width, 0) + px(getComputedStyle(brand).columnGap) * Math.max(0, items.length - 1), side = (inner - navigation.offsetWidth - 2 * gap) / 2; - const one = room => side >= Math.max(room, mark); - const row = room => inner - mark - gap >= room; - const steps = [['', need, one], ['tight', need - away, one], ['tight lean', need - away - spare, one], - ['wrap', need, row], ['wrap tight', need - away, row], ['wrap tight lean', need - away - spare, row], ['wrap tight lean least', need - away - spare - last, row]]; - const found = steps.find(([, room, fits]) => fits(room)); - const fit = found ? found[0] : 'wrap tight lean least'; + const capsule = width('.fv-monitor'), label = actions.querySelector('.fv-monitor-text'), words = label && label.offsetWidth > 0 ? label.offsetWidth + px(getComputedStyle(label.parentElement).columnGap) : 0; + const items = [...brand.children].filter(child => child.offsetWidth > 0), mark = items.reduce((sum, child) => sum + child.getBoundingClientRect().width, 0) + px(getComputedStyle(brand).columnGap) * Math.max(0, items.length - 1); + const logo = brand.querySelector('.fv-wordmark'), smaller = logo ? Math.max(0, logo.offsetWidth - 150) : 0; + // The one-row fit for a given brand width; the wrapped layout only needs the brand and the actions side by side. + const one = (room, brandWidth) => (inner - navigation.offsetWidth - 2 * gap) / 2 >= Math.max(room, brandWidth); + const row = (room, brandWidth) => inner - brandWidth - gap >= room; + // Before anything gives way, the switch may sit a little left of centre when the actions only just overflow their half. + const shifted = (room, brandWidth) => inner - brandWidth - navigation.offsetWidth - 2 * gap >= room; + // What gives way, in order: the switch moves left, the wordmark shrinks, the capsule's words, the capsule, the phone entry, the feedback entry, the guide entry. + // The capsule never comes back once it has gone: the wrapped layout starts without it. + const order = [['compact', words], ['gone', capsule - words], ['tight', away], ['lean', spare], ['least', last]]; + const steps = [['', need, mark, one], ['shift', need, mark, shifted]]; + let room = need, tokens = ['shift']; + if (smaller) { tokens.push('small'); steps.push([tokens.join(' '), room, mark - smaller, shifted]); } + for (const [token, saved] of order.slice(0, 4)) { room -= saved; tokens.push(token); steps.push([tokens.join(' '), room, mark - smaller, shifted]); } + room = need - capsule; tokens = ['wrap', 'gone']; + steps.push([tokens.join(' '), room, mark, row]); + for (const [token, saved] of order.slice(2)) { room -= saved; tokens.push(token); steps.push([tokens.join(' '), room, mark, row]); } + const found = steps.find(([, room, width, fits]) => fits(room, width)); + const fit = found ? found[0] : steps[steps.length - 1][0]; if (fit) header.dataset.fit = fit; } const observer = new ResizeObserver(check); diff --git a/web/theme.css b/web/theme.css index 279fb645..86677139 100644 --- a/web/theme.css +++ b/web/theme.css @@ -52,3 +52,25 @@ @keyframes fv-panel-enter{from{opacity:0;translate:0 5px}to{opacity:1;translate:0 0}} @keyframes fv-backdrop-enter{from{opacity:0}to{opacity:1}} @media(prefers-reduced-motion:reduce){:where(.fv-studio,.fv-setup)[open],:where(.fv-studio,.fv-setup)::backdrop{animation:none!important}} +/* The one dropdown, checkbox and menu face (fv_select.js). */ +.fv-select{position:relative;display:block;min-width:0} +.fv-select>select{appearance:none!important;-webkit-appearance:none!important;width:100%!important;max-width:100%!important;padding-right:34px!important;text-overflow:ellipsis;cursor:pointer;background-image:none} +.fv-select::after{content:'';position:absolute;right:12px;top:50%;width:16px;height:16px;margin-top:-8px;background:currentColor;color:var(--fv-ink);-webkit-mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 6.5 3.5 3 3.5-3'/%3E%3C/svg%3E") center/16px no-repeat;mask:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 6.5 3.5 3 3.5-3'/%3E%3C/svg%3E") center/16px no-repeat;pointer-events:none} +.fv-select:has(>select:disabled)::after{opacity:.5} +.fv-select:has(>select[hidden]){display:none} +.fv-select-menu-host>.fv-select-native{position:absolute;inset:0;opacity:0;pointer-events:none} +.fv-select-menu-host>.fv-select-trigger{display:flex;align-items:center;width:100%;max-width:100%;min-height:var(--fv-h-sm,32px);padding:4px 34px 4px 10px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised);color:var(--fv-ink);font:inherit;text-align:left;cursor:pointer} +.fv-select-menu-host>.fv-select-trigger:hover:not(:disabled){background:var(--fv-hover)}.fv-select-menu-host>.fv-select-trigger:disabled{opacity:.5;cursor:default} +.fv-select-face{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fv-select-menu{position:fixed;z-index:60;box-sizing:border-box;max-width:calc(100vw - 16px);overflow:auto;padding:6px;background:var(--fv-card);color:var(--fv-ink);border:1px solid var(--fv-border);border-radius:8px;font:13px var(--fv-font)} +.fv-select-menu[hidden]{display:none} +.fv-select-menu [role=separator]{height:1px;margin:5px 4px;background:var(--fv-border)} +.fv-select-group{padding:6px 10px 2px;color:var(--fv-muted);font-size:12px} +.fv-select-menu .fv-select-item{display:flex;align-items:center;justify-content:space-between;gap:24px;width:100%;height:32px;min-height:32px;padding:0 10px;border:0;border-radius:4px;background:transparent;color:var(--fv-ink);font:inherit;text-align:left;white-space:nowrap;cursor:pointer} +.fv-select-menu .fv-select-item:hover,.fv-select-menu .fv-select-item:focus-visible{background:color-mix(in srgb,var(--fv-accent) 12%,transparent);outline:1px solid var(--fv-accent);outline-offset:-1px} +.fv-select-label{overflow:hidden;text-overflow:ellipsis} +.fv-select-mark{flex:none;width:14px;height:14px;color:var(--fv-accent);visibility:hidden}.fv-select-mark svg{display:block;width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round} +.fv-select-item[aria-selected=true] .fv-select-mark{visibility:visible} +:where(.fv-studio,.fv-setup,.fv-panel,.fv-image-editor) :where(input[type=checkbox]:not([role=switch])){appearance:none;-webkit-appearance:none;flex:none;box-sizing:border-box;width:16px;height:16px;min-height:16px;margin:0;padding:0;border:1px solid var(--fv-border);border-radius:4px;background:var(--fv-card) center/12px no-repeat;cursor:pointer;vertical-align:middle;transition:background-color .15s,border-color .15s} +:where(.fv-studio,.fv-setup,.fv-panel,.fv-image-editor) :where(input[type=checkbox]:not([role=switch]):checked){border-color:var(--fv-accent);background-color:var(--fv-accent);background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E")} +:where(.fv-studio,.fv-setup,.fv-panel,.fv-image-editor) :where(input[type=checkbox]:not([role=switch]):disabled){opacity:.45;cursor:default} diff --git a/web/tutorial/edit-image-1.webp b/web/tutorial/edit-image-1.webp new file mode 100644 index 00000000..35a8f438 Binary files /dev/null and b/web/tutorial/edit-image-1.webp differ diff --git a/web/tutorial/edit-image-2.webp b/web/tutorial/edit-image-2.webp new file mode 100644 index 00000000..f3e09298 Binary files /dev/null and b/web/tutorial/edit-image-2.webp differ diff --git a/web/tutorial/local-image-edit-1.webp b/web/tutorial/local-image-edit-1.webp new file mode 100644 index 00000000..dee59486 Binary files /dev/null and b/web/tutorial/local-image-edit-1.webp differ diff --git a/web/tutorial/local-image-edit-2.webp b/web/tutorial/local-image-edit-2.webp new file mode 100644 index 00000000..de87c9fc Binary files /dev/null and b/web/tutorial/local-image-edit-2.webp differ diff --git a/web/tutorial/scenes-props-1.webp b/web/tutorial/scenes-props-1.webp new file mode 100644 index 00000000..3098fcc5 Binary files /dev/null and b/web/tutorial/scenes-props-1.webp differ diff --git a/web/tutorial/scenes-props-2.webp b/web/tutorial/scenes-props-2.webp new file mode 100644 index 00000000..b799292b Binary files /dev/null and b/web/tutorial/scenes-props-2.webp differ diff --git a/web/tutorial_book.css b/web/tutorial_book.css index f6c9c694..06367007 100644 --- a/web/tutorial_book.css +++ b/web/tutorial_book.css @@ -6,7 +6,7 @@ --book-xs:6px; --book-sm:12px; --book-md:20px; --book-lg:28px; --book-xl:36px; --book-radius:var(--fv-r-lg); --book-control-radius:var(--fv-r-sm); --book-media-radius:var(--fv-r-md); --book-control:var(--fv-h); --book-border:1px; --book-outline:2px; - --book-media-height:360px; --book-nav:240px; --book-width:1160px; --book-height:820px; --book-edge:24px; + --book-media-height:360px; --book-nav:272px; --book-width:1160px; --book-height:820px; --book-edge:24px; --book-body:15px; --book-small:13px; --book-title:24px; --book-section:16px; --book-nav-size:14px; --book-leading:1.7; --book-font:var(--fv-font); box-sizing:border-box; width:min(var(--book-width),calc(100vw - var(--book-edge) * 2)); @@ -24,11 +24,11 @@ .fv-tutorial-book :focus-visible { outline:var(--book-outline) solid var(--book-focus); outline-offset:var(--book-outline); } .fv-book-header { display:flex; align-items:center; justify-content:space-between; gap:var(--book-sm); padding:var(--book-sm) var(--book-md); border-bottom:var(--book-border) solid var(--book-line); } .fv-book-header h1 { margin:0; font-size:var(--book-section); font-weight:600; } -.fv-book-close { min-height:var(--book-control); padding:var(--book-xs) var(--book-sm); border:var(--book-border) solid var(--book-line); border-radius:var(--book-control-radius); background:transparent; } -.fv-book-close:hover { background:var(--book-hover); } +.fv-book-close { min-height:34px; width:34px; padding:0; border:0; border-radius:var(--book-control-radius); background:transparent; color:var(--book-muted); font-size:20px; line-height:1; } +.fv-book-close:hover { background:var(--book-hover); color:var(--book-ink); } .fv-book-layout { display:grid; grid-template-columns:var(--book-nav) minmax(0,1fr); min-height:0; flex:1; } .fv-book-nav { display:flex; flex-direction:column; gap:var(--book-xs); padding:var(--book-sm); overflow-y:auto; background:var(--book-panel); border-right:var(--book-border) solid var(--book-line); } -.fv-book-chapter { flex-shrink:0; width:100%; min-height:var(--book-control); text-align:start; border:0; border-radius:var(--book-control-radius); padding:var(--book-sm); background:transparent; font-size:var(--book-nav-size); overflow-wrap:anywhere; } +.fv-book-chapter { flex-shrink:0; width:100%; min-height:var(--book-control); text-align:start; border:0; border-radius:var(--book-control-radius); padding:var(--book-sm); background:transparent; font-size:var(--book-nav-size); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } .fv-book-chapter:hover { background:var(--book-hover); } .fv-book-chapter[aria-current] { background:var(--book-selected); } .fv-book-chapter[aria-current] { font-weight:600; } @@ -37,7 +37,7 @@ .fv-book-note { margin:var(--book-sm) 0 0; } .fv-book-note[hidden] { display:none; } .fv-book-summary { margin:0 0 var(--book-md); } -.fv-book-media { display:block; width:auto; max-width:100%; height:auto; max-height:var(--book-media-height); object-fit:contain; margin-inline:auto; border:var(--book-border) solid var(--book-line); border-radius:var(--book-media-radius); background:var(--book-panel); } +.fv-book-media { display:block; width:100%; max-width:min(100%,720px); height:auto; object-fit:contain; margin-inline:auto; border:var(--book-border) solid var(--book-line); border-radius:var(--book-media-radius); background:var(--book-panel); } .fv-book-steps { padding-inline-start:var(--book-lg); margin:var(--book-lg) 0 var(--book-md); } .fv-book-steps li { padding-inline-start:var(--book-xs); margin-bottom:var(--book-sm); } .fv-book-steps li::marker { font-weight:600; } @@ -59,3 +59,9 @@ .fv-book-picker select { flex:1; min-width:0; min-height:var(--book-control); padding:var(--book-xs) 28px var(--book-xs) var(--book-xs); text-overflow:ellipsis; border:var(--book-border) solid var(--book-line); border-radius:var(--book-control-radius); background:var(--book-bg); } .fv-book-article { flex:1; } } +.fv-book-zoom { max-width:calc(100vw - 48px); max-height:calc(100dvh - 48px); padding:0; border:0; background:transparent; cursor:zoom-out; } +.fv-book-zoom::backdrop { background:var(--book-backdrop); } +.fv-book-zoom img { display:block; max-width:100%; max-height:calc(100dvh - 48px); margin:auto; border-radius:var(--book-media-radius); } +.fv-book-zoom { position:relative; } +.fv-book-zoom-close { position:absolute; top:8px; right:8px; min-height:34px; width:34px; padding:0; border:0; border-radius:var(--book-control-radius); background:rgba(0,0,0,.6); color:#fff; font-size:20px; line-height:1; cursor:pointer; } +.fv-book-zoom-close:hover { background:rgba(0,0,0,.8); } diff --git a/web/tutorial_book.js b/web/tutorial_book.js index 93e50bf1..1e5fa19c 100644 --- a/web/tutorial_book.js +++ b/web/tutorial_book.js @@ -1,4 +1,5 @@ import {TUTORIAL_CHAPTERS} from './tutorial_book_content.js'; +import {skinSelect} from './fv_select.js'; const COPY = { zh: {book: '教程', contents: '目录', close: '关闭', limits: '限制', start: '开始演示', tryIt: '填入示例', goto: '带我去', noView: '无法打开剪辑视图,请点顶部的“剪辑视图”。', noTarget: '当前界面中找不到这一项,请按上面的步骤操作。', noHidden: '时间线上还没有带声音的片段,请先导入或生成一段。'}, @@ -33,6 +34,16 @@ function mediaElement(media, lang, title) { } else { node.alt = media.alt?.[lang] || title; node.decoding = 'async'; + node.tabIndex = 0; node.style.cursor = 'zoom-in'; + const enlarge = () => { + const zoom = el('dialog', 'fv-book-zoom'), big = el('img'); big.src = node.src; big.alt = node.alt; + const shut = el('button', 'fv-close fv-book-zoom-close', '×'); shut.type = 'button'; shut.setAttribute('aria-label', COPY[language()].close); + zoom.append(big, shut); zoom.onclick = () => zoom.close(); + zoom.addEventListener('close', () => { zoom.remove(); node.focus(); }); + node.closest('dialog').append(zoom); zoom.showModal(); + }; + node.onclick = enlarge; + node.onkeydown = event => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); enlarge(); } }; } return node; } @@ -61,12 +72,12 @@ export function openTutorialBook({chapter = TUTORIAL_CHAPTERS[0].id} = {}) { dialog.setAttribute('aria-labelledby', 'fv-book-title'); const header = el('header', 'fv-book-header'), title = el('h1', null, text.book); title.id = 'fv-book-title'; - const close = el('button', 'fv-book-close', text.close); close.type = 'button'; + const close = el('button', 'fv-close fv-book-close', '×'); close.type = 'button'; close.setAttribute('aria-label', text.close); header.append(title, close); const layout = el('div', 'fv-book-layout'), nav = el('nav', 'fv-book-nav'); nav.setAttribute('aria-label', text.contents); const mobile = el('label', 'fv-book-picker', text.contents), picker = el('select'); - picker.setAttribute('aria-label', text.contents); mobile.append(picker); + picker.setAttribute('aria-label', text.contents); mobile.append(skinSelect(picker)); const article = el('article', 'fv-book-article'); article.tabIndex = 0; const navButtons = new Map(); let start = null, tryButton = null; @@ -138,7 +149,7 @@ export function openTutorialBook({chapter = TUTORIAL_CHAPTERS[0].id} = {}) { article.scrollTop = 0; } for (const item of TUTORIAL_CHAPTERS) { - const button = el('button', 'fv-book-chapter', item.title[lang]); button.type = 'button'; + const button = el('button', 'fv-book-chapter', item.title[lang]); button.type = 'button'; button.title = item.title[lang]; button.dataset.chapter = item.id; button.onclick = () => select(item.id); navButtons.set(item.id, button); nav.append(button); diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 7e4378dc..62a751ae 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -142,6 +142,24 @@ export const TUTORIAL_CHAPTERS = [ en: ['A request uses up to 3 characters.', 'Characters cannot be used with first or last frames.', 'A character may look slightly different between runs.'], }, }, + { + id: 'scenes-props', + title: {zh: '场景和道具', en: 'Scenes and props'}, + summary: {zh: '把常用的地点和物品存进素材库,在描述中用 @ 引用。', en: 'Save a place or an object once, then mention it with @ in later prompts.'}, + media: { + type: 'image', + src: {zh: 'scenes-props-1.webp', en: 'scenes-props-2.webp'}, + alt: {zh: '道具窗口:名称和图片已用红框标出。', en: 'The prop window, with the name and the image outlined in red.'}, + }, + steps: { + zh: ['展开创作面板的“素材”,在“场景”或“道具”一行点“+”新建。', '填写“名称”,添加图片:可“上传图片”,也可“AI 生成”。场景最多 3 张图片,道具 1 张。', '点“保存”。', '之后在描述中输入 @ 选择它,并写明它在画面中的位置或用法。'], + en: ['Expand "Media" in the creation panel and choose + on the "Scenes" or "Props" row.', 'Enter a "Name" and add pictures: choose "Upload images" or "Generate with AI". A scene takes up to 3 pictures, a prop 1.', 'Choose "Save".', 'Later, type @ in the description to pick it, and say where it is in the shot or how it is used.'], + }, + limits: { + zh: ['一次请求最多使用 1 个场景和 2 个道具,不占角色的 3 个名额。', '场景和道具不能与首帧、尾帧同时使用。', '物品的细节可能每次略有不同。'], + en: ['A request uses up to 1 scene and 2 props, which do not count toward the 3 characters.', 'Scenes and props cannot be used with first or last frames.', 'Fine details of an object may differ slightly between runs.'], + }, + }, { id: 'replace-person', title: {zh: '替换人物', en: 'Replace person'}, @@ -179,6 +197,42 @@ export const TUTORIAL_CHAPTERS = [ en: ['Up to 2 people can be replaced in one request.', 'Replace person cannot be used with first or last frames.'], }, }, + { + id: 'edit-image', + title: {zh: '编辑图片', en: 'Edit a picture'}, + summary: {zh: '在生成前裁剪、修图或加文字,原图始终保留。', en: 'Crop, retouch or add text before generating. The original is always kept.'}, + media: { + type: 'image', + src: {zh: 'edit-image-1.webp', en: 'edit-image-2.webp'}, + alt: {zh: '图片编辑窗口:工具栏中的裁剪、文字和“保存”已用红框标出。', en: 'The picture editor, with the crop and text tools and Save outlined in red.'}, + }, + steps: { + zh: ['展开创作面板的“素材”,上传图片后点缩略图,或点图片下方的“编辑”。', '在左侧工具栏选择工具:“裁剪与画布”调整构图,“画笔”“污点修复”“仿制图章”修图,“文字”添加文字。', '改错了可点“撤销”,或按 Ctrl+Z。', '点“保存”。修改后的图片用于生成,原图仍保留,点“恢复原图”可改回。'], + en: ['Expand "Media" in the creation panel, upload a picture, then choose its thumbnail or the "Edit" button below it.', 'Choose a tool on the left: "Crop and canvas" to recompose, "Brush", "Spot healing" or "Clone stamp" to retouch, "Text" to add words.', 'To undo a change, choose "Undo" or press Ctrl+Z.', 'Choose "Save". The edited picture is used for generation and the original is kept; choose "Original" to switch back.'], + }, + limits: { + zh: ['视频的裁剪另在“调整裁剪”中设置。', '“AI 生成”和“点选”需要图片生成功能可用时才显示。'], + en: ['Crop for a video is set separately, with "Adjust crop".', '"Generate with AI" and "Click select" appear only when picture generation is available.'], + }, + }, + { + id: 'local-image-edit', + title: {zh: '局部改图', en: 'Change part of a picture'}, + summary: {zh: '在编辑图片中框出一块区域,只让 AI 修改这一块,其余部分保持不变。', en: 'Select an area in the image editor and let the AI change only that area, leaving the rest as it is.'}, + media: { + type: 'image', + src: {zh: 'local-image-edit-1.webp', en: 'local-image-edit-2.webp'}, + alt: {zh: '编辑图片:图片上框出了一块选区,“选框”工具、描述栏和“生成”按钮已用红框标出。', en: 'The image editor, with an area selected on the picture and the Marquee tool, the description field and the "Generate" button outlined in red.'}, + }, + steps: { + zh: ['展开创作面板的“素材”,点图片(或右上角的“编辑”),打开“编辑图片”。', '点左上角的“AI 生成”,选择“改这张”。', '用“选框”“套索”或“魔棒”在图片上选出要修改的区域;按住 Shift 拖动可以添加区域,按住 Alt 拖动可以减去一块。', '在描述栏中写下选区要改成什么样。', '点“生⁠成”。结果出现后选区仍然保留,可以直接再改;满意后点“保存”。'], + en: ['Expand "Media" in the creation panel, click the picture (or "Edit" at its top right) to open "Edit image".', 'Click "Generate with AI" at the top left and choose "Edit this".', 'Select the area to change on the picture with "Marquee", "Lasso" or "Magic wand". Hold Shift while dragging to add an area, or Alt to take one away.', 'In the description field, describe how the selection should change.', 'Click "Generate". The selection stays after the result appears, so it can be changed again; click "Save" when satisfied.'], + }, + limits: { + zh: ['没有选区时,“改这张”会按描述修改整张图片。', '“点选”需要先下载一次选区模型,面板会显示进度。', '图片生成需要所用的图片模型已经下载完成。'], + en: ['Without a selection, "Edit this" changes the whole picture according to the description.', '"Click select" needs a selection model downloaded once; the panel shows the progress.', 'Generating pictures needs the picture model to be downloaded first.'], + }, + }, { id: 'lora', goto: ['lora-add'], diff --git a/web/updates.js b/web/updates.js index 05873dc0..3d8f0d80 100644 --- a/web/updates.js +++ b/web/updates.js @@ -264,8 +264,8 @@ export function createVersionInfo(cn) { let closeNotes = () => {}; element.onclick = () => { closeNotes(); closeNotes = showReleaseNotes(cn); }; const render = () => { - const current = value?.current_release || (value?.current_version ? {version: value.current_version} : null); - element.textContent = current ? displayVersion(current) : cn ? '版本' : 'Version'; + element.textContent = cn ? '版本' : 'Version'; + if (value?.available) { const dot = document.createElement('i'); dot.className = 'fv-version-dot'; dot.setAttribute('aria-hidden', 'true'); element.append(dot); } }; listeners.add(render); render(); return {element, dispose: () => { listeners.delete(render); closeNotes(); }}; diff --git a/web/whatsnew.css b/web/whatsnew.css index 9ab812bb..7ec7028c 100644 --- a/web/whatsnew.css +++ b/web/whatsnew.css @@ -2,36 +2,82 @@ .fv-whatsnew-stage .fv-ed-ruler .fv-ed-tick:last-child span{left:auto;right:4px} .fv-whatsnew{box-sizing:border-box;width:min(720px,calc(100vw - 32px));max-height:calc(100dvh - 48px);padding:24px;border:1px solid var(--fv-border);border-radius:var(--fv-r-lg);background:var(--fv-canvas);color:var(--fv-ink);font:14px/1.6 var(--fv-font);overflow:auto} .fv-whatsnew::backdrop{background:#0009} -.fv-whatsnew[open]{animation:fv-dialog-enter .28s cubic-bezier(.2,.7,.2,1)} +.fv-whatsnew[open]{display:flex;flex-direction:column;animation:fv-dialog-enter .28s cubic-bezier(.2,.7,.2,1)} +.fv-whatsnew>*{flex:none} .fv-whatsnew h2{margin:0 0 16px;font-size:20px;font-weight:600} -.fv-whatsnew-host{border:1px solid var(--fv-border);border-radius:var(--fv-r-md);overflow:hidden;background:var(--fv-bg)} -.fv-whatsnew-stage .fv-ed-track-view{height:236px} +.fv-whatsnew-host{border:1px solid var(--fv-border);border-radius:12px;overflow:hidden;background:var(--fv-bg);} +.fv-whatsnew-stage{display:flex;flex-direction:column;position:relative} +.fv-whatsnew-stage .fv-ed-track-view{flex:1 1 0;height:auto;min-height:0} +.fv-whatsnew-measure,.fv-whatsnew-measure *{transition:none!important} .fv-whatsnew-stage .fv-ed-track-scroll,.fv-whatsnew-stage .fv-ed-track-heads{bottom:0} .fv-whatsnew-stage .fv-ed-clip{cursor:default;position:absolute} .fv-whatsnew-picture{background:linear-gradient(90deg,color-mix(in srgb,var(--fv-accent) 28%,var(--fv-card)),color-mix(in srgb,var(--fv-accent-ramp) 22%,var(--fv-card)) 50%,color-mix(in srgb,var(--fv-accent) 18%,var(--fv-card)))} /* Rows and clips change by state only; each change fades. */ .fv-whatsnew-stage .fv-ed-track-row,.fv-whatsnew-stage .fv-ed-track-head,.fv-whatsnew-stage .fv-ed-clip{transition:opacity .5s var(--fv-ease),translate .5s var(--fv-ease)} .fv-whatsnew-stage .fv-ed-progress-fill{transition:width .7s linear} -.fv-whatsnew-stage[data-split="0"] :is([data-track=a2],[data-track=a3]){opacity:0;translate:0 -6px} -.fv-whatsnew-stage[data-split="0"] [data-name=voice-old],.fv-whatsnew-stage[data-split="0"] [data-name=voice-new]{opacity:0} -.fv-whatsnew-stage[data-split="1"] [data-name=mixed]{opacity:0} -.fv-whatsnew-stage [data-name=voice-new],.fv-whatsnew-stage [data-name=music-new]{opacity:0} -.fv-whatsnew-stage[data-voice=new] [data-name=voice-old],.fv-whatsnew-stage[data-music=new] [data-name=music-old]{opacity:0} -.fv-whatsnew-stage[data-split="1"][data-voice=new] [data-name=voice-new],.fv-whatsnew-stage[data-music=new] [data-name=music-new]{opacity:1} -.fv-whatsnew-stage[data-music=running] [data-name=music-old],.fv-whatsnew-stage[data-voice=running] [data-name=voice-old]{outline:2px solid var(--fv-accent);outline-offset:-2px} +.fv-whatsnew-stage .fv-ed-clip[data-on=false]{opacity:0;pointer-events:none} +.fv-whatsnew-stage .fv-ed-clip{transition:opacity .5s var(--fv-ease),left .5s var(--fv-ease),width .5s var(--fv-ease)} +.fv-whatsnew-stage .fv-ed-clip[data-selected],.fv-whatsnew-stage .fv-ed-clip[data-state=running]{outline:2px solid var(--fv-accent);outline-offset:-2px} +.fv-whatsnew-stage .fv-ed-clip[data-state=running]{--fv-ed-progress:0} +.fv-whatsnew-stage .fv-ed-clip[data-state=running] .fv-ed-progress-fill{width:calc(var(--fv-ed-progress) * 100%)} +.fv-whatsnew-oldlabel{position:absolute;left:8px;top:4px;right:8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:12px;line-height:1.3;color:var(--fv-ink);text-shadow:0 0 3px var(--fv-card)} +.fv-whatsnew-label s{text-decoration-thickness:2px} .fv-whatsnew{position:relative} .fv-whatsnew:focus{outline:none} -.fv-whatsnew-close{position:absolute;top:12px;right:12px;width:var(--fv-h-sm);height:var(--fv-h-sm);padding:0;border:0;border-radius:var(--fv-r-sm);background:transparent;color:var(--fv-muted,var(--fv-ink));font:22px/1 var(--fv-font);cursor:pointer} -.fv-whatsnew-close:hover{background:var(--fv-hover);color:var(--fv-ink)} -.fv-whatsnew h2{padding-right:36px} -.fv-whatsnew-stage .fv-ed-track-view .fv-ed-track-head{justify-content:flex-start;padding-left:12px;box-sizing:border-box;white-space:nowrap} -.fv-whatsnew-foot{display:flex;align-items:center;gap:8px;margin-top:16px} -.fv-whatsnew-dots{display:flex;gap:6px;margin-right:auto} -.fv-whatsnew-dot{width:8px;height:8px;padding:0;border:0;border-radius:50%;background:var(--fv-border);cursor:pointer} -.fv-whatsnew-dot[aria-current=true]{background:var(--fv-accent)} -.fv-whatsnew-foot button:not(.fv-whatsnew-dot){min-height:var(--fv-h-sm);padding:4px 16px;border:0;border-radius:var(--fv-r-sm);font:inherit;cursor:pointer;background:var(--fv-hover);color:var(--fv-ink)} -.fv-whatsnew-next{background:var(--fv-accent)!important;color:var(--fv-bg)!important;font-weight:600} -.fv-whatsnew-foot[hidden],.fv-whatsnew-take[hidden]{display:none} +.fv-whatsnew-stage .fv-ed-track-view .fv-ed-track-head{justify-content:flex-start;padding-left:16px;box-sizing:border-box;white-space:nowrap} +.fv-whatsnew-foot::before{content:'';position:absolute;left:0;right:0;bottom:100%;height:24px;background:linear-gradient(transparent,var(--fv-canvas));pointer-events:none} +@media(max-width:520px){.fv-whatsnew-stage .fv-ed-ruler .fv-ed-tick:nth-last-child(3) span{display:none}} .fv-whatsnew :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} + +.fv-whatsnew-stage .fv-ed-track-row[data-on=false],.fv-whatsnew-stage .fv-ed-track-head[data-on=false]{opacity:0} +.fv-whatsnew-stage .fv-ed-clip[data-hl]{outline:2px solid color-mix(in srgb,var(--fv-accent) 60%,transparent);outline-offset:-2px} +.fv-whatsnew-stage .fv-ed-clip[data-flash]{outline:2px solid var(--fv-accent);outline-offset:-2px} +.fv-whatsnew-stage .fv-ed-track-content{position:relative} +.fv-whatsnew-playhead{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:var(--fv-accent);pointer-events:none} +.fv-whatsnew-playhead[data-on=false],.fv-whatsnew-range[data-on=false]{display:none} +.fv-whatsnew-range{position:absolute;box-sizing:border-box;border:2px solid var(--fv-accent);border-radius:var(--fv-r-sm);background:color-mix(in srgb,var(--fv-accent) 18%,transparent);pointer-events:none;overflow:hidden} +.fv-whatsnew-range span{position:absolute;right:6px;top:2px;font-size:12px;white-space:nowrap} +/* The editor's own generation bar, set into the dialog: the studio and editor classes bring its styles, not its window. */ +.fv-whatsnew-new{font-weight:400;color:var(--fv-accent)} +.fv-whatsnew-list li{transition:color .3s} +.fv-whatsnew-list li[data-lit]{color:var(--fv-accent);font-weight:600} @media(prefers-reduced-motion:reduce){.fv-whatsnew[open]{animation:none}.fv-whatsnew-stage *{transition:none!important}} -@media(max-width:520px){.fv-whatsnew{padding:16px}.fv-whatsnew-stage .fv-ed-track-view{--fv-ed-head-width:88px}.fv-whatsnew-stage .fv-ed-track-head>span:first-of-type{display:inline}.fv-whatsnew-stage .fv-ed-track-view .fv-ed-track-head{padding-left:8px;gap:4px;font-size:12px}.fv-whatsnew-stage .fv-ed-track-head svg{width:14px;height:14px}.fv-whatsnew-close{top:8px;right:8px}} +@media(max-width:520px){.fv-whatsnew{padding:16px}.fv-whatsnew-stage .fv-ed-track-view{--fv-ed-head-width:88px}.fv-whatsnew-stage .fv-ed-track-head>span:first-of-type{display:inline}.fv-whatsnew-stage .fv-ed-track-view .fv-ed-track-head{padding-left:12px;gap:4px;font-size:12px}.fv-whatsnew-stage .fv-ed-track-head svg{width:14px;height:14px}.fv-whatsnew-close{top:8px;right:8px}} + +/* The seven features sit under the demo, outside its frame. */ +.fv-whatsnew-list{display:grid;grid-template-columns:1fr 1fr;gap:8px 24px;margin:16px 0 16px;padding:0 0 0 17px;list-style:decimal inside;font-size:13px;line-height:20px} +.fv-whatsnew-foot{margin-top:auto} +@media(max-width:520px){.fv-whatsnew-list{grid-template-columns:1fr;padding-left:13px}} +.fv-whatsnew-close{height:36px;min-height:36px;padding:0 16px;border:1px solid var(--fv-border);border-radius:8px;background:var(--fv-raised);color:var(--fv-ink);font:inherit;cursor:pointer} +.fv-whatsnew-foot{display:flex;justify-content:flex-end;position:sticky;bottom:0;margin-top:auto;padding-top:8px;background:var(--fv-canvas)} + +/* The editor's own bars and menu set into the dialog: the studio and editor classes bring their styles, not the window. */ +.fv-whatsnew-ed.fv-studio.fv-editor{position:static;display:block;width:auto;height:auto;margin:0;border:0;border-radius:0;box-shadow:none;overflow:visible;background:transparent;color:var(--fv-ink);color-scheme:inherit;font:13px/1.5 var(--fv-font);animation:none;pointer-events:none} +.fv-whatsnew-top.fv-whatsnew-ed.fv-studio.fv-editor{overflow:hidden;background:var(--fv-card);transition:height .24s var(--fv-ease)} +.fv-whatsnew-top .fv-ed-genbar,.fv-whatsnew-top .fv-ed-record{grid-column:auto;grid-row:auto;box-sizing:border-box;min-height:56px} +.fv-whatsnew-top .fv-ed-genbar[hidden],.fv-whatsnew-top .fv-ed-record[hidden]{display:none} +.fv-whatsnew-top .fv-ed-record-end{justify-content:flex-end;align-items:center} +.fv-whatsnew-top .fv-ed-record-side{align-items:center;color:var(--fv-muted)} +.fv-whatsnew-top .fv-ed-description-box{flex:1} +.fv-whatsnew-top .fv-ed-voice-text{flex:1;min-width:0;box-sizing:border-box;min-height:36px;padding:8px;border:1px solid var(--fv-border);border-radius:6px;color:var(--fv-ink);font-size:13px;line-height:18px} +.fv-whatsnew-top .fv-ed-voice-text s{text-decoration-thickness:2px;color:var(--fv-muted)} +.fv-whatsnew-top .fv-whatsnew-new{font-weight:400;color:var(--fv-accent)} +.fv-whatsnew-top .fv-ed-genbar[data-voice=true] .fv-ed-generate{margin-left:auto} +.fv-whatsnew-menu.fv-whatsnew-ed.fv-studio.fv-editor{position:absolute;z-index:3;width:max-content;background:transparent} +.fv-whatsnew-menu.fv-whatsnew-ed.fv-studio.fv-editor[data-on=false]{display:none} +.fv-whatsnew-menu .fv-ed-context-menu{position:static;min-width:180px;box-shadow:0 4px 16px #0006} +.fv-whatsnew-menu .fv-ed-context-menu button[data-hover=true]{background:color-mix(in srgb,var(--fv-accent) 12%,transparent);outline:1px solid var(--fv-accent);outline-offset:-1px} +.fv-whatsnew-top .fv-ed-voice-lines{max-height:none;overflow:visible} +@media(max-width:520px){.fv-whatsnew-top .fv-ed-genbar[data-mode=running] .fv-ed-description-box{display:none}} +@media(max-width:520px){.fv-whatsnew h2{font-size:18px;margin-bottom:12px}.fv-whatsnew-list{gap:2px;margin:12px 0}.fv-whatsnew-top .fv-ed-voice-lines{gap:4px}} + +.fv-whatsnew-stage[data-lang=en] .fv-ed-voice-text s{margin-right:4px} +@media(max-width:520px){.fv-whatsnew-top .fv-ed-genbar[data-mode=selected] .fv-ed-description-box{flex:1 1 100%}} + +/* Style and mood share one row on a wide window. */ +@media(min-width:521px){.fv-whatsnew-top .fv-ed-music-presets:not([hidden]){flex-direction:row;flex-wrap:wrap;gap:6px 24px}} +/* The dubbing bar is one row, name left and button right; the lines lie over the lower tracks at the foot of the frame. */ +.fv-whatsnew-top .fv-ed-genbar[data-voice=true]{flex-wrap:nowrap} +.fv-whatsnew-top .fv-ed-genbar[data-voice=true] .fv-ed-voice{flex:1 1 0} +.fv-whatsnew-lines.fv-whatsnew-top.fv-whatsnew-ed.fv-studio.fv-editor{position:absolute;z-index:3;inset:auto 8px 8px;height:auto;padding:8px;border:1px solid var(--fv-border);border-radius:12px;box-shadow:0 4px 16px #0006} +.fv-whatsnew-lines[hidden]{display:none!important} diff --git a/web/whatsnew.js b/web/whatsnew.js index ab935a9f..a392e308 100644 --- a/web/whatsnew.js +++ b/web/whatsnew.js @@ -1,44 +1,34 @@ -// What's new in 0.5: the editor's own track view drawn with the editor's own classes and played as a short loop. +// What's new in 0.5: the editor's own track view drawn with the editor's own classes and played once. // Nothing is recorded; the picture is a placeholder, the tracks, clips, progress and waveforms are the real styles. +import {PAGES, LINES} from './whatsnew_pages.js'; +import {segmented} from './editor_adjust.js'; +import {PEAKS} from './whatsnew_peaks.js'; + const KEY = 'freevideo.whatsnew-seen'; const SERIES = '0.5'; -const PAGES = [ - {id: 'story', title: ['Edit picture and sound, track by track', '画面和声音,逐轨编辑'], steps: [[0, {split: 0, music: 'old', voice: 'old'}], [1800, {split: 1}], - [3000, {music: 'running', progress: .3}], [4200, {progress: .8}], [5000, {music: 'new', progress: 0}], - [6600, {voice: 'running', progress: .5}], [7600, {progress: .9}], [8300, {voice: 'new', progress: 0}]], length: 11000}, - {id: 'stems', title: ['Split the sound into three tracks', '把声音拆成三条轨道'], howto: 'separate-tracks', - steps: [[0, {split: 0, music: 'old', voice: 'old'}], [1400, {split: 1}]], length: 5200}, - {id: 'music', title: ['Replace the music', '换一段配乐'], howto: 'replace-music', - steps: [[0, {split: 1, music: 'old', voice: 'old'}], [1200, {music: 'running', progress: .35}], [2000, {progress: .8}], [2700, {music: 'new', progress: 0}]], length: 5200}, - {id: 'voice', title: ['Regenerate a line', '重新生成一句台词'], howto: 'replace-voice', - steps: [[0, {split: 1, music: 'old', voice: 'old'}], [1200, {voice: 'running', progress: .5}], [2200, {progress: .9}], [2800, {voice: 'new', progress: 0}]], length: 5200}, -]; const el = (tag, text, cls) => { const e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; }; const svg = (tag, attrs = {}) => { const e = document.createElementNS('http://www.w3.org/2000/svg', tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; }; const ICON = {video: 'M2 4h16v12H2z M4 13l4-5 4 4 2-2 3 4', audio: 'M2 8h4l4-4v12l-4-4H2z M13 6c4 2 4 6 0 8 M13 9c1 1 1 1 0 2'}; const icon = kind => { const s = svg('svg', {viewBox: '0 0 20 20', 'aria-hidden': 'true'}); s.append(svg('path', {d: ICON[kind]})); return s; }; -// A deterministic waveform in the editor's 200x40 box; shape decides how the sound reads. -function wave(seed, shape, rate = 1) { - let x = seed * 9301 + 49297, d = ''; - const random = () => (x = (x * 9301 + 49297) % 233280) / 233280; - for (let i = 0; i < 100; i++) { - const at = i / 100; - const level = shape === 'speech' ? (Math.sin(at * 23 * rate + seed) > -.2 ? .25 + random() * .75 : .06) * (.5 + .5 * Math.sin(at * 7 * rate + 1)) - : shape === 'music' ? .35 + .4 * Math.sin(at * 9 * rate + seed) ** 2 + random() * .15 - : shape === 'ambience' ? .12 + random() * .12 : .3 + random() * .7; - const h = Math.max(.5, Math.min(1, level) * 17); - d += `M${(i + .5) * 2} ${20 - h}v${h * 2}`; - } +// A waveform in the editor's 200x40 box from real peaks, drawn as the editor draws them. +function wave(id) { + const peaks = PEAKS[id]; + let d = ''; + peaks.forEach((peak, i) => { const h = Math.max(.5, Math.min(1, peak) * 17); d += `M${(i + .5) * 2} ${20 - h}v${h * 2}`; }); const s = svg('svg', {viewBox: '0 0 200 40', preserveAspectRatio: 'none', 'aria-hidden': 'true'}); s.classList.add('fv-ed-waveform'); s.append(svg('path', {d})); return s; } -const ROWS = [['v1', 'video', 64], ['a1', 'audio', 40], ['a2', 'audio', 40], ['a3', 'audio', 40]]; +const frame = fn => requestAnimationFrame(() => requestAnimationFrame(fn)); -export function createStage(cn) { - const t = (en, zh) => cn ? zh : en; - const root = el('div', null, 'fv-whatsnew-stage'); root.setAttribute('aria-hidden', 'true'); +// One page's scene: the editor's track view under its generation bar, changed only by the ops of its steps. +export function createStage(cn, page) { + const t = pair => pair[cn ? 1 : 0]; + const root = el('div', null, 'fv-whatsnew-stage'); root.setAttribute('aria-hidden', 'true'); root.dataset.lang = cn ? 'zh' : 'en'; + let ticker = 0, dragTicker = 0, instantLayout = false, need = 0; + // The generation bar, the lines and the recording bar are the editor's own, under the editor's styles; they share one box above the tracks. + const top = el('div', null, 'fv-whatsnew-top fv-whatsnew-ed fv-studio fv-editor'); const view = el('section', null, 'fv-ed-track-view'); view.dataset.trackSwitches = 'false'; const heads = el('div', null, 'fv-ed-track-heads'), scroll = el('div', null, 'fv-ed-track-scroll'), content = el('div', null, 'fv-ed-track-content'); const ruler = el('div', null, 'fv-ed-ruler'); @@ -48,95 +38,241 @@ export function createStage(cn) { ruler.append(tick); } content.append(ruler); - let top = 24; - const rows = {}, heading = {}; - for (const [id, kind, height] of ROWS) { - const row = el('div', null, 'fv-ed-track-row'); row.dataset.track = id; row.style.top = `${top}px`; row.style.height = `${height}px`; - const head = el('div', null, 'fv-ed-track-head'); head.dataset.track = id; head.style.top = `${top}px`; head.style.height = `${height}px`; - const name = el('span'); head.append(icon(kind), name); heads.append(head); content.append(row); - rows[id] = row; heading[id] = name; top += height + 6; + let bottom = 24; + const rows = {}, names = {}, headNodes = {}, bodies = {}; + for (const row of page.rows) { + const body = el('div', null, 'fv-ed-track-row'); body.dataset.track = row.id; body.style.top = `${bottom}px`; body.style.height = `${row.height}px`; + const head = el('div', null, 'fv-ed-track-head'); head.dataset.track = row.id; head.style.top = `${bottom}px`; head.style.height = `${row.height}px`; + const name = el('span', t(row.name)); head.append(icon(row.kind), name); heads.append(head); content.append(body); + rows[row.id] = body; names[row.id] = {row, name}; headNodes[row.id] = head; bodies[row.id] = body; bottom += row.height + 6; } - const clip = (row, name, node) => { const c = el('div', null, 'fv-ed-clip'); c.dataset.name = name; c.style.left = '0'; c.style.width = '100%'; - const media = el('span', null, 'fv-ed-clip-media'); if (node) media.append(node); c.append(media); - const fill = el('span', null, 'fv-ed-progress-fill'); c.append(fill); row.append(c); return c; }; - const video = clip(rows.v1, 'video'); video.querySelector('.fv-ed-clip-media').classList.add('fv-whatsnew-picture'); - const mixed = clip(rows.a1, 'mixed', wave(3, 'mixed')); - const voice = [clip(rows.a1, 'voice-old', wave(5, 'speech')), clip(rows.a1, 'voice-new', wave(11, 'speech', 2.6))]; - const music = [clip(rows.a2, 'music-old', wave(7, 'music')), clip(rows.a2, 'music-new', wave(13, 'music', 5.5))]; - clip(rows.a3, 'ambience', wave(9, 'ambience')); - for (const c of [video, mixed, ...voice, ...music]) c.tabIndex = -1; - scroll.append(content); view.append(heads, scroll); root.append(view); - const names = () => { - const split = root.dataset.split === '1'; - heading.v1.textContent = t('Video', '画面'); - heading.a1.textContent = split ? t('Dialogue', '人声') : t('Audio', '声音'); - heading.a2.textContent = t('Music', '音乐'); heading.a3.textContent = t('Ambience', '环境声'); + // The view and the playhead reach the last track that is showing, and follow it when a track appears. + const fit = () => { + let low = 24; + for (const id in names) if (headNodes[id].dataset.on !== 'false') low = Math.max(low, parseFloat(bodies[id].style.top) + names[id].row.height + 6); + low = Math.max(low, menu.offsetHeight + 40); + content.style.height = `${low}px`; playhead.style.bottom = 'auto'; playhead.style.height = `${low}px`; + // What the frame needs in this state: the bar and the tracks, or the bar and the lines laid over the lower tracks with the dialogue track left showing. + need = Math.max(top.offsetHeight + low + 4, linesBox.hidden ? 0 : top.offsetHeight + 122 + linesBox.offsetHeight); + }; + const clips = {}; + for (const spec of page.clips) { + const c = el('div', null, 'fv-ed-clip'); c.dataset.name = spec.id; c.tabIndex = -1; + const media = el('span', null, 'fv-ed-clip-media'); + if (spec.picture) media.classList.add('fv-whatsnew-picture'); + if (spec.wave) media.append(wave(spec.wave)); + c.append(media, el('span', null, 'fv-ed-progress-fill')); + rows[spec.row].append(c); clips[spec.id] = {spec, node: c}; + } + const playhead = el('div', null, 'fv-whatsnew-playhead'), range = el('div', null, 'fv-whatsnew-range'), rangeText = el('span'); + range.append(rangeText); content.append(range, playhead); + // The clip's right-click menu, as the editor draws it. + const menuBox = el('div', null, 'fv-whatsnew-menu fv-whatsnew-ed fv-studio fv-editor'), menu = el('div', null, 'fv-ed-context-menu'); + for (const item of page.menuItems || []) { + if (item === '-') { const rule = el('div'); rule.setAttribute('role', 'separator'); menu.append(rule); continue; } + const b = el('button'); b.type = 'button'; b.tabIndex = -1; b.setAttribute('role', 'menuitem'); b.append(el('span', t(item), 'fv-ed-menu-label')); menu.append(b); + } + menuBox.append(menu); root.append(menuBox); + scroll.append(content); view.append(heads, scroll); + + const SRC = page.lines || LINES; + const button = (label, cls) => { const b = el('button', label, cls); b.type = 'button'; b.tabIndex = -1; return b; }; + const genbar = el('div', null, 'fv-ed-genbar'); + const voice = el('div', null, 'fv-ed-voice'), voiceTop = el('div', null, 'fv-ed-voice-top'), voiceList = el('div', null, 'fv-ed-voice-lines'); + voiceTop.append(el('span', t(['Lines', '台词']), 'fv-ed-voice-name')); + const lineNodes = SRC.map((_, i) => { + const line = el('div', null, 'fv-ed-voice-line'), main = el('div', null, 'fv-ed-voice-main'), time = button(`0:0${[0, 4, 7][i]}`, 'fv-ed-voice-time'), body = el('div', null, 'fv-ed-voice-text'); + main.append(el('span', null, 'fv-ed-voice-mark'), time, body); line.append(main); voiceList.append(line); return {line, body}; + }); + // The three lines lie over the lower tracks in a box of their own; the bar keeps one row, with the name and the button. + const linesBox = el('div', null, 'fv-whatsnew-lines fv-whatsnew-top fv-whatsnew-ed fv-studio fv-editor'); linesBox.hidden = true; linesBox.append(voiceList); + voice.append(voiceTop); + const presets = el('div', null, 'fv-ed-music-presets'); + const picker = (label, options) => { + const row = el('div', null, 'fv-ed-music-row'), control = segmented(options.map(([value, text]) => ({value, label: t(text)}))); + control.setAttribute('aria-label', label); control.querySelectorAll('button').forEach(b => { b.tabIndex = -1; }); + row.append(el('span', label, 'fv-ed-music-label'), control); presets.append(row); return control; + }; + const styles = picker(t(['Style', '风格']), [['piano', ['Piano', '钢琴']], ['folk', ['Folk', '民谣']], ['ambient', ['Ambient', '氛围']]]); + const moods = picker(t(['Mood', '情绪']), [['calm', ['Calm', '舒缓']], ['upbeat', ['Upbeat', '明快']], ['tense', ['Tense', '紧张']], ['warm', ['Warm', '温暖']], ['sad', ['Sad', '悲伤']]]); + const description = el('div', null, 'fv-ed-description-box'), text = el('textarea', null, 'fv-ed-description'); + text.rows = 1; text.style.height = '36px'; text.readOnly = true; text.tabIndex = -1; description.append(text); + const hint = el('div', null, 'fv-ed-gen-hint'), go = button('', 'fv-ed-generate fv-primary'), cancel = button(t(['Cancel', '取消'])); + genbar.append(voice, presets, description, hint, go, cancel); + const record = el('div', null, 'fv-ed-record'), recMain = el('button', null, 'fv-ed-record-main'), recNote = el('span', null, 'fv-ed-record-note'); + recMain.type = 'button'; recMain.tabIndex = -1; + const recSide = el('div', null, 'fv-ed-record-side'), recCenter = el('div', null, 'fv-ed-record-center'), recEnd = el('div', null, 'fv-ed-record-side fv-ed-record-end'); + recSide.append(el('span', t(['Record', '录音']))); recCenter.append(recMain); recEnd.append(recNote); record.append(recSide, recCenter, recEnd); + top.append(genbar, record); root.append(top, view, linesBox); + const list = el('ol', null, 'fv-whatsnew-list'); for (const item of page.list || []) list.append(el('li', t(item))); + + let typing = 0, timers = []; + const place = (c, at, to) => { c.node.style.left = `${at * 100}%`; c.node.style.width = `${(to - at) * 100}%`; }; + const bar = {}, linesState = {}; + const typeInto = (node, full, done) => { + const put = value => { if (node.tagName === 'TEXTAREA') node.value = value; else node.textContent = value; }; + clearInterval(typing); const per = cn ? 70 : 35; let n = 0; put(''); + typing = setInterval(() => { n++; put(full.slice(0, n)); if (n >= full.length) { clearInterval(typing); done?.(); } }, per); + }; + // The box above the tracks grows and shrinks to its content over 240 ms. + const morph = change => { + if (instantLayout) { change(); return; } + const from = top.offsetHeight, shown = [voice.hidden, record.hidden, genbar.hidden].join(); + top.style.transition = 'none'; top.style.height = ''; change(); const to = top.offsetHeight; + // A panel that appears or goes is swapped at once, so no half-open frame shows; the other changes grow or shrink over 240 ms. + if (from === to || shown !== [voice.hidden, record.hidden, genbar.hidden].join()) { top.style.transition = ''; return; } + top.style.height = `${from}px`; top.getBoundingClientRect(); top.style.transition = ''; top.style.height = `${to}px`; + }; + const paintLines = () => { + lineNodes.forEach(({line, body: node}, i) => { + const [full, old, fresh] = SRC[i].map(x => t(x)); + line.dataset.changed = String(i === 1 && !!linesState.edit); + if (i !== 1) { node.textContent = full; return; } + if (linesState.edit === 'strike') node.replaceChildren(...full.split(old).flatMap((p, k, a) => k < a.length - 1 ? [document.createTextNode(p), el('s', old)] : [document.createTextNode(p)])); + else if (linesState.edit === 'typing') { const [head, tail] = full.split(old); node.replaceChildren(document.createTextNode(head), el('s', old), el('b', null, 'fv-whatsnew-new'), document.createTextNode(tail)); typeInto(node.querySelector('b'), fresh); } + else if (linesState.edit === 'done') node.textContent = full.replace(old, fresh); + else node.textContent = full; + }); + }; + const paintBar = () => { + const mode = bar.mode || 'idle', running = mode === 'running', recording = mode === 'record', open = mode === 'selected' || mode === 'lines'; + genbar.hidden = recording; record.hidden = !recording; + genbar.dataset.mode = running ? 'running' : open ? 'selected' : 'idle'; + voice.hidden = linesBox.hidden = !linesState.shown; if (linesState.shown) genbar.dataset.voice = 'true'; else genbar.removeAttribute('data-voice'); + presets.hidden = !bar.presets; genbar.classList.toggle('fv-ed-genbar-music', !!bar.presets); + styles.value = bar.style; moods.value = bar.mood; styles.disabled = moods.disabled = running; + text.placeholder = bar.ph ? t(bar.ph) : ''; text.disabled = running; + hint.hidden = !running; hint.textContent = running ? (bar.hint || []).map(t).join(' · ') : ''; + go.hidden = running; go.disabled = !open; go.textContent = bar.button ? t(bar.button) : ''; cancel.hidden = !running; + record.dataset.phase = bar.phase || 'idle'; recMain.textContent = bar.phase === 'recording' ? t(['Stop', '停止']) : t(['Start recording', '开始录音']); }; - const set = state => { - for (const [k, v] of Object.entries(state)) { if (k === 'progress') root.style.setProperty('--fv-ed-progress', String(v)); else root.dataset[k] = String(v); } - for (const [pair, key] of [[voice, 'voice'], [music, 'music']]) { - pair[0].dataset.state = root.dataset[key] === 'running' ? 'running' : 'ready'; - if (root.dataset[key] === 'running') pair[0].style.setProperty('--fv-ed-progress', root.style.getPropertyValue('--fv-ed-progress') || '0'); + const reset = () => { + clearInterval(typing); clearInterval(ticker); clearInterval(dragTicker); timers.forEach(clearTimeout); timers = []; recNote.textContent = ''; + for (const key of Object.keys(bar)) delete bar[key]; + for (const key of Object.keys(linesState)) delete linesState[key]; + for (const c of Object.values(clips)) { + place(c, c.spec.at, c.spec.at + (c.spec.grow ? .02 : c.spec.to - c.spec.at)); + c.node.dataset.on = String(!c.spec.off); c.node.dataset.state = 'ready'; c.node.removeAttribute('data-selected'); c.node.removeAttribute('data-hl'); + c.node.style.removeProperty('--fv-ed-progress'); c.node.style.clipPath = ''; c.node.style.transition = ''; c.node.style.opacity = ''; } - names(); + for (const id in names) { names[id].name.textContent = t(names[id].row.name); headNodes[id].dataset.on = String(!names[id].row.late); bodies[id].dataset.on = String(!names[id].row.late); } + const faders = [...Object.values(clips).map(c => c.node), ...Object.values(headNodes), ...Object.values(bodies)]; + faders.forEach(n => { n.style.transition = 'none'; }); frame(() => faders.forEach(n => { n.style.transition = ''; })); + playhead.style.cssText = ''; playhead.dataset.on = 'false'; range.dataset.on = 'false'; range.style.cssText = ''; menuBox.dataset.on = 'false'; + text.value = ''; list.querySelectorAll('li').forEach(li => li.removeAttribute('data-lit')); + top.style.height = ''; top.style.transition = ''; + paintLines(); paintBar(); fit(); }; - set({split: 0, music: 'old', voice: 'old', progress: 0}); - return {element: root, set}; + const set = (ops, instant = false) => { + if (ops.split) { + const faders = [...Object.values(clips).map(c => c.node), ...Object.values(headNodes), ...Object.values(bodies)]; + faders.forEach(n => { n.style.transition = 'none'; }); frame(() => faders.forEach(n => { n.style.transition = ''; })); + for (const c of Object.values(clips)) { if (c.spec.splits) c.node.dataset.on = 'true'; if (c.spec.id === 'mix') c.node.dataset.on = 'false'; } + for (const id in names) if (names[id].row.late && !names[id].row.record) { headNodes[id].dataset.on = 'true'; bodies[id].dataset.on = 'true'; } + if (names.a1.row.dialogue) names.a1.name.textContent = t(names.a1.row.dialogue); + } + if (ops.rowShow) { headNodes[ops.rowShow].dataset.on = 'true'; bodies[ops.rowShow].dataset.on = 'true'; } + for (const id of ops.hide || []) clips[id].node.dataset.on = 'false'; + for (const id of ops.show || []) clips[id].node.dataset.on = 'true'; + if ('select' in ops) for (const c of Object.values(clips)) { if (c.spec.id === ops.select) c.node.dataset.selected = 'true'; else c.node.removeAttribute('data-selected'); } + if ('hl' in ops) for (const c of Object.values(clips)) { if (ops.hl.includes(c.spec.id)) c.node.dataset.hl = 'true'; else c.node.removeAttribute('data-hl'); } + if (ops.lit) list.querySelectorAll('li').forEach((li, i) => { if (ops.lit.includes(i)) li.dataset.lit = 'true'; else li.removeAttribute('data-lit'); }); + if (ops.menu) { + menuBox.dataset.on = String(!!ops.menu.show); + menuBox.style.left = `${view.offsetLeft + scroll.offsetLeft + Math.max(8, Math.min(scroll.clientWidth * .38, scroll.clientWidth - menu.offsetWidth - 8))}px`; menuBox.style.top = `${view.offsetTop + 34}px`; + menu.querySelectorAll('button').forEach((b, i) => { b.dataset.hover = String(ops.menu.hover === i); }); + } + morph(() => { + if (ops.bar) { + const wasType = ops.bar.type; + Object.assign(bar, ops.bar); + if ('text' in ops.bar) { text.value = ops.bar.text ? t(ops.bar.text) : ''; } + if (wasType) { delete bar.type; typeInto(text, t(wasType), paintBar); } + } + if (ops.lines) { Object.assign(linesState, ops.lines); paintLines(); } + paintBar(); + }); + fit(); + if ('at' in ops) { playhead.dataset.on = 'true'; playhead.style.left = `${ops.at * 100}%`; } + if (ops.run && !instant) { + const {id, ms} = ops.run, node = clips[id].node, fill = node.querySelector('.fv-ed-progress-fill'); + node.dataset.state = 'running'; fill.style.transition = 'none'; node.style.setProperty('--fv-ed-progress', '0'); + frame(() => { fill.style.transition = `width ${ms}ms linear`; node.style.setProperty('--fv-ed-progress', '1'); }); + } + if (ops.done) { const c = clips[ops.done].node; c.dataset.state = 'ready'; c.style.removeProperty('--fv-ed-progress'); } + if (ops.reveal && !instant) { + const {id, ms} = ops.reveal, node = clips[id].node; + node.style.transition = 'none'; node.style.clipPath = 'inset(0 100% 0 0)'; + frame(() => { node.style.transition = `clip-path ${ms}ms var(--fv-ease)`; node.style.clipPath = 'inset(0 0 0 0)'; }); + } + if (ops.flash && !instant) { const node = clips[ops.flash].node; node.dataset.flash = 'true'; timers.push(setTimeout(() => node.removeAttribute('data-flash'), 900)); } + if (ops.play) { + const [from, to, ms] = ops.play; playhead.dataset.on = 'true'; playhead.style.transition = 'none'; playhead.style.left = `${(instant ? to : from) * 100}%`; + if (!instant) frame(() => { playhead.style.transition = `left ${ms}ms linear`; playhead.style.left = `${to * 100}%`; }); + } + if (ops.rec) { + const {id, from, to, ms} = ops.rec, c = clips[id]; c.node.style.transition = 'none'; place(c, from, instant ? to : from + .01); c.node.dataset.on = 'true'; + if (!instant) { + frame(() => { c.node.style.transition = `width ${ms}ms linear`; place(c, from, to); }); + const started = performance.now(); clearInterval(ticker); + ticker = setInterval(() => { const sec = Math.min(Math.floor((performance.now() - started) / 1000), Math.floor(ms / 1000)); recNote.textContent = `0:0${sec}`; }, 250); + } + } + if (instant) recNote.textContent = ''; + }; + reset(); + // Reduced motion shows the finished page: every step applied at once, typed text complete. + const settle = ops => { const o = {...ops}; if (o.bar?.type) o.bar = {...o.bar, text: o.bar.type, type: null}; if (o.lines?.edit === 'typing') o.lines = {...o.lines, edit: 'done'}; set(o, true); }; + const finish = () => { instantLayout = true; for (const [, ops] of page.steps) settle(ops); range.dataset.on = 'false'; instantLayout = false; }; + // The tallest the demo gets over the steps: the frame has that height from the start to the end, so nothing below it moves. + const tallest = () => { + root.classList.add('fv-whatsnew-measure'); instantLayout = true; reset(); let max = need; + for (const [, ops] of page.steps) { settle(ops); max = Math.max(max, need); } + instantLayout = false; reset(); root.classList.remove('fv-whatsnew-measure'); return max; + }; + return {element: root, list, set, reset, finish, tallest, height: value => { root.style.height = `${value}px`; }}; } -// Plays a page's steps in a loop; reduced motion shows the finished state once. +// Plays a page's steps once and stays on the finished state; reduced motion shows that state at once. export function play(stage, page, reduce) { let timers = [], dead = false; - const run = () => { - if (dead) return; - for (const [at, state] of page.steps) timers.push(setTimeout(() => !dead && stage.set(state), at)); - timers.push(setTimeout(run, page.length)); - }; - if (reduce) { for (const [, state] of page.steps) stage.set(state); return () => {}; } - run(); + if (reduce) { stage.finish(); return () => {}; } + stage.reset(); + for (const [at, ops] of page.steps) timers.push(setTimeout(() => !dead && stage.set(ops), at)); return () => { dead = true; timers.forEach(clearTimeout); }; } const seenSeries = () => { try { return localStorage.getItem(KEY) === SERIES; } catch { return false; } }; const remember = () => { try { localStorage.setItem(KEY, SERIES); } catch { /* Shown again next time. */ } }; -export function showWhatsNew(cn, {openedBy = 'user', start} = {}) { +export function showWhatsNew(cn, {openedBy = 'user'} = {}) { const t = (en, zh) => cn ? zh : en; const css = document.getElementById('fv-whatsnew-css') || Object.assign(document.createElement('link'), {id: 'fv-whatsnew-css', rel: 'stylesheet', href: new URL('./whatsnew.css', import.meta.url).href}); if (!css.isConnected) document.head.append(css); - for (const sheet of ['editor_tracks.css']) { + for (const sheet of ['editor.css', 'editor_expand.css', 'editor_adjust.css', 'editor_tracks.css', 'editor_menu.css', 'editor_voice.css', 'editor_record.css']) { const id = 'fv-whatsnew-' + sheet; if (!document.getElementById(id)) document.head.append(Object.assign(document.createElement('link'), {id, rel: 'stylesheet', href: new URL('./' + sheet, import.meta.url).href})); } const dialog = el('dialog', null, 'fv-whatsnew'); dialog.setAttribute('aria-label', t("What's new", '新功能')); - const title = el('h2'), close = el('button', '×', 'fv-whatsnew-close'), stageHost = el('div', null, 'fv-whatsnew-host'), foot = el('div', null, 'fv-whatsnew-foot'); - const dots = el('div', null, 'fv-whatsnew-dots'), take = el('button', t('Take me there', '带我去'), 'fv-whatsnew-take'), next = el('button', null, 'fv-whatsnew-next'); - take.type = next.type = close.type = 'button'; take.hidden = true; close.setAttribute('aria-label', t('Close', '关闭')); - foot.append(dots, take, next); dialog.append(close, title, stageHost, foot); + const title = el('h2'), stageHost = el('div', null, 'fv-whatsnew-host'), foot = el('div', null, 'fv-whatsnew-foot'); + const close = el('button', t('Close', '关闭'), 'fv-whatsnew-close'); close.type = 'button'; + foot.append(close); dialog.append(title, stageHost, foot); const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; - let index = 0, stop = () => {}, token = 0; - const dotButtons = PAGES.map((page, i) => { const b = el('button', null, 'fv-whatsnew-dot'); b.type = 'button'; b.setAttribute('aria-label', page.title[cn ? 1 : 0]); b.onclick = () => show(i); dots.append(b); return b; }); - function show(i) { - stop(); index = i; const page = PAGES[i], mine = ++token; - title.textContent = page.title[cn ? 1 : 0]; - const stage = createStage(cn); stageHost.replaceChildren(stage.element); - stop = play(stage, page, reduce); - dotButtons.forEach((b, n) => b.setAttribute('aria-current', String(n === i))); - next.textContent = i === PAGES.length - 1 ? t('Done', '完成') : t('Next', '下一页'); - take.hidden = true; - // A page that has no guided tour yet shows no button. - if (page.howto) Promise.resolve(window.freevideoHowTo?.available?.(page.howto)).then(ok => { if (ok && token === mine) take.hidden = false; }).catch(() => {}); - } - close.onclick = () => dialog.close(); - next.onclick = () => index === PAGES.length - 1 ? dialog.close() : show(index + 1); - take.onclick = () => { const id = PAGES[index].howto; dialog.close(); void window.freevideoHowTo?.start?.(id); }; - dialog.addEventListener('keydown', event => { - event.stopPropagation(); - if (event.key === 'ArrowRight' && index < PAGES.length - 1) show(index + 1); - if (event.key === 'ArrowLeft' && index > 0) show(index - 1); + const page = PAGES[0], stage = createStage(cn, page); + title.textContent = page.title[cn ? 1 : 0]; stageHost.replaceChildren(stage.element); dialog.insertBefore(stage.list, foot); + let stop = () => {}, closed = false; + // The demo frame is as tall as its tallest state for the whole run, so the list under it and the dialog never move. + stageHost.style.visibility = 'hidden'; + const sheets = [...document.querySelectorAll('link[id^="fv-whatsnew-"]')].filter(link => !link.sheet); + Promise.all(sheets.map(link => new Promise(done => { link.addEventListener('load', done, {once: true}); link.addEventListener('error', done, {once: true}); }))).then(() => { + if (closed) return; + stage.height(stage.tallest()); + stageHost.style.visibility = ''; stop = play(stage, page, reduce); }); - dialog.addEventListener('close', () => { stop(); remember(); dialog.remove(); }, {once: true}); - document.body.append(dialog); dialog.showModal(); show(start ?? 0); dialog.tabIndex = -1; dialog.focus(); + close.onclick = () => dialog.close(); + dialog.addEventListener('keydown', event => event.stopPropagation()); + dialog.addEventListener('close', () => { closed = true; stop(); remember(); dialog.remove(); }, {once: true}); + document.body.append(dialog); dialog.showModal(); dialog.tabIndex = -1; dialog.focus(); return () => { if (dialog.open) dialog.close(); }; } diff --git a/web/whatsnew_pages.js b/web/whatsnew_pages.js new file mode 100644 index 00000000..e926b19b --- /dev/null +++ b/web/whatsnew_pages.js @@ -0,0 +1,46 @@ +// The what's-new pages as data, after the storyboard: rows, clips, the generation bar and the lines panel, and timed steps. +// Every text is [English, Chinese] and the editor's own wording. A clip is {id, row, at, to} in fractions of the ruler; a step is +// [ms, ops] and the ops are applied by the scene in whatsnew.js. +const V = {id: 'v1', kind: 'video', name: ['Video', '画面'], height: 40}; +const SOUND = [{id: 'a1', kind: 'audio', name: ['Audio', '声音'], height: 40, dialogue: ['Dialogue', '人声']}, + {id: 'a2', kind: 'audio', name: ['Music', '音乐'], height: 40, late: true}, {id: 'a3', kind: 'audio', name: ['Ambience', '环境声'], height: 40, late: true}, + {id: 'a4', kind: 'audio', name: ['Sound', '声音'], height: 40, late: true, record: true}]; +const picture = {id: 'pic', row: 'v1', at: 0, to: 1, picture: true}; +const BLOCKS = [[.04, .26], [.34, .58], [.66, .84]]; +const blocks = (extra = {}) => BLOCKS.map(([at, to], i) => ({id: `v${i + 1}`, row: 'a1', at, to, wave: `v${i + 1}`, off: true, splits: true, ...extra})); +const music = {id: 'music', row: 'a2', at: 0, to: 1, wave: 'music', off: true, splits: true}; +const guitar = {id: 'guitar', row: 'a2', at: 0, to: 1, wave: 'guitar', off: true}; +const rain = {id: 'rain', row: 'a3', at: 0, to: 1, wave: 'rain', off: true, splits: true}; +const mix = {id: 'mix', row: 'a1', at: 0, to: 1, wave: 'mix'}; +const take = {id: 'take', row: 'a4', at: .3, to: .52, wave: 'take', off: true, grow: true}; +const redub = {id: 'v2n', row: 'a1', at: BLOCKS[1][0], to: BLOCKS[1][1], wave: 'v2n', off: true}; +export const LINES = [ + [['I have been waiting here for you to come back.', '我一直在这里等你回来。']], + [['Let us talk about this tomorrow.', '这件事我们明天再说。'], ['tomorrow', '明天'], ['now', '现在']], + [['The rain seems to be letting up.', '雨好像小了。']], +]; + +export const PAGES = [ + {id: 'overview', title: ['0.5.0: A fully upgraded sound system', '0.5.0 声音系统全面升级'], length: 11000, height: 400, list: [ + ['Separate tracks: dialogue, music, ambience', '分离音轨:人声、音乐、环境声'], ['Replace music: from a text description', '替换配乐:根据文字描述重新生成'], + ['Add music: generated to fit the selected length', '添加配乐:按选中的长度生成'], ['Edit lines: only changed lines are redubbed', '修改台词:只为改过的台词重新配音'], + ["Dub: in the original speaker's voice", '配音:沿用原说话人的声音'], ['Record: onto an audio track', '录音:录制到声音轨'], + ['Generate picture from sound', '按声音生成画面']], + rows: [V, ...SOUND], menuItems: [['Copy', '复制'], ['Delete', '删除'], '-', ['Separate tracks', '分离音轨']], + clips: [picture, mix, ...blocks(), music, guitar, rain, redub, take], preview: false, + steps: [[0, {at: .12, bar: {mode: 'idle', ph: ['Describe this part', '描述这一段'], button: ['Generate', '生成']}}], + [1200, {select: 'mix', lit: [0], menu: {show: true, hover: -1}}], [1900, {menu: {show: true, hover: 2}}], + [2600, {menu: {show: false}, bar: {mode: 'running', hint: [['Separating the sound', '正在分离声音']]}, run: {id: 'mix', ms: 1200}}], + [3900, {split: true, select: null, bar: {mode: 'idle'}}], + [5000, {select: 'music', lit: [1], bar: {mode: 'selected', presets: true, style: 'piano', mood: 'calm', ph: ['Describe the music', '描述配乐'], type: ['upbeat acoustic guitar', '轻快的原声吉他'], button: ['Replace music', '替换配乐']}}], + [5800, {bar: {button: ['Replace music · about 30 s', '替换配乐 · 约 30 秒']}}], + [6200, {bar: {mode: 'running', hint: [['Generating music', '正在生成配乐'], ['about 30 s', '约 30 秒']]}, run: {id: 'music', ms: 1800}}], + [8200, {done: 'music', hide: ['music'], show: ['guitar'], reveal: {id: 'guitar', ms: 500}, select: null, bar: {mode: 'idle', presets: false, text: null, button: ['Generate', '生成'], ph: ['Describe this part', '描述这一段']}}], + [8800, {select: 'v2', lit: [3, 4], bar: {mode: 'lines', button: ['Redub', '重新配音']}, lines: {shown: true, active: 1, edit: 'strike'}}], + [9100, {bar: {button: ['Redub 1 line', '重新配音 1 句']}}], [9200, {lines: {edit: 'typing'}}], + [9900, {bar: {mode: 'running', hint: [['Dubbing', '正在配音'], ['about 6 s', '约 6 秒']]}, run: {id: 'v2', ms: 1400}}], + [11300, {done: 'v2', hide: ['v2'], show: ['v2n'], flash: 'v2n', select: null, bar: {mode: 'lines', button: ['Redub', '重新配音']}, lines: {edit: 'done'}}], + [12600, {lit: [5], select: 'take', rowShow: 'a4', lines: {shown: false}, bar: {mode: 'record', phase: 'idle'}}], + [13400, {bar: {phase: 'recording'}, rec: {id: 'take', from: .3, to: .52, ms: 3000}, play: [.3, .52, 3000]}], + [16500, {bar: {mode: 'idle', phase: null, text: null, button: ['Generate', '生成'], ph: ['Describe this part', '描述这一段']}, select: null, lit: []}]]}, +]; diff --git a/web/whatsnew_peaks.js b/web/whatsnew_peaks.js new file mode 100644 index 00000000..512b1cb3 --- /dev/null +++ b/web/whatsnew_peaks.js @@ -0,0 +1,2 @@ +// Real peaks (100 buckets, abs max, as the editor draws them) of tracks the product separated from our own generated films. +export const PEAKS = {"mix":[0.045,0.032,0.068,0.102,0.11,0.114,0.113,0.307,0.39,0.361,0.289,0.403,0.496,0.413,0.373,0.361,0.42,0.356,0.347,0.288,0.253,0.222,0.197,0.179,0.159,0.149,0.128,0.12,0.2,0.184,0.16,0.161,0.141,0.116,0.134,0.114,0.092,0.098,0.108,0.095,0.109,0.122,0.127,0.14,0.156,0.18,0.21,0.156,0.167,0.172,0.225,0.193,0.177,0.167,0.167,0.144,0.15,0.14,0.136,0.149,0.122,0.117,0.093,0.093,0.099,0.106,0.095,0.085,0.083,0.114,0.114,0.115,0.065,0.074,0.092,0.062,0.105,0.075,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],"music":[0.045,0.032,0.068,0.102,0.109,0.114,0.113,0.279,0.36,0.312,0.216,0.346,0.494,0.411,0.362,0.351,0.413,0.358,0.35,0.287,0.252,0.221,0.193,0.179,0.158,0.149,0.127,0.113,0.2,0.182,0.161,0.155,0.138,0.113,0.134,0.113,0.092,0.102,0.106,0.099,0.097,0.122,0.116,0.136,0.152,0.168,0.188,0.155,0.167,0.162,0.215,0.184,0.172,0.158,0.157,0.13,0.137,0.139,0.129,0.123,0.107,0.101,0.071,0.079,0.069,0.075,0.063,0.063,0.053,0.077,0.099,0.102,0.051,0.045,0.051,0.032,0.048,0.054,0.001,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],"guitar":[0.036,0.036,0.025,0.019,0.021,0.013,0.01,0.013,0.053,0.102,0.16,0.188,0.171,0.269,0.306,0.286,0.273,0.316,0.266,0.304,0.303,0.351,0.305,0.39,0.341,0.3,0.301,0.382,0.338,0.279,0.336,0.345,0.312,0.315,0.355,0.286,0.283,0.335,0.429,0.428,0.296,0.24,0.218,0.204,0.172,0.187,0.18,0.206,0.253,0.291,0.275,0.277,0.36,0.308,0.348,0.354,0.272,0.218,0.247,0.267,0.351,0.42,0.336,0.445,0.491,0.508,0.536,0.566,0.594,0.568,0.57,0.53,0.559,0.417,0.316,0.324,0.203,0.198,0.184,0.171,0.125,0.109,0.1,0.057,0.044,0.033,0.035,0.028,0.02,0.018,0.01,0.009,0.01,0.008,0.008,0.006,0.004,0.003,0.002,0.001],"rain":[0.036,0.016,0.007,0.01,0.008,0.006,0.007,0.007,0.013,0.026,0.045,0.095,0.105,0.094,0.132,0.118,0.158,0.187,0.137,0.189,0.17,0.252,0.199,0.335,0.503,0.371,0.346,0.445,0.465,0.365,0.501,0.42,0.374,0.303,0.4,0.295,0.325,0.37,0.262,0.257,0.351,0.281,0.301,0.389,0.552,0.407,0.53,0.492,0.478,0.453,0.395,0.432,0.426,0.544,0.492,0.452,0.359,0.392,0.37,0.376,0.286,0.36,0.183,0.214,0.187,0.223,0.183,0.326,0.334,0.389,0.299,0.245,0.232,0.294,0.239,0.229,0.21,0.153,0.148,0.13,0.072,0.072,0.057,0.038,0.029,0.02,0.023,0.013,0.009,0.007,0.005,0.004,0.003,0.002,0.002,0.0,0.0,0.0,0.0,0.0],"v1":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.014,0.294,0.509,0.547,0.517,0.442,0.338,0.233,0.109,0.149,0.174,0.154,0.476,0.474,0.462,0.482,0.488,0.4,0.35,0.307,0.214,0.121,0.033,0.004,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0],"v2":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.001,0.164,0.332,0.337,0.202,0.139,0.096,0.125,0.073,0.35,0.432,0.437,0.425,0.367,0.167,0.089,0.114,0.17,0.412,0.411,0.414,0.43,0.45,0.444,0.429,0.349,0.098,0.057,0.303,0.366,0.389,0.383,0.344,0.052,0.037,0.06,0.099,0.154,0.17,0.387,0.399,0.414,0.432,0.402,0.342,0.34,0.305,0.263,0.123,0.233,0.137,0.038,0.033,0.134,0.218,0.254,0.263,0.243,0.249,0.224,0.21,0.188,0.178,0.148,0.138,0.132,0.027,0.003,0.0,0.0,0.0,0.084,0.016,0.008,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.001],"v3":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.001,0.0,0.0,0.0,0.0,0.0,0.004,0.019,0.166,0.209,0.199,0.144,0.107,0.074,0.055,0.052,0.063,0.072,0.074,0.101,0.252,0.261,0.225,0.211,0.215,0.191,0.176,0.127,0.083,0.053,0.048,0.061,0.079,0.016,0.162,0.191,0.188,0.195,0.186,0.201,0.217,0.211,0.228,0.23,0.199,0.13,0.065,0.046,0.05,0.023,0.002,0.026,0.143,0.106,0.122,0.13,0.13,0.043,0.013,0.015,0.01,0.015,0.01,0.012,0.078],"v2n":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.001,0.0,0.0,0.0,0.003,0.016,0.181,0.209,0.18,0.107,0.074,0.052,0.07,0.074,0.101,0.252,0.261,0.215,0.215,0.188,0.149,0.083,0.052,0.061,0.079,0.132,0.191,0.19,0.195,0.201,0.217,0.228,0.23,0.199,0.099,0.049,0.05,0.01,0.033,0.143,0.115,0.13,0.13,0.022,0.015],"take":[0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.005,0.23,0.337,0.334,0.202,0.145,0.096,0.125,0.073,0.316,0.42,0.437,0.43,0.418,0.342,0.167,0.089,0.114,0.078,0.41,0.412,0.414,0.419,0.45,0.449,0.429,0.423,0.327,0.098,0.057,0.28,0.34,0.382,0.389,0.375,0.189,0.025,0.037,0.073,0.099,0.154,0.12,0.387,0.399,0.408,0.426,0.432,0.367,0.342,0.327,0.298,0.245,0.123,0.233,0.137,0.057,0.032,0.06,0.2,0.243,0.263,0.253,0.236,0.249,0.224,0.21,0.188,0.178,0.158,0.142,0.132,0.058,0.005,0.001,0.0,0.0,0.0,0.084,0.026,0.008,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.001]};