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/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/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/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_assets.py b/freevideo_engine/comfy_assets.py index 266d8770..19545ba3 100644 --- a/freevideo_engine/comfy_assets.py +++ b/freevideo_engine/comfy_assets.py @@ -48,8 +48,8 @@ def resolve_loras(serialized, names, resolve, previous=None): continue name, strength = row.get('name'), row.get('strength', 1.) if (isinstance(strength, bool) or not isinstance(strength, (int, float)) - or not math.isfinite(strength) or not -4 <= strength <= 4): - raise ValueError('LoRA strength must be a finite number between -4 and 4') + or not math.isfinite(strength) or not -100 <= strength <= 100): + raise ValueError('LoRA strength must be a finite number between -100 and 100') if not isinstance(name, str) or name not in available or not name.lower().endswith('.safetensors'): raise ValueError('Select an available safetensors LoRA from your ComfyUI model folders') if strength != 0: 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).""" 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) 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() diff --git a/freevideo_engine/comfy_nodes.py b/freevideo_engine/comfy_nodes.py index 8a5f8de6..725e62f4 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], @@ -404,7 +404,7 @@ def define_schema(cls): return io.Schema(node_id='FreeVideoLoRA', display_name='FreeVideo · LoRA', category='FreeVideo', description='FreeVideo needs no acceleration LoRA.', inputs=[io.Combo.Input('lora', options=[NO_LORA, *names]), - io.Float.Input('strength', default=1., min=-4., max=4., step=.05), + io.Float.Input('strength', default=1., min=-100., max=100., step=.05), LoRAs.Input('previous', optional=True)], outputs=[LoRAs.Output('loras')]) @classmethod diff --git a/freevideo_engine/comfy_upload.py b/freevideo_engine/comfy_upload.py index 36feabca..108cfa7e 100644 --- a/freevideo_engine/comfy_upload.py +++ b/freevideo_engine/comfy_upload.py @@ -1,6 +1,7 @@ """Stream browser media to ComfyUI input without buffering a clip in RAM.""" import asyncio from pathlib import Path, PureWindowsPath +import shutil import uuid from .comfy_assets import media_kind @@ -9,36 +10,50 @@ CHUNK_BYTES = 256 * 1024 +class UploadError(ValueError): + def __init__(self, message, code): + super().__init__(message) + self.code = code + + async def receive(reader, input_directory): part = await reader.next() if part is None or part.name != 'file' or not part.filename: - raise ValueError('Choose a media file') + raise UploadError('Choose a media file', 'unsupported') name = PureWindowsPath(part.filename).name if not name or name in ('.', '..') or any(c in name for c in ('/', '\\', ':', '\x00')): - raise ValueError('Invalid media filename') - media_kind(name) + raise UploadError('Invalid media filename', 'invalid') + try: + media_kind(name) + except ValueError as error: + raise UploadError(str(error), 'unsupported') from error base = Path(input_directory).resolve() folder = base / 'freevideo' / uuid.uuid4().hex folder.resolve().relative_to(base) folder.mkdir(parents=True, exist_ok=False) - temporary = folder / (name + '.partial') - destination = folder / name - size = 0 - with temporary.open('xb') as output: - while True: - chunk = await part.read_chunk(size=CHUNK_BYTES) - if not chunk: - break - size += len(chunk) - if size > MAX_BYTES: - raise ValueError('Media file exceeds 2 GiB; trim the clip before uploading') - output.write(chunk) - if size == 0: - raise ValueError('Media file is empty') - if await reader.next() is not None: - raise ValueError('Upload one media file at a time') - from .file_ops import publish - publish(temporary, destination) + # An upload that fails or is interrupted leaves nothing behind. + try: + temporary = folder / (name + '.partial') + destination = folder / name + size = 0 + with temporary.open('xb') as output: + while True: + chunk = await part.read_chunk(size=CHUNK_BYTES) + if not chunk: + break + size += len(chunk) + if size > MAX_BYTES: + raise UploadError('Media file exceeds 2 GiB; trim the clip before uploading', 'too_large') + output.write(chunk) + if size == 0: + raise UploadError('Media file is empty', 'empty') + if await reader.next() is not None: + raise UploadError('Upload one media file at a time', 'multiple') + from .file_ops import publish + publish(temporary, destination) + except BaseException: + shutil.rmtree(folder, ignore_errors=True) + raise return {'file': destination.relative_to(base).as_posix(), 'bytes': size} @@ -55,7 +70,7 @@ async def upload(request): try: result = await receive(await request.multipart(), folder_paths.get_input_directory()) except (OSError, ValueError) as error: - return web.json_response({'error': str(error)}, status=400) + return web.json_response({'error': str(error), 'code': getattr(error, 'code', 'failed')}, status=400) return web.json_response(result) from .lora_triggers import get_trigger, set_trigger 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 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/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/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/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/image_gen/service.py b/freevideo_engine/image_gen/service.py index f2f78e25..92ebc1b3 100644 --- a/freevideo_engine/image_gen/service.py +++ b/freevideo_engine/image_gen/service.py @@ -6,6 +6,7 @@ import math from pathlib import Path import queue +import re import secrets import subprocess import threading @@ -30,6 +31,17 @@ class WorkerIdleExit(RuntimeError): """The worker closed itself after its idle timeout before it answered anything.""" +FAILURE_KINDS = tuple((name, re.compile(pattern, re.I)) for name, pattern in ( + ('panic', r'\bpanick?ed\b'), ('CUDA error', r'\bcuda\b[^\n]{0,40}\berror\b|\bcublas\w*_status|\bcudnn\w*_status'), + ('out of memory', r'out of memory|\bOOM\b'), ('MemoryError', r'\bMemoryError\b'), + ('access violation', r'access violation|segmentation fault|\bSIGSEGV\b'), + ('DLL load failed', r'DLL load failed|cannot open shared object'), ('ImportError', r'\b(Import|ModuleNotFound)Error\b'))) + + +class WorkerEarlyExit(WorkerIdleExit): + """The worker process ended with an error before it answered anything: nothing ran.""" + + class Worker: """One reader owns each pipe; stderr is drained without retaining user text.""" def __init__(self, process): @@ -38,6 +50,7 @@ def __init__(self, process): self.writer_mutex = threading.Lock() self.reader = threading.Thread(target=self.read, daemon=True) self.error_reader = threading.Thread(target=self.read_errors, daemon=True) + self.tail = '' self.reader.start() self.error_reader.start() @@ -56,8 +69,9 @@ def read(self): self.rows.put(None) def read_errors(self): - for _ in iter(lambda: self.process.stderr.read(1024), ''): - pass + # Only the last words are kept, for the report of a worker that ended abnormally. + for chunk in iter(lambda: self.process.stderr.read(1024), ''): + self.tail = (self.tail + chunk)[-1500:] def send(self, **row): with self.writer_mutex: @@ -560,14 +574,20 @@ def respawn(): respawn() try: self.infer(session, value) - except WorkerIdleExit: + except WorkerIdleExit as error: # The request reached a worker that was already unloading after # its idle timeout and was never read. Nothing ran: send it to a # fresh worker once; a second exit is reported as a failure. if self.stop.is_set() or self.session_stop.is_set(): raise + earlier = str(error) respawn() - self.infer(session, value) + try: + self.infer(session, value) + except RuntimeError as second: + if earlier.startswith('worker_exit (') and str(second).startswith('worker_exit'): + raise RuntimeError('%s; first try: %s' % (second, earlier[len('worker_exit '):])) from None + raise finally: # Keep the parent's lease until the whole owned process tree exits. if session is not None: @@ -646,7 +666,17 @@ def worker_gone(session, heard): code = None if code == 0 and not heard: return WorkerIdleExit('worker_exit (exit code 0, before any answer)') - return RuntimeError('worker_exit' if code is None else 'worker_exit (exit code %d)' % code) + if code is None: + return RuntimeError('worker_exit') + session.error_reader.join(timeout=1) + # What the worker wrote can hold a prompt or a user's folder: only a fixed word for + # the kind of failure leaves this function, never any of the text. + kind = next((name for name, pattern in FAILURE_KINDS if pattern.search(session.tail)), '') + session.tail = '' + message = 'worker_exit (exit code %d%s)' % (code, ', ' + kind if kind else '') + # A worker that ended with an error before answering never ran the request; one + # fresh worker gets it, and a second exit is reported with its own last words. + return (WorkerEarlyExit if not heard and code > 0 else RuntimeError)(message) def infer(self, session, value): context = self.context or Context('generate', value) diff --git a/freevideo_engine/launcher_session.py b/freevideo_engine/launcher_session.py index b9be1a8d..a33471f6 100644 --- a/freevideo_engine/launcher_session.py +++ b/freevideo_engine/launcher_session.py @@ -698,12 +698,26 @@ def generating(self): return queue_busy(url) def image_disk_fits(self, total): - """Whether the chosen installation location has room for the image model.""" + """Whether the disk the image model downloads to has room for it, by the margin that + download itself keeps and after the video models still waiting on the same disk.""" from .image_gen import image_pack - folder = self.form['destination'] if self.form['new_comfy'] else self.form['comfy'] + folder = Path(self.form['destination'] if self.form['new_comfy'] else self.form['comfy']) + if self.installation_engine(): + # An existing installation downloads to its own model folder, which may be on another disk. + try: + folder = self._image_location()[0] + except (OSError, ValueError): + pass try: - return disk_space.free_bytes(Path(folder).expanduser()) >= total + image_pack.RESERVE - except (OSError, ValueError): + folder = folder.expanduser() + free = disk_space.free_bytes(folder) + row = getattr(self.controller, 'state', None) or {} + if row.get('status') == 'review': + key = disk_space.disk_key(folder) + free -= sum(d.get('needed_bytes', 0) for d in row.get('disks', []) + if d.get('path') and disk_space.disk_key(d['path']) == key) + return free >= total + image_pack.BACKGROUND_RESERVE + except Exception: # A page reading the disk must never fail to draw. return True def image_pack_view(self): @@ -1127,6 +1141,12 @@ def update(self, token='', source='launcher'): return if self._open_release_page(): return + if (self.image_installer and self.image_installer.busy and not self.controller.busy + and not self.importer.busy and not self.cleaner.busy and not self.archives_busy + and not self.closing and not self.upgrade.active and not self.model_switch): + # An explicit update goes first. The image model download is the long, quiet kind and + # keeps what it has; it carries on from there when the user continues it. + self.image_installer.cancel() if self.task_busy() or self.updater and self.updater.busy: self.pending_update = (token, source) self.action_notice = 'update_queued' diff --git a/freevideo_engine/lora_triggers.py b/freevideo_engine/lora_triggers.py index c29e4e44..dca1aa54 100644 --- a/freevideo_engine/lora_triggers.py +++ b/freevideo_engine/lora_triggers.py @@ -161,8 +161,18 @@ def get_trigger_words(name, *, folders=None): return get_trigger(name, folders=folders)['words'] +def _written(prompt, words): + """Whether the phrase stands on its own in the prompt: sks is not found in asks. + + Only letters and digits at the phrase's own edges are checked, so a phrase in Chinese still matches in running text.""" + ends = ''.join(('(?= 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/release_notes.json b/freevideo_engine/release_notes.json index 362406ea..78f7589d 100644 --- a/freevideo_engine/release_notes.json +++ b/freevideo_engine/release_notes.json @@ -1,35 +1,72 @@ { "schema": 1, - "product_version": "0.4.7", + "product_version": "0.4.8", "en": { - "summary": "Your creations can now be selected and deleted several at a time with a short undo window, every view gets a Feedback button, and the launcher shows a QR code for opening FreeVideo from a phone. The description box in the editing view grows with its text, wording across the app is shorter and plainer, and long videos keep a steadier loudness from part to part.", + "summary": "Long videos keep a steadier voice from part to part, and continuing a video keeps your reference pictures and characters. LoRA strength can be typed up to 100, large downloads move to a faster source on their own, and the second-pass button uses the earlier wording again, with smaller fixes to uploads, your creations, the waiting queue, diagnostic reports and the interface.", "changes": [ - "Your creations: select several creations (click, Shift-click, a whole day, or all) and delete them in one step, with the size shown before you confirm and ten seconds to undo. Creations that are still in use are kept.", - "Feedback: every view has a Feedback button beside Settings. Write one sentence, optionally attach a screenshot of the page and a diagnostic summary (no prompts or images), and the GitHub suggestion page opens with the text filled in.", - "Launcher: local network access shows a QR code to open FreeVideo from a phone and offers an optional password for other devices; freevideo status prints the same QR code on Linux.", - "Your own ComfyUI: the FreeVideo toolbar appears only on FreeVideo workflows, no longer opens over your other workflows or adds nodes to them, and FreeVideo can be opened from the Extensions menu. Opening ComfyUI from the launcher keeps your other plugins; if a plugin stops FreeVideo from loading, the launcher starts again with FreeVideo only and says so.", - "Long videos: steadier loudness from part to part.", - "Editing view: the description box grows with its text and can be expanded into a larger window; errors and shortcut labels use plainer wording.", - "Studio: shorter hints. Generate no longer fails with \"missing node\" when the creation node was muted or bypassed in the workflow; the node is turned back on and a line says so. Two-pass sampling runs only with 8 first-pass steps; other step counts use a single pass, and the progress line, the ComfyUI node and Agent results say so.", - "Installation and launcher: install progress is steadier, with the total following the GPU acceleration build and the final start-up; sublines no longer show file names, and the terminal stays closed unless something goes wrong. Installation and update wording is plainer.", - "Progress and error messages: shorter, plainer wording." + "Second pass: the result button, preview badge, library and delete messages use the 0.4.6 wording again (Run second pass, First-pass preview).", + "Long videos: the voice keeps the same loudness and fullness from part to part, and with dialogue a continued part no longer opens with a second or two of unclear speech. Continuing a video from the creative panel keeps your reference pictures and characters, so the person stays the same; up to 3 pictures are used.", + "Characters: two @names written back to back are both recognised, the list no longer reopens after a finished name, and Save in the character library says what is still missing. An @character written in the editing view's generation bar now carries that character's description and pictures, as it does in the creation panel.", + "LoRA: you can type a strength beyond -4 to 4, up to 100, and a note marks values outside the usual range; the slider and default strength are unchanged. A trigger word that only appears inside a longer word (sks in asks) is now added.", + "Image generation: an out-of-memory failure says where memory ran out and at which size, and the progress line shows the smaller size while retrying; if the background process exits right after starting, it is retried once. Clicking Update while the image model is downloading pauses the download (what is already downloaded is kept and can be resumed) and starts the update at once. The option to install the image model as well checks free space on the drive that holds the models and is not offered when there is not enough room.", + "Downloads: large model downloads check the other sources while a transfer is slow and move to a clearly faster one, resuming the same file; a transfer that stops moving for 30 seconds changes source as well. The missing-model hint names each download and its size, and is checked again after a generation ends.", + "Media uploads: the upload buttons list only the file types FreeVideo can use, and unsupported files in a multiple selection are skipped. Files over 2 GiB are turned away in the page, a failed or interrupted upload no longer leaves a partial file, and upload errors appear in your language.", + "Your creations: an interrupted share or export no longer blocks deleting the creation, and its leftover working copy is removed at the next start. Adding the workflow to earlier videos no longer moves them to the top or changes their date.", + "Waiting queue: each task shows the start of its prompt, and Move to front highlights the moved task and confirms it.", + "Diagnostic report: the prompt-rewrite report no longer replaces the video's, the same file is saved and sent, and a large report is saved as a zip.", + "Wording: Agent chat, Settings, image generation and editor messages are shorter and say what to do next; the character library uses \"character\" throughout and its view labels are clearer.", + "Interface details: advanced node settings show Chinese names in the Chinese interface; generation cards in chat show a single progress figure; the Downloads card in Settings is tidier; Export in the editor keeps the primary colour and the Text and Subtitles tools use line icons; narrow windows lay out the preview, generate buttons, library title and notices better and keep Feedback in the header; long prompts no longer run under the @ button; library times follow the interface language; a result video that fails to load no longer offers actions that cannot work." ] }, "zh": { - "summary": "作品库可以多选、一次删除,并在短时间内撤销;每个视图新增“反馈”按钮;启动器显示二维码,手机扫码即可打开。剪辑视图的描述框随内容长高,各处说法更短、更直白,长视频各段的声音响度也更稳定。", + "summary": "长视频各段人声更一致,接着生成时保留参考图和角色。LoRA 强度可以手动输入到 100,大文件下载会自动换到更快的下载源,二次采样按钮恢复此前的说法;上传、作品库、等待队列、诊断报告和界面细节也有多处改进。", "changes": [ - "作品库:可多选作品(点选、Shift 连选、选择某一天或全选),确认前显示大小,一次删除,10 秒内可撤销;仍在使用的作品会保留。", - "反馈:每个视图的“设置”左边新增“反馈”按钮。写一句话,可选附上这一页的截图和诊断信息(不含提示词和画面),点一下即打开已填好文字的 GitHub 反馈页。", - "启动器:局域网访问显示二维码,手机扫码即可打开 FreeVideo,并可为其他设备设置密码;Linux 的 freevideo status 也会显示二维码。", - "自己的 ComfyUI:工具条只在 FreeVideo 工作流上出现,不再盖住其他工作流,也不往里面加节点;Extensions 菜单里可以打开 FreeVideo。从启动器打开时保留已装的其他插件;如有插件让 FreeVideo 载入失败,会自动只载入 FreeVideo 再启动一次,并提示一句。", - "长视频:各段声音响度更稳定。", - "剪辑视图:描述框随内容长高,可放大成大窗口编辑;出错和快捷键的说法更直白。", - "创作面板:提示更简短。创作节点在工作流里被静音或绕过时,点生成不再报“节点缺失”,会自动重新打开并提示一句。只有首轮为 8 步时才会使用二次采样,其他步数只生成一轮,进度、ComfyUI 节点和 Agent 接口的结果会说明。", - "安装和启动器:安装进度更平稳,总进度会跟随显卡加速编译和最后的启动步骤,副行不再显示文件名,终端只在出错时需要打开;安装和更新的说法更直白。", - "进度与出错提示:说法更短、更直白。" + "二次采样:结果卡按钮、预览角标、作品库和删除提示恢复 0.4.6 的说法(继续二采、一采预览)。", + "长视频:人声的音量和厚度在各段之间保持一致;有对话时,接续的段开头不再先冒出一两秒听不清的话。在创作面板接着生成视频时,会保留参考图和角色,人物前后保持一致,最多使用 3 张图片。", + "角色:紧挨着写的两个 @名字都能识别,写完名字后列表不再重新弹出,角色库保存按钮灰着时会说明还缺什么;剪辑视图的生成条里写 @角色,也会和创作面板一样带上该角色的描述和图片。", + "LoRA:强度可以手动输入超出 -4 到 4 的数,最大到 100,超出常用范围时有提示;滑块和默认强度不变。触发词只出现在更长的单词里(如 asks 里的 sks)时,现在也会自动加上。", + "图像生成:显存不够时写明在哪一步、哪个尺寸,自动缩小重试时显示新尺寸;后台进程刚启动就退出时自动重试一次。图像模型在后台下载时点“更新”,下载先停下(已下的部分保留,之后可以继续),更新马上开始;“同时安装图像模型”按模型所在的盘检查空间,空间不够时不再显示为可选。", + "下载:大文件下载变慢时会测试其他下载源,明显更快时自动换源并接着下载;停滞 30 秒也会换源。缺模型提示写明是哪几项、各多大,生成结束后重新检查。", + "素材上传:上传按钮只列出能用的文件格式,一次选多个时跳过不支持的;超过 2 GiB 的文件在页面上直接拦下;上传失败或中断不再留下残留文件,出错提示用当前界面语言。", + "作品库:分享或导出中断后留下的临时文件不再挡住删除,下次启动时清理;给早期视频添加工作流后,它们不再跳到最前,日期保持原样。", + "等待队列:每条任务显示提示词开头;“排到最前”后被移动的任务会高亮并提示。", + "诊断报告:提示词增强不再顶替视频的报告,保存和提交是同一份,较大的报告存成 zip。", + "文字:Agent 对话、设置、图像生成和编辑器里的提示更简短,并写明下一步该点哪个按钮;角色库统一称“角色”,视角标签更清楚。", + "界面细节:中文界面的生成节点高级参数显示中文名;对话里的生成卡只显示一个进度;设置“下载”卡更整齐;剪辑视图导出按钮保持主色,文字、字幕工具改用线性图标;窄窗口下预览、生成按钮、作品库标题和提示的排布更合理,顶栏仍保留“反馈”入口;长提示词不再被“@”按钮压住;作品卡片时间跟随界面语言;结果视频加载失败时不再显示无法使用的操作。" ] }, "history": [ + { + "product_version": "0.4.7", + "en": { + "summary": "Your creations can now be selected and deleted several at a time with a short undo window, every view gets a Feedback button, and the launcher shows a QR code for opening FreeVideo from a phone. The description box in the editing view grows with its text, wording across the app is shorter and plainer, and long videos keep a steadier loudness from part to part.", + "changes": [ + "Your creations: select several creations (click, Shift-click, a whole day, or all) and delete them in one step, with the size shown before you confirm and ten seconds to undo. Creations that are still in use are kept.", + "Feedback: every view has a Feedback button beside Settings. Write one sentence, optionally attach a screenshot of the page and a diagnostic summary (no prompts or images), and the GitHub suggestion page opens with the text filled in.", + "Launcher: local network access shows a QR code to open FreeVideo from a phone and offers an optional password for other devices; freevideo status prints the same QR code on Linux.", + "Your own ComfyUI: the FreeVideo toolbar appears only on FreeVideo workflows, no longer opens over your other workflows or adds nodes to them, and FreeVideo can be opened from the Extensions menu. Opening ComfyUI from the launcher keeps your other plugins; if a plugin stops FreeVideo from loading, the launcher starts again with FreeVideo only and says so.", + "Long videos: steadier loudness from part to part.", + "Editing view: the description box grows with its text and can be expanded into a larger window; errors and shortcut labels use plainer wording.", + "Studio: shorter hints. Generate no longer fails with \"missing node\" when the creation node was muted or bypassed in the workflow; the node is turned back on and a line says so. Two-pass sampling runs only with 8 first-pass steps; other step counts use a single pass, and the progress line, the ComfyUI node and Agent results say so.", + "Installation and launcher: install progress is steadier, with the total following the GPU acceleration build and the final start-up; sublines no longer show file names, and the terminal stays closed unless something goes wrong. Installation and update wording is plainer.", + "Progress and error messages: shorter, plainer wording." + ] + }, + "zh": { + "summary": "作品库可以多选、一次删除,并在短时间内撤销;每个视图新增“反馈”按钮;启动器显示二维码,手机扫码即可打开。剪辑视图的描述框随内容长高,各处说法更短、更直白,长视频各段的声音响度也更稳定。", + "changes": [ + "作品库:可多选作品(点选、Shift 连选、选择某一天或全选),确认前显示大小,一次删除,10 秒内可撤销;仍在使用的作品会保留。", + "反馈:每个视图的“设置”左边新增“反馈”按钮。写一句话,可选附上这一页的截图和诊断信息(不含提示词和画面),点一下即打开已填好文字的 GitHub 反馈页。", + "启动器:局域网访问显示二维码,手机扫码即可打开 FreeVideo,并可为其他设备设置密码;Linux 的 freevideo status 也会显示二维码。", + "自己的 ComfyUI:工具条只在 FreeVideo 工作流上出现,不再盖住其他工作流,也不往里面加节点;Extensions 菜单里可以打开 FreeVideo。从启动器打开时保留已装的其他插件;如有插件让 FreeVideo 载入失败,会自动只载入 FreeVideo 再启动一次,并提示一句。", + "长视频:各段声音响度更稳定。", + "剪辑视图:描述框随内容长高,可放大成大窗口编辑;出错和快捷键的说法更直白。", + "创作面板:提示更简短。创作节点在工作流里被静音或绕过时,点生成不再报“节点缺失”,会自动重新打开并提示一句。只有首轮为 8 步时才会使用二次采样,其他步数只生成一轮,进度、ComfyUI 节点和 Agent 接口的结果会说明。", + "安装和启动器:安装进度更平稳,总进度会跟随显卡加速编译和最后的启动步骤,副行不再显示文件名,终端只在出错时需要打开;安装和更新的说法更直白。", + "进度与出错提示:说法更短、更直白。" + ] + } + }, { "product_version": "0.4.6", "en": { @@ -190,43 +227,6 @@ "“AI 全自动”里任务开始或停止后,对话列表立刻更新;快速切换对话时停在所选的那一段。" ] } - }, - { - "product_version": "0.4.1", - "en": { - "summary": "Fixes Extend on videos made earlier, and improves progress display and several agent details.", - "changes": [ - "Extend works again on videos made earlier, including ones whose size was changed, and the new part now uses the previous video's picture size automatically. Clicking Extend on a result or in Creations now opens Edit with the video on the timeline and a new part already placed after it, ready to generate.", - "Autopilot can start a new conversation, and earlier ones stay in a list to return to; the agent also keeps listening after it answers, so it still responds when the conversation resumes later.", - "A percentage and the time left appear as soon as a video starts; progress keeps moving while the model loads and runs smoothly to the end, on Autopilot job cards as well.", - "In Edit, an agent can start a new timeline from just a name, and writes a short note on each clip so it is clear at a glance what the clip is.", - "A video added to the timeline from Creations now brings the description it was generated from, so it can be regenerated right away.", - "A video of 16 to 29 seconds requested through an agent is now made in one generation when the computer allows it, the same as in Create.", - "When a specific camera path, placement or order of actions keeps coming out wrong, the agent can offer to make a rough camera draft first and generate from it.", - "The question about freeing the disk space used by old model files now appears after updating.", - "At smaller sizes, more of the model stays in GPU memory, so computers with less memory read less from the drive while generating.", - "When the network is slow or a step takes a while, the install page now shows what it is doing.", - "Third-party download during setup now lists only the files this computer needs, and the list can be exported; after downloading, choose the folder and the files that pass the check are used directly.", - "When other programs use a large part of the GPU during a generation, or the GPU overheats, a short note appears under the result." - ] - }, - "zh": { - "summary": "修复了之前生成的视频不能“接着生成”的问题,并改进了进度显示和 Agent 的几处体验。", - "changes": [ - "之前生成的视频(包括换过尺寸的)都可以“接着生成”,不再提示不能接续;新的一段会自动用上一段的画面大小。在结果或作品上点“接着生成”,会打开剪辑视图:原视频已在时间线上,后面接好了新的一段,点“生成”继续。", - "“AI 全自动”里可以开新对话,旧对话留在列表里,可以回去接着做;Agent 答完以后会一直等下一句,隔一会儿再说也有回应。", - "点“生成”后马上显示百分比和预计剩余时间;加载模型时进度照常前进,平稳走到结束,“AI 全自动”的任务卡也一样。", - "剪辑视图里,Agent 只凭一个名字就能新建时间线,并会在每一段上写一句说明,一眼就能看出这一段是什么。", - "从“作品”放上时间线的视频会带上当时的生成描述,可以直接重新生成。", - "通过 Agent 生成 16–29 秒的视频时,电脑条件允许就一次生成,和创作面板一致。", - "指定的镜头路线、位置或动作顺序总是生成得不对时,Agent 会提议先做一个粗略的镜头草稿,再照草稿生成。", - "更新后询问是否释放旧模型文件所占硬盘空间的提示,现在会正常出现。", - "画面较小时,更多模型留在显存里,内存较小的电脑生成时读取硬盘更少。", - "安装时网络较慢或某一步较久时,安装页会显示当前正在做什么。", - "安装时选第三方下载,现在只列出这台电脑需要的文件,并可以导出下载清单;下载完成后选中那个文件夹,校验通过的文件直接使用。", - "生成期间其他程序占用了较多显卡,或显卡过热时,结果下方会出现一句简短的提示。" - ] - } } ] } 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'] diff --git a/scripts/test_freevideo_entry.mjs b/scripts/test_freevideo_entry.mjs index 5ef12828..6781a491 100644 --- a/scripts/test_freevideo_entry.mjs +++ b/scripts/test_freevideo_entry.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict'; import {readFile} from 'node:fs/promises'; import test from 'node:test'; +import {uploadMedia} from '../web/media_upload.js'; import {outputDownloadURL} from '../web/output_download.js'; import {regenerateResult, upscaleResult, createSecondPassQueue, enableNode} from '../web/studio_queue.js'; import {referenceInputs, mediaRows} from '../web/prompt_references.js'; @@ -38,7 +39,7 @@ test('main browser entry registers both views and preserves node hooks', async t const document = {head: new Element('head'), createElement: tag => new Element(tag), createRange: () => ({createContextualFragment: () => new Element('span')})}; const dependencies = { - app, api, outputDownloadURL, regenerateResult, upscaleResult, createSecondPassQueue, enableNode, referenceInputs, mediaRows, expandCast, cleanOptionalPrompt, cleanOptionalWidgets, optionalInputValue, + app, api, uploadMedia, outputDownloadURL, regenerateResult, upscaleResult, createSecondPassQueue, enableNode, referenceInputs, mediaRows, expandCast, cleanOptionalPrompt, cleanOptionalWidgets, optionalInputValue, createCastLibrary: options => { const cast = {element: new Element('cast'), options, destroyed: false, destroy() { this.destroyed = true; }}; casts.push(cast); return cast; @@ -113,6 +114,7 @@ test('main browser entry registers both views and preserves node hooks', async t .replace("import { isLaunchVisit } from './launch_visit.js';", 'const isLaunchVisit = () => false;') .replace("import { openFreeVideoWorkflow } from './launcher.js';", 'const openFreeVideoWorkflow = (...args) => globalThis.__freevideoEntryTest.openFreeVideoWorkflow?.(...args);') .replace("import { imageGenerationSupported } from './image_generation.js';", 'const imageGenerationSupported = () => true;') + .replace("import { uploadMedia } from './media_upload.js';", 'const {uploadMedia} = globalThis.__freevideoEntryTest;') .replace("import { createCastLibrary } from './cast_library.js';", 'const {createCastLibrary} = globalThis.__freevideoEntryTest;') .replace("import { agentInterfaceEnabled } from './agent_connect.js';", 'const agentInterfaceEnabled = async () => false;'); await import('data:text/javascript;base64,' + Buffer.from(source).toString('base64')); @@ -164,6 +166,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]; @@ -358,7 +392,7 @@ test('main browser entry registers both views and preserves node hooks', async t assert.match(audioHelp.textContent, /