Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions app/api/game-assets/[slug]/[file]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function corsHeaders(extra: Record<string, string> = {}): Record<string, string>
};
}

export async function GET(_request: NextRequest, context: RouteContext) {
export async function GET(request: NextRequest, context: RouteContext) {
const { slug: rawSlug, file: rawFile } = await context.params;
const slug = rawSlug.trim().toLowerCase();
const filename = rawFile.trim();
Expand All @@ -37,8 +37,10 @@ export async function GET(_request: NextRequest, context: RouteContext) {
status: 200,
headers: corsHeaders({
"Content-Type": "model/gltf-binary",
"Content-Disposition": `attachment; filename="${filename}"`,
"Cache-Control": "public, max-age=86400, s-maxage=86400",
"Content-Disposition": `inline; filename="${filename}"`,
"Cache-Control": request.nextUrl.searchParams.has("v")
? "private, no-store"
: "public, max-age=86400, s-maxage=86400",
}),
});
}
Expand Down
1 change: 1 addition & 0 deletions app/api/reverse-game/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ async function executeGameReverse(opts: {
gameName,
force,
onStatus: (message) => send("status", { message }),
onHero: (hero) => send("hero", hero),
});

if (!result.ok) {
Expand Down
19 changes: 18 additions & 1 deletion components/game-reverse-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,16 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { GameSpecFlavorText } from "@/components/game-spec-flavor-text";
import { HeroKernelPreview } from "@/components/hero-kernel-preview";
import { MeshyLiveStage } from "@/components/meshy-live-stage";
import { Navbar } from "@/components/navbar";
import { PromptMarkdown } from "@/components/prompt-markdown";
import { nameToSlug, parseGameInput } from "@/lib/parse-game-input";
import type { StoredHeroAsset } from "@/lib/game-asset-storage";
import {
mergeHeroProgress,
parseHeroProgressEvent,
type HeroProgressEvent,
} from "@/lib/meshy-progress";

type GameReversePageProps = {
gameSlug: string;
Expand All @@ -29,6 +35,7 @@ export function GameReversePage({ gameSlug, gameName }: GameReversePageProps) {
const [heroAssets, setHeroAssets] = useState<
Array<StoredHeroAsset & { url: string }>
>([]);
const [liveHeroes, setLiveHeroes] = useState<HeroProgressEvent[]>([]);
const started = useRef(false);
const resultsRef = useRef<HTMLDivElement>(null);

Expand All @@ -53,6 +60,7 @@ export function GameReversePage({ gameSlug, gameName }: GameReversePageProps) {
setError(null);
setPrompt(null);
setHeroAssets([]);
setLiveHeroes([]);
setStatusLine("Checking if it's cached…");

try {
Expand Down Expand Up @@ -116,11 +124,17 @@ export function GameReversePage({ gameSlug, gameName }: GameReversePageProps) {
prompt?: string;
fromCache?: boolean;
error?: string;
};
} & Partial<HeroProgressEvent>;

if (event === "status" && typeof json.message === "string") {
setStatusLine(json.message);
}
if (event === "hero") {
const hero = parseHeroProgressEvent(json);
if (hero) {
setLiveHeroes((prev) => mergeHeroProgress(prev, hero));
}
}
if (event === "done" && typeof json.prompt === "string") {
setPrompt(json.prompt);
if (json.fromCache) setStatusLine("Loaded from cache");
Expand Down Expand Up @@ -303,6 +317,9 @@ export function GameReversePage({ gameSlug, gameName }: GameReversePageProps) {
) : null}
</form>
</div>
{loading && liveHeroes.length > 0 ? (
<MeshyLiveStage heroes={liveHeroes} />
) : null}
</div>

{prompt ? (
Expand Down
40 changes: 27 additions & 13 deletions components/hero-kernel-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ type HeroKernelPreviewProps = {
title?: string;
subtitle?: string;
autoClip?: QuaterniusClip;
compact?: boolean;
};

export function HeroKernelPreview({
modelUrl,
title = "Quaternius kernel",
subtitle,
autoClip = "Idle_Loop",
compact = false,
}: HeroKernelPreviewProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [clip, setClip] = useState<string>(autoClip);
Expand Down Expand Up @@ -109,8 +111,14 @@ export function HeroKernelPreview({
if (disposed) return;

const root = gltf.scene;
let skin: import("three").SkinnedMesh | null = null;
root.traverse((obj) => {
const mesh = obj as import("three").Mesh;
const mesh = obj as import("three").SkinnedMesh;
if (mesh.isSkinnedMesh) {
mesh.bind(mesh.skeleton, mesh.bindMatrix);
mesh.frustumCulled = false;
if (!skin) skin = mesh;
}
if (mesh.isMesh) {
mesh.castShadow = true;
mesh.receiveShadow = true;
Expand All @@ -122,10 +130,10 @@ export function HeroKernelPreview({
const names = clips.map((c) => c.name).filter(Boolean);
setAvailable(names.length ? names : [...KERNEL_PREVIEW_CLIPS]);

mixer = new THREE.AnimationMixer(root);
mixer = new THREE.AnimationMixer(skin ?? root);
const actions = new Map<string, import("three").AnimationAction>();
for (const c of clips) {
const action = mixer.clipAction(c);
const action = mixer.clipAction(c.clone());
action.enabled = true;
actions.set(c.name, action);
}
Expand Down Expand Up @@ -198,26 +206,32 @@ export function HeroKernelPreview({

return (
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-zinc-700">{title}</h3>
<p className="mt-0.5 text-xs text-zinc-500">
{subtitle ?? "Quaternius Universal Animation Library kernel"}
{compact ? null : (
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-zinc-700">{title}</h3>
<p className="mt-0.5 text-xs text-zinc-500">
{subtitle ?? "Quaternius Universal Animation Library kernel"}
</p>
</div>
<p className="text-xs font-medium text-zinc-500" role="status">
{error ? error : status}
</p>
</div>
<p className="text-xs font-medium text-zinc-500" role="status">
{error ? error : status}
</p>
</div>
)}
<div className="relative overflow-hidden rounded-lg border-[3px] border-zinc-900 bg-[#f6efe2]">
<canvas
ref={canvasRef}
className="block h-[22rem] w-full"
className={`block w-full ${compact ? "h-[16rem]" : "h-[22rem]"}`}
aria-label="Animated hero preview"
/>
</div>
{error ? (
<p className="text-sm text-red-700">{error}</p>
) : compact ? (
<p className="text-xs font-medium text-zinc-500" role="status">
{status}
</p>
) : (
<div className="flex flex-wrap gap-1.5">
{clipButtons.map((name) => (
Expand Down
69 changes: 69 additions & 0 deletions components/meshy-live-stage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";

import { HeroKernelPreview } from "@/components/hero-kernel-preview";
import type { HeroProgressEvent } from "@/lib/meshy-progress";

function stageLabel(hero: HeroProgressEvent): string {
if (hero.stage === "ready") {
return hero.kernel ? "Walking" : "Ready";
}
if (hero.stage === "kernel") return "Auto-rig";
if (hero.stage === "refine") return `Texture ${hero.progress}%`;
if (hero.stage === "failed") return "Failed";
return `Sculpt ${hero.progress}%`;
}

function LiveHeroCard({ hero }: { hero: HeroProgressEvent }) {
const moving = Boolean(hero.kernel && hero.modelUrl);
return (
<div className="relative">
<div className="absolute inset-0 translate-x-2 translate-y-2 rounded-xl bg-zinc-900" />
<section className="relative z-10 overflow-hidden rounded-xl border-[3px] border-zinc-900 bg-[#fafafa] p-4">
<div className="mb-2 flex items-baseline justify-between gap-3">
<p className="text-sm font-semibold text-zinc-800">{hero.id}</p>
<p className="text-xs font-medium text-zinc-500" role="status">
{stageLabel(hero)}
</p>
</div>
<div className="mb-3 h-1.5 overflow-hidden rounded-full bg-zinc-200">
<div
className="h-full bg-zinc-900 transition-[width] duration-500"
style={{ width: `${Math.max(hero.stage === "ready" ? 100 : hero.progress, 2)}%` }}
/>
</div>
{hero.modelUrl ? (
<HeroKernelPreview
key={hero.modelUrl}
modelUrl={hero.modelUrl}
autoClip={moving ? "Walk_Loop" : "Idle_Loop"}
compact
/>
) : hero.thumbnailUrl ? (
<div className="overflow-hidden rounded-lg border-[3px] border-zinc-900 bg-[#f6efe2]">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={hero.thumbnailUrl}
alt=""
className="mx-auto h-[16rem] w-auto object-contain"
/>
</div>
) : (
<div className="flex h-24 items-center justify-center rounded-lg border-[3px] border-dashed border-zinc-300 bg-white text-xs text-zinc-500">
{hero.status}
</div>
)}
</section>
</div>
);
}

export function MeshyLiveStage({ heroes }: { heroes: HeroProgressEvent[] }) {
if (!heroes.length) return null;
return (
<div className="flex w-full max-w-2xl flex-col gap-3" aria-live="polite">
{heroes.map((hero) => (
<LiveHeroCard key={hero.id} hero={hero} />
))}
</div>
);
}
Loading