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