825 lines
34 KiB
Python
825 lines
34 KiB
Python
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
|