From 9fc83de276048de386ebb9c0c4967bc00c2857ba Mon Sep 17 00:00:00 2001 From: yuanzonghao Date: Thu, 4 Jun 2026 15:58:56 +0800 Subject: [PATCH 1/2] feat(web,engine): portrait-orientation scene images for mobile full-bleed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/layout.tsx | 11 +++- app/play/page.tsx | 60 ++++++++++++++++- components/PlayCanvas.tsx | 124 +++++++++++++++++++++++++---------- lib/ai-client/image.ts | 33 +++++++--- lib/engine/agents/painter.ts | 15 ++++- lib/engine/director.ts | 7 ++ lib/engine/mockImage.ts | 28 +++++--- lib/engine/orchestrator.ts | 2 + lib/engine/prompts.ts | 14 +++- lib/types/index.ts | 35 ++++++++++ 10 files changed, 268 insertions(+), 61 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index ec8a719..f76e561 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Cormorant_Garamond, Inter } from "next/font/google"; import { Analytics } from "@/components/Analytics"; import "./globals.css"; @@ -25,6 +25,15 @@ export const metadata: Metadata = { description: "InfiPlot 是一款用 AI 实时生成图片、语音与剧情分支的交互式剧情游戏 Demo。", }; +// viewportFit:cover lets the immersive /play portrait layout extend under the +// iOS notch / home-indicator and exposes env(safe-area-inset-*) to the +// floating controls. device-width + initialScale keep mobile rendering 1:1. +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", +}; + export default function RootLayout({ children, }: { diff --git a/app/play/page.tsx b/app/play/page.tsx index 0793efd..53665df 100644 --- a/app/play/page.tsx +++ b/app/play/page.tsx @@ -24,6 +24,7 @@ import type { Character, CharacterVoice, InsertBeatResponse, + Orientation, Scene, SceneExit, SceneResponse, @@ -58,6 +59,17 @@ function getByoHeaders(): Record { // 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(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("landscape"); const [lastExitLabel, setLastExitLabel] = useState(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 (
+ {orientation === "portrait" && ( +
+ + + + +
+ )}
); } @@ -1354,6 +1411,7 @@ function PlayInner() { onBackgroundClick={onBackgroundClick} onAdvance={onAdvance} onSelectChoice={onSelectChoice} + orientation={orientation} aboveCanvas={