From f751486664c9c038b57b8e2bff9c0a0b5b4df8f6 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:53 +0000 Subject: [PATCH 01/46] Long videos: keep each part's voice as loud and as full as in the first part Over several continued parts the voice could get louder and fuller in the low range. When parts have spoken lines, the join now measures the voice in each one and, only where a part has drifted more than 2 dB from the first, brings it back by at most 6 dB with one gain and one low-range shelf, easing in over half a second after the seam. A part's whole sound changes together, so music and background keep their place against the voice; parts without spoken lines and joins without any change are written exactly as before. --- freevideo_engine/chain_join.py | 89 +++++++++-- freevideo_engine/chain_level.py | 270 ++++++++++++++++++++++++++++++++ freevideo_engine/comfy_chain.py | 5 +- 3 files changed, 348 insertions(+), 16 deletions(-) create mode 100644 freevideo_engine/chain_level.py diff --git a/freevideo_engine/chain_join.py b/freevideo_engine/chain_join.py index 1116a6c1..5abfc0dd 100644 --- a/freevideo_engine/chain_join.py +++ b/freevideo_engine/chain_join.py @@ -24,6 +24,19 @@ class JoinCancelled(InterruptedError): pass +class _VoiceFailed(Exception): + """The optional voice step failed partway: the join is done again without it.""" + + +def _voice_step(call, *args): + try: + return call(*args) + except JoinCancelled: + raise + except Exception as error: + raise _VoiceFailed() from error + + class _ExactEndFailed(Exception): pass @@ -147,7 +160,7 @@ def _exact_tail(source, target, wanted, cancelled=None): temporary.unlink(missing_ok=True) -def join_files(sources, target, cancelled=None, frames=None, leads=None, expected=None): +def join_files(sources, target, cancelled=None, frames=None, leads=None, expected=None, speaking=None): """Write `target` from `sources` in order; returns the joined frame and sample counts. `frames`: stop at this length when a compatible short tail can close it exactly, @@ -158,13 +171,23 @@ def join_files(sources, target, cancelled=None, frames=None, leads=None, expecte """ sources = list(sources) try: - return _join_files(sources, target, cancelled, frames, leads=leads, expected=expected) + return _join_ending(sources, target, cancelled, frames, leads, expected, speaking) + except _VoiceFailed: + logging.warning('[FreeVideo] The voice of the parts could not be evened out; they are joined as they are', + exc_info=True) + return _join_ending(sources, target, cancelled, frames, leads, expected, None) + + +def _join_ending(sources, target, cancelled, frames, leads, expected, speaking): + try: + return _join_files(sources, target, cancelled, frames, leads=leads, expected=expected, speaking=speaking) except _ExactEndFailed: # A late mux or validation error must also leave the original ending available. - return _join_files(sources, target, cancelled, frames, exact=False, leads=leads, expected=expected) + return _join_files(sources, target, cancelled, frames, exact=False, leads=leads, expected=expected, + speaking=speaking) -def _join_files(sources, target, cancelled, frames, exact=True, leads=None, expected=None): +def _join_files(sources, target, cancelled, frames, exact=True, leads=None, expected=None, speaking=None): import av from .comfy_metadata import _copy_stream try: @@ -201,6 +224,7 @@ def audio_lead_in(video, previous=None): if shape[5] != FPS or step.denominator != 1: raise ValueError('The blocks are not 24 frames per second videos') step = int(step) + voice = _voice(sources, speaking, cancelled) if sound is not None else None with av.open(str(temporary), 'w', format='mp4', options={'movflags': 'use_metadata_tags+faststart'}) as output: picture = audio = fifo = joined_audio = None @@ -230,7 +254,7 @@ def audio_lead_in(video, previous=None): audio.codec_context.time_base = Fraction(1, rate) fifo = av.AudioFifo() joined_audio = _JoinedAudio(output, audio, fifo, rate, layout, - audio_lead_in, apply_audio_lead_in, cancelled) + audio_lead_in, apply_audio_lead_in, cancelled, voice) streams = [video] + ([container.streams.audio[0]] if sound is not None else []) shift, count = None, 0 # A decoder returns the encoder's padding after the last sample; the stream's own @@ -288,8 +312,10 @@ def audio_lead_in(video, previous=None): return dict(exact_end=wanted is not None and frames == wanted, frames=frames, width=shape[1], height=shape[2], audio_samples=samples, sample_rate=rate, seams_repaired=joined_audio.seams_repaired if joined_audio is not None else 0, - seams_expected=expected) - except JoinCancelled: + seams_expected=expected, + voice=[[round(setting[0], 1), round(setting[1], 1)] if setting is not None else None + for setting in voice] if voice is not None and audio is not None else None) + except (JoinCancelled, _VoiceFailed): raise except Exception as error: if used_tail: @@ -299,10 +325,26 @@ def audio_lead_in(video, previous=None): temporary.unlink(missing_ok=True) +def _voice(sources, speaking, cancelled): + """Each block's voice setting, or None: an optional step that never stops the join.""" + if speaking is None: + return None + try: + from .chain_level import plan + return plan(sources, speaking, lambda: cancelled is not None and cancelled()) + except Exception: + logging.warning('[FreeVideo] The voice of the parts could not be measured; they are joined as they are') + return None + + class _JoinedAudio: """Keep native seam windows out of the encoder until the next block's head is ready.""" - def __init__(self, output, stream, fifo, rate, layout, read_lead, apply_lead, cancelled=None): + def __init__(self, output, stream, fifo, rate, layout, read_lead, apply_lead, cancelled=None, voice=None): self.output, self.stream, self.fifo = output, stream, fifo + self.shaper = None + if voice is not None: + from .chain_level import Shaper + self.shaper = _voice_step(Shaper, voice, rate, stream.codec_context.layout.nb_channels) self.rate, self.layout = rate, layout self.read_lead, self.apply_lead = read_lead, apply_lead self.cancelled, self.next_head = cancelled, None @@ -316,7 +358,10 @@ def start(self, source, stream, limit, last=None, following=None, total_frames=N self.limit, self.heard, self.kept = limit, 0, 0 # end() has released the previous tail and flushed its resampler. Count # both encoded samples and those still waiting in the encoder queue. - joined = self.written + self.fifo.samples if total_frames is not None else 0 + joined = self.written + self.fifo.samples + (self.shaper.pending if self.shaper is not None else 0) \ + if total_frames is not None else 0 + if self.shaper is not None: + _voice_step(self.shaper.mark) if last is not None: cut = (max(0, round(total_frames * self.rate / FPS) - joined) if total_frames is not None else round((last + 1) * self.rate / FPS)) @@ -408,7 +453,7 @@ def _queue(self, data): def _encode(self, frames): self.heard, self.written = _encode(self.output, self.stream, self.fifo, frames, self.heard, self.limit, - self.written, self.rate, self.layout) + self.written, self.rate, self.layout, shaper=self.shaper) def end(self): if self.native is not None: @@ -420,7 +465,7 @@ def end(self): def finish(self): return _encode(self.output, self.stream, self.fifo, [], 0, None, - self.written, self.rate, self.layout, final=True)[1] + self.written, self.rate, self.layout, final=True, shaper=self.shaper)[1] def _audio_head(source, cancelled): @@ -452,10 +497,16 @@ def _audio_head(source, cancelled): return None -def _encode(output, stream, fifo, resampled, heard, limit, written, rate, layout, final=False): +def _encode(output, stream, fifo, resampled, heard, limit, written, rate, layout, final=False, shaper=None): """Queue one block's resampled sound up to its length and encode whole encoder frames; `final` writes the rest.""" import av import numpy as np + + def queue(data): + if data is not None: + frame = av.AudioFrame.from_ndarray(np.ascontiguousarray(data), format='fltp', layout=layout) + frame.sample_rate = rate + fifo.write(frame) for frame in resampled: keep = frame.samples if limit is None else min(frame.samples, limit - heard) if keep <= 0: @@ -466,7 +517,12 @@ def _encode(output, stream, fifo, resampled, heard, limit, written, rate, layout frame.sample_rate = rate heard += keep frame.pts = None # the queue counts samples itself: blocks follow one another without gaps - fifo.write(frame) + if shaper is not None: + queue(_voice_step(shaper.push, frame.to_ndarray())) + else: + fifo.write(frame) + if final and shaper is not None: + queue(_voice_step(shaper.flush)) size = stream.codec_context.frame_size or 1024 while fifo.samples >= size or final and fifo.samples: frame = fifo.read(min(size, fifo.samples)) @@ -506,7 +562,8 @@ def _replace_target(root, replace, identity, sources): return None -def join_outputs(outputs, identity, cancelled=None, frames=None, replace=None, leads=None, expected_seams=None): +def join_outputs(outputs, identity, cancelled=None, frames=None, replace=None, leads=None, expected_seams=None, + speaking=None): """Join relative block outputs into a saved video, replacing only this chain's earlier join.""" import folder_paths root = Path(folder_paths.get_output_directory()).resolve() @@ -537,7 +594,7 @@ def join_outputs(outputs, identity, cancelled=None, frames=None, replace=None, l originals = {str(source): output for source, output in zip(sources, outputs)} def kept(following, previous): return leads(originals.get(str(following)), originals.get(str(previous)) if previous is not None else None) - joined = join_files(sources, staged, cancelled, frames, leads=kept, expected=expected_seams) + joined = join_files(sources, staged, cancelled, frames, leads=kept, expected=expected_seams, speaking=speaking) if expected_seams is not None and joined['seams_repaired'] < expected_seams: # A seam sound left unrepaired is never reported as a finished video. raise ValueError('Only %d of %d seam sounds could be repaired' % (joined['seams_repaired'], expected_seams)) @@ -548,6 +605,8 @@ def kept(following, previous): exact_end=joined['exact_end'])) if expected_seams is not None: report['chain_join']['seams_expected'] = expected_seams + if joined.get('voice') is not None: + report['chain_join']['voice'] = joined['voice'] with temporary.open('w', encoding='utf-8') as stream: json.dump(report, stream, ensure_ascii=False) stream.flush() diff --git a/freevideo_engine/chain_level.py b/freevideo_engine/chain_level.py new file mode 100644 index 00000000..31e73f06 --- /dev/null +++ b/freevideo_engine/chain_level.py @@ -0,0 +1,270 @@ +"""Keep the voice of a long video's parts as loud, and as full, as in the first part. + +Each continued part takes its sound context from the part before it, and over +several parts the voice can drift: louder, and fuller in the low range. When the +join is told which parts have spoken lines, it measures the voice in each of them +and, only where a part has drifted more than THRESHOLD_DB from the first one, +brings it back by at most MOST_DB with one gain and one low-range shelf. A part's +whole sound is changed together, so music and background keep their place +against the voice; parts without spoken lines are left exactly as they are. Each +part is filtered on its own, so nothing spills across a seam, and a change eases +in over RAMP_SECONDS on the filtered side of the seam. +""" +import math +import re + +RATE = 32000 +THRESHOLD_DB = 2. +MOST_DB = 6. +RAMP_SECONDS = .5 +# The voice's body (fundamental and first harmonics) against its clear middle range. +LOW, MIDDLE = (60, 300), (300, 3400) +SHELF_HZ = 300. +# A part is never made louder than this peak. +HEADROOM = .95 +FRAME, HOP = 4096, 2048 +_GATE_DB, _FLOOR_DB, _LEAST = 15., -55., 20 + +# "A sign on the door says": the writing itself says the words, in the same clause. +_WRITING_SAYS = re.compile( + r'\b(?:signs?|banners?|captions?|posters?|screens?|labels?|boards?|neon|text|placards?|graffiti|headlines?|' + r'subtitles?|lettering)\b[^,;:.!?\n]*\b(?:says|said|reads|shows|displays)\s*[:,]?\s*$', re.I) + + +def voiced(text): + """Whether a part's description has a spoken line; when unsure, no (the part is then left as it is).""" + if not isinstance(text, str): + return False + from .prompt_vlm.rules import QUOTED, SPEAKER, plain, spoken_lines + if any(plain(match.group(2)) for match in SPEAKER.finditer(text)): + return True + lines = {plain(line) for line in spoken_lines(text)} + for match in QUOTED.finditer(text): + words = plain(next(group for group in match.groups() if group is not None)) + if words in lines and not _WRITING_SAYS.search(text[:match.start()]): + return True + return False + + + + +def _shelf(frequencies): + return 1. / (1. + (frequencies / SHELF_HZ) ** 2) + + +def _native(path, cancelled=None): + import av + import numpy as np + chunks = [] + with av.open(str(path)) as container: + if not container.streams.audio: + return None + stream = container.streams.audio[0] + resampler = av.AudioResampler(format='fltp', layout='stereo', rate=RATE) + for packet in container.demux(stream): + if cancelled is not None and cancelled(): + return None + for decoded in packet.decode(): + chunks.extend(frame.to_ndarray() for frame in resampler.resample(decoded)) + chunks.extend(frame.to_ndarray() for frame in resampler.resample(None)) + return np.concatenate(chunks, axis=1) if chunks else None + + +def measure(path, cancelled=None): + """The part's average spectrum over its speaking moments and its peak, or None.""" + import numpy as np + sound = _native(path, cancelled) + if sound is None or sound.shape[1] < FRAME * 2: + return None + window = np.hanning(FRAME) + count = 1 + (sound.shape[1] - FRAME) // HOP + frequencies = np.fft.rfftfreq(FRAME, 1 / RATE) + middle, low = [((frequencies >= a) & (frequencies < b)) for a, b in (MIDDLE, LOW)] + power = np.empty((count, len(frequencies))) + for start in range(0, count, 256): + rows = np.arange(start, min(count, start + 256)) + # Power per channel, then averaged: opposite channels must not cancel out. + frames = sound[:, rows[:, None] * HOP + np.arange(FRAME)] * window + power[rows] = (np.abs(np.fft.rfft(frames, axis=2)) ** 2).mean(axis=0) + power /= (FRAME * window.sum() / 2) + speech = power[:, middle].sum(axis=1) + level = 10 * np.log10(speech + 1e-20) + active = level >= max(np.percentile(level, 95) - _GATE_DB, _FLOOR_DB) + if active.sum() < _LEAST: + return None + spectrum = power[active].mean(axis=0) + return dict(spectrum=spectrum, frequencies=frequencies, peak=float(np.abs(sound).max())) + + +def _band(entry, band, shelf_db=0.): + import numpy as np + frequencies = entry['frequencies'] + weight = 10 ** (shelf_db * _shelf(frequencies) / 10) + chosen = (frequencies >= band[0]) & (frequencies < band[1]) + return 10 * math.log10(float((entry['spectrum'] * weight)[chosen].sum()) + 1e-20) + + +def _pull(difference): + if not math.isfinite(difference) or abs(difference) <= THRESHOLD_DB: + return 0. + return max(-MOST_DB, min(MOST_DB, difference)) + + +def _fit(gain, shelf, room): + """Lower a boost (gain, then a raised shelf) to at most `room` dB.""" + excess = gain + max(shelf, 0.) - max(room, 0.) + if excess > 0: + cut = min(excess, max(gain, 0.)) + gain, shelf = gain - cut, shelf - min(excess - cut, max(shelf, 0.)) + return gain, shelf + + +def _room(entry): + """How much the part may be raised before its peak passes HEADROOM, in dB.""" + return 20 * math.log10(HEADROOM / entry['peak']) if entry['peak'] > 0 else MOST_DB + + +def plan(sources, speaking, cancelled=None): + """Per part (gain dB, low shelf dB, room dB), None for a part left as it is; or None when no part needs a change. + + The room is how far the part may be raised, also while it eases in from the part before.""" + if speaking is None or len(speaking) != len(sources) or not any(speaking): + return None + reference, settings = None, [] + for source, spoken in zip(sources, speaking): + entry = measure(source, cancelled) if spoken else None + if entry is None: + settings.append(None) + continue + tilt = _band(entry, LOW) - _band(entry, MIDDLE) + if reference is None: + reference = entry, tilt + settings.append((0., 0., _room(entry))) + continue + shelf = _pull(reference[1] - tilt) + voice = (LOW[0], MIDDLE[1]) + gain = _pull(_band(reference[0], voice) - _band(entry, voice, shelf)) + settings.append(_fit(gain, shelf, _room(entry)) + (_room(entry),)) + return settings if any(setting and any(setting[:2]) for setting in settings) else None + + +class Shaper: + """Streaming short-time filter, one part at a time: samples in, the same samples out a little later. + + A part that needs no change (no setting, and no change easing in from the part + before) is given back exactly as it came. A filtered part sees only its own + samples; a change eases in over its first RAMP_SECONDS and, when the next part + is left as it is, back out over its last RAMP_SECONDS. + """ + def __init__(self, settings, rate, channels): + import numpy as np + self.settings, self.rate, self.channels = list(settings), rate, channels + self.ramp = max(1, round(RAMP_SECONDS * rate)) + self.window = np.sqrt(np.hanning(FRAME + 1)[:FRAME]) + self.shape = _shelf(np.fft.rfftfreq(FRAME, 1 / rate)) + self.index, self.pushed, self.emitted, self.ready = -1, 0, 0, [] + self.filtered = False + + def _setting(self, index): + return self.settings[index] if 0 <= index < len(self.settings) else None + + def _filters(self, index): + own, before = self._setting(index), self._setting(index - 1) + return own is not None and (any(own[:2]) or before is not None and any(before[:2])) + + def mark(self): + """The next pushed sample starts the next part.""" + self._close(ended=True) + self.index += 1 + self.filtered = self._filters(self.index) + if self.filtered: + import numpy as np + own, before = self._setting(self.index), self._setting(self.index - 1) + self.own = tuple(own[:2]) + # Easing in from the part before must not raise this part past its own room either. + self.start = tuple(before[:2]) if before is not None else (0., 0.) + if len(own) > 2: + self.start = _fit(*self.start, own[2]) + self.end = None if self._filters(self.index + 1) else (0., 0.) + # Half a frame of silence ahead gives the first samples two overlapping frames, as all others have. + self.buffer = np.zeros((self.channels, FRAME // 2), dtype=np.float64) + self.offset = -(FRAME // 2) # the part's position of buffer[:, 0] + self.added = np.zeros((self.channels, FRAME - HOP), dtype=np.float64) + self.skip = FRAME // 2 + self.length = self.out = 0 # samples of this part pushed, and given out + + @property + def pending(self): + return self.pushed - self.emitted + + def _at(self, position, length): + """(gain, shelf) at a position in the part; `length` is the part's length once known.""" + weight = min(1., max(0., position / self.ramp)) + gain = self.start[0] + (self.own[0] - self.start[0]) * weight + shelf = self.start[1] + (self.own[1] - self.start[1]) * weight + if length is not None and self.end is not None: + weight = min(1., max(0., (length - position) / self.ramp)) + gain, shelf = self.end[0] + (gain - self.end[0]) * weight, self.end[1] + (shelf - self.end[1]) * weight + return gain, shelf + + def _frames(self, length=None): + import numpy as np + out = [] + # Until the part's end is known, its last RAMP_SECONDS may still ease out: keep them back. + while self.buffer.shape[1] >= FRAME and (length is not None or self.end is None + or self.offset + FRAME // 2 < self.length - self.ramp): + gain, shelf = self._at(self.offset + FRAME // 2, length) + response = 10 ** ((gain + shelf * self.shape) / 20) + spectrum = np.fft.rfft(self.buffer[:, :FRAME] * self.window, axis=1) * response + frame = np.fft.irfft(spectrum, FRAME, axis=1) * self.window + frame[:, :FRAME - HOP] += self.added + out.append(frame[:, :HOP]) + self.added = frame[:, HOP:] + self.buffer = self.buffer[:, HOP:] + self.offset += HOP + if not out: + return + data = np.concatenate(out, axis=1) + skip, self.skip = min(self.skip, data.shape[1]), self.skip - min(self.skip, data.shape[1]) + # Less the leading silence, and only the part's own samples: what rang past its end is dropped. + data = data[:, skip:][:, :self.length - self.out] + self.out += data.shape[1] + self.ready.append(np.clip(data, -1., 1.).astype('float32')) + + def _close(self, ended): + """Finish the current part: `ended` when another part follows it.""" + import numpy as np + if not self.filtered: + return + self.filtered = False + if not ended: + self.end = None + self.buffer = np.concatenate((self.buffer, np.zeros((self.channels, FRAME), dtype=np.float64)), axis=1) + self._frames(self.length) + + def push(self, data): + """Add [channels, n] samples; return the finished ones (or None).""" + import numpy as np + self.pushed += data.shape[1] + if not self.filtered: + self.ready.append(np.asarray(data)) + else: + self.buffer = np.concatenate((self.buffer, np.asarray(data, dtype=np.float64)), axis=1) + self.length += data.shape[1] + self._frames() + return self._take() + + def flush(self): + self._close(ended=False) + return self._take() + + def _take(self): + import numpy as np + if not self.ready: + return None + data = np.concatenate(self.ready, axis=1) if len(self.ready) > 1 else self.ready[0] + self.ready = [] + if not data.shape[1]: + return None + self.emitted += data.shape[1] + return data diff --git a/freevideo_engine/comfy_chain.py b/freevideo_engine/comfy_chain.py index f1c86cb9..85f579f8 100644 --- a/freevideo_engine/comfy_chain.py +++ b/freevideo_engine/comfy_chain.py @@ -175,8 +175,11 @@ def join(self, record, cancelled): frames = planned['frames'] if planned and not planned['single'] and len(blocks) == len(record['blocks']) else None kept = {'leads': record['lead_reader'], 'expected_seams': record.get('expected_seams')} \ if record.get('lead_reader') is not None else {} + from .chain_level import voiced + # Parts with spoken lines keep their voice level and fullness to the first part's. + speaking = [voiced(block.get('inputs', {}).get('text')) for block in blocks] return join_outputs([block['output'] for block in blocks], record['id'], cancelled, - frames=frames, replace=record.get('output'), **kept) + frames=frames, replace=record.get('output'), speaking=speaking, **kept) def split_texts(self, value, cancelled): """One description per part from the local prompt model: (parts, None) or (None, reason).""" From fb9b2bc5f87c8feee8561534131635947de92c5b Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:53 +0000 Subject: [PATCH 02/46] Diagnostic report: keep video and prompt-rewrite reports apart, save one file, zip large ones --- .github/ISSUE_TEMPLATE/report.yml | 4 +- web/editor_failure.js | 2 +- web/generation_progress.js | 20 ++++++---- web/report_issue.js | 63 +++++++++++++++++++++++++++---- web/studio.js | 16 ++++++-- 5 files changed, 84 insertions(+), 21 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/report.yml b/.github/ISSUE_TEMPLATE/report.yml index 52a2cdb8..26d06869 100644 --- a/.github/ISSUE_TEMPLATE/report.yml +++ b/.github/ISSUE_TEMPLATE/report.yml @@ -13,8 +13,8 @@ body: id: report attributes: label: Diagnostic report / 诊断报告 - description: Please drag the downloaded report (FreeVideo-report-….json) here. In FreeVideo, select Diagnostic report under a finished video. / 请将下载的诊断报告(FreeVideo-report-….json)拖到这里(在 FreeVideo 视频下方点击「诊断报告」即可获得)。 - placeholder: Drop FreeVideo-report-….json here / 将 FreeVideo-report-….json 拖到这里 + description: Please drag the downloaded report (FreeVideo-report-….json or .zip) here. A link appears when the upload finishes. In FreeVideo, select Diagnostic report under a finished video. / 请将下载的诊断报告(FreeVideo-report-….json 或 .zip,在 FreeVideo 视频下方点击「诊断报告」获得)拖到这里,上传完成后会出现一个链接。 + placeholder: Drop FreeVideo-report-….json or .zip here / 将 FreeVideo-report-….json 或 .zip 拖到这里 validations: required: true - type: input diff --git a/web/editor_failure.js b/web/editor_failure.js index d778982b..02df4df6 100644 --- a/web/editor_failure.js +++ b/web/editor_failure.js @@ -76,7 +76,7 @@ export function createEditorFailures({api, state, jobStatuses, reasonText}) { } else blob = new Blob([JSON.stringify(report(id), null, 2)], {type: 'application/json'}); const latest = receipt(id); if (!current() || latest?.attempt !== found.attempt || latest?.value.detail !== found.value.detail) return; - saveReport(blob); + await saveReport(blob); // Use exactly the video's card and submit flow, including no second download. return showReportTip(bar, anchor, t, {mode: 'downloaded', submit: () => reportIssue(() => Promise.resolve(blob), productVersion(), {save: false})}); diff --git a/web/generation_progress.js b/web/generation_progress.js index 351a232f..1547c4d3 100644 --- a/web/generation_progress.js +++ b/web/generation_progress.js @@ -370,14 +370,15 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t } } const report = el('button', 'fv-report-download fv-quiet'); report.type = 'button'; report.hidden = true; - let reportId = null, revision = 0; + // A finished video's report is fetched once and kept: the file saved and the one sent are the same. + let reportId = null, revision = 0, pinned = false, pinnedBlob = null; report.textContent = label(); function updateReport(message) { if (message.new_request === true || message.overall?.reset === true || message.reset === true && !message.phase) { - reportId = null; revision++; report.hidden = true; + reportId = null; revision++; report.hidden = true; pinned = false; pinnedBlob = null; } if (typeof message.report_id === 'string' && /^[a-f0-9]{32}$/.test(message.report_id)) { - if (reportId !== message.report_id) { revision++; report.disabled = false; report.textContent = label(); } + if (reportId !== message.report_id) { revision++; pinned = false; pinnedBlob = null; report.disabled = false; report.textContent = label(); } reportId = message.report_id; } report.hidden = !reportId && !fallback; @@ -385,15 +386,18 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t } async function fetchReport() { if (reportId) { - const path = `/freevideo/report/${reportId}`; + if (pinned && pinnedBlob) return pinnedBlob; + const id = reportId, path = `/freevideo/report/${id}`; const response = await (api ? api.fetchApi(path) : fetch(path)); if (!response.ok) throw new Error('Report unavailable'); - return response.blob(); + const blob = await response.blob(); + if (pinned && reportId === id) pinnedBlob = blob; + return blob; } if (fallback) return fallback(); throw new Error('Report unavailable'); } - async function downloadReport() { saveReport(await fetchReport()); } + async function downloadReport() { await saveReport(await fetchReport()); } report.onclick = async () => { if (report.disabled || report.hidden) return; const current = revision; @@ -409,7 +413,7 @@ export function createDiagnosticReportControl(t, {api = null, fallback = null, t if (current === revision) report.textContent = t('Report unavailable · retry', '报告暂不可用 · 重试'); } finally { if (current === revision) report.disabled = false; } }; - return {report, updateReport, fetchReport, downloadReport, hasReport: () => reportId !== null}; + return {report, updateReport, fetchReport, downloadReport, hasReport: () => reportId !== null, pin: () => { pinned = true; }}; } export function createGenerationProgress(t, now = () => Date.now(), {compact = false, api = null, onLayout = null} = {}) { @@ -1063,6 +1067,6 @@ export function createGenerationProgress(t, now = () => Date.now(), {compact = f mini_fraction: visible && !mini.hidden && miniMode === 'determinate' ? miniShown : null, mini_shimmer: visible && !mini.hidden && miniShimmer}; } - return {element, stall, report, updateReport, fetchReport, downloadReport, hasReport: diagnostic.hasReport, update, hide, dispose: hide, + return {element, stall, report, updateReport, fetchReport, downloadReport, hasReport: diagnostic.hasReport, pinReport: diagnostic.pin, update, hide, dispose: hide, noticeCount: () => trims.size, stallShown: () => !stall.hidden, percentShown: () => !heading.hidden, snapshot}; } diff --git a/web/report_issue.js b/web/report_issue.js index 19932c28..a57fb8de 100644 --- a/web/report_issue.js +++ b/web/report_issue.js @@ -39,15 +39,61 @@ export function reportIssueURL(report, productVersion = null) { // Each download is named by when it was saved, so saving reports again never // leaves "video.debug (1).json"-style copies; the card names the file just saved. let savedName = null; -export function reportFileName(date = new Date()) { +export function reportFileName(date = new Date(), extension = 'json') { const pad = value => String(value).padStart(2, '0'); return `FreeVideo-report-${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` - + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.json`; + + `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}.${extension}`; } -export function saveReport(blob) { - const url = URL.createObjectURL(blob); - savedName = reportFileName(); +// A report larger than this is saved as a zip with the same name. +export const ZIP_THRESHOLD = 512 * 1024; + +const CRC_TABLE = (() => { + const table = new Uint32Array(256); + for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; table[n] = c >>> 0; } + return table; +})(); +function crc32(bytes) { + let c = 0xffffffff; + for (let i = 0; i < bytes.length; i++) c = CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} +async function deflate(bytes) { + if (typeof CompressionStream !== 'function') return null; + try { + const stream = new Blob([bytes]).stream().pipeThrough(new CompressionStream('deflate-raw')); + return new Uint8Array(await new Response(stream).arrayBuffer()); + } catch { return null; } +} + +// One file in a zip: deflated when the browser can, stored otherwise. +export async function zipReport(blob, name) { + const data = new Uint8Array(await blob.arrayBuffer()); + const packed = await deflate(data); + const body = packed && packed.length < data.length ? packed : data, method = body === data ? 0 : 8; + const file = new TextEncoder().encode(name), crc = crc32(data); + const local = new DataView(new ArrayBuffer(30)), central = new DataView(new ArrayBuffer(46)), end = new DataView(new ArrayBuffer(22)); + local.setUint32(0, 0x04034b50, true); local.setUint16(4, 20, true); local.setUint16(6, 0x0800, true); local.setUint16(8, method, true); + local.setUint16(12, 0x21, true); // 1980-01-01, so the file is the same each time + local.setUint32(14, crc, true); local.setUint32(18, body.length, true); local.setUint32(22, data.length, true); local.setUint16(26, file.length, true); + central.setUint32(0, 0x02014b50, true); central.setUint16(4, 20, true); central.setUint16(6, 20, true); central.setUint16(8, 0x0800, true); + central.setUint16(10, method, true); central.setUint16(14, 0x21, true); central.setUint32(16, crc, true); + central.setUint32(20, body.length, true); central.setUint32(24, data.length, true); central.setUint16(28, file.length, true); + const centralSize = 46 + file.length, centralStart = 30 + file.length + body.length; + end.setUint32(0, 0x06054b50, true); end.setUint16(8, 1, true); end.setUint16(10, 1, true); + end.setUint32(12, centralSize, true); end.setUint32(16, centralStart, true); + return new Blob([local, file, body, central, file, end], {type: 'application/zip'}); +} + +// Saves the report; a large one goes into a zip first. Resolves to the file name. +export async function saveReport(blob) { + const date = new Date(); + let name = reportFileName(date), out = blob; + if (blob.size > ZIP_THRESHOLD) { + try { out = await zipReport(blob, name); name = reportFileName(date, 'zip'); } catch { out = blob; } + } + const url = URL.createObjectURL(out); + savedName = name; const link = document.createElement('a'); link.href = url; link.download = savedName; document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 1000); @@ -59,7 +105,7 @@ export function saveReport(blob) { // saved file in. Resolves to the form's address. export async function reportIssue(fetchReport, productVersion = null, {save = true, open = url => window.open(url, '_blank', 'noopener')} = {}) { const blob = await fetchReport(); - if (save) saveReport(blob); + if (save) await saveReport(blob); let report = null; try { report = JSON.parse(await blob.text()); } catch { /* The form still opens, without prefilled fields. */ } const address = reportIssueURL(report, productVersion); @@ -129,7 +175,10 @@ export function showReportTip(bar, anchor, t, {mode = 'invite', submit, onHide = tip.classList.add('fv-report-tip-sent'); anchor.classList.remove('fv-report-attention'); mark.replaceChildren(glyph('done')); title.textContent = t('Last step', '最后一步'); - lead.textContent = savedName + lead.textContent = savedName?.endsWith('.zip') + ? t(`The report is large, so it was saved as ${savedName}. Please drag it into "Diagnostic report", then select Submit new issue.`, + `报告较大,已保存为 ${savedName}。请将它拖入「诊断报告」,再点击 Submit new issue 即可。`) + : savedName ? t(`Please drag ${savedName} into "Diagnostic report", then select Submit new issue.`, `请将 ${savedName} 拖入「诊断报告」,再点击 Submit new issue 即可。`) : t('Please drag the downloaded report into "Diagnostic report", then select Submit new issue.', diff --git a/web/studio.js b/web/studio.js index cc40722b..4d16499d 100644 --- a/web/studio.js +++ b/web/studio.js @@ -571,7 +571,8 @@ export function openStudio(node, {extendFrom = null} = {}) { let requestAssets = null; const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, prepare: text => expandCast(text, mediaRows(node), {active: !extension.source}).text, - onReport: row => { node.freevideoReportId = row.report_id; progress.updateReport(row); }, + // The rewrite has its own report; the video's report entry is not touched. + onReport: row => { node.freevideoPromptReportId = row.rewrite_report_id || row.report_id; }, onChange: () => { rememberPromptDraft(api, node); requestAssets?.refresh(); }, setText: text => { set(node, 'text', text || (untouched() ? starter : '')); placeholder(); }, context: () => { const items = referenceItems(node); @@ -1022,13 +1023,22 @@ export function openStudio(node, {extendFrom = null} = {}) { cleanup.push(() => recent.dispose()); // A run this window watched. Its result can arrive before or after the // final progress message, and more than once; the tip stays with that video. - let reportTipTimer = null, watchedRun = false, reportTipFor = null; + let reportTipTimer = null, watchedRun = false, reportTipFor = null, shownReport = null; // chosen: a video picked from Recent videos. It only changes the picture; a run in progress keeps its progress. function showResult(r, {chosen = false} = {}) { if (!r?.video || disposed) return; result = r; recent.setSelected(r.video); refreshEffortEstimate(true); - progress.updateReport({report_id: node.freevideoReportId}); + // The report belongs to the video that run made; a video picked from Recent videos + // shows its own report, or none when this window never saw its run. + const reports = node.freevideoVideoReports || (node.freevideoVideoReports = new Map()); + if (!chosen && node.freevideoReportId) reports.set(r.video, node.freevideoReportId); + const wanted = reports.get(r.video) || null; + if (!(chosen && busy) && wanted !== shownReport) { + shownReport = wanted; progress.updateReport({new_request: true}); + if (wanted) progress.updateReport({report_id: wanted}); + } + if (wanted && !(chosen && busy)) progress.pinReport(); stage.querySelector('video')?.pause(); stageMedia.replaceChildren(); const video = el('video'); video.src = view(r.video, 'output') + (r.version ? `&fv=${encodeURIComponent(r.version)}` : ''); video.controls = true; video.preload = 'metadata'; video.playsInline = true; stageMedia.append(video); video.addEventListener('error', () => { From d44019688309a621bcd396be448cf52314b9f9e3 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:53 +0000 Subject: [PATCH 03/46] Image generation: say where graphics memory ran out and at what size, show the retry size --- web/cast_generate.js | 8 +++++--- web/image_generation.js | 26 ++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/web/cast_generate.js b/web/cast_generate.js index 4eae26f5..e7a7524e 100644 --- a/web/cast_generate.js +++ b/web/cast_generate.js @@ -14,7 +14,8 @@ const VIEW_TITLES = Object.freeze({ }); function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } -const failureOf = error => ({error: error?.code || 'generation_failed', report_id: error?.report_id || null}); +const failureOf = error => ({error: error?.code || 'generation_failed', report_id: error?.report_id || null, + stage: error?.stage || '', fitted: error?.fitted || null}); const randomSeed = () => crypto.getRandomValues(new Uint32Array(1))[0] >>> 1; // The five pictures of one description. No DOM: the page below draws its state. @@ -40,7 +41,8 @@ export function createCastGenerator({client, onChange = () => {}, schedule = set const status = await client.status(job); if (!alive()) return null; if (status.state === 'error') { - throw Object.assign(new Error(status.error), {code: status.error, report_id: status.report_id}); + throw Object.assign(new Error(status.error), {code: status.error, report_id: status.report_id, + stage: status.stage, fitted: status.fitted}); } if (status.state === 'done') { if (!status.file) throw new Error('generation_failed'); @@ -175,7 +177,7 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt cell.fill.style.width = (active ? row.fraction * 100 : 0) + '%'; }); if (state.error) { - const key = `${state.error}:${state.report_id || ''}`; + const key = `${state.error}:${state.report_id || ''}:${state.stage || ''}`; if (failureKey !== key) { renderImageFailure(note, state, tr, {fetchApi: fetcher}); failureKey = key; } } else { failureKey = null; diff --git a/web/image_generation.js b/web/image_generation.js index 6279cead..52fc6ef1 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -36,6 +36,7 @@ export function blankCanvasSize(ratio, sizes = null) { } const failure = (code, info = {}) => Object.assign(new Error(code || 'generation_failed'), { code: code || 'generation_failed', report_id: info.report_id || null, stage: info.stage || '', + fitted: info.fitted || null, }); export function createImageGenClient(fetchImpl = globalThis.fetch.bind(globalThis)) { @@ -123,6 +124,20 @@ export function imageGenError(code, info, t) { generation_failed: ['Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'], }; if (code === 'pack_missing') return imagePackUnavailableMessage(info, t); + if (code === 'no_memory') { + // Where it ran out decides what helps: the reference pictures are read at a fixed size, + // so a smaller canvas does not change that stage. + const stage = info?.stage, size = info?.fitted; + if (stage === 'encode') + return t('Not enough GPU memory to load the reference images. Use fewer reference images, or close other apps using the GPU, then try again.', + '显存不够,参考图没能载入。少用几张参考图,或关掉其他占用显卡的程序后再试。'); + if (stage === 'load' || stage === 'text') + return t('Not enough GPU memory to load the model. Close other apps using the GPU, then try again.', + '显存不够,模型没能载入。关掉其他占用显卡的程序后再试。'); + if (size?.width && size?.height) + return t(`Not enough GPU memory, even at ${size.width}\u00a0×\u00a0${size.height}. Close other apps using the GPU, then try again.`, + `显存不够,${size.width}\u00a0×\u00a0${size.height} 也没能生成。关掉其他占用显卡的程序后再试。`); + } return t(...(Object.hasOwn(reasons, code) ? reasons[code] : reasons.generation_failed)); } @@ -144,7 +159,7 @@ export const REPORT_LABELS = Object.freeze({ // Shared by the editor and the other image entry points. Only the opaque report // ID goes in the request; raw service messages are never shown in the page. -export function renderImageFailure(host, {error, report_id} = {}, t, +export function renderImageFailure(host, {error, report_id, stage, fitted} = {}, t, {productVersion = () => null, fetchApi = globalThis.fetch.bind(globalThis)} = {}) { host.replaceChildren(); if (!error || error === 'cancelled') return null; @@ -155,7 +170,7 @@ export function renderImageFailure(host, {error, report_id} = {}, t, } const card = doc.createElement('div'); card.className = 'fv-result-links'; const reason = doc.createElement('div'); reason.className = 'fv-image-failure-reason'; - reason.setAttribute('role', 'status'); reason.textContent = imageGenError(error, null, t); + reason.setAttribute('role', 'status'); reason.textContent = imageGenError(error, {stage, fitted}, t); card.append(reason); host.append(card); if (!report_id) return card; const fetchReport = async () => { @@ -297,7 +312,8 @@ export function createImageGenSession({client, prepare, apply, onChange = () => waiting: '', fitted: null}; const emit = patch => { state = {...state, ...patch}; if (!disposed) onChange(state); }; const failed = error => error.code === 'cancelled' ? {error: '', report_id: null, stage: ''} - : {error: error.code || 'generation_failed', report_id: error.report_id || null, stage: error.stage || ''}; + : {error: error.code || 'generation_failed', report_id: error.report_id || null, stage: error.stage || '', + ...(error.fitted ? {fitted: error.fitted} : {})}; const cancelJob = job => Promise.resolve().then(() => client.cancel(job)).catch(() => {}); const forget = () => { try { store?.clear(); } catch { /* the job is only not resumed after a reload */ } }; function scheduleInfo() { @@ -546,7 +562,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection action.hidden = !!missing; const percent = Math.floor(state.fraction * 100) + '%'; if (state.error && !promptRequired) { - const failureKey = `${state.error}:${state.report_id || ''}`; + const failureKey = `${state.error}:${state.report_id || ''}:${state.stage || ''}`; if (mounted.failureKey !== failureKey) { renderImageFailure(note, state, t, {productVersion, fetchApi}); mounted.failureKey = failureKey; @@ -556,6 +572,8 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection note.textContent = missing ? imagePackUnavailableMessage(state.info, t) : promptRequired ? t('Write a short description first.', '请先写一句画面描述。') : state.running && state.waiting ? t(...(QUEUE_LABELS[state.waiting] || QUEUE_LABELS.image)) + : state.running && state.fitted ? t(`Not enough GPU memory. Retrying at ${state.fitted.width}\u00a0×\u00a0${state.fitted.height}`, + `显存不足,改用 ${state.fitted.width}\u00a0×\u00a0${state.fitted.height} 重试`) + ' · ' + percent : state.running ? (state.stage === 'load' ? t('Loading the model', '加载模型') + ' ' : '') + percent : state.fitted ? fittedNote(state.fitted, t) : ''; } From f77121386c3c03024c9dc626c6db1487e6d0db78 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:53 +0000 Subject: [PATCH 04/46] Cast mentions: adjacent @names, finished @name closes the list, Save says what is missing --- web/cast_library.css | 4 ++-- web/cast_library.js | 17 ++++++++++++++--- web/cast_mention.js | 12 +++++------- web/prompt_references.js | 21 ++++++++++++++++----- web/studio.js | 4 ++-- 5 files changed, 39 insertions(+), 19 deletions(-) diff --git a/web/cast_library.css b/web/cast_library.css index 02bc2427..425b191c 100644 --- a/web/cast_library.css +++ b/web/cast_library.css @@ -14,7 +14,7 @@ .fv-cast-top h2{margin:0;font-size:var(--fv-strong,16px);font-weight:var(--fv-semibold,600)} .fv-cast-dialog button{appearance:none;min-height:32px;padding:5px 13px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised);color:var(--fv-ink);font:inherit;cursor:pointer;touch-action:manipulation} .fv-cast-dialog button:hover{background:var(--fv-hover)} -.fv-cast-dialog button:disabled{opacity:.45;cursor:default} +.fv-cast-dialog button:disabled,.fv-cast-dialog button[aria-disabled=true]{opacity:.45;cursor:default} .fv-cast-dialog :focus-visible,.fv-cast-library :focus-visible{outline:2px solid var(--fv-accent);outline-offset:2px} .fv-cast-dialog .fv-cast-close{min-height:28px;width:28px;padding:0;border:0;background:transparent;color:var(--fv-muted);font-size:22px} .fv-cast-name{display:block;width:100%;height:36px;margin:0 0 14px;padding:6px 10px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm,8px);background:var(--fv-raised);color:var(--fv-ink);font:inherit} @@ -34,7 +34,7 @@ .fv-cast-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:18px} .fv-cast-dialog .fv-cast-delete{margin-right:auto;color:var(--fv-danger)} .fv-cast-dialog .fv-cast-save{border-color:var(--fv-accent);background:var(--fv-accent);color:var(--fv-bg);font-weight:var(--fv-semibold,600)} -.fv-cast-dialog .fv-cast-save:hover{background:var(--fv-accent-hover)} +.fv-cast-dialog .fv-cast-save:hover:not([aria-disabled=true]){background:var(--fv-accent-hover)} .fv-cast-message{margin-top:12px;color:var(--fv-danger);white-space:nowrap;text-overflow:ellipsis;overflow:hidden} .fv-cast-message.fv-cast-notice{color:var(--fv-muted)} .fv-cast-dialog .fv-cast-delete.fv-cast-confirm{border-color:#c9414f;background:#c9414f;color:#fff} diff --git a/web/cast_library.js b/web/cast_library.js index 5ce3fb10..3d94d09f 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -122,12 +122,22 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () status.textContent = content; status.hidden = !content; status.classList.toggle('fv-cast-notice', notice); }; const aiBusy = () => aiState.busy || !!generator?.isBusy(); + // What is still missing before Save works ('' when nothing is); the button says so on hover and when pressed. + let missing = ''; + const missingNow = () => !name.value.trim() ? text('Enter a name first', '先填写名字') + : aiBusy() ? text('Wait for the five pictures to finish', '等待五张图生成完成') + : active === 'upload' ? (frontless ? slots.some(Boolean) : slots[0]) ? '' : frontless ? text('Add an image first', '先添加图片') : text('Add a front image first', '先添加正面图片') + : aiState.done ? '' : text('Generate the images first', '先生成角色图片'); function sync() { const busy = loading || saving; name.disabled = busy; remove.disabled = busy; uploadTab.disabled = aiTab.disabled = busy; - save.disabled = busy || aiBusy() || !name.value.trim() - || (active === 'upload' ? !(frontless ? slots.some(Boolean) : slots[0]) : !aiState.done); + const before = missing; + missing = busy ? '' : missingNow(); + save.disabled = busy; + if (missing) { save.setAttribute('aria-disabled', 'true'); save.title = missing; } + else { save.removeAttribute('aria-disabled'); save.removeAttribute('title'); } + if (before && before !== missing && status.textContent === before) message(''); for (let index = 0; index < slotUI.length; index++) { slotUI[index].choose.disabled = busy; slotUI[index].remove.disabled = busy; } @@ -218,7 +228,8 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () } else message(error.message); } async function saveCard() { - if (save.disabled || closed) return; + if (closed || save.disabled) return; + if (missing) { message(missing, true); return; } saving = true; resetDelete(); message(''); sync(); try { const pictures = active === 'upload' ? slots.filter(Boolean) : generator.getPictures().map((picture, index) => ({ diff --git a/web/cast_mention.js b/web/cast_mention.js index c3ba43dc..51fd3ad5 100644 --- a/web/cast_mention.js +++ b/web/cast_mention.js @@ -63,19 +63,17 @@ export function mentionedIds(text, rows) { } const escape = text => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -// Where `@name` of a present character stands in the text; longer names win, an @ right after a word or an address is not -// a mention (the same rule as the @ list's). +// Where `@name` of a present character stands in the text; longer names win, an @ right after a Latin word or an address is +// not a mention (the same rule as the @ list's), but one right after another name or a Chinese character is. export function mentionRanges(text, people) { const names = [...new Set(people.map(person => person.handle ?? person.name).filter(Boolean))].sort((a, b) => b.length - a.length); if (!names.length || !text.includes('@')) return []; // A name written in Latin letters does not end in the middle of a longer word ("@Su Wanda" is not "@Su Wan"). const whole = name => escape(name) + (/[A-Za-z0-9_]$/.test(name) ? '(?![A-Za-z0-9_])' : ''); - const pattern = new RegExp('(^|[^\\w.%+\\-])@(' + names.map(whole).join('|') + ')', 'gu'); + const pattern = new RegExp('(? en, reset = false) { window.dispatchEvent(new CustomEvent('freevideo-reference-prompt', {detail: node.id})); } -export function mentionAt(text, start, end = start) { +// `handles` are the names of characters that can be mentioned: once the caret is past a whole "@name" the mention is +// finished (a character was chosen), so the list does not open again for what is written after it. +export function mentionAt(text, start, end = start, handles = []) { if (start < 1 || start !== end) return null; const at = text.lastIndexOf('@', start - 1); if (at < 0 || (at > 0 && /[\w.%+\-]/.test(text[at - 1]))) return null; const query = text.slice(at + 1, start); + const rest = text.slice(at + 1); + // A longer name that still begins like what is typed ("苏" and "苏晚") keeps the list open. + const typing = query.toLowerCase(), longer = handles.some(name => name && name.toLowerCase().startsWith(typing)); + if (!longer && handles.some(name => name && query.length > name.length && rest.startsWith(name) + && !(/[A-Za-z0-9_]$/.test(name) && /^[A-Za-z0-9_]/.test(rest.slice(name.length))))) return null; return /^[\p{L}\p{N}_. \-]{0,64}$/u.test(query) ? {start: at, end: start, query: query.trim().toLowerCase()} : null; } +export const handleNames = people => people.map(person => person.handle || person.name); + let serial = 0; // `characters()` lists library characters for the same popup: [{id, name, thumb, blocked}], `blocked` being the reason // one cannot be used now; `onCharacter(character)` puts its pictures in the media list and may return false to refuse. @@ -177,13 +186,13 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a } function refresh() { if (disposed || composing || input.disabled || input.readOnly || document.activeElement !== input) { close(); return; } - range = mentionAt(input.value, input.selectionStart, input.selectionEnd); + let everyone = []; + try { everyone = characters(); } catch {} + range = mentionAt(input.value, input.selectionStart, input.selectionEnd, handleNames(everyone)); if (!range) { close(); return; } let available = [], invalid = false; try { available = items(); } catch { invalid = true; } const media = available.filter(item => `${item.name} ${item.token} ${t(item.kind, {image:'图片',video:'视频',audio:'音频'}[item.kind])} ${item.number}`.toLowerCase().includes(range.query)); - let everyone = []; - try { everyone = characters(); } catch {} const people = everyone.filter(person => person.name.toLowerCase().includes(range.query)); matches = [...people.map(character => ({character})), ...media]; list.replaceChildren(); @@ -250,7 +259,9 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a refresh, open() { if (input.disabled || input.readOnly) return; - if (!mentionAt(input.value, input.selectionStart, input.selectionEnd)) { + let everyone = []; + try { everyone = characters(); } catch {} + if (!mentionAt(input.value, input.selectionStart, input.selectionEnd, handleNames(everyone))) { const prefix = input.value[input.selectionStart - 1] || ''; insert((/[\w.%+\-]/.test(prefix) ? ' ' : '') + '@', input.selectionStart, input.selectionEnd); } diff --git a/web/studio.js b/web/studio.js index 4d16499d..008e6f52 100644 --- a/web/studio.js +++ b/web/studio.js @@ -18,7 +18,7 @@ import { openLibrary } from './library.js'; import { createRecentVideos } from './studio_recent.js'; import { createAgentActivity } from './agent_activity.js'; import { createStudioQueue, randomSeed, turnNodeOn } from './studio_queue.js'; -import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs, mentionAt, mediaRows } from './prompt_references.js'; +import { attachReferencePicker, referenceItems, syncReferencePrompt, referenceInputs, mentionAt, handleNames, mediaRows } from './prompt_references.js'; import { attachTermSuggest } from './prompt_terms.js'; import { castInRows, expandCast, mentionHandle, mentionState, mentionedIds, withCharacter, MAX_PICTURES_EACH } from './cast_mention.js'; import { attachMentionOverlay } from './cast_overlay.js'; @@ -552,7 +552,7 @@ export function openStudio(node, {extendFrom = null} = {}) { mention.title = mention.getAttribute('aria-label'); mention.disabled = prompt.disabled; mention.onpointerdown = e => e.preventDefault(); promptEditor.append(mention); cleanup.push(() => references.dispose()); - const terms = attachTermSuggest(prompt, {t, host: dialog, suppressed: () => !!mentionAt(prompt.value, prompt.selectionStart, prompt.selectionEnd)}); + const terms = attachTermSuggest(prompt, {t, host: dialog, suppressed: () => !!mentionAt(prompt.value, prompt.selectionStart, prompt.selectionEnd, handleNames(castChoices()))}); cleanup.push(() => terms.dispose()); const castOverlay = attachMentionOverlay(prompt, {people: () => castInRows(mediaRows(node))}); // The library's entry row rings the characters this prompt uses. From c8b1c48859018a019f2f532d437c9207391f420f Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:54 +0000 Subject: [PATCH 05/46] Rewrite Agent chat wording: plain failure reasons, next steps name the button --- freevideo_engine/agent/errors.py | 26 +++++++++++++------------- freevideo_engine/image_gen/failure.py | 2 +- web/agent_activity.js | 10 +++++----- web/autopilot_chat.js | 26 +++++++++++++------------- web/image_generation.js | 2 +- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/freevideo_engine/agent/errors.py b/freevideo_engine/agent/errors.py index 4acf7bf9..79a676c6 100644 --- a/freevideo_engine/agent/errors.py +++ b/freevideo_engine/agent/errors.py @@ -130,9 +130,9 @@ 'worker_exit': ('Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'), 'timeout': ('This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'), 'generation_failed': ('Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'), - 'invalid_media': ('A reference picture is missing or cannot be read. Choose a readable picture.', - '参考图片不存在或读不出来,请选择可读取的图片。'), - 'invalid_request': ('The picture settings are invalid. Check them and try again.', '图片参数不合法,请检查后重试。'), + 'invalid_media': ('The reference image could not be read. Choose another one.', + '参考图读不出来,请换一张。'), + 'invalid_request': ('The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'), 'busy': ('Another picture is being generated. Try again when it finishes.', '已有一张图在生成,完成后再试。'), 'cancelled': ('The picture was cancelled.', '已取消这张图片。'), } @@ -260,14 +260,14 @@ def safe_details(value): 'not_ready': '生成所需的环境还没有安装好', 'cancelled': '生成已被停止', 'stopped_by_user': '生成已被停止', - 'interrupted': '生成服务中途重启,任务被打断', - 'worker_exit': '图片生成进程中途退出', + 'interrupted': '生成中途被打断', + 'worker_exit': '图片生成中途被打断', 'closed': '图片生成服务已经关闭', 'image_unavailable': '图片生成服务还没有启动', 'vlm_unavailable': '读取画面的模型服务还没有启动', - 'vlm_failed': '读取画面的模型没有完成请求', - 'vlm_invalid_json': '模型返回的结果格式无法读取', - 'context_length': '描述和参考图超过了模型可读取的长度', + 'vlm_failed': '没能读出画面', + 'vlm_invalid_json': '回复读不出来', + 'context_length': '描述和参考图太长', 'dependency_failed': '前面依赖的任务没有完成', 'lost': '生成服务没有保留这次任务的结果', 'unknown_job': '图片生成服务找不到这次任务', @@ -280,11 +280,11 @@ def safe_details(value): 'invalid_output': '生成的结果无法读取或保存', 'storage_unavailable': '任务记录或素材无法写入磁盘', 'submit_interrupted': '提交生成时服务中断,尚未确认任务是否开始', - 'timeout': '生成等待超时', + 'timeout': '生成等了太久没有结果', 'invalid_media': '参考素材无法读取', 'clip_unreadable': '生成的角色参考视频无法读取', 'invalid_request': '生成参数不符合支持的范围', - 'invalid_drama': '短剧计划中有无法使用的参数', + 'invalid_drama': '短剧设置有误', 'reshoot_limit': '这个镜头已达到可生成的次数上限', 'production_limit': '这部短剧已达到可生成的任务数量上限', 'export_failed': '导出成片时出错', @@ -293,8 +293,8 @@ def safe_details(value): 'too_many_images': '参考图片数量超过了支持的范围', 'chain_too_long': '接续后的视频超过了支持的段数', 'chain_rejected': '生成服务无法使用这组参数', - 'generation_failed': '生成过程中出错,未能完成结果', - 'internal_error': '处理任务时出错,未能完成结果', + 'generation_failed': '生成没有完成', + 'internal_error': '没有完成', } FAILURE_MESSAGES_ZH = { 'The previous video is too short to continue; it needs to be at least 2 s long.': '接续的上一段视频太短,至少需要 2 秒', @@ -317,7 +317,7 @@ def failure_reason(error): return FAILURE_MESSAGES_ZH[line] if line.startswith(('CUDA out of memory', 'MPS backend out of memory')): return FAILURE_REASONS_ZH['out_of_memory'] - return FAILURE_REASONS_ZH.get(error.get('code'), '处理任务时出错,未能完成结果') + return FAILURE_REASONS_ZH.get(error.get('code'), '没有完成') def with_reason(error): diff --git a/freevideo_engine/image_gen/failure.py b/freevideo_engine/image_gen/failure.py index 700c05ca..0035919d 100644 --- a/freevideo_engine/image_gen/failure.py +++ b/freevideo_engine/image_gen/failure.py @@ -28,7 +28,7 @@ 'worker_exit': '图像生成意外停止了,请再试一次。', 'timeout': '这一张用时太长,已停止。请再试一次。', 'generation_failed': '图像生成出错了,请再试一次。', - 'invalid_request': '图像参数不合法。', + 'invalid_request': '图片设置有误,请调整后重试。', } _DEVICE = re.compile( r'cuda.*(?:initializ|device.*(?:lost|unavailable|removed)|invalid device ordinal|' diff --git a/web/agent_activity.js b/web/agent_activity.js index 224ca578..4c60ddf9 100644 --- a/web/agent_activity.js +++ b/web/agent_activity.js @@ -7,7 +7,7 @@ const KINDS = { generate: ['Generating', '生成中', 'M4 7h11a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2zm13 4 5-3v8l-5-3'], assemble: ['Assembling', '拼接中', 'M3 6h7v12H3zM14 6h7v12h-7zM10 12h4'], vlm: ['Checking', '检查中', 'M2 12s4-7 10-7 10 7 10 7-4 7-10 7S2 12 2 12zm10 3a3 3 0 1 0 0-6 3 3 0 0 0 0 6z'], - enhance: ['Writing prompt', '润色提示词', 'M4 20l4-1 10-10-3-3L5 16zM14 7l3 3M18 3l1 2 2 1-2 1-1 2-1-2-2-1 2-1z'], + enhance: ['Enhancing prompt', '增强提示词', 'M4 20l4-1 10-10-3-3L5 16zM14 7l3 3M18 3l1 2 2 1-2 1-1 2-1-2-2-1 2-1z'], }; const NAMES = {generate: ['Video', '视频'], assemble: ['Sequence', '成片'], vlm: ['Check', '检查'], enhance: ['Prompt', '提示词']}; @@ -37,7 +37,7 @@ function phaseText(p, t) { if (p.phase === 'sampling' && step?.total) return step.done >= 1 ? t(`Step ${step.done} / ${step.total}`, `生成 ${step.done} / ${step.total}`) : t('Preparing', '准备中'); return {encoding: t('Reading prompt and media', '读取提示词和素材'), load: t('Loading the model', '加载模型'), decode: t('Saving video and audio', '保存视频与音频'), starting: t('Starting', '启动中'), - loading: t('Loading the model', '加载模型'), answering: t('Answering', '回答中'), rewriting: t('Writing', '改写中'), + loading: t('Loading the model', '加载模型'), answering: t('Answering', '回答中'), rewriting: t('Enhancing prompt', '增强提示词'), assembling: t('Joining clips', '拼接片段'), 'preparing images': t('Preparing images', '准备图片'), finishing: t('Finishing', '收尾中')}[p.phase] || ''; } @@ -48,7 +48,7 @@ function status(job, t) { if (job.status === 'cancelling') return t('Stopping', '正在停止'); if (job.status === 'queued') return job.progress?.queue_position ? t(`In queue · #${job.progress.queue_position}`, `排队 · 第 ${job.progress.queue_position} 位`) : t('Queued', '排队中'); - if (job.status === 'waiting') return job.waiting_for ? t('Waiting for earlier steps', '等待前序任务') : t('Waiting for the GPU', '等待显卡'); + if (job.status === 'waiting') return job.waiting_for ? t('Waiting for earlier tasks', '等待前面的任务') : t('Waiting for the GPU', '等待显卡'); return {succeeded: t('Done', '已完成'), failed: t('Failed', '失败'), cancelled: t('Cancelled', '已取消')}[job.status] || job.status; } function title(job, t) { @@ -76,7 +76,7 @@ function row(job, t, api, refresh, open, cancellationErrors) { if (job.status === 'running' && p.remaining_seconds > 0) meta.push(left(p.remaining_seconds, t)); const reason = job.status === 'failed' && REASONS[job.error?.code]; if (reason) meta.push(t(...reason)); - else if (job.status === 'failed') meta.push(t(job.error?.message || 'The task failed.', job.error?.error_zh || '处理任务时出错,未能完成结果')); + else if (job.status === 'failed') meta.push(t(job.error?.message || 'Did not finish', job.error?.error_zh || '没有完成')); if (job.project) meta.push(job.project); const foot = el('div', 'fv-agent-foot'); const details = el('p', 'fv-agent-meta', meta.join(' · ')); @@ -90,7 +90,7 @@ function row(job, t, api, refresh, open, cancellationErrors) { try { const response = await retryAgentPost(api, '/freevideo/agent/activity/cancel', {id: job.id}); if (!response?.ok) { - const message = t('Stopping failed after a retry. If still running, click again.', '停止没成功,已重试;仍在运行请再点一次'); + const message = t('Could not stop. Click again.', '没能停止,请再点一次。'); details.textContent = message; cancellationErrors.set(job.id, message); } else cancellationErrors.delete(job.id); diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index 6be090bf..c04e0c8d 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -22,9 +22,9 @@ let chatNumber = 0; const WORKING = ['running', 'queued', 'waiting', 'cancelling'], VIDEOS = ['generate', 'assemble']; // What a job with no picture of its own is doing, in the reader's language. -const QUIET = {vlm: ['Checking', '检查中'], enhance: ['Polishing', '润色中']}; +const QUIET = {vlm: ['Checking', '检查中'], enhance: ['Enhancing prompt', '增强提示词']}; const SEND_ATTEMPTS = 3, WAIT_FOR_AGENT = ['not_connected', 'not_listening']; -const GENERIC_FAILURE_ZH = '处理任务时出错,未能完成结果'; +const GENERIC_FAILURE_ZH = '没有完成'; // A listening/connection race is queued; other 4xx refusals never repeat automatically. const sendRefused = (error, response) => error?.code === 'session_full' || error?.retryable === false || response?.status >= 400 && response.status < 500 && !WAIT_FOR_AGENT.includes(error?.code); @@ -37,8 +37,8 @@ async function sendError(response) { function failureReason(value, t) { const error = value.error, reason = error?.error_zh || value.error_zh; if (error?.code === 'session_full' && (!reason || reason === GENERIC_FAILURE_ZH)) - return t('This conversation is full. Start a new chat.', '这个对话已达到消息数量上限,请新建对话'); - return t(error?.message || 'The task failed.', reason || GENERIC_FAILURE_ZH); + return t('This chat is full. Click "New chat".', '这个对话已满,请点“新对话”。'); + return t(error?.message || 'Did not finish', reason || GENERIC_FAILURE_ZH); } export const about = (seconds, t) => seconds >= 60 ? t(`About ${Math.ceil(seconds / 60)} min`, `约 ${Math.ceil(seconds / 60)} 分钟`) : t('Under 1 min', '不到 1 分钟'); @@ -266,7 +266,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () if (draftSession === session) return; saveDraft(); draftSession = session; input.value = drafts.get(session)?.text || ''; grow(); } - const reason = error => error?.message || t('Connection lost', '连接中断'); + const reason = error => error?.message || t('Connection lost. Try again.', '连接中断,请重试。'); async function requestError(response) { const data = await response.json().catch(() => ({})); return new Error(data.error?.message || data.error?.code || `HTTP ${response.status}`); @@ -572,7 +572,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () ids.forEach(id => stopping.add(id)); render(); report(); const results = await Promise.all(ids.map(id => retryAgentPost(api, '/freevideo/agent/activity/cancel', {id}))); results.forEach((response, index) => { if (!response?.ok) stopping.delete(ids[index]); }); - if (results.some(response => !response?.ok)) say(t('Stopping failed after a retry. If still running, click again.', '停止没成功,已重试;仍在运行请再点一次')); + if (results.some(response => !response?.ok)) say(t('Could not stop. Click again.', '没能停止,请再点一次。')); else say(''); if (!stopped) { render(); report(); void poll(); } } @@ -814,7 +814,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // Before anything is said: what the agent can do, and sentences that send themselves when clicked. function hello() { const item = el('li', 'fv-chat-hello'); - item.append(el('p', null, t('I can make videos for you,', '我可以替你生成视频,')), el('p', null, t('or turn a story into a short film.', '也可以把一个故事拍成短片。'))); + item.append(el('p', null, t('I can make videos for you,', '我可以为您生成视频,')), el('p', null, t('or turn a story into a short film.', '也可以把一个故事拍成短片。'))); return {element: item, update() {}}; } function examples() { @@ -919,8 +919,8 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () } } const waitingNotice = code => (code ? code === 'not_listening' : state.agent.connected === true) - ? t('Not sent: Agent is not listening yet. It will send automatically once listening resumes.', '没发出去:Agent 还没在听,继续监听后会自动发送') - : t('Not sent: Agent is not connected. It will send automatically once connected.', '没发出去:Agent 还没连接,连接后会自动发送'); + ? t('Will send when Agent is back', '等 Agent 回来后自动发送') + : t('Will send when Agent connects', '等 Agent 连上后自动发送'); async function send(text, original = null, draft = null, automatic = false) { text = String(text).trim(); original ||= pending.find(entry => entry.session === (state.session ?? null) && entry.text === text && (entry.error || entry.sending || entry.automatic)); @@ -959,9 +959,9 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () const exhausted = entry.attempts >= SEND_ATTEMPTS; entry.automatic = waiting && !exhausted; const explanation = refused || error && !waiting ? t('Not sent: ', '没发出去:') + failureReason({error}, t) - : exhausted ? t('Not sent: tried 3 times. Automatic sending stopped; please send again.', '没发出去:已尝试 3 次,已停止自动发送,请再发一次') - : waiting ? waitingNotice(error?.code) : t('The network is unstable. Retried; please send again.', '网络不稳,已重试,请再发一次'); - entry.error = waiting ? explanation : t('Could not send: ', '没有发送成功:') + (error?.message || explanation); + : exhausted ? t('Not sent. Click "Retry".', '没有发出,请点“重试”。') + : waiting ? waitingNotice(error?.code) : t('The connection is unstable. Click "Retry".', '网络不稳,请点“重试”。'); + entry.error = waiting || /^(Not sent|没发出|没有发出)/.test(explanation) ? explanation : t('Could not send: ', '没有发送成功:') + explanation; 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}}; @@ -985,7 +985,7 @@ export function agentChat(t, api, {open, edit, shown = () => true, changed = () // Editing a queued draft cancels its automatic delivery. Its original message remains available for manual retry. for (const entry of pending) if (entry.automatic && entry.session === draftSession && entry.draft !== drafts.get(draftSession)) { entry.automatic = false; - entry.error = t('Not sent: the queued draft changed. Retry to send the original.', '没发出去:排队草稿已修改,原消息需手动重试'); + entry.error = t('The message changed. Click "Retry" to send the original.', '消息已修改,点“重试”发送原消息。'); say(''); } if (!input.value.trim()) say(''); diff --git a/web/image_generation.js b/web/image_generation.js index 52fc6ef1..0c584493 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -118,7 +118,7 @@ export function imageGenError(code, info, t) { worker_exit: ['Image generation stopped unexpectedly. Try again.', '图像生成意外停止了,请再试一次。'], timeout: ['This image took too long and was stopped. Try again.', '这一张用时太长,已停止。请再试一次。'], cancelled: ['Image generation was cancelled.', '已取消图像生成。'], - invalid_request: ['The image settings are invalid. Check them and try again.', '图像生成参数不合法,请检查后重试。'], + invalid_request: ['The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'], invalid_media: ['The reference image cannot be read. Choose another image.', '参考图读不出来,请换一张图。'], reload: ['The image service restarted. Try again.', '图像服务已重启,请重试。'], generation_failed: ['Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'], From 1f6f3c38a7428392772d526003e506c6e70527a2 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:54 +0000 Subject: [PATCH 06/46] Rewrite image generation and editor wording: plain reasons, next steps name the action --- web/image_editor.js | 28 ++++++++++++++-------------- web/image_generation.js | 8 ++++---- web/result_actions.js | 2 +- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/web/image_editor.js b/web/image_editor.js index 4ae672f9..5a00f216 100644 --- a/web/image_editor.js +++ b/web/image_editor.js @@ -182,13 +182,13 @@ export async function openImageEditor({source = null, name = source || '', targe // ----- frame -------------------------------------------------------------- const dialog = el('dialog', null, 'fv-image-editor'); - dialog.setAttribute('aria-label', t('Edit image', '编辑素材图')); + dialog.setAttribute('aria-label', t('Edit image', '编辑图片')); const header = el('header', null, 'fv-ie-header'); const title = el('div', null, 'fv-ie-title'); // The picture's size; clicking it opens Image size. const sizeLabel = el('button', '', 'fv-ie-size'); sizeLabel.type = 'button'; sizeLabel.title = t('Image size…', '图像大小…'); sizeLabel.onclick = () => imageSizeDialog(); - title.append(el('strong', t('Edit image', '编辑素材图')), el('span', name.split('/').pop(), 'fv-ie-file'), sizeLabel); + title.append(el('strong', t('Edit image', '编辑图片')), el('span', name.split('/').pop(), 'fv-ie-file'), sizeLabel); const zoomLabel = button('100%', t('Fit to window (Ctrl+0)', '适合窗口(Ctrl+0)'), () => fit(), 'fv-ie-zoom-label'); const zoom = el('div', null, 'fv-ie-zoom'); zoom.append(iconButton('minus', t('Zoom out (Ctrl −)', '缩小(Ctrl −)'), () => zoomBy(1 / 1.25)), zoomLabel, @@ -254,7 +254,7 @@ export async function openImageEditor({source = null, name = source || '', targe bgInput.title = t('Background color', '背景色'); bgInput.setAttribute('aria-label', bgInput.title); fgInput.oninput = () => { fg = fgInput.value; }; bgInput.oninput = () => { bg = bgInput.value; }; - colors.append(bgInput, fgInput, iconButton('swap', t('Swap colors (X)', '交换前景色和背景色(X)'), () => swapColors(), 'fv-ie-swap')); + colors.append(bgInput, fgInput, iconButton('swap', t('Swap colors (X)', '交换颜色(X)'), () => swapColors(), 'fv-ie-swap')); toolbar.append(el('span', null, 'fv-ie-tool-fill'), colors); function swapColors() { [fg, bg] = [bg, fg]; fgInput.value = fg; bgInput.value = bg; } @@ -511,7 +511,7 @@ export async function openImageEditor({source = null, name = source || '', targe hint.textContent = text; hint.hidden = !text; } const ratioLabel = id => ({ - video: target ? t(`Video ${target.width}×${target.height}`, `视频画面 ${target.width}×${target.height}`) : t('Video', '视频画面'), + video: target ? t(`Video ${target.width} × ${target.height}`, `视频 ${target.width} × ${target.height}`) : t('Video', '视频'), original: t('Original', '原图比例'), free: t('Free', '自由'), })[id] || id; function renderOptions() { @@ -537,8 +537,8 @@ export async function openImageEditor({source = null, name = source || '', targe const auto = el('label', null, 'fv-ie-check'); const box = el('input'); box.type = 'checkbox'; box.checked = opts.move.auto; box.onchange = () => { opts.move.auto = box.checked; }; auto.append(box, el('span', t('Pick layer by clicking', '点击自动选择图层'))); add(auto, sep(), - iconButton('flipX', t('Flip layer horizontally', '水平翻转图层'), () => transformLayer(l => ({flipX: !l.flipX}))), - iconButton('flipY', t('Flip layer vertically', '垂直翻转图层'), () => transformLayer(l => ({flipY: !l.flipY}))), + iconButton('flipX', t('Flip horizontally', '水平翻转'), () => transformLayer(l => ({flipX: !l.flipX}))), + iconButton('flipY', t('Flip vertically', '垂直翻转'), () => transformLayer(l => ({flipY: !l.flipY}))), button(t('Reset size', '原始大小'), null, () => transformLayer(l => ({w: l.nw, h: l.nh, ...upright(l.nw, l.nh), cx: l.cx, cy: l.cy}))), button(t('Cover canvas', '铺满画布'), null, () => transformLayer(cover))); } else if (tool === 'crop') { @@ -571,7 +571,7 @@ export async function openImageEditor({source = null, name = source || '', targe const o = opts.heal; add(sizeSlider(o), segmented([['all', t('Whole stroke', '整个涂抹区域')], ['text', t('Captions only', '只去字幕')]], o.text ? 'text' : 'all', v => { o.text = v === 'text'; renderOptions(); }, t('Heal', '修复范围')).group, - note(o.text ? t('Paint over a caption: only its white letters and outline are replaced, the picture between them stays.', '涂过字幕即可:只替换白色文字和描边,字与字之间的画面保留。') + note(o.text ? t('Paint over a caption. Only the white letters and outline are removed; the picture between them stays.', '涂过字幕,只去掉白字和描边,字间的画面保留。') : t('Paint over the whole mark in one stroke; it is filled with texture from around it.', '一笔涂满要去掉的部分,松开后用周围的纹理补上。'))); } else if (tool === 'clone') { const o = opts.clone; @@ -594,7 +594,7 @@ export async function openImageEditor({source = null, name = source || '', targe } else if (tool === 'eyedropper') { add(note(t('Click to pick the foreground color; Alt-click for the background color.', '点击吸取前景色;按住 Alt 点击吸取背景色。'))); } else if (tool === 'hand') { - add(note(t('Drag to pan. With any tool, hold Space to pan; Ctrl + wheel zooms.', '拖动平移画面。任何工具下按住空格都能平移,Ctrl + 滚轮缩放。'))); + add(note(t('Drag to pan. Hold Space to pan with any tool; Ctrl + wheel zooms.', '拖动平移。用任何工具时按住空格也能平移,Ctrl + 滚轮缩放。'))); } } function maskToggle() { @@ -758,7 +758,7 @@ export async function openImageEditor({source = null, name = source || '', targe const summary = el('summary'); summary.innerHTML = SVG(ICONS.image, 16); summary.title = t('Add image layer', '添加图片图层'); summary.setAttribute('aria-label', summary.title); const menu = el('div', null, 'fv-ie-menu-list'); if (upload) menu.append(button(t('From this computer…', '从电脑选择…'), null, () => { addMenu.open = false; filePicker.click(); }, 'fv-ie-menu-item')); - if (library.length) menu.append(el('div', t('From Media', '来自素材'), 'fv-ie-menu-label')); + if (library.length) menu.append(el('div', t('From media', '来自素材'), 'fv-ie-menu-label')); for (const item of library) { const b = button('', null, () => { addMenu.open = false; addImageLayer(item.file, item.label); }, 'fv-ie-menu-item'); const img = el('img'); img.src = resolve(item.file); img.alt = ''; img.loading = 'lazy'; @@ -876,7 +876,7 @@ export async function openImageEditor({source = null, name = source || '', targe commitInserted(commands.addImageLayer(doc, {src: file, nw: img.naturalWidth, nh: img.naturalHeight, name: label || file.split('/').pop()})); select('move'); - } catch { message(t('The image could not be added.', '图片无法添加。')); } + } catch { message(t('The image could not be added. Choose another one.', '图片没能添加,请换一张。')); } } filePicker.onchange = async () => { const file = filePicker.files?.[0]; filePicker.value = ''; @@ -938,7 +938,7 @@ export async function openImageEditor({source = null, name = source || '', targe const color = el('input'); color.type = 'color'; color.value = e.color || bg; color.id = 'fv-ie-pad-color'; const buttons = el('div', null, 'fv-ie-row fv-ie-end'); const advice = t(`The picture is ${ow} × ${oh} px. Add space on each side:`, `画面 ${ow} × ${oh} 像素,在四周补充空间:`); - const reason = t('Use whole numbers. The new width and height must be 1 to 20000 px.', '请填整数,调整后的宽和高须在 1 到 20000 像素之间。'); + const reason = t('Enter whole numbers from 1 to 20000 for width and height.', '宽和高请填 1 到 20000 的整数。'); const note = el('p', advice, 'fv-ie-opt-note'); const values = () => Object.fromEntries(Object.entries(inputs).map(([key, input]) => [key, input.value.trim() ? Number(input.value) : NaN])); function update() { @@ -1012,7 +1012,7 @@ export async function openImageEditor({source = null, name = source || '', targe width.oninput = () => { preset = null; if (keep && valid(number(width))) height.value = String(Math.max(1, Math.round(number(width) * ch / cw))); update(); }; height.oninput = () => { preset = null; if (keep && valid(number(height))) width.value = String(Math.max(1, Math.round(number(height) * cw / ch))); update(); }; const dims = el('div', null, 'fv-ie-dims'); - dims.append(field(t('Width (px)', '宽 (px)'), width), chain, field(t('Height (px)', '高 (px)'), height)); + dims.append(field(t('Width (px)', '宽(px)'), width), chain, field(t('Height (px)', '高(px)'), height)); const notes = el('div', null, 'fv-ie-notes'), result = el('p', '', 'fv-ie-opt-note'); const stretch = el('p', t('With the proportions changed, pictures and shapes stretch; text keeps its shape.', '比例改变后,图片和形状会拉伸,文字保持原样。'), 'fv-ie-opt-note'); const vector = doc.layers.some(l => l.kind === 'text' || l.kind === 'shape'); @@ -1558,12 +1558,12 @@ export async function openImageEditor({source = null, name = source || '', targe commitText(); if (strip(doc) === strip(original())) { close(fresh ? null : {restore: true}); return; } if (!changed() && !fresh) { close(null); return; } - save.disabled = cancel.disabled = true; message(t('Saving the edited image…', '正在保存编辑后的图片…')); + save.disabled = cancel.disabled = true; message(t('Saving the image…', '正在保存图片…')); try { const {blob, width, height} = await renderer.export(doc, extension === 'jpg' ? 'image/jpeg' : 'image/png'); close({blob, name: name ? `${stem}-edit.${extension}` : `${stem}.${extension}`, edit: doc, width, height}); } catch { - message(t('The image could not be rendered. Try a smaller canvas or reload the page.', '图片生成失败,请缩小画布或刷新页面后重试。')); + message(t('The image could not be saved. Try a smaller canvas or reload the page.', '图片没能保存,请缩小画布或刷新页面后重试。')); save.disabled = cancel.disabled = false; } } diff --git a/web/image_generation.js b/web/image_generation.js index 0c584493..aaafd256 100644 --- a/web/image_generation.js +++ b/web/image_generation.js @@ -120,7 +120,7 @@ export function imageGenError(code, info, t) { cancelled: ['Image generation was cancelled.', '已取消图像生成。'], invalid_request: ['The image settings are invalid. Adjust them and try again.', '图片设置有误,请调整后重试。'], invalid_media: ['The reference image cannot be read. Choose another image.', '参考图读不出来,请换一张图。'], - reload: ['The image service restarted. Try again.', '图像服务已重启,请重试。'], + reload: ['Image generation was interrupted. Try again.', '图片生成中途被打断,请重试。'], generation_failed: ['Image generation ran into an error. Try again.', '图像生成出错了,请再试一次。'], }; if (code === 'pack_missing') return imagePackUnavailableMessage(info, t); @@ -152,7 +152,7 @@ export const fittedNote = (size, t) => t(`Not enough GPU memory. Made at ${size. `显存不足,已改为 ${size.width}\u00a0×\u00a0${size.height} 生成。`); export const REPORT_LABELS = Object.freeze({ download: ['Diagnostic report', '诊断报告'], - description: ['Redacted diagnostic report: hardware, settings and timings', '已脱敏的诊断报告:硬件、设置和耗时'], + description: ['Diagnostic report: hardware, settings and timings', '诊断报告:硬件、设置和耗时'], preparing: ['Preparing…', '正在整理…'], unavailable: ['Report unavailable · retry', '报告暂不可用 · 重试'], }); @@ -558,7 +558,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection action.textContent = state.running ? t('Stop', '停止') : t('Generate', '生成'); action.classList.toggle('fv-ie-primary', !state.running); action.setAttribute('aria-disabled', String(!state.running && (disabled || !prompt.trim()))); - action.title = !state.running && !prompt.trim() ? t('Write a short description first', '请先写一句画面描述') : ''; + action.title = !state.running && !prompt.trim() ? t('Write a prompt first', '请先写提示词') : ''; action.hidden = !!missing; const percent = Math.floor(state.fraction * 100) + '%'; if (state.error && !promptRequired) { @@ -570,7 +570,7 @@ export function createImageGeneration({client, renderer, t, getDoc, getSelection } else { mounted.failureKey = null; note.textContent = missing ? imagePackUnavailableMessage(state.info, t) - : promptRequired ? t('Write a short description first.', '请先写一句画面描述。') + : promptRequired ? t('Write a prompt first.', '请先写提示词。') : state.running && state.waiting ? t(...(QUEUE_LABELS[state.waiting] || QUEUE_LABELS.image)) : state.running && state.fitted ? t(`Not enough GPU memory. Retrying at ${state.fitted.width}\u00a0×\u00a0${state.fitted.height}`, `显存不足,改用 ${state.fitted.width}\u00a0×\u00a0${state.fitted.height} 重试`) + ' · ' + percent diff --git a/web/result_actions.js b/web/result_actions.js index 17a28564..2ec5967a 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -150,7 +150,7 @@ export function resultActions(record, t, {diagnostic = null, secondPass = null, if (diagnostic) { const reportLabel = t('Diagnostic report', '诊断报告'); const report = action('button', reportLabel, 'report', 'fv-action-secondary fv-action-report'); - report.title = t('Redacted diagnostic report: hardware, settings and timings', '已脱敏的诊断报告:硬件、设置和耗时'); + report.title = t('Diagnostic report: hardware, settings and timings', '诊断报告:硬件、设置和耗时'); const text = report.lastElementChild; report.onclick = async () => { if (report.disabled) return; From 293ba7a416b02b38e21054789a6065ed03a83bd5 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:54 +0000 Subject: [PATCH 07/46] Rewrite settings wording: shorter causes, next steps name the button --- freevideo_engine/download_settings.py | 6 ++-- freevideo_engine/download_settings_ui.py | 15 ++++---- freevideo_engine/model_repair.py | 18 +++++----- web/browser_extension.js | 12 +++---- web/setup.js | 44 +++++++++++++----------- web/updates.js | 4 +-- 6 files changed, 51 insertions(+), 48 deletions(-) diff --git a/freevideo_engine/download_settings.py b/freevideo_engine/download_settings.py index 4afc98fa..23402945 100644 --- a/freevideo_engine/download_settings.py +++ b/freevideo_engine/download_settings.py @@ -10,6 +10,8 @@ from .proxy import MODES CHOICES = ('auto', 'official', 'hf-mirror', 'modelscope') +# Shown as is by the Tk window and mapped to Chinese by the setup page. +SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".' FAMILIES = ('edge-models', 'vdn-models', 'models', 'image-models') @@ -19,7 +21,7 @@ def speed_text(row, zh=False): rate = row.get('bytes_per_second') if (row.get('speed_measured') is False or not isinstance(rate, (int, float)) or not math.isfinite(rate) or rate <= 0): - return '已连通,样本不足' if zh else 'Connected; insufficient sample' + return '已连通,速度还没测出' if zh else 'Connected; speed not measured yet' speed = rate / 2**20 return '<0.01 MiB/s' if speed < .005 else (('%.2f' if speed < 1 else '%.1f') % speed + ' MiB/s') @@ -134,7 +136,7 @@ def progress(value): with self.lock: # Provider exceptions can contain request headers/URLs. self.state=dict(status='failed',root=str(root), - error='Source speed test failed (' + type(error).__name__ + '). Retry the speed test.') + error=SPEED_TEST_FAILED) self.thread=threading.Thread(target=work,daemon=True) self.thread.start() return dict(self.state) diff --git a/freevideo_engine/download_settings_ui.py b/freevideo_engine/download_settings_ui.py index bcdb94a4..2424b6bb 100644 --- a/freevideo_engine/download_settings_ui.py +++ b/freevideo_engine/download_settings_ui.py @@ -1,7 +1,7 @@ """Native download controls, usable before ComfyUI or the engine is installed.""" from pathlib import Path -from .download_settings import CHOICES, MODES, Probe, read, speed_text +from .download_settings import CHOICES, MODES, SPEED_TEST_FAILED, Probe, read, speed_text class DownloadSettings: @@ -55,8 +55,8 @@ def __init__(self, parent, root, zh=False, probe=None, token=None, busy=None): self.speeds.heading(key, text=self.t(en, cn)); self.speeds.column(key, width=width, minwidth=80) self.speeds.pack(fill='both', expand=True) ttk.Label(body, text=self.t( - 'Switch applies to the current download. Compatible fragments resume; otherwise this file restarts, keeping old fragments on disk. Testing speeds does not switch sources.', - '切换立即作用于当前下载。兼容断点会续传;不兼容时当前文件重新下载,旧片段保留。仅测速不会切换。'), + 'Switching takes effect at once. Downloads resume where possible; otherwise the file restarts. Testing speeds does not switch.', + '切换后马上生效,能续传的接着下载,不能的重新下载这个文件。只测速不会切换。'), wraplength=560, style='Muted.TLabel').pack(fill='x', pady=(12, 4)) ttk.Label(body, text=str(self.root), wraplength=560, style='Muted.TLabel').pack(fill='x') ttk.Button(body, text=self.t('Done', '完成'), command=window.destroy).pack(anchor='e', pady=(8, 0)) @@ -120,7 +120,8 @@ def refresh(self): self.status.set(self.t('Testing sources… ', '正在测速… ') + ('%s/%s · %s' % (progress.get('done', 0), progress.get('total', '?'), progress.get('source', '')))) elif state.get('error'): - self.status.set(state['error']) + self.status.set(self.t(SPEED_TEST_FAILED, '测速没有完成,请点“重新测速”。') + if state['error'] == SPEED_TEST_FAILED else state['error']) elif state.get('network_unavailable'): self.status.set(self.t('No sources reachable. Check your network or connection mode.', '下载源均无法连接,请检查网络或切换连接模式。')) measured = value.get('probe') @@ -135,11 +136,11 @@ def refresh(self): families[family] = self.t('GPU packages', 'GPU 依赖') for row in rows: speed = speed_text(row, self.zh) - route = self.t('direct', '直连') if row.get('route') == 'direct' else self.t('current connection', '当前连接') + route = self.t('Direct', '直连') if row.get('route') == 'direct' else self.t('Current connection', '当前连接') self.speeds.insert('', 'end', values=(families.get(family, family), row['id'] + ' · ' + route, speed)) if measured and not busy and not state.get('network_unavailable'): - self.status.set(self.t('HTTP transfer measured without connection wait. Parallel/Xet downloads may differ. Choose a source above to switch.', - '测速已扣除连接等待;多线程/Xet 下载速度可能不同。在上方选择下载源即可切换。')) + self.status.set(self.t('Speeds are a guide. Choose a source above to switch.', + '测速只作参考。在上方选择下载源来切换。')) def poll(self): try: diff --git a/freevideo_engine/model_repair.py b/freevideo_engine/model_repair.py index a78b7ecb..8e3ef9f7 100644 --- a/freevideo_engine/model_repair.py +++ b/freevideo_engine/model_repair.py @@ -54,19 +54,17 @@ # A file the review listed changed before setup reached it (the confirmed plan # can also differ in other ways: bootstrap.confirmed raises plain CHANGED). FILE_CHANGED = 'A file in the installation folder changed after the review. ' + CHANGED -CHANGED_TITLE = ('The installation plan changed', '安装计划有变化') +CHANGED_TITLE = ('Installation stopped', '安装已停止') CHANGED_WHY = dict( - files=('Files in the installation folder changed after you confirmed the plan, so FreeVideo stopped instead of ' - 'continuing with it.', '确认计划之后,安装位置里的文件又有变化,FreeVideo 因此停止,未按原计划继续。'), - other=('Something changed after you confirmed the plan (for example the settings, the GPU or the free disk ' - 'space), so FreeVideo stopped instead of continuing with it.', - '确认计划之后,设置、显卡或可用磁盘空间发生了变化,FreeVideo 因此停止,未按原计划继续。'), + files=('Files in the installation folder changed after you confirmed.', '确认之后安装位置里的文件有变化。'), + other=('The settings, GPU or free disk space changed after you confirmed.', + '确认之后设置、显卡或磁盘空间有变化。'), ) -CHANGED_REPAIR = ('Click Repair again to review the new plan.', '请再次点击“修复”,查看新的计划。') +CHANGED_REPAIR = ('Click Repair again.', '请再点一次“修复”。') CHANGED_WAYS = dict( - busy=('When the current task ends, a Repair button appears here; click it to review the new plan.', - '当前任务结束后,这里会出现“修复”按钮;点击后可以查看新的计划。'), - none=('Click Retry installation below to review the new plan.', '请点击下方的“重试安装”,查看新的计划。'), + busy=('A Repair button appears here when the current task ends. Click it.', + '当前任务结束后,这里会出现“修复”按钮,请点它。'), + none=('Click Retry installation below.', '请点下方的“重试安装”。'), ) IDENTITY = ('bytes', 'mtime_ns', 'st_dev', 'st_ino', 'parent_realpath') diff --git a/web/browser_extension.js b/web/browser_extension.js index dddba375..7059a23a 100644 --- a/web/browser_extension.js +++ b/web/browser_extension.js @@ -121,12 +121,12 @@ export function detectBrowserExtension(error, {kind = 'unknown', known = null, b export const EXTENSION_COPY = Object.freeze({ title: ['A browser extension is interfering with FreeVideo', '浏览器扩展干扰了 FreeVideo'], // English uses straight quotes: curly ones widen in the CJK font stack. - both: ['The user script "{script}" in the browser extension "{extension}" interfered with this page, so FreeVideo could not complete this action.', - '浏览器扩展“{extension}”中的用户脚本“{script}”干扰了这个页面,FreeVideo 未能完成这次操作。'], - extension: ['The browser extension "{extension}" interfered with this page, so FreeVideo could not complete this action.', - '浏览器扩展“{extension}”干扰了这个页面,FreeVideo 未能完成这次操作。'], - unknown: ['An extension in this browser interfered with this page, so FreeVideo could not complete this action.', - '浏览器里的某个扩展干扰了这个页面,FreeVideo 未能完成这次操作。'], + both: ['The script "{script}" in the browser extension "{extension}" interfered with this page, so this action did not finish.', + '浏览器扩展“{extension}”里的脚本“{script}”干扰了页面,这次操作没有完成。'], + extension: ['The browser extension "{extension}" interfered with this page, so this action did not finish.', + '浏览器扩展“{extension}”干扰了页面,这次操作没有完成。'], + unknown: ['A browser extension interfered with this page, so this action did not finish.', + '浏览器里的某个扩展干扰了页面,这次操作没有完成。'], // The browser says a client blocked the file: an ad-blocking extension, though not which one. blocked: ['An ad-blocking extension in this browser, such as uBlock Origin, AdGuard or Adblock Plus, blocked one of FreeVideo\'s page files, so the page cannot work properly.', '浏览器里的广告拦截类扩展(例如 uBlock Origin、AdGuard、Adblock Plus)拦下了 FreeVideo 的页面文件,页面无法正常运行。'], diff --git a/web/setup.js b/web/setup.js index 1bc20e37..e04988d6 100644 --- a/web/setup.js +++ b/web/setup.js @@ -15,20 +15,22 @@ const cn = languageOverride === 'zh' || (languageOverride !== 'en' const t = (en, zh) => cn ? zh : en; const INSTALLED_HINT = {en: 'Already installed. To reinstall, click Detect & review first.', zh: '已装好。要重装,请先点“检测并预览”。'}; const gib = n => Number.isFinite(n) ? (n / 2 ** 30).toFixed(1) + " GiB" : "—"; +const SPEED_TEST_FAILED = 'The speed test did not finish. Click "Test source speeds".'; const failureText = value => { const text = String(value || ''), matches = [...text.matchAll(/MODEL_MISMATCH_REPAIR: ([^\r\n]+)/g)]; if (!matches.length && text.includes('The installation plan changed. Detect configuration again')) { const files = text.includes('A file in the installation folder changed after the review'); return files - ? t('The installation plan changed: files in the installation folder changed after you confirmed the plan, so FreeVideo stopped instead of continuing with it. Click Detect & review, then Install / repair to review the new plan.', - '安装计划有变化:确认计划之后,安装位置里的文件又有变化,FreeVideo 因此停止,未按原计划继续。请先点击“检测并预览”,再点击“安装/修复”,查看新的计划。') - : t('The installation plan changed: something changed after you confirmed the plan (for example the settings, the GPU or the free disk space), so FreeVideo stopped instead of continuing with it. Click Detect & review, then Install / repair to review the new plan.', - '安装计划有变化:确认计划之后,设置、显卡或可用磁盘空间发生了变化,FreeVideo 因此停止,未按原计划继续。请先点击“检测并预览”,再点击“安装/修复”,查看新的计划。'); + ? t('Files in the installation folder changed after you confirmed, so installation stopped. Click Detect & review, then Install / repair.', + '确认之后安装位置里的文件有变化,已停止。请先点“检测并预览”,再点“安装/修复”。') + : t('The settings, GPU or free disk space changed after you confirmed, so installation stopped. Click Detect & review, then Install / repair.', + '确认之后设置、显卡或磁盘空间有变化,已停止。请先点“检测并预览”,再点“安装/修复”。'); } + if (text === SPEED_TEST_FAILED) return t('The speed test did not finish. Click "Test source speeds".', '测速没有完成,请点“重新测速”。'); if (!matches.length) return text; const file = matches.at(-1)[1]; - return t(`${file} differs from the version FreeVideo tests and was left unchanged. Click Detect & review, then Install / repair: the plan will list this file, and after you confirm, it is renamed and kept, and the tested version is downloaded.`, - `${file} 与 FreeVideo 测试过的版本不同,这次没有改动它。请先点击“检测并预览”,再点击“安装/修复”:计划里会列出这个文件,确认后会把它改名保留,再下载测试过的版本。`); + return t(`${file} differs from the tested version and was left unchanged. Click Detect & review, then Install / repair. After you confirm, the original is renamed and kept, and the tested version is downloaded.`, + `${file} 和测试过的版本不同,这次没有改动。请先点“检测并预览”,再点“安装/修复”,确认后原文件改名保留,再下载测试过的版本。`); }; let current; @@ -106,7 +108,7 @@ export async function openSetup() { reserve.value = '1.0'; reserve.disabled = true; reserve.className = 'fv-number'; reserveLabel.append(reserve, document.createTextNode('GiB')); const saveReserve = node('button', t('Save', '保存')), reserveStatus = note(''); - const reserveHelp = note(t('Keep this much additional VRAM free. Applies to new requests; current usage is already accounted for.', '额外保留的空闲显存。对后续请求生效;其他程序当前的占用已单独计入。')); + const reserveHelp = note(t('Extra VRAM to keep free. Applies from the next generation.', '额外保留的空闲显存,下次生成起生效。')); resources.append(autoLabel, reserveLabel, reserveHelp, saveReserve, reserveStatus); advanced.append(resources); const clearReserveStatus = () => { reserveStatus.textContent = ''; reserveStatus.className = 'fv-muted'; }; @@ -127,8 +129,8 @@ export async function openSetup() { const calibrateButton = node('button', t('Run placement test', '运行放置测试')); const calibrateStatus = note(''); calibration.append(repeatsLabel, note(t( - 'Measures the placement choices this machine can settle and writes a report. It does not change your settings. The card stays busy for roughly half an hour per pass and generation cannot run meanwhile.', - '测量本机能定下来的放置选择并生成报告,不会改动你的设置。每轮大约占用显卡半小时,期间无法生成。')), + 'Measures this computer and writes a report without changing settings. Each pass keeps the GPU busy for about half an hour, and generation cannot run meanwhile.', + '测量这台电脑并生成报告,不改动设置。每轮约占用显卡半小时,期间不能生成。')), calibrateButton, calibrateStatus); advanced.append(calibration); const state = node("p"); state.setAttribute("role", "status"); @@ -184,8 +186,8 @@ export async function openSetup() { reserve.disabled = reserveAuto.checked; reserve.value = reserveAuto.checked ? (unified ? '2.0' : '1.0') : String(value[reserveField]); reserveHelp.textContent = unified - ? t('CPU and GPU share unified memory. Keep this much additional RAM available for macOS and other applications. Applies to new requests.', 'CPU 与 GPU 共用统一内存。额外保留这些 RAM 供 macOS 和其他程序使用,对后续请求生效。') - : t('Keep this much additional VRAM free. Applies to new requests; current usage is already accounted for.', '额外保留的空闲显存。对后续请求生效;其他程序当前的占用已单独计入。'); + ? t('Extra memory to keep for the system and other apps. Applies from the next generation.', '额外保留给系统和其他程序的内存,下次生成起生效。') + : t('Extra VRAM to keep free. Applies from the next generation.', '额外保留的空闲显存,下次生成起生效。'); }; calibrateButton.onclick = async () => { if (!calibrateRepeats.checkValidity()) { calibrateRepeats.reportValidity(); return; } @@ -246,16 +248,16 @@ export async function openSetup() { if (family.startsWith('torch-')) names[family] = t('GPU packages','GPU 依赖'); const rate = row.bytes_per_second / 2**20; const speed = !row.ok ? t('Unavailable','未连通') : row.speed_measured === false || !Number.isFinite(rate) || rate <= 0 ? - t('Connected; insufficient sample','已连通,样本不足') : rate < .005 ? '<0.01 MiB/s' : `${rate.toFixed(rate < 1 ? 2 : 1)} MiB/s`; - for (const text of [names[family] || family, `${row.id} · ${row.route === 'direct' ? t('direct','直连') : t('current connection','当前连接')}`,speed]) tr.append(node('td',text)); + t('Connected; speed not measured yet','已连通,速度还没测出') : rate < .005 ? '<0.01 MiB/s' : `${rate.toFixed(rate < 1 ? 2 : 1)} MiB/s`; + for (const text of [names[family] || family, `${row.id} · ${row.route === 'direct' ? t('Direct','直连') : t('Current connection','当前连接')}`,speed]) tr.append(node('td',text)); if (!row.ok) tr.lastChild.className = 'fv-off'; table.append(tr); } - speeds.append(table,note(t('HTTP transfer speed, excluding connection wait. Parallel/Xet downloads may differ.','HTTP 传输测速已扣除连接等待;多线程/Xet 下载速度可能不同。'))); + speeds.append(table,note(t('Speeds are a guide; actual downloads may differ.','测速只作参考,实际下载速度可能不同。'))); } source.onchange = async () => { source.disabled = true; - try { showDownloads(await request('network-source',{root:root.value,source:source.value})); sourceStatus.textContent=t('Switch requested for active downloads; retained fragments will be reused where compatible.','已请求立即切换当前下载,兼容的断点和分片将继续复用。'); } + try { showDownloads(await request('network-source',{root:root.value,source:source.value})); sourceStatus.textContent=t('Switching sources; downloaded parts are kept where possible.','正在切换下载源,能续传的部分会保留。'); } catch(error) { fail(error); } finally { source.disabled = false; } }; @@ -273,8 +275,8 @@ export async function openSetup() { const controls = () => { for (const e of [root, extra, copy, samplingCaches, saveFolders, inspect, use, hfToken, applyToken, clearToken]) e.disabled = busy; cancel.hidden = !busy; install.disabled = busy || !planId || !accept.checked; accept.disabled = busy; inspect.classList.toggle("fv-primary", !planId); install.classList.toggle("fv-primary", Boolean(planId)); - const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again to update the plan.", "设置已更改,请重新点击“检测并预览”更新安装计划。") - : planErrors ? t("The plan has problems shown in red above. Please resolve them, then click Detect & review again.", "安装计划中有标红的问题,请解决后重新点击“检测并预览”。") + const why = busy ? "" : planStale ? t("Settings changed. Click Detect & review again.", "设置已更改,请重新点“检测并预览”。") + : planErrors ? t("Fix the problems marked in red above, then click Detect & review again.", "上面有标红的问题,处理后请重新点“检测并预览”。") : ready && !planId ? t(INSTALLED_HINT.en, INSTALLED_HINT.zh) : !planId ? "" : !accept.checked ? t("Tick the agreement above to enable Install / repair.", "勾选上方的同意选项后,即可点击“安装/修复”。") : ""; @@ -338,15 +340,15 @@ export async function openSetup() { acceptLabel.hidden = busy || !planId; const p = task.progress || {}; state.className = task.status === "failed" ? "fv-failure" : task.ready && !savedMissing ? "fv-ready" : ""; - state.textContent = failureText(task.error) || (task.ready ? t("Ready. Generate with your existing workflow.", "准备就绪,可使用现有工作流生成。") : busy ? p.label || t("Checking configuration and local models…", "正在检查配置和本地模型…") : task.status === "complete" && task.action === "plan" ? (planId ? t("Review the plan, then install.", "请确认计划后安装。") : "") : task.status === "cancelled" ? t("Stopped. Files retained for resuming.", "已停止,文件保留,可继续安装。") : ""); + state.textContent = failureText(task.error) || (task.ready ? t("Ready to generate.", "准备就绪,可以开始生成。") : busy ? p.label || t("Checking configuration and local models…", "正在检查配置和本地模型…") : task.status === "complete" && task.action === "plan" ? (planId ? t("Review the plan, then install.", "请确认计划后安装。") : "") : task.status === "cancelled" ? t("Stopped. Files retained for resuming.", "已停止,文件保留,可继续安装。") : ""); const phases = task.phase_progress || {}, valid = Number.isFinite(p.total) && p.total > 0 && Number.isFinite(p.done) && p.done >= 0 && p.done <= p.total; overall.hidden = !busy; overallBar.max = phases.total > 0 ? phases.total : 1; overallBar.value = phases.done || 0; - overallText.textContent = phases.total > 0 ? `${t("Overall", "整体进度")} · ${phases.done} / ${phases.total} ${t("stages complete", "阶段已完成")} · ${Math.floor(100 * phases.done / phases.total)}%` : t("Preparing installation plan…", "正在准备安装计划…"); + overallText.textContent = phases.total > 0 ? `${t("Overall", "整体进度")} · ${phases.done} / ${phases.total} ${t("stages complete", "阶段已完成")} · ${Math.floor(100 * phases.done / phases.total)}%` : t("Preparing installation…", "正在准备安装…"); bar.hidden = !busy; bar.max = valid ? p.total : 1; bar.value = valid ? p.done : 0; const amount = n => p.total >= 2 ** 30 ? gib(n) : `${(n / 2 ** 20).toFixed(1)} MiB`; const count = valid ? `${Math.floor(100 * p.done / p.total)}% · ${p.unit === "bytes" ? `${amount(p.done)} / ${amount(p.total)}` : `${p.done} / ${p.total}`}` : t("Preparing…", "正在准备…"); - const eta = valid && Number.isFinite(p.remaining_seconds) && p.remaining_seconds > 0 ? `${p.unit === "bytes" ? t("File ETA", "本文件预计剩余") : t("ETA", "预计剩余")} ~${Math.ceil(p.remaining_seconds)} s` : valid ? t("Estimating remaining time…", "正在估算剩余时间…") : `${t("Elapsed", "已用时")} ${Math.floor((Date.now() - (waitSince || Date.now())) / 1000)} s`; + const eta = valid && Number.isFinite(p.remaining_seconds) && p.remaining_seconds > 0 ? `${p.unit === "bytes" ? t("File time left", "本文件预计剩余") : t("Time left", "预计剩余")} ~${Math.ceil(p.remaining_seconds)} s` : valid ? t("Estimating remaining time…", "正在估算剩余时间…") : `${t("Elapsed", "已用时")} ${Math.floor((Date.now() - (waitSince || Date.now())) / 1000)} s`; counters.textContent = busy ? `${count} · ${eta}` : ""; const others = (task.active_tasks || []).slice(1).map(p => p.label).filter(Boolean); const progressDetail = (p.detail || '').replace(/^Keeping the original file: /, t('Keeping the original file: ', '正在保留原文件:')); @@ -394,7 +396,7 @@ export async function openSetup() { catch (error) { fail(error); } }; use.onclick = async () => { - try { const result = await request("use", {root: root.value}); ready = Boolean(result.ready); state.textContent = result.ready ? t("Existing engine connected. Ready to generate.", "已连接现有引擎,可以生成。") : result.detail; state.className = "fv-ready"; invalidate(); } + try { const result = await request("use", {root: root.value}); ready = Boolean(result.ready); state.textContent = result.ready ? t("Existing installation connected. Ready to generate.", "已连接现有安装,可以生成。") : result.detail; state.className = "fv-ready"; invalidate(); } catch (error) { fail(error); } }; cancel.onclick = async () => { try { render(await request("cancel")); poll(); } catch (error) { fail(error); } }; diff --git a/web/updates.js b/web/updates.js index 4daef651..93eae187 100644 --- a/web/updates.js +++ b/web/updates.js @@ -156,8 +156,8 @@ export function createUpdateNotice(cn) { text = t(CANCEL_FAILURE.en, CANCEL_FAILURE.zh); } else if (busy) { text = model === 'switching' || (model && offline) - ? t('Switching to the faster model… ComfyUI restarts once and this page reconnects by itself.', '正在切换到更快的模型…ComfyUI 会重启一次,页面会自动恢复。') - : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video; ComfyUI restarts once.', '更快的模型已下载好,当前视频生成完后自动切换,ComfyUI 会重启一次。') + ? t('Switching to the faster model. This page reconnects by itself.', '正在切换到更快的模型,页面会自动恢复。') + : model === 'waiting' ? t('The faster model is ready. FreeVideo switches after the current video.', '更快的模型已下载好,当前视频完成后自动切换。') : offline || phase === 'restarting' || phase === 'engine' ? t('Restarting FreeVideo to finish the update. The page refreshes by itself.', '正在重启 FreeVideo 完成更新,页面会自动刷新。') : phase === 'downloading' ? t('Downloading the update', '正在下载更新') + percent From 774a585540a57f71094d5605becb850e3436992d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:51:54 +0000 Subject: [PATCH 08/46] Rewrite character library wording: character not person, clearer view labels --- web/cast_generate.js | 4 ++-- web/cast_library.js | 8 ++++---- web/prompt_references.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/web/cast_generate.js b/web/cast_generate.js index e7a7524e..c9597873 100644 --- a/web/cast_generate.js +++ b/web/cast_generate.js @@ -124,7 +124,7 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt const side = el('div', 'fv-cg-side'); const foot = el('div', 'fv-cg-foot'); const field = el('textarea', 'fv-cg-text'); - field.placeholder = tr('Describe the character', '描述人物'); + field.placeholder = tr('Describe the character', '描述角色'); field.setAttribute('aria-label', field.placeholder); field.maxLength = 1000; const note = el('div', 'fv-cg-note'); note.setAttribute('role', 'status'); @@ -207,7 +207,7 @@ export function mountCastGenerate({container, api, text, t = text, viewURL, onSt function begin() { if (!field.value.trim()) { - attention = tr('Describe the character first.', '请先描述人物。'); + attention = tr('Describe the character first.', '请先描述角色。'); render(); field.focus(); return; } diff --git a/web/cast_library.js b/web/cast_library.js index 3d94d09f..bff17f77 100644 --- a/web/cast_library.js +++ b/web/cast_library.js @@ -42,7 +42,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const data = await response.json().catch(() => null); if (!response.ok) { const error = new Error(data?.error?.message || (typeof data?.error === 'string' && data.error) - || data?.message || response.statusText || text('Request failed', '请求失败')); + || data?.message || response.statusText || text('Did not finish. Try again.', '没有完成,请重试。')); error.status = response.status; throw error; } return data; @@ -97,7 +97,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () const closeButton = button('fv-cast-close', '×', () => close()); closeButton.setAttribute('aria-label', text('Close', '关闭')); top.append(title, closeButton); const name = el('input', 'fv-cast-name'); name.type = 'text'; name.maxLength = 120; - name.placeholder = text('Name', '姓名'); name.setAttribute('aria-label', text('Name', '姓名')); + name.placeholder = text('Name', '名字'); name.setAttribute('aria-label', text('Name', '名字')); name.oninput = () => sync(); name.onkeydown = event => { if (event.key === 'Enter') event.preventDefault(); }; const tabs = el('div', 'fv-cast-tabs'); tabs.setAttribute('role', 'tablist'); @@ -188,7 +188,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () for (let index = 0; index < 6; index++) { const slot = el('div', 'fv-cast-slot' + (index ? '' : ' fv-cast-front')); const input = el('input'); input.type = 'file'; input.accept = 'image/*'; input.hidden = true; - const label = index ? text(`View image ${index}`, `视角图片 ${index}`) : text('Front image', '正面图片'); + const label = index ? text(`View ${index}`, `视角 ${index}`) : text('Front image', '正面图片'); const choose = button('fv-cast-choose', '', () => input.click()); choose.setAttribute('aria-label', label); const remove = button('fv-cast-remove', '×', () => { release(index); paint(index); }); remove.setAttribute('aria-label', text('Remove image', '移除图片')); @@ -238,7 +238,7 @@ export function createCastLibrary({api, text, viewURL, uploadOne, onChange = () })); const frontNeeded = active === 'ai' || !frontless; if (!pictures.length || pictures.length > 6 || (frontNeeded && !pictures.some(picture => picture.label === 'front'))) - throw new Error(text('Choose a front image and up to five views', '请选择正面图片,最多五张视角图片')); + throw new Error(text('Choose one front image and up to 5 more angles', '选一张正面图片,再加最多 5 张其他角度的图片')); const images = []; for (const picture of pictures) { if (closed) return; diff --git a/web/prompt_references.js b/web/prompt_references.js index a8dda577..e15dae38 100644 --- a/web/prompt_references.js +++ b/web/prompt_references.js @@ -199,7 +199,7 @@ export function attachReferencePicker(input, {items, t = en => en, view, host, a if (!matches.length) { const empty = document.createElement('div'); empty.className = 'fv-reference-empty'; empty.textContent = invalid ? t('Check your media selection.', '请检查素材用途是否冲突。') - : available.length || everyone.length ? t('No matching references', '没有匹配的素材') : t('Add reference media to use @', '添加参考素材后即可使用 @'); + : available.length || everyone.length ? t('No matching characters or media', '没有匹配的角色或素材') : t('Add reference media to use @', '添加参考素材后即可使用 @'); list.append(empty); if (!available.length && !everyone.length && !invalid && addMedia) { const add = document.createElement('button'); add.type = 'button'; add.textContent = t('Add media', '添加素材'); From a136a32708d8d6e3efbc0c3e2a46a98e021ae554 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 09/46] Continuing a video from the creative panel keeps its pictures and characters, up to three --- web/cast_mention.js | 27 ++++++++++++++++----------- web/freevideo.js | 7 ++++--- web/studio.css | 1 - web/studio.js | 19 ++++++++++--------- web/studio_extend.js | 4 ++-- 5 files changed, 32 insertions(+), 26 deletions(-) diff --git a/web/cast_mention.js b/web/cast_mention.js index 51fd3ad5..c49cb549 100644 --- a/web/cast_mention.js +++ b/web/cast_mention.js @@ -34,11 +34,13 @@ export function subjectDefinitions(subjects) { const IMAGE = /\.(png|jpe?g|webp|bmp|tiff?)$/i; const enabled = row => row && row.enabled !== false; // The number H3 gives each reference picture: images in list order (connected inputs come after the list). -function pictureNumbers(rows) { +// A part that continues a video keeps the first CONTINUE_PICTURES of them (continuation.PICTURES_MAX). +export const CONTINUE_PICTURES = 3; +function pictureNumbers(rows, limit = Infinity) { const numbers = new Map(); let count = 0; rows.forEach((row, index) => { - if (enabled(row) && row.role === 'reference' && IMAGE.test(row.file || '')) numbers.set(index, ++count); + if (enabled(row) && row.role === 'reference' && IMAGE.test(row.file || '') && count < limit) numbers.set(index, ++count); }); return numbers; } @@ -78,22 +80,24 @@ export function mentionRanges(text, people) { } // The prompt a request carries. Characters mentioned in the text are numbered in order of first mention; one whose -// pictures are gone is written as a plain name. `active: false` (first/last frames, a continued video) leaves every -// name plain too, since pictures of people cannot go with those. -export function expandCast(text, rows, {active = true} = {}) { +// pictures are gone is written as a plain name. `active: false` (first/last frames) leaves every name plain too, since +// pictures of people cannot go with those. `continued` (the part continues a video) lets the characters stay: the part +// keeps the first three reference pictures and its frames come after them, so a character whose pictures are not among +// those three is written as a plain name. +export function expandCast(text, rows, {active = true, continued = false} = {}) { text = String(text ?? ''); const list = Array.isArray(rows) ? rows : []; const people = castInRows(list); const ranges = mentionRanges(text, people); if (!ranges.length) return {text, subjects: []}; - if (list.some(row => enabled(row) && (row.role === 'first' || row.role === 'last'))) active = false; - const numbers = pictureNumbers(list); + if (!continued && list.some(row => enabled(row) && (row.role === 'first' || row.role === 'last'))) active = false; + const numbers = pictureNumbers(list, continued ? CONTINUE_PICTURES : Infinity); const order = []; let result = '', at = 0; for (const range of ranges) { result += text.slice(at, range.start); at = range.end; const person = people.find(p => (p.handle ?? p.name) === range.name); - if (!active || !person) { result += person ? person.name : range.name; continue; } + if (!active || !person || !person.rows.some(index => numbers.has(index))) { result += person ? person.name : range.name; continue; } let number = order.indexOf(person) + 1; if (!number) { if (order.length >= MAX_PEOPLE) { result += person.name; continue; } @@ -106,7 +110,7 @@ export function expandCast(text, rows, {active = true} = {}) { const subjects = order.map(person => { // Consecutive picture numbers make one range; a person whose pictures are apart gets several. const ranges = []; - for (const number of person.rows.map(index => numbers.get(index)).sort((a, b) => a - b)) { + for (const number of person.rows.map(index => numbers.get(index)).filter(Boolean).sort((a, b) => a - b)) { const last = ranges[ranges.length - 1]; if (last && number === last[1] + 1) last[1] = number; else ranges.push([number, number]); } @@ -126,12 +130,13 @@ export function cardPictures(card) { // Whether a character can be mentioned now; `reason` is the one thing in the way. // `count` is how many pictures the card would add when only its list row is known. -export function mentionState(rows, card, count = cardPictures(card).length) { +export function mentionState(rows, card, count = cardPictures(card).length, {continued = false} = {}) { const list = Array.isArray(rows) ? rows : []; const people = castInRows(list); if (people.some(person => person.id === card.id)) return {ok: true, present: true}; - if (list.some(row => enabled(row) && (row.role === 'first' || row.role === 'last'))) return {ok: false, reason: 'frames'}; + if (!continued && list.some(row => enabled(row) && (row.role === 'first' || row.role === 'last'))) return {ok: false, reason: 'frames'}; if (people.length >= MAX_PEOPLE) return {ok: false, reason: 'people'}; + if (continued && pictureNumbers(list).size >= CONTINUE_PICTURES) return {ok: false, reason: 'continued'}; if (list.filter(enabled).length + count > MAX_REFERENCES) return {ok: false, reason: 'full'}; return {ok: true, present: false}; } diff --git a/web/freevideo.js b/web/freevideo.js index 22b12b48..f49e5121 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -247,7 +247,8 @@ function mediaPanel(node, mount = null) { // A Generate node that continues a video takes these keyframes as pictures, so they may sit beside references. const continuing = (node.outputs?.[0]?.links || []).some(id => { const target = node.graph?.getNodeById(node.graph?.links[id]?.target_id); - return target?.type === 'FreeVideoGenerate' && optionalInputValue('continue_from', target.widgets?.find(w => w.name === 'continue_from')?.value) !== ''; + return target?.type === 'FreeVideoGenerate' && (target.freevideoContinues?.() + || optionalInputValue('continue_from', target.widgets?.find(w => w.name === 'continue_from')?.value) !== ''); }); const first = active.filter(row => row.role === "first").length + Number(linked('first')); const last = active.filter(row => row.role === "last").length + Number(linked('last')); @@ -265,7 +266,7 @@ function mediaPanel(node, mount = null) { chip.title = source?.title || source?.type || label; connections.append(chip); } mode.textContent = refs ? text("Reference video · experimental", "参考生成 · 实验性") : first && last ? text("First + last frame", "首尾帧生成") : first ? text("Image to video", "图生视频") : last ? text("Last frame to video", "尾帧生成") : text("Text to video · media is optional", "文生视频 · 素材可留空"); - message(refs > 32 ? text('There are more than 32 references; remove some before generating.', '参考素材超过 32 个,可移除部分后再生成。') : (first || last) && continuing ? text("When continuing a video, the first and last frames are used as references.", "接续视频时,首尾帧会用作参考图。") : refs && (first || last) ? text("Choose keyframes or references. Disable unused cards.", "首尾帧与参考模式不能混用,请关闭本次不用的素材。") : first > 1 || last > 1 ? text("Keep only one first frame and one last frame enabled.", "首帧和尾帧各只能启用一张图片。") : text("Drop or paste files, or connect images and audio on the left.", "可拖入、粘贴文件,或连接左侧的图片和音频。"), Boolean(refs > 32 || refs && (first || last) && !continuing || first > 1 || last > 1)); + message(refs > 32 ? text('There are more than 32 references; remove some before generating.', '参考素材超过 32 个,可移除部分后再生成。') : continuing && active.filter(row => row.role === 'reference' && mediaKind(row.file) === 'image').length > 3 ? text('When continuing, only the first 3 images are used.', '接续时只用前 3\u00a0张图片。') : (first || last) && continuing ? text("When continuing a video, the first and last frames are used as references.", "接续视频时,首尾帧会用作参考图。") : refs && (first || last) ? text("Choose keyframes or references. Disable unused cards.", "首尾帧与参考模式不能混用,请关闭本次不用的素材。") : first > 1 || last > 1 ? text("Keep only one first frame and one last frame enabled.", "首帧和尾帧各只能启用一张图片。") : text("Drop or paste files, or connect images and audio on the left.", "可拖入、粘贴文件,或连接左侧的图片和音频。"), Boolean(refs > 32 || refs && (first || last) && !continuing || first > 1 || last > 1)); if (!rows.length && !connections.childElementCount) cards.append(el("div", text("Leave empty for text to video.", "留空则为文生视频。"), "fv-empty")); if (!rows.length && connections.childElementCount) cards.append(el('div', text('Media comes from the connected inputs; no upload is needed.', '素材来自已连接的输入,无需上传。'), 'fv-connected-card')); rows.forEach((row, index) => { @@ -706,7 +707,7 @@ app.registerExtension({ } // Characters mentioned with @ travel as plus their definitions; the draft keeps the typed names. const prompt = this.widgets?.find(w => w.name === 'text'); - if (prompt) { const owner = this; prompt.serializeValue = () => expandCast(prompt.value, mediaRows(owner), {active: !owner.freevideoCastOff?.()}).text; } + if (prompt) { const owner = this; prompt.serializeValue = () => expandCast(prompt.value, mediaRows(owner), {continued: Boolean(owner.freevideoContinues?.())}).text; } const quality = this.widgets?.find(w => w.name === 'two_pass'); if (quality) { quality.label = text('Two-pass acceleration', '二次采样加速'); diff --git a/web/studio.css b/web/studio.css index 23d32696..217b1260 100644 --- a/web/studio.css +++ b/web/studio.css @@ -170,7 +170,6 @@ @media(max-width:480px){.fv-studio .fv-header:has(.fv-view-switch){height:auto;min-height:98px;grid-template-columns:1fr auto;padding-block:10px}.fv-studio .fv-header>.fv-view-switch{grid-column:1/-1;grid-row:2}.fv-studio .fv-header>.fv-header-actions{grid-column:2;grid-row:1}.fv-studio .fv-stats{grid-template-columns:repeat(2,minmax(0,1fr))}} .fv-studio .fv-extend-source{display:flex;align-items:center;gap:12px;margin-bottom:14px;padding:10px;border:1px solid var(--fv-border);border-radius:var(--fv-r-md);background:var(--fv-card)} .fv-studio .fv-extend-source[hidden]{display:none} -.fv-studio .fv-extend-active>:not(.fv-extend-source){display:none} .fv-studio .fv-extend-source img{width:80px;height:50px;object-fit:cover;border-radius:var(--fv-r-sm)} .fv-studio .fv-extend-source>span{flex:1;font-size:var(--fv-micro)} .fv-studio .fv-canvas-lock{display:inline-flex;margin-left:6px;vertical-align:-3px;color:var(--fv-muted)} diff --git a/web/studio.js b/web/studio.js index 008e6f52..dc062798 100644 --- a/web/studio.js +++ b/web/studio.js @@ -514,29 +514,28 @@ export function openStudio(node, {extendFrom = null} = {}) { }; const castBlocked = reason => ({frames: t('Not available with first and last frames', '首尾帧不能和角色同时使用'), people: t('Up to 3 characters', '最多 3 个角色'), full: t('Up to 32 references', '参考素材最多 32 个'), - extend: t('Not available when continuing a video', '接着生成时不能使用角色')}[reason] || ''); + continued: t('Up to 3 images when continuing', '接续时最多 3\u00a0张')}[reason] || ''); const castPending = new Map(); function castChoices() { if (!castLibrary || linked(node, 'text') || !castAssets()) return []; const rows = mediaRows(node); return castLibrary.list().filter(card => card.name).map(card => { - const state = extension.source ? {ok: false, reason: 'extend'} - : mentionState(rows, card, Math.min(MAX_PICTURES_EACH, Number(card.images) || 0)); + const state = mentionState(rows, card, Math.min(MAX_PICTURES_EACH, Number(card.images) || 0), {continued: Boolean(extension.source)}); return {id: card.id, name: card.name, handle: mentionHandle(rows, card, castPending), thumb: card.thumbnail?.file ? view(card.thumbnail.file) : card.thumbnail?.url || '', blocked: state.ok ? '' : castBlocked(state.reason)}; }); } // The name goes into the text at once; the card's pictures follow when the full card has been read. function mentionCharacter(person) { - if (!castLibrary || extension.source) return false; + if (!castLibrary) return false; if (castInRows(mediaRows(node)).some(entry => entry.id === person.id)) return true; - if (!mentionState(mediaRows(node), {id: person.id}, 1).ok) return false; + if (!mentionState(mediaRows(node), {id: person.id}, 1, {continued: Boolean(extension.source)}).ok) return false; const typed = person.handle || person.name; castPending.set(person.id, typed); castLibrary.get(person.id).then(card => { if (disposed) return; const rows = mediaRows(node), target = castAssets(); - if (!target || !mentionState(rows, card).ok) return; + if (!target || !mentionState(rows, card, undefined, {continued: Boolean(extension.source)}).ok) return; target.field.value = JSON.stringify(withCharacter(rows, card, typed)); target.owner.graph?.change?.(); window.dispatchEvent(new CustomEvent('freevideo-media', {detail: target.owner.id})); @@ -564,13 +563,13 @@ export function openStudio(node, {extendFrom = null} = {}) { prompt.addEventListener('input', announceCast); const castChanged = event => { if (String(event.detail) === String(mediaNode?.id)) { castOverlay.refresh(); references.refresh(); announceCast(); } }; window.addEventListener('freevideo-media', castChanged); - node.freevideoCastOff = () => Boolean(extension.source); - cleanup.push(() => { castLibrary?.destroy?.(); castLibrary = null; window.removeEventListener('freevideo-media', castChanged); prompt.removeEventListener('input', announceCast); castOverlay.dispose(); delete node.freevideoCastOff; }); + node.freevideoContinues = () => Boolean(extension.source); + cleanup.push(() => { castLibrary?.destroy?.(); castLibrary = null; window.removeEventListener('freevideo-media', castChanged); prompt.removeEventListener('input', announceCast); castOverlay.dispose(); delete node.freevideoContinues; }); openCastLibrary({api, text: t, viewURL: view, uploadOne: castUpload, onChange: () => { castOverlay.refresh(); references.refresh(); }}) .then(library => { if (disposed) library?.destroy?.(); else castLibrary = library; }); let requestAssets = null; const enhancer = createPromptEnhancer({node, input: prompt, editor: promptEditor, api, t, starter, - prepare: text => expandCast(text, mediaRows(node), {active: !extension.source}).text, + prepare: text => expandCast(text, mediaRows(node), {continued: Boolean(extension.source)}).text, // The rewrite has its own report; the video's report entry is not touched. onReport: row => { node.freevideoPromptReportId = row.rewrite_report_id || row.report_id; }, onChange: () => { rememberPromptDraft(api, node); requestAssets?.refresh(); }, @@ -1567,6 +1566,8 @@ export function openStudio(node, {extendFrom = null} = {}) { twoPass.checked = Boolean(source) || value(node, 'two_pass') !== false; previewFirst.checked = !source && value(node, 'preview') === true; prompt.value = shown(value(node, 'text')); placeholder(); enhancer.sync(); + // The media panel and the @ list read whether a video is being continued. + if (mediaNode) window.dispatchEvent(new CustomEvent('freevideo-media', {detail: mediaNode.id})); syncSamplingSteps(); syncCanvasSize(); seconds.dispatchEvent(new Event('input')); prompt.focus({preventScroll: true}); } diff --git a/web/studio_extend.js b/web/studio_extend.js index a8b1dfc7..31c4c039 100644 --- a/web/studio_extend.js +++ b/web/studio_extend.js @@ -108,9 +108,9 @@ export function extendSnapshot(snapshot, node, source, {multiple = false} = {}) // The predecessor's tail replaces the draft's first frame. const media = copy.output[String(inputs.media?.[0])]; if (media?.class_type === 'FreeVideoMedia') { - // The continuation's own last frames stand in for every first, last and reference item. + // The continuation's own last frames stand in for connected media; the panel's pictures (characters, references) + // go with it, and the engine keeps up to three of them (continuation.PICTURES_MAX). for (const key of Object.keys(media.inputs)) if (key !== 'assets') delete media.inputs[key]; - if (media.inputs.assets) media.inputs.assets = '[]'; } return copy; } From f294115a80357b905afdc08d0545065e46d58f15 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 10/46] Large downloads compare a slow source with the others and move to the faster one --- freevideo_engine/image_gen/image_pack.py | 3 +- freevideo_engine/model_transfer.py | 36 +++++++++- freevideo_engine/network.py | 91 ++++++++++++++++++++++-- freevideo_engine/prompt_vlm/assets.py | 3 +- freevideo_engine/subtitle_pack.py | 3 +- 5 files changed, 126 insertions(+), 10 deletions(-) diff --git a/freevideo_engine/image_gen/image_pack.py b/freevideo_engine/image_gen/image_pack.py index 52d421f0..3f9a4bc4 100644 --- a/freevideo_engine/image_gen/image_pack.py +++ b/freevideo_engine/image_gen/image_pack.py @@ -537,7 +537,8 @@ def resource_check(): failures.clear() network.download(network.model_urls(plan, row), path, row['sha256'], progress, network=plan, size=row['bytes'], category=FAMILY, - keep_partial=True, stall_seconds=30) + keep_partial=True, stall_seconds=30, + slow_seconds=15, low_speed_limit=64 * 1024) return except SwitchRequested: if background is None: diff --git a/freevideo_engine/model_transfer.py b/freevideo_engine/model_transfer.py index 328e519d..ab7b42e2 100644 --- a/freevideo_engine/model_transfer.py +++ b/freevideo_engine/model_transfer.py @@ -22,6 +22,10 @@ MiB = 1 << 20 MIN_SDK_BYTES = 64 * MiB STALL_SECONDS = 240 +# Bytes stopped while downloading move to another source after this long; the +# longer allowance stays for connecting, the xet start-up and verification. +DOWNLOAD_STALL_SECONDS = 30 +CONNECT_STALL_SECONDS = 90 SAME_SOURCE_ATTEMPTS = 3 @@ -482,6 +486,14 @@ def read_output(): prior_phase = None prior_backend = None prior_io = 0 + compare = dict(thread=None, rates={}, checks=0, at=None, current=0.) + moving = None + alternatives = [] + if (row['bytes'] >= 128 * MiB and not networking.get('manual_source_switch') and not request['require_xet'] + and not plan.get('manual_ranges')): + family = network.model_family(row) + alternatives = [(name, url, network.route_order(networking, family, name)[0]) + for name, url in network.model_urls(networking, row) if name != source] while process.poll() is None: if networking.get('resource_check'): networking['resource_check']() @@ -502,10 +514,29 @@ def read_output(): break # Xet reconstruction can lag network transfer; either counter counts # as activity. Verification gets a size-aware disk allowance. - timeout = max(STALL_SECONDS, row['bytes'] / (8*MiB)) if current['phase'] in ('verify', 'reconstruct') else STALL_SECONDS + timeout = (max(STALL_SECONDS, row['bytes'] / (8*MiB)) if current['phase'] in ('verify', 'reconstruct') + else DOWNLOAD_STALL_SECONDS if current['phase'] == 'download' and current.get('done_bytes') + else CONNECT_STALL_SECONDS) if time.monotonic() - current['last_activity'] > timeout: failure = 'stalled-transfer' break + if alternatives and current['phase'] == 'download' and current.get('done_bytes'): + if moving is None: + moving = (time.monotonic(), current['done_bytes']) + compare['at'] = moving[0] + network.SWITCH_AFTER_SECONDS + from .provision import model_headers + better = network._faster_source(compare, time.monotonic(), moving[0], moving[1], current['done_bytes'], + row['bytes'], alternatives, 5, None, networking, + network.model_family(row), model_headers, full_restart=True) + if better is not None: + from .download_settings import current as settings, SwitchRequested + failure = 'faster-source' + network.event(networking, category=network.model_family(row), source=source, + file=path.name, action='fallback', reason='faster-source', + resume_bytes=current['done_bytes']) + network.source_health(networking, network.model_family(row), source, False) + network.source_health(networking, network.model_family(row), better[0], True) + raise SwitchRequested(dict(settings(networking), source=better[0], automatic=True)) size_error = stage_overflow(stage, row, selected['part_bytes']) if size_error: failure = 'unexpected-transfer-size' @@ -683,7 +714,8 @@ def resource_check(): network.event(networking,category=network.model_family(row),source=selected, file=path.name,action='manual-switch',resume_bytes=retained['bytes'], retained_unmapped_bytes=retained.get('unmapped_bytes',0), - reason='User selected a new source; stopped the previous worker') + reason=('A faster source was found; stopped the slower transfer' if switch.value.get('automatic') + else 'User selected a new source; stopped the previous worker')) if path.is_file() and retained['bytes']==row['bytes']: break finally: diff --git a/freevideo_engine/network.py b/freevideo_engine/network.py index 934101b8..e69863a1 100644 --- a/freevideo_engine/network.py +++ b/freevideo_engine/network.py @@ -15,6 +15,7 @@ import re import shutil import subprocess +from types import SimpleNamespace from . import execution_paths from . import subprocess_output from .windows_ux import external_python, hidden_console @@ -45,6 +46,15 @@ SPEED_SAMPLE_BYTES = 32 * 2**20 SPEED_SAMPLE_SECONDS = 3. MIN_TRANSFER_LIMIT = 1024 * 1024 +# A transfer that is moving but slowly is compared with the untried sources: after +# SWITCH_AFTER_SECONDS the others get one bounded speed sample and the transfer +# moves (resuming the same partial) when that saves a real amount of time. +SWITCH_AFTER_SECONDS = 15. +SWITCH_RECHECK_SECONDS = 180. +SWITCH_MAX_CHECKS = 3 +SWITCH_MIN_REMAINING_SECONDS = 90. +SWITCH_MIN_SAVING_SECONDS = 60. +SWITCH_MIN_GAIN = 2. # One quick pass over sources/routes, followed by at most one patient pass. # These are inactivity timeouts, not deadlines for an entire installation. DOWNLOAD_RETRY_PROFILES = (('fast', '20', '1'), ('patient', '120', '5')) @@ -771,6 +781,51 @@ class DownloadError(RuntimeError): """Every permitted source failed; an alternate artifact may be tried.""" +def _faster_source(compare, now, started, offset, done, size, alternatives, timeout, env, network, category, headers_for, + full_restart=False): + """Return the untried candidate worth moving to, or None. Never blocks the transfer loop.""" + thread = compare['thread'] + if thread is None: + if now < compare['at']: + return None + rate = (done - offset) / max(.001, now - started) + remaining = size - done + if rate <= 0 or remaining / rate < SWITCH_MIN_REMAINING_SECONDS: + compare['at'] = now + SWITCH_RECHECK_SECONDS + return None + compare['current'], compare['remaining'], compare['rates'] = rate, remaining, {} + compare['checks'] += 1 + names = {} + for row in alternatives: + names.setdefault(row[0], row) + + def measure(row): + name, url, route = row + try: + headers = headers_for(name) if headers_for else () + _, result = speed_sample(url, timeout, env=route_environment(network, category, name, env, route), + headers=headers) + value = result.get('bytes_per_second') if result.get('speed_measured') else None + except (OSError, ValueError, RuntimeError, subprocess.SubprocessError): + value = None + if value: + compare['rates'][row[0], row[2]] = value + compare['thread'] = threading.Thread(target=lambda: [measure(r) for r in list(names.values())[:3]], daemon=True) + compare['thread'].start() + return None + if thread.is_alive(): + return None + compare['thread'] = None + compare['at'] = now + SWITCH_RECHECK_SECONDS + best = max(compare['rates'].items(), key=lambda item: item[1], default=None) + if best is None: + return None + rate, remaining = compare['current'], compare['remaining'] + if best[1] >= SWITCH_MIN_GAIN * rate and remaining / rate - (size if full_restart else remaining) / best[1] >= SWITCH_MIN_SAVING_SECONDS: + return next((row for row in alternatives if (row[0], row[2]) == best[0]), None) + return None + + def download(candidates, path, expected, progress=None, *, network=None, env=None, size=None, algorithm='sha256', git_blob=False, headers_for=None, stall_seconds=None, category='download', low_speed_limit=1024, max_seconds=None, cycles=2, slow_seconds=None, keep_partial=False): @@ -828,7 +883,7 @@ def verification(done, total): for cycle in range(cycles): # Once every route has been tried, keep the fastest observed route even # if the user's entire connection is slow. Do not oscillate forever. - if cycle and slow_seconds is not None: + if cycle and measured_rates: candidates.sort(key=lambda row: -measured_rates.get((row[0], row[2]), 0)) for candidate_index, (name, url, route) in enumerate(candidates): for restart in range(3 if model else 2): @@ -872,11 +927,14 @@ def remember_source(): if offset: command += ['--continue-at', str(offset)] process = curl_process(command, env=attempt_env, stdin=subprocess.PIPE, - stdout=subprocess.PIPE, stderr=subprocess.DEVNULL) + stdout=subprocess.PIPE, stderr=subprocess.PIPE) started = time.monotonic() oversized = None stalled = False slow = False + faster = None + compare = dict(thread=None, rates={}, checks=0, at=started + SWITCH_AFTER_SECONDS, current=0.) + alternatives_ahead = [row for row in candidates[candidate_index+1:] if row[0] != name] last_body, previous_done = started, offset recent = deque([(started, offset)]) try: @@ -908,6 +966,12 @@ def remember_source(): and (done - recent[0][1]) / (now - recent[0][0]) < low_speed_limit): slow = True break + if (cycle == 0 and size and alternatives_ahead and compare['checks'] < SWITCH_MAX_CHECKS + and not network.get('manual_source_switch')): + faster = _faster_source(compare, now, started, offset, done, size, alternatives_ahead, + timeout, env, network, category, headers_for) + if faster is not None: + break if progress: sample = next((row for row in recent if row[0] >= now - 5), recent[0]) progress(done, size or 0, max(0, done - sample[1]) / max(.001, now - sample[0])) @@ -916,6 +980,9 @@ def remember_source(): except subprocess.TimeoutExpired: pass finally: + thread = compare['thread'] + if thread is not None and thread.is_alive(): + thread.join(timeout=timeout + SPEED_SAMPLE_SECONDS + 3) if process.poll() is None: process.terminate() try: @@ -925,10 +992,19 @@ def remember_source(): process.wait() status = process.stdout.read(16).decode(errors='replace') process.stdout.close() + # --silent --show-error keeps this to a line or two. + curl_message = _package_failure(SimpleNamespace(stderr=process.stderr.read(2048)))[1] if process.returncode else '' + process.stderr.close() current_size = partial.stat().st_size if partial.exists() else 0 if slow_seconds is not None: measured_rates[name, route] = (max(0, current_size - offset) / max(.001, time.monotonic() - started)) if slow else 0 + if faster is not None: + measured_rates[name, route] = compare['current'] + measured_rates.update(compare['rates']) + # The faster source is tried next, with the retained prefix. + position = candidates.index(faster) + candidates.insert(candidate_index + 1, candidates.pop(position)) if model and current_size > offset: remember_source() if oversized is not None: @@ -958,19 +1034,24 @@ def remember_source(): # A mirror lacking Range must start over, with previous bytes kept. retain_partial(partial, 'resume-unavailable') continue - transient = stalled or process.returncode in (5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95) or status in ('429', '500', '502', '503', '504') + # A connection that was moving bytes and then ended with an + # unclassified exit is an interruption, not a refusal. + interrupted = process.returncode not in (0, 22, 33, 63) and current_size > offset + transient = (stalled or interrupted or process.returncode in (5, 6, 7, 18, 28, 35, 52, 55, 56, 92, 95) + or status in ('429', '500', '502', '503', '504')) if model and transient and restart < 2 and candidate_index + 1 == len(candidates) and process.returncode not in (5, 7): retry_wait(network, restart, category=category, source=name, file=path.name, resume_bytes=current_size, reason='no-progress' if stalled else 'connection-interrupted') continue - reason = 'no-progress' if stalled else 'slow-transfer' if slow else 'curl-' + str(process.returncode) + reason = 'no-progress' if stalled else 'slow-transfer' if slow else 'faster-source' if faster else 'curl-' + str(process.returncode) alternatives = route_order(network, category, name, env) if alternatives.index(route) + 1 < len(alternatives): event(network, category=category, source=name, route=alternatives[alternatives.index(route)+1], file=path.name, action='route-retry', reason=reason, resume_bytes=current_size) else: source_health(network, category, name, False) - event(network, category=category, source=name, route=route, file=path.name, action='fallback', reason=reason, http_status=status, resume_bytes=current_size) + event(network, category=category, source=name, route=route, file=path.name, action='fallback', reason=reason, http_status=status, resume_bytes=current_size, + **(dict(detail=curl_message) if curl_message else {})) break if model and partial.exists() and partial.stat().st_size: if not network.get('allow_model_restart'): diff --git a/freevideo_engine/prompt_vlm/assets.py b/freevideo_engine/prompt_vlm/assets.py index 0c7c60e9..1112fdd0 100644 --- a/freevideo_engine/prompt_vlm/assets.py +++ b/freevideo_engine/prompt_vlm/assets.py @@ -67,7 +67,8 @@ def install(root, progress, check): def moved(current, size, speed, **kwargs): progress(dict(phase='download', done=done + current, total=total)) network.download(network.model_urls(plan, remote), path, row['sha256'], moved, - network=plan, size=row['bytes'], keep_partial=True, stall_seconds=30) + network=plan, size=row['bytes'], keep_partial=True, stall_seconds=30, + slow_seconds=15, low_speed_limit=64 * 1024) ledger[str(path)] = provision.file_identity(path, row) save(ledger_path, ledger) done += row['bytes'] diff --git a/freevideo_engine/subtitle_pack.py b/freevideo_engine/subtitle_pack.py index 310896af..ff97d279 100644 --- a/freevideo_engine/subtitle_pack.py +++ b/freevideo_engine/subtitle_pack.py @@ -248,7 +248,8 @@ def install(groups=GROUPS, root=None, progress=None, check=None): def moved(current, size, speed, **kwargs): progress(dict(phase='download', done=done + current, total=total)) network.download(network.model_urls(plan, remote), path, row['sha256'], moved, - network=plan, size=row['bytes'], keep_partial=True, stall_seconds=30) + network=plan, size=row['bytes'], keep_partial=True, stall_seconds=30, + slow_seconds=15, low_speed_limit=64 * 1024) ledger[str(path)] = provision.file_identity(path, row) save(folder / LEDGER, ledger) done += row['bytes'] From ed3850727c91246917255edfaac4e46495661ac8 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 11/46] Hide 17 tail frames in continued dialogue parts Hiding 34 frames let the model keep talking through the discarded tail, so the next part opened with stray speech before its own line. 17 frames keep the seam level steps as low while removing most of that speech. Continuations recorded with a 34-frame tail still resume. --- freevideo_engine/cli.py | 2 +- freevideo_engine/comfy_nodes.py | 2 +- freevideo_engine/continuation.py | 9 ++++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/freevideo_engine/cli.py b/freevideo_engine/cli.py index 2059597d..cdc86902 100644 --- a/freevideo_engine/cli.py +++ b/freevideo_engine/cli.py @@ -98,7 +98,7 @@ def main(): 'reference picture. Reduces drift over many parts; holds framing and motion close to ' 'the opening, so leave it off when later parts should move away from it') generate.add_argument('--hide-tail', action='store_true', - help='Generate and discard 34 extra frames for dialogue segments.') + help='Generate and discard 17 extra frames for dialogue segments.') generate.add_argument('--voice-reference', action='store_true', help='With --continue-from: use the opening six seconds as a voice reference when the prompt has dialogue') generate.add_argument('--continue-anchor', type=Path, metavar='IMAGE', diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 8a5f8de6..1bc24cee 100644 --- a/freevideo_engine/comfy_nodes.py +++ b/freevideo_engine/comfy_nodes.py @@ -68,7 +68,7 @@ def define_schema(cls): io.Boolean.Input('voice_reference', display_name='Voice reference', default=False, 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 34 extra frames to keep dialogue moving between parts.'), + optional=True, advanced=True, tooltip='Generates and discards 17 extra frames to keep dialogue moving between parts.'), ], 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], diff --git a/freevideo_engine/continuation.py b/freevideo_engine/continuation.py index 139ab931..6a50d9f1 100644 --- a/freevideo_engine/continuation.py +++ b/freevideo_engine/continuation.py @@ -468,12 +468,15 @@ def load(request, stage): audio=torch.from_numpy(value['audio'].copy())) -# Dialogue parts settle into an ending over their last ~1.5 s; 17 frames hid only the pause, 34 also the pose reset. -HIDE_TAIL_FRAMES = 34 +# Dialogue parts settle into an ending over their last ~1.5 s. Hiding 34 frames also hid the pose reset, but the model +# kept talking through the longer hidden tail and the next part opened with stray speech; 17 frames keep the seam +# level steps as low with far less of it. +HIDE_TAIL_FRAMES = 17 def validate_hide_tail(frames): - if type(frames) is not int or frames not in (0, 17, HIDE_TAIL_FRAMES): + # 34 stays valid for continuations recorded by earlier versions. + if type(frames) is not int or frames not in (0, 17, 34): raise ValueError('Hidden tail must be 0, 17 or 34 frames') return frames From 035d11fa0193e7d514b68c3e19bc866c7ad4de12 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 12/46] Settings: put Test source speeds beside the source list and quiet the token fold-out --- web/setup.css | 6 ++++++ web/setup.js | 7 ++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/web/setup.css b/web/setup.css index 64a0f18d..1ece75a7 100644 --- a/web/setup.css +++ b/web/setup.css @@ -34,6 +34,12 @@ .fv-setup details[open]>summary{margin-bottom:12px} .fv-setup details details{margin:12px 0 0;padding:12px 14px;border:1px solid var(--fv-border);border-radius:var(--fv-r-sm);background:var(--fv-bg)} .fv-setup details details>summary{font-weight:var(--fv-medium);font-size:var(--fv-body)} +.fv-setup details.fv-token{margin:12px 0 0;padding:12px 0 0;border:0;border-top:1px solid var(--fv-border);border-radius:0;background:transparent} +.fv-setup details.fv-token>summary{font-size:13px;font-weight:500;color:var(--fv-muted)} +.fv-setup .fv-source-row{display:flex;align-items:center;gap:10px;margin-top:6px} +.fv-setup .fv-source-row>select{flex:1;margin:0;height:36px;min-height:36px;padding-block:0} +.fv-setup .fv-source-row>button{flex:none;height:36px;min-height:36px;padding:0 14px;white-space:nowrap} +@media (max-width:480px){.fv-setup .fv-source-row{flex-wrap:wrap}.fv-setup .fv-source-row>select{flex-basis:100%}.fv-setup .fv-source-row>button{margin-left:auto}} /* Fields. */ .fv-setup label{display:block;margin:12px 0 6px;font-size:var(--fv-body);color:var(--fv-muted)} diff --git a/web/setup.js b/web/setup.js index e04988d6..05d27033 100644 --- a/web/setup.js +++ b/web/setup.js @@ -77,11 +77,12 @@ export async function openSetup() { for (const [id, name] of [['auto',t('Automatic','自动选择')],['official','Hugging Face'],['hf-mirror','HF Mirror'],['modelscope','ModelScope']]) { const option = node('option',name); option.value = id; source.append(option); } - sourceLabel.append(source); downloads.append(sourceLabel); const probeButton = node('button', t('Test source speeds', '重新测速')); + const sourceRow = node('div'); sourceRow.className = 'fv-source-row'; sourceRow.append(source, probeButton); + sourceLabel.append(sourceRow); downloads.append(sourceLabel); const sourceStatus = note(''), speeds = node('div'); - downloads.append(probeButton, sourceStatus, speeds); - const auth = node('details'); + downloads.append(sourceStatus, speeds); + const auth = node('details'); auth.className = 'fv-token'; auth.append(node('summary', t('Hugging Face token · optional', 'Hugging Face Token · 可选'))); auth.append(note(t('Use your account’s download quota instead of the anonymous limit. Kept for this session only.', '使用账号下载额度,避开匿名限流。仅本次会话有效。'))); const hfTokenLabel = node('label', 'Hugging Face Token'); From 9e5326f843cd69d7c045158279d0820e2f306446 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 13/46] Header: the version number is a quiet label like Settings, not a raised button --- web/updates.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/updates.css b/web/updates.css index 126cbd11..ce4d33cb 100644 --- a/web/updates.css +++ b/web/updates.css @@ -13,6 +13,9 @@ .fv-update-summary{flex-basis:100%;font-weight:400;color:var(--fv-muted);overflow-wrap:anywhere}.fv-update-summary[hidden]{display:none} .fv-version-info{font:inherit;font-size:12px;color:var(--fv-muted);background:transparent;border:0;border-radius:6px;padding:6px 10px;cursor:pointer;white-space:nowrap} .fv-version-info:hover{background:var(--fv-hover);color:var(--fv-ink)} +/* Inside the studio the general button rule is more specific: keep the version a quiet label like Settings. */ +.fv-studio .fv-version-info{background:transparent;border-color:transparent;color:var(--fv-muted);font-size:12px;font-weight:400;padding:6px 10px} +.fv-studio .fv-version-info:hover{background:var(--fv-hover);border-color:transparent;color:var(--fv-ink)} .fv-release-notes{box-sizing:border-box;width:min(580px,calc(100vw - 32px));max-height:calc(100dvh - 48px);padding:24px;border:1px solid var(--fv-border,#354152);border-radius:16px;background:var(--fv-canvas,#161e29);color:var(--fv-ink,#e7edf7);font:14px/1.7 var(--fv-font,system-ui);overflow:auto;overflow-wrap:anywhere} .fv-release-notes::backdrop{background:#0009}.fv-release-notes h2{margin:0 0 20px;font-size:20px}.fv-release-notes h3{font-size:16px;margin:0 0 4px}.fv-release-notes h4{font-size:14px;margin:18px 0 0;color:var(--fv-ink,#e7edf7)}.fv-release-notes section+section{border-top:1px solid var(--fv-border,#354152);margin-top:24px;padding-top:20px}.fv-release-notes p{margin:8px 0}.fv-release-notes .fv-release-build{font-size:12px;color:var(--fv-muted,#a5b3c6)}.fv-release-notes ul{padding-left:22px}.fv-release-notes li+li{margin-top:8px} .fv-release-notes button{font:inherit;color:inherit;background:var(--fv-hover,#2a3341);border:0;border-radius:8px;padding:6px 18px;cursor:pointer}.fv-release-notes :focus-visible,.fv-version-info:focus-visible{outline:2px solid var(--fv-accent,#8eb8fb);outline-offset:2px} From 011ad385363f4be420c9e195d6932d8b7243fb49 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 14/46] Studio: opening the panel leaves focus on the panel, not on a ringed generate button --- web/studio.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/studio.js b/web/studio.js index dc062798..bafc11d3 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1600,7 +1600,9 @@ export function openStudio(node, {extendFrom = null} = {}) { if (!disposed && !busy && !result && saved) showResult(saved); } }).catch(() => {}); - requestAnimationFrame(() => { if (dialog.open && !dialog.querySelector('.fv-guide-layer') && !document.querySelector('dialog.fv-editor[open]')) (extension.source ? prompt : generate).focus({preventScroll: true}); }); + // Opening leaves focus on the panel itself, not on the main button with a ring as if already pressed; Tab still reaches it. + dialog.tabIndex = -1; + requestAnimationFrame(() => { if (dialog.open && !dialog.querySelector('.fv-guide-layer') && !document.querySelector('dialog.fv-editor[open]')) (extension.source ? prompt : dialog).focus({preventScroll: true}); }); // Establish the baseline after UI initialization; opening a panel is not an edit. trackPromptDraft(node); return dialog; From c876a9109fe15187df5585a506e75d75de5c2629 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 15/46] Editor: keep the Export button in the primary colour in every state --- web/editor.css | 4 +++- web/editor.js | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/web/editor.css b/web/editor.css index 5d7837da..9a055f07 100644 --- a/web/editor.css +++ b/web/editor.css @@ -19,6 +19,8 @@ .fv-editor .fv-ed-icon{height:32px;width:32px;min-height:32px;flex:none;padding:0;font-size:22px;background:transparent;border-color:transparent} .fv-ed-glyph{width:16px;height:16px;flex:none;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round}.fv-ed-name>span{overflow:hidden;text-overflow:ellipsis} .fv-ed-export-box{height:100%;display:flex;gap:12px;align-items:center;min-width:0} +.fv-editor .fv-ed-export-box>.fv-ed-export-main{height:32px;min-height:32px;padding:0 16px;border:0;border-radius:8px;background:var(--fv-accent,#6db8fa);color:#0f1722;font-weight:600} +.fv-editor .fv-ed-export-box>.fv-ed-export-main:hover{background:var(--fv-accent-hover,#91cbff);color:#0f1722} .fv-ed-export-hint{max-height:100%;overflow:auto;font-size:12px;color:var(--fv-muted)} .fv-ed-save-warning{max-height:100%;overflow:auto;font-size:12px;color:var(--fv-danger);min-width:0}.fv-ed-save-warning:not([hidden])+.fv-ed-export-hint{display:none} .fv-editor .fv-ed-library{grid-column:1;grid-row:2/4;border-right:1px solid var(--fv-border);padding:12px;overflow:auto;min-height:0;scrollbar-width:thin;scrollbar-color:var(--fv-border) transparent} @@ -167,7 +169,7 @@ .fv-editor .fv-ed-icon{width:28px;flex:none;font-size:20px} .fv-ed-export-box{gap:4px} .fv-ed-export-hint{display:none} - .fv-editor .fv-ed-export-box>button{padding:0 8px;font-size:12px} + .fv-editor .fv-ed-export-box>button,.fv-editor .fv-ed-export-box>.fv-ed-export-main{padding:0 8px;font-size:12px} .fv-editor .fv-ed-drawer-toggle{display:inline-flex!important} .fv-editor .fv-ed-library{display:none;position:absolute;top:52px;bottom:0;left:0;width:264px;max-width:90vw;z-index:7;background:var(--fv-bg)} .fv-editor .fv-ed-library[data-open=true]{display:block} diff --git a/web/editor.js b/web/editor.js index 520c7d13..d9043ebd 100644 --- a/web/editor.js +++ b/web/editor.js @@ -180,7 +180,7 @@ export function openEditor({api, app, from = null, panel = {}, components: given const options = await openExportDialog({parent: element, state, sources: store.sources, t, onError: error}); if (options && !disposed) await actions.export(options); } catch (value) { error(value, 'export'); } - }, 'fv-primary'); + }, 'fv-primary fv-ed-export-main'); const exportCancel = button('', () => store.cancelExport(exportStatus.id).catch(error)); exportCancel.hidden = true; exportCancel.append(icon('m4 4 8 8M12 4l-8 8')); exportCancel.setAttribute('aria-label', t('Cancel', '取消')); exportBox.append(exportHint, exportButton, exportCancel); brand.append(wordmark(), title); headerActions.append(exportBox, feedbackButton(t), button(t('Settings', '设置'), openSetup, 'fv-quiet')); From a2178d65c9dc68fdaf64abcbc4e4ffec6f2121b2 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 16/46] Editor: Text and Subtitles toolbar tools use line icons like the other tools --- web/editor.css | 1 - web/editor_text.css | 2 +- web/editor_text.js | 5 ++++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/editor.css b/web/editor.css index 9a055f07..ef692e37 100644 --- a/web/editor.css +++ b/web/editor.css @@ -146,7 +146,6 @@ .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-ed-more-panel button::after{content:attr(data-more-label)} -.fv-ed-more-panel .fv-ed-text-icon{width:18px;text-align:center;flex:none} /* 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} .fv-editor .fv-ed-toolbar .fv-ed-more-panel button[aria-pressed=true] svg{color:var(--fv-accent)} diff --git a/web/editor_text.css b/web/editor_text.css index 243f8bd9..d1831521 100644 --- a/web/editor_text.css +++ b/web/editor_text.css @@ -22,7 +22,7 @@ .fv-ed-track-row[data-hidden=true] .fv-ed-clip{opacity:.45}.fv-ed-track-row[data-locked=true] .fv-ed-clip{cursor:not-allowed} .fv-ed-track-row[data-locked=true]{background-image:repeating-linear-gradient(135deg,color-mix(in srgb,var(--fv-ink) 8%,transparent) 0 2px,transparent 2px 8px)} .fv-ed-clip[data-past=true]{-webkit-mask-image:linear-gradient(to right,#000 var(--fv-ed-past,0),rgb(0 0 0/.3) var(--fv-ed-past,0));mask-image:linear-gradient(to right,#000 var(--fv-ed-past,0),rgb(0 0 0/.3) var(--fv-ed-past,0))} -.fv-editor .fv-ed-text-buttons{gap:4px}.fv-editor .fv-ed-text-buttons button{display:inline-flex;gap:5px;padding:0 7px;height:28px}.fv-ed-text-icon{font-size:11px;font-weight:700;opacity:.7} +.fv-editor .fv-ed-text-buttons{gap:4px} .fv-editor .fv-ed-text-notice{color:var(--fv-warning);background:none;border:0;height:auto;max-width:260px;font-size:11px;white-space:normal;text-align:left;line-height:1.4} .fv-ed-text-menu{position:absolute;z-index:20;min-width:230px;padding:4px;background:var(--fv-card);border:1px solid var(--fv-border);border-radius:6px;box-shadow:0 6px 30px #0008} .fv-editor .fv-ed-text-menu button{display:flex;justify-content:space-between;width:100%;height:32px;padding:4px 8px;border:0;background:none;text-align:left}.fv-ed-text-menu kbd{color:var(--fv-muted);margin-left:18px;font:11px inherit} diff --git a/web/editor_text.js b/web/editor_text.js index b43dadd0..04f87199 100644 --- a/web/editor_text.js +++ b/web/editor_text.js @@ -27,7 +27,10 @@ export function createEditorText({element,state,t,actions,adjust,player,toolbar, // Icons only on the toolbar; the name lives in the label and hover. const textButton=button('',{onClick:()=>add('text')}), subtitleButton=button('',{onClick:()=>add('subtitle')}); textButton.classList.add('fv-ed-add-text'); subtitleButton.classList.add('fv-ed-add-subtitle'); - textButton.append(el('span','fv-ed-text-icon','T')); subtitleButton.append(el('span','fv-ed-text-icon','CC')); + // The same 16 px line glyphs as the other toolbar tools. + const glyph=d=>{const svg=document.createElementNS('http://www.w3.org/2000/svg','svg');svg.setAttribute('viewBox','0 0 16 16');svg.setAttribute('aria-hidden','true');svg.classList.add('fv-ed-glyph');const path=document.createElementNS(svg.namespaceURI,'path');path.setAttribute('d',d);svg.append(path);return svg;}; + textButton.classList.add('fv-ed-icon'); subtitleButton.classList.add('fv-ed-icon'); + textButton.append(glyph('M3 5V3.5h10V5M8 3.5v9M6 12.5h4')); subtitleButton.append(glyph('M4 3.5h8a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2zM4.5 7.5h3M9.5 7.5h2M4.5 10h2M8.5 10h3')); textButton.setAttribute('aria-label',t('Text','文字')); subtitleButton.setAttribute('aria-label',t('Subtitles','字幕')); textButton.title=t('Text T','文字 T'); subtitleButton.title=t('Subtitles','字幕'); buttons.append(textButton,subtitleButton); From bb6122634b0ce85f1b052de3f7e252fe2a212fcc Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 17/46] Editor: a narrow window keeps the preview at the project's shape, spare height to the timeline --- web/editor.css | 6 ++++++ web/editor_frame.js | 3 +++ 2 files changed, 9 insertions(+) diff --git a/web/editor.css b/web/editor.css index ef692e37..4151f2b2 100644 --- a/web/editor.css +++ b/web/editor.css @@ -227,3 +227,9 @@ .fv-editor .fv-ed-library{grid-area:auto;top:116px;bottom:0;z-index:8} .fv-editor:has(.fv-ed-library[data-open=true]) .fv-ed-drawer-toggle{position:fixed;top:128px;right:20px;z-index:9} } +/* Narrow window: the picture sits right under the header, the row takes the project's own shape (a + 16:9 project is about 200 px tall at 400 px wide), and whatever height is left goes to the timeline. */ +@media(max-width:680px){ + .fv-studio.fv-editor,.fv-studio.fv-editor:has(.fv-ed-gen-failure:not([hidden])){grid-template-rows:auto minmax(0,auto) 40px var(--fv-ed-generation-row,max-content) auto minmax(160px,1fr)} + .fv-editor .fv-ed-preview{box-sizing:border-box;align-self:start;aspect-ratio:var(--fv-ed-project-width,16)/var(--fv-ed-project-height,9);max-height:max(116px,calc(100dvh - 470px));padding-top:12px;padding-bottom:8px} +} diff --git a/web/editor_frame.js b/web/editor_frame.js index 614d15a7..463a383e 100644 --- a/web/editor_frame.js +++ b/web/editor_frame.js @@ -74,6 +74,9 @@ export function createPictureGuide(host, frame, {state, t}) { const show = !preview && !!clip?.source && differentPictureShape(size, record); const ratio = size.width / size.height, target = record.width / record.height; frame.style.setProperty('--fv-ed-width', String(size.width)); frame.style.setProperty('--fv-ed-height', String(size.height)); + // Narrow windows size the preview row by the project's shape, so the timeline does not move as the playhead crosses parts of other shapes. + const host = frame.closest('.fv-ed-preview'); + if (host) { host.style.setProperty('--fv-ed-project-width', String(record.width)); host.style.setProperty('--fv-ed-project-height', String(record.height)); } frame.dataset.playing = String(!!playing); updateTools(); shade.hidden = !show || clip?.fit === 'contain'; fill.setAttribute('aria-pressed', String(clip?.fit !== 'contain')); fit.setAttribute('aria-pressed', String(clip?.fit === 'contain')); From ffa51cd88da1fdfaacacd0bf5f8b00402c3d0848 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 18/46] Show a part's failure once: hide the line under the prompt bar while its card is open --- web/editor.css | 2 ++ web/editor_tracks.js | 11 +++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/web/editor.css b/web/editor.css index 4151f2b2..222d55f7 100644 --- a/web/editor.css +++ b/web/editor.css @@ -80,6 +80,8 @@ .fv-editor .fv-ed-gen-failure{display:block;flex:1 0 100%;min-width:0} .fv-ed-failure-actions{justify-content:flex-start;width:100%} .fv-ed-failure-reason{flex:1 1 220px;min-width:0;color:var(--fv-warning);overflow-wrap:anywhere} +/* While the failed block's card is open it already says this; the line keeps its place so the timeline does not move. */ +.fv-editor:has(.fv-ed-clip-popover:not([hidden])) .fv-ed-gen-failure .fv-ed-failure-actions{visibility:hidden} .fv-ed-gen-failure .fv-report-tip{width:100%} .fv-ed-basic-progress[data-state=waiting] .fv-ed-basic-percent{font-weight:500;color:var(--fv-muted)} .fv-ed-description{flex:1;width:0} diff --git a/web/editor_tracks.js b/web/editor_tracks.js index f42897a5..d9b85056 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -603,8 +603,15 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = Promise.resolve().then(() => actions.retry?.(item.clip.id)).catch(error => actions.editError?.(error, 'generate')); }; popover.replaceChildren(reason, report, retry); popover.hidden = false; const bounds = element.getBoundingClientRect(), clipBounds = item.element.getBoundingClientRect(); - popover.style.left = `${clamp(clipBounds.left - bounds.left, 8, Math.max(8, bounds.width - 288))}px`; - popover.style.top = `${Math.max(8, Math.min(clipBounds.bottom - bounds.top + 4, bounds.height - popover.getBoundingClientRect().height - 8))}px`; + // The card is as wide as the clip it belongs to (never narrower than before, so its two buttons stay on one row) and sits against the clip's lower edge; + // where that would cover the track below, it opens upward from the clip's top when there is room. + const width = Math.min(Math.max(clipBounds.width, 280), bounds.width - 16); + popover.style.width = `${width}px`; + popover.style.left = `${clamp(clipBounds.left - bounds.left, 8, Math.max(8, bounds.width - width - 8))}px`; + const height = popover.getBoundingClientRect().height, below = clipBounds.bottom - bounds.top + 4, above = clipBounds.top - bounds.top - 4 - height; + const next = [...element.querySelectorAll('.fv-ed-track-row')].map(row => row.getBoundingClientRect().top - bounds.top).filter(top => top >= clipBounds.bottom - bounds.top - 1); + const room = next.length ? Math.min(...next) : bounds.height; + popover.style.top = `${Math.max(8, below + height <= room || above < 8 ? Math.min(below, bounds.height - height - 8) : above)}px`; } function clickSelection(event) { if (event.range) return; From 66521a6a992ce3e2d104a3e3b2b61a85093c1a40 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 19/46] Group the generation buttons at the right with equal height on narrow screens --- web/editor.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/editor.css b/web/editor.css index 222d55f7..29ac5193 100644 --- a/web/editor.css +++ b/web/editor.css @@ -185,7 +185,8 @@ .fv-ed-ref-chips{max-width:calc(100vw - 96px)} .fv-ed-gen-hint{flex:1 0 auto;white-space:nowrap} .fv-ed-gen-note{order:-1;flex:1 1 100%;white-space:normal} - .fv-editor .fv-ed-genbar>button{margin-left:auto;max-width:100%;font-size:12px} + .fv-editor .fv-ed-genbar>button{margin-left:auto;max-width:100%;height:36px;min-height:36px;font-size:12px} + .fv-editor .fv-ed-genbar>button:not([hidden]) ~ button:not([hidden]){margin-left:0} .fv-ed-name-menu{position:fixed;top:120px;left:8px} .fv-ed-notice{top:60px;bottom:auto;pointer-events:none} .fv-ed-save-warning{position:absolute;top:60px;left:8px;right:8px;border:1px solid var(--fv-border);background:var(--fv-card);padding:8px 12px;border-radius:8px;z-index:9} From c3e15847b0825697c55a5d31d6fd839157c1ab5d Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 20/46] Start a continued block's description after its link on the timeline --- web/editor_notes.css | 3 +++ web/editor_tracks.js | 1 + 2 files changed, 4 insertions(+) diff --git a/web/editor_notes.css b/web/editor_notes.css index 48bdd29c..699847a1 100644 --- a/web/editor_notes.css +++ b/web/editor_notes.css @@ -13,6 +13,9 @@ /* Timeline: the strip sits under the pictures, one shade darker than the track. */ .fv-ed-clip[data-noted=true] .fv-ed-clip-media{bottom:22px} .fv-ed-note{position:absolute;left:0;right:0;bottom:0;z-index:1;height:22px;padding:0 8px;overflow:hidden;background:var(--fv-bg);color:var(--fv-ink);font:12px/22px var(--fv-font);text-align:left;text-overflow:ellipsis;white-space:nowrap} +/* A continued block's link sits at the strip's left end: the description starts after it, and the link is centred on the strip's line. */ +.fv-ed-clip.fv-ed-seamed .fv-ed-note{padding-left:26px} +.fv-ed-clip.fv-ed-seamed[data-noted=true] .fv-ed-seam-mark{bottom:2px} /* Block numbers take the top-left corner, so planned lengths move right beside them. */ .fv-ed-track-view:has(.fv-ed-track-row[data-noted=true]) .fv-ed-planned-label{left:24px} .fv-ed-block-number{position:absolute;left:6px;top:4px;z-index:2;color:#fff;font:700 11px/1 var(--fv-font);text-shadow:0 1px 2px rgba(0,0,0,.85);pointer-events:none} diff --git a/web/editor_tracks.js b/web/editor_tracks.js index d9b85056..31941d10 100644 --- a/web/editor_tracks.js +++ b/web/editor_tracks.js @@ -521,6 +521,7 @@ export function createTracks(host, {state, sources = {}, t = en => en, actions = if (seam === 'continued' && pixels(clip.frames) >= 26) { if (!mark) { mark = create('span', 'fv-ed-seam-mark'); mark.setAttribute('aria-hidden', 'true'); mark.dataset.kind = seam; mark.append(icon('link')); item.element.append(mark); } } else mark?.remove(); + item.element.classList.toggle('fv-ed-seamed', !!item.element.querySelector(':scope > .fv-ed-seam-mark')); const failureText = visual === 'failed' ? actions.failureText?.(clip.id) || actions.reasonText?.(clip.reason) || clip.reason || t('Generation failed', '生成时出错') : ''; if (visual === 'failed') item.status.replaceChildren(icon('warning'), create('span', 'fv-ed-clip-failure-reason', failureText)); else item.status.replaceChildren(); From 45176d713b820c384de3985a3ac21a92eaddbad3 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 21/46] Keep the editor notice off the view switch on narrow screens --- web/editor.css | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/editor.css b/web/editor.css index 29ac5193..4b14dc2f 100644 --- a/web/editor.css +++ b/web/editor.css @@ -188,7 +188,8 @@ .fv-editor .fv-ed-genbar>button{margin-left:auto;max-width:100%;height:36px;min-height:36px;font-size:12px} .fv-editor .fv-ed-genbar>button:not([hidden]) ~ button:not([hidden]){margin-left:0} .fv-ed-name-menu{position:fixed;top:120px;left:8px} - .fv-ed-notice{top:60px;bottom:auto;pointer-events:none} + /* Beside the view switch it covered "Editor"; here it sits at the foot of the picture area, in the grid. */ + .fv-ed-notice{position:relative;grid-column:1;grid-row:2;align-self:end;justify-self:end;margin:0 16px 8px;bottom:auto;right:auto;pointer-events:none} .fv-ed-save-warning{position:absolute;top:60px;left:8px;right:8px;border:1px solid var(--fv-border);background:var(--fv-card);padding:8px 12px;border-radius:8px;z-index:9} } From 74206ac0edbccdeb62e206250e59a2f548b16443 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 22/46] Remove duplicate dimensions from the preview header --- web/studio.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/web/studio.js b/web/studio.js index bafc11d3..189def86 100644 --- a/web/studio.js +++ b/web/studio.js @@ -893,8 +893,7 @@ export function openStudio(node, {extendFrom = null} = {}) { mode.value = value(node, 'control_after_generate') || 'fixed'; mode.onchange = () => set(node, 'control_after_generate', mode.value); seedFields.append(field(t('Seed', '种子'), seed), field(t('After generation', '生成后'), mode)); advanced.append(seedFields); const previewHead = el('div', null, 'fv-preview-head'); previewHead.append(el('strong', t('Preview', '预览'))); - const previewCaption = el('span'), previewTools = el('div', null, 'fv-preview-tools'); - previewTools.append(previewCaption); + const previewTools = el('div', null, 'fv-preview-tools'); previewTools.append(button(t('Creations', '作品'), () => { stage.querySelector('video')?.pause(); openLibrary(t, {upscale: value => node.freevideoUpscaleResult(value), extend: (saved, options) => openExtendEditor(saved, node, {studio: dialog, ...options})}); @@ -951,7 +950,6 @@ export function openStudio(node, {extendFrom = null} = {}) { function previewSize(w, h) { if (!(w > 0 && h > 0)) return; previewRatio = w / h; ratioStyle(); - dimensionText(previewCaption, `${w} × ${h}`); fitPreview(); } function dimensionText(element, text) { From e36a52107ea9c7d81a4737c28ac866259a44c739 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 23/46] Center and pad unavailable video messages in the preview --- web/studio.css | 1 + 1 file changed, 1 insertion(+) diff --git a/web/studio.css b/web/studio.css index 217b1260..39c9f805 100644 --- a/web/studio.css +++ b/web/studio.css @@ -72,6 +72,7 @@ .fv-studio .fv-stage{display:grid;place-items:center;position:relative;isolation:isolate;overflow:hidden;border:1px solid #ffffff14;border-radius:var(--fv-r-md);background:#101d2d;box-shadow:0 18px 52px #0004;max-width:100%;transition:width .48s cubic-bezier(.22,.7,.2,1),height .48s cubic-bezier(.22,.7,.2,1)} .fv-studio .fv-preview-scene{position:absolute;inset:0;width:100%;height:100%;pointer-events:none;transition:opacity .55s ease} .fv-studio .fv-stage-media{position:absolute;inset:0;display:grid;place-items:center;transition:opacity .55s ease;z-index:1} +.fv-studio .fv-stage-media>.fv-video-problem{min-width:0;max-width:100%;margin:0;padding:0 16px;text-align:center;color:var(--fv-muted);font-size:13px;line-height:1.6;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2;overflow:hidden;overflow-wrap:anywhere} .fv-studio .fv-stage video,.fv-studio .fv-stage img{display:block;width:100%;height:100%;min-height:0;object-fit:contain;background:#0d1118} .fv-studio .fv-stage:not([data-revealing=true]):has(.fv-generation-progress:not([hidden])) .fv-stage-media{opacity:0;pointer-events:none} .fv-studio .fv-stage:has(video):not(:has(.fv-generation-progress:not([hidden]))) .fv-preview-scene,.fv-studio .fv-stage[data-revealing=true] .fv-preview-scene{opacity:0} From d53b439b4e930d146a62bc01325f39d9c3f51664 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:08 +0000 Subject: [PATCH 24/46] Show compact resolution values while preserving native option labels --- web/studio.css | 6 ++++++ web/studio.js | 9 ++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/web/studio.css b/web/studio.css index 39c9f805..21d79c3b 100644 --- a/web/studio.css +++ b/web/studio.css @@ -30,6 +30,12 @@ .fv-prompt-guide a:hover,.fv-studio .fv-prompt-guide a:hover{background:transparent;color:var(--fv-ink)} .fv-prompt-guide a:focus-visible{outline:2px solid var(--fv-accent);outline-offset:3px} .fv-studio .fv-fields{display:grid;grid-template-columns:1fr 1fr;gap:12px}.fv-studio .fv-field{display:block;min-width:0}.fv-studio .fv-field>span{display:block;font-size:var(--fv-micro);color:var(--fv-muted);margin:0 0 7px} +.fv-studio .fv-resolution-select{position:relative;min-width:0} +.fv-studio .fv-resolution-select select{appearance:none;-webkit-appearance:none;color:transparent;padding-right:36px} +.fv-studio .fv-resolution-select option{color:var(--fv-ink)} +.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-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} diff --git a/web/studio.js b/web/studio.js index 189def86..0a021c66 100644 --- a/web/studio.js +++ b/web/studio.js @@ -614,10 +614,16 @@ export function openStudio(node, {extendFrom = null} = {}) { if (pixels === 1 && sizeSelection.value === .98) pixels = .98; for (const n of sizeSelection.options) { const option = el('option', n === .98 ? t('0.98 MP · Recommended', '0.98 MP · 推荐') : `${n.toFixed(2)} MP`); option.value = n; mp.append(option); } mp.value = sizeSelection.value; + // Keep the native menu's full labels; its closed face only needs the MP value. + const resolutionSelect = el('div', null, 'fv-resolution-select'), resolutionValue = el('span', '', 'fv-resolution-value'); + resolutionValue.setAttribute('aria-hidden', 'true'); + function syncResolutionLabel() { resolutionValue.textContent = `${Number(mp.value).toFixed(2)} MP`; } + syncResolutionLabel(); mp.addEventListener('input', syncResolutionLabel); + resolutionSelect.append(mp, resolutionValue, icon('chevron')); const legacyOption = [...mp.options].find(o => Number(o.value) === 1); mp.disabled = dimensionsLinked; const seconds = el('input'); seconds.type = 'number'; seconds.required = true; seconds.min = '1.625'; seconds.step = 'any'; seconds.value = savedSeconds(node, value(node, 'seconds')); seconds.disabled = linked(node, 'seconds'); seconds.setAttribute('aria-label', t('Duration in seconds', '时长(秒)')); - const fields = el('div', null, 'fv-fields'); fields.append(field(t('Resolution', '清晰度'), mp), field(t('Duration · seconds', '时长 · 秒'), seconds)); canvas.append(fields); + const fields = el('div', null, 'fv-fields'); fields.append(field(t('Resolution', '清晰度'), resolutionSelect), field(t('Duration · seconds', '时长 · 秒'), seconds)); canvas.append(fields); const canvasNote = el('div', null, 'fv-canvas-note'), dimensions = el('span'), duration = el('span'); canvasNote.append(dimensions, duration); canvas.append(canvasNote); const rangeMount = el('div'); canvas.append(rangeMount); let capabilityView; @@ -1421,6 +1427,7 @@ export function openStudio(node, {extendFrom = null} = {}) { : rest ? t(`${minutes} min ${rest} s`, `${minutes} 分 ${rest} 秒`) : t(`${minutes} min`, `${minutes} 分钟`)} · 24 fps`; } function updateCanvas() { + syncResolutionLabel(); // Render committed dimensions without replacing either input draft. // Duration/estimate refreshes and edits to the other dimension also // come here; only an explicit size choice should fill both fields. From f4c07404cecd85c7fc4ac8d3dc7870a814776e26 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:09 +0000 Subject: [PATCH 25/46] Format creation timestamps using the interface language --- web/library.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/web/library.js b/web/library.js index 72f88f01..59fedeeb 100644 --- a/web/library.js +++ b/web/library.js @@ -98,8 +98,9 @@ export async function libraryPage(t, {limit = 24, before = null, signal} = {}) { export function videoTimestamp(row, t) { const value = new Date(row.created_at); - return Number.isNaN(value.getTime()) ? t('Saved video', '已保存的视频') - : value.toLocaleString(undefined, {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'}); + if (Number.isNaN(value.getTime())) return t('Saved video', '已保存的视频'); + if (t('en', 'zh') === 'zh') return `${value.getMonth() + 1}月${value.getDate()}日 ${String(value.getHours()).padStart(2, '0')}:${String(value.getMinutes()).padStart(2, '0')}`; + return value.toLocaleString('en-US', {month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true}); } export function videoDimensions(row) { From dd7811f0bd0f912e9a049eccb6ce871ddb61ff2b Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:09 +0000 Subject: [PATCH 26/46] Collapse the library back label when the header runs out of space --- web/library.css | 5 ++++- web/library.js | 14 ++++++++++++-- web/result_actions.js | 1 + 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/web/library.css b/web/library.css index cd8a009c..bebe158d 100644 --- a/web/library.css +++ b/web/library.css @@ -1,6 +1,9 @@ .fv-studio.fv-library{width:calc(100vw - 32px);height:calc(100dvh - 32px);overflow:hidden} .fv-library .fv-library-header{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:12px;padding:18px 22px;border-bottom:1px solid var(--fv-border)} -.fv-library-header h2{font-size:var(--fv-strong);font-weight:var(--fv-semibold);margin:0}.fv-library-header>button:first-child{justify-self:start}.fv-library-header>button:last-child{justify-self:end} +.fv-library-header h2{font-size:var(--fv-strong);font-weight:var(--fv-semibold);margin:0;white-space:nowrap}.fv-library-header>button:first-child{justify-self:start}.fv-library-header>button:last-child{justify-self:end} +.fv-library-back-label{white-space:nowrap}.fv-library-back>.fv-icon{display:none} +.fv-library-header[data-compact=true] .fv-library-back-label{display:none} +.fv-library-header[data-compact=true] .fv-library-back>.fv-icon{display:inline-flex} .fv-library-body{height:calc(100% - 75px);display:grid;grid-template-columns:320px minmax(0,1fr);min-height:0} .fv-library-collection{overflow:auto;overscroll-behavior:contain;scrollbar-width:thin;padding:18px;border-right:1px solid var(--fv-border);text-align:center} .fv-library-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px 10px} diff --git a/web/library.js b/web/library.js index 59fedeeb..2cae7581 100644 --- a/web/library.js +++ b/web/library.js @@ -1,6 +1,6 @@ import { api } from '../../scripts/api.js'; import { closeDialog } from './motion.js'; -import { resultActions, previewBadge } from './result_actions.js'; +import { resultActions, previewBadge, icon } from './result_actions.js'; import { videoUnavailableText } from './studio_extend.js'; import { topDialog } from './dialog_keys.js'; @@ -167,12 +167,21 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu dialog.setAttribute('aria-label', t('Your creations', '我的作品')); const header = el('header', null, 'fv-library-header'); // Closing returns to the view this was opened from; that view names itself on the button. - const back = button(backLabel || t('Back to create', '返回创作'), () => closeDialog(dialog), 'fv-quiet'); + const backText = backLabel || t('Back to create', '返回创作'); + const back = button('', () => closeDialog(dialog), 'fv-quiet fv-library-back'); + back.setAttribute('aria-label', backText); back.title = backText; + back.append(icon('back'), el('span', backText, 'fv-library-back-label')); const title = el('h2', t('Your creations', '我的作品')); const refresh = button(t('Refresh', '刷新'), () => load(false), 'fv-quiet'); const choose = button(t('Select', '选择'), () => setSelecting(!selecting), 'fv-quiet fv-library-choose'); const tools = el('div', null, 'fv-library-tools'); tools.append(choose, refresh); header.append(back, title, tools); + // Try the full label on each resize, so wider windows restore it too. + const headerObserver = new ResizeObserver(() => { + header.dataset.compact = 'false'; + header.dataset.compact = String(title.scrollWidth > title.clientWidth || header.scrollWidth > header.clientWidth); + }); + headerObserver.observe(header); headerObserver.observe(tools); const body = el('div', null, 'fv-library-body'); const collection = el('section', null, 'fv-library-collection'); collection.setAttribute('aria-label', t('Saved videos', '已保存的视频')); const grid = el('div', null, 'fv-library-grid'); @@ -668,6 +677,7 @@ export function openLibrary(t, {upscale = null, selectedWork = null, extend = nu window.addEventListener('freevideo-result', completed); dialog.addEventListener('cancel', event => { event.preventDefault(); if (selecting && !deleting) setSelecting(false); else closeDialog(dialog); }); dialog.onclose = () => { + headerObserver.disconnect(); disposed = true; abort.abort(); thumbnails.disconnect(); releasePlayer(); clearTimeout(undoTimer); clearTimeout(sizeTimer); clearTimeout(syncTimer); window.removeEventListener('freevideo-result', completed); window.removeEventListener('storage', undoChanged); window.removeEventListener('freevideo-library-undo', showUndo); window.removeEventListener('freevideo-library-deleted', deletedElsewhere); dialog.remove(); if (opened === dialog) opened = null; diff --git a/web/result_actions.js b/web/result_actions.js index 2ec5967a..735d1319 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -7,6 +7,7 @@ const css = document.createElement('link'); css.rel = 'stylesheet'; css.href = new URL('./result_actions.css', import.meta.url).href; document.head.append(css); const ICONS = { + back: '', sidebar: '', download: '', share: '', From 285219a91c38706ed7afac3a83f0033b1fec82c5 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:09 +0000 Subject: [PATCH 27/46] Studio: a video that cannot be opened greys out download, extend and share Studio: a video that cannot be opened greys out download, extend and share; empty stat cells leave the strip --- web/result_actions.css | 1 + web/result_actions.js | 10 ++++++++++ web/studio.css | 4 ++++ web/studio.js | 9 +++++++-- 4 files changed, 22 insertions(+), 2 deletions(-) diff --git a/web/result_actions.css b/web/result_actions.css index ac5e3f0a..42313b4f 100644 --- a/web/result_actions.css +++ b/web/result_actions.css @@ -63,3 +63,4 @@ @media(prefers-reduced-motion:reduce){.fv-result-links>.fv-report-tip .fv-report-tip-mark{transition:none}} @keyframes fv-menu-enter{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}} @media(prefers-reduced-motion:reduce){.fv-actions *{transition:none!important;animation:none!important}} +.fv-actions[data-unavailable=true] :is(a.fv-action[download],.fv-action-extend,.fv-share-trigger){opacity:.45;cursor:default;pointer-events:none} diff --git a/web/result_actions.js b/web/result_actions.js index 735d1319..f5f45ee4 100644 --- a/web/result_actions.js +++ b/web/result_actions.js @@ -107,6 +107,16 @@ function deleteAction(t, remove) { return menu; } +// The video cannot be opened: what works on the file (download, extend, share) goes quiet and cannot be used. +export function actionsUnavailable(bar) { + if (!bar) return; + bar.dataset.unavailable = 'true'; + for (const item of bar.querySelectorAll('a.fv-action[download], .fv-action-extend, .fv-share-trigger')) { + item.setAttribute('aria-disabled', 'true'); item.tabIndex = -1; + if (item.tagName === 'A') item.removeAttribute('href'); else item.disabled = true; + } +} + // Download the video first; share and the report follow. A first-pass preview // leads with `secondPass` instead, and saved creations end with `remove`. // `diagnostic` downloads the redacted report (and may offer to send it); a diff --git a/web/studio.css b/web/studio.css index 21d79c3b..5f5b5b1e 100644 --- a/web/studio.css +++ b/web/studio.css @@ -102,6 +102,10 @@ .fv-studio .fv-stats{animation:fv-panel-enter .35s ease;display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;text-align:center;margin:0;border:1px solid var(--fv-border);border-radius:14px;background:var(--fv-border);overflow:hidden}.fv-studio .fv-stat{padding:13px 6px 12px;background:var(--fv-card)} .fv-studio .fv-stat strong{font-size:var(--fv-strong);font-weight:var(--fv-semibold);display:block;color:var(--fv-ink);font-variant-numeric:tabular-nums;white-space:nowrap}.fv-studio .fv-stat span{display:block;margin-top:3px;font-size:var(--fv-micro);color:var(--fv-muted)} .fv-studio .fv-stats+.fv-budget{margin-top:-9px} +.fv-studio .fv-stat[hidden]{display:none} +.fv-studio .fv-stats[data-count="1"]{grid-template-columns:minmax(0,1fr)} +.fv-studio .fv-stats[data-count="2"]{grid-template-columns:repeat(2,minmax(0,1fr))} +.fv-studio .fv-stats[data-count="3"]{grid-template-columns:repeat(3,minmax(0,1fr))} .fv-studio .fv-result-links{animation:fv-panel-enter .35s ease;display:flex;align-items:center;justify-content:center;gap:9px;flex-wrap:wrap} .fv-studio .fv-runbar{position:sticky;bottom:0;padding:16px 0 22px;background:linear-gradient(transparent,var(--fv-bg) 12%);z-index:2} .fv-studio .fv-runbar .fv-primary{width:100%;min-height:var(--fv-h-lg);font-size:14px} diff --git a/web/studio.js b/web/studio.js index 0a021c66..8afed586 100644 --- a/web/studio.js +++ b/web/studio.js @@ -25,7 +25,7 @@ import { attachMentionOverlay } from './cast_overlay.js'; import { openCastLibrary } from './cast_source.js'; import { reconcileLoraTriggers } from './lora_triggers.js'; import { createSamplingEffort } from './sampling_effort.js'; -import { resultActions, previewBadge, icon } from './result_actions.js'; +import { resultActions, previewBadge, icon, actionsUnavailable } from './result_actions.js'; import { createPromptEnhancer } from './prompt_enhance.js'; import { createRequestAssets, enabledLoras, referenceFiles } from './request_assets.js'; import { rememberPromptDraft, trackPromptDraft } from './prompt_draft.js'; @@ -1047,6 +1047,7 @@ export function openStudio(node, {extendFrom = null} = {}) { video.addEventListener('error', () => { if (!disposed && result === r && video.isConnected) { stageMedia.replaceChildren(el('p', videoUnavailableText(t), 'fv-note fv-video-problem')); + actionsUnavailable(links.querySelector('.fv-actions')); if (r.preview) stageMedia.append(previewBadge(r, t)); } }, {once: true}); @@ -1067,7 +1068,11 @@ export function openStudio(node, {extendFrom = null} = {}) { const unified = r.memory_model === 'unified'; metricLabels[2].textContent = unified ? t('Unified memory', '统一内存总量') : t('Peak VRAM', '最高显存'); metricLabels[3].textContent = unified ? t('Process peak RAM', '进程最高内存') : t('Peak RAM', '最高内存'); - const shown = [number(r.sample_seconds), number(r.request_seconds), number(unified ? r.unified_total_bytes : r.vram_peak_bytes, 2 ** 30, 'GiB'), number(r.ram_peak_bytes, 2 ** 30, 'GiB')]; metrics.forEach((e, i) => e.textContent = shown[i]); + const shown = [number(r.sample_seconds), number(r.request_seconds), number(unified ? r.unified_total_bytes : r.vram_peak_bytes, 2 ** 30, 'GiB'), number(r.ram_peak_bytes, 2 ** 30, 'GiB')]; metrics.forEach((e, i) => { e.textContent = shown[i]; e.parentElement.hidden = shown[i] === '—'; }); + // Cells without a number are left out, and the rest share the width; none at all hides the strip. + const present = shown.filter(value => value !== '—').length; + stats.dataset.count = String(present); + if (!present) stats.hidden = true; budget.textContent = r.result_cache_hit || r.preview ? '' : unified ? (Number.isFinite(r.unified_reserve_bytes) ? `${t('Reserved unified memory', '预留统一内存')} ${number(r.unified_reserve_bytes, 2 ** 30, 'GiB')}` : '') : (Number.isFinite(r.gpu_budget_bytes) ? `${t('VRAM budget', '可用显存预算')} ${number(r.gpu_budget_bytes, 2 ** 30, 'GiB')} · ${t('Device', '显卡总量')} ${number(r.gpu_total_bytes, 2 ** 30, 'GiB')}` : ''); From ffefc31e6764f09d71ef2e86f57cad76b20ef64e Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:09 +0000 Subject: [PATCH 28/46] Localize advanced generation node labels in Chinese --- freevideo_engine/failure_details.py | 6 +++--- scripts/test_freevideo_entry.mjs | 32 +++++++++++++++++++++++++++++ web/error_panel.js | 6 +++--- web/freevideo.js | 17 +++++++++++++++ 4 files changed, 55 insertions(+), 6 deletions(-) diff --git a/freevideo_engine/failure_details.py b/freevideo_engine/failure_details.py index faa5b3f8..ed856ca2 100644 --- a/freevideo_engine/failure_details.py +++ b/freevideo_engine/failure_details.py @@ -451,13 +451,13 @@ def launcher_failure(value, *, zh=False, other_disk=False, disk_name='', can_rep '请使用 39、56、73、90 等每次增加 17 的帧数;接续时,这个数包含开头重复的 22 帧。')), (r'Continue from video cannot be used with these settings\. Continuing a video needs an NVIDIA', 'continuation-route', ("This computer can't continue a video", '这台电脑无法接续视频'), - ('Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡。要生成新视频,请清空“Continue from video”。')), + ('Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡。要生成新视频,请清空“接着生成”。')), (r'Continue from video cannot be used with these settings\. Turn on "Two-pass', 'continuation-passes', ('Continuing needs two-pass acceleration', '接续需要二次采样加速'), - ('Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭首轮预览。')), + ('Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。')), (r'Continue from video cannot be used with these settings\. Clear either', 'continuation-refine', ('A continued part is made in full', '接续会完整生成这一段'), - ('Clear either "Second pass of preview" or "Continue from video".', '请清空“Second pass of preview”或“Continue from video”其中一项。')), + ('Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“接着生成”其中一项。')), (r'Continue from video cannot be used with these settings\. Connect up to 3 pictures', 'continuation-media', ('A continued part takes picture references only', '接续段只能用图片参考'), ('Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', '最多接 3 张图片作为参考图,并移除视频、音频参考和预编码输入。')), diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 5ef12828..c96118cc 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -164,6 +164,38 @@ test('main browser entry registers both views and preserves node hooks', async t } } finally { navigator.language = 'en'; } }); + await t.test(`${language} advanced node labels preserve keys, values and untranslated copy`, async () => { + navigator.language = language; + try { + await import('data:text/javascript;base64,' + Buffer.from(source + `\n// label language: ${language}`).toString('base64')); + const localized = extensions.at(-1); + class LocalizedGenerateNode extends GenerateNode {} + await localized.beforeRegisterNodeDef(LocalizedGenerateNode, {name: 'FreeVideoGenerate'}); + const rows = [ + ['force_regenerate', 'Force regeneration', '重新生成', false, '跳过已保存结果的缓存。'], + ['preview', 'Two-pass: preview first pass', '先出预览', false, + '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], + ['refine_from', 'Second pass of preview', '继续二采', 'preview.mp4', '从这个预览继续生成全分辨率视频。'], + ['continue_from', 'Continue from video', '接着生成', 'previous.mp4'], + ['frames', 'Total frames', '帧数', 141], + ['first_frame_reference', 'First-frame reference', '首帧参考', true, '减小漂移'], + ['voice_reference', 'Voice reference', 'Voice reference', true], + ['hide_tail', 'Hide dialogue tail', 'Hide dialogue tail', true], + ]; + const node = new LocalizedGenerateNode(); + node.widgets = rows.map(([name, label, , value]) => ({name, label, value, tooltip: `original ${name}`})); + node.onNodeCreated(); + assert.deepEqual(node.widgets.map(w => [w.name, w.value]), rows.map(([name, , , value]) => [name, value])); + for (const [name, en, zh, , tooltip] of rows) { + const widget = node.widgets.find(w => w.name === name); + 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); + node.onRemoved(); + } finally { navigator.language = 'en'; } + }); } await extension.beforeRegisterNodeDef(GenerateNode, {name: 'FreeVideoGenerate'}); const node = new GenerateNode(); app.graph._nodes = [node]; diff --git a/web/error_panel.js b/web/error_panel.js index 80c16d00..c9d38de5 100644 --- a/web/error_panel.js +++ b/web/error_panel.js @@ -23,13 +23,13 @@ const PLAIN_ERRORS = [ summary: ['Set the width and height to the previous video’s size.', '请把宽度和高度设为与上一段视频相同。']}, {key: 'continuation-route', message: 'Continue from video cannot be used with these settings. Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', title: ['This computer can\'t continue a video', '这台电脑无法接续视频'], - summary: ['Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡。要生成新视频,请清空“Continue from video”。']}, + summary: ['Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".', '接续视频需要 NVIDIA 显卡。要生成新视频,请清空“接着生成”。']}, {key: 'continuation-passes', message: 'Continue from video cannot be used with these settings. Turn on "Two-pass acceleration" and turn off the first-pass preview.', title: ['Continuing needs two-pass acceleration', '接续需要二次采样加速'], - summary: ['Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭首轮预览。']}, + summary: ['Turn on "Two-pass acceleration" and turn off the first-pass preview.', '请打开“二次采样加速”,并关闭先出预览。']}, {key: 'continuation-refine', message: 'Continue from video cannot be used with these settings. Clear either "Second pass of preview" or "Continue from video".', title: ['A continued part is made in full', '接续会完整生成这一段'], - summary: ['Clear either "Second pass of preview" or "Continue from video".', '请清空“Second pass of preview”或“Continue from video”其中一项。']}, + summary: ['Clear either "Second pass of preview" or "Continue from video".', '请清空“继续二采”或“接着生成”其中一项。']}, {key: 'continuation-media', message: 'Continue from video cannot be used with these settings. Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', title: ['A continued part takes picture references only', '接续段只能用图片参考'], summary: ['Connect up to 3 pictures as references, and remove video, audio and pre-encoded inputs.', '最多接 3 张图片作为参考图,并移除视频、音频参考和预编码输入。']}, diff --git a/web/freevideo.js b/web/freevideo.js index f49e5121..2ef03b09 100644 --- a/web/freevideo.js +++ b/web/freevideo.js @@ -721,6 +721,23 @@ app.registerExtension({ '修改采样步数可能降低生成质量。默认一采 8 步、二采 3 步;只有一采为 8 步时才会使用二次采样加速,其他步数只采样一遍;其他二采步数需小于一采步数。'); } } + if (cn) { + // Reuse product wording; leave untranslated names with no established Chinese copy. + for (const [name, label, tooltip] of [ + ['force_regenerate', '重新生成', '跳过已保存结果的缓存。'], + ['preview', '先出预览', '先出半分辨率预览,满意后在结果里点“继续二采”,成片与直接生成一致。'], + ['refine_from', '继续二采', '从这个预览继续生成全分辨率视频。'], + ['continue_from', '接着生成'], + ['frames', '帧数'], + ['first_frame_reference', '首帧参考', '减小漂移'], + ]) { + const widget = this.widgets?.find(w => w.name === name); + if (widget) { + widget.label = label; + if (tooltip) widget.tooltip = tooltip; + } + } + } for (const widget of this.widgets || []) { // Node and creation-panel edits use the same named draft. const node = this, callback = widget.callback; From 3f4d3d67d8d90ab22651b910684ada958e31d8a9 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:24 +0000 Subject: [PATCH 29/46] Remove duplicate step counts from Autopilot job cards --- web/autopilot_chat.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/autopilot_chat.js b/web/autopilot_chat.js index c04e0c8d..8066e30d 100644 --- a/web/autopilot_chat.js +++ b/web/autopilot_chat.js @@ -93,12 +93,12 @@ export function jobLevel(row, base, left, time) { // working, done, stopped or failed: which card a job shows. export const jobMode = job => WORKING.includes(job.status) ? 'working' : job.status === 'succeeded' ? 'done' : job.status === 'cancelled' ? 'stopped' : 'failed'; const SAVING = ['decode', 'finishing', 'assembling'], stepped = p => p.phase === 'sampling' && p.step?.total && p.step.done >= 1; -// The one line that says where a job is. No step count is written unless the engine gave one. +// The one line that says where a job is. Sampling leaves it blank; the preview shows progress. export function jobLine(job, t) { const p = job.progress || {}; if (job.status === 'cancelling') return t('Stopping', '正在停止'); if (job.status !== 'running') return t('Queued', '排队中'); - if (stepped(p)) return t(`Step ${p.step.done} / ${p.step.total}`, `生成 ${p.step.done} / ${p.step.total}`); + if (stepped(p)) return ''; if (SAVING.includes(p.phase)) return t('Saving', '保存中'); if (p.phase === 'encoding') return t('Reading the prompt', '读取提示词'); // Before the first step of the first part the model is still settling in: the line does not turn back to "Preparing". @@ -106,7 +106,7 @@ export function jobLine(job, t) { return t('Preparing', '准备中'); } // The line a card shows. After the last step, and again after saving, the engine has a moment with no step and no -// stage to report: a card that has counted steps or said "Saving" for a part keeps that line until there is a newer +// stage to report: a card that has started sampling or said "Saving" for a part keeps that line until there is a newer // one, and does not go back to the lines from before the first step. `said` is the card's own memory. export function cardLine(said, job, t) { const p = job.progress || {}, part = p.block?.index ?? 0; From 740f2db5062442ad314cde7dc90b168e83822f84 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:24 +0000 Subject: [PATCH 30/46] Reserve a toolbar row beneath the prompt text --- web/prompt_references.css | 8 +++++--- web/studio.js | 3 ++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/web/prompt_references.css b/web/prompt_references.css index 332485ed..377bba73 100644 --- a/web/prompt_references.css +++ b/web/prompt_references.css @@ -1,5 +1,8 @@ -.fv-prompt-editor{position:relative}.fv-studio .fv-prompt-editor textarea{padding-bottom:40px} -.fv-studio .fv-reference-trigger{position:absolute;right:9px;bottom:9px;min-height:27px;width:30px;padding:0;font-size:17px;border:0;background:var(--fv-raised);color:var(--fv-muted)} +.fv-prompt-editor{position:relative;display:flex;flex-direction:column;height:170px;min-height:148px;max-height:340px;resize:vertical;overflow:hidden;background:var(--fv-card)} +.fv-studio .fv-prompt-editor textarea{flex:1;min-height:0;height:0;max-height:none;resize:none;padding-bottom:8px;outline:none} +.fv-prompt-tools{display:flex;flex:none;align-items:center;justify-content:flex-end;height:36px;padding-right:9px} +.fv-studio .fv-reference-trigger{min-height:27px;height:27px;width:30px;padding:0;font-size:17px;border:0;background:var(--fv-raised);color:var(--fv-muted)} +@media(min-height:960px) and (min-width:681px){.fv-prompt-editor{height:clamp(170px,24dvh,340px)}} .fv-reference-picker{box-sizing:border-box;position:fixed;inset:auto;margin:0;padding:6px;border:1px solid var(--fv-border,#3a414c);border-radius:14px;background:var(--fv-card,#1a202a);color:var(--fv-ink,#eee);box-shadow:0 14px 42px #0006;font:13px/1.4 var(--fv-font,system-ui);z-index:10000;max-height:280px;overflow:hidden;animation:fv-reference-appear .12s ease-out} .fv-reference-picker[hidden]{display:none!important}.fv-reference-options{max-height:266px;overflow-y:auto;overscroll-behavior:contain;scrollbar-width:thin} .fv-reference-option{display:flex;align-items:center;gap:10px;min-height:59px;padding:7px;border-radius:9px;cursor:pointer} @@ -19,7 +22,6 @@ .fv-reference-face{width:24px;height:24px;flex:none;border-radius:50%;object-fit:cover;background:var(--fv-raised,#252e39)} .fv-reference-person strong{font-size:13px;font-weight:550;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .fv-reference-person[aria-disabled=true]{opacity:.5;cursor:not-allowed} -.fv-prompt-editor{position:relative} .fv-mention-mirror{position:absolute;box-sizing:border-box;overflow:hidden;pointer-events:none;white-space:pre-wrap;overflow-wrap:break-word;word-break:normal;color:var(--fv-ink,#eee);z-index:1} .fv-mention-mirror[hidden]{display:none} .fv-mention{color:var(--fv-accent,#6cb4ff)} diff --git a/web/studio.js b/web/studio.js index 8afed586..59b7ce66 100644 --- a/web/studio.js +++ b/web/studio.js @@ -549,7 +549,8 @@ export function openStudio(node, {extendFrom = null} = {}) { const mention = button('@', () => references.open(), 'fv-reference-trigger'); mention.setAttribute('aria-label', t('Reference media (@)', '引用素材(@)')); mention.title = mention.getAttribute('aria-label'); - mention.disabled = prompt.disabled; mention.onpointerdown = e => e.preventDefault(); promptEditor.append(mention); + mention.disabled = prompt.disabled; mention.onpointerdown = e => e.preventDefault(); + const promptTools = el('div', null, 'fv-prompt-tools'); promptTools.append(mention); promptEditor.append(promptTools); cleanup.push(() => references.dispose()); const terms = attachTermSuggest(prompt, {t, host: dialog, suppressed: () => !!mentionAt(prompt.value, prompt.selectionStart, prompt.selectionEnd, handleNames(castChoices()))}); cleanup.push(() => terms.dispose()); From 36d8925d75bef88158006df8455c9d298cc3fdda Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:24 +0000 Subject: [PATCH 31/46] Keep feedback accessible in narrow view headers --- web/editor.css | 1 + web/editor_header_fit.js | 17 ++++++++++++----- web/feedback.css | 9 +++++---- web/feedback.js | 9 ++++++++- 4 files changed, 26 insertions(+), 10 deletions(-) diff --git a/web/editor.css b/web/editor.css index 4b14dc2f..02abbfae 100644 --- a/web/editor.css +++ b/web/editor.css @@ -201,6 +201,7 @@ .fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-view-switch{grid-column:1/-1;grid-row:2;justify-self:center} .fv-studio.fv-editor .fv-header[data-fit~=wrap]>.fv-header-actions{grid-column:2;grid-row:1} .fv-studio.fv-editor .fv-header[data-fit~=wrap] .fv-ed-name{max-width:320px} +.fv-studio.fv-editor .fv-header[data-fit~=lean] .fv-feedback-entry{display:none} .fv-ed-volume-popover{position:absolute;top:calc(100% + 8px);left:0;display:flex;align-items:center;gap:8px;padding:10px 12px;border:1px solid var(--fv-border);border-radius:8px;background:var(--fv-card);white-space:nowrap;z-index:8} .fv-ed-volume{width:90px}.fv-ed-volume-value{font-variant-numeric:tabular-nums;font-size:12px;min-width:34px} diff --git a/web/editor_header_fit.js b/web/editor_header_fit.js index fe58c0a2..5552eb1e 100644 --- a/web/editor_header_fit.js +++ b/web/editor_header_fit.js @@ -1,5 +1,6 @@ // The view switch stays centred in the editor's header while the project name next to the logo stays readable. // When it would not, the logo first takes its phone size, then gives its place to the name; if that is still too tight, the switch moves to a second row. +// The feedback entry leaves before the switch moves down, and on two rows when the name is still tight. // About six Chinese characters or ten Latin letters of the name itself, without the menu arrow. const READABLE = 120, COMPACT_MARK = 90; @@ -16,13 +17,19 @@ export function fitEditorHeader(header, brand, title, navigation, actions) { const own = getComputedStyle(title), chevron = span.nextElementSibling, text = document.createRange(); text.selectNodeContents(span); const frame = px(own.paddingLeft) + px(own.paddingRight) + px(own.borderLeftWidth) + px(own.borderRightWidth) + (chevron ? chevron.getBoundingClientRect().width + px(own.columnGap) : 0); const wanted = Math.min(READABLE, text.getBoundingClientRect().width) + frame; - const fits = markWidth => side >= actions.offsetWidth && side - markWidth - spacing >= wanted; + // The actions' own width: the box itself may already be squeezed below its buttons. + const shown = [...actions.children].filter(child => child.offsetWidth > 0), apart = px(getComputedStyle(actions).columnGap); + const need = Math.max(actions.offsetWidth, shown.reduce((sum, child) => sum + child.getBoundingClientRect().width, 0) + apart * Math.max(0, shown.length - 1)); + const fits = markWidth => side >= need && side - markWidth - spacing >= wanted; const compact = Math.min(COMPACT_MARK, mark.offsetWidth); // On two rows the name shares the first row with the actions only; the logo shrinks too when that is still tight. - const rowFits = inner - actions.offsetWidth - gap - mark.offsetWidth - spacing >= wanted; - // From 681 px up the switch has one place in every view, so the logo leaves before the switch moves down. - const bare = !matchMedia('(max-width:680px)').matches && side >= actions.offsetWidth && side >= wanted; - const fit = fits(mark.offsetWidth) ? '' : fits(compact) ? 'compact' : bare ? 'bare' : rowFits ? 'wrap' : 'wrap compact'; + const row = markWidth => inner - need - gap - markWidth - spacing; + // The feedback entry is the first action to leave this header (it stays in the other views). + const feedback = actions.querySelector('.fv-feedback-entry'), spare = feedback ? feedback.offsetWidth + apart : 0; + // From 681 px up the switch has one place in every view, so the logo, then the feedback entry, leave before the switch moves down. + const wide = !matchMedia('(max-width:680px)').matches, bare = room => wide && side >= room && side >= wanted; + const fit = fits(mark.offsetWidth) ? '' : fits(compact) ? 'compact' : bare(need) ? 'bare' : spare && bare(need - spare) ? 'bare lean' + : row(mark.offsetWidth) >= wanted ? 'wrap' : row(compact) >= wanted || !spare ? 'wrap compact' : 'wrap compact lean'; if (fit) header.dataset.fit = fit; } const observer = new ResizeObserver(check); diff --git a/web/feedback.css b/web/feedback.css index 5cfe82fa..effd5e4e 100644 --- a/web/feedback.css +++ b/web/feedback.css @@ -6,10 +6,11 @@ .fv-feedback .fv-feedback-actions{display:flex;align-items:center;justify-content:flex-end;gap:14px;margin-top:20px} .fv-feedback .fv-feedback-status{flex:1;margin:0;min-width:0;color:#a5b3c6;font-size:13px;line-height:1.45;text-decoration:none} .fv-feedback .fv-feedback-actions button{flex:none;min-width:88px} -/* Header buttons never break into lines; where the header cannot hold the entry beside Settings, only Settings stays. */ +/* Keep the entry beside Settings; compact headers use the same labelled button with a line icon. */ .fv-header-actions>button{white-space:nowrap} -@media(max-width:1040px){.fv-autopilot .fv-header-actions>.fv-feedback-entry{display:none}} -/* Below 801 px the editor's header has room for the name, the view switch and Export only; the entry is in the other views. */ -@media(max-width:800px){.fv-editor .fv-header-actions>.fv-feedback-entry{display:none}} +.fv-feedback-entry>svg{display:none;flex:none;width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.4;stroke-linecap:round;stroke-linejoin:round} +@media(max-width:1040px){.fv-autopilot .fv-feedback-entry>svg{display:block}.fv-autopilot .fv-feedback-label{display:none}} +@media(max-width:800px){.fv-editor .fv-feedback-entry>svg{display:block}.fv-editor .fv-feedback-label{display:none}} +@media(max-width:680px){.fv-studio .fv-feedback-entry>svg{display:block}.fv-studio .fv-feedback-label{display:none}} /* On a narrow screen the line gets the whole width above the button. */ @media(max-width:480px){.fv-feedback .fv-feedback-actions{flex-wrap:wrap;row-gap:10px}.fv-feedback .fv-feedback-status{flex:1 0 100%}.fv-feedback .fv-feedback-status:empty{display:none}} diff --git a/web/feedback.js b/web/feedback.js index 6dd3510d..c2bc7e21 100644 --- a/web/feedback.js +++ b/web/feedback.js @@ -217,7 +217,14 @@ export function openFeedback(t, { open = openPage, version = productVersion, // The header entry, in the style of the Settings button beside it. export function feedbackButton(t, className = 'fv-quiet') { - const button = el('button', `${className} fv-feedback-entry`, t('Feedback', '反馈')); button.type = 'button'; + const label = t('Feedback', '反馈'); + const button = el('button', `${className} fv-feedback-entry`); button.type = 'button'; + button.setAttribute('aria-label', label); button.title = label; + const icon = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + icon.setAttribute('viewBox', '0 0 16 16'); icon.setAttribute('aria-hidden', 'true'); icon.setAttribute('focusable', 'false'); + const path = document.createElementNS(icon.namespaceURI, 'path'); + path.setAttribute('d', 'M13.5 2.5h-11a1 1 0 0 0-1 1v7a1 1 0 0 0 1 1h2v3l3.5-3h5.5a1 1 0 0 0 1-1v-7a1 1 0 0 0-1-1Z'); + icon.append(path); button.append(icon, el('span', 'fv-feedback-label', label)); button.onclick = () => openFeedback(t); return button; } From fd68e0c9cf752c4a899561abd7e741c6227b5570 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:25 +0000 Subject: [PATCH 32/46] Deleting a creation removes the working copy an interrupted share left; next start clears old ones --- freevideo_engine/comfy_library.py | 48 ++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/freevideo_engine/comfy_library.py b/freevideo_engine/comfy_library.py index 9549c1a9..42b4da7b 100644 --- a/freevideo_engine/comfy_library.py +++ b/freevideo_engine/comfy_library.py @@ -182,7 +182,8 @@ def video_download(output_directory, identity): _OWNED_FILE = re.compile(r'(?:video\.[A-Za-z0-9._-]+|prompt\.txt|workflow\.json|comfy-request\.json|generate\.log' r'|continuation-source\.safetensors' r'|media\.json|prompt-rewrite\.json|input-conditioning\.pt|(?:first|last)\.png' - r'|reference-\d{2}\.(?:png|mp4|wav))(?:\.[a-z0-9_]{8}\.tmp)?') + r'|reference-\d{2}\.(?:png|mp4|wav)' + r'|\.(?:share|editor-export)-[0-9a-f]{32}\.mp4)(?:\.[a-z0-9_]{8}\.tmp)?') _OWNED_FOLDER = 'video.artifacts' _REPARSE_POINT = 0x400 @@ -335,6 +336,45 @@ def release_decoded(folder): return dict(freed_bytes=0, left=['.'], skipped='unreadable') +# What an interrupted share or editor export leaves beside a creation: its working copy, +# written under a random name and renamed only when finished. +_INTERRUPTED = re.compile(r'\.(?:share|editor-export)-[0-9a-f]{32}\.mp4') + + +def clean_interrupted(output_directory, *, older=3600., now=None): + """Delete working copies of shares and exports that a closed program left behind. + + Only files not written to for ``older`` seconds, so one being written now stays. A folder + that holds nothing else afterwards (an export that never finished) goes with it. + Returns the bytes freed. + """ + try: + output_directory = checked_real_path(output_directory) + except AliasUnavailable: + return 0 + root = Path(output_directory).resolve() / 'FreeVideo' + now = time.time() if now is None else now + freed = 0 + for path in sorted(root.glob('*/*/.*.mp4')): + try: + folder = path.parent + if (not _INTERRUPTED.fullmatch(path.name) or not _ID.fullmatch(folder.relative_to(root).as_posix()) + or not _plain(folder.parent) or not _plain(folder) or not _plain(path)): + continue + info = path.stat() + if now - info.st_mtime < older: + continue + path.unlink() + freed += info.st_size + try: + folder.rmdir() + except OSError: + pass + except OSError: + continue + return freed + + def release_later(output_directory): """Have the next start finish a release that Windows kept open.""" from .monitoring import save @@ -1016,6 +1056,12 @@ def release(): result['freed_bytes'] / 1e9, result['videos']) except Exception: logging.warning('FreeVideo could not free the decoded files of earlier videos.', exc_info=True) + try: + freed = clean_interrupted(folder_paths.get_output_directory()) + if freed: + logging.info('FreeVideo removed %.1f MB left by an interrupted share or export.', freed / 1e6) + except Exception: + logging.warning('FreeVideo could not remove files left by an interrupted share or export.', exc_info=True) try: # A deletion the last session did not finish: its undo time is long over. finish_deleted(folder_paths.get_output_directory(), everything=True) From 091fb9f490b136a1a703bf7a153badb1bcfef938 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:25 +0000 Subject: [PATCH 33/46] Adding the workflow to an earlier video keeps its original time, and restores it from the backup --- freevideo_engine/comfy_metadata.py | 57 ++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/freevideo_engine/comfy_metadata.py b/freevideo_engine/comfy_metadata.py index 1a87aa21..00e052b5 100644 --- a/freevideo_engine/comfy_metadata.py +++ b/freevideo_engine/comfy_metadata.py @@ -67,7 +67,9 @@ def embed_comfy_metadata(path, *, prompt=None, workflow=None, backup=None): shutil.copy2(path, backup) if backup.stat().st_size != size: raise ValueError('The backup is incomplete') + original = path.stat() os.replace(temporary, path) + _restore_times(path, original) return True except Exception: logging.warning('FreeVideo could not store the workflow in %s; the video is unchanged.', path, exc_info=True) @@ -77,6 +79,15 @@ def embed_comfy_metadata(path, *, prompt=None, workflow=None, backup=None): return False +def _restore_times(path, original): + """The library orders videos and dates them by the file's modification time, + so a rewrite keeps the original's access and modification times.""" + try: + os.utime(path, ns=(original.st_atime_ns, original.st_mtime_ns)) + except OSError: + logging.warning('FreeVideo could not keep the original time of %s.', path, exc_info=True) + + def _media(path): """Each stream's codec and shape, with the count and a digest of its packet bytes.""" import av @@ -176,6 +187,42 @@ def backfill(output_directory, *, stop=None): return counts +TIMES_MARKER = '.workflow-times-restored.json' + + +def restore_backfilled_times(output_directory, *, stop=None): + """Videos that were rewritten before the original time was kept took the + time of the rewrite. Their backup is the original file (a hard link or a + copy that keeps the time), so its time goes back onto the video. Only a + video that is newer than its own backup and still carries a workflow is + touched. Runs once; returns the number restored, or None when it already ran.""" + root = Path(output_directory).resolve() / 'FreeVideo' + marker = root / TIMES_MARKER + if marker.is_file() or not root.is_dir(): + return None + from .monitoring import save + restored = 0 + complete = True + for backup in sorted(root.glob('*/*/' + BACKUP_NAME)): + if stop is not None and stop.is_set(): + return restored + video = backup.with_name('video.mp4') + try: + if backup.is_symlink() or video.is_symlink() or not backup.is_file() or not video.is_file(): + continue + before, now = backup.stat(), video.stat() + if now.st_mtime_ns <= before.st_mtime_ns or not has_workflow(video): + continue + os.utime(video, ns=(before.st_atime_ns, before.st_mtime_ns)) + restored += 1 + except Exception: + complete = False + logging.warning('FreeVideo could not restore the time of %s.', video, exc_info=True) + if complete: + save(marker, dict(schema=1, completed_at=time.time(), restored=restored)) + return restored + + DISMISSED = '.workflow-backfill-dismissed.json' _job = {'state': 'idle', 'result': None} _job_lock = threading.Lock() @@ -232,6 +279,16 @@ def run(output): with _job_lock: _job.update(state='done', result=result) + def restore_times(): + try: + count = restore_backfilled_times(folder_paths.get_output_directory()) + if count: + logging.info('FreeVideo restored the original time of %d earlier videos.', count) + except Exception: + logging.warning('FreeVideo could not restore the time of earlier videos.', exc_info=True) + + threading.Thread(target=restore_times, name='freevideo-workflow-times', daemon=True).start() + @server.routes.get('/freevideo/workflow-backfill') async def status(request): output = folder_paths.get_output_directory() From f046f70d18d0a22c657fb8597a6686bf17ee0a44 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:25 +0000 Subject: [PATCH 34/46] Waiting videos show the start of their prompt, and Move to front marks the moved video for a moment --- web/studio.css | 10 +++++++--- web/studio.js | 28 +++++++++++++++++++++++++--- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/web/studio.css b/web/studio.css index 5f5b5b1e..e0cb7e04 100644 --- a/web/studio.css +++ b/web/studio.css @@ -124,9 +124,13 @@ .fv-studio .fv-queue-actions{display:flex;align-items:center;justify-content:center;gap:4px;flex-wrap:wrap;margin-top:8px} .fv-studio .fv-queue-actions button,.fv-studio .fv-queue-details>summary{padding:4px 8px;min-height:28px;font-size:var(--fv-micro);color:var(--fv-muted)} .fv-studio .fv-queue-actions{position:relative}.fv-studio .fv-queue-details{position:static}.fv-studio .fv-queue-details>summary{cursor:pointer;list-style:none} -.fv-studio .fv-queue-list{left:0;right:auto;bottom:calc(100% + var(--fv-h-lg) + 24px);width:300px;max-height:260px;overflow:auto;scrollbar-width:thin} -.fv-studio .fv-queue-item{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:var(--fv-micro);padding:3px 0;white-space:nowrap} -.fv-studio .fv-queue-item span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis} +.fv-studio .fv-queue-list{left:0;right:auto;bottom:calc(100% + var(--fv-h-lg) + 24px);width:360px;max-height:260px;overflow:auto;scrollbar-width:thin} +.fv-studio .fv-queue-item{display:flex;align-items:center;justify-content:space-between;gap:8px;font-size:var(--fv-micro);padding:4px 6px;border-radius:var(--fv-r-sm);transition:background .3s ease;white-space:nowrap} +.fv-studio .fv-queue-text{flex:1;min-width:0;display:flex;flex-direction:column;gap:1px} +.fv-studio .fv-queue-text span{min-width:0;overflow:hidden;text-overflow:ellipsis} +.fv-studio .fv-queue-summary{color:var(--fv-ink)}.fv-studio .fv-queue-meta{color:var(--fv-muted)} +.fv-studio .fv-queue-item.fv-queue-moved{background:var(--fv-selected)} +.fv-studio .fv-queue-chip{flex:none;color:var(--fv-accent)} .fv-studio .fv-queue-item button{flex:none} .fv-studio .fv-status{min-height:22px;text-align:center;font-size:var(--fv-micro);color:var(--fv-muted);overflow-wrap:anywhere}.fv-studio .fv-status[data-error=true]{color:var(--fv-danger)} .fv-studio .fv-progress{width:100%;height:4px;accent-color:var(--fv-accent);border:0;margin:0 0 10px}.fv-studio .fv-budget,.fv-studio .fv-prewarm{text-align:center;font-size:var(--fv-micro);color:var(--fv-muted)} diff --git a/web/studio.js b/web/studio.js index 59b7ce66..04d15498 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1287,6 +1287,21 @@ export function openStudio(node, {extendFrom = null} = {}) { button.title = capturing ? t('Adding…', '正在添加…') : ''; } } + // The moved video keeps its place in view and is marked for a moment, so the list does not just look the same. + let movedId = null, movedTimer = 0; + function clearMoved() { + clearTimeout(movedTimer); movedId = null; + queueList.querySelectorAll('.fv-queue-moved').forEach(item => { item.classList.remove('fv-queue-moved'); item.querySelector('.fv-queue-chip')?.remove(); }); + } + function showMoved() { + const item = [...queueList.children].find(child => child.dataset.id === movedId); + if (!item || item.classList.contains('fv-queue-moved')) return; + item.classList.add('fv-queue-moved'); + const chip = el('span', t('Moved to front', '已排到最前'), 'fv-queue-chip'); + item.insertBefore(chip, item.querySelector('button')); + queueList.scrollTop = 0; + clearTimeout(movedTimer); movedTimer = setTimeout(clearMoved, 2800); + } function renderQueue(state) { if (disposed) return; queueState = state; @@ -1306,7 +1321,12 @@ export function openStudio(node, {extendFrom = null} = {}) { queueList.dataset.signature = signature; queueList.replaceChildren(); state.pending.forEach((row, index) => { const item = el('div', null, 'fv-queue-item'); - const description = el('span', `${index + 1} · ${row.inputs.width} × ${row.inputs.height} · ${row.inputs.seconds} s`); + item.dataset.id = row.id; + // Waiting videos often share every setting; the start of the prompt tells them apart. + const summary = String(row.inputs.text ?? '').replace(/\s+/g, ' ').trim(); + const description = el('div', null, 'fv-queue-text'); + if (summary) { const line = el('span', summary, 'fv-queue-summary'); line.title = summary; description.append(line); } + description.append(el('span', `${index + 1} · ${row.inputs.width} × ${row.inputs.height} · ${row.inputs.seconds} s`, 'fv-queue-meta')); const remove = button(t('Remove', '移除'), async () => { remove.disabled = true; try { await queueController.cancel(row.id); } @@ -1317,8 +1337,9 @@ export function openStudio(node, {extendFrom = null} = {}) { if (index > 0) { const first = button(t('Move to front', '排到最前'), async () => { first.disabled = remove.disabled = true; - try { await queueController.moveFirst(row.id); } - catch (error) { showQueueError(error); first.disabled = remove.disabled = false; } + movedId = row.id; + try { await queueController.moveFirst(row.id); showMoved(); } + catch (error) { movedId = null; showQueueError(error); first.disabled = remove.disabled = false; } }, 'fv-quiet'); first.setAttribute('aria-label', t(`Move queued video ${index + 1} to front`, `把排队的第 ${index + 1} 个排到最前`)); item.append(first); @@ -1326,6 +1347,7 @@ export function openStudio(node, {extendFrom = null} = {}) { item.append(remove); queueList.append(item); }); } + if (movedId && state.pending.some(row => row.id === movedId) && !queueList.querySelector('.fv-queue-moved')) showMoved(); if (!busy) cancelling = false; if (state.error) showQueueError(state.error); updateRunButton(); From f58ef2391c4f16652750782fd0d5dfcd6ae1cefc Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:25 +0000 Subject: [PATCH 35/46] Name the missing model downloads in one line and recheck them after a run The request-assets hint lists each automatic download by name and size (two-pass model, sampling data) instead of one total, and the check runs again when a generation or chain ends so a hint for files that are now present goes away. --- web/request_assets.js | 14 +++++++++++--- web/studio.js | 5 +++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/web/request_assets.js b/web/request_assets.js index 04eb6822..17689f5b 100644 --- a/web/request_assets.js +++ b/web/request_assets.js @@ -36,9 +36,17 @@ export function createRequestAssets({t, api, request, onError = () => {}}) { const size = bytes => bytes >= 2 ** 30 ? (bytes / 2 ** 30).toFixed(1) + ' GB' : Math.max(1, Math.round(bytes / 2 ** 20)) + ' MB'; function lines(items) { const rows = []; - // Tables and the upscaler are one download to the user. - const auto = items.filter(item => item.action === 'auto').reduce((sum, item) => sum + item.bytes, 0); - if (auto) rows.push(t(`Downloads ${size(auto)} before generating`, `生成前会先下载 ${size(auto)}`)); + // One line names what Generate will fetch first: the two-pass model, then the sampling data. + const names = {upscaler: t('two-pass model', '二次采样模型'), sampling: t('sampling data', '采样数据')}; + const bytes = {}; + for (const item of items) { + if (item.action !== 'auto') continue; + const kind = names[item.kind] ? item.kind : 'other'; + bytes[kind] = (bytes[kind] || 0) + item.bytes; + } + const parts = [['upscaler', names.upscaler], ['sampling', names.sampling], ['other', t('other model files', '其他模型文件')]] + .filter(([kind]) => bytes[kind]).map(([kind, name]) => `${name} ${size(bytes[kind])}`); + if (parts.length) rows.push(t(`Downloads before generating: ${parts.join(', ')}`, `生成前会先下载:${parts.join('、')}`)); for (const item of items) { if (item.kind === 'prompt-vlm') { rows.push(t(`First prompt rewrite downloads ${size(item.bytes)}`, `首次改写提示词会先下载 ${size(item.bytes)}`)); diff --git a/web/studio.js b/web/studio.js index 04d15498..8eed91f4 100644 --- a/web/studio.js +++ b/web/studio.js @@ -1137,6 +1137,8 @@ export function openStudio(node, {extendFrom = null} = {}) { failure.clear(); node.freevideoFailure = ''; node.freevideoFailureReport = null; lastQueueError = null; status.textContent = ''; status.dataset.error = 'false'; } + // Parts download what they need while they run; the line above Generate is read again when they stop. + if (chainWasActive && !active) requestAssets?.refresh(true); chainWasActive = active; partList.update(chain, active); busy = active || Boolean(queueState?.running || queueState?.pending.length); queued = chain?.state === 'queued'; @@ -1308,7 +1310,10 @@ export function openStudio(node, {extendFrom = null} = {}) { if (chainController?.active) { queueActions.hidden = false; updateRunButton(); return; } const active = state.running || state.pending[0]; activePrompt = active?.id || null; + const wasBusy = busy; busy = Boolean(active) || progressLive(); queued = !state.running && Boolean(state.pending.length); + // A finished, failed or cancelled video may have downloaded what the line above Generate still lists. + if (wasBusy && !busy) requestAssets?.refresh(true); const g = active?.inputs; // The right-hand frame belongs to the submitted video. Draft edits on // the left must never reshape a video currently being generated. From c3e3b8fe3255c6e43858ef136872e09070789226 Mon Sep 17 00:00:00 2001 From: Bowen Xue Date: Fri, 16 Oct 2026 04:52:25 +0000 Subject: [PATCH 36/46] Media pickers list only usable file types, and a mixed selection uploads the supported files --- scripts/test_freevideo_entry.mjs | 4 ++-- web/freevideo.js | 37 ++++++++++++++++++++++++-------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index c96118cc..0725a10d 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -390,7 +390,7 @@ test('main browser entry registers both views and preserves node hooks', async t assert.match(audioHelp.textContent, /