obj-drive-viewer / src /App.jsx
hrkjt's picture
Switch morph export to MP4
5688ac3 verified
Raw
History Blame Contribute Delete
11.7 kB
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import ObjViewer from './ObjViewer.jsx';
import { GOOGLE_CLIENT_ID } from './config.js';
import { downloadObjText, listObjFiles } from './driveApi.js';
import { useGoogleDriveAuth } from './useGoogleDriveAuth.js';
const ORIGINAL_FILE_PATTERN = /^ldsm_fit_original_(\d{5})_([^_]+)_(\d{8})\.obj$/i;
function parseOriginalFile(file) {
const match = file.name.match(ORIGINAL_FILE_PATTERN);
if (!match) return null;
const [, patientId, label, date] = match;
return {
...file,
patientId,
label,
studyDate: date,
studyDateLabel: `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`,
};
}
function formatBytes(value) {
if (!value) return 'size unknown';
const number = Number(value);
if (!Number.isFinite(number)) return 'size unknown';
const units = ['B', 'KB', 'MB', 'GB'];
let size = number;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(size >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
export default function App() {
const {
idToken,
accessToken,
user,
authReady,
authError,
signIn,
signOut,
} = useGoogleDriveAuth();
const viewerRef = useRef(null);
const [files, setFiles] = useState([]);
const [selectedPatientId, setSelectedPatientId] = useState('');
const [patientSearch, setPatientSearch] = useState('');
const [selectedFile, setSelectedFile] = useState(null);
const [objText, setObjText] = useState('');
const [isListing, setIsListing] = useState(false);
const [isLoadingObj, setIsLoadingObj] = useState(false);
const [isPreparingAnimation, setIsPreparingAnimation] = useState(false);
const [error, setError] = useState('');
const canUseDrive = Boolean(GOOGLE_CLIENT_ID);
const isAuthenticated = Boolean(idToken && accessToken);
const tokens = useMemo(() => ({ accessToken, idToken }), [accessToken, idToken]);
const loadFiles = useCallback(async () => {
if (!isAuthenticated) return;
setIsListing(true);
setError('');
try {
const result = await listObjFiles(tokens);
setFiles(result);
} catch (loadError) {
setError(loadError.message || 'OBJ一覧の取得に失敗しました。');
} finally {
setIsListing(false);
}
}, [isAuthenticated, tokens]);
useEffect(() => {
loadFiles();
}, [loadFiles]);
const originalFiles = useMemo(
() =>
files
.map(parseOriginalFile)
.filter(Boolean)
.sort((a, b) => a.patientId.localeCompare(b.patientId) || a.studyDate.localeCompare(b.studyDate)),
[files],
);
const patients = useMemo(() => {
const groups = new Map();
for (const file of originalFiles) {
if (!groups.has(file.patientId)) {
groups.set(file.patientId, []);
}
groups.get(file.patientId).push(file);
}
return [...groups.entries()].map(([patientId, patientFiles]) => ({
patientId,
files: patientFiles.sort((a, b) => a.studyDate.localeCompare(b.studyDate)),
}));
}, [originalFiles]);
const selectedPatient = useMemo(
() => patients.find((patient) => patient.patientId === selectedPatientId) || null,
[patients, selectedPatientId],
);
const visiblePatients = useMemo(() => {
const query = patientSearch.trim();
if (!query) return patients;
return patients.filter((patient) => patient.patientId.includes(query));
}, [patientSearch, patients]);
useEffect(() => {
if (!patients.length) {
setSelectedPatientId('');
setSelectedFile(null);
setObjText('');
return;
}
const currentPatient = patients.find((patient) => patient.patientId === selectedPatientId);
if (!currentPatient) {
setSelectedPatientId(patients[0].patientId);
setSelectedFile(patients[0].files[0]);
return;
}
if (!selectedFile || selectedFile.patientId !== currentPatient.patientId) {
setSelectedFile(currentPatient.files[0]);
}
}, [patients, selectedFile, selectedPatientId]);
const handleSelectPatient = useCallback(
(patientId) => {
const patient = patients.find((item) => item.patientId === patientId);
setSelectedPatientId(patientId);
setSelectedFile(patient?.files[0] || null);
setObjText('');
setError('');
},
[patients],
);
const handleSelectFile = useCallback(
async (file) => {
setSelectedFile(file);
setObjText('');
setIsLoadingObj(true);
setError('');
try {
const text = await downloadObjText(file.id, tokens);
setObjText(text);
} catch (loadError) {
setError(loadError.message || 'OBJファイルの読み込みに失敗しました。');
} finally {
setIsLoadingObj(false);
}
},
[tokens],
);
useEffect(() => {
if (selectedFile && isAuthenticated) {
handleSelectFile(selectedFile);
}
}, [handleSelectFile, isAuthenticated, selectedFile?.id]);
const loadPatientFrames = useCallback(async () => {
if (!selectedPatient || selectedPatient.files.length < 2) return [];
setIsPreparingAnimation(true);
setError('');
try {
return await Promise.all(
selectedPatient.files.map(async (file) => ({
file,
text: await downloadObjText(file.id, tokens),
})),
);
} catch (loadError) {
setError(loadError.message || 'アニメーション用OBJの取得に失敗しました。');
return [];
} finally {
setIsPreparingAnimation(false);
}
}, [selectedPatient, tokens]);
const handlePlayAnimation = useCallback(async () => {
const frames = await loadPatientFrames();
if (frames.length < 2) return;
viewerRef.current?.playMorph(frames);
}, [loadPatientFrames]);
const handleDownloadMp4 = useCallback(async () => {
const frames = await loadPatientFrames();
if (frames.length < 2) return;
const filename = `patient_${selectedPatient.patientId}_original_morph.mp4`;
await viewerRef.current?.downloadMorphMp4(frames, filename);
}, [loadPatientFrames, selectedPatient]);
const animationDisabled =
!selectedPatient || selectedPatient.files.length < 2 || isLoadingObj || isPreparingAnimation;
return (
<main className="app-shell">
<aside className="sidebar">
<div className="brand-block">
<p className="eyebrow">Google Drive</p>
<h1>OBJ Drive Viewer</h1>
<p className="subtle">originalデータを患者IDと日付で確認します。</p>
</div>
<div className="auth-actions">
{!isAuthenticated && (
<button className="primary-button" onClick={signIn} disabled={!authReady || !canUseDrive}>
Googleで認証
</button>
)}
{isAuthenticated && (
<>
<button className="primary-button" onClick={loadFiles} disabled={isListing}>
{isListing ? '更新中...' : '一覧を更新'}
</button>
<button className="ghost-button" onClick={signOut}>
サインアウト
</button>
</>
)}
</div>
{user?.email && (
<div className="signed-in-user">
<span>Signed in</span>
<strong>{user.email}</strong>
</div>
)}
{!canUseDrive && (
<div className="notice">
<strong>OAuth Client IDが未設定です</strong>
<span>GOOGLE_CLIENT_ID を設定してください。</span>
</div>
)}
{authError && (
<div className="notice error">
<strong>認証エラー</strong>
<span>{authError}</span>
</div>
)}
{error && (
<div className="notice error">
<strong>読み込みエラー</strong>
<span>{error}</span>
</div>
)}
<div className="selector-grid">
<div className="file-section">
<div className="section-heading">
<h2>Patient ID</h2>
<span>{isAuthenticated ? `${patients.length} patients` : 'sign in required'}</span>
</div>
<input
className="search-input"
inputMode="numeric"
maxLength={5}
placeholder="IDで検索"
value={patientSearch}
onChange={(event) => setPatientSearch(event.target.value.replace(/\D/g, '').slice(0, 5))}
disabled={!isAuthenticated || isListing}
/>
<div className="file-list patient-list">
{isListing && <div className="list-row muted">Driveを確認しています...</div>}
{!isListing && isAuthenticated && patients.length === 0 && (
<div className="list-row muted">original OBJが見つかりません。</div>
)}
{!isAuthenticated && <div className="list-row muted">認証後に表示します。</div>}
{visiblePatients.map((patient) => (
<button
key={patient.patientId}
className={`file-row compact ${patient.patientId === selectedPatientId ? 'active' : ''}`}
onClick={() => handleSelectPatient(patient.patientId)}
disabled={isLoadingObj || isPreparingAnimation}
>
<span className="file-name">{patient.patientId}</span>
<span className="file-meta">{patient.files.length} dates</span>
</button>
))}
</div>
</div>
<div className="file-section">
<div className="section-heading">
<h2>Date</h2>
<span>{selectedPatient ? `${selectedPatient.files.length} originals` : '-'}</span>
</div>
<div className="file-list date-list">
{!selectedPatient && <div className="list-row muted">患者を選択してください。</div>}
{selectedPatient?.files.map((file) => (
<button
key={file.id}
className={`file-row ${selectedFile?.id === file.id ? 'active' : ''}`}
onClick={() => handleSelectFile(file)}
disabled={isLoadingObj || isPreparingAnimation}
>
<span className="file-name">{file.studyDateLabel}</span>
<span className="file-meta">
{file.label} · {formatBytes(file.size)}
</span>
</button>
))}
</div>
</div>
</div>
<div className="animation-panel">
<div className="section-heading">
<h2>Morph</h2>
<span>{selectedPatient?.files.length > 1 ? 'available' : 'single date'}</span>
</div>
<div className="auth-actions">
<button className="ghost-button" onClick={handlePlayAnimation} disabled={animationDisabled}>
日付順に再生
</button>
<button className="ghost-button" onClick={handleDownloadMp4} disabled={animationDisabled}>
MP4を保存
</button>
</div>
</div>
</aside>
<ObjViewer ref={viewerRef} objText={objText} fileName={selectedFile?.name} />
{(isLoadingObj || isPreparingAnimation) && (
<div className="loading-overlay">
{isPreparingAnimation ? 'アニメーションを準備中...' : 'OBJを読み込み中...'}
</div>
)}
</main>
);
}