diff --git a/freevideo_engine/agent/cast.py b/freevideo_engine/agent/cast.py index 6b2a2e21..035d3e8a 100644 --- a/freevideo_engine/agent/cast.py +++ b/freevideo_engine/agent/cast.py @@ -1,4 +1,4 @@ -"""The cast library: durable character and setting cards shared by every film. +"""The cast library: durable character, setting and prop cards shared by every film. One JSON file per card under /cast, written atomically under Jobs' lock, the same way films are kept. Pictures and voices enter a card only as input @@ -21,7 +21,7 @@ from .jobs import ACTIVE, DONE, JobError, iso CARD = re.compile(r'c[0-9a-f]{12}') -KINDS = ('character', 'setting') +KINDS = ('character', 'setting', 'prop') ORIGINS = ('user', 'agent', 'builtin') LABELS = schema.LABELS EDITABLE = ('name', 'description', 'images', 'voice') @@ -39,6 +39,7 @@ TURN = dict(aspect='9:16', seconds=6) CLOSE_UP = dict(aspect='1:1', seconds=4) SCENE = dict(aspect='16:9', seconds=6) +PROP = dict(aspect='1:1', seconds=4) # The sheet of every view a shot carries for each person, put together here from the card's own pictures: up to six # equal cells (three to a row) on a light grey ground, each picture scaled whole and centred, no text. @@ -58,6 +59,9 @@ def reference_prompt(card, *, face=False): who = (card.get('description') or card['name']).strip().rstrip('.') if card['kind'] == 'setting': return ('%s. A slow, steady camera pan across the place, no people, sharp focus, no text.' % who) + if card['kind'] == 'prop': + return ('%s. The object alone in the centre of the frame, slowly turning in place, in front of a plain ' + 'light grey backdrop, even soft light, a static camera, sharp focus, no people, no text.' % who) if face: return ('%s. A close-up of the face and shoulders, looking straight at the camera with a neutral ' 'expression, in front of a plain light grey studio backdrop. The person stays still. ' @@ -676,7 +680,7 @@ def generate(self, identity, value=None, *, by='builtin', ref=None, revision=Non 'GET /freevideo/agent/v1/jobs/%s; when it ends, get the card.' % job, card=view(card), job=job) if value['face'] and card['kind'] != 'character': - raise spec.SpecError([schema.diagnostic('/face', 'false for a setting card', True)]) + raise spec.SpecError([schema.diagnostic('/face', 'false for a setting or prop card', True)]) ready = value.get('source') != 'video' and getattr(self.jobs.port, 'image_ready', lambda: False)() from ..image_gen.loras import validate_selection loras = validate_selection(value.get('loras', [])) @@ -689,12 +693,12 @@ def generate(self, identity, value=None, *, by='builtin', ref=None, revision=Non seed = secrets.randbelow(spec.MAX_SEED + 1) token = uuid.uuid4().hex[:12] if source == 'image': - from ..image_gen.cast_views import VIEWS, SETTING_VIEWS + from ..image_gen.cast_views import VIEWS, SETTING_VIEWS, PROP_VIEWS # A picture takes seconds, so a character always gets every view, the face close-up included. - views = list(SETTING_VIEWS if card['kind'] == 'setting' else VIEWS) + views = list(SETTING_VIEWS if card['kind'] == 'setting' else PROP_VIEWS if card['kind'] == 'prop' else VIEWS) items = self._image_items(card, views[:1], seed, loras=loras) else: - parts = [('turn', SCENE if card['kind'] == 'setting' else TURN, False)] + parts = [('turn', SCENE if card['kind'] == 'setting' else PROP if card['kind'] == 'prop' else TURN, False)] if value['face']: parts.append(('face', CLOSE_UP, True)) items = [dict(kind='generate', prompt=reference_prompt(card, face=face), seed=seed, @@ -847,7 +851,7 @@ def _land_image(self, identity, generating, hidden, rows): staged.append((temporary, file)) part = hidden['job_views'][job] pictures.append(dict(media=dict(file=file), by=generating['by'], from_job=job, - generated=True, label='other' if hidden['kind'] == 'setting' else part)) + generated=True, label='other' if hidden['kind'] in ('setting', 'prop') else part)) self._settle(identity, hidden, pictures, failure, staged) finally: for out, _ in staged: @@ -893,7 +897,7 @@ def _land(self, identity, generating, hidden, rows): failure = failure or dict(code='internal_error', job=job, message='The reference pictures could not be saved.') continue - setting = hidden.get('kind') == 'setting' + setting = hidden.get('kind') in ('setting', 'prop') for n, row_frame in enumerate(picked): if row_frame is None: temporary[n].unlink(missing_ok=True) diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index b61cf912..c5a435cd 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -629,7 +629,7 @@ def connection_text(value): GUIDE['cast_routes'] = [dict(operation=name, method=method, path=path, description=summary) for name, (method, path, summary) in CAST_OPERATIONS.items()] GUIDE['cast'] = [ - 'A card is a character or setting: name, a one-sentence English description used in prompts, up to six ' + 'A card is a character, setting or prop: name, a one-sentence English description used in prompts, up to six ' 'labelled pictures (front, three_quarter, side, back, face, sheet, other) and an optional 2 to 15 s voice.', 'Changes need the revision you read (body revision or If-Match); stale_revision returns the current card in ' 'error.card. Fields the user changed return changed_by_user: keep the user\'s version.', diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index bdfc2b8e..b2bd33ec 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -688,7 +688,7 @@ def response_refs(value): if isinstance(identity, str): if identity.startswith('d') and 'shots' in value: refs['drama'] = identity - elif identity.startswith('c') and value.get('kind') in ('character', 'setting'): + elif identity.startswith('c') and value.get('kind') in ('character', 'setting', 'prop'): refs['cast'] = identity return refs diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index b93dbb12..52f1375a 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -702,7 +702,7 @@ def drama_schema(name): CAST_OPERATIONS = { - 'create': ('POST', '/cast', 'Create a character or setting card when no existing card fits'), + 'create': ('POST', '/cast', 'Create a character, setting or prop card when no existing card fits'), 'list': ('GET', '/cast', 'Search the cast library before casting; reuse a matching card'), 'get': ('GET', '/cast/{id}', 'Read a card with its pictures, voice and revision'), 'update': ('PATCH', '/cast/{id}', 'Change a card; send the revision you read; fields the user changed stay theirs'), @@ -724,7 +724,7 @@ def cast_schema(name, *, local=False): images=array(obj(dict(media=picture, label=enum(LABELS)), ('media',)), 0, 6), voice=nullable(obj(dict(media=sound, start=number(0, 3600, default=0), end=number(0, 3600)), ('media',)))) if name == 'create': - return obj(dict(kind=enum(('character', 'setting'), default='character'), **fields, ref=REF), ('name',)) + return obj(dict(kind=enum(('character', 'setting', 'prop'), default='character'), **fields, ref=REF), ('name',)) if name == 'update': fields['images']['items']['properties']['label'] = {'type': 'string'} return obj(dict(fields, revision=number(1, integer=True), ref=REF)) @@ -738,7 +738,7 @@ def cast_schema(name, *, local=False): description='Replace pictures the user did not add; default fills empty slots.'), revision=number(1, integer=True), ref=REF)) if name == 'list': - return obj(dict(q=string(200), kind=enum(('character', 'setting')), origin=enum(('user', 'agent', 'builtin')), + return obj(dict(q=string(200), kind=enum(('character', 'setting', 'prop')), origin=enum(('user', 'agent', 'builtin')), production=PRODUCTION, limit=number(1, 200, integer=True, default=50))) return EMPTY @@ -753,7 +753,7 @@ def cast_view(): generated = obj(dict(job=JOB, face_job=nullable(JOB), source=enum(('image', 'video')), seed=number(0, 2**53 - 1, integer=True), prompt_version=number(1, integer=True)), ('job', 'face_job', 'seed', 'prompt_version')) - return dict(obj(dict(id=CAST, kind=enum(('character', 'setting')), name=string(120), + return dict(obj(dict(id=CAST, kind=enum(('character', 'setting', 'prop')), name=string(120), description=nullable(string(1000)), revision=number(1, integer=True), images=array(picture, 0, 6), candidates=array(picture, 0, 6), generating=nullable(generating), generated=nullable(generated), diff --git a/freevideo_engine/comfy_assets.py b/freevideo_engine/comfy_assets.py index a9bc04e8..ea6a349f 100644 --- a/freevideo_engine/comfy_assets.py +++ b/freevideo_engine/comfy_assets.py @@ -104,9 +104,9 @@ def resolve_assets(serialized, input_directory): or media_kind(row['file']) != 'video'): raise ValueError('depth is only allowed on video references') # The character an @ mention added the row for; a continued part keeps one picture per character. - if 'cast' in row and (set(row['cast'] if isinstance(row['cast'], dict) else ('?',)) not in ({'id'}, {'id', 'setting'}) + if 'cast' in row and (set(row['cast'] if isinstance(row['cast'], dict) else ('?',)) not in ({'id'}, {'id', 'setting'}, {'id', 'prop'}) or not isinstance(row['cast']['id'], str) or not 0 < len(row['cast']['id']) <= 128 - or row['cast'].get('setting', True) is not True): + or row['cast'].get('setting', True) is not True or row['cast'].get('prop', True) is not True): raise ValueError('Invalid media item') if type(row.get('enabled', True)) is not bool: raise ValueError('Media enabled must be a boolean') @@ -141,6 +141,8 @@ def resolve_assets(serialized, input_directory): reference['cast'] = cast_id(row) # only a continued part reads it (comfy_media.continued) if is_setting(row): reference['scene'] = True + if is_prop(row): + reference['prop'] = True if 'early_steps' in row: from .media_request import validate_early_steps validate_early_steps(dict(row, kind=kind)) @@ -199,7 +201,7 @@ def continued_assets(serialized): image = False if image: pictures.append(row) - return json.dumps([_kept_row(row) for row in pick_pictures(pictures, cast_id, is_setting)] + original) + return json.dumps([_kept_row(row) for row in pick_pictures(pictures, cast_id, is_setting, is_prop)] + original) def cast_id(row): @@ -215,10 +217,16 @@ def is_setting(row): return cast_id(row) is not None and cast.get('setting') is True +def is_prop(row): + """Whether a panel row carries the picture of a prop card (an @ mention of a prop).""" + cast = row.get('cast') if isinstance(row, dict) else None + return cast_id(row) is not None and cast.get('prop') is True + + def _kept_row(row): kept = dict(file=row['file'], role='reference') if cast_id(row) is not None: - kept['cast'] = dict(id=cast_id(row), **({'setting': True} if is_setting(row) else {})) + kept['cast'] = dict(id=cast_id(row), **({'setting': True} if is_setting(row) else {'prop': True} if is_prop(row) else {})) return kept diff --git a/freevideo_engine/comfy_launcher_api.py b/freevideo_engine/comfy_launcher_api.py index a2e14d5a..9d1a2ec7 100644 --- a/freevideo_engine/comfy_launcher_api.py +++ b/freevideo_engine/comfy_launcher_api.py @@ -136,6 +136,12 @@ def register(): async def launcher(request): return web.json_response(info) + @server.routes.get('/freevideo/launcher/last-exit') + async def last_exit(request): + """When the ComfyUI before this one stopped by itself: numbers and fixed words, never log text.""" + from .engine_exit import public, read + return web.json_response(dict(exit=public(read(info['engine_root']))), headers={'Cache-Control': 'no-store'}) + @server.routes.get('/freevideo/launcher/workflow') async def workflow(request): value = json.loads((source / 'example_workflows' / 'FreeVideo-All-in-One.json').read_text(encoding='utf-8')) diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index b8118d5c..d1ffbe06 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -736,6 +736,7 @@ def __init__(self, source, *, child_environment=None): self._access_poll_thread = None self.cancelled = threading.Event() self.server = None + self._exit_seen = None self.server_url = None self.server_address = None self.access_proxy = None @@ -852,13 +853,38 @@ def refresh_access_links(self): password_set=password_set) self.state = dict(self.state, lan_status='' if self.access_links else 'no-address', lan_password_set=password_set) + def check_server_exit(self): + """Record a ComfyUI this launcher started that ended while it was open, and offer to start it again. + + Only a process the launcher did not stop itself counts (stop_owned_server clears self.server under + the same lock), and an exit the user made (a normal exit, Ctrl+C) changes nothing. Returns the record or None. + """ + with self._server_lock: + server = self.server + if (server is None or self.state.get('status') != 'open' or self._exit_seen is server + or server.poll() is None): + return None + self._exit_seen = server + code, log = server.returncode, self.server_log + engine = (self.selection or {}).get('engine') + from . import engine_exit + if not engine or engine_exit.by_the_user(code): + return None + value = engine_exit.record(engine, code, log) + self.state = dict(self.state, status='restart-required', engine_exit=engine_exit.public(value)) + return value + def check_access_proxy(self): """Poll child lifetimes and refresh network links without moving ComfyUI.""" if self.access_proxy is None: return if self.server is not None and self.server.poll() is not None: - self._access_stopped() - self.state = dict(self.state, status='ready') + with self._server_lock: + # This check may see the exit before the launcher's own does: record it first. + self.check_server_exit() + self._access_stopped() + # An engine that ended while open keeps its "stopped" notice. + self.state = dict(self.state, status='restart-required' if self.state.get('engine_exit') else 'ready') elif self.access_proxy.poll() is not None: self._access_stopped() elif time.monotonic() - self.access_polled >= 5: @@ -1330,7 +1356,7 @@ def _open_access_proxy(self): def _connect_comfy(self): retry, self._scope_retry = self._scope_retry, False self.state = {key: value for key, value in self.state.items() - if key != 'moved_from' and (retry or key != 'plugin_scoped')} + if key not in ('moved_from', 'engine_exit') and (retry or key != 'plugin_scoped')} if not self.selection or not self.selection['ready']: raise ValueError('Choose and inspect ComfyUI first') selected = self.selection diff --git a/freevideo_engine/comfy_media.py b/freevideo_engine/comfy_media.py index 7dccb659..8178ec4b 100644 --- a/freevideo_engine/comfy_media.py +++ b/freevideo_engine/comfy_media.py @@ -60,8 +60,8 @@ def continued(exported, notes=None): original = [row for row in media.get('references', []) if row.get('kind') == 'audio' and row.get('use') == 'original'] keyframes = [name for name in ('first', 'last') if media.get(name)] # Characters added by an @ mention keep one picture each when there are several (pick_pictures). - chosen = pick_pictures(pictures, lambda row: None if row.get('scene') else row.get('cast'), - lambda row: row.get('scene') is True) + chosen = pick_pictures(pictures, lambda row: None if row.get('scene') or row.get('prop') else row.get('cast'), + lambda row: row.get('scene') is True, lambda row: row.get('prop') is True) candidates = [_plain(row) for row in chosen] + [{'kind': 'image', 'path': media[name]} for name in keyframes] if candidates or original: kept['references'] = candidates[:PICTURES_MAX] + original @@ -79,7 +79,7 @@ def continued(exported, notes=None): def _plain(row): """A reference row without the panel's character or scene mark, which the engine's request does not carry.""" - return {key: value for key, value in row.items() if key not in ('cast', 'scene')} + return {key: value for key, value in row.items() if key not in ('cast', 'scene', 'prop')} def continued_note(pictures, keyframes, used): diff --git a/freevideo_engine/continuation.py b/freevideo_engine/continuation.py index c47273d4..316b2c2b 100644 --- a/freevideo_engine/continuation.py +++ b/freevideo_engine/continuation.py @@ -42,14 +42,15 @@ PICTURES_MAX = 3 -def pick_pictures(rows, person, scene=None): +def pick_pictures(rows, person, scene=None, prop=None): """The pictures a continued part keeps from image rows in panel order: at most PICTURES_MAX. person(row) is the id of the character a row belongs to, or None. With two or more characters each keeps only the last of its rows (the face close-up, or the one picture its card has) so that everyone is seen; the other pictures follow. With none or one character the first rows are kept. scene(row) is true for the picture of a setting card: the last such row keeps one place, after the others, so the - part still shows the same place.""" + part still shows the same place. prop(row) is true for the picture of a prop card: props come after the + characters and the place, so they keep only the positions those leave free, in panel order, before the place.""" rows = list(rows) place = None if scene is not None: @@ -57,6 +58,10 @@ def pick_pictures(rows, person, scene=None): if places: place = places[-1] rows = [row for row in rows if not scene(row)] + things = [] + if prop is not None: + things = [row for row in rows if prop(row)] + rows = [row for row in rows if not prop(row)] last = {} for index, row in enumerate(rows): if person(row) is not None: @@ -68,6 +73,7 @@ def pick_pictures(rows, person, scene=None): faces = [rows[index] for index in sorted(last.values())] others = [row for row in rows if person(row) is None] chosen = (faces + others)[:room] + chosen = chosen + things[:room - len(chosen)] return chosen + ([place] if place is not None else []) diff --git a/freevideo_engine/engine_exit.py b/freevideo_engine/engine_exit.py new file mode 100644 index 00000000..aa5982c2 --- /dev/null +++ b/freevideo_engine/engine_exit.py @@ -0,0 +1,126 @@ +"""The last time the ComfyUI that the launcher started ended while it was open. + +The launcher notices the exit, writes this record into the installation's +launcher folder and tells the user. The next ComfyUI reads the record back, so +the page, a feedback message and a diagnostic report can say why the earlier +one stopped. The file keeps the last lines of the engine log for the user's own +machine; everything that leaves it (`public`) is numbers and fixed words only. +""" +import json +import math +import re +import signal +import time +from pathlib import Path + +from .exit_diagnostics import clean_reading, exit_reading, memory_sample +from .long_paths import io_path +from .monitoring import save +from .system import windows + +FILE = 'last-exit.json' +LOG_LINES = 20 +LOG_BYTES = 16 * 1024 +LINE_CHARS = 300 +# A Windows status in hex, a signal name or a plain exit status: nothing else is let through. +_CODE_TEXT = re.compile(r'^(?:0x[0-9A-F]{8}|SIG[A-Z0-9]{2,10}|[0-9]{1,3})$') + + +def location(engine): + return io_path(engine) / 'launcher' / FILE + + +def signal_name(number): + try: + return signal.Signals(number).name + except ValueError: + return 'SIG%d' % number + + +def code_text(code, system=None): + """The exit status as a person reads it: 0xC0000005 on Windows, SIGKILL for a signal, else the number.""" + if type(code) is not int: + return '' + on_windows = windows() if system is None else system == 'Windows' + if on_windows or code > 255: + return '0x%08X' % (code & 0xFFFFFFFF) + if code < 0: + return signal_name(-code) + if code > 128: + return signal_name(code - 128) + return str(code) + + +def by_the_user(code, system=None): + """An exit the user made: a normal exit, Ctrl+C or a closed console window (Windows 0xC000013A, SIGINT).""" + if code == 0: + return True + return exit_reading(code, system=system)['exit_class'] == 'interrupted' or code_text(code, system) == 'SIGINT' + + +def log_tail(log, lines=LOG_LINES): + """The last lines of the engine log, for this computer only; an unreadable log gives an empty list.""" + try: + path = io_path(log) + with path.open('rb') as stream: + stream.seek(0, 2) + stream.seek(max(0, stream.tell() - LOG_BYTES)) + text = stream.read().decode('utf-8', errors='replace') + except (OSError, TypeError, ValueError): + return [] + rows = [row.rstrip()[:LINE_CHARS] for row in text.splitlines() if row.strip()] + return rows[-lines:] + + +def build(code, log=None, *, now=None, system=None): + record = dict(at=time.time() if now is None else now, code_text=code_text(code, system)) + record.update(exit_reading(code, system=system)) + # The computer's memory when the exit was seen; the readings of this process are not the engine's. + record['memory'] = {key: value for key, value in memory_sample().items() if not key.startswith('process_')} + record['log_tail'] = log_tail(log) if log else [] + return record + + +def record(engine, code, log=None, *, now=None, system=None): + """Write the record of an exit and return it. A failed write loses the file, never the notice.""" + value = build(code, log, now=now, system=system) + try: + save(location(engine), value, plain_paths=False) + except Exception: + pass + return value + + +def read(engine): + """The stored record, or None when there is none or it cannot be read.""" + try: + value = json.loads(location(engine).read_text(encoding='utf-8')) + except (OSError, ValueError, TypeError): + return None + return value if isinstance(value, dict) else None + + +def public(value): + """What may leave this computer: the time, the status and the fixed words of exit_diagnostics; no log text.""" + if not isinstance(value, dict): + return None + at = value.get('at') + if type(at) not in (int, float) or not math.isfinite(at) or at <= 0: + return None + result = dict(at=at) + text = value.get('code_text') + if isinstance(text, str) and _CODE_TEXT.match(text): + result['code_text'] = text + result.update(clean_reading(value)) + return result + + +def describe(value, zh): + """The launcher's one sentence about a stopped engine, in the language of the window. + + The exit status is not in it: it is in the record, the logs and the diagnostic report. + """ + clock = time.strftime('%H:%M', time.localtime(value['at'])) + if zh: + return 'ComfyUI 于 %s 意外停止。请点击“重新连接”重新启动。' % clock + return 'ComfyUI stopped unexpectedly at %s. Click "Connect again" to restart it.' % clock diff --git a/freevideo_engine/image_gen/cast_views.py b/freevideo_engine/image_gen/cast_views.py index e86d05ae..9049edd8 100644 --- a/freevideo_engine/image_gen/cast_views.py +++ b/freevideo_engine/image_gen/cast_views.py @@ -9,6 +9,7 @@ VIEWS = ('front', 'three_quarter', 'side', 'back', 'face') SETTING_VIEWS = ('scene',) +PROP_VIEWS = ('prop',) SIZES = { 'front': (832, 1472), @@ -17,6 +18,7 @@ 'back': (832, 1472), 'face': (1024, 1024), 'scene': (1344, 768), + 'prop': (1024, 1024), } BACKDROP = 'in front of a plain light grey studio backdrop, even soft studio light, sharp focus, no text' @@ -71,6 +73,9 @@ def view_prompt(card, view): if card.get('kind') == 'setting': # The time, weather, light and colour the description gives are the point of a scene: only the people and text are barred. return '%s. A wide view of the place, no people, sharp focus, no text.' % who + if card.get('kind') == 'prop': + return ('%s. The single object alone, centred, in front of a plain light grey backdrop, even soft light, ' + 'sharp focus, no people, no text.' % who) if view == 'front': return ('%s. The person stands alone, the whole body from head to feet, facing the camera, neutral pose ' 'with the arms relaxed, %s.' % (who, BACKDROP)) @@ -106,9 +111,9 @@ def page_views(description, kind='character'): card made from a description and these pictures are made from the same words.""" if not isinstance(description, str) or not description.strip() or len(description) > 1000: raise ValueError('invalid_prompt') - if kind not in ('character', 'setting'): + if kind not in ('character', 'setting', 'prop'): raise ValueError('invalid_request') card = dict(name=description.strip()[:120], description=description.strip(), kind=kind) - views = SETTING_VIEWS if kind == 'setting' else VIEWS + views = SETTING_VIEWS if kind == 'setting' else PROP_VIEWS if kind == 'prop' else VIEWS return dict(views=[dict(view=view, prompt=view_prompt(card, view), width=SIZES[view][0], height=SIZES[view][1], from_front=uses_front(view)) for view in views]) diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index 7d2cd200..49e008ec 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -920,8 +920,8 @@ ApplicationWindow { ColumnLayout { Layout.fillWidth: true; spacing: 6 // A failed start stays failed while another task (an import, a cleanup) runs: the controller's own tasks clear it first. - FText { objectName: "statusTitle"; text: s.status === "open" ? t("FreeVideo is ready", "FreeVideo 已就绪") : s.status === "failed" ? t("Startup interrupted", "启动未完成") : s.busy ? t("Starting ComfyUI", "正在启动 ComfyUI") : s.status === "restart-required" ? t("Restart ComfyUI", "请重启 ComfyUI") : t("Ready to launch", "准备就绪"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } - FText { objectName: "statusDetail"; text: s.status === "open" ? s.url : s.status === "restart-required" ? t("Restart ComfyUI after the update, then select \"Connect again\".", "更新后需重启 ComfyUI,再点“重新连接”。") : s.status === "failed" ? t("ComfyUI did not start. See the message above.", "ComfyUI 未能启动,原因见上方。") : t("Your models and environment are connected.", "已连接模型与运行环境。"); color: theme.muted; Layout.fillWidth: true } + FText { objectName: "statusTitle"; text: s.status === "open" ? t("FreeVideo is ready", "FreeVideo 已就绪") : s.status === "failed" ? t("Startup interrupted", "启动未完成") : s.busy ? t("Starting ComfyUI", "正在启动 ComfyUI") : s.status === "restart-required" && s.engine_exit_text ? t("ComfyUI stopped", "ComfyUI 已停止") : s.status === "restart-required" ? t("Restart ComfyUI", "请重启 ComfyUI") : t("Ready to launch", "准备就绪"); font.pixelSize: theme.section + 2; font.weight: Font.DemiBold; Layout.fillWidth: true } + FText { objectName: "statusDetail"; text: s.status === "open" ? s.url : s.status === "restart-required" && s.engine_exit_text ? s.engine_exit_text : s.status === "restart-required" ? t("Restart ComfyUI after the update, then select \"Connect again\".", "更新后需重启 ComfyUI,再点“重新连接”。") : s.status === "failed" ? t("ComfyUI did not start. See the message above.", "ComfyUI 未能启动,原因见上方。") : t("Your models and environment are connected.", "已连接模型与运行环境。"); color: theme.muted; Layout.fillWidth: true } FText { objectName: "portMovedNote"; visible: s.status === "open" && s.port_from > 0; text: t("Port %1 is in use by another program, so FreeVideo now uses port %2 and will keep using this address.", "端口 %1 已被其他程序占用,FreeVideo 改用端口 %2,以后也使用这个地址。").arg(s.port_from).arg(s.port_to); font.pixelSize: 13; color: theme.muted; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -2 } FText { objectName: "pluginScopedNote"; visible: s.status === "open" && s.plugin_scoped; text: t("Some installed plugins are not compatible with FreeVideo, so only FreeVideo was loaded this time.", "部分已装的插件与 FreeVideo 不兼容,这次只载入了 FreeVideo。"); font.pixelSize: 13; color: theme.muted; wrapMode: Text.WordWrap; Layout.fillWidth: true; Layout.topMargin: -2 } } diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index 578b1be9..fe1a5123 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -1808,6 +1808,7 @@ def setup_replayable(self): def tick(self): if isinstance(self.controller, Controller) and not self.controller.busy: + self.controller.check_server_exit() self.controller.poll_access_proxy() self.sync_lan_from_store() self._finish_archives() @@ -1963,6 +1964,14 @@ def _tick_page_check(self, row): self.page_check.poll(address) self.page_error = self.page_check.text(self.t, self.browser_opened, address) + def engine_exit_text(self, row): + """The sentence for a ComfyUI that stopped by itself while it was open; empty otherwise.""" + value = row.get('engine_exit') + if row.get('status') != 'restart-required' or not isinstance(value, dict) or type(value.get('at')) not in (int, float): + return '' + from .engine_exit import describe + return describe(value, self.language.startswith('zh')) + def old_versions_text(self): """What the background removal of earlier FreeVideo copies freed this session.""" value = getattr(self.controller, 'old_versions', None) @@ -2472,6 +2481,7 @@ def snapshot(self): if row.get('lan_status') == 'no-address' else ''), port_from=port_from, port_to=port_to, plugin_scoped=row.get('status') == 'open' and row.get('plugin_scoped') is True, + engine_exit_text=self.engine_exit_text(row), old_versions=self.old_versions_text(), copied_models=self.copied_models_text(), archives=self.archives_text(), archives_confirm=self.archives_confirm_text(), archives_done=self.archives_done_text(), archives_failed=self.archives_failed_text(), diff --git a/freevideo_engine/portable_launcher.py b/freevideo_engine/portable_launcher.py index 71e296bf..e05c4c82 100644 --- a/freevideo_engine/portable_launcher.py +++ b/freevideo_engine/portable_launcher.py @@ -243,6 +243,8 @@ def _sync_access(self, status=True): self.state = dict(self.state, selection=dict(self.selection), lan_status=child.state.get('lan_status', '')) if status: self.state = dict(self.state, status=child.state['status'], url=child.state.get('url', '')) + if 'engine_exit' in child.state: + self.state = dict(self.state, engine_exit=child.state['engine_exit']) def _configure_access(self, values, *, connect=False): if connect: @@ -263,6 +265,16 @@ def stop_owned_server(self, *, grace=5): self.comfy_controller.stop_owned_server(grace=grace) super().stop_owned_server(grace=grace) + def check_server_exit(self): + """The ComfyUI of a package runs under the inner controller; its record and notice are copied here.""" + child = self.comfy_controller + if child is None: + return super().check_server_exit() + value = child.check_server_exit() + if value is not None: + self._sync_access() + return value + def check_access_proxy(self): if self.comfy_controller is None: return super().check_access_proxy() diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index ee67100a..76c6c5eb 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,27 +1,48 @@ { "schema": 1, - "product_version": "0.4.35", + "product_version": "0.4.36", "en": { - "summary": "External Agents can join the best part of every cut from different takes, the record button stays in place, and text and subtitles can be added after the sound has been separated.", + "summary": "Props join the cast library beside characters and scenes, the editor stays usable in a short window, and a refresh no longer clears text selected in the chat.", "changes": [ - "External Agents: when the takes of a shot have the same cuts, an Agent can read each take's cuts, per-part checks and how the takes join at every cut, then join the best part of every cut from different takes. The choice is kept with the film.", - "External Agents: when a voice is changed with a script, a sentence split by a pause is said once instead of twice, and numbers, hyphenated words and initials stay whole.", - "Editor: the record button stays in the toolbar and turns grey when the selection cannot take a recording, so the icons after it no longer move.", - "Editor: audio track names fit in the track head, and text and subtitles can be added after the sound has been separated.", - "Tutorial and feature tour: new cards and chapters for Describe each part and Reuse settings. The example button reads \"Use example\" in both." + "Cast library: props are a third kind beside characters and scenes. A prop can be uploaded or generated, and mentioned in a prompt. When a continued shot has room for fewer references, the character face is kept first, then the scene, then the prop.", + "Editor: in a short window the audio tracks and the add-audio button below the timeline are no longer cut off. The preview gives up height first, and the editor scrolls when that is not enough.", + "Chat: text selected in messages and cards is kept when the page refreshes, so it can be copied, and the reason for a failed send keeps its selection.", + "Launcher: when the engine stops unexpectedly, the launcher window and the page say when it stopped and offer to reconnect, and feedback and diagnostic reports include the latest exit record." ] }, "zh": { - "summary": "外部 Agent 可从不同版本中选取每个切镜最好的一段并拼接,录音按钮位置固定,声音分离后可以添加文字和字幕。", + "summary": "道具加入角色库,与角色和场景并列;窗口较矮时编辑器仍可完整使用;聊天刷新时不再清掉已选中的文字。", "changes": [ - "外部 Agent:同一镜头的各版本切镜相同时,Agent 可读取每一版的切镜、各段检查结果和各切镜处的衔接情况,从不同版本中为每个切镜选取最好的一段并拼接;所选片段随影片保存。", - "外部 Agent:传入台词换人声时,被停顿切开的一句话只说一次;数字、连字符词和缩写保持完整。", - "剪辑:录音按钮固定在工具栏中,选区不能录音时变灰,其后的图标不再移动。", - "剪辑:声音轨名称在轨头内完整显示,声音分离后可以添加文字和字幕。", - "教程和功能导览:新增“逐段描述”和“复用设置”的卡片和章节;示例按钮统一为“填入示例”。" + "角色库:道具成为与角色、场景并列的第三类,可上传或生成,并在提示词中引用;接续镜头可用的参考位置不足时,依次保留角色脸、场景、道具。", + "剪辑:窗口较矮时,时间线下方的声音轨和“+ 声音轨”按钮不再被裁掉;预览区先让出高度,仍不够时编辑器出现滚动条。", + "聊天:页面刷新时,消息和任务卡中已选中的文字不再被清掉,可正常复制;发送失败的原因也保留选区。", + "启动器:引擎意外停止时,启动器窗口和页面提示停止的时间并提供重新连接入口;反馈和诊断报告带上最近一次退出记录。" ] }, "history": [ + { + "product_version": "0.4.35", + "en": { + "summary": "External Agents can join the best part of every cut from different takes, the record button stays in place, and text and subtitles can be added after the sound has been separated.", + "changes": [ + "External Agents: when the takes of a shot have the same cuts, an Agent can read each take's cuts, per-part checks and how the takes join at every cut, then join the best part of every cut from different takes. The choice is kept with the film.", + "External Agents: when a voice is changed with a script, a sentence split by a pause is said once instead of twice, and numbers, hyphenated words and initials stay whole.", + "Editor: the record button stays in the toolbar and turns grey when the selection cannot take a recording, so the icons after it no longer move.", + "Editor: audio track names fit in the track head, and text and subtitles can be added after the sound has been separated.", + "Tutorial and feature tour: new cards and chapters for Describe each part and Reuse settings. The example button reads \"Use example\" in both." + ] + }, + "zh": { + "summary": "外部 Agent 可从不同版本中选取每个切镜最好的一段并拼接,录音按钮位置固定,声音分离后可以添加文字和字幕。", + "changes": [ + "外部 Agent:同一镜头的各版本切镜相同时,Agent 可读取每一版的切镜、各段检查结果和各切镜处的衔接情况,从不同版本中为每个切镜选取最好的一段并拼接;所选片段随影片保存。", + "外部 Agent:传入台词换人声时,被停顿切开的一句话只说一次;数字、连字符词和缩写保持完整。", + "剪辑:录音按钮固定在工具栏中,选区不能录音时变灰,其后的图标不再移动。", + "剪辑:声音轨名称在轨头内完整显示,声音分离后可以添加文字和字幕。", + "教程和功能导览:新增“逐段描述”和“复用设置”的卡片和章节;示例按钮统一为“填入示例”。" + ] + } + }, { "product_version": "0.4.34", "en": { @@ -396,23 +417,6 @@ "诊断报告:生成过程中 ComfyUI 重启后,这次运行的报告会标为已中断,不再显示为运行中;长视频会保留被中断那一段的报告。" ] } - }, - { - "product_version": "0.4.23", - "en": { - "summary": "Each part planned in the editor can have its own description; low-memory generations explain why they are slower.", - "changes": [ - "Generation: When a generation runs in low-memory mode, the progress view says why it is slower.", - "Editor: Each part of a video planned in the editor can now have its own description, and a continued part uses exactly what you write for it. The highlight on characters mentioned with @ in the prompt box updates as soon as a rewritten prompt is filled in." - ] - }, - "zh": { - "summary": "剪辑里规划的每一段可以单独写描述;低显存模式下的生成会说明较慢的原因。", - "changes": [ - "生成:低显存模式下,进度区会说明这次生成较慢的原因。", - "剪辑视图:剪辑里规划的每一段现在都可以单独写描述,接着生成的一段会用为它写的描述。改写后的提示词填回时,@人物 高亮立即刷新。" - ] - } } ] } diff --git a/freevideo_engine/support_report.py b/freevideo_engine/support_report.py index fecab5b0..e796b690 100644 --- a/freevideo_engine/support_report.py +++ b/freevideo_engine/support_report.py @@ -624,6 +624,15 @@ def clean(value, key=''): cleaned['progress_timeline'] = timeline from .diagnostic_summary import summary_report cleaned['analysis'] = summary_report(cleaned) + try: + # Why the ComfyUI before this one stopped, when the launcher saw it end: numbers and fixed words. + from .comfy_bridge import installation_root + from .engine_exit import public, read + stopped = public(read(installation_root())) + if stopped: + cleaned['engine_exit'] = stopped + except Exception: + pass save(target, cleaned) return target except Exception: diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 41221e9f..8edb817a 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -524,10 +524,12 @@ test('main browser entry registers both views and preserves node hooks', async t await extension.beforeRegisterNodeDef(MediaNode, {name: 'FreeVideoMedia'}); const media = new MediaNode(); media.onNodeCreated(); const panel = media.dom.find(row => row.name === 'freevideo_media').element; - const [castRow, sceneRow, toolbar, mode, note, audioHelp, connections] = panel.children; + const [castRow, sceneRow, propRow, toolbar, mode, note, audioHelp, connections] = panel.children; assert.equal(castRow, casts[0].element); assert.equal(sceneRow, casts[1].element); + assert.equal(propRow, casts[2].element); assert.equal(casts[1].options.kind, 'setting'); + assert.equal(casts[2].options.kind, 'prop'); assert.equal(casts[0].options.api, api); assert.equal(typeof casts[0].options.uploadOne, 'function'); assert.equal(typeof casts[0].options.viewURL, 'function'); @@ -564,11 +566,12 @@ test('main browser entry registers both views and preserves node hooks', async t await imagePicker.onchange(); assert.deepEqual(JSON.parse(media.widgets[0].value).map(row => row.role), ['reference', 'first']); media.onRemoved(); - assert.equal(casts[0].destroyed, true); assert.equal(casts[1].destroyed, true); + assert.equal(casts[0].destroyed, true); assert.equal(casts[1].destroyed, true); assert.equal(casts[2].destroyed, true); const mounted = new Element('mount'), disposeMedia = media.freevideoCreateMediaEditor(mounted); - assert.equal(mounted.children[0].children[0], casts[2].element); - assert.equal(mounted.children[0].children[1], casts[3].element); - disposeMedia(); assert.equal(casts[2].destroyed, true); assert.equal(casts[3].destroyed, true); + assert.equal(mounted.children[0].children[0], casts[3].element); + assert.equal(mounted.children[0].children[1], casts[4].element); + assert.equal(mounted.children[0].children[2], casts[5].element); + disposeMedia(); assert.equal(casts[3].destroyed, true); assert.equal(casts[4].destroyed, true); assert.equal(casts[5].destroyed, true); await t.test('Generate node removal preserves the original hook when setSize throws', async () => { const previousInterval = globalThis.setInterval, previousClear = globalThis.clearInterval; diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index fc6e7fe1..21613baa 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -19,6 +19,8 @@ const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = n const CHAT_ISSUE = ['Chat', '对话']; function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } +// Rewriting a text node drops the reader's selection, so a refresh writes only what changed. +function put(node, text) { if (node.textContent !== text) node.textContent = text; } function glyph(path, width = 2.2) { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true'); svg.setAttribute('stroke-width', String(width)); @@ -401,6 +403,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (hint) main.insertBefore(hint.element, form); // A quoted button name stays on one line, so “新对话” never wraps in the middle. function quoted(node, text) { + if (node.textContent === text) return; const parts = String(text).split(/(“[^”]{1,12}”|"[^"]{1,24}")/); node.textContent = text; if (parts.length > 1) node.replaceChildren(...parts.flatMap((part, index) => part ? [el('span', index % 2 ? 'fv-chat-keep' : '', part)] : [])); @@ -891,7 +894,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } return {element: item, update(entry, {editable = false} = {}) { current = entry; - if (!editor) { text.textContent = entry.text; text.hidden = !entry.text && !!entry.attachments?.length; } + if (!editor) { put(text, entry.text); text.hidden = !entry.text && !!entry.attachments?.length; } const names = (entry.attachments || []).map(picture => picture.name).join('|'); if (names !== shown) { shown = names; item.querySelector('.fv-chat-refs')?.remove(); @@ -974,7 +977,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () return draw = next => { const cells = jobParts(next); level(next); - name.textContent = jobName(next, t); meta.textContent = jobMeta(next, t); meta.hidden = !meta.textContent; + put(name, jobName(next, t)); put(meta, jobMeta(next, t)); meta.hidden = !meta.textContent; while (parts.children.length > cells.length) parts.lastChild.remove(); while (parts.children.length < cells.length) parts.append(el('i')); cells.forEach((value, index) => parts.children[index].style.setProperty('--w', `${value * 100}%`)); @@ -983,8 +986,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const where = cardLine(said, next, t); quiet = stalled(next); const waiting = t('Progress has not updated recently; generation is still running.', '进度暂未更新,仍在生成中。'); - part.textContent = cells.length ? t(`Part ${block.index} / ${block.total}`, `第 ${block.index} / ${block.total} 段`) : quiet ? waiting : where; - step.textContent = cells.length ? quiet ? waiting : where : ''; + put(part, cells.length ? t(`Part ${block.index} / ${block.total}`, `第 ${block.index} / ${block.total} 段`) : quiet ? waiting : where); + put(step, cells.length ? quiet ? waiting : where : ''); const going = stopping.has(next.id) || next.status === 'cancelling'; stop.disabled = going; stop.textContent = going ? t('Stopping…', '正在停止…') : t('Stop', '停止'); stop.setAttribute('aria-busy', String(going)); @@ -1062,7 +1065,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; + put(name, jobName(next, t)); put(meta, jobMeta(next, t)); meta.hidden = !meta.textContent; 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); }; @@ -1121,7 +1124,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () actions.append(diagnostic.report); if (retry) actions.append(...again()); links.append(actions); item.replaceChildren(reason, links); - apply = next => { current = next; reason.textContent = failureReason(next, t); + apply = next => { current = next; put(reason, failureReason(next, t)); diagnostic.updateReport({report_id: next.report_id}); }; } else { item.replaceChildren(); apply = null; } } @@ -1134,7 +1137,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () button.hidden = way === 'resend' && !text; // Going on needs no agent; saying the words again does. button.disabled = retrying.has(key) || !inSelectedChat() || way === 'resend' && !sendingAllowed(); - if (note) { note.textContent = fallbacks.get(key)?.reason || ''; note.hidden = !note.textContent; } + if (note) { put(note, fallbacks.get(key)?.reason || ''); note.hidden = !note.textContent; } } }}; } diff --git a/web/cast_generate.css b/web/cast_generate.css index 65fc980e..d8eafdcb 100644 --- a/web/cast_generate.css +++ b/web/cast_generate.css @@ -49,6 +49,7 @@ .fv-cg[data-kind=scene] .fv-cg-view{aspect-ratio:16/9;cursor:pointer} .fv-cg[data-kind=scene] .fv-cg-view:not([data-state=done]){cursor:default} .fv-cg[data-kind=scene] .fv-cg-view img{object-fit:cover} +.fv-cg[data-shape=square] .fv-cg-view{aspect-ratio:1/1} .fv-cg[data-kind=scene] .fv-cg-view[aria-pressed=true]{border:2px solid var(--fv-accent,#6db8fa)} .fv-cg[data-kind=scene] .fv-cg-view:focus-visible{outline:2px solid var(--fv-accent,#6db8fa);outline-offset:2px} .fv-cg-stage{display:none} diff --git a/web/cast_generate.js b/web/cast_generate.js index ecb0fd4b..dfb2a067 100644 --- a/web/cast_generate.js +++ b/web/cast_generate.js @@ -24,7 +24,7 @@ const randomSeed = () => crypto.getRandomValues(new Uint32Array(1))[0] >>> 1; // The five pictures of one description. No DOM: the page below draws its state. export function createCastGenerator({client, kind = 'character', onChange = () => {}, beforeGenerate = async () => {}, schedule = setTimeout, unschedule = clearTimeout}) { - const scene = kind === 'setting', names = scene ? SCENE_CANDIDATES : CAST_VIEWS; + const scene = kind === 'setting' || kind === 'prop', names = scene ? SCENE_CANDIDATES : CAST_VIEWS; const fresh = () => names.map(view => ({view, file: '', fraction: 0})); let state = {pictures: fresh(), running: false, current: -1, error: '', report_id: null, waiting: '', description: ''}; let epoch = 0, timer = null, job = null, plan = null, seed = 0, disposed = false, loras; @@ -116,7 +116,7 @@ export function createCastGenerator({client, kind = 'character', onChange = () = } export function mountCastGenerate({container, api, text, t = text, viewURL, onState = () => {}, onPick = () => {}, fetchApi, client, loras, kind = 'character'}) { - const scene = kind === 'setting', names = scene ? SCENE_CANDIDATES : CAST_VIEWS; + const scene = kind === 'setting' || kind === 'prop', names = scene ? SCENE_CANDIDATES : CAST_VIEWS; const tr = t || ((en) => en); const fetcher = fetchApi || (api ? api.fetchApi.bind(api) : globalThis.fetch.bind(globalThis)); client = client || createImageGenClient(fetcher); @@ -132,11 +132,12 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt const root = el('div', 'fv-cg'); if (scene) root.dataset.kind = 'scene'; + if (kind === 'prop') root.dataset.shape = 'square'; const body = el('div', 'fv-cg-body'); const side = el('div', 'fv-cg-side'); const foot = el('div', 'fv-cg-foot'); const field = el('textarea', 'fv-cg-text'); - field.placeholder = scene ? tr('Describe the scene', '描述场景') : tr('Describe the character', '描述角色'); + field.placeholder = kind === 'prop' ? tr('Describe the prop', '描述道具') : scene ? tr('Describe the scene', '描述场景') : tr('Describe the character', '描述角色'); field.setAttribute('aria-label', field.placeholder); field.maxLength = 1000; const note = el('div', 'fv-cg-note'); note.setAttribute('role', 'status'); @@ -240,7 +241,7 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt function begin() { if (!field.value.trim()) { - attention = scene ? tr('Describe the scene first.', '请先描述场景。') : tr('Describe the character first.', '请先描述角色。'); + attention = kind === 'prop' ? tr('Describe the prop first.', '请先描述道具。') : scene ? tr('Describe the scene first.', '请先描述场景。') : tr('Describe the character first.', '请先描述角色。'); render(); field.focus(); return; } diff --git a/web/cast_library.css b/web/cast_library.css index 83f1eec6..da89381d 100644 --- a/web/cast_library.css +++ b/web/cast_library.css @@ -54,3 +54,10 @@ .fv-cast-dialog[data-kind=scene] .fv-cast-views{grid-template-columns:repeat(3,minmax(0,1fr));grid-template-rows:none} .fv-cast-dialog[data-kind=scene] .fv-cast-slot{aspect-ratio:16/9} .fv-cast-dialog[data-kind=scene] .fv-cast-choose img{object-fit:cover} +.fv-cast-library[data-kind=prop] .fv-cast-avatar,.fv-cast-library[data-kind=prop] .fv-cast-add{border-radius:6px} +.fv-cast-library[data-kind=prop] .fv-cast-avatar img{object-position:50% 50%;object-view-box:none} +.fv-cast-dialog[data-kind=prop] .fv-cast-upload{display:block} +.fv-cast-dialog[data-kind=prop] .fv-cast-views{grid-template-columns:minmax(0,1fr);grid-template-rows:none} +.fv-cast-dialog[data-kind=prop] .fv-cast-slot{aspect-ratio:1/1;max-width:160px} +.fv-cast-dialog[data-kind=prop] .fv-cast-choose img{object-fit:cover} +.fv-studio .fv-panel .fv-cast-library .fv-cast-avatar,.fv-studio .fv-panel .fv-cast-library .fv-cast-add{padding:0;min-height:0} diff --git a/web/cast_library.js b/web/cast_library.js index d06ebb68..f68ab8bd 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -24,9 +24,10 @@ const button = (className, label, action) => { const path = id => '/freevideo/agent/cast/' + encodeURIComponent(id); // kind 'setting' is the scene row: wide pictures, up to 3 per card, and no front picture. +// kind 'prop' is the props row: square pictures, one per card, and no front picture. export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () => {}, kind = 'character'}) { - const scene = kind === 'setting', SLOTS = scene ? 3 : 6; - const word = (character, place) => scene ? place : character; + const prop = kind === 'prop', scene = kind === 'setting' || prop, SLOTS = prop ? 1 : scene ? 3 : 6; + const word = (character, place, thing = place) => prop ? thing : scene ? place : character; if (!document.querySelector('link[data-freevideo-cast]')) { 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); @@ -37,12 +38,12 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () // The row shows, and the page session is asked for, only once /freevideo/agent says the interface is on. let available = false; element.hidden = true; const probe = agentInterfaceEnabled(api).then(on => { available = on && !disposed; element.hidden = !available; return available; }); - element.dataset.kind = scene ? 'scene' : 'character'; - const heading = el('span', 'fv-cast-heading', word(text('Characters', '角色'), text('Scenes', '场景'))); + element.dataset.kind = prop ? 'prop' : scene ? 'scene' : 'character'; + const heading = el('span', 'fv-cast-heading', word(text('Characters', '角色'), text('Scenes', '场景'), text('Props', '道具'))); 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', word(text('New character', '新建角色'), text('New scene', '新建场景'))); add.dataset.tourId = 'cast-add'; + add.setAttribute('aria-label', word(text('New character', '新建角色'), text('New scene', '新建场景'), text('New prop', '新建道具'))); add.dataset.tourId = 'cast-add'; element.append(heading, avatars, add, skipped); async function value(response) { @@ -105,9 +106,9 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () let closed = false, loading = !!id, saving = false, revision, active = 'upload'; let generator = null, aiState = {busy: false, done: false}, deleteTimer = null, confirming = false, frontless = scene, beyond = []; const slots = Array(SLOTS).fill(null), slotUI = []; - const dialog = el('dialog', 'fv-cast-dialog'); dialog.dataset.kind = scene ? 'scene' : 'character'; + const dialog = el('dialog', 'fv-cast-dialog'); dialog.dataset.kind = prop ? 'prop' : scene ? 'scene' : 'character'; const top = el('div', 'fv-cast-top'); - const title = el('h2', '', id ? word(text('Edit character', '编辑角色'), text('Edit scene', '编辑场景')) : word(text('New character', '新建角色'), text('New scene', '新建场景'))); + const title = el('h2', '', id ? word(text('Edit character', '编辑角色'), text('Edit scene', '编辑场景'), text('Edit prop', '编辑道具')) : word(text('New character', '新建角色'), text('New scene', '新建场景'), text('New prop', '新建道具'))); dialog.setAttribute('aria-label', title.textContent); const closeButton = button('fv-cast-close', '×', () => close()); closeButton.setAttribute('aria-label', text('Close', '关闭')); top.append(title, closeButton); @@ -125,7 +126,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const aiPane = el('div', 'fv-cast-generate'); aiPane.hidden = true; const status = el('div', 'fv-cast-message'); status.setAttribute('role', 'status'); status.hidden = true; const actions = el('div', 'fv-cast-actions'); - const remove = button('fv-cast-delete', word(text('Delete character', '删除角色'), text('Delete scene', '删除场景')), () => deleteCard()); remove.hidden = !id; + const remove = button('fv-cast-delete', word(text('Delete character', '删除角色'), text('Delete scene', '删除场景'), text('Delete prop', '删除道具')), () => deleteCard()); remove.hidden = !id; const cancel = button('', text('Cancel', '取消'), () => close()); const save = button('fv-cast-save', text('Save', '保存'), () => saveCard()); actions.append(remove, cancel, save); @@ -144,7 +145,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const missingNow = () => !name.value.trim() ? text('Enter a name first', '先填写名字') : aiBusy() ? word(text('Wait for the five pictures to finish', '等待五张图生成完成'), text('Wait for the pictures to finish', '等待图片生成完成')) : active === 'upload' || scene ? (frontless ? slots.some(Boolean) : slots[0]) ? '' : frontless ? text('Add an image first', '先添加图片') : text('Add a front image first', '先添加正面图片') - : aiState.done ? '' : word(text('Generate the images first', '先生成角色图片'), text('Generate the images first', '先生成场景图片')); + : aiState.done ? '' : word(text('Generate the images first', '先生成角色图片'), text('Generate the images first', '先生成场景图片'), text('Generate the images first', '先生成道具图片')); function sync() { const busy = loading || saving; name.disabled = busy; remove.disabled = busy; @@ -222,7 +223,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () for (let index = 0; index < SLOTS; index++) { const slot = el('div', 'fv-cast-slot' + (index || scene ? '' : ' fv-cast-front')); const input = el('input'); input.type = 'file'; input.accept = 'image/*'; input.hidden = true; - const label = scene ? text(`Image ${index + 1}`, `图片 ${index + 1}`) : index ? text(`View ${index}`, `视角 ${index}`) : text('Front image', '正面图片'); + const label = prop ? text('Image', '图片') : scene ? text(`Image ${index + 1}`, `图片 ${index + 1}`) : index ? text(`View ${index}`, `视角 ${index}`) : text('Front image', '正面图片'); const choose = button('fv-cast-choose', '', () => input.click()); choose.setAttribute('aria-label', label); const remove = button('fv-cast-remove', '×', () => { release(index); paint(index); }); remove.setAttribute('aria-label', text('Remove image', '移除图片')); @@ -237,7 +238,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () uploadPane.append(views); function resetDelete() { clearTimeout(deleteTimer); deleteTimer = null; confirming = false; - remove.textContent = word(text('Delete character', '删除角色'), text('Delete scene', '删除场景')); remove.classList.remove('fv-cast-confirm'); + remove.textContent = word(text('Delete character', '删除角色'), text('Delete scene', '删除场景'), text('Delete prop', '删除道具')); remove.classList.remove('fv-cast-confirm'); } async function load() { loading = true; sync(); @@ -278,7 +279,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const frontNeeded = !scene && (active === 'ai' || !frontless); if (!pictures.length || pictures.length > SLOTS || (frontNeeded && !pictures.some(picture => picture.label === 'front'))) { message(word(text('Choose one front image and up to 5 more angles', '选一张正面图片,再加最多 5 张其他角度的图片'), - text('Choose up to 3 images', '最多选 3 张图片')), true); return; + text('Choose up to 3 images', '最多选 3 张图片'), text('Choose one image', '选一张图片')), true); return; } const images = []; for (const picture of pictures) { diff --git a/web/cast_mention.js b/web/cast_mention.js index 382f3f61..06ad336c 100644 --- a/web/cast_mention.js +++ b/web/cast_mention.js @@ -5,6 +5,7 @@ export const MAX_PEOPLE = 3; export const MAX_PICTURES_EACH = 2; export const MAX_REFERENCES = 32; +export const MAX_PROPS = 2; // Which of a card's pictures stands for the person after the sheet; the others follow in the card's own order. export const PICTURE_ORDER = ['face', 'front', 'three_quarter']; @@ -44,14 +45,17 @@ function pictureNumbers(rows, continued = false) { }); let kept = picked; if (continued) { - // The picture of a scene keeps its place after the others (continuation.pick_pictures). + // The picture of a scene keeps its place after the others, and props fill what is left (continuation.pick_pictures). const places = picked.filter(index => rows[index].cast?.setting === true); const place = places.length ? places[places.length - 1] : null; - const rest = place === null ? picked : picked.filter(index => rows[index].cast?.setting !== true); + const props = picked.filter(index => rows[index].cast?.prop === true); + const rest = picked.filter(index => rows[index].cast?.setting !== true && rows[index].cast?.prop !== true); const last = new Map(); rest.forEach(index => { if (rows[index].cast?.id) last.set(rows[index].cast.id, index); }); kept = last.size < 2 ? rest : [...last.values()].sort((a, b) => a - b).concat(rest.filter(index => !rows[index].cast?.id)); kept = kept.slice(0, CONTINUE_PICTURES - (place === null ? 0 : 1)); + // Props come after the people and the place: they keep only the positions those leave free. + kept = kept.concat(props.slice(0, CONTINUE_PICTURES - (place === null ? 0 : 1) - kept.length)); if (place !== null) kept.push(place); } return new Map(kept.map((index, at) => [index, at + 1])); @@ -64,7 +68,7 @@ export function castInRows(rows) { if (!enabled(row) || !row.cast?.id || row.role !== 'reference' || !IMAGE.test(row.file || '')) return; const name = row.cast.name || ''; // `handle` is what the text says after the @: the name, or "name (2)" when another present character has the same name. - const person = people.get(row.cast.id) || {id: row.cast.id, name, handle: row.cast.handle || name, description: row.cast.description || '', setting: row.cast.setting === true, rows: []}; + const person = people.get(row.cast.id) || {id: row.cast.id, name, handle: row.cast.handle || name, description: row.cast.description || '', setting: row.cast.setting === true, prop: row.cast.prop === true, rows: []}; person.rows.push(index); people.set(row.cast.id, person); }); return [...people.values()]; @@ -106,7 +110,7 @@ export function expandCast(text, rows, {active = true, continued = false} = {}) const people = castInRows(list); const ranges = mentionRanges(text, people); if (!ranges.length) return {text, subjects: []}; - const placed = []; + const placed = [], placedProps = []; if (!continued && list.some(row => enabled(row) && (row.role === 'first' || row.role === 'last'))) active = false; const numbers = pictureNumbers(list, continued); const order = []; @@ -119,6 +123,10 @@ export function expandCast(text, rows, {active = true, continued = false} = {}) if (active && !placed.includes(person) && person.rows.some(index => numbers.has(index))) placed.push(person); result += person.name; continue; } + if (person?.prop) { + if (active && !placedProps.includes(person) && person.rows.some(index => numbers.has(index))) placedProps.push(person); + result += person.name; continue; + } if (!active || !person || !person.rows.some(index => numbers.has(index))) { result += person ? person.name : range.name; continue; } let number = order.indexOf(person) + 1; if (!number) { @@ -133,7 +141,12 @@ export function expandCast(text, rows, {active = true, continued = false} = {}) const description = String(place.description || '').trim().replace(/\.+$/, ''); return `setting: ${place.name}${description ? ': ' + description : ''}. shows the place.`; }); - if (!order.length && !places.length) return {text: result, subjects: []}; + const objects = placedProps.slice(0, MAX_PROPS).map(thing => { + const number = thing.rows.map(index => numbers.get(index)).filter(Boolean)[0]; + const description = String(thing.description || '').trim().replace(/\.+$/, ''); + return `prop: ${thing.name}${description ? ': ' + description : ''}. shows the object.`; + }); + if (!order.length && !places.length && !objects.length) return {text: result, subjects: []}; const subjects = order.map(person => { // Consecutive picture numbers make one range; a person whose pictures are apart gets several. const ranges = []; @@ -143,7 +156,7 @@ export function expandCast(text, rows, {active = true, continued = false} = {}) } return {name: person.name, handle: person.handle ?? person.name, description: person.description, pictures: ranges, audio: null}; }); - const definitions = [...(subjects.length ? [subjectDefinitions(subjects)] : []), ...places].join(' '); + const definitions = [...(subjects.length ? [subjectDefinitions(subjects)] : []), ...places, ...objects].join(' '); return {text: definitions + '\n\n' + result, subjects}; } @@ -183,10 +196,17 @@ export function mentionState(rows, card, count = cardPictures(card).length, {con const people = castInRows(list); if (people.some(person => person.id === card.id)) return {ok: true, present: true}; if (!continued && list.some(row => enabled(row) && (row.role === 'first' || row.role === 'last'))) return {ok: false, reason: 'frames'}; - if (card.kind === 'setting' ? people.some(person => person.setting) : people.filter(person => !person.setting).length >= MAX_PEOPLE) { + if (card.kind === 'prop') { + if (people.filter(person => person.prop).length >= MAX_PROPS) return {ok: false, reason: 'props'}; + } else if (card.kind === 'setting' ? people.some(person => person.setting) : people.filter(person => !person.setting && !person.prop).length >= MAX_PEOPLE) { return {ok: false, reason: card.kind === 'setting' ? 'place' : 'people'}; } - if (continued && pictureNumbers(list).size >= CONTINUE_PICTURES) return {ok: false, reason: 'continued'}; + // Faces and the place come before props in a continued part, so props alone do not take their positions. + if (continued) { + const taken = card.kind === 'prop' ? pictureNumbers(list).size + : pictureNumbers(list.filter(row => row?.cast?.prop !== true)).size; + if (taken >= CONTINUE_PICTURES) return {ok: false, reason: 'continued'}; + } if (list.filter(enabled).length + count > MAX_REFERENCES) return {ok: false, reason: 'full'}; return {ok: true, present: false}; } @@ -209,6 +229,6 @@ export function withCharacter(rows, card, typed = '') { const list = Array.isArray(rows) ? rows.map(row => ({...row})) : []; if (castInRows(list).some(person => person.id === card.id)) return list; const handle = typed || mentionHandle(list, card); - const cast = {id: card.id, name: card.name, description: card.description || '', ...(card.kind === 'setting' ? {setting: true} : {}), ...(handle === card.name ? {} : {handle})}; - return [...list, ...cardPictures(card).map(file => ({file, role: 'reference', enabled: true, cast}))]; + const cast = {id: card.id, name: card.name, description: card.description || '', ...(card.kind === 'setting' ? {setting: true} : card.kind === 'prop' ? {prop: true} : {}), ...(handle === card.name ? {} : {handle})}; + return [...list, ...cardPictures(card).slice(0, card.kind === 'prop' ? 1 : undefined).map(file => ({file, role: 'reference', enabled: true, cast}))]; } diff --git a/web/editor.css b/web/editor.css index 667f5c41..223eebc2 100644 --- a/web/editor.css +++ b/web/editor.css @@ -1,5 +1,5 @@ /* Share the studio shell and header geometry; only the editor rows differ. */ -.fv-studio.fv-editor{--fv-ed-generation-row:max-content;display:grid;grid-template-columns:264px minmax(0,1fr);grid-template-rows:auto minmax(max(96px,calc(272px - var(--fv-ed-description-growth,0px))),1fr) 40px var(--fv-ed-generation-row,56px) 40px minmax(var(--fv-ed-tracks-min,174px),clamp(174px,calc(100dvh - 704px),236px));animation:none} +.fv-studio.fv-editor{--fv-ed-generation-row:max-content;overflow:hidden auto;display:grid;grid-template-columns:264px minmax(0,1fr);grid-template-rows:auto minmax(max(96px,calc(clamp(160px,calc(100dvh - 248px - var(--fv-ed-tracks-min,174px)),272px) - var(--fv-ed-description-growth,0px))),1fr) 40px var(--fv-ed-generation-row,56px) 40px minmax(var(--fv-ed-tracks-min,174px),clamp(174px,calc(100dvh - 704px),236px));animation:none;scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent} .fv-studio.fv-editor[data-generation-ready=false]{--fv-ed-generation-row:0px} @media(min-width:1280px){.fv-studio.fv-editor[data-adjust-available=true]{grid-template-columns:264px minmax(0,1fr) 264px}} .fv-editor *{box-sizing:border-box} @@ -11,7 +11,7 @@ /* Override the studio-wide motion reset only to retain the delay, with no fade. */ @media (prefers-reduced-motion:reduce){.fv-editor .fv-ed-pending{transition:opacity 0s linear .6s!important}} .fv-editor button:where(:not(.fv-header button)){height:36px;min-height:0;border-radius:var(--fv-r-sm);font:500 13px/1.4 var(--fv-font);white-space:nowrap} -.fv-editor .fv-header{grid-column:1/-1;position:relative;z-index:6} +.fv-editor .fv-header{grid-column:1/-1;position:sticky;top:0;z-index:6;background:var(--fv-bg)} .fv-editor>:not(.fv-header){font-size:13px;line-height:1.4} .fv-editor .fv-brand{position:relative}.fv-editor .fv-brand>.fv-wordmark{flex-shrink:0} .fv-editor .fv-header-actions{min-width:0;height:100%}.fv-editor .fv-header-actions>button,.fv-editor .fv-ed-export-box>button{flex:none} diff --git a/web/editor_references.js b/web/editor_references.js index 87479359..01e8635d 100644 --- a/web/editor_references.js +++ b/web/editor_references.js @@ -7,18 +7,23 @@ const MAX = 32; // The engine keeps three references of a part that continues: the first three, or, with two or more characters, // the last picture of each (the face) and then the others (continuation.pick_pictures). const CONTINUE_MAX = 3; -const personOf = ({row}) => typeof row.cast?.id === 'string' && row.cast.id && row.cast.setting !== true ? row.cast.id : null; +const personOf = ({row}) => typeof row.cast?.id === 'string' && row.cast.id && row.cast.setting !== true && row.cast.prop !== true ? row.cast.id : null; const placeOf = ({row}) => row.cast?.setting === true; +const propOf = ({row}) => row.cast?.prop === true; function pickContinued(list) { // A scene's picture keeps one place after the others (continuation.pick_pictures). const places = list.filter(placeOf), place = places[places.length - 1]; if (place) list = list.filter(entry => !placeOf(entry)); + // Props come after the people and the place and keep only the positions left over. + const props = list.filter(propOf); + list = list.filter(entry => !propOf(entry)); const room = CONTINUE_MAX - (place ? 1 : 0); const last = new Map(); list.forEach((entry, at) => { const person = personOf(entry); if (person) last.set(person, at); }); const chosen = last.size < 2 ? list.slice(0, room) : [...[...last.values()].sort((a, b) => a - b).map(at => list[at]), ...list.filter(entry => !personOf(entry))].slice(0, room); - return place ? [...chosen, place] : chosen; + const kept = [...chosen, ...props.slice(0, room - chosen.length)]; + return place ? [...kept, place] : kept; } export function createEditorReferences({t, source, size = () => null, error = () => {}, notify = () => {}}) { const root = el('div', 'fv-ed-refs'), chips = el('div', 'fv-ed-ref-chips'); diff --git a/web/engine_exit.js b/web/engine_exit.js new file mode 100644 index 00000000..ffc43e47 --- /dev/null +++ b/web/engine_exit.js @@ -0,0 +1,50 @@ +// Why the ComfyUI before this one stopped, as far as the launcher saw it end. The page keeps the record it +// found when it loaded, so only an exit that happened afterwards is announced; a feedback message carries +// the newest record it knows. Only numbers and fixed words arrive from the engine, never log text. +let baselineAt, latest = null, watching = null; + +const clock = at => { + const time = new Date(at * 1000); + return `${String(time.getHours()).padStart(2, '0')}:${String(time.getMinutes()).padStart(2, '0')}`; +}; + +async function load(api) { + try { + const response = await api.fetchApi('/freevideo/launcher/last-exit'); + if (!response.ok) return undefined; + const value = (await response.json())?.exit; + return value === null ? null : (value && Number.isFinite(value.at) ? value : undefined); + } catch { return undefined; } +} + +/** Remember the record that exists now, once per page. */ +export function watchEngineExit(api) { + watching ??= load(api).then(value => { if (value !== undefined) { baselineAt = value?.at ?? 0; if (value) latest = value; } }); + return watching; +} + +/** The record of an exit after this page loaded, or null. */ +export async function newEngineExit(api) { + await watchEngineExit(api); + const value = await load(api); + if (value === undefined) return null; + // The first read failed, so this one is the page's baseline: nothing is announced for it. + if (baselineAt === undefined) { baselineAt = value?.at ?? 0; if (value) latest = value; return null; } + if (!value) return null; + latest = value; + if (value.at === baselineAt) return null; + // Announced once: the next recovery says nothing unless the engine stops again. + baselineAt = value.at; + return value; +} + +/** The newest record this page has seen, for a feedback message; null when there is none. */ +export function knownEngineExit() { + return latest; +} + +/** One sentence for the status line after the engine started again. The exit status stays in the report. */ +export function engineExitText(record, t = en => en) { + return t(`The engine stopped unexpectedly at ${clock(record.at)} and has been restarted.`, + `引擎于 ${clock(record.at)} 意外停止,已重新启动。`); +} diff --git a/web/error_panel.js b/web/error_panel.js index e05c0ba8..7d7e6aed 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -4,6 +4,7 @@ import {repairAction} from './repair_hint.js'; import {detectBrowserExtension, browserExtensionAdvice, extensionSteps, isNetworkTypeError, connectionLostText} from './browser_extension.js'; import {SAMPLING_EFFORTS, STEP_CACHES, UPSCALE_FACTORS, tableLevel} from './sampling_effort.js'; import {failureIssue, failureStage} from './failure_issue.js'; +import {knownEngineExit} from './engine_exit.js'; import {focusReportTip, reportIssue, showReportTip} from './report_issue.js'; const reports = new WeakSet(); const captured = new WeakMap(); @@ -607,7 +608,7 @@ export function createErrorPanel(t, route = null) { } if (!issueModules) await loadIssueModules(); if (!issueModules) return; - const draft = failureIssue(currentReport, element.freevideoKind, {lora: githubLora, t}); + const draft = failureIssue(currentReport, element.freevideoKind, {lora: githubLora, t, engineExit: knownEngineExit()}); const url = issueModules.feedback.feedbackIssueURL(draft.text, issueModules.updates.productVersion(), draft.summary, draft.title); globalThis.open?.(url, '_blank', 'noopener'); }; diff --git a/web/failure_issue.js b/web/failure_issue.js index 88a3b7b8..00592255 100644 --- a/web/failure_issue.js +++ b/web/failure_issue.js @@ -41,8 +41,9 @@ function cause(report) { return text.slice(0, TAIL_HEAD) + '\n...\n' + text.slice(-(TAIL - TAIL_HEAD - 5)); } -/** Title, text and summary for the prefilled issue. `lora` marks a LoRA the check judged wrongly. */ -export function failureIssue(report, kind, {lora = false, t = en => en} = {}) { +/** Title, text and summary for the prefilled issue. `lora` marks a LoRA the check judged wrongly; `engineExit` is the + * record of the engine's last unexpected stop, when the launcher saw one. */ +export function failureIssue(report, kind, {lora = false, t = en => en, engineExit = null} = {}) { const stage = failureStage(report); const lines = String(report?.message || '').split(/\r?\n/).filter(line => line.trim()); // A traceback ends with the line that names the error; any other message starts with it. @@ -54,7 +55,8 @@ export function failureIssue(report, kind, {lora = false, t = en => en} = {}) { 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, - run, tail: cause(report)}); + // Before the long tail: an over-long summary is cut from its end. + engine_exit: engineExit || undefined, 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 6e413672..377098f2 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -164,7 +164,8 @@ function mediaPanel(node, mount = null) { toolbar.append(add, generateImage, addAudio, role, picker, audioPicker); panel.append(toolbar, mode, note, audioHelp, connections, cards); const cast = createCastLibrary({api, text, viewURL, uploadOne}); const places = createCastLibrary({api, text, viewURL, uploadOne, kind: 'setting'}); - panel.prepend(cast.element, places.element); + const props = createCastLibrary({api, text, viewURL, uploadOne, kind: 'prop'}); + panel.prepend(cast.element, places.element, props.element); const read = () => { const rows = JSON.parse(serialized.value || "[]"); if (!Array.isArray(rows) || rows.length > 32) throw new Error(text("Use at most 32 media items", "最多使用 32 个素材")); @@ -446,7 +447,7 @@ function mediaPanel(node, mount = null) { return JSON.stringify(requestRows(swapState(node))); } // The run takes only what it uses (and which character a picture belongs to); the editing data of a picture stays in the workflow. - try { return JSON.stringify(read().map(({file, role, enabled, cast, sound, depth, use, range}) => ({file, role, enabled, ...(use === AUDIO_USE.original ? {use, ...(range && typeof range === 'object' ? {range} : {})} : {}), ...(sound === false && mediaKindOrNull(file) === 'video' ? {sound: false} : {}), ...(depth === true && role === 'reference' && mediaKindOrNull(file) === 'video' ? {depth: true} : {}), ...(typeof cast?.id === 'string' && cast.id ? {cast: {id: cast.id, ...(cast.setting === true ? {setting: true} : {})}} : {})}))); } catch { return serialized.value; } + try { return JSON.stringify(read().map(({file, role, enabled, cast, sound, depth, use, range}) => ({file, role, enabled, ...(use === AUDIO_USE.original ? {use, ...(range && typeof range === 'object' ? {range} : {})} : {}), ...(sound === false && mediaKindOrNull(file) === 'video' ? {sound: false} : {}), ...(depth === true && role === 'reference' && mediaKindOrNull(file) === 'video' ? {depth: true} : {}), ...(typeof cast?.id === 'string' && cast.id ? {cast: {id: cast.id, ...(cast.setting === true ? {setting: true} : cast.prop === true ? {prop: true} : {})}} : {})}))); } catch { return serialized.value; } }; async function upload(files) { if (node.isUploading || !files.length) return; @@ -473,7 +474,7 @@ function mediaPanel(node, mount = null) { panel.ondragover = event => { event.preventDefault(); event.stopPropagation(); }; panel.ondrop = event => { event.preventDefault(); event.stopPropagation(); upload(event.dataTransfer.files); }; panel.onpaste = event => { if (event.clipboardData?.files.length) { event.preventDefault(); event.stopPropagation(); upload(event.clipboardData.files); } }; - const dispose = () => { window.removeEventListener('freevideo-media', changed); window.removeEventListener('freevideo-audio-blocked', clearUseNote); cast.destroy(); places.destroy(); }; + const dispose = () => { window.removeEventListener('freevideo-media', changed); window.removeEventListener('freevideo-audio-blocked', clearUseNote); cast.destroy(); places.destroy(); props.destroy(); }; if (mount) { mount.append(panel); render(); return dispose; } node.freevideoCreateMediaEditor = target => mediaPanel(node, target); node.addDOMWidget("freevideo_media", "freevideo_media", panel, {serialize: false, getMinHeight: () => 300, getMaxHeight: () => 520}); diff --git a/web/health.js b/web/health.js index 69cd3161..fa23ac3e 100644 --- a/web/health.js +++ b/web/health.js @@ -13,7 +13,7 @@ const t = (en, zh) => cn ? zh : en; // launcher.js removes this flag during setup; read it before that. const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. -export const MODULES = ['audio_original.js', 'chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.js', 'agent_confirm_settings.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'chat_attachments.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'chain_rules.js', 'timed_prompt.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', +export const MODULES = ['audio_original.js', 'chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.js', 'agent_confirm_settings.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'chat_attachments.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'chain_rules.js', 'timed_prompt.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'engine_exit.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', 'editor.js', 'editor_actions.js', 'editor_lifecycle.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_expand.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_duration.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_stems.js', 'editor_record.js', 'editor_record_model.js', 'editor_voice.js', 'editor_voice_install.js', 'editor_voice_model.js', 'editor_voice_store.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_music.js', 'editor_music_model.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_header_fit.js', 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'failure_issue.js', 'feedback.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'image_lora.js', 'launch_visit.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'lora_check.js', 'library.js', 'motion.js', 'native_errors.js', 'translate_guard.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_sections.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js', 'task_groups.js', diff --git a/web/prompt_references.css b/web/prompt_references.css index a4285def..24bacdd6 100644 --- a/web/prompt_references.css +++ b/web/prompt_references.css @@ -23,6 +23,7 @@ .fv-reference-person{min-height:36px;padding:5px 8px;gap:10px} .fv-reference-face{width:24px;height:24px;flex:none;border-radius:50%;object-fit:cover;object-position:50% 15%;object-view-box:inset(0 20% 40% 20%);background:var(--fv-raised,#252e39)} .fv-reference-face[data-place]{width:40px;height:24px;border-radius:4px;object-position:50% 50%;object-view-box:none} +.fv-reference-face[data-prop]{width:24px;height:24px;border-radius:4px;object-position:50% 50%;object-view-box:none} .fv-reference-person strong{font-size:13px;font-weight:550;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-reference-person[aria-disabled=true]{opacity:.5;cursor:not-allowed} .fv-mention-mirror{position:absolute;box-sizing:border-box;overflow:hidden;pointer-events:none;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;color:var(--fv-ink,#eee);z-index:1} diff --git a/web/prompt_references.js b/web/prompt_references.js index fbddd0a0..3940b7c3 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -215,8 +215,8 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a try { available = items(); } catch { invalid = true; } const media = available.filter(item => `${item.name} ${item.token} ${t(item.kind, {image:'图片',video:'视频',audio:'音频'}[item.kind])} ${item.number}`.toLowerCase().includes(range.query)); const found = everyone.filter(person => person.name.toLowerCase().includes(range.query)); - const people = [...found.filter(person => !person.setting), ...found.filter(person => person.setting)]; - const plain = people.filter(person => !person.setting).length; + const people = [...found.filter(person => !person.setting && !person.prop), ...found.filter(person => person.setting), ...found.filter(person => person.prop)]; + const plain = people.filter(person => !person.setting && !person.prop).length, places = plain + people.filter(person => person.setting).length; matches = [...people.map(character => ({character})), ...media]; list.replaceChildren(); if (!matches.length) { @@ -233,13 +233,14 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a const heading = (en, zh) => { const h = document.createElement('div'); h.className = 'fv-reference-group'; h.setAttribute('role', 'presentation'); h.textContent = t(en, zh); list.append(h); }; if (plain) heading('Characters', '角色'); for (const [index, item] of matches.entries()) { - if (index === plain && index < people.length) heading('Scenes', '场景'); + if (index === plain && index < places) heading('Scenes', '场景'); + if (index === places && index < people.length) heading('Props', '道具'); if (index === people.length && people.length && media.length) heading('Media', '素材'); const option = document.createElement('div'); option.id = `${list.id}-${index}`; option.className = 'fv-reference-option'; option.setAttribute('role', 'option'); if (item.character) { const person = item.character, face = document.createElement('img'); - face.className = 'fv-reference-face'; face.alt = ''; if (person.setting) face.dataset.place = ''; face.src = person.thumb || ''; + face.className = 'fv-reference-face'; face.alt = ''; if (person.setting) face.dataset.place = ''; else if (person.prop) face.dataset.prop = ''; face.src = person.thumb || ''; const name = document.createElement('strong'); name.textContent = person.name; option.className += ' fv-reference-person'; option.append(face, name); if (person.blocked) { option.setAttribute('aria-disabled', 'true'); if (typeof person.blocked === 'string') option.title = person.blocked; } diff --git a/web/studio.js b/web/studio.js index 6b29f7de..8a91b7b9 100644 --- a/web/studio.js +++ b/web/studio.js @@ -22,6 +22,7 @@ import { createRecentVideos } from './studio_recent.js'; import { createAgentActivity } from './agent_activity.js'; import { createStudioQueue, randomSeed, turnNodeOn } from './studio_queue.js'; import { isNetworkTypeError, connectionLostText, wrappedFetchOnly } from './browser_extension.js'; +import { newEngineExit, engineExitText, watchEngineExit } from './engine_exit.js'; import { attachReferencePicker, referenceItems, enhancementMedia, syncReferencePrompt, referenceInputs, mentionAt, handleNames, mediaRows } from './prompt_references.js'; import { attachTermSuggest } from './prompt_terms.js'; import { problem as timedProblem, timedText } from './timed_prompt.js'; @@ -701,13 +702,13 @@ export function openStudio(node, {extendFrom = null} = {}) { prompt.disabled = linked(node, 'text'); prompt.oninput = () => { set(node, 'text', prompt.value); placeholder(); }; const promptEditor = el('div', null, 'fv-prompt-editor'); promptEditor.append(prompt); // Library characters offered in the @ list; a mention puts the character's pictures in the media list. - let castLibrary = null, sceneLibrary = null; + let castLibrary = null, sceneLibrary = null, propLibrary = null; const castAssets = () => { const owner = upstream(node, 'media'); const field = owner && widget(owner, 'assets'); return field ? {owner, field} : null; }; // The library's own upload of a picture goes to the same streaming upload the media panel uses. const castUpload = file => uploadMedia(api, file); const castBlocked = reason => ({frames: t('Not available with first and last frames', '首尾帧不能和角色同时使用'), - people: t('Up to 3 characters', '最多 3 个角色'), full: t('Up to 32 references', '参考素材最多 32 个'), - continued: t('Up to 3 images when continuing', '接续时最多 3\u00a0张')}[reason] || ''); + people: t('Up to 3 characters', '最多 3 个角色'), props: t('Up to 2 props', '最多 2 个道具'), full: t('Up to 32 references', '参考素材最多 32 个'), + continued: t('No room left when continuing', '接续段位置已满')}[reason] || ''); const castPending = new Map(); // The feature tour's "try it": one example into this panel's media list, prompt and settings. const canTryTour = entry => !busy && !queued && !capturing && (!entry.prompt || !linked(node, 'text')) @@ -753,19 +754,20 @@ export function openStudio(node, {extendFrom = null} = {}) { if (!castLibrary || linked(node, 'text') || !castAssets()) return []; const rows = mediaRows(node); const places = (sceneLibrary?.list() || []).map(card => ({...card, kind: 'setting'})); - return [...castLibrary.list(), ...places].filter(card => card.name).map(card => { + const objects = (propLibrary?.list() || []).map(card => ({...card, kind: 'prop'})); + return [...castLibrary.list(), ...places, ...objects].filter(card => card.name).map(card => { const state = mentionState(rows, card, Math.min(MAX_PICTURES_EACH, Number(card.images) || 0), {continued: Boolean(extension.source)}); return {id: card.id, name: card.name, handle: mentionHandle(rows, card, castPending), thumb: card.thumbnail?.file ? view(card.thumbnail.file) : card.thumbnail?.url || '', - ...(card.kind === 'setting' ? {setting: true} : {}), blocked: state.ok ? '' : castBlocked(state.reason) || true}; + ...(card.kind === 'setting' ? {setting: true} : card.kind === 'prop' ? {prop: true} : {}), blocked: state.ok ? '' : castBlocked(state.reason) || true}; }); } // The name goes into the text at once; the card's pictures follow when the full card has been read. function mentionCharacter(person) { if (!castLibrary) return false; if (castInRows(mediaRows(node)).some(entry => entry.id === person.id)) return true; - const library = person.setting ? sceneLibrary : castLibrary; + const library = person.setting ? sceneLibrary : person.prop ? propLibrary : castLibrary; if (!library) return false; - if (!mentionState(mediaRows(node), {id: person.id, ...(person.setting ? {kind: 'setting'} : {})}, 1, {continued: Boolean(extension.source)}).ok) return false; + if (!mentionState(mediaRows(node), {id: person.id, ...(person.setting ? {kind: 'setting'} : person.prop ? {kind: 'prop'} : {})}, 1, {continued: Boolean(extension.source)}).ok) return false; const typed = person.handle || person.name; castPending.set(person.id, typed); library.get(person.id).then(card => { @@ -825,11 +827,13 @@ export function openStudio(node, {extendFrom = null} = {}) { // Time lines are read for one video made in a single run; parts of a long video keep the typed text. const timedRun = () => !extension.source && (lengthControl?.plan ? lengthControl.plan.count === 1 : Number(value(node, 'seconds')) <= 15); node.freevideoSingleRun = timedRun; - cleanup.push(() => { castLibrary?.destroy?.(); castLibrary = null; sceneLibrary?.destroy?.(); sceneLibrary = null; window.removeEventListener('freevideo-media', castChanged); prompt.removeEventListener('input', announceCast); castOverlay.dispose(); delete node.freevideoContinues; delete node.freevideoSingleRun; }); + cleanup.push(() => { castLibrary?.destroy?.(); castLibrary = null; sceneLibrary?.destroy?.(); sceneLibrary = null; propLibrary?.destroy?.(); propLibrary = null; window.removeEventListener('freevideo-media', castChanged); prompt.removeEventListener('input', announceCast); castOverlay.dispose(); delete node.freevideoContinues; delete node.freevideoSingleRun; }); openCastLibrary({api, text: t, viewURL: view, uploadOne: castUpload, onChange: () => { castOverlay.refresh(); references.refresh(); }}) .then(library => { if (disposed) library?.destroy?.(); else castLibrary = library; }); openCastLibrary({api, text: t, viewURL: view, uploadOne: castUpload, kind: 'setting', onChange: () => { references.refresh(); }}) .then(library => { if (disposed) library?.destroy?.(); else sceneLibrary = library; }); + openCastLibrary({api, text: t, viewURL: view, uploadOne: castUpload, kind: 'prop', onChange: () => { references.refresh(); }}) + .then(library => { if (disposed) library?.destroy?.(); else propLibrary = library; }); let requestAssets = null, rewriteSubjects = []; const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, issueButton, // One rewrite runs at a time, so the subjects of the text it was given are the ones its numbers refer to. @@ -1693,6 +1697,7 @@ export function openStudio(node, {extendFrom = null} = {}) { chain_resume_failed: t('Could not continue. Refresh the page and try again.', '没有继续生成,请刷新页面后再试。'), }[error?.message] || (error?.said ? t(error.said[0], error.said[1]) : null) || (isNetworkTypeError(error) ? connectionLostText(t) : error?.message) || String(error)); let lastQueueError = null, connectionNotice = false; + watchEngineExit(api).catch(() => {}); function showQueueError(error) { if (disposed) return; status.dataset.error = 'true'; @@ -1721,6 +1726,10 @@ export function openStudio(node, {extendFrom = null} = {}) { if (node.freevideoFailureReport?.stage !== 'queue') return; lastQueueError = null; failure.clear(); node.freevideoFailure = ''; node.freevideoFailureReport = null; status.dataset.error = 'false'; status.textContent = ''; + // The engine is back: say so when the launcher saw it stop by itself. + newEngineExit(api).then(record => { + if (record && !disposed && !status.textContent) status.textContent = engineExitText(record, t); + }).catch(() => {}); } // A part that stopped is one sentence on the status line; what ComfyUI said goes in the card, as a generation failure. function showPartError(error) {