// Creator — multi-step wizard for turning an mp3 into a video

const CREATOR_STEPS = [
  { id: "upload",    label: "업로드" },
  { id: "transcribe",label: "자막 추출" },
  { id: "scenes",    label: "장면 설계" },
  { id: "preview",   label: "미리보기" },
  { id: "export",    label: "내보내기" },
  { id: "video-play",label: "Video Play" },
];

function getDefaultCreatorStep(song) {
  return (
    song?.workflowStep ||
    (song?.status === "ready" ? "video-play" : song?.status === "draft" ? "scenes" : "upload")
  );
}

function buildTranscribeWorkflowState({ mode, status = "idle", currentStageId = "", detail = "", hasReferenceLyrics = false }) {
  const steps = mode === "reference_lyrics"
    ? [
        {
          id: "lyrics",
          label: "1. 가사 준비",
          description: "입력된 가사를 정리해 forced aligner에 전달할 입력으로 확정합니다.",
        },
        {
          id: "align",
          label: "2. timestamp 정렬",
          description: "qwen-forced-aligner가 mp3와 가사를 직접 맞춰 timestamp를 생성합니다.",
        },
      ]
    : mode === "imported_json"
      ? [
          {
            id: "import",
            label: "1. JSON 불러오기",
            description: "기존 lyric_timeline_json 결과를 현재 곡에 적용합니다.",
          },
        ]
      : [
          {
            id: "transcribe",
            label: "1. 가사 추출",
            description: "qwen-asr-vllm이 mp3에서 가사 초안을 만듭니다.",
          },
          {
            id: "align",
            label: "2. timestamp 정렬",
            description: "qwen-forced-aligner가 추출된 가사에 timestamp를 붙입니다.",
          },
        ];

  const activeIndex = steps.findIndex((step) => step.id === currentStageId);
  const normalizedStatus = status || "idle";
  const normalizedSteps = steps.map((step, index) => {
    let stepStatus = "pending";

    if (normalizedStatus === "completed") {
      stepStatus = "completed";
    } else if (normalizedStatus === "failed") {
      if (activeIndex > index) stepStatus = "completed";
      else if (activeIndex === index || activeIndex < 0) stepStatus = "error";
    } else if (normalizedStatus === "running") {
      if (activeIndex > index) stepStatus = "completed";
      else if (activeIndex === index) stepStatus = "active";
    } else if (mode === "reference_lyrics" && hasReferenceLyrics && step.id === "lyrics") {
      stepStatus = "completed";
    }

    return { ...step, status: stepStatus };
  });

  return {
    mode,
    status: normalizedStatus,
    currentStageId,
    detail,
    stepCount: normalizedSteps.length,
    steps: normalizedSteps,
  };
}

function deriveTranscribeWorkflow(song) {
  if (song?.transcribeWorkflow?.steps?.length) {
    return song.transcribeWorkflow;
  }

  const hasReferenceLyrics = !!(song?.referenceLyricsText && song.referenceLyricsText.trim());
  const hasResult = !!(song?.lyricTimeline || (song?.cues && song.cues.length > 0));
  const mode = song?.transcribeMode || (hasReferenceLyrics ? "reference_lyrics" : "asr");

  if (mode === "imported_json") {
    return buildTranscribeWorkflowState({
      mode,
      status: hasResult ? "completed" : "idle",
      currentStageId: "import",
      detail: hasResult ? "저장된 lyric_timeline_json을 적용했습니다." : "저장된 JSON을 기다리는 중입니다.",
      hasReferenceLyrics,
    });
  }

  if (hasResult) {
    return buildTranscribeWorkflowState({
      mode,
      status: "completed",
      currentStageId: "align",
      detail: mode === "reference_lyrics"
        ? "입력된 가사 기준 timestamp 정렬이 완료되었습니다."
        : "ASR 가사 추출과 timestamp 정렬이 완료되었습니다.",
      hasReferenceLyrics,
    });
  }

  return buildTranscribeWorkflowState({
    mode: hasReferenceLyrics ? "reference_lyrics" : "asr",
    status: "idle",
    currentStageId: hasReferenceLyrics ? "lyrics" : "",
    detail: hasReferenceLyrics
      ? "가사가 이미 있으므로 2단계에서 qwen-forced-aligner만 실행하면 됩니다."
      : "가사가 없으므로 1단계 ASR 후 2단계 forced aligner 정렬을 진행합니다.",
    hasReferenceLyrics,
  });
}

function useSongAudioSource(song) {
  const storage = window.SongfilmSongStorage;
  const [audioUrl, setAudioUrlState] = useState(() => {
    window.songAudioUrls = window.songAudioUrls || new Map();
    return window.songAudioUrls.get(song.id) || null;
  });

  useEffect(() => {
    let cancelled = false;
    window.songAudioUrls = window.songAudioUrls || new Map();
    const current = window.songAudioUrls.get(song.id);
    if (current) {
      setAudioUrlState(current);
      return () => {
        cancelled = true;
      };
    }
    if (!storage || (!song.audioStorageKey && !song.audioServerUrl)) {
      setAudioUrlState(null);
      return () => {
        cancelled = true;
      };
    }
    storage.restoreAudioUrl(song)
      .then((restored) => {
        if (!cancelled) setAudioUrlState(restored || null);
      })
      .catch(() => {
        if (!cancelled) setAudioUrlState(null);
      });
    return () => {
      cancelled = true;
    };
  }, [song.id, song.audioStorageKey, song.audioPersistedAt, song.audioServerUrl, storage]);

  const setSongAudioUrl = (url) => {
    window.songAudioUrls = window.songAudioUrls || new Map();
    if (url) {
      window.songAudioUrls.set(song.id, url);
    } else {
      window.songAudioUrls.delete(song.id);
    }
    setAudioUrlState(url || null);
  };

  const clearSongAudioUrl = () => {
    window.songAudioUrls = window.songAudioUrls || new Map();
    const current = window.songAudioUrls.get(song.id);
    if (current && String(current).startsWith("blob:")) {
      URL.revokeObjectURL(current);
    }
    window.songAudioUrls.delete(song.id);
    setAudioUrlState(null);
  };

  return { audioUrl, setSongAudioUrl, clearSongAudioUrl };
}

function Creator({ album, setAlbum, songId, onBack, onExit, onOpenLyricEditor }) {
  const { Icon, SongfilmSongStorage } = window;
  const song = album.songs.find(s => s.id === songId);
  const [step, setStep] = useState(getDefaultCreatorStep(song));
  const [saving, setSaving] = useState(false);

  if (!song) return <div className="empty-state">노래를 찾을 수 없습니다.</div>;

  const updateSong = (patch) => {
    setAlbum(prev => ({
      ...prev,
      songs: prev.songs.map(s => {
        if (s.id !== songId) return s;
        const nextPatch = typeof patch === "function" ? patch(s) : patch;
        return { ...s, ...nextPatch };
      }),
    }));
  };

  useEffect(() => {
    const nextStep = getDefaultCreatorStep(song);
    if (nextStep !== step) {
      setStep(nextStep);
    }
  }, [song.id, song.workflowStep]);

  useEffect(() => {
    if (song.workflowStep !== step) {
      updateSong({ workflowStep: step });
    }
  }, [song.id]);

  const goToStep = (nextStep) => {
    setStep(nextStep);
    if (song.workflowStep !== nextStep) {
      updateSong({ workflowStep: nextStep });
    }
  };

  const finalizeSong = () => {
    updateSong({
      status: "ready",
      workflowStep: "video-play",
      completedAt: new Date().toISOString(),
    });
    setStep("video-play");
    window.toast("노래를 완결 상태로 표시했습니다.");
  };

  const persistSong = async ({ download = false } = {}) => {
    if (!SongfilmSongStorage) {
      window.toast("song storage helper가 로드되지 않았습니다");
      return;
    }
    setSaving(true);
    try {
      const { patch, errors } = await SongfilmSongStorage.persistSongState(song, { step });
      const mergedSong = {
        ...song,
        ...patch,
        scenes: patch.scenes || song.scenes,
      };
      updateSong(patch);
      if (download) {
        const filename = await SongfilmSongStorage.downloadSongSnapshot(mergedSong);
        if (errors.length) {
          window.toast(`저장 후 다운로드 완료 · ${filename} · 경고 ${errors.length}건`);
        } else {
          window.toast(`"${filename}" 다운로드 준비 완료`);
        }
        return;
      }
      if (errors.length) {
        window.toast(`저장 완료 · 경고 ${errors.length}건`);
      } else {
        window.toast("현재 단계 저장 완료");
      }
    } catch (error) {
      window.toast(`저장 실패: ${error.message || error}`);
    } finally {
      setSaving(false);
    }
  };

  const stepIdx = CREATOR_STEPS.findIndex(s => s.id === step);
  const canAdvance = stepIdx < CREATOR_STEPS.length - 1;
  const canGoBack = stepIdx > 0;

  return (
    <div className="creator">
      {/* Stepper */}
      <div className="stepper">
        {CREATOR_STEPS.map((s, i) => (
          <React.Fragment key={s.id}>
            <div
              className={`step ${step === s.id ? "active" : i < stepIdx ? "done" : ""}`}
              onClick={() => { if (i < stepIdx) goToStep(s.id); }}
            >
              <div className="step-num"><span className="step-num-val">{i+1}</span></div>
              <span>{s.label}</span>
            </div>
            {i < CREATOR_STEPS.length - 1 && <div className="step-sep"/>}
          </React.Fragment>
        ))}
        <div style={{marginLeft:"auto", display:"flex", alignItems:"center", gap: 10}}>
          <div style={{fontSize: 12.5, color:"var(--ink-3)"}}>
            <span style={{color:"var(--ink-2)"}}>{song.title || "제목 없음"}</span>
            <span style={{color:"var(--ink-4)", margin:"0 8px"}}>·</span>
            <span className="mono" style={{color:"var(--ink-3)", fontSize: 11}}>{song.filename}</span>
          </div>
        </div>
      </div>

      {/* Body */}
      <div className="creator-body">
        {step === "upload"     && <UploadStep song={song} updateSong={updateSong} onDone={() => goToStep("transcribe")}/>}
        {step === "transcribe" && <TranscribeStep song={song} updateSong={updateSong} onDone={() => goToStep("scenes")} onOpenLyricEditor={onOpenLyricEditor}/>}
        {step === "scenes"     && <ScenesStep album={album} song={song} updateSong={updateSong}/>}
        {step === "preview"    && <PreviewStep song={song} updateSong={updateSong}/>}
        {step === "export"     && <ExportStep song={song} album={album} updateSong={updateSong}/>}
        {step === "video-play" && <VideoPlayStep song={song} updateSong={updateSong}/>}
      </div>

      {/* Footer */}
      <div className="footer-bar">
        <button className="pill-btn" onClick={onBack}>
          <Icon.ArrowLeft/> 앨범으로
        </button>
        <div style={{flex:1}}/>
        <button className="pill-btn" onClick={() => persistSong()} disabled={saving}>
          <Icon.Check size={11}/> {saving ? "저장 중" : "현재 단계 저장"}
        </button>
        <button className="pill-btn" onClick={() => persistSong({ download: true })} disabled={saving}>
          <Icon.Download size={11}/> Song JSON 다운로드
        </button>
        {canGoBack && (
          <button className="pill-btn" onClick={() => goToStep(CREATOR_STEPS[stepIdx-1].id)}>
            <Icon.ArrowLeft/> 이전
          </button>
        )}
        {step === "export" && song.exportState?.status === "done" && (
          <button className="pill-btn primary" onClick={finalizeSong}>
            완료&Movie Play <Icon.ArrowRight/>
          </button>
        )}
        {canAdvance && step !== "export" && (
          <button className="pill-btn primary" onClick={() => goToStep(CREATOR_STEPS[stepIdx+1].id)}>
            다음: {CREATOR_STEPS[stepIdx+1].label} <Icon.ArrowRight/>
          </button>
        )}
      </div>
    </div>
  );
}

// ─── Step 1: Upload ────────────────────────────────────
function UploadStep({ song, updateSong, onDone }) {
  const { Icon, Waveform, fmtTime, SongfilmSongStorage } = window;
  const [dragging, setDragging] = useState(false);
  const inputRef = useRef();
  const restoreRef = useRef();
  const audioRef = useRef();
  const [playing, setPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const { audioUrl, setSongAudioUrl, clearSongAudioUrl } = useSongAudioSource(song);

  const titleFromFilename = (name) =>
    name
      .replace(/\.[^.]+$/, "")      // 확장자 제거
      .replace(/^\d+[._\- ]+/, "")  // 앞 트랙 번호 제거 (예: 01_, 1. 등)
      .replace(/_/g, " ")           // 언더스코어 → 공백
      .trim();

  const handleFile = (f) => {
    if (!f) return;
    const sizeMB = (f.size / (1024*1024)).toFixed(1);
    const title = titleFromFilename(f.name);

    // revoke previous URL for this song
    clearSongAudioUrl();

    const url = URL.createObjectURL(f);
    setSongAudioUrl(url);

    // 서버 업로드 시도 → 실패 시 IndexedDB 폴백
    function uploadAudio() {
      const broker = window.SongfilmAIBroker;
      const fallbackToIndexedDB = (err) => {
        if (err) console.warn("[handleFile] 서버 업로드 실패, IndexedDB로 폴백:", err?.message || err);
        SongfilmSongStorage?.persistAudioFile(song.id, f)
          .then((audioPatch) => updateSong({ ...audioPatch, audioServerUrl: "" }))
          .catch((e) => window.toast(`오디오 저장 실패: ${e.message || e}`));
      };
      if (broker) {
        broker.saveAudioFile({ blob: f, filename: f.name })
          .then((result) => {
            if (result?.url) {
              updateSong({
                audioServerUrl: window.SongfilmApiConfig?.getMediaFilename?.(result.filename || result.url) || result.filename || result.url,
                audioStorageKey: "",
                audioPersistedAt: "",
                audioMimeType: f.type || "audio/mpeg",
                audioByteLength: f.size || 0,
              });
            } else {
              fallbackToIndexedDB(null);
            }
          })
          .catch(fallbackToIndexedDB);
      } else {
        fallbackToIndexedDB(null);
      }
    }

    const basePatch = {
      filename: f.name,
      title,
      size: `${sizeMB} MB`,
      status: "uploaded",
      cues: [],
      scenes: [],
      lyricTimeline: null,
      transcribeMode: "",
      transcribeWorkflow: null,
      storyId: "",
      workflowStep: "upload",
      audioServerUrl: "",
      audioStorageKey: "",
      audioPersistedAt: "",
      previewSettings: {
        fontSize: 36,
        subPos: "bottom",
        transition: "fade",
      },
      exportState: {
        status: "idle",
        progress: 0,
        done: false,
      },
    };

    // probe real duration before committing
    const probe = new Audio();
    probe.src = url;
    probe.addEventListener("loadedmetadata", () => {
      updateSong({ ...basePatch, duration: probe.duration || 30 });
      uploadAudio();
      window.toast(`"${title}" 업로드 완료 · ${fmtTime(probe.duration || 0)}`);
    });
    probe.addEventListener("error", () => {
      updateSong({ ...basePatch, duration: 30 });
      uploadAudio();
      window.toast(`"${title}" 업로드 완료 (메타데이터 읽기 실패)`);
    });
  };

  const handleSnapshotImport = (event) => {
    const file = event.target.files?.[0];
    if (!file) return;
    event.target.value = "";
    SongfilmSongStorage.importSongSnapshotFile(file, song.id)
      .then((patch) => {
        updateSong({
          ...patch,
          workflowStep: patch.workflowStep || "upload",
        });
        setPlaying(false);
        setCurrentTime(0);
        window.toast(`"${file.name}" 복구 완료`);
      })
      .catch((error) => {
        window.toast(`복구 실패: ${error.message || error}`);
      });
  };

  useEffect(() => {
    const a = audioRef.current;
    if (!a) return;
    const onTime = () => setCurrentTime(a.currentTime);
    const onEnd = () => { setPlaying(false); setCurrentTime(0); };
    a.addEventListener("timeupdate", onTime);
    a.addEventListener("ended", onEnd);
    return () => {
      a.removeEventListener("timeupdate", onTime);
      a.removeEventListener("ended", onEnd);
    };
  }, [audioUrl]);

  const togglePlay = () => {
    const a = audioRef.current;
    if (!a) return;
    if (playing) {
      a.pause();
      setPlaying(false);
    } else {
      a.play()
        .then(() => setPlaying(true))
        .catch(() => window.toast("재생할 수 없습니다"));
    }
  };

  const seek = (e) => {
    const a = audioRef.current;
    if (!a || !song.duration) return;
    const rect = e.currentTarget.getBoundingClientRect();
    const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    a.currentTime = pct * song.duration;
    setCurrentTime(a.currentTime);
  };

  const removeFile = () => {
    const a = audioRef.current;
    if (a) a.pause();
    clearSongAudioUrl();
    SongfilmSongStorage?.clearSongAudio(song.id).catch(() => {});
    setPlaying(false);
    setCurrentTime(0);
    updateSong({
      filename: "",
      size: "",
      status: "empty",
      cues: [],
      scenes: [],
      lyricTimeline: null,
      transcribeMode: "",
      transcribeWorkflow: null,
      storyId: "",
      workflowStep: "upload",
      audioStorageKey: "",
      audioPersistedAt: "",
      audioByteLength: 0,
      previewSettings: {
        fontSize: 36,
        subPos: "bottom",
        transition: "fade",
      },
      exportState: {
        status: "idle",
        progress: 0,
        done: false,
      },
    });
  };

  const hasFile = song.filename;
  const progress = song.duration ? currentTime / song.duration : 0;

  return (
    <div>
      <div style={{maxWidth: 640, margin: "0 auto"}}>
        <div style={{textAlign:"center", marginBottom: 10}}>
          <div className="display" style={{fontSize: 36, fontWeight:600, letterSpacing:"-0.02em"}}>노래를 업로드하세요</div>
          <div style={{color:"var(--ink-3)", fontSize: 13.5, marginTop: 6}}>
            mp3 파일을 올리면 가사를 추출하고 장면을 디자인해 비디오를 만듭니다.
          </div>
        </div>
      </div>

      {!hasFile ? (
        <div
          className={`upload-zone ${dragging ? "dragging" : ""}`}
          onClick={() => inputRef.current?.click()}
          onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
          onDragLeave={() => setDragging(false)}
          onDrop={(e) => {
            e.preventDefault(); setDragging(false);
            const f = e.dataTransfer.files?.[0];
            if (f) handleFile(f);
          }}
        >
          <div className="upload-icon"><Icon.Upload/></div>
          <h2>mp3를 여기에 드래그</h2>
          <p>또는 <span className="browse">파일 탐색</span> · 최대 15MB</p>
          <input ref={inputRef} type="file" accept="audio/mpeg,audio/mp3,audio/*" hidden onChange={e => handleFile(e.target.files?.[0])}/>
          <input ref={restoreRef} type="file" accept=".json,application/json" hidden onChange={handleSnapshotImport}/>
          <div style={{marginTop: 16, fontSize: 12, color:"var(--ink-3)"}}>
            저장된 Song JSON이 있으면 바로 복구할 수 있습니다.
          </div>
          <button className="pill-btn" style={{marginTop: 12}} onClick={(e) => { e.stopPropagation(); restoreRef.current?.click(); }}>
            <Icon.Upload size={11}/> Song JSON 복구
          </button>
        </div>
      ) : (
        <>
          {audioUrl && <audio ref={audioRef} src={audioUrl} preload="metadata"/>}
          <div className="uploaded-card">
            <button
              className="art"
              onClick={audioUrl ? togglePlay : undefined}
              disabled={!audioUrl}
              style={{
                cursor: audioUrl ? "pointer" : "default",
                transition: "transform 120ms",
              }}
              onMouseEnter={e => { if (audioUrl) e.currentTarget.style.transform = "scale(1.05)"; }}
              onMouseLeave={e => { e.currentTarget.style.transform = "scale(1)"; }}
              title={audioUrl ? (playing ? "일시정지" : "재생") : "재생할 수 없음"}
            >
              {audioUrl ? (playing ? <Icon.Pause size={20}/> : <Icon.Play size={20}/>) : <Icon.Music/>}
            </button>
            <div className="info">
              <div className="fname">{song.title || song.filename.replace(/\.[^.]+$/, "")}</div>
              <div className="fmeta">
                {song.filename} · {song.size} ·{" "}
                {audioUrl
                  ? <>{fmtTime(currentTime)} / {fmtTime(song.duration)}</>
                  : fmtTime(song.duration)}
              </div>
              <div style={{marginTop: 10, cursor: audioUrl ? "pointer" : "default"}} onClick={audioUrl ? seek : undefined}>
                <Waveform seed={song.filename.length + 7} bars={80} height={32} progress={progress} playing={playing}/>
              </div>
            </div>
            <button className="pill-btn" onClick={removeFile}>
              <Icon.X/> 제거
            </button>
          </div>

          <div style={{maxWidth: 640, margin: "12px auto 0", display:"flex", justifyContent:"flex-end"}}>
            <button className="pill-btn" onClick={() => restoreRef.current?.click()}>
              <Icon.Upload size={11}/> Song JSON으로 덮어쓰기 복구
            </button>
          </div>
          <input ref={restoreRef} type="file" accept=".json,application/json" hidden onChange={handleSnapshotImport}/>

          {audioUrl ? (
            <div style={{maxWidth: 640, margin: "12px auto 0", fontSize: 11.5, color:"var(--ink-3)", display:"flex", alignItems:"center", gap: 8}}>
              <Icon.Check size={11}/> 업로드된 파일을 재생해서 확인해보세요. 새로고침 후에도 복구 가능한 상태로 저장됩니다.
            </div>
          ) : (
            <div style={{maxWidth: 640, margin: "12px auto 0", fontSize: 11.5, color:"var(--ink-4)", fontStyle:"italic"}}>
              * 샘플 노래이거나 새로고침 이후에는 재생할 수 없습니다. 재생하려면 파일을 다시 업로드하세요.
            </div>
          )}

          <div style={{maxWidth: 640, margin: "24px auto 0", background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 12, padding: 20}}>
            <div style={{fontSize: 12, color:"var(--ink-3)", marginBottom: 10, textTransform:"uppercase", letterSpacing:"0.06em", fontWeight:600}}>노래 정보</div>
            <div style={{display:"flex", gap: 12}}>
              <input
                placeholder="제목"
                value={song.title || ""}
                onChange={e => updateSong({ title: e.target.value })}
                style={{flex: 1, background:"var(--bg-2)", border:"1px solid var(--line)", borderRadius: 8, padding: "10px 12px", fontSize: 14, outline:"none"}}
              />
            </div>
          </div>

          <div style={{maxWidth: 640, margin: "24px auto 0", textAlign:"right"}}>
            <button className="pill-btn accent" onClick={onDone}>
              자막 추출 시작 <Icon.ArrowRight/>
            </button>
          </div>
        </>
      )}
    </div>
  );
}

// ─── Step 2: Transcribe ────────────────────────────────
function TranscribeStep({ song, updateSong, onDone, onOpenLyricEditor }) {
  const { Icon, fmtTime, fmtTimeMs, SongfilmSongStorage } = window;
  const hasResult = !!(song.lyricTimeline || (song.cues && song.cues.length > 0));
  const hasReferenceLyrics = !!(song.referenceLyricsText && song.referenceLyricsText.trim());
  const referenceLyricsLineCount = hasReferenceLyrics
    ? song.referenceLyricsText.trim().split(/\n+/).length
    : 0;
  // lyricTimeline만 있는 경우 cues 파생 (표시용)
  const displayCues = song.cues && song.cues.length > 0
    ? song.cues
    : (song.lyricTimeline ? window.SongfilmAPI.flattenTimelineToCues(song.lyricTimeline) : []);
  const [running, setRunning] = useState(false);
  const [logs, setLogs] = useState([]);
  const [progress, setProgress] = useState(hasResult ? 100 : 0);
  const [workflow, setWorkflow] = useState(() => deriveTranscribeWorkflow(song));

  // Audio playback
  const audioRef = useRef();
  const lyricsListRef = useRef();
  const [playing, setPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const { audioUrl } = useSongAudioSource(song);

  useEffect(() => {
    setWorkflow(deriveTranscribeWorkflow(song));
  }, [song.id, song.transcribeWorkflow, song.transcribeMode, song.referenceLyricsText, song.lyricTimeline, song.cues]);

  useEffect(() => {
    const a = audioRef.current;
    if (!a) return;
    const onTime = () => setCurrentTime(a.currentTime);
    const onEnd  = () => { setPlaying(false); setCurrentTime(0); };
    a.addEventListener("timeupdate", onTime);
    a.addEventListener("ended", onEnd);
    return () => {
      a.removeEventListener("timeupdate", onTime);
      a.removeEventListener("ended", onEnd);
    };
  }, [audioUrl, hasResult]);

  // displayCues 변경 시 콘솔에 현재 자막 목록 출력
  useEffect(() => {
    if (!displayCues.length) return;
    console.group(`[Songfilm] 자막 목록 업데이트 — "${song.title || song.filename}"`);
    console.log('총 줄 수:', displayCues.length);
    console.log('총 길이:', window.fmtTime(displayCues[displayCues.length - 1].end));
    console.table(displayCues.map((c, i) => ({
      '#': i + 1,
      '시작(s)': c.start,
      '끝(s)': c.end,
      '길이(s)': (c.end - c.start).toFixed(2),
      '가사': c.text,
    })));
    console.groupEnd();
  }, [displayCues]);

  // 현재 재생 중인 자막 인덱스 (currentTime으로 계산)
  const activeCueIdx = hasResult
    ? displayCues.findIndex(c => currentTime >= c.start && currentTime < c.end)
    : -1;

  // 활성 자막이 바뀌면 자동 스크롤
  useEffect(() => {
    if (activeCueIdx < 0 || !lyricsListRef.current) return;
    const rows = lyricsListRef.current.querySelectorAll(".lyric-row");
    rows[activeCueIdx]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
  }, [activeCueIdx]);

  const handleCueClick = (cue, idx) => {
    if (!audioUrl) {
      window.toast("업로드 단계에서 파일을 업로드하면 여기서 재생할 수 있습니다");
      return;
    }
    const a = audioRef.current;
    if (!a) return;
    if (playing && activeCueIdx === idx) {
      // 같은 자막 다시 클릭 → 정지
      a.pause();
      setPlaying(false);
    } else {
      // 해당 자막 시작 위치로 이동 후 재생
      a.currentTime = cue.start;
      a.play().then(() => setPlaying(true)).catch(() => window.toast("재생할 수 없습니다"));
    }
  };

  // ── 내보내기 / 불러오기 ──────────────────────────────────
  const importFileRef = useRef();
  const lyricsFileRef = useRef();

  const sanitizeLyricsText = (text) => String(text || '').replace(/\ufeff/g, '').replace(/\r\n/g, '\n');
  const normalizeLyricsText = (text) => sanitizeLyricsText(text).trim();
  const referenceLyricsLabel = song.referenceLyricsName || '직접 입력';

  const setReferenceLyrics = (text, sourceName) => {
    const sanitized = sanitizeLyricsText(text);
    const trimmed = sanitized.trim();
    updateSong({
      referenceLyricsText: sanitized,
      referenceLyricsName: trimmed ? (sourceName || song.referenceLyricsName || '직접 입력') : '',
    });
  };

  const downloadTimeline = () => {
    const tl = song.lyricTimeline;
    if (!tl) { window.toast('내보낼 lyric_timeline이 없습니다'); return; }
    const filename = `${(song.title || 'song').replace(/[^\w가-힣\- ]/g, '_')}.lyric-timeline.json`;
    const blob = new Blob([JSON.stringify(tl, null, 2)], { type: 'application/json' });
    const url  = URL.createObjectURL(blob);
    const a    = document.createElement('a');
    a.href = url; a.download = filename; a.click();
    URL.revokeObjectURL(url);
    window.toast(`"${filename}" 저장됨`);
  };

  const handleImport = (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    e.target.value = '';                        // 같은 파일 재선택 허용
    const reader = new FileReader();
    reader.onload = (ev) => {
      try {
        const parsed = JSON.parse(ev.target.result);
        if (parsed.schema !== 'lyric-timeline/v1' || !parsed.root) {
          window.toast('올바른 lyric-timeline/v1 파일이 아닙니다'); return;
        }
        const cues = window.SongfilmAPI.flattenTimelineToCues(parsed);
        const scenes = window.buildScenes(cues);
        updateSong({
          lyricTimeline: parsed,
          cues,
          scenes,
          status: 'transcribed',
          paletteName: song.paletteName || 'midnight-violet',
          transcribeMode: 'imported_json',
          transcribeWorkflow: buildTranscribeWorkflowState({
            mode: 'imported_json',
            status: 'completed',
            currentStageId: 'import',
            detail: '저장된 lyric_timeline_json을 불러왔습니다.',
            hasReferenceLyrics,
          }),
          workflowStep: 'transcribe',
          storyId: SongfilmSongStorage?.buildStoryId({ ...song, lyricTimeline: parsed }) || song.storyId || '',
        });
        window.toast(`"${file.name}" 불러오기 완료 · ${cues.length}줄`);
      } catch (err) {
        window.toast(`JSON 파싱 오류: ${err.message}`);
      }
    };
    reader.readAsText(file);
  };

  const handleLyricsUpload = (e) => {
    const file = e.target.files?.[0];
    if (!file) return;
    e.target.value = '';
    const reader = new FileReader();
    reader.onload = (ev) => {
      try {
        const text = normalizeLyricsText(String(ev.target?.result || ''));
        if (!text) {
          window.toast('가사 text 파일이 비어 있습니다');
          return;
        }
        setReferenceLyrics(text, file.name);
        window.toast(`"${file.name}" 가사 text 업로드 완료 · ${text.split(/\n+/).length}줄`);
      } catch (err) {
        window.toast(`가사 text 읽기 오류: ${err.message}`);
      }
    };
    reader.onerror = () => window.toast('가사 text 파일을 읽을 수 없습니다');
    reader.readAsText(file);
  };

  const clearLyricsUpload = () => {
    setReferenceLyrics('', '');
    window.toast('가사 text 입력 내용을 제거했습니다');
  };

  const logViewerRef = useRef();

  const addLog = (tag, msg) => {
    const ts = new Date();
    const tsStr = `${ts.getMinutes().toString().padStart(2,"0")}:${ts.getSeconds().toString().padStart(2,"0")}.${Math.floor(ts.getMilliseconds()/10).toString().padStart(2,"0")}`;
    console.log(`[Songfilm ASR] [${tag.toUpperCase()}] ${msg}`);
    setLogs(prev => {
      const next = [...prev, { tag, msg, ts: tsStr }];
      return next;
    });
  };

  // 새 로그 줄이 추가되면 log-viewer 자동 스크롤
  useEffect(() => {
    if (logViewerRef.current) {
      logViewerRef.current.scrollTop = logViewerRef.current.scrollHeight;
    }
  }, [logs]);

  const start = async ({ useReferenceLyrics = false } = {}) => {
    const mode = useReferenceLyrics ? 'reference_lyrics' : 'asr';
    let latestStageId = useReferenceLyrics ? 'lyrics' : 'transcribe';
    setRunning(true);
    setLogs([]);
    setProgress(0);
    setPlaying(false);
    setCurrentTime(0);
    try {
      if (useReferenceLyrics && !hasReferenceLyrics) {
        throw new Error('먼저 가사 text를 입력하거나 업로드해주세요.');
      }
      const blobUrl = (window.songAudioUrls || new Map()).get(song.id);
      if (!blobUrl) throw new Error('업로드된 파일이 없습니다. 먼저 업로드 단계에서 파일을 올려주세요.');
      const blobRes = await fetch(blobUrl);
      const blob = await blobRes.blob();
      const file = new File([blob], song.filename || 'audio.mp3', { type: blob.type || 'audio/mpeg' });
      const startedWorkflow = buildTranscribeWorkflowState({
        mode,
        status: 'running',
        currentStageId: latestStageId,
        detail: useReferenceLyrics
          ? '가사가 이미 있으므로 ASR를 건너뛰고 정렬 준비를 시작합니다.'
          : '1단계 ASR 가사 추출을 시작합니다.',
        hasReferenceLyrics: useReferenceLyrics,
      });
      setWorkflow(startedWorkflow);
      updateSong({
        transcribeWorkflow: startedWorkflow,
      });
      addLog('info', useReferenceLyrics
        ? '2단계 워크플로우 중 2단계만 실행합니다. 입력된 가사로 qwen-forced-aligner 정렬을 시작합니다.'
        : '2단계 워크플로우를 시작합니다. 1단계 ASR 가사 추출 후 2단계 forced aligner 정렬을 진행합니다.');
      const { lyricTimeline, cues } = await window.SongfilmAPI.transcribe(file, {
        onLog: addLog,
        onProgress: setProgress,
        onStageChange: ({ stageId, detail, status }) => {
          latestStageId = stageId || latestStageId;
          setWorkflow(buildTranscribeWorkflowState({
            mode,
            status: status || 'running',
            currentStageId: latestStageId,
            detail,
            hasReferenceLyrics: useReferenceLyrics,
          }));
        },
        referenceLyrics: useReferenceLyrics ? song.referenceLyricsText : null,
        title: song.title || file.name.replace(/\.[^.]+$/, ''),
      });
      const scenes = window.buildScenes(cues);
      const completedWorkflow = buildTranscribeWorkflowState({
        mode,
        status: 'completed',
        currentStageId: mode === 'imported_json' ? 'import' : 'align',
        detail: useReferenceLyrics
          ? '입력된 가사에 timestamp 정렬을 완료했습니다.'
          : 'ASR 가사 추출과 timestamp 정렬을 모두 완료했습니다.',
        hasReferenceLyrics: useReferenceLyrics,
      });
      setRunning(false);
      setProgress(100);
      setWorkflow(completedWorkflow);
      updateSong({
        lyricTimeline,
        cues,
        scenes,
        status: 'transcribed',
        paletteName: song.paletteName || 'midnight-violet',
        transcribeMode: mode,
        transcribeWorkflow: completedWorkflow,
        workflowStep: 'transcribe',
        storyId: SongfilmSongStorage?.buildStoryId({ ...song, lyricTimeline }) || song.storyId || '',
      });
      window.toast(`${useReferenceLyrics ? '가사 text 기준 forced aligner' : 'ASR + forced aligner'} 자막 추출 완료 · ${cues.length}줄`);
    } catch (err) {
      const failedWorkflow = buildTranscribeWorkflowState({
        mode,
        status: 'failed',
        currentStageId: latestStageId,
        detail: err.message,
        hasReferenceLyrics: useReferenceLyrics,
      });
      setWorkflow(failedWorkflow);
      updateSong({ transcribeWorkflow: failedWorkflow });
      addLog('err', `오류: ${err.message}`);
      setRunning(false);
      window.toast(`오류: ${err.message}`);
    }
  };

  return (
    <div className="process-panel">
      {audioUrl && <audio ref={audioRef} src={audioUrl} preload="metadata"/>}
      {/* 숨겨진 파일 입력 — JSON 불러오기용 */}
      <input
        ref={importFileRef}
        type="file"
        accept=".json,application/json"
        style={{display:"none"}}
        onChange={handleImport}
      />
      <input
        ref={lyricsFileRef}
        type="file"
        accept=".txt,.md,.lrc,text/plain,text/markdown"
        style={{display:"none"}}
        onChange={handleLyricsUpload}
      />

      <h2>가사 & 자막 추출</h2>
      <div className="sub">
        이 화면은 항상 2단계로 관리됩니다. 가사가 없으면 `qwen-asr-vllm -> qwen-forced-aligner`, 가사가 이미 있으면 `가사 확인 -> qwen-forced-aligner` 흐름으로 바로 timestamp를 붙입니다.
      </div>

      <div style={{marginTop: 16, display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(220px, 1fr))', gap: 12}}>
        {workflow.steps.map((step, index) => {
          const isActive = step.status === 'active';
          const isDone = step.status === 'completed';
          const isError = step.status === 'error';
          return (
            <div
              key={step.id}
              style={{
                border: `1px solid ${isError ? '#ff6b81' : isActive || isDone ? 'var(--accent)' : 'var(--line)'}`,
                background: isActive ? 'rgba(255,255,255,0.04)' : 'var(--bg-1)',
                borderRadius: 12,
                padding: 14,
                boxShadow: isActive ? '0 0 0 1px rgba(255,255,255,0.03) inset' : 'none',
              }}
            >
              <div style={{display:'flex', alignItems:'center', justifyContent:'space-between', gap: 10}}>
                <div style={{fontSize: 13, fontWeight: 700, color:'var(--ink)'}}>{step.label}</div>
                <span className="mono" style={{fontSize: 11, color: isError ? '#ff6b81' : isActive || isDone ? 'var(--accent)' : 'var(--ink-4)'}}>
                  {isError ? 'error' : isDone ? 'done' : isActive ? 'active' : `${index + 1}/${workflow.stepCount}`}
                </span>
              </div>
              <div style={{fontSize: 12, color:'var(--ink-3)', lineHeight: 1.6, marginTop: 8}}>{step.description}</div>
            </div>
          );
        })}
      </div>

      <div style={{marginTop: 10, fontSize: 12, color: workflow.status === 'failed' ? '#ff6b81' : 'var(--ink-3)'}}>
        {workflow.detail}
      </div>

      <div style={{marginTop: 18, background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 12, padding: 18}}>
        <div style={{display:"flex", justifyContent:"space-between", alignItems:"flex-start", gap: 16, flexWrap:"wrap"}}>
          <div>
            <div style={{fontSize: 14, fontWeight: 600, color:"var(--ink)"}}>가사 text 입력</div>
            <div style={{fontSize: 12, color:"var(--ink-3)", marginTop: 4}}>
              txt/md/lrc 파일 업로드도 가능하고, 여기에 직접 붙여넣거나 수정해도 됩니다.
            </div>
            {hasReferenceLyrics && (
              <div style={{marginTop: 10, display:"flex", alignItems:"center", gap: 8, flexWrap:"wrap", fontSize: 11.5, color:"var(--ink-2)"}}>
                <span className="mono" style={{color:"var(--accent)"}}>{referenceLyricsLabel}</span>
                <span>· {referenceLyricsLineCount}줄</span>
                <span>· {song.referenceLyricsText.trim().length}자</span>
              </div>
            )}
          </div>
          <div style={{display:"flex", alignItems:"center", gap: 8, flexWrap:"wrap"}}>
            <button className="pill-btn" onClick={() => lyricsFileRef.current?.click()}>
              <Icon.Upload size={11}/> 가사 text 업로드
            </button>
            {hasReferenceLyrics && (
              <>
                <button className="pill-btn accent" onClick={() => start({ useReferenceLyrics: true })} disabled={running}>
                  <Icon.Sparkle/> 가사로 바로 timestamp 정렬
                </button>
                <button className="pill-btn" onClick={clearLyricsUpload}>
                  <Icon.X size={11}/> 입력 지우기
                </button>
              </>
            )}
          </div>
        </div>
        <textarea
          value={song.referenceLyricsText || ''}
          onChange={e => setReferenceLyrics(e.target.value)}
          onBlur={e => {
            const normalized = normalizeLyricsText(e.target.value);
            if (normalized !== e.target.value) setReferenceLyrics(normalized);
          }}
          placeholder={'가사를 여기에 붙여넣으세요.\n예)\n첫 번째 줄\n두 번째 줄'}
          style={{
            width:"100%",
            minHeight: 180,
            marginTop: 14,
            resize:"vertical",
            background:"var(--bg-2)",
            color:"var(--ink)",
            border:"1px solid var(--line)",
            borderRadius: 10,
            padding:"14px 16px",
            outline:"none",
            fontSize: 13,
            lineHeight: 1.7,
            fontFamily:"var(--font-mono)",
            boxSizing:"border-box",
          }}
        />
      </div>

      {!hasResult && !running && (
        <div style={{background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 12, overflow:"hidden"}}>
          <div style={{display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(280px, 1fr))', gap: 0}}>
            <div style={{padding: "30px 22px", borderRight: hasReferenceLyrics ? "1px solid var(--line)" : "none"}}>
              <Icon.Wand/>
              <div style={{fontFamily:"var(--font-display)", fontSize: 20, fontWeight:600, marginTop: 10}}>1단계부터 진행</div>
              <div style={{color:"var(--ink-3)", fontSize: 13, marginTop: 6}}>가사가 없을 때 사용하는 기본 경로입니다. `qwen-asr-vllm`으로 가사를 추출한 뒤 `qwen-forced-aligner`로 timestamp를 만듭니다.</div>
              <button className="pill-btn accent" style={{marginTop: 18}} onClick={() => start()}>
                <Icon.Sparkle/> ASR + 정렬 시작
              </button>
            </div>
            {hasReferenceLyrics && (
              <div style={{padding: "30px 22px"}}>
                <Icon.Sparkle/>
                <div style={{fontFamily:"var(--font-display)", fontSize: 20, fontWeight:600, marginTop: 10}}>2단계만 바로 실행</div>
                <div style={{color:"var(--ink-3)", fontSize: 13, marginTop: 6}}>가사가 이미 있으므로 `qwen-asr-vllm`은 건너뜁니다. 업로드한 mp3와 현재 가사를 바로 `qwen-forced-aligner`에 보내 timestamp를 붙입니다.</div>
                <button className="pill-btn" style={{marginTop: 18}} onClick={() => start({ useReferenceLyrics: true })}>
                  <Icon.ArrowRight/> forced aligner 바로 호출
                </button>
              </div>
            )}
          </div>
          <div style={{textAlign:"center", padding: "20px", display:"flex", alignItems:"center", gap: 12, justifyContent:"center", borderTop:"1px solid var(--line)"}}>
            <span style={{fontSize: 12, color:"var(--ink-4)"}}>이미 추출한 파일이 있다면</span>
            <button className="pill-btn" onClick={() => importFileRef.current?.click()}>
              <Icon.Upload size={11}/> JSON 불러오기
            </button>
          </div>
        </div>
      )}

      {(running || logs.length > 0) && (
        <>
          <div className="log-viewer" ref={logViewerRef}>
            {logs.map((l, i) => (
              <div key={i} className="log-line">
                <span className="log-ts mono">{l.ts}</span>
                <span className={`log-tag mono ${l.tag}`}>
                  {l.tag === "ok" ? "✓" : l.tag === "work" ? "▸" : l.tag === "err" ? "✗" : "·"}
                </span>
                <span style={{color: l.tag === "err" ? "#ff6b81" : undefined}}>{l.msg}</span>
              </div>
            ))}
            {running && (
              <div className="log-line">
                <span className="log-ts mono">…</span>
                <span className="log-tag mono info">·</span>
                <span style={{color:"var(--ink-3)"}}>대기 중…</span>
              </div>
            )}
          </div>
          <div className="progress-track"><div className="bar" style={{width: `${progress}%`}}/></div>
          <div style={{display:"flex", justifyContent:"space-between", fontSize: 11.5, color:"var(--ink-3)", marginTop: 6, fontFamily:"var(--font-mono)"}}>
            <span>{progress}% 진행</span>
            <span>{logs.length}개 로그</span>
          </div>
        </>
      )}

      {hasResult && (
        <>
          <div style={{marginTop: 28, display:"flex", justifyContent:"space-between", alignItems:"center"}}>
            <div>
              <div style={{fontSize: 13, fontWeight: 600, color:"var(--ink)"}}>
                추출된 자막 ({displayCues.length}줄)
                {song.lyricTimeline && <span style={{marginLeft: 8, fontSize: 11, color:"var(--accent)", fontFamily:"var(--font-mono)"}}>lyric_timeline_json ✓</span>}
              </div>
              <div style={{fontSize: 12, color:"var(--ink-3)", marginTop: 2}}>
                총 {displayCues.length ? fmtTime(displayCues[displayCues.length-1].end) : '—'}
                {audioUrl
                  ? <span style={{color:"var(--accent)", marginLeft: 8}}>· 항목을 클릭하면 해당 부분부터 재생</span>
                  : <span style={{color:"var(--ink-4)", marginLeft: 8}}>· 재생하려면 업로드 단계로 돌아가세요</span>
                }
              </div>
              <div style={{fontSize: 11.5, color:"var(--ink-3)", marginTop: 6, display:"flex", alignItems:"center", gap: 8, flexWrap:"wrap"}}>
                <span>
                  추출 방식: {
                    song.transcribeMode === 'reference_lyrics'
                      ? '가사 확인 + forced aligner 정렬'
                      : song.transcribeMode === 'imported_json'
                        ? '저장된 JSON 불러오기'
                        : 'ASR + forced aligner'
                  }
                </span>
                {song.transcribeWorkflow?.stepCount > 1 && <span className="mono" style={{color:"var(--accent)"}}>{song.transcribeWorkflow.stepCount} steps</span>}
                {hasReferenceLyrics && <span className="mono" style={{color:"var(--accent)"}}>lyrics: {referenceLyricsLabel}</span>}
              </div>
            </div>
            <div style={{display:"flex", alignItems:"center", gap: 8, flexWrap:"wrap", justifyContent:"flex-end"}}>
              {audioUrl && playing && (
                <div style={{display:"flex", alignItems:"center", gap: 6, fontSize: 12, color:"var(--accent)", fontFamily:"var(--font-mono)"}}>
                  <Icon.Play size={11}/> {fmtTime(currentTime)}
                </div>
              )}
              {song.lyricTimeline && (
                <button className="pill-btn" onClick={downloadTimeline} title="lyric_timeline_json 파일로 저장">
                  <Icon.Download size={11}/> JSON 저장
                </button>
              )}
              {onOpenLyricEditor && (
                <button className="pill-btn accent" onClick={onOpenLyricEditor}>
                  <Icon.Edit size={11}/> 가사 편집기
                </button>
              )}
              {hasReferenceLyrics && (
                <button className="pill-btn" onClick={() => start({ useReferenceLyrics: true })} disabled={running}>
                  <Icon.Refresh/> 가사 text 기준 다시 추출
                </button>
              )}
              <button className="pill-btn" onClick={() => importFileRef.current?.click()} title="저장된 JSON 파일 불러오기">
                <Icon.Upload size={11}/> JSON 불러오기
              </button>
              <button className="pill-btn" onClick={() => start()} disabled={running}>
                <Icon.Refresh/> 다시 추출
              </button>
            </div>
          </div>

          <div className="lyrics-panel" style={{marginTop: 16}}>
            <div className="lyrics-list" ref={lyricsListRef}>
              {displayCues.map((c, i) => {
                const isActive = i === activeCueIdx;
                const isPlaying = isActive && playing;
                return (
                  <div
                    key={i}
                    className={`lyric-row ${isActive ? "current" : ""}`}
                    onClick={() => handleCueClick(c, i)}
                    style={{cursor: audioUrl ? "pointer" : "default"}}
                  >
                    <span className="ts">{fmtTimeMs(c.start).slice(0,8)}</span>
                    <span className="dur">{(c.end - c.start).toFixed(1)}s</span>
                    <span className="text">{c.text}</span>
                    <span style={{color: isActive ? "var(--accent)" : "var(--ink-4)", display:"flex", alignItems:"center", justifyContent:"flex-end", width: 20}}>
                      {audioUrl && (isPlaying ? <Icon.Pause size={12}/> : <Icon.Play size={12}/>)}
                    </span>
                  </div>
                );
              })}
            </div>
            <div>
              <div className="side-card">
                <h3>lyric_timeline_json 미리보기</h3>
                <div style={{fontFamily:"var(--font-mono)", fontSize: 11, color:"var(--ink-2)", lineHeight: 1.8, maxHeight: 300, overflowY:"auto"}}>
                  {song.lyricTimeline ? (<>
                    <div style={{color:"var(--accent)"}}>lyric-timeline/v1</div>
                    <div style={{color:"var(--ink-4)"}}>language: {song.lyricTimeline.media?.language}</div>
                    <div style={{color:"var(--ink-4)"}}>duration: {((song.lyricTimeline.media?.duration_ms || 0) / 1000).toFixed(1)}s</div>
                    <div style={{height: 6}}/>
                    {displayCues.slice(0, 3).map((c, i) => (
                      <div key={i}>
                        <div style={{color:"var(--ink-4)"}}>{i+1}</div>
                        <div style={{color:"var(--accent-2)"}}>{fmtTimeMs(c.start)} --&gt; {fmtTimeMs(c.end)}</div>
                        <div>{c.text}</div>
                        <div style={{height: 4}}/>
                      </div>
                    ))}
                    <div style={{color:"var(--ink-4)"}}>… +{Math.max(0, displayCues.length - 3)}줄</div>
                  </>) : (<>
                    <div style={{color:"var(--accent)"}}>WEBVTT</div>
                    <div style={{color:"var(--ink-4)"}}>Kind: captions</div>
                    <div style={{height: 6}}/>
                    {displayCues.slice(0, 3).map((c, i) => (
                      <div key={i}>
                        <div style={{color:"var(--ink-4)"}}>{i+1}</div>
                        <div style={{color:"var(--accent-2)"}}>{fmtTimeMs(c.start)} --&gt; {fmtTimeMs(c.end)}</div>
                        <div>{c.text}</div>
                        <div style={{height: 4}}/>
                      </div>
                    ))}
                    <div style={{color:"var(--ink-4)"}}>… +{Math.max(0, displayCues.length - 3)}줄</div>
                  </>)}
                </div>
              </div>
              <div className="side-card" style={{marginTop: 12}}>
                <h3>통계</h3>
                <div className="stat-row"><span className="k">총 줄</span><span className="v">{displayCues.length}</span></div>
                <div className="stat-row"><span className="k">평균 신뢰도</span><span className="v">0.93</span></div>
                <div className="stat-row"><span className="k">언어 감지</span><span className="v">ko</span></div>
                <div className="stat-row"><span className="k">총 길이</span><span className="v">{displayCues.length ? fmtTime(displayCues[displayCues.length-1].end) : '—'}</span></div>
                {song.lyricTimeline && <div className="stat-row"><span className="k">포맷</span><span className="v" style={{color:"var(--accent)"}}>lyric_timeline_json</span></div>}
              </div>
            </div>
          </div>
        </>
      )}
    </div>
  );
}

// ─── Step 3: Scenes ────────────────────────────────────
function getSceneGlobal(song) {
  const imageUrl = typeof song?.sceneGlobal?.imageUrl === "string" ? song.sceneGlobal.imageUrl : "";
  return {
    promptKo: song?.sceneGlobal?.promptKo || "",
    promptEn: song?.sceneGlobal?.promptEn || "",
    referenceImages: Array.isArray(song?.sceneGlobal?.referenceImages)
      ? song.sceneGlobal.referenceImages
      : [],
    imageMode: song?.sceneGlobal?.imageMode === "global" ? "global" : "per-scene",
    imageSource: ["ai", "upload"].includes(song?.sceneGlobal?.imageSource) ? song.sceneGlobal.imageSource : "ai",
    imageUrl,
    imageFilename: song?.sceneGlobal?.imageFilename || "",
    imageSavedAt: song?.sceneGlobal?.imageSavedAt || "",
    imageStatus: imageUrl ? "done" : (["loading", "error"].includes(song?.sceneGlobal?.imageStatus) ? song.sceneGlobal.imageStatus : "idle"),
    imageError: song?.sceneGlobal?.imageError || "",
    imagePromptEn: song?.sceneGlobal?.imagePromptEn || "",
    imageSeed: Number.isFinite(song?.sceneGlobal?.imageSeed) ? song.sceneGlobal.imageSeed : Math.floor(Math.random() * 1000),
    updatedAt: song?.sceneGlobal?.updatedAt || "",
  };
}

const SCENE_IMAGE_OPTIONS = [
  {
    id: "per-scene-ai",
    imageMode: "per-scene",
    imageSource: "ai",
    title: "문장별 AI 생성",
    description: "현재처럼 각 sentence를 분석해 장면마다 AI 이미지를 만듭니다.",
  },
  {
    id: "per-scene-upload",
    imageMode: "per-scene",
    imageSource: "upload",
    title: "문장별 업로드",
    description: "장면은 sentence별로 유지하고, 필요한 카드마다 준비한 이미지를 업로드합니다.",
  },
  {
    id: "global-ai",
    imageMode: "global",
    imageSource: "ai",
    title: "전체 장면 AI 1장",
    description: "전체 가사를 하나의 대표 이미지로 해석해 모든 장면 배경으로 사용합니다.",
  },
  {
    id: "global-upload",
    imageMode: "global",
    imageSource: "upload",
    title: "전체 장면 업로드 1장",
    description: "사용자가 올린 한 장의 이미지를 전체 노래 배경으로 사용합니다.",
  },
];

function getSceneImageOption(sceneGlobal) {
  const mode = sceneGlobal?.imageMode === "global" ? "global" : "per-scene";
  const source = ["ai", "upload"].includes(sceneGlobal?.imageSource) ? sceneGlobal.imageSource : "ai";
  return `${mode}-${source}`;
}

function getSongLyricsText(song) {
  const timelineLines = [];
  const walk = (node) => {
    if (!node) return;
    if (node.type === "line" && String(node.text || "").trim()) {
      timelineLines.push(String(node.text).trim());
      return;
    }
    (node.children || []).forEach(walk);
  };
  if (song?.lyricTimeline?.root) walk(song.lyricTimeline.root);
  const cueLines = (song?.cues || []).map((cue) => String(cue?.text || "").trim()).filter(Boolean);
  return (timelineLines.length ? timelineLines : cueLines).join("\n");
}

function buildGoogleSceneImagePrompt({ sceneText, lyrics }) {
  return [
    "아래 노래의 다음 장면에 사용할 이미지를 만들어 보자.",
    "<장면>",
    String(sceneText || "").trim(),
    "</장면>",
    "<전체 노래>",
    String(lyrics || "").trim(),
    "</전체 노래>",
  ].join("\n");
}

function formatCommonImageStamp(date = new Date()) {
  const pad = (value, length = 2) => String(value).padStart(length, "0");
  return (
    `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}` +
    `-${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
  );
}

function sanitizeCommonImageStem(value) {
  return String(value || "song")
    .trim()
    .replace(/[^\w\u3131-\uD79D\-]+/g, "_")
    .replace(/^_+|_+$/g, "") || "song";
}

function buildCommonImageName(song, file, index, baseStamp) {
  const originalExt = String(file?.name || "").match(/\.[^.]+$/)?.[0] || "";
  const mimeExt = String(file?.type || "").includes("jpeg")
    ? ".jpg"
    : String(file?.type || "").includes("png")
      ? ".png"
      : String(file?.type || "").includes("webp")
        ? ".webp"
        : "";
  const ext = (originalExt || mimeExt || ".png").toLowerCase();
  const suffix = index > 0 ? `-${index + 1}` : "";
  return `${sanitizeCommonImageStem(song?.id)}-common-${baseStamp}${suffix}${ext}`;
}

function ensureUniqueCommonImageName(baseName, usedNames) {
  if (!usedNames.has(baseName)) {
    usedNames.add(baseName);
    return baseName;
  }
  const ext = baseName.match(/\.[^.]+$/)?.[0] || "";
  const stem = ext ? baseName.slice(0, -ext.length) : baseName;
  let counter = 2;
  let candidate = `${stem}-${counter}${ext}`;
  while (usedNames.has(candidate)) {
    counter += 1;
    candidate = `${stem}-${counter}${ext}`;
  }
  usedNames.add(candidate);
  return candidate;
}

function SceneGlobalSettingsModal({ song, album, onClose, onSave }) {
  const { Icon, SongfilmAIBroker, SongfilmSongStorage } = window;
  const fileInputRef = useRef();
  const globalImageInputRef = useRef();
  const initialGlobal = getSceneGlobal(song);
  const [promptKo, setPromptKo] = useState(initialGlobal.promptKo);
  const [promptEn, setPromptEn] = useState(initialGlobal.promptEn);
  const [referenceImages, setReferenceImages] = useState(initialGlobal.referenceImages);
  const [imageMode, setImageMode] = useState(initialGlobal.imageMode);
  const [imageSource, setImageSource] = useState(initialGlobal.imageSource);
  const [imageUrl, setImageUrl] = useState(initialGlobal.imageUrl);
  const [imageFilename, setImageFilename] = useState(initialGlobal.imageFilename);
  const [imageSavedAt, setImageSavedAt] = useState(initialGlobal.imageSavedAt);
  const [imageStatus, setImageStatus] = useState(initialGlobal.imageStatus);
  const [imageError, setImageError] = useState(initialGlobal.imageError);
  const [imagePromptEn, setImagePromptEn] = useState(initialGlobal.imagePromptEn);
  const [imageSeed, setImageSeed] = useState(initialGlobal.imageSeed);
  const [translating, setTranslating] = useState(false);
  const [uploading, setUploading] = useState(false);
  const [globalImageBusy, setGlobalImageBusy] = useState(false);
  const [showGuide, setShowGuide] = useState(true);
  const [error, setError] = useState("");

  const updateGlobal = (patch) => {
    const next = {
      promptKo,
      promptEn,
      referenceImages,
      imageMode,
      imageSource,
      imageUrl,
      imageFilename,
      imageSavedAt,
      imageStatus,
      imageError,
      imagePromptEn,
      imageSeed,
      ...patch,
      updatedAt: new Date().toISOString(),
    };
    onSave(next);
  };

  const applyGlobalImagePatch = (patch) => {
    if (Object.prototype.hasOwnProperty.call(patch, "imageMode")) setImageMode(patch.imageMode);
    if (Object.prototype.hasOwnProperty.call(patch, "imageSource")) setImageSource(patch.imageSource);
    if (Object.prototype.hasOwnProperty.call(patch, "imageUrl")) setImageUrl(patch.imageUrl);
    if (Object.prototype.hasOwnProperty.call(patch, "imageFilename")) setImageFilename(patch.imageFilename);
    if (Object.prototype.hasOwnProperty.call(patch, "imageSavedAt")) setImageSavedAt(patch.imageSavedAt);
    if (Object.prototype.hasOwnProperty.call(patch, "imageStatus")) setImageStatus(patch.imageStatus);
    if (Object.prototype.hasOwnProperty.call(patch, "imageError")) setImageError(patch.imageError);
    if (Object.prototype.hasOwnProperty.call(patch, "imagePromptEn")) setImagePromptEn(patch.imagePromptEn);
    if (Object.prototype.hasOwnProperty.call(patch, "imageSeed")) setImageSeed(patch.imageSeed);
    updateGlobal(patch);
  };

  const selectImageOption = (option) => {
    setImageMode(option.imageMode);
    setImageSource(option.imageSource);
    updateGlobal({
      imageMode: option.imageMode,
      imageSource: option.imageSource,
      imageError: "",
    });
  };

  const translatePrompt = async () => {
    if (!promptKo.trim()) {
      window.toast("먼저 한글 공통 프롬프트를 입력해 주세요.");
      return;
    }
    if (!SongfilmAIBroker?.translatePromptToEnglish) {
      window.toast("AI broker 번역 기능이 로드되지 않았습니다.");
      return;
    }
    setTranslating(true);
    setError("");
    try {
      const translated = await SongfilmAIBroker.translatePromptToEnglish({ text: promptKo });
      setPromptEn(translated);
      window.toast("공통 프롬프트를 영어로 번역했습니다.");
    } catch (err) {
      setError(err.message || String(err));
      window.toast(`번역 실패: ${err.message || err}`);
    } finally {
      setTranslating(false);
    }
  };

  const handleImageUpload = async (event) => {
    const files = Array.from(event.target.files || []).filter((file) => file.type?.startsWith("image/"));
    event.target.value = "";
    if (!files.length) return;
    if (!SongfilmAIBroker?.saveReferenceImage) {
      window.toast("reference image 저장 기능이 로드되지 않았습니다.");
      return;
    }

    setUploading(true);
    setError("");
    const storyId = SongfilmSongStorage?.buildStoryId(song) || song.storyId || song.id;
    const baseStamp = formatCommonImageStamp();
    const usedNames = new Set(referenceImages.map((image) => image.name).filter(Boolean));
    const uploaded = [];
    try {
      for (let index = 0; index < files.length; index += 1) {
        const file = files[index];
        const name = ensureUniqueCommonImageName(
          buildCommonImageName(song, file, index, baseStamp),
          usedNames
        );
        const id = name.replace(/\.[^.]+$/, "");
        const saved = await SongfilmAIBroker.saveReferenceImage({
          file,
          storyId,
          chapterNumber: 0,
        });
        uploaded.push({
          id,
          name,
          url: window.SongfilmApiConfig?.getMediaFilename?.(saved.filename || saved.url) || saved.filename || saved.url,
          filename: saved.filename,
          originalName: file.name,
          mimeType: saved.mimeType || file.type || "image/png",
          size: saved.size || file.size || 0,
          role: "main_character",
          createdAt: new Date().toISOString(),
        });
      }
      const nextImages = [...referenceImages, ...uploaded];
      setReferenceImages(nextImages);
      updateGlobal({ referenceImages: nextImages });
      window.toast(`공통 reference 이미지 ${uploaded.length}장을 저장했습니다.`);
    } catch (err) {
      setError(err.message || String(err));
      window.toast(`이미지 저장 실패: ${err.message || err}`);
    } finally {
      setUploading(false);
    }
  };

  const handleGlobalImageUpload = async (event) => {
    const file = Array.from(event.target.files || []).find((item) => item.type?.startsWith("image/"));
    event.target.value = "";
    if (!file) return;
    if (!SongfilmAIBroker?.saveImageFile) {
      window.toast("이미지 업로드 저장 기능이 로드되지 않았습니다.");
      return;
    }

    setGlobalImageBusy(true);
    setError("");
    applyGlobalImagePatch({
      imageMode: "global",
      imageSource: "upload",
      imageStatus: "loading",
      imageError: "",
    });
    try {
      const storyId = SongfilmSongStorage?.buildStoryId(song) || song.storyId || song.id;
      const saved = await SongfilmAIBroker.saveImageFile({
        file,
        storyId,
        chapterNumber: 0,
      });
      applyGlobalImagePatch({
        imageMode: "global",
        imageSource: "upload",
        imageUrl: saved.filename || window.SongfilmApiConfig?.getMediaFilename?.(saved.url) || saved.url,
        imageFilename: saved.filename || "",
        imageSavedAt: new Date().toISOString(),
        imageStatus: "done",
        imageError: "",
      });
      window.toast("전체 장면 이미지를 업로드했습니다.");
    } catch (err) {
      const message = err.message || String(err);
      setError(message);
      applyGlobalImagePatch({ imageStatus: "error", imageError: message });
      window.toast(`전체 이미지 업로드 실패: ${message}`);
    } finally {
      setGlobalImageBusy(false);
    }
  };

  const generateGlobalAiImage = async () => {
    if (!SongfilmAIBroker?.generateWholeSongImagePrompt || !SongfilmAIBroker?.generateSceneImage) {
      window.toast("AI 이미지 생성 기능이 로드되지 않았습니다.");
      return;
    }
    const lyrics = getSongLyricsText(song);
    if (!lyrics.trim()) {
      window.toast("전체 이미지를 만들 가사 데이터가 없습니다.");
      return;
    }

    setGlobalImageBusy(true);
    setError("");
    applyGlobalImagePatch({
      imageMode: "global",
      imageSource: "ai",
      imageStatus: "loading",
      imageError: "",
    });

    let generated = null;
    try {
      const currentGlobal = {
        ...getSceneGlobal(song),
        promptKo,
        promptEn,
        referenceImages,
        imageMode: "global",
        imageSource: "ai",
        imageSeed,
      };
      const prompt = await SongfilmAIBroker.generateWholeSongImagePrompt({
        songTitle: song.title || "Untitled",
        artist: album?.artist || "",
        lyrics,
        sceneGlobal: currentGlobal,
      });
      setImagePromptEn(prompt);
      generated = await SongfilmAIBroker.generateSceneImage({
        promptEn: prompt,
        seed: imageSeed,
        sceneGlobal: currentGlobal,
      });
      const storyId = SongfilmSongStorage?.buildStoryId(song) || song.storyId || song.id;
      const saved = await SongfilmAIBroker.saveGeneratedImage({
        imageBlob: generated.blob,
        storyId,
        chapterNumber: 0,
      });
      SongfilmAIBroker.revokeObjectUrl(generated.objectUrl);
      applyGlobalImagePatch({
        imageMode: "global",
        imageSource: "ai",
        imageUrl: saved.filename || window.SongfilmApiConfig?.getMediaFilename?.(saved.url) || saved.url,
        imageFilename: saved.filename || "",
        imageSavedAt: new Date().toISOString(),
        imageStatus: "done",
        imageError: "",
        imagePromptEn: prompt,
        imageSeed: Number.isFinite(generated.seed) ? generated.seed : imageSeed,
      });
      window.toast("전체 장면 AI 이미지를 생성했습니다.");
    } catch (err) {
      if (generated?.objectUrl) SongfilmAIBroker.revokeObjectUrl(generated.objectUrl);
      const message = err.message || String(err);
      setError(message);
      applyGlobalImagePatch({ imageStatus: "error", imageError: message });
      window.toast(`전체 이미지 생성 실패: ${message}`);
    } finally {
      setGlobalImageBusy(false);
    }
  };

  const removeImage = (imageId) => {
    const nextImages = referenceImages.filter((image) => image.id !== imageId);
    setReferenceImages(nextImages);
    updateGlobal({ referenceImages: nextImages });
  };

  const saveAndClose = () => {
    updateGlobal({
      promptKo,
      promptEn,
      referenceImages,
      imageMode,
      imageSource,
      imageUrl,
      imageFilename,
      imageSavedAt,
      imageStatus,
      imageError,
      imagePromptEn,
      imageSeed,
    });
    window.toast("전체 장면 공통 설정을 저장했습니다.");
    onClose();
  };

  return (
    <div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="scene-common-modal" onClick={(e) => e.stopPropagation()}>
        <div className="sdm-header">
          <span className="sdm-scene-num">공통 설정</span>
          <span className="sdm-timestamp">전체 장면의 주인공, 세계관, 톤을 고정합니다</span>
          <button className="sdm-close" onClick={onClose}>✕</button>
        </div>
        <div className="sdm-scroll">
          <div className="sdm-fields">
            {error && <div className="scene-common-error">{error}</div>}

            <div>
              <div className="scene-common-row">
                <label className="sdm-field-label" style={{ marginBottom: 0 }}>
                  이미지 사용 방식
                  <span className="sdm-label-badge">{SCENE_IMAGE_OPTIONS.find((option) => option.id === getSceneImageOption({ imageMode, imageSource }))?.title || "문장별 AI 생성"}</span>
                </label>
              </div>
              <div className="scene-option-grid">
                {SCENE_IMAGE_OPTIONS.map((option) => {
                  const active = option.id === getSceneImageOption({ imageMode, imageSource });
                  return (
                    <button
                      key={option.id}
                      className={`scene-option-card${active ? " active" : ""}`}
                      onClick={() => selectImageOption(option)}
                      disabled={globalImageBusy || uploading || translating}
                    >
                      <span>{option.title}</span>
                      <small>{option.description}</small>
                    </button>
                  );
                })}
              </div>
            </div>

            <div className="scene-global-image-panel">
              <div className="scene-common-row">
                <label className="sdm-field-label" style={{ marginBottom: 0 }}>
                  전체 장면 대표 이미지
                  <span className="sdm-label-badge">{imageUrl ? (imageSource === "upload" ? "upload" : "AI") : "없음"}</span>
                </label>
                <input
                  ref={globalImageInputRef}
                  type="file"
                  accept="image/*"
                  style={{ display: "none" }}
                  onChange={handleGlobalImageUpload}
                />
                <div style={{display:"flex", gap: 8, flexWrap:"wrap"}}>
                  <button className="pill-btn" onClick={generateGlobalAiImage} disabled={globalImageBusy || uploading || translating}>
                    <Icon.Sparkle/> {globalImageBusy && imageSource === "ai" ? "생성 중" : "AI로 1장 생성"}
                  </button>
                  <button className="pill-btn" onClick={() => globalImageInputRef.current?.click()} disabled={globalImageBusy || uploading || translating}>
                    <Icon.Upload size={11}/> {globalImageBusy && imageSource === "upload" ? "업로드 중" : "1장 업로드"}
                  </button>
                </div>
              </div>
              <div className="scene-global-image-preview">
                {imageUrl ? (
                  <>
                    <img src={window.SongfilmApiConfig?.buildMediaUrl ? window.SongfilmApiConfig.buildMediaUrl("image", imageUrl) : imageUrl} alt="전체 장면 대표 이미지" />
                    <div className="scene-global-image-meta">
                      <strong>{imageFilename || "전체 장면 이미지"}</strong>
                      <span>{imageMode === "global" ? "현재 렌더 배경으로 사용 중" : "저장됨 · 전체 이미지 모드 선택 시 사용"}</span>
                    </div>
                  </>
                ) : (
                  <div className="scene-common-empty">전체 장면에 사용할 대표 이미지가 없습니다. AI로 생성하거나 1장을 업로드하세요.</div>
                )}
                {imageStatus === "loading" && <div className="sdm-image-loading">전체 이미지 준비 중…</div>}
              </div>
              {imagePromptEn && (
                <textarea
                  className="sdm-textarea mono"
                  value={imagePromptEn}
                  onChange={(event) => {
                    setImagePromptEn(event.target.value);
                    updateGlobal({ imagePromptEn: event.target.value });
                  }}
                  rows={3}
                  placeholder="전체 장면 AI 이미지 프롬프트"
                />
              )}
              {imageError && <div className="scene-common-error">{imageError}</div>}
            </div>

            <div className="sdm-section">
              <button className="sdm-collapsible-btn" onClick={() => setShowGuide((value) => !value)}>
                <span className="sdm-field-label" style={{ marginBottom: 0 }}>
                  공통 프롬프트 가이드
                  <span className="sdm-label-badge">주인공 · 세계관 · 톤</span>
                </span>
                <span className="sdm-toggle-icon">{showGuide ? "▲" : "▼"}</span>
              </button>
              {showGuide && (
                <div className="scene-common-guide">
                  <div>모든 장면에 반복되어야 할 고정 요소만 적으세요. 주인공의 나이대, 인상, 헤어스타일, 의상, 소품, 전체 세계관, 색감, 조명, 카메라 톤이 좋습니다.</div>
                  <div className="mono">예: Nomad young man as the recurring protagonist, late 20s, wind-tousled black hair, simple outdoor jacket, carrying a worn backpack and a laptop. Keep every scene cinematic, realistic, emotionally quiet. No text, no subtitles, no watermark.</div>
                </div>
              )}
            </div>

            <div>
              <label className="sdm-field-label">공통 프롬프트 (한국어)</label>
              <textarea
                className="sdm-textarea"
                value={promptKo}
                onChange={(e) => setPromptKo(e.target.value)}
                rows={4}
                placeholder="예: Nomad 젊은 남자를 주인공으로 장면을 전체적으로 설계하자. 바다와 노트북, 자유로운 여행자의 분위기를 유지하자."
              />
            </div>

            <div>
              <div className="scene-common-row">
                <label className="sdm-field-label" style={{ marginBottom: 0 }}>
                  실제 사용 공통 프롬프트 (영문)
                  <span className="sdm-label-badge">장면 분석 + 이미지 생성에 사용</span>
                </label>
                <button className="pill-btn" onClick={translatePrompt} disabled={translating || uploading}>
                  <Icon.Sparkle/> {translating ? "번역 중" : "OLLAMA 번역"}
                </button>
              </div>
              <textarea
                className="sdm-textarea mono"
                value={promptEn}
                onChange={(e) => setPromptEn(e.target.value)}
                rows={5}
                placeholder="English global continuity prompt"
              />
            </div>

            <div>
              <div className="scene-common-row">
                <label className="sdm-field-label" style={{ marginBottom: 0 }}>
                  동일 주인공 Reference 이미지
                  <span className="sdm-label-badge">{referenceImages.length}장</span>
                </label>
                <input
                  ref={fileInputRef}
                  type="file"
                  accept="image/*"
                  multiple
                  style={{ display: "none" }}
                  onChange={handleImageUpload}
                />
                <button className="pill-btn" onClick={() => fileInputRef.current?.click()} disabled={uploading || translating}>
                  <Icon.Upload size={11}/> {uploading ? "저장 중" : "이미지 업로드"}
                </button>
              </div>
              <div className="scene-common-images">
                {referenceImages.length === 0 ? (
                  <div className="scene-common-empty">아직 reference 이미지가 없습니다.</div>
                ) : referenceImages.map((image) => (
                  <div key={image.id} className="scene-common-image-card">
                    <img src={window.SongfilmApiConfig?.buildMediaUrl ? window.SongfilmApiConfig.buildMediaUrl("image", image.url || image.filename) : image.url} alt={image.name || "reference"} />
                    <div className="scene-common-image-meta">
                      <div className="mono">{image.name || image.filename || image.id}</div>
                      <span>{image.originalName || image.mimeType || "reference image"}</span>
                    </div>
                    <button className="sdm-close" onClick={() => removeImage(image.id)} title="reference 이미지 제거">
                      <Icon.X size={12}/>
                    </button>
                  </div>
                ))}
              </div>
            </div>
          </div>
        </div>
        <div className="sdm-actions">
          <button className="pill-btn" onClick={onClose}>닫기</button>
          <button className="pill-btn primary" onClick={saveAndClose} disabled={uploading || translating}>
            <Icon.Check size={11}/> 저장
          </button>
        </div>
      </div>
    </div>
  );
}

function SceneDetailModal({ scene, sceneIndex, song, album, onClose, onSave, onRegenerate, onUploadImage, disabled }) {
  const { Icon, SceneImage, fmtTime, SongfilmAIBroker } = window;
  const sceneGlobal = getSceneGlobal(song);
  const sceneImageInputRef = useRef();
  const defaultGoogleImagePrompt = buildGoogleSceneImagePrompt({
    sceneText: scene.analysisText || scene.text,
    lyrics: getSongLyricsText(song),
  });
  const [draftPrompt, setDraftPrompt] = useState(scene.prompt || "");
  const [draftPromptEn, setDraftPromptEn] = useState(scene.promptEn || "");
  const [draftImageProvider, setDraftImageProvider] = useState(scene.imageProvider === "google" ? "google" : "");
  const [draftGoogleImagePrompt, setDraftGoogleImagePrompt] = useState(scene.googleImagePrompt || defaultGoogleImagePrompt);
  const [regenerating, setRegenerating] = useState(false);
  const [uploadingSceneImage, setUploadingSceneImage] = useState(false);
  const [showAnalysisPrompt, setShowAnalysisPrompt] = useState(false);

  const origPrompt = scene.prompt || "";
  const origPromptEn = scene.promptEn || "";
  const origImageProvider = scene.imageProvider === "google" ? "google" : "";
  const origGoogleImagePrompt = scene.googleImagePrompt || "";
  const useGoogleProvider = draftImageProvider === "google";
  const savedGoogleImagePrompt = useGoogleProvider ? draftGoogleImagePrompt : origGoogleImagePrompt;
  const isDirty = (
    draftPrompt !== origPrompt ||
    draftPromptEn !== origPromptEn ||
    draftImageProvider !== origImageProvider ||
    savedGoogleImagePrompt !== origGoogleImagePrompt
  );
  const isGlobalImageMode = sceneGlobal.imageMode === "global" && !!sceneGlobal.imageUrl;
  const previewScene = isGlobalImageMode
    ? { ...scene, imageUrl: sceneGlobal.imageUrl, imageSeed: sceneGlobal.imageSeed }
    : scene;
  const isImageBusy = scene.imageStatus === "loading" || regenerating || uploadingSceneImage;

  // 실제 Ollama에 전송된 전문 (이 장면 1건 기준으로 재현)
  const fullAnalysisPrompt = SongfilmAIBroker?.buildSceneAnalysisPrompt
    ? SongfilmAIBroker.buildSceneAnalysisPrompt({
        songTitle: song.title || "Untitled",
        artist: album?.artist || "",
        sceneGlobal,
        scenes: [{ ...scene, text: scene.analysisText || scene.text }],
      })
    : "";

  const handleSave = () => {
    onSave({
      prompt: draftPrompt,
      promptEn: draftPromptEn,
      imageProvider: draftImageProvider,
      googleImagePrompt: savedGoogleImagePrompt,
    });
  };

  const handleRegenerate = async () => {
    if ((!draftPromptEn && !useGoogleProvider) || isImageBusy) return;
    onSave({
      prompt: draftPrompt,
      promptEn: draftPromptEn,
      imageProvider: draftImageProvider,
      googleImagePrompt: savedGoogleImagePrompt,
    });
    setRegenerating(true);
    try {
      await onRegenerate(draftPromptEn, draftImageProvider, savedGoogleImagePrompt);
    } finally {
      setRegenerating(false);
    }
  };

  const handleSceneImageUpload = async (event) => {
    const file = Array.from(event.target.files || []).find((item) => item.type?.startsWith("image/"));
    event.target.value = "";
    if (!file || !onUploadImage || isImageBusy) return;
    setUploadingSceneImage(true);
    try {
      await onUploadImage(file);
    } finally {
      setUploadingSceneImage(false);
    }
  };

  return (
    <div className="modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="scene-detail-modal" onClick={(e) => e.stopPropagation()}>

        {/* ── 헤더 ── */}
        <div className="sdm-header">
          <span className="sdm-scene-num">#{sceneIndex + 1}</span>
          <span className="sdm-timestamp">{fmtTime(scene.start)} – {fmtTime(scene.end)}</span>
          <button className="sdm-close" onClick={onClose}>✕</button>
        </div>

        {/* ── 스크롤 영역 ── */}
        <div className="sdm-scroll">

          {/* 이미지 */}
          <div className="sdm-image-wrap">
            <SceneImage scene={previewScene} paletteName={song.paletteName || "midnight-violet"} />
            {isImageBusy && (
              <div className="sdm-image-loading">{uploadingSceneImage ? "이미지 업로드 중…" : "이미지 생성 중…"}</div>
            )}
            {isGlobalImageMode && <div className="scene-source-badge">전체 이미지 사용 중</div>}
          </div>

          {/* 가사 */}
          <div className="sdm-lyric">"{scene.text}"</div>

          <div className="sdm-fields">

            {/* 장면 제안 (한국어) — 수정 가능 */}
            <div>
              <label className="sdm-field-label">장면 제안 (한국어)</label>
              <textarea
                className="sdm-textarea"
                value={draftPrompt}
                onChange={(e) => setDraftPrompt(e.target.value)}
                rows={2}
                placeholder="AI가 제안한 장면 설명"
              />
            </div>

            {/* 이미지 생성 프롬프트 (영문) — 수정 가능, 이미지 API 전송용 */}
            <div>
              <label className="sdm-field-label">
                이미지 생성 프롬프트 (영문)
                <span className="sdm-label-badge">
                  → {useGoogleProvider ? "Google prompt 자동 생성" : `${sceneGlobal.referenceImages.length ? "/image/generate_with_ref_images" : "/image/generate"} 전송`}
                </span>
              </label>
              <textarea
                className="sdm-textarea mono"
                value={draftPromptEn}
                onChange={(e) => setDraftPromptEn(e.target.value)}
                rows={5}
                placeholder={useGoogleProvider ? "Google 사용 시 장면 가사와 전체 가사로 한국어 프롬프트를 자동 구성합니다" : "English image generation prompt"}
              />
            </div>

            <div className="sdm-section">
              <label className="sdm-field-label">
                이미지 생성 Provider
                <span className="sdm-label-badge">{useGoogleProvider ? "provider: google" : "기본"}</span>
              </label>
              <button
                className={`scene-option-card${useGoogleProvider ? " active" : ""}`}
                onClick={() => setDraftImageProvider(useGoogleProvider ? "" : "google")}
                disabled={isImageBusy || disabled}
                style={{ width: "100%", textAlign: "left" }}
              >
                <span>Google 이미지 생성 사용</span>
                <small>켜면 영어 프롬프트 대신 현재 장면 가사와 전체 노래를 담은 한국어 프롬프트를 보내고, 요청에 provider: "google"을 추가합니다.</small>
              </button>
            </div>

            {useGoogleProvider && (
              <div>
                <label className="sdm-field-label">
                  Google 이미지 생성 프롬프트
                  <span className="sdm-label-badge">수정 가능</span>
                </label>
                <textarea
                  className="sdm-textarea mono"
                  value={draftGoogleImagePrompt}
                  onChange={(e) => setDraftGoogleImagePrompt(e.target.value)}
                  rows={9}
                  placeholder={defaultGoogleImagePrompt}
                />
              </div>
            )}

            {/* Ollama 장면 분석 요청 전문 — 읽기 전용, 접기/펼치기 */}
            {fullAnalysisPrompt && (
              <div className="sdm-section">
                <button
                  className="sdm-collapsible-btn"
                  onClick={() => setShowAnalysisPrompt((v) => !v)}
                >
                  <span className="sdm-field-label" style={{ marginBottom: 0, cursor: "pointer" }}>
                    Ollama 장면 분석 요청 프롬프트
                    <span className="sdm-label-badge">→ /ai/generate 전송</span>
                  </span>
                  <span className="sdm-toggle-icon">{showAnalysisPrompt ? "▲" : "▼"}</span>
                </button>
                {showAnalysisPrompt && (
                  <textarea
                    className="sdm-textarea mono sdm-readonly"
                    value={fullAnalysisPrompt}
                    readOnly
                    rows={16}
                  />
                )}
              </div>
            )}

          </div>
        </div>

        {/* ── 액션 버튼 ── */}
        <div className="sdm-actions">
          <input
            ref={sceneImageInputRef}
            type="file"
            accept="image/*"
            style={{ display: "none" }}
            onChange={handleSceneImageUpload}
          />
          <button
            className="pill-btn"
            onClick={() => sceneImageInputRef.current?.click()}
            disabled={isImageBusy || disabled}
          >
            <Icon.Upload size={11}/> 장면 이미지 업로드
          </button>
          <button className="pill-btn" onClick={onClose}>닫기</button>
          {isDirty && (
            <button className="pill-btn" onClick={handleSave}>저장</button>
          )}
          <button
            className="pill-btn"
            style={{ background: "var(--accent)", color: "#fff" }}
            onClick={handleRegenerate}
            disabled={isImageBusy || (!draftPromptEn && !useGoogleProvider) || disabled}
          >
            {regenerating ? "생성 중…" : "이미지 재생성"}
          </button>
        </div>

      </div>
    </div>
  );
}

function ScenesStep({ album, song, updateSong }) {
  const { Icon, SceneImage, fmtTime, PALETTES, SongfilmAIBroker, SongfilmSongStorage } = window;
  const [generatingAll, setGeneratingAll] = useState(false);
  const [selectedSceneId, setSelectedSceneId] = useState(null);
  const [showGlobalSettings, setShowGlobalSettings] = useState(false);
  const [pipelineError, setPipelineError] = useState("");
  const [palette, setPalette] = useState(song.paletteName || "midnight-violet");
  const autoRunTokensRef = useRef(new Set());
  const pipelineRunningRef = useRef(false);
  const sceneGlobal = getSceneGlobal(song);

  useEffect(() => {
    setPalette(song.paletteName || "midnight-violet");
  }, [song.paletteName]);

  useEffect(() => {
    autoRunTokensRef.current = new Set();
  }, [song.id]);

  const changePalette = (name) => {
    setPalette(name);
    updateSong({ paletteName: name });
  };

  const saveSceneGlobal = (nextGlobal) => {
    updateSong({
      sceneGlobal: {
        ...getSceneGlobal(song),
        ...nextGlobal,
        referenceImages: Array.isArray(nextGlobal.referenceImages) ? nextGlobal.referenceImages : [],
        updatedAt: nextGlobal.updatedAt || new Date().toISOString(),
      },
    });
  };

  const toggleSceneExcluded = (sceneId) => {
    updateSong((currentSong) => ({
      scenes: currentSong.scenes.map((scene) =>
        scene.id === sceneId ? { ...scene, excluded: !scene.excluded } : scene
      ),
    }));
  };

  const buildAnalysisScenes = (includedTargets) => {
    const allScenes = song.scenes;
    return includedTargets.map((scene) => {
      const idx = allScenes.findIndex((s) => s.id === scene.id);
      const extraTexts = [];
      for (let j = idx + 1; j < allScenes.length && allScenes[j].excluded; j++) {
        extraTexts.push(allScenes[j].text);
      }
      return extraTexts.length
        ? { ...scene, text: [scene.text, ...extraTexts].join('\n') }
        : scene;
    });
  };

  const applyScenePatch = (sceneId, patch) => {
    updateSong((currentSong) => ({
      scenes: currentSong.scenes.map((scene) => {
        if (scene.id !== sceneId) return scene;
        const nextPatch = typeof patch === "function" ? patch(scene) : patch;
        return { ...scene, ...nextPatch };
      }),
    }));
  };

  const applySceneBatchPatch = (sceneIds, patchFactory) => {
    const sceneIdSet = new Set(sceneIds);
    updateSong((currentSong) => ({
      scenes: currentSong.scenes.map((scene) => {
        if (!sceneIdSet.has(scene.id)) return scene;
        const nextPatch = typeof patchFactory === "function" ? patchFactory(scene) : patchFactory;
        return nextPatch ? { ...scene, ...nextPatch } : scene;
      }),
    }));
  };

  const revokeSceneImage = (scene) => {
    if (SongfilmAIBroker && scene?.imageUrl) {
      SongfilmAIBroker.revokeObjectUrl(scene.imageUrl);
    }
  };

  const resolvePromptText = (scene) =>
    scene.prompt || scene.legacyPrompt || "AI가 이 문장을 장면으로 분석하는 중입니다.";

  const shorten = (text, maxLength = 140) => {
    if (!text) return "";
    return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
  };

  const getSceneIndex = (scene) => {
    const byId = song.scenes.findIndex((item) => item.id === scene.id);
    if (byId >= 0) return byId;
    const bySentenceId = song.scenes.findIndex((item) => item.sentenceId && item.sentenceId === scene.sentenceId);
    return bySentenceId >= 0 ? bySentenceId : 0;
  };

  const generateImageForScene = async (scene, promptEn, options = {}) => {
    const imageProvider = options.imageProvider ?? (scene.imageProvider === "google" ? "google" : "");
    const useGoogleProvider = imageProvider === "google";
    const googleImagePrompt = String(options.googleImagePrompt ?? scene.googleImagePrompt ?? "").trim();
    if (!promptEn && !useGoogleProvider) {
      const missingPromptError = new Error("영문 이미지 프롬프트가 없습니다");
      applyScenePatch(scene.id, {
        imageStatus: "error",
        imageError: missingPromptError.message,
      });
      throw missingPromptError;
    }

    console.log('[ScenesStep] 이미지 생성 시작', {
      sceneId: scene.id,
      provider: useGoogleProvider ? "google" : "default",
      promptEn: promptEn?.length > 80 ? promptEn.slice(0, 80) + '…' : promptEn,
    });

    revokeSceneImage(scene);
    applyScenePatch(scene.id, {
      imageUrl: "",
      imageStatus: "loading",
      imageError: "",
    });

    let generated = null;
    try {
      const storyId = SongfilmSongStorage?.buildStoryId(song) || song.storyId || song.id;
      const googlePrompt = useGoogleProvider
        ? googleImagePrompt || buildGoogleSceneImagePrompt({
            sceneText: scene.analysisText || scene.text,
            lyrics: getSongLyricsText(song),
          })
        : "";
      generated = await SongfilmAIBroker.generateSceneImage({
        promptEn,
        seed: scene.imageSeed,
        sceneGlobal: getSceneGlobal(song),
        provider: imageProvider,
        promptOverride: googlePrompt,
      });
      const savedImage = await SongfilmAIBroker.saveGeneratedImage({
        imageBlob: generated.blob,
        storyId,
        chapterNumber: SongfilmSongStorage?.buildChapterNumber(scene, getSceneIndex(scene)) || getSceneIndex(scene) + 1,
      });
      SongfilmAIBroker.revokeObjectUrl(generated.objectUrl);
      applyScenePatch(scene.id, {
        imageUrl: savedImage.filename || window.SongfilmApiConfig?.getMediaFilename?.(savedImage.url) || savedImage.url,
        imageFilename: savedImage.filename || "",
        imageSavedAt: new Date().toISOString(),
        imageStatus: "done",
        imageError: "",
        imageSource: "ai",
        imageProvider,
        googleImagePrompt: useGoogleProvider ? googlePrompt : (scene.googleImagePrompt || ""),
        imageSeed: Number.isFinite(generated.seed) ? generated.seed : scene.imageSeed,
      });
      console.log('[ScenesStep] 이미지 생성 완료', { sceneId: scene.id, url: savedImage.url });
    } catch (error) {
      if (generated?.objectUrl) {
        SongfilmAIBroker.revokeObjectUrl(generated.objectUrl);
      }
      applyScenePatch(scene.id, {
        imageStatus: "error",
        imageError: error.message || String(error),
      });
      console.error('[ScenesStep] 이미지 생성 실패', { sceneId: scene.id, error: error.message });
      throw error;
    }
  };

  const uploadImageForScene = async (scene, file) => {
    if (!file || !SongfilmAIBroker?.saveImageFile) {
      window.toast("이미지 업로드 저장 기능이 로드되지 않았습니다.");
      return;
    }
    revokeSceneImage(scene);
    applyScenePatch(scene.id, {
      imageUrl: "",
      imageStatus: "loading",
      imageError: "",
    });
    try {
      const storyId = SongfilmSongStorage?.buildStoryId(song) || song.storyId || song.id;
      const savedImage = await SongfilmAIBroker.saveImageFile({
        file,
        storyId,
        chapterNumber: SongfilmSongStorage?.buildChapterNumber(scene, getSceneIndex(scene)) || getSceneIndex(scene) + 1,
      });
      applyScenePatch(scene.id, {
        imageUrl: savedImage.filename || window.SongfilmApiConfig?.getMediaFilename?.(savedImage.url) || savedImage.url,
        imageFilename: savedImage.filename || "",
        imageSavedAt: new Date().toISOString(),
        imageStatus: "done",
        imageError: "",
        imageSource: "upload",
      });
      window.toast(`장면 #${getSceneIndex(scene) + 1} 이미지를 업로드했습니다.`);
    } catch (error) {
      applyScenePatch(scene.id, {
        imageStatus: "error",
        imageError: error.message || String(error),
      });
      window.toast(`장면 이미지 업로드 실패: ${error.message || error}`);
      throw error;
    }
  };

  const runScenePipeline = async ({ sceneIds = null, refreshPrompts = false, trigger = "manual" } = {}) => {
    if (!SongfilmAIBroker) {
      window.toast("AI broker가 로드되지 않았습니다");
      return;
    }

    if (pipelineRunningRef.current) {
      console.warn('[ScenesStep] runScenePipeline 중복 호출 차단', { trigger, sceneIds });
      return;
    }

    const targets = song.scenes.filter((scene) => !sceneIds || sceneIds.includes(scene.id));
    if (!targets.length) return;

    // 제외된 장면을 분석/생성 대상에서 제외
    const includedTargets = targets.filter((scene) => !scene.excluded);
    if (!includedTargets.length) {
      if (trigger !== "auto") window.toast("포함된 장면이 없습니다. 카드 하단의 토글로 분석할 장면을 선택해 주세요.");
      return;
    }

    const multiSceneRun = sceneIds == null || sceneIds.length > 1;
    const includedTargetIds = includedTargets.map((scene) => scene.id);
    let suggestionMap = new Map();
    let firstError = null;

    pipelineRunningRef.current = true;
    console.log('[ScenesStep] runScenePipeline 시작', {
      trigger, refreshPrompts,
      total: targets.length,
      included: includedTargets.length,
      excluded: targets.length - includedTargets.length,
    });

    setPipelineError("");
    if (multiSceneRun) setGeneratingAll(true);

    try {
      if (refreshPrompts || includedTargets.some((scene) => !scene.promptEn)) {
        applySceneBatchPatch(includedTargetIds, {
          analysisStatus: "loading",
          analysisError: "",
        });

        // 제외된 장면 텍스트를 바로 앞 포함 장면에 병합하여 AI 분석 컨텍스트 확장
        const analysisScenes = buildAnalysisScenes(includedTargets);
        const analysisTextMap = new Map(analysisScenes.map((s) => [s.id, s.text]));
        console.log('[ScenesStep] 장면 분석 요청', {
          sceneCount: analysisScenes.length,
          extendedScenes: analysisScenes.filter((s, i) => s.text !== includedTargets[i].text).length,
        });

        try {
          const suggestions = await SongfilmAIBroker.generateSceneSuggestions({
            songTitle: song.title || "Untitled",
            artist: album?.artist || "",
            scenes: analysisScenes,
            sceneGlobal: getSceneGlobal(song),
          });

          suggestionMap = new Map(suggestions.map((item) => [item.id, item]));
          applySceneBatchPatch(includedTargetIds, (scene) => {
            const suggestion = suggestionMap.get(scene.id);
            if (!suggestion) return null;
            return {
              prompt: suggestion.prompt,
              promptEn: suggestion.promptEn,
              promptSource: "ai",
              analysisStatus: "done",
              analysisError: "",
              analysisText: analysisTextMap.get(scene.id) || scene.text,
            };
          });
          console.log('[ScenesStep] 장면 분석 완료', { sceneCount: suggestions.length });
        } catch (error) {
          firstError = error;
          applySceneBatchPatch(includedTargetIds, (scene) => ({
            analysisStatus: scene.promptEn ? "done" : "error",
            analysisError: error.message || String(error),
          }));
          console.error('[ScenesStep] 장면 분석 실패', { error: error.message });
        }
      }

      // 이미지 생성은 순차적으로 처리 (서버 과부하 방지)
      const imageTargets = includedTargets
        .map((scene) => {
          const suggestion = suggestionMap.get(scene.id);
          return {
            ...scene,
            promptEn: suggestion?.promptEn || scene.promptEn,
          };
        })
        .filter((scene) => !!scene.promptEn || scene.imageProvider === "google");

      console.log('[ScenesStep] 이미지 생성 시작', { total: imageTargets.length });
      for (const scene of imageTargets) {
        try {
          await generateImageForScene(scene, scene.promptEn);
        } catch (error) {
          if (!firstError) firstError = error;
        }
      }

      if (firstError) {
        setPipelineError(firstError.message || String(firstError));
        if (trigger !== "auto") {
          window.toast(`AI 장면 생성 오류: ${firstError.message || firstError}`);
        }
        return;
      }

      if (trigger !== "auto") {
        window.toast(multiSceneRun ? "전체 장면 생성 완료" : "장면 재생성 완료");
      }
    } finally {
      pipelineRunningRef.current = false;
      if (multiSceneRun) setGeneratingAll(false);
      console.log('[ScenesStep] runScenePipeline 완료', { trigger, error: firstError?.message ?? null });
    }
  };

  const regenerateOne = async (sceneId) => {
    await runScenePipeline({
      sceneIds: [sceneId],
      refreshPrompts: true,
      trigger: "manual",
    });
  };

  const regenerateAll = async () => {
    await runScenePipeline({
      sceneIds: null,
      refreshPrompts: true,
      trigger: "manual",
    });
  };

  const regenerateFromModal = async (sceneId, promptEn, imageProvider = "", googleImagePrompt = "") => {
    const freshScene = song.scenes.find((s) => s.id === sceneId);
    if (!freshScene) return;
    await generateImageForScene(freshScene, promptEn, { imageProvider, googleImagePrompt });
  };

  const pickAndUploadSceneImage = (scene) => {
    const input = document.createElement("input");
    input.type = "file";
    input.accept = "image/*";
    input.onchange = async () => {
      const file = Array.from(input.files || []).find((item) => item.type?.startsWith("image/"));
      if (file) {
        try {
          await uploadImageForScene(scene, file);
        } catch {
          // uploadImageForScene already surfaced the error to the user.
        }
      }
      input.remove();
    };
    input.click();
  };

  const refreshScenesFromLyrics = () => {
    if (!song.lyricTimeline) { window.toast("가사 타임라인 데이터가 없습니다."); return; }
    const newCues = window.SongfilmAPI.flattenTimelineToCues(song.lyricTimeline);
    const existingBySentenceId = new Map(song.scenes.map(s => [s.sentenceId, s]));
    const newScenes = window.buildScenes(newCues).map(newScene => {
      const ex = existingBySentenceId.get(newScene.sentenceId);
      if (!ex) return newScene;
      return {
        ...newScene,
        chapterNumber: ex.chapterNumber || newScene.chapterNumber,
        prompt: ex.prompt, legacyPrompt: ex.legacyPrompt,
        promptEn: ex.promptEn, promptSource: ex.promptSource,
        analysisStatus: ex.analysisStatus, analysisError: ex.analysisError,
        imageStatus: ex.imageStatus, imageError: ex.imageError,
        imageUrl: ex.imageUrl, imageFilename: ex.imageFilename,
        imageSavedAt: ex.imageSavedAt, imageSeed: ex.imageSeed,
        imageSource: ex.imageSource, imageProvider: ex.imageProvider,
        googleImagePrompt: ex.googleImagePrompt,
        excluded: ex.excluded,
      };
    });
    updateSong({ cues: newCues, scenes: newScenes });
    window.toast(`가사 기준으로 장면을 새로고침했습니다 · ${newScenes.length}개`);
  };

  // 자동 분석은 제거 — 사용자가 직접 '선택 장면 재생성' 또는 '재생성' 버튼으로 시작해야 합니다.

  if (!song.scenes.length) {
    return (
      <div className="scene-board">
        <div className="scene-board-header">
          <div>
            <h2>장면 설계</h2>
            <p>먼저 자막을 추출하면 문장 단위로 장면을 분석하고 이미지를 생성할 수 있습니다.</p>
          </div>
          {song.lyricTimeline && (
            <button className="pill-btn" onClick={refreshScenesFromLyrics} title="가사 편집기에서 수정한 내용을 장면 목록에 반영합니다">
              <Icon.Refresh/> 가사 반영 새로고침
            </button>
          )}
          <button className={`pill-btn ${sceneGlobal.promptEn || sceneGlobal.referenceImages.length ? "accent" : ""}`} onClick={() => setShowGlobalSettings(true)}>
            <Icon.Cog/> 공통 설정
          </button>
        </div>
        {showGlobalSettings && (
          <SceneGlobalSettingsModal
            song={song}
            album={album}
            onClose={() => setShowGlobalSettings(false)}
            onSave={saveSceneGlobal}
          />
        )}
      </div>
    );
  }

  const anySceneLoading = song.scenes.some(
    (scene) => scene.analysisStatus === "loading" || scene.imageStatus === "loading"
  );
  const includedCount = song.scenes.filter((s) => !s.excluded).length;
  const excludedCount = song.scenes.length - includedCount;
  const commonRefCount = sceneGlobal.referenceImages.length;
  const hasCommonPrompt = !!sceneGlobal.promptEn.trim();
  const useGlobalImage = sceneGlobal.imageMode === "global" && !!sceneGlobal.imageUrl;
  const selectedScene = selectedSceneId ? song.scenes.find((s) => s.id === selectedSceneId) : null;
  const selectedSceneIndex = selectedScene ? song.scenes.indexOf(selectedScene) : -1;

  return (
    <div className="scene-board">
      <div className="scene-board-header">
        <div>
          <h2>장면 설계</h2>
          <p>
            문장 단위 자막을 분석해 장면 제안과 이미지를 생성합니다.
            {excludedCount > 0
              ? ` · ${includedCount}개 포함 / ${excludedCount}개 제외`
              : ` · ${includedCount}개 전체 포함`}
          </p>
          <p style={{fontSize: 11, color:"var(--ink-4)", marginTop: 2}}>
            각 카드 하단의 토글로 분석 대상을 선택하세요. 제외된 장면은 이전 장면의 프롬프트에 가사가 병합됩니다.
          </p>
          {(hasCommonPrompt || commonRefCount > 0) && (
            <p style={{fontSize: 11, color:"var(--accent)", marginTop: 2}}>
              공통 설정 적용 중 · 프롬프트 {hasCommonPrompt ? "있음" : "없음"} · reference 이미지 {commonRefCount}장
            </p>
          )}
          {useGlobalImage && (
            <p style={{fontSize: 11, color:"var(--accent-2)", marginTop: 2}}>
              전체 장면 대표 이미지 사용 중 · {sceneGlobal.imageSource === "upload" ? "업로드" : "AI 생성"} 1장
            </p>
          )}
          {pipelineError && (
            <div style={{marginTop: 8, fontSize: 12, color:"#ff8ea1"}}>
              {pipelineError}
            </div>
          )}
        </div>
        <div style={{display:"flex", alignItems:"center", gap: 10}}>
          <button className={`pill-btn ${hasCommonPrompt || commonRefCount > 0 || useGlobalImage ? "accent" : ""}`} onClick={() => setShowGlobalSettings(true)} disabled={anySceneLoading}>
            <Icon.Cog/> 공통 설정
          </button>
          <div style={{display:"flex", alignItems:"center", gap:8, padding:"6px 10px", background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 8}}>
            <span style={{fontSize: 11, color:"var(--ink-3)"}}>팔레트</span>
            <div className="swatch-row">
              {Object.keys(PALETTES).map(p => (
                <div key={p}
                  className={`swatch ${palette === p ? "active" : ""}`}
                  onClick={() => changePalette(p)}
                  title={p}
                  style={{background: `linear-gradient(135deg, ${PALETTES[p][0]}, ${PALETTES[p][2]})`}}
                />
              ))}
            </div>
          </div>
          <button className="pill-btn" onClick={refreshScenesFromLyrics} disabled={anySceneLoading || !song.lyricTimeline} title="가사 편집기에서 수정한 내용을 장면 목록에 반영합니다">
            <Icon.Refresh/> 가사 반영 새로고침
          </button>
          <button className="pill-btn" onClick={regenerateAll} disabled={generatingAll || anySceneLoading}>
            <Icon.Refresh/> {excludedCount > 0 ? `선택 장면 재생성 (${includedCount})` : "전체 재생성"}
          </button>
        </div>
      </div>

      {showGlobalSettings && (
        <SceneGlobalSettingsModal
          song={song}
          album={album}
          onClose={() => setShowGlobalSettings(false)}
          onSave={saveSceneGlobal}
        />
      )}

      {selectedScene && (
        <SceneDetailModal
          key={selectedScene.id}
          scene={selectedScene}
          sceneIndex={selectedSceneIndex}
          song={song}
          album={album}
          onClose={() => setSelectedSceneId(null)}
          onSave={(patch) => applyScenePatch(selectedScene.id, patch)}
          onRegenerate={(promptEn, imageProvider, googleImagePrompt) => regenerateFromModal(selectedScene.id, promptEn, imageProvider, googleImagePrompt)}
          onUploadImage={(file) => uploadImageForScene(selectedScene, file)}
          disabled={generatingAll}
        />
      )}

      <div className="scenes-grid">
        {song.scenes.map((s, i) => {
          const isExcluded = s.excluded === true;
          const cardLoading = !isExcluded && (generatingAll || s.analysisStatus === "loading" || s.imageStatus === "loading");
          const displayedScene = useGlobalImage
            ? { ...s, imageUrl: sceneGlobal.imageUrl, imageSeed: sceneGlobal.imageSeed }
            : s;
          const statusText = isExcluded
            ? "분석 제외"
            : s.analysisStatus === "loading"
              ? "장면 분석 중"
              : s.imageStatus === "loading"
                ? "이미지 생성 중"
                : s.analysisStatus === "error"
                  ? "장면 분석 실패"
                  : s.imageStatus === "error"
                    ? "이미지 생성 실패"
                  : useGlobalImage
                    ? `전체 이미지 · ${sceneGlobal.imageSource === "upload" ? "업로드" : "AI"}`
                    : s.imageSource === "upload"
                      ? "업로드 이미지"
                      : s.promptSource === "ai"
                      ? "AI 장면 제안"
                      : "기본 장면";
          const errorText = isExcluded ? "" : (s.analysisError || s.imageError || "");

          return (
            <div key={s.id} className={`scene-card${cardLoading ? " loading" : ""}${isExcluded ? " excluded" : ""} scene-card-clickable`} onClick={() => setSelectedSceneId(s.id)}>
              <div className="art">
                {!cardLoading && (
                  <SceneImage scene={displayedScene} paletteName={palette} showLabel/>
                )}
                <div className="ts mono">{fmtTime(s.start)}</div>
                <div className="scene-num">{i+1}</div>
                {isExcluded && <div className="excluded-badge">제외</div>}
                {useGlobalImage && !isExcluded && <div className="scene-source-badge">전체</div>}
              </div>
              <div className="meta">
                <div className="prompt">"{resolvePromptText(s)}"</div>
                <div className="lyric">{s.text}</div>
                <div style={{marginTop: 8, fontSize: 11, color: isExcluded ? "var(--ink-4)" : "var(--ink-3)"}}>
                  {statusText}
                </div>
                {!isExcluded && s.promptEn && (
                  <div style={{marginTop: 6, fontFamily:"var(--font-mono)", fontSize: 10.5, color:"var(--ink-4)", lineHeight: 1.6}}>
                    {shorten(s.promptEn)}
                  </div>
                )}
                {errorText && (
                  <div style={{marginTop: 8, fontSize: 11, color:"#ff8ea1", lineHeight: 1.5}}>
                    {errorText}
                  </div>
                )}
                <div style={{display:"flex", gap: 6, marginTop: 10, alignItems:"center"}}>
                  <button
                    className="pill-btn"
                    style={{height: 24, fontSize: 11, background: isExcluded ? "var(--bg-3)" : undefined}}
                    onClick={(e) => { e.stopPropagation(); toggleSceneExcluded(s.id); }}
                    disabled={cardLoading}
                    title={isExcluded ? "클릭하면 분석 대상에 다시 포함됩니다" : "클릭하면 분석에서 제외됩니다"}
                  >
                    {isExcluded ? "✕ 제외됨" : "✓ 포함"}
                  </button>
                  {!isExcluded && (
                    <>
                      <button
                        className="pill-btn"
                        style={{height: 24, fontSize: 11}}
                        onClick={(e) => { e.stopPropagation(); regenerateOne(s.id); }}
                        disabled={cardLoading}
                      >
                        <Icon.Refresh size={11}/> 재생성
                      </button>
                      <button
                        className="pill-btn"
                        style={{height: 24, fontSize: 11}}
                        onClick={(e) => { e.stopPropagation(); pickAndUploadSceneImage(s); }}
                        disabled={cardLoading}
                      >
                        <Icon.Upload size={11}/> 업로드
                      </button>
                    </>
                  )}
                </div>
              </div>
            </div>
          );
        })}
      </div>
    </div>
  );
}

Object.assign(window, { Creator });
