// Preview (with timeline + playing video-ish) and Export steps — split from creator.jsx for length

function estimatePreviewWords(text, start, end, prefix = "line") {
  const tokens = String(text || "").trim().split(/\s+/).filter(Boolean);
  if (tokens.length === 0) return [];

  const safeStart = Number.isFinite(start) ? start : 0;
  const baseEnd = Math.max(safeStart, Number.isFinite(end) ? end : safeStart);
  const resolvedEnd = Math.max(baseEnd, safeStart + tokens.length * 0.08);
  const duration = resolvedEnd - safeStart;

  return tokens.map((token, index) => {
    const wordStart = safeStart + (duration * index) / tokens.length;
    const nextStart = safeStart + (duration * (index + 1)) / tokens.length;
    return {
      id: `${prefix}-word-${index + 1}`,
      text: token,
      start: wordStart,
      end: index === tokens.length - 1 ? resolvedEnd : nextStart,
    };
  });
}

function collectPreviewSubtitleLines(song) {
  const lines = [];

  function pushLine(rawLine, sentenceId = "", fallbackId = "") {
    if (!rawLine || rawLine.kind === "comment") return;
    const text = String(rawLine.text || "").trim();
    if (!text) return;

    const start = Number(rawLine.start_ms || 0) / 1000;
    const end = Number(rawLine.end_ms || 0) / 1000;
    const id = rawLine.id || fallbackId || `line-${lines.length + 1}`;
    const words = (rawLine.children || [])
      .filter((child) => child?.type === "word" && String(child.text || "").trim())
      .map((word, index) => ({
        id: word.id || `${id}-word-${index + 1}`,
        text: String(word.text || "").trim(),
        start: Number(word.start_ms || 0) / 1000,
        end: Number(word.end_ms || 0) / 1000,
      }));
    const normalizedWords = words.length ? words : estimatePreviewWords(text, start, end, id);
    const resolvedEnd = normalizedWords[normalizedWords.length - 1]?.end ?? Math.max(start, end);

    lines.push({
      id,
      sentenceId,
      text,
      start,
      end: Math.max(start, end, resolvedEnd),
      words: normalizedWords,
    });
  }

  function walk(node, sentenceId = "") {
    if (!node) return;
    const nextSentenceId = node.type === "sentence" ? (node.id || sentenceId) : sentenceId;
    if (node.type === "line") {
      pushLine(node, nextSentenceId, `${nextSentenceId || "sentence"}-line-${lines.length + 1}`);
      return;
    }
    (node.children || []).forEach((child) => walk(child, nextSentenceId));
  }

  if (song?.lyricTimeline?.root) {
    walk(song.lyricTimeline.root, "");
  }

  if (lines.length > 0) {
    return lines.sort((left, right) => left.start - right.start || left.end - right.end);
  }

  return (song?.cues || [])
    .map((cue, index) => {
      const text = String(cue?.text || "").trim();
      if (!text) return null;
      const start = Number(cue.start || 0);
      const end = Number(cue.end || 0);
      const id = cue.id || cue.sentenceId || `cue-line-${index + 1}`;
      const words = estimatePreviewWords(text, start, end, id);
      return {
        id,
        sentenceId: cue.sentenceId || cue.id || "",
        text,
        start,
        end: Math.max(start, end, words[words.length - 1]?.end || 0),
        words,
      };
    })
    .filter(Boolean);
}

function getPreviewSubtitleIndex(lines, currentTime) {
  if (!Array.isArray(lines) || lines.length === 0) return -1;
  if (currentTime < lines[0].start) return -1;

  for (let index = lines.length - 1; index >= 0; index -= 1) {
    if (currentTime >= lines[index].start) return index;
  }

  return -1;
}

function getActivePreviewWordId(line, currentTime) {
  if (!line || !Array.isArray(line.words) || line.words.length === 0) return "";
  if (currentTime < line.start || currentTime > line.end) return "";

  for (let index = 0; index < line.words.length; index += 1) {
    const word = line.words[index];
    const nextWordStart = line.words[index + 1]?.start;
    const effectiveEnd = Number.isFinite(nextWordStart)
      ? nextWordStart
      : Math.max(line.end, word.end, word.start);
    if (currentTime >= word.start && currentTime < effectiveEnd) {
      return word.id;
    }
  }

  return line.words[line.words.length - 1]?.id || "";
}

function SubtitleLineText({ line, activeWordId = "", highlightWord = false }) {
  if (!line) return null;
  const words = Array.isArray(line.words) && line.words.length > 0
    ? line.words
    : estimatePreviewWords(line.text, line.start, line.end, line.id);

  return (
    <>
      {words.map((word, index) => (
        <React.Fragment key={word.id || `${line.id}-word-${index + 1}`}>
          <span className={`subtitle-word ${highlightWord && activeWordId === word.id ? "is-active" : ""}`}>
            {word.text}
          </span>
          {index < words.length - 1 && <span className="subtitle-gap" aria-hidden="true"> </span>}
        </React.Fragment>
      ))}
    </>
  );
}

// ─── Preview ────────────────────────────────────────────
function PreviewStep({ song, updateSong }) {
  const { Icon, SceneImage, fmtTimeMs, fmtTime } = window;
  const subtitleLines = useMemo(() => collectPreviewSubtitleLines(song), [song.lyricTimeline, song.cues, song.id]);
  const totalDur = Math.max(
    Number(song.duration || 0),
    Number(song.cues[song.cues.length - 1]?.end || 0),
    Number(subtitleLines[subtitleLines.length - 1]?.end || 0),
    Number(song.scenes[song.scenes.length - 1]?.end || 0),
  );
  const audioRef = useRef(null);
  const { audioUrl } = useSongAudioSource(song);
  const [playing, setPlaying] = useState(false);
  const [t, setT] = useState(0);
  const [fontSize, setFontSize] = useState(song.previewSettings?.fontSize || 36);
  const [subPos, setSubPos] = useState(song.previewSettings?.subPos || "bottom");
  const [transition, setTransition] = useState(song.previewSettings?.transition || "fade");

  useEffect(() => {
    setFontSize(song.previewSettings?.fontSize || 36);
    setSubPos(song.previewSettings?.subPos || "bottom");
    setTransition(song.previewSettings?.transition || "fade");
    setPlaying(false);
    setT(0);
  }, [song.id]);

  useEffect(() => {
    const current = song.previewSettings || {};
    if (
      current.fontSize === fontSize &&
      current.subPos === subPos &&
      current.transition === transition
    ) {
      return;
    }
    updateSong({
      workflowStep: "preview",
      previewSettings: {
        fontSize,
        subPos,
        transition,
      },
    });
  }, [fontSize, subPos, transition]);

  useEffect(() => {
    if (audioUrl) return;
    if (!playing) return;
    let rafId; let last = performance.now();
    const tick = (now) => {
      setT(prev => {
        const next = prev + (now - last) / 1000;
        last = now;
        if (next >= totalDur) { setPlaying(false); return 0; }
        return next;
      });
      rafId = requestAnimationFrame(tick);
    };
    rafId = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(rafId);
  }, [playing, totalDur, audioUrl]);

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

  useEffect(() => {
    const audio = audioRef.current;
    if (!audio || !audioUrl) return;
    if (playing) {
      const playPromise = audio.play();
      if (playPromise && playPromise.catch) {
        playPromise.catch(() => setPlaying(false));
      }
      return;
    }
    audio.pause();
  }, [playing, audioUrl]);

  useEffect(() => {
    if (!audioUrl && playing) {
      setPlaying(false);
    }
  }, [audioUrl, playing]);

  // find active subtitle line + scene
  const activeSubtitleIndex = useMemo(() => getPreviewSubtitleIndex(subtitleLines, t), [subtitleLines, t]);
  const activeSubtitleLine = activeSubtitleIndex >= 0 ? subtitleLines[activeSubtitleIndex] : null;
  const activeWordId = useMemo(
    () => getActivePreviewWordId(activeSubtitleLine, t),
    [activeSubtitleLine, t],
  );
  const activeWord = useMemo(
    () => activeSubtitleLine?.words?.find((word) => word.id === activeWordId) || null,
    [activeSubtitleLine, activeWordId],
  );
  const subtitleWindow = useMemo(() => {
    if (!activeSubtitleLine || activeSubtitleIndex < 0) return [];
    return [
      activeSubtitleIndex - 1,
      activeSubtitleIndex,
      activeSubtitleIndex + 1,
    ]
      .map((index) => ({
        line: subtitleLines[index] || null,
        slot: index - activeSubtitleIndex,
      }))
      .filter((item) => item.line);
  }, [subtitleLines, activeSubtitleIndex, activeSubtitleLine]);
  const activeSceneIdx = song.scenes.findIndex(sc => t >= sc.start && t < sc.end);
  const currentScene = song.scenes[Math.max(0, activeSceneIdx)];
  const nextScene = song.scenes[activeSceneIdx + 1];
  const sceneGlobal = song.sceneGlobal || {};
  const useGlobalImage = sceneGlobal.imageMode === "global" && !!sceneGlobal.imageUrl;
  const getPreviewScene = (scene) => (
    useGlobalImage && scene
      ? { ...scene, imageUrl: sceneGlobal.imageUrl, imageSeed: sceneGlobal.imageSeed }
      : scene
  );
  const currentPreviewScene = getPreviewScene(currentScene);

  // 미세한 이미지 모션으로 정적인 장면에 숨을 준다.
  const sceneProgress = currentScene
    ? Math.max(0, Math.min(1, (t - currentScene.start) / Math.max(0.001, currentScene.end - currentScene.start)))
    : 0;
  const smoothProgress = sceneProgress * sceneProgress * (3 - 2 * sceneProgress);
  const motionSeed = Number(currentPreviewScene?.imageSeed || 1) + Math.max(0, activeSceneIdx) * 97;
  const motionRand = (n) => {
    const x = Math.sin(motionSeed * 31 + n * 17) * 10000;
    return x - Math.floor(x);
  };
  const motionAmount = transition === "kenburns" ? 0.075 : 0.045;
  const startX = (motionRand(1) - 0.5) * 1.2;
  const endX = (motionRand(2) - 0.5) * 1.2;
  const startY = (motionRand(3) - 0.5) * 0.9;
  const endY = (motionRand(4) - 0.5) * 0.9;
  const panX = startX + (endX - startX) * smoothProgress;
  const panY = startY + (endY - startY) * smoothProgress;
  const imageMotionStyle = currentPreviewScene?.imageUrl
    ? {
        transform: `translate3d(${panX}%, ${panY}%, 0) scale(${1.012 + smoothProgress * motionAmount})`,
        transformOrigin: `${35 + motionRand(5) * 30}% ${35 + motionRand(6) * 30}%`,
        transition: "transform 100ms linear",
      }
    : {};
  const progressPct = totalDur > 0 ? Math.max(0, Math.min(100, (t / totalDur) * 100)) : 0;

  const scrub = (e) => {
    if (totalDur <= 0) return;
    const rect = e.currentTarget.getBoundingClientRect();
    const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
    const nextTime = pct * totalDur;
    setT(nextTime);
    const audio = audioRef.current;
    if (audio && audioUrl) {
      audio.currentTime = nextTime;
    }
  };

  const subStyle = {
    bottom: { bottom: "14%" },
    center: { top: "50%", transform: "translateY(-50%)" },
    top: { top: "14%" },
  }[subPos];

  return (
    <div className="preview-shell">
      {audioUrl && <audio ref={audioRef} src={audioUrl} preload="auto" style={{display:"none"}}/>}
      <div>
        <div className="preview-stage">
          {/* Image layer with transition */}
          {currentScene && (
            <div key={currentScene.id + transition} style={{
              position:"absolute", inset:0,
              animation: transition === "fade" ? "fade 600ms ease-out" : "none",
              ...imageMotionStyle,
            }}>
              <SceneImage scene={currentPreviewScene} paletteName={song.paletteName || "midnight-violet"}/>
            </div>
          )}

          {/* Subtitle overlay */}
          {activeSubtitleLine && (
            <div
              className="video-subtitle lines-mode"
              style={{
                ...subStyle,
                position: "absolute",
                "--subtitle-font-size": `${fontSize}px`,
                "--subtitle-line-step": `${Math.max(44, Math.round(fontSize * 1.45))}px`,
              }}
            >
              <div className="subtitle-stack">
                {subtitleWindow.map(({ line, slot }) => (
                  <div
                    key={line.id}
                    className={`subtitle-line ${slot === 0 ? "is-current" : slot < 0 ? "is-prev" : "is-next"}`}
                  >
                    <SubtitleLineText
                      line={line}
                      activeWordId={activeWordId}
                      highlightWord={slot === 0}
                    />
                  </div>
                ))}
              </div>
            </div>
          )}

          {/* Scene number chip */}
          <div style={{position:"absolute", top: 16, right: 16, padding:"4px 10px", background:"rgba(0,0,0,0.5)", backdropFilter:"blur(6px)", borderRadius: 6, fontFamily:"var(--font-mono)", fontSize: 11, color:"rgba(255,255,255,0.8)"}}>
            씬 {Math.max(0, activeSceneIdx)+1}/{song.scenes.length}{useGlobalImage ? " · 전체 이미지" : ""}
          </div>

          {/* Play button overlay when paused */}
          {!playing && (
            <button
              onClick={() => {
                if (!audioUrl && totalDur <= 0) return;
                setPlaying(true);
              }}
              style={{
                position:"absolute", inset:0, display:"flex", alignItems:"center", justifyContent:"center",
                background: t === 0 ? "rgba(0,0,0,0.3)" : "transparent",
                color:"#fff", transition:"background 160ms",
              }}
            >
              <div style={{
                width: 64, height: 64, borderRadius:"50%",
                background:"rgba(255,255,255,0.9)", color:"var(--bg)",
                display:"flex", alignItems:"center", justifyContent:"center",
                boxShadow:"0 10px 40px rgba(0,0,0,0.5)",
              }}>
                <Icon.Play size={24}/>
              </div>
            </button>
          )}
        </div>

        {/* Controls */}
        <div className="preview-controls">
          <button className="play-circle-btn" onClick={() => setPlaying(p => !p)} disabled={!audioUrl && totalDur <= 0}>
            {playing ? <Icon.Pause size={14}/> : <Icon.Play size={14}/>}
          </button>
          <div className="preview-time mono">{fmtTimeMs(t).slice(0,8)} / {fmtTimeMs(totalDur).slice(0,8)}</div>
          <div className="preview-scrub" onClick={scrub}>
            <div className="fill" style={{width: `${progressPct}%`}}/>
            {/* scene markers */}
            {song.scenes.map((s, i) => (
              <div
                key={i}
                style={{
                  position: "absolute",
                  left: `${totalDur > 0 ? (s.start / totalDur) * 100 : 0}%`,
                  top: -2,
                  width: 2,
                  height: 8,
                  background: "var(--ink-3)",
                }}
              />
            ))}
          </div>
        </div>

        {/* Timeline */}
        <div style={{marginTop: 16, background:"var(--bg-1)", border:"1px solid var(--line)", borderRadius: 12, padding: 14}}>
          <div style={{fontSize: 11, color:"var(--ink-3)", textTransform:"uppercase", letterSpacing:"0.06em", fontWeight:600, marginBottom: 10}}>
            타임라인
          </div>
          <div style={{display:"flex", gap: 3, height: 48, position:"relative"}}>
            {song.scenes.map((s, i) => {
              const w = totalDur > 0 ? ((s.end - s.start) / totalDur) * 100 : 0;
              const isCurrent = i === Math.max(0, activeSceneIdx);
              return (
                <div key={s.id}
                  onClick={() => {
                    setT(s.start);
                    const audio = audioRef.current;
                    if (audio && audioUrl) {
                      audio.currentTime = s.start;
                    }
                  }}
                  style={{
                    width: `${w}%`, height:"100%",
                    borderRadius: 4, overflow:"hidden", position:"relative",
                    border: isCurrent ? "1.5px solid var(--accent)" : "1px solid var(--line)",
                    cursor:"pointer",
                  }}
                >
                  <SceneImage scene={getPreviewScene(s)} paletteName={song.paletteName || "midnight-violet"}/>
                  <div style={{position:"absolute",left:3,top:2,fontFamily:"var(--font-mono)",fontSize:9,color:"#fff",textShadow:"0 1px 2px rgba(0,0,0,0.8)"}}>{i+1}</div>
                </div>
              );
            })}
            {/* playhead */}
            <div
              style={{
                position: "absolute",
                left: `${progressPct}%`,
                top: -4,
                width: 2,
                height: "calc(100% + 8px)",
                background: "var(--accent)",
                boxShadow: "0 0 8px var(--accent)",
              }}
            />
          </div>
        </div>
      </div>

      {/* Right column — subtitle settings */}
      <div>
        <div className="side-card">
          <h3>자막 설정</h3>
          <div className="tweak-row">
            <span className="label">크기</span>
            <input type="range" min="20" max="60" value={fontSize} onChange={e => setFontSize(+e.target.value)} style={{width: 140}}/>
          </div>
          <div className="tweak-row">
            <span className="label">위치</span>
            <div className="seg">
              {["top","center","bottom"].map(p => (
                <button key={p} className={subPos === p ? "active" : ""} onClick={() => setSubPos(p)}>{p === "top" ? "상단" : p === "center" ? "중앙" : "하단"}</button>
              ))}
            </div>
          </div>
          <div className="tweak-row">
            <span className="label">전환</span>
            <div className="seg">
              {["cut","fade","kenburns"].map(p => (
                <button key={p} className={transition === p ? "active" : ""} onClick={() => setTransition(p)}>
                  {p === "cut" ? "컷" : p === "fade" ? "페이드" : "켄번즈"}
                </button>
              ))}
            </div>
          </div>
        </div>

        <div className="side-card" style={{marginTop: 12}}>
          <h3>현재 자막</h3>
          {activeSubtitleLine ? (
            <div>
              <div className="mono" style={{fontSize: 11, color:"var(--ink-3)", marginBottom: 6}}>
                {fmtTimeMs(activeSubtitleLine.start).slice(0,8)} → {fmtTimeMs(activeSubtitleLine.end).slice(0,8)}
              </div>
              <div style={{fontSize: 14, color:"var(--ink)"}}>{activeSubtitleLine.text}</div>
              <div style={{marginTop: 8, fontSize: 12, color:"var(--ink-3)"}}>
                현재 단어: <span style={{color:"var(--accent)", fontWeight: 600}}>{activeWord?.text || "대기 중"}</span>
              </div>
            </div>
          ) : (
            <div style={{color:"var(--ink-3)", fontSize: 12}}>재생하거나 타임라인을 클릭하세요</div>
          )}
        </div>

        <div className="side-card" style={{marginTop: 12, background:"var(--bg-2)"}}>
          <h3>렌더 세팅</h3>
          <div className="stat-row"><span className="k">해상도</span><span className="v">1920 × 1080</span></div>
          <div className="stat-row"><span className="k">프레임레이트</span><span className="v">30fps</span></div>
          <div className="stat-row"><span className="k">코덱</span><span className="v">H.264</span></div>
          <div className="stat-row"><span className="k">엔진</span><span className="v">Remotion 4</span></div>
          <div className="stat-row"><span className="k">오디오</span><span className="v">{audioUrl ? "복구됨" : "없음"}</span></div>
        </div>
      </div>
    </div>
  );
}

// ─── Export ────────────────────────────────────────────
const RENDERER_BASE = window.SongfilmApiConfig.getBase("remotion");
const STORAGE_BASE  = window.SongfilmApiConfig.getBase("storage");
const RENDER_ACTIVE_STATUSES = new Set(["pending", "bundling", "rendering", "uploading"]);
const RENDER_SUBMITTED_STATUS = "submitted";
const RENDER_TERMINAL_STATUSES = new Set(["done", "error", "canceled", "stale"]);
const RENDER_RECONNECT_STATUSES = new Set([
  RENDER_SUBMITTED_STATUS,
  "pending",
  "bundling",
  "rendering",
  "uploading",
  "canceling",
]);

// ── 다운로드 헬퍼 ──────────────────────────────────────────────────
function fmtBytes(n) {
  if (!n) return '—';
  if (n < 1024) return n + ' B';
  if (n < 1048576) return (n / 1024).toFixed(1) + ' KB';
  return (n / 1048576).toFixed(1) + ' MB';
}

function triggerBlobDownload(blob, filename) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url; a.download = filename; a.click();
  setTimeout(() => URL.revokeObjectURL(url), 2000);
}

function loadJSZip() {
  if (window.JSZip) return Promise.resolve(window.JSZip);
  return new Promise((resolve, reject) => {
    const s = document.createElement('script');
    s.src = 'https://cdn.jsdelivr.net/npm/jszip@3.10.1/dist/jszip.min.js';
    s.onload = () => resolve(window.JSZip);
    s.onerror = reject;
    document.head.appendChild(s);
  });
}

function getRenderedVideoUrl(song) {
  const videoFilename = String(song?.exportState?.videoFilename || '').trim();
  if (videoFilename) {
    return window.SongfilmApiConfig?.buildMediaUrl
      ? window.SongfilmApiConfig.buildMediaUrl("video", videoFilename)
      : `${STORAGE_BASE}/video/${encodeURIComponent(videoFilename)}`;
  }
  const jobId = String(song?.exportState?.jobId || '').trim();
  if (jobId) {
    return `${RENDERER_BASE}/render/${jobId}/file`;
  }
  return '';
}

function isRenderReconnectable(exportState) {
  const jobId = String(exportState?.jobId || '').trim();
  if (!jobId) return false;
  const status = String(exportState?.status || RENDER_SUBMITTED_STATUS);
  return RENDER_RECONNECT_STATUSES.has(status) || !RENDER_TERMINAL_STATUSES.has(status);
}

function getRenderStatusMessage(status, exportState = {}) {
  if (status === "done") return "렌더링 완료";
  if (status === "error") return "렌더링 실패: " + (exportState.error || "알 수 없는 오류");
  if (status === "canceled") return "렌더링이 취소되었습니다.";
  if (status === "stale") return exportState.error || "렌더링 job 상태를 찾을 수 없습니다. 다시 렌더링할 수 있습니다.";
  if (status === RENDER_SUBMITTED_STATUS) return "렌더링 요청 제출됨…";
  if (status === "pending") return "렌더링 대기 중…";
  if (status === "bundling") return "webpack 번들링 중…";
  if (status === "rendering") {
    return `프레임 렌더링 중… ${exportState.framesRendered || 0}/${exportState.totalFrames || 0}`;
  }
  if (status === "uploading") return "스토리지에 업로드 중…";
  return "";
}

function formatRenderedAt(value) {
  if (!value) return "";
  const date = new Date(value);
  if (Number.isNaN(date.getTime())) return "";
  const month = date.getMonth() + 1;
  const day = date.getDate();
  const hour = String(date.getHours()).padStart(2, "0");
  const minute = String(date.getMinutes()).padStart(2, "0");
  return `${month}/${day} ${hour}:${minute}`;
}

function ExportStep({ song, album, updateSong }) {
  const { Icon, fmtTime } = window;
  const { audioUrl } = useSongAudioSource(song);
  const initialExportStatus = song.exportState?.status || "idle";
  const [rendering, setRendering] = useState(
    isRenderReconnectable(song.exportState)
  );
  const [statusMsg, setStatusMsg] = useState(() => {
    if (isRenderReconnectable(song.exportState)) {
      return "렌더링 상태 확인 중…";
    }
    return getRenderStatusMessage(initialExportStatus, song.exportState);
  });
  const [progress, setProgress] = useState(Number(song.exportState?.progress || 0));
  const [done, setDone] = useState(song.exportState?.status === "done");
  const [jobId, setJobId] = useState(song.exportState?.jobId || null);
  const [dlLoading, setDlLoading] = useState({});   // 개별 버튼 로딩
  const [zipBuilding, setZipBuilding] = useState(false);
  const pollRef = useRef(null);
  const renderedAtLabel = formatRenderedAt(song.exportState?.renderedAt);

  const setDlState = (key, v) => setDlLoading(p => ({ ...p, [key]: v }));

  // ── 가사 파일 크기 (lyricTimeline JSON) ──────────────────────────
  const lyricJsonSize = useMemo(() => {
    if (!song.lyricTimeline) return 0;
    return new Blob([JSON.stringify(song.lyricTimeline)]).size;
  }, [song.lyricTimeline]);

  const loadAudioDownloadBlob = async () => {
    const storedFilename = String(song.exportState?.audioFilename || '').trim();
    if (storedFilename) {
      const resp = await fetch(`${STORAGE_BASE}/audio/${encodeURIComponent(storedFilename)}`);
      if (!resp.ok) {
        throw new Error(`오디오 다운로드 실패 (${resp.status})`);
      }
      return await resp.blob();
    }

    const serverAudioUrl = String(song.audioServerUrl || '').trim();
    if (serverAudioUrl) {
      const audioUrl = window.SongfilmApiConfig?.buildMediaUrl
        ? window.SongfilmApiConfig.buildMediaUrl("audio", serverAudioUrl)
        : serverAudioUrl;
      const resp = await fetch(audioUrl);
      if (!resp.ok) {
        throw new Error(`오디오 다운로드 실패 (${resp.status})`);
      }
      return await resp.blob();
    }

    const currentAudioUrl = String(audioUrl || '').trim();
    if (currentAudioUrl) {
      const resp = await fetch(currentAudioUrl);
      if (!resp.ok) {
        throw new Error(`오디오 다운로드 실패 (${resp.status})`);
      }
      return await resp.blob();
    }

    const snap = await window.SongfilmSongStorage.exportSongSnapshot(song);
    const dataUrl = snap?.song?.audio?.dataUrl;
    if (!dataUrl) throw new Error('오디오 데이터 없음');
    return window.SongfilmSongStorage.dataUrlToBlob(dataUrl);
  };

  // ── 개별 다운로드 ─────────────────────────────────────────────────
  const dlMp3 = async () => {
    setDlState('mp3', true);
    try {
      const blob = await loadAudioDownloadBlob();
      triggerBlobDownload(blob, song.filename || `${song.title}.mp3`);
    } catch (e) {
      console.error('[Export] MP3 다운로드 실패:', e);
      window.toast('MP3 다운로드 실패: ' + e.message);
    } finally { setDlState('mp3', false); }
  };

  const dlMp4 = async () => {
    setDlState('mp4', true);
    try {
      const videoFilename = song.exportState?.videoFilename;
      let resp;
      if (videoFilename) {
        resp = await fetch(window.SongfilmApiConfig?.buildMediaUrl
          ? window.SongfilmApiConfig.buildMediaUrl("video", videoFilename)
          : `${STORAGE_BASE}/video/${encodeURIComponent(videoFilename)}`);
      } else {
        const id = jobId || song.exportState?.jobId;
        if (!id) throw new Error('렌더링된 비디오가 없습니다.');
        resp = await fetch(`${RENDERER_BASE}/render/${id}/file`);
      }
      if (!resp.ok) {
        throw new Error(`비디오 다운로드 실패 (${resp.status})`);
      }
      const blob = await resp.blob();
      triggerBlobDownload(blob, `${song.title}.mp4`);
    } catch (e) {
      console.error('[Export] MP4 다운로드 실패:', e);
      window.toast('Video 다운로드 실패: ' + e.message);
    } finally {
      setDlState('mp4', false);
    }
  };

  const dlLyric = () => {
    if (!song.lyricTimeline) { window.toast('가사 데이터가 없습니다.'); return; }
    const json = JSON.stringify(song.lyricTimeline, null, 2);
    const filename = (song.filename || song.title).replace(/\.[^.]+$/, '') + '.lyric-timeline.json';
    triggerBlobDownload(new Blob([json], { type: 'application/json' }), filename);
  };

  const dlSongJson = async () => {
    const snapshot = await window.SongfilmSongStorage.exportSongSnapshot(song);
    const json = JSON.stringify(snapshot, null, 2);
    triggerBlobDownload(
      new Blob([json], { type: 'application/json' }),
      `${song.title}.songfilm.json`
    );
  };

  const dlZip = async () => {
    setZipBuilding(true);
    try {
      const JSZip = await loadJSZip();
      const zip = new JSZip();
      const base = song.filename ? song.filename.replace(/\.[^.]+$/, '') : song.title;

      // MP3 — storage-api 우선, 없으면 IndexedDB 폴백
      try {
        zip.file(song.filename || `${base}.mp3`, await loadAudioDownloadBlob());
      } catch (e) {
        console.warn('[Export] ZIP: 오디오 fetch 실패', e);
      }

      // MP4 — storage-api 우선, 없으면 렌더 서버 폴백
      const storedVideo = song.exportState?.videoFilename;
      if (storedVideo && done) {
        try {
          const resp = await fetch(window.SongfilmApiConfig?.buildMediaUrl
            ? window.SongfilmApiConfig.buildMediaUrl("video", storedVideo)
            : `${STORAGE_BASE}/video/${encodeURIComponent(storedVideo)}`);
          if (resp.ok) zip.file(`${base}.mp4`, await resp.blob());
        } catch (e) { console.warn('[Export] ZIP: 비디오 fetch 실패', e); }
      } else {
        const id = jobId || song.exportState?.jobId;
        if (id && done) {
          const resp = await fetch(`${RENDERER_BASE}/render/${id}/file`);
          if (resp.ok) zip.file(`${base}.mp4`, await resp.blob());
        }
      }

      // 가사 파일
      if (song.lyricTimeline) {
        zip.file(`${base}.lyric-timeline.json`,
          JSON.stringify(song.lyricTimeline, null, 2));
      }

      // Song JSON
      const snapshot = await window.SongfilmSongStorage.exportSongSnapshot(song);
      zip.file(`${base}.songfilm.json`, JSON.stringify(snapshot, null, 2));

      const blob = await zip.generateAsync({
        type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 },
      });
      triggerBlobDownload(blob, `${base}.zip`);
    } catch (e) {
      console.error('[Export] ZIP 생성 실패:', e);
      window.toast('ZIP 생성 실패: ' + e.message);
    } finally { setZipBuilding(false); }
  };

  const pushExportState = (patch) => {
    updateSong((cur) => ({
      workflowStep: "export",
      exportState: {
        ...(cur.exportState || {}),
        ...patch,
        updatedAt: new Date().toISOString(),
      },
    }));
  };

  const stopPolling = () => {
    if (pollRef.current) {
      clearInterval(pollRef.current);
      pollRef.current = null;
    }
  };

  const syncRenderStatus = async (activeJobId) => {
    try {
      const statusResp = await fetch(`${RENDERER_BASE}/render/${activeJobId}/status`);
      if (!statusResp.ok) {
        if (statusResp.status === 404) {
          const existingVideoFilename = String(song.exportState?.videoFilename || '').trim();
          if (existingVideoFilename) {
            stopPolling();
            setRendering(false);
            setDone(true);
            setProgress(100);
            setStatusMsg("렌더링 완료");
            pushExportState({
              status: "done",
              progress: 100,
              done: true,
              jobId: activeJobId,
              videoFilename: existingVideoFilename,
              lastCheckedAt: new Date().toISOString(),
              error: "",
            });
            return;
          }

          const staleMessage = "렌더링 job 상태를 찾을 수 없습니다. 다시 렌더링할 수 있습니다.";
          stopPolling();
          setRendering(false);
          setDone(false);
          setStatusMsg(staleMessage);
          pushExportState({
            status: "stale",
            done: false,
            jobId: activeJobId,
            lastCheckedAt: new Date().toISOString(),
            error: staleMessage,
          });
          return;
        }

        let errMsg = `상태 조회 오류 ${statusResp.status}`;
        try {
          const errBody = await statusResp.json();
          errMsg = errBody?.error || errBody?.message || errMsg;
        } catch {
          try {
            const errText = await statusResp.text();
            if (errText) errMsg = `${errMsg}: ${errText.slice(0, 300)}`;
          } catch { /* ignore */ }
        }
        throw new Error(errMsg);
      }

      const st = await statusResp.json();
      const nextStatus = st.status || "rendering";
      const pct = Math.round((st.progress || 0) * 100);
      const checkedAt = new Date().toISOString();
      const baseStatusPatch = {
        status: nextStatus,
        progress: pct,
        done: false,
        jobId: activeJobId,
        framesRendered: Number(st.framesRendered || 0),
        totalFrames: Number(st.totalFrames || 0),
        lastCheckedAt: checkedAt,
        error: st.error || "",
        ...(st.videoFilename ? { videoFilename: st.videoFilename } : {}),
      };

      setJobId(activeJobId);
      setProgress(pct);

      setStatusMsg(getRenderStatusMessage(nextStatus, baseStatusPatch));

      if (RENDER_ACTIVE_STATUSES.has(nextStatus)) {
        setRendering(true);
        setDone(false);
        pushExportState(baseStatusPatch);
        return;
      }

      if (nextStatus === "done") {
        const videoFilename = st.videoFilename || song.exportState?.videoFilename || "";
        stopPolling();
        setRendering(false);
        setDone(true);
        setProgress(100);
        pushExportState({
          status: "done",
          progress: 100,
          done: true,
          jobId: activeJobId,
          framesRendered: Number(st.framesRendered || 0),
          totalFrames: Number(st.totalFrames || 0),
          videoFilename,
          renderedAt: checkedAt,
          lastCheckedAt: checkedAt,
          error: "",
        });
        window.toast("렌더링 완료 ✓");
        console.info('[Export] 렌더링 완료 jobId:', activeJobId, 'video:', videoFilename);
        return;
      }

      if (nextStatus === "canceled") {
        stopPolling();
        setRendering(false);
        setDone(false);
        const errMsg = st.error || "렌더링이 취소되었습니다.";
        pushExportState({
          ...baseStatusPatch,
          status: "canceled",
          error: errMsg,
        });
        setStatusMsg(errMsg);
        return;
      }

      if (nextStatus === "error") {
        stopPolling();
        setRendering(false);
        const errMsg = st.error || "알 수 없는 오류";
        pushExportState({
          ...baseStatusPatch,
          status: "error",
          error: errMsg,
        });
        console.error('[Export] 렌더링 서버 오류:', errMsg);
        window.toast("렌더링 실패: " + errMsg);
        setStatusMsg("렌더링 실패: " + errMsg);
        return;
      }

      pushExportState(baseStatusPatch);
    } catch (e) {
      stopPolling();
      setRendering(false);
      pushExportState({
        status: "error",
        done: false,
        jobId: activeJobId,
        lastCheckedAt: new Date().toISOString(),
        error: e.message,
      });
      console.error('[Export] 폴링 중 서버 연결 오류:', e);
      window.toast("서버 연결이 끊겼습니다.");
      setStatusMsg("서버 연결 오류: " + e.message);
    }
  };

  const startRenderPolling = (activeJobId) => {
    if (!activeJobId) return;
    stopPolling();
    syncRenderStatus(activeJobId);
    pollRef.current = setInterval(() => syncRenderStatus(activeJobId), 1500);
  };

  // 페이지 복귀/새로고침 시 진행 중이던 렌더 작업에 다시 연결
  useEffect(() => {
    const exportState = song.exportState || {};
    const savedStatus = exportState.status || "idle";
    stopPolling();
    setDone(savedStatus === "done");
    setProgress(Number(exportState.progress || 0));
    setJobId(exportState.jobId || null);

    if (savedStatus === "done") {
      setRendering(false);
      setStatusMsg(getRenderStatusMessage(savedStatus, exportState));
    } else if (savedStatus === "error") {
      setRendering(false);
      setStatusMsg(getRenderStatusMessage(savedStatus, exportState));
    } else if (savedStatus === "canceled" || savedStatus === "stale") {
      setRendering(false);
      setStatusMsg(getRenderStatusMessage(savedStatus, exportState));
    } else if (isRenderReconnectable(exportState)) {
      setRendering(true);
      setDone(false);
      setStatusMsg("렌더링 상태 확인 중…");
      startRenderPolling(exportState.jobId);
    } else {
      setRendering(false);
      setStatusMsg("");
    }

    return () => stopPolling();
  }, [song.id, song.exportState?.jobId, song.exportState?.status]);

  const render = async (event) => {
    event?.preventDefault();
    setRendering(true); setProgress(0); setDone(false); setStatusMsg("준비 중…");
    pushExportState({
      status: "pending",
      progress: 0,
      done: false,
      jobId: null,
      videoFilename: "",
      renderedAt: "",
      submittedAt: "",
      lastCheckedAt: "",
      framesRendered: 0,
      totalFrames: 0,
      error: "",
    });

    try {
      // 1) 오디오 blob 추출 (IndexedDB → data URL → Blob)
      const { SongfilmSongStorage } = window;
      setStatusMsg("오디오 로드 중…");
      const snapshot  = await SongfilmSongStorage.exportSongSnapshot(song);
      const audioDataUrl = snapshot?.song?.audio?.dataUrl || "";

      // 2) scene 이미지 → data URL 변환
      //    - blob: URL  : 브라우저 전용, 렌더 서버에서 접근 불가
      //    - http://127.0.0.1 또는 localhost : Docker 컨테이너 내부에서는
      //      호스트의 127.0.0.1에 접근 불가 → 반드시 data URL로 변환
      const needsConvert = (url) => {
        if (!url) return false;
        if (SongfilmSongStorage.isBlobUrl(url)) return true;
        if (!/^(https?:|data:|blob:)/i.test(String(url))) return true;
        try {
          const u = new URL(url);
          return u.hostname === '127.0.0.1' || u.hostname === 'localhost';
        } catch { return false; }
      };

      const fetchToDataUrl = async (url) => {
        const sourceUrl = window.SongfilmApiConfig?.buildMediaUrl
          ? window.SongfilmApiConfig.buildMediaUrl("image", url)
          : url;
        const resp = await fetch(sourceUrl);
        const blob = await resp.blob();
        return new Promise((res, rej) => {
          const r = new FileReader();
          r.onload  = () => res(r.result);
          r.onerror = rej;
          r.readAsDataURL(blob);
        });
      };

      const scenes = await Promise.all((song.scenes || []).map(async (sc) => {
        if (!needsConvert(sc.imageUrl)) return sc;
        try {
          const dataUrl = await fetchToDataUrl(sc.imageUrl);
          return { ...sc, imageUrl: dataUrl };
        } catch (e) {
          console.warn('[Export] 씬 이미지 변환 실패, 빈 이미지로 대체:', sc.imageUrl, e);
          return { ...sc, imageUrl: "" };
        }
      }));
      let sceneGlobal = song.sceneGlobal || {};
      if (needsConvert(sceneGlobal.imageUrl)) {
        try {
          sceneGlobal = {
            ...sceneGlobal,
            imageUrl: await fetchToDataUrl(sceneGlobal.imageUrl),
          };
        } catch (e) {
          console.warn('[Export] 전체 장면 이미지 변환 실패, 빈 이미지로 대체:', sceneGlobal.imageUrl, e);
          sceneGlobal = { ...sceneGlobal, imageUrl: "" };
        }
      }

      // 3) 오디오 파일명 결정
      //    우선순위: ① song.audioServerUrl에서 추출 → ② IndexedDB base64 → storage-api 업로드
      let audioFilename = null;

      if (song.audioServerUrl) {
        // 이미 서버에 저장된 오디오: URL 끝 세그먼트를 파일명으로 사용
        try {
          const lastSegment = window.SongfilmApiConfig?.getMediaFilename?.(song.audioServerUrl) || "";
          if (lastSegment) {
            audioFilename = lastSegment;
            console.info('[Export] audioServerUrl에서 파일명 추출:', audioFilename);
          }
        } catch (e) {
          console.warn('[Export] audioServerUrl 파싱 실패:', e);
        }
      }

      if (!audioFilename && audioDataUrl) {
        // IndexedDB base64 경로 (구버전 호환): storage-api에 업로드
        setStatusMsg("오디오 업로드 중…");
        try {
          const audioBlob = SongfilmSongStorage.dataUrlToBlob(audioDataUrl);
          const audioForm = new FormData();
          audioForm.append('file', audioBlob, song.filename || 'audio.mp3');
          const upResp = await fetch(`${STORAGE_BASE}/audio/save`, { method: 'POST', body: audioForm });
          if (!upResp.ok) throw new Error(`스토리지 오류 ${upResp.status}`);
          const { filename } = await upResp.json();
          audioFilename = filename;
          pushExportState({ audioFilename });
          console.info('[Export] 오디오 업로드 완료:', audioFilename);
        } catch (e) {
          console.warn('[Export] 오디오 storage-api 업로드 실패 (오디오 없이 렌더):', e);
        }
      }

      // 4) 렌더 작업 시작 — 서버 가용성 먼저 확인
      setStatusMsg("렌더링 서버 확인 중…");
      try {
        await fetch(`${RENDERER_BASE}/health`, { signal: AbortSignal.timeout(3000) });
      } catch {
        throw new Error(
          `렌더링 서버(포트 8013)에 연결할 수 없습니다.\n` +
          `서버를 먼저 실행해 주세요:\n` +
          `  cd remotion-renderer && node server.js`
        );
      }

      const songData = {
        title:           song.title,
        filename:        song.filename || "audio.mp3",
        duration:        song.duration,
        paletteName:     song.paletteName || "midnight-violet",
        previewSettings: song.previewSettings || {},
        scenes,
        sceneGlobal,
        lyricTimeline:   song.lyricTimeline || null,
      };

      setStatusMsg("렌더링 서버 연결 중…");
      const startResp = await fetch(`${RENDERER_BASE}/render`, {
        method: "POST",
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ songData, audioFilename }),
      });
      if (!startResp.ok) {
        let errMsg = `서버 오류 ${startResp.status}`;
        try {
          const errBody = await startResp.json();
          errMsg = errBody?.error || errBody?.message || errMsg;
        } catch {
          try {
            const errText = await startResp.text();
            if (errText) errMsg = `${errMsg}: ${errText.slice(0, 300)}`;
          } catch { /* ignore */ }
        }
        throw new Error(errMsg);
      }
      const { jobId: newJobId } = await startResp.json();
      const submittedAt = new Date().toISOString();
      setJobId(newJobId);
      setStatusMsg("렌더링 요청 제출됨…");
      pushExportState({
        status: RENDER_SUBMITTED_STATUS,
        progress: 0,
        done: false,
        jobId: newJobId,
        audioFilename,
        submittedAt,
        lastCheckedAt: submittedAt,
        framesRendered: 0,
        totalFrames: 0,
        videoFilename: "",
        renderedAt: "",
        error: "",
      });
      startRenderPolling(newJobId);

    } catch (err) {
      setRendering(false);
      console.error('[Export] 렌더링 실패:', err);
      setStatusMsg("오류: " + err.message);
      window.toast("렌더링 실패: " + err.message);
      pushExportState({
        status: "error",
        done: false,
        lastCheckedAt: new Date().toISOString(),
        error: err.message,
      });
    }
  };


  return (
    <div className="export-panel">
      <div style={{marginBottom: 20}}>
        <div className="display" style={{fontSize: 28, fontWeight: 600, letterSpacing:"-0.02em"}}>내보내기</div>
        <p style={{color:"var(--ink-3)", fontSize: 13, margin: "6px 0 0"}}>
          Remotion으로 비디오를 렌더링하고, 소스와 함께 묶어 다운로드합니다.
        </p>
      </div>

      <div className="export-card">
        <h3>1. 비디오 렌더링</h3>
        <p>Remotion Composition을 H.264 mp4로 내보냅니다. 약 {Math.ceil(song.duration / 2)}초 소요.</p>
        {!rendering && !done && (
          <>
            <button type="button" className="pill-btn accent" onClick={render}><Icon.Film/> 렌더 시작</button>
            {statusMsg && (
              <div style={{fontSize: 12, color:"var(--ink-3)", marginTop: 8, fontFamily:"var(--font-mono)"}}>
                {statusMsg}
              </div>
            )}
          </>
        )}
        {rendering && (
          <>
            <div className="progress-track"><div className="bar" style={{width: `${progress}%`}}/></div>
            <div style={{fontSize: 12, color:"var(--ink-3)", marginTop: 6, fontFamily:"var(--font-mono)"}}>
              {progress}% · {statusMsg}
            </div>
          </>
        )}
        {done && (
          <div style={{display:"flex", alignItems:"center", gap: 12, justifyContent:"space-between", flexWrap:"wrap"}}>
            <div style={{display:"flex", alignItems:"center", gap: 10, color:"oklch(78% 0.14 145)", fontSize: 13}}>
              <Icon.Check/> 렌더링 완료 — {song.title}.mp4 · 1920×1080 · 30fps{renderedAtLabel ? ` (${renderedAtLabel})` : ""}
            </div>
            <button type="button" className="pill-btn" onClick={render}><Icon.Film/> 재렌더링</button>
          </div>
        )}
      </div>

      <div className="export-card">
        <h3>2. 다운로드 패키지</h3>
        <p>개별로 받거나, 전체를 한꺼번에 zip으로 받을 수 있습니다.</p>
        <div className="file-list">

          {/* MP3 */}
          <div className="file-row">
            <Icon.Music size={13}/>
            <span>{song.filename || `${song.title}.mp3`}</span>
            <span className="sz">{song.size || '—'}</span>
            <button type="button" className="pill-btn sm" onClick={dlMp3} disabled={dlLoading.mp3}>
              {dlLoading.mp3 ? '로드 중…' : <><Icon.Download size={11}/> MP3 받기</>}
            </button>
          </div>

          {/* MP4 */}
          <div className="file-row">
            <Icon.Film size={13}/>
            <span>{song.title}.mp4</span>
            <span className="sz">{done ? '✓ 완료' : '—'}</span>
            <button type="button" className="pill-btn sm" onClick={dlMp4} disabled={!done || dlLoading.mp4}>
              {dlLoading.mp4 ? '로드 중…' : <><Icon.Download size={11}/> Video 받기</>}
            </button>
          </div>

          {/* 가사 파일 */}
          <div className="file-row">
            <span style={{color:"var(--accent-2)", fontSize:11, lineHeight:1}}>◆</span>
            <span>{(song.filename || song.title).replace(/\.[^.]+$/, '')}.lyric-timeline.json</span>
            <span className="sz">{fmtBytes(lyricJsonSize)}</span>
            <button type="button" className="pill-btn sm" onClick={dlLyric} disabled={!song.lyricTimeline}>
              <Icon.Download size={11}/> 가사 받기
            </button>
          </div>

          {/* Song JSON */}
          <div className="file-row">
            <span style={{color:"var(--accent-3)", fontSize:11, lineHeight:1}}>◆</span>
            <span>{song.title}.songfilm.json</span>
            <span className="sz">—</span>
            <button type="button" className="pill-btn sm" onClick={dlSongJson}>
              <Icon.Download size={11}/> JSON 받기
            </button>
          </div>

        </div>
        <div style={{marginTop: 14, paddingTop: 14, borderTop: '1px solid var(--line)'}}>
          <button type="button" className="pill-btn primary" onClick={dlZip} disabled={zipBuilding}>
            {zipBuilding
              ? <><Icon.Loader size={13}/> ZIP 생성 중…</>
              : <><Icon.Download/> {song.title}.zip 전체 받기</>}
          </button>
        </div>
      </div>

      <div className="export-card" style={{background:"var(--bg-2)", borderStyle:"dashed"}}>
        <h3 style={{color:"var(--ink-2)"}}>Remotion 소스 미리보기</h3>
        <pre style={{margin: 0, fontFamily:"var(--font-mono)", fontSize: 11, color:"var(--ink-2)", lineHeight: 1.6, overflowX:"auto", background:"var(--bg)", padding: 14, borderRadius: 8}}>
{`// Composition.tsx
import {AbsoluteFill, Audio, Sequence, staticFile} from 'remotion';
import {Scene} from './scenes/Scene';
import {Subtitle} from './Subtitle';

export const SongVideo = () => (
  <AbsoluteFill style={{backgroundColor: 'black'}}>
    <Audio src={staticFile('${song.filename}')} />
    ${song.scenes.slice(0, 2).map((s, i) => `
    <Sequence from={${Math.floor(s.start * 30)}} durationInFrames={${Math.floor((s.end - s.start) * 30)}}>
      <Scene image="scene-${i+1}.png" transition="fade" />
    </Sequence>`).join("")}
    {/* … +${song.scenes.length - 2} scenes */}
    <Subtitle cues={cues} />
  </AbsoluteFill>
);`}
        </pre>
      </div>
    </div>
  );
}

function VideoPlayStep({ song }) {
  const { Icon } = window;
  const [videoUrl, setVideoUrl] = useState(() => getRenderedVideoUrl(song));
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState('');
  const objectUrlRef = useRef('');

  useEffect(() => {
    let cancelled = false;

    const cleanupObjectUrl = () => {
      if (objectUrlRef.current) {
        URL.revokeObjectURL(objectUrlRef.current);
        objectUrlRef.current = '';
      }
    };

    const loadVideo = async () => {
      cleanupObjectUrl();
      setError('');

      const storedVideoUrl = String(song?.exportState?.videoFilename || '').trim();
      if (storedVideoUrl) {
        setVideoUrl(window.SongfilmApiConfig?.buildMediaUrl
          ? window.SongfilmApiConfig.buildMediaUrl("video", storedVideoUrl)
          : `${STORAGE_BASE}/video/${encodeURIComponent(storedVideoUrl)}`);
        setLoading(false);
        return;
      }

      const fallbackUrl = getRenderedVideoUrl(song);
      if (!fallbackUrl) {
        setVideoUrl('');
        setLoading(false);
        setError('재생할 비디오가 없습니다. 먼저 내보내기에서 렌더링을 완료하세요.');
        return;
      }

      setLoading(true);
      try {
        const resp = await fetch(fallbackUrl);
        if (!resp.ok) {
          throw new Error(`비디오 로드 실패 (${resp.status})`);
        }
        const blob = await resp.blob();
        const objectUrl = URL.createObjectURL(blob);
        objectUrlRef.current = objectUrl;
        if (!cancelled) {
          setVideoUrl(objectUrl);
        }
      } catch (e) {
        if (!cancelled) {
          setVideoUrl('');
          setError(e.message || String(e));
        }
      } finally {
        if (!cancelled) {
          setLoading(false);
        }
      }
    };

    loadVideo();

    return () => {
      cancelled = true;
      cleanupObjectUrl();
    };
  }, [song.id, song.exportState?.videoFilename, song.exportState?.jobId]);

  return (
    <div className="export-panel video-play-panel">
      <div className="video-play-grid">
        <div className="export-card video-play-stage-card">
          {loading && (
            <div className="video-play-loading">
            <Icon.Loader size={13}/> 비디오 로드 중…
            </div>
          )}
          {!loading && error && (
            <div className="video-play-error">{error}</div>
          )}
          {!loading && !error && videoUrl && (
            <div className="video-play-stage-shell">
              <video
                className="video-play-stage"
                controls
                preload="metadata"
                src={videoUrl}
              />
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

Object.assign(window, { PreviewStep, ExportStep, VideoPlayStep });
