From 20928e9a60f6df8f054e2c43bd2f08adb880cfe5 Mon Sep 17 00:00:00 2001 From: Daniil Date: Mon, 27 Apr 2026 23:28:28 +0300 Subject: [PATCH] chore: something changed, commit before reorg --- .../CaptionResultStep/CaptionResultStep.tsx | 71 +- .../CaptionSettingsStep.tsx | 132 +-- .../project/FragmentsStep/FragmentsStep.tsx | 87 +- .../SilenceResultModal/SilenceResultModal.tsx | 13 +- .../SilenceSettingsStep.tsx | 65 +- .../SubtitleRevisionStep.tsx | 61 +- .../TranscriptionSettingsStep.tsx | 47 +- .../project/UploadStep/UploadStep.tsx | 12 +- .../project/VerifyStep/VerifyStep.tsx | 68 +- src/shared/api/__generated__/openapi.types.ts | 485 ++++++++- src/shared/api/projectWorkflow.ts | 246 +++++ src/shared/context/SocketProvider.tsx | 7 + src/shared/context/WizardContext.tsx | 986 ++++++++++-------- src/shared/context/WorkspaceContext.tsx | 287 ++--- .../specs/project/caption-settings.spec.ts | 284 ++--- tests/e2e/specs/project/silence-apply.spec.ts | 378 ++++--- 16 files changed, 1967 insertions(+), 1262 deletions(-) create mode 100644 src/shared/api/projectWorkflow.ts diff --git a/src/features/project/CaptionResultStep/CaptionResultStep.tsx b/src/features/project/CaptionResultStep/CaptionResultStep.tsx index 80b8655..28ec307 100644 --- a/src/features/project/CaptionResultStep/CaptionResultStep.tsx +++ b/src/features/project/CaptionResultStep/CaptionResultStep.tsx @@ -43,57 +43,16 @@ export const CaptionResultStep: FunctionComponent = ({ className, }): JSX.Element => { const { - projectId, captionedVideoFileId, - captionedVideoPath, - goToStep, markStepCompleted, - setCaptionedVideoFileId, - setCaptionedVideoPath, + reopenCaptionConfig, } = useWizard() - // Recovery: if wizard state lost the file data, look up the latest caption job - const needsRecovery = !captionedVideoFileId && !captionedVideoPath - const { data: jobs } = api.useQuery( - "get", - "/api/jobs/jobs/", - {}, - { enabled: needsRecovery }, - ) - - const recoveredJob = useMemo(() => { - if (!needsRecovery || !jobs) return null - return jobs.find( - (j) => - j.project_id === projectId && - j.job_type === "CAPTIONS_GENERATE" && - j.status === "DONE" && - j.output_data?.file_id, - ) - }, [needsRecovery, jobs, projectId]) - - const effectiveFileId = - captionedVideoFileId ?? - (recoveredJob?.output_data?.file_id as string | undefined) ?? - null - const effectivePath = - captionedVideoPath ?? - (recoveredJob?.output_data?.output_path as string | undefined) ?? - null - - // Persist recovered values back to wizard state - if (recoveredJob && !captionedVideoFileId && effectiveFileId) { - setCaptionedVideoFileId(effectiveFileId) - } - if (recoveredJob && !captionedVideoPath && effectivePath) { - setCaptionedVideoPath(effectivePath) - } - const { data: fileInfo, isLoading } = api.useQuery( "get", "/api/files/files/{file_id}/resolve/", - { params: { path: { file_id: effectiveFileId ?? "" } } }, - { enabled: !!effectiveFileId }, + { params: { path: { file_id: captionedVideoFileId ?? "" } } }, + { enabled: !!captionedVideoFileId }, ) const videoUrl = fileInfo?.file_url ?? "" @@ -107,7 +66,7 @@ export const CaptionResultStep: FunctionComponent = ({ } const handleRerender = () => { - goToStep("caption-settings") + void reopenCaptionConfig() } const handleFinish = () => { @@ -210,21 +169,21 @@ export const CaptionResultStep: FunctionComponent = ({ {/* Footer */}
- -
- +
+ - + +
- ) } diff --git a/src/features/project/CaptionSettingsStep/CaptionSettingsStep.tsx b/src/features/project/CaptionSettingsStep/CaptionSettingsStep.tsx index 2f2ac25..3cae8b5 100644 --- a/src/features/project/CaptionSettingsStep/CaptionSettingsStep.tsx +++ b/src/features/project/CaptionSettingsStep/CaptionSettingsStep.tsx @@ -6,142 +6,58 @@ import type { JSX } from "react" import { FunctionComponent, - useEffect, - useMemo, useRef, useState, } from "react" import cs from "classnames" -import api from "@shared/api" import { useWizard } from "@shared/context/WizardContext" import { Button } from "@shared/ui" import { PresetGrid } from "./PresetGrid" import { StyleEditor } from "./StyleEditor" -import { useSubmitCaptionGenerate } from "./useSubmitCaptionGenerate" import styles from "./CaptionSettingsStep.module.scss" type CaptionPresetRead = components["schemas"]["CaptionPresetRead"] const ERROR_SUBMIT = "Не удалось запустить генерацию субтитров" -const ERROR_MISSING_DATA = - "Для генерации субтитров необходимы видеофайл и транскрипция. Пройдите предыдущие шаги." -const TRANSCRIPTION_ARTIFACT_TYPE = "TRANSCRIPTION_JSON" export const CaptionSettingsStep: FunctionComponent< ICaptionSettingsStepProps > = ({ className }): JSX.Element => { const { - projectId, - primaryFileKey, - transcriptionArtifactId: contextArtifactId, captionPresetId, - setCaptionPresetId, - setTranscriptionArtifactId, - startProcessingJob, + selectCaptionPreset, + startCaptionRender, goBack, } = useWizard() - const { data: artifacts, isLoading: isArtifactsLoading } = api.useQuery( - "get", - "/api/media/artifacts/", - {}, - { enabled: !contextArtifactId }, - ) - - const transcriptionArtifactId = useMemo(() => { - if (contextArtifactId) return contextArtifactId - if (!artifacts) return null - - const match = artifacts.find( - (artifact) => - artifact.project_id === projectId && - artifact.artifact_type === TRANSCRIPTION_ARTIFACT_TYPE && - !artifact.is_deleted, - ) - - return match?.id ?? null - }, [artifacts, contextArtifactId, projectId]) - - useEffect(() => { - if ( - !transcriptionArtifactId || - transcriptionArtifactId === contextArtifactId - ) { - return - } - - setTranscriptionArtifactId(transcriptionArtifactId) - }, [ - contextArtifactId, - setTranscriptionArtifactId, - transcriptionArtifactId, - ]) - - const { data: transcriptionEntry, isLoading: isTranscriptionLoading } = - api.useQuery( - "get", - "/api/transcribe/transcriptions/by-artifact/{artifact_id}/", - { - params: { - path: { artifact_id: transcriptionArtifactId ?? "" }, - }, - }, - { enabled: !!transcriptionArtifactId }, - ) - const [activeTab, setActiveTab] = useState<"select" | "editor">("select") const [editingPreset, setEditingPreset] = useState( null, ) const [submitError, setSubmitError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) const submitLockRef = useRef(false) - const isResolvingSourceData = isArtifactsLoading || isTranscriptionLoading - const { mutate, isPending } = useSubmitCaptionGenerate({ - onSuccess: (data) => { - if (!data?.job_id) { - submitLockRef.current = false - return - } - - if (data?.job_id) { - startProcessingJob( - data.job_id, - "CAPTIONS_GENERATE", - "caption-processing", - "caption-settings", - ) - } - }, - onError: () => { - submitLockRef.current = false - setSubmitError(ERROR_SUBMIT) - }, - }) - - const handleGenerate = () => { - if (submitLockRef.current || isPending) return - - const transcriptionId = transcriptionEntry?.id - if (!primaryFileKey || !transcriptionId) { - setSubmitError(ERROR_MISSING_DATA) - return - } + const handleGenerate = async () => { + if (submitLockRef.current || isSubmitting) return + if (!captionPresetId) return submitLockRef.current = true setSubmitError(null) - mutate({ - body: { - video_s3_path: primaryFileKey, - folder: "output_files", - transcription_id: transcriptionId, - project_id: projectId, - preset_id: captionPresetId, - }, - }) + setIsSubmitting(true) + + try { + await startCaptionRender() + submitLockRef.current = false + } catch { + submitLockRef.current = false + setSubmitError(ERROR_SUBMIT) + } finally { + setIsSubmitting(false) + } } const handleEdit = (preset: CaptionPresetRead) => { @@ -155,10 +71,14 @@ export const CaptionSettingsStep: FunctionComponent< } const handleSaved = (presetId: string) => { - setCaptionPresetId(presetId) + void selectCaptionPreset(presetId) setActiveTab("select") } + const handleSelectPreset = (presetId: string | null) => { + void selectCaptionPreset(presetId) + } + if (activeTab === "editor") { return (
@@ -202,11 +122,9 @@ export const CaptionSettingsStep: FunctionComponent<
diff --git a/src/features/project/FragmentsStep/FragmentsStep.tsx b/src/features/project/FragmentsStep/FragmentsStep.tsx index 51a4857..344f77a 100644 --- a/src/features/project/FragmentsStep/FragmentsStep.tsx +++ b/src/features/project/FragmentsStep/FragmentsStep.tsx @@ -23,12 +23,11 @@ import { import WaveSurfer from "wavesurfer.js" import api from "@shared/api" +import { useProjectWorkspaceQuery } from "@shared/api/projectWorkflow" import { useWizard } from "@shared/context/WizardContext" import { useSegmentResize } from "@shared/hooks/useSegmentResize" import { Button } from "@shared/ui" -import { useSubmitSilenceApply } from "../SilenceResultModal/useSubmitSilenceApply" - import styles from "./FragmentsStep.module.scss" const MIN_REGION_MS = 100 @@ -71,14 +70,12 @@ export const FragmentsStep: FunctionComponent = ({ }): JSX.Element => { const { projectId, - silenceJobId, primaryFileId, - primaryFileKey, - startProcessingJob, + startSilenceApply, + skipSilenceApply, goBack, - markStepCompleted, - goToStep, } = useWizard() + const { data: workspace } = useProjectWorkspaceQuery(projectId) const [cutRegions, setCutRegions] = useState([]) const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PPS) @@ -94,16 +91,7 @@ export const FragmentsStep: FunctionComponent = ({ const waveformRef = useRef(null) const wsRef = useRef(null) - /* ---- Data loading ---- */ - const { data: taskStatus } = api.useQuery( - "get", - "/api/tasks/status/{job_id}/", - { params: { path: { job_id: silenceJobId ?? "" } } }, - { enabled: !!silenceJobId }, - ) - - const outputData = taskStatus?.output_data as Record | null - const fileKey = primaryFileKey ?? ((outputData?.file_key as string) ?? "") + const silenceState = workspace?.silence const { data: fileInfo } = api.useQuery( "get", @@ -116,11 +104,12 @@ export const FragmentsStep: FunctionComponent = ({ /* ---- Initialize cut regions from detection results ---- */ useEffect(() => { - if (!outputData) return - const segments = outputData.silent_segments as - | { start_ms: number; end_ms: number }[] - | undefined - const dur = outputData.duration_ms as number | undefined + if (!silenceState) return + const segments = + silenceState.reviewed_cuts.length > 0 + ? silenceState.reviewed_cuts + : silenceState.detected_segments + const dur = silenceState.duration_ms if (segments && dur) { setDurationMs(dur) @@ -132,8 +121,7 @@ export const FragmentsStep: FunctionComponent = ({ })), ) } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [outputData]) + }, [silenceState]) /* ---- Timeline calculations ---- */ const totalWidth = Math.max(1, (durationMs / 1000) * pixelsPerSecond) @@ -599,49 +587,30 @@ export const FragmentsStep: FunctionComponent = ({ } }, [drawRuler, drawFrames]) - /* ---- Apply ---- */ - const { mutate: applyMutate, isPending: isApplying } = - useSubmitSilenceApply({ - onSuccess: (data) => { - const result = data as { job_id?: string } - if (result?.job_id) { - startProcessingJob( - result.job_id, - "SILENCE_APPLY", - "silence-apply-processing", - "fragments", - ) - } - }, - onError: (error) => { - console.error("Silence apply failed:", error) - }, - }) + const [isApplying, setIsApplying] = useState(false) - const handleApply = () => { + const handleApply = async () => { if (cutRegions.length === 0) { - markStepCompleted("fragments") - goToStep("transcription-settings") + setIsApplying(true) + try { + await skipSilenceApply() + } finally { + setIsApplying(false) + } return } - if (!fileKey) return - - const fileName = fileKey.split("/").pop() ?? "video.mp4" - const outputName = `Без тишины ${fileName}` - - ;(applyMutate as (args: { body: Record }) => void)({ - body: { - file_key: fileKey, - out_folder: "", - project_id: projectId, - output_name: outputName, - cuts: cutRegions.map((r) => ({ + setIsApplying(true) + try { + await startSilenceApply( + cutRegions.map((r) => ({ start_ms: Math.round(r.startMs), end_ms: Math.round(r.endMs), })), - }, - }) + ) + } finally { + setIsApplying(false) + } } return ( diff --git a/src/features/project/SilenceResultModal/SilenceResultModal.tsx b/src/features/project/SilenceResultModal/SilenceResultModal.tsx index aaec491..75329cf 100644 --- a/src/features/project/SilenceResultModal/SilenceResultModal.tsx +++ b/src/features/project/SilenceResultModal/SilenceResultModal.tsx @@ -23,6 +23,7 @@ import { import WaveSurfer from "wavesurfer.js" import api from "@shared/api" +import { useProjectWorkspaceQuery } from "@shared/api/projectWorkflow" import { useSegmentResize } from "@shared/hooks/useSegmentResize" import { Button, Modal } from "@shared/ui" @@ -70,6 +71,7 @@ export const SilenceResultModal: FunctionComponent = ( projectId, jobId, }): JSX.Element => { + const { data: workspace } = useProjectWorkspaceQuery(projectId) const [cutRegions, setCutRegions] = useState([]) const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PPS) const [durationMs, setDurationMs] = useState(0) @@ -95,16 +97,7 @@ export const SilenceResultModal: FunctionComponent = ( const outputData = taskStatus?.output_data as Record | null const fileKey = (outputData?.file_key as string) ?? "" - const { data: project } = api.useQuery( - "get", - "/api/projects/{project_id}/", - { params: { path: { project_id: projectId } } }, - { enabled: open }, - ) - - const primaryFileId = - (project?.workspace_state as { wizard?: { primary_file_id?: string | null } } | null) - ?.wizard?.primary_file_id ?? null + const primaryFileId = workspace?.source_file_id ?? null const { data: fileInfo } = api.useQuery( "get", diff --git a/src/features/project/SilenceSettingsStep/SilenceSettingsStep.tsx b/src/features/project/SilenceSettingsStep/SilenceSettingsStep.tsx index 1f6920e..91c0811 100644 --- a/src/features/project/SilenceSettingsStep/SilenceSettingsStep.tsx +++ b/src/features/project/SilenceSettingsStep/SilenceSettingsStep.tsx @@ -4,57 +4,32 @@ import type { ISilenceSettingsStepProps } from "./SilenceSettingsStep.d" import type { JSX } from "react" import cs from "classnames" -import { FunctionComponent, useCallback } from "react" +import { FunctionComponent, useCallback, useEffect, useState } from "react" import { useWizard } from "@shared/context/WizardContext" import { Button, Slider } from "@shared/ui" -import { useSubmitSilenceDetect } from "../SilenceSettingsModal/useSubmitSilenceDetect" - import styles from "./SilenceSettingsStep.module.scss" export const SilenceSettingsStep: FunctionComponent< ISilenceSettingsStepProps > = ({ className }): JSX.Element => { const { - projectId, primaryFileKey, silenceSettings, - setSilenceSettings, - startProcessingJob, + startSilenceDetect, goBack, } = useWizard() + const [localSettings, setLocalSettings] = useState(silenceSettings) - const { mutate, isPending } = useSubmitSilenceDetect({ - onSuccess: (data) => { - const result = data as { job_id?: string } - if (result?.job_id) { - startProcessingJob( - result.job_id, - "SILENCE_DETECT", - "processing", - "silence-settings", - ) - } - }, - onError: (error) => { - console.error("Silence detect submit failed:", error) - }, - }) + useEffect(() => { + setLocalSettings(silenceSettings) + }, [silenceSettings]) const handleSubmit = useCallback(() => { if (!primaryFileKey) return - - ;(mutate as (args: { body: Record }) => void)({ - body: { - file_key: primaryFileKey, - project_id: projectId, - min_silence_duration_ms: silenceSettings.min_silence_duration_ms, - silence_threshold_db: silenceSettings.silence_threshold_db, - padding_ms: silenceSettings.padding_ms, - }, - }) - }, [mutate, primaryFileKey, projectId, silenceSettings]) + void startSilenceDetect(localSettings) + }, [localSettings, primaryFileKey, startSilenceDetect]) return (
- setSilenceSettings({ - ...silenceSettings, + setLocalSettings({ + ...localSettings, min_silence_duration_ms: v, }) } @@ -89,15 +64,15 @@ export const SilenceSettingsStep: FunctionComponent< - setSilenceSettings({ - ...silenceSettings, + setLocalSettings({ + ...localSettings, silence_threshold_db: v, }) } @@ -105,15 +80,15 @@ export const SilenceSettingsStep: FunctionComponent< - setSilenceSettings({ - ...silenceSettings, + setLocalSettings({ + ...localSettings, padding_ms: v, }) } @@ -123,15 +98,15 @@ export const SilenceSettingsStep: FunctionComponent< {/* Footer */}
-
diff --git a/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx b/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx index eef08b9..e9b5ea5 100644 --- a/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx +++ b/src/features/project/SubtitleRevisionStep/SubtitleRevisionStep.tsx @@ -15,13 +15,10 @@ import { import "@vidstack/react/player/styles/default/theme.css" import "@vidstack/react/player/styles/default/layouts/video.css" import cs from "classnames" -import { FunctionComponent, useEffect, useMemo, useRef } from "react" +import { FunctionComponent, useEffect, useRef } from "react" import api from "@shared/api" -import { - StaticWorkspaceProvider, - useWorkspaceFiles, -} from "@shared/context/WorkspaceContext" +import { useWorkspaceFiles } from "@shared/context/WorkspaceContext" import { useWizard } from "@shared/context/WizardContext" import { Button } from "@shared/ui" import { TranscriptionEditor } from "@features/project" @@ -29,8 +26,6 @@ import { TimelinePanel } from "@widgets/TimelinePanel" import styles from "./SubtitleRevisionStep.module.scss" -const TRANSCRIPTION_ARTIFACT_TYPE = "TRANSCRIPTION_JSON" - /** * Auto-initializes WorkspaceContext with the video file * and transcription artifact so TimelinePanel and @@ -87,51 +82,15 @@ const SubtitleRevisionContent: FunctionComponent<{ projectId, videoUrl, primaryFileKey, - transcriptionArtifactId: contextArtifactId, - setTranscriptionArtifactId, + transcriptionArtifactId, goBack, - goToStep, - markStepCompleted, + markTranscriptionReviewed, } = useWizard() - - const { data: artifacts, isLoading: isArtifactsLoading } = api.useQuery( - "get", - "/api/media/artifacts/", - {}, - { enabled: !contextArtifactId }, - ) - - const transcriptionArtifactId = useMemo(() => { - if (contextArtifactId) return contextArtifactId - if (!artifacts) return null - const match = artifacts.find( - (a) => - a.project_id === projectId && - a.artifact_type === TRANSCRIPTION_ARTIFACT_TYPE && - !a.is_deleted, - ) - return match?.id ?? null - }, [contextArtifactId, artifacts, projectId]) - const isArtifactResolving = !contextArtifactId && isArtifactsLoading + const isArtifactResolving = false const isTranscriptionReady = Boolean(transcriptionArtifactId) const isTranscriptionUnavailable = !isTranscriptionReady && !isArtifactResolving - useEffect(() => { - if ( - !transcriptionArtifactId || - transcriptionArtifactId === contextArtifactId - ) { - return - } - - setTranscriptionArtifactId(transcriptionArtifactId) - }, [ - contextArtifactId, - setTranscriptionArtifactId, - transcriptionArtifactId, - ]) - // Auto-trigger frame extraction so video frames appear in timeline const frameExtractMutation = api.useMutation( "post", @@ -154,9 +113,7 @@ const SubtitleRevisionContent: FunctionComponent<{ const handleFinish = () => { if (!isTranscriptionReady) return - - markStepCompleted("subtitle-revision") - goToStep("caption-settings") + void markTranscriptionReviewed() } return ( @@ -280,9 +237,5 @@ const SubtitleRevisionContent: FunctionComponent<{ export const SubtitleRevisionStep: FunctionComponent< ISubtitleRevisionStepProps > = ({ className }): JSX.Element => { - return ( - - - - ) + return } diff --git a/src/features/project/TranscriptionSettingsStep/TranscriptionSettingsStep.tsx b/src/features/project/TranscriptionSettingsStep/TranscriptionSettingsStep.tsx index 03fe4fc..b606fce 100644 --- a/src/features/project/TranscriptionSettingsStep/TranscriptionSettingsStep.tsx +++ b/src/features/project/TranscriptionSettingsStep/TranscriptionSettingsStep.tsx @@ -13,8 +13,6 @@ import api from "@shared/api" import { useWizard } from "@shared/context/WizardContext" import { useAppSelector } from "@shared/hooks/useAppSelector" import { Button, CircularProgress, Form, Select, SelectItem } from "@shared/ui" - -import { useSubmitTranscription } from "../TranscriptionModal/useSubmitTranscription" import { buildCancelJobPayload, useCancelJob } from "../useCancelJob" import styles from "./TranscriptionSettingsStep.module.scss" @@ -53,12 +51,11 @@ export const TranscriptionSettingsStep: FunctionComponent< ITranscriptionSettingsStepProps > = ({ className }): JSX.Element => { const { - projectId, primaryFileKey, activeJobId, activeJobType, setActiveJob, - startProcessingJob, + startTranscription, goToStep, } = useWizard() @@ -66,6 +63,7 @@ export const TranscriptionSettingsStep: FunctionComponent< !!activeJobId && activeJobType === "TRANSCRIPTION_GENERATE" const [submitError, setSubmitError] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) const { mutate: cancelJob, isPending: isCancelling } = useCancelJob() const { control, handleSubmit, watch, setValue } = @@ -87,36 +85,23 @@ export const TranscriptionSettingsStep: FunctionComponent< } }, [engine, setValue]) - const { mutate, isPending } = useSubmitTranscription({ - onSuccess: (data) => { - if (data?.job_id) { - startProcessingJob( - data.job_id, - "TRANSCRIPTION_GENERATE", - "transcription-processing", - "transcription-settings", - ) - } - }, - onError: (error) => { - console.error("Transcription submit failed:", error) - setSubmitError("Не удалось запустить транскрипцию") - }, - }) - - const onSubmit = (data: ITranscriptionFormData): void => { + const onSubmit = async (data: ITranscriptionFormData): Promise => { if (!primaryFileKey) return setSubmitError(null) + setIsSubmitting(true) - mutate({ - body: { - file_key: primaryFileKey, - project_id: projectId, + try { + await startTranscription({ engine: data.engine, language: data.language === "auto" ? undefined : data.language, model: data.model, - }, - }) + }) + } catch (error) { + console.error("Transcription submit failed:", error) + setSubmitError("Не удалось запустить транскрипцию") + } finally { + setIsSubmitting(false) + } } /* ---- Processing state (inline) ---- */ @@ -309,7 +294,7 @@ export const TranscriptionSettingsStep: FunctionComponent< diff --git a/src/features/project/UploadStep/UploadStep.tsx b/src/features/project/UploadStep/UploadStep.tsx index ddf867b..18fa33e 100644 --- a/src/features/project/UploadStep/UploadStep.tsx +++ b/src/features/project/UploadStep/UploadStep.tsx @@ -20,7 +20,7 @@ const ERROR_UPLOAD_FAILED = "Не удалось загрузить файл" export const UploadStep: FunctionComponent = ({ className, }): JSX.Element => { - const { projectId, setFileKey, markStepCompleted, goNext } = useWizard() + const { projectId, setFileKey } = useWizard() const [isDragging, setIsDragging] = useState(false) const [isUploading, setIsUploading] = useState(false) const [progress, setProgress] = useState(0) @@ -39,16 +39,18 @@ export const UploadStep: FunctionComponent = ({ `projects/${projectId}`, setProgress, ) - setFileKey(result.file_path, result.file_id, result.filename ?? null) - markStepCompleted("upload") - goNext() + await setFileKey( + result.file_path, + result.file_id, + result.filename ?? null, + ) } catch { setError(ERROR_UPLOAD_FAILED) } finally { setIsUploading(false) } }, - [projectId, setFileKey, markStepCompleted, goNext], + [projectId, setFileKey], ) const handleFileChange = useCallback( diff --git a/src/features/project/VerifyStep/VerifyStep.tsx b/src/features/project/VerifyStep/VerifyStep.tsx index eabba80..889cc87 100644 --- a/src/features/project/VerifyStep/VerifyStep.tsx +++ b/src/features/project/VerifyStep/VerifyStep.tsx @@ -32,7 +32,7 @@ import { import cs from "classnames" -import api, { fetchClient } from "@shared/api" +import api from "@shared/api" import { useWizard } from "@shared/context/WizardContext" import { useTaskProgressState } from "@shared/hooks/useTaskProgressState" import { Badge, Button, CircularProgress } from "@shared/ui" @@ -55,19 +55,16 @@ export const VerifyStep: FunctionComponent = ({ className, }): JSX.Element => { const { - projectId, primaryFileKey, videoUrl, originalFileName, activeJobId, activeJobType, goBack, - goNext, - goToStep, - markStepCompleted, + confirmVerify, setFileKey, setActiveJob, - startProcessingJob, + startMediaConvert, } = useWizard() const [convertError, setConvertError] = useState(null) @@ -113,27 +110,11 @@ export const VerifyStep: FunctionComponent = ({ /* ---- Conversion logic ---- */ - const convertMutation = api.useMutation("post", "/api/tasks/media-convert/", { - onSuccess: (data) => { - startProcessingJob(data.job_id, "MEDIA_CONVERT", "verify") - setConvertError(null) - }, - onError: () => { - setConvertError(ERROR_CONVERT_FAILED) - }, - }) - const handleConvert = useCallback(() => { - if (!primaryFileKey) return - convertMutation.mutate({ - body: { - file_key: primaryFileKey, - out_folder: `projects/${projectId}`, - output_format: "mp4", - project_id: projectId, - }, + void startMediaConvert().catch(() => { + setConvertError(ERROR_CONVERT_FAILED) }) - }, [convertMutation, primaryFileKey, projectId]) + }, [startMediaConvert]) const { progressPct: convertProgressPct, @@ -149,47 +130,20 @@ export const VerifyStep: FunctionComponent = ({ useEffect(() => { if (!convertJobId || convertStatus !== "converting") return - if (convertTaskStatus === "DONE") { - fetchConvertedFileFromJob(convertJobId) - } - if (convertTaskStatus === "FAILED") { setActiveJob(null) setConvertError(convertErrorMessage ?? "Ошибка конвертации") } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [convertErrorMessage, convertJobId, convertStatus, convertTaskStatus]) - - const fetchConvertedFileFromJob = useCallback( - async (jobId: string) => { - const { data: taskStatus } = await fetchClient.GET( - "/api/tasks/status/{job_id}/", - { params: { path: { job_id: jobId } } }, - ) - const outputData = taskStatus?.output_data as { - file_id?: string - file_path?: string - } | null - - if (outputData?.file_id && outputData?.file_path) { - const convertedName = outputData.file_path.split("/").pop() ?? null - setFileKey(outputData.file_path, outputData.file_id, convertedName) - setActiveJob(null) - } - }, - [setFileKey, setActiveJob], - ) + }, [convertErrorMessage, convertJobId, convertStatus, convertTaskStatus, setActiveJob]) /* ---- Handlers ---- */ const handleReplace = () => { - setFileKey(null, null, null) - goToStep("upload") + void setFileKey(null, null, null) } const handleNext = () => { - markStepCompleted("verify") - goNext() + void confirmVerify() } /* ---- Converting view ---- */ @@ -266,7 +220,7 @@ export const VerifyStep: FunctionComponent = ({ variant="primary" size="sm" onClick={handleConvert} - disabled={convertMutation.isPending} + disabled={!primaryFileKey} > Конвертировать в MP4 @@ -357,7 +311,7 @@ export const VerifyStep: FunctionComponent = ({ variant="primary" size="sm" onClick={handleConvert} - disabled={convertMutation.isPending} + disabled={!primaryFileKey} > Конвертировать в MP4 diff --git a/src/shared/api/__generated__/openapi.types.ts b/src/shared/api/__generated__/openapi.types.ts index bfac5e7..f6bcbb3 100644 --- a/src/shared/api/__generated__/openapi.types.ts +++ b/src/shared/api/__generated__/openapi.types.ts @@ -200,6 +200,40 @@ export interface paths { patch: operations["patch_project_api_projects__project_id___patch"]; trace?: never; }; + "/api/projects/{project_id}/workspace": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Project Workspace */ + get: operations["get_project_workspace_api_projects__project_id__workspace_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project_id}/workflow/actions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dispatch Project Workflow Action */ + post: operations["dispatch_project_workflow_action_api_projects__project_id__workflow_actions_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/files/upload/": { parameters: { query?: never; @@ -987,6 +1021,19 @@ export interface paths { export type webhooks = Record; export interface components { schemas: { + /** ActiveJobState */ + ActiveJobState: { + /** + * Job Id + * Format: uuid + */ + job_id: string; + /** + * Job Type + * @enum {string} + */ + job_type: "MEDIA_PROBE" | "SILENCE_REMOVE" | "SILENCE_DETECT" | "SILENCE_APPLY" | "MEDIA_CONVERT" | "TRANSCRIPTION_GENERATE" | "CAPTIONS_GENERATE" | "FRAME_EXTRACT"; + }; /** ArtifactMediaFileCreate */ ArtifactMediaFileCreate: { /** Project Id */ @@ -1286,6 +1333,43 @@ export interface components { /** Result */ result: string; }; + /** CaptionsState */ + CaptionsState: { + /** @default IDLE */ + status: components["schemas"]["CaptionsWorkflowStatus"]; + /** Preset Id */ + preset_id?: string | null; + /** Style Config */ + style_config?: { + [key: string]: unknown; + } | null; + /** Render Job Id */ + render_job_id?: string | null; + /** Output File Id */ + output_file_id?: string | null; + }; + /** + * CaptionsWorkflowStatus + * @enum {string} + */ + CaptionsWorkflowStatus: "IDLE" | "CONFIGURED" | "PROCESSING" | "COMPLETED"; + /** ConfirmVerifyAction */ + ConfirmVerifyAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "CONFIRM_VERIFY"; + /** Revision */ + revision: number; + }; + /** CutRegionState */ + CutRegionState: { + /** Start Ms */ + start_ms: number; + /** End Ms */ + end_ms: number; + }; /** DispositionSchema */ DispositionSchema: { /** Default */ @@ -1653,6 +1737,16 @@ export interface components { /** Words */ words: components["schemas"]["WordNode"][]; }; + /** MarkTranscriptionReviewedAction */ + MarkTranscriptionReviewedAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "MARK_TRANSCRIPTION_REVIEWED"; + /** Revision */ + revision: number; + }; /** * MediaConvertRequest * @description Request to convert media file to different format. @@ -1909,10 +2003,6 @@ export interface components { * @enum {string} */ status: "DRAFT" | "PROCESSING" | "DONE" | "FAILED"; - /** Workspace State */ - workspace_state: { - [key: string]: unknown; - } | null; /** Is Active */ is_active: boolean; /** @@ -1938,10 +2028,71 @@ export interface components { folder?: string | null; /** Status */ status?: ("DRAFT" | "PROCESSING" | "DONE" | "FAILED") | null; - /** Workspace State */ - workspace_state?: { - [key: string]: unknown; - } | null; + }; + /** ProjectWorkspaceRead */ + ProjectWorkspaceRead: { + /** + * Project Id + * Format: uuid + */ + project_id: string; + /** Revision */ + revision: number; + /** Version */ + version: number; + phase: components["schemas"]["WorkflowPhase"]; + /** + * Current Screen + * @enum {string} + */ + current_screen: "upload" | "verify" | "silence-settings" | "processing" | "fragments" | "silence-apply-processing" | "transcription-settings" | "transcription-processing" | "subtitle-revision" | "caption-settings" | "caption-processing" | "caption-result"; + active_job: components["schemas"]["ActiveJobState"] | null; + /** Source File Id */ + source_file_id: string | null; + workspace_view: components["schemas"]["WorkspaceViewState"]; + silence: components["schemas"]["SilenceState"]; + transcription: components["schemas"]["TranscriptionState"]; + captions: components["schemas"]["CaptionsState"]; + }; + /** ReopenCaptionConfigAction */ + ReopenCaptionConfigAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "REOPEN_CAPTION_CONFIG"; + /** Revision */ + revision: number; + }; + /** ReopenSilenceReviewAction */ + ReopenSilenceReviewAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "REOPEN_SILENCE_REVIEW"; + /** Revision */ + revision: number; + }; + /** ReopenTranscriptionConfigAction */ + ReopenTranscriptionConfigAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "REOPEN_TRANSCRIPTION_CONFIG"; + /** Revision */ + revision: number; + }; + /** ResetSourceFileAction */ + ResetSourceFileAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "RESET_SOURCE_FILE"; + /** Revision */ + revision: number; }; /** SaluteSpeechParams */ SaluteSpeechParams: { @@ -1979,6 +2130,71 @@ export interface components { /** Lines */ lines: components["schemas"]["LineNode-Output"][]; }; + /** SelectCaptionPresetAction */ + SelectCaptionPresetAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SELECT_CAPTION_PRESET"; + /** Revision */ + revision: number; + /** Preset Id */ + preset_id?: string | null; + /** Style Config */ + style_config?: { + [key: string]: unknown; + } | null; + }; + /** SetSilenceCutsAction */ + SetSilenceCutsAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SET_SILENCE_CUTS"; + /** Revision */ + revision: number; + /** Cuts */ + cuts: components["schemas"]["CutRegionState"][]; + }; + /** SetSilenceSettingsAction */ + SetSilenceSettingsAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SET_SILENCE_SETTINGS"; + /** Revision */ + revision: number; + settings?: components["schemas"]["SilenceSettingsState"]; + }; + /** SetSourceFileAction */ + SetSourceFileAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SET_SOURCE_FILE"; + /** Revision */ + revision: number; + /** + * File Id + * Format: uuid + */ + file_id: string; + }; + /** SetWorkspaceViewAction */ + SetWorkspaceViewAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SET_WORKSPACE_VIEW"; + /** Revision */ + revision: number; + workspace_view: components["schemas"]["WorkspaceViewState"]; + }; /** * SilenceApplyRequest * @description Request to apply silence cuts to media file. @@ -2085,6 +2301,143 @@ export interface components { */ padding_ms: number; }; + /** SilenceSettingsState */ + SilenceSettingsState: { + /** + * Min Silence Duration Ms + * @default 200 + */ + min_silence_duration_ms: number; + /** + * Silence Threshold Db + * @default 16 + */ + silence_threshold_db: number; + /** + * Padding Ms + * @default 100 + */ + padding_ms: number; + }; + /** SilenceState */ + SilenceState: { + /** @default IDLE */ + status: components["schemas"]["SilenceWorkflowStatus"]; + settings?: components["schemas"]["SilenceSettingsState"]; + /** Detect Job Id */ + detect_job_id?: string | null; + /** Detected Segments */ + detected_segments?: components["schemas"]["CutRegionState"][]; + /** Reviewed Cuts */ + reviewed_cuts?: components["schemas"]["CutRegionState"][]; + /** Duration Ms */ + duration_ms?: number | null; + /** Applied Output File Id */ + applied_output_file_id?: string | null; + }; + /** + * SilenceWorkflowStatus + * @enum {string} + */ + SilenceWorkflowStatus: "IDLE" | "CONFIGURED" | "DETECTING" | "REVIEWING" | "APPLYING" | "COMPLETED" | "SKIPPED"; + /** SkipSilenceApplyAction */ + SkipSilenceApplyAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "SKIP_SILENCE_APPLY"; + /** Revision */ + revision: number; + }; + /** StartCaptionRenderAction */ + StartCaptionRenderAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "START_CAPTION_RENDER"; + /** Revision */ + revision: number; + /** + * Folder + * @default output_files + */ + folder: string; + }; + /** StartMediaConvertAction */ + StartMediaConvertAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "START_MEDIA_CONVERT"; + /** Revision */ + revision: number; + /** + * Output Format + * @default mp4 + */ + output_format: string; + /** + * Out Folder + * @default output_files + */ + out_folder: string; + }; + /** StartSilenceApplyAction */ + StartSilenceApplyAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "START_SILENCE_APPLY"; + /** Revision */ + revision: number; + /** Cuts */ + cuts?: components["schemas"]["CutRegionState"][] | null; + /** + * Out Folder + * @default output_files + */ + out_folder: string; + /** Output Name */ + output_name?: string | null; + }; + /** StartSilenceDetectAction */ + StartSilenceDetectAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "START_SILENCE_DETECT"; + /** Revision */ + revision: number; + }; + /** StartTranscriptionAction */ + StartTranscriptionAction: { + /** + * @description discriminator enum property added by openapi-typescript + * @enum {string} + */ + type: "START_TRANSCRIPTION"; + /** Revision */ + revision: number; + /** + * Engine + * @default whisper + * @enum {string} + */ + engine: "whisper" | "google" | "salutespeech"; + /** Language */ + language?: string | null; + /** + * Model + * @default base + */ + model: string; + request?: components["schemas"]["TranscriptionRequestState"] | null; + }; /** StreamSchema */ StreamSchema: { /** Index */ @@ -2324,6 +2677,39 @@ export interface components { */ updated_at: string; }; + /** TranscriptionRequestState */ + TranscriptionRequestState: { + /** + * Engine + * @default whisper + * @enum {string} + */ + engine: "whisper" | "google" | "salutespeech"; + /** Language */ + language?: string | null; + /** + * Model + * @default base + */ + model: string; + }; + /** TranscriptionState */ + TranscriptionState: { + /** @default IDLE */ + status: components["schemas"]["TranscriptionWorkflowStatus"]; + request?: components["schemas"]["TranscriptionRequestState"]; + /** Job Id */ + job_id?: string | null; + /** Artifact Id */ + artifact_id?: string | null; + /** Transcription Id */ + transcription_id?: string | null; + /** + * Reviewed + * @default false + */ + reviewed: boolean; + }; /** TranscriptionUpdate */ TranscriptionUpdate: { /** Document */ @@ -2335,6 +2721,11 @@ export interface components { [key: string]: unknown; } | null; }; + /** + * TranscriptionWorkflowStatus + * @enum {string} + */ + TranscriptionWorkflowStatus: "IDLE" | "PROCESSING" | "REVIEWING" | "COMPLETED"; /** UserCreate */ UserCreate: { /** Username */ @@ -2542,6 +2933,18 @@ export interface components { structure_tags: components["schemas"]["Tag"][]; time: components["schemas"]["TimeRange"]; }; + /** + * WorkflowPhase + * @enum {string} + */ + WorkflowPhase: "INGEST" | "VERIFY" | "SILENCE" | "TRANSCRIPTION" | "CAPTIONS" | "DONE"; + /** WorkspaceViewState */ + WorkspaceViewState: { + /** Used File Ids */ + used_file_ids?: string[]; + /** Selected File Id */ + selected_file_id?: string | null; + }; }; responses: never; parameters: never; @@ -3055,6 +3458,72 @@ export interface operations { }; }; }; + get_project_workspace_api_projects__project_id__workspace_get: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectWorkspaceRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + dispatch_project_workflow_action_api_projects__project_id__workflow_actions_post: { + parameters: { + query?: never; + header?: never; + path: { + project_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetSourceFileAction"] | components["schemas"]["ResetSourceFileAction"] | components["schemas"]["StartMediaConvertAction"] | components["schemas"]["ConfirmVerifyAction"] | components["schemas"]["SetSilenceSettingsAction"] | components["schemas"]["StartSilenceDetectAction"] | components["schemas"]["SetSilenceCutsAction"] | components["schemas"]["SkipSilenceApplyAction"] | components["schemas"]["StartSilenceApplyAction"] | components["schemas"]["ReopenSilenceReviewAction"] | components["schemas"]["StartTranscriptionAction"] | components["schemas"]["ReopenTranscriptionConfigAction"] | components["schemas"]["MarkTranscriptionReviewedAction"] | components["schemas"]["SelectCaptionPresetAction"] | components["schemas"]["StartCaptionRenderAction"] | components["schemas"]["ReopenCaptionConfigAction"] | components["schemas"]["SetWorkspaceViewAction"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ProjectWorkspaceRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; upload_file_api_files_upload__post: { parameters: { query?: never; diff --git a/src/shared/api/projectWorkflow.ts b/src/shared/api/projectWorkflow.ts new file mode 100644 index 0000000..686e34d --- /dev/null +++ b/src/shared/api/projectWorkflow.ts @@ -0,0 +1,246 @@ +"use client" + +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query" + +import { ACCESS_TOKEN_REGEXP, API_URL } from "@shared/lib/constants" + +export type WorkflowPhase = + | "INGEST" + | "VERIFY" + | "SILENCE" + | "TRANSCRIPTION" + | "CAPTIONS" + | "DONE" + +export type WorkflowScreen = + | "upload" + | "verify" + | "silence-settings" + | "processing" + | "fragments" + | "silence-apply-processing" + | "transcription-settings" + | "transcription-processing" + | "subtitle-revision" + | "caption-settings" + | "caption-processing" + | "caption-result" + +export interface SilenceSettingsPayload { + min_silence_duration_ms: number + silence_threshold_db: number + padding_ms: number +} + +export interface WorkflowCutRegionPayload { + start_ms: number + end_ms: number +} + +export interface WorkflowActiveJob { + job_id: string + job_type: string +} + +export interface WorkflowWorkspaceView { + used_file_ids: string[] + selected_file_id: string | null +} + +export interface WorkflowSilenceState { + status: string | null + settings: SilenceSettingsPayload | null + detect_job_id: string | null + detected_segments: WorkflowCutRegionPayload[] + reviewed_cuts: WorkflowCutRegionPayload[] + duration_ms: number | null + applied_output_file_id: string | null +} + +export interface WorkflowTranscriptionRequest { + engine: "whisper" | "google" | "salutespeech" + language?: string + model: string +} + +export interface WorkflowTranscriptionState { + status: string | null + job_id: string | null + request: WorkflowTranscriptionRequest | null + artifact_id: string | null + transcription_id: string | null + reviewed: boolean +} + +export interface WorkflowCaptionsState { + status: string | null + preset_id: string | null + style_config: Record | null + render_job_id: string | null + output_file_id: string | null +} + +export interface ProjectWorkspaceRead { + revision: number + phase: WorkflowPhase + current_screen: WorkflowScreen + active_job: WorkflowActiveJob | null + source_file_id: string | null + workspace_view: WorkflowWorkspaceView + silence: WorkflowSilenceState + transcription: WorkflowTranscriptionState + captions: WorkflowCaptionsState +} + +type WorkflowActionBase = { + type: TActionType + revision: number +} + +export type WorkflowActionRequest = + | (WorkflowActionBase<"SET_SOURCE_FILE"> & { + file_id: string + }) + | WorkflowActionBase<"RESET_SOURCE_FILE"> + | (WorkflowActionBase<"START_MEDIA_CONVERT"> & { + output_format?: "mp4" + }) + | WorkflowActionBase<"CONFIRM_VERIFY"> + | (WorkflowActionBase<"SET_SILENCE_SETTINGS"> & { + settings: SilenceSettingsPayload + }) + | WorkflowActionBase<"START_SILENCE_DETECT"> + | (WorkflowActionBase<"SET_SILENCE_CUTS"> & { + cuts: WorkflowCutRegionPayload[] + }) + | WorkflowActionBase<"SKIP_SILENCE_APPLY"> + | (WorkflowActionBase<"START_SILENCE_APPLY"> & { + cuts: WorkflowCutRegionPayload[] + }) + | WorkflowActionBase<"REOPEN_SILENCE_REVIEW"> + | (WorkflowActionBase<"START_TRANSCRIPTION"> & { + request: WorkflowTranscriptionRequest + }) + | WorkflowActionBase<"REOPEN_TRANSCRIPTION_CONFIG"> + | WorkflowActionBase<"MARK_TRANSCRIPTION_REVIEWED"> + | (WorkflowActionBase<"SELECT_CAPTION_PRESET"> & { + preset_id: string | null + }) + | WorkflowActionBase<"START_CAPTION_RENDER"> + | WorkflowActionBase<"REOPEN_CAPTION_CONFIG"> + | (WorkflowActionBase<"SET_WORKSPACE_VIEW"> & { + workspace_view: WorkflowWorkspaceView + }) + +class WorkflowApiError extends Error { + status: number + + constructor(status: number, message: string) { + super(message) + this.name = "WorkflowApiError" + this.status = status + } +} + +function getBaseApiUrl(): string { + if (API_URL?.length) return API_URL + if (typeof window !== "undefined") return window.location.origin + return "" +} + +function getAccessToken(): string | null { + if (typeof document === "undefined") return null + const token = document.cookie.replace(ACCESS_TOKEN_REGEXP, "$1") + return token.length ? token : null +} + +async function requestJson( + path: string, + init?: RequestInit, +): Promise { + const token = getAccessToken() + const response = await fetch(`${getBaseApiUrl()}${path}`, { + credentials: "include", + ...init, + headers: { + "Content-Type": "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(init?.headers ?? {}), + }, + }) + + if (!response.ok) { + const message = response.statusText || "Workflow request failed" + throw new WorkflowApiError(response.status, message) + } + + if (response.status === 204) { + return null as TResponse + } + + return (await response.json()) as TResponse +} + +export function getProjectWorkspaceQueryKey(projectId: string) { + return ["project-workspace", projectId] as const +} + +export async function fetchProjectWorkspace( + projectId: string, +): Promise { + return requestJson( + `/api/projects/${projectId}/workspace`, + { method: "GET" }, + ) +} + +export async function postWorkflowAction( + projectId: string, + action: WorkflowActionRequest, +): Promise { + return requestJson( + `/api/projects/${projectId}/workflow/actions`, + { + method: "POST", + body: JSON.stringify(action), + }, + ) +} + +export function useProjectWorkspaceQuery(projectId: string) { + return useQuery({ + queryKey: getProjectWorkspaceQueryKey(projectId), + queryFn: () => fetchProjectWorkspace(projectId), + enabled: !!projectId, + }) +} + +export function useWorkflowAction(projectId: string) { + const queryClient = useQueryClient() + const queryKey = getProjectWorkspaceQueryKey(projectId) + + return useMutation({ + mutationFn: (action: WorkflowActionRequest) => + postWorkflowAction(projectId, action), + onSuccess: (workspace) => { + if (workspace) { + queryClient.setQueryData(queryKey, workspace) + return + } + + queryClient.invalidateQueries({ queryKey }) + }, + onError: (error) => { + if ( + error instanceof WorkflowApiError && + error.status === 409 + ) { + queryClient.invalidateQueries({ queryKey }) + } + }, + }) +} + +export function isWorkflowConflictError(error: unknown): boolean { + return error instanceof WorkflowApiError && error.status === 409 +} diff --git a/src/shared/context/SocketProvider.tsx b/src/shared/context/SocketProvider.tsx index 5967808..b23b1f5 100644 --- a/src/shared/context/SocketProvider.tsx +++ b/src/shared/context/SocketProvider.tsx @@ -14,6 +14,7 @@ import { NotificationItem, setNotifications, } from "@shared/store/notifications" +import { getProjectWorkspaceQueryKey } from "@shared/api/projectWorkflow" interface SocketContextValue { isConnected: boolean @@ -246,6 +247,12 @@ export const SocketProvider = ({ queryKey: ["get", "/api/files/files/"], }) } + + if (data.project_id) { + queryClient.invalidateQueries({ + queryKey: getProjectWorkspaceQueryKey(data.project_id), + }) + } } catch { // Ignore malformed messages } diff --git a/src/shared/context/WizardContext.tsx b/src/shared/context/WizardContext.tsx index fc19129..3d1211f 100644 --- a/src/shared/context/WizardContext.tsx +++ b/src/shared/context/WizardContext.tsx @@ -11,13 +11,24 @@ import { useRef, useState, } from "react" +import { useQueryClient } from "@tanstack/react-query" import api from "@shared/api" -import { useAppSelector } from "@shared/hooks/useAppSelector" -import { useDebounce } from "@shared/hooks/useDebounce" +import { + getProjectWorkspaceQueryKey, + isWorkflowConflictError, + type ProjectWorkspaceRead, + type SilenceSettingsPayload, + type WorkflowActionRequest, + type WorkflowCutRegionPayload, + type WorkflowScreen, + type WorkflowTranscriptionRequest, + useProjectWorkspaceQuery, + useWorkflowAction, +} from "@shared/api/projectWorkflow" /* ------------------------------------------------------------------ */ -/* Step definitions */ +/* Step definitions */ /* ------------------------------------------------------------------ */ export const WIZARD_STEPS = [ @@ -96,64 +107,38 @@ export const WIZARD_STEPS = [ ] as const export type WizardStepKey = (typeof WIZARD_STEPS)[number]["key"] +export type SilenceSettings = SilenceSettingsPayload -const JOB_PROCESSING_STEP_MAP: Record = { - MEDIA_CONVERT: "verify", - SILENCE_DETECT: "processing", - SILENCE_APPLY: "silence-apply-processing", - TRANSCRIPTION_GENERATE: "transcription-processing", - CAPTIONS_GENERATE: "caption-processing", +const DEFAULT_SILENCE_SETTINGS: SilenceSettings = { + min_silence_duration_ms: 200, + silence_threshold_db: 16, + padding_ms: 100, } -const JOB_CANCELLED_STEP_MAP: Record = { - MEDIA_CONVERT: "verify", - SILENCE_DETECT: "silence-settings", - SILENCE_APPLY: "fragments", - TRANSCRIPTION_GENERATE: "transcription-settings", - CAPTIONS_GENERATE: "caption-settings", +const DEFAULT_BACK_STEP_MAP: Partial> = { + verify: "upload", + "silence-settings": "verify", + processing: "silence-settings", + fragments: "silence-settings", + "silence-apply-processing": "fragments", + "transcription-settings": "fragments", + "transcription-processing": "transcription-settings", + "subtitle-revision": "transcription-settings", + "caption-settings": "subtitle-revision", + "caption-processing": "caption-settings", + "caption-result": "caption-settings", } -function getProcessingStepForJobType( - jobType: string | null | undefined, -): WizardStepKey | null { - if (!jobType) return null - return JOB_PROCESSING_STEP_MAP[jobType] ?? null -} - -function getCancelledStepForJobType( - jobType: string | null | undefined, -): WizardStepKey | null { - if (!jobType) return null - return JOB_CANCELLED_STEP_MAP[jobType] ?? null -} - -function normalizeWizardStep( - currentStep: WizardStepKey, - activeJobId: string | null, - activeJobType: string | null, -): WizardStepKey { - if (!activeJobId) return currentStep - - const processingStep = getProcessingStepForJobType(activeJobType) - return processingStep ?? currentStep -} - -/* ------------------------------------------------------------------ */ -/* Silence settings */ -/* ------------------------------------------------------------------ */ - -export interface SilenceSettings { - min_silence_duration_ms: number - silence_threshold_db: number - padding_ms: number -} - -/* ------------------------------------------------------------------ */ -/* Context shape */ -/* ------------------------------------------------------------------ */ +type JobOverride = + | { + jobId: string | null + jobType: string | null + } + | undefined interface WizardContextValue { projectId: string + revision: number currentStep: WizardStepKey stepIndex: number completedSteps: WizardStepKey[] @@ -177,7 +162,7 @@ interface WizardContextValue { key: string | null, fileId: string | null, originalFileName?: string | null, - ) => void + ) => Promise setSilenceSettings: (settings: SilenceSettings) => void setActiveJob: (jobId: string | null, jobType?: string | null) => void startProcessingJob: ( @@ -188,129 +173,104 @@ interface WizardContextValue { ) => void setSilenceJobId: (jobId: string | null) => void setTranscriptionArtifactId: (id: string | null) => void - setCaptionPresetId: (id: string | null) => void + setCaptionPresetId: (id: string | null) => Promise setCaptionStyleConfig: (config: Record | null) => void setCaptionedVideoPath: (path: string | null) => void setCaptionedVideoFileId: (id: string | null) => void markStepCompleted: (step: WizardStepKey) => void + confirmVerify: () => Promise + startMediaConvert: () => Promise + saveSilenceSettings: (settings?: SilenceSettings) => Promise + startSilenceDetect: (settings?: SilenceSettings) => Promise + saveSilenceCuts: (cuts: WorkflowCutRegionPayload[]) => Promise + skipSilenceApply: () => Promise + startSilenceApply: (cuts: WorkflowCutRegionPayload[]) => Promise + reopenSilenceReview: () => Promise + startTranscription: (request: WorkflowTranscriptionRequest) => Promise + reopenTranscriptionConfig: () => Promise + markTranscriptionReviewed: () => Promise + selectCaptionPreset: (presetId: string | null) => Promise + startCaptionRender: () => Promise + reopenCaptionConfig: () => Promise } const WizardContext = createContext(null) -/* ------------------------------------------------------------------ */ -/* Persisted shape */ -/* ------------------------------------------------------------------ */ - -interface PersistedWizardState { - current_step: WizardStepKey - completed_steps: WizardStepKey[] - primary_file_id: string | null - primary_file_key: string | null - original_file_name: string | null - silence_settings: SilenceSettings - active_job_id: string | null - active_job_type: string | null - silence_job_id: string | null - transcription_artifact_id: string | null - caption_preset_id: string | null - caption_style_config: Record | null - captioned_video_path: string | null - captioned_video_file_id: string | null +function normalizeWorkspaceStep( + screen: WorkflowScreen | null | undefined, +): WizardStepKey { + const fallbackStep: WizardStepKey = "upload" + if (!screen) return fallbackStep + return WIZARD_STEPS.some((step) => step.key === screen) + ? (screen as WizardStepKey) + : fallbackStep } -const DEFAULT_SILENCE_SETTINGS: SilenceSettings = { - min_silence_duration_ms: 200, - silence_threshold_db: 16, - padding_ms: 100, +function getCompletedSteps(currentStep: WizardStepKey): WizardStepKey[] { + const currentStepIndex = WIZARD_STEPS.findIndex( + (step) => step.key === currentStep, + ) + if (currentStepIndex <= 0) return [] + return WIZARD_STEPS.slice(0, currentStepIndex).map((step) => step.key) } -const DEBOUNCE_MS = 1000 - -/* ------------------------------------------------------------------ */ -/* Provider */ -/* ------------------------------------------------------------------ */ - export const WizardProvider: FunctionComponent<{ projectId: string children: ReactNode }> = ({ projectId, children }) => { - const [currentStep, setCurrentStep] = useState("upload") - const [completedSteps, setCompletedSteps] = useState([]) - const [primaryFileId, setPrimaryFileId] = useState(null) - const [primaryFileKey, setPrimaryFileKey] = useState(null) - const [originalFileName, setOriginalFileName] = useState(null) - const [silenceSettings, setSilenceSettingsState] = useState( - DEFAULT_SILENCE_SETTINGS, - ) - const [activeJobId, setActiveJobId] = useState(null) - const [activeJobType, setActiveJobType] = useState(null) - const [silenceJobId, setSilenceJobIdState] = useState(null) - const [transcriptionArtifactId, setTranscriptionArtifactIdState] = useState< - string | null - >(null) - const [captionPresetId, setCaptionPresetIdState] = useState( - null, - ) - const [captionStyleConfig, setCaptionStyleConfigState] = useState | null>(null) - const [captionedVideoPath, setCaptionedVideoPathState] = useState< - string | null - >(null) - const [captionedVideoFileId, setCaptionedVideoFileIdState] = useState< - string | null - >(null) + const queryClient = useQueryClient() + const { data: workspace } = useProjectWorkspaceQuery(projectId) + const workflowAction = useWorkflowAction(projectId) - const isInitializedRef = useRef(false) - const initialSerializedRef = useRef(null) - - /* ---- Load from server ---- */ - - const { data: project, isSuccess } = api.useQuery( - "get", - "/api/projects/{project_id}/", - { params: { path: { project_id: projectId } } }, - { enabled: !!projectId }, + const [stepOverride, setStepOverride] = useState(null) + const [activeJobOverride, setActiveJobOverride] = useState( + undefined, ) + const [silenceSettingsDraft, setSilenceSettingsDraft] = + useState(DEFAULT_SILENCE_SETTINGS) + const [captionPresetDraft, setCaptionPresetDraft] = useState< + string | null | undefined + >(undefined) + const [captionStyleConfigDraft, setCaptionStyleConfigDraft] = useState< + Record | null | undefined + >(undefined) + const latestRevisionRef = useRef(null) useEffect(() => { - if (!isSuccess || isInitializedRef.current) return + if (!workspace) return - const saved = project?.workspace_state as - | { wizard?: PersistedWizardState } - | null - | undefined - const wizard = saved?.wizard - - if (wizard) { - setCurrentStep( - normalizeWizardStep( - wizard.current_step ?? "upload", - wizard.active_job_id ?? null, - wizard.active_job_type ?? null, - ), - ) - setCompletedSteps(wizard.completed_steps ?? []) - setPrimaryFileId(wizard.primary_file_id ?? null) - setPrimaryFileKey(wizard.primary_file_key ?? null) - setOriginalFileName(wizard.original_file_name ?? null) - setSilenceSettingsState( - wizard.silence_settings ?? DEFAULT_SILENCE_SETTINGS, - ) - setActiveJobId(wizard.active_job_id ?? null) - setActiveJobType(wizard.active_job_type ?? null) - setSilenceJobIdState(wizard.silence_job_id ?? null) - setTranscriptionArtifactIdState(wizard.transcription_artifact_id ?? null) - setCaptionPresetIdState(wizard.caption_preset_id ?? null) - setCaptionStyleConfigState(wizard.caption_style_config ?? null) - setCaptionedVideoPathState(wizard.captioned_video_path ?? null) - setCaptionedVideoFileIdState(wizard.captioned_video_file_id ?? null) + if (latestRevisionRef.current !== workspace.revision) { + latestRevisionRef.current = workspace.revision + setStepOverride(null) + setActiveJobOverride(undefined) } - initialSerializedRef.current = JSON.stringify(wizard ?? null) - isInitializedRef.current = true - }, [isSuccess, project]) + setSilenceSettingsDraft( + workspace.silence.settings ?? DEFAULT_SILENCE_SETTINGS, + ) + setCaptionPresetDraft(workspace.captions.preset_id ?? null) + setCaptionStyleConfigDraft(workspace.captions.style_config ?? null) + }, [workspace]) + + const workspaceCurrentStep = normalizeWorkspaceStep(workspace?.current_screen) + const currentStep = stepOverride ?? workspaceCurrentStep + const completedSteps = useMemo( + () => getCompletedSteps(currentStep), + [currentStep], + ) + const revision = workspace?.revision ?? 0 + + const primaryFileId = + workspace?.source_file_id ?? + workspace?.silence.applied_output_file_id ?? + null + + const { data: primaryFile } = api.useQuery( + "get", + "/api/files/files/{file_id}/", + { params: { path: { file_id: primaryFileId ?? "" } } }, + { enabled: !!primaryFileId }, + ) const { data: primaryFileInfo } = api.useQuery( "get", @@ -319,278 +279,173 @@ export const WizardProvider: FunctionComponent<{ { enabled: !!primaryFileId }, ) + const captionedVideoFileId = workspace?.captions.output_file_id ?? null + const { data: captionedVideoInfo } = api.useQuery( + "get", + "/api/files/files/{file_id}/resolve/", + { params: { path: { file_id: captionedVideoFileId ?? "" } } }, + { enabled: !!captionedVideoFileId }, + ) + + const primaryFileKey = primaryFile?.path ?? primaryFileInfo?.file_path ?? null const videoUrl = primaryFileInfo?.file_url ?? null + const originalFileName = + primaryFile?.original_filename ?? primaryFileInfo?.filename ?? null - /* ---- Save to server (debounced) ---- */ + const workspaceActiveJob = workspace?.active_job ?? null + const activeJobId = + activeJobOverride === undefined + ? (workspaceActiveJob?.job_id ?? null) + : activeJobOverride.jobId + const activeJobType = + activeJobOverride === undefined + ? (workspaceActiveJob?.job_type ?? null) + : activeJobOverride.jobType - const stateToSave = useMemo( - () => ({ - current_step: currentStep, - completed_steps: completedSteps, - primary_file_id: primaryFileId, - primary_file_key: primaryFileKey, - original_file_name: originalFileName, - silence_settings: silenceSettings, - active_job_id: activeJobId, - active_job_type: activeJobType, - silence_job_id: silenceJobId, - transcription_artifact_id: transcriptionArtifactId, - caption_preset_id: captionPresetId, - caption_style_config: captionStyleConfig, - captioned_video_path: captionedVideoPath, - captioned_video_file_id: captionedVideoFileId, - }), - [ - currentStep, - completedSteps, - primaryFileId, - primaryFileKey, - originalFileName, - silenceSettings, - activeJobId, - activeJobType, - silenceJobId, - transcriptionArtifactId, - captionPresetId, - captionStyleConfig, - captionedVideoPath, - captionedVideoFileId, - ], - ) + const silenceJobId = workspace?.silence.detect_job_id ?? null + const transcriptionArtifactId = workspace?.transcription.artifact_id ?? null + const captionPresetId = + captionPresetDraft === undefined + ? (workspace?.captions.preset_id ?? null) + : captionPresetDraft + const captionStyleConfig = + captionStyleConfigDraft === undefined + ? (workspace?.captions.style_config ?? null) + : captionStyleConfigDraft + const captionedVideoPath = captionedVideoInfo?.file_path ?? null - const debouncedState = useDebounce(stateToSave, DEBOUNCE_MS) - - const saveMutation = api.useMutation("patch", "/api/projects/{project_id}/") - - const buildPersistedState = useCallback( - (overrides: Partial = {}): PersistedWizardState => ({ - current_step: overrides.current_step ?? currentStep, - completed_steps: overrides.completed_steps ?? completedSteps, - primary_file_id: overrides.primary_file_id ?? primaryFileId, - primary_file_key: overrides.primary_file_key ?? primaryFileKey, - original_file_name: overrides.original_file_name ?? originalFileName, - silence_settings: overrides.silence_settings ?? silenceSettings, - active_job_id: overrides.active_job_id ?? activeJobId, - active_job_type: overrides.active_job_type ?? activeJobType, - silence_job_id: overrides.silence_job_id ?? silenceJobId, - transcription_artifact_id: - overrides.transcription_artifact_id ?? transcriptionArtifactId, - caption_preset_id: overrides.caption_preset_id ?? captionPresetId, - caption_style_config: - overrides.caption_style_config ?? captionStyleConfig, - captioned_video_path: - overrides.captioned_video_path ?? captionedVideoPath, - captioned_video_file_id: - overrides.captioned_video_file_id ?? captionedVideoFileId, - }), - [ - activeJobId, - activeJobType, - captionedVideoFileId, - captionedVideoPath, - captionPresetId, - captionStyleConfig, - completedSteps, - currentStep, - originalFileName, - primaryFileId, - primaryFileKey, - silenceJobId, - silenceSettings, - transcriptionArtifactId, - ], - ) - - const persistStateNow = useCallback( - (nextState: PersistedWizardState) => { - if (!isInitializedRef.current) return - - const serialized = JSON.stringify(nextState) - initialSerializedRef.current = serialized - saveMutation.mutate({ - params: { path: { project_id: projectId } }, - body: { - workspace_state: { wizard: nextState }, - }, - }) - }, - [projectId, saveMutation], - ) - - useEffect(() => { - if (!isInitializedRef.current) return - - const serialized = JSON.stringify(debouncedState) - if (serialized === initialSerializedRef.current) return - - initialSerializedRef.current = serialized - saveMutation.mutate({ - params: { path: { project_id: projectId } }, - body: { - workspace_state: { wizard: debouncedState }, - }, - }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedState, projectId]) - - /* ---- Auto-advance from Processing when job completes ---- */ - - const notifications = useAppSelector((state) => state.notifications.items) - - const isJobActive = - !!activeJobId && - (currentStep === "processing" || - currentStep === "silence-apply-processing" || - currentStep === "transcription-processing" || - currentStep === "caption-processing") - - const { data: taskStatus } = api.useQuery( + const { data: activeTaskStatus } = api.useQuery( "get", "/api/tasks/status/{job_id}/", { params: { path: { job_id: activeJobId ?? "" } } }, { - enabled: isJobActive, + enabled: !!activeJobId, refetchInterval: 2000, }, ) useEffect(() => { - if (!activeJobId) return - - if (taskStatus?.status === "CANCELLED") { - const cancelledStep = getCancelledStepForJobType(activeJobType) - setActiveJobId(null) - setActiveJobType(null) - if (cancelledStep) { - setCurrentStep(cancelledStep) - } - return - } + if (!projectId || !activeTaskStatus?.status) return if ( - currentStep !== "processing" && - currentStep !== "silence-apply-processing" && - currentStep !== "transcription-processing" && - currentStep !== "transcription-settings" && - currentStep !== "caption-processing" - ) + activeTaskStatus.status !== "DONE" && + activeTaskStatus.status !== "FAILED" && + activeTaskStatus.status !== "CANCELLED" + ) { return - - const isDone = - taskStatus?.status === "DONE" || - notifications.some((n) => n.job_id === activeJobId && n.status === "DONE") - - if (!isDone) return - - if (activeJobType === "SILENCE_DETECT") { - setSilenceJobIdState(activeJobId) - setActiveJobId(null) - setActiveJobType(null) - setCompletedSteps((prev) => - prev.includes("processing") ? prev : [...prev, "processing"], - ) - setCurrentStep("fragments") - } else if (activeJobType === "SILENCE_APPLY") { - const outputData = taskStatus?.output_data as - | { file_id?: string; file_path?: string } - | null - | undefined - - if ( - taskStatus?.status !== "DONE" || - !outputData?.file_id || - !outputData?.file_path - ) { - return - } - - setPrimaryFileId(outputData.file_id) - setPrimaryFileKey(outputData.file_path) - setOriginalFileName(outputData.file_path.split("/").pop() ?? null) - setSilenceJobIdState(null) - setActiveJobId(null) - setActiveJobType(null) - setCompletedSteps((prev) => - prev.includes("silence-apply-processing") - ? prev - : [...prev, "silence-apply-processing"], - ) - setCurrentStep("transcription-settings") - } else if (activeJobType === "TRANSCRIPTION_GENERATE") { - setActiveJobId(null) - setActiveJobType(null) - setCompletedSteps((prev) => - prev.includes("transcription-processing") - ? prev - : [...prev, "transcription-processing"], - ) - setCurrentStep("subtitle-revision") - } else if (activeJobType === "CAPTIONS_GENERATE") { - // Wait for taskStatus to have output_data (not just notification DONE) - // to avoid race where notification fires before polling has fresh data - const outputData = taskStatus?.output_data as - | { output_path?: string; file_id?: string } - | null - | undefined - if (taskStatus?.status !== "DONE" || !outputData?.file_id) { - return - } - setCaptionedVideoPathState(outputData.output_path ?? null) - setCaptionedVideoFileIdState(outputData.file_id) - setActiveJobId(null) - setActiveJobType(null) - setCompletedSteps((prev) => - prev.includes("caption-processing") - ? prev - : [...prev, "caption-processing"], - ) - setCurrentStep("caption-result") } - }, [notifications, activeJobId, activeJobType, currentStep, taskStatus]) - /* ---- Step index ---- */ + queryClient.invalidateQueries({ + queryKey: getProjectWorkspaceQueryKey(projectId), + }) + }, [activeTaskStatus?.status, projectId, queryClient]) - const stepIndex = WIZARD_STEPS.findIndex((s) => s.key === currentStep) + const performWorkflowAction = useCallback( + async ( + action: WorkflowActionRequest, + options?: { + optimisticStep?: WizardStepKey | null + optimisticActiveJob?: JobOverride + }, + ): Promise => { + if (!projectId) return null - /* ---- Actions ---- */ + if (options?.optimisticStep !== undefined) { + setStepOverride(options.optimisticStep) + } + + if (options?.optimisticActiveJob !== undefined) { + setActiveJobOverride(options.optimisticActiveJob) + } + + try { + return await workflowAction.mutateAsync(action) + } catch (error) { + if (options?.optimisticStep !== undefined) { + setStepOverride(null) + } + + if (options?.optimisticActiveJob !== undefined) { + setActiveJobOverride(undefined) + } + + if (isWorkflowConflictError(error)) { + return null + } + + throw error + } + }, + [projectId, workflowAction], + ) const goToStep = useCallback((step: WizardStepKey) => { - setCurrentStep(step) + setStepOverride(step) }, []) const goNext = useCallback(() => { - const idx = WIZARD_STEPS.findIndex((s) => s.key === currentStep) - if (idx < WIZARD_STEPS.length - 1) { - setCurrentStep(WIZARD_STEPS[idx + 1].key) + const currentStepIndex = WIZARD_STEPS.findIndex( + (step) => step.key === currentStep, + ) + if (currentStepIndex < 0 || currentStepIndex >= WIZARD_STEPS.length - 1) { + return } + + setStepOverride(WIZARD_STEPS[currentStepIndex + 1].key) }, [currentStep]) const goBack = useCallback(() => { - const idx = WIZARD_STEPS.findIndex((s) => s.key === currentStep) - if (idx > 0) { - setCurrentStep(WIZARD_STEPS[idx - 1].key) - } + const targetStep = DEFAULT_BACK_STEP_MAP[currentStep] + if (!targetStep) return + setStepOverride(targetStep) }, [currentStep]) const setFileKey = useCallback( - (key: string | null, fileId: string | null, fileName?: string | null) => { - setPrimaryFileId(fileId) - setPrimaryFileKey(key) - if (fileName !== undefined) { - setOriginalFileName(fileName) + async ( + _key: string | null, + fileId: string | null, + _fileName?: string | null, + ) => { + if (!workspace) return + + if (!fileId) { + await performWorkflowAction( + { + type: "RESET_SOURCE_FILE", + revision: workspace.revision, + }, + { + optimisticStep: "upload", + optimisticActiveJob: { jobId: null, jobType: null }, + }, + ) + return } + + await performWorkflowAction( + { + type: "SET_SOURCE_FILE", + revision: workspace.revision, + file_id: fileId, + }, + { + optimisticStep: "verify", + }, + ) }, - [], + [performWorkflowAction, workspace], ) const setSilenceSettings = useCallback((settings: SilenceSettings) => { - setSilenceSettingsState(settings) + setSilenceSettingsDraft(settings) }, []) const setActiveJob = useCallback( (jobId: string | null, jobType?: string | null) => { - setActiveJobId(jobId) - setActiveJobType(jobType ?? null) + setActiveJobOverride({ + jobId, + jobType: jobType ?? null, + }) }, [], ) @@ -600,74 +455,261 @@ export const WizardProvider: FunctionComponent<{ jobId: string, jobType: string, processingStep: WizardStepKey, - completedStep?: WizardStepKey, + _completedStep?: WizardStepKey, ) => { - const nextCompletedSteps = - completedStep && !completedSteps.includes(completedStep) - ? [...completedSteps, completedStep] - : completedSteps - - setActiveJobId(jobId) - setActiveJobType(jobType) - setCurrentStep(processingStep) - setCompletedSteps(nextCompletedSteps) - - persistStateNow( - buildPersistedState({ - active_job_id: jobId, - active_job_type: jobType, - current_step: processingStep, - completed_steps: nextCompletedSteps, - }), - ) - }, - [buildPersistedState, completedSteps, persistStateNow], - ) - - const setSilenceJobId = useCallback((jobId: string | null) => { - setSilenceJobIdState(jobId) - }, []) - - const setTranscriptionArtifactId = useCallback((id: string | null) => { - setTranscriptionArtifactIdState(id) - }, []) - - const setCaptionPresetId = useCallback((id: string | null) => { - setCaptionPresetIdState(id) - }, []) - - const setCaptionStyleConfig = useCallback( - (config: Record | null) => { - setCaptionStyleConfigState(config) + setActiveJobOverride({ + jobId, + jobType, + }) + setStepOverride(processingStep) }, [], ) - const setCaptionedVideoPath = useCallback((path: string | null) => { - setCaptionedVideoPathState(path) - }, []) + const setSilenceJobId = useCallback((_jobId: string | null) => {}, []) + const setTranscriptionArtifactId = useCallback((_id: string | null) => {}, []) + const setCaptionStyleConfig = useCallback( + (config: Record | null) => { + setCaptionStyleConfigDraft(config) + }, + [], + ) + const setCaptionedVideoPath = useCallback((_path: string | null) => {}, []) + const setCaptionedVideoFileId = useCallback((_id: string | null) => {}, []) + const markStepCompleted = useCallback((_step: WizardStepKey) => {}, []) - const setCaptionedVideoFileId = useCallback((id: string | null) => { - setCaptionedVideoFileIdState(id) - }, []) + const confirmVerify = useCallback(async () => { + if (!workspace) return + await performWorkflowAction( + { + type: "CONFIRM_VERIFY", + revision: workspace.revision, + }, + { + optimisticStep: "silence-settings", + }, + ) + }, [performWorkflowAction, workspace]) - const markStepCompleted = useCallback((step: WizardStepKey) => { - setCompletedSteps((prev) => (prev.includes(step) ? prev : [...prev, step])) - }, []) + const startMediaConvert = useCallback(async () => { + if (!workspace) return + await performWorkflowAction({ + type: "START_MEDIA_CONVERT", + revision: workspace.revision, + output_format: "mp4", + }) + }, [performWorkflowAction, workspace]) - /* ---- Value ---- */ + const saveSilenceSettings = useCallback( + async (settings?: SilenceSettings) => { + if (!workspace) return + + const nextSettings = settings ?? silenceSettingsDraft + setSilenceSettingsDraft(nextSettings) + + await performWorkflowAction({ + type: "SET_SILENCE_SETTINGS", + revision: workspace.revision, + settings: nextSettings, + }) + }, + [performWorkflowAction, silenceSettingsDraft, workspace], + ) + + const startSilenceDetect = useCallback( + async (settings?: SilenceSettings) => { + if (!workspace) return + + const nextSettings = settings ?? silenceSettingsDraft + setSilenceSettingsDraft(nextSettings) + + const updatedWorkspace = await performWorkflowAction({ + type: "SET_SILENCE_SETTINGS", + revision: workspace.revision, + settings: nextSettings, + }) + + await performWorkflowAction( + { + type: "START_SILENCE_DETECT", + revision: updatedWorkspace?.revision ?? workspace.revision, + }, + { + optimisticStep: "processing", + }, + ) + }, + [performWorkflowAction, silenceSettingsDraft, workspace], + ) + + const saveSilenceCuts = useCallback( + async (cuts: WorkflowCutRegionPayload[]) => { + if (!workspace) return + await performWorkflowAction({ + type: "SET_SILENCE_CUTS", + revision: workspace.revision, + cuts, + }) + }, + [performWorkflowAction, workspace], + ) + + const skipSilenceApply = useCallback(async () => { + if (!workspace) return + await performWorkflowAction( + { + type: "SKIP_SILENCE_APPLY", + revision: workspace.revision, + }, + { + optimisticStep: "transcription-settings", + }, + ) + }, [performWorkflowAction, workspace]) + + const startSilenceApply = useCallback( + async (cuts: WorkflowCutRegionPayload[]) => { + if (!workspace) return + + const updatedWorkspace = await performWorkflowAction({ + type: "SET_SILENCE_CUTS", + revision: workspace.revision, + cuts, + }) + + await performWorkflowAction( + { + type: "START_SILENCE_APPLY", + revision: updatedWorkspace?.revision ?? workspace.revision, + cuts, + }, + { + optimisticStep: "silence-apply-processing", + }, + ) + }, + [performWorkflowAction, workspace], + ) + + const reopenSilenceReview = useCallback(async () => { + if (!workspace) return + await performWorkflowAction( + { + type: "REOPEN_SILENCE_REVIEW", + revision: workspace.revision, + }, + { + optimisticStep: "fragments", + }, + ) + }, [performWorkflowAction, workspace]) + + const startTranscription = useCallback( + async (request: WorkflowTranscriptionRequest) => { + if (!workspace) return + + await performWorkflowAction( + { + type: "START_TRANSCRIPTION", + revision: workspace.revision, + request, + }, + { + optimisticStep: "transcription-processing", + }, + ) + }, + [performWorkflowAction, workspace], + ) + + const reopenTranscriptionConfig = useCallback(async () => { + if (!workspace) return + await performWorkflowAction( + { + type: "REOPEN_TRANSCRIPTION_CONFIG", + revision: workspace.revision, + }, + { + optimisticStep: "transcription-settings", + }, + ) + }, [performWorkflowAction, workspace]) + + const markTranscriptionReviewed = useCallback(async () => { + if (!workspace) return + await performWorkflowAction( + { + type: "MARK_TRANSCRIPTION_REVIEWED", + revision: workspace.revision, + }, + { + optimisticStep: "caption-settings", + }, + ) + }, [performWorkflowAction, workspace]) + + const selectCaptionPreset = useCallback( + async (presetId: string | null) => { + if (!workspace) return + + setCaptionPresetDraft(presetId) + + await performWorkflowAction({ + type: "SELECT_CAPTION_PRESET", + revision: workspace.revision, + preset_id: presetId, + }) + }, + [performWorkflowAction, workspace], + ) + + const setCaptionPresetId = useCallback( + async (id: string | null) => { + await selectCaptionPreset(id) + }, + [selectCaptionPreset], + ) + + const startCaptionRender = useCallback(async () => { + if (!workspace) return + + await performWorkflowAction( + { + type: "START_CAPTION_RENDER", + revision: workspace.revision, + }, + { + optimisticStep: "caption-processing", + }, + ) + }, [performWorkflowAction, workspace]) + + const reopenCaptionConfig = useCallback(async () => { + if (!workspace) return + + await performWorkflowAction( + { + type: "REOPEN_CAPTION_CONFIG", + revision: workspace.revision, + }, + { + optimisticStep: "caption-settings", + }, + ) + }, [performWorkflowAction, workspace]) const value = useMemo( () => ({ projectId, + revision, currentStep, - stepIndex, + stepIndex: WIZARD_STEPS.findIndex((step) => step.key === currentStep), completedSteps, primaryFileId, primaryFileKey, videoUrl, originalFileName, - silenceSettings, + silenceSettings: silenceSettingsDraft, activeJobId, activeJobType, silenceJobId, @@ -690,39 +732,67 @@ export const WizardProvider: FunctionComponent<{ setCaptionedVideoPath, setCaptionedVideoFileId, markStepCompleted, + confirmVerify, + startMediaConvert, + saveSilenceSettings, + startSilenceDetect, + saveSilenceCuts, + skipSilenceApply, + startSilenceApply, + reopenSilenceReview, + startTranscription, + reopenTranscriptionConfig, + markTranscriptionReviewed, + selectCaptionPreset, + startCaptionRender, + reopenCaptionConfig, }), [ - projectId, - currentStep, - stepIndex, - completedSteps, - primaryFileId, - primaryFileKey, - videoUrl, - originalFileName, - silenceSettings, activeJobId, activeJobType, - silenceJobId, - transcriptionArtifactId, captionPresetId, captionStyleConfig, - captionedVideoPath, captionedVideoFileId, - goToStep, - goNext, + captionedVideoPath, + completedSteps, + confirmVerify, + currentStep, goBack, - setFileKey, - setSilenceSettings, + goNext, + goToStep, + markStepCompleted, + markTranscriptionReviewed, + originalFileName, + primaryFileId, + primaryFileKey, + projectId, + reopenCaptionConfig, + reopenSilenceReview, + reopenTranscriptionConfig, + revision, + saveSilenceCuts, + saveSilenceSettings, + selectCaptionPreset, setActiveJob, - startProcessingJob, - setSilenceJobId, - setTranscriptionArtifactId, setCaptionPresetId, setCaptionStyleConfig, - setCaptionedVideoPath, setCaptionedVideoFileId, - markStepCompleted, + setCaptionedVideoPath, + setFileKey, + setSilenceJobId, + setSilenceSettings, + setTranscriptionArtifactId, + silenceJobId, + silenceSettingsDraft, + skipSilenceApply, + startCaptionRender, + startMediaConvert, + startProcessingJob, + startSilenceApply, + startSilenceDetect, + startTranscription, + transcriptionArtifactId, + videoUrl, ], ) @@ -731,10 +801,6 @@ export const WizardProvider: FunctionComponent<{ ) } -/* ------------------------------------------------------------------ */ -/* Hook */ -/* ------------------------------------------------------------------ */ - export function useWizard(): WizardContextValue { const ctx = useContext(WizardContext) if (!ctx) { diff --git a/src/shared/context/WorkspaceContext.tsx b/src/shared/context/WorkspaceContext.tsx index 1167212..b7bfef2 100644 --- a/src/shared/context/WorkspaceContext.tsx +++ b/src/shared/context/WorkspaceContext.tsx @@ -13,12 +13,13 @@ import { } from "react" import api from "@shared/api" +import { + type WorkflowWorkspaceView, + useProjectWorkspaceQuery, + useWorkflowAction, +} from "@shared/api/projectWorkflow" import { useDebounce } from "@shared/hooks/useDebounce" -/* ------------------------------------------------------------------ */ -/* Types */ -/* ------------------------------------------------------------------ */ - export type SelectedFile = { id: string path: string @@ -43,98 +44,182 @@ interface WorkspaceFileContextValue { isLoaded: boolean } -/* ------------------------------------------------------------------ */ -/* Context */ -/* ------------------------------------------------------------------ */ - const FileContext = createContext(null) -/* ------------------------------------------------------------------ */ -/* Provider */ -/* ------------------------------------------------------------------ */ +const DEBOUNCE_MS = 300 -const DEBOUNCE_MS = 1000 +function getFileIconType(mimeType: string | null | undefined) { + if (!mimeType) return "other" as const + if (mimeType.startsWith("video/")) return "video" as const + if (mimeType.startsWith("audio/")) return "audio" as const + if (mimeType.includes("json") || mimeType.startsWith("text/")) { + return "text" as const + } + return "other" as const +} + +function getArtifactDisplayName(artifactType: string | null | undefined): string { + switch (artifactType) { + case "TRANSCRIPTION_JSON": + return "Субтитры" + default: + return artifactType ?? "Артефакт" + } +} export const WorkspaceProvider: FunctionComponent<{ projectId: string children: ReactNode }> = ({ projectId, children }) => { - const [selectedFile, setSelectedFileState] = useState( + const { data: workspace } = useProjectWorkspaceQuery(projectId) + const workflowAction = useWorkflowAction(projectId) + + const [usedFileIds, setUsedFileIds] = useState([]) + const [selectedPersistedId, setSelectedPersistedId] = useState( null, ) - const [usedFiles, setUsedFiles] = useState([]) - const isInitializedRef = useRef(false) - const initialValueRef = useRef(null) + const [selectedFile, setSelectedFileState] = useState(null) + const latestRevisionRef = useRef(null) - /* ---- Load from server ---- */ + useEffect(() => { + if (!workspace) return - const { data: project, isSuccess } = api.useQuery( - "get", - "/api/projects/{project_id}/", - { params: { path: { project_id: projectId } } }, - { enabled: !!projectId }, + if (latestRevisionRef.current === workspace.revision) { + return + } + + latestRevisionRef.current = workspace.revision + setUsedFileIds(workspace.workspace_view.used_file_ids) + setSelectedPersistedId(workspace.workspace_view.selected_file_id) + }, [workspace]) + + const { data: files } = api.useQuery("get", "/api/files/files/", {}) + const { data: artifacts } = api.useQuery("get", "/api/media/artifacts/", {}) + + const fileMap = useMemo(() => { + const nextMap = new Map() + + for (const file of files ?? []) { + if (file.project_id !== projectId || file.is_deleted) continue + + nextMap.set(file.id, { + id: file.id, + path: file.path, + source: "file", + mimeType: file.mime_type, + displayName: file.original_filename, + iconType: getFileIconType(file.mime_type), + }) + } + + return nextMap + }, [files, projectId]) + + const artifactMap = useMemo(() => { + const nextMap = new Map() + + for (const artifact of artifacts ?? []) { + if (artifact.project_id !== projectId || artifact.is_deleted) continue + + nextMap.set(artifact.id, { + id: artifact.id, + path: "transcription", + source: "artifact", + artifactType: artifact.artifact_type, + displayName: getArtifactDisplayName(artifact.artifact_type), + iconType: + artifact.artifact_type === "TRANSCRIPTION_JSON" ? "text" : "other", + }) + } + + return nextMap + }, [artifacts, projectId]) + + const resolveUsedFile = useCallback( + (fileId: string, previous?: UsedFile | null): UsedFile | null => { + return fileMap.get(fileId) ?? artifactMap.get(fileId) ?? previous ?? null + }, + [fileMap, artifactMap], + ) + + const usedFiles = useMemo( + () => + usedFileIds + .map((fileId) => resolveUsedFile(fileId)) + .filter((file): file is UsedFile => file !== null), + [resolveUsedFile, usedFileIds], ) useEffect(() => { - if (!isSuccess || isInitializedRef.current) return + setSelectedFileState((prev) => { + if (!selectedPersistedId) return null - const saved = project?.workspace_state as - | { used_files?: UsedFile[] } - | null - | undefined - const loaded = saved?.used_files ?? [] + const resolved = resolveUsedFile( + selectedPersistedId, + prev as UsedFile | null, + ) + if (!resolved) return prev - setUsedFiles(loaded) - initialValueRef.current = JSON.stringify(loaded) - isInitializedRef.current = true - }, [isSuccess, project]) + if (prev?.id === selectedPersistedId) { + return { + ...resolved, + scrollToSegmentIndex: prev.scrollToSegmentIndex, + } + } - /* ---- Save to server (debounced) ---- */ - - const debouncedUsedFiles = useDebounce(usedFiles, DEBOUNCE_MS) - - const saveMutation = api.useMutation( - "patch", - "/api/projects/{project_id}/", - ) - - useEffect(() => { - if (!isInitializedRef.current) return - - const serialized = JSON.stringify(debouncedUsedFiles) - if (serialized === initialValueRef.current) return - - initialValueRef.current = serialized - saveMutation.mutate({ - params: { path: { project_id: projectId } }, - body: { - workspace_state: { used_files: debouncedUsedFiles }, - }, + return resolved }) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [debouncedUsedFiles, projectId]) + }, [resolveUsedFile, selectedPersistedId]) - /* ---- Actions ---- */ - - const setSelectedFile = useCallback( - (file: SelectedFile | null) => setSelectedFileState(file), - [], + const persistableWorkspaceView = useMemo( + () => ({ + used_file_ids: usedFileIds, + selected_file_id: selectedPersistedId, + }), + [selectedPersistedId, usedFileIds], ) + const debouncedWorkspaceView = useDebounce( + persistableWorkspaceView, + DEBOUNCE_MS, + ) + + useEffect(() => { + if (!workspace) return + + const localSignature = JSON.stringify(debouncedWorkspaceView) + const serverSignature = JSON.stringify(workspace.workspace_view) + + if (localSignature === serverSignature) return + + void workflowAction.mutateAsync({ + type: "SET_WORKSPACE_VIEW", + revision: workspace.revision, + workspace_view: debouncedWorkspaceView, + }) + }, [debouncedWorkspaceView, workflowAction, workspace]) + + const setSelectedFile = useCallback((file: SelectedFile | null) => { + setSelectedFileState(file) + setSelectedPersistedId(file?.id ?? null) + }, []) + const addUsedFile = useCallback((file: UsedFile) => { - setUsedFiles((prev) => { - if (prev.some((f) => f.id === file.id)) return prev - return [...prev, file] + setUsedFileIds((prev) => { + if (prev.includes(file.id)) return prev + return [...prev, file.id] }) }, []) const removeUsedFile = useCallback((id: string) => { - setUsedFiles((prev) => prev.filter((f) => f.id !== id)) + setUsedFileIds((prev) => prev.filter((fileId) => fileId !== id)) + setSelectedPersistedId((prev) => (prev === id ? null : prev)) + setSelectedFileState((prev) => (prev?.id === id ? null : prev)) }, []) const isFileUsed = useCallback( - (id: string) => usedFiles.some((f) => f.id === id), - [usedFiles], + (id: string) => usedFileIds.includes(id), + [usedFileIds], ) const value = useMemo( @@ -145,82 +230,22 @@ export const WorkspaceProvider: FunctionComponent<{ addUsedFile, removeUsedFile, isFileUsed, - isLoaded: isInitializedRef.current, + isLoaded: Boolean(workspace), }), [ + addUsedFile, + isFileUsed, + removeUsedFile, selectedFile, setSelectedFile, usedFiles, - addUsedFile, - removeUsedFile, - isFileUsed, + workspace, ], ) return {children} } -/* ------------------------------------------------------------------ */ -/* Static provider (in-memory only, no server persistence) */ -/* ------------------------------------------------------------------ */ - -export const StaticWorkspaceProvider: FunctionComponent<{ - children: ReactNode -}> = ({ children }) => { - const [selectedFile, setSelectedFileState] = useState( - null, - ) - const [usedFiles, setUsedFiles] = useState([]) - - const setSelectedFile = useCallback( - (file: SelectedFile | null) => setSelectedFileState(file), - [], - ) - - const addUsedFile = useCallback((file: UsedFile) => { - setUsedFiles((prev) => { - if (prev.some((f) => f.id === file.id)) return prev - return [...prev, file] - }) - }, []) - - const removeUsedFile = useCallback((id: string) => { - setUsedFiles((prev) => prev.filter((f) => f.id !== id)) - }, []) - - const isFileUsed = useCallback( - (id: string) => usedFiles.some((f) => f.id === id), - [usedFiles], - ) - - const value = useMemo( - () => ({ - selectedFile, - setSelectedFile, - usedFiles, - addUsedFile, - removeUsedFile, - isFileUsed, - isLoaded: true, - }), - [ - selectedFile, - setSelectedFile, - usedFiles, - addUsedFile, - removeUsedFile, - isFileUsed, - ], - ) - - return {children} -} - -/* ------------------------------------------------------------------ */ -/* Hook */ -/* ------------------------------------------------------------------ */ - -/** File selection & used-files list — stable during playback */ export function useWorkspaceFiles(): WorkspaceFileContextValue { const ctx = useContext(FileContext) if (!ctx) { diff --git a/tests/e2e/specs/project/caption-settings.spec.ts b/tests/e2e/specs/project/caption-settings.spec.ts index a775475..ba6dd24 100644 --- a/tests/e2e/specs/project/caption-settings.spec.ts +++ b/tests/e2e/specs/project/caption-settings.spec.ts @@ -2,12 +2,14 @@ import { expect, test } from "@playwright/test" const USER_ID = "00000000-0000-0000-0000-000000000001" const PROJECT_ID = "65df675b-013b-4b1f-ab2d-075dadbcd0d9" +const SOURCE_FILE_ID = "00000000-0000-0000-0000-000000000011" const CAPTION_PRESET_ID = "00000000-0000-0000-0000-000000000010" const TRANSCRIPTION_ARTIFACT_ID = "00000000-0000-0000-0000-000000000020" const TRANSCRIPTION_ID = "00000000-0000-0000-0000-000000000030" const CAPTION_JOB_ID = "00000000-0000-0000-0000-000000000040" const PRIMARY_FILE_KEY = "projects/test/video.mp4" +const PRIMARY_FILE_URL = "http://localhost:4444/files/video.mp4" const DEFAULT_USER = { id: USER_ID, @@ -26,53 +28,49 @@ const DEFAULT_USER = { } test.describe("Caption Settings Step", () => { - test("should recover a missing transcription artifact from project data", async ({ + test("should render from typed workspace and start caption render via workflow action", async ({ page, }) => { - let project: Record = { - id: PROJECT_ID, - owner_id: USER_ID, - name: "Тестовый проект", - description: null, - language: "auto", - folder: null, - status: "DRAFT", - workspace_state: { - wizard: { - current_step: "caption-settings", - completed_steps: [ - "upload", - "verify", - "silence-settings", - "processing", - "fragments", - "transcription-settings", - "transcription-processing", - "subtitle-revision", - ], - primary_file_key: PRIMARY_FILE_KEY, - video_url: "http://localhost:9000/projects/test/video.mp4", - silence_settings: { - min_silence_duration_ms: 200, - silence_threshold_db: 16, - padding_ms: 100, - }, - active_job_id: null, - active_job_type: null, - silence_job_id: null, - transcription_artifact_id: null, - caption_preset_id: CAPTION_PRESET_ID, - caption_style_config: null, - captioned_video_path: null, - }, + let workflowActions: Array> = [] + let workspace: Record = { + revision: 1, + phase: "CAPTIONS", + current_screen: "caption-settings", + active_job: null, + source_file_id: SOURCE_FILE_ID, + workspace_view: { + used_file_ids: [], + selected_file_id: null, + }, + silence: { + status: "SKIPPED", + settings: { + min_silence_duration_ms: 200, + silence_threshold_db: 16, + padding_ms: 100, + }, + detect_job_id: null, + detected_segments: [], + reviewed_cuts: [], + duration_ms: null, + applied_output_file_id: null, + }, + transcription: { + status: "REVIEW_READY", + job_id: null, + request: null, + artifact_id: TRANSCRIPTION_ARTIFACT_ID, + transcription_id: TRANSCRIPTION_ID, + reviewed: true, + }, + captions: { + status: "CONFIG_READY", + preset_id: CAPTION_PRESET_ID, + style_config: null, + render_job_id: null, + output_file_id: null, }, - is_active: true, - created_at: "2025-06-01T00:00:00Z", - updated_at: "2025-06-01T00:00:00Z", } - let savedWizardState: Record | null = null - let generateRequestBody: Record | null = null - let generateRequestCount = 0 await page.context().addCookies([ { @@ -98,37 +96,128 @@ test.describe("Caption Settings Step", () => { }) await page.route(`**/api/projects/${PROJECT_ID}/`, async (route) => { - if (route.request().method() === "GET") { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(project), - }) - return - } - - if (route.request().method() === "PATCH") { - const body = route.request().postDataJSON() as { - workspace_state?: { wizard?: Record } - } - - savedWizardState = body.workspace_state?.wizard ?? null - project = { - ...project, - workspace_state: body.workspace_state ?? project.workspace_state, - } - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(project), - }) - return - } - - await route.fallback() + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: PROJECT_ID, + owner_id: USER_ID, + name: "Тестовый проект", + description: null, + language: "auto", + folder: null, + status: "DRAFT", + workspace_state: null, + is_active: true, + created_at: "2025-06-01T00:00:00Z", + updated_at: "2025-06-01T00:00:00Z", + }), + }) }) + await page.route(`**/api/projects/${PROJECT_ID}/workspace*`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(workspace), + }) + }) + + await page.route( + `**/api/projects/${PROJECT_ID}/workflow/actions*`, + async (route) => { + const action = route.request().postDataJSON() as Record + workflowActions.push(action) + + if (action.type === "START_CAPTION_RENDER") { + workspace = { + ...workspace, + revision: 2, + current_screen: "caption-processing", + active_job: { + job_id: CAPTION_JOB_ID, + job_type: "CAPTIONS_GENERATE", + }, + captions: { + ...workspace.captions, + status: "RUNNING", + render_job_id: CAPTION_JOB_ID, + }, + } + } + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(workspace), + }) + }, + ) + + await page.route("**/api/files/files/", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + id: SOURCE_FILE_ID, + project_id: PROJECT_ID, + owner_id: USER_ID, + original_filename: "video.mp4", + path: PRIMARY_FILE_KEY, + storage_backend: "S3", + mime_type: "video/mp4", + size_bytes: 1024, + checksum: null, + file_format: "mp4", + is_uploaded: true, + is_deleted: false, + is_active: true, + created_at: "2025-06-01T00:00:00Z", + }, + ]), + }) + }) + + await page.route(`**/api/files/files/${SOURCE_FILE_ID}/`, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: SOURCE_FILE_ID, + project_id: PROJECT_ID, + owner_id: USER_ID, + original_filename: "video.mp4", + path: PRIMARY_FILE_KEY, + storage_backend: "S3", + mime_type: "video/mp4", + size_bytes: 1024, + checksum: null, + file_format: "mp4", + is_uploaded: true, + is_deleted: false, + is_active: true, + created_at: "2025-06-01T00:00:00Z", + }), + }) + }) + + await page.route( + `**/api/files/files/${SOURCE_FILE_ID}/resolve/`, + async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + file_id: SOURCE_FILE_ID, + file_url: PRIMARY_FILE_URL, + file_path: PRIMARY_FILE_KEY, + filename: "video.mp4", + }), + }) + }, + ) + await page.route("**/api/media/artifacts/", async (route) => { await route.fulfill({ status: 200, @@ -149,20 +238,6 @@ test.describe("Caption Settings Step", () => { }) }) - await page.route( - `**/api/transcribe/transcriptions/by-artifact/${TRANSCRIPTION_ARTIFACT_ID}/`, - async (route) => { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - id: TRANSCRIPTION_ID, - artifact_id: TRANSCRIPTION_ARTIFACT_ID, - }), - }) - }, - ) - await page.route("**/api/captions/presets/", async (route) => { await route.fulfill({ status: 200, @@ -183,27 +258,13 @@ test.describe("Caption Settings Step", () => { }) }) - await page.route("**/api/tasks/captions-generate/", async (route) => { - generateRequestCount += 1 - generateRequestBody = route.request().postDataJSON() as Record< - string, - unknown - > - - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ job_id: CAPTION_JOB_ID }), - }) - }) - await page.route("**/api/tasks/status/**", async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ status: "RUNNING", - progress_pct: 0, + progress_pct: 25, output_data: null, }), }) @@ -220,26 +281,13 @@ test.describe("Caption Settings Step", () => { await expect(captionStep.getByText("Системный пресет")).toBeVisible() await expect(generateButton).toBeEnabled() - await expect - .poll(() => savedWizardState?.transcription_artifact_id ?? null) - .toBe(TRANSCRIPTION_ARTIFACT_ID) - await generateButton.click() - expect(generateRequestBody).toMatchObject({ - video_s3_path: PRIMARY_FILE_KEY, - transcription_id: TRANSCRIPTION_ID, - project_id: PROJECT_ID, - preset_id: CAPTION_PRESET_ID, + expect(workflowActions).toHaveLength(1) + expect(workflowActions[0]).toMatchObject({ + type: "START_CAPTION_RENDER", + revision: 1, }) - expect(generateRequestCount).toBe(1) - - await expect - .poll(() => savedWizardState?.current_step ?? null) - .toBe("caption-processing") - await expect - .poll(() => savedWizardState?.active_job_id ?? null) - .toBe(CAPTION_JOB_ID) await expect(page.locator("[data-testid='ProcessingStep']")).toBeVisible() }) diff --git a/tests/e2e/specs/project/silence-apply.spec.ts b/tests/e2e/specs/project/silence-apply.spec.ts index 282d9ca..b3cf5f5 100644 --- a/tests/e2e/specs/project/silence-apply.spec.ts +++ b/tests/e2e/specs/project/silence-apply.spec.ts @@ -2,7 +2,6 @@ import { expect, test } from "@playwright/test" const USER_ID = "00000000-0000-0000-0000-000000000001" const PROJECT_ID = "75df675b-013b-4b1f-ab2d-075dadbcd0d9" -const DETECT_JOB_ID = "00000000-0000-0000-0000-000000000050" const APPLY_JOB_ID = "00000000-0000-0000-0000-000000000051" const TRANSCRIPTION_JOB_ID = "00000000-0000-0000-0000-000000000052" const ORIGINAL_FILE_ID = "00000000-0000-0000-0000-000000000060" @@ -34,51 +33,50 @@ const MOCK_SEGMENTS = [ ] test.describe("Silence Apply Flow", () => { - test("should show processing for cut application and transcribe the processed video", async ({ + test("should persist cuts via workflow actions and continue to transcription on processed source file", async ({ page, }) => { - let project: Record = { - id: PROJECT_ID, - owner_id: USER_ID, - name: "Тестовый проект", - description: null, - language: "auto", - folder: null, - status: "DRAFT", - workspace_state: { - wizard: { - current_step: "fragments", - completed_steps: [ - "upload", - "verify", - "silence-settings", - "processing", - ], - primary_file_id: ORIGINAL_FILE_ID, - primary_file_key: ORIGINAL_FILE_KEY, - original_file_name: "original-video.mp4", - silence_settings: { - min_silence_duration_ms: 200, - silence_threshold_db: 16, - padding_ms: 100, - }, - active_job_id: null, - active_job_type: null, - silence_job_id: DETECT_JOB_ID, - transcription_artifact_id: null, - caption_preset_id: null, - caption_style_config: null, - captioned_video_path: null, - captioned_video_file_id: null, - }, + let applyStatus: "RUNNING" | "DONE" = "RUNNING" + const workflowActions: Array> = [] + let workspace: Record = { + revision: 1, + phase: "SILENCE", + current_screen: "fragments", + active_job: null, + source_file_id: ORIGINAL_FILE_ID, + workspace_view: { + used_file_ids: [], + selected_file_id: null, + }, + silence: { + status: "REVIEW_READY", + settings: { + min_silence_duration_ms: 200, + silence_threshold_db: 16, + padding_ms: 100, + }, + detect_job_id: "00000000-0000-0000-0000-000000000050", + detected_segments: MOCK_SEGMENTS, + reviewed_cuts: [], + duration_ms: 30000, + applied_output_file_id: null, + }, + transcription: { + status: "IDLE", + job_id: null, + request: null, + artifact_id: null, + transcription_id: null, + reviewed: false, + }, + captions: { + status: "IDLE", + preset_id: null, + style_config: null, + render_job_id: null, + output_file_id: null, }, - is_active: true, - created_at: "2025-06-01T00:00:00Z", - updated_at: "2025-06-01T00:00:00Z", } - let savedWizardState: Record | null = null - let applyStatus = "RUNNING" - let transcriptionRequestBody: Record | null = null await page.context().addCookies([ { @@ -104,35 +102,154 @@ test.describe("Silence Apply Flow", () => { }) await page.route(`**/api/projects/${PROJECT_ID}/`, async (route) => { - if (route.request().method() === "GET") { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify(project), - }) - return + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: PROJECT_ID, + owner_id: USER_ID, + name: "Тестовый проект", + description: null, + language: "auto", + folder: null, + status: "DRAFT", + workspace_state: null, + is_active: true, + created_at: "2025-06-01T00:00:00Z", + updated_at: "2025-06-01T00:00:00Z", + }), + }) + }) + + await page.route(`**/api/projects/${PROJECT_ID}/workspace*`, async (route) => { + if ( + applyStatus === "DONE" && + workspace.current_screen === "silence-apply-processing" + ) { + workspace = { + ...workspace, + revision: 4, + phase: "TRANSCRIPTION", + current_screen: "transcription-settings", + active_job: null, + source_file_id: CUT_FILE_ID, + silence: { + ...workspace.silence, + status: "APPLIED", + applied_output_file_id: CUT_FILE_ID, + }, + } } - if (route.request().method() === "PATCH") { - const body = route.request().postDataJSON() as { - workspace_state?: { wizard?: Record } + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(workspace), + }) + }) + + await page.route( + `**/api/projects/${PROJECT_ID}/workflow/actions*`, + async (route) => { + const action = route.request().postDataJSON() as Record + workflowActions.push(action) + + if (action.type === "SET_SILENCE_CUTS") { + workspace = { + ...workspace, + revision: 2, + silence: { + ...workspace.silence, + reviewed_cuts: action.cuts as typeof MOCK_SEGMENTS, + }, + } } - savedWizardState = body.workspace_state?.wizard ?? null - project = { - ...project, - workspace_state: body.workspace_state ?? project.workspace_state, + if (action.type === "START_SILENCE_APPLY") { + workspace = { + ...workspace, + revision: 3, + current_screen: "silence-apply-processing", + active_job: { + job_id: APPLY_JOB_ID, + job_type: "SILENCE_APPLY", + }, + silence: { + ...workspace.silence, + status: "APPLYING", + }, + } + } + + if (action.type === "START_TRANSCRIPTION") { + workspace = { + ...workspace, + revision: 5, + current_screen: "transcription-processing", + active_job: { + job_id: TRANSCRIPTION_JOB_ID, + job_type: "TRANSCRIPTION_GENERATE", + }, + transcription: { + ...workspace.transcription, + status: "RUNNING", + job_id: TRANSCRIPTION_JOB_ID, + request: action.request as { + engine: "whisper" + language?: string + model: string + }, + }, + } } await route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(project), + body: JSON.stringify(workspace), }) - return - } + }, + ) - await route.fallback() + await page.route("**/api/files/files/", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([ + { + id: ORIGINAL_FILE_ID, + project_id: PROJECT_ID, + owner_id: USER_ID, + original_filename: "original-video.mp4", + path: ORIGINAL_FILE_KEY, + storage_backend: "S3", + mime_type: "video/mp4", + size_bytes: 1024, + checksum: null, + file_format: "mp4", + is_uploaded: true, + is_deleted: false, + is_active: true, + created_at: "2025-06-01T00:00:00Z", + }, + { + id: CUT_FILE_ID, + project_id: PROJECT_ID, + owner_id: USER_ID, + original_filename: "cut-video.mp4", + path: CUT_FILE_KEY, + storage_backend: "S3", + mime_type: "video/mp4", + size_bytes: 1024, + checksum: null, + file_format: "mp4", + is_uploaded: true, + is_deleted: false, + is_active: true, + created_at: "2025-06-01T00:00:00Z", + }, + ]), + }) }) await page.route("**/api/files/files/*/resolve/", async (route) => { @@ -146,30 +263,50 @@ test.describe("Silence Apply Flow", () => { file_id: isCutFile ? CUT_FILE_ID : ORIGINAL_FILE_ID, file_url: isCutFile ? CUT_FILE_URL : ORIGINAL_FILE_URL, file_path: isCutFile ? CUT_FILE_KEY : ORIGINAL_FILE_KEY, + filename: isCutFile ? "cut-video.mp4" : "original-video.mp4", }), }) }) + await page.route("**/api/files/files/*/", async (route) => { + const fileId = route.request().url().split("/files/")[1]?.split("/")[0] + const isCutFile = fileId === CUT_FILE_ID + + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + id: isCutFile ? CUT_FILE_ID : ORIGINAL_FILE_ID, + project_id: PROJECT_ID, + owner_id: USER_ID, + original_filename: isCutFile + ? "cut-video.mp4" + : "original-video.mp4", + path: isCutFile ? CUT_FILE_KEY : ORIGINAL_FILE_KEY, + storage_backend: "S3", + mime_type: "video/mp4", + size_bytes: 1024, + checksum: null, + file_format: "mp4", + is_uploaded: true, + is_deleted: false, + is_active: true, + created_at: "2025-06-01T00:00:00Z", + }), + }) + }) + + await page.route("**/api/media/artifacts/", async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify([]), + }) + }) + await page.route("**/api/tasks/status/**", async (route) => { const url = route.request().url() - if (url.includes(DETECT_JOB_ID)) { - await route.fulfill({ - status: 200, - contentType: "application/json", - body: JSON.stringify({ - status: "DONE", - job_type: "SILENCE_DETECT", - progress_pct: 100, - output_data: { - silent_segments: MOCK_SEGMENTS, - duration_ms: 30000, - }, - }), - }) - return - } - if (url.includes(APPLY_JOB_ID)) { await route.fulfill({ status: 200, @@ -190,6 +327,20 @@ test.describe("Silence Apply Flow", () => { return } + if (url.includes(TRANSCRIPTION_JOB_ID)) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + status: "RUNNING", + job_type: "TRANSCRIPTION_GENERATE", + progress_pct: 10, + output_data: null, + }), + }) + return + } + await route.fulfill({ status: 200, contentType: "application/json", @@ -201,60 +352,45 @@ test.describe("Silence Apply Flow", () => { }) }) - await page.route("**/api/tasks/silence-apply/", async (route) => { - await route.fulfill({ - status: 202, - contentType: "application/json", - body: JSON.stringify({ job_id: APPLY_JOB_ID }), - }) - }) - - await page.route("**/api/tasks/transcription-generate/", async (route) => { - transcriptionRequestBody = route.request().postDataJSON() as Record< - string, - unknown - > - - await route.fulfill({ - status: 202, - contentType: "application/json", - body: JSON.stringify({ job_id: TRANSCRIPTION_JOB_ID }), - }) - }) - await page.goto(`/projects/${PROJECT_ID}`) - const fragmentsStep = page.locator("[data-testid='FragmentsStep']") - await expect(fragmentsStep).toBeVisible() + await expect(page.locator("[data-testid='FragmentsStep']")).toBeVisible() + await expect(page.locator("[data-testid='cut-region']")).toHaveCount(2) - await fragmentsStep.getByRole("button", { name: "Применить" }).click() + await page.getByRole("button", { name: "Применить" }).click() + + await expect.poll(() => workflowActions.length).toBe(2) + + expect(workflowActions[0]).toMatchObject({ + type: "SET_SILENCE_CUTS", + revision: 1, + }) + expect(workflowActions[1]).toMatchObject({ + type: "START_SILENCE_APPLY", + revision: 2, + }) await expect(page.locator("[data-testid='ProcessingStep']")).toBeVisible() - await expect - .poll(() => savedWizardState?.active_job_type ?? null) - .toBe("SILENCE_APPLY") - await expect - .poll(() => savedWizardState?.current_step ?? null) - .toBe("processing") applyStatus = "DONE" - const transcriptionStep = page.locator( - "[data-testid='TranscriptionSettingsStep']", - ) - await expect(transcriptionStep).toBeVisible({ timeout: 10_000 }) + await expect( + page.locator("[data-testid='TranscriptionSettingsStep']"), + ).toBeVisible() - await expect - .poll(() => savedWizardState?.primary_file_key ?? null) - .toBe(CUT_FILE_KEY) + await page.getByRole("button", { name: "Сгенерировать субтитры" }).click() - await transcriptionStep - .getByRole("button", { name: "Сгенерировать субтитры" }) - .click() - - expect(transcriptionRequestBody).toMatchObject({ - file_key: CUT_FILE_KEY, - project_id: PROJECT_ID, + expect(workflowActions[2]).toMatchObject({ + type: "START_TRANSCRIPTION", + revision: 4, + request: { + engine: "whisper", + model: "base", + }, }) + + await expect(page.locator("[data-testid='ProcessingStep']")).toContainText( + "ТРАНСКРИБАЦИЯ", + ) }) })