feat(web,engine): portrait-orientation scene images for mobile full-bleed

Thread orientation (portrait|landscape) from client through API, engine,
and image gen. Portrait devices render 1024x1792 (9:16) full-bleed scenes;
desktop/landscape keeps 1792x1024 (16:9). Adds cover-aware click→image
coordinate mapping, session-locked orientation, a shared coerceOrientation
helper, and a choices overflow cap in portrait.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
yuanzonghao
2026-06-04 15:58:56 +08:00
parent 77f5296e18
commit 9fc83de276
10 changed files with 268 additions and 61 deletions
+59 -1
View File
@@ -24,6 +24,7 @@ import type {
Character,
CharacterVoice,
InsertBeatResponse,
Orientation,
Scene,
SceneExit,
SceneResponse,
@@ -58,6 +59,17 @@ function getByoHeaders(): Record<string, string> {
// it, low enough to catch a scene that's clearly being rate-limited.
const SILENCE_NUDGE_THRESHOLD = 3;
// Mobile-portrait users get a 9:16 scene image painted for them; everyone else
// (desktop, tablet, mobile-landscape) keeps the 16:9 landscape image. Only a
// touch device (coarse pointer) held upright counts as "portrait" — a mouse
// device is always landscape. Detected once and locked for the whole session.
function detectOrientation(): Orientation {
if (typeof window === "undefined") return "landscape";
const portrait = window.matchMedia("(orientation: portrait)").matches;
const coarse = window.matchMedia("(pointer: coarse)").matches;
return portrait && coarse ? "portrait" : "landscape";
}
// Cap how long we wait for the browser to download + decode a scene image
// before giving up and rendering anyway. Runware's CDN is usually <2s for a
// 1792×1024 PNG, but over slow links / VPN / strict corp networks the same
@@ -457,6 +469,9 @@ function PlayInner() {
} | null>(null);
const [error, setError] = useState<string | null>(null);
const [presentation, setPresentation] = useState(false);
// Session-locked image orientation (see detectOrientation). "portrait" makes
// the whole play surface render full-bleed vertical on phones.
const [orientation, setOrientation] = useState<Orientation>("landscape");
const [lastExitLabel, setLastExitLabel] = useState<string | null>(null);
// Consecutive server-side TTS misses (null audio / failed /api/beat-audio).
// Climbs when the shared server key is rate-limited by MiMo — the exact pain
@@ -801,6 +816,7 @@ function PlayInner() {
worldSetting: string;
styleGuide: string;
styleReferenceImage?: string;
orientation?: Orientation;
} | null = null;
if (!cardName) {
if (presetId) {
@@ -829,6 +845,15 @@ function PlayInner() {
}
}
// Lock orientation for the whole session. Prebaked cards (decision C) are
// landscape-baked, so they stay landscape regardless of device; only the
// live /api/start path requests a portrait paint when the phone is upright.
const sessionOrientation: Orientation = cardName
? "landscape"
: detectOrientation();
setOrientation(sessionOrientation);
if (livePayload) livePayload.orientation = sessionOrientation;
if (!cardName && !livePayload) {
router.replace("/");
return;
@@ -903,6 +928,7 @@ function PlayInner() {
characters: data.characters,
storyState: data.storyState,
styleReferenceImage: data.styleReferenceImage,
orientation: data.scene.orientation ?? sessionOrientation,
};
visitedBeatsRef.current = [data.scene.entryBeatId];
setSession(initial);
@@ -1290,7 +1316,13 @@ function PlayInner() {
);
}
if (presentation) {
// Mobile portrait renders full-bleed by default — it sidesteps the iOS
// Safari Fullscreen API (unsupported on iPhone) with a CSS full-viewport
// layout instead. Desktop "presentation" mode shares the same immersive
// canvas, toggled via the F key.
const immersive = presentation || orientation === "portrait";
if (immersive) {
return (
<div className="fixed inset-0 bg-black flex items-center justify-center z-50">
<PlayCanvas
@@ -1304,8 +1336,33 @@ function PlayInner() {
onBackgroundClick={onBackgroundClick}
onAdvance={onAdvance}
onSelectChoice={onSelectChoice}
orientation={orientation}
fullViewport
/>
{orientation === "portrait" && (
<div
className="absolute inset-x-0 top-0 z-10 flex items-center justify-between px-4 pointer-events-none"
style={{ paddingTop: "max(0.5rem, env(safe-area-inset-top))" }}
>
<Link
href="/"
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
aria-label="返回"
>
<i className="fa-solid fa-arrow-left text-[13px]" />
</Link>
<button
type="button"
onClick={toggleMuted}
className="pointer-events-auto flex h-9 w-9 items-center justify-center rounded-full bg-black/40 text-white/80 backdrop-blur-sm transition-colors hover:text-white"
aria-label={muted ? "取消静音" : "静音"}
>
<i
className={`fa-solid ${muted ? "fa-volume-xmark" : "fa-volume-high"} text-[13px]`}
/>
</button>
</div>
)}
</div>
);
}
@@ -1354,6 +1411,7 @@ function PlayInner() {
onBackgroundClick={onBackgroundClick}
onAdvance={onAdvance}
onSelectChoice={onSelectChoice}
orientation={orientation}
aboveCanvas={
<button
type="button"