b0b5630a25
Adds a "导出图集" action at the bottom-right of the play canvas that
snapshots the current session into localStorage and opens
/gallery#id=<id> in a new tab — the original play page keeps running
untouched. In parallel, sends the doc to /api/gallery-pack and
downloads the result as a binary .infiplot file the player can send
to a friend.
The snapshot pulls in:
- Every visited scene's image + beat graph + recorded visit trail
- All AI-prefetched alternate scenes (a new resolvedPrefetchesRef in
PlayInner captures each prefetch as it resolves, so abandoned
branches the engine already paid to generate are kept)
- Character names + basePortraitUrl (voice base64 / styleReference
are stripped — they aren't needed for replay)
/gallery is a no-network interactive replay:
- Per-beat advance and per-choice navigation. Picked choices are
highlighted; unpicked choices are clickable when an alternate was
prefetched, greyed otherwise.
- Stack-based navigation for stepping into branches with one-tap
"返回主线" to collapse back to the main path.
- Top-bar batch download for scene images (including unique
AI-prefetched branch scenes, deduped against the main path) and
character portraits. Fetched with a per-file AbortController + 20s
timeout in a small concurrency pool, then clicked serially.
Prevents one slow CDN response from stranding the busy button.
- In-progress hint banner reminding the player to allow the
browser's "multiple downloads" prompt.
- F-key fullscreen with a top toolbar that auto-retracts after the
initial glance and pops back down on cursor approach.
- Per-scene dialogue panel (fa-clock-rotate-left, matching the
in-game history affordance).
- "导入分享文件" entry on the empty/error state — accepts a friend's
.infiplot, posts to /api/gallery-unpack, renders the decrypted doc.
Share-file format (.infiplot):
- AES-256-GCM via Web Crypto (portable to Cloudflare Workers).
- Layout: 4-byte magic "IFPL" + 1-byte version + 12-byte nonce +
ciphertext (includes 16-byte auth tag).
- Key derived from GALLERY_SECRET via SHA-256.
- GCM's auth tag gives tamper-detection for free; any flip in the
ciphertext/nonce surfaces as "文件校验失败" — same error as wrong-key,
so the distinction can't leak server config.
- Stateless: server keeps no record of issued files.
- GALLERY_SECRET unset → /api/gallery-pack returns 503, the play page
silently skips the share-file download, local view still works.
Rotating the secret invalidates every previously-issued file.
Retention: trimGalleryExports keeps only the 2 most recent localStorage
docs; older ones are evicted before each write so quota stays flat
regardless of how many times the player exports. Share files live on
the player's own disk — no retention concern.
Adds 'gallery_export' to the analytics event schema (scene_count only —
no free text).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
611 lines
23 KiB
TypeScript
611 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||
import {
|
||
DialogueHistoryModal,
|
||
type DialogueHistoryItem,
|
||
} from "@/components/DialogueHistoryModal";
|
||
import type { Beat, BeatChoice, Orientation } from "@infiplot/types";
|
||
|
||
export type Phase =
|
||
| "loading-first" // first scene not yet rendered
|
||
| "ready" // current beat is interactive
|
||
| "vision-thinking" // background click → waiting on vision verdict
|
||
| "inserting-beat" // vision-driven beat being generated
|
||
| "transitioning"; // changing scenes (cache miss or speculative wait)
|
||
|
||
const SHADOW =
|
||
"0 1px 0 rgba(45,24,16,0.05), 0 36px 64px -28px rgba(45,24,16,0.25), 0 8px 18px -6px rgba(45,24,16,0.10)";
|
||
|
||
const DEFAULT_CHAR_MS = 28;
|
||
const MIN_CHAR_MS = 30;
|
||
// Voice playback speed multiplier. >1 speeds up the (somewhat slow) MiMo voice
|
||
// while preserving pitch. Typewriter pacing is divided by the same factor.
|
||
const SPEECH_RATE = 1.2;
|
||
// If audio metadata never arrives within this window, give up waiting and
|
||
// let the typewriter run at default speed.
|
||
const AUDIO_WAIT_TIMEOUT_MS = 2500;
|
||
|
||
// ── Typewriter hook ────────────────────────────────────────────────────
|
||
// Returns the progressively-revealed text, a `done` flag, and a `skip()` that
|
||
// instantly completes the current text. Reset is keyed by `resetKey` (the beat
|
||
// id) rather than the text, so a new beat whose line happens to match the
|
||
// previous one still replays from scratch.
|
||
//
|
||
// When `targetDurationMs` is provided we space characters to span that audio
|
||
// duration, keeping text and voice in lockstep. While `waitForAudio` is true
|
||
// and we don't yet know a duration, the typewriter holds (so text doesn't
|
||
// race ahead of an audio that's still loading).
|
||
function useTypewriter(
|
||
text: string,
|
||
resetKey: string,
|
||
opts: { targetDurationMs?: number; waitForAudio: boolean } = {
|
||
waitForAudio: false,
|
||
},
|
||
): { shown: string; done: boolean; skip: () => void } {
|
||
const { targetDurationMs, waitForAudio } = opts;
|
||
const [displayed, setDisplayed] = useState("");
|
||
const [prevKey, setPrevKey] = useState(resetKey);
|
||
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||
// Sticky once the player has skipped this beat: prevents a late-arriving
|
||
// audio metadata event from re-triggering the effect and replaying the text.
|
||
const skippedRef = useRef(false);
|
||
|
||
// Render-phase reset (React "adjust state on prop change" pattern): when the
|
||
// beat changes, drop the old progress before this render commits.
|
||
if (resetKey !== prevKey) {
|
||
setPrevKey(resetKey);
|
||
setDisplayed("");
|
||
skippedRef.current = false;
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (!text) return;
|
||
// `=== undefined` (not `!targetDurationMs`): 0 means "audio failed or
|
||
// timed out — run at default speed". The original truthy check stalled
|
||
// the typewriter forever on those fallback paths.
|
||
if (waitForAudio && targetDurationMs === undefined) return;
|
||
// If the player skipped, settle on the full text and don't restart even
|
||
// when audio metadata arrives late and re-triggers this effect.
|
||
if (skippedRef.current) {
|
||
setDisplayed(text);
|
||
return;
|
||
}
|
||
|
||
const speed =
|
||
targetDurationMs && text.length > 0
|
||
? Math.max(MIN_CHAR_MS, targetDurationMs / text.length)
|
||
: DEFAULT_CHAR_MS;
|
||
|
||
let i = 0;
|
||
timer.current = setInterval(() => {
|
||
i += 1;
|
||
setDisplayed(text.slice(0, i));
|
||
if (i >= text.length && timer.current) {
|
||
clearInterval(timer.current);
|
||
timer.current = null;
|
||
}
|
||
}, speed);
|
||
return () => {
|
||
if (timer.current) clearInterval(timer.current);
|
||
timer.current = null;
|
||
};
|
||
}, [resetKey, text, targetDurationMs, waitForAudio]);
|
||
|
||
const skip = useCallback(() => {
|
||
if (timer.current) {
|
||
clearInterval(timer.current);
|
||
timer.current = null;
|
||
}
|
||
skippedRef.current = true;
|
||
setDisplayed(text);
|
||
}, [text]);
|
||
|
||
// During the throwaway render where the beat just changed, `displayed` still
|
||
// holds the previous beat's text — coerce it to empty so nothing stale shows.
|
||
const shown = resetKey === prevKey ? displayed : "";
|
||
const done = text.length === 0 || shown.length >= text.length;
|
||
return { shown, done, skip };
|
||
}
|
||
|
||
// ── Choice button ──────────────────────────────────────────────────────
|
||
function ChoiceButton({
|
||
index,
|
||
label,
|
||
disabled,
|
||
vertical,
|
||
onClick,
|
||
}: {
|
||
index: number;
|
||
label: string;
|
||
disabled: boolean;
|
||
vertical: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
className={`group relative ${vertical ? "w-full" : "flex-1 min-w-0"} px-4 py-3 text-left transition-all duration-200
|
||
disabled:opacity-50 disabled:cursor-wait`}
|
||
style={{
|
||
background: "rgba(20, 14, 8, 0.68)",
|
||
border: "1.5px solid rgba(180, 140, 80, 0.65)",
|
||
borderRadius: "6px",
|
||
backdropFilter: "blur(8px)",
|
||
WebkitBackdropFilter: "blur(8px)",
|
||
boxShadow: "0 2px 12px rgba(0,0,0,0.4), inset 0 1px 0 rgba(200,165,90,0.12)",
|
||
}}
|
||
>
|
||
<span
|
||
className="absolute inset-0 rounded-[5px] opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
|
||
style={{
|
||
background: "rgba(180,140,60,0.10)",
|
||
border: "1.5px solid rgba(200,165,90,0.85)",
|
||
}}
|
||
/>
|
||
<span className="relative flex items-baseline gap-2">
|
||
<span
|
||
className={`shrink-0 font-serif num ${vertical ? "text-[13px]" : "text-[11px]"}`}
|
||
style={{ color: "rgba(195,155,75,0.9)" }}
|
||
>
|
||
{index + 1}.
|
||
</span>
|
||
<span
|
||
className={`font-serif leading-snug ${vertical ? "text-[15px]" : "text-[13px] md:text-[14px]"}`}
|
||
style={{ color: "rgba(245,235,210,0.95)" }}
|
||
>
|
||
{label}
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
}
|
||
|
||
// ── Main component ─────────────────────────────────────────────────────
|
||
export function PlayCanvas({
|
||
imageUrl,
|
||
audioSrc,
|
||
muted,
|
||
phase,
|
||
beat,
|
||
pendingClick,
|
||
onBackgroundClick,
|
||
onAdvance,
|
||
onSelectChoice,
|
||
fullViewport = false,
|
||
orientation = "landscape",
|
||
aboveCanvas,
|
||
aboveCanvasLeft,
|
||
belowCanvas,
|
||
dialogueHistory = [],
|
||
}: {
|
||
imageUrl: string | null;
|
||
audioSrc: string | null;
|
||
muted: boolean;
|
||
phase: Phase;
|
||
beat: Beat | null;
|
||
pendingClick: { x: number; y: number } | null;
|
||
onBackgroundClick: (click: { x: number; y: number }) => void;
|
||
onAdvance: () => void;
|
||
onSelectChoice: (choice: BeatChoice) => void;
|
||
fullViewport?: boolean;
|
||
// 会话锁定的图片朝向。"portrait" 时整图铺满视口(object-fit:cover)、选项竖排、字号放大。
|
||
orientation?: Orientation;
|
||
// 渲染在图片正上方、右对齐的 slot(画面外、紧贴右上角)。
|
||
aboveCanvas?: ReactNode;
|
||
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||
aboveCanvasLeft?: ReactNode;
|
||
// 渲染在图片正下方、右对齐的 slot(画面外、紧贴右下角),与 aboveCanvas 垂直镜像。
|
||
belowCanvas?: ReactNode;
|
||
dialogueHistory?: DialogueHistoryItem[];
|
||
}) {
|
||
const imgRef = useRef<HTMLImageElement>(null);
|
||
const audioRef = useRef<HTMLAudioElement>(null);
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [audioDurationMs, setAudioDurationMs] = useState<number | undefined>(
|
||
undefined,
|
||
);
|
||
|
||
const isChoiceBeat = beat?.next.type === "choice";
|
||
const choices: BeatChoice[] = isChoiceBeat
|
||
? (beat!.next as { type: "choice"; choices: BeatChoice[] }).choices
|
||
: [];
|
||
|
||
const displayBody = beat?.speaker ? beat.line ?? "" : beat?.narration ?? "";
|
||
const { shown: typedBody, done: typingDone, skip: skipTypewriter } =
|
||
useTypewriter(displayBody, beat?.id ?? "", {
|
||
targetDurationMs: audioDurationMs,
|
||
waitForAudio: Boolean(audioSrc),
|
||
});
|
||
|
||
// ── Audio source change ──────────────────────────────────────────────
|
||
// Reset duration when audio source changes; if loading takes too long,
|
||
// unblock the typewriter via timeout so text doesn't stall.
|
||
useEffect(() => {
|
||
setAudioDurationMs(undefined);
|
||
if (!audioSrc) return;
|
||
const timer = setTimeout(() => {
|
||
setAudioDurationMs((prev) => prev ?? 0);
|
||
}, AUDIO_WAIT_TIMEOUT_MS);
|
||
return () => clearTimeout(timer);
|
||
}, [audioSrc]);
|
||
|
||
// ── Mute toggle ───────────────────────────────────────────────────────
|
||
useEffect(() => {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
el.muted = muted;
|
||
el.playbackRate = SPEECH_RATE;
|
||
if (!muted && audioSrc && el.paused) {
|
||
el.play().catch(() => {
|
||
// autoplay blocked — silent until next interaction
|
||
});
|
||
}
|
||
}, [muted, audioSrc]);
|
||
|
||
function handleAudioMetadata() {
|
||
const el = audioRef.current;
|
||
if (!el) return;
|
||
el.playbackRate = SPEECH_RATE;
|
||
// Effective playback time is shorter once sped up — keep the typewriter in sync.
|
||
const ms = Number.isFinite(el.duration)
|
||
? (el.duration * 1000) / SPEECH_RATE
|
||
: 0;
|
||
setAudioDurationMs(ms > 0 ? ms : 0);
|
||
if (!muted) {
|
||
el.play().catch(() => {
|
||
// autoplay blocked
|
||
});
|
||
}
|
||
}
|
||
|
||
function handleAudioError() {
|
||
// Treat as zero duration so the typewriter runs at default speed.
|
||
setAudioDurationMs(0);
|
||
}
|
||
|
||
function handleImageClick(e: React.MouseEvent<HTMLImageElement>) {
|
||
if (phase !== "ready" || !imgRef.current || !beat) return;
|
||
const el = imgRef.current;
|
||
const rect = el.getBoundingClientRect();
|
||
// Portrait renders with object-fit:cover, which scales the 9:16 image to
|
||
// FILL the box and crops the overflow — so the rendered box ≠ the full
|
||
// image. Map the click from box-space back into full-image-space via the
|
||
// cover geometry so the marker lands where the user tapped. Landscape's box
|
||
// matches the image aspect (no crop), so it keeps simple normalization.
|
||
let x: number;
|
||
let y: number;
|
||
if (orientation === "portrait") {
|
||
const nw = el.naturalWidth || 1024;
|
||
const nh = el.naturalHeight || 1792;
|
||
const scale = Math.max(rect.width / nw, rect.height / nh);
|
||
const dispW = nw * scale;
|
||
const dispH = nh * scale;
|
||
x = (e.clientX - rect.left + (dispW - rect.width) / 2) / dispW;
|
||
y = (e.clientY - rect.top + (dispH - rect.height) / 2) / dispH;
|
||
} else {
|
||
x = (e.clientX - rect.left) / rect.width;
|
||
y = (e.clientY - rect.top) / rect.height;
|
||
}
|
||
// If the typewriter is still printing, a click completes it instantly
|
||
// (standard VN affordance) — the page never sees this click.
|
||
if (!typingDone) {
|
||
skipTypewriter();
|
||
return;
|
||
}
|
||
// For continue-type beats, image click advances; for choice beats,
|
||
// image click goes through vision (treat as freeform action).
|
||
if (beat.next.type === "continue") {
|
||
onAdvance();
|
||
return;
|
||
}
|
||
onBackgroundClick({
|
||
x: Math.max(0, Math.min(1, x)),
|
||
y: Math.max(0, Math.min(1, y)),
|
||
});
|
||
}
|
||
|
||
// Card swallows its own clicks so they never fall through to the image's
|
||
// vision (识图) trigger: while typing a click completes the text, a continue
|
||
// beat advances, and a choice beat stays inert (player must pick an option).
|
||
function handleCardClick() {
|
||
if (phase !== "ready" || !beat) return;
|
||
if (!typingDone) {
|
||
skipTypewriter();
|
||
return;
|
||
}
|
||
if (beat.next.type === "continue") onAdvance();
|
||
}
|
||
|
||
const interactive = phase === "ready" && !!imageUrl;
|
||
const dimmed = phase === "transitioning";
|
||
|
||
const portrait = orientation === "portrait";
|
||
const intrinsicW = portrait ? 1024 : 1792;
|
||
const intrinsicH = portrait ? 1792 : 1024;
|
||
|
||
// Portrait (mobile) always fills the whole viewport with object-fit:cover so
|
||
// the 9:16 image matches the exact device/window — no letterbox. Landscape
|
||
// keeps the prior contain-style sizing so the full 16:9 frame stays visible.
|
||
const sizeStyle: React.CSSProperties = portrait
|
||
? { width: "100vw", height: "100dvh", objectFit: "cover" }
|
||
: fullViewport
|
||
? { maxWidth: "100vw", maxHeight: "100dvh" }
|
||
: { maxWidth: "96vw", maxHeight: "calc(100dvh - 200px)" };
|
||
|
||
const placeholderStyle: React.CSSProperties = portrait
|
||
? { width: "100vw", height: "100dvh" }
|
||
: {
|
||
width: fullViewport
|
||
? "min(100vw, calc(100dvh * 16 / 9))"
|
||
: "min(96vw, calc((100dvh - 200px) * 16 / 9))",
|
||
};
|
||
|
||
|
||
return (
|
||
<div
|
||
className={`flex flex-col items-center ${fullViewport ? "w-full h-full justify-center" : "w-full"}`}
|
||
>
|
||
{/* Hidden audio element — voice playback for the current beat */}
|
||
{audioSrc && (
|
||
<audio
|
||
key={audioSrc.slice(-48)}
|
||
ref={audioRef}
|
||
src={audioSrc}
|
||
preload="auto"
|
||
onLoadedMetadata={handleAudioMetadata}
|
||
onError={handleAudioError}
|
||
className="hidden"
|
||
/>
|
||
)}
|
||
|
||
{imageUrl ? (
|
||
<div
|
||
className="relative inline-block"
|
||
style={{ boxShadow: fullViewport ? "none" : SHADOW }}
|
||
>
|
||
{/* Background image — Runware CDN URL or data URI (mock mode).
|
||
The width/height attributes give the browser the intrinsic aspect
|
||
ratio (1792:1024 landscape / 1024:1792 portrait) so that, while the
|
||
bytes are still arriving from the CDN, the <img> reserves the right
|
||
box instead of collapsing to a one-pixel sliver — fixes the
|
||
"等很久 → 一根线 → 突然出图" jank. Landscape uses w-auto/h-auto +
|
||
maxWidth/maxHeight (contain); portrait switches sizeStyle to
|
||
100vw×100dvh with object-fit:cover (full-bleed, no letterbox). */}
|
||
<img
|
||
key={imageUrl.slice(-48)}
|
||
ref={imgRef}
|
||
src={imageUrl}
|
||
width={intrinsicW}
|
||
height={intrinsicH}
|
||
alt="Generated scene"
|
||
onClick={handleImageClick}
|
||
draggable={false}
|
||
className={`block ${portrait ? "" : "w-auto h-auto"} select-none animate-fade-in transition-opacity duration-700 ease-out ${
|
||
interactive ? "cursor-pointer" : "cursor-wait"
|
||
} ${dimmed ? "opacity-40" : "opacity-100"}`}
|
||
style={sizeStyle}
|
||
/>
|
||
|
||
{!fullViewport && (
|
||
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
|
||
)}
|
||
|
||
{/* 画面正上方右对齐的 slot —— 用 bottom-full + right-0 让它整体浮在图片之外、紧贴右上角 */}
|
||
{!fullViewport && aboveCanvas && (
|
||
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
||
{aboveCanvas}
|
||
</div>
|
||
)}
|
||
{!fullViewport && aboveCanvasLeft && (
|
||
<div className="absolute bottom-full left-0 mb-2 flex items-center gap-2">
|
||
{aboveCanvasLeft}
|
||
</div>
|
||
)}
|
||
{!fullViewport && belowCanvas && (
|
||
<div className="absolute top-full right-0 mt-2 flex items-center gap-2">
|
||
{belowCanvas}
|
||
</div>
|
||
)}
|
||
|
||
{beat && (
|
||
<div
|
||
className="absolute inset-0 flex flex-col justify-end pointer-events-none select-none"
|
||
style={
|
||
portrait
|
||
? { paddingBottom: "env(safe-area-inset-bottom)" }
|
||
: undefined
|
||
}
|
||
>
|
||
{historyOpen && (
|
||
<DialogueHistoryModal
|
||
items={dialogueHistory}
|
||
portrait={portrait}
|
||
onClose={() => setHistoryOpen(false)}
|
||
/>
|
||
)}
|
||
|
||
{choices.length > 0 && (
|
||
<div
|
||
className={`pointer-events-auto px-[3%] pb-[1.5%] flex items-stretch ${
|
||
portrait
|
||
? "vn-scrollbar flex-col gap-2 max-h-[45dvh] overflow-y-auto"
|
||
: "gap-[1.5%]"
|
||
}`}
|
||
>
|
||
{choices.map((choice, i) => (
|
||
<ChoiceButton
|
||
key={choice.id}
|
||
index={i}
|
||
label={choice.label}
|
||
disabled={phase !== "ready"}
|
||
vertical={portrait}
|
||
onClick={() => onSelectChoice(choice)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{(beat.narration || beat.line) && (
|
||
<div
|
||
className="pointer-events-auto mx-[2%] mb-[2%] px-[3%] py-[2.2%] relative"
|
||
onClick={handleCardClick}
|
||
style={{
|
||
background: "rgba(14, 10, 6, 0.72)",
|
||
border: "1.5px solid rgba(175, 138, 72, 0.60)",
|
||
borderRadius: "6px",
|
||
backdropFilter: "blur(10px)",
|
||
WebkitBackdropFilter: "blur(10px)",
|
||
boxShadow:
|
||
"0 4px 24px rgba(0,0,0,0.55), inset 0 1px 0 rgba(200,165,90,0.10)",
|
||
}}
|
||
>
|
||
<span
|
||
className="absolute top-[6px] left-[8px] text-[10px] opacity-40 pointer-events-none"
|
||
style={{ color: "rgba(195,155,75,1)" }}
|
||
aria-hidden
|
||
>
|
||
✦
|
||
</span>
|
||
<span
|
||
className="absolute top-[6px] right-[8px] text-[10px] opacity-40 pointer-events-none"
|
||
style={{ color: "rgba(195,155,75,1)" }}
|
||
aria-hidden
|
||
>
|
||
✦
|
||
</span>
|
||
|
||
{beat.speaker && (
|
||
<p
|
||
className={`font-serif smallcaps mb-[0.6em] ${
|
||
portrait ? "text-[13px]" : "text-[11px] md:text-[12px]"
|
||
}`}
|
||
style={{ color: "rgba(205,165,90,0.92)" }}
|
||
>
|
||
{beat.speaker}
|
||
</p>
|
||
)}
|
||
|
||
<p
|
||
className={`font-serif leading-[1.85] ${
|
||
portrait ? "text-[16px]" : "text-[13px] md:text-[15px]"
|
||
}`}
|
||
style={{ color: "rgba(245,235,210,0.95)" }}
|
||
>
|
||
{typedBody}
|
||
{beat.speaker && beat.narration && (
|
||
<span
|
||
className={`block mt-[0.5em] italic transition-opacity duration-300 ${
|
||
portrait ? "text-[14px]" : "text-[12px] md:text-[13px]"
|
||
} ${typingDone ? "opacity-100" : "opacity-0"}`}
|
||
style={{ color: "rgba(200,185,155,0.78)" }}
|
||
aria-hidden={!typingDone}
|
||
>
|
||
{beat.narration}
|
||
</span>
|
||
)}
|
||
</p>
|
||
|
||
{typingDone && beat.next.type === "continue" && (
|
||
<span
|
||
className="absolute bottom-[6px] right-[42px] text-[10px] animate-slow-pulse"
|
||
style={{ color: "rgba(195,155,75,0.7)" }}
|
||
aria-hidden
|
||
>
|
||
▼
|
||
</span>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setHistoryOpen(true);
|
||
}}
|
||
className="absolute bottom-[6px] right-[8px] flex h-7 w-7 items-center justify-center text-[rgba(195,155,75,0.78)] transition-colors hover:text-[rgba(245,235,210,0.96)]"
|
||
aria-label="打开剧情回溯"
|
||
title="剧情回溯"
|
||
>
|
||
<i className="fa-solid fa-clock-rotate-left text-[12px]" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{(phase === "transitioning" || phase === "inserting-beat") && (
|
||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||
<p className="text-[10px] smallcaps text-cream-50/70 animate-slow-pulse">
|
||
{phase === "transitioning"
|
||
? "AI · 正 · 在 · 描 · 画 · 下 · 一 · 幕"
|
||
: "AI · 正 · 在 · 想 · 你 · 看 · 到 · 了 · 什 · 么"}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{pendingClick && (
|
||
<>
|
||
<div
|
||
className="absolute rounded-full border border-ember-500 pointer-events-none"
|
||
style={{
|
||
left: `${pendingClick.x * 100}%`,
|
||
top: `${pendingClick.y * 100}%`,
|
||
transform: "translate(-50%, -50%)",
|
||
width: 30,
|
||
height: 30,
|
||
animation:
|
||
"infiplot-ripple 1.6s cubic-bezier(0.16,1,0.3,1) infinite",
|
||
}}
|
||
/>
|
||
<div
|
||
className="absolute rounded-full pointer-events-none"
|
||
style={{
|
||
left: `${pendingClick.x * 100}%`,
|
||
top: `${pendingClick.y * 100}%`,
|
||
transform: "translate(-50%, -50%)",
|
||
width: 11,
|
||
height: 11,
|
||
background: "#D97A2E",
|
||
boxShadow:
|
||
"0 0 0 3px rgba(251,247,240,0.95), 0 0 14px rgba(217,122,46,0.55)",
|
||
}}
|
||
/>
|
||
</>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div
|
||
className={`relative bg-cream-200 flex flex-col items-center justify-center gap-4 ${
|
||
portrait ? "" : "aspect-video"
|
||
}`}
|
||
style={{ ...placeholderStyle, boxShadow: fullViewport ? "none" : SHADOW }}
|
||
>
|
||
<div className="w-1.5 h-1.5 bg-clay-500 rounded-full animate-slow-pulse" />
|
||
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
|
||
正 · 在 · 绘 · 制 · 第 · 一 · 幕
|
||
</p>
|
||
{/* 加载占位也挂同一对 slot,让右上 / 左上的操作按钮在第一帧就出现 */}
|
||
{!fullViewport && aboveCanvas && (
|
||
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
||
{aboveCanvas}
|
||
</div>
|
||
)}
|
||
{!fullViewport && aboveCanvasLeft && (
|
||
<div className="absolute bottom-full left-0 mb-2 flex items-center gap-2">
|
||
{aboveCanvasLeft}
|
||
</div>
|
||
)}
|
||
{!fullViewport && belowCanvas && (
|
||
<div className="absolute top-full right-0 mt-2 flex items-center gap-2">
|
||
{belowCanvas}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
</div>
|
||
);
|
||
}
|