687 lines
21 KiB
TypeScript
687 lines
21 KiB
TypeScript
'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>
|
||
);
|
||
}
|