./kaisetsu-app/src/app/page.tsx
'use client';
import { useState, useCallback, useRef } from 'react';
import Header from '@/components/Header';
import VideoPlayer, { VideoPlayerHandle } from '@/components/VideoPlayer';
import Timeline from '@/components/Timeline';
import DownloadModal from '@/components/DownloadModal';
import FileUpload from '@/components/FileUpload';
import { TimelineBlockData, VoiceTypeId } from '@/components/TimelineBlock';
const DICTIONARY_STORAGE_KEY = 'kaisetsu-user-dictionary';
const DEFAULT_TC_OFFSET = '09;59;45;00';
const DEFAULT_TC_OFFSET_SECONDS = 9 * 3600 + 59 * 60 + 45; // 35985
function getStepLabel(step: string): string {
switch (step) {
case 'queued': return 'ジョブをキューに追加中...';
case 'whisper': return 'Step 1/3: Whisper で音声認識中...';
case 'audio_meta': return 'Step 2/3: 音声メタデータを生成中...';
case 'visual_meta': return 'Step 2/3: 映像メタデータを生成中...';
case 'narration': return 'Step 3/3: 解説音声原稿を生成中...';
case 'saving': return '結果を保存中...';
case 'done': return '完了';
default: return '処理中...';
}
}
export default function Home() {
const [hasFile, setHasFile] = useState(false);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysisProgress, setAnalysisProgress] = useState(0);
const [analysisStep, setAnalysisStep] = useState('解析中...');
const [analysisError, setAnalysisError] = useState<string | null>(null);
const [fileName, setFileName] = useState('');
const [lastModified, setLastModified] = useState('');
const [videoUrl, setVideoUrl] = useState<string>('');
const [videoFile, setVideoFile] = useState<File | null>(null);
const [currentTime, setCurrentTime] = useState(0);
const [blocks, setBlocks] = useState<TimelineBlockData[]>([]);
const [isDownloadOpen, setIsDownloadOpen] = useState(false);
const [tcOffsetSeconds, setTcOffsetSeconds] = useState(DEFAULT_TC_OFFSET_SECONDS);
const [tcOffsetDisplay, setTcOffsetDisplay] = useState(DEFAULT_TC_OFFSET);
const [debugData, setDebugData] = useState<{ whisperSegments?: unknown[]; preGeneratedMeta?: string; audioMetaRaw?: string; visualMetaRaw?: string } | null>(null);
const videoPlayerRef = useRef<VideoPlayerHandle>(null);
const handleTcOffsetChange = useCallback((value: string) => {
setTcOffsetDisplay(value);
const parts = value.split(';').map(Number);
if (parts.length === 4 && parts.every(p => !isNaN(p))) {
setTcOffsetSeconds(parts[0] * 3600 + parts[1] * 60 + parts[2] + parts[3] / 30);
}
}, []);
const handleFileSelect = useCallback(async (file: File, generateNarration: boolean, tcOffset?: string, genre?: string) => {
// Parse TC offset string
if (tcOffset) {
setTcOffsetDisplay(tcOffset);
const parts = tcOffset.split(';').map(Number);
if (parts.length === 4) {
const offsetSec = parts[0] * 3600 + parts[1] * 60 + parts[2] + parts[3] / 30;
setTcOffsetSeconds(offsetSec);
}
}
setFileName(file.name);
setLastModified(
new Date(file.lastModified).toLocaleString('ja-JP', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
);
const url = URL.createObjectURL(file);
setVideoUrl(url);
setVideoFile(file);
setIsAnalyzing(true);
setAnalysisProgress(0);
setAnalysisError(null);
setAnalysisStep('アップロード中...');
try {
let mimeType = file.type;
if (!mimeType || mimeType === 'application/octet-stream') {
const ext = file.name.toLowerCase().split('.').pop();
const mimeTypes: Record<string, string> = {
'mp4': 'video/mp4',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
'mkv': 'video/x-matroska',
'webm': 'video/webm',
'm4v': 'video/x-m4v',
};
mimeType = mimeTypes[ext || ''] || 'video/mp4';
}
const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let gcsUri: string;
if (isLocal) {
setAnalysisStep('サーバー経由でアップロード中...');
const formData = new FormData();
formData.append('video', file);
const uploadRes = await fetch('/api/upload', {
method: 'POST',
body: formData,
});
if (!uploadRes.ok) {
const err = await uploadRes.json();
throw new Error(err.error || 'Upload failed');
}
const uploadData = await uploadRes.json();
gcsUri = uploadData.gcsUri;
} else {
setAnalysisStep('GCS にアップロード中...');
const uploadUrlRes = await fetch('/api/upload-url', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: file.name, contentType: mimeType }),
});
if (!uploadUrlRes.ok) {
const err = await uploadUrlRes.json();
throw new Error(err.error || 'Failed to get upload URL');
}
const { signedUrl, gcsUri: uri } = await uploadUrlRes.json();
const gcsUploadRes = await fetch(signedUrl, {
method: 'PUT',
headers: { 'Content-Type': mimeType },
body: file,
});
if (!gcsUploadRes.ok) {
throw new Error(`GCS upload failed: ${gcsUploadRes.status}`);
}
gcsUri = uri;
}
// Submit analysis job
setAnalysisStep('解析ジョブを作成中...');
setAnalysisProgress(5);
const response = await fetch('/api/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gcsUri,
mimeType,
generateNarration,
tcOffset: tcOffset || DEFAULT_TC_OFFSET,
genre: genre || 'variety',
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Analysis failed');
}
const { jobId } = await response.json();
// Poll for job status
setAnalysisStep('ジョブをキューに追加中...');
let pollCount = 0;
let retryCount = 0;
const MAX_RETRIES = 10;
while (true) {
await new Promise(resolve => setTimeout(resolve, 3000 + retryCount * 2000));
pollCount++;
const statusRes = await fetch(`/api/analyze/status?jobId=${jobId}`);
if (!statusRes.ok) {
if (statusRes.status === 404) {
// Job not found yet, keep waiting
if (pollCount > 5) throw new Error('Job not found');
continue;
}
if (statusRes.status === 429 || statusRes.status === 503) {
retryCount++;
if (retryCount > MAX_RETRIES) throw new Error('サーバーが混雑しています。しばらく待ってから再試行してください。');
continue;
}
throw new Error('Status check failed');
}
retryCount = 0;
const status = await statusRes.json();
// Update progress UI
setAnalysisProgress(status.progress || 0);
setAnalysisStep(getStepLabel(status.step));
if (status.status === 'completed') {
// Save debug data for download
setDebugData({
whisperSegments: status.whisperSegments,
preGeneratedMeta: status.preGeneratedMeta,
audioMetaRaw: status.audioMetaRaw,
visualMetaRaw: status.visualMetaRaw,
});
// Process result blocks
if (status.blocks && Array.isArray(status.blocks)) {
const narrationBlocks = status.blocks.filter(
(block: TimelineBlockData) => block.type === 'narration' && block.text
);
if (narrationBlocks.length > 0) {
setAnalysisStep(`音声合成中... (0/${narrationBlocks.length})`);
setAnalysisProgress(90);
let completedTts = 0;
const blocksWithAudio = await Promise.all(
status.blocks.map(async (block: TimelineBlockData) => {
if (block.type === 'narration' && block.text) {
try {
const savedDict = localStorage.getItem(DICTIONARY_STORAGE_KEY);
const dict = savedDict ? JSON.parse(savedDict) : [];
const ttsResponse = await fetch('/api/tts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: block.text,
voiceType: block.voiceType || '7nO7lVCISGqz9Dhm3AAx',
dictionary: dict,
}),
});
if (ttsResponse.ok) {
const audioBlob = await ttsResponse.blob();
const audioUrl = URL.createObjectURL(audioBlob);
completedTts++;
setAnalysisStep(`音声合成中... (${completedTts}/${narrationBlocks.length})`);
return { ...block, audioUrl };
}
} catch (error) {
console.error('TTS error for block:', block.id, error);
}
}
return block;
})
);
setBlocks(blocksWithAudio);
} else {
setBlocks(status.blocks);
}
}
break;
}
if (status.status === 'error') {
throw new Error(status.error || 'Processing failed');
}
// Safety timeout: 15 minutes
if (pollCount > 300) {
throw new Error('Processing timeout (15 minutes exceeded)');
}
}
setAnalysisProgress(100);
setIsAnalyzing(false);
setHasFile(true);
} catch (error) {
console.error('Analysis error:', error);
setAnalysisError(error instanceof Error ? error.message : 'Analysis failed');
setIsAnalyzing(false);
setHasFile(true);
setBlocks([]);
}
}, []);
const handleTimeUpdate = useCallback((time: number) => {
setCurrentTime(time);
}, []);
const handleAddNarration = useCallback((afterTime: number) => {
const newBlock: TimelineBlockData = {
id: Date.now().toString(),
type: 'narration',
startTime: afterTime,
voiceType: '7nO7lVCISGqz9Dhm3AAx',
text: ''
};
setBlocks((prev) => {
const newBlocks = [...prev, newBlock].sort(
(a, b) => a.startTime - b.startTime
);
return newBlocks;
});
}, []);
const handleBlockTextChange = useCallback((id: string, text: string) => {
setBlocks((prev) =>
prev.map((block) => (block.id === id ? { ...block, text, audioUrl: undefined } : block))
);
}, []);
const handleBlockVoiceTypeChange = useCallback((id: string, voiceType: VoiceTypeId) => {
setBlocks((prev) =>
prev.map((block) => (block.id === id ? { ...block, voiceType, audioUrl: undefined } : block))
);
}, []);
const handleBlockSpeakerChange = useCallback((id: string, speaker: string) => {
setBlocks((prev) =>
prev.map((block) => (block.id === id ? { ...block, speaker } : block))
);
}, []);
const handleBlockDelete = useCallback((id: string) => {
setBlocks((prev) => prev.filter((block) => block.id !== id));
}, []);
const handleTimestampClick = useCallback((time: number, blockType: 'dialogue' | 'narration' | 'silence') => {
const offset = blockType === 'narration' ? 2 : 0;
const seekTime = Math.max(0, time - offset);
videoPlayerRef.current?.seekTo(seekTime);
videoPlayerRef.current?.play();
setCurrentTime(seekTime);
}, []);
return (
<div className="min-h-screen bg-gray-100">
{/* Header */}
<Header
fileName={fileName}
lastModified={lastModified}
tcOffsetDisplay={tcOffsetDisplay}
onTcOffsetChange={handleTcOffsetChange}
onDownload={() => setIsDownloadOpen(true)}
/>
{/* Main content */}
<main className="p-6">
{!hasFile && !isAnalyzing ? (
<div className="max-w-3xl mx-auto">
<FileUpload onFileSelect={handleFileSelect} />
</div>
) : isAnalyzing ? (
<div className="max-w-3xl mx-auto">
<FileUpload
onFileSelect={handleFileSelect}
isAnalyzing={true}
progress={Math.min(100, Math.round(analysisProgress))}
analysisStep={analysisStep}
/>
{analysisError && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{analysisError}
</div>
)}
</div>
) : (
<div className="flex gap-6">
{/* Left column: Video (sticky) */}
<div className="w-[600px] flex-shrink-0">
<div className="sticky top-6 space-y-3">
<VideoPlayer
ref={videoPlayerRef}
videoUrl={videoUrl}
onTimeUpdate={handleTimeUpdate}
blocks={blocks}
tcOffsetSeconds={tcOffsetSeconds}
/>
</div>
</div>
{/* Right column: Timeline (scrollable) */}
<div className="flex-1 min-w-0">
<Timeline
blocks={blocks}
currentTime={currentTime}
tcOffsetSeconds={tcOffsetSeconds}
onBlockTextChange={handleBlockTextChange}
onBlockVoiceTypeChange={handleBlockVoiceTypeChange}
onBlockSpeakerChange={handleBlockSpeakerChange}
onBlockDelete={handleBlockDelete}
onTimestampClick={handleTimestampClick}
onAddNarration={handleAddNarration}
/>
</div>
</div>
)}
</main>
{/* Modals */}
<DownloadModal
isOpen={isDownloadOpen}
onClose={() => setIsDownloadOpen(false)}
blocks={blocks}
videoFile={videoFile}