Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 71 additions & 24 deletions app/_launch_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -559,7 +559,11 @@ def _coordinated_generation_slot(
def _is_durable_generation_job(job: dict) -> bool:
"""Only persist ordinary Studio generations, not Director sub-jobs."""
params = job.get("params") if isinstance(job.get("params"), dict) else {}
return bool(params.get("model_type")) and not params.get("_director_pipeline_id")
return (
bool(params.get("model_type"))
and not params.get("_director_pipeline_id")
and not params.get("_non_durable_tool")
)


def _persist_generation_job(job: dict) -> None:
Expand Down Expand Up @@ -783,6 +787,7 @@ def _generation_job_acceptance(job: dict) -> dict:
"minimax:image-01": "MiniMax Image-01",
"minimax_h3_legacy": "MiniMax H3 Legacy Quality — ConvRot",
"post_processing": "Post-processing",
"rembg-u2net": "rembg U2Net",
}


Expand Down Expand Up @@ -14963,33 +14968,21 @@ def _get_recast_u2net_session():
model_home = os.path.join(_app_dir, "ckpts", "rembg")
os.makedirs(model_home, exist_ok=True)
os.environ.setdefault("U2NET_HOME", model_home)
import onnxruntime as _ort
from rembg import new_session
from services.rembg_adapter import background_session

print(
"[Recast] Loading U2Net edge refinement on CPU "
"(SAM3 remains the person selector)"
)
# This rembg release ignores a caller-supplied providers list and
# chooses CUDA whenever the GPU build of ONNX Runtime is present.
# That both spends generation VRAM and emits a scary DLL error on
# systems whose ORT CUDA version differs from PyTorch. Report CPU
# only for the brief session-construction call; the resulting ORT
# session then remains explicitly CPU-backed for its lifetime.
original_get_device = _ort.get_device
try:
_ort.get_device = lambda: "CPU"
_recast_u2net_session = new_session("u2net")
finally:
_ort.get_device = original_get_device
"(SAM3 remains the person selector; shared rembg adapter)"
)
_recast_u2net_session = background_session("u2net", force_cpu=True)
return _recast_u2net_session


def _run_recast_u2net_matte(reference_frame, reference_path=None):
"""Return decontaminated foreground RGB and alpha, with a small cache."""
import numpy as np
from PIL import Image as _PILImage
from rembg import remove
from services.rembg_adapter import remove_background_image

frame = np.asarray(reference_frame, dtype=np.uint8)
cache_key = None
Expand All @@ -15011,8 +15004,9 @@ def _run_recast_u2net_matte(reference_frame, reference_path=None):

session = _get_recast_u2net_session()
with _recast_u2net_run_lock:
cutout = remove(
cutout = remove_background_image(
_PILImage.fromarray(frame),
model="u2net",
session=session,
alpha_matting=True,
alpha_matting_erode_size=1,
Expand Down Expand Up @@ -35773,18 +35767,21 @@ def _publish_generation_task(job: dict) -> dict:
[] if status in {"created", "queued", "waiting_resource"}
else ["remote:https://api.minimax.io" if is_remote else _local_gpu_lane.key]
)
task_title = {
"image": "Image generation", "video": "Video generation",
"audio": "Audio generation", "music": "Music generation",
"model3d": "3D generation", "avatar": "Video edit",
}.get(mode, "Generation job")
if str(provenance.get("capability") or "") == "remove_background":
task_title = "Tools · Remove background"
return _upsert_canonical_task(
workspace,
task_id,
root_id=root_task_id,
parent_id=parent_task_id,
kind=mode,
workflow="generation",
title={
"image": "Image generation", "video": "Video generation",
"audio": "Audio generation", "music": "Music generation",
"model3d": "3D generation", "avatar": "Video edit",
}.get(mode, "Generation job"),
title=task_title,
status=status,
phase=str(job.get("phase") or status),
message=str(job.get("message") or status.replace("_", " ").title()),
Expand Down Expand Up @@ -36283,6 +36280,56 @@ def _control_canonical_task(task: dict, action: str):
uploads_dir=lambda: os.path.join(os.getcwd(), "uploads"),
))

from routers.tools import create_tools_router
from shared.tools.background_removal_job import (
BackgroundRemovalJobHooks,
run_remove_background_job,
)


def _start_remove_background_job(job: dict) -> None:
"""Start the Tools worker with the shared generation lifecycle seams."""
def worker() -> None:
hooks = BackgroundRemovalJobHooks(
generation_slot=lambda current: _coordinated_generation_slot(
current,
description="HocusPocus Lab GPU tool · background removal",
),
try_start=try_start,
update_job=update_job,
finish_job=finish_job,
is_cancel_requested=is_cancel_requested,
record_job_outputs=record_job_outputs,
register_abort_state=register_abort_state,
unregister_abort_state=unregister_abort_state,
acknowledge_cancel=acknowledge_cancel,
active_states=_active_gen_states,
publish_sidecar=lambda current, path, sidecar: _publish_generation_sidecar_for_studio_job(
current,
path,
sidecar,
tool="tools",
),
simulated_artifact=execution_mode.create_artifact,
)
run_remove_background_job(job, hooks=hooks)

threading.Thread(
target=worker,
name=f"remove-background-{job.get('id', 'job')}",
daemon=False,
).start()


api.include_router(create_tools_router(
get_active_workspace=_get_active_workspace,
list_workspaces=_list_workspaces,
workspace_dir=_workspace_dir,
uploads_dir=lambda: os.path.join(os.getcwd(), "uploads"),
register_job=_register_manual_generation_job,
start_remove_background=_start_remove_background_job,
))

from routers.projects import create_projects_router

api.include_router(create_projects_router(
Expand Down
29 changes: 29 additions & 0 deletions app/docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -697,6 +697,35 @@ status = requests.get(f"{base}/api/v1/video-editor/export/{job['job_id']}").json

`POST /api/v1/video-editor/screenshot` writes a PNG (`generation_mode: "image"`) at `{source, time, name?, workspace?}`. Character Creator uses it for Hunyuan views.

## Image background removal

`POST /api/v1/tools/remove-background` queues a standalone image tool job. It
uses the shared rembg U2Net adapter, never overwrites the source, and publishes
the transparent PNG plus a canonical `.meta.json` asset manifest in the
destination workspace. Use an exact `asset_id` from `GET /api/v1/assets?kind=image`
whenever possible; `source` may be the exact filename, an `/api/v1/file/...`
URL, or an absolute path already inside the selected uploads/workspace root.
`source_workspace` is required when the source belongs to another output
folder. Poll `GET /api/v1/status/{job_id}` and cancel with
`POST /api/v1/cancel/{job_id}`.

```bash
curl -X POST "$HOCUSPOCUS_URL/api/v1/tools/remove-background" \
-H "Content-Type: application/json" \
-d '{
"asset_id": "asset_image_123",
"workspace": "default",
"instruction": "preserve the hair edges",
"provenance": {"actor": "user"}
}'
```

The response is accepted immediately with `job_id`, canonical task IDs and
frozen model details (`rembg-u2net`). The Activity/task record reports queued,
running, completed, failed or cancelled state; the derived asset exposes the
source asset ID, tool/capability, instruction, model/backend, timings and
transparent-PNG technical metadata.

## Gallery mix kinds

`GET /api/v1/outputs` accepts `result_kind=music_video|trailer|series_episode` (plus the existing `media_type`, `multiclip_only`, `favorites_only`, `search`, `workspace`, `limit`, `offset`). Classification lives in `services.output_result_kind` and applies only to **assembled** filenames (`multiclip`, `_mv.mp4`, `_movie.mp4`, `_rejoin_multiclip.mp4`, `_series_assembly`). Requesting `series_episode` also matches `chapter`. When `result_kind` is set, pagination is bypassed and every match is returned.
Expand Down
114 changes: 114 additions & 0 deletions app/routers/tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""HTTP boundary for standalone image tools."""

from __future__ import annotations

import uuid
from collections.abc import Callable, Iterable, Mapping
from typing import Any

from fastapi import APIRouter

from services.asset_catalog import find_asset
from services.generation_provenance import normalize_submission_provenance
from shared.tools.background_removal_job import build_background_removal_job
from shared.tools.background_removal_request import (
RemoveBackgroundRequest,
destination_context,
job_response,
resolve_source,
)


def create_tools_router(
*,
get_active_workspace: Callable[[], str],
list_workspaces: Callable[[], Iterable[Mapping[str, Any]]],
workspace_dir: Callable[[str], str],
uploads_dir: Callable[[], str],
asset_finder: Callable[[str], Mapping[str, Any] | None] | None = None,
register_job: Callable[[dict[str, Any]], dict[str, Any]],
start_remove_background: Callable[[dict[str, Any]], None],
) -> APIRouter:
router = APIRouter(prefix="/api/v1/tools", tags=["Tools"])

def roots() -> list[dict[str, str]]:
result: list[dict[str, str]] = []
seen: set[str] = set()
for item in list_workspaces():
name = str(item.get("name") or "").strip() if isinstance(item, Mapping) else ""
if not name or name in seen:
continue
try:
path = workspace_dir(name)
except Exception:
# Workspace registries can retain a stale entry while its
# output directory is being removed. Asset lookup should
# continue over the healthy roots instead of failing the
# whole Tools request.
continue
result.append({"workspace_id": name, "path": path})
seen.add(name)
result.append({"workspace_id": "__uploads__", "path": uploads_dir()})
return result

def find_source_asset(asset_id: str) -> Mapping[str, Any] | None:
if asset_finder is not None:
return asset_finder(asset_id)
return find_asset(roots(), asset_id)

@router.post("/remove-background")
def remove_background(payload: RemoveBackgroundRequest):
destination_workspace, output_dir = destination_context(
payload,
get_active_workspace=get_active_workspace,
list_workspaces=list_workspaces,
workspace_dir=workspace_dir,
)
source_path, source_filename, source_workspace = resolve_source(
payload,
destination_workspace=destination_workspace,
workspace_dir=workspace_dir,
uploads_dir=uploads_dir,
asset_finder=find_source_asset,
)
source_asset_id = payload.asset_id or (
"asset_unmanaged_"
+ uuid.uuid5(
uuid.NAMESPACE_URL,
f"hocuspocus:unmanaged:{source_workspace}:{source_filename}",
).hex
)
# Capability must be present before normalize: the Tools panel only
# sends `{actor: user}`, and a later stamp would leave tool=studio.
provenance = normalize_submission_provenance({
**payload.provenance,
"capability": "remove_background",
})
job_id = uuid.uuid4().hex[:8]
source_root = output_dir if source_workspace == "__uploads__" else workspace_dir(source_workspace)
job = build_background_removal_job(
job_id=job_id,
workspace=destination_workspace,
output_dir=output_dir,
source_path=source_path,
source_filename=source_filename,
source_workspace=source_workspace,
source_asset_id=source_asset_id,
uploads_root=uploads_dir(),
source_root=source_root,
provenance=provenance,
instruction=payload.instruction.strip(),
)
accepted = register_job(job)
start_remove_background(accepted)
return job_response(
accepted,
fallback_id=job_id,
source_asset_id=source_asset_id,
source_filename=source_filename,
)

return router


__all__ = ["RemoveBackgroundRequest", "create_tools_router"]
18 changes: 17 additions & 1 deletion app/services/asset_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,7 @@ def adapt_legacy_sidecar(
"effective": params.get("prompt") or params.get("video_prompt"),
"negative": params.get("negative_prompt"),
"audio": params.get("audio_prompt"),
"instruction": params.get("instruction"),
"language": params.get("language") or params.get("prompt_language"),
}
model = {
Expand Down Expand Up @@ -374,6 +375,18 @@ def adapt_legacy_sidecar(
or params.get("director_pipeline_id")
or params.get("_director_pipeline_id")
)
inputs = legacy.get("inputs")
if not isinstance(inputs, Sequence) or isinstance(inputs, (str, bytes, bytearray)):
inputs = params.get("inputs")
parents = legacy.get("parents")
if not isinstance(parents, Sequence) or isinstance(parents, (str, bytes, bytearray)):
parents = params.get("parents")
transformations = legacy.get("transformations")
if not isinstance(transformations, Sequence) or isinstance(transformations, (str, bytes, bytearray)):
transformations = params.get("transformations")
technical = legacy.get("technical")
if not isinstance(technical, Mapping):
technical = params.get("technical")
stable_workspace = workspace_id or legacy.get("workspace") or params.get("workspace")
stable_folder = (
output_folder
Expand Down Expand Up @@ -412,7 +425,10 @@ def adapt_legacy_sidecar(
"completed_at": completed_at,
"inference_ms": inference_ms,
},
technical={"legacy_sidecar": True},
inputs=inputs,
parents=parents,
transformations=transformations,
technical={"legacy_sidecar": True, **dict(technical or {})},
error=legacy.get("error") if isinstance(legacy.get("error"), Mapping) else None,
)

Expand Down
13 changes: 8 additions & 5 deletions app/services/character_kit_face_cleanup.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,14 @@ def crop_to_alpha(image: Image.Image, *, padding: int = DEFAULT_PADDING) -> Imag


def _rembg_remove(image: Image.Image) -> Image.Image:
from rembg import remove
cleaned = remove(image.convert("RGBA"))
if not isinstance(cleaned, Image.Image):
cleaned = Image.open(cleaned).convert("RGBA")
return cleaned.convert("RGBA")
# Keep Face Rig and the general Tools operation on the same cached U2Net
# adapter. The wrapper remains a named seam for existing tests/callers.
from services.rembg_adapter import remove_background_image

# Historical rembg defaults: no alpha matting and no bgcolor composite.
# A white transparent bgcolor mixes edge pixels toward white and leaves
# a halo when the overlay is placed over a face.
return remove_background_image(image, alpha_matting=False, bgcolor=None)


def clean_character_kit_overlay(
Expand Down
1 change: 1 addition & 0 deletions app/services/generation_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
_TRUSTED_TOOL_BY_CAPABILITY = {
"generate_story_song": "story_lab",
"start_director_production": "director",
"remove_background": "tools",
}
_TASK_ENTITY_FIELDS = (
("production", "production_id"),
Expand Down
Loading