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
@@ -5,6 +5,8 @@
// Rounded hover for ghost icon button
:global(.rt-IconButton) {
border-radius: variables.$radius-sm;
transition: background-color variables.$duration-normal variables.$ease-out,
color variables.$duration-normal variables.$ease-out;
}
}
@@ -17,7 +19,7 @@
height: 16px;
padding: 0 4px;
border-radius: 9999px;
background-color: #ef4444;
background-color: var(--color-danger);
color: #fff;
font-size: 10px;
font-weight: 700;
@@ -26,4 +28,10 @@
pointer-events: none;
border: 1.5px solid variables.$bg-default;
box-sizing: content-box;
animation: badgePulse 2s var(--ease-out) infinite;
}
@keyframes badgePulse {
0%, 100% { transform: translate(50%, -50%) scale(1); }
50% { transform: translate(50%, -50%) scale(1.08); }
}
@@ -8,42 +8,48 @@
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 360px;
width: 380px;
max-height: 480px;
background-color: variables.$bg-surface;
background-color: variables.$bg-default;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: variables.$shadow-lg;
box-shadow: var(--shadow-lg);
z-index: 100;
display: flex;
flex-direction: column;
overflow: hidden;
animation: popupEntrance 0.2s var(--ease-out) both;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid variables.$border-subtle;
padding: 14px 16px;
border-bottom: 1px solid variables.$border-default;
}
.title {
@include typography.font-body-14(600);
@include typography.font-body-14(700);
letter-spacing: -0.006em;
color: variables.$text-primary;
}
.readAllBtn {
@include typography.font-caption-m;
font-weight: 500;
font-weight: 600;
color: variables.$purple-500;
background: none;
border: none;
cursor: pointer;
padding: 0;
padding: 4px 8px;
border-radius: variables.$radius-sm;
transition: background-color variables.$duration-normal variables.$ease-out,
color variables.$duration-normal variables.$ease-out;
&:hover {
color: variables.$purple-700;
background-color: variables.$bg-surface;
}
}
@@ -57,10 +63,10 @@
gap: 12px;
padding: 12px 16px;
cursor: pointer;
transition: background-color 0.15s;
transition: background-color variables.$duration-normal variables.$ease-out;
&:hover {
background-color: variables.$bg-hover;
background-color: variables.$bg-surface;
}
&:not(:last-child) {
@@ -78,7 +84,7 @@
}
.itemTitle {
@include typography.font-body-14(500);
@include typography.font-body-14(600);
color: variables.$text-primary;
display: flex;
align-items: center;
@@ -109,23 +115,23 @@
padding: 1px 6px;
border-radius: 9999px;
font-size: 11px;
font-weight: 500;
font-weight: 600;
line-height: 16px;
}
.statusRunning {
background-color: #dbeafe;
color: #1d4ed8;
background-color: hsl(262, 50%, 94%);
color: hsl(262, 72%, 45%);
}
.statusDone {
background-color: #dcfce7;
color: #15803d;
background-color: hsl(150, 30%, 92%);
color: hsl(150, 50%, 30%);
}
.statusFailed {
background-color: #fee2e2;
color: #b91c1c;
background-color: hsl(0, 80%, 95%);
color: hsl(0, 65%, 40%);
}
.progressBar {
@@ -141,12 +147,23 @@
height: 100%;
background-color: variables.$purple-500;
border-radius: 2px;
transition: width 0.3s ease;
transition: width 0.4s var(--ease-out);
}
.empty {
padding: 32px 16px;
padding: 40px 16px;
text-align: center;
@include typography.font-body-14(400);
color: variables.$text-tertiary;
}
@keyframes popupEntrance {
from {
opacity: 0;
transform: translateY(-4px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@@ -20,9 +20,10 @@ interface IProfileFormData {
phone_number: string
}
export const EditProfileForm: FunctionComponent<
IEditProfileFormProps
> = ({ user, className }): JSX.Element => {
export const EditProfileForm: FunctionComponent<IEditProfileFormProps> = ({
user,
className,
}): JSX.Element => {
const dispatch = useAppDispatch()
const [successMessage, setSuccessMessage] = useState(false)
@@ -78,7 +79,7 @@ export const EditProfileForm: FunctionComponent<
/>
<TextField
id="email"
label="Email"
label="Эл. почта"
placeholder="Ваш email"
type="email"
{...register("email")}
@@ -0,0 +1,3 @@
export interface ICaptionResultStepProps {
className?: string
}
@@ -0,0 +1,58 @@
.root {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
}
.title {
font-size: 20px;
font-weight: 600;
color: var(--gray-12);
margin: 0;
}
.playerWrapper {
border-radius: 12px;
overflow: hidden;
background: #000;
max-height: 60vh;
aspect-ratio: 16 / 9;
}
.player {
width: 100%;
height: 100%;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
aspect-ratio: 16 / 9;
color: var(--gray-9);
}
.filename {
font-size: 13px;
color: var(--gray-9);
margin: 0;
}
.loading {
padding: 48px;
text-align: center;
color: var(--gray-9);
}
.footer {
display: flex;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--gray-6);
}
.rightActions {
display: flex;
gap: 8px;
}
@@ -0,0 +1,159 @@
"use client"
import type { ICaptionResultStepProps } from "./CaptionResultStep.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 { Download, RefreshCw } from "lucide-react"
import { FunctionComponent, useMemo } from "react"
import cs from "classnames"
import api from "@shared/api"
import { useWizard } from "@shared/context/WizardContext"
import { Button } from "@shared/ui"
import styles from "./CaptionResultStep.module.scss"
export const CaptionResultStep: FunctionComponent<ICaptionResultStepProps> = ({
className,
}): JSX.Element => {
const {
projectId,
captionedVideoFileId,
captionedVideoPath,
goToStep,
markStepCompleted,
setCaptionedVideoFileId,
setCaptionedVideoPath,
} = 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: fileRecord } = api.useQuery(
"get",
"/api/files/files/{file_id}/",
{ params: { path: { file_id: effectiveFileId ?? "" } } },
{ enabled: !!effectiveFileId },
)
const filePath = fileRecord?.path ?? effectivePath ?? ""
const { data: fileInfo, isLoading } = api.useQuery(
"get",
"/api/files/get_file/",
{ params: { query: { file_path: filePath } } },
{ enabled: !!filePath },
)
const videoUrl = fileInfo?.file_url ?? ""
const handleDownload = () => {
if (!videoUrl) return
const link = document.createElement("a")
link.href = videoUrl
link.download = fileInfo?.filename ?? "captioned-video.mp4"
link.click()
}
const handleRerender = () => {
goToStep("caption-settings")
}
const handleFinish = () => {
markStepCompleted("caption-result")
}
if (isLoading) {
return (
<div className={cs(styles.root, className)}>
<p className={styles.loading}>Загрузка видео...</p>
</div>
)
}
return (
<div className={cs(styles.root, className)} data-testid="CaptionResultStep">
<h2 className={styles.title}>Результат</h2>
<div className={styles.playerWrapper}>
{videoUrl ? (
<MediaPlayer
src={videoUrl}
crossOrigin=""
playsInline
className={styles.player}
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
) : (
<div className={styles.placeholder}>Видео недоступно</div>
)}
</div>
{fileInfo?.filename && (
<p className={styles.filename}>{fileInfo.filename}</p>
)}
<div className={styles.footer}>
<Button variant="outline" onClick={handleRerender}>
<RefreshCw size={16} />
Перегенерировать
</Button>
<div className={styles.rightActions}>
<Button variant="outline" onClick={handleDownload}>
<Download size={16} />
Скачать
</Button>
<Button variant="primary" onClick={handleFinish}>
Завершить
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { CaptionResultStep } from "./CaptionResultStep"
@@ -0,0 +1,3 @@
export interface ICaptionSettingsStepProps {
className?: string
}
@@ -0,0 +1,36 @@
.root {
display: flex;
flex-direction: column;
gap: 24px;
padding: 24px;
flex: 1;
min-height: 0;
overflow: hidden;
}
.title {
font-size: 20px;
font-weight: 600;
color: var(--gray-12);
margin: 0;
}
.scrollArea {
flex: 1;
min-height: 0;
overflow-y: auto;
container-type: size;
}
.footer {
display: flex;
justify-content: space-between;
padding-top: 16px;
border-top: 1px solid var(--gray-6);
}
.error {
color: var(--color-danger);
font-size: 13px;
margin: 0;
}
@@ -0,0 +1,214 @@
"use client"
import type { ICaptionSettingsStepProps } from "./CaptionSettingsStep.d"
import type { components } from "@shared/api/__generated__/openapi.types"
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,
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<CaptionPresetRead | null>(
null,
)
const [submitError, setSubmitError] = useState<string | null>(null)
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
}
submitLockRef.current = true
setSubmitError(null)
mutate({
body: {
video_s3_path: primaryFileKey,
folder: "output_files",
transcription_id: transcriptionId,
project_id: projectId,
preset_id: captionPresetId,
},
})
}
const handleEdit = (preset: CaptionPresetRead) => {
setEditingPreset(preset)
setActiveTab("editor")
}
const handleCreateNew = () => {
setEditingPreset(null)
setActiveTab("editor")
}
const handleSaved = (presetId: string) => {
setCaptionPresetId(presetId)
setActiveTab("select")
}
if (activeTab === "editor") {
return (
<div
className={cs(styles.root, className)}
data-testid="CaptionSettingsStep"
>
<h2 className={styles.title}>Редактор стиля</h2>
<StyleEditor
initialConfig={editingPreset?.style_config}
presetId={editingPreset?.id}
presetName={editingPreset?.name}
onSaved={handleSaved}
onCancel={() => setActiveTab("select")}
/>
</div>
)
}
return (
<div
className={cs(styles.root, className)}
data-testid="CaptionSettingsStep"
>
<h2 className={styles.title}>Выбор пресета субтитров</h2>
<div className={styles.scrollArea}>
<PresetGrid
selectedPresetId={captionPresetId}
onSelect={setCaptionPresetId}
onEdit={handleEdit}
onCreateNew={handleCreateNew}
/>
</div>
{submitError && <p className={styles.error}>{submitError}</p>}
<div className={styles.footer}>
<Button variant="outline" onClick={goBack}>
Назад
</Button>
<Button
variant="primary"
onClick={handleGenerate}
disabled={
!captionPresetId || isPending || isResolvingSourceData
}
>
{isPending ? "Запуск..." : "Генерировать"}
</Button>
</div>
</div>
)
}
@@ -0,0 +1,127 @@
.grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-content: flex-start;
gap: 16px;
}
.card {
position: relative;
display: flex;
flex-direction: column;
height: 100cqh;
box-sizing: border-box;
border: 2px solid var(--gray-6);
border-radius: 12px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s ease;
background: var(--gray-2);
&:hover {
border-color: var(--gray-8);
}
}
.selected {
border-color: var(--accent-9);
&:hover {
border-color: var(--accent-10);
}
}
.cardFooter {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
}
.cardName {
font-size: 13px;
font-weight: 500;
color: var(--gray-12);
}
.systemBadge {
font-size: 10px;
font-weight: 500;
color: var(--accent-11);
background: var(--accent-3);
padding: 2px 6px;
border-radius: 4px;
}
.cardActions {
position: absolute;
top: 8px;
right: 8px;
display: flex;
gap: 4px;
opacity: 0;
transition: opacity 0.15s ease;
.card:hover & {
opacity: 1;
}
}
.iconButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 6px;
background: var(--gray-3);
color: var(--gray-11);
cursor: pointer;
transition: background 0.1s ease;
&:hover {
background: var(--gray-5);
color: var(--gray-12);
}
}
.createCard {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
height: 100cqh;
box-sizing: border-box;
aspect-ratio: 9 / 16;
border-style: dashed;
border-color: var(--gray-7);
&:hover {
border-color: var(--accent-8);
}
}
.createIcon {
color: var(--gray-9);
}
.createLabel {
font-size: 13px;
color: var(--gray-9);
}
.loading {
padding: 48px;
text-align: center;
color: var(--gray-9);
}
.deleteActions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 16px;
}
@@ -0,0 +1,150 @@
"use client"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { JSX } from "react"
import cs from "classnames"
import { Pencil, Plus, Trash2 } from "lucide-react"
import { FunctionComponent, useState } from "react"
import { Button, Modal } from "@shared/ui"
import { StylePreview } from "./StylePreview"
import { useDeletePreset, usePresetsQuery } from "./useCaptionPresets"
import styles from "./PresetGrid.module.scss"
type CaptionPresetRead = components["schemas"]["CaptionPresetRead"]
interface IPresetGridProps {
selectedPresetId: string | null
onSelect: (presetId: string) => void
onEdit: (preset: CaptionPresetRead) => void
onCreateNew: () => void
}
const PresetCard: FunctionComponent<{
preset: CaptionPresetRead
isSelected: boolean
onSelect: () => void
onEdit: () => void
onDelete: () => void
}> = ({ preset, isSelected, onSelect, onEdit, onDelete }) => (
<div
className={cs(styles.card, { [styles.selected]: isSelected })}
onClick={onSelect}
role="button"
tabIndex={0}
>
<StylePreview config={preset.style_config} size="small" />
<div className={styles.cardFooter}>
<span className={styles.cardName}>{preset.name}</span>
{preset.is_system && (
<span className={styles.systemBadge}>Системный</span>
)}
</div>
{!preset.is_system && (
<div className={styles.cardActions}>
<button
className={styles.iconButton}
onClick={(e) => {
e.stopPropagation()
onEdit()
}}
title="Редактировать"
>
<Pencil size={14} />
</button>
<button
className={styles.iconButton}
onClick={(e) => {
e.stopPropagation()
onDelete()
}}
title="Удалить"
>
<Trash2 size={14} />
</button>
</div>
)}
</div>
)
export const PresetGrid: FunctionComponent<IPresetGridProps> = ({
selectedPresetId,
onSelect,
onEdit,
onCreateNew,
}): JSX.Element => {
const { data: presets, isLoading } = usePresetsQuery()
const deletePreset = useDeletePreset()
const [deleteTarget, setDeleteTarget] = useState<CaptionPresetRead | null>(
null,
)
const handleConfirmDelete = () => {
if (!deleteTarget) return
deletePreset.mutate(
{ params: { path: { preset_id: deleteTarget.id } } },
{ onSettled: () => setDeleteTarget(null) },
)
}
if (isLoading) {
return <div className={styles.loading}>Загрузка пресетов...</div>
}
return (
<>
<div className={styles.grid}>
{presets?.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPresetId === preset.id}
onSelect={() => onSelect(preset.id)}
onEdit={() => onEdit(preset)}
onDelete={() => setDeleteTarget(preset)}
/>
))}
<div
className={cs(styles.card, styles.createCard)}
onClick={onCreateNew}
role="button"
tabIndex={0}
>
<Plus size={32} className={styles.createIcon} />
<span className={styles.createLabel}>Создать пресет</span>
</div>
</div>
{deleteTarget && (
<Modal
open={!!deleteTarget}
onOpenChange={(open) => !open && setDeleteTarget(null)}
title="Удаление пресета"
>
<p>
Удалить пресет &laquo;{deleteTarget.name}&raquo;? Это
действие нельзя отменить.
</p>
<div className={styles.deleteActions}>
<Button
variant="outline"
onClick={() => setDeleteTarget(null)}
>
Отмена
</Button>
<Button
variant="danger"
onClick={handleConfirmDelete}
disabled={deletePreset.isPending}
>
Удалить
</Button>
</div>
</Modal>
)}
</>
)
}
@@ -0,0 +1,132 @@
.editor {
display: flex;
flex-direction: column;
gap: 16px;
}
.nameRow {
display: flex;
gap: 8px;
}
.nameField {
flex: 1;
}
.tabs {
flex: 1;
}
.fields {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
padding-top: 16px;
}
.fieldGroup {
display: flex;
flex-direction: column;
gap: 8px;
background: var(--gray-2);
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--gray-4);
transition: border-color 0.2s;
&:hover {
border-color: var(--gray-6);
}
}
.sliderField {
display: flex;
flex-direction: column;
justify-content: center;
background: var(--gray-2);
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--gray-4);
transition: border-color 0.2s;
&:hover {
border-color: var(--gray-6);
}
}
.fieldLabel {
font-size: 13px;
font-weight: 500;
color: var(--gray-11);
}
.colorField {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
background: var(--gray-2);
padding: 12px 16px;
border-radius: 12px;
border: 1px solid var(--gray-4);
transition: border-color 0.2s;
&:hover {
border-color: var(--gray-6);
}
}
.colorSwatch {
width: 28px;
height: 28px;
border-radius: 6px;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
}
.colorPopover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 20;
padding: 16px;
background: var(--gray-1);
border: 1px solid var(--gray-6);
border-radius: 16px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
}
.colorClose {
display: block;
width: 100%;
margin-top: 12px;
padding: 6px;
font-size: 13px;
font-weight: 500;
border: none;
border-radius: 8px;
background: var(--accent-9);
color: white;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: var(--accent-10);
}
}
.editorFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 16px;
border-top: 1px solid var(--gray-6);
}
@@ -0,0 +1,678 @@
"use client"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { JSX } from "react"
import cs from "classnames"
import { FunctionComponent, useCallback, useRef, useState } from "react"
import { HexColorPicker } from "react-colorful"
import { Controller, useForm, useWatch } from "react-hook-form"
import {
Button,
Select,
SelectItem,
Slider,
Tabs,
TabsContent,
TabsList,
TabsTrigger,
TextField,
} from "@shared/ui"
import { StylePreview } from "./StylePreview"
import { useCreatePreset, useUpdatePreset } from "./useCaptionPresets"
import styles from "./StyleEditor.module.scss"
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
interface IStyleEditorProps {
initialConfig?: CaptionStyleConfig | null
presetId?: string | null
presetName?: string
onSaved: (presetId: string) => void
onCancel: () => void
}
interface FormValues {
name: string
text: {
font_family: string
font_size: number
font_weight: number
text_color: string
highlight_color: string
text_shadow: string | null
text_stroke_width: number
text_stroke_color: string
}
layout: {
vertical_position: "top" | "center" | "bottom"
horizontal_alignment: "left" | "center" | "right"
padding_px: number
max_width_pct: number
lines_per_screen: number
}
animation: {
highlight_style: "color" | "scale" | "underline" | "color_scale"
highlight_scale: number
segment_transition: "fade" | "slide" | "none"
fade_duration_frames: number
animation_speed: number
}
background: {
bg_color: string
bg_blur_px: number
bg_glow_color: string | null
bg_border_radius_px: number
bg_padding_px: number
}
}
const DEFAULT_VALUES: FormValues = {
name: "",
text: {
font_family: "Lobster",
font_size: 40,
font_weight: 400,
text_color: "#FFFFFF",
highlight_color: "#FFFF00",
text_shadow: "0 2px 4px rgba(0,0,0,0.5)" as string | null,
text_stroke_width: 0,
text_stroke_color: "#000000",
},
layout: {
vertical_position: "bottom" as const,
horizontal_alignment: "center" as const,
padding_px: 16,
max_width_pct: 90,
lines_per_screen: 2,
},
animation: {
highlight_style: "color" as const,
highlight_scale: 1.2,
segment_transition: "fade" as const,
fade_duration_frames: 5,
animation_speed: 1.0,
},
background: {
bg_color: "rgba(0,0,0,0.6)",
bg_blur_px: 0,
bg_glow_color: null as string | null,
bg_border_radius_px: 8,
bg_padding_px: 12,
},
}
/* ------------------------------------------------------------------ */
/* Color picker field */
/* ------------------------------------------------------------------ */
const ColorField: FunctionComponent<{
value: string
onChange: (val: string) => void
label: string
}> = ({ value, onChange, label }) => {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
return (
<div className={styles.colorField} ref={ref}>
<span className={styles.fieldLabel}>{label}</span>
<button
type="button"
className={styles.colorSwatch}
style={{ backgroundColor: value || "transparent" }}
onClick={() => setOpen(!open)}
/>
{open && (
<div className={styles.colorPopover}>
<HexColorPicker color={value} onChange={onChange} />
<button
type="button"
className={styles.colorClose}
onClick={() => setOpen(false)}
>
Готово
</button>
</div>
)}
</div>
)
}
/* ------------------------------------------------------------------ */
/* Sub-tab: Текст */
/* ------------------------------------------------------------------ */
const TextFields: FunctionComponent<{
control: ReturnType<typeof useForm<FormValues>>["control"]
}> = ({ control }) => (
<div className={styles.fields}>
<Controller
name="text.font_family"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>Шрифт</span>
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Шрифт"
>
{["Lobster", "Inter", "Roboto", "Montserrat", "Open Sans"].map(
(f) => (
<SelectItem key={f} value={f}>
{f}
</SelectItem>
),
)}
</Select>
</div>
)}
/>
<Controller
name="text.font_size"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Размер шрифта"
unit="px"
min={16}
max={96}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="text.font_weight"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>Начертание</span>
<Select
value={String(field.value)}
onValueChange={(v) => field.onChange(Number(v))}
placeholder="Начертание"
>
<SelectItem value="400">Обычный</SelectItem>
<SelectItem value="700">Жирный</SelectItem>
</Select>
</div>
)}
/>
<Controller
name="text.text_color"
control={control}
render={({ field }) => (
<ColorField
label="Цвет текста"
value={field.value}
onChange={field.onChange}
/>
)}
/>
<Controller
name="text.highlight_color"
control={control}
render={({ field }) => (
<ColorField
label="Цвет выделения"
value={field.value}
onChange={field.onChange}
/>
)}
/>
<Controller
name="text.text_stroke_width"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Обводка текста"
unit="px"
min={0}
max={5}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="text.text_stroke_color"
control={control}
render={({ field }) => (
<ColorField
label="Цвет обводки"
value={field.value}
onChange={field.onChange}
/>
)}
/>
</div>
)
/* ------------------------------------------------------------------ */
/* Sub-tab: Позиция */
/* ------------------------------------------------------------------ */
const LayoutFields: FunctionComponent<{
control: ReturnType<typeof useForm<FormValues>>["control"]
}> = ({ control }) => (
<div className={styles.fields}>
<Controller
name="layout.vertical_position"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>
Вертикальная позиция
</span>
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Позиция"
>
<SelectItem value="top">Сверху</SelectItem>
<SelectItem value="center">По центру</SelectItem>
<SelectItem value="bottom">Снизу</SelectItem>
</Select>
</div>
)}
/>
<Controller
name="layout.horizontal_alignment"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>Выравнивание</span>
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Выравнивание"
>
<SelectItem value="left">Слева</SelectItem>
<SelectItem value="center">По центру</SelectItem>
<SelectItem value="right">Справа</SelectItem>
</Select>
</div>
)}
/>
<Controller
name="layout.max_width_pct"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Макс. ширина"
unit="%"
min={20}
max={100}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="layout.padding_px"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Отступы"
unit="px"
min={0}
max={64}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="layout.lines_per_screen"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Строк на экране"
min={1}
max={4}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
</div>
)
/* ------------------------------------------------------------------ */
/* Sub-tab: Анимация */
/* ------------------------------------------------------------------ */
const AnimationFields: FunctionComponent<{
control: ReturnType<typeof useForm<FormValues>>["control"]
}> = ({ control }) => (
<div className={styles.fields}>
<Controller
name="animation.highlight_style"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>Стиль выделения</span>
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Стиль"
>
<SelectItem value="color">Цвет</SelectItem>
<SelectItem value="scale">Масштаб</SelectItem>
<SelectItem value="underline">Подчёркивание</SelectItem>
<SelectItem value="color_scale">
Цвет + масштаб
</SelectItem>
</Select>
</div>
)}
/>
<Controller
name="animation.highlight_scale"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Масштаб выделения"
min={1.0}
max={2.0}
step={0.1}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="animation.segment_transition"
control={control}
render={({ field }) => (
<div className={styles.fieldGroup}>
<span className={styles.fieldLabel}>Переход</span>
<Select
value={field.value}
onValueChange={field.onChange}
placeholder="Переход"
>
<SelectItem value="fade">Затухание</SelectItem>
<SelectItem value="slide">Сдвиг</SelectItem>
<SelectItem value="none">Без перехода</SelectItem>
</Select>
</div>
)}
/>
<Controller
name="animation.fade_duration_frames"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Длительность перехода"
unit=" кадров"
min={0}
max={30}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="animation.animation_speed"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Скорость анимации"
min={0.5}
max={2.0}
step={0.1}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
</div>
)
/* ------------------------------------------------------------------ */
/* Sub-tab: Фон */
/* ------------------------------------------------------------------ */
const BackgroundFields: FunctionComponent<{
control: ReturnType<typeof useForm<FormValues>>["control"]
}> = ({ control }) => (
<div className={styles.fields}>
<Controller
name="background.bg_color"
control={control}
render={({ field }) => (
<ColorField
label="Цвет фона"
value={field.value}
onChange={field.onChange}
/>
)}
/>
<Controller
name="background.bg_blur_px"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Размытие фона"
unit="px"
min={0}
max={20}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="background.bg_glow_color"
control={control}
render={({ field }) => (
<ColorField
label="Цвет свечения"
value={field.value ?? ""}
onChange={field.onChange}
/>
)}
/>
<Controller
name="background.bg_border_radius_px"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Скругление углов"
unit="px"
min={0}
max={24}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
<Controller
name="background.bg_padding_px"
control={control}
render={({ field }) => (
<div className={styles.sliderField}>
<Slider
label="Внутренний отступ"
unit="px"
min={0}
max={32}
value={field.value}
onChange={field.onChange}
/>
</div>
)}
/>
</div>
)
/* ------------------------------------------------------------------ */
/* Main editor */
/* ------------------------------------------------------------------ */
const buildDefaultValues = (
config?: CaptionStyleConfig | null,
name?: string,
): FormValues => ({
name: name ?? "",
text: { ...DEFAULT_VALUES.text, ...config?.text },
layout: { ...DEFAULT_VALUES.layout, ...config?.layout },
animation: { ...DEFAULT_VALUES.animation, ...config?.animation },
background: { ...DEFAULT_VALUES.background, ...config?.background },
})
export const StyleEditor: FunctionComponent<IStyleEditorProps> = ({
initialConfig,
presetId,
presetName,
onSaved,
onCancel,
}): JSX.Element => {
const isEditing = !!presetId
const { control, handleSubmit, formState } = useForm<FormValues>({
defaultValues: buildDefaultValues(initialConfig, presetName),
})
const watchedValues = useWatch({ control })
const previewConfig: CaptionStyleConfig = {
text: watchedValues.text as CaptionStyleConfig["text"],
layout: watchedValues.layout as CaptionStyleConfig["layout"],
animation: watchedValues.animation as CaptionStyleConfig["animation"],
background:
watchedValues.background as CaptionStyleConfig["background"],
}
const createPreset = useCreatePreset()
const updatePreset = useUpdatePreset()
const isSaving = createPreset.isPending || updatePreset.isPending
const onSubmit = useCallback(
(data: FormValues) => {
const styleConfig: CaptionStyleConfig = {
text: data.text,
layout: data.layout,
animation: data.animation,
background: data.background,
}
if (isEditing && presetId) {
updatePreset.mutate(
{
params: { path: { preset_id: presetId } },
body: {
name: data.name || undefined,
style_config: styleConfig,
},
},
{ onSuccess: () => onSaved(presetId) },
)
} else {
createPreset.mutate(
{
body: {
name: data.name,
style_config: styleConfig,
},
},
{ onSuccess: (res) => onSaved(res.id) },
)
}
},
[isEditing, presetId, createPreset, updatePreset, onSaved],
)
return (
<form
className={styles.editor}
onSubmit={handleSubmit(onSubmit)}
data-testid="StyleEditor"
>
<StylePreview config={previewConfig} size="large" />
<div className={styles.nameRow}>
<Controller
name="name"
control={control}
rules={{ required: !isEditing }}
render={({ field }) => (
<TextField
{...field}
id="preset-name"
placeholder="Название пресета"
className={styles.nameField}
/>
)}
/>
</div>
<Tabs defaultValue="text" className={styles.tabs}>
<TabsList>
<TabsTrigger value="text">Текст</TabsTrigger>
<TabsTrigger value="layout">Позиция</TabsTrigger>
<TabsTrigger value="animation">Анимация</TabsTrigger>
<TabsTrigger value="background">Фон</TabsTrigger>
</TabsList>
<TabsContent value="text">
<TextFields control={control} />
</TabsContent>
<TabsContent value="layout">
<LayoutFields control={control} />
</TabsContent>
<TabsContent value="animation">
<AnimationFields control={control} />
</TabsContent>
<TabsContent value="background">
<BackgroundFields control={control} />
</TabsContent>
</Tabs>
<div className={styles.editorFooter}>
<Button
type="button"
variant="outline"
onClick={onCancel}
>
Отмена
</Button>
<Button
type="submit"
variant="primary"
disabled={isSaving || (!isEditing && !formState.dirtyFields.name)}
>
{isSaving
? "Сохранение..."
: isEditing
? "Сохранить"
: "Создать пресет"}
</Button>
</div>
</form>
)
}
@@ -0,0 +1,18 @@
.root {
display: flex;
flex-direction: column;
background: #0c0a1a;
border-radius: 8px;
overflow: hidden;
}
.small {
--preview-h: calc(100cqh - 38px);
height: var(--preview-h);
width: calc(var(--preview-h) * 9 / 16);
}
.large {
aspect-ratio: 9 / 16;
max-height: 400px;
}
@@ -0,0 +1,123 @@
"use client"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { JSX } from "react"
import { FunctionComponent } from "react"
import cs from "classnames"
import styles from "./StylePreview.module.scss"
type CaptionStyleConfig = components["schemas"]["CaptionStyleConfig"]
interface IStylePreviewProps {
config?: CaptionStyleConfig | null
size?: "small" | "large"
className?: string
}
const SMALL_SCALE = 0.65
const buildContainerStyles = (
config: CaptionStyleConfig,
scale: number,
): React.CSSProperties => {
const bg = config.background
return {
backgroundColor: bg?.bg_color ?? "rgba(0,0,0,0.6)",
borderRadius: (bg?.bg_border_radius_px ?? 8) * scale,
padding: (bg?.bg_padding_px ?? 12) * scale,
...(bg?.bg_blur_px
? { backdropFilter: `blur(${bg.bg_blur_px * scale}px)` }
: {}),
...(bg?.bg_glow_color
? { boxShadow: `0 0 ${20 * scale}px ${bg.bg_glow_color}` }
: {}),
}
}
const buildTextStyles = (
config: CaptionStyleConfig,
scale: number,
): React.CSSProperties => {
const text = config.text
return {
fontFamily: text?.font_family ?? "Lobster",
fontSize: (text?.font_size ?? 40) * scale,
fontWeight: text?.font_weight ?? 400,
color: text?.text_color ?? "#FFFFFF",
textAlign:
(config.layout?.horizontal_alignment as "left" | "center" | "right") ??
"center",
...(text?.text_shadow ? { textShadow: text.text_shadow } : {}),
...(text?.text_stroke_width
? {
WebkitTextStroke: `${(text.text_stroke_width ?? 0) * scale}px ${text.text_stroke_color ?? "#000000"}`,
}
: {}),
}
}
const VERTICAL_MAP: Record<string, string> = {
top: "flex-start",
center: "center",
bottom: "flex-end",
}
const HORIZONTAL_MAP: Record<string, string> = {
left: "flex-start",
center: "center",
right: "flex-end",
}
const buildPositionStyles = (
config: CaptionStyleConfig,
scale: number,
): React.CSSProperties => {
const layout = config.layout
const vPos = layout?.vertical_position ?? "bottom"
const hAlign = layout?.horizontal_alignment ?? "center"
const padding = (layout?.padding_px ?? 20) * scale
return {
justifyContent: VERTICAL_MAP[vPos] ?? "flex-end",
alignItems: HORIZONTAL_MAP[hAlign] ?? "center",
padding,
}
}
export const StylePreview: FunctionComponent<IStylePreviewProps> = ({
config,
size = "small",
className,
}): JSX.Element => {
const safeConfig = config ?? {}
const highlightColor = safeConfig.text?.highlight_color ?? "#FFFF00"
const scale = size === "small" ? SMALL_SCALE : 1
return (
<div
className={cs(styles.root, styles[size], className)}
style={buildPositionStyles(safeConfig, scale)}
data-testid="StylePreview"
>
<div
style={{
...buildContainerStyles(safeConfig, scale),
maxWidth: "100%",
boxSizing: "border-box",
}}
>
<span
style={{
...buildTextStyles(safeConfig, scale),
wordBreak: "break-word",
}}
>
Пример <span style={{ color: highlightColor }}>субтитров</span>
</span>
</div>
</div>
)
}
@@ -0,0 +1 @@
export { CaptionSettingsStep } from "./CaptionSettingsStep"
@@ -0,0 +1,39 @@
import { useQueryClient } from "@tanstack/react-query"
import api from "@shared/api"
const PRESETS_QUERY_KEY = ["get", "/api/captions/presets/"]
export const usePresetsQuery = () => {
return api.useQuery("get", "/api/captions/presets/", {})
}
export const useCreatePreset = () => {
const queryClient = useQueryClient()
return api.useMutation("post", "/api/captions/presets/", {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
},
})
}
export const useUpdatePreset = () => {
const queryClient = useQueryClient()
return api.useMutation("patch", "/api/captions/presets/{preset_id}/", {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
},
})
}
export const useDeletePreset = () => {
const queryClient = useQueryClient()
return api.useMutation("delete", "/api/captions/presets/{preset_id}/", {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PRESETS_QUERY_KEY })
},
})
}
@@ -0,0 +1,20 @@
import api from "@shared/api"
interface IUseSubmitCaptionGenerateParams {
onSuccess?: (data: { job_id: string }) => void
onError?: (error: unknown) => void
}
export const useSubmitCaptionGenerate = ({
onSuccess,
onError,
}: IUseSubmitCaptionGenerateParams = {}) => {
return api.useMutation("post", "/api/tasks/captions-generate/", {
onSuccess: (data) => {
onSuccess?.(data)
},
onError: (error) => {
onError?.(error)
},
})
}
@@ -1,8 +1,7 @@
import type { Dialog } from "@radix-ui/themes"
import type { ComponentProps } from "react"
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
export interface ICreateProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
IModalProps,
"open" | "onOpenChange"
> {
onCreated?: () => void | Promise<void>
@@ -1,5 +1,5 @@
.root {
min-width: 520px;
width: 100%;
}
.fields {
+2 -3
View File
@@ -1,8 +1,7 @@
import type { Dialog } from "@radix-ui/themes"
import type { ComponentProps } from "react"
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
export interface IDeleteFileModalProps
extends Pick<ComponentProps<typeof Dialog.Root>, "open" | "onOpenChange"> {
extends Pick<IModalProps, "open" | "onOpenChange"> {
fileName: string
onConfirm: () => void
isPending: boolean
@@ -1,5 +1,5 @@
.root {
min-width: 420px;
width: 100%;
}
.message {
@@ -1,9 +1,8 @@
import type { Dialog } from "@radix-ui/themes"
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IDeleteProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
IModalProps,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
@@ -1,5 +1,5 @@
.root {
min-width: 420px;
width: 100%;
}
.message {
@@ -1,9 +1,8 @@
import type { Dialog } from "@radix-ui/themes"
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IEditProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
IModalProps,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
@@ -1,5 +1,5 @@
.root {
min-width: 520px;
width: 100%;
}
.fields {
+9
View File
@@ -0,0 +1,9 @@
export interface IFragmentsStepProps {
className?: string
}
export interface CutRegion {
id: string
startMs: number
endMs: number
}
@@ -0,0 +1,229 @@
.root {
display: flex;
flex-direction: column;
flex: 1;
padding: 16px 24px 0;
overflow: hidden;
min-height: 0;
}
.playerWrapper {
position: relative;
width: 100%;
flex: 1;
min-height: 0;
border-radius: variables.$radius-md;
overflow: hidden;
background: #000;
:global([data-media-player]) {
width: 100% !important;
height: 100% !important;
}
:global(.vds-video-layout) {
width: 100%;
height: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.timelineSection {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 16px;
flex-shrink: 0;
}
.zoomControls {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: variables.$text-secondary;
}
.zoomButton {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-sm;
background: variables.$bg-default;
color: variables.$text-primary;
cursor: pointer;
font-size: 16px;
font-weight: 500;
user-select: none;
&:hover {
background: variables.$bg-hover;
}
}
.timelineContainer {
position: relative;
overflow-x: auto;
overflow-y: hidden;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md;
background: variables.$bg-surface;
}
.timelineInner {
position: relative;
}
.rulerRow {
position: relative;
height: 24px;
border-bottom: 1px solid variables.$border-subtle;
}
.rulerCanvas {
position: absolute;
top: 0;
left: 0;
display: block;
height: 24px;
}
.framesRow {
position: relative;
height: 48px;
border-bottom: 1px solid variables.$border-subtle;
background: #111;
}
.framesCanvas {
position: absolute;
top: 0;
left: 0;
display: block;
height: 48px;
}
.waveformRow {
position: relative;
height: 48px;
border-bottom: 1px solid variables.$border-subtle;
}
.cutRegionsRow {
position: relative;
height: 32px;
}
.infoBar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 0;
font-size: 13px;
color: variables.$text-secondary;
}
.infoTotal {
font-variant-numeric: tabular-nums;
}
// --- Cut region blocks ---
.cutRegion {
position: absolute;
top: 0;
height: 100%;
background: rgba(255, 152, 0, 0.3);
border: 1px solid rgba(255, 152, 0, 0.7);
border-radius: 2px;
cursor: grab;
user-select: none;
transition: background 0.1s ease;
&:hover {
background: rgba(255, 152, 0, 0.4);
}
}
.handleLeft,
.handleRight {
position: absolute;
top: 0;
width: 6px;
height: 100%;
cursor: col-resize;
z-index: 2;
}
.handleLeft {
left: -3px;
}
.handleRight {
right: -3px;
}
// --- Context menu ---
.contextMenu {
min-width: 160px;
padding: 4px;
background: variables.$bg-surface;
border: 1px solid variables.$border-default;
border-radius: variables.$radius-md;
box-shadow: variables.$shadow-md;
z-index: 100;
}
.contextMenuItem {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border: none;
border-radius: variables.$radius-sm;
background: none;
color: variables.$text-primary;
font-size: 13px;
cursor: pointer;
text-align: left;
&:hover {
background: variables.$bg-hover;
}
}
.contextMenuDanger {
color: variables.$color-danger;
}
// --- Playhead ---
.playhead {
position: absolute;
top: 0;
width: 2px;
height: 100%;
background: variables.$color-danger;
z-index: 10;
pointer-events: none;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 0;
border-top: 1px solid variables.$border-subtle;
flex-shrink: 0;
}
@@ -0,0 +1,850 @@
"use client"
import type { CutRegion, IFragmentsStepProps } from "./FragmentsStep.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider } from "@vidstack/react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default"
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import cs from "classnames"
import { Plus, Trash2 } from "lucide-react"
import {
FunctionComponent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react"
import WaveSurfer from "wavesurfer.js"
import api from "@shared/api"
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
const DEFAULT_NEW_REGION_MS = 1000
const DEFAULT_PPS = 10
const MIN_PPS = 2
const MAX_PPS = 200
const PPS_STEP = 2
const FRAMES_HEIGHT = 48
const WAVEFORM_HEIGHT = 48
const RULER_HEIGHT = 24
const MAX_EXTRACTED_FRAMES = 150
const CANVAS_OVERSCAN = 300
let regionIdCounter = 0
const nextRegionId = (): string => `region_${++regionIdCounter}`
const formatDuration = (ms: number): string => {
const totalSec = Math.floor(ms / 1000)
const min = Math.floor(totalSec / 60)
const sec = totalSec % 60
if (min > 0) return `${min}м ${sec}с`
return `${sec}с`
}
function resolveWaveformColors(): { wave: string; progress: string } {
const root = getComputedStyle(document.documentElement)
return {
wave:
root.getPropertyValue("--waveform-wave").trim() ||
"hsl(297, 70%, 44%)",
progress:
root.getPropertyValue("--waveform-progress").trim() ||
"hsl(293, 100%, 34%)",
}
}
export const FragmentsStep: FunctionComponent<IFragmentsStepProps> = ({
className,
}): JSX.Element => {
const {
projectId,
silenceJobId,
primaryFileKey,
startProcessingJob,
goBack,
markStepCompleted,
goToStep,
} = useWizard()
const [cutRegions, setCutRegions] = useState<CutRegion[]>([])
const [pixelsPerSecond, setPixelsPerSecond] = useState(DEFAULT_PPS)
const [durationMs, setDurationMs] = useState(0)
const [contextMenu, setContextMenu] = useState<{
x: number
y: number
regionId: string | null
timeMs: number
} | null>(null)
const timelineRef = useRef<HTMLDivElement>(null)
const playerRef = useRef<any>(null)
const waveformRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WaveSurfer | null>(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<string, unknown> | null
const fileKey = primaryFileKey ?? ((outputData?.file_key as string) ?? "")
const { data: fileInfo } = api.useQuery(
"get",
"/api/files/get_file/",
{ params: { query: { file_path: fileKey } } },
{ enabled: !!fileKey },
)
const videoUrl = fileInfo?.file_url ?? null
/* ---- 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 (segments && dur) {
setDurationMs(dur)
setCutRegions(
segments.map((s) => ({
id: nextRegionId(),
startMs: s.start_ms,
endMs: s.end_ms,
})),
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [outputData])
/* ---- Timeline calculations ---- */
const totalWidth = Math.max(1, (durationMs / 1000) * pixelsPerSecond)
const msToPixels = useCallback(
(ms: number) => (ms / 1000) * pixelsPerSecond,
[pixelsPerSecond],
)
const pixelsToMs = useCallback(
(px: number) => (px / pixelsPerSecond) * 1000,
[pixelsPerSecond],
)
const totalRemovedMs = useMemo(
() => cutRegions.reduce((sum, r) => sum + (r.endMs - r.startMs), 0),
[cutRegions],
)
/* ---- Region mutations ---- */
const addRegion = useCallback(
(atMs: number) => {
const startMs = Math.max(0, atMs - DEFAULT_NEW_REGION_MS / 2)
const endMs = Math.min(durationMs, startMs + DEFAULT_NEW_REGION_MS)
setCutRegions((prev) =>
[...prev, { id: nextRegionId(), startMs, endMs }].sort(
(a, b) => a.startMs - b.startMs,
),
)
},
[durationMs],
)
const removeRegion = useCallback((regionId: string) => {
setCutRegions((prev) => prev.filter((r) => r.id !== regionId))
}, [])
/* ---- Resize handling ---- */
const { handlePointerDown: handleResizePointerDown } = useSegmentResize({
pixelsPerSecond,
onResize: (index, edge, deltaSec) => {
setCutRegions((prev) => {
const updated = [...prev]
const region = { ...updated[index] }
const deltaMs = deltaSec * 1000
if (edge === "left") {
region.startMs = Math.max(
0,
Math.min(
region.endMs - MIN_REGION_MS,
region.startMs + deltaMs,
),
)
} else {
region.endMs = Math.min(
durationMs,
Math.max(
region.startMs + MIN_REGION_MS,
region.endMs + deltaMs,
),
)
}
updated[index] = region
return updated
})
},
onResizeEnd: () => {},
})
/* ---- Drag-to-move handling ---- */
const handleRegionDragStart = useCallback(
(e: React.PointerEvent, index: number) => {
e.stopPropagation()
const startX = e.clientX
const region = cutRegions[index]
const regionDuration = region.endMs - region.startMs
const onMove = (moveE: PointerEvent) => {
const dx = moveE.clientX - startX
const deltaMs = pixelsToMs(dx)
let newStart = region.startMs + deltaMs
newStart = Math.max(
0,
Math.min(durationMs - regionDuration, newStart),
)
setCutRegions((prev) => {
const updated = [...prev]
updated[index] = {
...updated[index],
startMs: Math.round(newStart),
endMs: Math.round(newStart + regionDuration),
}
return updated
})
}
const onUp = () => {
document.removeEventListener("pointermove", onMove)
document.removeEventListener("pointerup", onUp)
}
document.addEventListener("pointermove", onMove)
document.addEventListener("pointerup", onUp)
},
[cutRegions, durationMs, pixelsToMs],
)
/* ---- Context menu ---- */
const handleContextMenu = useCallback(
(e: React.MouseEvent, regionId: string | null) => {
e.preventDefault()
e.stopPropagation()
const rect = timelineRef.current?.getBoundingClientRect()
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
const x = e.clientX - (rect?.left ?? 0) + scrollLeft
const timeMs = pixelsToMs(x)
setContextMenu({ x: e.clientX, y: e.clientY, regionId, timeMs })
},
[pixelsToMs],
)
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)
document.addEventListener("click", close)
return () => document.removeEventListener("click", close)
}, [contextMenu])
/* ---- Timeline click to seek ---- */
const handleTimelineClick = useCallback(
(e: React.MouseEvent) => {
const rect = timelineRef.current?.getBoundingClientRect()
if (!rect) return
const scrollLeft = timelineRef.current?.scrollLeft ?? 0
const x = e.clientX - rect.left + scrollLeft
const timeSec = pixelsToMs(x) / 1000
if (playerRef.current) {
playerRef.current.currentTime = timeSec
}
},
[pixelsToMs],
)
/* ---- Canvas: ruler ---- */
const rulerRef = useRef<HTMLCanvasElement>(null)
const drawRuler = useCallback(() => {
const container = timelineRef.current
const canvas = rulerRef.current
if (!container || !canvas || !durationMs) return
const sl = container.scrollLeft
const vw = container.clientWidth
if (!vw) return
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
const offset = Math.max(
0,
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
)
const dpr = window.devicePixelRatio || 1
canvas.width = canvasW * dpr
canvas.height = RULER_HEIGHT * dpr
canvas.style.width = `${canvasW}px`
canvas.style.height = `${RULER_HEIGHT}px`
canvas.style.transform = `translateX(${offset}px)`
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.clearRect(0, 0, canvasW, RULER_HEIGHT)
const rootStyles = getComputedStyle(document.documentElement)
const textColor =
rootStyles.getPropertyValue("--text-secondary").trim() || "#888"
const lineColor =
rootStyles.getPropertyValue("--border-subtle").trim() || "#444"
ctx.strokeStyle = lineColor
ctx.fillStyle = textColor
ctx.font = "10px monospace"
ctx.textAlign = "center"
const totalSec = durationMs / 1000
let tickInterval = 1
if (pixelsPerSecond < 5) tickInterval = 30
else if (pixelsPerSecond < 10) tickInterval = 15
else if (pixelsPerSecond < 20) tickInterval = 10
else if (pixelsPerSecond < 50) tickInterval = 5
else if (pixelsPerSecond < 150) tickInterval = 1
else tickInterval = 0.5
const majorMultiple = tickInterval >= 1 ? 5 : 1
const startSec =
Math.floor(offset / pixelsPerSecond / tickInterval) * tickInterval
const endSec = Math.min(
totalSec,
(offset + canvasW) / pixelsPerSecond,
)
for (let sec = startSec; sec <= endSec; sec += tickInterval) {
const x = sec * pixelsPerSecond - offset
if (x < -20 || x > canvasW + 20) continue
const isMajor =
Math.round(sec / tickInterval) % majorMultiple === 0
ctx.beginPath()
ctx.moveTo(x, isMajor ? 0 : 14)
ctx.lineTo(x, RULER_HEIGHT)
ctx.stroke()
if (isMajor) {
const min = Math.floor(sec / 60)
const s = Math.floor(sec % 60)
const label = `${min}:${s.toString().padStart(2, "0")}`
const labelW = ctx.measureText(label).width
const tx = Math.max(labelW / 2, x)
ctx.fillText(label, tx, 10)
}
}
}, [durationMs, pixelsPerSecond, totalWidth])
/* ---- WaveSurfer ---- */
useEffect(() => {
if (!videoUrl || !waveformRef.current || !durationMs) return
const durationSec = durationMs / 1000
const colors = resolveWaveformColors()
const ws = WaveSurfer.create({
container: waveformRef.current,
url: videoUrl,
duration: durationSec,
height: WAVEFORM_HEIGHT,
waveColor: colors.wave,
progressColor: colors.progress,
cursorWidth: 0,
barWidth: 2,
barGap: 1,
barRadius: 2,
normalize: true,
interact: false,
minPxPerSec: pixelsPerSecond,
hideScrollbar: true,
fillParent: false,
autoCenter: false,
autoScroll: false,
dragToSeek: false,
mediaControls: false,
backend: "MediaElement",
})
ws.setVolume(0)
wsRef.current = ws
return () => {
ws.destroy()
wsRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoUrl, durationMs])
useEffect(() => {
const ws = wsRef.current
if (!ws) return
try {
ws.zoom(pixelsPerSecond)
} catch {
// WaveSurfer might not be ready yet
}
}, [pixelsPerSecond])
/* ---- Video frames extraction ---- */
const framesCanvasRef = useRef<HTMLCanvasElement>(null)
const framesCacheRef = useRef<{ timeSec: number; bitmap: ImageBitmap }[]>(
[],
)
const [framesReady, setFramesReady] = useState(false)
useEffect(() => {
if (!videoUrl || !durationMs) return
framesCacheRef.current.forEach((f) => f.bitmap.close())
framesCacheRef.current = []
setFramesReady(false)
let cancelled = false
const video = document.createElement("video")
video.crossOrigin = "anonymous"
video.muted = true
video.preload = "auto"
video.src = videoUrl
const extract = async () => {
await new Promise<void>((resolve, reject) => {
video.onloadedmetadata = () => resolve()
video.onerror = () => reject(new Error("video load error"))
if (video.readyState >= 1) resolve()
})
if (cancelled) return
const durationSec = durationMs / 1000
const frameCount = Math.min(
Math.ceil(durationSec / 2),
MAX_EXTRACTED_FRAMES,
)
const interval = durationSec / frameCount
const aspect = video.videoWidth / video.videoHeight || 16 / 9
const frameW = Math.round(FRAMES_HEIGHT * aspect)
const offCanvas = document.createElement("canvas")
offCanvas.width = frameW * 2
offCanvas.height = FRAMES_HEIGHT * 2
const offCtx = offCanvas.getContext("2d")!
const cache: { timeSec: number; bitmap: ImageBitmap }[] = []
for (let i = 0; i < frameCount; i++) {
if (cancelled) return
const timeSec = i * interval
video.currentTime = timeSec
await new Promise<void>((r) => {
video.onseeked = () => r()
})
if (cancelled) return
offCtx.drawImage(
video,
0,
0,
offCanvas.width,
offCanvas.height,
)
const bitmap = await createImageBitmap(offCanvas)
cache.push({ timeSec, bitmap })
}
if (!cancelled) {
framesCacheRef.current = cache
setFramesReady(true)
}
}
extract().catch(() => {})
return () => {
cancelled = true
video.src = ""
video.load()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoUrl, durationMs])
const drawFrames = useCallback(() => {
const container = timelineRef.current
const canvas = framesCanvasRef.current
if (!container || !canvas || !framesReady) return
const cache = framesCacheRef.current
if (cache.length === 0) return
const sl = container.scrollLeft
const vw = container.clientWidth
if (!vw) return
const canvasW = Math.min(vw + CANVAS_OVERSCAN * 2, totalWidth)
const offset = Math.max(
0,
Math.min(sl - CANVAS_OVERSCAN, totalWidth - canvasW),
)
const dpr = window.devicePixelRatio || 1
canvas.width = canvasW * dpr
canvas.height = FRAMES_HEIGHT * dpr
canvas.style.width = `${canvasW}px`
canvas.style.height = `${FRAMES_HEIGHT}px`
canvas.style.transform = `translateX(${offset}px)`
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.scale(dpr, dpr)
ctx.fillStyle = "#111"
ctx.fillRect(0, 0, canvasW, FRAMES_HEIGHT)
const firstBitmap = cache[0].bitmap
const aspect = firstBitmap.width / firstBitmap.height || 16 / 9
const naturalW = Math.round(FRAMES_HEIGHT * aspect)
const step = Math.max(
1,
Math.ceil(cache.length / Math.max(1, Math.floor(totalWidth / naturalW))),
)
for (let i = 0; i < cache.length; i += step) {
const globalX = cache[i].timeSec * pixelsPerSecond
const nextIdx = Math.min(i + step, cache.length - 1)
const nextGlobalX =
nextIdx > i
? cache[nextIdx].timeSec * pixelsPerSecond
: totalWidth
if (nextGlobalX < offset) continue
if (globalX > offset + canvasW) break
const x = globalX - offset
const tileW = Math.max(naturalW, nextGlobalX - globalX)
ctx.drawImage(cache[i].bitmap, x, 0, tileW, FRAMES_HEIGHT)
}
}, [framesReady, pixelsPerSecond, totalWidth])
/* ---- Animation loop: playhead sync + canvas redraw ---- */
const [playheadMs, setPlayheadMs] = useState(0)
const animRef = useRef<number>(0)
const lastScrollRef = useRef(-1)
const lastViewportRef = useRef(-1)
useEffect(() => {
const tick = () => {
if (playerRef.current) {
const timeMs = playerRef.current.currentTime * 1000
setPlayheadMs(timeMs)
const ws = wsRef.current
if (ws) {
try {
ws.setTime(playerRef.current.currentTime)
} catch {
// ignore
}
}
}
const container = timelineRef.current
if (container) {
const sl = container.scrollLeft
const vw = container.clientWidth
if (
sl !== lastScrollRef.current ||
vw !== lastViewportRef.current
) {
lastScrollRef.current = sl
lastViewportRef.current = vw
drawRuler()
drawFrames()
}
}
animRef.current = requestAnimationFrame(tick)
}
animRef.current = requestAnimationFrame(tick)
return () => {
if (animRef.current) cancelAnimationFrame(animRef.current)
lastScrollRef.current = -1
lastViewportRef.current = -1
}
}, [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 handleApply = () => {
if (cutRegions.length === 0) {
markStepCompleted("fragments")
goToStep("transcription-settings")
return
}
if (!fileKey) return
const fileName = fileKey.split("/").pop() ?? "video.mp4"
const outputName = `Без тишины ${fileName}`
;(applyMutate as (args: { body: Record<string, unknown> }) => void)({
body: {
file_key: fileKey,
out_folder: "",
project_id: projectId,
output_name: outputName,
cuts: cutRegions.map((r) => ({
start_ms: Math.round(r.startMs),
end_ms: Math.round(r.endMs),
})),
},
})
}
return (
<div
className={cs(styles.root, className)}
data-testid="FragmentsStep"
>
{/* Video player */}
<div className={styles.playerWrapper}>
{videoUrl && (
<MediaPlayer
ref={playerRef}
src={videoUrl}
crossOrigin=""
playsInline
>
<MediaProvider />
<DefaultVideoLayout icons={defaultLayoutIcons} />
</MediaPlayer>
)}
</div>
{/* Timeline section */}
<div className={styles.timelineSection}>
<div className={styles.zoomControls}>
<button
className={styles.zoomButton}
onClick={() =>
setPixelsPerSecond((p) =>
Math.max(MIN_PPS, p - PPS_STEP),
)
}
>
-
</button>
<span>Масштаб</span>
<button
className={styles.zoomButton}
onClick={() =>
setPixelsPerSecond((p) =>
Math.min(MAX_PPS, p + PPS_STEP),
)
}
>
+
</button>
</div>
<div
ref={timelineRef}
className={styles.timelineContainer}
onClick={handleTimelineClick}
onContextMenu={(e) => handleContextMenu(e, null)}
>
<div
className={styles.timelineInner}
style={{ width: `${totalWidth}px` }}
>
<div className={styles.rulerRow}>
<canvas
ref={rulerRef}
className={styles.rulerCanvas}
/>
</div>
<div className={styles.framesRow}>
<canvas
ref={framesCanvasRef}
className={styles.framesCanvas}
/>
</div>
<div className={styles.waveformRow}>
<div
ref={waveformRef}
style={{ height: WAVEFORM_HEIGHT }}
/>
</div>
<div className={styles.cutRegionsRow}>
{cutRegions.map((region, index) => {
const left = msToPixels(region.startMs)
const width = msToPixels(
region.endMs - region.startMs,
)
return (
<div
key={region.id}
data-testid="cut-region"
className={styles.cutRegion}
style={{
left: `${left}px`,
width: `${width}px`,
}}
onPointerDown={(e) => {
if (e.button === 0) {
handleRegionDragStart(e, index)
}
}}
onContextMenu={(e) =>
handleContextMenu(e, region.id)
}
>
<div
className={styles.handleLeft}
onPointerDown={(e) =>
handleResizePointerDown(
e,
index,
"left",
)
}
/>
<div
className={styles.handleRight}
onPointerDown={(e) =>
handleResizePointerDown(
e,
index,
"right",
)
}
/>
</div>
)
})}
<div
className={styles.playhead}
style={{
left: `${msToPixels(playheadMs)}px`,
}}
/>
</div>
</div>
</div>
</div>
{/* Info bar */}
<div className={styles.infoBar}>
<span>Фрагментов: {cutRegions.length}</span>
<span className={styles.infoTotal}>
Будет удалено: {formatDuration(totalRemovedMs)}
</span>
</div>
{/* Context menu */}
{contextMenu && (
<div
className={styles.contextMenu}
style={{
position: "fixed",
left: contextMenu.x,
top: contextMenu.y,
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{contextMenu.regionId && (
<button
className={cs(
styles.contextMenuItem,
styles.contextMenuDanger,
)}
onClick={() => {
removeRegion(contextMenu.regionId!)
setContextMenu(null)
}}
>
<Trash2 size={14} />
<span>Удалить</span>
</button>
)}
<button
className={styles.contextMenuItem}
onClick={() => {
addRegion(contextMenu.timeMs)
setContextMenu(null)
}}
>
<Plus size={14} />
<span>Добавить новый</span>
</button>
</div>
)}
{/* Footer */}
<div className={styles.footer}>
<Button
variant="outline"
disabled={isApplying}
onClick={goBack}
>
Отмена
</Button>
<Button
variant="primary"
disabled={isApplying}
onClick={handleApply}
>
{cutRegions.length === 0 ? "Пропустить" : "Применить"}
</Button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from "./FragmentsStep"
@@ -0,0 +1,3 @@
export interface IProcessingStepProps {
className?: string
}
@@ -0,0 +1,93 @@
.root {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
padding: 40px;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
max-width: 400px;
text-align: center;
}
.progressWrapper {
position: relative;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.circle {
position: absolute;
inset: 0;
}
.circleBg {
stroke: variables.$border-subtle;
}
.circleValue {
transition: stroke-dashoffset 0.4s ease;
}
.progressInner {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 1;
}
.percentage {
font-weight: 700;
font-size: 28px;
line-height: 36px;
color: variables.$text-primary;
font-variant-numeric: tabular-nums;
}
.statusLabel {
font-weight: 600;
font-size: 12px;
line-height: 18px;
color: variables.$text-tertiary;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.description {
@include typography.font-body-14(400);
color: variables.$text-secondary;
margin: 0;
}
.descriptionError {
color: variables.$color-danger;
}
.infoCard {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
background: variables.$bg-hover;
border-radius: variables.$radius-md;
font-weight: 400;
font-size: 13px;
line-height: 18px;
color: variables.$text-secondary;
text-align: left;
}
.infoIcon {
color: variables.$text-tertiary;
flex-shrink: 0;
margin-top: 1px;
}
@@ -0,0 +1,148 @@
"use client"
import type { IProcessingStepProps } from "./ProcessingStep.d"
import type { JSX } from "react"
import cs from "classnames"
import { Info } from "lucide-react"
import { FunctionComponent } from "react"
import { useWizard } from "@shared/context/WizardContext"
import { useAppSelector } from "@shared/hooks/useAppSelector"
import { Button, CircularProgress } from "@shared/ui"
import {
buildCancelJobPayload,
useCancelJob,
} from "../useCancelJob"
import styles from "./ProcessingStep.module.scss"
const JOB_TYPE_LABELS: Record<string, string> = {
SILENCE_DETECT: "АНАЛИЗ",
SILENCE_APPLY: "ПРИМЕНЕНИЕ ВЫРЕЗОК",
TRANSCRIPTION_GENERATE: "ТРАНСКРИБАЦИЯ",
CAPTIONS_GENERATE: "ГЕНЕРАЦИЯ СУБТИТРОВ",
}
const JOB_TYPE_BACK_STEP_MAP = {
SILENCE_APPLY: "fragments",
} as const
export const ProcessingStep: FunctionComponent<IProcessingStepProps> = ({
className,
}): JSX.Element => {
const { activeJobId, activeJobType, setActiveJob, goBack, goToStep } =
useWizard()
const { mutate: cancelJob, isPending: isCancelling } = useCancelJob()
const navigateBack = () => {
const targetStep = activeJobType
? JOB_TYPE_BACK_STEP_MAP[
activeJobType as keyof typeof JOB_TYPE_BACK_STEP_MAP
]
: null
if (targetStep) {
goToStep(targetStep)
return
}
goBack()
}
const notification = useAppSelector((state) =>
activeJobId
? state.notifications.items.find(
(n) => n.job_id === activeJobId,
)
: null,
)
const progressPct = notification?.progress_pct ?? 0
const statusLabel = activeJobType
? (JOB_TYPE_LABELS[activeJobType] ?? "ОБРАБОТКА")
: "ОБРАБОТКА"
const statusMessage = notification?.message ?? "Подождите, идёт обработка..."
const isFailed = notification?.status === "FAILED"
const handleCancel = () => {
if (!activeJobId || isCancelling) return
cancelJob(buildCancelJobPayload(activeJobId), {
onSuccess: () => {
setActiveJob(null)
navigateBack()
},
})
}
const handleFailedBack = () => {
setActiveJob(null)
navigateBack()
}
return (
<div
className={cs(styles.root, className)}
data-testid="ProcessingStep"
>
<div className={styles.content}>
<div className={styles.progressWrapper}>
<CircularProgress
percentage={progressPct}
size={200}
strokeWidth={8}
color={
isFailed
? "var(--color-danger)"
: "var(--color-success)"
}
className={styles.circle}
bgClassName={styles.circleBg}
valueClassName={styles.circleValue}
/>
<div className={styles.progressInner}>
<span className={styles.percentage}>
{Math.round(progressPct)}%
</span>
<span className={styles.statusLabel}>
{isFailed ? "ОШИБКА" : statusLabel}
</span>
</div>
</div>
<p
className={cs(styles.description, {
[styles.descriptionError]: isFailed,
})}
>
{isFailed
? (notification?.message ?? "Произошла ошибка при обработке")
: statusMessage}
</p>
<div className={styles.infoCard}>
<Info size={16} className={styles.infoIcon} />
<span>
Обработка выполняется на сервере. Вы можете покинуть
страницу прогресс сохранится.
</span>
</div>
<Button
variant={isFailed ? "outline" : "danger"}
size="sm"
onClick={isFailed ? handleFailedBack : handleCancel}
disabled={isCancelling}
>
{isFailed
? "Назад"
: isCancelling
? "Отмена..."
: "Отменить обработку"}
</Button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from "./ProcessingStep"
@@ -1,9 +1,8 @@
import type { Dialog } from "@radix-ui/themes"
import type { IModalProps } from "@shared/ui/Modal/Modal.d"
import type { components } from "@shared/api/__generated__/openapi.types"
import type { ComponentProps } from "react"
export interface IRenameProjectModalProps extends Pick<
ComponentProps<typeof Dialog.Root>,
IModalProps,
"open" | "onOpenChange"
> {
project: components["schemas"]["ProjectRead"]
@@ -1,5 +1,5 @@
.root {
min-width: 420px;
width: 100%;
}
.fields {
@@ -4,17 +4,47 @@
gap: 16px;
}
.player {
.playerWrapper {
display: flex;
flex-direction: column;
width: 100%;
border-radius: variables.$radius-md;
overflow: hidden;
aspect-ratio: 16 / 9;
}
.playerWrapper {
.videoArea {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
background: #000;
}
.video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.playButton {
position: absolute;
bottom: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
cursor: pointer;
transition: background 0.15s;
&:hover {
background: rgba(0, 0, 0, 0.8);
}
}
.timeRange {
@@ -30,6 +60,52 @@
pointer-events: none;
}
.segmentControls {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: variables.$bg-canvas;
}
.segmentTime {
font-size: 11px;
font-variant-numeric: tabular-nums;
color: variables.$text-secondary;
white-space: nowrap;
}
.segmentTrack {
position: relative;
flex: 1;
height: 4px;
background: variables.$border-subtle;
border-radius: 2px;
cursor: pointer;
}
.segmentTrackFill {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: variables.$purple-400;
border-radius: 2px;
pointer-events: none;
}
.segmentTrackThumb {
position: absolute;
top: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: variables.$purple-400;
transform: translate(-50%, -50%);
pointer-events: none;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
.textArea {
width: 100%;
min-height: 72px;
@@ -3,14 +3,7 @@
import type { ISegmentEditModalProps } from "./SegmentEditModal.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider, useMediaState } from "@vidstack/react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default"
import "@vidstack/react/player/styles/default/theme.css"
import "@vidstack/react/player/styles/default/layouts/video.css"
import { LoaderCircle, Scissors } from "lucide-react"
import { LoaderCircle, Pause, Play, Scissors } from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Button, Modal } from "@shared/ui"
@@ -23,50 +16,146 @@ import { SegmentSplitter } from "@features/project/SegmentSplitter"
import styles from "./SegmentEditModal.module.scss"
const SegmentPlayer = ({
videoUrl,
start,
end,
}: {
const SegmentPlayer: FunctionComponent<{
videoUrl: string
start: number
end: number
}) => {
const currentTime = useMediaState("currentTime")
const playing = useMediaState("playing")
const hasPausedRef = useRef(false)
const playerRef = useRef<HTMLElement | null>(null)
}> = ({ videoUrl, start, end }) => {
const videoRef = useRef<HTMLVideoElement>(null)
const trackRef = useRef<HTMLDivElement>(null)
const rafRef = useRef<number>(0)
const [currentTime, setCurrentTime] = useState(start)
const [playing, setPlaying] = useState(false)
const [dragging, setDragging] = useState(false)
const duration = end - start
const progress =
duration > 0
? Math.min(Math.max((currentTime - start) / duration, 0), 1)
: 0
/* Time tracking via rAF — only runs while playing or dragging */
useEffect(() => {
hasPausedRef.current = false
if (!playing && !dragging) return
const video = videoRef.current
if (!video) return
const tick = () => {
setCurrentTime(video.currentTime)
if (video.currentTime >= end && !video.paused) {
video.pause()
setPlaying(false)
}
rafRef.current = requestAnimationFrame(tick)
}
rafRef.current = requestAnimationFrame(tick)
return () => cancelAnimationFrame(rafRef.current)
}, [playing, dragging, end])
/* Set initial time once video is ready */
useEffect(() => {
const video = videoRef.current
if (!video) return
const onLoaded = () => {
video.currentTime = start
}
video.addEventListener("loadedmetadata", onLoaded)
if (video.readyState >= 1) onLoaded()
return () => video.removeEventListener("loadedmetadata", onLoaded)
}, [start])
const togglePlay = useCallback(() => {
const video = videoRef.current
if (!video) return
if (video.paused) {
if (video.currentTime >= end) video.currentTime = start
video.play()
setPlaying(true)
} else {
video.pause()
setPlaying(false)
}
}, [start, end])
const seekToPosition = useCallback(
(clientX: number) => {
const track = trackRef.current
const video = videoRef.current
if (!track || !video || duration <= 0) return
const rect = track.getBoundingClientRect()
const fraction = Math.min(
Math.max((clientX - rect.left) / rect.width, 0),
1,
)
video.currentTime = start + fraction * duration
},
[start, duration],
)
const handleTrackMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault()
setDragging(true)
seekToPosition(e.clientX)
},
[seekToPosition],
)
useEffect(() => {
if (!playing) return
if (currentTime >= end && !hasPausedRef.current) {
hasPausedRef.current = true
const player = playerRef.current as HTMLElement & {
pause?: () => void
}
player?.pause?.()
if (!dragging) return
const handleMouseMove = (e: MouseEvent) => seekToPosition(e.clientX)
const handleMouseUp = () => setDragging(false)
window.addEventListener("mousemove", handleMouseMove)
window.addEventListener("mouseup", handleMouseUp)
return () => {
window.removeEventListener("mousemove", handleMouseMove)
window.removeEventListener("mouseup", handleMouseUp)
}
}, [currentTime, end, playing])
}, [dragging, seekToPosition])
return (
<div className={styles.playerWrapper}>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
slots={{
settingsMenu: null,
pipButton: null,
fullscreenButton: null,
airPlayButton: null,
googleCastButton: null,
}}
/>
<div className={styles.timeRange}>
{secondsToTimecode(start)} {secondsToTimecode(end)}
<div className={styles.videoArea}>
<video
ref={videoRef}
src={videoUrl}
crossOrigin="anonymous"
playsInline
preload="auto"
className={styles.video}
/>
<button
type="button"
className={styles.playButton}
onClick={togglePlay}
>
{playing ? <Pause size={24} /> : <Play size={24} />}
</button>
<div className={styles.timeRange}>
{secondsToTimecode(start)} {secondsToTimecode(end)}
</div>
</div>
<div className={styles.segmentControls}>
<span className={styles.segmentTime}>
{secondsToTimecode(Math.max(currentTime, start))}
</span>
<div
className={styles.segmentTrack}
ref={trackRef}
onMouseDown={handleTrackMouseDown}
>
<div
className={styles.segmentTrackFill}
style={{ width: `${progress * 100}%` }}
/>
<div
className={styles.segmentTrackThumb}
style={{ left: `${progress * 100}%` }}
/>
</div>
<span className={styles.segmentTime}>
{secondsToTimecode(end)}
</span>
</div>
</div>
)
@@ -147,18 +236,11 @@ export const SegmentEditModal: FunctionComponent<
>
<div className={styles.root} data-testid="SegmentEditModal">
{videoUrl && (
<MediaPlayer
src={videoUrl}
currentTime={segment.start}
className={styles.player}
autoPlay
>
<SegmentPlayer
videoUrl={videoUrl}
start={segment.start}
end={segment.end}
/>
</MediaPlayer>
<SegmentPlayer
videoUrl={videoUrl}
start={segment.start}
end={segment.end}
/>
)}
{splitMode ? (
@@ -1,5 +1,5 @@
.root {
min-width: 520px;
width: 100%;
}
.fields {
@@ -0,0 +1,3 @@
export interface ISilenceSettingsStepProps {
className?: string
}
@@ -0,0 +1,48 @@
.root {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.header {
text-align: center;
margin-bottom: 32px;
max-width: 480px;
}
.title {
@include typography.font-header-l;
color: variables.$text-primary;
margin: 0 0 8px;
}
.description {
@include typography.font-body-14(400);
color: variables.$text-secondary;
margin: 0;
}
.fields {
display: grid;
gap: 24px;
width: 100%;
max-width: 480px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-top: 1px solid variables.$border-subtle;
background: variables.$bg-surface;
}
@@ -0,0 +1,139 @@
"use client"
import type { ISilenceSettingsStepProps } from "./SilenceSettingsStep.d"
import type { JSX } from "react"
import cs from "classnames"
import { FunctionComponent, useCallback } 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,
goBack,
} = useWizard()
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)
},
})
const handleSubmit = useCallback(() => {
if (!primaryFileKey) return
;(mutate as (args: { body: Record<string, unknown> }) => 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])
return (
<div
className={cs(styles.root, className)}
data-testid="SilenceSettingsStep"
>
<div className={styles.content}>
<div className={styles.header}>
<h2 className={styles.title}>Параметры обнаружения тишины</h2>
<p className={styles.description}>
Настройте параметры для автоматического обнаружения
тихих участков в видео
</p>
</div>
<div className={styles.fields}>
<Slider
label="Мин. длительность тишины"
value={silenceSettings.min_silence_duration_ms}
min={100}
max={2000}
step={50}
unit="мс"
helpText="Минимальная длительность тихого участка для обнаружения"
onChange={(v) =>
setSilenceSettings({
...silenceSettings,
min_silence_duration_ms: v,
})
}
/>
<Slider
label="Порог тишины"
value={silenceSettings.silence_threshold_db}
min={6}
max={40}
step={2}
unit="дБ"
helpText="Уровень громкости ниже которого звук считается тишиной"
onChange={(v) =>
setSilenceSettings({
...silenceSettings,
silence_threshold_db: v,
})
}
/>
<Slider
label="Отступ"
value={silenceSettings.padding_ms}
min={0}
max={500}
step={25}
unit="мс"
helpText="Дополнительный отступ по краям тихих участков"
onChange={(v) =>
setSilenceSettings({
...silenceSettings,
padding_ms: v,
})
}
/>
</div>
</div>
{/* Footer */}
<div className={styles.footer}>
<Button variant="outline" onClick={goBack} disabled={isPending}>
Назад
</Button>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isPending || !primaryFileKey}
>
{isPending ? "Запуск..." : "Далее"}
</Button>
</div>
</div>
)
}
@@ -0,0 +1 @@
export * from "./SilenceSettingsStep"
@@ -0,0 +1,3 @@
export interface ISubtitleRevisionStepProps {
className?: string
}
@@ -0,0 +1,94 @@
.root {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
.mediaPlayer {
display: flex !important;
flex-direction: column !important;
flex: 1;
min-height: 0;
overflow: hidden;
// Reset vidstack player defaults
aspect-ratio: unset !important;
width: 100% !important;
height: auto !important;
}
.mainGrid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
flex: 1;
padding: 16px 24px;
overflow: hidden;
min-height: 0;
align-self: stretch;
}
.playerColumn {
position: relative;
border-radius: variables.$radius-md;
overflow: hidden;
background: #000;
min-height: 0;
:global([data-media-player]) {
width: 100% !important;
height: 100% !important;
}
:global(.vds-video-layout) {
width: 100%;
height: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.editorColumn {
overflow-y: auto;
min-height: 0;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md;
background: variables.$bg-surface;
}
.placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: variables.$text-tertiary;
@include typography.font-body-14(500);
}
.timelineWrapper {
border-top: 1px solid variables.$border-subtle;
padding: 0 24px;
align-self: stretch;
overflow: hidden;
}
.timeline {
width: 100%;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-top: 1px solid variables.$border-subtle;
background: variables.$bg-surface;
align-self: stretch;
flex-shrink: 0;
}
@@ -0,0 +1,256 @@
"use client"
import type { ISubtitleRevisionStepProps } from "./SubtitleRevisionStep.d"
import type { JSX } from "react"
import { MediaPlayer, MediaProvider } from "@vidstack/react"
import {
DefaultVideoLayout,
defaultLayoutIcons,
} from "@vidstack/react/player/layouts/default"
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 api from "@shared/api"
import {
StaticWorkspaceProvider,
useWorkspaceFiles,
} from "@shared/context/WorkspaceContext"
import { useWizard } from "@shared/context/WizardContext"
import { Button } from "@shared/ui"
import { TranscriptionEditor } from "@features/project"
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
* TranscriptionEditor work correctly.
*/
const WorkspaceInit: FunctionComponent<{
fileKey: string | null
transcriptionArtifactId: string | null
}> = ({ fileKey, transcriptionArtifactId }) => {
const { selectedFile, setSelectedFile, addUsedFile } = useWorkspaceFiles()
useEffect(() => {
if (!fileKey) return
addUsedFile({
id: fileKey,
path: fileKey,
source: "file",
mimeType: "video/mp4",
displayName: "Видео",
iconType: "video",
})
if (!selectedFile) {
setSelectedFile({
id: fileKey,
path: fileKey,
source: "file",
mimeType: "video/mp4",
})
}
}, [fileKey, addUsedFile, setSelectedFile, selectedFile])
useEffect(() => {
if (!transcriptionArtifactId) return
addUsedFile({
id: transcriptionArtifactId,
path: "transcription",
source: "artifact",
artifactType: "TRANSCRIPTION_JSON",
displayName: "Субтитры",
iconType: "text",
})
}, [transcriptionArtifactId, addUsedFile])
return null
}
const SubtitleRevisionContent: FunctionComponent<{
className?: string
}> = ({ className }) => {
const {
projectId,
videoUrl,
primaryFileKey,
transcriptionArtifactId: contextArtifactId,
setTranscriptionArtifactId,
goBack,
goToStep,
markStepCompleted,
} = useWizard()
const { data: artifacts } = 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])
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",
"/api/tasks/frame-extract/",
)
const extractTriggeredRef = useRef(false)
useEffect(() => {
if (!primaryFileKey || !projectId || extractTriggeredRef.current) return
extractTriggeredRef.current = true
frameExtractMutation.mutate({
body: {
file_key: primaryFileKey,
project_id: projectId,
regenerate: false,
},
})
}, [primaryFileKey, projectId]) // eslint-disable-line react-hooks/exhaustive-deps
const handleFinish = () => {
markStepCompleted("subtitle-revision")
goToStep("caption-settings")
}
return (
<div
className={cs(styles.root, className)}
data-testid="SubtitleRevisionStep"
>
<WorkspaceInit
fileKey={primaryFileKey}
transcriptionArtifactId={transcriptionArtifactId}
/>
<MediaPlayer
src={videoUrl ?? ""}
crossOrigin=""
playsInline
className={styles.mediaPlayer}
style={{
display: "flex",
flexDirection: "column",
flex: 1,
aspectRatio: "unset",
width: "100%",
height: "auto",
minHeight: 0,
overflow: "hidden",
}}
>
{/* Main content: video + editor */}
<div className={styles.mainGrid}>
{/* Left column: video player */}
<div className={styles.playerColumn}>
{videoUrl ? (
<>
<MediaProvider />
<DefaultVideoLayout
icons={defaultLayoutIcons}
disableTimeSlider
slots={{
timeSlider: null,
currentTime: null,
timeDivider: null,
endTime: null,
startDuration: null,
seekBackwardButton: null,
seekForwardButton: null,
captionButton: null,
settingsMenu: null,
pipButton: null,
airPlayButton: null,
googleCastButton: null,
downloadButton: null,
}}
/>
</>
) : (
<div className={styles.placeholder}>
Видео недоступно
</div>
)}
</div>
{/* Right column: transcription editor */}
<div className={styles.editorColumn}>
{transcriptionArtifactId ? (
<TranscriptionEditor
artifactId={transcriptionArtifactId}
/>
) : (
<div className={styles.placeholder}>
Транскрипция не найдена
</div>
)}
</div>
</div>
{/* Bottom: timeline */}
<div className={styles.timelineWrapper}>
<TimelinePanel
projectId={projectId}
audioUrl={videoUrl}
className={styles.timeline}
/>
</div>
{/* Footer */}
<div className={styles.footer}>
<Button variant="outline" onClick={goBack}>
Отмена
</Button>
<Button variant="primary" onClick={handleFinish}>
Далее
</Button>
</div>
</MediaPlayer>
</div>
)
}
export const SubtitleRevisionStep: FunctionComponent<
ISubtitleRevisionStepProps
> = ({ className }): JSX.Element => {
return (
<StaticWorkspaceProvider>
<SubtitleRevisionContent className={className} />
</StaticWorkspaceProvider>
)
}
@@ -0,0 +1 @@
export * from "./SubtitleRevisionStep"
@@ -80,11 +80,16 @@
}
.segment {
border: 1px solid variables.$border-default;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-md;
padding: 10px 12px;
padding: 12px 16px;
background: variables.$bg-surface;
transition: border-color 0.3s, box-shadow 0.3s;
transition: all 0.3s ease;
&:hover {
border-color: variables.$border-default;
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.04);
}
&.highlight {
border-color: variables.$color-primary;
@@ -94,51 +99,72 @@
.segmentTimes {
display: flex;
align-items: flex-end;
gap: 10px;
margin-bottom: 8px;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.timesGroup {
display: flex;
align-items: center;
gap: 16px;
}
.actionsGroup {
display: flex;
align-items: center;
gap: 6px;
}
.timeLabel {
display: flex;
flex-direction: column;
gap: 2px;
align-items: center;
gap: 8px;
}
.timeLabelText {
font-size: 11px;
color: variables.$text-tertiary;
font-weight: 500;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.timeInput {
width: 100px;
width: 84px;
padding: 4px 8px;
border: 1px solid variables.$border-default;
border: 1px solid transparent;
border-radius: variables.$radius-sm;
font-size: 13px;
font-size: 12px;
font-family: monospace;
color: variables.$text-primary;
background: variables.$bg-default;
color: variables.$text-secondary;
background: variables.$bg-hover;
transition: all 0.2s ease;
text-align: center;
&:focus {
outline: none;
background: variables.$bg-surface;
border-color: variables.$color-primary;
color: variables.$text-primary;
box-shadow: 0 0 0 2px rgba(139, 92, 246, 0.1);
}
}
.splitButton {
.splitButton, .removeButton {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: auto;
padding: 4px;
padding: 6px;
border: none;
background: none;
background: transparent;
color: variables.$text-tertiary;
cursor: pointer;
border-radius: variables.$radius-sm;
transition: all 0.2s ease;
}
.splitButton {
&:hover:not(:disabled) {
color: variables.$color-primary;
background: variables.$bg-hover;
@@ -151,37 +177,34 @@
}
.removeButton {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
border: none;
background: none;
color: variables.$text-tertiary;
cursor: pointer;
border-radius: variables.$radius-sm;
&:hover {
color: variables.$color-danger;
background: variables.$bg-hover;
background: rgba(239, 68, 68, 0.1);
}
}
.textArea {
width: 100%;
padding: 8px;
border: 1px solid variables.$border-default;
padding: 10px 12px;
border: 1px solid transparent;
border-radius: variables.$radius-sm;
font-size: 13px;
font-size: 14px;
line-height: 1.5;
color: variables.$text-primary;
background: variables.$bg-default;
background: variables.$bg-hover;
resize: vertical;
font-family: inherit;
transition: all 0.2s ease;
&:hover {
background: variables.$bg-hover;
}
&:focus {
outline: none;
background: variables.$bg-surface;
border-color: variables.$color-primary;
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.15);
}
}
@@ -4,8 +4,7 @@ import type { ITranscriptionEditorProps } from "./TranscriptionEditor.d"
import type { JSX } from "react"
import { useQueryClient } from "@tanstack/react-query"
import cs from "classnames"
import { LoaderCircle, Plus, Save, Scissors, Trash2 } from "lucide-react"
import { LoaderCircle, Plus, Scissors, Trash2 } from "lucide-react"
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react"
import api from "@shared/api"
@@ -146,6 +145,15 @@ export const TranscriptionEditor: FunctionComponent<
}
}, [transcription, segments, artifactId, queryClient])
// Auto-save when dirty (debounced)
useEffect(() => {
if (!dirty) return
const timer = setTimeout(() => {
handleSave()
}, 1500)
return () => clearTimeout(timer)
}, [dirty, handleSave])
/* Loading */
if (isLoading) {
return (
@@ -171,18 +179,6 @@ export const TranscriptionEditor: FunctionComponent<
{/* Header */}
<div className={styles.header}>
<h3 className={styles.title}>Редактор транскрипции</h3>
<button
className={cs(styles.saveButton, { [styles.disabled]: !dirty })}
onClick={handleSave}
disabled={!dirty || saving}
>
{saving ? (
<LoaderCircle size={16} className={styles.spinner} />
) : (
<Save size={16} />
)}
<span>Сохранить</span>
</button>
</div>
{/* Segments list */}
@@ -190,49 +186,53 @@ export const TranscriptionEditor: FunctionComponent<
{segments.map((seg, idx) => (
<div key={idx} className={styles.segment} data-segment-index={idx}>
<div className={styles.segmentTimes}>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Начало</span>
<input
className={styles.timeInput}
type="text"
value={seg.startTime}
onChange={(e) =>
updateSegment(idx, "startTime", e.target.value)
<div className={styles.timesGroup}>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Начало</span>
<input
className={styles.timeInput}
type="text"
value={seg.startTime}
onChange={(e) =>
updateSegment(idx, "startTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Конец</span>
<input
className={styles.timeInput}
type="text"
value={seg.endTime}
onChange={(e) =>
updateSegment(idx, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
</div>
<div className={styles.actionsGroup}>
<button
className={styles.splitButton}
onClick={() => setSplittingIdx(idx)}
title={
!seg.words || seg.words.length < 2
? "Нет данных о словах для разделения"
: "Разделить сегмент"
}
placeholder="00:00.000"
/>
</label>
<label className={styles.timeLabel}>
<span className={styles.timeLabelText}>Конец</span>
<input
className={styles.timeInput}
type="text"
value={seg.endTime}
onChange={(e) =>
updateSegment(idx, "endTime", e.target.value)
}
placeholder="00:00.000"
/>
</label>
<button
className={styles.splitButton}
onClick={() => setSplittingIdx(idx)}
title={
!seg.words || seg.words.length < 2
? "Нет данных о словах для разделения"
: "Разделить сегмент"
}
disabled={!seg.words || seg.words.length < 2}
>
<Scissors size={14} />
</button>
<button
className={styles.removeButton}
onClick={() => removeSegment(idx)}
title="Удалить сегмент"
>
<Trash2 size={14} />
</button>
disabled={!seg.words || seg.words.length < 2}
>
<Scissors size={14} />
</button>
<button
className={styles.removeButton}
onClick={() => removeSegment(idx)}
title="Удалить сегмент"
>
<Trash2 size={14} />
</button>
</div>
</div>
{splittingIdx === idx ? (
<SegmentSplitter
@@ -1,5 +1,5 @@
.root {
min-width: 520px;
width: 100%;
}
.fields {
@@ -0,0 +1,3 @@
export interface ITranscriptionSettingsStepProps {
className?: string
}
@@ -0,0 +1,157 @@
.root {
display: flex;
flex-direction: column;
flex: 1;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px;
}
.header {
text-align: center;
margin-bottom: 32px;
max-width: 480px;
}
.title {
@include typography.font-header-l;
color: variables.$text-primary;
margin: 0 0 8px;
}
.description {
@include typography.font-body-14(400);
color: variables.$text-secondary;
margin: 0;
}
.fields {
display: grid;
gap: 16px;
width: 100%;
max-width: 480px;
}
.selectField {
display: grid;
gap: 6px;
}
.selectLabel {
@include typography.font-body-14(500);
color: variables.$text-primary;
}
.error {
@include typography.font-body-14(500);
color: variables.$color-danger;
margin-top: 12px;
text-align: center;
}
.formFooter {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-top: 32px;
width: 100%;
max-width: 480px;
}
// --- Inline processing view ---
.processingContent {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
max-width: 400px;
margin: 0 auto;
text-align: center;
}
.progressWrapper {
position: relative;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.circle {
position: absolute;
inset: 0;
}
.circleBg {
stroke: variables.$border-subtle;
}
.circleValue {
transition: stroke-dashoffset 0.4s ease;
}
.progressInner {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 1;
}
.percentage {
font-weight: 700;
font-size: 28px;
line-height: 36px;
color: variables.$text-primary;
font-variant-numeric: tabular-nums;
}
.statusLabel {
font-weight: 600;
font-size: 12px;
line-height: 18px;
color: variables.$text-tertiary;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.processingDescription {
@include typography.font-body-14(400);
color: variables.$text-secondary;
margin: 0;
}
.descriptionError {
color: variables.$color-danger;
}
.infoCard {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
background: variables.$bg-hover;
border-radius: variables.$radius-md;
font-weight: 400;
font-size: 13px;
line-height: 18px;
color: variables.$text-secondary;
text-align: left;
}
.infoIcon {
color: variables.$text-tertiary;
flex-shrink: 0;
margin-top: 1px;
}
@@ -59,7 +59,7 @@ export const TranscriptionSettingsStep: FunctionComponent<
activeJobType,
setActiveJob,
startProcessingJob,
goBack,
goToStep,
} = useWizard()
const isProcessing =
@@ -310,7 +310,7 @@ export const TranscriptionSettingsStep: FunctionComponent<
type="button"
variant="outline"
disabled={isPending}
onClick={goBack}
onClick={() => goToStep("fragments")}
>
Назад
</Button>
@@ -0,0 +1 @@
export * from "./TranscriptionSettingsStep"
+3
View File
@@ -0,0 +1,3 @@
export interface IUploadStepProps {
className?: string
}
@@ -0,0 +1,89 @@
.root {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
padding: 40px;
}
.content {
width: 100%;
max-width: 560px;
}
.dropZone {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 48px 32px;
border: 2px dashed variables.$border-default;
border-radius: variables.$radius-lg;
background: variables.$bg-surface;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
&:hover {
border-color: variables.$color-primary;
background: variables.$bg-hover;
}
}
.dropZoneActive {
border-color: variables.$color-primary;
background: rgba(var(--iris-a3), 0.08);
}
.dropZoneUploading {
cursor: default;
pointer-events: none;
}
.fileInput {
display: none;
}
.icon {
color: variables.$text-tertiary;
margin-bottom: 4px;
}
.title {
@include typography.font-body-16(600);
color: variables.$text-primary;
}
.subtitle {
@include typography.font-body-14(400);
color: variables.$text-secondary;
}
.progressTrack {
width: 100%;
max-width: 300px;
height: 6px;
border-radius: 3px;
background: variables.$border-subtle;
overflow: hidden;
margin-top: 8px;
}
.progressBar {
height: 100%;
background: variables.$color-primary;
border-radius: 3px;
transition: width 0.2s ease;
}
.progressLabel {
@include typography.font-body-14(500);
color: variables.$text-secondary;
font-variant-numeric: tabular-nums;
}
.error {
@include typography.font-body-14(500);
color: variables.$color-danger;
margin-top: 8px;
}
@@ -0,0 +1,141 @@
"use client"
import type { IUploadStepProps } from "./UploadStep.d"
import type { JSX } from "react"
import { Upload } from "lucide-react"
import { FunctionComponent, useCallback, useRef, useState } from "react"
import cs from "classnames"
import { uploadFileWithProgress } from "@shared/api/uploadFile"
import { useWizard } from "@shared/context/WizardContext"
import { Button } from "@shared/ui"
import styles from "./UploadStep.module.scss"
const ACCEPTED_VIDEO_TYPES = "video/*"
const ERROR_UPLOAD_FAILED = "Не удалось загрузить файл"
export const UploadStep: FunctionComponent<IUploadStepProps> = ({
className,
}): JSX.Element => {
const { projectId, setFileKey, markStepCompleted, goNext } = useWizard()
const [isDragging, setIsDragging] = useState(false)
const [isUploading, setIsUploading] = useState(false)
const [progress, setProgress] = useState(0)
const [error, setError] = useState<string | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
const handleUpload = useCallback(
async (file: File) => {
setIsUploading(true)
setProgress(0)
setError(null)
try {
const result = await uploadFileWithProgress(
file,
`projects/${projectId}`,
setProgress,
)
setFileKey(result.file_path, result.file_url, result.filename ?? null)
markStepCompleted("upload")
goNext()
} catch {
setError(ERROR_UPLOAD_FAILED)
} finally {
setIsUploading(false)
}
},
[projectId, setFileKey, markStepCompleted, goNext],
)
const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleUpload(file)
/* Reset input so re-selecting the same file triggers change */
e.target.value = ""
},
[handleUpload],
)
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
const file = e.dataTransfer.files[0]
if (file) handleUpload(file)
},
[handleUpload],
)
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}, [])
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}, [])
return (
<div className={cs(styles.root, className)} data-testid="UploadStep">
<div className={styles.content}>
<div
className={cs(styles.dropZone, {
[styles.dropZoneActive]: isDragging,
[styles.dropZoneUploading]: isUploading,
})}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => inputRef.current?.click()}
>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_VIDEO_TYPES}
className={styles.fileInput}
onChange={handleFileChange}
disabled={isUploading}
/>
<Upload size={48} className={styles.icon} />
{isUploading ? (
<>
<p className={styles.title}>Загрузка файла...</p>
<div className={styles.progressTrack}>
<div
className={styles.progressBar}
style={{ width: `${progress}%` }}
/>
</div>
<p className={styles.progressLabel}>{Math.round(progress)}%</p>
</>
) : (
<>
<p className={styles.title}>Перетащите видеофайл сюда</p>
<p className={styles.subtitle}>или нажмите для выбора файла</p>
<Button
variant="outline"
size="sm"
type="button"
onClick={(e) => {
e.stopPropagation()
inputRef.current?.click()
}}
>
Выбрать файл
</Button>
</>
)}
{error && <p className={styles.error}>{error}</p>}
</div>
</div>
</div>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./UploadStep"
+3
View File
@@ -0,0 +1,3 @@
export interface IVerifyStepProps {
className?: string
}
@@ -0,0 +1,221 @@
.root {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
min-height: 0;
}
.layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 24px;
flex: 1;
padding: 24px;
min-height: 0;
overflow: hidden;
}
.playerWrapper {
position: relative;
border-radius: variables.$radius-md;
overflow: hidden;
background: #000;
min-height: 0;
height: 100%;
:global([data-media-player]) {
width: 100% !important;
height: 100% !important;
}
:global(.vds-video-layout) {
width: 100%;
height: 100%;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 100%;
min-height: 300px;
color: variables.$text-tertiary;
}
.sidebar {
display: flex;
flex-direction: column;
gap: 16px;
}
.statusRow {
display: flex;
align-items: center;
}
.infoCards {
display: grid;
gap: 8px;
}
.infoCard {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 12px;
background: variables.$bg-surface;
border: 1px solid variables.$border-subtle;
border-radius: variables.$radius-sm;
}
.infoIcon {
color: variables.$text-tertiary;
flex-shrink: 0;
margin-top: 2px;
}
.infoContent {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.infoLabel {
@include typography.font-caption-m;
font-weight: 500;
color: variables.$text-tertiary;
}
.infoValue {
@include typography.font-body-14(500);
color: variables.$text-primary;
word-break: break-all;
}
.sidebarActions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: auto;
}
.convertErrorText {
font-size: 13px;
color: variables.$color-danger;
margin: 0;
}
.placeholderHint {
@include typography.font-body-14(400);
color: variables.$text-tertiary;
margin: 0;
}
/* Converting view */
.convertingContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
flex: 1;
padding: 40px;
max-width: 400px;
margin: 0 auto;
text-align: center;
}
.progressWrapper {
position: relative;
width: 200px;
height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.circle {
position: absolute;
inset: 0;
}
.circleBg {
stroke: variables.$border-subtle;
}
.circleValue {
transition: stroke-dashoffset 0.4s ease;
}
.progressInner {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
z-index: 1;
}
.percentage {
font-weight: 700;
font-size: 28px;
line-height: 36px;
color: variables.$text-primary;
font-variant-numeric: tabular-nums;
}
.statusLabel {
font-weight: 600;
font-size: 12px;
line-height: 18px;
color: variables.$text-tertiary;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.convertDescription {
@include typography.font-body-14(400);
color: variables.$text-secondary;
margin: 0;
}
.convertInfoCard {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 16px;
background: variables.$bg-hover;
border-radius: variables.$radius-md;
font-weight: 400;
font-size: 13px;
line-height: 18px;
color: variables.$text-secondary;
text-align: left;
}
.convertInfoIcon {
color: variables.$text-tertiary;
flex-shrink: 0;
margin-top: 1px;
}
.footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
border-top: 1px solid variables.$border-subtle;
background: variables.$bg-surface;
}
@@ -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>
)
}
+1
View File
@@ -0,0 +1 @@
export * from "./VerifyStep"
+9
View File
@@ -1,15 +1,24 @@
export { CaptionResultStep } from "./CaptionResultStep"
export { CaptionSettingsStep } from "./CaptionSettingsStep"
export { ConvertMediaView } from "./ConvertMediaView"
export { CreateProjectModal } from "./CreateProjectModal"
export { DeleteFileModal } from "./DeleteFileModal"
export { DeleteProjectModal } from "./DeleteProjectModal"
export { EditProjectModal } from "./EditProjectModal"
export { FragmentsStep } from "./FragmentsStep"
export { ProcessingStep } from "./ProcessingStep"
export { RenameProjectModal } from "./RenameProjectModal"
export { SegmentEditModal } from "./SegmentEditModal"
export { SegmentSplitter } from "./SegmentSplitter"
export { SilenceSettingsStep } from "./SilenceSettingsStep"
export { SubtitleRevisionStep } from "./SubtitleRevisionStep"
export { TranscriptionEditor } from "./TranscriptionEditor"
export { TranscriptionSettingsStep } from "./TranscriptionSettingsStep"
export { SilenceResultModal } from "./SilenceResultModal"
export { SilenceSettingsModal } from "./SilenceSettingsModal"
export { TranscriptionModal } from "./TranscriptionModal"
export { UploadStep } from "./UploadStep"
export { VerifyStep } from "./VerifyStep"
export { WaveformTrack } from "./WaveformTrack"
export { SubtitlesTrack } from "./SubtitlesTrack"
export { SilenceTrack } from "./SilenceTrack"
+18
View File
@@ -0,0 +1,18 @@
"use client"
import api from "@shared/api"
const CANCEL_MESSAGE = "Отменено пользователем"
export const useCancelJob = () => {
return api.useMutation("patch", "/api/jobs/jobs/{job_id}/", {
})
}
export const buildCancelJobPayload = (jobId: string) => ({
params: { path: { job_id: jobId } },
body: {
status: "CANCELLED" as const,
current_message: CANCEL_MESSAGE,
},
})