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
41436a3
Optional LAN password sign-in with device-bound access for the phone …
KBRASK Oct 18, 2026
ead145b
Local page LAN settings API with page-session CSRF; phones through th…
KBRASK Oct 18, 2026
54fec07
Resolve the optional LAN password module per call in the launcher ses…
KBRASK Oct 18, 2026
c6fc7b1
Resolve the optional LAN password module at import time again in the …
KBRASK Oct 18, 2026
d4ecec6
Remove the local page LAN settings API for now
KBRASK Oct 18, 2026
57a1513
Remove optional LAN password sign-in for the phone proxy for now
KBRASK Oct 18, 2026
1a814ba
Quality levels: Max uses 20 steps with a guarded step cache, High 16 …
KBRASK Oct 18, 2026
addf3ec
Agents import rendered clips into editor timelines over HTTP, MCP and…
KBRASK Oct 18, 2026
6ca16b3
Keep FreeVideo's own text out of browser auto-translate (MP stays MP)
KBRASK Oct 18, 2026
f324669
Upgrade keeps two-pass, step counts, step cache and upscale when an o…
KBRASK Oct 18, 2026
1b80f3b
Studio: queue more long-video requests while one is generating
KBRASK Oct 18, 2026
244531f
Editor: flag an unreadable reference picture right away
KBRASK Oct 18, 2026
ce7954c
Picture editing inside a selected region by box, click or outline
KBRASK Oct 18, 2026
c47b25a
Read cuDNN and package metadata files through extended paths in the l…
KBRASK Oct 18, 2026
b91f587
Record decoded audio level in the diagnostic report
KBRASK Oct 18, 2026
497aa7c
Prompt model check: no new download when only file times changed
KBRASK Oct 18, 2026
7d67692
Add a one-line introduction to the unconnected autopilot page
KBRASK Oct 18, 2026
2dec389
Feedback for a failed generation carries the engine's error text
KBRASK Oct 18, 2026
5e22993
Showcase: a cat jumping up a bookshelf replaces the race car example
KBRASK Oct 18, 2026
088c00b
Prompt enhancement on 6 GB cards: keep int8 layers on the GPU and str…
KBRASK Oct 18, 2026
36789f9
Release 0.4.14
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
9 changes: 9 additions & 0 deletions freevideo_engine/agent/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ def build_parser():
schema.cli_arguments(command, schema.contracts()['GET', '/jobs']['query'])
command = commands.add_parser('upload', help='Copy a local media file into FreeVideo inputs')
command.add_argument('path')
command = commands.add_parser('imports', help='Bring local video or audio into the editing timeline')
imports = command.add_subparsers(dest='operation', required=True)
command = imports.add_parser('add', help='Upload content; poll imports list until ready before insert')
command.add_argument('path')
schema.cli_arguments(command, schema.obj({'ref': schema.REF}))
imports.add_parser('list', help='List imported sources and preparation progress')
command = commands.add_parser('generate-image', help='Generate or change a picture with the local image model')
schema.cli_arguments(command, schema.image(), exclude=('kind',))
command.add_argument('--wait', action='store_true', help='Wait for the picture and print the result')
Expand Down Expand Up @@ -280,6 +286,9 @@ def source(value):
result = client.get(path, **value) if method == 'GET' else client.post(path, value)
elif args.command == 'upload':
result = client.upload(args.path)
elif args.command == 'imports':
result = (client.import_media(args.path, **({'ref': args.ref} if hasattr(args, 'ref') else {}))
if args.operation == 'add' else client.get('/imports'))
else:
result = client.frame(source(args.source), values.get('at', 'last'))
except ApiError as error:
Expand Down
20 changes: 17 additions & 3 deletions freevideo_engine/agent/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,16 @@ def asset(self, identity, target, *, thumbnail=False):

def upload(self, path):
"""Read on the client computer and upload content; never send a local path."""
return self._upload(path, '/media')

def import_media(self, path, *, ref=None):
"""Import a client file into the editor. Poll /imports until its state is ready."""
from . import schema
if ref is not None:
schema.validate(schema.obj({'ref': schema.REF}), dict(ref=ref))
return self._upload(path, '/imports', ref=ref)

def _upload(self, path, endpoint, *, ref=None):
from .schema import MAX_UPLOAD_BYTES, MAX_JSON_UPLOAD_BYTES
source = local_file(path)
try:
Expand All @@ -289,7 +299,10 @@ def upload(self, path):
raise ApiError(413, 'too_large', 'File exceeds upload limit',
dict(next='Reduce the file to at most %d bytes.' % MAX_UPLOAD_BYTES, details=[dict(limit_bytes=MAX_UPLOAD_BYTES)]))
if size * 4 // 3 + 4096 <= MAX_JSON_UPLOAD_BYTES:
return self.upload_bytes(source.name, source.read_bytes())
if endpoint == '/media':
return self.upload_bytes(source.name, source.read_bytes())
return self.post(endpoint, dict(name=source.name, data_base64=base64.b64encode(source.read_bytes()).decode(),
**({'ref': ref} if ref is not None else {})), timeout=600)
import secrets
boundary = 'freevideo-' + secrets.token_hex(16)
# Only a safe extension crosses a MIME header; local filenames stay local.
Expand All @@ -298,14 +311,15 @@ def upload(self, path):
raise ApiError(422, 'invalid_media', 'Invalid media extension')
prefix = ('--%s\r\nContent-Disposition: form-data; name="file"; filename="media%s"\r\n'
'Content-Type: application/octet-stream\r\n\r\n' % (boundary, suffix)).encode('ascii')
ending = ('\r\n--%s--\r\n' % boundary).encode('ascii')
ending = (('\r\n--%s\r\nContent-Disposition: form-data; name="ref"\r\n\r\n%s' % (boundary, ref)
if ref is not None else '') + '\r\n--%s--\r\n' % boundary).encode('ascii')
with source.open('rb') as stream:
def chunks():
yield prefix
while data := stream.read(1024 * 1024):
yield data
yield ending
return self.request('POST', '/freevideo/agent/v1/media', encoded=chunks(),
return self.request('POST', '/freevideo/agent/v1' + endpoint, encoded=chunks(),
content_type='multipart/form-data; boundary=' + boundary,
content_length=len(prefix) + size + len(ending), timeout=600)
except OSError:
Expand Down
14 changes: 12 additions & 2 deletions freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@
],
'quality': [
'Steps per quality: "light" = 8 first-pass steps + 3 refine steps, two-pass, the first pass at half size '
'(default); "medium" = the same 8 + 3 two-pass with a larger first pass, for more detail; "high" = 20 steps '
'with a step cache and "max" = 20 plain steps, each in one pass. The same on a Mac. sampling.two_pass=false '
'(default); "medium" = the same 8 + 3 two-pass with a larger first pass, for more detail; "high" = 16 steps '
'with a step cache and "max" = 20 steps with a step cache, each in one pass. The same on a Mac. sampling.two_pass=false '
'without base_steps runs 20 steps.',
'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 '
Expand Down Expand Up @@ -128,6 +128,14 @@
],
'project_routes': [dict(operation=name, method=method, path=path, description=summary)
for name, (method, path, summary) in PROJECT_OPERATIONS.items()],
'bring_your_own_footage': [
'Render complete clips with ffmpeg/Canvas on your computer; bake overlays into their pixels. FreeVideo receives media, not your code. Use the project dimensions (even pixels), 24 fps and integer frame positions; seconds = frames / 24.',
'Download generated shots using result.video.url, library video.url, or sources[key].video/audio from GET /projects/{id}, with the same Authorization: Bearer token (Range supported). These URLs are relative to the server origin. Render your changes locally.',
'POST /imports accepts multipart file then optional ref, or JSON {name, data_base64, ref?}; HTTP never reads a local path. MCP freevideo_import_media and CLI imports add read a local path and upload content, like media upload. Limits: 2 GiB content, 96 MiB JSON body, and the same media decode limits.',
'The answer is {import: {id, source: "import:<id>", state, fraction, ...}}. As on the page, poll GET /imports (MCP freevideo_imports, CLI imports list) about every two seconds. Match id in items; preparing is not insertable, ready is usable, failed is terminal with reason. Same ref or Idempotency-Key and filename/content reuses one import for that X-FreeVideo-Client, including after restart; changed content conflicts.',
'The page receiver and transcode queue create an upright 24 fps H.264 preview by dropping/duplicating timestamped frames, with even dimensions and longest side at most 1920; preview audio is 32 kHz stereo AAC. Original dimensions are retained. Inserting the first video follows its original size by default (up to 8192 pixels); set project size_mode="fixed" to keep a chosen canvas. Later clips fit the canvas (cover by default). Export reads the original when available at the project output size/rate (preview fallback if missing).',
'Insert with {op:"insert", source:"import:<id>", at:48} in POST /projects/{id}/operations using the current revision. Add subtitles/text and audio through project operations, then POST /projects/{id}/export and wait for the job. There is one video track; effects are baked into complete clips, not separate overlay tracks or blend modes.',
],
'editing_images': [
'POST /images opens a picture as the image editor does; GET /images/{id} reads its document (layers), selection and revision.',
'POST /images/{id}/operations sends revision and 1-64 operations run with the page\'s own commands; the batch saves once or changes nothing. Layer IDs of new layers are in results[i].new_layer_ids.',
Expand Down Expand Up @@ -275,6 +283,8 @@
('POST', '/jobs/{id}/cancel'): '取消任务',
('POST', '/jobs/{id}/resume'): '接着生成一条视频里还没完成的部分',
('POST', '/media'): '上传图片、视频或音频内容',
('POST', '/imports'): '导入视频或音频到剪辑时间线;轮询 GET /imports 等待就绪',
('GET', '/imports'): '列出已导入素材和准备进度',
('GET', '/media/{kind}/{file}/file'): '下载上传或处理后的素材',
('POST', '/media/inspect'): '查看素材信息',
('POST', '/media/frame'): '提取视频的一帧',
Expand Down
142 changes: 142 additions & 0 deletions freevideo_engine/agent/imports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
"""Content uploads into the editor's import store and software transcode queue."""
import asyncio
import base64
import binascii
import hashlib
from pathlib import Path
import tempfile
from types import SimpleNamespace
from urllib.parse import quote, urlencode

from .. import comfy_editor as editor
from . import media, schema, spec
from .jobs import JobError


def invalid(path, expected, code='invalid_media'):
error = spec.SpecError(schema.diagnostic(path, expected, None))
error.code = code
raise error


def view(value):
fields = ('id', 'name', 'kind', 'state', 'fraction', 'reason', 'frames', 'fps', 'width', 'height',
'preview_width', 'preview_height', 'has_audio')
row = {key: value.get(key) for key in fields}
row['fps'] = value.get('fps', editor.FPS)
row['has_audio'] = value.get('has_audio', False)
row['source'] = 'import:' + value['id']
for kind in ('preview', 'thumbnail'):
row[kind] = (schema.PREFIX + '/editor/sources/content?' + urlencode(dict(key=row['source'], file=kind))
if row['state'] == 'ready' else None)
return row


def listing(root):
return dict(items=[view(row) for row in editor.list_imports(root)])


def replay(root, receipt):
if receipt is None:
return None
for row in editor.list_imports(root):
value = editor._read_import(root, row['id'])
saved = value.get('agent_receipt')
if saved and (saved.get('client'), saved.get('ref')) == (receipt['client'], receipt['ref']):
if saved['digest'] != receipt['digest']:
invalid('/ref', 'the same filename and content for a replay', 'idempotency_conflict')
return view(row)
return None


class Content:
"""Adapt a checked upload to the page's streaming receiver, without a server-side path API."""
def __init__(self, path):
self.path = path

async def iter_chunked(self, size):
with self.path.open('rb') as stream:
while chunk := await asyncio.to_thread(stream.read, size):
yield chunk


async def upload(request, value, root, manager, lock, decoders):
if manager is None:
raise JobError('not_ready', 'Editor import queue unavailable')
# Temporary content stays beside the destination, and is removed on rejection or disconnect.
with tempfile.TemporaryDirectory(prefix='.agent-import-', dir=root) as temporary:
multipart = request.content_type == 'multipart/form-data'
field = '/file' if multipart else '/data_base64'
ref = None if multipart else value.get('ref')
if multipart:
reader = await request.multipart()
part = await reader.next()
if part is None or getattr(part, 'name', None) != 'file' or not getattr(part, 'filename', None):
invalid('/file', 'one nonempty file part, followed by optional ref')
name = part.filename
else:
name = value['name']
spec.import_name(name)
target = Path(temporary) / ('media' + Path(name).suffix.lower())
digest = hashlib.sha256()
size = 0
with target.open('xb') as stream:
if multipart:
while chunk := await part.read_chunk(size=256 * 1024):
size += len(chunk)
if size > schema.MAX_UPLOAD_BYTES:
raise JobError('too_large', 'Upload exceeds limit', limit_bytes=schema.MAX_UPLOAD_BYTES)
digest.update(chunk)
await asyncio.to_thread(stream.write, chunk)
extra = await reader.next()
if extra is not None:
if getattr(extra, 'name', None) != 'ref' or getattr(extra, 'filename', None):
invalid('/file', 'one file part followed only by optional ref')
data = bytearray()
while chunk := await extra.read_chunk():
data.extend(chunk)
if len(data) > 1024:
spec.fail('/ref', 'a ref of at most 128 characters')
try:
ref = data.decode('utf-8')
except UnicodeDecodeError:
spec.fail('/ref', 'a UTF-8 ref')
if await reader.next() is not None:
invalid('/file', 'one file part followed only by optional ref')
else:
try:
data = base64.b64decode(value['data_base64'], validate=True)
except (binascii.Error, ValueError):
invalid(field, 'valid base64-encoded video or audio content')
size = len(data)
if size > schema.MAX_UPLOAD_BYTES:
raise JobError('too_large', 'Upload exceeds limit', limit_bytes=schema.MAX_UPLOAD_BYTES)
digest.update(data)
await asyncio.to_thread(stream.write, data)
if not size:
invalid(field, 'nonempty video or audio content')
key = request.headers.get('Idempotency-Key')
for where, candidate in (('/ref', ref), ('/headers/Idempotency-Key', key)):
if candidate is not None:
problems = schema.issues(schema.REF, candidate, where)
if problems:
raise spec.SpecError(problems)
if ref and key and ref != key:
invalid('/ref', 'the same value as Idempotency-Key', 'idempotency_conflict')
ref = key or ref
receipt = (dict(ref=ref, client=request.headers.get('X-FreeVideo-Client', '')[:80] or None,
digest=hashlib.sha256(name.encode() + b'\0' + digest.digest()).hexdigest()) if ref else None)
async with decoders:
try:
await asyncio.to_thread(media.validate_upload, target)
except (OSError, ValueError, SyntaxError):
invalid(field, 'decodable video or audio within the media upload limits')
async with lock:
previous = await asyncio.to_thread(replay, root, receipt)
if previous is not None:
return {'import': previous}, 200
adapted = SimpleNamespace(content_length=size, headers={'X-FreeVideo-Filename': quote(name, safe='')},
content=Content(target))
made = await editor.receive(adapted, root, receipt=receipt)
manager.submit(made['id'])
return {'import': view(made)}, 201
13 changes: 12 additions & 1 deletion freevideo_engine/agent/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@
"frame": "last"}), look at results with freevideo_look, ask the local VLM with freevideo_ask, and join clips
with freevideo_assemble. For durable filming, use freevideo_drama_plan → start → get → sheet → choose/reshoot → finish.
Cast first: search freevideo_cast_list, reuse the card the user named or one that fits, create a card only if none does.
If paused is set, wait for the user using get with since and wait=25. Only the user may pause/resume."""
If paused is set, wait for the user using get with since and wait=25. Only the user may pause/resume.
Bring your own footage: render complete video clips on your computer with ffmpeg/Canvas, baking effects into pixels.
freevideo_import_media reads your local file and uploads content (up to 2 GiB); reuse ref for safe retries.
Poll freevideo_imports about every two seconds until state=ready (failed is terminal), then insert its source=import:<id>
at an integer frame in a 24 fps project, add subtitles/audio, and export. GET /guide bring_your_own_footage explains
authenticated source downloads, dimensions and audio. No second video track or blend mode is needed."""


def tool_list():
Expand All @@ -53,6 +58,8 @@ def tool_list():
dict(name='freevideo_cancel', title='Cancel a job', description='Cancel a waiting, queued or running job.', annotations={'readOnlyHint': False, 'destructiveHint': True, 'openWorldHint': False}),
dict(name='freevideo_resume', title='Resume generation', description='Resume a failed, interrupted or cancelled generation; only unfinished blocks run. Poll the same job ID.', annotations=write),
dict(name='freevideo_add_media', title='Add media', description='Read a local image, video or audio file and upload its content; returns the "file" to use in media.', annotations=write),
dict(name='freevideo_import_media', title='Import footage', description='Read a local video or audio file and upload it into the editor. Returns import.source (import:<id>) and state. Poll freevideo_imports until ready, then insert that source into a project. Same ref and content safely replay.', annotations=write),
dict(name='freevideo_imports', title='Imported footage', description='List editor imports with source, state (preparing/ready/failed), fraction and authenticated preview URLs. Wait for ready before insert; failed is terminal.', annotations=read),
dict(name='freevideo_look', title='Look at media', description='Return a picture you can see: one frame of a video (at = first, last or seconds) or a contact sheet of timed frames (mode = sheet), or an image file. Use it to review shots.', annotations=read),
dict(name='freevideo_edit_image', title='Edit an image', description="Crop/rotate/flip/pad/adjust an image, add text, shapes or image overlays, or remove burned-in captions (remove_text), without the browser. The result is a new input file plus an editor document a person can reopen in FreeVideo's image editor.", annotations=write),
dict(name='freevideo_ask', title='Ask the local VLM', description='Ask FreeVideo\'s local vision-language model about images or sampled frames of a video (e.g. "Does this shot match its description? Answer as JSON {score, issues}").', annotations=write),
Expand Down Expand Up @@ -359,6 +366,10 @@ def tool(self, identity, name, args, token):
return c.resume(args['id'], **({'ref': args['ref']} if 'ref' in args else {})), []
if name == 'freevideo_add_media':
return c.upload(args['path']), []
if name == 'freevideo_import_media':
return c.import_media(args['path'], **({'ref': args['ref']} if 'ref' in args else {})), []
if name == 'freevideo_imports':
return c.get('/imports'), []
if name == 'freevideo_edit_image':
made = c.edit(args['source'], args.get('recipe'), document=args.get('document'), target=args.get('target'))
return made, self.picture(made['view'])
Expand Down
Loading