Files
wehub-resource-sync 6ede33ccdb
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled
Obsidian Plugin Lint / lint (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:33:44 +08:00

254 lines
9.6 KiB
Python

"""Celery tasks for video presentation generation."""
import asyncio
import logging
import sys
from contextlib import asynccontextmanager
from sqlalchemy import select
from app.agents.video_presentation.graph import graph as video_presentation_graph
from app.agents.video_presentation.state import State as VideoPresentationState
from app.celery_app import celery_app
from app.config import config as app_config
from app.db import VideoPresentation, VideoPresentationStatus
from app.services.billable_calls import (
BillingSettlementError,
QuotaInsufficientError,
_resolve_agent_billing_for_workspace,
billable_call,
)
from app.tasks.celery_tasks import get_celery_session_maker, run_async_celery_task
logger = logging.getLogger(__name__)
if sys.platform.startswith("win"):
try:
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
except AttributeError:
logger.warning(
"WindowsProactorEventLoopPolicy is unavailable; async subprocess support may fail."
)
@asynccontextmanager
async def _celery_billable_session():
"""Session factory used by billable_call inside the Celery worker loop."""
async with get_celery_session_maker()() as session:
yield session
@celery_app.task(name="generate_video_presentation", bind=True)
def generate_video_presentation_task(
self,
video_presentation_id: int,
source_content: str,
workspace_id: int,
user_prompt: str | None = None,
) -> dict:
"""
Celery task to generate video presentation from source content.
Updates existing video presentation record created by the tool.
"""
try:
return run_async_celery_task(
lambda: _generate_video_presentation(
video_presentation_id,
source_content,
workspace_id,
user_prompt,
)
)
except Exception as e:
logger.error(f"Error generating video presentation: {e!s}")
# Mark FAILED in a fresh loop — the previous loop is closed.
# Swallow secondary failures; the row will simply stay in
# GENERATING and be flushed by the periodic stale cleanup.
try:
run_async_celery_task(
lambda: _mark_video_presentation_failed(video_presentation_id)
)
except Exception:
logger.exception(
"Failed to mark video presentation %s as failed",
video_presentation_id,
)
return {"status": "failed", "video_presentation_id": video_presentation_id}
async def _mark_video_presentation_failed(video_presentation_id: int) -> None:
"""Mark a video presentation as failed in the database."""
async with get_celery_session_maker()() as session:
try:
result = await session.execute(
select(VideoPresentation).filter(
VideoPresentation.id == video_presentation_id
)
)
video_pres = result.scalars().first()
if video_pres:
video_pres.status = VideoPresentationStatus.FAILED
await session.commit()
except Exception as e:
logger.error(f"Failed to mark video presentation as failed: {e}")
async def _generate_video_presentation(
video_presentation_id: int,
source_content: str,
workspace_id: int,
user_prompt: str | None = None,
) -> dict:
"""Generate video presentation and update existing record."""
async with get_celery_session_maker()() as session:
result = await session.execute(
select(VideoPresentation).filter(
VideoPresentation.id == video_presentation_id
)
)
video_pres = result.scalars().first()
if not video_pres:
raise ValueError(f"VideoPresentation {video_presentation_id} not found")
try:
video_pres.status = VideoPresentationStatus.GENERATING
await session.commit()
try:
(
owner_user_id,
billing_tier,
base_model,
) = await _resolve_agent_billing_for_workspace(
session,
workspace_id,
thread_id=video_pres.thread_id,
)
except ValueError as resolve_err:
logger.error(
"VideoPresentation %s: cannot resolve billing for workspace=%s: %s",
video_pres.id,
workspace_id,
resolve_err,
)
video_pres.status = VideoPresentationStatus.FAILED
await session.commit()
return {
"status": "failed",
"video_presentation_id": video_pres.id,
"reason": "billing_resolution_failed",
}
graph_config = {
"configurable": {
"video_title": video_pres.title,
"workspace_id": workspace_id,
"user_prompt": user_prompt,
}
}
initial_state = VideoPresentationState(
source_content=source_content,
db_session=session,
)
try:
async with billable_call(
user_id=owner_user_id,
workspace_id=workspace_id,
billing_tier=billing_tier,
base_model=base_model,
quota_reserve_micros_override=app_config.QUOTA_DEFAULT_VIDEO_PRESENTATION_RESERVE_MICROS,
usage_type="video_presentation_generation",
call_details={
"video_presentation_id": video_pres.id,
"title": video_pres.title,
"thread_id": video_pres.thread_id,
},
billable_session_factory=_celery_billable_session,
):
graph_result = await video_presentation_graph.ainvoke(
initial_state, config=graph_config
)
except QuotaInsufficientError as exc:
logger.info(
"VideoPresentation %s denied: out of credits "
"(balance=%d remaining=%d)",
video_pres.id,
exc.balance_micros,
exc.remaining_micros,
)
video_pres.status = VideoPresentationStatus.FAILED
await session.commit()
return {
"status": "failed",
"video_presentation_id": video_pres.id,
"reason": "premium_quota_exhausted",
}
except BillingSettlementError:
logger.exception(
"VideoPresentation %s: premium billing settlement failed",
video_pres.id,
)
video_pres.status = VideoPresentationStatus.FAILED
await session.commit()
return {
"status": "failed",
"video_presentation_id": video_pres.id,
"reason": "billing_settlement_failed",
}
# Serialize slides (parsed content + audio info merged)
slides_raw = graph_result.get("slides", [])
audio_results_raw = graph_result.get("slide_audio_results", [])
scene_codes_raw = graph_result.get("slide_scene_codes", [])
audio_map = {}
for ar in audio_results_raw:
data = ar.model_dump() if hasattr(ar, "model_dump") else ar
audio_map[data.get("slide_number", 0)] = data
serializable_slides = []
for slide in slides_raw:
slide_data = (
slide.model_dump() if hasattr(slide, "model_dump") else dict(slide)
)
audio_data = audio_map.get(slide_data.get("slide_number", 0), {})
slide_data["audio_file"] = audio_data.get("audio_file")
slide_data["duration_seconds"] = audio_data.get("duration_seconds")
slide_data["duration_in_frames"] = audio_data.get("duration_in_frames")
serializable_slides.append(slide_data)
serializable_scene_codes = []
for sc in scene_codes_raw:
sc_data = sc.model_dump() if hasattr(sc, "model_dump") else dict(sc)
serializable_scene_codes.append(sc_data)
video_pres.slides = serializable_slides
video_pres.scene_codes = serializable_scene_codes
video_pres.status = VideoPresentationStatus.READY
logger.info(
"VideoPresentation %s: committing READY slides=%d scene_codes=%d",
video_pres.id,
len(serializable_slides),
len(serializable_scene_codes),
)
await session.commit()
logger.info("VideoPresentation %s: READY commit complete", video_pres.id)
logger.info(f"Successfully generated video presentation: {video_pres.id}")
return {
"status": "ready",
"video_presentation_id": video_pres.id,
"title": video_pres.title,
"slide_count": len(serializable_slides),
}
except Exception as e:
logger.error(f"Error in _generate_video_presentation: {e!s}")
video_pres.status = VideoPresentationStatus.FAILED
await session.commit()
raise