diff --git a/freevideo_engine/adaptive.py b/freevideo_engine/adaptive.py index 37d9a9b8..ea166ea2 100644 --- a/freevideo_engine/adaptive.py +++ b/freevideo_engine/adaptive.py @@ -36,6 +36,11 @@ def cuda_runtime_error(error): return False +def commit_exhausted(error): + from .diagnostic_resources import commit_exhausted as found + return found(error) + + def classify_failure(error, metrics=None): """Only positively identified resource failures are retriable. @@ -63,7 +68,7 @@ def classify_failure(error, metrics=None): kind, outcome, retry = 'timeout', 'cancelled', False elif details.get('kind') == 'gpu_oom' or 'cuda out of memory' in text or 'cuda error: out of memory' in text: kind, outcome, retry = 'gpu_oom', 'resource_failure', True - elif isinstance(error, MemoryError) or details.get('kind') == 'ram_pressure': + elif isinstance(error, MemoryError) or details.get('kind') == 'ram_pressure' or commit_exhausted(error): kind, outcome, retry = 'ram_pressure', 'resource_failure', True elif isinstance(error, WorkerExit) and not (metrics or {}).get('error'): kind, outcome, retry = 'unknown_worker_exit', 'discarded', False @@ -434,7 +439,12 @@ def change(section, name, new): host_pins = failure['kind'] == 'gpu_oom' and nonlocal_exhausted(failure) compute = None if failure['kind'] == 'gpu_oom': - if host_pins and phase in ('load', 'sample') and (engine.get('pin_host_gb', 0.) > 0 or engine.get('pin_host_weights')): + if phase == 'sample' and engine.get('step_cache') == 'fbcache' and not host_pins: + # Its two packed residuals are the newest GPU allocation in the + # pass; sample every step in full before moving any weights. + changes['engine.step_cache'] = {'from': 'fbcache', 'to': None} + del engine['step_cache'] + elif host_pins and phase in ('load', 'sample') and (engine.get('pin_host_gb', 0.) > 0 or engine.get('pin_host_weights')): # Page-locked memory ran out, not VRAM: return pinned weights and # keep every GPU setting. Halve once, then pin nothing. change('engine', 'pin_host_gb', 0. if previous_gpu_retries else round(engine.get('pin_host_gb', 0.) / 2, 3)) diff --git a/freevideo_engine/agent/editor_runtime.py b/freevideo_engine/agent/editor_runtime.py index e2470143..b8bbf755 100644 --- a/freevideo_engine/agent/editor_runtime.py +++ b/freevideo_engine/agent/editor_runtime.py @@ -17,8 +17,8 @@ # import does. editor_model and editor_text_model import each other: the # module still loading is reached only through a namespace import. MODULES = ('editor_picture_fields', 'editor_transport_model', 'editor_audio_model', 'editor_speed_model', - 'editor_text_model', 'editor_model', 'editor_room', 'editor_actions', 'editor_generation', 'chain_support', - 'editor_store') + 'editor_text_model', 'editor_model', 'editor_room', 'editor_actions', 'editor_generation', 'sampling_effort', + 'chain_support', 'editor_store') TIME_LIMIT = 5.0 MEMORY_LIMIT = 256 * 2**20 STACK_LIMIT = 4 * 2**20 diff --git a/freevideo_engine/agent/guide.py b/freevideo_engine/agent/guide.py index 2cf848f5..6a6a0681 100644 --- a/freevideo_engine/agent/guide.py +++ b/freevideo_engine/agent/guide.py @@ -71,8 +71,10 @@ 'the draft and looks plainer than a direct generation, so keep the direct one when the user prefers its look.', ], 'quality': [ - 'Steps per quality: "light" = 8 first-pass steps + 3 refine steps, two-pass (default); "medium" = 12 steps, ' - '"high" = 16 steps, "max" = 20 steps, each in one pass.', + 'Steps per quality: "light" = 8 first-pass steps + 3 refine steps, two-pass (default); "medium" = 8 steps, ' + '"high" = 20 steps with a step cache, "max" = 20 steps with a step cache that skips less work, each in one pass. ' + '"max" runs plain 20 steps when the GPU has no room for its cache. On a Mac, "high" is 16 steps and ' + '"max" 20 steps, without a cache.', 'Two-pass generation runs only with 8 first-pass steps. Any other base_steps (for example 20 with ' 'two_pass=true) runs in one pass of that many steps; the job notice says so. A preview is always two-pass ' 'with 8 steps.', diff --git a/freevideo_engine/agent/length.py b/freevideo_engine/agent/length.py index adacee8f..a44e1499 100644 --- a/freevideo_engine/agent/length.py +++ b/freevideo_engine/agent/length.py @@ -41,9 +41,14 @@ def single_frame_limit(spec, *, port=None, table=None): tiers = table.get('tiers', {}) tier = tiers.get(spec['quality']) if tier is None: - tier = next((row for row in tiers.values() - if row.get('steps') == spec['base_steps'] and row.get('two_pass') == spec['two_pass'] - and (not spec['two_pass'] or spec['refine_steps'] == 3)), {}) + # The same plan with the same step cache first. A cache keeps the length a + # card fits (the planner drops a first-block cache that does not fit), + # so the same plan with another cache is the next best row. + same = [row for row in tiers.values() + if row.get('steps') == spec['base_steps'] and row.get('two_pass') == spec['two_pass'] + and (not spec['two_pass'] or spec['refine_steps'] == 3)] + tier = next((row for row in same if row.get('step_cache') == spec.get('step_cache')), + same[0] if same else {}) index = next((i for i, row in enumerate(table.get('sizes', [])) if (row['width'], row['height']) == (spec['width'], spec['height'])), None) if index is None: diff --git a/freevideo_engine/agent/productions.py b/freevideo_engine/agent/productions.py index df888664..64bcb6d4 100644 --- a/freevideo_engine/agent/productions.py +++ b/freevideo_engine/agent/productions.py @@ -1012,9 +1012,12 @@ def _too_long(self, item, resolved): tiers = table.get('tiers', {}) tier = tiers.get(normalized['quality']) if tier is None: - tier = next((row for row in tiers.values() - if row.get('steps') == normalized['base_steps'] and row.get('two_pass') == normalized['two_pass'] - and (not normalized['two_pass'] or normalized['refine_steps'] == 3)), None) + # Same plan and step cache first, then the same plan (see length.single_frame_limit). + same = [row for row in tiers.values() + if row.get('steps') == normalized['base_steps'] and row.get('two_pass') == normalized['two_pass'] + and (not normalized['two_pass'] or normalized['refine_steps'] == 3)] + tier = next((row for row in same if row.get('step_cache') == normalized.get('step_cache')), + same[0] if same else None) maxima = tier.get('max_frames') if tier else None if index is None or not isinstance(maxima, list) or index >= len(maxima): return False diff --git a/freevideo_engine/agent/routes.py b/freevideo_engine/agent/routes.py index 040c525f..69534e6b 100644 --- a/freevideo_engine/agent/routes.py +++ b/freevideo_engine/agent/routes.py @@ -25,6 +25,7 @@ from . import assets from . import private_file from . import spec as specs +from .. import tiers as quality_tiers from . import schema as contract, errors, access from .jobs import ACTIVE, DONE, JobError, Jobs, chain_error, library_view @@ -213,7 +214,8 @@ def capabilities(port, jobs): seconds_tested=[4, 15], fps=24, base_steps=[1, 32], refine_steps=[1, 31], references=32, loras=32, lora_strength=[-4, 4], upload_bytes=2 * 1024 ** 3, vlm_images=24, takes=8, batch_jobs=64), - quality={name: dict(two_pass=plan[0], base_steps=plan[1], refine_steps=plan[2]) + quality={name: dict(two_pass=plan[0], base_steps=plan[1], refine_steps=plan[2], + step_cache=quality_tiers.step_cache(name, quality_tiers.on_mac())) for name, plan in specs.QUALITY.items()}, aspects=sorted(specs.ASPECTS), native_megapixels=.98, tasks=GUIDE['tasks'], media_extensions={k: sorted(v) for k, v in EXTENSIONS.items()}, diff --git a/freevideo_engine/agent/schema.py b/freevideo_engine/agent/schema.py index df65bb38..bad26eda 100644 --- a/freevideo_engine/agent/schema.py +++ b/freevideo_engine/agent/schema.py @@ -9,10 +9,13 @@ import re from . import API_VERSION +from .. import tiers S = 'https://json-schema.org/draft/2020-12/schema' PREFIX = '/freevideo/agent/v1' -QUALITY = {'light': (True, 8, 3), 'medium': (False, 12, 3), 'high': (False, 16, 3), 'max': (False, 20, 3)} +# (two_pass, base_steps, refine_steps) of each level on this platform; the step +# cache a level adds is tiers.step_cache(name, mac) (none on Mac). +QUALITY = {name: tiers.plan(name, mac=tiers.on_mac()) for name in tiers.TIERS} ASPECTS = {'16:9': (16, 9), '9:16': (9, 16), '1:1': (1, 1), '4:3': (4, 3), '3:4': (3, 4), '21:9': (21, 9), '2:3': (2, 3), '3:2': (3, 2)} IMAGE_ASPECTS = {'1:1': (1024, 1024), '16:9': (1344, 768), '9:16': (768, 1344), @@ -191,7 +194,7 @@ def generate(*, local=False): {'required': ['sampling', 'quality'], 'properties': { 'sampling': {'required': ['two_pass'], 'not': {'required': ['base_steps']}, 'properties': {'two_pass': {'const': True}}}, - 'quality': {'enum': ['medium', 'high', 'max']}}}]}}}], + 'quality': {'enum': [name for name, plan in QUALITY.items() if plan[1] != 8]}}}]}}}], description='x-job-kind selects normalization and checks derived geometry and sampling step comparisons; these calculations are not JSON Schema constraints.', **{'x-job-kind': 'generate'}) diff --git a/freevideo_engine/agent/spec.py b/freevideo_engine/agent/spec.py index 1a932aee..75ee1404 100644 --- a/freevideo_engine/agent/spec.py +++ b/freevideo_engine/agent/spec.py @@ -126,6 +126,8 @@ def canvas_size(value): def sampling(value): + from .. import tiers + mac = tiers.on_mac() quality = value.get('quality', contract.generate()['properties']['quality']['default']) two, base, refine = QUALITY[quality] custom = value.get('sampling', {}) @@ -136,10 +138,13 @@ def sampling(value): # Only the 8-step first pass goes two-pass; any other count is one pass, said once in the answer. note = one_pass_note(base) two = two and note is None + # A level's step cache stays only with that level's own plan; custom sampling runs every step. + cache = tiers.step_cache(quality, mac) if QUALITY[quality] == (two, base, refine) else None if custom: - quality = next((k for k, v in QUALITY.items() if v == (two, base, refine)), 'custom') + quality = next((k for k, v in QUALITY.items() if v == (two, base, refine) + and tiers.step_cache(k, mac) == cache), 'custom') return dict(quality=quality, two_pass=two, base_steps=base, refine_steps=refine, - **({'sampling_note': note} if note else {})) + **({'step_cache': cache} if cache else {}), **({'sampling_note': note} if note else {})) def generation_media(value): @@ -276,6 +281,8 @@ def comfy_prompt(spec, node, *, prompt, assets, refine_from=''): if spec.get('first_frame_reference'): # Every part of the video carries it; the part that continues nothing ignores it. inputs['first_frame_reference'] = True + if spec.get('step_cache'): + inputs['step_cache'] = spec['step_cache'] graph = {node: {'class_type': 'FreeVideoGenerate', 'inputs': inputs, '_meta': {'title': 'FreeVideo · ' + (spec.get('label') or 'Agent job')}}} if assets: diff --git a/freevideo_engine/capabilities.py b/freevideo_engine/capabilities.py index 9daddfba..bf9ea16b 100644 --- a/freevideo_engine/capabilities.py +++ b/freevideo_engine/capabilities.py @@ -18,12 +18,12 @@ from .policy import BLOCK_BYTES, ResourceBudgetError, choose from .stage_estimate import STAGES, _params, gpu_key, predict_prior from .two_pass import first_pass_policy, plan as sampling_plan +from . import tiers as quality_tiers # Shared by all queries, including the default summary; parity with the editor # is checked in test_capabilities (JS uses positive Math.round, not bankers' rounding). SIZE_PRESETS = (.5, .75, .98, 1., 1.5, 2.) -TIERS = {'light': (8, True), 'medium': (12, False), - 'high': (16, False), 'max': (20, False)} +TIERS = {name: (tier['steps'], tier['two_pass']) for name, tier in quality_tiers.TIERS.items()} PLAN_NAMES = {'-1': 'refused', '0': 'normal', '1': 'slower_streaming', '2': 'admitted_tight_memory'} # WholeVideoProgress.for_request starts an ordinary new-prompt request with @@ -242,7 +242,12 @@ def _request(width, height, frames, steps, two_pass, task='t2va', refine=3, def _admit(machine, width, height, frames, steps, two_pass, task='t2va', refine=3, - references=None, reference_rows=None): + references=None, reference_rows=None, step_cache=None): + """Admission of one request; `step_cache` asks the planner for a first-pass cache. + + The request carries the cache the planner keeps (never one on Mac), so its + estimate and history follow what the engine will actually run. + """ canvas, sampling, request = _request(width, height, frames, steps, two_pass, task, refine, references, reference_rows) rows = dict(video=canvas.get('reference_video_tokens', 0), audio=canvas.get('reference_audio_tokens', 0)) @@ -269,7 +274,9 @@ def _admit(machine, width, height, frames, steps, two_pass, task='t2va', refine= machine['ram'], machine['ram_available'], system=machine['system']) options = dict(precision=machine['precision'], attention='sage2/sage2', available_backends={'sage2'}) choose(hardware, canvas=canvas, stage='encoding', **options) - final = choose(hardware, canvas=canvas, **options) + final = choose(hardware, canvas=dict(canvas, step_cache=step_cache) if step_cache else canvas, **options) + if final.engine.get('step_cache'): + request['step_cache'] = final.engine['step_cache'] first = first_pass_policy(final.legacy_profile(), canvas, sampling) engines = [first['profile']['engine'] if first else final.engine, final.engine] request.update(pass1_resident_blocks=engines[0]['resident_blocks'], @@ -327,7 +334,7 @@ def ratios(rows): request, _, _ = _admit(machine, shape['width'], shape['height'], shape['frames'], row['base_steps'], row['two_pass'], row['task'], row['refine_steps'] if row['two_pass'] else 3, - reference_rows=refs) + reference_rows=refs, step_cache=row.get('step_cache')) if request is None: continue request['lora_count'] = int(bool(row.get('adapters'))) @@ -355,6 +362,7 @@ def _whole_records(records, request, adapters): and r.get('flow', 'full') == 'full' and r.get('task') == request['task'] and r.get('adapters', False) == adapters and r.get('two_pass') == request['two_pass'] and r.get('base_steps') == request['base_steps'] and r.get('refine_steps') == request['refine_steps'] + and r.get('step_cache') == request.get('step_cache') and ((request['task'] == 't2va' and r.get('reference_rows') is None) or (valid_reference_rows(r.get('reference_rows')) and r['reference_rows'] == rows))] @@ -407,11 +415,12 @@ def estimate(request): return local.get('seconds', prior['total']['seconds'] * ratio) tiers = {} - for name, (steps, two_pass) in TIERS.items(): + for name, tier in quality_tiers.table(machine['mac']).items(): + steps, two_pass, step_cache = tier['steps'], tier['two_pass'], tier['step_cache'] times, codes, maxima, frame_maxima, limits = [], [], [], [], [] for size_row in sizes: admitted = {f: _admit(machine, size_row['width'], size_row['height'], f, steps, two_pass, task, - references=references) + references=references, step_cache=step_cache) for f in range(39, lengths[-1]['frames'] + 1, 17)} estimates = {} for f, (request, _, _) in admitted.items(): @@ -437,7 +446,7 @@ def estimate(request): row_codes.append(code) times.append(row_times) codes.append(row_codes) - tiers[name] = dict(steps=steps, two_pass=two_pass, estimate_seconds=times, + tiers[name] = dict(steps=steps, two_pass=two_pass, step_cache=step_cache, estimate_seconds=times, max_seconds=maxima, max_frames=frame_maxima, limit=limits, plan=codes) default = next((i for i, s in enumerate(sizes) if s['pixels'] == .98), 0) result = dict(version=1, status='ready', diff --git a/freevideo_engine/cli.py b/freevideo_engine/cli.py index cdc86902..acbda450 100644 --- a/freevideo_engine/cli.py +++ b/freevideo_engine/cli.py @@ -113,6 +113,8 @@ def main(): generate.add_argument('--seed', type=int, default=2026090901) generate.add_argument('--two-pass', action=argparse.BooleanOptionalAction, default=True, help='Sample a smaller canvas, upscale latents and refine (default: on; 8 + 3 steps)') + generate.add_argument('--step-cache', choices=('easycache-av', 'fbcache'), + help='Approximate reuse between sampling steps (disabled by default)') generate.add_argument('--base-steps', type=int, choices=range(1, 33), metavar='1..32', help='First-pass steps (default: 8, or explicit profile). Changes may reduce quality.') generate.add_argument('--refine-steps', type=int, choices=range(1, 32), default=3, metavar='1..31', diff --git a/freevideo_engine/comfy_assets.py b/freevideo_engine/comfy_assets.py index 19545ba3..f1d0e61d 100644 --- a/freevideo_engine/comfy_assets.py +++ b/freevideo_engine/comfy_assets.py @@ -156,6 +156,13 @@ def connect_assets(assets, *, first=None, last=None, reference=None, reference_a return result +def sampling_plan(report): + """The report's sampling plan, with the step cache its first pass used (names High and Max).""" + from .effort_forecast import report_step_cache + plan, cache = report.get('sampling_plan'), report_step_cache(report) + return dict(plan, step_cache=cache) if isinstance(plan, dict) and cache else plan + + def output_summary(report, relative_video): video = report.get('video', {}) encoding = report.get('encoding', {}) @@ -175,7 +182,7 @@ def output_summary(report, relative_video): gpu_total_bytes=report.get('profile', {}).get('policy', {}).get('hardware', {}).get('vram_total'), # A small size is sampled larger and scaled back; people see the size they asked for. geometry=dict(report.get('geometry', {}), **(report.get('output_size') or {})), - sampling_plan=report.get('sampling_plan'), + sampling_plan=sampling_plan(report), low_memory=bool(report.get('low_memory_mode')), thermal=thermal_slowdown(worker_gpu(report)), shared_gpu=shared_gpu_note(worker_gpu(report)), diff --git a/freevideo_engine/comfy_bridge.py b/freevideo_engine/comfy_bridge.py index 6b5b750e..606784d6 100644 --- a/freevideo_engine/comfy_bridge.py +++ b/freevideo_engine/comfy_bridge.py @@ -4,6 +4,7 @@ This bridge only retains a request, supervises that CLI and forwards progress. """ import json +import logging import math import os from pathlib import Path @@ -208,7 +209,7 @@ def __init__(self, clock=time.perf_counter, *, hardware=None, request=None, @classmethod def for_request(cls, machine, canvas, output_directory, *, base_steps=8, refine_steps=3, two_pass=True, preview=False, upscale=False, - encoder_prewarm=None, options=None): + encoder_prewarm=None, options=None, step_cache=None): import platform from .effort_forecast import start_context started, opened = time.perf_counter(), time.monotonic() @@ -222,7 +223,8 @@ def for_request(cls, machine, canvas, output_directory, *, base_steps=8, flags['encoder_cold'] = False tracker = cls(hardware=hardware, records=records, cold_flags=flags, started=started, request=dict(canvas, base_steps=base_steps, refine_steps=refine_steps, - two_pass=two_pass, flow='upscale' if upscale else 'preview' if preview else 'full'), + two_pass=two_pass, flow='upscale' if upscale else 'preview' if preview else 'full', + step_cache=step_cache), **(options or {})) tracker.history = (output_directory, machine, opened) return tracker @@ -300,6 +302,13 @@ def configure(self, *, plan=None, media=None, conditioning=False, placement=None self.request['decoder_resident_blocks'] = blocks if type(decoder.get('offload')) is bool: self.request['decoder_offload'] = decoder['offload'] + if self.request.get('step_cache') is not None: + # The planner keeps the first-block cache only where it fits; the + # estimate follows the cache this placement actually uses. + if engine.get('step_cache') is None: + self.request.pop('step_cache') + else: + self.request['step_cache'] = engine['step_cache'] if media is not None: self.request.update(task=task_for(media), lora_count=len(media.get('loras') or []), reference_count=len(media.get('references') or []) + @@ -1170,7 +1179,13 @@ def generate(prompt, width, height, seconds, seed, output_directory, *, release_models=None, export_inputs=None, two_pass=True, encoder_prewarm=None, force_regenerate=False, base_steps=8, refine_steps=3, comfy_metadata=None, prompt_rewrite_report=None, preview=False, refine_from=None, report_extra=None, - continue_from=None, frames=None, first_frame_reference=False, voice_reference=False, hide_tail=False): + continue_from=None, frames=None, first_frame_reference=False, voice_reference=False, hide_tail=False, + step_cache=None): + from .tiers import STEP_CACHES + if step_cache == 'none': + step_cache = None + if step_cache not in STEP_CACHES: + raise ValueError('Step cache must be none, ' + ', '.join(c for c in STEP_CACHES if c)) if type(first_frame_reference) is not bool: raise ValueError('First-frame reference must be a boolean') if type(voice_reference) is not bool: @@ -1202,6 +1217,8 @@ def generate(prompt, width, height, seconds, seed, output_directory, *, two_pass, preview = True, False base_steps = int(argument(arguments, '--base-steps', 8)) refine_steps = int(argument(arguments, '--refine-steps', 3)) + # The second pass never uses a step cache; the preview's own command keeps its flag. + step_cache = argument(arguments, '--step-cache') from .preview import validate_flags validate_flags(preview, upscale, two_pass, base_steps) from .two_pass import validate_steps @@ -1216,6 +1233,11 @@ def check_ending(): from .two_pass import sampling_canvas check(continue_from, sampling_canvas(canvas)[0]) + if step_cache is not None and machine.get('device_backend') == 'mps': + # The step caches are not ported to Mac yet: the request samples every step, + # and its identity is that of the same request without a cache. + logging.warning('[FreeVideo] Step caches are not available on Mac yet; sampling every step.') + step_cache = None if continue_from is not None: validate_route(backend=machine.get('device_backend', 'cuda')) from .continuation import MISSING, prepare, summary @@ -1263,6 +1285,8 @@ def check_ending(): '--width', str(width), '--height', str(height), '--seconds', str(seconds), '--seed', str(seed), '--two-pass' if two_pass else '--no-two-pass', '--base-steps', str(base_steps), '--refine-steps', str(refine_steps)] + if step_cache is not None: + command += ['--step-cache', step_cache] if frames is not None: position = command.index('--seconds') command[position:position + 2] = ['--frames', str(frames)] @@ -1300,7 +1324,8 @@ def check_ending(): try: whole_progress = WholeVideoProgress.for_request(machine, canvas, output_directory, base_steps=base_steps, refine_steps=refine_steps, two_pass=two_pass, - preview=preview, upscale=bool(upscale), encoder_prewarm=state['encoder_prewarm']) + preview=preview, upscale=bool(upscale), encoder_prewarm=state['encoder_prewarm'], + step_cache=None if upscale else step_cache) except Exception: # A progress estimate cannot prevent generation, even without its parameters. try: @@ -1414,6 +1439,9 @@ def poll(): new_frames=sampled['frames'] - hide_tail_frames - OVERLAP_FRAMES, encoded=True) # A preview is its own result; an upscale is always run from its preview. keyed = dict(planned, preview=True) if preview else planned + if step_cache is not None: + # A cache changes the arithmetic; without one the identity stays that of earlier versions. + keyed = dict(keyed, step_cache=step_cache) if hide_tail_frames: keyed = dict(keyed, hide_tail_frames=hide_tail_frames) identify = lambda check: request_key(prompt, seed, canvas, keyed, extra, machine, @@ -1448,7 +1476,9 @@ def asset_progress(message): # Reference audio selects its own tables; match the encoder's choice. asset_task = ((ANCHOR_ADALN_TASK if anchored or pictured else ADALN_TASK) if continue_from is not None else engine_task(extra.get('media', {}), media_base)) - preparation = prepare_sampling_assets(root, machine, planned, asset_task, + # The cache only names the level of a missing table (high or max at 20 steps). + preparation = prepare_sampling_assets(root, machine, dict(planned, step_cache=step_cache) if step_cache else planned, + asset_task, progress=asset_progress, interrupted=interrupted, environ=environment) except BaseException: elapsed = time.monotonic() - preparation_started diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py index 85f579f8..ec3648be 100644 --- a/freevideo_engine/comfy_chain.py +++ b/freevideo_engine/comfy_chain.py @@ -78,7 +78,9 @@ def inputs(self, block_inputs): values[name] = '' if independent else block_inputs[key] if 'voice_reference' in schema and values.get('continue_from'): values['voice_reference'] = True - if 'hide_tail' in schema and not independent and block_inputs.get('index', 0) < block_inputs.get('count', 0) - 1: + if ('hide_tail' in schema and not independent and block_inputs.get('index', 0) < block_inputs.get('count', 0) - 1 + and continuation_supported()): + # Only a backend that continues videos makes the extra tail frames; others ignore the option. values['hide_tail'] = True return values @@ -241,8 +243,13 @@ def continuation_directions(): return {key for key, name in CONTINUE_INPUTS.items() if name in schema} +def continuation_supported(): + from .continuation import supported + return supported()[0] + + def continuation_available(): - return 'after' in continuation_directions() + return 'after' in continuation_directions() and continuation_supported() def report_fields(chain): diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py index 42b4da7b..3d8c5114 100644 --- a/freevideo_engine/comfy_library.py +++ b/freevideo_engine/comfy_library.py @@ -1084,11 +1084,18 @@ async def sampling_estimate(request): device = machine.get('device_identity') if machine.get('device_backend') == 'mps' else machine.get('gpu_uuid') rows = await asyncio.to_thread(local_records, folder_paths.get_output_directory(), device) adapters = request.query.get('adapters') == '1' - # A small canvas runs in one pass whatever the switch says. - result = {name: {str(steps): estimate(rows, canvas, base_steps=steps, - two_pass=plan(canvas, enabled, task, base_steps=steps)['enabled'], - task=task, adapters=adapters) - for steps in ((8,) if enabled else (8, 12, 16, 20))} + # A small canvas runs in one pass whatever the switch says. A cached + # level is keyed ":". Without a run of it, plain runs + # stand in: the first-block cache is counted at full time, since the + # planner may not keep it on this card. + share = {'fbcache': 1.} + result = {name: {(str(steps) + (':' + cache if cache else '')): + estimate(rows, canvas, base_steps=steps, + two_pass=plan(canvas, enabled, task, base_steps=steps)['enabled'], + task=task, adapters=adapters, step_cache=cache, cache_share=share.get(cache)) + for steps, cache in (((8, None),) if enabled else + ((8, None), (12, None), (16, None), (20, None), + (20, 'easycache-av'), (20, 'fbcache')))} for name, enabled in (('single', False), ('two_pass', True))} return web.json_response(result, headers={'Cache-Control': 'no-store'}) except (OSError, ValueError, TypeError, KeyError): diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 725e62f4..7232ac24 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -12,6 +12,13 @@ LoRAs = io.Custom('FREEVIDEO_LORAS') Media = io.Custom('FREEVIDEO_MEDIA') NO_LORA = 'None' +STEP_CACHE_OPTIONS = ('none', 'easycache-av', 'fbcache') + + +def step_cache_option(value): + """Bridge keyword for the Step cache widget; a workflow without it, or with 'none', adds nothing.""" + return {} if value in (None, 'none') else {'step_cache': value} + class FreeVideoGenerate(io.ComfyNode): @classmethod @@ -69,6 +76,11 @@ def define_schema(cls): optional=True, advanced=True, tooltip='Keeps the opening voice consistent in continued dialogue.'), io.Boolean.Input('hide_tail', display_name='Hide dialogue tail', default=False, optional=True, advanced=True, tooltip='Generates and discards 17 extra frames to keep dialogue moving between parts.'), + # Last, so the widget values of saved workflows keep their positions. + io.Combo.Input('step_cache', display_name='Step cache', options=list(STEP_CACHE_OPTIONS), default='none', + optional=True, advanced=True, + tooltip='Reuses work between similar sampling steps to finish sooner, with a small change to the result. ' + 'High uses easycache-av; Max uses fbcache.'), ], outputs=[io.Video.Output('video'), io.String.Output('report', display_name='Report JSON')], hidden=[io.Hidden.unique_id, io.Hidden.prompt, io.Hidden.extra_pnginfo], @@ -114,7 +126,8 @@ def fingerprint_inputs(cls, **kwargs): def execute(cls, text, width, height, seconds, seed, first=None, last=None, references=None, loras=None, conditioning=None, media=None, two_pass=True, force_regenerate=False, base_steps=8, refine_steps=3, preview=False, refine_from='', - continue_from='', frames=0, first_frame_reference=False, voice_reference=False, hide_tail=False): + continue_from='', frames=0, first_frame_reference=False, voice_reference=False, hide_tail=False, + step_cache='none'): from server import PromptServer from .comfy_chain import block_context, block_finished, report_fields refine_from, continue_from = optional_text(refine_from), optional_text(continue_from) @@ -257,6 +270,7 @@ def kept(exported): **({'first_frame_reference': True} if first_frame_reference is True and options.get('continue_from') else {}), **({'voice_reference': True} if voice_reference is True and options.get('continue_from') else {}), **({'hide_tail': True} if hide_tail is True else {}), + **step_cache_option(step_cache), **({'report_extra': {'chain': report_fields(chain)}} if chain is not None else {})) relative = output.relative_to(output_root) report = output.with_suffix('.request.json').read_text(encoding='utf-8') diff --git a/freevideo_engine/comfy_share.py b/freevideo_engine/comfy_share.py index 0dd387a6..3421d247 100644 --- a/freevideo_engine/comfy_share.py +++ b/freevideo_engine/comfy_share.py @@ -60,6 +60,9 @@ def details(root, identity): plan = summary.get('sampling_plan') or {} result['sampling_plan'] = {k: v for k, v in plan.items() if k in ('enabled', 'base_steps', 'refine_steps') and type(v) in (int, bool)} + from .tiers import STEP_CACHES + if plan.get('step_cache') in STEP_CACHES[1:]: + result['sampling_plan']['step_cache'] = plan['step_cache'] result['id'] = identity result['graph'] = saved_graph(path) return result diff --git a/freevideo_engine/decode_prefetch.py b/freevideo_engine/decode_prefetch.py index aca8b572..1fbeb573 100644 --- a/freevideo_engine/decode_prefetch.py +++ b/freevideo_engine/decode_prefetch.py @@ -20,11 +20,32 @@ """ import json import os +import sys from pathlib import Path import threading import time -from .encoder_prewarm import CHUNK, GiB, available_memory +from .encoder_prewarm import CHUNK, GiB, available_memory as host_available_memory, cgroup_memory + + +def available_memory(): + """Memory the file cache may use for this read: physical RAM, not commit. + + Pages read through a reused buffer sit in the OS file cache. They are + reclaimable and add nothing to the commit charge, so on Windows the commit + headroom (which pinned weights and sampling may have used up) is no bound + here. The cgroup limit on Linux and the system reserve still apply. + """ + available, complete = host_available_memory() + try: + from .system import system_memory + physical = system_memory().get('physical_available_bytes') + except Exception: + physical = None + if sys.platform == 'win32' and type(physical) is int: + group = cgroup_memory().get('available_bytes') + available = physical if group is None else min(physical, group) + return available, complete def decoder_ranges(base): @@ -61,12 +82,30 @@ def __init__(self, base, *, ram_budget_bytes=None, budget_is_estimate=False): self.ram_budget_bytes = ram_budget_bytes self.budget_is_estimate = bool(budget_is_estimate) self.drive_idle = False + self.load_read_rate = None + self.total_bytes = None self.cancelled = threading.Event() self.thread = None self.result = dict(state='not-started', read_bytes=0, elapsed_seconds=0., gpu_allocation_bytes=0, private_buffer_bytes=CHUNK, scope='Decoder-only checkpoint pages; reclaimable OS cache, no GPU residency') + # Reading from the start of sampling matters only when the drive is too + # slow to finish in the last two steps. The rate is what the model load + # measured; unknown (a resident model read nothing) keeps the last two steps. + EARLY_MARGIN = 1.25 + + def due(self, remaining_seconds): + rate = self.load_read_rate + if self.thread is not None or not rate or rate <= 0 or remaining_seconds is None: + return False + if self.total_bytes is None: + try: + self.total_bytes = sum(hi - lo for _, spans, _ in decoder_ranges(self.base) for lo, hi in spans) + except Exception: + self.total_bytes = 0 + return self.total_bytes > 0 and self.total_bytes / rate * self.EARLY_MARGIN >= remaining_seconds + def start(self, *, drive_idle=False): if self.thread is not None: return diff --git a/freevideo_engine/diagnostic_resources.py b/freevideo_engine/diagnostic_resources.py index 7b9d3536..495137c7 100644 --- a/freevideo_engine/diagnostic_resources.py +++ b/freevideo_engine/diagnostic_resources.py @@ -47,6 +47,21 @@ TENSOR_VARIABLES = ('input', 'output', 'x', 'q', 'scale', 'indices', 'qdata', 'weight', 'mat1', 'mat2') +def commit_exhausted(error): + """Windows error 1455 anywhere in the chain: the system commit limit is spent. + + Rust-backed file readers raise a plain OSError carrying only the localized + system message plus "(os error 1455)", so the numeric attributes are unset. + """ + seen = set() + while isinstance(error, BaseException) and id(error) not in seen: + seen.add(id(error)) + if getattr(error, 'winerror', None) == 1455 or re.search(r'(?:os error|winerror)\s*1455\b', str(error), re.I): + return True + error = error.__cause__ or error.__context__ + return False + + def error_details(error): """Extract fixed classifications and numbers, never the exception message. @@ -59,7 +74,7 @@ def error_details(error): result.update(category='cuda_out_of_memory', error_code=2) elif 'CUDA out of memory' in message: result['category'] = 'torch_allocator_out_of_memory' - elif isinstance(error, MemoryError) or getattr(error, 'winerror', None) == 1455: + elif isinstance(error, MemoryError) or commit_exhausted(error): result['category'] = 'host_memory_allocation' elif 'CUDA error:' in message: result['category'] = 'cuda_error' @@ -466,7 +481,8 @@ def resources(value): 'first_budget_bytes', 'last_budget_bytes', 'budget_changes', 'over_budget_samples', 'samples') # Whole-device NVML samples inside one sampling step (monitoring.step_activity). STEP_ACTIVITY = ('samples', 'busy_samples', 'gpu_util_mean_percent', 'sm_clock_mean_mhz', 'power_mean_mw', - 'device_used_peak_bytes', 'device_used_minimum_bytes', 'device_total_bytes') + 'device_used_peak_bytes', 'device_used_minimum_bytes', 'device_total_bytes', + 'other_programs_checks', 'other_programs_percent_mean', 'other_programs_percent_peak') def dynamic_budget(value): diff --git a/freevideo_engine/effort_forecast.py b/freevideo_engine/effort_forecast.py index 31839066..159734bb 100644 --- a/freevideo_engine/effort_forecast.py +++ b/freevideo_engine/effort_forecast.py @@ -70,6 +70,7 @@ def timing_record(report, gpu_uuid, system, *, stage_details=True): refine = plan.get('refine_steps', 0) if plan.get('enabled') and not preview else 0 if measured[0]['steps'] != base or (refine and measured[1]['steps'] != refine): return None + cache = effective_step_cache(passes[0]) encoding = report.get('encoding') or {} cached = bool(encoding.get('cache_hit') or report.get('input_cache', {}).get('conditioning_hit') or report.get('tuning', {}).get('conditioning_cache_hit')) @@ -82,6 +83,9 @@ def timing_record(report, gpu_uuid, system, *, stage_details=True): task=video.get('config', {}).get('task', 't2va'), adapters=bool(report.get('profile', {}).get('engine', {}).get('loras') or report.get('lora') or video.get('config', {}).get('online_lora'))) + if cache is not None: + # Rows without the key are plain runs, as every run before the caches was. + record['step_cache'] = cache if not stage_details: return record upscale = video.get('latent_upscale') or {} @@ -131,14 +135,42 @@ def timing_record(report, gpu_uuid, system, *, stage_details=True): return record -def estimate(records, canvas, *, base_steps=8, refine_steps=3, two_pass=True, task='t2va', adapters=False): +def effective_step_cache(part): + """The step cache a first pass actually used, from its report; None for a plain pass.""" + from .tiers import STEP_CACHES + stats = part.get('step_cache') if isinstance(part, dict) else None + if not isinstance(stats, dict) or stats.get('disabled_reason') or stats.get('kind') not in STEP_CACHES: + return None + return stats['kind'] + + +def report_step_cache(report): + """The first-pass step cache a finished request used, from its report.""" + video = report.get('video') or {} if isinstance(report, dict) else {} + passes = video.get('sampling_passes') + return effective_step_cache(passes[0] if isinstance(passes, list) and passes else video) + + +def estimate(records, canvas, *, base_steps=8, refine_steps=3, two_pass=True, task='t2va', adapters=False, + step_cache=None, cache_share=None): + """Whole-request seconds from local runs of this shape. + + Runs with the requested step cache come first. Without one, plain runs stand + in with their first pass scaled by `cache_share` (tiers.sampling_share by + default) and a wider range; a cached run never stands in for a plain request. + """ from .two_pass import validate_steps + from .tiers import sampling_share validate_steps(base_steps, refine_steps, two_pass) + share = sampling_share(step_cache) if cache_share is None else cache_share candidates = [] for row in records: if (row.get('flow', 'full') != 'full' or row['task'] != task or row['adapters'] != adapters or row['shape'] != {k: canvas[k] for k in ('width', 'height', 'frames')}): continue + cache = row.get('step_cache') + if cache != step_cache and (cache is not None or row['two_pass'] != two_pass): + continue # Both passes retain their own resolution costs. Never multiply the # entire generation by a step ratio or confuse eight low-res steps # with eight full-res steps. Existing first-step warmup stays intact. @@ -152,7 +184,12 @@ def estimate(records, canvas, *, base_steps=8, refine_steps=3, two_pass=True, ta seconds = row['request_seconds'] - row['sampling_seconds'] + last['seconds'] + (base_steps - last['steps']) * last['incremental'] else: seconds = row['request_seconds'] + (base_steps - row['base_steps']) * row['passes'][0]['incremental'] - if two_pass and not switched: + if cache != step_cache: + # A plain run of the same plan: only its first pass changes with a cache. + first = row['passes'][0]['seconds'] + (base_steps - row['base_steps']) * row['passes'][0]['incremental'] + seconds -= (1 - share) * first + switched = True + if two_pass and row['two_pass'] == two_pass: seconds += (refine_steps - row['refine_steps']) * row['passes'][1]['incremental'] if positive(seconds): candidates.append((seconds, row, switched)) @@ -255,10 +292,13 @@ def _stage_forecast(records, request, cold_flags, prior): if positive(stages.get('decode_save')): found.append(('decode_save', stages['decode_save'])) similar_passes = row['two_pass'] == request.get('two_pass', True) + # A step cache changes the first pass only; its steps and totals stay with their own cache. + same_cache = row.get('step_cache') == request.get('step_cache') for i, (target, observed, count) in enumerate(zip((first, second), (r1, r2), counts), 1): near = _near(token_count(target), token_count(observed)) similar_passes = similar_passes and (near or not count) - if not (count and same_task and same_lora and near and i <= len(row['passes'])): + if not (count and same_task and same_lora and near and i <= len(row['passes']) + and (same_cache or i == 2)): continue part = row['passes'][i-1] seconds = part['seconds'] + (count-part['steps']) * part['incremental'] @@ -271,7 +311,7 @@ def _stage_forecast(records, request, cold_flags, prior): if similar_passes and same_task and same_lora and positive(stages.get('upscale')): found.append(('upscale', stages['upscale'])) if (request.get('flow', 'full') == row.get('flow', 'full') and similar_passes and same_task and same_lora - and same_refs): + and same_refs and same_cache): total = row['request_seconds'] + (counts[0] - row['base_steps']) * row['passes'][0]['incremental'] if counts[1]: total += (counts[1] - row['refine_steps']) * row['passes'][1]['incremental'] diff --git a/freevideo_engine/execution_config.py b/freevideo_engine/execution_config.py index 864d1397..77a326fa 100644 --- a/freevideo_engine/execution_config.py +++ b/freevideo_engine/execution_config.py @@ -18,7 +18,7 @@ 'window_batch': 1, 'linear_compute': 'native-fp8', 'fp8_gemm': 'auto', 'window_varlen': False, 'varlen_smooth_k': True, 'task': 't2va', 'stream_weights': False, 'head_parallelism': 1, 'residual_offload': False, - 'adaln_task': None, + 'adaln_task': None, 'step_cache': None, }) DECODER_DEFAULTS = MappingProxyType({ 'offload': False, 'prefetch': True, 'tile_group': False, diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index ed856ca2..bc5d26b6 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -7,10 +7,11 @@ from .long_paths import io_path, plain_text from .diagnostics import Redactor +from .tiers import STEP_CACHES, TIERS, on_mac, table_level STEP_OUTPUT = '\n\nFull step output:\n' -QUALITY_LEVELS = (('Light', '轻量'), ('Medium', '标准'), ('High', '精细'), ('Max', '极致')) +QUALITY_LEVELS = (('Light', '轻量'), ('Medium', '标准'), ('High', '精细'), ('Max', '极致')) # tiers.TIERS order IMAGE_PACK_FAILURES = { @@ -69,9 +70,11 @@ def sampling_table_details(value): and type(details['refine_steps']) is int and 0 <= details['refine_steps'] <= 31 and type(details['missing_bytes']) is int and details['missing_bytes'] > 0 and type(details['decimal_sizes']) is bool and isinstance(details['available'], list) - and all(type(i) is int and 0 <= i <= 3 for i in details['available'])): + and all(type(i) is int and 0 <= i <= 3 for i in details['available']) + and details.get('step_cache') in STEP_CACHES): return {key: details[key] for key in - ('base_steps', 'two_pass', 'refine_steps', 'missing_bytes', 'available', 'decimal_sizes')} + ('base_steps', 'two_pass', 'refine_steps', 'missing_bytes', 'available', 'decimal_sizes', 'step_cache') + if key in details} except (ValueError, KeyError, TypeError): pass return None @@ -83,9 +86,10 @@ def sampling_table_failure(value, *, zh=False): return None from .launcher_copy import size_text language = 1 if zh else 0 - index = next((i for i, steps in enumerate((8, 12, 16, 20)) - if details['base_steps'] == steps and details['two_pass'] == (i == 0) - and (i != 0 or details['refine_steps'] == 3)), None) + # Tables follow the step count; the cache tells high from max at 20 steps. + level = table_level(details['base_steps'], details['two_pass'], details['refine_steps'], + details.get('step_cache'), on_mac()) + index = list(TIERS).index(level) if level else None names = [('“%s”' if zh else '%s') % QUALITY_LEVELS[i][language] for i in sorted(set(details['available']))] available = '' if names: @@ -577,7 +581,7 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep ('Click Repair to reinstall a complete copy of PyTorch. Models and videos are kept.', '请点“修复”,FreeVideo 会重新安装运行组件,模型和视频都会保留。')), (r'ModuleNotFoundError|No module named|DLL load failed', 'dependencies', *DEPENDENCIES), - (r'CUDA (?:error: )?out of memory|torch\.OutOfMemoryError|Insufficient currently available memory|crossed its RAM budget|paging file is too small|WinError 1455', 'memory', + (r'CUDA (?:error: )?out of memory|torch\.OutOfMemoryError|Insufficient currently available memory|crossed its RAM budget|paging file is too small|WinError 1455|os error 1455', 'memory', ('Memory is unavailable for this step', '这一步可用内存不足'), ('Close other memory-heavy applications and retry. Copy the details if it continues.', '关闭占用内存较多的程序后重试;仍失败时可复制详情反馈。')), ) diff --git a/freevideo_engine/generate.py b/freevideo_engine/generate.py index 3d03c7d3..910120cf 100644 --- a/freevideo_engine/generate.py +++ b/freevideo_engine/generate.py @@ -180,6 +180,9 @@ def automatic_profile(args, canvas, *, stage, evidence, descriptor=None, environ planning_canvas = dict(canvas) if getattr(args, 'task', None): planning_canvas['task'] = args.task + if getattr(args, 'step_cache', None): + # The planner keeps the first-block cache only where it fits. + planning_canvas['step_cache'] = args.step_cache options.update(available_backends=available_backends(hardware, probe_missing=False), canvas=planning_canvas, demonstrated_ram_bytes=demonstrated_local_ram(hardware, args, canvas)) cache = getattr(args, 'cache', None) @@ -548,6 +551,8 @@ def usable_continuation(value, *check): base_steps = requested_steps if requested_steps is not None else profile['engine'].get('steps', 8) validate_steps(base_steps, refine_steps, two_pass) profile['engine']['steps'] = base_steps + if args.profile and getattr(args, 'step_cache', None): + profile['engine']['step_cache'] = args.step_cache if 'engine' in profile.get('policy', {}): profile['policy']['engine']['steps'] = base_steps if base_steps != 8: diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index a33471f6..f33881e1 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -949,7 +949,8 @@ def open_browser(self): # compatible; only the browser hint is added at launch time. try: if not open_browser(address): - raise OSError('The system did not accept the browser request') + raise OSError(self.t("Couldn't open the browser. Click Copy address and open it in your browser.", + '没能自动打开浏览器,请点“复制地址”,在浏览器里打开。')) if moved_from: self.port_notice_url = self.browser_url self.browser_error = '' diff --git a/freevideo_engine/monitoring.py b/freevideo_engine/monitoring.py index c7ea98f0..e0f60efb 100644 --- a/freevideo_engine/monitoring.py +++ b/freevideo_engine/monitoring.py @@ -181,20 +181,23 @@ def refresh(self): self.ours = self.ours or {pid for pid in (os.getpid(), self.pid) if pid} def check(self, util): - """Read once a second; count the reading when the worker is computing on a busy device.""" + """Read once a second; count the reading when the worker is computing on a busy device. + + Returns the other programs' share of the cores in percent for a reading that was counted, else None. + """ now = time.monotonic() if self.supported is False or now - self.checked < self.seconds: - return + return None self.checked = now found = self.read() if found is None: self.failures += 1 if self.supported is None and self.failures >= self.failures_allowed: self.supported = False - return + return None self.supported = True if not found or util is None or util < 50: - return + return None # A process we have not seen may be a child the worker just started. if now - self.refreshed >= self.refresh_seconds or any(pid not in self.seen for pid in found): self.refresh() @@ -207,12 +210,13 @@ def check(self, util): # device did beyond the other programs was the worker's. own = max(0., util - other) if own < self.own_percent: - return + return None self.checks += 1 self.shared += other >= self.share_percent self.other_total += other self.own_total += own self.other_peak = max(self.other_peak, other) + return other def result(self): result = dict(supported=bool(self.supported), own_reported=self.own_reported, checks=self.checks, @@ -433,7 +437,8 @@ def loop(self): with self.path.open('w', buffering=1, encoding='utf-8') as f: writer = csv.writer(f) writer.writerow(['epoch_seconds', 'monotonic_seconds', 'used_bytes', 'total_bytes', 'gpu_util_percent', - 'temperature_c', 'power_mw', 'sm_clock_mhz', 'memory_clock_mhz', 'clock_reason_mask']) + 'temperature_c', 'power_mw', 'sm_clock_mhz', 'memory_clock_mhz', 'clock_reason_mask', + 'other_programs_percent']) while not self.stop_event.is_set(): try: used, total, util = self.sample() @@ -449,8 +454,12 @@ def loop(self): self.max_temperature = max(self.max_temperature or 0, auxiliary[0]) if auxiliary[1] is not None: self.max_power_mw = max(self.max_power_mw or 0, auxiliary[1]) - writer.writerow([time.time(), time.monotonic(), used, total, util, *auxiliary, reasons]) - self.programs.check(util) + try: + other = self.programs.check(util) + except Exception: + other = None + writer.writerow([time.time(), time.monotonic(), used, total, util, *auxiliary, reasons, + '' if other is None else '%.1f' % other]) except Exception as e: if str(e) not in self.errors and len(self.errors) < 20: self.errors.append(str(e)) @@ -526,6 +535,13 @@ def number(sample, key): total = item['sums'].setdefault(key, [0., 0]) total[0] += value total[1] += 1 + other = number(sample, 'other_programs_percent') + if other is not None and other <= 100: + item['other_programs_checks'] = item.get('other_programs_checks', 0) + 1 + item['other_programs_percent_peak'] = max(item.get('other_programs_percent_peak', 0.), other) + total = item['sums'].setdefault('other_programs_percent_mean', [0., 0]) + total[0] += other + total[1] += 1 used, size = number(sample, 'used_bytes'), number(sample, 'total_bytes') if used is not None: item['device_used_peak_bytes'] = max(item.get('device_used_peak_bytes', 0), int(used)) diff --git a/freevideo_engine/offload.py b/freevideo_engine/offload.py index 123217fc..085db769 100644 --- a/freevideo_engine/offload.py +++ b/freevideo_engine/offload.py @@ -519,6 +519,36 @@ def at_boundary(self): """No layer pass is open, so the next pass can start; False after a failure inside one.""" return not (self.expected or self.pending or self.host_pending) + def end_pass_early(self): + """Finish a successful partial traversal after its last forward hook. + + Indices belong to this offloaded subset, not the transformer's blocks. + Block zero may be resident, so an already idle traversal is valid too. + Keep admitted weight caches and live reader pools for the next step. + """ + if self.closed: + raise RuntimeError('Cannot finish a closed offloader') + # _stage may still be submitting copies or filling an admitted cache. + # Join producers before waiting on the final events they recorded. + for future in self.pending.values(): + future.result() + for read in self.host_pending.values(): + if not read.future.cancel(): + read.future.result() + # A skipped prefetch has copy_done but no new compute_done. Likewise, + # the first block's compute may still read its slot after Python returns. + # Never reset/reuse either plane until both operations have completed. + for slot in [*self.slots, *self.cached.values(), *self.host_slots]: + for event in (slot.copy_done, slot.compute_done): + if event is not None: + event.synchronize() + if self.read_ahead is not None: + self.read_ahead.end_pass_early() + self.pending.clear() + self.host_pending.clear() + self.host_cursor = 0 + self.expected = 0 + def cache_between_steps(self, max_bytes, *, max_layers=None, allow_growth=True): """Retain affordable device planes until this sampling pass closes. diff --git a/freevideo_engine/packing.py b/freevideo_engine/packing.py index 236f4e27..816e4a5f 100644 --- a/freevideo_engine/packing.py +++ b/freevideo_engine/packing.py @@ -37,12 +37,25 @@ def install_streamed_forward(model, chunk=1024, residual_offload=False): def blocks(self, value, length, temb, adaln_indices, rotary): first = length not in collected collected.add(length) + cache = getattr(self, '_freevideo_step_cache', None) + cache = cache if cache is not None and not cache.disabled_reason and cache.block_level else None + if cache is not None and not isinstance(value, torch.Tensor): + cache.disabled_reason = 'residual_offload' + cache = None for index, block in enumerate(self.transformer_blocks): if first and index in (1, 2) and hasattr(value, 'collect_after_store'): value.collect_after_store = True + if cache is not None and index == 0: + cache.before_first_block(value) value = block(value, temb, adaln_indices, rotary) if first and index == 0 and not hasattr(value, 'collect_after_store'): gc.collect() + if cache is not None and index == 0 and cache.after_first_block(value): + if cache.offloader is not None: + cache.offloader.end_pass_early() + return value + if cache is not None: + cache.after_last_block(value) return value def forward(self, hidden_states, audio_hidden_states, encoder_hidden_states, @@ -55,6 +68,24 @@ def forward(self, hidden_states, audio_hidden_states, encoder_hidden_states, length = position_ids.shape[0] if position_ids.shape != (length, 3) or token_tags.shape != (length,) or timestep_indices.shape != (length,): raise ValueError('Invalid packed sequence geometry') + cache = getattr(self, '_freevideo_step_cache', None) + if cache is not None and residual_offload and cache.block_level: + # The first-block cache keeps packed residuals on the GPU; reusing + # a whole step (EasyCache) does not touch them. + cache.disabled_reason = 'residual_offload' + cache = cache if cache is not None and not cache.disabled_reason else None + sigmas = None + if cache is not None: + cache.begin(video_indices, audio_indices) + if not cache.block_level: + # H3 time is t = 1 - sigma; generated video rows come last. + sigmas = (1. - float(timestep[timestep_indices[video_indices[-1]]]), + 1. - float(timestep[timestep_indices[audio_indices[0]]])) + cached = cache.skip_forward(hidden_states[0], audio_hidden_states[0], sigmas) + if cached is not None: + video = cached[0][None].to(self.proj_out.weight.dtype) + audio = cached[1][None].to(self.audio_proj_out.weight.dtype) + return MiniMaxH3TransformerOutput(sample=video, audio_sample=audio) if return_dict else (video, audio) rotary = self.rope(position_ids) text = getattr(self, '_freevideo_refined_text', None) if text is None: @@ -95,6 +126,8 @@ def forward(self, hidden_states, audio_hidden_states, encoder_hidden_states, audio_all[:, start:start + chunk] = self.audio_proj_out(normalized) video = video_all.index_select(1, video_indices) audio = audio_all.index_select(1, audio_indices) + if sigmas is not None: + cache.record_forward(hidden_states[0], audio_hidden_states[0], sigmas, video[0], audio[0]) return MiniMaxH3TransformerOutput(sample=video, audio_sample=audio) if return_dict else (video, audio) model.forward = types.MethodType(forward, model) diff --git a/freevideo_engine/policy.py b/freevideo_engine/policy.py index 8636924d..faa72396 100644 --- a/freevideo_engine/policy.py +++ b/freevideo_engine/policy.py @@ -767,10 +767,74 @@ def adaln_extra_bytes(canvas=None): return max(0, steps - 8) * times_per_step * 50 * 3 * 6 * 5376 * 2 -def choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto', - gpu_reserve_gib=None, ram_reserve_gib=None, available_backends=None, canvas=None, - demonstrated_ram_bytes=None, allow_capacity_trial=False, stage='generation', - lora_max_block_bytes=0, lora_root_bytes=0, precision='fp8'): +STEP_CACHES = ('easycache-av', 'fbcache') +# Engine choices the first-block cache may change. B200 at 24 GiB, 20 steps with the +# cache against without: 8 resident blocks given up 297 s vs 396 s sampling (20 s), +# head chunk 16 -> 8 368 s vs 439 s, 28 -> 16 149-166 s vs 184-200 s (10 s). Host pins +# follow the GPU placement. Anything else (prefetch, attention outputs on the CPU, ...) +# costs more than the skipped steps save, and the request runs every step instead. +FIRST_BLOCK_CACHE_MAY_CHANGE = ('pin_host_gb', 'resident_blocks', 'head_chunk') +# Reserved growth over the counted video rows: 3.34 GiB measured / 2.87 GiB counted (B200, 20 s 1344x768). +FIRST_BLOCK_CACHE_RESERVED_SCALE = 1.17 + + +def first_block_cache_bytes(canvas): + """GPU storage the first-block cache keeps through a sampling pass. + + It holds two packed residuals of the last full step: block 0's output and + the remaining blocks' contribution, BF16 rows of 5376 channels. At 20 s of + 1344x768 the video rows are 2 x 1.43 GiB; B200 measured 2.98 GiB more + allocated and 3.34 GiB more reserved than the same request without it (text + and audio rows, allocator rounding), so the rows are scaled to the reserved + growth. + """ + from .two_pass import placement_tokens + return math.ceil(2 * placement_tokens(canvas) * 5376 * 2 * FIRST_BLOCK_CACHE_RESERVED_SCALE) + + +def choose(hardware: Hardware, *, canvas=None, stage='generation', **options): + """Placement for a request; canvas['step_cache'] asks for a step cache. + + The first-block cache is kept when reserving its storage only gives up + resident blocks or at most halves the head chunk; otherwise the request + samples every step in full. EasyCache keeps only latent-sized tensors and + is always kept. + """ + wanted = (canvas or {}).get('step_cache') if stage == 'generation' else None + if wanted is None: + plain = canvas if canvas is None or 'step_cache' not in canvas else { + key: value for key, value in canvas.items() if key != 'step_cache'} + return _choose(hardware, canvas=plain, stage=stage, **options) + if wanted not in STEP_CACHES: + raise ValueError('Step cache must be one of ' + ', '.join(STEP_CACHES)) + from dataclasses import replace + plain = {key: value for key, value in canvas.items() if key != 'step_cache'} + base = _choose(hardware, canvas=plain, stage=stage, **options) + kept, note = True, None + if wanted == 'fbcache': + try: + cached = _choose(hardware, canvas=plain, stage=stage, step_cache_bytes=first_block_cache_bytes(plain), **options) + except ResourceBudgetError: + cached = None + route = lambda engine: {key: value for key, value in engine.items() if key not in FIRST_BLOCK_CACHE_MAY_CHANGE} + kept = (cached is not None and route(cached.engine) == route(base.engine) + and 2 * (cached.engine.get('head_chunk') or 0) >= (base.engine.get('head_chunk') or 0)) + note = ('First-block cache keeps %.2f GiB of residuals in free GPU memory.' if kept else + 'First-block cache needs %.2f GiB the placement does not leave free; every step runs in full.' + ) % (first_block_cache_bytes(plain) / GiB) + if kept: + # Decoding starts after the pass has released the cache. + base = replace(base, engine=cached.engine) + # Without a cache the engine options stay exactly those of a plain request, + # so its history and identities still match. + return replace(base, engine=dict(base.engine, step_cache=wanted) if kept else base.engine, + notes=list(base.notes) + ([note] if note else [])) + + +def _choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto', + gpu_reserve_gib=None, ram_reserve_gib=None, available_backends=None, canvas=None, + demonstrated_ram_bytes=None, allow_capacity_trial=False, stage='generation', + lora_max_block_bytes=0, lora_root_bytes=0, precision='fp8', step_cache_bytes=0): """precision is the prepared cache's: int8 caches run int8 projections.""" from .geometry import geometry if stage not in ('encoding', 'generation'): @@ -791,7 +855,7 @@ def choose(hardware: Hardware, *, vram_gib=None, ram_gib=None, attention='auto', lora_workspace = (128 * 2**20 + 2 * lora_max_block_bytes + lora_root_bytes if lora_max_block_bytes or lora_root_bytes else 0) table_workspace = adaln_extra_bytes(canvas) if stage == 'generation' else 0 - placement_workspace = lora_workspace + table_workspace + placement_workspace = lora_workspace + table_workspace + step_cache_bytes if canvas is not None: checked = geometry(canvas['width'], canvas['height'], frames=canvas['frames']) if any(canvas.get(k) != checked[k] for k in ('width', 'height', 'frames', 'video_tokens')): diff --git a/freevideo_engine/release_notes.json b/freevideo_engine/release_notes.json index 78f7589d..add3d221 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,41 +1,78 @@ { "schema": 1, - "product_version": "0.4.8", + "product_version": "0.4.9", "en": { - "summary": "Long videos keep a steadier voice from part to part, and continuing a video keeps your reference pictures and characters. LoRA strength can be typed up to 100, large downloads move to a faster source on their own, and the second-pass button uses the earlier wording again, with smaller fixes to uploads, your creations, the waiting queue, diagnostic reports and the interface.", + "summary": "Quality levels are reworked: Medium is one pass of 8 steps, and High and Max use step caches to finish much sooner; decoding on Windows reads ahead earlier on slow drives, and the editor handles ranges, splits and text tracks more reliably.", "changes": [ - "Second pass: the result button, preview badge, library and delete messages use the 0.4.6 wording again (Run second pass, First-pass preview).", - "Long videos: the voice keeps the same loudness and fullness from part to part, and with dialogue a continued part no longer opens with a second or two of unclear speech. Continuing a video from the creative panel keeps your reference pictures and characters, so the person stays the same; up to 3 pictures are used.", - "Characters: two @names written back to back are both recognised, the list no longer reopens after a finished name, and Save in the character library says what is still missing. An @character written in the editing view's generation bar now carries that character's description and pictures, as it does in the creation panel.", - "LoRA: you can type a strength beyond -4 to 4, up to 100, and a note marks values outside the usual range; the slider and default strength are unchanged. A trigger word that only appears inside a longer word (sks in asks) is now added.", - "Image generation: an out-of-memory failure says where memory ran out and at which size, and the progress line shows the smaller size while retrying; if the background process exits right after starting, it is retried once. Clicking Update while the image model is downloading pauses the download (what is already downloaded is kept and can be resumed) and starts the update at once. The option to install the image model as well checks free space on the drive that holds the models and is not offered when there is not enough room.", - "Downloads: large model downloads check the other sources while a transfer is slow and move to a clearly faster one, resuming the same file; a transfer that stops moving for 30 seconds changes source as well. The missing-model hint names each download and its size, and is checked again after a generation ends.", - "Media uploads: the upload buttons list only the file types FreeVideo can use, and unsupported files in a multiple selection are skipped. Files over 2 GiB are turned away in the page, a failed or interrupted upload no longer leaves a partial file, and upload errors appear in your language.", - "Your creations: an interrupted share or export no longer blocks deleting the creation, and its leftover working copy is removed at the next start. Adding the workflow to earlier videos no longer moves them to the top or changes their date.", - "Waiting queue: each task shows the start of its prompt, and Move to front highlights the moved task and confirms it.", - "Diagnostic report: the prompt-rewrite report no longer replaces the video's, the same file is saved and sent, and a large report is saved as a zip.", - "Wording: Agent chat, Settings, image generation and editor messages are shorter and say what to do next; the character library uses \"character\" throughout and its view labels are clearer.", - "Interface details: advanced node settings show Chinese names in the Chinese interface; generation cards in chat show a single progress figure; the Downloads card in Settings is tidier; Export in the editor keeps the primary colour and the Text and Subtitles tools use line icons; narrow windows lay out the preview, generate buttons, library title and notices better and keep Feedback in the header; long prompts no longer run under the @ button; library times follow the interface language; a result video that fails to load no longer offers actions that cannot work." + "Quality levels: Medium is one pass of 8 steps; High and Max run 20 steps with step caches and finish much sooner than plain 20 steps.", + "Decoding on Windows: read-ahead is sized by physical memory and starts earlier on slow drives, so the end of a run waits less for the disk.", + "When Windows runs out of virtual memory, in any system language, FreeVideo now retries the generation automatically in a fresh process and reports a memory shortage if it still fails.", + "The progress estimate counts reference images, so requests with reference images show a closer total time from the start.", + "Editor: dragging a range's left edge to the start of the timeline no longer pushes its right edge later; splitting a sped-up or reversed part keeps the fade-in only on the first half and the fade-out only on the second.", + "Editor: drag-selecting parts is smooth again on timelines with many parts.", + "Editor text: clicking the side Content box right after pressing T keeps the new text track, and dropping text onto a locked text track says the track is locked.", + "Long videos: each part's description can also be edited in a larger window.", + "Chat: when a conversation is full, the notice appears once under the message and its button starts a new chat instead of retrying.", + "Export: the sizes offered in the export dialog are rounded the same way as the saved work.", + "Diagnostic reports record how much of the graphics card other programs were using during each sampling step.", + "Launcher: when the browser cannot be opened automatically, the message says to click Copy address and open it in a browser, in Chinese as well. Canvas margin labels stay readable and aligned. Nodes: width, height, duration and seed have Chinese labels." ] }, "zh": { - "summary": "长视频各段人声更一致,接着生成时保留参考图和角色。LoRA 强度可以手动输入到 100,大文件下载会自动换到更快的下载源,二次采样按钮恢复此前的说法;上传、作品库、等待队列、诊断报告和界面细节也有多处改进。", + "summary": "质量档位调整:标准为单遍 8 步,精细和极致用步间缓存,快得多;Windows 解码在慢硬盘上更早预读;编辑器的区间、切分和文字轨更可靠。", "changes": [ - "二次采样:结果卡按钮、预览角标、作品库和删除提示恢复 0.4.6 的说法(继续二采、一采预览)。", - "长视频:人声的音量和厚度在各段之间保持一致;有对话时,接续的段开头不再先冒出一两秒听不清的话。在创作面板接着生成视频时,会保留参考图和角色,人物前后保持一致,最多使用 3 张图片。", - "角色:紧挨着写的两个 @名字都能识别,写完名字后列表不再重新弹出,角色库保存按钮灰着时会说明还缺什么;剪辑视图的生成条里写 @角色,也会和创作面板一样带上该角色的描述和图片。", - "LoRA:强度可以手动输入超出 -4 到 4 的数,最大到 100,超出常用范围时有提示;滑块和默认强度不变。触发词只出现在更长的单词里(如 asks 里的 sks)时,现在也会自动加上。", - "图像生成:显存不够时写明在哪一步、哪个尺寸,自动缩小重试时显示新尺寸;后台进程刚启动就退出时自动重试一次。图像模型在后台下载时点“更新”,下载先停下(已下的部分保留,之后可以继续),更新马上开始;“同时安装图像模型”按模型所在的盘检查空间,空间不够时不再显示为可选。", - "下载:大文件下载变慢时会测试其他下载源,明显更快时自动换源并接着下载;停滞 30 秒也会换源。缺模型提示写明是哪几项、各多大,生成结束后重新检查。", - "素材上传:上传按钮只列出能用的文件格式,一次选多个时跳过不支持的;超过 2 GiB 的文件在页面上直接拦下;上传失败或中断不再留下残留文件,出错提示用当前界面语言。", - "作品库:分享或导出中断后留下的临时文件不再挡住删除,下次启动时清理;给早期视频添加工作流后,它们不再跳到最前,日期保持原样。", - "等待队列:每条任务显示提示词开头;“排到最前”后被移动的任务会高亮并提示。", - "诊断报告:提示词增强不再顶替视频的报告,保存和提交是同一份,较大的报告存成 zip。", - "文字:Agent 对话、设置、图像生成和编辑器里的提示更简短,并写明下一步该点哪个按钮;角色库统一称“角色”,视角标签更清楚。", - "界面细节:中文界面的生成节点高级参数显示中文名;对话里的生成卡只显示一个进度;设置“下载”卡更整齐;剪辑视图导出按钮保持主色,文字、字幕工具改用线性图标;窄窗口下预览、生成按钮、作品库标题和提示的排布更合理,顶栏仍保留“反馈”入口;长提示词不再被“@”按钮压住;作品卡片时间跟随界面语言;结果视频加载失败时不再显示无法使用的操作。" + "质量档位:标准为单遍 8 步;精细和极致为 20 步加步间缓存,比纯 20 步快得多。", + "Windows 解码:预读按物理内存决定大小,并在慢硬盘上更早开始,生成收尾时等硬盘的时间更短。", + "Windows 虚拟内存耗尽时,不论系统语言,FreeVideo 现在会在新进程中自动重试生成,仍失败时提示内存不足。", + "进度估计把参考图计入,带参考图的请求一开始就显示更接近实际的总时间。", + "编辑器:把区间左边拖到时间线开头,右边不再被往后推;切分加速或倒放的片段时,淡入只留在前半段,淡出只留在后半段。", + "编辑器:片段很多时,框选重新变得流畅。", + "编辑器文字:按 T 后马上点侧边“内容”框,新文字轨会保留;把文字拖到已锁定的文字轨上会提示该轨已锁定。", + "长视频:每段描述也能在放大窗口里编辑。", + "对话:对话已满时,提示只在消息下出现一次,按钮换成“新对话”。", + "导出:导出窗口里给出的尺寸和保存的作品用同样的方式取整。", + "诊断报告记录每一步采样时其他程序占用显卡的比例。", + "启动器:浏览器没能自动打开时,提示点“复制地址”到浏览器里打开,中文界面也显示中文。画布边距标签更清楚、对齐。节点:宽度、高度、时长和种子有中文标签。" ] }, "history": [ + { + "product_version": "0.4.8", + "en": { + "summary": "Long videos keep a steadier voice from part to part, and continuing a video keeps your reference pictures and characters. LoRA strength can be typed up to 100, large downloads move to a faster source on their own, and the second-pass button uses the earlier wording again, with smaller fixes to uploads, your creations, the waiting queue, diagnostic reports and the interface.", + "changes": [ + "Second pass: the result button, preview badge, library and delete messages use the 0.4.6 wording again (Run second pass, First-pass preview).", + "Long videos: the voice keeps the same loudness and fullness from part to part, and with dialogue a continued part no longer opens with a second or two of unclear speech. Continuing a video from the creative panel keeps your reference pictures and characters, so the person stays the same; up to 3 pictures are used.", + "Characters: two character names typed back to back after the @ key are both recognised, the list no longer reopens after a finished name, and Save in the character library says what is still missing. A character mentioned with @ in the editing view's generation bar now carries that character's description and pictures, as it does in the creation panel.", + "LoRA: you can type a strength beyond -4 to 4, up to 100, and a note marks values outside the usual range; the slider and default strength are unchanged. A trigger word that only appears inside a longer word (sks in asks) is now added.", + "Image generation: an out-of-memory failure says where memory ran out and at which size, and the progress line shows the smaller size while retrying; if the background process exits right after starting, it is retried once. Clicking Update while the image model is downloading pauses the download (what is already downloaded is kept and can be resumed) and starts the update at once. The option to install the image model as well checks free space on the drive that holds the models and is not offered when there is not enough room.", + "Downloads: large model downloads check the other sources while a transfer is slow and move to a clearly faster one, resuming the same file; a transfer that stops moving for 30 seconds changes source as well. The missing-model hint names each download and its size, and is checked again after a generation ends.", + "Media uploads: the upload buttons list only the file types FreeVideo can use, and unsupported files in a multiple selection are skipped. Files over 2 GiB are turned away in the page, a failed or interrupted upload no longer leaves a partial file, and upload errors appear in your language.", + "Your creations: an interrupted share or export no longer blocks deleting the creation, and its leftover working copy is removed at the next start. Adding the workflow to earlier videos no longer moves them to the top or changes their date.", + "Waiting queue: each task shows the start of its prompt, and Move to front highlights the moved task and confirms it.", + "Diagnostic report: the prompt-rewrite report no longer replaces the video's, the same file is saved and sent, and a large report is saved as a zip.", + "Wording: Agent chat, Settings, image generation and editor messages are shorter and say what to do next; the character library uses \"character\" throughout and its view labels are clearer.", + "Interface details: advanced node settings show Chinese names in the Chinese interface; generation cards in chat show a single progress figure; the Downloads card in Settings is tidier; Export in the editor keeps the primary colour and the Text and Subtitles tools use line icons; narrow windows lay out the preview, generate buttons, library title and notices better and keep Feedback in the header; long prompts no longer run under the @ button; library times follow the interface language; a result video that fails to load no longer offers actions that cannot work." + ] + }, + "zh": { + "summary": "长视频各段人声更一致,接着生成时保留参考图和角色。LoRA 强度可以手动输入到 100,大文件下载会自动换到更快的下载源,二次采样按钮恢复此前的说法;上传、作品库、等待队列、诊断报告和界面细节也有多处改进。", + "changes": [ + "二次采样:结果卡按钮、预览角标、作品库和删除提示恢复 0.4.6 的说法(继续二采、一采预览)。", + "长视频:人声的音量和厚度在各段之间保持一致;有对话时,接续的段开头不再先冒出一两秒听不清的话。在创作面板接着生成视频时,会保留参考图和角色,人物前后保持一致,最多使用 3 张图片。", + "角色:紧挨着写的两个 @名字都能识别,写完名字后列表不再重新弹出,角色库保存按钮灰着时会说明还缺什么;剪辑视图的生成条里写 @角色,也会和创作面板一样带上该角色的描述和图片。", + "LoRA:强度可以手动输入超出 -4 到 4 的数,最大到 100,超出常用范围时有提示;滑块和默认强度不变。触发词只出现在更长的单词里(如 asks 里的 sks)时,现在也会自动加上。", + "图像生成:显存不够时写明在哪一步、哪个尺寸,自动缩小重试时显示新尺寸;后台进程刚启动就退出时自动重试一次。图像模型在后台下载时点“更新”,下载先停下(已下的部分保留,之后可以继续),更新马上开始;“同时安装图像模型”按模型所在的盘检查空间,空间不够时不再显示为可选。", + "下载:大文件下载变慢时会测试其他下载源,明显更快时自动换源并接着下载;停滞 30 秒也会换源。缺模型提示写明是哪几项、各多大,生成结束后重新检查。", + "素材上传:上传按钮只列出能用的文件格式,一次选多个时跳过不支持的;超过 2 GiB 的文件在页面上直接拦下;上传失败或中断不再留下残留文件,出错提示用当前界面语言。", + "作品库:分享或导出中断后留下的临时文件不再挡住删除,下次启动时清理;给早期视频添加工作流后,它们不再跳到最前,日期保持原样。", + "等待队列:每条任务显示提示词开头;“排到最前”后被移动的任务会高亮并提示。", + "诊断报告:提示词增强不再顶替视频的报告,保存和提交是同一份,较大的报告存成 zip。", + "文字:Agent 对话、设置、图像生成和编辑器里的提示更简短,并写明下一步该点哪个按钮;角色库统一称“角色”,视角标签更清楚。", + "界面细节:中文界面的生成节点高级参数显示中文名;对话里的生成卡只显示一个进度;设置“下载”卡更整齐;剪辑视图导出按钮保持主色,文字、字幕工具改用线性图标;窄窗口下预览、生成按钮、作品库标题和提示的排布更合理,顶栏仍保留“反馈”入口;长提示词不再被“@”按钮压住;作品卡片时间跟随界面语言;结果视频加载失败时不再显示无法使用的操作。" + ] + } + }, { "product_version": "0.4.7", "en": { @@ -194,39 +231,6 @@ "图像生成模型现在可以正常下载和安装。" ] } - }, - { - "product_version": "0.4.2", - "en": { - "summary": "Adds optional image generation models: generate and edit pictures, make several views for character cards, and let the agent use them. Also fixes several install and launcher issues.", - "changes": [ - "Adds optional image generation models. \"Generate with AI\" in the media panel makes a picture from one sentence on a blank canvas, and \"Edit this\" in the image editor changes the current picture, with up to two more pictures from your media as references.", - "With the image generation models installed, character cards start from a front view and use it as the reference for three-quarter, side, back and close-up views; scene cards are made the same way.", - "The agent can generate or edit pictures and add them to your media, and can first make a frame with people placed as described, then generate the video from it.", - "Setup can install the image generation models at the same time (about 16 GB), and existing installs can download them in the background from the launcher; the download pauses while a video is generating.", - "Online installs into a folder the system code page cannot spell, such as a Chinese folder on an English system, no longer fail at the kernel check.", - "On Linux, when the system compiler is newer than the installed GPU toolkit supports, setup now uses a compatible compiler instead of stopping while building the GPU attention component.", - "When a Windows security feature blocks files FreeVideo needs, the error card now says so and notes that the setting may need to be changed in Windows.", - "After a launcher error, an address or path being typed in the settings is no longer reset to its previous value.", - "When the agent makes a film, a shot that continues the previous one is at least 10 seconds long, 10 seconds by default; a length this computer cannot make in one run is reported before generation starts.", - "In AI Autopilot, the conversation list updates as soon as a job starts or stops, and switching conversations quickly stays on the one selected." - ] - }, - "zh": { - "summary": "新增可选的图像生成模型,可以生成和修改图片,为角色卡生成多个角度,Agent 也能使用;并修复了几处安装和启动器的问题。", - "changes": [ - "新增可选的图像生成模型。素材面板的“AI 生成”可以在空白画布上用一句话生成一张图;图片编辑器里的“改这张”可以修改当前图片,还可以再选最多两张素材图作参考。", - "装有图像生成模型时,角色卡先生成正面像,再以它为参考生成四分之三侧面、侧面、背面和脸部特写;场景卡也用它生成。", - "Agent 可以生成或修改图片并放进素材,也可以先按描述的人物站位生成一张首帧,再用它生成视频。", - "安装时可以选择同时安装图像生成模型(约 16 GB);已经安装 FreeVideo 的,可以在启动器里下载,下载在后台进行,生成视频时自动暂停。", - "英文系统上在线安装到中文文件夹时,内核检查不再因为路径无法识别而失败。", - "Linux 上系统编译器比 GPU 工具包支持的版本更新时,安装会换用兼容的编译器,不再停在编译 GPU 注意力加速组件这一步。", - "Windows 的安全功能拦下运行所需的文件时,错误卡会说明原因,并提示可能需要在 Windows 中调整这项设置。", - "启动器出错后,设置里正在输入的地址或路径不再被换回原值。", - "Agent 拍片时,接上一镜的镜头每段至少 10 秒,没写长度时默认 10 秒;这台电脑一次生成不了的长度会在开始前说明。", - "“AI 全自动”里任务开始或停止后,对话列表立刻更新;快速切换对话时停在所选的那一段。" - ] - } } ] } diff --git a/freevideo_engine/resident_worker.py b/freevideo_engine/resident_worker.py index ebba34ff..14192674 100644 --- a/freevideo_engine/resident_worker.py +++ b/freevideo_engine/resident_worker.py @@ -79,7 +79,7 @@ def run(message): if diagnostics is not None: diagnostics.finish(False) traceback.print_exc() - from .adaptive import classify_failure, cuda_runtime_error + from .adaptive import classify_failure, commit_exhausted, cuda_runtime_error failure = classify_failure(error) if request.get('metrics'): from .monitoring import save @@ -112,6 +112,13 @@ def run(message): if failure['kind'] == 'cuda_error' or cuda_runtime_error(error): log.flush() os._exit(74) + # An exhausted Windows commit limit includes what this process + # still holds, notably resident models charged by WDDM. Cleanup + # cannot be trusted to return it, and every later request would + # fail the same way. A fresh worker starts from zero. + if commit_exhausted(error): + log.flush() + os._exit(75) failed_error = error # ModelBank cannot own tensors held by an exception's traceback. diff --git a/freevideo_engine/resource_history.py b/freevideo_engine/resource_history.py index 62ca498c..831f7b2d 100644 --- a/freevideo_engine/resource_history.py +++ b/freevideo_engine/resource_history.py @@ -89,6 +89,9 @@ def _canonical_config(config, purpose): # The ordinary task's schedule has the same historical computation identity. if resolved_engine.get('adaln_task') in (None, engine.get('task', 't2va')): resolved_engine.pop('adaln_task', None) + # Runs without a step cache keep their historical identity; a cache changes the arithmetic. + if resolved_engine.get('step_cache') is None: + resolved_engine.pop('step_cache', None) if resolved_decoder.get('continuation') is None: resolved_decoder.pop('continuation', None) normalized_engine = _computation(resolved_engine) diff --git a/freevideo_engine/runtime.py b/freevideo_engine/runtime.py index 6bc32980..3c21b356 100644 --- a/freevideo_engine/runtime.py +++ b/freevideo_engine/runtime.py @@ -62,7 +62,9 @@ def __init__(self, cache, *, base=None, checkpoint=None, fp8_gemm='auto', window_varlen=False, varlen_smooth_k=True, task='t2va', stream_weights=False, canvas=None, head_parallelism=1, residual_offload=False, device_backend=None, gpu_allocation_bytes=None, - fetch_tables=True, adaln_task=None): + fetch_tables=True, adaln_task=None, step_cache=None): + from .step_cache import requested + requested(step_cache) # Validate the effective selection before loading weights. self.device_backend = device_backend if device_backend is not None else get_backend() if gpu_allocation_bytes is not None and (type(gpu_allocation_bytes) is not int or gpu_allocation_bytes < 0): raise ValueError('GPU allocation limit must be a nonnegative byte count') @@ -519,6 +521,8 @@ def prepare_streamed(block, index, unread=False): 'window_varlen': window_varlen, 'varlen_smooth_k': varlen_smooth_k if window_varlen else None, 'steps': steps, 'source_id': manifest['source_id']} + if step_cache is not None: + self.config['step_cache'] = step_cache if self.adaln_task != self.task: self.config['adaln_task'] = self.adaln_task @@ -776,6 +780,15 @@ def append(self, value): step_callback(value) if active_steps > 1 and len(self) == prefetch_step: trigger_prefetch() + elif active_steps > 1 and 1 < len(self) < prefetch_step and owner.drive_idle: + # A drive too slow to read the decoder in the last two steps + # starts sooner. The first step carries compilation, so the + # remaining time comes from the steps after it. + due = getattr(final_step_callback, 'due', None) + if due is not None and prefetch_timing['trigger_step'] is None: + steady = list(self)[1:] + if due(sum(steady) / len(steady) * (active_steps - len(self))): + trigger_prefetch() owner = self step_seconds = StepTimes() @@ -833,9 +846,18 @@ def append(self, value): frame_preview.observing(latent_frames=canvas.get('latent_frames'), frame_h=height // 32, frame_w=width // 32, step_offset=progress_offset, total=progress_total if progress_total is not None else active_steps): - latents, audio = generate_latents(self.transformer, prompt, tags, frames, - self.steps, seed, self.device_backend.device, step_seconds=step_seconds, - conditions=conditions, **sampling_kwargs) + from .step_cache import begin_pass, end_pass + if not refining: + disabled = ('residual_offload' if self.config.get('residual_offload') and self.config.get('step_cache') == 'fbcache' else + 'streamed_forward_unavailable' if not self.config.get('projection_chunk') else None) + begin_pass(self.transformer, active_steps, self.config.get('step_cache'), + offloader=offloader if self.offload_layers else None, disabled_reason=disabled) + try: + latents, audio = generate_latents(self.transformer, prompt, tags, frames, + self.steps, seed, self.device_backend.device, step_seconds=step_seconds, + conditions=conditions, **sampling_kwargs) + finally: + step_cache = end_pass(self.transformer) if prefix is not None: latents, audio = prefix.finish(latents, audio) stats = offloader.stats() @@ -858,7 +880,7 @@ def append(self, value): 'head_execution': dict(requested_parallelism=self.config.get('head_parallelism', 1), **{name: getattr(self.attention, name, 0) - count for name, count in parallel_before.items()}), - 'text_refinement': refinement, + 'text_refinement': refinement, 'step_cache': step_cache, 'torch_peak_allocated_bytes': self.device_backend.max_memory_allocated(), 'torch_peak_reserved_bytes': self.device_backend.max_memory_reserved()} if watch is not None: diff --git a/freevideo_engine/sampling_assets.py b/freevideo_engine/sampling_assets.py index 22988572..c8609881 100644 --- a/freevideo_engine/sampling_assets.py +++ b/freevideo_engine/sampling_assets.py @@ -80,7 +80,9 @@ def base_ready(steps): not refinement(t) and len(t['identity']['timesteps']) == steps and ready(t) for t in task_tables) refine_ready = any(refinement(t) and ready(t) for t in task_tables) - return [i for i, steps in enumerate((8, 12, 16, 20)) if base_ready(steps) and (i != 0 or refine_ready)] + from .tiers import table + return [i for i, tier in enumerate(table(sys.platform == 'darwin').values()) + if base_ready(tier['steps']) and (not tier['two_pass'] or refine_ready)] @plain_errors @@ -380,7 +382,8 @@ def moved(current, size, speed, **_): details = dict(base_steps=sampling['base_steps'], two_pass=bool(sampling.get('enabled')), refine_steps=sampling.get('refine_steps', 0), missing_bytes=total, available=available_levels(cache, shared, manifest, kind, catalog), - decimal_sizes=sys.platform == 'darwin') + decimal_sizes=sys.platform == 'darwin', + **({'step_cache': sampling['step_cache']} if sampling.get('step_cache') else {})) raise SamplingTableUnavailable(details, failure.cause) from failure.cause if failure is not None: raise failure diff --git a/freevideo_engine/stage_estimate.py b/freevideo_engine/stage_estimate.py index 5b5dded9..752bd6dd 100644 --- a/freevideo_engine/stage_estimate.py +++ b/freevideo_engine/stage_estimate.py @@ -20,6 +20,11 @@ from statistics import median TOKEN_UNIT = 72576. +# Every generated query attends to the reference rows, so a reference video +# row costs one token plus this many more per canvas-sized unit of tokens +# (B200: linear in the row count, 1.4x at 960x544 and 1.85x at 1664x960); +# canvases past the measured size keep the largest measured weight. +REFERENCE_VIDEO_PAIR_WEIGHT = 4. DEFAULT_INTERVAL = [.45, 2.2] STAGES = ('encode', 'load', 'pass1', 'upscale', 'pass2', 'decode_save') PARAMS_PATH = Path(__file__).with_name('stage_estimate_params.json') @@ -179,13 +184,19 @@ def stream_features(h, r, i): return [1.] + tiers + [f*laptop] +def _reference_rows(h, r, x): + # Measured on NVIDIA only; the Apple fit keeps one row per reference token. + weight = 1. if _apple(h) else 1. + REFERENCE_VIDEO_PAIR_WEIGHT*min(x, 1.) + return (weight*float(_num(r, 'reference_video_tokens', 0.)) + + float(_num(r, 'reference_audio_tokens', 0.))) + + def step_parts(h, r, i, p): canvas = canvases(r)[i-1] x = token_count(canvas)/TOKEN_UNIT # Reference tokens affect the actual attention sequence (policy.py:697). # No guessed duration is substituted when unavailable. - ref = float(_num(r, 'reference_video_tokens', 0.)) + float(_num(r, 'reference_audio_tokens', 0.)) - x += ref/TOKEN_UNIT + x += _reference_rows(h, r, x)/TOKEN_UNIT if _apple(h): b, c = p['apple']['step_bc'] return p['apple']['step_fixed'], x*(b+c*x) @@ -313,8 +324,8 @@ def _area(r): def _physical_transfer(h, r, p, measured, coef): b = p['step']['bc_normalized'][0] c, ss = coef[0], coef[1:] - ref = float(_num(r, 'reference_video_tokens', 0.)) + float(_num(r, 'reference_audio_tokens', 0.)) - x = [(token_count(g)+ref)/TOKEN_UNIT for g in canvases(r)[:2]] + x = [token_count(g)/TOKEN_UNIT for g in canvases(r)[:2]] + x = [v+_reference_rows(h, r, v)/TOKEN_UNIT for v in x] s = [_dot(stream_features(h, r, i), ss) for i in [1, 2]] if _apple(h): s1, comp1 = step_parts(h, r, 1, p) @@ -435,12 +446,19 @@ def predict_prior(hardware, request, cold_flags, params=None): video_decode is a diagnostic SUBSET of decode: never add it to total. Input hardware RAM/VRAM use GiB. pcie_bandwidth_gbs is decimal GB/s. - request uses schema names plus optional pass1_resident_blocks, reference_seconds. + request uses schema names plus optional pass1_resident_blocks, reference_seconds, + step_cache (the effective first-pass cache). Cold flags may include pass1/2_canvas_compiled and pass1/2_compile_cold. Unknown flags must be None, never inferred from future observed first steps. """ p=_params(params) v,d=_raw(hardware,request,cold_flags or {},p) + if request.get('step_cache'): + # The fit has only plain passes; a step cache shortens the first pass alone. + from .tiers import sampling_share + d['step_cache_share']=sampling_share(request['step_cache']) + v['pass1']*=d['step_cache_share'] + _sum_total(v) wide=_apple(hardware) or _gpu_item(hardware,p) is None return _decode_save({'stages':{k:_interval(k,x,p,wide=wide) for k,x in v.items() if k!='total'}, 'total':_interval('total',v['total'],p,wide=wide), 'diagnostics':d, diff --git a/freevideo_engine/step_cache.py b/freevideo_engine/step_cache.py new file mode 100644 index 00000000..2ea64635 --- /dev/null +++ b/freevideo_engine/step_cache.py @@ -0,0 +1,196 @@ +"""Step-skipping caches for MiniMax H3 sampling, ported from the ComfyUI community. + +Both approximate an earlier step of the same pass (one forward per step, no CFG). +Only explicit step_cache configuration or FREEVIDEO_STEP_CACHE=[:] +enables them. Numerical operations follow the FreeVideo H200 experiment snapshot +fb7786481cd10f0908c646667ff0e41e23b81dcfbaae656cb9b0622e73d579fd. + +fbcache: MiniMax H3 FirstBlockCache by duckyshell (MIT), Fast preset, upstream +f7a27128e73e1859f2295e64698164203799029d. Compare the video rows' first-block +residual with the last full step (relative mean absolute difference, row/channel +stride 8, threshold 0.10, window 10-95%, at most two consecutive hits). Audio +is sampled for diagnostics only. A hit adds the last full step's cached tail. + +easycache-av: ComfyUI EasyCache, reimplemented from the paper/node behaviour +(ComfyUI #15326; original snapshot SHA-256 +746fd299918efff7f2abe919fb8a0c20abd384be1e28a0f122c0d321a69ce174). +The last full step's rate k = |d out| / |d in| predicts k |d in| / |out|. +The H3 AV adaptation accumulates the larger audio/video prediction; below 0.2 +in the 15-95% window, reuse each modality's output-minus-input difference. +As in the H200 snapshot, this method has no consecutive-hit cap. + +FirstBlockCache attribution and MIT permission notice: +MIT License + +Copyright (c) 2026 duckyshell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +""" +import os + +DEFAULTS = {'easycache-av': 0.2, 'fbcache': 0.10} +WINDOWS = {'easycache-av': (0.15, 0.95), 'fbcache': (0.10, 0.95)} +BLOCK_LEVEL = ('fbcache',) +STRIDE = 8 + + +def requested(config=None): + """Resolve (kind, threshold, source); a nonempty experiment overrides config.""" + value = os.environ.get('FREEVIDEO_STEP_CACHE', '').strip() + if value: + kind, _, threshold = value.partition(':') + if kind not in DEFAULTS: + raise ValueError('Unknown FREEVIDEO_STEP_CACHE kind %r; use one of %s' % (kind, ', '.join(DEFAULTS))) + return kind, float(threshold) if threshold else DEFAULTS[kind], 'environment' + if config is None: + return None + if config not in DEFAULTS: + raise ValueError('Unknown step_cache %r; use one of %s' % (config, ', '.join(DEFAULTS))) + return config, DEFAULTS[config], 'config' + + +def _relative(current, previous): + return float((current - previous).abs().mean() / previous.abs().mean().clamp_min(1e-6)) + + +class StepCache: + def __init__(self, kind, threshold, steps, *, source=None, offloader=None, disabled_reason=None): + self.kind, self.threshold, self.steps = kind, threshold, steps + self.source, self.offloader = source, offloader + self.disabled_reason = disabled_reason + self.block_level = kind in BLOCK_LEVEL + self.step = -1 + self.hits, self.decisions = [], [] + self.consecutive = 0 + # block level + self.previous = None # (audio, video) block-0 residual samples of the last full step + self.tail = None # packed: last block output - block 0 output, last full step + self.indices = None + # model level + self.prev_input = None + self.prev_diff = None + self.prev_out = None + self.rate = None + self.accumulated = 0. + + # ---- shared ------------------------------------------------------------------------------------ + def in_window(self): + start, end = WINDOWS.get(self.kind, (0., 1.)) + progress = self.step / self.steps + return start <= progress < end + + def begin(self, video_indices, audio_indices): + """Called at the start of every transformer forward (one per step).""" + self.step += 1 + self.indices = (video_indices, audio_indices) + + def stats(self): + full = self.steps - len(self.hits) + return dict(kind=self.kind, threshold=self.threshold, steps=self.steps, cached_steps=list(self.hits), + full_forwards=full, decisions=self.decisions, source=self.source, + disabled_reason=self.disabled_reason) + + # ---- block level: fbcache ----------------------------------------------------------------- + def _samples(self, residual): + video, audio = self.indices + rows = residual[0] + return (rows.index_select(0, audio[::STRIDE])[:, ::STRIDE].float(), + rows.index_select(0, video[::STRIDE])[:, ::STRIDE].float()) + + def before_first_block(self, value): + self.before = self._samples(value) + + def after_first_block(self, after): + """Whether to skip the remaining blocks; on a hit `after` becomes the cached output.""" + audio, video = (now - then for now, then in zip(self._samples(after), self.before)) + self.before = None + hit, scores = False, None + if self.previous is not None and self.tail is not None and self.in_window() and self.consecutive < 2: + scores = (_relative(audio, self.previous[0]), _relative(video, self.previous[1])) + decisive = scores[1] + hit = decisive <= self.threshold + self.decisions.append(dict(step=self.step, audio=scores and round(scores[0], 5), + video=scores and round(scores[1], 5), hit=hit)) + if hit: + self.consecutive += 1 + self.hits.append(self.step) + after.add_(self.tail) + return True + self.consecutive = 0 + self.previous = (audio, video) + self.anchor = after.clone() + return False + + def after_last_block(self, output): + self.tail = output - self.anchor + self.anchor = None + + # ---- model level: easycache-av -------------------------------------------------------- + def skip_forward(self, video_in, audio_in, sigmas): + """Cached (video, audio) velocities for this step, or None to run the model.""" + return self._easycache(video_in, audio_in) + + def record_forward(self, video_in, audio_in, sigmas, video_out, audio_out): + stride = (slice(None), slice(None, None, STRIDE)) + current_in = (video_in[stride].float(), audio_in[stride].float()) + current_out = (video_out[stride].float(), audio_out[stride].float()) + if self.prev_input is not None and self.prev_out is not None: + rates = [] + for i in range(2): + change_in = (current_in[i] - self.prev_input[i]).abs().mean() + change_out = (current_out[i] - self.prev_out[i]).abs().mean() + rates.append(float(change_out / change_in.clamp_min(1e-8))) + self.rate = rates + self.prev_input, self.prev_out = current_in, current_out + self.prev_norm = [float(o.abs().mean()) for o in current_out] + self.prev_diff = (video_out.float() - video_in.float(), audio_out.float() - audio_in.float()) + + def _easycache(self, video_in, audio_in): + reuse = False + if self.rate is not None and self.prev_diff is not None and self.in_window(): + stride = (slice(None), slice(None, None, STRIDE)) + current = (video_in[stride].float(), audio_in[stride].float()) + predicted = [self.rate[i] * float((current[i] - self.prev_input[i]).abs().mean()) / max(self.prev_norm[i], 1e-8) + for i in range(2)] + self.accumulated += max(predicted) + reuse = self.accumulated < self.threshold + if not reuse: + self.accumulated = 0. + self.decisions.append(dict(step=self.step, accumulated=round(self.accumulated, 5), hit=reuse)) + if not reuse: + return None + self.hits.append(self.step) + return (video_in.float() + self.prev_diff[0], audio_in.float() + self.prev_diff[1]) + + +def begin_pass(transformer, steps, config=None, *, offloader=None, disabled_reason=None): + """Attach fresh state for this sampling pass; refinement never calls this.""" + wanted = requested(config) + transformer._freevideo_step_cache = (StepCache(wanted[0], wanted[1], steps, source=wanted[2], + offloader=offloader, disabled_reason=disabled_reason) if wanted else None) + return transformer._freevideo_step_cache + + +def end_pass(transformer): + cache = getattr(transformer, '_freevideo_step_cache', None) + transformer._freevideo_step_cache = None + if cache is None: + return None + cache.offloader = None + return cache.stats() diff --git a/freevideo_engine/streamed_weights.py b/freevideo_engine/streamed_weights.py index 87ed2578..af23e448 100644 --- a/freevideo_engine/streamed_weights.py +++ b/freevideo_engine/streamed_weights.py @@ -251,6 +251,24 @@ def consumed(self, index): self.cursor = 0 self._fill() + def end_pass_early(self): + """Cancel the unread window and rewind without reading the skipped layers.""" + self.stop.set() + for _, future in self.pending.values(): + future.cancel() + for _, future in self.pending.values(): + if not future.cancelled(): + try: + future.result() + except Exception as error: + self.failed = self.failed or type(error).__name__ + ': ' + str(error) + self.pending.clear() + self.pending_bytes = 0 + self.cursor = 0 + if not self.failed and not self.closed: + self.stop.clear() + # before() will refill on demand; cached steps need no speculative I/O. + def stats(self): with self.lock: return dict(self.counters, enabled=True, failure=self.failed, diff --git a/freevideo_engine/tiers.py b/freevideo_engine/tiers.py new file mode 100644 index 00000000..af1131b2 --- /dev/null +++ b/freevideo_engine/tiers.py @@ -0,0 +1,94 @@ +"""The quality levels, defined once. + +Every entry (editor, Agent API, ComfyUI capabilities, estimates, docs) reads +its level -> plan mapping from here; tests/test_tiers.py checks the entries +that cannot import it (web JS, prose docs) against this table. + +- light: two-pass, 8 + 3 steps. +- medium: one pass of 8 steps. +- high: one pass of 20 steps with the audio-video EasyCache. +- max: one pass of 20 steps with the first-block cache. The planner keeps that + cache only where the card has room for it; otherwise the request samples all + 20 steps in full (engine.json/debug record the cache actually used). + +Only a two-pass level samples a second time. Single-pass levels carry +REFINE_STEPS only as the neutral value the `refine_steps` field needs; it is +never run for them. `step_cache` is the engine's cache key: None, +'easycache-av' (audio-video EasyCache) or 'fbcache' (first-block cache). + +The caches are not ported to Mac (MPS) yet: there high is one pass of 16 +steps and max one pass of 20, both without a cache (TIERS_MAC). +""" +import sys + +REFINE_STEPS = 3 +STEP_CACHES = (None, 'easycache-av', 'fbcache') + +TIERS = { + 'light': dict(steps=8, two_pass=True, step_cache=None), + 'medium': dict(steps=8, two_pass=False, step_cache=None), + 'high': dict(steps=20, two_pass=False, step_cache='easycache-av'), + 'max': dict(steps=20, two_pass=False, step_cache='fbcache'), +} +TIERS_MAC = dict(TIERS, high=dict(steps=16, two_pass=False, step_cache=None), + max=dict(steps=20, two_pass=False, step_cache=None)) + +# Sampling time of a cached pass as a share of the same pass without a cache, +# for estimates that have no run with that cache yet. B200, 20 s clips +# (/mnt/fvbench/eng5/tiers/RESULTS-b200.md): EasyCache-AV sampled 1.8-1.9x and +# the first-block cache 1.4-1.5x faster than plain 20 steps. 10 s clips with the +# GPU held to 8-48 GiB (/mnt/fvbench/remy/tiers-v2/REPORT.md): EasyCache-AV +# 0.48-0.75 of plain 20 steps (median about 0.6); a first-block cache kept at +# 32-48 GiB 0.71-0.82. Under the current keep rule at 8-24 GiB +# (review/quality-tiers-v2/linux-evidence.md) it is kept at 12 and 24 GiB, +# 0.81-1.28 (median 0.87), and dropped at 8 and 16 GiB. The shares are not +# fitted to the median: EasyCache-AV is slightly conservative, the first-block +# cache near the 8-24 GiB median. A first-block cache the planner did not keep +# is plain 20 steps. +SAMPLING_SHARE = {None: 1., 'easycache-av': .64, 'fbcache': .9} + + +def on_mac(): + """FreeVideo runs on Apple's MPS backend on every Mac.""" + return sys.platform == 'darwin' + + +def table(mac=False): + return TIERS_MAC if mac else TIERS + + +def plan(name, mac=False): + """(two_pass, base_steps, refine_steps) of a level.""" + tier = table(mac)[name] + return tier['two_pass'], tier['steps'], REFINE_STEPS + + +def step_cache(name, mac=False): + return table(mac)[name]['step_cache'] + + +def name_for(steps, two_pass, refine_steps=REFINE_STEPS, step_cache=None, mac=False): + """The level with exactly this plan, or None for custom sampling.""" + return next((name for name, tier in table(mac).items() + if tier['steps'] == steps and tier['two_pass'] == two_pass + and (not two_pass or refine_steps == REFINE_STEPS) and tier['step_cache'] == step_cache), None) + + +def table_level(steps, two_pass, refine_steps=REFINE_STEPS, step_cache=None, mac=False): + """The level whose sampling table a plan needs, for naming a missing download. + + Tables follow the step count only. Prefer the exact level (the cache tells + high from max at 20 steps), then the other platform's table (20 steps + without a cache is "max", as on Mac; 16 steps is "high"), then the first + level with that step count. + """ + found = name_for(steps, two_pass, refine_steps, step_cache, mac) or name_for( + steps, two_pass, refine_steps, step_cache, not mac) + return found or next((name for name, tier in TIERS.items() + if tier['steps'] == steps and tier['two_pass'] == two_pass + and (not two_pass or refine_steps == REFINE_STEPS)), None) + + +def sampling_share(cache): + """Estimated sampling time with this effective cache, as a share of the plain pass.""" + return SAMPLING_SHARE.get(cache, 1.) diff --git a/freevideo_engine/worker.py b/freevideo_engine/worker.py index b79bbaf2..f190ad3a 100644 --- a/freevideo_engine/worker.py +++ b/freevideo_engine/worker.py @@ -267,6 +267,8 @@ def capture_first(video): metrics['latent_reload_seconds'] = time.perf_counter() - tick metrics['transformer_released_before_decode'] = resident is None or not resident.engine_on_gpu() else: + from .sampling_memory import SamplingMemory as _Counters + load_read_start = _Counters.process_counters().get('read_bytes') tick = time.perf_counter() # Windows charges the allocator room still to be reserved to commit; # the engine keeps that room out of its locked weights. @@ -279,6 +281,8 @@ def capture_first(video): 'epoch': time.time(), 'seconds': time.perf_counter() - started}), flush=True) engine, reused = resident.engine(request, factory) if resident is not None else (factory(), False) loaded_seconds = time.perf_counter() - tick + load_read_end = _Counters.process_counters().get('read_bytes') + load_read_bytes = (load_read_end - load_read_start) if all(isinstance(v, (int, float)) for v in (load_read_start, load_read_end)) else None metrics.update(config=dict(engine.config), load_seconds=loaded_seconds, resident_engine_cache_hit=reused, load_breakdown=({'resident_lookup_seconds': loaded_seconds} if reused else engine.load_breakdown), phase='sample', @@ -303,6 +307,11 @@ def capture_first(video): def read_decoder_ahead(): # Every offloaded layer in memory and none read in the last step. decode_read_ahead.start(drive_idle=getattr(engine, 'drive_idle', False) is True) + if hasattr(decode_read_ahead, 'due'): + read_decoder_ahead.due = decode_read_ahead.due + if not reused and isinstance(load_read_bytes, (int, float)) and loaded_seconds > 0: + decode_read_ahead.load_read_rate = load_read_bytes / loaded_seconds + decode_read_ahead.result['load_read_bytes_per_second'] = round(decode_read_ahead.load_read_rate) from .sampling_memory import SamplingMemory memory_diagnostics = SamplingMemory(torch, Path(request['output']).with_suffix('.sampling-memory.json')).start() from . import frame_preview diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 6781a491..bf0ca415 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -174,6 +174,10 @@ test('main browser entry registers both views and preserves node hooks', async t class LocalizedGenerateNode extends GenerateNode {} await localized.beforeRegisterNodeDef(LocalizedGenerateNode, {name: 'FreeVideoGenerate'}); const rows = [ + ['width', 'width', '宽度', 1344], + ['height', 'height', '高度', 768], + ['seconds', 'Duration (s)', '时长(秒)', 10], + ['seed', 'seed', '种子', 2026090903], ['force_regenerate', 'Force regeneration', '重新生成', false, '跳过已保存结果的缓存。'], ['preview', 'Two-pass: preview first pass', '先出预览', false, '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], @@ -181,6 +185,8 @@ test('main browser entry registers both views and preserves node hooks', async t ['continue_from', 'Continue from video', '接着生成', 'previous.mp4'], ['frames', 'Total frames', '帧数', 141], ['first_frame_reference', 'First-frame reference', '首帧参考', true, '减小漂移'], + ['step_cache', 'Step cache', '步间缓存', 'none', + '复用相近采样步之间的计算,更快完成,结果会有细微变化。精细档用 easycache-av,极致档用 fbcache。'], ['voice_reference', 'Voice reference', 'Voice reference', true], ['hide_tail', 'Hide dialogue tail', 'Hide dialogue tail', true], ]; @@ -193,8 +199,8 @@ test('main browser entry registers both views and preserves node hooks', async t assert.equal(widget.label, language === 'zh' ? zh : en, name); assert.equal(widget.tooltip, language === 'zh' && tooltip ? tooltip : `original ${name}`, name); } - assert.equal(node.widgets[0].type, 'hidden'); - assert.equal(node.widgets[0].serializeValue(), false); + assert.equal(node.widgets.find(w => w.name === 'force_regenerate').type, 'hidden'); + assert.equal(node.widgets.find(w => w.name === 'force_regenerate').serializeValue(), false); node.onRemoved(); } finally { navigator.language = 'en'; } }); diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 8066e30d..d4fbf48f 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -463,7 +463,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (focus && session === state.session) input.focus(); else if (narrow()) chats.focus(); render(); void readConversations(true); void poll(); } - async function startChat() { + // `from` is the message whose "New chat" was clicked: a failure is also written there, where the reader is looking. + async function startChat(from = null) { closeMenu(); if (creating || !active() || conversations.supported !== true) return; creating = true; newError.hidden = true; renderConversations(); render(); @@ -476,7 +477,10 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () unsaidSessions.add(data.session); chooseChat(data.session, true); } else throw await requestError(response); } catch (error) { - if (!stopped) { newError.textContent = t('Could not start a new chat: ', '没有新建成功:') + reason(error); newError.hidden = false; } + if (!stopped) { + newError.textContent = t('Could not start a new chat: ', '没有新建成功:') + reason(error); newError.hidden = false; + if (from) from.error = newError.textContent; + } } finally { creating = false; if (!stopped) { renderConversations(); render(); } } } @@ -598,12 +602,15 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () error = el('span', 'fv-chat-error'); error.setAttribute('role', 'alert'); retry = press('fv-chat-retry', t('Retry', '重试'), () => { const original = pending.find(value => value.id === entry.id); + if (entry.errorCode === 'session_full') { void startChat(original || entry); return; } if (original) void send(original.text, original); }); status.append(error, retry); item.append(status); } error.textContent = entry.error || ''; error.hidden = !entry.error; - retry.disabled = entry.sending || !sendingAllowed(); + const full = entry.errorCode === 'session_full'; + retry.textContent = full ? t('New chat', '新对话') : t('Retry', '重试'); + retry.disabled = entry.sending || (full ? creating : !sendingAllowed()); } else if (status) { status.remove(); status = null; } }}; } @@ -933,6 +940,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (draft) entry.draft = draft; if (!original) pending.push(entry); if (!automatic) entry.attempts = 0; + entry.errorCode = null; if (!sendingAllowed()) { entry.automatic = true; entry.error = waitingNotice(); say(entry.error); render(); report(); return false; @@ -955,6 +963,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () return true; } const error = await sendError(response), refused = sendRefused(error, response); + entry.errorCode = error?.code; const waiting = !refused && (WAIT_FOR_AGENT.includes(error?.code) || !error && state.agent.connected !== true); const exhausted = entry.attempts >= SEND_ATTEMPTS; entry.automatic = waiting && !exhausted; @@ -965,7 +974,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (entry.session === state.session) { if (waiting && error?.code === 'not_connected') state = {...state, agent: {connected: false, name: null}}; if (waiting && error?.code === 'not_listening') state = {...state, agent: {...state.agent, listening: false}}; - say(explanation); + say(entry.errorCode === 'session_full' ? '' : explanation); } return false; } finally { diff --git a/web/capability_view.js b/web/capability_view.js index 9df6df14..ec2e6b80 100644 --- a/web/capability_view.js +++ b/web/capability_view.js @@ -1,4 +1,5 @@ import { referenceItems } from './prompt_references.js'; +import { stepCacheOf } from './sampling_effort.js'; const PRESETS = [.5, .75, .98, 1.5, 2]; const COLUMN_SPACING = 'equal'; // 'equal' or 'width' @@ -44,7 +45,7 @@ export function aboutTime(seconds, t) { // documented task fallback, rather than pretending the known subset is all of it. export function capabilityContext(node, state) { if (linked(node, 'conditioning') || linked(node, 'width') || linked(node, 'height') - || ['base_steps', 'refine_steps', 'two_pass'].some(name => linked(node, name))) return null; + || ['base_steps', 'refine_steps', 'two_pass', 'step_cache'].some(name => linked(node, name))) return null; let items, assets; try { items = referenceItems(node); assets = JSON.parse(value(upstream(node, 'media'), 'assets') || '[]'); } catch { return null; } @@ -76,12 +77,15 @@ export function capabilityContext(node, state) { return query.toString(); } +// The grid's level with exactly this plan; a step cache tells High from Max at 20 steps. +const gridTier = (grid, steps, twoPass, refine, stepCache) => Object.values(grid.tiers || {}).find(v => v.steps === steps + && v.two_pass === twoPass && (!v.two_pass || refine === 3) && stepCacheOf(v.step_cache) === stepCacheOf(stepCache)); + export function capabilitySurface(grid, state) { if (grid?.version !== 1 || grid.status !== 'ready' || typeof grid.revision !== 'string' || !grid.machine?.system || /darwin|mac|apple/i.test(grid.machine.system) || !Array.isArray(grid.sizes) || !grid.sizes.length || !Array.isArray(grid.lengths)) return null; - const tier = Object.values(grid.tiers || {}).find(v => v.steps === state.steps && v.two_pass === state.twoPass - && (!v.two_pass || state.refine === 3)); + const tier = gridTier(grid, state.steps, state.twoPass, state.refine, state.stepCache); if (!tier || !Array.isArray(tier.estimate_seconds) || !Array.isArray(tier.max_seconds)) return null; const lengths = grid.lengths.map(l => l.seconds); if (!lengths.length || lengths.some((s, i) => !Number.isInteger(s) || s < 2 || (i && s <= lengths[i - 1]))) return null; @@ -171,7 +175,7 @@ export function surfacePixels(surface, width, height) { } let serial = 0; -export function createCapabilityView({node, api, t, getState, onPick, onEstimate}) { +export function createCapabilityView({node, api, t, getState, onPick, onEstimate, onGrid}) { if (!document.querySelector('link[data-fv-capability]')) { const css = el('link', ''); css.rel = 'stylesheet'; css.dataset.fvCapability = ''; css.href = new URL('./capability_view.css', import.meta.url).href; document.head.append(css); @@ -241,7 +245,7 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate function paint(animate = false) { if (!surface || !opened || element.hidden || !plot.clientWidth) return; const dpr = Math.max(1, window.devicePixelRatio || 1), width = Math.round(plot.clientWidth * dpr), height = Math.round(plot.clientHeight * dpr); - const key = `${grid.revision}:${getState().steps}:${getState().twoPass}:${width}:${height}:${query}`; + const key = `${grid.revision}:${getState().steps}:${getState().twoPass}:${stepCacheOf(getState().stepCache)}:${width}:${height}:${query}`; if (key === drawn) { current(); return; } drawn = key; if (animate && canvas.width && !matchMedia('(prefers-reduced-motion: reduce)').matches) { @@ -266,6 +270,7 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate current(); hideHover(); } function applyGrid(animate = false) { + if (grid?.version === 1 && grid.status === 'ready') onGrid?.(grid); surface = capabilitySurface(grid, getState()); element.hidden = !surface; onEstimate?.(); if (!surface) { hideHover(); return; } @@ -370,9 +375,9 @@ export function createCapabilityView({node, api, t, getState, onPick, onEstimate api.addEventListener('freevideo_capabilities', changed); const outside = event => { if (!plot.contains(event.target)) hideHover(); }; node && window.addEventListener('pointerdown', outside); - return {element, update, estimate(steps, twoPass, refine) { + return {element, update, estimate(steps, twoPass, refine, stepCache = null) { if (!grid || !surface) return ''; - const tier = Object.values(grid.tiers || {}).find(v => v.steps === steps && v.two_pass === twoPass && (!twoPass || refine === 3)); + const tier = gridTier(grid, steps, twoPass, refine, stepCache); const i = grid.sizes.findIndex(s => s.width === Number(value(node, 'width')) && s.height === Number(value(node, 'height'))); const j = grid.lengths.findIndex(l => l.seconds === Number(value(node, 'seconds'))); const seconds = tier?.estimate_seconds?.[i]?.[j]; diff --git a/web/chain_support.js b/web/chain_support.js index 9471f2a3..94ab4f2c 100644 --- a/web/chain_support.js +++ b/web/chain_support.js @@ -1,5 +1,6 @@ // Pure helpers the creative panel's long-video planning shares with the editor store; kept out of // editor_store.js so the panel does not load the editing implementation. +import { stepCacheOf } from './sampling_effort.js'; const clockDefault = {now: () => Date.now(), setTimeout: (fn, ms) => setTimeout(fn, ms), clearTimeout: id => clearTimeout(id)}; export const chainRoute = (id, action) => '/freevideo/chain' + (id ? '/' + encodeURIComponent(id) : '') + (action ? '/' + action : ''); @@ -37,9 +38,9 @@ function capabilityTier(reply, settings) { const tiers = reply.tiers; if (!tiers || typeof tiers !== 'object') return reply; if (tiers[settings?.tier]) return tiers[settings.tier]; - const steps = Number(settings?.base_steps), twoPass = !!settings?.two_pass; + const steps = Number(settings?.base_steps), twoPass = !!settings?.two_pass, cache = stepCacheOf(settings?.step_cache); return Object.values(tiers).find(tier => tier.steps === steps && tier.two_pass === twoPass - && (!twoPass || Number(settings?.refine_steps) === 3)) || {}; + && (!twoPass || Number(settings?.refine_steps) === 3) && stepCacheOf(tier.step_cache) === cache) || {}; } export function capabilityPoints(reply, {width, height, settings = {}}) { const tier = capabilityTier(reply, settings); diff --git a/web/editor.css b/web/editor.css index 02abbfae..f570f3dc 100644 --- a/web/editor.css +++ b/web/editor.css @@ -146,7 +146,7 @@ .fv-ed-more-panel .fv-ed-text-buttons{display:contents} .fv-editor .fv-ed-toolbar .fv-ed-more-panel button{flex:none;width:100%;height:32px;justify-content:flex-start;gap:10px;padding:0 10px;margin:0;color:var(--fv-ink);text-align:left;white-space:nowrap} .fv-ed-more-panel[data-up=true]{top:auto;bottom:calc(100% - 2px)} -.fv-editor .fv-ed-toolbar .fv-ed-more-panel button:has(svg)>span{display:none} +.fv-editor .fv-ed-toolbar .fv-ed-more-panel button[data-more-icon]>span{display:none} .fv-ed-more-panel button::after{content:attr(data-more-label)} /* A switch that is on shows it in its icon; the row tint stays for hover and focus. */ .fv-editor .fv-ed-toolbar .fv-ed-more-panel button[aria-pressed=true]{color:var(--fv-ink);background:transparent} diff --git a/web/editor_entry.js b/web/editor_entry.js index 99667746..2c9aab27 100644 --- a/web/editor_entry.js +++ b/web/editor_entry.js @@ -11,7 +11,7 @@ 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)), + step_cache: values.step_cache, 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'); diff --git a/web/editor_expand.css b/web/editor_expand.css index d0de662b..df42cb75 100644 --- a/web/editor_expand.css +++ b/web/editor_expand.css @@ -1,6 +1,6 @@ .fv-ed-description-box{position:relative;display:flex;flex:1;min-width:0} .fv-ed-description-box .fv-ed-description{width:100%;min-height:36px;max-height:108px;line-height:18px;padding:8px 36px 8px 10px;border-radius:var(--fv-r-sm);resize:none;overflow-y:hidden} -.fv-ed-expand,.fv-editor .fv-ed-expand{position:absolute;right:4px;bottom:4px;width:26px;height:26px;padding:4px;min-height:0;border:0;background:var(--fv-bg);color:var(--fv-muted)} +.fv-ed-expand,.fv-editor .fv-ed-expand,.fv-studio .fv-ed-expand{position:absolute;right:4px;bottom:4px;width:26px;height:26px;padding:4px;min-height:0;border:0;background:var(--fv-bg);color:var(--fv-muted)} .fv-ed-expand svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round} .fv-ed-expand-dialog{box-sizing:border-box;position:fixed;inset:0;margin:auto;width:60vw;height:70dvh;max-width:calc(100vw - 32px);max-height:calc(100dvh - 32px);padding:20px;border:1px solid var(--fv-border);border-radius:var(--fv-r-lg);background:var(--fv-card);color:var(--fv-ink)} .fv-ed-expand-dialog[open]{display:flex;flex-direction:column;gap:16px} diff --git a/web/editor_expand.js b/web/editor_expand.js index 2442a307..73a20d5c 100644 --- a/web/editor_expand.js +++ b/web/editor_expand.js @@ -35,10 +35,10 @@ export function createExpandButton({t = en => en, textarea, onGenerate, canGener if (!dialog) return; // The small box changed from outside (another part selected, text restored): the dialog follows. if (textarea.value !== seen) { seen = textarea.value; large.value = seen; } - large.disabled = !writable(); generate.disabled = !allowed(); + large.disabled = !writable(); if (generate) generate.disabled = !allowed(); } async function submit() { - if (!allowed()) return; + if (!onGenerate || !allowed()) return; commit(); notice.textContent = ''; pending = true; sync(); let reason = ''; @@ -83,6 +83,7 @@ export function createExpandButton({t = en => en, textarea, onGenerate, canGener dialog.setAttribute('aria-label', t('Expand', '放大')); seen = textarea.value; large = el('textarea', 'fv-ed-expand-text'); large.value = seen; + if (textarea.maxLength >= 0) large.maxLength = textarea.maxLength; large.setAttribute('aria-label', textarea.getAttribute('aria-label') || t('Expand', '放大')); notice = el('div', 'fv-ed-expand-note'); notice.setAttribute('role', 'status'); large.oninput = () => { @@ -92,9 +93,10 @@ export function createExpandButton({t = en => en, textarea, onGenerate, canGener sync(); }; const buttons = el('div', 'fv-ed-expand-buttons'); - generate = el('button', 'fv-primary', t('Generate', '生成')); generate.type = 'button'; generate.onclick = submit; + // Without a way to generate (a single part cannot be generated alone) only Close is offered. + if (onGenerate) { generate = el('button', 'fv-primary', t('Generate', '生成')); generate.type = 'button'; generate.onclick = submit; } const dismiss = el('button', '', t('Close', '关闭')); dismiss.type = 'button'; dismiss.onclick = close; - buttons.append(generate, dismiss); dialog.append(large, notice, buttons); + if (generate) buttons.append(generate); buttons.append(dismiss); dialog.append(large, notice, buttons); cleanups.push(listenDialogKey(dialog, 'keydown', event => { if (event.isComposing || event.keyCode === 229) return; if (event.key === 'Escape') { event.preventDefault(); close(); } @@ -102,7 +104,7 @@ export function createExpandButton({t = en => en, textarea, onGenerate, canGener event.preventDefault(); if (!event.repeat) submit(); } else if (event.key === 'Tab') { event.preventDefault(); - const controls = [large, generate, dismiss].filter(node => !node.disabled); + const controls = [large, generate, dismiss].filter(node => node && !node.disabled); const index = controls.indexOf(document.activeElement); controls[(index + (event.shiftKey ? -1 : 1) + controls.length) % controls.length]?.focus(); } @@ -132,7 +134,7 @@ export function createExpandButton({t = en => en, textarea, onGenerate, canGener const observer = typeof MutationObserver === 'function' ? new MutationObserver(sync) : null; observer?.observe(textarea, {attributes: true, attributeFilter: ['disabled', 'readonly']}); sync(); - return {element, open, close, commit, dispose() { + return {element, open, close, commit, get isOpen() { return !!dialog; }, dispose() { close(); disposed = true; observer?.disconnect(); textarea.removeEventListener('input', sync); textarea.removeEventListener('change', sync); element.onclick = null; element.remove(); diff --git a/web/editor_export_dialog.js b/web/editor_export_dialog.js index 66c7ef67..4e31ebf5 100644 --- a/web/editor_export_dialog.js +++ b/web/editor_export_dialog.js @@ -18,9 +18,14 @@ const el = (tag, cls, text) => { }; export function exportSizes(record) { - const short = Math.min(record.width, record.height); + const short = Math.min(record.width, record.height), long = Math.max(record.width, record.height); + // Same rounding as the engine's export (nearest even long side), so the dialog and the saved work agree. + const sized = n => { + const other = Math.max(2, 2 * Math.floor((long * n + short) / (2 * short))); + return record.width >= record.height ? {width: other, height: n} : {width: n, height: other}; + }; return [{short: null, width: record.width, height: record.height}, ...[1080, 720, 480].filter(n => n < short).map(n => ({ - short: n, width: Math.floor(record.width * n / short / 2) * 2, height: Math.floor(record.height * n / short / 2) * 2, + short: n, ...sized(n), }))]; } diff --git a/web/editor_model.js b/web/editor_model.js index 7587ebd9..71d60985 100644 --- a/web/editor_model.js +++ b/web/editor_model.js @@ -1101,6 +1101,6 @@ export function restoreVersion(record, id, index, {by = 'user'} = {}) { } return finish(next); } -connectSpeedOperations({copy, finish, detachAttempt, trackOf, freshId, +connectSpeedOperations({copy, finish, detachAttempt, trackOf, freshId, splitAudioFields, makeRoom: (record, track, start, frames, excluded) => applyRoom(record, start, frames, {tracks: [].concat(track), excluded: [...excluded]}), }); diff --git a/web/editor_select.js b/web/editor_select.js index 7a0339b1..684ebbb6 100644 --- a/web/editor_select.js +++ b/web/editor_select.js @@ -5,6 +5,7 @@ import {copyContent, pasteContent, copyRange, pasteRange, playheadTargets, remov export {copyContent, pasteContent} from './editor_room.js'; import {transportSnapPoints} from './editor_transport_model.js'; import {unlockedIds} from './editor_audio_model.js'; +import {isTextTrack, textDropLocked} from './editor_text_model.js'; const end = clip => clip.start + clip.frames; const busy = clip => ['queued', 'running'].includes(clip.pending); @@ -143,11 +144,13 @@ export function createSelectionActions({state, t = en => en, actions = {}, clipb // project, dispatching selection events, or rebuilding clip DOM. export function prepareMove(record, ids) { const selected = model.linkedIds(record, ids), members = all(record).filter(clip => selected.has(clip.id)); - const rows = record.tracks.map(track => ({moving: track.clips.filter(clip => selected.has(clip.id)), others: track.clips.filter(clip => !selected.has(clip.id))})); + const rows = record.tracks.map(track => ({locked: isTextTrack(track) && track.locked, + moving: track.clips.filter(clip => selected.has(clip.id)), others: track.clips.filter(clip => !selected.has(clip.id))})); const start = Math.min(...members.map(clip => clip.start)), stop = Math.max(...members.map(end)); - return {ids: [...selected], start, end: stop, members, preview(delta) { + return {ids: [...selected], start, end: stop, members, preview(delta, trackId = null) { delta = Math.max(-start, delta); - let code = members.some(busy) ? 'busy' : null; + let code = rows.some(row => row.locked && row.moving.length) || textDropLocked(record, members, trackId) ? 'locked' + : members.some(busy) ? 'busy' : null; for (const {moving, others} of rows) { let i = 0; for (const clip of moving) { @@ -241,7 +244,8 @@ export function createSelectionGestures({state, element, content, scroll, clips, const clip = clips.get(g.id).clip, desired = clip.start + Math.round((p.x - g.start.x) * model.FPS / state.zoom); const points = [...g.points, ...[desired, desired + clip.frames].map(frame => Math.round(frame / (5 * model.FPS)) * 5 * model.FPS)]; const aligned = snap(Math.max(0, desired), transportSnapPoints(state, points, event), state.zoom, {offsets: [0, clip.frames]}); - const result = g.plan.preview(aligned.frame - clip.start); g.result = result; + const track = [...rows].find(([, row]) => p.y >= row.top && p.y <= row.top + row.height)?.[0]; + const result = g.plan.preview(aligned.frame - clip.start, track); g.result = result; if (result.code === 'busy') { if (!g.warned) { g.warned = true; commands.report(new model.EditError('busy')); } return; @@ -283,6 +287,7 @@ export function createSelectionGestures({state, element, content, scroll, clips, if (cancel) state.select(g.previous); else if (g.type === 'box') { const ids = g.selected || (adding(g) ? selectedIds(g.record, g.previous) : []); state.select(selection(ids), {history: true}); anchor = ids.at(-1) ?? null; } else if (g.type === 'move' && !g.moved) choose(g.id); + else if (g.type === 'move' && g.result?.code === 'locked') commands.report(new model.EditError('locked')); else if (g.type === 'move' && g.result && !g.result.code) state.apply(t('Move', '移动'), record => actionsModel.moveParts(record, g.plan.ids, {frames: g.result.delta, track: null})); } catch (error) { commands.report(error); } finally { paint(selectedIds(state.record, state.selection)); renderSelection(); actions.gestureEnd?.(); } diff --git a/web/editor_speed_model.js b/web/editor_speed_model.js index 7426786a..3a9a8fe2 100644 --- a/web/editor_speed_model.js +++ b/web/editor_speed_model.js @@ -138,13 +138,14 @@ export function splitSpeed(record, id, frame, by = 'user') { const clips = selected(record, id), primary = clips[0], left = frame - primary.start; check(left >= 0 && left <= primary.frames, 'range'); if (left === 0 || left === primary.frames) return record; - const {copy, finish, detachAttempt, trackOf, freshId} = operations(); + const {copy, finish, detachAttempt, trackOf, freshId, splitAudioFields} = operations(); const next = copy(record); detachAttempt(next, clips.map(clip => clip.id)); const rights = []; for (const old of clips) { const clip = model.find(next, old.id); if (by === 'user') delete clip.text_by; const right = structuredClone(clip); replaceSlice(clip, 0, left); replaceSlice(right, left, old.frames - left); right.id = freshId(next); right.start = frame; + splitAudioFields(clip, right); trackOf(next, clip.id).clips.push(right); rights.push(right); } if (rights.length === 2) { rights[0].link = rights[1].id; rights[1].link = rights[0].id; } diff --git a/web/editor_text.js b/web/editor_text.js index 04f87199..bef65039 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -319,9 +319,11 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, editing={id,input};boundsLayer.replaceChildren(input); input.oninput=()=>updateContent(c,input.value,true); input.onkeydown=e=>{e.stopPropagation();if(e.key==='Escape'){e.preventDefault();finishInline();} };input.onkeyup=e=>e.stopPropagation(); - input.onblur=()=>finishInline();render();input.focus();if(selectAll)input.select(); + input.onblur=e=>finishInline(e.relatedTarget);render();input.focus();if(selectAll)input.select(); } - function finishInline(){if(!editing)return;const old=editing;editing=null;old.input.onblur=null;const c=model.find(state.record,old.id);if(!old.input.value.trim())endEditing(old.id);else if(c&&c.text!==old.input.value)updateContent(c,old.input.value);else commitDraft();old.input.remove();render();} + function finishInline(next){if(!editing)return;const old=editing;editing=null;old.input.onblur=null;const c=model.find(state.record,old.id); + // Moving to the side panel's Content box keeps typing the same text; an empty one is cleaned up when that box is left. + if(!old.input.value.trim()&&next?.classList?.contains('fv-ed-text-content')&&selectedText()?.id===old.id)typing=old.id;else if(!old.input.value.trim())endEditing(old.id);else if(c&&c.text!==old.input.value)updateContent(c,old.input.value);else commitDraft();old.input.remove();render();} function guide(axis,value){const line=el('span','fv-ed-text-guide '+axis);line.style[axis==='x'?'left':'top']=`${value*100}%`;boundsLayer.append(line);} function beginDrag(e,c){ if(e.button!==0||locked(c)||editing)return;e.preventDefault();e.stopPropagation(); diff --git a/web/editor_text_model.js b/web/editor_text_model.js index 80556468..b7d32711 100644 --- a/web/editor_text_model.js +++ b/web/editor_text_model.js @@ -65,6 +65,9 @@ export function readTextProject(raw, parseMedia) { export function assertUnlocked(record, ids) { check(!record.tracks.some(t => t.locked && t.clips.some(c => ids.includes(c.id))), 'locked'); } +// Timeline moves keep their original lanes, but text cannot land over a locked overlay lane. +export const textDropLocked = (record, clips, trackId) => clips.some(isText) + && record.tracks.some(track => track.id === trackId && isTextTrack(track) && track.locked); export function addText(record, {kind = 'text', at = 0, text = '', style, frames = kind === 'subtitle' ? 48 : 72} = {}) { check(['text', 'subtitle'].includes(kind)); const next = model.serialize(record); let row = next.tracks.find(t => t.kind === kind); diff --git a/web/editor_toolbar_more.js b/web/editor_toolbar_more.js index 2b073352..638599aa 100644 --- a/web/editor_toolbar_more.js +++ b/web/editor_toolbar_more.js @@ -16,7 +16,7 @@ export function createToolbarMore({toolbar, t, keep}) { .filter(node => node !== more && node !== panel && !node.matches(keep)); // Each row names its tool; labels are read on opening because tools rename themselves (shortcut hints dropped). function open(value) { - if (value) for (const control of panel.querySelectorAll('button')) control.dataset.moreLabel = (control.getAttribute('aria-label') || control.textContent).replace(/\s*[((][^()()]*[))]$/, ''); + if (value) for (const control of panel.querySelectorAll('button')) { control.dataset.moreLabel = (control.getAttribute('aria-label') || control.textContent).replace(/\s*[((][^()()]*[))]$/, ''); control.toggleAttribute('data-more-icon', !!control.querySelector('svg')); } panel.hidden = !value; more.setAttribute('aria-expanded', String(value)); // Open downward when the rows fit below the toolbar, otherwise upward over the preview. if (value) { const room = innerHeight - toolbar.getBoundingClientRect().bottom; panel.dataset.up = String(panel.offsetHeight > room - 8 && toolbar.getBoundingClientRect().top > room); } @@ -31,7 +31,7 @@ export function createToolbarMore({toolbar, t, keep}) { } else if (!media?.matches && moved.length) { for (const [node, place] of moved.splice(0)) { place.replaceWith(node); - for (const control of node.matches('button') ? [node] : node.querySelectorAll('button')) delete control.dataset.moreLabel; + for (const control of node.matches('button') ? [node] : node.querySelectorAll('button')) { delete control.dataset.moreLabel; control.removeAttribute('data-more-icon'); } } } more.hidden = !media?.matches; diff --git a/web/editor_tracks.js b/web/editor_tracks.js index 31941d10..2ab5f969 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -1,5 +1,5 @@ import {editorIcon} from './editor_icons.js'; -import {displayTracks, isText, isTextTrack} from './editor_text_model.js'; +import {displayTracks, isText, isTextTrack, textDropLocked} from './editor_text_model.js'; // Pointer gestures translate coordinates into model calls; the model owns edits. // These exports deliberately have no DOM dependencies, so input arithmetic can // be checked in Node with the same code that runs in the browser. @@ -42,7 +42,9 @@ export function insertionPoint(clips, frame, exceptIds = []) { // A range starts where it was pressed (ends there when drawn leftwards); its length is whole seconds, at least two. export function rangeParameters(anchor, frame, tracks = ['v1', 'a1'], fps = model.FPS) { const from = Math.max(0, Math.round(anchor)), to = Math.max(0, Math.round(frame)); - const frames = Math.max(2 * fps, Math.round(Math.abs(to - from) / fps) * fps); + let frames = Math.max(2 * fps, Math.round(Math.abs(to - from) / fps) * fps); + // Drawn leftwards past the start, the end stays put: the length shrinks to the whole seconds that fit before it. + if (to < from && frames > from) frames = Math.max(2 * fps, Math.floor(from / fps) * fps); return {kind: 'range', start: to < from ? Math.max(0, from - frames) : from, frames, tracks: [...tracks]}; } @@ -774,6 +776,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = plannedLines.hidden = true; const desired = gesture.clip.start + frame - gesture.anchor; const result = moveParameters(gesture.clip, gesture.anchor, frame, dragPoints(desired, gesture.clip.frames), state.zoom); + result.track = isText(gesture.clip) ? pointerHit(event).track : null; gesture.result = result; const track = state.record.tracks.find(row => row.id === gesture.track); const drop = isText(gesture.clip) ? {hit: false} : insertionPoint(track.clips, result.frame, gesture.ids); @@ -783,7 +786,10 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = } gesture.rightPreviewIds = []; let preview, error, shifted = 0; - try { preview = model.move(state.record, gesture.id, result.frame); } + try { + if (textDropLocked(state.record, gesture.members, result.track)) throw new model.EditError('locked'); + preview = model.move(state.record, gesture.id, result.frame); + } catch (caught) { if (!(caught instanceof model.EditError)) throw caught; error = caught; } if (preview) for (const row of preview.tracks) for (const clip of row.clips) { const item = locate(clip.id); @@ -913,7 +919,10 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = if (['range', 'rangeMove', 'rangeEdge', 'rangeBoundary'].includes(finished.type)) { state.select(finished.selection, {history: true}); return; } const result = finished.result; if (!result) return; - if (finished.type === 'move') attempt(t('Move', '移动'), record => model.move(record, finished.id, result.frame)); + if (finished.type === 'move') attempt(t('Move', '移动'), record => { + if (textDropLocked(record, finished.members, result.track)) throw new model.EditError('locked'); + return model.move(record, finished.id, result.frame); + }); else if (finished.type === 'plannedBoundary') attempt(t('Resize parts', '调整段长'), record => model.resizePlannedBoundary(record, finished.id, finished.neighbor.clip.id, result.frame, actions.generationLimits?.())); else if (result.kind === 'planned') attempt(t('Resize', '改长短'), record => model.resizePlanned(record, finished.id, finished.edge, result.frame, actions.generationLimits?.())); else if (result.kind === 'extend') { diff --git a/web/error_panel.js b/web/error_panel.js index c9d38de5..a3a4c775 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -1,5 +1,6 @@ // Shared by the Studio and graph view. Text only; never render exception HTML. import {detectBrowserExtension, browserExtensionAdvice, extensionSteps} from './browser_extension.js'; +import {SAMPLING_EFFORTS, STEP_CACHES, tableLevel} from './sampling_effort.js'; const reports = new WeakSet(); const captured = new WeakMap(); const diagnosticReports = new WeakSet(); @@ -72,9 +73,11 @@ function knownError(text) { if (Number.isInteger(d.base_steps) && d.base_steps >= 1 && d.base_steps <= 32 && typeof d.two_pass === 'boolean' && Number.isInteger(d.refine_steps) && d.refine_steps >= 0 && d.refine_steps <= 31 && Number.isSafeInteger(d.missing_bytes) && d.missing_bytes > 0 && typeof d.decimal_sizes === 'boolean' - && Array.isArray(d.available) && d.available.every(i => Number.isInteger(i) && i >= 0 && i <= 3)) + && Array.isArray(d.available) && d.available.every(i => Number.isInteger(i) && i >= 0 && i <= 3) + && (d.step_cache === undefined || STEP_CACHES.includes(d.step_cache))) return {kind: 'sampling-table', base_steps: d.base_steps, two_pass: d.two_pass, refine_steps: d.refine_steps, - missing_bytes: d.missing_bytes, decimal_sizes: d.decimal_sizes, available: [...new Set(d.available)].sort()}; + missing_bytes: d.missing_bytes, decimal_sizes: d.decimal_sizes, available: [...new Set(d.available)].sort(), + ...(d.step_cache ? {step_cache: d.step_cache} : {})}; } catch { /* Malformed metadata stays in technical details. */ } } return PLAIN_ERRORS.find(error => line === error.message || error.aliases?.includes(line))?.key ?? null; @@ -214,6 +217,8 @@ export function createErrorReport(detail, context = {}) { const value = Array.isArray(inputs?.[key]) && inputs[key].length === 1 ? inputs[key][0] : inputs?.[key]; if (typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value)) settings[key] = value; } + const cache = Array.isArray(inputs?.step_cache) && inputs.step_cache.length === 1 ? inputs.step_cache[0] : inputs?.step_cache; + if (typeof cache === 'string' && (cache === 'none' || STEP_CACHES.includes(cache))) settings.step_cache = cache; const progress = {}; for (const key of ['phase', 'stage']) if (typeof context.progress?.[key] === 'string') progress[key] = clean(context.progress[key]); for (const key of ['done', 'total']) if (Number.isFinite(context.progress?.[key])) progress[key] = context.progress[key]; @@ -258,9 +263,9 @@ export function failureAdvice(value, t, known = knownError(value)) { const row = (kind, title, summary, action) => ({kind, title: t(...title), summary: t(...summary), action: t(...action)}); if (known?.kind === 'sampling-table') { // Names and plan matching follow sampling_effort.js; metadata carries no user text. - const levels = [['Light', '轻量'], ['Medium', '标准'], ['High', '精细'], ['Max', '极致']]; - const index = [8, 12, 16, 20].findIndex((steps, i) => known.base_steps === steps - && known.two_pass === (i === 0) && (i !== 0 || known.refine_steps === 3)); + const levels = SAMPLING_EFFORTS.map(tier => [tier.name, tier.zh]); + const level = tableLevel(known.base_steps, known.two_pass, known.refine_steps, known.step_cache ?? null); + const index = SAMPLING_EFFORTS.findIndex(tier => tier.name === level?.name); const custom = index < 0; // Iris: a level name and its 档 never break apart. The summary is set as text, // so word joiners (U+2060) keep each group on one line instead of markup. @@ -313,7 +318,7 @@ export function failureAdvice(value, t, known = knownError(value)) { ['Generation did not start because there is not enough available RAM or GPU memory.', '可用内存或显存不足,本次生成未开始。'], ['Close memory-heavy applications, or lower the resolution or duration, then retry.', '可关闭占用内存较多的程序,或调低分辨率、缩短时长后重试。']); } - if (/commit headroom exhausted|paging file is too small|WinError 1455/i.test(text)) + if (/commit headroom exhausted|paging file is too small|WinError 1455|os error 1455/i.test(text)) return row('commit', ['Not enough Windows memory', 'Windows 内存不足'], ['Windows cannot provide enough memory for generation right now.', 'Windows 暂时无法提供生成所需的内存。'], ['Close memory-heavy applications and retry. In Windows virtual memory settings, use a system-managed paging file on a drive with free space.', '关闭占用内存较多的程序后重试。在 Windows 虚拟内存设置中,使用系统管理的分页文件,并确保所在磁盘有空闲空间。']); diff --git a/web/freevideo.js b/web/freevideo.js index 30c9829f..ad369b77 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -741,12 +741,17 @@ app.registerExtension({ if (cn) { // Reuse product wording; leave untranslated names with no established Chinese copy. for (const [name, label, tooltip] of [ + ['width', '宽度'], + ['height', '高度'], + ['seconds', '时长(秒)'], + ['seed', '种子'], ['force_regenerate', '重新生成', '跳过已保存结果的缓存。'], ['preview', '先出预览', '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], ['refine_from', '继续二采', '从这个预览继续生成全分辨率视频。'], ['continue_from', '接着生成'], ['frames', '帧数'], ['first_frame_reference', '首帧参考', '减小漂移'], + ['step_cache', '步间缓存', '复用相近采样步之间的计算,更快完成,结果会有细微变化。精细档用 easycache-av,极致档用 fbcache。'], ]) { const widget = this.widgets?.find(w => w.name === name); if (widget) { diff --git a/web/health.js b/web/health.js index c39ff858..ffd8f989 100644 --- a/web/health.js +++ b/web/health.js @@ -15,7 +15,7 @@ const launchVisit = query.get('freevideo') === 'launch'; // Every script this folder ships; a test keeps the list in step with web/*.js. export const MODULES = ['agent_activity.js', 'agent_connect.js', 'agent_session.js', 'autopilot.js', 'autopilot_chat.js', 'autopilot_conversations.js', 'browser_extension.js', 'caret_rect.js', 'chain_support.js', 'capability_view.js', 'cast_generate.js', 'cast_mention.js', 'cast_overlay.js', 'cast_source.js', 'compatibility.js', '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_expand.js', 'editor_export_dialog.js', 'editor_first_frame.js', 'editor_media_references.js', 'editor_references.js', 'editor_genbar.js', 'editor_geometry.js', 'editor_library.js', 'editor_menu.js', 'editor_model.js', 'editor_frame.js', 'editor_player.js', 'editor_room.js', 'editor_select.js', 'editor_state.js', 'editor_store.js', 'editor_tracks.js', 'editor_generation.js', 'editor_transport.js', 'editor_transport_model.js', 'editor_audio.js', 'editor_audio_model.js', 'editor_audio_notes.js', 'editor_audio_replace.js', 'editor_audio_rules.js', 'editor_speed.js', 'editor_speed_model.js', 'editor_failure.js', 'editor_header_fit.js', - 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'feedback.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', 'library.js', 'motion.js', 'output_download.js', 'port_notice.js', 'preview_scene.js', + 'editor_loader.js', 'editor_text_coverage.js', 'editor_text.js', 'editor_toolbar_more.js', 'editor_text_model.js', 'editor_text_layout.js', 'editor_notes.js', 'editor_icons.js', 'dialog_keys.js', 'done_notify.js', 'feedback.js', 'frame_sizes.js', 'health.js', 'image_commands.js', 'image_editor.js', 'image_editor_engine.js', 'image_generation.js', 'launch_visit.js', 'launcher.js', 'length_plan.js', 'lora_triggers.js', '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', 'prompt_terms.js', 'request_assets.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', 'optional_inputs.js', 'studio_queue.js', 'studio_size.js', 'toolbar_position.js', 'updates.js', 'view_navigation.js', 'wordmark.js', 'cast_library.js', 'media_upload.js']; diff --git a/web/image_editor.css b/web/image_editor.css index 89db2ed0..6de00b22 100644 --- a/web/image_editor.css +++ b/web/image_editor.css @@ -160,7 +160,8 @@ button.fv-ie-size:hover{background:var(--fv-hover);color:var(--fv-ink)} .fv-ie-popover{position:absolute;top:12px;right:12px;width:min(320px,calc(100% - 24px));display:flex;flex-direction:column;gap:10px;padding:14px; border:1px solid var(--fv-border);border-radius:var(--fv-r-md);background:var(--fv-card);box-shadow:0 14px 40px rgba(0,0,0,.5);z-index:2} .fv-ie-popover h3{margin:0;font-size:var(--fv-body);font-weight:var(--fv-semibold)} -.fv-ie-pad-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px} +.fv-ie-pad-grid{display:grid;grid-template-columns:minmax(52px,max-content) minmax(0,1fr) minmax(52px,max-content) minmax(0,1fr);gap:8px} +.fv-ie-pad-grid .fv-ie-field{display:grid;grid-template-columns:subgrid;grid-column:span 2} /* Image size: presets as wide as their words, width and height either side of the chain. */ .fv-ie-image-size p{margin:0} .fv-ie-presets{display:flex;flex-wrap:nowrap} diff --git a/web/prompt_draft.js b/web/prompt_draft.js index 7fa30fa5..9b8e419c 100644 --- a/web/prompt_draft.js +++ b/web/prompt_draft.js @@ -2,9 +2,12 @@ // alongside the existing schema-1 prompt draft; ordinary reloads keep their graph. const FILE = '/userdata/' + encodeURIComponent('freevideo/prompt-draft.json'); const WIDGETS = ['text', 'width', 'height', 'seconds', 'seed', 'control_after_generate', 'two_pass', - 'base_steps', 'refine_steps', 'force_regenerate', 'preview', 'refine_from', 'continue_from', 'frames', 'first_frame_reference']; -const DEFAULTS = ['', 1344, 768, 10, 2026090903, 'randomize', true, 8, 3, false, false, '', '', 0, false]; -const SAVED_WIDGETS = WIDGETS.filter(name => !['text', 'force_regenerate', 'refine_from', 'continue_from', 'frames'].includes(name)); + 'base_steps', 'refine_steps', 'force_regenerate', 'preview', 'refine_from', 'continue_from', 'frames', 'first_frame_reference', + 'voice_reference', 'hide_tail', 'step_cache']; +const DEFAULTS = ['', 1344, 768, 10, 2026090903, 'randomize', true, 8, 3, false, false, '', '', 0, false, false, false, 'none']; +const SAVED_WIDGETS = WIDGETS.filter(name => !['text', 'force_regenerate', 'refine_from', 'continue_from', 'frames', + 'voice_reference', 'hide_tail'].includes(name)); +const STEP_CACHE_OPTIONS = ['none', 'easycache-av', 'fbcache']; const PROPERTIES = ['freevideo_aspect', 'freevideo_pixels', 'freevideo_seconds', 'freevideo_single', 'freevideo_block_seconds', 'freevideo_run_mode', 'freevideo_batch_count']; const OUTBOX = 'freevideo.prompt-pending'; @@ -46,7 +49,8 @@ export function applyPromptDraft(workflow, draft) { for (const name of SAVED_WIDGETS) { const value = draft.widgets?.[name], index = WIDGETS.indexOf(name); if (value === undefined || typeof value !== typeof DEFAULTS[index] - || (typeof value === 'number' && !Number.isFinite(value))) continue; + || (typeof value === 'number' && !Number.isFinite(value)) + || (name === 'step_cache' && !STEP_CACHE_OPTIONS.includes(value))) continue; while (node.widgets_values.length <= index) node.widgets_values.push(DEFAULTS[node.widgets_values.length]); node.widgets_values[index] = value; } diff --git a/web/sampling_effort.js b/web/sampling_effort.js index 40ffcd9f..966b2df9 100644 --- a/web/sampling_effort.js +++ b/web/sampling_effort.js @@ -1,20 +1,54 @@ // One editor control. The queued request remains an immutable graph snapshot. -// Each level is a complete plan: Light is two-pass 8 + 3; the others sample once. -export const SAMPLING_EFFORTS = Object.freeze([ - {name: 'Light', zh: '轻量', steps: 8, twoPass: true, color: '#3dd6b5'}, - {name: 'Medium', zh: '标准', steps: 12, twoPass: false, color: '#4ea8ff'}, - {name: 'High', zh: '精细', steps: 16, twoPass: false, color: '#9d7bff'}, - {name: 'Max', zh: '极致', steps: 20, twoPass: false, color: '#ffa94d'}, -].map(Object.freeze)); +// Each level is a complete plan (freevideo_engine/tiers.py): Light is two-pass 8 + 3; +// the others sample once, High and Max with a step cache. +const frozen = rows => Object.freeze(rows.map(row => Object.freeze({...row}))); +export const SAMPLING_EFFORTS = frozen([ + {name: 'Light', zh: '轻量', steps: 8, twoPass: true, stepCache: null, color: '#3dd6b5'}, + {name: 'Medium', zh: '标准', steps: 8, twoPass: false, stepCache: null, color: '#4ea8ff'}, + {name: 'High', zh: '精细', steps: 20, twoPass: false, stepCache: 'easycache-av', color: '#9d7bff'}, + {name: 'Max', zh: '极致', steps: 20, twoPass: false, stepCache: 'fbcache', color: '#ffa94d'}, +]); +// Mac (MPS) has no step caches yet: High samples 16 steps and Max 20, both in full. +const MAC = {High: {steps: 16, stepCache: null}, Max: {stepCache: null}}; +export const SAMPLING_EFFORTS_MAC = frozen(SAMPLING_EFFORTS.map(tier => ({...tier, ...MAC[tier.name]}))); export const effortName = (t, tier) => t(tier.name, tier.zh); +export const STEP_CACHES = Object.freeze([null, 'easycache-av', 'fbcache']); + +// The cache a `step_cache` value names; 'none', '' and a missing value sample in full. +export const stepCacheOf = value => (typeof value === 'string' && value && value !== 'none' ? value : null); + +// The levels of the machine a capability grid describes: its own table when the +// grid lists one, else the Mac table on a Mac, else the default table. +export function effortsFrom(grid) { + const base = grid?.machine?.system === 'Darwin' ? SAMPLING_EFFORTS_MAC : SAMPLING_EFFORTS; + const tiers = grid?.tiers; + if (!tiers || typeof tiers !== 'object') return base; + return frozen(base.map(tier => { + const row = tiers[tier.name.toLowerCase()]; + if (!row || !Number.isInteger(row.steps) || typeof row.two_pass !== 'boolean') return tier; + return {...tier, steps: row.steps, twoPass: row.two_pass, + stepCache: 'step_cache' in row ? stepCacheOf(row.step_cache) : tier.stepCache}; + })); +} // The level a plan matches, or undefined for custom steps. -export function effortFor(steps, twoPass, refine) { - return SAMPLING_EFFORTS.find(tier => tier.steps === steps && tier.twoPass === twoPass && (!twoPass || refine === 3)); +export function effortFor(steps, twoPass, refine, stepCache = null, levels = SAMPLING_EFFORTS) { + const cache = stepCacheOf(stepCache); + return levels.find(tier => tier.steps === steps && tier.twoPass === twoPass && (!twoPass || refine === 3) + && tier.stepCache === cache); } -export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { +// The level whose sampling table a plan needs, for naming a missing download (tiers.table_level): +// the exact level of either table (the cache tells High from Max at 20 steps; 20 steps +// without a cache is Max and 16 steps High, as on Mac), else the first level with those steps. +export function tableLevel(steps, twoPass, refine, stepCache = null) { + return effortFor(steps, twoPass, refine, stepCache) || effortFor(steps, twoPass, refine, stepCache, SAMPLING_EFFORTS_MAC) + || SAMPLING_EFFORTS.find(tier => tier.steps === steps && tier.twoPass === twoPass && (!twoPass || refine === 3)); +} + +export function createSamplingEffort(t, {onChange, onPreview = () => {}, levels = SAMPLING_EFFORTS}) { + let efforts = levels; const el = (tag, cls, text) => { const e = document.createElement(tag); e.className = cls; if (text) e.textContent = text; return e; }; const element = el('div', 'fv-effort'); const header = el('div', 'fv-effort-heading'); @@ -33,7 +67,7 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { const knob = el('span', 'fv-effort-knob'); track.append(knob); rail.append(track); knob.addEventListener('animationend', () => knob.classList.remove('fv-effort-pop')); const names = el('div', 'fv-effort-names'); - const nameButtons = SAMPLING_EFFORTS.map((tier, i) => { + const nameButtons = efforts.map((tier, i) => { const b = el('button', 'fv-effort-name', effortName(t, tier)); b.type = 'button'; b.tabIndex = -1; b.style.setProperty('--fv-stop', i / 3); b.onclick = () => { if (!disabled && pointer === null) commit(i); }; @@ -41,10 +75,10 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { }); const custom = el('span', 'fv-effort-custom', t('Custom steps', '自定义步数')); custom.hidden = true; element.append(header, rail, names, custom); - let selected = 0, disabled = false, pointer = null, draft = 0, currentSteps = 8, currentRefine = 3, twoPass = true; + let selected = 0, disabled = false, pointer = null, draft = 0, currentSteps = 8, currentRefine = 3, twoPass = true, stepCache = null, reason = ''; function paint(position) { element.style.setProperty('--fv-position', position / 3); - const index = Math.round(position), tier = SAMPLING_EFFORTS[index]; + const index = Math.round(position), tier = efforts[index]; nameButtons.forEach((b, i) => b.toggleAttribute('data-active', i === index)); stops.forEach((stop, i) => stop.toggleAttribute('data-filled', i <= position + .01)); rail.setAttribute('aria-valuenow', String(index)); @@ -57,13 +91,13 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { } function commit(index) { selected = index; custom.hidden = true; delete element.dataset.custom; - paint(index); onChange(SAMPLING_EFFORTS[index]); + paint(index); onChange(efforts[index]); knob.classList.remove('fv-effort-pop'); void knob.offsetWidth; knob.classList.add('fv-effort-pop'); } function cancel() { const id = pointer; pointer = null; delete rail.dataset.dragging; if (id !== null && rail.hasPointerCapture(id)) rail.releasePointerCapture(id); - update({baseSteps: currentSteps, refineSteps: currentRefine, twoPass, disabled}); + update({baseSteps: currentSteps, refineSteps: currentRefine, twoPass, stepCache, disabled, reason}); onPreview(null); } rail.onpointerdown = event => { @@ -71,11 +105,11 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { if (disabled || event.button !== 0 || pointer !== null) return; event.preventDefault(); rail.focus({preventScroll: true, focusVisible: false}); pointer = event.pointerId; rail.setPointerCapture(pointer); rail.dataset.dragging = 'true'; - draft = position(event); paint(draft); onPreview(SAMPLING_EFFORTS[Math.round(draft)]); + draft = position(event); paint(draft); onPreview(efforts[Math.round(draft)]); }; rail.onpointermove = event => { if (event.pointerId !== pointer) return; - draft = position(event); paint(draft); onPreview(SAMPLING_EFFORTS[Math.round(draft)]); + draft = position(event); paint(draft); onPreview(efforts[Math.round(draft)]); }; rail.onpointerup = event => { if (event.pointerId !== pointer) return; @@ -95,21 +129,30 @@ export function createSamplingEffort(t, {onChange, onPreview = () => {}}) { }; function update(state) { currentSteps = Number(state.baseSteps); currentRefine = Number(state.refineSteps); - twoPass = state.twoPass; disabled = state.disabled; + twoPass = state.twoPass; stepCache = stepCacheOf(state.stepCache); disabled = state.disabled; reason = state.reason || ''; rail.tabIndex = disabled ? -1 : 0; rail.setAttribute('aria-disabled', String(disabled)); element.dataset.disabled = String(disabled); if (pointer !== null) return; - const exact = SAMPLING_EFFORTS.findIndex(v => v.steps === currentSteps); - selected = exact < 0 ? SAMPLING_EFFORTS.reduce((best, v, i) => Math.abs(v.steps - currentSteps) < Math.abs(SAMPLING_EFFORTS[best].steps - currentSteps) ? i : best, 0) : exact; - const isCustom = !effortFor(currentSteps, twoPass, currentRefine); + const level = effortFor(currentSteps, twoPass, currentRefine, stepCache, efforts); + // Custom steps sit at the nearest level, preferring one with the same pass count. + const distance = v => Math.abs(v.steps - currentSteps) * 2 + (v.twoPass !== twoPass); + selected = level ? efforts.indexOf(level) : efforts.reduce((best, v, i) => distance(v) < distance(efforts[best]) ? i : best, 0); + const isCustom = !level; element.dataset.custom = String(isCustom); custom.hidden = !isCustom; const unit = currentSteps === 1 && !twoPass ? t('step', '步') : t('steps', '步'); custom.textContent = `${t('Custom', '自定义')} · ${currentSteps}${twoPass ? ' + ' + currentRefine : ''} ${unit}`; paint(selected); if (isCustom) rail.setAttribute('aria-valuetext', custom.textContent); - rail.title = disabled ? state.reason || t('Controlled by connected nodes.', '由连接的节点控制。') + rail.title = disabled ? reason || t('Controlled by connected nodes.', '由连接的节点控制。') : t('Same model. Higher effort uses more sampling steps; results vary by scene.', '使用同一模型,提高档位会增加采样步数;效果因场景而异。'); for (const name of nameButtons) { name.disabled = disabled; name.title = ''; } } - return {element, update, setEstimate(text, title = '') { estimate.textContent = text; if (title) estimate.title = title; else estimate.removeAttribute('title'); }, dispose: cancel}; + // The machine's own table (effortsFrom); the names stay, the plans follow the machine. + function setLevels(next) { + const same = (a, b) => a.steps === b.steps && a.twoPass === b.twoPass && a.stepCache === b.stepCache; + if (!next || next.length !== efforts.length || next.every((tier, i) => same(tier, efforts[i]))) return; + efforts = next; + update({baseSteps: currentSteps, refineSteps: currentRefine, twoPass, stepCache, disabled, reason}); + } + return {element, update, setLevels, levels: () => efforts, setEstimate(text, title = '') { estimate.textContent = text; if (title) estimate.title = title; else estimate.removeAttribute('title'); }, dispose: cancel}; } diff --git a/web/share.js b/web/share.js index ddc034a9..f0aea76d 100644 --- a/web/share.js +++ b/web/share.js @@ -1,6 +1,6 @@ import {api} from '../../scripts/api.js'; import {closeDialog} from './motion.js'; -import {effortFor, effortName} from './sampling_effort.js'; +import {SAMPLING_EFFORTS_MAC, effortFor, effortName} from './sampling_effort.js'; import {fontCSS as bundledFontCSS, fontLoaded, loadFont} from './fonts.js'; import {shareLayout} from './share_layout.js'; import {videoUnavailableText} from './studio_extend.js'; @@ -38,7 +38,9 @@ export function drawShareCard(canvas,frame,logo,record,t) { const ctx=canvas.getContext('2d'),w=shape.width,y=shape.rect[3],H=shape.footer,pad=44,wide=w>=1000; const p=record.sampling_plan||{},steps=p.base_steps,refine=p.enabled?p.refine_steps:0; // Results made with the earlier 8 + 2 default count as Light. - const tier=effortFor(steps,!!p.enabled,p.enabled&&steps===8&&refine===2?3:refine), accent=tier?.color||'#8fa3bb'; + // A Mac result names its level from the Mac table (High 16 steps, Max 20 without a cache). + const plan=[steps,!!p.enabled,p.enabled&&steps===8&&refine===2?3:refine,p.step_cache??null]; + const tier=effortFor(...plan)||effortFor(...plan,SAMPLING_EFFORTS_MAC), accent=tier?.color||'#8fa3bb'; ctx.fillStyle='#0b1016';ctx.fillRect(0,0,w,shape.height);ctx.clearRect(...shape.rect); const bg=ctx.createLinearGradient(0,y,0,y+H);bg.addColorStop(0,'#131b26');bg.addColorStop(1,'#0b1016'); ctx.fillStyle=bg;ctx.fillRect(0,y,w,H); diff --git a/web/studio.css b/web/studio.css index f25d4679..e82dd7f3 100644 --- a/web/studio.css +++ b/web/studio.css @@ -35,7 +35,7 @@ .fv-studio .fv-resolution-select option{color:var(--fv-ink)} .fv-studio .fv-resolution-value{position:absolute;inset:0 36px 0 12px;display:flex;align-items:center;white-space:nowrap;pointer-events:none} .fv-studio .fv-resolution-select>.fv-icon{position:absolute;right:12px;top:50%;transform:translateY(-50%);pointer-events:none} -.fv-studio .fv-resolution-select:has(select:disabled)>:not(select){opacity:.5} +.fv-studio .fv-resolution-select select:disabled~*{opacity:.5} .fv-studio .fv-shapes{position:relative;isolation:isolate;display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:14px} .fv-studio .fv-shapes::before{content:'';position:absolute;inset:0 auto 0 0;width:calc((100% - 24px)/4);border:1px solid var(--fv-accent);border-radius:var(--fv-r-sm);background:var(--fv-selected);transform:translateX(calc(var(--fv-shape-index,0)*(100% + 8px)));transition:transform .38s cubic-bezier(.22,.7,.2,1),opacity .2s;pointer-events:none;z-index:-1} .fv-studio .fv-shapes[data-custom=true]::before{opacity:0} @@ -211,7 +211,8 @@ .fv-studio .fv-chain-part .fv-chain-part-text:disabled{opacity:1;color:var(--fv-disabled)} .fv-studio .fv-chain-part:has(.fv-chain-part-text:disabled) :is(.fv-chain-part-name,.fv-chain-part-tag:not([data-kind=error])){color:var(--fv-disabled)} .fv-studio .fv-chain-part-text[hidden]{display:none} -.fv-studio .fv-chain-part .fv-chain-part-editor{min-height:76px;height:96px;max-height:220px;padding:6px 10px;font-size:var(--fv-micro);line-height:20px;outline:none} +.fv-studio .fv-chain-part-edit{position:relative;min-width:0} +.fv-studio .fv-chain-part .fv-chain-part-editor{min-height:76px;height:96px;max-height:220px;padding:6px 36px 6px 10px;font-size:var(--fv-micro);line-height:20px;outline:none} .fv-studio .fv-chain-part-tag{padding:7px 8px 0 0;color:var(--fv-muted);white-space:nowrap} .fv-studio .fv-chain-part-tag:empty{display:none} .fv-studio .fv-chain-part-tag[data-kind=error]{color:var(--fv-warning);white-space:normal;max-width:16em;text-align:right} diff --git a/web/studio.js b/web/studio.js index 2816d039..79375190 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 {createExpandButton} from './editor_expand.js'; import {cleanOptionalPrompt} from './optional_inputs.js'; import { app } from '../../scripts/app.js'; import { api } from '../../scripts/api.js'; @@ -25,7 +26,7 @@ import { attachMentionOverlay } from './cast_overlay.js'; import { openCastLibrary } from './cast_source.js'; import { uploadMedia } from './media_upload.js'; import { reconcileLoraTriggers } from './lora_triggers.js'; -import { createSamplingEffort } from './sampling_effort.js'; +import { createSamplingEffort, effortFor, effortsFrom, stepCacheOf } from './sampling_effort.js'; import { resultActions, previewBadge, icon, actionsUnavailable } from './result_actions.js'; import { createPromptEnhancer } from './prompt_enhance.js'; import { createRequestAssets, enabledLoras, referenceFiles } from './request_assets.js'; @@ -73,12 +74,17 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = { const rows = new Map(), timers = new Map(); let editing = null, showing = null; function tag(row, text, kind = '') { row.tag.textContent = text; row.tag.dataset.kind = kind; } + function removeEditor(row) { + row.expand?.dispose(); row.expand = null; + row.editBox?.remove(); row.editBox = row.editor = null; row.draft = null; + row.text.hidden = false; + } function draw(row) { const {part} = row, writing = editing === row; row.element.dataset.state = part.editable ? 'pending' : part.state; row.text.textContent = part.text; row.text.disabled = !part.editable; - if (!writing && row.editor) { row.editor.remove(); row.editor = null; row.text.hidden = false; } + if (!writing && row.editor) removeEditor(row); // A note about a lost edit stays on its line until that part is done. if (row.tag.dataset.kind === 'error' && part.state === 'done') row.tag.dataset.kind = ''; if (timers.has(part.id) || row.tag.dataset.kind === 'error') return; @@ -90,6 +96,7 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = { } async function finish(row, keep) { if (editing !== row) return; + row.expand?.commit(); const before = row.part.text, value = row.editor.value; editing = null; if (!keep || !value.trim() || value === before) { draw(row); return; } @@ -111,29 +118,45 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = { if (editing) finish(editing, true); editing = row; clearTimeout(timers.get(row.part.id)); timers.delete(row.part.id); tag(row, ''); const editor = row.editor = el('textarea', null, 'fv-chain-part-editor'); - editor.maxLength = 20000; editor.value = row.part.text; + editor.maxLength = 20000; editor.value = row.draft = row.part.text; editor.setAttribute('aria-label', t(`Part ${row.part.index + 1} description`, `第 ${row.part.index + 1} 段描述`)); - editor.addEventListener('blur', () => finish(row, true)); + editor.addEventListener('input', () => { row.draft = editor.value; }); editor.addEventListener('keydown', event => { // Escape leaves the edit, never the studio. if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); finish(row, false); row.text.focus(); } }); - row.text.hidden = true; row.text.after(editor); editor.focus(); + const box = row.editBox = el('div', null, 'fv-chain-part-edit'); + row.expand = createExpandButton({t, textarea: editor, canGenerate: () => false, + // Like the main description, keep a live draft while preserving the small box's undo history. + onDraft: text => { row.draft = text; }}); + box.append(editor, row.expand.element); + box.addEventListener('focusout', event => { + if (!row.expand?.isOpen && !box.contains(event.relatedTarget)) finish(row, true); + }); + row.text.hidden = true; row.text.after(box); editor.focus(); editor.setSelectionRange(editor.value.length, editor.value.length); editor.scrollIntoView({block: 'nearest'}); } function clear() { if (!element.hidden) onHide(); - element.hidden = true; element.replaceChildren(); rows.clear(); editing = null; showing = null; + editing = null; + for (const row of rows.values()) removeEditor(row); + element.hidden = true; element.replaceChildren(); rows.clear(); showing = null; for (const id of timers.values()) clearTimeout(id); timers.clear(); } return {element, clear, update(chain, active) { // The list belongs to one generation: it shows once the descriptions are written and closes when that ends. - if (!active || !chain?.split || chainSplit(chain).writing) { if (!active || showing !== chain?.id) clear(); return; } + if (!active || !chain?.split || chainSplit(chain).writing) { clear(); return; } if (showing !== chain.id) clear(); showing = chain.id; const first = element.hidden; element.hidden = false; - chainParts(chain).forEach((part, index) => { + const parts = chainParts(chain), ids = new Set(parts.map(part => part.id)); + for (const [id, row] of rows) if (!ids.has(id)) { + if (editing === row) editing = null; + removeEditor(row); row.element.remove(); rows.delete(id); + clearTimeout(timers.get(id)); timers.delete(id); + } + parts.forEach((part, index) => { part.state = chain.blocks[index].state === 'pending' && chain.blocks[index].prompt_id != null ? 'queued' : chain.blocks[index].state; let row = rows.get(part.id); if (!row) { @@ -145,7 +168,7 @@ function createChainPartList(t, save, {onShow = () => {}, onHide = () => {}} = { } if (editing === row && !part.editable) { // The part began while it was open: the words typed so far cannot reach it. - const changed = row.editor.value !== row.part.text; + const changed = row.draft !== row.part.text; editing = null; row.part = part; draw(row); if (changed) flash(row, t('This part is already generating. Your edit was not saved.', '这一段已在生成,改动没有保存'), 'error'); return; @@ -672,6 +695,9 @@ export function openStudio(node, {extendFrom = null} = {}) { }); twoPassLabel.append(el('span', t('Two-pass', '二次采样')), twoPassStatus, passMode); let effortEstimate = {}, effortEstimateKey = '', effortEstimateTimer = null, effortEstimateRequest = null, lengthControl = null, chainController = null; + // A level's step cache; custom steps and workflows without the input sample every step. + const stepCacheNow = () => stepCacheOf(value(node, 'step_cache')); + const clearStepCache = () => { if (stepCacheNow()) set(node, 'step_cache', 'none'); }; const effort = createSamplingEffort(t, {onChange: tier => { baseSteps.value = tier.steps; refineSteps.value = 3; @@ -679,14 +705,15 @@ export function openStudio(node, {extendFrom = null} = {}) { autoSinglePass = false; set(node, 'two_pass', twoPass.checked); set(node, 'refine_steps', 3); + set(node, 'step_cache', tier.stepCache || 'none'); syncSamplingSteps(); - }, onPreview: tier => tier ? showEffortEstimate(tier.steps, tier.twoPass) : showEffortEstimate()}); + }, onPreview: tier => tier ? showEffortEstimate(tier.steps, tier.twoPass, tier.stepCache) : showEffortEstimate()}); const samplingSettings = el('div', null, 'fv-sampling-settings'); const previewFirst = el('input'); previewFirst.type = 'checkbox'; previewFirst.checked = value(node, 'preview') === true; function choosePassMode(mode) { if (extension.source) return; const wantsTwoPass = mode !== 'off', wantsPreview = mode === 'preview'; - if (twoPass.checked !== wantsTwoPass) { twoPass.checked = wantsTwoPass; autoSinglePass = false; set(node, 'two_pass', wantsTwoPass); } + if (twoPass.checked !== wantsTwoPass) { twoPass.checked = wantsTwoPass; autoSinglePass = false; set(node, 'two_pass', wantsTwoPass); clearStepCache(); } if (previewFirst.checked !== wantsPreview) { previewFirst.checked = wantsPreview; set(node, 'preview', wantsPreview); } syncSamplingSteps(); updateRunButton(); } @@ -775,6 +802,8 @@ export function openStudio(node, {extendFrom = null} = {}) { input.onchange = () => input.reportValidity(); samplingFields.append(field(label, input)); } + // Typed steps are custom sampling, which has no step cache. + baseSteps.oninput = () => { clearStepCache(); syncSamplingSteps(); }; // Apply the two-pass rule to committed values, never while typing. baseSteps.onchange = () => { // Before validation: one step is valid only once two-pass is off. @@ -790,7 +819,7 @@ export function openStudio(node, {extendFrom = null} = {}) { // The run button exists only further down; until then its label is set there. let runButtonReady = false; function syncSamplingSteps() { - if (extension.source) { baseSteps.value = '8'; refineSteps.value = '3'; twoPass.checked = true; set(node, 'two_pass', true); autoSinglePass = false; } + if (extension.source) { baseSteps.value = '8'; refineSteps.value = '3'; twoPass.checked = true; set(node, 'two_pass', true); clearStepCache(); autoSinglePass = false; } const baseLinked = linked(node, 'base_steps'); // Two-pass is offered at 8 first-pass steps. A saved workflow that already // uses it with other steps is shown as custom and left unchanged. @@ -819,13 +848,14 @@ export function openStudio(node, {extendFrom = null} = {}) { } } effort.update({baseSteps: Number(baseSteps.value), refineSteps: Number(refineSteps.value), - twoPass: twoPass.checked, disabled: baseSteps.disabled || linked(node, 'two_pass') || linked(node, 'refine_steps'), + twoPass: twoPass.checked, stepCache: stepCacheNow(), + disabled: baseSteps.disabled || linked(node, 'two_pass') || linked(node, 'refine_steps') || linked(node, 'step_cache'), reason: extension.source ? t('A continuation uses the Light level.', '接着生成使用轻量档。') : ''}); refreshEffortEstimate(); } syncSamplingSteps(); advanced.append(samplingFields, samplingWarning); - function showEffortEstimate(steps = Number(baseSteps.value), twoPassPlan = twoPass.checked) { + function showEffortEstimate(steps = Number(baseSteps.value), twoPassPlan = twoPass.checked, cache = stepCacheNow()) { const parts = lengthControl.plan?.count > 1 ? t(`${lengthControl.plan.count} parts`, `分 ${lengthControl.plan.count} 段`) : ''; // A request that fits one run keeps its own estimate while the longest run is unknown. if ((lengthControl.capabilityState === 'pending' || lengthControl.capabilityState === 'unavailable') @@ -838,16 +868,16 @@ export function openStudio(node, {extendFrom = null} = {}) { if (lengthControl.needsPlan && !lengthControl.plan) { effort.setEstimate(''); return; } if (lengthControl.plan?.count > 1) { const plan = lengthControl.plan; - const seconds = steps === Number(baseSteps.value) && twoPassPlan === twoPass.checked + const seconds = steps === Number(baseSteps.value) && twoPassPlan === twoPass.checked && cache === stepCacheNow() ? sumPartEstimates(plan, lengthControl.estimate) : null; effort.setEstimate(seconds > 0 ? t(`${plan.count} parts · about ${Math.ceil(seconds / 60)} min`, `分 ${plan.count} 段 · 约 ${Math.ceil(seconds / 60)} 分钟`) : t(`${plan.count} parts`, `分 ${plan.count} 段`)); return; } - const rangeEstimate = capabilityView?.estimate(steps, twoPassPlan, arguments.length ? 3 : Number(refineSteps.value)); + const rangeEstimate = capabilityView?.estimate(steps, twoPassPlan, arguments.length ? 3 : Number(refineSteps.value), cache); if (rangeEstimate) { effort.setEstimate(rangeEstimate); return; } const mode = twoPassPlan ? 'two_pass' : 'single'; - const estimate = effortEstimate[mode]?.[steps]; + const estimate = effortEstimate[mode]?.[cache ? `${steps}:${cache}` : String(steps)]; // A previewed level always refines three steps; the current plan may not. if (estimate?.status !== 'estimated' || (mode === 'two_pass' && !arguments.length && Number(refineSteps.value) !== 3)) { effort.setEstimate('', t('No estimate is available for these settings.', '当前设置暂无预计用时。')); @@ -880,8 +910,9 @@ export function openStudio(node, {extendFrom = null} = {}) { const requestWidth = extension.source?.geometry.width ?? value(node, 'width'); const requestHeight = extension.source?.geometry.height ?? value(node, 'height'); lengthControl?.refresh({width: requestWidth, height: requestHeight, task, adapters, - settings: {tier: twoPass.checked ? 'light' : ({12: 'medium', 16: 'high', 20: 'max'}[Number(baseSteps.value)] || 'custom'), - base_steps: Number(baseSteps.value), refine_steps: Number(refineSteps.value), two_pass: twoPass.checked}}, force); + settings: {tier: effortFor(Number(baseSteps.value), twoPass.checked, Number(refineSteps.value), stepCacheNow(), effort.levels())?.name.toLowerCase() || 'custom', + base_steps: Number(baseSteps.value), refine_steps: Number(refineSteps.value), two_pass: twoPass.checked, + step_cache: stepCacheNow()}}, force); requestAssetsQuery = () => { const query = new URLSearchParams({width: requestWidth, height: requestHeight, seconds: value(node, 'seconds'), task, base_steps: baseSteps.value, refine_steps: refineSteps.value, two_pass: twoPass.checked ? '1' : '0', @@ -1581,7 +1612,8 @@ export function openStudio(node, {extendFrom = null} = {}) { dialog.onclose = () => { disposed = true; for (const f of cleanup) f(); stage.querySelector('video')?.pause(); dialog.remove(); if (current?.dialog === dialog) { current = null; viewChanged('nodes', node); } }; capabilityView = createCapabilityView({node, api, t, getState: () => ({ratio, custom: selected === 'custom', steps: Number(baseSteps.value), twoPass: twoPass.checked, - refine: Number(refineSteps.value), disabled: Boolean(extension.source) || dimensionsLinked || seconds.disabled}), onEstimate: () => showEffortEstimate(), onPick: point => { + refine: Number(refineSteps.value), stepCache: stepCacheNow(), disabled: Boolean(extension.source) || dimensionsLinked || seconds.disabled}), + onEstimate: () => showEffortEstimate(), onGrid: grid => effort.setLevels(effortsFrom(grid)), onPick: point => { pixels = point.pixels; mp.value = String(pixels); set(node, 'width', point.width); set(node, 'height', point.height); node.properties.freevideo_pixels = pixels; diff --git a/web/studio_extend.js b/web/studio_extend.js index 31c4c039..a5453ac8 100644 --- a/web/studio_extend.js +++ b/web/studio_extend.js @@ -56,7 +56,7 @@ export async function readExtendRecord(api, record) { } export function createExtendState(node, read, write) { - const keys = ['width', 'height', 'seconds', 'two_pass', 'preview', 'base_steps', 'refine_steps']; + const keys = ['width', 'height', 'seconds', 'two_pass', 'preview', 'base_steps', 'refine_steps', 'step_cache']; return { get source() { return node.properties?.[EXTEND_PROPERTY]?.source || null; }, enter(record) { @@ -76,8 +76,8 @@ export function createExtendState(node, read, write) { node.properties[EXTEND_PROPERTY] = {source, previous}; for (const [key, value] of Object.entries({width: g.width, height: g.height, seconds: Math.min(60, seconds), text: '', preview: false, - // A continuation always runs the Light plan: two passes, 8 + 3 steps. - two_pass: true, base_steps: 8, refine_steps: 3})) write(key, value); + // A continuation always runs the Light plan: two passes, 8 + 3 steps, no step cache. + two_pass: true, base_steps: 8, refine_steps: 3, step_cache: 'none'})) write(key, value); node.properties.freevideo_seconds = seconds; node.properties.freevideo_aspect = 'custom'; node.properties.freevideo_pixels = g.width * g.height / 1024 ** 2; @@ -101,7 +101,7 @@ export function extendSnapshot(snapshot, node, source, {multiple = false} = {}) const copy = structuredClone(snapshot), inputs = copy.output[String(node)].inputs; inputs.width = source.geometry.width; inputs.height = source.geometry.height; inputs.preview = false; inputs.refine_from = ''; inputs.two_pass = true; - for (const [key, value] of [['base_steps', 8], ['refine_steps', 3]]) if (key in inputs) inputs[key] = value; + for (const [key, value] of [['base_steps', 8], ['refine_steps', 3], ['step_cache', 'none']]) if (key in inputs) inputs[key] = value; // A continuation takes no first or last frame and no pre-encoded conditioning. for (const key of ['first', 'last', 'conditioning']) delete inputs[key]; if (multiple) delete inputs.continue_from; else inputs.continue_from = source.video;