feat: 16:9 landscape canvas + F-key presentation mode
- image prompt: vertical 9:16 → landscape 16:9 cinematic, scene fills canvas with bottom dialogue band and horizontal choice row - image-client: pass size=1792x1024 hint (provider honors it → output is now exact 16:9 instead of the model's default 1.75:1) - PlayCanvas: drop 560px cap, use object-contain into available space, add fullViewport prop for chrome-less presentation rendering - play page: F / Esc shortcuts + Fullscreen API + fullscreenchange sync; chrome-less black-letterbox overlay (bg-black) suited for screen recording Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Suspense, useEffect, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
|
import { PlayCanvas, type Phase } from "@/components/PlayCanvas";
|
||||||
import { PRESETS } from "@/lib/presets";
|
import { PRESETS } from "@/lib/presets";
|
||||||
import type {
|
import type {
|
||||||
@@ -29,11 +29,59 @@ function PlayInner() {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [turnNum, setTurnNum] = useState(0);
|
const [turnNum, setTurnNum] = useState(0);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [presentation, setPresentation] = useState(false);
|
||||||
|
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const prefetchAbortRef = useRef<AbortController | null>(null);
|
const prefetchAbortRef = useRef<AbortController | null>(null);
|
||||||
const prefetchRef = useRef<Record<string, Promise<InteractResponse>>>({});
|
const prefetchRef = useRef<Record<string, Promise<InteractResponse>>>({});
|
||||||
|
|
||||||
|
const togglePresentation = useCallback(async () => {
|
||||||
|
const entering = !presentation;
|
||||||
|
if (entering) {
|
||||||
|
try {
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
await document.documentElement.requestFullscreen();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Browser may refuse fullscreen — still enter chrome-less mode
|
||||||
|
}
|
||||||
|
setPresentation(true);
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if (document.fullscreenElement) {
|
||||||
|
await document.exitFullscreen();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
setPresentation(false);
|
||||||
|
}
|
||||||
|
}, [presentation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "f" || e.key === "F") {
|
||||||
|
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
e.preventDefault();
|
||||||
|
void togglePresentation();
|
||||||
|
} else if (e.key === "Escape" && presentation) {
|
||||||
|
setPresentation(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onFullscreenChange() {
|
||||||
|
// Sync if user exited browser fullscreen via Esc / system gesture
|
||||||
|
if (!document.fullscreenElement && presentation) {
|
||||||
|
setPresentation(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
document.addEventListener("fullscreenchange", onFullscreenChange);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", onKey);
|
||||||
|
document.removeEventListener("fullscreenchange", onFullscreenChange);
|
||||||
|
};
|
||||||
|
}, [togglePresentation, presentation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (startedRef.current) return;
|
if (startedRef.current) return;
|
||||||
startedRef.current = true;
|
startedRef.current = true;
|
||||||
@@ -241,6 +289,20 @@ function PlayInner() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (presentation) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 bg-black flex items-center justify-center z-50">
|
||||||
|
<PlayCanvas
|
||||||
|
imageBase64={imageBase64}
|
||||||
|
phase={phase}
|
||||||
|
pendingClick={pendingClick}
|
||||||
|
onClick={handleClick}
|
||||||
|
fullViewport
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col">
|
<div className="min-h-screen flex flex-col">
|
||||||
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
<header className="px-5 md:px-12 pt-6 md:pt-8 flex items-center justify-between">
|
||||||
@@ -300,10 +362,18 @@ function PlayInner() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="px-5 md:px-12 pb-6">
|
<footer className="px-5 md:px-12 pb-6 flex items-center justify-between">
|
||||||
<div className="text-[9px] smallcaps text-clay-400 text-center num">
|
<button
|
||||||
Ⅰ · Ⅰ
|
type="button"
|
||||||
</div>
|
onClick={() => void togglePresentation()}
|
||||||
|
className="text-[9px] smallcaps text-clay-400 hover:text-clay-700 transition-colors flex items-center gap-2"
|
||||||
|
aria-label="进入演示模式"
|
||||||
|
>
|
||||||
|
<i className="fa-solid fa-expand text-[10px]" />
|
||||||
|
F · 演 · 示
|
||||||
|
</button>
|
||||||
|
<div className="text-[9px] smallcaps text-clay-400 num">Ⅰ · Ⅰ</div>
|
||||||
|
<span className="text-[9px] w-[60px]" aria-hidden />
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ export function PlayCanvas({
|
|||||||
phase,
|
phase,
|
||||||
pendingClick,
|
pendingClick,
|
||||||
onClick,
|
onClick,
|
||||||
|
fullViewport = false,
|
||||||
}: {
|
}: {
|
||||||
imageBase64: string | null;
|
imageBase64: string | null;
|
||||||
phase: Phase;
|
phase: Phase;
|
||||||
pendingClick: { x: number; y: number } | null;
|
pendingClick: { x: number; y: number } | null;
|
||||||
onClick: (click: { x: number; y: number }) => void;
|
onClick: (click: { x: number; y: number }) => void;
|
||||||
|
fullViewport?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
|
||||||
@@ -35,10 +37,26 @@ export function PlayCanvas({
|
|||||||
const interactive = phase === "ready" && !!imageBase64;
|
const interactive = phase === "ready" && !!imageBase64;
|
||||||
const dimmed = phase === "interacting";
|
const dimmed = phase === "interacting";
|
||||||
|
|
||||||
|
// 16:9 sizing — letterbox into available viewport
|
||||||
|
const sizeStyle = fullViewport
|
||||||
|
? { maxWidth: "100vw", maxHeight: "100dvh" }
|
||||||
|
: { maxWidth: "96vw", maxHeight: "calc(100dvh - 280px)" };
|
||||||
|
|
||||||
|
// Placeholder needs an explicit width for aspect-video to compute height.
|
||||||
|
// Pick the largest 16:9 box that fits in the available viewport.
|
||||||
|
const placeholderWidth = fullViewport
|
||||||
|
? "min(100vw, calc(100dvh * 16 / 9))"
|
||||||
|
: "min(96vw, calc((100dvh - 280px) * 16 / 9))";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex flex-col items-center">
|
<div
|
||||||
|
className={`flex flex-col items-center ${fullViewport ? "w-full h-full justify-center" : "w-full"}`}
|
||||||
|
>
|
||||||
{imageBase64 ? (
|
{imageBase64 ? (
|
||||||
<div className="relative inline-block" style={{ boxShadow: SHADOW }}>
|
<div
|
||||||
|
className="relative inline-block"
|
||||||
|
style={{ boxShadow: fullViewport ? "none" : SHADOW }}
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
key={imageBase64.slice(-48)}
|
key={imageBase64.slice(-48)}
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
@@ -51,14 +69,15 @@ export function PlayCanvas({
|
|||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
className={`block w-auto h-auto select-none animate-fade-in transition-opacity duration-700 ease-out ${interactive ? "cursor-pointer" : "cursor-wait"} ${dimmed ? "opacity-30" : "opacity-100"}`}
|
className={`block w-auto h-auto select-none animate-fade-in transition-opacity duration-700 ease-out ${interactive ? "cursor-pointer" : "cursor-wait"} ${dimmed ? "opacity-30" : "opacity-100"}`}
|
||||||
style={{
|
style={sizeStyle}
|
||||||
maxWidth: "min(560px, 92vw)",
|
|
||||||
maxHeight: "calc(100dvh - 200px)",
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
|
{!fullViewport && (
|
||||||
<div className="absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-clay-900/12 to-transparent pointer-events-none" />
|
<>
|
||||||
|
<div className="absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-clay-900/12 to-transparent pointer-events-none" />
|
||||||
|
<div className="absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-clay-900/12 to-transparent pointer-events-none" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{pendingClick && (
|
{pendingClick && (
|
||||||
<>
|
<>
|
||||||
@@ -92,10 +111,10 @@ export function PlayCanvas({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="relative aspect-[2/3] bg-cream-200 flex flex-col items-center justify-center gap-4"
|
className="relative aspect-video bg-cream-200 flex flex-col items-center justify-center gap-4"
|
||||||
style={{
|
style={{
|
||||||
width: "min(560px, calc((100dvh - 200px) * 2 / 3), 92vw)",
|
width: placeholderWidth,
|
||||||
boxShadow: SHADOW,
|
boxShadow: fullViewport ? "none" : SHADOW,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-1.5 h-1.5 bg-clay-500 rounded-full animate-slow-pulse" />
|
<div className="w-1.5 h-1.5 bg-clay-500 rounded-full animate-slow-pulse" />
|
||||||
@@ -105,17 +124,19 @@ export function PlayCanvas({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
{!fullViewport && (
|
||||||
className="flex items-center justify-between mt-3 px-1 w-full"
|
<div
|
||||||
style={{ maxWidth: "min(560px, 92vw)" }}
|
className="flex items-center justify-between mt-3 px-1 w-full"
|
||||||
>
|
style={{ maxWidth: "96vw" }}
|
||||||
<span className="text-[9px] smallcaps text-clay-400 num">
|
>
|
||||||
{dims ? `${dims.w} × ${dims.h} · png` : "—"}
|
<span className="text-[9px] smallcaps text-clay-400 num">
|
||||||
</span>
|
{dims ? `${dims.w} × ${dims.h} · png` : "—"}
|
||||||
<span className="text-[9px] smallcaps text-clay-400">
|
</span>
|
||||||
{phase === "ready" ? "任 · 意 · 点 · 击" : "···"}
|
<span className="text-[9px] smallcaps text-clay-400">
|
||||||
</span>
|
{phase === "ready" ? "任 · 意 · 点 · 击" : "···"}
|
||||||
</div>
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export async function generateImage(
|
|||||||
const body = {
|
const body = {
|
||||||
model: config.model,
|
model: config.model,
|
||||||
modalities: ["image", "text"],
|
modalities: ["image", "text"],
|
||||||
|
size: "1792x1024",
|
||||||
messages: [{ role: "user", content: prompt }],
|
messages: [{ role: "user", content: prompt }],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -65,31 +65,32 @@ export function buildImagePrompt(
|
|||||||
.map((e) => `- ${e.kind}: ${e.label}`)
|
.map((e) => `- ${e.kind}: ${e.label}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|
||||||
return `Generate a vertical 9:16 visual novel UI screen.
|
return `Generate a landscape 16:9 cinematic visual novel UI screen, widescreen format (1792x1024 or equivalent).
|
||||||
|
|
||||||
ART STYLE: ${styleGuide}
|
ART STYLE: ${styleGuide}
|
||||||
(Match this style consistently — for the scene art AND the UI elements.
|
(Match this style consistently — for the scene art AND the UI elements.
|
||||||
For example: anime → traditional galgame dialogue box; cyberpunk → neon HUD;
|
For example: anime → traditional galgame dialogue box; cyberpunk → neon HUD;
|
||||||
stick figure → hand-drawn paper UI; cinematic realism → minimalist film overlay.)
|
stick figure → hand-drawn paper UI; cinematic realism → minimalist film overlay.)
|
||||||
|
|
||||||
SCENE (occupies the upper portion of the image):
|
SCENE (fills the entire 16:9 canvas as a cinematic widescreen background):
|
||||||
${frame.scenePrompt}
|
${frame.scenePrompt}
|
||||||
|
|
||||||
DIALOGUE PANEL (semi-transparent, lower-middle area):
|
DIALOGUE PANEL (cinematic bottom band, semi-transparent, spans full width, occupies the lower ~25% of the frame):
|
||||||
${frame.speaker ? `Speaker name displayed prominently: "${frame.speaker}"` : "Narration only — no speaker tag."}
|
${frame.speaker ? `Speaker name displayed prominently above the dialogue text: "${frame.speaker}"` : "Narration only — no speaker tag."}
|
||||||
${frame.line ? `Dialogue text: "${frame.line}"` : ""}
|
${frame.line ? `Dialogue text: "${frame.line}"` : ""}
|
||||||
${frame.narration ? `Narration text (italic if speaker also present): "${frame.narration}"` : ""}
|
${frame.narration ? `Narration text (italic if speaker also present): "${frame.narration}"` : ""}
|
||||||
|
|
||||||
CHOICE PANEL (bottom area, three clearly tappable buttons stacked or arranged):
|
CHOICE PANEL (three clearly tappable buttons, arranged HORIZONTALLY in a row across the lower-third of the frame, ABOVE or overlaid on the dialogue band; equally sized; centered in the safe zone of the 16:9 canvas):
|
||||||
${choiceList}
|
${choiceList}
|
||||||
${extraUI ? `\nADDITIONAL UI ELEMENTS:\n${extraUI}` : ""}
|
${extraUI ? `\nADDITIONAL UI ELEMENTS:\n${extraUI}` : ""}
|
||||||
|
|
||||||
CRITICAL LAYOUT REQUIREMENTS:
|
CRITICAL LAYOUT REQUIREMENTS:
|
||||||
- All text must be perfectly legible (high contrast, readable size)
|
- 16:9 LANDSCAPE orientation — wider than tall. Do NOT produce a portrait/square image.
|
||||||
- Choice buttons must be clearly distinguishable as interactive elements
|
- All text and buttons must be inside the central safe zone (avoid the outer 8% on every side), so the viewport can letterbox without cropping any UI.
|
||||||
- Choice text must NOT be cropped, NOT overlap with character faces
|
- All text must be perfectly legible (high contrast, readable size).
|
||||||
- The image is the entire interface — no external chrome will be added
|
- Choice buttons must be clearly distinguishable as interactive elements, arranged horizontally left-to-right in the order listed above.
|
||||||
- Choices appear in the order listed above`;
|
- Choice text must NOT be cropped, NOT overlap with character faces or the dialogue panel.
|
||||||
|
- The image is the entire interface — no external chrome will be added.`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VISION_SYSTEM_PROMPT = `你是视觉理解助手。用户在视觉小说界面上点击了红色圆点位置,你要根据红点位置和图中可见的 UI 元素,判断用户的意图。
|
export const VISION_SYSTEM_PROMPT = `你是视觉理解助手。用户在视觉小说界面上点击了红色圆点位置,你要根据红点位置和图中可见的 UI 元素,判断用户的意图。
|
||||||
|
|||||||
Reference in New Issue
Block a user