This commit is contained in:
Daniil
2026-04-04 14:51:40 +03:00
parent 10a1d28f77
commit 0523ef3d72
191 changed files with 12065 additions and 2658 deletions
@@ -0,0 +1,392 @@
"use client"
import type { IVerifyStepProps } from "./VerifyStep.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider } from "@vidstack/react"
import {
defaultLayoutIcons,
DefaultVideoLayout,
} from "@vidstack/react/player/layouts/default"
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import {
AlertTriangle,
CheckCircle,
FileVideo,
HardDrive,
Info,
Monitor,
Music,
RefreshCw,
} from "lucide-react"
import {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useState,
} from "react"
import cs from "classnames"
import api, { fetchClient } from "@shared/api"
import { useWizard } from "@shared/context/WizardContext"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Badge, Button, CircularProgress } from "@shared/ui"
import { StaticLoader } from "@shared/ui/Loader"
import { buildCancelJobPayload, useCancelJob } from "../useCancelJob"
import styles from "./VerifyStep.module.scss"
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} Б`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} ГБ`
}
const ERROR_CONVERT_FAILED = "Не удалось запустить конвертацию"
export const VerifyStep: FunctionComponent<IVerifyStepProps> = ({
className,
}): JSX.Element => {
const {
projectId,
primaryFileKey,
videoUrl,
originalFileName,
activeJobId,
activeJobType,
goBack,
goNext,
goToStep,
markStepCompleted,
setFileKey,
setActiveJob,
startProcessingJob,
} = useWizard()
const [convertError, setConvertError] = useState<string | null>(null)
const { mutate: cancelJob, isPending: isCancelling } = useCancelJob()
/* Derive conversion state from wizard-persisted activeJob */
const convertJobId = activeJobType === "MEDIA_CONVERT" ? activeJobId : null
const convertStatus: "idle" | "converting" | "failed" = convertJobId
? "converting"
: convertError
? "failed"
: "idle"
const { data: probeData, isPending: isProbing } = api.useQuery(
"get",
"/api/media/get_meta/",
{ params: { query: { file_path: primaryFileKey ?? "" } } },
{ enabled: !!primaryFileKey },
)
const mediaInfo = useMemo(() => {
if (!probeData) return null
const videoStream = probeData.streams?.find((s) => s.codec_type === "video")
const audioStream = probeData.streams?.find((s) => s.codec_type === "audio")
const format = probeData.format
const rawName = originalFileName ?? primaryFileKey?.split("/").pop() ?? null
const actualFileName = primaryFileKey?.split("/").pop() ?? rawName
const ext = actualFileName?.split(".").pop()?.toUpperCase() ?? null
return {
filename: rawName,
size: format?.size ? Number(format.size) : null,
formatName: ext,
width: videoStream?.width ?? null,
height: videoStream?.height ?? null,
audioCodec: audioStream?.codec_name ?? null,
}
}, [probeData, originalFileName, primaryFileKey])
const needsConversion = useMemo(() => {
if (!mediaInfo?.formatName) return false
return mediaInfo.formatName !== "MP4"
}, [mediaInfo])
/* ---- 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,
},
})
}, [convertMutation, primaryFileKey, projectId])
const convertNotification = useAppSelector((state) =>
convertJobId
? state.notifications.items.find((n) => n.job_id === convertJobId)
: null,
)
const convertProgressPct = convertNotification?.progress_pct ?? 0
const convertMessage = convertNotification?.message ?? "Конвертация видео..."
useEffect(() => {
if (!convertJobId || convertStatus !== "converting") return
if (convertNotification?.status === "DONE") {
fetchConvertedFileFromJob(convertJobId)
}
if (convertNotification?.status === "FAILED") {
setActiveJob(null)
setConvertError(convertNotification?.message ?? "Ошибка конвертации")
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [convertNotification, convertJobId, convertStatus])
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_path?: string
file_url?: string
} | null
if (outputData?.file_path && outputData?.file_url) {
const convertedName = outputData.file_path.split("/").pop() ?? null
setFileKey(outputData.file_path, outputData.file_url, convertedName)
setActiveJob(null)
}
},
[setFileKey, setActiveJob],
)
/* ---- Handlers ---- */
const handleReplace = () => {
setFileKey("", "", null)
goToStep("upload")
}
const handleNext = () => {
markStepCompleted("verify")
goNext()
}
/* ---- Converting view ---- */
if (convertStatus === "converting") {
return (
<div className={cs(styles.root, className)} data-testid="VerifyStep">
<div className={styles.convertingContent}>
<div className={styles.progressWrapper}>
<CircularProgress
percentage={convertProgressPct}
size={200}
strokeWidth={8}
color="var(--color-success)"
className={styles.circle}
bgClassName={styles.circleBg}
valueClassName={styles.circleValue}
/>
<div className={styles.progressInner}>
<span className={styles.percentage}>
{Math.round(convertProgressPct)}%
</span>
<span className={styles.statusLabel}>КОНВЕРТАЦИЯ</span>
</div>
</div>
<p className={styles.convertDescription}>{convertMessage}</p>
<div className={styles.convertInfoCard}>
<Info size={16} className={styles.convertInfoIcon} />
<span>
Конвертация выполняется на сервере. Вы можете покинуть страницу
прогресс сохранится.
</span>
</div>
<Button
variant="danger"
size="sm"
onClick={() => {
if (!convertJobId || isCancelling) return
cancelJob(buildCancelJobPayload(convertJobId), {
onSuccess: () => {
setActiveJob(null)
},
})
}}
disabled={isCancelling}
>
{isCancelling ? "Отмена..." : "Отменить конвертацию"}
</Button>
</div>
</div>
)
}
/* ---- Normal / needs-conversion view ---- */
return (
<div className={cs(styles.root, className)} data-testid="VerifyStep">
<div className={styles.layout}>
{/* Video player */}
<div className={styles.playerWrapper}>
{isProbing && primaryFileKey ? (
<div className={styles.placeholder}>
<StaticLoader block description="Анализ видеофайла..." />
</div>
) : needsConversion ? (
<div className={styles.placeholder}>
<FileVideo size={48} />
<p>Формат {mediaInfo?.formatName ?? ""} не поддерживается</p>
<Button
variant="primary"
size="sm"
onClick={handleConvert}
disabled={convertMutation.isPending}
>
Конвертировать в MP4
</Button>
</div>
) : videoUrl ? (
<MediaPlayer src={videoUrl} crossOrigin="" playsInline>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
) : (
<div className={styles.placeholder}>
<FileVideo size={48} />
<p>Видео не загружено</p>
</div>
)}
</div>
{/* Info sidebar */}
<div className={styles.sidebar}>
<div className={styles.statusRow}>
{isProbing && primaryFileKey ? (
<Badge variant="info">
<Info size={14} />
Анализ файла...
</Badge>
) : needsConversion ? (
<Badge variant="warning">
<AlertTriangle size={14} />
Требуется конвертация
</Badge>
) : (
<Badge variant="success">
<CheckCircle size={14} />
Готово к обработке
</Badge>
)}
</div>
<div className={styles.infoCards}>
<div className={styles.infoCard}>
<FileVideo size={16} className={styles.infoIcon} />
<div className={styles.infoContent}>
<span className={styles.infoLabel}>Файл</span>
<span className={styles.infoValue}>
{mediaInfo?.filename ?? "—"}
</span>
</div>
</div>
<div className={styles.infoCard}>
<HardDrive size={16} className={styles.infoIcon} />
<div className={styles.infoContent}>
<span className={styles.infoLabel}>Размер и формат</span>
<span className={styles.infoValue}>
{mediaInfo?.size ? formatFileSize(mediaInfo.size) : "—"}{" "}
&middot; {mediaInfo?.formatName ?? "—"}
</span>
</div>
</div>
<div className={styles.infoCard}>
<Monitor size={16} className={styles.infoIcon} />
<div className={styles.infoContent}>
<span className={styles.infoLabel}>Разрешение</span>
<span className={styles.infoValue}>
{mediaInfo?.width && mediaInfo?.height
? `${mediaInfo.width}x${mediaInfo.height}`
: "—"}
</span>
</div>
</div>
<div className={styles.infoCard}>
<Music size={16} className={styles.infoIcon} />
<div className={styles.infoContent}>
<span className={styles.infoLabel}>Аудиокодек</span>
<span className={styles.infoValue}>
{mediaInfo?.audioCodec ?? "—"}
</span>
</div>
</div>
</div>
<div className={styles.sidebarActions}>
{needsConversion ? (
<>
{convertError && (
<p className={styles.convertErrorText}>{convertError}</p>
)}
<Button
variant="primary"
size="sm"
onClick={handleConvert}
disabled={convertMutation.isPending}
>
Конвертировать в MP4
</Button>
</>
) : null}
<Button variant="danger" size="sm" onClick={handleReplace}>
<RefreshCw size={14} />
Заменить видео
</Button>
{!needsConversion && (
<Button variant="outline" size="sm" disabled>
Предварительная обрезка
</Button>
)}
</div>
</div>
</div>
{/* Footer */}
<div className={styles.footer}>
<Button variant="outline" onClick={goBack}>
Назад
</Button>
<Button
variant="primary"
onClick={handleNext}
disabled={needsConversion}
>
Далее: Настройки тишины
</Button>
</div>
</div>
)
}