initial commit

This commit is contained in:
root
2026-05-13 14:20:41 +00:00
commit 6e178d2012
6022 changed files with 399872 additions and 0 deletions
+686
View File
@@ -0,0 +1,686 @@
'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>
);
}