iter 2
This commit is contained in:
@@ -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) : "—"}{" "}
|
||||
· {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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user