diff --git a/.project-docs/30-worklog/tasks/20260916-design-media-client-a7d3e9c1.md b/.project-docs/30-worklog/tasks/20260916-design-media-client-a7d3e9c1.md index 31eff68..d359e2d 100644 --- a/.project-docs/30-worklog/tasks/20260916-design-media-client-a7d3e9c1.md +++ b/.project-docs/30-worklog/tasks/20260916-design-media-client-a7d3e9c1.md @@ -12,44 +12,41 @@ ## Scope -- Diagnosis only: YouthCreationCard media and reference controls, localized blockers, image workspace direct-edit command path. Resumed for user-requested flow verification; own this record and scripts/diagnostics/design-video-flow.test.tsx plus its isolated Vitest config. Use actual React components/store with a process-local bridge to the real server compiler, mock only transport/uploads; no product edits. +- Client implementation: own the shared/Main command and capability serialization, image-workspace store preparation action, YouthCreationCard capability-aware media/reference controls, explicit video first-frame binding, history playback/preview/reuse actions, and the related tests plus scripts/diagnostics/design-video-flow.test.tsx and its isolated Vitest config. Work only in this isolated client worktree; do not touch server, root main, provider calls or paid generation. ## Intent And Constraints -- Determine whether the supported UI emits a complete video draft and whether the reference badge represents a first-frame binding. No client product changes, root edits, dependency installation, merge or production access. -- Concurrent and Planning Gates passed in this isolated worktree; accepted Design ADR and related project context loaded. Server companion replay: 20260916-design-media-blockers-6c8e2a91. Packaging and other active task scopes remain read-only and do not overlap this diagnosis. +- Implement the agreed `prepare_generation` input and `generation_options` capability contract while preserving direct edits, stable operation identities, replay/unknown recovery and public assistant activity boundaries. A prepare operation never creates a chat bubble; blockers remain visible and actionable until the user supplies the missing input. +- User authorized parallel client implementation. Concurrent and Planning Gates passed in this isolated worktree; accepted Design ADR and related project context loaded. Server companion: 20260916-design-media-blockers-6c8e2a91. Packaging and other active task scopes remain read-only and do not overlap this work. +- Root resumed exclusive client file ownership after pausing the authorized client worker for final contract/UI verification. Existing worker edits are retained; no second worker, root-main write or ownership takeover occurred. ## Outcome -- Source diagnosis only; no product code or dependencies changed. YouthCreationCard's media select emits intent.media plus a default duration when entering video; leaving video emits only intent.media. It does not bind a first frame, arrange a Shot, clear prior medium-specific values or trigger semantic redrafting. -- Upload always creates an inspiration reference with no reviewed observations. The green 已引用 badge checks promptReferencesImage(prompt, index) only. It is not proof that the current route can use the asset or that it is a video first frame. -- applyFieldOperations sends apply_input/direct_edit, unlike sendChat's chat input. Server direct-edit uses the Specification kernel, without an assistant turn. A previous static-image reply can therefore remain while the form selects video; the screenshot does not establish who changed type or when. -- The active conversation mounts YouthCreationCard. FineTuneDrawer and DesignFormPane still contain professional frame/Shot controls but are not imported by any page in this checkout. The visible form cannot resolve the two explicit video blockers itself. -- Generic unsupported messages hide reference_role_unsupported and text_to_video_unsupported behind identical wording. The prepare button disables on any blocker, so the user has no actionable route from those messages. 方案已更新 reports save state, not production readiness. -- Related reachable defects: video duration control accepts 1-30 while current server schema accepts 2-15; quantity control accepts 1-4 while production capabilities cap each medium at 1. Screenshot values 6 and 1 are valid, so neither explains this incident. -- Resumed user-flow verification now exercises actual React controls and Zustand command dispatch. A local stdin bridge invokes the server's actual kernel, production capability constructor and compiler. Normal user steps produce direct_edit -> direct_edit -> request_quote and the four screenshot blockers, without any preparation reasoning step. The positive control supplies a complete semantic draft, then the same UI/store reaches the confirm button and emits confirm_generation. -- Additional confirmed end-of-flow defect: active DesignHistoryRail mounts DesignPlanHistory, which renders videos through DesignAssetThumbnail. The video elements lack controls and there is no play/view-video button or click handler; only download is offered. A successful video presentation fixture fails the in-app playback-entry assertion. DesignProductionPanel has a controlled player but that panel is not mounted by the active page. -- Additional source-only availability finding: ImageCanvas/index.tsx uses bootstrap.capabilities.generation, not the video flag, and YouthCreationCard always offers video. Server can legitimately expose image with video disabled, so this UI entry does not establish that video is available. -- History currently provides image preview/download but no explicit use-this-image-to-make-video action. Together with generic blocker wording and the unmounted first-frame/Shot controls, users cannot reliably recover via the visible plan controls. +- Implementation complete locally. The shared contract carries capability-specific generation options and a `prepare_generation` user input; Main validates/serializes it and maps server snake_case capability fields into Renderer camelCase state. The store exposes `prepareGeneration()` and `requestQuote()` prepares the current draft before requesting an immutable revision-bound quote. +- YouthCreationCard media changes now call prepare, use real available aspect/duration/output limits, expose an explicit video first-frame selection path and retain references across media changes. Existing references can be promoted to first frame without deleting their assets; unsupported image references are shown with an explicit removal action. Save state no longer treats a blocked preparation as ready. +- DesignPlanHistory now presents generated videos with native controls, a large preview, download, and an explicit historical-image-to-video-first-frame action. The action only binds the asset to the design; it never deletes or silently selects among multiple images. +- The previous diagnosis is now addressed in the owned client slice: direct edits remain deterministic, while `prepare_generation` is the explicit semantic preparation command. Media conversion no longer pretends to be ready; the server response updates the form and blockers, and the UI keeps the recovery action visible. +- The old “已引用” display is now accompanied by actual reference-role handling. Uploading the first image for a video binds it as `first_frame`; selecting an existing image promotes it in place, preserves aliases and does not delete assets. Switching back to image leaves references intact and shows an explicit removal path when the real capability does not support them. +- The visible controls now consume capability-specific availability, ratios, durations, output limits and reference roles. The video option is not advertised when the server says it is unavailable, and invalid values remain visible as unavailable instead of being silently rewritten. +- The active history rail now exposes native video controls, a large video preview, download and “用作视频首帧” for historical images. All media URLs continue through the existing Main-owned resolver; no direct renderer HTTP was added. +- Final parent verification fixed actual path failures: entering video saves the supported duration rather than only displaying it; missing capability data disables generation instead of inventing options; prepare rejection stops quotation even without a live blocker event; failed conversion restores the saved medium. Explicit removal clears the starting-image pointer, and a prepared prompt is displayed from the server result rather than overwritten with the pre-preparation text. +- Youth-facing copy uses 视频开始画面 and 用这张图做视频. Actual first-frame selection shows 已选用 independently of @ aliases. Unsupported reference usage and missing starting images offer explicit guidance; assets are retained. The make-interfaces-feel-better skill influenced only new-control hit areas: video preview/download and history-image reuse use 40px targets, with video actions moved above native playback controls so they do not cover them. ## Verification -- Initial pass read YouthCreationCard media/upload/button handlers, image-workspace store command construction, youth-issue-copy, production capability constructor and compiler in the paired server task. Follow-up below adds component execution; deployed version remains unverified. -- Paired server command python -m scripts.diagnostics.design_media_blockers reproduces the screenshot-shaped four blockers, exit 1 intentionally. --compare exits 0 with eight scenario assertions: same reference as first_frame leaves only missing Shot; one Shot makes the plan ready. Returning to image alone leaves a hidden video-duration blocker and unsupported-reference blocker. -- check_project_docs and task-aware check_doc_drift both PASS. Only this task record is changed since the recorded base; no product changes. Staged whitespace check is performed before the diagnostic handoff commit. -- Follow-up command: with DESIGN_DIAGNOSTIC_PYTHON pointing to the server project Python and DESIGN_DIAGNOSTIC_SERVER to the companion worktree, run node_modules/.bin/vitest run --config scripts/diagnostics/vitest.design-flow.config.ts --reporter=verbose. Final run: 1 passed, 2 failed, 17.57 seconds. Failures intentionally assert missing product acceptance; this explicitly selected diagnostic file is outside the normal unit-suite include paths. -- Initial bridge setup failures (Windows stdin decoding and a loose pre-existing client fixture missing server Field Decisions) were corrected in the diagnostic only: Python runs with UTF-8 and the baseline Specification is created by the actual server kernel. Final failures occur at the targeted user-flow assertions, not fixture setup. +- Initial pass read YouthCreationCard media/upload/button handlers, image-workspace store command construction, youth-issue-copy, production capability constructor and compiler in the paired server task. Follow-up implementation is component/store/adapter scoped; deployed version remains unverified. +- From this client worktree, DESIGN_DIAGNOSTIC_PYTHON points to the server Python and DESIGN_DIAGNOSTIC_SERVER to its companion worktree. `pnpm exec vitest run --config scripts/diagnostics/vitest.design-flow.config.ts --reporter=verbose` passed 2 tests. Commands are prepare (media/duration) -> prepare (selected image/motion) -> prepare (before quote) -> request_quote -> explicit confirm_generation. No client-side shot injection; the Python bridge invokes actual Workspace preparation and compiler with a fixture Reasoner. Quote/confirmation transport remains simulated; a separate real server lifecycle test covers Task execution with fixture provider/storage/ledger. +- Targeted final client run: `pnpm exec vitest run tests/unit/video-first-frame.test.ts tests/unit/youth-creation-card.test.tsx tests/unit/works-square-design-workspace.test.ts tests/unit/image-workspace-route.test.ts tests/unit/image-workspace-store.test.ts --maxWorkers=1` passed 5 files / 76 tests. Typecheck and Vite Renderer/Main/Preload/utility builds passed. Whole-repository lint has 0 errors and 8 pre-existing warnings outside this slice. Build warnings concern stale browser data, existing chunk sizes and mixed imports. +- Electron E2E fixture updated for capabilities and requires prepare before quote at the latest revision; the Electron E2E suite was not run. No claim of desktop rendering/media decoding from jsdom assertions. Final docs/drift/whitespace gates are run before committing this handoff. - Existing root node_modules reused through an ignored junction in this owned worktree; no packages installed or manifests changed. Tests use jsdom, simulated upload/HTTP/pricing, local server processes and a synthetic completed-video presentation fixture. No desktop app, real LLM, gateway transport, video playback bytes, production API or paid generation used. -- Server companion verified the complete-draft quote -> one confirmed Task -> real Worker -> running/succeeded -> ready video Asset/public event path with simulated provider, output materializer and ledger; its targeted flow/adapter/dependency suite passed 6 tests. -- ESLint passed for both diagnostic TypeScript files; task-aware documentation drift passed. Cleanup of the temporary ignored dependency junction was rejected by the execution policy before running; no alternate deletion attempted. The link remains at this owned worktree/node_modules, pointing to the existing root node_modules. No product source file changed. ## Follow-ups -- Await authorization for implementation. Keep the simple youth-facing UI, but make selecting image/video produce a coherent current plan; route semantic motion arrangement through the Agent and handle deterministic binding/cleanup in application code. Do not expose more professional form fields as the default fix. +- Local client/server flow acceptance is complete with the stated fixtures. Keep semantic motion arrangement in the Agent and deterministic binding/cleanup in application code. Merge/deployment are not authorized by this implementation turn; update both server and client contracts together when subsequently approved. - Reference selection must distinguish actual supported uses from a mere text mention. For video, explicitly identify which asset starts the clip; for image, align selectable uses with real backend capabilities. Do not silently drop references. - Improve actionable capability-aware messages and controls. Separately verify installed client/server versions if deployed behavior differs from this local reproduction. -- Flow acceptance is NOT passed: fix both prepare-entry and video-playback dead ends, then validate available-video gating, starting-image reuse, Agent motion preparation, quote/explicit confirm, visible progress, result recovery and actual playback together. Real-provider validation must be separately approved; no claim of production video availability from mock-backed passes. +- Deployed acceptance remains pending: an explicitly approved real short-video generation must verify staging, model acceptance, output ingestion, event/reconnect behavior and actual playback bytes together. No production availability or generation-quality claim from fixture-backed passes; no paid generation was performed. ## Promotion Candidates -- Target: accepted Design living-form ADR and UI capability coverage, only through an authorized future integration. Proposal: simple controls must produce semantically coherent drafts and cannot advertise success from text reference tokens alone. Evidence: exact direct-edit/upload handlers and paired compiler replay. Future impact: close a user-facing dead end without requiring children to edit first-frame/Shot fields. Semantic conflict: partial medium edits conflict with server-authoritative complete production requirements. Human confirmation required before implementing behavior/capability changes. +- Target: accepted Design living-form ADR and UI capability coverage. Proposal: simple controls must produce semantically coherent drafts and cannot advertise success from text reference tokens alone. Evidence: exact client command/adapter changes, focused tests and paired compiler replay. Future impact: close a user-facing dead end without requiring children to edit first-frame/Shot fields. Human confirmation was provided for this implementation slice; integration into root main remains outside this child task. diff --git a/README.md b/README.md index bd3a678..7a0c59c 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Makelore 是一个面向软件、视觉创作、智能机器人与个人云智 - 会话观察同步:本地编程会话在一轮问答完成并进入空闲后,后台通过个人资料 PUT 上传该会话截至当前的完整问答快照;只保留用户/助手自然语言文本,过滤代码、路径、日志、工具调用、附件与产物。同步只从本地上行,云端不回写、不恢复或删除本地会话;失败数据留在本地等待重试。 - AI 绘画:一个 Workspace 固定对应一个设计方向、一条 Agent Session 和一份持久 Living Form。中央使用与 AI 编程一致的低干扰对话布局:助手回复不套独立卡片,底部输入器是唯一强调层级;当前制作方案以对话内轻量编辑稿呈现,而不是独立硬模块。聊天提取、用户修改创作提示词和制作参数都提交到同一个服务端 reducer,并由方向版本、字段来源和决策状态形成权威投影。创作提示词是公开规格 `content.concept` 的可编辑投影;媒介、画幅、时长和数量始终以紧凑控件直接显示,不用折叠面板隐藏。 - AI 绘画生成:服务端从当前规格编译专业图片或视频指令并返回不可变 Quote;客户端只展示创作提示词、参考图绑定、媒介、画幅、数量、格式、时长、警告和设计点,不展示或改写供应商 Prompt、模型、价格原子或存储地址。修改方案会先提交规格并重新核价,确认时只提交 Quote 身份;确认后的方案不再常驻,而是进入可展开的历史作品记录,桌面端位于右侧全高栏,紧凑窗口保留在时间线中。参考图行只负责缩略图、`@图片N` 状态、替换和删除,保留什么、改变什么等语义统一由创作提示词表达。任务与资产始终属于 Workspace。 +- 视频制作:类型、画幅、时长和数量依据服务端真实能力显示。用户可以上传图片或从历史作品选择视频开始画面,绑定状态不依赖提示词中是否出现 `@图片N`。制作前先由 Agent 整理动作与可执行方案,再按最新方案核价;整理失败不继续报价,也不创建任务。只有用户确认 Quote 后才开始制作。历史视频提供播放、放大预览和下载;切回图片会清理隐藏的视频参数,不能使用的参考用途须由用户显式移除,图片素材不被删除。 - AI 绘画健壮性:Renderer 为每次命令生成稳定 operation id,网络结果未知时保留原命令供原样重试,不把未知写入当失败或创建第二次生成。Main 负责 Token 刷新、Agent Gateway REST 提交和有界 Run 查询,并将可恢复事件流投影为 Host API SSE;断线后按事件游标续接并重新读取权威 Workspace。Canvas 只使用 Works Square 云端 V2 契约,没有本地语义适配器或降级路径,上游不可用时明确报错。 - AI 绘画左侧项目栏只展示 Workspace,桌面设计模式下固定为 256px 且不提供收纳入口,不再在项目下创建独立设计会话;右侧全高历史作品栏同为 256px,展示当前 Workspace 的任务与生成资产。紧凑窗口则把项目列表放进左侧抽屉并在中央时间线保留历史记录。删除时必须完整输入项目名称;删除后项目、Living Form、任务、参考图和生成作品会从账户中隐藏且无法访问,不影响用户已另存到磁盘的副本。删除当前项目后自动打开最近更新的剩余项目,删除最后一个项目后进入空状态。 - 项目资源设置:项目配置页顶部以一横排三张资源卡提供模型、知识与插件入口,不再提供独立的本地 Skill 入口。模型列表完整显示具体模型名称,以及服务端或已验证模型档案声明的可选思考强度;未知能力不做猜测。插件抽屉把插件与 Skill 统一为“可选 / 已安装”两个页签:可选页展示云端目录及仍待安装的账号能力,已安装页同时展示官方 Release、随应用提供的插件和用户自行安装的 loose `SKILL.md` / Pi 包。每项提供“查看详情”和当前真实生命周期对应的添加、安装、添加到项目、启用或状态按钮。详情只展示介绍、主要能力、使用方式和必要设置,不暴露版本、包、权限标识、运行方式或计费实现字段。深链仍使用 `/project-config/plugins`;旧 `/plugins`、Marketplace、My Plugins、Project Plugins URL 会确定性落入对应页签。来源身份保持 `official:`、`local:`、`retained:` 分离,账号获取、官方设备交付和项目启用仍分别写入既有 authority,不会自动推进下一步;本机 Skill/Pi 插件仍只能通过对话安装,页面只管理已安装包。 diff --git a/electron/api/routes/image-workspace.ts b/electron/api/routes/image-workspace.ts index e263fda..63de15f 100644 --- a/electron/api/routes/image-workspace.ts +++ b/electron/api/routes/image-workspace.ts @@ -94,6 +94,15 @@ function decodeUserInput(value: unknown): DesignUserInput { } return { kind: 'direct_edit', operations: input.operations.map(decodeFieldOperation) }; } + if (input.kind === 'prepare_generation') { + if (input.operations !== undefined && !Array.isArray(input.operations)) { + invalidCommand('制作方案整理的字段操作无效'); + } + return { + kind: 'prepare_generation', + operations: input.operations?.map(decodeFieldOperation) ?? [], + }; + } if (input.kind === 'chat') { return { kind: 'chat', message: requiredString(input.message, '消息') }; } diff --git a/electron/image-workspace/works-square-workspace.ts b/electron/image-workspace/works-square-workspace.ts index 6e0507b..79ada84 100644 --- a/electron/image-workspace/works-square-workspace.ts +++ b/electron/image-workspace/works-square-workspace.ts @@ -18,8 +18,10 @@ import { type DesignDirectionUpdatedEvent, type DesignForm, type DesignGenerationQuote, + type DesignGenerationOptions, type DesignGenerationTask, type DesignInteractionOutcome, + type DesignMedium, type DesignQuoteBlockedEvent, type DesignQuoteOutputSummary, type DesignRenameWorkspaceInput, @@ -134,6 +136,49 @@ function arrayValue(value: unknown, field: string): unknown[] { return value; } +function booleanValue(value: unknown, field: string): boolean { + if (typeof value !== 'boolean') { + throw new DesignWorkspaceModuleError(502, 'DESIGN_WORKSPACE_RESPONSE_INVALID', `${field} 无效`); + } + return value; +} + +function mapGenerationOptions(value: unknown, medium: DesignMedium): DesignGenerationOptions { + const options = record(value); + return { + available: booleanValue(options.available, `${medium} generation availability`), + supportedAspectRatios: arrayValue( + options.supported_aspect_ratios, + `${medium} aspect ratios`, + ).map((ratio) => stringValue(ratio, `${medium} aspect ratio`)), + supportedDurationSeconds: arrayValue( + options.supported_duration_seconds, + `${medium} durations`, + ).map((duration) => numberValue(duration, `${medium} duration`)), + maxOutputCount: numberValue(options.max_output_count, `${medium} output count`), + requiresFirstFrame: booleanValue(options.requires_first_frame, `${medium} first frame flag`), + supportedReferenceRoles: arrayValue( + options.supported_reference_roles, + `${medium} reference roles`, + ).map((role) => stringValue(role, `${medium} reference role`)), + }; +} + +function mapCapabilities(value: unknown): DesignCapabilities { + const capabilities = record(value); + const generationOptions = record(capabilities.generation_options); + return { + conversation: booleanValue(capabilities.conversation, 'Conversation capability'), + generation: booleanValue(capabilities.generation, 'Generation capability'), + image: booleanValue(capabilities.image, 'Image capability'), + video: booleanValue(capabilities.video, 'Video capability'), + generationOptions: { + image: mapGenerationOptions(generationOptions.image, 'image'), + video: mapGenerationOptions(generationOptions.video, 'video'), + }, + }; +} + function nullableString(value: unknown): string | null { return value === null || value === undefined ? null : stringValue(value, '可选文本'); } @@ -546,6 +591,12 @@ function normalizeWorkspaceEvent( function mapUserInput(input: DesignUserInput): Record { if (input.kind === 'direct_edit') return input; + if (input.kind === 'prepare_generation') { + return { + kind: input.kind, + operations: input.operations ?? [], + }; + } if (input.kind === 'chat') return input; if (input.kind === 'restore') { return { kind: input.kind, specification_revision_id: input.specificationRevisionId }; @@ -677,7 +728,7 @@ function userFacingErrorMessage(code: string): string { const messages: Record = { design_direction_revision_conflict: '设计表单已更新,正在重新同步', design_idempotency_conflict: '这次操作与已经受理的请求冲突', - design_quote_blocked: '当前设计信息还不足以生成,请先补全表单', + design_quote_blocked: '制作方案还没整理好,请检查提示后重试', design_quote_expired: '当前报价已过期,请重新获取报价', design_quote_consumed: '当前报价已经生成过', budget_denied: '当前设计点不足,无法开始生成', @@ -789,11 +840,14 @@ export class WorksSquareDesignWorkspace implements DesignWorkspaceModule { async bootstrap(): Promise { const [capabilities, workspaces] = await Promise.all([ - this.requestJson('/api/design/capabilities'), + this.requestJson('/api/design/capabilities'), this.requestJson('/api/design/workspaces?limit=100&offset=0'), ]); this.eventSessionsEnabled = true; - return { capabilities, workspaces: workspaces.map(mapWorkspaceSummary) }; + return { + capabilities: mapCapabilities(capabilities), + workspaces: workspaces.map(mapWorkspaceSummary), + }; } async createWorkspace(input: DesignCreateWorkspaceInput): Promise { diff --git a/scripts/diagnostics/design-video-flow.test.tsx b/scripts/diagnostics/design-video-flow.test.tsx index b2d65ab..10d484a 100644 --- a/scripts/diagnostics/design-video-flow.test.tsx +++ b/scripts/diagnostics/design-video-flow.test.tsx @@ -1,10 +1,10 @@ -/** Offline connectivity diagnostic, intentionally red; not in the normal unit suite. +/** Offline connectivity diagnostic; not in the normal unit suite. * Actual React controls + Zustand commands -> stdin bridge -> actual Python * Specification kernel / production capability builder / compiler. Upload and * transport are simulated. No live gateway, reasoner, paid task or provider. */ import { spawnSync } from 'node:child_process'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { afterEach, beforeEach, expect, it, vi } from 'vitest'; import { YouthCreationCard } from '@/pages/ImageCanvas/YouthCreationCard'; import { DesignPlanHistory } from '@/pages/ImageCanvas/DesignPlanHistory'; @@ -13,7 +13,7 @@ import { ImageWorkspaceApiError, submitImageWorkspaceCommand, uploadImageWorkspaceAsset, } from '@/lib/image-workspace'; import { - designFormFixture, designQuoteFixture, designWorkspaceFixture, + designCapabilitiesFixture, designFormFixture, designQuoteFixture, designWorkspaceFixture, } from '../../tests/fixtures/design-workspace-v2'; import type { DesignAsset, DesignCompilationIssue, DesignSpecification, DesignUserFieldOperation, @@ -32,12 +32,16 @@ vi.mock('@/stores/auth', () => ({ useAuthStore: { getState: () => ({ isAuthenticated: () => true }) }, })); -function serverStep(specification: DesignSpecification | null, operations: DesignUserFieldOperation[]) { +function serverStep( + specification: DesignSpecification | null, + inputKind: 'direct_edit' | 'prepare_generation', + operations: DesignUserFieldOperation[] = [], +) { const result = spawnSync(process.env.DESIGN_DIAGNOSTIC_PYTHON!, [ '-X', 'utf8', '-m', 'scripts.diagnostics.design_media_blockers', '--json-step', ], { cwd: process.env.DESIGN_DIAGNOSTIC_SERVER, - input: JSON.stringify({ specification, operations }), encoding: 'utf8', + input: JSON.stringify({ specification, input_kind: inputKind, operations }), encoding: 'utf8', }); if (result.status !== 0) throw new Error(result.stderr || String(result.error)); return JSON.parse(result.stdout) as { @@ -55,7 +59,12 @@ const asset: DesignAsset = { function LiveCard() { const workspace = useImageWorkspaceStore((state) => state.workspace)!; const blockers = useImageWorkspaceStore((state) => state.quoteBlockers); - return ; + return ; } beforeEach(() => { @@ -64,11 +73,8 @@ beforeEach(() => { }); afterEach(cleanup); -it.each([ - { label: 'current UI payload', complete: false }, - { label: 'control with a complete semantic video draft supplied', complete: true }, -])('reaches video confirmation: $label', async ({ complete }) => { - const initial = serverStep(null, [ +it('reaches video confirmation through the real prepare-generation bridge', async () => { + const initial = serverStep(null, 'direct_edit', [ { kind: 'set', path: 'intent.media', value: 'image' }, { kind: 'set', path: 'content.concept', value: '沙漠里的小动物围圈玩游戏,天上的云在飘。' }, { kind: 'set', path: 'output.aspect_ratio', value: '9:16' }, @@ -84,21 +90,24 @@ it.each([ vi.mocked(uploadImageWorkspaceAsset).mockResolvedValue(asset); vi.mocked(submitImageWorkspaceCommand).mockImplementation(async (command) => { commands.push(command.kind === 'apply_input' ? command.input.kind : command.kind); - const operations = command.kind === 'apply_input' && command.input.kind === 'direct_edit' - ? command.input.operations : []; - const step = serverStep(workspace.form.specification, operations); - if (command.kind === 'request_quote' && !step.ready) { - useImageWorkspaceStore.setState({ quoteBlockers: step.blockers }); - throw new ImageWorkspaceApiError(409, 'design_quote_blocked', 'offline blocked'); - } + const inputKind = command.kind === 'apply_input' ? command.input.kind : 'direct_edit'; + const operations = command.kind === 'apply_input' && ( + command.input.kind === 'direct_edit' || command.input.kind === 'prepare_generation' + ) ? command.input.operations ?? [] : []; + const step = serverStep(workspace.form.specification, inputKind, operations); workspace = { - ...workspace, assets: operations.some((operation) => operation.path === 'references') + ...workspace, + assets: operations.some((operation) => operation.path === 'references') ? [asset] : workspace.assets, workspace: { ...workspace.workspace, workspaceViewRevision: workspace.workspace.workspaceViewRevision + 1 }, form: { ...workspace.form, specification: step.specification, specificationRevision: workspace.form.specificationRevision + 1, directionRevision: workspace.form.directionRevision + 1 }, }; + useImageWorkspaceStore.setState({ workspace, quoteBlockers: step.blockers }); + if (!step.ready && command.kind === 'request_quote') { + throw new ImageWorkspaceApiError(409, 'design_quote_blocked', 'offline blocked'); + } if (command.kind === 'request_quote') { const quote = designQuoteFixture(); workspace.form.activeQuotes = [{ ...quote, medium: 'video', @@ -114,22 +123,15 @@ it.each([ fireEvent.change(screen.getByLabelText('选择参考图片'), { target: { files: [new File(['offline-upload'], 'start.png', { type: 'image/png' })] }, }); - await screen.findByText('已引用'); - if (complete) { - // Control input, not a claim that today's Agent/UI performs this transition. - const reference = workspace.form.specification.values.references[0]; - const step = serverStep(workspace.form.specification, [ - { kind: 'set', path: 'references', value: [{ ...reference, role: 'first_frame' }] }, - { kind: 'set', path: 'video.shots', value: [{ id: 'one', story_beat: '小动物围圈游戏,云缓慢飘过。' }] }, - ]); - workspace = { ...workspace, form: { ...workspace.form, specification: step.specification } }; - act(() => useImageWorkspaceStore.setState({ workspace })); - } + await screen.findByText('已选用'); + expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.first_frame_asset_id).toBe('image-1'); + expect(useImageWorkspaceStore.getState().workspace?.form.specification.values.video.shots.length).toBe(1); await waitFor(() => expect(screen.getByRole('button', { name: '准备制作方案' })).toBeEnabled()); fireEvent.click(screen.getByRole('button', { name: '准备制作方案' })); await waitFor(() => expect(commands).toContain('request_quote')); console.log('FLOW_COMMANDS', commands); console.log('FLOW_BLOCKERS', useImageWorkspaceStore.getState().quoteBlockers.map((item) => item.code)); + expect(commands.filter((kind) => kind === 'prepare_generation')).toHaveLength(3); expect(useImageWorkspaceStore.getState().quoteBlockers).toEqual([]); expect(screen.getByRole('button', { name: '确认并开始制作' })).toBeEnabled(); fireEvent.click(screen.getByRole('button', { name: '确认并开始制作' })); diff --git a/shared/image-workspace.ts b/shared/image-workspace.ts index 08f0748..da06ac1 100644 --- a/shared/image-workspace.ts +++ b/shared/image-workspace.ts @@ -16,11 +16,24 @@ export type DesignProvenance = | 'restored' | 'legacy_import'; +export type DesignGenerationOptions = { + available: boolean; + supportedAspectRatios: string[]; + supportedDurationSeconds: number[]; + maxOutputCount: number; + requiresFirstFrame: boolean; + supportedReferenceRoles: string[]; +}; + export type DesignCapabilities = { conversation: boolean; generation: boolean; image: boolean; video: boolean; + generationOptions: { + image: DesignGenerationOptions; + video: DesignGenerationOptions; + }; }; export type DesignFieldDecision = { @@ -353,6 +366,7 @@ export type DesignUserFieldOperation = export type DesignUserInput = | { kind: 'direct_edit'; operations: DesignUserFieldOperation[] } + | { kind: 'prepare_generation'; operations?: DesignUserFieldOperation[] } | { kind: 'chat'; message: string } | { kind: 'prompt_resolution'; diff --git a/src/pages/ImageCanvas/DesignAssetThumbnail.tsx b/src/pages/ImageCanvas/DesignAssetThumbnail.tsx index 10eb9cf..88a820f 100644 --- a/src/pages/ImageCanvas/DesignAssetThumbnail.tsx +++ b/src/pages/ImageCanvas/DesignAssetThumbnail.tsx @@ -8,10 +8,12 @@ export function DesignAssetThumbnail({ asset, className, alt, + controls = false, }: { asset: DesignAsset | null; className?: string; alt?: string; + controls?: boolean; }) { const [resolution, setResolution] = useState<{ contentPath: string | null; @@ -61,6 +63,8 @@ export function DesignAssetThumbnail({ aria-label={alt ?? '视频作品预览'} muted preload="metadata" + controls={controls} + playsInline /> ); } diff --git a/src/pages/ImageCanvas/DesignConversationPane.tsx b/src/pages/ImageCanvas/DesignConversationPane.tsx index 376abf5..32223f0 100644 --- a/src/pages/ImageCanvas/DesignConversationPane.tsx +++ b/src/pages/ImageCanvas/DesignConversationPane.tsx @@ -17,7 +17,11 @@ import { type DesignAssistantActivity, useImageWorkspaceStore, } from '@/stores/image-workspace'; -import type { DesignCompilationIssue, DesignWorkspace } from '../../../shared/image-workspace'; +import type { + DesignCompilationIssue, + DesignGenerationOptions, + DesignWorkspace, +} from '../../../shared/image-workspace'; import { DesignPendingOperationsPanel } from './DesignProductionPanel'; import { DesignPlanHistory } from './DesignPlanHistory'; import { YouthCreationCard } from './YouthCreationCard'; @@ -135,11 +139,16 @@ export function DesignConversationPane({ workspace, quoteBlockers, generationAvailable, + generationOptions, showPlanHistory = true, }: { workspace: DesignWorkspace; quoteBlockers: DesignCompilationIssue[]; generationAvailable: boolean; + generationOptions?: { + image: DesignGenerationOptions; + video: DesignGenerationOptions; + }; showPlanHistory?: boolean; }) { const chatDraft = useImageWorkspaceStore((state) => state.chatDraft); @@ -340,6 +349,7 @@ export function DesignConversationPane({ workspace={workspace} quoteBlockers={quoteBlockers} generationAvailable={generationAvailable} + generationOptions={generationOptions} /> )} diff --git a/src/pages/ImageCanvas/DesignPlanHistory.tsx b/src/pages/ImageCanvas/DesignPlanHistory.tsx index a47632e..563eab3 100644 --- a/src/pages/ImageCanvas/DesignPlanHistory.tsx +++ b/src/pages/ImageCanvas/DesignPlanHistory.tsx @@ -2,9 +2,12 @@ import { ChevronDown, Clock3, Download, Film, ImageIcon, Loader2 } from 'lucide- import { toast } from 'sonner'; import { saveImageWorkspaceAsset } from '@/lib/image-workspace'; import { cn } from '@/lib/utils'; +import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignGenerationTask, DesignWorkspace } from '../../../shared/image-workspace'; import { DesignAssetThumbnail } from './DesignAssetThumbnail'; import { DesignImagePreviewButton } from './DesignImagePreview'; +import { createVideoFirstFrameOperations } from './video-first-frame'; +import { DesignVideoPreviewButton } from './DesignVideoPreview'; import { youthTaskIssueMessage } from './youth-issue-copy'; const STATUS_LABELS: Record = { @@ -36,6 +39,22 @@ function taskDescription(task: DesignGenerationTask): string { } export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace }) { + const prepareGeneration = useImageWorkspaceStore((state) => state.prepareGeneration); + const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); + const currentMedium = workspace.form.specification.values.intent.media; + const preparing = Object.values(pendingOperations).some((operation) => ( + operation.command.workspaceId === workspace.workspace.workspaceId + )); + const handleUseAsFirstFrame = async (asset: DesignWorkspace['assets'][number]) => { + if (preparing || currentMedium !== 'video') return; + try { + const firstFrame = createVideoFirstFrameOperations(workspace, asset); + await prepareGeneration(firstFrame.operations); + toast.success('视频开始画面已选好'); + } catch { + toast.error('这张图片暂时不能用于视频,请检查制作方案'); + } + }; const tasks = [...workspace.tasks].sort( (left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt), ); @@ -111,13 +130,34 @@ export function DesignPlanHistory({ workspace }: { workspace: DesignWorkspace })
{resultAssets.map((asset) => (
- + {asset.mediaType === 'image' && } + {asset.mediaType === 'video' && } + {asset.mediaType === 'image' && currentMedium === 'video' && ( + + )} + + +
+ {failed ? ( +
+
+ ) : ( + <> + {!loaded && ( +
+
+ )} + {source && ( +
+ + ); +} + +export function DesignVideoPreviewButton({ asset }: { asset: DesignAsset }) { + return ( + + + + + + + + + ); +} diff --git a/src/pages/ImageCanvas/YouthCreationCard.tsx b/src/pages/ImageCanvas/YouthCreationCard.tsx index e87e912..2978bd6 100644 --- a/src/pages/ImageCanvas/YouthCreationCard.tsx +++ b/src/pages/ImageCanvas/YouthCreationCard.tsx @@ -24,6 +24,7 @@ import { cn } from '@/lib/utils'; import { useImageWorkspaceStore } from '@/stores/image-workspace'; import type { DesignCompilationIssue, + DesignGenerationOptions, DesignSpecificationValues, DesignUserFieldOperation, DesignWorkspace, @@ -38,6 +39,7 @@ import { referenceToken, removeReferenceTokenAndShift, } from './reference-tokens'; +import { createVideoFirstFrameOperations, creatorPromptForWorkspace } from './video-first-frame'; import { youthCompilationIssueMessage } from './youth-issue-copy'; type ReferenceValue = DesignSpecificationValues['references'][number]; @@ -47,6 +49,10 @@ export type YouthCreationCardProps = { workspace: DesignWorkspace; quoteBlockers: DesignCompilationIssue[]; generationAvailable: boolean; + generationOptions?: { + image: DesignGenerationOptions; + video: DesignGenerationOptions; + }; onOpenFineTune?: () => void; onFocusComposer?: () => void; onQuoteOffered?: () => void; @@ -66,17 +72,24 @@ function createReferenceId(): string { return `reference-${random}`.slice(0, 64); } -function canonicalCreatorPrompt(workspace: DesignWorkspace): string { - const values = workspace.form.specification.values; - return values.content.concept?.trim() - || values.content.narrative?.trim() - || [ - ...values.content.subjects.map((subject) => subject.description.trim()), - values.content.action?.trim(), - values.content.environment?.trim(), - values.content.emotional_tone?.trim(), - ].filter((value): value is string => Boolean(value)).join(','); -} +const UNAVAILABLE_GENERATION_OPTIONS: Record<'image' | 'video', DesignGenerationOptions> = { + image: { + available: false, + supportedAspectRatios: [], + supportedDurationSeconds: [], + maxOutputCount: 1, + requiresFirstFrame: false, + supportedReferenceRoles: [], + }, + video: { + available: false, + supportedAspectRatios: [], + supportedDurationSeconds: [], + maxOutputCount: 1, + requiresFirstFrame: true, + supportedReferenceRoles: [], + }, +}; function Stepper({ label, @@ -84,6 +97,7 @@ function Stepper({ suffix, minimum, maximum, + allowedValues, disabled, onChange, }: { @@ -92,9 +106,15 @@ function Stepper({ suffix?: string; minimum: number; maximum: number; + allowedValues?: number[]; disabled: boolean; onChange: (value: number) => void; }) { + const values = allowedValues + ? [...new Set(allowedValues)].sort((left, right) => left - right) + : Array.from({ length: Math.max(0, maximum - minimum + 1) }, (_, index) => minimum + index); + const previous = values.filter((item) => item < value).at(-1) ?? values[0] ?? minimum; + const next = values.find((item) => item > value) ?? values.at(-1) ?? maximum; return (
{label} @@ -103,8 +123,8 @@ function Stepper({ type="button" aria-label={`减少${label}`} className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35" - disabled={disabled || value <= minimum} - onClick={() => onChange(Math.max(minimum, value - 1))} + disabled={disabled || values.length === 0 || value <= (values[0] ?? minimum)} + onClick={() => onChange(previous)} > @@ -115,8 +135,8 @@ function Stepper({ type="button" aria-label={`增加${label}`} className="flex h-full w-8 items-center justify-center text-muted-foreground hover:bg-surface-subtle hover:text-foreground disabled:opacity-35" - disabled={disabled || value >= maximum} - onClick={() => onChange(Math.min(maximum, value + 1))} + disabled={disabled || values.length === 0 || value >= (values.at(-1) ?? maximum)} + onClick={() => onChange(next)} > @@ -129,14 +149,16 @@ export function YouthCreationCard({ workspace, quoteBlockers, generationAvailable, + generationOptions, onQuoteOffered, }: YouthCreationCardProps) { const applyFieldOperations = useImageWorkspaceStore((state) => state.applyFieldOperations); + const prepareGeneration = useImageWorkspaceStore((state) => state.prepareGeneration); const requestQuote = useImageWorkspaceStore((state) => state.requestQuote); const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration); const pendingOperations = useImageWorkspaceStore((state) => state.pendingOperations); const values = workspace.form.specification.values; - const canonicalPrompt = canonicalCreatorPrompt(workspace); + const canonicalPrompt = creatorPromptForWorkspace(workspace); const offeredQuote = workspace.form.activeQuotes.find((quote) => quote.status === 'offered') ?? null; const [prompt, setPrompt] = useState(canonicalPrompt); const [promptDirty, setPromptDirty] = useState(false); @@ -183,12 +205,35 @@ export function YouthCreationCard({ )); const busy = localBusy || submitting || uploading; const blockers = quoteBlockers.filter((issue) => issue.severity === 'blocker'); + const options = generationOptions ?? UNAVAILABLE_GENERATION_OPTIONS; + const activeOptions = options[medium]; + const supportedAspectRatios = activeOptions.supportedAspectRatios; + const supportedDurations = activeOptions.supportedDurationSeconds; + const selectedFirstFrameAssetId = values.video.first_frame_asset_id; + const selectedFirstFrameAsset = selectedFirstFrameAssetId + ? workspace.assets.find((asset) => asset.assetId === selectedFirstFrameAssetId) ?? null + : null; + const videoNeedsFirstFrame = medium === 'video' && activeOptions.requiresFirstFrame && !selectedFirstFrameAsset; + const imageReferencesSupported = activeOptions.supportedReferenceRoles.length > 0; + const unsupportedReferences = references.some((reference) => ( + !activeOptions.supportedReferenceRoles.includes(reference.role) + )); const canPrepareQuote = generationAvailable + && activeOptions.available && prompt.trim().length > 0 && Boolean(medium) && Boolean(aspectRatio) + && supportedAspectRatios.includes(aspectRatio) + && (!videoNeedsFirstFrame || !activeOptions.requiresFirstFrame) + && !unsupportedReferences && unboundReferenceNumbers.length === 0 - && blockers.length === 0; + && variantCount <= activeOptions.maxOutputCount + && !(medium === 'video' && !supportedDurations.includes(duration)); + const canAddReference = medium === 'video' + ? activeOptions.supportedReferenceRoles.includes('first_frame') + && (!selectedFirstFrameAsset + || activeOptions.supportedReferenceRoles.includes('inspiration')) + : imageReferencesSupported; const setPromptAndCursor = (value: string, cursor: number) => { setPrompt(value); @@ -202,7 +247,7 @@ export function YouthCreationCard({ const runOperations = async ( operations: DesignUserFieldOperation[], - options: { ensureQuote?: boolean; nextPrompt?: string } = {}, + options: { ensureQuote?: boolean; nextPrompt?: string; prepare?: boolean } = {}, ) => { if (busy || operations.length === 0) return null; const shouldRefreshQuote = options.ensureQuote === true || Boolean(offeredQuote); @@ -210,10 +255,12 @@ export function YouthCreationCard({ setSaveState('saving'); let saved = false; try { - const updated = await applyFieldOperations(operations); + const updated = options.prepare + ? await prepareGeneration(operations) + : await applyFieldOperations(operations); saved = true; if (options.nextPrompt !== undefined) { - setPrompt(options.nextPrompt); + setPrompt(options.prepare ? creatorPromptForWorkspace(updated) : options.nextPrompt); setPromptDirty(false); } if (shouldRefreshQuote) { @@ -275,6 +322,19 @@ export function YouthCreationCard({ try { const asset = await uploadImageWorkspaceAsset(workspace.workspace.workspaceId, file); const replacing = replaceIndex; + const replacingFirstFrame = replacing !== null && references[replacing]?.role === 'first_frame'; + if (medium === 'video' && (selectedFirstFrameAsset === null || replacingFirstFrame)) { + const firstFrame = createVideoFirstFrameOperations(workspace, asset, prompt); + setUploading(false); + const updated = await runOperations(firstFrame.operations, { + nextPrompt: firstFrame.nextPrompt, + prepare: true, + }); + if (!updated) return; + setMentionRange(null); + toast.success('视频开始画面已选好'); + return; + } const nextReferences: ReferenceValue[] = replacing === null ? [...references, { id: createReferenceId(), @@ -301,7 +361,11 @@ export function YouthCreationCard({ operations.push({ kind: 'set', path: 'content.concept', value: inserted.value.trim() }); } setUploading(false); - await runOperations(operations, { nextPrompt: inserted.value.trim() }); + const updated = await runOperations(operations, { + nextPrompt: inserted.value.trim(), + prepare: medium === 'video', + }); + if (!updated) return; setMentionRange(null); toast.success(replacing === null ? '参考图已添加并写入提示词' : '参考图已替换'); } catch { @@ -325,9 +389,27 @@ export function YouthCreationCard({ ? { kind: 'set', path: 'content.concept', value: nextPrompt } : { kind: 'clear', path: 'content.concept', resolution: 'open' }, ]; + if (references[index]?.asset_id === values.video.first_frame_asset_id) { + operations.push({ kind: 'clear', path: 'video.first_frame_asset_id' }); + } void runOperations(operations, { nextPrompt }); }; + const handleAssetAsFirstFrame = (asset: NonNullable) => { + if (busy || medium !== 'video') return; + try { + const firstFrame = createVideoFirstFrameOperations(workspace, asset, prompt); + void runOperations(firstFrame.operations, { + nextPrompt: firstFrame.nextPrompt, + prepare: true, + }).then((updated) => { + if (updated) toast.success('视频开始画面已更新'); + }); + } catch { + toast.error('这张图片暂时不能用于视频,请检查制作方案'); + } + }; + const insertExistingReference = (index: number) => { if (!mentionRange && promptReferencesImage(prompt, index)) { promptRef.current?.focus(); @@ -342,8 +424,9 @@ export function YouthCreationCard({ if (saveState === 'repricing') return { text: '正在重新核价', icon: }; if (saveState === 'error') return { text: '方案需要重试', icon: }; if (promptDirty) return { text: '提示词尚未保存', icon: }; + if (blockers.length > 0) return { text: '还需要补充一点信息', icon: }; return { text: offeredQuote ? '方案已核价' : '方案已更新', icon: }; - }, [offeredQuote, promptDirty, saveState]); + }, [blockers.length, offeredQuote, promptDirty, saveState]); if (!hasActiveCreationPlan(workspace)) return null; @@ -371,7 +454,7 @@ export function YouthCreationCard({
@@ -451,7 +534,7 @@ export function YouthCreationCard({ }} > - 上传新的参考图 + {medium === 'video' && videoNeedsFirstFrame ? '选择视频开始画面' : '上传新的参考图'}
)} @@ -464,11 +547,14 @@ export function YouthCreationCard({ {(references.length > 0 || unboundReferenceNumbers.length > 0) && (
-

参考图 {references.length > 0 ? references.length : ''}

+

+ {medium === 'video' ? '视频开始画面和参考图' : '参考图'} {references.length > 0 ? references.length : ''} +

{references.map((reference, index) => { const asset = workspace.assets.find((item) => item.assetId === reference.asset_id) ?? null; - const referenced = promptReferencesImage(prompt, index); + const isStartingImage = reference.asset_id === selectedFirstFrameAssetId && medium === 'video'; + const referenced = isStartingImage || promptReferencesImage(prompt, index); return (
@@ -480,20 +566,33 @@ export function YouthCreationCard({ > {referenceToken(index)} - {asset ? `参考图 ${index + 1} · ${asset.width}×${asset.height}` : '图片正在同步'} + {reference.role === 'first_frame' + ? '视频开始画面' + : asset ? `参考图 ${index + 1} · ${asset.width}×${asset.height}` : '图片正在同步'} - {referenced ? '已引用' : '未引用'} + {isStartingImage ? '已选用' : referenced ? '已引用' : '未引用'} + {medium === 'video' && asset && reference.role !== 'first_frame' && ( + + )}
+ {unsupportedReferences && ( +

+ {medium === 'video' ? '当前视频制作只使用一张开始画面,请移除其他用途的引用。' : '当前图片制作不支持这些参考用途,请移除不支持的引用。'} + 点击图片旁的删除按钮只会移除引用,不会删除图片素材。 +

+ )} + {unboundReferenceNumbers.length > 0 && (

@@ -539,7 +645,7 @@ export function YouthCreationCard({

)} + {videoNeedsFirstFrame && ( +

+ 先选一张图片作为视频开始画面,AI 才能继续整理视频方案。可以上传图片,也可以使用历史作品里的图片。 +

+ )} + { const next = event.currentTarget.value as 'image' | 'video'; + if (!options[next].available) { + toast.error(`${next === 'video' ? '视频' : '图片'}制作暂不可用`); + return; + } const operations: DesignUserFieldOperation[] = [{ kind: 'set', path: 'intent.media', value: next }]; if (next === 'video' && values.video.total_duration_seconds === null) { - operations.push({ kind: 'set', path: 'video.total_duration_seconds', value: duration }); + const durations = options.video.supportedDurationSeconds; + const initialDuration = durations.includes(6) ? 6 : durations[0]; + if (initialDuration !== undefined) { + operations.push({ kind: 'set', path: 'video.total_duration_seconds', value: initialDuration }); + setDuration(initialDuration); + } } - changeParameter(operations, () => setMedium(next)); + if (busy) return; + setMedium(next); + void runOperations(operations, { prepare: true }).then((updated) => { + if (!updated) { + setMedium(values.intent.media ?? 'image'); + setDuration(values.video.total_duration_seconds ?? 6); + } + }); }} > - - + + @@ -608,7 +736,18 @@ export function YouthCreationCard({ }} > - {ASPECT_RATIOS.map((item) => )} + {ASPECT_RATIOS.map((item) => ( + + ))} + {aspectRatio && !ASPECT_RATIOS.some((item) => item.value === aspectRatio) && ( + + )} @@ -618,7 +757,8 @@ export function YouthCreationCard({ value={duration} suffix="秒" minimum={1} - maximum={30} + maximum={Math.max(1, ...supportedDurations)} + allowedValues={supportedDurations} disabled={busy} onChange={(next) => changeParameter( [{ kind: 'set', path: 'video.total_duration_seconds', value: next }], @@ -631,7 +771,7 @@ export function YouthCreationCard({ label="数量" value={variantCount} minimum={1} - maximum={4} + maximum={Math.max(1, activeOptions.maxOutputCount)} disabled={busy} onChange={(next) => changeParameter( [{ kind: 'set', path: 'output.variant_count', value: next }], @@ -640,6 +780,12 @@ export function YouthCreationCard({ /> + {!activeOptions.available && ( +

+ 当前暂时不能制作{medium === 'video' ? '视频' : '图片'},可以先继续完善想法。 +

+ )} + {blockers.length > 0 && (
{blockers.map((blocker) => ( @@ -676,7 +822,8 @@ export function YouthCreationCard({