chore: something changed, commit before reorg

This commit is contained in:
Daniil
2026-04-27 23:19:04 +03:00
parent 259d3da89f
commit b9030a863e
19 changed files with 2753 additions and 146 deletions
@@ -0,0 +1 @@
"""Typed project workspace module."""
+30
View File
@@ -0,0 +1,30 @@
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, JSON
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from cpv3.db.base import Base, utcnow
STATE_JSON_TYPE = JSON().with_variant(JSONB(), "postgresql")
class ProjectWorkspace(Base):
__tablename__ = "project_workspaces"
project_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("projects.id", ondelete="CASCADE"),
primary_key=True,
)
revision: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
state: Mapped[dict] = mapped_column(STATE_JSON_TYPE, default=dict, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=utcnow,
onupdate=utcnow,
)
@@ -0,0 +1,74 @@
from __future__ import annotations
import uuid
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from cpv3.db.base import utcnow
from cpv3.modules.project_workspaces.models import ProjectWorkspace
class WorkspaceRevisionConflictError(RuntimeError):
"""Raised when the optimistic workspace revision check fails."""
class ProjectWorkspaceRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def get_by_project_id(self, project_id: uuid.UUID) -> ProjectWorkspace | None:
result = await self._session.execute(
select(ProjectWorkspace).where(ProjectWorkspace.project_id == project_id)
)
return result.scalar_one_or_none()
async def create(self, *, project_id: uuid.UUID, state: dict) -> ProjectWorkspace:
workspace = ProjectWorkspace(project_id=project_id, revision=0, state=state)
self._session.add(workspace)
await self._session.commit()
await self._session.refresh(workspace)
return workspace
async def get_or_create(self, *, project_id: uuid.UUID, state: dict) -> ProjectWorkspace:
workspace = await self.get_by_project_id(project_id)
if workspace is not None:
return workspace
try:
return await self.create(project_id=project_id, state=state)
except IntegrityError:
await self._session.rollback()
workspace = await self.get_by_project_id(project_id)
if workspace is None:
raise
return workspace
async def update_state(
self,
*,
project_id: uuid.UUID,
expected_revision: int,
state: dict,
) -> ProjectWorkspace:
stmt = (
update(ProjectWorkspace)
.where(ProjectWorkspace.project_id == project_id)
.where(ProjectWorkspace.revision == expected_revision)
.values(
state=state,
revision=expected_revision + 1,
updated_at=utcnow(),
)
)
result = await self._session.execute(stmt)
if result.rowcount != 1:
await self._session.rollback()
raise WorkspaceRevisionConflictError
await self._session.commit()
workspace = await self.get_by_project_id(project_id)
if workspace is None:
raise RuntimeError("Workspace disappeared after update")
return workspace
+78
View File
@@ -0,0 +1,78 @@
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from cpv3.db.session import get_db
from cpv3.infrastructure.auth import get_current_user
from cpv3.modules.projects.service import ProjectService
from cpv3.modules.project_workspaces.schemas import (
ProjectWorkspaceRead,
WorkflowActionRequest,
)
from cpv3.modules.project_workspaces.service import (
ProjectWorkspaceRevisionConflictError,
ProjectWorkspaceService,
ProjectWorkflowValidationError,
)
from cpv3.modules.users.models import User
router = APIRouter(prefix="/api/projects", tags=["Project Workspaces"])
@router.get("/{project_id}/workspace", response_model=ProjectWorkspaceRead)
@router.get(
"/{project_id}/workspace/",
response_model=ProjectWorkspaceRead,
include_in_schema=False,
)
async def get_project_workspace(
project_id: uuid.UUID,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ProjectWorkspaceRead:
project_service = ProjectService(db)
project = await project_service.get_project(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
workspace_service = ProjectWorkspaceService(db)
return await workspace_service.get_workspace(project=project)
@router.post("/{project_id}/workflow/actions", response_model=ProjectWorkspaceRead)
@router.post(
"/{project_id}/workflow/actions/",
response_model=ProjectWorkspaceRead,
include_in_schema=False,
)
async def dispatch_project_workflow_action(
project_id: uuid.UUID,
body: WorkflowActionRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> ProjectWorkspaceRead:
project_service = ProjectService(db)
project = await project_service.get_project(project_id)
if project is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Не найдено")
if not current_user.is_staff and project.owner_id != current_user.id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Доступ запрещён")
workspace_service = ProjectWorkspaceService(db)
try:
return await workspace_service.apply_action(
project=project,
requester=current_user,
action=body,
)
except ProjectWorkspaceRevisionConflictError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
except ProjectWorkflowValidationError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+471
View File
@@ -0,0 +1,471 @@
from __future__ import annotations
from enum import StrEnum
from typing import Annotated, Literal, get_args
from uuid import UUID
from pydantic import AliasChoices, Field, model_validator
from cpv3.common.schemas import Schema
from cpv3.modules.jobs.schemas import JobTypeEnum
WORKFLOW_VERSION = 1
VALID_JOB_TYPES = set(get_args(JobTypeEnum))
WorkspaceScreenEnum = Literal[
"upload",
"verify",
"silence-settings",
"processing",
"fragments",
"silence-apply-processing",
"transcription-settings",
"transcription-processing",
"subtitle-revision",
"caption-settings",
"caption-processing",
"caption-result",
]
class WorkflowPhase(StrEnum):
INGEST = "INGEST"
VERIFY = "VERIFY"
SILENCE = "SILENCE"
TRANSCRIPTION = "TRANSCRIPTION"
CAPTIONS = "CAPTIONS"
DONE = "DONE"
class SilenceWorkflowStatus(StrEnum):
IDLE = "IDLE"
CONFIGURED = "CONFIGURED"
DETECTING = "DETECTING"
REVIEWING = "REVIEWING"
APPLYING = "APPLYING"
COMPLETED = "COMPLETED"
SKIPPED = "SKIPPED"
class TranscriptionWorkflowStatus(StrEnum):
IDLE = "IDLE"
PROCESSING = "PROCESSING"
REVIEWING = "REVIEWING"
COMPLETED = "COMPLETED"
class CaptionsWorkflowStatus(StrEnum):
IDLE = "IDLE"
CONFIGURED = "CONFIGURED"
PROCESSING = "PROCESSING"
COMPLETED = "COMPLETED"
class ActiveJobState(Schema):
job_id: UUID
job_type: JobTypeEnum
class WorkspaceViewState(Schema):
used_file_ids: list[UUID] = Field(default_factory=list)
selected_file_id: UUID | None = None
class SilenceSettingsState(Schema):
min_silence_duration_ms: int = 200
silence_threshold_db: int = 16
padding_ms: int = 100
class CutRegionState(Schema):
start_ms: int
end_ms: int
class SilenceState(Schema):
status: SilenceWorkflowStatus = SilenceWorkflowStatus.IDLE
settings: SilenceSettingsState = Field(default_factory=SilenceSettingsState)
detect_job_id: UUID | None = None
detected_segments: list[CutRegionState] = Field(default_factory=list)
reviewed_cuts: list[CutRegionState] = Field(
default_factory=list,
validation_alias=AliasChoices("reviewed_cuts", "cut_regions"),
serialization_alias="reviewed_cuts",
)
duration_ms: int | None = None
applied_output_file_id: UUID | None = Field(
default=None,
validation_alias=AliasChoices("applied_output_file_id", "output_file_id"),
serialization_alias="applied_output_file_id",
)
class TranscriptionRequestState(Schema):
engine: Literal["whisper", "google", "salutespeech"] = "whisper"
language: str | None = None
model: str = "base"
class TranscriptionState(Schema):
status: TranscriptionWorkflowStatus = TranscriptionWorkflowStatus.IDLE
request: TranscriptionRequestState = Field(default_factory=TranscriptionRequestState)
job_id: UUID | None = None
artifact_id: UUID | None = None
transcription_id: UUID | None = None
reviewed: bool = False
class CaptionsState(Schema):
status: CaptionsWorkflowStatus = CaptionsWorkflowStatus.IDLE
preset_id: UUID | None = None
style_config: dict | None = None
render_job_id: UUID | None = Field(
default=None,
validation_alias=AliasChoices("render_job_id", "job_id"),
serialization_alias="render_job_id",
)
output_file_id: UUID | None = None
class ProjectWorkspaceState(Schema):
version: int = WORKFLOW_VERSION
phase: WorkflowPhase = WorkflowPhase.INGEST
active_job: ActiveJobState | None = None
source_file_id: UUID | None = None
workspace_view: WorkspaceViewState = Field(default_factory=WorkspaceViewState)
silence: SilenceState = Field(default_factory=SilenceState)
transcription: TranscriptionState = Field(default_factory=TranscriptionState)
captions: CaptionsState = Field(default_factory=CaptionsState)
class ProjectWorkspaceRead(Schema):
project_id: UUID
revision: int
version: int
phase: WorkflowPhase
current_screen: WorkspaceScreenEnum
active_job: ActiveJobState | None
source_file_id: UUID | None
workspace_view: WorkspaceViewState
silence: SilenceState
transcription: TranscriptionState
captions: CaptionsState
class WorkflowActionBase(Schema):
type: str
revision: int
class SetSourceFileAction(WorkflowActionBase):
type: Literal["SET_SOURCE_FILE"]
file_id: UUID = Field(
validation_alias=AliasChoices("file_id", "source_file_id"),
serialization_alias="file_id",
)
class ResetSourceFileAction(WorkflowActionBase):
type: Literal["RESET_SOURCE_FILE"]
class StartMediaConvertAction(WorkflowActionBase):
type: Literal["START_MEDIA_CONVERT"]
output_format: str = "mp4"
out_folder: str = "output_files"
class ConfirmVerifyAction(WorkflowActionBase):
type: Literal["CONFIRM_VERIFY"]
class SetSilenceSettingsAction(WorkflowActionBase):
type: Literal["SET_SILENCE_SETTINGS"]
settings: SilenceSettingsState = Field(default_factory=SilenceSettingsState)
@model_validator(mode="before")
@classmethod
def normalize_settings(cls, data: object) -> object:
if not isinstance(data, dict) or "settings" in data:
return data
return {
**data,
"settings": {
"min_silence_duration_ms": data.get("min_silence_duration_ms", 200),
"silence_threshold_db": data.get("silence_threshold_db", 16),
"padding_ms": data.get("padding_ms", 100),
},
}
class StartSilenceDetectAction(WorkflowActionBase):
type: Literal["START_SILENCE_DETECT"]
class SetSilenceCutsAction(WorkflowActionBase):
type: Literal["SET_SILENCE_CUTS"]
cuts: list[CutRegionState] = Field(
validation_alias=AliasChoices("cuts", "reviewed_cuts", "cut_regions"),
)
class SkipSilenceApplyAction(WorkflowActionBase):
type: Literal["SKIP_SILENCE_APPLY"]
class StartSilenceApplyAction(WorkflowActionBase):
type: Literal["START_SILENCE_APPLY"]
cuts: list[CutRegionState] | None = None
out_folder: str = "output_files"
output_name: str | None = None
class ReopenSilenceReviewAction(WorkflowActionBase):
type: Literal["REOPEN_SILENCE_REVIEW"]
class StartTranscriptionAction(WorkflowActionBase):
type: Literal["START_TRANSCRIPTION"]
engine: Literal["whisper", "google", "salutespeech"] = "whisper"
language: str | None = None
model: str = "base"
request: TranscriptionRequestState | None = None
@model_validator(mode="after")
def normalize_request(self) -> "StartTranscriptionAction":
if self.request is None:
self.request = TranscriptionRequestState(
engine=self.engine,
language=self.language,
model=self.model,
)
return self
self.engine = self.request.engine
self.language = self.request.language
self.model = self.request.model
return self
class ReopenTranscriptionConfigAction(WorkflowActionBase):
type: Literal["REOPEN_TRANSCRIPTION_CONFIG"]
class MarkTranscriptionReviewedAction(WorkflowActionBase):
type: Literal["MARK_TRANSCRIPTION_REVIEWED"]
class SelectCaptionPresetAction(WorkflowActionBase):
type: Literal["SELECT_CAPTION_PRESET"]
preset_id: UUID | None = None
style_config: dict | None = None
class StartCaptionRenderAction(WorkflowActionBase):
type: Literal["START_CAPTION_RENDER"]
folder: str = "output_files"
class ReopenCaptionConfigAction(WorkflowActionBase):
type: Literal["REOPEN_CAPTION_CONFIG"]
class SetWorkspaceViewAction(WorkflowActionBase):
type: Literal["SET_WORKSPACE_VIEW"]
workspace_view: WorkspaceViewState
@model_validator(mode="before")
@classmethod
def normalize_workspace_view(cls, data: object) -> object:
if not isinstance(data, dict) or "workspace_view" in data:
return data
return {
**data,
"workspace_view": {
"used_file_ids": data.get("used_file_ids", []),
"selected_file_id": data.get("selected_file_id"),
},
}
WorkflowActionRequest = Annotated[
(
SetSourceFileAction
| ResetSourceFileAction
| StartMediaConvertAction
| ConfirmVerifyAction
| SetSilenceSettingsAction
| StartSilenceDetectAction
| SetSilenceCutsAction
| SkipSilenceApplyAction
| StartSilenceApplyAction
| ReopenSilenceReviewAction
| StartTranscriptionAction
| ReopenTranscriptionConfigAction
| MarkTranscriptionReviewedAction
| SelectCaptionPresetAction
| StartCaptionRenderAction
| ReopenCaptionConfigAction
| SetWorkspaceViewAction
),
Field(discriminator="type"),
]
def build_default_workspace_state() -> ProjectWorkspaceState:
return ProjectWorkspaceState()
def build_workspace_state_from_legacy(
legacy_workspace_state: dict | None,
) -> ProjectWorkspaceState:
state = build_default_workspace_state()
if not isinstance(legacy_workspace_state, dict):
return state
wizard = legacy_workspace_state.get("wizard")
if not isinstance(wizard, dict):
wizard = {}
source_file_id = _parse_uuid(wizard.get("primary_file_id"))
if source_file_id is not None:
state.source_file_id = source_file_id
used_file_ids: list[UUID] = []
used_files = legacy_workspace_state.get("used_files")
if isinstance(used_files, list):
for item in used_files:
if not isinstance(item, dict):
continue
file_id = _parse_uuid(item.get("id"))
if file_id is not None and file_id not in used_file_ids:
used_file_ids.append(file_id)
if source_file_id is not None and source_file_id not in used_file_ids:
used_file_ids.insert(0, source_file_id)
state.workspace_view.used_file_ids = used_file_ids
if source_file_id is not None and source_file_id in used_file_ids:
state.workspace_view.selected_file_id = source_file_id
active_job_id = _parse_uuid(wizard.get("active_job_id"))
active_job_type = wizard.get("active_job_type")
if active_job_id is not None and active_job_type in VALID_JOB_TYPES:
state.active_job = ActiveJobState(
job_id=active_job_id,
job_type=active_job_type,
)
silence_job_id = _parse_uuid(wizard.get("silence_job_id"))
if silence_job_id is not None:
state.silence.detect_job_id = silence_job_id
transcription_artifact_id = _parse_uuid(wizard.get("transcription_artifact_id"))
if transcription_artifact_id is not None:
state.transcription.artifact_id = transcription_artifact_id
caption_preset_id = _parse_uuid(wizard.get("caption_preset_id"))
if caption_preset_id is not None:
state.captions.preset_id = caption_preset_id
caption_style_config = wizard.get("caption_style_config")
if isinstance(caption_style_config, dict):
state.captions.style_config = caption_style_config
captioned_video_file_id = _parse_uuid(wizard.get("captioned_video_file_id"))
if captioned_video_file_id is not None:
state.captions.output_file_id = captioned_video_file_id
silence_settings = wizard.get("silence_settings")
if isinstance(silence_settings, dict):
state.silence.settings = SilenceSettingsState.model_validate(silence_settings)
state.silence.status = SilenceWorkflowStatus.CONFIGURED
current_step = wizard.get("current_step")
step_phase_map = {
"upload": WorkflowPhase.INGEST,
"verify": WorkflowPhase.VERIFY,
"silence-settings": WorkflowPhase.SILENCE,
"processing": WorkflowPhase.SILENCE,
"fragments": WorkflowPhase.SILENCE,
"silence-apply-processing": WorkflowPhase.SILENCE,
"transcription-settings": WorkflowPhase.TRANSCRIPTION,
"transcription-processing": WorkflowPhase.TRANSCRIPTION,
"subtitle-revision": WorkflowPhase.TRANSCRIPTION,
"caption-settings": WorkflowPhase.CAPTIONS,
"caption-processing": WorkflowPhase.CAPTIONS,
"caption-result": WorkflowPhase.DONE,
}
if current_step in step_phase_map:
state.phase = step_phase_map[current_step]
if current_step == "processing":
state.silence.status = SilenceWorkflowStatus.DETECTING
elif current_step == "fragments":
state.silence.status = SilenceWorkflowStatus.REVIEWING
elif current_step == "silence-apply-processing":
state.silence.status = SilenceWorkflowStatus.APPLYING
elif current_step == "transcription-processing":
state.transcription.status = TranscriptionWorkflowStatus.PROCESSING
elif current_step == "subtitle-revision":
state.transcription.status = TranscriptionWorkflowStatus.REVIEWING
elif current_step == "caption-settings":
state.captions.status = CaptionsWorkflowStatus.CONFIGURED
elif current_step == "caption-processing":
state.captions.status = CaptionsWorkflowStatus.PROCESSING
elif current_step == "caption-result":
state.captions.status = CaptionsWorkflowStatus.COMPLETED
if state.active_job is not None:
if state.active_job.job_type == "MEDIA_CONVERT":
state.phase = WorkflowPhase.VERIFY
elif state.active_job.job_type == "SILENCE_DETECT":
state.phase = WorkflowPhase.SILENCE
state.silence.status = SilenceWorkflowStatus.DETECTING
state.silence.detect_job_id = state.active_job.job_id
elif state.active_job.job_type == "SILENCE_APPLY":
state.phase = WorkflowPhase.SILENCE
state.silence.status = SilenceWorkflowStatus.APPLYING
elif state.active_job.job_type == "TRANSCRIPTION_GENERATE":
state.phase = WorkflowPhase.TRANSCRIPTION
state.transcription.status = TranscriptionWorkflowStatus.PROCESSING
state.transcription.job_id = state.active_job.job_id
elif state.active_job.job_type == "CAPTIONS_GENERATE":
state.phase = WorkflowPhase.CAPTIONS
state.captions.status = CaptionsWorkflowStatus.PROCESSING
state.captions.render_job_id = state.active_job.job_id
if captioned_video_file_id is not None:
state.phase = WorkflowPhase.DONE
state.captions.status = CaptionsWorkflowStatus.COMPLETED
elif transcription_artifact_id is not None and (
state.transcription.status == TranscriptionWorkflowStatus.IDLE
):
state.phase = WorkflowPhase.TRANSCRIPTION
state.transcription.status = TranscriptionWorkflowStatus.REVIEWING
elif silence_job_id is not None and state.silence.status == SilenceWorkflowStatus.IDLE:
state.phase = WorkflowPhase.SILENCE
state.silence.status = SilenceWorkflowStatus.REVIEWING
elif source_file_id is not None and state.phase == WorkflowPhase.INGEST:
state.phase = WorkflowPhase.VERIFY
return state
def _parse_uuid(value: object) -> UUID | None:
if value is None:
return None
try:
return UUID(str(value))
except (TypeError, ValueError):
return None
# Backward-compatible aliases used by existing tests and frontend hand-written types.
TaskWorkflowActiveJob = ActiveJobState
SilenceSettingsPayload = SilenceSettingsState
WorkflowSilenceState = SilenceState
WorkflowTranscriptionRequest = TranscriptionRequestState
+824
View File
@@ -0,0 +1,824 @@
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from cpv3.modules.captions.models import CaptionPreset
from cpv3.modules.captions.repository import CaptionPresetRepository
from cpv3.modules.files.models import File
from cpv3.modules.files.repository import FileRepository
from cpv3.modules.jobs.models import Job
from cpv3.modules.media.repository import ArtifactRepository
from cpv3.modules.project_workspaces.models import ProjectWorkspace
from cpv3.modules.project_workspaces.repository import (
ProjectWorkspaceRepository,
WorkspaceRevisionConflictError as RepositoryWorkspaceRevisionConflictError,
)
from cpv3.modules.project_workspaces.schemas import (
ActiveJobState,
CaptionsState,
CaptionsWorkflowStatus,
ConfirmVerifyAction,
CutRegionState,
MarkTranscriptionReviewedAction,
ProjectWorkspaceRead,
ProjectWorkspaceState,
ReopenCaptionConfigAction,
ReopenSilenceReviewAction,
ReopenTranscriptionConfigAction,
ResetSourceFileAction,
SelectCaptionPresetAction,
SetSilenceCutsAction,
SetSilenceSettingsAction,
SetSourceFileAction,
SetWorkspaceViewAction,
SilenceState,
SilenceWorkflowStatus,
SkipSilenceApplyAction,
StartCaptionRenderAction,
StartMediaConvertAction,
StartSilenceApplyAction,
StartSilenceDetectAction,
StartTranscriptionAction,
TranscriptionState,
TranscriptionWorkflowStatus,
WorkflowActionRequest,
WorkflowPhase,
WorkspaceScreenEnum,
WorkspaceViewState,
build_workspace_state_from_legacy,
)
from cpv3.modules.projects.models import Project
from cpv3.modules.projects.repository import ProjectRepository
from cpv3.modules.tasks.schemas import (
CaptionsGenerateRequest,
MediaConvertRequest,
SilenceApplyRequest,
SilenceDetectRequest,
TranscriptionGenerateRequest,
)
from cpv3.modules.transcription.repository import TranscriptionRepository
from cpv3.modules.users.models import User
class ProjectWorkspaceRevisionConflictError(RuntimeError):
pass
class ProjectWorkflowValidationError(ValueError):
pass
WorkspaceRevisionConflictError = ProjectWorkspaceRevisionConflictError
@dataclass(slots=True)
class _WorkspaceItemValidationResult:
item_id: UUID
file: File | None = None
class ProjectWorkspaceService:
def __init__(self, session: AsyncSession) -> None:
self._session = session
self._repo = ProjectWorkspaceRepository(session)
self._project_repo = ProjectRepository(session)
self._file_repo = FileRepository(session)
self._artifact_repo = ArtifactRepository(session)
self._transcription_repo = TranscriptionRepository(session)
self._caption_preset_repo = CaptionPresetRepository(session)
self._task_service_factory = self._build_task_service
async def create_for_project(self, project: Project) -> ProjectWorkspaceRead:
workspace = await self._get_or_create_workspace(project=project)
return self._to_read_model(workspace)
async def get_workspace(self, *, project: Project) -> ProjectWorkspaceRead:
workspace = await self._get_or_create_workspace(project=project)
return self._to_read_model(workspace)
async def apply_action(
self,
*,
project: Project,
requester: User,
action: WorkflowActionRequest,
) -> ProjectWorkspaceRead:
workspace = await self._get_or_create_workspace(project=project)
if workspace.revision != action.revision:
raise ProjectWorkspaceRevisionConflictError("Версия рабочего пространства устарела")
state = ProjectWorkspaceState.model_validate(workspace.state)
next_state = await self._apply_action_to_state(
project=project,
requester=requester,
state=state,
action=action,
)
workspace = await self._save_workspace_state(
project_id=project.id,
expected_revision=workspace.revision,
state=next_state,
)
return self._to_read_model(workspace)
async def handle_job_update(self, *, job: Job) -> ProjectWorkspaceRead | None:
if job.project_id is None:
return None
project = await self._project_repo.get_by_id(job.project_id)
if project is None:
return None
workspace = await self._get_or_create_workspace(project=project)
state = ProjectWorkspaceState.model_validate(workspace.state)
next_state = self._apply_job_event_to_state(state, job)
if next_state.model_dump(mode="json") == state.model_dump(mode="json"):
return self._to_read_model(workspace)
try:
workspace = await self._save_workspace_state(
project_id=project.id,
expected_revision=workspace.revision,
state=next_state,
)
except ProjectWorkspaceRevisionConflictError:
workspace = await self._get_or_create_workspace(project=project)
return self._to_read_model(workspace)
async def apply_job_update(
self,
*,
project: Project,
job: Job,
) -> ProjectWorkspaceRead | None:
workspace = await self._get_or_create_workspace(project=project)
state = ProjectWorkspaceState.model_validate(workspace.state)
next_state = self._apply_job_event_to_state(state, job)
if next_state.model_dump(mode="json") == state.model_dump(mode="json"):
return self._to_read_model(workspace)
try:
workspace = await self._save_workspace_state(
project_id=project.id,
expected_revision=workspace.revision,
state=next_state,
)
except ProjectWorkspaceRevisionConflictError:
workspace = await self._get_or_create_workspace(project=project)
return self._to_read_model(workspace)
async def apply_job_event(self, job: Job) -> None:
await self.handle_job_update(job=job)
async def _get_or_create_workspace(self, *, project: Project) -> ProjectWorkspace:
workspace = await self._repo.get_by_project_id(project.id)
if workspace is not None:
return workspace
initial_state = build_workspace_state_from_legacy(getattr(project, "workspace_state", None))
state_payload = initial_state.model_dump(mode="json")
get_or_create = getattr(self._repo, "get_or_create", None)
if callable(get_or_create):
return await get_or_create(project_id=project.id, state=state_payload)
return await self._repo.create(project_id=project.id, state=state_payload)
async def _save_workspace_state(
self,
*,
project_id: UUID,
expected_revision: int,
state: ProjectWorkspaceState,
) -> ProjectWorkspace:
try:
return await self._repo.update_state(
project_id=project_id,
expected_revision=expected_revision,
state=state.model_dump(mode="json"),
)
except RepositoryWorkspaceRevisionConflictError as exc:
raise ProjectWorkspaceRevisionConflictError(
"Версия рабочего пространства устарела"
) from exc
async def _apply_action_to_state(
self,
*,
project: Project,
requester: User,
state: ProjectWorkspaceState,
action: WorkflowActionRequest,
) -> ProjectWorkspaceState:
next_state = state.model_copy(deep=True)
if isinstance(action, SetSourceFileAction):
file_result = await self._validate_accessible_file(
requester=requester,
project=project,
file_id=action.file_id,
)
next_state.phase = WorkflowPhase.VERIFY
next_state.active_job = None
next_state.source_file_id = file_result.item_id
next_state.silence = SilenceState()
next_state.transcription = TranscriptionState()
next_state.captions = CaptionsState()
next_state.workspace_view = WorkspaceViewState(
used_file_ids=[file_result.item_id],
selected_file_id=file_result.item_id,
)
return next_state
if isinstance(action, ResetSourceFileAction):
return ProjectWorkspaceState(version=state.version)
if isinstance(action, StartMediaConvertAction):
self._require_phase(next_state, WorkflowPhase.VERIFY)
source_file = await self._require_source_file(
next_state,
project=project,
requester=requester,
)
task_service = self._task_service_factory()
response = await task_service.submit_media_convert(
requester=requester,
request=MediaConvertRequest(
file_key=source_file.path,
out_folder=action.out_folder,
output_format=action.output_format,
project_id=project.id,
),
)
next_state.active_job = ActiveJobState(
job_id=response.job_id,
job_type="MEDIA_CONVERT",
)
return next_state
if isinstance(action, ConfirmVerifyAction):
self._require_phase(next_state, WorkflowPhase.VERIFY)
await self._require_source_file(
next_state,
project=project,
requester=requester,
)
next_state.phase = WorkflowPhase.SILENCE
next_state.active_job = None
next_state.silence.status = SilenceWorkflowStatus.CONFIGURED
return next_state
if isinstance(action, SetSilenceSettingsAction):
self._require_phase(next_state, WorkflowPhase.SILENCE)
if next_state.silence.status in {
SilenceWorkflowStatus.DETECTING,
SilenceWorkflowStatus.APPLYING,
}:
raise ProjectWorkflowValidationError("Нельзя менять настройки во время обработки")
next_state.silence.settings = action.settings
next_state.silence.status = SilenceWorkflowStatus.CONFIGURED
return next_state
if isinstance(action, StartSilenceDetectAction):
self._require_phase(next_state, WorkflowPhase.SILENCE)
source_file = await self._require_source_file(
next_state,
project=project,
requester=requester,
)
task_service = self._task_service_factory()
response = await task_service.submit_silence_detect(
requester=requester,
request=SilenceDetectRequest(
file_key=source_file.path,
project_id=project.id,
min_silence_duration_ms=next_state.silence.settings.min_silence_duration_ms,
silence_threshold_db=next_state.silence.settings.silence_threshold_db,
padding_ms=next_state.silence.settings.padding_ms,
),
)
next_state.active_job = ActiveJobState(
job_id=response.job_id,
job_type="SILENCE_DETECT",
)
next_state.silence.status = SilenceWorkflowStatus.DETECTING
next_state.silence.detect_job_id = response.job_id
next_state.silence.detected_segments = []
next_state.silence.reviewed_cuts = []
next_state.silence.duration_ms = None
next_state.silence.applied_output_file_id = None
return next_state
if isinstance(action, SetSilenceCutsAction):
self._require_phase(next_state, WorkflowPhase.SILENCE)
next_state.silence.reviewed_cuts = [
CutRegionState.model_validate(cut) for cut in action.cuts
]
next_state.silence.status = SilenceWorkflowStatus.REVIEWING
return next_state
if isinstance(action, SkipSilenceApplyAction):
self._require_phase(next_state, WorkflowPhase.SILENCE)
next_state.phase = WorkflowPhase.TRANSCRIPTION
next_state.active_job = None
next_state.silence.status = SilenceWorkflowStatus.SKIPPED
return next_state
if isinstance(action, StartSilenceApplyAction):
self._require_phase(next_state, WorkflowPhase.SILENCE)
source_file = await self._require_source_file(
next_state,
project=project,
requester=requester,
)
if action.cuts is not None:
next_state.silence.reviewed_cuts = [
CutRegionState.model_validate(cut) for cut in action.cuts
]
if not next_state.silence.reviewed_cuts:
raise ProjectWorkflowValidationError("Нет выбранных фрагментов для применения")
task_service = self._task_service_factory()
response = await task_service.submit_silence_apply(
requester=requester,
request=SilenceApplyRequest(
file_key=source_file.path,
out_folder=action.out_folder,
project_id=project.id,
output_name=action.output_name,
cuts=[cut.model_dump(mode="json") for cut in next_state.silence.reviewed_cuts],
),
)
next_state.active_job = ActiveJobState(
job_id=response.job_id,
job_type="SILENCE_APPLY",
)
next_state.silence.status = SilenceWorkflowStatus.APPLYING
return next_state
if isinstance(action, ReopenSilenceReviewAction):
if not next_state.silence.detected_segments:
raise ProjectWorkflowValidationError("Нет результатов детекции тишины")
next_state.phase = WorkflowPhase.SILENCE
next_state.active_job = None
next_state.silence.status = SilenceWorkflowStatus.REVIEWING
return next_state
if isinstance(action, StartTranscriptionAction):
if next_state.phase not in {WorkflowPhase.SILENCE, WorkflowPhase.TRANSCRIPTION}:
raise ProjectWorkflowValidationError("Транскрибация пока недоступна")
transcription_file = await self._resolve_transcription_input_file(
next_state,
project=project,
requester=requester,
)
request_payload = action.request or TranscriptionGenerateRequest(
engine=action.engine,
language=action.language,
model=action.model,
file_key=transcription_file.path,
project_id=project.id,
)
task_service = self._task_service_factory()
response = await task_service.submit_transcription_generate(
requester=requester,
request=TranscriptionGenerateRequest(
file_key=transcription_file.path,
project_id=project.id,
engine=request_payload.engine,
language=request_payload.language,
model=request_payload.model,
),
)
next_state.phase = WorkflowPhase.TRANSCRIPTION
next_state.active_job = ActiveJobState(
job_id=response.job_id,
job_type="TRANSCRIPTION_GENERATE",
)
next_state.transcription.status = TranscriptionWorkflowStatus.PROCESSING
next_state.transcription.request = action.request or next_state.transcription.request
next_state.transcription.job_id = response.job_id
next_state.transcription.artifact_id = None
next_state.transcription.transcription_id = None
next_state.transcription.reviewed = False
next_state.captions = CaptionsState()
return next_state
if isinstance(action, ReopenTranscriptionConfigAction):
if next_state.phase not in {
WorkflowPhase.TRANSCRIPTION,
WorkflowPhase.CAPTIONS,
WorkflowPhase.DONE,
}:
raise ProjectWorkflowValidationError("Нельзя вернуться к настройкам транскрибации")
next_state.phase = WorkflowPhase.TRANSCRIPTION
next_state.active_job = None
next_state.transcription = TranscriptionState(
request=next_state.transcription.request,
)
next_state.captions = CaptionsState()
return next_state
if isinstance(action, MarkTranscriptionReviewedAction):
self._require_phase(next_state, WorkflowPhase.TRANSCRIPTION)
if next_state.transcription.transcription_id is None:
raise ProjectWorkflowValidationError("Сначала завершите транскрибацию")
next_state.transcription.reviewed = True
next_state.transcription.status = TranscriptionWorkflowStatus.COMPLETED
next_state.phase = WorkflowPhase.CAPTIONS
if next_state.captions.status == CaptionsWorkflowStatus.IDLE:
next_state.captions.status = CaptionsWorkflowStatus.CONFIGURED
return next_state
if isinstance(action, SelectCaptionPresetAction):
if next_state.phase not in {WorkflowPhase.CAPTIONS, WorkflowPhase.DONE}:
raise ProjectWorkflowValidationError("Сначала завершите транскрибацию")
preset = await self._validate_caption_preset(
requester=requester,
preset_id=action.preset_id,
)
next_state.phase = WorkflowPhase.CAPTIONS
next_state.active_job = None
next_state.captions.preset_id = action.preset_id
next_state.captions.style_config = (
action.style_config
if action.style_config is not None
else (preset.style_config if preset is not None else None)
)
next_state.captions.render_job_id = None
next_state.captions.output_file_id = None
next_state.captions.status = CaptionsWorkflowStatus.CONFIGURED
return next_state
if isinstance(action, StartCaptionRenderAction):
if next_state.phase not in {WorkflowPhase.CAPTIONS, WorkflowPhase.DONE}:
raise ProjectWorkflowValidationError("Рендер субтитров пока недоступен")
if next_state.transcription.transcription_id is None:
raise ProjectWorkflowValidationError("Сначала завершите транскрибацию")
video_file = await self._resolve_caption_video_file(
next_state,
project=project,
requester=requester,
)
transcription = await self._transcription_repo.get_by_id(
next_state.transcription.transcription_id
)
if transcription is None:
raise ProjectWorkflowValidationError("Транскрипция не найдена")
task_service = self._task_service_factory()
response = await task_service.submit_captions_generate(
requester=requester,
request=CaptionsGenerateRequest(
video_s3_path=video_file.path,
folder=action.folder,
transcription_id=transcription.id,
project_id=project.id,
preset_id=next_state.captions.preset_id,
style_config=next_state.captions.style_config,
),
)
next_state.phase = WorkflowPhase.CAPTIONS
next_state.active_job = ActiveJobState(
job_id=response.job_id,
job_type="CAPTIONS_GENERATE",
)
next_state.captions.render_job_id = response.job_id
next_state.captions.output_file_id = None
next_state.captions.status = CaptionsWorkflowStatus.PROCESSING
return next_state
if isinstance(action, ReopenCaptionConfigAction):
if next_state.phase not in {WorkflowPhase.CAPTIONS, WorkflowPhase.DONE}:
raise ProjectWorkflowValidationError("Нельзя вернуться к настройкам рендера")
next_state.phase = WorkflowPhase.CAPTIONS
next_state.active_job = None
next_state.captions.render_job_id = None
next_state.captions.output_file_id = None
next_state.captions.status = CaptionsWorkflowStatus.CONFIGURED
return next_state
if isinstance(action, SetWorkspaceViewAction):
await self._validate_workspace_view_items(
requester=requester,
project=project,
used_file_ids=action.workspace_view.used_file_ids,
selected_file_id=action.workspace_view.selected_file_id,
)
next_state.workspace_view = action.workspace_view
return next_state
raise ProjectWorkflowValidationError("Неподдерживаемое действие")
def _apply_job_event_to_state(
self,
state: ProjectWorkspaceState,
job: Job,
) -> ProjectWorkspaceState:
next_state = state.model_copy(deep=True)
output_data = job.output_data or {}
if next_state.active_job is not None and next_state.active_job.job_id == job.id:
next_state.active_job = None
if job.status in {"FAILED", "CANCELLED"}:
if job.job_type == "MEDIA_CONVERT":
next_state.phase = WorkflowPhase.VERIFY
elif job.job_type == "SILENCE_DETECT":
next_state.phase = WorkflowPhase.SILENCE
next_state.silence.status = SilenceWorkflowStatus.CONFIGURED
next_state.silence.detect_job_id = None
elif job.job_type == "SILENCE_APPLY":
next_state.phase = WorkflowPhase.SILENCE
next_state.silence.status = (
SilenceWorkflowStatus.REVIEWING
if next_state.silence.detected_segments
else SilenceWorkflowStatus.CONFIGURED
)
elif job.job_type == "TRANSCRIPTION_GENERATE":
next_state.phase = WorkflowPhase.TRANSCRIPTION
next_state.transcription.status = TranscriptionWorkflowStatus.IDLE
next_state.transcription.job_id = None
elif job.job_type == "CAPTIONS_GENERATE":
next_state.phase = WorkflowPhase.CAPTIONS
next_state.captions.status = CaptionsWorkflowStatus.CONFIGURED
next_state.captions.render_job_id = None
return next_state
if job.status != "DONE":
return next_state
if job.job_type == "MEDIA_CONVERT":
converted_file_id = self._parse_uuid(output_data.get("file_id"))
if converted_file_id is None:
return next_state
next_state.source_file_id = converted_file_id
next_state.phase = WorkflowPhase.VERIFY
self._append_used_file(next_state, converted_file_id)
next_state.workspace_view.selected_file_id = converted_file_id
return next_state
if job.job_type == "SILENCE_DETECT":
silent_segments = output_data.get("silent_segments")
if not isinstance(silent_segments, list):
return next_state
cut_regions = [CutRegionState.model_validate(item) for item in silent_segments]
next_state.phase = WorkflowPhase.SILENCE
next_state.silence.detect_job_id = job.id
next_state.silence.detected_segments = cut_regions
next_state.silence.reviewed_cuts = cut_regions
next_state.silence.duration_ms = self._parse_int(output_data.get("duration_ms"))
next_state.silence.status = SilenceWorkflowStatus.REVIEWING
return next_state
if job.job_type == "SILENCE_APPLY":
output_file_id = self._parse_uuid(output_data.get("file_id"))
if output_file_id is None:
return next_state
next_state.phase = WorkflowPhase.TRANSCRIPTION
next_state.silence.applied_output_file_id = output_file_id
next_state.silence.status = SilenceWorkflowStatus.COMPLETED
self._append_used_file(next_state, output_file_id)
next_state.workspace_view.selected_file_id = output_file_id
return next_state
if job.job_type == "TRANSCRIPTION_GENERATE":
artifact_id = self._parse_uuid(output_data.get("artifact_id"))
transcription_id = self._parse_uuid(output_data.get("transcription_id"))
if artifact_id is None or transcription_id is None:
return next_state
next_state.phase = WorkflowPhase.TRANSCRIPTION
next_state.transcription.status = TranscriptionWorkflowStatus.REVIEWING
next_state.transcription.job_id = job.id
next_state.transcription.artifact_id = artifact_id
next_state.transcription.transcription_id = transcription_id
next_state.transcription.reviewed = False
return next_state
if job.job_type == "CAPTIONS_GENERATE":
output_file_id = self._parse_uuid(output_data.get("file_id"))
if output_file_id is None:
return next_state
next_state.phase = WorkflowPhase.DONE
next_state.captions.status = CaptionsWorkflowStatus.COMPLETED
next_state.captions.render_job_id = job.id
next_state.captions.output_file_id = output_file_id
self._append_used_file(next_state, output_file_id)
next_state.workspace_view.selected_file_id = output_file_id
return next_state
return next_state
def _to_read_model(self, workspace: ProjectWorkspace) -> ProjectWorkspaceRead:
state = ProjectWorkspaceState.model_validate(workspace.state)
return ProjectWorkspaceRead(
project_id=workspace.project_id,
revision=workspace.revision,
version=state.version,
phase=state.phase,
current_screen=self._derive_current_screen(state),
active_job=state.active_job,
source_file_id=state.source_file_id,
workspace_view=state.workspace_view,
silence=state.silence,
transcription=state.transcription,
captions=state.captions,
)
def _derive_current_screen(self, state: ProjectWorkspaceState) -> WorkspaceScreenEnum:
if state.phase == WorkflowPhase.INGEST:
return "upload"
if state.phase == WorkflowPhase.VERIFY:
return "verify"
if state.phase == WorkflowPhase.SILENCE:
if state.silence.status == SilenceWorkflowStatus.DETECTING:
return "processing"
if state.silence.status == SilenceWorkflowStatus.REVIEWING:
return "fragments"
if state.silence.status == SilenceWorkflowStatus.APPLYING:
return "silence-apply-processing"
return "silence-settings"
if state.phase == WorkflowPhase.TRANSCRIPTION:
if state.transcription.status == TranscriptionWorkflowStatus.PROCESSING:
return "transcription-processing"
if state.transcription.status in {
TranscriptionWorkflowStatus.REVIEWING,
TranscriptionWorkflowStatus.COMPLETED,
}:
return "subtitle-revision"
return "transcription-settings"
if state.phase == WorkflowPhase.CAPTIONS:
if state.captions.status == CaptionsWorkflowStatus.PROCESSING:
return "caption-processing"
if state.captions.status == CaptionsWorkflowStatus.COMPLETED:
return "caption-result"
return "caption-settings"
return "caption-result"
async def _validate_accessible_file(
self,
*,
requester: User,
project: Project,
file_id: UUID,
) -> _WorkspaceItemValidationResult:
file = await self._file_repo.get_by_id(file_id)
if file is None:
raise ProjectWorkflowValidationError("Файл не найден")
if file.project_id not in {None, project.id}:
raise ProjectWorkflowValidationError("Файл не относится к текущему проекту")
if not requester.is_staff and file.owner_id not in {None, requester.id}:
raise ProjectWorkflowValidationError("Файл недоступен")
return _WorkspaceItemValidationResult(item_id=file.id, file=file)
async def _require_source_file(
self,
state: ProjectWorkspaceState,
*,
project: Project,
requester: User,
) -> File:
if state.source_file_id is None:
raise ProjectWorkflowValidationError("Сначала выберите исходный файл")
validation = await self._validate_accessible_file(
requester=requester,
project=project,
file_id=state.source_file_id,
)
if validation.file is None:
raise ProjectWorkflowValidationError("Исходный файл не найден")
return validation.file
async def _resolve_transcription_input_file(
self,
state: ProjectWorkspaceState,
*,
project: Project,
requester: User,
) -> File:
if state.silence.applied_output_file_id is not None:
validation = await self._validate_accessible_file(
requester=requester,
project=project,
file_id=state.silence.applied_output_file_id,
)
if validation.file is not None:
return validation.file
return await self._require_source_file(
state,
project=project,
requester=requester,
)
async def _resolve_caption_video_file(
self,
state: ProjectWorkspaceState,
*,
project: Project,
requester: User,
) -> File:
return await self._resolve_transcription_input_file(
state,
project=project,
requester=requester,
)
async def _validate_caption_preset(
self,
*,
requester: User,
preset_id: UUID | None,
) -> CaptionPreset | None:
if preset_id is None:
return None
preset = await self._caption_preset_repo.get_by_id(preset_id)
if preset is None:
raise ProjectWorkflowValidationError("Пресет субтитров не найден")
if not requester.is_staff and not preset.is_system and preset.user_id != requester.id:
raise ProjectWorkflowValidationError("Пресет субтитров недоступен")
return preset
async def _validate_workspace_view_items(
self,
*,
requester: User,
project: Project,
used_file_ids: list[UUID],
selected_file_id: UUID | None,
) -> None:
if selected_file_id is not None and selected_file_id not in used_file_ids:
raise ProjectWorkflowValidationError(
"Выбранный файл должен входить в список используемых файлов"
)
seen: set[UUID] = set()
for item_id in used_file_ids:
if item_id in seen:
continue
seen.add(item_id)
await self._validate_workspace_item(
requester=requester,
project=project,
item_id=item_id,
)
async def _validate_workspace_item(
self,
*,
requester: User,
project: Project,
item_id: UUID,
) -> None:
file = await self._file_repo.get_by_id(item_id)
if file is not None:
if file.project_id not in {None, project.id}:
raise ProjectWorkflowValidationError("Файл не относится к текущему проекту")
if not requester.is_staff and file.owner_id not in {None, requester.id}:
raise ProjectWorkflowValidationError("Файл недоступен")
return
artifact = await self._artifact_repo.get_by_id(item_id)
if artifact is None or artifact.project_id not in {None, project.id}:
raise ProjectWorkflowValidationError("Элемент рабочего пространства не найден")
def _build_task_service(self):
from cpv3.modules.tasks.service import TaskService
return TaskService(self._session)
def _require_phase(
self,
state: ProjectWorkspaceState,
required_phase: WorkflowPhase,
) -> None:
if state.phase != required_phase:
raise ProjectWorkflowValidationError(
f"Ожидалась фаза {required_phase}, текущая фаза {state.phase}"
)
def _append_used_file(
self,
state: ProjectWorkspaceState,
file_id: UUID,
) -> None:
if file_id not in state.workspace_view.used_file_ids:
state.workspace_view.used_file_ids.append(file_id)
def _parse_int(self, value: object) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _parse_uuid(self, value: object) -> UUID | None:
if value is None:
return None
try:
return UUID(str(value))
except (TypeError, ValueError):
return None