Skip to content
Merged
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ It coordinates VRAM, system memory and disk, adapting weight placement, compute
<img alt="FreeVideo creative workspace" src="https://github.com/user-attachments/assets/7647a6f8-4306-403c-b147-45d7a393e18d" width="92%">
</div>

**Security software:** FreeVideo.exe is code-signed. If security software blocks FreeVideo.exe or quarantines files in the FreeVideo folder, check that the SHA-256 of FreeVideo.exe matches the value on the release page, then restore the files and add the FreeVideo folder to the software's exclusion list.

**Offline installation:** Download the packages from [Quark](https://pan.quark.cn/s/c51235b84618) and drag the ZIP files into the launcher without extracting them. A fully offline installation needs the four model packs (video model, text encoder, video & audio decoder, and sampling caches), plus the environment package for a new ComfyUI installation. After importing only the environment package, you can also choose **Automatic download** for the models (v0.3.12 or newer). The audio reference cache is optional and is used only when a reference includes audio.

### macOS (Apple silicon preview)
Expand Down
2 changes: 2 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ FreeVideo 是面向消费级显卡的 MiniMax H3 本地推理引擎,基于 [Op
<img alt="FreeVideo 创作面板" src="https://github.com/user-attachments/assets/f4719079-bb48-4836-9c64-2473477cd1a0" width="92%">
</div>

**安全软件:** FreeVideo.exe 已进行代码签名。如果安全软件拦截 FreeVideo.exe,或隔离了 FreeVideo 文件夹中的文件,请先核对 FreeVideo.exe 的 SHA-256 与发布页列出的值一致,再恢复文件,并把 FreeVideo 文件夹加入该软件的排除列表。

**离线安装:** 从[夸克网盘](https://pan.quark.cn/s/c51235b84618)下载离线包,将 ZIP 文件直接拖入启动器,无需解压。完全离线安装需要「视频模型」「文本编码器」「视频与音频解码器」「采样缓存」四个模型包,全新安装 ComfyUI 时还需要「运行环境」包。也可以只导入「运行环境」包,再在模型来源中选择**自动下载**(v0.3.12 及以上)。「音频参考缓存」为可选包,仅在参考素材带有音频时使用。

### macOS(Apple 芯片预览版)
Expand Down
4 changes: 3 additions & 1 deletion freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,9 @@
'Pictures. When the user wants a picture, or a first frame or reference picture that does not exist yet, make '
'it with freevideo_generate_image; to change a picture, pass it in images. The result is an input file: use '
'result.file as a first frame, as a reference, or in images again. When where people stand in the shot matters, '
'make the first frame with people (cast pictures and places) and generate the video from it.',
'make the first frame with scene_image and people (cast card IDs or pictures, x, height, facing left/right, '
'framing full/half), then generate the video from result.file. Use dimensions divisible by 32. '
'This controls the scene, requested framing and approximate positions; face similarity is not guaranteed.',
'Cut by default. Wherever the film can change shot, make the new shot from the reference pictures it should '
'have (cast cards, scene pictures) instead of continuing the previous video. Continue a video only for a '
'long take that cannot be cut, an action that must stay unbroken, or sound that must stay unbroken, and '
Expand Down
25 changes: 25 additions & 0 deletions freevideo_engine/agent/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -797,6 +797,7 @@ def submit_request(self, value, *, client=None, key=None, parent=None, editor=No
item = substitute(item, names)
try:
normalized = specs.normalize(item, port=_Warmed(self.port))
self.resolve_image_cast(normalized)
except specs.SpecError as error:
problems.extend(schema.prefix_diagnostic(e, prefix) for e in error.errors)
continue
Expand Down Expand Up @@ -1853,6 +1854,30 @@ def generation_failure(self, row, chain):
return failure

# Local image generation ----------------------------------------------------------------------------
def resolve_image_cast(self, spec):
"""Pin character card pictures before saving the job, including across restarts/replays."""
if spec['kind'] != 'image':
return
for i, person in enumerate(spec.get('people', [])):
identity = person.get('cast')
if identity is None:
continue
path = '/people/%d/cast' % i
try:
card = self.cast.get(identity)
except JobError as error:
if error.code != 'not_found':
raise
specs.fail(path, 'an existing character card ID from GET /freevideo/agent/v1/cast')
if card['kind'] != 'character':
specs.fail(path, 'a character card, rather than a setting card')
picture = next((row for row in card['images'] if row['label'] == 'front'), None)
if picture is None:
specs.fail(path, 'a character card with a front picture; add or generate one first')
person.pop('cast')
person['image'] = picture['media']['file']
person.setdefault('name', card['name'][:40])

def wait_image(self, row, code, *, waiting_for=None):
if code not in IMAGE_WAIT_ERRORS:
return False
Expand Down
2 changes: 1 addition & 1 deletion freevideo_engine/agent/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ def tool_list():
dict(name='freevideo_schema', title='Job JSON Schemas', description='JSON Schemas of every job kind and of image edit recipes.', annotations=read),
dict(name='freevideo_plan', title='Plan a job', description="Validate a job without running it: actual frames/seconds, sampling passes, warnings and a time estimate from this computer's history. Same fields as freevideo_generate.", annotations=read),
dict(name='freevideo_generate', title='Generate a video', description='Generate one video from an H3 prompt, up to 60 seconds. Under 30 s defaults to one generation if the device supports this size and quality; unknown limits use 15 s. Otherwise the video is cut into as few parts as possible, each at least 10 s, and how many parts depends on how long this computer can generate in one run; the parts continue and join automatically. Prefer a new shot with its reference pictures wherever the film can cut; continue only a take, action or sound that must stay unbroken. Use this for any single video, also after freevideo_enhance_prompt; the drama tools are only for a film of several separately chosen shots. Optionally from first/last frames or references (files, local paths, or frames of earlier jobs). Returns the job; with wait, its result (video path and URL).', annotations=write),
dict(name='freevideo_generate_image', title='Generate a picture', description='Generate a picture from a prompt; with images, change or combine input pictures; with people, make a first frame with cast members standing where you say. The result is an input file to use as a first frame, as a reference, or in images again.', annotations=write),
dict(name='freevideo_generate_image', title='Generate a picture', description='Generate a picture from a prompt; with images, change or combine input pictures; with people, make a first frame with them near the places you say, in the scene from scene_image or the prompt. The result is an input file to use as a first frame, as a reference, or in images again.', annotations=write),
dict(name='freevideo_submit_batch', title='Submit a storyboard', description='Submit several jobs at once. Each row is a job object with "kind" (generate default, assemble, vlm, enhance, image) and an optional "ref"; later rows use "@ref" wherever a job id is expected (from_job, after, clips, refine_from). FreeVideo runs them in dependency order.', annotations=write),
dict(name='freevideo_job', title='Job status', description='Status, progress, result or error of a job.', annotations=read),
dict(name='freevideo_wait', title='Wait for a job or the user', description="Wait at most 25 seconds for a job (with id) or, without id, at most 50 seconds for what the user says in FreeVideo (inbox). again=true means nothing new yet: call it again. A job wait returns early when the user says something. If the job is still active, call it again with the same ID; do not resubmit.", annotations=read),
Expand Down
23 changes: 16 additions & 7 deletions freevideo_engine/agent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,14 +234,20 @@ def image(*, local=False):
seed=nullable(number(-2**63, 2**64 - 1, integer=True)),
images=array({'oneOf': [FILE, obj(dict(file=FILE), ('file',))]}, high=4, description='Pictures to change '
'or combine: input files, such as result.file of an earlier picture job. The prompt calls them image 1, image 2.'),
people=array(obj(dict(image=FILE, name=string(40), x=number(0, 1), height=number(.05, 1), feet=number(0, 1)),
('image',)), 1, 3, description='Makes a first frame with people standing where you say. image is '
"a full-body picture on a plain backdrop, such as a cast card's front picture. From 0 to 1: x is the person's "
'centre across the width (default 0.5), height their size (0.62) and feet where they stand down the height '
'(0.9). The prompt describes the scene, or scene_image shows it. Not together with images.'),
people=array(obj(dict(image=FILE, cast=CAST, name=string(40), x=number(0, 1), height=number(.05, 1),
feet=number(0, 1), facing=enum(('left', 'right')),
framing=enum(('full', 'half'), default='full')),
oneOf=[{'required': ['image']}, {'required': ['cast']}]), 1, 3,
description="First frame with people near the places you say; faces may not match the pictures exactly. "
"x, height and feet run 0 to 1: x the centre across the width (0.5), height the visible figure's share of the frame "
"height (0.62; head to waist for half), feet the figure's lower edge from the top (the larger of 0.9 and height). "
"facing is left or right on screen. Not with images."),
scene_image=FILE,
**common()),
('kind', 'prompt'), allOf=[{'if': {'required': ['width']}, 'then': {'required': ['height']}},
('kind',), allOf=[{'if': {'not': {'required': ['scene_image']}}, 'then': {'required': ['prompt']}},
{'if': {'required': ['people']}, 'then': {'properties': {
key: number(256, 2048, integer=True, multipleOf=32) for key in ('width', 'height')}}},
{'if': {'required': ['width']}, 'then': {'required': ['height']}},
{'if': {'required': ['height']}, 'then': {'required': ['width']}},
{'if': {'required': ['aspect']},
'then': {'not': {'anyOf': [{'required': ['width']}, {'required': ['height']}]}}}],
Expand Down Expand Up @@ -1180,6 +1186,9 @@ def bad(field, allowed, got=None):
bad('images', 'omitted when people is supplied', value['images'])
if value.get('scene_image') and not value.get('people'):
bad('scene_image', 'omitted unless people is supplied', value['scene_image'])
for i, person in enumerate(value.get('people', [])):
if 'feet' in person and person['feet'] < person.get('height', .62):
bad('people/%d/feet' % i, 'at least the figure height, so the figure fits inside the frame', person['feet'])
if kind == 'generate':
if value.get('continue_from') and isinstance(value.get('media'), dict) and any(
v is not None for k, v in value['media'].items() if k != 'references'):
Expand Down Expand Up @@ -1471,7 +1480,7 @@ def openapi():


SESSION_ARGUMENT = dict(type='string', pattern='^s[0-9a-f]{12}$',
description='Work session for this call; omit to use your latest one.')
description='Work session; omit for your latest.')


SHAPE_MIN_BYTES = 400
Expand Down
8 changes: 5 additions & 3 deletions freevideo_engine/agent/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,13 +269,15 @@ def normalize(value, *, port=None):
elif kind == 'image':
width, height = ((value['width'], value['height']) if 'width' in value else
contract.IMAGE_ASPECTS[value.get('aspect', '1:1')])
result.update(prompt=value['prompt'], width=width, height=height,
result.update(prompt=value.get('prompt', ''), width=width, height=height,
images=[item if isinstance(item, str) else item['file'] for item in value.get('images', [])])
if value.get('seed') is not None:
result['seed'] = value['seed']
if value.get('people'):
result['people'] = [dict(image=person['image'], x=person.get('x', .5), height=person.get('height', .62),
feet=person.get('feet', .9), **({'name': person['name']} if person.get('name') else {}))
result['people'] = [dict({k: person[k] for k in ('image', 'cast', 'name', 'facing') if k in person},
x=person.get('x', .5), height=person.get('height', .62),
framing=person.get('framing', 'full'),
feet=person.get('feet', max(.9, person.get('height', .62))))
for person in value['people']]
if value.get('scene_image'):
result['scene_image'] = value['scene_image']
Expand Down
Loading