Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 12 additions & 8 deletions freevideo_engine/agent/cast.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""The cast library: durable character and setting cards shared by every film.
"""The cast library: durable character, setting and prop cards shared by every film.
One JSON file per card under <jobs>/cast, written atomically under Jobs' lock,
the same way films are kept. Pictures and voices enter a card only as input
Expand All @@ -21,7 +21,7 @@
from .jobs import ACTIVE, DONE, JobError, iso

CARD = re.compile(r'c[0-9a-f]{12}')
KINDS = ('character', 'setting')
KINDS = ('character', 'setting', 'prop')
ORIGINS = ('user', 'agent', 'builtin')
LABELS = schema.LABELS
EDITABLE = ('name', 'description', 'images', 'voice')
Expand All @@ -39,6 +39,7 @@
TURN = dict(aspect='9:16', seconds=6)
CLOSE_UP = dict(aspect='1:1', seconds=4)
SCENE = dict(aspect='16:9', seconds=6)
PROP = dict(aspect='1:1', seconds=4)

# The sheet of every view a shot carries for each person, put together here from the card's own pictures: up to six
# equal cells (three to a row) on a light grey ground, each picture scaled whole and centred, no text.
Expand All @@ -58,6 +59,9 @@ def reference_prompt(card, *, face=False):
who = (card.get('description') or card['name']).strip().rstrip('.')
if card['kind'] == 'setting':
return ('%s. A slow, steady camera pan across the place, no people, sharp focus, no text.' % who)
if card['kind'] == 'prop':
return ('%s. The object alone in the centre of the frame, slowly turning in place, in front of a plain '
'light grey backdrop, even soft light, a static camera, sharp focus, no people, no text.' % who)
if face:
return ('%s. A close-up of the face and shoulders, looking straight at the camera with a neutral '
'expression, in front of a plain light grey studio backdrop. The person stays still. '
Expand Down Expand Up @@ -676,7 +680,7 @@ def generate(self, identity, value=None, *, by='builtin', ref=None, revision=Non
'GET /freevideo/agent/v1/jobs/%s; when it ends, get the card.' % job,
card=view(card), job=job)
if value['face'] and card['kind'] != 'character':
raise spec.SpecError([schema.diagnostic('/face', 'false for a setting card', True)])
raise spec.SpecError([schema.diagnostic('/face', 'false for a setting or prop card', True)])
ready = value.get('source') != 'video' and getattr(self.jobs.port, 'image_ready', lambda: False)()
from ..image_gen.loras import validate_selection
loras = validate_selection(value.get('loras', []))
Expand All @@ -689,12 +693,12 @@ def generate(self, identity, value=None, *, by='builtin', ref=None, revision=Non
seed = secrets.randbelow(spec.MAX_SEED + 1)
token = uuid.uuid4().hex[:12]
if source == 'image':
from ..image_gen.cast_views import VIEWS, SETTING_VIEWS
from ..image_gen.cast_views import VIEWS, SETTING_VIEWS, PROP_VIEWS
# A picture takes seconds, so a character always gets every view, the face close-up included.
views = list(SETTING_VIEWS if card['kind'] == 'setting' else VIEWS)
views = list(SETTING_VIEWS if card['kind'] == 'setting' else PROP_VIEWS if card['kind'] == 'prop' else VIEWS)
items = self._image_items(card, views[:1], seed, loras=loras)
else:
parts = [('turn', SCENE if card['kind'] == 'setting' else TURN, False)]
parts = [('turn', SCENE if card['kind'] == 'setting' else PROP if card['kind'] == 'prop' else TURN, False)]
if value['face']:
parts.append(('face', CLOSE_UP, True))
items = [dict(kind='generate', prompt=reference_prompt(card, face=face), seed=seed,
Expand Down Expand Up @@ -847,7 +851,7 @@ def _land_image(self, identity, generating, hidden, rows):
staged.append((temporary, file))
part = hidden['job_views'][job]
pictures.append(dict(media=dict(file=file), by=generating['by'], from_job=job,
generated=True, label='other' if hidden['kind'] == 'setting' else part))
generated=True, label='other' if hidden['kind'] in ('setting', 'prop') else part))
self._settle(identity, hidden, pictures, failure, staged)
finally:
for out, _ in staged:
Expand Down Expand Up @@ -893,7 +897,7 @@ def _land(self, identity, generating, hidden, rows):
failure = failure or dict(code='internal_error', job=job,
message='The reference pictures could not be saved.')
continue
setting = hidden.get('kind') == 'setting'
setting = hidden.get('kind') in ('setting', 'prop')
for n, row_frame in enumerate(picked):
if row_frame is None:
temporary[n].unlink(missing_ok=True)
Expand Down
2 changes: 1 addition & 1 deletion freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -629,7 +629,7 @@ def connection_text(value):
GUIDE['cast_routes'] = [dict(operation=name, method=method, path=path, description=summary)
for name, (method, path, summary) in CAST_OPERATIONS.items()]
GUIDE['cast'] = [
'A card is a character or setting: name, a one-sentence English description used in prompts, up to six '
'A card is a character, setting or prop: name, a one-sentence English description used in prompts, up to six '
'labelled pictures (front, three_quarter, side, back, face, sheet, other) and an optional 2 to 15 s voice.',
'Changes need the revision you read (body revision or If-Match); stale_revision returns the current card in '
'error.card. Fields the user changed return changed_by_user: keep the user\'s version.',
Expand Down
2 changes: 1 addition & 1 deletion freevideo_engine/agent/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,7 @@ def response_refs(value):
if isinstance(identity, str):
if identity.startswith('d') and 'shots' in value:
refs['drama'] = identity
elif identity.startswith('c') and value.get('kind') in ('character', 'setting'):
elif identity.startswith('c') and value.get('kind') in ('character', 'setting', 'prop'):
refs['cast'] = identity
return refs

Expand Down
8 changes: 4 additions & 4 deletions freevideo_engine/agent/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -702,7 +702,7 @@ def drama_schema(name):


CAST_OPERATIONS = {
'create': ('POST', '/cast', 'Create a character or setting card when no existing card fits'),
'create': ('POST', '/cast', 'Create a character, setting or prop card when no existing card fits'),
'list': ('GET', '/cast', 'Search the cast library before casting; reuse a matching card'),
'get': ('GET', '/cast/{id}', 'Read a card with its pictures, voice and revision'),
'update': ('PATCH', '/cast/{id}', 'Change a card; send the revision you read; fields the user changed stay theirs'),
Expand All @@ -724,7 +724,7 @@ def cast_schema(name, *, local=False):
images=array(obj(dict(media=picture, label=enum(LABELS)), ('media',)), 0, 6),
voice=nullable(obj(dict(media=sound, start=number(0, 3600, default=0), end=number(0, 3600)), ('media',))))
if name == 'create':
return obj(dict(kind=enum(('character', 'setting'), default='character'), **fields, ref=REF), ('name',))
return obj(dict(kind=enum(('character', 'setting', 'prop'), default='character'), **fields, ref=REF), ('name',))
if name == 'update':
fields['images']['items']['properties']['label'] = {'type': 'string'}
return obj(dict(fields, revision=number(1, integer=True), ref=REF))
Expand All @@ -738,7 +738,7 @@ def cast_schema(name, *, local=False):
description='Replace pictures the user did not add; default fills empty slots.'),
revision=number(1, integer=True), ref=REF))
if name == 'list':
return obj(dict(q=string(200), kind=enum(('character', 'setting')), origin=enum(('user', 'agent', 'builtin')),
return obj(dict(q=string(200), kind=enum(('character', 'setting', 'prop')), origin=enum(('user', 'agent', 'builtin')),
production=PRODUCTION, limit=number(1, 200, integer=True, default=50)))
return EMPTY

Expand All @@ -753,7 +753,7 @@ def cast_view():
generated = obj(dict(job=JOB, face_job=nullable(JOB), source=enum(('image', 'video')),
seed=number(0, 2**53 - 1, integer=True), prompt_version=number(1, integer=True)),
('job', 'face_job', 'seed', 'prompt_version'))
return dict(obj(dict(id=CAST, kind=enum(('character', 'setting')), name=string(120),
return dict(obj(dict(id=CAST, kind=enum(('character', 'setting', 'prop')), name=string(120),
description=nullable(string(1000)), revision=number(1, integer=True),
images=array(picture, 0, 6), candidates=array(picture, 0, 6),
generating=nullable(generating), generated=nullable(generated),
Expand Down
16 changes: 12 additions & 4 deletions freevideo_engine/comfy_assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ def resolve_assets(serialized, input_directory):
or media_kind(row['file']) != 'video'):
raise ValueError('depth is only allowed on video references')
# The character an @ mention added the row for; a continued part keeps one picture per character.
if 'cast' in row and (set(row['cast'] if isinstance(row['cast'], dict) else ('?',)) not in ({'id'}, {'id', 'setting'})
if 'cast' in row and (set(row['cast'] if isinstance(row['cast'], dict) else ('?',)) not in ({'id'}, {'id', 'setting'}, {'id', 'prop'})
or not isinstance(row['cast']['id'], str) or not 0 < len(row['cast']['id']) <= 128
or row['cast'].get('setting', True) is not True):
or row['cast'].get('setting', True) is not True or row['cast'].get('prop', True) is not True):
raise ValueError('Invalid media item')
if type(row.get('enabled', True)) is not bool:
raise ValueError('Media enabled must be a boolean')
Expand Down Expand Up @@ -141,6 +141,8 @@ def resolve_assets(serialized, input_directory):
reference['cast'] = cast_id(row) # only a continued part reads it (comfy_media.continued)
if is_setting(row):
reference['scene'] = True
if is_prop(row):
reference['prop'] = True
if 'early_steps' in row:
from .media_request import validate_early_steps
validate_early_steps(dict(row, kind=kind))
Expand Down Expand Up @@ -199,7 +201,7 @@ def continued_assets(serialized):
image = False
if image:
pictures.append(row)
return json.dumps([_kept_row(row) for row in pick_pictures(pictures, cast_id, is_setting)] + original)
return json.dumps([_kept_row(row) for row in pick_pictures(pictures, cast_id, is_setting, is_prop)] + original)


def cast_id(row):
Expand All @@ -215,10 +217,16 @@ def is_setting(row):
return cast_id(row) is not None and cast.get('setting') is True


def is_prop(row):
"""Whether a panel row carries the picture of a prop card (an @ mention of a prop)."""
cast = row.get('cast') if isinstance(row, dict) else None
return cast_id(row) is not None and cast.get('prop') is True


def _kept_row(row):
kept = dict(file=row['file'], role='reference')
if cast_id(row) is not None:
kept['cast'] = dict(id=cast_id(row), **({'setting': True} if is_setting(row) else {}))
kept['cast'] = dict(id=cast_id(row), **({'setting': True} if is_setting(row) else {'prop': True} if is_prop(row) else {}))
return kept


Expand Down
6 changes: 6 additions & 0 deletions freevideo_engine/comfy_launcher_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ def register():
async def launcher(request):
return web.json_response(info)

@server.routes.get('/freevideo/launcher/last-exit')
async def last_exit(request):
"""When the ComfyUI before this one stopped by itself: numbers and fixed words, never log text."""
from .engine_exit import public, read
return web.json_response(dict(exit=public(read(info['engine_root']))), headers={'Cache-Control': 'no-store'})

@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'))
Expand Down
32 changes: 29 additions & 3 deletions freevideo_engine/comfy_launcher_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,7 @@ def __init__(self, source, *, child_environment=None):
self._access_poll_thread = None
self.cancelled = threading.Event()
self.server = None
self._exit_seen = None
self.server_url = None
self.server_address = None
self.access_proxy = None
Expand Down Expand Up @@ -852,13 +853,38 @@ def refresh_access_links(self):
password_set=password_set)
self.state = dict(self.state, lan_status='' if self.access_links else 'no-address', lan_password_set=password_set)

def check_server_exit(self):
"""Record a ComfyUI this launcher started that ended while it was open, and offer to start it again.
Only a process the launcher did not stop itself counts (stop_owned_server clears self.server under
the same lock), and an exit the user made (a normal exit, Ctrl+C) changes nothing. Returns the record or None.
"""
with self._server_lock:
server = self.server
if (server is None or self.state.get('status') != 'open' or self._exit_seen is server
or server.poll() is None):
return None
self._exit_seen = server
code, log = server.returncode, self.server_log
engine = (self.selection or {}).get('engine')
from . import engine_exit
if not engine or engine_exit.by_the_user(code):
return None
value = engine_exit.record(engine, code, log)
self.state = dict(self.state, status='restart-required', engine_exit=engine_exit.public(value))
return value

def check_access_proxy(self):
"""Poll child lifetimes and refresh network links without moving ComfyUI."""
if self.access_proxy is None:
return
if self.server is not None and self.server.poll() is not None:
self._access_stopped()
self.state = dict(self.state, status='ready')
with self._server_lock:
# This check may see the exit before the launcher's own does: record it first.
self.check_server_exit()
self._access_stopped()
# An engine that ended while open keeps its "stopped" notice.
self.state = dict(self.state, status='restart-required' if self.state.get('engine_exit') else 'ready')
elif self.access_proxy.poll() is not None:
self._access_stopped()
elif time.monotonic() - self.access_polled >= 5:
Expand Down Expand Up @@ -1330,7 +1356,7 @@ def _open_access_proxy(self):
def _connect_comfy(self):
retry, self._scope_retry = self._scope_retry, False
self.state = {key: value for key, value in self.state.items()
if key != 'moved_from' and (retry or key != 'plugin_scoped')}
if key not in ('moved_from', 'engine_exit') and (retry or key != 'plugin_scoped')}
if not self.selection or not self.selection['ready']:
raise ValueError('Choose and inspect ComfyUI first')
selected = self.selection
Expand Down
6 changes: 3 additions & 3 deletions freevideo_engine/comfy_media.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ def continued(exported, notes=None):
original = [row for row in media.get('references', []) if row.get('kind') == 'audio' and row.get('use') == 'original']
keyframes = [name for name in ('first', 'last') if media.get(name)]
# Characters added by an @ mention keep one picture each when there are several (pick_pictures).
chosen = pick_pictures(pictures, lambda row: None if row.get('scene') else row.get('cast'),
lambda row: row.get('scene') is True)
chosen = pick_pictures(pictures, lambda row: None if row.get('scene') or row.get('prop') else row.get('cast'),
lambda row: row.get('scene') is True, lambda row: row.get('prop') is True)
candidates = [_plain(row) for row in chosen] + [{'kind': 'image', 'path': media[name]} for name in keyframes]
if candidates or original:
kept['references'] = candidates[:PICTURES_MAX] + original
Expand All @@ -79,7 +79,7 @@ def continued(exported, notes=None):

def _plain(row):
"""A reference row without the panel's character or scene mark, which the engine's request does not carry."""
return {key: value for key, value in row.items() if key not in ('cast', 'scene')}
return {key: value for key, value in row.items() if key not in ('cast', 'scene', 'prop')}


def continued_note(pictures, keyframes, used):
Expand Down
10 changes: 8 additions & 2 deletions freevideo_engine/continuation.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,26 @@
PICTURES_MAX = 3


def pick_pictures(rows, person, scene=None):
def pick_pictures(rows, person, scene=None, prop=None):
"""The pictures a continued part keeps from image rows in panel order: at most PICTURES_MAX.
person(row) is the id of the character a row belongs to, or None. With two or more characters each keeps
only the last of its rows (the face close-up, or the one picture its card has) so that everyone is seen; the
other pictures follow. With none or one character the first rows are kept.
scene(row) is true for the picture of a setting card: the last such row keeps one place, after the others, so the
part still shows the same place."""
part still shows the same place. prop(row) is true for the picture of a prop card: props come after the
characters and the place, so they keep only the positions those leave free, in panel order, before the place."""
rows = list(rows)
place = None
if scene is not None:
places = [row for row in rows if scene(row)]
if places:
place = places[-1]
rows = [row for row in rows if not scene(row)]
things = []
if prop is not None:
things = [row for row in rows if prop(row)]
rows = [row for row in rows if not prop(row)]
last = {}
for index, row in enumerate(rows):
if person(row) is not None:
Expand All @@ -68,6 +73,7 @@ def pick_pictures(rows, person, scene=None):
faces = [rows[index] for index in sorted(last.values())]
others = [row for row in rows if person(row) is None]
chosen = (faces + others)[:room]
chosen = chosen + things[:room - len(chosen)]
return chosen + ([place] if place is not None else [])


Expand Down
Loading