feat: add resource library and real image workflow

This commit is contained in:
afei A
2026-03-29 00:24:29 +08:00
parent eeaff269eb
commit 04da401ab4
38 changed files with 3033 additions and 117 deletions

View File

@@ -1,20 +1,82 @@
"""Export mock activity."""
"""Export activity."""
from app.domain.enums import AssetType, OrderStatus, StepStatus
from app.infra.db.models.asset import AssetORM
from app.infra.db.session import get_session_factory
from temporalio import activity
from app.domain.enums import AssetType
from app.workers.activities.tryon_activities import execute_asset_step
from app.workers.activities.tryon_activities import create_step_record, jsonable, load_order_and_run, utc_now
from app.workers.workflows.types import MockActivityResult, StepActivityInput
@activity.defn
async def run_export_activity(payload: StepActivityInput) -> MockActivityResult:
"""Mock final asset export."""
"""Finalize the chosen source asset as the order's exported deliverable."""
return await execute_asset_step(
payload,
AssetType.FINAL,
filename="final.png",
finalize=True,
)
if payload.source_asset_id is None:
raise ValueError("run_export_activity requires source_asset_id")
async with get_session_factory()() as session:
order, workflow_run = await load_order_and_run(session, payload.order_id, payload.workflow_run_id)
step = create_step_record(payload)
session.add(step)
order.status = OrderStatus.RUNNING
workflow_run.status = OrderStatus.RUNNING
workflow_run.current_step = payload.step_name
await session.flush()
try:
source_asset = await session.get(AssetORM, payload.source_asset_id)
if source_asset is None:
raise ValueError(f"Source asset {payload.source_asset_id} not found")
if source_asset.order_id != payload.order_id:
raise ValueError(
f"Source asset {payload.source_asset_id} does not belong to order {payload.order_id}"
)
metadata = {
**payload.metadata,
"source_asset_id": payload.source_asset_id,
"selected_asset_id": payload.selected_asset_id,
}
metadata = {key: value for key, value in metadata.items() if value is not None}
asset = AssetORM(
order_id=payload.order_id,
asset_type=AssetType.FINAL,
step_name=payload.step_name,
uri=source_asset.uri,
metadata_json=jsonable(metadata),
)
session.add(asset)
await session.flush()
result = MockActivityResult(
step_name=payload.step_name,
success=True,
asset_id=asset.id,
uri=asset.uri,
score=0.95,
passed=True,
message="mock success",
metadata=jsonable(metadata) or {},
)
order.final_asset_id = asset.id
order.status = OrderStatus.SUCCEEDED
workflow_run.status = OrderStatus.SUCCEEDED
step.step_status = StepStatus.SUCCEEDED
step.output_json = jsonable(result)
step.ended_at = utc_now()
await session.commit()
return result
except Exception as exc:
step.step_status = StepStatus.FAILED
step.error_message = str(exc)
step.ended_at = utc_now()
order.status = OrderStatus.FAILED
workflow_run.status = OrderStatus.FAILED
await session.commit()
raise

View File

@@ -29,11 +29,22 @@ async def run_qc_activity(payload: StepActivityInput) -> MockActivityResult:
candidate_uri: str | None = None
if passed:
if payload.source_asset_id is None:
raise ValueError("run_qc_activity requires source_asset_id")
source_asset = await session.get(AssetORM, payload.source_asset_id)
if source_asset is None:
raise ValueError(f"Source asset {payload.source_asset_id} not found")
if source_asset.order_id != payload.order_id:
raise ValueError(
f"Source asset {payload.source_asset_id} does not belong to order {payload.order_id}"
)
candidate = AssetORM(
order_id=payload.order_id,
asset_type=AssetType.QC_CANDIDATE,
step_name=payload.step_name,
uri=mock_uri(payload.order_id, payload.step_name.value, "candidate.png"),
uri=source_asset.uri,
metadata_json=jsonable({"source_asset_id": payload.source_asset_id}),
)
session.add(candidate)

View File

@@ -1,19 +1,122 @@
"""Scene mock activity."""
"""Scene activities."""
from temporalio import activity
from app.domain.enums import AssetType
from app.workers.activities.tryon_activities import execute_asset_step
from app.domain.enums import AssetType, LibraryResourceType, OrderStatus, StepStatus
from app.infra.db.models.asset import AssetORM
from app.infra.db.session import get_session_factory
from app.workers.activities.tryon_activities import (
create_step_record,
execute_asset_step,
get_image_generation_service,
get_order_artifact_storage_service,
jsonable,
load_active_library_resource,
load_order_and_run,
utc_now,
)
from app.workers.workflows.types import MockActivityResult, StepActivityInput
@activity.defn
async def run_scene_activity(payload: StepActivityInput) -> MockActivityResult:
"""Mock scene replacement."""
"""Generate a scene-composited asset, or fall back to mock mode when configured."""
return await execute_asset_step(
payload,
AssetType.SCENE,
extra_metadata={"scene_ref_asset_id": payload.scene_ref_asset_id},
)
service = get_image_generation_service()
if service.__class__.__name__ == "MockImageGenerationService":
return await execute_asset_step(
payload,
AssetType.SCENE,
extra_metadata={"scene_ref_asset_id": payload.scene_ref_asset_id},
)
if payload.source_asset_id is None:
raise ValueError("run_scene_activity requires source_asset_id")
if payload.scene_ref_asset_id is None:
raise ValueError("run_scene_activity requires scene_ref_asset_id")
async with get_session_factory()() as session:
order, workflow_run = await load_order_and_run(session, payload.order_id, payload.workflow_run_id)
step = create_step_record(payload)
session.add(step)
order.status = OrderStatus.RUNNING
workflow_run.status = OrderStatus.RUNNING
workflow_run.current_step = payload.step_name
await session.flush()
try:
source_asset = await session.get(AssetORM, payload.source_asset_id)
if source_asset is None:
raise ValueError(f"Source asset {payload.source_asset_id} not found")
if source_asset.order_id != payload.order_id:
raise ValueError(
f"Source asset {payload.source_asset_id} does not belong to order {payload.order_id}"
)
scene_resource, scene_original = await load_active_library_resource(
session,
payload.scene_ref_asset_id,
resource_type=LibraryResourceType.SCENE,
)
generated = await service.generate_scene_image(
source_image_url=source_asset.uri,
scene_image_url=scene_original.public_url,
)
storage_key, public_url = await get_order_artifact_storage_service().upload_generated_image(
order_id=payload.order_id,
step_name=payload.step_name,
image_bytes=generated.image_bytes,
mime_type=generated.mime_type,
)
metadata = {
**payload.metadata,
"source_asset_id": source_asset.id,
"scene_ref_asset_id": payload.scene_ref_asset_id,
"scene_resource_id": scene_resource.id,
"scene_original_file_id": scene_original.id,
"scene_original_url": scene_original.public_url,
"provider": generated.provider,
"model": generated.model,
"storage_key": storage_key,
"mime_type": generated.mime_type,
"prompt": generated.prompt,
}
metadata = {key: value for key, value in metadata.items() if value is not None}
asset = AssetORM(
order_id=payload.order_id,
asset_type=AssetType.SCENE,
step_name=payload.step_name,
uri=public_url,
metadata_json=jsonable(metadata),
)
session.add(asset)
await session.flush()
result = MockActivityResult(
step_name=payload.step_name,
success=True,
asset_id=asset.id,
uri=asset.uri,
score=1.0,
passed=True,
message="scene generated",
metadata=jsonable(metadata) or {},
)
step.step_status = StepStatus.SUCCEEDED
step.output_json = jsonable(result)
step.ended_at = utc_now()
await session.commit()
return result
except Exception as exc:
step.step_status = StepStatus.FAILED
step.error_message = str(exc)
step.ended_at = utc_now()
order.status = OrderStatus.FAILED
workflow_run.status = OrderStatus.FAILED
await session.commit()
raise

View File

@@ -1,4 +1,4 @@
"""Prepare-model and try-on mock activities plus shared helpers."""
"""Prepare-model and try-on activities plus shared helpers."""
from __future__ import annotations
@@ -8,14 +8,20 @@ from enum import Enum
from typing import Any
from uuid import uuid4
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from temporalio import activity
from app.domain.enums import AssetType, OrderStatus, StepStatus
from app.application.services.image_generation_service import build_image_generation_service
from app.domain.enums import AssetType, LibraryResourceStatus, LibraryResourceType, OrderStatus, StepStatus
from app.infra.db.models.asset import AssetORM
from app.infra.db.models.library_resource import LibraryResourceORM
from app.infra.db.models.library_resource_file import LibraryResourceFileORM
from app.infra.db.models.order import OrderORM
from app.infra.db.models.workflow_run import WorkflowRunORM
from app.infra.db.models.workflow_step import WorkflowStepORM
from app.infra.db.session import get_session_factory
from app.infra.storage.s3 import S3ObjectStorageService
from app.workers.workflows.types import MockActivityResult, StepActivityInput
@@ -71,6 +77,64 @@ def create_step_record(payload: StepActivityInput) -> WorkflowStepORM:
)
async def load_active_library_resource(
session,
resource_id: int,
*,
resource_type: LibraryResourceType,
) -> tuple[LibraryResourceORM, LibraryResourceFileORM]:
"""Load an active library resource and its original file from the library."""
result = await session.execute(
select(LibraryResourceORM)
.options(selectinload(LibraryResourceORM.files))
.where(LibraryResourceORM.id == resource_id)
)
resource = result.scalar_one_or_none()
if resource is None:
raise ValueError(f"Library resource {resource_id} not found")
if resource.resource_type != resource_type:
raise ValueError(f"Resource {resource_id} is not a {resource_type.value} resource")
if resource.status != LibraryResourceStatus.ACTIVE:
raise ValueError(f"Library resource {resource_id} is not active")
if resource.original_file_id is None:
raise ValueError(f"Library resource {resource_id} is missing an original file")
original_file = next((item for item in resource.files if item.id == resource.original_file_id), None)
if original_file is None:
raise ValueError(f"Library resource {resource_id} original file record not found")
return resource, original_file
def get_image_generation_service():
"""Return the configured image-generation service."""
return build_image_generation_service()
def get_order_artifact_storage_service() -> S3ObjectStorageService:
"""Return the object-storage service for workflow-generated images."""
return S3ObjectStorageService()
def build_resource_input_snapshot(
resource: LibraryResourceORM,
original_file: LibraryResourceFileORM,
) -> dict[str, Any]:
"""Build a frontend-friendly snapshot of one library input resource."""
return {
"resource_id": resource.id,
"resource_name": resource.name,
"original_file_id": original_file.id,
"original_url": original_file.public_url,
"mime_type": original_file.mime_type,
"width": original_file.width,
"height": original_file.height,
}
async def execute_asset_step(
payload: StepActivityInput,
asset_type: AssetType,
@@ -145,26 +209,213 @@ async def execute_asset_step(
@activity.defn
async def prepare_model_activity(payload: StepActivityInput) -> MockActivityResult:
"""Mock model preparation for the pipeline."""
"""Resolve a model resource into an order-scoped prepared-model asset."""
return await execute_asset_step(
payload,
AssetType.PREPARED_MODEL,
extra_metadata={
"model_id": payload.model_id,
"pose_id": payload.pose_id,
"garment_asset_id": payload.garment_asset_id,
"scene_ref_asset_id": payload.scene_ref_asset_id,
},
)
if payload.model_id is None:
raise ValueError("prepare_model_activity requires model_id")
async with get_session_factory()() as session:
order, workflow_run = await load_order_and_run(session, payload.order_id, payload.workflow_run_id)
step = create_step_record(payload)
session.add(step)
order.status = OrderStatus.RUNNING
workflow_run.status = OrderStatus.RUNNING
workflow_run.current_step = payload.step_name
await session.flush()
try:
resource, original_file = await load_active_library_resource(
session,
payload.model_id,
resource_type=LibraryResourceType.MODEL,
)
garment_snapshot = None
if payload.garment_asset_id is not None:
garment_resource, garment_original = await load_active_library_resource(
session,
payload.garment_asset_id,
resource_type=LibraryResourceType.GARMENT,
)
garment_snapshot = build_resource_input_snapshot(garment_resource, garment_original)
scene_snapshot = None
if payload.scene_ref_asset_id is not None:
scene_resource, scene_original = await load_active_library_resource(
session,
payload.scene_ref_asset_id,
resource_type=LibraryResourceType.SCENE,
)
scene_snapshot = build_resource_input_snapshot(scene_resource, scene_original)
metadata = {
**payload.metadata,
"model_id": payload.model_id,
"pose_id": payload.pose_id,
"garment_asset_id": payload.garment_asset_id,
"scene_ref_asset_id": payload.scene_ref_asset_id,
"library_resource_id": resource.id,
"library_original_file_id": original_file.id,
"library_original_url": original_file.public_url,
"library_original_mime_type": original_file.mime_type,
"library_original_width": original_file.width,
"library_original_height": original_file.height,
"model_input": build_resource_input_snapshot(resource, original_file),
"garment_input": garment_snapshot,
"scene_input": scene_snapshot,
"pose_input": {"pose_id": payload.pose_id} if payload.pose_id is not None else None,
"normalized": False,
}
metadata = {key: value for key, value in metadata.items() if value is not None}
asset = AssetORM(
order_id=payload.order_id,
asset_type=AssetType.PREPARED_MODEL,
step_name=payload.step_name,
uri=original_file.public_url,
metadata_json=jsonable(metadata),
)
session.add(asset)
await session.flush()
result = MockActivityResult(
step_name=payload.step_name,
success=True,
asset_id=asset.id,
uri=asset.uri,
score=1.0,
passed=True,
message="prepared model ready",
metadata=jsonable(metadata) or {},
)
step.step_status = StepStatus.SUCCEEDED
step.output_json = jsonable(result)
step.ended_at = utc_now()
await session.commit()
return result
except Exception as exc:
step.step_status = StepStatus.FAILED
step.error_message = str(exc)
step.ended_at = utc_now()
order.status = OrderStatus.FAILED
workflow_run.status = OrderStatus.FAILED
await session.commit()
raise
@activity.defn
async def run_tryon_activity(payload: StepActivityInput) -> MockActivityResult:
"""Mock try-on rendering."""
"""执行试衣渲染步骤,或在未接真实能力时走 mock 分支。
return await execute_asset_step(
payload,
AssetType.TRYON,
extra_metadata={"prepared_asset_id": payload.source_asset_id},
)
流程:
1. 读取当前配置的图片生成 service。
2. 如果当前仍是 mock service就退回通用的 mock 资产产出逻辑。
3. 如果是真实模式,先读取 prepare_model_activity 产出的 prepared_model 资产。
4. 再读取本次选中的服装资源,并解析出它的原图 URL。
5. 把“模特准备图 + 服装原图”一起发给 provider 做试衣生成。
6. 把生成结果上传到 S3并落成订单内的一条 TRYON 资产。
7. 在 metadata 里记录 provider、model、prompt 和输入来源,方便追踪排查。
"""
service = get_image_generation_service()
# 保留旧的 mock 分支,这样在没有接入真实 provider 时 workflow 也还能跑通。
if service.__class__.__name__ == "MockImageGenerationService":
return await execute_asset_step(
payload,
AssetType.TRYON,
extra_metadata={"prepared_asset_id": payload.source_asset_id},
)
if payload.source_asset_id is None:
raise ValueError("run_tryon_activity requires source_asset_id")
if payload.garment_asset_id is None:
raise ValueError("run_tryon_activity requires garment_asset_id")
async with get_session_factory()() as session:
order, workflow_run = await load_order_and_run(session, payload.order_id, payload.workflow_run_id)
step = create_step_record(payload)
session.add(step)
order.status = OrderStatus.RUNNING
workflow_run.status = OrderStatus.RUNNING
workflow_run.current_step = payload.step_name
await session.flush()
try:
# 试衣步骤的输入起点固定是上一阶段产出的 prepared_model 资产。
prepared_asset = await session.get(AssetORM, payload.source_asset_id)
if prepared_asset is None or prepared_asset.order_id != payload.order_id:
raise ValueError(f"Prepared asset {payload.source_asset_id} not found for order {payload.order_id}")
if prepared_asset.asset_type != AssetType.PREPARED_MODEL:
raise ValueError(f"Asset {payload.source_asset_id} is not a prepared_model asset")
# 服装素材来自共享资源库workflow 实际消费的是资源库里的原图。
garment_resource, garment_original = await load_active_library_resource(
session,
payload.garment_asset_id,
resource_type=LibraryResourceType.GARMENT,
)
# provider 接收两张真实输入图:模特准备图 + 服装参考图。
generated = await service.generate_tryon_image(
person_image_url=prepared_asset.uri,
garment_image_url=garment_original.public_url,
)
# workflow 生成出的结果图先上传到 S3再登记成订单资产。
storage_key, public_url = await get_order_artifact_storage_service().upload_generated_image(
order_id=payload.order_id,
step_name=payload.step_name,
image_bytes=generated.image_bytes,
mime_type=generated.mime_type,
)
# 记录足够的追踪信息,方便回溯这张试衣图由哪些输入和哪个 provider 产出。
metadata = {
**payload.metadata,
"prepared_asset_id": prepared_asset.id,
"garment_resource_id": garment_resource.id,
"garment_original_file_id": garment_original.id,
"garment_original_url": garment_original.public_url,
"provider": generated.provider,
"model": generated.model,
"storage_key": storage_key,
"mime_type": generated.mime_type,
"prompt": generated.prompt,
}
metadata = {key: value for key, value in metadata.items() if value is not None}
asset = AssetORM(
order_id=payload.order_id,
asset_type=AssetType.TRYON,
step_name=payload.step_name,
uri=public_url,
metadata_json=jsonable(metadata),
)
session.add(asset)
await session.flush()
result = MockActivityResult(
step_name=payload.step_name,
success=True,
asset_id=asset.id,
uri=asset.uri,
score=1.0,
passed=True,
message="try-on generated",
metadata=jsonable(metadata) or {},
)
step.step_status = StepStatus.SUCCEEDED
step.output_json = jsonable(result)
step.ended_at = utc_now()
await session.commit()
return result
except Exception as exc:
step.step_status = StepStatus.FAILED
step.error_message = str(exc)
step.ended_at = utc_now()
order.status = OrderStatus.FAILED
workflow_run.status = OrderStatus.FAILED
await session.commit()
raise

View File

@@ -26,14 +26,13 @@ with workflow.unsafe.imports_passed_through():
from app.workers.activities.review_activities import mark_workflow_failed_activity
from app.workers.activities.scene_activities import run_scene_activity
from app.workers.activities.tryon_activities import prepare_model_activity, run_tryon_activity
from app.workers.workflows.timeout_policy import DEFAULT_ACTIVITY_TIMEOUT, activity_timeout_for_task_queue
from app.workers.workflows.types import (
PipelineWorkflowInput,
StepActivityInput,
WorkflowFailureActivityInput,
)
ACTIVITY_TIMEOUT = timedelta(seconds=30)
ACTIVITY_RETRY_POLICY = RetryPolicy(
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
@@ -64,6 +63,8 @@ class LowEndPipelineWorkflow:
try:
# 每个步骤都通过 execute_activity 触发真正执行。
# workflow 自己不做计算,只负责调度。
# prepare_model 的职责是把“模特资源 + 订单上下文”整理成后续可消费的人物底图。
# 这一步产出的 prepared.asset_id 会作为 tryon 的 source_asset_id。
prepared = await workflow.execute_activity(
prepare_model_activity,
StepActivityInput(
@@ -76,12 +77,14 @@ class LowEndPipelineWorkflow:
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_CONTROL_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.TRYON
# 下游步骤通过 source_asset_id 引用上一步生成的资产。
# tryon 是换装主步骤:把 prepared 模特图和 garment_asset_id 组合成试衣结果。
# 它产出的 tryon_result.asset_id 是后续 scene / qc 的基础输入。
tryon_result = await workflow.execute_activity(
run_tryon_activity,
StepActivityInput(
@@ -92,38 +95,46 @@ class LowEndPipelineWorkflow:
garment_asset_id=payload.garment_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.SCENE
scene_result = await workflow.execute_activity(
run_scene_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.SCENE,
source_asset_id=tryon_result.asset_id,
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)
scene_source_asset_id = tryon_result.asset_id
if payload.scene_ref_asset_id is not None:
current_step = WorkflowStepName.SCENE
# scene 是可选步骤。
# 有场景图时,用 scene_ref_asset_id 把 tryon 结果合成到目标背景;
# 没有场景图时,直接沿用 tryon 结果继续往下走。
scene_result = await workflow.execute_activity(
run_scene_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.SCENE,
source_asset_id=tryon_result.asset_id,
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
scene_source_asset_id = scene_result.asset_id
current_step = WorkflowStepName.QC
# QC 是流程里的“闸门”。
# 如果这里不通过,低端流程直接失败,不会再 export。
# source_asset_id 指向当前链路里“最接近最终成品”的那张图:
# 有场景时是 scene 结果,没有场景时就是 tryon 结果。
qc_result = await workflow.execute_activity(
run_qc_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.QC,
source_asset_id=scene_result.asset_id,
source_asset_id=scene_source_asset_id,
),
task_queue=IMAGE_PIPELINE_QC_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_QC_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -134,16 +145,17 @@ class LowEndPipelineWorkflow:
current_step = WorkflowStepName.EXPORT
# candidate_asset_ids 是 QC 推荐可导出的候选资产。
# 当前 MVP 只会返回一个候选;如果没有,就退回 scene 结果导出。
# export 是最后的收口步骤:生成最终交付资产,并把订单状态写成 succeeded。
final_result = await workflow.execute_activity(
run_export_activity,
StepActivityInput(
order_id=payload.order_id,
workflow_run_id=payload.workflow_run_id,
step_name=WorkflowStepName.EXPORT,
source_asset_id=(qc_result.candidate_asset_ids or [scene_result.asset_id])[0],
source_asset_id=(qc_result.candidate_asset_ids or [scene_source_asset_id])[0],
),
task_queue=IMAGE_PIPELINE_EXPORT_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_EXPORT_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
return {
@@ -177,6 +189,6 @@ class LowEndPipelineWorkflow:
message=message,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=DEFAULT_ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)

View File

@@ -34,6 +34,7 @@ with workflow.unsafe.imports_passed_through():
from app.workers.activities.scene_activities import run_scene_activity
from app.workers.activities.texture_activities import run_texture_activity
from app.workers.activities.tryon_activities import prepare_model_activity, run_tryon_activity
from app.workers.workflows.timeout_policy import DEFAULT_ACTIVITY_TIMEOUT, activity_timeout_for_task_queue
from app.workers.workflows.types import (
MockActivityResult,
PipelineWorkflowInput,
@@ -44,8 +45,6 @@ with workflow.unsafe.imports_passed_through():
WorkflowFailureActivityInput,
)
ACTIVITY_TIMEOUT = timedelta(seconds=30)
ACTIVITY_RETRY_POLICY = RetryPolicy(
initial_interval=timedelta(seconds=1),
backoff_coefficient=2.0,
@@ -87,6 +86,8 @@ class MidEndPipelineWorkflow:
# current_step 用于失败时记录“最后跑到哪一步”。
current_step = WorkflowStepName.PREPARE_MODEL
try:
# prepare_model / tryon / scene 这三段和低端流程共享同一套 activity
# 区别只在于中端流程后面还会继续进入 texture / face / fusion / review。
prepared = await workflow.execute_activity(
prepare_model_activity,
StepActivityInput(
@@ -99,11 +100,12 @@ class MidEndPipelineWorkflow:
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_CONTROL_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.TRYON
# tryon 产出基础换装图,后面的所有增强步骤都以它为起点。
tryon_result = await workflow.execute_activity(
run_tryon_activity,
StepActivityInput(
@@ -114,23 +116,37 @@ class MidEndPipelineWorkflow:
garment_asset_id=payload.garment_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
current_step = WorkflowStepName.SCENE
scene_result = await self._run_scene(payload, tryon_result.asset_id)
scene_source_asset_id = tryon_result.asset_id
if payload.scene_ref_asset_id is not None:
current_step = WorkflowStepName.SCENE
# scene 仍然是可选步骤。
# 如果存在场景图,就先完成换背景,再把结果交给 texture / fusion
# 如果没有场景图,就直接把 tryon 结果当成“场景基底”继续流程。
scene_result = await self._run_scene(payload, tryon_result.asset_id)
scene_source_asset_id = scene_result.asset_id
else:
scene_result = tryon_result
current_step = WorkflowStepName.TEXTURE
texture_result = await self._run_texture(payload, scene_result.asset_id)
# texture 是复杂流独有步骤。
# 它通常负责衣物纹理、材质细节或局部增强,输入是当前场景基底图。
texture_result = await self._run_texture(payload, scene_source_asset_id)
current_step = WorkflowStepName.FACE
# face 也是复杂流独有步骤。
# 它在 texture 结果基础上做脸部修复/增强,产出供 fusion 合成的人像版本。
face_result = await self._run_face(payload, texture_result.asset_id)
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
# fusion 负责把“场景基底”和“脸部增强结果”合成成候选成品。
fusion_result = await self._run_fusion(payload, scene_source_asset_id, face_result.asset_id)
current_step = WorkflowStepName.QC
# QC 和低端流程复用同一套 activity但这里检查的是 fusion 产物。
qc_result = await self._run_qc(payload, fusion_result.asset_id)
if not qc_result.passed:
await self._mark_failed(payload, current_step, qc_result.message)
@@ -144,6 +160,8 @@ class MidEndPipelineWorkflow:
current_step = WorkflowStepName.REVIEW
# 这里通过 activity 把数据库里的订单状态更新成 waiting_review
# 同时创建 review_task供 API 查询待审核列表。
# review 是复杂流真正区别于低端流程的核心:
# 在 export 前插入人工决策点,并支持按意见回流重跑。
await workflow.execute_activity(
mark_waiting_for_review_activity,
ReviewWaitActivityInput(
@@ -152,7 +170,7 @@ class MidEndPipelineWorkflow:
candidate_asset_ids=qc_result.candidate_asset_ids,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_CONTROL_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -172,7 +190,7 @@ class MidEndPipelineWorkflow:
comment=review_payload.comment,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_CONTROL_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -180,6 +198,7 @@ class MidEndPipelineWorkflow:
current_step = WorkflowStepName.EXPORT
# 如果审核人显式选了资产,就导出该资产;
# 否则默认导出 QC 候选资产。
# export 本身仍然和低端流程是同一个 activity。
export_source_id = review_payload.selected_asset_id
if export_source_id is None:
export_source_id = (qc_result.candidate_asset_ids or [fusion_result.asset_id])[0]
@@ -192,7 +211,7 @@ class MidEndPipelineWorkflow:
source_asset_id=export_source_id,
),
task_queue=IMAGE_PIPELINE_EXPORT_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_EXPORT_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
return {
@@ -208,22 +227,30 @@ class MidEndPipelineWorkflow:
# rerun 的核心思想是:
# 把指定节点后的链路重新跑一遍,然后再次进入 QC 和 waiting_review。
if review_payload.decision == ReviewDecision.RERUN_SCENE:
current_step = WorkflowStepName.SCENE
scene_result = await self._run_scene(payload, tryon_result.asset_id)
# 从 scene 开始重跑意味着 scene / texture / face / fusion 全部重算。
if payload.scene_ref_asset_id is not None:
current_step = WorkflowStepName.SCENE
scene_result = await self._run_scene(payload, tryon_result.asset_id)
scene_source_asset_id = scene_result.asset_id
else:
scene_result = tryon_result
scene_source_asset_id = tryon_result.asset_id
current_step = WorkflowStepName.TEXTURE
texture_result = await self._run_texture(payload, scene_result.asset_id)
texture_result = await self._run_texture(payload, scene_source_asset_id)
current_step = WorkflowStepName.FACE
face_result = await self._run_face(payload, texture_result.asset_id)
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
fusion_result = await self._run_fusion(payload, scene_source_asset_id, face_result.asset_id)
elif review_payload.decision == ReviewDecision.RERUN_FACE:
# 从 face 开始重跑时scene / texture 结果保持不变。
current_step = WorkflowStepName.FACE
face_result = await self._run_face(payload, texture_result.asset_id)
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
fusion_result = await self._run_fusion(payload, scene_source_asset_id, face_result.asset_id)
elif review_payload.decision == ReviewDecision.RERUN_FUSION:
# 从 fusion 重跑是最小范围回流,只重做最终合成。
current_step = WorkflowStepName.FUSION
fusion_result = await self._run_fusion(payload, scene_result.asset_id, face_result.asset_id)
fusion_result = await self._run_fusion(payload, scene_source_asset_id, face_result.asset_id)
current_step = WorkflowStepName.QC
qc_result = await self._run_qc(payload, fusion_result.asset_id)
@@ -264,7 +291,7 @@ class MidEndPipelineWorkflow:
scene_ref_asset_id=payload.scene_ref_asset_id,
),
task_queue=IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -280,7 +307,7 @@ class MidEndPipelineWorkflow:
source_asset_id=source_asset_id,
),
task_queue=IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -296,7 +323,7 @@ class MidEndPipelineWorkflow:
source_asset_id=source_asset_id,
),
task_queue=IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -318,7 +345,7 @@ class MidEndPipelineWorkflow:
selected_asset_id=face_asset_id,
),
task_queue=IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -334,7 +361,7 @@ class MidEndPipelineWorkflow:
source_asset_id=source_asset_id,
),
task_queue=IMAGE_PIPELINE_QC_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=activity_timeout_for_task_queue(IMAGE_PIPELINE_QC_TASK_QUEUE),
retry_policy=ACTIVITY_RETRY_POLICY,
)
@@ -355,6 +382,6 @@ class MidEndPipelineWorkflow:
message=message,
),
task_queue=IMAGE_PIPELINE_CONTROL_TASK_QUEUE,
start_to_close_timeout=ACTIVITY_TIMEOUT,
start_to_close_timeout=DEFAULT_ACTIVITY_TIMEOUT,
retry_policy=ACTIVITY_RETRY_POLICY,
)

View File

@@ -0,0 +1,24 @@
"""Shared activity timeout policy for Temporal workflows."""
from datetime import timedelta
from app.infra.temporal.task_queues import (
IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
)
DEFAULT_ACTIVITY_TIMEOUT = timedelta(seconds=30)
LONG_RUNNING_ACTIVITY_TIMEOUT = timedelta(minutes=5)
LONG_RUNNING_TASK_QUEUES = {
IMAGE_PIPELINE_IMAGE_GEN_TASK_QUEUE,
IMAGE_PIPELINE_POST_PROCESS_TASK_QUEUE,
}
def activity_timeout_for_task_queue(task_queue: str) -> timedelta:
"""Return the timeout that matches the workload behind the given task queue."""
if task_queue in LONG_RUNNING_TASK_QUEUES:
return LONG_RUNNING_ACTIVITY_TIMEOUT
return DEFAULT_ACTIVITY_TIMEOUT

View File

@@ -32,14 +32,22 @@ class PipelineWorkflowInput:
这是一张订单进入 workflow 时携带的最小上下文。
"""
# 订单主键。整个 workflow 的所有步骤都会围绕这张订单落库、查状态。
order_id: int
# workflow_run 主键。用于把每一步 step 记录关联到同一次运行。
workflow_run_id: int
# 客户层级。决定业务侧订单归属,也会影响后续统计和展示。
customer_level: CustomerLevel
# 服务模式。这里直接决定启动简单流程还是复杂流程。
service_mode: ServiceMode
# 模特资源 ID。prepare_model 会基于它准备人物素材。
model_id: int
pose_id: int
# 服装资源/资产 ID。tryon 会把它作为换装输入。
garment_asset_id: int
scene_ref_asset_id: int
# 场景参考图 ID。可为空为空时跳过 scene 步骤。
scene_ref_asset_id: int | None = None
# 模特姿势 ID。当前 MVP 已经放宽为可空,后续需要姿势控制时再启用。
pose_id: int | None = None
def __post_init__(self) -> None:
"""在反序列化后把枚举字段修正回来。"""
@@ -59,15 +67,25 @@ class StepActivityInput:
- 上一步产出的 asset_id
"""
# 当前步骤属于哪张订单。
order_id: int
# 当前步骤属于哪次 workflow 运行。
workflow_run_id: int
# 当前执行的步骤名,例如 tryon / qc / export。
step_name: WorkflowStepName
# 模特资源 ID。只有 prepare_model 等少数步骤会直接消费它。
model_id: int | None = None
# 姿势 ID。当前大多为透传预留字段。
pose_id: int | None = None
# 服装资源/资产 ID。tryon 是主要消费者。
garment_asset_id: int | None = None
# 场景参考图 ID。scene 步骤会用它完成换背景。
scene_ref_asset_id: int | None = None
# 上一步产出的资产 ID。绝大多数步骤都靠它串起资产链路。
source_asset_id: int | None = None
# 人工选中的资产 ID。review / fusion / export 等需要显式选图时使用。
selected_asset_id: int | None = None
# 额外扩展参数。给特殊步骤塞一些不值得单独建字段的临时上下文。
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
@@ -80,14 +98,23 @@ class StepActivityInput:
class MockActivityResult:
"""通用 mock activity 返回结构。"""
# 这是哪一步返回的结果,方便 workflow 和落库代码识别来源。
step_name: WorkflowStepName
# activity 是否执行成功。一般表示“代码执行成功”,不等于业务一定通过。
success: bool
# 这一步新生成的资产 ID如果步骤不产出资产可以为空。
asset_id: int | None
# 产出资产的访问地址;当前 mock 阶段通常是 mock:// URI。
uri: str | None
# 模型分数/质量分数之类的数值结果,非所有步骤都有。
score: float | None = None
# 业务是否通过。典型场景是 QCactivity 成功执行,但 passed 可能为 False。
passed: bool | None = None
# 给 workflow 或 API 展示的简短结果消息。
message: str = "mock success"
# 候选资产列表。主要给 QC / review 这类“多候选图”步骤使用。
candidate_asset_ids: list[int] = field(default_factory=list)
# 补充元数据。用于把步骤内部的一些上下文回传给调用方。
metadata: dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
@@ -100,9 +127,13 @@ class MockActivityResult:
class ReviewSignalPayload:
"""API 发给中端 workflow 的审核 signal 载荷。"""
# 审核动作:通过 / 拒绝 / 从某一步重跑。
decision: ReviewDecision
# 操作审核的用户 ID便于审计和任务归属。
reviewer_id: int
# 审核人最终选中的候选资产 IDapprove 时最常见。
selected_asset_id: int | None = None
# 审核备注,例如“脸部不自然,重跑融合”。
comment: str | None = None
def __post_init__(self) -> None:
@@ -115,9 +146,13 @@ class ReviewSignalPayload:
class ReviewWaitActivityInput:
"""把流程切到 waiting_review 时传给 activity 的输入。"""
# 当前审核任务属于哪张订单。
order_id: int
# 当前审核任务属于哪次 workflow 运行。
workflow_run_id: int
# 提交给审核端看的候选资产列表。
candidate_asset_ids: list[int] = field(default_factory=list)
# 进入审核态时附带的说明文案,当前大多留空。
comment: str | None = None
@@ -125,11 +160,17 @@ class ReviewWaitActivityInput:
class ReviewResolutionActivityInput:
"""审核结果到达后,用于结束 waiting_review 的输入。"""
# 当前审核结果属于哪张订单。
order_id: int
# 当前审核结果属于哪次 workflow 运行。
workflow_run_id: int
# 审核最终决策。
decision: ReviewDecision
# 处理这次审核的审核人 ID。
reviewer_id: int
# 如果审核人明确挑了一张图,这里记录最终选择的资产 ID。
selected_asset_id: int | None = None
# 审核备注或重跑原因。
comment: str | None = None
def __post_init__(self) -> None:
@@ -142,10 +183,15 @@ class ReviewResolutionActivityInput:
class WorkflowFailureActivityInput:
"""流程失败收尾 activity 的输入。"""
# 失败发生在哪张订单。
order_id: int
# 失败发生在哪次 workflow 运行。
workflow_run_id: int
# 失败停留在哪个步骤,用于更新 workflow_run.current_step。
current_step: WorkflowStepName
# 失败原因文本,通常来自异常或 QC 驳回消息。
message: str
# 要写回数据库的最终状态,默认就是 failed。
status: OrderStatus = OrderStatus.FAILED
def __post_init__(self) -> None: