合并最新主分支
Project CI / Repository checks (pull_request) Successful in 4m15s
Project CI / Frontend tests (pull_request) Successful in 5m31s
Project CI / Backend tests (pull_request) Successful in 5m53s
Project CI / Native shell tests (pull_request) Successful in 14m47s

This commit is contained in:
2026-08-23 21:46:21 +08:00
18 changed files with 803 additions and 68 deletions
@@ -1638,6 +1638,8 @@ pub(crate) fn register_local_asset_entry(
kind: kind.to_string(),
media_type: media_type.to_string(),
local_path: normalized_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source,
});
Ok((id, "asset.register"))
@@ -2312,6 +2312,8 @@ mod ui_editor_font_tests {
kind: "font".to_string(),
media_type: "font/ttf".to_string(),
local_path: relative_path.to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -2762,6 +2762,8 @@ fn commit_asset_canvas_at_internal(
kind: asset_kind,
media_type: staged.media_type.clone(),
local_path: final_relative_path.clone(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source,
};
manifest.assets.push(asset.clone());
@@ -2958,6 +2958,8 @@ mod tests {
kind: "illustration".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -4108,6 +4110,8 @@ mod tests {
kind: "illustration".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
@@ -408,6 +408,8 @@ fn refine_preserves_source_and_records_non_destructive_lineage() {
kind: "illustration".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -488,6 +490,8 @@ fn discovers_unique_active_refine_draft_and_rejects_ambiguous_candidates() {
kind: "illustration".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -979,6 +983,8 @@ fn rejects_symlinked_or_hardlinked_media_at_source_staging_and_final_paths() {
kind: "illustration".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/source.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Uploaded,
canvas_project_id: None,
@@ -183,6 +183,8 @@ fn successful_first_playable_registration_creates_one_initial_version_with_asset
kind: "character".to_string(),
media_type: "image/png".to_string(),
local_path: "assets/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Generated,
canvas_project_id: None,
@@ -633,6 +633,8 @@ mod tests {
kind: "test".to_string(),
media_type: "image/png".to_string(),
local_path: format!("assets/{id}.png"),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: None,
File diff suppressed because it is too large Load Diff
@@ -29,7 +29,7 @@ pub enum NodeSource {
pub enum StageStatus {
NoProblem,
NeedReview(String), // reason inside
Blocked(String), // reason inside
Blocked(String), // reason inside
}
#[cfg(test)]
@@ -14,6 +14,7 @@ export type ResourceEditSubmitInput = {
export function ResourceEditSurface({
resourceLabel,
editKind,
generationMode = 'derive',
initialAssetName,
semanticNotice,
onCancel,
@@ -21,6 +22,7 @@ export function ResourceEditSurface({
}: {
resourceLabel: string;
editKind: LocalProjectResourceEditKind;
generationMode?: 'create' | 'derive';
initialAssetName: string;
semanticNotice: string | null;
onCancel: () => void;
@@ -33,6 +35,7 @@ export function ResourceEditSurface({
const [error, setError] = useState('');
const inputLocked = attempted || submitting;
const promptMaxLength = resourceEditPromptMaxLength(editKind);
const isCreate = generationMode === 'create';
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
@@ -72,7 +75,7 @@ export function ResourceEditSurface({
</button>
<span>
<small></small>
<small>{isCreate ? '新建媒体资源' : '编辑现有资源'}</small>
<strong id="game-resource-editor-title">{resourceLabel}</strong>
</span>
</header>
@@ -96,7 +99,11 @@ export function ResourceEditSurface({
rows={7}
autoFocus
disabled={inputLocked}
placeholder="描述希望在现有资源基础上发生的变化"
placeholder={
isCreate
? '描述希望生成的视频、音效或背景音乐'
: '描述希望在现有资源基础上发生的变化'
}
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
</label>
@@ -132,7 +139,9 @@ export function ResourceEditSurface({
? '正在派生…'
: editKind === 'version'
? '创建子版本'
: '生成派生资源'}
: isCreate
? '生成资源'
: '生成派生资源'}
</button>
)}
</div>
@@ -89,7 +89,10 @@ import { ResourceDependencyOverlay } from './ResourceDependencyOverlay';
import {
defaultDerivedResourceName,
type ProjectResourceEditCapability,
resolveProjectCharacterAnimationCapability,
resolveProjectResourceEditCapability,
type ResourceGenerationKind,
resourceGenerationOptions,
} from './resourceEditModel';
import {
type ResourceEditSubmitInput,
@@ -176,8 +179,9 @@ type ResourceEditorRoute = {
flowId: string;
operationId: string;
idempotencyKey: string;
resource: ProjectResource;
resource: ProjectResource | null;
capability: Extract<ProjectResourceEditCapability, { route: 'derive' }>;
generationMode: 'create' | 'derive';
};
type UiEditorRoute = {
@@ -2324,11 +2328,70 @@ export default function ProjectDevelopmentView({
idempotencyKey: crypto.randomUUID(),
resource,
capability,
generationMode: 'derive',
});
},
[advanceFocusGeneration, normalizeRasterSourceAndOpen, openAssetCanvas],
);
const openCharacterAnimation = useCallback(
(resource: ProjectResource) => {
const capability = resolveProjectCharacterAnimationCapability(resource);
if (!capability) {
return;
}
canvasOpenEpochRef.current += 1;
advanceFocusGeneration();
const flowId = crypto.randomUUID();
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = null;
setAssetCanvasRoute(null);
setAssetCanvasNotice('');
setResourceEditorRoute({
flowId,
operationId: crypto.randomUUID(),
idempotencyKey: crypto.randomUUID(),
resource,
capability,
generationMode: 'derive',
});
},
[advanceFocusGeneration],
);
const openCreateResource = useCallback(
(kind: ResourceGenerationKind) => {
canvasOpenEpochRef.current += 1;
advanceFocusGeneration();
const flowId = crypto.randomUUID();
activeFocusFlowIdRef.current = flowId;
pendingResourceFocusRef.current = null;
setAssetCanvasRoute(null);
setAssetCanvasNotice('');
const sourceMediaType = kind === 'video' ? 'video/mp4' : 'audio/mpeg';
setResourceEditorRoute({
flowId,
operationId: crypto.randomUUID(),
idempotencyKey: crypto.randomUUID(),
resource: null,
capability: {
route: 'derive',
editKind: kind,
sourceMediaType,
semanticNotice:
kind === 'background-music'
? '将生成全新的背景音乐。'
: kind === 'sound-effect'
? '将生成全新的音效。'
: '将生成全新的视频。',
generationMode: 'create',
},
generationMode: 'create',
});
},
[advanceFocusGeneration],
);
const cancelResourceEditor = useCallback(() => {
if (resourceEditorRoute) {
resourceEditorRevisionRef.current.delete(resourceEditorRoute.operationId);
@@ -2690,6 +2753,7 @@ export default function ProjectDevelopmentView({
);
}
const resource = route.resource;
const isCreate = route.generationMode === 'create';
const result = await withPlatformSessionRefresh(() =>
invoke<DeriveLocalProjectResourceResult>(
'derive_local_project_resource',
@@ -2701,16 +2765,27 @@ export default function ProjectDevelopmentView({
operationId: route.operationId,
idempotencyKey: route.idempotencyKey,
editKind: route.capability.editKind,
sourceResourceId: resource.id,
sourceAssetId: resource.manifestAssetId,
generationMode: route.generationMode,
sourceResourceId: isCreate
? `create:${route.operationId}`
: (resource?.id ?? ''),
sourceAssetId: isCreate
? null
: (resource?.manifestAssetId ?? null),
sourcePath:
resource.version || resource.subtype === 'agent-result'
isCreate ||
resource?.version ||
resource?.subtype === 'agent-result'
? null
: resource.path,
: (resource?.path ?? null),
sourceMediaType: route.capability.sourceMediaType,
sourceSubtype: resource.subtype,
producerTaskId: resource.producerTaskId,
sourceVersionId: resource.version?.versionId ?? null,
sourceSubtype: isCreate ? null : (resource?.subtype ?? null),
producerTaskId: isCreate
? null
: (resource?.producerTaskId ?? null),
sourceVersionId: isCreate
? null
: (resource?.version?.versionId ?? null),
prompt,
assetName,
},
@@ -2759,7 +2834,11 @@ export default function ProjectDevelopmentView({
setResourceEditorRoute(null);
setFocusedResourceId(null);
setMode('resources');
setAssetCanvasNotice('派生资源已保存,正在同步资源与布局…');
setAssetCanvasNotice(
isCreate
? '生成资源已保存,正在同步资源与布局…'
: '派生资源已保存,正在同步资源与布局…',
);
resourceEditorRevisionRef.current.delete(route.operationId);
},
[manifest.projectId, onManifestChange, projectPath, resourceEditorRoute],
@@ -3116,14 +3195,16 @@ export default function ProjectDevelopmentView({
: `管理未完成编辑 (${pendingResourceEdits.length})`}
</button>
) : null}
<button
type="button"
disabled
title="新增资源暂未开放,请先选择现有图片进行编辑"
>
<Sparkles size={15} aria-hidden="true" />
</button>
{resourceGenerationOptions.map((option) => (
<button
key={option.kind}
type="button"
onClick={() => openCreateResource(option.kind)}
>
<Sparkles size={15} aria-hidden="true" />
{option.label}
</button>
))}
<button
type="button"
onClick={() => void createUiDesignResource()}
@@ -3197,11 +3278,30 @@ export default function ProjectDevelopmentView({
/>
) : mode === 'resources' && resourceEditorRoute ? (
<ResourceEditSurface
resourceLabel={resourceEditorRoute.resource.label}
resourceLabel={
resourceEditorRoute.resource
? resourceEditorRoute.resource.label
: ((
{
video: '新视频',
'sound-effect': '新音效',
'background-music': '新背景音乐',
} as Record<string, string>
)[resourceEditorRoute.capability.editKind] ?? '新资源')
}
editKind={resourceEditorRoute.capability.editKind}
initialAssetName={defaultDerivedResourceName(
resourceEditorRoute.resource,
)}
generationMode={resourceEditorRoute.generationMode}
initialAssetName={
resourceEditorRoute.resource
? defaultDerivedResourceName(resourceEditorRoute.resource)
: ((
{
video: '新视频',
'sound-effect': '新音效',
'background-music': '新背景音乐',
} as Record<string, string>
)[resourceEditorRoute.capability.editKind] ?? '新资源')
}
semanticNotice={resourceEditorRoute.capability.semanticNotice}
onCancel={cancelResourceEditor}
onSubmit={submitResourceEdit}
@@ -3227,6 +3327,16 @@ export default function ProjectDevelopmentView({
</span>
</span>
<span className="game-resource-focus-actions">
{resolveProjectCharacterAnimationCapability(
focusedResource,
) ? (
<button
type="button"
onClick={() => openCharacterAnimation(focusedResource)}
>
</button>
) : null}
<button
type="button"
onClick={() => openResourceEditor(focusedResource)}
@@ -3,6 +3,7 @@ import type { ProjectResource } from './resourceProjectionModel';
export type LocalProjectResourceEditKind =
| 'image-reference'
| 'svg'
| 'character-animation'
| 'video'
| 'sound-effect'
| 'background-music'
@@ -28,8 +29,23 @@ export type ProjectResourceEditCapability =
editKind: LocalProjectResourceEditKind;
sourceMediaType: string;
semanticNotice: string | null;
generationMode?: 'create' | 'derive';
};
export type ResourceGenerationKind = Exclude<
LocalProjectResourceEditKind,
'image-reference' | 'svg' | 'text' | 'agent-result' | 'version'
>;
export const resourceGenerationOptions: Array<{
kind: ResourceGenerationKind;
label: string;
}> = [
{ kind: 'video', label: '生成视频' },
{ kind: 'sound-effect', label: '生成音效' },
{ kind: 'background-music', label: '生成背景音乐' },
];
const extensionMediaTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
@@ -195,6 +211,20 @@ export function resolveProjectResourceEditCapability(
};
}
export function resolveProjectCharacterAnimationCapability(
resource: ProjectResource,
): Extract<ProjectResourceEditCapability, { route: 'derive' }> | null {
const mediaType = canonicalProjectedResourceMediaType(resource);
if (!mediaType.startsWith('image/')) return null;
return {
route: 'derive',
editKind: 'character-animation',
sourceMediaType: mediaType,
semanticNotice: '将基于当前图片生成角色动作序列帧,源图片保持不变。',
generationMode: 'derive',
};
}
export function defaultDerivedResourceName(resource: ProjectResource) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-编辑版`;
@@ -210,6 +240,8 @@ export function resourceEditPromptMaxLength(
return 1_900;
case 'video':
return 4_000;
case 'character-animation':
return 4_000;
default:
return 32_000;
}
@@ -45,6 +45,14 @@ export type ProjectResource = {
dependencyDepth: number;
content?: string;
version?: ProjectVersionResourceSummary;
imageSequenceFrames?: Array<{
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
}> | null;
imageSequenceDurationMs?: number | null;
};
const documentExtension =
@@ -200,6 +208,8 @@ export function projectResourcesFromReadModels(
referenceResourceIds: asset.source.referenceResourceIds ?? [],
dependencies: [],
dependencyDepth: 0,
imageSequenceFrames: asset.imageSequenceFrames ?? null,
imageSequenceDurationMs: asset.imageSequenceDurationMs ?? null,
});
}
@@ -695,10 +695,12 @@ describe('project resource live canvas integration', () => {
expect(
(
screen.getByRole('button', {
name: '新增资源',
name: '生成视频',
}) as HTMLButtonElement
).disabled,
).toBe(true);
).toBe(false);
expect(screen.getByRole('button', { name: '生成音效' })).not.toBeNull();
expect(screen.getByRole('button', { name: '生成背景音乐' })).not.toBeNull();
fireEvent.click(sourceCard);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import {
canonicalProjectedResourceMediaType,
resolveProjectCharacterAnimationCapability,
resolveProjectResourceEditCapability,
resourceEditPromptMaxLength,
resourceGenerationOptions,
} from '../src/view/project-development/resourceEditModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
@@ -211,6 +213,40 @@ describe('全类型资源编辑能力模型', () => {
expect(resourceEditPromptMaxLength('background-music')).toBe(140);
expect(resourceEditPromptMaxLength('sound-effect')).toBe(1_900);
expect(resourceEditPromptMaxLength('video')).toBe(4_000);
expect(resourceEditPromptMaxLength('character-animation')).toBe(4_000);
expect(resourceEditPromptMaxLength('text')).toBe(32_000);
});
it('图片资源可进入角色动画派生入口', () => {
const capability = resolveProjectCharacterAnimationCapability(
resource({
id: 'asset:hero',
category: 'art',
path: 'assets/hero.png',
mediaType: 'image/png',
manifestAssetId: 'hero',
}),
);
expect(capability?.editKind).toBe('character-animation');
expect(capability?.generationMode).toBe('derive');
expect(
resolveProjectCharacterAnimationCapability(
resource({
id: 'asset:bgm',
category: 'audio',
path: 'audio/theme.mp3',
mediaType: 'audio/mpeg',
manifestAssetId: 'bgm',
}),
),
).toBeNull();
});
it('资源页提供新建视频、音效和背景音乐入口', () => {
expect(resourceGenerationOptions).toEqual([
{ kind: 'video', label: '生成视频' },
{ kind: 'sound-effect', label: '生成音效' },
{ kind: 'background-music', label: '生成背景音乐' },
]);
});
});
@@ -1,5 +1,16 @@
# AI 游戏创作智能体 App 实施计划
## 2026-08-23 AGC 资源生成补齐(视频 / 动画 / 音效 / 背景音乐)
- 统一资源编辑命令新增 `generationMode=create|derive``create` 用于无源生成视频、音效和背景音乐,`derive` 保持现有“基于已有资源派生”。视频新建请求不再携带 `referenceVideoSrcs`;音效 / 背景音乐新建使用同款文本生成端点,但提示词前缀改为“生成新音频”。
- 图片资源新增“生成动画”派生入口:复用 `/api/external/v1/editor/character-animations/generations`(账号模式自动映射 `/api/editor/character-animations/generations`),以源图片的稳定 objectKey、真实宽高和固定 `seedance2.0-fast / 720p / same / 32帧 / 4秒` 参数提交;完成后下载 `previewVideoPath` 预览视频并以 `character-animation` 资源类型登记为本地 MP4/WebM 媒体,避免只保存首帧。
- 本地 manifest 资产模型新增 `imageSequenceFrames``imageSequenceDurationMs`:角色动画完成时把远端 `frames``durationSeconds` 随账本持久化,恢复或重新提交后仍写回 manifest,前端资源投影同步暴露。
- 资源页顶部新增“生成视频 / 生成音效 / 生成背景音乐”三个入口,图片聚焦态新增“生成动画”按钮;统一资源编辑面板按 `generationMode` 显示“新建媒体资源”或“编辑现有资源”。
- 新建视频 / 音效 / 背景音乐时,先复用 `prepare_external_canvas_generation_context` 创建或匹配同名画板项目与素材库目录,并在生成请求中携带 `projectId / assetFolderId / canvasCompletion`,结果同时进入平台画布 / 素材库与本地 manifest。
- durable 账本新增 `remoteLegacyPublicPath` 字段(旧账本默认缺省),用于角色动画预览视频的换签下载与恢复;资源提交、轮询、下载、manifest 提交和恢复队列继续复用既有资源编辑事务链。
- 资源编辑请求指纹纳入 `generationMode`,同一 `operationId` 换生成模式重试时不再按旧账本模式恢复;旧账本继续通过 legacy 指纹兼容。
- 去背景(`/api/editor/images/background-removals`)仍缺 External v1 契约,不在本次补齐范围;本次只补视频 / 角色动画 / 音效 / 背景音乐。
## 2026-08-19 UI Editor 节点右键菜单
## 2026-08-20 UI Editor 最终预览互斥子节点
@@ -466,6 +466,14 @@ export interface GameCreationAppAssetManifestEntry {
mediaType: string;
localPath: string;
source: GameCreationAppAssetSource;
imageSequenceFrames?: Array<{
imageSrc: string;
objectKey?: string | null;
assetObjectId?: string | null;
width: number;
height: number;
}> | null;
imageSequenceDurationMs?: number | null;
}
export const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION =
@@ -478,6 +478,18 @@ pub struct GameCreationAppAssetSource {
pub reference_resource_ids: Vec<String>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppImageSequenceFrame {
pub image_src: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub object_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub asset_object_id: Option<String>,
pub width: u32,
pub height: u32,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct GameCreationAppAssetManifestEntry {
@@ -486,6 +498,10 @@ pub struct GameCreationAppAssetManifestEntry {
pub media_type: String,
pub local_path: String,
pub source: GameCreationAppAssetSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_sequence_frames: Option<Vec<GameCreationAppImageSequenceFrame>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_sequence_duration_ms: Option<u64>,
}
pub const GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION: &str = "game-creator-resource-layout.v1";
@@ -1777,6 +1793,8 @@ mod tests {
kind: "character".to_string(),
media_type: "image".to_string(),
local_path: "assets/images/player.png".to_string(),
image_sequence_frames: None,
image_sequence_duration_ms: None,
source: GameCreationAppAssetSource {
kind: GameCreationAppAssetSourceKind::Canvas,
canvas_project_id: Some("canvas-project-1".to_string()),