Files
main_frontend/src/features/project/VerifyStep/VerifyStep.tsx
T
2026-04-27 23:28:28 +03:00

349 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 from "@shared/api"
import { useWizard } from "@shared/context/WizardContext"
import { useTaskProgressState } from "@shared/hooks/useTaskProgressState"
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 {
primaryFileKey,
videoUrl,
originalFileName,
activeJobId,
activeJobType,
goBack,
confirmVerify,
setFileKey,
setActiveJob,
startMediaConvert,
} = 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 handleConvert = useCallback(() => {
void startMediaConvert().catch(() => {
setConvertError(ERROR_CONVERT_FAILED)
})
}, [startMediaConvert])
const {
progressPct: convertProgressPct,
message: convertMessage,
status: convertTaskStatus,
errorMessage: convertErrorMessage,
} = useTaskProgressState({
jobId: convertJobId,
enabled: !!convertJobId && convertStatus === "converting",
defaultMessage: "Конвертация видео...",
})
useEffect(() => {
if (!convertJobId || convertStatus !== "converting") return
if (convertTaskStatus === "FAILED") {
setActiveJob(null)
setConvertError(convertErrorMessage ?? "Ошибка конвертации")
}
}, [convertErrorMessage, convertJobId, convertStatus, convertTaskStatus, setActiveJob])
/* ---- Handlers ---- */
const handleReplace = () => {
void setFileKey(null, null, null)
}
const handleNext = () => {
void confirmVerify()
}
/* ---- 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={!primaryFileKey}
>
Конвертировать в 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={!primaryFileKey}
>
Конвертировать в 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>
)
}