diff --git "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" index 12580964..03fb88e0 100644 --- "a/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" +++ "b/docs/zh-CN/\344\275\277\347\224\250\350\257\264\346\230\216.md" @@ -46,7 +46,7 @@ ### 限制 -- 两种做法一样,一条视频最多 5 段(第一段加 4 次接着生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 +- 两种做法一样,一条视频最多 8 段(第一段加 7 次接着生成);创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 110 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。 - 接着生成使用轻量档,需要 NVIDIA 显卡或 Mac,也不能和“继续二采”同时使用。 - 单次生成的最长时长随画幅和显卡而不同,在创作面板的“可生成范围”中可查看各尺寸的最长时长和预计用时。 - 分段生成同样需要 NVIDIA 显卡或 Mac。 diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index 234acb4c..5be6329b 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -41,6 +41,32 @@ def commit_exhausted(error): return found(error) +# A CUDA error that may leave the device itself unusable; never retried. +DEVICE_STATE_ERRORS = ('cuda error: unknown error', 'device is lost', 'device lost', 'gpu is lost', + 'illegal memory access', 'device-side assert', 'unspecified launch failure', 'driver shutting down', + 'device has been removed', 'cuda error: launch timeout', 'the launch timed out', + 'cudaerrorillegaladdress', 'cudaerrorunknown') + + +def second_pass_resumable(error, failure, metrics, attempts): + """A rejected, non-sticky CUDA call in the second pass, first pass retained. + + The retry is one fresh process that loads the retained first pass and + repeats only the second pass with the same placement. A sticky device + error, an unconfirmed worker stop, a failure after sampling finished and + any earlier resumed attempt stay final; out-of-memory keeps its own path. + """ + from .decode_resume import completed_sampling, refine_checkpoint_complete + if failure.get('kind') != 'code_error' or not isinstance(metrics, dict): + return False + recorded = str(metrics.get('error', '')).lower() + if not (cuda_runtime_error(error) or 'cuda error' in recorded) or 'out of memory' in recorded: + return False + if not refine_checkpoint_complete(metrics) or completed_sampling(metrics): + return False + return not any(row.get('decision', {}).get('second_pass_resume') for row in attempts) + + def classify_failure(error, metrics=None): """Only positively identified resource failures are retriable. @@ -55,10 +81,7 @@ def classify_failure(error, metrics=None): attached = getattr(error, 'cleanup_errors', []) cleanup += attached if isinstance(attached, list) else [attached] text = '\n'.join([str(error), str(metrics.get('error', ''))] + [str(item) for item in cleanup]).lower() - if any(part in text for part in ('cuda error: unknown error', 'device is lost', 'device lost', - 'gpu is lost', 'illegal memory access', 'device-side assert', 'unspecified launch failure', - 'driver shutting down', 'device has been removed', 'cuda error: launch timeout', - 'the launch timed out', 'cudaerrorillegaladdress', 'cudaerrorunknown')) or details.get('kind') == 'cuda_error': + if any(part in text for part in DEVICE_STATE_ERRORS) or details.get('kind') == 'cuda_error': kind, outcome, retry = 'cuda_error', 'discarded', False elif getattr(error, 'worker_stop_confirmed', None) is False: kind, outcome, retry = 'worker_stop_unconfirmed', 'discarded', False @@ -628,17 +651,25 @@ def execute_attempts(history, identity, profile, canvas, launch, *, history.finish(attempt, failure['outcome'], details=details) row.update(state=failure['outcome'], failure=failure) persist() - if not automatic or not failure['retryable'] or index == max_retries: + resume = (automatic and index < max_retries and not failure['retryable'] + and second_pass_resumable(error, failure, metrics, report['resource_attempts'])) + if not automatic or not (failure['retryable'] or resume) or index == max_retries: raise previous_ram_retries = sum(r.get('failure', {}).get('kind') == 'ram_pressure' for r in report['resource_attempts'][:-1]) previous_gpu_retries = sum(r.get('failure', {}).get('kind') == 'gpu_oom' for r in report['resource_attempts'][:-1]) from .decode_resume import completed_sampling - updated, decision = next_placement(current, failure, (metrics or {}).get('phase', 'sample'), canvas, - previous_ram_retries=previous_ram_retries, - previous_gpu_retries=previous_gpu_retries, - sampling_complete=completed_sampling(metrics)) + if resume: + updated, decision = copy.deepcopy(current), dict( + reason='A CUDA call was rejected in the second pass; repeat only the second pass ' + 'from the retained first pass with the same placement.', + numerical_class='unchanged', second_pass_resume=True) + else: + updated, decision = next_placement(current, failure, (metrics or {}).get('phase', 'sample'), canvas, + previous_ram_retries=previous_ram_retries, + previous_gpu_retries=previous_gpu_retries, + sampling_complete=completed_sampling(metrics)) if updated is None: row['recovery_note'] = decision['reason'] persist() diff --git a/freevideo_engine/agent/cast.py b/freevideo_engine/agent/cast.py index 1e7d4a2e..a3cce5e7 100644 --- a/freevideo_engine/agent/cast.py +++ b/freevideo_engine/agent/cast.py @@ -196,6 +196,7 @@ def __init__(self, jobs): self.directory = jobs.directory / 'cast' self.records = {} self.deleted = {} + self._unreadable = set() # Bad cards whose quarantine rename failed. self.changed = threading.Condition(jobs.lock) self.landing = set() # Cards whose reference pictures a tick is landing now (under the lock). self.load() @@ -204,6 +205,8 @@ def __init__(self, jobs): def load(self): for folder, target in ((self.directory / '.deleted', self.deleted), (self.directory, self.records)): for path in sorted(folder.glob('*.json')): + if not CARD.fullmatch(path.stem): + continue try: record = json.loads(path.read_text(encoding='utf-8')) _check(record, path.stem) @@ -212,11 +215,16 @@ def load(self): path.unlink() continue target[path.stem] = record + self._unreadable.discard(path) + except FileNotFoundError: + continue # A missing record is not an unreadable card. except (ValueError, KeyError, TypeError, AttributeError, IndexError, RecursionError, OSError): try: path.replace(path.with_name(path.name + '.bad-' + uuid.uuid4().hex)) + self._unreadable.discard(path) except OSError: - pass + if target is self.records: + self._unreadable.add(path) logging.getLogger(__name__).warning('Isolated an invalid cast card; other cards remain available') def _write(self, folder, record): @@ -235,6 +243,7 @@ def _write(self, folder, record): def _save(self, record): self._write(self.directory, record) self.records[record['card']['id']] = record + self._unreadable.discard(self.directory / (record['card']['id'] + '.json')) self.changed.notify_all() def _record(self, identity): @@ -411,7 +420,11 @@ def list(self, *, q=None, kind=None, origin=None, production=None, limit=50): and (not production or production in c['used_by'] or c['made_for'] == production) and (not text or text in c['name'].casefold() or text in (c['description'] or '').casefold())] rows.sort(key=lambda c: (c['updated_at'], c['id']), reverse=True) - return dict(cards=[row_view(c) for c in rows[:limit]], total=len(rows)) + unreadable = 0 + if production is None: + quarantined = {path for path in self.directory.glob('*.json.bad-*') if path.is_file()} + unreadable = len(quarantined | {path for path in self._unreadable if path.exists()}) + return dict(cards=[row_view(c) for c in rows[:limit]], total=len(rows), unreadable=unreadable) def update(self, identity, value, *, by='builtin', ref=None, revision=None, local=False, client=None): shape = schema.cast_schema('update', local=local) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 8f05a205..dd4dccc4 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -66,7 +66,7 @@ 'chain_unavailable': (503, True, 'The continuous generation backend is unavailable.', 'Start FreeVideo with the continuous generation backend enabled, then retry.'), 'chain_missing': (409, True, 'The saved chain receipt is unavailable.', 'Restore the chain receipt and retry resume, or submit a new job.'), 'join_failed': (409, True, 'The generated blocks could not be joined; completed blocks are retained.', 'POST /freevideo/agent/v1/jobs/{id}/resume to retry joining the completed blocks.'), - 'chain_too_long': (422, False, 'This video can have up to 5 parts. Make it shorter.', 'Submit a new job with a smaller seconds; continue_from counts the parts of the video it continues.'), + 'chain_too_long': (422, False, 'One continuous shot can have up to 8 parts. Make it shorter.', 'Submit a new job with a smaller seconds, or cut to a new shot with a new job without continue_from; continue_from counts the parts of the video it continues.'), 'chain_rejected': (409, False, 'The continuous backend rejected this generation.', 'Read GET /freevideo/agent/v1/capabilities and submit a supported generation request.'), 'invalid_wait': (422, False, 'wait must be a finite nonnegative number of seconds.', 'Use wait=0 or wait=25; larger valid values are clamped to 25 seconds.'), 'invalid_resume': (422, False, 'resume accepts an empty object or an optional ref only.', 'Send {} or {"ref":"resume-1"}; submit a new job to change generation parameters.'), diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 1ac71bdb..4bada091 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -246,8 +246,8 @@ 'Cut by default. Wherever the film can change shot, make the new shot from the reference pictures it should ' 'have (cast cards, scene pictures) instead of continuing the previous video. Continue a video only for a ' 'long take that cannot be cut, an action that must stay unbroken, or sound that must stay unbroken, and ' - 'continuing again and again is not recommended. One video has at most 5 parts (the first and 4 ' - 'continuations), each continued part at least 10 seconds, about 70 seconds in all; filling that is not ' + 'continuing again and again is not recommended. One video has at most 8 parts (the first and 7 ' + 'continuations), each continued part at least 10 seconds, about 110 seconds in all; filling that is not ' 'recommended. Past it, cut to a new shot.', 'A question is not a request. When the user asks about progress or status ("is it working?", "what happened?") ' 'answer it in one sentence from what you know or from the job and white-model status, and start nothing; never ' @@ -282,7 +282,7 @@ '先用 people(角色图和各自的位置)做出首帧,再用它生成视频。', '默认切镜头。能切镜头的地方就切:新镜头直接用对应的参考图(人物卡、场景图)生成,不接上一段视频。' '只有不能切镜的长镜头、必须连贯的动作、必须连贯的声音,才接着上一段生成,而且不建议一段接一段地连续接。' - '一条视频最多 5 段(第一段加 4 次接着生成),接着生成的每一段至少 10 秒,总长约 70 秒;不建议接满,超过就切镜头。', + '一条视频最多 8 段(第一段加 7 次接着生成),接着生成的每一段至少 10 秒,总长约 110 秒;不建议接满,超过就切镜头。', '问话不是要求。用户问进度或状态(“在做吗”“怎么了”)时,用一句话照实回答,依据是你所知道的、任务和白模的状态,' '什么都不要开始;不要把问句当提示词。某条 inbox 带有 whitebox 时,说明用户刚启动了白模场景,' '它还在准备或已经失败:照实说是哪一种,再等用户回话。', @@ -568,7 +568,7 @@ def connection_text(value): 'before uploading. Unknown fields are rejected and every batch is validated before it is recorded.', 'POST /plan gives the length supported for the current size and quality. Without continuation, only a ' 'single block can run; unknown device limits use 15 s. Multiple blocks return continuation_unavailable.', - 'One video has at most 5 parts (60 s in one request). continue_from counts the parts of the video it ' + 'One video has at most 8 parts (60 s in one request). continue_from counts the parts of the video it ' 'continues; plan and submit refuse a longer one and name the longest seconds that still fits.', 'features in the status reply names what this installation offers; false means not usable now (for example ' 'chain.auto while continuation is false), and a missing name means not offered here.', diff --git a/freevideo_engine/agent/jobs.py b/freevideo_engine/agent/jobs.py index aad1a92e..827e42b8 100644 --- a/freevideo_engine/agent/jobs.py +++ b/freevideo_engine/agent/jobs.py @@ -480,6 +480,7 @@ def __init__(self, port, directory, *, clock=now): self.directory = Path(directory) self.clock = clock self.jobs = {} + self.unreadable = 0 self.lock = threading.RLock() self._receipts = _ReceiptWrites(self.directory) if windows() else None self.cpu = threading.Semaphore(1) @@ -507,28 +508,68 @@ def __init__(self, port, directory, *, clock=now): # Records ------------------------------------------------------------------------------------------- def load(self): + self.unreadable = 0 if not self.directory.is_dir(): return # One atomic batch receipt is the commit point. Later per-job records # override its initial rows. A crash never exposes a partial submission. initial = {} + unreadable_ids, unreadable_batches = set(), set() + + def readable(row): + return (isinstance(row, dict) and specs.JOB_ID.fullmatch(str(row.get('id'))) + and row.get('kind') in ('generate', 'assemble', 'export', 'listen', 'image_edit') + GPU_KINDS + and row.get('status') in ACTIVE + DONE and isinstance(row.get('spec'), dict) + and type(row.get('created_at')) in (int, float) and math.isfinite(row['created_at']) + and (self._receipts is None or (type(row.get('_receipt_revision', 0)) is int + and row.get('_receipt_revision', 0) >= 0))) + for path in sorted(self.directory.glob('b*.json')): + if not re.fullmatch(r'b[0-9a-f]{32}', path.stem): + continue try: batch = json.loads(path.read_text(encoding='utf-8')) - initial.update({r['id']: r for r in batch['jobs']}) + if not isinstance(batch, dict) or not isinstance(batch.get('jobs'), list): + raise ValueError + except FileNotFoundError: + continue except (OSError, ValueError, KeyError, TypeError): + unreadable_batches.add(path) + logging.getLogger(__name__).warning('Skipped an unreadable batch file') continue + bad = False + for row in batch['jobs']: + if readable(row): + initial[row['id']] = row + else: + bad = True + identity = row.get('id') if isinstance(row, dict) else None + if isinstance(identity, str) and specs.JOB_ID.fullmatch(identity): + unreadable_ids.add(identity) + else: + # No task id can be recovered: count this receipt once. + unreadable_batches.add(path) + if bad: + logging.getLogger(__name__).warning('Skipped an unreadable batch file') records = list(initial.values()) for path in sorted(self.directory.glob('j*.json')): + if not specs.JOB_ID.fullmatch(path.stem): + continue try: - records.append(json.loads(path.read_text(encoding='utf-8'))) - except (OSError, ValueError): + row = json.loads(path.read_text(encoding='utf-8')) + if not readable(row) or row['id'] != path.stem: + raise ValueError + records.append(row) + except FileNotFoundError: continue + except (OSError, ValueError): + unreadable_ids.add(path.stem) + logging.getLogger(__name__).warning('Skipped an unreadable job file') if self._receipts is not None: for path in sorted(self.directory.glob('j*.pending')): try: row = json.loads(path.read_text(encoding='utf-8')) - if isinstance(row, dict) and path.name.startswith(str(row.get('id')) + '.'): + if readable(row) and path.name.startswith(str(row.get('id')) + '.'): records.append(row) except (OSError, ValueError): continue # An interrupted staging write is not a receipt. @@ -545,10 +586,7 @@ def load(self): latest[row['id']] = row records = list(latest.values()) for row in records: - if (isinstance(row, dict) and specs.JOB_ID.fullmatch(str(row.get('id'))) - and row.get('kind') in ('generate', 'assemble', 'export', 'listen', 'image_edit') + GPU_KINDS - and row.get('status') in ACTIVE + DONE and isinstance(row.get('spec'), dict) - and type(row.get('created_at')) in (int, float) and math.isfinite(row['created_at'])): + if readable(row): if row.get('status') == 'running' and row['kind'] in ('assemble', 'export', 'listen', 'image_edit') + GPU_KINDS: # This process restarted while the work ran in it; nothing continues it. self.finish(row, 'failed', error=dict(code='interrupted', @@ -564,6 +602,7 @@ def load(self): # Before device-aware Agent planning, every accepted job used the unknown limit. row['spec'].setdefault('single_max_frames', None) self.jobs[row['id']] = row + self.unreadable = len(unreadable_batches) + len(unreadable_ids - self.jobs.keys()) for row in self.jobs.values(): if (row['kind'] != 'generate' or row['spec'].get('refine_from') or row['status'] in DONE and not row.get('resume_pending') diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index a7766712..3be38e26 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -1111,7 +1111,7 @@ def read(): order = (finished if finished is not None else job['created_at'], job['created_at'], job['id']) if sid not in covers or order < covers[sid][0]: covers[sid] = (order, work) - return dict(agent=agent_state(), here=here, + return dict(agent=agent_state(), here=here, unreadable=store.unreadable if store is not None else 0, conversations=[dict(row, running=running.get(row['id'], 0), cover='/freevideo/library/thumbnail?id=' + quote(covers[row['id']][1], safe='') if row['id'] in covers else None) for row in rows]) @@ -1195,13 +1195,15 @@ def read(): rows = sorted(jobs.rows(), key=lambda r: (r['created_at'], r['id']), reverse=True) active = sum(1 for r in rows if r['status'] in ACTIVE) cursor = request.query.get('cursor') + unreadable = jobs.unreadable if cursor is None else 0 if cursor is None: shown = [r for r in rows if r['status'] in ACTIVE or (r.get('finished_at') or 0) > now - 1800][:12] after = shown[-1] if shown else None older = [r for r in rows if after is None or (r['created_at'], r['id']) < (after['created_at'], after['id'])] if after is None and older: # Nothing recent: history starts from the newest job. - return dict(active=active, jobs=[], next_cursor=_activity_cursor({'created_at': older[0]['created_at'] + 1, 'id': ''})) + return dict(active=active, jobs=[], unreadable=unreadable, + next_cursor=_activity_cursor({'created_at': older[0]['created_at'] + 1, 'id': ''})) else: try: stamp, _, ident = cursor.partition(':') @@ -1212,7 +1214,7 @@ def read(): return None page = [r for r in rows if (r['created_at'], r['id']) < key] shown, older = page[:30], page[30:] - return dict(active=active, jobs=[activity_item(r) for r in shown], + return dict(active=active, jobs=[activity_item(r) for r in shown], unreadable=unreadable, next_cursor=_activity_cursor(shown[-1]) if shown and older else None) value = await asyncio.to_thread(read) return problem('invalid_request', 'Invalid cursor') if value is None else reply(value) diff --git a/freevideo_engine/agent/sessions.py b/freevideo_engine/agent/sessions.py index 064402b5..432acc11 100644 --- a/freevideo_engine/agent/sessions.py +++ b/freevideo_engine/agent/sessions.py @@ -59,6 +59,7 @@ def __init__(self, root: Path, *, clock=time.time, max_events=20000): self.lock = threading.Lock() self.on_stop = [] # HTTP invokes these after persisting a user stop. self._meta = {} + self._unreadable = set() self._ids = {} self._clients = {} self._initial_seq = {} @@ -95,28 +96,31 @@ def _load(self): return for path in paths: sid = path.stem - if schema.issues(schema.SESSION, sid) or path.is_symlink(): + if schema.issues(schema.SESSION, sid): continue try: + if path.is_symlink(): + continue self._load_file(path, sid) - except OSError: + except (OSError, ValueError, UnicodeDecodeError, SessionError, AttributeError) as error: # One unreadable file must not stop the agent from starting. for table in (self._meta, self._ids, self._clients, self._acted, self._said, self._initial_seq, self._user_seq): table.pop(sid, None) self._chosen.discard(sid) self._partial.discard(sid) - logging.getLogger(__name__).warning('Skipped an unreadable session file') + if not isinstance(error, FileNotFoundError): + self._unreadable.add(sid) + logging.getLogger(__name__).warning('Skipped an unreadable session file') def _load_file(self, path, sid): with path.open('rb') as stream: first = stream.readline() - try: - meta = json.loads(first) - if not first.endswith(b'\n') or meta.get('session') != sid: - return - _check(schema.session_metadata(), meta) - except (ValueError, UnicodeDecodeError, SessionError, AttributeError): - return + if not first.endswith(b'\n'): + return # Creation has not committed a complete header yet. + meta = json.loads(first) + if meta.get('session') != sid: + raise _invalid('/session', 'the session id of the saved file') + _check(schema.session_metadata(), meta) self._meta[sid] = dict(id=sid, title=meta['title'], created_at=meta['created_at'], updated_at=meta['created_at'], client=meta['client'], last_seq=0, stopped=False) self._ids[sid] = {} @@ -138,6 +142,12 @@ def _load_file(self, path, sid): continue self._fold(sid, event) self._initial_seq[sid] = self._meta[sid]['last_seq'] + self._unreadable.discard(sid) + + @property + def unreadable(self): + with self.lock: + return len(self._unreadable) def _fold(self, sid, event): meta = self._meta[sid] @@ -194,6 +204,7 @@ def create(self, *, title=None, client=None, by='agent') -> dict: self._ids[sid], self._clients[sid] = {}, {client} if client else set() self._initial_seq[sid] = 0 self._acted[sid] = (meta['created_at'], 1) + self._unreadable.discard(sid) if by == 'user': self._chosen.add(sid) return deepcopy(self._meta[sid]) diff --git a/freevideo_engine/chain_plan.py b/freevideo_engine/chain_plan.py index d7cf593f..5eb36f56 100644 --- a/freevideo_engine/chain_plan.py +++ b/freevideo_engine/chain_plan.py @@ -35,11 +35,14 @@ def _first_frames(seconds): return geometry(seconds=seconds)['frames'] -def plan(seconds, single=False, max_frames=None, single_limit_seconds=None, block_seconds=None, continued=False): +def plan(seconds, single=False, max_frames=None, single_limit_seconds=None, block_seconds=None, continued=False, max_parts=None): """`max_frames`: the most frames this device generates at once for the current settings (unknown: 15 s). `continued`: the video goes on from a saved one, so its first block too spends OVERLAP frames on what came before and adds only the rest. + + `max_parts`: the most parts one video may have. A new video that needs more is refused here, before anything + is submitted; a continued one adds to parts this planner does not know, so the server checks it on submission. """ _number(seconds, 'Length') if type(single) is not bool: @@ -104,6 +107,8 @@ def plan(seconds, single=False, max_frames=None, single_limit_seconds=None, bloc raise PlanError('length_not_splittable', 'This length cannot be cut into parts of the requested length') if count > MAX_BLOCKS: raise PlanError('too_long', 'This length needs too many blocks') + if max_parts is not None and not continued and count + 1 > max_parts: + raise PlanError('too_many_parts', 'One continuous shot can have up to %d parts. Make it shorter.' % max_parts) lengths = [whole // count + (index < whole % count) for index in range(count)] if continued: lengths[0] = round(lengths[0] - (whole - total), 6) # a fractional request keeps its length in the first part diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index 4d400c05..37439109 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -22,9 +22,9 @@ class ChainTooLong(ValueError): code = 'chain_too_long' def __init__(self, parts, limit): - super().__init__('This video can have up to %d parts. Make it shorter.' % limit) + super().__init__('One continuous shot can have up to %d parts. Make it shorter.' % limit) self.parts, self.limit = parts, limit - self.message_zh = '这条视频最多 %d 段,请缩短一些。' % limit + self.message_zh = '一个连续镜头最多 %d 段,请缩短。' % limit class ChainPartTooShort(ValueError): diff --git a/freevideo_engine/chain_store.py b/freevideo_engine/chain_store.py index 5722e142..d7e536f4 100644 --- a/freevideo_engine/chain_store.py +++ b/freevideo_engine/chain_store.py @@ -26,8 +26,8 @@ 'unavailable': 'Splitting is not available here, so every part used the whole description.', 'failed': 'Splitting the description failed, so every part used the whole description.', } -# Parts one video may be continued through: the first part and four continuations. -MAX_PARTS = 5 +# Parts one video may be continued through: the first part and seven continuations. +MAX_PARTS = 8 def reference(value, queue=False): diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py index 482e6813..aa4b5cb2 100644 --- a/freevideo_engine/comfy_chain.py +++ b/freevideo_engine/comfy_chain.py @@ -333,7 +333,7 @@ async def plan(request): value = await body(request) result = chain_plan.plan( value.get('seconds'), value.get('single', False), value.get('max_frames'), - value.get('single_limit_seconds'), value.get('block_seconds')) + value.get('single_limit_seconds'), value.get('block_seconds'), max_parts=runner.max_parts) if not result['single'] and not continuation_supported(): # A device that cannot continue a video has one block to give: never a plan in parts. raise chain_plan.PlanError('device_limit', 'This device cannot generate this length at this size') diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index 55287b00..281fc84e 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -503,7 +503,7 @@ def save(output_directory, record, create=None, operation=None): return record -def _projects(root, *, strict=False): +def _projects(root, *, strict=False, unreadable=None): folder = _editor_path(root, 'projects') if not folder.exists(): return [] @@ -517,9 +517,14 @@ def _projects(root, *, strict=False): record = _read_project(root, path.name) info = _project_path(root, path.name).stat() records.append((info.st_mtime_ns, path.name, record, info)) + except FileNotFoundError: + if strict: + raise ValueError('Editor references unavailable') from None except (OSError, ValueError, TypeError, RecursionError): if strict: raise ValueError('Editor references unavailable') from None + if unreadable is not None: + unreadable.append(path.name) return sorted(records, key=lambda row: row[:2], reverse=True) @@ -578,10 +583,11 @@ def duration(record): return max((clip['start'] + clip['frames'] for track in record['tracks'] for clip in track['clips']), default=0) -def list_projects(root): +def list_projects(root, unreadable=None): + """Readable projects, newest first; damaged records are named in the optional `unreadable` list.""" rows = [] with _STATE: - records = _projects(root) + records = _projects(root, unreadable=unreadable) for _, identity, record, info in records: first = next(iter(record['tracks'][0]['clips']), None) cover = sources(root, [source_key(first['source'])]) if first and first['source'] else {} @@ -829,7 +835,9 @@ def _import_update(root, identity, **fields): return value -def list_imports(root): +def list_imports(root, unreadable=None): + """Imports, newest first; damaged records are named in the optional `unreadable` list. A folder whose + record was never written is an upload that did not finish, not a damaged one.""" folder = _editor_path(root, 'imports') if not folder.exists(): return [] @@ -850,8 +858,11 @@ def list_imports(root): row['preview'] = '/freevideo/editor/media?' + urlencode(dict(id=child.name, file='preview')) if value['state'] == 'ready' else None row['thumbnail'] = '/freevideo/editor/media?' + urlencode(dict(id=child.name, file='thumbnail')) if value['state'] == 'ready' else None rows.append((stamp, child.name, row)) - except (OSError, ValueError, TypeError): + except FileNotFoundError: continue + except (OSError, ValueError, TypeError): + if unreadable is not None: + unreadable.append(child.name) return [row for _, _, row in sorted(rows, key=lambda item: item[:2], reverse=True)] @@ -1269,7 +1280,9 @@ def root(): @endpoint('get', '/freevideo/editor/projects') async def projects(request): - return web.json_response(dict(items=await asyncio.to_thread(list_projects, root())), headers=_HEADERS) + unreadable = [] + items = await asyncio.to_thread(list_projects, root(), unreadable) + return web.json_response(dict(items=items, unreadable=len(unreadable)), headers=_HEADERS) @endpoint('get', '/freevideo/editor/project') async def project(request): @@ -1353,7 +1366,9 @@ async def upload(request): @endpoint('get', '/freevideo/editor/imports') async def imports(request): - return web.json_response(dict(items=await asyncio.to_thread(list_imports, root())), headers=_HEADERS) + unreadable = [] + items = await asyncio.to_thread(list_imports, root(), unreadable) + return web.json_response(dict(items=items, unreadable=len(unreadable)), headers=_HEADERS) @endpoint('get', '/freevideo/editor/waveform') async def peaks(request): diff --git a/freevideo_engine/comfy_progress.py b/freevideo_engine/comfy_progress.py index 51da6f5a..d51268d0 100644 --- a/freevideo_engine/comfy_progress.py +++ b/freevideo_engine/comfy_progress.py @@ -2,6 +2,8 @@ from copy import deepcopy from pathlib import Path import heapq +import json +import os import threading import time import uuid @@ -173,6 +175,7 @@ class ReportDownloads: """Only server-registered runs are readable; clients never supply a path.""" def __init__(self): self.runs = {} + self.roots = set() self.lock = threading.Lock() def register(self, output): @@ -181,11 +184,59 @@ def register(self, output): self.runs[token] = Path(output) while len(self.runs) > 64: del self.runs[next(iter(self.runs))] + self._remember(token, Path(output)) return token - def snapshot(self, token): + def _lookup(self, token): with self.lock: output = self.runs.get(token) + if output is None and isinstance(token, str) and len(token) == 32 and token.isalnum(): + output = self._recall(token) + return output + + @staticmethod + def _index(output): + # The run folder is /FreeVideo//; any other layout is not persisted. + root = output.parent.parent.parent + return (root / '.agent' / 'report-ids.json') if root.name == 'FreeVideo' else None + + def _remember(self, token, output): + """Keep the token readable after the service restarts: a failed run's report is otherwise unreachable.""" + index = self._index(output) + if index is None: + return + try: + try: + rows = json.loads(index.read_text('utf-8')) + except (OSError, ValueError): + rows = {} + rows = {key: value for key, value in (rows if isinstance(rows, dict) else {}).items() + if isinstance(value, str)} + self.roots.add(index.parent.parent) + rows[token] = output.relative_to(index.parent.parent).as_posix() + while len(rows) > 256: + del rows[next(iter(rows))] + index.parent.mkdir(parents=True, exist_ok=True) + temporary = index.with_name('report-ids.%s.tmp' % uuid.uuid4().hex) + temporary.write_text(json.dumps(rows), 'utf-8') + os.replace(temporary, index) + except Exception: + pass # Reports stay readable in this process; the index is only a convenience. + + def _recall(self, token): + """The registered output of an earlier service, from the index beside its runs.""" + for root in self.roots: + try: + rows = json.loads((root / '.agent' / 'report-ids.json').read_text('utf-8')) + relative = rows.get(token) if isinstance(rows, dict) else None + if isinstance(relative, str) and relative and not Path(relative).is_absolute() and '..' not in Path(relative).parts: + return root / relative + except (OSError, ValueError): + continue + return None + + def snapshot(self, token): + output = self._lookup(token) if output is None: raise KeyError('Unknown report') from .support_report import write @@ -203,8 +254,7 @@ def preview(self, token): """The newest frame preview picture of a registered run, or KeyError.""" from .frame_preview import FILE from .diagnostics import is_link - with self.lock: - output = self.runs.get(token) + output = self._lookup(token) if output is None: raise KeyError('Unknown run') path = output.parent / FILE @@ -294,6 +344,11 @@ def register(): if server is None or getattr(server, '_freevideo_progress', False): return server._freevideo_progress = True + try: + import folder_paths + REPORTS.roots.add(Path(folder_paths.get_output_directory()).resolve() / 'FreeVideo') + except Exception: + pass # Without it only runs of this process (and their index folders) are found. report_lock = asyncio.Lock() @server.routes.get('/freevideo/report/{report_id}') diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py index 29e08a03..dedd74be 100644 --- a/freevideo_engine/diagnostics.py +++ b/freevideo_engine/diagnostics.py @@ -41,12 +41,13 @@ ABSOLUTE_PATH = re.compile( # The preceding character guard prevents URL slashes from being treated # as local paths while still matching a drive path after prose such as - # ``at C:\\Users\\...`` or a quoted traceback location. + # ``at C:\\Users\\...`` or a quoted traceback location. A slash followed by + # whitespace is prose (``8.0 / SM80``), not the start of a path. # Consume explicit relative paths too, spaces included, so an interior # slash (after e.g. a hyphen) cannot subsequently be mistaken for an # absolute path. Only our own ``./setup.sh`` command stays as written. r'''(?\./setup\.sh(?![\w/\\-]|\.\w))|\.{1,2}/[^"'<>;\r\n,\)\]}]*|''' - r'''(?:(?:[A-Za-z]:[\\/])|(?:\\\\[^\\/\s"'<>]+[\\/])|/(?!/))[^"'<>;\r\n,\)\]}]*)''') + r'''(?:(?:[A-Za-z]:[\\/])|(?:\\\\[^\\/\s"'<>]+[\\/])|/(?![/\s]))[^"'<>;\r\n,\)\]}]*)''') class Redactor: diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index 7a5a3c2a..b7826938 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -558,9 +558,10 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('Update the NVIDIA driver to version 580 or newer, restart the computer, then click "Retry installation".', '请将 NVIDIA 显卡驱动更新到 580 或更新版本,重启电脑后点击“重试安装”。')), (r'CUDA_ARCH_UNSUPPORTED', 'gpu-architecture', - ('This GPU lacks the required compute support', '这张显卡不满足当前计算要求'), - ('The detected GPU and compute capability are shown below. The CUDA runtime requires native BF16 computation (SM80 or newer). Use a compatible GPU; changing the driver does not change its hardware capability.', - '下方是这张显卡的信息。FreeVideo 需要较新的显卡,更新驱动无法解决。')), + ('This GPU is not supported', '这张显卡不受支持'), + ('FreeVideo requires a GPU with compute capability 8.0 or higher, such as an NVIDIA GeForce RTX 30 series card or newer. ' + 'This requirement does not depend on the amount of VRAM, and a driver update cannot change it.', + 'FreeVideo 需要计算能力 8.0 及以上的显卡,例如 NVIDIA GeForce RTX 30 系列及更新型号。这一要求与显存大小无关,更新驱动也无法改变。')), (r'CUDA_CAPABILITY_UNKNOWN', 'gpu-detection', ('GPU information could not be read', '未能读取显卡信息'), ('Retry hardware detection. If it still fails, export the report; it includes the GPU identity and the raw driver query.', @@ -666,6 +667,12 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep # The launcher's failure card shows the action only when there is no detail, so the # instruction goes under the detected driver instead of being hidden by it. return dict(title=title[zh], detail=detail + '\n' + action[zh], action='', kind=kind) + if kind == 'gpu-architecture': + found = re.search(r'\[CUDA_ARCH_UNSUPPORTED\]\s+(.+?)\s+\(SM\w+; compute capability ([\d.]+);', text) + if found: + model, capability = found.groups() + detail = ('检测到:%s,计算能力 %s' if zh else 'Detected: %s, compute capability %s') % (model, capability) + return dict(title=title[zh], detail=detail + '\n' + action[zh], action='', kind=kind) return dict(title=title[zh], detail=detail, action=action[zh], kind=kind) # Validation messages are already actionable. Show the cause, not a stack # trace or a guessed hardware diagnosis, and leave long details expandable. @@ -794,6 +801,73 @@ def runtime_explanation(run, repair=None): ANSI = re.compile(r'\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))') +PLAIN_FIELD = re.compile(r'[^\w .+()/-]') + + +def _plain(value, limit=40): + return PLAIN_FIELD.sub('', '' if value is None else str(value))[:limit].strip() + + +def _mac_run(report, run, redactor): + """The machine, size, length and settings of a Mac failure, and whether its stage was retried. + + One line of fixed fields; typed text is not part of it. The retried error is redacted whole + before it is shortened: a prompt or credential is only recognized in full. + """ + hardware = report.get('runtime_hardware') + if report.get('device_backend') != 'mps' or not isinstance(hardware, dict): + return '' + ram = hardware.get('ram_total') + machine = [_plain(hardware.get('gpu_name')) or 'Apple GPU'] + if type(ram) is int and ram > 0: + machine.append('%d GiB' % round(ram / 2**30)) + software = report.get('runtime_software') + if isinstance(software, dict): + for key, label in (('macos', 'macOS'), ('mlx', 'MLX'), ('torch', 'torch')): + if software.get(key): + machine.append('%s %s' % (label, _plain(software[key], 20))) + parts = [', '.join(machine)] + canvas, plan = report.get('geometry'), report.get('sampling_plan') + if isinstance(canvas, dict) and type(canvas.get('width')) is int and type(canvas.get('height')) is int: + size = '%dx%d' % (canvas['width'], canvas['height']) + if isinstance(canvas.get('seconds'), (int, float)): + size += ' %.1f s' % canvas['seconds'] + first = plan.get('first') if isinstance(plan, dict) and plan.get('enabled') is True else None + if isinstance(first, dict) and type(first.get('width')) is int and type(first.get('height')) is int: + size += ' (first pass %dx%d)' % (first['width'], first['height']) + parts.append(size) + if isinstance(plan, dict) and type(plan.get('base_steps')) is int: + two = plan.get('enabled') is True and type(plan.get('refine_steps')) is int + parts.append('two-pass %d+%d' % (plan['base_steps'], plan['refine_steps']) if two else '%d steps' % plan['base_steps']) + engine = (report.get('profile') or {}).get('engine') if isinstance(report.get('profile'), dict) else None + if isinstance(engine, dict) and engine.get('task'): + parts.append(_plain(engine['task'])) + if report.get('step_cache'): + parts.append('step cache ' + _plain(report['step_cache'])) + metrics = _read(Path(run) / 'video.engine.json', 512*1024) + try: + metrics = json.loads(metrics) + except ValueError: + metrics = {} + retries = metrics.get('memory_retries') if isinstance(metrics, dict) else None + row = retries[0] if isinstance(retries, list) and retries and isinstance(retries[0], dict) else None + if row is None: + parts.append('not retried') + else: + stages = metrics.get('stages') if isinstance(metrics.get('stages'), dict) else {} + key = row.get('phase') + joined = stages.get(key) + if (key == 'first-pass' and isinstance(joined, dict) and joined.get('joined_sampling') is True + and joined.get('success') is False and joined.get('sampling_phase') in ('upscale', 'refinement')): + # A joined child failed in a later pass; the retry ran that pass on its own. + key = joined['sampling_phase'] + stage = stages.get(key) + passed = isinstance(stage, dict) and stage.get('success') is True + error = (redactor.text(str(row.get('error_message') or '')).strip().splitlines() or [''])[0][:160].replace('"', "'") + parts.append('%s retried once after "%s"; the retry %s' % (_plain(key, 20), error, 'passed' if passed else 'failed too')) + return 'Run: ' + '; '.join(parts) + + def generation_failure(run, exit_code=None, *, repair=None): """Surface the cause, including a child traceback, even without a report. @@ -885,6 +959,9 @@ def generation_failure(run, exit_code=None, *, repair=None): lines.insert(1, 'Cause: ' + causes[-1][:300]) if len(lines) == 1: lines.append('The worker ended without a readable error report.') + summary = _mac_run(report, run, redactor) + if summary: + lines.insert(2 if promoted else 1, summary) lines.append('\nRetained outputs: ' + str(run)) # The table failure can be in a retained child log rather than the report. # Keep its numeric metadata ahead of technical output for all interactive callers. diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 3d79a00e..ff599ceb 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -104,8 +104,11 @@ def child(command, env, descriptor, log, *, ram_budget_bytes=None, gpu=None, on_ except BaseException as stop_error: # A stop timeout is not a controlled timeout. The worker may # still own CUDA; never convert this into an ordinary retry. - error.worker_stop_confirmed = False - error.worker_stop_error = repr(stop_error) + # A worker observed gone afterwards owns nothing, whatever the + # signal call reported while it was exiting. + if not processes.gone(process): + error.worker_stop_confirmed = False + error.worker_stop_error = repr(stop_error) error.telemetry = telemetry raise finally: diff --git a/freevideo_engine/macos_generate.py b/freevideo_engine/macos_generate.py index ccd5ebf4..85dd616e 100644 --- a/freevideo_engine/macos_generate.py +++ b/freevideo_engine/macos_generate.py @@ -143,7 +143,7 @@ def interrupted(signum, frame): resource = policy(args) hardware = inventory()['hardware'] resource['hardware'] = hardware - report.update(runtime_hardware=hardware, runtime_code=code_identity()) + report.update(runtime_hardware=hardware, runtime_code=code_identity(), runtime_software=runtime_software()) print(json.dumps(dict(event='compute_device', backend='MPS', name=hardware['gpu_name'], memory_model='unified', ram_total_bytes=hardware['ram_total'])), flush=True) from .continuation import validate_frames, summary as continuation_summary @@ -342,7 +342,7 @@ def execute(value, name, module='freevideo_engine.macos_generate'): if hide_tail_frames: engine['hide_tail_frames'] = hide_tail_frames if getattr(args, 'step_cache', None) == 'easycache-av': - engine['step_cache'] = args.step_cache + engine['step_cache'] = report['step_cache'] = args.step_cache if output_size: engine['output_size'] = [output_size['width'], output_size['height']] if preview: @@ -372,6 +372,17 @@ def execute(value, name, module='freevideo_engine.macos_generate'): write_debug(output) +def runtime_software(): + """The system and library versions a Metal failure depends on.""" + versions = dict(macos=platform.mac_ver()[0]) + for name in ('mlx', 'torch'): + try: + versions[name] = importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + versions[name] = None + return versions + + def worker(value): from .macos_bootstrap import require_native require_native() diff --git a/freevideo_engine/macos_stages.py b/freevideo_engine/macos_stages.py index 8f5dffc4..25e77633 100644 --- a/freevideo_engine/macos_stages.py +++ b/freevideo_engine/macos_stages.py @@ -39,11 +39,15 @@ def read_receipt(receipt): return observed +METAL_FAILURE = '[METAL] Command buffer execution failed' + + def memory_exhausted(stage, observed): - """A sampling pass that stopped because MPS reached its allocator limit.""" + """A sampling pass that stopped because MPS reached its allocator limit or Metal failed a command buffer.""" # The physical RAM floor protects the whole machine, and the outer guard also ends the request. + message = str(observed.get('error_message', '')) return (stage in ('first-pass', 'refinement') and observed.get('success') is False - and 'MPS backend out of memory' in str(observed.get('error_message', ''))) + and ('MPS backend out of memory' in message or METAL_FAILURE in message)) def joined_admission(engine, value): @@ -195,7 +199,8 @@ def interrupted(signum, frame): if retry or not memory_exhausted(stage, failed): raise # Unified memory taken by other applications can leave a - # sampling pass short of its plan. Retry it once in a fresh + # sampling pass short of its plan, and Metal can fail a command + # buffer without naming memory. Retry it once in a fresh # process with the smallest grouped-projection partitions. # Keeping a saved first pass avoids changing its latents # with those new partitions. @@ -210,12 +215,14 @@ def interrupted(signum, frame): and len(rows[0]['step_seconds']) == sampling['base_steps']): retain_joined_progress(result, failed) retained_joined = failed + metal = METAL_FAILURE in str(failed.get('error_message', '')) result.setdefault('memory_retries', []).append(dict(phase=stage, + kind='metal_command_buffer' if metal else 'unified_memory', error_type=failed.get('error_type'), error_message=failed.get('error_message'), compute=failed.get('compute'), process_seconds=time.monotonic() - tick)) print(json.dumps(dict(event='resource_retry', retry=dict( failed_phase='refinement' if stage == 'refinement' or retained_joined else 'sampling', - kind='unified_memory', attempt=2, max_attempts=2, + kind='metal_command_buffer' if metal else 'unified_memory', attempt=2, max_attempts=2, reuse_first_pass=stage == 'refinement' or bool(retained_joined)))), flush=True) if retained_joined: break diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index f52ee983..01d4b231 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -117,7 +117,31 @@ # 960x544x243 went from head 4 with one block (10.37 s/step, 4.68 GiB) to # head 8 with none (8.43 s, 4.18 GiB). WINDOWS_INT8_HEAD8_TOKENS = 72576 +# Native FP8 (Ada and Blackwell) takes the same rule up to a two-pass first +# pass. On a Blackwell card held to an 8 GiB laptop's 6.48 GiB allocator limit +# with the Windows allocator (no expandable segments), 832x448x243 (26208 rows) +# with two resident blocks and the second slot peaked 4.74 GiB reserved with +# four heads and 5.03 with eight, 0.29 GiB against the 0.42 the rule charges at +# this length. +# Full-resolution FP8 stays on four heads until it is measured. +WINDOWS_FP8_HEAD8_TOKENS = 4 * 9072 WINDOWS_INT8_HEAD8_EXTRA = int(1.15 * GiB) +# Beyond that row count the four-head plan stayed on 12 GB cards although the +# budget covers eight heads. Reserved peak of eight heads and no resident +# block, complete requests with the allocator capped at 10.48 GiB and the +# Windows allocator emulated (no expandable segments), 1344x768: +# +# 72576 rows 7.04 GiB 97776 rows 9.40 107856 rows 10.23 +# +# Four heads with two blocks at 97776 rows reserved 8.81 here and 9.31 under +# Windows on an RTX 3060, so 0.5 GiB is added for the Windows allocator and +# compositor. Eight heads charge the larger of the figure below and the plan at +# 72576 rows, which keeps the requirement continuous there and never lower than +# before. Frames do not matter, only rows: the figures come from 243, 328 and +# 360 frames. +WINDOWS_INT8_LONG_HEAD8_TOKENS = 107856 +WINDOWS_INT8_LONG_HEAD8_BASE = int(7.54 * GiB) +WINDOWS_INT8_LONG_HEAD8_PER_ROW = .0937 * GiB / 1000 WINDOWS_INT8_HEAD8_AMPERE_EXTRA = int(.3 * GiB) # Below that band an 8 GiB card stages the residual stream, and the staged # pass kept four heads. With the Windows allocator (no expandable segments) @@ -1170,15 +1194,27 @@ def held(group): and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and effective_tokens <= WINDOWS_SMALL_HEAD8_TOKENS) - windows_int8_full = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] + windows_int8_full = (desktop and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES - and WINDOWS_SMALL_HEAD8_TOKENS < effective_tokens <= WINDOWS_INT8_HEAD8_TOKENS) + and WINDOWS_SMALL_HEAD8_TOKENS < effective_tokens + <= (WINDOWS_INT8_HEAD8_TOKENS if int8 else 0 if ampere else WINDOWS_FP8_HEAD8_TOKENS)) windows_int8_staged_head8 = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] and effective_tokens is not None and canvas['frames'] <= WINDOWS_SMALL_HEAD8_FRAMES and effective_tokens <= WINDOWS_INT8_STAGED_HEAD8_TOKENS) + windows_int8_long = (desktop and int8 and not lora and legs == ['sage2', 'sage2'] + and effective_tokens is not None + and not (canvas.get('reference_video_tokens') or canvas.get('reference_audio_tokens')) + and WINDOWS_INT8_HEAD8_TOKENS < effective_tokens <= WINDOWS_INT8_LONG_HEAD8_TOKENS) reserves = SMALL_ACTIVATION_RESERVES + if windows_int8_long: + four = dict(SMALL_ACTIVATION_RESERVES)[4] + wanted = max(four + WINDOWS_INT8_HEAD8_EXTRA, + WINDOWS_INT8_LONG_HEAD8_BASE + + int(WINDOWS_INT8_LONG_HEAD8_PER_ROW * (effective_tokens - WINDOWS_INT8_HEAD8_TOKENS))) + reserves = tuple((heads, max(need, wanted - small_adjustment) if heads == 8 else need) + for heads, need in reserves) if windows_int8_full: four = dict(SMALL_ACTIVATION_RESERVES)[4] extra = int(WINDOWS_INT8_HEAD8_EXTRA * effective_tokens / WINDOWS_INT8_HEAD8_TOKENS) @@ -1195,7 +1231,7 @@ def held(group): selected = (head, activation_bytes(head, effective_tokens) - GPU_ATTENTION_OUTPUT_BYTES) else: available = [(heads, need) for heads, need in reserves - if heads <= 4 or not desktop or ((windows_small_head8 or windows_int8_full) + if heads <= 4 or not desktop or ((windows_small_head8 or windows_int8_full or windows_int8_long) and gpu_budget >= need + small_adjustment + GPU_ATTENTION_OUTPUT_BYTES)] selected = next(((heads, need + small_adjustment) for heads, need in available if gpu_budget >= need + small_adjustment), None) diff --git a/freevideo_engine/processes.py b/freevideo_engine/processes.py index b91069f5..54c3dcef 100644 --- a/freevideo_engine/processes.py +++ b/freevideo_engine/processes.py @@ -163,6 +163,17 @@ def popen(command, *, pass_fds=(), supervise=False, **kwargs): return subprocess.Popen(command, pass_fds=pass_fds, **kwargs) +def gone(process): + """The worker process no longer exists, checked directly rather than by a signal.""" + try: + if hasattr(process, 'created'): + from .resident_process import alive + return not alive(process.pid, process.created) + return process.poll() is not None + except BaseException: + return False + + def stop(process, grace=10): if hasattr(process, 'stop_request'): return process.stop_request(grace=grace) diff --git a/freevideo_engine/progress_timeline.py b/freevideo_engine/progress_timeline.py index 5b492431..bec0de27 100644 --- a/freevideo_engine/progress_timeline.py +++ b/freevideo_engine/progress_timeline.py @@ -127,6 +127,7 @@ 'Decoding audio', 'Decoding video tiles', 'Downloading extra model weights (one-time download)', + 'Extracting depth from the reference video', 'Encoding image/video references', 'Encoding input media', 'Encoding long references in smaller blocks', diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index dbfb4815..fee21a7c 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,33 +1,66 @@ { "schema": 1, - "product_version": "0.4.19", + "product_version": "0.4.20", "en": { - "summary": "Several pictures can be deleted from the image history at once; prompt enhancement states the reason when it cannot finish.", + "summary": "One continuous shot can now be split into up to 8 parts; generated pictures can be downloaded directly and the path of a video can be copied.", "changes": [ - "Install: setup no longer stops when some graphics card details cannot be read; command-line generation works on Windows when FreeVideo is installed in a very deep folder.", - "Image history: after Select, several pictures can be checked and deleted at once, with a second click to confirm; pictures that are in use or cannot be deleted stay in place with the reason shown.", - "Prompt enhancement: when enhancement cannot finish and the original prompt is used, the reason is shown; a rewrite that lacks the video or audio description is sent back to the model to add it.", - "Editor: Extend on a finished video without a description asks for a description first instead of showing an English validation error; when the description box is cleared, the description carried over for that segment is used.", - "The Linux package now includes curl, so installation and downloads work on systems without it.", - "Download list: the check no longer hashes every model file again each time it runs; files already hashed are skipped while their size, times and inode are unchanged.", - "Keeps the failed phase, step count, error type and memory readings of a GPU error that raised the compatibility level, so a diagnostic report sent after a later run still shows where it stopped.", - "The chat no longer shows the notice for an edited waiting message as an error with a report button; it is a plain status line." + "Long videos: one continuous shot can now be split into up to 8 parts instead of 5. The line below the duration in the creation panel states that each run makes one continuous shot and points to Edit or Autopilot for a storyboard; a length that needs more parts than allowed is stopped before it is submitted, and a refusal at submission is shown as a plain notice instead of an unknown error.", + "Reference video: fixed generation failing when a reference video is combined with a non-default upscale factor such as 1.5x.", + "Pictures and videos: picture cards in the Autopilot chat and the enlarged picture view have a Download button; results in the library and the creation panel have Copy path.", + "The desktop notification for a finished generation is now on by default: permission is requested once, the first time Generate is clicked; it stays off for anyone who has turned it off.", + "Edit: while the generation service has not answered, the generation bar stays visible with Generate dimmed; when the automatic checks get no answer, the status line shows \"Generation unavailable\" with a retry link, which checks again when clicked.", + "The edits, imports, characters, Agent conversations and Agent jobs lists now end with a line such as \"N edits could not be read\" when some records cannot be read.", + "Windows graphics cards with 8 to 12 GB of video memory: the first pass of FP8 two-pass videos on RTX 40 and 50 series cards, and the second pass of int8 videos longer than 10 seconds on 12 GB cards, now run attention 8 heads at a time instead of 4, within the planned memory budget. LoRA, Linux and Mac are unchanged. The same seed can give a slightly different video.", + "Retry after one-time errors: a second pass that stops with a one-time CUDA error resumes once from the finished first pass instead of failing or repeating it; on a Mac, a pass that stops with a Metal command-buffer error is retried once with smaller memory partitions. Failure reports name the machine, settings and retry result, and report ids stay retrievable after the service restarts.", + "Decoding the same request with the same seed now writes identical pixels every time. Compared with earlier versions, the same seed can give slightly different pixels.", + "The card shown for an unsupported graphics card now names the detected card and its compute capability, and states that FreeVideo needs compute capability 8.0 or higher (GeForce RTX 30 series or newer) regardless of VRAM. Error text no longer replaces a slash inside a sentence with a path placeholder." ] }, "zh": { - "summary": "生图历史可以一次删除多张;提示词增强未能完成时写明原因。", + "summary": "一个连续镜头最多可分 8 段;生成的图片可以直接下载,作品可以复制路径。", "changes": [ - "安装:部分显卡信息读不到时,安装不再中断;Windows 上 FreeVideo 安装在很深的文件夹时,命令行生成可以正常运行。", - "生图历史:点“选择”后可以勾选多张一次删除,删除前需要再次确认;被占用或无法删除的图片会保留并写明原因。", - "提示词增强:增强未能完成、改用原提示词时,写明原因;改写结果缺少视频或音频描述时,会请模型补上。", - "剪辑页:在没有描述的成片后“接着生成”时,会提示先写一句描述,不再显示英文校验错误;描述框被清空时,沿用这一段原有的描述。", - "Linux 安装包自带 curl,系统未安装 curl 时也能正常安装和下载。", - "下载清单:检查不再每次重新校验所有模型文件;已经校验过的文件在大小、时间和 inode 都没变时直接跳过。", - "保留导致兼容档位升高的显卡错误的出错阶段、步数、错误类型和内存读数,之后一次运行发送的诊断报告仍能看出它停在哪里。", - "对话中修改了正在等待 Agent 的消息后,提示不再按错误显示、不再带“报告问题”按钮,改为普通状态提示。" + "长视频:一个连续镜头最多可分 8 段(原为 5 段)。创作面板时长下方写明每次生成一个连续镜头,有分镜剧本时可转到剪辑视图或 AI 全自动;所需段数超过上限时在提交前提示缩短时长,提交时仍被拒绝也显示为普通提示,不再显示为未知错误。", + "参考视频:修复参考视频配合 1.5 倍等非默认放大倍数时生成失败。", + "图片和作品:AI 全自动对话中的图片卡和放大查看窗增加“下载”;作品库和创作面板的结果按钮增加“复制路径”。", + "生成完成的桌面通知改为默认开启:第一次点生成时请求一次通知权限;已经关闭通知的保持关闭。", + "剪辑视图:生成服务暂时没有回答时,生成栏不再被藏起,“生成”按钮置灰;几次自动检查都没有回答时,状态行显示“生成服务暂不可用 · 重试”,点击后重新检查。", + "剪辑、已导入、角色、Agent 对话和 Agent 任务列表中有记录无法读取时,列表末尾写明“另有 N 个……无法读取”。", + "Windows 上显存 8–12 GB 的显卡:RTX 40 和 50 系列 FP8 两遍视频的第一遍,以及 12 GB 显卡上超过 10 秒的 int8 视频的第二遍,每次计算的注意力头由 4 个增加到 8 个,显存占用保持在计划预算内。LoRA、Linux 和 Mac 不变。同一个种子生成的视频可能略有不同。", + "一次性错误自动重试:第二遍遇到一次性的 CUDA 错误时,自动从已完成的第一遍续跑一次,不再整个请求失败或重做第一遍;Mac 上采样阶段遇到 Metal 命令缓冲区错误时,会用更小的内存分块重试一次。失败报告写明机器、设置和重试结果,报告编号在服务重启后仍可取回。", + "同一请求、同一种子,解码出的画面每次逐位一致。与之前的版本相比,同一种子的画面可能有细微差别。", + "不受支持的显卡的提示卡现在写出检测到的显卡和计算能力,并说明 FreeVideo 需要计算能力 8.0 及以上的显卡(GeForce RTX 30 系列及更新),与显存大小无关。错误文字中句子里的斜杠不再被替换成路径占位符。" ] }, "history": [ + { + "product_version": "0.4.19", + "en": { + "summary": "Several pictures can be deleted from the image history at once; prompt enhancement states the reason when it cannot finish.", + "changes": [ + "Install: setup no longer stops when some graphics card details cannot be read; command-line generation works on Windows when FreeVideo is installed in a very deep folder.", + "Image history: after Select, several pictures can be checked and deleted at once, with a second click to confirm; pictures that are in use or cannot be deleted stay in place with the reason shown.", + "Prompt enhancement: when enhancement cannot finish and the original prompt is used, the reason is shown; a rewrite that lacks the video or audio description is sent back to the model to add it.", + "Editor: Extend on a finished video without a description asks for a description first instead of showing an English validation error; when the description box is cleared, the description carried over for that segment is used.", + "The Linux package now includes curl, so installation and downloads work on systems without it.", + "Download list: the check no longer hashes every model file again each time it runs; files already hashed are skipped while their size, times and inode are unchanged.", + "Keeps the failed phase, step count, error type and memory readings of a GPU error that raised the compatibility level, so a diagnostic report sent after a later run still shows where it stopped.", + "The chat no longer shows the notice for an edited waiting message as an error with a report button; it is a plain status line." + ] + }, + "zh": { + "summary": "生图历史可以一次删除多张;提示词增强未能完成时写明原因。", + "changes": [ + "安装:部分显卡信息读不到时,安装不再中断;Windows 上 FreeVideo 安装在很深的文件夹时,命令行生成可以正常运行。", + "生图历史:点“选择”后可以勾选多张一次删除,删除前需要再次确认;被占用或无法删除的图片会保留并写明原因。", + "提示词增强:增强未能完成、改用原提示词时,写明原因;改写结果缺少视频或音频描述时,会请模型补上。", + "剪辑页:在没有描述的成片后“接着生成”时,会提示先写一句描述,不再显示英文校验错误;描述框被清空时,沿用这一段原有的描述。", + "Linux 安装包自带 curl,系统未安装 curl 时也能正常安装和下载。", + "下载清单:检查不再每次重新校验所有模型文件;已经校验过的文件在大小、时间和 inode 都没变时直接跳过。", + "保留导致兼容档位升高的显卡错误的出错阶段、步数、错误类型和内存读数,之后一次运行发送的诊断报告仍能看出它停在哪里。", + "对话中修改了正在等待 Agent 的消息后,提示不再按错误显示、不再带“报告问题”按钮,改为普通状态提示。" + ] + } + }, { "product_version": "0.4.18", "en": { @@ -271,37 +304,6 @@ "int8:带调制部分的 LoRA 不再让生成中断,这些部分会逐个跳过,跳过的数量写进报告。" ] } - }, - { - "product_version": "0.4.10", - "en": { - "summary": "High on Mac uses the step cache and finishes much sooner; in the editor, audio clips can move to another audio track, and several notices and narrow-window layouts are clearer; workflows saved by an earlier version generate again.", - "changes": [ - "Mac: High uses the step cache on the first pass and finishes much sooner, and the starting time estimate is based on the chip, so it is closer from the first step.", - "Editor: an audio clip dragged from the library onto the second audio track lands there, and a single audio clip on the timeline can be dragged to another audio track.", - "Editor: pressing split or trim while the playhead is outside the selected part, or asking for full screen where the browser refuses it, now says why in one line.", - "Character mentions written back to back, with no space between the names, are each recognised, and the character list opens again right after a name.", - "Autopilot: while a task is running, the Autopilot button in the header stays whole and clickable in narrow windows.", - "Chat: on phones and other narrow windows, button names quoted in notices stay on one line.", - "Diagnostic reports for failed image and video requests record the available memory when the request started and when the generation process ended, and whether the system appears to have ended the process.", - "Nodes: the English Width, Height and Seed labels are capitalised like the other fields.", - "Generating from a workflow saved by an earlier version, or from a restored tab, no longer fails with an invalid step cache setting." - ] - }, - "zh": { - "summary": "Mac 精细档使用步间缓存,快得多;编辑器里音频片段可以拖到另一条音频轨;几处提示和窄窗口布局更清楚;旧版本保存的工作流又能正常生成。", - "changes": [ - "Mac:精细档第一遍使用步间缓存,快得多;开始时的预计时间按芯片给出,从第一步起就更接近实际。", - "编辑器:从素材库把音频拖到第二条音频轨会放在该轨;时间线上单个音频片段可以拖到另一条音频轨。", - "编辑器:播放头不在所选片段上时按切分或修剪,或在浏览器不允许全屏的地方点全屏,会用一行字说明原因。", - "连着写、名字之间没有空格的角色提及都能被识别,写完一个名字后接着输入也会弹出角色列表。", - "AI 全自动:有任务在跑时,窄窗口里顶栏的“AI 全自动”按钮也始终完整可点。", - "对话:手机等窄窗口里,提示中引号内的按钮名不再被拆到两行。", - "图像和视频失败请求的诊断报告现在记录请求开始和生成进程结束时的可用内存,并标明进程是否疑似被系统结束。", - "节点:英文界面里 Width、Height、Seed 的大小写与其他项一致。", - "修复:用旧版本保存的工作流或恢复的标签页生成时提示步间缓存参数无效。" - ] - } } ] } diff --git a/freevideo_engine/step_cache.py b/freevideo_engine/step_cache.py index a0335173..b39e8a82 100644 --- a/freevideo_engine/step_cache.py +++ b/freevideo_engine/step_cache.py @@ -100,6 +100,13 @@ def __init__(self, kind, threshold, steps, *, source=None, offloader=None, disab self.accumulated = 0. # ---- shared ------------------------------------------------------------------------------------ + def forget(self): + """Drop every earlier step: the packed sequence changed length (an early reference ended), so the + cached residuals, velocities and rate belong to another layout and cannot be compared with it.""" + self.previous = self.tail = self.anchor = None + self.prev_input = self.prev_diff = self.prev_out = self.rate = None + self.consecutive, self.accumulated = 0, 0. + def in_window(self): start, end = WINDOWS.get(self.kind, (0., 1.)) progress = self.step / self.steps @@ -132,6 +139,8 @@ def after_first_block(self, after): audio, video = (now - then for now, then in zip(self._samples(after), self.before)) self.before = None hit, scores = False, None + if self.previous is not None and (self.previous[0].shape != audio.shape or self.previous[1].shape != video.shape): + self.forget() if self.previous is not None and self.tail is not None and self.in_window() and self.consecutive < 2: scores = (_relative(audio, self.previous[0]), _relative(video, self.previous[1])) decisive = scores[1] @@ -162,6 +171,8 @@ def record_forward(self, video_in, audio_in, sigmas, video_out, audio_out): # Copies: float32 latents make .float() a view, and the sampler updates them in place. current_in = (_copy(video_in[stride]), _copy(audio_in[stride])) current_out = (_copy(video_out[stride]), _copy(audio_out[stride])) + if self.prev_input is not None and any(a.shape != b.shape for a, b in zip(self.prev_input, current_in)): + self.forget() if self.prev_input is not None and self.prev_out is not None: rates = [] for i in range(2): @@ -176,6 +187,9 @@ def record_forward(self, video_in, audio_in, sigmas, video_out, audio_out): def _easycache(self, video_in, audio_in): reuse = False + if self.prev_input is not None and (self.prev_input[0].shape[0] != video_in.shape[0] + or self.prev_input[1].shape[0] != audio_in.shape[0]): + self.forget() if self.rate is not None and self.prev_diff is not None and self.in_window(): stride = (slice(None), slice(None, None, STRIDE)) current = (video_in[stride].float(), audio_in[stride].float()) diff --git a/freevideo_engine/two_pass.py b/freevideo_engine/two_pass.py index 12dec3b5..22444daf 100644 --- a/freevideo_engine/two_pass.py +++ b/freevideo_engine/two_pass.py @@ -298,8 +298,10 @@ def references(row): refinement = None if sampling.get('enabled'): steps(sampling) - expected = plan(canvas, task=canvas.get('task', 't2va'), - base_steps=sampling['base_steps'], refine_steps=sampling['refine_steps']) + # The receipt records a non-default upscale factor and a staged first pass; the check replays both. + expected = plan(canvas, task=canvas.get('task', 't2va'), base_steps=sampling['base_steps'], + refine_steps=sampling['refine_steps'], staged=sampling.get('staged', False), + scale=sampling.get('scale', 2)) if any(sampling.get(key) != expected[key] for key in ('first', 'second')): raise ValueError('Early reference sampling geometry disagrees with the request') from .geometry import geometry diff --git a/freevideo_engine/vae_tiles.py b/freevideo_engine/vae_tiles.py index 37f12e3a..5b8223c2 100644 --- a/freevideo_engine/vae_tiles.py +++ b/freevideo_engine/vae_tiles.py @@ -34,11 +34,26 @@ def compile_blocks(decoder): return False for block in decoder.transformer_blocks: if not getattr(block, 'freevideo_compiled', False): - block.forward = torch.compile(block.forward, dynamic=False) + block.forward = _fixed_reduction_order(torch.compile(block.forward, dynamic=False)) block.freevideo_compiled = True return True +def _fixed_reduction_order(compiled): + """Run the compiled block with Inductor's deterministic mode. + + Otherwise Inductor times several warp counts for each fused RMSNorm reduction + when a process first runs the block, and the counts sum in different orders. + About 1 process in 20 then decoded the same latents to pixels that differ by one + 8-bit level in 0.3% of values; 150 processes in this mode gave identical output. + """ + def forward(*args, **kwargs): + from torch._inductor import config + with config.patch(deterministic=True): + return compiled(*args, **kwargs) + return forward + + class TileDecoder: def __init__(self, decoder, prefetch=True, weight_source=None, resident_blocks=0): self.decoder = decoder diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 8e25a9fe..124f326f 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -39,6 +39,7 @@ class Element { close() { this.open = false; this.onclose?.(); } } +globalThis.__permissionAsks = []; test('main browser entry registers both views and preserves node hooks', async t => { const extensions = [], opened = [], installed = [], events = new EventTarget(); const issueURLs = []; @@ -48,7 +49,7 @@ test('main browser entry registers both views and preserves node hooks', async t let launchReady = null; const opening = {setName: name => openingEvents.push(['name', name]), cover: () => openingEvents.push(['cover']), finish: () => openingEvents.push(['finish'])}; const casts = []; - let progressView, progressOptions, realProgressFactory, studioView; + let progressView, progressOptions, realProgressFactory, studioView, doneNotifyOptions; const app = { registerExtension(extension) { extensions.push(extension); }, graph: {extra: {freevideo_studio: true}, _nodes: [], getNodeById() {}}, @@ -90,7 +91,7 @@ test('main browser entry registers both views and preserves node hooks', async t notifyCompatibility: async () => { compatibilityChecks++; }, startUpdateChecks: () => { updateChecks++; }, startWhiteboxWorker: () => ({stop() {}}), - startDoneNotify: () => { doneNotifyStarts++; }, askDesktopPermission: () => {}, + startDoneNotify: options => { doneNotifyStarts++; doneNotifyOptions = options; }, installNavigation: callback => installed.push(callback), refreshNavigation: () => refreshed++, preferredView: () => rememberedView, beginWorkspaceRestore: () => { openingEvents.push(['begin']); return opening; }, finishWorkspaceRestore: () => { restoreReleases++; }, waitForLauncherRestore: () => launchReady, @@ -123,7 +124,7 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { notifyCompatibility } from './compatibility.js';", 'const {notifyCompatibility} = globalThis.__freevideoEntryTest;') .replace("import { startWhiteboxWorker } from './whitebox_worker.js';", 'const {startWhiteboxWorker} = globalThis.__freevideoEntryTest;') .replace("import { startUpdateChecks } from './updates.js';", 'const {startUpdateChecks} = globalThis.__freevideoEntryTest;') - .replace("import { startDoneNotify, askDesktopPermission, DESKTOP_SETTING } from './done_notify.js';", 'const {startDoneNotify,askDesktopPermission} = globalThis.__freevideoEntryTest; const DESKTOP_SETTING = "FreeVideo.DoneNotify.Desktop";') + .replace("import { startDoneNotify, askDesktopPermission, DESKTOP_SETTING } from './done_notify.js';", 'const {startDoneNotify} = globalThis.__freevideoEntryTest; const askDesktopPermission = (...args) => globalThis.__permissionAsks.push(args); const DESKTOP_SETTING = "FreeVideo.DoneNotify.Desktop";') .replace("import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs, mediaRows } from './prompt_references.js';", 'const {attachReferencePicker,referenceItems,syncReferencePrompt,referenceInputs,mediaRows} = globalThis.__freevideoEntryTest;') .replace("import { expandCast } from './cast_mention.js';", 'const {expandCast} = globalThis.__freevideoEntryTest;') .replace("import { outputDownloadURL } from './output_download.js';", 'const {outputDownloadURL} = globalThis.__freevideoEntryTest;') @@ -153,6 +154,41 @@ test('main browser entry registers both views and preserves node hooks', async t assert.equal(updateChecks, 1); assert.equal(doneNotifyStarts, 1); + await t.test('desktop notification uses the registered default without overwriting stored opt-outs', () => { + const setting = extension.settings.find(row => row.id === 'FreeVideo.DoneNotify.Desktop'); + assert.equal(setting.defaultValue, true); + assert.equal(typeof setting.onChange, 'function', 'turning the setting on is a user request'); + globalThis.__permissionAsks.length = 0; + setting.onChange(true, undefined); + setting.onChange(true, true); + setting.onChange(false, true); + assert.equal(globalThis.__permissionAsks.length, 0, 'registration and unrelated changes never ask'); + setting.onChange(true, false); + assert.equal(globalThis.__permissionAsks.length, 1, 'switching from off to on asks once'); + assert.equal(doneNotifyOptions.desktop(), true, 'missing values use the new default'); + // ComfyUI keeps explicit values separately and get() uses value ?? default. + // Registration must not write true over a previously stored false. + const values = new Map(); + app.extensionManager = {setting: {get: key => values.get(key) ?? setting.defaultValue}}; + app.ui = {settings: {getSettingValue: () => true}}; + assert.equal(doneNotifyOptions.desktop(), true); + assert.equal(values.size, 0, 'an untouched default is not persisted'); + values.set(setting.id, false); + assert.equal(doneNotifyOptions.desktop(), false, 'modern false wins over legacy true'); + assert.equal(values.get(setting.id), false); + app.extensionManager.setting.get = () => undefined; + app.ui.settings.getSettingValue = () => false; + assert.equal(doneNotifyOptions.desktop(), false, 'legacy false is also preserved'); + app.ui.settings.getSettingValue = () => undefined; + assert.equal(doneNotifyOptions.desktop(), true); + app.graph._nodes.push({type: 'KSampler'}); + assert.equal(doneNotifyOptions.isFreeVideoWorkflow(), false); + app.graph._nodes.push({comfyClass: 'FreeVideoGenerate'}); + assert.equal(doneNotifyOptions.isFreeVideoWorkflow(), true); + app.graph._nodes.length = 0; + delete app.extensionManager; delete app.ui; + }); + class GenerateNode { constructor() { this.id = 7; this.type = 'FreeVideoGenerate'; diff --git a/web/agent_activity.css b/web/agent_activity.css index 3b2efcac..16f884b8 100644 --- a/web/agent_activity.css +++ b/web/agent_activity.css @@ -18,6 +18,7 @@ .fv-agent-panel header{padding:16px 18px 12px;border-bottom:1px solid var(--fv-border)} .fv-agent-panel h2{margin:0;font-size:var(--fv-strong);font-weight:var(--fv-semibold)} .fv-agent-panel ol{list-style:none;margin:0;padding:6px;overflow:auto;display:grid;gap:2px} +.fv-agent-unreadable{flex:none;margin:8px 18px;font-size:12px;color:var(--fv-muted);text-align:left}.fv-agent-unreadable:empty,.fv-agent-unreadable[hidden]{display:none} .fv-agent-job{display:grid;grid-template-columns:20px minmax(0,1fr);gap:12px;padding:12px;border-radius:var(--fv-r-sm)} .fv-agent-job:hover{background:var(--fv-hover)} .fv-agent-glyph{width:20px;height:20px;margin-top:1px;fill:none;stroke:var(--fv-muted);stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round} diff --git a/web/agent_activity.js b/web/agent_activity.js index 4cd4dd3f..89720626 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -159,8 +159,12 @@ export function createAgentActivity(t, api, host, {interval = [1500, 8000], open header.append(el('h2', null, t('Agent jobs', 'Agent 任务'))); const list = el('ol'); const key = refill(panel, list, rows(data.jobs, t, api, poll, job => { setOpen(false); open(job); }, cancellationErrors)); + const unreadable = Number.isSafeInteger(data.unreadable) && data.unreadable > 0 ? data.unreadable : 0; + const skipped = el('div', 'fv-agent-unreadable', unreadable > 0 + ? t(`${unreadable} ${unreadable === 1 ? 'job' : 'jobs'} could not be read`, `另有 ${unreadable} 个任务无法读取`) : ''); + skipped.hidden = !unreadable; panel.replaceChildren(); - panel.append(header, list); + panel.append(header, list, skipped); if (key) (panel.querySelector(`[data-job="${key[0]}"] .${key[1].split(' ')[0]}`) || panel).focus?.(); } function render() { diff --git a/web/autopilot_chat.css b/web/autopilot_chat.css index 91d27920..532c7ac7 100644 --- a/web/autopilot_chat.css +++ b/web/autopilot_chat.css @@ -6,6 +6,7 @@ .fv-chat[data-conversations=true]{grid-template-columns:var(--fv-chats-width) minmax(0,1fr)} .fv-chat-conversations{position:relative;box-sizing:border-box;min-height:0;min-width:0;display:flex;flex-direction:column;gap:var(--fv-chats-gap);padding:16px var(--fv-chats-pad);border-right:1px solid var(--fv-border);background:var(--fv-bg)} .fv-chat-conversation-list{min-height:0;overflow:auto;display:grid;align-content:start;gap:var(--fv-chats-gap)} +.fv-chat-unreadable{flex:none;margin:8px 0;font-size:12px;color:var(--fv-muted);text-align:left}.fv-chat-unreadable:empty{display:none} .fv-studio .fv-chat-conversations button{box-sizing:border-box;flex:none;justify-content:flex-start;min-width:0;width:100%;min-height:var(--fv-chats-row);padding:8px var(--fv-chats-pad);border-radius:var(--fv-chats-radius);border:1px solid transparent;background:transparent;font-size:var(--fv-chats-font);font-weight:var(--fv-regular);color:var(--fv-ink);text-align:left;gap:10px} .fv-studio .fv-chat-conversations .fv-chat-new{border-color:var(--fv-border);margin-bottom:6px} .fv-studio .fv-chat-conversations button:hover:not(:disabled){background:var(--fv-raised)} @@ -83,6 +84,8 @@ .fv-studio.fv-chat-image-viewer[open]{display:grid;grid-template-rows:auto minmax(0,1fr);gap:12px;padding:12px;border-radius:14px;background:var(--fv-card)} .fv-chat-image-viewer>.fv-chat-actions{justify-content:flex-end} .fv-studio.fv-chat-image-viewer .fv-close{min-height:34px;width:34px;padding:0;font-size:20px;line-height:1;background:transparent;border-color:transparent;color:var(--fv-muted)} +.fv-studio.fv-chat-image-viewer .fv-chat-save{min-height:34px;padding:0 14px;border-radius:7px;font-size:13px;background:transparent;border-color:transparent;color:var(--fv-muted)} +.fv-studio.fv-chat-image-viewer .fv-chat-save:hover{background:var(--fv-raised);color:var(--fv-ink)} .fv-studio.fv-chat-image-viewer .fv-close:hover{background:var(--fv-raised);color:var(--fv-ink)} .fv-chat-preview[data-open=true]{cursor:zoom-in} .fv-chat-image-stage{position:relative;min-width:0;min-height:0;overflow:hidden;border-radius:9px;background:var(--fv-bg);touch-action:none;cursor:grab;user-select:none} diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index b66970a9..6308a9a2 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -159,12 +159,19 @@ export function jobParts(job) { return Array.from({length: block.total}, (_, i) => i < (block.done || 0) ? 1 : i === block.index - 1 ? inside : 0); } +// Saves a picture through the browser's own download, under the file's name. +function saveImage(src) { + const link = el('a'); link.href = src; link.download = new URL(src, location.href).searchParams.get('filename') || ''; + document.body.append(link); link.click(); link.remove(); +} + // A picture is viewed here, independently of the video library. The image stays within reach while zoomed. function imageViewer(t, src, name, opener, failed) { const dialog = el('dialog', 'fv-studio fv-chat-image-viewer'), actions = el('div', 'fv-chat-actions'); dialog.setAttribute('aria-label', name); const close = press('fv-close', '×', () => closeDialog(dialog)); - close.setAttribute('aria-label', t('Close', '关闭')); close.title = t('Close', '关闭'); actions.append(close); + close.setAttribute('aria-label', t('Close', '关闭')); close.title = t('Close', '关闭'); + actions.append(press('fv-chat-save', t('Download', '下载'), () => saveImage(src)), close); const stage = el('div', 'fv-chat-image-stage'), image = el('img'); image.alt = name; image.draggable = false; stage.append(image); dialog.append(actions, stage); let scale = 1, fit = 1, x = 0, y = 0, disposed = false; @@ -369,6 +376,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () let listBusy = false, listAgain = false, listAt = -Infinity; const listInterval = 10000; const sidebar = el('nav', 'fv-chat-conversations'), list = el('div', 'fv-chat-conversation-list'), tools = el('div', 'fv-chat-tools'); + const skippedConversations = el('div', 'fv-chat-unreadable'); skippedConversations.hidden = true; sidebar.id = `fv-chat-conversations-${++chatNumber}`; sidebar.setAttribute('aria-label', t('Chats', '对话')); sidebar.hidden = true; const newChat = press('fv-chat-new', t('New chat', '新对话'), () => void startChat()); newChat.prepend(glyph('M12 5v14M5 12h14')); newChat.title = t('New chat', '新对话'); @@ -387,7 +395,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () chats.append(glyph('M4 5h16v12H9l-5 4V5M8 9h8M8 13h5'), chatsDot); chats.title = t('Chats', '对话'); chats.setAttribute('aria-label', chats.title); chats.setAttribute('aria-controls', sidebar.id); chats.setAttribute('aria-expanded', 'false'); - tools.hidden = true; tools.append(chats); main.prepend(tools); sidebar.append(newChat, newError, newIssue, deleteError, list); element.prepend(sidebar); + tools.hidden = true; tools.append(chats); main.prepend(tools); sidebar.append(newChat, newError, newIssue, deleteError, list, skippedConversations); element.prepend(sidebar); const listNodes = new Map(), narrow = () => window.matchMedia('(max-width: 720px)').matches; let menuState = null, changingMenu = false; const deleting = new Set(), deleted = new Set(); @@ -472,7 +480,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const lostFocus = previous && inMenu(document.activeElement); menuState = conversationMenu(conversations, menuState); // After the final deletion, keep New chat reachable as the keyboard destination. - const visible = hasConversations(conversations) || conversations.supported === true && deleted.size > 0; + const unreadable = conversations.unreadable; + skippedConversations.textContent = unreadable > 0 ? t(`${unreadable} ${unreadable === 1 ? 'conversation' : 'conversations'} could not be read`, `另有 ${unreadable} 个对话无法读取`) : ''; + skippedConversations.hidden = !unreadable; + const visible = hasConversations(conversations) || conversations.supported === true && (deleted.size > 0 || unreadable > 0); element.dataset.conversations = String(visible); sidebar.hidden = tools.hidden = !visible; if (!visible) drawer(false); newChat.disabled = creating; chatsDot.hidden = !conversations.conversations.some(row => row.running > 0); @@ -797,7 +808,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () dismissImage = imageViewer(t, api.apiURL(job.thumbnail), name.textContent, show, () => { show.disabled = true; }); }); const cut = press('fv-chat-edit', t('Edit', '剪辑'), () => edit(job)); - if (picture && job.thumbnail) { show.disabled = true; foot.append(show); } + const save = press('fv-chat-save', t('Download', '下载'), () => saveImage(api.apiURL(job.thumbnail))); + if (picture && job.thumbnail) { show.disabled = save.disabled = true; foot.append(show, save); } else if (!picture && job.work) foot.append(show, cut); info.append(head); if (foot.childElementCount) info.append(foot); const children = [info]; @@ -805,7 +817,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const {box, shape} = preview(job), length = el('span', 'fv-chat-length', job.seconds > 0 ? clock(job.seconds) : ''); if (job.thumbnail) { const image = el('img'); image.alt = ''; - const viewable = on => { show.disabled = !on; if (on) box.dataset.open = 'true'; else delete box.dataset.open; }; + const viewable = on => { show.disabled = save.disabled = !on; if (on) box.dataset.open = 'true'; else delete box.dataset.open; }; image.onload = () => { if (!box.style.aspectRatio) shape(image.naturalWidth, image.naturalHeight); if (picture) viewable(true); }; if (picture) { image.onerror = () => viewable(false); box.onclick = () => { if (!show.disabled) show.click(); }; } image.src = api.apiURL(job.thumbnail); box.append(image); @@ -858,7 +870,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () item.replaceChildren(...children); return (next, latest) => { name.textContent = jobName(next, t); meta.textContent = jobMeta(next, t); meta.hidden = !meta.textContent; - if (picture) show.setAttribute('aria-label', t('Open ', '打开 ') + jobName(next, t)); + if (picture) { show.setAttribute('aria-label', t('Open ', '打开 ') + jobName(next, t)); save.setAttribute('aria-label', t('Download ', '下载 ') + jobName(next, t)); } show.classList.toggle('fv-primary', latest); }; } diff --git a/web/autopilot_conversations.js b/web/autopilot_conversations.js index 0b63efd9..b13721e0 100644 --- a/web/autopilot_conversations.js +++ b/web/autopilot_conversations.js @@ -1,5 +1,5 @@ // The list is a server snapshot, in the server's order. An unsaid current conversation need not be in it. -export const emptyConversations = () => ({supported: null, current: null, conversations: []}); +export const emptyConversations = () => ({supported: null, current: null, conversations: [], unreadable: 0}); export const selectConversation = (state, session) => ({...state, current: session ?? null}); export const hasConversations = state => state.supported === true && state.conversations.length > 0; // The first other conversation with work under way; null when there is none. @@ -21,7 +21,7 @@ export const removeConversation = (state, session, here = null) => ({...state, export function mergeConversations(state, data, status = 200) { // A legacy server is probed once per page; ordinary errors retain the last good snapshot. - if (status === 404) return {...state, supported: false, conversations: []}; + if (status === 404) return {...state, supported: false, conversations: [], unreadable: 0}; if (state.supported === false || status !== 200 || !Array.isArray(data?.conversations)) return state; const seen = new Set(), conversations = []; for (const row of data.conversations) { @@ -34,5 +34,6 @@ export function mergeConversations(state, data, status = 200) { running: Math.max(0, Math.floor(Number(row.running) || 0))}); if (conversations.length === 50) break; } - return {...state, supported: true, conversations}; + return {...state, supported: true, conversations, + unreadable: Number.isSafeInteger(data.unreadable) && data.unreadable > 0 ? data.unreadable : 0}; } diff --git a/web/cast_library.css b/web/cast_library.css index ccab799f..f41a9f9c 100644 --- a/web/cast_library.css +++ b/web/cast_library.css @@ -1,5 +1,6 @@ .fv-cast-library[hidden]{display:none} -.fv-cast-library{display:flex;align-items:center;justify-content:flex-end;gap:10px;min-width:0;padding:2px 0 10px;color:var(--fv-ink);font:13px/1.5 var(--fv-font)} +.fv-cast-library{display:flex;flex-wrap:wrap;align-items:center;justify-content:flex-end;gap:10px;min-width:0;padding:2px 0 10px;color:var(--fv-ink);font:13px/1.5 var(--fv-font)} +.fv-cast-unreadable{flex-basis:100%;font-size:12px;color:var(--fv-muted);text-align:left}.fv-cast-unreadable:empty{display:none} .fv-cast-heading{flex-shrink:0;margin-right:auto;font-weight:var(--fv-medium,500)} .fv-cast-avatars{display:flex;align-items:center;gap:7px;min-width:0;overflow:auto;padding:3px} .fv-cast-avatars:empty{display:none} diff --git a/web/cast_library.js b/web/cast_library.js index 65a47f49..479b4cac 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -28,7 +28,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const css = el('link'); css.rel = 'stylesheet'; css.dataset.freevideoCast = ''; css.href = new URL('./cast_library.css', import.meta.url).href; document.head.append(css); } - let rows = [], mentioned = new Set(), disposed = false, request = 0, current = null, broadcasting = false; + let rows = [], unreadable = 0, mentioned = new Set(), disposed = false, request = 0, current = null, broadcasting = false; const subscribers = new Set(); const element = el('div', 'fv-cast-library'); // The row shows, and the page session is asked for, only once /freevideo/agent says the interface is on. @@ -36,9 +36,10 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const probe = agentInterfaceEnabled(api).then(on => { available = on && !disposed; element.hidden = !available; return available; }); const heading = el('span', 'fv-cast-heading', text('Characters', '角色')); const avatars = el('div', 'fv-cast-avatars'); + const skipped = el('div', 'fv-cast-unreadable'); skipped.hidden = true; const add = button('fv-cast-add', '+', () => openDialog()); add.setAttribute('aria-label', text('New character', '新建角色')); - element.append(heading, avatars, add); + element.append(heading, avatars, add, skipped); async function value(response) { const data = await response.json().catch(() => null); @@ -62,6 +63,8 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () }; function render() { avatars.replaceChildren(); heading.hidden = !rows.length; + skipped.textContent = unreadable > 0 ? text(`${unreadable} ${unreadable === 1 ? 'character' : 'characters'} could not be read`, `另有 ${unreadable} 个角色无法读取`) : ''; + skipped.hidden = !unreadable; for (const row of rows) { const avatar = button('fv-cast-avatar', '', () => openDialog(row.id)); avatar.classList.toggle('fv-cast-mentioned', mentioned.has(row.id)); @@ -76,7 +79,8 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const serial = ++request; const data = await value(await agentGet(api, '/freevideo/agent/cast?kind=character&limit=200')); if (!disposed && serial === request) { - rows = data.cards; render(); onChange(rows); + rows = data.cards; unreadable = Number.isSafeInteger(data.unreadable) && data.unreadable > 0 ? data.unreadable : 0; + render(); onChange(rows); for (const fn of subscribers) fn(rows); } return rows; diff --git a/web/done_notify.js b/web/done_notify.js index d5bf111e..53fac998 100644 --- a/web/done_notify.js +++ b/web/done_notify.js @@ -1,11 +1,14 @@ // Tells the user a long run has finished while FreeVideo is in a background tab: // the tab title flashes and one short beep plays until the page is shown again. -// A desktop notification is sent only when the user turned it on in the settings. +// Desktop notifications default on; an explicit opt-out keeps them off. // Only a queue that ran a FreeVideo node counts: other workflows finish silently. export const DESKTOP_SETTING = 'FreeVideo.DoneNotify.Desktop'; +const PERMISSION_ASKED = 'freevideo.desktop.notification-asked'; +const permissionAsked = new WeakSet(); const LONG_RUN_MS = 20000, FLASH_MS = 1000; -export function startDoneNotify({api, text, doc = document, win = window, now = () => Date.now(), desktop = () => false, isFreeVideoNode = () => false, +export function startDoneNotify({api, text, doc = document, win = window, now = () => Date.now(), desktop = () => true, isFreeVideoNode = () => false, + isFreeVideoWorkflow = () => false, beep = playBeep, notify = desktopNotice, minimum = LONG_RUN_MS, setTimer = setInterval, clearTimer = clearInterval} = {}) { let busySince = null, failed = false, cancelled = false, flashing = null, involved = false; const away = () => doc.hidden || (typeof doc.hasFocus === 'function' && !doc.hasFocus()); @@ -43,6 +46,12 @@ export function startDoneNotify({api, text, doc = document, win = window, now = }; const interrupted = () => { cancelled = true; }; const shown = () => { if (!away()) stopFlashing(); }; + // Capture the actual Generate click before the host starts any async work. + // The workspace marks its button; current and older ComfyUI use the other two. + const generate = event => { + const button = event.target?.closest?.('[data-fv-guide="generate"], [data-testid="queue-button"], #queue-button'); + if (button && !button.disabled && desktop() && isFreeVideoWorkflow()) askDesktopPermission(win); + }; api.addEventListener('status', status); api.addEventListener('execution_error', error); api.addEventListener('execution_interrupted', interrupted); @@ -50,13 +59,15 @@ export function startDoneNotify({api, text, doc = document, win = window, now = api.addEventListener('freevideo_prewarm', freevideo); api.addEventListener('executing', executing); doc.addEventListener('visibilitychange', shown); + doc.addEventListener('click', generate, true); win.addEventListener?.('focus', shown); return {stop() { api.removeEventListener('status', status); api.removeEventListener('execution_error', error); api.removeEventListener('execution_interrupted', interrupted); api.removeEventListener('freevideo_progress', freevideo); api.removeEventListener('freevideo_prewarm', freevideo); api.removeEventListener('executing', executing); - doc.removeEventListener('visibilitychange', shown); win.removeEventListener?.('focus', shown); stopFlashing(); + doc.removeEventListener('visibilitychange', shown); doc.removeEventListener('click', generate, true); + win.removeEventListener?.('focus', shown); stopFlashing(); }}; } @@ -82,7 +93,17 @@ function desktopNotice(win, label, clicked) { notice.onclick = () => { clicked(); notice.close?.(); }; } -// Asks for notification permission when the user switches the setting on. -export function askDesktopPermission(win = window) { - try { win.Notification?.requestPermission?.(); } catch { /* Optional. */ } +// Call synchronously from a Generate gesture, never at startup or on completion. +// A user turning the setting on is an explicit request, so it ignores the earlier record. +export function askDesktopPermission(win = window, {explicit = false} = {}) { + const Notice = win.Notification; + if (Notice?.permission !== 'default' || typeof Notice.requestPermission !== 'function') return; + if (!explicit) { + if (permissionAsked.has(win)) return; + try { if (win.localStorage.getItem(PERMISSION_ASKED) === '1') return; } catch { /* Storage may be blocked. */ } + } + // Remember before requesting, including dismissals, throws and pending promises. + permissionAsked.add(win); + try { win.localStorage.setItem(PERMISSION_ASKED, '1'); } catch { /* Keep the in-memory guard. */ } + try { Notice.requestPermission()?.catch?.(() => {}); } catch { /* Optional. */ } } diff --git a/web/editor.css b/web/editor.css index fbeee9c6..886a2eab 100644 --- a/web/editor.css +++ b/web/editor.css @@ -44,6 +44,7 @@ .fv-ed-audio-icon{font-size:26px;color:var(--fv-muted)} .fv-editor .fv-ed-import{width:100%;height:auto;aspect-ratio:16/9;border:1px dashed var(--fv-border);border-radius:6px;background:transparent;color:var(--fv-muted);padding:0} .fv-ed-library-message{font-size:12px;color:var(--fv-muted);line-height:1.6} +.fv-ed-unreadable{margin:6px 10px;font-size:12px;color:var(--fv-muted);line-height:1.5;text-align:center;white-space:normal}.fv-ed-unreadable:empty{display:none} .fv-ed-source-card .fv-issue-button{font-size:11px;min-height:28px;max-width:100%} .fv-editor .fv-ed-preview{grid-column:2;grid-row:2;min-width:0;min-height:0;display:grid;place-items:center;padding:16px;overflow:hidden;background:var(--fv-bg);container-type:size} .fv-ed-picture{position:relative;width:min(100cqw,calc(100cqh * var(--fv-ed-width,16) / var(--fv-ed-height,9)));aspect-ratio:var(--fv-ed-width,16)/var(--fv-ed-height,9);background:#000;border-radius:8px;overflow:hidden} @@ -78,6 +79,9 @@ .fv-ed-gen-note[hidden]{display:none} .fv-ed-gen-note:has(.fv-issue-button){white-space:normal;overflow:visible} .fv-ed-gen-note .fv-issue-button,.fv-ed-library-message .fv-issue-button{margin-left:8px} +.fv-editor .fv-ed-gen-retry{height:auto;padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer} +.fv-editor .fv-ed-gen-retry:hover:not(:disabled){text-decoration:underline} +.fv-editor .fv-ed-gen-retry:disabled{color:var(--fv-muted);cursor:default} .fv-ed-genbar .fv-request-assets{order:-2;flex:1 1 100%;min-width:0;font-size:12px;line-height:1.5} .fv-ed-genbar .fv-request-assets[hidden]{display:none} .fv-editor:has(.fv-ed-gen-failure:not([hidden])){--fv-ed-generation-row:max-content} diff --git a/web/editor.js b/web/editor.js index 607eb0cb..ddf8e52a 100644 --- a/web/editor.js +++ b/web/editor.js @@ -62,7 +62,7 @@ export function editorErrorText(value, t = en => en) { return t(`${stage}: not enough GPU memory.`, `${stage}时显存不足。`); const known = { chain_part_too_short: t('Each continued part needs at least 10 seconds.', '接续的每段至少 10 秒。'), - chain_too_long: t('A video can have up to 5 parts.', '一条视频最多 5 段。'), + chain_too_long: t('One continuous shot can have up to 8 parts.', '一个连续镜头最多 8 段。'), too_long: t('This part is longer than the maximum length. Shorten it and try again.', '这一段超过了最长时长,请缩短后再试。'), missing_node: t('Generation is not ready. Restart FreeVideo.', '生成功能没有就绪,请重新启动 FreeVideo。'), chain_missing: t('Generation could not start. Choose "Generate" again.', '生成没能开始,请再点“生成”。'), @@ -301,7 +301,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (code === 'unsupported') { genbar?.update(null); return; } if (['chain_too_long', 'chain_part_too_short'].includes(code)) { const [en, zh] = (value.said || []).map(text => typeof text === 'string' && text ? text.slice(0, 200) : null); - const text = code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短一些。') + const text = code === 'chain_too_long' ? t(en || 'One continuous shot can have up to 8 parts. Make it shorter.', zh || '一个连续镜头最多 8 段,请缩短。') : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接着生成的每一段至少 10 秒,请加长一些。'); out(text); return {ok: false, reason: code, say: text}; } @@ -317,10 +317,11 @@ export function openEditor({api, app, from = null, panel = {}, components: given const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '没有完成,请重试'); out(text, step === 'export' ? EXPORT_ISSUE : EDITOR_ISSUE, failureKind(site, value)); return {ok: false, reason: code, say: text}; } - const store = createEditorStore(api, {t, notify, standIn: given?.generationStandIn, onGenerationReady: ready => { + const store = createEditorStore(api, {t, notify, standIn: given?.generationStandIn, onGenerationChecking: () => { if (!disposed) settleService(); }, onGenerationReady: ready => { if (disposed) return; - generationAsked = true; genHost.hidden = !ready; element.dataset.generationReady = String(ready); - settleGenbar(); if (ready) { offerInitial?.(); extendEntry?.(); void startHeld(); } + // Only a server that said it cannot generate hides the bar; an unanswered check keeps it, greyed. + generationAsked = true; genHost.hidden = !ready && store.generationGone; element.dataset.generationReady = ready ? 'true' : store.generationGone ? 'false' : 'waiting'; + settleGenbar(); settleService(); if (ready) { offerInitial?.(); extendEntry?.(); void startHeld(); } // Parts queued while waiting for the answer never start here; they go back to drafts with the reason. else if (store.generationGone && held.length) { for (const entry of held.splice(0)) markHeld(entry.ids, false); @@ -333,8 +334,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given let generationAsked = store.generationAvailable, loaded = false; const settleGenbar = () => { // While the engine is still answering, a press queues the part instead of doing nothing. - genHost.inert = !loaded || generationAsked && !store.generationAvailable; genHost.classList.toggle('fv-ed-pending', !loaded); + genHost.inert = !loaded; genHost.classList.toggle('fv-ed-pending', !loaded); }; + // When every automatic check went unanswered, the bar's line offers to check again; not while a check runs. + const settleService = () => genbar?.service(store.generationStalled ? {checking: store.generationChecking, retry: () => store.retryGeneration()} : null); store.generationReady(); const queue = store.createSaveQueue({get record() { return state.record; }, adopt(value) { @@ -740,9 +743,11 @@ export function openEditor({api, app, from = null, panel = {}, components: given const remove = button(deleteEditText(t).item, confirmDelete, 'fv-ed-delete'); remove.setAttribute('role', 'menuitem'); menu.append(remove); try { // The current edit is already named in the box at the top. - const rows = (await store.projects()).filter(row => row.id !== state.record.id && !hiddenEmptyEdit(row, initial())); if (disposed || menu.hidden || !menu.contains(name)) return; - if (rows.length) { const line = el('div'); line.setAttribute('role', 'separator'); menu.append(line); } + const listed = await store.projects(), unread = listed.unreadable || 0; + const rows = listed.filter(row => row.id !== state.record.id && !hiddenEmptyEdit(row, initial())); if (disposed || menu.hidden || !menu.contains(name)) return; + if (rows.length || unread) { const line = el('div'); line.setAttribute('role', 'separator'); menu.append(line); } for (const row of rows) { const item = button(row.name || new Date(row.updated_at).toLocaleDateString(), () => switchProject(row.id), 'fv-ed-project'); item.setAttribute('role', 'menuitem'); item.title = item.textContent; menu.append(item); } + if (unread) menu.append(el('p', 'fv-ed-unreadable', t(`${unread} ${unread === 1 ? 'edit' : 'edits'} could not be read`, `另有 ${unread} 个剪辑无法读取`))); } catch (value) { error(value, null, undefined, 'projects'); } name.focus(); name.select(); } @@ -759,6 +764,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given }, generationLimits: () => ({maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}), generationAvailable: () => store.generationAvailable, + // The server was asked and has not said yes: Generate is greyed until it does. + generationWaiting: () => generationAsked && !store.generationAvailable, importFiles: (files, options) => library?.importFiles(files, options), gestureStart({selectionGesture = false} = {}) { pointerEditing = true; @@ -1118,7 +1125,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given return running; } - genbar = createEditorGenbar(genHost, {state, t, actions, text: ''}); + genbar = createEditorGenbar(genHost, {state, t, actions, text: ''}); settleService(); const adjust = adjustPanel = createAdjustPanel(adjustHost, {state, t, actions}); const pictureEdit = pictureSession(state, t); const notes = createBlockNotes({state, t, actions}); actions.blockNote = notes.decorate; actions.describedTrack = describedTrack; diff --git a/web/editor_genbar.js b/web/editor_genbar.js index 19d515dc..b6152eb9 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -50,6 +50,15 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { input.dataset.fvGuide = 'editor-describe'; generate.dataset.fvGuide = 'editor-generate'; for (const button of [generate, cancel, resume]) button.type = 'button'; host.append(hint, note, description, generate, cancel, resume); + // The line's own "check again", shown whenever nothing else is said there. + const retryService = el('button', 'fv-ed-gen-retry', t('Generation unavailable · retry', '生成服务暂不可用 · 重试')); retryService.type = 'button'; + let service = null; + retryService.onclick = () => { if (service && !service.checking) { retryService.disabled = true; service.retry(); } }; + function showService() { + if (disposed || !service) return; + retryService.disabled = service.checking; + if (note.hidden) { note.replaceChildren(retryService); note.hidden = false; } + } // What this part will download first, above the bar like the studio's line above Generate. const assets = actions.assets ? createRequestAssets({t, api: actions.assets.api, request: () => actions.assets.query()}) : null; if (assets) host.prepend(assets.element); @@ -95,7 +104,8 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { queuedAt = text && queued ? progressKey() : null; clearTimeout(noteTimer); note.textContent = text || ''; note.hidden = !text; if (text && where) note.append(issueButton(t, () => ({where, message: text}))); - if (text) noteTimer = setTimeout(() => { note.hidden = true; }, where ? 20000 : 6000); + if (text) noteTimer = setTimeout(() => { note.hidden = true; showService(); }, where ? 20000 : 6000); + else showService(); } async function render() { if (disposed) return; @@ -161,8 +171,8 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { firstFrame.update(busy); references?.update(busy, busy ? undefined : actions.references.visible() || false); generate.textContent = t('Generate', '生成'); - // Never disabled for a reason the press itself can explain or fix. - generate.disabled = busy || !!previewSelection; + // Never disabled for a reason the press itself can explain or fix; only until the server says it can generate. + generate.disabled = busy || !!previewSelection || !!actions.generationWaiting?.(); resizeDescription(); input.dispatchEvent(new Event('change')); generate.title = ''; @@ -236,7 +246,12 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (event.type === 'selection' || event.type === 'record') render(); }); render(); - return {element: host, say, downloadFailure, getText() { expand.commit(); return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, + return {element: host, say, downloadFailure, + service(value) { + service = value; + if (!value && note.contains(retryService)) { retryService.remove(); note.hidden = true; } + showService(); + }, getText() { expand.commit(); return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, dispose() { disposed = true; ++serial; clearTimeout(noteTimer); clearTimeout(refNoticeTimer); unsubscribe(); resize?.disconnect(); expand.dispose(); references?.dispose(); assets?.dispose(); }}; } diff --git a/web/editor_library.js b/web/editor_library.js index 261d2ac0..25008146 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -15,11 +15,12 @@ export function createEditorLibrary(host, {store, t, actions, state}) { importButton.type = 'button'; searchTools.append(search, importButton); const grid = el('div', 'fv-ed-library-grid'); grid.setAttribute('role', 'tabpanel'); const message = el('p', 'fv-ed-library-message'); message.setAttribute('role', 'status'); + const unreadable = el('p', 'fv-ed-unreadable'); const file = el('input'); file.type = 'file'; file.multiple = true; file.hidden = true; file.accept = '.mp4,.mov,.webm,.mkv,.m4v,.wav,.mp3,.flac,.ogg,.m4a,.aac,.opus'; importButton.onclick = () => file.click(); - host.append(tabs, searchTools, grid, message, file); - let tab = 'work', rows = [], imported = [], next = null, disposed = false, loading = false; + host.append(tabs, searchTools, grid, message, unreadable, file); + let tab = 'work', rows = [], imported = [], importUnread = 0, next = null, disposed = false, loading = false; let listRead = Promise.resolve(), refreshTask = null, retryTimer = null, finishRetry = null, refreshAgain = false; const expectedOutputs = new Set(); const uploading = new Map(), recentImports = new Map(), pendingAdds = new Map(); @@ -136,6 +137,8 @@ export function createEditorLibrary(host, {store, t, actions, state}) { const [text, where] = importFailure(failed.reason); message.textContent = text; if (where) message.append(issueButton(t, () => ({where, message: text}))); } + const unread = tab === 'import' ? importUnread : 0; + unreadable.textContent = unread ? t(`${unread} imported ${unread === 1 ? 'file' : 'files'} could not be read`, `另有 ${unread} 个导入的文件无法读取`) : ''; if (scroll > 0) { const replacement = anchor && [...grid.querySelectorAll('.fv-ed-source')].find(node => node.dataset.key === anchor.dataset.key); host.scrollTop = replacement ? scroll + replacement.getBoundingClientRect().top - anchorTop : scroll; @@ -245,7 +248,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { const stop = store.watchImports(items => { for (const item of items) recentImports.delete(item.id); // A list request started before an upload may finish after it. - imported = [...recentImports.values(), ...items]; render(); + imported = [...recentImports.values(), ...items]; importUnread = items.unreadable || 0; render(); placeReady(imported); startPending(imported); }); const stopUsed = state?.subscribe(event => { if (event.type === 'record') updateUsed(); }); diff --git a/web/editor_model.js b/web/editor_model.js index 5f8b4d19..0e7d5a86 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -9,7 +9,7 @@ export const FPS = 24; // followSeconds mirrors chain_plan.MIN_PART_SECONDS: a part that continues another is at least this long. export const RULES = Object.freeze({maxSeconds: 15, singleSeconds: 30, minSeconds: 2, tailSeconds: 5, followSeconds: 10, step: 17, first: 5, overlap: 22, - maxTracks: 3, maxClips: 500, maxText: 8000, maxParts: 5}); + maxTracks: 3, maxClips: 500, maxText: 8000, maxParts: 8}); // Slice and bound user text by Unicode code points, never UTF-16 halves. export const truncateText = (text, limit) => Array.from(text).slice(0, limit).join(''); export const copyName = (name, suffix) => truncateText(name, RULES.maxText - Array.from(suffix).length) + suffix; @@ -710,7 +710,7 @@ export const partLengths = (frames, {maxSeconds = RULES.maxSeconds, maxFollowSec export function maxRangeFrames(record, {continues = false, maxSeconds = RULES.maxSeconds, maxFollowSeconds = maxSeconds}) { const before = continues; const follow = Math.floor(Math.min(RULES.maxSeconds, maxFollowSeconds)) * FPS; - // The part being continued is the first of the five. + // The part being continued is the first of them. return before ? (RULES.maxParts - 1) * follow : Math.floor(Math.min(RULES.maxSeconds, maxSeconds)) * FPS + (RULES.maxParts - 1) * follow; } // Manual boundaries use device limits, not the automatic fifteen-second preference. diff --git a/web/editor_store.js b/web/editor_store.js index caa8bc12..f3ab5744 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -173,10 +173,10 @@ export function mediaURL(api, value) { return api.apiURL?.(path) || path; } -export function createEditorStore(api, {t = en => en, notify = () => {}, onGenerationReady = () => {}, clock = clockDefault, pollMs = 2000, standIn: injected = null} = {}) { +export function createEditorStore(api, {t = en => en, notify = () => {}, onGenerationReady = () => {}, onGenerationChecking = () => {}, clock = clockDefault, pollMs = 2000, standIn: injected = null} = {}) { const sources = new Map(), sourceReads = new Map(), watchers = new Set(), pollers = new Set(), once = new Set(); let recovered = null, capabilityPoll = null; - let generationAvailable = false, generationGone = false, generationProbe = null, generationRetry = null, generationAttempts = 0; + let generationAvailable = false, generationGone = false, generationProbe = null, generationRetry = null, generationAttempts = 0, generationStalled = false, generationAsking = false; let disposed = false; const uploads = new Set(); const session = holdSession(); let activeProject = null, sessionTimer = null, sessionWrites = Promise.resolve(); @@ -217,6 +217,12 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener if (query.id) return copy(memoryProjects.get(query.id) || (query.id === 'memory' ? memoryProjects.get('memory') : null)); return copy([...memoryProjects.values()].find(record => record.tracks[0].clips[0]?.source?.id === query.work) || null); } + // A list keeps how many damaged records the server left out of it. + function counted(value) { + const items = Array.isArray(value?.items) ? value.items : []; + const unreadable = Number.isSafeInteger(value?.unreadable) && value.unreadable > 0 ? value.unreadable : 0; + return Object.defineProperty(items, 'unreadable', {value: unreadable}); + } // lost: called once after three reads in a row fail; the watch then stops. function watch(read, deliver, lost = null) { let failures = 0, poller = null; @@ -233,7 +239,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener } function askGenerationAgain() { const delay = [2000, 5000, 15000, 30000][generationAttempts++]; - if (delay === undefined) return; + if (delay === undefined) { generationStalled = true; return; } const timer = clock.setTimeout(() => { retry.dispose(); store.generationReady(); }, delay); const retry = generationRetry = {dispose() { clock.clearTimeout(timer); pollers.delete(retry); if (generationRetry === retry) generationRetry = null; }}; pollers.add(retry); @@ -257,6 +263,15 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener get generationAvailable() { return standIn ? standIn.generationAvailable ?? true : generationAvailable; }, // The server said for certain it cannot generate (not just a missed answer that is asked again). get generationGone() { return !standIn && generationGone; }, + // Every automatic check went unanswered: the page offers to check again. It stays so until an answer comes. + get generationStalled() { return !standIn && generationStalled && !generationAvailable && !generationGone; }, + get generationChecking() { return !standIn && generationAsking; }, + // Checks now and starts the automatic checks over; the page is told of every answer as before. + retryGeneration() { + if (standIn || generationAvailable || generationGone || disposed) return Promise.resolve(generationAvailable); + generationRetry?.dispose(); generationAttempts = 0; + return store.generationReady(); + }, generationReady() { if (standIn) return standIn.generationReady ? Promise.resolve(standIn.generationReady(ready => { if (!disposed) onGenerationReady(ready); })) @@ -273,14 +288,14 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener controller.abort(); resolve(null); }, 5000); }); const probe = {dispose() { clock.clearTimeout(timer); controller.abort(); finish(null); pollers.delete(probe); }}; - pollers.add(probe); + pollers.add(probe); generationAsking = true; if (!disposed) onGenerationChecking(); try { // true: present; false: no such route; null: no usable answer. answer = await Promise.race([Promise.resolve().then(() => request('/freevideo/chains', {signal: controller.signal})) .then(async response => response.status === 404 ? false : response.ok && Array.isArray((await response.json())?.chains) ? true : null) .catch(() => null), timeout]); - } finally { probe.dispose(); generationProbe = null; } + } finally { probe.dispose(); generationProbe = null; generationAsking = false; } if (disposed || generationGone) return false; if (answer === false) generationAbsent(); else if (answer) { generationAvailable = true; onGenerationReady(true); } @@ -324,10 +339,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener if (recovered) break; } }, load: id => load({id}), byWork: work => load({work}), - async projects() { - const value = await json('/freevideo/editor/projects', undefined, {items: []}); - return value.items || []; - }, + async projects() { return counted(await json('/freevideo/editor/projects', undefined, {items: []})); }, createSaveQueue(state, {onStatus = () => {}} = {}) { const queue = createSaveQueue({clock, session, onStatus, initial: state.record, getRecord: () => state.record, copySuffix: t(' (copy)', '(副本)'), write: (record, create, operation) => { const options = post({project: record, operation, ...(create ? {create} : {})}); return request('/freevideo/editor/project', {...options, keepalive: new TextEncoder().encode(options.body).length < 60000}); }, read: id => store.load(id), @@ -377,7 +389,7 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener if (Object.keys(fallback).length) cache(fallback); return value; }, - async imports() { return (await json('/freevideo/editor/imports', undefined, {items: []})).items || []; }, + async imports() { return counted(await json('/freevideo/editor/imports', undefined, {items: []})); }, watchImports(fn) { return watch(() => store.imports(), items => { fn(items); return true; }); }, upload(file, progress = () => {}) { if (disposed) return Promise.reject(new Error('editor_closed')); diff --git a/web/error_panel.js b/web/error_panel.js index e1d8a333..c3cd3f2b 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -47,6 +47,9 @@ const PLAIN_ERRORS = [ {key: 'media-mixed-layout', message: 'Choose keyframes or experimental references; mixed layouts are not supported', title: ['First and last frames cannot be used with references', '首尾帧不能和参考素材同时使用'], summary: ['Turn off the media cards or inputs you are not using, then generate again.', '请关闭本次不用的素材卡片或输入,再重新生成。']}, + {key: 'chain-too-long', prefixes: ['One continuous shot can have up to '], + title: ['This shot has too many parts', '这个镜头的段数太多'], + summary: ['Choose a shorter duration, or add the next shot in Edit.', '请缩短时长,或在剪辑视图中添加下一个镜头。']}, {key: 'continuation-frames', message: 'Frame count must be 5 + 17n and at least 39 (39, 56, 73, 90, ...).', aliases: ['Frames must be 0 to use Duration, or 5 + 17n and at least 39 (for example, 39, 56, 141 or 243). The total includes 22 overlap frames when continuing.'], title: ['Choose a supported frame count', '请选择支持的帧数'], diff --git a/web/failure_issue.js b/web/failure_issue.js index 26a7a0dd..88a3b7b8 100644 --- a/web/failure_issue.js +++ b/web/failure_issue.js @@ -23,13 +23,20 @@ export function failureStage(report) { } const TAIL = 1500, TAIL_HEAD = 500; +// The engine's one-line summary of a Mac run (machine, size, length, settings, retry). It travels as its +// own field, so a long traceback cannot cut it and it does not take room from the tail. +const RUN_LINE = /^Run: .*(?:\r?\n|$)/m; // The engine's failure text, not ComfyUI's traceback after it: details end with // the bridge's own frames. The error and stage lead that text and the child // log's error ends it, so a long one gives up its middle. -function cause(report) { +function failureText(report) { const message = String(report?.message || ''); - const text = message.includes('\n') ? message : String(report?.details || ''); + return message.includes('\n') ? message : String(report?.details || ''); +} + +function cause(report) { + const text = failureText(report).replace(RUN_LINE, ''); if (text.length <= TAIL) return text; return text.slice(0, TAIL_HEAD) + '\n...\n' + text.slice(-(TAIL - TAIL_HEAD - 5)); } @@ -44,9 +51,10 @@ export function failureIssue(report, kind, {lora = false, t = en => en} = {}) { `Stage: ${stage ? stage[0] : report?.stage || 'unknown'}`, `Error: ${first.trim().slice(0, 300)}`, `Kind: ${kind || 'unknown'}`].join('\n'); const types = (report?.nodes || []).map(node => node.type).filter(Boolean); + const run = RUN_LINE.exec(failureText(report))?.[0].trim().slice(5, 505); const summary = JSON.stringify({kind: kind || 'unknown', stage: report?.stage, exception_type: report?.exception_type, known_error: report?.known_error, nodes: types, settings: report?.settings, progress: report?.progress, - tail: cause(report)}); + run, tail: cause(report)}); const title = lora ? t('[Feedback] LoRA compatibility misjudged', '[Feedback] LoRA 兼容判断有误') : t('[Feedback] Generation did not finish', '[Feedback] 生成未完成') + (stage ? t(`: ${stage[0]}`, `:${stage[1]}`) : ''); return {title, text, summary}; diff --git a/web/freevideo.js b/web/freevideo.js index 081d66a8..a5392780 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -690,12 +690,14 @@ async function openFromMenu() { } } -const desktopNoticeEnabled = () => (app.extensionManager?.setting?.get?.(DESKTOP_SETTING) ?? app.ui?.settings?.getSettingValue?.(DESKTOP_SETTING)) === true; +const desktopNoticeEnabled = () => (app.extensionManager?.setting?.get?.(DESKTOP_SETTING) ?? app.ui?.settings?.getSettingValue?.(DESKTOP_SETTING) ?? true) === true; app.registerExtension({ name: "FreeVideo.UnifiedMedia", settings: [{id: DESKTOP_SETTING, name: text('Desktop notification when done', '完成时发桌面通知'), - type: 'boolean', defaultValue: false, onChange: value => { if (value) askDesktopPermission(); }}], + type: 'boolean', defaultValue: true, + // ComfyUI calls onChange(value, undefined) while registering; only a switch from off to on is a user request. + onChange: (value, oldValue) => { if (oldValue === false && value === true) askDesktopPermission(window, {explicit: true}); }}], // The one entry that stays in ComfyUI's own menu, whatever workflow is in front. commands: [{id: 'FreeVideo.Open', label: 'FreeVideo', function: openFromMenu}], menuCommands: [{path: ['Extensions'], commands: ['FreeVideo.Open']}], @@ -703,7 +705,8 @@ app.registerExtension({ async setup() { // One failing part must not take the others down; health.js shows what failed. for (const [where, start] of [['progress', () => progressConnection.start()], ['navigation', () => installNavigation(showStudio, agentInterfaceState(api), {openWorkflow: openFreeVideoWorkflow, recheck: () => agentInterfaceState(api)})], - ['updates', startUpdateChecks], ['done-notify', () => startDoneNotify({api, text, desktop: desktopNoticeEnabled, isFreeVideoNode})], + ['updates', startUpdateChecks], ['done-notify', () => startDoneNotify({api, text, desktop: desktopNoticeEnabled, isFreeVideoNode, + isFreeVideoWorkflow: () => (app.graph?._nodes || []).some(isGenerate)})], ['whitebox', () => startWhiteboxWorker({api})]]) { try { start(); } catch (error) { health({type: 'error', where, error}); } } diff --git a/web/length_plan.js b/web/length_plan.js index 773bad33..525405c5 100644 --- a/web/length_plan.js +++ b/web/length_plan.js @@ -32,7 +32,7 @@ export function readLengthPlan(reply) { export function classifyPlanError(reply) { if (reply?.code === 'device_limit') return 'continuation_unavailable'; - if (reply?.code === 'too_long') return 'too_long'; + if (reply?.code === 'too_long' || reply?.code === 'too_many_parts') return 'too_long'; if (['block_too_long', 'block_too_short', 'too_short', 'invalid'].includes(reply?.code)) return 'other'; const value = reply?.error; const message = reply?.code || (typeof value === 'string' ? value : value?.code || value?.message || ''); diff --git a/web/result_actions.css b/web/result_actions.css index 2f0c1019..b3b1b2da 100644 --- a/web/result_actions.css +++ b/web/result_actions.css @@ -64,3 +64,5 @@ @keyframes fv-menu-enter{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} @media(prefers-reduced-motion:reduce){.fv-actions *{transition:none!important;animation:none!important}} .fv-actions[data-unavailable=true] :is(a.fv-action[download],.fv-action-extend,.fv-share-trigger){opacity:.45;cursor:default;pointer-events:none} +.fv-action-copy .fv-copy-text{display:inline-grid;justify-items:start} +.fv-action-copy .fv-copy-text::after{content:attr(data-widest);grid-area:1/1;visibility:hidden;height:0;overflow:hidden} diff --git a/web/result_actions.js b/web/result_actions.js index cb15dd77..75befcd9 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -18,6 +18,7 @@ const ICONS = { lock: '', close: '', expand: '', + copy: '', trash: '', }; @@ -130,6 +131,15 @@ export function actionsUnavailable(bar) { } } +// Writes text to the clipboard; resolves false when neither the clipboard API nor the old copy command is allowed. +export async function copyText(text) { + try { await navigator.clipboard.writeText(text); return true; } catch { /* fall through to the selection copy */ } + const area = document.createElement('textarea'); area.value = text; area.setAttribute('readonly', ''); + area.style.cssText = 'position:fixed;top:0;left:0;opacity:0'; + document.body.append(area); area.select(); + try { return document.execCommand('copy'); } catch { return false; } finally { area.remove(); } +} + // Download the video first; share and the report follow. A first-pass preview // leads with `secondPass` instead, and saved creations end with `remove`. // `diagnostic` downloads the redacted report (and may offer to send it); a @@ -142,6 +152,17 @@ export function resultActions(record, t, {diagnostic = null, secondPass = null, const download = action('a', t('Download video', '下载视频'), 'download', secondPass ? 'fv-action-secondary' : 'fv-action-primary'); download.href = outputDownloadURL(api, record.video); download.download = ''; bar.append(download); + const copy = action('button', t('Copy path', '复制路径'), 'copy', 'fv-action-secondary fv-action-copy'); + const text = copy.lastElementChild; let timer = 0; + // The button keeps the width of its longest label, so the bar does not shift when the label changes. + text.className = 'fv-copy-text'; + text.dataset.widest = [t('Copy path', '复制路径'), t('Copied', '已复制'), t('Could not copy', '复制失败')].sort((a, b) => b.length - a.length)[0]; + copy.onclick = async () => { + clearTimeout(timer); + text.textContent = await copyText(record.video) ? t('Copied', '已复制') : t('Could not copy', '复制失败'); + timer = setTimeout(() => { text.textContent = t('Copy path', '复制路径'); }, 1800); + }; + bar.append(copy); } if (extend && canExtend(record)) { const button = action('button', extendText(t, 'action'), 'extend', 'fv-action-secondary fv-action-extend'); diff --git a/web/studio.css b/web/studio.css index 22482d4e..993a7ce1 100644 --- a/web/studio.css +++ b/web/studio.css @@ -31,6 +31,10 @@ .fv-prompt-guide a:hover,.fv-studio .fv-prompt-guide a:hover{background:transparent;color:var(--fv-ink)} .fv-prompt-guide a:focus-visible{outline:2px solid var(--fv-accent);outline-offset:3px} .fv-studio .fv-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px}.fv-studio .fv-field{display:block;min-width:0}.fv-studio .fv-field>span{display:block;font-size:var(--fv-micro);color:var(--fv-muted);margin:0 0 7px} +/* Links inside the length hint and the length note read as words; a label never breaks across lines. */ +.fv-studio .fv-length-link{display:inline;min-height:0;padding:0;border:0;border-radius:0;background:transparent;color:var(--fv-accent);font:inherit;vertical-align:baseline;text-decoration:underline;text-underline-offset:3px;white-space:nowrap} +.fv-studio .fv-length-link:hover{background:transparent;color:var(--fv-ink)} +.fv-studio .fv-length-link:focus-visible{outline:2px solid var(--fv-accent);outline-offset:3px} .fv-studio .fv-resolution-select{position:relative;min-width:0} .fv-studio .fv-resolution-select select{appearance:none;-webkit-appearance:none;color:transparent;padding-right:36px} .fv-studio .fv-resolution-select option{color:var(--fv-ink)} diff --git a/web/studio.js b/web/studio.js index fde4fb0a..071a152d 100644 --- a/web/studio.js +++ b/web/studio.js @@ -658,8 +658,10 @@ export function openStudio(node, {extendFrom = null} = {}) { tools.append(feedbackButton(t), button(t('Settings', '设置'), openSetup, 'fv-quiet')); // The Agent's page is the fourth tab, shown where the Agent interface is on; the guide's last step circles it. agentTabReady(agentInterfaceState(api), () => agentInterfaceState(api)); - const navigation = viewSwitch('studio', () => {}, () => showNodes(node, dialog), - () => openEditorView(node, {studio: dialog}), () => openAutopilot(node, {studio: dialog})); + // The length hint links to the same two views, through the same actions. + const toEditor = () => openEditorView(node, {studio: dialog}), toAutopilot = () => openAutopilot(node, {studio: dialog}); + const navigation = viewSwitch('studio', () => {}, () => showNodes(node, dialog), toEditor, toAutopilot); + const autopilotTab = navigation.querySelector('[data-view="autopilot"]'); // The guide starts after the tab settles: it fixes its steps from the anchors present at that moment. const agentDiscovery = navigation.ready; header.append(brand, navigation, tools); @@ -959,8 +961,12 @@ export function openStudio(node, {extendFrom = null} = {}) { } advanced.append(custom); lengthControl = createStudioLength({api, node, seconds, advanced, fields, t, field, - setSeconds: seconds => set(node, 'seconds', seconds), onChange: () => { if (runButtonReady) { updateRunButton(); showEffortEstimate(); updateDuration(); } }}); + setSeconds: seconds => set(node, 'seconds', seconds), onChange: () => { if (runButtonReady) { updateRunButton(); showEffortEstimate(); updateDuration(); } }, + onEditor: toEditor, onAutopilot: toAutopilot, autopilot: !autopilotTab.hidden}); cleanup.push(() => lengthControl.dispose()); + // The tab settles once the Agent interface has answered, and again if a failed check is retried. + const autopilotWatch = new MutationObserver(() => { lengthControl.autopilot = !autopilotTab.hidden; }); + autopilotWatch.observe(autopilotTab, {attributes: true, attributeFilter: ['hidden']}); cleanup.push(() => autopilotWatch.disconnect()); // Under the length row, only where a video is continued and the node can take it. const firstFrame = firstFrameRow(t, 'fv-first-frame'); firstFrame.row.hidden = true; fields.after(firstFrame.row); diff --git a/web/studio_length.js b/web/studio_length.js index 5cfa1c35..bc72c781 100644 --- a/web/studio_length.js +++ b/web/studio_length.js @@ -25,8 +25,10 @@ export async function readLengthCapability(api, context, signal, onReply = () => } catch { return null; } } +// onEditor and onAutopilot are the creation panel's own view-switch actions; autopilot says whether that tab shows. export function createStudioLength({api, node, seconds, advanced, fields, t, field, setSeconds, - onChange = () => {}, capability = readLengthCapability, delay = 220, capabilityTimeout = 10000}) { + onChange = () => {}, capability = readLengthCapability, delay = 220, capabilityTimeout = 10000, + onEditor = () => {}, onAutopilot = () => {}, autopilot = false}) { const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text) e.textContent = text; return e; }; const controls = el('div', 'fv-length-controls'); controls.style.marginTop = '14px'; const single = el('input'); single.type = 'checkbox'; single.dataset.fvLength = 'single'; @@ -44,11 +46,27 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie // The limits are stated before anything is typed, not only when a request is refused. const NO_PARTS = t('This device cannot make longer videos in parts.', '此设备不支持分段生成长视频。'); let noParts = false; - const hint = el('p', 'fv-muted fv-length-hint', t('Longer videos are made in parts automatically, up to 5 parts.', '较长的视频会自动分段,最多 5 段。')); + // A storyboard goes to the same views as the tabs above; Autopilot is named only while its tab shows. + let autopilotShown = Boolean(autopilot); + const viewLink = (label, action) => { const link = el('button', 'fv-length-link', label); link.type = 'button'; link.onclick = action; return link; }; + function storyboard() { + const parts = [t('For a storyboard, use ', '有分镜剧本时,请用'), viewLink(t('Edit', '剪辑视图'), onEditor)]; + if (autopilotShown) { + const link = viewLink(t('Autopilot', 'AI 全自动'), onAutopilot); link.setAttribute('translate', 'no'); + parts.push(t(' or ', '或'), link); + } + return [...parts, t('.', '。')]; + } + const storyboardText = () => storyboard().map(part => typeof part === 'string' ? part : part.textContent).join(''); + const TOO_LONG = () => t('This shot is too long. Choose a shorter duration. ', '这个镜头太长,请缩短时长。'); + const hint = el('p', 'fv-muted fv-length-hint'); + const writeHint = () => noParts ? hint.textContent = NO_PARTS + : hint.replaceChildren(t('Each run makes one continuous shot, split into up to 8 parts when longer. ', '每次生成一个连续镜头,较长时自动分段,最多 8 段。'), ...storyboard()); + writeHint(); fields.after(hint); let limits = null, planLimits = null, contextKey = '', revision = 0, timer = null, request = null, capabilityRequest = null, capabilityRevision = 0; let capabilityState = 'idle', waiting = false, waitingTimer = null, timeoutTimer = null, submitted = false; - let plan = null, error = null, absent = false, disposed = false, pending = null, edited = false, blockEdited = false; + let plan = null, error = null, absent = false, disposed = false, pending = null, edited = false, blockEdited = false, plannedSeconds = null; const remember = (key, value) => { node.properties ||= {}; node.properties[key] = value; node.graph?.change(); node.setDirtyCanvas?.(true, true); }; const total = () => Number(seconds.value); const effectiveSingle = () => node.properties?.[LENGTH_PROPERTIES.single] === true && total() <= (planLimits?.singleMaximum ?? 15); @@ -82,7 +100,8 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie // The same sentence is the problem note once a length needs parts: say it once. hint.hidden = absent || (noParts && Boolean(error)); note.hidden = absent || !error; - note.textContent = error ? errorMessage(error) : ''; + if (error === 'too_long') note.replaceChildren(TOO_LONG(), ...storyboard()); + else note.textContent = error ? errorMessage(error) : ''; if (error === 'other') note.append(issueButton(t, () => ({where: ['Video length planning', '视频分段规划'], message: errorMessage('other')}))); note.style.margin = noParts && error === 'continuation_unavailable' ? '12px 0' : ''; // as spaced as the line it replaces } @@ -90,11 +109,11 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie return {single_unavailable: t('This length is beyond a single run.', '这个长度超出了单次能生成的范围。'), continuation_unavailable: noParts ? NO_PARTS : t('Choose a smaller picture size to make a longer video.', '如需生成更长的视频,请选择更小的画面大小。'), - too_long: t('This video is too long. Choose a shorter duration.', '这条视频太长,请缩短时长。'), + too_long: TOO_LONG() + storyboardText(), other: t('Could not plan this video. Try again.', '暂时无法安排这条视频的分段,请重试。')}[kind]; } function invalidate() { - revision++; plan = null; error = null; clearTimeout(timer); timer = null; request?.abort(); request = null; pending = null; + revision++; plan = null; error = null; clearTimeout(timer); timer = null; request?.abort(); request = null; pending = null; plannedSeconds = total(); paint(); onChange(); } async function calculate(version = revision) { @@ -132,13 +151,16 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie const value = total() > 30 ? Math.round(total()) : total(); seconds.value = value; if (value > 60) remember(LENGTH_PROPERTIES.seconds, value); else if (node.properties && Object.hasOwn(node.properties, LENGTH_PROPERTIES.seconds)) { delete node.properties[LENGTH_PROPERTIES.seconds]; node.graph?.change(); } - setSeconds(Math.min(60, value)); invalidate(); schedule(); return true; + setSeconds(Math.min(60, value)); + // Leaving the field with the length already planned keeps that plan and its note, so a link in the note can be pressed. + if (value !== plannedSeconds || error === 'other') { invalidate(); schedule(); } + return true; } paint(); // Devices that cannot continue a video say so up front. An unreadable answer keeps the general line. Promise.resolve().then(() => api.fetchApi('/freevideo/chains')).then(reply => reply?.ok ? reply.json() : null).then(value => { if (disposed || value?.continuation !== false) return; - noParts = true; hint.textContent = NO_PARTS; invalidate(); schedule(); + noParts = true; writeHint(); invalidate(); schedule(); }).catch(() => {}); return { commit, options, get plan() { return plan; }, get absent() { return absent; }, @@ -148,6 +170,8 @@ export function createStudioLength({api, node, seconds, advanced, fields, t, fie get capabilityState() { return capabilityState; }, get estimating() { return waiting; }, get blocked() { return !absent && Boolean(error); }, get message() { return error ? errorMessage(error) : ''; }, fallback() { absent = true; invalidate(); }, + // Follows the Autopilot tab when its state settles after the panel opened. + set autopilot(shown) { if (disposed || Boolean(shown) === autopilotShown) return; autopilotShown = Boolean(shown); writeHint(); paint(); onChange(); }, freeze() { submitted = true; }, async ready() { while (!disposed) { diff --git a/web/tutorial_book_content.js b/web/tutorial_book_content.js index 635ce7fa..5d5d901e 100644 --- a/web/tutorial_book_content.js +++ b/web/tutorial_book_content.js @@ -33,8 +33,8 @@ export const TUTORIAL_CHAPTERS = [ en: ['Enter a longer duration. It is made in parts automatically. "Available sizes and lengths" shows how long each size can be and about how long it takes.', 'Choose "Generate video".', 'Choose "Extend" on a finished video to continue it.', 'Parts can also be added one by one in "Edit".'], }, limits: { - zh: ['一条视频最多 5 段,创作面板中时长最长 60 秒。', '分段生成和接着生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], - en: ['A video has at most 5 parts, and the creation panel accepts up to 60 seconds.', 'Generating in parts and Extend need an NVIDIA graphics card or a Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], + zh: ['一个连续镜头最多 8 段,创作面板中时长最长 60 秒;分镜剧本可在剪辑视图中逐个镜头添加,或用 AI 全自动。', '分段生成和接着生成需要 NVIDIA 显卡或 Mac。', '段数越多,衔接处越容易出现变化;适合切换镜头处,可直接开始新的一段。'], + en: ['One continuous shot has at most 8 parts, and the creation panel accepts up to 60 seconds. For a storyboard, add shots one by one in Edit, or use Autopilot.', 'Generating in parts and Extend need an NVIDIA graphics card or a Mac.', 'The more parts are joined, the more the joins may change. Where a cut fits, cut to a new shot and start a fresh part.'], }, }, { @@ -51,8 +51,8 @@ export const TUTORIAL_CHAPTERS = [ en: ['Select the part to continue.', 'Click + next to the prompt to upload or generate images, up to 3.', 'Describe it, then choose "Generate".'], }, limits: { - zh: ['接续生成最多使用 3 张参考图,不使用首帧和尾帧。', '剪辑视图只能添加图片;参考视频和音频在创作面板的“素材”中添加。', '同样最多 5 段,需要 NVIDIA 显卡或 Mac。'], - en: ['A part that continues the previous one uses up to 3 reference images and no first or last frames.', 'Edit takes images only. Add reference videos and audio under "Media" in the creation panel.', 'The same 5-part limit applies, and an NVIDIA graphics card or a Mac is required.'], + zh: ['接续生成最多使用 3 张参考图,不使用首帧和尾帧。', '剪辑视图只能添加图片;参考视频和音频在创作面板的“素材”中添加。', '同样最多 8 段;需要 NVIDIA 显卡或 Mac。'], + en: ['A part that continues the previous one uses up to 3 reference images and no first or last frames.', 'Edit takes images only. Add reference videos and audio under "Media" in the creation panel.', 'The same 8-part limit applies. An NVIDIA graphics card or a Mac is required.'], }, }, ];