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
e55235e
Install progress: the GPU acceleration build keeps the step's sentenc…
KBRASK Oct 17, 2026
55751aa
Setup calls the GPU acceleration steps by one name in Chinese
KBRASK Oct 17, 2026
a13e32e
Prompt enhancement model also downloads from ModelScope
KBRASK Oct 17, 2026
92dfb3c
Mac docs: timings are for the Light level; drop the High-level time m…
KBRASK Oct 17, 2026
557cd83
Agent sequences are saved as library works and keep their name in the…
KBRASK Oct 17, 2026
67b54a2
Installers: prune Qt bundle payloads, dedupe data and recompress for …
KBRASK Oct 17, 2026
f4ac944
int8 LoRA: skip unsupported patches one by one and count them by cate…
KBRASK Oct 17, 2026
55b9d9e
Image worker: record the Python stack of a native crash in the failur…
KBRASK Oct 17, 2026
52b31eb
Replace a restored FreeVideo tab from another version with the curren…
KBRASK Oct 17, 2026
d56fa16
Studio: add another long video to the queue while one is generating
KBRASK Oct 17, 2026
b127b2a
Parts of a long video and continued parts sample 8 + 3 steps
KBRASK Oct 17, 2026
2f1e84f
Guide how-tos: short walk-throughs over the real interface, started f…
KBRASK Oct 17, 2026
9ee07ea
Add an offline bilingual tutorial book with a Settings entry
KBRASK Oct 17, 2026
8df550a
Copy sweep for guide, how-to and tutorial strings; reference video an…
KBRASK Oct 17, 2026
5736cbb
Continue from a video at another size of the same shape; short and lo…
KBRASK Oct 17, 2026
4c5ace3
Show partial LoRA application for int8 models
KBRASK Oct 17, 2026
ffc0f76
Hold EasyCache-AV back: the High level samples all 20 steps until the…
KBRASK Oct 17, 2026
5d09eee
Guard EasyCache-AV step reuse and bring it back for the High level
KBRASK Oct 17, 2026
c80c1ac
Take back adding a long video to the queue while one is generating
KBRASK Oct 18, 2026
e3244ad
Stamp the workflow version from the engine folder
KBRASK Oct 18, 2026
a8d542a
Release 0.4.13
KBRASK Oct 18, 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
4 changes: 4 additions & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ recursive-include example_workflows *.json
recursive-include web *.js *.css
include web/vendor/three/LICENSE
recursive-include web/assets *.svg *.png *.webp
recursive-include web/howto *.png
recursive-include web/fonts *.woff2
recursive-include web/tutorial *.webp *.mp4
recursive-include freevideo_engine/assets *.png *.ico
recursive-include freevideo_engine/launcher *.qml
recursive-include freevideo_engine/launcher/licenses *.txt
Expand All @@ -16,6 +18,8 @@ include scripts/bootstrap_linux.sh scripts/build_windows.py scripts/windows_app.
include scripts/build_macos.py scripts/macos_app.py scripts/verify_macos_build.py
include scripts/scan_windows_release.py
include scripts/build_launcher_notices.py
include scripts/prune_qt_bundle.py scripts/deduplicate_bundle_data.py scripts/pyi_rth_bundle_data.py
include scripts/verify_dmg_conversion.py
prune docs
include docs/LoRA.md docs/LoRA.zh-CN.md
prune tests
Expand Down
2 changes: 1 addition & 1 deletion docs/Mac.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ This approves FreeVideo only. It doesn't turn off Gatekeeper or change your othe

## Generation time

On an M5 with 24 GB of unified memory and about 14 GB available:
On an M5 with 24 GB of unified memory and about 14 GB available, at the Light level:

| Resolution | Length | Time |
| --- | --- | --- |
Expand Down
2 changes: 1 addition & 1 deletion docs/Mac.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Mac 预览版暂未进行 Apple 公证,因此首次打开时 macOS 会拦截

## 生成时间

在 M5、24GB 统一内存、可用内存约 14GB 的情况下:
在 M5、24GB 统一内存、可用内存约 14GB、轻量档的情况下:

| 分辨率 | 时长 | 生成时间 |
| --- | --- | --- |
Expand Down
24 changes: 12 additions & 12 deletions docs/zh-CN/使用说明.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
## 参考图生成视频

1. 打开创作面板,展开“素材”。
2. 点“添加”,选择图片。上传的每张图片可以指定用途:首帧、尾帧或参考。要让画面里出现这张图里的人物、物品或场景,选“参考”。
3. 在描述里输入 `@`,从列表里选一张素材,描述里会出现 `<Picture 1>` 这样的标记。视频和音频素材对应 `<Video 1>`、`<Audio 1>`,各类素材分别编号。
4. 在描述里说明这张图在画面中的角色,例如“`<Picture 1>` 里的女孩在海边奔跑”,然后点“生成视频”。
2. 点“上传文件”,选择图片、视频或音频。上传的每张图片可以指定用途:首帧、尾帧或参考。要让画面里出现这张图里的人物、物品或场景,选“参考”。
3. 在描述中输入 `@`,从列表里选一张素材,描述里会出现 `<Picture 1>` 这样的标记。视频和音频素材对应 `<Video 1>`、`<Audio 1>`,各类素材分别编号。
4. 在描述中写明这张图在画面中的作用,例如“`<Picture 1>` 里的女孩在海边奔跑”,然后点“生成视频”。

素材可以暂时关闭而不删除:取消勾选的素材不参与这次生成,其余素材的编号会随之重排,描述里的标记会同步更新。

参考图生成视频目前仍是实验能力。能够运行不等于每次都能保持人物外观一致,画面不满意时可以换一张更清晰的正面图再试。
参考图生成视频目前仍是实验能力。能够运行不等于每次都能保持人物外观一致,清晰的正面图效果更稳定。

## 首帧、尾帧和参考的区别

Expand All @@ -21,11 +21,11 @@
| 尾帧 | 视频在这张图结束 | 指定结束时的画面,可以只给尾帧 |
| 参考 | 图里的内容出现在视频中,但不规定哪一帧 | 保持人物、物品、场景的样子 |

- 首帧和尾帧可以同时使用。
- 首帧、尾帧与参考暂时不能混用。同时给了两类,先保留其中一类。
- 首帧和尾帧会按输出画幅居中裁切;参考图保持原有比例,按输出像素面积缩小。
- 首帧和尾帧可以同时使用,会按输出画幅居中裁切。
- 首帧、尾帧暂不能与参考图、参考视频同时使用;同时给了两类,先保留其中一类。
- 参考图保持原有比例,按输出像素面积缩小。参考视频只取与成片等长的一段,最长 15 秒,生成用时约为只用图片时的两倍。

## 在描述里用 @ 引用素材
## 在描述中用 @ 引用素材

- 输入 `@` 后会列出当前可用的素材,选中后插入对应的标记。
- 也可以直接写标记,如 `<Picture 2>`、`<Video 1>`、`<Audio 1>`。
Expand All @@ -37,7 +37,7 @@
有三种做法,可以单独用,也可以搭配。

1. **直接把时长填长。** 超过一次能生成的长度时,FreeVideo 自动分成几段依次生成,再接成一条视频。
2. **接着生成。** 在生成好的视频结果卡上点“接着生成”,继续下一段;也可以到“剪辑视图”里逐段修改。适合边看边调。
2. **接着生成。** 在生成好的视频结果卡上点“接着生成”,继续下一段;也可在“剪辑视图”中逐段修改。适合边看边调。
3. **用 AI 全自动。** 打开“AI 全自动”,把想法交给 Agent,它会按镜头拆分、逐镜生成并拼接。耗时随镜头数和每镜时长而定,每镜按普通生成计时。

### 在剪辑视图里加参考图
Expand All @@ -46,8 +46,8 @@

### 限制

- 两种做法一样,一条视频最多 5 段(第一段加 4 次接着生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。接得越多,衔接处越容易出现变化,能切镜头的地方建议直接切镜头,用对应的参考图开始新的一段。
- 两种做法一样,一条视频最多 5 段(第一段加 4 次接着生成),所以创作面板里时长最多填 60 秒。接续的每一段至少 10 秒,整条约 70 秒。剪辑视图里逐段生成同样适用。段数越多,衔接处越容易出现变化,适合切换镜头处,可直接切镜头,用对应的参考图开始新的一段。
- 接着生成使用轻量档,需要 NVIDIA 显卡,也不能和“继续二采”同时使用。
- 单次生成的最长时长随画幅和显卡而不同,在创作面板的“范围”里可以查看每种尺寸能生成多长、大约需要多久。
- 分段生成同样需要 NVIDIA 显卡,Mac 上不支持。
- 单次生成的最长时长随画幅和显卡而不同,在创作面板的“可生成范围”中可查看各尺寸的最长时长和预计用时。
- 分段生成同样需要 NVIDIA 显卡,不支持 Mac。
- 分段生成过程中取消,只会停止当前这一段,已经完成的段落会保留,之后可以从第一个未完成的段落继续。
5 changes: 3 additions & 2 deletions freevideo_engine/agent/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -298,10 +298,11 @@ def safe_details(value):
'internal_error': '没有完成',
}
FAILURE_MESSAGES_ZH = {
'The previous video is too short to continue; it needs to be at least 2 s long.': '接续的上一段视频太短,至少需要 2 秒',
'The previous video is too short to continue; it needs to be at least 1.7 s long.': '接续的上一段视频太短,至少需要 1.7 秒',
'The previous video has no usable continuation data. Generate it again with this version of FreeVideo.': '上一段视频缺少可用的接续数据',
'The previous video cannot be read.': '接续的上一段视频无法读取',
'Use the same width and height as the previous video.': '接续的画面大小与上一段视频不一致',
"The previous video's frame rate is too low to continue; it needs at least 10 frames per second.": '接续的上一段视频帧率太低,每秒至少需要 10 帧',
'Use the same aspect ratio as the previous video.': '接续的画面比例与上一段视频不同',
'The reference video is too short; it needs to be at least about 1 s long.': '参考视频太短,至少需要约 1 秒',
}

Expand Down
25 changes: 9 additions & 16 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -1953,13 +1953,10 @@ def run_assemble(self, row):
clips = []
for item in spec['clips']:
clips.append(dict(path=str(self.source_video(item)), start=item.get('start', 0.), end=item.get('end')))
name = spec.get('name') or spec.get('label') or row['id']
name = ''.join(c if c.isalnum() or c in '._-' else '-' for c in name)[:64].strip('-.') or row['id']
relative = 'FreeVideo-Sequences/%s/%s-%s.mp4' % (time.strftime('%Y-%m-%d', time.gmtime()), name, row['id'])
if row.get('parent', {}).get('production'):
# Deterministic output identity survives a process restart during assembly.
run = hashlib.sha256(row['id'].encode()).hexdigest()[:32]
relative = 'FreeVideo/%s/%s/video.mp4' % (iso(row['created_at'])[:10], run)
# Every sequence is saved as a library work, so it shows in My works and has a library link.
# The identity comes from the job id, so it survives a process restart during assembly.
run = hashlib.sha256(row['id'].encode()).hexdigest()[:32]
relative = 'FreeVideo/%s/%s/video.mp4' % (iso(row['created_at'])[:10], run)
out = self.port.output_path(relative, create=True)
row['_phase'] = 'assembling'
def progress(value):
Expand All @@ -1971,20 +1968,16 @@ def progress(value):
cancelled=lambda: bool(row.get('_cancel')))
if row.get('_cancel'):
raise JobError('cancelled', 'Cancelled')
manifest = dict(schema='freevideo.sequence', schema_version=1, job=row['id'], label=spec.get('label'),
manifest = dict(schema='freevideo.sequence', schema_version=1, job=row['id'], label=spec.get('label'), name=spec.get('name'),
project=spec.get('project'), created_at=iso(self.clock()),
clips=[dict(source={k: v for k, v in item.items() if k in ('from_job', 'library', 'file')},
start=item.get('start', 0.), end=item.get('end')) for item in spec['clips']],
transition=spec['transition'], transition_seconds=spec['transition_seconds'])
out.with_suffix('.json').write_text(json.dumps(manifest, indent=1), encoding='utf-8')
if row.get('parent', {}).get('production'):
out.with_suffix('.request.json').write_text(json.dumps(dict(success=True, geometry={
k: made.get(k) for k in ('seconds', 'frames', 'width', 'height')}, sequence=manifest)), encoding='utf-8')
return dict(library_id=library_id(relative), video=library_view(self.port, relative),
**{k: made.get(k) for k in ('seconds', 'frames', 'width', 'height', 'has_audio', 'clips')})
return dict(video=self.port.file_view(relative), seconds=made.get('seconds'), frames=made.get('frames'),
width=made.get('width'), height=made.get('height'), has_audio=made.get('has_audio'),
clips=manifest['clips'])
out.with_suffix('.request.json').write_text(json.dumps(dict(success=True, geometry={
k: made.get(k) for k in ('seconds', 'frames', 'width', 'height')}, sequence=manifest)), encoding='utf-8')
return dict(library_id=library_id(relative), video=library_view(self.port, relative),
**{k: made.get(k) for k in ('seconds', 'frames', 'width', 'height', 'has_audio', 'clips')})


VLM_ERRORS = {
Expand Down
15 changes: 15 additions & 0 deletions freevideo_engine/comfy_continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,21 @@ def node_frames(value):
return value


def part_steps(base_steps, refine_steps, step_cache):
"""(base_steps, refine_steps, step_cache, note) for a continued part or any part of a video made in parts.

The engine continues a video only in two passes, and two passes run only with 8 first-pass steps
(two_pass.TWO_PASS_BASE_STEPS). Other steps would let the first part finish and refuse the next,
so every part samples 8 + 3, the same for the whole film. note is None when nothing changed.
"""
from .two_pass import TWO_PASS_BASE_STEPS
if base_steps == TWO_PASS_BASE_STEPS:
return base_steps, refine_steps, step_cache, None
return (TWO_PASS_BASE_STEPS, 3, 'none',
'Parts of a long video and continued parts use %d + 3 steps with "Two-pass acceleration". '
'%d first-pass steps apply only to a single video.' % (TWO_PASS_BASE_STEPS, base_steps))


def request_frames(value):
"""Translate the node's duration sentinel into the bridge's optional count."""
if type(value) is not int or value != 0 and (value < 39 or (value - 5) % 17):
Expand Down
7 changes: 7 additions & 0 deletions freevideo_engine/comfy_launcher_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,13 @@ async def launcher(request):
@server.routes.get('/freevideo/launcher/workflow')
async def workflow(request):
value = json.loads((source / 'example_workflows' / 'FreeVideo-All-in-One.json').read_text(encoding='utf-8'))
# The tab restored from an older version is replaced when this stamp differs.
from .release_notes import installed_details
version = installed_details(Path(__file__).parent).get('version')
if isinstance(version, str) and version:
for node in value['nodes']:
if node.get('type') == 'FreeVideoGenerate':
node.setdefault('properties', {})['freevideo_version'] = version
return web.json_response(value)

# web/health.js posts what it found; the launcher shows a failure to the
Expand Down
8 changes: 7 additions & 1 deletion freevideo_engine/comfy_nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from comfy_api.latest import ComfyExtension, InputImpl, io, ui

from . import comfy_bridge
from .comfy_continuation import node_frames, optional_text, request_options
from .comfy_continuation import node_frames, optional_text, part_steps, request_options
from .default_prompt import DEFAULT_PROMPT

References = io.Custom('FREEVIDEO_REFERENCES')
Expand Down Expand Up @@ -144,6 +144,7 @@ def validate_inputs(cls, text, width, height, seconds, seed, continue_from='', f
if options.get('continue_from'):
# A continued part turns on two passes, skips the preview and takes keyframes as pictures (execute).
two_pass, preview = True, False
base_steps, refine_steps = part_steps(base_steps, refine_steps, step_cache)[:2]
options.update(two_pass=True, preview=False, refine_from=refine_from or None,
media=dict(first=False, last=False, conditioning_info=False))
comfy_bridge.validate_request(text, width, height, seconds, seed, **options)
Expand Down Expand Up @@ -181,6 +182,11 @@ def execute(cls, text, width, height, seconds, seed, first=None, last=None,
chain = block_context(PromptServer.instance, node_id)
options = request_options(continue_from, node_frames(frames))
notes = []
if options.get('continue_from') or chain is not None and chain.get('count', 1) > 1:
base_steps, refine_steps, step_cache, note = part_steps(base_steps, refine_steps, step_cache)
if note:
two_pass = True
notes.append(note)
if options.get('continue_from') and not two_pass:
two_pass = True
notes.append('Continuing a video needs "Two-pass acceleration", so it is on for this part.')
Expand Down
4 changes: 2 additions & 2 deletions freevideo_engine/comfy_upload.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,12 @@ async def save_lora_triggers(request):
@server.routes.get('/freevideo/lora/check')
async def lora_check(request):
from .comfy_assets import resolve_loras
from .lora_check import check_file
from .lora_check import check_installed_file
try:
name = request.query.get('name')
path = resolve_loras(json.dumps([dict(name=name)]), folder_paths.get_filename_list('loras'),
lambda n: folder_paths.get_full_path_or_raise('loras', n))[0]['path']
result = await asyncio.to_thread(check_file, path)
result = await asyncio.to_thread(check_installed_file, path)
except (OSError, ValueError, TypeError) as error:
return web.json_response({'error': str(error)}, status=400)
return web.json_response(result, headers={'Cache-Control': 'no-store'})
Expand Down
6 changes: 4 additions & 2 deletions freevideo_engine/continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
VERSION = 1

MISSING = 'The previous video has no usable continuation data. Generate it again with this version of FreeVideo.'
SIZE_MISMATCH = 'Use the same width and height as the previous video.'
SIZE_MISMATCH = 'Use the same aspect ratio as the previous video.'
# Every refusal starts with UNSUPPORTED, then says what to change.
UNSUPPORTED = 'Continue from video cannot be used with these settings.'
UNSUPPORTED_DEVICE = UNSUPPORTED + ' Continuing a video needs an NVIDIA GPU. To make a new video, clear "Continue from video".'
Expand Down Expand Up @@ -398,8 +398,10 @@ def prepare(video_path, canvas, manifest=None, sampling_plan=None, *, directory=
def _prepare_snapshot(request, value, canvas, manifest=None, sampling_plan=None):
validate_frames(canvas['frames'])
metadata = value['metadata']
# A record of another size is set aside like a missing one: the previous video's own ending is
# encoded instead, which scales the same shape and refuses another one.
if any(metadata['geometry'][key] != canvas[key] for key in ('width', 'height')):
raise ValueError(SIZE_MISMATCH)
raise ValueError(MISSING)
if manifest is not None and metadata['identity'] != identity(manifest):
raise ValueError(MISSING)
if sampling_plan is not None and metadata['first_canvas'] != {
Expand Down
5 changes: 4 additions & 1 deletion freevideo_engine/continuation_tail.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@
TICK_SAMPLES = 800
AUDIO_WINDOW_TICKS = 600 # 15 s, the longest sound H3 generates
FPS = 24
TOO_SHORT = 'The previous video is too short to continue; it needs to be at least 2 s long.'
TOO_SHORT = 'The previous video is too short to continue; it needs to be at least 1.7 s long.' # NOMINAL_FRAMES is 1.625 s at 24 fps; the message rounds up so a 1.7 s video always fits
UNREADABLE = 'The previous video cannot be read.'
LOW_RATE = "The previous video's frame rate is too low to continue; it needs at least 10 frames per second."


def _fit(image, width, height):
Expand Down Expand Up @@ -54,6 +55,8 @@ def _picture(frame):

def _rate(stream):
rate = float(stream.average_rate or stream.guessed_rate or 0)
if 0 < rate < 10:
raise ValueError(LOW_RATE)
if not 10 <= rate <= 121:
raise ValueError(UNREADABLE)
return rate
Expand Down
5 changes: 3 additions & 2 deletions freevideo_engine/desktop_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ def source_files(root):
files = [root / name for name in names]
files += [p for folder in ('freevideo_engine', 'constraints', 'web', 'example_workflows')
for p in (root / folder).rglob('*')
if p.is_file() and p.suffix in ('.py', '.json', '.txt', '.ps1', '.sh', '.js', '.css', '.svg', '.png', '.ico', '.qml',
'.woff2', '.webp')]
if p.is_file() and (p.suffix in ('.py', '.json', '.txt', '.ps1', '.sh', '.js', '.css', '.svg', '.png', '.ico', '.qml',
'.woff2', '.webp')
or folder == 'web' and p.parent.name == 'tutorial' and p.suffix == '.mp4')]
return sorted(files)


Expand Down
3 changes: 3 additions & 0 deletions freevideo_engine/effort_forecast.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ def timing_record(report, gpu_uuid, system, *, stage_details=True):
if measured[0]['steps'] != base or (refine and measured[1]['steps'] != refine):
return None
cache = effective_step_cache(passes[0])
if cache == 'easycache-av' and not passes[0]['step_cache'].get('guard'):
# Before the guard this cache reused steps unchecked and ran too fast to describe any request.
return None
encoding = report.get('encoding') or {}
cached = bool(encoding.get('cache_hit') or report.get('input_cache', {}).get('conditioning_hit')
or report.get('tuning', {}).get('conditioning_cache_hit'))
Expand Down
Loading