完善画布任务列表与远程抠图接入

新增图片画布任务侧栏页签、滚轮隔离、完成时间、后端计时和运行中状态展示。

限制外部生成任务只显示当前项目,并让任务点击聚焦画布内容和快速编辑源图。

接入外部生成任务列表查询状态过滤、提示词摘要和后端任务映射。

补充 BiRefNet 抠图请求日志、任务耗时提示和相关文档。

补充侧栏、画布聚焦、外部任务契约和 api-server 定向测试。
This commit is contained in:
2026-06-29 16:19:28 +08:00
parent d2428b30f2
commit 12ef866b66
21 changed files with 1327 additions and 70 deletions
@@ -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)]
@@ -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
View File
@@ -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}` : ''}`,