Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5c77402
Clarify refinement step limits and unavailable time estimates
KBRASK Oct 15, 2026
7163928
Clarify image model damage messages and test bilingual failure copy
KBRASK Oct 15, 2026
cf7ac23
The starter prompt's shot times read as seconds in the box
KBRASK Oct 15, 2026
6175754
Progress words follow the generation steps the page shows; legacy mes…
KBRASK Oct 15, 2026
ebba760
Keep package names off the install card, keep the working card for im…
KBRASK Oct 15, 2026
d4b1362
Launcher copy: simplify installation and update wording
KBRASK Oct 15, 2026
9c4c3ef
Editor prompt expand test: caret after a middle insert and a replaced…
KBRASK Oct 15, 2026
ad3a5e6
Creation panel and editor: acceptance is recorded where the server ta…
KBRASK Oct 15, 2026
dbf8489
Simplify editor stage names, next steps, and shortcut labels
KBRASK Oct 15, 2026
90e60ef
Library batch delete: a failed undo keeps the folder for a later try,…
KBRASK Oct 15, 2026
8a204a0
Feedback: header screenshots at in-between widths in the browser test
KBRASK Oct 15, 2026
f9063d7
Long-video split rules: quiet parts stay quiet, plain continuation se…
KBRASK Oct 15, 2026
07a85be
Saved AdaLN tables are checked for NaN and infinity with an integer m…
KBRASK Oct 15, 2026
e8f6afe
Two-pass generation runs only with 8 first-pass steps
KBRASK Oct 15, 2026
d28f02a
The launcher's phone access shows a QR code and an optional password,…
KBRASK Oct 15, 2026
a394f15
Release 0.4.7
KBRASK Oct 15, 2026
48b0f29
Restore the starter prompt shown as the original integrated_multimoda…
KBRASK Oct 15, 2026
1c31819
Names go back to the earlier wording: two-pass acceleration, first-pa…
KBRASK Oct 15, 2026
8975071
The launcher keeps the user's own ComfyUI plugins
KBRASK Oct 15, 2026
cada503
Keep FreeVideo out of ComfyUI workflows without FreeVideo nodes
KBRASK Oct 15, 2026
d40e4ef
Release 0.4.7
KBRASK Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ https://github.com/user-attachments/assets/ecda7d0d-7fbe-4e0c-8c29-8f3315bafc15
## 更新动态

- **2026-10-13** · **[v0.4.0](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.4.0):Agent 全自动生成、分钟级长视频和剪辑视图。** 连接 Claude Code、Codex 等 AI Agent,让它全自动生成视频;一条视频可以生成到一分钟以上;在时间线上剪辑。
- **2026-10-07** · **[v0.3.2](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.3.2):先出预览。** 先看半分辨率预览,满意的再继续二采。
- **2026-10-07** · **[v0.3.2](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.3.2):先出预览。** 先看半分辨率预览,满意的再继续生成。
- **2026-10-07** · **[v0.3.0](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.3.0):更快的 int8 模型。** GeForce 和 RTX 30 系显卡改用 int8,RTX 3090 生成 5 秒视频快约 2.3 倍。
- **2026-10-06** · **[作品展示](https://freevideo-community.pages.dev/#gallery)上线。** 观看 FreeVideo 生成的 20 秒视频,以及四档质量的并排对比。
- **2026-10-06** · **[v0.2.3](https://github.com/FlashML-org/FreeVideo/releases/tag/v0.2.3):视频自带工作流。** 把 FreeVideo 生成的视频拖到 ComfyUI 画布上,即可还原提示词、种子和全部参数,之前的视频也能补上。
Expand Down
2 changes: 1 addition & 1 deletion docs/Linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ To connect from computers on your local network without SSH:
freevideo server --listen 0.0.0.0
```

This prints an access link with a token. Anyone with the link can use FreeVideo on the server; `freevideo server --listen 0.0.0.0 --new-token` replaces it, and `freevideo status` shows it again. ComfyUI itself still listens only on the server; a small proxy in front of it turns away every request without the token. The connection is not encrypted, so use it on a network you trust, or add `--tls-cert cert.pem --tls-key key.pem` for HTTPS.
This prints an access link with a token, and a QR code of it to scan with a phone camera. Anyone with the link can use FreeVideo on the server; `freevideo server --listen 0.0.0.0 --new-token` replaces it, and `freevideo status` shows it again. ComfyUI itself still listens only on the server; a small proxy in front of it turns away every request without the token. The connection is not encrypted, so use it on a network you trust, or add `--tls-cert cert.pem --tls-key key.pem` for HTTPS.

To start FreeVideo with the server, add `--enable`; it becomes a systemd user service. To keep it running after you log out and start it at boot, also run `sudo loginctl enable-linger $USER` once. `freevideo server --disable` removes the service.

Expand Down
2 changes: 1 addition & 1 deletion docs/Linux.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ ssh -N -L 8188:127.0.0.1:8188 用户名@服务器
freevideo server --listen 0.0.0.0
```

这会打印一个带访问令牌的链接,持有链接的人都可以使用这台服务器上的 FreeVideo;用 `freevideo server --listen 0.0.0.0 --new-token` 可以换一个新链接,`freevideo status` 可以再次查看。ComfyUI 本身仍然只监听服务器本机,前面的访问代理会拒绝所有不带令牌的请求。连接没有加密,请只在可信的网络中使用,或加上 `--tls-cert cert.pem --tls-key key.pem` 使用 HTTPS。
这会打印一个带访问令牌的链接和对应的二维码(可用手机相机扫描),持有链接的人都可以使用这台服务器上的 FreeVideo;用 `freevideo server --listen 0.0.0.0 --new-token` 可以换一个新链接,`freevideo status` 可以再次查看。ComfyUI 本身仍然只监听服务器本机,前面的访问代理会拒绝所有不带令牌的请求。连接没有加密,请只在可信的网络中使用,或加上 `--tls-cert cert.pem --tls-key key.pem` 使用 HTTPS。

如需随服务器启动,加上 `--enable`,FreeVideo 会注册为 systemd 用户服务;若要在注销后保持运行、开机自动启动,还需运行一次 `sudo loginctl enable-linger $USER`。`freevideo server --disable` 会移除这个服务。

Expand Down
18 changes: 16 additions & 2 deletions freevideo_engine/adaln.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@
import torch


def all_finite(tensor):
"""Whether every element is finite; reads half-precision bits instead of calling isfinite.

A bfloat16 or float16 table is finite exactly when its exponent field is not
all ones, so one integer mask answers the question about ten times faster on
the CPU than isfinite() on those dtypes.
"""
mask = {torch.bfloat16: 0x7F80, torch.float16: 0x7C00}.get(tensor.dtype)
if mask is None:
return bool(torch.isfinite(tensor).all())
bits = tensor.reshape(-1).view(torch.int16)
return not bool((bits & mask).eq(mask).any())


class ScheduleCursor:
def __init__(self, timesteps):
self.timesteps = timesteps
Expand Down Expand Up @@ -174,7 +188,7 @@ def load(self, index, steps):
# These are small immutable constants; their producer's GPU/runtime is
# deliberately irrelevant. Validate before transferring to this device.
state = load_file(path, device=self.device)
if any(not bool(torch.isfinite(tensor).all()) for tensor in state.values()):
if any(not all_finite(tensor) for tensor in state.values()):
raise ValueError('Nonfinite AdaLN model asset')
return [state[f'step_{i}'].chunk(6, dim=-1) for i in range(steps)]

Expand All @@ -199,7 +213,7 @@ def save(self, index, module, *, producer_device=None):
path = self.root / f'{index:02d}.safetensors'
temporary = path.with_suffix('.partial')
state = {name: tensor.cpu() for name, tensor in module.state_dict().items()}
if any(not bool(torch.isfinite(tensor).all()) for tensor in state.values()):
if any(not all_finite(tensor) for tensor in state.values()):
raise ValueError('Cannot save nonfinite AdaLN constants')
save_file(state, temporary)
row = {'bytes': temporary.stat().st_size, 'sha256': assets.file_hash(temporary)}
Expand Down
7 changes: 7 additions & 0 deletions freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@
'lighting and surroundings as described above; do not copy its simplified, flat look." The result follows '
'the draft and looks plainer than a direct generation, so keep the direct one when the user prefers its look.',
],
'quality': [
'Steps per quality: "light" = 8 first-pass steps + 3 refine steps, two-pass (default); "medium" = 12 steps, '
'"high" = 16 steps, "max" = 20 steps, each in one pass.',
'Two-pass generation runs only with 8 first-pass steps. Any other base_steps (for example 20 with '
'two_pass=true) runs in one pass of that many steps; the job notice says so. A preview is always two-pass '
'with 8 steps.',
],
'timing': [
'Single-block frames are 24 fps on a 17n+5 grid (6 s -> 6.583 s, 10 s -> 10.125 s). '
'Under 30 s defaults to one generation when the device supports this size and quality; unknown limits use 15 s. '
Expand Down
44 changes: 42 additions & 2 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,8 +131,14 @@ def continuation_problems(spec, prefix='', *, editor=None):
problems = []
if not spec['two_pass']:
# The quality name is a contract enum, never caller text.
problem = schema.diagnostic(prefix + '/quality', '"light" (two-pass), or sampling.two_pass=true,' + why, None)
got = '"%s" (one pass)' % spec['quality']
if spec.get('sampling_note'):
# Two-pass was asked for with a first-pass count other than 8: it runs one pass.
problem = schema.diagnostic(prefix + '/quality',
'"light" (two-pass), or sampling.two_pass=true with base_steps=8,' + why, None)
got = '%d first-pass steps (one pass)' % spec['base_steps']
else:
problem = schema.diagnostic(prefix + '/quality', '"light" (two-pass), or sampling.two_pass=true,' + why, None)
got = '"%s" (one pass)' % spec['quality']
problems.append(dict(problem, got=got, message=problem['message'].replace('received None', 'received ' + got)))
if spec['preview']:
problems.append(schema.diagnostic(prefix + '/preview', 'false' + why, True))
Expand Down Expand Up @@ -546,6 +552,12 @@ def load(self):
self.finish(row, 'failed', error=dict(code='interrupted',
message='ComfyUI restarted while this job ran; submit it again'), save=False)
row['_restored'] = True
if row['kind'] == 'generate' and row['status'] in ACTIVE:
# Waiting, queued or running when the process restarted: this process runs it
# under the one-pass rule, however it is started again. A finished job is left.
note = self.legacy_one_pass_note(row['spec'])
if note:
row['spec']['sampling_note'] = note
if row['kind'] == 'generate' and not row['spec'].get('refine_from'):
# Before device-aware Agent planning, every accepted job used the unknown limit.
row['spec'].setdefault('single_max_frames', None)
Expand Down Expand Up @@ -1079,6 +1091,15 @@ def view(self, row, *, detail=True):
result['chain_id'] = row['chain_id']
if row['kind'] == 'generate':
result.update(cancel_requested=row.get('cancel_requested', False), attempt=row.get('attempt', 1))
note = spec.get('sampling_note')
if (note is None and row['status'] not in DONE and spec.get('two_pass') and not spec.get('preview')
and not spec.get('refine_from')):
# A job stored before the one-pass rule and not yet run: the engine applies it when it runs.
# A finished one keeps what it did, which a stored request cannot tell.
from ..two_pass import one_pass_note
note = one_pass_note(spec.get('base_steps', 8))
if note:
result['notice'] = note
if row['status'] == 'waiting':
pending = [d for d in row.get('depends_on', ()) if self.jobs.get(d, {}).get('status') not in DONE]
result['waiting_for'] = pending or row.get('waiting_for') or (['gpu'] if row['kind'] in GPU_KINDS + ('generate',) else [])
Expand Down Expand Up @@ -1449,9 +1470,28 @@ def media_file(self, row, item, name, *, target=None):
relative = edited
return relative

@staticmethod
def legacy_one_pass_note(spec):
"""The note for an unfinished two-pass job stored before the one-pass rule, else None."""
base = spec.get('base_steps', 8)
if (spec.get('sampling_note') is None and spec.get('two_pass') is True and not spec.get('preview')
and not spec.get('refine_from') and type(base) is int and 1 <= base <= 32 and base != 8):
from ..two_pass import one_pass_note
return one_pass_note(base)
return None # a damaged stored value gets no note; the row is judged where it is used

async def start_generation(self, row):
def prepare():
spec = row['spec']
note = self.legacy_one_pass_note(spec)
if note:
# A job stored before the one-pass rule starts under it now, on a first start
# and on a resume alike: keep what it runs as, so the note outlasts the run and
# a reload (a stored request alone cannot tell, and a finished old job must
# not gain a note).
with self.lock:
spec['sampling_note'] = note
self.touch(row)
if row.get('resume_pending'):
self.start_chain(row, resume=True)
return
Expand Down
2 changes: 2 additions & 0 deletions freevideo_engine/agent/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,8 @@ def plan(port, value, jobs=None):
result['geometry'] = {k: canvas[k] for k in ('width', 'height', 'frames', 'seconds', 'fps', 'latent_frames')}
result['sampling'] = dict(two_pass=passes['enabled'], total_steps=passes['total_steps'],
first_pass=passes.get('first'), second_pass=passes.get('second') if passes['enabled'] else None)
if spec.get('sampling_note'):
result['warnings'].append(spec['sampling_note'])
if spec['seconds'] > 15:
result['warnings'].append('Longer than 15 s is experimental')
if spec['task'] == 'ref2va':
Expand Down
21 changes: 19 additions & 2 deletions freevideo_engine/agent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,22 @@ def generate(*, local=False):
{'if': {'if': {'required': ['sampling'], 'properties': {'sampling': {'required': ['two_pass']}}},
'then': {'properties': {'sampling': {'properties': {'two_pass': {'const': False}}}}},
'else': {'required': ['quality'], 'properties': {'quality': {'not': {'const': 'light'}}}}},
'then': {'properties': {'preview': {'const': False}}}}],
'then': {'properties': {'preview': {'const': False}}}},
# A preview is two-pass, which runs only with 8 first-pass steps: refused when the effective
# first-pass count is another number, given in sampling or inherited from the quality.
{'if': {'properties': {'preview': {'const': True}}, 'required': ['preview']},
# x-derived: issues() names this case at /sampling with the derived sampling checks.
'then': {'x-derived': True, 'not': {'anyOf': [
{'required': ['sampling'], 'properties': {'sampling': {'required': ['two_pass', 'base_steps'],
'properties': {'two_pass': {'const': True}, 'base_steps': {'not': {'const': 8}}}}}},
{'required': ['sampling'], 'properties': {
'sampling': {'required': ['base_steps'], 'not': {'required': ['two_pass']},
'properties': {'base_steps': {'not': {'const': 8}}}},
'quality': {'const': 'light'}}},
{'required': ['sampling', 'quality'], 'properties': {
'sampling': {'required': ['two_pass'], 'not': {'required': ['base_steps']},
'properties': {'two_pass': {'const': True}}},
'quality': {'enum': ['medium', 'high', 'max']}}}]}}}],
description='x-job-kind selects normalization and checks derived geometry and sampling step comparisons; these calculations are not JSON Schema constraints.',
**{'x-job-kind': 'generate'})

Expand Down Expand Up @@ -976,7 +991,7 @@ def bad(allowed, got=value, suffix='', **kw):
if 'if' in schema:
rule = schema.get('then', True) if not issues(schema['if'], value) else schema.get('else', True)
errors += issues(rule, value, path)
if 'not' in schema and not issues(schema['not'], value):
if 'not' in schema and not schema.get('x-derived') and not issues(schema['not'], value):
errors.append(diagnostic(path, 'a permitted field combination', value))
if 'oneOf' in schema or 'anyOf' in schema:
# Structural unions choose the most specific applicable branch, then
Expand Down Expand Up @@ -1101,6 +1116,8 @@ def bad(field, allowed, got=None):
validate_steps(base, refine, two)
except ValueError:
bad('sampling', 'two-pass refine_steps=3 or refine_steps below base_steps', custom)
if value.get('preview') and two and base != 8:
bad('sampling', 'base_steps=8 for a preview, the only first-pass count that runs two-pass', custom)
if kind == 'vlm':
rows = value['questions'] if isinstance(value['questions'], list) else [value['questions']]
count = sum(len(r.get('images', [])) + (r.get('video', {}).get('frames', 6) if isinstance(r.get('video'), dict)
Expand Down
9 changes: 8 additions & 1 deletion freevideo_engine/agent/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,16 @@ def sampling(value):
two, base, refine = QUALITY[quality]
custom = value.get('sampling', {})
two, base, refine = custom.get('two_pass', two), custom.get('base_steps', base), custom.get('refine_steps', refine)
note = None
if two:
from ..two_pass import one_pass_note
# Only the 8-step first pass goes two-pass; any other count is one pass, said once in the answer.
note = one_pass_note(base)
two = two and note is None
if custom:
quality = next((k for k, v in QUALITY.items() if v == (two, base, refine)), 'custom')
return dict(quality=quality, two_pass=two, base_steps=base, refine_steps=refine)
return dict(quality=quality, two_pass=two, base_steps=base, refine_steps=refine,
**({'sampling_note': note} if note else {}))


def generation_media(value):
Expand Down
Loading