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
10 changes: 6 additions & 4 deletions freevideo_engine/access_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, WSMsgType, client_exceptions, web
from yarl import URL
from . import proxy_identity, lan_password
from .origin_scheme import effective_scheme
from .lan_auth import PasswordAuth, DEVICE_COOKIE, REMEMBER_SECONDS, LOGIN_DELAY

PHONE_WEB = Path(__file__).resolve().parents[1] / 'web'
Expand Down Expand Up @@ -81,17 +82,18 @@ def same_origin(request):
try:
if len(request.headers.getall('Host', [])) != 1 or len(request.headers.getall('Origin', [])) != 1:
return False
host = urlsplit(request.scheme + '://' + request.host)
origin = urlsplit(request.headers['Origin'])
scheme = effective_scheme(request, origin.scheme)
host = urlsplit(scheme + '://' + request.host)
socket = request.transport.get_extra_info('sockname')
allowed = {socket[0].split('%')[0]}
if ipaddress.ip_address(socket[0].split('%')[0]).is_loopback:
allowed.update(('localhost', '127.0.0.1', '::1'))
own_port = host.port or (443 if request.scheme == 'https' else 80)
own_port = host.port or (443 if scheme == 'https' else 80)
return (host.hostname in allowed and own_port == socket[1] and origin.hostname == host.hostname
and (origin.port or (443 if origin.scheme == 'https' else 80)) == own_port
and origin.scheme == request.scheme and not any((origin.path, origin.query, origin.fragment,
origin.username, origin.password)))
and origin.scheme == scheme and not any((origin.path, origin.query, origin.fragment,
origin.username, origin.password)))
except (ValueError, TypeError, IndexError, KeyError):
return False

Expand Down
6 changes: 4 additions & 2 deletions freevideo_engine/agent/access.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from .jobs import JobError
from .. import proxy_identity
from ..origin_scheme import effective_scheme


def peer_address(request):
Expand Down Expand Up @@ -63,8 +64,9 @@ def check(request, *, local=False, lan=False):
if origin is not None:
parsed = urlsplit(origin)
port = parsed.port or (443 if parsed.scheme == 'https' else 80)
own_port = host.port or (443 if scheme == 'https' else 80)
if (parsed.scheme != scheme or parsed.hostname != host.hostname or port != own_port
wanted = scheme if proxied else effective_scheme(request, parsed.scheme)
own_port = host.port or (443 if wanted == 'https' else 80)
if (parsed.scheme != wanted or parsed.hostname != host.hostname or port != own_port
or parsed.path or parsed.query or parsed.fragment or parsed.username or parsed.password):
raise ValueError
except (ValueError, TypeError, IndexError):
Expand Down
71 changes: 71 additions & 0 deletions freevideo_engine/agent/attachments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Chat images in the retained input assets directory, with server-owned filenames."""
import filecmp
import json
import os
from pathlib import Path
import re
import threading
import uuid

from . import media, schema

FOLDER = 'freevideo/agent/assets'
_LOCK = threading.RLock()


def path(input_root, name):
if not isinstance(name, str) or not re.fullmatch(schema.CHAT_ATTACHMENT_NAME['pattern'], name):
raise ValueError('Invalid attachment name')
root = Path(input_root).resolve()
target = root / FOLDER / name
# Reject links, including links to another location inside the input directory.
for part in (target, *target.parents):
if part == root:
break
if part.is_symlink():
raise ValueError('Attachment paths cannot contain links')
if target.resolve() != target:
raise ValueError('Attachment leaves assets')
return target


def _metadata(target):
result = target.with_name('.' + target.name + '.json')
if result.is_symlink():
raise ValueError('Invalid attachment metadata')
return result


def publish(input_root, temporary, name, original_name):
"""Publish decoded bytes atomically; the first filename for identical content wins."""
with _LOCK:
target = path(input_root, name)
target.parent.mkdir(parents=True, exist_ok=True)
metadata = _metadata(target)
if not metadata.exists():
pending = metadata.with_name('.attachment-' + uuid.uuid4().hex + '.tmp')
try:
pending.write_text(json.dumps(dict(original_name=original_name), ensure_ascii=False), encoding='utf-8')
os.replace(pending, metadata)
finally:
pending.unlink(missing_ok=True)
# Identical bytes are already in place; replacing a file the page is reading fails on Windows.
if target.is_file() and filecmp.cmp(temporary, target, shallow=False):
Path(temporary).unlink(missing_ok=True)
else:
os.replace(temporary, target)
return json.loads(metadata.read_text(encoding='utf-8'))['original_name']


def read(input_root, name):
"""Reinspect stored bytes; never use dimensions or filenames sent with a message."""
with _LOCK:
target = path(input_root, name)
if not target.is_file() or target.stat().st_size > schema.MAX_CHAT_ATTACHMENT_BYTES:
raise ValueError('No saved attachment within the upload limit')
info, suffix = media.validate_upload(target)
if suffix not in ('.png', '.jpg', '.webp') or info['kind'] != 'image':
raise ValueError('Not a chat image')
metadata = _metadata(target)
original_name = json.loads(metadata.read_text(encoding='utf-8'))['original_name'] if metadata.exists() else name
return dict(name=name, original_name=original_name, width=info['width'], height=info['height'])
3 changes: 3 additions & 0 deletions freevideo_engine/agent/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
'itself; read the project afterwards and check that one clip covers the whole film.',
],
'references': [
"User message attachments are reference images by default. Use each name directly in media.references, media.first or media.last according to the user's words; if the intended role is unclear, ask one question. Text can be empty when a message has attachments.",
'Tags are numbered per kind in reference order: <Picture 1>, <Picture 2>, <Video 1>, <Audio 1>. '
'For keyframes the first frame is <Picture 1> and the last frame the next picture.',
'Name subjects <Subject 1>, <Subject 2> in subject_definitions and reuse those labels in every section.',
Expand Down Expand Up @@ -211,6 +212,7 @@

# How an agent is expected to work once a user has asked for something. Only what this version can honour.
RULES = [
"User message attachments are reference images by default. Use each name directly in media.references, media.first or media.last according to the user's words; if the intended role is unclear, ask one question. Text can be empty when a message has attachments.",
'Finish the job. When the user asks for a video, keep going until the finished video exists. Decide ordinary '
'things yourself: how to split shots, how many takes, which take to keep. Tell the user what you decided in '
'one or two sentences at the end, not along the way. Name every video you submit: label is a short name of what '
Expand Down Expand Up @@ -259,6 +261,7 @@


RULES_ZH = [
'用户消息里的 attachments 默认当参考图。按用户的话把每张图的 name 直接用在 media.references、media.first 或 media.last;拿不准用途时问一句。有附件的消息 text 可以为空串。',
'把任务做完。用户要求生成视频时,持续推进,直到成片生成。如何拆分镜头、拍几条、选哪一条等普通决定,'
'由你自己做。最后用一两句话告诉用户你做了哪些决定,不要每做一步都请用户决定。'
'提交的每条视频都起个名字:label 写拍的是什么,用用户的语言,2 到 8 个字,例如“清晨海边”。',
Expand Down
105 changes: 93 additions & 12 deletions freevideo_engine/agent/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import uuid

from . import API_VERSION, enabled
from . import assets
from . import assets, attachments
from . import private_file
from . import spec as specs
from .. import tiers as quality_tiers
Expand Down Expand Up @@ -433,6 +433,7 @@ def reply(value, status=200):
connection = dict(connected=False, client=None, via=None, last_seen_at=None)
# The conversation in the Autopilot view: the page and the connected agent talk in the agent's latest session.
chat = dict(client=None, waiting=0, folds={}, told={})
chat_writes = asyncio.Lock()
server._freevideo_agent_connection = connection

def problem(code, message=None, status=None, **details):
Expand Down Expand Up @@ -473,7 +474,12 @@ async def respond(request):
valid = definition.get('lan') and proxy_identity.check_csrf(request)
else:
valid = valid_page_session(request)
if not valid:
elif definition.get('page_cookie'):
session = page_sessions.get(request.cookies.get('freevideo_agent_page', ''))
valid = session is not None and session[1] >= time.monotonic()
else:
valid = True
if not valid:
raise JobError('page_session_required', 'Page session required')
header = request.headers.get('Authorization', '')
given = header[7:].strip() if header[:7].lower() == 'bearer ' else request.headers.get('X-FreeVideo-Agent', '')
Expand Down Expand Up @@ -522,7 +528,7 @@ async def respond(request):
return await handler(request)
except specs.SpecError as error:
value = errors.validation(error.errors, getattr(error, 'code', 'invalid_request'))
return reply({'error': value}, errors.ERRORS[value['code']][0])
return reply({'error': value}, getattr(error, 'status', errors.ERRORS[value['code']][0]))
except JobError as error:
value = error.view()
if value.get('next') and request.match_info.get('id'):
Expand Down Expand Up @@ -888,7 +894,8 @@ def chat_fold(store, sid):
fold['seq'] = row['seq']
base = dict(seq=row['seq'], at=row['at'], type=row['type'])
if row['type'] == 'message':
fold['items'].append(dict(base, role='user' if row['by'] == 'user' else 'agent', text=row['text']))
fold['items'].append(dict(base, role='user' if row['by'] == 'user' else 'agent', text=row['text'],
**({'attachments': row['attachments']} if row.get('attachments') else {})))
if row['by'] == 'user':
fold['said'] = row['text']
if row.get('mid'):
Expand Down Expand Up @@ -1017,6 +1024,75 @@ def read():
active=sum(1 for r in rows if r['status'] in ACTIVE))
return reply(await asyncio.to_thread(read))

def chat_not_connected():
return reply({'error': dict(code='not_connected', message='No agent is connected.', retryable=True,
error_zh='尚未连接 Agent', error_en='No agent is connected.',
details=[], next='Connect an agent in the Autopilot view, then say it again.',
request_id=errors.request_id())}, 409)

@routes.post('/freevideo/agent/chat/attach')
@guarded
async def chat_attach(request):
from . import media
if work_sessions() is None or chat['client'] is None:
return chat_not_connected()
limit = contract.MAX_CHAT_ATTACHMENT_BYTES
if request.content_length is not None and request.content_length > limit:
raise JobError('too_large', 'Upload exceeds limit', limit_bytes=limit)
with tempfile.TemporaryDirectory(prefix='.chat-attach-', dir=port.input_root) as temporary:
# The suffix selects the image decoder; Pillow recognizes the actual content format.
target = Path(temporary) / 'image.png'
size, digest = 0, hashlib.sha256()
with target.open('xb') as output:
while True:
chunk = await request.content.read(min(256 * 1024, limit - size + 1))
if not chunk:
break
size += len(chunk)
if size > limit:
raise JobError('too_large', 'Upload exceeds limit', limit_bytes=limit)
output.write(chunk)
digest.update(chunk)
async with upload_decoders:
try:
info, suffix = await asyncio.to_thread(media.validate_upload, target)
if suffix not in ('.png', '.jpg', '.webp'):
raise ValueError('Only PNG, JPEG and WebP chat images are supported')
except (OSError, ValueError, SyntaxError):
raise JobError('invalid_media', 'Image cannot be decoded within the upload limits') from None
name = digest.hexdigest() + suffix
original = await asyncio.to_thread(attachments.publish, port.input_root, target, name, request[query_key]['name'])
return reply(dict(name=name, original_name=original, width=info['width'], height=info['height'], bytes=size), 201)

@routes.get('/freevideo/agent/chat/attachment')
@guarded
async def chat_attachment(request):
name = request[query_key]['name']
try:
path = attachments.path(port.input_root, name)
async with upload_decoders:
await asyncio.to_thread(attachments.read, port.input_root, name)
except (OSError, ValueError, SyntaxError):
raise JobError('not_found', 'No saved chat image') from None
kind = {'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp'}[path.suffix]
return web.FileResponse(path, headers={'Content-Type': kind, 'Cache-Control': 'private, no-store',
'X-Content-Type-Options': 'nosniff'})

def chat_attachments(values):
result, seen = [], set()
for i, item in enumerate(values):
field = '/attachments/%d/name' % i
if item['name'] in seen:
raise specs.SpecError(contract.diagnostic(field, 'a distinct attachment name', item['name']))
seen.add(item['name'])
try:
result.append(attachments.read(port.input_root, item['name']))
except (OSError, ValueError, SyntaxError):
error = specs.SpecError(contract.diagnostic(field, 'an existing uploaded image', item['name']))
error.status = 400
raise error from None
return result

@routes.post('/freevideo/agent/chat')
@guarded
async def chat_say(request):
Expand All @@ -1026,17 +1102,22 @@ def write():
if store is None or client is None:
return None
before = store.current_for(client)
sid = chat_session(store, value.get('session')) or store.create(client=client, by='user')['id']
fold = chat_fold(store, sid)
if value['id'] in fold['sent']:
sid = chat_session(store, value.get('session'))
fold = chat_fold(store, sid) if sid else None
if fold is not None and value['id'] in fold['sent']:
seq = fold['sent'][value['id']]
first = next(i for i in fold['items'] if i['seq'] == seq)
return 200, dict(seq=seq, id=value['id'], at=first['at'], session=sid,
read=seq <= store.read_until(sid, client), agent=agent_state())
pictures = chat_attachments(value.get('attachments', []))
sid = sid or store.create(client=client, by='user')['id']
fold = fold or chat_fold(store, sid)
if store.stopped(sid):
# Speaking again continues a conversation the user stopped earlier.
store.append(sid, dict(type='control', action='resumed'), by='user')
message = dict(type='message', text=value['text'], mid=value['id'])
if pictures:
message['attachments'] = pictures
if value.get('whitebox'):
message['whitebox'] = True
row = store.append(sid, message, by='user')
Expand All @@ -1046,13 +1127,11 @@ def write():
fold['ids'].pop(fold['sent'].pop(next(iter(fold['sent']))), None)
chat_moved(store, before)
return 201, dict(seq=row['seq'], id=value['id'], at=row['at'], session=sid, read=False, agent=agent_state())
done = await asyncio.to_thread(write)
async with chat_writes, upload_decoders:
done = await asyncio.to_thread(write)
if done is None:
# Nothing is kept: no agent has connected since FreeVideo started, so nobody would read it.
return reply({'error': dict(code='not_connected', message='No agent is connected.', retryable=True,
error_zh='尚未连接 Agent', error_en='No agent is connected.',
details=[], next='Connect an agent in the Autopilot view, then say it again.',
request_id=errors.request_id())}, 409)
return chat_not_connected()
return reply(done[1], done[0])

@routes.post('/freevideo/agent/chat/abandon')
Expand Down Expand Up @@ -1520,6 +1599,8 @@ async def session_post(request):
value['text'] = chat_text(value['text'])
client = session_client(request, by)
def write():
if value.get('attachments'):
value['attachments'] = chat_attachments(value['attachments'])
row = sessions.append(sid, value, by=by, client=client)
here = sessions.current_for(client) if client and value['type'] == 'message' else None
if here not in (None, sid):
Expand Down
Loading