diff --git a/freevideo_engine/access_proxy.py b/freevideo_engine/access_proxy.py
index c382951a..1040bf6f 100644
--- a/freevideo_engine/access_proxy.py
+++ b/freevideo_engine/access_proxy.py
@@ -1,4 +1,4 @@
-"""Token-protected access to a ComfyUI that listens on this computer only.
+"""Authenticated access to a ComfyUI that listens on this computer only.
The launcher's LAN switch or `freevideo server --listen ADDRESS` runs this in front of ComfyUI. The access
link carries the token once; the page then keeps it in a cookie, and API
@@ -9,17 +9,93 @@
import argparse
import asyncio
import hmac
+import hashlib
+import html
+import math
+import time
+import ipaddress
import os
+import re
from pathlib import Path
import ssl
-from urllib.parse import urlencode
+from urllib.parse import urlencode, urlsplit
from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, WSMsgType, client_exceptions, web
from yarl import URL
-from . import proxy_identity
+from . import proxy_identity, lan_password
+from .lan_auth import PasswordAuth, DEVICE_COOKIE, REMEMBER_SECONDS, LOGIN_DELAY
+
+PHONE_WEB = Path(__file__).resolve().parents[1] / 'web'
+PHONE_FILE = re.compile(r'[A-Za-z0-9_][A-Za-z0-9_.-]*\.(?:js|css)')
+PHONE_ASSETS = ('assets/freevideo.svg', 'assets/freevideo-light.svg')
+PHONE_TYPES = {'.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml'}
+# Inline like the password page: installed packages only carry .js/.css/.svg under web/.
+PHONE_PAGE = (
+ ''
+ '
FreeVideo')
COOKIE = 'freevideo_access'
DAYS = 30
+LOGIN_PATH = '/m/login'
+# Temporary English copy; keep all password-page text here for Iris.
+PASSWORD_COPY = dict(title='FreeVideo', password='Password', remember='Keep me signed in',
+ submit='Sign in', incorrect='Incorrect password', retry='Try again later ({minutes} minutes).',
+ phone='FreeVideo mobile')
+
+
+def password_page(*, error='', retry_after=0, status=401):
+ copy = PASSWORD_COPY
+ message = copy['retry'].format(minutes=math.ceil(retry_after / 60)) if retry_after else error
+ response = web.Response(status=status, content_type='text/html', text=(
+ '{title}'
+ '
{title}
{message}
').format(
+ **{k: html.escape(v) for k, v in copy.items()}, message=html.escape(message), retry_after=retry_after, action=LOGIN_PATH))
+ response.headers['Cache-Control'] = 'no-store'
+ # Chromium sends Origin: null from a no-referrer page, which the login would refuse.
+ response.headers['Referrer-Policy'] = 'same-origin'
+ response.headers['Content-Security-Policy'] = "default-src 'none'; form-action 'self'; frame-ancestors 'none'"
+ if retry_after:
+ response.headers['Retry-After'] = str(retry_after)
+ response.headers['X-FreeVideo-Retry-After'] = str(retry_after)
+ return response
+
+
+def phone_file(path, root=None):
+ """The page file /m/ stands for: .js/.css directly inside web/ and the two wordmark SVGs (/m/ itself is PHONE_PAGE)."""
+ root = (root or PHONE_WEB).resolve()
+ name = path[len('/m/'):]
+ if not (PHONE_FILE.fullmatch(name) or name in PHONE_ASSETS):
+ return None
+ try:
+ found = (root / name).resolve(strict=True)
+ except (OSError, RuntimeError):
+ return None
+ return found if found.parent in (root, root / 'assets') and found.is_file() else None
+
+
+def same_origin(request):
+ try:
+ if len(request.headers.getall('Host', [])) != 1 or len(request.headers.getall('Origin', [])) != 1:
+ return False
+ host = urlsplit(request.scheme + '://' + request.host)
+ origin = urlsplit(request.headers['Origin'])
+ socket = request.transport.get_extra_info('sockname')
+ allowed = {socket[0].split('%')[0]}
+ if ipaddress.ip_address(socket[0].split('%')[0]).is_loopback:
+ allowed.update(('localhost', '127.0.0.1', '::1'))
+ own_port = host.port or (443 if request.scheme == 'https' else 80)
+ return (host.hostname in allowed and own_port == socket[1] and origin.hostname == host.hostname
+ and (origin.port or (443 if origin.scheme == 'https' else 80)) == own_port
+ and origin.scheme == request.scheme and not any((origin.path, origin.query, origin.fragment,
+ origin.username, origin.password)))
+ except (ValueError, TypeError, IndexError, KeyError):
+ return False
+
+
# Hop-by-hop headers belong to one connection; Content-Length follows the body actually sent.
HOP_BY_HOP = frozenset(('connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'proxy-connection',
'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length'))
@@ -56,26 +132,88 @@ def forwarded(request):
headers = []
for name, value in request.headers.items():
lower = name.lower()
- if lower in HOP_BY_HOP or lower in ('authorization', proxy_identity.HEADER.lower(),
- proxy_identity.PEER.lower(), proxy_identity.ORIGIN.lower()):
+ if lower in HOP_BY_HOP or lower == 'authorization' or lower.startswith('x-freevideo-proxy-'):
continue
if lower == 'cookie':
value = '; '.join(part for part in value.split(';') if part.strip() and
- part.split('=', 1)[0].strip() != COOKIE)
+ part.split('=', 1)[0].strip() not in (COOKIE, DEVICE_COOKIE))
if not value:
continue
headers.append((name, value))
return headers
-def create(upstream, token):
+def create(upstream, token, engine=None):
upstream = upstream.rstrip('/')
sockets = 'ws' + upstream[len('http'):]
client = []
hop_secret = os.environ.pop(proxy_identity.ENV, '')
+ auth = PasswordAuth(engine)
+ login_lock = asyncio.Lock()
+
+ def browser_headers(response, request):
+ device = request.get('lan_device')
+ if device and request.path in ('/m/', '/m/csrf'):
+ response.headers['X-FreeVideo-CSRF'] = proxy_identity.csrf(hop_secret, device)
+ session = request.get('lan_session')
+ if session and session['remember']:
+ response.set_cookie(DEVICE_COOKIE, request.cookies[DEVICE_COOKIE], max_age=REMEMBER_SECONDS,
+ httponly=True, samesite='Lax', secure=request.secure, path='/')
+ return response
+
+ async def login(request):
+ if not same_origin(request):
+ return web.Response(status=403)
+ # The rest of the proxy streams unlimited uploads. Only login is bounded.
+ body = bytearray()
+ async for chunk in request.content.iter_chunked(4096):
+ body.extend(chunk)
+ if len(body) > 16384:
+ return web.Response(status=413)
+ from urllib.parse import parse_qs
+ try:
+ if request.content_type != 'application/x-www-form-urlencoded':
+ return web.Response(status=415)
+ fields = parse_qs(body.decode('utf-8'), keep_blank_values=True, max_num_fields=8)
+ texts = fields.get('password', [''])
+ text = texts[0] if len(texts) == 1 else ''
+ remember = fields.get('remember') == ['1']
+ except (ValueError, UnicodeError):
+ return web.Response(status=400)
+ ip = request.remote or '' # Never trust X-Forwarded-For for attempt limits.
+ async with login_lock:
+ started = time.monotonic()
+ auth.reload()
+ record = auth.record
+ correct = await asyncio.to_thread(lan_password.verify_record, record, text)
+ await asyncio.sleep(max(0, LOGIN_DELAY - (time.monotonic() - started)))
+ auth.reload()
+ if not auth.enabled or auth.record != record:
+ return password_page(error=PASSWORD_COPY['incorrect'])
+ now = time.monotonic()
+ remaining = auth.attempts.remaining(ip, now)
+ if remaining:
+ auth.log(ip, 'locked')
+ return password_page(retry_after=remaining, status=429)
+ if not correct:
+ remaining = auth.attempts.failed(ip, now)
+ if remaining:
+ auth.log(ip, 'locked')
+ return password_page(error=PASSWORD_COPY['incorrect'], retry_after=remaining,
+ status=429 if remaining else 401)
+ value = auth.issue(remember, time.time())
+ auth.attempts.success(ip)
+ auth.log(ip, 'success')
+ response = web.Response(status=303, headers={'Location': '/m/', 'Cache-Control': 'no-store'})
+ response.set_cookie(DEVICE_COOKIE, value, max_age=REMEMBER_SECONDS if remember else None,
+ httponly=True, samesite='Lax', secure=request.secure, path='/')
+ return response
def forwarding_headers(request):
- return forwarded(request) + proxy_identity.headers(request, hop_secret)
+ headers = forwarded(request) + proxy_identity.headers(request, hop_secret)
+ if request.get('lan_device'):
+ headers.append((proxy_identity.DEVICE, request['lan_device']))
+ return headers
async def startup(app):
# No total timeout: downloads and the progress socket last as long as they need.
@@ -96,6 +234,7 @@ async def forward(request):
response.headers.add(name, value)
if reply.content_length is not None and request.method != 'HEAD':
response.content_length = reply.content_length
+ browser_headers(response, request)
await response.prepare(request)
async for chunk in reply.content.iter_chunked(1 << 16):
await response.write(chunk)
@@ -105,7 +244,7 @@ async def forward(request):
return web.json_response(dict(error='ComfyUI is not answering yet.'), status=502)
async def websocket(request):
- browser = web.WebSocketResponse(max_msg_size=0)
+ browser = browser_headers(web.WebSocketResponse(max_msg_size=0), request)
await browser.prepare(request)
headers = [(k, v) for k, v in forwarding_headers(request) if k.lower() not in (
'sec-websocket-key', 'sec-websocket-version', 'sec-websocket-extensions', 'sec-websocket-protocol')]
@@ -131,18 +270,54 @@ async def pump(source, target):
return browser
async def handle(request):
- offered = request.rel_url.query.get('token')
- if offered is not None:
- if not matches(offered, token):
+ auth.reload()
+ if auth.enabled:
+ if request.path == LOGIN_PATH and request.method == 'POST':
+ return await login(request)
+ session = auth.authenticate(request.cookies.get(DEVICE_COOKIE), time.time())
+ if session:
+ request['lan_device'] = session['hash']
+ request['lan_session'] = session
+ else:
+ header = request.headers.get('Authorization', '')
+ if not (header[:7].lower() == 'bearer ' and matches(header[7:].strip(), token)):
+ remaining = auth.attempts.remaining(request.remote or '', time.monotonic())
+ return password_page(retry_after=remaining, status=429 if remaining else 401)
+ else:
+ offered = request.rel_url.query.get('token')
+ if offered is not None:
+ if not matches(offered, token):
+ return denied(request)
+ # Preserve the existing one-time link exchange and cookie behavior.
+ rest = [(k, v) for k, v in request.rel_url.query.items() if k != 'token']
+ response = web.Response(status=303, headers={'Location': request.rel_url.path + ('?' + urlencode(rest) if rest else '')})
+ response.set_cookie(COOKIE, token, max_age=DAYS * 86400, httponly=True, samesite='Lax',
+ secure=request.secure, path='/')
+ return response
+ if not matches(supplied(request), token):
return denied(request)
- # The access link: keep the token in a cookie and show the address without it.
+ if matches(request.cookies.get(COOKIE), token):
+ request['lan_device'] = hashlib.sha256(token.encode()).hexdigest()
+ # A query token in password mode neither authenticates nor reaches ComfyUI.
+ if auth.enabled and 'token' in request.query:
rest = [(k, v) for k, v in request.rel_url.query.items() if k != 'token']
- response = web.Response(status=303, headers={'Location': request.rel_url.path + ('?' + urlencode(rest) if rest else '')})
- response.set_cookie(COOKIE, token, max_age=DAYS * 86400, httponly=True, samesite='Lax',
- secure=request.secure, path='/')
- return response
- if not matches(supplied(request), token):
- return denied(request)
+ location = request.path + ('?' + urlencode(rest) if rest else '')
+ return browser_headers(web.Response(status=303, headers={'Location': location}), request)
+ if request.path.startswith('/m/') and request.path != LOGIN_PATH and request.method in ('GET', 'HEAD'):
+ if not request.get('lan_device'):
+ return web.Response(status=403)
+ if request.path == '/m/csrf':
+ response = web.json_response(dict(csrf=proxy_identity.csrf(hop_secret, request['lan_device'])))
+ elif request.path == '/m/':
+ response = web.Response(text=PHONE_PAGE if request.method == 'GET' else '', content_type='text/html', charset='utf-8')
+ else:
+ found = phone_file(request.path)
+ if found is None:
+ return web.Response(status=404, headers={'Cache-Control': 'no-store'})
+ response = web.Response(body=found.read_bytes() if request.method == 'GET' else b'',
+ content_type=PHONE_TYPES[found.suffix], charset='utf-8')
+ response.headers['Cache-Control'] = 'no-store'
+ return browser_headers(response, request)
if request.headers.get('Upgrade', '').lower() == 'websocket':
return await websocket(request)
return await forward(request)
@@ -172,7 +347,7 @@ def main(argv=None):
if args.tls_cert:
context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
context.load_cert_chain(args.tls_cert, args.tls_key)
- web.run_app(create(args.upstream, token), host=args.listen, port=args.port, ssl_context=context,
+ web.run_app(create(args.upstream, token, args.token_file.parent.parent), host=args.listen, port=args.port, ssl_context=context,
access_log=None, print=None)
diff --git a/freevideo_engine/agent/access.py b/freevideo_engine/agent/access.py
index 553ad56b..e7ed6807 100644
--- a/freevideo_engine/agent/access.py
+++ b/freevideo_engine/agent/access.py
@@ -27,12 +27,13 @@ def lan_enabled():
return False
-def check(request, *, local=False):
+def check(request, *, local=False, lan=False):
address = peer_address(request)
proxied = proxy_identity.HEADER in request.headers
- # Even a proxy client on loopback is never a local control-plane client.
- if (proxied and (not proxy_identity.trusted(request) or local or not lan_enabled())
- or address is None or not address.is_loopback and (local or not address.is_private or not lan_enabled())):
+ # Only explicitly listed browser routes may use a verified proxy device.
+ local_only = local and not (proxied and lan and bool(proxy_identity.device(request)))
+ if (proxied and (not proxy_identity.trusted(request) or local_only or not lan_enabled())
+ or address is None or not address.is_loopback and (local_only or not address.is_private or not lan_enabled())):
raise JobError('local_only', 'Peer is not permitted')
socket = request.transport.get_extra_info('sockname') if request.transport else None
try:
diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py
index 21c3aed9..9ec75f8b 100644
--- a/freevideo_engine/agent/errors.py
+++ b/freevideo_engine/agent/errors.py
@@ -17,6 +17,7 @@
ERRORS = {
'forbidden': (403, False, 'This caller cannot write this session event.', 'Only the user may stop or continue a session or answer a confirmation; steps are written by the server.'),
'session_full': (409, False, 'This session has reached its event limit.', 'Create a new session with POST /freevideo/agent/v1/sessions.'),
+ 'lan_unavailable': (409, False, 'LAN settings require a launcher installation.', 'Complete installation in the FreeVideo launcher on this computer. 请在本机 FreeVideo 启动器中完成安装。'),
'page_session_required': (403, False, 'A valid page session and CSRF header are required.', 'Reload the page served by this FreeVideo instance and repeat the action.'),
'redirect_forbidden': (403, False, 'A credentialed request cannot follow a cross-origin redirect.', 'Connect directly to the intended FreeVideo instance using its verified HTTP or HTTPS endpoint.'),
'method_not_allowed': (405, False, 'This method is not registered for the requested endpoint.', 'Read GET /freevideo/agent/v1/openapi.json and use the documented method.'),
@@ -252,6 +253,7 @@ def safe_details(value):
# One public sentence shared by conversation cards, HTTP and MCP. Backend text
# is classified, never echoed: it can contain prompts, filenames or credentials.
FAILURE_REASONS_ZH = {
+ 'lan_unavailable': '局域网设置需要启动器安装记录',
'out_of_memory': '生成时显存或内存不足',
'insufficient_memory': '可用显存或内存不够生成这段内容',
'no_memory': '可用显存或内存不够生成这张图片',
diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py
index 1b5995c9..ca03c42d 100644
--- a/freevideo_engine/agent/routes.py
+++ b/freevideo_engine/agent/routes.py
@@ -51,6 +51,10 @@
'failed: say it was not made, and why) and start nothing. A question is never a prompt.')
+
+def _activity_cursor(row):
+ return '%r:%s' % (row['created_at'], row['id'])
+
def local_address(request):
"""The backend's bound port, independent of page origins and forwarding headers."""
socket = request.transport.get_extra_info('sockname') if request.transport else None
@@ -463,12 +467,13 @@ async def respond(request):
definition = contracts.get((method, suffix))
if definition is None:
raise JobError('method_not_allowed' if any(p == suffix for _, p in contracts) else 'not_found', 'No contract')
- access.check(request, local=definition.get('local', False))
+ access.check(request, local=definition.get('local', False), lan=definition.get('lan', False))
if definition.get('auth') is False and definition.get('local') and method not in ('GET', 'HEAD'):
- session = page_sessions.get(request.cookies.get('freevideo_agent_page', ''))
- csrf = request.headers.get('X-FreeVideo-CSRF', '')
- if (session is None or session[1] < time.monotonic() or not csrf
- or not secrets.compare_digest(csrf.encode(), session[0].encode())):
+ if proxy_identity.HEADER in request.headers:
+ valid = definition.get('lan') and proxy_identity.check_csrf(request)
+ else:
+ valid = valid_page_session(request)
+ if not valid:
raise JobError('page_session_required', 'Page session required')
header = request.headers.get('Authorization', '')
given = header[7:].strip() if header[:7].lower() == 'bearer ' else request.headers.get('X-FreeVideo-Agent', '')
@@ -673,16 +678,19 @@ async def read_body(request, limit, optional=False):
async def body(request, **ignored):
return request[body_key]
+ def valid_page_session(request):
+ page = page_sessions.get(request.cookies.get('freevideo_agent_page', ''))
+ csrf = request.headers.get('X-FreeVideo-CSRF', '')
+ return (page is not None and page[1] >= time.monotonic() and bool(csrf)
+ and secrets.compare_digest(csrf.encode(), page[0].encode()))
+
def session_caller(request):
"""guarded verifies supplied tokens; otherwise require a local page and CSRF."""
header = request.headers.get('Authorization', '')
given = header[7:].strip() if header[:7].lower() == 'bearer ' else request.headers.get('X-FreeVideo-Agent', '')
if given:
return 'agent'
- page = page_sessions.get(request.cookies.get('freevideo_agent_page', ''))
- csrf = request.headers.get('X-FreeVideo-CSRF', '')
- if (page is not None and page[1] >= time.monotonic() and csrf
- and secrets.compare_digest(csrf.encode(), page[0].encode())):
+ if valid_page_session(request):
access.check(request, local=True)
return 'user'
raise JobError('unauthorized', 'Token or valid page session required')
@@ -695,7 +703,7 @@ def agent_path(path):
return path == '/freevideo/agent' or path.startswith('/freevideo/agent/')
async def no_cross_site_headers(request, response):
- if agent_path(request.path):
+ if agent_path(request.path) or request.path in ('/freevideo/lan', '/api/freevideo/lan'):
for key in list(response.headers):
if key.lower().startswith('access-control-'):
del response.headers[key]
@@ -741,9 +749,52 @@ async def page_session(request):
response = reply(dict(csrf=page_sessions[current][0]))
# The cookie follows the copy the page asked: its later calls go to the same one.
response.set_cookie('freevideo_agent_page', current, httponly=True, samesite='Strict', secure=request.secure,
- path=('/api' if request.path.startswith('/api/') else '') + '/freevideo/agent', max_age=12 * 3600)
+ path=('/api' if request.path.startswith('/api/') else '') + '/freevideo', max_age=12 * 3600)
return response
+ # Page controls stay outside the Agent contract and never use its bearer token.
+ # ComfyUI mirrors these routes under /api along with the other page routes.
+ @routes.get('/freevideo/lan')
+ @routes.post('/freevideo/lan')
+ async def lan_settings(request):
+ from .. import lan_page
+ from ..comfy_bridge import installation_root
+ identity = errors.REQUEST_ID.set('r-' + uuid.uuid4().hex)
+ try:
+ access.check(request, local=True)
+ root = installation_root()
+ if request.method == 'POST':
+ if not valid_page_session(request):
+ raise JobError('page_session_required', 'Page session required')
+ value = await read_body(request, contract.MAX_BODY_BYTES)
+ return reply(await asyncio.to_thread(lan_page.apply, root, value))
+ return reply(await asyncio.to_thread(lan_page.read, root))
+ except lan_page.InvalidRequest as error:
+ field = error.field if error.field in ('enabled', 'password', '') else ''
+ row = contract.diagnostic('/' + field, error.allowed, error.got)
+ if field == 'password' and not isinstance(error.got, str):
+ row = contract.diagnostic('/password', error.allowed, None)
+ row.update(got='', message='/password must be %s.' % error.allowed)
+ value = errors.validation([row])
+ return reply({'error': value}, 422)
+ except lan_page.LanUnavailable:
+ return problem('lan_unavailable')
+ except JobError as error:
+ return reply({'error': error.view()}, errors.ERRORS[error.code][0])
+ except (json.JSONDecodeError, UnicodeDecodeError, RecursionError):
+ return problem('invalid_json')
+ except specs.SpecError as error:
+ return reply({'error': errors.validation(error.errors)}, 422)
+ except web.HTTPException as error:
+ return problem('too_large' if error.status == 413 else 'invalid_request')
+ except OSError:
+ return problem('storage_unavailable')
+ except Exception:
+ # Do not log exception text or request bodies from password controls.
+ return problem('internal_error')
+ finally:
+ errors.REQUEST_ID.reset(identity)
+
def activity_item(row, *, card=False):
view = jobs.view(row, detail=False)
value = {k: view.get(k) for k in ('id', 'kind', 'status', 'label', 'project', 'client', 'take',
@@ -764,23 +815,26 @@ def activity_item(row, *, card=False):
if (view.get('result') or {}).get('library_id'):
# The work this video became, for the page to show it in My works.
value['work'] = view['result']['library_id']
+ from urllib.parse import quote
+ work = value.get('work')
+ # The saved work the page can play and picture without the agent's token.
+ value['play'] = '/freevideo/agent/chat/video?id=' + quote(work, safe='') if work else None
+ value['thumbnail'] = '/freevideo/library/thumbnail?id=' + quote(work, safe='') if work else None
+ picture = view.get('result') or {}
+ if row['kind'] == 'image' and picture.get('file'):
+ # ComfyUI's own view of the input file, which the page can show without the agent's token.
+ folder, _, leaf = picture['file'].rpartition('/')
+ value['thumbnail'] = '/view?filename=%s&subfolder=%s&type=input' % (quote(leaf, safe=''), quote(folder, safe=''))
if card:
- # A conversation card: the page plays and pictures the saved work itself, without the agent's token.
- from urllib.parse import quote
+ # A conversation card: the page plays and pictures the saved work itself.
value.pop('client')
- work = value.setdefault('work', None)
+ value.setdefault('work', None)
seconds = row['spec'].get('seconds')
size = [row['spec'].get(k) if type(row['spec'].get(k)) is int else None for k in ('width', 'height')]
value.update(seconds=seconds if isinstance(seconds, (int, float)) and not isinstance(seconds, bool) else None,
- width=size[0], height=size[1],
- thumbnail='/freevideo/library/thumbnail?id=' + quote(work, safe='') if work else None,
- play='/freevideo/agent/chat/video?id=' + quote(work, safe='') if work else None)
- picture = view.get('result') or {}
+ width=size[0], height=size[1])
if row['kind'] == 'image' and picture.get('file'):
- # ComfyUI's own view of the input file, which the page can show without the agent's token.
- folder, _, leaf = picture['file'].rpartition('/')
- value.update(width=picture.get('width'), height=picture.get('height'),
- thumbnail='/view?filename=%s&subfolder=%s&type=input' % (quote(leaf, safe=''), quote(folder, safe='')))
+ value.update(width=picture.get('width'), height=picture.get('height'))
return value
def agent_state():
@@ -1119,10 +1173,30 @@ async def activity(request):
def read():
# For the studio: what agents are doing, as ComfyUI's own queue and history already show.
now = time.time()
- rows = sorted(jobs.rows(), key=lambda r: r['created_at'], reverse=True)
- shown = [r for r in rows if r['status'] in ACTIVE or (r.get('finished_at') or 0) > now - 1800][:12]
- return dict(active=sum(1 for r in rows if r['status'] in ACTIVE), jobs=[activity_item(r) for r in shown])
- return reply(await asyncio.to_thread(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')
+ 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': ''}))
+ else:
+ try:
+ stamp, _, ident = cursor.partition(':')
+ key = (float(stamp), ident)
+ if not math.isfinite(key[0]) or len(cursor) > 80:
+ raise ValueError
+ except ValueError:
+ 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],
+ 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)
@routes.post('/freevideo/agent/activity/cancel')
@guarded
diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py
index d49a475d..5a88c66d 100644
--- a/freevideo_engine/agent/schema.py
+++ b/freevideo_engine/agent/schema.py
@@ -441,7 +441,16 @@ def route(summary, body=None, query=None, **kw):
('/chat', 'POST', obj({'text': string(8000), 'id': CHAT_ID, 'session': SESSION, 'whitebox': {'type': 'boolean'}}, ('text', 'id'))),
('/chat/confirm', 'POST', obj({'id': string(12, pattern=r'^cf[0-9a-f]{10}$'), 'approve': {'type': 'boolean'},
'session': SESSION}, ('id', 'approve')))):
- routes[(method, '/freevideo/agent' + suffix)] = route('Studio Agent controls', body=body, auth=False, local=True)
+ routes[(method, '/freevideo/agent' + suffix)] = route('Studio Agent controls', body=body, auth=False, local=True,
+ lan=suffix in ('/chat', '/chat/now', '/chat/confirm', '/chat/list', '/chat/new', '/chat/video',
+ '/activity', '/activity/cancel'))
+ # These existing ComfyUI handlers are registered outside the Agent dispatcher.
+ # /chain is a creation POST; monitoring reads /chains and /chain/{id}.
+ for suffix in ('/freevideo/progress', '/freevideo/chains', '/freevideo/chain/{id}'):
+ routes['GET', suffix] = route('Read generation monitoring', auth=False, lan=True, external=True,
+ query=obj({'client_id': string()}) if suffix != '/freevideo/progress' else EMPTY,
+ **({'path': obj({'id': string()}, ('id',))} if '{id}' in suffix else {}))
+ routes['GET', '/freevideo/agent/activity']['query'] = obj({'cursor': string(80)})
routes['GET', '/freevideo/agent/chat']['query'] = obj({'after': number(0, integer=True), 'session': SESSION})
routes['GET', '/freevideo/agent/chat/video']['query'] = obj({'id': string(43, pattern=r'^[0-9]{4}-[0-9]{2}-[0-9]{2}/[0-9a-f]{32}$')}, ('id',))
for method, suffix, summary, extra in (
diff --git a/freevideo_engine/chain_leads.py b/freevideo_engine/chain_leads.py
index bfa0f904..a6ef2404 100644
--- a/freevideo_engine/chain_leads.py
+++ b/freevideo_engine/chain_leads.py
@@ -55,6 +55,27 @@ def load(directory, chain, index, previous, output=None):
return None
+def copy(directory, source, target, index):
+ """Give chain `target` the seam sound `source` kept for block `index`; False when there is none."""
+ import os
+ import uuid
+ origin, path = _path(directory, source, index), _path(directory, target, index)
+ try:
+ data = origin.read_bytes()
+ except FileNotFoundError:
+ return False
+ temporary = path.with_name(path.name + '.' + uuid.uuid4().hex[:8] + '.tmp')
+ try:
+ with temporary.open('wb') as stream:
+ stream.write(data)
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, path)
+ finally:
+ temporary.unlink(missing_ok=True)
+ return True
+
+
def has(directory, chain, index):
try:
return _path(directory, chain, index).is_file()
diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py
index 2194ded2..bf37a1f3 100644
--- a/freevideo_engine/chain_runner.py
+++ b/freevideo_engine/chain_runner.py
@@ -3,7 +3,8 @@
import json
import logging
import math
-from pathlib import PurePosixPath
+from pathlib import Path, PurePosixPath
+import random
import threading
import time
import uuid
@@ -68,8 +69,9 @@ def node_prompt(prompt, node):
class ChainRunner:
def __init__(self, store_dir, host, clock=time.monotonic, wall=time.time, estimate=None, poll=0.5, inputs=None,
- max_parts=None, min_part_frames=None):
+ max_parts=None, min_part_frames=None, seeds=None):
self.store_dir, self.host = store_dir, host
+ self.seeds = seeds or random.SystemRandom()
self.max_parts = max_parts or store.MAX_PARTS
self.min_part_frames = chain_plan.MIN_PART_FRAMES if min_part_frames is None else min_part_frames
self.clock, self.wall, self.estimate, self.poll = clock, wall, estimate, poll
@@ -336,6 +338,81 @@ def resume(self, chain_id, client_id=None):
self.wake.set()
return self._view(record)
+ def redo(self, chain_id, block_id, text=None, client_id=None):
+ """A new chain that keeps the parts before `block_id` and makes that part and every later one
+ again with new seeds. Later parts continue the part before them, so redoing one part alone would
+ break the next seam. The original chain, its parts and its joined video stay as they are."""
+ if text is not None and (not isinstance(text, str) or not text.strip() or len(text) > 20000):
+ raise ValueError('Text must be a nonempty string of at most 20000 characters')
+ if client_id is not None and not isinstance(client_id, str):
+ raise ValueError('Client ID must be a string')
+ from .lora_triggers import apply_chain_triggers
+ with self.lock:
+ source = self.records[chain_id]
+ start = next((row for row in source['blocks'] if row['id'] == block_id), None)
+ if start is None:
+ raise KeyError(block_id)
+ if source['state'] in store.ACTIVE or source['output_state'] == 'joining':
+ raise ChainBusy('This chain is already active')
+ kept = [row for row in source['blocks'] if row['index'] < start['index']]
+ if any(row['state'] != 'done' or not row['output'] for row in kept):
+ raise ValueError('The parts before this one are not finished')
+ names = {row['id'] for row in kept}
+ if any(isinstance(row[key], str) and row[key] not in names for row in kept for key in ('after', 'before')):
+ raise ValueError('A kept part depends on a part that is made again')
+ record = deepcopy(source)
+ identity = source['id']
+ while identity in self.records or (Path(self.store_dir) / (identity + '.json')).exists():
+ identity = 'c-' + uuid.uuid4().hex[:12]
+ for key in ('last_report_id', 'last_heartbeat_at', 'output_seams', 'join_final_state'):
+ record.pop(key, None)
+ now = self.wall()
+ record.update(id=identity, created=now, state='queued', redo_of=dict(chain=chain_id, block=block_id),
+ output=None, output_state='pending' if record['join'] else 'none',
+ output_error=None, output_blocks=0)
+ if client_id is not None:
+ record['client_id'] = client_id
+ if record.get('split') is not None and record['split']['state'] == 'running':
+ record['split']['state'] = 'pending'
+ for block in record['blocks'][start['index']:]:
+ seed = block['inputs']['seed']
+ while seed == block['inputs']['seed']:
+ seed = self.seeds.randrange(2 ** 31)
+ block['inputs']['seed'] = seed
+ block.update(state='pending', prompt_id=None, attempts=0, started=None, finished=None,
+ seconds=None, output=None, report_id=None, error=None,
+ fraction=0., remaining_seconds=None, continuation=None,
+ skip_frames=0, skip_audio_samples=0)
+ if text is not None:
+ # Only the new words take the LoRA triggers; the other parts keep their texts as written.
+ probe = dict(node=record['node'], snapshot=deepcopy(record['snapshot']),
+ blocks=[dict(inputs=dict(text=text))])
+ try:
+ apply_chain_triggers(probe)
+ except Exception:
+ raise ValueError('Text must be at most 20000 characters with LoRA triggers') from None
+ record['blocks'][start['index']]['inputs']['text'] = probe['blocks'][0]['inputs']['text']
+ short = [row['inputs']['frames'] for row in record['blocks']
+ if (row['after'] is not None or row['before'] is not None)
+ and row['inputs']['frames'] < self.min_part_frames]
+ if short:
+ raise ChainPartTooShort(min(short) / chain_plan.FPS, chain_plan.MIN_PART_SECONDS)
+ parts = max(self._depths(record).values())
+ if parts > self.max_parts:
+ raise ChainTooLong(parts, self.max_parts)
+ # The kept seams are repaired with the sound the original chain kept for them. Copies left by
+ # a failed save have no record, so the next start prunes them.
+ try:
+ for row in kept[1:]:
+ chain_leads.copy(self.store_dir, chain_id, identity, row['index'])
+ self._save(record)
+ except OSError:
+ raise ValueError('The kept parts could not be copied') from None
+ self.records[identity] = record
+ self.queue.append(identity)
+ self.wake.set()
+ return self._view(record)
+
def list(self, client_id=None):
with self.lock:
records = [row for row in self.records.values()
diff --git a/freevideo_engine/chain_store.py b/freevideo_engine/chain_store.py
index 2c2c5d47..5722e142 100644
--- a/freevideo_engine/chain_store.py
+++ b/freevideo_engine/chain_store.py
@@ -198,6 +198,7 @@ def summary(record):
('version', 'id', 'node', 'created', 'updated', 'state',
'join', 'output', 'output_state', 'output_error', 'output_blocks')},
output_seams=deepcopy(record.get('output_seams')), split=deepcopy(record.get('split')),
+ redo_of=deepcopy(record.get('redo_of')),
count=len(blocks), done=sum(row['state'] == 'done' for row in blocks),
current=current, elapsed_seconds=sum(row['seconds'] or 0 for row in blocks if row['state'] == 'done'),
last_report_id=record.get('last_report_id') or next((row['report_id'] for row in reversed(blocks)
@@ -205,9 +206,14 @@ def summary(record):
def detail(record):
- return dict(summary(record), blocks=deepcopy(record['blocks']), plan=deepcopy(record['plan']),
- generation_size={key: record['snapshot']['prompt'][record['node']]['inputs'].get(key)
- for key in ('width', 'height')})
+ value = dict(summary(record), blocks=deepcopy(record['blocks']), plan=deepcopy(record['plan']),
+ generation_size={key: record['snapshot']['prompt'][record['node']]['inputs'].get(key)
+ for key in ('width', 'height')})
+ if (record.get('split') or {}).get('state') == 'done':
+ # Split parts repeat words put there by code; the page lists and edits only each part's own.
+ from .prompt_vlm.rules import own_words
+ value['part_words'] = own_words([str(row['inputs'].get('text', '')) for row in record['blocks']])
+ return value
def save(directory, record):
@@ -268,6 +274,12 @@ def load_all(directory, limit=200, *, identity=None):
if seams is not None and (not isinstance(seams, dict) or set(seams) != {'expected', 'kept'}
or any(type(value) is not int or value < 0 for value in seams.values())):
raise ValueError('Invalid chain seam count')
+ redo = record.get('redo_of')
+ if redo is not None and (not isinstance(redo, dict) or set(redo) != {'chain', 'block'}
+ or not isinstance(redo['chain'], str)
+ or not re.fullmatch(r'c-[0-9a-f]{12}', redo['chain'])
+ or not isinstance(redo['block'], str) or not redo['block']):
+ raise ValueError('Invalid chain redo source')
if record.get('join_final_state', 'done') not in ('done', 'cancelled', 'failed', 'interrupted'):
raise ValueError('Invalid chain join final state')
if record['plan'] is not None:
diff --git a/freevideo_engine/comfy_assets.py b/freevideo_engine/comfy_assets.py
index ecbf3115..8d4f8a7a 100644
--- a/freevideo_engine/comfy_assets.py
+++ b/freevideo_engine/comfy_assets.py
@@ -75,7 +75,7 @@ def resolve_assets(serialized, input_directory):
root = Path(input_directory).resolve()
result = {}
for row in rows:
- if not isinstance(row, dict) or set(row) - {'file', 'role', 'enabled', 'cast', 'early_steps'}:
+ if not isinstance(row, dict) or set(row) - {'file', 'role', 'enabled', 'cast', 'early_steps', 'sound'}:
raise ValueError('Invalid media item')
# 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 ('?',)) != {'id'}
@@ -112,6 +112,11 @@ def resolve_assets(serialized, input_directory):
from .media_request import validate_early_steps
validate_early_steps(dict(row, kind=kind))
reference['early_steps'] = row['early_steps']
+ if 'sound' in row:
+ from .media_request import validate_sound
+ validate_sound(dict(row, kind=kind))
+ if not row['sound']:
+ reference['sound'] = False
result.setdefault('references', []).append(reference)
else:
raise ValueError('Media role must be first, last or reference')
diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py
index cb50be26..482e6813 100644
--- a/freevideo_engine/comfy_chain.py
+++ b/freevideo_engine/comfy_chain.py
@@ -408,6 +408,32 @@ async def text(request):
except ValueError as failure:
return response({'error': str(failure)}, 400)
+ @server.routes.post('/freevideo/chain/{id}/blocks/{block}/redo')
+ async def redo(request):
+ # A new chain from this part on; the original chain and its video are kept.
+ try:
+ value = await body(request) if request.can_read_body else {}
+ identity, block = request.match_info['id'], request.match_info['block']
+ if not fake and os.environ.get('FREEVIDEO_CHAIN_INDEPENDENT') != '1':
+ missing = {key for row in runner.get(identity)['blocks'] for key in CONTINUE_INPUTS
+ if row[key] is not None} - continuation_directions()
+ if missing:
+ return response({'error': 'Continuing a video is not available in this version'}, 409)
+ return response({'chain': await asyncio.to_thread(runner.redo, identity, block,
+ value.get('text'), value.get('client_id'))})
+ except KeyError:
+ return response({'error': 'Unknown chain or part'}, 404)
+ except ChainBusy as failure:
+ return response({'error': str(failure)}, 409)
+ except ChainTooLong as failure:
+ return response({'error': str(failure), 'error_zh': failure.message_zh, 'code': failure.code,
+ 'parts': failure.parts, 'max_parts': failure.limit}, 400)
+ except ChainPartTooShort as failure:
+ return response({'error': str(failure), 'error_zh': failure.message_zh, 'code': failure.code,
+ 'min_seconds': failure.limit}, 400)
+ except ValueError as failure:
+ return response({'error': str(failure)}, 400)
+
@server.routes.post('/freevideo/chain/{id}/resume')
async def resume(request):
try:
diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py
index b33af27c..ddcf4eda 100644
--- a/freevideo_engine/comfy_launcher_runtime.py
+++ b/freevideo_engine/comfy_launcher_runtime.py
@@ -32,7 +32,7 @@
from .desktop_runtime import materialize_source, source_release
from .monitoring import save
from .long_paths import io_path, resolved, plain, plain_text, plain_error, plain_errors
-from . import engine_binding, processes
+from . import engine_binding, processes, lan_password
PROTOCOL = 1
TEMPLATE = 'FreeVideo-All-in-One.json'
@@ -405,6 +405,19 @@ def address(hostname, port):
return 'http://%s:%d' % ('[%s]' % hostname if ':' in hostname else hostname, port)
+def lan_access_links(engine, port, *, password_set=None):
+ """Use the same phone entry and authentication policy for launcher and page."""
+ from .comfy_service import token
+ from .proxy import local_addresses
+ hosts = local_addresses()
+ if not hosts:
+ return []
+ if password_set is None:
+ password_set = lan_password.is_set(engine)
+ suffix = '/m/' if password_set else '/m/?freevideo=launch&token=' + token(engine)
+ return [address(host, port) + suffix for host in hosts]
+
+
def next_free_address(url, count=12):
"""The next local address after this one that nothing listens or answers on, as setup.sh chooses."""
parsed = urlsplit(url)
@@ -743,13 +756,11 @@ def _access_stopped(self):
self.state = dict(self.state, selection=dict(self.selection), lan_status='stopped')
def refresh_access_links(self):
- from .comfy_service import token
- from .proxy import local_addresses
self.access_polled = time.monotonic()
- secret = token(self.selection['engine'])
- self.access_links = [address(host, self.selection['lan_port']) + '/?freevideo=launch&token=' + secret
- for host in local_addresses()]
- self.state = dict(self.state, lan_status='' if self.access_links else 'no-address')
+ password_set = lan_password.is_set(self.selection['engine'])
+ self.access_links = lan_access_links(self.selection['engine'], self.selection['lan_port'],
+ 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_access_proxy(self):
"""Poll child lifetimes and refresh network links without moving ComfyUI."""
diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py
index da63ee3c..805b5dc2 100644
--- a/freevideo_engine/comfy_library.py
+++ b/freevideo_engine/comfy_library.py
@@ -1123,8 +1123,9 @@ async def request_assets(request):
for item in query.getall('ref', [])[:32]:
kind, _, name = item.partition(':')
parts = Path(name).parts
- if kind in ('image', 'video', 'audio') and parts and not Path(name).is_absolute() and '..' not in parts:
- refs.append(dict(kind=kind, path=name))
+ silent = kind == 'video-silent'
+ if (kind in ('image', 'video', 'audio') or silent) and parts and not Path(name).is_absolute() and '..' not in parts:
+ refs.append(dict(kind='video', path=name, sound=False) if silent else dict(kind=kind, path=name))
if refs:
# The generation path's own classification decides the tables (audio track in a video).
value.update(references=refs, media_base=folder_paths.get_input_directory())
diff --git a/freevideo_engine/diagnostics.py b/freevideo_engine/diagnostics.py
index 41170e4e..9f15bc49 100644
--- a/freevideo_engine/diagnostics.py
+++ b/freevideo_engine/diagnostics.py
@@ -30,6 +30,7 @@
'prompt-rewrite.json',
'plan.json', 'machine.before.json', 'comparison.json', 'profile.json',
'result.json', 'gpu.csv', 'ram.jsonl', 'network.jsonl', 'optimization.json', 'storage.json', 'kernel-capabilities.json'}
+SENSITIVE_FILES = frozenset(('lan-password.json', 'lan-devices.json'))
SENSITIVE = re.compile(r'(?:^|[_-])(?:token|password|secret|credential|api[_-]?key)(?:$|[_-])', re.I)
# Path-bearing fields are retained only as installation-relative names (when a
# known report root is available) or as a basename under ````. Reports
@@ -283,6 +284,9 @@ def add(name, raw, truncated=False):
manifest['payload_bytes'] += len(data)
def add_path(name, path):
+ if Path(path).name.lower() in SENSITIVE_FILES:
+ manifest['omitted'].append({'file': name, 'reason': 'Private LAN authentication record'})
+ return
try:
data, truncated = read_complete(path) if complete else read_bounded(path)
add(name, data, truncated)
diff --git a/freevideo_engine/feedback.py b/freevideo_engine/feedback.py
index 2c2f0c2c..d34d065d 100644
--- a/freevideo_engine/feedback.py
+++ b/freevideo_engine/feedback.py
@@ -15,6 +15,10 @@
HARDWARE_KEYS = ('gpu_name', 'vram_total', 'ram_total', 'system', 'architecture', 'driver_version',
'torch_version', 'cuda_version')
+# Free physical RAM and commit headroom stay apart: a refusal text is cut
+# short, and the two numbers say which of them left too little.
+MEMORY_KEYS = ('physical_available_bytes', 'commit_available_bytes', 'available_bytes', 'total_bytes')
+
def scrub(value):
return TOKEN.sub('', PATH.sub('', str(value)))
@@ -37,13 +41,19 @@ def summary(report, machine=None):
for key in ('version', 'status', 'request_seconds', 'geometry', 'config', 'stages', 'budgets'):
if key in analysis:
out[key] = analysis[key]
+ planning = analysis.get('resource_planning')
+ memory = planning[-1].get('system_memory') if isinstance(planning, list) and planning and isinstance(planning[-1], dict) else None
+ if isinstance(memory, dict):
+ memory = {k: memory[k] for k in MEMORY_KEYS if type(memory.get(k)) is int}
+ if memory:
+ out['memory'] = memory
errors = analysis.get('errors')
if isinstance(errors, list):
out['errors'] = [scrub(e)[:300] for e in errors[:3]]
if hardware:
out['hardware'] = hardware
# Bound the whole summary; drop the largest optional parts first.
- for key in ('stages', 'config', 'errors', 'budgets', 'geometry'):
+ for key in ('stages', 'config', 'errors', 'budgets', 'geometry', 'memory'):
if len(json.dumps(out, separators=(',', ':'), ensure_ascii=False)) <= MAX_DIAGNOSTICS:
break
out.pop(key, None)
diff --git a/freevideo_engine/hardware.py b/freevideo_engine/hardware.py
index 49b1e988..4970f9a3 100644
--- a/freevideo_engine/hardware.py
+++ b/freevideo_engine/hardware.py
@@ -23,6 +23,8 @@ class Hardware:
cgroup_ram_limit: int | None = None
gpu_uuid: str = ''
driver_version: str = ''
+ ram_physical_available: int | None = None
+ ram_commit_available: int | None = None
@property
def architecture(self):
@@ -196,7 +198,8 @@ def detect():
limit, available = cgroup_capacity(include_reclaimable=True)
return replace(observed, ram_total=memory['total_bytes'],
ram_available=min(memory['available_bytes'], available) if available is not None else memory['available_bytes'],
- cgroup_ram_limit=limit)
+ cgroup_ram_limit=limit, ram_physical_available=memory.get('physical_available_bytes'),
+ ram_commit_available=memory.get('commit_available_bytes'))
def _detect_local():
@@ -223,7 +226,8 @@ def _detect_local():
pass
return Hardware(torch.cuda.get_device_name(index), torch.cuda.get_device_capability(index),
total, free, ram['total_bytes'], min(ram['available_bytes'], available) if available is not None else ram['available_bytes'],
- platform.system(), str(torch.__version__), str(torch.version.cuda), cap, uuid, driver)
+ platform.system(), str(torch.__version__), str(torch.version.cuda), cap, uuid, driver,
+ ram.get('physical_available_bytes'), ram.get('commit_available_bytes'))
def installed_backends():
diff --git a/freevideo_engine/lan_auth.py b/freevideo_engine/lan_auth.py
new file mode 100644
index 00000000..fdce39c0
--- /dev/null
+++ b/freevideo_engine/lan_auth.py
@@ -0,0 +1,118 @@
+"""Proxy-owned device sessions and in-memory password attempt limits."""
+from collections import deque
+from datetime import datetime, timezone
+import hashlib
+import json
+import math
+import secrets
+
+from . import lan_password
+
+DEVICE_COOKIE = 'freevideo_device'
+REMEMBER_SECONDS = 30 * 86400
+SESSION_SECONDS = 12 * 3600
+LOGIN_DELAY = .25
+TOUCH_SECONDS = 60
+MAX_SOURCES = 1024
+
+
+class Attempts:
+ def __init__(self):
+ self.sources = {}
+ self.failures = deque()
+ self.global_until = 0
+
+ def remaining(self, ip, now):
+ return max(0, math.ceil(max(self.global_until, self.sources.get(ip, (0, 0))[1]) - now))
+
+ def failed(self, ip, now):
+ count = self.sources.get(ip, (0, 0))[0] + 1
+ until = now + min(900, 60 * 2 ** min(count - 5, 4)) if count >= 5 else 0
+ self.sources[ip] = count, until
+ if len(self.sources) > MAX_SOURCES:
+ for key in [key for key, (_, wait) in self.sources.items() if wait <= now and key != ip]:
+ del self.sources[key]
+ while len(self.sources) > MAX_SOURCES:
+ del self.sources[next(iter(self.sources))]
+ while self.failures and self.failures[0] <= now - 600:
+ self.failures.popleft()
+ self.failures.append(now)
+ # Deliberate: a burst of failures from many sources locks every login for five minutes,
+ # so guessing across addresses cannot outrun the per-source delay. The owner waits it out.
+ if len(self.failures) >= 30:
+ self.global_until = now + 300
+ return self.remaining(ip, now)
+
+ def success(self, ip):
+ self.sources.pop(ip, None)
+
+
+class PasswordAuth:
+ def __init__(self, engine):
+ self.engine = engine
+ self.signature = None
+ self.enabled = False
+ self.record = None
+ self.attempts = Attempts()
+
+ def reload(self):
+ if self.engine is None:
+ return
+ try:
+ info = lan_password.path(self.engine).stat()
+ signature = info.st_mtime_ns, info.st_ino, info.st_size
+ except FileNotFoundError:
+ signature = None
+ except OSError:
+ signature = 'unreadable'
+ self.enabled = signature is not None
+ if signature != self.signature:
+ self.record = lan_password.read(self.engine) if self.enabled else None
+ self.signature = signature
+
+ def generation(self):
+ return hashlib.sha256(b''.join(self.record)).hexdigest() if self.record else ''
+
+ def devices(self, now):
+ try:
+ target = lan_password.path(self.engine).with_name(lan_password.DEVICES_FILE)
+ value = json.loads(target.read_text(encoding='utf-8'))
+ if not isinstance(value, dict) or value.get('password') != self.generation() or not self.record:
+ return []
+ return [row for row in value['devices'] if isinstance(row, dict) and isinstance(row['hash'], str) and
+ len(row['hash']) == 64 and all(c in '0123456789abcdef' for c in row['hash']) and
+ all(type(row[key]) in (int, float) and math.isfinite(row[key]) for key in ('created', 'last_used')) and
+ type(row['remember']) is bool and
+ now < (row['last_used'] + REMEMBER_SECONDS if row['remember'] else row['created'] + SESSION_SECONDS)]
+ except (OSError, ValueError, KeyError, TypeError):
+ return []
+
+ def save(self, rows):
+ lan_password.save_private(lan_password.path(self.engine).with_name(lan_password.DEVICES_FILE),
+ dict(version=1, password=self.generation(), devices=rows))
+
+ def issue(self, remember, now):
+ value = secrets.token_urlsafe(32)
+ digest = hashlib.sha256(value.encode()).hexdigest()
+ rows = self.devices(now)
+ rows.append(dict(hash=digest, created=now, last_used=now, remember=remember))
+ self.save(rows)
+ return value
+
+ def authenticate(self, value, now):
+ if not value or len(value) != 43 or not self.record:
+ return None
+ digest = hashlib.sha256(value.encode()).hexdigest()
+ rows = self.devices(now)
+ for row in rows:
+ if secrets.compare_digest(row['hash'], digest):
+ if now - row['last_used'] >= TOUCH_SECONDS:
+ row['last_used'] = now
+ self.save(rows)
+ return row
+ return None
+
+ def log(self, ip, result):
+ target = lan_password.path(self.engine).with_name('access-proxy.log')
+ with target.open('a', encoding='utf-8') as stream:
+ stream.write('%s %s %s\n' % (datetime.now(timezone.utc).isoformat(), ip, result))
diff --git a/freevideo_engine/lan_page.py b/freevideo_engine/lan_page.py
new file mode 100644
index 00000000..ecdae418
--- /dev/null
+++ b/freevideo_engine/lan_page.py
@@ -0,0 +1,75 @@
+"""Local page LAN settings, independent of the HTTP server."""
+import socket
+import time
+
+from . import lan_auth, lan_password
+from .launcher_settings import Store, default_language
+
+
+class LanUnavailable(RuntimeError):
+ """The launcher has not recorded an installation on this computer."""
+
+
+class InvalidRequest(ValueError):
+ def __init__(self, field, allowed, got):
+ self.field, self.allowed, self.got = field, allowed, got
+ super().__init__('%s must be %s' % (field or 'body', allowed))
+
+
+def read(engine):
+ installation = Store(engine).read().get('installation')
+ flag = bool(installation and installation.get('lan_access') is True)
+ port = installation.get('lan_port') if installation else None
+ enabled = False
+ if flag and port is not None:
+ try:
+ with socket.create_connection(('127.0.0.1', port), timeout=.3):
+ enabled = True
+ except OSError:
+ pass
+ password_set = lan_password.is_set(engine)
+ auth = lan_auth.PasswordAuth(engine)
+ auth.reload()
+ link = ''
+ if enabled:
+ from .comfy_launcher_runtime import lan_access_links
+ links = lan_access_links(engine, port, password_set=password_set)
+ link = links[0] if links else ''
+ return dict(available=installation is not None, enabled=enabled, starting=flag and not enabled,
+ link=link, password_set=password_set, devices=len(auth.devices(time.time())),
+ min_length=lan_password.MIN_LENGTH)
+
+
+def apply(engine, body):
+ if not isinstance(body, dict) or not body:
+ raise InvalidRequest('', 'a nonempty object containing enabled or password', body)
+ for field in body:
+ if field not in ('enabled', 'password'):
+ raise InvalidRequest(field, 'absent (unknown field)', None)
+ if 'enabled' in body and type(body['enabled']) is not bool:
+ raise InvalidRequest('enabled', 'a boolean', body['enabled'])
+ if 'password' in body:
+ password = body['password']
+ if password is not None and not isinstance(password, str):
+ raise InvalidRequest('password', 'a string or null', password)
+ if isinstance(password, str) and len(password) < lan_password.MIN_LENGTH:
+ raise InvalidRequest('password', 'at least %d characters (min_length=%d)' % (
+ lan_password.MIN_LENGTH, lan_password.MIN_LENGTH), password)
+ store = Store(engine)
+ with store.exclusive():
+ saved = store.read()
+ installation = saved.get('installation')
+ if installation is None:
+ raise LanUnavailable()
+ # Validate the whole request and installation before changing authentication.
+ if 'password' in body:
+ if body['password'] is None:
+ lan_password.remove(engine)
+ else:
+ lan_password.set_password(engine, body['password'])
+ if 'enabled' in body:
+ installation = dict(installation, lan_access=body['enabled'])
+ # access.lan_enabled() checks the current top-level permission copies.
+ saved['lan_access'] = body['enabled']
+ store.write_locked(saved, saved.get('language', default_language()), installation, saved.get('setup'))
+ return read(engine)
diff --git a/freevideo_engine/lan_password.py b/freevideo_engine/lan_password.py
new file mode 100644
index 00000000..be9e34d6
--- /dev/null
+++ b/freevideo_engine/lan_password.py
@@ -0,0 +1,92 @@
+"""Optional LAN password and private, atomic authentication records.
+
+The engine root is the same root used by launcher_settings.Store. Passwords
+never enter launcher preferences; only the fixed scrypt record is persisted.
+"""
+import hashlib
+import hmac
+import json
+import os
+from pathlib import Path
+import secrets
+
+from .agent import private_file
+from .long_paths import io_path
+
+MIN_LENGTH = 6
+N, R, P = 2 ** 15, 8, 1
+PASSWORD_FILE = 'lan-password.json'
+DEVICES_FILE = 'lan-devices.json'
+
+
+def path(engine):
+ # Extended form on Windows, so long installation roots work for every reader.
+ return io_path(Path(engine) / 'launcher' / PASSWORD_FILE)
+
+
+def is_set(engine):
+ # An unreadable or invalid record must never silently disable protection.
+ return path(engine).exists()
+
+
+def save_private(target, value):
+ target = io_path(target)
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
+ temporary = target.with_name('.' + target.name + '-' + secrets.token_hex(8))
+ descriptor = private_file.create(temporary)
+ try:
+ with os.fdopen(descriptor, 'w', encoding='utf-8') as stream:
+ json.dump(value, stream, separators=(',', ':'))
+ stream.write('\n')
+ stream.flush()
+ os.fsync(stream.fileno())
+ os.replace(temporary, target)
+ finally:
+ temporary.unlink(missing_ok=True)
+
+
+def clear_devices(engine):
+ save_private(path(engine).with_name(DEVICES_FILE), dict(version=1, devices=[]))
+
+
+def derive(text, salt):
+ # OpenSSL's default 32 MiB cap is slightly below this parameter set's needs.
+ return hashlib.scrypt(text.encode('utf-8'), salt=salt, n=N, r=R, p=P, dklen=32, maxmem=64 * 1024 ** 2)
+
+
+def read(engine):
+ try:
+ with path(engine).open(encoding='utf-8') as stream:
+ value = json.loads(stream.read(4096))
+ if (value['version'], value['n'], value['r'], value['p']) != (1, N, R, P):
+ return None
+ salt, digest = bytes.fromhex(value['salt']), bytes.fromhex(value['hash'])
+ return (salt, digest) if len(salt) == 16 and len(digest) == 32 else None
+ except (OSError, ValueError, KeyError, TypeError):
+ return None
+
+
+def verify_record(record, text):
+ salt, expected = record if record is not None else (bytes(16), bytes(32))
+ actual = derive(text, salt)
+ return hmac.compare_digest(actual, expected) and record is not None
+
+
+def verify(engine, text):
+ return verify_record(read(engine), text)
+
+
+def set_password(engine, text):
+ if not isinstance(text, str) or len(text) < MIN_LENGTH:
+ raise ValueError('LAN password is too short')
+ salt = secrets.token_bytes(16)
+ digest = derive(text, salt)
+ # Device records also carry this password generation, so concurrent logins
+ # using the old record cannot revive devices during an atomic replacement.
+ clear_devices(engine)
+ save_private(path(engine), dict(version=1, salt=salt.hex(), n=N, r=R, p=P, hash=digest.hex()))
+
+
+def remove(engine):
+ clear_devices(engine)
+ path(engine).unlink(missing_ok=True)
diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py
index e16d0eea..a83a49fa 100644
--- a/freevideo_engine/launcher_session.py
+++ b/freevideo_engine/launcher_session.py
@@ -1,6 +1,7 @@
"""Widget-independent state for the Qt launcher; uses the existing installer."""
from copy import deepcopy
from functools import lru_cache
+import importlib
import json
import os
from pathlib import Path
@@ -28,6 +29,7 @@
# Background launcher release checks while the window stays open.
CHECK_SECONDS = 30 * 60
+SETTLE_SECONDS = 60
# "Later" hides one reminder for this long; a newer release reminds at once.
SNOOZE_SECONDS = 4 * 3600
QUEUE_POLL_SECONDS = 3
@@ -45,6 +47,11 @@
@lru_cache(maxsize=4)
+def _lan_password():
+ # Resolved per call: the access module is optional and may be absent.
+ return importlib.import_module('.lan_password', __package__)
+
+
def _qr_rows(text):
from .qr_code import encode
try:
@@ -67,6 +74,7 @@ class Session:
bridge_pending = False
bridge_written = (0., None)
update_checked = 0.
+ settle_at = None
page = 'comfy'
selected = installed_versions = engine_identity = bundled_identity = None
release_details = None
@@ -1182,10 +1190,10 @@ def _open_release_page(self):
"""A package the user installs themselves (macOS app, Windows folder): the release
page opens and the reminder closes. True when this update was handled that way."""
row = self.updater.state if self.updater else {}
+ from .launcher_update import manual_install, release_page
if not (row.get('candidate') and row.get('status') != 'checking'
- and self.updater.current.get('packaging') in ('onedir', 'app')):
+ and manual_install(self.updater.current, self.updater)):
return False
- from .launcher_update import release_page
from .windows_ux import open_browser
self.pending_update = None
self.update_intent = None
@@ -1361,6 +1369,15 @@ def _tick_updates(self):
updater.run('check', background=True)
if self.resume_until:
self._check_resumed(now)
+ if updater and updater.current.get('packaging') == 'app' and sys.platform == 'darwin':
+ # A new app has to run for a while before the old one beside it is deleted.
+ self.settle_at = self.settle_at or now + SETTLE_SECONDS
+ if now >= self.settle_at:
+ self.settle_at = float('inf')
+ from . import mac_app_update
+ app = mac_app_update.current_app()
+ if app is not None:
+ mac_app_update.settle(app)
if self.bridge and now - self.bridge_polled >= 1:
self.bridge_polled = now
from .launcher_bridge import take_request
@@ -1496,10 +1513,13 @@ def update_view(self):
row = dict(self.updater.state) if self.updater else dict(status='development')
# Only display fields from the update manifest, never access tokens.
view = {k: row[k] for k in ('status', 'error', 'progress', 'candidate') if k in row}
- from .launcher_update import error_text, release_page
+ from .launcher_update import error_text, manual_install, release_page
if row.get('error_code'):
view['error'] = error_text(row, self.language.startswith('zh'))
view['release_page'] = release_page(self.updater.current) if self.updater else ''
+ if self.updater and self.updater.install_failed and view.get('candidate'):
+ # The new app did not go in and the old one is back: the reminder says so.
+ view['status'], view['error'] = 'error', 'The update could not be installed.'
key = self.update_key()
view.update(engine=self.engine_update_pending(), current=__version__,
current_release=public_details(self.updater.current) if self.updater else self.release_details,
@@ -1507,7 +1527,7 @@ def update_view(self):
installed=self.installed_version(), phase=self.update_phase(), key=key,
channel=self.updater.current.get('channel') if self.updater else None,
track=self.updater.current.get('track', 'stable') if self.updater else 'stable',
- manual=bool(self.updater and self.updater.current.get('packaging') in ('onedir', 'app')),
+ manual=bool(self.updater and manual_install(self.updater.current, self.updater)),
check_requested=self.pending_check is not None or row.get('status') == 'checking',
check_text=self.t(*ACTION_COPY['checking_update']))
# Versions between the one in use and the update, so skipped notes are not lost.
@@ -2012,14 +2032,14 @@ def lan_password_state(self):
"""(supported, set) for the optional LAN password; unsupported without the access module."""
engine = (self.controller.selection or {}).get('engine')
try:
- from . import lan_password
+ lan_password = _lan_password()
return True, bool(engine) and bool(lan_password.is_set(engine))
except (ImportError, OSError):
return False, False
def lan_password_min(self):
try:
- from . import lan_password
+ lan_password = _lan_password()
return int(getattr(lan_password, 'MIN_LENGTH', 6))
except (ImportError, OSError, TypeError, ValueError):
return 6
@@ -2029,7 +2049,7 @@ def change_lan_password(self, text=None):
engine = (self.controller.selection or {}).get('engine')
if not engine:
return
- from . import lan_password
+ lan_password = _lan_password()
if text is None:
lan_password.remove(engine)
else:
diff --git a/freevideo_engine/launcher_update.py b/freevideo_engine/launcher_update.py
index 8f684d44..9c8c8bc2 100644
--- a/freevideo_engine/launcher_update.py
+++ b/freevideo_engine/launcher_update.py
@@ -57,6 +57,7 @@ def source_order(language=None):
WEB = 'https://github.com/' + REPOSITORY
RELEASE_PAGE = WEB + '/releases/latest'
MAX_EXE_BYTES = 512 * 2**20
+MAX_DMG_BYTES = 2**30
MAC_CHANNEL = 'macos-preview'
# Linux has no release before combined releases, so no platform tag to fall back on.
LINUX_CHANNEL = 'linux-preview'
@@ -167,6 +168,16 @@ def build_track(value):
return value.get('track', 'stable')
+def manual_install(current, client=None):
+ """Whether the user installs the update themselves from the release page."""
+ if current.get('packaging') == 'app':
+ if client is not None and client.install_failed:
+ return True
+ from . import mac_app_update
+ return not mac_app_update.replaceable()
+ return current.get('packaging') == 'onedir'
+
+
def release_page(current):
if build_track(current) == 'nightly':
return 'https://github.com/' + REPOSITORY + '/releases/tag/' + NIGHTLY_TAG
@@ -220,7 +231,7 @@ def manifest(value):
result = build_identity(value)
asset = value.get('asset', {})
if (not isinstance(asset, dict) or type(asset.get('id')) is not int or asset['id'] <= 0
- or type(asset.get('bytes')) is not int or not 2 <= asset['bytes'] <= MAX_EXE_BYTES
+ or type(asset.get('bytes')) is not int or not 2 <= asset['bytes'] <= (MAX_DMG_BYTES if result['channel'] == MAC_CHANNEL else MAX_EXE_BYTES)
or not re.fullmatch(r'[0-9a-f]{64}', str(asset.get('sha256', '')))):
raise ValueError('Invalid launcher update asset')
result['asset'] = {k: asset[k] for k in ('id', 'bytes', 'sha256')}
@@ -672,11 +683,19 @@ def launch_download(candidate, root, *, token='', popen=None):
overlay = source_update.prepared(candidate, root)
if overlay is not None:
return source_update.launch(candidate, root, overlay, token=token, popen=popen)
- if build_target(candidate) == 'macos-arm64':
- raise ValueError('Mac update activation is not available in this development build')
path = executable_path(root, candidate)
if not verified(path, candidate['asset']):
raise DownloadedLauncherUnavailable('Downloaded launcher is missing or changed; download again')
+ if build_target(candidate) == 'macos-arm64':
+ from . import mac_app_update
+ staged = Path(path.parent) / 'staged.txt'
+ try:
+ location = Path(staged.read_text(encoding='utf-8').strip())
+ process = mac_app_update.activate(location, mac_app_update.current_app(), path.parent, popen=popen or subprocess.Popen)
+ except (OSError, ValueError, AttributeError):
+ raise DownloadedLauncherUnavailable('The prepared app is missing; download again')
+ save(Path(root) / 'updates' / 'active.json', dict(approved=True, candidate=candidate))
+ return process
from .linux_bundle import child_environment
env = dict(child_environment(), FREEVIDEO_LAUNCHER_HOME=plain(resolved(root)))
if token:
@@ -778,6 +797,16 @@ def __init__(self, current, root, token=None, *, language=None):
self.cancelled = threading.Event()
self.retry = None
self.failures = 0
+ # The swap of an earlier run put the old app back: say so once, and
+ # leave the release page as the way to update this time.
+ self.install_failed = False
+ if self.current.get('packaging') == 'app':
+ marker = io_path(self.root) / 'updates' / 'mac-update-failed.json'
+ try:
+ self.install_failed = marker.is_file()
+ marker.unlink(missing_ok=True)
+ except OSError:
+ pass
@property
def busy(self):
@@ -806,7 +835,7 @@ def run(self, operation, candidate=None, *, background=False, _retry=False):
return
if operation not in ('check', 'download'):
raise ValueError('Unknown update operation')
- if operation == 'download' and self.current.get('packaging') in ('onedir', 'app'):
+ if operation == 'download' and manual_install(self.current):
kind = 'application' if self.current.get('packaging') == 'app' else 'folder'
raise ValueError('Download and extract the updated %s ZIP from the release page' % kind)
self.cancelled.clear()
@@ -844,9 +873,19 @@ def noticed(value):
found = check(self.current, self.token, language=self.language)
if found and newer(found, candidate):
selected = found
+ def prepare_app(dmg):
+ from . import mac_app_update
+ app = mac_app_update.current_app()
+ if app is None:
+ raise ValueError('Not running from an app bundle')
+ staged = mac_app_update.prepare(dmg, app, dmg.parent)
+ (dmg.parent / 'staged.txt').write_text(str(staged), encoding='utf-8')
def progress(value):
self.state = dict(status='downloading', candidate=selected, progress=value)
- download(selected, self.root, self.token, progress=progress, cancel=self.cancelled, language=self.language)
+ target = download(selected, self.root, self.token, progress=progress, cancel=self.cancelled, language=self.language)
+ # An incremental overlay comes back instead of the DMG; only the DMG is mounted.
+ if self.current.get('packaging') == 'app' and Path(target) == executable_path(self.root, selected):
+ prepare_app(target)
self.state = dict(status='ready', candidate=selected)
self.failures = 0
except Exception as error:
diff --git a/freevideo_engine/launcher_update_ui.py b/freevideo_engine/launcher_update_ui.py
index f08bebf2..890f8614 100644
--- a/freevideo_engine/launcher_update_ui.py
+++ b/freevideo_engine/launcher_update_ui.py
@@ -3,7 +3,7 @@
import webbrowser
from . import branding
-from .launcher_update import error_text, release_page, launch_download, DownloadedLauncherUnavailable
+from .launcher_update import error_text, manual_install, release_page, launch_download, DownloadedLauncherUnavailable
class UpdateUI:
@@ -68,7 +68,7 @@ def accept(self):
self.closed = True
app.close()
elif row['status'] == 'available' or (row['status'] in ('error', 'cancelled') and row.get('candidate')):
- if getattr(self.client, 'current', {}).get('packaging') == 'onedir':
+ if manual_install(getattr(self.client, 'current', {}), self.client):
webbrowser.open(release_page(self.client.current))
else:
self.client.run('download', row['candidate'])
diff --git a/freevideo_engine/mac_app_update.py b/freevideo_engine/mac_app_update.py
new file mode 100644
index 00000000..baf8f790
--- /dev/null
+++ b/freevideo_engine/mac_app_update.py
@@ -0,0 +1,157 @@
+"""Replace the running FreeVideo.app with the one inside a downloaded DMG.
+
+The DMG is checked and copied next to the running app while the old version
+keeps working. Only the final swap waits for the old process to exit, and it
+puts the old app back whenever any step of it fails."""
+import os
+from pathlib import Path
+import plistlib
+import shutil
+import subprocess
+import sys
+import tempfile
+import time
+
+
+MARK = '.FreeVideo-update-'
+BACKUP = '.FreeVideo-backup-'
+MARGIN_BYTES = 256 * 2**20
+HDIUTIL, DITTO, CODESIGN, XATTR = '/usr/bin/hdiutil', '/usr/bin/ditto', '/usr/bin/codesign', '/usr/bin/xattr'
+
+# Plain sh, so the swap does not depend on anything inside the app it replaces.
+SWAP_SCRIPT = r'''#!/bin/sh
+pid="$1"; app="$2"; staged="$3"; backup="$4"; failure="$5"; inject="$6"
+opener="${FREEVIDEO_OPEN:-/usr/bin/open}"
+note() { printf '{"reason": "%s", "at": %s}\n' "$1" "$(date +%s)" > "$failure"; }
+waited=0
+while kill -0 "$pid" 2>/dev/null; do
+ waited=$((waited + 1))
+ if [ "$waited" -gt 600 ]; then note "old app did not exit"; rm -rf "$staged"; exit 1; fi
+ sleep 0.2
+done
+restore() {
+ rm -rf "$app"
+ if [ -d "$backup" ]; then /bin/mv "$backup" "$app"; fi
+ rm -rf "$staged"
+ "$opener" "$app"
+}
+[ "$inject" = "before_backup" ] && { note "injected failure"; rm -rf "$staged"; "$opener" "$app"; exit 1; }
+if ! /bin/mv "$app" "$backup"; then note "could not set the old app aside"; rm -rf "$staged"; "$opener" "$app"; exit 1; fi
+if [ "$inject" = "after_backup" ] || ! /bin/mv "$staged" "$app"; then note "could not put the new app in place"; restore; exit 1; fi
+if [ "$inject" = "after_swap" ] || ! "$opener" "$app"; then
+ note "the new app did not open"; /bin/mv "$app" "$staged" && restore; exit 1
+fi
+# The old app stays until the new launcher has run well; it removes it (settle).
+'''
+
+
+class MacUpdateUnavailable(ValueError):
+ """The app cannot be replaced from here; the release page stays the way to update."""
+
+
+def current_app(executable=None):
+ """The .app bundle the process runs from, or None outside one."""
+ try:
+ path = Path(executable or sys.executable).resolve()
+ except OSError:
+ return None
+ for parent in path.parents:
+ if parent.suffix == '.app' and (parent / 'Contents/MacOS').is_dir():
+ return parent
+ return None
+
+
+def replaceable(app=None):
+ """Whether this app sits where the launcher may swap it for a new one."""
+ app = app or current_app()
+ if app is None or 'AppTranslocation' in str(app) or sys.platform != 'darwin' and not os.environ.get('FREEVIDEO_MAC_UPDATE_TEST'):
+ return False
+ return os.access(app.parent, os.W_OK | os.X_OK) and os.access(app, os.W_OK)
+
+
+def _run(command, run, timeout=1800):
+ return run(command, check=True, capture_output=True, timeout=timeout)
+
+
+def _plist(app):
+ with (app / 'Contents/Info.plist').open('rb') as stream:
+ return plistlib.load(stream)
+
+
+def _size(path):
+ return sum(f.stat().st_size for f in Path(path).rglob('*') if f.is_file() and not f.is_symlink())
+
+
+def clear_stale(app):
+ for entry in app.parent.glob(MARK + '*.app'):
+ shutil.rmtree(entry, ignore_errors=True)
+
+
+def settle(app):
+ """Remove the old versions kept beside a running app; called once it has run well."""
+ for entry in app.parent.glob(BACKUP + '*.app'):
+ shutil.rmtree(entry, ignore_errors=True)
+
+
+def prepare(dmg, app, directory, *, run=subprocess.run):
+ """Copy and check the app inside dmg next to `app`; returns the staged path.
+
+ Nothing here touches the running app. Every failure raises and leaves the
+ old version as it was."""
+ if not replaceable(app):
+ raise MacUpdateUnavailable('This app cannot replace itself where it is installed')
+ clear_stale(app)
+ staged = app.parent / (MARK + '%d-%d.app' % (os.getpid(), int(time.time())))
+ mount = Path(tempfile.mkdtemp(prefix='freevideo-update-mount-'))
+ attached = False
+ try:
+ _run([HDIUTIL, 'attach', '-readonly', '-nobrowse', '-noautoopen', '-mountpoint', str(mount), str(dmg)], run)
+ attached = True
+ apps = sorted(p for p in mount.iterdir() if p.suffix == '.app')
+ if len(apps) != 1 or apps[0].name != app.name:
+ raise ValueError('The downloaded image does not contain %s' % app.name)
+ source = apps[0]
+ free = shutil.disk_usage(app.parent).free
+ if free < _size(source) + MARGIN_BYTES:
+ raise OSError(28, 'Not enough free space next to the app')
+ _run([DITTO, str(source), str(staged)], run)
+ finally:
+ if attached:
+ try:
+ _run([HDIUTIL, 'detach', str(mount)], run, 120)
+ except (subprocess.SubprocessError, OSError):
+ try:
+ _run([HDIUTIL, 'detach', '-force', str(mount)], run, 120)
+ except (subprocess.SubprocessError, OSError):
+ pass
+ shutil.rmtree(mount, ignore_errors=True)
+ try:
+ old, new = _plist(app), _plist(staged)
+ if new.get('CFBundleIdentifier') != old.get('CFBundleIdentifier'):
+ raise ValueError('The downloaded app is a different application')
+ if not (staged / 'Contents/MacOS' / new.get('CFBundleExecutable', '')).is_file():
+ raise ValueError('The downloaded app has no executable')
+ if sys.platform == 'darwin':
+ _run([XATTR, '-dr', 'com.apple.quarantine', str(staged)], run, 120)
+ _run([CODESIGN, '--verify', '--deep', '--strict', str(staged)], run, 300)
+ except BaseException:
+ shutil.rmtree(staged, ignore_errors=True)
+ raise
+ return staged
+
+
+def activate(staged, app, directory, *, popen=subprocess.Popen, inject=''):
+ """Start the swap. It waits for this process to exit, then replaces the app."""
+ staged = Path(staged)
+ if not (staged / 'Contents/Info.plist').is_file():
+ raise FileNotFoundError('The prepared app is missing')
+ directory = Path(directory)
+ script = directory / 'swap-app.sh'
+ script.write_text(SWAP_SCRIPT, encoding='utf-8')
+ script.chmod(0o755)
+ backup = app.parent / (BACKUP + '%d.app' % int(time.time()))
+ failure = directory.parent / 'mac-update-failed.json'
+ failure.unlink(missing_ok=True)
+ with (directory.parent / 'mac-update.log').open('ab') as log:
+ return popen(['/bin/sh', str(script), str(os.getpid()), str(app), str(staged), str(backup), str(failure), inject],
+ stdin=subprocess.DEVNULL, stdout=log, stderr=log, close_fds=True, start_new_session=True)
diff --git a/freevideo_engine/media_encoding.py b/freevideo_engine/media_encoding.py
index 5014737a..4544a681 100644
--- a/freevideo_engine/media_encoding.py
+++ b/freevideo_engine/media_encoding.py
@@ -182,7 +182,9 @@ def prepare(media, canvas, directory):
pixels.flush()
ref.update(pixels=str(target), frames=count, normalized_fps=24)
used = count / 24.
- soundtrack, longer_audio, _ = _audio(path, directory / ('reference-%02d-audio.npy' % index), limit / 24.)
+ # The clip's own sound is a reference too, unless the request turned it off.
+ soundtrack, longer_audio, _ = (None, False, None) if row.get('sound') is False else \
+ _audio(path, directory / ('reference-%02d-audio.npy' % index), limit / 24.)
longer = longer or longer_audio
if soundtrack:
ref['audio'] = soundtrack
diff --git a/freevideo_engine/media_request.py b/freevideo_engine/media_request.py
index 256e3ffa..be8d0ec2 100644
--- a/freevideo_engine/media_request.py
+++ b/freevideo_engine/media_request.py
@@ -55,7 +55,7 @@ def file_info(name):
raise ValueError('Invalid ' + field + ' entry')
if field != 'references' and 'early_steps' in entry:
raise ValueError('early_steps is only allowed on video references')
- allowed = {'path', 'kind', 'early_steps'} if field == 'references' else {'path', 'strength', 'alpha'}
+ allowed = {'path', 'kind', 'early_steps', 'sound'} if field == 'references' else {'path', 'strength', 'alpha'}
if set(entry) - allowed:
raise ValueError('Unknown ' + field + ' fields')
row = file_info(entry.get('path'))
@@ -66,6 +66,10 @@ def file_info(name):
if 'early_steps' in entry:
validate_early_steps(entry)
row['early_steps'] = entry['early_steps']
+ if 'sound' in entry:
+ validate_sound(entry)
+ if not entry['sound']:
+ row['sound'] = False
else:
if Path(row['path']).suffix.lower() != '.safetensors':
raise ValueError('LoRA requires safetensors; pickle adapters are not loaded')
@@ -103,6 +107,13 @@ def validate_early_steps(row):
raise ValueError('Video reference early_steps must be an integer >= 0')
+def validate_sound(row):
+ if row.get('kind') != 'video':
+ raise ValueError('sound is only allowed on video references')
+ if type(row['sound']) is not bool:
+ raise ValueError('Video reference sound must be true or false')
+
+
def reference_stages(media):
"""Active references at each boundary; zero-step inputs never reach an encoder."""
refs = media.get('references', [])
diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py
index 46078073..44965c3c 100644
--- a/freevideo_engine/policy.py
+++ b/freevideo_engine/policy.py
@@ -191,6 +191,9 @@
# Above this the resident copies displace nothing useful, so it bounds the
# VRAM-side arithmetic before host RAM narrows it further.
RESIDENT_VRAM_CEILING = 44
+# Extra card memory the whole stack needs beyond the table's activations: the
+# 1280x704 run peaked 0.5 GiB above the plan, and one more GiB stays free.
+WHOLE_STACK_MARGIN = int(1.5 * GiB)
# A streamed transformer uses one device transfer slot without prefetch. The
# read-ahead slot adds one prepared block (BLOCK_BYTES) and lets host staging
@@ -542,7 +545,9 @@ def __init__(self, hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_g
ram_total_bytes=hardware.ram_total, ram_capacity_bytes=ram_total,
ram_available_bytes=free_ram, ram_reserve_bytes=int(reserve_ram * GiB),
ram_budget_bytes=free_ram-int(reserve_ram * GiB), ram_minimum_bytes=ram_minimum_bytes,
- cgroup_ram_limit_bytes=hardware.cgroup_ram_limit)
+ cgroup_ram_limit_bytes=hardware.cgroup_ram_limit,
+ ram_physical_available_bytes=hardware.ram_physical_available,
+ ram_commit_available_bytes=hardware.ram_commit_available)
failed = [name for name in ('gpu', 'ram')
if self.details[name+'_budget_bytes'] < self.details[name+'_minimum_bytes']]
self.details['insufficient'] = failed
@@ -584,6 +589,9 @@ def __init__(self, hardware, gpu_total, ram_total, free_gpu, free_ram, reserve_g
self.details[name+'_total_bytes']/GiB))
if self.details[name+'_shortfall_bytes']:
lines.append('%s shortfall: %.2f GiB.' % (label, self.details[name+'_shortfall_bytes']/GiB))
+ if hardware.ram_commit_available is not None and hardware.ram_physical_available is not None:
+ lines.append('Windows reports %.2f GiB of free physical RAM and %.2f GiB of commit headroom.' %
+ (hardware.ram_physical_available / GiB, hardware.ram_commit_available / GiB))
lines.append('Available memory includes validated idle-cache credit, if present. Resolution, frames and steps were not reduced.')
super().__init__('\n'.join(lines))
@@ -1366,11 +1374,18 @@ def held(group):
# The second transfer slot in place of one resident block.
prefetch, resident = True, resident - 1
pinned_like = None
+ whole_stack = False
if not small:
# One measured requirement for the group in use, so the block count is
# continuous across the band edges rather than restarting at each.
- resident = max(0, min(RESIDENT_VRAM_CEILING,
- int((gpu_budget - activation_bytes(head, effective_tokens)) / block_bytes)))
+ card_blocks = int((gpu_budget - activation_bytes(head, effective_tokens)) / block_bytes)
+ resident = max(0, min(RESIDENT_VRAM_CEILING, card_blocks))
+ # The card holds every block with the activations to spare: keeping them
+ # all there costs no host copy and no locked pages, and the sampling
+ # latents come out the same (measured at 1280x704, 96 GB).
+ # Windows keeps its own margin: the display can take card memory mid-run.
+ whole_stack = (hardware.system != 'Windows' and
+ gpu_budget - activation_bytes(head, effective_tokens) - WHOLE_STACK_MARGIN >= 50 * block_bytes)
# The pinned subset below keeps the size sixteen heads give it: a
# wider group's activations come out of the same budget, and a lower
# count here would only lock more host memory, which no run measured.
@@ -1502,6 +1517,8 @@ def held(group):
ram_wants = max(0, 50 - max(0, int(host_room // block_bytes)))
if not small:
resident = min(resident, max(RESIDENT_TARGET, ram_wants))
+ if whole_stack:
+ resident, pin = 50, 0.
# Windows reads non-streamed offloaded blocks into private memory (pread;
# a section view would be charged to commit in full). The RAM guard counts
# it, it cannot be reclaimed, and pins planned after that load-all pass find
diff --git a/freevideo_engine/prompt_vlm/rules.py b/freevideo_engine/prompt_vlm/rules.py
index e0efbf3a..f0dfb48f 100644
--- a/freevideo_engine/prompt_vlm/rules.py
+++ b/freevideo_engine/prompt_vlm/rules.py
@@ -616,6 +616,51 @@ def restore(words):
return parts
+SOUND_SECTIONS = re.compile(r'\n\n(?:overall_soundscape|non_diegetic_music):')
+SENTENCE_END = '.。!!??'
+
+
+def own_words(texts):
+ """Where each split part's own events stand in its text: [start, end] in UTF-16 units, or None.
+
+ The continuation sentence, the shared description, subject definitions, LoRA trigger words and sound
+ sections are put there by code and are the same in every part; the page shows and edits only the rest.
+ The shared description is what the parts start with in common, up to a sentence end before a space.
+ """
+ starts, ends = [], []
+ for text in texts:
+ found = SOUND_SECTIONS.search(text)
+ ends.append(found.start() if found else len(text))
+ at = text.find(CONTINUE + ' ')
+ starts.append(at + len(CONTINUE) + 1 if at >= 0 else None)
+ # The opening part has no continuation sentence; what stands before it elsewhere comes first there too.
+ lead = next((texts[i][:at - len(CONTINUE) - 1] for i, at in enumerate(starts) if at is not None), None)
+ if lead is not None and starts and starts[0] is None and texts[0].startswith(lead):
+ starts[0] = len(lead)
+ known = [texts[i][at:ends[i]] for i, at in enumerate(starts) if at is not None and at <= ends[i]]
+ if len(known) < 2:
+ return [None] * len(texts)
+ common = known[0]
+ for words in known[1:]:
+ common = common[:next((i for i, (a, b) in enumerate(zip(common, words)) if a != b), min(len(common), len(words)))]
+ shared = next((k for k in range(len(common), 19, -1) if common[k - 1] in SENTENCE_END
+ and all(len(words) == k or words[k] == ' ' for words in known)), None)
+ if shared is None:
+ return [None] * len(texts)
+ def units(text, index):
+ return len(text[:index].encode('utf-16-le')) // 2
+ spans = []
+ for text, at, end in zip(texts, starts, ends):
+ if at is None or at > end or not text[at:end].startswith(common[:shared]):
+ spans.append(None)
+ continue
+ begin = at + shared + 1
+ # A part with nothing of its own shows the shared description it repeats.
+ spans.append([units(text, at), units(text, end)] if begin >= end
+ else [units(text, begin), units(text, end)])
+ return spans
+
+
def validate_parts(parts, value):
"""Worker output shape for a split job: one non-empty text per part."""
if (not isinstance(parts, list) or len(parts) != len(value['seconds'])
diff --git a/freevideo_engine/proxy_identity.py b/freevideo_engine/proxy_identity.py
index a6eb9f13..03e3a048 100644
--- a/freevideo_engine/proxy_identity.py
+++ b/freevideo_engine/proxy_identity.py
@@ -11,6 +11,7 @@
HEADER = 'X-FreeVideo-Proxy-Hop'
PEER = 'X-FreeVideo-Proxy-Peer'
ORIGIN = 'X-FreeVideo-Proxy-Origin'
+DEVICE = 'X-FreeVideo-Proxy-Device'
@lru_cache(maxsize=1)
@@ -32,3 +33,19 @@ def trusted(request):
secret = app.get(app_key(), '') if app is not None else ''
offered = request.headers.get(HEADER, '')
return bool(secret and offered) and hmac.compare_digest(secret.encode(), offered.encode())
+
+
+def csrf(secret, device):
+ """A browser proof bound to one authenticated device and this proxy hop."""
+ return hmac.new(secret.encode(), ('lan-csrf:' + device).encode(), 'sha256').hexdigest()
+
+
+def device(request):
+ return request.headers.get(DEVICE, '') if trusted(request) else ''
+
+
+def check_csrf(request):
+ identity = device(request)
+ offered = request.headers.get('X-FreeVideo-CSRF', '')
+ secret = request.app.get(app_key(), '')
+ return bool(identity and offered) and hmac.compare_digest(offered.encode(), csrf(secret, identity).encode())
diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json
index 9afb1d0a..e2011fa9 100644
--- a/freevideo_engine/release_notes.json
+++ b/freevideo_engine/release_notes.json
@@ -1,41 +1,68 @@
{
"schema": 1,
- "product_version": "0.4.15",
+ "product_version": "0.4.16",
"en": {
- "summary": "Image generation keeps a history of every picture and needs less memory on both small and large graphics cards, error messages everywhere now come with a button to report the problem, and Windows updates download faster in mainland China.",
+ "summary": "Any part of a long video can be generated again as a new video, phones on the same network can follow chats and generations behind an optional password, and the Mac app now updates itself.",
"changes": [
- "Image generation: every image generated on this machine is kept in a history, newest first, and can be placed on the canvas, used as a reference, deleted, or opened in its folder. Blank canvases offer Standard and HD sizes.",
- "Image generation: on 6-8 GB graphics cards, decoding uses less graphics memory and runs faster. On cards that can hold the whole model, the weights load straight to the card and no second copy stays in system memory, which cuts memory use after loading from about 15 GB to about 2 GB.",
- "Reporting problems: wherever an error or failure message appears, including generation, editing and export, models and environment, image generation, the agent connection, the library, prompt enhancement, sharing, updates, and the launcher, a Report a problem button next to it opens the feedback form with the context filled in.",
- "AI Autopilot: when a chat cannot be deleted, it now says why and offers Stop and delete; other failures are reported with a button to submit the problem.",
- "White models: only the cat ready-made scene remains, and one click starts it. A white model that was not made now says why and what to do next, and the page shows rendering progress. A question about progress is answered instead of being started as a new request.",
- "Character sheets: in the back view, the head now turns away together with the body.",
- "Mac: long videos can now be generated in parts.",
- "Launcher and updates: Windows updates download faster in mainland China. When GitHub is slow, the launcher can fetch the update from a domestic mirror, and every file from the mirror is checked against the release signature before use. The install confirmation page now fits narrow windows: the sidebar collapses to icons and the buttons stay visible.",
- "Workflows: workflows saved by older versions run again. Options added in later versions that are empty or invalid in an old file are set back to their defaults.",
- "Video generation: the High quality level generates faster. On 8 GB graphics cards, 1920x1088 videos that were refused for falling just short of graphics memory now run. Changing the early steps setting of a video reference now produces a new result instead of reusing the earlier one.",
- "LoRA: a LoRA trained for Wan 2.x now says so when it is selected for the int8 model, instead of only saying it cannot be used.",
- "Linux: on computers with less memory, generating again right after a generation no longer runs out of memory near the end of sampling. On 6 GB graphics cards, Max no longer takes hours to finish."
+ "Long videos: the part list shows each part's length and what it follows, and marks failed parts. Any finished part can be generated again as a new video that keeps the parts before it, and the original video stays as it was. In the joined video, click a part's length to play two seconds either side of its seam. The list shows only each part's own events, and editing a part keeps the rest of its description unchanged.",
+ "Phone access: phones on the same network can open FreeVideo from the phone icon and QR code in the top bar, chat with AI Autopilot and follow generations. Phone access can be switched on or off and protected with a password that phones sign in with.",
+ "Mac: an update is downloaded in the background, and Restart & update replaces the app in place and opens the new version. Models and settings are kept, and if any step fails the previous version is restored.",
+ "Reference videos: a reference video can now be used without its own sound.",
+ "Editor: parts cut off when the engine stopped now show \"Interrupted\". A problem reported from a failure now includes the step that failed.",
+ "Linux: on graphics cards that can hold the whole video model, video generation no longer keeps a copy of the weights in system memory, which saves about 20 GB.",
+ "Reporting problems: memory readings in feedback record free physical memory and commit headroom separately."
]
},
"zh": {
- "summary": "生图工具会保存所有生成过的图片,小显存和大显存的显卡生图都更省内存;所有错误提示旁都有“提交问题”按钮;Windows 版在中国大陆更新更快。",
+ "summary": "长视频的任何一段都可以重新生成为一条新视频;同一网络下的手机可以查看对话和生成进度,可设密码;Mac 版可以自动更新。",
"changes": [
- "生图:本机生成过的图片都保存在历史记录里,按时间从新到旧排列,可放到画布、用作参考图、删除或打开所在文件夹。空白画布可选“标准 / 高清”尺寸。",
- "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存放得下整个模型的显卡,权重直接加载到显卡,不再在内存里留一份副本,加载后内存占用从约 15 GB 降到约 2 GB。",
- "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点一下带上情况打开问题反馈。",
- "AI 全自动:对话删不掉时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。",
- "白模:现成场景只保留“橘猫跳上书架”,点一下直接开始。白模没做成时会说明原因和下一步,渲染中页面显示进度。问白模进度时会回答进度,不再当成新要求。",
- "人物设定图:背面图里头和身体一起转向背后。",
- "Mac:支持分段生成长视频。",
- "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收成图标,按钮始终可见。",
- "工作流:旧版本存的工作流可以直接运行。旧文件里后加的选项为空或不合法时,改回默认值。",
- "视频生成:“精细”档生成更快。8 GB 显存的显卡上,以前因显存差一点而被拒绝的 1920×1088 视频,现在可以生成。改动视频参考的前期步数设置后,会生成新的结果,不再沿用之前的结果。",
- "LoRA:为 Wan 2.x 训练的 LoRA 选进 int8 模型时,会说明这是给 Wan 2.x 训练的 LoRA,不再只说无法使用。",
- "Linux:内存较小的电脑接着再生成一次时,不再在采样快结束时内存不足。6 GB 显存的显卡选“极致”时,不再要等好几个小时。"
+ "长视频:分段列表显示每段的时长和接在哪一段之后,并标出失败的段。任何已完成的段都可以从这里重新生成一条新视频,前面的段保持不变,原视频也不受影响。整条视频生成后,点某段的时长可播放它接缝前后各 2 秒。列表只显示每段自己的动作,编辑某一段时其余描述保持不变。",
+ "手机访问:同一网络下的手机可以通过顶栏的手机图标和二维码打开 FreeVideo,和 AI 全自动对话、查看生成进度。手机访问可以开关,也可以设置密码,手机登录后才能使用。",
+ "Mac:新版在后台下载,点“重启并更新”后原位替换应用并打开新版。模型和设置都会保留,任何一步失败都会还原旧版。",
+ "参考视频:参考视频可以只用画面、不使用它自己的声音。",
+ "剪辑视图:引擎中断而没做完的片段显示“上次中断”。从失败提示提交的问题会写明是哪一步出错。",
+ "Linux:显存放得下整个视频模型的显卡,生成视频时不再在系统内存里留一份权重副本,约省 20 GB 内存。",
+ "提交问题:反馈里的内存读数分开记录物理可用内存和提交余量。"
]
},
"history": [
+ {
+ "product_version": "0.4.15",
+ "en": {
+ "summary": "Image generation keeps a history of every picture and needs less memory on both small and large graphics cards, error messages everywhere now come with a button to report the problem, and Windows updates download faster in mainland China.",
+ "changes": [
+ "Image generation: every image generated on this machine is kept in a history, newest first, and can be placed on the canvas, used as a reference, deleted, or opened in its folder. Blank canvases offer Standard and HD sizes.",
+ "Image generation: on 6-8 GB graphics cards, decoding uses less graphics memory and runs faster. On cards that can hold the whole model, the weights load straight to the card and no second copy stays in system memory, which cuts memory use after loading from about 15 GB to about 2 GB.",
+ "Reporting problems: wherever an error or failure message appears, including generation, editing and export, models and environment, image generation, the agent connection, the library, prompt enhancement, sharing, updates, and the launcher, a Report a problem button next to it opens the feedback form with the context filled in.",
+ "AI Autopilot: when a chat cannot be deleted, it now says why and offers Stop and delete; other failures are reported with a button to submit the problem.",
+ "White models: only the cat ready-made scene remains, and one click starts it. A white model that was not made now says why and what to do next, and the page shows rendering progress. A question about progress is answered instead of being started as a new request.",
+ "Character sheets: in the back view, the head now turns away together with the body.",
+ "Mac: long videos can now be generated in parts.",
+ "Launcher and updates: Windows updates download faster in mainland China. When GitHub is slow, the launcher can fetch the update from a domestic mirror, and every file from the mirror is checked against the release signature before use. The install confirmation page now fits narrow windows: the sidebar collapses to icons and the buttons stay visible.",
+ "Workflows: workflows saved by older versions run again. Options added in later versions that are empty or invalid in an old file are set back to their defaults.",
+ "Video generation: the High quality level generates faster. On 8 GB graphics cards, 1920x1088 videos that were refused for falling just short of graphics memory now run. Changing the early steps setting of a video reference now produces a new result instead of reusing the earlier one.",
+ "LoRA: a LoRA trained for Wan 2.x now says so when it is selected for the int8 model, instead of only saying it cannot be used.",
+ "Linux: on computers with less memory, generating again right after a generation no longer runs out of memory near the end of sampling. On 6 GB graphics cards, Max no longer takes hours to finish."
+ ]
+ },
+ "zh": {
+ "summary": "生图工具会保存所有生成过的图片,小显存和大显存的显卡生图都更省内存;所有错误提示旁都有“提交问题”按钮;Windows 版在中国大陆更新更快。",
+ "changes": [
+ "生图:本机生成过的图片都保存在历史记录里,按时间从新到旧排列,可放到画布、用作参考图、删除或打开所在文件夹。空白画布可选“标准 / 高清”尺寸。",
+ "生图:6–8 GB 显存的显卡解码时占用的显存更少,速度更快。显存放得下整个模型的显卡,权重直接加载到显卡,不再在内存里留一份副本,加载后内存占用从约 15 GB 降到约 2 GB。",
+ "提交问题:生成、剪辑与导出、环境与模型、生图、智能体连接、作品库、提示词优化、分享、更新和启动器里,出现错误或失败提示的地方旁边都有“提交问题”按钮,点一下带上情况打开问题反馈。",
+ "AI 全自动:对话删不掉时会说明原因,并提供“停止并删除”;其他失败也会提示,并带“提交问题”按钮。",
+ "白模:现成场景只保留“橘猫跳上书架”,点一下直接开始。白模没做成时会说明原因和下一步,渲染中页面显示进度。问白模进度时会回答进度,不再当成新要求。",
+ "人物设定图:背面图里头和身体一起转向背后。",
+ "Mac:支持分段生成长视频。",
+ "启动器与更新:Windows 版在中国大陆更新更快。GitHub 慢时,启动器可以从国内镜像下载更新,镜像上的每个文件都先按发布签名校验。“确认安装”页适配窄窗口:侧栏收成图标,按钮始终可见。",
+ "工作流:旧版本存的工作流可以直接运行。旧文件里后加的选项为空或不合法时,改回默认值。",
+ "视频生成:“精细”档生成更快。8 GB 显存的显卡上,以前因显存差一点而被拒绝的 1920×1088 视频,现在可以生成。改动视频参考的前期步数设置后,会生成新的结果,不再沿用之前的结果。",
+ "LoRA:为 Wan 2.x 训练的 LoRA 选进 int8 模型时,会说明这是给 Wan 2.x 训练的 LoRA,不再只说无法使用。",
+ "Linux:内存较小的电脑接着再生成一次时,不再在采样快结束时内存不足。6 GB 显存的显卡选“极致”时,不再要等好几个小时。"
+ ]
+ }
+ },
{
"product_version": "0.4.14",
"en": {
@@ -256,37 +283,6 @@
"界面细节:中文界面的生成节点高级参数显示中文名;对话里的生成卡只显示一个进度;设置“下载”卡更整齐;剪辑视图导出按钮保持主色,文字、字幕工具改用线性图标;窄窗口下预览、生成按钮、作品库标题和提示的排布更合理,顶栏仍保留“反馈”入口;长提示词不再被“@”按钮压住;作品卡片时间跟随界面语言;结果视频加载失败时不再显示无法使用的操作。"
]
}
- },
- {
- "product_version": "0.4.7",
- "en": {
- "summary": "Your creations can now be selected and deleted several at a time with a short undo window, every view gets a Feedback button, and the launcher shows a QR code for opening FreeVideo from a phone. The description box in the editing view grows with its text, wording across the app is shorter and plainer, and long videos keep a steadier loudness from part to part.",
- "changes": [
- "Your creations: select several creations (click, Shift-click, a whole day, or all) and delete them in one step, with the size shown before you confirm and ten seconds to undo. Creations that are still in use are kept.",
- "Feedback: every view has a Feedback button beside Settings. Write one sentence, optionally attach a screenshot of the page and a diagnostic summary (no prompts or images), and the GitHub suggestion page opens with the text filled in.",
- "Launcher: local network access shows a QR code to open FreeVideo from a phone and offers an optional password for other devices; freevideo status prints the same QR code on Linux.",
- "Your own ComfyUI: the FreeVideo toolbar appears only on FreeVideo workflows, no longer opens over your other workflows or adds nodes to them, and FreeVideo can be opened from the Extensions menu. Opening ComfyUI from the launcher keeps your other plugins; if a plugin stops FreeVideo from loading, the launcher starts again with FreeVideo only and says so.",
- "Long videos: steadier loudness from part to part.",
- "Editing view: the description box grows with its text and can be expanded into a larger window; errors and shortcut labels use plainer wording.",
- "Studio: shorter hints. Generate no longer fails with \"missing node\" when the creation node was muted or bypassed in the workflow; the node is turned back on and a line says so. Two-pass sampling runs only with 8 first-pass steps; other step counts use a single pass, and the progress line, the ComfyUI node and Agent results say so.",
- "Installation and launcher: install progress is steadier, with the total following the GPU acceleration build and the final start-up; sublines no longer show file names, and the terminal stays closed unless something goes wrong. Installation and update wording is plainer.",
- "Progress and error messages: shorter, plainer wording."
- ]
- },
- "zh": {
- "summary": "作品库可以多选、一次删除,并在短时间内撤销;每个视图新增“反馈”按钮;启动器显示二维码,手机扫码即可打开。剪辑视图的描述框随内容长高,各处说法更短、更直白,长视频各段的声音响度也更稳定。",
- "changes": [
- "作品库:可多选作品(点选、Shift 连选、选择某一天或全选),确认前显示大小,一次删除,10 秒内可撤销;仍在使用的作品会保留。",
- "反馈:每个视图的“设置”左边新增“反馈”按钮。写一句话,可选附上这一页的截图和诊断信息(不含提示词和画面),点一下即打开已填好文字的 GitHub 反馈页。",
- "启动器:局域网访问显示二维码,手机扫码即可打开 FreeVideo,并可为其他设备设置密码;Linux 的 freevideo status 也会显示二维码。",
- "自己的 ComfyUI:工具条只在 FreeVideo 工作流上出现,不再盖住其他工作流,也不往里面加节点;Extensions 菜单里可以打开 FreeVideo。从启动器打开时保留已装的其他插件;如有插件让 FreeVideo 载入失败,会自动只载入 FreeVideo 再启动一次,并提示一句。",
- "长视频:各段声音响度更稳定。",
- "剪辑视图:描述框随内容长高,可放大成大窗口编辑;出错和快捷键的说法更直白。",
- "创作面板:提示更简短。创作节点在工作流里被静音或绕过时,点生成不再报“节点缺失”,会自动重新打开并提示一句。只有首轮为 8 步时才会使用二次采样,其他步数只生成一轮,进度、ComfyUI 节点和 Agent 接口的结果会说明。",
- "安装和启动器:安装进度更平稳,总进度会跟随显卡加速编译和最后的启动步骤,副行不再显示文件名,终端只在出错时需要打开;安装和更新的说法更直白。",
- "进度与出错提示:说法更短、更直白。"
- ]
- }
}
]
}
diff --git a/freevideo_engine/sampling_assets.py b/freevideo_engine/sampling_assets.py
index 1ef47a86..0f96f9ad 100644
--- a/freevideo_engine/sampling_assets.py
+++ b/freevideo_engine/sampling_assets.py
@@ -176,7 +176,8 @@ def engine_task(media, base=None):
"""The task the engine selects after encoding these references.
A reference video counts as audio when it has an audio stream, exactly as
- the encoder decides; tables therefore match before the request starts.
+ the encoder decides, unless the request turned that video's sound off;
+ tables therefore match before the request starts.
"""
from .media_request import task_for
task = task_for(media)
@@ -186,7 +187,7 @@ def engine_task(media, base=None):
for ref in media.get('references', []):
visual |= ref['kind'] in ('image', 'video')
audio |= ref['kind'] == 'audio'
- if ref['kind'] == 'video' and not audio:
+ if ref['kind'] == 'video' and not audio and ref.get('sound') is not False:
path = Path(ref['path'])
if base is not None and not path.is_absolute():
path = Path(base) / path
diff --git a/web/autopilot.js b/web/autopilot.js
index c64145b3..ee9302f6 100644
--- a/web/autopilot.js
+++ b/web/autopilot.js
@@ -1,3 +1,4 @@
+import {createLanAccess} from './lan_access.js';
// The Agent's own page, beside the studio and the editing view: the card that connects one, then the conversation with it.
import { api } from '../../scripts/api.js';
import { agentConnectCard, connectionLabel } from './agent_connect.js';
@@ -35,7 +36,8 @@ export function openAutopilotView(node, {studio = null, initialPrompt = '', init
go.type = halt.type = 'button'; go.append(ring, name, more, percent, eta); now.append(go, halt);
halt.title = t('Stop all', '全部停止'); halt.setAttribute('aria-label', halt.title);
const line = el('i', 'fv-autopilot-line');
- tools.append(now, lamp, feedbackButton(t), settings);
+ const phoneAccess = createLanAccess(t, api);
+ tools.append(now, lamp, phoneAccess.element, feedbackButton(t), settings);
// The view chosen next takes over once this page has closed; Escape goes back to what is underneath.
let next = () => studio?.open ? viewChanged('studio', node) : viewChanged('nodes', node);
const leave = action => { next = action; closeDialog(dialog); };
@@ -77,6 +79,7 @@ export function openAutopilotView(node, {studio = null, initialPrompt = '', init
if (arrived) chat.input.focus();
lamp.hidden = !ready; lamp.dataset.color = label.color; lampText.textContent = lamp.title = label.text; lamp.setAttribute('aria-label', label.text); lamp.disabled = connected || !last.any;
const job = talking ? last.current : null;
+ phoneAccess.observe(job);
now.hidden = !job; line.dataset.on = String(!!job);
halt.disabled = !!last.stopping;
halt.setAttribute('aria-busy', String(!!last.stopping));
@@ -100,7 +103,7 @@ export function openAutopilotView(node, {studio = null, initialPrompt = '', init
dialog.append(header, body);
dialog.addEventListener('cancel', event => { event.preventDefault(); closeDialog(dialog); });
dialog.onclose = () => {
- card.stop(); chat.stop(); assets.dispose(); dialog.remove();
+ phoneAccess.dispose(); card.stop(); chat.stop(); assets.dispose(); dialog.remove();
if (current === dialog) current = null;
next();
};
diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js
index 78e5a80e..e9e2168d 100644
--- a/web/autopilot_chat.js
+++ b/web/autopilot_chat.js
@@ -2,6 +2,8 @@
// cards at the place they happened. Reads and sends use the page session; the agent's token never enters the page.
import {agentGet, agentPost, retryAgentPost} from './agent_session.js';
import {SCENE_TEXT, sceneList, startScene, watchScene} from './whitebox_scenes.js';
+import {chatData, chatDelay, chatReadRequest, emptyChat, chatMerge, chatEntries, canSend, newId, SEND_ATTEMPTS, WAIT_FOR_AGENT, sendRefused, sendError} from './chat_data.js';
+export {emptyChat, chatMerge, chatEntries, canSend} from './chat_data.js';
import {isolateDialogKeys, routeDialogKey} from './dialog_keys.js';
import {closeDialog} from './motion.js';
import { estimatedFraction, remainingAbove, remainingShown, createDiagnosticReportControl } from './generation_progress.js';
@@ -40,33 +42,7 @@ const WHITEBOX = {
play: ['Play', '播放'], pause: ['Pause', '暂停'], seconds: ['s', '秒'],
failure: ['Could not start: ', '无法开始生成:'],
};
-const SEND_ATTEMPTS = 3, WAIT_FOR_AGENT = ['not_connected', 'not_listening'];
const GENERIC_FAILURE_ZH = '没有完成';
-// A listening/connection race is queued; other 4xx refusals never repeat automatically.
-const sendRefused = (error, response) => error?.code === 'session_full' || error?.retryable === false
- || response?.status >= 400 && response.status < 500 && !WAIT_FOR_AGENT.includes(error?.code);
-async function sendError(response) {
- if (!response) return null;
- try { return (await (response.clone?.() || response).json())?.error || null; }
- catch { return null; }
-}
-// Only an explicit field-validation refusal is safe to resend without the optional field.
-// An older backend that ignores it already succeeds; unrelated errors keep the existing retry policy.
-async function previewUnsupported(response) {
- if (![400, 422].includes(response?.status)) return false;
- try {
- const value = await response.clone().json(), error = value.error;
- // The current server redacts unknown field names. The only boolean true we add is this flag.
- const detail = error?.details?.[0];
- if (error?.code === 'invalid_request' && error.details?.length === 1 && detail?.path === '/'
- && detail.allowed === 'absent (unknown field)' && detail.got === 'True') return true;
- if (Array.isArray(value.detail) && value.detail.some(item => item.type === 'extra_forbidden'
- && item.loc?.includes(WHITEBOX_PREVIEW.field))) return true;
- const named = error?.field === WHITEBOX_PREVIEW.field || error?.message?.includes(WHITEBOX_PREVIEW.field);
- return !!named && (['unknown_field', 'unsupported_field', 'unsupported_parameter', 'extra_forbidden'].includes(error?.code)
- || /unknown|unsupported|unexpected|unrecognized|not supported|extra field/i.test(error?.message || ''));
- } catch { return false; }
-}
// Sends and failure cards use the same public reason, including the engine's Chinese mapping.
function failureReason(value, t) {
const error = value.error, reason = error?.error_zh || value.error_zh;
@@ -163,33 +139,6 @@ export function jobParts(job) {
return Array.from({length: block.total}, (_, i) => i < (block.done || 0) ? 1 : i === block.index - 1 ? inside : 0);
}
-export const emptyChat = () => ({session: undefined, items: new Map(), jobs: {}, failures: {}, confirms: {}, last: 0, read: 0, agent: {}, current: null, active: 0});
-// One answer from the server folded into what the page already has: entries add up by their number, jobs are
-// replaced by id (a card keeps its last state after the server stops listing its job), and so are the answers to its questions.
-export function chatMerge(state, data) {
- const items = new Map(state.items);
- for (const item of data.items || []) if (['message', 'job', 'confirm', 'production', 'cast'].includes(item.type) && Number.isFinite(item.seq)) items.set(item.seq, item);
- const read = Math.max(state.read, Number(data.read_seq) || 0, ...(data.items || []).filter(item => item.read === true).map(item => item.seq));
- return {session: data.session ?? null, items, jobs: {...state.jobs, ...(data.jobs || {})}, failures: data.failures || {}, confirms: {...state.confirms, ...(data.confirms || {})}, read,
- last: Math.max(state.last, Number(data.last_seq) || 0, ...items.keys()), agent: data.agent || {},
- current: data.current || null, active: Number(data.active) || 0};
-}
-// The entries in order. A user's sentence counts as taken once the agent has read it or has said or done anything after it.
-export function chatEntries(state) {
- const list = [...state.items.values()].sort((a, b) => a.seq - b.seq);
- let answered = 0;
- for (const item of list) if (item.type !== 'message' || item.role !== 'user') answered = item.seq;
- return list.map(item => item.type === 'message' && item.role === 'user'
- ? {...item, taken: item.read === true || item.seq <= state.read || item.seq < answered} : item);
-}
-// Whether a sentence sent now reaches anyone: the agent is waiting for one, or is at work and will come back for it.
-export const canSend = state => state.agent?.connected === true && (state.agent.listening !== false || state.active > 0);
-
-function newId() {
- const bytes = crypto.getRandomValues(new Uint8Array(12));
- return 'm-' + [...bytes].map(b => b.toString(36).padStart(2, '0')).join('').slice(0, 20);
-}
-
// 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');
@@ -267,6 +216,7 @@ function imageViewer(t, src, name, opener, failed) {
// user can see the conversation; the page calls sync() whenever that may have changed. changed(view) follows every
// answer: {known, agent, current, active, any}.
export function agentChat(t, api, {open, edit, shown = () => true, changed = () => {}, interval = 1500, productVersion = () => null, initialPrompt = '', initialWhiteboxPreview = false, initialScene = '', hint = null} = {}) {
+ const dataLayer = chatData(api);
const element = el('section', 'fv-chat'), main = el('div', 'fv-chat-main'), scroller = el('div', 'fv-chat-scroll'), log = el('ol', 'fv-chat-log');
log.setAttribute('role', 'log'); log.setAttribute('aria-label', t('Conversation', '对话'));
const down = press('fv-chat-down', null, () => { scroller.scrollTo({top: scroller.scrollHeight, behavior: 'smooth'}); });
@@ -537,7 +487,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
// A disabled focused button can lose native focus. Keep it on the origin while awaiting the request.
menuOrigin()?.focus({preventScroll: true}); updateMenu();
try {
- const response = await agentPost(api, '/freevideo/agent/chat/delete', stop ? {session, stop: true} : {session});
+ const response = await dataLayer.remove(session, stop);
if (stopped) return;
const data = await response.json().catch(() => ({}));
if (stopped) return;
@@ -587,7 +537,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
if (creating || !active() || conversations.supported !== true) return;
creating = true; newError.hidden = newIssue.hidden = true; renderConversations(); render();
try {
- const response = await agentPost(api, '/freevideo/agent/chat/new', {});
+ const response = await dataLayer.create();
if (stopped) return;
if (response.ok) {
const data = await response.json();
@@ -608,7 +558,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
if (!force && performance.now() - listAt < listInterval) return;
listBusy = true; listAt = performance.now();
try {
- const response = await agentGet(api, '/freevideo/agent/chat/list', {signal: closed.signal});
+ const response = await dataLayer.list({signal: closed.signal});
if (stopped) return;
// The body is awaited first: a conversation chosen meanwhile must stay the current one.
const data = response.ok ? await response.json() : null;
@@ -693,7 +643,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
if (!ids.length) return;
say(t('Stopping…', '正在停止…'));
ids.forEach(id => stopping.add(id)); render(); report();
- const results = await Promise.all(ids.map(id => retryAgentPost(api, '/freevideo/agent/activity/cancel', {id})));
+ const results = await Promise.all(ids.map(id => dataLayer.cancel(id)));
results.forEach((response, index) => { if (!response?.ok) stopping.delete(ids[index]); });
if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '没能停止,请再点一次。'), true);
else say('');
@@ -1005,7 +955,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
item.querySelectorAll('button').forEach(button => { button.disabled = true; });
confirming.add(key); confirmErrors.delete(key); render();
try {
- const response = await agentPost(api, '/freevideo/agent/chat/confirm', {id: entry.id, approve, session});
+ const response = await dataLayer.confirm({id: entry.id, approve, session});
if (!response.ok) throw await requestError(response);
answers.set(key, (await response.json()).state || (approve ? 'approved' : 'declined'));
} catch (error) { confirmErrors.set(key, t('Could not start shooting: ', '没有开始拍摄:') + reason(error)); }
@@ -1110,8 +1060,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
if (!row) { away = null; return; }
try {
// Past the last message: only that conversation's jobs come back.
- const query = new URLSearchParams({session: row.id, after: String(Number.MAX_SAFE_INTEGER)});
- const response = await agentGet(api, `/freevideo/agent/chat?${query}`, {signal: closed.signal});
+ const response = await dataLayer.read({session: row.id, after: Number.MAX_SAFE_INTEGER, signal: closed.signal});
if (!response.ok) { away = null; return; }
const data = await response.json(), jobs = Object.values(data.jobs || {}).filter(job => WORKING.includes(job?.status));
const job = data.jobs?.[data.current] || jobs[0];
@@ -1158,18 +1107,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
const whiteboxes = [...state.items.values()].filter(card => card.type === 'confirm' && card.whitebox && whiteboxState(card) === 'asked');
entry.sending = true; entry.retried = !!original; entry.error = null; entry.automatic = false; render(true); report();
try {
- const body = {id: entry.id, text: entry.text};
- if (conversations.supported === true && entry.session) body.session = entry.session;
- if (entry.whiteboxPreview) body[WHITEBOX_PREVIEW.field] = true;
- entry.attempts++;
- const response = await retryAgentPost(api, '/freevideo/agent/chat', body, {retry: async response => {
- if (!stopped && !entry.acknowledged && entry.whiteboxPreview && entry.attempts < SEND_ATTEMPTS && await previewUnsupported(response)) {
- entry.whiteboxPreview = false; delete body[WHITEBOX_PREVIEW.field];
- entry.attempts++; return body;
- }
- if (stopped || entry.acknowledged || sendRefused(await sendError(response), response) || entry.attempts >= SEND_ATTEMPTS) return null;
- entry.attempts++; return body;
- }});
+ const response = await dataLayer.sendMessage(entry, {sessionSupported: conversations.supported === true, stopped: () => stopped, previewField: WHITEBOX_PREVIEW.field});
if (entry.acknowledged) return true;
if (response?.ok) {
const value = await response.json().catch(() => ({}));
@@ -1249,11 +1187,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
const readSelection = selection, requested = selectedSession;
let again = false;
try {
- const after = state.session && (!requested || requested === state.session) ? state.last : 0;
- const query = new URLSearchParams();
- if (requested) query.set('session', requested);
- if (after) query.set('after', String(after));
- const response = await agentGet(api, '/freevideo/agent/chat' + (query.size ? `?${query}` : ''), {signal: closed.signal});
+ const {after} = chatReadRequest(state, requested);
+ const response = await dataLayer.read({session: requested, after, signal: closed.signal});
if (stopped) return;
if (readSelection !== selection) again = true;
else if (response.ok) {
@@ -1296,7 +1231,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = ()
known = true;
// The next read is booked before the page hears of this one, so the page's sync() does not start another.
const idle = state.agent.connected !== true && !state.active;
- timer = setTimeout(poll, (idle ? Math.max(interval, 3000) : interval) * Math.min(4, 1 + failures));
+ timer = setTimeout(poll, chatDelay({idle, interval, failures}));
render(); report();
if (sendingAllowed()) for (const entry of [...pending]) {
if (entry.session !== state.session || !entry.automatic || entry.sending || entry.acknowledged) continue;
diff --git a/web/capability_view.js b/web/capability_view.js
index 78139bb1..d0c01f11 100644
--- a/web/capability_view.js
+++ b/web/capability_view.js
@@ -54,7 +54,7 @@ export function capabilityContext(node, state) {
const source = assets[item.assetIndex] || {}, row = {kind: item.kind};
if (item.kind !== 'audio') { row.width = source.width; row.height = source.height; }
if (item.kind !== 'image') row.seconds = source.seconds;
- if (item.kind === 'video' && positive(source.audio_seconds)) row.audio_seconds = source.audio_seconds;
+ if (item.kind === 'video' && item.sound !== false && positive(source.audio_seconds)) row.audio_seconds = source.audio_seconds;
return row;
});
const audio = refs.some((r, i) => r.kind === 'audio' || positive(rows[i].audio_seconds));
diff --git a/web/chat_data.js b/web/chat_data.js
new file mode 100644
index 00000000..fe7fec75
--- /dev/null
+++ b/web/chat_data.js
@@ -0,0 +1,119 @@
+import {agentGet, agentPost, retryAgentPost} from './agent_session.js';
+export const SEND_ATTEMPTS = 3, WAIT_FOR_AGENT = ['not_connected', 'not_listening'];
+// A listening/connection race is queued; other 4xx refusals never repeat automatically.
+export const sendRefused = (error, response) => error?.code === 'session_full' || error?.retryable === false
+ || response?.status >= 400 && response.status < 500 && !WAIT_FOR_AGENT.includes(error?.code);
+export async function sendError(response) {
+ if (!response) return null;
+ try { return (await (response.clone?.() || response).json())?.error || null; }
+ catch { return null; }
+}
+export const emptyChat = () => ({session: undefined, items: new Map(), jobs: {}, failures: {}, confirms: {}, last: 0, read: 0, agent: {}, current: null, active: 0});
+// One answer from the server folded into what the page already has: entries add up by their number, jobs are
+// replaced by id (a card keeps its last state after the server stops listing its job), and so are the answers to its questions.
+export function chatMerge(state, data) {
+ const items = new Map(state.items);
+ for (const item of data.items || []) if (['message', 'job', 'confirm', 'production', 'cast'].includes(item.type) && Number.isFinite(item.seq)) items.set(item.seq, item);
+ const read = Math.max(state.read, Number(data.read_seq) || 0, ...(data.items || []).filter(item => item.read === true).map(item => item.seq));
+ return {session: data.session ?? null, items, jobs: {...state.jobs, ...(data.jobs || {})}, failures: data.failures || {}, confirms: {...state.confirms, ...(data.confirms || {})}, read,
+ last: Math.max(state.last, Number(data.last_seq) || 0, ...items.keys()), agent: data.agent || {},
+ current: data.current || null, active: Number(data.active) || 0};
+}
+// The entries in order. A user's sentence counts as taken once the agent has read it or has said or done anything after it.
+export function chatEntries(state) {
+ const list = [...state.items.values()].sort((a, b) => a.seq - b.seq);
+ let answered = 0;
+ for (const item of list) if (item.type !== 'message' || item.role !== 'user') answered = item.seq;
+ return list.map(item => item.type === 'message' && item.role === 'user'
+ ? {...item, taken: item.read === true || item.seq <= state.read || item.seq < answered} : item);
+}
+// Whether a sentence sent now reaches anyone: the agent is waiting for one, or is at work and will come back for it.
+export const canSend = state => state.agent?.connected === true && (state.agent.listening !== false || state.active > 0);
+
+export function newId() {
+ const bytes = crypto.getRandomValues(new Uint8Array(12));
+ return 'm-' + [...bytes].map(b => b.toString(36).padStart(2, '0')).join('').slice(0, 20);
+}
+
+
+
+// The cursor is scoped to its conversation; switching always starts at zero.
+export function chatReadRequest(state, session) {
+ return {session, after: state.session && (!session || session === state.session) ? state.last : 0};
+}
+// A send's id and attempt budget survive an uncertain response. Presentation can
+// acknowledge the same entry from a concurrent poll before this request returns.
+// Only an explicit field-validation refusal is safe to resend without the optional field.
+// An older backend that ignores it already succeeds; unrelated errors keep the existing retry policy.
+export async function previewUnsupported(response, field) {
+ if (!field || ![400, 422].includes(response?.status)) return false;
+ try {
+ const value = await response.clone().json(), error = value.error;
+ // The current server redacts unknown field names. The only boolean true we add is this flag.
+ const detail = error?.details?.[0];
+ if (error?.code === 'invalid_request' && error.details?.length === 1 && detail?.path === '/'
+ && detail.allowed === 'absent (unknown field)' && detail.got === 'True') return true;
+ if (Array.isArray(value.detail) && value.detail.some(item => item.type === 'extra_forbidden'
+ && item.loc?.includes(field))) return true;
+ const named = error?.field === field || error?.message?.includes(field);
+ return !!named && (['unknown_field', 'unsupported_field', 'unsupported_parameter', 'extra_forbidden'].includes(error?.code)
+ || /unknown|unsupported|unexpected|unrecognized|not supported|extra field/i.test(error?.message || ''));
+ } catch { return false; }
+}
+export async function sendChatMessage(api, entry, {sessionSupported = true, stopped = () => false, previewField = null} = {}) {
+ const body = {id: entry.id, text: entry.text};
+ if (sessionSupported && entry.session) body.session = entry.session;
+ if (entry.whiteboxPreview && previewField) body[previewField] = true;
+ entry.attempts = (entry.attempts || 0) + 1;
+ return retryAgentPost(api, '/freevideo/agent/chat', body, {retry: async response => {
+ if (!stopped() && !entry.acknowledged && entry.whiteboxPreview && entry.attempts < SEND_ATTEMPTS && await previewUnsupported(response, previewField)) {
+ entry.whiteboxPreview = false; delete body[previewField];
+ entry.attempts++; return body;
+ }
+ if (stopped() || entry.acknowledged || sendRefused(await sendError(response), response) || entry.attempts >= SEND_ATTEMPTS) return null;
+ entry.attempts++; return body;
+ }});
+}
+
+// DOM-free transport and state folds shared by desktop and phone. Rendering,
+// drafts and visibility decisions remain with the owning view.
+export function chatData(api) {
+ const read = (path, options) => agentGet(api, path, options);
+ return {
+ read({session, after, signal} = {}) {
+ const query = new URLSearchParams();
+ if (session) query.set('session', session);
+ if (after) query.set('after', String(after));
+ return read('/freevideo/agent/chat' + (query.size ? `?${query}` : ''), {signal});
+ },
+ now: options => read('/freevideo/agent/chat/now', options),
+ list: options => read('/freevideo/agent/chat/list', options),
+ create: () => agentPost(api, '/freevideo/agent/chat/new', {}),
+ remove: (session, stop = false) => agentPost(api, '/freevideo/agent/chat/delete', stop ? {session, stop: true} : {session}),
+ confirm: body => agentPost(api, '/freevideo/agent/chat/confirm', body),
+ cancel: id => retryAgentPost(api, '/freevideo/agent/activity/cancel', {id}),
+ activity: (cursor, options) => read('/freevideo/agent/activity' + (cursor ? '?cursor=' + encodeURIComponent(cursor) : ''), options),
+ sendMessage: (entry, options) => sendChatMessage(api, entry, options),
+ send: (body, options) => retryAgentPost(api, '/freevideo/agent/chat', body, options),
+ video: work => '/freevideo/agent/chat/video?id=' + encodeURIComponent(work),
+ };
+}
+export function chatDelay({hidden = false, phone = false, idle = false, interval = 1500, failures = 0} = {}) {
+ return phone ? (hidden ? 10000 : 2000) : (idle ? Math.max(interval, 3000) : interval) * Math.min(4, 1 + failures);
+}
+// Serial polling, visibility rescheduling and disposal work without a browser.
+export function chatPoller(read, {delay = () => 2000, error = () => {}, clock = globalThis} = {}) {
+ let timer = null, running = null, disposed = false, again = false;
+ function poll() {
+ clock.clearTimeout(timer); timer = null;
+ if (disposed) return Promise.resolve();
+ if (running) { again = true; return running; }
+ running = Promise.resolve().then(read).catch(error).finally(() => {
+ running = null;
+ if (!disposed) { const ms = again ? 0 : delay(); again = false; timer = clock.setTimeout(poll, ms); }
+ });
+ return running;
+ }
+ return {poll, reschedule() { clock.clearTimeout(timer); if (!disposed && !running) timer = clock.setTimeout(poll, delay()); },
+ dispose() { disposed = true; clock.clearTimeout(timer); }};
+}
diff --git a/web/editor.css b/web/editor.css
index 1cb06064..fa1681ae 100644
--- a/web/editor.css
+++ b/web/editor.css
@@ -209,6 +209,7 @@
/* editor_header_fit.js: when the project name would not stay readable beside a centred view switch, the logo takes its phone size, then leaves, then the switch takes a second row. */
.fv-studio.fv-editor .fv-header[data-fit~=compact] .fv-wordmark{width:90px}
.fv-studio.fv-editor .fv-header[data-fit~=bare] .fv-wordmark{display:none}
+.fv-studio.fv-editor .fv-header[data-fit~=tight] .fv-lan{display:none}
.fv-studio.fv-editor .fv-header[data-fit~=wrap]{height:auto;min-height:98px;grid-template-columns:minmax(0,1fr) auto;padding-block:10px}
.fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center}
.fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-header-actions{grid-column:2;grid-row:1}
diff --git a/web/editor.js b/web/editor.js
index 58359916..e89323c9 100644
--- a/web/editor.js
+++ b/web/editor.js
@@ -1,3 +1,4 @@
+import {createLanAccess} from './lan_access.js';
import {createEditorText} from './editor_text.js';
import {isNetworkTypeError, connectionLostText} from './browser_extension.js';
import {createToolbarMore} from './editor_toolbar_more.js';
@@ -196,9 +197,11 @@ export function openEditor({api, app, from = null, panel = {}, components: given
if (options && !disposed) await actions.export(options);
} catch (value) { error(value, 'export'); }
}, 'fv-primary fv-ed-export-main');
- const exportCancel = button('', () => store.cancelExport(exportStatus.id).catch(error)); exportCancel.hidden = true; exportCancel.append(icon('m4 4 8 8M12 4l-8 8')); exportCancel.setAttribute('aria-label', t('Cancel', '取消'));
+ const exportCancel = button('', () => store.cancelExport(exportStatus.id).catch(value => error(value, null, undefined, 'cancel-export'))); exportCancel.hidden = true; exportCancel.append(icon('m4 4 8 8M12 4l-8 8')); exportCancel.setAttribute('aria-label', t('Cancel', '取消'));
exportBox.append(exportHint, exportButton, exportCancel); brand.append(wordmark(), title);
- headerActions.append(exportBox, feedbackButton(t), button(t('Settings', '设置'), openSetup, 'fv-quiet'));
+ const phoneAccess = createLanAccess(t, api);
+ listeners.push(phoneAccess.dispose);
+ headerActions.append(exportBox, phoneAccess.element, feedbackButton(t), button(t('Settings', '设置'), openSetup, 'fv-quiet'));
top.append(brand, navigation, headerActions);
const headerFit = fitEditorHeader(top, brand, title, navigation, headerActions);
const menu = el('div', 'fv-ed-name-menu'); menu.hidden = true; menu.setAttribute('role', 'menu');
@@ -255,6 +258,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
}
}
function receiveProgress(message) {
+ phoneAccess.observe(message);
if (disposed || !message.chain?.id || !message.chain.block) return false;
const job = jobStatuses.get(message.chain.id);
if (!job || String(job.node ?? panel.node?.id ?? panel.node ?? message.node) !== String(message.node)) return false;
@@ -272,17 +276,21 @@ export function openEditor({api, app, from = null, panel = {}, components: given
return true;
}
const content = record => JSON.stringify({...record, id: null, revision: 0});
+ function failureKind(site, value) {
+ const code = value?.reason || value?.code || value?.name;
+ return site + ':' + (String(code || '').replace(/[^A-Za-z0-9_.-]/g, '').slice(0, 40) || 'unknown');
+ }
// `where` marks a failure: the notice then carries the "Report a problem" button and stays longer to be reached.
- function notify(text, where = null) {
+ function notify(text, where = null, kind = null) {
if (disposed) return;
if (!text) { clearTimeout(noticeTimer); notice.hidden = true; return; }
notice.textContent = text;
- if (where) notice.append(issueButton(t, () => ({where, message: text})));
+ if (where) notice.append(issueButton(t, () => ({where, message: text, kind})));
notice.hidden = false; clearTimeout(noticeTimer); noticeTimer = setTimeout(() => { notice.hidden = true; }, where ? 20000 : 9000);
}
const EDITOR_ISSUE = ['Editor', '编辑器'], EXPORT_ISSUE = ['Export', '导出'];
// `step` names what was being done, so a failure there says which step went wrong; `out` is where the line is said (the bar's own line for a reference picture).
- function error(value, step = null, out = notify) {
+ function error(value, step = null, out = notify, site = step || 'editor') {
const code = value?.reason || value?.code || value?.message;
if (['missing_glyphs', 'MissingGlyphs', 'source_missing'].includes(code)) {
out(code === 'source_missing' ? t('A source file is missing. Import it again from the library.', '有素材的原文件找不到了,请在素材库重新导入。')
@@ -307,7 +315,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
return {ok: false, reason: code};
}
const text = step === 'generate' ? editorErrorText(value, t) : step === 'export' ? exportErrorText(value, t) : t('Could not complete. Try again.', '没有完成,请重试');
- out(text, step === 'export' ? EXPORT_ISSUE : EDITOR_ISSUE); return {ok: false, reason: code, say: text};
+ 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 => {
if (disposed) return;
@@ -480,7 +488,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
await queue.flush();
}
if (disposed || closing || epoch !== projectEpoch) return false;
- if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE); return false; }
+ if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE, failureKind('save', {reason: queue.failure?.reason})); return false; }
return !!state.record.id;
}
function updateTop() {
@@ -543,7 +551,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
} catch (value) {
if (!(value instanceof model.EditError)) throw value;
state.settle(record => model.fail(record, block.id, 'result_mismatch', job.id));
- error(value);
+ error(value, null, undefined, 'fill');
}
} else if (block.state === 'failed') {
const cause = block.reason || block.error;
@@ -553,6 +561,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given
state.settle(record => pinStoppedJob(record, [block.id], job.id));
} else if (block.state === 'running' && clip.pending !== 'running') state.settle(record => model.settle(record, [block.id], 'running'));
}
+ // The status word of a part the engine dropped is read from the job, which arrives after the record is drawn.
+ const dropped = job => (job?.blocks || []).filter(block => block.state === 'interrupted').length;
+ if (dropped(job) !== dropped(previous)) tracks?.refresh?.();
genbar?.update(barJob(job)); updateTop(); updateTransport();
const active = ['queued', 'running', 'cancelling'].includes(job.state);
if (completed.size || (!active && previous?.state !== job.state) || (job.output && job.output !== previous?.output))
@@ -632,7 +643,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
forgetPointer();
})); if (disposed) return;
// A restored edit remains usable offline with its existing placeholders.
- await completeSources().catch(error); if (disposed) return; recoverJobs();
+ await completeSources().catch(value => error(value, null, undefined, 'sources')); if (disposed) return; recoverJobs();
state.restoreView(remembered);
// Also when generating is confirmed only after the edit has opened.
offerInitial = () => { if (epoch === projectEpoch && !state.selection && state.record.tracks.every(track => !track.clips.length)) selectInitial(); };
@@ -662,7 +673,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
}
} catch (value) {
const reason = deleteEditText(t)[value?.reason];
- if (reason) notify(reason); else error(value);
+ if (reason) notify(reason); else error(value, null, undefined, 'open-load');
} finally { switching = false; element.inert = false; updateTop(); }
}
const emptyEdit = () => discardEmptyEdit(state.record, initial(), {busy: jobActive(), hasMedia: store.uploading});
@@ -693,7 +704,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
const words = deleteEditText(t);
if (jobActive()) throw {reason: 'generation_active'};
await queue.flush(); releaseStamp();
- if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE); return; }
+ if (queue.dirty) { notify(saveErrorText(queue.failure, t), EDITOR_ISSUE, failureKind('save', {reason: queue.failure?.reason})); return; }
// Load the replacement before deleting, so a read failure keeps the edit open.
const rows = (await store.projects()).filter(row => row.id !== state.record.id);
// Most recently used: the order the edits were opened, then the last save.
@@ -704,7 +715,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
await discardProject();
await replaceProject(record); menu.hidden = true; title.setAttribute('aria-expanded', 'false'); title.focus();
} catch (value) {
- const words = deleteEditText(t); notify(words[value?.reason] || words.delete_failed, EDITOR_ISSUE);
+ const words = deleteEditText(t); notify(words[value?.reason] || words.delete_failed, EDITOR_ISSUE, failureKind('delete', {reason: value?.reason}));
} finally { switching = false; element.inert = false; updateTop(); }
}
function confirmDelete() {
@@ -731,7 +742,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
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); }
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); }
- } catch (value) { error(value); }
+ } catch (value) { error(value, null, undefined, 'projects'); }
name.focus(); name.select();
}
const actions = {
@@ -771,12 +782,15 @@ export function openEditor({api, app, from = null, panel = {}, components: given
return {ok: true};
} catch (valueError) {
if (!(valueError instanceof model.EditError)) throw valueError;
- valueError.displayFrame = value.at ?? model.duration(state.record); error(valueError);
+ valueError.displayFrame = value.at ?? model.duration(state.record); error(valueError, null, undefined, 'insert');
return {ok: false, reason: valueError.code};
}
},
reasonText: value => editorErrorText(value, t),
failureText: id => failures.text(id),
+ // A part that was running when the engine went away, as opposed to one the user stopped.
+ interrupted: clip => clip.pending === 'stopped' && !clip.source && !!clip.job
+ && (jobStatuses.get(clip.job)?.blocks || []).some(block => block.state === 'interrupted' && model.attemptClip(state.record, block.id)?.id === clip.id),
selectedFailure() {
const clip = state.selection?.kind === 'clip' && state.record.tracks.flatMap(track => track.clips).find(clip => state.selection.ids.includes(clip.id) && clip.pending === 'failed');
if (clip) return {id: clip.id, text: failures.text(clip.id)};
@@ -1076,7 +1090,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
function exportLost() {
if (disposed) return;
exportStatus = null; exportCancel.hidden = true; updateTop();
- notify(t('Lost the connection to FreeVideo, so the export progress cannot be read. Export again once it is back.', '与 FreeVideo 的连接已断开,无法读取导出进度。恢复后请重新导出。'), EXPORT_ISSUE);
+ notify(t('Lost the connection to FreeVideo, so the export progress cannot be read. Export again once it is back.', '与 FreeVideo 的连接已断开,无法读取导出进度。恢复后请重新导出。'), EXPORT_ISSUE, 'export:lost');
}
function updateExport(value) {
if (!value) return false;
@@ -1086,11 +1100,11 @@ export function openEditor({api, app, from = null, panel = {}, components: given
exportCancel.hidden = !running; updateTop(); exportButton.classList.remove('fv-primary');
if (!running) {
if (value.state === 'failed') {
- if (['source_missing', 'missing_glyphs', 'MissingGlyphs'].includes(value.reason || value.code)) error(value);
+ if (['source_missing', 'missing_glyphs', 'MissingGlyphs'].includes(value.reason || value.code)) error(value, null, undefined, 'export');
else notify(value.reason === 'not_writable' ? t('Export failed: the output folder cannot be written to. Check that the folder can be written to, then retry.', '导出时出错:输出目录不可写。请检查这个文件夹的写入权限后重试。')
: value.reason === 'disk_full' ? t('Export failed: the disk is full. Free some space, then export again.', '导出时出错:磁盘空间不够,空出空间后再导出。')
: value.reason === 'unreadable' ? t('Export failed: a source file cannot be read. Import it again from the library.', '导出时出错:有素材读不出来,请在素材库重新导入。')
- : t('Export failed. Try again.', '导出时出错,请再试一次。'), EXPORT_ISSUE);
+ : t('Export failed. Try again.', '导出时出错,请再试一次。'), EXPORT_ISSUE, failureKind('export', value));
}
if (value.state === 'cancelled') notify(t('Export cancelled. No unfinished file is kept.', '已取消导出,没有留下不完整的文件。'));
window.FreeVideoNotifyTaskDone?.({kind: 'export', id: value.id, ok: value.state === 'done'});
@@ -1126,7 +1140,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
() => selectionActions.run('gaps'), 'fv-ed-close-gap');
const previous = toolButton(t('Previous version', '上一版'), 'M3 6V2M3 6h4M3 6a5 5 0 1 1-1 5M8 5v3l2 2', () => {
const clip = selectedContent().find(clip => clip.versions.length);
- if (clip) try { state.apply(t('Previous version', '上一版'), record => model.restoreVersion(record, clip.id, 0)); } catch (value) { error(value); }
+ if (clip) try { state.apply(t('Previous version', '上一版'), record => model.restoreVersion(record, clip.id, 0)); } catch (value) { error(value, null, undefined, 'restore'); }
}, 'fv-ed-previous');
historyTools.append(toolUndo, toolRedo); editTools.append(split, remove, closeGap, previous);
const pictureTools = createPictureTools({state, sources: store.sources, t, capabilities: () => capability, error});
@@ -1199,7 +1213,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
const next = content(state.record);
if (!suppressSave && !initializing && next !== savedContent && exportStatus?.state === 'done') { exportStatus = null; exportButton.textContent = t('Export', '导出'); }
if (!suppressSave && !initializing && next !== savedContent) { savedContent = next; queue.enqueue(state.record); }
- completeSources().catch(error); recoverJobs();
+ completeSources().catch(value => error(value, null, undefined, 'sources')); recoverJobs();
}
if (event.type === 'selection') { if (state.selection !== initialSelection) initialSelection = null; }
if (event.type === 'record' || event.type === 'selection') { updateTop(); updateTransport(); pictureTools.update(); }
@@ -1319,7 +1333,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
if (!queue.dirty && emptyEdit()) {
try { await discardProject({automatic: true}); }
catch (value) {
- notify(deleteEditText(t)[value?.reason] || deleteEditText(t).delete_failed, EDITOR_ISSUE);
+ notify(deleteEditText(t)[value?.reason] || deleteEditText(t).delete_failed, EDITOR_ISSUE, failureKind('delete', {reason: value?.reason}));
closing = false; element.inert = false; closePromise = null; return false;
}
}
@@ -1369,19 +1383,19 @@ export function openEditor({api, app, from = null, panel = {}, components: given
if (draft?.startsWith('draft:')) key = draft;
else if (id) { record = await store.load(id); if (record) key = id; }
}
- } catch (value) { error(value); }
+ } catch (value) { error(value, null, undefined, 'open-load'); }
if (closing || disposed) return;
try {
await replaceProject(record || state.record, key);
if (from?.work && !state.record.id) queue.enqueue(state.record);
- } catch (value) { error(value); }
+ } catch (value) { error(value, null, undefined, 'open-session'); }
if (closing || disposed) return;
initializing = false; savedContent = content(state.record); player.refresh();
loaded = true; settleGenbar();
updateTop(); updateTransport();
const [trackResult, componentResult] = await componentsReady; if (closing || disposed) return;
// Optional progress components may fail without taking the tracks with them.
- if (componentResult.status === 'rejected') error(componentResult.reason);
+ if (componentResult.status === 'rejected') error(componentResult.reason, null, undefined, 'components');
if (trackResult.status === 'rejected') throw trackResult.reason;
const module = trackResult.value, components = componentResult.status === 'fulfilled' ? componentResult.value : {};
blockStates = components.blocks?.blockStates || null;
@@ -1444,7 +1458,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given
await store.generationReady();
if (!enter() && !closing && !disposed) extendEntry = () => { if (enter(false) || closing || disposed) extendEntry = null; };
}
- })().catch(error).finally(() => {
+ })().catch(value => error(value, null, undefined, 'init')).finally(() => {
// Every exit (including close and partial failure) settles readiness.
// After a completed start the saved baseline is already set; edits made since must stay unsaved.
if (initializing) { initializing = false; savedContent = content(state.record); }
diff --git a/web/editor_header_fit.js b/web/editor_header_fit.js
index 5552eb1e..b4468219 100644
--- a/web/editor_header_fit.js
+++ b/web/editor_header_fit.js
@@ -17,9 +17,9 @@ export function fitEditorHeader(header, brand, title, navigation, actions) {
const own = getComputedStyle(title), chevron = span.nextElementSibling, text = document.createRange(); text.selectNodeContents(span);
const frame = px(own.paddingLeft) + px(own.paddingRight) + px(own.borderLeftWidth) + px(own.borderRightWidth) + (chevron ? chevron.getBoundingClientRect().width + px(own.columnGap) : 0);
const wanted = Math.min(READABLE, text.getBoundingClientRect().width) + frame;
- // The actions' own width: the box itself may already be squeezed below its buttons.
+ // The actions' own width: the box itself, and the buttons in it, may already be squeezed below their content.
const shown = [...actions.children].filter(child => child.offsetWidth > 0), apart = px(getComputedStyle(actions).columnGap);
- const need = Math.max(actions.offsetWidth, shown.reduce((sum, child) => sum + child.getBoundingClientRect().width, 0) + apart * Math.max(0, shown.length - 1));
+ const need = Math.max(actions.offsetWidth, shown.reduce((sum, child) => sum + Math.max(child.getBoundingClientRect().width, child.scrollWidth), 0) + apart * Math.max(0, shown.length - 1));
const fits = markWidth => side >= need && side - markWidth - spacing >= wanted;
const compact = Math.min(COMPACT_MARK, mark.offsetWidth);
// On two rows the name shares the first row with the actions only; the logo shrinks too when that is still tight.
@@ -28,7 +28,11 @@ export function fitEditorHeader(header, brand, title, navigation, actions) {
const feedback = actions.querySelector('.fv-feedback-entry'), spare = feedback ? feedback.offsetWidth + apart : 0;
// From 681 px up the switch has one place in every view, so the logo, then the feedback entry, leave before the switch moves down.
const wide = !matchMedia('(max-width:680px)').matches, bare = room => wide && side >= room && side >= wanted;
- const fit = fits(mark.offsetWidth) ? '' : fits(compact) ? 'compact' : bare(need) ? 'bare' : spare && bare(need - spare) ? 'bare lean'
+ // Before the feedback entry, the phone entry steps aside.
+ const lan = actions.querySelector('.fv-lan'), away = lan ? lan.offsetWidth + apart : 0;
+ // The phone entry also steps aside rather than let the view switch drop to a second row.
+ const fitsWithout = markWidth => side >= need - away && side - markWidth - spacing >= wanted;
+ const fit = fits(mark.offsetWidth) ? '' : fits(compact) ? 'compact' : away && fitsWithout(mark.offsetWidth) ? 'tight' : away && fitsWithout(compact) ? 'compact tight' : bare(need) ? 'bare' : away && bare(need - away) ? 'bare tight' : away && spare && bare(need - away - spare) ? 'bare tight lean' : spare && bare(need - spare) ? 'bare lean'
: row(mark.offsetWidth) >= wanted ? 'wrap' : row(compact) >= wanted || !spare ? 'wrap compact' : 'wrap compact lean';
if (fit) header.dataset.fit = fit;
}
diff --git a/web/editor_notes.js b/web/editor_notes.js
index 46e71322..c8784bd1 100644
--- a/web/editor_notes.js
+++ b/web/editor_notes.js
@@ -18,9 +18,9 @@ export const stripText = clip => clip.note || firstLine(clip.text);
export const describedTrack = track => track.kind === 'video' && track.clips.some(clip => !isText(clip) && stripText(clip));
const agentWrote = record => record.tracks.some(track => track.clips.some(clip => clip.text_by === 'agent'));
// Status words appear only in the hover tip and the panels (Iris 10-11).
-export function blockStatus(clip, t, fraction = 0) {
+export function blockStatus(clip, t, fraction = 0, interrupted = false) {
if (clip.pending === 'running') return t(`Generating ${Math.round(fraction * 100)}%`, `生成中 ${Math.round(fraction * 100)}%`);
- if (clip.pending === 'stopped') return t('Stopped', '已停止');
+ if (clip.pending === 'stopped') return interrupted ? t('Interrupted', '上次中断') : t('Stopped', '已停止');
if (clip.pending === 'failed') return t('Unfinished', '未完成');
if (clip.pending) return t('Not generated', '未生成');
const current = model.takes(clip).find(take => take.index === -1);
@@ -103,7 +103,7 @@ export function createBlockNotes({state, t, actions}) {
function showTip(item) {
const view = item.element.closest('.fv-ed-track-view'), clip = item.clip; if (!view || !item.element.isConnected) return;
const fraction = Number(item.element.style.getPropertyValue('--fv-ed-progress')) || 0;
- const text = [clip.note || clip.text.trim(), blockStatus(clip, t, fraction)].filter(Boolean).join(' · ');
+ const text = [clip.note || clip.text.trim(), blockStatus(clip, t, fraction, !!actions.interrupted?.(clip))].filter(Boolean).join(' · ');
if (!text) return;
if (!tip) { tip = el('div', 'fv-ed-note-tip'); tip.setAttribute('role', 'tooltip'); }
if (tip.parentElement !== view) view.append(tip);
diff --git a/web/editor_tracks.js b/web/editor_tracks.js
index c1d8ef45..937073ce 100644
--- a/web/editor_tracks.js
+++ b/web/editor_tracks.js
@@ -536,7 +536,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions =
const failureText = visual === 'failed' ? actions.failureText?.(clip.id) || actions.reasonText?.(clip.reason) || clip.reason || t('Generation failed', '生成时出错') : '';
if (visual === 'failed') item.status.replaceChildren(icon('warning'), create('span', 'fv-ed-clip-failure-reason', failureText));
else item.status.replaceChildren();
- const status = {planned: t('Not generated yet', '还没生成'), running: t('Generating', '正在生成'),
+ const status = {planned: actions.interrupted?.(clip) ? t('Interrupted', '上次中断') : t('Not generated yet', '还没生成'), running: t('Generating', '正在生成'),
done: t('Complete', '完成'), failed: failureText}[visual];
const seconds = Math.round(clip.frames / model.FPS);
item.element.title = '';
diff --git a/web/freevideo.js b/web/freevideo.js
index 25ba0a31..d8ca0461 100644
--- a/web/freevideo.js
+++ b/web/freevideo.js
@@ -332,15 +332,23 @@ function mediaPanel(node, mount = null) {
preview.controls = true; preview.preload = "metadata"; if (kind === "video") { preview.muted = true; preview.playsInline = true; }
card.append(preview);
}
+ let showLength = () => {};
+ if (kind === "video") {
+ const sound = el("label", undefined, "fv-sound"); const box = el("input"); box.type = "checkbox"; box.checked = row.sound !== false;
+ box.onchange = () => { if (box.checked) delete rows[index].sound; else rows[index].sound = false; showLength(); update(rows); };
+ sound.append(box, el("span", text("Use the video's sound", "使用视频声音"))); card.append(sound);
+ }
if (kind !== "image") {
// References are cut to the generated length (at most 15 s); say so before the run.
+ // A video used without its sound has no audio to cut.
const length = el("div", "", "fv-note"); length.hidden = true; card.append(length);
- preview.onloadedmetadata = () => {
- if (!(preview.duration > 15.05) || !Number.isFinite(preview.duration)) return;
- length.textContent = text(`Audio length: ${preview.duration.toFixed(1)} s; uses up to the first 15 s within the video length.`,
+ showLength = () => {
+ const long = preview.duration > 15.05 && Number.isFinite(preview.duration);
+ length.hidden = !long || row.sound === false || rows[index]?.sound === false;
+ if (long) length.textContent = text(`Audio length: ${preview.duration.toFixed(1)} s; uses up to the first 15 s within the video length.`,
`音频长 ${preview.duration.toFixed(1)} 秒,最多取前 15 秒且不超过视频时长。`);
- length.hidden = false;
};
+ preview.onloadedmetadata = () => showLength();
}
const controls = el("div", undefined, "fv-row");
const move = delta => { const target = index + delta; if (target < 0 || target >= rows.length) return; [rows[index], rows[target]] = [rows[target], rows[index]]; update(rows); };
@@ -357,7 +365,7 @@ function mediaPanel(node, mount = null) {
serialized.serializeValue = () => {
if (node.isUploading) throw new Error(text("Wait for media uploads to finish before running", "请等待素材上传完成后再生成"));
// 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}) => ({file, role, enabled, ...(typeof cast?.id === 'string' && cast.id ? {cast: {id: cast.id}} : {})}))); } catch { return serialized.value; }
+ try { return JSON.stringify(read().map(({file, role, enabled, cast, sound}) => ({file, role, enabled, ...(sound === false && mediaKindOrNull(file) === 'video' ? {sound: false} : {}), ...(typeof cast?.id === 'string' && cast.id ? {cast: {id: cast.id}} : {})}))); } catch { return serialized.value; }
};
async function upload(files) {
if (node.isUploading || !files.length) return;
diff --git a/web/health.js b/web/health.js
index e1bf094d..81add2c2 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 = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js',
+export const MODULES = ['chat_data.js', 'phone_model.js', 'phone.js', 'lan_access.js', 'qrcodegen.js', 'agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', 'entry_showcase.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js',
'editor.js', 'editor_actions.js', 'editor_lifecycle.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_expand.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_header_fit.js',
'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'failure_issue.js', 'feedback.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launch_visit.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'lora_check.js', 'library.js', 'motion.js', 'native_errors.js', 'translate_guard.js', 'output_download.js', 'port_notice.js', 'preview_scene.js',
'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'prompt_terms.js', 'request_assets.js', 'repair_hint.js', 'report_issue.js', 'issue_button.js', 'studio_recent.js',
diff --git a/web/image_editor.css b/web/image_editor.css
index 52e0ffc3..a9369f14 100644
--- a/web/image_editor.css
+++ b/web/image_editor.css
@@ -315,3 +315,4 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)}
border-radius:var(--fv-r-sm);background:color-mix(in srgb,var(--fv-warning) 9%,transparent);color:var(--fv-ink);font-size:var(--fv-micro)}
.fv-panel .fv-shape-note span{flex:1 1 180px}
.fv-panel .fv-shape-note button{min-height:26px;padding:2px 10px}
+.fv-panel .fv-sound{display:flex;align-items:center;gap:6px;margin:0 0 8px;font-size:var(--fv-micro);cursor:pointer}
diff --git a/web/lan_access.css b/web/lan_access.css
new file mode 100644
index 00000000..3732cce4
--- /dev/null
+++ b/web/lan_access.css
@@ -0,0 +1,8 @@
+.fv-lan.fv-lan{position:relative;display:inline-flex;flex:none;font-size:var(--fv-body);color:var(--fv-ink)}.fv-lan.fv-lan [hidden]{display:none!important}.fv-lan.fv-lan button{font:inherit;cursor:pointer;color:inherit;border:0;touch-action:manipulation}.fv-lan.fv-lan button:disabled{opacity:.45;cursor:default}.fv-lan.fv-lan .fv-lan-trigger{position:relative;width:32px;height:32px;min-width:32px;padding:7px;border-radius:6px;background:transparent;color:var(--fv-muted)}.fv-lan-trigger:hover,.fv-lan-trigger[aria-expanded=true]{background:var(--fv-hover)!important;color:var(--fv-ink)!important}.fv-lan.fv-lan svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;display:block}.fv-lan-dot{position:absolute;right:1px;top:1px;width:7px;height:7px;background:var(--fv-accent);border:2px solid var(--fv-bg);box-sizing:content-box;border-radius:50%}.fv-lan-panel{position:fixed;z-index:1000;box-sizing:border-box;width:264px;max-width:calc(100vw - 16px);padding:16px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:var(--fv-r-md);box-shadow:0 12px 36px #0006;text-align:left;max-height:calc(100dvh - 80px);overflow:auto}.fv-lan-heading{display:flex;align-items:center;justify-content:space-between;gap:8px}.fv-lan-heading strong{font-size:calc(var(--fv-body) + 1px);font-weight:var(--fv-semibold)}.fv-lan.fv-lan .fv-lan-switch{height:32px;width:44px;padding:0;position:relative;border-radius:99px;background:transparent}.fv-lan-switch:before{content:'';position:absolute;inset:5px 3px;background:var(--fv-hover);border-radius:99px}.fv-lan-switch:after{content:'';position:absolute;left:6px;top:8px;width:16px;height:16px;border-radius:50%;background:var(--fv-ink)}.fv-lan-switch[aria-checked=true]:before{background:var(--fv-accent)}.fv-lan-switch[aria-checked=true]:after{left:22px}.fv-lan-content{display:flex;flex-direction:column;align-items:stretch}.fv-lan-qr{width:168px;height:168px;border-radius:8px;background:#fff;align-self:center;margin-top:14px;image-rendering:pixelated}.fv-lan-scan{text-align:center;font-size:var(--fv-micro);color:var(--fv-muted);margin:10px 0 8px;line-height:1.5}.fv-lan.fv-lan .fv-lan-copy{height:36px;background:var(--fv-raised);border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);font-weight:var(--fv-semibold)}.fv-lan-password{display:flex;align-items:center;justify-content:space-between;border-top:1px solid var(--fv-border);margin-top:14px;padding-top:10px;color:var(--fv-muted)}.fv-lan.fv-lan .fv-lan-set{color:var(--fv-accent);background:none;height:32px;padding:0 4px}.fv-lan-starting{font-size:var(--fv-body);color:var(--fv-muted);line-height:1.65;margin:12px 0 0}.fv-lan-starting span{white-space:nowrap}.fv-lan-form{width:100%;margin-top:10px}.fv-lan-field{display:flex;background:var(--fv-raised);border:1px solid var(--fv-border);border-radius:8px;overflow:hidden}.fv-lan-field input{font:inherit;font-size:var(--fv-strong);color:var(--fv-ink);width:100%;min-width:0;background:none;border:0;padding:8px}.fv-lan.fv-lan .fv-lan-eye{width:44px;min-height:44px;flex:none;background:none;display:flex;align-items:center;justify-content:center}.fv-lan-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}.fv-lan-actions button{min-height:36px;padding:0 12px;border-radius:8px;background:var(--fv-raised)}.fv-lan-actions .fv-lan-save{background:var(--fv-accent);color:var(--fv-bg)}.fv-lan-error{font-size:var(--fv-body);color:var(--fv-danger);margin:8px 0 0}.fv-lan.fv-lan :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px}
+@media(pointer:coarse){.fv-lan.fv-lan .fv-lan-trigger{width:44px;height:44px;padding:13px}.fv-lan-panel button{min-height:44px}}
+
+@media(max-width:480px){.fv-studio .fv-header .fv-version-info{display:none}}
+
+.fv-lan-tip{position:fixed;z-index:1001;background:var(--fv-raised);color:var(--fv-ink);font-size:var(--fv-micro);padding:6px 10px;border:1px solid var(--fv-border);border-radius:6px;white-space:nowrap;pointer-events:none;box-shadow:0 4px 12px #0004}
+/* The extra tool must not take the readable project name out of a narrow editor. */
+@media(max-width:680px){.fv-studio.fv-editor .fv-header[data-fit~=wrap][data-fit~=compact] .fv-wordmark{display:none}}
diff --git a/web/lan_access.js b/web/lan_access.js
new file mode 100644
index 00000000..a3b26075
--- /dev/null
+++ b/web/lan_access.js
@@ -0,0 +1,106 @@
+import {longGeneration} from './phone_model.js';
+import {agentGet, agentPost} from './agent_session.js';
+import {qrcodegen} from './qrcodegen.js';
+const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./lan_access.css', import.meta.url).href; document.head.append(css);
+const seenKey = 'freevideo.phone.opened';
+export function qrMatrix(link) { return qrcodegen.QrCode.encodeText(link, qrcodegen.QrCode.Ecc.MEDIUM); }
+function drawQr(canvas, link) {
+ const qr = qrMatrix(link), scale = Math.max(2, Math.floor(336 / (qr.size + 8))), size = (qr.size + 8) * scale;
+ canvas.width = canvas.height = size; const ctx = canvas.getContext('2d'); ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, size, size); ctx.fillStyle = '#000';
+ for (let y = 0; y < qr.size; y++) for (let x = 0; x < qr.size; x++) if (qr.getModule(x, y)) ctx.fillRect((x + 4) * scale, (y + 4) * scale, scale, scale);
+}
+const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
+const button = (cls, label, action) => { const b = el('button', cls, label); b.type = 'button'; b.onclick = action; return b; };
+export function createLanAccess(t, api) {
+ const element = el('div', 'fv-lan'), trigger = button('fv-lan-trigger', '', toggle), panel = el('section', 'fv-lan-panel');
+ const label = t('Phone access', '手机连接'); trigger.setAttribute('aria-label', label); trigger.setAttribute('aria-expanded', 'false'); trigger.setAttribute('aria-haspopup', 'dialog');
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('aria-hidden', 'true');
+ const path = document.createElementNS(svg.namespaceURI || 'http://www.w3.org/2000/svg', 'path'); path.setAttribute('d', 'M7 2h10a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1H7a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1zM10 18h4'); svg.append(path); trigger.append(svg);
+ const dot = el('i', 'fv-lan-dot'); dot.hidden = true; dot.setAttribute('aria-hidden', 'true'); trigger.append(dot);
+ panel.hidden = true; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', label);
+ const head = el('div', 'fv-lan-heading'), toggleButton = button('fv-lan-switch', '', changeEnabled);
+ toggleButton.setAttribute('role', 'switch'); toggleButton.setAttribute('aria-label', label); toggleButton.setAttribute('aria-checked', 'false'); toggleButton.disabled = true;
+ head.append(el('strong', '', label), toggleButton);
+ const starting = el('p', 'fv-lan-starting'), content = el('div', 'fv-lan-content'), qr = el('canvas', 'fv-lan-qr'); qr.setAttribute('role', 'img'); qr.setAttribute('aria-label', t('Phone access QR code', '手机连接二维码'));
+ const copy = button('fv-lan-copy', t('Copy link', '复制链接'), async () => {
+ try {
+ if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(state.link);
+ else { const area = el('textarea'); area.value = state.link; panel.append(area); area.select(); const ok = document.execCommand('copy'); area.remove(); if (!ok) throw new Error(); }
+ copy.textContent = t('Copied', '已复制'); clearTimeout(copied); copied = setTimeout(() => { copy.textContent = t('Copy link', '复制链接'); }, 1500);
+ } catch { fail(); }
+ });
+ const passwordRow = el('div', 'fv-lan-password'), passwordState = el('span'), set = button('fv-lan-set', t('Set', '设置'), () => { form.hidden = false; password.focus(); });
+ passwordRow.append(passwordState, set);
+ const form = el('form', 'fv-lan-form'), field = el('div', 'fv-lan-field'), password = el('input'); password.type = 'password'; password.autocomplete = 'new-password'; password.setAttribute('aria-label', t('New password', '新密码'));
+ const eye = button('fv-lan-eye', '', () => { password.type = password.type === 'password' ? 'text' : 'password'; eye.setAttribute('aria-label', password.type === 'password' ? t('Show password', '显示密码') : t('Hide password', '隐藏密码')); }); eye.setAttribute('aria-label', t('Show password', '显示密码'));
+ const eyeSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'), eyePath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); eyeSvg.setAttribute('viewBox', '0 0 24 24'); eyeSvg.setAttribute('aria-hidden', 'true'); eyePath.setAttribute('d', 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'); eyeSvg.append(eyePath); eye.append(eyeSvg); field.append(password, eye);
+ const actions = el('div', 'fv-lan-actions'), cancel = button('', t('Cancel', '取消'), () => { form.hidden = true; password.value = ''; set.focus(); }), save = button('fv-lan-save', t('Save', '保存'), () => form.requestSubmit()); save.disabled = true;
+ password.oninput = () => { save.disabled = [...password.value].length < (state.password_min_length || 6) || writing; };
+ actions.append(cancel, save); form.append(field, actions); form.hidden = true;
+ const error = el('p', 'fv-lan-error', t('Please try again.', '请重试。')); error.hidden = true; error.setAttribute('role', 'status');
+ content.append(qr, el('p', 'fv-lan-scan', t('Connect to the same Wi-Fi, then scan', '手机连同一个 Wi-Fi 后扫码')), copy, passwordRow, form);
+ const tip = el('span', 'fv-lan-tip', label); tip.setAttribute('role', 'tooltip'); tip.hidden = true;
+ panel.append(head, starting, content, error); element.append(trigger, panel, tip);
+ let state = {enabled: false, link: '', password_set: false}, seen = false, disposed = false, writing = false, desired = null, reading = false, timer = null, copied = null, drawn = '';
+ try { seen = localStorage.getItem(seenKey) === '1'; } catch {}
+ const windows = /Win/i.test(globalThis.navigator?.platform || globalThis.navigator?.userAgent || '');
+ function fail() { error.hidden = false; }
+ function position() {
+ if (panel.hidden) return;
+ const box = trigger.getBoundingClientRect(); panel.style.top = `${box.bottom + 8}px`;
+ panel.style.left = `${Math.max(8, Math.min(box.right - 264, window.innerWidth - 272))}px`;
+ }
+ function render() {
+ const on = desired ?? state.enabled, pending = on && (!state.enabled || !state.link);
+ toggleButton.setAttribute('aria-checked', String(!!on)); toggleButton.disabled = writing || desired != null;
+ content.hidden = !on || pending; starting.hidden = !pending;
+ starting.replaceChildren();
+ if (pending && windows) {
+ if (t('en', 'zh') === 'zh') { starting.append(document.createTextNode('Windows 会弹出确认框,'), el('br'), el('span', '', '请勾选“专用网络”,再点“允许访问”。')); }
+ else starting.textContent = 'Windows will ask for permission. Check "Private networks", then click "Allow access".';
+ } else if (pending) starting.textContent = t('Opening…', '正在打开…');
+ passwordState.textContent = state.password_set ? t('Password: on', '密码:已设置') : t('Password: not set', '密码:未设置');
+ set.textContent = state.password_set ? t('Change', '修改') : t('Set', '设置');
+ if (on && !pending && state.link !== drawn) { try { drawQr(qr, state.link); drawn = state.link; } catch { fail(); } }
+ position();
+ }
+ async function read() {
+ clearTimeout(timer); if (disposed || panel.hidden || reading) return; reading = true;
+ try {
+ const response = await agentGet(api, '/freevideo/lan', {signal: AbortSignal.timeout(10000)});
+ if (!response.ok) throw new Error(); const value = await response.json(); if (disposed || writing) return;
+ state = value; if (desired === state.enabled && (!desired || state.link)) desired = null;
+ error.hidden = true; render();
+ } catch { if (!disposed) fail(); }
+ finally { reading = false; if (!disposed && !panel.hidden) timer = setTimeout(read, document.hidden ? 10000 : 2000); }
+ }
+ trigger.onpointerenter = trigger.onfocus = () => { if (!panel.hidden) return; const box = trigger.getBoundingClientRect(); tip.style.top = `${box.bottom + 8}px`; tip.style.right = `${Math.max(8, window.innerWidth - box.right)}px`; tip.hidden = false; };
+ trigger.onpointerleave = trigger.onblur = () => { tip.hidden = true; };
+ function close() { tip.hidden = true; panel.hidden = true; trigger.setAttribute('aria-expanded', 'false'); clearTimeout(timer); form.hidden = true; password.value = ''; }
+ function toggle() {
+ tip.hidden = true;
+ if (!panel.hidden) { close(); return; }
+ panel.hidden = false; trigger.setAttribute('aria-expanded', 'true'); seen = true; dot.hidden = true;
+ try { localStorage.setItem(seenKey, '1'); } catch {}
+ window.dispatchEvent(new Event('freevideo-phone-opened'));
+ render(); read();
+ }
+ async function write(body) {
+ writing = true; error.hidden = true; render();
+ try {
+ const response = await agentPost(api, '/freevideo/lan', body); if (!response.ok) throw new Error();
+ const value = await response.json(); if (disposed) return false;
+ if (typeof value.enabled === 'boolean') state = {...state, ...value}; return true;
+ } catch { if (!disposed) fail(); return false; }
+ finally { writing = false; if (!disposed) render(); }
+ }
+ async function changeEnabled() { desired = !state.enabled; if (!await write({enabled: desired})) desired = null; render(); read(); }
+ form.onsubmit = async event => { event.preventDefault(); if (save.disabled || writing) return; save.disabled = true; if (await write({password: password.value})) { password.value = ''; form.hidden = true; set.focus(); read(); } else password.oninput(); };
+ function observe(value) { if (!seen && longGeneration(value)) dot.hidden = false; }
+ const opened = () => { seen = true; dot.hidden = true; };
+ const storage = event => { if (event.key === seenKey && event.newValue === '1') opened(); };
+ window.addEventListener('freevideo-phone-opened', opened); window.addEventListener('storage', storage);
+ const progress = event => observe(event.detail), outside = event => { if (!element.contains(event.target)) close(); }, keys = event => { if (!panel.hidden && event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); trigger.focus(); } };
+ api.addEventListener?.('freevideo_progress', progress); document.addEventListener('pointerdown', outside, true); document.addEventListener('keydown', keys, true); window.addEventListener('resize', position);
+ return {element, observe, dispose() { disposed = true; close(); clearTimeout(copied); api.removeEventListener?.('freevideo_progress', progress); document.removeEventListener('pointerdown', outside, true); document.removeEventListener('keydown', keys, true); window.removeEventListener('resize', position); window.removeEventListener('freevideo-phone-opened', opened); window.removeEventListener('storage', storage); }};
+}
diff --git a/web/phone.css b/web/phone.css
new file mode 100644
index 00000000..9436884d
--- /dev/null
+++ b/web/phone.css
@@ -0,0 +1,13 @@
+.fv-phone{margin:0;background:var(--fv-bg);color:var(--fv-ink);font-size:var(--fv-strong);line-height:1.5;-webkit-font-smoothing:antialiased}
+.fv-phone *{box-sizing:border-box}.fv-phone [hidden]{display:none!important}.fv-phone button,.fv-phone input,.fv-phone textarea,.fv-phone select{font:inherit;color:inherit}.fv-phone button{border:0;cursor:pointer;background:none;min-height:44px;touch-action:manipulation}.fv-phone button:disabled{opacity:.45;cursor:default}.fv-phone :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px}
+#phone{height:100vh;height:100dvh;display:flex;flex-direction:column;overflow:hidden;position:relative}.phone-top{height:52px;flex:none;display:flex;align-items:center;justify-content:space-between;padding:0 16px;border-bottom:1px solid var(--fv-border)}.phone-top .fv-wordmark{height:20px;width:auto}.phone-offline{padding:8px 16px;background:var(--fv-raised);font-size:var(--fv-body);flex:none}.phone-main{flex:1;min-height:0;overflow:auto;padding:16px;overscroll-behavior:contain}.phone-log{display:flex;flex-direction:column;gap:14px;margin:0;padding:0;list-style:none}.phone-message{text-wrap:pretty;white-space:pre-wrap;overflow-wrap:anywhere;max-width:92%}.phone-message.user{align-self:flex-end;max-width:82%;padding:10px 14px;background:var(--fv-selected);border-radius:16px 16px 4px 16px}
+.phone-compose{display:flex;gap:8px;align-items:flex-end;padding:8px 12px;border-top:1px solid var(--fv-border)}.phone-compose textarea{resize:none;flex:1;min-width:0;min-height:44px;max-height:134px;line-height:22px;background:var(--fv-raised);border:1px solid var(--fv-border);border-radius:22px;padding:10px 16px}.phone-compose textarea::placeholder{color:var(--fv-disabled)}.phone-send{height:44px;width:44px;border-radius:50%;background:var(--fv-accent)!important;color:var(--fv-bg)!important;flex:none}.phone-tabs{flex:none;display:flex;border-top:1px solid var(--fv-border);background:var(--fv-canvas);padding-bottom:env(safe-area-inset-bottom,0px)}.phone-tab{flex:1;height:56px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:2px;font-size:var(--fv-micro)!important;color:var(--fv-muted)!important}.phone-tab[aria-selected=true]{color:var(--fv-accent)!important}.fv-phone svg{width:22px;height:22px;stroke:currentColor;fill:none;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;vertical-align:middle}
+.phone-mini{display:flex;text-align:left;gap:12px;align-items:center;width:300px;max-width:100%;padding:8px;background:var(--fv-card)!important;border:1px solid var(--fv-border)!important;border-radius:var(--fv-r-md)}.phone-mini .phone-picture{width:44px;height:78px;border-radius:6px;flex:none}.phone-mini-body{min-width:0;flex:1;display:flex;flex-direction:column;gap:6px}.phone-mini-title{font-size:var(--fv-body);color:var(--fv-muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.phone-mini-meta{display:flex;justify-content:space-between;font-size:var(--fv-body)}.phone-link{color:var(--fv-accent)!important;font-weight:var(--fv-semibold)}.phone-bar{height:4px;background:var(--fv-raised);border-radius:99px;overflow:hidden}.phone-bar i{height:100%;display:block;background:var(--fv-accent);border-radius:99px}.phone-picture{overflow:hidden;background:var(--fv-raised);position:relative}.phone-picture img{display:block;width:100%;height:100%;object-fit:cover}.phone-picture.strip img{max-width:none;object-fit:fill}
+.phone-section{margin:4px 0 8px;font-size:var(--fv-body);font-weight:var(--fv-semibold);color:var(--fv-muted)}.phone-running{border:1px solid var(--fv-border);border-radius:var(--fv-r-lg);overflow:hidden;background:var(--fv-card);margin-bottom:20px}.phone-frame{height:232px;background:#0b0f15;display:flex;align-items:center;justify-content:center}.phone-frame .phone-picture{height:100%;max-width:100%;aspect-ratio:9/16}.phone-frame.no-frame .phone-picture{display:none}.phone-frame .phone-frame-pct{font-size:var(--fv-section)}.phone-info{padding:12px 14px 14px;display:flex;flex-direction:column;gap:10px}.phone-prompt{font-size:calc(var(--fv-body) + 1px);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin:0}.phone-job-row{display:flex;justify-content:space-between;align-items:center;gap:8px}.phone-pct{font-size:var(--fv-section);font-weight:var(--fv-semibold)}.phone-eta{font-size:var(--fv-body);color:var(--fv-muted)}.phone-quiet{padding:0 12px;border-radius:var(--fv-r-sm);background:var(--fv-raised)!important;font-size:var(--fv-body)!important}.phone-queue{display:flex;gap:12px;align-items:center;min-height:54px;border-bottom:1px solid var(--fv-border);margin-bottom:12px;touch-action:pan-y}.phone-queue .ph{width:28px;height:28px;background:var(--fv-raised);border-radius:6px;flex:none}.phone-queue .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:calc(var(--fv-body) + 1px);flex:1}.phone-queue .position{color:var(--fv-muted);font-size:var(--fv-body)}.phone-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-bottom:16px}.phone-grid.landscape{grid-template-columns:repeat(2,minmax(0,1fr))}.phone-done{padding:0;text-align:left;min-width:0}.phone-done .phone-picture{aspect-ratio:9/16;border-radius:var(--fv-r-sm)}.landscape .phone-done .phone-picture{aspect-ratio:16/9}.phone-caption{font-size:var(--fv-micro);color:var(--fv-muted);margin-top:4px;display:block}.phone-empty{color:var(--fv-muted);margin:28px 0}.phone-toast{position:relative;flex:none;margin:8px 12px;background:var(--fv-raised);border:1px solid var(--fv-border);border-radius:var(--fv-r-md);padding:10px 12px;box-shadow:0 8px 24px #0006;display:flex;gap:12px;align-items:center;font-size:calc(var(--fv-body) + 1px)}.phone-toast .phone-picture{width:28px;height:50px;flex:none;border-radius:4px}.phone-toast span{flex:1}
+.phone-result{flex:1;min-height:0;display:flex;flex-direction:column}.phone-viewer{flex:1;min-height:0;background:#000;display:flex;align-items:center;justify-content:center}.phone-viewer video{width:100%;max-height:100%;object-fit:contain}.phone-result-actions{padding:12px 16px 28px;display:flex;flex-direction:column;gap:12px}.phone-result-actions .phone-prompt{color:var(--fv-muted)}.fv-phone .phone-primary{min-height:44px;background:var(--fv-accent)!important;color:var(--fv-bg)!important;border-radius:10px;font-weight:var(--fv-semibold);width:100%}.phone-back{width:44px;margin-left:-12px}.phone-password{flex:1;display:flex;flex-direction:column;justify-content:center;padding:0 28px 120px;gap:16px}.phone-password .fv-wordmark{height:26px;width:auto;align-self:flex-start;margin-bottom:24px}.phone-password h1{font-size:calc(var(--fv-section) + 2px);font-weight:var(--fv-semibold);margin:0}.phone-password-field{display:flex;min-height:48px;border-radius:10px;background:var(--fv-raised);border:1px solid var(--fv-border);padding:0 2px 0 16px}.phone-password-field:focus-within{border-color:var(--fv-accent)}.phone-password-field.bad,.phone-password-field.bad:focus-within{border-color:var(--fv-danger)}.phone-password-field input{background:none;border:0;outline:none!important;min-width:0;flex:1;font-size:var(--fv-strong)}.phone-eye{width:44px;flex:none;color:var(--fv-muted)!important}.phone-error{font-size:var(--fv-body);color:var(--fv-danger);margin:0}.phone-password .phone-error{margin-top:-8px}.phone-confirm{background:var(--fv-card);border:1px solid var(--fv-border);border-radius:var(--fv-r-md);padding:16px;color:var(--fv-ink);max-width:calc(100vw - 32px)}.phone-confirm::backdrop{background:#0008}.phone-confirm p{margin:0 0 16px}.phone-confirm-actions{display:flex;gap:12px}.phone-chat-tools{display:flex;gap:8px;justify-content:flex-end;margin-bottom:12px}.phone-chat-tools select{max-width:250px;min-height:44px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:8px}.phone-chat-tools button{width:44px}.phone-notice{margin:0;padding:4px 16px;font-size:var(--fv-body);color:var(--fv-danger)}
+
+.phone-viewer{position:relative}.phone-viewer video{max-height:min(100%,560px);padding-bottom:48px;background:#000}
+
+.fv-phone .phone-chats{width:40px;height:40px;min-height:40px;margin-right:-8px;display:flex;align-items:center;justify-content:center;color:var(--fv-muted)}.phone-chats svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
+.phone-sheet{position:absolute;inset:0;z-index:20;background:#0008;display:flex;align-items:flex-end}.phone-sheet-panel{width:100%;max-height:70%;overflow:auto;background:var(--fv-card);border-top:1px solid var(--fv-border);border-radius:16px 16px 0 0;padding:10px 0 calc(16px + env(safe-area-inset-bottom))}
+.fv-phone .phone-sheet-new{width:100%;text-align:left;padding:0 20px;color:var(--fv-accent);font-weight:var(--fv-semibold)}.phone-sheet-list{list-style:none;margin:0;padding:0}.fv-phone .phone-sheet-row{width:100%;display:flex;align-items:center;gap:12px;padding:0 20px;text-align:left}.phone-sheet-check{width:16px;flex:none;color:var(--fv-accent)}.phone-sheet-title{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.phone-sheet-time{flex:none;color:var(--fv-muted);font-size:13px}.phone-sheet-grip{width:36px;height:4px;border-radius:2px;margin:0 auto 8px;background:color-mix(in srgb,var(--fv-muted) 30%,transparent)}
diff --git a/web/phone.js b/web/phone.js
new file mode 100644
index 00000000..48438fd5
--- /dev/null
+++ b/web/phone.js
@@ -0,0 +1,290 @@
+import {chatData, chatPoller, chatDelay, chatReadRequest, emptyChat, chatMerge, chatEntries, newId} from './chat_data.js';
+import {phoneText, jobGroups, mergePhoneJobs, retryWords, queueWords, remainingWords, mediaPath, saveVideo, applyPhoneChain, phonePartWords, phoneJobKey} from './phone_model.js';
+import {wordmark} from './wordmark.js';
+
+export function mountPhone(root) {
+const t = phoneText(navigator.language);
+document.documentElement.lang = t('en', 'zh-CN');
+const el = (tag, cls = '', text) => { const e = document.createElement(tag); e.className = cls; if (text != null) e.textContent = text; return e; };
+const button = (cls, label, run) => { const b = el('button', cls, label); b.type = 'button'; b.onclick = run; return b; };
+const icon = path => { const s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); const p = document.createElementNS(s.namespaceURI, 'path'); p.setAttribute('d', path); s.append(p); return s; };
+const closed = new AbortController();
+let locked = false, stopped = false, sending = false, retryUntil = 0, countdown = null;
+let chat = emptyChat(), jobs = {}, selected, revision = 0, cursor = null, historyStarted = false, limit = 30, loadingMore = false, tab = location.hash === '#jobs' ? 'jobs' : 'chat', result = null;
+let lastSend = null, toastTimer = null, flash = null, toastJob = null, permissionAsked = false;
+try { permissionAsked = localStorage.getItem('freevideo.phone.notification-asked') === '1'; } catch {}
+const drafts = new Map(), rows = new Map(), scrollPositions = {chat: 0, jobs: 0};
+const top = el('header', 'phone-top'), offline = el('div', 'phone-offline', t('Connection lost. Reconnecting…', '连接断了,正在重新连接…'));
+offline.hidden = true; offline.setAttribute('role', 'status');
+const main = el('main', 'phone-main'), log = el('ol', 'phone-log'); log.setAttribute('role', 'log'); log.setAttribute('aria-label', t('Chat', '对话'));
+let convRows = [], convCurrent = '';
+const chatsButton = button('phone-chats', '', () => openSheet()); chatsButton.setAttribute('aria-label', t('Chats', '对话')); chatsButton.setAttribute('aria-haspopup', 'dialog'); chatsButton.append(icon('M4 4h16v12H9l-5 4V4zM12 7v6M9 10h6'));
+const sheet = el('div', 'phone-sheet'), sheetPanel = el('section', 'phone-sheet-panel'); sheet.hidden = true; sheetPanel.setAttribute('role', 'dialog'); sheetPanel.setAttribute('aria-label', t('Chats', '对话')); sheet.append(sheetPanel);
+const clock = seconds => { const d = new Date(Number(seconds) * 1000); if (!Number.isFinite(d.getTime()) || !seconds) return ''; const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hour12: false}) : d.toLocaleDateString(t('en-US', 'zh-CN'), {month: 'long', day: 'numeric'}); };
+function closeSheet() { sheet.hidden = true; chatsButton.setAttribute('aria-expanded', 'false'); chatsButton.focus(); }
+function renderSheet() {
+ const fresh = button('phone-sheet-new', t('New chat', '新对话'), async () => {
+ fresh.disabled = true;
+ try { const data = await json(await dataLayer.create()); if (!data.session) throw new Error(); closeSheet(); choose(data.session); }
+ catch { closeSheet(); notice(t('Could not start a new chat. Try again.', '没有新建成功,请重试。')); }
+ });
+ const list = el('ul', 'phone-sheet-list');
+ for (const row of convRows.some(row => row.id === convCurrent) || !convCurrent ? convRows : [{id: convCurrent}, ...convRows]) {
+ const item = button('phone-sheet-row', '', () => { closeSheet(); if (row.id !== convCurrent) choose(row.id); });
+ const check = el('span', 'phone-sheet-check', row.id === convCurrent ? '✓' : ''); check.setAttribute('aria-hidden', 'true');
+ if (row.id === convCurrent) item.setAttribute('aria-current', 'true');
+ item.append(check, el('span', 'phone-sheet-title', row.title || t('New chat', '新对话')), el('span', 'phone-sheet-time', clock(row.updated_at)));
+ const li = el('li'); li.append(item); list.append(li);
+ }
+ const grip = el('div', 'phone-sheet-grip'); grip.setAttribute('aria-hidden', 'true');
+ sheetPanel.replaceChildren(grip, fresh, list);
+}
+function openSheet() { renderSheet(); sheet.hidden = false; chatsButton.setAttribute('aria-expanded', 'true'); sheetPanel.querySelector('button').focus(); }
+top.append(wordmark(), chatsButton);
+sheet.addEventListener('pointerdown', event => { if (event.target === sheet) closeSheet(); });
+let dragFrom = null;
+sheetPanel.addEventListener('touchstart', event => { dragFrom = sheetPanel.scrollTop <= 0 ? event.touches[0].clientY : null; }, {passive: true});
+sheetPanel.addEventListener('touchend', event => { if (dragFrom !== null && event.changedTouches[0].clientY - dragFrom > 60) closeSheet(); dragFrom = null; }, {passive: true});
+sheet.addEventListener('keydown', event => { if (event.key === 'Escape') { event.preventDefault(); closeSheet(); } });
+const generations = el('div', 'phone-generations');
+main.append(log, generations);
+const compose = el('form', 'phone-compose'), input = el('textarea'), send = button('phone-send', '', () => compose.requestSubmit());
+input.rows = 1; input.maxLength = 8000; input.placeholder = t('What would you like to make?', '想做什么视频?'); input.setAttribute('aria-label', input.placeholder);
+send.append(icon('M12 19V5M5 12l7-7 7 7')); send.setAttribute('aria-label', t('Send', '发送')); send.disabled = true; compose.append(input, send);
+const warning = el('p', 'phone-notice'); warning.hidden = true; warning.setAttribute('role', 'status');
+const tabs = el('nav', 'phone-tabs'); tabs.setAttribute('aria-label', t('Pages', '页面'));
+const chatTab = button('phone-tab', '', () => show('chat')), jobsTab = button('phone-tab', '', () => show('jobs'));
+for (const [b, label, path] of [[chatTab, t('Chat', '对话'), 'M4 4h16v12H9l-5 4V4z'], [jobsTab, t('Generations', '生成'), 'M4 7h11v11H4zM15 11l6-4v11l-6-4']]) { b.append(icon(path), el('span', '', label)); b.setAttribute('role', 'tab'); tabs.append(b); }
+tabs.setAttribute('role', 'tablist');
+const viewer = el('section', 'phone-result'), toast = el('div', 'phone-toast'); toast.hidden = viewer.hidden = true; toast.setAttribute('role', 'status');
+const pw = el('form', 'phone-password'), field = el('div', 'phone-password-field'), password = el('input'), pwError = el('p', 'phone-error');
+password.type = 'password'; password.autocomplete = 'current-password'; password.setAttribute('aria-label', t('Password', '密码'));
+const eye = button('phone-eye', '', () => { password.type = password.type === 'password' ? 'text' : 'password'; eye.setAttribute('aria-label', password.type === 'password' ? t('Show password', '显示密码') : t('Hide password', '隐藏密码')); });
+eye.append(icon('M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z')); eye.setAttribute('aria-label', t('Show password', '显示密码'));
+const enter = button('phone-primary', t('Open', '进入'), () => pw.requestSubmit()); enter.disabled = true;
+field.append(password, eye); pwError.hidden = true; pwError.setAttribute('role', 'alert'); pw.append(wordmark(), el('h1', '', t('Enter password', '输入密码')), field, pwError, enter); pw.hidden = true;
+root.append(top, offline, main, warning, compose, viewer, toast, tabs, pw, sheet);
+function notice(text = '') { warning.textContent = text; warning.hidden = !text; }
+function connection(ok) { offline.hidden = ok || locked; }
+function requirePassword() { if (locked) return; locked = true; document.querySelectorAll('dialog.phone-confirm').forEach(dialog => dialog.close()); viewer.querySelector('video')?.pause(); renderShell(); password.focus(); }
+const api = {apiURL: path => path, async fetchApi(path, options = {}) {
+ const signals = [closed.signal, options.signal, AbortSignal.timeout(10000)].filter(Boolean);
+ // The proxy hands each signed-in device its write proof at /m/csrf; the desktop page asks the agent route.
+ const response = await fetch(path === '/freevideo/agent/session' ? '/m/csrf' : path, {...options, signal: AbortSignal.any(signals), credentials: 'same-origin'});
+ // Without a valid device cookie the proxy answers every request with its password page, never JSON.
+ if ([401, 429].includes(response.status) && (response.headers.get('Content-Type') || '').startsWith('text/html')) {
+ requirePassword(); const wait = Number(response.headers.get('X-FreeVideo-Retry-After')); if (wait > 0) lockFor(wait);
+ }
+ return response;
+}};
+const dataLayer = chatData(api);
+async function json(response) { const data = await response.json(); if (!response.ok) throw Object.assign(new Error(), {data, status: response.status}); return data; }
+function lockFor(seconds) { retryUntil = Date.now() + Math.max(0, Number(seconds) || 0) * 1000; clearInterval(countdown); tickPassword(); countdown = setInterval(tickPassword, 250); }
+function tickPassword() {
+ const remaining = Math.ceil((retryUntil - Date.now()) / 1000);
+ enter.disabled = remaining > 0 || !password.value || pw.dataset.busy === 'true';
+ if (remaining > 0) { pwError.textContent = retryWords(remaining, t); pwError.hidden = false; field.classList.add('bad'); }
+ else if (retryUntil) { retryUntil = 0; clearInterval(countdown); pwError.hidden = true; field.classList.remove('bad'); }
+}
+password.oninput = tickPassword;
+pw.onsubmit = async event => {
+ event.preventDefault(); if (enter.disabled) return; pw.dataset.busy = 'true'; tickPassword();
+ try {
+ // The proxy owns the sign-in: a form post, answered by a redirect that sets the device cookie.
+ const response = await api.fetchApi('/m/login', {method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({password: password.value, remember: '1'}).toString()});
+ const wait = Number(response.headers.get('X-FreeVideo-Retry-After'));
+ if (response.ok) { locked = false; password.value = ''; pwError.hidden = true; field.classList.remove('bad'); renderShell(); await poller.poll(); }
+ else if (response.status === 429 && wait > 0) lockFor(wait);
+ else if (response.status === 401) { pwError.textContent = t('Wrong password. Please try again.', '密码不对,请再输一次。'); pwError.hidden = false; field.classList.add('bad'); password.focus(); password.select(); }
+ else { pwError.textContent = t('Connection lost. Reconnecting…', '连接断了,正在重新连接…'); pwError.hidden = false; }
+ } catch { pwError.textContent = t('Connection lost. Reconnecting…', '连接断了,正在重新连接…'); pwError.hidden = false; }
+ finally { pw.dataset.busy = 'false'; tickPassword(); }
+};
+function renderShell() {
+ pw.hidden = !locked; top.hidden = main.hidden = tabs.hidden = locked;
+ compose.hidden = warning.hidden = locked || result != null || tab !== 'chat'; warning.hidden ||= !warning.textContent;
+ viewer.hidden = locked || !result; main.hidden ||= !!result; tabs.hidden ||= !!result;
+ offline.hidden ||= locked; toast.hidden = locked || !!result || !toastJob;
+ chatsButton.hidden = log.hidden = tab !== 'chat'; generations.hidden = tab !== 'jobs';
+ chatTab.setAttribute('aria-selected', String(tab === 'chat')); jobsTab.setAttribute('aria-selected', String(tab === 'jobs'));
+}
+function show(value) { if (!result) scrollPositions[tab] = main.scrollTop; viewer.querySelector('video')?.pause(); result = null; top.replaceChildren(wordmark(), chatsButton); tab = value; renderShell(); if (value === 'jobs') renderJobs(); main.scrollTop = scrollPositions[value]; }
+function choose(session) { drafts.set(selected ?? chat.session, input.value); selected = session; revision++; chat = emptyChat(); input.value = drafts.get(session) || ''; lastSend = null; rows.clear(); log.replaceChildren(); grow(); poller.poll(); }
+function grow() { input.style.height = 'auto'; input.style.height = `${Math.min(input.scrollHeight, 134)}px`; send.disabled = sending || !input.value.trim(); }
+input.oninput = () => { if (lastSend?.text !== input.value.trim()) lastSend = null; grow(); notice(); };
+input.onkeydown = event => { if (event.key === 'Enter' && !event.shiftKey && !event.isComposing && event.keyCode !== 229) { event.preventDefault(); compose.requestSubmit(); } };
+compose.onsubmit = async event => {
+ event.preventDefault(); const text = input.value.trim(); if (sending || !text) return;
+ const body = lastSend?.text === text && lastSend.session === (chat.session ?? null) ? lastSend : {id: newId(), text, session: chat.session ?? null};
+ lastSend = body; sending = true; const version = revision; grow(); notice();
+ try {
+ body.attempts = 0;
+ const response = await dataLayer.sendMessage(body, {stopped: () => locked || stopped});
+ if (!response?.ok) throw new Error();
+ if (version === revision && input.value.trim() === text) { input.value = ''; lastSend = null; }
+ await poller.poll();
+ } catch { notice(t('Could not send. Please try again.', '没有发送成功,请重试。')); }
+ finally { sending = false; grow(); }
+};
+function picture(job, preview = false) {
+ const box = el('div', 'phone-picture');
+ const p = job.progress || {}, frame = p.frame_preview;
+ const path = preview && frame?.serial > 0 && frame.frames > 0 && frame.frames <= 8 && job.node != null
+ ? `/freevideo/frame-preview/${encodeURIComponent(job.node)}?serial=${frame.serial}` : mediaPath(job.thumbnail, 'image');
+ if (path) {
+ const img = el('img'); img.alt = ''; img.loading = 'lazy'; img.src = path; box.append(img);
+ if (preview && frame?.frames > 0) { box.classList.add('strip'); img.style.width = `${frame.frames * 100}%`; img.style.transform = `translateX(-${(frame.frames - 1) * 100 / frame.frames}%)`; }
+ img.onerror = () => { img.remove(); box.closest('.phone-frame')?.classList.add('no-frame'); };
+ }
+ return box;
+}
+const fraction = job => Math.max(0, Math.min(1, Number(job.progress?.fraction) || 0));
+function bar(job) { const b = el('div', 'phone-bar'), fill = el('i'); fill.style.width = `${fraction(job) * 100}%`; b.append(fill); b.setAttribute('role', 'progressbar'); b.setAttribute('aria-valuenow', String(Math.floor(fraction(job) * 100))); b.setAttribute('aria-label', job.label || t('Generating', '正在生成')); return b; }
+function openJob(job) { if (job.status === 'succeeded') openResult(job); else { show('jobs'); generations.querySelector(`[data-job="${CSS.escape(job.id)}"]`)?.scrollIntoView({block: 'center'}); } }
+function mini(job) {
+ const b = button('phone-mini', '', () => openJob(jobs[job.id] || job)), body = el('div', 'phone-mini-body');
+ body.append(el('div', 'phone-mini-title', job.label || t('Video', '视频')));
+ if (job.status === 'succeeded') body.append(el('span', 'phone-link', t('View', '查看')));
+ else if (['waiting', 'queued'].includes(job.status)) body.append(el('span', 'phone-mini-meta', t('Queued', '排队中')));
+ else if (['failed', 'cancelled'].includes(job.status)) body.append(el('span', 'phone-mini-meta', job.status === 'failed' ? t('Failed', '失败') : t('Stopped', '已停止')));
+ else { const meta = el('div', 'phone-mini-meta'); meta.append(el('b', '', `${Math.floor(fraction(job) * 100)}%`), el('span', 'phone-eta', remainingWords(job.progress?.remaining_seconds, t))); body.append(bar(job), meta); }
+ b.append(picture(job, true), body); return b;
+}
+function renderChat() {
+ const pinned = main.scrollHeight - main.clientHeight - main.scrollTop < 60;
+ for (const entry of chatEntries(chat)) {
+ if (lastSend && entry.id === lastSend.id) { lastSend.acknowledged = true; if (input.value.trim() === lastSend.text) { input.value = ''; grow(); } lastSend = null; notice(); }
+ const job = jobs[entry.job], key = String(entry.seq), signature = JSON.stringify([entry, phoneJobKey(job), chat.confirms[entry.id]]);
+ if (rows.get(key)?.signature === signature) continue;
+ const row = el('li');
+ if (entry.type === 'message') { row.className = 'phone-message ' + (entry.role === 'user' ? 'user' : 'agent'); row.textContent = entry.text; }
+ else if (entry.type === 'job' && job) row.append(mini(job));
+ else if (entry.type === 'confirm') {
+ row.className = 'phone-confirm'; row.append(el('p', '', entry.question));
+ const state = chat.confirms[entry.id] || entry.state;
+ if (state === 'approved' || state === 'declined') row.append(el('span', '', state === 'approved' ? t('Approved', '已同意') : t('Declined', '已拒绝')));
+ else for (const approve of [true, false]) {
+ const b = button('phone-quiet', approve ? t('Start', '开始') : t('Cancel', '取消'), async () => {
+ const version = revision, session = chat.session; row.querySelectorAll('button').forEach(e => e.disabled = true);
+ try { const answer = await json(await dataLayer.confirm({id: entry.id, approve, ...(session ? {session} : {})})); if (revision === version) { chat.confirms[entry.id] = answer.state; renderChat(); } await poller.poll(); }
+ catch { row.querySelectorAll('button').forEach(e => e.disabled = false); notice(t('Please try again.', '请重试。')); }
+ }); row.append(b);
+ }
+ } else continue;
+ const old = rows.get(key); if (old) old.element.replaceWith(row); else log.append(row); rows.set(key, {element: row, signature});
+ }
+ if (pinned && tab === 'chat') main.scrollTop = main.scrollHeight;
+}
+async function cancel(job, confirm) {
+ if (confirm) {
+ const dialog = el('dialog', 'fv-phone phone-confirm'); dialog.append(el('p', '', t('Stop this generation?', '停止这个生成?')));
+ const actions = el('div', 'phone-confirm-actions');
+ const yes = button('phone-quiet', t('Stop', '停止'), async () => { yes.disabled = true; if (await cancel(job, false)) dialog.close(); else yes.disabled = false; });
+ const no = button('phone-quiet', t('Keep generating', '继续生成'), () => dialog.close()); actions.append(yes, no); dialog.append(actions); document.body.append(dialog); dialog.onclose = () => dialog.remove(); dialog.showModal(); no.focus(); return;
+ }
+ try { await json(await dataLayer.cancel(job.id)); await poller.poll(); return true; }
+ catch { connection(false); return false; }
+}
+let jobsSignature = '';
+function renderJobs() {
+ const signature = JSON.stringify([Object.values(jobs).map(phoneJobKey), limit]); if (signature === jobsSignature) return; jobsSignature = signature;
+ const active = document.activeElement?.dataset.action;
+ const {running, queued, done} = jobGroups(jobs); generations.replaceChildren();
+ if (!running.length && !queued.length && !done.length) generations.append(el('p', 'phone-empty', t('Nothing yet. Tell the Agent what to make in Chat.', '还没有作品,在对话里说想做什么。')));
+ if (running.length) generations.append(el('h2', 'phone-section', t('Generating', '正在生成')));
+ for (const job of running) {
+ const card = el('article', 'phone-running'), frame = el('div', 'phone-frame'), info = el('div', 'phone-info'); card.dataset.job = job.id;
+ const img = picture(job, true); img.style.aspectRatio = `${job.width || 9}/${job.height || 16}`; frame.append(img); frame.classList.toggle('no-frame', !img.firstChild);
+ if (!img.firstChild) frame.append(el('span', 'phone-frame-pct', `${Math.floor(fraction(job) * 100)}%`));
+ const row = el('div', 'phone-job-row'), numbers = el('div'); numbers.append(el('span', 'phone-pct', `${Math.floor(fraction(job) * 100)}%`), document.createTextNode(' '), el('span', 'phone-eta', remainingWords(job.progress?.remaining_seconds, t)));
+ const stop = button('phone-quiet', t('Stop', '停止'), () => cancel(job, true)); stop.disabled = job.status === 'cancelling'; stop.dataset.action = 'stop-' + job.id; row.append(numbers, stop);
+ info.append(el('p', 'phone-prompt', job.label)); if (phonePartWords(job, t)) info.append(el('span', 'phone-eta', phonePartWords(job, t))); info.append(bar(job), row); card.append(frame, info); generations.append(card);
+ }
+ if (queued.length) generations.append(el('h2', 'phone-section', t('Queued', '排队中')));
+ queued.forEach((job, index) => {
+ const row = el('div', 'phone-queue'), remove = button('phone-quiet', t('Remove', '移除'), async () => { remove.disabled = true; if (!await cancel(job, false)) remove.disabled = false; }); remove.hidden = true;
+ row.tabIndex = 0; row.setAttribute('aria-label', `${job.label}, ${queueWords(index, t)}`); row.dataset.job = job.id;
+ row.append(el('i', 'ph'), el('span', 'name', job.label), el('span', 'position', queueWords(index, t)), remove);
+ let hold, x, y; const reveal = () => { remove.hidden = false; };
+ row.onpointerdown = event => { x = event.clientX; y = event.clientY; hold = setTimeout(reveal, 500); };
+ row.onpointermove = event => { if (Math.abs(event.clientY - y) > 12) clearTimeout(hold); if (x - event.clientX > 40 && Math.abs(event.clientY - y) < 25) { clearTimeout(hold); reveal(); } };
+ row.onpointerup = row.onpointercancel = row.onlostpointercapture = () => clearTimeout(hold);
+ row.oncontextmenu = event => { event.preventDefault(); reveal(); };
+ row.onkeydown = event => { if (['Delete', 'Enter', ' '].includes(event.key)) { event.preventDefault(); reveal(); remove.focus(); } };
+ generations.append(row);
+ });
+ if (done.length) generations.append(el('h2', 'phone-section', t('Done', '已完成')));
+ // Keep chronological order, grouping only adjacent works of the same orientation.
+ let grid, landscape;
+ for (const job of done.slice(0, limit)) {
+ const wide = job.width > job.height; if (!grid || landscape !== wide) { landscape = wide; grid = el('div', 'phone-grid' + (wide ? ' landscape' : '')); generations.append(grid); }
+ const card = button('phone-done', '', () => openResult(job)); card.setAttribute('aria-label', job.label || t('View', '查看')); card.dataset.job = job.id;
+ const time = job.finished_at ? new Date(job.finished_at * 1000).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', hour12: false}) : '';
+ card.append(picture(job), el('span', 'phone-caption', [job.seconds > 0 ? `${job.seconds} s` : '', time].filter(Boolean).join(' · '))); grid.append(card);
+ }
+ if (active) generations.querySelector(`[data-action="${CSS.escape(active)}"]`)?.focus({preventScroll: true});
+}
+function openResult(job) {
+ scrollPositions[tab] = main.scrollTop; result = job; toastJob = null; clearTimeout(toastTimer); const back = button('phone-back', '', () => show('jobs')); back.setAttribute('aria-label', t('Back', '返回')); back.append(icon('M15 5l-7 7 7 7')); top.replaceChildren(back);
+ viewer.replaceChildren(); const stage = el('div', 'phone-viewer'), video = el('video'); video.controls = true; video.playsInline = true; video.preload = 'metadata'; video.src = mediaPath(job.play); const poster = mediaPath(job.thumbnail, 'image'); if (poster) video.poster = poster; stage.append(video);
+ const actions = el('div', 'phone-result-actions'), error = el('p', 'phone-error'); error.hidden = true;
+ const download = button('phone-primary', t('Download', '下载'), async () => {
+ download.disabled = true; error.hidden = true;
+ try { await saveVideo(job, {fetch: api.fetchApi, download(path, filename) { const a = el('a'); a.href = path; a.download = filename; document.body.append(a); a.click(); a.remove(); }}); }
+ catch { error.textContent = t('Please try again.', '请重试。'); error.hidden = false; }
+ finally { download.disabled = false; }
+ });
+ actions.append(el('p', 'phone-prompt', job.label), download, error); viewer.append(stage, actions); renderShell();
+}
+function completed(job) {
+ toastJob = job; toast.replaceChildren(picture(job), el('span', '', t('Your video is ready', '生成好了')), button('phone-link', t('View', '查看'), () => { show('jobs'); openResult(job); }));
+ main.classList.add('toast-space'); renderShell(); clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => { toastJob = null; toast.hidden = true; main.classList.remove('toast-space'); }, 6000);
+ clearInterval(flash); let lit = false; flash = setInterval(() => { document.title = (lit = !lit) ? t('Your video is ready', '生成好了') : 'FreeVideo'; }, 1000);
+ setTimeout(() => { clearInterval(flash); document.title = 'FreeVideo'; }, 6000);
+ if ('Notification' in window) {
+ if (document.hidden && Notification.permission === 'granted') { try { const n = new Notification(t('Your video is ready', '生成好了')); n.onclick = () => { window.focus(); show('jobs'); openResult(job); n.close(); }; } catch {} }
+ if (!permissionAsked && Notification.permission === 'default') { permissionAsked = true; try { localStorage.setItem('freevideo.phone.notification-asked', '1'); } catch {} Notification.requestPermission().catch(() => {}); }
+ }
+}
+async function refresh() {
+ if (stopped || locked) return;
+ const version = revision, {after} = chatReadRequest(chat, selected);
+ try {
+ const [data, activity, progress, now] = await Promise.all([dataLayer.read({session: selected, after}).then(json), dataLayer.activity().then(json), api.fetchApi('/freevideo/progress', {cache: 'no-store'}).then(json), dataLayer.now().then(json)]);
+ if (stopped || locked || version !== revision) return;
+ if (after && data.session !== chat.session) { drafts.set(chat.session, input.value); input.value = drafts.get(data.session) || ''; grow(); chat = emptyChat(); rows.clear(); log.replaceChildren(); await refresh(); return; }
+ if (data.session !== chat.session) { chat = emptyChat(); rows.clear(); log.replaceChildren(); }
+ chat = chatMerge(chat, data); const previous = jobs;
+ jobs = mergePhoneJobs(jobs, {...chat.jobs, ...(now.current?.id ? {[now.current.id]: now.current} : {})}, activity.jobs || [], progress.progress || []);
+ if (!historyStarted) cursor = activity.next_cursor ?? null;
+ const running = jobGroups(jobs).running;
+ await Promise.all(running.filter(job => job.chain_id).map(async job => {
+ const {chain} = await json(await api.fetchApi('/freevideo/chain/' + encodeURIComponent(job.chain_id), {cache: 'no-store'}));
+ if (stopped || version !== revision || !chain) return;
+ jobs[job.id] = applyPhoneChain(job, chain);
+ }));
+ if (stopped || version !== revision) return;
+ for (const job of Object.values(jobs)) if (job.status === 'succeeded' && previous[job.id] && ['running', 'queued', 'waiting', 'cancelling'].includes(previous[job.id].status)) completed(job);
+ connection(true); renderChat(); if (tab === 'jobs') renderJobs();
+ const response = await dataLayer.list();
+ if (response.ok) { const list = await response.json(); if (version !== revision || stopped) return; convRows = list.conversations || []; convCurrent = selected || chat.session || list.here || ''; if (!sheet.hidden) renderSheet(); }
+ } catch { if (!stopped) connection(false); }
+}
+const poller = chatPoller(refresh, {delay: () => chatDelay({phone: true, hidden: document.hidden})});
+main.addEventListener('scroll', async () => {
+ if (tab !== 'jobs' || main.scrollTop + main.clientHeight < main.scrollHeight - 150 || loadingMore) return;
+ if (jobGroups(jobs).done.length > limit) { limit += 30; renderJobs(); return; }
+ if (!cursor) return;
+ loadingMore = true;
+ try { const data = await json(await dataLayer.activity(cursor)); jobs = mergePhoneJobs(jobs, {}, data.jobs || []); cursor = data.next_cursor ?? null; historyStarted = true; limit += 30; renderJobs(); }
+ catch { connection(false); } finally { loadingMore = false; }
+}, {passive: true});
+document.addEventListener('visibilitychange', () => { if (!document.hidden) { clearInterval(flash); document.title = 'FreeVideo'; poller.poll(); } else poller.reschedule(); });
+window.addEventListener('pagehide', event => { if (!event.persisted) { stopped = true; closed.abort(); poller.dispose(); clearInterval(countdown); clearInterval(flash); clearTimeout(toastTimer); } });
+window.addEventListener('pageshow', event => { if (event.persisted) poller.poll(); });
+renderShell(); poller.poll();
+
+return {refresh: () => poller.poll()};
+}
+if (document.querySelector('#phone')) mountPhone(document.querySelector('#phone'));
diff --git a/web/phone_model.js b/web/phone_model.js
new file mode 100644
index 00000000..450acee6
--- /dev/null
+++ b/web/phone_model.js
@@ -0,0 +1,88 @@
+import {chainPosition} from './studio_chain.js';
+// Data shaping only. Backend identifiers are never guessed from the current job.
+export const phoneText = (language = '') => String(language).toLowerCase().startsWith('zh') ? (en, zh) => zh : en => en;
+export const activeJob = job => ['running', 'cancelling'].includes(job.status);
+export const queuedJob = job => ['waiting', 'queued'].includes(job.status);
+export function jobGroups(jobs) {
+ const rows = Object.values(jobs).filter(job => ['generate', 'assemble'].includes(job.kind));
+ return {running: rows.filter(activeJob), queued: rows.filter(queuedJob).sort((a, b) => (a.progress?.queue_position ?? Infinity) - (b.progress?.queue_position ?? Infinity) || a.created_at - b.created_at),
+ done: rows.filter(job => job.status === 'succeeded').sort((a, b) => b.finished_at - a.finished_at || b.created_at - a.created_at)};
+}
+export function mergePhoneJobs(previous, chat, activity, snapshots = []) {
+ const jobs = {...previous};
+ for (const row of [...Object.values(chat), ...activity]) {
+ const old = jobs[row.id] || {};
+ jobs[row.id] = {...old, ...row, progress: {...old.progress, ...row.progress}};
+ }
+ for (const job of Object.values(jobs)) {
+ if (!activeJob(job)) continue;
+ const message = snapshots.find(m => m.job_id === job.id || (job.node != null && job.prompt_id && String(m.node) === String(job.node) && m.prompt_id === job.prompt_id));
+ if (message) job.progress = {...job.progress, ...message,
+ fraction: message.overall?.fraction ?? job.progress?.fraction,
+ remaining_seconds: message.overall?.remaining_seconds ?? job.progress?.remaining_seconds};
+ }
+ return jobs;
+}
+export function retryWords(seconds, t) {
+ const count = Math.ceil(Math.max(0, seconds));
+ return count > 90 ? t(`Too many tries. Please try again in ${Math.ceil(count / 60)} min.`, `试错次数太多,请 ${Math.ceil(count / 60)} 分钟后再试。`)
+ : t(`Too many tries. Please try again in ${count} s.`, `试错次数太多,请 ${count} 秒后再试。`);
+}
+export function queueWords(index, t) {
+ const n = index + 1, mod = n % 100;
+ const suffix = mod >= 11 && mod <= 13 ? 'th' : ({1: 'st', 2: 'nd', 3: 'rd'}[n % 10] || 'th');
+ return t(index === 0 ? 'Next' : `${n}${suffix}`, `第 ${n} 个`);
+}
+export function remainingWords(seconds, t) {
+ if (!(seconds > 0)) return '';
+ return seconds >= 60 ? t(`About ${Math.ceil(seconds / 60)} min left`, `还要约 ${Math.ceil(seconds / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟');
+}
+export function mediaPath(value, kind = 'video') {
+ if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) return '';
+ const path = value.split('?')[0];
+ return (kind === 'video' ? path === '/freevideo/agent/chat/video' : path === '/view' || /^\/freevideo\/frame-preview\/[^/]+$/.test(path)) ? value : '';
+}
+export const isIOS = navigator => /iPad|iPhone|iPod/.test(navigator.userAgent || '') || navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1;
+export async function saveVideo(job, {navigator: nav = globalThis.navigator, fetch: get = globalThis.fetch, download, File: FileType = globalThis.File} = {}) {
+ const path = mediaPath(job.play); if (!path) throw new Error('Video unavailable');
+ if (isIOS(nav) && nav.share && FileType) {
+ const response = await get(path, {credentials: 'same-origin'});
+ if (!response.ok) throw new Error('Video unavailable');
+ const file = new FileType([await response.blob()], 'FreeVideo.mp4', {type: 'video/mp4'});
+ if (!nav.canShare || nav.canShare({files: [file]})) {
+ try { await nav.share({files: [file]}); return 'shared'; }
+ catch (error) { if (error.name === 'AbortError') return 'cancelled'; throw error; }
+ }
+ }
+ download(path, 'FreeVideo.mp4'); return 'downloaded';
+}
+
+export function applyPhoneChain(job, chain) {
+ if (!chain) return job;
+ const position = chainPosition(chain), overall = chain.overall || {};
+ return {...job, status: chain.state === 'queued' || chain.waiting === true ? 'waiting' : job.status,
+ progress: {...job.progress, ...(Number.isFinite(overall.fraction) ? {fraction: overall.fraction} : {}),
+ ...(Number.isFinite(overall.remaining_seconds) ? {remaining_seconds: overall.remaining_seconds} : {}),
+ block: {index: position.index + 1, total: position.count}, waiting: chain.waiting === true}};
+}
+export function phonePartWords(job, t) {
+ const b = job.progress?.block;
+ if (!(b?.total > 1)) return '';
+ return job.progress.waiting ? t(`Waiting in queue · Part ${b.index} of ${b.total}`, `正在排队 · 第 ${b.index} 段,共 ${b.total} 段`)
+ : t(`Part ${b.index} of ${b.total}`, `第 ${b.index} 段,共 ${b.total} 段`);
+}
+
+// Ignore heartbeat-only fields when deciding whether a card needs repainting.
+export function phoneJobKey(job = {}) {
+ const p = job.progress || {};
+ return [job.id, job.kind, job.status, job.label, job.play, job.thumbnail, job.width, job.height, job.node,
+ job.seconds, job.finished_at, p.fraction, p.remaining_seconds, p.queue_position, p.block, p.waiting,
+ p.frame_preview?.serial, p.frame_preview?.frames];
+}
+
+export function longGeneration(value) {
+ if (!value || ['succeeded', 'failed', 'cancelled', 'complete'].includes(value.status || value.phase)) return false;
+ if (value.status && value.status !== 'running' || ['queued', 'waiting'].includes(value.phase)) return false;
+ const p = value.progress || value;
+ return Number(p.remaining_seconds ?? p.overall?.remaining_seconds) > 120 || Number(p.elapsed_seconds ?? p.overall?.elapsed_seconds) >= 120;
+}
diff --git a/web/prompt_references.js b/web/prompt_references.js
index dcc4f365..48cd4cd3 100644
--- a/web/prompt_references.js
+++ b/web/prompt_references.js
@@ -38,7 +38,8 @@ export function referenceItems(node) {
const kind = referenceKind(row.file);
if (!kind) throw new Error('invalid_media');
const item = {key: `file:${media.id}:${row.file}:${occurrence}`, kind, file: row.file,
- name: row.cast?.name || row.file.split('/').pop(), assetIndex: rows.indexOf(row)};
+ name: row.cast?.name || row.file.split('/').pop(), assetIndex: rows.indexOf(row),
+ ...(row.sound === false && kind === 'video' ? {sound: false} : {})};
if (row.role === 'reference') refs.push(item);
else if (kind === 'image' && ['first','last'].includes(row.role) && !anchors[row.role]) anchors[row.role] = item;
else throw new Error('invalid_media');
diff --git a/web/qrcodegen.js b/web/qrcodegen.js
new file mode 100644
index 00000000..e69adc58
--- /dev/null
+++ b/web/qrcodegen.js
@@ -0,0 +1,840 @@
+/*
+ * QR Code generator library (compiled from TypeScript)
+ *
+ * Copyright (c) Project Nayuki. (MIT License)
+ * https://www.nayuki.io/page/qr-code-generator-library
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy of
+ * this software and associated documentation files (the "Software"), to deal in
+ * the Software without restriction, including without limitation the rights to
+ * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+ * the Software, and to permit persons to whom the Software is furnished to do so,
+ * subject to the following conditions:
+ * - The above copyright notice and this permission notice shall be included in
+ * all copies or substantial portions of the Software.
+ * - The Software is provided "as is", without warranty of any kind, express or
+ * implied, including but not limited to the warranties of merchantability,
+ * fitness for a particular purpose and noninfringement. In no event shall the
+ * authors or copyright holders be liable for any claim, damages or other
+ * liability, whether in an action of contract, tort or otherwise, arising from,
+ * out of or in connection with the Software or the use or other dealings in the
+ * Software.
+ */
+"use strict";
+var qrcodegen;
+(function (qrcodegen) {
+ /*---- QR Code symbol class ----*/
+ /*
+ * A QR Code symbol, which is a type of two-dimension barcode.
+ * Invented by Denso Wave and described in the ISO/IEC 18004 standard.
+ * Instances of this class represent an immutable square grid of dark and light cells.
+ * The class provides static factory functions to create a QR Code from text or binary data.
+ * The class covers the QR Code Model 2 specification, supporting all versions (sizes)
+ * from 1 to 40, all 4 error correction levels, and 4 character encoding modes.
+ *
+ * Ways to create a QR Code object:
+ * - High level: Take the payload data and call QrCode.encodeText() or QrCode.encodeBinary().
+ * - Mid level: Custom-make the list of segments and call QrCode.encodeSegments().
+ * - Low level: Custom-make the array of data codeword bytes (including
+ * segment headers and final padding, excluding error correction codewords),
+ * supply the appropriate version number, and call the QrCode() constructor.
+ * (Note that all ways require supplying the desired error correction level.)
+ */
+ class QrCode {
+ /*-- Constructor (low level) and fields --*/
+ // Creates a new QR Code with the given version number,
+ // error correction level, data codeword bytes, and mask number.
+ // This is a low-level API that most users should not use directly.
+ // A mid-level API is the encodeSegments() function.
+ constructor(
+ // The version number of this QR Code, which is between 1 and 40 (inclusive).
+ // This determines the size of this barcode.
+ version,
+ // The error correction level used in this QR Code.
+ errorCorrectionLevel, dataCodewords, msk) {
+ this.version = version;
+ this.errorCorrectionLevel = errorCorrectionLevel;
+ // The modules of this QR Code (false = light, true = dark).
+ // Immutable after constructor finishes. Accessed through getModule().
+ this.modules = [];
+ // Indicates function modules that are not subjected to masking. Discarded when constructor finishes.
+ this.isFunction = [];
+ // Check scalar arguments
+ if (version < QrCode.MIN_VERSION || version > QrCode.MAX_VERSION)
+ throw new RangeError("Version value out of range");
+ if (msk < -1 || msk > 7)
+ throw new RangeError("Mask value out of range");
+ this.size = version * 4 + 17;
+ // Initialize both grids to be size*size arrays of Boolean false
+ let row = [];
+ for (let i = 0; i < this.size; i++)
+ row.push(false);
+ for (let i = 0; i < this.size; i++) {
+ this.modules.push(row.slice()); // Initially all light
+ this.isFunction.push(row.slice());
+ }
+ // Compute ECC, draw modules
+ this.drawFunctionPatterns();
+ const allCodewords = this.addEccAndInterleave(dataCodewords);
+ this.drawCodewords(allCodewords);
+ // Do masking
+ if (msk == -1) { // Automatically choose best mask
+ let minPenalty = 1000000000;
+ for (let i = 0; i < 8; i++) {
+ this.applyMask(i);
+ this.drawFormatBits(i);
+ const penalty = this.getPenaltyScore();
+ if (penalty < minPenalty) {
+ msk = i;
+ minPenalty = penalty;
+ }
+ this.applyMask(i); // Undoes the mask due to XOR
+ }
+ }
+ assert(0 <= msk && msk <= 7);
+ this.mask = msk;
+ this.applyMask(msk); // Apply the final choice of mask
+ this.drawFormatBits(msk); // Overwrite old format bits
+ this.isFunction = [];
+ }
+ /*-- Static factory functions (high level) --*/
+ // Returns a QR Code representing the given Unicode text string at the given error correction level.
+ // As a conservative upper bound, this function is guaranteed to succeed for strings that have 738 or fewer
+ // Unicode code points (not UTF-16 code units) if the low error correction level is used. The smallest possible
+ // QR Code version is automatically chosen for the output. The ECC level of the result may be higher than the
+ // ecl argument if it can be done without increasing the version.
+ static encodeText(text, ecl) {
+ const segs = qrcodegen.QrSegment.makeSegments(text);
+ return QrCode.encodeSegments(segs, ecl);
+ }
+ // Returns a QR Code representing the given binary data at the given error correction level.
+ // This function always encodes using the binary segment mode, not any text mode. The maximum number of
+ // bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output.
+ // The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version.
+ static encodeBinary(data, ecl) {
+ const seg = qrcodegen.QrSegment.makeBytes(data);
+ return QrCode.encodeSegments([seg], ecl);
+ }
+ /*-- Static factory functions (mid level) --*/
+ // Returns a QR Code representing the given segments with the given encoding parameters.
+ // The smallest possible QR Code version within the given range is automatically
+ // chosen for the output. Iff boostEcl is true, then the ECC level of the result
+ // may be higher than the ecl argument if it can be done without increasing the
+ // version. The mask number is either between 0 to 7 (inclusive) to force that
+ // mask, or -1 to automatically choose an appropriate mask (which may be slow).
+ // This function allows the user to create a custom sequence of segments that switches
+ // between modes (such as alphanumeric and byte) to encode text in less space.
+ // This is a mid-level API; the high-level API is encodeText() and encodeBinary().
+ static encodeSegments(segs, ecl, minVersion = 1, maxVersion = 40, mask = -1, boostEcl = true) {
+ if (!(QrCode.MIN_VERSION <= minVersion && minVersion <= maxVersion && maxVersion <= QrCode.MAX_VERSION)
+ || mask < -1 || mask > 7)
+ throw new RangeError("Invalid value");
+ // Find the minimal version number to use
+ let version;
+ let dataUsedBits;
+ for (version = minVersion;; version++) {
+ const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8; // Number of data bits available
+ const usedBits = QrSegment.getTotalBits(segs, version);
+ if (usedBits <= dataCapacityBits) {
+ dataUsedBits = usedBits;
+ break; // This version number is found to be suitable
+ }
+ if (version >= maxVersion) // All versions in the range could not fit the given data
+ throw new RangeError("Data too long");
+ }
+ // Increase the error correction level while the data still fits in the current version number
+ for (const newEcl of [QrCode.Ecc.MEDIUM, QrCode.Ecc.QUARTILE, QrCode.Ecc.HIGH]) { // From low to high
+ if (boostEcl && dataUsedBits <= QrCode.getNumDataCodewords(version, newEcl) * 8)
+ ecl = newEcl;
+ }
+ // Concatenate all segments to create the data bit string
+ let bb = [];
+ for (const seg of segs) {
+ appendBits(seg.mode.modeBits, 4, bb);
+ appendBits(seg.numChars, seg.mode.numCharCountBits(version), bb);
+ for (const b of seg.getData())
+ bb.push(b);
+ }
+ assert(bb.length == dataUsedBits);
+ // Add terminator and pad up to a byte if applicable
+ const dataCapacityBits = QrCode.getNumDataCodewords(version, ecl) * 8;
+ assert(bb.length <= dataCapacityBits);
+ appendBits(0, Math.min(4, dataCapacityBits - bb.length), bb);
+ appendBits(0, (8 - bb.length % 8) % 8, bb);
+ assert(bb.length % 8 == 0);
+ // Pad with alternating bytes until data capacity is reached
+ for (let padByte = 0xEC; bb.length < dataCapacityBits; padByte ^= 0xEC ^ 0x11)
+ appendBits(padByte, 8, bb);
+ // Pack bits into bytes in big endian
+ let dataCodewords = [];
+ while (dataCodewords.length * 8 < bb.length)
+ dataCodewords.push(0);
+ bb.forEach((b, i) => dataCodewords[i >>> 3] |= b << (7 - (i & 7)));
+ // Create the QR Code object
+ return new QrCode(version, ecl, dataCodewords, mask);
+ }
+ /*-- Accessor methods --*/
+ // Returns the color of the module (pixel) at the given coordinates, which is false
+ // for light or true for dark. The top left corner has the coordinates (x=0, y=0).
+ // If the given coordinates are out of bounds, then false (light) is returned.
+ getModule(x, y) {
+ return 0 <= x && x < this.size && 0 <= y && y < this.size && this.modules[y][x];
+ }
+ /*-- Private helper methods for constructor: Drawing function modules --*/
+ // Reads this object's version field, and draws and marks all function modules.
+ drawFunctionPatterns() {
+ // Draw horizontal and vertical timing patterns
+ for (let i = 0; i < this.size; i++) {
+ this.setFunctionModule(6, i, i % 2 == 0);
+ this.setFunctionModule(i, 6, i % 2 == 0);
+ }
+ // Draw 3 finder patterns (all corners except bottom right; overwrites some timing modules)
+ this.drawFinderPattern(3, 3);
+ this.drawFinderPattern(this.size - 4, 3);
+ this.drawFinderPattern(3, this.size - 4);
+ // Draw numerous alignment patterns
+ const alignPatPos = this.getAlignmentPatternPositions();
+ const numAlign = alignPatPos.length;
+ for (let i = 0; i < numAlign; i++) {
+ for (let j = 0; j < numAlign; j++) {
+ // Don't draw on the three finder corners
+ if (!(i == 0 && j == 0 || i == 0 && j == numAlign - 1 || i == numAlign - 1 && j == 0))
+ this.drawAlignmentPattern(alignPatPos[i], alignPatPos[j]);
+ }
+ }
+ // Draw configuration data
+ this.drawFormatBits(0); // Dummy mask value; overwritten later in the constructor
+ this.drawVersion();
+ }
+ // Draws two copies of the format bits (with its own error correction code)
+ // based on the given mask and this object's error correction level field.
+ drawFormatBits(mask) {
+ // Calculate error correction code and pack bits
+ const data = this.errorCorrectionLevel.formatBits << 3 | mask; // errCorrLvl is uint2, mask is uint3
+ let rem = data;
+ for (let i = 0; i < 10; i++)
+ rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
+ const bits = (data << 10 | rem) ^ 0x5412; // uint15
+ assert(bits >>> 15 == 0);
+ // Draw first copy
+ for (let i = 0; i <= 5; i++)
+ this.setFunctionModule(8, i, getBit(bits, i));
+ this.setFunctionModule(8, 7, getBit(bits, 6));
+ this.setFunctionModule(8, 8, getBit(bits, 7));
+ this.setFunctionModule(7, 8, getBit(bits, 8));
+ for (let i = 9; i < 15; i++)
+ this.setFunctionModule(14 - i, 8, getBit(bits, i));
+ // Draw second copy
+ for (let i = 0; i < 8; i++)
+ this.setFunctionModule(this.size - 1 - i, 8, getBit(bits, i));
+ for (let i = 8; i < 15; i++)
+ this.setFunctionModule(8, this.size - 15 + i, getBit(bits, i));
+ this.setFunctionModule(8, this.size - 8, true); // Always dark
+ }
+ // Draws two copies of the version bits (with its own error correction code),
+ // based on this object's version field, iff 7 <= version <= 40.
+ drawVersion() {
+ if (this.version < 7)
+ return;
+ // Calculate error correction code and pack bits
+ let rem = this.version; // version is uint6, in the range [7, 40]
+ for (let i = 0; i < 12; i++)
+ rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
+ const bits = this.version << 12 | rem; // uint18
+ assert(bits >>> 18 == 0);
+ // Draw two copies
+ for (let i = 0; i < 18; i++) {
+ const color = getBit(bits, i);
+ const a = this.size - 11 + i % 3;
+ const b = Math.floor(i / 3);
+ this.setFunctionModule(a, b, color);
+ this.setFunctionModule(b, a, color);
+ }
+ }
+ // Draws a 9*9 finder pattern including the border separator,
+ // with the center module at (x, y). Modules can be out of bounds.
+ drawFinderPattern(x, y) {
+ for (let dy = -4; dy <= 4; dy++) {
+ for (let dx = -4; dx <= 4; dx++) {
+ const dist = Math.max(Math.abs(dx), Math.abs(dy)); // Chebyshev/infinity norm
+ const xx = x + dx;
+ const yy = y + dy;
+ if (0 <= xx && xx < this.size && 0 <= yy && yy < this.size)
+ this.setFunctionModule(xx, yy, dist != 2 && dist != 4);
+ }
+ }
+ }
+ // Draws a 5*5 alignment pattern, with the center module
+ // at (x, y). All modules must be in bounds.
+ drawAlignmentPattern(x, y) {
+ for (let dy = -2; dy <= 2; dy++) {
+ for (let dx = -2; dx <= 2; dx++)
+ this.setFunctionModule(x + dx, y + dy, Math.max(Math.abs(dx), Math.abs(dy)) != 1);
+ }
+ }
+ // Sets the color of a module and marks it as a function module.
+ // Only used by the constructor. Coordinates must be in bounds.
+ setFunctionModule(x, y, isDark) {
+ this.modules[y][x] = isDark;
+ this.isFunction[y][x] = true;
+ }
+ /*-- Private helper methods for constructor: Codewords and masking --*/
+ // Returns a new byte string representing the given data with the appropriate error correction
+ // codewords appended to it, based on this object's version and error correction level.
+ addEccAndInterleave(data) {
+ const ver = this.version;
+ const ecl = this.errorCorrectionLevel;
+ if (data.length != QrCode.getNumDataCodewords(ver, ecl))
+ throw new RangeError("Invalid argument");
+ // Calculate parameter numbers
+ const numBlocks = QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
+ const blockEccLen = QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver];
+ const rawCodewords = Math.floor(QrCode.getNumRawDataModules(ver) / 8);
+ const numShortBlocks = numBlocks - rawCodewords % numBlocks;
+ const shortBlockLen = Math.floor(rawCodewords / numBlocks);
+ // Split data into blocks and append ECC to each block
+ let blocks = [];
+ const rsDiv = QrCode.reedSolomonComputeDivisor(blockEccLen);
+ for (let i = 0, k = 0; i < numBlocks; i++) {
+ let dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
+ k += dat.length;
+ const ecc = QrCode.reedSolomonComputeRemainder(dat, rsDiv);
+ if (i < numShortBlocks)
+ dat.push(0);
+ blocks.push(dat.concat(ecc));
+ }
+ // Interleave (not concatenate) the bytes from every block into a single sequence
+ let result = [];
+ for (let i = 0; i < blocks[0].length; i++) {
+ blocks.forEach((block, j) => {
+ // Skip the padding byte in short blocks
+ if (i != shortBlockLen - blockEccLen || j >= numShortBlocks)
+ result.push(block[i]);
+ });
+ }
+ assert(result.length == rawCodewords);
+ return result;
+ }
+ // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire
+ // data area of this QR Code. Function modules need to be marked off before this is called.
+ drawCodewords(data) {
+ if (data.length != Math.floor(QrCode.getNumRawDataModules(this.version) / 8))
+ throw new RangeError("Invalid argument");
+ let i = 0; // Bit index into the data
+ // Do the funny zigzag scan
+ for (let right = this.size - 1; right >= 1; right -= 2) { // Index of right column in each column pair
+ if (right == 6)
+ right = 5;
+ for (let vert = 0; vert < this.size; vert++) { // Vertical counter
+ for (let j = 0; j < 2; j++) {
+ const x = right - j; // Actual x coordinate
+ const upward = ((right + 1) & 2) == 0;
+ const y = upward ? this.size - 1 - vert : vert; // Actual y coordinate
+ if (!this.isFunction[y][x] && i < data.length * 8) {
+ this.modules[y][x] = getBit(data[i >>> 3], 7 - (i & 7));
+ i++;
+ }
+ // If this QR Code has any remainder bits (0 to 7), they were assigned as
+ // 0/false/light by the constructor and are left unchanged by this method
+ }
+ }
+ }
+ assert(i == data.length * 8);
+ }
+ // XORs the codeword modules in this QR Code with the given mask pattern.
+ // The function modules must be marked and the codeword bits must be drawn
+ // before masking. Due to the arithmetic of XOR, calling applyMask() with
+ // the same mask value a second time will undo the mask. A final well-formed
+ // QR Code needs exactly one (not zero, two, etc.) mask applied.
+ applyMask(mask) {
+ if (mask < 0 || mask > 7)
+ throw new RangeError("Mask value out of range");
+ for (let y = 0; y < this.size; y++) {
+ for (let x = 0; x < this.size; x++) {
+ let invert;
+ switch (mask) {
+ case 0:
+ invert = (x + y) % 2 == 0;
+ break;
+ case 1:
+ invert = y % 2 == 0;
+ break;
+ case 2:
+ invert = x % 3 == 0;
+ break;
+ case 3:
+ invert = (x + y) % 3 == 0;
+ break;
+ case 4:
+ invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 == 0;
+ break;
+ case 5:
+ invert = x * y % 2 + x * y % 3 == 0;
+ break;
+ case 6:
+ invert = (x * y % 2 + x * y % 3) % 2 == 0;
+ break;
+ case 7:
+ invert = ((x + y) % 2 + x * y % 3) % 2 == 0;
+ break;
+ default: throw new Error("Unreachable");
+ }
+ if (!this.isFunction[y][x] && invert)
+ this.modules[y][x] = !this.modules[y][x];
+ }
+ }
+ }
+ // Calculates and returns the penalty score based on state of this QR Code's current modules.
+ // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score.
+ getPenaltyScore() {
+ let result = 0;
+ // Adjacent modules in row having same color, and finder-like patterns
+ for (let y = 0; y < this.size; y++) {
+ let runColor = false;
+ let runX = 0;
+ let runHistory = [0, 0, 0, 0, 0, 0, 0];
+ for (let x = 0; x < this.size; x++) {
+ if (this.modules[y][x] == runColor) {
+ runX++;
+ if (runX == 5)
+ result += QrCode.PENALTY_N1;
+ else if (runX > 5)
+ result++;
+ }
+ else {
+ this.finderPenaltyAddHistory(runX, runHistory);
+ if (!runColor)
+ result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
+ runColor = this.modules[y][x];
+ runX = 1;
+ }
+ }
+ result += this.finderPenaltyTerminateAndCount(runColor, runX, runHistory) * QrCode.PENALTY_N3;
+ }
+ // Adjacent modules in column having same color, and finder-like patterns
+ for (let x = 0; x < this.size; x++) {
+ let runColor = false;
+ let runY = 0;
+ let runHistory = [0, 0, 0, 0, 0, 0, 0];
+ for (let y = 0; y < this.size; y++) {
+ if (this.modules[y][x] == runColor) {
+ runY++;
+ if (runY == 5)
+ result += QrCode.PENALTY_N1;
+ else if (runY > 5)
+ result++;
+ }
+ else {
+ this.finderPenaltyAddHistory(runY, runHistory);
+ if (!runColor)
+ result += this.finderPenaltyCountPatterns(runHistory) * QrCode.PENALTY_N3;
+ runColor = this.modules[y][x];
+ runY = 1;
+ }
+ }
+ result += this.finderPenaltyTerminateAndCount(runColor, runY, runHistory) * QrCode.PENALTY_N3;
+ }
+ // 2*2 blocks of modules having same color
+ for (let y = 0; y < this.size - 1; y++) {
+ for (let x = 0; x < this.size - 1; x++) {
+ const color = this.modules[y][x];
+ if (color == this.modules[y][x + 1] &&
+ color == this.modules[y + 1][x] &&
+ color == this.modules[y + 1][x + 1])
+ result += QrCode.PENALTY_N2;
+ }
+ }
+ // Balance of dark and light modules
+ let dark = 0;
+ for (const row of this.modules)
+ dark = row.reduce((sum, color) => sum + (color ? 1 : 0), dark);
+ const total = this.size * this.size; // Note that size is odd, so dark/total != 1/2
+ // Compute the smallest integer k >= 0 such that (45-5k)% <= dark/total <= (55+5k)%
+ const k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
+ assert(0 <= k && k <= 9);
+ result += k * QrCode.PENALTY_N4;
+ assert(0 <= result && result <= 2568888); // Non-tight upper bound based on default values of PENALTY_N1, ..., N4
+ return result;
+ }
+ /*-- Private helper functions --*/
+ // Returns an ascending list of positions of alignment patterns for this version number.
+ // Each position is in the range [0,177), and are used on both the x and y axes.
+ // This could be implemented as lookup table of 40 variable-length lists of integers.
+ getAlignmentPatternPositions() {
+ if (this.version == 1)
+ return [];
+ else {
+ const numAlign = Math.floor(this.version / 7) + 2;
+ const step = (this.version == 32) ? 26 :
+ Math.ceil((this.version * 4 + 4) / (numAlign * 2 - 2)) * 2;
+ let result = [6];
+ for (let pos = this.size - 7; result.length < numAlign; pos -= step)
+ result.splice(1, 0, pos);
+ return result;
+ }
+ }
+ // Returns the number of data bits that can be stored in a QR Code of the given version number, after
+ // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8.
+ // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table.
+ static getNumRawDataModules(ver) {
+ if (ver < QrCode.MIN_VERSION || ver > QrCode.MAX_VERSION)
+ throw new RangeError("Version number out of range");
+ let result = (16 * ver + 128) * ver + 64;
+ if (ver >= 2) {
+ const numAlign = Math.floor(ver / 7) + 2;
+ result -= (25 * numAlign - 10) * numAlign - 55;
+ if (ver >= 7)
+ result -= 36;
+ }
+ assert(208 <= result && result <= 29648);
+ return result;
+ }
+ // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any
+ // QR Code of the given version number and error correction level, with remainder bits discarded.
+ // This stateless pure function could be implemented as a (40*4)-cell lookup table.
+ static getNumDataCodewords(ver, ecl) {
+ return Math.floor(QrCode.getNumRawDataModules(ver) / 8) -
+ QrCode.ECC_CODEWORDS_PER_BLOCK[ecl.ordinal][ver] *
+ QrCode.NUM_ERROR_CORRECTION_BLOCKS[ecl.ordinal][ver];
+ }
+ // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be
+ // implemented as a lookup table over all possible parameter values, instead of as an algorithm.
+ static reedSolomonComputeDivisor(degree) {
+ if (degree < 1 || degree > 255)
+ throw new RangeError("Degree out of range");
+ // Polynomial coefficients are stored from highest to lowest power, excluding the leading term which is always 1.
+ // For example the polynomial x^3 + 255x^2 + 8x + 93 is stored as the uint8 array [255, 8, 93].
+ let result = [];
+ for (let i = 0; i < degree - 1; i++)
+ result.push(0);
+ result.push(1); // Start off with the monomial x^0
+ // Compute the product polynomial (x - r^0) * (x - r^1) * (x - r^2) * ... * (x - r^{degree-1}),
+ // and drop the highest monomial term which is always 1x^degree.
+ // Note that r = 0x02, which is a generator element of this field GF(2^8/0x11D).
+ let root = 1;
+ for (let i = 0; i < degree; i++) {
+ // Multiply the current product by (x - r^i)
+ for (let j = 0; j < result.length; j++) {
+ result[j] = QrCode.reedSolomonMultiply(result[j], root);
+ if (j + 1 < result.length)
+ result[j] ^= result[j + 1];
+ }
+ root = QrCode.reedSolomonMultiply(root, 0x02);
+ }
+ return result;
+ }
+ // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials.
+ static reedSolomonComputeRemainder(data, divisor) {
+ let result = divisor.map(_ => 0);
+ for (const b of data) { // Polynomial division
+ const factor = b ^ result.shift();
+ result.push(0);
+ divisor.forEach((coef, i) => result[i] ^= QrCode.reedSolomonMultiply(coef, factor));
+ }
+ return result;
+ }
+ // Returns the product of the two given field elements modulo GF(2^8/0x11D). The arguments and result
+ // are unsigned 8-bit integers. This could be implemented as a lookup table of 256*256 entries of uint8.
+ static reedSolomonMultiply(x, y) {
+ if (x >>> 8 != 0 || y >>> 8 != 0)
+ throw new RangeError("Byte out of range");
+ // Russian peasant multiplication
+ let z = 0;
+ for (let i = 7; i >= 0; i--) {
+ z = (z << 1) ^ ((z >>> 7) * 0x11D);
+ z ^= ((y >>> i) & 1) * x;
+ }
+ assert(z >>> 8 == 0);
+ return z;
+ }
+ // Can only be called immediately after a light run is added, and
+ // returns either 0, 1, or 2. A helper function for getPenaltyScore().
+ finderPenaltyCountPatterns(runHistory) {
+ const n = runHistory[1];
+ assert(n <= this.size * 3);
+ const core = n > 0 && runHistory[2] == n && runHistory[3] == n * 3 && runHistory[4] == n && runHistory[5] == n;
+ return (core && runHistory[0] >= n * 4 && runHistory[6] >= n ? 1 : 0)
+ + (core && runHistory[6] >= n * 4 && runHistory[0] >= n ? 1 : 0);
+ }
+ // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore().
+ finderPenaltyTerminateAndCount(currentRunColor, currentRunLength, runHistory) {
+ if (currentRunColor) { // Terminate dark run
+ this.finderPenaltyAddHistory(currentRunLength, runHistory);
+ currentRunLength = 0;
+ }
+ currentRunLength += this.size; // Add light border to final run
+ this.finderPenaltyAddHistory(currentRunLength, runHistory);
+ return this.finderPenaltyCountPatterns(runHistory);
+ }
+ // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore().
+ finderPenaltyAddHistory(currentRunLength, runHistory) {
+ if (runHistory[0] == 0)
+ currentRunLength += this.size; // Add light border to initial run
+ runHistory.pop();
+ runHistory.unshift(currentRunLength);
+ }
+ }
+ /*-- Constants and tables --*/
+ // The minimum version number supported in the QR Code Model 2 standard.
+ QrCode.MIN_VERSION = 1;
+ // The maximum version number supported in the QR Code Model 2 standard.
+ QrCode.MAX_VERSION = 40;
+ // For use in getPenaltyScore(), when evaluating which mask is best.
+ QrCode.PENALTY_N1 = 3;
+ QrCode.PENALTY_N2 = 3;
+ QrCode.PENALTY_N3 = 40;
+ QrCode.PENALTY_N4 = 10;
+ QrCode.ECC_CODEWORDS_PER_BLOCK = [
+ // Version: (note that index 0 is for padding, and is set to an illegal value)
+ //0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
+ [-1, 7, 10, 15, 20, 26, 18, 20, 24, 30, 18, 20, 24, 26, 30, 22, 24, 28, 30, 28, 28, 28, 28, 30, 30, 26, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
+ [-1, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28],
+ [-1, 13, 22, 18, 26, 18, 24, 18, 22, 20, 24, 28, 26, 24, 20, 30, 24, 28, 28, 26, 30, 28, 30, 30, 30, 30, 28, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30],
+ [-1, 17, 28, 22, 16, 22, 28, 26, 26, 24, 28, 24, 28, 22, 24, 24, 30, 28, 28, 26, 28, 30, 24, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30, 30], // High
+ ];
+ QrCode.NUM_ERROR_CORRECTION_BLOCKS = [
+ // Version: (note that index 0 is for padding, and is set to an illegal value)
+ //0, 1, 2, 3, 4, 5, 6, 7, 8, 9,10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40 Error correction level
+ [-1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 4, 4, 4, 4, 4, 6, 6, 6, 6, 7, 8, 8, 9, 9, 10, 12, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 24, 25],
+ [-1, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49],
+ [-1, 1, 1, 2, 2, 4, 4, 6, 6, 8, 8, 8, 10, 12, 16, 12, 17, 16, 18, 21, 20, 23, 23, 25, 27, 29, 34, 34, 35, 38, 40, 43, 45, 48, 51, 53, 56, 59, 62, 65, 68],
+ [-1, 1, 1, 2, 4, 4, 4, 5, 6, 8, 8, 11, 11, 16, 16, 18, 16, 19, 21, 25, 25, 25, 34, 30, 32, 35, 37, 40, 42, 45, 48, 51, 54, 57, 60, 63, 66, 70, 74, 77, 81], // High
+ ];
+ qrcodegen.QrCode = QrCode;
+ // Appends the given number of low-order bits of the given value
+ // to the given buffer. Requires 0 <= len <= 31 and 0 <= val < 2^len.
+ function appendBits(val, len, bb) {
+ if (len < 0 || len > 31 || val >>> len != 0)
+ throw new RangeError("Value out of range");
+ for (let i = len - 1; i >= 0; i--) // Append bit by bit
+ bb.push((val >>> i) & 1);
+ }
+ // Returns true iff the i'th bit of x is set to 1.
+ function getBit(x, i) {
+ return ((x >>> i) & 1) != 0;
+ }
+ // Throws an exception if the given condition is false.
+ function assert(cond) {
+ if (!cond)
+ throw new Error("Assertion error");
+ }
+ /*---- Data segment class ----*/
+ /*
+ * A segment of character/binary/control data in a QR Code symbol.
+ * Instances of this class are immutable.
+ * The mid-level way to create a segment is to take the payload data
+ * and call a static factory function such as QrSegment.makeNumeric().
+ * The low-level way to create a segment is to custom-make the bit buffer
+ * and call the QrSegment() constructor with appropriate values.
+ * This segment class imposes no length restrictions, but QR Codes have restrictions.
+ * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data.
+ * Any segment longer than this is meaningless for the purpose of generating QR Codes.
+ */
+ class QrSegment {
+ /*-- Constructor (low level) and fields --*/
+ // Creates a new QR Code segment with the given attributes and data.
+ // The character count (numChars) must agree with the mode and the bit buffer length,
+ // but the constraint isn't checked. The given bit buffer is cloned and stored.
+ constructor(
+ // The mode indicator of this segment.
+ mode,
+ // The length of this segment's unencoded data. Measured in characters for
+ // numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode.
+ // Always zero or positive. Not the same as the data's bit length.
+ numChars,
+ // The data bits of this segment. Accessed through getData().
+ bitData) {
+ this.mode = mode;
+ this.numChars = numChars;
+ this.bitData = bitData;
+ if (numChars < 0)
+ throw new RangeError("Invalid argument");
+ this.bitData = bitData.slice(); // Make defensive copy
+ }
+ /*-- Static factory functions (mid level) --*/
+ // Returns a segment representing the given binary data encoded in
+ // byte mode. All input byte arrays are acceptable. Any text string
+ // can be converted to UTF-8 bytes and encoded as a byte mode segment.
+ static makeBytes(data) {
+ let bb = [];
+ for (const b of data)
+ appendBits(b, 8, bb);
+ return new QrSegment(QrSegment.Mode.BYTE, data.length, bb);
+ }
+ // Returns a segment representing the given string of decimal digits encoded in numeric mode.
+ static makeNumeric(digits) {
+ if (!QrSegment.isNumeric(digits))
+ throw new RangeError("String contains non-numeric characters");
+ let bb = [];
+ for (let i = 0; i < digits.length;) { // Consume up to 3 digits per iteration
+ const n = Math.min(digits.length - i, 3);
+ appendBits(parseInt(digits.substring(i, i + n), 10), n * 3 + 1, bb);
+ i += n;
+ }
+ return new QrSegment(QrSegment.Mode.NUMERIC, digits.length, bb);
+ }
+ // Returns a segment representing the given text string encoded in alphanumeric mode.
+ // The characters allowed are: 0 to 9, A to Z (uppercase only), space,
+ // dollar, percent, asterisk, plus, hyphen, period, slash, colon.
+ static makeAlphanumeric(text) {
+ if (!QrSegment.isAlphanumeric(text))
+ throw new RangeError("String contains unencodable characters in alphanumeric mode");
+ let bb = [];
+ let i;
+ for (i = 0; i + 2 <= text.length; i += 2) { // Process groups of 2
+ let temp = QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)) * 45;
+ temp += QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i + 1));
+ appendBits(temp, 11, bb);
+ }
+ if (i < text.length) // 1 character remaining
+ appendBits(QrSegment.ALPHANUMERIC_CHARSET.indexOf(text.charAt(i)), 6, bb);
+ return new QrSegment(QrSegment.Mode.ALPHANUMERIC, text.length, bb);
+ }
+ // Returns a new mutable list of zero or more segments to represent the given Unicode text string.
+ // The result may use various segment modes and switch modes to optimize the length of the bit stream.
+ static makeSegments(text) {
+ // Select the most efficient segment encoding automatically
+ if (text == "")
+ return [];
+ else if (QrSegment.isNumeric(text))
+ return [QrSegment.makeNumeric(text)];
+ else if (QrSegment.isAlphanumeric(text))
+ return [QrSegment.makeAlphanumeric(text)];
+ else
+ return [QrSegment.makeBytes(QrSegment.toUtf8ByteArray(text))];
+ }
+ // Returns a segment representing an Extended Channel Interpretation
+ // (ECI) designator with the given assignment value.
+ static makeEci(assignVal) {
+ let bb = [];
+ if (assignVal < 0)
+ throw new RangeError("ECI assignment value out of range");
+ else if (assignVal < (1 << 7))
+ appendBits(assignVal, 8, bb);
+ else if (assignVal < (1 << 14)) {
+ appendBits(0b10, 2, bb);
+ appendBits(assignVal, 14, bb);
+ }
+ else if (assignVal < 1000000) {
+ appendBits(0b110, 3, bb);
+ appendBits(assignVal, 21, bb);
+ }
+ else
+ throw new RangeError("ECI assignment value out of range");
+ return new QrSegment(QrSegment.Mode.ECI, 0, bb);
+ }
+ // Tests whether the given string can be encoded as a segment in numeric mode.
+ // A string is encodable iff each character is in the range 0 to 9.
+ static isNumeric(text) {
+ return QrSegment.NUMERIC_REGEX.test(text);
+ }
+ // Tests whether the given string can be encoded as a segment in alphanumeric mode.
+ // A string is encodable iff each character is in the following set: 0 to 9, A to Z
+ // (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon.
+ static isAlphanumeric(text) {
+ return QrSegment.ALPHANUMERIC_REGEX.test(text);
+ }
+ /*-- Methods --*/
+ // Returns a new copy of the data bits of this segment.
+ getData() {
+ return this.bitData.slice(); // Make defensive copy
+ }
+ // (Package-private) Calculates and returns the number of bits needed to encode the given segments at
+ // the given version. The result is infinity if a segment has too many characters to fit its length field.
+ static getTotalBits(segs, version) {
+ let result = 0;
+ for (const seg of segs) {
+ const ccbits = seg.mode.numCharCountBits(version);
+ if (seg.numChars >= (1 << ccbits))
+ return Infinity; // The segment's length doesn't fit the field's bit width
+ result += 4 + ccbits + seg.bitData.length;
+ }
+ return result;
+ }
+ // Returns a new array of bytes representing the given string encoded in UTF-8.
+ static toUtf8ByteArray(str) {
+ str = encodeURI(str);
+ let result = [];
+ for (let i = 0; i < str.length; i++) {
+ if (str.charAt(i) != "%")
+ result.push(str.charCodeAt(i));
+ else {
+ result.push(parseInt(str.substring(i + 1, i + 3), 16));
+ i += 2;
+ }
+ }
+ return result;
+ }
+ }
+ /*-- Constants --*/
+ // Describes precisely all strings that are encodable in numeric mode.
+ QrSegment.NUMERIC_REGEX = /^[0-9]*$/;
+ // Describes precisely all strings that are encodable in alphanumeric mode.
+ QrSegment.ALPHANUMERIC_REGEX = /^[A-Z0-9 $%*+.\/:-]*$/;
+ // The set of all legal characters in alphanumeric mode,
+ // where each character value maps to the index in the string.
+ QrSegment.ALPHANUMERIC_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:";
+ qrcodegen.QrSegment = QrSegment;
+})(qrcodegen || (qrcodegen = {}));
+/*---- Public helper enumeration ----*/
+(function (qrcodegen) {
+ var QrCode;
+ (function (QrCode) {
+ /*
+ * The error correction level in a QR Code symbol. Immutable.
+ */
+ class Ecc {
+ /*-- Constructor and fields --*/
+ constructor(
+ // In the range 0 to 3 (unsigned 2-bit integer).
+ ordinal,
+ // (Package-private) In the range 0 to 3 (unsigned 2-bit integer).
+ formatBits) {
+ this.ordinal = ordinal;
+ this.formatBits = formatBits;
+ }
+ }
+ /*-- Constants --*/
+ Ecc.LOW = new Ecc(0, 1); // The QR Code can tolerate about 7% erroneous codewords
+ Ecc.MEDIUM = new Ecc(1, 0); // The QR Code can tolerate about 15% erroneous codewords
+ Ecc.QUARTILE = new Ecc(2, 3); // The QR Code can tolerate about 25% erroneous codewords
+ Ecc.HIGH = new Ecc(3, 2); // The QR Code can tolerate about 30% erroneous codewords
+ QrCode.Ecc = Ecc;
+ })(QrCode = qrcodegen.QrCode || (qrcodegen.QrCode = {}));
+})(qrcodegen || (qrcodegen = {}));
+/*---- Public helper enumeration ----*/
+(function (qrcodegen) {
+ var QrSegment;
+ (function (QrSegment) {
+ /*
+ * Describes how a segment's data bits are interpreted. Immutable.
+ */
+ class Mode {
+ /*-- Constructor and fields --*/
+ constructor(
+ // The mode indicator bits, which is a uint4 value (range 0 to 15).
+ modeBits,
+ // Number of character count bits for three different version ranges.
+ numBitsCharCount) {
+ this.modeBits = modeBits;
+ this.numBitsCharCount = numBitsCharCount;
+ }
+ /*-- Method --*/
+ // (Package-private) Returns the bit width of the character count field for a segment in
+ // this mode in a QR Code at the given version number. The result is in the range [0, 16].
+ numCharCountBits(ver) {
+ return this.numBitsCharCount[Math.floor((ver + 7) / 17)];
+ }
+ }
+ /*-- Constants --*/
+ Mode.NUMERIC = new Mode(0x1, [10, 12, 14]);
+ Mode.ALPHANUMERIC = new Mode(0x2, [9, 11, 13]);
+ Mode.BYTE = new Mode(0x4, [8, 16, 16]);
+ Mode.KANJI = new Mode(0x8, [8, 10, 12]);
+ Mode.ECI = new Mode(0x7, [0, 0, 0]);
+ QrSegment.Mode = Mode;
+ })(QrSegment = qrcodegen.QrSegment || (qrcodegen.QrSegment = {}));
+})(qrcodegen || (qrcodegen = {}));
+
+// FreeVideo ES module adapter.
+export {qrcodegen};
diff --git a/web/request_assets.js b/web/request_assets.js
index 31eec2c4..8aa02df6 100644
--- a/web/request_assets.js
+++ b/web/request_assets.js
@@ -27,7 +27,7 @@ export function enabledLoras(start, {upstream, widget, value}) {
// Reference files as `kind:file`, so generation's own rule can tell whether a video has audio.
// A reference that is not a file in the input folder makes the task unknowable here: send none.
export function referenceFiles(refs) {
- return refs.length && refs.every(r => typeof r.file === 'string' && r.kind) ? refs.map(r => `${r.kind}:${r.file}`) : [];
+ return refs.length && refs.every(r => typeof r.file === 'string' && r.kind) ? refs.map(r => `${r.kind === 'video' && r.sound === false ? 'video-silent' : r.kind}:${r.file}`) : [];
}
// Keep the extension and the end of a long file name; shorten the middle.
diff --git a/web/studio.css b/web/studio.css
index 3eb8aefe..23089455 100644
--- a/web/studio.css
+++ b/web/studio.css
@@ -203,22 +203,31 @@
.fv-studio .fv-first-frame input:checked{background:var(--fv-accent,#6db8fa)}
.fv-studio .fv-first-frame input:checked::before{transform:translateX(10px)}
.fv-studio .fv-first-frame:has(input:disabled){opacity:.4}
-.fv-studio .fv-chain-parts{display:grid;gap:2px;margin-top:-7px}
+/* Lines share the list's columns, so names, lengths and words line up whatever each line holds. */
+.fv-studio .fv-chain-parts{display:grid;grid-template-columns:auto auto minmax(0,1fr) auto auto;gap:2px 14px;margin-top:-7px}
.fv-studio .fv-chain-parts[hidden]{display:none}
-.fv-studio .fv-chain-part{display:grid;grid-template-columns:auto minmax(0,1fr) auto;align-items:start;gap:14px;padding:4px 4px 4px 12px;border-radius:var(--fv-r-sm);font-size:var(--fv-micro);line-height:20px}
-.fv-studio .fv-chain-part-name{padding-top:7px;color:var(--fv-muted);white-space:nowrap}
-.fv-studio .fv-chain-part .fv-chain-part-text{display:block;min-width:0;min-height:32px;padding:6px 10px;border-color:transparent;background:transparent;color:var(--fv-ink);font-weight:var(--fv-regular);text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.fv-studio .fv-chain-part{display:grid;grid-column:1/-1;grid-template-columns:subgrid;align-items:start;padding:4px 4px 4px 12px;border-radius:var(--fv-r-sm);font-size:var(--fv-micro);line-height:20px}
+.fv-studio .fv-chain-part-name{grid-column:1;grid-row:1;padding-top:7px;color:var(--fv-muted);white-space:nowrap}
+.fv-studio .fv-chain-part .fv-chain-part-meta{grid-column:2;grid-row:1;align-self:start;justify-self:start;display:block;min-height:0;padding:7px 0 0;border:0;border-radius:2px;background:none;color:var(--fv-muted);font-weight:inherit;white-space:nowrap;text-align:left}
+.fv-studio .fv-chain-part .fv-chain-part-meta:disabled{opacity:1}
+.fv-studio .fv-chain-part .fv-chain-part-meta:not(:disabled){text-decoration:underline;text-decoration-color:var(--fv-border);text-underline-offset:3px}
+.fv-studio .fv-chain-part .fv-chain-part-meta:not(:disabled):hover{background:none;color:var(--fv-ink);text-decoration-color:currentColor}
+/* Sized like the Creations button above the preview, so it reads as a button and not as a state. */
+.fv-studio .fv-chain-part .fv-chain-part-redo{grid-column:5;grid-row:1;min-height:var(--fv-h-sm);padding:5px 12px;white-space:nowrap}
+.fv-studio .fv-chain-part .fv-chain-part-redo[hidden]{display:none}
+.fv-studio .fv-chain-part .fv-chain-part-text{grid-column:3;grid-row:1;display:block;min-width:0;min-height:32px;padding:6px 10px;border-color:transparent;background:transparent;color:var(--fv-ink);font-weight:var(--fv-regular);text-align:left;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.fv-studio .fv-chain-part:has(.fv-chain-part-text:not(:disabled):not([hidden])):hover{background:var(--fv-hover);cursor:text}
.fv-studio .fv-chain-part:has(.fv-chain-part-text:not(:disabled):not([hidden])):hover .fv-chain-part-text{background:transparent;cursor:text}
.fv-studio .fv-chain-part .fv-chain-part-text:disabled{opacity:1;color:var(--fv-disabled)}
-.fv-studio .fv-chain-part:has(.fv-chain-part-text:disabled) :is(.fv-chain-part-name,.fv-chain-part-tag:not([data-kind=error])){color:var(--fv-disabled)}
+.fv-studio .fv-chain-part:has(.fv-chain-part-text:disabled) :is(.fv-chain-part-name,.fv-chain-part-meta,.fv-chain-part-tag:not([data-kind=error]):not([data-kind=failed])){color:var(--fv-disabled)}
.fv-studio .fv-chain-part-text[hidden]{display:none}
-.fv-studio .fv-chain-part-edit{position:relative;min-width:0}
+.fv-studio .fv-chain-part-edit{grid-column:3;grid-row:1;position:relative;min-width:0}
.fv-studio .fv-chain-part .fv-chain-part-editor{min-height:76px;height:96px;max-height:220px;padding:6px 36px 6px 10px;font-size:var(--fv-micro);line-height:20px;outline:none}
-.fv-studio .fv-chain-part-tag{padding:7px 8px 0 0;color:var(--fv-muted);white-space:nowrap}
+.fv-studio .fv-chain-part-tag{grid-column:4;grid-row:1;padding:7px 8px 0 0;color:var(--fv-muted);white-space:nowrap}
.fv-studio .fv-chain-part-tag:empty{display:none}
.fv-studio .fv-chain-part-tag[data-kind=error]{color:var(--fv-warning);white-space:normal;max-width:16em;text-align:right}
-@media(max-width:680px){.fv-studio .fv-chain-part{gap:8px;padding-left:4px}}
+.fv-studio .fv-chain-part-tag[data-kind=failed]{color:var(--fv-danger)}
+@media(max-width:680px){.fv-studio .fv-chain-parts{grid-template-columns:auto minmax(0,1fr) auto;column-gap:8px}.fv-studio .fv-chain-part{row-gap:0;padding-left:4px}.fv-studio .fv-chain-part .fv-chain-part-meta{grid-column:1;grid-row:2;align-self:center;min-height:32px;margin:4px 0;padding:0;display:flex;align-items:center}.fv-studio .fv-chain-part :is(.fv-chain-part-text,.fv-chain-part-edit){grid-column:2}.fv-studio .fv-chain-part-tag{grid-column:3;text-align:right}.fv-studio .fv-chain-part .fv-chain-part-redo{grid-column:2/4;grid-row:2;justify-self:end;margin:4px 0}}
.fv-studio .fv-request-assets{font-size:12px;line-height:1.5;margin:0 0 8px}
.fv-lora-problem{grid-column:var(--lora-lead,2)/span 3;grid-row:4;display:flex;flex-direction:column;align-items:flex-start;gap:6px;font-size:var(--fv-micro)}.fv-lora-problem p{margin:0;color:var(--fv-danger)}.fv-lora-problem.fv-lora-partial{color:var(--fv-muted)}.fv-lora-problem[hidden]{display:none}.fv-lora-why summary{cursor:pointer;color:var(--fv-muted)}.fv-lora-why pre{margin:6px 0 0;white-space:pre-wrap;word-break:break-all;color:var(--fv-muted);font:inherit;font-family:ui-monospace,monospace}.fv-lora-row[data-unusable=true] select{color:var(--fv-muted)}
diff --git a/web/studio.js b/web/studio.js
index ecc8c57e..a41b8a60 100644
--- a/web/studio.js
+++ b/web/studio.js
@@ -1,3 +1,4 @@
+import {createLanAccess} from './lan_access.js';
import { createErrorPanel, errorText, createErrorReport } from './error_panel.js';
import {topDialog} from './dialog_keys.js';
import {createExpandButton} from './editor_expand.js';
@@ -42,7 +43,7 @@ import { createFirstRunGuide } from './first_run_guide.js';
import { SHOWCASE_SCENE } from './entry_showcase.js';
import { createStudioLength, savedSeconds } from './studio_length.js?v=20261011-length';
import { firstFrameRow } from './first_frame_row.js';
-import { createStudioChain, chainPosition, chainParts, chainSplit, readChainResult, sumPartEstimates, singleRunSnapshot } from './studio_chain.js?v=20261014-parts';
+import { createStudioChain, chainPosition, chainParts, chainSplit, composePartText, readChainResult, sumPartEstimates, singleRunSnapshot } from './studio_chain.js?v=20261019-words';
import { createExtendState, extendText, extendSourceExists, readExtendRecord, extendSnapshot, mountExtendThumbnail, videoUnavailableText } from './studio_extend.js';
const languageOverride = typeof location !== 'undefined'
@@ -75,7 +76,8 @@ const enhanceCss = document.createElement('link'); enhanceCss.rel = 'stylesheet'
const el = (tag, label, cls) => { const e = document.createElement(tag); if (label != null) e.textContent = label; if (cls) e.className = cls; return e; };
// One line per part of a long video, under the preview. A part that has not started opens in
// place for editing and saves when it loses focus; the server's reply decides what stays.
-function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = {}) {
+// After the video stops, an ended part can be made again from there on as a new video.
+function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}, redo = async () => {}, resume = async () => {}, playSeam = null} = {}) {
const element = el('div', null, 'fv-chain-parts'); element.hidden = true;
const rows = new Map(), timers = new Map();
let editing = null, showing = null;
@@ -89,12 +91,38 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = {
const {part} = row, writing = editing === row;
row.element.dataset.state = part.editable ? 'pending' : part.state;
row.text.textContent = part.text;
- row.text.disabled = !part.editable;
+ // A part waits for its save to come back before it can be edited again, so two saves never cross.
+ row.text.disabled = !part.editable || row.saving != null;
if (!writing && row.editor) removeEditor(row);
// A note about a lost edit stays on its line until that part is done.
if (row.tag.dataset.kind === 'error' && part.state === 'done') row.tag.dataset.kind = '';
- if (timers.has(part.id) || row.tag.dataset.kind === 'error') return;
- tag(row, part.state === 'done' ? t('Done', '已完成') : part.editable || !['queued', 'running', 'pending'].includes(part.state) ? '' : t('Generating', '生成中'));
+ // In the joined video a continuing part plays its seam: two seconds either side.
+ const seam = playSeam && part.seam != null;
+ row.meta.disabled = !seam; row.meta.dataset.seam = String(seam);
+ if (seam) row.meta.setAttribute('aria-label', t(`Play the seam before part ${part.index + 1}`, `播放第 ${part.index + 1} 段的接缝`));
+ else row.meta.removeAttribute('aria-label');
+ // One way on per part: a stopped video continues from its first unfinished part; a finished part makes a new video.
+ const action = part.action, number = part.index + 1;
+ row.redo.hidden = !action;
+ if (action) {
+ row.redo.textContent = action === 'resume' ? t('Continue from here', '从这段继续') : t('Redo from here', '从这段重新生成');
+ row.redo.setAttribute('aria-label', action === 'resume' ? t(`Continue from part ${number}`, `从第 ${number} 段继续`)
+ : t(`Redo from part ${number}`, `从第 ${number} 段重新生成`));
+ }
+ if (timers.has(part.id) || row.tag.dataset.kind === 'error' || row.starting) return;
+ const ended = {done: t('Done', '已完成'), failed: t('Failed', '失败'), cancelled: t('Stopped', '已停止'), interrupted: t('Stopped', '已停止')}[part.state];
+ tag(row, ended ?? (part.live && !part.editable && ['queued', 'running', 'pending'].includes(part.state) ? t('Generating', '生成中') : ''),
+ part.state === 'failed' ? 'failed' : '');
+ }
+ async function remake(row) {
+ if (row.starting) return;
+ // The click answers at once; the new video replaces this list when the server takes it.
+ row.starting = true; row.redo.disabled = true; tag(row, t('Starting…', '正在开始…'));
+ try { await (row.part.action === 'resume' ? resume() : redo(row.part.id)); }
+ catch (error) {
+ if (rows.get(row.part.id) === row) flash(row, error.status === 409 ? t('Another video is generating. Try again when it finishes.', '正在生成别的视频,完成后再试。')
+ : t('Could not start. Try again.', '没能开始,请重试。'), 'error');
+ } finally { row.starting = false; row.redo.disabled = false; }
}
function flash(row, text, kind) {
clearTimeout(timers.get(row.part.id)); tag(row, text, kind);
@@ -110,7 +138,7 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = {
try {
await save(row.part.id, value);
row.saving = null;
- if (rows.get(row.part.id) === row) flash(row, t('Saved', '已保存'), 'saved');
+ if (rows.get(row.part.id) === row) { draw(row); flash(row, t('Saved', '已保存'), 'saved'); }
} catch (error) {
row.saving = null;
if (rows.get(row.part.id) !== row) return;
@@ -120,7 +148,7 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = {
}
}
function open(row) {
- if (!row.part.editable || editing === row) return;
+ if (!row.part.editable || row.saving != null || editing === row) return;
if (editing) finish(editing, true);
editing = row; clearTimeout(timers.get(row.part.id)); timers.delete(row.part.id); tag(row, '');
const editor = row.editor = el('textarea', null, 'fv-chain-part-editor');
@@ -150,9 +178,10 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = {
element.hidden = true; element.replaceChildren(); rows.clear(); showing = null;
for (const id of timers.values()) clearTimeout(id); timers.clear();
}
- return {element, clear, update(chain, active) {
- // The list belongs to one generation: it shows once the descriptions are written and closes when that ends.
- if (!active || !chain?.split || chainSplit(chain).writing) { clear(); return; }
+ return {element, clear, resumes: () => !element.hidden && [...rows.values()].some(row => row.part.action === 'resume'), update(chain, active, shown = false) {
+ // The list belongs to one long video: it shows once the descriptions are written, and stays after the
+ // video stops while the preview still shows it.
+ if (!(active || shown) || !chain?.split || chainSplit(chain).writing) { clear(); return; }
if (showing !== chain.id) clear();
showing = chain.id;
const first = element.hidden; element.hidden = false;
@@ -164,19 +193,26 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = {
}
parts.forEach((part, index) => {
part.state = chain.blocks[index].state === 'pending' && chain.blocks[index].prompt_id != null ? 'queued' : chain.blocks[index].state;
+ part.live = active;
let row = rows.get(part.id);
if (!row) {
row = {element: el('div', null, 'fv-chain-part'), tag: el('span', null, 'fv-chain-part-tag')};
row.text = el('button', null, 'fv-chain-part-text'); row.text.type = 'button';
+ row.redo = el('button', null, 'fv-chain-part-redo'); row.redo.type = 'button';
+ row.redo.onclick = event => { event.stopPropagation(); remake(row); };
row.element.onclick = () => open(row);
- row.element.append(el('span', t(`Part ${index + 1}`, `第 ${index + 1} 段`), 'fv-chain-part-name'), row.text, row.tag);
+ const joins = part.continues === 'part' ? t('After the previous part', '接上一段') : part.continues === 'video' ? t('After the video', '接原视频') : '';
+ row.meta = el('button', [joins, t(`${part.seconds} s`, `${part.seconds} 秒`)].filter(Boolean).join(' · '), 'fv-chain-part-meta');
+ row.meta.type = 'button';
+ row.meta.onclick = event => { event.stopPropagation(); if (row.part.seam != null) playSeam?.(row.part.seam); };
+ row.element.append(el('span', t(`Part ${index + 1}`, `第 ${index + 1} 段`), 'fv-chain-part-name'), row.meta, row.text, row.tag, row.redo);
rows.set(part.id, row); element.append(row.element);
}
if (editing === row && !part.editable) {
- // The part began while it was open: the words typed so far cannot reach it.
+ // The part began while it was open: the words typed so far cannot reach it. A video that ended just closes it.
const changed = row.draft !== row.part.text;
editing = null; row.part = part; draw(row);
- if (changed) flash(row, t('This part is already generating. Your edit was not saved.', '这一段已在生成,改动没有保存'), 'error');
+ if (changed && active) flash(row, t('This part is already generating. Your edit was not saved.', '这一段已在生成,改动没有保存'), 'error');
return;
}
if (editing === row) { row.part = {...part, text: row.part.text}; return; }
@@ -614,6 +650,7 @@ export function openStudio(node, {extendFrom = null} = {}) {
brand.append(wordmark());
const tools = el('div', null, 'fv-header-actions');
const agents = createAgentActivity(t, api, dialog); tools.append(agents.element); cleanup.push(agents.dispose);
+ const phoneAccess = createLanAccess(t, api); tools.append(phoneAccess.element); cleanup.push(phoneAccess.dispose);
const versionInfo = createVersionInfo(cn); tools.append(versionInfo.element); cleanup.push(versionInfo.dispose);
tools.append(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.
@@ -1089,15 +1126,45 @@ export function openStudio(node, {extendFrom = null} = {}) {
})); previewHead.append(previewTools); output.append(previewHead);
const previewSpace = el('div', null, 'fv-preview-space'); output.append(previewSpace);
// The preview keeps the size it has once the list is in; an opened part grows the column, which scrolls.
- const partList = createChainPartList(t, (block, text) => chainController.setText(block, text), {
+ const partList = createChainPartList(t, (block, text) => chainController.setText(block, composePartText(chainController.state, block, text)), {
onShow: () => { previewSpace.style.flex = `0 0 ${previewSpace.getBoundingClientRect().height}px`; },
- onHide: () => { previewSpace.style.flex = ''; }});
+ onHide: () => { previewSpace.style.flex = ''; },
+ redo: block => { failure.clear(); return chainController.redo(block); },
+ resume: () => { failure.clear(); return chainController.resume(); },
+ playSeam: seconds => playAround(seconds)});
+ // Set once the queue buttons exist: "Continue generating" stays hidden while a listed part offers the same.
+ let syncResume = () => {};
+ // A stopped long video keeps its parts listed while the preview shows it.
+ const syncPartList = () => {
+ const chain = chainController?.state;
+ if (chain) partList.update(chain, chainController.active, Boolean(result?.request_id) && result.request_id === chain.id);
+ syncResume();
+ };
output.append(partList.element);
cleanup.push(() => partList.clear());
const stage = el('div', null, 'fv-stage');
stage.setAttribute('aria-label', t('Video preview', '视频预览'));
const stageMedia = el('div', null, 'fv-stage-media');
stage.append(stageMedia); previewSpace.append(stage);
+ // Plays two seconds either side of a seam and stops there; pausing or jumping elsewhere hands the video back.
+ let seamFrame = 0;
+ function playAround(seconds) {
+ const video = stageMedia.querySelector('video');
+ if (!video) return;
+ cancelAnimationFrame(seamFrame);
+ const end = seconds + 2;
+ let last = Math.max(0, seconds - 2);
+ video.currentTime = last;
+ video.play().catch(() => {});
+ const watch = () => {
+ const now = video.currentTime;
+ if (Math.abs(now - last) > 1 || (video.paused && !video.seeking)) return;
+ if (now >= end) { video.pause(); return; }
+ last = now; seamFrame = requestAnimationFrame(watch);
+ };
+ seamFrame = requestAnimationFrame(watch);
+ }
+ cleanup.push(() => cancelAnimationFrame(seamFrame));
const progress = createGenerationProgress(t, undefined, {compact: true, api}); stage.append(progress.element);
const chainLine = el('div', '', 'fv-muted fv-chain-position'); chainLine.hidden = true; progress.element.prepend(chainLine);
// The queued count sits after the part number; a line too narrow for both keeps the part number only.
@@ -1159,6 +1226,7 @@ export function openStudio(node, {extendFrom = null} = {}) {
const previewObserver = new ResizeObserver(fitPreview);
previewObserver.observe(previewSpace); cleanup.push(() => previewObserver.disconnect());
function showProgress(message) {
+ phoneAccess.observe(message);
watchedRun = true; reportTipFor = null; clearTimeout(reportTipTimer);
reuseRow.hidden = true;
stats.hidden = true;
@@ -1221,7 +1289,7 @@ export function openStudio(node, {extendFrom = null} = {}) {
// chosen: a video picked from Recent videos. It only changes the picture; a run in progress keeps its progress.
function showResult(r, {chosen = false} = {}) {
if (!r?.video || disposed) return; result = r;
- recent.setSelected(r.video);
+ recent.setSelected(r.video); syncPartList();
refreshEffortEstimate(true);
// The report belongs to the video that run made; a video picked from Recent videos
// shows its own report, or none when this window never saw its run.
@@ -1330,7 +1398,7 @@ export function openStudio(node, {extendFrom = null} = {}) {
// Parts download what they need while they run; the line above Generate is read again when they stop.
if (chainWasActive && !active) requestAssets?.refresh(true);
chainWasActive = active;
- partList.update(chain, active);
+ partList.update(chain, active, Boolean(result?.request_id) && result.request_id === chain?.id);
busy = active || Boolean(queueState?.running || queueState?.pending.length); queued = chain?.state === 'queued';
chainLine.hidden = !active;
setChainLine(outcome?.joining ? t('Joining the parts.', '正在接合各段。')
@@ -1341,8 +1409,7 @@ export function openStudio(node, {extendFrom = null} = {}) {
if (chain?.state === 'cancelled') status.textContent = t('Cancelled', '已取消');
// After a stop with videos queued the next one starts at once; its line says so until something else is done.
if (stopNote) { status.dataset.error = 'false'; status.textContent = stopNote; }
- resumeChain.hidden = !['cancelled', 'failed', 'interrupted'].includes(chain?.state);
- queueActions.hidden = !active && !busy && resumeChain.hidden;
+ syncResume();
updateRunButton();
},
onResult: (result, outcome) => { node.freevideoLastResult = result; showResult(result, {chosen: outcome.partial});
@@ -1408,6 +1475,10 @@ export function openStudio(node, {extendFrom = null} = {}) {
finally { stopLoop.disabled = false; }
}, 'fv-quiet');
queueActions.append(queueDetails, cancelCurrent, stopLoop, resumeChain);
+ syncResume = () => {
+ resumeChain.hidden = !['cancelled', 'failed', 'interrupted'].includes(chainController?.state?.state) || partList.resumes();
+ queueActions.hidden = !chainController?.active && !busy && !queueState?.looping && resumeChain.hidden;
+ };
runbar.append(requestAssets.element, runrow, queueActions); controls.append(runbar);
const queueError = error => ({
queue_unavailable: t('Could not read the queue. The loop has stopped; videos already added stay queued.', '暂时读不到队列,循环已停止;已加入的视频还在队列中。'),
diff --git a/web/studio_chain.js b/web/studio_chain.js
index 88b74c31..c6440c0f 100644
--- a/web/studio_chain.js
+++ b/web/studio_chain.js
@@ -4,6 +4,7 @@ import {buildChainRequest} from './length_plan.js?v=20261011-length';
export const CHAIN_PROPERTY = 'freevideo_chain';
const activeStates = new Set(['queued', 'running', 'cancelling']);
const MAX_WAITING = 5;
+const FPS = 24;
const finite = value => typeof value === 'number' && Number.isFinite(value) && value >= 0;
const post = body => ({method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
@@ -35,10 +36,41 @@ export function chainSplit(chain) {
const split = chain?.split;
return {writing: ['pending', 'running'].includes(split?.state), reason: split?.state === 'failed' ? split.reason || 'failed' : null};
}
+// A split part lists only its own events: the continuation sentence and the shared description the server
+// repeats in every part stay as they are around an edit (part_words: [start, end] of the part's own words).
+function ownWords(chain, index, text) {
+ const span = chain?.part_words?.[index];
+ return Array.isArray(span) && span.length === 2 && span.every(Number.isInteger)
+ && 0 <= span[0] && span[0] < span[1] && span[1] <= text.length ? span : null;
+}
+function ownText(chain, index, text) {
+ const span = ownWords(chain, index, text);
+ return span ? text.slice(...span) : text;
+}
+// Once the video has ended, each part offers one way on: a stopped video continues from its first unfinished part,
+// and a finished part, with every part before it done, can be made again as a new video.
+const STOPPED = new Set(['failed', 'cancelled', 'interrupted']);
export function chainParts(chain) {
- const writing = chainSplit(chain).writing;
- return (chain?.blocks || []).map((block, index) => ({id: block.id, index, text: block.inputs?.text ?? '',
- editable: !writing && block.state === 'pending' && block.prompt_id == null}));
+ const writing = chainSplit(chain).writing, blocks = chain?.blocks || [];
+ const idle = Boolean(chain) && !activeStates.has(chain.state) && chain.output_state !== 'joining' && !writing;
+ const unfinished = blocks.findIndex(block => block.state !== 'done' || !block.output);
+ // Where each part begins in the joined video: the parts are joined end to end, each without the opening it repeats.
+ const joined = readChainResult(chain)?.joined, kept = Number.isInteger(chain?.output_blocks) ? chain.output_blocks : blocks.length;
+ let start = 0;
+ const starts = blocks.map(block => { const at = start; start = Number.isInteger(block.new_frames) && start != null ? start + block.new_frames : null; return at; });
+ return blocks.map((block, index) => ({id: block.id, index, text: ownText(chain, index, block.inputs?.text ?? ''),
+ editable: !writing && block.state === 'pending' && block.prompt_id == null,
+ // The seconds this part adds to the video; a continuing part repeats the end of the one before it.
+ seconds: Math.max(1, Math.round((block.new_frames ?? block.inputs?.frames ?? 0) / FPS)),
+ continues: typeof block.after === 'string' ? 'part' : block.after ? 'video' : null,
+ seam: joined && index > 0 && index < kept && typeof block.after === 'string' && starts[index] != null ? starts[index] / FPS : null,
+ action: !idle ? null : index === unfinished ? (STOPPED.has(chain.state) ? 'resume' : null)
+ : unfinished < 0 || index < unfinished ? 'redo' : null}));
+}
+export function composePartText(chain, block, words) {
+ const index = (chain?.blocks || []).findIndex(row => row.id === block);
+ const text = chain?.blocks?.[index]?.inputs?.text ?? '', span = index < 0 ? null : ownWords(chain, index, text);
+ return span ? text.slice(0, span[0]) + words + text.slice(span[1]) : words;
}
export function sumPartEstimates(plan, estimate) {
@@ -231,6 +263,18 @@ export function createStudioChain({api, node, onState = () => {}, onProgress = (
if (value?.id === id && version === epoch && !disposed) deliver(value);
return value;
},
+ // A new video from this part on; the finished one stays in Creations as it was.
+ async redo(block) {
+ if (!chain || active()) throw new Error('chain_busy');
+ const id = chain.id, version = epoch;
+ const value = (await json(chainRoute(id, `blocks/${encodeURIComponent(block)}/redo`),
+ post(api.clientId ? {client_id: api.clientId} : {})))?.chain;
+ if (!value?.id) throw new Error('chain_missing');
+ if (version !== epoch || disposed) return value;
+ const {id: _, ...metadata} = node.properties?.[CHAIN_PROPERTY] || {};
+ epoch++; adopt(value, metadata);
+ return value;
+ },
async cancel() {
if (!chain) return;
await changeState('cancel', {});
diff --git a/web/theme.css b/web/theme.css
index 17ec71a9..6301ba99 100644
--- a/web/theme.css
+++ b/web/theme.css
@@ -1,7 +1,7 @@
/* One token table for the web views, mirroring freevideo_engine/branding.py so
a control looks the same in the launcher and in ComfyUI. Scope the tokens to
our own views; never change ComfyUI's theme. */
-:where(.fv-studio,.fv-setup,.fv-panel,.fv-generation-progress,.fv-view-navigation,.fv-prompt-guide,.fv-failure-dialog,.fv-reference-picker,.fv-release-notes,.fv-image-editor,.fv-cast-dialog,.fv-tutorial-book){
+:where(.fv-phone,.fv-lan,.fv-studio,.fv-setup,.fv-panel,.fv-generation-progress,.fv-view-navigation,.fv-prompt-guide,.fv-failure-dialog,.fv-reference-picker,.fv-release-notes,.fv-image-editor,.fv-cast-dialog,.fv-tutorial-book){
/* Surfaces, from the backdrop up to a hovered control; mirrors branding.py. */
--fv-bg:#111720;--fv-canvas:#161e29;--fv-card:#1a222e;--fv-raised:#212a37;--fv-hover:#2a3341;
--fv-border:#2a3443;--fv-sheen:#35404e;
diff --git a/web/view_navigation.js b/web/view_navigation.js
index 3f102c99..2be364d0 100644
--- a/web/view_navigation.js
+++ b/web/view_navigation.js
@@ -1,3 +1,5 @@
+import {createLanAccess} from './lan_access.js';
+import {api} from '../../scripts/api.js';
import { app } from '../../scripts/app.js';
import { openSetup } from './setup.js';
import { feedbackButton } from './feedback.js';
@@ -139,7 +141,7 @@ export function installNavigation(openStudio, agent = false, {openWorkflow = nul
const handle = await openEditorView(node);
if (created) handle?.notify(t('Created an editing project for you.', '已为你新建一个剪辑工程'));
return handle;
- }, true), () => choose(node => openAutopilot(node), true)), createVersionInfo(cn).element, feedbackButton(t, 'fv-view-settings'), settings); toolbar.append(row);
+ }, true), () => choose(node => openAutopilot(node), true)), createLanAccess(t, api).element, createVersionInfo(cn).element, feedbackButton(t, 'fv-view-settings'), settings); toolbar.append(row);
toolbar.append(createUpdateNotice(cn).element);
document.body.append(toolbar); refreshNavigation();
makeMovable(toolbar);