// /Users/mdamba/dev/candrm/hotels_video/scripts/web/app.jsx // ホテル動画ジェネレータ — 本番 UI (React + Babel inline) // docs/ver1 の 2 ペイン構成を採用しつつ、すべて実 API に接続している。 // POST /api/scripts … LLM で台本を初期生成 // POST /api/narration … 1 文ぶんのナレーション合成 (WAV) // POST /api/narration/rewrite … AI による書き直し // POST /api/jobs … 動画レンダリングジョブ作成 // GET /api/jobs/{id} … ジョブ進捗ポーリング // GET /api/jobs/{id}/result … 完成動画 (mp4) // // 共通デザイントークンは ./tokens.css と ./components.css に従う。 const { useState, useEffect, useRef, useMemo, useCallback } = React; // ============================================================ // ログイン画面 (docs/ver1_login から移植) // セッションは sessionStorage で保持する。 // ============================================================ // ---------- ログイン: アイコン ---------- const LoginIcon = { Mail: () => ( ), Arrow: () => ( ), Back: () => ( ), Check: () => ( ), Alert: () => ( ), }; // ---------- ログイン: ロゴ ---------- function LoginBrandLogo() { return (
H
ホテル動画ジェネレータ
困っていますか? e.preventDefault()}>ヘルプ
); } // ---------- ログイン: メール入力ステップ ---------- function LoginStepEmail({ initialEmail, onSend, isLoading }) { const [email, setEmail] = useState(initialEmail || ""); const [agree, setAgree] = useState(true); const [touched, setTouched] = useState(false); const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); const showError = touched && !valid && email.length > 0; const submit = (e) => { e.preventDefault(); setTouched(true); if (!valid || !agree) return; onSend(email); }; return (
STEP 1 / 2

ログイン

メールアドレスに 6 桁の認証コードをお送りします。
ご登録がない場合は自動でアカウントが作成されます。

setEmail(e.target.value)} onBlur={() => setTouched(true)} autoFocus disabled={isLoading} /> {showError && ( 正しいメールアドレスを入力してください )}
); } // ---------- ログイン: 認証コードステップ ---------- function LoginStepCode({ email, expectedCode, onVerify, onBack, onResend, isLoading }) { const [digits, setDigits] = useState(["", "", "", "", "", ""]); const [error, setError] = useState(null); const [resendTimer, setResendTimer] = useState(45); const inputsRef = useRef([]); useEffect(() => { if (resendTimer <= 0) return; const id = setTimeout(() => setResendTimer((s) => s - 1), 1000); return () => clearTimeout(id); }, [resendTimer]); useEffect(() => { inputsRef.current[0]?.focus(); }, []); const setDigit = (i, v) => { setError(null); setDigits((prev) => { const next = [...prev]; next[i] = v; return next; }); }; const handleChange = (i, e) => { const v = e.target.value.replace(/\D/g, "").slice(-1); setDigit(i, v); if (v && i < 5) inputsRef.current[i + 1]?.focus(); if (i === 5 && v) { const all = [...digits.slice(0, 5), v].join(""); if (all.length === 6) setTimeout(() => verifyCode(all), 120); } }; const handleKeyDown = (i, e) => { if (e.key === "Backspace") { if (digits[i]) { setDigit(i, ""); } else if (i > 0) { inputsRef.current[i - 1]?.focus(); setDigit(i - 1, ""); } } else if (e.key === "ArrowLeft" && i > 0) { inputsRef.current[i - 1]?.focus(); } else if (e.key === "ArrowRight" && i < 5) { inputsRef.current[i + 1]?.focus(); } else if (e.key === "Enter") { const code = digits.join(""); if (code.length === 6) verifyCode(code); } }; const handlePaste = (e) => { const text = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6); if (!text) return; e.preventDefault(); const next = [...digits]; for (let i = 0; i < 6; i++) next[i] = text[i] || ""; setDigits(next); setError(null); inputsRef.current[Math.min(text.length, 5)]?.focus(); if (text.length === 6) setTimeout(() => verifyCode(text), 120); }; // 検証は完全にサーバ側で行う。expectedCode は廃止 (古いモック互換のため引数だけ残す)。 const verifyCode = async (code) => { setError(null); let ok = false; try { const r = onVerify(code); ok = r && typeof r.then === "function" ? await r : Boolean(r); } catch (_) { ok = false; } if (!ok) { setError("認証コードが一致しません。もう一度ご確認ください。"); setTimeout(() => { inputsRef.current[0]?.focus(); inputsRef.current[0]?.select?.(); }, 350); } }; const code = digits.join(""); const canSubmit = code.length === 6 && !isLoading; return (
{ e.preventDefault(); if (canSubmit) verifyCode(code); }} >
STEP 2 / 2

認証コードを入力

メールに 6 桁のコードをお送りしました。
受信トレイにない場合は迷惑メールフォルダもご確認ください。

送信先 {email}
{digits.map((d, i) => ( (inputsRef.current[i] = el)} type="text" inputMode="numeric" autoComplete={i === 0 ? "one-time-code" : "off"} maxLength={1} value={d} onChange={(e) => handleChange(i, e)} onKeyDown={(e) => handleKeyDown(i, e)} disabled={isLoading} className={"otp-cell" + (d ? " filled" : "") + (error ? " error" : "")} /> ))}
{error && ( {error} )}
コードが届かない? {resendTimer > 0 ? ( <> {String(Math.floor(resendTimer / 60)).padStart(2, "0")}:{String(resendTimer % 60).padStart(2, "0")} 後に再送 ) : ( )}
); } // ---------- ログイン: 成功ステップ ---------- function LoginStepSuccess({ email, onDone }) { useEffect(() => { const id = setTimeout(onDone, 1400); return () => clearTimeout(id); }, [onDone]); return (

ログイン完了

{email} でログインしました。

ダッシュボードへ移動しています
); } // ---------- ログイン: 開発用トースト (擬似メール) ---------- function LoginDevToast({ email, code, onDismiss }) { if (!code) return null; return (
DEV {email} 宛の認証コード: {code}
); } // ---------- ログイン: 画面全体 ---------- function LoginScreen({ onLogin }) { const [step, setStep] = useState("email"); const [email, setEmail] = useState(""); const [code, setCode] = useState(null); // dev トースト表示用 (LOGIN_DEV_RETURN_CODE=true 時のみ) const [loading, setLoading] = useState(false); const [animKey, setAnimKey] = useState(0); const [error, setError] = useState(""); // メールアドレスを受け取り → サーバに OTP 発行を依頼 const sendCode = async (em) => { setEmail(em); setLoading(true); setError(""); try { const fd = new FormData(); fd.append("email", em); const res = await fetch("/api/login/start", { method: "POST", body: fd }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`); // 開発モード時は code が返るので DEV トーストに表示 setCode(data.code || null); setStep("code"); setAnimKey((k) => k + 1); } catch (e) { setError(e.message || "送信に失敗しました"); } finally { setLoading(false); } }; // OTP を検証 → 成功なら Cookie がセットされる // 戻り値の boolean を StepCode 側のシェイク制御に使う const verifyCode = async (entered) => { setLoading(true); setError(""); try { const fd = new FormData(); fd.append("email", email); fd.append("code", String(entered || "")); const res = await fetch("/api/login/verify", { method: "POST", body: fd, credentials: "same-origin", }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.detail || "コードが一致しません"); setStep("success"); setAnimKey((k) => k + 1); return true; } catch (e) { setError(e.message || "検証に失敗しました"); return false; } finally { setLoading(false); } }; const resendCode = async () => { if (!email) return; await sendCode(email); }; const backToEmail = () => { setStep("email"); setCode(null); setError(""); setAnimKey((k) => k + 1); }; const handleDone = useCallback(() => { onLogin(email); }, [email, onLogin]); return (
{step === "email" && ( )} {step === "code" && ( )} {step === "success" && }
e.preventDefault()}>利用規約 e.preventDefault()}>プライバシー e.preventDefault()}>セキュリティ © 2026 Hotel Video Generator
setCode(null)} />
); } // ============================================================ // 以下、メインアプリ // ============================================================ // ---------- 定数 ---------- const ANGLES = [ ["foodie", "食重視"], ["onsen", "温泉重視"], ["couple", "カップル向け"], ["family", "家族連れ向け"], ["solo", "一人旅向け"], ]; const TELOP_STYLES = [ ["classic", "クラシック"], ["yellow", "イエロー"], ["pink", "ピンク"], ["neon", "ネオン"], ]; const TELOP_POS = [ ["top", "上"], ["center", "中央"], ["bottom", "下"], ]; const SPEAKERS = [ ["3", "ずんだもん"], ["2", "四国めたん"], ["8", "春日部つむぎ"], ["14", "冥鳴ひまり"], ]; // 「修正の方向性」プリセット const REWRITE_PRESETS = [ "もっと短く", "もっと長く", "数字を入れて", "数字を消す", "若者向けに", "丁寧に", "カジュアルに", "感情的に", "落ち着いた口調で", "一文にまとめて", ]; // ナレーション文字数からの粗い秒数推定 (UI 表示用) function estDuration(text) { const chars = (text || "").replace(/\s/g, "").length; return Math.max(1.5, +(chars / 6.2).toFixed(1)); } function cx(...xs) { return xs.filter(Boolean).join(" "); } // ---------- 素材プレビュー ---------- // File から img / video を作る。サムネ用に静止表示する。 function MaterialMedia({ material, autoplay = false, controls = false }) { if (!material) { return
素材なし
; } if (material.kind === "VIDEO") { return (