Merge remote-tracking branch 'origin/codex/bbyy-production-156-20260820' into codex/deploy-48-bloub-20260902
This commit is contained in:
commit
0ed21b1b76
@ -2182,7 +2182,7 @@ export function AdminSettingsClient() {
|
||||
usageTimeEnabled: false,
|
||||
usageTimeStart: "08:00",
|
||||
usageTimeEnd: "20:00",
|
||||
usageTimeMessage: "由壹点累了也是需要休息的",
|
||||
usageTimeMessage: "BuYi 累了也是需要休息的",
|
||||
defaultTheme: "system",
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
@ -2224,7 +2224,7 @@ export function AdminSettingsClient() {
|
||||
agentWorkflowDefaultModelId: null,
|
||||
canvasDetailDefaultModelId: null,
|
||||
canvasDetailAgentModelId: null,
|
||||
lovartComposerPrefix: "让「由壹点AI」",
|
||||
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
|
||||
lovartComposerPreviewTexts: [
|
||||
"打造引人注目的社交媒体视觉",
|
||||
"帮我把这张产品图做成高级电商海报",
|
||||
@ -2240,8 +2240,8 @@ export function AdminSettingsClient() {
|
||||
lovartPromoText:
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||
surpriseCategories: ["全部", "精选"],
|
||||
membershipConfig: defaultMembershipConfig,
|
||||
membershipUserOverrides: defaultMembershipUserOverrides,
|
||||
@ -2302,7 +2302,7 @@ export function AdminSettingsClient() {
|
||||
usageTimeStart: siteSettingsData.usageTimeStart || "08:00",
|
||||
usageTimeEnd: siteSettingsData.usageTimeEnd || "20:00",
|
||||
usageTimeMessage:
|
||||
siteSettingsData.usageTimeMessage || "由壹点累了也是需要休息的",
|
||||
siteSettingsData.usageTimeMessage || "BuYi 累了也是需要休息的",
|
||||
defaultTheme: siteSettingsData.defaultTheme || "system",
|
||||
bannerEnabled: siteSettingsData.bannerEnabled ?? true,
|
||||
bannerText:
|
||||
@ -2398,7 +2398,7 @@ export function AdminSettingsClient() {
|
||||
(siteSettingsData as any).canvasDetailAgentModelId || null,
|
||||
lovartComposerPrefix:
|
||||
String((siteSettingsData as any).lovartComposerPrefix || "").trim() ||
|
||||
"让「由壹点AI」",
|
||||
"让「𝗕𝘂𝗬𝗶」",
|
||||
lovartComposerPreviewTexts: Array.isArray(
|
||||
siteSettingsData.lovartComposerPreviewTexts
|
||||
)
|
||||
@ -2500,9 +2500,9 @@ export function AdminSettingsClient() {
|
||||
lovartPromoActionText:
|
||||
siteSettingsData.lovartPromoActionText || "立即升级",
|
||||
lovartHeroTitle:
|
||||
siteSettingsData.lovartHeroTitle || "由壹点AI 让设计更简单",
|
||||
siteSettingsData.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle:
|
||||
siteSettingsData.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
||||
siteSettingsData.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||
surpriseCategories: Array.isArray(siteSettingsData.surpriseCategories)
|
||||
? siteSettingsData.surpriseCategories
|
||||
.map((item: any) => String(item).trim())
|
||||
@ -10524,7 +10524,7 @@ export function AdminSettingsClient() {
|
||||
lovartComposerPrefix: e.target.value.slice(0, 40),
|
||||
})
|
||||
}
|
||||
placeholder="让「由壹点AI」"
|
||||
placeholder="让「𝗕𝘂𝗬𝗶」"
|
||||
value={siteSettings.lovartComposerPrefix}
|
||||
/>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
@ -10726,7 +10726,7 @@ export function AdminSettingsClient() {
|
||||
lovartHeroTitle: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="由壹点AI 让设计更简单"
|
||||
placeholder="𝗕𝘂𝗬𝗶 让设计触手可及"
|
||||
value={siteSettings.lovartHeroTitle}
|
||||
/>
|
||||
</div>
|
||||
@ -10740,7 +10740,7 @@ export function AdminSettingsClient() {
|
||||
lovartHeroSubtitle: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="懂你的设计代理,帮你搞定一切"
|
||||
placeholder="下达指令,即刻呈现"
|
||||
value={siteSettings.lovartHeroSubtitle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Suspense } from "react";
|
||||
import { ChatShell } from "@/components/chat-shell";
|
||||
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
|
||||
import { DataStreamProvider } from "@/components/data-stream-provider";
|
||||
import { auth } from "../(auth)/auth";
|
||||
import {
|
||||
@ -13,7 +14,9 @@ export const dynamic = "force-dynamic";
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<DataStreamProvider>
|
||||
<Suspense fallback={<div className="flex h-dvh" />}>
|
||||
<Suspense
|
||||
fallback={<CanvasLogoLoadingView label="正在加载页面..." />}
|
||||
>
|
||||
<SidebarWrapper>{children}</SidebarWrapper>
|
||||
</Suspense>
|
||||
</DataStreamProvider>
|
||||
|
||||
@ -190,6 +190,48 @@ function assertArchivedAssetBuffer(params: {
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyArchivedAssetUpload(params: {
|
||||
url: string;
|
||||
expectedBytes: number;
|
||||
validateAs?: ArchiveValidationKind;
|
||||
}) {
|
||||
const headResponse = await fetch(params.url, {
|
||||
method: "HEAD",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!headResponse.ok) {
|
||||
throw new Error(`归档文件校验失败(HTTP ${headResponse.status})`);
|
||||
}
|
||||
|
||||
const archivedLength = Number(headResponse.headers.get("content-length"));
|
||||
if (
|
||||
Number.isFinite(archivedLength) &&
|
||||
archivedLength > 0 &&
|
||||
archivedLength !== params.expectedBytes
|
||||
) {
|
||||
throw new Error(
|
||||
`归档文件大小不一致(源文件 ${params.expectedBytes},归档文件 ${archivedLength})`
|
||||
);
|
||||
}
|
||||
|
||||
if (!params.validateAs) return;
|
||||
|
||||
const rangeResponse = await fetch(params.url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-63" },
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!rangeResponse.ok) {
|
||||
throw new Error(`归档文件头校验失败(HTTP ${rangeResponse.status})`);
|
||||
}
|
||||
const headerBuffer = await rangeResponse.arrayBuffer();
|
||||
assertArchivedAssetBuffer({
|
||||
buffer: headerBuffer,
|
||||
contentType: rangeResponse.headers.get("content-type"),
|
||||
validateAs: params.validateAs,
|
||||
});
|
||||
}
|
||||
|
||||
async function archiveRemoteAssetIfNeeded(params: {
|
||||
cacheKey: string;
|
||||
sourceUrl?: string | null;
|
||||
@ -249,6 +291,11 @@ async function archiveRemoteAssetIfNeeded(params: {
|
||||
);
|
||||
const filename = `${params.filenamePrefix}.${ext}`;
|
||||
const uploaded = await uploadFile(filename, assetBuffer);
|
||||
await verifyArchivedAssetUpload({
|
||||
url: uploaded.url,
|
||||
expectedBytes: assetBuffer.byteLength,
|
||||
validateAs: params.validateAs,
|
||||
});
|
||||
cache.set(params.cacheKey, {
|
||||
sourceUrl,
|
||||
archivedUrl: uploaded.url,
|
||||
@ -299,7 +346,8 @@ async function archiveRemoteAssetMapIfNeeded(params: {
|
||||
)
|
||||
).filter((entry): entry is readonly [string, string] => Boolean(entry));
|
||||
|
||||
return archivedEntries.length > 0 ? Object.fromEntries(archivedEntries) : null;
|
||||
if (archivedEntries.length !== entries.length) return null;
|
||||
return Object.fromEntries(archivedEntries);
|
||||
}
|
||||
|
||||
function getStringValue(...values: unknown[]) {
|
||||
@ -837,6 +885,19 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
fallbackExtension: "spz",
|
||||
validateAs: "spz",
|
||||
});
|
||||
const sourceSpzEntryCount = Object.keys(
|
||||
extractedAssetPayload.spzUrls || {}
|
||||
).length;
|
||||
const stableSpzEntryCount = Object.keys(stableSpzUrls || {}).length;
|
||||
const assetsArchivingPending = Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
(rawThumbnailUrl && !stableThumbnailUrl) ||
|
||||
(rawPanoUrl && !stablePanoUrl) ||
|
||||
(sourceSpzEntryCount > 0 && stableSpzEntryCount !== sourceSpzEntryCount)
|
||||
);
|
||||
const storageArchivedAt = assetsArchivingPending
|
||||
? null
|
||||
: new Date().toISOString();
|
||||
const assetPayload = {
|
||||
...externalAssetPayloadBase,
|
||||
worldId: extractedAssetPayload.worldId,
|
||||
@ -852,7 +913,9 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
meshUrl: stableModelUrl,
|
||||
spzUrls: stableSpzUrls,
|
||||
semanticsMetadata: extractedAssetPayload.semanticsMetadata,
|
||||
storageArchivedAt: new Date().toISOString(),
|
||||
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
|
||||
storageArchivedAt,
|
||||
assetsArchivingPending,
|
||||
sourceWorldUrl: extractedAssetPayload.worldUrl || null,
|
||||
sourceMeshUrl:
|
||||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||
@ -891,6 +954,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
hasPano: Boolean(stablePanoUrl),
|
||||
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||
assetsArchivingPending,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
@ -1171,24 +1235,15 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
: 0;
|
||||
const hasMissingSpzArchive =
|
||||
rawSpzUrls.length > 0 && stableSpzEntryCount < rawSpzUrls.length;
|
||||
const storageArchivedAt =
|
||||
[
|
||||
[rawWorldUrl, stableWorldUrl],
|
||||
[rawMeshUrl, stableModelUrl],
|
||||
[rawThumbnailUrl, stableThumbnailUrl],
|
||||
[rawPanoUrl, stablePanoUrl],
|
||||
].some(([rawUrl, stableUrl]) => rawUrl && stableUrl && rawUrl !== stableUrl) ||
|
||||
Boolean(
|
||||
stableSpzUrls &&
|
||||
assetPayload.spzUrls &&
|
||||
Object.entries(stableSpzUrls).some(
|
||||
([key, value]) =>
|
||||
typeof assetPayload.spzUrls?.[key] === "string" &&
|
||||
assetPayload.spzUrls[key] !== value
|
||||
)
|
||||
)
|
||||
? new Date().toISOString()
|
||||
: null;
|
||||
const assetsArchivingPending = Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
(rawThumbnailUrl && !stableThumbnailUrl) ||
|
||||
(rawPanoUrl && !stablePanoUrl) ||
|
||||
hasMissingSpzArchive
|
||||
);
|
||||
const storageArchivedAt = assetsArchivingPending
|
||||
? null
|
||||
: new Date().toISOString();
|
||||
|
||||
logger.info("task success status", {
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
@ -1200,10 +1255,7 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
hasThumbnail: Boolean(stableThumbnailUrl),
|
||||
hasSplat: Boolean(stableSpzUrls && Object.keys(stableSpzUrls).length > 0),
|
||||
storageArchivedAt,
|
||||
assetsArchivingPending: Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
hasMissingSpzArchive
|
||||
),
|
||||
assetsArchivingPending,
|
||||
});
|
||||
|
||||
const responseAssetPayload = {
|
||||
@ -1213,12 +1265,9 @@ export async function GET(request: Request, { params }: RouteParams) {
|
||||
panoUrl: stablePanoUrl || assetPayload.panoUrl,
|
||||
meshUrl: stableModelUrl,
|
||||
spzUrls: stableSpzUrls,
|
||||
spzArchiveValidatedAt: stableSpzUrls ? new Date().toISOString() : null,
|
||||
spzArchiveValidatedAt: stableSpzUrls ? storageArchivedAt : null,
|
||||
storageArchivedAt,
|
||||
assetsArchivingPending: Boolean(
|
||||
(rawMeshUrl && !stableModelUrl) ||
|
||||
hasMissingSpzArchive
|
||||
),
|
||||
assetsArchivingPending,
|
||||
sourceWorldUrl: rawWorldUrl || null,
|
||||
sourceMeshUrl:
|
||||
rawMeshUrl && stableModelUrl && rawMeshUrl !== stableModelUrl
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/lib/db";
|
||||
import { generatedImage } from "@/lib/db/schema";
|
||||
import { eq, desc, sql } from "drizzle-orm";
|
||||
import { generatedImage, imageArchiveTask } from "@/lib/db/schema";
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
@ -19,12 +19,23 @@ export async function GET(request: NextRequest) {
|
||||
const page = Number.parseInt(searchParams.get("page") || "1");
|
||||
const pageSize = Number.parseInt(searchParams.get("pageSize") || "24");
|
||||
const offset = (page - 1) * pageSize;
|
||||
const visibleImageFilter = and(
|
||||
eq(generatedImage.userId, userId),
|
||||
isNull(imageArchiveTask.id)
|
||||
);
|
||||
|
||||
// 查询总数
|
||||
const countResult = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(generatedImage)
|
||||
.where(eq(generatedImage.userId, userId));
|
||||
.leftJoin(
|
||||
imageArchiveTask,
|
||||
and(
|
||||
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
|
||||
eq(imageArchiveTask.status, "abandoned")
|
||||
)
|
||||
)
|
||||
.where(visibleImageFilter);
|
||||
|
||||
const total = countResult[0]?.count || 0;
|
||||
|
||||
@ -42,7 +53,14 @@ export async function GET(request: NextRequest) {
|
||||
modelId: generatedImage.modelId, // 添加 modelId 字段
|
||||
})
|
||||
.from(generatedImage)
|
||||
.where(eq(generatedImage.userId, userId))
|
||||
.leftJoin(
|
||||
imageArchiveTask,
|
||||
and(
|
||||
eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl),
|
||||
eq(imageArchiveTask.status, "abandoned")
|
||||
)
|
||||
)
|
||||
.where(visibleImageFilter)
|
||||
.orderBy(desc(generatedImage.createdAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
@ -399,7 +399,7 @@ function normalizeLovartComposerPreviewTexts(value: unknown): string[] {
|
||||
function normalizeLovartComposerPrefix(value: unknown): string {
|
||||
const normalized = String(value || "").trim();
|
||||
if (!normalized) {
|
||||
return "让「由壹点AI」";
|
||||
return "让「𝗕𝘂𝗬𝗶」";
|
||||
}
|
||||
return normalized.slice(0, 40);
|
||||
}
|
||||
@ -573,7 +573,7 @@ const PUBLIC_SITE_SETTINGS_SCOPES = new Set<string>([
|
||||
]);
|
||||
|
||||
const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||
siteName: "AI Chatbot",
|
||||
siteName: "𝗕𝘂𝗬𝗶",
|
||||
siteLogo: null,
|
||||
siteLogoDark: null,
|
||||
favicon: null,
|
||||
@ -583,11 +583,11 @@ const HOME_SITE_SETTINGS_FALLBACK: Record<string, unknown> = {
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
bannerActionText: "立即查看",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||
surpriseCategories: ["全部", "精选"],
|
||||
canvasDefaultModelId: null,
|
||||
lovartComposerPrefix: "让「由壹点AI」",
|
||||
lovartComposerPrefix: "让「𝗕𝘂𝗬𝗶」",
|
||||
lovartComposerPreviewTexts: [],
|
||||
lovartPromptTagVisibility: {},
|
||||
contactConfig: normalizeContactConfig(undefined),
|
||||
@ -664,7 +664,7 @@ async function readScopedSiteSettings(keys: string[]) {
|
||||
|
||||
function createBrandingSettings(settings: Record<string, unknown> | undefined) {
|
||||
return {
|
||||
siteName: String(settings?.siteName || "AI Chatbot"),
|
||||
siteName: String(settings?.siteName || "𝗕𝘂𝗬𝗶"),
|
||||
siteLogo: settings?.siteLogo || null,
|
||||
siteLogoDark: settings?.siteLogoDark || null,
|
||||
favicon: settings?.favicon || null,
|
||||
@ -822,7 +822,7 @@ async function getScopedPublicSiteSettings(
|
||||
usageTimeStart: typedSettings?.usageTimeStart || "08:00",
|
||||
usageTimeEnd: typedSettings?.usageTimeEnd || "20:00",
|
||||
usageTimeMessage:
|
||||
typedSettings?.usageTimeMessage || "由壹点累了也是需要休息的",
|
||||
typedSettings?.usageTimeMessage || "BuYi 累了也是需要休息的",
|
||||
});
|
||||
}
|
||||
|
||||
@ -953,9 +953,9 @@ async function getScopedPublicSiteSettings(
|
||||
typedSettings?.lovartPromoText ||
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: typedSettings?.lovartPromoActionText || "立即升级",
|
||||
lovartHeroTitle: typedSettings?.lovartHeroTitle || "由壹点AI 让设计更简单",
|
||||
lovartHeroTitle: typedSettings?.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle:
|
||||
typedSettings?.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
||||
typedSettings?.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||
surpriseCategories: normalizeSurpriseCategories(
|
||||
valueByKey.get(HOME_SURPRISE_CATEGORIES_KEY)
|
||||
),
|
||||
@ -1476,7 +1476,7 @@ export async function GET(request: NextRequest) {
|
||||
usageTimeEnabled: false,
|
||||
usageTimeStart: "08:00",
|
||||
usageTimeEnd: "20:00",
|
||||
usageTimeMessage: "由壹点累了也是需要休息的",
|
||||
usageTimeMessage: "BuYi 累了也是需要休息的",
|
||||
bannerEnabled: true,
|
||||
bannerText: "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: null,
|
||||
@ -1525,8 +1525,8 @@ export async function GET(request: NextRequest) {
|
||||
canvasDetailAgentModelId: null,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||
surpriseCategories,
|
||||
aiDetailGenerationEnabled,
|
||||
lovartComposerPrefix,
|
||||
@ -1546,8 +1546,8 @@ export async function GET(request: NextRequest) {
|
||||
baseSettings.lovartPromoText ||
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: baseSettings.lovartPromoActionText || "立即升级",
|
||||
lovartHeroTitle: baseSettings.lovartHeroTitle || "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: baseSettings.lovartHeroTitle || "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: baseSettings.lovartHeroSubtitle || "下达指令,即刻呈现",
|
||||
notification,
|
||||
surpriseCategories,
|
||||
aiDetailGenerationEnabled,
|
||||
@ -3119,7 +3119,7 @@ export async function PUT(request: NextRequest) {
|
||||
usageTimeEnabled: siteSettingsPayload.usageTimeEnabled ?? false,
|
||||
usageTimeStart: siteSettingsPayload.usageTimeStart ?? "08:00",
|
||||
usageTimeEnd: siteSettingsPayload.usageTimeEnd ?? "20:00",
|
||||
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "由壹点累了也是需要休息的",
|
||||
usageTimeMessage: siteSettingsPayload.usageTimeMessage ?? "BuYi 累了也是需要休息的",
|
||||
bannerEnabled: siteSettingsPayload.bannerEnabled ?? true,
|
||||
bannerText: siteSettingsPayload.bannerText ?? "限时特惠:升级会员最高立享 45% OFF!",
|
||||
bannerLink: siteSettingsPayload.bannerLink,
|
||||
@ -3144,8 +3144,8 @@ export async function PUT(request: NextRequest) {
|
||||
siteSettingsPayload.lovartPromoText ??
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: siteSettingsPayload.lovartPromoActionText ?? "立即升级",
|
||||
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: siteSettingsPayload.lovartHeroTitle ?? "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: siteSettingsPayload.lovartHeroSubtitle ?? "下达指令,即刻呈现",
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
@ -365,6 +365,62 @@ function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) {
|
||||
);
|
||||
}
|
||||
|
||||
function getTaskPreviewCandidates(task?: TaskRecord | null) {
|
||||
if (!task) return [];
|
||||
const candidates = [
|
||||
task.previewImageUrl,
|
||||
task.assetPayload?.thumbnailUrl,
|
||||
task.assetPayload?.panoUrl,
|
||||
task.assetPayload?.sourcePreviewImageUrl,
|
||||
task.assetPayload?.sourceThumbnailUrl,
|
||||
task.assetPayload?.sourcePanoUrl,
|
||||
];
|
||||
const seen = new Set<string>();
|
||||
return candidates
|
||||
.map((value) => String(value || "").trim())
|
||||
.filter((value) => {
|
||||
if (!value || seen.has(value)) return false;
|
||||
seen.add(value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isPersistableTaskPreviewUrl(value?: string | null) {
|
||||
return /^https?:\/\//i.test(String(value || "").trim());
|
||||
}
|
||||
|
||||
function TaskAssetPreview({ task }: { task: TaskRecord }) {
|
||||
const candidates = getTaskPreviewCandidates(task);
|
||||
const signature = candidates.join("|");
|
||||
const [candidateIndex, setCandidateIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setCandidateIndex(0);
|
||||
}, [signature]);
|
||||
|
||||
const previewUrl = candidates[candidateIndex] || null;
|
||||
if (!previewUrl) {
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
||||
{task.status === "queued" || task.status === "running" ? (
|
||||
<Loader2 className="h-7 w-7 animate-spin text-yellow-400/70" />
|
||||
) : (
|
||||
<Box className="h-8 w-8" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<img
|
||||
alt="3D 资产预览"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
|
||||
onError={() => setCandidateIndex((index) => index + 1)}
|
||||
src={previewUrl}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function canImportTaskToCanvas(task?: TaskRecord | null) {
|
||||
if (!task || task.status !== "success") return false;
|
||||
if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task));
|
||||
@ -1126,7 +1182,9 @@ export default function CanvasModel3DPage() {
|
||||
modelVersion: task.modelVersion,
|
||||
progress: task.progress ?? null,
|
||||
modelUrl: task.modelUrl ?? null,
|
||||
previewImageUrl: task.previewImageUrl ?? null,
|
||||
previewImageUrl: isPersistableTaskPreviewUrl(task.previewImageUrl)
|
||||
? task.previewImageUrl
|
||||
: null,
|
||||
error: task.error ?? null,
|
||||
sourceTaskId: task.sourceTaskId ?? null,
|
||||
exportFormat: task.exportFormat ?? null,
|
||||
@ -1693,6 +1751,7 @@ export default function CanvasModel3DPage() {
|
||||
sourceTaskId?: string | null;
|
||||
exportFormat?: string | null;
|
||||
assetPayload?: Model3DTaskAssetPayload | null;
|
||||
previewImageUrl?: string | null;
|
||||
}) => {
|
||||
const normalizedPayload =
|
||||
params.provider === "tripo-3d" &&
|
||||
@ -1747,6 +1806,7 @@ export default function CanvasModel3DPage() {
|
||||
createdAt: Date.now(),
|
||||
sourceTaskId: params.sourceTaskId || null,
|
||||
exportFormat: params.exportFormat || null,
|
||||
previewImageUrl: params.previewImageUrl || null,
|
||||
assetPayload:
|
||||
params.provider === "worldlabs-3d"
|
||||
? worldCreateResultData?.assetPayload ?? params.assetPayload ?? null
|
||||
@ -1862,6 +1922,7 @@ export default function CanvasModel3DPage() {
|
||||
providerId: uploadResult.data.providerId || null,
|
||||
providerName: uploadResult.data.providerName || null,
|
||||
},
|
||||
previewImageUrl: referencePreviewUrl,
|
||||
});
|
||||
toast.success("已提交图生3D任务");
|
||||
return;
|
||||
@ -1896,6 +1957,8 @@ export default function CanvasModel3DPage() {
|
||||
providerId: uploadResult.data.providerId || null,
|
||||
providerName: uploadResult.data.providerName || null,
|
||||
},
|
||||
previewImageUrl:
|
||||
batchImageSlots.find((slot) => slot.file === file)?.previewUrl || null,
|
||||
});
|
||||
successCount += 1;
|
||||
}
|
||||
@ -1951,6 +2014,7 @@ export default function CanvasModel3DPage() {
|
||||
providerId: boundTripoProviderId,
|
||||
providerName: boundTripoProviderName,
|
||||
},
|
||||
previewImageUrl: multiViewSlots.find((slot) => slot.previewUrl)?.previewUrl || null,
|
||||
});
|
||||
toast.success("已提交多视图3D任务");
|
||||
} catch (error) {
|
||||
@ -2033,6 +2097,7 @@ export default function CanvasModel3DPage() {
|
||||
mode: "world-image",
|
||||
promptText: normalizedPrompt || "图生世界",
|
||||
modelLabel: worldModel,
|
||||
previewImageUrl: sourceUrl || worldImagePreviewUrl,
|
||||
});
|
||||
toast.success("已提交图生世界任务");
|
||||
return;
|
||||
@ -2117,6 +2182,10 @@ export default function CanvasModel3DPage() {
|
||||
mode: "world-multi-image",
|
||||
promptText: normalizedPrompt || "多图世界",
|
||||
modelLabel: worldModel,
|
||||
previewImageUrl:
|
||||
mediaAssets.find((item) => "sourceUrl" in item)?.sourceUrl ||
|
||||
selectedWorldImages.find((slot) => slot.previewUrl)?.previewUrl ||
|
||||
null,
|
||||
});
|
||||
toast.success("已提交多图世界任务");
|
||||
} catch (error) {
|
||||
@ -4218,8 +4287,6 @@ export default function CanvasModel3DPage() {
|
||||
const canImportWorld =
|
||||
task.provider === "worldlabs-3d" && canImportTaskToCanvas(task);
|
||||
const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId);
|
||||
const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@ -4236,17 +4303,7 @@ export default function CanvasModel3DPage() {
|
||||
onClick={() => handleSelectTask(task)}
|
||||
type="button"
|
||||
>
|
||||
{previewUrl ? (
|
||||
<img
|
||||
alt="preview"
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.035]"
|
||||
src={previewUrl}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-zinc-700">
|
||||
<Box className="h-8 w-8" />
|
||||
</div>
|
||||
)}
|
||||
<TaskAssetPreview task={task} />
|
||||
</button>
|
||||
<div className="border-white/10 border-t bg-zinc-950/95 px-1.5 py-2">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
|
||||
259
components/bloub-avatar.tsx
Normal file
259
components/bloub-avatar.tsx
Normal file
@ -0,0 +1,259 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { defaultCycle } from "@/lib/bloub-official/cycles";
|
||||
import { NOTIF_BLUE } from "@/lib/bloub-official/decor";
|
||||
import { type BotFrame, BotEngine } from "@/lib/bloub-official/engine";
|
||||
import {
|
||||
DEFAULT_EXPRESSION,
|
||||
EXPRESSION_BY_ID,
|
||||
} from "@/lib/bloub-official/expressions";
|
||||
import { DEMI_VIEWBOX, RAYON } from "@/lib/bloub-official/repere";
|
||||
import { SHAPE_BY_ID } from "@/lib/bloub-official/skins";
|
||||
import type { StateId } from "@/lib/bloub-official/states";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type BloubAvatarProps = {
|
||||
animated?: boolean;
|
||||
className?: string;
|
||||
label?: string;
|
||||
playSequence?: boolean;
|
||||
size?: number;
|
||||
state?: StateId;
|
||||
surface?: "chat" | "page";
|
||||
};
|
||||
|
||||
export function BloubAvatar({
|
||||
animated = true,
|
||||
className,
|
||||
label = "AI 助手",
|
||||
playSequence = false,
|
||||
size = 32,
|
||||
state = "idle",
|
||||
surface = "chat",
|
||||
}: BloubAvatarProps) {
|
||||
const rawId = useId();
|
||||
const uid = rawId.replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
const maskId = `bloub-mask-${uid}`;
|
||||
const engineRef = useRef<BotEngine | null>(null);
|
||||
const clockRef = useRef(0);
|
||||
|
||||
if (!engineRef.current) {
|
||||
engineRef.current = new BotEngine(
|
||||
RAYON,
|
||||
state,
|
||||
SHAPE_BY_ID.get("squircle")?.radii ?? null,
|
||||
EXPRESSION_BY_ID.get(DEFAULT_EXPRESSION) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
const [frame, setFrame] = useState<BotFrame>(() =>
|
||||
engineRef.current!.sample(0)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (playSequence) return;
|
||||
const engine = engineRef.current;
|
||||
if (!engine || engine.state === state) return;
|
||||
engine.setState(state, clockRef.current);
|
||||
setFrame(engine.sample(clockRef.current));
|
||||
}, [playSequence, state]);
|
||||
|
||||
useEffect(() => {
|
||||
const engine = engineRef.current;
|
||||
if (!engine) return;
|
||||
|
||||
const cycle = defaultCycle().blocks;
|
||||
const firstState = playSequence
|
||||
? cycle[0]?.state ?? "idle"
|
||||
: engine.state;
|
||||
engine.reset(firstState, 0);
|
||||
clockRef.current = 0;
|
||||
setFrame(engine.sample(0));
|
||||
|
||||
const reducedMotion = window.matchMedia(
|
||||
"(prefers-reduced-motion: reduce)"
|
||||
).matches;
|
||||
if (!animated || reducedMotion) return;
|
||||
|
||||
let animationFrame = 0;
|
||||
let lastFrameAt = 0;
|
||||
let blockIndex = 0;
|
||||
let nextBlockAt = cycle[0]?.duration ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
const tick = (timestamp: number) => {
|
||||
const delta = lastFrameAt
|
||||
? Math.min((timestamp - lastFrameAt) / 1000, 0.064)
|
||||
: 0;
|
||||
lastFrameAt = timestamp;
|
||||
clockRef.current += delta;
|
||||
|
||||
if (playSequence && cycle.length > 0) {
|
||||
while (clockRef.current >= nextBlockAt) {
|
||||
blockIndex = (blockIndex + 1) % cycle.length;
|
||||
const nextBlock = cycle[blockIndex]!;
|
||||
engine.setState(nextBlock.state, nextBlockAt);
|
||||
nextBlockAt += nextBlock.duration;
|
||||
}
|
||||
}
|
||||
|
||||
setFrame(engine.sample(clockRef.current));
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
animationFrame = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(animationFrame);
|
||||
}, [animated, playSequence]);
|
||||
|
||||
const renderDot = (
|
||||
dot: BotFrame["dots"][number],
|
||||
index: number,
|
||||
layer: "back" | "front"
|
||||
) => {
|
||||
const fill = dot.color ?? "currentColor";
|
||||
const common = {
|
||||
fill,
|
||||
opacity:
|
||||
dot.color || dot.depth === undefined
|
||||
? dot.opacity
|
||||
: dot.opacity * dot.depth,
|
||||
};
|
||||
const key = `${layer}-${index}`;
|
||||
|
||||
if (dot.d) {
|
||||
return (
|
||||
<path
|
||||
{...common}
|
||||
d={dot.d}
|
||||
key={key}
|
||||
transform={`translate(${dot.x} ${dot.y}) rotate(${dot.rot ?? 0}) scale(${RAYON})`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<circle {...common} cx={dot.x} cy={dot.y} key={key} r={dot.r} />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
"block shrink-0 text-[#0a0a0c] dark:text-[#3ecf8e]",
|
||||
surface === "page"
|
||||
? "[--bloub-paper:#f4f4f5] dark:[--bloub-paper:#08090b]"
|
||||
: "[--bloub-paper:#ffffff] dark:[--bloub-paper:#09090b]",
|
||||
className
|
||||
)}
|
||||
height={size}
|
||||
role="img"
|
||||
viewBox={`${-DEMI_VIEWBOX} ${-DEMI_VIEWBOX} ${DEMI_VIEWBOX * 2} ${DEMI_VIEWBOX * 2}`}
|
||||
width={size}
|
||||
>
|
||||
<defs>
|
||||
<mask
|
||||
height={DEMI_VIEWBOX * 2}
|
||||
id={maskId}
|
||||
maskUnits="userSpaceOnUse"
|
||||
width={DEMI_VIEWBOX * 2}
|
||||
x={-DEMI_VIEWBOX}
|
||||
y={-DEMI_VIEWBOX}
|
||||
>
|
||||
<path d={frame.bodyPath} fill="#fff" />
|
||||
{frame.eyes.map((eye, index) => (
|
||||
<path
|
||||
d={eye.d}
|
||||
fill="#000"
|
||||
key={`eye-${index}`}
|
||||
opacity={eye.alpha}
|
||||
transform={eye.matrix}
|
||||
/>
|
||||
))}
|
||||
{frame.notch ? (
|
||||
<circle
|
||||
cx={frame.notch.x}
|
||||
cy={frame.notch.y}
|
||||
fill="#000"
|
||||
r={frame.notch.r}
|
||||
/>
|
||||
) : null}
|
||||
</mask>
|
||||
|
||||
{frame.arcs.map((arc) => (
|
||||
<linearGradient
|
||||
gradientUnits="userSpaceOnUse"
|
||||
id={`${uid}-${arc.id}`}
|
||||
key={`gradient-${arc.id}`}
|
||||
x1={arc.grad.x1}
|
||||
x2={arc.grad.x2}
|
||||
y1={arc.grad.y1}
|
||||
y2={arc.grad.y2}
|
||||
>
|
||||
{arc.grad.stops.map((color, index) => (
|
||||
<stop
|
||||
key={`${arc.id}-${color}-${index}`}
|
||||
offset={index / (arc.grad.stops.length - 1)}
|
||||
stopColor={color}
|
||||
/>
|
||||
))}
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<g fill="none" strokeLinecap="round">
|
||||
{frame.arcs.map((arc) => (
|
||||
<path
|
||||
d={arc.back}
|
||||
key={`arc-back-${arc.id}`}
|
||||
opacity={arc.opacity}
|
||||
stroke={`url(#${uid}-${arc.id})`}
|
||||
strokeWidth={arc.width}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
|
||||
{frame.dotsBehind ? (
|
||||
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "back"))}</g>
|
||||
) : null}
|
||||
|
||||
<g opacity={frame.bodyAlpha}>
|
||||
<path d={frame.bodyPath} fill="var(--bloub-paper)" />
|
||||
<g mask={`url(#${maskId})`}>
|
||||
<rect
|
||||
fill="currentColor"
|
||||
height={DEMI_VIEWBOX * 2}
|
||||
width={DEMI_VIEWBOX * 2}
|
||||
x={-DEMI_VIEWBOX}
|
||||
y={-DEMI_VIEWBOX}
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
{!frame.dotsBehind ? (
|
||||
<g>{frame.dots.map((dot, index) => renderDot(dot, index, "front"))}</g>
|
||||
) : null}
|
||||
|
||||
{frame.notif ? (
|
||||
<circle
|
||||
cx={frame.notif.x}
|
||||
cy={frame.notif.y}
|
||||
fill={NOTIF_BLUE}
|
||||
r={frame.notif.r}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<g fill="none" strokeLinecap="round">
|
||||
{frame.arcs.map((arc) => (
|
||||
<path
|
||||
d={arc.front}
|
||||
key={`arc-front-${arc.id}`}
|
||||
opacity={arc.opacity}
|
||||
stroke={`url(#${uid}-${arc.id})`}
|
||||
strokeWidth={arc.width}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -153,6 +153,7 @@ import { getCanvasSkillIconComponent } from "@/components/canvas/canvas-skill-ic
|
||||
import { ImageParamsBar, type ImageGenerationParams } from "@/components/image-params-bar";
|
||||
import { VideoParamsBar, type VideoGenerationParams } from "@/components/video-params-bar";
|
||||
import { ImageGenerationLoading } from "@/components/image-generation-loading";
|
||||
import { BloubAvatar } from "@/components/bloub-avatar";
|
||||
import { CanvasImageDownloadProgressDialog } from "@/components/canvas/CanvasImageDownloadProgressDialog";
|
||||
import type { VideoToolCapabilityContract } from "@/types/video-tool-capability";
|
||||
import { DetailUiValidationPanel } from "@/components/canvas/DetailUiValidationPanel";
|
||||
@ -1356,7 +1357,7 @@ type VideoStoryboardStreamData =
|
||||
|
||||
// 建议卡片数据 - 已移除
|
||||
const SUGGESTIONS: { title: string; desc: string; image: string }[] = [];
|
||||
const CANVAS_COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」";
|
||||
const CANVAS_COMPOSER_PREFIX_DEFAULT = "让「𝗕𝘂𝗬𝗶」";
|
||||
const CANVAS_COMPOSER_PREVIEW_TEXTS_DEFAULT = [
|
||||
"打造引人注目的社交媒体视觉",
|
||||
"帮我把这张产品图做成高级电商海报",
|
||||
@ -2631,7 +2632,12 @@ function CanvasAssistantTypingPlaceholder({
|
||||
aria-label={label}
|
||||
className="inline-flex h-8 items-center gap-2 rounded-[16px] bg-[#f4f5f7] px-3.5 text-[15px] font-medium text-[#9aa3b2] dark:bg-white/8 dark:text-zinc-400"
|
||||
>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<BloubAvatar
|
||||
className="-ml-1"
|
||||
label={`AI 助手${label}`}
|
||||
size={28}
|
||||
state="thinking"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{onCancel ? (
|
||||
<button
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BloubAvatar } from "@/components/bloub-avatar";
|
||||
|
||||
type CanvasLogoLoadingViewProps = {
|
||||
label?: string;
|
||||
@ -10,46 +9,17 @@ type CanvasLogoLoadingViewProps = {
|
||||
|
||||
export function CanvasLogoLoadingView({
|
||||
label = "正在加载画布...",
|
||||
siteName = "AI Chatbot",
|
||||
siteLogo,
|
||||
siteLogoDark,
|
||||
}: CanvasLogoLoadingViewProps) {
|
||||
const resolvedSiteName = String(siteName || "AI Chatbot").trim();
|
||||
const lightLogo = String(siteLogo || siteLogoDark || "").trim();
|
||||
const darkLogo = String(siteLogoDark || siteLogo || "").trim();
|
||||
const hasSeparateDarkLogo = Boolean(darkLogo && darkLogo !== lightLogo);
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-screen items-center justify-center bg-[#f4f4f5] text-[#20242a] dark:bg-[#08090b] dark:text-zinc-100">
|
||||
<div className="flex flex-col items-center gap-3.5">
|
||||
<div className="relative flex h-[78px] w-[78px] items-center justify-center">
|
||||
<div className="absolute inset-0 animate-spin rounded-full bg-[conic-gradient(from_0deg,#ff7a00,#ffe04b,#48e6a5,#35a7ff,#b15cff,#ff4aa2,#ff7a00)] opacity-90 blur-[1px]" />
|
||||
<div className="absolute inset-[6px] animate-ping rounded-full bg-[conic-gradient(from_180deg,#ff7a00,#35a7ff,#b15cff,#48e6a5,#ff7a00)] opacity-25" />
|
||||
<div className="absolute inset-1 rounded-full bg-[#f4f4f5] dark:bg-[#08090b]" />
|
||||
<div className="relative flex h-14 w-14 items-center justify-center overflow-hidden rounded-full border border-black/8 bg-white shadow-[0_13px_38px_rgba(15,23,42,0.16)] dark:border-white/10 dark:bg-zinc-950 dark:shadow-[0_17px_49px_rgba(0,0,0,0.45)]">
|
||||
{lightLogo ? (
|
||||
<>
|
||||
<img
|
||||
alt={resolvedSiteName}
|
||||
className={cn(
|
||||
"h-[34px] w-[34px] object-contain",
|
||||
hasSeparateDarkLogo && "dark:hidden"
|
||||
)}
|
||||
src={lightLogo}
|
||||
/>
|
||||
{hasSeparateDarkLogo ? (
|
||||
<img
|
||||
alt={resolvedSiteName}
|
||||
className="hidden h-[34px] w-[34px] object-contain dark:block"
|
||||
src={darkLogo}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<Sparkles className="h-[22px] w-[22px] text-[#20242a] dark:text-white" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-2.5">
|
||||
<BloubAvatar
|
||||
label={label}
|
||||
playSequence
|
||||
size={104}
|
||||
state="idle"
|
||||
surface="page"
|
||||
/>
|
||||
<div className="text-[12px] font-medium tracking-[0.16em] text-[#6b7280] dark:text-zinc-400">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
@ -1917,9 +1917,6 @@ function WorldSplatCaptureBridge({
|
||||
function WorldSplatViewer({
|
||||
splatUrl,
|
||||
splatUrls,
|
||||
fallbackMeshUrl,
|
||||
fallbackMeshSettings,
|
||||
fallbackMeshLayerVisibility,
|
||||
worldUrl,
|
||||
previewImageUrl,
|
||||
firstPerson,
|
||||
@ -1927,8 +1924,6 @@ function WorldSplatViewer({
|
||||
interactionMode,
|
||||
onPick,
|
||||
onHotspotSelect,
|
||||
onFallbackMeshLayersChange,
|
||||
onFallbackMeshStatsChange,
|
||||
semanticsMetadata,
|
||||
resetViewToken,
|
||||
minimalFrame = false,
|
||||
@ -1938,9 +1933,6 @@ function WorldSplatViewer({
|
||||
}: {
|
||||
splatUrl?: string | null;
|
||||
splatUrls?: string[] | null;
|
||||
fallbackMeshUrl?: string | null;
|
||||
fallbackMeshSettings?: Model3DViewerSettings;
|
||||
fallbackMeshLayerVisibility?: Record<string, boolean>;
|
||||
worldUrl?: string | null;
|
||||
previewImageUrl?: string | null;
|
||||
minimalFrame?: boolean;
|
||||
@ -1949,8 +1941,6 @@ function WorldSplatViewer({
|
||||
interactionMode?: "navigate" | "hotspot";
|
||||
onPick?: (pick: World3DPickResult) => void;
|
||||
onHotspotSelect?: (hotspotId: string) => void;
|
||||
onFallbackMeshLayersChange?: (layers: Model3DLayer[]) => void;
|
||||
onFallbackMeshStatsChange?: (stats: Model3DStats) => void;
|
||||
semanticsMetadata?: Record<string, unknown> | null;
|
||||
resetViewToken?: number;
|
||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||
@ -1967,7 +1957,6 @@ function WorldSplatViewer({
|
||||
const [prefetchedSplat, setPrefetchedSplat] = useState<PrefetchedSplatAsset | null>(
|
||||
null
|
||||
);
|
||||
const [useMeshFallback, setUseMeshFallback] = useState(false);
|
||||
const resolvedSplatUrls = useMemo(() => {
|
||||
const rawUrls = [
|
||||
splatUrl,
|
||||
@ -1985,16 +1974,13 @@ function WorldSplatViewer({
|
||||
}, [splatUrl, splatUrls]);
|
||||
const resolvedSplatSignature = resolvedSplatUrls.join("|");
|
||||
const resolvedSplatUrl = resolvedSplatUrls[activeSplatIndex] || "";
|
||||
const resolvedFallbackMeshUrl = String(fallbackMeshUrl || "").trim() || null;
|
||||
const hasMeshFallback = Boolean(resolvedFallbackMeshUrl);
|
||||
const loadStartedAtRef = useRef(0);
|
||||
const movementStateRef = useRef<MovementState>(createMovementState());
|
||||
|
||||
useEffect(() => {
|
||||
setActiveSplatIndex(0);
|
||||
setFailedSplatLoads([]);
|
||||
setUseMeshFallback(false);
|
||||
}, [resolvedFallbackMeshUrl, resolvedSplatSignature]);
|
||||
}, [resolvedSplatSignature]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedSplatUrl) return;
|
||||
@ -2047,10 +2033,6 @@ function WorldSplatViewer({
|
||||
const failedUrl = resolvedSplatUrl;
|
||||
const nextIndex = activeSplatIndex + 1;
|
||||
const nextUrl = resolvedSplatUrls[nextIndex] || "";
|
||||
const shouldUseMeshFallback =
|
||||
hasMeshFallback &&
|
||||
(message.includes("SPZ 加载超时") || !nextUrl);
|
||||
|
||||
if (failedUrl) {
|
||||
setFailedSplatLoads((items) => [
|
||||
...items,
|
||||
@ -2058,20 +2040,6 @@ function WorldSplatViewer({
|
||||
]);
|
||||
}
|
||||
|
||||
if (shouldUseMeshFallback) {
|
||||
console.warn("[WorldSplatViewer] Switching to mesh fallback", {
|
||||
error: message,
|
||||
failedUrl,
|
||||
attemptedUrls: resolvedSplatUrls.slice(0, nextIndex),
|
||||
fallbackMeshUrl: resolvedFallbackMeshUrl,
|
||||
});
|
||||
setPrefetchedSplat(null);
|
||||
setLoadProgress(null);
|
||||
setOverlayPhase("hidden");
|
||||
setUseMeshFallback(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextUrl) {
|
||||
console.warn("[WorldSplatViewer] Falling back to next SPZ asset", {
|
||||
error: message,
|
||||
@ -2096,15 +2064,13 @@ function WorldSplatViewer({
|
||||
},
|
||||
[
|
||||
activeSplatIndex,
|
||||
hasMeshFallback,
|
||||
resolvedFallbackMeshUrl,
|
||||
resolvedSplatUrl,
|
||||
resolvedSplatUrls,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resolvedSplatUrl || useMeshFallback) return;
|
||||
if (!resolvedSplatUrl) return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
let cancelled = false;
|
||||
@ -2141,25 +2107,7 @@ function WorldSplatViewer({
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
abortController.abort();
|
||||
};
|
||||
}, [handleSplatLoadStateChange, resolvedSplatUrl, useMeshFallback]);
|
||||
|
||||
if ((useMeshFallback || !resolvedSplatUrl) && resolvedFallbackMeshUrl) {
|
||||
return (
|
||||
<Model3DViewer
|
||||
emptyStateTitle="准备加载 3D Mesh"
|
||||
layerVisibility={fallbackMeshLayerVisibility}
|
||||
modelRotation={WORLDLABS_ASSET_ROTATION}
|
||||
modelUrl={resolvedFallbackMeshUrl}
|
||||
onCaptureReady={onCaptureReady}
|
||||
onLayersChange={onFallbackMeshLayersChange}
|
||||
onStatsChange={onFallbackMeshStatsChange}
|
||||
previewImageUrl={previewImageUrl}
|
||||
resetViewToken={resetViewToken}
|
||||
settings={fallbackMeshSettings || DEFAULT_MESH_SETTINGS}
|
||||
showMovementControls={showMovementControls}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [handleSplatLoadStateChange, resolvedSplatUrl]);
|
||||
|
||||
if (!resolvedSplatUrl) {
|
||||
return (
|
||||
@ -2375,13 +2323,8 @@ export function World3DViewer({
|
||||
<WorldViewerInteractionSurface className={className}>
|
||||
<WorldSplatViewer
|
||||
firstPerson={firstPerson}
|
||||
fallbackMeshLayerVisibility={meshLayerVisibility}
|
||||
fallbackMeshSettings={meshSettings}
|
||||
fallbackMeshUrl={meshUrl}
|
||||
hotspots={hotspots}
|
||||
interactionMode={interactionMode}
|
||||
onFallbackMeshLayersChange={onMeshLayersChange}
|
||||
onFallbackMeshStatsChange={onMeshStatsChange}
|
||||
onCaptureReady={onCaptureReady}
|
||||
onHotspotSelect={onHotspotSelect}
|
||||
onPick={onPick}
|
||||
|
||||
@ -282,6 +282,7 @@ export function WorldSharePage({
|
||||
mode={activeView}
|
||||
previewImageUrl={activeWorld?.previewImageUrl || null}
|
||||
semanticsMetadata={activeWorld?.semanticsMetadata || null}
|
||||
showInfoOverlay={false}
|
||||
showMovementControls={activeView === "splat" || activeView === "mesh"}
|
||||
splatUrl={activeWorld?.splatUrl || null}
|
||||
worldUrl={activeWorld?.worldUrl || null}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import dynamic from "next/dynamic";
|
||||
import { usePathname } from "next/navigation";
|
||||
import type { User } from "next-auth";
|
||||
import { CanvasLogoLoadingView } from "@/components/canvas/canvas-logo-loading";
|
||||
import type { ChatShellChromeProps } from "@/components/chat-shell-chrome";
|
||||
|
||||
type CustomLink = {
|
||||
@ -49,7 +50,7 @@ const ChatShellChrome = dynamic(
|
||||
(module) => module.ChatShellChrome
|
||||
),
|
||||
{
|
||||
loading: () => <div className="h-svh w-full overflow-hidden" />,
|
||||
loading: () => <CanvasLogoLoadingView label="正在加载页面..." />,
|
||||
ssr: false,
|
||||
}
|
||||
);
|
||||
|
||||
@ -345,7 +345,7 @@ type LovartPromptTagKey = (typeof PROMPT_TAGS)[number]["key"];
|
||||
type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
|
||||
|
||||
const FEATURED_PAGE_SIZE = 24;
|
||||
const COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」";
|
||||
const COMPOSER_PREFIX_DEFAULT = "让「𝗕𝘂𝗬𝗶」";
|
||||
|
||||
function SidebarSvgIcon({
|
||||
iconUrl,
|
||||
|
||||
@ -13,6 +13,16 @@ type LovartFeaturedGalleryProps = {
|
||||
setActiveCategory: (category: string) => void;
|
||||
};
|
||||
|
||||
function getFeaturedPreviewUrl(item: FeaturedImage) {
|
||||
const source = item.thumbnailUrl || item.originalUrl;
|
||||
|
||||
if (!source || !/^https:\/\/yybb\.top\//i.test(source) || source.includes("?")) {
|
||||
return source;
|
||||
}
|
||||
|
||||
return `${source}?imageView2/2/w/900/q/78`;
|
||||
}
|
||||
|
||||
export function LovartFeaturedGallery({
|
||||
activeCategory,
|
||||
categories,
|
||||
@ -60,10 +70,21 @@ export function LovartFeaturedGallery({
|
||||
className="group mb-[24px] block w-full cursor-pointer break-inside-avoid text-left"
|
||||
>
|
||||
<img
|
||||
src={item.thumbnailUrl || item.originalUrl}
|
||||
src={getFeaturedPreviewUrl(item)}
|
||||
alt={item.prompt || "灵感图"}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(event) => {
|
||||
const image = event.currentTarget;
|
||||
const fallbackUrl = item.originalUrl || item.thumbnailUrl;
|
||||
|
||||
if (!fallbackUrl || image.dataset.fallbackApplied === "true") {
|
||||
return;
|
||||
}
|
||||
|
||||
image.dataset.fallbackApplied = "true";
|
||||
image.src = fallbackUrl;
|
||||
}}
|
||||
className="block h-auto w-full rounded-[6px] bg-[#e8e8e8] object-cover transition-transform duration-150 ease-out will-change-transform group-hover:scale-[1.008] dark:bg-[#1b1c20]"
|
||||
/>
|
||||
<div className="mt-[8px] flex h-[22px] items-center justify-between text-[11px] font-normal leading-none text-[#b8b8b8] dark:text-white/45">
|
||||
|
||||
@ -101,7 +101,7 @@ export type LovartPromptTagVisibility = Record<LovartPromptTagKey, boolean>;
|
||||
|
||||
export const MASONRY_HEIGHTS = [420, 290, 340, 470, 300, 360, 430, 320];
|
||||
export const FEATURED_PAGE_SIZE = 24;
|
||||
export const COMPOSER_PREFIX_DEFAULT = "让「由壹点AI」";
|
||||
export const COMPOSER_PREFIX_DEFAULT = "让「𝗕𝘂𝗬𝗶」";
|
||||
|
||||
export function normalizeComposerPreviewTail(text: string, fixedPrefix: string) {
|
||||
const trimmed = text.trim();
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import { memo } from "react";
|
||||
import { SparklesIcon } from "./icons";
|
||||
import { Scissors, Expand, Droplet } from "lucide-react";
|
||||
import { BloubAvatar } from "./bloub-avatar";
|
||||
|
||||
interface ImageProcessingLoadingProps {
|
||||
type: "enhance" | "rmbg" | "extend" | "watermark"; // 处理类型:变清晰、抠图、图像拓展或去水印
|
||||
@ -85,16 +86,12 @@ export const ImageProcessingLoading = memo(({ type }: ImageProcessingLoadingProp
|
||||
data-testid="message-assistant-image-processing-loading"
|
||||
>
|
||||
<div className="flex items-start justify-start gap-3">
|
||||
{/* 头像 */}
|
||||
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
|
||||
<div className={`absolute inset-0 rounded-full ${colorClasses.ping} animate-ping`} />
|
||||
<div className={`absolute inset-0 rounded-full ${colorClasses.pulse} animate-pulse`} />
|
||||
<div className={`relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ${colorClasses.ring}`}>
|
||||
<div className={`animate-spin ${colorClasses.text}`}>
|
||||
<Icon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BloubAvatar
|
||||
className="-mt-1"
|
||||
label={`${text}中`}
|
||||
size={36}
|
||||
state="thinking"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* 文本提示 */}
|
||||
@ -191,4 +188,3 @@ export const ImageProcessingLoading = memo(({ type }: ImageProcessingLoadingProp
|
||||
});
|
||||
|
||||
ImageProcessingLoading.displayName = "ImageProcessingLoading";
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ export function BrandTopBar({
|
||||
showBrand = true,
|
||||
siteName,
|
||||
}: BrandTopBarProps) {
|
||||
const resolvedSiteName = String(siteName || "").trim() || "由壹点AI";
|
||||
const resolvedSiteName = String(siteName || "").trim() || "𝗕𝘂𝗬𝗶";
|
||||
const positionClass =
|
||||
position === "fixed"
|
||||
? "fixed left-0 top-0 z-40 w-full"
|
||||
|
||||
@ -19,13 +19,13 @@ import {
|
||||
ToolInput,
|
||||
ToolOutput,
|
||||
} from "./elements/tool";
|
||||
import { SparklesIcon } from "./icons";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageEditor } from "./message-editor";
|
||||
import { MessageReasoning } from "./message-reasoning";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Weather } from "./weather";
|
||||
import { ImageProcessingLoading } from "./image-processing-loading";
|
||||
import { BloubAvatar } from "./bloub-avatar";
|
||||
|
||||
/**
|
||||
* 解析文本中的标注内容,返回文本片段数组
|
||||
@ -420,9 +420,12 @@ const PurePreviewMessage = ({
|
||||
})}
|
||||
>
|
||||
{message.role === "assistant" && hasVisibleTextPart && (
|
||||
<div className="-mt-1 flex size-8 shrink-0 items-center justify-center rounded-full bg-background ring-1 ring-border">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
<BloubAvatar
|
||||
animated={false}
|
||||
className="-mt-1"
|
||||
label="AI 助手"
|
||||
size={32}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
@ -792,30 +795,12 @@ export const ThinkingMessage = () => {
|
||||
data-testid="message-assistant-loading"
|
||||
>
|
||||
<div className="flex items-start justify-start gap-3">
|
||||
{/* 增强的头像动画 - 添加多层脉动效果 */}
|
||||
<div className="relative -mt-1 flex size-8 shrink-0 items-center justify-center">
|
||||
{/* 外层脉动光晕 */}
|
||||
<div className="absolute inset-0 rounded-full bg-primary/20 animate-ping" />
|
||||
{/* 中层脉动光晕 */}
|
||||
<div className="absolute inset-0 rounded-full bg-primary/10 animate-pulse" />
|
||||
{/* 头像容器 */}
|
||||
<div className="relative flex size-8 items-center justify-center rounded-full bg-background ring-2 ring-primary/30">
|
||||
<div className="animate-spin text-primary">
|
||||
<SparklesIcon size={14} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
|
||||
<span className="sr-only">正在处理</span>
|
||||
<span className="flex gap-1" aria-hidden="true">
|
||||
<span className="h-2 w-2 rounded-full bg-foreground/65 animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="h-2 w-2 rounded-full bg-foreground/65 animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="h-2 w-2 rounded-full bg-foreground/65 animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<BloubAvatar
|
||||
className="-mt-1"
|
||||
label="AI 助手正在思考"
|
||||
size={36}
|
||||
state="thinking"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -1118,7 +1118,7 @@ export function StyleProWorkspace({
|
||||
persistedState?.sourceImageUrl,
|
||||
]);
|
||||
|
||||
const siteName = brandingData?.siteName || "由壹点AI";
|
||||
const siteName = brandingData?.siteName || "𝗕𝘂𝗬𝗶";
|
||||
const logo = brandingData?.siteLogoDark || brandingData?.siteLogo || "";
|
||||
|
||||
const selectedOptionLabels = useMemo(() => {
|
||||
|
||||
21
lib/bloub-official.LICENSE
Normal file
21
lib/bloub-official.LICENSE
Normal file
@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jérémy Perret
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
10
lib/bloub-official/UPSTREAM.md
Normal file
10
lib/bloub-official/UPSTREAM.md
Normal file
@ -0,0 +1,10 @@
|
||||
# Bloub upstream
|
||||
|
||||
The files in this directory are copied without functional changes from:
|
||||
|
||||
- Repository: https://github.com/jeremy-prt/bloub
|
||||
- Commit: b4bb3c1b5f93c7b87a2e8d620f667c4093d97749
|
||||
- License: MIT (see `../bloub-official.LICENSE`)
|
||||
|
||||
Only the upstream runtime files are vendored. The Vue presentation component and
|
||||
upstream tests are intentionally not copied into the application bundle.
|
||||
225
lib/bloub-official/cycles.ts
Normal file
225
lib/bloub-official/cycles.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import { SEQUENCE, STATES, STATE_BY_ID, type StateId } from './states'
|
||||
|
||||
/**
|
||||
* Un cycle est un montage : une suite de blocs, chacun un etat tenu pendant une
|
||||
* duree choisie. C'est la partie "editeur" du dossier, et elle en garde les
|
||||
* regles — donnees pures, aucune horloge, aucun import Vue : le meme cycle doit
|
||||
* pouvoir etre relu par les tests, par le lecteur et par la timeline.
|
||||
*
|
||||
* Un bloc n'a pas d'identifiant : c'est une position dans une liste, la cle de
|
||||
* rendu est l'index. Ca garde le JSON du localStorage lisible et les tests
|
||||
* deterministes.
|
||||
*/
|
||||
export interface Block {
|
||||
state: StateId
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface Cycle {
|
||||
id: string
|
||||
name: string
|
||||
blocks: Block[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Plancher commun a tous les blocs. Le moteur ne garde qu'une case d'historique
|
||||
* (`BotEngine.setState` ecrase `prev`), donc un bloc plus court que le fondu d'entree du
|
||||
* bloc suivant saute a l'image au lieu de se fondre.
|
||||
*
|
||||
* DERIVE du catalogue et non ecrit a la main. La valeur etait 0,6, ce qui marchait
|
||||
* uniquement parce que 0,6 se trouvait etre le plus long `morph` du catalogue — celui
|
||||
* d'`orbit`. Rien ne le garantissait : ajouter un etat qui morphe en 0,8 s aurait fait
|
||||
* trembler l'editeur sans qu'aucun test ne bronche. Maintenant le plancher suit.
|
||||
*/
|
||||
export const MIN_BLOCK = Math.max(...STATES.map((s) => s.morph))
|
||||
|
||||
/**
|
||||
* Garde-fou d'editeur, pas une mesure : allonger un bloc est sans risque (les
|
||||
* etats saturent leurs rampes et tiennent leur pose finale), mais une piste de
|
||||
* blocs d'une minute n'est plus lisible.
|
||||
*/
|
||||
export const MAX_BLOCK = 10
|
||||
|
||||
/**
|
||||
* Combien de blocs et de montages on accepte, a l'edition comme a la relecture.
|
||||
*
|
||||
* Ce ne sont pas des limites de produit mais des bornes contre un stockage hostile, qui
|
||||
* est modifiable et tient quelques megaoctets alors que rien en aval n'est dimensionne
|
||||
* pour ca : un seul cycle de 150 000 blocs, soit environ 4 Mo de JSON, donne 1 500 000 s
|
||||
* de duree, autant de graduations a allouer et une piste de 29 700 000 px de large.
|
||||
* L'onglet figeait en entrant dans la vue Animations.
|
||||
*
|
||||
* 200 blocs font une demi-heure de montage, largement au-dela de tout usage.
|
||||
*/
|
||||
export const MAX_BLOCS = 200
|
||||
export const MAX_CYCLES = 50
|
||||
|
||||
/** Pas de la molette et du redimensionnement, en secondes. */
|
||||
export const STEP = 0.1
|
||||
|
||||
const DEFAULT_CYCLE_ID = 'defaut'
|
||||
|
||||
/** Duree minimale d'un bloc : le plancher moteur, ou la mesure de l'etat. */
|
||||
export function minDurationOf(state: StateId): number {
|
||||
return Math.max(MIN_BLOCK, STATE_BY_ID.get(state)?.minDuration ?? MIN_BLOCK)
|
||||
}
|
||||
|
||||
/** Ramene une duree dans ses bornes et sur le pas, sans trainee de flottants. */
|
||||
export function clampDuration(state: StateId, seconds: number): number {
|
||||
const snapped = Math.round(seconds / STEP) * STEP
|
||||
const bounded = Math.min(MAX_BLOCK, Math.max(minDurationOf(state), snapped))
|
||||
return Math.round(bounded * 100) / 100
|
||||
}
|
||||
|
||||
export function makeBlock(state: StateId): Block {
|
||||
// la duree de reference est celle relevee sur la video pour cet etat
|
||||
return { state, duration: clampDuration(state, STATE_BY_ID.get(state)?.duration ?? 2) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Le montage releve sur la video : l'ordre de `SEQUENCE`, chaque etat tenu sa
|
||||
* duree mesuree. Il sert d'amorce au premier lancement, puis il appartient a
|
||||
* l'utilisateur — il s'edite et se stocke comme les autres. La reference, elle,
|
||||
* reste dans le code : vider le stockage la fait revenir.
|
||||
*/
|
||||
export function defaultCycle(): Cycle {
|
||||
return {
|
||||
/**
|
||||
* Nom vide = « jamais nomme par l'utilisateur », donc affiche dans la langue
|
||||
* courante. Ecrire ici « Cycle par defaut » l'aurait fige : le nom part au
|
||||
* localStorage des la premiere visite et redevient une donnee utilisateur,
|
||||
* que changer de langue ne retraduirait plus.
|
||||
*/
|
||||
name: '',
|
||||
id: DEFAULT_CYCLE_ID,
|
||||
blocks: SEQUENCE.map(makeBlock)
|
||||
}
|
||||
}
|
||||
|
||||
export function totalDuration(blocks: Block[]): number {
|
||||
return blocks.reduce((sum, b) => sum + b.duration, 0)
|
||||
}
|
||||
|
||||
/** Date de debut d'un bloc dans le montage. */
|
||||
export function offsetOf(blocks: Block[], index: number): number {
|
||||
let acc = 0
|
||||
for (let i = 0; i < index && i < blocks.length; i++) acc += blocks[i]!.duration
|
||||
return acc
|
||||
}
|
||||
|
||||
/**
|
||||
* Bloc joue a la date `t` et temps ecoule dedans. Au-dela du dernier bloc on
|
||||
* retombe au debut : la lecture boucle. L'appelant verifie que le montage n'est
|
||||
* pas vide.
|
||||
*/
|
||||
export function blockAt(blocks: Block[], t: number): { index: number; elapsed: number } {
|
||||
const total = totalDuration(blocks)
|
||||
if (!blocks.length || total <= 0) return { index: 0, elapsed: 0 }
|
||||
// le modulo n'est applique que s'il sert : sur une date deja dans le cycle il
|
||||
// n'ajouterait qu'une trainee de flottants au temps ecoule
|
||||
const wrapped = t >= 0 && t < total ? t : ((t % total) + total) % total
|
||||
let acc = 0
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const end = acc + blocks[i]!.duration
|
||||
if (wrapped < end) return { index: i, elapsed: wrapped - acc }
|
||||
acc = end
|
||||
}
|
||||
return { index: blocks.length - 1, elapsed: 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Ajoute une animation a la fin du montage (palette de droite ou carte « + »).
|
||||
*
|
||||
* Plafonnee a `MAX_BLOCS`, comme la relecture. Sans ca l'editeur laissait construire un
|
||||
* montage plus grand que ce que le stockage rend au rechargement, et le travail
|
||||
* disparaissait en silence — une borne de relecture qui n'est pas aussi une borne d'edition
|
||||
* est un piege, pas une protection.
|
||||
*/
|
||||
export function blocksWith(blocks: Block[], state: StateId): Block[] {
|
||||
if (blocks.length >= MAX_BLOCS) return blocks
|
||||
return [...blocks, makeBlock(state)]
|
||||
}
|
||||
|
||||
/** Deplace un bloc, en rendant une nouvelle liste (les etats Vue sont remplaces). */
|
||||
export function moveBlock(blocks: Block[], from: number, to: number): Block[] {
|
||||
const next = blocks.slice()
|
||||
const [moved] = next.splice(from, 1)
|
||||
if (!moved) return blocks
|
||||
next.splice(Math.min(Math.max(to, 0), next.length), 0, moved)
|
||||
return next
|
||||
}
|
||||
|
||||
/** `Mon cycle`, `Mon cycle 2`, `Mon cycle 3`... — jamais deux fois le meme nom. */
|
||||
export function uniqueName(base: string, cycles: Cycle[]): string {
|
||||
const taken = new Set(cycles.map((c) => c.name))
|
||||
if (!taken.has(base)) return base
|
||||
let n = 2
|
||||
while (taken.has(`${base} ${n}`)) n++
|
||||
return `${base} ${n}`
|
||||
}
|
||||
|
||||
/** Identifiant sans collision, y compris avec un localStorage bricole a la main. */
|
||||
export function nextCycleId(cycles: Cycle[]): string {
|
||||
const taken = new Set(cycles.map((c) => c.id))
|
||||
let n = 1
|
||||
while (taken.has(`c${n}`)) n++
|
||||
return `c${n}`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- lecture du stockage */
|
||||
|
||||
function parseBlock(raw: unknown): Block | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null
|
||||
const { state, duration } = raw as { state?: unknown; duration?: unknown }
|
||||
/*
|
||||
* Valide contre SEQUENCE et non contre `STATE_BY_ID` : ce dernier contient `swirl`, qui
|
||||
* est deliberement hors du catalogue — c'est la transition d'entree des reglages, un
|
||||
* test la verrouille hors de la palette et de la planche. Un montage utilisateur ne se
|
||||
* construit qu'a partir de la palette, donc un `swirl` ne peut y arriver que par un
|
||||
* stockage bricole a la main, et il n'y a aucune raison de l'y tolerer quand on l'exclut
|
||||
* partout ailleurs.
|
||||
*/
|
||||
if (typeof state !== 'string' || !SEQUENCE.includes(state as StateId)) return null
|
||||
if (typeof duration !== 'number' || !Number.isFinite(duration)) return null
|
||||
return { state: state as StateId, duration: clampDuration(state as StateId, duration) }
|
||||
}
|
||||
|
||||
function parseCycle(raw: unknown, seen: Cycle[]): Cycle | null {
|
||||
if (typeof raw !== 'object' || raw === null) return null
|
||||
const { id, name, blocks } = raw as { id?: unknown; name?: unknown; blocks?: unknown }
|
||||
if (typeof id !== 'string' || !id) return null
|
||||
// le nom peut etre vide — c'est le montage d'amorce, qui suit la langue
|
||||
if (typeof name !== 'string') return null
|
||||
if (!Array.isArray(blocks)) return null
|
||||
// on tronque AVANT de relire : valider 150 000 blocs pour n'en garder que 200 serait
|
||||
// faire le travail qu'on cherche justement a eviter
|
||||
const kept = blocks
|
||||
.slice(0, MAX_BLOCS)
|
||||
.map(parseBlock)
|
||||
.filter((b): b is Block => b !== null)
|
||||
if (!kept.length) return null
|
||||
if (seen.some((c) => c.id === id)) return null
|
||||
return { id, name, blocks: kept }
|
||||
}
|
||||
|
||||
/**
|
||||
* Le localStorage est modifiable a la main : on ne lui fait pas confiance, meme
|
||||
* regle que pour le hash de l'URL. Tout ce qui ne se relit pas est jete
|
||||
* silencieusement plutot que de casser l'application au demarrage.
|
||||
*/
|
||||
export function parseCycles(raw: string | null): Cycle[] {
|
||||
if (!raw) return []
|
||||
let data: unknown
|
||||
try {
|
||||
data = JSON.parse(raw)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
if (!Array.isArray(data)) return []
|
||||
const out: Cycle[] = []
|
||||
for (const item of data.slice(0, MAX_CYCLES)) {
|
||||
const cycle = parseCycle(item, out)
|
||||
if (cycle) out.push(cycle)
|
||||
}
|
||||
return out
|
||||
}
|
||||
285
lib/bloub-official/decor.ts
Normal file
285
lib/bloub-official/decor.ts
Normal file
@ -0,0 +1,285 @@
|
||||
import { TAU, clamp, createRng, r2 } from './math'
|
||||
|
||||
/* ------------------------------------------------------------------ couleurs */
|
||||
|
||||
/**
|
||||
* Les anneaux ne sont pas des couleurs plates : la video montre une roue de
|
||||
* teintes complete a luminosite constante, avec un degrade le long de chaque
|
||||
* trace. Mesure : S 45-62 %, L 50-67 %.
|
||||
*/
|
||||
function wheel(hue: number, s = 0.55, l = 0.62): string {
|
||||
const h = ((hue % 360) + 360) % 360
|
||||
const c = (1 - Math.abs(2 * l - 1)) * s
|
||||
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
|
||||
const m = l - c / 2
|
||||
const [r, g, b] =
|
||||
h < 60
|
||||
? [c, x, 0]
|
||||
: h < 120
|
||||
? [x, c, 0]
|
||||
: h < 180
|
||||
? [0, c, x]
|
||||
: h < 240
|
||||
? [0, x, c]
|
||||
: h < 300
|
||||
? [x, 0, c]
|
||||
: [c, 0, x]
|
||||
const hex = (v: number) =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, '0')
|
||||
return `#${hex(r)}${hex(g)}${hex(b)}`
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- types de rendu */
|
||||
|
||||
export interface DotRender {
|
||||
x: number
|
||||
y: number
|
||||
r: number
|
||||
opacity: number
|
||||
/** couleur explicite ; par defaut le rendu prend celle du corps */
|
||||
color?: string
|
||||
/**
|
||||
* Brume de profondeur : 0 = fondu dans le fond, 1 = couleur du corps pleine.
|
||||
* Le melange se fait au rendu, qui seul connait la couleur choisie.
|
||||
*/
|
||||
depth?: number
|
||||
/**
|
||||
* Forme non circulaire, en unites de rayon de boule et centree sur l'origine
|
||||
* (le point du "!" penche est une goutte, pas un disque). Quand elle est
|
||||
* fournie, `r` n'est plus utilise pour le trace.
|
||||
*/
|
||||
d?: string
|
||||
/** rotation appliquee a `d`, en degres */
|
||||
rot?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Ce qu'un etat declare : la geometrie de l'arc reste en unites de rayon de
|
||||
* boule, c'est le moteur (seul a connaitre l'echelle du viewBox) qui la
|
||||
* rasterise. Sans ca les etats devraient connaitre le viewBox.
|
||||
*/
|
||||
export interface ArcSpec {
|
||||
id: string
|
||||
seed: ArcSeed
|
||||
t: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
export interface ArcRender {
|
||||
id: string
|
||||
/** portion devant le corps */
|
||||
front: string
|
||||
/** portion derriere le corps (dessinee avant, donc masquee par la silhouette) */
|
||||
back: string
|
||||
width: number
|
||||
opacity: number
|
||||
/** degrade de teinte le long du trace */
|
||||
grad: { x1: number; y1: number; x2: number; y2: number; stops: string[] }
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------- arc elliptique 3D */
|
||||
|
||||
export interface ArcSeed {
|
||||
/** demi-grand axe, en unites de rayon de boule */
|
||||
a: number
|
||||
/** aplatissement b/a : mesure <= 0.45, les plans d'orbite sont vus par la tranche */
|
||||
k: number
|
||||
/** inclinaison du grand axe a l'ecran, radians */
|
||||
tilt: number
|
||||
/** tours par seconde */
|
||||
speed: number
|
||||
phase: number
|
||||
/** fraction du tour reellement tracee */
|
||||
sweep: number
|
||||
hue: number
|
||||
hueSpan: number
|
||||
width: number
|
||||
cx: number
|
||||
cy: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Projette un cercle 3D incline en orthographique.
|
||||
*
|
||||
* Le cercle vit dans le plan engendre par u (dans l'ecran) et v (qui plonge
|
||||
* dans la profondeur). La composante z sert a couper l'arc en deux : la moitie
|
||||
* arriere est dessinee avant le corps, donc occultee par lui. C'est ce vrai tri
|
||||
* en profondeur qui fait lire les anneaux comme des orbites et pas comme un
|
||||
* dessin plat.
|
||||
*/
|
||||
export function arcRender(seed: ArcSeed, t: number, scale: number, id: string, opacity = 1): ArcRender {
|
||||
const spin = seed.phase + t * seed.speed * TAU
|
||||
const cu = Math.cos(seed.tilt)
|
||||
const su = Math.sin(seed.tilt)
|
||||
const kz = Math.sqrt(Math.max(0, 1 - seed.k * seed.k))
|
||||
|
||||
const N = 64
|
||||
const span = seed.sweep * TAU
|
||||
let front = ''
|
||||
let back = ''
|
||||
let prev: boolean | null = null
|
||||
|
||||
for (let i = 0; i <= N; i++) {
|
||||
const th = spin + (i / N) * span
|
||||
const ct = Math.cos(th)
|
||||
const st = Math.sin(th)
|
||||
// u = (cos tilt, sin tilt, 0) ; v = (-sin tilt * k, cos tilt * k, kz)
|
||||
const x = seed.a * (ct * cu + st * -su * seed.k) + seed.cx
|
||||
const y = seed.a * (ct * su + st * cu * seed.k) + seed.cy
|
||||
const z = seed.a * st * kz
|
||||
|
||||
const behind = z < 0
|
||||
const sx = r2(x * scale)
|
||||
const sy = r2(y * scale)
|
||||
const cmd = behind !== prev ? 'M' : 'L'
|
||||
if (behind) back += `${cmd}${sx} ${sy}`
|
||||
else front += `${cmd}${sx} ${sy}`
|
||||
prev = behind
|
||||
}
|
||||
|
||||
const gx = Math.cos(seed.tilt) * seed.a * scale
|
||||
const gy = Math.sin(seed.tilt) * seed.a * scale
|
||||
return {
|
||||
id,
|
||||
front,
|
||||
back,
|
||||
width: seed.width * scale,
|
||||
opacity,
|
||||
grad: {
|
||||
x1: r2(seed.cx * scale - gx),
|
||||
y1: r2(seed.cy * scale - gy),
|
||||
x2: r2(seed.cx * scale + gx),
|
||||
y2: r2(seed.cy * scale + gy),
|
||||
stops: [wheel(seed.hue), wheel(seed.hue + seed.hueSpan * 0.5), wheel(seed.hue + seed.hueSpan)]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- anneaux */
|
||||
|
||||
const RING_RNG = createRng(0xa11ce)
|
||||
|
||||
/**
|
||||
* 6 anneaux, demi-grand axe 1.30-1.40 (donc nettement plus grands que la
|
||||
* boule), aplatissement toujours <= 0.45, epaisseur 0.055, ~3.3 tours/s.
|
||||
*/
|
||||
export const RINGS: ArcSeed[] = Array.from({ length: 6 }, (_, i) => ({
|
||||
a: 1.3 + RING_RNG() * 0.1,
|
||||
k: 0.05 + RING_RNG() * 0.4,
|
||||
tilt: (i / 6) * Math.PI + RING_RNG() * 0.5,
|
||||
speed: 3 + RING_RNG() * 0.7,
|
||||
phase: RING_RNG() * TAU,
|
||||
sweep: 0.6 + RING_RNG() * 0.25,
|
||||
hue: (i * 360) / 6 + RING_RNG() * 30,
|
||||
hueSpan: 60 + RING_RNG() * 60,
|
||||
width: 0.05 + RING_RNG() * 0.012,
|
||||
cx: 0,
|
||||
cy: 0.1
|
||||
}))
|
||||
|
||||
/**
|
||||
* Bouquet d'arcs emboites qui balaie le triangle juste avant les orbites.
|
||||
* Vus quasiment par la tranche (d'ou la forme en epingle a cheveux), rmax 1.37.
|
||||
*/
|
||||
export const SWOOSH: ArcSeed[] = Array.from({ length: 4 }, (_, i) => ({
|
||||
a: 0.78 + i * 0.2,
|
||||
k: 0.05 + i * 0.02,
|
||||
tilt: -0.62 + i * 0.05,
|
||||
speed: 0.3,
|
||||
phase: 0.06 * i,
|
||||
sweep: 0.4,
|
||||
hue: 95 + i * 62,
|
||||
hueSpan: 100,
|
||||
width: 0.05,
|
||||
cx: 0,
|
||||
cy: -0.12
|
||||
}))
|
||||
|
||||
/* ------------------------------------------------------------- 3 points */
|
||||
|
||||
/** x mesures : -0.557 / -0.013 / +0.532, y = 0. */
|
||||
export const DOT_X = [-0.557, -0.013, 0.532] as const
|
||||
export const DOT_R = 0.165
|
||||
export const DOT_PEAK = 1.25
|
||||
|
||||
/* ------------------------------------------------------------ particules */
|
||||
|
||||
const P_RNG = createRng(0xbeef)
|
||||
|
||||
/** 5 particules, une nouvelle toutes les 0.2 s, duree de vie 0.55 s. */
|
||||
const PARTICLES = Array.from({ length: 5 }, (_, i) => ({
|
||||
birth: i * 0.2,
|
||||
angle: P_RNG() * TAU,
|
||||
rho: 0.58 + P_RNG() * 0.18
|
||||
}))
|
||||
|
||||
/**
|
||||
* Les particules ne partent pas en ligne droite : elles spiralent vers le
|
||||
* centre (rayon x0.75 par frame, angle +100 deg/s) en grossissant, et passent
|
||||
* derriere le noyau ou elles sont avalees.
|
||||
*/
|
||||
export function particles(t: number, scale: number): DotRender[] {
|
||||
const out: DotRender[] = []
|
||||
for (const p of PARTICLES) {
|
||||
const u = t - p.birth
|
||||
if (u < 0 || u > 0.62) continue
|
||||
const rho = p.rho * Math.pow(0.75, u * 10)
|
||||
const a = p.angle + (u * 100 * Math.PI) / 180
|
||||
out.push({
|
||||
x: Math.cos(a) * rho * scale,
|
||||
y: Math.sin(a) * rho * scale,
|
||||
r: (0.04 + 0.028 * clamp(u / 0.55)) * scale,
|
||||
depth: clamp(1 - rho / 0.8),
|
||||
opacity: clamp(u / 0.06) * clamp((0.62 - u) / 0.08)
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ comete */
|
||||
|
||||
/**
|
||||
* Contrairement a l'intuition, le point ne traverse pas l'ecran : il reste au
|
||||
* centre et c'est la trainee qui l'orbite. Ellipse a = 0.85, b = 0.15,
|
||||
* grand axe incline de +34deg, 4 rubans, ~210 deg/s.
|
||||
*/
|
||||
const COMET_RNG = createRng(0xc0e7)
|
||||
export const COMET_RIBBONS: ArcSeed[] = Array.from({ length: 4 }, (_, i) => {
|
||||
const d = i - 1.5
|
||||
return {
|
||||
a: 0.85 * (1 + d * 0.03),
|
||||
// meme aplatissement a +-5 % pres : les rubans forment un faisceau serre
|
||||
k: (0.15 / 0.85) * (1 + d * 0.16),
|
||||
tilt: (34 * Math.PI) / 180 + d * 0.035,
|
||||
speed: 210 / 360,
|
||||
// dephasage mesure : 10 a 20 degres entre rubans, pas davantage
|
||||
phase: -i * 0.045 + COMET_RNG() * 0.012,
|
||||
sweep: 0.34,
|
||||
hue: i * 85 + COMET_RNG() * 20,
|
||||
hueSpan: 80,
|
||||
width: 0.095,
|
||||
cx: 0,
|
||||
cy: 0
|
||||
}
|
||||
})
|
||||
|
||||
/** Rayon du point de la comete, mesure a 0.129. */
|
||||
export const COMET_DOT = 0.129
|
||||
|
||||
/* --------------------------------------------------- pastille notification */
|
||||
|
||||
/** Bleu releve au pixel. */
|
||||
export const NOTIF_BLUE = '#2496e8'
|
||||
/** La pastille est posee exactement sur la circonference, a -42deg. */
|
||||
export const NOTIF_ANGLE = -42
|
||||
export const NOTIF_DIST = 1.003
|
||||
/** Rayon au repos ; le pop culmine 14 % au-dessus. */
|
||||
export const NOTIF_R = 0.15
|
||||
export const NOTIF_POP = 1.14
|
||||
/**
|
||||
* L'encoche est un disque concentrique a la pastille, soustrait du corps.
|
||||
* La marge est constante (0.054 R) et suit l'echelle du corps.
|
||||
*/
|
||||
export const NOTIF_MARGIN = 0.054
|
||||
556
lib/bloub-official/engine.ts
Normal file
556
lib/bloub-official/engine.ts
Normal file
@ -0,0 +1,556 @@
|
||||
import { arcRender, type ArcRender, type DotRender } from './decor'
|
||||
import { blendExpression, type BotExpression } from './expressions'
|
||||
import { decalageDesYeux } from './eyefit'
|
||||
import { blinkScale, eyePoses, liveliness } from './face'
|
||||
import { clamp, easings, lerp, r2 } from './math'
|
||||
import {
|
||||
blend,
|
||||
capsulePath,
|
||||
closedPath,
|
||||
radiusAtAngle,
|
||||
toPoints,
|
||||
type Point,
|
||||
type Silhouette
|
||||
} from './shape'
|
||||
import { STATE_BY_ID, type Pose, type StateDef, type StateId } from './states'
|
||||
|
||||
export interface RenderedEye {
|
||||
d: string
|
||||
matrix: string
|
||||
alpha: number
|
||||
}
|
||||
export interface BotFrame {
|
||||
bodyPath: string
|
||||
bodyAlpha: number
|
||||
eyes: RenderedEye[]
|
||||
dots: DotRender[]
|
||||
/** true = les points passent derriere le corps (particules de l'eclatement) */
|
||||
dotsBehind: boolean
|
||||
arcs: ArcRender[]
|
||||
notif: { x: number; y: number; r: number } | null
|
||||
notch: { x: number; y: number; r: number } | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Ou le bot porte son regard quand quelque chose d'exterieur le pilote — le
|
||||
* pointeur de la souris, aujourd'hui.
|
||||
*
|
||||
* `yaw` et `pitch` sont des directions ABSOLUES, qui remplacent celles de la pose
|
||||
* a mesure que `mix` monte. Deux raisons, chacune un piege deja tombe :
|
||||
*
|
||||
* - c'est le MOTEUR qui doit faire ce melange, pas l'appelant, parce que lui seul
|
||||
* connait la pose A CET INSTANT. Un appelant qui compenserait l'orientation de
|
||||
* l'expression lirait sa valeur d'arrivee pendant que le morph est encore en
|
||||
* cours, et les yeux sautaient a chaque changement d'humeur ;
|
||||
* - et il faut que ce soit absolu sur les DEUX axes. En relatif, la hauteur des
|
||||
* yeux suivait celle de chaque expression — « neutre » regarde a +28,6deg quand
|
||||
* les autres sont entre -9 et +9 — donc les yeux tombaient d'un coup au premier
|
||||
* changement d'humeur. Ce qui fait le caractere d'une expression pendant le
|
||||
* suivi, c'est la FORME de ses yeux (plisses, ronds, dissymetriques), pas
|
||||
* l'endroit ou elle regarde : celui-la, c'est le curseur qui le decide.
|
||||
*
|
||||
* `mix` dit a quel point l'exterieur commande la DIRECTION (0 = pas du tout).
|
||||
*
|
||||
* `wander` dit, separement, ce qui reste de derive automatique. Les deux ne se
|
||||
* confondent pas : quand le pointeur bouge, la derive doit s'eteindre — cumulees,
|
||||
* le bot aurait l'air de chercher le curseur sans jamais le tenir. Mais quand il
|
||||
* n'y a PAS de pointeur (arrivee au clavier, au tactile, ou souris sortie de la
|
||||
* fenetre), la tete doit rester tournee ET continuer de vivre. Les confondre
|
||||
* figeait le regard des que la vue s'ouvrait.
|
||||
*
|
||||
* `spin` est un tour a parcourir EN CHEMIN, en degres, qu'on fait fondre vers 0
|
||||
* avec l'arrivee. Comme les yeux vivent sur une sphere, un tour les fait passer
|
||||
* derriere la boule et revenir de l'autre cote — et `-360deg` etant le meme
|
||||
* angle que `0`, il ne change rien a l'endroit ou ils se posent.
|
||||
*/
|
||||
export interface Look {
|
||||
yaw: number
|
||||
pitch: number
|
||||
mix: number
|
||||
spin: number
|
||||
wander: number
|
||||
}
|
||||
|
||||
const NO_LOOK: Look = { yaw: 0, pitch: 0, mix: 0, spin: 0, wander: 1 }
|
||||
|
||||
const lerpLook = (a: Look, b: Look, t: number): Look => ({
|
||||
yaw: lerp(a.yaw, b.yaw, t),
|
||||
pitch: lerp(a.pitch, b.pitch, t),
|
||||
mix: lerp(a.mix, b.mix, t),
|
||||
spin: lerp(a.spin, b.spin, t),
|
||||
wander: lerp(a.wander, b.wander, t)
|
||||
})
|
||||
|
||||
const lerpEye = (a: Pose['eyes'][number], b: Pose['eyes'][number], t: number) => ({
|
||||
w: lerp(a.w, b.w, t),
|
||||
h: lerp(a.h, b.h, t),
|
||||
open: lerp(a.open, b.open, t),
|
||||
tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t)
|
||||
})
|
||||
|
||||
/** Interpolation de deux poses. Le decor se croise en opacite, pas en geometrie. */
|
||||
function blendPose(a: Pose, b: Pose, t: number): Pose {
|
||||
const out = 1 - t
|
||||
return {
|
||||
sil: blend(a.sil, b.sil, t),
|
||||
offX: lerp(a.offX, b.offX, t),
|
||||
offY: lerp(a.offY, b.offY, t),
|
||||
gaze: {
|
||||
yaw: lerp(a.gaze.yaw, b.gaze.yaw, t),
|
||||
pitch: lerp(a.gaze.pitch, b.gaze.pitch, t),
|
||||
roll: lerp(a.gaze.roll, b.gaze.roll, t)
|
||||
},
|
||||
split: lerp(a.split, b.split, t),
|
||||
eyes: [lerpEye(a.eyes[0], b.eyes[0], t), lerpEye(a.eyes[1], b.eyes[1], t)],
|
||||
eyeAlpha: lerp(a.eyeAlpha, b.eyeAlpha, t),
|
||||
bodyAlpha: lerp(a.bodyAlpha, b.bodyAlpha, t),
|
||||
dots: [
|
||||
...a.dots.map((d) => ({ ...d, opacity: d.opacity * out })),
|
||||
...b.dots.map((d) => ({ ...d, opacity: d.opacity * t }))
|
||||
],
|
||||
arcs: [
|
||||
...a.arcs.map((r) => ({ ...r, id: `a${r.id}`, opacity: r.opacity * out })),
|
||||
...b.arcs.map((r) => ({ ...r, id: `b${r.id}`, opacity: r.opacity * t }))
|
||||
],
|
||||
// la pastille appartient a un seul des deux etats, elle ne se melange pas
|
||||
notif: t < 0.5 ? a.notif : b.notif,
|
||||
dotsBehind: t < 0.5 ? a.dotsBehind : b.dotsBehind
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moteur sans horloge : `sample(t)` est une fonction pure du temps.
|
||||
*
|
||||
* Consequence pratique : pause, reprise, ralenti et saut a une date arbitraire
|
||||
* donnent exactement la meme image, et le rendu est testable sans DOM.
|
||||
*/
|
||||
export class BotEngine {
|
||||
/** rayon de la boule au repos, en unites de viewBox */
|
||||
readonly scale: number
|
||||
|
||||
private cur: StateId
|
||||
private prev: StateId | null = null
|
||||
/**
|
||||
* Pose de depart FIGEE, posee seulement quand un changement d'etat arrive alors qu'un
|
||||
* fondu est deja en cours. Cf. `setState`.
|
||||
*/
|
||||
private departFige: Pose | null = null
|
||||
private tCur = 0
|
||||
private tPrev = 0
|
||||
private blinkAt = -10
|
||||
private pts: Point[] = []
|
||||
private shape: number[] | null = null
|
||||
private shapePrev: number[] | null = null
|
||||
private shapeAt = -10
|
||||
private expr: BotExpression | null = null
|
||||
private exprPrev: BotExpression | null = null
|
||||
private exprAt = -10
|
||||
private look: Look = NO_LOOK
|
||||
private lookPrev: Look = NO_LOOK
|
||||
private lookAt = -10
|
||||
/** duree de rattrapage en cours ; voir `LOOK_MORPH`, sa valeur par defaut */
|
||||
private lookMorph = 0.24
|
||||
|
||||
/** duree du morph quand on change la forme du corps */
|
||||
static readonly SHAPE_MORPH = 0.45
|
||||
|
||||
/**
|
||||
* Duree de rattrapage du regard vers la cible. Plus court que `SHAPE_MORPH` :
|
||||
* un regard qui suit doit paraitre attentif, pas visqueux. Comme la cible est
|
||||
* reposee a chaque mouvement de souris, c'est cette duree qui donne au suivi
|
||||
* son inertie — le regard n'atteint jamais tout a fait un curseur qui bouge.
|
||||
*/
|
||||
static readonly LOOK_MORPH = 0.24
|
||||
|
||||
constructor(
|
||||
scale = 100,
|
||||
initial: StateId = 'idle',
|
||||
shape: number[] | null = null,
|
||||
expression: BotExpression | null = null
|
||||
) {
|
||||
this.scale = scale
|
||||
this.cur = initial
|
||||
this.shape = shape
|
||||
this.expr = expression
|
||||
}
|
||||
|
||||
/**
|
||||
* Expression de repos choisie dans le personnalisateur. Comme la forme, elle
|
||||
* glisse vers la nouvelle valeur au lieu de sauter.
|
||||
*/
|
||||
setExpression(expression: BotExpression | null, now = 0) {
|
||||
if (expression === this.expr) return
|
||||
this.exprPrev = this.expr
|
||||
this.expr = expression
|
||||
this.exprAt = now
|
||||
}
|
||||
|
||||
/** Expression effective a l'instant `now`, morph en cours compris. */
|
||||
private exprAtTime(now: number): BotExpression | null {
|
||||
const to = this.expr
|
||||
const from = this.exprPrev
|
||||
if (!to || !from) return to
|
||||
const k = (now - this.exprAt) / BotEngine.SHAPE_MORPH
|
||||
if (k >= 1) return to
|
||||
return blendExpression(from, to, easings.easeOutQuint(clamp(k)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme choisie dans le personnalisateur. Elle ne remplace le corps que sur
|
||||
* les etats au repos (`baseBody`) : sur les autres, la silhouette EST
|
||||
* l'animation et ne doit pas etre ecrasee.
|
||||
*
|
||||
* Le changement se fait en morph, pas d'un coup : comme toutes les formes sont
|
||||
* echantillonnees aux memes angles, il suffit d'interpoler les rayons.
|
||||
*/
|
||||
setShape(radii: number[] | null, now = 0) {
|
||||
if (radii === this.shape) return
|
||||
this.shapePrev = this.shape
|
||||
this.shape = radii
|
||||
this.shapeAt = now
|
||||
}
|
||||
|
||||
/**
|
||||
* Forme effective a l'instant `now`, morph en cours compris.
|
||||
*
|
||||
* Ne remet PAS `shapePrev` a null en fin de morph : `sample` doit rester une
|
||||
* fonction pure du temps, donc relire une date passee doit redonner l'image
|
||||
* intermediaire. On garde juste une reference de plus.
|
||||
*/
|
||||
private shapeAtTime(now: number): number[] | null {
|
||||
const to = this.shape
|
||||
const from = this.shapePrev
|
||||
if (!to || !from) return to
|
||||
const k = (now - this.shapeAt) / BotEngine.SHAPE_MORPH
|
||||
if (k >= 1) return to
|
||||
const t = easings.easeOutQuint(clamp(k))
|
||||
// alloue seulement pendant le morph ; hors morph on rend le tableau tel quel
|
||||
return to.map((r, i) => lerp(from[i] ?? r, r, t))
|
||||
}
|
||||
|
||||
/**
|
||||
* Nouvelle cible de regard, `null` pour revenir a celui de l'etat.
|
||||
*
|
||||
* Elle repart de la valeur COURANTE, et non de la cible precedente comme
|
||||
* `setShape` : cette methode est appelee a chaque mouvement de pointeur, et
|
||||
* repartir de l'ancienne cible ferait reculer le regard d'un cran avant
|
||||
* chaque rattrapage — le suivi tremblerait au lieu de glisser.
|
||||
*
|
||||
* Meme contrat que `setShape` par ailleurs : l'etat externe entre par un
|
||||
* setter horodate, jamais par une variable lue pendant `sample`, sinon le
|
||||
* moteur cesse d'etre une fonction pure du temps.
|
||||
*/
|
||||
setLook(look: Look | null, now: number, morph = BotEngine.LOOK_MORPH) {
|
||||
/*
|
||||
* Une cible non finie est refusee. Le moteur GARDE la derniere : un `NaN`
|
||||
* pose une seule fois se propagerait a chaque image et le bot ne se
|
||||
* reposerait plus jamais. C'est arrive pour de vrai — un
|
||||
* `getBoundingClientRect` sur une boite de taille nulle donne `0 / 0` chez
|
||||
* l'appelant. Celui-la est corrige, mais le moteur n'a pas a dependre de la
|
||||
* prudence de ses appelants pour rester rejouable.
|
||||
*/
|
||||
if (look && !Number.isFinite(look.yaw + look.pitch + look.mix + look.spin + look.wander)) {
|
||||
return
|
||||
}
|
||||
this.lookPrev = this.lookAtTime(now)
|
||||
this.look = look ?? NO_LOOK
|
||||
this.lookAt = now
|
||||
this.lookMorph = morph
|
||||
}
|
||||
|
||||
/** Regard effectif a l'instant `now`, rattrapage en cours compris. */
|
||||
private lookAtTime(now: number): Look {
|
||||
const k = (now - this.lookAt) / this.lookMorph
|
||||
if (k >= 1) return this.look
|
||||
return lerpLook(this.lookPrev, this.look, easings.easeOutQuint(clamp(k)))
|
||||
}
|
||||
|
||||
private posed(
|
||||
def: StateDef,
|
||||
t: number,
|
||||
shape: number[] | null,
|
||||
expr: BotExpression | null
|
||||
): Pose {
|
||||
let pose = def.pose(t)
|
||||
if (def.baseBody && shape) {
|
||||
// on garde la pose (rotation, decalage, squash) et on n'echange que le profil
|
||||
pose = { ...pose, sil: { ...pose.sil, radii: shape } }
|
||||
}
|
||||
if (def.baseFace && expr) {
|
||||
pose = { ...pose, gaze: expr.gaze, split: expr.split, eyes: expr.eyes }
|
||||
}
|
||||
return pose
|
||||
}
|
||||
|
||||
/**
|
||||
* Decalage des yeux a l'instant `now` pour un etat donne, en unites de rayon de boule.
|
||||
*
|
||||
* Il est LU dans une table et interpole, jamais recalcule : `eyefit.ts` explique
|
||||
* pourquoi cette distinction est tout le correctif. Ici il ne reste qu'a l'interpoler
|
||||
* sur l'axe de la forme, avec exactement la courbe et la duree du morph de silhouette
|
||||
* — c'est la meme cause, donc ce doit etre le meme mouvement.
|
||||
*
|
||||
* On interroge la table sur les BORNES du morph (`shapePrev` et `shape`) et non sur le
|
||||
* profil que rend `shapeAtTime` : celui-la est un tableau neuf alloue a chaque image,
|
||||
* donc sans identite, et il n'existe dans aucune table.
|
||||
*/
|
||||
private decalageAtTime(now: number, state: StateId): { x: number; y: number } {
|
||||
/**
|
||||
* Un axe de morph : on lit la table sur ses deux BORNES et on interpole avec sa
|
||||
* courbe. Jamais sur la valeur interpolee — celle-la n'a pas d'identite et n'existe
|
||||
* dans aucune table, et c'est en la lui donnant a manger que les versions
|
||||
* precedentes tremblaient.
|
||||
*/
|
||||
const surAxe = (
|
||||
debut: number,
|
||||
duree: number,
|
||||
a: { x: number; y: number },
|
||||
b: { x: number; y: number }
|
||||
) => {
|
||||
if (a === b) return b
|
||||
const k = (now - debut) / duree
|
||||
if (k >= 1) return b
|
||||
const t = easings.easeOutQuint(clamp(k))
|
||||
return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) }
|
||||
}
|
||||
|
||||
// axe de l'expression, pour chacune des deux formes en presence
|
||||
const parForme = (radii: number[] | null) =>
|
||||
surAxe(
|
||||
this.exprAt,
|
||||
BotEngine.SHAPE_MORPH,
|
||||
decalageDesYeux(radii, state, this.exprPrev?.id ?? null),
|
||||
decalageDesYeux(radii, state, this.expr?.id ?? null)
|
||||
)
|
||||
|
||||
// puis axe de la forme
|
||||
return surAxe(
|
||||
this.shapeAt,
|
||||
BotEngine.SHAPE_MORPH,
|
||||
parForme(this.shapePrev),
|
||||
parForme(this.shape)
|
||||
)
|
||||
}
|
||||
|
||||
get state(): StateId {
|
||||
return this.cur
|
||||
}
|
||||
|
||||
/**
|
||||
* Repart sur `id` SANS etat precedent, comme un moteur neuf pose sur cet etat.
|
||||
*
|
||||
* C'est ce que veut dire « rembobiner » pour ce moteur. `setState` seul ne peut pas le
|
||||
* faire : il garde l'etat quitte pour le fondre, ce qui est exactement son role en
|
||||
* lecture, et exactement ce qu'il ne faut pas quand on revient au debut d'une sequence.
|
||||
* Rejouer l'image 0 apres une passe complete melangeait le premier etat avec le DERNIER,
|
||||
* et l'export GIF s'ouvrait sur une boule sans yeux — la comete a un `eyeAlpha` nul.
|
||||
*
|
||||
* `sample` reste une fonction pure du temps : comme `setState`, ceci est un setter DATE,
|
||||
* appele par le pilote de la sequence, jamais pendant un echantillonnage.
|
||||
*/
|
||||
reset(id: StateId, now: number) {
|
||||
this.cur = id
|
||||
this.prev = null
|
||||
this.departFige = null
|
||||
this.tCur = now
|
||||
this.tPrev = now
|
||||
this.blinkAt = -10
|
||||
}
|
||||
|
||||
/**
|
||||
* Origine du fondu en cours : la pose figee s'il y en a une, sinon l'etat quitte evalue
|
||||
* a son propre temps ecoule — donc encore en train de s'animer, ce qui est voulu.
|
||||
*/
|
||||
private origine(
|
||||
now: number,
|
||||
shape: number[] | null,
|
||||
expr: BotExpression | null
|
||||
): Pose | null {
|
||||
if (this.departFige) return this.departFige
|
||||
if (!this.prev) return null
|
||||
const prevDef = STATE_BY_ID.get(this.prev)!
|
||||
return this.posed(prevDef, Math.max(0, now - this.tPrev), shape, expr)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pose composite a l'instant `now`, fondu en cours compris : exactement ce que `sample`
|
||||
* melange, avant la couche de vie au repos et de regard. Extraite pour que `setState`
|
||||
* puisse la figer.
|
||||
*/
|
||||
private poseComposee(now: number): Pose {
|
||||
const def = STATE_BY_ID.get(this.cur)!
|
||||
const shape = this.shapeAtTime(now)
|
||||
const expr = this.exprAtTime(now)
|
||||
const pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr)
|
||||
const since = now - this.tCur
|
||||
if (since >= def.morph) return pose
|
||||
const origine = this.origine(now, shape, expr)
|
||||
if (!origine) return pose
|
||||
return blendPose(origine, pose, easings.easeOutQuint(clamp(since / def.morph)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Changement d'etat, date.
|
||||
*
|
||||
* Le moteur ne garde qu'UNE case d'historique, donc un changement qui arrive pendant un
|
||||
* fondu remplacait l'origine du melange par la pose PLEINE de l'etat qu'on quittait, au
|
||||
* lieu de l'image partiellement melangee qui etait a l'ecran. Mesure sur
|
||||
* `idle -> wide -> idle` a 100 ms : 35,9 px de saut contre 8,0 px de mouvement normal.
|
||||
*
|
||||
* On fige donc la pose composite courante et on melange depuis elle. Continu par
|
||||
* construction, quel que soit le nombre de changements enchaines.
|
||||
*
|
||||
* Et SEULEMENT dans ce cas. Figer a chaque changement arreterait net l'animation de
|
||||
* l'etat qu'on quitte pendant tout le fondu — le « ! » d'`alert` se figerait en pleine
|
||||
* course — alors qu'il n'y a rien a corriger hors morph : l'etat quitte y est deja
|
||||
* exactement l'image affichee. La lecture d'un montage, dont les blocs durent au moins
|
||||
* le plus long fondu (`MIN_BLOCK`), ne fige donc jamais rien et rend au bit ce qu'elle
|
||||
* rendait.
|
||||
*/
|
||||
setState(id: StateId, now: number) {
|
||||
if (id === this.cur) return
|
||||
const morph = STATE_BY_ID.get(this.cur)!.morph
|
||||
const enPleinFondu = this.prev !== null && now - this.tCur < morph
|
||||
this.departFige = enPleinFondu ? this.poseComposee(now) : null
|
||||
this.prev = this.cur
|
||||
this.tPrev = this.tCur
|
||||
this.cur = id
|
||||
this.tCur = now
|
||||
// Dans la video, chaque changement de forme est masque par un clignement.
|
||||
if (STATE_BY_ID.get(id)?.blinkIn) this.blinkAt = now
|
||||
}
|
||||
|
||||
sample(now: number): BotFrame {
|
||||
const R = this.scale
|
||||
const def = STATE_BY_ID.get(this.cur)!
|
||||
const shape = this.shapeAtTime(now)
|
||||
const expr = this.exprAtTime(now)
|
||||
let pose = this.posed(def, Math.max(0, now - this.tCur), shape, expr)
|
||||
let decalage = this.decalageAtTime(now, this.cur)
|
||||
|
||||
// --- transition -------------------------------------------------------
|
||||
const since = now - this.tCur
|
||||
// L'etat precedent n'est jamais purge : `since < def.morph` suffit a
|
||||
// l'ignorer une fois le fondu passe, et l'oublier rendrait le moteur non
|
||||
// rejouable — relire une date d'avant la fin du fondu ne le retrouverait
|
||||
// plus. C'est l'optimisation qui parait innocente et qui casse tout.
|
||||
const origine = since < def.morph ? this.origine(now, shape, expr) : null
|
||||
if (origine) {
|
||||
// Ease-out exponentiel : c'est la courbe mesuree sur la video. Le corps
|
||||
// n'a pas d'overshoot (seuls la pastille et l'ouverture des yeux en ont).
|
||||
// Le ratio est borne : relire une date ANTERIEURE au changement d'etat
|
||||
// donnerait un ratio negatif, que l'ease-out extrapole — la silhouette
|
||||
// part alors trente fois trop loin.
|
||||
const ratio = easings.easeOutQuint(clamp(since / def.morph))
|
||||
pose = blendPose(origine, pose, ratio)
|
||||
// Le decalage des yeux suit la MEME courbe que la silhouette qui le motive. Il vient
|
||||
// de l'etat quitte, que `setState` renseigne toujours en meme temps que l'origine —
|
||||
// le test est la pour le typage, pas pour un cas reel.
|
||||
const quitte = this.prev
|
||||
if (quitte) {
|
||||
const avant = this.decalageAtTime(now, quitte)
|
||||
decalage = {
|
||||
x: lerp(avant.x, decalage.x, ratio),
|
||||
y: lerp(avant.y, decalage.y, ratio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- vie au repos -----------------------------------------------------
|
||||
const alive = pose.eyeAlpha > 0.01
|
||||
const look = this.lookAtTime(now)
|
||||
const life = liveliness(now, { wander: alive ? look.wander : 0, blink: alive })
|
||||
|
||||
const gaze = {
|
||||
// Les deux visees REMPLACENT celles de la pose au lieu de s'y ajouter (voir
|
||||
// `Look`), et le tour se retranche en chemin. La derive s'ajoute APRES le
|
||||
// melange, sinon la cible l'annulerait en meme temps que la pose — or elle
|
||||
// doit survivre a une tete tournee sans pointeur.
|
||||
yaw: lerp(pose.gaze.yaw, look.yaw, look.mix) + life.dYaw - look.spin,
|
||||
pitch: lerp(pose.gaze.pitch, look.pitch, look.mix) + life.dPitch,
|
||||
// le roulis, lui, ne suit rien : la tete du bot est penchee de -13deg dans
|
||||
// la video, et la faire rouler avec le curseur casse cette signature
|
||||
roll: pose.gaze.roll + life.dRoll
|
||||
}
|
||||
|
||||
// clignement declenche par le changement d'etat, en plus du calendrier
|
||||
const forced = clamp((now - this.blinkAt) / 0.2)
|
||||
const forcedLid = forced < 1 ? Math.abs(forced * 2 - 1) : 1
|
||||
const lid = Math.min(life.lid, forcedLid)
|
||||
|
||||
const offX = pose.offX + life.driftX
|
||||
const offY = pose.offY + life.driftY
|
||||
|
||||
// --- corps ------------------------------------------------------------
|
||||
const sil: Silhouette = {
|
||||
...pose.sil,
|
||||
cx: pose.sil.cx + offX,
|
||||
cy: pose.sil.cy + offY,
|
||||
sy: pose.sil.sy * life.breath
|
||||
}
|
||||
const bodyPath = closedPath(toPoints(sil, R, this.pts))
|
||||
|
||||
// --- yeux -------------------------------------------------------------
|
||||
// Les yeux vivent sur une sphere de rayon 1 ; des que la silhouette n'est
|
||||
// plus un cercle, on les ramene au prorata du rayon reel dans leur
|
||||
// direction, sinon ils debordent et le masque les coupe.
|
||||
const bodyRadius = (x: number, y: number) =>
|
||||
radiusAtAngle(pose.sil.radii, Math.atan2(y, x) - pose.sil.rot)
|
||||
|
||||
const eyes: RenderedEye[] = []
|
||||
if (pose.eyeAlpha > 0.01) {
|
||||
const poses = eyePoses(gaze, R, pose.split)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const e = poses[i]!
|
||||
if (e.depth <= 0.02) continue
|
||||
const cfg = pose.eyes[i]!
|
||||
const fit = bodyRadius(e.x, e.y)
|
||||
// Inclinaison propre de l'oeil : on compose le repere tangent avec une
|
||||
// rotation dans le plan de l'oeil (Basis x Rot). C'est ce qui permet des
|
||||
// inclinaisons en miroir entre les deux yeux.
|
||||
const phi = ((cfg.tilt ?? 0) * Math.PI) / 180
|
||||
const cp = Math.cos(phi)
|
||||
const sp = Math.sin(phi)
|
||||
const ax = e.a * cp + e.c * sp
|
||||
const ay = e.b * cp + e.d * sp
|
||||
const cx2 = -e.a * sp + e.c * cp
|
||||
const cy2 = -e.b * sp + e.d * cp
|
||||
// Le clignement s'applique APRES tout ca : c'est un ecrasement vertical
|
||||
// a l'ecran, pas le long de l'axe de la gelule.
|
||||
const k = blinkScale(Math.min(lid, cfg.open))
|
||||
eyes.push({
|
||||
d: capsulePath(cfg.w * R, cfg.h * R),
|
||||
matrix: `matrix(${r2(ax)},${r2(ay * k)},${r2(cx2)},${r2(cy2 * k)},${r2(e.x * fit + (offX + decalage.x) * R)},${r2(e.y * fit + (offY + decalage.y) * R)})`,
|
||||
alpha: pose.eyeAlpha * clamp(e.depth / 0.12)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- decor ------------------------------------------------------------
|
||||
const dots = pose.dots
|
||||
.filter((p) => p.opacity > 0.01 && p.r > 0.0005)
|
||||
.map((p) => ({ ...p, x: (p.x + offX) * R, y: (p.y + offY) * R, r: p.r * R }))
|
||||
|
||||
// la pastille est posee sur le contour : elle suit donc la forme aussi
|
||||
const nFit = pose.notif ? bodyRadius(pose.notif.x, pose.notif.y) : 1
|
||||
const nx = pose.notif ? (pose.notif.x * nFit + offX) * R : 0
|
||||
const ny = pose.notif ? (pose.notif.y * nFit + offY) * R : 0
|
||||
const notif = pose.notif ? { x: nx, y: ny, r: pose.notif.r * R } : null
|
||||
const notch = pose.notif ? { x: nx, y: ny, r: pose.notif.notch * R } : null
|
||||
|
||||
return {
|
||||
bodyPath,
|
||||
bodyAlpha: pose.bodyAlpha,
|
||||
eyes,
|
||||
dots,
|
||||
dotsBehind: pose.dotsBehind,
|
||||
// Les etats declarent des arcs en unites de rayon de boule ; le moteur
|
||||
// est le seul a connaitre l'echelle du viewBox, donc c'est lui qui trace.
|
||||
arcs: pose.arcs
|
||||
.filter((a) => a.opacity > 0.01)
|
||||
.map((a) => arcRender(a.seed, a.t, R, a.id, a.opacity)),
|
||||
notif,
|
||||
notch
|
||||
}
|
||||
}
|
||||
}
|
||||
192
lib/bloub-official/expressions.ts
Normal file
192
lib/bloub-official/expressions.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face'
|
||||
import { lerp } from './math'
|
||||
import type { EyeCfg } from './states'
|
||||
|
||||
/**
|
||||
* Expression de repos du bot.
|
||||
*
|
||||
* Le visage ne tient qu'à deux gélules, donc tout se joue sur quatre leviers :
|
||||
* l'orientation de la tête, l'écart des yeux, leurs proportions, et
|
||||
* l'inclinaison propre de chaque œil. C'est ce dernier qui permet la colère et
|
||||
* la tristesse : elles demandent des inclinaisons EN MIROIR (les hauts qui
|
||||
* convergent ou divergent), impossible avec le seul roulis de tête qui incline
|
||||
* les deux yeux du même côté.
|
||||
*
|
||||
* Seul l'état de repos porte cette expression. Les états expressifs de la vidéo
|
||||
* (clin d'œil, yeux écarquillés, notification) gardent la leur : c'est elle
|
||||
* qu'on est venu reproduire.
|
||||
*
|
||||
* Les amplitudes s'appuient sur bible-strong-avatar-lab, qui expose le même
|
||||
* modèle (tête X/Y/Z, largeur et hauteur par œil, écart, angle par œil) : chez
|
||||
* eux la largeur va de 0,8 à 2,7 fois le neutre, la hauteur de 0,3 à 1,5, et
|
||||
* les angles jusqu'à ±80°. On reste dans cette enveloppe.
|
||||
*/
|
||||
/** Enumeres pour que la couche i18n verifie leurs traductions a la compilation. */
|
||||
export type ExpressionId =
|
||||
| 'neutre'
|
||||
| 'attentif'
|
||||
| 'surpris'
|
||||
| 'excite'
|
||||
| 'heureux'
|
||||
| 'hilare'
|
||||
| 'colere'
|
||||
| 'triste'
|
||||
| 'effraye'
|
||||
| 'mefiant'
|
||||
| 'confus'
|
||||
| 'curieux'
|
||||
| 'fier'
|
||||
| 'timide'
|
||||
| 'blase'
|
||||
| 'somnolent'
|
||||
|
||||
export interface BotExpression {
|
||||
id: ExpressionId
|
||||
gaze: HeadGaze
|
||||
split: number
|
||||
eyes: [EyeCfg, EyeCfg]
|
||||
}
|
||||
|
||||
/** `tilt` en degrés, positif = le haut de la gélule part vers la droite. */
|
||||
const eye = (w: number, h: number, tilt = 0, open = 1): EyeCfg => ({ w, h, tilt, open })
|
||||
|
||||
/** Les deux yeux identiques, inclinaisons en miroir si `tilt` est fourni. */
|
||||
const pair = (w: number, h: number, tilt = 0, open = 1): [EyeCfg, EyeCfg] => [
|
||||
eye(w, h, tilt, open),
|
||||
eye(w, h, -tilt, open)
|
||||
]
|
||||
|
||||
export const EXPRESSIONS: BotExpression[] = [
|
||||
{
|
||||
// la pose relevée image par image sur la vidéo de référence
|
||||
id: 'neutre',
|
||||
gaze: { ...REST_GAZE },
|
||||
split: EYE_SPLIT,
|
||||
eyes: [eye(EYE_W, EYE_H), eye(EYE_W, EYE_H)]
|
||||
},
|
||||
{
|
||||
id: 'attentif',
|
||||
gaze: { yaw: 4, pitch: 5, roll: -4 },
|
||||
split: 16,
|
||||
eyes: pair(0.21, 0.44)
|
||||
},
|
||||
{
|
||||
id: 'surpris',
|
||||
gaze: { yaw: 3, pitch: -3, roll: 0 },
|
||||
split: 19,
|
||||
eyes: pair(0.45, 0.47)
|
||||
},
|
||||
{
|
||||
id: 'excite',
|
||||
gaze: { yaw: 6, pitch: -14, roll: 0 },
|
||||
split: 19.5,
|
||||
eyes: pair(0.4, 0.56, -10)
|
||||
},
|
||||
{
|
||||
// yeux plissés en arc : les hauts convergent légèrement
|
||||
id: 'heureux',
|
||||
gaze: { yaw: 5, pitch: 9, roll: 0 },
|
||||
split: 17,
|
||||
eyes: pair(0.27, 0.17, 14)
|
||||
},
|
||||
{
|
||||
id: 'hilare',
|
||||
gaze: { yaw: 4, pitch: 14, roll: 0 },
|
||||
split: 18,
|
||||
eyes: pair(0.34, 0.13, 20)
|
||||
},
|
||||
{
|
||||
// hauts des yeux qui convergent fort vers le centre + yeux étrécis
|
||||
id: 'colere',
|
||||
gaze: { yaw: 3, pitch: 7, roll: 0 },
|
||||
split: 17,
|
||||
eyes: pair(0.34, 0.15, 30)
|
||||
},
|
||||
{
|
||||
// l'inverse : les hauts divergent, et le regard tombe
|
||||
id: 'triste',
|
||||
gaze: { yaw: 3, pitch: -13, roll: 0 },
|
||||
split: 16,
|
||||
eyes: pair(0.22, 0.4, -28)
|
||||
},
|
||||
{
|
||||
id: 'effraye',
|
||||
gaze: { yaw: 2, pitch: -20, roll: 0 },
|
||||
split: 20.5,
|
||||
eyes: pair(0.4, 0.6)
|
||||
},
|
||||
{
|
||||
// un œil franchement plus fermé que l'autre
|
||||
id: 'mefiant',
|
||||
gaze: { yaw: 12, pitch: 6, roll: -6 },
|
||||
split: 16,
|
||||
eyes: [eye(0.21, 0.4), eye(0.22, 0.15)]
|
||||
},
|
||||
{
|
||||
// asymétrique sur les deux axes : tailles ET inclinaisons dépareillées.
|
||||
// L'œil plissé est volontairement plat (rapport 1,6) : à un rapport proche
|
||||
// de 1 il serait rond, et son inclinaison ne se verrait pas.
|
||||
id: 'confus',
|
||||
gaze: { yaw: -14, pitch: 3, roll: 8 },
|
||||
split: 16.5,
|
||||
eyes: [eye(0.2, 0.44, -18), eye(0.28, 0.17, 14)]
|
||||
},
|
||||
{
|
||||
// la tête penche : c'est le roulis qui porte la curiosité
|
||||
id: 'curieux',
|
||||
gaze: { yaw: 16, pitch: -9, roll: -15 },
|
||||
split: 16.5,
|
||||
eyes: [eye(0.24, 0.46, -8), eye(0.2, 0.38, -8)]
|
||||
},
|
||||
{
|
||||
id: 'fier',
|
||||
gaze: { yaw: 5, pitch: 17, roll: 0 },
|
||||
split: 17,
|
||||
eyes: pair(0.3, 0.15, 18)
|
||||
},
|
||||
{
|
||||
id: 'timide',
|
||||
gaze: { yaw: -19, pitch: -14, roll: -7 },
|
||||
split: 14,
|
||||
eyes: pair(0.17, 0.3)
|
||||
},
|
||||
{
|
||||
// fentes horizontales et regard qui part sur le côté
|
||||
id: 'blase',
|
||||
gaze: { yaw: -22, pitch: 2, roll: 0 },
|
||||
split: 16,
|
||||
eyes: pair(0.3, 0.12)
|
||||
},
|
||||
{
|
||||
// paupières à moitié tombées : on passe par `open`, donc l'écrasement
|
||||
// vertical à l'écran, le même mécanisme que le clignement
|
||||
id: 'somnolent',
|
||||
gaze: { yaw: 6, pitch: -9, roll: -3 },
|
||||
split: 16,
|
||||
eyes: pair(0.2, 0.42, 0, 0.42)
|
||||
}
|
||||
]
|
||||
|
||||
export const EXPRESSION_BY_ID = new Map<string, BotExpression>(EXPRESSIONS.map((e) => [e.id, e]))
|
||||
export const DEFAULT_EXPRESSION = 'neutre'
|
||||
|
||||
const lerpEyeCfg = (a: EyeCfg, b: EyeCfg, t: number): EyeCfg => ({
|
||||
w: lerp(a.w, b.w, t),
|
||||
h: lerp(a.h, b.h, t),
|
||||
tilt: lerp(a.tilt ?? 0, b.tilt ?? 0, t),
|
||||
open: lerp(a.open, b.open, t)
|
||||
})
|
||||
|
||||
/** Interpolation de deux expressions : le changement se fait en glissant. */
|
||||
export function blendExpression(a: BotExpression, b: BotExpression, t: number): BotExpression {
|
||||
return {
|
||||
id: b.id,
|
||||
gaze: {
|
||||
yaw: lerp(a.gaze.yaw, b.gaze.yaw, t),
|
||||
pitch: lerp(a.gaze.pitch, b.gaze.pitch, t),
|
||||
roll: lerp(a.gaze.roll, b.gaze.roll, t)
|
||||
},
|
||||
split: lerp(a.split, b.split, t),
|
||||
eyes: [lerpEyeCfg(a.eyes[0], b.eyes[0], t), lerpEyeCfg(a.eyes[1], b.eyes[1], t)]
|
||||
}
|
||||
}
|
||||
455
lib/bloub-official/eyefit.ts
Normal file
455
lib/bloub-official/eyefit.ts
Normal file
@ -0,0 +1,455 @@
|
||||
/**
|
||||
* Ou poser le visage sur une forme du personnalisateur.
|
||||
*
|
||||
* Les yeux vivent sur une sphere, et `radiusAtAngle` les recolle au contour reel au
|
||||
* prorata du rayon local. Ce prorata place bien leur CENTRE, mais l'oeil a une taille :
|
||||
* la marge qui lui reste devant le bord est multipliee par le meme facteur, donc une
|
||||
* silhouette etroite dans sa direction le pousse contre le bord jusqu'a ce que le
|
||||
* masque l'ouvre vers l'exterieur. La gelule apparaissait comme une encoche dans le
|
||||
* corps sur `capsule`, `triangle`, `nuage` et `goutte`.
|
||||
*
|
||||
* Ce module resout le probleme UNE FOIS, au chargement, et rend une table de decalages.
|
||||
* Ce choix est l'essentiel du correctif, bien plus que la geometrie qui suit :
|
||||
*
|
||||
* Resolue dans la boucle de rendu, la correction reagit a tout ce qui bouge a soixante
|
||||
* images par seconde — la derive du regard, le pointeur, l'expression en cours de
|
||||
* morph, le bord le plus proche qui change, l'oeil le plus contraint qui change. Sept
|
||||
* variantes ont ete ecrites ainsi et toutes produisaient un artefact de mouvement
|
||||
* visible : tremblement permanent, saut de direction de 26 unites quand le bord de
|
||||
* reference basculait, grossissement brusque quand la taille entrait dans le calcul.
|
||||
* Le defaut n'etait dans aucune de leurs geometries, il etait dans le fait de resoudre
|
||||
* par image.
|
||||
*
|
||||
* Le reste du moteur ne travaille pas comme ca : les poses sont DECLAREES et il ne fait
|
||||
* que les interpoler avec des courbes connues. Un decalage tabule rentre dans ce moule.
|
||||
* Il ne bouge pas quand le regard derive ni quand le pointeur bouge, et sur un changement
|
||||
* de forme ou d'expression il ne fait qu'aller d'une entree de table a l'autre, sur la
|
||||
* courbe de ce morph. Le tremblement devient impossible par construction, au lieu d'etre
|
||||
* repousse : interpoler entre deux constantes est monotone, alors que re-resoudre le
|
||||
* probleme sur un regard en cours d'interpolation ne l'est pas.
|
||||
*
|
||||
* Corollaire agreable : le solveur n'a plus aucune contrainte de continuite, puisqu'il ne
|
||||
* tourne pas pendant l'animation. Il peut donc sonder tout un faisceau de directions et
|
||||
* couvrir le pire cas de la derive du regard, ce qu'une version par image ne pouvait pas
|
||||
* se permettre.
|
||||
*
|
||||
* La table est une constante de module, batie a l'import a partir de donnees pures :
|
||||
* meme nature que le calendrier de clignements de `face.ts`, deterministe et sans etat,
|
||||
* donc sans effet sur la purete de `engine.sample(t)`.
|
||||
*/
|
||||
|
||||
import { EXPRESSIONS, type BotExpression } from './expressions'
|
||||
import { eyePoses } from './face'
|
||||
import { radiusAtAngle, toPoints, type Point } from './shape'
|
||||
import { SHAPES } from './skins'
|
||||
import { STATES, type Pose, type StateDef, type StateId } from './states'
|
||||
|
||||
/** Rayon de reference du solveur. Le decalage rendu est en unites de ce rayon. */
|
||||
const R = 100
|
||||
|
||||
/**
|
||||
* Amplitudes maximales de la vie au repos, lues sur `liveliness` : `loopNoise` est
|
||||
* borne a 1 en valeur absolue, donc ces sommes sont des bornes exactes et non des
|
||||
* estimations.
|
||||
*
|
||||
* Il faut les couvrir, sinon la correction est juste sur la pose nominale et fausse une
|
||||
* seconde plus tard : 7 degres de lacet deplacent l'oeil d'une douzaine d'unites sur une
|
||||
* boule de rayon 100. C'est precisement ce qui faisait deborder `capsule` + `effraye`
|
||||
* alors qu'une mesure a un seul instant le declarait bon.
|
||||
*/
|
||||
const DERIVE_YAW = 5.5 + 1.6
|
||||
const DERIVE_PITCH = 4.2 + 1.3
|
||||
/** Flottement du centre, en unites de rayon de boule. */
|
||||
const DERIVE_X = 0.006
|
||||
const DERIVE_Y = 0.007
|
||||
|
||||
/** Le visage d'une pose, ce dont le solveur a besoin pour placer ses gelules. */
|
||||
interface Visage {
|
||||
gaze: Pose['gaze']
|
||||
split: number
|
||||
eyes: Pose['eyes']
|
||||
}
|
||||
|
||||
/**
|
||||
* Une gelule prete a etre mesuree : le segment de son axe, et de quoi calculer le rayon
|
||||
* a degager DANS UNE DIRECTION donnee.
|
||||
*
|
||||
* Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par la
|
||||
* matrice tangente est donc un segment epaissi d'une ELLIPSE, et le rayon a degager
|
||||
* depend de la direction : c'est la fonction d'appui de cette ellipse, `r * |A^T u|`.
|
||||
*
|
||||
* Prendre a la place sa plus grande valeur singuliere serait conservateur mais faux dans
|
||||
* la seule direction qui compte, et ca se paie cher : la marge de reference sur le cercle
|
||||
* ressortait NEGATIVE, donc la demande devenait sans dents et 34 combinaisons
|
||||
* continuaient de deborder.
|
||||
*/
|
||||
interface Empreinte {
|
||||
/** centre, en unites de viewBox */
|
||||
x: number
|
||||
y: number
|
||||
/** demi-vecteur de l'axe */
|
||||
ax: number
|
||||
ay: number
|
||||
/** rayon du disque local, avant transformation */
|
||||
r: number
|
||||
/** colonnes de la matrice tangente, pour la fonction d'appui */
|
||||
m: [number, number, number, number]
|
||||
}
|
||||
|
||||
/**
|
||||
* Empreintes des deux yeux d'un visage, posees sur un profil.
|
||||
*
|
||||
* Une gelule est exactement un segment epaissi d'un disque de rayon `r`. Son image par
|
||||
* la matrice tangente est donc un segment epaissi d'une ELLIPSE, et un disque du rayon
|
||||
* de son grand axe la couvre : d'ou la plus grande valeur singuliere. La mesure reste
|
||||
* ainsi conservatrice au sens strict, une marge positive garantissant que la gelule est
|
||||
* dedans.
|
||||
*
|
||||
* Le clignement n'y est pas : un oeil ferme n'a pas besoin qu'on lui fasse de la place.
|
||||
*/
|
||||
function empreintes(visage: Visage, sil: Pose['sil'], radii: number[]): Empreinte[] {
|
||||
const out: Empreinte[] = []
|
||||
const poses = eyePoses(visage.gaze, R, visage.split)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const e = poses[i]!
|
||||
if (e.depth <= 0.02) continue
|
||||
const cfg = visage.eyes[i]!
|
||||
const phi = ((cfg.tilt ?? 0) * Math.PI) / 180
|
||||
const cp = Math.cos(phi)
|
||||
const sp = Math.sin(phi)
|
||||
const ax = e.a * cp + e.c * sp
|
||||
const ay = e.b * cp + e.d * sp
|
||||
const cx = -e.a * sp + e.c * cp
|
||||
const cy = -e.b * sp + e.d * cp
|
||||
|
||||
const hw = Math.max(cfg.w * R, 0.01) / 2
|
||||
const hh = Math.max(cfg.h * R, 0.01) / 2
|
||||
const r = Math.min(hw, hh)
|
||||
// l'axe est celui de la plus grande dimension
|
||||
const long = hh > hw
|
||||
const demi = long ? hh - r : hw - r
|
||||
// le prorata du rayon local, exactement comme le fait le moteur
|
||||
const fit = radiusAtAngle(radii, Math.atan2(e.y, e.x) - sil.rot)
|
||||
out.push({
|
||||
x: e.x * fit,
|
||||
y: e.y * fit,
|
||||
ax: (long ? cx : ax) * demi,
|
||||
ay: (long ? cy : ay) * demi,
|
||||
r,
|
||||
m: [ax, ay, cx, cy]
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Approche la plus courte entre un contour et un segment : la distance, et le vecteur
|
||||
* qui va du contour vers le segment — le sens qui degage.
|
||||
*
|
||||
* Les deux sortent de la MEME passe. Les calculer separement doublait le seul vrai cout
|
||||
* de ce module, qui est ce balayage.
|
||||
*/
|
||||
function approche(pts: Point[], x0: number, y0: number, x1: number, y1: number) {
|
||||
const sx = x1 - x0
|
||||
const sy = y1 - y0
|
||||
const len2 = sx * sx + sy * sy
|
||||
let best = Infinity
|
||||
let vx = 0
|
||||
let vy = 0
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i]!
|
||||
let t = len2 > 0 ? ((p.x - x0) * sx + (p.y - y0) * sy) / len2 : 0
|
||||
t = t < 0 ? 0 : t > 1 ? 1 : t
|
||||
const ex = x0 + t * sx - p.x
|
||||
const ey = y0 + t * sy - p.y
|
||||
const d2 = ex * ex + ey * ey
|
||||
if (d2 < best) {
|
||||
best = d2
|
||||
vx = ex
|
||||
vy = ey
|
||||
}
|
||||
}
|
||||
const d = Math.sqrt(best)
|
||||
return { d, ux: d > 1e-9 ? vx / d : 0, uy: d > 1e-9 ? vy / d : 0 }
|
||||
}
|
||||
|
||||
/** Une epreuve : des gelules a faire tenir dans un contour, et le contour de reference. */
|
||||
interface Epreuve {
|
||||
empreintes: Empreinte[]
|
||||
reference: Empreinte[]
|
||||
contour: Point[]
|
||||
calContour: Point[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Flottement du centre au repos, en unites de viewBox. Il est ajoute au rayon de la
|
||||
* gelule : moins d'une unite, donc l'absorber ainsi coute moins cher que de multiplier les
|
||||
* epreuves par ses quatre coins.
|
||||
*/
|
||||
const FLOTTEMENT = Math.hypot(DERIVE_X, DERIVE_Y) * R
|
||||
|
||||
/** Marge de la gelule la plus serree, et le sens qui la degage. */
|
||||
function pire(pts: Point[], emps: Empreinte[], tx: number, ty: number) {
|
||||
let marge = Infinity
|
||||
let ux = 0
|
||||
let uy = 0
|
||||
for (const e of emps) {
|
||||
const x = e.x + tx
|
||||
const y = e.y + ty
|
||||
const a = approche(pts, x - e.ax, y - e.ay, x + e.ax, y + e.ay)
|
||||
// fonction d'appui de l'ellipse dans la direction de l'approche
|
||||
const [m0, m1, m2, m3] = e.m
|
||||
const rayon =
|
||||
e.r * Math.hypot(m0 * a.ux + m1 * a.uy, m2 * a.ux + m3 * a.uy) + FLOTTEMENT
|
||||
if (a.d - rayon < marge) {
|
||||
marge = a.d - rayon
|
||||
ux = a.ux
|
||||
uy = a.uy
|
||||
}
|
||||
}
|
||||
return { marge, ux, uy }
|
||||
}
|
||||
|
||||
/**
|
||||
* Directions sondees et pas de la dichotomie. Le produit des deux est le cout de
|
||||
* construction de la table, seul chiffre a surveiller ici.
|
||||
*/
|
||||
const DIRECTIONS = 12
|
||||
const DICHOTOMIE = 8
|
||||
|
||||
/**
|
||||
* Le decalage a poser sur les deux yeux pour cette forme, cet etat et cette expression.
|
||||
*
|
||||
* Une TRANSLATION commune aux deux yeux, donc une isometrie : ecart entre les yeux,
|
||||
* tailles et inclinaisons sont conserves au pixel. Le visage est seulement pose un peu
|
||||
* plus bas sur un corps qui n'a pas de place en haut, ce qui est le geste qu'on ferait a
|
||||
* la main. Les variantes qui bornaient chaque oeil separement ecartaient la paire, et
|
||||
* celles qui mettaient le visage a l'echelle rapetissaient les yeux — visiblement.
|
||||
*
|
||||
* La marge visee est celle du profil D'ORIGINE, pas un degagement strict : sur le cercle
|
||||
* l'oeil exterieur frole deja le bord, 17,3 unites pour une boule de rayon 100, et c'est
|
||||
* voulu, c'est ce qui donne le volume. Elle est plafonnee par ce que la forme offre en son
|
||||
* centre, sinon la demande est intenable sur un corps plat.
|
||||
*
|
||||
* RECHERCHE DIRECTIONNELLE et non descente. On cherche la translation de plus petite
|
||||
* norme qui tient, donc on sonde une couronne de directions et on dichotomie la distance
|
||||
* le long de chacune. Une descente de gradient a ete ecrite d'abord et elle ne converge
|
||||
* pas : degager la paire d'un bord la rapproche de l'autre, si bien qu'elle tatonne et ne
|
||||
* fait que garder son meilleur essai — passer ses tours de 40 a 18 suffisait a faire
|
||||
* reapparaitre 34 debordements. Ici le resultat ne depend pas d'une convergence : chaque
|
||||
* direction est resolue exactement, au pas de dichotomie pres.
|
||||
*/
|
||||
function resous(epreuves: Epreuve[]): { x: number; y: number } {
|
||||
if (!epreuves.length) return { x: 0, y: 0 }
|
||||
|
||||
/** La marge la plus serree sur toutes les epreuves, pour une translation donnee. */
|
||||
const marge = (tx: number, ty: number) => {
|
||||
let m = Infinity
|
||||
for (const ep of epreuves) m = Math.min(m, pire(ep.contour, ep.empreintes, tx, ty).marge)
|
||||
return m
|
||||
}
|
||||
|
||||
// Marge exigee : la plus serree que le profil d'origine tolere, sur toutes les
|
||||
// epreuves. Puis plafonnee par le plus degage que la forme puisse offrir a la paire,
|
||||
// son centre.
|
||||
let requis = Infinity
|
||||
for (const ep of epreuves) {
|
||||
requis = Math.min(requis, pire(ep.calContour, ep.reference, 0, 0).marge)
|
||||
}
|
||||
/*
|
||||
* La course doit pouvoir atteindre le centre du corps : `wide` a des gelules de 87
|
||||
* unites de long, et sur un triangle elles ne tiennent que vers le milieu, a une
|
||||
* cinquantaine d'unites de leur place nominale. Une course fixe les laissait dehors.
|
||||
*/
|
||||
let mx = 0
|
||||
let my = 0
|
||||
const emps = epreuves[0]!.empreintes
|
||||
for (const e of emps) {
|
||||
mx -= e.x / emps.length
|
||||
my -= e.y / emps.length
|
||||
}
|
||||
const course = Math.max(0.35 * R, Math.hypot(mx, my) * 1.25)
|
||||
|
||||
// Plafond de la demande : ce que la forme offre en son centre, toujours atteignable.
|
||||
requis = Math.min(requis, marge(mx, my))
|
||||
|
||||
/*
|
||||
* Deja bon : le cas du cercle, et de toute forme assez large. La gelule doit RENTRER
|
||||
* en plus de n'etre pas plus serree que sur le profil d'origine — sans cette seconde
|
||||
* condition, une forme ou rien ne rentre satisfait la premiere de facon degeneree et
|
||||
* on abandonnait. `wide` a des gelules de 87 unites de long, `notify` de 50 de
|
||||
* diametre : sur un triangle ou une goutte elles debordent quoi qu'on fasse, et il
|
||||
* faut alors viser le moins pire, pas renoncer.
|
||||
*/
|
||||
const depart = marge(0, 0)
|
||||
if (depart >= requis && depart >= 0) return { x: 0, y: 0 }
|
||||
const cible = Math.max(requis, 0)
|
||||
|
||||
let meilleurX = 0
|
||||
let meilleurY = 0
|
||||
let meilleureNorme = Infinity
|
||||
// repli quand rien ne rentre : la translation qui degage le plus, sondee au passage
|
||||
let secoursX = 0
|
||||
let secoursY = 0
|
||||
let secours = depart
|
||||
|
||||
for (let d = 0; d < DIRECTIONS; d++) {
|
||||
const a = (d / DIRECTIONS) * Math.PI * 2
|
||||
const ux = Math.cos(a)
|
||||
const uy = Math.sin(a)
|
||||
if (marge(ux * course, uy * course) < cible) {
|
||||
// cette direction ne mene nulle part ; on garde quand meme le meilleur degagement
|
||||
// pas de solution par la, mais peut-etre un meilleur degagement
|
||||
for (const k of [0.3, 0.6, 1]) {
|
||||
const m = marge(ux * course * k, uy * course * k)
|
||||
if (m > secours) {
|
||||
secours = m
|
||||
secoursX = ux * course * k
|
||||
secoursY = uy * course * k
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// la plus courte distance qui tient, le long de cette direction
|
||||
let bas = 0
|
||||
let haut = course
|
||||
for (let i = 0; i < DICHOTOMIE; i++) {
|
||||
const mid = (bas + haut) / 2
|
||||
if (marge(ux * mid, uy * mid) >= cible) haut = mid
|
||||
else bas = mid
|
||||
}
|
||||
if (haut < meilleureNorme) {
|
||||
meilleureNorme = haut
|
||||
meilleurX = ux * haut
|
||||
meilleurY = uy * haut
|
||||
}
|
||||
}
|
||||
|
||||
const x = meilleureNorme === Infinity ? secoursX : meilleurX
|
||||
const y = meilleureNorme === Infinity ? secoursY : meilleurY
|
||||
// rendu en unites de RAYON DE BOULE : le moteur le remet a son echelle
|
||||
return { x: +(x / R).toFixed(6), y: +(y / R).toFixed(6) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Le visage a couvrir : celui de l'expression si l'etat l'accepte, le sien sinon.
|
||||
*
|
||||
* UNE entree de table par expression, et non un pire cas commun a toutes. Un pire cas
|
||||
* paraissait plus sur — un decalage constant ne peut pas bouger quand l'expression change
|
||||
* — mais il est intenable : sur une capsule, `neutre` a les yeux hauts et demande a
|
||||
* descendre quand `effraye` les a bas et demande a monter. Aucune translation unique ne
|
||||
* satisfait les deux, et la mesure le confirme (4 debordements de 4,8 unites).
|
||||
*
|
||||
* Une entree par expression n'est pas moins fluide pour autant : le moteur interpole
|
||||
* entre DEUX CONSTANTES, ce qui est monotone par construction. Ce qui tremblait, c'etait
|
||||
* de re-resoudre le probleme sur un regard en cours d'interpolation.
|
||||
*/
|
||||
function visageDe(def: StateDef, pose: Pose, expr: BotExpression | null): Visage {
|
||||
if (def.baseFace && expr) return { gaze: expr.gaze, split: expr.split, eyes: expr.eyes }
|
||||
return { gaze: pose.gaze, split: pose.split, eyes: pose.eyes }
|
||||
}
|
||||
|
||||
/** Les dates a echantillonner dans un etat : une seule si sa pose ne bouge pas. */
|
||||
function dates(def: StateDef): number[] {
|
||||
/** Tout ce dont le solveur se sert : si rien ne bouge, une date suffit. */
|
||||
const signature = (p: Pose) =>
|
||||
JSON.stringify([p.gaze, p.split, p.eyes, p.sil.rot, p.sil.cx, p.sil.cy, p.sil.sx, p.sil.sy])
|
||||
if (signature(def.pose(0)) === signature(def.pose(def.duration))) return [0]
|
||||
const n = 3
|
||||
return Array.from({ length: n }, (_, i) => (i / (n - 1)) * def.duration)
|
||||
}
|
||||
|
||||
/** Le decalage d'une forme sur un etat et une expression, derive comprise. */
|
||||
function decalagePour(
|
||||
def: StateDef,
|
||||
radii: number[],
|
||||
expr: BotExpression | null
|
||||
): { x: number; y: number } {
|
||||
const epreuves: Epreuve[] = []
|
||||
for (const t of dates(def)) {
|
||||
const pose = def.pose(t)
|
||||
const contour = toPoints({ ...pose.sil, radii }, R)
|
||||
const calContour = toPoints(pose.sil, R)
|
||||
const v = visageDe(def, pose, expr)
|
||||
// Les quatre coins de la derive bornent la pose nominale, qui est leur centre : la
|
||||
// tester en plus ne changerait aucune marge et coute une epreuve sur cinq.
|
||||
const coins: Visage[] = []
|
||||
for (const dy of [-DERIVE_YAW, DERIVE_YAW]) {
|
||||
for (const dp of [-DERIVE_PITCH, DERIVE_PITCH]) {
|
||||
coins.push({
|
||||
...v,
|
||||
gaze: { yaw: v.gaze.yaw + dy, pitch: v.gaze.pitch + dp, roll: v.gaze.roll }
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const c of coins) {
|
||||
epreuves.push({
|
||||
empreintes: empreintes(c, pose.sil, radii),
|
||||
reference: empreintes(c, pose.sil, pose.sil.radii),
|
||||
contour,
|
||||
calContour
|
||||
})
|
||||
}
|
||||
}
|
||||
return resous(epreuves)
|
||||
}
|
||||
|
||||
/** Zero, la valeur commune a tout ce qui n'a rien a corriger. */
|
||||
const NUL = { x: 0, y: 0 } as const
|
||||
|
||||
/** Clef d'une entree : l'etat, et l'expression quand l'etat l'accepte. */
|
||||
const clef = (state: StateId, expr: string | null) => `${state}|${expr ?? ''}`
|
||||
|
||||
/**
|
||||
* Table des decalages, batie a l'import : une entree par (forme, etat a corps de base,
|
||||
* expression). Seuls `idle` et `swirl` portent le visage de repos, donc seuls eux se
|
||||
* declinent par expression — les trois autres etats a corps de base ont un visage releve
|
||||
* sur la video et une seule entree.
|
||||
*
|
||||
* Clef par REFERENCE du tableau de rayons, ce qui est deja la convention du moteur : ses
|
||||
* gardes `radii === this.shape` et `expression === this.expr` reposent sur la meme
|
||||
* stabilite. Un profil inconnu, ou `null`, ne corrige rien — l'API accepte n'importe quel
|
||||
* tableau et le moteur n'a pas a dependre de la prudence de ses appelants.
|
||||
*/
|
||||
function batir(): Map<number[], Map<string, { x: number; y: number }>> {
|
||||
return new Map(
|
||||
SHAPES.map((forme) => {
|
||||
const par = new Map<string, { x: number; y: number }>()
|
||||
for (const def of STATES) {
|
||||
if (!def.baseBody) continue
|
||||
const expressions = def.baseFace ? [null, ...EXPRESSIONS] : [null]
|
||||
for (const expr of expressions) {
|
||||
par.set(clef(def.id, expr?.id ?? null), decalagePour(def, forme.radii, expr))
|
||||
}
|
||||
}
|
||||
return [forme.radii, par]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const DECALAGES = batir()
|
||||
|
||||
/**
|
||||
* Decalage a appliquer aux deux yeux pour cette forme sur cet etat, en unites de rayon
|
||||
* de boule — le moteur le remet a son echelle.
|
||||
*
|
||||
* Vaut zero des que la forme n'est pas au catalogue, ce qui couvre `null` et le cercle :
|
||||
* sur le cercle les deux profils sont le meme, donc la marge est deja celle exigee et la
|
||||
* descente sort au premier tour. La forme relevee sur la video ne bouge donc pas, sans
|
||||
* cas particulier.
|
||||
*/
|
||||
export function decalageDesYeux(
|
||||
radii: number[] | null,
|
||||
state: StateId,
|
||||
expr: string | null
|
||||
): { x: number; y: number } {
|
||||
if (!radii) return NUL
|
||||
const par = DECALAGES.get(radii)
|
||||
if (!par) return NUL
|
||||
// un etat sans visage de repos n'a qu'une entree, quelle que soit l'expression
|
||||
return par.get(clef(state, expr)) ?? par.get(clef(state, null)) ?? NUL
|
||||
}
|
||||
|
||||
/** Pour les tests : de quoi verifier la table sans refaire la geometrie. */
|
||||
/** Pour les tests : de quoi chronometrer la construction de la table. */
|
||||
export const POUR_TESTS = { batir }
|
||||
179
lib/bloub-official/face.ts
Normal file
179
lib/bloub-official/face.ts
Normal file
@ -0,0 +1,179 @@
|
||||
import { clamp, createRng, loopNoise } from './math'
|
||||
|
||||
/**
|
||||
* Les yeux sont peints sur une sphere, pas poses a plat.
|
||||
*
|
||||
* Mesure sur la video : l'oeil le plus proche du bord fait 0.69 fois la largeur
|
||||
* de l'autre, et son aire 0.663 fois — exactement le facteur de profondeur
|
||||
* (z = 0.669) d'un point de sphere a cette distance du centre. On modelise donc
|
||||
* une vraie orientation de tete : chaque oeil recupere le repere tangent de la
|
||||
* sphere, projete en orthographique. La compression et l'inclinaison en
|
||||
* decoulent toutes seules, c'est ce qui donne le volume.
|
||||
*
|
||||
* Les constantes ci-dessous ne sont pas choisies a la main : elles sortent d'un
|
||||
* ajustement du modele sur les positions et tailles relevees image par image
|
||||
* (erreur residuelle ~1 px sur un rayon de 190 px).
|
||||
*/
|
||||
|
||||
type Vec3 = [number, number, number]
|
||||
|
||||
/** Demi-ecart des yeux sur la sphere, en degres (separation totale ~31deg). */
|
||||
export const EYE_SPLIT = 15.46
|
||||
/** Taille de l'oeil au repos, en unites de rayon de boule. */
|
||||
export const EYE_W = 0.186
|
||||
export const EYE_H = 0.412
|
||||
|
||||
/** Orientation de tete au repos, ajustee sur les frames de reference. */
|
||||
export const REST_GAZE: HeadGaze = { yaw: 28.49, pitch: 28.62, roll: -13 }
|
||||
|
||||
export interface EyePose {
|
||||
x: number
|
||||
y: number
|
||||
/** matrice tangente 2x2 : [a b c d] au sens SVG matrix(a,b,c,d,e,f) */
|
||||
a: number
|
||||
b: number
|
||||
c: number
|
||||
d: number
|
||||
/** composante z de la normale : > 0 = face visible */
|
||||
depth: number
|
||||
}
|
||||
|
||||
export interface HeadGaze {
|
||||
/** lacet, degres, positif = regarde a droite */
|
||||
yaw: number
|
||||
/** tangage, degres, positif = regarde en haut */
|
||||
pitch: number
|
||||
/** roulis, degres, inclinaison de la tete */
|
||||
roll: number
|
||||
}
|
||||
|
||||
const deg = (d: number) => (d * Math.PI) / 180
|
||||
|
||||
/** Fait tourner deux vecteurs d'un repere orthonorme dans leur plan commun. */
|
||||
function spin(u: Vec3, v: Vec3, angle: number): [Vec3, Vec3] {
|
||||
const c = Math.cos(angle)
|
||||
const s = Math.sin(angle)
|
||||
return [
|
||||
[u[0] * c + v[0] * s, u[1] * c + v[1] * s, u[2] * c + v[2] * s],
|
||||
[v[0] * c - u[0] * s, v[1] * c - u[1] * s, v[2] * c - u[2] * s]
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Repere de la tete puis des deux yeux.
|
||||
* Repere ecran : x a droite, y vers le bas, z vers le spectateur.
|
||||
* L'indice 0 est l'oeil interieur, l'indice 1 l'oeil exterieur.
|
||||
*/
|
||||
export function eyePoses(gaze: HeadGaze, scale: number, split = EYE_SPLIT): [EyePose, EyePose] {
|
||||
let f: Vec3 = [0, 0, 1]
|
||||
let right: Vec3 = [1, 0, 0]
|
||||
let down: Vec3 = [0, 1, 0]
|
||||
|
||||
// lacet : forward bascule vers right
|
||||
;[f, right] = spin(f, right, deg(gaze.yaw))
|
||||
// tangage : forward bascule vers le haut (donc a l'oppose de down)
|
||||
;[down, f] = spin(down, f, deg(gaze.pitch))
|
||||
// roulis : la tete penche dans son propre plan
|
||||
;[right, down] = spin(right, down, deg(gaze.roll))
|
||||
|
||||
const build = (side: number): EyePose => {
|
||||
const [ef, er] = spin(f, right, deg(split * side))
|
||||
return {
|
||||
x: ef[0] * scale,
|
||||
y: ef[1] * scale,
|
||||
a: er[0],
|
||||
b: er[1],
|
||||
c: down[0],
|
||||
d: down[1],
|
||||
depth: ef[2]
|
||||
}
|
||||
}
|
||||
|
||||
return [build(-1), build(1)]
|
||||
}
|
||||
|
||||
/**
|
||||
* Vie au repos : derive lente du regard, saccades, clignements.
|
||||
*
|
||||
* Fonction pure du temps (aucun etat interne), donc pause, reprise et saut a
|
||||
* une date arbitraire donnent toujours la meme image. Les valeurs sont des
|
||||
* ECARTS a ajouter a la pose de l'etat courant.
|
||||
*/
|
||||
export interface Liveliness {
|
||||
dYaw: number
|
||||
dPitch: number
|
||||
dRoll: number
|
||||
/** 1 = oeil ouvert, 0 = ferme (ecrasement vertical en repere ecran) */
|
||||
lid: number
|
||||
driftX: number
|
||||
driftY: number
|
||||
breath: number
|
||||
}
|
||||
|
||||
const BLINK_RNG = createRng(0x5eed)
|
||||
/** Calendrier de clignements pre-tire : deterministe et sans etat. */
|
||||
const BLINKS: number[] = (() => {
|
||||
const out: number[] = []
|
||||
let t = 1.4
|
||||
while (t < 900) {
|
||||
out.push(t)
|
||||
// 1.9 a 4.6 s entre deux clignements, plus un double clignement parfois
|
||||
t += 1.9 + BLINK_RNG() * 2.7
|
||||
if (BLINK_RNG() < 0.18) {
|
||||
out.push(t)
|
||||
t += 0.24
|
||||
}
|
||||
}
|
||||
return out
|
||||
})()
|
||||
|
||||
/** Mesure : 1 a 2 frames a 10 fps. */
|
||||
const BLINK_DUR = 0.18
|
||||
|
||||
function blinkLid(t: number): number {
|
||||
for (let i = 0; i < BLINKS.length; i++) {
|
||||
const start = BLINKS[i]!
|
||||
if (t < start) break
|
||||
const k = (t - start) / BLINK_DUR
|
||||
if (k >= 0 && k <= 1) {
|
||||
// fermeture rapide, reouverture un peu plus lente
|
||||
return k < 0.45 ? 1 - k / 0.45 : (k - 0.45) / 0.55
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
export interface LivelinessOptions {
|
||||
wander?: number
|
||||
blink?: boolean
|
||||
float?: boolean
|
||||
}
|
||||
|
||||
export function liveliness(t: number, opt: LivelinessOptions = {}): Liveliness {
|
||||
const { wander = 1, blink = true, float = true } = opt
|
||||
|
||||
// Periodes premieres entre elles : la derive ne se repete jamais a l'oeil.
|
||||
return {
|
||||
dYaw: (loopNoise(t, 11.3, 0.4) * 5.5 + loopNoise(t, 3.7, 2.1) * 1.6) * wander,
|
||||
dPitch: (loopNoise(t, 9.1, 1.3) * 4.2 + loopNoise(t, 4.3, 0.7) * 1.3) * wander,
|
||||
dRoll: loopNoise(t, 13.7, 3.2) * 2.2 * wander,
|
||||
lid: blink ? blinkLid(t) : 1,
|
||||
// Au repos la video est quasiment immobile (centre stable a +-0.003, rayon
|
||||
// constant) : toute la vie passe par le regard et les clignements. On garde
|
||||
// juste de quoi ne pas figer completement l'image.
|
||||
driftX: float ? loopNoise(t, 7.9, 1.9) * 0.006 : 0,
|
||||
driftY: float ? loopNoise(t, 5.3, 0.3) * 0.007 : 0,
|
||||
// La largeur est constante, seule la hauteur respire tres legerement.
|
||||
breath: float ? 1 + Math.sin((t / 3.4) * Math.PI * 2) * 0.005 : 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Le clignement est un ecrasement VERTICAL en repere ecran autour du centre de
|
||||
* l'oeil (mesure : la largeur de bbox est conservee, la hauteur tombe a ~0.35),
|
||||
* pas un retrecissement le long de l'axe incline de la gelule. On le compose
|
||||
* donc apres la matrice tangente, en n'affectant que les sorties en y.
|
||||
*/
|
||||
export function blinkScale(lid: number): number {
|
||||
return 0.06 + 0.94 * clamp(lid)
|
||||
}
|
||||
42
lib/bloub-official/math.ts
Normal file
42
lib/bloub-official/math.ts
Normal file
@ -0,0 +1,42 @@
|
||||
export const TAU = Math.PI * 2
|
||||
|
||||
export const clamp = (v: number, lo = 0, hi = 1) => (v < lo ? lo : v > hi ? hi : v)
|
||||
export const lerp = (a: number, b: number, t: number) => a + (b - a) * t
|
||||
|
||||
export type Easing = (t: number) => number
|
||||
|
||||
/**
|
||||
* Mesure sur la video : les transitions sont des ease-out exponentiels, sans
|
||||
* depassement du corps. Les seuls effets de ressort sont locaux (le pop de la
|
||||
* pastille de notification, l'ouverture des yeux) et sont ecrits directement
|
||||
* dans l'etat concerne.
|
||||
*/
|
||||
export const easings = {
|
||||
easeOutCubic: (t: number) => 1 - (1 - t) ** 3,
|
||||
easeInOutCubic: (t: number) => (t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2),
|
||||
easeOutQuint: (t: number) => 1 - (1 - t) ** 5
|
||||
} satisfies Record<string, Easing>
|
||||
|
||||
/** Bruit 1D periodique : boucle sans couture sur `period`, utile pour la derive du regard. */
|
||||
export function loopNoise(t: number, period: number, seed = 0): number {
|
||||
const p = (t / period) * TAU
|
||||
return (
|
||||
0.55 * Math.sin(p + seed) +
|
||||
0.3 * Math.sin(2 * p + seed * 1.7 + 1.1) +
|
||||
0.15 * Math.sin(3 * p + seed * 2.3 + 2.4)
|
||||
)
|
||||
}
|
||||
|
||||
/** PRNG deterministe (mulberry32) : meme sequence a chaque lecture. */
|
||||
export function createRng(seed: number) {
|
||||
let a = seed >>> 0
|
||||
return () => {
|
||||
a = (a + 0x6d2b79f5) >>> 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Arrondi court : divise par ~2 le poids des chaines de path generees a 60 fps. */
|
||||
export const r2 = (v: number) => Math.round(v * 100) / 100
|
||||
21
lib/bloub-official/profiles.ts
Normal file
21
lib/bloub-official/profiles.ts
Normal file
@ -0,0 +1,21 @@
|
||||
// Profils radiaux r(theta) releves au pixel sur la video de reference.
|
||||
// theta = 0 pointe vers la droite et croit dans le sens horaire (y vers le bas).
|
||||
// Unite : rayon de la boule au repos = 1.
|
||||
//
|
||||
// Genere par tools/extract-profiles.py — ne pas editer a la main.
|
||||
|
||||
export const PROFILE_SAMPLES = 64
|
||||
|
||||
export const PROFILES = {
|
||||
// oeuf : meme hauteur que la boule, retreci en largeur
|
||||
// image 164, empreinte mesuree 1.647 x 2.000
|
||||
egg: [0.8369,0.8424,0.8497,0.8585,0.8674,0.8775,0.8878,0.8983,0.9089,0.9185,0.9288,0.9374,0.9445,0.9504,0.9543,0.9559,0.9555,0.9519,0.9466,0.9389,0.9302,0.9193,0.9085,0.8969,0.8852,0.8734,0.8625,0.8513,0.8411,0.8325,0.8243,0.8179,0.8137,0.8112,0.8102,0.8128,0.8178,0.8262,0.8374,0.8518,0.8702,0.8922,0.9169,0.9446,0.9741,1.0023,1.0267,1.0433,1.0481,1.0393,1.0216,0.9970,0.9697,0.9418,0.9169,0.8949,0.8760,0.8604,0.8490,0.8394,0.8337,0.8314,0.8305,0.8326],
|
||||
// hexagone pointe en haut, coins tres arrondis
|
||||
// image 174, empreinte mesuree 1.826 x 2.011
|
||||
hexagon: [0.9210,0.9282,0.9441,0.9706,0.9984,1.0059,0.9896,0.9562,0.9290,0.9124,0.9047,0.9058,0.9157,0.9349,0.9642,0.9873,0.9882,0.9665,0.9336,0.9105,0.8968,0.8918,0.8955,0.9080,0.9293,0.9611,0.9820,0.9812,0.9590,0.9282,0.9089,0.8978,0.8964,0.9026,0.9189,0.9439,0.9778,0.9990,0.9964,0.9713,0.9439,0.9274,0.9196,0.9206,0.9308,0.9502,0.9799,1.0121,1.0226,1.0071,0.9752,0.9510,0.9366,0.9316,0.9351,0.9485,0.9711,1.0026,1.0213,1.0155,0.9863,0.9547,0.9347,0.9232],
|
||||
// triangle pointe en haut, coins tres arrondis
|
||||
// image 190, empreinte mesuree 1.995 x 1.884
|
||||
triangle: [0.7819,0.8211,0.8747,0.9440,1.0223,1.0960,1.1401,1.1340,1.0808,1.0047,0.9265,0.8603,0.8104,0.7730,0.7450,0.7273,0.7151,0.7118,0.7148,0.7245,0.7427,0.7680,0.8037,0.8518,0.9148,0.9876,1.0583,1.1073,1.1109,1.0667,0.9940,0.9164,0.8482,0.7948,0.7555,0.7261,0.7056,0.6925,0.6859,0.6869,0.6938,0.7084,0.7305,0.7615,0.8040,0.8595,0.9311,1.0092,1.0791,1.1171,1.1054,1.0501,0.9779,0.9050,0.8450,0.7990,0.7656,0.7413,0.7258,0.7160,0.7146,0.7204,0.7330,0.7528],
|
||||
} as const
|
||||
|
||||
export type ProfileName = keyof typeof PROFILES
|
||||
31
lib/bloub-official/repere.ts
Normal file
31
lib/bloub-official/repere.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Le repere de tout ce que le moteur rend.
|
||||
*
|
||||
* `engine.sample()` sort des coordonnees en unites de viewBox, et ces deux nombres en sont
|
||||
* la definition : sans eux, une sortie du moteur ne veut rien dire. Ils vivaient dans
|
||||
* `BloubBot.vue`, donc hors d'atteinte — un `<script setup>` n'exporte rien — et
|
||||
* `export.ts` en redisait un a la main avec le commentaire qui nommait le probleme.
|
||||
*
|
||||
* Ils sont ici parce que `src/bot/` est ce qui se lit et se consomme du dehors : le
|
||||
* composant Vue est UN client du moteur, pas sa definition.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Rayon de la boule au repos, en unites de viewBox. C'est le `scale` que le composant
|
||||
* passe a `BotEngine`.
|
||||
*
|
||||
* Choisi et non mesure : c'est l'unite de travail. Tout le reste du dossier s'exprime en
|
||||
* fractions de ce rayon, ce qui rend les mesures relevees sur la video independantes de la
|
||||
* taille d'affichage.
|
||||
*/
|
||||
export const RAYON = 100
|
||||
|
||||
/**
|
||||
* Demi-cote du viewBox affiche. La marge au-dela du rayon loge les anneaux.
|
||||
*
|
||||
* Ce n'est pas une valeur libre : les anneaux de l'orbite et le swoosh de la comete montent
|
||||
* a 1,4 fois le rayon, soit 140. Rien ne les borne au runtime — c'est le reglage a la main
|
||||
* des tableaux `RINGS` et `SWOOSH` (`decor.ts`) qui les tient sous 158, et un test le
|
||||
* verrouille.
|
||||
*/
|
||||
export const DEMI_VIEWBOX = 158
|
||||
310
lib/bloub-official/shape.ts
Normal file
310
lib/bloub-official/shape.ts
Normal file
@ -0,0 +1,310 @@
|
||||
import { TAU, lerp, r2 } from './math'
|
||||
import { PROFILES, PROFILE_SAMPLES, type ProfileName } from './profiles'
|
||||
|
||||
export interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Une silhouette = un profil radial r(theta) plus une pose.
|
||||
*
|
||||
* Tout passe par des profils echantillonnes au MEME nombre d'angles : deux
|
||||
* formes quelconques ont donc des points qui se correspondent un a un, et le
|
||||
* morphing se reduit a une interpolation lineaire des rayons. C'est ce qui
|
||||
* rend les transitions propres sans librairie de morphing de path.
|
||||
*/
|
||||
export interface Silhouette {
|
||||
radii: number[]
|
||||
/** rotation du profil, en radians */
|
||||
rot: number
|
||||
/** decalage du centre, en unites de rayon de boule */
|
||||
cx: number
|
||||
cy: number
|
||||
/** squash & stretch, applique en repere ecran (apres rotation) */
|
||||
sx: number
|
||||
sy: number
|
||||
}
|
||||
|
||||
const ANGLES = Array.from({ length: PROFILE_SAMPLES }, (_, i) => (i / PROFILE_SAMPLES) * TAU)
|
||||
const COS = ANGLES.map(Math.cos)
|
||||
const SIN = ANGLES.map(Math.sin)
|
||||
|
||||
export function silhouette(name: ProfileName, pose: Partial<Silhouette> = {}): Silhouette {
|
||||
return {
|
||||
radii: [...PROFILES[name]],
|
||||
rot: 0,
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
...pose
|
||||
}
|
||||
}
|
||||
|
||||
/** Cercle parfait : sert de base neutre (point, bulle, cible de fondu). */
|
||||
export function circle(radius: number, pose: Partial<Silhouette> = {}): Silhouette {
|
||||
return {
|
||||
radii: new Array(PROFILE_SAMPLES).fill(radius),
|
||||
rot: 0,
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
...pose
|
||||
}
|
||||
}
|
||||
|
||||
/** Interpolation de deux silhouettes. `out` est reutilise pour eviter d'allouer a 60 fps. */
|
||||
export function blend(a: Silhouette, b: Silhouette, t: number, out?: Silhouette): Silhouette {
|
||||
const dst = out ?? { radii: new Array<number>(PROFILE_SAMPLES), rot: 0, cx: 0, cy: 0, sx: 1, sy: 1 }
|
||||
for (let i = 0; i < PROFILE_SAMPLES; i++) {
|
||||
dst.radii[i] = lerp(a.radii[i] ?? 1, b.radii[i] ?? 1, t)
|
||||
}
|
||||
// Rotation par le chemin le plus court : evite de faire un tour complet
|
||||
// quand on passe par exemple de +170deg a -170deg.
|
||||
let dRot = b.rot - a.rot
|
||||
while (dRot > Math.PI) dRot -= TAU
|
||||
while (dRot < -Math.PI) dRot += TAU
|
||||
dst.rot = a.rot + dRot * t
|
||||
dst.cx = lerp(a.cx, b.cx, t)
|
||||
dst.cy = lerp(a.cy, b.cy, t)
|
||||
dst.sx = lerp(a.sx, b.sx, t)
|
||||
dst.sy = lerp(a.sy, b.sy, t)
|
||||
return dst
|
||||
}
|
||||
|
||||
/** Projette la silhouette en points ecran. `scale` = rayon de la boule en unites de viewBox. */
|
||||
export function toPoints(s: Silhouette, scale: number, out: Point[] = []): Point[] {
|
||||
const cr = Math.cos(s.rot)
|
||||
const sr = Math.sin(s.rot)
|
||||
for (let i = 0; i < PROFILE_SAMPLES; i++) {
|
||||
const r = s.radii[i] ?? 1
|
||||
const x = r * (COS[i] ?? 0)
|
||||
const y = r * (SIN[i] ?? 0)
|
||||
// rotation puis squash en repere ecran, puis translation
|
||||
const rx = x * cr - y * sr
|
||||
const ry = x * sr + y * cr
|
||||
const p = out[i] ?? { x: 0, y: 0 }
|
||||
p.x = (rx * s.sx + s.cx) * scale
|
||||
p.y = (ry * s.sy + s.cy) * scale
|
||||
out[i] = p
|
||||
}
|
||||
out.length = PROFILE_SAMPLES
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyligne fermee -> cubiques Catmull-Rom.
|
||||
*
|
||||
* Avec 64 points les tangentes centrees suffisent largement : le contour est
|
||||
* lisse au pixel pres meme affiche en 600 px, et la chaine reste courte.
|
||||
*/
|
||||
export function closedPath(pts: Point[], tension = 1 / 6): string {
|
||||
const n = pts.length
|
||||
if (n < 3) return ''
|
||||
const first = pts[0]!
|
||||
let d = `M${r2(first.x)} ${r2(first.y)}`
|
||||
for (let i = 0; i < n; i++) {
|
||||
const p0 = pts[(i - 1 + n) % n]!
|
||||
const p1 = pts[i]!
|
||||
const p2 = pts[(i + 1) % n]!
|
||||
const p3 = pts[(i + 2) % n]!
|
||||
const c1x = p1.x + (p2.x - p0.x) * tension
|
||||
const c1y = p1.y + (p2.y - p0.y) * tension
|
||||
const c2x = p2.x - (p3.x - p1.x) * tension
|
||||
const c2y = p2.y - (p3.y - p1.y) * tension
|
||||
d += `C${r2(c1x)} ${r2(c1y)} ${r2(c2x)} ${r2(c2y)} ${r2(p2.x)} ${r2(p2.y)}`
|
||||
}
|
||||
return `${d}Z`
|
||||
}
|
||||
|
||||
/**
|
||||
* Polygone quelconque -> profil radial, par lancer de rayon depuis `center`.
|
||||
*
|
||||
* Sert a fabriquer les formes qui ne s'expriment pas naturellement en r(theta)
|
||||
* (la barre tronconique du "!"). Calcule une seule fois au chargement, jamais
|
||||
* dans la boucle de rendu.
|
||||
*/
|
||||
export function profileFromPolygon(poly: Point[], cx: number, cy: number): number[] {
|
||||
const radii = new Array<number>(PROFILE_SAMPLES).fill(0)
|
||||
const n = poly.length
|
||||
for (let k = 0; k < PROFILE_SAMPLES; k++) {
|
||||
const dx = COS[k] ?? 0
|
||||
const dy = SIN[k] ?? 0
|
||||
let best = 0
|
||||
for (let i = 0; i < n; i++) {
|
||||
const a = poly[i]!
|
||||
const b = poly[(i + 1) % n]!
|
||||
const ex = b.x - a.x
|
||||
const ey = b.y - a.y
|
||||
const den = dx * ey - dy * ex
|
||||
if (Math.abs(den) < 1e-9) continue
|
||||
const px = a.x - cx
|
||||
const py = a.y - cy
|
||||
const t = (px * ey - py * ex) / den // distance le long du rayon
|
||||
const u = (px * dy - py * dx) / den // position sur le segment
|
||||
if (t > best && u >= 0 && u <= 1) best = t
|
||||
}
|
||||
radii[k] = best
|
||||
}
|
||||
return radii
|
||||
}
|
||||
|
||||
/** Enveloppe convexe de deux cercles : la barre tronconique du "!" vertical. */
|
||||
export function hullOfCircles(
|
||||
x1: number,
|
||||
y1: number,
|
||||
r1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
r2v: number,
|
||||
steps = 96
|
||||
): Point[] {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
const dist = Math.hypot(dx, dy) || 1e-6
|
||||
// angle des tangentes externes communes
|
||||
const base = Math.atan2(dy, dx)
|
||||
const spread = Math.acos(Math.max(-1, Math.min(1, (r1 - r2v) / dist)))
|
||||
const pts: Point[] = []
|
||||
// arc du grand cercle
|
||||
for (let i = 0; i <= steps / 2; i++) {
|
||||
const a = base + spread + ((TAU - 2 * spread) * i) / (steps / 2)
|
||||
pts.push({ x: x1 + Math.cos(a) * r1, y: y1 + Math.sin(a) * r1 })
|
||||
}
|
||||
// arc du petit cercle
|
||||
for (let i = 0; i <= steps / 2; i++) {
|
||||
const a = base - spread + ((2 * spread) * i) / (steps / 2)
|
||||
pts.push({ x: x2 + Math.cos(a) * r2v, y: y2 + Math.sin(a) * r2v })
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
/**
|
||||
* Rayon du profil dans une direction quelconque, par interpolation entre les
|
||||
* deux echantillons voisins.
|
||||
*
|
||||
* Sert a recaler ce qui est pose "sur" le corps (les yeux, la pastille de
|
||||
* notification) quand la silhouette n'est plus un cercle : sans ca, un oeil
|
||||
* place a 0.62 rayon sort d'une forme dont le bord est a 0.55 dans cette
|
||||
* direction, et le masque le rogne.
|
||||
*/
|
||||
export function radiusAtAngle(radii: number[], angle: number): number {
|
||||
const n = radii.length
|
||||
const t = ((((angle / TAU) % 1) + 1) % 1) * n
|
||||
const i = Math.floor(t)
|
||||
return lerp(radii[i % n] ?? 1, radii[(i + 1) % n] ?? 1, t - i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Superellipse : |x/sx|^n + |y/sy|^n = 1.
|
||||
* n = 2 donne une ellipse, n ~ 4 le squircle du personnalisateur.
|
||||
*/
|
||||
export function superellipseProfile(n: number, sx = 1, sy = 1): number[] {
|
||||
return ANGLES.map((_, i) => {
|
||||
const c = Math.abs((COS[i] ?? 0) / sx) ** n
|
||||
const s = Math.abs((SIN[i] ?? 0) / sy) ** n
|
||||
return (c + s) ** (-1 / n)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Profil radial de l'UNION de disques : r(theta) = la plus lointaine des
|
||||
* intersections rayon/cercle. Exact tant que l'origine est dans l'union — c'est
|
||||
* ce qui donne les bosses du nuage sans booleen de path.
|
||||
*/
|
||||
export function unionOfCirclesProfile(circles: Array<{ x: number; y: number; r: number }>): number[] {
|
||||
const out = new Array<number>(PROFILE_SAMPLES).fill(0)
|
||||
for (let i = 0; i < PROFILE_SAMPLES; i++) {
|
||||
const dx = COS[i] ?? 0
|
||||
const dy = SIN[i] ?? 0
|
||||
let best = 0
|
||||
for (const c of circles) {
|
||||
const b = dx * c.x + dy * c.y
|
||||
const disc = b * b - (c.x * c.x + c.y * c.y - c.r * c.r)
|
||||
if (disc < 0) continue
|
||||
const t = b + Math.sqrt(disc)
|
||||
if (t > best) best = t
|
||||
}
|
||||
out[i] = best
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Polygone a coins arrondis, par somme de Minkowski avec un disque : chaque
|
||||
* arete est poussee de `rc` vers l'exterieur, chaque sommet devient un arc de
|
||||
* rayon `rc`. Les sommets sont donc a poser au rayon voulu MOINS rc.
|
||||
* Attend un polygone en sens horaire (repere ecran, y vers le bas).
|
||||
*/
|
||||
function roundedPolygon(verts: Point[], rc: number, arcSteps = 10): Point[] {
|
||||
const n = verts.length
|
||||
const out: Point[] = []
|
||||
const normal = (a: Point, b: Point) => {
|
||||
const dx = b.x - a.x
|
||||
const dy = b.y - a.y
|
||||
const len = Math.hypot(dx, dy) || 1
|
||||
// sens horaire + y vers le bas : la normale sortante est (dy, -dx)
|
||||
return Math.atan2(-dx / len, dy / len)
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const prev = verts[(i - 1 + n) % n]!
|
||||
const cur = verts[i]!
|
||||
const next = verts[(i + 1) % n]!
|
||||
const a0 = normal(prev, cur)
|
||||
const a1 = normal(cur, next)
|
||||
let d = a1 - a0
|
||||
while (d > Math.PI) d -= TAU
|
||||
while (d < -Math.PI) d += TAU
|
||||
for (let k = 0; k <= arcSteps; k++) {
|
||||
const a = a0 + (d * k) / arcSteps
|
||||
out.push({ x: cur.x + Math.cos(a) * rc, y: cur.y + Math.sin(a) * rc })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Polygone regulier a coins arrondis, inscrit dans `radius`. */
|
||||
export function regularPolygonProfile(
|
||||
sides: number,
|
||||
radius: number,
|
||||
rc: number,
|
||||
rotationDeg = 0
|
||||
): number[] {
|
||||
const rot = (rotationDeg * Math.PI) / 180
|
||||
const verts = Array.from({ length: sides }, (_, i) => {
|
||||
// sens horaire a l'ecran : theta croissant avec y vers le bas
|
||||
const a = rot + (i / sides) * TAU
|
||||
return { x: Math.cos(a) * (radius - rc), y: Math.sin(a) * (radius - rc) }
|
||||
})
|
||||
return profileFromPolygon(roundedPolygon(verts, rc), 0, 0)
|
||||
}
|
||||
|
||||
/** Polyligne fermee exacte : garde les segments droits (contrairement a closedPath). */
|
||||
export function polyPath(pts: Point[], scale = 1): string {
|
||||
if (pts.length < 3) return ''
|
||||
let d = ''
|
||||
for (let i = 0; i < pts.length; i++) {
|
||||
const p = pts[i]!
|
||||
d += `${i === 0 ? 'M' : 'L'}${r2(p.x * scale)} ${r2(p.y * scale)}`
|
||||
}
|
||||
return `${d}Z`
|
||||
}
|
||||
|
||||
/** Capsule (stade) centree sur l'origine : la forme exacte des yeux du bot. */
|
||||
export function capsulePath(w: number, h: number): string {
|
||||
const hw = Math.max(w, 0.01) / 2
|
||||
const hh = Math.max(h, 0.01) / 2
|
||||
const r = Math.min(hw, hh)
|
||||
return (
|
||||
`M${r2(-hw)} ${r2(-hh + r)}` +
|
||||
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw + r)} ${r2(-hh)}` +
|
||||
`L${r2(hw - r)} ${r2(-hh)}` +
|
||||
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw)} ${r2(-hh + r)}` +
|
||||
`L${r2(hw)} ${r2(hh - r)}` +
|
||||
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(hw - r)} ${r2(hh)}` +
|
||||
`L${r2(-hw + r)} ${r2(hh)}` +
|
||||
`A${r2(r)} ${r2(r)} 0 0 1 ${r2(-hw)} ${r2(hh - r)}Z`
|
||||
)
|
||||
}
|
||||
147
lib/bloub-official/skins.ts
Normal file
147
lib/bloub-official/skins.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { PROFILE_SAMPLES } from './profiles'
|
||||
import {
|
||||
hullOfCircles,
|
||||
profileFromPolygon,
|
||||
regularPolygonProfile,
|
||||
superellipseProfile,
|
||||
unionOfCirclesProfile
|
||||
} from './shape'
|
||||
|
||||
/**
|
||||
* Formes et couleurs proposees par le personnalisateur du bot.
|
||||
*
|
||||
* A la difference des silhouettes d'animation (`profiles.ts`), celles-ci ne sont
|
||||
* PAS relevees sur la video : elles sont construites analytiquement d'apres la
|
||||
* grille du personnalisateur d'origine. Deux sources distinctes, donc, et c'est
|
||||
* volontaire — les etats animes doivent rester fideles a la video, les formes de
|
||||
* base sont un choix d'utilisateur.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Les identifiants sont enumeres plutot que deduits du tableau : c'est ce qui
|
||||
* permet a la couche i18n de verifier A LA COMPILATION que chaque forme a bien
|
||||
* sa traduction dans les trois langues (`t(\`shapes.${id}\`)` ne compile que si
|
||||
* la cle existe). Un `as const` sur le tableau aurait le meme effet mais
|
||||
* rendrait `radii` en lecture seule, alors que le moteur le passe tel quel.
|
||||
*/
|
||||
export type ShapeId =
|
||||
| 'cercle'
|
||||
| 'galet'
|
||||
| 'squircle'
|
||||
| 'capsule'
|
||||
| 'triangle'
|
||||
| 'hexagone'
|
||||
| 'nuage'
|
||||
| 'goutte'
|
||||
|
||||
export interface BotShape {
|
||||
id: ShapeId
|
||||
radii: number[]
|
||||
}
|
||||
|
||||
/** Ramene le rayon maximal a `max` pour que toutes les formes pesent pareil a l'oeil. */
|
||||
function normalize(radii: number[], max = 1): number[] {
|
||||
const peak = Math.max(...radii)
|
||||
if (peak <= 0) return radii
|
||||
const k = max / peak
|
||||
return radii.map((r) => r * k)
|
||||
}
|
||||
|
||||
const ANGLES = Array.from({ length: PROFILE_SAMPLES }, (_, i) => (i / PROFILE_SAMPLES) * Math.PI * 2)
|
||||
|
||||
/** Galet : cercle deforme par deux harmoniques basses, donc irregulier mais lisse. */
|
||||
const pebble = normalize(
|
||||
ANGLES.map((a) => 1 + 0.075 * Math.cos(2 * a + 0.5) + 0.035 * Math.cos(3 * a + 2.1)),
|
||||
1.02
|
||||
)
|
||||
|
||||
/** Nuage : union de bosses, large en bas, deux lobes en haut. */
|
||||
const cloud = normalize(
|
||||
unionOfCirclesProfile([
|
||||
{ x: -0.44, y: 0.2, r: 0.54 },
|
||||
{ x: 0.46, y: 0.2, r: 0.5 },
|
||||
{ x: 0.02, y: 0.3, r: 0.6 },
|
||||
{ x: -0.24, y: -0.3, r: 0.48 },
|
||||
{ x: 0.3, y: -0.24, r: 0.44 }
|
||||
]),
|
||||
1.02
|
||||
)
|
||||
|
||||
/** Goutte : gros disque en bas, pointe effilee en haut. */
|
||||
const droplet = normalize(
|
||||
profileFromPolygon(hullOfCircles(0, 0.28, 0.66, 0, -0.96, 0.05), 0, 0),
|
||||
1.04
|
||||
)
|
||||
|
||||
/** Capsule couchee : enveloppe de deux disques cote a cote. */
|
||||
const capsule = profileFromPolygon(hullOfCircles(-0.42, 0, 0.62, 0.42, 0, 0.62), 0, 0)
|
||||
|
||||
export const SHAPES: BotShape[] = [
|
||||
{ id: 'cercle', radii: new Array(PROFILE_SAMPLES).fill(1) },
|
||||
{ id: 'galet', radii: pebble },
|
||||
// 1.15 et pas 1.02 : sur une superellipse le rayon maximal est la diagonale,
|
||||
// donc normaliser dessus donne une forme qui parait plus petite que le cercle.
|
||||
{ id: 'squircle', radii: normalize(superellipseProfile(4.2), 1.15) },
|
||||
{ id: 'capsule', radii: capsule },
|
||||
// -90deg : un sommet vers le haut de l'ecran (y est oriente vers le bas)
|
||||
{ id: 'triangle', radii: regularPolygonProfile(3, 1.12, 0.34, -90) },
|
||||
// 0deg : sommets a gauche et a droite, donc aretes du haut et du bas plates
|
||||
{ id: 'hexagone', radii: regularPolygonProfile(6, 1.04, 0.26, 0) },
|
||||
{ id: 'nuage', radii: cloud },
|
||||
{ id: 'goutte', radii: droplet }
|
||||
]
|
||||
|
||||
// Map indexee par `string` et non par `ShapeId` : les appelants interrogent avec
|
||||
// une valeur relue du localStorage ou d'une prop, donc non validee.
|
||||
export const SHAPE_BY_ID = new Map<string, BotShape>(SHAPES.map((s) => [s.id, s]))
|
||||
export const DEFAULT_SHAPE = 'cercle'
|
||||
|
||||
export type ColorId =
|
||||
| 'encre'
|
||||
| 'creme'
|
||||
| 'brun'
|
||||
| 'rouge'
|
||||
| 'orange'
|
||||
| 'ambre'
|
||||
| 'vert'
|
||||
| 'turquoise'
|
||||
| 'bleu'
|
||||
| 'violet'
|
||||
| 'rose'
|
||||
| 'gris'
|
||||
|
||||
export interface BotColor {
|
||||
id: ColorId
|
||||
hex: string
|
||||
}
|
||||
|
||||
/** Palette du personnalisateur d'origine. */
|
||||
export const COLORS: BotColor[] = [
|
||||
{ id: 'encre', hex: '#0a0a0c' },
|
||||
{ id: 'brun', hex: '#8b5e3c' },
|
||||
{ id: 'rouge', hex: '#e8483f' },
|
||||
{ id: 'orange', hex: '#f08a24' },
|
||||
{ id: 'ambre', hex: '#f0b429' },
|
||||
{ id: 'vert', hex: '#3ecf8e' },
|
||||
{ id: 'turquoise', hex: '#2fbfa0' },
|
||||
{ id: 'bleu', hex: '#3b93f0' },
|
||||
{ id: 'violet', hex: '#8b5cf6' },
|
||||
{ id: 'rose', hex: '#e152b0' },
|
||||
{ id: 'gris', hex: '#a3a3a3' },
|
||||
{ id: 'creme', hex: '#f1efe9' }
|
||||
]
|
||||
|
||||
export const COLOR_BY_ID = new Map<string, BotColor>(COLORS.map((c) => [c.id, c]))
|
||||
export const DEFAULT_COLOR = 'encre'
|
||||
|
||||
/** Melange deux couleurs hex. Sert a la brume de profondeur des particules. */
|
||||
export function mixHex(from: string, to: string, t: number): string {
|
||||
const parse = (h: string) => {
|
||||
const v = parseInt(h.slice(1), 16)
|
||||
return [(v >> 16) & 255, (v >> 8) & 255, v & 255]
|
||||
}
|
||||
const a = parse(from)
|
||||
const b = parse(to)
|
||||
const c = a.map((x, i) => Math.round(x + (b[i]! - x) * t))
|
||||
return `#${c.map((x) => x.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
616
lib/bloub-official/states.ts
Normal file
616
lib/bloub-official/states.ts
Normal file
@ -0,0 +1,616 @@
|
||||
import {
|
||||
COMET_DOT,
|
||||
COMET_RIBBONS,
|
||||
DOT_PEAK,
|
||||
DOT_R,
|
||||
DOT_X,
|
||||
NOTIF_ANGLE,
|
||||
NOTIF_DIST,
|
||||
NOTIF_MARGIN,
|
||||
NOTIF_POP,
|
||||
NOTIF_R,
|
||||
RINGS,
|
||||
SWOOSH,
|
||||
particles,
|
||||
type ArcSpec,
|
||||
type DotRender
|
||||
} from './decor'
|
||||
import { EYE_H, EYE_SPLIT, EYE_W, REST_GAZE, type HeadGaze } from './face'
|
||||
import { TAU, clamp, easings } from './math'
|
||||
import {
|
||||
circle,
|
||||
hullOfCircles,
|
||||
polyPath,
|
||||
profileFromPolygon,
|
||||
silhouette,
|
||||
type Silhouette
|
||||
} from './shape'
|
||||
|
||||
export interface EyeCfg {
|
||||
/** largeur locale (axe court de la gelule), en unites de rayon de boule */
|
||||
w: number
|
||||
/** hauteur locale (axe long) */
|
||||
h: number
|
||||
/** 1 = ouvert, 0 = ferme */
|
||||
open: number
|
||||
/**
|
||||
* Inclinaison propre de la gelule, en degres, positif = le haut part a
|
||||
* droite. Appliquee APRES le repere tangent de la sphere. Sans elle, les deux
|
||||
* yeux penchent forcement du meme cote (le roulis de tete) et la colere comme
|
||||
* la tristesse, qui demandent des inclinaisons en miroir, sont hors de portee.
|
||||
*/
|
||||
tilt?: number
|
||||
}
|
||||
|
||||
export interface Pose {
|
||||
/** silhouette du corps, en unites de rayon de boule */
|
||||
sil: Silhouette
|
||||
/** decalage global du corps ET des yeux */
|
||||
offX: number
|
||||
offY: number
|
||||
gaze: HeadGaze
|
||||
/** demi-ecart des yeux sur la sphere, en degres */
|
||||
split: number
|
||||
/** [oeil interieur, oeil exterieur] */
|
||||
eyes: [EyeCfg, EyeCfg]
|
||||
/** opacite des yeux : sert aux etats sans visage */
|
||||
eyeAlpha: number
|
||||
bodyAlpha: number
|
||||
dots: DotRender[]
|
||||
arcs: ArcSpec[]
|
||||
notif: { x: number; y: number; r: number; notch: number } | null
|
||||
/** true = le decor passe derriere le corps (particules de l'eclatement) */
|
||||
dotsBehind: boolean
|
||||
}
|
||||
|
||||
const pair = (w: number, h: number): [EyeCfg, EyeCfg] => [
|
||||
{ w, h, open: 1 },
|
||||
{ w, h, open: 1 }
|
||||
]
|
||||
|
||||
function base(over: Partial<Pose> = {}): Pose {
|
||||
return {
|
||||
sil: circle(1),
|
||||
offX: 0,
|
||||
offY: 0,
|
||||
gaze: { ...REST_GAZE },
|
||||
split: EYE_SPLIT,
|
||||
eyes: pair(EYE_W, EYE_H),
|
||||
eyeAlpha: 1,
|
||||
bodyAlpha: 1,
|
||||
dots: [],
|
||||
arcs: [],
|
||||
notif: null,
|
||||
dotsBehind: false,
|
||||
...over
|
||||
}
|
||||
}
|
||||
|
||||
/* --------------------------------------------------- formes non radiales */
|
||||
|
||||
/**
|
||||
* Barre du "!" vertical : enveloppe convexe de deux cercles.
|
||||
* Mesure : cercle haut (0, -0.505) r 0.132, cercle bas (0, +0.130) r 0.075,
|
||||
* flancs rectilignes. Elle est donc tronconique (rapport haut/bas 1.76).
|
||||
*/
|
||||
const BAR_UPRIGHT_CY = -0.1875
|
||||
const BAR_UPRIGHT = profileFromPolygon(
|
||||
hullOfCircles(0, -0.505, 0.132, 0, 0.13, 0.075),
|
||||
0,
|
||||
BAR_UPRIGHT_CY
|
||||
)
|
||||
|
||||
/** Barre du "!" penche : capsule pure (largeur constante 0.269, longueur 0.776). */
|
||||
const BAR_ITALIC = profileFromPolygon(hullOfCircles(0, -0.2535, 0.1345, 0, 0.2535, 0.1345), 0, 0)
|
||||
|
||||
const barUpright = (pose: Partial<Silhouette> = {}): Silhouette => ({
|
||||
radii: [...BAR_UPRIGHT],
|
||||
rot: 0,
|
||||
cx: 0,
|
||||
cy: BAR_UPRIGHT_CY,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
...pose
|
||||
})
|
||||
|
||||
const barItalic = (pose: Partial<Silhouette> = {}): Silhouette => ({
|
||||
radii: [...BAR_ITALIC],
|
||||
rot: 0,
|
||||
cx: 0,
|
||||
cy: 0,
|
||||
sx: 1,
|
||||
sy: 1,
|
||||
...pose
|
||||
})
|
||||
|
||||
/**
|
||||
* Le point du "!" penche n'est pas un disque : c'est une goutte, bout rond
|
||||
* (r 0.118) du cote de la barre et pointe effilee a l'oppose, longueur 0.300
|
||||
* dans l'axe du glyphe. Centree sur le barycentre du bout rond.
|
||||
*/
|
||||
const TEAR = polyPath(hullOfCircles(0, 0, 0.118, 0, 0.172, 0.012))
|
||||
|
||||
/**
|
||||
* Le triangle ne tourne pas sur lui-meme : son centre decrit un cercle de
|
||||
* rayon 0.213 autour de l'origine (mesure). C'est ce decalage qui donne
|
||||
* l'impression qu'il bascule au lieu de pivoter sur place.
|
||||
*/
|
||||
const TRI_ORBIT = 0.213
|
||||
|
||||
function spinningTriangle(rot: number): Silhouette {
|
||||
return silhouette('triangle', {
|
||||
rot,
|
||||
cx: -TRI_ORBIT * Math.sin(rot),
|
||||
cy: TRI_ORBIT * Math.cos(rot)
|
||||
})
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ etats */
|
||||
|
||||
export type StateId =
|
||||
| 'idle'
|
||||
| 'thinking'
|
||||
| 'wink'
|
||||
| 'wide'
|
||||
| 'alert'
|
||||
| 'notify'
|
||||
| 'exclaim'
|
||||
| 'sleep'
|
||||
| 'egg'
|
||||
| 'hexagon'
|
||||
| 'play'
|
||||
| 'orbit'
|
||||
| 'burst'
|
||||
| 'comet'
|
||||
/** transition d'interface, pas une animation du catalogue : hors `SEQUENCE` */
|
||||
| 'swirl'
|
||||
|
||||
export interface StateDef {
|
||||
id: StateId
|
||||
/** duree de maintien quand la sequence complete est jouee */
|
||||
duration: number
|
||||
/**
|
||||
* duree en dessous de laquelle l'animation est coupee avant d'aboutir : le
|
||||
* "!" ne revient pas, le corps reste eclate. Elle se lit dans les constantes
|
||||
* de `pose` ci-dessous, elle ne se choisit pas. Absente = l'etat ignore le
|
||||
* temps ou boucle, n'importe quelle duree lui va (voir `MIN_BLOCK`).
|
||||
*/
|
||||
minDuration?: number
|
||||
/** duree du morph d'entree */
|
||||
morph: number
|
||||
/** true = l'entree est masquee par un clignement, comme dans la video */
|
||||
blinkIn: boolean
|
||||
/**
|
||||
* true = le corps est la silhouette "au repos", donc remplacable par la forme
|
||||
* choisie dans le personnalisateur. Les etats qui dessinent leur propre forme
|
||||
* (le "!", les points, l'oeuf, le triangle...) valent false : c'est cette forme
|
||||
* la qui EST l'animation.
|
||||
*/
|
||||
baseBody: boolean
|
||||
/**
|
||||
* true = l'etat porte le visage "au repos", donc remplacable par l'expression
|
||||
* choisie. Seul `idle` : les autres etats a visage ont une expression relevee
|
||||
* sur la video, c'est precisement ce qu'on reproduit.
|
||||
*/
|
||||
baseFace: boolean
|
||||
pose(local: number): Pose
|
||||
}
|
||||
|
||||
/** Onde de pulsation qui parcourt les trois points de gauche a droite. */
|
||||
function dotPulse(t: number, index: number): number {
|
||||
const p = ((((t - index * 0.5) / 1.5) % 1) + 1) % 1
|
||||
const k = p < 0.5 ? 0.5 - 0.5 * Math.cos(p * TAU) : 0
|
||||
return clamp(k * 2)
|
||||
}
|
||||
|
||||
export const STATES: StateDef[] = [
|
||||
{
|
||||
id: 'idle',
|
||||
duration: 2.4,
|
||||
morph: 0.45,
|
||||
blinkIn: false,
|
||||
baseFace: true,
|
||||
baseBody: true,
|
||||
pose: () => base()
|
||||
},
|
||||
|
||||
{
|
||||
id: 'thinking',
|
||||
duration: 2.6,
|
||||
morph: 0.4,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: true,
|
||||
pose: (t) => {
|
||||
const mid = dotPulse(t, 1)
|
||||
// Les points lateraux sortent des flancs de la boule : dans la video ils
|
||||
// restent fusionnes avec elle 1-2 frames avant de se detacher.
|
||||
const emerge = 0.3 + 0.7 * easings.easeOutCubic(clamp(t / 0.3))
|
||||
return base({
|
||||
// la boule DEVIENT le point du milieu : le morph reste continu
|
||||
sil: circle(DOT_R * (1 + (DOT_PEAK - 1) * mid), { cx: DOT_X[1]! }),
|
||||
eyeAlpha: 0,
|
||||
dots: [0, 2].map((i) => {
|
||||
const k = dotPulse(t, i)
|
||||
return {
|
||||
x: DOT_X[i]! * emerge,
|
||||
y: 0,
|
||||
r: DOT_R * (1 + (DOT_PEAK - 1) * k),
|
||||
opacity: 0.55 + 0.45 * k
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'wink',
|
||||
duration: 1.6,
|
||||
morph: 0.3,
|
||||
blinkIn: true,
|
||||
baseFace: false,
|
||||
baseBody: true,
|
||||
pose: () =>
|
||||
base({
|
||||
gaze: { yaw: -5.37, pitch: 4.55, roll: 6.7 },
|
||||
split: 16.25,
|
||||
// L'oeil ferme n'est pas l'oeil ouvert ecrase : c'est un tiret
|
||||
// horizontal PLUS LARGE que l'oeil ouvert (0.447 contre 0.236).
|
||||
eyes: [
|
||||
{ w: 0.236, h: 0.464, open: 1 },
|
||||
{ w: 0.447, h: 0.089, open: 1 }
|
||||
]
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
id: 'wide',
|
||||
duration: 1.8,
|
||||
morph: 0.55,
|
||||
blinkIn: true,
|
||||
baseFace: false,
|
||||
baseBody: true,
|
||||
pose: () =>
|
||||
base({
|
||||
gaze: { yaw: 6.92, pitch: -21.96, roll: 11.6 },
|
||||
split: 18.43,
|
||||
eyes: pair(0.356, 0.875)
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
id: 'alert',
|
||||
duration: 2.4,
|
||||
// le "!" revient en place a 1.6 + 0.4
|
||||
minDuration: 2,
|
||||
morph: 0.45,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: false,
|
||||
pose: (t) => {
|
||||
// Course mesuree : -0.087 -> +0.732 en 1.5 s, ease-in-out, micro-overshoot.
|
||||
const p = clamp(t / 1.5)
|
||||
const travel = easings.easeInOutCubic(p) * 0.82 - 0.087
|
||||
const back = t > 1.6 ? clamp((t - 1.6) / 0.4) : 0
|
||||
const x = travel * (1 - back) + 0.1 * back
|
||||
// Vibration secondaire a 2.5 Hz, barre et point en opposition de phase.
|
||||
const buzz = Math.sin(t * 2.5 * TAU) * 0.005
|
||||
const tilt = (17.7 * Math.PI) / 180
|
||||
return base({
|
||||
sil: barItalic({ rot: tilt, cx: x, cy: -0.325 - buzz }),
|
||||
eyeAlpha: 0,
|
||||
dots: [
|
||||
{
|
||||
// le point suit l'axe du glyphe, a 0.580 du centre de la barre
|
||||
x: x - Math.sin(tilt) * 0.58,
|
||||
y: -0.325 + Math.cos(tilt) * 0.58 + buzz * 2.8,
|
||||
r: 0.118,
|
||||
d: TEAR,
|
||||
rot: (tilt * 180) / Math.PI,
|
||||
opacity: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'notify',
|
||||
duration: 2.2,
|
||||
morph: 0.5,
|
||||
blinkIn: true,
|
||||
baseFace: false,
|
||||
baseBody: true,
|
||||
pose: (t) => {
|
||||
// Pop du point bleu : pic a +14 % vers 0.3 s puis stabilisation.
|
||||
const p = clamp(t / 0.45)
|
||||
const pop = 1 + (NOTIF_POP - 1) * Math.sin(p * Math.PI) * (1 - p * 0.35)
|
||||
const r = NOTIF_R * (p < 1 ? pop : 1)
|
||||
const a = (NOTIF_ANGLE * Math.PI) / 180
|
||||
return base({
|
||||
// le regard part a l'oppose de la pastille
|
||||
gaze: { yaw: -21.94, pitch: -5.82, roll: -12.2 },
|
||||
split: 18.89,
|
||||
eyes: pair(0.505, 0.498),
|
||||
notif: {
|
||||
x: Math.cos(a) * NOTIF_DIST,
|
||||
y: Math.sin(a) * NOTIF_DIST,
|
||||
r,
|
||||
notch: r + NOTIF_MARGIN
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'exclaim',
|
||||
duration: 2,
|
||||
morph: 0.45,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: false,
|
||||
pose: () =>
|
||||
base({
|
||||
sil: barUpright(),
|
||||
eyeAlpha: 0,
|
||||
dots: [{ x: -0.012, y: 0.526, r: 0.113, opacity: 1 }]
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
id: 'sleep',
|
||||
duration: 2.4,
|
||||
morph: 0.5,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: false,
|
||||
pose: (t) =>
|
||||
base({
|
||||
// Rebond vertical mesure : +-0.19 autour de +0.11, periode 0.6 s.
|
||||
sil: circle(0.1585, { cy: 0.11 + Math.sin(t * (TAU / 0.6)) * 0.19 }),
|
||||
eyeAlpha: 0
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
id: 'egg',
|
||||
duration: 1.8,
|
||||
morph: 0.4,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: true,
|
||||
pose: () =>
|
||||
base({
|
||||
sil: silhouette('egg'),
|
||||
gaze: { yaw: 19.97, pitch: 26.01, roll: -17.1 },
|
||||
// les yeux se resserrent comme le corps
|
||||
split: 11.07,
|
||||
eyes: pair(0.164, 0.385)
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
id: 'hexagon',
|
||||
duration: 1.6,
|
||||
morph: 0.4,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: true,
|
||||
pose: () =>
|
||||
base({
|
||||
sil: silhouette('hexagon'),
|
||||
gaze: { yaw: 23.11, pitch: 24.42, roll: -13.3 },
|
||||
split: 13.37,
|
||||
eyes: pair(0.177, 0.411)
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
id: 'play',
|
||||
duration: 2,
|
||||
morph: 0.5,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: true,
|
||||
pose: (t) => {
|
||||
// Le triangle reste quasi immobile pendant que le bouquet le traverse.
|
||||
const fade = clamp(t / 0.35) * clamp((2.2 - t) / 0.5)
|
||||
return base({
|
||||
sil: spinningTriangle(0),
|
||||
gaze: { yaw: 12, pitch: -8, roll: -6 },
|
||||
split: 15,
|
||||
eyes: pair(0.18, 0.34),
|
||||
// le bouquet balaie de la droite vers la gauche par-dessus le triangle
|
||||
arcs: SWOOSH.map((s, i) => ({
|
||||
id: `sw${i}`,
|
||||
seed: { ...s, cx: 0.45 - t * 0.42 },
|
||||
t,
|
||||
opacity: fade
|
||||
}))
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'orbit',
|
||||
duration: 3.4,
|
||||
// le corps a fini de se relacher du triangle vers la boule a 1.6 + 0.9
|
||||
minDuration: 2.5,
|
||||
morph: 0.6,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: false,
|
||||
pose: (t) => {
|
||||
// Rotation mesuree : rampe sur 0.35 s puis 1.25 tour/s (sens antihoraire).
|
||||
const ramp = easings.easeInOutCubic(clamp(t / 0.35))
|
||||
const rot = -TAU * 1.25 * t * ramp
|
||||
// Le corps se relache du triangle vers la boule pendant l'orbite.
|
||||
const back = easings.easeInOutCubic(clamp((t - 1.6) / 0.9))
|
||||
const tri = spinningTriangle(rot)
|
||||
const ball = circle(1, { rot })
|
||||
const sil: Silhouette = {
|
||||
radii: tri.radii.map((r, i) => r + (ball.radii[i]! - r) * back),
|
||||
rot,
|
||||
cx: tri.cx * (1 - back),
|
||||
cy: tri.cy * (1 - back),
|
||||
sx: 1,
|
||||
sy: 1
|
||||
}
|
||||
const fade = clamp(t / 0.8) * clamp((3.6 - t) / 0.9)
|
||||
return base({
|
||||
sil,
|
||||
// les yeux filent autour de la sphere ~3x plus vite que la silhouette
|
||||
gaze: {
|
||||
yaw: REST_GAZE.yaw + Math.sin(t * 6.5) * 65 * (1 - back),
|
||||
pitch: -4 + back * 32,
|
||||
roll: -13
|
||||
},
|
||||
eyes: pair(0.18, 0.34 + back * 0.07),
|
||||
// les anneaux entrent un par un sur 0.8 s
|
||||
arcs: RINGS.map((s, i) => ({
|
||||
id: `rg${i}`,
|
||||
seed: s,
|
||||
t,
|
||||
opacity: fade * clamp((t - i * 0.13) / 0.3)
|
||||
}))
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
/**
|
||||
* Entree dans la vue des reglages.
|
||||
*
|
||||
* SEUL etat qui n'est pas releve sur la video : il est CHOISI, comme la
|
||||
* couleur `--ink`. Il emprunte le vocabulaire d'`orbit` — les memes anneaux,
|
||||
* avec leurs parametres mesures — mais coupe court : 1 s au lieu de 3,4, la
|
||||
* moitie des anneaux, et aucun triangle.
|
||||
*
|
||||
* Les deux drapeaux a `true` sont tout l'interet de cet etat :
|
||||
*
|
||||
* - `baseBody` laisse la forme choisie remplacer le corps, donc la vue peut
|
||||
* imposer le cercle et le galet ou la goutte y MORPHENT au lieu de sauter ;
|
||||
* - `baseFace` fait porter le visage de repos, donc le suivi du curseur
|
||||
* s'applique des cette entree. Un etat qui aurait sa propre pose de regard
|
||||
* (comme `orbit`) rendrait la main a l'etat suivant en pleine course, et
|
||||
* les yeux sauteraient d'un coup a la reprise.
|
||||
*
|
||||
* Il n'est volontairement PAS dans `SEQUENCE` : ce n'est pas une animation du
|
||||
* catalogue, c'est une transition d'interface.
|
||||
*/
|
||||
id: 'swirl',
|
||||
// un peu plus que le tour du regard (`TURN_TIME`, 1,1 s) : les yeux doivent
|
||||
// etre poses a gauche avant que les anneaux ne s'effacent
|
||||
duration: 1.3,
|
||||
minDuration: 1.3,
|
||||
morph: 0.3,
|
||||
baseFace: true,
|
||||
baseBody: true,
|
||||
// le morph de forme est masque par un clignement, comme partout ailleurs
|
||||
blinkIn: true,
|
||||
pose: (t) =>
|
||||
base({
|
||||
// trois anneaux sur les six d'`orbit` : la moitie du bouquet suffit a le
|
||||
// reconnaitre, et c'est autant d'arcs en moins a rasteriser par image
|
||||
arcs: RINGS.slice(0, 3).map((s, i) => ({
|
||||
id: `sw${i}`,
|
||||
seed: s,
|
||||
t,
|
||||
// ils entrent l'un apres l'autre puis s'effacent avant la fin du bloc,
|
||||
// pour que la reprise au repos se fasse sur une image deja propre
|
||||
opacity: clamp((t - i * 0.06) / 0.14) * clamp((1.22 - t) / 0.34)
|
||||
}))
|
||||
})
|
||||
},
|
||||
|
||||
{
|
||||
id: 'burst',
|
||||
duration: 2.6,
|
||||
// le corps est recompose a 1.7 + 0.7
|
||||
minDuration: 2.4,
|
||||
morph: 0.4,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: false,
|
||||
pose: (t) => {
|
||||
// Effondrement mesure : 1.0 -> 0.166 en 0.7 s, ease-out, sans rebond.
|
||||
const collapse = 1 - 0.834 * easings.easeOutQuint(clamp(t / 0.7))
|
||||
const regrow = easings.easeOutQuint(clamp((t - 1.7) / 0.7))
|
||||
return base({
|
||||
sil: circle(collapse + (1 - collapse) * regrow),
|
||||
eyeAlpha: clamp((t - 1.85) / 0.4),
|
||||
dots: particles(t, 1),
|
||||
dotsBehind: true
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
id: 'comet',
|
||||
duration: 2.4,
|
||||
// le point se recompose a 1.85 + 0.6 = 2.45, soit 0.05 s apres la coupe de
|
||||
// la video : ce reliquat se termine pendant le fondu suivant, comme dans la
|
||||
// reference. On ne descend donc pas sous la duree mesuree.
|
||||
minDuration: 2.4,
|
||||
morph: 0.45,
|
||||
baseFace: false,
|
||||
baseBody: false,
|
||||
blinkIn: false,
|
||||
pose: (t) => {
|
||||
const collapse = 1 - (1 - COMET_DOT) * easings.easeOutQuint(clamp(t / 0.55))
|
||||
const regrow = easings.easeOutQuint(clamp((t - 1.85) / 0.6))
|
||||
const fade = clamp((t - 0.15) / 0.25) * clamp((1.95 - t) / 0.3)
|
||||
return base({
|
||||
// Le point derive de 0.035 vers le bas puis remonte (wobble mesure).
|
||||
sil: circle(collapse + (1 - collapse) * regrow, {
|
||||
cy: Math.sin(clamp(t / 1.7) * Math.PI) * 0.035
|
||||
}),
|
||||
eyeAlpha: clamp((t - 2) / 0.35),
|
||||
arcs: COMET_RIBBONS.map((s, i) => ({ id: `cm${i}`, seed: s, t, opacity: fade }))
|
||||
})
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const STATE_BY_ID = new Map(STATES.map((s) => [s.id, s]))
|
||||
|
||||
/** Ordre de lecture de la sequence complete, calque sur la video de reference. */
|
||||
/**
|
||||
* Date, en temps local, ou chaque etat est le plus lisible : c'est la pose que
|
||||
* montrent les vignettes et la planche. Rendu deterministe, donc comparable
|
||||
* d'une execution a l'autre. Le type force a couvrir tout nouvel etat.
|
||||
*/
|
||||
export const POSES: Record<StateId, number> = {
|
||||
idle: 1,
|
||||
thinking: 1.1,
|
||||
wink: 0.8,
|
||||
wide: 0.8,
|
||||
alert: 0.75,
|
||||
notify: 0.9,
|
||||
exclaim: 0.8,
|
||||
sleep: 0.45,
|
||||
egg: 0.8,
|
||||
hexagon: 0.8,
|
||||
play: 0.9,
|
||||
orbit: 1.2,
|
||||
swirl: 0.5,
|
||||
burst: 0.45,
|
||||
comet: 1.15
|
||||
}
|
||||
|
||||
export const SEQUENCE: StateId[] = [
|
||||
'idle',
|
||||
'thinking',
|
||||
'wink',
|
||||
'wide',
|
||||
'alert',
|
||||
'notify',
|
||||
'exclaim',
|
||||
'sleep',
|
||||
'egg',
|
||||
'hexagon',
|
||||
'play',
|
||||
'orbit',
|
||||
'burst',
|
||||
'comet'
|
||||
]
|
||||
@ -4725,8 +4725,8 @@ export async function getSiteSettings() {
|
||||
canvasDetailAgentModelId: null,
|
||||
lovartPromoText: "现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠",
|
||||
lovartPromoActionText: "立即升级",
|
||||
lovartHeroTitle: "由壹点AI 让设计更简单",
|
||||
lovartHeroSubtitle: "懂你的设计代理,帮你搞定一切",
|
||||
lovartHeroTitle: "𝗕𝘂𝗬𝗶 让设计触手可及",
|
||||
lovartHeroSubtitle: "下达指令,即刻呈现",
|
||||
};
|
||||
try {
|
||||
const [settings, videoCanvasEnabledSetting, videoCanvasSidebarEnabledSetting] =
|
||||
|
||||
@ -475,7 +475,7 @@ export const siteSettings = pgTable("SiteSettings", {
|
||||
usageTimeEnabled: boolean("usageTimeEnabled").notNull().default(false), // 是否启用时间限制
|
||||
usageTimeStart: varchar("usageTimeStart", { length: 5 }).default("08:00"), // 开始时间(HH:mm)
|
||||
usageTimeEnd: varchar("usageTimeEnd", { length: 5 }).default("20:00"), // 结束时间(HH:mm)
|
||||
usageTimeMessage: text("usageTimeMessage").default("由壹点累了也是需要休息的"), // 超时提示词
|
||||
usageTimeMessage: text("usageTimeMessage").default("BuYi 累了也是需要休息的"), // 超时提示词
|
||||
// 广告横幅设置
|
||||
bannerEnabled: boolean("bannerEnabled").notNull().default(true), // 是否显示广告横幅
|
||||
bannerText: text("bannerText").default("限时特惠:升级会员最高立享 45% OFF!"), // 广告横幅文字
|
||||
@ -505,8 +505,8 @@ export const siteSettings = pgTable("SiteSettings", {
|
||||
"现在升级,立享 Kling 3.0 与 Nano Banana Pro,最高 5 折优惠"
|
||||
),
|
||||
lovartPromoActionText: varchar("lovartPromoActionText", { length: 32 }).default("立即升级"),
|
||||
lovartHeroTitle: text("lovartHeroTitle").default("由壹点AI 让设计更简单"),
|
||||
lovartHeroSubtitle: text("lovartHeroSubtitle").default("懂你的设计代理,帮你搞定一切"),
|
||||
lovartHeroTitle: text("lovartHeroTitle").default("𝗕𝘂𝗬𝗶 让设计触手可及"),
|
||||
lovartHeroSubtitle: text("lovartHeroSubtitle").default("下达指令,即刻呈现"),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
updatedBy: uuid("updatedBy").references(() => user.id),
|
||||
});
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import sharp from "sharp";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import {
|
||||
enqueueImageArchiveTasks,
|
||||
@ -31,6 +33,10 @@ export function isDeferredImageArchiveEnabled() {
|
||||
return process.env.AI_IMAGE_DEFERRED_ARCHIVE !== "false";
|
||||
}
|
||||
|
||||
export function isImmediateImageArchiveEnabled() {
|
||||
return process.env.AI_IMAGE_IMMEDIATE_ARCHIVE !== "false";
|
||||
}
|
||||
|
||||
export async function registerGeneratedImageTemporaryUrls(params: {
|
||||
urls: string[];
|
||||
metadata?: DeferredImageArchiveMetadata | null;
|
||||
@ -80,18 +86,19 @@ export async function formatDeferredGeneratedImageMarkdown(params: {
|
||||
throw new Error("AI 未返回可用图片地址");
|
||||
}
|
||||
|
||||
await registerGeneratedImageTemporaryUrls({
|
||||
urls,
|
||||
metadata: {
|
||||
providerType: params.providerType,
|
||||
modelId: params.modelId,
|
||||
imageSize: params.imageSize,
|
||||
...(params.metadata || {}),
|
||||
},
|
||||
});
|
||||
const metadata = {
|
||||
providerType: params.providerType,
|
||||
modelId: params.modelId,
|
||||
imageSize: params.imageSize,
|
||||
...(params.metadata || {}),
|
||||
};
|
||||
const persisted = await persistGeneratedImageUrls({ urls, metadata });
|
||||
|
||||
return urls
|
||||
.map((url) => ``)
|
||||
return persisted
|
||||
.map(
|
||||
({ originalUrl, thumbnailUrl }) =>
|
||||
``
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
@ -102,17 +109,61 @@ export async function createDeferredUploadedImage(
|
||||
const url = String(sourceUrl || "").trim();
|
||||
if (!isDeferredImageArchiveEnabled() || !isHttpUrl(url)) return null;
|
||||
|
||||
await registerGeneratedImageTemporaryUrls({
|
||||
const [persisted] = await persistGeneratedImageUrls({
|
||||
urls: [url],
|
||||
metadata: metadata || null,
|
||||
});
|
||||
|
||||
return {
|
||||
thumbnail: url,
|
||||
original: url,
|
||||
thumbnail: persisted?.thumbnailUrl || url,
|
||||
original: persisted?.originalUrl || url,
|
||||
};
|
||||
}
|
||||
|
||||
async function persistGeneratedImageUrls(params: {
|
||||
urls: string[];
|
||||
metadata?: DeferredImageArchiveMetadata | null;
|
||||
}) {
|
||||
if (!isImmediateImageArchiveEnabled()) {
|
||||
await registerGeneratedImageTemporaryUrls(params);
|
||||
return params.urls.map((url) => ({
|
||||
originalUrl: url,
|
||||
thumbnailUrl: url,
|
||||
}));
|
||||
}
|
||||
|
||||
return await Promise.all(
|
||||
params.urls.map(async (url) => {
|
||||
try {
|
||||
const archived = await archiveTemporaryImage(url);
|
||||
logger.info("generated image archived before response", {
|
||||
providerType: params.metadata?.providerType,
|
||||
modelId: params.metadata?.modelId,
|
||||
bytes: archived.bytes,
|
||||
});
|
||||
return {
|
||||
originalUrl: archived.url,
|
||||
thumbnailUrl: archived.thumbnailUrl || archived.url,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("immediate generated image archive failed; queued for retry", {
|
||||
providerType: params.metadata?.providerType,
|
||||
modelId: params.metadata?.modelId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await registerGeneratedImageTemporaryUrls({
|
||||
urls: [url],
|
||||
metadata: params.metadata || null,
|
||||
});
|
||||
return {
|
||||
originalUrl: url,
|
||||
thumbnailUrl: url,
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function getImageExtension(contentType: string | null, sourceUrl: string) {
|
||||
const normalized = String(contentType || "").split(";")[0].trim().toLowerCase();
|
||||
const byType: Record<string, string> = {
|
||||
@ -187,10 +238,28 @@ export async function downloadTemporaryImage(params: {
|
||||
|
||||
export async function archiveTemporaryImage(sourceUrl: string) {
|
||||
const downloaded = await downloadTemporaryImage({ sourceUrl });
|
||||
const filename = `ai-generated-archive-${Date.now()}.${downloaded.extension}`;
|
||||
const objectSuffix = `${Date.now()}-${randomUUID()}`;
|
||||
const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`;
|
||||
const uploaded = await uploadFile(filename, toArrayBuffer(downloaded.buffer));
|
||||
const thumbnailBuffer = await sharp(downloaded.buffer, { failOn: "none" })
|
||||
.resize(640, 640, { fit: "inside", withoutEnlargement: true })
|
||||
.webp({ quality: 82 })
|
||||
.toBuffer()
|
||||
.catch(() => null);
|
||||
const thumbnailUploaded = thumbnailBuffer
|
||||
? await uploadFile(
|
||||
`ai-generated-archive-thumb-${objectSuffix}.webp`,
|
||||
toArrayBuffer(thumbnailBuffer)
|
||||
).catch((error) => {
|
||||
logger.warn("generated image thumbnail upload failed", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
url: uploaded.url,
|
||||
thumbnailUrl: thumbnailUploaded?.url || uploaded.url,
|
||||
pathname: uploaded.pathname,
|
||||
contentType: uploaded.contentType,
|
||||
bytes: downloaded.buffer.length,
|
||||
|
||||
@ -55,6 +55,7 @@
|
||||
"test": "export PLAYWRIGHT=True && pnpm exec playwright test",
|
||||
"storage:configure-upload-cors": "pnpm exec tsx scripts/storage/configure-direct-upload-cors.ts",
|
||||
"storage:configure-upload-lifecycle": "pnpm exec tsx scripts/storage/configure-multipart-lifecycle.ts",
|
||||
"storage:repair-model3d-qiniu": "pnpm exec tsx scripts/storage/repair-model3d-qiniu-assets.ts",
|
||||
"verify:ruxa-models": "pnpm exec tsx scripts/verify-ruxa-image-models.ts",
|
||||
"providers:consolidate": "pnpm exec tsx scripts/consolidate-ai-providers.ts",
|
||||
"providers:test-vip98k": "pnpm exec tsx scripts/test-vip98k-capability.ts"
|
||||
|
||||
@ -7,6 +7,11 @@ candidate_dir="$(mktemp -d "${app_dir}/.next-candidate.XXXXXX")"
|
||||
previous_dir="${app_dir}/.next-previous-$(date +%Y%m%dT%H%M%S)"
|
||||
build_container="ai-chatbot-build-$(date +%s)"
|
||||
|
||||
if grep -Eq '^[[:space:]]*CANVAS_E2E_LOCAL[[:space:]]*=[[:space:]]*"?true"?[[:space:]]*$' "${app_dir}/.env.local"; then
|
||||
echo "Refusing production deployment while CANVAS_E2E_LOCAL=true." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup_candidate() {
|
||||
if [ -d "$candidate_dir" ]; then
|
||||
rm -rf -- "$candidate_dir"
|
||||
|
||||
414
scripts/storage/repair-model3d-qiniu-assets.ts
Normal file
414
scripts/storage/repair-model3d-qiniu-assets.ts
Normal file
@ -0,0 +1,414 @@
|
||||
import { config as loadEnv } from "dotenv";
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import Module from "node:module";
|
||||
import path from "node:path";
|
||||
|
||||
loadEnv({ path: ".env.local" });
|
||||
loadEnv();
|
||||
|
||||
const moduleLoader = Module as typeof Module & {
|
||||
_load: (request: string, parent: NodeModule | null, isMain: boolean) => unknown;
|
||||
};
|
||||
const originalLoad = moduleLoader._load;
|
||||
moduleLoader._load = function patchedServerOnlyLoad(
|
||||
this: typeof moduleLoader,
|
||||
request,
|
||||
parent,
|
||||
isMain
|
||||
) {
|
||||
if (request === "server-only") return {};
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
|
||||
type AssetKind = "glb" | "image" | "spz";
|
||||
type StringRecord = Record<string, string>;
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
const worldOnly = process.argv.includes("--world-only");
|
||||
const tripoOnly = process.argv.includes("--tripo-only");
|
||||
const limitArg = process.argv.find((arg) => arg.startsWith("--limit="));
|
||||
const limit = limitArg ? Math.max(1, Number(limitArg.split("=")[1]) || 1) : null;
|
||||
const DOWNLOAD_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function normalizeUrl(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string) {
|
||||
return /^https?:\/\//i.test(value);
|
||||
}
|
||||
|
||||
function hasGlbMagic(bytes: Uint8Array) {
|
||||
return (
|
||||
bytes.length >= 12 &&
|
||||
bytes[0] === 0x67 &&
|
||||
bytes[1] === 0x6c &&
|
||||
bytes[2] === 0x54 &&
|
||||
bytes[3] === 0x46
|
||||
);
|
||||
}
|
||||
|
||||
function hasSpzMagic(bytes: Uint8Array) {
|
||||
return (
|
||||
(bytes.length >= 4 &&
|
||||
bytes[0] === 0x4e &&
|
||||
bytes[1] === 0x47 &&
|
||||
bytes[2] === 0x53 &&
|
||||
bytes[3] === 0x50) ||
|
||||
(bytes.length >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b)
|
||||
);
|
||||
}
|
||||
|
||||
function looksLikeErrorPayload(bytes: Uint8Array) {
|
||||
const head = new TextDecoder("utf-8", { fatal: false })
|
||||
.decode(bytes.slice(0, Math.min(bytes.length, 64)))
|
||||
.trimStart();
|
||||
return head.startsWith("<") || head.startsWith("{") || head.startsWith("[");
|
||||
}
|
||||
|
||||
function validateBytes(bytes: Uint8Array, kind: AssetKind) {
|
||||
if (bytes.byteLength <= 0 || looksLikeErrorPayload(bytes)) return false;
|
||||
if (kind === "glb") return hasGlbMagic(bytes);
|
||||
if (kind === "spz") return hasSpzMagic(bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function probeAsset(url: string, kind: AssetKind) {
|
||||
if (!isHttpUrl(url)) return false;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Range: "bytes=0-63" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
return validateBytes(bytes, kind);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadAsset(url: string, kind: AssetKind) {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { "User-Agent": "AI-Chatbot-3D-Qiniu-Repair/1.0" },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败(HTTP ${response.status})`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (!validateBytes(new Uint8Array(buffer), kind)) {
|
||||
throw new Error(`${kind.toUpperCase()} 文件内容无效`);
|
||||
}
|
||||
return {
|
||||
buffer,
|
||||
contentType: response.headers.get("content-type"),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveExtension(url: string, kind: AssetKind, contentType?: string | null) {
|
||||
if (kind === "glb") return "glb";
|
||||
if (kind === "spz") return "spz";
|
||||
const type = String(contentType || "").toLowerCase();
|
||||
if (type.includes("webp")) return "webp";
|
||||
if (type.includes("png")) return "png";
|
||||
if (type.includes("gif")) return "gif";
|
||||
try {
|
||||
const ext = new URL(url).pathname.split(".").pop()?.toLowerCase();
|
||||
if (ext && ["jpg", "jpeg", "png", "webp", "gif"].includes(ext)) return ext;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return "jpg";
|
||||
}
|
||||
|
||||
function replaceUrlsDeep(value: unknown, replacements: Map<string, string>): unknown {
|
||||
if (typeof value === "string") return replacements.get(value) || value;
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => replaceUrlsDeep(item, replacements));
|
||||
}
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as JsonRecord).map(([key, item]) => [
|
||||
key,
|
||||
replaceUrlsDeep(item, replacements),
|
||||
])
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function toStringRecord(value: unknown): StringRecord {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as JsonRecord)
|
||||
.map(([key, item]) => [key, normalizeUrl(item)] as const)
|
||||
.filter(([, item]) => isHttpUrl(item))
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [dbModule, schemaModule, storageModule, tripoClient, tripoServer] =
|
||||
await Promise.all([
|
||||
import("../../lib/db"),
|
||||
import("../../lib/db/schema"),
|
||||
import("../../lib/storage/service"),
|
||||
import("../../lib/tripo/client"),
|
||||
import("../../lib/tripo/server"),
|
||||
]);
|
||||
const { db } = dbModule;
|
||||
const { canvasNode, model3dAsset, storageConfig } = schemaModule;
|
||||
const { toDbS3UploadConfig, uploadFileWithS3Config } = storageModule;
|
||||
|
||||
const qiniuRows = await db
|
||||
.select()
|
||||
.from(storageConfig)
|
||||
.where(eq(storageConfig.type, "qiniu-kodo"))
|
||||
.orderBy(desc(storageConfig.isDefault), desc(storageConfig.updatedAt));
|
||||
const qiniuRow = qiniuRows.find((item) => item.isEnabled);
|
||||
const qiniuConfig = toDbS3UploadConfig(qiniuRow);
|
||||
if (!qiniuConfig || qiniuConfig.provider !== "qiniu-kodo") {
|
||||
throw new Error("未找到已启用的七牛云 Kodo 配置,已拒绝使用其他存储");
|
||||
}
|
||||
|
||||
let assets = await db
|
||||
.select()
|
||||
.from(model3dAsset)
|
||||
.where(eq(model3dAsset.status, "success"))
|
||||
.orderBy(desc(model3dAsset.createdAt));
|
||||
if (worldOnly) assets = assets.filter((asset) => asset.provider === "worldlabs-3d");
|
||||
if (tripoOnly) assets = assets.filter((asset) => asset.provider === "tripo-3d");
|
||||
if (limit) assets = assets.slice(0, limit);
|
||||
|
||||
const backupPath = path.join(
|
||||
process.cwd(),
|
||||
"backups",
|
||||
`model3d-assets-before-qiniu-repair-${new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-")}.json`
|
||||
);
|
||||
if (!dryRun) {
|
||||
await mkdir(path.dirname(backupPath), { recursive: true });
|
||||
await writeFile(backupPath, JSON.stringify(assets, null, 2));
|
||||
console.log(`[repair-3d] backup=${backupPath}`);
|
||||
}
|
||||
|
||||
const summary = {
|
||||
scanned: 0,
|
||||
repairedAssets: 0,
|
||||
repairedFiles: 0,
|
||||
failedFiles: 0,
|
||||
updatedNodes: 0,
|
||||
};
|
||||
|
||||
for (const asset of assets) {
|
||||
summary.scanned += 1;
|
||||
const payload =
|
||||
asset.assetPayload && typeof asset.assetPayload === "object"
|
||||
? ({ ...asset.assetPayload } as JsonRecord)
|
||||
: {};
|
||||
const replacements = new Map<string, string>();
|
||||
let nextModelUrl = normalizeUrl(asset.modelUrl) || null;
|
||||
let nextPreviewUrl = normalizeUrl(asset.previewImageUrl) || null;
|
||||
let freshTripoOutput: Record<string, unknown> | null = null;
|
||||
|
||||
const uploadRecoveredAsset = async (params: {
|
||||
currentUrl?: string | null;
|
||||
sourceUrl?: string | null;
|
||||
kind: AssetKind;
|
||||
label: string;
|
||||
}) => {
|
||||
const currentUrl = normalizeUrl(params.currentUrl);
|
||||
if (currentUrl && (await probeAsset(currentUrl, params.kind))) return currentUrl;
|
||||
|
||||
let sourceUrl = normalizeUrl(params.sourceUrl);
|
||||
if (
|
||||
asset.provider === "tripo-3d" &&
|
||||
(!sourceUrl || !(await probeAsset(sourceUrl, params.kind)))
|
||||
) {
|
||||
if (!freshTripoOutput) {
|
||||
const preferredProviderId = normalizeUrl(payload.providerId);
|
||||
const provider = await tripoServer.resolveTripoProviderConfigForTaskId(
|
||||
asset.taskId,
|
||||
preferredProviderId
|
||||
);
|
||||
const task = await tripoClient.tripoRequest<{
|
||||
output?: Record<string, unknown>;
|
||||
}>(provider, {
|
||||
method: "GET",
|
||||
path: `/task/${encodeURIComponent(asset.taskId)}`,
|
||||
});
|
||||
freshTripoOutput = task.data.output || {};
|
||||
}
|
||||
sourceUrl =
|
||||
params.kind === "glb"
|
||||
? normalizeUrl(tripoClient.pickTaskModelUrl(freshTripoOutput))
|
||||
: normalizeUrl(tripoClient.pickTaskPreviewImageUrl(freshTripoOutput));
|
||||
}
|
||||
|
||||
if (!sourceUrl || !(await probeAsset(sourceUrl, params.kind))) {
|
||||
throw new Error(`${params.label} 没有可恢复的源文件`);
|
||||
}
|
||||
if (dryRun) return sourceUrl;
|
||||
|
||||
const downloaded = await downloadAsset(sourceUrl, params.kind);
|
||||
const extension = resolveExtension(
|
||||
sourceUrl,
|
||||
params.kind,
|
||||
downloaded.contentType
|
||||
);
|
||||
const uploaded = await uploadFileWithS3Config(
|
||||
`repair-${asset.taskId}-${params.label}.${extension}`,
|
||||
downloaded.buffer,
|
||||
qiniuConfig
|
||||
);
|
||||
if (!(await probeAsset(uploaded.url, params.kind))) {
|
||||
throw new Error(`${params.label} 上传七牛云后校验失败`);
|
||||
}
|
||||
if (currentUrl) replacements.set(currentUrl, uploaded.url);
|
||||
if (sourceUrl) replacements.set(sourceUrl, uploaded.url);
|
||||
summary.repairedFiles += 1;
|
||||
console.log(
|
||||
`[repair-3d] repaired task=${asset.taskId} file=${params.label} bytes=${downloaded.buffer.byteLength}`
|
||||
);
|
||||
return uploaded.url;
|
||||
};
|
||||
|
||||
try {
|
||||
if (asset.provider === "worldlabs-3d") {
|
||||
nextModelUrl = await uploadRecoveredAsset({
|
||||
currentUrl: nextModelUrl,
|
||||
sourceUrl: normalizeUrl(payload.sourceMeshUrl),
|
||||
kind: "glb",
|
||||
label: "mesh",
|
||||
}).catch((error) => {
|
||||
summary.failedFiles += 1;
|
||||
console.warn(`[repair-3d] ${asset.taskId} mesh: ${String(error)}`);
|
||||
return nextModelUrl;
|
||||
});
|
||||
|
||||
const nextThumbnailUrl = await uploadRecoveredAsset({
|
||||
currentUrl: normalizeUrl(payload.thumbnailUrl),
|
||||
sourceUrl: normalizeUrl(payload.sourceThumbnailUrl),
|
||||
kind: "image",
|
||||
label: "thumbnail",
|
||||
}).catch((error) => {
|
||||
summary.failedFiles += 1;
|
||||
console.warn(`[repair-3d] ${asset.taskId} thumbnail: ${String(error)}`);
|
||||
return normalizeUrl(payload.thumbnailUrl) || null;
|
||||
});
|
||||
const nextPanoUrl = await uploadRecoveredAsset({
|
||||
currentUrl: normalizeUrl(payload.panoUrl),
|
||||
sourceUrl: normalizeUrl(payload.sourcePanoUrl),
|
||||
kind: "image",
|
||||
label: "pano",
|
||||
}).catch((error) => {
|
||||
summary.failedFiles += 1;
|
||||
console.warn(`[repair-3d] ${asset.taskId} pano: ${String(error)}`);
|
||||
return normalizeUrl(payload.panoUrl) || null;
|
||||
});
|
||||
|
||||
const currentSpzUrls = toStringRecord(payload.spzUrls);
|
||||
const sourceSpzUrls = toStringRecord(payload.sourceSpzUrls);
|
||||
const nextSpzUrls: StringRecord = {};
|
||||
for (const [key, sourceUrl] of Object.entries(sourceSpzUrls)) {
|
||||
const repairedUrl = await uploadRecoveredAsset({
|
||||
currentUrl: currentSpzUrls[key],
|
||||
sourceUrl,
|
||||
kind: "spz",
|
||||
label: `spz-${key}`,
|
||||
}).catch((error) => {
|
||||
summary.failedFiles += 1;
|
||||
console.warn(`[repair-3d] ${asset.taskId} spz-${key}: ${String(error)}`);
|
||||
return currentSpzUrls[key] || null;
|
||||
});
|
||||
if (repairedUrl) nextSpzUrls[key] = repairedUrl;
|
||||
}
|
||||
for (const [key, currentUrl] of Object.entries(currentSpzUrls)) {
|
||||
if (!nextSpzUrls[key] && (await probeAsset(currentUrl, "spz"))) {
|
||||
nextSpzUrls[key] = currentUrl;
|
||||
}
|
||||
}
|
||||
|
||||
payload.meshUrl = nextModelUrl;
|
||||
payload.thumbnailUrl = nextThumbnailUrl;
|
||||
payload.panoUrl = nextPanoUrl;
|
||||
payload.spzUrls = nextSpzUrls;
|
||||
nextPreviewUrl = nextThumbnailUrl || nextPanoUrl || nextPreviewUrl;
|
||||
const allSpzRecovered =
|
||||
Object.keys(sourceSpzUrls).length > 0 &&
|
||||
Object.keys(nextSpzUrls).length === Object.keys(sourceSpzUrls).length;
|
||||
payload.assetsArchivingPending = !allSpzRecovered;
|
||||
payload.storageArchivedAt = allSpzRecovered ? new Date().toISOString() : null;
|
||||
payload.spzArchiveValidatedAt = allSpzRecovered
|
||||
? new Date().toISOString()
|
||||
: null;
|
||||
} else if (asset.provider === "tripo-3d") {
|
||||
nextModelUrl = await uploadRecoveredAsset({
|
||||
currentUrl: nextModelUrl,
|
||||
sourceUrl: normalizeUrl(payload.sourceModelUrl),
|
||||
kind: "glb",
|
||||
label: "model",
|
||||
});
|
||||
nextPreviewUrl = await uploadRecoveredAsset({
|
||||
currentUrl: nextPreviewUrl,
|
||||
sourceUrl: normalizeUrl(payload.sourcePreviewImageUrl),
|
||||
kind: "image",
|
||||
label: "preview",
|
||||
});
|
||||
payload.storageArchivedAt = new Date().toISOString();
|
||||
payload.assetsArchivingPending = false;
|
||||
}
|
||||
} catch (error) {
|
||||
summary.failedFiles += 1;
|
||||
console.warn(`[repair-3d] task=${asset.taskId} skipped: ${String(error)}`);
|
||||
}
|
||||
|
||||
if (replacements.size === 0 || dryRun) continue;
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(model3dAsset)
|
||||
.set({
|
||||
modelUrl: nextModelUrl,
|
||||
previewImageUrl: nextPreviewUrl,
|
||||
assetPayload: replaceUrlsDeep(payload, replacements) as JsonRecord,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(model3dAsset.id, asset.id));
|
||||
|
||||
const nodes = await tx
|
||||
.select({ id: canvasNode.id, data: canvasNode.data })
|
||||
.from(canvasNode)
|
||||
.where(eq(canvasNode.projectId, asset.projectId));
|
||||
for (const node of nodes) {
|
||||
const serialized = JSON.stringify(node.data || {});
|
||||
if (!serialized.includes(asset.taskId) && ![...replacements.keys()].some((url) => serialized.includes(url))) {
|
||||
continue;
|
||||
}
|
||||
await tx
|
||||
.update(canvasNode)
|
||||
.set({
|
||||
data: replaceUrlsDeep(node.data, replacements),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(canvasNode.id, node.id));
|
||||
summary.updatedNodes += 1;
|
||||
}
|
||||
});
|
||||
summary.repairedAssets += 1;
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({ dryRun, summary }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -41,3 +41,15 @@ test("production deploy stages the main build and retains active static chunks",
|
||||
);
|
||||
expect(deployScript).toContain('curl -fsS "$MAIN_INTERNAL_URL/ping"');
|
||||
});
|
||||
|
||||
test("atomic production deploy rejects the local E2E fallback switch", () => {
|
||||
const deployScript = readFileSync(
|
||||
resolve(process.cwd(), "scripts/deploy-production-atomic.sh"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
expect(deployScript).toContain("CANVAS_E2E_LOCAL");
|
||||
expect(deployScript).toContain(
|
||||
"Refusing production deployment while CANVAS_E2E_LOCAL=true."
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user