feat(loading): support typewriter story teaser during first scene generation
This commit is contained in:
@@ -0,0 +1,45 @@
|
|||||||
|
import { chat } from "@infiplot/ai-client";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { loadEngineConfig } from "@/lib/config";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const maxDuration = 30;
|
||||||
|
|
||||||
|
const TEASER_SYSTEM = `你是一个交互视觉小说的“故事预告设计师/旁白配音员”。
|
||||||
|
根据用户输入的故事设定、面向观众、剧情风格和内容节奏,为该故事撰写一段富有悬念、画面感极强、极具吸引力的【故事预告】(类似电影预告片旁白风格)。
|
||||||
|
|
||||||
|
要求:
|
||||||
|
1. 语言必须富有情感、张力、史诗感或治愈感(根据题材基调决定),用第二人称“你”指代玩家。
|
||||||
|
2. 长度控制在 80-150 字以内,字句简练,用字考究,多用短句。
|
||||||
|
3. 绝对只返回预告片纯文本内容,不要带有任何 JSON 标记、Markdown 标题或“预告:”等任何额外字符。直接输出文字本身。`;
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
let body: { worldSetting?: string };
|
||||||
|
try {
|
||||||
|
body = (await req.json()) as { worldSetting?: string };
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const worldSetting = body.worldSetting?.trim();
|
||||||
|
if (!worldSetting) {
|
||||||
|
return NextResponse.json({ error: "worldSetting is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = loadEngineConfig(req.headers);
|
||||||
|
const rawTeaser = await chat(
|
||||||
|
config.text,
|
||||||
|
[
|
||||||
|
{ role: "system", content: TEASER_SYSTEM },
|
||||||
|
{ role: "user", content: `故事设定如下,请生成一段精彩的预告:\n\n${worldSetting}` },
|
||||||
|
],
|
||||||
|
{ temperature: 0.85, tag: "teaser" }
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ teaser: rawTeaser.trim() });
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
return NextResponse.json({ error: message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -402,6 +402,7 @@ function PlayInner() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [presentation, setPresentation] = useState(false);
|
const [presentation, setPresentation] = useState(false);
|
||||||
const [lastExitLabel, setLastExitLabel] = useState<string | null>(null);
|
const [lastExitLabel, setLastExitLabel] = useState<string | null>(null);
|
||||||
|
const [teaserText, setTeaserText] = useState<string | null>(null);
|
||||||
|
|
||||||
const startedRef = useRef(false);
|
const startedRef = useRef(false);
|
||||||
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
const poolRef = useRef<Map<string, PrefetchEntry>>(new Map());
|
||||||
@@ -694,6 +695,25 @@ function PlayInner() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (livePayload) {
|
||||||
|
fetch("/api/teaser", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...getByoHeaders(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ worldSetting: livePayload.worldSetting }),
|
||||||
|
})
|
||||||
|
.then(async (r) => {
|
||||||
|
if (!r.ok) return;
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.teaser) {
|
||||||
|
setTeaserText(data.teaser);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
type PrebakedFirstAct = StartResponse & {
|
type PrebakedFirstAct = StartResponse & {
|
||||||
worldSetting: string;
|
worldSetting: string;
|
||||||
styleGuide: string;
|
styleGuide: string;
|
||||||
@@ -1155,6 +1175,7 @@ function PlayInner() {
|
|||||||
onAdvance={onAdvance}
|
onAdvance={onAdvance}
|
||||||
onSelectChoice={onSelectChoice}
|
onSelectChoice={onSelectChoice}
|
||||||
fullViewport
|
fullViewport
|
||||||
|
teaserText={teaserText}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1194,6 +1215,7 @@ function PlayInner() {
|
|||||||
onBackgroundClick={onBackgroundClick}
|
onBackgroundClick={onBackgroundClick}
|
||||||
onAdvance={onAdvance}
|
onAdvance={onAdvance}
|
||||||
onSelectChoice={onSelectChoice}
|
onSelectChoice={onSelectChoice}
|
||||||
|
teaserText={teaserText}
|
||||||
aboveCanvas={
|
aboveCanvas={
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ export function PlayCanvas({
|
|||||||
fullViewport = false,
|
fullViewport = false,
|
||||||
aboveCanvas,
|
aboveCanvas,
|
||||||
aboveCanvasLeft,
|
aboveCanvasLeft,
|
||||||
|
teaserText,
|
||||||
}: {
|
}: {
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
audioBase64: string | null;
|
audioBase64: string | null;
|
||||||
@@ -188,6 +189,7 @@ export function PlayCanvas({
|
|||||||
aboveCanvas?: ReactNode;
|
aboveCanvas?: ReactNode;
|
||||||
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
// 渲染在图片正上方、左对齐的 slot(画面外、紧贴左上角),与 aboveCanvas 水平镜像。
|
||||||
aboveCanvasLeft?: ReactNode;
|
aboveCanvasLeft?: ReactNode;
|
||||||
|
teaserText?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
@@ -207,6 +209,10 @@ export function PlayCanvas({
|
|||||||
waitForAudio: Boolean(audioBase64),
|
waitForAudio: Boolean(audioBase64),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { shown: typedTeaser } = useTypewriter(teaserText ?? "", "teaser_reset", {
|
||||||
|
waitForAudio: false,
|
||||||
|
});
|
||||||
|
|
||||||
// ── Audio source change ──────────────────────────────────────────────
|
// ── Audio source change ──────────────────────────────────────────────
|
||||||
// Reset duration when audio source changes; if loading takes too long,
|
// Reset duration when audio source changes; if loading takes too long,
|
||||||
// unblock the typewriter via timeout so text doesn't stall.
|
// unblock the typewriter via timeout so text doesn't stall.
|
||||||
@@ -488,16 +494,35 @@ export function PlayCanvas({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="relative aspect-video 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 p-6 md:p-12 text-center"
|
||||||
style={{
|
style={{
|
||||||
width: placeholderWidth,
|
width: placeholderWidth,
|
||||||
boxShadow: fullViewport ? "none" : SHADOW,
|
boxShadow: fullViewport ? "none" : SHADOW,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="w-1.5 h-1.5 bg-clay-500 rounded-full animate-slow-pulse" />
|
{teaserText ? (
|
||||||
<p className="text-[9px] smallcaps text-clay-500 animate-slow-pulse">
|
<div className="max-w-[85%] md:max-w-[75%] flex flex-col items-center justify-center gap-3.5 md:gap-5 animate-fade-in">
|
||||||
正 · 在 · 绘 · 制 · 第 · 一 · 幕
|
<span className="text-[9px] smallcaps text-clay-400 tracking-[0.2em] font-sans">
|
||||||
</p>
|
✦ 故 · 事 · 预 · 告 ✦
|
||||||
|
</span>
|
||||||
|
<p className="font-serif leading-[2] text-clay-700 text-[13px] md:text-[15px] italic whitespace-pre-wrap">
|
||||||
|
{typedTeaser}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 md:mt-4 flex items-center gap-2.5">
|
||||||
|
<span className="w-1.5 h-1.5 bg-ember-500 rounded-full animate-slow-pulse" />
|
||||||
|
<span className="text-[9px] smallcaps text-clay-400">
|
||||||
|
正 · 在 · 绘 · 制 · 第 · 一 · 幕
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 加载占位也挂同一对 slot,让右上 / 左上的操作按钮在第一帧就出现 */}
|
{/* 加载占位也挂同一对 slot,让右上 / 左上的操作按钮在第一帧就出现 */}
|
||||||
{!fullViewport && aboveCanvas && (
|
{!fullViewport && aboveCanvas && (
|
||||||
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
<div className="absolute bottom-full right-0 mb-2 flex items-center gap-2">
|
||||||
|
|||||||
+2
-1
@@ -6,6 +6,7 @@
|
|||||||
"app/api/scene/route.ts": { "maxDuration": 60 },
|
"app/api/scene/route.ts": { "maxDuration": 60 },
|
||||||
"app/api/vision/route.ts": { "maxDuration": 60 },
|
"app/api/vision/route.ts": { "maxDuration": 60 },
|
||||||
"app/api/insert-beat/route.ts": { "maxDuration": 60 },
|
"app/api/insert-beat/route.ts": { "maxDuration": 60 },
|
||||||
"app/api/beat-audio/route.ts": { "maxDuration": 30 }
|
"app/api/beat-audio/route.ts": { "maxDuration": 30 },
|
||||||
|
"app/api/teaser/route.ts": { "maxDuration": 30 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user