Blur Bake

This commit is contained in:
2026-02-16 13:51:25 +09:00
parent fc2dc0a478
commit 67178e0f52
10 changed files with 826 additions and 285 deletions
+1
View File
@@ -1,4 +1,5 @@
"""Core module exports."""
from .async_bake_generator import AsyncBakeGenerator, get_bake_generator
from .async_generator import AsyncMaskGenerator, get_generator
from .compositor_setup import create_mask_blur_node_tree, get_or_create_blur_node_tree
+161
View File
@@ -0,0 +1,161 @@
"""
Async blur bake generator using Thread + Queue + Timer pattern.
This module mirrors AsyncMaskGenerator behavior for bake-and-swap workflow,
so Blender UI remains responsive during server-side bake processing.
"""
import threading
import queue
from typing import Optional, Callable
# Will be imported when running inside Blender
bpy = None
class AsyncBakeGenerator:
"""Asynchronous bake generator for non-blocking blur bake tasks."""
def __init__(self):
self.result_queue: queue.Queue = queue.Queue()
self.progress_queue: queue.Queue = queue.Queue()
self.worker_thread: Optional[threading.Thread] = None
self.is_running: bool = False
self.total_frames: int = 0
self.current_frame: int = 0
self._on_complete: Optional[Callable] = None
self._on_progress: Optional[Callable] = None
def start(
self,
video_path: str,
mask_path: str,
output_path: str,
blur_size: int,
fmt: str,
on_complete: Optional[Callable] = None,
on_progress: Optional[Callable] = None,
):
"""Start asynchronous bake request and progress polling."""
global bpy
import bpy as _bpy
bpy = _bpy
if self.is_running:
raise RuntimeError("Blur bake already in progress")
self.is_running = True
self.total_frames = 0
self.current_frame = 0
self._on_complete = on_complete
self._on_progress = on_progress
self.worker_thread = threading.Thread(
target=self._worker,
args=(video_path, mask_path, output_path, blur_size, fmt),
daemon=True,
)
self.worker_thread.start()
bpy.app.timers.register(
self._check_progress,
first_interval=0.1,
)
def cancel(self):
"""Cancel the current bake processing."""
self.is_running = False
if self.worker_thread and self.worker_thread.is_alive():
self.worker_thread.join(timeout=2.0)
def _worker(
self,
video_path: str,
mask_path: str,
output_path: str,
blur_size: int,
fmt: str,
):
import time
from .inference_client import get_client
task_id = None
try:
client = get_client()
task_id = client.bake_blur(
video_path=video_path,
mask_path=mask_path,
output_path=output_path,
blur_size=blur_size,
fmt=fmt,
)
while self.is_running:
status = client.get_task_status(task_id)
state = status.get("status")
total = status.get("total", 0)
if total > 0:
self.total_frames = total
progress = status.get("progress", 0)
if progress >= 0:
self.progress_queue.put(("progress", progress))
if state == "completed":
result_path = status.get("result_path", output_path)
self.result_queue.put(("done", result_path))
return
if state == "failed":
error_msg = status.get("message", "Unknown server error")
self.result_queue.put(("error", error_msg))
return
if state == "cancelled":
self.result_queue.put(("cancelled", None))
return
time.sleep(0.5)
# Local cancel path
if task_id:
client.cancel_task(task_id)
self.result_queue.put(("cancelled", None))
except Exception as e:
self.result_queue.put(("error", str(e)))
def _check_progress(self) -> Optional[float]:
while not self.progress_queue.empty():
try:
msg_type, data = self.progress_queue.get_nowait()
if msg_type == "progress":
self.current_frame = data
if self._on_progress:
self._on_progress(self.current_frame, self.total_frames)
except queue.Empty:
break
if not self.result_queue.empty():
try:
msg_type, data = self.result_queue.get_nowait()
self.is_running = False
if self._on_complete:
self._on_complete(msg_type, data)
return None
except queue.Empty:
pass
if self.is_running:
return 0.1
return None
_bake_generator: Optional[AsyncBakeGenerator] = None
def get_bake_generator() -> AsyncBakeGenerator:
global _bake_generator
if _bake_generator is None:
_bake_generator = AsyncBakeGenerator()
return _bake_generator
+49 -2
View File
@@ -9,6 +9,7 @@ import json
import os
import signal
import subprocess
import sys
import threading
import time
import urllib.error
@@ -96,9 +97,16 @@ class InferenceClient:
self.log_file = open(self.log_file_path, "w", buffering=1) # Line buffered
print(f"[FaceMask] Server log: {self.log_file_path}")
# Start process with 'python' command (will use venv if PATH is set correctly)
# Start server with explicit Python executable when available.
python_executable = "python"
venv_python = os.path.join(venv_bin, "python")
if os.path.isfile(venv_python):
python_executable = venv_python
else:
python_executable = sys.executable
self.server_process = subprocess.Popen(
["python", "-u", server_script], # -u for unbuffered output
[python_executable, "-u", server_script], # -u for unbuffered output
cwd=root_dir,
text=True,
env=server_env,
@@ -241,6 +249,45 @@ class InferenceClient:
except urllib.error.HTTPError:
return {"status": "unknown"}
def bake_blur(
self,
video_path: str,
mask_path: str,
output_path: str,
blur_size: int,
fmt: str,
) -> str:
"""
Request blur bake for a source video + mask video.
Returns:
task_id (str)
"""
if not self.is_server_running():
self.start_server()
data = {
"video_path": video_path,
"mask_path": mask_path,
"output_path": output_path,
"blur_size": blur_size,
"format": fmt,
}
req = urllib.request.Request(
f"{self.SERVER_URL}/bake_blur",
data=json.dumps(data).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as response:
result = json.loads(response.read().decode("utf-8"))
return result["id"]
except urllib.error.HTTPError as e:
raise RuntimeError(f"Server error: {e.read().decode('utf-8')}")
def cancel_task(self, task_id: str):
"""Cancel a task."""
try:
+39 -21
View File
@@ -47,6 +47,37 @@ def get_server_status() -> Dict:
return result
def get_cache_root() -> str:
"""
Resolve cache root directory from scene setting or defaults.
Priority:
1) Scene setting: facemask_cache_dir (if non-empty)
2) Saved blend file directory + .mask_cache
3) Temp directory + blender_mask_cache
"""
import bpy
scene = getattr(bpy.context, "scene", None)
cache_setting = ""
if scene is not None:
cache_setting = (getattr(scene, "facemask_cache_dir", "") or "").strip()
if cache_setting:
return bpy.path.abspath(cache_setting)
blend_file = bpy.data.filepath
if blend_file:
project_dir = os.path.dirname(blend_file)
return os.path.join(project_dir, ".mask_cache")
return os.path.join(tempfile.gettempdir(), "blender_mask_cache")
def get_cache_dir_for_strip(strip_name: str) -> str:
"""Get cache directory path for a specific strip."""
return os.path.join(get_cache_root(), strip_name)
def get_cache_info(strip_name: Optional[str] = None) -> Tuple[str, int, int]:
"""
Get cache directory information.
@@ -59,22 +90,10 @@ def get_cache_info(strip_name: Optional[str] = None) -> Tuple[str, int, int]:
"""
import bpy
blend_file = bpy.data.filepath
if strip_name:
# Get cache for specific strip
if blend_file:
project_dir = os.path.dirname(blend_file)
cache_path = os.path.join(project_dir, ".mask_cache", strip_name)
else:
cache_path = os.path.join(tempfile.gettempdir(), "blender_mask_cache", strip_name)
cache_path = get_cache_dir_for_strip(strip_name)
else:
# Get cache root
if blend_file:
project_dir = os.path.dirname(blend_file)
cache_path = os.path.join(project_dir, ".mask_cache")
else:
cache_path = os.path.join(tempfile.gettempdir(), "blender_mask_cache")
cache_path = get_cache_root()
# Calculate size and count
total_size = 0
@@ -83,13 +102,12 @@ def get_cache_info(strip_name: Optional[str] = None) -> Tuple[str, int, int]:
if os.path.exists(cache_path):
for root, dirs, files in os.walk(cache_path):
for file in files:
if file.endswith('.png'): # Only count mask images
file_path = os.path.join(root, file)
try:
total_size += os.path.getsize(file_path)
file_count += 1
except OSError:
pass
file_path = os.path.join(root, file)
try:
total_size += os.path.getsize(file_path)
file_count += 1
except OSError:
pass
return cache_path, total_size, file_count