Files
2026-05-13 14:20:41 +00:00

687 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useState, useRef, useEffect, Suspense } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
function UniqueizerContent() {
const router = useRouter();
const searchParams = useSearchParams();
const fileInputRef = useRef<HTMLInputElement>(null);
const fileUploadRef = useRef<HTMLDivElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const [status, setStatus] = useState('');
const [error, setError] = useState('');
const [videoKey, setVideoKey] = useState<string | null>(null);
// generationUuid получаем из URL (передаётся из дашборда)
const generationUuidFromUrl = searchParams.get('generationUuid');
const [generationUuid, setGenerationUuid] = useState<string | null>(generationUuidFromUrl);
const [scenarioStarted, setScenarioStarted] = useState(false);
// Проверяем generationUuid из URL
useEffect(() => {
const uuidFromUrl = searchParams.get('generationUuid');
if (uuidFromUrl) {
setGenerationUuid(uuidFromUrl);
setScenarioStarted(true);
setStatus('Сценарий запущен');
} else {
setError('generationUuid не указан. Вернитесь на дашборд и запустите сценарий.');
}
}, [searchParams]);
// Drag and drop
useEffect(() => {
const el = fileUploadRef.current;
if (!el) return;
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
el.addEventListener(eventName, preventDefaults, false);
});
['dragenter', 'dragover'].forEach(eventName => {
el.addEventListener(eventName, () => el.classList.add('dragover'), false);
});
['dragleave', 'drop'].forEach(eventName => {
el.addEventListener(eventName, () => el.classList.remove('dragover'), false);
});
el.addEventListener('drop', handleDrop, false);
}, []);
function preventDefaults(e: Event) {
e.preventDefault();
e.stopPropagation();
}
function handleDrop(e: DragEvent) {
const dt = e.dataTransfer;
if (dt?.files.length) {
handleFileSelect(dt.files[0]);
}
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
if (e.target.files?.length) {
handleFileSelect(e.target.files[0]);
}
}
function handleFileSelect(file: File) {
const allowedTypes = ['video/mp4', 'video/quicktime', 'video/x-msvideo', 'video/webm'];
if (!allowedTypes.includes(file.type)) {
setError('Недопустимый тип файла. Разрешены: MP4, MOV, AVI, WebM');
return;
}
const maxSize = 500 * 1024 * 1024;
if (file.size > maxSize) {
setError('Файл слишком большой. Максимум: 500MB');
return;
}
setSelectedFile(file);
setPreview(URL.createObjectURL(file));
setError('');
setVideoKey(null);
}
function removeFile() {
setSelectedFile(null);
setPreview(null);
setVideoKey(null);
if (fileInputRef.current) fileInputRef.current.value = '';
}
function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
async function handleUploadVideo() {
try {
const s3Key = await uploadVideoMultipart();
setVideoKey(s3Key); // Сохраняем ключ после загрузки
} catch (err: unknown) {
// Ошибка уже обработана в uploadVideoMultipart
}
}
async function uploadVideoMultipart() {
if (!selectedFile) throw new Error('Файл не выбран');
if (!generationUuid) throw new Error('generationUuid не получен');
setUploading(true);
setUploadProgress(0);
setError('');
setStatus('Инициализация загрузки...');
try {
const csrfToken = await getCsrfToken();
// 1. Инициализация multipart upload
const initResponse = await fetch('/api/upload/video/init', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
filename: selectedFile.name,
contentType: selectedFile.type,
fileSize: selectedFile.size,
generationUuid,
}),
});
if (!initResponse.ok) {
const errorData = await initResponse.json();
throw new Error(errorData.message || 'Ошибка инициализации загрузки');
}
const initData = await initResponse.json();
const { fileId, uploadId, parts, s3Key } = initData.data;
setStatus(`Загрузка видео... 0%`);
// 2. Параллельная загрузка частей напрямую в S3
const uploadedParts = new Array(parts.length);
const PART_SIZE = 10 * 1024 * 1024; // 10MB (увеличено для эффективности)
const MAX_CONCURRENT = 6; // До 6 частей параллельно
// Функция загрузки одной части с retry и прогрессом
const uploadPartWithRetry = async (partIndex: number, maxRetries = 3): Promise<{ PartNumber: number; ETag: string } | undefined> => {
const part = parts[partIndex];
const start = partIndex * PART_SIZE;
const end = Math.min(start + PART_SIZE, selectedFile.size);
const chunk = selectedFile.slice(start, end);
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const startTime = Date.now();
console.log(`[Upload] Часть ${part.partNumber}: начало загрузки, размер: ${(chunk.size / 1024 / 1024).toFixed(2)} MB`);
// Используем XMLHttpRequest для лучшей производительности с бинарными данными
const etag = await new Promise<string>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', part.presignedUrl, true);
// Отслеживаем прогресс загрузки
xhr.upload.onprogress = function(e) {
if (e.lengthComputable) {
const percent = (e.loaded / e.total * 100).toFixed(1);
const speed = (e.loaded / ((Date.now() - startTime) / 1000) / 1024 / 1024).toFixed(2);
console.log(`[Upload] Часть ${part.partNumber}: ${percent}% (${speed} MB/s)`);
}
};
// Устанавливаем таймаут 120 секунд
xhr.timeout = 120000;
xhr.onload = function() {
const duration = ((Date.now() - startTime) / 1000).toFixed(2);
const speed = (chunk.size / 1024 / 1024 / parseFloat(duration)).toFixed(2);
console.log(`[Upload] Часть ${part.partNumber}: завершено за ${duration}s (${speed} MB/s)`);
if (xhr.status === 200) {
const etag = xhr.getResponseHeader('ETag');
if (etag) {
resolve(etag.replace(/"/g, '')); // MinIO возвращает ETag в кавычках
} else {
reject(new Error('Нет ETag'));
}
} else {
reject(new Error(`HTTP ${xhr.status}: ${xhr.responseText}`));
}
};
xhr.onerror = function() {
console.error(`[Upload] Часть ${part.partNumber}: ошибка сети`);
reject(new Error('Network error'));
};
xhr.ontimeout = function() {
console.error(`[Upload] Часть ${part.partNumber}: таймаут`);
reject(new Error('Timeout'));
};
xhr.send(chunk);
});
return {
PartNumber: part.partNumber,
ETag: etag,
};
} catch (err) {
if (attempt === maxRetries) {
throw new Error(`Часть ${part.partNumber}: ${err instanceof Error ? err.message : 'ошибка'}`);
}
// Ждём перед retry
await new Promise(r => setTimeout(r, 100 * attempt));
}
}
};
// Загружаем части пачками по MAX_CONCURRENT
for (let i = 0; i < parts.length; i += MAX_CONCURRENT) {
const batch = [];
const batchEnd = Math.min(i + MAX_CONCURRENT, parts.length);
for (let j = i; j < batchEnd; j++) {
batch.push(uploadPartWithRetry(j));
}
const results = await Promise.all(batch);
// Сохраняем результаты
for (let k = 0; k < results.length; k++) {
uploadedParts[i + k] = results[k];
}
// Обновляем прогресс
const progress = Math.round(((i + batch.length) / parts.length) * 100);
setUploadProgress(progress);
setStatus(`Загрузка видео... ${progress}%`);
}
// 3. Завершение multipart upload
setStatus('Завершение загрузки...');
const completeResponse = await fetch('/api/upload/video/complete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({
fileId,
uploadId,
parts: uploadedParts.filter(p => p !== undefined),
}),
});
if (!completeResponse.ok) {
const errorData = await completeResponse.json();
throw new Error(errorData.message || 'Ошибка завершения загрузки');
}
setUploadProgress(100);
setStatus('Видео загружено');
setUploading(false);
return s3Key;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка загрузки');
setUploading(false);
throw err;
}
}
async function handleUniqueize() {
setError('');
try {
if (!generationUuid) throw new Error('generationUuid не получен');
let s3Key = videoKey;
// Если видео ещё не загружено, загружаем
if (!s3Key) {
setUploading(true);
setStatus('Загрузка видео...');
s3Key = await uploadVideoMultipart();
setVideoKey(s3Key);
setUploading(false);
}
setStatus('Видео отправлено на обработку');
const csrfToken = await getCsrfToken();
// Вызываем step/1 с url видео
const stepPayload = {
input: {
url: s3Key,
},
};
const stepResponse = await fetch('/api/scenario/uniqueizer/step/1', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-csrf-token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify(stepPayload),
});
if (!stepResponse.ok) {
const data = await stepResponse.json();
throw new Error(data.message || 'Ошибка выполнения шага');
}
// Переход на страницу результата
setTimeout(() => {
router.push(`/result?generationUuid=${generationUuid}`);
}, 1000);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Ошибка обработки');
setUploading(false);
}
}
async function getCsrfToken(): Promise<string> {
const response = await fetch('/api/auth/csrf', { credentials: 'same-origin' });
const data = await response.json();
return data.csrfToken || '';
}
function handleClose() {
router.push('/dashboard');
}
return (
<div style={styles.page}>
<div style={styles.container}>
<div style={styles.card}>
<h1 style={styles.title}>Уникализация видео</h1>
{error && <div style={styles.error}>{error}</div>}
{status && (
<div style={styles.status}>
<span>{status}</span>
</div>
)}
{/* Статус сценария */}
{!scenarioStarted && (
<div style={styles.infoBox}>
<span style={styles.spinner}></span>
Запуск сценария...
</div>
)}
{scenarioStarted && !error && (
<div style={styles.infoBox}>
Сценарий запущен (ID: {generationUuid?.slice(0, 8)}...)
</div>
)}
{/* Загрузка видео */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>1. Загрузите видео</h2>
{!selectedFile ? (
<div
ref={fileUploadRef}
style={styles.fileUpload}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="video/mp4,video/quicktime,video/x-msvideo,video/webm"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
<div style={styles.fileUploadIcon}>🎬</div>
<div style={styles.fileUploadText}>
Нажмите для загрузки или перетащите файл
</div>
<div style={styles.fileUploadHint}>
MP4, MOV, AVI, WebM до 500MB
</div>
</div>
) : (
<div style={styles.filePreview}>
<video src={preview!} controls style={styles.previewVideo} />
<div style={styles.filePreviewInfo}>
<div style={styles.filePreviewName}>{selectedFile.name}</div>
<div>{formatFileSize(selectedFile.size)}</div>
</div>
<button
type="button"
onClick={removeFile}
style={styles.fileRemove}
disabled={uploading}
>
×
</button>
</div>
)}
{selectedFile && !videoKey && (
<div>
{uploading && uploadProgress > 0 && (
<div style={styles.progressContainer}>
<div style={{
...styles.progressBar,
width: `${uploadProgress}%`,
}}></div>
<span style={styles.progressText}>{uploadProgress}%</span>
</div>
)}
<button
onClick={handleUploadVideo}
disabled={uploading || !scenarioStarted}
style={{
...styles.btn,
...styles.btnPrimary,
...((uploading || !scenarioStarted) ? styles.btnDisabled : {}),
}}
>
{uploading ? 'Загрузка...' : 'Загрузить видео'}
</button>
</div>
)}
</div>
{/* Кнопка уникализации */}
{(videoKey || selectedFile) && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>2. Уникализируйте</h2>
<p style={styles.sectionDesc}>
После нажатия видео будет отправлено на обработку
</p>
<button
onClick={handleUniqueize}
disabled={uploading || !scenarioStarted}
style={{
...styles.btn,
...styles.btnPrimary,
...((uploading || !scenarioStarted) ? styles.btnDisabled : {}),
}}
>
{uploading ? 'Загрузка...' : 'Уникализировать'}
</button>
</div>
)}
{/* Кнопка закрытия */}
<button onClick={handleClose} style={styles.btnSecondary}>
Закрыть
</button>
</div>
</div>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
page: {
minHeight: '100vh',
background: '#f5f5f5',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '40px 20px',
},
container: {
maxWidth: '700px',
width: '100%',
},
emptyState: {
textAlign: 'center',
padding: '60px 20px',
color: '#666',
fontSize: '16px',
},
card: {
background: 'white',
padding: '40px',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
},
title: {
fontSize: '24px',
fontWeight: 600,
marginBottom: '24px',
color: '#333',
textAlign: 'center',
},
infoBox: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '12px',
background: '#e8f5e9',
color: '#2e7d32',
borderRadius: '6px',
marginBottom: '20px',
fontSize: '14px',
},
section: {
marginBottom: '32px',
padding: '20px',
background: '#f8f9fa',
borderRadius: '8px',
},
sectionTitle: {
fontSize: '16px',
fontWeight: 600,
color: '#333',
marginBottom: '8px',
},
sectionDesc: {
fontSize: '14px',
color: '#666',
marginBottom: '16px',
},
fileUpload: {
border: '2px dashed #ddd',
borderRadius: '8px',
padding: '40px 20px',
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.2s',
},
fileUploadIcon: {
fontSize: '48px',
marginBottom: '16px',
},
fileUploadText: {
fontSize: '16px',
color: '#666',
marginBottom: '8px',
},
fileUploadHint: {
fontSize: '13px',
color: '#999',
},
filePreview: {
position: 'relative',
marginBottom: '16px',
},
previewVideo: {
width: '100%',
maxHeight: '400px',
borderRadius: '8px',
background: '#000',
},
filePreviewInfo: {
padding: '12px',
background: '#fff',
borderRadius: '6px',
marginTop: '8px',
fontSize: '14px',
color: '#666',
},
filePreviewName: {
fontWeight: 500,
color: '#333',
marginBottom: '4px',
},
fileRemove: {
position: 'absolute',
top: '8px',
right: '8px',
background: 'rgba(220, 38, 38, 0.9)',
color: 'white',
border: 'none',
borderRadius: '50%',
width: '32px',
height: '32px',
fontSize: '20px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
btn: {
width: '100%',
padding: '14px 24px',
borderRadius: '6px',
fontSize: '15px',
fontWeight: 500,
cursor: 'pointer',
border: 'none',
transition: 'all 0.2s',
},
btnPrimary: {
background: '#007bff',
color: 'white',
},
btnDisabled: {
background: '#ccc',
cursor: 'not-allowed',
},
btnSecondary: {
width: '100%',
padding: '12px 24px',
background: '#f5f5f5',
color: '#333',
border: '1px solid #ddd',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 500,
cursor: 'pointer',
},
error: {
background: '#fee',
color: '#c00',
padding: '12px',
borderRadius: '6px',
marginBottom: '20px',
fontSize: '14px',
},
status: {
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '12px',
background: '#e7f3ff',
color: '#0066cc',
borderRadius: '6px',
marginBottom: '20px',
fontSize: '14px',
},
spinner: {
display: 'inline-block',
width: '16px',
height: '16px',
border: '2px solid rgba(0,123,255,0.3)',
borderTopColor: '#007bff',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
},
progressContainer: {
position: 'relative',
height: '24px',
background: '#e0e0e0',
borderRadius: '12px',
marginBottom: '12px',
overflow: 'hidden',
},
progressBar: {
height: '100%',
background: 'linear-gradient(90deg, #007bff, #0056b3)',
borderRadius: '12px',
transition: 'width 0.3s ease',
},
progressText: {
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
fontSize: '13px',
fontWeight: '600',
color: '#333',
},
};
export default function UniqueizerPage() {
return (
<Suspense fallback={<div style={styles.page}><div style={styles.container}><div style={styles.emptyState}>Загрузка...</div></div></div>}>
<UniqueizerContent />
</Suspense>
);
}