Zum Inhalt

visualizer/src/components/SectionCard.tsx

import type React from 'react';
import { interpolate, spring } from 'remotion';
import type { ChapterMarker } from '../utils/timeline';

type Props = {
  chapter: ChapterMarker | null;
  /** Frames until the chapter starts (positive) or since it ended (negative). */
  framesFromBoundary: number;
  frame: number;
  fps: number;
  reducedMotion: boolean;
};

/** Frames the card stays up after the chapter starts (fade-out ends here). */
export const SECTION_CARD_FRAMES = 90;
const FADE_OUT_FROM = 78;

/**
 * Visibility of the card at `frame`: `opacity` (0 when hidden) and the
 * entry `enterProgress` (0 → 1). Pure, so the composition can duck the
 * running caption with the same curve the card fades with.
 */
export const sectionCardVisibility = (
  chapter: ChapterMarker | null,
  frame: number,
  fps: number,
  reducedMotion: boolean,
): { opacity: number; enterProgress: number } => {
  if (!chapter) return { opacity: 0, enterProgress: 0 };

  const chapterFrameStart = (chapter.startMs / 1000) * fps;
  const localFrame = frame - chapterFrameStart;

  // Visible during the first SECTION_CARD_FRAMES of the chapter, fading
  // out smoothly over the last 12 of them.
  if (localFrame < 0 || localFrame > SECTION_CARD_FRAMES) return { opacity: 0, enterProgress: 0 };

  const enterProgress = reducedMotion
    ? Math.min(1, Math.max(0, localFrame / 12))
    : spring({
        frame: localFrame,
        fps,
        config: { damping: 22, stiffness: 140, mass: 0.7 },
        durationInFrames: 24,
      });

  const fadeOut = interpolate(localFrame, [FADE_OUT_FROM, SECTION_CARD_FRAMES], [1, 0], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return { opacity: Math.min(enterProgress, fadeOut), enterProgress };
};

/**
 * Section / Chapter title-card overlay in the stage accent (birds of yore,
 * Subtheme Crimson; until 2026-10-14 the KSP aspect Sensei/Ember). Animation:
 *
 *  • A spring scales a horizontal rule from 0 → 1 across 18 frames.
 *  • The kanji + roman title fade up 12 px on entry.
 *  • Dismisses after SECTION_CARD_FRAMES (3 s at 30 fps), whatever the
 *    chapter length, so it doesn't block the visualiser.
 */
export const SectionCard: React.FC<Props> = ({ chapter, frame, fps, reducedMotion }) => {
  const { opacity, enterProgress } = sectionCardVisibility(chapter, frame, fps, reducedMotion);
  if (!chapter || opacity <= 0) return null;

  const lift = (1 - enterProgress) * 12;

  return (
    <div
      style={{
        position: 'absolute',
        inset: 0,
        pointerEvents: 'none',
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        opacity,
        background: `radial-gradient(ellipse at center,
          color-mix(in oklab, var(--accent) 14%, transparent) 0%,
          transparent 60%)`,
      }}
    >
      <div
        className="ksp-mono-caps"
        style={{
          fontSize: 13,
          letterSpacing: '0.24em',
          color: 'var(--accent)',
          marginBottom: 16,
          transform: `translateY(${lift}px)`,
        }}
      >
        CHAPTER · 章 · {String(chapter.index + 1).padStart(2, '0')}
      </div>
      <div
        style={{
          fontFamily: 'var(--font-display)',
          fontStyle: 'italic',
          fontWeight: 500,
          fontSize: 96,
          lineHeight: 1.05,
          letterSpacing: '-0.02em',
          color: 'var(--fg-primary)',
          textAlign: 'center',
          maxWidth: 1400,
          transform: `translateY(${lift}px)`,
        }}
      >
        {chapter.title}
      </div>
      {/* Spring-scaled rule beneath title */}
      <div
        style={{
          marginTop: 32,
          width: `${Math.round(enterProgress * 320)}px`,
          height: 2,
          background: 'var(--accent)',
          boxShadow: '0 0 18px var(--accent)',
        }}
      />
    </div>
  );
};