// 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" },
];

const STYLE_TEMPLATES = [
  { label: "Cinematic Film", value: "cinematic film still, anamorphic lens, shallow depth of field\nwarm golden hour lighting, lens flare\nrich color grading, teal and orange tones\n35mm film grain, soft bokeh background\ndramatic composition, widescreen aspect ratio" },
  { label: "Anime / Illustration", value: "anime illustration style, vibrant cel shading\nclean line art, detailed character design\nsoft pastel sky, expressive lighting\nstudio ghibli inspired color palette\ndreamy atmosphere, hand-drawn aesthetic" },
  { label: "Watercolor Painting", value: "pastel watercolor illustration, soft edges\nhand-painted feel, delicate brush strokes\nsubtle color blending, paper texture\nwarm gentle daylight, muted tones\ncandid intimate storytelling, nostalgic mood" },
  { label: "Neon Cyberpunk", value: "cyberpunk neon aesthetic, futuristic cityscape\nvibrant neon glow, pink and cyan lighting\nrain-soaked reflections, dark moody atmosphere\nholographic elements, digital glitch effects\nhigh contrast, cinematic night scene" },
  { label: "Vintage Retro", value: "vintage retro photography, faded film colors\n70s analog warmth, light leak effects\nsoft vignette, slightly overexposed highlights\ngrainy texture, desaturated earth tones\nnostalgic summer mood, polaroid aesthetic" },
];

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

function getAlbumSubtitleDefaults(album) {
  return {
    fontSize: 108,
    lineGap: 1.45,
    subPos: "bottom",
    transition: "fade",
    subtitleContrastMode: "auto",
    subtitleBackdrop: true,
    subtitleCloud: false,
    ...(album?.subtitleDefaults || {}),
  };
}

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: "forced-aligner가 mp3와 가사를 직접 맞춰 timestamp를 생성합니다.",
        },
      ]
    : mode === "imported_json"
      ? [
          {
            id: "import",
            label: "1. JSON 불러오기",
            description: "기존 lyric_timeline_json 결과를 현재 곡에 적용합니다.",
          },
        ]
      : [
          {
            id: "transcribe",
            label: "1. 가사 추출",
            description: "ASR 단계(즉 가사추출단계)가 mp3에서 가사 초안을 만듭니다.",
          },
          {
            id: "align",
            label: "2. timestamp 정렬",
            description: "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단계에서 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, options = {}) => {
    setAlbum(prev => ({
      ...prev,
      songs: prev.songs.map(s => {
        if (s.id !== songId) return s;
        const nextPatch = typeof patch === "function" ? patch(s) : patch;
        const dirtyPatch = options.markRenderDirty
          ? (window.markSongRenderDirty?.(s, options.dirtyReason) || {})
          : {};
        return { ...s, ...nextPatch, ...dirtyPatch };
      }),
    }));
  };

  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(),
      ...(window.clearSongRenderDirty?.() || {}),
    });
    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" data-role="main-creator-root-panel">
      {/* Stepper */}
      <div className="stepper" data-role="main-creator-stepper-panel">
        {CREATOR_STEPS.map((s, i) => (
          <React.Fragment key={s.id}>
            <div
              className={`step ${step === s.id ? "active" : i < stepIdx ? "done" : ""}`}
              data-role="main-creator-step-item"
              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 className="stepper-song-meta" style={{marginLeft:"auto", display:"flex", alignItems:"center", gap: 10}}>
          <div style={{fontSize: 12.5, color:"var(--ink-3)"}}>
            <span className="stepper-song-title" style={{color:"var(--ink-2)"}}>{song.title || "제목 없음"}</span>
            <span className="stepper-song-sep" style={{color:"var(--ink-4)", margin:"0 8px"}}>·</span>
            <span className="mono stepper-song-file" style={{color:"var(--ink-3)", fontSize: 11}}>{song.filename}</span>
          </div>
        </div>
      </div>

      {/* Body */}
      <div className={`creator-body step-${step}`} data-role="main-creator-step-body-panel">
        {step === "upload"     && <UploadStep album={album} 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 setAlbum={setAlbum} 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" data-role="main-creator-footer-panel">
        <button className="pill-btn footer-album-btn" data-role="main-creator-footer-back-album-btn" onClick={onBack}>
          <Icon.Home size={11}/>
          <span className="label-full">앨범으로</span>
          <span className="label-mobile">앨범</span>
        </button>
        <div style={{flex:1}}/>
        <button className="pill-btn footer-save-btn" data-role="main-creator-footer-save-btn" onClick={() => persistSong()} disabled={saving}>
          <Icon.Save size={11}/>
          <span className="label-full">{saving ? "저장 중" : "현재 단계 저장"}</span>
          <span className="label-mobile">{saving ? "저장 중" : "저장"}</span>
        </button>
        <button className="pill-btn song-json-download" data-role="main-creator-footer-download-song-json-btn" onClick={() => persistSong({ download: true })} disabled={saving}>
          <Icon.Download size={11}/> Song JSON 다운로드
        </button>
        {canGoBack && (
          <button className="pill-btn footer-prev-btn" data-role="main-creator-footer-prev-step-btn" onClick={() => goToStep(CREATOR_STEPS[stepIdx-1].id)}>
            <Icon.ArrowLeft/>
            <span className="label-full">이전</span>
            <span className="label-mobile">이전</span>
          </button>
        )}
        {step === "export" && song.exportState?.status === "done" && (
          <button className="pill-btn footer-complete-btn" data-role="main-creator-footer-complete-btn" onClick={finalizeSong}>
            <span className="label-full">완료&Movie Play</span>
            <span className="label-mobile">완료</span>
            <Icon.ArrowRight/>
          </button>
        )}
        {canAdvance && step !== "export" && (
          <button className="pill-btn footer-next-btn" data-role="main-creator-footer-next-step-btn" onClick={() => goToStep(CREATOR_STEPS[stepIdx+1].id)}>
            <span className="label-full">다음: {CREATOR_STEPS[stepIdx+1].label}</span>
            <span className="label-mobile">다음</span>
            <Icon.ArrowRight/>
          </button>
        )}
      </div>
    </div>
  );
}

// ─── Step 1: Upload ────────────────────────────────────
function UploadStep({ album, 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);
  useWakeLock(playing);

  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,
      lyric_original: "",
      lyric_style: "",
      transcribeMode: "",
      transcribeWorkflow: null,
      storyId: "",
      workflowStep: "upload",
      audioServerUrl: "",
      audioStorageKey: "",
      audioPersistedAt: "",
      previewSettings: getAlbumSubtitleDefaults(album),
      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,
      lyric_original: "",
      lyric_style: "",
      transcribeMode: "",
      transcribeWorkflow: null,
      storyId: "",
      workflowStep: "upload",
      audioStorageKey: "",
      audioPersistedAt: "",
      audioByteLength: 0,
      previewSettings: getAlbumSubtitleDefaults(album),
      exportState: {
        status: "idle",
        progress: 0,
        done: false,
      },
    });
  };

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

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

      {!hasFile ? (
        <div
          className={`upload-zone ${dragging ? "dragging" : ""}`}
          data-role="main-creator-upload-dropzone"
          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" data-role="main-creator-upload-dropzone-icon"><Icon.Upload/></div>
          <h2 data-role="main-creator-upload-dropzone-title">mp3를 여기에 드래그</h2>
          <p data-role="main-creator-upload-dropzone-guide">또는 <span className="browse">파일 탐색</span> · 최대 15MB</p>
          <input ref={inputRef} data-role="main-creator-upload-audio-file-input" type="file" accept="audio/mpeg,audio/mp3,audio/*" hidden onChange={e => handleFile(e.target.files?.[0])}/>
          <input ref={restoreRef} data-role="main-creator-upload-song-json-input" type="file" accept=".json,application/json" hidden onChange={handleSnapshotImport}/>
          <div data-role="main-creator-upload-restore-song-json-guide" style={{marginTop: 16, fontSize: 12, color:"var(--ink-3)"}}>
            저장된 Song JSON이 있으면 바로 복구할 수 있습니다.
          </div>
          <button className="pill-btn" data-role="main-creator-upload-restore-song-json-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" data-role="main-creator-uploaded-card-panel">
            <button
              className="art"
              data-role="main-creator-uploaded-card-play-toggle-btn"
              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" data-role="main-creator-uploaded-card-info-panel">
              <div className="fname" data-role="main-creator-uploaded-card-song-title">{song.title || song.filename.replace(/\.[^.]+$/, "")}</div>
              <div className="fmeta" data-role="main-creator-uploaded-card-song-meta">
                {song.filename} · {song.size} ·{" "}
                {audioUrl
                  ? <>{fmtTime(currentTime)} / {fmtTime(song.duration)}</>
                  : fmtTime(song.duration)}
              </div>
              <div data-role="main-creator-uploaded-card-waveform-panel" 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" data-role="main-creator-uploaded-card-remove-file-btn" onClick={removeFile}>
              <Icon.X/> 제거
            </button>
          </div>

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

          {audioUrl ? (
            <div data-role="main-creator-uploaded-card-audio-ready-guide" 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 data-role="main-creator-uploaded-card-audio-missing-guide" style={{maxWidth: 640, margin: "12px auto 0", fontSize: 11.5, color:"var(--ink-4)", fontStyle:"italic"}}>
              * 샘플 노래이거나 새로고침 이후에는 재생할 수 없습니다. 재생하려면 파일을 다시 업로드하세요.
            </div>
          )}

          <div data-role="main-creator-upload-lyrics-source-panel" style={{maxWidth: 640, margin: "20px auto 0", background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 12, padding: 20}}>
            <div data-role="main-creator-upload-lyrics-source-title" style={{fontSize: 12, color:"var(--ink-3)", marginBottom: 10, textTransform:"uppercase", letterSpacing:"0.06em", fontWeight:600}}>Suno · 가사 · 스타일</div>
            <p data-role="main-creator-upload-lyrics-source-description" style={{margin: "0 0 14px", fontSize: 12.5, color:"var(--ink-3)", lineHeight: 1.5}}>
              Suno에서 쓴 가사와 Style 프롬프트를 붙여 넣을 수 있습니다. 「현재 단계 저장」 또는 Song JSON 다운로드 시 <span className="mono" style={{color:"var(--accent)"}}>lyric_original</span>, <span className="mono" style={{color:"var(--accent)"}}>lyric_style</span>로 함께 저장됩니다.
            </p>
            <label data-role="main-creator-upload-lyric-original-label" style={{display:"block", fontSize: 12, color:"var(--ink-2)", marginBottom: 6, fontWeight: 500}}>Suno 가사 (lyric_original)</label>
            <textarea
              data-role="main-creator-upload-lyric-original-textarea"
              value={song.lyric_original || ""}
              onChange={e => updateSong({ lyric_original: e.target.value })}
              placeholder="Suno에 입력한 가사 전문…"
              rows={5}
              style={{width: "100%", boxSizing: "border-box", background:"var(--bg-2)", border:"1px solid var(--line)", borderRadius: 8, padding: "10px 12px", fontSize: 13, lineHeight: 1.45, outline:"none", resize: "vertical", fontFamily: "inherit", marginBottom: 14}}
            />
            <label data-role="main-creator-upload-lyric-style-label" style={{display:"block", fontSize: 12, color:"var(--ink-2)", marginBottom: 6, fontWeight: 500}}>Suno Style (lyric_style)</label>
            <textarea
              data-role="main-creator-upload-lyric-style-textarea"
              value={song.lyric_style || ""}
              onChange={e => updateSong({ lyric_style: e.target.value })}
              placeholder="예: k-pop, female vocal, emotional…"
              rows={3}
              style={{width: "100%", boxSizing: "border-box", background:"var(--bg-2)", border:"1px solid var(--line)", borderRadius: 8, padding: "10px 12px", fontSize: 13, lineHeight: 1.45, outline:"none", resize: "vertical", fontFamily: "inherit"}}
            />
          </div>

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

          <div data-role="main-creator-upload-next-action-panel" style={{maxWidth: 640, margin: "24px auto 0", textAlign:"right"}}>
            <button className="pill-btn accent" data-role="main-creator-upload-start-transcribe-btn" 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);
  useWakeLock(playing);

  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 || '직접 입력') : '',
    }, { markRenderDirty: true, dirtyReason: "lyrics-reference" });
  };

  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 || '',
        }, { markRenderDirty: true, dirtyReason: "lyrics-json-import" });
        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 cleanReferenceLyricsText = (raw) => {
    const text = sanitizeLyricsText(raw);
    const lines = text.split('\n');
    const allowedOnly = /^[\s0-9가-힣a-zA-Zㄱ-ㅎㅏ-ㅣ.,!?'"'"“”\-·…:;()]*$/;
    const hasLyricLetter = /[가-힣a-zA-Z]/;
    const processed = [];
    for (const line of lines) {
      const trimmed = line.trim();
      if (!trimmed) {
        processed.push('');
        continue;
      }
      if (!allowedOnly.test(line) || !hasLyricLetter.test(trimmed)) continue;
      processed.push(trimmed);
    }
    const merged = [];
    let prevBlank = false;
    for (let i = 0; i < processed.length; i++) {
      const isBlank = processed[i] === '';
      if (isBlank) {
        if (merged.length === 0) continue;
        if (prevBlank) continue;
        merged.push('');
        prevBlank = true;
      } else {
        merged.push(processed[i]);
        prevBlank = false;
      }
    }
    while (merged.length && merged[merged.length - 1] === '') merged.pop();
    return merged.join('\n');
  };

  const applyCleanLyricsText = () => {
    const next = cleanReferenceLyricsText(song.referenceLyricsText || '');
    setReferenceLyrics(next, song.referenceLyricsName);
    window.toast('가사 텍스트를 정리했습니다');
  };

  const canImportLyricsFromOriginal = !!(song.lyric_original && String(song.lyric_original).trim());

  const applyLyricsFromOriginal = () => {
    const raw = song.lyric_original || '';
    if (!String(raw).trim()) {
      window.toast('업로드 단계에서 입력한 Suno 가사(lyric_original)가 없습니다.');
      return;
    }
    const next = cleanReferenceLyricsText(raw);
    setReferenceLyrics(next, '가사원본(Suno)');
    if (!next.trim()) {
      window.toast('정리 후 남는 가사가 없습니다. lyric_original 내용을 확인해 주세요.');
    } else {
      const lineCount = next.split('\n').filter((line) => line.trim()).length;
      window.toast(`가사 원본에서 정리해 반영했습니다 · ${lineCount}줄`);
    }
  };

  const applyReferenceLyricsStructureLayout = () => {
    const logRefLayout = (level, message) => {
      const line = `[참조 가사 구조 정열] ${message}`;
      if (level === 'error') console.error(line);
      else console.log(line);
    };
    const L = window.LyricReferenceResegment;
    if (!L) {
      const msg = '참조 구조 정열 모듈이 로드되지 않았습니다.';
      logRefLayout('error', msg);
      window.toast(msg);
      return;
    }
    const result = L.resegmentLyricTimelineToReferenceLayout(song.lyricTimeline, song.referenceLyricsText || '');
    if (result.error) {
      logRefLayout('error', result.error);
      window.toast(result.error);
      return;
    }
    const cues = window.SongfilmAPI.flattenTimelineToCues(result.lyricTimeline);
    const scenes = window.buildScenes(cues);
    updateSong({
      lyricTimeline: result.lyricTimeline,
      cues,
      scenes,
      storyId: SongfilmSongStorage?.buildStoryId({ ...song, lyricTimeline: result.lyricTimeline }) || song.storyId || '',
    }, { markRenderDirty: true, dirtyReason: 'lyrics-reference-layout' });
    const st = result.stats;
    const opMsg = st.wordMerged || st.wordSplit || st.wordRealigned
      ? ` · merge ${st.wordMerged || 0} · split ${st.wordSplit || 0} · realign ${st.wordRealigned || 0}`
      : '';
    const okMsg =
      st.timelineWordsBefore !== st.words
        ? `참조 가사 구조 정열 완료 · 문장 ${st.sentences} · 줄 ${st.lines} · 단어 ${st.words}개 (추출 리프 ${st.timelineWordsBefore}개 반영${opMsg})`
        : `참조 가사 구조 정열 완료 · 문장 ${st.sentences} · 줄 ${st.lines} · 단어 ${st.words}개${opMsg}`;
    logRefLayout('info', okMsg);
    window.toast(okMsg);
  };

  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단계만 실행합니다. 입력된 가사로 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 || '',
      }, { markRenderDirty: true, dirtyReason: useReferenceLyrics ? "lyrics-align" : "lyrics-transcribe" });
      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}`);
    }
  };

  // 타임스탬프 이상 구간(밀집/클램프/균일보간) 감지 결과 — 재정렬 버튼 노출 판단용
  // 구버전 dense-align-fix.js가 캐시에 남아 있어도 화면이 죽지 않도록 방어한다.
  let denseAnalysis = null;
  if (song.lyricTimeline && typeof window.DenseAlignFix?.analyze === 'function') {
    try {
      denseAnalysis = window.DenseAlignFix.analyze(song.lyricTimeline);
    } catch (err) {
      console.error('[DenseAlignFix] analyze 실패:', err);
    }
  }

  // 밀집 구간 재정렬 — transcribe와 독립된 작업.
  // 재정렬 입력(슬라이스 WAV + 가사 TXT)과 결과 JSON이 자동 다운로드된다.
  const runDenseRealign = async () => {
    if (running || !song.lyricTimeline) return;
    if (typeof window.DenseAlignFix?.fix !== 'function') {
      window.toast('재정렬 모듈이 구버전입니다. 강력 새로고침(Ctrl+F5) 후 다시 시도해주세요.');
      return;
    }
    setRunning(true);
    try {
      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' });

      addLog('info', '밀집 구간 재정렬을 시작합니다. 재정렬 입력(오디오/가사)과 결과 JSON이 다운로드됩니다.');
      const fix = await window.DenseAlignFix.fix(song.lyricTimeline, file, { onLog: addLog });
      if (fix.fixed) {
        const cues = window.SongfilmAPI.flattenTimelineToCues(fix.timeline);
        const scenes = window.buildScenes(cues);
        updateSong({
          lyricTimeline: fix.timeline,
          cues,
          scenes,
          storyId: SongfilmSongStorage?.buildStoryId({ ...song, lyricTimeline: fix.timeline }) || song.storyId || '',
        }, { markRenderDirty: true, dirtyReason: 'dense-realign' });
        window.toast(`밀집 구간 재정렬 완료 · ${cues.length}줄 · ${fix.message}`);
      } else {
        window.toast(`밀집 구간 재정렬 실패 — ${fix.message}`);
      }
    } catch (err) {
      addLog('err', `밀집 구간 재정렬 오류: ${err.message}`);
      window.toast(`밀집 구간 재정렬 오류: ${err.message}`);
    } finally {
      setRunning(false);
    }
  };

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

      <h2 data-role="main-creator-transcribe-title">가사 & 자막 추출</h2>
      <div className="sub" data-role="main-creator-transcribe-description">
        이 화면은 항상 2단계로 관리됩니다. 가사가 없으면 `ASR 단계(즉 가사추출단계) ⇒ forced-aligner`, 가사가 이미 있으면 `가사 확인 ⇒ forced-aligner` 흐름으로 바로 timestamp를 붙입니다.
      </div>

      <div data-role="main-creator-transcribe-workflow-grid" 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}
              data-role="main-creator-transcribe-workflow-step-card"
              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 data-role="main-creator-transcribe-workflow-step-header" style={{display:'flex', alignItems:'center', justifyContent:'space-between', gap: 10}}>
                <div data-role="main-creator-transcribe-workflow-step-title" style={{fontSize: 13, fontWeight: 700, color:'var(--ink)'}}>{step.label}</div>
                <span className="mono" data-role="main-creator-transcribe-workflow-step-status" 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 data-role="main-creator-transcribe-workflow-step-description" style={{fontSize: 12, color:'var(--ink-3)', lineHeight: 1.6, marginTop: 8}}>{step.description}</div>
            </div>
          );
        })}
      </div>

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

      {denseAnalysis?.found && (
        <div
          data-role="main-creator-transcribe-dense-warning-panel"
          style={{
            marginTop: 12,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
            gap: 12,
            flexWrap: 'wrap',
            border: '1px solid rgba(255, 184, 76, 0.45)',
            background: 'rgba(255, 184, 76, 0.07)',
            borderRadius: 12,
            padding: '12px 16px',
          }}
        >
          <div data-role="main-creator-transcribe-dense-warning-message" style={{fontSize: 12.5, color: 'var(--ink-2)', lineHeight: 1.6}}>
            <span style={{color: '#ffb84c', fontWeight: 700}}>⚠ 타임스탬프 이상 구간 감지</span>
            {' · '}{denseAnalysis.message}
            <div style={{fontSize: 11.5, color: 'var(--ink-3)', marginTop: 2}}>
              긴 구간은 여러 라운드로 나눠 앞에서부터 수리합니다. 종료 시 검증용 디버그 ZIP(슬라이스 오디오·가사·결과 JSON)이 1개 다운로드됩니다.
            </div>
          </div>
          <button
            className="pill-btn accent"
            data-role="main-creator-transcribe-dense-realign-btn"
            onClick={runDenseRealign}
            disabled={running}
          >
            <Icon.Sparkle/> 밀집 구간 재정렬
          </button>
        </div>
      )}

      <div data-role="main-creator-transcribe-reference-lyrics-panel" style={{marginTop: 18, background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 12, padding: 18}}>
        <div data-role="main-creator-transcribe-reference-lyrics-header" style={{display:"flex", justifyContent:"space-between", alignItems:"flex-start", gap: 16, flexWrap:"wrap"}}>
          <div data-role="main-creator-transcribe-reference-lyrics-title-panel">
            <div data-role="main-creator-transcribe-reference-lyrics-title" style={{fontSize: 14, fontWeight: 600, color:"var(--ink)"}}>가사 text 입력</div>
            <div data-role="main-creator-transcribe-reference-lyrics-description" style={{fontSize: 12, color:"var(--ink-3)", marginTop: 4}}>
              txt/md/lrc 파일 업로드도 가능하고, 여기에 직접 붙여넣거나 수정해도 됩니다.
            </div>
            {hasReferenceLyrics && (
              <div data-role="main-creator-transcribe-reference-lyrics-meta" 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 data-role="main-creator-transcribe-reference-lyrics-actions" style={{display:"flex", alignItems:"center", gap: 8, flexWrap:"wrap"}}>
            <button className="pill-btn" data-role="main-creator-transcribe-upload-lyrics-btn" onClick={() => lyricsFileRef.current?.click()}>
              <Icon.Upload size={11}/> 가사 text 업로드
            </button>
            <button
              type="button"
              className="pill-btn"
              data-role="main-creator-transcribe-import-original-lyrics-btn"
              title="업로드 단계의 Suno 가사(lyric_original)를 Clean Text와 동일한 규칙으로 정리해 아래 입력란에 넣습니다"
              onClick={applyLyricsFromOriginal}
              disabled={running || !canImportLyricsFromOriginal}
            >
              <Icon.Music size={11}/> 가사원본에서 가져오기
            </button>
            {hasReferenceLyrics && (
              <>
                <button className="pill-btn accent" data-role="main-creator-transcribe-align-with-reference-btn" onClick={() => start({ useReferenceLyrics: true })} disabled={running}>
                  <Icon.Sparkle/> 가사로 바로 timestamp 정렬
                </button>
                <button className="pill-btn" data-role="main-creator-transcribe-clear-reference-lyrics-btn" onClick={clearLyricsUpload}>
                  <Icon.X size={11}/> 입력 지우기
                </button>
                <button
                  type="button"
                  className="pill-btn"
                  data-role="main-creator-transcribe-clean-reference-lyrics-btn"
                  title="가사 내용 중에서 실제 가사와 상관없는 내용을 제거하여 효과적으로 timestamp 정열이 잘 되게 함"
                  onClick={applyCleanLyricsText}
                  disabled={!hasReferenceLyrics || running}
                >
                  <Icon.Wand size={11}/> Clean Text
                </button>
              </>
            )}
          </div>
        </div>
        <textarea
          data-role="main-creator-transcribe-reference-lyrics-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 data-role="main-creator-transcribe-start-options-panel" style={{background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 12, overflow:"hidden"}}>
          <div data-role="main-creator-transcribe-start-options-grid" style={{display:'grid', gridTemplateColumns:'repeat(auto-fit, minmax(280px, 1fr))', gap: 0}}>
            <div data-role="main-creator-transcribe-start-option-asr-panel" style={{padding: "30px 22px", borderRight: hasReferenceLyrics ? "1px solid var(--line)" : "none"}}>
              <Icon.Wand/>
              <div data-role="main-creator-transcribe-start-option-asr-title" style={{fontFamily:"var(--font-display)", fontSize: 20, fontWeight:600, marginTop: 10}}>1단계부터 진행</div>
              <div data-role="main-creator-transcribe-start-option-asr-description" style={{color:"var(--ink-3)", fontSize: 13, marginTop: 6}}>가사가 없을 때 사용하는 기본 경로입니다. `ASR 단계(즉 가사추출단계)`으로 가사를 추출한 뒤 `forced-aligner`로 timestamp를 만듭니다.</div>
              <button className="pill-btn accent" data-role="main-creator-transcribe-start-asr-btn" style={{marginTop: 18}} onClick={() => start()}>
                <Icon.Sparkle/> ASR + 정렬 시작
              </button>
            </div>
            {hasReferenceLyrics && (
              <div data-role="main-creator-transcribe-start-option-reference-panel" style={{padding: "30px 22px"}}>
                <Icon.Sparkle/>
                <div data-role="main-creator-transcribe-start-option-reference-title" style={{fontFamily:"var(--font-display)", fontSize: 20, fontWeight:600, marginTop: 10}}>2단계만 바로 실행</div>
                <div data-role="main-creator-transcribe-start-option-reference-description" style={{color:"var(--ink-3)", fontSize: 13, marginTop: 6}}>가사가 이미 있으므로 `ASR 단계(즉 가사추출단계)`은 건너뜁니다. 업로드한 mp3와 현재 가사를 바로 `forced-aligner`에 보내 timestamp를 붙입니다.</div>
                <button className="pill-btn" data-role="main-creator-transcribe-start-align-only-btn" style={{marginTop: 18}} onClick={() => start({ useReferenceLyrics: true })}>
                  <Icon.ArrowRight/> forced aligner 바로 호출
                </button>
              </div>
            )}
          </div>
          <div data-role="main-creator-transcribe-import-json-panel" 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" data-role="main-creator-transcribe-import-json-btn" onClick={() => importFileRef.current?.click()}>
              <Icon.Upload size={11}/> JSON 불러오기
            </button>
          </div>
        </div>
      )}

      {(running || logs.length > 0) && (
        <>
          <div className="log-viewer" data-role="main-creator-transcribe-log-viewer" ref={logViewerRef}>
            {logs.map((l, i) => (
              <div key={i} className="log-line" data-role="main-creator-transcribe-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" data-role="main-creator-transcribe-progress-track"><div className="bar" data-role="main-creator-transcribe-progress-bar" style={{width: `${progress}%`}}/></div>
          <div data-role="main-creator-transcribe-progress-meta" 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 data-role="main-creator-transcribe-result-title" 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 data-role="main-creator-transcribe-result-summary" 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 data-role="main-creator-transcribe-result-meta" 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 data-role="main-creator-transcribe-result-actions" 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" data-role="main-creator-transcribe-download-json-btn" onClick={downloadTimeline} title="lyric_timeline_json 파일로 저장">
                  <Icon.Download size={11}/> JSON 저장
                </button>
              )}
              {onOpenLyricEditor && (
                <button className="pill-btn accent" data-role="main-creator-transcribe-open-lyric-editor-btn" onClick={onOpenLyricEditor}>
                  <Icon.Edit size={11}/> 가사 편집기
                </button>
              )}
              {song.lyricTimeline && hasReferenceLyrics && (
                <button
                  type="button"
                  className="pill-btn"
                  data-role="main-creator-transcribe-reference-layout-btn"
                  onClick={applyReferenceLyricsStructureLayout}
                  disabled={running}
                  title="JSON word와 참조 가사 토큰을 앞에서부터 scan해 필요한 word merge/split을 적용한 뒤, 참조 가사의 줄·문장 구조로 line/sentence/paragraph를 재조립합니다. word 타임스탬프는 재계산하지 않습니다."
                >
                  <Icon.Link size={11}/> 참조 가사 구조 정열
                </button>
              )}
              {hasReferenceLyrics && (
                <button className="pill-btn" data-role="main-creator-transcribe-restart-with-reference-btn" onClick={() => start({ useReferenceLyrics: true })} disabled={running}>
                  <Icon.Refresh/> 가사 text 기준 다시 추출
                </button>
              )}
              <button className="pill-btn" data-role="main-creator-transcribe-reimport-json-btn" onClick={() => importFileRef.current?.click()} title="저장된 JSON 파일 불러오기">
                <Icon.Upload size={11}/> JSON 불러오기
              </button>
              <button className="pill-btn" data-role="main-creator-transcribe-restart-asr-btn" onClick={() => start()} disabled={running}>
                <Icon.Refresh/> 다시 추출
              </button>
            </div>
          </div>

          <div className="lyrics-panel" data-role="main-creator-transcribe-lyrics-panel" style={{marginTop: 16}}>
            <div className="lyrics-list" data-role="main-creator-transcribe-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" : ""}`}
                    data-role="main-creator-transcribe-lyric-row"
                    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" data-role="main-creator-transcribe-timeline-preview-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" data-role="main-creator-transcribe-stats-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 migrateLegacySceneGlobalPromptKo(promptKo) {
  const raw = String(promptKo || "").trim();
  if (!raw) return { promptCommonBackground: "", promptStyle: "" };
  const bgM = raw.match(/<공통 배경>\s*([\s\S]*?)<\/공통 배경>/i);
  const stM = raw.match(/<Style>\s*([\s\S]*?)<\/Style>/i);
  if (bgM || stM) {
    return {
      promptCommonBackground: (bgM?.[1] || "").trim(),
      promptStyle: (stM?.[1] || "").trim(),
    };
  }
  const oldM = raw.match(/<공통 프롬프트 \(한국어\)>\s*([\s\S]*?)<\/공통 프롬프트 \(한국어\)>/i);
  if (oldM) return { promptCommonBackground: oldM[1].trim(), promptStyle: "" };
  return { promptCommonBackground: raw, promptStyle: "" };
}

function getSceneGlobal(song) {
  const imageUrl = typeof song?.sceneGlobal?.imageUrl === "string" ? song.sceneGlobal.imageUrl : "";
  const sg = song?.sceneGlobal || {};
  const hasSplitKeys =
    Object.prototype.hasOwnProperty.call(sg, "promptCommonBackground") ||
    Object.prototype.hasOwnProperty.call(sg, "promptStyle");
  const legacyKo = typeof sg.promptKo === "string" ? sg.promptKo : "";
  let promptCommonBackground = "";
  let promptStyle = "";
  if (hasSplitKeys) {
    promptCommonBackground = typeof sg.promptCommonBackground === "string" ? sg.promptCommonBackground : "";
    promptStyle = typeof sg.promptStyle === "string" ? sg.promptStyle : "";
  } else {
    const migrated = migrateLegacySceneGlobalPromptKo(legacyKo);
    promptCommonBackground = migrated.promptCommonBackground;
    promptStyle = migrated.promptStyle;
  }
  return {
    promptCommonBackground,
    promptStyle,
    promptKo: legacyKo,
    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 || "",
  };
}

function hasSceneGlobalCommonContent(sceneGlobal) {
  return !!(
    String(sceneGlobal?.promptCommonBackground || "").trim() ||
    String(sceneGlobal?.promptStyle || "").trim() ||
    String(sceneGlobal?.promptKo || "").trim()
  );
}

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, sceneGlobal = null }) {
  const commonTagged = String(
    window.SongfilmAIBroker?.buildSceneGlobalCommonTagged?.(sceneGlobal || {}) || ""
  ).trim();
  const lines = [];
  lines.push("아래 노래의 다음 장면에 사용할 이미지를 만들어 보자.");
  if (commonTagged) {
    lines.push(commonTagged);
    lines.push("");
  }
  lines.push(
    "<장면>",
    String(sceneText || "").trim(),
    "</장면>",
    "<전체 노래>",
    String(lyrics || "").trim(),
    "</전체 노래>",
  );
  return lines.join("\n");
}

function stripGoogleCommonTaggedBlocks(text) {
  return String(text || "")
    .replace(/<공통 프롬프트 \(한국어\)>\s*[\s\S]*?<\/공통 프롬프트 \(한국어\)>\s*/g, "")
    .replace(/<공통 배경>\s*[\s\S]*?<\/공통 배경>\s*/gi, "")
    .replace(/<Style>\s*[\s\S]*?<\/Style>\s*/gi, "")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

function buildGoogleCommonSceneGlobalBlock(sceneGlobal) {
  return String(
    window.SongfilmAIBroker?.buildSceneGlobalCommonTagged?.(sceneGlobal || {}) || ""
  ).trim();
}

/** 저장된 Google 프롬프트가 있어도, 현재 공통 설정 태그 블록이 `<장면>` 바로 앞에 오도록 맞춘다. */
function resolveGoogleImagePromptForApi({ savedOverride, sceneText, lyrics, sceneGlobal }) {
  const built = buildGoogleSceneImagePrompt({ sceneText, lyrics, sceneGlobal });
  const saved = String(savedOverride || "").trim();
  if (!saved) return built;

  const commonBlock = buildGoogleCommonSceneGlobalBlock(sceneGlobal);
  let body = stripGoogleCommonTaggedBlocks(saved).replace(/\n{3,}/g, "\n\n").trim();
  if (!commonBlock) return body;

  const marker = "<장면>";
  const i = body.indexOf(marker);
  if (i >= 0) {
    const head = body.slice(0, i).trimEnd();
    const tail = body.slice(i);
    return [head, commonBlock, tail].filter(Boolean).join("\n\n");
  }
  return [commonBlock, body].filter(Boolean).join("\n\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 [promptCommonBackground, setPromptCommonBackground] = useState(initialGlobal.promptCommonBackground);
  const [promptStyle, setPromptStyle] = useState(initialGlobal.promptStyle);
  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 [uploading, setUploading] = useState(false);
  const [globalImageBusy, setGlobalImageBusy] = useState(false);
  const [error, setError] = useState("");

  const updateGlobal = (patch) => {
    const next = {
      promptCommonBackground,
      promptStyle,
      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 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),
        promptCommonBackground,
        promptStyle,
        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({
      promptCommonBackground,
      promptStyle,
      promptKo: "",
      referenceImages,
      imageMode,
      imageSource,
      imageUrl,
      imageFilename,
      imageSavedAt,
      imageStatus,
      imageError,
      imagePromptEn,
      imageSeed,
    });
    window.toast("전체 장면 공통 설정을 저장했습니다.");
    onClose();
  };

  return (
    <div className="modal-overlay" data-role="main-scene-global-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="scene-common-modal" data-role="main-scene-global-modal-dialog" onClick={(e) => e.stopPropagation()}>
        <div className="sdm-header" data-role="main-scene-global-modal-header">
          <span className="sdm-scene-num">공통 설정</span>
          <span className="sdm-timestamp">전체 장면의 주인공, 세계관, 톤을 고정합니다</span>
          <button className="sdm-close" data-role="main-scene-global-modal-close-btn" onClick={onClose}>✕</button>
        </div>
        <div className="sdm-scroll" data-role="main-scene-global-modal-scroll-panel">
          <div className="sdm-fields" data-role="main-scene-global-modal-fields-panel">
            {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" : ""}`}
                      data-role="main-scene-global-modal-image-option-btn"
                      onClick={() => selectImageOption(option)}
                      disabled={globalImageBusy || uploading}
                    >
                      <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"
                  data-role="main-scene-global-modal-image-file-input"
                  accept="image/*"
                  style={{ display: "none" }}
                  onChange={handleGlobalImageUpload}
                />
                <div style={{display:"flex", gap: 8, flexWrap:"wrap"}}>
                  <button className="pill-btn" data-role="main-scene-global-modal-generate-image-btn" onClick={generateGlobalAiImage} disabled={globalImageBusy || uploading}>
                    <Icon.Sparkle/> {globalImageBusy && imageSource === "ai" ? "생성 중" : "AI로 1장 생성"}
                  </button>
                  <button className="pill-btn" data-role="main-scene-global-modal-upload-image-btn" onClick={() => globalImageInputRef.current?.click()} disabled={globalImageBusy || uploading}>
                    <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"
                  data-role="main-scene-global-modal-image-prompt-textarea"
                  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>
              <label className="sdm-field-label">공통 배경</label>
              <textarea
                className="sdm-textarea"
                data-role="main-scene-global-modal-common-background-textarea"
                value={promptCommonBackground}
                onChange={(e) => {
                  setPromptCommonBackground(e.target.value);
                  updateGlobal({ promptCommonBackground: e.target.value });
                }}
                rows={4}
                placeholder={"모든 장면에 반복될 고정 요소만 적으세요. 주인공·세계관·장소·색·조명·카메라 톤 등.\n예:\n4명의 남자 형제가 여행을 같이 한다.\nLocation: 한국의 아름다운 장소"}
              />
            </div>

            <div>
              <div className="scene-common-row">
                <label className="sdm-field-label" style={{ marginBottom: 0 }}>스타일 (Style)</label>
                <select
                  className="style-template-select"
                  data-role="main-scene-global-modal-style-template-select"
                  title="스타일 템플릿 선택"
                  value=""
                  onChange={(e) => {
                    if (!e.target.value) return;
                    const tpl = STYLE_TEMPLATES.find(t => t.label === e.target.value);
                    if (tpl) {
                      setPromptStyle(tpl.value);
                      updateGlobal({ promptStyle: tpl.value });
                    }
                    e.target.value = "";
                  }}
                >
                  <option value="">템플릿 선택…</option>
                  {STYLE_TEMPLATES.map(t => (
                    <option key={t.label} value={t.label}>{t.label}</option>
                  ))}
                </select>
              </div>
              <textarea
                className="sdm-textarea"
                data-role="main-scene-global-modal-style-textarea"
                value={promptStyle}
                onChange={(e) => {
                  setPromptStyle(e.target.value);
                  updateGlobal({ promptStyle: e.target.value });
                }}
                rows={4}
                placeholder={"그림체·질감·조명·무드 등은 영문 키워드가 잘 맞습니다.\n예:\npastel watercolor illustration\nsoft edges, hand-painted feel\nsubtle film-like nostalgia\nwarm, gentle daylight\ncandid, intimate storytelling"}
              />
            </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"
                  data-role="main-scene-global-modal-reference-images-input"
                  accept="image/*"
                  multiple
                  style={{ display: "none" }}
                  onChange={handleImageUpload}
                />
                <button className="pill-btn" data-role="main-scene-global-modal-reference-upload-btn" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
                  <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" data-role="main-scene-global-modal-reference-remove-btn" onClick={() => removeImage(image.id)} title="reference 이미지 제거">
                      <Icon.X size={12}/>
                    </button>
                  </div>
                ))}
              </div>
            </div>
          </div>
        </div>
        <div className="sdm-actions" data-role="main-scene-global-modal-actions-panel">
          <button className="pill-btn" data-role="main-scene-global-modal-close-action-btn" onClick={onClose}>닫기</button>
          <button className="pill-btn primary" data-role="main-scene-global-modal-save-btn" onClick={saveAndClose} disabled={uploading}>
            <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),
    sceneGlobal,
  });
  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 [analysisBusy, setAnalysisBusy] = useState(false);
  const [draftAnalysisRequestPrompt, setDraftAnalysisRequestPrompt] = useState(() =>
    (SongfilmAIBroker?.buildSceneAnalysisPrompt
      ? SongfilmAIBroker.buildSceneAnalysisPrompt({
          songTitle: song.title || "Untitled",
          artist: album?.artist || "",
          sceneGlobal,
          scenes: [{
            ...scene,
            text: scene.analysisText || scene.text,
            ollamaLyricLine: String(scene.text || "").trim(),
          }],
          fullLyrics: getSongLyricsText(song),
        })
      : ""));

  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 || analysisBusy;

  const handleRunOllamaSceneAnalysis = async () => {
    if (!SongfilmAIBroker?.generateSceneSuggestionFromCustomPrompt) {
      window.toast("AI broker가 로드되지 않았습니다.");
      return;
    }
    const body = String(draftAnalysisRequestPrompt || "").trim();
    if (!body) {
      window.toast("Ollama 요청 프롬프트를 입력하세요.");
      return;
    }
    setAnalysisBusy(true);
    try {
      const { proposal, promptEn } = await SongfilmAIBroker.generateSceneSuggestionFromCustomPrompt(body);
      setDraftPrompt(proposal || scene.prompt || scene.text);
      setDraftPromptEn(promptEn);
      window.toast("영문 이미지 프롬프트를 생성했습니다.");
    } catch (err) {
      window.toast(err.message || String(err));
    } finally {
      setAnalysisBusy(false);
    }
  };

  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" data-role="main-scene-detail-modal-overlay" onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
      <div className="scene-detail-modal" data-role="main-scene-detail-modal-dialog" onClick={(e) => e.stopPropagation()}>

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

        {/* ── 스크롤 영역 ── */}
        <div className="sdm-scroll" data-role="main-scene-detail-modal-scroll-panel">

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

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

          <div className="sdm-fields" data-role="main-scene-detail-modal-fields-panel">

            {/* Ollama에 보내는 장면 분석 요청 전문 — 편집 가능 */}
            <div>
              <label className="sdm-field-label">
                Ollama 요청 프롬프트
                <span className="sdm-label-badge">영문 이미지용 prompt_en 생성 · /ai/generate</span>
              </label>
              <textarea
                className="sdm-textarea mono"
                data-role="main-scene-detail-modal-analysis-request-textarea"
                value={draftAnalysisRequestPrompt}
                onChange={(e) => setDraftAnalysisRequestPrompt(e.target.value)}
                rows={10}
                placeholder="Ollama에 보낼 요청 전문(평문 영문 프롬프트만 응답하도록 구성됨). 필요하면 수정하세요."
                disabled={isImageBusy || disabled}
              />
            </div>

            <div className="sdm-section">
              <button
                type="button"
                className="sdm-collapsible-btn"
                data-role="main-scene-detail-modal-generate-prompt-btn"
                onClick={handleRunOllamaSceneAnalysis}
                disabled={isImageBusy || disabled}
              >
                <span className="sdm-field-label" style={{ marginBottom: 0, cursor: "pointer" }}>
                  OLLAMA로 영문 이미지 프롬프트 생성
                  <span className="sdm-label-badge">위 프롬프트 전송 → 아래 영문란 채움</span>
                </span>
                <span className="sdm-toggle-icon">{analysisBusy ? "…" : "▶"}</span>
              </button>
            </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"
                data-role="main-scene-detail-modal-prompt-en-textarea"
                value={draftPromptEn}
                onChange={(e) => setDraftPromptEn(e.target.value)}
                rows={5}
                placeholder={useGoogleProvider ? "Google 사용 시 장면 가사와 전체 가사로 한국어 프롬프트를 자동 구성합니다" : "English image generation prompt"}
                disabled={isImageBusy || disabled}
              />
            </div>

            <div>
              <label className="sdm-field-label">
                장면 설명 (한국어)
                <span className="sdm-label-badge">OLLAMA proposal_ko · 저장 시 반영</span>
              </label>
              <textarea
                className="sdm-textarea"
                data-role="main-scene-detail-modal-prompt-ko-textarea"
                value={draftPrompt}
                onChange={(e) => setDraftPrompt(e.target.value)}
                rows={2}
                placeholder="OLLAMA 생성 후 채워지며, 필요하면 수정할 수 있습니다."
                disabled={isImageBusy || disabled}
              />
            </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" : ""}`}
                data-role="main-scene-detail-modal-google-provider-toggle-btn"
                onClick={() => setDraftImageProvider(useGoogleProvider ? "" : "google")}
                disabled={isImageBusy || disabled}
                style={{ width: "100%", textAlign: "left" }}
              >
                <span>Google 이미지 생성 사용</span>
                <small>켜면 영어 프롬프트 대신 현재 장면 가사와 전체 노래를 담은 한국어 프롬프트를 보내고, 요청에 provider: "google"을 추가합니다.</small>
              </button>
            </div>

            {useGoogleProvider && (
              <div>
                <div className="sdm-field-label-row">
                  <label className="sdm-field-label">
                    Google 이미지 생성 프롬프트
                    <span className="sdm-label-badge">수정 가능</span>
                  </label>
                  <button
                    className="sdm-clear-btn"
                    type="button"
                    data-role="main-scene-detail-modal-clear-google-prompt-btn"
                    title="Google 프롬프트 전체 지우기"
                    onClick={() => setDraftGoogleImagePrompt("")}
                    disabled={!draftGoogleImagePrompt}
                  >
                    <Icon.Trash size={12}/>
                  </button>
                </div>
                <textarea
                  className="sdm-textarea mono"
                  data-role="main-scene-detail-modal-google-prompt-textarea"
                  value={draftGoogleImagePrompt}
                  onChange={(e) => setDraftGoogleImagePrompt(e.target.value)}
                  rows={9}
                  placeholder={defaultGoogleImagePrompt}
                />
              </div>
            )}

          </div>
        </div>

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

      </div>
    </div>
  );
}

function interpretImageHealthPayload(payload) {
  if (!payload || typeof payload !== "object" || payload.parseFailed) {
    return { tier: "unavailable", label: "이미지 서버 상태를 해석할 수 없습니다." };
  }
  const statusLower = String(payload.status || "").toLowerCase();
  if (statusLower === "unknown" && payload.message) {
    return { tier: "unavailable", label: String(payload.message) };
  }
  const pipeErr = payload.pipe_error ?? payload.error;
  if (pipeErr != null && String(pipeErr).trim() !== "") {
    return { tier: "unavailable", label: "이미지 파이프라인 오류 — 생성 요청이 실패할 수 있습니다." };
  }
  if (payload.is_warmed_up === false) {
    return { tier: "warmup", label: "파이프라인이 워밍업 중입니다. 잠시 후 다시 시도해 주세요." };
  }
  if (/(warm|warming|loading|initializ|starting|busy)/.test(statusLower)) {
    return { tier: "warmup", label: "이미지 서버가 아직 준비 중입니다." };
  }
  if (payload.is_warmed_up === true) {
    return { tier: "ready", label: "이미지 서버 정상 (워밍업 완료)" };
  }
  if (/(ready|idle|ok|running)/.test(statusLower) && payload.is_warmed_up !== false) {
    return { tier: "ready", label: "이미지 서버 정상" };
  }
  if (/(error|fail|down|unavailable)/.test(statusLower)) {
    return { tier: "unavailable", label: "이미지 서버 상태가 비정상입니다." };
  }
  return { tier: "ready", label: "이미지 서버에 연결됨" };
}

function interpretImageHealthFetchResult(result) {
  if (!result) {
    return { tier: "unavailable", label: "상태 응답이 없습니다." };
  }
  if (result.fetchError === "timeout") {
    return { tier: "unavailable", label: "이미지 서버 health 확인이 시간 초과되었습니다." };
  }
  if (result.fetchError) {
    return { tier: "unavailable", label: `이미지 서버에 연결할 수 없습니다: ${result.fetchError}` };
  }
  const { ok, httpStatus, data } = result;
  if (!data || typeof data !== "object") {
    return { tier: "unavailable", label: "응답 본문을 읽을 수 없습니다." };
  }
  if (!ok && data.health && typeof data.health === "object" && !data.health.parseFailed) {
    const inner = interpretImageHealthPayload(data.health);
    const prefix = [data.error, data.message].filter(Boolean).join(": ");
    return {
      tier: inner.tier,
      label: prefix ? `${prefix} — ${inner.label}` : inner.label,
    };
  }
  if (!ok) {
    const label =
      data.message
      || data.error
      || (httpStatus === 504 ? "이미지 서버 health 시간 초과" : "")
      || `API 응답 ${httpStatus}`;
    return { tier: "unavailable", label: String(label) };
  }
  if (data.parseFailed) {
    return { tier: "unavailable", label: "health 응답이 올바른 JSON이 아닙니다." };
  }
  return interpretImageHealthPayload(data);
}

function ScenesBoardHeading({ tier, label }) {
  return (
    <div className="scene-board-intro-heading">
      <h2>장면 설계</h2>
      <span
        className="image-server-health-dot"
        data-tier={tier}
        title={label}
        role="status"
        aria-live="polite"
        aria-label={label}
      />
    </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 [pipelineLog, setPipelineLog] = useState([]);
  const [pipelineProgress, setPipelineProgress] = useState({ done: 0, total: 0 });
  const [imageHealth, setImageHealth] = useState({ tier: "loading", label: "이미지 서버 상태 확인 중…" });
  const autoRunTokensRef = useRef(new Set());
  const pipelineRunningRef = useRef(false);
  const cancelledRef = useRef(false);
  const runIdRef = useRef(0);
  const logEndRef = useRef(null);
  const sceneGlobal = getSceneGlobal(song);

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

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

  useEffect(() => {
    if (logEndRef.current) {
      logEndRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
    }
  }, [pipelineLog]);

  useEffect(() => {
    const broker = window.SongfilmAIBroker;
    if (!broker?.fetchImageHealth) {
      setImageHealth({ tier: "unavailable", label: "이미지 health API가 로드되지 않았습니다." });
      return undefined;
    }

    let intervalId;
    let cancelled = false;

    const run = async () => {
      if (typeof document !== "undefined" && document.visibilityState === "hidden") return;
      let result;
      try {
        result = await broker.fetchImageHealth();
      } catch (e) {
        result = { ok: false, httpStatus: 0, data: null, fetchError: String(e?.message || e) };
      }
      if (cancelled) return;
      setImageHealth(interpretImageHealthFetchResult(result));
    };

    run();
    intervalId = setInterval(run, 30000);

    const onVisibility = () => {
      if (document.visibilityState === "visible") run();
    };
    document.addEventListener("visibilitychange", onVisibility);

    return () => {
      cancelled = true;
      clearInterval(intervalId);
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  const changePalette = (name) => {
    setPalette(name);
    updateSong({ paletteName: name }, { markRenderDirty: true, dirtyReason: "preview-style" });
  };

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

  const toggleSceneExcluded = (sceneId) => {
    updateSong((currentSong) => ({
      scenes: currentSong.scenes.map((scene) =>
        scene.id === sceneId ? { ...scene, excluded: !scene.excluded } : scene
      ),
    }), { markRenderDirty: true, dirtyReason: "scene-structure" });
  };

  const buildAnalysisScenes = (includedTargets) => {
    const allScenes = song.scenes;
    return includedTargets.map((scene) => {
      const primaryLine = String(scene.text || "").trim();
      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);
      }
      const ollamaLyricLine = primaryLine;
      return extraTexts.length
        ? { ...scene, text: [primaryLine, ...extraTexts].join("\n"), ollamaLyricLine }
        : { ...scene, text: primaryLine, ollamaLyricLine };
    });
  };

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

  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 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 { runId } = options;
    const isCurrentRun = () => runId === undefined || runIdRef.current === runId;
    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,
    });
    if (!useGoogleProvider) {
      console.log("[Songfilm Pipeline] 2/2 이미지 API — generateImageForScene(기본)", {
        sceneId: scene.id,
        promptEnChars: String(promptEn || "").length,
        message: "곧 SongfilmAIBroker.generateSceneImage → buildImagePrompt(로컬: 영문 장면만 / Google: 공통 한글+영문)",
      });
    }

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

    let generated = null;
    try {
      const storyId = SongfilmSongStorage?.buildStoryId(song) || song.storyId || song.id;
      const globalForGoogle = getSceneGlobal(song);
      const googlePrompt = useGoogleProvider
        ? resolveGoogleImagePromptForApi({
            savedOverride: googleImagePrompt,
            sceneText: scene.analysisText || scene.text,
            lyrics: getSongLyricsText(song),
            sceneGlobal: globalForGoogle,
          })
        : "";
      generated = await SongfilmAIBroker.generateSceneImage({
        promptEn,
        seed: scene.imageSeed,
        sceneGlobal: globalForGoogle,
        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);
      if (!isCurrentRun()) return;
      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,
      }, { markRenderDirty: true, dirtyReason: "scene-image" });
      console.log('[ScenesStep] 이미지 생성 완료', { sceneId: scene.id, url: savedImage.url });
      if (!useGoogleProvider) {
        console.log("[Songfilm Pipeline] 2/2 이미지 API — 장면 이미지 저장 완료(기본)", { sceneId: scene.id });
      }
    } catch (error) {
      if (generated?.objectUrl) {
        SongfilmAIBroker.revokeObjectUrl(generated.objectUrl);
      }
      if (isCurrentRun()) {
        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",
      }, { markRenderDirty: true, dirtyReason: "scene-image" });
      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 myRunId = ++runIdRef.current;
    cancelledRef.current = false;

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

    const addLog = (text) => setPipelineLog(prev => [...prev, text]);

    pipelineRunningRef.current = true;
    console.log('[ScenesStep] runScenePipeline 시작', {
      trigger, refreshPrompts,
      total: targets.length,
      included: includedTargets.length,
      excluded: targets.length - includedTargets.length,
    });
    console.log("[Songfilm Pipeline] 개요", {
      trigger,
      refreshPrompts,
      selection: sceneIds == null ? "전체(포함 장면만)" : `지정 ${sceneIds.length}개`,
      stepsForNonGoogle: "① Ollama: 로컬 이미지용 영문 prompt_en만 생성 ② 로컬 이미지 API: prompt_en",
      stepsForGoogle: "① Ollama 장면 분석 생략 ② Google 이미지 API(가사·한글 공통 등)",
      includedScenes: includedTargets.map((s, idx) => ({
        n: idx + 1,
        id: s.id,
        provider: s.imageProvider === "google" ? "google" : "default",
      })),
    });

    setPipelineError("");
    setPipelineLog([]);
    setPipelineProgress({ done: 0, total: 0 });
    if (multiSceneRun) setGeneratingAll(true);

    try {
      const localsNeedingOllama = includedTargets.filter(
        (s) => s.imageProvider !== "google" && (refreshPrompts || !String(s.promptEn || "").trim()),
      );
      const googleSceneCount = includedTargets.filter((s) => s.imageProvider === "google").length;

      if (localsNeedingOllama.length > 0) {
        applySceneBatchPatch(localsNeedingOllama.map((s) => s.id), {
          analysisStatus: "loading",
          analysisError: "",
        });

        if (googleSceneCount > 0) {
          addLog(`Google 이미지 장면 ${googleSceneCount}개: 장면 분석(Ollama) 생략 (영문 프롬프트는 로컬 이미지 생성 전용).`);
        }

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

        addLog(`AI 장면 분석 시작 (로컬 이미지용 ${analysisScenes.length}개 장면, 장면별 1회씩 Ollama 호출)...`);

        try {
          suggestionMap = new Map();
          const lyricsText = getSongLyricsText(song);
          const sceneGlobalPayload = getSceneGlobal(song);
          console.log("[Songfilm Pipeline] 1/2 Ollama — 단계 시작(로컬 장면만, 순차)", {
            sceneCount: analysisScenes.length,
            fullLyricsChars: lyricsText.length,
            hasSceneGlobalCommonTagged: !!String(
              window.SongfilmAIBroker?.buildSceneGlobalCommonTagged?.(sceneGlobalPayload) || ""
            ).trim(),
          });
          for (let i = 0; i < analysisScenes.length; i++) {
            const one = analysisScenes[i];
            addLog(`  Ollama AI로 장면 분석 ${i + 1}/${analysisScenes.length}…`);
            console.log("[Songfilm Pipeline] 1/2 Ollama — 장면 요청", {
              index: `${i + 1}/${analysisScenes.length}`,
              sceneId: one.id,
              lyricLinePreview: (() => {
                const line = String((one.ollamaLyricLine ?? one.text) || "").trim();
                return `${line.slice(0, 80)}${line.length > 80 ? "…" : ""}`;
              })(),
            });
            const slice = await SongfilmAIBroker.generateSceneSuggestions({
              songTitle: song.title || "Untitled",
              artist: album?.artist || "",
              scenes: [one],
              sceneGlobal: sceneGlobalPayload,
              fullLyrics: lyricsText,
              onProgress: ({ type, msg }) => {
                if (type === 'log') addLog(msg);
              },
            });
            const suggestion = slice[0];
            if (!suggestion) {
              throw new Error(`장면 분석 결과가 비었습니다 (${one.id})`);
            }
            suggestionMap.set(suggestion.id, suggestion);
            applyScenePatch(one.id, {
              prompt: suggestion.prompt,
              promptEn: suggestion.promptEn,
              promptSource: "ai",
              analysisStatus: "done",
              analysisError: "",
              analysisText: analysisTextMap.get(one.id) || one.text,
            });
            console.log("[Songfilm Pipeline] 1/2 Ollama — 장면 응답 반영", {
              sceneId: one.id,
              promptEnChars: String(suggestion.promptEn || "").length,
              promptEnPreview: `${String(suggestion.promptEn || "").slice(0, 100)}${String(suggestion.promptEn || "").length > 100 ? "…" : ""}`,
            });
          }
          addLog(`장면 분석 완료 (${analysisScenes.length}개)`);
          console.log('[ScenesStep] 장면 분석 완료', { sceneCount: analysisScenes.length });
          console.log("[Songfilm Pipeline] 1/2 Ollama — 단계 종료(모든 장면 처리 시도 완료)");
        } catch (error) {
          firstError = error;
          applySceneBatchPatch(localsNeedingOllama.map((s) => s.id), (scene) => {
            if (suggestionMap.has(scene.id)) return null;
            return {
              analysisStatus: scene.promptEn ? "done" : "error",
              analysisError: error.message || String(error),
            };
          });
          addLog(`장면 분석 실패: ${error.message}`);
          console.error('[ScenesStep] 장면 분석 실패', { error: error.message });
        }
      } else {
        console.log("[Songfilm Pipeline] 1/2 Ollama — 생략(로컬 장면이 새 영문 프롬프트가 필요 없음)", {
          includedSceneIds: includedTargetIds,
          googleSceneCount,
        });
        if (googleSceneCount > 0) {
          addLog(`Google 이미지 장면 ${googleSceneCount}개: 장면 분석(Ollama) 생략 (영문 프롬프트는 로컬 이미지 생성 전용).`);
        }
      }

      // 이미지 생성은 순차적으로 처리 (서버 과부하 방지)
      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");

      setPipelineProgress({ done: 0, total: imageTargets.length });
      let doneCount = 0;

      console.log('[ScenesStep] 이미지 생성 시작', { total: imageTargets.length });
      console.log("[Songfilm Pipeline] 2/2 이미지 API — 단계 시작(순차)", {
        count: imageTargets.length,
        targets: imageTargets.map((s) => ({
          id: s.id,
          provider: s.imageProvider === "google" ? "google" : "default",
          hasPromptEn: !!s.promptEn,
        })),
      });
      for (const scene of imageTargets) {
        if (cancelledRef.current) {
          addLog('⏹ 사용자가 종료했습니다');
          break;
        }
        try {
          await generateImageForScene(scene, scene.promptEn, { runId: myRunId });
          doneCount++;
          setPipelineProgress({ done: doneCount, total: imageTargets.length });
          addLog(`이미지 [${doneCount}/${imageTargets.length}] "${shorten(scene.text, 24)}" ✓`);
        } catch (error) {
          if (!firstError) firstError = error;
          doneCount++;
          setPipelineProgress({ done: doneCount, total: imageTargets.length });
          addLog(`이미지 [${doneCount}/${imageTargets.length}] 오류: ${error.message}`);
        }
      }

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

      if (!cancelledRef.current && 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 },
      { markRenderDirty: true, dirtyReason: "lyrics-to-scenes" },
    );
    window.toast(`가사 기준으로 장면을 새로고침했습니다 · ${newScenes.length}개`);
  };

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

  if (!song.scenes.length) {
    return (
      <div className="scene-board" data-role="main-creator-scenes-step-panel">
        <div className="scene-board-header" data-role="main-creator-scenes-header-panel">
          <div>
            <div className="scene-board-intro" data-role="main-creator-scenes-intro-panel">
              <ScenesBoardHeading tier={imageHealth.tier} label={imageHealth.label} />
              <p>먼저 자막을 추출하면 문장 단위로 장면을 분석하고 이미지를 생성할 수 있습니다.</p>
            </div>
          </div>
          {song.lyricTimeline && (
            <button className="pill-btn" data-role="main-creator-scenes-refresh-from-lyrics-btn" onClick={refreshScenesFromLyrics} title="가사 편집기에서 수정한 내용을 장면 목록에 반영합니다">
              <Icon.Refresh/> <span className="scene-lyrics-refresh-label">가사 반영 새로고침</span>
            </button>
          )}
          <button className={`pill-btn ${hasSceneGlobalCommonContent(sceneGlobal) || sceneGlobal.referenceImages.length ? "accent" : ""}`} data-role="main-creator-scenes-open-global-settings-btn" 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 = hasSceneGlobalCommonContent(sceneGlobal);
  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" data-role="main-creator-scenes-step-panel">
      <div className="scene-board-header" data-role="main-creator-scenes-header-panel">
        <div>
          <div className="scene-board-intro" data-role="main-creator-scenes-intro-panel">
            <ScenesBoardHeading tier={imageHealth.tier} label={imageHealth.label} />
            <p>
              문장 단위 자막을 분석해 장면 제안과 이미지를 생성합니다.
              {excludedCount > 0
                ? ` · ${includedCount}개 포함 / ${excludedCount}개 제외`
                : ` · ${includedCount}개 전체 포함`}
            </p>
            <p style={{fontSize: 11, color:"var(--ink-4)", marginTop: 2}}>
              각 카드 하단의 토글로 분석 대상을 선택하세요. 제외된 장면은 이전 장면의 프롬프트에 가사가 병합됩니다.
            </p>
          </div>
          {(hasCommonPrompt || commonRefCount > 0) && (
            <p className="scene-common-status" 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 className="scene-board-actions" data-role="main-creator-scenes-actions-panel" style={{display:"flex", alignItems:"center", gap: 10}}>
          <button className={`pill-btn ${hasCommonPrompt || commonRefCount > 0 || useGlobalImage ? "accent" : ""}`} data-role="main-creator-scenes-open-global-settings-btn" onClick={() => setShowGlobalSettings(true)} disabled={anySceneLoading}>
            <Icon.Cog/> <span className="scene-common-settings-label-full">공통 설정</span><span className="scene-common-settings-label-short">공통</span>
          </button>
          <div className="scene-palette-box" data-role="main-creator-scenes-palette-panel" style={{display:"flex", alignItems:"center", gap:8, padding:"6px 10px", background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 8}}>
            <span className="scene-palette-label" style={{fontSize: 11, color:"var(--ink-3)"}}>팔레트</span>
            <div className="swatch-row">
              {Object.keys(PALETTES).map(p => (
                <div key={p}
                  data-role="main-creator-scenes-palette-swatch"
                  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" data-role="main-creator-scenes-refresh-from-lyrics-btn" onClick={refreshScenesFromLyrics} disabled={anySceneLoading || !song.lyricTimeline} title="가사 편집기에서 수정한 내용을 장면 목록에 반영합니다">
            <Icon.Refresh/> <span className="scene-lyrics-refresh-label">가사 반영 새로고침</span>
          </button>
          <button className="pill-btn" data-role="main-creator-scenes-regenerate-all-btn" onClick={regenerateAll} disabled={generatingAll || anySceneLoading}>
            <Icon.Refresh/>{" "}
            {excludedCount > 0 ? (
              `선택 장면 재생성 (${includedCount})`
            ) : (
              <>
                <span className="scene-regenerate-label-full">전체 재생성</span>
                <span className="scene-regenerate-label-short">전체</span>
              </>
            )}
          </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, { markRenderDirty: true, dirtyReason: "scene-detail" })}
          onRegenerate={(promptEn, imageProvider, googleImagePrompt) => regenerateFromModal(selectedScene.id, promptEn, imageProvider, googleImagePrompt)}
          onUploadImage={(file) => uploadImageForScene(selectedScene, file)}
          disabled={generatingAll}
        />
      )}

      {generatingAll && (
        <div className="pipeline-overlay" data-role="main-creator-scenes-pipeline-overlay">
          <div className="pipeline-overlay-header" data-role="main-creator-scenes-pipeline-header">
            <span className="pipeline-overlay-title">장면 생성 중...</span>
            <div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
              {pipelineProgress.total > 0 && (
                <span className="pipeline-overlay-counter">
                  이미지 {pipelineProgress.done} / {pipelineProgress.total}
                </span>
              )}
              <button
                className="pill-btn danger"
                data-role="main-creator-scenes-pipeline-stop-btn"
                style={{ height: 26, fontSize: 11 }}
                onClick={() => { cancelledRef.current = true; }}
              >
                ⏹ 종료
              </button>
            </div>
          </div>
          {pipelineProgress.total > 0 && (
            <div className="pipeline-progress-track" data-role="main-creator-scenes-pipeline-progress-track">
              <div
                className="pipeline-progress-fill"
                data-role="main-creator-scenes-pipeline-progress-bar"
                style={{ width: `${(pipelineProgress.done / pipelineProgress.total) * 100}%` }}
              />
            </div>
          )}
          <div className="pipeline-log-area" data-role="main-creator-scenes-pipeline-log-area">
            {pipelineLog.map((line, i) => (
              <div key={i} className="pipeline-log-line" data-role="main-creator-scenes-pipeline-log-line">{line}</div>
            ))}
            <div ref={logEndRef} />
          </div>
        </div>
      )}

      <div className="scenes-grid" data-role="main-creator-scenes-grid-panel">
        {song.scenes.map((s, i) => {
          const isExcluded = s.excluded === true;
          const cardLoading = !isExcluded && (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} data-role="main-creator-scenes-card" className={`scene-card${cardLoading ? " loading" : ""}${isExcluded ? " excluded" : ""} scene-card-clickable`} onClick={() => setSelectedSceneId(s.id)}>
              <div className="art" data-role="main-creator-scenes-card-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" data-role="main-creator-scenes-card-meta">
                <div className="lyric" data-role="main-creator-scenes-card-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"
                    data-role="main-creator-scenes-toggle-include-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"
                        data-role="main-creator-scenes-regenerate-one-btn"
                        style={{height: 24, fontSize: 11}}
                        onClick={(e) => { e.stopPropagation(); regenerateOne(s.id); }}
                        disabled={cardLoading}
                      >
                        <Icon.Refresh size={11}/> 재생성
                      </button>
                      {s.imageProvider === "google" && (
                        <span
                          className="scene-google-provider-badge"
                          title="Google 이미지 생성"
                          aria-label="Google 이미지 생성"
                          onClick={(e) => e.stopPropagation()}
                        >
                          G
                        </span>
                      )}
                      <button
                        className="pill-btn"
                        data-role="main-creator-scenes-upload-image-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 });
