修复资源画板生成事务与快速编辑边界
Project CI / Repository checks (pull_request) Failing after 9s
Project CI / Backend tests (pull_request) Failing after 9s
Project CI / Frontend tests (pull_request) Successful in 4m14s
Project CI / Native shell tests (pull_request) Successful in 16m17s

生成失败时持久化失败或对账状态,避免遗留 running 任务

增加候选落盘与 archive-pending 可恢复事务阶段

收紧 Host Port 必选方法并统一 unsupported-capability 合同

钳制快速编辑卡 viewport 定位并补齐回归测试与技术合同

修正认证恢复和候选生成测试断言
This commit is contained in:
2026-08-23 16:21:48 +08:00
parent 9cc3f4ce5c
commit ba105767ae
6 changed files with 323 additions and 40 deletions
@@ -184,6 +184,7 @@ enum GenerationLedgerPhase {
CandidateReady,
AssetDurableCommitted,
Failed,
ArchivePending,
Archived,
ReconciliationRequired,
}
@@ -201,6 +202,7 @@ impl GenerationLedgerPhase {
Self::CandidateReady => "candidate-ready",
Self::AssetDurableCommitted => "asset-durable-committed",
Self::Failed => "failed",
Self::ArchivePending => "archive-pending",
Self::Archived => "archived",
Self::ReconciliationRequired => "reconciliation-required",
}
@@ -855,6 +857,7 @@ fn public_phase(phase: &GenerationLedgerPhase) -> Option<AssetCanvasGenerationSt
Some(AssetCanvasGenerationStatus::AssetDurableCommitted)
}
GenerationLedgerPhase::Failed => Some(AssetCanvasGenerationStatus::Failed),
GenerationLedgerPhase::ArchivePending => None,
GenerationLedgerPhase::Archived => None,
GenerationLedgerPhase::ReconciliationRequired => {
Some(AssetCanvasGenerationStatus::ReconciliationRequired)
@@ -979,9 +982,15 @@ fn validate_generation_ledger(ledger: &AssetCanvasGenerationLedger) -> Result<()
}
}
match (&ledger.phase, ledger.archived_at) {
(GenerationLedgerPhase::Archived, Some(archived_at)) => {
(
GenerationLedgerPhase::ArchivePending | GenerationLedgerPhase::Archived,
Some(archived_at),
) => {
validate_safe_revision(archived_at, "archivedAt")?;
}
(GenerationLedgerPhase::ArchivePending, None) => {
return Err("待归档的素材画布生成账本缺少 archivedAt".to_string());
}
(GenerationLedgerPhase::Archived, None) => {
return Err("已归档的素材画布生成账本缺少 archivedAt".to_string());
}
@@ -2948,6 +2957,35 @@ fn preserve_submit_reconciliation(
))
}
fn finish_archive_pending_at(
root: &Path,
ledger: &mut AssetCanvasGenerationLedger,
) -> Result<AssetCanvasDraft, String> {
if ledger.phase != GenerationLedgerPhase::ArchivePending {
return Err("素材画布生成账本不在待归档阶段".to_string());
}
let _draft_guard = acquire_asset_canvas_draft_lock(root)?;
let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)?
.ok_or_else(|| "待归档生成所属素材画布草稿不存在".to_string())?;
if let Some(index) = draft
.generations
.iter()
.position(|record| record.generation_id == ledger.generation_id)
{
draft.generations.remove(index);
draft.revision = draft
.revision
.checked_add(1)
.ok_or_else(|| "草稿 revision 已达到上限".to_string())?;
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
}
ledger.phase = GenerationLedgerPhase::Archived;
write_generation_ledger(root, ledger)?;
Ok(draft)
}
async fn reconcile_generation(
root: &Path,
mut ledger: AssetCanvasGenerationLedger,
@@ -2965,6 +3003,10 @@ async fn reconcile_generation(
if ledger.phase == GenerationLedgerPhase::Archived {
return Err("素材画布失败生成已归档".to_string());
}
if ledger.phase == GenerationLedgerPhase::ArchivePending {
finish_archive_pending_at(root, &mut ledger)?;
return Err("素材画布失败生成已归档".to_string());
}
migrate_retryable_credential_failure(root, &mut ledger)?;
if ledger.phase == GenerationLedgerPhase::Failed {
return Err(sanitized_generation_error(
@@ -3039,8 +3081,7 @@ async fn reconcile_generation(
(false, "platform-service-configuration")
};
if code == "authentication-required" {
ledger.error_code = Some(code.to_string());
write_generation_ledger(root, &mut ledger)?;
mark_generation_error(root, &mut ledger, true, code, emit)?;
} else {
mark_generation_error(root, &mut ledger, false, code, emit)?;
}
@@ -3055,8 +3096,7 @@ async fn reconcile_generation(
{
let code = error.code();
if code == "authentication-required" {
ledger.error_code = Some(code.to_string());
write_generation_ledger(root, &mut ledger)?;
mark_generation_error(root, &mut ledger, true, code, emit)?;
} else {
mark_generation_error(root, &mut ledger, false, code, emit)?;
}
@@ -3339,13 +3379,13 @@ async fn reconcile_generation(
"candidate-reconciliation-required",
));
}
let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?;
set_private_phase(
root,
&mut ledger,
GenerationLedgerPhase::CandidateReady,
None,
)?;
let (generation, draft, image) = persist_generation_candidate_at(root, &mut ledger)?;
emit(generation_progress_event(
&ledger,
public_phase_name(&generation.phase),
@@ -3613,11 +3653,23 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
draft,
});
}
if draft.revision != input.expected_draft_revision {
return Err("draft-revision-conflict".to_string());
if ledger.phase == GenerationLedgerPhase::ArchivePending && generation_index.is_none() {
ledger.phase = GenerationLedgerPhase::Archived;
write_generation_ledger(root, &mut ledger)?;
return Ok(ArchiveAssetCanvasGenerationResult {
generation_id: ledger.generation_id,
phase: GenerationLedgerPhase::Archived.as_str().to_string(),
archived_at: ledger
.archived_at
.ok_or_else(|| "待归档的素材画布生成账本缺少 archivedAt".to_string())?,
draft,
});
}
let generation_index =
generation_index.ok_or_else(|| "待归档的失败生成不在当前素材画布草稿中".to_string())?;
if draft.revision != input.expected_draft_revision {
return Err("draft-revision-conflict".to_string());
}
let public_generation = &draft.generations[generation_index];
if public_generation.intent_id != ledger.intent_id {
return Err("待归档的素材画布公开生成记录身份无效".to_string());
@@ -3627,10 +3679,11 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
if public_generation.phase != AssetCanvasGenerationStatus::Failed {
return Err("只有公开状态明确失败的素材画布生成可以归档".to_string());
}
ledger.phase = GenerationLedgerPhase::Archived;
ledger.phase = GenerationLedgerPhase::ArchivePending;
ledger.archived_at = Some(asset_canvas_now());
write_generation_ledger(root, &mut ledger)?;
}
GenerationLedgerPhase::ArchivePending => {}
GenerationLedgerPhase::Archived => {}
GenerationLedgerPhase::ReconciliationRequired => {
return Err("reconciliation-required: 结果未知的素材画布生成不能归档".to_string());
@@ -3649,6 +3702,10 @@ pub(crate) async fn archive_failed_asset_canvas_generation_at(
validate_safe_revision(draft.revision, "草稿 revision")?;
draft.updated_at = asset_canvas_now();
write_asset_canvas_draft_locked(root, &draft)?;
if ledger.phase == GenerationLedgerPhase::ArchivePending {
ledger.phase = GenerationLedgerPhase::Archived;
write_generation_ledger(root, &mut ledger)?;
}
Ok(ArchiveAssetCanvasGenerationResult {
generation_id: ledger.generation_id,
phase: ledger.phase.as_str().to_string(),
@@ -3722,12 +3779,19 @@ pub(crate) async fn recover_asset_canvas_generations_at(
}
let mut recoverable_generation_ids = Vec::new();
for generation_id in generation_ids {
let Some(ledger) = read_generation_ledger(root, &generation_id)? else {
let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else {
continue;
};
if ledger.project_id == input.expected_project_id
&& ledger.draft_id == input.draft_id
&& ledger.phase != GenerationLedgerPhase::AssetDurableCommitted
if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id {
continue;
}
if ledger.phase == GenerationLedgerPhase::ArchivePending {
let _guard =
generation_singleflight_lock(&input.expected_project_id, &generation_id).await;
let _ = finish_archive_pending_at(root, &mut ledger);
continue;
}
if ledger.phase != GenerationLedgerPhase::AssetDurableCommitted
&& (ledger.phase != GenerationLedgerPhase::Failed
|| retryable_credential_failure_phase(&ledger).is_some())
{
@@ -4989,6 +5053,60 @@ mod tests {
.generations
.iter()
.any(|record| record.generation_id == input.generation_id));
let mut pending = archived_ledger.clone();
pending.phase = GenerationLedgerPhase::ArchivePending;
pending.archived_at = Some(archived.archived_at);
let mut pending_draft = archived.draft.clone();
pending_draft.generations.push(AssetCanvasGenerationRecord {
generation_id: input.generation_id.clone(),
intent_id: input.intent_id.clone(),
phase: AssetCanvasGenerationStatus::Failed,
reference_resource_ids: Vec::new(),
output_asset_id: None,
source_layer_id: None,
placeholder: None,
error_code: Some("generation-rejected".to_string()),
created_at: 1,
updated_at: 1,
idempotency_key: None,
status: None,
prompt: None,
operation_id: None,
output_media_ids: Vec::new(),
});
pending_draft.revision += 1;
write_asset_canvas_draft_locked(directory.path(), &pending_draft)
.expect("write pending archive draft");
write_generation_ledger(directory.path(), &mut pending)
.expect("write pending archive ledger");
let pending_recovery = recover_asset_canvas_generations_at(
directory.path(),
&RecoverAssetCanvasGenerationsInput {
project_path: directory.path().to_string_lossy().into_owned(),
expected_project_id: project_id.to_string(),
draft_id: draft.draft_id.clone(),
},
|_| {},
)
.await
.expect("recover pending archive");
assert!(pending_recovery.result.resumed_generation_ids.is_empty());
assert_eq!(
read_generation_ledger(directory.path(), &input.generation_id)
.expect("read recovered pending archive ledger")
.expect("recovered pending archive ledger exists")
.phase,
GenerationLedgerPhase::Archived
);
assert!(
!read_asset_canvas_draft_locked(directory.path(), project_id, &draft.draft_id)
.expect("read recovered pending archive draft")
.expect("recovered pending archive draft exists")
.generations
.iter()
.any(|record| record.generation_id == input.generation_id)
);
}
#[tokio::test]
@@ -6686,12 +6804,12 @@ mod tests {
resumed.platform_owner_user_id.as_deref(),
Some("original-login-owner")
);
assert_eq!(resumed.phase, GenerationLedgerPhase::AssetDurableCommitted);
assert_eq!(resumed.phase, GenerationLedgerPhase::CandidateReady);
assert_eq!(resumed.error_code, None);
assert!(resumed.commit_result.is_some());
assert!(resumed.commit_result.is_none());
let manifest =
current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest");
assert_eq!(manifest.assets.len(), 1);
assert!(manifest.assets.is_empty());
}
#[tokio::test]
@@ -185,6 +185,36 @@ export function generationAspectRatioForOriginalImage(
);
}
export function resolveQuickEditPanelPosition(input: {
anchorX: number;
belowTop: number;
aboveTop: number;
canvasSize: { width: number; height: number };
panelSize: { width: number; height: number };
edgePadding?: number;
}) {
const edgePadding = input.edgePadding ?? 12;
const panelWidth = Math.max(0, input.panelSize.width);
const panelHeight = Math.max(0, input.panelSize.height);
const minCenterX = edgePadding + panelWidth / 2;
const maxCenterX = Math.max(
minCenterX,
input.canvasSize.width - edgePadding - panelWidth / 2,
);
const maxTop = Math.max(
edgePadding,
input.canvasSize.height - edgePadding - panelHeight,
);
const top =
input.belowTop + panelHeight <= input.canvasSize.height - edgePadding
? input.belowTop
: input.aboveTop;
return {
left: Math.min(Math.max(input.anchorX, minCenterX), maxCenterX),
top: Math.min(Math.max(top, edgePadding), maxTop),
};
}
type RuntimeGenerationTask = {
generationId: string;
sourceLayerId: string | null;
@@ -765,7 +795,12 @@ export function AssetCanvasSurface({
const [documentVersion, setDocumentVersion] = useState(0);
const [recoveryReloadToken, setRecoveryReloadToken] = useState(0);
const [canvasSize, setCanvasSize] = useState({ width: 900, height: 640 });
const [quickEditPanelSize, setQuickEditPanelSize] = useState({
width: 0,
height: 0,
});
const viewportElementRef = useRef<HTMLDivElement | null>(null);
const quickEditPanelRef = useRef<HTMLElement | null>(null);
const importInputRef = useRef<HTMLInputElement | null>(null);
const layersRef = useRef(layers);
const viewportRef = useRef(viewport);
@@ -1629,7 +1664,6 @@ export function AssetCanvasSurface({
const handleLocalImport = useCallback(async () => {
if (
!host.asset.importLocalImages ||
lifecycleRef.current.kind !== 'canvas.editing' ||
backgroundInteractionLockedRef.current
) {
@@ -2426,10 +2460,49 @@ export function AssetCanvasSurface({
setQuickEditOpen(false);
dragRef.current = null;
setNotice('生成任务已提交,结果会作为新候选加入画布');
const persistGenerationFailure = async (
code: string,
message: string,
) => {
const currentDraft = draftRef.current;
if (!currentDraft) return false;
const phase =
code === 'reconciliation-required'
? ('reconciliation-required' as const)
: ('failed' as const);
const failedDraft = {
...currentDraft,
status: 'editing' as const,
generations: currentDraft.generations.map((record) =>
record.generationId === identity.generationId
? {
...record,
phase,
errorCode: code,
updatedAt: Date.now(),
}
: record,
),
};
draftRef.current = failedDraft;
setDraft(failedDraft);
const persisted = await persistDraft();
if (!persisted) {
setNotice(`${message};失败状态保存未完成,请重新打开后对账`);
return false;
}
return true;
};
const task = (async () => {
if (needsDraftPersist) {
const persisted = await persistDraft();
if (!persisted) return;
if (!persisted) {
await persistGenerationFailure(
'draft-save-failed',
'生成任务占位保存失败',
);
return;
}
}
const currentDraft = draftRef.current;
if (
@@ -2488,6 +2561,12 @@ export function AssetCanvasSurface({
}
if (result.status !== 'ok') {
const code = result.status === 'failed' ? result.code : result.status;
await persistGenerationFailure(
code,
result.status === 'failed'
? result.message
: '图片生成发生 revision 冲突',
);
setGenerationTasks((current) =>
current.map((generationTask) =>
generationTask.generationId === identity.generationId
@@ -2523,11 +2602,14 @@ export function AssetCanvasSurface({
persistedDocumentVersionRef.current = documentVersionRef.current;
setLifecycle({ kind: 'canvas.editing', dirty: false });
setNotice('候选图片已加入画布,请选择满意结果设为最终图');
})().catch((error: unknown) => {
})().catch(async (error: unknown) => {
if (
epoch === epochRef.current &&
focusEpoch === generationFocusEpochRef.current
) {
const message =
error instanceof Error ? error.message : String(error);
await persistGenerationFailure('canvas-generation-failed', message);
setGenerationTasks((current) =>
current.map((generationTask) =>
generationTask.generationId === identity.generationId
@@ -2541,7 +2623,7 @@ export function AssetCanvasSurface({
),
);
setLifecycle({ kind: 'canvas.editing', dirty: false });
setNotice(error instanceof Error ? error.message : String(error));
setNotice(message);
}
});
void task.finally(() => {
@@ -2573,7 +2655,6 @@ export function AssetCanvasSurface({
const archiveFailedGeneration = useCallback(
async (generationId: string) => {
if (
!host.generation.archiveFailedGeneration ||
archivingGenerationIds.includes(generationId) ||
!generationTasks.some(
(task) =>
@@ -2731,18 +2812,55 @@ export function AssetCanvasSurface({
quickEditOpen && quickEditSourceLayerId
? (layers.find((layer) => layer.id === quickEditSourceLayerId) ?? null)
: null;
useLayoutEffect(() => {
const element = quickEditPanelRef.current;
if (!element || !quickEditSourceLayer) return undefined;
const measure = () =>
setQuickEditPanelSize({
width: element.offsetWidth,
height: element.offsetHeight,
});
measure();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', measure);
return () => window.removeEventListener('resize', measure);
}
const observer = new ResizeObserver(measure);
observer.observe(element);
return () => observer.disconnect();
}, [quickEditSourceLayer, quickEditSourceLayerId, quickEditOpen]);
const quickEditPanelStyle: CSSProperties | null = quickEditSourceLayer
? {
left:
? (() => {
const edgePadding = 12;
const panelWidth =
quickEditPanelSize.width ||
Math.min(672, canvasSize.width - edgePadding * 2);
const panelHeight =
quickEditPanelSize.height ||
Math.min(352, canvasSize.height - edgePadding * 2);
const anchorX =
viewport.x +
(quickEditSourceLayer.x + quickEditSourceLayer.width / 2) *
viewport.scale,
top:
viewport.scale;
const belowTop =
viewport.y +
(quickEditSourceLayer.y + quickEditSourceLayer.height) *
viewport.scale +
12,
}
edgePadding;
const aboveTop =
viewport.y +
quickEditSourceLayer.y * viewport.scale -
panelHeight -
edgePadding;
return resolveQuickEditPanelPosition({
anchorX,
belowTop,
aboveTop,
canvasSize,
panelSize: { width: panelWidth, height: panelHeight },
edgePadding,
});
})()
: null;
const currentFinalLayer =
(draft?.lastCommit
@@ -2919,13 +3037,7 @@ export function AssetCanvasSurface({
label="导入图片"
title="导入本地图片"
icon={<Upload size={15} aria-hidden="true" />}
onClick={() => {
if (host.asset.importLocalImages) {
void handleLocalImport();
} else {
importInputRef.current?.click();
}
}}
onClick={() => void handleLocalImport()}
disabled={backgroundInteractionLocked}
>
{importPending ? '正在导入…' : '导入'}
@@ -3535,6 +3647,7 @@ export function AssetCanvasSurface({
>
{quickEditSourceLayer && quickEditPanelStyle ? (
<section
ref={quickEditPanelRef}
className="asset-canvas-surface__quick-edit-card"
style={quickEditPanelStyle}
role="region"
@@ -3698,8 +3811,7 @@ export function AssetCanvasSurface({
<span aria-hidden="true" />
<strong>{generationPhaseLabels[task.phase]}</strong>
{task.errorCode ? <small>{task.errorCode}</small> : null}
{task.phase === 'failed' &&
host.generation.archiveFailedGeneration ? (
{task.phase === 'failed' ? (
<CanvasChromeButton
label="删除失败任务"
title="删除失败任务"
@@ -3982,8 +4094,7 @@ export function AssetCanvasSurface({
{Math.round(task.progress)}%
</span>
) : null}
{task.phase === 'failed' &&
host.generation.archiveFailedGeneration ? (
{task.phase === 'failed' ? (
<CanvasChromeButton
label={`删除失败任务 ${task.generationId.slice(0, 8)}`}
title="删除失败任务"
@@ -239,6 +239,7 @@ export function createMockImageCanvasGenerationPort(): ImageCanvasGenerationPort
message: '当前阶段使用明确 mock,不会提交真实 AI 生成请求',
});
return {
archiveFailedGeneration: unsupported,
generateImage: unsupported,
recoverImages: async () => ({
status: 'ok' as const,
@@ -32,6 +32,7 @@ import {
AssetCanvasSurface,
generationAspectRatioForOriginalImage,
type RenderAssetCanvasImage,
resolveQuickEditPanelPosition,
shouldApplyAssetCanvasDraftCandidate,
} from '../src/features/asset-canvas/AssetCanvasSurface';
import {
@@ -822,6 +823,38 @@ afterEach(() => {
});
describe('Tauri 素材创作无限画布独立 Surface', () => {
it('快速编辑卡位置始终钳制在 viewport 四边内并在底部翻转到上方', () => {
const common = {
canvasSize: { width: 320, height: 240 },
panelSize: { width: 220, height: 120 },
edgePadding: 12,
};
expect(
resolveQuickEditPanelPosition({
...common,
anchorX: 4,
belowTop: 180,
aboveTop: 20,
}),
).toEqual({ left: 122, top: 20 });
expect(
resolveQuickEditPanelPosition({
...common,
anchorX: 316,
belowTop: 40,
aboveTop: -100,
}),
).toEqual({ left: 198, top: 40 });
expect(
resolveQuickEditPanelPosition({
...common,
anchorX: 160,
belowTop: 200,
aboveTop: -100,
}),
).toEqual({ left: 160, top: 12 });
});
it('从精修文件名剥离历史提交后缀并保持后端合法名称', () => {
expect(
canonicalAssetBaseName(
@@ -2774,6 +2807,12 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(
within(taskSidebar).getByText(/insufficient-mud-points/),
).toBeTruthy();
await waitFor(() =>
expect(memory.getDraft()?.generations[0]?.phase).toBe('failed'),
);
expect(memory.getDraft()?.generations[0]?.errorCode).toBe(
'insufficient-mud-points',
);
expect(screen.getByText('泥点余额不足,请充值后重试')).toBeTruthy();
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
@@ -2833,6 +2872,12 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
expect(
within(taskSidebar).getByText(/authentication-required/),
).toBeTruthy();
await waitFor(() =>
expect(memory.getDraft()?.generations[0]?.phase).toBe('failed'),
);
expect(memory.getDraft()?.generations[0]?.errorCode).toBe(
'authentication-required',
);
expect(screen.getByText('登录已失效,请重新登录后重试')).toBeTruthy();
expect(screen.queryByRole('alert', { name: '图片生成失败' })).toBeNull();
expect(screen.queryByRole('button', { name: '停止等待并返回' })).toBeNull();
@@ -88,6 +88,14 @@
13. 画布带运行中 generation 重新进入时,在远端恢复完成前已经可选图、平移和继续编辑;后台恢复继续推进原 operation,不重复 POST、不重新扣费。阻断性提交/恢复失败的遮罩覆盖整个 Tauri 窗口。
14. 已登记 refine 资产的候选设为最终图后保持原 `assetId`、原 `source.resourceId` 与新正式 PNG 路径;`revision-installed` 中断后重新加载可前向收敛为 committed。
## PR 176 事务与宿主合同补充
- 生成失败时,公开 generation 记录必须在同一草稿 revision 链路中落为 `failed``reconciliation-required`,不得留下持久化的 `generation-running` 幽灵任务。
- 候选生成必须先幂等落盘候选媒体、候选图层和公开记录,回读成功后才能推进私有 ledger 的 `candidate-ready` 终态;两次写入之间中断时,恢复必须可重放。
- 失败归档使用 `archive-pending` 中间态:先记录归档意图,再删除草稿公开记录,最后发布 `archived`;任一边界中断都必须在重启后收敛且保持幂等。
- `ImageCanvasAssetPort.importLocalImages``ImageCanvasGenerationPort.archiveFailedGeneration` 是冻结合同中的必选方法;不支持的宿主返回结构化 `unsupported-capability`
- 快速编辑卡按自身测量宽高选择上下方位置,并在 viewport 四边保留安全留白;窄屏、滚动、缩放和 resize 后都重新计算。
## 验证命令
```powershell
+2 -2
View File
@@ -200,7 +200,7 @@ export interface ImageCanvasProjectPort {
}
export interface ImageCanvasAssetPort {
importLocalImages?(input: {
importLocalImages(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
viewportSize: { width: number; height: number };
@@ -225,7 +225,7 @@ export interface ImageCanvasAssetPort {
}
export interface ImageCanvasGenerationPort {
archiveFailedGeneration?(input: {
archiveFailedGeneration(input: {
scope: ImageCanvasHostScope;
expectedDraftRevision: number;
generationId: string;