diff --git a/freevideo_engine/agent/cast.py b/freevideo_engine/agent/cast.py index 3178bde..ab44345 100644 --- a/freevideo_engine/agent/cast.py +++ b/freevideo_engine/agent/cast.py @@ -129,14 +129,23 @@ def _check(record, stem): or type(card['revision']) is not int or card['revision'] < 1 or not isinstance(card['name'], str) or not isinstance(card['description'], (str, type(None)))): raise ValueError('Invalid card identity') - if set(card['edited_by_user']) != set(EDITABLE) or not all(type(v) is bool for v in card['edited_by_user'].values()): + marks = card.get('edited_by_user', {}) + if not isinstance(marks, dict): raise ValueError('Invalid edit marks') + normalized = {k: marks.get(k, False) for k in EDITABLE} + if not all(type(v) is bool for v in normalized.values()): + raise ValueError('Invalid edit marks') + if marks != normalized: + logging.getLogger(__name__).warning('Normalized edit marks on a readable cast card') + card['edited_by_user'] = normalized + unknown_labels = False for key, limit in (('images', MAX_IMAGES), ('candidates', MAX_CANDIDATES)): if not isinstance(card[key], list) or len(card[key]) > limit: raise ValueError('Invalid pictures') for row in card[key]: - if row['label'] not in LABELS or not isinstance(row['media']['file'], str): + if not isinstance(row['label'], str) or not isinstance(row['media']['file'], str): raise ValueError('Invalid picture') + unknown_labels |= row['label'] not in LABELS if card['voice'] is not None: if not isinstance(card['voice']['clip']['file'], str) or not ( isinstance(card['voice']['media'].get('file'), str) or isinstance(card['voice']['media'].get('library'), str)): @@ -145,6 +154,8 @@ def _check(record, stem): raise ValueError('Invalid film list') record['receipts'] = operation_receipts.load(record['receipts']) json.dumps(record, allow_nan=False) + if unknown_labels: + logging.getLogger(__name__).warning('Kept unknown picture labels on a readable cast card') def _revision(body, header): @@ -363,7 +374,19 @@ def list(self, *, q=None, kind=None, origin=None, production=None, limit=50): def update(self, identity, value, *, by='builtin', ref=None, revision=None, local=False, client=None): shape = schema.cast_schema('update', local=local) - schema.validate(shape, value) + shape['properties']['images']['items']['properties']['label'] = schema.enum(LABELS) + checked = deepcopy(value) + with self.changed: + record = self.records.get(identity) if isinstance(identity, str) else None + if record is not None and isinstance(checked, dict) and isinstance(checked.get('images'), list): + known = {(row['media']['file'], row['label']) for key in ('images', 'candidates') + for row in record['card'][key] if row['label'] not in LABELS} + for row in checked['images']: + if isinstance(row, dict) and isinstance(row.get('label'), str): + if (_plain(row.get('media')), row['label']) in known: + # Let callers return a saved label unchanged; new labels still use the request schema. + row.pop('label') + schema.validate(shape, checked) ref = ref if ref is not None else value.get('ref') value = {k: v for k, v in value.items() if k != 'ref'} expected = _revision(value.pop('revision', None), revision) diff --git a/freevideo_engine/agent/editor_runtime.py b/freevideo_engine/agent/editor_runtime.py index 3d4c42c..e247014 100644 --- a/freevideo_engine/agent/editor_runtime.py +++ b/freevideo_engine/agent/editor_runtime.py @@ -166,12 +166,15 @@ }; try { const record = model.parse(request.project); + const generation = __load('editor_generation'); if (request.mode === 'request') { const value = model.generationRequest(record, request.ids, {text: request.text, paths: request.paths}); - return JSON.stringify({ok: true, blocks: value.blocks.map(({before, ...block}) => block)}); + // Parts that continue finished footage are made at its shape, as on the page. + const follow = generation.continuedSize(model.generationBefore(record, request.ids), new Map(Object.entries(request.sizes || {}))); + return JSON.stringify({ok: true, blocks: value.blocks.map(({before, ...block}) => block), size: follow}); } - const store = __load('editor_store'), generation = __load('editor_generation'); - const size = generation.generationSize(record), target = request.target, words = request.text; + const store = __load('editor_store'); + const size = request.size || generation.generationSize(record), target = request.target, words = request.text; const reply = request.capability, ready = reply && (!reply.status || reply.status === 'ready'); const limits = ready ? store.capabilityPoints(reply, {...record, ...size}) : {maxSeconds: 15, maxFollowSeconds: 15, maxRequestFrames: null}; @@ -354,7 +357,7 @@ def batch(self, project, operations): return self._entry('__batch', dict(project=project, operations=operations)) def generation(self, **request): - """mode='plan' (project, target, text, capability) or mode='request' (project, ids, text, paths).""" + """mode='plan' (project, target, text, capability[, size]) or mode='request' (project, ids, text, paths, sizes).""" return self._entry('__generation', request) def deliver(self, **request): diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index d23838c..f752ab5 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -162,10 +162,8 @@ def load(self): raise ValueError('Invalid execution receipts') record['receipts'] = operation_receipts.load(record['receipts']) json.dumps(record, allow_nan=False) - checked = production.plan(dict(screenplay=state['screenplay'], - takes_per_shot=state['takes_per_shot'], director=state['director'])) - if [s['id'] for s in state['shots']] != [s['id'] for s in checked['preview']['shots']]: - raise ValueError('Invalid shot receipts') + request = dict(screenplay=state['screenplay'], + takes_per_shot=state['takes_per_shot'], director=state['director']) for ref, child in record['children'].items(): schema.validate(schema.REF, ref) if child['status'] not in DONE: @@ -174,10 +172,15 @@ def load(self): raise ValueError('Invalid submission marker') production.view(state) try: + checked = production.plan(request) + if [s['id'] for s in state['shots']] != [s['id'] for s in checked['preview']['shots']]: + raise JobError('internal_error', 'Saved shots differ from the current plan') production.advance(deepcopy(state), deepcopy(record['children']), now=state['updated_at']) - except production.ProductionError as error: + except (production.ProductionError, JobError) as error: # A readable record that cannot advance yet is kept, never renamed away. record['stalled'] = self._stalled(error) + logging.getLogger(__name__).warning('Kept a readable production receipt stalled (%s)', + record['stalled']['code']) self.records[state['id']] = record except (ValueError, KeyError, TypeError, AttributeError, IndexError, RecursionError, OSError): path.replace(path.with_name(path.name + '.bad-' + uuid.uuid4().hex)) diff --git a/freevideo_engine/agent/projects.py b/freevideo_engine/agent/projects.py index 2dd233f..ddcb13e 100644 --- a/freevideo_engine/agent/projects.py +++ b/freevideo_engine/agent/projects.py @@ -325,10 +325,18 @@ def generate(self, identity, value, *, jobs, capability=None, ref=None, client=N if any(info['missing'] or not info.get('path') for info in found.values()): raise ProjectError('source_missing', 'The part this generation continues from is not available.') request = rules.generation(mode='request', project=plan['project'], ids=plan['ids'], text=text, - paths={key: info['path'] for key, info in found.items()}) + paths={key: info['path'] for key, info in found.items()}, + sizes={key: dict(width=info['width'], height=info['height']) for key, info in found.items()}) if not request['ok']: self._rejected(request['error'], stored, '/target', target) - limit = plan['max_request_frames'] + size, limit = plan['size'], plan['max_request_frames'] + if request.get('size') and request['size'] != size: + # A part continuing footage of another size has that size's limit, as on the page. + size = request['size'] + reply = capability(size) if capability is not None else None + if isinstance(reply, dict) and reply.get('status', 'ready') == 'ready': + limit = rules.generation(mode='plan', project=stored, target=target, text=text, capability=reply, + size=size).get('max_request_frames') longest = max(block['frames'] for block in request['blocks']) if limit is not None and longest > limit: raise _invalid('/target', 'parts of at most %d frames at this size and quality; choose a shorter range' @@ -338,8 +346,8 @@ def generate(self, identity, value, *, jobs, capability=None, ref=None, client=N prompt = text.strip() or next((b['text'] for b in blocks if b['text'].strip()), '') or 'Continue the video.' frames = sum(block['frames'] for block in blocks) spec = dict({k: v for k, v in settings.items() if k in ('quality', 'sampling', 'loras', 'force_regenerate')}, - kind='generate', prompt=prompt, seed=seed, width=plan['size']['width'], - height=plan['size']['height'], seconds=min(60., max(1.625, frames / 24)), + kind='generate', prompt=prompt, seed=seed, width=size['width'], + height=size['height'], seconds=min(60., max(1.625, frames / 24)), label='Editing project ' + identity) saved = self._save(plan['project']) # The reservation: the page's revision check orders it. editor = dict(project=identity, ids=plan['ids'], blocks=blocks, revision=saved['revision']) diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index b132164..62f22f7 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -471,6 +471,7 @@ def cast_schema(name, *, local=False): if name == 'create': return obj(dict(kind=enum(('character', 'setting'), default='character'), **fields, ref=REF), ('name',)) if name == 'update': + fields['images']['items']['properties']['label'] = {'type': 'string'} return obj(dict(fields, revision=number(1, integer=True), ref=REF)) if name == 'delete': return obj(dict(revision=number(1, integer=True), ref=REF)) @@ -489,7 +490,7 @@ def cast_schema(name, *, local=False): def cast_view(): picture = obj(dict(media=obj(dict(file=FILE, url=string(2048)), ('file', 'url')), by=enum(('user', 'agent', 'builtin')), from_job=JOB, generated={'type': 'boolean'}, - label=enum(LABELS), label_guess={'type': 'boolean'}), ('media', 'by', 'label')) + label={'type': 'string'}, label_guess={'type': 'boolean'}), ('media', 'by', 'label')) generating = obj(dict(job=JOB, face_job=nullable(JOB), jobs=array(JOB, 1, 5), source=enum(('image', 'video')), replace={'type': 'boolean'}, at=string(), by=enum(('user', 'agent', 'builtin'))), ('job', 'face_job', 'replace', 'at', 'by')) diff --git a/freevideo_engine/bootstrap.py b/freevideo_engine/bootstrap.py index 5b990d9..63c5270 100644 --- a/freevideo_engine/bootstrap.py +++ b/freevideo_engine/bootstrap.py @@ -5,6 +5,7 @@ from contextlib import ExitStack, contextmanager import hashlib import json +import logging import os from pathlib import Path import platform @@ -49,8 +50,8 @@ def _permission_limited_memory_sample(row): different security context even though the install process and the system memory counters remain readable. In that case the process-tree number is incomplete, but the global emergency floor is still a valid safety guard. - Require a real sampled process count so synthetic/no-sample rows and a - failed process snapshot continue to fail closed. + Require a real sampled process count to distinguish denied child queries + from a failed process snapshot. """ if type(row.get('processes')) is not int or row['processes'] <= 0: return False @@ -469,8 +470,13 @@ def prepare_end(ui, success=True): ui.end(PREPARE_KEY, success=success) +def missing_sage_tools(snapshot): + return ([] if snapshot.get('compiler') else ['g++']) + ([] if snapshot.get('make', True) else ['make']) + + def plan(args, *, local_progress=None, stage=None): root = args.root.expanduser().resolve() + warnings = [] saved = json.loads((root / 'machine.json').read_text(encoding='utf-8')) if (root / 'machine.json').is_file() else {} storage = getattr(args, 'storage', None) or saved.get('storage', 'compact') layout = select_layout(getattr(args, 'environment', None), saved) @@ -513,13 +519,15 @@ def plan(args, *, local_progress=None, stage=None): # A missing or too old Git is replaced by FreeVideo's own. if not windows_target: # ./setup.sh installs these before planning; the desktop launcher names the command instead. - missing = ([] if snapshot.get('compiler') else ['g++']) + ([] if snapshot.get('make', True) else ['make']) + missing = missing_sage_tools(snapshot) if missing: from .system import linux_tools_command command = linux_tools_command(missing) - errors.append('Missing %s, needed to build GPU acceleration for this GPU. %s, then check again.' % ( + warnings.append('Missing %s, needed to build GPU acceleration for this GPU. %s, then check again.' % ( ' and '.join(missing), 'In a terminal, run: ' + command if command else 'Ask your administrator to install them')) + logging.getLogger(__name__).warning('%s Skipping Sage2 source compilation; ' + 'continuing with cudnn/torch-flash attention.', warnings[-1]) if offline_installation(root): # Its sources carry no Git history and its Python is the package's. errors.append('This folder holds an installation from offline packages; automatic installation does not change it. ' @@ -584,7 +592,9 @@ def plan(args, *, local_progress=None, stage=None): local_reuse = None local_folder = getattr(args, 'reuse_models', None) local_manifest = getattr(args, 'reuse_models_manifest', None) - if local_folder or local_manifest: + from .launcher_settings import saved_model_libraries + saved_libraries = saved_model_libraries() + if local_folder or local_manifest or saved_libraries: from .local_models import scan, scan_many, can_link, key as local_key previous_reuse = None approved = getattr(args, 'approved_plan', None) @@ -598,6 +608,12 @@ def plan(args, *, local_progress=None, stage=None): if not isinstance(libraries, dict) or libraries.get('version') != 1 or not isinstance(libraries.get('roots'), list): raise ValueError('Invalid model library manifest') directories = ([str(local_folder)] if local_folder else []) + libraries['roots'] + from .local_models import library_roots + directories = library_roots(library_roots(directories) + saved_libraries, keep_first=True) + scan_library = lambda: scan_many(directories, needed, prior=previous_reuse, callback=local_progress) + elif saved_libraries: + from .local_models import library_roots + directories = library_roots(([str(local_folder)] if local_folder else []) + saved_libraries, keep_first=True) scan_library = lambda: scan_many(directories, needed, prior=previous_reuse, callback=local_progress) else: scan_library = lambda: scan(local_folder, needed, prior=previous_reuse, callback=local_progress) @@ -765,7 +781,7 @@ def plan(args, *, local_progress=None, stage=None): '4–8 GiB working set plus reclaimable source file cache; measured by setup'), 'test_artifacts_estimate_gib': '5–8 GiB for the standard five-case suite; all outputs retained', 'first_generation_note': 'Portable AdaLN tables follow model weights and the exact schedule, across GPUs and Torch/CUDA versions. New schedules or modulation-changing LoRAs require preparation. Existing environments stay on disk when switching.', - 'estimates_are_not_capacity_guarantees': True, 'errors': errors, + 'estimates_are_not_capacity_guarantees': True, 'errors': errors, 'warnings': warnings, 'steps': ['Install isolated uv/Python and ' + ('one shared CUDA environment' if layout == 'unified' else 'two CUDA environments'), 'Clone the VDN, patched Diffusers and native H3 text-encoder library versions FreeVideo tests', 'Install verified Windows Triton / Sage2 wheels' if windows_target else 'Install a local CUDA compiler and build Sage2 for the detected GPU only', @@ -1254,34 +1270,39 @@ def sample(): try: row = memory.sample(os.getpid()) except OSError as error: - if self.system != 'Windows': - raise # The process snapshot itself can fail, too. Still - # obtain fresh physical/commit headroom; never reuse - # the previous reading or invent process usage. + # obtain fresh system headroom without inventing process usage. system = system_memory() row = dict(guard_bytes=None, system_available_bytes=system['available_bytes'], system_physical_available_bytes=system['physical_available_bytes'], system_commit_available_bytes=system['commit_available_bytes'], memory_read_errors=[dict(error=str(error), winerror=getattr(error, 'winerror', None))]) + if self.system == 'Linux': + from .hardware import cgroup_memory + group = cgroup_memory() + available = group['available_bytes'] + reclaimable = group.get('reclaimable_available_bytes', available) + row.update(cgroup_memory=group, + effective_available_bytes=min(system['available_bytes'], + available) if available is not None else system['available_bytes'], + inference_available_bytes=min(system['available_bytes'], + reclaimable) if reclaimable is not None else system['available_bytes']) used = memory_sample(row) if used is None: unreadable += 1 failures += 1 - # A live worker can have descendants for which - # Windows returns ERROR_ACCESS_DENIED (5). Keep - # the sample and continue under the global RAM/ - # commit floor; abort only when the whole process - # snapshot is unavailable or the root cannot be - # sampled. This avoids killing installs merely - # because a helper has a stricter token. - degraded = (self.system == 'Windows' and - _permission_limited_memory_sample(row)) + # Unreadable process usage does not prove exhaustion; + # retain the samples and keep checking the system floor. + denied = self.system == 'Windows' and _permission_limited_memory_sample(row) + degraded = denied or self.system != 'Windows' or unreadable >= 3 if degraded: self.state['resource_warning'] = ( 'Some Windows child processes denied memory queries (WinError 5); ' - 'continuing with the global RAM/commit floor.') + 'continuing with the global RAM/commit floor.' if denied else + 'Process-tree memory is unavailable; continuing with the global RAM/commit floor.') + if self.state.get('monitor_status') != 'degraded': + self.ui.write(self.state['resource_warning'] + '\n') row = dict(row, monitor_status='degraded' if degraded else 'retrying', consecutive_unreadable=unreadable) else: @@ -1290,6 +1311,7 @@ def sample(): row = dict(row, monitor_status='recovered' if unreadable else 'complete', consecutive_unreadable=0) unreadable = 0 + self.state['monitor_status'] = row['monitor_status'] pressure = setup_memory_pressure(row, self.resources['ram_budget_bytes'], self.system) row['installation_guard'] = pressure stream.write(json.dumps(dict(row, elapsed_seconds=time.monotonic()-self.started, epoch_seconds=time.time())) + '\n') @@ -1306,15 +1328,6 @@ def sample(): break if available < pressure['planning_reserve_bytes']: self.state['memory_headroom_warning'] = pressure - if (used is None and (self.system != 'Windows' or unreadable >= 3) - and row.get('monitor_status') != 'degraded'): - details = json.dumps(dict( - unreadable_memory_pids=row.get('unreadable_memory_pids', row.get('unreadable_pss_pids', [])), - memory_read_errors=row.get('memory_read_errors', []), - available_bytes=available), ensure_ascii=False) - raise RuntimeError('Cannot read process-tree memory after %d consecutive samples. ' - 'Download progress is retained; retry installation to resume. ' - 'RAM sample: %s; full samples: %s' % (unreadable, details, self.run_dir / 'ram.jsonl')) self.monitor_stop.wait(1) except BaseException as error: self.state['resource_guard'] = 'Installation RAM monitoring failed: ' + repr(error) @@ -1674,6 +1687,11 @@ def attempt(source, route, retry_mode): def install_sage(self, uv, python): if self.system == 'Windows': return self.install_sage_windows(uv, python) + missing = missing_sage_tools(self.plan['inventory']) + if missing: + self.ui.write('Missing %s; skipping Sage2 source compilation and continuing with ' + 'cudnn/torch-flash attention.\n' % ' and '.join(missing)) + return None code = ('import json,platform,sys,torch; print(json.dumps(dict(' 'python="cp"+str(sys.version_info.major)+str(sys.version_info.minor), ' 'torch=str(torch.__version__),cuda=torch.version.cuda,cxx11_abi=torch.compiled_with_cxx11_abi(), ' @@ -1958,6 +1976,8 @@ def check_kernels(self, python, kernel_report): def record_kernel_validation(self, results, kernel_report): sage_manifest = results['sage'] + if sage_manifest is None: + return sage_record = json.loads(sage_manifest.read_text(encoding='utf-8')) kernels = json.loads(kernel_report.read_text(encoding='utf-8')) sage_record['kernel_validation'] = ('Small Sage2 kernels passed on ' + self.plan['inventory']['selected_gpu']['uuid'] diff --git a/freevideo_engine/chain_runner.py b/freevideo_engine/chain_runner.py index 469353c..fbbb0b1 100644 --- a/freevideo_engine/chain_runner.py +++ b/freevideo_engine/chain_runner.py @@ -522,6 +522,11 @@ def _step_chain(self, identity): block_inputs[key] = (source['output'] or '') if source is not None else '' for name in getattr(self.host, 'continuation_inputs', ()): prompt[node]['inputs'].pop(name, None) + # A part that continues footage takes no keyframes or references of its own: + # only a part that starts fresh keeps the request's media. + if any(source is not None for source in sources.values()): + for name in getattr(self.host, 'media_inputs', ()): + prompt[node]['inputs'].pop(name, None) prompt[node]['inputs'].update(self.inputs(block_inputs)) self.host.submit(prompt_id, node_prompt(prompt, node), extra) except Exception as error: diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 4598e5d..fa843f4 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -1416,6 +1416,8 @@ def asset_progress(message): state['encoder_prewarm'] = prewarm_summary(encoder_prewarm) if machine.get('device_backend') != 'mps' and (source/'freevideo_engine'/'resident_worker.py').is_file(): from .resident_process import OWNER, ENV + # Whether the models were kept for this request or released, and why. + state['resident_since_last'] = OWNER.take_decisions() if environment.get('FREEVIDEO_KEEP_MODELS', 'auto').lower() in ('0', 'off', 'false'): OWNER.close() environment.pop(ENV, None) @@ -1529,7 +1531,7 @@ def store_result(check): # request's process tree. Confirm it exits too before offering # another generation; cancellation should release its memory. from .resident_process import OWNER - OWNER.close() + OWNER.release('cancelled') raise finally: from .resident_process import OWNER diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py index b28625d..c770c38 100644 --- a/freevideo_engine/comfy_chain.py +++ b/freevideo_engine/comfy_chain.py @@ -39,6 +39,7 @@ def seconds_for_frames(frames, minimum=1.625, maximum=60., step=.1): class ComfyHost: continuation_inputs = tuple(CONTINUE_INPUTS.values()) + media_inputs = ('media', 'first', 'last', 'references', 'conditioning') def __init__(self, server): self.server = server diff --git a/freevideo_engine/comfy_editor.py b/freevideo_engine/comfy_editor.py index d915d2c..619b9b2 100644 --- a/freevideo_engine/comfy_editor.py +++ b/freevideo_engine/comfy_editor.py @@ -242,7 +242,7 @@ def bounded(value, depth=0): raise ValueError('Duplicate versions') clip = dict(id=identity, start=int(raw['start']), frames=int(raw['frames']), length=int(raw['length']), source=source, link=link, text=raw.get('text', ''), part=raw.get('part'), pending=pending, - reason=raw.get('reason'), job=raw.get('job'), extend=raw.get('extend', source is not None and source['kind'] != 'import'), + reason=raw.get('reason'), job=raw.get('job'), extend=raw.get('extend', source is not None), versions=history, **{'in': int(raw['in'])}) clip.update(speed_fields) clip.update(clip_fields(raw, kind)) @@ -646,6 +646,9 @@ def sources(root, keys): if source['kind'] in ('work', 'generated'): relative = path.relative_to(Path(root).resolve()).as_posix() info['path'] = relative + elif path.suffix == '.mp4': + # An imported video is continued from its upright 24 fps copy; it is still shown through the import. + info['path'] = path.relative_to(Path(root).resolve()).as_posix() if path.stat().st_size == 0: raise ValueError('Empty media') if source['kind'] == 'work': diff --git a/freevideo_engine/comfy_launcher_runtime.py b/freevideo_engine/comfy_launcher_runtime.py index 7cefc0e..94ea39b 100644 --- a/freevideo_engine/comfy_launcher_runtime.py +++ b/freevideo_engine/comfy_launcher_runtime.py @@ -8,6 +8,7 @@ import hashlib import ipaddress import json +import logging import os from pathlib import Path import secrets @@ -471,7 +472,12 @@ def node_target(root): if (path / 'freevideo-launcher.json').is_file() or (code.is_file() and 'FreeVideoGenerate' in code.read_text(encoding='utf-8')): found.append(path) if len(found) > 1: - raise ValueError('Multiple FreeVideo node installations found. Keep one enabled before installing.') + managed = [path for path in found if (path / 'freevideo-launcher.json').is_file()] + if len(managed) != 1: + raise ValueError('Multiple FreeVideo node installations found. Keep one enabled before installing.') + logging.getLogger(__name__).warning('Multiple FreeVideo node installations found; using %s with ' + 'freevideo-launcher.json and leaving the other folders unchanged.', plain(managed[0])) + found = managed target = found[0] if found else parent / 'FreeVideo' if target.exists() and not found and any(target.iterdir()): raise ValueError('custom_nodes/FreeVideo already contains unrelated files. Choose another ComfyUI or rename that folder first.') @@ -520,10 +526,10 @@ def deploy(root, source, engine): target.mkdir(parents=True, exist_ok=True) entry = target / '__init__.py' old = entry.read_bytes() if entry.is_file() else None - if (old and previous.get('entry_sha256') and hashlib.sha256(old).hexdigest() != previous['entry_sha256'] + if (old is not None and previous.get('entry_sha256') and hashlib.sha256(old).hexdigest() != previous['entry_sha256'] and not checkout_entry(old, target)): - raise ValueError(f'The managed FreeVideo entry point ({plain(entry)}) was edited. Changes are retained; ' - 'undo them or delete that file before updating.') + logging.getLogger(__name__).warning('The managed FreeVideo entry point (%s) was edited; ' + 'backing it up in launcher/backups before restoring the loader.', plain(entry)) # The small loader may be replaced; all original checkout files remain intact. code = ("# Installed by FreeVideo launcher. Original entry points are retained in the engine launcher/backups folder.\n" "import importlib.util as _util\nimport json as _json\nfrom pathlib import Path as _Path\nimport sys as _sys\n" @@ -534,7 +540,7 @@ def deploy(root, source, engine): "_spec = _util.spec_from_file_location(__name__ + '._engine', _source / '__init__.py', submodule_search_locations=[str(_source)])\n" "_engine = _util.module_from_spec(_spec)\n_sys.modules[_spec.name] = _engine\n_spec.loader.exec_module(_engine)\n" "WEB_DIRECTORY = str(_source / 'web')\ncomfy_entrypoint = _engine.comfy_entrypoint\n").encode() - if old and old != code: + if old is not None and old != code: backup = engine / 'launcher' / 'backups' / (target.name + '-' + hashlib.sha256(old).hexdigest()[:12] + '-init.py') backup.parent.mkdir(parents=True, exist_ok=True) if not backup.exists(): @@ -800,9 +806,12 @@ def restore(self, values): source = Path(plain(resolved(receipt['source']))) if (receipt.get('protocol') != PROTOCOL or Path(plain(resolved(receipt['engine_root']))) != engine or not io_path(source / 'freevideo_engine/comfy_nodes.py').is_file() - or not io_path(source / '__init__.py').is_file() - or hashlib.sha256((receipt_path.parent / '__init__.py').read_bytes()).hexdigest() != receipt.get('entry_sha256')): + or not io_path(source / '__init__.py').is_file()): return False + entry = receipt_path.parent / '__init__.py' + if hashlib.sha256(entry.read_bytes()).hexdigest() != receipt.get('entry_sha256'): + logging.getLogger(__name__).warning('The managed FreeVideo entry point (%s) differs from ' + 'entry_sha256; continuing with the saved installation.', plain(entry)) installation(source, {'FREEVIDEO_HOME': str(engine)}) python = (record.get('python') or values.get('python') or managed_python(engine, Path(descriptor['root'])) or descriptor.get('python')) @@ -966,7 +975,8 @@ def _inspect(self, values): extra = values.get('model_dirs', []) if not isinstance(extra, list) or any(not isinstance(p, str) for p in extra): raise ValueError('Model folders must be a list of directory paths') - extra = extra + ([values['models']] if values.get('models') else []) + from .launcher_settings import extra_model_directories + extra = extra + ([values['models']] if values.get('models') else []) + extra_model_directories() # A saved folder that no longer exists (for example a removed offline # package) has nothing to reuse; it must not stop the installation. extra = [p for p in dict.fromkeys(extra) if p.strip() and io_path(Path(p).expanduser()).is_dir()] diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index e11d77f..eacab22 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -233,6 +233,8 @@ def release(): preview['freevideo_summary'][0]['continuation']['requested_frames'] = options['frames'] preview['freevideo_summary'][0]['result_cache_hit'] = result_reused[0] except BaseException as error: + from .resident_process import OWNER + OWNER.finished() cancelled = isinstance(error, KeyboardInterrupt) or type(error).__name__ in ('InterruptProcessingException', 'CancelledError') phase = 'cancelled' if cancelled else 'failed' publish(PromptServer.instance, node_id, {'label': 'Generation cancelled' if cancelled else 'Generation stopped', @@ -244,11 +246,32 @@ def release(): from .encoder_prewarm import warm_target target = warm_target(measured) from .resident_process import OWNER + server = PromptServer.instance + def busy(): + return server is not None and server.prompt_queue.get_tasks_remaining() > 0 + # Keep the models only while the next part is on its way; otherwise give + # the memory back now and let the file cache make the next load quick. + try: + # The running prompt is this one; only prompts still waiting are a next part. + queued = server is not None and bool(server.prompt_queue.get_current_queue()[1]) + except (AttributeError, TypeError, ValueError, IndexError): + queued = False + try: + # Agent videos wait in the agent's own list until the running one ends. + queued = queued or any(row['kind'] == 'generate' and row['status'] == 'waiting' + for row in server._freevideo_agent.active()) + except (AttributeError, TypeError, KeyError): + pass + next_part = 'queued' if queued else ('continuing' if chain is not None and chain.get('index', 0) + 1 < chain.get('count', 0) + or chain is None and options.get('continue_from') else None) + try: + gpu = comfy_bridge.installation()[1].get('gpu_uuid') + except (OSError, ValueError, KeyError, TypeError): + gpu = None + OWNER.finished(next_part=next_part, gpu=gpu, + agent=server is not None and str(getattr(server, 'client_id', '') or '').startswith('agent:')) if target and not result_reused[0]: - server = PromptServer.instance client = server.client_id if server is not None else None - def busy(): - return server is not None and server.prompt_queue.get_tasks_remaining() > 0 def warmed(value): if server is not None: server.send_sync('freevideo_prewarm', dict(value, node=node_id), sid=client) @@ -258,12 +281,8 @@ def warmed(value): IDLE.schedule(path, output.with_suffix('.prewarm.json'), ram_budget=measured.get('profile', {}).get('inference_ram_budget_gb', 0) * 1e9, busy=busy, notify=warmed, purpose='upscale') - elif OWNER.process is not None: - root, machine = comfy_bridge.installation() - environment = comfy_bridge.engine_environment(root, comfy_bridge.source_root()) - environment.update(FREEVIDEO_HOME=str(root), PYTHONPATH=str(comfy_bridge.source_root())) - OWNER.warm_encoder(output, machine['python'], environment, busy=busy, notify=warmed) else: + # File cache only: a kept worker must not grow on the card while it waits. IDLE.schedule(path, output.with_suffix('.prewarm.json'), ram_budget=measured.get('profile', {}).get('inference_ram_budget_gb', 0) * 1e9, busy=busy, notify=warmed) diff --git a/freevideo_engine/comfy_residency.py b/freevideo_engine/comfy_residency.py index c9062d9..b2ee815 100644 --- a/freevideo_engine/comfy_residency.py +++ b/freevideo_engine/comfy_residency.py @@ -31,7 +31,7 @@ def free_memory(memory_required, device, *args, **kwargs): if (not getattr(_local, 'keep', False) and not OWNER.active and OWNER.process is not None and OWNER.process.poll() is None): if device is None or memory_required > memory.get_free_memory(device): - OWNER.close() + OWNER.release('comfy_model') return original(memory_required, device, *args, **kwargs) free_memory._freevideo_resident_cooperation = True diff --git a/freevideo_engine/comfy_setup.py b/freevideo_engine/comfy_setup.py index 77f9104..e71834c 100644 --- a/freevideo_engine/comfy_setup.py +++ b/freevideo_engine/comfy_setup.py @@ -25,7 +25,8 @@ def discover_libraries(folder_paths, environ=None): env = os.environ if environ is None else environ - candidates = [str(folder_paths.models_dir)] + from .launcher_settings import saved_model_libraries + candidates = saved_model_libraries() + [str(folder_paths.models_dir)] # folder_paths already incorporates extra_model_paths.yaml, including paths # registered by other nodes. No second, subtly different YAML parser. for name in ('diffusion_models', 'unet', 'checkpoints', 'text_encoders', 'clip', 'vae'): @@ -157,14 +158,34 @@ def discovery(self, download_root=None): except (OSError, ValueError) as error: detail = str(error) from .sampling_assets import total_bytes + from .launcher_settings import extra_model_directories return dict(root=str(root), ready=ready, detail=detail, - libraries=discover_libraries(self.folder_paths), token=self.token, + libraries=discover_libraries(self.folder_paths), extra_libraries=extra_model_directories(), token=self.token, environment='Managed FreeVideo environment; ComfyUI packages are kept', restart_required=False, downloads=self.downloads(download_root or root), resources=self.resource_settings(), sampling_cache_bytes=total_bytes()) + @plain_errors + def model_directories(self, value): + if self.runner.busy: + raise ValueError('Wait for setup to finish before saving model folders') + from .launcher_settings import model_directory_store + choices = model_directory_store().save_model_directories(value.get('extra_libraries')) + missing = [] + for path in choices: + try: + exists = io_path(Path(path).expanduser()).is_dir() + except (OSError, RuntimeError): + exists = False + if not exists: + missing.append(path) + self.plan = None + self.state.pop('plan_id', None) + self.state.pop('plan', None) + return dict(saved=True, extra_libraries=choices, missing_libraries=missing) + def resource_settings(self, value=None): from .resource_settings import read, write, setting root = installation_root(self.source) @@ -254,7 +275,7 @@ def inspect(self, value): raise ValueError('Invalid setup choices') # Folders the user chose are validated and kept first; folders found # automatically fill the remaining places. - chosen = library_roots(extra) + chosen = library_roots(extra, skip_missing=True) libraries = library_roots(chosen + discover_libraries(self.folder_paths), keep_first=True) io_path(self.logs).mkdir(parents=True, exist_ok=True) manifest = self.logs / ('libraries-' + uuid.uuid4().hex + '.json') @@ -454,6 +475,8 @@ async def action(request): name = request.match_info['action'] if name == 'inspect': result = setup.inspect(value) + elif name == 'model-directories': + result = setup.model_directories(value) elif name in ('network-probe','network-source'): result = setup.network_settings(value,probe=name=='network-probe') elif name == 'network-token': diff --git a/freevideo_engine/comfy_source.py b/freevideo_engine/comfy_source.py index b81a38b..f57fb01 100644 --- a/freevideo_engine/comfy_source.py +++ b/freevideo_engine/comfy_source.py @@ -55,7 +55,7 @@ def download(target, *, plan, env, run): # A finished checkout survives cancellation before publication. Git fetch's # own incomplete attempts are retained beside this directory by network.clone. network.clone(stage, spec['url'], spec['commit'], network=plan, env=env, run=run) - if not all((stage / name).is_file() for name in ('main.py', 'folder_paths.py', 'requirements.txt', 'LICENSE')) or not (stage / 'comfy_api/latest').is_dir(): + if not all((stage / name).is_file() for name in ('main.py', 'folder_paths.py', 'requirements.txt')) or not (stage / 'comfy_api/latest').is_dir(): raise ValueError('The downloaded ComfyUI application is incomplete; files retained at ' + str(stage)) save(stage / RECEIPT, spec) if target.exists() or target.is_symlink(): diff --git a/freevideo_engine/dependencies.json b/freevideo_engine/dependencies.json index 1048331..74dadd1 100644 --- a/freevideo_engine/dependencies.json +++ b/freevideo_engine/dependencies.json @@ -38,7 +38,7 @@ "encoder_sha256": "35a88d51044231fe332301d7a62aa81e3f2cba62febeb446e2c1e3e0ef76f2c6", "edge_modelscope": { "repo": "OpenVDN/vdn-minimax-h3-edge", - "revision": "7832055a557c968ba86fa000374337560193adcf", + "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a", "hf_revision": "5e6ecc39f472f98ca7493000df49a4f712efb61a", "hf_snapshot_revision": "d95329e1441404e95082053b148ba5f8d3dd754f", "probe_file": "cache/blocks/00.safetensors" @@ -46,35 +46,35 @@ "edge_modelscope_variants": { "rowwise": { "repo": "OpenVDN/vdn-minimax-h3-edge", - "revision": "5be1ff90c02c9b1c1ce7300d6a803931732e6ab0", + "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a", "hf_revision": "ae041e5aec51f8516a3416bd86d579cb29c4c796", "probe_file": "rowwise/cache/blocks/00.safetensors" }, "int8_convrot": { "repo": "OpenVDN/vdn-minimax-h3-edge", - "revision": "17738b37839b490c5ae95246d8cb41893e76d887", + "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a", "hf_revision": "da37681f5a1f7015fdb63482142b865111b687d3", "probe_file": "int8-convrot/cache/blocks/00.safetensors" }, "community_sigma3": { "repo": "OpenVDN/vdn-minimax-h3-edge", "hf_revision": "4bbe518a5b24aa596f1e262e5f1a08062790670b", - "revision": "bbb2b06aded5536f42294fe4f3449240f86a5fdd" + "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a" }, "sampling_levels": { "repo": "OpenVDN/vdn-minimax-h3-edge", "hf_revision": "0cd66b30a58021012def78b0340caf131e0df5c2", - "revision": "1bff010a4fd429be95455fb0dbb2a34037f1bccd" + "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a" }, "reference_audio": { "repo": "OpenVDN/vdn-minimax-h3-edge", "hf_revision": "b3a8dd8d4cf215b3a90bcc20b4362318f5f4f158", - "revision": "fe57b4d98c95f9c6798431ff4d37515fa1192d97" + "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a" }, "sampling_steps": { "repo": "OpenVDN/vdn-minimax-h3-edge", "hf_revision": "8f6860a5e5de2844bb9af7fa801e0fafb2376bd5", - "revision": "f892657d413ea3e1033cab882f6fb8d1d33db61b" + "revision": "c247caf6f9f54fab9240e78fc7e0ec233016e47a" } }, "latent_upscaler": { diff --git a/freevideo_engine/desktop_runtime.py b/freevideo_engine/desktop_runtime.py index 48c082d..9edc47b 100644 --- a/freevideo_engine/desktop_runtime.py +++ b/freevideo_engine/desktop_runtime.py @@ -43,8 +43,11 @@ def launcher_root(): return Path(home) / 'FreeVideo-launcher' +NOTICE_FILES = ('LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md') + + def source_files(root): - names = ['pyproject.toml', 'README.md', 'LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md', 'MANIFEST.in', '__init__.py', + names = ['pyproject.toml', 'README.md', 'MANIFEST.in', '__init__.py', 'freevideo', 'setup.sh', 'test.sh', 'freevideo.ps1', 'setup.ps1', 'test.ps1', 'freevideo.cmd', 'setup.cmd', 'test.cmd', 'scripts/bootstrap_linux.sh', 'scripts/windows_app.py', 'scripts/build_windows.py', @@ -52,6 +55,8 @@ def source_files(root): # Older deployed Windows payloads may predate the optional Mac entry points. files_optional = ('scripts/build_macos.py', 'scripts/macos_app.py') names += [name for name in files_optional if (root / name).is_file()] + # Notice and licence files are carried when present; a launcher never fails to start without one. + names += [name for name in NOTICE_FILES if (root / name).is_file()] files = [root / name for name in names] files += [p for folder in ('freevideo_engine', 'constraints', 'web', 'example_workflows') for p in (root / folder).rglob('*') @@ -78,7 +83,7 @@ def matching_source(bundled, installed): return True try: for path in source_files(bundled): - if path.name == 'comfyui.json': + if path.name == 'comfyui.json' or path.relative_to(bundled).as_posix() in NOTICE_FILES: continue other = installed / path.relative_to(bundled) if not other.is_file() or path.read_bytes() != other.read_bytes(): diff --git a/freevideo_engine/editor_text.py b/freevideo_engine/editor_text.py index 84fea21..8e76892 100644 --- a/freevideo_engine/editor_text.py +++ b/freevideo_engine/editor_text.py @@ -8,6 +8,7 @@ from bisect import bisect_right import hashlib import json +import logging import math from pathlib import Path import re @@ -136,7 +137,8 @@ def _font(name, bold, size): @lru_cache(maxsize=8) def _verify_font(path, digest): if hashlib.sha256(Path(path).read_bytes()).hexdigest() != digest: - raise ValueError('Text font coverage is out of date') + # A replaced font can still render; the cached check warns once per face. + logging.getLogger(__name__).warning('Text font coverage is out of date; using font: %s', path) def wrap_text(text, measure, width): diff --git a/freevideo_engine/image_gen/image_models.json b/freevideo_engine/image_gen/image_models.json index a394c49..e7840ef 100644 --- a/freevideo_engine/image_gen/image_models.json +++ b/freevideo_engine/image_gen/image_models.json @@ -6,14 +6,14 @@ "revision": "main", "groups": { "image": [ - {"file": "diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors", "component": "dit", "bytes": 7256787829, "sha256": "8144a0182964aa8403ad6dbc011db837304167d235161b9aabaa8532e967314c", "sources": [{"id": "official", "url": "https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Comfy-Org/Qwen-Image-2.1/resolve/master/diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors"}]}, - {"file": "text_encoders/qwen3vl_8b_int8_convrot.safetensors", "component": "text_encoder", "bytes": 9350798360, "sha256": "8bfd0f6e12abf2d2d697ecc888e5e90b0d6741d6708f05799f53afa560452e8f", "sources": [{"id": "official", "url": "https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Comfy-Org/Qwen-Image-2.1/resolve/master/text_encoders/qwen3vl_8b_int8_convrot.safetensors"}]}, - {"file": "vae/qwen_image_2.1_vae_bf16.safetensors", "component": "vae", "bytes": 675509688, "sha256": "bb21f7473051e1ac368515dd3f2e15cd44d7a11748ee8823e1ddca3e4876b7c9", "sources": [{"id": "official", "url": "https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Comfy-Org/Qwen-Image-2.1/resolve/master/vae/qwen_image_2.1_vae_bf16.safetensors"}]}, - {"file": "text_encoder/config.json", "component": "text_encoder_config", "bytes": 1542, "sha256": "89050cba6b66c4f68f52305b54fc37efe28d95232246b94aa8dc0cfa36f3b734", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/text_encoder/config.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/text_encoder/config.json"}]}, - {"file": "processor/tokenizer.json", "component": "processor", "bytes": 11422650, "sha256": "be75606093db2094d7cd20f3c2f385c212750648bd6ea4fb2bf507a6a4c55506", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/tokenizer.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/tokenizer.json"}]}, - {"file": "processor/chat_template.jinja", "component": "processor", "bytes": 5292, "sha256": "3636d0f0bd6bef02654cdffdc447b79cb2cef8ab02cc75267345946291a489e4", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/chat_template.jinja"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/chat_template.jinja"}]}, - {"file": "processor/processor_config.json", "component": "processor", "bytes": 1300, "sha256": "14932921ca485d458a04dafd8069fbb0a4505622a48208d19ed247115801385b", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/processor_config.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/processor_config.json"}]}, - {"file": "processor/tokenizer_config.json", "component": "processor", "bytes": 705, "sha256": "fd42a0b35a8d13cd473e88e310b50cbf1bccaa9eb60ed0fd873af5f8f0c59307", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/tokenizer_config.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/tokenizer_config.json"}]} + {"file": "diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors", "component": "dit", "bytes": 7256787829, "sha256": "8144a0182964aa8403ad6dbc011db837304167d235161b9aabaa8532e967314c", "sources": [{"id": "official", "url": "https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Comfy-Org/Qwen-Image-2.1/resolve/main/diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Comfy-Org/Qwen-Image-2.1/resolve/master/diffusion_models/qwen_image_2.1_turbo_int8_convrot.safetensors"}]}, + {"file": "text_encoders/qwen3vl_8b_int8_convrot.safetensors", "component": "text_encoder", "bytes": 9350798360, "sha256": "8bfd0f6e12abf2d2d697ecc888e5e90b0d6741d6708f05799f53afa560452e8f", "sources": [{"id": "official", "url": "https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Comfy-Org/Qwen-Image-2.1/resolve/main/text_encoders/qwen3vl_8b_int8_convrot.safetensors"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Comfy-Org/Qwen-Image-2.1/resolve/master/text_encoders/qwen3vl_8b_int8_convrot.safetensors"}]}, + {"file": "vae/qwen_image_2.1_vae_bf16.safetensors", "component": "vae", "bytes": 675509688, "sha256": "bb21f7473051e1ac368515dd3f2e15cd44d7a11748ee8823e1ddca3e4876b7c9", "sources": [{"id": "official", "url": "https://huggingface.co/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Comfy-Org/Qwen-Image-2.1/resolve/main/vae/qwen_image_2.1_vae_bf16.safetensors"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Comfy-Org/Qwen-Image-2.1/resolve/master/vae/qwen_image_2.1_vae_bf16.safetensors"}]}, + {"file": "text_encoder/config.json", "component": "text_encoder_config", "bytes": 1542, "sha256": "89050cba6b66c4f68f52305b54fc37efe28d95232246b94aa8dc0cfa36f3b734", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/text_encoder/config.json"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Qwen/Qwen-Image-2.1-Turbo/resolve/main/text_encoder/config.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/text_encoder/config.json"}]}, + {"file": "processor/tokenizer.json", "component": "processor", "bytes": 11422650, "sha256": "be75606093db2094d7cd20f3c2f385c212750648bd6ea4fb2bf507a6a4c55506", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/tokenizer.json"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/tokenizer.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/tokenizer.json"}]}, + {"file": "processor/chat_template.jinja", "component": "processor", "bytes": 5292, "sha256": "3636d0f0bd6bef02654cdffdc447b79cb2cef8ab02cc75267345946291a489e4", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/chat_template.jinja"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/chat_template.jinja"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/chat_template.jinja"}]}, + {"file": "processor/processor_config.json", "component": "processor", "bytes": 1300, "sha256": "14932921ca485d458a04dafd8069fbb0a4505622a48208d19ed247115801385b", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/processor_config.json"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/processor_config.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/processor_config.json"}]}, + {"file": "processor/tokenizer_config.json", "component": "processor", "bytes": 705, "sha256": "fd42a0b35a8d13cd473e88e310b50cbf1bccaa9eb60ed0fd873af5f8f0c59307", "sources": [{"id": "official", "url": "https://huggingface.co/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/tokenizer_config.json"}, {"id": "hf-mirror", "url": "https://hf-mirror.com/Qwen/Qwen-Image-2.1-Turbo/resolve/main/processor/tokenizer_config.json"}, {"id": "modelscope", "url": "https://www.modelscope.cn/models/Qwen/Qwen-Image-2.1-Turbo/resolve/master/processor/tokenizer_config.json"}]} ] } } diff --git a/freevideo_engine/image_gen/image_pack.py b/freevideo_engine/image_gen/image_pack.py index b061866..f4d7088 100644 --- a/freevideo_engine/image_gen/image_pack.py +++ b/freevideo_engine/image_gen/image_pack.py @@ -9,6 +9,7 @@ from collections import deque from contextlib import ExitStack, contextmanager import json +import logging from pathlib import Path, PurePosixPath import re import signal @@ -28,6 +29,11 @@ BACKGROUND_RESERVE = 2 * 2**30 GENERATION_POLL_SECONDS = .25 GENERATION_RESUME_SECONDS = 10. +# Asking ComfyUI whether it is busy can take seconds (a closed port on Windows +# refuses only after about 2 s), and hashing checks once per 4 MiB block. Ask at +# most every 2 s, and a slow answer waits four times its own cost before the next. +GENERATION_PROBE_SECONDS = 2. +GENERATION_PROBE_COST_FACTOR = 4 SPEED_WINDOW_SECONDS = 10. STATUS_INTERVAL_SECONDS = .25 OFFLINE_TRANSFER_REASONS = frozenset(('curl-5', 'curl-6', 'curl-7')) @@ -49,10 +55,18 @@ def __init__(self, installer, stop): self.installer, self.stop = installer, stop self.resume_at = None self.paused = False + self.probed_at, self.probe_gap, self.busy_seen = None, 0., False def generating(self): - if self.installer.generation_busy and self.installer.generation_busy(): - return True + if self.installer.generation_busy: + if self.probed_at is None or time.monotonic() - self.probed_at >= self.probe_gap: + began = time.monotonic() + self.busy_seen = bool(self.installer.generation_busy()) + self.probed_at = time.monotonic() + self.probe_gap = max(GENERATION_PROBE_SECONDS, + GENERATION_PROBE_COST_FACTOR * (self.probed_at - began)) + if self.busy_seen: + return True # Probe the existing lease exclusively, without borrowing an inherited # descriptor. This also detects shared device work. Release immediately: # downloads never hold engine.lock or delay a generation acquiring it. @@ -122,7 +136,9 @@ def validate_catalog(spec): or not re.fullmatch('[0-9a-f]{64}', row['sha256']) or row['component'] not in ('dit', 'text_encoder', 'vae', 'text_encoder_config', 'processor')): raise PackError('invalid_catalog') - if {s['id'] for s in row['sources']} != {'official', 'modelscope'} or len(row['sources']) != 2: + ids = [s['id'] for s in row['sources']] + if (len(set(ids)) != len(ids) or not {'official', 'modelscope'} <= set(ids) + or not set(ids) <= {'official', 'modelscope', 'hf-mirror'}): raise PackError('invalid_catalog') for source in row['sources']: url = urlsplit(source['url']) @@ -185,12 +201,34 @@ def _owned_intact(path, stamp): stamp.get(k) == v for k, v in provision.file_identity(path, stamp).items()) +# The notice written next to the weights. The text lives here; licenses/NOTICE.txt carries the same words +# for people reading the source and is never needed at run time. +NOTICE_TEXT = (b'Qwen is licensed under the Qwen RESEARCH LICENSE AGREEMENT, Copyright (c) 2026 Hangzhou Tongyi ' + b'Laboratory Technology Co., Ltd. All Rights Reserved.\n') + + def _notice(): - return (Path(__file__).parent / 'licenses' / 'NOTICE.txt').read_bytes() + return NOTICE_TEXT def _notice_ready(path): - return path.is_file() and path.stat().st_size == len(_notice()) and path.read_bytes() == _notice() + """Informational only: a missing or different notice never makes the pack unusable.""" + try: + return path.is_file() and path.read_bytes() == NOTICE_TEXT + except OSError: + return False + + +def _write_notice(folder, owned): + """Put the notice into the model folder when possible; a failure is logged and the install goes on.""" + notice = _path(folder, 'NOTICE.txt') + try: + if notice.exists(): + return # present (ours or the user's own text): left as it is + notice.write_bytes(NOTICE_TEXT) + _own(folder, owned, notice) + except Exception as error: + logging.warning('[FreeVideo] Could not write the image model notice: %s', error) def status(root=None): @@ -213,7 +251,7 @@ def status(root=None): notice = _path(folder, 'NOTICE.txt') notice_ready = _notice_ready(notice) total = sum(r['bytes'] for r in files) - available = not missing and notice_ready + available = not missing return dict(installable=True, ready=available, total_bytes=total, verified_bytes=verified_bytes, downloaded_bytes=downloaded, bytes=sum(r['bytes'] for r in files if not r['ready']), remaining_bytes=sum(r['bytes'] - r['downloaded_bytes'] for r in files if not r['ready']), @@ -281,9 +319,9 @@ def _download_plan(engine_root=None): # that measured ranking until an image-specific probe is available. if FAMILY not in plan['sources']: inherited = plan['sources'].get('vdn-models', []) - plan['sources'][FAMILY] = [dict(r) for r in inherited if r['id'] in ('official', 'modelscope')] + plan['sources'][FAMILY] = [dict(r) for r in inherited if r['id'] in ('official', 'modelscope', 'hf-mirror')] known = {r['id'] for r in plan['sources'][FAMILY]} - plan['sources'][FAMILY] += [dict(id=s) for s in ('official', 'modelscope') if s not in known] + plan['sources'][FAMILY] += [dict(id=s) for s in ('official', 'hf-mirror', 'modelscope') if s not in known] return plan @@ -309,7 +347,7 @@ def event(value): background.wait() settings = current(plan) # An explicit choice supersedes affinity to the old partial's source. - plan['manual_source_switch'] = settings['source'] in ('official', 'modelscope') + plan['manual_source_switch'] = settings['source'] in ('official', 'hf-mirror', 'modelscope') def resource_check(): check() latest = current(plan) @@ -363,13 +401,7 @@ def install(root=None, progress=None, check=None, *, engine_root=None, repair=Fa plan = _download_plan(engine_root) plan.update(quiet=True, resource_check=check) owned = _read(folder, OWNERSHIP) - notice = _path(folder, 'NOTICE.txt') - if notice.exists() and not _notice_ready(notice): - # Preserve an unexpected text rather than overwriting it. - model_repair.rename_noreplace(notice, notice.with_name('NOTICE.txt.replaced-' + str(time.time_ns()))) - if not notice.exists(): - notice.write_bytes(_notice()) - _own(folder, owned, notice) + _write_notice(folder, owned) done = 0 pending_prefixes = 0 if _background is not None: diff --git a/freevideo_engine/image_gen/image_policy.py b/freevideo_engine/image_gen/image_policy.py index 95c92dc..c25b72d 100644 --- a/freevideo_engine/image_gen/image_policy.py +++ b/freevideo_engine/image_gen/image_policy.py @@ -53,6 +53,12 @@ def size(layer): _DENOISE_BASE, _DENOISE_PER_MP = 1.06 * GiB, 0.554 * GiB _DECODE_BASE, _DECODE_PER_MP = 2.10 * GiB, 0.15 * GiB _UNTILED_DECODE_PER_MP = 6.5 * GiB # decoding the whole image at once instead of in tiles +# Decoding in 512-pixel tiles (256 overlap) keeps the decoder's padded tile edges +# out of tall pictures, where 256-pixel tiles left coloured vertical lines and a +# grid texture. A fixed-latent B200 sweep measured +1.43 GiB of decoder workspace; +# reserve 1.6 GiB. A card that cannot afford it decodes in 256-pixel tiles as before. +SMALL_VAE_TILE, LARGE_VAE_TILE = 256, 512 +_LARGE_TILE_DECODE_EXTRA = 1.6 * GiB # Reference images, about 1 MP each after resizing. Their tokens are recomputed at # every step (the pipeline keeps no prefix key/value cache: it would hold about # 2 GiB per reference on the card, and the extra compute is about 1 s per image on @@ -87,12 +93,14 @@ class ImagePlan: te_streamed_bytes: int link_floor_seconds: object reasons: list = field(default_factory=list) + vae_tile_size: int = SMALL_VAE_TILE def to_dict(self): return dict(self.__dict__) -def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pixels=0, vae_tiling=True): +def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pixels=0, vae_tiling=True, + vae_tile_size=SMALL_VAE_TILE): """Largest allocation of each stage for a given residency (bytes).""" mp = width * height / 1e6 ref_mp = reference_pixels / 1e6 @@ -102,6 +110,8 @@ def stage_peaks(shape, dit_resident, te_resident, *, width, height, reference_pi decode = _DECODE_BASE + _DECODE_PER_MP * mp + resident if not vae_tiling: decode += _UNTILED_DECODE_PER_MP * mp + elif vae_tile_size > SMALL_VAE_TILE: + decode += _LARGE_TILE_DECODE_EXTRA if ref_mp: encode += max(_REF_ENCODE_FLOOR, _REF_ENCODE_PER_MP * ref_mp - _REF_ENCODE_OFFSET) denoise += _REF_DENOISE_PER_MP * ref_mp @@ -127,11 +137,15 @@ def plan_image(*, gpu_budget_bytes, host_available_bytes=None, width=1024, heigh reasons = [] kwargs = dict(width=width, height=height, reference_pixels=reference_pixels) + tile_size = LARGE_VAE_TILE + def peak(kd, kt, tiling=True): - return max(stage_peaks(shape, kd, kt, vae_tiling=tiling, **kwargs).values()) + return max(stage_peaks(shape, kd, kt, vae_tiling=tiling, vae_tile_size=tile_size, **kwargs).values()) kd = kt = 0 tiling = True + if peak(0, 0) > limit: + tile_size = SMALL_VAE_TILE feasible = peak(0, 0) <= limit if not feasible: reasons.append('even full streaming with a tiled VAE needs %.1f GiB beyond the %.1f GiB budget' % ( @@ -146,7 +160,7 @@ def peak(kd, kt, tiling=True): # Untiled decoding is faster; take it only when it fits beside the same residency. if peak(kd, kt, tiling=False) <= limit: tiling = False - peaks = stage_peaks(shape, kd, kt, vae_tiling=tiling, **kwargs) + peaks = stage_peaks(shape, kd, kt, vae_tiling=tiling, vae_tile_size=tile_size, **kwargs) streamed_dit = (shape.dit_blocks - kd) * shape.dit_block_bytes streamed_te = (shape.te_layers - kt) * shape.te_layer_bytes @@ -169,4 +183,4 @@ def peak(kd, kt, tiling=True): host_mode=host_mode, feasible=feasible and host_mode != 'insufficient', peak_allocated_bytes=max(peaks.values()), stage_peaks=peaks, margin_bytes=margin, dit_streamed_bytes_per_step=streamed_dit, te_streamed_bytes=streamed_te, - link_floor_seconds=floor, reasons=reasons) + link_floor_seconds=floor, reasons=reasons, vae_tile_size=tile_size) diff --git a/freevideo_engine/image_gen/image_runner.py b/freevideo_engine/image_gen/image_runner.py index cde4150..593a0db 100644 --- a/freevideo_engine/image_gen/image_runner.py +++ b/freevideo_engine/image_gen/image_runner.py @@ -126,7 +126,7 @@ def path(directory, name): self.shape = image_policy.ImageShape.measure( list(self.transformer.transformer_blocks), list(language.layers), - total_weight_bytes=self.dit_stack.pinned_bytes + self.te_stack.pinned_bytes) + total_weight_bytes=self.dit_stack.host_bytes + self.te_stack.host_bytes) self.generator = QwenImageGenerator( None, placement=_Placement(self), device=self.device, transformer=self.transformer, vae=self.vae, text_encoder=text, processor=processor, sample_sigmas=None, vae_tile=True) @@ -160,8 +160,16 @@ def plan(self, width, height, reference_images=None): scale = (resolution * resolution / (image.width * image.height)) ** 0.5 reference_pixels += round(image.width * scale) * round(image.height * scale) try: - from ..offload import live_pin_capacity - host_available = live_pin_capacity() + self.dit_stack.pinned_bytes + self.te_stack.pinned_bytes + # The weights are loaded by now, so what is left to find is the request's + # working memory. Credit what the weights already hold, locked or not, and + # read free RAM itself: the locked-page budget (half of RAM on Windows) + # and its headroom only limit pinning, which happened at load. Gating on + # them refused every request on a 28 GB Windows machine, the second one + # after a first had just been made at the same size. + from ..system import system_memory + memory = system_memory() + free = memory.get('physical_available_bytes') or memory['available_bytes'] + host_available = free + self.dit_stack.host_bytes + self.te_stack.host_bytes except Exception: host_available = None return image_policy.plan_image( @@ -176,6 +184,7 @@ def generate(self, prompt, *, width, height, seed=None, reference_images=None, p self.dit_stack.set_resident(plan.dit_resident_layers) self.te_stack.set_resident(plan.te_resident_layers) self.generator.vae_tile = plan.vae_tiling + self.generator.vae_tile_size = plan.vae_tile_size return self.generator.generate(prompt, width=width, height=height, seed=seed, reference_images=reference_images, progress=progress, cancel=cancel) diff --git a/freevideo_engine/image_gen/image_stream.py b/freevideo_engine/image_gen/image_stream.py index d9836a2..7b783a5 100644 --- a/freevideo_engine/image_gen/image_stream.py +++ b/freevideo_engine/image_gen/image_stream.py @@ -21,6 +21,10 @@ def __init__(self, layers, *, name, device='cuda', pin=True, max_pin_bytes=None, self.layers = list(layers) if not self.layers: raise ValueError('StreamedStack needs at least one layer') + # Host bytes of every layer, pinned or not: the weights hold this much of + # the host whichever storage they ended up on. + self.host_bytes = sum(tensor.numel() * tensor.element_size() for layer in self.layers + for tensor in list(layer.parameters()) + list(layer.buffers())) # Pinning is an optimization: a host that refuses it costs transfer # speed, not the request (pin_layer_weights keeps the unpinned layers # on their ordinary storage and the offloader stages them). diff --git a/freevideo_engine/image_gen/qwen_pipeline.py b/freevideo_engine/image_gen/qwen_pipeline.py index e4c90f0..8bde8b5 100644 --- a/freevideo_engine/image_gen/qwen_pipeline.py +++ b/freevideo_engine/image_gen/qwen_pipeline.py @@ -158,6 +158,7 @@ def __init__(self, pack_dir: Path | None, *, placement: ImagePlacement | None = self.text_encoder = text_encoder self._closed = False self.vae_tile = vae_tile + self.vae_tile_size = None # None: the decoder's own profile if not isinstance(reference_resolution, int) or reference_resolution < 32: raise ValueError("reference_resolution must be at least 32") self.reference_resolution = reference_resolution @@ -358,6 +359,9 @@ def report(stage, fraction): kwargs = {"return_dict": False} if self.vae_tile: kwargs["tile"] = True + if self.vae_tile_size: + kwargs["tile_size"] = int(self.vae_tile_size) + kwargs["tile_stride"] = int(self.vae_tile_size) // 2 pixels = _sample(self.vae.decode(latents, **kwargs))[:, :, 0] check_cancel() array = (pixels / 2 + 0.5).clamp(0, 1).float().cpu().permute(0, 2, 3, 1).numpy()[0] diff --git a/freevideo_engine/image_gen/qwen_vae.py b/freevideo_engine/image_gen/qwen_vae.py index e00ecc0..e96861c 100644 --- a/freevideo_engine/image_gen/qwen_vae.py +++ b/freevideo_engine/image_gen/qwen_vae.py @@ -1344,7 +1344,8 @@ def _decode(self, z: torch.Tensor, return_dict: bool = True): return DecoderOutput(sample=out) # Copied from diffusers.models.autoencoders.autoencoder_kl_wan.AutoencoderKLWan.decode - def decode(self, z: torch.Tensor, return_dict: bool = True, *, tile: bool | None = None) -> DecoderOutput | torch.Tensor: + def decode(self, z: torch.Tensor, return_dict: bool = True, *, tile: bool | None = None, + tile_size: int | None = None, tile_stride: int | None = None) -> DecoderOutput | torch.Tensor: r""" Decode a batch of images. @@ -1359,8 +1360,15 @@ def decode(self, z: torch.Tensor, return_dict: bool = True, *, tile: bool | None returned. """ previous_tiling = self.use_tiling + previous_tiles = (self.tile_sample_min_height, self.tile_sample_min_width, + self.tile_sample_stride_height, self.tile_sample_stride_width) if tile is not None: self.use_tiling = tile + if tile_size is not None: + # Larger decoding tiles keep the decoder's padded tile edges out of the picture; + # encoding keeps its own profile so the latents do not depend on the card. + self.tile_sample_min_height = self.tile_sample_min_width = int(tile_size) + self.tile_sample_stride_height = self.tile_sample_stride_width = int(tile_stride or tile_size // 2) try: if self.use_slicing and z.shape[0] > 1: decoded_slices = [self._decode(z_slice).sample for z_slice in z.split(1)] @@ -1369,6 +1377,8 @@ def decode(self, z: torch.Tensor, return_dict: bool = True, *, tile: bool | None decoded = self._decode(z).sample finally: self.use_tiling = previous_tiling + (self.tile_sample_min_height, self.tile_sample_min_width, + self.tile_sample_stride_height, self.tile_sample_stride_width) = previous_tiles if not return_dict: return (decoded,) @@ -1504,9 +1514,11 @@ def tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> DecoderOutp # Split z into overlapping tiles and decode them separately. # The tiles have an overlap to avoid seams between tiles. rows = [] - for i in range(0, height, tile_latent_stride_height): + # Stop once a tile already covers the edge: a redundant narrow last tile has little + # context and leaves coloured seams. + for i in range(0, max(0, height - tile_latent_min_height) + tile_latent_stride_height, tile_latent_stride_height): row = [] - for j in range(0, width, tile_latent_stride_width): + for j in range(0, max(0, width - tile_latent_min_width) + tile_latent_stride_width, tile_latent_stride_width): self.clear_cache() time = [] for k in range(num_frames): @@ -1531,7 +1543,9 @@ def tiled_decode(self, z: torch.Tensor, return_dict: bool = True) -> DecoderOutp tile = self.blend_v(rows[i - 1][j], tile, blend_height) if j > 0: tile = self.blend_h(row[j - 1], tile, blend_width) - result_row.append(tile[:, :, :, :tile_sample_stride_height, :tile_sample_stride_width]) + keep_height = tile_sample_stride_height if i < len(rows) - 1 else sample_height - i * tile_sample_stride_height + keep_width = tile_sample_stride_width if j < len(row) - 1 else sample_width - j * tile_sample_stride_width + result_row.append(tile[:, :, :, :keep_height, :keep_width]) result_rows.append(torch.cat(result_row, dim=-1)) dec = torch.cat(result_rows, dim=3)[:, :, :, :sample_height, :sample_width] diff --git a/freevideo_engine/image_gen/service.py b/freevideo_engine/image_gen/service.py index 2e6d55b..c3727d7 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -309,6 +309,7 @@ def work(self, root, machine): env[resident_process.ENV] = str(owner.endpoint) try: resident_process.release_idle_cache(env, descriptor) + resident_process.OWNER.record(action='released', reason='image_model') except resident_process.SessionRestarted: pass env.pop(resident_process.ENV, None) diff --git a/freevideo_engine/image_gen/worker.py b/freevideo_engine/image_gen/worker.py index 2839488..3fffbd7 100644 --- a/freevideo_engine/image_gen/worker.py +++ b/freevideo_engine/image_gen/worker.py @@ -2,6 +2,7 @@ import argparse from contextlib import ExitStack, redirect_stdout import json +import os from pathlib import Path import queue import sys @@ -150,7 +151,14 @@ def main(): args = parser.parse_args() if not 0 < args.idle_seconds < float('inf'): parser.error('--idle-seconds must be positive and finite') - serve(args.pack_dir, device=args.device, idle_seconds=args.idle_seconds) + stream = None + if os.name == 'nt': + from ..win32 import private_stdin + try: + stream = private_stdin() + except OSError as error: + print('private stdin unavailable, reading stdin directly: %s' % error, file=sys.stderr, flush=True) + serve(args.pack_dir, device=args.device, idle_seconds=args.idle_seconds, stream=stream) return 0 diff --git a/freevideo_engine/launcher/Main.qml b/freevideo_engine/launcher/Main.qml index f0dbf70..f8b5c64 100644 --- a/freevideo_engine/launcher/Main.qml +++ b/freevideo_engine/launcher/Main.qml @@ -1181,8 +1181,9 @@ ApplicationWindow { } } } + // Mac generation shares unified memory and has no compatibility levels. FGroup { - Layout.fillWidth: true; title: t("Compatibility", "兼容性") + Layout.fillWidth: true; title: t("Compatibility", "兼容性"); visible: Qt.platform.os !== "osx" RowLayout { Layout.fillWidth: true FText { text: t("Level", "档位"); Layout.fillWidth: true } diff --git a/freevideo_engine/launcher_settings.py b/freevideo_engine/launcher_settings.py index 10a19ee..4b80a48 100644 --- a/freevideo_engine/launcher_settings.py +++ b/freevideo_engine/launcher_settings.py @@ -1,11 +1,12 @@ """Durable launcher preferences, independent of an EXE's unpack directory.""" +from contextlib import ExitStack, contextmanager import json import os from pathlib import Path import sys import time -from .monitoring import save +from .monitoring import save as _save from .long_paths import io_path, plain_data TEXT = ('comfy', 'destination', 'engine', 'models', 'python', 'url', 'language', 'offline_runtime') @@ -14,6 +15,12 @@ ARCHIVE_IDENTITY = ('path', 'bytes', 'mtime_ns') +def save(path, value): + # sanitize() already normalizes the other settings. Keep directory choices + # verbatim, including a user's explicit Windows namespace spelling. + _save(path, value, plain_paths=False) + + def default_language(): if sys.platform == 'darwin': from .macos_preferences import preferred_language @@ -28,6 +35,7 @@ def default_language(): def sanitize(value): + extra = value.get('extra_model_dirs', []) if isinstance(value, dict) else [] value = plain_data(value) if isinstance(value, dict) else {} result = {k: value[k] for k in TEXT if isinstance(value.get(k), str)} for key in ('separate', 'new_comfy', 'sampling_caches', 'lan_access', 'image_model'): @@ -67,6 +75,12 @@ def sanitize(value): resume = value.get('setup') if isinstance(resume, dict): result['setup'] = {k: resume[k] for k in ('status', 'action', 'phase') if isinstance(resume.get(k), str)} + if 'extra_model_dirs' in value: + from .local_models import directory_choices + try: + result['extra_model_dirs'] = directory_choices(extra) + except (OSError, RuntimeError, ValueError, TypeError): + result['extra_model_dirs'] = [] return result @@ -118,7 +132,40 @@ def currently_allows(self, key): values = [read(path) for path in self.paths] return bool(values) and all(value.get(key) is True for value in values) + @contextmanager + def exclusive(self, wait=10.): + """Serialize read-merge-write between the launcher and the node panel. + + A lock that cannot be taken in time never stops a save: the settings + are small and a rare lost update is better than a button that does nothing. + """ + from .locking import runtime_lock + stack, deadline = ExitStack(), time.monotonic() + wait + while True: + try: + stack.enter_context(runtime_lock(self.local.with_suffix('.lock'), inherit=False)) + break + except BlockingIOError: + if time.monotonic() > deadline: + break + time.sleep(.05) + except (OSError, ValueError): + break + with stack: + yield + def write(self, values, language, selection=None, setup=None): + with self.exclusive(): + return self.write_locked(values, language, selection, setup) + + def write_locked(self, values, language, selection=None, setup=None): + # A launcher form opened before a node-panel save does not own this + # field. Preserve its current value when other preferences are saved. + values = dict(values) + if 'extra_model_dirs' not in values: + current = self.read() + if 'extra_model_dirs' in current: + values['extra_model_dirs'] = current['extra_model_dirs'] payload = sanitize(dict(values, language=language, installation=selection, setup=setup, saved_at_ns=time.time_ns())) failures, written = [], [] @@ -134,3 +181,34 @@ def write(self, values, language, selection=None, setup=None): if not written: raise failures[0][1] return written, failures + + def save_model_directories(self, values): + from .local_models import directory_choices + choices = directory_choices(values) + with self.exclusive(): + saved = self.read() + saved['extra_model_dirs'] = choices + self.write_locked(saved, saved.get('language', default_language()), + saved.get('installation'), saved.get('setup')) + return choices + + +def model_directory_store(): + from .desktop_runtime import launcher_root + return Store(launcher_root()) + + +def extra_model_directories(): + """Untrusted preferences are validated on every read, including startup.""" + try: + return model_directory_store().read().get('extra_model_dirs', []) + except (OSError, RuntimeError, ValueError, TypeError): + return [] + + +def saved_model_libraries(): + from .local_models import library_roots + try: + return library_roots(extra_model_directories(), skip_missing=True) + except (OSError, RuntimeError, ValueError, TypeError): + return [] diff --git a/freevideo_engine/local_models.py b/freevideo_engine/local_models.py index 3871365..0619cd6 100644 --- a/freevideo_engine/local_models.py +++ b/freevideo_engine/local_models.py @@ -16,7 +16,7 @@ MAX_LISTED = 4096 # Paths accepted before duplicates and nested folders are merged. -def library_roots(values, *, keep_first=False): +def library_roots(values, *, keep_first=False, skip_missing=False): """Normalize selected libraries without walking entire drives. The 64-library limit counts distinct folders: a folder named twice, or one @@ -27,10 +27,14 @@ def library_roots(values, *, keep_first=False): raise ValueError('Invalid model library list') roots = [] for value in values: - if not isinstance(value, str) or not value.strip() or len(value) > 4096: + if not isinstance(value, str) or not value.strip() or len(value) > 4096 or '\0' in value: raise ValueError('Invalid model library path') path = resolved(Path(value).expanduser()) - if not path.is_dir() or path == Path(path.anchor): + if path == Path(path.anchor): + raise ValueError('Choose an existing model folder, not an entire drive: ' + plain(path)) + if not path.is_dir(): + if skip_missing: + continue raise ValueError('Choose an existing model folder, not an entire drive: ' + plain(path)) if path not in roots: roots.append(path) @@ -44,6 +48,35 @@ def library_roots(values, *, keep_first=False): return [plain(path) for path in roots] +def directory_choices(values): + """Validate preferences without requiring a mounted/existing library. + + Keep the user's spelling; resolution is only for drive-root safety and + discovery. Missing directories must survive saving and reopening. + """ + if not isinstance(values, list) or len(values) > MAX_LISTED: + raise ValueError('Invalid model library list') + choices = [] + for value in values: + if not isinstance(value, str) or len(value) > 4096 or '\0' in value: + raise ValueError('Invalid model library path') + value = value.strip() + if not value: + continue + path = resolved(Path(value).expanduser()) + if path == Path(path.anchor): + raise ValueError('Choose a model folder, not an entire drive: ' + plain(path)) + if value not in choices: + choices.append(value) + # Count canonical distinct/nested paths just as library_roots does, even + # when a drive is currently disconnected. + roots = list(dict.fromkeys(resolved(Path(p).expanduser()) for p in choices)) + roots = [p for p in roots if not any(parent in roots for parent in p.parents)] + if len(roots) > MAX_ROOTS: + raise ValueError('Select at most 64 model libraries') + return choices + + def scan_many(directories, rows, *, prior=None, callback=None): roots = library_roots(directories) result = dict(root=roots[0] if roots else None, roots=roots, matches={}, diff --git a/freevideo_engine/macos_encoder.py b/freevideo_engine/macos_encoder.py index 2805e1b..3bdc43f 100644 --- a/freevideo_engine/macos_encoder.py +++ b/freevideo_engine/macos_encoder.py @@ -218,7 +218,6 @@ def release_encoder(): # encoder reference before the independent input VAE is loaded. del encoded, tokens, vision_kwargs release_encoder() - # Text streaming deliberately uses a small allocation ceiling. # Input VAEs have separate workspaces: re-admit after releasing # text weights, against the request's original capacity bound. # configure_budget also enforces live availability, the reserve diff --git a/freevideo_engine/macos_generate.py b/freevideo_engine/macos_generate.py index 89a9646..f5e6a7d 100644 --- a/freevideo_engine/macos_generate.py +++ b/freevideo_engine/macos_generate.py @@ -54,7 +54,9 @@ def amount(name, default): allocator_capacity = min(capacity - reserve, amount('allocator_limit_gib', capacity - reserve)) return dict(device_backend='mps', memory_model='unified', observed=memory, reserve_bytes=reserve, allocator_bytes=allocator, allocator_capacity_bytes=allocator_capacity, - encoder_bytes=min(3 * GiB, allocator), + # Text/vision encoding gets the same live allowance: a fixed 3 GiB trial + # cap failed once reference images added vision tokens on large Macs. + encoder_bytes=allocator, ram_budget_bytes=capacity if getattr(args, 'ram_gib', None) is not None else working, ram_budget_is_estimate=getattr(args, 'ram_gib', None) is None, emergency_floor_bytes=GiB, query_chunk=128, ff_chunk=256, projection_chunk=256, diff --git a/freevideo_engine/macos_vdn.py b/freevideo_engine/macos_vdn.py index dd3c5a5..0dd0c07 100644 --- a/freevideo_engine/macos_vdn.py +++ b/freevideo_engine/macos_vdn.py @@ -94,7 +94,10 @@ def validate(): path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(data) (stage / 'identity.json').write_text(json.dumps(identity, indent=2) + '\n') - shutil.copyfile(source / 'LICENSE', stage / 'LICENSE') + try: + shutil.copyfile(source / 'LICENSE', stage / 'LICENSE') + except OSError: + pass # the licence text is carried when the source has one try: stage.rename(target) except OSError: diff --git a/freevideo_engine/monitoring.py b/freevideo_engine/monitoring.py index 2b4ce75..c7ea98f 100644 --- a/freevideo_engine/monitoring.py +++ b/freevideo_engine/monitoring.py @@ -12,7 +12,7 @@ from .long_paths import io_path, plain_data -def save(path, value, *, staging=None): +def save(path, value, *, staging=None, plain_paths=True): path = io_path(path) path.parent.mkdir(parents=True, exist_ok=True) # Unique, closed staging file; Windows readers/antivirus may briefly hold @@ -23,7 +23,7 @@ def save(path, value, *, staging=None): prefix=path.name + '.' if staging is None else staging, suffix='.tmp', dir=path.parent, delete=False) as stream: temporary = Path(stream.name) - stream.write(json.dumps(plain_data(value), ensure_ascii=False, indent=2) + '\n') + stream.write(json.dumps(plain_data(value) if plain_paths else value, ensure_ascii=False, indent=2) + '\n') for attempt in range(8): try: temporary.replace(path) diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 7656b78..934101b 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -33,8 +33,9 @@ 'vdn-models': {'official': 'https://huggingface.co', 'hf-mirror': 'https://hf-mirror.com', 'modelscope': 'https://www.modelscope.cn'}, 'edge-models': {'official': 'https://huggingface.co', 'hf-mirror': 'https://hf-mirror.com', - 'modelscope': 'https://modelscope.ai'}, - 'image-models': {'official': 'https://huggingface.co', 'modelscope': 'https://www.modelscope.cn'}, + 'modelscope': 'https://www.modelscope.cn'}, + 'image-models': {'official': 'https://huggingface.co', 'hf-mirror': 'https://hf-mirror.com', + 'modelscope': 'https://www.modelscope.cn'}, 'github': {'official': 'https://github.com', 'ghfast': 'https://ghfast.top/https://github.com'}, 'cuda': {'official': 'https://developer.download.nvidia.com/compute/cuda/redist', 'nvidia-cn': 'https://developer.download.nvidia.cn/compute/cuda/redist'}, diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index b7d21d1..13e87ae 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -434,12 +434,14 @@ def int8_gpu_output_need(tokens, windows=False, full_tail=False): # blocks, against 7.14 and 7.82 from the envelope, so the plan held sixteen # and eighteen blocks where the budget held about twenty-five and streamed # the rest from disk on every step. Each figure carries one GiB of margin. -# The curve joins the envelope at its lowest anchor (896 square, 243 -# frames) and never exceeds it. It rests on one card. +# It rests on one card and holds only up to its last measured point. Joined +# straight to the envelope's anchor, a 16 GB Windows laptop's 44370-token +# refinement held three more blocks than the envelope gave, filled the card +# beside other programs, and Windows moved memory to system RAM: each step +# took five times as long. Above 16320 tokens the envelope applies. SMALL_CANVAS_INT8_WORKSPACE = ( (4320, 3.48), (16320, 4.46), - (56448, 6.5 + 6. * 56448 / 72576 + BLOCK_BYTES / GiB - 56448 * FF_STASH_WIDTH * 2 / GiB), ) @@ -458,12 +460,12 @@ def windows_gpu_output_workspace(video_tokens, *, prefetch=False, ff_stash=True, estimate, not a fitted throughput model or proof of capacity elsewhere. The OS growth reserve has already been subtracted from the budget. - small_canvas=True counts resident blocks below the anchors from the - measured int8 curve (SMALL_CANVAS_INT8_WORKSPACE) instead of the envelope. + small_canvas=True counts resident blocks up to the last measured point + from the int8 curve (SMALL_CANVAS_INT8_WORKSPACE) instead of the envelope. Head-group choices keep using the envelope. """ tokens = 72576 if video_tokens is None else video_tokens - if small_canvas and not ff_stash and tokens < SMALL_CANVAS_INT8_WORKSPACE[-1][0]: + if small_canvas and not ff_stash and tokens <= SMALL_CANVAS_INT8_WORKSPACE[-1][0]: for (low, at_low), (high, at_high) in zip(SMALL_CANVAS_INT8_WORKSPACE, SMALL_CANVAS_INT8_WORKSPACE[1:]): if tokens <= high: at = at_low + (at_high - at_low) * max(0, tokens - low) / (high - low) diff --git a/freevideo_engine/prompt_vlm/service.py b/freevideo_engine/prompt_vlm/service.py index 481c4f0..fc64c89 100644 --- a/freevideo_engine/prompt_vlm/service.py +++ b/freevideo_engine/prompt_vlm/service.py @@ -448,6 +448,7 @@ def _infer(self, root, machine, descriptor, value): if env.get(resident_process.ENV) and Path(env[resident_process.ENV]).is_file(): try: resident_process.release_idle_cache(env, descriptor) + resident_process.OWNER.record(action='released', reason='prompt_model') except resident_process.SessionRestarted: pass # That worker already exited, which is the release we need. env.pop(resident_process.ENV, None) @@ -570,8 +571,9 @@ def read_rows(): releasing = result_received_at[0] if result_received_at else time.monotonic() if child.poll() is None: # Killing only the Linux supervisor bypasses its descendant - # cleanup. Stop the owned group/Windows Job and wait instead. - processes.stop(child, grace=5) + # cleanup. Stop the owned group/Windows Job and wait instead; + # the worker finishes its current GPU operation and exits. + processes.stop(child, grace=15) child.wait() writer.join(timeout=5) session.close() diff --git a/freevideo_engine/prompt_vlm/worker.py b/freevideo_engine/prompt_vlm/worker.py index 9ba4264..0fe96ce 100644 --- a/freevideo_engine/prompt_vlm/worker.py +++ b/freevideo_engine/prompt_vlm/worker.py @@ -103,6 +103,31 @@ def available_memory(): return available +class Cancelled(BaseException): + """Raised by a stop signal between Python operations, never inside a GPU call.""" + + +def stop_on_signal(): + """Stop like a finished run: no hard exit while weights or tokens are on their way to the GPU.""" + import signal + names = [signal.SIGTERM, signal.SIGINT] + ([signal.SIGBREAK] if os.name == 'nt' else []) + def stop(*_): + for name in names: + signal.signal(name, signal.SIG_IGN) + raise Cancelled() + for name in names: + signal.signal(name, stop) + + +def settle(): + torch = sys.modules.get('torch') + if torch is not None and torch.cuda.is_initialized(): + try: + torch.cuda.synchronize() + except (RuntimeError, OSError): + pass + + def emit(**value): print(json.dumps(value, ensure_ascii=True), flush=True) @@ -480,6 +505,7 @@ def main(): value = {} prestarting = os.environ.get(PRESTART_ENV) == '1' requested = False + stop_on_signal() try: if prestarting: started = time.monotonic() @@ -503,6 +529,10 @@ def main(): else: value = json.loads(sys.stdin.buffer.read(1024 * 1024)) run(Path(sys.argv[1]), value, stats=stats) + except Cancelled: + settle() + emit(phase='cancelled') + return 0 except Exception as error: if not prestarting or requested: gpu_snapshot(stats) diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 59cd283..19fac48 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,25 +1,62 @@ { "schema": 1, - "product_version": "0.4.3", + "product_version": "0.4.4", "en": { - "summary": "LAN access settings of offline-package installations are kept when the launcher is reopened, and the agent no longer replies after a job is stopped on the page. Also includes several fixes; the image generation models can now be downloaded and installed normally.", + "summary": "Continuing a video segment by segment no longer stops with a memory error, medium-size videos on Windows with 16 GB or smaller cards are fast again in the second pass, and image generation on Windows no longer stalls. The editing view gains reference pictures and imported-video continuation, and several issues that stopped generation or setup are fixed.", "changes": [ - "With an offline-package installation, LAN access and its port now stay as set after the launcher is reopened.", - "After a job is stopped on the page, the agent adds no message about it and does not retry on its own.", - "When the agent asks for a video length this computer cannot make, it now gets a clear explanation instead of an error.", - "The image generation models can now be downloaded and installed normally." + "Continuing a video segment by segment no longer stops with a memory error from the second segment on.", + "Windows: medium-size videos on graphics cards with 16 GB or less no longer slow down in the second pass.", + "Image generation: on Windows it no longer stalls while the image model starts, reports a false out-of-memory error after loading, or stalls while checking downloaded model files; tall pictures no longer show purple vertical lines or a grid texture.", + "After a video finishes, FreeVideo releases graphics and system memory right away unless the next part is about to start, and gives way at once to image generation or other programs.", + "Editing view: the generation bar takes reference pictures (upload one or make one with the image model, then set it as reference, first frame or last frame), every Generate press answers at once, and queued parts stay queued after the page is reloaded and start by themselves; if generation cannot run, they go back to drafts with the reason.", + "Editing view: videos imported from your computer can be extended and continued, files dropped on the timeline land where they are dropped, reopening it from the launcher keeps it usable, and keys pressed in the media library no longer change the timeline.", + "Mac: generating with reference pictures no longer runs out of memory while encoding; the compatibility setting, which only applies to separate graphics cards, is no longer shown.", + "Additional model folders entered in the node settings are now saved and used when FreeVideo looks for models and starts.", + "Edge model files now download from the ModelScope domestic site, and the image model gains an additional mirror source.", + "Image editor: leaving or reloading the page with unsaved changes now asks first. Flipping, resetting, fitting or dragging the locked original works on an automatic copy as one undo step. Renaming a layer by double-click keeps the name, and invalid canvas sizes are explained instead of failing silently.", + "Small mismatches no longer stop the launcher, setup, saved Agent films and cast cards, subtitle import, the image editor, sharing or prompt enhancement; FreeVideo continues and repairs what it can. Turning off or cancelling prompt enhancement while it runs stops it cleanly, and the release link in the update notice opens the latest release.", + "A workflow restored with an empty Total frames value no longer fails with a frame-count error; it generates by duration." ] }, "zh": { - "summary": "用离线包安装时,局域网访问的设置在重开启动器后保留;在页面上停止任务后,Agent 不再另外回复;另有几处修复,图像生成模型现在可以正常下载和安装。", + "summary": "一段接一段连续生成不再因内存报错;Windows 上 16 GB 及以下显卡生成中等尺寸视频,第二遍恢复原来的速度;Windows 图像生成不再卡住。剪辑视图可以加参考图、接续导入的视频,另修复了几处会让生成或安装中断的问题。", "changes": [ - "用离线包安装时,局域网访问的开关和端口在重开启动器后保持不变。", - "在页面上停止任务后,Agent 不再另外回复,也不会自行重试。", - "Agent 请求的视频长度超出这台电脑能做的范围时,会收到明确的说明,不再出错。", - "图像生成模型现在可以正常下载和安装。" + "一段接一段连续生成时,从第二段起不再因内存不足报错。", + "Windows:16 GB 及以下显卡生成中等尺寸视频时,第二遍不再变慢。", + "图像生成:Windows 上图像模型启动时不再卡住、加载完不再误报内存不足,下载后校验文件不再卡住;高图上不再出现紫色竖线和网格纹。", + "视频生成完会立即释放显存和内存;下一段马上开始时才短暂保留,遇到图像生成或其他程序要用时立刻让出。", + "剪辑视图:生成条可以加参考图(上传图片或用图像模型生成一张,可设为参考、首帧或尾帧);每次点“生成”都会立刻有反应,排队的段刷新页面后仍在排队、轮到时自动开始,不能生成时退回草稿并说明原因。", + "剪辑视图:从电脑导入的视频也能往后接续生成,拖到时间线上的文件落在松手的位置;从启动入口再次进入时可以正常操作,素材库里按键不再改动时间线。", + "Mac:用参考图生成时,编码阶段不再报内存不足;只对独立显卡起作用的兼容性设置不再显示。", + "节点设置里填写的“其他模型目录”现在会保存,查找模型和启动时都会用到。", + "Edge 模型文件改从魔搭国内站下载,图像模型增加一个镜像下载源。", + "图片编辑器:有未保存的改动时离开或刷新页面会先提示;对已锁定的原图翻转、重置、适配或拖动时,自动在副本上操作并算一步撤销;双击重命名图层保留名称;画布尺寸不合法时给出提示而不是无反应。", + "启动器、安装、Agent 已存的影片和角色卡、字幕导入、图片编辑器、分享和提示词改写遇到小偏差不再中断,FreeVideo 会继续并自动修复能修的部分;提示词增强运行中关闭或取消时会正常停止,更新提示里的发布页链接打开最新版本。", + "恢复的工作流把“总帧数”留空时,生成不再因帧数校验报错,按时长正常生成。" ] }, "history": [ + { + "product_version": "0.4.3", + "en": { + "summary": "LAN access settings of offline-package installations are kept when the launcher is reopened, and the agent no longer replies after a job is stopped on the page. Also includes several fixes; the image generation models can now be downloaded and installed normally.", + "changes": [ + "With an offline-package installation, LAN access and its port now stay as set after the launcher is reopened.", + "After a job is stopped on the page, the agent adds no message about it and does not retry on its own.", + "When the agent asks for a video length this computer cannot make, it now gets a clear explanation instead of an error.", + "The image generation models can now be downloaded and installed normally." + ] + }, + "zh": { + "summary": "用离线包安装时,局域网访问的设置在重开启动器后保留;在页面上停止任务后,Agent 不再另外回复;另有几处修复,图像生成模型现在可以正常下载和安装。", + "changes": [ + "用离线包安装时,局域网访问的开关和端口在重开启动器后保持不变。", + "在页面上停止任务后,Agent 不再另外回复,也不会自行重试。", + "Agent 请求的视频长度超出这台电脑能做的范围时,会收到明确的说明,不再出错。", + "图像生成模型现在可以正常下载和安装。" + ] + } + }, { "product_version": "0.4.2", "en": { @@ -236,43 +273,6 @@ "图片编辑器可以正常打开了。通过启动器安装或更新的 0.3.21 缺少编辑器用的字体,点“编辑”只会提示“这张图片无法打开编辑。”。" ] } - }, - { - "product_version": "0.3.21", - "en": { - "summary": "FreeVideo comes to Linux desktops as an AppImage, images in Media can be edited in place, and videos on Windows generate faster and more reliably when GPU memory or RAM is tight.", - "changes": [ - "FreeVideo comes to Linux desktops as an AppImage with the same launcher as Windows and macOS: it installs ComfyUI, the runtime and the models, adds FreeVideo to the application menu and updates itself. In a terminal, ./setup.sh now continues until FreeVideo is open, and freevideo opens it again later. On a GPU server, freevideo server keeps FreeVideo running for other computers to use.", - "Images in Media can now be edited without leaving FreeVideo: click Edit on a picture to crop it to the video's shape, add text and shapes, adjust light and color, and work with layers, masks and selections. Spot healing fills each stroke with texture from the surrounding picture, and Captions only removes burned-in captions. Text uses FreeVideo's built-in open-source fonts. The original file is kept, and opening the edit again brings back every layer.", - "The FreeVideo toolbar with the Create and Nodes switch can now be dragged anywhere on the page, like a window. It stays where you leave it after ComfyUI, FreeVideo or the browser restarts, and in other browsers too. Double-click an empty part of the toolbar to put it back at the top.", - "Prompt enhancement now keeps the lines written for characters to say as spoken dialogue, word for word, instead of turning them into on-screen text, and puts each section of the enhanced prompt on its own line.", - "On Windows, FreeVideo now leaves room for the GPU memory other applications are using, so videos no longer slow down or start over when the GPU fills up. On a test GPU limited to 8 GB, with 2.6 GB used by another application, a 5-second 960×544 video took 208 s instead of 290 s. On computers with little free RAM, the second pass also no longer loads the video model again.", - "On Windows, int8 videos sample faster on GPUs with 6 to 16 GB of memory. When memory allows, attention is computed in larger groups without using more video memory: on a Windows test GPU limited to 12 GB, a 1344×768 step took 58.2 s instead of 63.3 s, about 8% faster. 16 GB RTX 40 and 30 series cards with 13.5 to 14 GB free now copy the next model layer while the current one computes, as RTX 50 series cards already did. The same seed can give a slightly different video, as it already does between GPUs with different memory.", - "On Windows computers short of RAM, model layers read from the drive are now read ahead while the previous layer computes, without using more memory; in our tests this cut each sampling step by about 20%. While the last steps run, FreeVideo also reads ahead as much of the video decoder as free RAM allows, instead of skipping it when the whole decoder does not fit. The same seed gives the same video as before.", - "Videos spend less time getting ready. A new prompt or video size no longer makes FreeVideo compile its GPU kernels again, which took about 7 seconds per video on a Windows test machine, and a long ComfyUI session keeps them compiled instead of falling back to a much slower path. The second sampling step no longer compiles part of the model again, which on Windows could stop an attempt when the GPU was nearly full. The same seed gives the same video as before.", - "On Mac, the installer shows and checks free space the same way Finder does, including space macOS frees on its own, such as local Time Machine snapshots. Below the install location it names the disk and its free space, Choose another disk lists the other connected disks, and when space runs short it suggests one with enough room. A new installation needs about 73 GB instead of about 110 GB, and exFAT, FAT32, read-only and disconnected disks are explained before installation starts.", - "Installation is harder to interrupt and easier to follow: the overall progress moves with the model download, a small file whose download breaks off is fetched again, and the launcher reinstalls its loader when the FreeVideo folder in ComfyUI's custom nodes had its original files put back.", - "Offline-package installations take less disk space. Once the installation holds and has verified everything in the imported ZIP files, FreeVideo offers to delete them, so the packages no longer take the space twice; nothing is deleted without confirmation. The first start after installing or updating removes unpacked package folders and download leftovers that are no longer needed and shows how much space was freed, and models copied from another disk come with a note on how to avoid the copy.", - "Fixes: FreeVideo's page now loads with ad-block filter lists that blocked one of its scripts by name, and the page check names any FreeVideo file a browser extension blocks. Enlarging a video to full screen in the node view no longer shows the \"did not load completely\" message. Newer ComfyUI versions no longer print an asset registration warning after every video. Local optimization works when the second pass keeps part of its data in RAM, as on 8 GB Windows GPUs." - ] - }, - "zh": { - "summary": "FreeVideo 推出 Linux 桌面版 AppImage,素材图片可以直接编辑,Windows 上显存或内存紧张时生成更快、更稳定。", - "changes": [ - "Linux 桌面可以使用 FreeVideo 的 AppImage,启动器与 Windows、macOS 相同:自动安装 ComfyUI、运行环境和模型,加入应用菜单,并能自动更新。在终端里,./setup.sh 现在会一直进行到打开 FreeVideo,之后输入 freevideo 会再次打开它。在 GPU 服务器上,freevideo server 会让 FreeVideo 保持运行,供其他电脑使用。", - "素材里的图片现在可以直接在 FreeVideo 中编辑:点击图片上的“编辑”,可以裁剪成视频比例、添加文字和形状、调整光线和颜色,并使用图层、蒙版和选区。污点修复会用周围画面的纹理填补,“只去字幕”可以去掉烧进画面的字幕。文字使用 FreeVideo 内置的开源字体。原图会保留,再次打开编辑时所有图层都在。", - "带“创作面板 / 节点视图”切换的 FreeVideo 工具栏现在可以像窗口一样拖到页面任意位置。重启 ComfyUI、FreeVideo 或浏览器后,它仍在原处,换一个浏览器也一样。双击工具栏空白处,可以让它回到顶部。", - "提示词增强现在会把写给角色说的台词原样保留为对白,不再变成画面里的文字;增强后的提示词每个部分各占一行。", - "在 Windows 上,FreeVideo 现在会给其他程序占用的显存留出空间,显卡被占满时生成不再变慢或中止重来。在一块限制为 8 GB、其中 2.6 GB 被其他程序占用的测试显卡上,生成 5 秒 960×544 视频从 290 秒缩短到 208 秒。在空闲内存较少的电脑上,第二遍也不再重新加载视频模型。", - "在 Windows 上,6–16 GB 显存的显卡生成 int8 视频更快。显存允许时,注意力以更大的分组计算,显存占用不增加:在一块限制为 12 GB 的 Windows 测试显卡上,1344×768 视频每步从 63.3 秒缩短到 58.2 秒,约快 8%。空闲显存为 13.5 到 14 GB 的 16 GB RTX 40 和 30 系列显卡,现在也会在当前层计算的同时复制下一层,与 RTX 50 系列此前的做法相同。同一个种子生成的视频可能略有不同,就像在显存不同的显卡上本来就会有差异一样。", - "在内存不足的 Windows 电脑上,需要从硬盘读取的模型层现在会在上一层计算时提前读取,而且不额外占用内存;在我们的测试中,每个采样步缩短约 20%。在最后几个采样步进行时,FreeVideo 还会在空闲内存允许的范围内预先读取尽可能多的视频解码器,不再因为放不下整个解码器就完全不读。同一个种子生成的视频与以前相同。", - "视频开始生成前的等待更短。换提示词或视频尺寸后,FreeVideo 不再重新编译显卡算子(在 Windows 测试机上每个视频约省 7 秒),在较长的 ComfyUI 会话中也不会退回慢得多的未编译方式。第二个采样步不再重新编译部分模型;在 Windows 上,这次编译曾在显卡快满时使尝试中止。同一个种子生成的视频与以前相同。", - "在 Mac 上,安装器显示和检查的可用空间现在与访达一致,包括 macOS 需要时会自动释放的空间,例如 Time Machine 本地快照。安装位置下方会显示所在硬盘和可用空间,点“换一块硬盘”可以选择其他已连接的硬盘,空间不够时会推荐一块够用的硬盘。全新安装所需的空间从约 110 GB 降到约 73 GB;exFAT、FAT32、只读或没有连接的硬盘,会在安装开始前说明原因。", - "安装更不容易中断,过程也更清楚:整体进度会随模型下载推进;小文件下载中断时会重新下载;ComfyUI 自定义节点里的 FreeVideo 文件夹被放回原始文件后,启动器会重新装上加载器。", - "用离线包安装后,占用的硬盘空间更少。安装已包含并校验完导入的压缩包里的全部内容后,FreeVideo 会提示删除这些压缩包,离线包不再占两份空间;删除前需要确认。安装或更新后第一次启动时,FreeVideo 会清理不再需要的离线包解压目录和下载残留,并显示释放了多少空间;从另一块硬盘复制来的模型,会附一句说明,讲清楚怎样避免这份复制。", - "修复:部分广告拦截列表会按文件名拦下 FreeVideo 的一个脚本,导致创作面板不显示,现在页面可以正常加载,页面检查也会指出被浏览器插件拦截的文件。在节点视图里把视频放大到全屏时,不再出现“没有完整加载”的提示。较新的 ComfyUI 不再在每个视频完成后打印资源登记警告。第二遍需要把部分数据放在内存里时(例如 8 GB Windows 显卡),本地优化可以正常运行。" - ] - } } ] } diff --git a/freevideo_engine/resident_models.py b/freevideo_engine/resident_models.py index e45cc75..a85c9d4 100644 --- a/freevideo_engine/resident_models.py +++ b/freevideo_engine/resident_models.py @@ -266,7 +266,16 @@ def input_vae_room(self, model, canvas, *, audio=False): weights = sum(t.numel()*t.element_size() for t in list(model.parameters()) + list(model.buffers())) area = canvas.get('width', 1344)*canvas.get('height', 768)/(1344*768) workspace = 2*GiB if audio else max(2*GiB, 6*GiB*area) - self.make_room('input_audio_vae' if audio else 'input_video_vae', weights + workspace) + # The same stage also grows this whole process's host memory: the + # video encoder's checkpoint pages and the decoded reference frames + # rose by more than 13 GiB at 960x544 (about 26 GiB per full 1344x768 + # canvas) beside an idle engine's pinned weights. That pushed a + # 25.6 GiB budget through its RAM guard and failed the continued + # segment. The GPU estimate says nothing about host memory, so an idle + # model's host copy is released unless the live budget covers the + # stage as well. Plentiful hosts keep it for reuse. + host = weights + (2*GiB if audio else max(8*GiB, 26*GiB*area)) + self.make_room('input_audio_vae' if audio else 'input_video_vae', weights + workspace, ram_need=host) def device_ceiling(self): """What this process's allocator can hold before the device itself is full. diff --git a/freevideo_engine/resident_process.py b/freevideo_engine/resident_process.py index e76366b..e95fd23 100644 --- a/freevideo_engine/resident_process.py +++ b/freevideo_engine/resident_process.py @@ -22,6 +22,16 @@ from .monitoring import save ENV = 'FREEVIDEO_RESIDENT_SESSION' +GiB = 2**30 +# A finished request releases the worker unless its next part is already on the +# way. Then it is kept briefly, and given up at once when anything else needs +# the memory: another program's GPU use grows, host memory runs low, or an +# image or prompt model starts (those release it themselves). +HOLD_SECONDS = 90. +AGENT_HOLD_SECONDS = 30. +OTHER_GPU_GROWTH = 512 * 2**20 +HOST_FLOOR = 4 * GiB +RELEASE_LOG = 20 class SessionRestarted(RuntimeError): @@ -346,9 +356,76 @@ def __init__(self): self.identity = None self.active = False self.prewarm = None + self.mutex = threading.RLock() + self.generation = 0 + self.idle_since = None + self.decisions = [] atexit.register(self.close) + def record(self, **row): + row.setdefault('at', time.time()) + with self.mutex: + self.decisions = (self.decisions + [row])[-RELEASE_LOG:] + return row + + def take_decisions(self): + """Keep or release decisions since the previous request, for its report.""" + with self.mutex: + rows, self.decisions = self.decisions, [] + return rows + + def finished(self, *, next_part=None, agent=False, gpu=None): + """Release the idle worker now, or keep it while a next part is on its way.""" + with self.mutex: + if self.active or self.process is None or self.process.poll() is not None: + return None + hold = min(HOLD_SECONDS, AGENT_HOLD_SECONDS) if agent else HOLD_SECONDS + if not next_part or hold <= 0: + return self.release('finished' if not next_part else 'no_hold') + reason = pressure(gpu, None) + if reason: + return self.release(reason) + self.generation += 1 + self.idle_since = time.monotonic() + baseline = gpu_used(gpu) + threading.Thread(target=self._watch, args=(self.generation, self.idle_since, hold, gpu, baseline), + name='freevideo-resident-hold', daemon=True).start() + return self.record(action='kept', reason=next_part, seconds=hold, pid=self.process.pid) + + def _watch(self, generation, since, hold, gpu, baseline): + while True: + time.sleep(.5) + reason = ('idle' if time.monotonic() - since >= hold + else pressure(gpu, baseline)) + with self.mutex: + if generation != self.generation or self.active or self.process is None: + return + if self.process.poll() is not None: + return # The image or prompt model released it and recorded why. + if reason: + self.release(reason) + return + + def release(self, reason): + with self.mutex: + if self.process is None or self.process.poll() is not None: + self.close() + return None + row = dict(action='released', reason=reason, pid=self.process.pid, + idle_seconds=None if self.idle_since is None else round(time.monotonic() - self.idle_since, 1), + **worker_memory(self.process.pid)) + self.generation += 1 + self.idle_since = None + self.close() + return self.record(**row) + def start(self, root, source, python, environment): + with self.mutex: + self.generation += 1 + self.idle_since = None + return self._start(root, source, python, environment) + + def _start(self, root, source, python, environment): import hashlib import psutil from .triton_compat import COMPILER_ENVIRONMENT_KEYS, environment as compiler_environment @@ -419,17 +496,6 @@ def stop_prewarm(self): receipt['worker_released'] = True return receipt - def warm_encoder(self, output, python, environment, *, busy=None, notify=None): - if (self.process is None or self.process.poll() is not None or self.active or - environment.get('FREEVIDEO_ENCODER_PREWARM', 'auto').lower() in ('0', 'off', 'false')): - return False - self.stop_prewarm() - if self.process is None: - return False - from .idle_encoder import IdleEncoder - self.prewarm = IdleEncoder() - return self.prewarm.schedule(self.endpoint, output, python, environment, busy=busy, notify=notify) - def close(self): self.stop_prewarm() if self.process is not None: @@ -437,6 +503,50 @@ def close(self): self.process = None +def gpu_used(gpu): + try: + from .background_room import Gpu + device = Gpu(gpu) + try: + memory = device.memory() + finally: + device.close() + return memory[0] if memory else None + except (OSError, RuntimeError, AttributeError, ValueError, ImportError): + return None + + +def pressure(gpu, baseline): + """Why a kept idle worker should go now, or None.""" + try: + from .background_room import available_memory + if available_memory() < HOST_FLOOR: + return 'host_memory_low' + except (OSError, RuntimeError, AttributeError, ValueError, ImportError): + pass + if baseline is not None: + # The idle worker does not allocate, so growth on the card is another program's. + used = gpu_used(gpu) + if used is not None and used - baseline >= OTHER_GPU_GROWTH: + return 'other_program_gpu' + return None + + +def worker_memory(pid): + """Host memory the release returns, from the worker tree.""" + try: + import psutil + from .background_room import family + rss = private = 0 + for member in family(pid): + info = psutil.Process(member).memory_info() + rss += info.rss + private += getattr(info, 'private', 0) or getattr(info, 'rss', 0) - getattr(info, 'shared', 0) + return dict(worker_rss_bytes=rss, worker_private_bytes=private) + except Exception: + return {} + + OWNER = SessionOwner() diff --git a/freevideo_engine/weights.py b/freevideo_engine/weights.py index b2bf11d..104dfd9 100644 --- a/freevideo_engine/weights.py +++ b/freevideo_engine/weights.py @@ -5,8 +5,10 @@ """ import hashlib import json +import logging from pathlib import Path import re +import shutil import time from safetensors.torch import save_file @@ -124,16 +126,26 @@ def prepare(base, checkpoint, cache_root): output, identity, key = cache_location(cache_root, 'vdn-bf16-', identity) output.mkdir(parents=True, exist_ok=True) marker = output / 'source.json' + invalid = None if marker.exists() and json.loads(marker.read_text(encoding='utf-8')) != identity: - raise ValueError('Prepared weight identity conflict: ' + str(output)) - marker.write_text(json.dumps(identity, indent=2) + '\n', encoding='utf-8') + invalid = 'Prepared weight identity conflict: ' + str(output) manifest = output / 'manifest.json' - if manifest.exists(): + if invalid is None and manifest.exists(): record = json.loads(manifest.read_text(encoding='utf-8')) for item in record['groups']: path = output / item['file'] if not path.is_file() or path.stat().st_size != item['bytes']: - raise ValueError('Prepared weight cache is incomplete: ' + str(path)) + invalid = 'Prepared weight cache is incomplete: ' + str(path) + break + if invalid is not None: + root, resolved = cache_root.resolve(), output.resolve() + if resolved == root or not resolved.is_relative_to(root): + raise ValueError('Refusing to delete prepared weight cache outside cache root: ' + str(output)) + logging.getLogger(__name__).warning('%s; rebuilding cache', invalid) + shutil.rmtree(output) + output.mkdir(parents=True, exist_ok=True) + marker.write_text(json.dumps(identity, indent=2) + '\n', encoding='utf-8') + if manifest.exists(): print(json.dumps({'cache': str(output), 'reused': True}), flush=True) return output groups = {} diff --git a/freevideo_engine/win32.py b/freevideo_engine/win32.py index d3b8cd5..e89c740 100644 --- a/freevideo_engine/win32.py +++ b/freevideo_engine/win32.py @@ -40,6 +40,7 @@ def kernel32(): 'CreateFileW': (HANDLE, [C.c_wchar_p, DWORD, DWORD, C.c_void_p, DWORD, DWORD, HANDLE]), 'GetFileInformationByHandle': (BOOL, [HANDLE, C.c_void_p]), 'GetFileInformationByHandleEx': (BOOL, [HANDLE, C.c_int, C.c_void_p, DWORD]), + 'SetStdHandle': (BOOL, [DWORD, HANDLE]), } for name, (result, arguments) in declarations.items(): function = getattr(lib, name) @@ -53,6 +54,32 @@ def checked(value): return value +def private_stdin(encoding='utf-8'): + """Read the parent's pipe through a private handle; standard input becomes NUL. + + A thread blocked reading the standard-input pipe stalls DLL loads on the + main thread: importing numpy (its OpenBLAS) queries the standard handles + and waits until the parent writes again. A private duplicate keeps those + queries away from the pending read. + """ + import msvcrt + import sys + private = os.dup(sys.stdin.fileno()) + try: + null = os.open(os.devnull, os.O_RDONLY) + try: + os.dup2(null, 0) + finally: + os.close(null) + checked(kernel32().SetStdHandle(DWORD(-10 & 0xffffffff), msvcrt.get_osfhandle(0))) # STD_INPUT_HANDLE + except OSError: + # Put the pipe back so the caller can still read from sys.stdin. + os.dup2(private, 0) + os.close(private) + raise + return open(private, encoding=encoding, errors=sys.stdin.errors) + + def process_exited(pid): """True/False/None for exited/running/uninspectable, without waiting. diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index d790bb6..de10236 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -4,6 +4,7 @@ import test from 'node:test'; import {outputDownloadURL} from '../web/output_download.js'; import {regenerateResult, upscaleResult} from '../web/studio_queue.js'; import {referenceInputs} from '../web/prompt_references.js'; +import {cleanOptionalPrompt, cleanOptionalWidgets} from '../web/optional_inputs.js'; class Element { constructor(tag) { @@ -35,7 +36,7 @@ test('main browser entry registers both views and preserves node hooks', async t const document = {head: new Element('head'), createElement: tag => new Element(tag), createRange: () => ({createContextualFragment: () => new Element('span')})}; const dependencies = { - app, api, outputDownloadURL, regenerateResult, upscaleResult, referenceInputs, + app, api, outputDownloadURL, regenerateResult, upscaleResult, referenceInputs, cleanOptionalPrompt, cleanOptionalWidgets, createErrorPanel: () => ({element: new Element('error'), show() {}, clear() {}}), errorText: value => String(value), openStudio: node => opened.push(node), @@ -93,6 +94,7 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { shareButton } from './share.js';", 'const {shareButton} = globalThis.__freevideoEntryTest;') .replace("import { reportFileName } from './report_issue.js';", 'const {reportFileName} = globalThis.__freevideoEntryTest;') .replace("import { rememberPromptDraft, savePromptDraft } from './prompt_draft.js';", 'const {rememberPromptDraft,savePromptDraft} = globalThis.__freevideoEntryTest;') + .replace("import { cleanOptionalWidgets, cleanOptionalPrompt } from './optional_inputs.js';", 'const {cleanOptionalWidgets,cleanOptionalPrompt} = globalThis.__freevideoEntryTest;') .replace("import { regenerateResult, upscaleResult } from './studio_queue.js';", 'const {regenerateResult,upscaleResult} = globalThis.__freevideoEntryTest;') .replace("import { openImageEditor, icon } from './image_editor.js';", 'const {openImageEditor,icon} = globalThis.__freevideoEntryTest;') .replace("import { installNavigation, refreshNavigation, preferredView } from './view_navigation.js';", 'const {installNavigation,refreshNavigation,preferredView} = globalThis.__freevideoEntryTest;') diff --git a/web/editor.css b/web/editor.css index c3dca78..79cb4b0 100644 --- a/web/editor.css +++ b/web/editor.css @@ -64,6 +64,10 @@ .fv-ed-genbar .fv-ed-description{width:auto} .fv-ed-clip[data-state=running] .fv-block-progress.fv-ed-block-progress{display:grid} .fv-ed-gen-hint{font-size:13px;color:var(--fv-muted);flex:1;min-width:0} +.fv-ed-genbar[data-mode=selected] .fv-ed-gen-hint{flex:none} +.fv-ed-gen-note{font-size:13px;color:var(--fv-ink);flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.fv-ed-gen-note[hidden]{display:none} +.fv-ed-basic-progress[data-state=waiting] .fv-ed-basic-percent{font-weight:500;color:var(--fv-muted)} .fv-ed-description{flex:1;width:0} .fv-ed-first-frame{display:flex;align-items:center;gap:8px;flex:none;font-size:13px;white-space:nowrap} .fv-ed-first-frame[hidden]{display:none} @@ -73,6 +77,24 @@ .fv-ed-first-frame input:checked::before{transform:translateX(10px)} .fv-ed-first-frame:has(input:disabled){opacity:.4} .fv-ed-description:disabled{opacity:.4} +.fv-ed-genbar{position:relative} +.fv-ed-refs{display:flex;align-items:center;gap:6px;flex:none} +.fv-ed-refs[hidden],.fv-ed-ref-menu[hidden]{display:none} +.fv-ed-ref-chips{display:flex;align-items:center;gap:6px;max-width:min(240px,32vw);overflow-x:auto;scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent;padding:1px} +.fv-ed-ref-chips:empty{display:none} +.fv-ed-ref-add,.fv-ed-ref-chip{flex:none;width:30px;height:30px;padding:0;border:1px solid var(--fv-border);border-radius:8px;background:transparent;color:var(--fv-muted);font:16px/1 var(--fv-font);cursor:pointer;overflow:hidden;position:relative} +.fv-ed-ref-chip{border-color:transparent;background:var(--fv-bg)} +.fv-ed-ref-add:not(:disabled):not([aria-disabled=true]):hover,.fv-ed-ref-add[aria-expanded=true],.fv-ed-ref-chip:not(:disabled):not([aria-disabled=true]):hover{border-color:var(--fv-accent);color:var(--fv-ink)} +.fv-ed-ref-add:disabled,.fv-ed-ref-chip:disabled,.fv-ed-ref-add[aria-disabled=true],.fv-ed-ref-chip[aria-disabled=true]{opacity:.4;cursor:default} +.fv-ed-ref-thumb{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--fv-muted)} +.fv-ed-ref-thumb img{display:block;width:100%;height:100%;object-fit:cover} +.fv-ed-ref-menu{position:absolute;bottom:calc(100% - 4px);min-width:180px;max-width:260px;padding:6px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:8px;z-index:12;display:flex;flex-direction:column;font:13px var(--fv-font)} +.fv-ed-ref-menu>button{display:block;width:100%;height:32px;padding:0 10px;border:0;border-radius:4px;background:transparent;color:var(--fv-ink);font:inherit;text-align:left;cursor:pointer} +.fv-ed-ref-menu>button:hover,.fv-ed-ref-menu button:focus-visible{background:color-mix(in srgb,var(--fv-accent) 12%,transparent);outline:1px solid var(--fv-accent);outline-offset:-1px} +.fv-ed-ref-menu>button[aria-checked=true]::after{content:'✓';float:right;color:var(--fv-accent)} +.fv-ed-ref-menu [role=separator]{flex:none;height:1px;margin:5px 4px;background:var(--fv-border)} +.fv-ed-ref-spare{display:flex;flex-wrap:wrap;gap:6px;padding:2px 4px} +.fv-ed-ref-spare button{position:relative;width:44px;height:44px;padding:0;border:1px solid var(--fv-border);border-radius:6px;background:var(--fv-bg);overflow:hidden;cursor:pointer} .fv-editor .fv-ed-gen-target{height:36px;align-items:center} .fv-editor .fv-ed-gen-target button{height:30px;box-shadow:none} .fv-editor .fv-ed-genbar>button{flex:none} @@ -124,6 +146,11 @@ .fv-ed-toolbar{grid-column:1;grid-row:5;padding:0 12px;gap:8px;overflow-x:auto} .fv-ed-genbar{grid-column:1;grid-row:4;min-height:56px;gap:8px;padding:10px 12px;flex-wrap:wrap} .fv-ed-description{width:100%;flex:1 1 100%} + .fv-ed-genbar .fv-ed-description{order:-1} + .fv-ed-refs{margin-right:auto;max-width:100%} + .fv-ed-ref-chips{max-width:calc(100vw - 96px)} + .fv-ed-gen-hint{flex:1 0 auto;white-space:nowrap} + .fv-ed-gen-note{order:-1;flex:1 1 100%;white-space:normal} .fv-editor .fv-ed-genbar>button{margin-left:auto;max-width:100%;font-size:12px} .fv-ed-name-menu{position:fixed;top:120px;left:8px} .fv-ed-notice{top:60px;bottom:auto;pointer-events:none} @@ -136,7 +163,7 @@ /* Keep the editor's controls visible instead of clipping them at phone widths. */ @media(max-width:480px){ - .fv-studio.fv-editor{grid-template-rows:auto minmax(128px,1fr) 40px auto auto 236px} + .fv-studio.fv-editor{grid-template-rows:auto minmax(96px,1fr) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,236px)} .fv-studio.fv-editor .fv-header:has(.fv-view-switch){grid-template-columns:minmax(0,1fr) auto} .fv-ed-toolbar{display:grid;grid-template-columns:auto minmax(0,1fr);height:auto;gap:4px 8px;padding:4px 12px;overflow:visible} .fv-ed-toolbar>.fv-ed-tool-group:nth-child(2){grid-column:2;grid-row:1/3;flex-wrap:wrap;align-self:start;min-width:0} diff --git a/web/editor.js b/web/editor.js index a119d60..97df74e 100644 --- a/web/editor.js +++ b/web/editor.js @@ -46,7 +46,7 @@ function restoreGeneration(record, before, prepared) { return next; } const icon = path => { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('viewBox', '0 0 16 16'); svg.setAttribute('aria-hidden', 'true'); svg.classList.add('fv-ed-glyph'); const line = document.createElementNS(svg.namespaceURI, 'path'); line.setAttribute('d', path); svg.append(line); return svg; }; -function basicBlockProgress(clipId, clipElement) { +function basicBlockProgress(clipId, clipElement, t = en => en) { const element = el('div', 'fv-ed-basic-progress'), water = el('div', 'fv-ed-basic-water'), percent = el('span', 'fv-ed-basic-percent'); element.append(water, percent); let level = 0, run = null; @@ -54,7 +54,10 @@ function basicBlockProgress(clipId, clipElement) { if (value.run && value.run !== run) { level = 0; run = value.run; } element.dataset.state = value.state; const fraction = level = value.state === 'waiting' ? 0 : Math.max(level, Math.max(0, Math.min(1, value.fraction || 0))); water.style.height = `${fraction * 100}%`; - percent.textContent = `${Math.floor(fraction * 100 + 1e-8)}%`; percent.hidden = value.state !== 'generating' || clipElement.dataset.track !== 'v1' || clipElement.getBoundingClientRect().width < 44; + const queued = value.state === 'waiting' && !!value.queued; + element.dataset.queued = queued ? 'true' : 'false'; + percent.textContent = queued ? t('Queued', '排队中') : `${Math.floor(fraction * 100 + 1e-8)}%`; + percent.hidden = (value.state !== 'generating' && !queued) || clipElement.dataset.track !== 'v1' || clipElement.getBoundingClientRect().width < 44; water.hidden = value.state !== 'generating'; }, dispose() { element.remove(); }}; } @@ -85,7 +88,17 @@ export function suspendCanvasPointer(canvas) { // `components` replaces the optional progress module (see loadEditorComponents); tests also pass their generation stand-in here. export function openEditor({api, app, from = null, panel = {}, components: given = undefined, onView = view => viewChanged(view)}) { - if (opened) { opened.element.focus(); return opened; } + if (opened) { + const dialog = opened.element; + const upper = topDialog(); + if (upper !== dialog && !dialog.contains(upper) && upper?.querySelector('.fv-view-switch')) { + // Re-enter the native modal top layer and keep keyboard routing in + // the same order. The restored editor and other views stay alive. + if (dialog.open) dialog.close(); + dialog.parentNode.append(dialog); dialog.showModal(); + } + dialog.focus(); return opened; + } const t = (en, zh) => String(language()).toLowerCase().startsWith('zh') ? zh : en; const returnFocus = document.activeElement; const even = value => Math.max(2, Math.floor(value / 2) * 2); @@ -185,13 +198,18 @@ export function openEditor({api, app, from = null, panel = {}, components: given const code = value?.code || value?.message; if (['audio_prepare', 'speed_pending', 'speed_locked', 'speed'].includes(code)) return {ok: false, reason: code}; if (code === 'unsupported') { genbar?.update(null); return; } - if (code === 'chain_too_long') { + if (['chain_too_long', 'chain_part_too_short'].includes(code)) { const [en, zh] = (value.said || []).map(text => typeof text === 'string' && text ? text.slice(0, 200) : null); - notify(t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短一些。')); + notify(code === 'chain_too_long' ? t(en || 'This video can have up to 5 parts. Make it shorter.', zh || '这条视频最多 5 段,请缩短一些。') + : t(en || 'Each continued part must be at least 10 seconds. Make it longer.', zh || '接着生成的每一段至少 10 秒,请加长一些。')); return {ok: false, reason: code}; } if (['busy', 'locked', 'text', 'audio_full', 'overlap', 'range'].includes(code)) { if (['locked', 'audio_full', 'overlap', 'range'].includes(code)) tracks?.flashBlocked?.(value.displayFrame); + const said = {busy: t('This part is generating.', '这一段正在生成。'), locked: t('This track is locked.', '这条轨道已锁定。'), + text: t('Write a description first.', '先写一句描述。'), overlap: t('That spot is taken. Use an empty spot.', '这里已经有片段了,请放到空白处。'), + audio_full: t('Both audio tracks are full here. Move an audio clip first.', '这里两条声音轨都满了,请先挪开一段声音。')}[code]; + if (said) notify(said); return {ok: false, reason: code}; } notify(t('Could not complete. Try again.', '没有完成,请重试')); @@ -199,15 +217,20 @@ export function openEditor({api, app, from = null, panel = {}, components: given const store = createEditorStore(api, {t, notify, standIn: given?.generationStandIn, onGenerationReady: ready => { if (disposed) return; generationAsked = true; genHost.hidden = !ready; element.dataset.generationReady = String(ready); - settleGenbar(); if (ready) { offerInitial?.(); extendEntry?.(); } + settleGenbar(); if (ready) { offerInitial?.(); extendEntry?.(); void startHeld(); } + // Parts queued while waiting for the answer never start here; they go back to drafts with the reason. + else if (store.generationGone && held.length) { + for (const entry of held.splice(0)) markHeld(entry.ids, false); + rememberView(); notify(t('Restart FreeVideo to generate.', '请重新启动 FreeVideo 后再生成。')); + } tracks?.refresh?.(); genbar?.update(mostRecentJob ? barJob(jobStatuses.get(mostRecentJob)) : null); updateTransport(); adjustPanel?.update(); }}); // Until the server has answered, the bar keeps its place but cannot be used. let generationAsked = store.generationAvailable, loaded = false; const settleGenbar = () => { - const waiting = !loaded || !generationAsked; - genHost.inert = waiting || !store.generationAvailable; genHost.classList.toggle('fv-ed-pending', waiting); + // While the engine is still answering, a press queues the part instead of doing nothing. + genHost.inert = !loaded || generationAsked && !store.generationAvailable; genHost.classList.toggle('fv-ed-pending', !loaded); }; store.generationReady(); const queue = store.createSaveQueue({get record() { return state.record; }, @@ -215,6 +238,8 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (pointerEditing) { deferredStamp = value; return; } if (!value.nameSuffix && state.record.id === value.id && state.record.revision === value.revision) return; suppressSave = true; try { state.adopt(value); } finally { suppressSave = false; } + // A queued part kept under a new edit's first key moves to its saved one. + if (held.length) rememberView(); }, }, {onStatus: () => { if (!queue.dirty) { discardOnSwitch = false; saveWarning.hidden = true; } @@ -231,7 +256,100 @@ export function openEditor({api, app, from = null, panel = {}, components: given const start = Math.min(...clips.map(clip => clip.start)); return {start, frames: Math.max(...clips.map(clip => clip.start + clip.frames)) - start}; } + // Whether the part about to be made starts fresh instead of continuing footage: only then can pictures guide it. + function startsFresh() { + const range = selectionRange(); if (!range) return false; + try { + const selected = selectionClips(); + const planned = selected.length && selected.every(clip => clip.pending && !clip.source) + ? model.startAttempt(state.record, selected.map(clip => clip.id), 'x') + : model.planRange(state.record, {...range, target: 'both', text: 'x', maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}); + const ids = planned.tracks[0].clips.filter(clip => clip.start >= range.start && clip.start < range.start + range.frames + && clip.pending && !['queued', 'running'].includes(clip.pending)).map(clip => clip.id); + return ids.length > 0 && model.generationBefore(model.prepare(planned, ids), ids).some(before => before == null); + } catch (value) { if (value instanceof model.EditError) return false; throw value; } + } const jobActive = () => generationBusy || [...jobStatuses.values()].some(job => ['queued', 'running', 'cancelling'].includes(job.state)); + // Parts pressed while they cannot be sent yet: the engine is still starting, or they continue a part + // that is not finished. They are planned at once, shown queued, and sent in order when they can be. + // Parts, and ranges not yet planned into parts, whose request is being sent. + const held = [], sending = new Set(), sendingRanges = new Set(); + const overlaps = (a, b) => a.start < b.start + b.frames && a.start + a.frames > b.start; + let startingHeld = false; + const generating = clip => ['queued', 'running'].includes(clip?.pending); + const isHeld = id => held.some(entry => entry.ids.includes(id)); + const partBefore = start => state.record.tracks[0].clips.find(clip => clip.frames > 0 && clip.start + clip.frames === start); + function busySelection(selection) { + const range = selectionRange(selection); if (!range) return false; + if ([...sendingRanges].some(other => overlaps(other, range))) return true; + return state.record.tracks[0].clips.some(clip => (generating(clip) || isHeld(clip.id) || sending.has(clip.id)) + && clip.start < range.start + range.frames && clip.start + clip.frames > range.start); + } + function waitingFor(selection) { + if (!store.generationAvailable) return 'ready'; + const before = partBefore(selectionRange(selection).start); + return generationBusy || generating(before) || before && isHeld(before.id) ? 'before' : null; + } + // The nearest spot at or after the chosen one where a part of the same length fits. + function emptySpot(selection) { + if (!['range', 'gap'].includes(selection?.kind)) return null; + const range = selectionRange(selection), clips = state.record.tracks.filter(track => ['v1', 'a1', 'a2'].includes(track.id)).flatMap(track => track.clips); + const starts = [range.start, ...clips.map(clip => clip.start + clip.frames).filter(at => at > range.start)].sort((a, b) => a - b); + for (const start of starts) { + if (clips.some(clip => clip.start < start + range.frames && clip.start + clip.frames > start)) continue; + const moved = {kind: 'range', start, frames: range.frames, tracks: ['v1', 'a1']}; + if (actions.supportedSelection(moved)) return moved; + } + return null; + } + // A range left a sliver (under the shortest part) after a clip closes up to that clip's end. + function flushSpot(selection) { + if (selection?.kind !== 'range') return null; + const range = selectionRange(selection), most = model.RULES.minSeconds * model.FPS; + const clips = state.record.tracks.filter(track => ['v1', 'a1', 'a2'].includes(track.id)).flatMap(track => track.clips); + const end = Math.max(-1, ...state.record.tracks[0].clips.map(clip => clip.start + clip.frames).filter(at => at < range.start)); + if (end < 0 || range.start - end >= most || clips.some(clip => clip.start < range.start && clip.start + clip.frames > end)) return null; + const moved = {kind: 'range', start: end, frames: range.frames, tracks: ['v1', 'a1']}; + return actions.supportedSelection(moved) ? moved : null; + } + // A part with no description borrows the one before it, else the nearest earlier one, else any. + function nearbyText(start) { + const before = partBefore(start); + if (before?.text?.trim()) return {text: before.text, before: true}; + const described = state.record.tracks[0].clips.filter(clip => clip.text?.trim()).sort((a, b) => a.start - b.start); + const found = described.filter(clip => clip.start < start).at(-1) || described.at(-1); + return found ? {text: found.text, before: false} : null; + } + const markHeld = (ids, queued) => { for (const id of ids) tracks?.setProgress(id, {state: 'waiting', fraction: 0, remainingSeconds: null, ...(queued ? {queued: true} : {})}); }; + function hold(selection, text) { + const range = selectionRange(selection), selectedIds = selectionClips(selection).map(clip => clip.id), shown = selection === state.selection; + let ids = []; + state.apply(t('Generate', '生成'), record => { + const selected = selectedIds.map(id => model.find(record, id)).filter(Boolean), planned = selected.length && selected.every(clip => !clip.source && clip.pending && !generating(clip)); + const next = planned ? model.startAttempt(record, selected.map(clip => clip.id), text) : model.planRange(record, {...range, target: 'both', text, maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}); + ids = next.tracks[0].clips.filter(clip => clip.start >= range.start && clip.start < range.start + range.frames && clip.pending && !generating(clip)).map(clip => clip.id); + return next; + }, {checkpoint: true}); + held.push({ids, text}); markHeld(ids, true); + if (shown) state.select({kind: 'clip', ids}); + rememberView(); + } + async function startHeld() { + if (startingHeld || disposed || closing || generationBusy || !store.generationAvailable) return; + startingHeld = true; + try { + while (held.length && !disposed) { + const entry = held[0], clips = entry.ids.map(id => model.find(state.record, id)).filter(clip => clip && clip.pending && !generating(clip)); + if (!clips.length) { held.shift(); markHeld(entry.ids, false); rememberView(); continue; } + const selection = {kind: 'clip', ids: clips.map(clip => clip.id)}, before = partBefore(Math.min(...clips.map(clip => clip.start))); + if (generating(before)) break; + held.shift(); markHeld(entry.ids, false); rememberView(); + if (before && !before.source && before.pending) { genbar?.say(t('The part before did not finish, so this one did not start.', '上一段没有完成,这一段没有开始。')); continue; } + try { await actions.generate({text: entry.text, selection}); } + catch (value) { const reason = error(value); if (reason && !['chain_too_long', 'chain_part_too_short'].includes(reason.reason)) genbar?.say(t('This part did not start. Choose "Generate" again.', '这一段没有开始,请再点一次“生成”。')); } + } + } finally { startingHeld = false; } + } function completeSources() { return store.getSources(model.references(state.record).map(sourceKey)).then(() => { player.refresh(); tracks?.refresh?.(); }); } @@ -252,8 +370,10 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!value.node || !value.prompt?.[value.node]) throw new Error('missing_node'); const inputs = value.prompt[value.node].inputs; Object.assign(inputs, state.record.settings || {}, capability.generationSize || generationSize(state.record)); - // Tonight's new content uses text. References remain in the creative panel. - delete inputs.media; delete inputs.continue_from; delete inputs.continue_to; + // The media panel's pictures guide a part that starts fresh (generate() drops them when every part continues footage); + // without a panel to show them they stay out of the request. + if (!panel.references?.available()) delete inputs.media; + delete inputs.continue_from; delete inputs.continue_to; return {node: String(value.node), prompt: value.prompt, ...(value.extra_data ? {extra_data: value.extra_data} : {}), ...(api.clientId ? {client_id: api.clientId} : {})}; } function pinStoppedJob(record, ids, job) { @@ -301,6 +421,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (completed.size || (!active && previous?.state !== job.state) || (job.output && job.output !== previous?.output)) library?.refresh({outputs: job.output ? [job.output] : [...completed]}); if (!active) jobWatchers.delete(job.id); + void startHeld(); return active; } function watchJob(id) { @@ -314,10 +435,13 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (!viewReady) return; const key = state.record.id || viewKey; const value = {selection: state.selection, text: genbar.getText(), playhead: state.playhead, - zoom: state.zoom, scrollLeft: tracks?.getScrollLeft() || 0}; + zoom: state.zoom, scrollLeft: tracks ? tracks.getScrollLeft() : editorViews.get(key)?.scrollLeft || 0}; editorViews.set(key, value); try { - localStorage.setItem(viewPrefix + key, JSON.stringify({selection: value.selection, text: value.text})); + // Queued parts stay queued across a reload; they are only planned drafts until sent. + const waiting = held.map(entry => ({ids: entry.ids, text: entry.text})); + value.held = waiting; + localStorage.setItem(viewPrefix + key, JSON.stringify({selection: value.selection, text: value.text, ...(waiting.length ? {held: waiting} : {})})); if (state.record.id) localStorage.setItem('fv-editor-last', key); } catch {} // An untouched edit belongs to this tab until it has a saved identity. @@ -333,7 +457,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given let remembered = editorViews.get(viewKey); if (!remembered) try { remembered = JSON.parse(localStorage.getItem(viewPrefix + viewKey)); } catch {} initialSelection = offerInitial = extendEntry = null; - projectEpoch++; + projectEpoch++; held.length = 0; player.pause(); for (const stop of jobWatchers.values()) stop(); jobWatchers.clear(); jobStatuses.clear(); progress.clear(); runProgress.clear(); progressConnection?.reset(); mostRecentJob = null; genbar?.update(null); queue.reset(record); stopExport?.(); stopExport = null; exportStatus = exportSnapshot = null; exportButton.textContent = t('Export', '导出'); suppressSave = true; try { state.replace(record); savedContent = content(record); } finally { suppressSave = false; } @@ -375,7 +499,14 @@ export function openEditor({api, app, from = null, panel = {}, components: given offerInitial(); genbar?.setText(typeof remembered?.text === 'string' ? remembered.text : ''); tracks?.setScrollLeft(remembered?.scrollLeft); - viewReady = true; updateTransport(); + // Only parts still waiting as drafts come back; one that has since been sent or changed is left alone. + for (const entry of Array.isArray(remembered?.held) && !store.generationGone ? remembered.held : []) { + if (!Array.isArray(entry?.ids) || typeof entry.text !== 'string') continue; + const clips = entry.ids.map(id => model.find(state.record, id)); + if (!clips.length || clips.some(clip => !clip || clip.source || clip.pending !== 'draft' || clip.job) || entry.ids.some(isHeld)) continue; + held.push({ids: [...entry.ids], text: entry.text}); markHeld(entry.ids, true); + } + viewReady = true; updateTransport(); if (remembered?.held?.length) rememberView(); if (held.length) void startHeld(); } async function switchProject(id) { if (switching) return; switching = true; updateTop(); @@ -417,6 +548,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given }, generationLimits: () => ({maxSeconds: capability.maxSeconds, maxFollowSeconds: capability.maxFollowSeconds}), generationAvailable: () => store.generationAvailable, + importFiles: (files, options) => library?.importFiles(files, options), gestureStart({selectionGesture = false} = {}) { pointerEditing = true; if (selectionGesture && !menu.hidden) { menu.hidden = true; updateTransport(); } @@ -445,13 +577,13 @@ export function openEditor({api, app, from = null, panel = {}, components: given } }, reasonText: code => code === 'gpu_memory' ? t('Not enough graphics memory', '显存不够') : t('Could not generate', '没能生成'), - supportedSelection() { - if (state.selection?.kind === 'clip' && state.selection.ids.some(id => isText(model.find(state.record, id)))) return false; - const range = selectionRange(); if (!range) return false; - if (state.selection.kind === 'range' && !['v1', 'a1'].every(id => state.selection.tracks.includes(id))) return false; - if (state.selection.kind === 'gap' && state.selection.track !== 'v1') return false; + supportedSelection(selection = state.selection) { + if (selection?.kind === 'clip' && selection.ids.some(id => isText(model.find(state.record, id)))) return false; + const range = selectionRange(selection); if (!range) return false; + if (selection.kind === 'range' && !['v1', 'a1'].every(id => selection.tracks.includes(id))) return false; + if (selection.kind === 'gap' && selection.track !== 'v1') return false; try { - const selected = selectionClips(); + const selected = selectionClips(selection); if (selected.length && selected.every(clip => clip.pending && !clip.source)) model.prepare(state.record, selected.map(clip => clip.id)); else model.planRange(state.record, {...range, target: 'both'}); @@ -459,14 +591,17 @@ export function openEditor({api, app, from = null, panel = {}, components: given } catch (value) { if (value instanceof model.EditError) return value.code === 'busy'; throw value; } }, - canGenerate(text) { - if (!store.generationAvailable || !actions.supportedSelection() || jobActive()) return false; - const range = selectionRange(); + // Another part generating is no reason: the server runs parts one after another. + canGenerate(text, selection = state.selection) { + return store.generationAvailable && actions.supportedSelection(selection) && (!!text.trim() || !actions.needsText(selection)); + }, + needsText(selection = state.selection) { + const range = selectionRange(selection); if (!range) return true; // Only a part pulled out of a finished one needs no description: it continues that one. const head = state.record.tracks[0].clips.find(clip => clip.start === range.start); const before = !!head && model.continuesBefore(state.record, head.id) && state.record.tracks[0].clips.some(clip => clip.start + clip.frames === range.start && clip.source && clip.extend && clip.in + clip.frames === clip.length); - return !!text.trim() || before; + return !before; }, // Only where earlier footage is continued: a part right after an extendable clip, or a range split into several parts. firstFrameVisible() { @@ -477,6 +612,9 @@ export function openEditor({api, app, from = null, panel = {}, components: given clip.start + clip.frames === range.start && clip.source && clip.extend && clip.in + clip.frames === clip.length); return before || model.rangeParts(state.record, {...range, ...capability}).length > 1; }, + // Pictures that guide a new part: the creative panel's media rows, shown while the part starts fresh. + references: panel.references ? {source: panel.references, notify, size: () => capability.generationSize || generationSize(state.record), + visible: () => store.generationAvailable && actions.supportedSelection() && startsFresh()} : null, setFirstFrame: value => { state.apply(t('First-frame reference', '首帧参考'), record => model.setFlag(record, 'first_frame_reference', value)); }, previewRange: selection => genbar?.preview(selection), async estimate(selection) { @@ -485,13 +623,15 @@ export function openEditor({api, app, from = null, panel = {}, components: given for (const count of model.rangeParts(state.record, {...range, ...capability})) { const value = capability.estimate(count); if (value == null) return null; seconds += value; } return seconds; }, - async generate({target = 'both', text = '', label = t('Generate', '生成')} = {}) { - if (target !== 'both' || !actions.supportedSelection()) throw new model.EditError('unsupported'); - if (!actions.canGenerate(text)) throw new model.EditError('text'); + async generate({target = 'both', text = '', label = t('Generate', '生成'), selection = state.selection} = {}) { + if (target !== 'both' || !actions.supportedSelection(selection)) throw new model.EditError('unsupported'); + if (!actions.canGenerate(text, selection)) throw new model.EditError('text'); + const shown = selection === state.selection; generationBusy = true; updateTop(); updateTransport(); generationDone = new Promise(resolve => { finishGeneration = resolve; }); let ids = [], prepared = null, beforeAttempt = null, priorSelection = null, priorText = null, submittedSelection = null; - const epoch = projectEpoch, range = selectionRange(), selectedIds = selectionClips().map(clip => clip.id); + const epoch = projectEpoch, range = selectionRange(selection), selectedIds = selectionClips(selection).map(clip => clip.id); + sendingRanges.add(range); try { const frozen = await snapshot(); if (epoch !== projectEpoch || closing || disposed || !store.generationAvailable) return; beforeAttempt = state.record; priorSelection = state.selection; priorText = state.rangeText; @@ -502,10 +642,15 @@ export function openEditor({api, app, from = null, panel = {}, components: given ids = next.tracks[0].clips.filter(clip => clip.start >= range.start && clip.start < range.start + range.frames && clip.pending && !['queued', 'running'].includes(clip.pending)).map(clip => clip.id); return model.prepare(next, ids); }, {checkpoint: true}); + for (const id of ids) sending.add(id); prepared = state.record; - submittedSelection = state.select({kind: 'clip', ids}); await completeSources(); + // Only a press on what is still chosen moves the choice; a part started in the background + // leaves whatever the user has moved on to, and what they typed there. + submittedSelection = shown && state.selection === selection ? state.select({kind: 'clip', ids}) : state.selection; await completeSources(); const paths = Object.fromEntries([...store.sources].map(([key, value]) => [key, value.path])); const request = model.generationRequest(prepared, ids, {text, paths}); + // Pictures guide parts that start fresh; a request whose parts all continue footage carries none. + if (!request.blocks.some(block => !block.after)) delete frozen.prompt[frozen.node].inputs.media; const follow = continuedSize(model.generationBefore(prepared, ids), store.sources), edit = capability.generationSize || generationSize(state.record); Object.assign(frozen.prompt[frozen.node].inputs, follow || {}); // Parts made at another size than the edit's have that size's limit. @@ -523,7 +668,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const job = await store.startChain({...frozen, blocks}); if (!job && !store.generationAvailable) { if (epoch === projectEpoch) { - const restoreSelection = state.selection === submittedSelection; + const restoreSelection = submittedSelection !== priorSelection && state.selection === submittedSelection; // Reverse only this submission's changes. Keep save receipts, // reserved IDs and unrelated edits made while POST was pending. state.settle(record => restoreGeneration(record, beforeAttempt, prepared)); @@ -539,9 +684,59 @@ export function openEditor({api, app, from = null, panel = {}, components: given } catch (value) { if (ids.length && epoch === projectEpoch) state.settle(record => model.settle(record, ids, 'draft')); throw value; - } finally { if (disposed && ids.length) queue.enqueue(state.record); generationBusy = false; finishGeneration?.(); generationDone = null; finishGeneration = null; updateTop(); updateTransport(); } + } finally { sendingRanges.delete(range); for (const id of ids) sending.delete(id); if (disposed && ids.length) queue.enqueue(state.record); generationBusy = false; finishGeneration?.(); generationDone = null; finishGeneration = null; updateTop(); updateTransport(); startHeld(); } + }, + // A press on Generate always answers: what can be done for the user is done, then said in one line. + async press(text) { + if (disposed || closing || initializing) return {say: t('Opening the edit…', '正在打开…')}; + let selection = state.selection, said = null; + if (!selectionRange(selection)) return {say: t('Drag on the timeline to choose a part first.', '先在时间线上拖出一段。')}; + if (actions.selectionHeld()) return {say: t('Queued', '排队中')}; + if (busySelection(selection)) return {say: t('This part is generating.', '这一段正在生成。')}; + const flush = flushSpot(selection); + if (flush) { selection = state.select(flush); said = t('Moved to right after the part before.', '已接在上一段后面。'); genbar?.say(said); } + else if (!actions.supportedSelection(selection)) { + const moved = emptySpot(selection); + if (!moved) return {say: t('Choose an empty spot on the timeline.', '请在时间线上选一段空白。')}; + selection = state.select(moved); said = t('Moved to the next empty spot.', '已挪到后面的空位。'); genbar?.say(said); + } + let used = null; + if (!text.trim() && actions.needsText(selection)) { + const found = nearbyText(selectionRange(selection).start); + if (!found) return {say: [said, t('Write a description first.', '先写一句描述。')].filter(Boolean).join(' '), focus: true}; + text = found.text; used = found.before ? t('Used the description of the part before.', '沿用了上一段的描述。') + : t('Used the latest description.', '沿用了最近一次的描述。'); + state.setRangeText(text); genbar?.setText(text); + } + const waiting = waitingFor(selection); + if (waiting) { + hold(selection, text); + return {text, say: [said, used, waiting === 'ready' ? t('Queued. It starts once generation is ready.', '已排队,准备好后自动开始。') + : t('Queued. It starts after the part before it.', '已排队,上一段完成后自动开始。')].filter(Boolean).join(' '), queued: true}; + } + const busyBefore = jobActive(); + await actions.generate({text, selection}); + return {text, say: [said, used, busyBefore ? t('Queued. It starts after the part now generating.', '已排队,正在生成的那段完成后自动开始。') : null].filter(Boolean).join(' ') || null, queued: busyBefore}; + }, + selectionRunning: () => busySelection(state.selection), + // A choice over existing clips that Generate would move to an empty spot. + selectionMovable: () => !actions.supportedSelection() && !!emptySpot(state.selection), + selectionSending() { const range = selectionRange(state.selection); return !!range && ([...sendingRanges].some(other => overlaps(other, range)) || [...sending].some(id => { const clip = model.find(state.record, id); return clip && overlaps(clip, range); })); }, + selectionHeld() { const ids = selectionClips(state.selection).map(clip => clip.id); return ids.length > 0 && ids.every(isHeld); }, + // Takes the selected parts out of the line; they stay as drafts. + unhold() { + const ids = selectionClips(state.selection).map(clip => clip.id); + for (let i = held.length - 1; i >= 0; i--) if (held[i].ids.some(id => ids.includes(id))) { markHeld(held[i].ids, false); held.splice(i, 1); } + rememberView(); + }, + async cancel() { + const range = selectionRange(state.selection); + const own = range && state.record.tracks[0].clips.find(clip => clip.job && generating(clip) + && clip.start < range.start + range.frames && clip.start + clip.frames > range.start)?.job; + // A part still being sent has no job yet; never stop another one in its place. + if (!own && actions.selectionSending()) return; + const id = own || mostRecentJob; if (id) await deliverChain(await store.chainAction(id, 'cancel')); }, - async cancel() { const id = mostRecentJob; if (id) await deliverChain(await store.chainAction(id, 'cancel')); }, async resume() { if (!store.generationAvailable) return; const epoch = projectEpoch; @@ -747,6 +942,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given actions.seekPlayhead = frame => tracks.seekPlayhead(frame); function keyboard(event) { if (event.isComposing || event.keyCode === 229) return; + if (event.target.closest('.fv-ed-library')) return; if (!initializing && textTools.keyboard(event)) return; if (pictureEdit.keyboard(event)) return; if (event.key.toLowerCase() === 'escape' && dismissPopup(event)) return; @@ -781,7 +977,13 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (tracks?.cancelGesture()) { event.preventDefault(); return; } if (document.fullscreenElement) { event.preventDefault(); document.exitFullscreen().catch(() => {}); return; } } - if (handleAudioShortcut(event, state, {t, error})) return; + if (inside && event.target.closest?.('.fv-ed-refs')) { + // The reference pictures' own buttons and menu keep their keys: no timeline, audio or transport shortcut. + for (let target = event.target; target && target !== element; target = target.parentNode) target.onkeydown?.(event); + return; + } + const inLibrary = inside && !!event.target.closest('.fv-ed-library'); + if ((!inLibrary || ['Enter', ' '].includes(event.key)) && handleAudioShortcut(event, state, {t, error})) return; if (!inside && transportControls.keyboard(event, {ready: !initializing})) return; if (inside) { // Preserve the input/library controls' own handlers before shortcuts. @@ -789,6 +991,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (target.onkeydown?.(event) === false) event.preventDefault(); if (event.defaultPrevented) return; } + if (inLibrary) return; if (!event.target.closest('.fv-ed-adjust,.fv-ed-library') && transportControls.keyboard(event, {ready: !initializing})) return; if (event.target.closest('.fv-ed-tracks')) { if (!textTools.keyboard(event)) tracks?.keyboard(event); return; } } @@ -799,6 +1002,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (deferredStamp) { const value = deferredStamp; deferredStamp = null; suppressSave = true; try { state.adopt(value); } finally { suppressSave = false; } + if (held.length) rememberView(); } }; listen('dragover', event => { if ([...event.dataTransfer.types].includes('Files')) { event.preventDefault(); event.dataTransfer.dropEffect = 'copy'; } }); @@ -887,7 +1091,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given if (trackResult.status === 'rejected') throw trackResult.reason; const module = trackResult.value, components = componentResult.status === 'fulfilled' ? componentResult.value : {}; blockStates = components.blocks?.blockStates || null; - let blockProgress = basicBlockProgress; + let blockProgress = (id, clipElement) => basicBlockProgress(id, clipElement, t); if (components.blocks?.createBlockProgress) blockProgress = (id, clipElement) => { const component = components.blocks.createBlockProgress({t, showPercent: clipElement.dataset.track === 'v1'}); let run = null; diff --git a/web/editor_entry.js b/web/editor_entry.js index 9d2926e..3f2feb8 100644 --- a/web/editor_entry.js +++ b/web/editor_entry.js @@ -3,15 +3,18 @@ import {openEditor} from './editor.js'; import {api} from '../../scripts/api.js'; import {app} from '../../scripts/app.js'; import {openStudio, showNodes} from './studio.js'; +import {cleanOptionalPrompt} from './optional_inputs.js'; import {openAutopilot, viewChanged} from './view_navigation.js'; +import {mediaReferences} from './editor_media_references.js'; function editorPanel(node) { const values = Object.fromEntries((node.widgets || []).filter(widget => widget.name).map(widget => [widget.name, widget.value])); return {node: node.id, width: values.width, height: values.height, text: values.text || '', settings: Object.fromEntries(Object.entries({ two_pass: values.two_pass, base_steps: values.base_steps, refine_steps: values.refine_steps, seed: values.seed, sampling_effort: values.sampling_effort}).filter(([, value]) => value !== undefined)), + references: mediaReferences(node), async snapshot() { if (node.freevideoEditorLooping?.()) throw new Error('loop_active'); - const value = structuredClone(await app.graphToPrompt()); + const value = cleanOptionalPrompt(structuredClone(await app.graphToPrompt())); return {node: String(node.id), prompt: value.output, extra_data: {extra_pnginfo: {workflow: value.workflow || {}}}}; }}; } diff --git a/web/editor_genbar.js b/web/editor_genbar.js index a2ce09d..b828114 100644 --- a/web/editor_genbar.js +++ b/web/editor_genbar.js @@ -1,33 +1,65 @@ // The editor owns its generation controls; progress can use the optional component. import {createFirstFrameSwitch} from './editor_first_frame.js'; +import {createEditorReferences} from './editor_references.js'; const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; export const timeEstimate = (seconds, t) => seconds == null ? '' : seconds < 60 ? t(`about ${Math.max(1, Math.ceil(seconds))} s`, `约 ${Math.max(1, Math.ceil(seconds))} 秒`) : t(`about ${Math.ceil(seconds / 60)} minutes`, `约 ${Math.ceil(seconds / 60)} 分钟`); +// After the next frame is on screen; a page nobody sees has no frame to wait for. +const painted = () => typeof document === 'undefined' || document.visibilityState !== 'visible' || typeof requestAnimationFrame !== 'function' ? Promise.resolve() + : new Promise(resolve => requestAnimationFrame(() => { const {port1, port2} = new MessageChannel(); port1.onmessage = () => { port1.close(); resolve(); }; port2.postMessage(null); })); export function createEditorGenbar(host, {state, t, actions, text = ''}) { - const hint = el('span', 'fv-ed-gen-hint'); + const hint = el('span', 'fv-ed-gen-hint'), note = el('span', 'fv-ed-gen-note'); + note.setAttribute('role', 'status'); note.hidden = true; const input = el('input', 'fv-ed-description'); input.type = 'text'; input.value = text; const generate = el('button', 'fv-ed-generate'), cancel = el('button', null, t('Cancel', '取消')), resume = el('button', null, t('Continue', '继续')); for (const button of [generate, cancel, resume]) button.type = 'button'; - host.append(hint, input, generate, cancel, resume); + host.append(hint, note, input, generate, cancel, resume); const firstFrame = createFirstFrameSwitch(host, {state, t, actions}); input.after(firstFrame.element); - let status = null, serial = 0, disposed = false, submitting = false, previewSelection = null, estimateCache = null; + // Reference pictures for a part that starts fresh: thumbnails and a small "+" before the description. + const references = actions.references ? createEditorReferences({t, source: actions.references.source, size: actions.references.size, error: actions.error, notify: actions.references.notify}) : null; + if (references) input.before(references.element); + let status = null, serial = 0, disposed = false, pressing = new Set(), previewSelection = null, estimateCache = null, noteTimer = null, queuedAt = null; + // Which parts are generating, or how the job ended: a "Queued" line goes once this moves on. + const progressKey = () => ['done', 'failed', 'cancelled', 'interrupted'].includes(status?.state) ? status.state + : (status?.blocks || []).filter(block => block.state === 'running').map(block => block.id).join(','); + function say(text, queued = false) { + if (disposed) return; + queuedAt = text && queued ? progressKey() : null; + clearTimeout(noteTimer); note.textContent = text || ''; note.hidden = !text; + if (text) noteTimer = setTimeout(() => { note.hidden = true; }, 6000); + } async function render() { if (disposed) return; + if (queuedAt != null && progressKey() !== queuedAt) say(''); const current = ++serial, selection = previewSelection || state.selection; - const busy = submitting || ['queued', 'running', 'cancelling'].includes(status?.state); - const stopped = ['failed', 'cancelled', 'interrupted'].includes(status?.state); + const running = ['queued', 'running', 'cancelling'].includes(status?.state); const selected = !!selection && (!!previewSelection || (actions.supportedSelection?.() ?? true)); + // While a part generates, choosing another place offers Generate again; it is queued behind. + // A press is being sent for the parts in `pressing`; only that same selection waits for it. + const sending = pressing.has(JSON.stringify(state.selection ?? null)), starting = pressing.size > 0 && !running; + const other = (running || starting) && !sending && selected && !previewSelection && actions.selectionRunning?.() === false; + // A part pressed while it could not start yet waits in line; its Cancel takes it out of the line. + const held = !previewSelection && !!actions.selectionHeld?.(); + const busy = sending || held || (running || starting) && !other; + // The selected part is still being sent: it can only be stopped once it has started. + const opening = (starting || !!actions.selectionSending?.()) && !held; + const stopped = ['failed', 'cancelled', 'interrupted'].includes(status?.state); host.dataset.mode = busy ? 'running' : selected ? 'selected' : 'idle'; - hint.hidden = !busy; input.hidden = generate.hidden = busy; - input.disabled = !selected || busy; - input.placeholder = selected ? t('Describe this part', '描述这一段') + hint.hidden = !busy && !other; input.hidden = generate.hidden = busy; + // A choice that will move to an empty spot can still be described first. + const writable = selected || !previewSelection && !!selection && !!actions.selectionMovable?.(); + input.disabled = !writable || busy; + input.placeholder = writable ? t('Describe this part', '描述这一段') : ''; input.setAttribute('aria-label', t('Description', '描述')); generate.classList.toggle('fv-primary', selected && !busy); - cancel.hidden = !busy; resume.hidden = !stopped; - if (busy) { + cancel.hidden = !busy || opening; resume.hidden = !stopped; + const waiting = status?.state === 'queued' && !(status.blocks || []).some(block => ['queued', 'running'].includes(block.state) && block.prompt_id); + if (opening) hint.textContent = t('Starting…', '正在开始…'); + else if (held || waiting) hint.textContent = t('Queued', '排队中'); + else if (running) { const blocks = status?.blocks || [], done = blocks.filter(block => block.state === 'done').length; const number = Math.min(blocks.length || 1, Math.max(done, status?.chainProgress?.index || 0) + 1), count = blocks.length || 1; const remaining = status?.chainProgress?.overall?.remaining_seconds ?? (blocks.every(block => Number.isFinite(block.remaining_seconds)) ? blocks.reduce((sum, block) => sum + block.remaining_seconds, 0) : null); @@ -35,8 +67,10 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { + (remaining != null ? ' · ' + timeEstimate(remaining, t) : ''); } firstFrame.update(busy); + references?.update(busy, busy ? undefined : !!actions.references.visible()); generate.textContent = t('Generate', '生成'); - generate.disabled = busy || !!previewSelection || !selected || !actions.canGenerate?.(input.value); + // Never disabled for a reason the press itself can explain or fix. + generate.disabled = busy || !!previewSelection; generate.title = ''; if (selected && !busy) { const key = JSON.stringify(selection); @@ -50,12 +84,24 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { } input.oninput = () => { state.setRangeText(input.value); render(); }; generate.onclick = async () => { - submitting = true; render(); - try { await actions.generate({target: 'both', text: input.value}); } - catch (error) { actions.error(error); } - finally { submitting = false; render(); } + const key = JSON.stringify(state.selection ?? null); + if (pressing.has(key)) return; + pressing.add(key); say(''); render(); + const typed = input.value; + try { + // The press shows before any planning, however long that takes. + await painted(); + const result = actions.press ? await actions.press(input.value) : await actions.generate({target: 'both', text: input.value}); + // Never over what the user typed meanwhile, for another part. + if (result?.text != null && result.text !== input.value && input.value === typed) input.value = result.text; + if (result?.say) say(result.say, !!result.queued); + if (result?.focus) { pressing.delete(key); render(); input.disabled = false; input.focus(); } + } + // A specific reason has already been said; a general one is added only when there was none. + catch (error) { if (!actions.error(error) && !note.textContent) say(t('Could not start. Try again.', '没能开始,请重试。')); } + finally { pressing.delete(key); render(); } }; - cancel.onclick = () => actions.cancel().catch(actions.error); + cancel.onclick = () => (!previewSelection && actions.selectionHeld?.() ? Promise.resolve(actions.unhold()).then(render) : actions.cancel()).catch(actions.error); resume.onclick = () => actions.resume().catch(actions.error); const unsubscribe = state.subscribe(event => { if (event.type === 'selection' && state.selection?.kind === 'range') { @@ -70,8 +116,8 @@ export function createEditorGenbar(host, {state, t, actions, text = ''}) { if (event.type === 'selection' || event.type === 'record') render(); }); render(); - return {element: host, getText() { return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, - dispose() { disposed = true; ++serial; unsubscribe(); }}; + return {element: host, say, getText() { return input.value; }, update(value) { status = value; render(); }, preview(selection) { previewSelection = selection; render(); }, setText(value) { input.value = value || ''; state.setRangeText(input.value); render(); }, + dispose() { disposed = true; ++serial; clearTimeout(noteTimer); unsubscribe(); references?.dispose(); }}; } // A missing optional module leaves the editor's existing progress in place. @@ -100,6 +146,7 @@ export function editorBlockStates(job, messages = [], resolve = null) { ? Number.isFinite(live.percent) ? live.percent / 100 : live.bar ?? live.overall?.fraction ?? block.fraction : block.fraction; return [block.id, {state: running && live ? 'generating' : states[block.state] || 'waiting', + ...(['pending', 'queued'].includes(block.state) && !live ? {queued: true} : {}), fraction: Math.max(0, Math.min(1, fraction || 0)), remainingSeconds: running && live ? live.remaining_seconds ?? live.overall?.remaining_seconds ?? block.remaining_seconds ?? null : block.remaining_seconds ?? null}]; diff --git a/web/editor_generation.js b/web/editor_generation.js index 4bf818d..dbc2730 100644 --- a/web/editor_generation.js +++ b/web/editor_generation.js @@ -13,11 +13,12 @@ export function generationSize(project, sizes = []) { || Math.abs(a.width * a.height - area) - Math.abs(b.width * b.height - area)); return {width: candidates[0].width, height: candidates[0].height}; } -// A part that continues finished footage is made at that footage's size, whatever the edit's own: the size all the -// footage a request continues has in common. null when a video in the request starts fresh or the sizes differ; the -// edit's size then stands. +// A part that continues finished footage is made at that footage's shape, whatever the edit's own: the size all the +// footage a request continues has in common (an imported video off the 32-pixel grid gets the nearest size of its +// shape). null when a video in the request starts fresh or the sizes differ; the edit's size then stands. export function continuedSize(before, sources) { - const sizes = before.map(key => key && sources.get(key)); + const sizes = before.map(key => key && sources.get(key)).map(size => + valid(size) ? size : [size?.width, size?.height].every(side => Number.isSafeInteger(side) && side > 0) ? generationSize(size) : null); if (!sizes.length || !sizes.every(valid)) return null; const {width, height} = sizes[0]; return sizes.every(size => size.width === width && size.height === height) ? {width, height} : null; diff --git a/web/editor_library.js b/web/editor_library.js index 83b3c61..3b52db3 100644 --- a/web/editor_library.js +++ b/web/editor_library.js @@ -165,8 +165,24 @@ export function createEditorLibrary(host, {store, t, actions, state}) { } host.addEventListener('scroll', scheduleMore, {passive: true}); const resize = new ResizeObserver(scheduleMore); resize.observe(host); - async function importFiles(files) { + // Files dropped on the tracks go to the drop point once ready, in drop order. + const placing = []; + let placed = Promise.resolve(); + function placeReady(items) { + while (placing.length && !disposed) { + const head = placing[0], row = items.find(item => item.id === head.id); + if (row?.state === 'preparing' || (!row && recentImports.has(head.id))) return; + placing.shift(); + if (row?.state !== 'ready') continue; + const value = metadata(row, 'import'), at = head.place.at; + if (value.hasVideo) head.place.at += value.frames; + const insert = {source: {kind: 'import', id: row.id}, at, frames: value.frames, hasVideo: value.hasVideo, hasAudio: value.hasAudio}; + placed = placed.then(() => disposed ? null : actions.insertSource(insert, {loadMetadata: true})).catch(actions.error); + } + } + async function importFiles(files, {at} = {}) { choose('import'); + const place = Number.isSafeInteger(at) && at >= 0 ? {at} : null; for (const value of files) { if (disposed) break; const id = 'upload-' + Math.random(), row = {id, name: value.name, kind: value.type.startsWith('audio') ? 'audio' : 'video', state: 'preparing', fraction: 0}; @@ -176,6 +192,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { if (item && !disposed) { recentImports.set(item.id, item); imported = [item, ...imported.filter(existing => existing.id !== item.id)]; + if (place) { placing.push({id: item.id, place}); placeReady(imported); } } } catch { actions.notify(t('This file cannot be opened. Supported formats: ', '这个文件打不开。支持的格式:') + FORMATS); } finally { uploading.delete(id); render(); } @@ -186,6 +203,7 @@ export function createEditorLibrary(host, {store, t, actions, state}) { for (const item of items) recentImports.delete(item.id); // A list request started before an upload may finish after it. imported = [...recentImports.values(), ...items]; render(); + placeReady(imported); }); const stopUsed = state?.subscribe(event => { if (event.type === 'record') updateUsed(); }); render(); load(); diff --git a/web/editor_media_references.js b/web/editor_media_references.js new file mode 100644 index 0000000..3080bf5 --- /dev/null +++ b/web/editor_media_references.js @@ -0,0 +1,76 @@ +// The editing view's reference pictures are the creative panel's own media rows: one list, two places to change it. +import {api} from '../../scripts/api.js'; +import {openImageEditor} from './image_editor.js'; +import {imageGenerationSupported} from './image_generation.js'; +export const MAX_REFERENCES = 32; +const IMAGE = /\.(png|jpe?g|webp|bmp|tiff?)$/i; +export const isImageFile = file => IMAGE.test(file || ''); +const inUse = rows => rows.filter(row => row.enabled !== false).length; +export const ROLES = ['reference', 'first', 'last']; +const upstream = (node, name) => { + const input = node?.inputs?.find(i => i.name === name); + const links = node?.graph?.links; + const link = input?.link != null ? links?.get?.(input.link) ?? links?.[input.link] : null; + return link ? node.graph.getNodeById(link.origin_id) : null; +}; +// `node` is the generate node; null while no media panel is connected to it. +export function mediaReferences(node) { + const media = () => upstream(node, 'media'); + const widget = () => media()?.widgets?.find(w => w.name === 'assets') || null; + const read = () => { + try { const rows = JSON.parse(widget()?.value || '[]'); return Array.isArray(rows) ? rows : []; } catch { return []; } + }; + return { + available: () => !!widget(), + rows: read, + write(rows) { + const field = widget(), owner = media(); + // The limit counts pictures in use; when the list would grow past it, the oldest spare ones go first. + for (let at = rows.findIndex(row => row.enabled === false); rows.length > MAX_REFERENCES && at >= 0; + at = rows.findIndex(row => row.enabled === false)) rows.splice(at, 1); + if (!field || rows.length > MAX_REFERENCES) throw new Error('references'); + field.value = JSON.stringify(rows); + owner.graph?.change?.(); + window.dispatchEvent(new CustomEvent('freevideo-media', {detail: owner.id})); + }, + url(file) { + const split = file.lastIndexOf('/'); + return api.apiURL('/view?' + new URLSearchParams({ + filename: file.slice(split + 1), subfolder: split < 0 ? '' : file.slice(0, split), type: 'input'})); + }, + // Into ComfyUI's input folder through FreeVideo's streaming upload; a run waits for it, as in the creative panel. + async upload(file) { + const owner = media(); + // One upload at a time across the panel and this view, so neither writes over the other's list. + for (let waited = 0; owner?.isUploading && waited < 600; waited++) await new Promise(done => setTimeout(done, 100)); + if (owner?.isUploading) throw new Error('uploading'); + if (owner) owner.isUploading = true; + try { + const body = new FormData(); body.append('file', file); + const response = await api.fetchApi('/freevideo/media/upload', {method: 'POST', body}); + const result = await response.json(); + if (!response.ok) throw new Error(result.error || response.statusText); + return result; + } finally { if (owner) owner.isUploading = false; } + }, + canGenerateImage: imageGenerationSupported, + // The image editor opens on a blank picture the shape of the video, ready to generate one; a saved one is added as a reference. + async newImage({target, t}) { + const rows = read(); + if (inUse(rows) >= MAX_REFERENCES) return null; + const library = rows.filter(row => isImageFile(row.file)).map(row => ({file: row.file, label: (row.source || row.file).split('/').pop()})); + const outcome = await openImageEditor({target, role: 'reference', t, resolve: file => this.url(file), upload: file => this.upload(file), library}); + if (!outcome?.blob) return null; + const result = await this.upload(new File([outcome.blob], outcome.name, {type: outcome.blob.type})); + const latest = read(); + if (inUse(latest) >= MAX_REFERENCES) return null; + latest.push({file: result.file, role: 'reference', enabled: true, edit: outcome.edit}); this.write(latest); + return result.file; + }, + subscribe(listener) { + const changed = event => { if (String(event.detail) === String(media()?.id)) listener(); }; + window.addEventListener('freevideo-media', changed); + return () => window.removeEventListener('freevideo-media', changed); + }, + }; +} diff --git a/web/editor_model.js b/web/editor_model.js index 0c80ecf..ac2acf7 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -161,7 +161,7 @@ export function parse(value) { const versions = item.versions.map(value => versionData(value, kind)), versionKeys = versions.map(key); check(new Set(versionKeys).size === versions.length, 'versions'); const clip = {id: item.id, start: item.start, frames: item.frames, in: item.in, length: item.length, - source, link, text, part, pending, reason, job, extend: item.extend, versions}; + source, link, text, part, pending, reason, job, extend: item.extend || (source?.kind === 'import' && item.length >= TAIL_FRAMES), versions}; if (kind === 'video') { Object.assign(clip, pictureFields(item)); clip.fit = pictureFit(item.fit); } else check(item.fit === undefined, 'fit'); if (kind === 'audio') { check(typeof item.volume === 'number' && Number.isFinite(item.volume) && item.volume >= 0 && item.volume <= 2, 'volume'); clip.volume = item.volume; } @@ -321,10 +321,12 @@ function audioTrack(record, id = 'a1') { return trackBy(record, id); } const freshId = record => `c${++record.serial}`; +// A continued video needs this many frames for its encoded tail. +const TAIL_FRAMES = 39; function newClip(record, track, {source = null, start, frames, part = null, text = ''}) { const clip = {id: freshId(record), start, frames, in: 0, length: frames, source, link: null, text, part, pending: source ? null : 'draft', reason: null, job: null, - extend: source ? source.kind !== 'import' : true, versions: []}; + extend: source?.kind !== 'import' || frames >= TAIL_FRAMES, versions: []}; if (track.kind === 'audio') clip.volume = 1; track.clips.push(clip); return clip; } @@ -911,7 +913,7 @@ export function prepare(record, ids) { const spare = Math.min(...members.map(clip => clip.length - clip.in - clip.frames)); const first = find(next, clips[0].id); // Leave at least one visible frame in every planned segment. - const credit = spare < first.frames ? spare : 0; + const credit = spare > 0 && spare < first.frames ? spare : 0; if (credit) { busy(members); for (const clip of members) clip.frames += credit; @@ -939,6 +941,12 @@ export function generationBefore(record, ids) { return generationPlan(record, clips).filter(row => !row.before || !clips.some(clip => clip.id === row.before.id)) .map(row => row.before ? key(row.before.source) : null); } +// An imported video is continued from the copy the editor plays, which is upright and at 24 fps. +function continuedPath(source, path) { + if (source.kind !== 'import') return sourceData({kind: 'generated', id: path}).id; + check(path === `FreeVideo/.editor/imports/${source.id}/preview.mp4`, 'source'); + return path; +} export function generationRequest(record, ids, {text, paths = {}} = {}) { check(object(paths), 'source'); if (text !== undefined) check(validText(text), 'text'); @@ -947,7 +955,7 @@ export function generationRequest(record, ids, {text, paths = {}} = {}) { let after = null; if (row.before) { if (clips.some(clip => clip.id === row.before.id)) after = row.before.id; - else { const path = paths[key(row.before.source)]; check(typeof path === 'string' && path.length > 0, 'source', 'missing source path'); after = {work: sourceData({kind: 'generated', id: path}).id}; } + else { const path = paths[key(row.before.source)]; check(typeof path === 'string' && path.length > 0, 'source', 'missing source path'); after = {work: continuedPath(row.before.source, path)}; } } blocks.push({id: row.clip.id, part: row.clip.part ?? row.clip.id, after, before: null, text: text ?? row.clip.text, frames: row.request_frames}); plan[row.clip.id] = {target_frames: row.target_frames, net_frames: row.net_frames, request_frames: row.request_frames, audio: row.clip.link}; @@ -996,7 +1004,7 @@ export function fill(record, id, {source, frames, width, height, skip_frames = 0 const take = numberTakes(clip) + 1; remember(clip, source); clip.source = source; clip.in = skip_frames; clip.length = frames; clip.take = take; if (clip.text) clip.made_text = clip.text; else delete clip.made_text; - clip.pending = clip.reason = clip.job = null; clip.extend = source.kind !== 'import'; + clip.pending = clip.reason = clip.job = null; clip.extend = true; delete clip.splice; if (skip_frames || skip_audio_samples) clip.splice = {frames: skip_frames, audio_samples: skip_audio_samples}; clearPlan(clip); @@ -1040,7 +1048,7 @@ export function restoreVersion(record, id, index, {by = 'user'} = {}) { remember(clip, version); clip.source = {kind: version.kind, id: version.id}; clip.in = version.in; clip.length = version.length; delete clip.take; delete clip.made_text; madeFields(version, clip); touchedBy(clip, by); // Placement belongs to this timeline part, including when its media changes. - clip.pending = clip.reason = clip.job = null; clip.extend = version.kind !== 'import'; clearPlan(clip); + clip.pending = clip.reason = clip.job = null; clip.extend = version.kind !== 'import' || version.length >= TAIL_FRAMES; clearPlan(clip); delete clip.splice; if (version.splice) clip.splice = version.splice; } return finish(next); diff --git a/web/editor_references.js b/web/editor_references.js new file mode 100644 index 0000000..e085128 --- /dev/null +++ b/web/editor_references.js @@ -0,0 +1,151 @@ +// Reference pictures for a new part, kept small inside the generation bar: thumbnails and one "+". +// `source` is the media rows of the creative panel (editor_media_references.js); `size()` is the video's shape. +const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }; +const IMAGE = /\.(png|jpe?g|webp|bmp|tiff?)$/i; +const MAX = 32; +export function createEditorReferences({t, source, size = () => null, error = () => {}, notify = () => {}}) { + const root = el('div', 'fv-ed-refs'), chips = el('div', 'fv-ed-ref-chips'); + const add = el('button', 'fv-ed-ref-add'); add.type = 'button'; + add.setAttribute('aria-haspopup', 'menu'); add.setAttribute('aria-expanded', 'false'); + add.setAttribute('aria-label', t('Reference image', '参考图')); add.title = t('Reference image', '参考图'); + add.textContent = '+'; + const menu = el('div', 'fv-ed-ref-menu'); menu.hidden = true; menu.setAttribute('role', 'menu'); + const picker = el('input'); picker.type = 'file'; picker.accept = 'image/*'; picker.multiple = true; picker.hidden = true; + root.append(add, chips, menu, picker); + let shown = false, busy = false, working = false, opener = null, disposed = false, entries = []; + const active = () => source.rows().map((row, index) => ({row, index})).filter(({row}) => row.enabled !== false); + const isImage = file => IMAGE.test(file || ''); + // On hover without a full stop; in the notice line with one. + const waitText = () => t('Available after generation finishes', '生成完成后可改'); + const fullText = () => t('Up to 32 images', '最多 32\u00a0张'); + const sentence = text => text + t('.', '。'); + const label = role => role === 'first' ? t('First frame', '首帧') : role === 'last' ? t('Last frame', '尾帧') : t('Reference', '参考'); + function close(focus = true) { + menu.hidden = true; add.setAttribute('aria-expanded', 'false'); + const back = opener; opener = null; + if (focus && back?.isConnected) back.focus(); + } + // After a choice the menu is gone; a chip that was re-drawn by the change is found again by position so keyboard focus stays in the row. + function choose(run) { + const back = opener, at = Array.from(chips.children).indexOf(back); + close(false); run(); + const target = back?.isConnected ? back : back === add ? add : chips.children[at] || add; + if (target.isConnected && !target.disabled) target.focus(); + } + function open(anchor, items) { + menu.replaceChildren(...items); menu.hidden = false; opener = anchor; + add.setAttribute('aria-expanded', String(anchor === add)); + // Under the clicked thumbnail, pulled back inside the bar when that is near its right edge. + const bar = (root.offsetParent || root.parentNode)?.getBoundingClientRect?.(), at = anchor.getBoundingClientRect?.(); + if (bar && at) menu.style.left = Math.min(Math.max(0, at.left - bar.left), Math.max(0, bar.width - Math.min(menu.offsetWidth || 260, 260) - 4)) + 'px'; + else menu.style.left = Math.max(0, anchor.offsetLeft || 0) + 'px'; + entries[0]?.focus(); + } + // Every button of the open menu, in order, for the arrow keys. + const item = (text, run, role = 'menuitem') => { + const b = el('button', null, text); b.type = 'button'; b.setAttribute('role', role); + b.onclick = () => choose(run); entries.push(b); return b; + }; + const thumb = (file, cls) => { + const holder = el('span', cls); + if (isImage(file)) { const image = el('img'); image.alt = ''; image.decoding = 'async'; image.src = source.url(file); image.onerror = () => { image.hidden = true; }; holder.append(image); } + else holder.textContent = /\.(wav|mp3|flac|ogg|m4a|aac|opus)$/i.test(file) ? t('Audio', '音频') : t('Video', '视频'); + return holder; + }; + const write = rows => { try { source.write(rows); } catch (value) { error(value); } }; + async function addFiles(files) { + if (working) return; + working = true; update(); + try { + for (const file of files) { + if (active().length >= MAX) break; + const result = await source.upload(file); + write([...source.rows(), {file: result.file, role: 'reference', enabled: true}]); + } + } catch (value) { error(value); } + finally { working = false; picker.value = ''; update(); } + } + async function generate() { + if (working) return; + working = true; update(); + try { await source.newImage({target: size(), t}); } catch (value) { error(value); } + finally { working = false; update(); } + } + function addMenu() { + entries = []; + const rows = source.rows(); + const items = [item(t('Upload image', '上传图片'), () => picker.click())]; + if (source.canGenerateImage?.()) items.push(item(t('New image', '生成一张'), generate)); + // Pictures sent to the creative panel earlier and not used now. + const spare = rows.map((row, index) => ({row, index})).filter(({row}) => row.enabled === false && isImage(row.file)); + if (spare.length) { + const separator = el('div'); separator.setAttribute('role', 'separator'); + const grid = el('div', 'fv-ed-ref-spare'); + for (const {row, index} of spare) { + const b = el('button'); b.type = 'button'; b.setAttribute('role', 'menuitem'); + b.title = (row.source || row.file).split('/').pop(); b.append(thumb(row.file, 'fv-ed-ref-thumb')); + b.onclick = () => choose(() => { const latest = source.rows(); if (latest[index]?.file === row.file) { latest[index] = {...latest[index], enabled: true}; write(latest); } }); + grid.append(b); entries.push(b); + } + items.push(separator, grid); + } + return items; + } + function chipMenu(entry) { + entries = []; + const roles = ['reference', 'first', 'last'].map(role => { + const b = item(label(role), () => { + const rows = source.rows(); + // One first frame and one last frame: the earlier one goes back to being a reference. + if (role !== 'reference') for (const other of rows) if (other.role === role && other.enabled !== false) other.role = 'reference'; + if (rows[entry.index]?.file === entry.row.file) rows[entry.index] = {...rows[entry.index], role}; + write(rows); + }, 'menuitemradio'); + b.setAttribute('aria-checked', String((entry.row.role || 'reference') === role)); return b; + }); + const separator = el('div'); separator.setAttribute('role', 'separator'); + return [...roles, separator, item(t('Remove', '移除'), () => { + const rows = source.rows(); + if (rows[entry.index]?.file === entry.row.file) { rows[entry.index] = {...rows[entry.index], enabled: false}; write(rows); } + })]; + } + function update(isBusy = busy, visible = shown) { + busy = isBusy; shown = visible; + root.hidden = !shown || !source.available(); + if ((root.hidden || busy) && !menu.hidden) close(false); + // The panel holds at most MAX pictures: a full list greys the plus out rather than ignoring a click. + const full = active().length >= MAX; + // While a part is being made the controls stay clickable and say why they do nothing. + add.disabled = working; + add.setAttribute('aria-disabled', String(busy || full)); + add.title = busy ? waitText() : full ? fullText() : t('Reference image', '参考图'); + chips.replaceChildren(...active().map(entry => { + const chip = el('button', 'fv-ed-ref-chip'); chip.type = 'button'; chip.disabled = working; chip.setAttribute('aria-disabled', String(busy)); + chip.dataset.role = entry.row.role || 'reference'; + chip.title = busy ? waitText() : label(entry.row.role); chip.setAttribute('aria-haspopup', 'menu'); + chip.setAttribute('aria-label', label(entry.row.role)); + chip.append(thumb(entry.row.file, 'fv-ed-ref-thumb')); + chip.onclick = () => { if (busy) { notify(sentence(waitText())); return; } if (!menu.hidden && opener === chip) close(); else open(chip, chipMenu(entry)); }; + return chip; + })); + } + add.onclick = () => { + if (busy) { notify(sentence(waitText())); return; } + if (active().length >= MAX) { notify(sentence(fullText())); return; } + if (!menu.hidden && opener === add) close(); else open(add, addMenu()); }; + picker.onchange = () => addFiles(Array.from(picker.files)); + // Keys stay in the menu: Escape closes it only, and the arrows move through it. + menu.onkeydown = event => { + if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); close(); return; } + if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { event.stopPropagation(); return; } + if (!entries.length) return; + event.preventDefault(); event.stopPropagation(); + const at = entries.indexOf(document.activeElement), step = event.key === 'ArrowDown' ? 1 : -1; + entries[event.key === 'Home' ? 0 : event.key === 'End' ? entries.length - 1 : at < 0 ? 0 : (at + step + entries.length) % entries.length].focus(); + }; + const outside = event => { if (!menu.hidden && !root.contains(event.target)) close(false); }; + document.addEventListener('pointerdown', outside, true); + const unsubscribe = source.subscribe(() => { if (!disposed) update(); }); + update(); + return {element: root, update, dispose() { disposed = true; unsubscribe(); document.removeEventListener('pointerdown', outside, true); }}; +} diff --git a/web/editor_store.js b/web/editor_store.js index eae650d..8592382 100644 --- a/web/editor_store.js +++ b/web/editor_store.js @@ -209,14 +209,19 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener pollers.add(retry); } // Optional adapter (or factory): startChain, owns, chain, chainAction, and - // optional restoreSources. Production callers provide none. + // optional restoreSources and generationAvailable/generationReady readiness + // hooks for a delayed test generator. Production callers provide none. const standIn = typeof injected === 'function' ? injected({clock, sources, request: json, mediaURL: value => mediaURL(api, value)}) : injected; const store = { sources, cache, - get generationAvailable() { return !!standIn || generationAvailable; }, + get generationAvailable() { return standIn ? standIn.generationAvailable ?? true : generationAvailable; }, + // The server said for certain it cannot generate (not just a missed answer that is asked again). + get generationGone() { return !standIn && generationGone; }, generationReady() { - if (standIn) return Promise.resolve(true); + if (standIn) return standIn.generationReady + ? Promise.resolve(standIn.generationReady(ready => { if (!disposed) onGenerationReady(ready); })) + : Promise.resolve(true); if (generationAvailable || generationGone || disposed) return Promise.resolve(generationAvailable); // Generating stays off until the list of long videos answers in its // expected shape. One bounded request at a time, shared by concurrent @@ -413,9 +418,9 @@ export function createEditorStore(api, {t = en => en, notify = () => {}, onGener return null; } if (response.status === 400) { - // The server's own sentence for a video with too many parts is shown as it is. + // The server's own sentence for a video with too many parts, or a continued part too short, is shown as it is. const body = await response.json().catch(() => null); - if (body?.code === 'chain_too_long') throw Object.assign(new Error(body.code), {code: body.code, said: [body.error, body.error_zh]}); + if (['chain_too_long', 'chain_part_too_short'].includes(body?.code)) throw Object.assign(new Error(body.code), {code: body.code, said: [body.error, body.error_zh]}); } if (!response.ok) throw errorFor(response); return (await response.json())?.chain || null; diff --git a/web/editor_text.js b/web/editor_text.js index 9ac5901..a161dca 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -145,8 +145,8 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, const file=el('input');file.type='file';file.accept='.srt';file.hidden=true; const importButton=button(t('Import subtitle file','导入字幕文件'),{onClick:()=>file.click()});importButton.classList.add('fv-ed-import-srt'); // Only a file that cannot be read says so; a refused edit (a locked track) flashes red like any other. - file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;try{if(f.size>4*1024*1024)throw new Error();text=await f.text();parseSRT(text);}catch{actions.notify?.(t('Could not read this subtitle file','读不出这个字幕文件'));return;}finally{file.value='';} - if(apply(t('Import subtitle file','导入字幕文件'),r=>importSRT(r,text))){const missing=projectMissing(state.record);if(missing.length)actions.notify?.(missingHint([...new Set(missing.flatMap(r=>r.chars))],t));}}; + file.onchange=async()=>{const f=file.files?.[0];if(!f)return;let text;const result={};try{if(f.size>4*1024*1024)throw new Error();text=await f.text();parseSRT(text,result);}catch{actions.notify?.(t('Could not read this subtitle file','读不出这个字幕文件'));return;}finally{file.value='';} + if(apply(t('Import subtitle file','导入字幕文件'),r=>importSRT(r,text))){const missing=projectMissing(state.record);if(missing.length)actions.notify?.(missingHint([...new Set(missing.flatMap(r=>r.chars))],t));if(result.truncated)actions.notify?.(t('Only the first 500 subtitles were imported.','只导入了前 500 条字幕。'));}}; // Shown only where this computer can recognise speech. const hear=button(t('Auto subtitles','自动字幕'),{onClick:toggleSpeech});hear.classList.add('fv-ed-auto-subtitles');for(const b of speech.buttons)if(!b.isConnected)speech.buttons.delete(b);speech.buttons.add(hear);speechLabel(speech.busy?speech.shown:null); host.append(importButton,hear,file); diff --git a/web/editor_text_model.js b/web/editor_text_model.js index cf42183..8055646 100644 --- a/web/editor_text_model.js +++ b/web/editor_text_model.js @@ -98,17 +98,34 @@ export function activeText(record, frame) { return record.tracks.filter(t => isTextTrack(t) && !t.hidden).sort((a,b) => (a.kind === 'text' ? -1 : 1) - (b.kind === 'text' ? -1 : 1)) .flatMap(t => t.clips.filter(c => c.start <= frame && c.start+c.frames > frame).map(c => ({...c, style: styleOf(record,c), opacity: c.text_kind === 'text' && styleOf(record,c).fade ? Math.min(1,(frame-c.start+.5)/7.2,(c.start+c.frames-frame-.5)/7.2) : 1}))); } -export function parseSRT(text) { +export function parseSRT(text, result = {}) { check(typeof text === 'string' && text.length <= 4*1024*1024, 'srt'); const blocks = text.replace(/^\ufeff/, '').replace(/\r\n?/g,'\n').trim().split(/\n\s*\n/); - check(blocks.length > 0 && blocks.length <= 500, 'srt'); let end = 0; - return blocks.map(block => { - const lines = block.split('\n'); if (/^\d+$/.test(lines[0])) lines.shift(); - const match = /^(\d{2,3}):([0-5]\d):([0-5]\d)[,.](\d{3}) --> (\d{2,3}):([0-5]\d):([0-5]\d)[,.](\d{3})$/.exec(lines.shift()); check(match && lines.length, 'srt'); - const time = i => Math.round((Number(match[i])*3600+Number(match[i+1])*60+Number(match[i+2])+Number(match[i+3])/1000)*24); - const start = time(1), stop = time(5); check(start >= end && stop > start && stop <= 1e7, 'srt'); end = stop; - return {at:start, frames:stop-start, text:lines.join('\n'), kind:'subtitle'}; - }); + const entries = []; let skipped = 0, trimmed = 0; + for (const block of blocks) { + let entry; + try { + const lines = block.split('\n'); if (/^\d+$/.test(lines[0])) lines.shift(); + const match = /^(\d{2,3}):([0-5]\d):([0-5]\d)[,.](\d{3}) --> (\d{2,3}):([0-5]\d):([0-5]\d)[,.](\d{3})$/.exec(lines.shift()); check(match && lines.length, 'srt'); + const time = i => Math.round((Number(match[i])*3600+Number(match[i+1])*60+Number(match[i+2])+Number(match[i+3])/1000)*24); + const start = time(1), stop = time(5), content = lines.join('\n'); + check(stop > start && stop <= 1e7 && Array.from(content).length <= TEXT_LIMIT && !/[\u0000-\u0008\u000b-\u001f\u007f\ud800-\udfff]/u.test(content), 'srt'); + entry = {at:start, frames:stop-start, text:content, kind:'subtitle'}; + } catch { skipped++; continue; } + // Keep readable cues in file order, shortening or removing earlier overlaps. + while (entries.length && entries.at(-1).at + entries.at(-1).frames > entry.at) { + const previous = entries.at(-1); previous.frames = entry.at - previous.at; trimmed++; + if (previous.frames > 0) break; + entries.pop(); + } + entries.push(entry); + } + if (skipped) console.warn(`FreeVideo SRT import skipped ${skipped} unreadable blocks.`); + if (trimmed) console.warn(`FreeVideo SRT import trimmed ${trimmed} overlapping subtitles.`); + check(entries.length > 0, 'srt'); + result.truncated = entries.length > 500; + if (result.truncated) console.warn('FreeVideo SRT import kept only the first 500 subtitles.'); + return entries.slice(0, 500); } // An import replaces every subtitle already there, in one edit that one undo reverses. export function importSRT(record, text) { diff --git a/web/editor_tracks.css b/web/editor_tracks.css index efd73dd..bbd6ece 100644 --- a/web/editor_tracks.css +++ b/web/editor_tracks.css @@ -35,7 +35,7 @@ .fv-ed-progress-fill{display:none;right:auto;width:calc(var(--fv-ed-progress) * 100%);background:color-mix(in srgb,var(--fv-accent) 55%,transparent)} .fv-ed-clip[data-state=running] .fv-ed-progress-fill:not([hidden]){display:block} .fv-ed-block-progress{display:none} -.fv-ed-clip[data-state=running] .fv-ed-block-progress{display:block} +.fv-ed-clip[data-state=running] .fv-ed-block-progress,.fv-ed-block-progress[data-queued=true]{display:block} .fv-ed-clip[data-state=failed]{border:1px solid color-mix(in srgb,var(--fv-warning) 60%,transparent);background:color-mix(in srgb,var(--fv-warning) 12%,transparent);cursor:grab} .fv-ed-clip-status{position:absolute;left:10px;right:4px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;overflow:hidden;color:var(--fv-warning);pointer-events:none} .fv-ed-clip-status svg{width:12px;height:12px;flex:none;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round} diff --git a/web/editor_tracks.js b/web/editor_tracks.js index c0a821c..4f8b297 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -437,7 +437,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = const value = progresses.get(item.clip.id) || {state: item.clip.pending === 'queued' ? 'waiting' : busy(item.clip) ? 'generating' : model.clipState(item.clip) === 'done' ? 'done' : item.clip.pending === 'stopped' ? 'stopped' : model.clipState(item.clip) === 'failed' ? 'failed' : 'waiting', - fraction: 0, remainingSeconds: null}; + fraction: 0, remainingSeconds: null, ...(item.clip.pending === 'queued' ? {queued: true} : {})}; item.element.style.setProperty('--fv-ed-progress', String(clamp(Number(value.fraction) || 0, 0, 1))); item.component?.update(value); } @@ -655,6 +655,9 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = for (const value of [frame, frame + length]) points.push(Math.round(value / grid) * grid); return transportSnapPoints(state, points, pointerEvent); } + // A clip's end can be dragged longer only over hidden source, by generating more, or as a planned part. + const canGrow = clip => (clip.pending && !clip.source) || isText(clip) || speedEdited(clip) || clip.in + clip.frames < clip.length + || (clip.extend && actions.generationAvailable?.() !== false); // Only a part that continues another is held to the length of a continued part; a range being drawn never is. function boundaryLimits(clip = null) { const limits = actions.generationLimits?.() || {}, maxSeconds = limits.maxSeconds ?? model.RULES.maxSeconds; @@ -714,7 +717,11 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = if (gesture.type === 'rangeMove' && gesture.rangeBlocked) tooltip.hidden = true; } else if (gesture.type === 'range') { const aligned = snap(frame, points, state.zoom); - gesture.selection = capRange(rangeParameters(gesture.anchor, aligned.frame), aligned.frame < gesture.anchor); + const drawn = rangeParameters(gesture.anchor, aligned.frame); + // Drawn from a clip's end, the range starts exactly there. + if (gesture.flush != null && aligned.frame > gesture.flush) Object.assign(drawn, {start: gesture.flush, + frames: Math.max(2 * model.FPS, Math.round((aligned.frame - gesture.flush) / model.FPS) * model.FPS)}); + gesture.selection = capRange(drawn, aligned.frame < gesture.anchor); previewSelection = gesture.selection; showGuide(aligned.guide); renderSelection(); hint(gesture.selection, event); } else if (gesture.type === 'move') { @@ -749,6 +756,13 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = selectionBox.classList.toggle('fv-ed-cannot-drop', !!error); if (error) tooltip.hidden = true; } else if (gesture.type === 'edge') { + // Past the end of a clip that cannot grow there, the drag draws the next part from that end. + const clipEnd = gesture.clip.start + gesture.clip.frames; + if (gesture.edge === 'end' && frame > clipEnd && !canGrow(gesture.clip)) { + for (const id of [...gesture.ids, ...(gesture.rightPreviewIds || [])]) { const item = locate(id); if (item) position(item.element, item.clip.start, item.clip.frames); } + Object.assign(gesture, {type: 'range', anchor: clipEnd, flush: clipEnd, id: undefined, ids: [], clip: null, edge: null, rightPreviewIds: []}); + cachedPoints = allPoints([]); tooltip.hidden = true; updatePointer(event); return; + } let result = edgeParameters(gesture.clip, gesture.edge, frame, points, state.zoom, {canExtend: actions.generationAvailable?.() !== false}); if (result.kind === 'extend') result.frames = capRange({start: result.frame, frames: result.frames}, false, true).frames; if (result.kind === 'planned') { @@ -959,7 +973,18 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = } return added; } + const droppingFiles = event => actions.importFiles && !Array.from(event.dataTransfer?.types || []).includes(SOURCE_TYPE) + && Array.from(event.dataTransfer?.types || []).includes('Files'); function dropOver(event) { + if (droppingFiles(event)) { + // Files from the computer: only the place is known until they are imported. + event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; + const frame = snap(frameAt(event), transportSnapPoints(state, allPoints([]), event), state.zoom); + const track = state.record.tracks.find(row => row.id === trackAt(event)); + showInsertion(insertionPoint(track?.clips || [], frame.frame).frame); insertion.classList.remove('fv-ed-blocked-insertion'); + showGuide(frame.guide); tooltip.hidden = true; + return; + } if (!Array.from(event.dataTransfer?.types || []).includes(SOURCE_TYPE)) return; event.preventDefault(); event.stopPropagation(); event.dataTransfer.dropEffect = 'copy'; const frame = snap(frameAt(event), transportSnapPoints(state, allPoints([]), event), state.zoom); @@ -995,6 +1020,13 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = } function dropSource(event) { clearSourcePreview(); + if (droppingFiles(event) && event.dataTransfer.files?.length) { + event.preventDefault(); event.stopPropagation(); + const at = snap(frameAt(event), transportSnapPoints(state, allPoints([]), event), state.zoom).frame; + actions.importFiles([...event.dataTransfer.files], {at}); + showInsertion(null); showGuide(null); dropPoint = null; dropPreviewKey = ''; + return; + } const payload = sourcePayload(event.dataTransfer?.getData(SOURCE_TYPE)); if (!payload) { showInsertion(null); showGuide(null); return; } event.preventDefault(); event.stopPropagation(); diff --git a/web/freevideo.js b/web/freevideo.js index 26bb1f4..28c1d75 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -14,6 +14,7 @@ import { outputDownloadURL } from './output_download.js'; import { shareButton } from './share.js'; import { reportFileName } from './report_issue.js'; import { rememberPromptDraft, savePromptDraft } from './prompt_draft.js'; +import { cleanOptionalWidgets, cleanOptionalPrompt } from './optional_inputs.js'; import { regenerateResult, upscaleResult } from './studio_queue.js'; import { openImageEditor, icon } from './image_editor.js'; import { imageGenerationSupported } from './image_generation.js'; @@ -322,17 +323,20 @@ function mediaPanel(node, mount = null) { let uploading = false; serialized.serializeValue = () => { if (node.isUploading) throw new Error(text("Wait for media uploads to finish before running", "请等待素材上传完成后再生成")); - return serialized.value; + // The run takes only what it uses; the editing data of a picture stays in the workflow. + try { return JSON.stringify(read().map(({file, role, enabled}) => ({file, role, enabled}))); } catch { return serialized.value; } }; async function upload(files) { if (node.isUploading || !files.length) return; uploading = true; node.isUploading = true; add.disabled = true; addAudio.disabled = true; try { for (const [index, file] of Array.from(files).entries()) { - const rows = read(); if (rows.length >= 32) throw new Error(text("Use at most 32 media items", "最多使用 32 个素材")); + if (read().length >= 32) throw new Error(text("Use at most 32 media items", "最多使用 32 个素材")); const kind = mediaKind(file.name); message(`${text("Uploading", "正在上传")} ${index + 1}/${files.length} · ${file.name}`); const result = await uploadOne(file); + // Read again after the wait: another place may have changed the list meanwhile. + const rows = read(); if (rows.length >= 32) throw new Error(text("Use at most 32 media items", "最多使用 32 个素材")); rows.push({file: result.file, role: kind === "image" ? role.value : "reference", enabled: true}); update(rows); } } catch (error) { message(error.message, true); } @@ -455,6 +459,7 @@ function resultPanel(node) { // before the public version added sampling-step widgets. const base = this.widgets?.find(w => w.name === 'base_steps'); if (base && typeof base.value === 'boolean') base.value = 8; + cleanOptionalWidgets(this); // Workflows saved with the earlier 8 + 2 default move to the 8 + 3 default once. if (!args[0]?.properties?.freevideo_refine_v3) { const refine = this.widgets?.find(w => w.name === 'refine_steps'); @@ -546,13 +551,13 @@ function resultPanel(node) { node.freevideoUpscaleResult = async value => { if (node.freevideoUpscaling) return; node.freevideoUpscaling = true; - try { return await upscaleResult(api, node.id, value, () => app.graphToPrompt()); } + try { return await upscaleResult(api, node.id, value, async () => cleanOptionalPrompt(await app.graphToPrompt())); } finally { node.freevideoUpscaling = false; } }; node.freevideoRegenerateResult = async value => { if (node.freevideoRegenerating) return; node.freevideoRegenerating = true; - try { return await regenerateResult(api, node.id, value, () => app.graphToPrompt()); } + try { return await regenerateResult(api, node.id, value, async () => cleanOptionalPrompt(await app.graphToPrompt())); } finally { node.freevideoRegenerating = false; } }; const executed = node.onExecuted; diff --git a/web/health.js b/web/health.js index 3451f84..536d6e4 100644 --- a/web/health.js +++ b/web/health.js @@ -14,10 +14,10 @@ const t = (en, zh) => cn ? zh : en; 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', 'chain_support.js', 'capability_view.js', 'compatibility.js', 'error_panel.js', 'first_run_guide.js', 'fonts.js', 'freevideo.js', 'generation_progress.js', - 'editor.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.js', 'editor_export_dialog.js', 'editor_first_frame.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.js', 'editor_actions.js', 'editor_adjust.js', 'editor_adjust_picture.js', 'editor_picture_fields.js', 'editor_picture_session.js', 'editor_preview_picture.js', 'editor_entry.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_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', 'progress_connection.js', 'prompt_draft.js', 'prompt_enhance.js', 'prompt_references.js', 'report_issue.js', 'studio_recent.js', - 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'studio_queue.js', 'studio_size.js', + 'result_actions.js', 'sampling_effort.js', 'setup.js', 'share.js', 'share_layout.js', 'studio.js', 'studio_chain.js', 'studio_extend.js', 'studio_length.js', 'first_frame_row.js', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js']; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); let visibleAt = 0; diff --git a/web/image_editor.js b/web/image_editor.js index 0881ec0..e4592cb 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -117,7 +117,7 @@ function loadImage(src) { const image = new Image(); image.onload = () => resolve(image); image.onerror = () => reject(new Error('load')); image.src = src; }); } -const strip = d => JSON.stringify({...d, active: null, layers: d.layers.map(l => ({...l, name: ''}))}); +const strip = d => JSON.stringify({...d, active: null}); /** * Open the editor over everything else. @@ -139,13 +139,13 @@ export async function openImageEditor({source = null, name = source || '', targe const d = migrate(null, source, baseImage); d.layers[0].name = t('Original', '原图'); return d; }; let doc = edit ? migrate(edit, source, baseImage) : original(); - doc = {...doc, layers: doc.layers.map(l => l.id === 'base' ? {...l, name: t('Original', '原图')} : l)}; + doc = {...doc, layers: doc.layers.map(l => l.id === 'base' && (!l.name || ['Original', '原图'].includes(l.name)) ? {...l, name: t('Original', '原图')} : l)}; if (fresh && hasOriginal && target && role !== 'reference') { // A keyframe becomes the first or last frame of the video: start from its shape. doc = commands.setRatio(doc, 'video', target); } // Text draws with the fonts that ship with FreeVideo; the text tool starts in bold sans. - await Promise.all([renderer.preload(doc), loadFont('sans', 700)]); + await Promise.all([renderer.preload(doc), loadFont('sans', 700).catch(error => console.warn('FreeVideo image editor font unavailable; using fallback fonts.', error))]); // A text box measured with other fonts (or by another renderer) fits its text again, // keeping its place and scale. It is part of opening, not a change to undo or save. doc = {...doc, layers: doc.layers.map(l => { @@ -156,6 +156,7 @@ export async function openImageEditor({source = null, name = source || '', targe return {...l, ...size, w: size.nw * sx, h: size.nh * sy}; })}; const initial = doc; + const savedState = strip(initial); let history = [doc], future = []; let selection = null; // {parts, feather, invert}; not part of the saved document let maskTarget = false; // painting goes to the active layer's mask @@ -297,7 +298,10 @@ export async function openImageEditor({source = null, name = source || '', targe // A step being dragged: drawn, not yet in the history. function preview(next) { doc = next; refresh(false); } function step(direction) { - if (direction < 0 ? history.length <= 1 : !future.length) return; + if (direction < 0 ? history.length <= 1 : !future.length) { + message(direction < 0 ? t('Nothing to undo.', '没有可撤销的步骤。') : t('Nothing to redo.', '没有可重做的步骤。')); + return; + } const next = (direction < 0 ? commands.undoStep : commands.redoStep)({history, future, doc, selection}); ({history, future, doc, selection} = next); const {sx, sy} = next.scale; @@ -309,8 +313,21 @@ export async function openImageEditor({source = null, name = source || '', targe if (!doc.layers.length && tool !== 'hand') tool = 'generate'; refresh(true); } - const changed = () => history.length > 1 && strip(doc) !== strip(initial); + // Compare with the saved document, even after its step leaves the bounded + // undo history. Active layer choice is not an edit; names and previews are. + const changed = () => strip(doc) !== savedState; + let unloadWarning = false; + function beforeUnload(event) { + event.preventDefault(); event.returnValue = ''; + } + function syncUnloadWarning() { + const dirty = changed(); + if (dirty === unloadWarning) return; + unloadWarning = dirty; + window[dirty ? 'addEventListener' : 'removeEventListener']('beforeunload', beforeUnload); + } async function refresh(panels) { + syncUnloadWarning(); if (doc.layers.some(l => l.src && !renderer.loaded(l.src))) await renderer.preload(doc); computeLayout(); draw(); controls(); if (panels === 'layers') renderLayers(); @@ -473,13 +490,14 @@ export async function openImageEditor({source = null, name = source || '', targe stage.dataset.tool = tool; } function select(id) { - if (!doc.layers.length && !['generate', 'hand'].includes(id)) return; + if (!doc.layers.length && !['generate', 'hand'].includes(id)) { message(emptyCanvasReason()); return; } if (id === 'generate' && !imageGenerationSupported()) return; commitText(); gesture?.cancel?.(); gesture = null; tool = id; renderOptions(); controls(); draw(); showHint(); keepFocus(); if (id === 'generate') void generation.selected(); } + function emptyCanvasReason() { return t('The canvas is empty. Add or generate an image first.', '画布是空的,请先添加或生成一张图片。'); } function showHint() { const layer = active(); if (!layer) { hint.textContent = ''; hint.hidden = true; return; } @@ -667,7 +685,7 @@ export async function openImageEditor({source = null, name = source || '', targe v => setTextFont(layer.id, {font: v}), t('Font', '字体')); font.group.id = 'fv-ie-font'; font.group.classList.add('fv-ie-fonts'); for (const b of font.group.children) b.style.fontFamily = fontStack(b.dataset.value); - loadFonts(FONT_KEYS.map(k => [k, 400])).catch(() => {}); + loadFonts(FONT_KEYS.map(k => [k, 400])).catch(error => console.warn('FreeVideo image editor fonts unavailable; using fallback fonts.', error)); const size = el('input'); size.type = 'number'; size.min = '6'; size.max = '2000'; size.value = String(Math.round(layer.text.size)); size.id = 'fv-ie-font-size'; size.onchange = () => commit(updateText(layer.id, {size: clamp(Number(size.value) || 12, 6, 2000)})); const color = el('input'); color.type = 'color'; color.value = layer.text.color; color.id = 'fv-ie-text-color'; @@ -697,7 +715,7 @@ export async function openImageEditor({source = null, name = source || '', targe if (!fontLoaded(next.font, next.weight)) { message(t('Preparing the font…', '正在准备字体…')); try { await loadFont(next.font, next.weight); } - catch { message(t('The font could not be loaded. Reload the page and try again.', '字体加载失败,请刷新页面后重试。')); renderProps(); return; } + catch (error) { console.warn('FreeVideo image editor font unavailable; using fallback fonts.', error); } message(''); } if (dialog.open && doc.layers.some(l => l.id === id)) commit(updateText(id, patch)); @@ -728,8 +746,7 @@ export async function openImageEditor({source = null, name = source || '', targe } return box; } - function renderLayers() { - clearPanel(layersBox); + function renderLayerHead() { const layer = active(), index = doc.layers.findIndex(l => l.id === layer?.id); const head = el('div', null, 'fv-ie-section-head'); head.append(el('h3', t('Layers', '图层'))); @@ -756,16 +773,24 @@ export async function openImageEditor({source = null, name = source || '', targe tools.append(iconButton('plus', t('New paint layer', '新建绘画图层'), () => addPaintLayer()), addMenu, maskButton, copy, up, down, del); head.append(tools); + return head; + } + function renderLayers() { + clearPanel(layersBox); + let head = renderLayerHead(); const list = el('ol', null, 'fv-ie-layer-list'); list.setAttribute('aria-label', t('Layers', '图层')); for (const l of [...doc.layers].reverse()) { const row = el('li', null, 'fv-ie-layer'); row.dataset.id = l.id; + // Flush a focused text preview before its blur handler can replace + // the clicked name, so double-click also works just after typing. + row.onpointerdown = () => commitText(false); row.setAttribute('aria-selected', String(l.id === doc.active)); const eye = iconButton(l.visible ? 'eye' : 'eyeOff', l.visible ? t('Hide layer', '隐藏图层') : t('Show layer', '显示图层'), e => { e.stopPropagation(); commit(commands.setLayer(doc, l.id, {visible: !l.visible})); }, 'fv-ie-eye'); const thumb = el('canvas', null, 'fv-ie-thumb'); thumb.width = 48; thumb.height = 34; drawThumb(thumb, l); const name = el('span', l.name || t('Layer', '图层'), 'fv-ie-layer-name'); name.title = t('Double-click to rename', '双击重命名'); - name.ondblclick = e => { e.stopPropagation(); renameLayer(l, name); }; + name.ondblclick = e => { e.stopPropagation(); renameLayer(doc.layers.find(layer => layer.id === l.id), name); }; row.append(eye, thumb); if (l.mask) { const m = el('canvas', null, 'fv-ie-thumb fv-ie-mask-thumb'); m.width = 48; m.height = 34; drawMaskThumb(m, l); @@ -777,7 +802,19 @@ export async function openImageEditor({source = null, name = source || '', targe row.append(name); if (l.locked) { const lk = el('span', null, 'fv-ie-lock'); lk.innerHTML = SVG(ICONS.lock, 14); lk.title = t('Locked', '已锁定'); row.append(lk); } else if (l.opacity < 1) row.append(el('span', Math.round(l.opacity * 100) + '%', 'fv-ie-layer-opacity')); - row.onclick = () => { setActive(l.id); maskTarget = false; renderPanels(); renderOptions(); }; + row.onclick = () => { + setActive(l.id); maskTarget = false; + // Preserve the name node across the first click of a physical + // double-click, including when it selects a different layer. + for (const item of list.children) { + item.setAttribute('aria-selected', String(item.dataset.id === doc.active)); + drawThumb(item.querySelector('.fv-ie-thumb'), doc.layers.find(layer => layer.id === item.dataset.id)); + item.querySelector('.fv-ie-mask-thumb')?.classList.remove('fv-ie-target'); + item.querySelector('.fv-ie-thumb')?.classList.toggle('fv-ie-target', item.dataset.id === doc.active && !!l.mask); + } + const nextHead = renderLayerHead(); head.replaceWith(nextHead); head = nextHead; + renderProps(); renderOptions(); + }; list.append(row); } layersBox.append(head, list); @@ -808,7 +845,19 @@ export async function openImageEditor({source = null, name = source || '', targe const input = el('input', null, 'fv-ie-rename'); input.value = layer.name; input.setAttribute('aria-label', t('Layer name', '图层名称')); node.replaceWith(input); input.focus(); input.select(); let done = false; - const finishRename = keep => { if (done) return; done = true; if (keep && input.value.trim() && input.value.trim() !== layer.name) commit(commands.renameLayer(doc, layer.id, input.value.trim())); else renderLayers(); }; + const finishRename = keep => { + if (done) return; done = true; + if (keep && !input.value.trim()) message(t('The name was empty, so it was not changed.', '名称为空,已保留原名。')); + const name = keep && input.value.trim() ? input.value.trim() : layer.name; + // Blur may come from clicking another layer. Keep that clicked row + // in place too, so its single-click still selects it. + if (name !== layer.name) commit(commands.renameLayer(doc, layer.id, name), false); + node.textContent = name || t('Layer', '图层'); input.replaceWith(node); + if (doc.active === layer.id) { + const label = props.querySelector('.fv-ie-props-name'); if (label) label.textContent = name; + } + keepFocus(); + }; input.onkeydown = e => { e.stopPropagation(); if (e.key === 'Enter') finishRename(true); if (e.key === 'Escape') { e.preventDefault(); finishRename(false); } }; input.onblur = () => finishRename(true); input.onclick = e => e.stopPropagation(); @@ -880,19 +929,37 @@ export async function openImageEditor({source = null, name = source || '', targe const [ow, oh] = orientedSize(doc), e = doc.extend, inputs = {}; const grid = el('div', null, 'fv-ie-pad-grid'); for (const [key, text] of [['t', t('Top', '上')], ['b', t('Bottom', '下')], ['l', t('Left', '左')], ['r', t('Right', '右')]]) { - const input = el('input'); input.type = 'number'; input.min = '0'; input.max = '20000'; input.value = String(Math.round(e[key])); input.id = `fv-ie-pad-${key}`; + const input = el('input'); input.type = 'number'; input.min = '-20000'; input.max = '20000'; input.step = '1'; input.value = String(Math.round(e[key])); input.id = `fv-ie-pad-${key}`; grid.append(field(text + ' (px)', input)); inputs[key] = input; } const color = el('input'); color.type = 'color'; color.value = e.color || bg; color.id = 'fv-ie-pad-color'; const buttons = el('div', null, 'fv-ie-row fv-ie-end'); - buttons.append(button(t('Cancel', '取消'), null, () => box.remove()), button(t('Apply', '应用'), null, () => { - const extend = {l: +inputs.l.value || 0, t: +inputs.t.value || 0, r: +inputs.r.value || 0, b: +inputs.b.value || 0, color: color.value}; - const next = commands.setCanvas(doc, extend); box.remove(); - commit(next); - }, 'fv-ie-button fv-ie-primary')); - box.append(el('h3', t('Canvas size', '画布大小')), el('p', t(`The picture is ${ow} × ${oh} px. Add space on each side:`, `画面 ${ow} × ${oh} 像素,在四周补充空间:`), 'fv-ie-opt-note'), + const advice = t(`The picture is ${ow} × ${oh} px. Add space on each side:`, `画面 ${ow} × ${oh} 像素,在四周补充空间:`); + const reason = t('Use whole numbers. The new width and height must be 1 to 20000 px.', '请填整数,调整后的宽和高须在 1 到 20000 像素之间。'); + const note = el('p', advice, 'fv-ie-opt-note'); + const values = () => Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.value.trim() ? Number(input.value) : NaN])); + function update() { + const v = values(), w = ow + v.l + v.r, h = oh + v.t + v.b; + const valid = Object.values(v).every(n => Number.isInteger(n) && n >= -20000 && n <= 20000) + && w >= 1 && w <= 20000 && h >= 1 && h <= 20000; + ok.disabled = !valid; + note.textContent = valid ? advice : reason; note.classList.toggle('fv-ie-note-warn', !valid); + return valid; + } + function apply() { + if (!update()) { message(reason); return; } + const next = commands.setCanvas(doc, {...values(), color: color.value}); + box.remove(); stage.focus(); commit(next); + } + const ok = button(t('Apply', '应用'), null, apply, 'fv-ie-button fv-ie-primary'); + buttons.append(button(t('Cancel', '取消'), null, () => { box.remove(); stage.focus(); }), ok); + for (const input of Object.values(inputs)) input.oninput = update; + box.addEventListener('keydown', event => { + if (event.key === 'Enter' && !event.ctrlKey && !event.metaKey && event.target.tagName === 'INPUT') { event.preventDefault(); event.stopPropagation(); apply(); } + }); + box.append(el('h3', t('Canvas size', '画布大小')), note, grid, field(t('Fill color', '补边颜色'), color), buttons); - stage.append(box); inputs.t.focus(); + update(); stage.append(box); inputs.t.focus(); } // A selection and the clone source are kept in document pixels: when a step changes the @@ -982,7 +1049,8 @@ export async function openImageEditor({source = null, name = source || '', targe ok.disabled = !allowed; } function apply() { - if (ok.disabled) return; + update(); + if (ok.disabled) { message(result.textContent); return; } const next = commands.resizeImage(doc, number(width), number(height), keep || same, measureText); followSize(doc, next); box.remove(); stage.focus(); @@ -1032,6 +1100,7 @@ export async function openImageEditor({source = null, name = source || '', targe if (layer) commit(pixelCommand(doc, layer.id, d => commands.fillSelection(d, layer.id, color, selection))); } function clearSelection() { + if (!selection) { message(t('Nothing is selected.', '还没有选区。')); return; } const layer = pixelLayer(); if (layer && selection) commit(pixelCommand(doc, layer.id, d => commands.clearSelection(d, layer.id, selection))); } @@ -1117,14 +1186,14 @@ export async function openImageEditor({source = null, name = source || '', targe if (tool === 'wand') { const src = rasterKinds.has(layer.kind) ? layer : doc.layers[0]; const [u, v] = toLocal(src, d); - if (u < 0 || v < 0 || u > 1 || v > 1) return null; + if (u < 0 || v < 0 || u > 1 || v > 1) { message(t('Click on an image layer.', '请点在图片图层上。')); return null; } addPart({shape: 'wand', layer: src.id, u, v, tolerance: opts.wand.tolerance, contiguous: opts.wand.contiguous, upto: src.ops.length}, mode || opts.wand.mode); return null; } if (tool === 'eyedropper') { const {canvas: pic, scale} = picture(), r = rect(); const x = Math.floor((o[0] - r.x) * scale), y = Math.floor((o[1] - r.y) * scale); - if (x < 0 || y < 0 || x >= pic.width || y >= pic.height) return null; + if (x < 0 || y < 0 || x >= pic.width || y >= pic.height) { message(t('Click on the canvas to pick a color.', '请点在画布上取色。')); return null; } const px = pic.getContext('2d', {willReadFrequently: true}).getImageData(x, y, 1, 1).data; if (px[3] < 16) { message(t('Nothing to pick here: this part is transparent.', '这里是透明的,没有可吸取的颜色。')); return null; } const hex = '#' + [px[0], px[1], px[2]].map(v => v.toString(16).padStart(2, '0')).join(''); @@ -1261,7 +1330,7 @@ export async function openImageEditor({source = null, name = source || '', targe const kind = opts.shape.type; return {...dragBox(o, (a, b) => { const width = opts.shape.width, dx = b[0] - a[0], dy = b[1] - a[1]; - if (Math.hypot(dx, dy) * layout.k < 4) return; + if (Math.hypot(dx, dy) * layout.k < 4) { message(t('Drag to draw a shape.', '请拖动画出形状。')); return; } const shape = {type: kind, fill: kind === 'line' ? null : fg, stroke: kind === 'line' ? fg : null, width, radius: 0}; commitInserted(commands.addShapeLayer(doc, {from: a, to: b, shape, name: {rect: t('Rectangle', '矩形'), ellipse: t('Ellipse', '椭圆'), line: t('Line', '直线')}[kind]})); @@ -1316,8 +1385,9 @@ export async function openImageEditor({source = null, name = source || '', targe const hit = hitLayer(p); if (hit && hit.id !== layer.id) { setActive(hit.id); maskTarget = false; renderPanels(); layer = hit; } } - if (layer.locked) { showHint(); return null; } + if (layer.locked && !(layer.id === 'base' && doc.layers.length === 1)) { transformBase(); return null; } const l0 = {...layer}; + let copied = false; const frame = new DOMMatrix().translateSelf(l0.cx, l0.cy).rotateSelf(l0.rotation); const local = q => { const r = frame.inverse().transformPoint(new DOMPoint(q[0], q[1])); return [r.x, r.y]; }; // A handle names a corner or edge of the box as drawn; find its side in the layer's own axes. @@ -1349,6 +1419,12 @@ export async function openImageEditor({source = null, name = source || '', targe const c = frame.transformPoint(new DOMPoint(sx ? sx * (w - l0.w) / 2 : 0, sy ? sy * (h - l0.h) / 2 : 0)); patch = {w, h, cx: c.x, cy: c.y}; } + // A click alone does not create a copy. Copy and move form one + // undo step, and cancellation goes back to the locked original. + if (layer.locked && !copied) { + if (Object.entries(patch).every(([key, value]) => value === l0[key])) return; + doc = transformBase(); layer = active(); copied = true; maskTarget = false; + } preview(replaceLayer(layer.id, patch)); }, up() { if (doc !== history[history.length - 1]) commit(doc); }, @@ -1356,8 +1432,20 @@ export async function openImageEditor({source = null, name = source || '', targe }; } function transformLayer(fn) { - const layer = active(); if (layer.locked) { showHint(); return; } - commit(replaceLayer(layer.id, fn(layer))); + const base = transformBase(); if (!base) return; + const layer = base.layers.find(l => l.id === base.active); + maskTarget = false; + commit({...base, layers: base.layers.map(l => l.id === layer.id ? {...l, ...fn(layer)} : l)}); + } + function transformBase() { + const layer = active(); if (!layer) return null; + if (!layer.locked) return doc; + if (layer.id !== 'base' || doc.layers.length !== 1) { + message(t('This layer is locked. Choose another layer, or duplicate it first.', '这一层已锁定,请选择其他图层,或复制后再改。')); + return null; + } + message(t('Copied the original. Changes go on the copy.', '已复制原图,改动在副本上。')); + return commands.duplicateLayer(doc, layer.id, t(`${layer.name} copy`, `${layer.name} 副本`)); } function cover(layer) { const r = rect(), centre = toD([r.x + r.w / 2, r.y + r.h / 2]); @@ -1461,7 +1549,7 @@ export async function openImageEditor({source = null, name = source || '', targe close(null); } async function finish() { - if (!doc.layers.length) return; + if (!doc.layers.length) { message(emptyCanvasReason()); return; } void generation.cancel(); if (healing) await healing; commitText(); @@ -1477,6 +1565,7 @@ export async function openImageEditor({source = null, name = source || '', targe } } function close(value) { + window.removeEventListener('beforeunload', beforeUnload); unloadWarning = false; void generation.dispose(); clearInterval(antsTimer); clearTimeout(messageTimer); observer.disconnect(); @@ -1507,6 +1596,6 @@ export async function openImageEditor({source = null, name = source || '', targe select(!doc.layers.length ? 'generate' : startTool || (fresh && target && role !== 'reference' ? 'crop' : 'move')); (save.disabled ? stage : save).focus(); // The other fonts load in the background, so switching to one is immediate. - loadFonts(FONT_KEYS.flatMap(k => [[k, 400], [k, 700]])).catch(() => {}); + loadFonts(FONT_KEYS.flatMap(k => [[k, 400], [k, 700]])).catch(error => console.warn('FreeVideo image editor fonts unavailable; using fallback fonts.', error)); return result; } diff --git a/web/image_editor_engine.js b/web/image_editor_engine.js index 4830d15..896451c 100644 --- a/web/image_editor_engine.js +++ b/web/image_editor_engine.js @@ -10,7 +10,12 @@ // Every edit is an entry in a list, so a saved document reopens from the // original image with each step still in place. -import {fontCSS, fontLoaded, loadFonts} from './fonts.js'; +import {fontCSS as bundledFontCSS, fontLoaded, loadFonts} from './fonts.js'; +// A blocked font must not turn otherwise readable text into the bundled box glyph. +const fontCSS = (name, weight, size) => { + const css = bundledFontCSS(name, weight, size); + return fontLoaded(name, weight) ? css : css.replace('"FreeVideo Box"', 'sans-serif'); +}; export const BLENDS = ['source-over', 'multiply', 'screen', 'overlay', 'soft-light', 'darken', 'lighten', 'color-dodge', 'color-burn', 'difference']; @@ -117,8 +122,7 @@ export const rasterKinds = new Set(['image', 'paint']); export const textFonts = doc => doc.layers.filter(l => l.kind === 'text').map(l => [l.text.font, l.text.weight]); export const textFontsLoaded = doc => textFonts(doc).every(([name, weight]) => fontLoaded(name, weight)); -// Native size of a text block, from the same font settings it is drawn with. The font must -// be loaded (textFonts): measuring before that would use another font's metrics. +// Native size of a text block, using the same bundled or fallback font as drawing. export function measureText(text) { const ctx = document.createElement('canvas').getContext('2d'); ctx.font = fontCSS(text.font, text.weight, text.size); @@ -970,7 +974,7 @@ export class Renderer { loaded(file) { return this.images.get(file)?.value; } async preload(doc) { for (const l of doc.layers) if (l.src) { const img = await this.image(l.src); this.images.get(l.src).value = img; } - await loadFonts(textFonts(doc)); + await loadFonts(textFonts(doc)).catch(error => console.warn('FreeVideo image editor fonts unavailable; using fallback fonts.', error)); } // Raster of a layer at scale × its native pixels, edits applied (before // adjustments and mask), reusing the cached prefix of its edits. exact: for a saved @@ -1120,7 +1124,7 @@ export class Renderer { return out; } text(layer, scale) { - const key = `${layer.id}@${scale}#text`, look = JSON.stringify([layer.text, layer.nw, layer.nh]); + const key = `${layer.id}@${scale}#text`, look = JSON.stringify([layer.text, layer.nw, layer.nh, fontCSS(layer.text.font, layer.text.weight, layer.text.size)]); const cached = this.layers.get(key); if (cached?.look === look) return cached.canvas; const t = layer.text, c = canvas(layer.nw * scale, layer.nh * scale), ctx = c.getContext('2d'); diff --git a/web/optional_inputs.js b/web/optional_inputs.js new file mode 100644 index 0000000..1ce5dcf --- /dev/null +++ b/web/optional_inputs.js @@ -0,0 +1,30 @@ +// The optional advanced inputs were appended after the first public workflow +// shape. A workflow restored from an older version, or a widget list that moved, +// can leave an empty or missing value in them, and Comfy then rejects the whole +// request. Keep each of them to a value the server accepts. +const DEFAULTS = {continue_from: '', frames: 0, first_frame_reference: false}; + +export function optionalInputValue(name, value) { + if (name === 'frames') return Number.isSafeInteger(value) && value >= 0 ? value : 0; + if (name === 'continue_from') return typeof value === 'string' ? value : ''; + if (name === 'first_frame_reference') return value === true; + return value; +} + +export function cleanOptionalWidgets(node) { + for (const widget of node?.widgets || []) { + if (!(widget.name in DEFAULTS)) continue; + const value = optionalInputValue(widget.name, widget.value); + if (value !== widget.value) widget.value = value; + } +} + +export function cleanOptionalPrompt(prompt) { + for (const entry of Object.values(prompt?.output || {})) { + if (entry?.class_type !== 'FreeVideoGenerate' || !entry.inputs) continue; + for (const name of Object.keys(DEFAULTS)) { + if (name in entry.inputs) entry.inputs[name] = optionalInputValue(name, entry.inputs[name]); + } + } + return prompt; +} diff --git a/web/prompt_enhance.js b/web/prompt_enhance.js index 49d9cb3..8cd3c68 100644 --- a/web/prompt_enhance.js +++ b/web/prompt_enhance.js @@ -257,15 +257,28 @@ export function createPromptEnhancer({node, input, editor, api, context, setText }; redo.onclick = () => enhance().catch(() => {}); show(''); save(false); render(); + function useOriginal(error) { + console.warn('FreeVideo prompt rewrite failed; using the original prompt.', error?.message); + versions.value.selected = 'original'; apply(); + show(t('Could not enhance; generated with the original.', '没能改写,已用原文生成。'), 'note'); + } return {element, async beforeGenerate() { if (running) { try { await running; } - catch (error) { if (versions.value.enabled && !versions.value.useOriginal) return false; } + catch (error) { + if (versions.value.enabled && !versions.value.useOriginal) { + if (error?.message === 'cancelled' || cancelling || disposed) return false; + useOriginal(error); return; + } + } } // An untouched box generates the starter prompt as it is. if (!versions.value.enabled || versions.value.useOriginal || !versions.value.original.trim()) return; try { if (!versions.fresh(context())) await enhance(); } - catch (error) { show(errorText(error), error?.message === 'cancelled' ? '' : 'error'); return false; } + catch (error) { + if (error?.message === 'cancelled' || cancelling || disposed) { show(errorText({message: 'cancelled'})); return false; } + useOriginal(error); + } }, sync() { if (input.value !== versions.text()) changed(); }, dispose() { disposed = true; prestart.dispose(); cancelJob().catch(() => {}); input.removeEventListener('input', changed); input.removeEventListener('beforeinput', replace); diff --git a/web/setup.css b/web/setup.css index a7b2f07..2a4a71d 100644 --- a/web/setup.css +++ b/web/setup.css @@ -65,6 +65,7 @@ /* Text, plans and measurements. */ .fv-setup p{margin:8px 0} +.fv-setup [role=status]{overflow-wrap:anywhere;white-space:pre-line} .fv-setup .fv-muted{color:var(--fv-muted);font-size:var(--fv-micro);white-space:pre-wrap} .fv-setup .fv-model-repair-path{overflow-wrap:anywhere} .fv-setup progress{display:block;width:100%;height:6px;margin:6px 0} diff --git a/web/setup.js b/web/setup.js index dd48e8a..79c5bbc 100644 --- a/web/setup.js +++ b/web/setup.js @@ -144,7 +144,8 @@ export async function openSetup() { const use = node("button", t("Use existing installation", "使用已有安装")); const install = node("button", t("Install / repair", "安装/修复")); install.disabled = true; const cancel = node("button", t("Stop setup", "停止安装")); cancel.disabled = true; - actions.append(inspect, use, install, cancel); installation.append(actions); + const saveFolders = node('button', t('Save model folders', '保存模型目录')); + actions.append(saveFolders, inspect, use, install, cancel); installation.append(actions); // Why Install / repair is unavailable, so it never looks like a broken button. const blocked = note(""); blocked.classList.add("fv-setup-why"); blocked.setAttribute("aria-live", "polite"); blocked.hidden = true; installation.append(blocked); @@ -156,8 +157,9 @@ export async function openSetup() { const motions = [...dialog.querySelectorAll('details')].map(animateDetails); dialog.addEventListener('cancel', e => { e.preventDefault(); closeDialog(dialog); }); document.body.append(dialog); dialog.showModal(); + let savedFolders = "", savedMissing = false; let token, planId, busy = false, probeBusy = false, pollTimer, lastPlanId, planErrors = false, planStale = false, ready = false; - const fail = error => { installation.open = true; state.textContent = failureText(error.message); state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; + const fail = error => { installation.open = true; state.textContent = [savedFolders, failureText(error.message)].filter(Boolean).join(" · "); state.className = "fv-failure"; state.scrollIntoView({block:'nearest'}); }; const request = async (action, value) => { const response = await api.fetchApi("/freevideo/setup" + (action ? "/" + action : root.value ? '?root='+encodeURIComponent(root.value) : ""), action ? { method: "POST", headers: { "Content-Type": "application/json", "X-FreeVideo-Setup": token }, body: JSON.stringify(value || {}), @@ -169,6 +171,8 @@ export async function openSetup() { const unified = value && Object.hasOwn(value, 'ram_reserve_gib'); reserveField = unified ? 'ram_reserve_gib' : 'gpu_reserve_gib'; reserveSummary.textContent = unified ? t('Reserved unified memory', '预留统一内存') : t('Reserved VRAM', '预留显存'); + // Mac generation shares unified memory and has no compatibility levels. + compatibility.hidden = unified; reserveLabel.firstChild.textContent = reserveSummary.textContent; reserve.min = unified ? '1' : '0.2'; reserveAuto.checked = value?.[reserveField] == null; @@ -254,7 +258,7 @@ export async function openSetup() { catch(error) { probeButton.disabled=false; fail(error); } }; const controls = () => { - for (const e of [root, extra, copy, samplingCaches, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; + for (const e of [root, extra, copy, samplingCaches, saveFolders, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; cancel.disabled = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); const why = busy || (ready && !planId) ? "" : planStale ? t("Settings changed. Click Detect & review again to update the plan.", "设置已更改,请重新点击「检测并预览」更新安装计划。") : planErrors ? t("The plan has problems shown in red above. Please resolve them, then click Detect & review again.", "安装计划中有标红的问题,请解决后重新点击「检测并预览」。") @@ -278,6 +282,7 @@ export async function openSetup() { applyToken.onclick = () => setToken(hfToken.value); clearToken.onclick = () => setToken(''); for (const e of [root, extra, copy, samplingCaches]) e.oninput = () => invalidate(true); + extra.oninput = () => { if (savedFolders) state.textContent = ""; savedFolders = ""; savedMissing = false; invalidate(true); }; accept.onchange = controls; function showPlan(value) { plan.replaceChildren(); @@ -316,8 +321,9 @@ export async function openSetup() { } acceptLabel.hidden = busy || !planId; const p = task.progress || {}; - state.className = task.status === "failed" ? "fv-failure" : task.ready ? "fv-ready" : ""; + state.className = task.status === "failed" ? "fv-failure" : task.ready && !savedMissing ? "fv-ready" : ""; state.textContent = failureText(task.error) || (task.ready ? t("Ready. Generate with your existing workflow.", "准备就绪,可使用现有工作流生成。") : busy ? p.label || t("Checking configuration and local models…", "正在检查配置和本地模型…") : task.status === "complete" && task.action === "plan" ? (planId ? t("Review the plan, then install.", "请确认计划后安装。") : "") : task.status === "cancelled" ? t("Stopped. Files retained for resuming.", "已停止,文件保留,可继续安装。") : ""); + state.textContent = [savedFolders, state.textContent].filter(Boolean).join(" · "); const phases = task.phase_progress || {}, valid = Number.isFinite(p.total) && p.total > 0 && Number.isFinite(p.done) && p.done >= 0 && p.done <= p.total; overall.hidden = !busy; overallBar.max = phases.total > 0 ? phases.total : 1; overallBar.value = phases.done || 0; @@ -338,9 +344,27 @@ export async function openSetup() { catch (error) { fail(error); } if (dialog.open && (busy || probeBusy)) pollTimer = setTimeout(poll, 750); } + async function saveModelFolders() { + const result = await request('model-directories', {extra_libraries: extra.value.split("\n")}); + extra.value = result.extra_libraries.join("\n"); + const missing = result.missing_libraries || []; + savedMissing = missing.length > 0; + savedFolders = !missing.length ? t('Saved.', '已保存。') + : missing.length === 1 ? t(`Saved. This folder was not found:\n${missing[0]}`, `已保存。找不到这个目录:\n${missing[0]}`) + : t(`Saved. These folders were not found:\n${missing.join('\n')}`, `已保存。找不到这些目录:\n${missing.join('\n')}`); + state.textContent = savedFolders; state.className = savedMissing ? '' : 'fv-ready'; + state.scrollIntoView({block: 'nearest'}); + return result; + } + saveFolders.onclick = async () => { + invalidate(true); busy = true; controls(); + try { await saveModelFolders(); } + catch (error) { fail(error); } + finally { busy = false; controls(); } + }; inspect.onclick = async () => { invalidate(); busy = true; controls(); - try { render(await request("inspect", {root: root.value, extra_libraries: extra.value.split("\n").map(s => s.trim()).filter(Boolean), copy: copy.checked, sampling_caches: samplingCaches.checked})); poll(); } + try { await saveModelFolders(); render(await request("inspect", {root: root.value, extra_libraries: extra.value.split("\n").map(s => s.trim()).filter(Boolean), copy: copy.checked, sampling_caches: samplingCaches.checked})); poll(); } catch (error) { busy = false; controls(); fail(error); } }; install.onclick = async () => { @@ -355,6 +379,7 @@ export async function openSetup() { dialog.onclose = () => { clearTimeout(pollTimer); for (const dispose of motions) dispose(); dialog.remove(); if (current === dialog) current = undefined; }; try { const info = await request(); token = info.discovery.token; root.value = info.discovery.root; + extra.value = (info.discovery.extra_libraries || []).join("\n"); showDownloads(info.discovery.downloads); showResources(info.discovery.resources); samplingText.textContent = t('Prepare all quality levels (optional)', '提前下载全部质量档位(可选)') diff --git a/web/share.js b/web/share.js index c4f6248..622e90a 100644 --- a/web/share.js +++ b/web/share.js @@ -1,7 +1,7 @@ import {api} from '../../scripts/api.js'; import {closeDialog} from './motion.js'; import {effortFor, effortName} from './sampling_effort.js'; -import {fontCSS, loadFont} from './fonts.js'; +import {fontCSS as bundledFontCSS, fontLoaded, loadFont} from './fonts.js'; import {shareLayout} from './share_layout.js'; const css=document.createElement('link'); css.rel='stylesheet'; css.href=new URL('./share.css',import.meta.url).href; document.head.append(css); @@ -27,10 +27,9 @@ async function withGraph(blob,graph){ const tint=(hex,alpha)=>{const v=parseInt(hex.slice(1),16);return `rgba(${v>>16},${v>>8&255},${v&255},${alpha})`;}; let opened; -// Deterministic Canvas rendering in FreeVideo's bundled sans (load it first: shareFonts), never a -// font of the computer; no external resources or user prompt are included. The image window -// preserves every pixel. -export const shareFonts=()=>Promise.all([loadFont('sans',400),loadFont('sans',700)]); +// Prefer bundled sans, but keep the card usable when optional font files are blocked. +export const shareFonts=()=>Promise.all([loadFont('sans',400),loadFont('sans',700)]).catch(error=>console.warn('FreeVideo share fonts unavailable; using fallback fonts.',error)); +const fontCSS=(name,weight,size)=>{const css=bundledFontCSS(name,weight,size);return fontLoaded(name,weight)?css:css.replace('"FreeVideo Box"','sans-serif');}; export function drawShareCard(canvas,frame,logo,record,t) { const g=record.geometry||{}, shape=shareLayout(g.width||frame?.naturalWidth||1344,g.height||frame?.naturalHeight||768); @@ -49,7 +48,7 @@ export function drawShareCard(canvas,frame,logo,record,t) { if(frame)ctx.drawImage(frame,...shape.rect); const fit=(text,weight,size,max)=>{while(size>12){ctx.font=fontCSS('sans',weight,size);if(ctx.measureText(text).width<=max)break;size--;}return ctx.measureText(text).width;}; const logoWidth=wide?156:140, logoTop=wide?y+H/2-26:y+30; - ctx.drawImage(logo,pad,logoTop,logoWidth,logoWidth*337/2016); + if(logo)ctx.drawImage(logo,pad,logoTop,logoWidth,logoWidth*337/2016); const seconds=g.seconds||g.frames/(g.fps||24); ctx.font=fontCSS('sans',400,14);ctx.fillStyle='#6f8399';ctx.textAlign=wide?'left':'right'; ctx.fillText(`${g.width} × ${g.height}${Number.isFinite(seconds)?' · '+seconds.toFixed(1)+' s':''}`,wide?pad:w-pad,wide?y+H/2+24:y+46); @@ -157,9 +156,9 @@ export async function openShare(record,t){ const response=await api.fetchApi('/freevideo/share?'+new URLSearchParams({id}),{signal:abort.signal}); if(!response.ok)throw new Error(t('This saved video is unavailable.','这个已保存的视频暂时无法读取。')); metadata=await response.json(); - [first,logo]=await Promise.all([image(api.apiURL('/freevideo/share/frame?'+new URLSearchParams({id}))),image(new URL('./assets/freevideo.svg',import.meta.url).href)]); + [first,logo]=await Promise.all([image(api.apiURL('/freevideo/share/frame?'+new URLSearchParams({id}))),image(new URL('./assets/freevideo.svg',import.meta.url).href).catch(error=>{console.warn('FreeVideo share logo unavailable; continuing without it.',error);return null;})]); if(disposed)return; - await shareFonts();if(document.fonts?.ready)await document.fonts.ready; + await shareFonts();if(document.fonts?.ready)await document.fonts.ready.catch(error=>console.warn('FreeVideo share fonts unavailable; using fallback fonts.',error)); shape=drawShareCard(canvas,null,logo,metadata,t);template=canvas.toDataURL('image/png').split(',')[1]; preview.style.aspectRatio=`${shape.width}/${shape.height}`; player.style.cssText=`left:${shape.rect[0]/shape.width*100}%;top:0;width:${shape.rect[2]/shape.width*100}%;height:${shape.rect[3]/shape.height*100}%`; diff --git a/web/studio.js b/web/studio.js index eced36b..8d9db68 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1,5 +1,6 @@ import { createErrorPanel, errorText, createErrorReport } from './error_panel.js'; import {topDialog} from './dialog_keys.js'; +import {cleanOptionalPrompt} from './optional_inputs.js'; import { app } from '../../scripts/app.js'; import { api } from '../../scripts/api.js'; import { openSetup } from './setup.js'; @@ -948,7 +949,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // Only serialization briefly locks the editor. Network submission // and all GPU execution leave the next draft fully editable. for (const section of controls.querySelectorAll('.fv-section')) section.inert = true; - const snapshot = singleRunSnapshot(extendSnapshot(structuredClone(await app.graphToPrompt()), node.id, source, + const snapshot = singleRunSnapshot(extendSnapshot(cleanOptionalPrompt(structuredClone(await app.graphToPrompt())), node.id, source, {multiple: lengthPlan?.count > 1}), node.id, lengthPlan); if (!snapshot) { status.textContent = t('Could not plan this single run. Try again.', '暂时无法计算这次单次生成,请重试。'); return; } const seedIndex = node.widgets.findIndex(w => w.name === 'seed'); diff --git a/web/updates.js b/web/updates.js index 78b205c..0475c13 100644 --- a/web/updates.js +++ b/web/updates.js @@ -1,8 +1,7 @@ import { api } from '../../scripts/api.js'; -// Every platform downloads from one release: the latest vX.Y.Z, or the rolling nightly. +// Every platform downloads from one release: the latest vX.Y.Z. const releasePage = 'https://github.com/FlashML-org/FreeVideo/releases/latest'; -const nightlyPage = 'https://github.com/FlashML-org/FreeVideo/releases/tag/nightly'; const storageKey = 'freevideo.dismissed-update'; const listeners = new Set(); // Launcher phases during which the server may disappear and come back updated. @@ -135,7 +134,7 @@ export function createUpdateNotice(cn) { const render = () => { const launcher = value?.launcher, phase = launcher?.phase || '', candidate = value?.available; const mac = value?.channel === 'macos-preview', linux = value?.channel === 'linux-preview'; - link.href = value?.track === 'nightly' ? nightlyPage : releasePage; + link.href = releasePage; link.title = mac ? t('After your task finishes, open the new FreeVideo.app to update.', '当前任务完成后,打开新版 FreeVideo.app 更新。') : linux ? t('After your task finishes, open the new FreeVideo AppImage to update.', '当前任务完成后,打开新版 FreeVideo AppImage 更新。')