Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
8528e1c
[feat] web: create Director Stage nodes that save and reopen
MrDongsls Nov 12, 2026
11e8b2a
[feat] contracts: imported stage assets pin registered file identity
MrDongsls Nov 12, 2026
b87df1b
[feat] web: import GLB and self-contained GLTF into the Director Stage
MrDongsls Nov 12, 2026
2e4f5e2
[feat] contracts: probe stage model bytes and pin self-contained imports
MrDongsls Nov 13, 2026
f1d1db4
[feat] web: probe stage imports in the browser and drive imported ani…
MrDongsls Nov 13, 2026
282d4fd
[bugfix] web: rename the stage export snapshot that shadowed the stil…
MrDongsls Nov 13, 2026
879b45a
[bugfix] web: restore the rest pose when leaving an imported motion clip
MrDongsls Nov 13, 2026
b8ba4bf
[bugfix] web: report corrupt GLB JSON instead of claiming external re…
MrDongsls Nov 13, 2026
0eff527
[bugfix] web: keep stage upload titles valid for freely named objects
MrDongsls Nov 13, 2026
9300d9b
[bugfix] contracts: bound stage model probing to the import byte limit
MrDongsls Nov 13, 2026
5d35292
[bugfix] web: share one in-flight stage asset load across effect runs
MrDongsls Nov 13, 2026
1fcf15e
[bugfix] contracts: verify stage animation metadata against pinned bytes
MrDongsls Nov 13, 2026
4c5eb6d
[bugfix] web: reject stage JSON imports that pin foreign project assets
MrDongsls Nov 13, 2026
94b454b
[test] stage: cover truncated imports and unavailable pinned media
MrDongsls Nov 13, 2026
b8299f4
[bugfix] contracts: deliver stage object validation errors bilingually
MrDongsls Nov 13, 2026
d616107
[bugfix] contracts: deliver pinned-reference rejection bilingually an…
MrDongsls Nov 13, 2026
c124800
[bugfix] web: reject compressed stage models and localize import boun…
MrDongsls Nov 14, 2026
92dbe1e
[bugfix] web: own stage numeric text, playhead addressing, and camera…
MrDongsls Nov 14, 2026
3e7188d
[feat] web: region-scoped stage scrolling with a centered frame
MrDongsls Nov 14, 2026
2fd9bcf
[feat] contracts: director documents pin a captured still asset
MrDongsls Nov 13, 2026
9851e9c
[feat] web: register stage stills and preview MP4s as project assets
MrDongsls Nov 13, 2026
3e37c2e
[bugfix] web: harden stage stills, exports, and imported model resources
MrDongsls Nov 13, 2026
6ed0486
[bugfix] contracts: release refused editor deliveries instead of repl…
MrDongsls Nov 14, 2026
5b92874
[feat] web: stage preview outputs keep only local video tools
MrDongsls Nov 14, 2026
2bd41d3
[feat] agent: publish stage and timeline outputs through shared commands
MrDongsls Nov 13, 2026
cbc76ec
[test] ci: run the director stage entry check in both locales
MrDongsls Nov 15, 2026
56a1d93
[test] ci: promote the director stage entry check to the gate lane
MrDongsls Nov 15, 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
6 changes: 6 additions & 0 deletions docs/development/validation.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,12 @@ CREATION_WEB_URL=http://127.0.0.1:5179 CREATION_API_URL=http://127.0.0.1:8019 \

```sh
node scripts/verify_creation_browser.mjs # UI/API 主流程与重载
node tests/browser/director_stage_entry.mjs # 导演台创建、GLB 导入、关键帧、撤销、重做与重开(默认 en-US)
DIRECTOR_STAGE_LOCALE=zh-CN node tests/browser/director_stage_entry.mjs # 同一导演台与 GLB 流程走中文界面
node tests/browser/director_stage_animation.mjs # 动画 GLB 导入、播放头采样与 MP4 同源合成
node tests/browser/director_stage_output.mjs # 定格与 MP4 登记为工程资产并被时间线引用
CREATION_DEVICE_SCALE_FACTOR=2 node tests/browser/director_stage_output.mjs # 同一流程在高 DPI 下复验定格尺寸
node tests/browser/director_stage_delivery.mjs # 被后端明确拒绝的存储投递只损失一次尝试,随后重规划恢复渲染与应用
node tests/browser/reference_reconnect.mjs # 参考连线删除后重连
node tests/browser/context_menu_dismissal.mjs # 右键菜单关闭
node tests/browser/mp4_export.mjs # 合成 MP4(WebCodecs + 软件 AAC)
Expand Down
27 changes: 24 additions & 3 deletions packages/core/src/opendirector/agent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
from opendirector.installation import SOURCE_ROOT, BUNDLED

ROOT = SOURCE_ROOT or BUNDLED
# File registration goes through artifact.publish with the turn's execution
# directory; the HTTP upload staging behind asset.import has no tool path.
EXCLUDED = {'project.create', 'project.trash', 'project.restore', 'asset.import', 'job.checkpoint',
'canvas.applyEdit', 'canvas.publishMedia', 'timeline.publishRender'}
'canvas.applyEdit'}

QUERY_MODELS = get_args(get_args(Query)[0])
#: The legal `type` values, read from the union itself. A new query type cannot
Expand Down Expand Up @@ -204,7 +206,27 @@ def minimal_value(node: dict, branch: str | None = None) -> JsonValue:
item['kind'] = spec['kind']
item['binding'] = {'role': 'generation', 'objectRef': {'id': spec['id']}}
model = COMMANDS[command]
parsed_payload = model.model_fields['payload'].annotation.model_validate(example['payload'])
payload_model = model.model_fields['payload'].annotation
# Some commands accept only some editor document kinds; walk the
# document union branches until one validates instead of assuming
# the timeline default from minimal_value.
candidates = [example['payload']]
document_node = payload.get('properties', {}).get('document')
if document_node is not None:
for key, choice in branches(resolve(document_node)).items():
if key != 'timeline':
candidates.append(
{**example['payload'], 'document': minimal_value(resolve(choice))})
parsed_payload = None
for candidate in candidates:
try:
parsed_payload = payload_model.model_validate(candidate)
example['payload'] = candidate
break
except ValidationError:
continue
if parsed_payload is None:
parsed_payload = payload_model.model_validate(candidates[0])
sample = model.model_construct(type=command, payload=parsed_payload, projectId=project_id)
versions = sample.required_versions()
if versions:
Expand Down Expand Up @@ -429,4 +451,3 @@ def narrow(value: JsonValue) -> None:
else:
receipt = await asyncio.to_thread(self.projects.execute, command, session=session, source='agent')
return receipt.model_dump(mode='json')

Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,9 @@ def _publish_render(self, data, payload):
doc = next(d for d in snapshot.editorDocuments if d.id == payload.editorId)
self._validate_timeline(doc, snapshot)
output = self.assets._reference(snapshot, payload.asset)
if output.kind != 'video' or output.provenance.source != 'upload' or any(r.assetId == output.id for r in asset_references(snapshot)):
# Browser sessions upload renders; agents register them through
# artifact.publish. Either way the render must be fresh and unused.
if output.kind != 'video' or output.provenance.source not in {'upload', 'agent_written'} or any(r.assetId == output.id for r in asset_references(snapshot)):
raise ContractError('role_restricted', '合成输出必须是尚未发布的视频素材。', output.id)
if any(r.assetId == output.id for record in self.projects.history(snapshot.project.id) for r in asset_references(record.snapshot)):
raise ContractError('role_restricted', '合成输出已有历史用途,不能改写来源。', output.id)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ def publish_media(service, data, payload):
service._available(service.assets._reference(snapshot, ref))
for output in payload.outputs:
asset = service.assets._reference(snapshot, output.asset)
if asset.kind not in allowed or asset.provenance.source != 'upload' or asset.id in used | historical:
# Uploads come from the browser session; agents register the same
# deliverables through artifact.publish. The invariant is freshness.
if asset.kind not in allowed or asset.provenance.source not in {'upload', 'agent_written'} or asset.id in used | historical:
raise ContractError('role_restricted', '输出必须是尚未发布且类型匹配的真实素材。', asset.id)
service._available(asset)
if doc.kind == 'director' and (asset.metadata.durationSeconds is None or abs(asset.metadata.durationSeconds - doc.data.duration) > .15):
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/opendirector/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
"generation": "Submit a configured generation task.",
"result": "Select a generated result for adoption.",
"editor": "Create and save editable media documents.",
"timeline": "Discover resources for editable timelines.",
"timeline": "Discover timeline resources and publish rendered timeline results as project media.",
"history": "Undo or redo reversible project operations.",
"creation": "Apply multiple project operations atomically.",
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/opendirector/domain/creation/assets.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ class MediaMetadata(Contract):
hasAudio: bool | None = Field(default=None, description='Whether the media contains an audio stream.')
sampleRate: PositiveInt | None = Field(default=None, description='Audio samples per second.')
channels: PositiveInt | None = Field(default=None, description='Audio channel count.')
selfContained: bool | None = Field(default=None, description='Whether a model container embeds all its buffers and images.')
animationNames: tuple[str, ...] | None = Field(default=None, description='Animation clip names declared by a parsed model container, in document order.')

@model_validator(mode='after')
def dimensions(self):
Expand Down
40 changes: 39 additions & 1 deletion packages/core/src/opendirector/domain/creation/editors.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,11 +378,48 @@ class MotionClip(Contract):

class StageObject(StageTransform):
id: Identifier = Field(description='Stable object ID.')
kind: Literal['person', 'box', 'sphere'] = Field(description='Business object or media type.')
kind: Literal['person', 'box', 'sphere', 'gltf'] = Field(description='Business object or media type.')
name: str = Field(max_length=255, description='User-visible name.')
color: Color = Field(description='Serialized display color.')
keyframes: tuple[TransformFrame, ...] = Field(default=(), description='Ordered animation keyframes.')
motionClips: tuple[MotionClip, ...] = Field(default=(), description='Ordered procedural motion intervals.')
asset: AssetRef | None = Field(default=None, description='Registered GLB or self-contained GLTF asset for an imported stage object.')
role: Literal['character', 'scene'] | None = Field(default=None, description='Imported stage object role.')
format: Literal['glb', 'gltf'] | None = Field(default=None, description='Imported stage asset container format.')
animationNames: tuple[str, ...] = Field(default=(), description='Animation clip names discovered in the imported asset.')
hasAnimation: bool = Field(default=False, description='Whether the imported asset contains animation clips.')

@model_validator(mode='after')
def imported_asset(self):
imported_fields = (self.asset, self.role, self.format)
if self.animationNames and any(not name.strip() for name in self.animationNames):
raise ValueError('Imported animation names cannot be blank. 导入的动画名称不能为空。')
if self.kind == 'gltf':
if self.asset is None or self.role is None or self.format is None:
raise ValueError(
'Imported stage objects require a registered asset, role and format. '
'导入的舞台对象需要已注册的素材、角色和格式。'
)
if self.asset.kind != 'file':
raise ValueError('Imported stage assets must be file assets. 导入的舞台素材必须是文件素材。')
if self.role == 'scene' and self.motionClips:
raise ValueError('Scene assets cannot have character motion clips. 场景素材不能携带角色动作片段。')
if not self.hasAnimation and self.motionClips:
raise ValueError(
'Imported characters without animation cannot have motion clips. '
'没有动画数据的导入人物不能携带动作片段。'
)
if self.hasAnimation != bool(self.animationNames):
raise ValueError(
'Imported animation metadata must match animation names. '
'导入的动画信息必须与动画名称一致。'
)
elif any(value is not None for value in imported_fields) or self.animationNames or self.hasAnimation:
raise ValueError(
'Procedural stage objects cannot carry imported asset metadata. '
'非导入的舞台对象不能携带导入素材信息。'
)
return self


class StageCamera(Contract):
Expand Down Expand Up @@ -443,6 +480,7 @@ class DirectorData(Contract):
cameraSource: Literal['camera', 'storyboard'] = Field(default='camera', description='Camera selection strategy.')
shots: tuple[StageShot, ...] = Field(default=(), description='Ordered camera shot intervals.')
objects: tuple[StageObject, ...] = Field(default=(), description='Editable stage objects.')
still: AssetRef | None = Field(default=None, description='Captured still frame pinned as a project image asset.')

@model_validator(mode='after')
def stage_relations(self):
Expand Down
19 changes: 17 additions & 2 deletions packages/core/src/opendirector/domain/creation/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
from .assets import Asset, AssetRef
from .base import Contract, NonNegative, ObjectVersion, Point, ProjectEntity, Revision, unique
from .canvas import CanvasItem, EditorBinding, GenerationBinding, MaterialBinding, MediaCanvasItem
from .editors import EditorDocument, MediaEditDocument, LayerDocument, StackDocument, SubjectDocument, TimelineDocument
from .editors import DirectorDocument, EditorDocument, MediaEditDocument, LayerDocument, StackDocument, SubjectDocument, TimelineDocument
from .generation import FollowingReference, GenerationSpec, Job, PromptReference, Reference, ResultVersion
from ..rule_facts import TIMELINE_DURATION_EPSILON

STAGE_MODEL_MIME = {'glb': 'model/gltf-binary', 'gltf': 'model/gltf+json'}


class Project(Contract):
id: UUID = Field(description='Stable object ID.')
Expand Down Expand Up @@ -81,7 +83,7 @@ def project_relations(self):
for ref in asset_references(objects):
asset = assets.get(ref.assetId)
if asset is None or ref.revision > asset.revision or ref.fingerprint != asset.fingerprint or ref.kind != asset.kind:
raise ValueError('Missing or inconsistent pinned asset reference')
raise ValueError('Missing or inconsistent pinned asset reference. 缺失或不一致的固定素材引用。')
parents = {}
for item in self.canvasItems:
if item.parentId:
Expand Down Expand Up @@ -208,6 +210,19 @@ def project_relations(self):
duration = asset.metadata.durationSeconds
if asset.kind != 'image' and duration is not None and clip.outPoint > duration + TIMELINE_DURATION_EPSILON:
raise ValueError('时间线片段超过素材的实际时长。')
if isinstance(doc, DirectorDocument):
for obj in doc.data.objects:
if obj.kind == 'gltf':
asset = assets[obj.asset.assetId]
if asset.metadata.mimeType != STAGE_MODEL_MIME[obj.format] or asset.metadata.selfContained is not True:
raise ValueError('Imported stage objects require a matching self-contained GLB or GLTF asset. 导入的舞台对象需要匹配的自包含 GLB 或 GLTF 素材。')
Comment on lines +217 to +218

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce stage decoder support at the backend boundary

An agent can register a self-contained model requiring Draco, meshopt, or KTX2 and attach it to a director document: inspect_file records the matching MIME type and selfContained=True without checking decoder requirements, and this validator accepts it. The browser's importAsset explicitly rejects these models, while loadStageAsset constructs a GLTFLoader without those decoders, so the accepted agent-created stage cannot load its pinned model in the UI. Enforce the supported decoder requirements through shared backend probing and validation rather than only through the browser import precheck.

AGENTS.md reference: AGENTS.md:L61-L62

Useful? React with 👍 / 👎.

# Animation metadata is attested by the client; verify it
# against the names the pinned bytes actually declare.
names = asset.metadata.animationNames or ()
if tuple(obj.animationNames) != names or obj.hasAnimation != bool(names):
raise ValueError('Imported animation metadata must match the pinned asset bytes. 导入的动画信息必须与固定素材的实际字节一致。')
if doc.data.still is not None and assets[doc.data.still.assetId].kind != 'image':
raise ValueError('Captured stage stills must pin image assets. 舞台定格必须固定为图片素材。')
return self


Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Project/session-scoped streaming staging; no project facts before commit."""
import hashlib
import json
import os
from pathlib import Path
import tempfile
Expand All @@ -14,7 +15,7 @@
from opendirector.domain.creation.base import Contract, Fingerprint, MediaKind
from opendirector.domain.projects import ProjectError
from opendirector.infrastructure.creation_files import file_identity, upload_directory
from opendirector.ports.files import MAX_CREATION_ASSET_BYTES, MAX_CREATION_TEXT_BYTES
from opendirector.ports.files import MAX_CREATION_ASSET_BYTES, MAX_STAGE_MODEL_BYTES, MAX_CREATION_TEXT_BYTES
from opendirector.infrastructure.creation_projects import directory, durable_json, sync_directory
from opendirector.infrastructure.local_projects import child, read_json
from opendirector.infrastructure.rename import rename_path
Expand All @@ -30,10 +31,30 @@ def asset_name(value):
return value


STAGE_MODEL_TOO_LARGE = '舞台模型文件应不超过 100 MiB,与浏览器导入上限一致。Stage model files must not exceed 100 MiB, matching the browser import limit.'


def upload_asset_id(project_id, handle):
return uuid5(UUID(str(project_id)), 'upload:' + str(handle))


def _has_external_gltf_resources(document):
entries = [entry for key in ('buffers', 'images') if isinstance(document.get(key), list) for entry in document[key]]
return any(isinstance(entry, dict) and isinstance(entry.get('uri'), str) and not entry['uri'].startswith('data:')
for entry in entries)


def _gltf_animation_names(document):
"""Names must match what a browser loader derives: declared names
trimmed, unnamed clips falling back to animation_{index}."""
animations = document.get('animations')
if not isinstance(animations, list):
return ()
return tuple((entry.get('name') if isinstance(entry, dict) and isinstance(entry.get('name'), str) else '').strip()
or f'animation_{index}'
for index, entry in enumerate(animations))


class UploadOwner(Contract):
projectId: UUID = Field(description='Owning project ID.')
id: UUID = Field(description='Stable object ID.')
Expand Down Expand Up @@ -69,7 +90,9 @@ def inspect_file(path: Path, name: str, *, expected_kind=None):
raise ProjectError('文件包含非文本内容,请导入 UTF-8 文本。')
return 'text', MediaMetadata(size=size, mimeType='text/markdown' if suffix == '.md' else 'text/plain')
with path.open('rb') as stream:
prefix = stream.read(16)
# Image and container magic detection needs only the first 12 bytes;
# glTF JSON heads behind longer whitespace are streamed below.
prefix = stream.read(4096)
image_bytes = (prefix.startswith((b'\x89PNG\r\n\x1a\n', b'\xff\xd8\xff', b'GIF87a', b'GIF89a'))
or prefix.startswith(b'RIFF') and prefix[8:12] == b'WEBP' or heif_type(prefix))
if suffix in {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.heic', '.heif'} or image_bytes or expected_kind == 'image':
Expand Down Expand Up @@ -103,6 +126,55 @@ def inspect_file(path: Path, name: str, *, expected_kind=None):
return kind, MediaMetadata(size=size, mimeType=mime, **value)
except ValueError:
raise ProjectError('媒体尺寸、时长或音频信息无效,请检查文件。') from None
if prefix.startswith(b'glTF') and size >= 12:
if size > MAX_STAGE_MODEL_BYTES:
raise ProjectError(STAGE_MODEL_TOO_LARGE, 413)
version, total = int.from_bytes(prefix[4:8], 'little'), int.from_bytes(prefix[8:12], 'little')
if version == 2 and 12 <= total <= size:
document = None
with path.open('rb') as stream:
stream.seek(12)
while len(header := stream.read(8)) == 8:
length = int.from_bytes(header[:4], 'little')
payload = stream.read(length)
if len(payload) < length:
break
if header[4:] == b'JSON':
document = payload
break
try:
parsed = json.loads(document) if document is not None else None
except (ValueError, RecursionError):
parsed = None
self_contained = (isinstance(parsed, dict) and isinstance(parsed.get('asset'), dict)
and 'version' in parsed['asset'] and not _has_external_gltf_resources(parsed))
return 'file', MediaMetadata(size=size, mimeType='model/gltf-binary', selfContained=self_contained,
animationNames=_gltf_animation_names(parsed) if isinstance(parsed, dict) else None)
# Stage models are probed as whole JSON documents, so oversized files are
# rejected up front instead of being fully loaded into memory; oversized
# JSON without a .gltf suffix stays a generic file.
# A JSON head may sit behind any amount of leading whitespace, matching
# what a browser JSON.parse accepts; only files small enough to be stage
# models are worth scanning.
json_head = prefix.lstrip()[:1] == b'{'
if not json_head and not prefix.strip() and size <= MAX_STAGE_MODEL_BYTES:
with path.open('rb') as stream:
while chunk := stream.read(4096):
stripped = chunk.lstrip()
if stripped:
json_head = stripped[:1] == b'{'
break
if size <= MAX_STAGE_MODEL_BYTES and (json_head or suffix == '.gltf'):
try:
parsed = json.loads(path.read_text(encoding='utf-8-sig'))
except (UnicodeDecodeError, ValueError, RecursionError):
parsed = None
if isinstance(parsed, dict) and isinstance(parsed.get('asset'), dict) and 'version' in parsed['asset']:
return 'file', MediaMetadata(size=size, mimeType='model/gltf+json',
selfContained=not _has_external_gltf_resources(parsed),
animationNames=_gltf_animation_names(parsed))
elif suffix == '.gltf':
raise ProjectError(STAGE_MODEL_TOO_LARGE, 413)
with path.open('rb') as stream:
mime = 'application/pdf' if stream.read(5) == b'%PDF-' else 'application/octet-stream'
return 'file', MediaMetadata(size=size, mimeType=mime)
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/opendirector/ports/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
CHUNK_BYTES = 1024 * 1024
MAX_CREATION_ASSET_BYTES = 512 * 1024 * 1024
MAX_CREATION_TEXT_BYTES = 1024 * 1024
MAX_STAGE_MODEL_BYTES = 100 * 1024 * 1024


class AssetFileWrite(Contract):
Expand Down
Loading
Loading