完善画布任务列表与远程抠图接入
新增图片画布任务侧栏页签、滚轮隔离、完成时间、后端计时和运行中状态展示。 限制外部生成任务只显示当前项目,并让任务点击聚焦画布内容和快速编辑源图。 接入外部生成任务列表查询状态过滤、提示词摘要和后端任务映射。 补充 BiRefNet 抠图请求日志、任务耗时提示和相关文档。 补充侧栏、画布聚焦、外部任务契约和 api-server 定向测试。
This commit is contained in:
@@ -108,7 +108,7 @@
|
||||
- 快速编辑面板底部必须显示当前选择的比例 / 尺寸和模型,参数按钮可展开修改且视觉对齐其它可编辑面板;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户额外选择的参考图。
|
||||
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
|
||||
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。
|
||||
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果,完成后用新的 project resource 引用替换当前图层,并在右上角本地任务侧栏展示耗时和状态。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。
|
||||
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果,完成后用新的 project resource 引用替换当前图层,并在右上角本地任务侧栏展示耗时和状态。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板或绿色阴影。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。
|
||||
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
|
||||
- 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。
|
||||
- 快速编辑的额外参考图既进入输入快照,也作为 `/api/editor/images/edits` 的 `referenceImageSrcs` 提交给后端;原图仍只作为 `sourceImageSrc`,不占用额外参考图额度。
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table:
|
||||
|
||||
- `GET /api/runtime/external-generation/queue-overview`:当前账号队列概览,用于兼容旧展示和轻量状态读取。返回 pending、running、未确认终态数量和更新时间。
|
||||
- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务。
|
||||
- `GET /api/runtime/external-generation/jobs?limit=20&includeAcknowledgedTerminal=false`:当前账号正式生成任务列表,用于 `我的` 页签任务列表和完成 / 失败提示。返回每个任务的 job id、kind、source、可展示 label、状态、进度、错误、`priceMudPoints`、`refundLedgerId`、`notificationAcknowledgedAt` 和时间戳。默认不返回已确认的终态任务;需要拆分活跃和完成列表时可追加 `statuses=running,queued` 或 `statuses=completed,failed`,BFF 仍只返回当前账号任务。
|
||||
- `POST /api/runtime/external-generation/jobs/acknowledge`:生成完成 / 失败提示展示后由前端后台调用,BFF 只传当前账号 job ids,后端只确认属于当前账号且已终态的任务。
|
||||
- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ExternalGenerationTaskRecord {
|
||||
sourceModule: string;
|
||||
sourceEntityId: string;
|
||||
requestLabel: string;
|
||||
requestPrompt?: string | null;
|
||||
status: ExternalGenerationJobStatus;
|
||||
phaseLabel: string;
|
||||
phaseDetail: string;
|
||||
|
||||
@@ -1846,13 +1846,26 @@ async fn request_editor_background_removal_image(
|
||||
source_image: OpenAiReferenceImage,
|
||||
) -> Result<EditorBackgroundRemovalImage, AppError> {
|
||||
let url = editor_background_removal_endpoint(state)?;
|
||||
let call_id = format!("background-removal-call-{}", current_utc_micros());
|
||||
let request_started_at = Instant::now();
|
||||
let timeout_ms = state
|
||||
.config
|
||||
.editor_background_removal_request_timeout_ms
|
||||
.max(1);
|
||||
let input_bytes = source_image.bytes.len();
|
||||
let source_file_name = source_image.file_name.clone();
|
||||
let source_mime_type = source_image.mime_type.clone();
|
||||
tracing::info!(
|
||||
%call_id,
|
||||
upstream_url = %url,
|
||||
file_name = %source_file_name,
|
||||
mime_type = %source_mime_type,
|
||||
input_bytes,
|
||||
timeout_ms,
|
||||
"editor_background_removal_request_start"
|
||||
);
|
||||
let http_client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_millis(
|
||||
state
|
||||
.config
|
||||
.editor_background_removal_request_timeout_ms
|
||||
.max(1),
|
||||
))
|
||||
.timeout(std::time::Duration::from_millis(timeout_ms))
|
||||
.build()
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
@@ -1884,13 +1897,29 @@ async fn request_editor_background_removal_image(
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(map_editor_background_removal_error)?;
|
||||
.map_err(|error| {
|
||||
tracing::warn!(
|
||||
%call_id,
|
||||
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||||
timeout = error.is_timeout(),
|
||||
error = %error,
|
||||
"editor_background_removal_request_failed"
|
||||
);
|
||||
map_editor_background_removal_error(error)
|
||||
})?;
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let message = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|error| format!("读取上游错误失败:{error}"));
|
||||
tracing::warn!(
|
||||
%call_id,
|
||||
upstream_status = status.as_u16(),
|
||||
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||||
upstream_message = %message.chars().take(160).collect::<String>(),
|
||||
"editor_background_removal_upstream_rejected"
|
||||
);
|
||||
return Err(
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({
|
||||
"provider": "birefnet",
|
||||
@@ -1907,6 +1936,11 @@ async fn request_editor_background_removal_image(
|
||||
.and_then(normalize_editor_reference_image_mime_type)
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let upstream_elapsed_ms = response
|
||||
.headers()
|
||||
.get("x-birefnet-elapsed-ms")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<u64>().ok());
|
||||
let bytes = read_editor_background_removal_bytes(response).await?;
|
||||
if bytes.is_empty() {
|
||||
return Err(
|
||||
@@ -1922,6 +1956,17 @@ async fn request_editor_background_removal_image(
|
||||
extension: editor_reference_image_extension(mime_type.as_str()).to_string(),
|
||||
mime_type,
|
||||
};
|
||||
tracing::info!(
|
||||
%call_id,
|
||||
status = status.as_u16(),
|
||||
width = decoded.width(),
|
||||
height = decoded.height(),
|
||||
input_bytes,
|
||||
output_bytes = image.bytes.len(),
|
||||
elapsed_ms = request_started_at.elapsed().as_millis() as u64,
|
||||
upstream_elapsed_ms,
|
||||
"editor_background_removal_request_done"
|
||||
);
|
||||
|
||||
Ok(EditorBackgroundRemovalImage {
|
||||
image,
|
||||
|
||||
@@ -5,7 +5,7 @@ use axum::{
|
||||
response::Response,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use serde_json::{Value, json};
|
||||
use shared_contracts::external_generation::{
|
||||
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
|
||||
ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview,
|
||||
@@ -31,6 +31,7 @@ const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation";
|
||||
pub struct ExternalGenerationTaskListQuery {
|
||||
limit: Option<u32>,
|
||||
include_acknowledged_terminal: Option<bool>,
|
||||
statuses: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get_external_generation_queue_overview(
|
||||
@@ -45,6 +46,7 @@ pub async fn get_external_generation_queue_overview(
|
||||
owner_user_id,
|
||||
limit: 1,
|
||||
include_acknowledged_terminal: false,
|
||||
statuses: Vec::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|error| external_generation_error_response(&request_context, error))?;
|
||||
@@ -64,26 +66,33 @@ pub async fn list_external_generation_tasks(
|
||||
Query(query): Query<ExternalGenerationTaskListQuery>,
|
||||
) -> Result<Json<serde_json::Value>, Response> {
|
||||
let owner_user_id = authenticated.claims().user_id().to_string();
|
||||
let requested_limit = query.limit.unwrap_or(50).clamp(1, 100);
|
||||
let status_filter = parse_external_generation_status_filter(query.statuses.as_deref());
|
||||
let statuses = external_generation_status_filter_input(&status_filter);
|
||||
let list = state
|
||||
.spacetime_client()
|
||||
.list_external_generation_jobs(ExternalGenerationJobListRecordInput {
|
||||
owner_user_id,
|
||||
limit: query.limit.unwrap_or(50).clamp(1, 100),
|
||||
limit: requested_limit,
|
||||
include_acknowledged_terminal: query.include_acknowledged_terminal.unwrap_or(false),
|
||||
statuses,
|
||||
})
|
||||
.await
|
||||
.map_err(|error| external_generation_error_response(&request_context, error))?;
|
||||
let overview = map_external_generation_queue_overview(&list);
|
||||
let mut tasks: Vec<_> = list
|
||||
.jobs
|
||||
.into_iter()
|
||||
.map(map_external_generation_task_record)
|
||||
.filter(|task| {
|
||||
status_filter.is_empty() || status_filter.iter().any(|status| status == &task.status)
|
||||
})
|
||||
.collect();
|
||||
tasks.truncate(requested_limit as usize);
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
ExternalGenerationTaskListResponse {
|
||||
overview: map_external_generation_queue_overview(&list),
|
||||
tasks: list
|
||||
.jobs
|
||||
.into_iter()
|
||||
.map(map_external_generation_task_record)
|
||||
.collect(),
|
||||
},
|
||||
ExternalGenerationTaskListResponse { overview, tasks },
|
||||
))
|
||||
}
|
||||
|
||||
@@ -176,12 +185,14 @@ fn map_external_generation_task_record(
|
||||
job: ExternalGenerationJobRecord,
|
||||
) -> ExternalGenerationTaskRecord {
|
||||
let status_record = map_external_generation_job_status(job.clone());
|
||||
let request_prompt = extract_external_generation_request_prompt(&job.request_payload_json);
|
||||
ExternalGenerationTaskRecord {
|
||||
job_id: job.job_id,
|
||||
job_kind: job.job_kind,
|
||||
source_module: job.source_module,
|
||||
source_entity_id: job.source_entity_id,
|
||||
request_label: job.request_label,
|
||||
request_prompt,
|
||||
status: status_record.status,
|
||||
phase_label: status_record.phase_label,
|
||||
phase_detail: status_record.phase_detail,
|
||||
@@ -198,6 +209,90 @@ fn map_external_generation_task_record(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_external_generation_request_prompt(request_payload_json: &str) -> Option<String> {
|
||||
let payload: Value = serde_json::from_str(request_payload_json).ok()?;
|
||||
for key in ["prompt", "promptText", "spritesheetLabel"] {
|
||||
if let Some(prompt) = payload
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_external_generation_prompt_text)
|
||||
{
|
||||
return Some(prompt);
|
||||
}
|
||||
}
|
||||
if let Some(prompt) = payload
|
||||
.get("iconDescriptions")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.filter_map(normalize_external_generation_prompt_text)
|
||||
.collect::<Vec<_>>()
|
||||
.join("、")
|
||||
})
|
||||
.and_then(|value| normalize_external_generation_prompt_text(&value))
|
||||
{
|
||||
return Some(prompt);
|
||||
}
|
||||
payload
|
||||
.get("generationInputs")
|
||||
.and_then(|value| value.get("fields"))
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|fields| {
|
||||
fields.iter().find_map(|field| {
|
||||
let title = field.get("title").and_then(Value::as_str)?.trim();
|
||||
if !matches!(title, "prompt" | "gpt_description_prompt") {
|
||||
return None;
|
||||
}
|
||||
field
|
||||
.get("value")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(normalize_external_generation_prompt_text)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_external_generation_prompt_text(value: &str) -> Option<String> {
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_external_generation_status_filter(
|
||||
statuses: Option<&str>,
|
||||
) -> Vec<ExternalGenerationJobStatus> {
|
||||
statuses
|
||||
.unwrap_or_default()
|
||||
.split(',')
|
||||
.filter_map(|status| match status.trim() {
|
||||
"queued" => Some(ExternalGenerationJobStatus::Queued),
|
||||
"running" => Some(ExternalGenerationJobStatus::Running),
|
||||
"completed" => Some(ExternalGenerationJobStatus::Completed),
|
||||
"failed" => Some(ExternalGenerationJobStatus::Failed),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn external_generation_status_filter_input(
|
||||
statuses: &[ExternalGenerationJobStatus],
|
||||
) -> Vec<String> {
|
||||
statuses
|
||||
.iter()
|
||||
.map(|status| match status {
|
||||
ExternalGenerationJobStatus::Queued => "queued",
|
||||
ExternalGenerationJobStatus::Running => "running",
|
||||
ExternalGenerationJobStatus::Completed => "completed",
|
||||
ExternalGenerationJobStatus::Failed => "failed",
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn current_utc_micros() -> i64 {
|
||||
shared_kernel::offset_datetime_to_unix_micros(time::OffsetDateTime::now_utc())
|
||||
}
|
||||
@@ -213,3 +308,37 @@ fn external_generation_error_response(
|
||||
}))
|
||||
.into_response_with_context(Some(request_context))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_external_generation_status_filter() {
|
||||
assert_eq!(
|
||||
parse_external_generation_status_filter(Some("running,queued,unknown")),
|
||||
vec![
|
||||
ExternalGenerationJobStatus::Running,
|
||||
ExternalGenerationJobStatus::Queued
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_external_generation_request_prompt_from_payload() {
|
||||
assert_eq!(
|
||||
extract_external_generation_request_prompt(
|
||||
r#"{"prompt":" 发光主视觉 ","sourceImageSrc":"data:image/png;base64,secret"}"#,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("发光主视觉"),
|
||||
);
|
||||
assert_eq!(
|
||||
extract_external_generation_request_prompt(
|
||||
r#"{"iconDescriptions":["返回按钮"," 设置按钮 "],"referenceImageSrc":"data:image/png;base64,secret"}"#,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("返回按钮、设置按钮"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,7 @@ pub struct ExternalGenerationTaskRecord {
|
||||
pub source_module: String,
|
||||
pub source_entity_id: String,
|
||||
pub request_label: String,
|
||||
pub request_prompt: Option<String>,
|
||||
pub status: ExternalGenerationJobStatus,
|
||||
pub phase_label: String,
|
||||
pub phase_detail: String,
|
||||
|
||||
@@ -83,6 +83,7 @@ impl From<ExternalGenerationJobListRecordInput> for ExternalGenerationJobListInp
|
||||
owner_user_id: input.owner_user_id,
|
||||
limit: input.limit,
|
||||
include_acknowledged_terminal: input.include_acknowledged_terminal,
|
||||
statuses: input.statuses,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -270,6 +271,7 @@ pub struct ExternalGenerationJobListRecordInput {
|
||||
pub owner_user_id: String,
|
||||
pub limit: u32,
|
||||
pub include_acknowledged_terminal: bool,
|
||||
pub statuses: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ pub struct ExternalGenerationJobListInput {
|
||||
pub owner_user_id: String,
|
||||
pub limit: u32,
|
||||
pub include_acknowledged_terminal: bool,
|
||||
pub statuses: Vec<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for ExternalGenerationJobListInput {
|
||||
|
||||
@@ -153,6 +153,7 @@ pub struct ExternalGenerationJobListInput {
|
||||
pub owner_user_id: String,
|
||||
pub limit: u32,
|
||||
pub include_acknowledged_terminal: bool,
|
||||
pub statuses: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
|
||||
@@ -568,6 +569,7 @@ fn list_external_generation_jobs_tx(
|
||||
let now_micros = ctx.timestamp.to_micros_since_unix_epoch();
|
||||
let (pending_count, running_count, unacknowledged_terminal_count) =
|
||||
count_external_generation_jobs_for_owner(ctx, &owner_user_id);
|
||||
let status_filter = normalize_external_generation_job_status_filter(&input.statuses);
|
||||
let mut rows = Vec::new();
|
||||
|
||||
for row in ctx
|
||||
@@ -579,7 +581,9 @@ fn list_external_generation_jobs_tx(
|
||||
let should_include = input.include_acknowledged_terminal
|
||||
|| !is_external_generation_job_terminal(&row)
|
||||
|| row.notification_acknowledged_at.is_none();
|
||||
if should_include {
|
||||
let should_include_status =
|
||||
status_filter.is_empty() || status_filter.iter().any(|status| row.status == *status);
|
||||
if should_include && should_include_status {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
@@ -966,6 +970,22 @@ fn external_generation_job_sort_bucket(row: &ExternalGenerationJob) -> u8 {
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_external_generation_job_status_filter(statuses: &[String]) -> Vec<&'static str> {
|
||||
statuses
|
||||
.iter()
|
||||
.filter_map(|status| match status.trim() {
|
||||
"queued" | EXTERNAL_GENERATION_STATUS_PENDING => {
|
||||
Some(EXTERNAL_GENERATION_STATUS_PENDING)
|
||||
}
|
||||
EXTERNAL_GENERATION_STATUS_RUNNING => Some(EXTERNAL_GENERATION_STATUS_RUNNING),
|
||||
EXTERNAL_GENERATION_STATUS_COMPLETED => Some(EXTERNAL_GENERATION_STATUS_COMPLETED),
|
||||
EXTERNAL_GENERATION_STATUS_FAILED => Some(EXTERNAL_GENERATION_STATUS_FAILED),
|
||||
EXTERNAL_GENERATION_STATUS_CANCELLED => Some(EXTERNAL_GENERATION_STATUS_CANCELLED),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn record_external_generation_claimable_age(
|
||||
stats: &mut ExternalGenerationQueueStatsSnapshot,
|
||||
row: &ExternalGenerationJob,
|
||||
@@ -1232,6 +1252,21 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_list_status_filter_accepts_public_queued_alias() {
|
||||
assert_eq!(
|
||||
normalize_external_generation_job_status_filter(&[
|
||||
"queued".to_string(),
|
||||
"running".to_string(),
|
||||
"unknown".to_string(),
|
||||
]),
|
||||
vec![
|
||||
EXTERNAL_GENERATION_STATUS_PENDING,
|
||||
EXTERNAL_GENERATION_STATUS_RUNNING,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_ownership_requires_matching_trimmed_worker_id() {
|
||||
let mut row = external_generation_job_fixture(EXTERNAL_GENERATION_STATUS_RUNNING);
|
||||
|
||||
@@ -116,6 +116,7 @@ function createTopbarProps(): ImageCanvasTopbarViewProps {
|
||||
|
||||
function createStageProps(): ImageCanvasStageViewProps {
|
||||
return {
|
||||
projectId: 'project-1',
|
||||
canvasViewportRef: createRef<HTMLDivElement>(),
|
||||
specToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
musicToolWrapRef: createRef<HTMLSpanElement>(),
|
||||
@@ -175,6 +176,9 @@ function createStageProps(): ImageCanvasStageViewProps {
|
||||
onUpdateLayerAssetKind: vi.fn(),
|
||||
onGenerationFramePointerDown: vi.fn(),
|
||||
onActivateGenerationDialog: vi.fn(),
|
||||
onFocusGenerationDialog: vi.fn(),
|
||||
onFocusTaskLayer: vi.fn(),
|
||||
onFocusExternalTask: vi.fn(),
|
||||
onToggleTaskSidebar: vi.fn(),
|
||||
onCropExpandHandlePointerDown: vi.fn(),
|
||||
onOpenQuickEditPanel: vi.fn(),
|
||||
|
||||
@@ -228,6 +228,8 @@ export type GenerateDialogState = {
|
||||
aspectRatio?: string;
|
||||
imageSize?: string;
|
||||
errorMessage?: string;
|
||||
generationStartedAt?: number;
|
||||
generationFinishedAt?: number;
|
||||
placeholder?: {
|
||||
x: number;
|
||||
y: number;
|
||||
@@ -260,6 +262,7 @@ export type CanvasTaskListItem = {
|
||||
elapsedMs?: number;
|
||||
provider?: string;
|
||||
errorMessage?: string;
|
||||
focusLayerId?: string;
|
||||
};
|
||||
|
||||
export type ImageContextMenuState = {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type EditorProjectSnapshot,
|
||||
loadEditorGenerationPricing,
|
||||
} from '../../services/image-editor/editorProjectClient';
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { shouldShowRechargeEntry } from '../../services/payment/paymentPlatform';
|
||||
import { getPlatformProfileDashboard } from '../../services/platform-entry/platformProfileClient';
|
||||
import { useAuthUi } from '../auth/AuthUiContext';
|
||||
@@ -50,7 +51,11 @@ import type {
|
||||
import { getEditorUploadAccept, isImageFile } from './ImageCanvasFileModel';
|
||||
import { createLayerGenerationDialogDraft } from './ImageCanvasGenerationDialogModel';
|
||||
import { applyEditorGenerationPricingConfig } from './ImageCanvasGenerationModel';
|
||||
import { isCanvasGenerationComposerVisible } from './ImageCanvasOverlayModel';
|
||||
import {
|
||||
isCanvasGenerationComposerVisible,
|
||||
resolveQuickEditFocusViewport,
|
||||
} from './ImageCanvasOverlayModel';
|
||||
import { fitViewportToBounds } from './ImageCanvasInteractionModel';
|
||||
import {
|
||||
getCanvasGenerationSelectionId,
|
||||
getSelectedGenerationDialogIds,
|
||||
@@ -78,6 +83,12 @@ import {
|
||||
useImageCanvasViewportControls,
|
||||
} from './useImageCanvasViewportControls';
|
||||
|
||||
const TASK_FOCUS_HORIZONTAL_INSET = 28;
|
||||
const TASK_FOCUS_TOP_INSET = 82;
|
||||
const TASK_FOCUS_BOTTOM_INSET = 90;
|
||||
const TASK_FOCUS_DIALOG_BOTTOM_INSET = 280;
|
||||
const TASK_FOCUS_PADDING = 18;
|
||||
|
||||
function resolveLayerProjectResourceImageSrc(layer: CanvasLayer) {
|
||||
const objectKey = layer.objectKey?.trim();
|
||||
if (objectKey) {
|
||||
@@ -578,6 +589,108 @@ export function ImageCanvasEditorView() {
|
||||
},
|
||||
[setGenerateDialog],
|
||||
);
|
||||
const focusCanvasBounds = useCallback(
|
||||
(
|
||||
bounds: { x: number; y: number; width: number; height: number },
|
||||
options: { reserveDialogSpace?: boolean } = {},
|
||||
) => {
|
||||
const nextViewport = fitViewportToBounds({
|
||||
bounds,
|
||||
canvasSize,
|
||||
insets: {
|
||||
left: TASK_FOCUS_HORIZONTAL_INSET,
|
||||
right: TASK_FOCUS_HORIZONTAL_INSET,
|
||||
top: TASK_FOCUS_TOP_INSET,
|
||||
bottom:
|
||||
TASK_FOCUS_BOTTOM_INSET +
|
||||
(options.reserveDialogSpace ? TASK_FOCUS_DIALOG_BOTTOM_INSET : 0),
|
||||
},
|
||||
padding: TASK_FOCUS_PADDING,
|
||||
});
|
||||
setViewport(nextViewport);
|
||||
},
|
||||
[canvasSize, setViewport],
|
||||
);
|
||||
const focusCanvasLayerById = useCallback(
|
||||
(layerId: string) => {
|
||||
const targetLayer = layersRef.current.find((layer) => layer.id === layerId);
|
||||
if (!targetLayer) {
|
||||
return;
|
||||
}
|
||||
selectSingleLayer(targetLayer.id);
|
||||
setActiveTool('select');
|
||||
setActiveSidebarPanel('layers');
|
||||
focusCanvasBounds(targetLayer);
|
||||
},
|
||||
[focusCanvasBounds, selectSingleLayer, setActiveSidebarPanel, setActiveTool],
|
||||
);
|
||||
const focusCanvasGenerationDialog = useCallback(
|
||||
(dialog: CanvasGenerationDialogState) => {
|
||||
if (
|
||||
dialog.mode === 'quick-edit' &&
|
||||
dialog.sourceLayerId &&
|
||||
!dialog.generatedLayerId
|
||||
) {
|
||||
const sourceLayer = layersRef.current.find(
|
||||
(layer) => layer.id === dialog.sourceLayerId,
|
||||
);
|
||||
if (sourceLayer) {
|
||||
setSelectedLayerId(sourceLayer.id);
|
||||
setSelectedLayerIds([sourceLayer.id]);
|
||||
setActiveTool('generate');
|
||||
setActiveSidebarPanel('layers');
|
||||
setViewport(resolveQuickEditFocusViewport({ sourceLayer, canvasSize }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (dialog.generatedLayerId) {
|
||||
focusCanvasLayerById(dialog.generatedLayerId);
|
||||
return;
|
||||
}
|
||||
if (!dialog.placeholder) {
|
||||
return;
|
||||
}
|
||||
setSelectedLayerId(null);
|
||||
setSelectedLayerIds([getCanvasGenerationSelectionId(dialog.id)]);
|
||||
setActiveTool('select');
|
||||
focusCanvasBounds(dialog.placeholder, {
|
||||
reserveDialogSpace: isCanvasGenerationComposerVisible(dialog),
|
||||
});
|
||||
},
|
||||
[
|
||||
canvasSize,
|
||||
focusCanvasBounds,
|
||||
focusCanvasLayerById,
|
||||
setActiveSidebarPanel,
|
||||
setActiveTool,
|
||||
setViewport,
|
||||
],
|
||||
);
|
||||
const focusExternalGenerationTask = useCallback(
|
||||
(task: ExternalGenerationTaskRecord) => {
|
||||
const targetLayer = layersRef.current.find(
|
||||
(layer) =>
|
||||
layer.taskId === task.jobId ||
|
||||
layer.id === task.sourceEntityId ||
|
||||
layer.resourceId === task.sourceEntityId ||
|
||||
layer.sourceResourceId === task.sourceEntityId,
|
||||
);
|
||||
if (targetLayer) {
|
||||
focusCanvasLayerById(targetLayer.id);
|
||||
return;
|
||||
}
|
||||
const targetDialog = canvasGenerationDialogsRef.current.find(
|
||||
(dialog) =>
|
||||
dialog.id === task.sourceEntityId ||
|
||||
dialog.sourceLayerId === task.sourceEntityId ||
|
||||
dialog.generatedLayerId === task.sourceEntityId,
|
||||
);
|
||||
if (targetDialog) {
|
||||
focusCanvasGenerationDialog(targetDialog);
|
||||
}
|
||||
},
|
||||
[focusCanvasGenerationDialog, focusCanvasLayerById],
|
||||
);
|
||||
const persistGeneratedAsset = useCallback(
|
||||
(layer: CanvasLayer) => {
|
||||
if (authUiRef.current && !authUiRef.current.canAccessProtectedData) {
|
||||
@@ -1548,6 +1661,7 @@ export function ImageCanvasEditorView() {
|
||||
onOpenWallet: openRechargeOrRewardCodeModal,
|
||||
};
|
||||
const stageProps = {
|
||||
projectId,
|
||||
canvasViewportRef,
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
@@ -1612,6 +1726,9 @@ export function ImageCanvasEditorView() {
|
||||
onUpdateLayerAssetKind: updateLayerAssetKind,
|
||||
onGenerationFramePointerDown: handleGenerationFramePointerDown,
|
||||
onActivateGenerationDialog: activateCanvasGenerationDialog,
|
||||
onFocusGenerationDialog: focusCanvasGenerationDialog,
|
||||
onFocusTaskLayer: focusCanvasLayerById,
|
||||
onFocusExternalTask: focusExternalGenerationTask,
|
||||
onToggleTaskSidebar: generationSurface.toggleTaskSidebar,
|
||||
onCropExpandHandlePointerDown: generationSurface.startCropExpandFrameResize,
|
||||
onOpenQuickEditPanel: openLayerGenerationDialog,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
createMinimapModel,
|
||||
fitViewportToBounds,
|
||||
fitViewportToLayers,
|
||||
getCanvasDropPoint,
|
||||
getCanvasPointFromClient,
|
||||
@@ -90,6 +91,21 @@ describe('ImageCanvasInteractionModel', () => {
|
||||
).toBe(0.025);
|
||||
});
|
||||
|
||||
it('fits bounds inside reserved viewport insets', () => {
|
||||
const viewport = fitViewportToBounds({
|
||||
bounds: { x: 100, y: 100, width: 400, height: 300 },
|
||||
canvasSize: { width: 900, height: 640 },
|
||||
insets: { top: 80 },
|
||||
padding: 20,
|
||||
});
|
||||
|
||||
expect(viewport).toEqual({
|
||||
x: 150,
|
||||
y: 110,
|
||||
scale: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps client points to canvas and world coordinates with safe fallbacks', () => {
|
||||
const rect = { left: 100, top: 50, right: 700, bottom: 450 };
|
||||
|
||||
|
||||
@@ -141,10 +141,39 @@ export function fitViewportToLayers({
|
||||
if (!bounds) {
|
||||
return null;
|
||||
}
|
||||
const boundsWidth = Math.max(1, bounds.maxX - bounds.minX);
|
||||
const boundsHeight = Math.max(1, bounds.maxY - bounds.minY);
|
||||
const availableWidth = Math.max(1, canvasSize.width - FIT_VIEW_PADDING * 2);
|
||||
const availableHeight = Math.max(1, canvasSize.height - FIT_VIEW_PADDING * 2);
|
||||
return fitViewportToBounds({
|
||||
bounds: {
|
||||
x: bounds.minX,
|
||||
y: bounds.minY,
|
||||
width: bounds.maxX - bounds.minX,
|
||||
height: bounds.maxY - bounds.minY,
|
||||
},
|
||||
canvasSize,
|
||||
});
|
||||
}
|
||||
|
||||
export function fitViewportToBounds({
|
||||
bounds,
|
||||
canvasSize,
|
||||
insets = {},
|
||||
padding = FIT_VIEW_PADDING,
|
||||
}: {
|
||||
bounds: { x: number; y: number; width: number; height: number };
|
||||
canvasSize: CanvasSize;
|
||||
insets?: { top?: number; right?: number; bottom?: number; left?: number };
|
||||
padding?: number;
|
||||
}): CanvasViewport {
|
||||
const boundsWidth = Math.max(1, bounds.width);
|
||||
const boundsHeight = Math.max(1, bounds.height);
|
||||
const leftInset = Math.max(0, insets.left ?? 0) + padding;
|
||||
const rightInset = Math.max(0, insets.right ?? 0) + padding;
|
||||
const topInset = Math.max(0, insets.top ?? 0) + padding;
|
||||
const bottomInset = Math.max(0, insets.bottom ?? 0) + padding;
|
||||
const availableWidth = Math.max(1, canvasSize.width - leftInset - rightInset);
|
||||
const availableHeight = Math.max(
|
||||
1,
|
||||
canvasSize.height - topInset - bottomInset,
|
||||
);
|
||||
const scale = clamp(
|
||||
Math.min(1, availableWidth / boundsWidth, availableHeight / boundsHeight),
|
||||
MIN_SCALE,
|
||||
@@ -152,8 +181,8 @@ export function fitViewportToLayers({
|
||||
);
|
||||
|
||||
return {
|
||||
x: canvasSize.width / 2 - (bounds.minX + boundsWidth / 2) * scale,
|
||||
y: canvasSize.height / 2 - (bounds.minY + boundsHeight / 2) * scale,
|
||||
x: leftInset + availableWidth / 2 - (bounds.x + boundsWidth / 2) * scale,
|
||||
y: topInset + availableHeight / 2 - (bounds.y + boundsHeight / 2) * scale,
|
||||
scale,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
|
||||
import { ImageCanvasBottomToolbarView } from './ImageCanvasBottomToolbarView';
|
||||
import { ImageCanvasContextMenusView } from './ImageCanvasContextMenusView';
|
||||
import type {
|
||||
ExternalGenerationTaskRecord,
|
||||
} from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import type {
|
||||
CanvasAssetKind,
|
||||
CanvasClipboard,
|
||||
@@ -38,6 +41,7 @@ import { ImageCanvasUiAssetExtractionOverlayView } from './ImageCanvasUiAssetExt
|
||||
import { ImageCanvasWorldView } from './ImageCanvasWorldView';
|
||||
|
||||
export type ImageCanvasStageViewProps = {
|
||||
projectId?: string | null;
|
||||
canvasViewportRef: RefObject<HTMLDivElement | null>;
|
||||
specToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
musicToolWrapRef: RefObject<HTMLSpanElement | null>;
|
||||
@@ -113,6 +117,9 @@ export type ImageCanvasStageViewProps = {
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => void;
|
||||
onActivateGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
|
||||
onFocusGenerationDialog: (dialog: CanvasGenerationDialogState) => void;
|
||||
onFocusTaskLayer: (layerId: string) => void;
|
||||
onFocusExternalTask: (task: ExternalGenerationTaskRecord) => void;
|
||||
onToggleTaskSidebar: () => void;
|
||||
onCropExpandHandlePointerDown: (
|
||||
event: ReactPointerEvent<HTMLButtonElement>,
|
||||
@@ -175,6 +182,7 @@ export type ImageCanvasStageViewProps = {
|
||||
};
|
||||
|
||||
export function ImageCanvasStageView({
|
||||
projectId,
|
||||
canvasViewportRef,
|
||||
specToolWrapRef,
|
||||
musicToolWrapRef,
|
||||
@@ -235,6 +243,9 @@ export function ImageCanvasStageView({
|
||||
onUpdateLayerAssetKind,
|
||||
onGenerationFramePointerDown,
|
||||
onActivateGenerationDialog,
|
||||
onFocusGenerationDialog,
|
||||
onFocusTaskLayer,
|
||||
onFocusExternalTask,
|
||||
onToggleTaskSidebar,
|
||||
onCropExpandHandlePointerDown,
|
||||
onOpenQuickEditPanel,
|
||||
@@ -433,11 +444,14 @@ export function ImageCanvasStageView({
|
||||
/>
|
||||
|
||||
<ImageCanvasTaskSidebarView
|
||||
projectId={projectId}
|
||||
generationDialogs={canvasGenerationDialogs}
|
||||
backgroundRemovalTasks={backgroundRemovalTasks}
|
||||
open={isTaskSidebarOpen}
|
||||
onToggleOpen={onToggleTaskSidebar}
|
||||
onActivateGenerationDialog={onActivateGenerationDialog}
|
||||
onFocusGenerationDialog={onFocusGenerationDialog}
|
||||
onFocusLayer={onFocusTaskLayer}
|
||||
onFocusExternalTask={onFocusExternalTask}
|
||||
/>
|
||||
|
||||
<ImageCanvasBottomToolbarView
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { ExternalGenerationTaskRecord } from '../../../packages/shared/src/contracts/externalGeneration';
|
||||
import { listExternalGenerationTasks } from '../../services/external-generation';
|
||||
import { ImageCanvasTaskSidebarView } from './ImageCanvasTaskSidebarView';
|
||||
import type { CanvasGenerationDialogState } from './ImageCanvasEditorTypes';
|
||||
|
||||
vi.mock('../../services/external-generation', () => ({
|
||||
listExternalGenerationTasks: vi.fn().mockResolvedValue({
|
||||
overview: {
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
tasks: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
const listExternalGenerationTasksMock = vi.mocked(listExternalGenerationTasks);
|
||||
|
||||
beforeEach(() => {
|
||||
listExternalGenerationTasksMock.mockClear();
|
||||
listExternalGenerationTasksMock.mockResolvedValue({
|
||||
overview: {
|
||||
pendingCount: 0,
|
||||
runningCount: 0,
|
||||
unacknowledgedTerminalCount: 0,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
tasks: [],
|
||||
});
|
||||
});
|
||||
|
||||
function createDialog(
|
||||
overrides: Partial<CanvasGenerationDialogState>,
|
||||
): CanvasGenerationDialogState {
|
||||
return {
|
||||
id: overrides.id ?? 'dialog-1',
|
||||
mode: overrides.mode ?? 'generate',
|
||||
prompt: overrides.prompt ?? '',
|
||||
status: overrides.status ?? 'idle',
|
||||
composerOpen: false,
|
||||
placeholder: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 320,
|
||||
height: 320,
|
||||
originalWidth: 1024,
|
||||
originalHeight: 1024,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createExternalTask(
|
||||
overrides: Partial<ExternalGenerationTaskRecord> = {},
|
||||
): ExternalGenerationTaskRecord {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
jobId: overrides.jobId ?? 'extgen-1',
|
||||
jobKind: overrides.jobKind ?? 'editor_image_generation',
|
||||
sourceModule: overrides.sourceModule ?? 'editor-canvas',
|
||||
sourceEntityId: overrides.sourceEntityId ?? 'project-1',
|
||||
requestLabel: overrides.requestLabel ?? '图片画布生成图片',
|
||||
requestPrompt: overrides.requestPrompt ?? '发光猫咪主视觉',
|
||||
status: overrides.status ?? 'running',
|
||||
phaseLabel: overrides.phaseLabel ?? '生成图片',
|
||||
phaseDetail: overrides.phaseDetail ?? '正在生成第 2/4 段。',
|
||||
progress: overrides.progress ?? 35,
|
||||
error: overrides.error ?? null,
|
||||
priceMudPoints: overrides.priceMudPoints ?? 1,
|
||||
refundLedgerId: overrides.refundLedgerId ?? null,
|
||||
notificationAcknowledgedAt: overrides.notificationAcknowledgedAt ?? null,
|
||||
createdAt: overrides.createdAt ?? now,
|
||||
startedAt: overrides.startedAt ?? now,
|
||||
completedAt: overrides.completedAt ?? null,
|
||||
updatedAt: overrides.updatedAt ?? now,
|
||||
updatedAtMicros: overrides.updatedAtMicros ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ImageCanvasTaskSidebarView', () => {
|
||||
it('groups tasks by tabs and keeps running tasks before queued tasks', async () => {
|
||||
const focusGenerationDialog = vi.fn();
|
||||
const parentWheel = vi.fn();
|
||||
render(
|
||||
<div onWheel={parentWheel}>
|
||||
<ImageCanvasTaskSidebarView
|
||||
projectId="project-1"
|
||||
generationDialogs={[
|
||||
createDialog({
|
||||
id: 'queued-dialog',
|
||||
mode: 'icon',
|
||||
status: 'idle',
|
||||
}),
|
||||
createDialog({
|
||||
id: 'running-dialog',
|
||||
mode: 'character',
|
||||
status: 'generating',
|
||||
generationStartedAt: Date.now() - 3000,
|
||||
}),
|
||||
createDialog({
|
||||
id: 'done-dialog',
|
||||
mode: 'video',
|
||||
prompt: '生成一段开场动画',
|
||||
status: 'idle',
|
||||
generatedLayerId: 'layer-video',
|
||||
generationStartedAt: Date.now() - 4000,
|
||||
generationFinishedAt: Date.now() - 1000,
|
||||
}),
|
||||
]}
|
||||
backgroundRemovalTasks={[]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusGenerationDialog={focusGenerationDialog}
|
||||
onFocusLayer={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
/>
|
||||
</div>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(listExternalGenerationTasksMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
includeAcknowledgedTerminal: false,
|
||||
limit: 100,
|
||||
statuses: ['running', 'queued'],
|
||||
}),
|
||||
);
|
||||
expect(listExternalGenerationTasksMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
includeAcknowledgedTerminal: true,
|
||||
limit: 20,
|
||||
statuses: ['completed', 'failed'],
|
||||
}),
|
||||
);
|
||||
|
||||
const activeTitles = screen
|
||||
.getAllByRole('button')
|
||||
.map((button) => button.textContent ?? '')
|
||||
.filter((text) => text.includes('角色图片') || text.includes('图标素材'));
|
||||
expect(activeTitles[0]).toContain('角色图片');
|
||||
expect(activeTitles[1]).toContain('图标素材');
|
||||
|
||||
const completedTab = screen.getByRole('tab', { name: /已完成/u });
|
||||
fireEvent.click(completedTab);
|
||||
expect(screen.queryByText('视频生成')).not.toBeNull();
|
||||
expect(screen.queryByText(/生成一段开场动画/u)).not.toBeNull();
|
||||
expect(screen.queryByText(/完成于/u)).not.toBeNull();
|
||||
expect(screen.queryByText(/生成完成/u)).toBeNull();
|
||||
|
||||
fireEvent.wheel(screen.getByLabelText('画布任务列表'), { deltaY: 120 });
|
||||
expect(parentWheel).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(screen.getByText('视频生成'));
|
||||
expect(focusGenerationDialog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'done-dialog' }),
|
||||
);
|
||||
expect(completedTab.getAttribute('aria-selected')).toBe('true');
|
||||
});
|
||||
|
||||
it('shows only current project external tasks with backend timing and phase detail', async () => {
|
||||
const startedAt = new Date(Date.now() - 65_000).toISOString();
|
||||
const completedAt = new Date(Date.now() - 20_000).toISOString();
|
||||
listExternalGenerationTasksMock.mockImplementation(
|
||||
(options: Parameters<typeof listExternalGenerationTasks>[0] = {}) =>
|
||||
Promise.resolve({
|
||||
overview: {
|
||||
pendingCount: 0,
|
||||
runningCount: 1,
|
||||
unacknowledgedTerminalCount: 1,
|
||||
updatedAtMicros: 1,
|
||||
},
|
||||
tasks: options.statuses?.includes('running')
|
||||
? [
|
||||
createExternalTask({
|
||||
jobId: 'active-current',
|
||||
startedAt,
|
||||
}),
|
||||
createExternalTask({
|
||||
jobId: 'active-other-project',
|
||||
sourceEntityId: 'project-other',
|
||||
requestPrompt: '不该显示的外部项目任务',
|
||||
}),
|
||||
]
|
||||
: [
|
||||
createExternalTask({
|
||||
jobId: 'done-current',
|
||||
status: 'completed',
|
||||
requestPrompt: '已完成提示词',
|
||||
startedAt: new Date(Date.now() - 90_000).toISOString(),
|
||||
completedAt,
|
||||
updatedAt: completedAt,
|
||||
progress: 100,
|
||||
phaseDetail: '生成已完成。',
|
||||
}),
|
||||
createExternalTask({
|
||||
jobId: 'done-other-project',
|
||||
sourceEntityId: 'project-other',
|
||||
status: 'completed',
|
||||
requestPrompt: '另一个项目的已完成任务',
|
||||
completedAt,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<ImageCanvasTaskSidebarView
|
||||
projectId="project-1"
|
||||
generationDialogs={[]}
|
||||
backgroundRemovalTasks={[]}
|
||||
open
|
||||
onToggleOpen={vi.fn()}
|
||||
onFocusGenerationDialog={vi.fn()}
|
||||
onFocusLayer={vi.fn()}
|
||||
onFocusExternalTask={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText(/正在生成第 2\/4 段。 · 总进度 35%/u),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText(/已用时 1分/u)).toBeTruthy();
|
||||
expect(screen.queryByText(/不该显示的外部项目任务/u)).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: /已完成/u }));
|
||||
expect(screen.getByText(/已完成提示词/u)).toBeTruthy();
|
||||
expect(screen.getByText(/完成于/u)).toBeTruthy();
|
||||
expect(screen.queryByText(/另一个项目的已完成任务/u)).toBeNull();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,11 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { isCanvasGenerationDialog } from './ImageCanvasGenerationModel';
|
||||
import type {
|
||||
@@ -10,6 +17,35 @@ type CanvasGenerationDialogUpdater = (
|
||||
dialog: CanvasGenerationDialogState,
|
||||
) => CanvasGenerationDialogState | null;
|
||||
|
||||
function withGenerationTimestamps<T extends GenerateDialogState | null>(
|
||||
nextDialog: T,
|
||||
previousDialog?: GenerateDialogState | null,
|
||||
): T {
|
||||
if (!nextDialog) {
|
||||
return nextDialog;
|
||||
}
|
||||
if (nextDialog.status !== 'generating') {
|
||||
const shouldMarkFinished =
|
||||
nextDialog.status === 'failed' || Boolean(nextDialog.generatedLayerId);
|
||||
return {
|
||||
...nextDialog,
|
||||
generationFinishedAt: shouldMarkFinished
|
||||
? (nextDialog.generationFinishedAt ??
|
||||
previousDialog?.generationFinishedAt ??
|
||||
Date.now())
|
||||
: nextDialog.generationFinishedAt,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...nextDialog,
|
||||
generationStartedAt:
|
||||
nextDialog.generationStartedAt ??
|
||||
previousDialog?.generationStartedAt ??
|
||||
Date.now(),
|
||||
generationFinishedAt: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function useCanvasGenerationDialogs({
|
||||
onActivate,
|
||||
}: {
|
||||
@@ -18,7 +54,7 @@ export function useCanvasGenerationDialogs({
|
||||
const generationDialogCounterRef = useRef(0);
|
||||
const generateDialogRef = useRef<GenerateDialogState | null>(null);
|
||||
const inactiveGenerateDialogsRef = useRef<CanvasGenerationDialogState[]>([]);
|
||||
const [generateDialog, setGenerateDialog] =
|
||||
const [generateDialog, setGenerateDialogState] =
|
||||
useState<GenerateDialogState | null>(null);
|
||||
const [inactiveGenerateDialogs, setInactiveGenerateDialogs] = useState<
|
||||
CanvasGenerationDialogState[]
|
||||
@@ -30,6 +66,18 @@ export function useCanvasGenerationDialogs({
|
||||
const activeCanvasGenerationDialog = isCanvasGenerationDialog(generateDialog)
|
||||
? generateDialog
|
||||
: null;
|
||||
|
||||
const setGenerateDialog = useCallback<
|
||||
Dispatch<SetStateAction<GenerateDialogState | null>>
|
||||
>((nextDialogOrUpdater) => {
|
||||
setGenerateDialogState((currentDialog) => {
|
||||
const nextDialog =
|
||||
typeof nextDialogOrUpdater === 'function'
|
||||
? nextDialogOrUpdater(currentDialog)
|
||||
: nextDialogOrUpdater;
|
||||
return withGenerationTimestamps(nextDialog, currentDialog);
|
||||
});
|
||||
}, []);
|
||||
const canvasGenerationDialogs = useMemo(
|
||||
() =>
|
||||
activeCanvasGenerationDialog
|
||||
@@ -80,12 +128,12 @@ export function useCanvasGenerationDialogs({
|
||||
}
|
||||
archiveActiveCanvasGenerationDialog();
|
||||
const id = createGenerationDialogId();
|
||||
const nextDialog = {
|
||||
const nextDialog = withGenerationTimestamps({
|
||||
...dialog,
|
||||
id,
|
||||
};
|
||||
});
|
||||
generateDialogRef.current = nextDialog;
|
||||
setGenerateDialog(nextDialog);
|
||||
setGenerateDialogState(nextDialog);
|
||||
return id;
|
||||
},
|
||||
[archiveActiveCanvasGenerationDialog, createGenerationDialogId],
|
||||
@@ -98,28 +146,34 @@ export function useCanvasGenerationDialogs({
|
||||
isCanvasGenerationDialog(currentDialogRef) &&
|
||||
currentDialogRef.id === dialogId
|
||||
) {
|
||||
generateDialogRef.current = updater(currentDialogRef);
|
||||
generateDialogRef.current = withGenerationTimestamps(
|
||||
updater(currentDialogRef),
|
||||
currentDialogRef,
|
||||
);
|
||||
}
|
||||
inactiveGenerateDialogsRef.current =
|
||||
inactiveGenerateDialogsRef.current.flatMap((dialog) => {
|
||||
if (dialog.id !== dialogId) {
|
||||
return [dialog];
|
||||
}
|
||||
const nextDialog = updater(dialog);
|
||||
const nextDialog = withGenerationTimestamps(updater(dialog), dialog);
|
||||
return nextDialog ? [nextDialog] : [];
|
||||
});
|
||||
setGenerateDialog((currentDialog) =>
|
||||
isCanvasGenerationDialog(currentDialog) &&
|
||||
currentDialog.id === dialogId
|
||||
? updater(currentDialog)
|
||||
: currentDialog,
|
||||
);
|
||||
setGenerateDialogState((currentDialog) => {
|
||||
if (
|
||||
!isCanvasGenerationDialog(currentDialog) ||
|
||||
currentDialog.id !== dialogId
|
||||
) {
|
||||
return currentDialog;
|
||||
}
|
||||
return withGenerationTimestamps(updater(currentDialog), currentDialog);
|
||||
});
|
||||
setInactiveGenerateDialogs((currentDialogs) =>
|
||||
currentDialogs.flatMap((dialog) => {
|
||||
if (dialog.id !== dialogId) {
|
||||
return [dialog];
|
||||
}
|
||||
const nextDialog = updater(dialog);
|
||||
const nextDialog = withGenerationTimestamps(updater(dialog), dialog);
|
||||
return nextDialog ? [nextDialog] : [];
|
||||
}),
|
||||
);
|
||||
@@ -160,7 +214,7 @@ export function useCanvasGenerationDialogs({
|
||||
}
|
||||
return nextDialogs;
|
||||
});
|
||||
setGenerateDialog({
|
||||
setGenerateDialogState({
|
||||
...targetDialog,
|
||||
composerOpen: true,
|
||||
});
|
||||
@@ -184,7 +238,7 @@ export function useCanvasGenerationDialogs({
|
||||
[...dialogs].reverse().find((dialog) => dialog.composerOpen !== false) ??
|
||||
dialogs[dialogs.length - 1] ??
|
||||
null;
|
||||
setGenerateDialog(
|
||||
setGenerateDialogState(
|
||||
activeDialog
|
||||
? {
|
||||
...activeDialog,
|
||||
@@ -209,7 +263,7 @@ export function useCanvasGenerationDialogs({
|
||||
const keepDialog = (dialog: CanvasGenerationDialogState) =>
|
||||
dialog.sourceLayerId !== targetLayerId &&
|
||||
dialog.generatedLayerId !== targetLayerId;
|
||||
setGenerateDialog((currentDialog) =>
|
||||
setGenerateDialogState((currentDialog) =>
|
||||
isCanvasGenerationDialog(currentDialog) && !keepDialog(currentDialog)
|
||||
? null
|
||||
: currentDialog,
|
||||
|
||||
@@ -1394,6 +1394,7 @@ export function useImageCanvasGenerationWorkflow({
|
||||
detail: '正在抠图',
|
||||
status: 'running',
|
||||
startedAt,
|
||||
focusLayerId: sourceLayer.id,
|
||||
},
|
||||
...currentTasks,
|
||||
]);
|
||||
|
||||
+68
-1
@@ -6198,6 +6198,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
width: min(20rem, calc(100vw - 5.6rem));
|
||||
max-height: min(24rem, calc(100vh - 9rem));
|
||||
overflow: hidden;
|
||||
overscroll-behavior: contain;
|
||||
border: 1px solid var(--image-canvas-brand-border-soft);
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
@@ -6233,13 +6234,66 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.28rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 0.38rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-tab {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
border: 0;
|
||||
border-radius: 0.38rem;
|
||||
background: transparent;
|
||||
padding: 0.42rem 0.5rem;
|
||||
color: #64748b;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-tab:hover {
|
||||
background: #f8fafc;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-tab--active {
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-tab span {
|
||||
display: inline-flex;
|
||||
min-width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-list {
|
||||
display: grid;
|
||||
max-height: calc(min(24rem, calc(100vh - 9rem)) - 2.8rem);
|
||||
max-height: calc(min(24rem, calc(100vh - 9rem)) - 5.55rem);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-empty {
|
||||
padding: 1.15rem 0.75rem;
|
||||
color: #94a3b8;
|
||||
font-size: 0.74rem;
|
||||
font-weight: 750;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
@@ -6281,6 +6335,19 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
|
||||
color: #0891b2;
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item--running
|
||||
.image-canvas-editor__task-sidebar-item-icon
|
||||
svg {
|
||||
animation: image-canvas-task-sidebar-spin 0.9s linear infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes image-canvas-task-sidebar-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.image-canvas-editor__task-sidebar-item--failed
|
||||
.image-canvas-editor__task-sidebar-item-icon {
|
||||
background: #fef2f2;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ExternalGenerationJobStatus,
|
||||
ExternalGenerationJobStatusResponse,
|
||||
ExternalGenerationQueueOverviewResponse,
|
||||
ExternalGenerationTaskAcknowledgeRequest,
|
||||
@@ -50,6 +51,7 @@ export async function listExternalGenerationTasks(
|
||||
options: {
|
||||
limit?: number;
|
||||
includeAcknowledgedTerminal?: boolean;
|
||||
statuses?: ExternalGenerationJobStatus[];
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
) {
|
||||
@@ -63,6 +65,9 @@ export async function listExternalGenerationTasks(
|
||||
options.includeAcknowledgedTerminal ? 'true' : 'false',
|
||||
);
|
||||
}
|
||||
if (options.statuses?.length) {
|
||||
params.set('statuses', options.statuses.join(','));
|
||||
}
|
||||
const query = params.toString();
|
||||
return requestJson<ExternalGenerationTaskListResponse>(
|
||||
`${EXTERNAL_GENERATION_API_BASE}/jobs${query ? `?${query}` : ''}`,
|
||||
|
||||
Reference in New Issue
Block a user