支持所有现有资源非破坏性编辑
Project CI / Repository checks (pull_request) Failing after 50s
Project CI / Frontend tests (pull_request) Failing after 2m8s
Project CI / Native shell tests (pull_request) Failing after 2m59s
Project CI / Backend tests (pull_request) Successful in 3m51s

前端统一现有资源编辑入口,按图片、视频、音频、文本、Agent 回执和版本分流。
Tauri 增加来源复核、稳定幂等、远端轮询、非破坏性派生与恢复账本。
服务端补齐资源编辑队列身份、视频音频幂等键和裁剪后的稳定完成结果。
同步共享合同、产品技术文档与定向回归测试。
This commit is contained in:
2026-08-10 15:19:34 +08:00
parent ea8e74dc2e
commit fe02999904
25 changed files with 3998 additions and 150 deletions
@@ -468,12 +468,29 @@ pub(crate) async fn resolve_authenticated_canvas_resource_download(
access_token: &str,
resource: &serde_json::Value,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit_and_route(
resolve_authenticated_canvas_resource_download_with_limit(
client,
api_base_url,
access_token,
resource,
20 * 1024 * 1024,
)
.await
}
pub(crate) async fn resolve_authenticated_canvas_resource_download_with_limit(
client: &reqwest::Client,
api_base_url: &str,
access_token: &str,
resource: &serde_json::Value,
max_bytes: usize,
) -> Result<Option<CanvasResourceDownload>, String> {
resolve_canvas_resource_download_with_limit_and_route(
client,
api_base_url,
access_token,
resource,
max_bytes,
"/api/assets/read-url",
)
.await
@@ -1136,6 +1136,24 @@ pub(crate) fn register_local_asset(
)
}
#[tauri::command]
pub(crate) async fn derive_local_project_resource(
input: DeriveLocalProjectResourceInput,
) -> Result<DeriveLocalProjectResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
derive_local_project_resource_at(input).await
}
#[tauri::command]
pub(crate) fn normalize_local_project_raster_resource(
input: NormalizeLocalProjectRasterResourceInput,
) -> Result<NormalizeLocalProjectRasterResourceResult, String> {
let root = Path::new(input.project_path.trim());
enforce_project_permission_policy(root, "asset.register")?;
normalize_local_project_raster_resource_at(input)
}
#[tauri::command]
pub(crate) fn import_canvas_asset(
project_path: String,
@@ -1365,12 +1383,18 @@ pub(crate) fn read_local_project_media_preview(
is_supported_project_audio_resource(&asset.local_path, &asset.media_type)
}
}
}) || (kind == ProjectMediaPreviewKind::Art
&& manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& is_supported_project_art_media_resource(&normalized_path, "")
}));
}) || manifest.tasks.iter().any(|task| {
task.status == GameCreationAppTaskStatus::Completed
&& task.artifacts.iter().any(|path| path == &normalized_path)
&& match kind {
ProjectMediaPreviewKind::Art => {
is_supported_project_art_media_resource(&normalized_path, "")
}
ProjectMediaPreviewKind::Audio => {
is_supported_project_audio_resource(&normalized_path, "")
}
}
});
if !is_registered_media {
return Err("只能预览当前项目已登记的媒体资源".to_string());
}
@@ -2187,6 +2187,8 @@ fn main() {
read_game_creator_mcp_catalog,
upload_local_asset,
register_local_asset,
derive_local_project_resource,
normalize_local_project_raster_resource,
import_canvas_asset,
import_canvas_export,
sync_canvas_project_assets,
@@ -12,6 +12,7 @@ mod filesystem;
mod manifest;
mod memory;
mod resource_dependency_graph;
mod resource_editor;
mod resource_layout;
mod verification;
@@ -24,5 +25,6 @@ pub(crate) use filesystem::*;
pub(crate) use manifest::*;
pub(crate) use memory::*;
pub(crate) use resource_dependency_graph::*;
pub(crate) use resource_editor::*;
pub(crate) use resource_layout::*;
pub(crate) use verification::*;
@@ -8,6 +8,7 @@ pub(crate) const ASSET_CANVAS_GENERATION_PROGRESS_EVENT: &str =
"game-creator-asset-generation-progress";
const ASSET_CANVAS_GENERATION_LEDGER_MAX_BYTES: usize = 512 * 1024;
const ASSET_CANVAS_GENERATION_REFERENCE_LIMIT: usize = 9;
const ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE: &str = "game-creator-resource-editor";
static ASSET_CANVAS_GENERATION_LOCKS: OnceLock<
tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
@@ -1345,6 +1346,13 @@ fn build_generation_request_snapshot(
serde_json::json!({ "title": ledger.asset_name, "placeholder": placeholder }),
);
let endpoint = if ledger.intent == AssetCanvasIntent::Refine {
body.insert(
"generationInputs".to_string(),
serde_json::json!({
"source": ASSET_CANVAS_RESOURCE_EDIT_QUEUE_SOURCE,
"operationId": ledger.generation_id,
}),
);
let source_image_src = ledger
.source_resource_id
.as_ref()
@@ -59,6 +59,7 @@ fn version_fixture(
}],
created_reason,
created_at: project_revision,
edit_prompt: None,
}
}
File diff suppressed because it is too large Load Diff
@@ -41,8 +41,57 @@ pub(crate) fn is_supported_project_text_resource(path: &str, media_type: &str) -
let media_type = media_type.trim().to_ascii_lowercase();
matches!(
path_extension(path).as_deref(),
Some("md" | "markdown" | "mdx" | "txt" | "json" | "yaml" | "yml" | "toml")
Some(
"md" | "markdown"
| "mdx"
| "txt"
| "json"
| "yaml"
| "yml"
| "toml"
| "html"
| "htm"
| "css"
| "scss"
| "less"
| "js"
| "jsx"
| "mjs"
| "cjs"
| "ts"
| "tsx"
| "rs"
| "py"
| "go"
| "java"
| "kt"
| "kts"
| "c"
| "cc"
| "cpp"
| "h"
| "hpp"
| "cs"
| "swift"
| "php"
| "rb"
| "lua"
| "sh"
| "bash"
| "zsh"
| "sql"
| "graphql"
| "gql"
| "xml"
| "csv"
| "ini"
| "conf"
| "vue"
| "svelte"
)
) && (media_type.is_empty()
|| !media_type.contains('/')
|| media_type == "application/octet-stream"
|| media_type.starts_with("text/")
|| media_type.contains("json")
|| media_type.contains("yaml")
@@ -164,6 +213,16 @@ fn project_text_media_type(path: &str) -> Option<&'static str> {
"json" => Some("application/json"),
"yaml" | "yml" => Some("application/yaml"),
"toml" => Some("application/toml"),
"html" | "htm" => Some("text/html"),
"css" | "scss" | "less" => Some("text/css"),
"js" | "jsx" | "mjs" | "cjs" => Some("text/javascript"),
"ts" | "tsx" => Some("text/typescript"),
"rs" => Some("text/x-rust"),
"py" => Some("text/x-python"),
"go" => Some("text/x-go"),
"java" | "kt" | "kts" | "c" | "cc" | "cpp" | "h" | "hpp" | "cs" | "swift" | "php"
| "rb" | "lua" | "sh" | "bash" | "zsh" | "sql" | "graphql" | "gql" | "xml" | "csv"
| "ini" | "conf" | "vue" | "svelte" => Some("text/plain"),
_ => None,
}
}
@@ -219,7 +278,7 @@ fn detect_project_media_type(
}
}
fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
pub(crate) fn validate_safe_svg(bytes: &[u8]) -> Result<(), String> {
let text = std::str::from_utf8(bytes).map_err(|_| "SVG 必须使用 UTF-8 编码".to_string())?;
let lower = text.to_ascii_lowercase();
if !lower.contains("<svg") {
+189 -11
View File
@@ -750,7 +750,9 @@ textarea {
@media (prefers-reduced-motion: reduce) {
.launcher-agent-chat-waiting > span,
.game-chat-runtime-status[data-tone='active'] .game-chat-runtime-state > span {
.game-chat-runtime-status[data-tone='active']
.game-chat-runtime-state
> span {
animation: none;
}
}
@@ -1538,7 +1540,9 @@ textarea {
box-shadow: 0 0 0 4px rgb(240 68 56 / 15%);
}
.game-chat-runtime-status[data-tone='complete'] .game-chat-runtime-state > span {
.game-chat-runtime-status[data-tone='complete']
.game-chat-runtime-state
> span {
background: #2e90fa;
box-shadow: 0 0 0 4px rgb(46 144 250 / 14%);
}
@@ -4415,6 +4419,25 @@ iframe.preview-frame {
white-space: nowrap;
}
.game-resource-focus-actions {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.game-resource-focus-actions > button:first-child {
min-height: 32px;
padding: 0 14px;
border: 1px solid #d78d69;
border-radius: 9px;
background: #fff;
color: #a65331;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.game-resource-focus-body {
display: grid;
grid-auto-rows: max-content;
@@ -4451,6 +4474,167 @@ iframe.preview-frame {
cursor: pointer;
}
.game-resource-editor {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: 100%;
min-width: 0;
min-height: 0;
overflow: hidden;
background: #fffdfa;
color: #563b31;
}
.game-resource-editor-titlebar {
display: flex;
align-items: center;
gap: 12px;
min-height: 58px;
padding: 10px 16px;
border-bottom: 1px solid #ead8cf;
background: #fff8f3;
}
.game-resource-editor-titlebar > span {
display: grid;
min-width: 0;
}
.game-resource-editor-titlebar small {
color: #a27764;
font-size: 9px;
font-weight: 700;
}
.game-resource-editor-titlebar strong {
overflow: hidden;
font-size: 15px;
text-overflow: ellipsis;
white-space: nowrap;
}
.game-resource-editor-back {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 32px;
padding: 0 10px;
border: 1px solid #ead8cf;
border-radius: 9px;
background: #fff;
color: #795d52;
cursor: pointer;
}
.game-resource-editor-form {
display: grid;
align-content: start;
gap: 16px;
width: min(680px, calc(100% - 32px));
margin: 0 auto;
padding: 28px 0 36px;
overflow: auto;
}
.game-resource-editor-form label {
display: grid;
gap: 7px;
color: #76594e;
font-size: 11px;
font-weight: 700;
}
.game-resource-editor-form input,
.game-resource-editor-form textarea {
width: 100%;
min-width: 0;
padding: 10px 12px;
border: 1px solid #dfc9bf;
border-radius: 10px;
outline: 0;
background: #fff;
color: #50382f;
font: inherit;
font-size: 12px;
font-weight: 400;
}
.game-resource-editor-form textarea {
min-height: 150px;
resize: vertical;
line-height: 1.6;
}
.game-resource-editor-form input:focus,
.game-resource-editor-form textarea:focus {
border-color: #c8754e;
box-shadow: 0 0 0 3px rgb(200 117 78 / 13%);
}
.game-resource-editor-form input:disabled,
.game-resource-editor-form textarea:disabled {
background: #f7f1ed;
color: #806d64;
}
.game-resource-editor-semantic-note,
.game-resource-editor-error {
margin: 0;
padding: 10px 12px;
border-radius: 10px;
font-size: 11px;
line-height: 1.5;
}
.game-resource-editor-semantic-note {
border: 1px solid #ead8cf;
background: #fff8f3;
color: #80665b;
}
.game-resource-editor-error {
border: 1px solid #e5a78d;
background: #fff1eb;
color: #9e4d2f;
}
.game-resource-editor-actions {
display: flex;
justify-content: flex-end;
}
.game-resource-editor-actions button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
padding: 0 16px;
border: 1px solid #bd603a;
border-radius: 10px;
background: #c96e47;
color: #fff;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.game-resource-editor-actions button:disabled,
.game-resource-editor-back:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.game-resource-editor-spinner {
animation: game-resource-editor-spin 0.9s linear infinite;
}
@keyframes game-resource-editor-spin {
to {
transform: rotate(360deg);
}
}
.game-resource-focus-metadata {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -5002,9 +5186,7 @@ iframe.preview-frame {
}
.game-workbench-chat .project-runtime-overview,
.game-workbench-chat
.agent-runtime-status
.project-runtime-pending-command {
.game-workbench-chat .agent-runtime-status .project-runtime-pending-command {
background: var(--platform-warm-bg);
}
@@ -5020,9 +5202,7 @@ iframe.preview-frame {
accent-color: var(--platform-accent);
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-professional-list {
.game-workbench-chat .agent-runtime-status .project-runtime-professional-list {
border-top-color: var(--platform-line-soft);
}
@@ -5040,9 +5220,7 @@ iframe.preview-frame {
color: var(--platform-button-primary-text);
}
.game-workbench-chat
.agent-runtime-status
.project-runtime-recovery {
.game-workbench-chat .agent-runtime-status .project-runtime-recovery {
border-color: var(--platform-button-danger-border);
background: var(--platform-button-danger-fill);
}
@@ -0,0 +1,142 @@
import { ChevronLeft, LoaderCircle, Sparkles } from 'lucide-react';
import { type FormEvent, useState } from 'react';
import {
type LocalProjectResourceEditKind,
resourceEditPromptMaxLength,
} from './resourceEditModel';
export type ResourceEditSubmitInput = {
prompt: string;
assetName: string;
};
export function ResourceEditSurface({
resourceLabel,
editKind,
initialAssetName,
semanticNotice,
onCancel,
onSubmit,
}: {
resourceLabel: string;
editKind: LocalProjectResourceEditKind;
initialAssetName: string;
semanticNotice: string | null;
onCancel: () => void;
onSubmit: (input: ResourceEditSubmitInput) => Promise<void>;
}) {
const [prompt, setPrompt] = useState('');
const [assetName, setAssetName] = useState(initialAssetName);
const [attempted, setAttempted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const inputLocked = attempted || submitting;
const promptMaxLength = resourceEditPromptMaxLength(editKind);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const normalizedPrompt = prompt.trim();
const normalizedName = assetName.trim();
if (!normalizedPrompt || !normalizedName || submitting) {
return;
}
setAttempted(true);
setSubmitting(true);
setError('');
try {
await onSubmit({ prompt: normalizedPrompt, assetName: normalizedName });
} catch (submitError) {
setError(
submitError instanceof Error
? submitError.message
: String(submitError),
);
setSubmitting(false);
}
}
return (
<section
className="game-resource-editor"
aria-labelledby="game-resource-editor-title"
>
<header className="game-resource-editor-titlebar">
<button
type="button"
className="game-resource-editor-back"
onClick={onCancel}
disabled={submitting}
>
<ChevronLeft size={16} aria-hidden="true" />
</button>
<span>
<small></small>
<strong id="game-resource-editor-title">{resourceLabel}</strong>
</span>
</header>
<form className="game-resource-editor-form" onSubmit={submit}>
{editKind !== 'version' ? (
<label>
<span></span>
<input
value={assetName}
maxLength={120}
disabled={inputLocked}
onChange={(event) => setAssetName(event.currentTarget.value)}
/>
</label>
) : null}
<label>
<span></span>
<textarea
value={prompt}
maxLength={promptMaxLength}
rows={7}
autoFocus
disabled={inputLocked}
placeholder="描述希望在现有资源基础上发生的变化"
onChange={(event) => setPrompt(event.currentTarget.value)}
/>
</label>
{semanticNotice ? (
<p className="game-resource-editor-semantic-note">{semanticNotice}</p>
) : null}
{error ? (
<p className="game-resource-editor-error" role="alert">
{error}
</p>
) : null}
<div className="game-resource-editor-actions">
{error ? (
<button type="submit" disabled={submitting}>
<Sparkles size={15} aria-hidden="true" />
使
</button>
) : (
<button
type="submit"
disabled={submitting || !prompt.trim() || !assetName.trim()}
>
{submitting ? (
<LoaderCircle
className="game-resource-editor-spinner"
size={15}
aria-hidden="true"
/>
) : (
<Sparkles size={15} aria-hidden="true" />
)}
{submitting
? '正在派生…'
: editKind === 'version'
? '创建子版本'
: '生成派生资源'}
</button>
)}
</div>
</form>
</section>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,224 @@
import type { ProjectResource } from './resourceProjectionModel';
export type LocalProjectResourceEditKind =
| 'image-reference'
| 'svg'
| 'video'
| 'sound-effect'
| 'background-music'
| 'text'
| 'agent-result'
| 'version';
export type ProjectResourceEditCapability =
| {
route: 'image-canvas';
sourceAssetId: string;
normalizeSource: false;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
}
| {
route: 'image-canvas';
sourceAssetId: null;
normalizeSource: true;
mediaType: 'image/png' | 'image/jpeg' | 'image/webp';
}
| {
route: 'derive';
editKind: LocalProjectResourceEditKind;
sourceMediaType: string;
requiresAccessToken: boolean;
semanticNotice: string | null;
};
const extensionMediaTypes: Record<string, string> = {
png: 'image/png',
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
webp: 'image/webp',
gif: 'image/gif',
svg: 'image/svg+xml',
avif: 'image/avif',
bmp: 'image/bmp',
mp4: 'video/mp4',
webm: 'video/webm',
mov: 'video/quicktime',
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
aac: 'audio/aac',
flac: 'audio/flac',
opus: 'audio/opus',
md: 'text/markdown',
markdown: 'text/markdown',
mdx: 'application/mdx',
txt: 'text/plain',
json: 'application/json',
yaml: 'application/yaml',
yml: 'application/yaml',
toml: 'application/toml',
html: 'text/html',
htm: 'text/html',
css: 'text/css',
scss: 'text/css',
less: 'text/css',
js: 'text/javascript',
jsx: 'text/javascript',
mjs: 'text/javascript',
cjs: 'text/javascript',
ts: 'text/typescript',
tsx: 'text/typescript',
rs: 'text/x-rust',
py: 'text/x-python',
go: 'text/x-go',
java: 'text/plain',
kt: 'text/plain',
kts: 'text/plain',
c: 'text/plain',
cc: 'text/plain',
cpp: 'text/plain',
h: 'text/plain',
hpp: 'text/plain',
cs: 'text/plain',
swift: 'text/plain',
php: 'text/plain',
rb: 'text/plain',
lua: 'text/plain',
sh: 'text/plain',
bash: 'text/plain',
zsh: 'text/plain',
sql: 'text/plain',
graphql: 'text/plain',
gql: 'text/plain',
xml: 'text/plain',
csv: 'text/csv',
ini: 'text/plain',
conf: 'text/plain',
vue: 'text/plain',
svelte: 'text/plain',
};
function pathExtension(path: string) {
return path.split(/[?#]/u, 1)[0]?.split('.').pop()?.toLowerCase() ?? '';
}
export function canonicalProjectedResourceMediaType(resource: ProjectResource) {
const declared = resource.mediaType.trim().toLowerCase();
if (/^[a-z0-9.+-]+\/[a-z0-9.+-]+$/u.test(declared)) {
return declared;
}
return extensionMediaTypes[pathExtension(resource.path)] ?? declared;
}
function isBackgroundMusic(resource: ProjectResource) {
const semanticSource = `${resource.subtype} ${resource.path}`.toLowerCase();
return ['background', 'bgm', 'music', 'theme'].some((marker) =>
semanticSource.includes(marker),
);
}
export function resolveProjectResourceEditCapability(
resource: ProjectResource,
): ProjectResourceEditCapability {
if (resource.version) {
return {
route: 'derive',
editKind: 'version',
sourceMediaType: 'application/vnd.genarrative.project-version+json',
requiresAccessToken: false,
semanticNotice: null,
};
}
if (resource.subtype === 'agent-result') {
return {
route: 'derive',
editKind: 'agent-result',
sourceMediaType: 'text/markdown',
requiresAccessToken: false,
semanticNotice: null,
};
}
const mediaType = canonicalProjectedResourceMediaType(resource);
if (['image/png', 'image/jpeg', 'image/webp'].includes(mediaType)) {
return resource.manifestAssetId
? {
route: 'image-canvas',
sourceAssetId: resource.manifestAssetId,
normalizeSource: false,
mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
}
: {
route: 'image-canvas',
sourceAssetId: null,
normalizeSource: true,
mediaType: mediaType as 'image/png' | 'image/jpeg' | 'image/webp',
};
}
if (mediaType === 'image/svg+xml') {
return {
route: 'derive',
editKind: 'svg',
sourceMediaType: mediaType,
requiresAccessToken: false,
semanticNotice: null,
};
}
if (mediaType.startsWith('image/')) {
return {
route: 'derive',
editKind: 'image-reference',
sourceMediaType: mediaType,
requiresAccessToken: true,
semanticNotice: null,
};
}
if (mediaType.startsWith('video/')) {
return {
route: 'derive',
editKind: 'video',
sourceMediaType: mediaType,
requiresAccessToken: true,
semanticNotice: null,
};
}
if (resource.category === 'audio' || mediaType.startsWith('audio/')) {
return {
route: 'derive',
editKind: isBackgroundMusic(resource)
? 'background-music'
: 'sound-effect',
sourceMediaType: mediaType,
requiresAccessToken: true,
semanticNotice: '音频将基于原资源语义派生重制,不会修改原音频波形。',
};
}
return {
route: 'derive',
editKind: 'text',
sourceMediaType: mediaType,
requiresAccessToken: false,
semanticNotice: null,
};
}
export function defaultDerivedResourceName(resource: ProjectResource) {
const baseName = resource.label.replace(/\.[^.]+$/u, '').trim();
return `${baseName || '资源'}-编辑版`;
}
export function resourceEditPromptMaxLength(
editKind: LocalProjectResourceEditKind,
) {
switch (editKind) {
case 'background-music':
return 140;
case 'sound-effect':
return 1_900;
case 'video':
return 4_000;
default:
return 32_000;
}
}
@@ -47,7 +47,8 @@ export type ProjectResource = {
version?: ProjectVersionResourceSummary;
};
const documentExtension = /\.(md|markdown|mdx|txt|json|ya?ml|toml)$/iu;
const documentExtension =
/\.(md|markdown|mdx|txt|json|ya?ml|toml|html?|css|scss|less|m?[jt]sx?|cjs|rs|py|go|java|kt|kts|c|cc|cpp|h|hpp|cs|swift|php|rb|lua|sh|bash|zsh|sql|graphql|gql|xml|csv|ini|conf|vue|svelte)$/iu;
const artExtension = /\.(png|jpe?g|webp|gif|svg|avif|bmp|mp4|webm|mov)$/iu;
const audioExtension = /\.(mp3|wav|ogg|m4a|aac|flac|opus)$/iu;
const artKind =
@@ -120,7 +121,7 @@ export function projectResourcesFromReadModels(
}
for (const path of task.artifacts) {
const category = classifyProjectedResource({ path, mediaType: '' });
if (!category || category === 'audio') {
if (!category) {
continue;
}
resources.push({
@@ -0,0 +1,54 @@
/** @vitest-environment jsdom */
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { ResourceEditSurface } from '../src/view/project-development/ResourceEditSurface';
import { fireEvent, render, screen, waitFor } from './appSurface/harness';
describe('统一资源编辑面板', () => {
it('失败后锁定原请求字段并使用相同输入重试', async () => {
const submit = vi
.fn<(input: { prompt: string; assetName: string }) => Promise<void>>()
.mockRejectedValueOnce(new Error('网络暂不可用'))
.mockResolvedValueOnce(undefined);
render(
<ResourceEditSurface
resourceLabel="玩法规则.md"
editKind="text"
initialAssetName="玩法规则-编辑版"
semanticNotice={null}
onCancel={() => undefined}
onSubmit={submit}
/>,
);
const name = screen.getByLabelText('派生资源名称');
const prompt = screen.getByLabelText('编辑提示词');
fireEvent.change(name, { target: { value: '红发角色规则' } });
fireEvent.change(prompt, { target: { value: '把角色头发设定改为红色' } });
fireEvent.click(screen.getByRole('button', { name: '生成派生资源' }));
expect((await screen.findByRole('alert')).textContent).toContain(
'网络暂不可用',
);
expect((name as HTMLInputElement).disabled).toBe(true);
expect((prompt as HTMLTextAreaElement).disabled).toBe(true);
fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' }));
await waitFor(() => expect(submit).toHaveBeenCalledTimes(2));
expect(submit.mock.calls[0]?.[0]).toEqual(submit.mock.calls[1]?.[0]);
});
it('对音频显示语义派生边界', () => {
render(
<ResourceEditSurface
resourceLabel="theme.ogg"
editKind="background-music"
initialAssetName="theme-编辑版"
semanticNotice="音频将基于原资源语义派生重制,不会修改原音频波形。"
onCancel={() => undefined}
onSubmit={async () => undefined}
/>,
);
expect(screen.getByText(/不会修改原音频波形/)).not.toBeNull();
});
});
@@ -211,6 +211,69 @@ function LiveWorkbench() {
);
}
function DerivedWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
initial.assets = [
{
id: 'source-rules',
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: 'docs/rules.md',
source: { kind: 'generated', resourceId: 'rules-resource' },
},
];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
function TaskImageWorkbench() {
const initial = createGameCreationAppManifest(
'live-canvas-project',
'实时画布项目',
);
const task = initial.tasks.find(
(candidate) => candidate.id === 'art-asset-plan',
);
if (!task) throw new Error('missing art task fixture');
task.status = 'completed';
task.artifacts = ['assets/task-hero.png'];
const [manifest, setManifest] = useState(initial);
canvasFixture.manifest = manifest;
return (
<ProjectDevelopmentView
projectName={manifest.name}
projectPath={projectPath}
manifest={manifest}
attachments={[]}
recentRunStatus={null}
recentRunStopReason={null}
supervisor={<div>Supervisor</div>}
onHomeOpen={() => undefined}
onProjectsOpen={() => undefined}
onManifestChange={(_path, nextManifest) => setManifest(nextManifest)}
/>
);
}
describe('project resource live canvas integration', () => {
afterEach(() => {
delete window.__TAURI__;
@@ -219,9 +282,11 @@ describe('project resource live canvas integration', () => {
canvasFixture.sequence = 0;
});
function installTauri() {
function installTauri(options: { failFirstDerive?: boolean } = {}) {
const layoutWrites: Array<Record<string, unknown>> = [];
const graphReads: Array<Record<string, unknown>> = [];
const deriveCalls: Array<Record<string, unknown>> = [];
const normalizeCalls: Array<Record<string, unknown>> = [];
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'get_local_game_project_revision') {
@@ -266,6 +331,80 @@ describe('project resource live canvas integration', () => {
dataUrl: 'data:image/png;base64,AA==',
};
}
if (command === 'read_local_project_text_preview') {
return {
path: String(args?.relativePath ?? ''),
mediaType: 'text/markdown',
byteLen: 8,
content: '# 玩法规则',
};
}
if (command === 'derive_local_project_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
deriveCalls.push(input);
if (options.failFirstDerive && deriveCalls.length === 1) {
throw new Error('result-unknown: 测试网络中断');
}
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const operationId = String(input.operationId);
const assetId = `edit-${operationId}`;
const asset = {
id: assetId,
kind: 'game-rules',
mediaType: 'text/markdown',
localPath: `assets/edits/${operationId}-rules.md`,
source: {
kind: 'generated' as const,
resourceId: `local-asset:${assetId}`,
referenceResourceIds: ['rules-resource'],
},
};
canvasFixture.revision += 1;
const nextManifest = {
...base,
assets: [...base.assets, asset],
};
canvasFixture.manifest = nextManifest;
return {
operationId,
editKind: input.editKind,
sourceResourceId: 'rules-resource',
committedProjectRevision: canvasFixture.revision,
asset,
version: null,
manifest: nextManifest,
};
}
if (command === 'normalize_local_project_raster_resource') {
const input = structuredClone(
(args?.input ?? {}) as Record<string, unknown>,
);
normalizeCalls.push(input);
const base = canvasFixture.manifest;
if (!base) throw new Error('missing manifest fixture');
const asset = {
id: 'normalized-task-hero',
kind: 'art-image',
mediaType: 'image/png',
localPath: String(input.sourcePath),
source: {
kind: 'generated' as const,
taskId: String(input.producerTaskId),
resourceId: String(input.sourceResourceId),
},
};
canvasFixture.revision += 1;
const nextManifest = { ...base, assets: [...base.assets, asset] };
canvasFixture.manifest = nextManifest;
return {
committedProjectRevision: canvasFixture.revision,
asset,
manifest: nextManifest,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
@@ -273,7 +412,7 @@ describe('project resource live canvas integration', () => {
core: { invoke },
event: { listen: async () => () => undefined },
};
return { graphReads, layoutWrites };
return { deriveCalls, graphReads, layoutWrites, normalizeCalls };
}
it('enters refine in the central view, keeps the draft on return, and preserves the source after a durable edit', async () => {
@@ -284,9 +423,11 @@ describe('project resource live canvas integration', () => {
name: /source-art\.png/,
});
expect(
(screen.getByRole('button', {
name: '新增资源',
}) as HTMLButtonElement).disabled,
(
screen.getByRole('button', {
name: '新增资源',
}) as HTMLButtonElement
).disabled,
).toBe(true);
fireEvent.click(sourceCard);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
@@ -373,4 +514,49 @@ describe('project resource live canvas integration', () => {
.value,
).toBe('');
});
it('derives a document non-destructively and retries with the original operation identity', async () => {
const { deriveCalls } = installTauri({ failFirstDerive: true });
render(<DerivedWorkbench />);
fireEvent.click(await screen.findByRole('button', { name: /rules\.md/ }));
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(await screen.findByText('编辑现有资源')).not.toBeNull();
fireEvent.change(screen.getByLabelText('编辑提示词'), {
target: { value: '把角色头发设定改为红色' },
});
fireEvent.click(screen.getByRole('button', { name: '生成派生资源' }));
expect(await screen.findByRole('alert')).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '使用原请求重试' }));
await waitFor(() => expect(deriveCalls).toHaveLength(2));
expect(deriveCalls[0]?.operationId).toBe(deriveCalls[1]?.operationId);
expect(deriveCalls[0]?.idempotencyKey).toBe(deriveCalls[1]?.idempotencyKey);
const operationId = String(deriveCalls[1]?.operationId);
expect(
await screen.findByRole('region', {
name: new RegExp(operationId, 'u'),
}),
).not.toBeNull();
fireEvent.click(screen.getByRole('button', { name: '收起资源' }));
expect(
await screen.findByRole('button', { name: /docs\/rules\.md/ }),
).not.toBeNull();
});
it('normalizes a completed task image before opening the existing refine canvas', async () => {
const { normalizeCalls } = installTauri();
render(<TaskImageWorkbench />);
fireEvent.click(
await screen.findByRole('button', { name: /task-hero\.png/ }),
);
fireEvent.click(screen.getByRole('button', { name: '编辑资源' }));
expect(
(await screen.findByLabelText('测试素材创作画布')).textContent,
).toContain('refine:normalized-task-hero');
expect(normalizeCalls).toHaveLength(1);
expect(normalizeCalls[0]?.sourcePath).toBe('assets/task-hero.png');
expect(normalizeCalls[0]?.producerTaskId).toBe('art-asset-plan');
});
});
@@ -0,0 +1,216 @@
import { describe, expect, it } from 'vitest';
import {
canonicalProjectedResourceMediaType,
resolveProjectResourceEditCapability,
resourceEditPromptMaxLength,
} from '../src/view/project-development/resourceEditModel';
import type { ProjectResource } from '../src/view/project-development/resourceProjectionModel';
function resource(
input: Partial<ProjectResource> &
Pick<ProjectResource, 'id' | 'category' | 'path'>,
): ProjectResource {
return {
subtype: 'task-artifact',
label: input.path,
mediaType: '',
sourceLabel: '测试来源',
taskTitle: null,
manifestAssetId: null,
producerTaskId: 'task-1',
externalResourceId: null,
referenceResourceIds: [],
dependencies: [],
dependencyDepth: 0,
...input,
};
}
describe('全类型资源编辑能力模型', () => {
it('把现役资源稳定分流到图片画布、媒体派生、文本派生和版本分支', () => {
const cases: Array<{
resource: ProjectResource;
route: 'image-canvas' | 'derive';
editKind?: string;
normalizeSource?: boolean;
}> = [
{
resource: resource({
id: 'asset:png',
category: 'art',
path: 'assets/hero.png',
mediaType: 'image/png',
manifestAssetId: 'png',
}),
route: 'image-canvas',
normalizeSource: false,
},
{
resource: resource({
id: 'task:art:hero.webp',
category: 'art',
path: 'hero.webp',
mediaType: '美术产物',
}),
route: 'image-canvas',
normalizeSource: true,
},
{
resource: resource({
id: 'asset:gif',
category: 'art',
path: 'assets/hero.gif',
mediaType: 'image/gif',
manifestAssetId: 'gif',
}),
route: 'derive',
editKind: 'image-reference',
},
{
resource: resource({
id: 'asset:svg',
category: 'art',
path: 'assets/icon.svg',
mediaType: 'image/svg+xml',
manifestAssetId: 'svg',
}),
route: 'derive',
editKind: 'svg',
},
{
resource: resource({
id: 'task:art:intro.mp4',
category: 'art',
path: 'assets/intro.mp4',
mediaType: '美术产物',
}),
route: 'derive',
editKind: 'video',
},
{
resource: resource({
id: 'asset:bgm',
category: 'audio',
path: 'audio/theme.ogg',
subtype: 'background-music',
mediaType: 'audio/ogg',
manifestAssetId: 'bgm',
}),
route: 'derive',
editKind: 'background-music',
},
{
resource: resource({
id: 'asset:sfx',
category: 'audio',
path: 'audio/hit.wav',
subtype: 'sound-effect',
mediaType: 'audio/wav',
manifestAssetId: 'sfx',
}),
route: 'derive',
editKind: 'sound-effect',
},
{
resource: resource({
id: 'task:code:main.ts',
category: 'document',
path: 'src/main.ts',
mediaType: '项目文档',
}),
route: 'derive',
editKind: 'text',
},
{
resource: resource({
id: 'agent-result:design:message-1',
category: 'document',
path: '专业 Agent 文本回执',
subtype: 'agent-result',
producerTaskId: null,
content: '回执',
}),
route: 'derive',
editKind: 'agent-result',
},
{
resource: resource({
id: 'version:v1',
category: 'version',
path: '项目版本 · v1',
subtype: 'project-version',
producerTaskId: null,
version: {
versionId: 'v1',
parentVersionId: null,
projectRevision: 1,
resourceBindings: [],
createdReason: 'initial',
createdAt: 1,
label: '版本 1',
childVersionIds: [],
},
}),
route: 'derive',
editKind: 'version',
},
];
for (const expected of cases) {
const capability = resolveProjectResourceEditCapability(
expected.resource,
);
expect(capability.route, expected.resource.id).toBe(expected.route);
if (capability.route === 'derive') {
expect(capability.editKind, expected.resource.id).toBe(
expected.editKind,
);
} else {
expect(capability.normalizeSource, expected.resource.id).toBe(
expected.normalizeSource,
);
}
}
});
it('用路径补齐任务产物缺失的标准媒体类型', () => {
expect(
canonicalProjectedResourceMediaType(
resource({
id: 'task:art:clip.mov',
category: 'art',
path: 'assets/clip.mov',
mediaType: '美术产物',
}),
),
).toBe('video/quicktime');
expect(
canonicalProjectedResourceMediaType(
resource({
id: 'task:audio:effect.wav',
category: 'audio',
path: 'audio/effect.wav',
mediaType: '美术产物',
}),
),
).toBe('audio/wav');
expect(
canonicalProjectedResourceMediaType(
resource({
id: 'task:code:main.ts',
category: 'document',
path: 'src/main.ts',
mediaType: '项目文档',
}),
),
).toBe('text/typescript');
});
it('按现役媒体接口限制编辑提示词长度', () => {
expect(resourceEditPromptMaxLength('background-music')).toBe(140);
expect(resourceEditPromptMaxLength('sound-effect')).toBe(1_900);
expect(resourceEditPromptMaxLength('video')).toBe(4_000);
expect(resourceEditPromptMaxLength('text')).toBe(32_000);
});
});
@@ -85,7 +85,7 @@
### 3.7 主站 UI 对齐与共享视觉边界
实现状态(2026-08-10):主站与 Tauri 已完成同源 chrome 接入。Tauri 现有中央素材画布直接消费共享动作按钮、工具栏、工具组和分隔符;工作台外围继续保留四区结构,并以平台 token 统一中央壳、Supervisor、Agent Dock、状态提示和主要操作。当前普通用户入口临时收敛为仅编辑现有图片:“新增资源”不可进入 create,图片“编辑资源”继续使用 refine 非破坏性生成新 asset。生成、保存、登录、计费、草稿、manifest、Runtime 和审批语义未随入口收敛而改变。
实现状态(2026-08-10):主站与 Tauri 已完成同源 chrome 接入。Tauri 现有中央素材画布直接消费共享动作按钮、工具栏、工具组和分隔符;工作台外围继续保留四区结构,并以平台 token 统一中央壳、Supervisor、Agent Dock、状态提示和主要操作。当前普通用户入口禁用“新增资源”,现有资源“编辑资源”按图片、SVG、视频、音频、文档/代码、Agent 回执和项目版本分流,所有结果均以新 asset 或子版本保存。生成、保存、登录、计费、草稿、manifest、Runtime 和审批语义不因入口分流而改变。
- 项目工作台继续保留左侧平台导航、中央主视窗、右侧 Project Supervisor 和底部专业 Agent 状态栏四区结构;主站图片编辑器只作为视觉语言和共享画布组件的事实源,不把其素材库侧栏、账号业务或云端项目外壳整体搬入客户端。
- 平台主题事实源固定为 `packages/shared/src/theme.css`。画布通用 chrome 固定落在 `@genarrative/image-canvas-react`,主站与 Tauri 必须直接 import 同一组件和作用域样式;客户端不得复制 `src/components/image-editor/`,也不得导入主站完整 `src/index.css`
@@ -103,7 +103,9 @@
```text
resource-overview
-> asset-canvas.create(当前临时禁用,不向普通用户开放)
-> asset-canvas.refine在唯一图片资源上点击“编辑资源”)
-> asset-canvas.refine静态图片“编辑资源”)
-> resource-editor.deriveSVG、视频、音频、文档/代码、Agent 回执“编辑资源”)
-> resource-editor.version-branch(项目版本“编辑资源”)
-> run(存在 runnableVersion 且 loopback preview 可启动)
asset-canvas.create|refine
@@ -143,13 +145,13 @@ idle -> focused(document|art|audio|version) -> idle
- 文档:合法 Agent 文本回执直接使用对话投影内容;项目文件只允许读取当前 manifest 已登记资产或已完成任务产物中的 Markdown、文本、JSON、YAML、TOML,必须经过 `file.read` auto 权限、相对路径、项目边界、普通文件、符号链接 / 硬链接、读取漂移、2 MiB、UTF-8 与扩展名白名单校验。正文使用不执行 HTML、不加载远程图片、不产生可点击外链的安全 Markdown 渲染,并在中央画布内独立滚动;读取失败显示错误空态。
- 美术:PNG、JPEG、WEBP 继续使用图片魔数与像素边界预览;GIF、SVG、AVIF、BMP、MP4、WebM、MOV 通过新增受控媒体读取链路按文件签名校验后在中央画布放大聚焦。SVG 额外拒绝脚本、事件处理器、外部资源引用和实体声明;视频使用内置播放控件。读取失败显示错误空态。
- 音频:读取 manifest 已登记音频已成功导入登记到 manifest 的附件,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。音频任务声明中的未登记路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。资源替换仍留给后续切片
- 音频:读取 manifest 已登记音频已成功导入登记到 manifest 的附件,以及已完成任务 `artifacts` 明确声明的音频产物,按文件签名接受 MP3、WAV、OGG / Opus、M4A、AAC、FLAC;聚焦态展示实际格式、浏览器解码后的时长以及带播放进度和暂停能力的内置播放器。未完成任务或未在 `artifacts` 中登记的任意本地路径继续不得读取或播放。
- 版本:只展示 manifest 中正式、不可变的迭代版本记录;版本卡展示项目修订、创建原因与父版本,聚焦态同时展示直接子版本和资源绑定。点击版本卡后高亮仍存在于当前资源投影中的引用资源;缺失历史资源只保留绑定身份,不生成幽灵资源卡。2026-08-10 起“编辑资源”只允许追加继承源绑定并记录提示词的子版本,不允许原地替换或修改源版本
- 资源聚焦不提供通用工具栏或工具侧边栏;图片聚焦态允许一个明确的“精修资源”业务动作进入素材创作无限画布,该动作不是在聚焦容器中内嵌编辑器或恢复通用工具栏。
- 点击资源后,中央主视窗从 `resource-overview.list` 切换为 `resource-overview.focused.document / art / audio / version`,左侧平台导航、右侧 Supervisor 对话和底部 Agent 状态栏保持原位;聚焦容器只包含标题、资源主体、必要元数据与右上角收起按钮,不使用页面级浮层或可拖动标题栏。
- 焦点转换以稳定资源 ID 为准。只有从资源列表进入详情或从一个资源 ID 切换到另一个 ID 时聚焦详情 region;同一资源 ID 因 manifest 更新而重新投影时,不得抢走详情内音频 / 视频控件、文档链接或收起按钮的当前焦点。
- 显式收起或按 Escape 后恢复进入前的搜索条件、dependency / type 布局模式、资源画布滚动位置和选中资源,并优先把键盘焦点还给原触发资源卡;这些只属于当前前端会话,不写入布局 sidecar。若资源已经被后台删除,必须清理 stale focused / selected ID、关闭详情并把焦点落到“搜索项目资源”,不得落到 `body`。项目切换和进入运行视图必须取消旧项目的焦点恢复意图。
- 资源管理阶段四至阶段七交付上述受控读取、媒体展示、正式版本只读展示和引用高亮;`2026-08-05`,后续素材创作切片已冻结完整图片闭环,不再把图片导入、基础编辑、生成、导出或本地回写列为非目标。入口必须与草稿 CAS、正式事务提交、`referenceResourceIds` 血缘、新资源即时投影、两份布局和焦点竞态一次实现,不能只打开一个没有回写的板。
- 资源管理阶段四至阶段七交付上述受控读取、媒体展示、正式版本展示和引用高亮;`2026-08-05`图片编辑闭环生效,`2026-08-10` 起全类型非破坏性派生与版本子分支覆盖旧的只读限制。入口必须与草稿或 operation 恢复、正式事务提交、`referenceResourceIds` 血缘、新资源即时投影、两份布局和焦点竞态一次实现,不能只打开一个没有回写的板。
### 4.4 历史成果与当前状态
@@ -305,7 +307,7 @@ type UpdateProjectResourceCanvasLayoutResult =
### 5.3 资源类型与替换兼容性(P1)
实现状态(2026-08-03):当前资源投影已收口到固定的“文档 -> 项目版本 -> 美术资源 -> 音乐音效资源”四区。文档接收 Markdown / 文本 / JSON / YAML 等正式项目文档和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频接收 manifest 已登记音频资产或已成功导入并登记到 manifest 的音频附件,任务声明中的未登记音频路径不冒充正式音频资源。无法识别的二进制任务产物和附件不进入资源画布。阶段四已为本地文档、安全 SVG / 扩展图片 / 视频和音频补齐受控读取、中央聚焦、失败空态与媒体播放;这些都是只读表现层,不改变资源投影或 manifest 真相
实现状态(2026-08-10):当前资源投影已收口到固定的“文档 -> 项目版本 -> 美术资源 -> 音乐音效资源”四区。文档接收受支持的 UTF-8 文档/代码和合法 Agent 文本回执;项目版本只接收显式 `ProjectVersionResourceSummary` read model,未知任务产物不得兜底为版本;美术接收图片、SVG、动画和视频类产物;音频接收 manifest 资产、上传登记资产和已完成任务 `artifacts` 明确声明的音频产物。无法识别的二进制任务产物和附件不进入资源画布。受控读取、中央聚焦、失败空态与媒体播放不改变 manifest 真相;编辑成功后只追加新的 asset 或版本子记录
资源身份固定使用 manifest asset ID、正式 version ID、Agent ID + run ID 或已导入资源稳定路径;显示标题、来源文案变化不得改变 `resourceId`,从而避免布局、依赖边、选择和聚焦状态因改名失效。
@@ -420,9 +422,9 @@ type ProjectAgentMudPointAttribution = {
- 已实施依赖/类型两套坐标持久化、首次默认不重叠布局、历史坐标跨重启恢复与自动协调 CAS 冲突处理;资源卡手动拖动暂缓。
- 资源关系线在布局持久化验收通过后单独实施,不与本切片捆绑伪造完成。
- 已实施正式版本只读模型、版本卡、父子关系与引用资源高亮;资源兼容性判断和不可变下一迭代版本创建仍待后续切片。
- 已实施正式版本不可变模型、版本卡、父子关系与引用资源高亮;“编辑资源”可追加继承源绑定并记录提示词的子版本,资源直接替换、运行版本切换和兼容性迁移仍待后续切片。
- 素材创作无限画布阶段一按权威专题一次交付图片导入、编辑、生成、导出、草稿恢复、正式本地回写、即时投影和焦点竞态闭环。
- 高级抠图、图集、角色动画、视频编辑和音频编辑按后续切片实施。
- 高级抠图、图集、角色动画、视频时间线编辑和音频波形级编辑按后续切片实施;当前视频走源引用派生,音频只做语义重制
### P2
@@ -475,7 +477,7 @@ type ProjectAgentMudPointAttribution = {
1. manifest 缺少 `versions` 时旧项目正常打开且不显示伪造版本;存在合法记录时,固定“项目版本”分区按追加顺序显示稳定版本卡。
2. 根版本、父版本和直接子版本关系在卡片或聚焦态可见;悬空父版本、自引用、重复 ID、非递增修订、倒退时间、重复 slot 和超限数字均失败关闭。
3. 点击版本卡后,当前 manifest 中仍存在的绑定资产卡被高亮;历史已删除资产只在版本详情保留 ID,不创建幽灵卡,也不把 External Editor resource ID 猜成 manifest asset ID。
4. 版本聚焦态只读展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定,不提供编辑、替换、切换、回滚或运行按钮。
4. 版本聚焦态展示身份、修订、创建原因、父子关系、创建时间和 slot 绑定;“编辑资源”只追加继承源绑定并记录提示词的子版本,不提供原地替换、切换、回滚或运行按钮。
5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;存储边界以跨进程专用锁串行覆盖旧状态读取、前缀校验、安装和回读,修改、删除、重排或并发旧快照覆盖已有版本时写入失败。
6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revisiondependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。
@@ -512,8 +514,8 @@ type ProjectAgentMudPointAttribution = {
## 8. 非目标
- 资源总览当前不实现资源卡手动拖动,也不实现通用聚焦工具栏/工具侧边栏、下一迭代版本创建入口、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,但当前只读取、校验和展示已有记录
- 素材创作阶段一不实现高级蒙版/毛发级抠图、图集、角色动画、视频编辑或音频编辑;图片画布平移/缩放、图层选择/移动/缩放、撤销重做、导入、基础编辑、生成、导出和本地回写明确不是非目标
- 资源总览当前不实现资源卡手动拖动,也不实现通用聚焦工具栏/工具侧边栏、资源直接替换、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,“编辑资源”仅追加子版本
- 素材创作阶段一不实现高级蒙版/毛发级抠图、图集、角色动画、视频时间线编辑或音频波形级编辑;图片画布继续提供现役编辑闭环,其他类型使用统一非破坏性派生面板
- 资源总览不持久化资源聚焦状态、搜索条件、筛选条件或当前 dependency/type mode;其会话上下文不能塞入 `game-creator-resource-layout.v1`。素材创作 viewport 和图层状态按独立 `game-creator-asset-canvas-draft.v1` 保存,不能混用资源总览 sidecar。
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -1,5 +1,14 @@
# 决策记录
## 2026-08-10 客户端现有资源编辑扩展到全部现役类型
- 产品入口:继续禁用“新增资源”,资源聚焦态的 manifest asset、已完成任务产物、上传附件、Agent 文本回执和项目版本统一显示“编辑资源”。静态图片复用 refine 图片画布,其他类型进入同一资源编辑壳,不建立平行资源总览。
- 非破坏性边界:图片、SVG、视频、音频、文档和代码生成新的本地文件与 manifest asset;Agent 原回执保持不变,派生文档引用回执身份;版本只追加继承资源绑定的子版本。任何路径都不得覆盖、删除或重排源记录。
- 能力分流:SVG/文本/代码走结构化 LLM 内容派生与格式复核;视频使用源视频稳定引用;音效/BGM 因现役接口无源音频字段,固定定义为基于源语义的派生重制,不能宣称波形级编辑;项目版本追加子版本。
- 信任边界:前端能力提示不是授权事实,Tauri 在提交前按 manifest、已完成任务或上传登记重新核验来源并复核项目 revision / 源内容摘要。远端生成继续使用稳定幂等身份、`202` 轮询与稳定 object/resource/asset 身份,签名 URL 不落 manifest。
- 队列与结果边界:登录态媒体派生统一使用 `game-creator-resource-editor` 专用消费身份;图片 refine、视频、音效和 BGM 复用同一逻辑请求的稳定 `Idempotency-Key`,路由不得丢弃。完成结果只返回裁剪后的稳定 object/resource/asset 引用与必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
- 关联:`docs/technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md``apps/ai-game-creator-shell/src/view/project-development/resourceEditModel.ts``apps/ai-game-creator-shell/src-tauri/src/project/resource_editor.rs`
## 2026-08-10 客户端素材画布临时收敛为现有图片非破坏性编辑
- 产品决策:资源总览“新增资源”暂时禁用,普通用户只从唯一图片资源进入“编辑资源”。底层 create 草稿和兼容测试继续保留,不删除既有合同,后续恢复入口时不需要重建数据层。
@@ -368,7 +368,7 @@ game-project/
- 项目工作台点击已登记图片时必须在中央主视窗的资源聚焦状态中直接渲染图片,而不是只展示路径与 MIME。图片通过受控 Tauri 命令从项目 `assets/` / `game/` 读取,只允许 manifest 已登记资产或已完成任务产物,并复用 `file.read` auto 权限、图片魔数、文件大小、像素尺寸、普通文件、路径漂移和符号链接校验后以 data URL 返回;首版只支持 PNG、JPEG、WEBP,不向 WebView 暴露任意本机文件协议或绝对路径。
- 2026-08-03 阶段四在上述图片链路外新增 `read_local_project_text_preview``read_local_project_media_preview`。前者只接收当前 manifest 已登记文档或已完成任务中的 Markdown / 文本 / JSON / YAML / TOML,限制 2 MiB 与 UTF-8;Agent 文本回执继续直接消费合法对话投影,不反查本地路径。后者的美术分支接收 GIF、安全 SVG、AVIF、BMP、MP4、WebM、MOV,音频分支只接收 manifest 已登记的 MP3、WAV、OGG / Opus、M4A、AAC、FLAC,二进制媒体限制 32 MiB。两条命令统一执行 `file.read` auto 权限、规范化相对路径、项目边界、敏感路径、普通文件、父目录链接、硬链接、读取漂移和重开身份复核;媒体按文件签名而非只按扩展名或 MIME 建立 data URL,SVG 额外拒绝活动内容与外部引用。
- `canvas.export_import` 复用 `/editor/canvas` 已有素材导出 ZIP 格式,读取根 `metadata.json`、复制 `images/` / `media/` / `sequences/` 到本地项目 `assets/canvas-imports/`,再按导出层登记为 `canvas` 来源资产;导出包不保存真实 resourceId 时,使用 `canvas-export:<file>` 作为可追踪 assetObjectId,不伪造后端资源行。
- 阶段一正式闭环只覆盖图片。网站 adapter 保留账户、钱包、服务端 editor project、云端素材库、OSS/asset object 与现有生成 APITauri adapter 使用本地项目、受控媒体、`game-creator-asset-canvas-draft.v1` 草稿、manifest、项目 mutation revision 和 External Editor API。高级抠图、图集、角色动画、视频和音频编辑后续分期。
- 阶段一图片画布正式闭环只覆盖图片。网站 adapter 保留账户、钱包、服务端 editor project、云端素材库、OSS/asset object 与现有生成 APITauri adapter 使用本地项目、受控媒体、`game-creator-asset-canvas-draft.v1` 草稿、manifest、项目 mutation revision 和 External Editor API。2026-08-10 起视频和音频进入统一非破坏性派生壳,但高级抠图、图集、角色动画、视频时间线和音频波形级编辑后续分期。
- Tauri 正式保存必须通过受控 staging 与 `commit_local_project_asset`,携带 `expectedProjectId + expectedRevision + expectedDraftRevision + commitId + idempotencyKey`。事务固定为 prepared journal、最终图片、manifest/revision 可恢复更新、回读验证、committed ledger/草稿、最后发布 `game-creator-local-asset-committed`;不能继续用先推进 revision 再分别登记资产的旧命令拼装正式闭环。
- refine 默认保留源文件和源 manifest asset,新建 `canvas-<commitId>` 资产。源资产没有外部 `source.resourceId` 时在同一 manifest 事务中补齐 `local-asset:<manifestAssetId>`,新资产通过 `referenceResourceIds` 引用该规范身份;禁止把裸 manifest asset ID 冒充 External Editor resource ID。
@@ -379,8 +379,8 @@ game-project/
2026-07-20 起,产品状态机、P0/P1/P2 范围与后续数据合同以 [`【AI游戏创作】项目开发工作台PRD-2026-07-20.md`](../prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md) 为准;本节只保留当前实现边界。
- 页面骨架固定为左侧现有全局导航、中间主视窗、右侧陶泥儿对话和底部子 Agent 状态栏;不新建第二套客户端或平行项目页。
- 中间主视窗提供 `resource-overview / asset-canvas / run` 种状态。2026-08-10 起普通用户入口临时收敛为仅编辑现有图片:“新增资源”显示为禁用态且处理函数拒绝 create,图片聚焦“编辑资源”进入 refine 素材创作无限画布;底层 create 合同仅保留兼容。素材画布只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执已导入附件派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,任务声明中的未登记音频也不冒充正式音频`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 中间主视窗提供 `resource-overview / asset-canvas / resource-editor / run` 种状态。2026-08-10 起普通用户“新增资源”显示为禁用态且处理函数拒绝 create;所有现役资源从聚焦“编辑资源”进入非破坏性派生。静态图片继续进入 refine 素材创作无限画布,SVG、视频、音频、文档/代码、Agent 回执和项目版本进入统一资源编辑壳并按能力分流;底层 create 合同仅保留兼容。编辑面板只替换中央区域,不覆盖右侧 Supervisor 或底部 Agent。`code-prototype` 任务完成前运行入口保持视觉不可用,但仍可点击查看“当前无可运行版本”,不能使用会阻断说明交互的原生 `disabled``aria-disabled`;完成后才允许进入运行表现层。切回资源总览只修改前端展示态,不伪造后端预览暂停结果。
- 资源管理从当前 `GameCreationAppManifest`(包含可选 `versions`)、合法 Agent 文本回执已导入附件和已完成任务明确登记的产物派生资源,固定按文档、项目版本、美术资源、音乐音效资源分区;未知任务产物不再兜底为版本,未完成任务或未在 `artifacts` 中登记的任意本地音频也不冒充正式资源`按依赖 / 按类型` 使用各自前端排列,dependency 模式额外绘制当前 manifest 与资源投影可证明的依赖关系。排列与图层都不写回 manifest,不能推断或伪造缺失依赖。
- 资源卡支持点击聚焦、搜索和类型筛选。2026-07-28 起完成两套二维坐标与本地 CAS sidecar2026-07-31 起 dependency 模式增加不持久化的原生 SVG 关系图层。2026-08-03 mentor 决定暂缓资源总览卡片拖动,当前卡片不挂载 Pointer Down / Move / Up / Cancel 拖动入口,只允许自动布局和点击聚焦。聚焦态替换中央主视窗内容,保留左侧导航、右侧对话和底部 Agent 状态栏,退出后恢复搜索、布局模式、滚动位置与选中资源;不提供通用工具栏、工具侧边栏或可拖动标题栏。阶段四已补齐安全本地文档、扩展美术媒体与音频聚焦,正文独立滚动,视频 / 音频使用内置媒体控件,失败显示空态。该资源总览边界不限制后续素材创作无限画布内的图片图层移动/缩放、生成和正式回写。
- 运行表现层首版直接嵌入当前项目的 loopback 游戏画面,并展示上一项 / 暂停继续 / 下一项切片控制、素材信息和数值微调面板。`preview.start` 启动本地 server 后把真实 URL 回写工作台,`preview.open` 只激活客户端内运行视图,不再调用系统浏览器;切片、参数调整和自然语言新增调节项首版仍只保留本地 UI 草稿,不修改代码或 manifest。
- 右侧继续复用现有 Project Supervisor 会话、Runtime 澄清和确认链路;输入区展示 `严格审批 / 风险审批 / 无需审批` 独立面板。P0 只有严格审批可选;风险审批和无需审批保持视觉不可用但允许点击查看原因,不替代 Runtime 的逐动作权限、确认、sandbox 或 reconciliation 门禁。风险 Rank 算法记录在 `docs/project-memory/todos/【待解决】AI游戏创作高风险审批Rank-2026-07-20.md`,前端不得自行计算。
@@ -936,7 +936,7 @@ game-project/
- 正式资产命令固定为 `commit_local_project_asset`,同时绑定 `expectedProjectId`、项目 `expectedRevision``expectedDraftRevision``commitId``idempotencyKey` 和 staging 摘要。事务在项目锁内按 prepared、最终图片、manifest/revision 逻辑原子更新、回读、ledger/草稿提交推进,释放锁后最后发布 `game-creator-local-asset-committed`;事件至少一次并按固定 eventId 去重。
- refine 不覆盖源文件或复用源 asset ID。源缺少外部 resourceId 时补齐 `local-asset:<manifestAssetId>`,新 `canvas-<commitId>` 资产用 `referenceResourceIds` 登记源和其它直接引用,避免混淆 manifest asset ID 与 External Editor resource ID。
- 提交返回完整最新 manifest;当前项目仍匹配时立即更新项目上下文、资源投影、依赖图和两种布局,布局 ready 后才按焦点守卫决定选中。用户已切项目、切状态、开始新 session、选择其它资源或改变搜索/筛选时,迟到结果不得抢焦点;新资源被隐藏时保留条件并提供显式清除/定位动作。
- 图片首版包含平移、缩放、图层选择/移动/缩放、撤销重做、导入、基础编辑、生成、导出和本地回写。高级抠图、图集、角色动画、视频与音频编辑后续分期;本阶段不修改 SpacetimeDB schema。
- 图片首版包含平移、缩放、图层选择/移动/缩放、撤销重做、导入、基础编辑、生成、导出和本地回写。2026-08-10 起视频使用源引用派生、音频使用语义重制;高级抠图、图集、角色动画、视频时间线与音频波形级编辑后续分期;本阶段不修改 SpacetimeDB schema。
## 2026-08-05 客户端素材创作无限画布阶段二共享源码抽取
@@ -2,7 +2,7 @@
更新时间:`2026-08-10`
状态:阶段一产品与技术合同已冻结;截至 2026-08-10,客户登录态图片生成、泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地。当前产品入口按第 16 节临时收敛为仅编辑现有图片,后续增量仍受本文合同约束。
状态:阶段一产品与技术合同已冻结;截至 2026-08-10,客户登录态图片生成、泥点计费链路、中央进度/失败态、Tauri 正式资产提交及画布 UI 对齐阶段三至五已落地。当前产品入口按第 16 节禁用新增,并把编辑入口扩展到资源总览的全部现役类型;后续增量仍受本文合同约束。
本文是网站与 AI 游戏创作 Tauri 客户端共享图片画布能力的下一阶段编码依据。若本文与资源管理阶段七的“美术编辑暂缓”口径冲突,以本文对后续素材创作切片的更新决定为准;资源总览既有布局、依赖图和只读聚焦合同继续有效。
@@ -11,7 +11,7 @@
1. 项目工作台中央主视窗必须把“资源总览画布”和“素材创作无限画布”建模为两个不同状态;两者复用同一个工作台壳,不创建平行项目页。
2. 资源总览顶部的“新增资源”和图片资源聚焦态的“精修资源”进入素材创作无限画布。资源总览卡片仍不可拖动;素材创作无限画布中的图片图层必须可选择、移动和缩放,二者不是同一种交互。
3. 阶段一正式闭环只覆盖图片。PNG、JPEG、WebP 的导入、画布平移与缩放、单选与多选、图层移动与缩放、层序、显隐、锁定、翻转、分组、撤销与重做、裁剪/扩图等基础编辑、图片生成、导出和 Tauri 本地正式回写均属于目标,不得再列为非目标。
4. 现有一键去背景能力可以通过共享 Host Port 接入;毛发级抠图、可编辑蒙版和高级边缘修复后续分期。图集、角色动画、视频编辑和音频编辑不进入本阶段正式闭环。
4. 现有一键去背景能力可以通过共享 Host Port 接入;毛发级抠图、可编辑蒙版和高级边缘修复后续分期。图集、角色动画、视频时间线编辑和音频波形级编辑不进入本阶段正式闭环;视频源引用派生和音频语义重制按第 16 节执行
5. 网站和 Tauri 必须实际 import 同一份画布 core 与 React/UI 源码。现役 `src/components/image-editor/` 是抽取来源,不得把整个目录复制到客户端,也不得形成网站版和 Tauri 版两份长期分叉的画布实现。
6. 网站继续负责账户、钱包、服务端编辑器项目和云端素材库;Tauri 继续负责本地项目、受控文件读写、manifest、项目 mutation revision,并以当前平台登录态复用同一服务端编辑器与计费链路。第三方 Agent/CLI 使用的 External Editor API 是独立开发者通道,不是普通客户素材画布的鉴权方式。共享画布不知道这些事实来自哪个宿主。
7. 本阶段不修改 SpacetimeDB schema,不新增前端业务真相,不把草稿 sidecar 当成正式资产。
@@ -969,17 +969,24 @@ confirmation-required
- 保存设置的产品语义固定为“名称可编辑、用途受控、格式枚举”。create 默认 `game-art`,普通用户只从 `game-art / icon-spec / ui-prototype / art-spritesheet` 四个权威用途选择;界面显示中文名称,不暴露自由 slug 输入。refine 必须继承源 manifest asset 的 `kind` 并锁定,未知历史 kind 原值透传但只显示“原资源用途”,不得借精修改变 subtype。PNG/JPEG/WebP 继续是有限格式选项。
- 工具动作与保存设置必须是显式上下两行,保存栅格把主按钮列固定为 `max-content` 且禁止换行;容器宽度不足时保存按钮独占整行。普通用户工作区状态只显示项目名称,不直接展示本机绝对项目路径;显式目录选择、权限确认或开发诊断不受该展示规则替代。
## 16. 2026-08-10 现有图片非破坏性编辑阶段覆盖条款
## 16. 2026-08-10 全类型现有资源非破坏性编辑阶段覆盖条款
本节是当前产品入口的临时覆盖条款;与第 1、2、4、13 节中要求同时开放 create/refine 入口的文字冲突时,以本节为准。底层 create 草稿、序列化和恢复兼容继续保留,不作为当前普通用户入口。
本节是当前产品入口的覆盖条款;与第 1、2、4、13 节中要求同时开放 create/refine 入口或只允许图片编辑的文字冲突时,以本节为准。底层 create 草稿、序列化和恢复兼容继续保留,不作为当前普通用户入口。
- 资源总览的“新增资源”保留为明确禁用态,入口处理函数也必须拒绝 create,不能只依赖按钮外观阻止进入空白画布。
- 当前唯一正式入口是图片资源聚焦态的“编辑资源”。打开后必须使用 `intent=refine`,自动加载唯一源图片,并把源资源身份作为图片编辑请求的必选引用
- 资源聚焦态的所有现役资源均提供“编辑资源”,覆盖 manifest asset、已完成任务产物、已导入附件、Agent 文本回执和项目版本。后端必须按 manifest、任务完成态、上传登记或回执身份重新核验来源;没有唯一来源身份的本地媒体不得仅凭前端路径进入编辑
- 静态 PNG / JPEG / WebP 继续使用 `AssetCanvasSurface + intent=refine`,自动加载唯一源图片,并把源资源身份作为图片编辑请求的必选引用。任务产物或附件中的静态图片必须先正规化为正式 manifest asset,再进入现有图片画布。
- 普通客户确认编辑后固定调用 `POST /api/editor/images/edits`;提示词、比例、尺寸、资源用途、登录态、泥点计费和原 operation 恢复继续复用现有生成合同。
- 编辑结果固定创建新的本地 asset、文件路径、commitId 和资源身份。源 asset 与源文件不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧图片
- SVG、UTF-8 文档、代码和 Agent 文本回执使用文本差异派生:把源内容当作不可信数据交给当前客户端 LLM,响应必须是完整、唯一的结构化内容 envelope;JSON、SVG 等可校验格式必须在落盘前重新校验。结果写入新的本地路径和 manifest asset,不能直接写回源文件。Agent 回执原记录不转写、不删除,新 asset 以回执资源身份登记血缘
- 视频使用现役 `POST /api/editor/videos/generations`。有稳定远端引用时直接作为 `referenceVideoSrcs`,只有本地文件时先走 direct-upload ticket、OSS 表单上传和 confirm,再提交同一逻辑生成;结果必须下载到新的本地文件并登记远端稳定身份。
- 音效和背景音乐分别使用现役 `POST /api/editor/audios/sound-effects/generations``POST /api/editor/audios/background-music/generations`。当前接口没有源音频引用字段,因此产品语义固定为“基于原资源语义的派生重制”,界面不得描述为对源波形的裁剪、变声或局部修改;结果仍必须引用源资源身份并保留原音频。
- 项目版本编辑固定追加 `parentVersionId` 指向源版本的子版本,继承源版本资源绑定并记录本轮编辑提示;已有版本数组元素不可修改、删除或重排。
- 所有编辑结果固定创建新的本地 asset、文件路径、版本 ID 或资源身份。源 asset、源文件、Agent 回执与源版本不得删除、覆盖或复用;新 asset 的 `referenceResourceIds` 必须包含源资源规范身份,资源总览同时保留新旧资源。
- 各类型统一使用稳定 `operationId / Idempotency-Key`。远端 `202` 只表示受理,必须轮询原 operation;提交结果未知或登录失效时保留原身份供恢复,不能换键重提。签名 URL 不得写入 manifestmanifest 只保存稳定 objectKey 对应的资源 / 资产身份。
- 登录态图片 refine、视频、音效和背景音乐请求统一在 `generationInputs.source` 写入专用消费身份 `game-creator-resource-editor`;视频和音频登录态路由必须实际读取并传递同一稳定 `Idempotency-Key`,不能只由客户端发送后在路由层丢弃。队列完成态只向该消费身份返回经过裁剪的稳定 `objectKey / resource / asset` 引用和必要媒体元数据,不暴露 provider、worker、队列内部字段或临时签名 URL。
- 生成公开状态每次写入草稿都必须推进草稿 revision,并把最新 revision 同步到私有生成账本、进度事件、staging 与正式 commit;旧 UI 快照不得覆盖 accepted/running/reconciliation 状态。
- operation 已受理或首次提交结果未知后遇到 401/403,不得写成 terminal failed。私有账本保留原 operation、原请求字节和原幂等身份,公开状态进入可恢复对账态;登录刷新后只继续同一 operation。
- 返回资源总览时,clean 草稿直接保留并退出;dirty 草稿必须使用独立确认面板提供“保留草稿并退出”和“放弃草稿”,默认保留。保留前必须等待当前保存或主动 flush,放弃才允许调用 discard。
- 图层选择与图层缩放必须同时提供指针和键盘路径;不得嵌套 button/role=button。Enter/Space 可选择图层,缩放手柄使用原生 button 并提供方向键离散缩放。
当前阶段不主动扩展 create 专属空画布导入、生成或保存体验;共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响 refine 编辑链路。
当前阶段不主动扩展 create 专属空画布导入、生成或保存体验;图片继续复用现有画布,其他类型使用同一资源编辑壳按能力分流,不能把视频、音频或版本强塞进图片图层模型。共享持久化、安全、幂等和可访问性缺陷仍必须修复,因为它们直接影响编辑链路。
@@ -555,6 +555,7 @@ export interface GameIterationVersion {
resourceBindings: GameIterationVersionResourceBinding[];
createdReason: GameIterationVersionCreatedReason;
createdAt: number;
editPrompt?: string | null;
}
export interface GameCreationAppManifest {
@@ -10,7 +10,7 @@ use std::{
use axum::{
Json,
extract::{Extension, Path as AxumPath, Query, State, rejection::JsonRejection},
http::StatusCode,
http::{HeaderMap, StatusCode},
response::Response,
};
use image::{
@@ -83,7 +83,8 @@ use crate::{
EditorGenerationOperationContext, PreparedEditorGenerationResultItem,
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
persist_editor_generation_result_atomically, preflight_editor_billable_generation_target,
optional_editor_idempotency_key, persist_editor_generation_result_atomically,
preflight_editor_billable_generation_target,
remove_editor_generated_screen_background_with_bgfilter,
resolve_editor_reference_object_key_for_owner, sanitize_editor_client_generation_inputs,
serialize_editor_generation_inputs, serialize_editor_image_sequence_frames,
@@ -1058,6 +1059,7 @@ pub async fn generate_editor_video(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<EditorVideoGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = payload.map_err(|error| {
@@ -1069,6 +1071,8 @@ pub async fn generate_editor_video(
})),
)
})?;
let idempotency_key = optional_editor_idempotency_key(&headers)
.map_err(|error| editor_video_error_response(&request_context, error))?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_video_generation_for_owner(
@@ -1076,7 +1080,7 @@ pub async fn generate_editor_video(
&request_context,
owner_user_id.as_str(),
payload,
None,
idempotency_key,
)
.await?;
return Ok(json_success_body(
@@ -500,9 +500,12 @@ pub(crate) struct EditorGenerationOperationContext {
pub(crate) enum EditorGenerationQueueConsumer {
Standard,
EditorAgent,
GameCreatorResourceEditor,
ExternalApi,
}
const GAME_CREATOR_RESOURCE_EDITOR_SOURCE: &str = "game-creator-resource-editor";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct EditorGenerationQueueResultContext {
consumer: EditorGenerationQueueConsumer,
@@ -524,22 +527,25 @@ impl EditorGenerationQueueResultContext {
}
pub(crate) fn from_job(job: &ExternalGenerationJobRecord) -> Self {
let generation_source = serde_json::from_str::<Value>(job.request_payload_json.as_str())
.ok()
.and_then(|payload| {
payload
.pointer("/generationInputs/source")
.and_then(Value::as_str)
.map(str::trim)
.map(str::to_string)
});
let consumer = if job
.dedupe_key
.trim()
.starts_with("external-api-generation:")
{
EditorGenerationQueueConsumer::ExternalApi
} else if serde_json::from_str::<Value>(job.request_payload_json.as_str())
.ok()
.is_some_and(|payload| {
payload
.pointer("/generationInputs/source")
.and_then(Value::as_str)
.is_some_and(|source| source.trim() == "editor-agent")
})
{
} else if generation_source.as_deref() == Some("editor-agent") {
EditorGenerationQueueConsumer::EditorAgent
} else if generation_source.as_deref() == Some(GAME_CREATOR_RESOURCE_EDITOR_SOURCE) {
EditorGenerationQueueConsumer::GameCreatorResourceEditor
} else {
EditorGenerationQueueConsumer::Standard
};
@@ -1105,6 +1111,14 @@ fn serialize_atomic_editor_generation_job_result(
compact_editor_generation_result(response.clone()),
);
}
if queue_result_context.consumer == EditorGenerationQueueConsumer::GameCreatorResourceEditor
&& let Some(object) = payload.as_object_mut()
{
object.insert(
"result".to_string(),
compact_editor_generation_result(response.clone()),
);
}
if queue_result_context.consumer == EditorGenerationQueueConsumer::ExternalApi
&& let Some(object) = payload.as_object_mut()
{
@@ -4002,11 +4016,21 @@ fn align_editor_image_edit_dimension(value: u32) -> u32 {
value.saturating_add(15) / 16 * 16
}
fn ensure_editor_image_edit_asset_kind_allowed(asset_kind: Option<&str>) -> Result<(), AppError> {
fn is_game_creator_resource_editor_generation(generation_inputs: Option<&Value>) -> bool {
generation_inputs
.and_then(|value| value.pointer("/source"))
.and_then(Value::as_str)
.is_some_and(|source| source.trim() == GAME_CREATOR_RESOURCE_EDITOR_SOURCE)
}
fn ensure_editor_image_edit_asset_kind_allowed(
asset_kind: Option<&str>,
generation_inputs: Option<&Value>,
) -> Result<(), AppError> {
let Some(asset_kind) = asset_kind.map(str::trim).filter(|value| !value.is_empty()) else {
return Ok(());
};
if asset_kind != "icon" {
if asset_kind != "icon" || is_game_creator_resource_editor_generation(generation_inputs) {
return Ok(());
}
@@ -4073,7 +4097,10 @@ async fn ensure_editor_image_edit_source_allowed(
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
return ensure_editor_image_edit_asset_kind_allowed(
payload.asset_kind.as_deref(),
payload.generation_inputs.as_ref(),
);
};
let Some(target_layer_id) = payload
.target_layer_id
@@ -4081,7 +4108,10 @@ async fn ensure_editor_image_edit_source_allowed(
.map(str::trim)
.filter(|value| !value.is_empty())
else {
return ensure_editor_image_edit_asset_kind_allowed(payload.asset_kind.as_deref());
return ensure_editor_image_edit_asset_kind_allowed(
payload.asset_kind.as_deref(),
payload.generation_inputs.as_ref(),
);
};
let project = state
.spacetime_client()
@@ -4098,7 +4128,7 @@ async fn ensure_editor_image_edit_source_allowed(
target_layer_id,
)?
.or(payload.asset_kind.as_deref());
ensure_editor_image_edit_asset_kind_allowed(asset_kind)
ensure_editor_image_edit_asset_kind_allowed(asset_kind, payload.generation_inputs.as_ref())
}
fn encode_editor_image_png(
@@ -5269,7 +5299,9 @@ pub async fn edit_editor_image(
.await
}
fn optional_editor_idempotency_key(headers: &HeaderMap) -> Result<Option<&str>, AppError> {
pub(crate) fn optional_editor_idempotency_key(
headers: &HeaderMap,
) -> Result<Option<&str>, AppError> {
let Some(value) = headers.get("idempotency-key") else {
return Ok(None);
};
@@ -18421,6 +18453,36 @@ mod tests {
.is_none()
);
ordinary_job.request_payload_json = json!({
"generationInputs": { "source": GAME_CREATOR_RESOURCE_EDITOR_SOURCE },
})
.to_string();
let resource_editor_context = EditorGenerationQueueResultContext::from_job(&ordinary_job);
assert_eq!(
resource_editor_context.consumer,
EditorGenerationQueueConsumer::GameCreatorResourceEditor
);
let resource_editor_payload: Value = serde_json::from_str(
serialize_atomic_editor_generation_job_result(&resource_editor_context, &result)
.expect("resource editor queue payload")
.as_str(),
)
.expect("resource editor queue payload should be JSON");
assert_eq!(
resource_editor_payload["result"]["objectKey"],
"generated/image.png"
);
assert_eq!(
resource_editor_payload["result"]["resource"],
json!({
"resourceId": "resource-1",
"objectKey": "generated/image.png",
"assetObjectId": "asset-object-1",
})
);
assert!(resource_editor_payload["result"].get("asset").is_none());
assert!(resource_editor_payload["result"].get("provider").is_none());
ordinary_job.request_payload_json = "{}".to_string();
ordinary_job.dedupe_key =
"external-api-generation:editor_image_generation:fingerprint".to_string();
@@ -18772,7 +18834,7 @@ mod tests {
#[test]
fn editor_image_edit_rejects_individual_icons_but_allows_spritesheets_and_specs() {
let error = ensure_editor_image_edit_asset_kind_allowed(Some("icon"))
let error = ensure_editor_image_edit_asset_kind_allowed(Some("icon"), None)
.expect_err("individual icon assets should not support quick edit");
assert_eq!(error.status_code(), StatusCode::BAD_REQUEST);
assert_eq!(
@@ -18784,10 +18846,19 @@ mod tests {
Some(&json!("icon")),
);
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spritesheet")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image")).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(None).is_ok());
assert!(
ensure_editor_image_edit_asset_kind_allowed(Some("icon-spritesheet"), None).is_ok()
);
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("icon-spec"), None).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(Some("image"), None).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(None, None).is_ok());
assert!(
ensure_editor_image_edit_asset_kind_allowed(
Some("icon"),
Some(&json!({ "source": GAME_CREATOR_RESOURCE_EDITOR_SOURCE })),
)
.is_ok()
);
let layers = json!([
{
@@ -18809,7 +18880,7 @@ mod tests {
let icon_kind =
resolve_editor_image_edit_target_layer_asset_kind(&layers, &resources, "layer-icon")
.expect("icon layer should exist");
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind).is_err());
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_kind, None).is_err());
let icon_spec_kind = resolve_editor_image_edit_target_layer_asset_kind(
&layers,
&resources,
@@ -18817,7 +18888,7 @@ mod tests {
)
.expect("icon spec layer should exist");
assert_eq!(icon_spec_kind, Some("icon-spec"));
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind).is_ok());
assert!(ensure_editor_image_edit_asset_kind_allowed(icon_spec_kind, None).is_ok());
assert!(
resolve_editor_image_edit_target_layer_asset_kind(&layers, &[], "missing").is_err()
);
@@ -1,7 +1,7 @@
use std::future::Future;
use axum::Extension;
use axum::http::StatusCode;
use axum::http::{HeaderMap, StatusCode};
use axum::{
Json,
extract::{State, rejection::JsonRejection},
@@ -34,9 +34,9 @@ use crate::{
EditorGenerationOperationContext, PersistEditorGeneratedAssetInput,
build_editor_canvas_generated_layer_item, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record,
normalize_optional_string, persist_editor_generation_result_atomically,
preflight_editor_billable_generation_target, prepare_editor_generated_asset,
sanitize_editor_client_generation_inputs,
normalize_optional_string, optional_editor_idempotency_key,
persist_editor_generation_result_atomically, preflight_editor_billable_generation_target,
prepare_editor_generated_asset, sanitize_editor_client_generation_inputs,
},
http_error::AppError,
request_context::RequestContext,
@@ -224,9 +224,12 @@ pub async fn generate_editor_sound_effect(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<assets::EditorSoundEffectGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let idempotency_key = optional_editor_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let owner_user_id = authenticated.claims().user_id().to_string();
if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_sound_effect_generation_for_owner(
@@ -234,7 +237,7 @@ pub async fn generate_editor_sound_effect(
&request_context,
owner_user_id.as_str(),
payload,
None,
idempotency_key,
)
.await?;
return Ok(json_success_body(
@@ -823,9 +826,12 @@ pub async fn generate_editor_background_music(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<assets::EditorBackgroundMusicGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, Response> {
let Json(payload) = parse_json_payload(&request_context, payload)?;
let idempotency_key = optional_editor_idempotency_key(&headers)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let payload = CanonicalEditorBackgroundMusicSubmissionPayload::new(payload)
.map_err(|error| error.into_response_with_context(Some(&request_context)))?;
let owner_user_id = authenticated.claims().user_id().to_string();
@@ -835,6 +841,7 @@ pub async fn generate_editor_background_music(
&request_context,
owner_user_id.as_str(),
payload,
idempotency_key,
)
.await?;
return Ok(json_success_body(
@@ -915,6 +922,7 @@ async fn enqueue_logged_in_editor_background_music_generation_for_owner(
request_context: &RequestContext,
owner_user_id: &str,
payload: CanonicalEditorBackgroundMusicSubmissionPayload,
external_idempotency_key: Option<&str>,
) -> Result<ExternalGenerationJobRecord, Response> {
let pricing = load_editor_background_music_queue_pricing(state, request_context).await?;
let prepared = payload
@@ -925,7 +933,7 @@ async fn enqueue_logged_in_editor_background_music_generation_for_owner(
request_context,
owner_user_id,
prepared,
None,
external_idempotency_key,
)
.await
}
@@ -640,6 +640,8 @@ pub struct GameIterationVersion {
pub resource_bindings: Vec<GameIterationVersionResourceBinding>,
pub created_reason: GameIterationVersionCreatedReason,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit_prompt: Option<String>,
}
fn validate_iteration_version_id(value: &str, label: &str, max_chars: usize) -> Result<(), String> {
@@ -677,6 +679,19 @@ pub fn validate_game_iteration_versions(versions: &[GameIterationVersion]) -> Re
version.version_id
));
}
if let Some(edit_prompt) = version.edit_prompt.as_deref() {
if edit_prompt.trim().is_empty()
|| edit_prompt.chars().count() > 32_000
|| edit_prompt.chars().any(|character| {
character.is_control() && !matches!(character, '\n' | '\r' | '\t')
})
{
return Err(format!(
"项目版本 {} 的 editPrompt 必须在 1..=32000 字符内且不能包含非法控制字符",
version.version_id
));
}
}
if index == 0 {
if version.parent_version_id.is_some()
@@ -1749,6 +1764,7 @@ mod tests {
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 456,
edit_prompt: None,
});
manifest.assets.push(GameCreationAppAssetManifestEntry {
id: "asset-player".to_string(),
@@ -1806,6 +1822,7 @@ mod tests {
}],
created_reason: GameIterationVersionCreatedReason::Initial,
created_at: 100,
edit_prompt: None,
};
let child = GameIterationVersion {
version_id: "version-child".to_string(),
@@ -1814,6 +1831,7 @@ mod tests {
resource_bindings: Vec::new(),
created_reason: GameIterationVersionCreatedReason::AgentRevision,
created_at: 101,
edit_prompt: Some("继续优化这一版".to_string()),
};
validate_game_iteration_versions(&[root.clone(), child.clone()])