diff --git a/README.md b/README.md index a6f6560f..9a43f262 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Use it to explore visual ideas, refine assets, and make short films. Projects st - **Timeline editing**: Arrange assets, adjust start and end points, speed, and volume, then preview and render an MP4. You can also export JSON, FCPXML, and asset bundles. - **Local projects, your choice of models**: The canvas saves automatically to your machine. Configure multiple models for a service and switch between them as needed. -If image generation repeatedly times out or disconnects through a system proxy, open **Settings → Model connections → Edit service → Advanced connection** and set **Network connection mode** to **Direct connection**. This affects that service's model requests and result downloads without changing the system proxy. Check the provider's records before starting another attempt when request acceptance is unknown. +If image generation repeatedly times out or disconnects through a system proxy, open **Settings → Model connections → Edit service → Advanced connection** and set **Network connection mode** to **Direct (no proxy)**. This affects that service's model requests and result downloads without changing the system proxy. Check the provider's records before starting another attempt when request acceptance is unknown. ## Example film: We Can Still Win diff --git a/README.zh-CN.md b/README.zh-CN.md index 0240e7ca..c45b4ce0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -34,7 +34,7 @@ OpenDirector 是一个在本地运行、通过浏览器使用的 AI 视频创作 - **时间线编排**:安排素材顺序、起止点、速度和音量,预览并合成 MP4,也可导出 JSON、FCPXML 和素材包。 - **本地项目,自选模型**:画布自动保存到本机,同一服务可配置多个模型,按用途切换。 -若图片生成经系统代理反复超时或连接中断,可在「设置 → 模型接入 → 编辑服务 → 高级连接」将「网络连接方式」设为「直连」。此设置仅影响该服务的模型请求和结果下载,不修改系统代理。提交结果不明的任务应先核对供应商记录,再决定是否新建尝试。 +若图片生成经系统代理反复超时或连接中断,可在「设置 → 模型接入 → 编辑服务 → 高级连接」将「网络连接方式」设为「直连(不使用代理)」。此设置仅影响该服务的模型请求和结果下载,不修改系统代理。提交结果不明的任务应先核对供应商记录,再决定是否新建尝试。 ## 短片示例:《我们还能赢》 diff --git a/apps/api/src/opendirector_api/localization.py b/apps/api/src/opendirector_api/localization.py index e4d1466e..d60daa19 100644 --- a/apps/api/src/opendirector_api/localization.py +++ b/apps/api/src/opendirector_api/localization.py @@ -9,6 +9,8 @@ from opendirector.domain.creation.commands import CommandReceipt ENGLISH_BY_CODE = { + 'tool_concept_translation': 'Use a short English subject description, or add a chat model in Models to translate it automatically.', + 'tool_no_video_match': 'The requested subject was not found in the video. Try a different description.', 'voice_upload_type': 'Upload the voice recording using multipart/form-data.', 'voice_upload_form': 'Check the recording, form fields and text lengths, then try the upload again.', 'voice_model_unsupported': 'This checkpoint does not accept uploaded voices. Choose a Base model for voice cloning or a compatible built-in voice.', diff --git a/docs/development/lessons.md b/docs/development/lessons.md index 6b62f4f0..a89af92a 100644 --- a/docs/development/lessons.md +++ b/docs/development/lessons.md @@ -23,3 +23,9 @@ When work exposes a consequential risk or a recurring problem, add a lesson or u - **Trigger:** A relevant test fails and comparison shows that it also fails on the baseline. - **Lesson:** Commits `28d61dce` and `0a5630ac` each recorded the same generation test failing on the baseline. That historical record does not establish whether the test still fails today. - **Check:** Record the versions and results used in the comparison. Link an existing issue or create one when appropriate. Report the check as failed; a baseline failure is not a pass. + +## Provider result URLs are an independent trust boundary + +- **Trigger:** Accepting a cross-origin result URL from a configured model service. +- **Lesson:** [PR #210](https://github.com/ThinkFlowLab/OpenDirector/pull/210) added compatible image CDN downloads without provider credentials, but initially left arbitrary local and private hosts reachable. Credential isolation and bounded responses do not constrain the network destination. +- **Check:** Preserve explicitly configured local service origins; validate all DNS answers for unconfigured hosts, connect to a checked public IP while retaining the original Host and TLS identity, and revalidate every redirect. Cover local metadata addresses, IPv6, mixed DNS answers, redirect loops, and recovery without another model submission. diff --git a/packages/core/src/opendirector/application/creation_tool_drafts.py b/packages/core/src/opendirector/application/creation_tool_drafts.py index 3cefd421..a8fcc38c 100644 --- a/packages/core/src/opendirector/application/creation_tool_drafts.py +++ b/packages/core/src/opendirector/application/creation_tool_drafts.py @@ -85,6 +85,28 @@ def freeze_inputs(service, project_id, value): targetIntent=target_intent(snapshot, target) if target else None) +def _numbered(title): + head, separator, tail = title.rpartition(' ') + return (head, int(tail)) if separator and head and tail.isascii() and tail.isdigit() else (title, None) + + +def result_title(snapshot, base, used=(), exclude=None): + """Keep result names tied to their tool; a repeated run takes the next number.""" + family, index = _numbered(base) + occupied = [item.title for item in snapshot.canvasItems + if item.visible and item.title and (exclude is None or item.id != exclude)] + taken = (*occupied, *used) + if base not in taken: + return base + numbers = [0] + for title in taken: + head, number = _numbered(title) + if head != family: + continue + numbers.append(1 if number is None else number) + return f'{family} {max(numbers) + 1}' + + def release_placeholder(service, task, session=None): """Release only the accepted task's unchanged target; never infer UI defaults.""" snapshot = service.projects.snapshot(task['projectId']) @@ -156,6 +178,7 @@ def expect(kind, obj): expect('canvas_item', item) if policy == 'if_current' and not matching: return unplaced() + titles = [] for index, output in enumerate(task['outputs']): item_id = UUID(output_item_id(task, index, output['kind'])) item = next((item for item in snapshot.canvasItems if item.id == item_id), None) @@ -181,17 +204,23 @@ def expect(kind, obj): expect('canvas_item', item) operations.append(dict(type='canvas.show', payload=dict(item=item.model_dump(mode='json')))) continue + # The placeholder already carries the tool/preset name in the UI language. + title = result_title(snapshot, item.title if item else output['title'], + titles, item.id if item else None) + titles.append(title) if item: editable(snapshot, item.id) expect('canvas_item', item) if not item.visible: operations.append(dict(type='canvas.show', payload=dict(item=item.model_dump(mode='json')))) + if item.title != title: + operations.append(dict(type='canvas.update', payload=dict(itemId=item.id, title=title))) operations.append(dict(type='canvas.materialize', payload=dict(itemId=item.id, asset=asset_ref(asset).model_dump(mode='json'), discardGenerationDraft=True))) else: width, height = media_card_size(asset.kind, asset.metadata.width, asset.metadata.height, source.width if source else 320, source.height if source else 240) operations.append(dict(type='canvas.show', payload=dict(item=dict(id=item_id, projectId=snapshot.project.id, - kind=asset.kind, title=output['title'], position=dict(x=(source.position.x + source.width + 120 if source else 80) + index * 360, y=source.position.y if source else 80), + kind=asset.kind, title=title, position=dict(x=(source.position.x + source.width + 120 if source else 80) + index * 360, y=source.position.y if source else 80), width=width, height=height, binding=dict(role='material', objectRef=asset_ref(asset).model_dump(mode='json')))))) if asset.kind == 'image': editor_id = uuid5(UUID(task['id']), f'output-editor-{item_id}') diff --git a/packages/core/src/opendirector/application/tool_context.py b/packages/core/src/opendirector/application/tool_context.py index 703609d3..592c9da6 100644 --- a/packages/core/src/opendirector/application/tool_context.py +++ b/packages/core/src/opendirector/application/tool_context.py @@ -31,6 +31,14 @@ class Output: suffix: str title: str + +class ToolInputError(ProjectError): + """A tool rejection with stable localization identity and interpolation data.""" + + def __init__(self, message, *, code, label): + super().__init__(message, 422, code=code) + self.params = {'label': label} + class ToolContext: """What a recipe may use: inputs, model capabilities and progress reporting.""" @@ -121,7 +129,9 @@ async def concepts(self, text: str, default: tuple[str, ...]) -> list[tuple[str, except ProjectError: english = None if not english or not english.isascii(): - raise ProjectError(f'无法识别「{part}」,请改用英文描述,或在“模型接入”中添加对话模型以自动翻译。', 422) + raise ToolInputError( + f'无法识别「{part}」,请改用英文描述,或在“模型接入”中添加对话模型以自动翻译。', + code='tool_concept_translation', label=part) pairs.append((english, part)) return pairs @@ -160,7 +170,7 @@ async def video_from_frame(self, frame: Image.Image, prompt: str, seconds: float CONCEPTS = { '人': 'person', '人物': 'person', '人像': 'person', '人群': 'person', '主体': 'person', '男人': 'man', '女人': 'woman', '小孩': 'child', '孩子': 'child', '儿童': 'child', '脸': 'face', '手': 'hand', '头发': 'hair', - '动物': 'animal', '猫': 'cat', '狗': 'dog', '鸟': 'bird', '马': 'horse', '鱼': 'fish', + '动物': 'animal', '猫': 'cat', '猫咪': 'cat', '小猫': 'cat', '狗': 'dog', '鸟': 'bird', '马': 'horse', '鱼': 'fish', '车': 'car', '汽车': 'car', '车辆': 'vehicle', '交通工具': 'vehicle', '自行车': 'bicycle', '摩托车': 'motorcycle', '船': 'boat', '飞机': 'airplane', '树': 'tree', '花': 'flower', '植物': 'plant', '建筑': 'building', '房子': 'house', '天空': 'sky', '桌子': 'table', '椅子': 'chair', '沙发': 'sofa', '家具': 'furniture', '杯子': 'cup', '瓶子': 'bottle', diff --git a/packages/core/src/opendirector/application/tool_image_edits.py b/packages/core/src/opendirector/application/tool_image_edits.py index 75976817..d5229cef 100644 --- a/packages/core/src/opendirector/application/tool_image_edits.py +++ b/packages/core/src/opendirector/application/tool_image_edits.py @@ -74,6 +74,8 @@ async def smart_edit(ctx: ToolContext): input_image = marked instruction += '。图上的笔画、箭头与文字是编辑指示' instruction += ',完成修改后移除这些辅助标记' + if mode != 'annotation' and ctx.mask: + instruction += '。这是原图上的局部修改,请严格保持原图构图、镜头、主体位置、大小和姿势,使编辑结果与原图逐像素对齐' edited = await ctx.edit(input_image, instruction + '。保持未提及的内容不变', 0.78, references=references) if mode != 'annotation' and ctx.mask: # A selection is an exact editing boundary: pixels outside it stay original. @@ -116,7 +118,7 @@ async def inpainting_remove(ctx: ToolContext): mask = media.mask_from(ctx.mask, image.size) await ctx.progress('正在消除并填补') drawn = await ctx.edit(media.soften_region(image, mask), '移除涂抹区域中的物体与文字,用周围环境自然填补,画面干净完整', 0.8) - return [image_output(media.blend(image, drawn, media.feather(mask, max(3, min(image.size) / 120))), '消除')] + return [image_output(media.blend(image, drawn, media.feather(mask, max(3, min(image.size) / 120))), '消除笔')] def _yaw_text(yaw): names = ['正面', '右前方', '右侧面', '右后方', '背面', '左后方', '左侧面', '左前方'] @@ -176,11 +178,14 @@ class ImagePreset: '保持参考图画风,纯净浅灰背景、均匀柔光、弱透视,无剧情场景、文字、标签、水印、分割线或额外视图。',)), 'face-three-view': ImagePreset('面部三视图', ( '以参考图人物的面部为唯一身份依据,生成同一人物的面部三视图参考板。' - '超宽横版画面分为三个等宽区域,从左至右严格排列:正面、左侧90度、右侧90度。' + '3:1超宽横版画面均分为三个等宽区域,从左至右严格排列:正面、左侧90度、右侧90度。' + '第二格(左侧90度)鼻尖朝向画面右方,第三格(右侧90度)鼻尖朝向画面左方;' + '两张侧脸朝向相反、左右镜像对称,不要输出两张朝向相同的侧脸。' + '每个区域只放一个居中的头肩像,三个头像之间留有自然间隔,不接触、不重叠、不粘连。' '每个视图只呈现完整头部至肩颈,头顶、下巴、耳朵均完整,不出现背面或全身视图。' '保持同一身份、年龄、脸型、骨相、五官比例、肤色、发际线、发型及识别性细节,左右侧面符合真实结构。' '三个头部大小一致、眼睛基线对齐、中性表情、视线随朝向自然变化,面部不被头发遮挡。' - '保持参考图画风,纯净浅灰背景、均匀柔光,无美颜改脸、夸张透视、文字、标签、水印或额外人物。',), (2688, 1152)), + '保持参考图画风、纯净浅灰背景、均匀柔光,无美颜改脸、夸张透视、文字、标签、水印或额外人物。',), (3456, 1152)), 'product-three-view': ImagePreset('产品三视图', ( '以参考图中的产品为唯一设计依据,生成同一产品的工业设计三视图参考板。' '横版画面分为三个等宽区域,从左至右严格排列:正视图、右侧90度侧视图、后视图。' @@ -188,7 +193,7 @@ class ImagePreset: '三个视图保持结构、尺寸关系、材质、颜色、表面纹理、接口、按钮、接缝及标识一致,细节在视角之间符合物理对应关系。' '不可见部位依据已知结构作合理且一致的补全,不增加功能件或改变产品设计。' '产品独立展示,纯白背景、均匀棚拍光,无人物、手、包装道具、剧情场景、说明文字、尺寸标注、水印或额外视图;保留原有品牌标识。',)), - 'storyboard': ImagePreset('分镜', ('把镜头拉远成远景建立镜头,完整交代周围环境,主体在画面中较小', + 'storyboard': ImagePreset('连续分镜图', ('把镜头拉远成远景建立镜头,完整交代周围环境,主体在画面中较小', '改为中景镜头,主体占画面一半高度,动作清晰', '改为近景镜头,镜头推近到主体胸口以上,突出表情', '改为特写镜头,只拍主体面部或关键细节,背景虚化')), diff --git a/packages/core/src/opendirector/application/tool_tasks.py b/packages/core/src/opendirector/application/tool_tasks.py index cdec8f7d..4194d41d 100644 --- a/packages/core/src/opendirector/application/tool_tasks.py +++ b/packages/core/src/opendirector/application/tool_tasks.py @@ -443,7 +443,9 @@ async def preserve(): if current.get('cancelRequested'): await self._stopped(task['projectId'], task['id']) else: - await self._update(directory, task, status='failed', localState='stopped', phase=None, error=str(failure), finishedAt=_now()) + await self._update(directory, task, status='failed', localState='stopped', phase=None, + error=str(failure), errorDetails={'code': failure.code, 'params': getattr(failure, 'params', {})}, + finishedAt=_now()) except Exception: # noqa: BLE001 — a recipe bug must still end the task visibly. current = self.get(task['projectId'], task['id']) if current.get('cancelRequested') or self.closing: diff --git a/packages/core/src/opendirector/application/tool_video_recipes.py b/packages/core/src/opendirector/application/tool_video_recipes.py index 81bc21eb..1e97bd9d 100644 --- a/packages/core/src/opendirector/application/tool_video_recipes.py +++ b/packages/core/src/opendirector/application/tool_video_recipes.py @@ -3,7 +3,7 @@ from PIL import Image, ImageChops, ImageFilter -from opendirector.application.tool_context import Output, Source, ToolContext +from opendirector.application.tool_context import Output, Source, ToolContext, ToolInputError from opendirector.domain.capabilities import EditMask, Tracks from opendirector.domain.projects import ProjectError from opendirector.media import processing as media @@ -111,7 +111,9 @@ async def video_matting(ctx: ToolContext): await ctx.progress(f"正在识别并跟踪「{'、'.join(label for _, label in pairs)}」") tracks = await ctx.track(clip.read_bytes(), tuple(concept for concept, _ in pairs)) if not any(tracks.frames): - raise ProjectError(f"视频中没有识别到「{'、'.join(label for _, label in pairs)}」,请换一种描述后重试。", 422) + label = '、'.join(label for _, label in pairs) + raise ToolInputError(f'视频中没有识别到「{label}」,请换一种描述后重试。', + code='tool_no_video_match', label=label) await ctx.progress('正在合成抠像视频') await blocking(_write_mattes, tracks, work.path / 'mattes') data, suffix = await blocking(media.matte, clip, str(work.path / 'mattes' / '%05d.png'), info['fps'], diff --git a/packages/core/src/opendirector/integrations/model_adapters.py b/packages/core/src/opendirector/integrations/model_adapters.py index 669571e1..a95d4ba2 100644 --- a/packages/core/src/opendirector/integrations/model_adapters.py +++ b/packages/core/src/opendirector/integrations/model_adapters.py @@ -286,11 +286,17 @@ async def work(): result = value['data'][0] if result.get('b64_json'): return base64.b64decode(result['b64_json'], validate=True) - # Download only from the configured service, never a third-party host. - if urlsplit(result['url']).netloc != urlsplit(config['url']).netloc: - raise protocol_failure('download') - async with client.stream('GET', result['url'], headers=auth_headers(config)) as response: - return await bounded(response, stage='download', secrets=secrets) + # Compatible APIs can return a CDN URL even when base64 was requested. + # Reuse the generation downloader, which sends no API key to the CDN. + async def download(): + target, base = urlsplit(result['url']), urlsplit(config['url']) + if (target.scheme, target.netloc) != (base.scheme, base.netloc): + _, data = await VolcengineGeneration(None, transport=self.transport).resume_result( + config, 'png', result['url'], public_only=True) + return data + async with client.stream('GET', result['url'], headers=auth_headers(config)) as response: + return await bounded(response, stage='download', secrets=secrets) + return await _call('download', download) return _decoded(await _call('submit', work)) diff --git a/packages/core/src/opendirector/integrations/providers.py b/packages/core/src/opendirector/integrations/providers.py index 993ed5ae..63e17af0 100644 --- a/packages/core/src/opendirector/integrations/providers.py +++ b/packages/core/src/opendirector/integrations/providers.py @@ -88,16 +88,20 @@ async def resume_result(self, config, suffix, url): if not config or config.get('protocol') in {None, 'volcengine'}: return await super().resume_result(config, suffix, url) # Only send a service token back to that exact configured origin. - base, target = urlsplit(config['url']), urlsplit(url) - headers = auth_headers(config) if (base.scheme, base.netloc) == (target.scheme, target.netloc) else {} try: + base, target = urlsplit(config['url']), urlsplit(url) + same_origin = (base.scheme, base.netloc) == (target.scheme, target.netloc) async with httpx.AsyncClient(event_hooks=MODEL_HTTP_HOOKS, transport=self.transport, trust_env=trust_environment(url, config=config), timeout=httpx.Timeout(240, connect=15)) as client: - async with client.stream('GET', url, headers=headers, follow_redirects=False) as response: + if not same_origin: + return suffix, await self._download(client, url, secrets=(config.get('apiKey', ''),), public_only=True) + async with client.stream('GET', url, headers=auth_headers(config), follow_redirects=False) as response: return suffix, await bounded(response, stage='download', secrets=(config.get('apiKey', ''),)) except httpx.TimeoutException: raise transport_failure('download', timeout=True) from None except httpx.HTTPError: raise transport_failure('download') from None + except (httpx.InvalidURL, ValueError, TypeError, KeyError, AttributeError): + raise protocol_failure('download') from None async def generate(self, config, task, submitted, *, result_ready=None): adapter = self._profile_adapter(config) diff --git a/packages/core/src/opendirector/integrations/volcengine.py b/packages/core/src/opendirector/integrations/volcengine.py index 640530c5..a7db78b4 100644 --- a/packages/core/src/opendirector/integrations/volcengine.py +++ b/packages/core/src/opendirector/integrations/volcengine.py @@ -3,6 +3,8 @@ import asyncio import base64 import json +import socket +from ipaddress import ip_address from urllib.parse import quote, urlsplit from uuid import uuid4 @@ -33,6 +35,38 @@ async def bounded(response, *, stage, secrets=(), limit=MAX_ASSET_BYTES): return bytes(data) +async def _public_result_url(url): + """Pin an unconfigured download to checked public DNS answers, retaining TLS identity.""" + try: + target = httpx.URL(url) + except httpx.InvalidURL: + raise protocol_failure('download') from None + host = target.host.rstrip('.').lower() + if (target.scheme not in {'http', 'https'} or not host or target.userinfo + or '%' in host or host == 'localhost' or host.endswith('.localhost')): + raise protocol_failure('download') + try: + addresses = [ip_address(host)] + except ValueError: + try: + answers = await asyncio.wait_for(asyncio.to_thread( + socket.getaddrinfo, host, target.port or (443 if target.scheme == 'https' else 80), + type=socket.SOCK_STREAM), timeout=15) + except (OSError, asyncio.TimeoutError): + raise transport_failure('download') from None + addresses = [ip_address(answer[4][0]) for answer in answers] + for address in addresses: + checked_address = getattr(address, 'ipv4_mapped', None) or address + if (not checked_address.is_global or checked_address.is_multicast + or checked_address.is_reserved): + raise protocol_failure('download') + if not addresses: + raise protocol_failure('download') + # A pooled connection to one IP must not reuse another hostname's TLS identity. + headers = {'Host': target.netloc.decode('ascii'), 'Connection': 'close'} + return target.copy_with(host=str(addresses[0])), headers, {'sni_hostname': target.host} + + class VolcengineGeneration: def __init__(self, store, transport=None, poll_interval=3, retry_delays=(0.5, 1.0)): self.store, self.transport, self.poll_interval = store, transport, poll_interval @@ -52,15 +86,17 @@ async def _retry_read(self, operation): raise await asyncio.sleep(self.retry_delays[attempt]) - async def resume_result(self, config, suffix, url): + async def resume_result(self, config, suffix, url, *, public_only=False): secrets = (config['apiKey'],) if config else () try: async with httpx.AsyncClient(event_hooks=MODEL_HTTP_HOOKS, transport=self.transport, trust_env=trust_environment(url, config=config), timeout=httpx.Timeout(240, connect=15)) as client: - return suffix, await self._download(client, url, secrets=secrets) + return suffix, await self._download(client, url, secrets=secrets, public_only=public_only) except httpx.TimeoutException: raise transport_failure('download', timeout=True) from None except httpx.HTTPError: raise transport_failure('download') from None + except (httpx.InvalidURL, ValueError, TypeError): + raise protocol_failure('download') from None def validate(self, kind, config): if kind in {"video", "audio"}: @@ -207,12 +243,29 @@ async def query(): except (ValueError, TypeError, KeyError, IndexError, AttributeError): raise protocol_failure(stage) from None - async def _download(self, client, url, *, secrets=()): + async def _download(self, client, url, *, secrets=(), public_only=False): parts = urlsplit(url) if parts.scheme not in {"http", "https"} or not parts.hostname or parts.username or parts.password: raise protocol_failure('download') # CDN downloads never receive provider credentials. async def download(): + if public_only: + current = httpx.URL(url) + for _ in range(6): + pinned, headers, extensions = await _public_result_url(current) + async with client.stream('GET', pinned, headers=headers, extensions=extensions, + follow_redirects=False) as response: + if response.is_redirect: + location = response.headers.get('location') + if not location: + raise protocol_failure('download') + try: + current = current.join(location) + except httpx.InvalidURL: + raise protocol_failure('download') from None + continue + return await bounded(response, stage='download', secrets=secrets) + raise protocol_failure('download') async with client.stream("GET", url, follow_redirects=True) as response: return await bounded(response, stage='download', secrets=secrets) return await self._retry_read(download) diff --git a/src/backend/tools.ts b/src/backend/tools.ts index d6c51249..940157e3 100644 --- a/src/backend/tools.ts +++ b/src/backend/tools.ts @@ -10,7 +10,11 @@ import type { import { CANVAS_TOOLS } from "../canvas/toolCatalog.ts"; import { ApiError, errorMessage, json, request } from "./api.ts"; import { creationClient } from "./creationClient.ts"; -import { toolTaskError, toolTaskPhase } from "../canvas/toolTaskText.ts"; +import { + toolTaskError, + toolTaskPhase, + type ToolTaskFailure, +} from "../canvas/toolTaskText.ts"; import { toolReasonText } from "../canvas/toolReasonText.ts"; import { tNow } from "../i18n/messages.ts"; import { @@ -66,6 +70,7 @@ export interface ToolTask { | "interrupted"; phase: string | null; error: string | null; + errorDetails?: ToolTaskFailure; outputs: ToolTaskOutput[]; createdAt?: string; finishedAt?: string | null; @@ -130,7 +135,7 @@ export function toolTaskMessage(task: ToolTask): string | undefined { : tNow("errors.tools.taskMessage.interruptedEnded"); return tNow("errors.tools.taskMessage.interrupted", { detail }); } - return toolTaskError(task.error); + return toolTaskError(task.error, undefined, task.errorDetails); } export interface ToolCanvasOrigin { diff --git a/src/canvas/CanvasIcon.tsx b/src/canvas/CanvasIcon.tsx index f58fcafe..fd98bb2e 100644 --- a/src/canvas/CanvasIcon.tsx +++ b/src/canvas/CanvasIcon.tsx @@ -45,6 +45,23 @@ const icons: Record = { ), + /** A round smiling face: the identification-driven face preset. */ + face: ( + <> + + + + + + ), + /** A box seen from the corner: three faces, like a product three-view. */ + product: ( + <> + + + + + ), canvas: ( <> @@ -140,6 +157,30 @@ const icons: Record = { ), + /** A framed canvas on its stand; the arrow leaves the frame. */ + canvasExport: ( + <> + + + + + + + + + ), + /** The same canvas with the arrow coming back into the frame. */ + canvasImport: ( + <> + + + + + + + + + ), download: ( <> diff --git a/src/canvas/NodeSelectionToolbar.tsx b/src/canvas/NodeSelectionToolbar.tsx index e85be7e1..84cb0b87 100644 --- a/src/canvas/NodeSelectionToolbar.tsx +++ b/src/canvas/NodeSelectionToolbar.tsx @@ -7,6 +7,8 @@ import { type KeyboardEvent, } from "react"; import { CanvasIcon } from "./CanvasIcon"; +import { ToolIcon } from "./ToolIcon"; +import type { ToolGlyphName } from "./toolGlyphs"; import { TooltipButton } from "./TooltipButton"; import { GridSplitOptions, SelectionToolbarMenu } from "./SelectionToolbarMenu"; import type { CanvasNode } from "./model"; @@ -48,6 +50,8 @@ type Action = { id: string; label: string; icon: string; + /** Solid tool glyph; tool actions outweigh the line icons used for chrome. */ + glyph?: ToolGlyphName; run?: () => void; options?: MenuOption[]; disabled?: boolean; @@ -71,13 +75,14 @@ type OpenMenu = { grid?: boolean; }; -const imageTools: readonly [CanvasToolId, MessageKey, string][] = [ - ["smart-edit", "canvas.selection.tool.smartEdit", "sparkle"], - ["panorama", "canvas.selection.tool.panorama", "expand"], - ["outpaint", "canvas.selection.tool.outpaint", "expand"], - ["image-hd", "canvas.selection.tool.upscale", "image"], - ["cutout", "canvas.selection.tool.cutout", "subject"], -]; +const imageTools: readonly [CanvasToolId, MessageKey, string, ToolGlyphName][] = + [ + ["smart-edit", "canvas.selection.tool.smartEdit", "sparkle", "smart-edit"], + ["panorama", "canvas.selection.tool.panorama", "expand", "panorama"], + ["outpaint", "canvas.selection.tool.outpaint", "expand", "outpaint"], + ["image-hd", "canvas.selection.tool.upscale", "image", "image-hd"], + ["cutout", "canvas.selection.tool.cutout", "subject", "cutout"], + ]; const videoTools: readonly [CanvasToolId, MessageKey, string][] = [ ["video-partial-reshoot", "canvas.selection.tool.videoReshoot", "camera"], ["video-upscale", "canvas.selection.tool.upscale", "video"], @@ -142,16 +147,23 @@ export function NodeSelectionToolbar({ label: string, icon: string, options: MenuOption[], + glyph?: ToolGlyphName, ) => { if (options.some((option) => !option.disabled)) - editing.push({ id, label, icon, options, disabled: locked }); + editing.push({ id, label, icon, glyph, options, disabled: locked }); }; - const addTool = (tool: CanvasToolId, label: string, icon: string) => { + const addTool = ( + tool: CanvasToolId, + label: string, + icon: string, + glyph?: ToolGlyphName, + ) => { if (onTool) editing.push({ id: tool, label, icon, + glyph, run: () => onTool(tool), disabled: locked, }); @@ -172,8 +184,8 @@ export function NodeSelectionToolbar({ }); }; if (node.type === "image") { - for (const [tool, labelKey, icon] of imageTools) - addTool(tool, t(labelKey), icon); + for (const [tool, labelKey, icon, glyph] of imageTools) + addTool(tool, t(labelKey), icon, glyph); addMenu( "image-presets", t("canvas.selection.presets"), @@ -186,49 +198,69 @@ export function NodeSelectionToolbar({ ? "camera" : value === "storyboard" ? "grid" - : value === "product-three-view" - ? "image" - : "subject", + : value === "face-three-view" + ? "face" + : value === "product-three-view" + ? "product" + : "subject", run: () => onPreset?.(String(value)), disabled: !onPreset, })), + "presets", + ); + addTool( + "view-control", + t("canvas.selection.multiAngle"), + "rotate", + "view-control", + ); + addTool("lighting", t("canvas.selection.lighting"), "palette", "lighting"); + addMenu( + "marketing", + t("canvas.selection.marketing"), + "palette", + [ + toolOption("multi-size", t("canvas.selection.multiSize"), "expand"), + toolOption( + "layer-decompose", + t("canvas.selection.layerDecompose"), + "stack", + ), + toolOption( + "product-replacement", + t("canvas.selection.productReplacement"), + "image", + ), + toolOption( + "dynamic-poster", + t("canvas.selection.dynamicPoster"), + "video", + ), + ], + "marketing", + ); + addMenu( + "tools", + t("canvas.selection.tools"), + "tool", + [ + toolOption( + "inpainting-remove", + t("canvas.selection.inpaintRemove"), + "edit", + ), + toolOption("composition", t("canvas.selection.cropCompose"), "crop"), + { + id: "grid-split", + label: t("canvas.selection.gridSplit"), + icon: "grid", + grid: true, + disabled: !onGridSplit, + }, + toolOption("annotation", t("canvas.selection.annotate"), "pencil"), + ], + "tools", ); - addTool("view-control", t("canvas.selection.multiAngle"), "rotate"); - addTool("lighting", t("canvas.selection.lighting"), "palette"); - addMenu("marketing", t("canvas.selection.marketing"), "palette", [ - toolOption("multi-size", t("canvas.selection.multiSize"), "expand"), - toolOption( - "layer-decompose", - t("canvas.selection.layerDecompose"), - "stack", - ), - toolOption( - "product-replacement", - t("canvas.selection.productReplacement"), - "image", - ), - toolOption( - "dynamic-poster", - t("canvas.selection.dynamicPoster"), - "video", - ), - ]); - addMenu("tools", t("canvas.selection.tools"), "tool", [ - toolOption( - "inpainting-remove", - t("canvas.selection.inpaintRemove"), - "edit", - ), - toolOption("composition", t("canvas.selection.cropCompose"), "crop"), - { - id: "grid-split", - label: t("canvas.selection.gridSplit"), - icon: "grid", - grid: true, - disabled: !onGridSplit, - }, - toolOption("annotation", t("canvas.selection.annotate"), "pencil"), - ]); } else if (node.type === "video") { for (const [tool, labelKey, icon] of videoTools) addTool(tool, t(labelKey), icon); @@ -316,6 +348,7 @@ export function NodeSelectionToolbar({ id: "assistant", label: t("canvas.selection.assistant"), icon: "sparkle", + glyph: "assistant", run: onAssistant, }); @@ -487,7 +520,11 @@ export function NodeSelectionToolbar({ ); }} > - + {action.glyph ? ( + + ) : ( + + )} {!action.iconOnly && {action.label}} {action.options && } diff --git a/src/canvas/NodeTaskPanel.tsx b/src/canvas/NodeTaskPanel.tsx index 31ce42f4..e9746e62 100644 --- a/src/canvas/NodeTaskPanel.tsx +++ b/src/canvas/NodeTaskPanel.tsx @@ -95,18 +95,20 @@ export function NodeTaskPanel({ const preset = capability?.fields ?.find((field) => field.key === "preset") ?.options?.find((option) => option.value === parameters.preset); + const presetName = translateToolText( + locale, + preset?.label ?? String(parameters.preset ?? ""), + ); const note = errors[0] ?? (runnable ? t("tools.nodeTask.presetNote") : (capability?.reason ?? t("tools.nodeTask.notConnected"))); - const model = capability?.models?.["image.edit"]?.find( - (item) => item.id === parameters.imageModelConfigId, - ); + const models = capability?.models?.["image.edit"] ?? []; return (
{ if (event.key === "Escape") { event.preventDefault(); @@ -115,32 +117,54 @@ export function NodeTaskPanel({ } }} > - {t("tools.nodeTask.presetTitle")} - - {translateToolText( - locale, - preset?.label ?? String(parameters.preset ?? ""), +
+ {presetName} + + + +
+
+ {models.length > 0 && ( + )} - - - {note} - - + + {note} + + +
); } diff --git a/src/canvas/ToolIcon.tsx b/src/canvas/ToolIcon.tsx new file mode 100644 index 00000000..8e65b311 --- /dev/null +++ b/src/canvas/ToolIcon.tsx @@ -0,0 +1,33 @@ +import type { SVGProps } from "react"; + +import { TOOL_GLYPHS, type ToolGlyphName } from "./toolGlyphs"; + +export interface ToolIconProps extends Omit< + SVGProps, + "name" | "size" +> { + name: ToolGlyphName; + size?: number; +} + +/** Filled tool glyphs for the selection toolbar, drawn on the same 24 grid as + * CanvasIcon but as solid shapes so tool buttons outweigh ordinary chrome. */ +export function ToolIcon({ name, size = 20, ...props }: ToolIconProps) { + const glyph = TOOL_GLYPHS[name] ?? TOOL_GLYPHS["smart-edit"]; + return ( + + ); +} + +export default ToolIcon; diff --git a/src/canvas/connectionPolicy.ts b/src/canvas/connectionPolicy.ts index 2ba3f977..4a2373b2 100644 --- a/src/canvas/connectionPolicy.ts +++ b/src/canvas/connectionPolicy.ts @@ -63,9 +63,12 @@ export function referenceMaterial(node: CanvasNode): string | null { return null; } -/** Ports and creation menus are discoverable before the source has content. */ +/** Empty generation nodes keep their ports; assets need readable material. */ export function canStartReference(node: CanvasNode): boolean { - return Boolean(nodeDownstreamKinds(node).length); + return ( + Boolean(nodeDownstreamKinds(node).length) && + (node.data.mediaRole !== "asset" || Boolean(referenceMaterial(node))) + ); } export function canSourceReference(node: CanvasNode): boolean { diff --git a/src/canvas/connectionPorts.ts b/src/canvas/connectionPorts.ts index 97710b9e..0fed40ae 100644 --- a/src/canvas/connectionPorts.ts +++ b/src/canvas/connectionPorts.ts @@ -1,20 +1,45 @@ -import type { Point, Rect } from "./model.ts"; +import { canSourceReference } from "./connectionPolicy.ts"; +import type { CanvasNode, Point, Rect } from "./model.ts"; export type PortSide = "top" | "right" | "bottom" | "left"; +/** Which ways references can travel from the node these ports surround. */ +export type ReferencePortDirection = "in" | "out" | "both"; export const REFERENCE_PORT_SIZE = 22; export const REFERENCE_PORT_MAX_SIZE = 30; /** Drawn size of the direction icon inside the port circle. */ export const REFERENCE_PORT_ICON_SIZE = 15; -/** Asset ports point outwards; editable nodes show the edge's two directions. */ -export function referencePortIcon(side: PortSide, sourceOnly: boolean) { - if (sourceOnly) - return { - top: "arrowUp", - right: "arrowRight", - bottom: "arrowDown", - left: "arrowLeft", - }[side]; +/** + * Asset ports point outwards; editable nodes show both directions. A generation + * node without content can only receive references, so its ports point inwards + * until a result makes it a source as well. + */ +export function referencePortDirection( + node: CanvasNode | undefined, +): ReferencePortDirection { + if (!node) return "both"; + if (node.data.mediaRole === "asset") return "out"; + return canSourceReference(node) ? "both" : "in"; +} + +const OUTWARD_ICONS: Record = { + top: "arrowUp", + right: "arrowRight", + bottom: "arrowDown", + left: "arrowLeft", +}; +const INWARD_ICONS: Record = { + top: "arrowDown", + right: "arrowLeft", + bottom: "arrowUp", + left: "arrowRight", +}; +export function referencePortIcon( + side: PortSide, + direction: ReferencePortDirection, +) { + if (direction === "in") return INWARD_ICONS[side]; + if (direction === "out") return OUTWARD_ICONS[side]; return side === "top" || side === "bottom" ? "referenceArrowsVertical" : "referenceArrowsHorizontal"; diff --git a/src/canvas/node-task-panel.css b/src/canvas/node-task-panel.css index 4f5f3054..7564a379 100644 --- a/src/canvas/node-task-panel.css +++ b/src/canvas/node-task-panel.css @@ -161,6 +161,11 @@ background: #1e2028; border: 1px solid #ffffff20; } +/* A two-row form is much shorter than an editor panel: the shared 24px corner + would read as a pill, so it keeps the 15px panel radius instead. */ +.cw-node-action-dock[data-tool="image-presets"] { + border-radius: 15px; +} .cw-node-action-dock > section { min-width: 0; } @@ -182,36 +187,74 @@ .cw-node-task-panel.cw-preset-task-panel { display: flex; - align-items: center; - flex-wrap: nowrap; - gap: 16px; - padding: 14px 18px; - min-height: 64px; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + min-height: 0; box-sizing: border-box; +} +.cw-preset-task-panel .cw-preset-task-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0; + min-height: 0; +} +.cw-preset-task-panel .cw-preset-task-head strong { + font-size: 13px; + font-weight: 650; + line-height: 18px; + overflow: hidden; white-space: nowrap; + text-overflow: ellipsis; } -.cw-preset-task-panel > strong { +.cw-preset-task-panel .cw-preset-task-head > button { flex-shrink: 0; - font-size: 14px; - font-weight: 650; + display: grid; + place-items: center; + width: 22px; + min-height: 22px; + padding: 0; + border-color: transparent; + background: transparent; +} +.cw-preset-task-panel .cw-preset-task-head > button:hover { + background: #ffffff0e; +} +.cw-preset-task-row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; } -.cw-preset-task-name { +.cw-preset-task-panel .cw-preset-task-model { flex-shrink: 0; - font-size: 14px; - font-weight: 400; + max-width: 168px; + min-width: 0; + height: 28px; + padding: 0 6px; + font-size: 11px; + border-radius: 7px; } .cw-preset-task-note { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; color: var(--cw-control-muted, #9299a9); font-size: 11px; } -.cw-preset-task-panel > .cw-node-task-primary { +.cw-preset-task-row > .cw-node-task-primary { flex-shrink: 0; - margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 5px; min-width: 72px; - min-height: 34px; - border-radius: 10px; + min-height: 32px; + padding: 4px 14px; + font-size: 12px; + border-radius: 7px; } diff --git a/src/canvas/toolGlyphs.ts b/src/canvas/toolGlyphs.ts new file mode 100644 index 00000000..cc9eda7f --- /dev/null +++ b/src/canvas/toolGlyphs.ts @@ -0,0 +1,107 @@ +/** Solid tool glyphs for the node selection toolbar. + * + * One filled family, 24x24, 2..22 safe area. A sparkle marks the actions that + * generate or rewrite pixels; plain tools stay unmarked so the marker keeps + * meaning. Paths are filled with currentColor, so every theme colour works. + * Labels live in the i18n catalog, not here. + */ +export const TOOL_GLYPHS = { + /** magic wand + sparkle. */ + "smart-edit": { + d: [ + "M6.13 20.13L14.13 12.13A1.6 1.6 0 0 1 11.87 9.87L3.87 17.87A1.6 1.6 0 0 1 6.13 20.13Z", + "M18 2.4C18.11 4.31 19.69 5.89 21.6 6C19.69 6.11 18.11 7.69 18 9.6C17.89 7.69 16.31 6.11 14.4 6C16.31 5.89 17.89 4.31 18 2.4Z", + ], + }, + /** globe with a panorama band. */ + panorama: { + d: [ + "M12 2.8A9.2 9.2 0 1 1 12 21.2A9.2 9.2 0 1 1 12 2.8ZM12 4.6A7.4 7.4 0 1 1 12 19.4A7.4 7.4 0 1 1 12 4.6Z", + "M12 7.4A8.9 4.6 0 1 1 12 16.6A8.9 4.6 0 1 1 12 7.4ZM12 8.85A7.45 3.15 0 1 1 12 15.15A7.45 3.15 0 1 1 12 8.85Z", + ], + }, + /** corner brackets + centre block. */ + outpaint: { + d: [ + "M3 8.2V4.2A1.2 1.2 0 0 1 4.2 3H8.2V4.9H4.9V8.2Z", + "M21 8.2V4.2A1.2 1.2 0 0 0 19.8 3H15.8V4.9H19.1V8.2Z", + "M21 15.8v4a1.2 1.2 0 0 1-1.2 1.2h-4v-1.9h3.3v-3.3Z", + "M3 15.8v4A1.2 1.2 0 0 0 4.2 21h4v-1.9H4.9v-3.3Z", + "M11.2 9.8H12.8A1.4 1.4 0 0 1 14.2 11.2V12.8A1.4 1.4 0 0 1 12.8 14.2H11.2A1.4 1.4 0 0 1 9.8 12.8V11.2A1.4 1.4 0 0 1 11.2 9.8Z", + ], + }, + /** HD frame + sparkle. */ + "image-hd": { + d: [ + "M6.4 4.2H17.6A3.6 3.6 0 0 1 21.2 7.8V16.2A3.6 3.6 0 0 1 17.6 19.8H6.4A3.6 3.6 0 0 1 2.8 16.2V7.8A3.6 3.6 0 0 1 6.4 4.2ZM6.4 5.9H17.6A1.9 1.9 0 0 1 19.5 7.8V16.2A1.9 1.9 0 0 1 17.6 18.1H6.4A1.9 1.9 0 0 1 4.5 16.2V7.8A1.9 1.9 0 0 1 6.4 5.9Z", + "M6.5 8.4h1.45v2.85h1.85V8.4h1.45v7.2H9.8v-2.85H7.95V15.6H6.5Z", + "M12.35 8.4h1.95a3.6 3.6 0 0 1 0 7.2h-1.95Zm1.4 1.45v4.3h.55a2.15 2.15 0 0 0 0-4.3Z", + "M20.1 0.6C20.2 2.35 21.65 3.8 23.4 3.9C21.65 4 20.2 5.45 20.1 7.2C20 5.45 18.55 4 16.8 3.9C18.55 3.8 20 2.35 20.1 0.6Z", + ], + }, + /** subject inside a frame that is cut open. */ + cutout: { + d: [ + "M7.2 2.8H16.8A4.4 4.4 0 0 1 21.2 7.2V16.8A4.4 4.4 0 0 1 16.8 21.2H7.2A4.4 4.4 0 0 1 2.8 16.8V7.2A4.4 4.4 0 0 1 7.2 2.8ZM7.2 4.7H16.8A2.5 2.5 0 0 1 19.3 7.2V16.8A2.5 2.5 0 0 1 16.8 19.3H7.2A2.5 2.5 0 0 1 4.7 16.8V7.2A2.5 2.5 0 0 1 7.2 4.7ZM17.2 9.6h5.4v3.4h-5.4Z", + "M12 5.4A3.2 3.2 0 1 1 12 11.8A3.2 3.2 0 1 1 12 5.4Z", + "M12 13.4c-3.5 0-6 2.3-6 5.5v.3h12v-.3c0-3.2-2.5-5.5-6-5.5Z", + ], + }, + /** 2x2 tiles, sparkle in the fourth slot. */ + presets: { + d: [ + "M5.2 3H7.6A2.2 2.2 0 0 1 9.8 5.2V7.6A2.2 2.2 0 0 1 7.6 9.8H5.2A2.2 2.2 0 0 1 3 7.6V5.2A2.2 2.2 0 0 1 5.2 3Z", + "M16.4 3H18.8A2.2 2.2 0 0 1 21 5.2V7.6A2.2 2.2 0 0 1 18.8 9.8H16.4A2.2 2.2 0 0 1 14.2 7.6V5.2A2.2 2.2 0 0 1 16.4 3Z", + "M5.2 14.2H7.6A2.2 2.2 0 0 1 9.8 16.4V18.8A2.2 2.2 0 0 1 7.6 21H5.2A2.2 2.2 0 0 1 3 18.8V16.4A2.2 2.2 0 0 1 5.2 14.2Z", + "M17.6 13.8C17.71 15.81 19.39 17.49 21.4 17.6C19.39 17.71 17.71 19.39 17.6 21.4C17.49 19.39 15.81 17.71 13.8 17.6C15.81 17.49 17.49 15.81 17.6 13.8Z", + ], + }, + /** isometric cube. */ + "view-control": { + d: [ + "M12 3.4 19.6 7.8 12 12.2 4.4 7.8Z", + "M4.4 9.4 11.2 13.3 11.2 20.4 4.4 16.5Z", + "M19.6 9.4 12.8 13.3 12.8 20.4 19.6 16.5Z", + ], + }, + /** lamp bar casting three rays. */ + lighting: { + d: [ + "M6.6 4.4H17.4A1.8 1.8 0 0 1 19.2 6.2V6.2A1.8 1.8 0 0 1 17.4 8H6.6A1.8 1.8 0 0 1 4.8 6.2V6.2A1.8 1.8 0 0 1 6.6 4.4Z", + "M11 10.6L11 18.4A1 1 0 0 1 13 18.4L13 10.6A1 1 0 0 1 11 10.6Z", + "M6.71 10.27L5.91 16.47A1 1 0 0 1 7.89 16.73L8.69 10.53A1 1 0 0 1 6.71 10.27Z", + "M15.31 10.53L16.11 16.73A1 1 0 0 1 18.09 16.47L17.29 10.27A1 1 0 0 1 15.31 10.53Z", + ], + }, + /** shopping bag + sparkle. */ + marketing: { + d: [ + "M7.2 8.6H16.8A2.6 2.6 0 0 1 19.4 11.2V17.8A2.6 2.6 0 0 1 16.8 20.4H7.2A2.6 2.6 0 0 1 4.6 17.8V11.2A2.6 2.6 0 0 1 7.2 8.6Z", + "M8.9 9V8a3.1 3.1 0 0 1 6.2 0v1h-1.8V8a1.3 1.3 0 0 0-2.6 0v1Z", + "M19.4 1.3C19.5 3.05 20.95 4.5 22.7 4.6C20.95 4.7 19.5 6.15 19.4 7.9C19.3 6.15 17.85 4.7 16.1 4.6C17.85 4.5 19.3 3.05 19.4 1.3Z", + ], + }, + /** open-end wrench. */ + tools: { + d: [ + "M6.05 19.65L14.05 11.65A1.2 1.2 0 0 1 12.35 9.95L4.35 17.95A1.2 1.2 0 0 1 6.05 19.65Z", + "M16.4 2.9A4.7 4.7 0 1 1 16.4 12.3A4.7 4.7 0 1 1 16.4 2.9ZM16.4 4.9A2.7 2.7 0 1 1 16.4 10.3A2.7 2.7 0 1 1 16.4 4.9ZM15.34 6.54 19.58 2.3 21.64 4.36 17.46 8.66Z", + ], + }, + /** speech bubble + knocked-out sparkle. */ + assistant: { + d: [ + "M6.6 5H13.8A3.6 3.6 0 0 1 17.4 8.6v5.6a3.6 3.6 0 0 1-3.6 3.6h-2.4L6.6 20.9v-3.1A3.6 3.6 0 0 1 3 14.2V8.6A3.6 3.6 0 0 1 6.6 5ZM18.8 0.2C18.95 2.85 21.15 5.05 23.8 5.2C21.15 5.35 18.95 7.55 18.8 10.2C18.65 7.55 16.45 5.35 13.8 5.2C16.45 5.05 18.65 2.85 18.8 0.2Z", + "M18.8 1.6C18.91 3.51 20.49 5.09 22.4 5.2C20.49 5.31 18.91 6.89 18.8 8.8C18.69 6.89 17.11 5.31 15.2 5.2C17.11 5.09 18.69 3.51 18.8 1.6Z", + ], + }, + /** two frames, large and small. */ + "multi-size": { + d: [ + "M5.2 3H11.8A2.2 2.2 0 0 1 14 5.2V11.8A2.2 2.2 0 0 1 11.8 14H5.2A2.2 2.2 0 0 1 3 11.8V5.2A2.2 2.2 0 0 1 5.2 3ZM5.2 4.7H11.8A0.5 0.5 0 0 1 12.3 5.2V11.8A0.5 0.5 0 0 1 11.8 12.3H5.2A0.5 0.5 0 0 1 4.7 11.8V5.2A0.5 0.5 0 0 1 5.2 4.7Z", + "M16.2 14.4H19.2A1.8 1.8 0 0 1 21 16.2V19.2A1.8 1.8 0 0 1 19.2 21H16.2A1.8 1.8 0 0 1 14.4 19.2V16.2A1.8 1.8 0 0 1 16.2 14.4ZM16.3 15.9H19.1A0.4 0.4 0 0 1 19.5 16.3V19.1A0.4 0.4 0 0 1 19.1 19.5H16.3A0.4 0.4 0 0 1 15.9 19.1V16.3A0.4 0.4 0 0 1 16.3 15.9Z", + ], + }, +} as const; + +export type ToolGlyphName = keyof typeof TOOL_GLYPHS; diff --git a/src/canvas/toolTaskText.ts b/src/canvas/toolTaskText.ts index d712ab26..9f7eeafd 100644 --- a/src/canvas/toolTaskText.ts +++ b/src/canvas/toolTaskText.ts @@ -7,6 +7,16 @@ import { translateToolText } from "./toolText.ts"; const CJK = /[\u3400-\u9fff]/; +export interface ToolTaskFailure { + code: string; + params?: MessageParams; +} + +const CODE: Record = { + tool_concept_translation: "errors.tools.error.conceptTranslation", + tool_no_video_match: "errors.tools.error.noVideoMatch", +}; + const PHASE: Record = { 准备素材: "errors.tools.phase.prepare", 停止处理中: "errors.tools.phase.stopping", @@ -113,10 +123,17 @@ export function toolTaskPhase( export function toolTaskError( text: string | null | undefined, t: Translate = defaultTranslate, + failure?: ToolTaskFailure, ): string | undefined { + const key = failure && CODE[failure.code]; + if (key) return t(key, failure?.params); if (!text) return undefined; if (!CJK.test(text)) return text; if (text.startsWith("生成请求的提交结果不明。")) return t("errors.tools.error.submissionUnknown"); + const concept = /^无法识别「(.+)」,请改用英文描述,或在“模型接入”中添加对话模型以自动翻译。$/.exec(text); + if (concept) return t("errors.tools.error.conceptTranslation", { label: concept[1]! }); + const missing = /^视频中没有识别到「(.+)」,请换一种描述后重试。$/.exec(text); + if (missing) return t("errors.tools.error.noVideoMatch", { label: missing[1]! }); return t(ERROR[text] ?? "errors.tools.error.generic"); } diff --git a/src/canvas/toolText.ts b/src/canvas/toolText.ts index 0d7a925f..3c6f97f9 100644 --- a/src/canvas/toolText.ts +++ b/src/canvas/toolText.ts @@ -64,7 +64,7 @@ const EN: Record = { "人物三视图": "Character three-view", "面部三视图": "Face three-view", "产品三视图": "Product three-view", - "分镜": "Storyboard", + "连续分镜图": "Storyboard sequence", "多机位": "Multi-camera", "智能打光": "AI Lighting", "设置主光方向、软硬光、强度与颜色。": diff --git a/src/canvas/workspace.css b/src/canvas/workspace.css index 7ad1cc28..844adee6 100644 --- a/src/canvas/workspace.css +++ b/src/canvas/workspace.css @@ -809,19 +809,23 @@ --cw-node-info-shadow: 0 18px 44px rgba(0, 0, 0, 0.5); } .cw-node-info-trigger { - padding: 5px 10px; + padding: 4px 8px; border: 0; border-radius: 7px; background: transparent; color: var(--cw-node-info-fg); font: inherit; - font-size: 12px; cursor: default; white-space: nowrap; transition: background 0.15s, color 0.15s; } +/* Matches the language toggle next to it; the workspace button reset is more + specific, so the size needs this selector to win. */ +.cw-topbar .cw-node-info-trigger { + font-size: 11px; +} .cw-node-info-trigger:hover { background: var(--cw-node-info-hover-bg); color: var(--cw-node-info-hover-fg); @@ -950,14 +954,14 @@ } .cw-rail { position: relative; - margin-left: 12px; - width: 48px; - padding: 6px; + margin-left: 10px; + width: 46px; + padding: 4px; display: flex; flex-direction: column; - gap: 5px; + gap: 3px; border: 1px solid #ffffff0b; - border-radius: 12px; + border-radius: 10px; background: #202022; box-shadow: 0 8px 24px #0003; pointer-events: auto; @@ -970,7 +974,7 @@ display: flex; align-items: center; width: 36px; - height: 38px; + height: 36px; padding: 0; border: 0; border-radius: 8px; @@ -982,7 +986,7 @@ align-items: center; justify-content: center; width: 36px; - height: 38px; + height: 36px; flex-shrink: 0; border-radius: 8px; } @@ -1052,7 +1056,7 @@ } .cw-rail .cw-divider { display: block; - margin: 5px 4px 10px; + margin: 4px 2px; } @media (prefers-reduced-motion: reduce) { .cw-rail-mask, diff --git a/src/canvas/workspace/WorkspaceView.tsx b/src/canvas/workspace/WorkspaceView.tsx index 59239dd1..0bc6ad78 100644 --- a/src/canvas/workspace/WorkspaceView.tsx +++ b/src/canvas/workspace/WorkspaceView.tsx @@ -28,7 +28,7 @@ import { CanvasNotifications } from "../CanvasNotifications"; import { CanvasToolRail } from "../CanvasToolRail"; import { canStartReference, connectedCreationKinds } from "../connectionPolicy"; -import { REFERENCE_PORT_ICON_SIZE, REFERENCE_PORT_SIZE, referencePortIcon } from "../connectionPorts"; +import { REFERENCE_PORT_ICON_SIZE, REFERENCE_PORT_SIZE, referencePortDirection, referencePortIcon } from "../connectionPorts"; import { batchCreationKinds, SELECTION_PADDING, SELECTION_PORT_SIZE } from "../selectionReferences"; import "../multi-selection.css"; @@ -689,6 +689,9 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) { } data-reference-source={port.nodeId} data-side={port.side} + data-direction={referencePortDirection( + doc.nodes.find((node) => node.id === port.nodeId), + )} data-active={port.active} style={{ left: port.point.x, @@ -707,8 +710,9 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) { node.id === port.nodeId)?.data - .mediaRole === "asset", + referencePortDirection( + doc.nodes.find((node) => node.id === port.nodeId), + ), )} size={REFERENCE_PORT_ICON_SIZE} /> @@ -861,15 +865,17 @@ export function WorkspaceView({ rt }: { rt: WorkspaceRuntime }) { onClick={() => togglePanel("history")} /> importInput.current?.click()} /> field.key === "preset") + ?.options?.find( + (option) => option.value === task.parameters.preset, + )?.label + : undefined; const title = translateToolText( locale, - CANVAS_TOOLS.find((tool) => tool.id === task.tool)?.title ?? task.tool, + presetLabel ?? + CANVAS_TOOLS.find((tool) => tool.id === task.tool)?.title ?? + task.tool, ); const outputKind = toolOutputKind(task.tool, origin.kind); const targetNodeId = outputKind ? crypto.randomUUID() : undefined; diff --git a/src/canvas/workspace/workspaceChrome.tsx b/src/canvas/workspace/workspaceChrome.tsx index 67fc15d8..b91f0e65 100644 --- a/src/canvas/workspace/workspaceChrome.tsx +++ b/src/canvas/workspace/workspaceChrome.tsx @@ -166,6 +166,7 @@ export function IconButton({ highlighted = active, disabled, shortcut, + size, tooltipSide = "top", tooltipDelay = 200, }: { @@ -176,6 +177,7 @@ export function IconButton({ highlighted?: boolean; disabled?: boolean; shortcut?: string; + size?: number; tooltipSide?: "top" | "bottom" | "left" | "right"; tooltipDelay?: number; }) { @@ -191,7 +193,7 @@ export function IconButton({ onClick={onClick} disabled={disabled} > - + ); } diff --git a/src/i18n/messages/en-US/canvasShell.ts b/src/i18n/messages/en-US/canvasShell.ts index f2b609d7..7b98d885 100644 --- a/src/i18n/messages/en-US/canvasShell.ts +++ b/src/i18n/messages/en-US/canvasShell.ts @@ -112,7 +112,7 @@ export const canvasShell = { "canvas.selection.productReplacement": "Product replacement", "canvas.selection.dynamicPoster": "Motion poster", "canvas.selection.tools": "Tools", - "canvas.selection.inpaintRemove": "Remove object", + "canvas.selection.inpaintRemove": "Eraser", "canvas.selection.cropCompose": "Crop and compose", "canvas.selection.annotate": "Annotate", "canvas.selection.captureFrame": "Capture frame", diff --git a/src/i18n/messages/en-US/errors.ts b/src/i18n/messages/en-US/errors.ts index 7a257290..7c8ce33b 100644 --- a/src/i18n/messages/en-US/errors.ts +++ b/src/i18n/messages/en-US/errors.ts @@ -304,6 +304,10 @@ export const errors = { "errors.tools.error.cancelUnconfirmed": "The provider cancellation is unconfirmed.", "errors.tools.error.generic": "The tool task did not complete. Try again.", + "errors.tools.error.conceptTranslation": + 'Could not translate "{label}" into an English object name. Enter a short English description or check the chat model used for translation.', + "errors.tools.error.noVideoMatch": + 'Could not find "{label}" in the video. Try a different description.', "errors.tools.error.localStopped": "Local processing stopped.", "errors.tools.error.serviceInterrupted": "The service was interrupted. Execution records are kept; the task will not be regenerated automatically.", diff --git a/src/i18n/messages/en-US/models.ts b/src/i18n/messages/en-US/models.ts index 087ab08b..92d4a7c6 100644 --- a/src/i18n/messages/en-US/models.ts +++ b/src/i18n/messages/en-US/models.ts @@ -2,7 +2,7 @@ export const models = { "models.service.proxyMode": "Network connection mode", "models.service.proxySystem": "Use system proxy", - "models.service.proxyDirect": "Direct connection", + "models.service.proxyDirect": "Direct (no proxy)", "models.service.proxyNote": "Applies to this model service's requests and result downloads. Use a direct connection if a proxy causes timeouts or disconnections.", "models.readFailed": "Could not read model settings.", "models.reload": "Reload", diff --git a/src/i18n/messages/en-US/tools.ts b/src/i18n/messages/en-US/tools.ts index 3d3e6395..e8d50dc2 100644 --- a/src/i18n/messages/en-US/tools.ts +++ b/src/i18n/messages/en-US/tools.ts @@ -1,7 +1,6 @@ export const tools = { - "tools.nodeTask.presetTitle": "Generate preset images", "tools.nodeTask.presetNote": - "The selected model service will process this. Results will connect to this node.", + "The result connects to this node.", "tools.nodeTask.panelAria": "{title} panel", "tools.nodeTask.close": "Exit {title}", "tools.nodeTask.productReference": "Product reference", diff --git a/src/i18n/messages/zh-CN/canvasShell.ts b/src/i18n/messages/zh-CN/canvasShell.ts index 934f5e06..f31b8b18 100644 --- a/src/i18n/messages/zh-CN/canvasShell.ts +++ b/src/i18n/messages/zh-CN/canvasShell.ts @@ -110,7 +110,7 @@ export const canvasShell = { "canvas.selection.productReplacement": "商品替换", "canvas.selection.dynamicPoster": "动态海报", "canvas.selection.tools": "工具", - "canvas.selection.inpaintRemove": "消除", + "canvas.selection.inpaintRemove": "消除笔", "canvas.selection.cropCompose": "裁剪与构图", "canvas.selection.annotate": "标注", "canvas.selection.captureFrame": "截取帧", diff --git a/src/i18n/messages/zh-CN/errors.ts b/src/i18n/messages/zh-CN/errors.ts index 96aae9e4..9594f54c 100644 --- a/src/i18n/messages/zh-CN/errors.ts +++ b/src/i18n/messages/zh-CN/errors.ts @@ -221,6 +221,10 @@ export const errors = { "已接受工具任务的模型配置已变化,请新建尝试。", "errors.tools.error.cancelUnconfirmed": "供应商取消未确认。", "errors.tools.error.generic": "工具任务未完成,请重试。", + "errors.tools.error.conceptTranslation": + "无法将「{label}」转换为英文对象名称。请改用简短的英文描述,或检查用于自动翻译的对话模型。", + "errors.tools.error.noVideoMatch": + "视频中没有识别到「{label}」,请换一种描述后重试。", "errors.tools.error.localStopped": "已停止本地处理。", "errors.tools.error.serviceInterrupted": "服务中断,原执行记录已保留;不会自动重新生成。", diff --git a/src/i18n/messages/zh-CN/models.ts b/src/i18n/messages/zh-CN/models.ts index 3ad028e8..9415e962 100644 --- a/src/i18n/messages/zh-CN/models.ts +++ b/src/i18n/messages/zh-CN/models.ts @@ -2,7 +2,7 @@ export const models = { "models.service.proxyMode": "网络连接方式", "models.service.proxySystem": "跟随系统代理", - "models.service.proxyDirect": "直连", + "models.service.proxyDirect": "直连(不使用代理)", "models.service.proxyNote": "仅影响此模型服务的请求与结果下载。代理导致超时或连接中断时,可切换为直连。", "models.readFailed": "无法读取模型配置。", "models.reload": "重新读取", diff --git a/src/i18n/messages/zh-CN/tools.ts b/src/i18n/messages/zh-CN/tools.ts index 5d7643bd..818b6553 100644 --- a/src/i18n/messages/zh-CN/tools.ts +++ b/src/i18n/messages/zh-CN/tools.ts @@ -1,7 +1,6 @@ /** Tool catalog, tool editors and local media tools. */ export const tools = { - "tools.nodeTask.presetTitle": "生成预设图片", - "tools.nodeTask.presetNote": "由已选的模型服务处理,结果会连接到当前节点。", + "tools.nodeTask.presetNote": "生成结果会连接到当前节点。", "tools.nodeTask.panelAria": "{title}面板", "tools.nodeTask.close": "退出{title}", "tools.nodeTask.productReference": "商品参考图", diff --git a/tests/browser/node_connections.mjs b/tests/browser/node_connections.mjs index 385636ed..34ffef5e 100644 --- a/tests/browser/node_connections.mjs +++ b/tests/browser/node_connections.mjs @@ -55,6 +55,10 @@ const browser = await chromium.launch({ args: ["--no-sandbox"], }); const page = await browser.newPage({ locale: "zh-CN", viewport: { width: 1600, height: 1000 } }); +// Skip the first-run model setup welcome; it covers the canvas. +await page.addInitScript(() => + localStorage.setItem("opendirector.modelSetup.v1", "dismissed"), +); page.setDefaultTimeout(12000); const errors = []; page.on("pageerror", (e) => errors.push(e.message)); @@ -199,6 +203,20 @@ try { return port; }; if (kind === "image" && targetKind === "subject") { + // An empty generation node only takes references, so its ports point inwards. + await target.hover(); + const emptyTargetPorts = page.locator( + `.cw-node-reference-port[data-reference-source="${f.target}"]`, + ); + await emptyTargetPorts.first().waitFor(); + assert.deepEqual( + await emptyTargetPorts.evaluateAll((ports) => + ports + .map((port) => `${port.dataset.side}:${port.dataset.direction}`) + .sort(), + ), + ["bottom:in", "left:in", "right:in", "top:in"], + ); for (const theme of ["light", "dark"]) { await page.evaluate((theme) => { document.documentElement.dataset.theme = theme; @@ -219,6 +237,15 @@ try { await nodePorts.filter({ has: page.locator("svg") }).count(), 4, ); + // Uploaded material only gives references, so every port points outwards. + assert.deepEqual( + await nodePorts.evaluateAll((ports) => + ports + .map((port) => `${port.dataset.side}:${port.dataset.direction}`) + .sort(), + ), + ["bottom:out", "left:out", "right:out", "top:out"], + ); assert.equal( await page .locator('.cw-node-reference-port[data-active="true"]') @@ -358,6 +385,21 @@ try { count, ); if (kind === "image" && targetKind === "subject") { + // Assembling the source into the subject gives it content, so the node now + // both accepts and provides references. + await target.hover(); + const targetPorts = page.locator( + `.cw-node-reference-port[data-reference-source="${f.target}"]`, + ); + await targetPorts.first().waitFor(); + assert.deepEqual( + await targetPorts.evaluateAll((ports) => + ports + .map((port) => `${port.dataset.side}:${port.dataset.direction}`) + .sort(), + ), + ["bottom:both", "left:both", "right:both", "top:both"], + ); const inbound = await showPort("left", target); const from = await inbound.boundingBox(); const to = await source.boundingBox(); @@ -405,6 +447,52 @@ try { await page.reload(); await page.getByText("已保存到本机", { exact: true }).waitFor(); assert.equal(await page.locator(".react-flow__edge").count(), count); + if (kind === "image" && targetKind === "subject") { + const sourceAssetId = saved.canvasItems.find( + (item) => item.id === f.source, + ).binding.objectRef.assetId; + const unavailable = (url) => + url.pathname === + `/api/creation/projects/${f.id}/assets/${sourceAssetId}/content`; + await page.route(unavailable, (route) => + route.fulfill({ + status: 409, + contentType: "application/json", + body: JSON.stringify({ + code: "storage_unavailable", + message: "Fixture asset is unavailable", + }), + }), + ); + await page.reload(); + await page.getByText("已保存到本机", { exact: true }).waitFor(); + await source.locator(".cw-node-placeholder").waitFor(); + await source.hover(); + await page.waitForTimeout(100); + assert.equal( + await page + .locator( + `.cw-node-reference-port[data-reference-source="${f.source}"]`, + ) + .count(), + 0, + ); + await page.unroute(unavailable); + await page.reload(); + await page.getByText("已保存到本机", { exact: true }).waitFor(); + await source.locator("img").waitFor(); + await source.hover(); + const recoveredPorts = page.locator( + `.cw-node-reference-port[data-reference-source="${f.source}"]`, + ); + await recoveredPorts.first().waitFor(); + assert.deepEqual( + await recoveredPorts.evaluateAll((ports) => + ports.map((port) => port.dataset.direction), + ), + ["out", "out", "out", "out"], + ); + } rows.push({ mode: "unified", kind, diff --git a/tests/conftest.py b/tests/conftest.py index a54e6754..af1661df 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,26 @@ explains why a case was skipped. """ import pytest +import socket from opendirector.wiring import configure_runtime configure_runtime() +@pytest.fixture +def public_cdn(monkeypatch): + """Mocked downloads still validate DNS, without depending on public resolvers.""" + lookup = socket.getaddrinfo + + def resolve(host, port, *args, **kwargs): + if host in {'cdn.test', 'cdn.example'}: + return [(socket.AF_INET, socket.SOCK_STREAM, 6, '', ('93.184.216.34', port))] + return lookup(host, port, *args, **kwargs) + + monkeypatch.setattr(socket, 'getaddrinfo', resolve) + + def pytest_configure(config): config.addinivalue_line('markers', 'requires_ffmpeg: needs a local FFmpeg and ffprobe') config.addinivalue_line('markers', 'requires_posix: needs POSIX filesystem semantics (symlinks, shebang)') diff --git a/tests/nodeReferencePorts.test.ts b/tests/nodeReferencePorts.test.ts index fc522697..cdea3cc0 100644 --- a/tests/nodeReferencePorts.test.ts +++ b/tests/nodeReferencePorts.test.ts @@ -2,15 +2,47 @@ import assert from "node:assert/strict"; import test from "node:test"; import { floatingReferencePort, + referencePortDirection, + referencePortIcon, referencePortMetrics, restingReferencePort, REFERENCE_PORT_ICON_SIZE, REFERENCE_PORT_MAX_SIZE, REFERENCE_PORT_SIZE, } from "../src/canvas/connectionPorts.ts"; +import { createNode } from "../src/canvas/model.ts"; +import { canStartReference } from "../src/canvas/connectionPolicy.ts"; const bounds = { x: 100, y: 100, width: 300, height: 300 }; +test("asset ports require usable content while empty generation ports stay discoverable", () => { + for (const kind of ["image", "video", "audio"] as const) { + const asset = createNode(kind, undefined, { + mediaRole: "asset", + src: `data:${kind}/fixture`, + assetState: "ready", + }); + assert.equal(canStartReference(asset), true, kind); + assert.equal(referencePortDirection(asset), "out", kind); + assert.equal( + canStartReference({ + ...asset, + data: { ...asset.data, assetState: "missing" }, + }), + false, + kind, + ); + assert.equal( + canStartReference(createNode(kind, undefined, { mediaRole: "asset" })), + false, + kind, + ); + const emptyGeneration = createNode(kind); + assert.equal(canStartReference(emptyGeneration), true, kind); + assert.equal(referencePortDirection(emptyGeneration), "in", kind); + } +}); + test("resting port keeps its screen size cap and a six pixel gap to the node", () => { for (const zoom of [0.08, 0.6, 1, 1.5, 4, 8]) { const metrics = referencePortMetrics(zoom); @@ -22,9 +54,8 @@ test("resting port keeps its screen size cap and a six pixel gap to the node", ( ); // The drawn plus stays proportional to the circle at every zoom. assert.ok( - Math.abs( - metrics.iconSize - REFERENCE_PORT_ICON_SIZE * metrics.scale, - ) < 1e-8, + Math.abs(metrics.iconSize - REFERENCE_PORT_ICON_SIZE * metrics.scale) < + 1e-8, ); assert.ok(REFERENCE_PORT_ICON_SIZE < REFERENCE_PORT_SIZE); const resting = restingReferencePort(bounds, "right", zoom); @@ -74,3 +105,61 @@ test("enlarged ports keep their node gutter while following the nearby pointer", ); } }); + +test("port arrows follow what a node can actually give and take", () => { + const sides = ["top", "right", "bottom", "left"] as const; + const glyphs = (direction: "in" | "out" | "both") => + sides.map((side) => referencePortIcon(side, direction)); + + const asset = createNode("image", undefined, { + src: "data:image/fixture", + mediaRole: "asset", + }); + assert.equal(referencePortDirection(asset), "out"); + assert.deepEqual(glyphs("out"), [ + "arrowUp", + "arrowRight", + "arrowDown", + "arrowLeft", + ]); + + // An empty generation node cannot be a source, so its ports point inwards. + for (const node of [ + createNode("image"), + createNode("video"), + createNode("audio"), + createNode("text"), + createNode("subject"), + ]) { + assert.equal(referencePortDirection(node), "in", node.type); + assert.deepEqual(glyphs("in"), [ + "arrowDown", + "arrowLeft", + "arrowUp", + "arrowRight", + ]); + } + + // Content makes the same node both a source and a target. + for (const node of [ + createNode("image", undefined, { src: "data:image/fixture" }), + createNode("text", undefined, { text: "written" }), + ]) { + assert.equal(referencePortDirection(node), "both", node.type); + assert.deepEqual(glyphs("both"), [ + "referenceArrowsVertical", + "referenceArrowsHorizontal", + "referenceArrowsVertical", + "referenceArrowsHorizontal", + ]); + } + + const missing = createNode("image", undefined, { + src: "data:image/fixture", + mediaRole: "generation", + assetState: "missing", + }); + assert.equal(referencePortDirection(missing), "in"); + // A port whose owner is no longer rendered keeps the two-direction glyph. + assert.equal(referencePortDirection(undefined), "both"); +}); diff --git a/tests/test_creation_tool_drafts.py b/tests/test_creation_tool_drafts.py index 2fe598d5..af41b6c9 100644 --- a/tests/test_creation_tool_drafts.py +++ b/tests/test_creation_tool_drafts.py @@ -33,6 +33,43 @@ def test_media_card_size_matches_pixels_and_compacts_audio(): assert media_card_size('audio', 1080, 1920, 300, 520) == (300, 112) +def test_result_titles_stay_named_after_their_preset_without_collisions(): + from types import SimpleNamespace + + from opendirector.application.creation_tool_drafts import result_title + + def snapshot(*titles): + return SimpleNamespace(canvasItems=[SimpleNamespace(id=uuid4(), title=title, visible=True) + for title in titles]) + + assert result_title(snapshot(), '面部三视图') == '面部三视图' + assert result_title(snapshot('面部三视图'), '面部三视图') == '面部三视图 2' + assert result_title(snapshot('面部三视图', '面部三视图 2'), '面部三视图') == '面部三视图 3' + # The empty placeholder that is about to receive the result is not a conflict. + placeholder = snapshot('面部三视图') + assert result_title(placeholder, '面部三视图', exclude=placeholder.canvasItems[0].id) == '面部三视图' + # A later run of a numbered preset continues the same series. + assert result_title(snapshot('连续分镜图 1', '连续分镜图 2'), '连续分镜图 1') == '连续分镜图 3' + # Outputs of one run are numbered in publication order. + assert result_title(snapshot(), '连续分镜图 1', ['连续分镜图 1']) == '连续分镜图 2' + # Unrelated names keep their own family. + assert result_title(snapshot('超清 4K'), '超清 4K') == '超清 4K 2' + + +@pytest.mark.parametrize('suffix', ['²', '①', '٢', '2']) +def test_result_titles_treat_unicode_digit_suffixes_as_ordinary_text(suffix): + from types import SimpleNamespace + from opendirector.application.creation_tool_drafts import result_title + + title = f'Area m {suffix}' + snapshot = SimpleNamespace(canvasItems=[ + SimpleNamespace(id=uuid4(), title=name, visible=True) + for name in ['Preset', f'Preset {suffix}', title] + ]) + assert result_title(snapshot, 'Preset') == 'Preset 2' + assert result_title(snapshot, title) == f'{title} 2' + + @pytest.fixture def prepared(project, monkeypatch): store, pid, session = project @@ -128,7 +165,8 @@ def test_stale_or_forged_draft_cannot_accept_task(prepared, change): assert service.list(str(pid)) == [] and seen == [] -def test_source_change_keeps_candidate_then_explicit_adoption_is_independent(prepared): +@pytest.mark.parametrize('title_collision', [False, True]) +def test_source_change_keeps_candidate_then_explicit_adoption_is_independent(prepared, title_collision): store, pid, session, service, value, source, _, _ = prepared async def run(): try: @@ -142,11 +180,19 @@ async def run(): current = store.snapshot(pid) target = current.canvasItems[1] assert not target.visible - assert service.placement(str(pid), value['id'], 'placed', session, policy='explicit')['placement'] == 'placed' + if title_collision: + owner = current.canvasItems[0] + assert send(store, pid, 'canvas.update', session=session, versions=[ver('canvas_item', owner)], + itemId=owner.id, title=target.title).status == 'applied' + adopted = service.placement(str(pid), value['id'], 'placed', session, policy='explicit') + assert adopted['placement'] == 'placed' current = store.snapshot(pid) assert current.canvasItems[0].binding.objectRef.assetId == asset.id assert current.canvasItems[1].visible assert current.canvasItems[1].binding.objectRef.assetId != source.id + assert current.canvasItems[1].title == ('Pending 2' if title_collision else 'Pending') + assert service.placement(str(pid), value['id'], 'placed', session, policy='explicit') == adopted + assert store.snapshot(pid) == current finally: await service.close() asyncio.run(run()) diff --git a/tests/test_localization.py b/tests/test_localization.py index 2492f745..121f8c53 100644 --- a/tests/test_localization.py +++ b/tests/test_localization.py @@ -19,6 +19,15 @@ def test_accept_language_uses_the_primary_language() -> None: assert wants_english(request("")) is False +def test_matting_error_codes_remain_localized_when_backend_copy_changes() -> None: + for code, expected in [ + ('tool_concept_translation', 'short English subject description'), + ('tool_no_video_match', 'not found in the video'), + ]: + assert expected in localized_error(request('en-US'), chinese='更新后的中文', status=422, code=code) + assert localized_error(request('zh-CN'), chinese='更新后的中文', status=422, code=code) == '更新后的中文' + + def test_chinese_and_english_messages_do_not_mix() -> None: assert ( localized_error( diff --git a/tests/test_openai_images.py b/tests/test_openai_images.py index d91c42bf..5e269592 100644 --- a/tests/test_openai_images.py +++ b/tests/test_openai_images.py @@ -20,6 +20,7 @@ from opendirector.domain.input_rules import input_rules from opendirector.infrastructure.model_configs import ModelConfigs from opendirector.domain.capabilities import ImageRequest +from opendirector.domain.generation import GenerationError from opendirector.integrations.model_adapters import OpenAIImages, adapter_for from opendirector.integrations.providers import GenerationProviders @@ -151,6 +152,20 @@ def handle(request): CONFIG = {'kind': 'image', 'url': GPT_IMAGE['url'], 'modelId': 'gpt-image-1', 'apiKey': KEY, 'protocol': 'openai'} +def test_malformed_cdn_url_is_a_download_protocol_error_after_accepted_submit(): + seen = [] + + def handle(request): + seen.append(request.method) + return httpx.Response(200, json={'data': [{'url': 'https://[broken/result.png'}]}) + + adapter = OpenAIImages(httpx.MockTransport(handle)) + with pytest.raises(GenerationError) as error: + asyncio.run(adapter.image(CONFIG, ImageRequest(prompt='edit', width=64, height=48, image=png()))) + assert (error.value.details.stage, error.value.details.category) == ('download', 'protocol') + assert seen == ['POST'] + + def test_capability_adapter_keeps_the_gpt_image_request_shape(): adapter, seen = adapter_calls(adapter_for(GPT_IMAGE)) result = asyncio.run(adapter.image(CONFIG, ImageRequest(prompt='灯塔', width=1536, height=1024))) diff --git a/tests/test_result_download_security.py b/tests/test_result_download_security.py new file mode 100644 index 00000000..72e42da1 --- /dev/null +++ b/tests/test_result_download_security.py @@ -0,0 +1,157 @@ +"""Unconfigured result hosts cannot turn a completed image call into SSRF.""" +import asyncio +import socket + +import httpx +import pytest + +from opendirector.domain.capabilities import ImageRequest +from opendirector.domain.generation import GenerationError +from opendirector.integrations.model_adapters import OpenAIImages +from opendirector.integrations.providers import GenerationProviders +from test_openai_images import CONFIG, png + + +@pytest.fixture +def resolve(monkeypatch): + calls = [] + addresses = ['93.184.216.34'] + + def lookup(host, port, **kwargs): + calls.append(host) + return [(socket.AF_INET6 if ':' in address else socket.AF_INET, + socket.SOCK_STREAM, 6, '', (address, port)) for address in addresses] + + monkeypatch.setattr(socket, 'getaddrinfo', lookup) + return addresses, calls + + +def image_from(url, responder=None): + calls = [] + + def handle(request): + calls.append(request) + if request.method == 'POST': + return httpx.Response(200, json={'data': [{'url': url}]}) + return responder(request) if responder else httpx.Response(200, content=png()) + + adapter = OpenAIImages(httpx.MockTransport(handle)) + return adapter, calls + + +def generate(adapter, config=CONFIG): + return asyncio.run(adapter.image(config, ImageRequest(prompt='edit', width=64, height=48))) + + +@pytest.mark.parametrize('url', [ + 'http://127.0.0.1:8019/private', 'http://127.1/private', + 'http://localhost:8019/private', 'http://LOCALHOST./private', + 'http://10.0.0.1/private', 'http://172.16.0.1/private', + 'http://192.168.1.1/private', 'http://169.254.169.254/latest/meta-data', + 'http://100.64.0.1/private', 'http://0.0.0.0/private', + 'http://224.0.0.1/private', 'http://[::1]/private', + 'http://[fc00::1]/private', 'http://[fe80::1]/private', + 'http://[::ffff:127.0.0.1]/private', 'file:///private.png', + 'https://user:password@cdn.test/private', + 'https://cdn.test:bad/result.png', +]) +def test_unconfigured_local_result_is_rejected_before_any_get(url, resolve): + # Noncanonical numeric hosts and local aliases must be checked after DNS too. + resolve[0][:] = ['127.0.0.1'] + adapter, calls = image_from(url) + with pytest.raises(GenerationError) as error: + generate(adapter) + assert (error.value.details.stage, error.value.details.category) == ('download', 'protocol') + assert [call.method for call in calls] == ['POST'] + + +@pytest.mark.parametrize('addresses', [ + ['127.0.0.1'], ['169.254.169.254'], ['::1'], ['::ffff:192.168.1.1'], + ['93.184.216.34', '10.0.0.1'], [], +]) +def test_dns_names_and_mixed_answers_cannot_hide_private_addresses(resolve, addresses): + resolve[0][:] = addresses + adapter, calls = image_from('https://cdn.test/result.png') + with pytest.raises(GenerationError): + generate(adapter) + assert len(calls) == 1 + + +def test_public_download_pins_checked_ip_and_preserves_host_tls_and_signed_path(resolve): + adapter, calls = image_from('https://cdn.test/result.png?signature=keep%2Bme') + assert generate(adapter).data == png() + post, download = calls + assert post.headers['authorization'] == f"Bearer {CONFIG['apiKey']}" + assert str(download.url) == 'https://93.184.216.34/result.png?signature=keep%2Bme' + assert download.headers['host'] == 'cdn.test' + assert download.extensions['sni_hostname'] == 'cdn.test' + assert 'authorization' not in download.headers + assert resolve[1] == ['cdn.test'] # No second, unchecked DNS lookup to connect. + + +def test_public_ipv6_dns_answer_is_pinned(resolve): + resolve[0][:] = ['2606:4700:4700::1111'] + adapter, calls = image_from('https://cdn.test/result.png') + assert generate(adapter).data == png() + assert calls[1].url.host == '2606:4700:4700::1111' + assert calls[1].headers['host'] == 'cdn.test' + + +def test_failed_dns_cannot_fall_back_to_an_unchecked_request(monkeypatch): + def failed(*args, **kwargs): + raise socket.gaierror('resolver unavailable') + + monkeypatch.setattr(socket, 'getaddrinfo', failed) + adapter, calls = image_from('https://cdn.test/result.png') + with pytest.raises(GenerationError) as error: + generate(adapter) + assert error.value.details.stage == 'download' + assert len(calls) == 1 + + +@pytest.mark.parametrize('location', [ + 'http://127.0.0.1:8019/private', 'http://[::1]/private', 'https://[broken/result.png', +]) +def test_public_redirect_cannot_reach_a_local_service(resolve, location): + adapter, calls = image_from('https://cdn.test/start', + lambda _: httpx.Response(302, headers={'Location': location})) + with pytest.raises(GenerationError) as error: + generate(adapter) + assert error.value.details.stage == 'download' + assert [call.method for call in calls] == ['POST', 'GET'] + + +def test_relative_public_redirects_are_revalidated_and_have_a_hop_limit(resolve): + def redirected(request): + return (httpx.Response(302, headers={'Location': '../final.png'}) + if request.url.path == '/dir/start' else httpx.Response(200, content=png())) + + adapter, calls = image_from('https://cdn.test/dir/start', redirected) + assert generate(adapter).data == png() + assert [call.url.path for call in calls[1:]] == ['/dir/start', '/final.png'] + assert resolve[1] == ['cdn.test', 'cdn.test'] + adapter, calls = image_from('https://cdn.test/loop', + lambda _: httpx.Response(302, headers={'Location': '/loop'})) + with pytest.raises(GenerationError): + generate(adapter) + assert len(calls) <= 7 + + +def test_explicit_local_origin_keeps_auth_but_a_different_port_is_untrusted(resolve): + config = {**CONFIG, 'url': 'http://127.0.0.1:9000/v1'} + adapter, calls = image_from('http://127.0.0.1:9000/result.png') + assert generate(adapter, config).data == png() + assert calls[1].headers['authorization'] == f"Bearer {CONFIG['apiKey']}" + adapter, calls = image_from('http://127.0.0.1:8019/private') + with pytest.raises(GenerationError): + generate(adapter, config) + assert len(calls) == 1 + + +def test_compatible_generation_recovery_uses_the_same_public_host_policy(resolve): + calls = [] + provider = GenerationProviders(None, httpx.MockTransport( + lambda request: calls.append(request) or httpx.Response(200, content=png()))) + with pytest.raises(GenerationError): + asyncio.run(provider.resume_result(CONFIG, 'png', 'http://127.0.0.1:8019/private')) + assert calls == [] diff --git a/tests/test_self_hosted.py b/tests/test_self_hosted.py index 48838a97..2e69a56a 100644 --- a/tests/test_self_hosted.py +++ b/tests/test_self_hosted.py @@ -148,7 +148,7 @@ def test_fasth3_480p_follows_reference_orientation(tmp_path, dimensions, expecte @pytest.mark.parametrize('origin,expected_auth', [('http://localhost:9999','Bearer private-token'),('https://cdn.example',None)]) -def test_self_hosted_image_result_auth_stays_on_configured_origin(tmp_path, origin, expected_auth): +def test_self_hosted_image_result_auth_stays_on_configured_origin(tmp_path, origin, expected_auth, public_cdn): store=LocalProjects(tmp_path) def handle(request): if request.method=='POST': diff --git a/tests/test_self_hosted_proxy.py b/tests/test_self_hosted_proxy.py index b07fbc03..dfd99fd3 100644 --- a/tests/test_self_hosted_proxy.py +++ b/tests/test_self_hosted_proxy.py @@ -116,7 +116,7 @@ async def submitted(value): accepted.append(value) assert flags and all(flag is False for flag in flags) -def test_external_result_cdn_keeps_proxy_and_never_receives_service_token(monkeypatch): +def test_external_result_cdn_keeps_proxy_and_never_receives_service_token(monkeypatch, public_cdn): async_client = httpx.AsyncClient flags = [] def client(**kwargs): diff --git a/tests/test_tool_tasks.py b/tests/test_tool_tasks.py index 0f066ab2..b45d25c7 100644 --- a/tests/test_tool_tasks.py +++ b/tests/test_tool_tasks.py @@ -120,7 +120,7 @@ def test_catalog_reports_availability_by_capability(project): assert {'image.edit', 'video.first_frame', 'audio.speech', 'text.chat'} <= set(configured['capabilities']) assert any(field['key'] == 'preset' and field['options'] for field in tools['image-presets']['fields']) preset_field = next(field for field in tools['image-presets']['fields'] if field['key'] == 'preset') - assert [option['label'] for option in preset_field['options']] == ['人物三视图', '面部三视图', '产品三视图', '分镜', '多机位'] + assert [option['label'] for option in preset_field['options']] == ['人物三视图', '面部三视图', '产品三视图', '连续分镜图', '多机位'] def test_outpaint_keeps_original_pixels_and_returns_requested_size(project): @@ -204,6 +204,47 @@ def test_inpainting_only_changes_the_masked_region(project): assert image.getpixel((5, 5)) == (0, 0, 255) and image.getpixel((60, 60))[0] > 200 +@pytest.mark.parametrize('cdn_result', ['image', 'timeout', 'invalid_url', 'malformed_url']) +def test_smart_edit_accepts_cdn_results_without_forwarding_the_service_key(project, cdn_result, public_cdn): + store, project_id, session = project + configs = ModelConfigs(store.local.repository) + configs.save('image', 'https://images.test/v1', 'seedream-5.0-pro', 'service-secret', protocol='openai') + source = upload(store, project_id, session, 'blue.png', png()) + mask = upload(store, project_id, session, 'mask.png', base64.b64decode(box_mask((64, 48), (10, 8, 30, 32)))) + calls = [] + + def handle(request): + calls.append(request.method) + if request.method == 'POST': + assert request.headers['authorization'] == 'Bearer service-secret' + assert b'b64_json' in request.read() + url = 'file:///private.png' if cdn_result == 'invalid_url' else 'https://cdn.test/result.png' + if cdn_result == 'malformed_url': + url = 'https://[broken/result.png' + return httpx.Response(200, json={'data': [{'url': url}]}) + assert request.url.host == '93.184.216.34' and request.headers['host'] == 'cdn.test' + assert 'authorization' not in request.headers + if cdn_result == 'timeout': + raise httpx.ReadTimeout('download interrupted', request=request) + return httpx.Response(200, content=png(color='red')) + + task = run(store, configs, httpx.MockTransport(handle), project_id, session, + {'tool': 'smart-edit', 'sourceAssetIds': [source], 'maskAssetId': mask, + 'parameters': {'editMode': 'selection', 'prompt': '改成红色'}}) + assert calls.count('POST') == 1 + if cdn_result == 'image': + assert task['status'] == 'succeeded', task['error'] + result = output_image(store, project_id, task) + assert result.getpixel((20, 20)) == (255, 0, 0) + assert result.getpixel((5, 5)) == (0, 0, 255) + else: + assert task['status'] == 'failed' + assert '提交结果不明' not in task['error'] + assert ('连接超时' if cdn_result == 'timeout' else '接口协议') in task['error'] + if cdn_result in {'invalid_url', 'malformed_url'}: + assert calls == ['POST'] + + def test_presets_return_one_output_per_shot_and_local_hd_needs_no_model(project): store, project_id, session = project configs = configure(store, ZIMAGE) @@ -219,7 +260,7 @@ def test_presets_return_one_output_per_shot_and_local_hd_needs_no_model(project) @pytest.mark.parametrize('preset,count,size', [ ('three-view', 1, (2048, 1152)), - ('face-three-view', 1, (2688, 1152)), + ('face-three-view', 1, (3456, 1152)), ('product-three-view', 1, (2048, 1152)), ('storyboard', 4, (2048, 1152)), ('multi-camera', 4, (2048, 1152)), @@ -242,6 +283,11 @@ def test_image_presets_reframe_portrait_reference_and_register_each_result(proje assert width / height == pytest.approx(size[0] / size[1], abs=.04) if preset == 'face-three-view': assert '左侧90度'.encode() in calls[0] and '右侧90度'.encode() in calls[0] + # The three heads must stay apart; touching profiles are the reported defect. + assert '不接触、不重叠、不粘连'.encode() in calls[0] + # The last two heads must face opposite ways; two matching profiles were the second defect. + assert '鼻尖朝向画面右方'.encode() in calls[0] and '鼻尖朝向画面左方'.encode() in calls[0] + assert '朝向相反、左右镜像对称'.encode() in calls[0] elif preset == 'product-three-view': assert '近正交投影'.encode() in calls[0] @@ -547,6 +593,7 @@ def test_layer_decompose_by_boxes_and_description_without_an_editor(project): task = run(store, configs, transport, project_id, session, {'tool': 'layer-decompose', 'sourceAssetIds': [source], 'parameters': {'mode': 'description', 'prompt': '吉他'}}) assert task['status'] == 'failed' and '吉他' in task['error'] + assert task['errorDetails'] == {'code': 'tool_concept_translation', 'params': {'label': '吉他'}} def test_selection_preview_returns_alpha_masks_without_output_assets(project): @@ -669,6 +716,7 @@ def test_smart_edit_uses_the_active_mode_and_retains_legacy_combined_edits(proje assert task['status'] == 'succeeded', task['error'] assert calls[0].count(b'name="image"') == (1 if mode == 'selection' else 2) assert ('画笔说明'.encode() in calls[0]) == (mode != 'selection') + assert ('逐像素对齐'.encode() in calls[0]) == (mode != 'annotation') result = output_rgba(store, project_id, task) assert result.getpixel((20, 20)) == (255, 0, 0, 255) outside = (255, 0, 0, 255) if mode == 'annotation' else (0, 0, 255, 255) @@ -754,15 +802,16 @@ def test_video_matting_keeps_the_tracked_subject_over_the_chosen_background(proj red, green, blue = frame.getpixel((58, 18)) assert green > 200 and red < 60 and blue < 60 # The untracked right half is green screen. task = run(store, configs, httpx.MockTransport(tracking_server(log)), project_id, session, - {'tool': 'video-matting', 'sourceAssetIds': [source], 'parameters': {'background': 'transparent', 'prompt': '狗'}}) + {'tool': 'video-matting', 'sourceAssetIds': [source], 'parameters': {'background': 'transparent', 'prompt': '猫咪'}}) assert task['status'] == 'succeeded', task['error'] - assert log[-1] == 'dog' and task['outputs'][0]['type'] == 'video/webm' + assert log[-1] == 'cat' and task['outputs'][0]['type'] == 'video/webm' path = tmp_path / ('output.' + task['outputs'][0]['type'].split('/')[-1]) path.write_bytes(CreationToolAssets(store).read_bytes(project_id, task['outputs'][0]['id'])) assert probe(path, 'stream_tags=alpha_mode').get('tags', {}).get('alpha_mode') == '1' task = run(store, configs, httpx.MockTransport(tracking_server(log, empty=True)), project_id, session, {'tool': 'video-matting', 'sourceAssetIds': [source], 'parameters': {'background': 'black'}}) assert task['status'] == 'failed' and '没有识别到「人物」' in task['error'] + assert task['errorDetails'] == {'code': 'tool_no_video_match', 'params': {'label': '人物'}} def configure_depth(store): diff --git a/tests/toolTaskText.test.ts b/tests/toolTaskText.test.ts index 672737b1..b00e6e6d 100644 --- a/tests/toolTaskText.test.ts +++ b/tests/toolTaskText.test.ts @@ -48,3 +48,18 @@ test("tool task errors never leak persisted Chinese", () => { assert.equal(toolTaskError(null), undefined); setActiveLocale("zh-CN"); }); + +test("matting errors distinguish prompt translation from an empty tracking result in both locales", () => { + const translation = "无法识别「三花猫」,请改用英文描述,或在“模型接入”中添加对话模型以自动翻译。"; + const missing = "视频中没有识别到「cat」,请换一种描述后重试。"; + try { + setActiveLocale("zh-CN"); + assert.match(toolTaskError(translation)!, /「三花猫」.*改用简短的英文描述/); + assert.equal(toolTaskError(missing), missing); + setActiveLocale("en-US"); + assert.match(toolTaskError(translation)!, /translate "三花猫".*short English description/); + assert.match(toolTaskError(missing)!, /Could not find "cat" in the video/); + } finally { + setActiveLocale("zh-CN"); + } +}); diff --git a/tests/toolTasks.test.ts b/tests/toolTasks.test.ts index cba11731..d692d313 100644 --- a/tests/toolTasks.test.ts +++ b/tests/toolTasks.test.ts @@ -8,6 +8,29 @@ import { type ToolTask, } from "../src/backend/tools.ts"; import type { ProjectSnapshot } from "../src/contracts/generated.ts"; +import { setActiveLocale } from "../src/i18n/messages.ts"; + +test("structured matting failures survive changed backend wording in both locales", () => { + const task: ToolTask = { + id: "task", tool: "video-matting", status: "failed", outputs: [], + phase: null, error: "Backend wording has changed entirely.", + }; + try { + for (const locale of ["zh-CN", "en-US"] as const) { + setActiveLocale(locale); + for (const [code, expected] of [ + ["tool_concept_translation", locale === "zh-CN" ? "改用简短的英文描述" : "short English description"], + ["tool_no_video_match", locale === "zh-CN" ? "视频中没有识别到" : "Could not find"], + ]) { + const message = toolTaskMessage({ ...task, errorDetails: { code, params: { label: "三花猫" } } })!; + assert.ok(message.includes(expected)); + assert.ok(message.includes("三花猫")); + } + } + } finally { + setActiveLocale("zh-CN"); + } +}); test("tool cancellation messages distinguish stopping, remote acknowledgement and historical uncertainty", () => { const task: ToolTask = {