Initial commit: YouTube Shorts maker application
Features: - Video download from TikTok/Douyin using yt-dlp - Audio transcription with OpenAI Whisper - GPT-4 translation (direct/summarize/rewrite modes) - Subtitle generation with ASS format - Video trimming with frame-accurate preview - BGM integration with volume control - Intro text overlay support - Thumbnail generation with text overlay Tech stack: - Backend: FastAPI, Python 3.11+ - Frontend: React, Vite, TailwindCSS - Video processing: FFmpeg - AI: OpenAI Whisper, GPT-4 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
219
frontend/src/components/ManualSubtitleInput.jsx
Normal file
219
frontend/src/components/ManualSubtitleInput.jsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, Save, Clock, PenLine, Volume2, XCircle } from 'lucide-react';
|
||||
import { processApi } from '../api/client';
|
||||
|
||||
export default function ManualSubtitleInput({ job, onProcessWithSubtitle, onProcessWithoutSubtitle, onCancel }) {
|
||||
const [segments, setSegments] = useState([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [videoDuration, setVideoDuration] = useState(60); // Default 60 seconds
|
||||
|
||||
// Initialize with one empty segment
|
||||
useEffect(() => {
|
||||
if (segments.length === 0) {
|
||||
addSegment();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addSegment = () => {
|
||||
const lastEnd = segments.length > 0 ? segments[segments.length - 1].end : 0;
|
||||
setSegments([
|
||||
...segments,
|
||||
{
|
||||
id: Date.now(),
|
||||
start: lastEnd,
|
||||
end: Math.min(lastEnd + 3, videoDuration),
|
||||
text: '',
|
||||
translated: '',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const removeSegment = (id) => {
|
||||
setSegments(segments.filter((seg) => seg.id !== id));
|
||||
};
|
||||
|
||||
const updateSegment = (id, field, value) => {
|
||||
setSegments(
|
||||
segments.map((seg) =>
|
||||
seg.id === id ? { ...seg, [field]: value } : seg
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const formatTimeInput = (seconds) => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = (seconds % 60).toFixed(1);
|
||||
return `${mins}:${secs.padStart(4, '0')}`;
|
||||
};
|
||||
|
||||
const parseTimeInput = (timeStr) => {
|
||||
const parts = timeStr.split(':');
|
||||
if (parts.length === 2) {
|
||||
const mins = parseInt(parts[0]) || 0;
|
||||
const secs = parseFloat(parts[1]) || 0;
|
||||
return mins * 60 + secs;
|
||||
}
|
||||
return parseFloat(timeStr) || 0;
|
||||
};
|
||||
|
||||
const handleSaveSubtitles = async () => {
|
||||
// Validate segments
|
||||
const validSegments = segments.filter(
|
||||
(seg) => seg.text.trim() || seg.translated.trim()
|
||||
);
|
||||
|
||||
if (validSegments.length === 0) {
|
||||
alert('최소 한 개의 자막을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Format segments for API
|
||||
const formattedSegments = validSegments.map((seg) => ({
|
||||
start: seg.start,
|
||||
end: seg.end,
|
||||
text: seg.text.trim() || seg.translated.trim(),
|
||||
translated: seg.translated.trim() || seg.text.trim(),
|
||||
}));
|
||||
|
||||
await processApi.addManualSubtitle(job.job_id, formattedSegments);
|
||||
onProcessWithSubtitle?.(formattedSegments);
|
||||
} catch (err) {
|
||||
console.error('Failed to save subtitles:', err);
|
||||
alert('자막 저장 실패: ' + (err.response?.data?.detail || err.message));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProcessWithoutSubtitle = () => {
|
||||
onProcessWithoutSubtitle?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-gray-900 rounded-xl border border-gray-800 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<PenLine size={20} className="text-amber-400" />
|
||||
<h3 className="font-medium text-white">자막 직접 입력</h3>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
{segments.length}개 세그먼트
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
영상에 표시할 자막을 직접 입력하세요. 시작/종료 시간과 텍스트를 설정합니다.
|
||||
</p>
|
||||
|
||||
{/* Segments List */}
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto mb-4">
|
||||
{segments.map((segment, index) => (
|
||||
<div
|
||||
key={segment.id}
|
||||
className="p-4 bg-gray-800/50 rounded-lg border border-gray-700"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-xs text-gray-500 font-mono">#{index + 1}</span>
|
||||
<button
|
||||
onClick={() => removeSegment(segment.id)}
|
||||
className="text-gray-500 hover:text-red-400 transition-colors"
|
||||
disabled={segments.length === 1}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Time Inputs */}
|
||||
<div className="flex gap-4 mb-3">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 mb-1 block">
|
||||
<Clock size={12} className="inline mr-1" />
|
||||
시작 (초)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={segment.start}
|
||||
onChange={(e) =>
|
||||
updateSegment(segment.id, 'start', parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-500 mb-1 block">
|
||||
<Clock size={12} className="inline mr-1" />
|
||||
종료 (초)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
value={segment.end}
|
||||
onChange={(e) =>
|
||||
updateSegment(segment.id, 'end', parseFloat(e.target.value) || 0)
|
||||
}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Text Input */}
|
||||
<div>
|
||||
<label className="text-xs text-gray-500 mb-1 block">자막 텍스트</label>
|
||||
<textarea
|
||||
value={segment.translated || segment.text}
|
||||
onChange={(e) => updateSegment(segment.id, 'translated', e.target.value)}
|
||||
placeholder="표시할 자막을 입력하세요..."
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded px-3 py-2 text-sm focus:outline-none focus:border-amber-500 resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Add Segment Button */}
|
||||
<button
|
||||
onClick={addSegment}
|
||||
className="w-full py-2 border border-dashed border-gray-700 rounded-lg text-gray-400 hover:text-white hover:border-gray-600 transition-colors flex items-center justify-center gap-2 mb-4"
|
||||
>
|
||||
<Plus size={16} />
|
||||
자막 추가
|
||||
</button>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleSaveSubtitles}
|
||||
disabled={isSaving || segments.every((s) => !s.text && !s.translated)}
|
||||
className="flex-1 btn-primary py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<PenLine size={18} />
|
||||
{isSaving ? '저장 중...' : '자막으로 처리'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProcessWithoutSubtitle}
|
||||
className="flex-1 btn-secondary py-3 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Volume2 size={18} />
|
||||
BGM만 추가
|
||||
</button>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="w-full py-2 border border-gray-700 rounded-lg text-gray-400 hover:text-red-400 hover:border-red-800 transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<XCircle size={16} />
|
||||
작업 취소
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user