feat(web): editorial homepage rework — flat 2×30 stories, autosize input

Revises the InfiPlot homepage from the initial prototype pass.

Stories data model
- Replaces the artificial 7-hero + 16-gallery split with a flat
  per-gender model: 30 preset stories each for 男性向 / 女性向.
- Renames assets hero*/gallery* → m{0..29} / f{0..29}; same index
  shares aspect ratio across genders so the gender crossfade never
  jumps card height.
- Fills in the missing 女性向 set and expands both genders to 30.

Cards
- StoryCard measures aspect ratio at runtime from the loaded image
  (onLoad → naturalWidth/Height), fixing the frosted-caption band
  reflow on lazy image load. Drops ready/fallback props; single
  masonry map over STORIES[gender].

Hero input
- Single-line <input> → auto-growing <textarea> (rows=1, resize-none)
  so long prompts and long card seeds are fully visible. Enter submits,
  Shift+Enter inserts a newline.
- lining-nums on the input so digits sit on the baseline instead of
  Cormorant's default old-style figures.

Typography / styles
- layout.tsx: editorial fonts (Cormorant Garamond + Inter via
  --font-serif / --font-sans) + Font Awesome; drops Patrick Hand /
  Noto Sans SC and the hand-drawn SVG jitter filters.
- globals.css trimmed to the editorial base (paper grain, hairline,
  num, ripple); play/page.tsx font/style follow-up.

Scripts
- generate-home-images.mjs reworked into a flat 2×30 idempotent
  Runware FLUX.2 generator.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-02 01:14:49 +08:00
parent 136ceff69f
commit 8f94d3aa65
65 changed files with 836 additions and 1074 deletions
+36 -12
View File
@@ -266,6 +266,10 @@ function PlayInner() {
// when older sessionStorage payloads omit the field. Mutated once in
// bootstrap and read by fetchBeatAudio to early-return without any /api call.
const audioEnabledRef = useRef<boolean>(true);
// Mirrors `muted` so the closure-stable fetchBeatAudio (deps []) can gate on
// it. Muting stops TTS *synthesis*, not just playback — TTS is the only sound
// source, so synthesizing audio the user can't hear just burns quota.
const mutedRef = useRef<boolean>(muted);
// Mirrors for use inside async handlers (closure-stable)
const sessionRef = useRef<Session | null>(null);
@@ -291,6 +295,9 @@ function PlayInner() {
useEffect(() => {
currentBeatRef.current = currentBeat;
}, [currentBeat]);
useEffect(() => {
mutedRef.current = muted;
}, [muted]);
// Whenever currentBeatId changes, append it to visited (skip consecutive dups)
useEffect(() => {
@@ -322,6 +329,7 @@ function PlayInner() {
beat: { id: string; speaker?: string; line?: string; lineDelivery?: string },
): Promise<void> => {
if (!audioEnabledRef.current) return; // user toggled 语音配音 → 关闭
if (mutedRef.current) return; // 静音 → 不合成 TTS(避免无谓的调用与花费)
if (!beat.speaker || !beat.line) return;
const speaker = sess.characters.find((c) => c.name === beat.speaker);
if (!speaker?.voice) return; // not yet provisioned — server can't synth anyway
@@ -367,22 +375,26 @@ function PlayInner() {
beatAudioAbortRef.current.clear();
}
// Fire one /api/beat-audio request per speaking beat each time the scene
// changes. Cancel any in-flight requests from the prior scene first —
// beat ids are scene-local ("b1" repeats across scenes) so a late arrival
// would land under the wrong beat in the audio map otherwise.
useEffect(() => {
cancelBeatAudioFetches();
setBeatAudioMap({});
const scene = currentScene;
// Fire one /api/beat-audio request per speaking beat in the current scene.
// Reads refs (not props) so it stays closure-stable and can be re-run on
// un-mute as well as on scene change.
const prefetchSceneAudio = useCallback(() => {
const scene = currentSceneRef.current;
const sess = sessionRef.current;
if (!scene || !sess) return;
for (const b of scene.beats) {
if (b.speaker && b.line) {
void fetchBeatAudio(sess, b);
}
if (b.speaker && b.line) void fetchBeatAudio(sess, b);
}
}, [currentScene?.id, fetchBeatAudio]);
}, [fetchBeatAudio]);
// (Re)synthesize each time the scene changes. Cancel any in-flight requests
// from the prior scene first — beat ids are scene-local ("b1" repeats across
// scenes) so a late arrival would land under the wrong beat otherwise.
useEffect(() => {
cancelBeatAudioFetches();
setBeatAudioMap({});
prefetchSceneAudio();
}, [currentScene?.id, prefetchSceneAudio]);
// ── Mute persistence (read is via the useState lazy initializer above) ─
const toggleMuted = useCallback(() => {
@@ -397,6 +409,18 @@ function PlayInner() {
});
}, []);
// Muting stops synthesis, not just playback: abort in-flight requests when
// muting. When un-muting, re-synthesize the current scene — fetchBeatAudio
// skips synthesis while muted, so a scene entered muted has no audio to play
// back otherwise. (Clearing the map re-synthesizes already-fetched beats on a
// mid-scene un-mute, but that's bounded to one scene and a rare toggle.)
useEffect(() => {
cancelBeatAudioFetches();
if (muted) return;
setBeatAudioMap({});
prefetchSceneAudio();
}, [muted, prefetchSceneAudio]);
// ── Presentation mode toggle ─────────────────────────────────────────
const togglePresentation = useCallback(async () => {
const entering = !presentation;