Compare commits

..
Author SHA1 Message Date
suyx 5e8a491f2a fix(POSTV1-TEMPLATE-PLACEMENT-18): 修复文字模板坐标与描边解析
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 18:25:41 +08:00
suyx ff115f70ca fix(POSTV1-TEMPLATE-FIDELITY-17): 修复模板图案还原与预览状态
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 17:28:45 +08:00
suyx a70b9fc241 feat(POSTV1-ASSET-ALL-16): 完整还原归档文字模板素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 16:20:24 +08:00
suyx ef6950c5df test(POSTV1-ASSET-ALL-16): 完成全量素材集成回归
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 12:19:21 +08:00
suyx 2cd85cd7cd feat(POSTV1-ASSET-ALL-16): 在编辑器接入全部复杂素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:55:06 +08:00
suyx 9de0d2a63c feat(POSTV1-ASSET-ALL-16): 生成并部署全量复杂素材
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:39:46 +08:00
suyx fd4cd277cc feat(POSTV1-ASSET-ALL-16): 发布完整素材注册清单
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:30:09 +08:00
suyx 910e32917f test(POSTV1-integration): 增加界面版本回退门禁
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 11:04:03 +08:00
suyx 83fe57f319 fix(POSTV1-editor): 修复多选拖动与底图调整
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:53:38 +08:00
suyx 51d613f459 fix(POSTV1-browser): 同时支持 Chrome 150 与 151
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:52:31 +08:00
suyx 3779cfbadc fix(POSTV1-runtime): 改善后台启动与状态窗口可见性
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-06 10:51:08 +08:00
suyx e8ce1d9031 fix(POSTV1-11): 修复生成提交的会话令牌轮换
Dada P0-A isolated Windows CI / validate-and-package (push) Waiting to run
2026-08-05 22:22:49 +08:00
suyx edbefcc738 feat(POSTV1-10): 增加统一交互反馈
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 19:19:33 +08:00
suyx 1713607572 fix(POSTV1-09): 展示项目生成图片
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 19:00:07 +08:00
suyx e4aec01ea6 fix(POSTV1-08): 修复文字拖动闪烁与自动保存
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 18:47:11 +08:00
suyx 468bb5579d fix(POSTV1-07): 自动关闭编辑器操作提示
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 18:01:03 +08:00
suyx 9d2aa879e9 fix(POSTV1-07): 固定画布布局与文字贴纸选择
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 17:59:53 +08:00
suyx d9e39702e0 fix(POSTV1-06): 恢复贴纸与字体运行时资源链路
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 16:58:45 +08:00
suyx 7de633d4c9 fix(POSTV1-05): 修复生成请求与 Worker 重入
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 16:04:11 +08:00
suyx b995662388 fix(POSTV1-04): 修复生成服务运行时装配
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 15:36:47 +08:00
suyx 92434aec17 feat(POSTV1-03): 增加本机测试直达入口
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 14:53:20 +08:00
suyx f7cac5dabf fix(POSTV1-02): 修复便携网页静态资源类型
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 14:07:42 +08:00
suyx a7f62adad4 fix(POSTV1-02): 确保AI探测输出后退出
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 13:26:44 +08:00
suyx b1f143c238 feat(POSTV1-02): 增加AI真实生成探测
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 13:14:40 +08:00
suyx 99fd3b1802 fix(POSTV1-02): 修复便携版网页入口门禁
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:58:28 +08:00
suyx fd0003a804 fix(POSTV1-02): 补齐便携包Worker运行依赖
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:46:59 +08:00
suyx bfdfe44f87 test(POSTV1-02): 修正便携包AI适配器检查
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:34:46 +08:00
suyx cbb7f658a3 fix(POSTV1-02): 接通真实AI生成链路
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:29:14 +08:00
suyx 43d946bb5c fix(POSTV1-02): 修复便携包首次启动链路
Dada P0-A isolated Windows CI / validate-and-package (push) Canceled after 0s
2026-08-05 12:00:23 +08:00
95 changed files with 52570 additions and 494 deletions
+2 -1
View File
@@ -3,7 +3,8 @@
"browsers": [
{
"brand": "Google Chrome",
"fullVersion": "150.0.7871.187"
"fullVersion": "150.0.7871.187",
"supportedMajorVersions": [150, 151]
},
{
"brand": "Microsoft Edge",
+8 -5
View File
@@ -16,7 +16,7 @@ const forbiddenDiagnosticPatterns = [
/https?:\/\//i,
];
const safePauseReasons = new Set([
"asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
"asset_manifest_invalid", "asset_root_missing", "asset_root_state_missing", "balance_insufficient", "configured_disabled", "contract_blocked",
"contract_unverified", "gateway_balance_insufficient", "gateway_paused", "health_check_failed",
"model_disabled", "provider_unavailable", "quota_exhausted", "service_state_missing", "unknown",
"worker_degraded", "worker_state_missing", "worker_stopped",
@@ -151,10 +151,13 @@ export function createAdminDiagnosticsProvider(input: {
const system: AdminDiagnosticsResponse["system"] = {
api_status: "ready",
app_version: input.appVersion ?? input.browserSupportRelease?.appVersion ?? "0.0.0",
browser_support: (input.browserSupportRelease?.browsers ?? []).map((browser) => ({
brand: browser.brand,
major: Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10),
})).filter((browser) => Number.isSafeInteger(browser.major) && browser.major > 0),
browser_support: (input.browserSupportRelease?.browsers ?? []).flatMap((browser) => {
const majors = browser.supportedMajorVersions
?? [Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10)];
return majors
.map((major) => ({ brand: browser.brand, major }))
.filter((entry) => Number.isSafeInteger(entry.major) && entry.major > 0);
}),
worker_status: services.services.find((service) => service.service_id === "worker")?.status === "active"
? "ready"
: services.services.find((service) => service.service_id === "worker")?.status === "unavailable"
+72 -4
View File
@@ -1,6 +1,6 @@
import { randomBytes, randomUUID } from "node:crypto";
import { createReadStream, existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { extname, resolve } from "node:path";
import {
AccountDeletionCompleteRequestSchema,
@@ -218,6 +218,24 @@ import type { ManagedStorage } from "./managed-storage.js";
import { PrivateContentError, PrivateContentService } from "./private-content.js";
import { assertSafeAdminDiagnostics, assertSafeAdminServicesStorage } from "./admin-state.js";
const productAssetContentTypes: Readonly<Record<string, string>> = {
".css": "text/css; charset=utf-8",
".jpeg": "image/jpeg",
".jpg": "image/jpeg",
".js": "text/javascript; charset=utf-8",
".json": "application/json; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
};
function productAssetContentType(path: string) {
return productAssetContentTypes[extname(path).toLowerCase()] ?? "application/octet-stream";
}
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
dependencies: [],
@@ -238,12 +256,14 @@ export interface CreateAppOptions {
assetReleases?: AssetReleaseReader;
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
browserGate?: boolean;
productIndexHtml?: string;
browserSupportRelease?: BrowserSupportRelease;
browserSupportSecret?: Buffer;
credits?: CreditService;
eventHub?: EventHub;
generations?: GenerationSubmissionService;
latestExports?: LatestExportService;
localTestAuth?: boolean;
models?: ModelConfigurationService;
networkBoundary?: NetworkBoundaryOptions;
publicAssets?: PublicAssetResolver;
@@ -273,7 +293,7 @@ const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html")
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
const productWebRoot = resolve(process.env.DADA_WEB_ROOT ?? "apps/web/dist");
const productIndexHtml = existsSync(resolve(productWebRoot, "index.html"))
const packagedProductIndexHtml = existsSync(resolve(productWebRoot, "index.html"))
? readFileSync(resolve(productWebRoot, "index.html"), "utf8")
: undefined;
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
@@ -714,6 +734,7 @@ export async function createApp(options: CreateAppOptions = {}) {
)
: undefined);
const browserGate = options.browserGate ?? true;
const productIndexHtml = options.productIndexHtml ?? packagedProductIndexHtml;
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
const browserSupportRelease = options.browserSupportRelease;
const app = Fastify({
@@ -905,15 +926,23 @@ export async function createApp(options: CreateAppOptions = {}) {
});
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
app.get(route, { schema: { hide: true } }, async (request, reply) => {
reply.type("text/html; charset=utf-8");
return productIndexHtml ?? supportGateHtml;
if (!browserGate) return productIndexHtml ?? supportGateHtml;
const verified = verifyBrowserSupportCookie({
cookieHeader: headerValue(request.headers.cookie),
release: browserSupportRelease,
secChUa: headerValue(request.headers["sec-ch-ua"]),
secret: browserSupportSecret,
});
return verified.supported && productIndexHtml ? productIndexHtml : supportGateHtml;
});
}
app.get("/assets/*", { schema: { hide: true } }, async (request, reply) => {
const relativePath = decodeURIComponent(request.url.split("?", 1)[0]!.slice("/assets/".length));
const assetPath = resolve(productWebRoot, "assets", relativePath);
if (!assetPath.startsWith(resolve(productWebRoot, "assets")) || !existsSync(assetPath)) return reply.code(404).send();
reply.type(productAssetContentType(assetPath));
return reply.send(readFileSync(assetPath));
});
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
@@ -2047,6 +2076,45 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
if (options.localTestAuth && options.registration) {
app.get(
"/api/v1/auth/local-test",
{ schema: { hide: true } },
async () => ({ available: true }),
);
app.post(
"/api/v1/auth/local-test",
{ schema: { hide: true } },
async (request, reply) => {
try {
const result = options.registration!.createLocalTestSession();
reply.header(
"Set-Cookie",
`${userSessionCookieName}=${result.sessionToken}; Max-Age=${30 * 24 * 60 * 60}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
audience: result.audience,
credits: {
available_balance: result.credits.availableBalance,
reserved_balance: result.credits.reservedBalance,
},
session_expires_at: new Date(result.sessionExpiresAt).toISOString(),
status: result.status,
user: {
creator_name: result.user.creatorName,
role: result.user.role,
social_id: result.user.socialId,
status: result.user.status,
user_id: result.user.userId,
},
};
} catch (error) {
return registrationFailure(reply, request.id, error);
}
},
);
}
app.post(
"/api/v1/auth/login/complete",
{
+28 -7
View File
@@ -43,7 +43,7 @@ export const BrowserSupportSuccessSchema = Type.Object(
app_version: Type.String({ maxLength: 80 }),
browser: SupportedBrowserSummarySchema,
status: Type.Literal("supported"),
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 8 }),
},
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
);
@@ -57,6 +57,7 @@ export interface BrowserSupportRelease {
browsers: ReadonlyArray<{
brand: SupportedBrand;
fullVersion: string;
supportedMajorVersions?: ReadonlyArray<number>;
}>;
}
@@ -115,7 +116,14 @@ function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
if (!release) return [];
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
return release.browsers.flatMap(({ brand, fullVersion, supportedMajorVersions }) => {
const majors = supportedMajorVersions ?? [major(fullVersion)!];
return majors.map((supportedMajor) => ({ brand, major: supportedMajor }));
});
}
function acceptedMajorVersions(browser: BrowserSupportRelease["browsers"][number]) {
return browser.supportedMajorVersions ?? [major(browser.fullVersion)!];
}
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
@@ -126,13 +134,26 @@ export function validateBrowserSupportRelease(value: unknown): value is BrowserS
}
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
const brands = new Set(release.browsers.map(({ brand }) => brand));
const supportedMajorCount = release.browsers.reduce(
(count, browser) => count + (browser.supportedMajorVersions?.length ?? 1),
0,
);
return (
brands.size === 2 &&
brands.has("Google Chrome") &&
brands.has("Microsoft Edge") &&
release.browsers.every(
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
)
supportedMajorCount <= 8 &&
release.browsers.every(({ brand, fullVersion, supportedMajorVersions }) => {
if (!supportedBrands.has(brand) || !fullVersionPattern.test(fullVersion)) return false;
const baselineMajor = major(fullVersion);
if (!baselineMajor) return false;
if (supportedMajorVersions === undefined) return true;
return supportedMajorVersions.length > 0
&& supportedMajorVersions.length <= 8
&& supportedMajorVersions.every((value: number) => Number.isSafeInteger(value) && value >= 1)
&& new Set(supportedMajorVersions).size === supportedMajorVersions.length
&& supportedMajorVersions.includes(baselineMajor);
})
);
}
@@ -188,7 +209,7 @@ export function checkBrowserSupport(
}
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
if (!supported || !acceptedMajorVersions(supported).includes(fullIdentity.major)) {
return { reason: "version_unsupported", supported: false };
}
return { identity: fullIdentity, supported: true };
@@ -268,7 +289,7 @@ export function verifyBrowserSupportCookie(input: {
return { reason: "identity_unavailable" as const, supported: false as const };
}
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
if (!supported || currentIdentity.major !== payload.major || !acceptedMajorVersions(supported).includes(currentIdentity.major)) {
return { reason: "version_unsupported" as const, supported: false as const };
}
return { identity: currentIdentity, supported: true as const };
+22 -9
View File
@@ -19,7 +19,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof import("better-sqlite3");
const assetIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const fixedDirectories = [
"db",
"content/references",
@@ -33,6 +33,12 @@ const fixedDirectories = [
"logs/supervisor",
] as const;
export function ensureLocalDataRuntimeDirectories(dataRoot: string) {
for (const directory of fixedDirectories) {
mkdirSync(join(resolve(dataRoot), directory), { recursive: true });
}
}
export const DATA_TRANSFER_POLICY = {
allowed_downloads: ["original_generation", "jpg", "png"],
application_backup: false,
@@ -76,13 +82,21 @@ export function readConfiguredLocalDataRoot(configFile = defaultInstanceConfigPa
return resolve(candidate);
}
export function readConfiguredAssetRoot(configFile = defaultInstanceConfigPath()) {
const configuration = JSON.parse(readFileSync(configFile, "utf8")) as Record<string, unknown>;
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
throw new Error("asset_root_configuration_invalid");
}
return resolve(configuration.asset_root);
}
export interface ValidatedReadOnlyAssetRoot {
absolute_root: string;
ok: true;
root_ref: string;
}
interface PublicAssetEntry {
export interface PublicAssetEntry {
assetId: string;
mimeType: string;
relativePath: string;
@@ -219,9 +233,7 @@ export function initializeLocalDataRoot(input: {
const createdRoot = !existsSync(validation.normalized_path);
try {
for (const directory of fixedDirectories) {
mkdirSync(join(validation.normalized_path, directory), { recursive: true });
}
ensureLocalDataRuntimeDirectories(validation.normalized_path);
openInstanceDatabase(join(validation.normalized_path, "db", "dada.sqlite3"));
const configuration: InstanceConfiguration = {
data_root: validation.normalized_path,
@@ -313,18 +325,19 @@ export function createPublicAssetResolver(input: {
const roots = new Map(input.roots.map((root) => [root.root_ref, root.absolute_root]));
const entries = new Map<string, PublicAssetEntry>();
for (const entry of input.entries) {
if (!assetIdPattern.test(entry.assetId) || entries.has(entry.assetId)) throw new Error("asset_id_invalid");
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
if (!assetIdPattern.test(entry.assetId) || entries.has(key)) throw new Error("asset_id_invalid");
if (!roots.has(entry.rootRef)) throw new Error("asset_root_unvalidated");
if (!/^[a-z0-9][a-z0-9._-]{0,79}$/i.test(entry.resourceVersion)) throw new Error("resource_version_invalid");
if (!/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)) throw new Error("mime_type_invalid");
entries.set(entry.assetId, { ...entry });
entries.set(key, { ...entry });
}
return {
read(resourceVersion, assetId) {
if (!assetIdPattern.test(assetId)) return undefined;
const entry = entries.get(assetId);
if (!entry || entry.resourceVersion !== resourceVersion) return undefined;
const entry = entries.get(`${resourceVersion}\u0000${assetId}`);
if (!entry) return undefined;
const root = roots.get(entry.rootRef);
if (!root) return undefined;
let path: string;
+34 -3
View File
@@ -5,7 +5,7 @@ import { registrationNotice } from "@dada/shared-contracts";
import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js";
import { defaultInstanceConfigPath, readConfiguredLocalDataRoot } from "./local-data-root.js";
import { defaultInstanceConfigPath, ensureLocalDataRuntimeDirectories, readConfiguredLocalDataRoot, type PublicAssetResolver } from "./local-data-root.js";
import { ManagedStorage } from "./managed-storage.js";
import { LatestExportService } from "./latest-exports.js";
import { CreditService } from "./credits.js";
@@ -16,10 +16,16 @@ import { MockResendAdapter } from "./resend-adapter.js";
import { readSecureConfigCandidate } from "./secure-config.js";
import { StructuredJsonlLogger } from "./structured-log.js";
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
import { ModelConfigurationService } from "./model-configuration.js";
import { GenerationSubmissionService } from "./generation-submission.js";
import {
GenerationModelConfigurationCatalog,
ModelConfigurationService,
portableRuntimeModelCandidates,
} from "./model-configuration.js";
import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js";
import { StickerReleaseService } from "./sticker-releases.js";
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
import { loadConfiguredRuntimeAssets, type RuntimeAssetState } from "./runtime-assets.js";
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
let registration: RegistrationService | undefined;
@@ -28,18 +34,31 @@ let credits: CreditService | undefined;
let storage: ManagedStorage | undefined;
let latestExports: LatestExportService | undefined;
let models: ModelConfigurationService | undefined;
let generations: GenerationSubmissionService | undefined;
let recentAssets: RecentAssetService | undefined;
let stickers: StickerReleaseService | undefined;
let publicAssets: PublicAssetResolver | undefined;
let assetRootState: RuntimeAssetState | undefined;
let amap: AmapAdapter = new MockAmapAdapter();
let localTestAuth = false;
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
if (credentialChannelEnabled) {
const clients = initializeApiCredentialClients(await receiveApiCredentials());
try {
amap = clients.amap;
localTestAuth = !clients.resendConfigured;
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
.update(`Dada/P0A/${purpose}/v1`, "utf8")
.digest();
const dataRoot = readConfiguredLocalDataRoot(instanceConfigPath);
ensureLocalDataRuntimeDirectories(dataRoot);
const runtimeAssets = loadConfiguredRuntimeAssets({
configFile: instanceConfigPath,
dataRoot,
trustedManifestPath: resolve("asset-metadata", "manifest.json"),
});
publicAssets = runtimeAssets.publicAssets;
assetRootState = runtimeAssets.state;
const databasePath = join(dataRoot, "db", "dada.sqlite3");
registration = new RegistrationService({
adminAllowlistPepper: Buffer.from(clients.adminAllowlistPepper),
@@ -55,7 +74,12 @@ if (credentialChannelEnabled) {
storage = new ManagedStorage({ dataRoot, databasePath });
stickers = new StickerReleaseService({ databasePath, storage });
latestExports = new LatestExportService({ databasePath, storage });
models = new ModelConfigurationService({ database: registration.database });
models = new ModelConfigurationService({ database: registration.database, seedCandidates: portableRuntimeModelCandidates });
generations = new GenerationSubmissionService({
credits,
models: new GenerationModelConfigurationCatalog(models),
storage,
});
recentAssets = new RecentAssetService({ database: registration.database });
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
} catch (error) {
@@ -65,6 +89,8 @@ if (credentialChannelEnabled) {
stickers = undefined;
latestExports?.close();
latestExports = undefined;
generations?.close();
generations = undefined;
storage?.close();
storage = undefined;
credits?.close();
@@ -85,6 +111,7 @@ const adminServicesStorage = registration
database: registration.database,
...(models ? { models } : {}),
...(storage ? { storage } : {}),
...(assetRootState ? { assetRoot: assetRootState } : {}),
})
: undefined;
const adminDiagnostics = adminServicesStorage
@@ -99,9 +126,12 @@ const app = await createApp({
amap,
...(browserSupportRelease ? { browserSupportRelease } : {}),
...(credits ? { credits } : {}),
...(generations ? { generations } : {}),
...(latestExports ? { latestExports } : {}),
...(registration && localTestAuth ? { localTestAuth: true } : {}),
...(models ? { models } : {}),
...(projects ? { projects } : {}),
...(publicAssets ? { publicAssets } : {}),
...(registration ? { registration } : {}),
...(recentAssets ? { recentAssets } : {}),
...(stickers ? { stickers } : {}),
@@ -121,6 +151,7 @@ if (controlPipeIndex >= 0) {
await app.close();
amap.dispose?.();
latestExports?.close();
generations?.close();
credits?.close();
projects?.close();
registration?.close();
+90 -6
View File
@@ -2,6 +2,8 @@ import { randomUUID, createHash } from "node:crypto";
import type BetterSqlite3 from "better-sqlite3";
import { serializeAuditSummary, auditRetentionMilliseconds } from "./audit-policy.js";
import type { GenerationModelCatalog, GenerationModelSnapshot } from "./generation-submission.js";
import { projectRatios } from "./projects.js";
export const modelIds = [
"gemini-3.1-flash-image-preview",
@@ -59,6 +61,45 @@ export interface ModelConfigurationView {
models: ModelConfigView[];
}
type ReadableModelConfiguration = Pick<ModelConfigurationService, "read">;
function generationRuntimeReason(reason: ModelRuntimeReason): GenerationModelSnapshot["runtimeAvailability"]["reason"] {
if (reason === "gateway_balance_insufficient") return reason;
if (reason === "contract_unverified" || reason === "contract_blocked") return "gateway_contract_invalid";
if (reason === "available") return null;
return "model_disabled";
}
export class GenerationModelConfigurationCatalog implements GenerationModelCatalog {
constructor(private readonly models: ReadableModelConfiguration) {}
readModel(modelId: string): GenerationModelSnapshot | undefined {
const configuration = this.models.read();
const model = configuration.models.find((entry) => entry.model_id === modelId);
if (!model) return undefined;
const supportedRatios = projectRatios.filter((ratio) => model.supported_ratios.includes(ratio));
return {
configSetVersion: configuration.config_set_version,
configVersion: model.config_version,
contractValidationStatus: model.contract_validation_status === "verified" ? "verified" : "unverified",
creditCost: model.credit_cost,
enabled: model.enabled,
modelId: model.model_id,
promptMaxLength: model.prompt_max_length,
referenceLimits: {
maxFileBytes: model.reference_limits.max_file_bytes,
maxFiles: model.reference_limits.max_files,
maxTotalBytes: model.reference_limits.max_total_bytes,
},
runtimeAvailability: {
availableForNewJobs: model.runtime_availability.available_for_new_jobs,
reason: generationRuntimeReason(model.runtime_availability.reason),
},
supportedRatios,
};
}
}
export class ModelConfigurationError extends Error {
constructor(
readonly code:
@@ -111,7 +152,7 @@ const defaultErrorMapping: Record<string, string> = {
upstream_timeout: "upstream_timeout",
};
const seedCandidates: ModelConfigCandidate[] = [
const defaultSeedCandidates: ModelConfigCandidate[] = [
{
model_id: modelIds[0], display_name: "Gemini 3.1 Flash Image Preview", enabled: true, is_default: true,
recommendation_priority: 1, route_profile: { endpoint: "https://mock.invalid/v1/images", mode: "sync" },
@@ -138,6 +179,42 @@ const seedCandidates: ModelConfigCandidate[] = [
},
];
export const portableRuntimeModelCandidates: ModelConfigCandidate[] = [
{
...defaultSeedCandidates[0]!,
display_name: "Gemini 3.1 Flash Image",
route_profile: {
endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions",
mode: "sync",
protocol_version: "gemini-openai-chat-v1",
provider_model_id: "gemini-3.1-flash-image",
},
gateway_account_ref: "oneapi-intelligrow-test",
contract_validation_status: "verified",
contract_evidence_ref: "contract:wp7-02:gemini-3.1-flash-image:v7",
},
{
...defaultSeedCandidates[1]!,
enabled: false,
route_profile: { endpoint: "https://oneapi.intelligrow.cn/unsupported", mode: "disabled" },
gateway_account_ref: "oneapi-intelligrow-test",
contract_validation_status: "unverified",
contract_evidence_ref: null,
},
{
...defaultSeedCandidates[2]!,
route_profile: {
endpoint: "https://oneapi.intelligrow.cn/v1/images/generations",
mode: "sync",
protocol_version: "openai-images-v1",
reference_endpoint: "https://oneapi.intelligrow.cn/v1/images/edits",
},
gateway_account_ref: "oneapi-intelligrow-test",
contract_validation_status: "verified",
contract_evidence_ref: "contract:wp7-02:gpt-image-2:v2",
},
];
function stableJson(value: unknown): string {
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
if (value && typeof value === "object") {
@@ -207,17 +284,20 @@ export interface ModelConfigurationServiceOptions {
clock?: () => number;
database: BetterSqlite3.Database;
onChanged?: (configSetVersion: number) => void;
seedCandidates?: ModelConfigCandidate[];
}
export class ModelConfigurationService {
readonly database: BetterSqlite3.Database;
readonly #clock: () => number;
readonly #onChanged: ((configSetVersion: number) => void) | undefined;
readonly #seedCandidates: ModelConfigCandidate[];
constructor(options: ModelConfigurationServiceOptions) {
this.database = options.database;
this.#clock = options.clock ?? Date.now;
this.#onChanged = options.onChanged;
this.#seedCandidates = structuredClone(options.seedCandidates ?? defaultSeedCandidates);
this.ensureSchema();
}
@@ -503,7 +583,7 @@ export class ModelConfigurationService {
const current = this.database.prepare("SELECT config_set_id FROM model_config_current WHERE singleton = 1").get() as { config_set_id: string } | undefined;
if (current) return;
const seed = this.database.transaction(() => {
validateModelConfigurationCandidateSet(seedCandidates);
validateModelConfigurationCandidateSet(this.#seedCandidates);
const now = this.#clock();
const setId = randomUUID();
this.database.prepare("INSERT INTO model_config_sets (config_set_id, config_set_version, created_at, created_by) VALUES (?, 1, ?, 'system_seed')")
@@ -520,7 +600,9 @@ export class ModelConfigurationService {
INSERT INTO model_config_set_members (config_set_id, model_id, config_version, enabled, is_default, recommendation_priority)
VALUES (?, ?, 1, ?, ?, ?)
`);
for (const candidate of seedCandidates) {
for (const candidate of this.#seedCandidates) {
const contractStatus = candidate.contract_validation_status ?? "unverified";
const contractEvidenceRef = contractStatus === "unverified" ? null : candidate.contract_evidence_ref ?? null;
const routeProfileId = profileRef("route", candidate.route_profile);
const errorMappingProfileId = profileRef("error", candidate.error_mapping_profile);
this.database.prepare("INSERT OR IGNORE INTO gateway_route_profiles (route_profile_id, profile_json, created_at) VALUES (?, ?, ?)")
@@ -530,12 +612,14 @@ export class ModelConfigurationService {
insertVersion.run(candidate.model_id, candidate.display_name, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0,
candidate.recommendation_priority, routeProfileId, stableJson(candidate.route_profile), candidate.gateway_account_ref,
errorMappingProfileId, stableJson(candidate.error_mapping_profile), candidate.credit_cost, stableJson(candidate.supported_ratios), stableJson(candidate.reference_limits),
candidate.prompt_max_length, candidate.safety_source, "unverified", null, fingerprint(candidate), now);
candidate.prompt_max_length, candidate.safety_source, contractStatus, contractEvidenceRef, fingerprint(candidate), now);
insertMember.run(setId, candidate.model_id, candidate.enabled ? 1 : 0, candidate.is_default ? 1 : 0, candidate.recommendation_priority);
const available = candidate.enabled && contractStatus === "verified";
const runtimeReason = !candidate.enabled ? "configured_disabled" : available ? "available" : "contract_unverified";
this.database.prepare(`
INSERT INTO model_runtime_availability (model_id, available_for_new_jobs, reason, checked_at, runtime_availability_version)
VALUES (?, 0, 'contract_unverified', ?, 0)
`).run(candidate.model_id, now);
VALUES (?, ?, ?, ?, 0)
`).run(candidate.model_id, available ? 1 : 0, runtimeReason, now);
}
this.database.prepare("INSERT INTO model_config_current (singleton, config_set_id) VALUES (1, ?)").run(setId);
});
+55
View File
@@ -38,6 +38,7 @@ export interface RegistrationTransactionEvent {
| "registration_send"
| "registration_complete"
| "registration_send_compensation"
| "local_test_session"
| "login_send"
| "login_complete"
| "admin_login_send"
@@ -654,6 +655,60 @@ export class RegistrationService {
return outcome;
}
createLocalTestSession(): LoginCompleteResult {
const now = this.options.clock();
return this.runImmediate("local_test_session", () => {
const registrationId = "local-test-user-v1";
const existing = this.database.prepare(`
SELECT user_id, role, status FROM users WHERE registration_id = ?
`).get(registrationId) as {
role: "user" | "super_admin";
status: "active" | "suspended" | "deleted";
user_id: string;
} | undefined;
if (existing) {
if (existing.role !== "user" || existing.status !== "active") {
throw new RegistrationError("AUTH_ENTRY_REJECTED", "account_suspended");
}
const session = this.insertSession(existing.user_id, "user", now);
return {
outcome: "committed",
value: this.loginResult(this.readCompletedRegistration(existing.user_id, session.sessionId)),
};
}
const userId = randomUUID();
this.database.prepare(`
INSERT INTO users (
user_id, normalized_email, role, status, counts_toward_stage_limit,
registration_id, created_at
) VALUES (?, 'local-test-user@dada.invalid', 'user', 'active', 0, ?, ?)
`).run(userId, registrationId, now);
this.database.prepare(`
INSERT INTO user_profiles (
user_id, creator_name, social_id, private_content_notice_version,
private_content_notice_acknowledged_at
) VALUES (?, '本机测试用户', '@dada_local_test', NULL, NULL)
`).run(userId);
this.database.prepare(`
INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at)
VALUES (?, 10, 0, ?)
`).run(userId, now);
this.database.prepare(`
INSERT INTO credit_ledger (
ledger_id, user_id, operation_key, entry_type, amount,
available_before, available_after, reserved_before, reserved_after, created_at
) VALUES (?, ?, 'local-test-registration:v1', 'registration_grant', 10, 0, 10, 0, 0, ?)
`).run(randomUUID(), userId, now);
const session = this.insertSession(userId, "user", now);
return {
outcome: "committed",
value: this.loginResult(this.readCompletedRegistration(userId, session.sessionId)),
};
});
}
applySecureConfig(candidate: SecureConfigCandidate) {
const now = this.options.clock();
const fail = (reason: string): never => {
+93
View File
@@ -0,0 +1,93 @@
import { createHash } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import {
createPublicAssetResolver,
readConfiguredAssetRoot,
validateReadOnlyAssetRoot,
type PublicAssetEntry,
type PublicAssetResolver,
} from "./local-data-root.js";
const rootRef = "p0a_runtime_assets";
const schemaVersion = "DadaRuntimeAssets/v1";
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
const shaPattern = /^[a-f0-9]{64}$/i;
export interface RuntimeAssetState {
checked_at: string;
configured: boolean;
pause_reason: "asset_manifest_invalid" | "asset_root_missing" | "asset_root_state_missing" | null;
status: "active" | "unavailable";
}
export interface LoadedRuntimeAssets {
publicAssets?: PublicAssetResolver;
state: RuntimeAssetState;
}
function parseRuntimeManifest(bytes: Buffer): PublicAssetEntry[] {
const value = JSON.parse(bytes.toString("utf8")) as Record<string, unknown>;
if (value.schema_version !== schemaVersion || value.source !== "external_read_only" || value.root_ref !== rootRef) {
throw new Error("runtime_asset_manifest_invalid");
}
if (!Array.isArray(value.entries) || value.entries.length === 0) throw new Error("runtime_asset_manifest_invalid");
return value.entries.map((candidate) => {
if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) throw new Error("runtime_asset_manifest_invalid");
const entry = candidate as Record<string, unknown>;
if (
typeof entry.assetId !== "string" || !assetIdPattern.test(entry.assetId)
|| typeof entry.mimeType !== "string" || !/^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i.test(entry.mimeType)
|| typeof entry.relativePath !== "string" || entry.relativePath.includes("\\") || entry.relativePath.split("/").includes("..")
|| typeof entry.resourceVersion !== "string" || !releasePattern.test(entry.resourceVersion)
|| entry.rootRef !== rootRef
|| typeof entry.sha256 !== "string" || !shaPattern.test(entry.sha256)
) throw new Error("runtime_asset_manifest_invalid");
return entry as unknown as PublicAssetEntry;
});
}
function unavailable(
configured: boolean,
pauseReason: Exclude<RuntimeAssetState["pause_reason"], null>,
checkedAt: string,
): LoadedRuntimeAssets {
return { state: { checked_at: checkedAt, configured, pause_reason: pauseReason, status: "unavailable" } };
}
export function loadConfiguredRuntimeAssets(input: {
configFile: string;
dataRoot: string;
trustedManifestPath: string;
clock?: () => number;
}): LoadedRuntimeAssets {
const checkedAt = new Date((input.clock ?? Date.now)()).toISOString();
let assetRoot: string;
try {
assetRoot = readConfiguredAssetRoot(input.configFile);
} catch {
return unavailable(false, "asset_root_state_missing", checkedAt);
}
if (!existsSync(input.trustedManifestPath)) return unavailable(true, "asset_manifest_invalid", checkedAt);
try {
const trustedBytes = readFileSync(input.trustedManifestPath);
const entries = parseRuntimeManifest(trustedBytes);
const validatedRoot = validateReadOnlyAssetRoot({
dataRoot: input.dataRoot,
expectedSha256: createHash("sha256").update(trustedBytes).digest("hex"),
manifestRelativePath: "manifest.json",
root: assetRoot,
rootRef,
});
if (!validatedRoot.ok) {
return unavailable(true, validatedRoot.reason === "asset_root_missing" ? "asset_root_missing" : "asset_manifest_invalid", checkedAt);
}
return {
publicAssets: createPublicAssetResolver({ entries, roots: [validatedRoot] }),
state: { checked_at: checkedAt, configured: true, pause_reason: null, status: "active" },
};
} catch {
return unavailable(true, "asset_manifest_invalid", checkedAt);
}
}
+2 -1
View File
@@ -27,12 +27,13 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce
}
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
try {
try {
const adminPepper = credentials["Dada/P0A/admin/pepper"];
if (!adminPepper) throw new Error("admin_pepper_not_configured");
return {
adminAllowlistPepper: Buffer.from(adminPepper, "utf8"),
amap: credentials["Dada/P0A/api/amap"] ? new RealAmapAdapter(credentials["Dada/P0A/api/amap"]) : new MockAmapAdapter(),
resendConfigured: Boolean(credentials["Dada/P0A/api/resend"]),
};
} finally {
for (const name of API_CREDENTIALS) credentials[name] = "";
+3 -7
View File
@@ -1,13 +1,8 @@
import { useEffect, useRef } from "react";
import { P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
import { COLOR_CARD_HALF_SIZES, P0A_COLOR_CARDS, createColorCardElement, drawColorCard, type ColorCardDefinition } from "./palette-provider.js";
const previewPalette = ["#04D960", "#0ABF58", "#5FD994", "#A0F2C4", "#D5F2E2"] as const;
const previewHalfSize = {
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
style_08: { height: 10, width: 73 }, style_16: { height: 9, width: 78 },
} as const;
function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
@@ -18,7 +13,8 @@ function ColorCardPreview({ definition }: { definition: ColorCardDefinition }) {
context.clearRect(0, 0, canvas.width, canvas.height);
context.fillStyle = "#30343b";
context.fillRect(0, 0, canvas.width, canvas.height);
const half = previewHalfSize[definition.styleId];
const half = COLOR_CARD_HALF_SIZES[definition.styleId];
if (!half) return;
const scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
context.translate(canvas.width / 2, canvas.height / 2);
context.scale(scale, scale);
+33 -16
View File
@@ -3,6 +3,7 @@ import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
import type { CanvasElementIdentity } from "./editor-elements.js";
import complexAssetCatalog from "./generated/complex-assets.json";
type CanvasElement = CanvasState["elements"][number];
@@ -45,23 +46,17 @@ export function dyn012DisplayParts(element: CanvasElement) {
} as const;
}
const dynamicDefinitions: readonly DynamicStickerDefinition[] = [
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
] as const;
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
const definition = dynamicDefinitions.find((item) => item.templateId === templateId);
if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`);
return definition;
const item = dynamicCatalogById.get(templateId);
if (!item) throw new Error(`missing dynamic sticker definition ${templateId}`);
return {
category: item.category as DynamicCategory,
displayName: item.display_name,
requiresLocationConsent: item.requires_location_consent,
templateId,
};
});
function twoDigits(value: number) {
@@ -96,7 +91,29 @@ function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderCont
if (templateId === "DYN011") return { fields: { day, month, year }, value: `${year}.${month}.${day}` };
if (templateId === "DYN012") return { fields: { font_substitution: "FONT081", hour, minute }, value: `${hour}:${minute}` };
if (templateId === "DYN015") return { fields: { nickname: context.profile.creatorName }, value: context.profile.creatorName };
return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
if (templateId === "DYN016") return { fields: { nickname: normalizeSocialId(context.profile.socialId) }, value: normalizeSocialId(context.profile.socialId) };
const definition = dynamicCatalogById.get(templateId);
if (!definition) throw new Error("dynamic_template_unavailable");
const location = context.location?.formattedValue ?? "输入地点";
const values: Record<string, string | number> = {
city: location,
city_en: location.toUpperCase(),
day,
hour,
latitude: context.location?.latitude ?? 0,
longitude: context.location?.longitude ?? 0,
minute,
month,
nickname: normalizeSocialId(context.profile.socialId),
title: location,
year,
};
const fields = Object.fromEntries(definition.required_fields.map((field) => [field, values[field] ?? ""]));
if (definition.category === "identity") return { fields, value: normalizeSocialId(context.profile.socialId) };
if (definition.category === "location") return { fields, value: location };
if (definition.category === "time") return { fields, value: `${year}.${month}.${day} ${hour}:${minute}` };
return { fields, value: context.profile.creatorName };
}
export function createDynamicStickerElement(
+42 -12
View File
@@ -1,8 +1,9 @@
import type { CanvasState } from "@dada/shared-contracts";
import { P0A_COMPLEX_RELEASE_VERSION } from "@dada/template-registry";
import { P0A_COMPLEX_RELEASE_VERSION, P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
import { fontOption, type FontOption } from "./text-assets.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import complexAssetCatalog from "./generated/complex-assets.json";
type CanvasElement = CanvasState["elements"][number];
@@ -55,17 +56,13 @@ const dynamicFont = (fontId: string): FontOption => ({
url: `/api/v1/assets/public/${DYNAMIC_RESOURCE_VERSION}/${fontId}`,
});
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = [
dynamicFont("15974853bc3294ef68e7e6d58fe74fd7"),
dynamicFont("46f8336813e4c48d06a1aef294fdccf6"),
dynamicFont("53ca6b704728520da50c145eabb2e635"),
dynamicFont("cca5efc0e02fb1bf62349bd68ef30fc1"),
dynamicFont("dd25b35dcb7ba4476cbaa9a9592e39e2"),
dynamicFont("e4210c9872f0c279b35273f230809821"),
dynamicFont("f4bfd4132df2d6be97ceabadf3853505"),
] as const;
const dynamicFontIds = [...new Set(complexAssetCatalog.dynamic_stickers.flatMap((item) => item.font_ids))]
.filter((fontId) => fontId !== "FONT081")
.toSorted();
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
export const DYNAMIC_FONT_OPTIONS: readonly FontOption[] = dynamicFontIds.map(dynamicFont);
const EXACT_DYNAMIC_RENDER_MODELS: Readonly<Record<string, DynamicRenderModel>> = {
DYN001: {
halfSize: { height: 42, width: 130 },
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
@@ -145,6 +142,38 @@ export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRe
},
};
const dynamicCatalogById = new Map(complexAssetCatalog.dynamic_stickers.map((item) => [item.template_id, item]));
function genericTextValue(item: (typeof complexAssetCatalog.dynamic_stickers)[number]): DynamicTextValue {
if (item.category === "identity") return "nickname";
if (item.category === "location") return item.required_fields.includes("title") ? "title" : "city";
if (item.category === "time") return item.required_fields.includes("hour") && item.required_fields.includes("minute") ? "time" : "day";
return "nickname";
}
function genericDynamicModel(templateId: string): DynamicRenderModel {
const item = dynamicCatalogById.get(templateId);
if (!item) throw new Error(`missing dynamic render model ${templateId}`);
return {
halfSize: { height: 42, width: 170 },
imageLayers: [],
sourceCandidateId: item.source_candidate_id,
textLayers: [{
align: "center",
color: "#FFFFFF",
fontId: item.font_ids[0] ?? "FONT081",
fontSize: 34,
value: genericTextValue(item),
x: 0,
y: 0,
}],
};
}
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = Object.fromEntries(
P0A_DYNAMIC_STICKER_IDS.map((templateId) => [templateId, EXACT_DYNAMIC_RENDER_MODELS[templateId] ?? genericDynamicModel(templateId)]),
);
export function dynamicFontOptionsFor(templateId: string) {
if (templateId === "DYN012") {
const replacement = fontOption("FONT081");
@@ -153,7 +182,8 @@ export function dynamicFontOptionsFor(templateId: string) {
const model = DYNAMIC_RENDER_MODELS[templateId as DynamicTemplateId];
if (!model) return [];
const ids = new Set(model.textLayers.map((layer) => layer.fontId));
return DYNAMIC_FONT_OPTIONS.filter((option) => ids.has(option.fontId));
return [...ids].map((fontId) => fontOption(fontId) ?? DYNAMIC_FONT_OPTIONS.find((option) => option.fontId === fontId))
.filter((option): option is FontOption => option !== undefined);
}
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
+1
View File
@@ -22,6 +22,7 @@ function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedF
useEffect(() => {
let active = true;
const model = DYNAMIC_RENDER_MODELS[props.templateId];
if (!model) return () => { active = false; };
const element = createDynamicStickerElement(props.templateId, {
location: { formattedValue: "温州", latitude: 27.9943, longitude: 120.6994 },
now: new Date("2026-08-03T09:07:00+08:00"), profile: { creatorName: "Dada Creator", socialId: "@dada" },
+115 -2
View File
@@ -2,6 +2,27 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
export type BackgroundAdjustments = CanvasState["background"]["adjustments"];
interface CanvasSize {
height: number;
width: number;
}
interface CanvasRect {
height: number;
width: number;
x: number;
y: number;
}
export interface BackgroundDrawPlan {
destination: CanvasRect;
source: CanvasRect;
}
function clamp(value: number, minimum: number, maximum: number) {
return Math.min(maximum, Math.max(minimum, value));
}
const elementKeys = new Set([
"colors", "content", "coordinates", "created_at", "dynamic_fields", "element_id", "font_override", "font_size",
"formatted_value", "opacity", "position", "resource_version", "rotation", "scale", "style_id", "style_parameters",
@@ -106,7 +127,99 @@ export function deserializeFabricCanvas(input: unknown): CanvasState | undefined
return isCanvasState(candidate) ? structuredClone(candidate) : undefined;
}
export function backgroundDrawPlan(
image: CanvasSize,
canvas: CanvasSize,
adjustments: BackgroundAdjustments,
): BackgroundDrawPlan {
if (image.width <= 0 || image.height <= 0 || canvas.width <= 0 || canvas.height <= 0) {
throw new Error("background_dimensions_invalid");
}
const crop = adjustments.crop;
const normalizedX = crop ? clamp(crop.x, 0, 1) : 0;
const normalizedY = crop ? clamp(crop.y, 0, 1) : 0;
const normalizedWidth = crop ? Math.min(crop.width, 1 - normalizedX) : 1;
const normalizedHeight = crop ? Math.min(crop.height, 1 - normalizedY) : 1;
const source = normalizedWidth > 0 && normalizedHeight > 0
? {
height: image.height * normalizedHeight,
width: image.width * normalizedWidth,
x: image.width * normalizedX,
y: image.height * normalizedY,
}
: { height: image.height, width: image.width, x: 0, y: 0 };
if (adjustments.fit === "fill") {
return { destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 }, source };
}
if (adjustments.fit === "fit") {
const scale = Math.min(canvas.width / source.width, canvas.height / source.height);
const width = source.width * scale;
const height = source.height * scale;
return {
destination: { height, width, x: (canvas.width - width) / 2, y: (canvas.height - height) / 2 },
source,
};
}
const sourceAspect = source.width / source.height;
const canvasAspect = canvas.width / canvas.height;
if (sourceAspect > canvasAspect) {
const width = source.height * canvasAspect;
source.x += (source.width - width) / 2;
source.width = width;
} else if (sourceAspect < canvasAspect) {
const height = source.width / canvasAspect;
source.y += (source.height - height) / 2;
source.height = height;
}
return {
destination: { height: canvas.height, width: canvas.width, x: 0, y: 0 },
source,
};
}
export function adjustBackgroundPixels(
pixels: Uint8ClampedArray,
width: number,
height: number,
adjustments: Pick<BackgroundAdjustments, "sharpness" | "temperature">,
) {
if (!Number.isInteger(width) || !Number.isInteger(height) || width <= 0 || height <= 0 || pixels.length !== width * height * 4) {
throw new Error("background_pixel_buffer_invalid");
}
const source = new Uint8ClampedArray(pixels);
const output = new Uint8ClampedArray(source);
const sharpness = clamp(adjustments.sharpness, 0, 100) / 100;
const temperature = clamp(adjustments.temperature, -100, 100) / 100;
const channelOffsets = [35 * temperature, 8 * temperature, -35 * temperature];
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const index = (y * width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
let value = source[index + channel]!;
if (sharpness > 0 && x > 0 && x < width - 1 && y > 0 && y < height - 1) {
const left = source[index + channel - 4]!;
const right = source[index + channel + 4]!;
const above = source[index + channel - width * 4]!;
const below = source[index + channel + width * 4]!;
value = value * (1 + 4 * sharpness) - (left + right + above + below) * sharpness;
}
output[index + channel] = value + channelOffsets[channel]!;
}
output[index + 3] = source[index + 3]!;
}
}
return output;
}
export function cssFilterForBackground(adjustments: BackgroundAdjustments) {
const filter = adjustments.filter === "grayscale" ? "grayscale(1)" : adjustments.filter === "sepia" ? "sepia(0.75)" : "none";
return `${filter} brightness(${100 + adjustments.brightness}%) contrast(${100 + adjustments.contrast}%) saturate(${100 + adjustments.saturation}%)`;
const filters: string[] = [];
if (adjustments.filter === "grayscale") filters.push("grayscale(1)");
else if (adjustments.filter === "sepia") filters.push("sepia(0.75)");
if (adjustments.brightness !== 0) filters.push(`brightness(${100 + adjustments.brightness}%)`);
if (adjustments.contrast !== 0) filters.push(`contrast(${100 + adjustments.contrast}%)`);
if (adjustments.saturation !== 0) filters.push(`saturate(${100 + adjustments.saturation}%)`);
return filters.length > 0 ? filters.join(" ") : "none";
}
+22 -7
View File
@@ -2,6 +2,7 @@ import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { textTemplateById } from "./text-assets.js";
type CanvasElement = CanvasState["elements"][number];
@@ -76,9 +77,10 @@ export function elementHalfExtents(state: CanvasState, element: CanvasElement):
const longestCharacterCount = Math.max(1, ...lines.map((line) => Array.from(line).length));
const widthPixels = longestLine * fontSize + (longestCharacterCount - 1) * letterSpacing + 32 + strokeWidth * 2;
const heightPixels = Math.max(1, lines.length) * fontSize * lineHeight + 32 + strokeWidth * 2;
const template = textTemplateById(element.template_or_asset_id);
return {
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2) * element.scale.y,
x: Math.max(hitHalfExtent, widthPixels / state.pixel_width / 2, (template?.renderModel.halfSize.width ?? 0) / state.pixel_width) * element.scale.x,
y: Math.max(hitHalfExtent, heightPixels / state.pixel_height / 2, (template?.renderModel.halfSize.height ?? 0) / state.pixel_height) * element.scale.y,
};
}
@@ -187,7 +189,7 @@ export class CanvasElementController {
.map((element) => structuredClone(element));
}
selectAt(point: CanvasPoint, options: { append?: boolean } = {}) {
selectAt(point: CanvasPoint, options: { append?: boolean; preserveSelection?: boolean } = {}) {
const candidates = this.candidatesAt(point);
if (candidates.length === 0) {
if (!options.append) this.selection = [];
@@ -196,7 +198,9 @@ export class CanvasElementController {
}
if (options.append) {
this.pointerMoved();
return this.selectById(candidates[0]!.element_id, true);
const elementId = candidates[0]!.element_id;
if (options.preserveSelection && this.selection.includes(elementId)) return this.selectedIds;
return this.selectById(elementId, true);
}
const signature = candidates.map((candidate) => candidate.element_id).join("|");
if (samePoint(this.cyclePoint, point) && signature === this.cycleSignature) this.cycleIndex = (this.cycleIndex + 1) % candidates.length;
@@ -255,7 +259,8 @@ export class CanvasElementController {
moveSelected(delta: CanvasPoint, options: { snap?: boolean } = {}) {
const selected = new Set(this.selection);
const primary = this.current.elements.find((element) => selected.has(element.element_id));
const selectedElements = this.current.elements.filter((element) => selected.has(element.element_id));
const primary = selectedElements[0];
if (!primary) return { guides: [] as string[], state: this.value };
let nextX = primary.position.x + delta.x;
let nextY = primary.position.y + delta.y;
@@ -278,10 +283,20 @@ export class CanvasElementController {
nextX = snapAxis(nextX, "x");
nextY = snapAxis(nextY, "y");
}
const adjusted = { x: nextX - primary.position.x, y: nextY - primary.position.y };
const minimumX = Math.min(...selectedElements.map((element) => element.position.x));
const maximumX = Math.max(...selectedElements.map((element) => element.position.x));
const minimumY = Math.min(...selectedElements.map((element) => element.position.y));
const maximumY = Math.max(...selectedElements.map((element) => element.position.y));
const adjusted = {
x: clamp(nextX - primary.position.x, -minimumX, 1 - maximumX),
y: clamp(nextY - primary.position.y, -minimumY, 1 - maximumY),
};
const state = this.updateSelected((element) => ({
...element,
position: { x: clamp(element.position.x + adjusted.x, 0, 1), y: clamp(element.position.y + adjusted.y, 0, 1) },
position: {
x: clamp(Number((element.position.x + adjusted.x).toFixed(12)), 0, 1),
y: clamp(Number((element.position.y + adjusted.y).toFixed(12)), 0, 1),
},
}));
return { guides, state };
}
+9 -3
View File
@@ -1,9 +1,12 @@
.editor-page-shell {
min-height: 100vh;
height: 100vh;
height: 100dvh;
min-height: 0;
display: grid;
grid-template-rows: 56px minmax(0, 1fr) 32px;
background: #e8e8e5;
color: #111111;
overflow: hidden;
}
.editor-page-shell :focus-visible {
@@ -124,6 +127,7 @@
display: grid;
grid-template-columns: 280px minmax(0, 1fr) 320px;
min-height: 0;
overflow: hidden;
}
.editor-assets-panel,
@@ -395,6 +399,8 @@
.editor-template-grid button:disabled { border-style: dashed; background: #e8e8e5; color: #62625d; cursor: not-allowed; }
.editor-template-grid strong { overflow: hidden; font-family: Consolas, monospace; font-size: 10px; text-overflow: ellipsis; }
.editor-template-grid small { color: #8f1d14; font-size: 9px; }
.editor-template-preview { width: 100%; height: 44px; object-fit: contain; border: 1px solid #111111; background: #30343b; }
.editor-template-live-preview { display: grid; width: 100%; height: 44px; place-items: center; overflow: hidden; border: 1px solid #111111; background: #30343b; white-space: nowrap; }
.editor-template-mark { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #f2f400; font-size: 18px; font-weight: 800; }
.editor-template-mark.title { background: #111111; color: #ffffff; }
.editor-template-mark.tag { background: #dbeafe; }
@@ -542,13 +548,13 @@
}
@media (max-width: 760px) {
.editor-page-shell { grid-template-rows: auto minmax(0, 1fr) auto; }
.editor-page-shell { height: auto; min-height: 100dvh; grid-template-rows: auto minmax(0, 1fr) auto; overflow: visible; }
.editor-toolbar { display: flex; min-height: 56px; flex-wrap: wrap; gap: 8px; padding: 8px 10px; }
.editor-title { min-width: 0; flex: 1 1 calc(100% - 56px); }
.editor-history-actions { order: 3; }
.editor-save-status { order: 4; flex: 1 1 128px; }
.editor-toolbar-controls > button { display: block; order: 5; }
.editor-layout { grid-template-columns: 1fr; }
.editor-layout { grid-template-columns: 1fr; overflow: visible; }
.editor-assets-panel, .editor-inspector { border: 0; }
.editor-assets-panel { order: 2; }
.editor-inspector { order: 3; }
+162 -52
View File
@@ -32,6 +32,7 @@ import {
P0A_TEXT_TEMPLATES,
TextEditSession,
createTextTemplateElement,
textTemplateFontOptions,
type TextStylePatch,
type TextTemplateCategory,
type TextTemplateDefinition,
@@ -86,6 +87,14 @@ interface EditorExportResult {
status: ExportFlowStatus;
}
function withTextDraft(canvasState: CanvasState, textEdit: TextEditState | undefined) {
if (!textEdit) return canvasState;
return {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
};
}
interface EditorProject {
canvas_state?: CanvasState;
created_at: string;
@@ -155,11 +164,26 @@ export function EditorPage({ projectId }: { projectId: string }) {
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
const dragRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const opacityGestureRef = useRef<{ base: CanvasState; last: CanvasState; selectedIds: string[] } | undefined>(undefined);
const textHistoryRef = useRef<{ base: CanvasState; elementId: string; last: CanvasState } | undefined>(undefined);
const clipboardRef = useRef<CanvasElement[]>([]);
const fontLoaderRef = useRef<ArchivedFontLoader | undefined>(undefined);
const conflictExportGuardRef = useRef(new ConflictExportGuard());
const candidateMenuRef = useRef<HTMLDivElement | null>(null);
const candidateTriggerRef = useRef<HTMLButtonElement | null>(null);
const noticeTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
function showNotice(message: string) {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
setNotice(message);
noticeTimerRef.current = setTimeout(() => {
setNotice("");
noticeTimerRef.current = undefined;
}, 3_000);
}
useEffect(() => () => {
if (noticeTimerRef.current) clearTimeout(noticeTimerRef.current);
}, []);
useEffect(() => {
let active = true;
@@ -175,7 +199,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
setDraftAdjustments(initial.background.adjustments);
historyRef.current = new CanvasEditHistory(initial);
elementControllerRef.current = new CanvasElementController(initial);
}).catch(() => { if (active) setNotice("编辑器暂时无法读取项目"); });
}).catch(() => { if (active) showNotice("编辑器暂时无法读取项目"); });
return () => { active = false; };
}, [projectId]);
@@ -228,12 +252,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
useEffect(() => {
if (!canvasState) return;
for (const element of canvasState.elements) {
const options = element.type === "text_template"
? [fontIdForTextElement(element)].map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : [];
for (const option of options) void ensureFont(option.fontId, option.url);
}
const options = canvasState.elements.flatMap((element) => element.type === "text_template"
? [...textTemplateFontOptions(element.template_or_asset_id), ...[fontIdForTextElement(element)]
.map((fontId) => fontId ? fontOption(fontId) : undefined).filter((option) => option !== undefined)]
: element.type === "dynamic_sticker" ? dynamicFontOptionsFor(element.template_or_asset_id) : []);
const unique = [...new Map(options.map((option) => [option.fontId, option])).values()];
void (async () => {
for (const option of unique) await ensureFont(option.fontId, option.url);
})();
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
useEffect(() => {
@@ -261,6 +287,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
});
}, [canvasState, selectedIds.join("|")]);
useEffect(() => {
if (textEdit) commitTextDraftAutomatically(textEdit);
}, [textEdit?.draft]);
async function ensureFont(fontId: string, url: string, retry = false) {
const current = fontStatuses[fontId];
if (current === "ready" || (current === "unavailable" && !retry)) return current;
@@ -273,28 +303,51 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
async function retryTextFonts() {
const available = P0A_TEXT_TEMPLATES.filter((template) => template.available && template.fontUrl);
await Promise.all(available.map((template) => ensureFont(template.defaultFontId, template.fontUrl!, true)));
const failed = [...new Set(P0A_TEXT_TEMPLATES
.filter((template) => fontStatuses[template.defaultFontId] === "unavailable")
.map((template) => template.defaultFontId))];
for (const fontId of failed) {
const option = fontOption(fontId);
if (option) await ensureFont(option.fontId, option.url, true);
}
}
function commitCanvas(next: CanvasState) {
async function ensureTemplateFonts(template: TextTemplateDefinition) {
for (const option of textTemplateFontOptions(template.templateId)) {
const status = await ensureFont(option.fontId, option.url, fontStatuses[option.fontId] === "unavailable");
if (status !== "ready") return false;
}
return true;
}
function finalizeTextHistory() {
const pending = textHistoryRef.current;
if (!pending) return undefined;
textHistoryRef.current = undefined;
historyRef.current?.commit(pending.last);
return pending.last;
}
function commitCanvas(next: CanvasState, options: { preserveTextEdit?: boolean } = {}) {
if (!project || saveStatus === "conflicted") return;
historyRef.current?.commit(next);
const finalizedText = finalizeTextHistory();
if (!finalizedText || JSON.stringify(finalizedText) !== JSON.stringify(next)) historyRef.current?.commit(next);
elementControllerRef.current?.replaceState(next);
setSelectedIds(elementControllerRef.current?.selectedIds ?? []);
setCanvasState(next);
setDraftAdjustments(next.background.adjustments);
queueRef.current?.commit({ canvas_state: next, name: project.name });
setTextEdit(undefined);
if (!options.preserveTextEdit) setTextEdit(undefined);
}
function applyPreview() {
if (!canvasState) return;
commitCanvas(updateBackgroundAdjustments(canvasState, draftAdjustments));
setNotice("底图调整已提交");
showNotice("底图调整已提交");
}
function undo() {
finalizeTextHistory();
const previous = historyRef.current?.undo();
if (previous) {
elementControllerRef.current?.replaceState(previous);
@@ -307,6 +360,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function redo() {
finalizeTextHistory();
const next = historyRef.current?.redo();
if (next) {
elementControllerRef.current?.replaceState(next);
@@ -334,9 +388,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
const palette = await paletteForAsset(pendingBackground);
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
setPendingBackground(undefined);
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
showNotice("已更换底图,覆盖元素保留,底图处理已重置");
} catch {
setNotice("新底图无法读取,未更换底图或刷新色卡");
showNotice("新底图无法读取,未更换底图或刷新色卡");
}
}
@@ -353,7 +407,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitCanvas(next);
setGuides([]);
setCandidateMenu(undefined);
setNotice(message);
showNotice(message);
}
function addSticker(sticker: StaticStickerCatalogItem) {
@@ -369,8 +423,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
}));
commitElementOperation(controller, "贴纸已加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("贴纸未能加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("贴纸未能加入画布");
}
}
@@ -383,8 +437,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.add(createColorCardElement(definition, palette, newElementIdentity(), canvasState.elements.length));
commitElementOperation(controller, "色卡已按原始底图加入画布");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("无法从原始底图稳定提取五色,色卡未加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("无法从原始底图稳定提取五色,色卡未加入画布");
}
}
@@ -394,7 +448,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
if (templateId === "DYN012") {
const font = fontOption("FONT081");
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
showNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
return;
}
}
@@ -407,8 +461,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitElementOperation(controller, "动态值已确认并加入画布");
setLocationDialog(undefined);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("动态贴纸未能加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("动态贴纸未能加入画布");
}
}
@@ -456,7 +510,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.replaceElement(overrideDynamicStickerValue(element, value));
commitElementOperation(controller, "动态贴纸显示文字已更新");
} catch {
setNotice("动态贴纸显示文字不能为空");
showNotice("动态贴纸显示文字不能为空");
}
}
@@ -478,9 +532,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
async function addTextTemplate(template: TextTemplateDefinition) {
if (!template.fontUrl || !canvasState) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。");
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
const controller = controllerForCurrent();
@@ -490,8 +543,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
commitElementOperation(controller, "文字模板已加入画布");
void recordRecentTextTemplate(template.templateId, template.resourceVersion);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("文字模板未能加入画布");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("文字模板未能加入画布");
}
}
@@ -503,18 +556,48 @@ export function EditorPage({ projectId }: { projectId: string }) {
action(edit);
return { ...current, draft: edit.value };
} catch {
setNotice("文字参数不在允许范围内");
showNotice("文字参数不在允许范围内");
return current;
}
});
}
function commitTextDraftAutomatically(editState: TextEditState) {
if (!canvasState || !project || saveStatus === "conflicted") return;
const index = canvasState.elements.findIndex((element) => element.element_id === editState.elementId);
if (index < 0 || JSON.stringify(canvasState.elements[index]) === JSON.stringify(editState.draft)) return;
try {
const complete = new TextEditSession(editState.draft, P0A_TEXT_TEMPLATES).complete();
const next = structuredClone(canvasState);
next.elements[index] = complete;
const history = textHistoryRef.current;
if (!history || history.elementId !== editState.elementId) {
if (history) finalizeTextHistory();
textHistoryRef.current = { base: canvasState, elementId: editState.elementId, last: next };
} else {
history.last = next;
}
elementControllerRef.current?.replaceState(next);
setCanvasState(next);
queueRef.current?.commit({ canvas_state: next, name: project.name });
if (complete.template_or_asset_id !== editState.originalTemplateId) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
setTextEdit((current) => current?.elementId === editState.elementId ? {
...current,
draft: complete,
originalTemplateId: complete.template_or_asset_id,
} : current);
} catch (error) {
if (!(error instanceof Error && error.message === "text_content_required")) showNotice("文字编辑未能自动保存");
}
}
async function changeTextTemplate(templateId: string) {
const template = P0A_TEXT_TEMPLATES.find((candidate) => candidate.templateId === templateId);
if (!template?.fontUrl) return;
const status = await ensureFont(template.defaultFontId, template.fontUrl);
if (status !== "ready") {
setNotice("素材暂不可用,未使用系统字体替代。");
if (!await ensureTemplateFonts(template)) {
showNotice("素材暂不可用,未使用系统字体替代。");
return;
}
updateTextDraft((edit) => edit.switchTemplate(templateId));
@@ -527,7 +610,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
const option = fontOption(fontId);
if (!option || await ensureFont(option.fontId, option.url) !== "ready") {
setNotice("字体素材暂不可用,未使用系统字体替代。");
showNotice("字体素材暂不可用,未使用系统字体替代。");
return;
}
updateTextDraft((edit) => edit.setStyle({ fontOverride: fontId }));
@@ -535,6 +618,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
function completeTextEdit() {
if (!textEdit) return;
if (!pendingTextDraft()) {
finalizeTextHistory();
showNotice("文字修改已进入自动保存");
return;
}
try {
const edit = new TextEditSession(textEdit.draft, P0A_TEXT_TEMPLATES);
const complete = edit.complete();
@@ -546,17 +634,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
void recordRecentTextTemplate(complete.template_or_asset_id, complete.resource_version);
}
} catch (error) {
if (error instanceof Error && error.message === "text_content_required") setNotice("请输入文字内容或删除该元素。");
else setNotice("文字编辑未能完成");
if (error instanceof Error && error.message === "text_content_required") showNotice("请输入文字内容或删除该元素。");
else showNotice("文字编辑未能完成");
}
}
function cancelTextEdit() {
const pendingHistory = textHistoryRef.current;
if (pendingHistory && project) {
textHistoryRef.current = undefined;
elementControllerRef.current?.replaceState(pendingHistory.base);
setCanvasState(pendingHistory.base);
queueRef.current?.commit({ canvas_state: pendingHistory.base, name: project.name });
}
if (canvasState && textEdit) {
const current = canvasState.elements.find((element) => element.element_id === textEdit.elementId);
const source = pendingHistory?.base ?? canvasState;
const current = source.elements.find((element) => element.element_id === textEdit.elementId);
if (current) setTextEdit({ draft: structuredClone(current), elementId: current.element_id, originalTemplateId: current.template_or_asset_id });
}
setNotice("已取消未提交的文字修改");
showNotice("已取消未提交的文字修改");
}
function pendingTextDraft() {
@@ -624,7 +720,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
return;
}
const ran = await conflictExportGuardRef.current.run(() => executeExport(options));
if (!ran) setNotice("版本冲突时仅允许导出本页版本一次");
if (!ran) showNotice("版本冲突时仅允许导出本页版本一次");
}
async function retryExportDownload() {
@@ -639,8 +735,8 @@ export function EditorPage({ projectId }: { projectId: string }) {
action(controller);
commitElementOperation(controller, message);
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
else setNotice("对象操作未完成");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
else showNotice("对象操作未完成");
}
}
@@ -668,7 +764,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
opacityGestureRef.current = undefined;
if (gesture.last === gesture.base) return;
commitCanvas(gesture.last);
setNotice("贴纸透明度已提交");
showNotice("贴纸透明度已提交");
}
function duplicateSelection() {
@@ -688,7 +784,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
const controller = controllerForCurrent();
if (!controller || selectedIds.length === 0) return;
clipboardRef.current = controller.copySelected();
setNotice("已复制到画布剪贴板");
showNotice("已复制到画布剪贴板");
}
function pasteSelection() {
@@ -698,18 +794,23 @@ export function EditorPage({ projectId }: { projectId: string }) {
controller.pasteElements(clipboardRef.current, () => newElementIdentity());
commitElementOperation(controller, "已粘贴画布对象");
} catch (error) {
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
if (error instanceof Error && error.message === "canvas_element_limit_reached") showNotice("画布最多 50 个元素,请先删除现有元素。");
}
}
function selectAt(point: CanvasPoint, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller || !canvasState) return false;
const candidates = controller.candidatesAt(point);
const selection = controller.selectAt(point, { append: append || multiMode });
const selection = controller.selectAt(point, {
append: append || multiMode,
preserveSelection: multiMode && !append,
});
setSelectedIds(selection);
setCandidateMenu(undefined);
dragRef.current = { base: canvasState, last: canvasState, selectedIds: selection };
const dragBase = withTextDraft(canvasState, textEdit);
dragRef.current = { base: dragBase, last: dragBase, selectedIds: selection };
return candidates.length > 0;
}
@@ -721,19 +822,25 @@ export function EditorPage({ projectId }: { projectId: string }) {
const preview = previewController.moveSelected(delta);
drag.last = preview.state;
setCanvasState(preview.state);
setTextEdit((current) => {
if (!current || !drag.selectedIds.includes(current.elementId)) return current;
const movedDraft = preview.state.elements.find((element) => element.element_id === current.elementId);
return movedDraft ? { ...current, draft: movedDraft } : current;
});
setGuides(preview.guides);
}
function commitMove() {
const drag = dragRef.current;
if (!drag) return;
commitCanvas(drag.last);
setNotice("对象位置已提交");
commitCanvas(drag.last, { preserveTextEdit: true });
showNotice("对象位置已提交");
setGuides([]);
dragRef.current = undefined;
}
function marqueeSelect(rectangle: CanvasRect, append: boolean) {
finalizeTextHistory();
const controller = controllerForCurrent();
if (!controller) return;
setSelectedIds(controller.marqueeSelect(rectangle, append || multiMode));
@@ -782,6 +889,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
function clearSelection() {
finalizeTextHistory();
elementControllerRef.current?.clearSelection();
setSelectedIds([]);
setCandidateMenu(undefined);
@@ -789,10 +897,11 @@ export function EditorPage({ projectId }: { projectId: string }) {
}
if (!project || !canvasState) return <main className="editor-loading" aria-live="polite"></main>;
const renderedCanvasState = textEdit ? {
const backgroundPreviewState: CanvasState = {
...canvasState,
elements: canvasState.elements.map((element) => element.element_id === textEdit.elementId ? textEdit.draft : element),
} : canvasState;
background: { ...canvasState.background, adjustments: draftAdjustments },
};
const renderedCanvasState = withTextDraft(backgroundPreviewState, textEdit);
const imageUrl = `/api/v1/private-assets/projects/${projectId}/images/${canvasState.background.asset_id ?? project.current_image_id ?? ""}`;
const canEdit = saveStatus !== "conflicted";
const selectedElements = renderedCanvasState.elements.filter((element) => selectedIds.includes(element.element_id));
@@ -837,6 +946,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
fontStatuses={fontStatuses}
onAdd={(template) => { void addTextTemplate(template); }}
onCategory={setTemplateCategory}
onEnsure={(template) => { void ensureTemplateFonts(template); }}
onQuery={setTemplateQuery}
onRetry={() => { void retryTextFonts(); }}
query={templateQuery}
@@ -869,7 +979,6 @@ export function EditorPage({ projectId }: { projectId: string }) {
maxWidth: `min(720px, calc(${(canvasState.pixel_width / canvasState.pixel_height * 100).toFixed(4)}vh - ${(canvasState.pixel_width / canvasState.pixel_height * 168).toFixed(4)}px))`,
}}>
<EditorStage
assetId={canvasState.background.asset_id}
canvasState={renderedCanvasState}
fontStatuses={fontStatuses}
guides={guides}
@@ -877,6 +986,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
onClearSelection={clearSelection}
onCopy={copySelection}
onDelete={deleteSelection}
onDragStart={() => setCandidateMenu(undefined)}
onMarquee={marqueeSelect}
onMoveCommit={commitMove}
onMovePreview={previewMove}
+324 -67
View File
@@ -1,25 +1,38 @@
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import type { CanvasState } from "@dada/shared-contracts";
import { cssFilterForBackground } from "./editor-canvas.js";
import { adjustBackgroundPixels, backgroundDrawPlan, cssFilterForBackground } from "./editor-canvas.js";
import { DYN012_RENDER_LAYOUT, dyn012DisplayParts } from "./dynamic-provider.js";
import type { DynamicTemplateId } from "./dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS, dynamicFontOptionsFor, dynamicImageUrl, dynamicTextValue } from "./dynamic-render-models.js";
import type { CanvasPoint, CanvasRect } from "./editor-elements.js";
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
import { fontIdForTextElement } from "./text-assets.js";
import { drawColorCard } from "./palette-provider.js";
import {
fontIdForTextElement,
textTemplateById,
textTemplateFontOptions,
textTemplateImageUrls,
type TextTemplateImageLayer,
type TextTemplateNinePatch,
type TextTemplateParticleLayer,
type TextTemplateTextLayer,
} from "./text-assets.js";
import { COLOR_CARD_HALF_SIZES, drawColorCard } from "./palette-provider.js";
interface Gesture {
append: boolean;
bounds: DOMRect;
hit: boolean;
longPressOpened: boolean;
moved: boolean;
pointerId: number;
start: CanvasPoint;
startClient: CanvasPoint;
}
const DRAG_THRESHOLD_PX = 4;
interface EditorStageProps {
assetId: string | null;
canvasState: CanvasState;
guides: readonly string[];
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>;
@@ -27,6 +40,7 @@ interface EditorStageProps {
onClearSelection: () => void;
onCopy: () => void;
onDelete: () => void;
onDragStart: () => void;
onMarquee: (rectangle: CanvasRect, append: boolean) => void;
onMoveCommit: () => void;
onMovePreview: (delta: CanvasPoint) => void;
@@ -38,11 +52,10 @@ interface EditorStageProps {
selectedIds: readonly string[];
}
function pointFromEvent(event: PointerEvent<HTMLCanvasElement>): CanvasPoint {
const bounds = event.currentTarget.getBoundingClientRect();
function pointFromClient(clientX: number, clientY: number, bounds: DOMRect): CanvasPoint {
return {
x: Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (event.clientY - bounds.top) / bounds.height)),
x: Math.max(0, Math.min(1, (clientX - bounds.left) / bounds.width)),
y: Math.max(0, Math.min(1, (clientY - bounds.top) / bounds.height)),
};
}
@@ -56,6 +69,47 @@ interface TextPixelGeometry {
width: number;
}
interface NinePatchRectangle { height: number; width: number; x: number; y: number }
export interface NinePatchSlice { destination: NinePatchRectangle; source: NinePatchRectangle }
function destinationEdges(size: number, first: number, last: number) {
const fixed = first + last;
if (fixed <= size || fixed === 0) return [first, last] as const;
const ratio = size / fixed;
return [first * ratio, last * ratio] as const;
}
export function ninePatchSlices(
width: number,
height: number,
patch: TextTemplateNinePatch,
bitmapSize: { height: number; width: number } = { height: patch.sourceHeight, width: patch.sourceWidth },
): NinePatchSlice[] {
const [left, right] = destinationEdges(width, patch.left, patch.right);
const [top, bottom] = destinationEdges(height, patch.top, patch.bottom);
const sourceScaleX = bitmapSize.width / patch.sourceWidth;
const sourceScaleY = bitmapSize.height / patch.sourceHeight;
const sourceColumns = [0, patch.left * sourceScaleX, bitmapSize.width - patch.right * sourceScaleX, bitmapSize.width];
const sourceRows = [0, patch.top * sourceScaleY, bitmapSize.height - patch.bottom * sourceScaleY, bitmapSize.height];
const destinationColumns = [0, left, width - right, width];
const destinationRows = [0, top, height - bottom, height];
const slices: NinePatchSlice[] = [];
for (let row = 0; row < 3; row += 1) {
for (let column = 0; column < 3; column += 1) {
const source = {
height: sourceRows[row + 1]! - sourceRows[row]!, width: sourceColumns[column + 1]! - sourceColumns[column]!,
x: sourceColumns[column]!, y: sourceRows[row]!,
};
const destination = {
height: destinationRows[row + 1]! - destinationRows[row]!, width: destinationColumns[column + 1]! - destinationColumns[column]!,
x: destinationColumns[column]!, y: destinationRows[row]!,
};
if (source.width > 0 && source.height > 0 && destination.width > 0 && destination.height > 0) slices.push({ destination, source });
}
}
return slices;
}
function prepareTextContext(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontId: string) {
const fontSize = element.font_size ?? 48;
const letterSpacing = styleValue(element, "letter_spacing", 1);
@@ -77,47 +131,154 @@ function measureTextElement(context: CanvasRenderingContext2D, element: CanvasSt
};
}
function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") {
context.fillStyle = "#e5e7eb";
context.fillRect(-110, -34, 220, 68);
context.fillStyle = "#9f1d1d";
context.font = "600 22px Microsoft YaHei UI, sans-serif";
context.textAlign = "center";
context.fillText("字体不可用", 0, 8);
return;
function drawTemplateImageLayer(
context: CanvasRenderingContext2D,
layer: TextTemplateImageLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
context.save();
context.globalAlpha *= layer.alpha;
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
const left = -layer.anchorX * layer.width;
const top = -layer.anchorY * layer.height;
if (layer.ninePatch) {
for (const slice of ninePatchSlices(
layer.width,
layer.height,
layer.ninePatch,
{ height: image.naturalHeight, width: image.naturalWidth },
)) {
context.drawImage(
image, slice.source.x, slice.source.y, slice.source.width, slice.source.height,
left + slice.destination.x, top + slice.destination.y, slice.destination.width, slice.destination.height,
);
}
} else context.drawImage(image, left, top, layer.width, layer.height);
context.restore();
}
function deterministicUnit(index: number, salt: number) {
const value = Math.sin(index * 12.9898 + salt * 78.233) * 43_758.5453;
return value - Math.floor(value);
}
function drawTemplateParticles(
context: CanvasRenderingContext2D,
layer: TextTemplateParticleLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const image = resourceImages[layer.assetId];
if (!image) return;
const columns = Math.max(1, Math.floor(layer.atlasColumns));
const rows = Math.max(1, Math.floor(layer.atlasRows));
const sourceWidth = image.naturalWidth / columns;
const sourceHeight = image.naturalHeight / rows;
const count = Math.max(6, Math.min(80, Math.round((layer.width + layer.height) / 18 * layer.density)));
context.save();
context.globalAlpha *= layer.alpha;
for (let index = 0; index < count; index += 1) {
const progress = index / count;
const angle = progress * Math.PI * 2;
const jitter = (deterministicUnit(index, 1) - 0.5) * layer.randomizePosition * 18;
const x = layer.x + Math.cos(angle) * (layer.width / 2 + jitter);
const y = layer.y + Math.sin(angle) * (layer.height / 2 + jitter);
const cell = index % (columns * rows);
const sourceX = (cell % columns) * sourceWidth;
const sourceY = Math.floor(cell / columns) * sourceHeight;
context.save();
context.translate(x, y);
context.rotate((layer.rotation + (deterministicUnit(index, 2) - 0.5) * layer.randomizeAngle * 360) * Math.PI / 180);
context.drawImage(
image, sourceX, sourceY, sourceWidth, sourceHeight,
-layer.particleWidth / 2, -layer.particleHeight / 2, layer.particleWidth, layer.particleHeight,
);
context.restore();
}
const fontSize = element.font_size ?? 48;
const lineHeight = styleValue(element, "line_height", 1.2);
const letterSpacing = styleValue(element, "letter_spacing", 1);
const align = styleValue(element, "text_align", "center") as CanvasTextAlign;
const lines = (element.content ?? "").split("\n");
prepareTextContext(context, element, fontId);
context.restore();
}
function drawTemplateTextLayer(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
layer: TextTemplateTextLayer,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const editable = layer.editable;
const fontId = editable ? fontIdForTextElement(element) ?? layer.fontId : layer.fontId;
const fontSize = editable ? element.font_size ?? layer.fontSize : layer.fontSize;
const lineHeight = editable ? styleValue(element, "line_height", layer.lineHeight) : layer.lineHeight;
const letterSpacing = editable ? styleValue(element, "letter_spacing", layer.letterSpacing) : layer.letterSpacing;
const align = (editable ? styleValue(element, "text_align", layer.align) : layer.align) as CanvasTextAlign;
const lines = (editable ? element.content ?? layer.text : layer.text).split("\n");
context.save();
context.globalAlpha *= layer.alpha;
context.translate(layer.x, layer.y);
context.rotate(layer.rotation * Math.PI / 180);
context.scale(layer.scaleX, layer.scaleY);
context.font = `${fontSize}px "${fontFamilyName(fontId)}"`;
(context as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${letterSpacing}px`;
context.textAlign = align;
context.textBaseline = "middle";
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, line.length - 1) * letterSpacing);
const widths = lines.map((line) => context.measureText(line).width + Math.max(0, Array.from(line).length - 1) * letterSpacing);
const textWidth = Math.max(1, ...widths);
const textHeight = Math.max(fontSize * lineHeight, lines.length * fontSize * lineHeight);
const padding = 16;
const backgroundEnabled = styleValue(element, "background_enabled", false);
if (backgroundEnabled) {
const elementOpacity = context.globalAlpha;
context.globalAlpha = elementOpacity * styleValue(element, "background_opacity", 1);
if (editable && styleValue(element, "background_enabled", false)) {
const alpha = context.globalAlpha;
context.globalAlpha = alpha * styleValue(element, "background_opacity", 1);
context.fillStyle = styleValue(element, "background_color", "#FFE62C");
context.fillRect(-textWidth / 2 - padding, -textHeight / 2 - padding, textWidth + padding * 2, textHeight + padding * 2);
context.globalAlpha = elementOpacity;
context.fillRect(-textWidth / 2 - 16, -textHeight / 2 - 16, textWidth + 32, textHeight + 32);
context.globalAlpha = alpha;
}
const firstY = -((lines.length - 1) * fontSize * lineHeight) / 2;
const anchorX = align === "left" ? -textWidth / 2 : align === "right" ? textWidth / 2 : 0;
context.fillStyle = styleValue(element, "fill_color", "#111111");
context.strokeStyle = styleValue(element, "stroke_color", "#000000");
context.lineWidth = styleValue(element, "stroke_width", 0);
context.shadowColor = layer.shadowColor;
context.shadowBlur = layer.shadowBlur;
context.shadowOffsetX = layer.shadowOffsetX;
context.shadowOffsetY = layer.shadowOffsetY;
const fillOverridden = editable && styleValue(element, "template_fill_overridden", false);
const patternImage = !fillOverridden && layer.fillPatternAssetId ? resourceImages[layer.fillPatternAssetId] : undefined;
context.fillStyle = patternImage ? context.createPattern(patternImage, "repeat") ?? layer.fillColor
: editable ? styleValue(element, "fill_color", layer.fillColor) : layer.fillColor;
context.strokeStyle = editable ? styleValue(element, "stroke_color", layer.strokeColor) : layer.strokeColor;
context.lineWidth = editable ? styleValue(element, "stroke_width", layer.strokeWidth) : layer.strokeWidth;
const centerY = (0.5 - layer.anchorY) * textHeight;
const firstY = centerY - ((lines.length - 1) * fontSize * lineHeight) / 2;
const anchorX = align === "left" ? -layer.anchorX * textWidth
: align === "right" ? (1 - layer.anchorX) * textWidth
: (0.5 - layer.anchorX) * textWidth;
lines.forEach((line, index) => {
const y = firstY + index * fontSize * lineHeight;
if (styleValue(element, "stroke_enabled", false) && context.lineWidth > 0) context.strokeText(line, anchorX, y);
if ((editable ? styleValue(element, "stroke_enabled", layer.strokeWidth > 0) : layer.strokeWidth > 0) && context.lineWidth > 0) {
context.strokeText(line, anchorX, y);
}
context.fillText(line, anchorX, y);
});
context.restore();
}
function drawTextTemplate(
context: CanvasRenderingContext2D,
element: CanvasState["elements"][number],
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
resourceImages: Readonly<Record<string, HTMLImageElement>>,
) {
const template = textTemplateById(element.template_or_asset_id);
if (!template || textTemplateFontOptions(template.templateId).some((font) => fontStatuses[font.fontId] !== "ready")) {
drawDynamicUnavailable(context, "原版字体不可用");
return;
}
const layers = [
...template.renderModel.imageLayers.map((layer) => ({ kind: "image" as const, layer })),
...template.renderModel.particleLayers.map((layer) => ({ kind: "particles" as const, layer })),
...template.renderModel.textLayers.map((layer) => ({ kind: "text" as const, layer })),
].toSorted((left, right) => left.layer.order - right.layer.order);
for (const item of layers) {
if (item.kind === "image") drawTemplateImageLayer(context, item.layer, resourceImages);
else if (item.kind === "particles") drawTemplateParticles(context, item.layer, resourceImages);
else drawTemplateTextLayer(context, element, item.layer, resourceImages);
}
}
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
@@ -182,22 +343,24 @@ function elementSelectionHalfSize(
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
) {
if (element.type === "color_card") {
if (element.style_id === "style_01") return { height: 76 * element.scale.y, width: 26 * element.scale.x };
if (element.style_id === "style_02") return { height: 77 * element.scale.y, width: 18 * element.scale.x };
if (element.style_id === "style_08") return { height: 10 * element.scale.y, width: 73 * element.scale.x };
return { height: 9 * element.scale.y, width: 78 * element.scale.x };
const half = COLOR_CARD_HALF_SIZES[element.style_id ?? ""] ?? { height: 24, width: 78 };
return { height: half.height * element.scale.y, width: half.width * element.scale.x };
}
if (element.type === "dynamic_sticker") {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
return { height: (model?.halfSize.height ?? 62) * element.scale.y, width: (model?.halfSize.width ?? 170) * element.scale.x };
}
if (element.type !== "text_template") return { height: 78 * element.scale.y, width: 78 * element.scale.x };
const template = textTemplateById(element.template_or_asset_id);
const fontId = fontIdForTextElement(element);
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
context.save();
const geometry = measureTextElement(context, element, fontId);
context.restore();
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
return {
height: Math.max(geometry.height / 2, template?.renderModel.halfSize.height ?? 0) * element.scale.y,
width: Math.max(geometry.width / 2, template?.renderModel.halfSize.width ?? 0) * element.scale.x,
};
}
function drawElement(
@@ -226,7 +389,7 @@ function drawElement(
const height = image.naturalHeight * scale;
context.drawImage(image, -width / 2, -height / 2, width, height);
}
} else if (element.type === "text_template") drawTextElement(context, element, fontStatuses);
} else if (element.type === "text_template") drawTextTemplate(context, element, fontStatuses, resourceImages);
else if (element.type === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
context.restore();
}
@@ -240,6 +403,27 @@ function loadCanvasImage(url: string) {
});
}
type CanvasImageLoader = (url: string) => Promise<HTMLImageElement | undefined>;
interface SceneResources {
background: HTMLImageElement | undefined;
resourceImages: Readonly<Record<string, HTMLImageElement>>;
}
function createCachedCanvasImageLoader(): CanvasImageLoader {
const cache = new Map<string, Promise<HTMLImageElement | undefined>>();
return (url) => {
const cached = cache.get(url);
if (cached) return cached;
const pending = loadCanvasImage(url).then((image) => {
if (!image) cache.delete(url);
return image;
});
cache.set(url, pending);
return pending;
};
}
function resourceUrlsForCanvas(canvasState: CanvasState) {
const imageReferences = new Map<string, string>();
for (const element of canvasState.elements) {
@@ -248,15 +432,26 @@ function resourceUrlsForCanvas(canvasState: CanvasState) {
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
for (const layer of model?.imageLayers ?? []) imageReferences.set(layer.assetId, dynamicImageUrl(element.resource_version, layer.assetId));
}
if (element.type === "text_template") {
for (const [assetId, url] of textTemplateImageUrls(element.template_or_asset_id, element.resource_version)) imageReferences.set(assetId, url);
}
}
return imageReferences;
}
async function loadSceneResources(canvasState: CanvasState, projectId: string) {
function sceneResourceKey(canvasState: CanvasState, projectId: string) {
const background = canvasState.background.asset_id
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
? `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`
: null;
const resources = [...resourceUrlsForCanvas(canvasState)].toSorted(([left], [right]) => left.localeCompare(right));
return JSON.stringify({ background, projectId, resources });
}
async function loadSceneResources(canvasState: CanvasState, projectId: string, loadImage: CanvasImageLoader = loadCanvasImage): Promise<SceneResources> {
const background = canvasState.background.asset_id
? loadImage(`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(canvasState.background.asset_id)}`)
: Promise.resolve(undefined);
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
const resources = Promise.all([...resourceUrlsForCanvas(canvasState)].map(async ([assetId, url]) => [assetId, await loadImage(url)] as const));
const [image, loaded] = await Promise.all([background, resources]);
return {
background: image,
@@ -274,9 +469,41 @@ function renderEditorScene(
context.clearRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.fillStyle = "#ffffff";
context.fillRect(0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.filter = cssFilterForBackground(canvasState.background.adjustments);
if (background) context.drawImage(background, 0, 0, canvasState.pixel_width, canvasState.pixel_height);
context.filter = "none";
if (background) {
const plan = backgroundDrawPlan(
{ height: background.naturalHeight || background.height, width: background.naturalWidth || background.width },
{ height: canvasState.pixel_height, width: canvasState.pixel_width },
canvasState.background.adjustments,
);
context.save();
context.filter = cssFilterForBackground(canvasState.background.adjustments);
context.drawImage(
background,
plan.source.x,
plan.source.y,
plan.source.width,
plan.source.height,
plan.destination.x,
plan.destination.y,
plan.destination.width,
plan.destination.height,
);
context.restore();
if (canvasState.background.adjustments.temperature !== 0 || canvasState.background.adjustments.sharpness !== 0) {
const x = Math.max(0, Math.floor(plan.destination.x));
const y = Math.max(0, Math.floor(plan.destination.y));
const right = Math.min(canvasState.pixel_width, Math.ceil(plan.destination.x + plan.destination.width));
const bottom = Math.min(canvasState.pixel_height, Math.ceil(plan.destination.y + plan.destination.height));
const width = right - x;
const height = bottom - y;
if (width > 0 && height > 0) {
const imageData = context.getImageData(x, y, width, height);
imageData.data.set(adjustBackgroundPixels(imageData.data, width, height, canvasState.background.adjustments));
context.putImageData(imageData, x, y);
}
}
}
for (const element of [...canvasState.elements].sort((left, right) => left.z_index - right.z_index)) {
drawElement(context, element, canvasState.pixel_width, canvasState.pixel_height, fontStatuses, resourceImages);
}
@@ -284,7 +511,10 @@ function renderEditorScene(
function requiredFontIds(canvasState: CanvasState) {
return canvasState.elements.flatMap((element) => {
if (element.type === "text_template") return [fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId));
if (element.type === "text_template") return [...new Set([
...textTemplateFontOptions(element.template_or_asset_id).map((font) => font.fontId),
...[fontIdForTextElement(element)].filter((fontId): fontId is string => Boolean(fontId)),
])];
if (element.type === "dynamic_sticker") return dynamicFontOptionsFor(element.template_or_asset_id).map((font) => font.fontId);
return [];
});
@@ -314,16 +544,29 @@ export function EditorStage(props: EditorStageProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const gestureRef = useRef<Gesture | undefined>(undefined);
const longPressRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const imageLoaderRef = useRef<CanvasImageLoader | undefined>(undefined);
const [sceneResources, setSceneResources] = useState<{ key: string; resources: SceneResources }>();
const [marquee, setMarquee] = useState<CanvasRect>();
const resourceKey = sceneResourceKey(props.canvasState, props.projectId);
if (!imageLoaderRef.current) imageLoaderRef.current = createCachedCanvasImageLoader();
useEffect(() => {
let active = true;
void loadSceneResources(props.canvasState, props.projectId, imageLoaderRef.current).then((resources) => {
if (active) setSceneResources({ key: resourceKey, resources });
});
return () => { active = false; };
}, [props.projectId, resourceKey]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return undefined;
canvas.width = props.canvasState.pixel_width;
canvas.height = props.canvasState.pixel_height;
if (canvas.width !== props.canvasState.pixel_width) canvas.width = props.canvasState.pixel_width;
if (canvas.height !== props.canvasState.pixel_height) canvas.height = props.canvasState.pixel_height;
const context = canvas.getContext("2d");
if (!context) return undefined;
if (!sceneResources || sceneResources.key !== resourceKey) return undefined;
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
renderEditorScene(context, props.canvasState, props.fontStatuses, image, resourceImages);
context.lineWidth = 4;
@@ -345,21 +588,22 @@ export function EditorStage(props: EditorStageProps) {
if (marquee) context.strokeRect(marquee.x * canvas.width, marquee.y * canvas.height, marquee.width * canvas.width, marquee.height * canvas.height);
context.restore();
};
void loadSceneResources(props.canvasState, props.projectId).then(({ background, resourceImages }) => {
if (!active) return;
render(background, resourceImages);
});
return () => { active = false; };
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
render(sceneResources.resources.background, sceneResources.resources.resourceImages);
return undefined;
}, [marquee, props.canvasState, props.fontStatuses, props.guides, props.selectedIds, resourceKey, sceneResources]);
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
function handlePointerDown(event: PointerEvent<HTMLCanvasElement>) {
if (event.button !== 0) return;
const start = pointFromEvent(event);
const bounds = event.currentTarget.getBoundingClientRect();
const start = pointFromClient(event.clientX, event.clientY, bounds);
const append = event.shiftKey;
const hit = props.onSelect(start, append);
gestureRef.current = { append, hit, longPressOpened: false, pointerId: event.pointerId, start };
gestureRef.current = {
append, bounds, hit, longPressOpened: false, moved: false, pointerId: event.pointerId, start,
startClient: { x: event.clientX, y: event.clientY },
};
event.currentTarget.setPointerCapture(event.pointerId);
longPressRef.current = setTimeout(() => {
const gesture = gestureRef.current;
@@ -375,9 +619,15 @@ export function EditorStage(props: EditorStageProps) {
props.onPointerMoved();
return;
}
const point = pointFromEvent(event);
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
if (!gesture.moved) {
if (clientDistance < DRAG_THRESHOLD_PX) return;
gesture.moved = true;
gesture.longPressOpened = false;
props.onDragStart();
}
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
if (Math.abs(delta.x) + Math.abs(delta.y) < 0.003) return;
if (longPressRef.current) clearTimeout(longPressRef.current);
props.onPointerMoved();
if (gesture.hit && !gesture.longPressOpened) props.onMovePreview(delta);
@@ -388,11 +638,18 @@ export function EditorStage(props: EditorStageProps) {
const gesture = gestureRef.current;
if (!gesture || gesture.pointerId !== event.pointerId) return;
if (longPressRef.current) clearTimeout(longPressRef.current);
const point = pointFromEvent(event);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
const moved = Math.abs(delta.x) + Math.abs(delta.y) >= 0.003;
const clientDistance = Math.hypot(event.clientX - gesture.startClient.x, event.clientY - gesture.startClient.y);
const moved = gesture.moved || clientDistance >= DRAG_THRESHOLD_PX;
if (moved && !gesture.moved && !gesture.longPressOpened) {
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
const delta = { x: point.x - gesture.start.x, y: point.y - gesture.start.y };
if (gesture.hit) props.onMovePreview(delta);
}
if (gesture.hit && moved && !gesture.longPressOpened) props.onMoveCommit();
else if (!gesture.hit && moved) props.onMarquee({ height: delta.y, width: delta.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
else if (!gesture.hit && moved) {
const point = pointFromClient(event.clientX, event.clientY, gesture.bounds);
props.onMarquee({ height: point.y - gesture.start.y, width: point.x - gesture.start.x, x: gesture.start.x, y: gesture.start.y }, gesture.append);
}
setMarquee(undefined);
gestureRef.current = undefined;
event.currentTarget.releasePointerCapture(event.pointerId);
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
:root {
--dada-interaction-duration: 120ms;
--dada-interaction-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
:where(button:not(:disabled), a[href], label:has(input:not(:disabled))) {
cursor: pointer;
}
:where(button:not(:disabled), a[href]) {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease,
color var(--dada-interaction-duration) ease,
opacity var(--dada-interaction-duration) ease;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)) {
transition:
border-color var(--dada-interaction-duration) ease,
box-shadow var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
:where(.product-page, .product-loading) :focus-visible {
outline: 2px solid #005fcc;
outline-offset: 3px;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):focus-visible {
border-color: #005fcc;
box-shadow: 0 0 0 3px rgb(0 95 204 / 16%);
}
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-template-preview,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
transition:
transform var(--dada-interaction-duration) var(--dada-interaction-easing),
box-shadow var(--dada-interaction-duration) var(--dada-interaction-easing),
border-color var(--dada-interaction-duration) ease,
background-color var(--dada-interaction-duration) ease;
}
@media (hover: hover) and (pointer: fine) {
:where(button:not(:disabled)):hover {
border-color: #111111;
box-shadow: 0 2px 0 rgb(17 17 17 / 35%);
}
:where(a[href]):hover {
color: #005fcc;
opacity: 0.78;
text-decoration-thickness: 2px;
text-underline-offset: 3px;
}
.product-header nav a:hover {
color: #111111;
background: #e9e9e5;
box-shadow: inset 0 -3px #111111;
opacity: 1;
}
.product-header nav a[aria-current="page"]:hover {
background: #f2f500;
}
.ratio-control label:hover span,
.reference-input:hover {
border-color: #111111;
background: #ffffd6;
box-shadow: inset 0 -3px #111111;
}
.project-card:hover {
border-color: #111111;
box-shadow: 0 3px 0 rgb(17 17 17 / 22%);
}
.editor-asset-tabs button:not(:disabled):hover,
.editor-source:hover,
.editor-sticker-grid button:not(:disabled):hover,
.editor-template-categories button:not(:disabled):hover,
.editor-template-grid button:not(:disabled):hover,
.editor-provider-grid button:not(:disabled):hover,
.editor-candidates button:not(:disabled):hover {
border-color: #111111;
background: #ffffd6;
}
:where(input:not(:disabled), select:not(:disabled), textarea:not(:disabled)):hover {
border-color: #111111;
}
}
@media (hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):hover {
transform: translateY(-1px);
}
.project-card:hover .project-preview img,
.editor-sticker-grid button:not(:disabled):hover .editor-sticker-preview {
transform: scale(1.02);
}
.editor-template-grid button:not(:disabled):hover .editor-template-mark,
.editor-template-grid button:not(:disabled):hover .editor-template-preview,
.editor-provider-grid button:not(:disabled):hover > :first-child {
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: no-preference) {
:where(button:not(:disabled), a[href]):active {
transform: translateY(1px);
transition-duration: 45ms;
}
}
:where(button:not(:disabled)):active {
box-shadow: inset 0 2px 0 rgb(17 17 17 / 24%);
}
:where(a[href]):active {
opacity: 0.58;
}
@media (prefers-reduced-motion: reduce) {
:where(button, a[href], input, select, textarea),
.project-card,
.project-preview img,
.ratio-control span,
.reference-input,
.editor-sticker-preview,
.editor-template-mark,
.editor-template-preview,
.editor-color-card-preview,
.editor-dynamic-preview,
.editor-source-preview-canvas,
.editor-thumb {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
}
+2
View File
@@ -16,6 +16,8 @@ import { ProjectDetailPage, ProjectsPage, WorkspacePage } from "./project-pages.
import { EditorPage } from "./editor-page.js";
import { AdminOverviewPage, AdminPlaceholderPage, AdminProtectedRoute } from "./admin-shell.js";
import "./interaction-feedback.css";
const root = document.getElementById("root");
if (!root) {
+125
View File
@@ -27,6 +27,17 @@ export const COLOR_CARD_SOURCE_GEOMETRY = {
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
} as const;
export const COLOR_CARD_HALF_SIZES: Readonly<Record<string, { height: number; width: number }>> = {
style_01: { height: 76, width: 26 }, style_02: { height: 77, width: 18 },
style_03: { height: 75, width: 22 }, style_04: { height: 75, width: 18 },
style_05: { height: 18, width: 78 }, style_06: { height: 16, width: 78 },
style_07: { height: 20, width: 78 }, style_08: { height: 10, width: 73 },
style_09: { height: 75, width: 58 }, style_10: { height: 75, width: 60 },
style_11: { height: 18, width: 78 }, style_12: { height: 34, width: 70 },
style_13: { height: 75, width: 18 }, style_14: { height: 28, width: 60 },
style_15: { height: 16, width: 78 }, style_16: { height: 9, width: 78 },
};
function normalizedHex(value: string) {
return value.toUpperCase();
}
@@ -139,6 +150,65 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
});
return;
}
if (element.style_id === "style_03") {
colors.forEach((color, index) => {
context.fillStyle = color;
context.fillRect(-22, -75 + index * 30, 44, 29);
});
return;
}
if (element.style_id === "style_04") {
context.strokeStyle = "#ffffff";
context.lineWidth = 2;
context.beginPath();
context.moveTo(0, -75);
context.lineTo(0, 75);
context.stroke();
colors.forEach((color, index) => {
context.fillStyle = color;
context.beginPath();
context.arc(0, -60 + index * 30, 9, 0, Math.PI * 2);
context.fill();
});
return;
}
if (["style_05", "style_06", "style_07", "style_11"].includes(element.style_id ?? "")) {
if (element.style_id === "style_11") {
context.strokeStyle = "#ffffff";
context.lineWidth = 3;
context.strokeRect(-78, -18, 156, 36);
}
colors.forEach((color, index) => {
const left = -72 + index * 29;
context.fillStyle = color;
context.fillRect(left, -10, 28, 20);
if (element.style_id === "style_07") {
context.fillStyle = "#ffffff";
context.beginPath();
context.arc(left + 14, -16, 4, 0, Math.PI * 2);
context.fill();
}
});
if (element.style_id === "style_05") {
context.fillStyle = "#111111";
context.fillRect(-78, -18, 20, 36);
context.fillStyle = "#ffffff";
context.font = "700 7px Arial, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText("C", -68, 0);
}
if (element.style_id === "style_06") {
context.fillStyle = "#ffffff";
context.beginPath();
context.moveTo(-4, -16);
context.lineTo(4, -16);
context.lineTo(0, -10);
context.closePath();
context.fill();
}
return;
}
if (element.style_id === "style_08") {
colors.forEach((color, index) => {
const left = -73 + index * 29.2;
@@ -159,6 +229,61 @@ export function drawColorCard(context: CanvasRenderingContext2D, element: Canvas
});
return;
}
if (element.style_id === "style_09" || element.style_id === "style_10") {
context.fillStyle = "#ffffff";
context.fillRect(-58, -75, 116, 150);
colors.forEach((color, index) => {
context.fillStyle = color;
context.fillRect(-50, -64 + index * 23, 100, 22);
});
context.fillStyle = "#111111";
context.font = "700 8px Arial, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(element.style_id === "style_09" ? "COLOR PALETTE" : "FIVE COLORS", 0, 63);
return;
}
if (element.style_id === "style_12") {
const positions = [[-52, -18], [0, -18], [52, -18], [-26, 18], [26, 18]] as const;
colors.forEach((color, index) => {
const position = positions[index]!;
context.fillStyle = color;
context.fillRect(position[0] - 24, position[1] - 14, 48, 28);
});
return;
}
if (element.style_id === "style_13") {
colors.forEach((color, index) => {
context.fillStyle = color;
context.beginPath();
context.arc(0, -60 + index * 30, 11, 0, Math.PI * 2);
context.fill();
});
return;
}
if (element.style_id === "style_14") {
colors.forEach((color, index) => {
context.globalAlpha = 0.9;
context.fillStyle = color;
context.beginPath();
context.arc(-40 + index * 20, 0, 24, 0, Math.PI * 2);
context.fill();
});
context.globalAlpha = 1;
return;
}
if (element.style_id === "style_15") {
colors.forEach((color, index) => {
context.fillStyle = color;
context.strokeStyle = "#ffffff";
context.lineWidth = 3;
context.beginPath();
context.arc(-58 + index * 29, 0, 12, 0, Math.PI * 2);
context.fill();
context.stroke();
});
return;
}
context.fillStyle = "#ffffff";
context.beginPath();
context.moveTo(-78, -9);
+25 -8
View File
@@ -592,15 +592,26 @@
height: 18px;
}
.project-placeholder {
.project-placeholder,
.project-preview {
display: grid;
height: 154px;
grid-template-columns: repeat(4, 1fr);
overflow: hidden;
border-bottom: 1px solid #a5a59f;
background: #d8d8d3;
}
.project-placeholder {
grid-template-columns: repeat(4, 1fr);
}
.project-preview img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.project-placeholder span {
display: grid;
place-items: end center;
@@ -935,9 +946,9 @@
font-size: 19px;
}
.project-current > .project-placeholder {
height: auto;
min-height: 480px;
.project-current > .project-placeholder,
.project-current > .project-preview {
height: 480px;
border: 1px solid #73736d;
}
@@ -945,6 +956,10 @@
font-size: 80px;
}
.project-current > .project-preview img {
object-fit: contain;
}
.project-actions {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -1013,7 +1028,8 @@
padding: 6px;
}
.project-history li .project-placeholder {
.project-history li .project-placeholder,
.project-history li .project-preview {
height: 88px;
border: 0;
}
@@ -1439,8 +1455,9 @@
flex-direction: column;
}
.project-current > .project-placeholder {
min-height: 360px;
.project-current > .project-placeholder,
.project-current > .project-preview {
height: 360px;
}
.local-only-footer {
+56 -4
View File
@@ -27,6 +27,7 @@ interface LocalDataPayload {
}
interface AccountSettingsPayload {
csrf_token: string;
local_data: LocalDataPayload;
}
@@ -231,6 +232,33 @@ function ProjectPlaceholder({ ratio, status }: { ratio: Ratio; status: ProjectSt
);
}
function ProjectPreview({ alt, imageId, loading = "lazy", projectId, ratio, status }: {
alt: string;
imageId: string | null;
loading?: "eager" | "lazy";
projectId: string;
ratio: Ratio;
status: ProjectStatus;
}) {
const [loadFailed, setLoadFailed] = useState(false);
useEffect(() => setLoadFailed(false), [imageId, projectId]);
if (!imageId || loadFailed) return <ProjectPlaceholder ratio={ratio} status={status} />;
return (
<div className="project-preview" data-ratio={ratio} data-status={status}>
<img
alt={alt}
decoding="async"
loading={loading}
onError={() => setLoadFailed(true)}
src={`/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(imageId)}`}
/>
</div>
);
}
export function WorkspacePage() {
const promptId = useId();
const [session, setSession] = useState<SessionPayload>();
@@ -269,7 +297,12 @@ export function WorkspacePage() {
if (!active) return;
if (modelResult.status === "fulfilled") setModels(modelResult.value);
if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value);
if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data);
const settings = settingsResult.status === "fulfilled" ? settingsResult.value : undefined;
if (settings) {
setLocalData(settings.local_data);
// Account settings rotates the mutation token; keep the workspace token current.
setSession((current) => current ? { ...current, csrf_token: settings.csrf_token } : current);
}
setGenerationStateLoaded(true);
});
}).catch((error) => {
@@ -568,7 +601,13 @@ function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, o
/>
</label>
) : null}
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<ProjectPreview
alt={`${project.name}预览图`}
imageId={project.current_image_id}
projectId={project.project_id}
ratio={project.ratio}
status={project.status}
/>
<div className="project-card-body">
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
<p>{project.successful_image_count} · {project.ratio}</p>
@@ -953,7 +992,14 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<div className="project-detail-grid">
<section className="project-current" aria-labelledby="current-image-title">
<header><h2 id="current-image-title"></h2><span>{project.pixel_width ?? 1080} × {project.pixel_height ?? 1440}</span></header>
<ProjectPlaceholder ratio={project.ratio} status={project.status} />
<ProjectPreview
alt={`${project.name}当前底图`}
imageId={project.current_image_id}
loading="eager"
projectId={project.project_id}
ratio={project.ratio}
status={project.status}
/>
<div className="project-actions">
<button disabled={conflicted || atHistoryLimit} onClick={() => window.location.assign(`/app?continue=${project.project_id}`)} type="button"></button>
{conflicted || !project.current_image_id ? <button disabled type="button"></button> : <a href={`/app/projects/${project.project_id}/editor`}></a>}
@@ -970,7 +1016,13 @@ export function ProjectDetailPage({ projectId }: { projectId: string }) {
<ol>
{project.images.toReversed().map((image, index) => (
<li key={image.image_id} data-current={image.image_id === project.current_image_id}>
<ProjectPlaceholder ratio={project.ratio} status="active" />
<ProjectPreview
alt={`生成结果 ${project.images.length - index}`}
imageId={image.image_id}
projectId={project.project_id}
ratio={project.ratio}
status="active"
/>
<div><strong> {project.images.length - index}</strong><time dateTime={image.created_at}>{formatUpdatedAt(image.created_at)}</time><a href={`/api/v1/private-assets/projects/${project.project_id}/images/${image.image_id}`}></a></div>
</li>
))}
+200 -67
View File
@@ -2,12 +2,86 @@ import type { CanvasState } from "@dada/shared-contracts";
import { P0A_COMPLEX_RELEASE_VERSION, P0A_REQUIRED_FONT_PANEL_IDS, P0A_TEXT_TEMPLATE_IDS } from "@dada/template-registry";
import type { CanvasElementIdentity } from "./editor-elements.js";
import complexAssetCatalog from "./generated/complex-assets.json";
type CanvasElement = CanvasState["elements"][number];
export type TextTemplateCategory = "flower" | "simple" | "tag" | "title";
export type TextAlign = "center" | "left" | "right";
export interface TextTemplateImageLayer {
alpha: number;
anchorX: number;
anchorY: number;
assetId: string;
height: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
width: number;
x: number;
y: number;
ninePatch?: TextTemplateNinePatch;
}
export interface TextTemplateNinePatch {
bottom: number;
left: number;
right: number;
sourceHeight: number;
sourceWidth: number;
top: number;
}
export interface TextTemplateParticleLayer extends TextTemplateImageLayer {
alpha: number;
atlasColumns: number;
atlasRows: number;
color: string;
density: number;
particleHeight: number;
particleWidth: number;
randomizeAngle: number;
randomizePosition: number;
}
export interface TextTemplateTextLayer {
alpha: number;
align: TextAlign;
anchorX: number;
anchorY: number;
editable: boolean;
fillColor: string;
fillPatternAssetId?: string;
fontId: string;
fontSize: number;
height: number;
letterSpacing: number;
lineHeight: number;
order: number;
rotation: number;
scaleX: number;
scaleY: number;
shadowBlur: number;
shadowColor: string;
shadowOffsetX: number;
shadowOffsetY: number;
strokeColor: string;
strokeWidth: number;
text: string;
width: number;
x: number;
y: number;
}
export interface TextTemplateRenderModel {
halfSize: { height: number; width: number };
imageLayers: readonly TextTemplateImageLayer[];
particleLayers: readonly TextTemplateParticleLayer[];
textLayers: readonly TextTemplateTextLayer[];
}
export interface TextTemplateDefinition {
available: boolean;
catalogOrder: number;
@@ -17,6 +91,8 @@ export interface TextTemplateDefinition {
defaultText: string;
displayName: string;
fontUrl?: string;
previewUrl?: string;
renderModel: TextTemplateRenderModel;
resourceClass: "parameter_only" | "zip_template";
resourceVersion: string;
templateId: string;
@@ -43,6 +119,9 @@ export interface TextStylePatch {
}
const resourceVersion = P0A_COMPLEX_RELEASE_VERSION;
const textResourceRevision = complexAssetCatalog.text_resource_revision;
const publicTextAssetUrl = (assetId: string, version = resourceVersion) =>
`/api/v1/assets/public/${version}/${assetId}?revision=${textResourceRevision}`;
const defaults = {
background_color: "#FFE62C",
background_enabled: false,
@@ -56,85 +135,124 @@ const defaults = {
text_align: "center",
} as const;
type CatalogSeed = [id: string, category: TextTemplateCategory, displayName: string, defaultText: string, defaultFontId: string, available?: boolean, resourceClass?: "parameter_only"];
const textCatalogById = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
const seeds: readonly CatalogSeed[] = [
["FLOWER001", "flower", "春日计划", "春日计划", "FONT011", true],
["FLOWER002", "flower", "笑不活了", "笑不活了", "FLOWER002_FONT"],
["FLOWER003", "flower", "人生照片", "人生照片", "FONT008"],
["FLOWER004", "flower", "我的日常生活", "我的日常生活", "FLOWER004_FONT"],
["FLOWER005", "flower", "碎片生活", "碎片生活", "FONT008"],
["FLOWER006", "flower", "闪光瞬间", "闪光瞬间", "FLOWER006_FONT"],
["FLOWER007", "flower", "好柿花生", "好柿花生", "FONT046", false, "parameter_only"],
["FLOWER008", "flower", "Vlog.", "Vlog.", "FONT005"],
["H001", "title", "电影生活记录", "电影生活记录", "H001_FONT"],
["H002", "title", "30°C", "30°C", "H002_FONT"],
["H003", "title", "生活分享家", "生活分享家", "FONT039", true],
["H004", "title", "快乐充值成功", "快乐充值成功", "FONT046"],
["H005", "title", "日常的镜头", "日常的镜头", "H005_FONT"],
["H006", "title", "慢生活指南", "慢生活指南", "FONT052"],
["H007", "title", "做个有闲人", "做个有闲人", "H007_FONT"],
["H008", "title", "海滩日记", "海滩日记", "H008_FONT"],
["TAG001", "tag", "自定义标签", "自定义标签", "FONT027"],
["TAG002", "tag", "自定义标签", "自定义标签", "FONT043"],
["TAG003", "tag", "打卡x1", "打卡x1", "FONT043"],
["TAG004", "tag", "自定义标签", "自定义标签", "TAG004_FONT"],
["TAG005", "tag", "自定义标签", "自定义标签", "FONT008"],
["TAG006", "tag", "City Walk", "City Walk", "TAG006_FONT"],
["TAG007", "tag", "打卡x1", "打卡x1", "FONT043"],
["TAG051", "tag", "自定义标签", "自定义标签", "FONT022"],
["SIMPLE001", "simple", "碎片回忆录", "碎片回忆录", "SIMPLE001_FONT"],
["SIMPLE002", "simple", "秋天的信笺", "秋天的信笺", "SIMPLE002_FONT"],
["SIMPLE003", "simple", "返航时海鸟追着船盘旋", "返航时海鸟追着船盘旋", "SIMPLE003_FONT"],
["SIMPLE004", "simple", "下段旅程,幸福丰盛。", "下段旅程,幸福丰盛。", "SIMPLE002_FONT"],
["SIMPLE005", "simple", "见信好。", "见信好。", "SIMPLE005_FONT"],
["SIMPLE006", "simple", "万物回春", "万物回春", "SIMPLE001_FONT"],
["SIMPLE007", "simple", "周而复始。", "周而复始。", "SIMPLE007_FONT"],
["SIMPLE008", "simple", "周五愉快", "周五愉快", "SIMPLE008_FONT"],
];
function imageLayer(layer: {
alpha?: number; anchor_x?: number; anchor_y?: number; asset_id: string; height: number; nine_patch?: {
bottom: number; left: number; right: number; source_height: number; source_width: number; top: number;
}; order: number; rotation: number; scale_x: number; scale_y: number; width: number; x: number; y: number;
}): TextTemplateImageLayer {
return {
alpha: layer.alpha ?? 1, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
assetId: layer.asset_id, height: layer.height, order: layer.order, rotation: layer.rotation,
scaleX: layer.scale_x, scaleY: layer.scale_y, width: layer.width, x: layer.x, y: layer.y,
...(layer.nine_patch ? { ninePatch: {
bottom: layer.nine_patch.bottom, left: layer.nine_patch.left, right: layer.nine_patch.right,
sourceHeight: layer.nine_patch.source_height, sourceWidth: layer.nine_patch.source_width, top: layer.nine_patch.top,
} } : {}),
};
}
const seedById = new Map(seeds.map((seed) => [seed[0], seed]));
interface RawTextLayer {
alpha?: number; align: string; anchor_x?: number; anchor_y?: number; editable: boolean; fill_color: string; fill_pattern_asset_id?: string; font_id: string; font_size: number;
height: number; letter_spacing: number; line_height: number; order: number; rotation: number; scale_x: number; scale_y: number;
shadow_blur: number; shadow_color: string; shadow_offset_x: number; shadow_offset_y: number; stroke_color: string;
stroke_width: number; text: string; width: number; x: number; y: number;
}
function renderModel(item: (typeof complexAssetCatalog.text_templates)[number]): TextTemplateRenderModel {
return {
halfSize: { height: item.render_model.half_size.height, width: item.render_model.half_size.width },
imageLayers: item.render_model.image_layers.map(imageLayer),
particleLayers: item.render_model.particle_layers.map((layer) => ({
...imageLayer(layer), alpha: layer.alpha, atlasColumns: layer.atlas_columns, atlasRows: layer.atlas_rows,
color: layer.color, density: layer.density, particleHeight: layer.particle_height,
particleWidth: layer.particle_width, randomizeAngle: layer.randomize_angle, randomizePosition: layer.randomize_position,
})),
textLayers: item.render_model.text_layers.map((value) => {
const layer = value as unknown as RawTextLayer;
return {
alpha: layer.alpha ?? 1, align: layer.align as TextAlign, anchorX: layer.anchor_x ?? 0.5, anchorY: layer.anchor_y ?? 0.5,
editable: layer.editable, fillColor: layer.fill_color,
...(layer.fill_pattern_asset_id ? { fillPatternAssetId: layer.fill_pattern_asset_id } : {}),
fontId: layer.font_id, fontSize: layer.font_size, height: layer.height, letterSpacing: layer.letter_spacing,
lineHeight: layer.line_height, order: layer.order, rotation: layer.rotation, scaleX: layer.scale_x,
scaleY: layer.scale_y, shadowBlur: layer.shadow_blur, shadowColor: layer.shadow_color,
shadowOffsetX: layer.shadow_offset_x, shadowOffsetY: layer.shadow_offset_y,
strokeColor: layer.stroke_color, strokeWidth: layer.stroke_width, text: layer.text,
width: layer.width, x: layer.x, y: layer.y,
};
}),
};
}
export const P0A_TEXT_TEMPLATES: readonly TextTemplateDefinition[] = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
const seed = seedById.get(templateId);
if (!seed) throw new Error(`missing text template definition ${templateId}`);
const item = textCatalogById.get(templateId);
if (!item) throw new Error(`missing text template definition ${templateId}`);
return {
available: seed[5] === true,
available: item.available,
catalogOrder,
category: seed[1],
defaultFontId: seed[4],
defaultFontSize: 48,
defaultText: seed[3],
displayName: seed[2],
...(seed[5] === true ? { fontUrl: `/api/v1/assets/public/${resourceVersion}/${seed[4]}` } : {}),
resourceClass: seed[6] ?? "zip_template",
category: item.category as TextTemplateCategory,
defaultFontId: item.default_font_id,
defaultFontSize: item.default_font_size,
defaultText: item.default_text,
displayName: item.display_name,
fontUrl: publicTextAssetUrl(item.default_font_id),
...(item.preview_asset_id ? { previewUrl: publicTextAssetUrl(item.preview_asset_id) } : {}),
renderModel: renderModel(item),
resourceClass: item.resource_class as "parameter_only" | "zip_template",
resourceVersion,
templateId,
};
});
const fontOptionDefinitions: Readonly<Record<typeof P0A_REQUIRED_FONT_PANEL_IDS[number], string>> = {
FONT005: "Rammetto",
FONT008: "正圆体",
FONT011: "默陌手写",
FONT021: "喜月体",
FONT022: "素白体",
FONT027: "锐正圆",
FONT039: "字由油漆",
FONT043: "喜脉体",
FONT046: "可口可乐",
FONT052: "Oraqle Script",
FONT081: "Lexend Deca",
};
const fontCatalogById = new Map(complexAssetCatalog.font_panel_items.map((item) => [item.font_id, item]));
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => ({
displayName: fontOptionDefinitions[fontId],
fontId,
url: `/api/v1/assets/public/${resourceVersion}/${fontId}`,
}));
export const P0A_FONT_OPTIONS: readonly FontOption[] = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId) => {
const item = fontCatalogById.get(fontId);
if (!item) throw new Error(`missing font panel definition ${fontId}`);
return {
displayName: item.display_name,
fontId,
url: publicTextAssetUrl(fontId),
};
});
const templateFontOptions: readonly FontOption[] = P0A_TEXT_TEMPLATES.flatMap((template) => {
const fontIds = [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))];
return fontIds.map((fontId, index) => ({
displayName: `${template.displayName}原版字体${fontIds.length > 1 ? ` ${index + 1}` : ""}`,
fontId,
url: publicTextAssetUrl(fontId, template.resourceVersion),
}));
});
const allFontOptions = new Map([...P0A_FONT_OPTIONS, ...templateFontOptions].map((option) => [option.fontId, option]));
export function fontOption(fontId: string) {
return P0A_FONT_OPTIONS.find((option) => option.fontId === fontId);
return allFontOptions.get(fontId);
}
export function textTemplateById(templateId: string) {
return P0A_TEXT_TEMPLATES.find((template) => template.templateId === templateId);
}
export function textTemplateFontOptions(templateId: string) {
const template = textTemplateById(templateId);
if (!template) return [];
return [...new Set(template.renderModel.textLayers.map((layer) => layer.fontId))]
.map((fontId) => fontOption(fontId)).filter((option): option is FontOption => option !== undefined);
}
export function textTemplateImageUrls(templateId: string, version = resourceVersion) {
const template = textTemplateById(templateId);
if (!template) return new Map<string, string>();
const assetIds = new Set([
...template.renderModel.imageLayers.map((layer) => layer.assetId),
...template.renderModel.particleLayers.map((layer) => layer.assetId),
...template.renderModel.textLayers.flatMap((layer) => layer.fillPatternAssetId ? [layer.fillPatternAssetId] : []),
]);
return new Map([...assetIds].map((assetId) => [assetId, publicTextAssetUrl(assetId, version)]));
}
export function fontIdForTextElement(element: CanvasElement) {
@@ -162,7 +280,19 @@ function isStep(value: number, minimum: number, step: number) {
}
function templateStyle(template: TextTemplateDefinition): Record<string, string | number | boolean | null> {
return { ...defaults, default_font_id: template.defaultFontId };
const primary = template.renderModel.textLayers.find((layer) => layer.editable) ?? template.renderModel.textLayers[0];
return {
...defaults,
default_font_id: template.defaultFontId,
fill_color: primary?.fillColor ?? defaults.fill_color,
letter_spacing: primary?.letterSpacing ?? defaults.letter_spacing,
line_height: primary?.lineHeight ?? defaults.line_height,
stroke_color: primary?.strokeColor ?? defaults.stroke_color,
stroke_enabled: (primary?.strokeWidth ?? 0) > 0,
stroke_width: primary?.strokeWidth ?? defaults.stroke_width,
template_fill_overridden: false,
text_align: primary?.align ?? defaults.text_align,
};
}
export function searchTextTemplates(
@@ -238,7 +368,10 @@ export class TextEditSession {
setStyle(patch: TextStylePatch) {
const style = { ...(this.draft.style_parameters ?? {}) };
if (patch.fillColor !== undefined) style.fill_color = checkedColor(patch.fillColor);
if (patch.fillColor !== undefined) {
style.fill_color = checkedColor(patch.fillColor);
style.template_fill_overridden = true;
}
if (patch.strokeColor !== undefined) style.stroke_color = checkedColor(patch.strokeColor);
if (patch.backgroundColor !== undefined) style.background_color = checkedColor(patch.backgroundColor);
if (patch.strokeEnabled !== undefined) style.stroke_enabled = patch.strokeEnabled;
+43 -5
View File
@@ -1,10 +1,44 @@
import type { ArchivedFontStatus } from "./text-font-loader.js";
import { useEffect, useRef, type CSSProperties } from "react";
import { fontFamilyName, type ArchivedFontStatus } from "./text-font-loader.js";
import { searchTextTemplates, type TextTemplateCategory, type TextTemplateDefinition } from "./text-assets.js";
const categories: Array<{ id?: TextTemplateCategory; label: string }> = [
{ label: "全部" }, { id: "flower", label: "花字" }, { id: "title", label: "标题" }, { id: "tag", label: "标签" }, { id: "simple", label: "简约" },
];
function LiveTemplatePreview(props: { onEnsure: () => void; template: TextTemplateDefinition }) {
const previewRef = useRef<HTMLSpanElement>(null);
const layer = props.template.renderModel.textLayers.find((item) => item.editable) ?? props.template.renderModel.textLayers[0]!;
useEffect(() => {
const node = previewRef.current;
if (!node || typeof IntersectionObserver === "undefined") {
props.onEnsure();
return;
}
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
props.onEnsure();
});
observer.observe(node);
return () => observer.disconnect();
}, [props.onEnsure, props.template.templateId]);
const scale = Math.min(1, 38 / Math.max(1, layer.height), 112 / Math.max(1, layer.width));
const style: CSSProperties = {
color: layer.fillColor,
fontFamily: `"${fontFamilyName(layer.fontId)}"`,
fontSize: `${layer.fontSize * scale}px`,
letterSpacing: `${layer.letterSpacing * scale}px`,
lineHeight: layer.lineHeight,
textShadow: layer.shadowBlur > 0 || layer.shadowOffsetX !== 0 || layer.shadowOffsetY !== 0
? `${layer.shadowOffsetX * scale}px ${layer.shadowOffsetY * scale}px ${layer.shadowBlur * scale}px ${layer.shadowColor}`
: undefined,
transform: `rotate(${layer.rotation}deg) scale(${layer.scaleX}, ${layer.scaleY})`,
WebkitTextStroke: layer.strokeWidth > 0 ? `${layer.strokeWidth * scale}px ${layer.strokeColor}` : undefined,
};
return <span className="editor-template-live-preview" ref={previewRef} style={style}>{layer.text || props.template.defaultText}</span>;
}
export function TextTemplatePanel(props: {
canAdd: boolean;
category?: TextTemplateCategory;
@@ -12,6 +46,7 @@ export function TextTemplatePanel(props: {
onAdd: (template: TextTemplateDefinition) => void;
onCategory: (category?: TextTemplateCategory) => void;
onQuery: (query: string) => void;
onEnsure: (template: TextTemplateDefinition) => void;
onRetry: () => void;
query: string;
recentIds: readonly string[];
@@ -31,12 +66,15 @@ export function TextTemplatePanel(props: {
<div className="editor-template-grid">
{visible.map((template) => {
const status = props.fontStatuses[template.defaultFontId] ?? "idle";
const unavailable = !template.available || status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
<span className={`editor-template-mark ${template.category}`}>{template.displayName.slice(0, 2)}</span>
const unavailable = !template.available;
const retryable = template.available && status === "unavailable";
return <button aria-label={`${template.templateId} ${template.displayName}${unavailable ? " 素材暂不可用" : retryable ? " 字体待重试" : ""}`} disabled={!props.canAdd || unavailable || status === "loading"} key={template.templateId} onClick={() => props.onAdd(template)} type="button">
{template.previewUrl
? <img alt="" className="editor-template-preview" decoding="async" loading="lazy" src={template.previewUrl} />
: <LiveTemplatePreview onEnsure={() => props.onEnsure(template)} template={template} />}
<strong>{template.templateId}</strong>
<span>{template.displayName}</span>
{unavailable ? <small></small> : status === "loading" ? <small></small> : null}
{unavailable ? <small></small> : retryable ? <small></small> : status === "loading" ? <small></small> : null}
</button>;
})}
</div>
+19
View File
@@ -124,6 +124,25 @@ button {
margin-inline: auto;
}
.auth-test-entry {
margin-bottom: 18px;
border-bottom: 1px solid #b4b4af;
padding-bottom: 18px;
}
.auth-test-entry .auth-primary {
margin-top: 0;
border-color: #111111;
background: #111111;
color: #f2f500;
}
.auth-test-entry .auth-primary:disabled {
border-color: #777773;
background: #deded9;
color: #777773;
}
.auth-tabs {
display: grid;
grid-template-columns: 1fr 1fr;
+44
View File
@@ -77,6 +77,9 @@ export function UserAuthPage() {
const [sendState, setSendState] = useState<SendState>("idle");
const [countdown, setCountdown] = useState(0);
const [error, setError] = useState<string>();
const [localTestAvailable, setLocalTestAvailable] = useState(false);
const [localTestError, setLocalTestError] = useState<string>();
const [localTestSubmitting, setLocalTestSubmitting] = useState(false);
const [submitting, setSubmitting] = useState(false);
const emailValid = /^[^@\s]+@[^@\s]+$/.test(email);
const registrationReady = Boolean(
@@ -93,6 +96,18 @@ export function UserAuthPage() {
return () => window.clearInterval(timer);
}, [countdown]);
useEffect(() => {
const controller = new AbortController();
void fetch("/api/v1/auth/local-test", { credentials: "same-origin", signal: controller.signal })
.then(async (response) => {
if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return;
const body = await response.json() as { available?: boolean };
if (body.available === true) setLocalTestAvailable(true);
})
.catch(() => undefined);
return () => controller.abort();
}, []);
useEffect(() => {
if (!noticeOpen) return;
const previousOverflow = document.body.style.overflow;
@@ -255,6 +270,27 @@ export function UserAuthPage() {
}
}
async function enterLocalTest() {
if (localTestSubmitting) return;
setLocalTestSubmitting(true);
setLocalTestError(undefined);
try {
const response = await fetch("/api/v1/auth/local-test", {
credentials: "same-origin",
method: "POST",
});
if (!response.ok) {
setLocalTestError("本机测试会话未能建立,请重试。");
return;
}
window.location.assign("/app");
} catch {
setLocalTestError("本机测试会话未能建立,请重试。");
} finally {
setLocalTestSubmitting(false);
}
}
return (
<>
<main className="auth-page">
@@ -271,6 +307,14 @@ export function UserAuthPage() {
<section className="auth-content">
<a className="auth-admin-link" href="/admin/login"></a>
<div className="auth-panel">
{localTestAvailable ? (
<div className="auth-test-entry">
<button className="auth-primary" disabled={localTestSubmitting} onClick={enterLocalTest} type="button">
{localTestSubmitting ? "正在进入" : "直接进入本机测试"}
</button>
{localTestError ? <p className="auth-error" role="alert">{localTestError}</p> : null}
</div>
) : null}
<div className="auth-tabs" role="tablist" aria-label="认证方式">
<button
aria-selected={mode === "login"}
+1
View File
@@ -69,6 +69,7 @@ async function checkSupport() {
browserValue.textContent = `${result.browser.brand} ${result.browser.major}`;
supportedValue.textContent = supportedLabel(result.supported_browsers);
window.dispatchEvent(new CustomEvent("dada:support-ready"));
window.location.replace(window.location.pathname.startsWith("/admin") ? "/admin" : "/app");
return;
}
showBlocked(
+6
View File
@@ -7,6 +7,11 @@ export interface GenerationAdapterRequest {
prompt: string;
ratio: "3:4" | "1:1" | "4:3" | "9:16";
referenceAssetIds: readonly string[];
referenceImages?: readonly {
assetId: string;
bytes: Buffer;
mimeType: "image/jpeg" | "image/png" | "image/webp";
}[];
}
export interface NormalizedGenerationOutput {
@@ -27,6 +32,7 @@ export type GenerationAdapterResult =
};
export interface GenerationAdapter {
dispose?(): void;
start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult>;
poll?(upstreamJobReference: string): Promise<GenerationAdapterResult>;
}
+47
View File
@@ -0,0 +1,47 @@
import type { GenerationAdapter } from "./ai-adapter-contract.js";
export type AiRuntimeProbeResult =
| {
code: "ai_probe_passed";
mime_type: "image/jpeg" | "image/png" | "image/webp";
pixel_height: number;
pixel_width: number;
real_calls: 1;
success: true;
}
| {
code: "ai_probe_failed";
error_category: string;
real_calls: 1;
success: false;
};
export async function runAiRuntimeProbe(adapter: GenerationAdapter): Promise<AiRuntimeProbeResult> {
const result = await adapter.start({
configSnapshot: { probe: true },
generationId: "00000000-0000-4000-8000-000000000002",
modelId: "gemini-3.1-flash-image-preview",
prompt: "生成一张简洁的红蓝几何色块测试图,不含文字。",
ratio: "1:1",
referenceAssetIds: [],
});
if (result.status === "failed") {
return { code: "ai_probe_failed", error_category: result.category, real_calls: 1, success: false };
}
if (result.status !== "completed" || result.outputs.length !== 1) {
return { code: "ai_probe_failed", error_category: "gateway_contract_invalid", real_calls: 1, success: false };
}
const output = result.outputs[0]!;
try {
return {
code: "ai_probe_passed",
mime_type: output.mimeType,
pixel_height: output.pixelHeight,
pixel_width: output.pixelWidth,
real_calls: 1,
success: true,
};
} finally {
output.bytes.fill(0);
}
}
@@ -0,0 +1,35 @@
export interface GenerationPollingProcessor {
processNext(): Promise<unknown>;
}
export class GenerationPollingLoop {
private closed = false;
private inFlight = false;
private readonly timer: ReturnType<typeof setInterval>;
constructor(
private readonly processor: GenerationPollingProcessor,
intervalMilliseconds = 250,
) {
if (!Number.isSafeInteger(intervalMilliseconds) || intervalMilliseconds <= 0) {
throw new Error("generation_polling_interval_invalid");
}
this.timer = setInterval(() => this.run(), intervalMilliseconds);
}
close() {
if (this.closed) return;
this.closed = true;
clearInterval(this.timer);
}
private run() {
if (this.closed || this.inFlight) return;
this.inFlight = true;
void this.processor.processNext()
.catch(() => undefined)
.finally(() => {
this.inFlight = false;
});
}
}
+36 -3
View File
@@ -1,11 +1,11 @@
import { createHash, randomUUID } from "node:crypto";
import { existsSync, mkdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import type { GenerationAdapter, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
import type { GenerationAdapter, GenerationAdapterRequest, GenerationAdapterResult, NormalizedGenerationOutput } from "./ai-adapter-contract.js";
import { GatewayBalanceRuntime } from "./gateway-balance-runtime.js";
import { generationErrorRegistry, type GenerationErrorCategory } from "./generation-error-registry.js";
import { configureWorkerDatabase } from "./sqlite-connection.js";
@@ -120,6 +120,7 @@ export class GenerationProcessor {
.run("worker_stopped", now, this.workerId);
});
this.gatewayBalance.close();
this.adapter.dispose?.();
this.database.close();
}
@@ -144,6 +145,12 @@ export class GenerationProcessor {
SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ? ORDER BY position
`).all(generationId) as Array<{ managed_file_id: string }>;
let adapterResult: GenerationAdapterResult;
let referenceImages: NonNullable<GenerationAdapterRequest["referenceImages"]>;
try {
referenceImages = this.loadReferenceImages(references.map((row) => row.managed_file_id));
} catch {
return this.completeFailure(job, "reference_invalid", "reference_load_failed");
}
try {
if (job.upstream_job_reference) {
if (!this.adapter.poll) return this.completeFailure(job, "unknown_retryable", "poll_unsupported", undefined, false, "pending_manual_review");
@@ -156,10 +163,13 @@ export class GenerationProcessor {
prompt: job.prompt,
ratio: job.ratio,
referenceAssetIds: references.map((row) => row.managed_file_id),
referenceImages,
}));
}
} catch {
return this.completeFailure(job, "unknown_retryable", "adapter_exception", undefined, false, "pending_manual_review");
} finally {
for (const reference of referenceImages) reference.bytes.fill(0);
}
if (adapterResult.status === "failed") return this.completeFailure(job, adapterResult.category, adapterResult.sourceCategory, adapterResult.balanceSignal);
@@ -175,6 +185,29 @@ export class GenerationProcessor {
}
}
private loadReferenceImages(referenceAssetIds: string[]): NonNullable<GenerationAdapterRequest["referenceImages"]> {
return referenceAssetIds.map((assetId) => {
const row = this.database.prepare(`
SELECT relative_path, mime_type FROM managed_files
WHERE file_id = ? AND file_kind = 'reference' AND status = 'committed'
`).get(assetId) as { mime_type: string; relative_path: string } | undefined;
if (!row || !["image/jpeg", "image/png", "image/webp"].includes(row.mime_type) || isAbsolute(row.relative_path)) {
throw new Error("reference_invalid");
}
const path = resolve(this.dataRoot, row.relative_path);
const child = relative(this.dataRoot, path);
if (!child || child === ".." || child.startsWith(`..${sep}`) || isAbsolute(child)
|| !existsSync(path) || !statSync(path).isFile()) {
throw new Error("reference_invalid");
}
return {
assetId,
bytes: readFileSync(path),
mimeType: row.mime_type as "image/jpeg" | "image/png" | "image/webp",
};
});
}
private claim(generationId: string) {
return this.immediate(() => {
const row = this.readJob(generationId);
@@ -0,0 +1,221 @@
import sharp from "sharp";
import type {
GenerationAdapter,
GenerationAdapterRequest,
GenerationAdapterResult,
NormalizedGenerationOutput,
} from "./ai-adapter-contract.js";
import { gptImageRequestSizeForRatio, normalizeImageOutputToRatio } from "./image-output-normalizer.mjs";
const geminiProductModelId = "gemini-3.1-flash-image-preview";
const geminiProviderModelId = "gemini-3.1-flash-image";
const gptImageModelId = "gpt-image-2";
const geminiEndpoint = "https://oneapi.intelligrow.cn/v1/chat/completions";
const gptImageEndpoint = "https://oneapi.intelligrow.cn/v1/images/generations";
const gptImageReferenceEndpoint = "https://oneapi.intelligrow.cn/v1/images/edits";
const maximumResponseBytes = 32 * 1024 * 1024;
const requestTimeoutMilliseconds = 180_000;
const geminiImageSystemInstruction = "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.";
type FetchLike = typeof fetch;
class OneApiRuntimeError extends Error {
constructor(
readonly category: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | "upstream_failed" | "upstream_timeout" | "unknown_non_retryable",
readonly sourceCategory: string,
) {
super(sourceCategory);
}
}
function failure(error: unknown): GenerationAdapterResult {
if (error instanceof OneApiRuntimeError) {
return { category: error.category, sourceCategory: error.sourceCategory, status: "failed" };
}
if (error instanceof Error && error.name === "AbortError") {
return { category: "upstream_timeout", sourceCategory: "upstream_timeout", status: "failed" };
}
return { category: "upstream_failed", sourceCategory: "upstream_failed", status: "failed" };
}
function mapHttpFailure(status: number) {
if (status === 408 || status === 504) return new OneApiRuntimeError("upstream_timeout", `upstream_http_${status}`);
if (status === 429) return new OneApiRuntimeError("gateway_balance_insufficient", "upstream_http_429");
if (status >= 500) return new OneApiRuntimeError("upstream_failed", `upstream_http_${status}`);
if (status === 400 || status === 404 || status === 422) return new OneApiRuntimeError("gateway_contract_invalid", `upstream_http_${status}`);
return new OneApiRuntimeError("unknown_non_retryable", `upstream_http_${status}`);
}
async function readBoundedJson(response: Response) {
const declaredLength = Number(response.headers.get("content-length") ?? 0);
if (Number.isFinite(declaredLength) && declaredLength > maximumResponseBytes) {
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
}
if (!response.body) throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_empty");
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
try {
while (true) {
const next = await reader.read();
if (next.done) break;
const chunk = Buffer.from(next.value);
total += chunk.length;
if (total > maximumResponseBytes) {
await reader.cancel();
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_too_large");
}
chunks.push(chunk);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
} catch {
throw new OneApiRuntimeError("gateway_contract_invalid", "upstream_response_invalid");
}
} finally {
for (const chunk of chunks) chunk.fill(0);
}
}
function extractGeminiImage(response: unknown) {
if (!response || typeof response !== "object" || !("choices" in response) || !Array.isArray(response.choices)) {
throw new OneApiRuntimeError("gateway_contract_invalid", "response_shape_invalid");
}
const choice = response.choices[0];
const content = choice && typeof choice === "object" && "message" in choice && choice.message && typeof choice.message === "object"
&& "content" in choice.message && typeof choice.message.content === "string" ? choice.message.content : "";
const matches = [...content.matchAll(/!\[[^\]]*\]\(\s*data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\r\n]+)\s*\)/gi)];
if (matches.length !== 1) throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
return { bytes: Buffer.from(matches[0]![2]!, "base64"), declaredMimeType: matches[0]![1]!.toLowerCase() };
}
function extractGptImage(response: unknown) {
if (!response || typeof response !== "object" || !("data" in response) || !Array.isArray(response.data)
|| response.data.length !== 1 || !response.data[0] || typeof response.data[0] !== "object"
|| !("b64_json" in response.data[0]) || typeof response.data[0].b64_json !== "string") {
throw new OneApiRuntimeError("gateway_contract_invalid", "response_single_image_required");
}
return { bytes: Buffer.from(response.data[0].b64_json, "base64"), declaredMimeType: undefined };
}
async function normalizeOutput(bytes: Buffer, declaredMimeType: string | undefined, ratio: GenerationAdapterRequest["ratio"]): Promise<NormalizedGenerationOutput> {
try {
const metadata = await sharp(bytes, { failOn: "error", limitInputPixels: 40_000_000 }).metadata();
const mimeType = metadata.format === "png" ? "image/png" : metadata.format === "jpeg" ? "image/jpeg" : metadata.format === "webp" ? "image/webp" : undefined;
if (!mimeType || !metadata.width || !metadata.height || (declaredMimeType && declaredMimeType !== mimeType)) {
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
}
const normalized = await normalizeImageOutputToRatio({ bytes, mimeType, pixelHeight: metadata.height, pixelWidth: metadata.width, ratio });
return { bytes: normalized.bytes, mimeType: normalized.mimeType, pixelHeight: normalized.pixelHeight, pixelWidth: normalized.pixelWidth };
} catch (error) {
if (error instanceof OneApiRuntimeError) throw error;
throw new OneApiRuntimeError("gateway_contract_invalid", "response_media_invalid");
}
}
function validateRequest(request: GenerationAdapterRequest) {
if (!request.prompt.trim() || request.prompt.length > 1_000) throw new OneApiRuntimeError("gateway_contract_invalid", "prompt_invalid");
const references = request.referenceImages ?? [];
if (references.length !== request.referenceAssetIds.length || references.length > 2) {
throw new OneApiRuntimeError("reference_invalid", "reference_count_invalid");
}
const totalBytes = references.reduce((total, reference) => total + reference.bytes.length, 0);
if (totalBytes > 20 * 1024 * 1024 || references.some((reference) => reference.bytes.length === 0 || reference.bytes.length > 10 * 1024 * 1024)) {
throw new OneApiRuntimeError("reference_invalid", "reference_size_invalid");
}
return references;
}
function buildRequest(request: GenerationAdapterRequest) {
const references = validateRequest(request);
if (request.modelId === geminiProductModelId) {
const content = references.length === 0
? request.prompt
: [
{ text: request.prompt, type: "text" },
...references.map((reference) => ({
image_url: { url: `data:${reference.mimeType};base64,${reference.bytes.toString("base64")}` },
type: "image_url",
})),
];
return {
body: JSON.stringify({
extra_body: { google: { image_config: { aspect_ratio: request.ratio, image_size: "1K" } } },
messages: [
{ content: geminiImageSystemInstruction, role: "system" },
{ content, role: "user" },
],
model: geminiProviderModelId,
stream: false,
}),
contentType: "application/json",
endpoint: geminiEndpoint,
parser: extractGeminiImage,
};
}
if (request.modelId !== gptImageModelId) throw new OneApiRuntimeError("model_disabled", "model_not_supported");
if (references.length > 0) {
const form = new FormData();
form.append("model", gptImageModelId);
form.append("prompt", request.prompt);
form.append("response_format", "b64_json");
form.append("size", gptImageRequestSizeForRatio(request.ratio));
references.forEach((reference, index) => form.append("image[]", new Blob([reference.bytes], { type: reference.mimeType }), `reference-${index + 1}.png`));
return { body: form, contentType: undefined, endpoint: gptImageReferenceEndpoint, parser: extractGptImage };
}
return {
body: JSON.stringify({ model: gptImageModelId, prompt: request.prompt, response_format: "b64_json", size: gptImageRequestSizeForRatio(request.ratio) }),
contentType: "application/json",
endpoint: gptImageEndpoint,
parser: extractGptImage,
};
}
export class OneApiGenerationAdapter implements GenerationAdapter {
private readonly credential: Buffer;
private readonly fetchImpl: FetchLike;
private disposed = false;
constructor(input: { credential: Buffer; fetch?: FetchLike }) {
if (input.credential.length < 8) throw new Error("ai_gateway_credential_invalid");
this.credential = Buffer.from(input.credential);
this.fetchImpl = input.fetch ?? fetch;
}
async start(request: GenerationAdapterRequest): Promise<GenerationAdapterResult> {
if (this.disposed) return { category: "upstream_failed", sourceCategory: "adapter_disposed", status: "failed" };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), requestTimeoutMilliseconds);
let sourceBytes: Buffer | undefined;
try {
const providerRequest = buildRequest(request);
const headers = new Headers({ authorization: `Bearer ${this.credential.toString("utf8")}` });
if (providerRequest.contentType) headers.set("content-type", providerRequest.contentType);
const response = await this.fetchImpl(providerRequest.endpoint, {
body: providerRequest.body,
headers,
method: "POST",
redirect: "error",
signal: controller.signal,
});
if (!response.ok) throw mapHttpFailure(response.status);
const parsed = await readBoundedJson(response);
const extracted = providerRequest.parser(parsed);
sourceBytes = extracted.bytes;
const output = await normalizeOutput(sourceBytes, extracted.declaredMimeType, request.ratio);
return { outputs: [output], status: "completed" };
} catch (error) {
return failure(error);
} finally {
clearTimeout(timeout);
sourceBytes?.fill(0);
}
}
dispose() {
if (this.disposed) return;
this.disposed = true;
this.credential.fill(0);
}
}
+7 -1
View File
@@ -25,7 +25,13 @@ export async function receiveWorkerCredentials(input: NodeJS.ReadableStream = pr
}
export function initializeWorkerCredentialClient(credentials: Record<(typeof WORKER_CREDENTIALS)[number], string>) {
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
const value = credentials["Dada/P0A/worker/ai-gateway"];
try {
if (!value) throw new Error("worker_ai_gateway_not_configured");
return { aiGatewayCredential: Buffer.from(value, "utf8") };
} finally {
for (const name of WORKER_CREDENTIALS) credentials[name] = "";
}
}
export function attachWorkerSupervisorControl(pipeName: string, shutdown: () => Promise<void> | void) {
+34 -15
View File
@@ -2,8 +2,10 @@ import { parentPort } from "node:worker_threads";
import { join } from "node:path";
import { WorkerAiCallGate } from "./ai-call-gate.js";
import { MockGenerationAdapter } from "./ai-adapter-contract.js";
import { runAiRuntimeProbe } from "./ai-runtime-probe.js";
import { GenerationPollingLoop } from "./generation-polling-loop.js";
import { GenerationProcessor } from "./generation-processor.js";
import { OneApiGenerationAdapter } from "./oneapi-generation-adapter.js";
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
import { RetentionCleanup } from "./retention-cleanup.js";
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
@@ -23,8 +25,25 @@ if (workerPort) {
});
}
if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
initializeWorkerCredentialClient(await receiveWorkerCredentials());
if (!workerPort && process.argv.includes("--dada-ai-probe")) {
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
let adapter: OneApiGenerationAdapter | undefined;
let probeResult: Awaited<ReturnType<typeof runAiRuntimeProbe>> | { code: "ai_probe_failed"; error_category: "upstream_failed"; real_calls: 0; success: false };
try {
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
probeResult = await runAiRuntimeProbe(adapter);
} catch {
probeResult = { code: "ai_probe_failed", error_category: "upstream_failed", real_calls: 0, success: false };
} finally {
credentialClient.aiGatewayCredential.fill(0);
adapter?.dispose();
}
await new Promise<void>((resolveWrite, rejectWrite) => {
process.stdout.write(JSON.stringify(probeResult), (error) => error ? rejectWrite(error) : resolveWrite());
});
process.exit(probeResult.success ? 0 : 2);
} else if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
const credentialClient = initializeWorkerCredentialClient(await receiveWorkerCredentials());
const controlPipeIndex = process.argv.indexOf("--dada-control-pipe");
const controlPipe = process.argv[controlPipeIndex + 1];
if (controlPipeIndex < 0 || !controlPipe) throw new Error("Supervisor control pipe name is required.");
@@ -34,13 +53,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
let projectCleanup: ProjectPurgeCleanup | undefined;
let retentionTimer: ReturnType<typeof setInterval> | undefined;
let processor: GenerationProcessor | undefined;
let generationTimer: ReturnType<typeof setInterval> | undefined;
let generationLoop: GenerationPollingLoop | undefined;
const control = attachWorkerSupervisorControl(controlPipe, () => {
clearInterval(keepAlive);
if (retentionTimer) clearInterval(retentionTimer);
retention?.close();
projectCleanup?.close();
if (generationTimer) clearInterval(generationTimer);
generationLoop?.close();
processor?.close();
storage?.close();
});
@@ -51,15 +70,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
storage = new WorkerStorageStatus(databasePath);
retention = new RetentionCleanup({ databasePath });
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
processor = new GenerationProcessor({
adapter: new MockGenerationAdapter({
status: "completed",
outputs: [{ bytes: Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=", "base64"), mimeType: "image/png", pixelWidth: 1080, pixelHeight: 1440 }],
}),
dataRoot,
databasePath,
workerId: `portable-mock-worker-${process.pid}`,
});
let adapter: OneApiGenerationAdapter;
try {
adapter = new OneApiGenerationAdapter({ credential: credentialClient.aiGatewayCredential });
} finally {
credentialClient.aiGatewayCredential.fill(0);
}
processor = new GenerationProcessor({ adapter, dataRoot, databasePath, workerId: `portable-oneapi-worker-${process.pid}` });
const runRetentionCleanup = () => {
try {
retention?.purgeExpired();
@@ -86,9 +103,11 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
});
logger.write({ error_category: "none", status_category: "ready" });
new WorkerAiCallGate({ getStorageStatus: () => storageStatus === "unavailable" ? storageStatus : (storage?.getStatus() ?? "unavailable"), logger });
generationTimer = setInterval(() => { void processor?.processNext().catch(() => undefined); }, 250);
generationLoop = new GenerationPollingLoop(processor);
} catch {
storageStatus = "unavailable";
control.reportStatus("storage_unavailable");
clearInterval(keepAlive);
setTimeout(() => process.exit(1), 50);
}
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -20,7 +20,12 @@
"test:performance": "node scripts/run-wp4-07-layer.mjs performance",
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
"test:package": "pnpm build:workspace-packages && pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs",
"test:postv1-ui-integration": "node scripts/validate-postv1-ui-lineage.mjs && playwright test tests/e2e/projects-workspace.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts --config playwright.config.ts",
"package:portable": "node scripts/build-portable.mjs",
"assets:manifest": "pnpm build:workspace-packages && node scripts/generate-runtime-asset-manifest.mjs",
"assets:deploy": "pnpm build:workspace-packages && node scripts/deploy-runtime-assets.mjs",
"assets:validate-browser-fonts": "node scripts/validate-browser-font-assets.mjs",
"assets:browser-catalog": "pnpm build:workspace-packages && node scripts/generate-complex-browser-catalog.mjs",
"generate:openapi": "node scripts/generate-openapi.mjs",
"check:openapi": "node scripts/check-openapi.mjs",
"validate:tdd-trace": "node scripts/validate-tdd-trace.mjs",
+14 -2
View File
@@ -4,8 +4,8 @@ export interface ColorCardDefinition {
cardId: typeof P0A_COLOR_CARD_IDS[number];
displayName: string;
mappingStatus: "confirmed_native_mapping" | "stable_web_style_native_mapping_provisional";
rendererName: "horizontal_line" | "ticket_strip" | "vertical_stack" | "vertical_ticket";
styleId: "style_01" | "style_02" | "style_08" | "style_16";
rendererName: string;
styleId: string;
}
export type FiveColorPalette = readonly [string, string, string, string, string];
@@ -17,7 +17,19 @@ export interface ColorCardRenderPlan extends ColorCardDefinition {
export const P0A_COLOR_CARD_DEFINITIONS: readonly ColorCardDefinition[] = [
{ cardId: "COLOR001", displayName: "纵向票据", mappingStatus: "confirmed_native_mapping", rendererName: "vertical_ticket", styleId: "style_01" },
{ cardId: "COLOR002", displayName: "纵向色阶", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_stack", styleId: "style_02" },
{ cardId: "COLOR003", displayName: "纵向色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_strip", styleId: "style_03" },
{ cardId: "COLOR004", displayName: "纵向标线", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_line", styleId: "style_04" },
{ cardId: "COLOR005", displayName: "横向标签", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_label_strip", styleId: "style_05" },
{ cardId: "COLOR006", displayName: "指示色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "indicator_strip", styleId: "style_06" },
{ cardId: "COLOR007", displayName: "图钉色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "pinned_strip", styleId: "style_07" },
{ cardId: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
{ cardId: "COLOR009", displayName: "色彩海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "color_poster", styleId: "style_09" },
{ cardId: "COLOR010", displayName: "标题海报", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "caption_poster", styleId: "style_10" },
{ cardId: "COLOR011", displayName: "边框色条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "framed_strip", styleId: "style_11" },
{ cardId: "COLOR012", displayName: "OTTO 色块", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "otto_blocks", styleId: "style_12" },
{ cardId: "COLOR013", displayName: "纵向圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "vertical_dots", styleId: "style_13" },
{ cardId: "COLOR014", displayName: "三色圆环", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "three_circles", styleId: "style_14" },
{ cardId: "COLOR015", displayName: "描边圆点", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "outlined_dots", styleId: "style_15" },
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
] as const;
+1 -1
View File
@@ -281,7 +281,7 @@ export const AdminDiagnosticsResponseSchema = Type.Object({
browser_support: Type.Array(Type.Object({
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
major: Type.Integer({ minimum: 1 }),
}, { additionalProperties: false }), { maxItems: 2 }),
}, { additionalProperties: false }), { maxItems: 8 }),
worker_status: Type.Union([Type.Literal("ready"), Type.Literal("degraded"), Type.Literal("unavailable")]),
}, { additionalProperties: false }),
}, { additionalProperties: false, $id: "AdminDiagnosticsResponse" });
+1 -1
View File
@@ -83,7 +83,7 @@ export const ErrorDetailsSchema = Type.Object(
},
{ additionalProperties: false },
),
{ maxItems: 2 },
{ maxItems: 8 },
),
),
capacity_status: Type.Optional(
+41 -38
View File
@@ -3,24 +3,40 @@ import type { StaticStickerCatalog, StaticStickerCatalogItem } from "@dada/stati
export const P0A_COMPLEX_RELEASE_VERSION = "p0a-complex-v1";
export const P0A_STATIC_STICKER_RELEASE_VERSION = "p0a-static-v1";
export const P0A_TEXT_TEMPLATE_IDS = [
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
function numberedIds(prefix: string, count: number) {
return Object.freeze(Array.from({ length: count }, (_, index) => `${prefix}${String(index + 1).padStart(3, "0")}`));
}
export const P0A_TEXT_TEMPLATE_IDS = Object.freeze([
...numberedIds("FLOWER", 145),
...numberedIds("H", 119),
...numberedIds("TAG", 51),
...numberedIds("SIMPLE", 17),
]);
export const P0A_REQUIRED_FONT_PANEL_IDS = numberedIds("FONT", 86);
export const P0A_COLOR_CARD_IDS = numberedIds("COLOR", 16);
export const P0A_DYNAMIC_STICKER_IDS = numberedIds("DYN", 35);
export const P0A_DYNAMIC_RUNTIME_FONT_SOURCES = [
{ assetId: "15974853bc3294ef68e7e6d58fe74fd7", sourceReference: "fonts/15974853bc3294ef68e7e6d58fe74fd7", templateId: "DYN002" },
{ assetId: "46f8336813e4c48d06a1aef294fdccf6", sourceReference: "fonts/46f8336813e4c48d06a1aef294fdccf6", templateId: "DYN016" },
{ assetId: "53ca6b704728520da50c145eabb2e635", sourceReference: "fonts/53ca6b704728520da50c145eabb2e635", templateId: "DYN007" },
{ assetId: "cca5efc0e02fb1bf62349bd68ef30fc1", sourceReference: "fonts/cca5efc0e02fb1bf62349bd68ef30fc1", templateId: "DYN015" },
{ assetId: "dd25b35dcb7ba4476cbaa9a9592e39e2", sourceReference: "fonts/dd25b35dcb7ba4476cbaa9a9592e39e2", templateId: "DYN001" },
{ assetId: "e4210c9872f0c279b35273f230809821", sourceReference: "fonts/e4210c9872f0c279b35273f230809821", templateId: "DYN011" },
{ assetId: "f4bfd4132df2d6be97ceabadf3853505", sourceReference: "fonts/f4bfd4132df2d6be97ceabadf3853505", templateId: "DYN008" },
] as const;
// Derived from exact package-hash matches between the 32 frozen templates and the 86-item font panel.
export const P0A_REQUIRED_FONT_PANEL_IDS = [
"FONT005", "FONT008", "FONT011", "FONT021", "FONT022", "FONT027",
"FONT039", "FONT043", "FONT046", "FONT052", "FONT081",
] as const;
export const P0A_COLOR_CARD_IDS = ["COLOR001", "COLOR002", "COLOR008", "COLOR016"] as const;
export const P0A_DYNAMIC_STICKER_IDS = [
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007",
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
export const P0A_DYNAMIC_RUNTIME_IMAGE_SOURCES = [
{ assetId: "DYN001-image28", sourceReference: "resource/image28.png", templateId: "DYN001" },
{ assetId: "DYN002-image29", sourceReference: "resource/image29.png", templateId: "DYN002" },
{ assetId: "DYN003-image30", sourceReference: "resource/image30.png", templateId: "DYN003" },
{ assetId: "DYN004-image32", sourceReference: "resource/image32.png", templateId: "DYN004" },
{ assetId: "DYN008-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN008" },
{ assetId: "DYN011-backendui0", sourceReference: "resource/backendui0.png", templateId: "DYN011" },
{ assetId: "DYN015-imager2", sourceReference: "resource/imager2_2.png", templateId: "DYN015" },
{ assetId: "DYN016-image21", sourceReference: "resource/image21.png", templateId: "DYN016" },
] as const;
export type RegisteredComplexFamily = "color_card" | "font_panel" | "interactive_sticker" | "text_template";
@@ -55,12 +71,12 @@ export interface P0aPublicManifest {
text_templates: PublicComplexAsset[];
};
counts: {
color_cards: 4;
dynamic_stickers: 10;
font_panel_items: 11;
color_cards: 16;
dynamic_stickers: 35;
font_panel_items: 86;
static_parts: 25;
static_stickers: 1407;
text_templates: 32;
text_templates: 332;
};
release_tier: "alpha_whitelist";
release_version: string;
@@ -119,15 +135,6 @@ function validateStaticCatalog(catalog: StaticStickerCatalog) {
if (Object.keys(catalog.part_counts).length !== 25) throw new Error("static sticker part counts must contain 25 parts");
}
function fontIdsForTemplates(templates: readonly RegisteredComplexAsset[]) {
const referenced = new Set<string>();
for (const template of templates) {
for (const fontId of template.font_panel_references ?? []) referenced.add(fontId);
}
referenced.add("FONT081");
return [...referenced].sort((left, right) => Number(left.slice(4)) - Number(right.slice(4)));
}
export function createP0aPublicManifest(input: {
complexManifest: ComplexRegistryManifest;
staticCatalog: StaticStickerCatalog;
@@ -136,11 +143,7 @@ export function createP0aPublicManifest(input: {
validateStaticCatalog(input.staticCatalog);
const textTemplates = orderedItems(input.complexManifest.items, P0A_TEXT_TEMPLATE_IDS, "text_template");
const derivedFontIds = fontIdsForTemplates(textTemplates);
if (JSON.stringify(derivedFontIds) !== JSON.stringify(P0A_REQUIRED_FONT_PANEL_IDS)) {
throw new Error(`P0-A referenced font panel mismatch: received ${derivedFontIds.join(",")}`);
}
const fontPanelItems = orderedItems(input.complexManifest.items, derivedFontIds, "font_panel");
const fontPanelItems = orderedItems(input.complexManifest.items, P0A_REQUIRED_FONT_PANEL_IDS, "font_panel");
const colorCards = orderedItems(input.complexManifest.items, P0A_COLOR_CARD_IDS, "color_card");
const dynamicStickers = orderedItems(input.complexManifest.items, P0A_DYNAMIC_STICKER_IDS, "interactive_sticker");
@@ -153,12 +156,12 @@ export function createP0aPublicManifest(input: {
text_templates: textTemplates.map(publicItem),
},
counts: {
color_cards: 4,
dynamic_stickers: 10,
font_panel_items: 11,
color_cards: 16,
dynamic_stickers: 35,
font_panel_items: 86,
static_parts: 25,
static_stickers: 1_407,
text_templates: 32,
text_templates: 332,
},
release_tier: "alpha_whitelist",
release_version: input.complexManifest.release_version,
+11 -1
View File
@@ -1,10 +1,20 @@
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs";
import { validateFinalReleaseRecord } from "./lib/wp7-07-final-release.mjs";
const outputIndex = process.argv.indexOf("--output");
const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release");
const result = await buildAndValidatePortablePackage({ outputRoot });
const previousRelease = JSON.parse(readFileSync(resolve("RELEASE.json"), "utf8"));
const releaseRecord = validateFinalReleaseRecord({
...previousRelease,
buildCommit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
maintenanceFromCommit: previousRelease.buildCommit,
recordedAt: new Date().toISOString(),
});
const result = await buildAndValidatePortablePackage({ outputRoot, releaseRecord });
console.log(JSON.stringify({
package: result.packageManifest.package_name,
sha256: result.packageManifest.zip_sha256,
+4 -4
View File
@@ -90,17 +90,17 @@ const familyCounts = Object.fromEntries(["text_template", "font_panel", "color_c
]));
const fullP0Enabled = complex.manifest.items.filter((item) => item.release_tier === "full_p0" && item.release_status === "enabled").length;
const publicJson = JSON.stringify(manifest);
const hiddenIds = ["FLOWER009", "H009", "TAG008", "SIMPLE009", "COLOR003", "DYN005"];
const completionIds = ["FLOWER145", "H119", "TAG051", "SIMPLE017", "FONT086", "COLOR016", "DYN035"];
const response = {
counts: manifest.counts,
full_p0_enabled: fullP0Enabled,
hidden_ids_absent: hiddenIds.every((id) => !publicJson.includes(id)),
complete_catalog_present: completionIds.every((id) => publicJson.includes(id)),
no_absolute_paths: !/[A-Za-z]:[\\/]/.test(publicJson),
release_tier: manifest.release_tier,
status: "passed",
};
const registrationValidation = {
allowlist: {
public_catalog: {
color_cards: P0A_COLOR_CARD_IDS,
dynamic_stickers: P0A_DYNAMIC_STICKER_IDS,
font_panel_items: P0A_REQUIRED_FONT_PANEL_IDS,
@@ -112,7 +112,7 @@ const registrationValidation = {
source_mutations: complex.report.source_mutations + staticResult.report.source_mutations,
static_parts: Object.keys(staticResult.catalog.part_counts).length,
static_stickers: staticResult.catalog.count,
status: response.hidden_ids_absent && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
status: response.complete_catalog_present && response.no_absolute_paths && fullP0Enabled === 0 ? "passed" : "failed",
};
if (registrationValidation.status !== "passed") throw new Error("P0-A registration validation failed");
+37
View File
@@ -0,0 +1,37 @@
import { readFileSync } from "node:fs";
import { isAbsolute, join, resolve } from "node:path";
import {
buildP0aRuntimeAssetPlan,
defaultReplicationRoot,
deployRuntimeAssetPlan,
readRuntimeAssetManifest,
serializeRuntimeAssetManifest,
} from "./lib/runtime-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function defaultConfigPath() {
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) throw new Error("local_app_data_unavailable");
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
}
const configFile = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
const configuration = JSON.parse(readFileSync(configFile, "utf8"));
const assetRootCandidate = option("--asset-root") ?? configuration.asset_root;
if (typeof assetRootCandidate !== "string" || !isAbsolute(assetRootCandidate)) {
throw new Error("asset_root_configuration_invalid");
}
const assetRoot = resolve(assetRootCandidate);
const trustedManifest = readRuntimeAssetManifest(resolve(option("--trusted-manifest") ?? "config/runtime-assets-manifest.json"));
const plan = await buildP0aRuntimeAssetPlan({
replicationRoot: resolve(option("--replication-root") ?? defaultReplicationRoot()),
});
if (serializeRuntimeAssetManifest(plan.manifest) !== serializeRuntimeAssetManifest(trustedManifest)) {
throw new Error("runtime_asset_source_does_not_match_trusted_manifest");
}
const result = deployRuntimeAssetPlan({ allowManagedUpdate: true, assetRoot, manifest: trustedManifest, resources: plan.resources });
process.stdout.write(`${JSON.stringify({ linked_files: result.linked_files, status: result.status })}\n`);
@@ -0,0 +1,133 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import {
P0A_DYNAMIC_STICKER_IDS,
P0A_REQUIRED_FONT_PANEL_IDS,
P0A_TEXT_TEMPLATE_IDS,
} from "../packages/template-registry/dist/index.js";
import { compileTextTemplateAssets } from "./lib/text-template-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function oneDirectoryWithPrefix(root, prefix) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
if (matches.length !== 1) throw new Error(`complex_catalog_directory_invalid:${prefix}`);
return join(root, matches[0].name);
}
function textTemplateDirectory(root, templateId) {
const family = templateId.startsWith("FLOWER") ? "花字"
: templateId.startsWith("SIMPLE") ? "简约"
: templateId.startsWith("TAG") ? "标签"
: "标题";
return join(root, family, "templates", templateId);
}
function normalizedTextCategory(value) {
if (value === "花字") return "flower";
if (value === "简约") return "simple";
if (value === "标签") return "tag";
return "title";
}
function normalizedDynamicCategory(value) {
if (value === "user") return "identity";
if (value === "location" || value === "time") return value;
return "other";
}
const replicationRoot = resolve(option("--replication-root") ?? join(homedir(), "Desktop", "sticker_web_replication_assets"));
if (!isAbsolute(replicationRoot) || !existsSync(replicationRoot)) throw new Error("replication_asset_root_unavailable");
const outputPath = resolve(option("--output") ?? "apps/web/src/generated/complex-assets.json");
const fontPackagesRoot = join(replicationRoot, "sticker_text", "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages");
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
const dynamicRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
const fontPanelItems = P0A_REQUIRED_FONT_PANEL_IDS.map((fontId, displayOrder) => {
const metadata = readJson(join(oneDirectoryWithPrefix(fontPackagesRoot, fontId), "metadata.json"));
if (typeof metadata.local_sha256 !== "string") throw new Error(`complex_catalog_font_hash_missing:${fontId}`);
return {
display_name: String(metadata.display_name ?? fontId),
display_order: displayOrder,
font_id: fontId,
};
});
const textResourceRevision = createHash("sha256");
const textTemplates = P0A_TEXT_TEMPLATE_IDS.map((templateId, catalogOrder) => {
const directory = textTemplateDirectory(textRoot, templateId);
const metadata = readJson(join(directory, "metadata.json"));
const compiled = compileTextTemplateAssets({ templateDirectory: directory, templateId });
if (compiled.diagnostics.unresolved_images !== 0) throw new Error(`text_template_image_reference_unresolved:${templateId}`);
for (const resource of compiled.resources) {
const bytes = resource.sourceBytes ?? readFileSync(resource.sourcePath);
textResourceRevision.update(resource.assetId).update(createHash("sha256").update(bytes).digest());
}
const previewReference = typeof metadata.files?.preview === "string" ? metadata.files.preview : undefined;
const hasPreview = previewReference ? existsSync(join(directory, ...previewReference.split("/"))) : false;
return {
available: true,
catalog_order: catalogOrder,
category: normalizedTextCategory(metadata.category),
...compiled.catalog,
display_name: String(metadata.display_name || metadata.default_text || compiled.catalog.default_text || templateId),
...(hasPreview ? { preview_asset_id: `TEXT-PREVIEW-${templateId}` } : {}),
resource_class: metadata.resource_class === "parameter_only" ? "parameter_only" : "zip_template",
template_id: templateId,
};
});
const dynamicStickers = P0A_DYNAMIC_STICKER_IDS.map((templateId, catalogOrder) => {
const metadata = readJson(join(dynamicRoot, templateId, "metadata.json"));
const requiredFields = Array.isArray(metadata.dynamic_keys) ? metadata.dynamic_keys.map(String) : [];
const fontIds = Array.isArray(metadata.files?.fonts)
? metadata.files.fonts.map((reference) => basename(reference))
: [];
return {
catalog_order: catalogOrder,
category: normalizedDynamicCategory(metadata.category),
display_name: String(metadata.display_name ?? templateId),
font_ids: fontIds.length > 0 ? fontIds : ["FONT081"],
required_fields: requiredFields,
requires_location_consent: requiredFields.includes("latitude") || requiredFields.includes("longitude"),
source_candidate_id: String(metadata.source_candidate_id ?? metadata.display_name ?? templateId),
template_id: templateId,
};
});
const catalog = {
dynamic_stickers: dynamicStickers,
font_panel_items: fontPanelItems,
schema_version: "DadaComplexBrowserCatalog/v2",
text_resource_revision: textResourceRevision.digest("hex").slice(0, 16),
text_templates: textTemplates,
};
const serialized = `${JSON.stringify(catalog, null, 2)}\n`;
function containsAbsolutePath(value) {
if (typeof value === "string") return /^[A-Z]:[\\/]/i.test(value);
if (Array.isArray(value)) return value.some(containsAbsolutePath);
return value && typeof value === "object" ? Object.values(value).some(containsAbsolutePath) : false;
}
const unsafeTemplateIds = textTemplates.filter(containsAbsolutePath).map((item) => item.template_id);
if (unsafeTemplateIds.length > 0) throw new Error(`complex_catalog_absolute_path_detected:${unsafeTemplateIds.join(",")}`);
mkdirSync(dirname(outputPath), { recursive: true });
writeFileSync(outputPath, serialized);
process.stdout.write(`${JSON.stringify({
dynamic_stickers: dynamicStickers.length,
font_panel_items: fontPanelItems.length,
output: outputPath,
text_previews: textTemplates.filter((item) => item.preview_asset_id).length,
text_templates: textTemplates.length,
})}\n`);
@@ -0,0 +1,23 @@
import { resolve } from "node:path";
import {
buildP0aRuntimeAssetPlan,
defaultReplicationRoot,
serializeRuntimeAssetManifest,
writeRuntimeAssetManifest,
} from "./lib/runtime-assets.mjs";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const replicationRoot = resolve(option("--replication-root") ?? defaultReplicationRoot());
const outputPath = resolve(option("--output") ?? "config/runtime-assets-manifest.json");
const plan = await buildP0aRuntimeAssetPlan({ replicationRoot });
writeRuntimeAssetManifest(outputPath, plan.manifest);
process.stdout.write(`${JSON.stringify({
counts: plan.manifest.counts,
manifest_bytes: Buffer.byteLength(serializeRuntimeAssetManifest(plan.manifest)),
status: "generated",
})}\n`);
+6 -2
View File
@@ -18,6 +18,7 @@ import { tmpdir } from "node:os";
import { basename, dirname, join, relative, resolve, sep } from "node:path";
import { frozenRuntime } from "../frozen-versions.mjs";
import { readRuntimeAssetManifest } from "./runtime-assets.mjs";
const repositoryRoot = resolve(import.meta.dirname, "..", "..");
const fixedPort = 43121;
@@ -336,7 +337,7 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
debug("copy API application");
const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify", "sharp"]);
debug("copy Worker application");
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]);
const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3", "sharp"]);
const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts");
mkdirSync(sharedDestination, { recursive: true });
copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist"));
@@ -363,7 +364,10 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu
copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web"));
copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate"));
writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" });
writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" });
writeJson(
join(packageDirectory, "asset-metadata", "manifest.json"),
readRuntimeAssetManifest(join(repositoryRoot, "config", "runtime-assets-manifest.json")),
);
writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies });
const commit = run("git", ["rev-parse", "HEAD"]);
+428
View File
@@ -0,0 +1,428 @@
import { createHash } from "node:crypto";
import {
existsSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
renameSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, extname, isAbsolute, join, resolve, sep } from "node:path";
import { compileTextTemplateAssets } from "./text-template-assets.mjs";
export const P0A_RUNTIME_ASSET_ROOT_REF = "p0a_runtime_assets";
export const RUNTIME_ASSET_MANIFEST_SCHEMA = "DadaRuntimeAssets/v1";
const assetIdPattern = /^[a-z0-9][a-z0-9_-]{2,119}$/i;
const mimePattern = /^[a-z0-9][a-z0-9.+-]*\/[a-z0-9][a-z0-9.+-]*$/i;
const releasePattern = /^[a-z0-9][a-z0-9._-]{0,79}$/i;
const shaPattern = /^[a-f0-9]{64}$/i;
const fontMimeTypes = new Map([
[".otf", "font/otf"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
function sha256(bytes) {
return createHash("sha256").update(bytes).digest("hex");
}
function fileSha256(path) {
return sha256(readFileSync(path));
}
function stableEntries(entries) {
return entries.map((entry) => {
if (!entry || typeof entry !== "object") throw new Error("runtime_asset_entry_invalid");
if (!assetIdPattern.test(entry.assetId)) throw new Error("runtime_asset_id_invalid");
if (!mimePattern.test(entry.mimeType)) throw new Error("runtime_asset_mime_invalid");
if (!releasePattern.test(entry.resourceVersion)) throw new Error("runtime_asset_version_invalid");
if (entry.rootRef !== P0A_RUNTIME_ASSET_ROOT_REF) throw new Error("runtime_asset_root_ref_invalid");
if (!shaPattern.test(entry.sha256)) throw new Error("runtime_asset_sha256_invalid");
if (
typeof entry.relativePath !== "string"
|| isAbsolute(entry.relativePath)
|| entry.relativePath.includes("\\")
|| entry.relativePath.split("/").some((part) => part === "" || part === "..")
) throw new Error("runtime_asset_relative_path_invalid");
return { ...entry, sha256: entry.sha256.toLowerCase() };
}).sort((left, right) => {
const byVersion = left.resourceVersion.localeCompare(right.resourceVersion);
return byVersion || left.assetId.localeCompare(right.assetId);
});
}
function derivedCounts(entries) {
return {
dynamic_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^[a-f0-9]{32}$/.test(entry.assetId)).length,
dynamic_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^DYN\d{3}-/.test(entry.assetId)).length,
font_panel_items: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^FONT\d{3}$/.test(entry.assetId)).length,
static_stickers: entries.filter((entry) => entry.resourceVersion === "p0a-static-v1" && /^STK\d{3,4}$/.test(entry.assetId)).length,
text_fonts: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-FONT-/.test(entry.assetId)).length,
text_images: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-IMAGE-/.test(entry.assetId)).length,
text_previews: entries.filter((entry) => entry.resourceVersion === "p0a-complex-v1" && /^TEXT-PREVIEW-/.test(entry.assetId)).length,
};
}
export function createRuntimeAssetManifest({ counts, entries, sourceManifestSha256 }) {
const normalizedEntries = stableEntries(entries);
const keys = new Set();
const paths = new Set();
for (const entry of normalizedEntries) {
const key = `${entry.resourceVersion}\u0000${entry.assetId}`;
if (keys.has(key)) throw new Error("runtime_asset_id_duplicate");
if (paths.has(entry.relativePath)) throw new Error("runtime_asset_path_duplicate");
keys.add(key);
paths.add(entry.relativePath);
}
const actualCounts = derivedCounts(normalizedEntries);
if (JSON.stringify(counts) !== JSON.stringify(actualCounts)) throw new Error("runtime_asset_counts_invalid");
if (sourceManifestSha256 !== undefined && !shaPattern.test(sourceManifestSha256)) {
throw new Error("runtime_asset_source_manifest_sha256_invalid");
}
return {
counts: actualCounts,
entries: normalizedEntries,
root_ref: P0A_RUNTIME_ASSET_ROOT_REF,
schema_version: RUNTIME_ASSET_MANIFEST_SCHEMA,
source: "external_read_only",
...(sourceManifestSha256 ? { source_manifest_sha256: sourceManifestSha256.toLowerCase() } : {}),
};
}
export function readRuntimeAssetManifest(path) {
const value = JSON.parse(readFileSync(path, "utf8"));
if (
value?.schema_version !== RUNTIME_ASSET_MANIFEST_SCHEMA
|| value?.source !== "external_read_only"
|| value?.root_ref !== P0A_RUNTIME_ASSET_ROOT_REF
|| !Array.isArray(value.entries)
) throw new Error("runtime_asset_manifest_invalid");
return createRuntimeAssetManifest({
counts: value.counts,
entries: value.entries,
...(value.source_manifest_sha256 ? { sourceManifestSha256: value.source_manifest_sha256 } : {}),
});
}
export function serializeRuntimeAssetManifest(manifest) {
return `${JSON.stringify(manifest, null, 2)}\n`;
}
export function writeRuntimeAssetManifest(path, manifest) {
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, serializeRuntimeAssetManifest(manifest));
}
function targetWithinRoot(root, relativePath) {
const absoluteRoot = resolve(root);
const target = resolve(absoluteRoot, ...relativePath.split("/"));
if (target === absoluteRoot || !target.startsWith(`${absoluteRoot}${sep}`)) throw new Error("asset_target_path_invalid");
return target;
}
function sameFile(left, right) {
const leftStat = statSync(left);
const rightStat = statSync(right);
return leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino;
}
function installRuntimeAsset(resource, targetPath, expectedSha) {
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
if (generatedBytes) writeFileSync(targetPath, generatedBytes, { flag: "wx" });
else {
try {
linkSync(resource.sourcePath, targetPath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
throw new Error("asset_hardlink_volume_mismatch");
}
throw error;
}
}
if (fileSha256(targetPath) !== expectedSha) throw new Error(generatedBytes ? "asset_generated_write_invalid" : "asset_hardlink_verification_failed");
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_hardlink_verification_failed");
}
function replaceManagedRuntimeAsset(resource, targetPath, expectedSha) {
const nextPath = `${targetPath}.dada-next`;
const previousPath = `${targetPath}.dada-previous`;
if (existsSync(nextPath) || existsSync(previousPath)) throw new Error("asset_update_staging_conflict");
installRuntimeAsset(resource, nextPath, expectedSha);
renameSync(targetPath, previousPath);
try {
renameSync(nextPath, targetPath);
if (fileSha256(targetPath) !== expectedSha) throw new Error("asset_update_verification_failed");
rmSync(previousPath, { force: true });
} catch (error) {
if (existsSync(targetPath)) renameSync(targetPath, nextPath);
renameSync(previousPath, targetPath);
rmSync(nextPath, { force: true });
throw error;
}
}
export function deployRuntimeAssetPlan({ allowManagedUpdate = false, assetRoot, manifest, resources }) {
if (!isAbsolute(assetRoot)) throw new Error("asset_root_must_be_absolute");
const normalizedManifest = createRuntimeAssetManifest({
counts: manifest.counts,
entries: manifest.entries,
...(manifest.source_manifest_sha256 ? { sourceManifestSha256: manifest.source_manifest_sha256 } : {}),
});
const entries = new Map(normalizedManifest.entries.map((entry) => [`${entry.resourceVersion}\u0000${entry.assetId}`, entry]));
if (resources.length !== entries.size) throw new Error("asset_resource_plan_incomplete");
mkdirSync(assetRoot, { recursive: true });
const previousManifestPath = join(assetRoot, "manifest.json");
const previousEntries = allowManagedUpdate && existsSync(previousManifestPath)
? new Map(readRuntimeAssetManifest(previousManifestPath).entries.map((entry) => [entry.relativePath, entry]))
: new Map();
for (const resource of resources) {
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
const entry = entries.get(key);
if (!entry || JSON.stringify(entry) !== JSON.stringify({ ...resource.entry, sha256: resource.entry.sha256.toLowerCase() })) {
throw new Error("asset_resource_plan_mismatch");
}
const generatedBytes = Buffer.isBuffer(resource.sourceBytes) ? resource.sourceBytes : undefined;
if (!generatedBytes && (!existsSync(resource.sourcePath) || !statSync(resource.sourcePath).isFile() || lstatSync(resource.sourcePath).isSymbolicLink())) {
throw new Error("asset_source_invalid");
}
if ((generatedBytes ? sha256(generatedBytes) : fileSha256(resource.sourcePath)) !== entry.sha256) throw new Error("asset_source_hash_invalid");
const targetPath = targetWithinRoot(assetRoot, entry.relativePath);
mkdirSync(dirname(targetPath), { recursive: true });
if (existsSync(targetPath)) {
const targetSha = fileSha256(targetPath);
if (targetSha !== entry.sha256) {
const previous = previousEntries.get(entry.relativePath);
if (!previous || targetSha !== previous.sha256) throw new Error("asset_target_conflict");
replaceManagedRuntimeAsset(resource, targetPath, entry.sha256);
continue;
}
if (!generatedBytes && !sameFile(resource.sourcePath, targetPath)) throw new Error("asset_target_not_hardlink");
continue;
}
installRuntimeAsset(resource, targetPath, entry.sha256);
}
writeRuntimeAssetManifest(join(assetRoot, "manifest.json"), normalizedManifest);
return { linked_files: resources.length, manifest: normalizedManifest, status: "ready" };
}
function oneDirectoryWithPrefix(root, prefix) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isDirectory() && entry.name.startsWith(`${prefix}_`));
if (matches.length !== 1) throw new Error(`runtime_asset_source_directory_invalid:${prefix}`);
return join(root, matches[0].name);
}
function oneSupportedFont(root) {
const matches = readdirSync(root, { withFileTypes: true })
.filter((entry) => entry.isFile() && fontMimeTypes.has(extname(entry.name).toLowerCase()));
if (matches.length !== 1) throw new Error(`runtime_font_source_invalid:${basename(root)}`);
return join(root, matches[0].name);
}
function entryFor(sourcePath, assetId, resourceVersion, relativePath, mimeType) {
return {
assetId,
mimeType,
relativePath,
resourceVersion,
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
sha256: fileSha256(sourcePath),
};
}
function entryForBytes(sourceBytes, assetId, resourceVersion, relativePath, mimeType) {
return {
assetId,
mimeType,
relativePath,
resourceVersion,
rootRef: P0A_RUNTIME_ASSET_ROOT_REF,
sha256: sha256(sourceBytes),
};
}
function safeRuntimeComponent(value) {
const normalized = value.normalize("NFKD").replaceAll(/[^A-Za-z0-9_-]+/g, "-").replaceAll(/^-+|-+$/g, "");
if (normalized === value) return normalized;
return `${normalized || "asset"}-${sha256(value).slice(0, 8).toLowerCase()}`;
}
function dynamicMetadata(templateRoot, descriptor, field) {
const templateDirectory = join(templateRoot, descriptor.templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
if (!Array.isArray(metadata?.files?.[field]) || !metadata.files[field].includes(descriptor.sourceReference)) {
throw new Error(`runtime_dynamic_reference_invalid:${descriptor.assetId}`);
}
return templateDirectory;
}
function textTemplateDirectory(root, templateId) {
const family = templateId.startsWith("FLOWER") ? "花字"
: templateId.startsWith("SIMPLE") ? "简约"
: templateId.startsWith("TAG") ? "标签"
: "标题";
return join(root, family, "templates", templateId);
}
export async function buildP0aRuntimeAssetPlan({ replicationRoot }) {
const [{ compileStaticStickerCatalog }, registry] = await Promise.all([
import("../../packages/asset-compiler/dist/index.js"),
import("../../packages/template-registry/dist/index.js"),
]);
const compilerOutput = mkdtempSync(join(tmpdir(), "dada-runtime-asset-plan-"));
try {
const staticSourceRoot = join(replicationRoot, "sticker_normal");
const staticResult = compileStaticStickerCatalog({
outputDirectory: compilerOutput,
releaseVersion: registry.P0A_STATIC_STICKER_RELEASE_VERSION,
sourceRoot: staticSourceRoot,
});
const resources = staticResult.catalog.items.map((item) => {
const sourcePath = join(staticSourceRoot, ...item.relative_path.split("/"));
const entry = entryFor(
sourcePath,
item.stable_id,
registry.P0A_STATIC_STICKER_RELEASE_VERSION,
`${registry.P0A_STATIC_STICKER_RELEASE_VERSION}/${item.stable_id}.png`,
"image/png",
);
if (entry.sha256 !== item.sha256.toLowerCase()) throw new Error(`static_sticker_hash_invalid:${item.stable_id}`);
return { entry, sourcePath };
});
const resourceKeys = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
const addResource = (resource) => {
const key = `${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`;
const existing = resourceKeys.get(key);
if (existing) {
if (existing.entry.sha256 !== resource.entry.sha256 || existing.entry.mimeType !== resource.entry.mimeType) {
throw new Error(`runtime_asset_duplicate_conflict:${resource.entry.assetId}`);
}
return;
}
resourceKeys.set(key, resource);
resources.push(resource);
};
const fontPackagesRoot = join(
replicationRoot,
"sticker_text",
"字体",
"面板全量采集",
"font_panel_full_20260722",
"resources",
"font_packages",
);
for (const assetId of registry.P0A_REQUIRED_FONT_PANEL_IDS) {
const packageDirectory = oneDirectoryWithPrefix(fontPackagesRoot, assetId);
const sourcePath = oneSupportedFont(join(packageDirectory, "font_files"));
const extension = extname(sourcePath).toLowerCase();
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
fontMimeTypes.get(extension),
),
sourcePath,
});
}
const templateRoot = join(replicationRoot, "sticker_interactive", "单模板归档", "templates");
for (const templateId of registry.P0A_DYNAMIC_STICKER_IDS) {
const templateDirectory = join(templateRoot, templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
for (const sourceReference of metadata.files?.fonts ?? []) {
const sourcePath = oneSupportedFont(join(templateDirectory, ...sourceReference.split("/")));
const extension = extname(sourcePath).toLowerCase();
const assetId = basename(sourceReference);
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}${extension}`,
fontMimeTypes.get(extension),
),
sourcePath,
});
}
for (const sourceReference of metadata.files?.images ?? []) {
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
const extension = extname(sourcePath).toLowerCase();
const assetId = `${templateId}-${safeRuntimeComponent(basename(sourceReference, extension))}`;
if (!existsSync(sourcePath) || extension !== ".png") throw new Error(`runtime_dynamic_image_invalid:${assetId}`);
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
"image/png",
),
sourcePath,
});
}
}
const textRoot = join(replicationRoot, "sticker_text", "模板", "单模板归档");
for (const templateId of registry.P0A_TEXT_TEMPLATE_IDS) {
const templateDirectory = textTemplateDirectory(textRoot, templateId);
const metadata = JSON.parse(readFileSync(join(templateDirectory, "metadata.json"), "utf8"));
const compiled = compileTextTemplateAssets({ templateDirectory, templateId });
for (const resource of compiled.resources) {
const relativePath = `${registry.P0A_COMPLEX_RELEASE_VERSION}/${resource.assetId}${resource.extension}`;
const entry = resource.sourceBytes
? entryForBytes(resource.sourceBytes, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType)
: entryFor(resource.sourcePath, resource.assetId, registry.P0A_COMPLEX_RELEASE_VERSION, relativePath, resource.mimeType);
addResource({ entry, ...(resource.sourceBytes ? { sourceBytes: resource.sourceBytes } : { sourcePath: resource.sourcePath }) });
}
const previewReference = metadata.files?.preview;
if (typeof previewReference !== "string" || previewReference.length === 0) continue;
const sourcePath = join(templateDirectory, ...previewReference.split("/"));
if (!existsSync(sourcePath) || extname(sourcePath).toLowerCase() !== ".png") {
throw new Error(`runtime_text_preview_invalid:${templateId}`);
}
const assetId = `TEXT-PREVIEW-${templateId}`;
addResource({
entry: entryFor(
sourcePath,
assetId,
registry.P0A_COMPLEX_RELEASE_VERSION,
`${registry.P0A_COMPLEX_RELEASE_VERSION}/${assetId}.png`,
"image/png",
),
sourcePath,
});
}
const manifestPath = join(replicationRoot, "sticker_web_handoff", "sticker_web_catalog_manifest.json");
const manifest = createRuntimeAssetManifest({
counts: derivedCounts(resources.map((resource) => resource.entry)),
entries: resources.map((resource) => resource.entry),
sourceManifestSha256: fileSha256(manifestPath),
});
const resourcesByKey = new Map(resources.map((resource) => [`${resource.entry.resourceVersion}\u0000${resource.entry.assetId}`, resource]));
return {
manifest,
resources: manifest.entries.map((entry) => resourcesByKey.get(`${entry.resourceVersion}\u0000${entry.assetId}`)),
};
} finally {
rmSync(compilerOutput, { force: true, recursive: true });
}
}
export function defaultReplicationRoot(environment = process.env) {
if (!environment.USERPROFILE || !isAbsolute(environment.USERPROFILE)) throw new Error("user_profile_unavailable");
return join(environment.USERPROFILE, "Desktop", "sticker_web_replication_assets");
}
+786
View File
@@ -0,0 +1,786 @@
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
import { basename, dirname, extname, join, relative } from "node:path";
import { inflateRawSync } from "node:zlib";
const fontMimeTypes = new Map([
[".otf", "font/otf"],
[".ttf", "font/ttf"],
[".woff", "font/woff"],
[".woff2", "font/woff2"],
]);
function sfntChecksum(bytes, offset = 0, length = bytes.length) {
let checksum = 0;
for (let index = 0; index < length; index += 4) {
let value = 0;
for (let byte = 0; byte < 4; byte += 1) value = (value << 8) | (bytes[offset + index + byte] ?? 0);
checksum = (checksum + (value >>> 0)) >>> 0;
}
return checksum;
}
function rebuildSfnt(source, tables) {
const ordered = tables.toSorted((left, right) => (left.tag < right.tag ? -1 : left.tag > right.tag ? 1 : 0));
const tableCount = ordered.length;
const largestPower = 2 ** Math.floor(Math.log2(tableCount));
let outputLength = 12 + tableCount * 16;
const records = ordered.map((table) => {
const bytes = table.bytes
? Buffer.from(table.bytes)
: Buffer.from(source.subarray(table.offset, table.offset + table.length));
if (table.tag === "head") bytes.writeUInt32BE(0, 8);
const record = { ...table, bytes, offset: outputLength };
outputLength += Math.ceil(bytes.length / 4) * 4;
return record;
});
const output = Buffer.alloc(outputLength);
output.writeUInt32BE(source.readUInt32BE(0), 0);
output.writeUInt16BE(tableCount, 4);
output.writeUInt16BE(largestPower * 16, 6);
output.writeUInt16BE(Math.log2(largestPower), 8);
output.writeUInt16BE(tableCount * 16 - largestPower * 16, 10);
records.forEach((record, index) => {
const directoryOffset = 12 + index * 16;
output.write(record.tag, directoryOffset, 4, "ascii");
output.writeUInt32BE(sfntChecksum(record.bytes), directoryOffset + 4);
output.writeUInt32BE(record.offset, directoryOffset + 8);
output.writeUInt32BE(record.bytes.length, directoryOffset + 12);
record.bytes.copy(output, record.offset);
});
const head = records.find((record) => record.tag === "head");
if (!head || head.bytes.length < 12) throw new Error("text_font_sfnt_head_invalid");
output.writeUInt32BE((0xB1B0AFBA - sfntChecksum(output)) >>> 0, head.offset + 8);
return output;
}
function normalizeGlyphBounds(source, tables) {
const glyphTable = tables.get("glyf");
const headerTable = tables.get("head");
const locationTable = tables.get("loca");
const maximumProfileTable = tables.get("maxp");
if (
!glyphTable
|| !headerTable
|| headerTable.length < 54
|| !locationTable
|| !maximumProfileTable
|| maximumProfileTable.length < 6
) return undefined;
const glyphCount = source.readUInt16BE(maximumProfileTable.offset + 4);
const locationFormat = source.readInt16BE(headerTable.offset + 50);
const locationEntrySize = locationFormat === 0 ? 2 : locationFormat === 1 ? 4 : 0;
if (locationEntrySize === 0 || locationTable.length < (glyphCount + 1) * locationEntrySize) {
throw new Error("text_font_glyph_location_invalid");
}
const glyphBytes = Buffer.from(source.subarray(glyphTable.offset, glyphTable.offset + glyphTable.length));
const glyphOffset = (index) => {
const offset = locationTable.offset + index * locationEntrySize;
return locationFormat === 0 ? source.readUInt16BE(offset) * 2 : source.readUInt32BE(offset);
};
let changed = false;
let previousEnd = 0;
for (let index = 0; index < glyphCount; index += 1) {
const start = glyphOffset(index);
const end = glyphOffset(index + 1);
if (start < previousEnd || end < start || end > glyphBytes.length) throw new Error("text_font_glyph_location_invalid");
previousEnd = end;
if (end - start < 10) continue;
const xMin = glyphBytes.readInt16BE(start + 2);
const yMin = glyphBytes.readInt16BE(start + 4);
const xMax = glyphBytes.readInt16BE(start + 6);
const yMax = glyphBytes.readInt16BE(start + 8);
if (xMin > xMax) {
glyphBytes.writeInt16BE(xMax, start + 2);
glyphBytes.writeInt16BE(xMin, start + 6);
changed = true;
}
if (yMin > yMax) {
glyphBytes.writeInt16BE(yMax, start + 4);
glyphBytes.writeInt16BE(yMin, start + 8);
changed = true;
}
}
return changed ? glyphBytes : undefined;
}
export function normalizeBrowserFontBytes(source) {
const bytes = Buffer.from(source);
if (bytes.length < 12 || bytes.readUInt32BE(0) !== 0x00010000) return undefined;
const tableCount = bytes.readUInt16BE(4);
if (12 + tableCount * 16 > bytes.length) throw new Error("text_font_sfnt_directory_invalid");
const tables = new Map();
for (let index = 0; index < tableCount; index += 1) {
const recordOffset = 12 + index * 16;
const tag = bytes.toString("ascii", recordOffset, recordOffset + 4);
const offset = bytes.readUInt32BE(recordOffset + 8);
const length = bytes.readUInt32BE(recordOffset + 12);
if (offset + length > bytes.length) throw new Error("text_font_sfnt_table_invalid");
tables.set(tag, { length, offset, recordOffset, tag });
}
const head = tables.get("head");
const verticalHeader = tables.get("vhea");
if (!head || head.length < 12) return undefined;
const invalidVerticalVersion = verticalHeader?.length >= 4 && bytes.readUInt32BE(verticalHeader.offset) === 0x00010001;
const invalidWholeFontChecksum = sfntChecksum(bytes) !== 0xB1B0AFBA;
const normalizedGlyphs = normalizeGlyphBounds(bytes, tables);
if (!invalidVerticalVersion && !invalidWholeFontChecksum && !normalizedGlyphs) return undefined;
const keptTables = [...tables.values()]
.filter((table) => !invalidVerticalVersion || !["vhea", "vmtx"].includes(table.tag))
.map((table) => {
if (table.tag === "glyf" && normalizedGlyphs) return { ...table, bytes: normalizedGlyphs };
if (!invalidVerticalVersion || table.tag !== "post") return table;
const post = Buffer.alloc(32);
bytes.copy(post, 0, table.offset, table.offset + Math.min(table.length, post.length));
post.writeUInt32BE(0x00030000, 0);
return { ...table, bytes: post, length: post.length };
});
if (invalidVerticalVersion && !tables.has("post")) {
const post = Buffer.alloc(32);
post.writeUInt32BE(0x00030000, 0);
keptTables.push({ bytes: post, length: post.length, offset: 0, recordOffset: 0, tag: "post" });
}
return rebuildSfnt(bytes, keptTables);
}
function filesBelow(root) {
const files = [];
const visit = (directory) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.name === "__MACOSX" || entry.name === ".DS_Store" || entry.name.startsWith("._")) continue;
const path = join(directory, entry.name);
if (entry.isDirectory()) visit(path);
else if (entry.isFile()) files.push(path);
}
};
if (existsSync(root)) visit(root);
return files.toSorted((left, right) => left.localeCompare(right));
}
function assetFileType(path) {
const extension = extname(path).toLowerCase();
if ([".manifest", ".mat", ".png", ".prefab", ".sprite"].includes(extension)) return extension.slice(1);
const bytes = readFileSync(path);
if (bytes.length >= 24 && bytes.readUInt32BE(12) === 0x49484452) return "png";
if (bytes[0] === 0x7b) {
try {
const parsed = JSON.parse(bytes.toString("utf8"));
if (["Sprite", "Prefab", "Material"].includes(parsed?.typeId)) return String(parsed.typeId).toLocaleLowerCase("en-US");
} catch {
return undefined;
}
}
return undefined;
}
function readJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
function stemKey(path) {
return basename(path, extname(path)).normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
}
function resourceStemKey(path, type) {
const name = basename(path);
const extension = extname(name).toLowerCase();
const stem = extension === `.${type}` ? basename(name, extension) : name.replace(new RegExp(`_${type}$`, "i"), "");
return stem.normalize("NFKC").toLocaleLowerCase("en-US").replaceAll(/[^\p{L}\p{N}]+/gu, "");
}
function zipEntries(path) {
const archive = readFileSync(path);
let eocd = -1;
for (let index = archive.length - 22; index >= Math.max(0, archive.length - 65_557); index -= 1) {
if (archive.readUInt32LE(index) === 0x06054b50) {
eocd = index;
break;
}
}
if (eocd < 0) throw new Error(`text_font_zip_invalid:${basename(path)}`);
const totalEntries = archive.readUInt16LE(eocd + 10);
let cursor = archive.readUInt32LE(eocd + 16);
const entries = [];
for (let index = 0; index < totalEntries; index += 1) {
if (archive.readUInt32LE(cursor) !== 0x02014b50) throw new Error(`text_font_zip_directory_invalid:${basename(path)}`);
const compression = archive.readUInt16LE(cursor + 10);
const compressedSize = archive.readUInt32LE(cursor + 20);
const uncompressedSize = archive.readUInt32LE(cursor + 24);
const nameLength = archive.readUInt16LE(cursor + 28);
const extraLength = archive.readUInt16LE(cursor + 30);
const commentLength = archive.readUInt16LE(cursor + 32);
const localOffset = archive.readUInt32LE(cursor + 42);
const name = archive.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8").replaceAll("\\", "/");
if (name.startsWith("/") || name.split("/").includes("..")) throw new Error(`text_font_zip_path_invalid:${basename(path)}`);
if (!name.endsWith("/")) {
if (archive.readUInt32LE(localOffset) !== 0x04034b50) throw new Error(`text_font_zip_entry_invalid:${basename(path)}`);
const localNameLength = archive.readUInt16LE(localOffset + 26);
const localExtraLength = archive.readUInt16LE(localOffset + 28);
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
const compressed = archive.subarray(dataOffset, dataOffset + compressedSize);
const bytes = compression === 0 ? Buffer.from(compressed)
: compression === 8 ? inflateRawSync(compressed)
: undefined;
if (!bytes || bytes.length !== uncompressedSize) throw new Error(`text_font_zip_compression_invalid:${basename(path)}`);
entries.push({ bytes, name });
}
cursor += 46 + nameLength + extraLength + commentLength;
}
return entries;
}
function browserFontResource(templateDirectory, templateId, sourceReference, index) {
const sourcePath = join(templateDirectory, ...sourceReference.split("/"));
if (!existsSync(sourcePath) || !statSync(sourcePath).isFile()) throw new Error(`text_font_source_missing:${templateId}:${index}`);
const assetId = `TEXT-FONT-${templateId}-${String(index + 1).padStart(2, "0")}`;
const directExtension = extname(sourcePath).toLowerCase();
if (fontMimeTypes.has(directExtension)) {
return {
assetId,
extension: directExtension,
mimeType: fontMimeTypes.get(directExtension),
names: [basename(sourcePath).toLocaleLowerCase("en-US")],
sourcePath,
};
}
const extracted = filesBelow(dirname(sourcePath)).filter((path) => fontMimeTypes.has(extname(path).toLowerCase()));
if (extracted.length === 1) {
const extension = extname(extracted[0]).toLowerCase();
return {
assetId,
extension,
mimeType: fontMimeTypes.get(extension),
names: [basename(extracted[0]).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
sourcePath: extracted[0],
};
}
const archivedFonts = zipEntries(sourcePath).filter((entry) => fontMimeTypes.has(extname(entry.name).toLowerCase()));
if (archivedFonts.length !== 1) throw new Error(`text_font_archive_ambiguous:${templateId}:${index}`);
const archived = archivedFonts[0];
const extension = extname(archived.name).toLowerCase();
return {
assetId,
extension,
mimeType: fontMimeTypes.get(extension),
names: [basename(archived.name).toLocaleLowerCase("en-US"), basename(sourcePath, directExtension).toLocaleLowerCase("en-US")],
sourceBytes: archived.bytes,
};
}
function manifestFileMap(packageFiles) {
const filesByName = new Map();
const filesByAsciiIdentity = new Map();
for (const path of packageFiles) {
const key = basename(path).toLocaleLowerCase("en-US");
const values = filesByName.get(key) ?? [];
values.push(path);
filesByName.set(key, values);
const asciiIdentity = key.replaceAll(/[^a-z0-9]+/g, "");
const asciiValues = filesByAsciiIdentity.get(asciiIdentity) ?? [];
asciiValues.push(path);
filesByAsciiIdentity.set(asciiIdentity, asciiValues);
}
const mappings = new Map();
const spriteDefinitions = new Map();
const manifestEntries = [];
for (const path of packageFiles.filter((candidate) => extname(candidate).toLowerCase() === ".manifest")) {
let manifest;
try {
manifest = readJson(path);
} catch {
continue;
}
for (const item of manifest.UUIDToFilePath ?? []) {
const uuid = item?.key?.value;
const fileName = item?.value?.fileName;
if (typeof uuid !== "string" || typeof fileName !== "string") continue;
manifestEntries.push({ directories: item.value.directories ?? [], fileName, uuid });
const candidate = join(dirname(path), ...(item.value.directories ?? []), fileName);
const normalizedName = basename(fileName).toLocaleLowerCase("en-US");
const asciiCandidates = filesByAsciiIdentity.get(normalizedName.replaceAll(/[^a-z0-9]+/g, "")) ?? [];
const resolved = existsSync(candidate) ? candidate
: filesByName.get(normalizedName)?.[0]
?? (asciiCandidates.length === 1 ? asciiCandidates[0] : undefined);
if (resolved) {
mappings.set(uuid, resolved);
if (assetFileType(resolved) === "sprite") spriteDefinitions.set(uuid, resolved);
}
else mappings.set(uuid, fileName);
}
}
const actualImagesByUuid = new Map();
const actualSprites = [];
for (const spritePath of packageFiles.filter((candidate) => assetFileType(candidate) === "sprite")) {
let sprite;
try {
sprite = readJson(spritePath);
} catch {
continue;
}
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
const siblingImages = packageFiles.filter((candidate) => dirname(candidate) === dirname(spritePath) && assetFileType(candidate) === "png");
const imagePath = siblingImages.find((candidate) => resourceStemKey(candidate, "png") === resourceStemKey(spritePath, "sprite"))
?? (siblingImages.length === 1 ? siblingImages[0] : undefined);
if (typeof imageUuid === "string") actualSprites.push({ imageUuid, path: spritePath });
if (typeof imageUuid === "string" && imagePath) {
actualImagesByUuid.set(imageUuid, imagePath);
mappings.set(imageUuid, imagePath);
}
}
for (const spriteEntry of manifestEntries.filter((entry) => extname(entry.fileName).toLowerCase() === ".sprite")) {
const imageEntry = manifestEntries.find((entry) => extname(entry.fileName).toLowerCase() === ".png"
&& stemKey(entry.fileName) === stemKey(spriteEntry.fileName)
&& JSON.stringify(entry.directories) === JSON.stringify(spriteEntry.directories));
const asciiIdentity = basename(spriteEntry.fileName).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "");
const matchingActualSprites = actualSprites.filter((entry) => basename(entry.path).toLocaleLowerCase("en-US").replaceAll(/[^a-z0-9]+/g, "") === asciiIdentity);
const matchingImageUuids = [...new Set(matchingActualSprites.map((entry) => entry.imageUuid))];
const inferredImageUuid = matchingImageUuids.length === 1 ? matchingImageUuids[0] : undefined;
const imagePath = imageEntry ? actualImagesByUuid.get(imageEntry.uuid) ?? mappings.get(imageEntry.uuid)
: inferredImageUuid ? actualImagesByUuid.get(inferredImageUuid) ?? mappings.get(inferredImageUuid)
: undefined;
const spriteDefinition = matchingActualSprites.length === 1 ? matchingActualSprites[0].path : mappings.get(spriteEntry.uuid);
if (typeof spriteDefinition === "string" && existsSync(spriteDefinition) && assetFileType(spriteDefinition) === "sprite") {
spriteDefinitions.set(spriteEntry.uuid, spriteDefinition);
}
if (typeof imagePath === "string" && existsSync(imagePath)) mappings.set(spriteEntry.uuid, imagePath);
}
return { mappings, spriteDefinitions };
}
function colorHex(value, fallback = "#111111") {
if (!value || typeof value !== "object") return fallback;
const channel = (name) => Math.max(0, Math.min(255, Math.round(Number(value[name] ?? 0) * 255))).toString(16).padStart(2, "0");
return `#${channel("r")}${channel("g")}${channel("b")}`.toUpperCase();
}
function quaternionDegrees(rotation) {
const z = Number(rotation?.z ?? 0);
const w = Number(rotation?.w ?? 1);
return Math.atan2(2 * w * z, 1 - 2 * z * z) * 180 / Math.PI;
}
function spriteNinePatch(sprite) {
const object = sprite?.object;
if (Number(object?.m_type) !== 3) return undefined;
const sourceWidth = Number(object.m_width);
const sourceHeight = Number(object.m_height);
const left = Number(object.m_startW);
const rightEdge = Number(object.m_endW);
const top = Number(object.m_startH);
const bottomEdge = Number(object.m_endH);
if (![sourceWidth, sourceHeight, left, rightEdge, top, bottomEdge].every(Number.isFinite)
|| sourceWidth <= 0 || sourceHeight <= 0
|| left < 0 || rightEdge < left || rightEdge > sourceWidth
|| top < 0 || bottomEdge < top || bottomEdge > sourceHeight) return undefined;
return {
bottom: sourceHeight - bottomEdge,
left,
right: sourceWidth - rightEdge,
source_height: sourceHeight,
source_width: sourceWidth,
top,
};
}
function pngDimensions(path) {
const bytes = readFileSync(path);
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452) return undefined;
return { height: bytes.readUInt32BE(20), width: bytes.readUInt32BE(16) };
}
function firstMaterialTextureAssetId(renderer, input) {
const materialUuids = (renderer?.m_Materials ?? []).map((item) => item?.uuid?.uuid).filter((uuid) => typeof uuid === "string");
for (const materialUuid of materialUuids) {
const materialPath = input.manifestMappings.get(materialUuid);
if (typeof materialPath !== "string" || extname(materialPath).toLowerCase() !== ".mat" || !existsSync(materialPath)) continue;
let material;
try {
material = readJson(materialPath);
} catch {
continue;
}
const pending = [material];
while (pending.length > 0) {
const value = pending.pop();
if (Array.isArray(value)) {
pending.push(...value);
continue;
}
if (!value || typeof value !== "object") continue;
const textureUuid = value?.uuid?.uuid;
if (typeof textureUuid === "string") {
const texturePath = input.manifestMappings.get(textureUuid);
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
if (assetId) return assetId;
}
pending.push(...Object.values(value));
}
}
return undefined;
}
function prefabResolver(prefab) {
const instances = new Map();
for (const item of prefab?.instance_map ?? []) {
if (Number.isInteger(item?.instance_type) && Number.isInteger(item?.instance_id)) {
instances.set(`${item.instance_type}:${item.instance_id}`, item);
}
}
const resolve = (value) => {
let current = value?.internalObject ?? value;
const visited = new Set();
while (current && typeof current === "object" && !current.object) {
if (current.internalObject) {
current = current.internalObject;
continue;
}
if (current.inner_ptr) {
current = current.inner_ptr;
continue;
}
const key = Number.isInteger(current.instance_type) && Number.isInteger(current.instance_id)
? `${current.instance_type}:${current.instance_id}`
: undefined;
if (!key || visited.has(key) || !instances.has(key)) break;
visited.add(key);
current = instances.get(key);
}
if (current?.inner_ptr && !current.object) return resolve(current.inner_ptr);
return current;
};
return resolve;
}
function components(object, resolve) {
return (object?.m_Components ?? []).map(resolve).filter((item) => item?.object);
}
function prefabLayers(prefab, input) {
const layers = [];
let order = 0;
const resolve = prefabResolver(prefab);
const root = resolve(prefab?.object?.m_RootSo)?.object;
const visit = (wrapped, parent, ignorePosition = false) => {
const typed = resolve(wrapped);
const object = typed?.object;
if (!object) return;
const local = object.m_LocalTfrm ?? {};
const localPosition = local.m_Position ?? {};
const localScale = local.m_Scale ?? {};
const scaleX = parent.scaleX * Number(localScale.x ?? 1);
const scaleY = parent.scaleY * Number(localScale.y ?? 1);
const localX = ignorePosition ? 0 : Number(localPosition.x ?? 0) * parent.scaleX;
const localY = ignorePosition ? 0 : Number(localPosition.y ?? 0) * parent.scaleY;
const parentRadians = parent.rotation * Math.PI / 180;
const transform = {
alpha: parent.alpha * Math.max(0, Math.min(1, Number(object.m_CustomAlpha ?? 1))),
anchorX: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.x ?? 0.5))),
anchorY: Math.max(0, Math.min(1, Number(object.m_anchorPoint?.y ?? 0.5))),
rotation: parent.rotation + quaternionDegrees(local.m_Rotation),
scaleX,
scaleY,
x: parent.x + localX * Math.cos(parentRadians) - localY * Math.sin(parentRadians),
y: parent.y + localX * Math.sin(parentRadians) + localY * Math.cos(parentRadians),
};
const nodeComponents = components(object, resolve);
const localUnderlines = [
...(parent.underlines ?? []),
...nodeComponents.filter((item) => item.typeId === "UnderLineBehavior").flatMap((item) => item.object?.m_UnderLineConfig ?? []),
];
transform.underlines = localUnderlines;
const textMesh = nodeComponents.find((item) => item.typeId === "TextMesh")?.object;
if (textMesh) {
const textRenderer = nodeComponents.find((item) => item.typeId === "TextRenderer")?.object;
const style = textMesh.m_fontStyleInfo ?? {};
const outline = style.outlineInfo?.outlineSize > 0 ? style.outlineInfo : undefined;
const shadow = style.shadowInfos?.find((item) => Number(item?.offset?.x ?? 0) !== 0 || Number(item?.offset?.y ?? 0) !== 0);
const fontUuid = textMesh.m_font?.uuid?.uuid;
layers.push({
align: Number(style.alignment ?? 0) === 2 ? "right" : Number(style.alignment ?? 0) === 1 ? "left" : "center",
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
fill_color: colorHex(style.color),
fill_pattern_asset_id: firstMaterialTextureAssetId(textRenderer, input),
font_file: typeof fontUuid === "string" ? input.manifestMappings.get(fontUuid) : undefined,
font_size: Math.max(1, Number(style.fontSize ?? 48) * Math.abs(scaleY)),
height: Math.max(1, Number(object.m_contentSize?.height ?? style.fontSize ?? 48) * Math.abs(scaleY)),
letter_spacing: Number(style.characterSpacing ?? 1),
line_height: Number(style.lineSpacing ?? 1),
order: order++,
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
shadow_blur: Math.max(0, Number(shadow?.blur ?? shadow?.SDFFontBorder ?? 0)),
shadow_color: colorHex(shadow?.color, "#000000"),
shadow_offset_x: Number(shadow?.offset?.x ?? 0),
shadow_offset_y: -Number(shadow?.offset?.y ?? 0),
stroke_color: colorHex(outline?.outlineColor, "#000000"),
stroke_width: Math.max(0, Number(outline?.outlineSize ?? 0)),
text: String(textMesh.m_text ?? ""),
type: "text",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
x: transform.x,
y: -transform.y,
});
for (const underline of localUnderlines.filter((item) => item?.p1?.enable !== false && item?.p0 === object.m_Name)) {
const config = underline.p1?.exportParams ?? {};
const ninePatch = config.ninePatchInfos?.find((item) => item?.enable !== false && typeof item?.texture?.uuid?.uuid === "string");
const textureUuid = ninePatch?.texture?.uuid?.uuid ?? config.texture?.uuid?.uuid;
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
if (!assetId || typeof texturePath !== "string") continue;
const dimensions = pngDimensions(texturePath);
const targetWidth = Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)
* (config.enableUnderLineSizeWithText === false ? 1 : Number(config.underLineSize ?? 100) / 100));
const naturalRatio = dimensions ? dimensions.height / Math.max(1, dimensions.width) : 0.15;
const targetHeight = Math.max(2, Math.min(Number(object.m_contentSize?.height ?? 48) * 0.65, targetWidth * naturalRatio));
layers.push({
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
asset_id: assetId,
height: targetHeight,
order: order++,
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
type: "image",
width: targetWidth,
x: transform.x,
y: -transform.y + Number(object.m_contentSize?.height ?? 48) * Math.abs(scaleY) / 2
+ Number(config.relativeDistance ?? 0) + targetHeight / 2,
});
}
for (const particleComponent of nodeComponents.filter((item) => item.typeId === "ParticlesText2D").map((item) => item.object)) {
if (particleComponent?.m_isEnabled === false) continue;
const textureUuid = particleComponent?.m_altasTexUUID?.uuid;
const texturePath = typeof textureUuid === "string" ? input.manifestMappings.get(textureUuid) : undefined;
const assetId = typeof texturePath === "string" ? input.imageIds.get(texturePath) : undefined;
if (!assetId) continue;
layers.push({
alpha: transform.alpha * Math.max(0, Math.min(1, Number(particleComponent.m_AlphaIdensity ?? 1))),
anchor_x: transform.anchorX,
anchor_y: 1 - transform.anchorY,
asset_id: assetId,
atlas_columns: Math.max(1, Number(particleComponent.m_altasUcount ?? 1)),
atlas_rows: Math.max(1, Number(particleComponent.m_altasVcount ?? 1)),
color: colorHex(particleComponent.m_ParticleColor, "#FFFFFF"),
density: Math.max(1, Number(particleComponent.m_particlesDensity ?? 1)),
height: Math.max(1, Number(object.m_contentSize?.height ?? textMesh.m_fontStyleInfo?.fontSize ?? 48) * Math.abs(scaleY)),
order: order++,
particle_height: Math.max(1, Number(particleComponent.m_particlesRenderSize?.y ?? 8)),
particle_width: Math.max(1, Number(particleComponent.m_particlesRenderSize?.x ?? 8)),
randomize_angle: Number(particleComponent.m_particlesRandomizeAngle ?? 0),
randomize_position: Number(particleComponent.m_particlesRandomizePosition ?? 0),
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
type: "particles",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
x: transform.x,
y: -transform.y,
});
}
}
const spriteRenderer = nodeComponents.find((item) => item.typeId === "SpriteRenderer")?.object;
if (spriteRenderer && spriteRenderer.m_isEnabled !== false) {
const spriteUuid = spriteRenderer?.m_sprite?.uuid?.uuid;
const spritePath = typeof spriteUuid === "string" ? input.manifestMappings.get(spriteUuid) : undefined;
const spriteDefinitionPath = typeof spriteUuid === "string" ? input.spriteDefinitions.get(spriteUuid) : undefined;
let imagePath;
let ninePatch;
if (typeof spriteDefinitionPath === "string" && existsSync(spriteDefinitionPath)) {
const sprite = readJson(spriteDefinitionPath);
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
imagePath = typeof spritePath === "string" && assetFileType(spritePath) === "png"
? spritePath
: typeof imageUuid === "string" ? input.manifestMappings.get(imageUuid) : undefined;
ninePatch = spriteNinePatch(sprite);
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "sprite" && existsSync(spritePath)) {
const sprite = readJson(spritePath);
const imageUuid = sprite?.object?.m_UUIDList?.[0]?.uuid;
if (typeof imageUuid === "string") imagePath = input.manifestMappings.get(imageUuid);
ninePatch = spriteNinePatch(sprite);
} else if (typeof spritePath === "string" && assetFileType(spritePath) === "png") imagePath = spritePath;
const assetId = typeof imagePath === "string" ? input.imageIds.get(imagePath) : undefined;
if (assetId) {
layers.push({
...(transform.alpha === 1 ? {} : { alpha: transform.alpha }),
...(transform.anchorX === 0.5 ? {} : { anchor_x: transform.anchorX }),
...(transform.anchorY === 0.5 ? {} : { anchor_y: 1 - transform.anchorY }),
asset_id: assetId,
height: Math.max(1, Number(object.m_contentSize?.height ?? 1) * Math.abs(scaleY)),
order: order++,
rotation: -transform.rotation,
scale_x: Math.sign(scaleX) || 1,
scale_y: Math.sign(scaleY) || 1,
type: "image",
width: Math.max(1, Number(object.m_contentSize?.width ?? 1) * Math.abs(scaleX)),
x: transform.x,
y: -transform.y,
...(ninePatch ? { nine_patch: ninePatch } : {}),
});
} else {
input.unresolvedImages.push({ spriteUuid, spritePath });
}
}
for (const child of object.m_Children ?? []) visit(child, transform);
};
for (const child of root?.m_Children ?? []) visit(child, { alpha: 1, rotation: 0, scaleX: 1, scaleY: 1, x: 0, y: 0 }, true);
return layers;
}
function fontIdForFile(fontResources, file) {
if (typeof file !== "string") return fontResources[0]?.assetId;
const name = basename(file).toLocaleLowerCase("en-US");
return fontResources.find((resource) => resource.names.includes(name))?.assetId ?? fontResources[0]?.assetId;
}
function defaultText(metadata, layers) {
const candidates = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
.filter((value) => typeof value === "string" && value.trim());
return String(candidates[0] ?? layers.find((layer) => layer.type === "text" && layer.text.trim())?.text ?? metadata.display_name ?? metadata.canonical_id);
}
function normalizeModel(layers, defaultValue, fontResources) {
const textLayers = layers.filter((layer) => layer.type === "text");
const primary = textLayers.find((layer) => layer.text.trim().toLocaleLowerCase("zh-CN") === defaultValue.trim().toLocaleLowerCase("zh-CN")) ?? textLayers[0];
if (!primary) return undefined;
for (const layer of textLayers) {
layer.editable = layer === primary;
layer.font_id = fontIdForFile(fontResources, layer.font_file);
delete layer.font_file;
}
const bounds = layers.map((layer) => {
const anchorX = Number(layer.anchor_x ?? 0.5);
const anchorY = Number(layer.anchor_y ?? 0.5);
const radians = Number(layer.rotation ?? 0) * Math.PI / 180;
const cosine = Math.cos(radians);
const sine = Math.sin(radians);
const corners = [
[-anchorX * layer.width, -anchorY * layer.height],
[(1 - anchorX) * layer.width, -anchorY * layer.height],
[-anchorX * layer.width, (1 - anchorY) * layer.height],
[(1 - anchorX) * layer.width, (1 - anchorY) * layer.height],
].map(([x, y]) => ({
x: layer.x + x * Number(layer.scale_x ?? 1) * cosine - y * Number(layer.scale_y ?? 1) * sine,
y: layer.y + x * Number(layer.scale_x ?? 1) * sine + y * Number(layer.scale_y ?? 1) * cosine,
}));
return {
bottom: Math.max(...corners.map((item) => item.y)),
left: Math.min(...corners.map((item) => item.x)),
right: Math.max(...corners.map((item) => item.x)),
top: Math.min(...corners.map((item) => item.y)),
};
});
const left = Math.min(...bounds.map((item) => item.left));
const right = Math.max(...bounds.map((item) => item.right));
const top = Math.min(...bounds.map((item) => item.top));
const bottom = Math.max(...bounds.map((item) => item.bottom));
const centerX = (left + right) / 2;
const centerY = (top + bottom) / 2;
const normalization = Math.min(1, 360 / Math.max(1, right - left), 260 / Math.max(1, bottom - top));
for (const layer of layers) {
layer.x = Number(((layer.x - centerX) * normalization).toFixed(3));
layer.y = Number(((layer.y - centerY) * normalization).toFixed(3));
layer.width = Number((layer.width * normalization).toFixed(3));
layer.height = Number((layer.height * normalization).toFixed(3));
if (layer.type === "text") {
layer.font_size = Number((layer.font_size * normalization).toFixed(3));
layer.stroke_width = Number((layer.stroke_width * normalization).toFixed(3));
layer.shadow_blur = Number((layer.shadow_blur * normalization).toFixed(3));
layer.shadow_offset_x = Number((layer.shadow_offset_x * normalization).toFixed(3));
layer.shadow_offset_y = Number((layer.shadow_offset_y * normalization).toFixed(3));
} else if (layer.type === "particles") {
layer.particle_height = Number((layer.particle_height * normalization).toFixed(3));
layer.particle_width = Number((layer.particle_width * normalization).toFixed(3));
}
}
return {
half_size: {
height: Number(((bottom - top) * normalization / 2).toFixed(3)),
width: Number(((right - left) * normalization / 2).toFixed(3)),
},
image_layers: layers.filter((layer) => layer.type === "image"),
particle_layers: layers.filter((layer) => layer.type === "particles"),
text_layers: textLayers,
};
}
export function compileTextTemplateAssets({ templateDirectory, templateId }) {
const metadata = readJson(join(templateDirectory, "metadata.json"));
const fontResources = (metadata.files?.fonts ?? []).map((reference, index) => {
const resource = browserFontResource(templateDirectory, templateId, reference, index);
const normalized = normalizeBrowserFontBytes(resource.sourceBytes ?? readFileSync(resource.sourcePath));
return normalized ? { ...resource, sourceBytes: normalized, sourcePath: undefined } : resource;
});
if (fontResources.length === 0) throw new Error(`text_template_font_missing:${templateId}`);
const packageRoot = join(templateDirectory, "package");
const packageFiles = filesBelow(packageRoot);
const imagePaths = packageFiles.filter((path) => assetFileType(path) === "png");
const imageResources = imagePaths.map((sourcePath, index) => ({
assetId: `TEXT-IMAGE-${templateId}-${String(index + 1).padStart(3, "0")}`,
extension: ".png",
mimeType: "image/png",
sourcePath,
}));
const imageIds = new Map(imagePaths.map((path, index) => [path, imageResources[index].assetId]));
const { mappings: manifestMappings, spriteDefinitions } = manifestFileMap(packageFiles);
const unresolvedImages = [];
const prefabCandidates = packageFiles.filter((path) => extname(path).toLowerCase() === ".prefab").flatMap((path) => {
let prefab;
try {
prefab = readJson(path);
} catch {
return [];
}
const layers = prefabLayers(prefab, { imageIds, manifestMappings, spriteDefinitions, unresolvedImages });
const texts = layers.filter((layer) => layer.type === "text").map((layer) => layer.text.trim().toLocaleLowerCase("zh-CN"));
const expected = [metadata.default_text, metadata.runtime?.layer?.default_text, ...(metadata.runtime?.human_strings ?? [])]
.filter((value) => typeof value === "string" && value.trim()).map((value) => value.trim().toLocaleLowerCase("zh-CN"));
const match = expected.some((value) => texts.includes(value));
return [{ layers, path, score: (match ? 10_000 : 0) + texts.length * 100 + layers.length }];
}).filter((candidate) => candidate.layers.some((layer) => layer.type === "text"));
const chosen = prefabCandidates.toSorted((left, right) => right.score - left.score || left.path.localeCompare(right.path))[0];
const value = defaultText(metadata, chosen?.layers ?? []);
const fallbackLayers = [{
align: "center",
editable: true,
fill_color: "#111111",
font_id: fontResources[0].assetId,
font_size: 48,
height: 58,
letter_spacing: 1,
line_height: 1.2,
order: 0,
rotation: 0,
scale_x: 1,
scale_y: 1,
shadow_blur: 0,
shadow_color: "#000000",
shadow_offset_x: 0,
shadow_offset_y: 0,
stroke_color: "#000000",
stroke_width: 0,
text: value,
type: "text",
width: Math.max(96, Array.from(value).length * 52),
x: 0,
y: 0,
}];
const renderModel = normalizeModel(chosen?.layers ?? fallbackLayers, value, fontResources);
if (!renderModel || renderModel.text_layers.some((layer) => !layer.font_id)) throw new Error(`text_template_render_model_invalid:${templateId}`);
return {
catalog: {
default_font_id: renderModel.text_layers.find((layer) => layer.editable)?.font_id ?? fontResources[0].assetId,
default_font_size: renderModel.text_layers.find((layer) => layer.editable)?.font_size ?? 48,
default_text: value,
font_match_status: "template_package",
render_model: renderModel,
},
diagnostics: {
package_images: imageResources.length,
prefab_candidates: prefabCandidates.length,
unresolved_images: unresolvedImages.length,
},
resources: [...fontResources, ...imageResources].map(({ names: _names, ...resource }) => resource),
};
}
+1 -1
View File
@@ -112,7 +112,7 @@ export function validateWp5FinalManifest(path) {
const raw = readFileSync(path, "utf8");
if (raw.includes(WP4_07_RED_RESOURCE_VERSION) || raw.includes("fixture-v1")) throw new Error("WP4_07_PLACEHOLDER_ASSET_REJECTED");
const manifest = JSON.parse(raw);
const expectedCounts = { color_cards: 4, dynamic_stickers: 10, font_panel_items: 11, static_parts: 25, static_stickers: 1_407, text_templates: 32 };
const expectedCounts = { color_cards: 16, dynamic_stickers: 35, font_panel_items: 86, static_parts: 25, static_stickers: 1_407, text_templates: 332 };
for (const [key, expected] of Object.entries(expectedCounts)) {
if (manifest.counts?.[key] !== expected) throw new Error(`WP4_07_FINAL_MANIFEST_COUNT_MISMATCH:${key}`);
}
+22 -1
View File
@@ -14,7 +14,11 @@ export const DEFERRED_EXTERNAL_TASKS = Object.freeze(["TASK-WP7-03", "TASK-WP7-0
export function buildFinalReleaseRecord({ appVersion, browsers, buildCommit, frozenFromCommit, recordedAt, windows }) {
const record = {
appVersion,
browsers: browsers.map(({ brand, fullVersion }) => ({ brand, fullVersion })),
browsers: browsers.map(({ brand, fullVersion, supportedMajorVersions }) => ({
brand,
fullVersion,
...(supportedMajorVersions ? { supportedMajorVersions: [...supportedMajorVersions] } : {}),
})),
buildCommit: buildCommit.toLowerCase(),
deferredExternalTasks: [...DEFERRED_EXTERNAL_TASKS],
finalRelease: true,
@@ -44,8 +48,25 @@ export function validateFinalReleaseRecord(record) {
} else {
const brands = record.browsers.map(({ brand }) => brand).sort();
if (brands.join("|") !== "Google Chrome|Microsoft Edge") errors.push("browserBrands");
const supportedMajorCount = record.browsers.reduce(
(count, browser) => count + (Array.isArray(browser.supportedMajorVersions)
? browser.supportedMajorVersions.length
: 1),
0,
);
if (supportedMajorCount > 8) errors.push("supportedMajorVersions.total");
for (const browser of record.browsers) {
if (!VERSION.test(browser.fullVersion ?? "")) errors.push(`${browser.brand}.fullVersion`);
if (browser.supportedMajorVersions !== undefined) {
const values = browser.supportedMajorVersions;
const baselineMajor = Number.parseInt(browser.fullVersion.split(".")[0] ?? "0", 10);
if (!Array.isArray(values) || values.length === 0 || values.length > 8
|| values.some((value) => !Number.isSafeInteger(value) || value < 1)
|| new Set(values).size !== values.length
|| !values.includes(baselineMajor)) {
errors.push(`${browser.brand}.supportedMajorVersions`);
}
}
if ("path" in browser || "executablePath" in browser || "executableSha256" in browser) errors.push(`${browser.brand}.privateMetadata`);
}
}
+1 -1
View File
@@ -80,7 +80,7 @@ function findFiles(directory, name) {
if (phase === "green") {
const traces = findFiles(outputDirectory, "trace.zip");
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("p0-a-public-allowlist"));
const whiteTrace = traces.find((path) => path.toLowerCase().includes("wp5-white-001") || path.toLowerCase().includes("complete-complex-asset-catalog"));
const colorTrace = traces.find((path) => path.toLowerCase().includes("wp5-col-001") || path.toLowerCase().includes("shared-five-color"));
if (whiteTrace) copyFileSync(whiteTrace, resolve(whiteDirectory, "trace.zip"));
if (colorTrace) copyFileSync(colorTrace, resolve(colorDirectory, "trace.zip"));
+91
View File
@@ -0,0 +1,91 @@
import { readFileSync } from "node:fs";
import { createServer } from "node:http";
import { isAbsolute, join, resolve } from "node:path";
import { chromium } from "@playwright/test";
function option(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
function defaultConfigPath() {
if (!process.env.LOCALAPPDATA || !isAbsolute(process.env.LOCALAPPDATA)) {
throw new Error("local_app_data_unavailable");
}
return join(process.env.LOCALAPPDATA, "Dada", "P0A", "config", "instance.json");
}
const manifestPath = resolve(option("--manifest") ?? "config/runtime-assets-manifest.json");
const configPath = resolve(option("--config") ?? process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultConfigPath());
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
const configuration = JSON.parse(readFileSync(configPath, "utf8"));
if (typeof configuration.asset_root !== "string" || !isAbsolute(configuration.asset_root)) {
throw new Error("asset_root_configuration_invalid");
}
const fontEntries = manifest.entries
.filter((entry) => entry.assetId.startsWith("TEXT-FONT-") && entry.mimeType.startsWith("font/"))
.toSorted((left, right) => left.assetId.localeCompare(right.assetId));
const uniqueEntries = [...new Map(fontEntries.map((entry) => [entry.sha256, entry])).values()];
const fontPaths = new Map(
uniqueEntries.map((entry) => [
`/${encodeURIComponent(entry.assetId)}`,
{ mimeType: entry.mimeType, path: join(configuration.asset_root, entry.relativePath) },
]),
);
const server = createServer((request, response) => {
const font = fontPaths.get(request.url ?? "");
if (!font) {
response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
response.end("<!doctype html><title>Dada browser font validation</title>");
return;
}
const bytes = readFileSync(font.path);
response.writeHead(200, { "Content-Length": bytes.length, "Content-Type": font.mimeType });
response.end(bytes);
});
await new Promise((resolveReady) => server.listen(0, "127.0.0.1", resolveReady));
const address = server.address();
if (!address || typeof address === "string") throw new Error("browser_font_probe_server_unavailable");
const origin = `http://127.0.0.1:${address.port}`;
const browser = await chromium.launch({ channel: option("--channel") ?? "msedge", headless: true });
const failures = [];
try {
let page = await browser.newPage();
await page.goto(origin);
for (let index = 0; index < uniqueEntries.length; index += 1) {
if (index > 0 && index % 25 === 0) {
await page.close();
page = await browser.newPage();
await page.goto(origin);
}
const entry = uniqueEntries[index];
const result = await page.evaluate(async ({ family, url }) => {
try {
const response = await fetch(url);
if (!response.ok) return `http_${response.status}`;
await new FontFace(family, await response.arrayBuffer()).load();
return "loaded";
} catch (error) {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
}, { family: `DadaFontProbe${index}`, url: `${origin}/${encodeURIComponent(entry.assetId)}` });
if (result !== "loaded") failures.push({ asset_id: entry.assetId, error: result });
}
} finally {
await browser.close();
await new Promise((resolveClosed) => server.close(resolveClosed));
}
const result = {
checked_entries: fontEntries.length,
failed_fonts: failures,
status: failures.length === 0 ? "passed" : "failed",
unique_fonts: uniqueEntries.length,
};
process.stdout.write(`${JSON.stringify(result)}\n`);
if (failures.length > 0) process.exitCode = 1;
+24
View File
@@ -0,0 +1,24 @@
import { execFileSync } from "node:child_process";
const requiredSubjects = [
"fix(POSTV1-07): 固定画布布局与文字贴纸选择",
"fix(POSTV1-07): 自动关闭编辑器操作提示",
"fix(POSTV1-08): 修复文字拖动闪烁与自动保存",
"fix(POSTV1-09): 展示项目生成图片",
"feat(POSTV1-10): 增加统一交互反馈",
"fix(POSTV1-11): 修复生成提交的会话令牌轮换",
"fix(POSTV1-runtime): 改善后台启动与状态窗口可见性",
"fix(POSTV1-browser): 同时支持 Chrome 150 与 151",
"fix(POSTV1-editor): 修复多选拖动与底图调整",
];
const subjects = new Set(execFileSync("git", ["log", "--format=%s", "HEAD"], { encoding: "utf8" })
.split(/\r?\n/u)
.filter(Boolean));
const missing = requiredSubjects.filter((subject) => !subjects.has(subject));
if (missing.length > 0) {
throw new Error(`postv1_ui_lineage_incomplete:${missing.join("|")}`);
}
console.log(JSON.stringify({ required_commit_count: requiredSubjects.length, status: "passed" }));
@@ -40,6 +40,7 @@ internal static class Program
var supervisor = await TestSupervisorLifecycleAsync();
await TestAmapProbeSecurityAsync();
TestSecureConfigurationPersistence();
TestRuntimeDirectoryBootstrap();
TestStructuredLogging();
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SEC"), security);
WriteEvidence(Environment.GetEnvironmentVariable("DADA_EVIDENCE_DIR_SUP"), supervisor);
@@ -125,6 +126,29 @@ internal static class Program
}
}
private static void TestRuntimeDirectoryBootstrap()
{
var root = Path.Combine(Path.GetTempPath(), $"dada-runtime-root-{Guid.NewGuid():N}");
try
{
Directory.CreateDirectory(root);
SupervisorRuntime.EnsureRuntimeDirectories(root);
foreach (var relativePath in new[]
{
"db", "content/references", "content/generated", "content/exports",
"managed-assets", "derived-assets", "staging",
"logs/api", "logs/worker", "logs/supervisor",
})
{
True(Directory.Exists(Path.Combine(root, relativePath)), $"runtime directory missing: {relativePath}");
}
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static async Task<object> TestCredentialBoundaryAsync()
{
var store = new TestCredentialStore();
@@ -146,6 +170,8 @@ internal static class Program
True(leakProbe.SensitiveOutputDetected, "credential echo must be detected");
Equal(string.Empty, leakProbe.StandardOutput, "credential echo output discarded");
Equal(string.Empty, leakProbe.StandardError, "credential echo error discarded");
True(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"mime_type\":\"image/png\",\"pixel_height\":1080,\"pixel_width\":1080,\"real_calls\":1,\"success\":true}", out _), "AI probe success output accepted");
False(AiGatewayProbe.TryValidateOutput("{\"code\":\"ai_probe_passed\",\"raw_body\":\"private\",\"real_calls\":1,\"success\":true}", out _), "AI probe private output rejected");
var externalArguments = new[]
{
@@ -0,0 +1,74 @@
using System.Diagnostics;
using System.Text.Json;
namespace Dada.Supervisor;
internal static class AiGatewayProbe
{
internal static async Task<int> RunAsync(ICredentialStore credentials, CancellationToken cancellationToken = default)
{
var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe");
var worker = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
if (!File.Exists(node) || !File.Exists(worker)) return WriteFailure("ai_probe_runtime_missing", 0);
var startInfo = new ProcessStartInfo(node) { WorkingDirectory = AppContext.BaseDirectory };
startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node");
startInfo.ArgumentList.Add(worker);
startInfo.ArgumentList.Add("--dada-ai-probe");
startInfo.ArgumentList.Add("--dada-credential-stdin");
var result = await CredentialProcessLauncher.RunToCompletionAsync(startInfo, ChildRole.Worker, credentials, cancellationToken);
if (result.SensitiveOutputDetected || result.StandardError.Length > 0 || !TryValidateOutput(result.StandardOutput, out var sanitized))
{
return WriteFailure("ai_probe_runtime_failed", 0);
}
Console.WriteLine(sanitized);
return result.ExitCode;
}
internal static bool TryValidateOutput(string output, out string sanitized)
{
sanitized = string.Empty;
try
{
using var document = JsonDocument.Parse(output);
var root = document.RootElement;
if (root.ValueKind != JsonValueKind.Object) return false;
var allowed = new HashSet<string>(StringComparer.Ordinal)
{
"code", "error_category", "mime_type", "pixel_height", "pixel_width", "real_calls", "success",
};
if (root.EnumerateObject().Any(property => !allowed.Contains(property.Name))) return false;
if (!root.TryGetProperty("success", out var success) || success.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) return false;
if (!root.TryGetProperty("real_calls", out var realCalls) || realCalls.ValueKind != JsonValueKind.Number || !realCalls.TryGetInt32(out var count) || count is < 0 or > 1) return false;
var passed = success.GetBoolean();
var code = root.GetProperty("code").GetString();
if (passed)
{
if (code != "ai_probe_passed" || count != 1) return false;
var mime = root.GetProperty("mime_type").GetString();
if (mime is not ("image/jpeg" or "image/png" or "image/webp")) return false;
if (!PositiveDimension(root, "pixel_width") || !PositiveDimension(root, "pixel_height")) return false;
}
else
{
if (code != "ai_probe_failed" || !root.TryGetProperty("error_category", out var category)
|| category.ValueKind != JsonValueKind.String || (category.GetString()?.Length ?? 0) is < 1 or > 64) return false;
}
sanitized = JsonSerializer.Serialize(root);
return true;
}
catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException)
{
return false;
}
}
private static bool PositiveDimension(JsonElement root, string name) =>
root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.Number
&& value.TryGetInt32(out var dimension) && dimension is > 0 and <= 4096;
private static int WriteFailure(string code, int realCalls)
{
Console.WriteLine(JsonSerializer.Serialize(new { code, real_calls = realCalls, success = false }));
return 2;
}
}
+6 -2
View File
@@ -50,7 +50,9 @@ internal static class CredentialProcessLauncher
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var target in CredentialCatalog.RequiredFor(role))
{
credentials[target] = store.Read(target) ?? string.Empty;
var value = store.Read(target);
if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target);
credentials[target] = value ?? string.Empty;
}
startInfo.UseShellExecute = false;
@@ -98,7 +100,9 @@ internal static class CredentialProcessLauncher
var credentials = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var target in CredentialCatalog.RequiredFor(role))
{
credentials[target] = store.Read(target) ?? string.Empty;
var value = store.Read(target);
if (role == ChildRole.Worker && string.IsNullOrWhiteSpace(value)) throw new MissingCredentialException(target);
credentials[target] = value ?? string.Empty;
}
startInfo.UseShellExecute = false;
@@ -26,7 +26,7 @@ internal static class OfflineCommandRouter
return args[0] switch
{
"configure" => RunConfigure(args.Skip(1).ToArray()),
"secrets" => RunSecrets(args.Skip(1).ToArray(), credentials),
"secrets" => await RunSecretsAsync(args.Skip(1).ToArray(), credentials),
"admin-allowlist" => RunAdminAllowlist(args.Skip(1).ToArray(), credentials),
"doctor" when args.Length == 1 => RunDoctor(credentials),
"validate-external" => await ControlledExternalValidationLauncher.RunAsync(args.Skip(1).ToArray(), credentials),
@@ -66,7 +66,7 @@ internal static class OfflineCommandRouter
return 0;
}
private static int RunSecrets(string[] args, ICredentialStore store)
private static async Task<int> RunSecretsAsync(string[] args, ICredentialStore store)
{
if (args.Length != 2 || !TryResolveCredential(args[1], out var target)) return Usage();
switch (args[0])
@@ -86,6 +86,8 @@ internal static class OfflineCommandRouter
return 0;
case "probe" when target == CredentialCatalog.ApiAmap:
return AmapProbe.Run(store.Read(target));
case "probe" when target == CredentialCatalog.WorkerAiGateway:
return await AiGatewayProbe.RunAsync(store);
default:
return Usage();
}
+4 -1
View File
@@ -30,7 +30,10 @@ internal static class Program
};
var state = await runtime.StartAsync();
if (!form.IsDisposed) form.SetState(state);
if (state == SupervisorState.Ready) SupervisorForm.OpenProductInSupportedBrowser();
if (state == SupervisorState.Ready && !SupervisorForm.OpenProductInSupportedBrowser())
{
form.SetBrowserLaunchFailure();
}
}
form.Shown += async (_, _) => await StartRuntimeAsync();
form.RestartRequested += async () => await StartRuntimeAsync();
+9 -4
View File
@@ -26,6 +26,7 @@ internal sealed class SupervisorForm : Form
Font = new Font("Segoe UI", 9F);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
ShowInTaskbar = true;
StartPosition = FormStartPosition.CenterScreen;
Text = "Dada";
@@ -90,10 +91,6 @@ internal sealed class SupervisorForm : Form
trayIcon.DoubleClick += (_, _) => RestoreWindow();
FormClosing += (_, _) => trayIcon.Visible = false;
Resize += (_, _) =>
{
if (WindowState == FormWindowState.Minimized) Hide();
};
SetState(initialState);
}
@@ -139,6 +136,14 @@ internal sealed class SupervisorForm : Form
Activate();
}
internal void SetBrowserLaunchFailure()
{
if (state == SupervisorState.Ready)
{
statusDetail.Text = "本机服务运行正常,但未能自动打开浏览器;请点击“打开 Dada”或选择浏览器。";
}
}
protected override void Dispose(bool disposing)
{
if (disposing) trayIcon.Dispose();
@@ -5,6 +5,19 @@ namespace Dada.Supervisor;
internal sealed class SupervisorRuntime : IAsyncDisposable
{
private static readonly string[] RequiredDataDirectories =
[
"db",
Path.Combine("content", "references"),
Path.Combine("content", "generated"),
Path.Combine("content", "exports"),
"managed-assets",
"derived-assets",
"staging",
Path.Combine("logs", "api"),
Path.Combine("logs", "worker"),
Path.Combine("logs", "supervisor"),
];
private readonly ICredentialStore credentials;
private ManagedComponentSupervisor? api;
private ManagedComponentSupervisor? worker;
@@ -26,6 +39,7 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
{
return SupervisorState.StartupFailed;
}
EnsureRuntimeDirectories(configuration.LocalDataRoot);
try
{
logger = new StructuredJsonlLogger(Path.Combine(configuration.LocalDataRoot, "logs", "supervisor"), "supervisor");
@@ -42,12 +56,30 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs");
if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed;
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
await api.StartAsync(cancellationToken);
try
{
api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded);
await api.StartAsync(cancellationToken);
worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded);
await worker.StartAsync(cancellationToken);
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
worker = CreateComponent(node, workerEntry, ChildRole.Worker, SupervisorState.WorkerDegraded);
await worker.StartAsync(cancellationToken);
return TryLog(new StructuredLogEvent("ready", ErrorCategory: "none")) ? SupervisorState.Ready : SupervisorState.StorageUnavailable;
}
catch
{
await StopComponentsAsync();
return TryLog(new StructuredLogEvent("failed", ErrorCategory: "service_unavailable"))
? SupervisorState.StartupFailed
: SupervisorState.StorageUnavailable;
}
}
internal static void EnsureRuntimeDirectories(string dataRoot)
{
foreach (var directory in RequiredDataDirectories)
{
Directory.CreateDirectory(Path.Combine(dataRoot, directory));
}
}
private void EnsureAdminPepper()
@@ -102,10 +134,26 @@ internal sealed class SupervisorRuntime : IAsyncDisposable
}
public async ValueTask DisposeAsync()
{
await StopComponentsAsync();
}
private async Task StopComponentsAsync()
{
var stops = new List<Task>();
if (worker is not null) stops.Add(worker.DisposeAsync().AsTask());
if (api is not null) stops.Add(api.DisposeAsync().AsTask());
await Task.WhenAll(stops);
try
{
await Task.WhenAll(stops);
}
catch
{
}
finally
{
worker = null;
api = null;
}
}
}
@@ -1,3 +1,4 @@
using System.ComponentModel;
using System.Diagnostics;
using Microsoft.Win32;
@@ -13,10 +14,20 @@ internal static class SupportedBrowserLauncher
{
var executable = FindExecutable(executableName);
if (executable is null) return false;
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
startInfo.ArgumentList.Add(uri.AbsoluteUri);
Process.Start(startInfo);
return true;
try
{
var startInfo = new ProcessStartInfo(executable) { UseShellExecute = false };
startInfo.ArgumentList.Add(uri.AbsoluteUri);
return Process.Start(startInfo) is not null;
}
catch (Win32Exception)
{
return false;
}
catch (InvalidOperationException)
{
return false;
}
}
private static string? FindExecutable(string executableName)
+12
View File
@@ -0,0 +1,12 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("POSTV1-11 workspace generation CSRF refresh", () => {
it("keeps the rotated account-settings token for generation submission", () => {
const source = readFileSync("apps/web/src/project-pages.tsx", "utf8");
expect(source).toContain("csrf_token: string;");
expect(source).toContain("csrf_token: settings.csrf_token");
});
});
@@ -0,0 +1,100 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { GenerationModelConfigurationCatalog, type ModelConfigurationView } from "../../apps/api/src/model-configuration.js";
describe("POSTV1-04 generation runtime wiring", () => {
it("constructs, injects and closes the production generation submission service", () => {
const main = readFileSync("apps/api/src/main.ts", "utf8");
expect(main).toContain('import { GenerationSubmissionService } from "./generation-submission.js";');
expect(main).toContain("let generations: GenerationSubmissionService | undefined;");
expect(main).toContain("generations = new GenerationSubmissionService({");
expect(main).toContain("models: new GenerationModelConfigurationCatalog(models),");
expect(main).toContain("...(generations ? { generations } : {}),");
expect(main).toContain("generations?.close();");
});
it("maps the current model configuration into the generation submission contract", () => {
const configuration: ModelConfigurationView = {
config_set_version: 7,
configured_default_model_id: "gemini-3.1-flash-image-preview",
recommended_model_id: "gemini-3.1-flash-image-preview",
models: [{
config_version: 3,
contract_evidence_ref: "fixture-contract",
contract_validation_status: "verified",
credit_cost: 2,
display_name: "Fixture model",
enabled: true,
error_mapping_profile: {},
gateway_account_ref: "fixture-gateway",
is_default: true,
model_id: "gemini-3.1-flash-image-preview",
prompt_max_length: 1_000,
recommendation_priority: 1,
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
route_profile: {},
runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-05T00:00:00.000Z", reason: "available" },
safety_source: "provider",
supported_ratios: ["3:4", "invalid"],
}],
};
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
expect(catalog.readModel("gemini-3.1-flash-image-preview")).toEqual({
configSetVersion: 7,
configVersion: 3,
contractValidationStatus: "verified",
creditCost: 2,
enabled: true,
modelId: "gemini-3.1-flash-image-preview",
promptMaxLength: 1_000,
referenceLimits: { maxFileBytes: 10, maxFiles: 2, maxTotalBytes: 20 },
runtimeAvailability: { availableForNewJobs: true, reason: null },
supportedRatios: ["3:4"],
});
expect(catalog.readModel("missing")).toBeUndefined();
});
it("maps model and contract blocks into generation error categories", () => {
const configuration = {
config_set_version: 1,
configured_default_model_id: "gemini-3.1-flash-image-preview",
recommended_model_id: null,
models: [],
} satisfies ModelConfigurationView;
const catalog = new GenerationModelConfigurationCatalog({ read: () => configuration });
const base = {
config_version: 1,
contract_evidence_ref: null,
contract_validation_status: "verified" as const,
credit_cost: 1,
display_name: "Fixture model",
enabled: true,
error_mapping_profile: {},
gateway_account_ref: "fixture-gateway",
is_default: true,
model_id: "gemini-3.1-flash-image-preview" as const,
prompt_max_length: 1_000,
recommendation_priority: 1,
reference_limits: { max_file_bytes: 10, max_files: 2, max_total_bytes: 20 },
route_profile: {},
safety_source: "provider",
supported_ratios: ["3:4"],
};
configuration.models = [{
...base,
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "contract_blocked" },
}];
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("gateway_contract_invalid");
configuration.models = [{
...base,
runtime_availability: { available_for_new_jobs: false, checked_at: "2026-08-05T00:00:00.000Z", reason: "worker_degraded" },
}];
expect(catalog.readModel(base.model_id)?.runtimeAvailability.reason).toBe("model_disabled");
});
});
@@ -0,0 +1,95 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createApp } from "../../apps/api/src/app.js";
import { RegistrationService } from "../../apps/api/src/registration.js";
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
const fixedNow = Date.parse("2026-08-05T06:00:00.000Z");
const writeHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" };
const roots: string[] = [];
const services: RegistrationService[] = [];
function createRegistrationService() {
const root = mkdtempSync(join(tmpdir(), "dada-local-test-session-"));
roots.push(root);
const registration = new RegistrationService({
challengePepper: Buffer.alloc(32, 0x51),
clock: () => fixedNow,
currentPrivacyNoticeVersion: "p0a-notice-v1",
databasePath: join(root, "dada.sqlite3"),
invitePepper: Buffer.alloc(32, 0x52),
resend: new MockResendAdapter(),
sessionPepper: Buffer.alloc(32, 0x53),
});
services.push(registration);
return registration;
}
afterEach(() => {
for (const service of services.splice(0)) {
try { service.close(); } catch { /* already closed by the test */ }
}
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
});
describe("POSTV1-03 local test session", () => {
it("does not expose the local test route unless explicitly enabled", async () => {
const registration = createRegistrationService();
const app = await createApp({ browserGate: false, networkBoundary: { allowTestPort: true }, registration });
const status = await app.inject({ headers: writeHeaders, method: "GET", url: "/api/v1/auth/local-test" });
const created = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
expect(status.statusCode).toBe(404);
expect(created.statusCode).toBe(404);
await app.close();
});
it("creates one isolated fixture account and restores it without duplicate credits", async () => {
const registration = createRegistrationService();
const app = await createApp({
browserGate: false,
localTestAuth: true,
networkBoundary: { allowTestPort: true },
registration,
});
const status = await app.inject({ headers: writeHeaders, method: "GET", url: "/api/v1/auth/local-test" });
expect(status.statusCode).toBe(200);
expect(status.json()).toEqual({ available: true });
const first = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
expect(first.statusCode).toBe(200);
expect(first.json()).toMatchObject({
audience: "user",
credits: { available_balance: 10, reserved_balance: 0 },
status: "authenticated",
user: { creator_name: "本机测试用户", role: "user", social_id: "@dada_local_test", status: "active" },
});
expect(first.headers["set-cookie"]).toContain("dada_session=");
const session = await app.inject({
headers: { cookie: first.headers["set-cookie"], host: "127.0.0.1:43121" },
method: "GET",
url: "/api/v1/auth/session",
});
expect(session.statusCode).toBe(200);
expect(session.json()).toMatchObject({ authenticated: true, credits: { available_balance: 10 } });
const second = await app.inject({ headers: writeHeaders, method: "POST", url: "/api/v1/auth/local-test" });
expect(second.statusCode).toBe(200);
expect(second.json().user.user_id).toBe(first.json().user.user_id);
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM users").get()).toEqual({ count: 1 });
expect(registration.database.prepare("SELECT COUNT(*) AS count FROM credit_ledger").get()).toEqual({ count: 1 });
expect(registration.database.prepare("SELECT counts_toward_stage_limit FROM users").get()).toEqual({ counts_toward_stage_limit: 0 });
const openapi = JSON.stringify(app.swagger());
expect(openapi).not.toContain("/api/v1/auth/local-test");
expect(openapi).not.toContain("local-test-user");
await app.close();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from "node:crypto";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { loadConfiguredRuntimeAssets } from "../../apps/api/src/runtime-assets.js";
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
if (resolve(directory).startsWith(resolve(tmpdir()))) rmSync(directory, { force: true, recursive: true });
}
});
function fixture() {
const base = mkdtempSync(join(tmpdir(), "dada-postv1-assets-"));
temporaryDirectories.push(base);
const assetRoot = join(base, "assets");
const dataRoot = join(base, "data");
const configFile = join(base, "instance.json");
const trustedManifestPath = join(base, "trusted-manifest.json");
const bytes = Buffer.from("synthetic sticker bytes");
const entry = {
assetId: "STK001",
mimeType: "image/png",
relativePath: "p0a-static-v1/STK001.png",
resourceVersion: "p0a-static-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const manifest = {
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_previews: 0 },
entries: [entry],
root_ref: "p0a_runtime_assets",
schema_version: "DadaRuntimeAssets/v1",
source: "external_read_only",
};
mkdirSync(join(assetRoot, "p0a-static-v1"), { recursive: true });
writeFileSync(join(assetRoot, entry.relativePath), bytes);
writeFileSync(join(assetRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(trustedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
return { assetRoot, configFile, dataRoot, trustedManifestPath };
}
describe("POSTV1-06 portable runtime assets", () => {
it("activates a validated external asset root without exposing its path", () => {
const input = fixture();
const loaded = loadConfiguredRuntimeAssets(input);
expect(loaded.state).toMatchObject({ configured: true, pause_reason: null, status: "active" });
expect(loaded.publicAssets?.read("p0a-static-v1", "STK001")?.bytes.toString()).toBe("synthetic sticker bytes");
expect(JSON.stringify(loaded.state)).not.toContain(input.assetRoot);
});
it("rejects a changed external manifest and leaves unrelated API features available", () => {
const input = fixture();
writeFileSync(join(input.assetRoot, "manifest.json"), "{}\n");
const loaded = loadConfiguredRuntimeAssets(input);
expect(loaded.publicAssets).toBeUndefined();
expect(loaded.state).toMatchObject({ configured: true, pause_reason: "asset_manifest_invalid", status: "unavailable" });
});
});
+44
View File
@@ -0,0 +1,44 @@
import { createRequire } from "node:module";
import { describe, expect, it } from "vitest";
import {
ModelConfigurationService,
portableRuntimeModelCandidates,
} from "../../apps/api/src/model-configuration.js";
const requireFromApi = createRequire(new URL("../../apps/api/package.json", import.meta.url));
const Database = requireFromApi("better-sqlite3") as new (path: string) => {
close(): void;
};
describe("POSTV1-02 portable runtime model seed", () => {
it("enables only models backed by the real OneAPI contract", () => {
const database = new Database(":memory:");
try {
const models = new ModelConfigurationService({ database, seedCandidates: portableRuntimeModelCandidates }).read();
const flash = models.models.find((model) => model.model_id === "gemini-3.1-flash-image-preview");
const pro = models.models.find((model) => model.model_id === "gemini-3-pro-image-preview");
const gpt = models.models.find((model) => model.model_id === "gpt-image-2");
expect(models.configured_default_model_id).toBe("gemini-3.1-flash-image-preview");
expect(flash).toMatchObject({
contract_validation_status: "verified",
enabled: true,
runtime_availability: { available_for_new_jobs: true, reason: "available" },
});
expect(flash?.route_profile).toMatchObject({ endpoint: "https://oneapi.intelligrow.cn/v1/chat/completions" });
expect(pro).toMatchObject({
contract_validation_status: "unverified",
enabled: false,
runtime_availability: { available_for_new_jobs: false, reason: "configured_disabled" },
});
expect(gpt).toMatchObject({
contract_validation_status: "verified",
enabled: true,
runtime_availability: { available_for_new_jobs: true, reason: "available" },
});
} finally {
database.close();
}
});
});
+44 -1
View File
@@ -13,6 +13,14 @@ const supportedEdge = browserSupportFixture({
brand: "Microsoft Edge",
fullVersion: "150.0.4078.99",
});
const supportedChrome150 = browserSupportFixture({
brand: "Google Chrome",
fullVersion: "150.0.7871.187",
});
const supportedChrome151 = browserSupportFixture({
brand: "Google Chrome",
fullVersion: "151.0.0.0",
});
const rejectedIdentityCases = [
{
expectedReason: "platform_unsupported",
@@ -125,7 +133,12 @@ afterAll(async () => {
describe("TDD-WP0-BRW-001 supported browser contract", () => {
it("cross-checks UA-CH and issues only a short-lived signed support cookie", async () => {
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
const app = await createApp({
browserSupportRelease: testBrowserSupportRelease,
productIndexHtml: "<!doctype html><title>Dada product test</title><div id=\"root\"></div>",
} as never);
const gate = await app.inject({ headers: { host: "127.0.0.1:43121" }, method: "GET", url: "/" });
expect(gate.body).toContain("当前浏览器无法使用 Dada");
const checked = await app.inject({
headers: supportedEdge.headers,
method: "POST",
@@ -140,6 +153,7 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
status: "supported",
supported_browsers: [
{ brand: "Google Chrome", major: 150 },
{ brand: "Google Chrome", major: 151 },
{ brand: "Microsoft Edge", major: 150 },
],
});
@@ -150,6 +164,14 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
const cookie = supportCookie(checked);
expect(cookie).toBeDefined();
const productHtml = await app.inject({
headers: { cookie, host: "127.0.0.1:43121", "sec-ch-ua": supportedEdge.headers["sec-ch-ua"] },
method: "GET",
url: "/app",
});
expect(productHtml.statusCode).toBe(200);
expect(productHtml.body).toContain("Dada product test");
expect(productHtml.body).not.toContain("当前浏览器无法使用 Dada");
const product = await app.inject({
headers: {
cookie,
@@ -175,6 +197,26 @@ describe("TDD-WP0-BRW-001 supported browser contract", () => {
expect(staleCookie.statusCode).toBe(426);
await restarted.close();
});
it.each([
{ expectedMajor: 150, fixture: supportedChrome150 },
{ expectedMajor: 151, fixture: supportedChrome151 },
])("accepts explicitly declared Chrome $expectedMajor", async ({ expectedMajor, fixture }) => {
const app = await createApp({ browserSupportRelease: testBrowserSupportRelease } as never);
const checked = await app.inject({
headers: fixture.headers,
method: "POST",
payload: fixture.body,
url: "/api/v1/support/check",
});
expect(checked.statusCode).toBe(200);
expect(checked.json()).toMatchObject({
browser: { brand: "Google Chrome", major: expectedMajor },
status: "supported",
});
await app.close();
});
});
describe("TDD-WP0-BRW-002 hard block", () => {
@@ -195,6 +237,7 @@ describe("TDD-WP0-BRW-002 hard block", () => {
reason: expectedReason,
supported_browsers: [
{ brand: "Google Chrome", major: 150 },
{ brand: "Google Chrome", major: 151 },
{ brand: "Microsoft Edge", major: 150 },
],
},
+1 -1
View File
@@ -26,7 +26,7 @@ describe("TDD-WP0-DATA-001-root-validation resource boundary", () => {
const assetRoot = join(base, "read-only-assets");
const relativePath = "images/source.png";
const bytes = Buffer.from("synthetic png fixture");
const assetId = randomUUID();
const assetId = "STK001";
mkdirSync(join(assetRoot, "images"), { recursive: true });
writeFileSync(join(assetRoot, relativePath), bytes);
const manifest = JSON.stringify({ assets: [{ asset_id: assetId, relative_path: relativePath }] });
@@ -40,8 +40,23 @@ describe("TDD-WP7-EXT-003 production Amap adapter", () => {
const clients = initializeApiCredentialClients(credentials);
expect(clients.amap).toBeInstanceOf(RealAmapAdapter);
expect(clients.resendConfigured).toBe(true);
expect(Object.values(credentials)).toEqual(["", "", ""]);
clients.amap.dispose();
clients.amap.dispose?.();
clients.adminAllowlistPepper.fill(0);
});
it("reports an empty Resend credential without retaining its value", () => {
const credentials = {
"Dada/P0A/admin/pepper": "fixture-admin-value",
"Dada/P0A/api/amap": "",
"Dada/P0A/api/resend": "",
};
const clients = initializeApiCredentialClients(credentials);
expect(clients.resendConfigured).toBe(false);
expect(Object.values(credentials)).toEqual(["", "", ""]);
clients.amap.dispose?.();
clients.adminAllowlistPepper.fill(0);
});
});
+45 -2
View File
@@ -33,6 +33,10 @@ function routeSession(page: Page) {
}));
}
function generatedImageSvg(label: string) {
return `<svg xmlns="http://www.w3.org/2000/svg" width="300" height="400"><rect width="300" height="400" fill="#d9f24f"/><text x="150" y="210" text-anchor="middle">${label}</text></svg>`;
}
async function captureEvidence(page: Page, caseId: string, name: string) {
const root = process.env.DADA_EVIDENCE_DIR_PROJECTS;
if (!root) return;
@@ -75,6 +79,12 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
],
}), contentType: "application/json", status: 200,
}));
await page.route(`**/api/v1/private-assets/projects/${successId}/images/*`, (route) => route.fulfill({
body: generatedImageSvg("城市工作室"),
contentType: "image/svg+xml",
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
status: 200,
}));
let batchPayload: unknown;
await page.route("**/api/v1/projects/failed-empty/trash", async (route) => {
batchPayload = route.request().postDataJSON();
@@ -86,12 +96,30 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
await expect(page.getByRole("heading", { name: "项目" })).toBeVisible();
await expect(page.getByText("2 / 20 active")).toBeVisible();
const projectPreview = page.getByRole("img", { name: "城市工作室预览图" });
await expect(projectPreview).toHaveAttribute(
"src",
`/api/v1/private-assets/projects/${successId}/images/00000000-0000-4000-8000-000000000213`,
);
await expect(projectPreview).toHaveCSS("object-fit", "cover");
await expect(page.getByLabel("选择失败草稿:失败草稿")).toBeVisible();
await expect(page.getByLabel("选择失败草稿:城市工作室")).toHaveCount(0);
await page.getByLabel("选择失败草稿:失败草稿").check();
await page.getByRole("button", { name: "批量移入回收站" }).click();
const batchTrashButton = page.getByRole("button", { name: "批量移入回收站" });
await batchTrashButton.hover();
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, -1)");
const buttonBounds = await batchTrashButton.boundingBox();
if (!buttonBounds) throw new Error("Batch trash button geometry is unavailable.");
await page.mouse.move(buttonBounds.x + buttonBounds.width / 2, buttonBounds.y + buttonBounds.height / 2);
await page.mouse.down();
await expect(batchTrashButton).toHaveCSS("transform", "matrix(1, 0, 0, 1, 0, 1)");
await page.mouse.move(0, 0);
await page.mouse.up();
await batchTrashButton.click();
expect(batchPayload).toEqual({ project_ids: [failedId] });
await expect(page.getByText("失败草稿已移入回收站")).toBeVisible();
await page.emulateMedia({ reducedMotion: "reduce" });
await expect(page.getByRole("link", { name: "打开项目:城市工作室" })).toHaveCSS("transition-duration", "0s");
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(375);
await captureEvidence(page, "TDD-WP2-PROJ-005-failed-draft-retry", "projects-mobile.png");
});
@@ -99,9 +127,10 @@ test("TDD-WP2-PROJ-005 limits failed-empty selection on the project list", async
test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project detail", async ({ page }) => {
await routeSession(page);
const projectId = "00000000-0000-4000-8000-000000000221";
const currentImageId = "00000000-0000-4000-8000-000000000222";
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
body: JSON.stringify({
created_at: "2026-07-28T08:00:00.000Z", current_image_id: "00000000-0000-4000-8000-000000000222",
created_at: "2026-07-28T08:00:00.000Z", current_image_id: currentImageId,
draft_prompt: "城市工作室", generations: [], images: Array.from({ length: 10 }, (_, index) => ({
created_at: `2026-07-28T08:${String(index).padStart(2, "0")}:00.000Z`,
generation_id: `00000000-0000-4000-8000-${String(223 + index).padStart(12, "0")}`,
@@ -111,11 +140,25 @@ test("TDD-WP2-PROJ-001 keeps ratio fixed and blocks an eleventh image in project
status: "active", successful_image_count: 10, updated_at: "2026-07-28T08:10:00.000Z",
}), contentType: "application/json", status: 200,
}));
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => route.fulfill({
body: generatedImageSvg("生成结果"),
contentType: "image/svg+xml",
headers: { "Content-Disposition": "attachment; filename=\"dada-original.png\"" },
status: 200,
}));
await page.goto(`${webUrl}/app/projects/${projectId}`);
await expect(page.getByRole("heading", { name: "城市工作室" })).toBeVisible();
await expect(page.getByText("固定比例 3:4")).toBeVisible();
await expect(page.getByText("10 / 10 张成功图")).toBeVisible();
const currentImage = page.getByRole("img", { name: "城市工作室当前底图" });
await expect(currentImage).toHaveAttribute(
"src",
`/api/v1/private-assets/projects/${projectId}/images/${currentImageId}`,
);
await expect(currentImage).toHaveAttribute("loading", "eager");
await expect(currentImage).toHaveCSS("object-fit", "contain");
await expect(page.getByRole("img", { name: "生成结果 10" })).toBeVisible();
await expect(page.getByRole("button", { name: "继续生成" })).toBeDisabled();
await expect(page.getByText("请先删除一张非当前底图的历史图")).toBeVisible();
await expect(page.getByRole("radio")).toHaveCount(0);
+23
View File
@@ -23,6 +23,29 @@ test.beforeAll(async () => {
test.afterAll(async () => vite.close());
test("POSTV1-03 enters the workspace through the local test session", async ({ page }) => {
await page.route("**/api/v1/auth/local-test", (route) => {
if (route.request().method() === "GET") {
return route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ available: true }) });
}
expect(route.request().postData()).toBeNull();
return route.fulfill({ contentType: "application/json", status: 200, body: JSON.stringify({ status: "authenticated" }) });
});
await page.goto(webUrl);
const button = page.getByRole("button", { name: "直接进入本机测试" });
await expect(button).toBeVisible();
await button.click();
await expect(page).toHaveURL(`${webUrl}/app`);
});
test("POSTV1-03 hides the local test entry when the API does not enable it", async ({ page }) => {
await page.route("**/api/v1/auth/local-test", (route) => route.fulfill({ status: 404, body: "" }));
await page.goto(webUrl);
await expect(page.getByRole("button", { name: "直接进入本机测试" })).toHaveCount(0);
});
test("TDD-WP1-NOTICE-001 expands DVPM8 only after successful code delivery", async ({ page }) => {
await page.route("**/api/v1/auth/register/send", (route) => route.fulfill({
contentType: "application/json",
@@ -67,6 +67,20 @@ async function routeEditor(page: Page) {
});
}
async function canvasFingerprint(page: Page) {
return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas context unavailable.");
return [
...context.getImageData(108, 720, 1, 1).data,
...context.getImageData(324, 720, 1, 1).data,
...context.getImageData(540, 720, 1, 1).data,
...context.getImageData(756, 720, 1, 1).data,
...context.getImageData(972, 720, 1, 1).data,
];
});
}
test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => {
await routeEditor(page);
const saves: Array<Record<string, unknown>> = [];
@@ -144,3 +158,35 @@ test("TDD-WP4-BG-002 commits, reopens, undoes, and resets background processing"
writeEvidence("TDD-WP4-BG-002-processing-controls", "pixel-diff.json", { preview_commit_undo_reset: true, export_source_canvas_state_stable: true });
await page.screenshot({ fullPage: true, path: process.env.DADA_EVIDENCE_DIR_EDITOR ? resolve(process.env.DADA_EVIDENCE_DIR_EDITOR, "TDD-WP4-BG-002-processing-controls", "processing-controls.png") : undefined });
});
test("TDD-WP4-BG-002 previews background pixels before committing", async ({ page }) => {
await routeEditor(page);
const saves: Array<Record<string, unknown>> = [];
let version = 7;
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
saves.push(route.request().postDataJSON() as Record<string, unknown>);
version += 1;
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: version }), contentType: "application/json", status: 200 });
});
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "恢复原图" }).click();
await page.getByRole("button", { name: "应用调整" }).click();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(0);
await expect.poll(async () => (await canvasFingerprint(page)).some((channel) => channel < 240)).toBe(true);
const baseline = (await canvasFingerprint(page)).join(",");
const ranges = page.locator(".editor-inspector input[type=range]");
await ranges.nth(0).fill("40");
await ranges.nth(1).fill("25");
await ranges.nth(2).fill("-35");
await ranges.nth(3).fill("70");
await ranges.nth(4).fill("100");
await expect.poll(async () => (await canvasFingerprint(page)).join(",")).not.toBe(baseline);
await page.getByRole("button", { name: "应用调整" }).click();
await expect.poll(() => saves.length, { timeout: 4_000 }).toBeGreaterThan(1);
const committed = saves.at(-1) as { canvas_state: typeof initialCanvasState };
expect(committed.canvas_state.background.adjustments).toMatchObject({
brightness: 40, contrast: 25, saturation: -35, sharpness: 100, temperature: 70,
});
});
+125 -1
View File
@@ -5,6 +5,8 @@ import { homedir } from "node:os";
import { join, resolve } from "node:path";
import type { CanvasState } from "@dada/shared-contracts";
import { P0A_TEXT_TEMPLATES, createTextTemplateElement } from "../../apps/web/src/text-assets.js";
let vite: ViteDevServer;
let webUrl: string;
@@ -26,7 +28,8 @@ const session = {
user: { creator_name: "Canvas User", role: "user", social_id: "@canvas_user", status: "active", user_id: "00000000-0000-4000-8000-000000000501" },
};
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材");
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT
?? join(homedir(), "Desktop", "sticker_web_replication_assets", "sticker_normal");
const originalStickerFixtures: Readonly<Record<string, string>> = {
STK001: join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"),
STK002: join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"),
@@ -131,6 +134,41 @@ test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first",
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-CAN-001-fifty-elements", "fifty-elements.png") });
});
test("TDD-WP4-CAN-001 drags distant multi-selected objects as one group", async ({ page }) => {
const projectId = uuid(519);
const backend = {
canvas: canvas([sticker(1, { x: 0.2, y: 0.2 }, 0), sticker(2, { x: 0.8, y: 0.8 }, 1)]),
saves: 0,
version: 5,
};
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
if (!bounds) throw new Error("Canvas bounds unavailable.");
const point = (x: number, y: number) => ({ x: bounds.x + bounds.width * x, y: bounds.y + bounds.height * y });
await page.getByRole("button", { name: "多选模式" }).click();
await page.mouse.click(point(0.2, 0.2).x, point(0.2, 0.2).y);
await page.mouse.click(point(0.8, 0.8).x, point(0.8, 0.8).y);
await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible();
await page.mouse.move(point(0.2, 0.2).x, point(0.2, 0.2).y);
await page.mouse.down();
await page.mouse.move(point(0.3, 0.3).x, point(0.3, 0.3).y, { steps: 4 });
await page.mouse.up();
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(0);
const [first, second] = backend.canvas.elements.map(({ position }) => position);
expect(first?.x).toBeCloseTo(0.3, 6);
expect(first?.y).toBeCloseTo(0.3, 6);
expect(second?.x).toBeCloseTo(0.9, 6);
expect(second?.y).toBeCloseTo(0.9, 6);
expect((first?.x ?? 0) - 0.2).toBeCloseTo((second?.x ?? 0) - 0.8, 10);
expect((first?.y ?? 0) - 0.2).toBeCloseTo((second?.y ?? 0) - 0.8, 10);
await expect(page.getByRole("heading", { name: "已选 2 个对象" })).toBeVisible();
});
test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers", async ({ page }) => {
const projectId = uuid(520);
const backend = { canvas: canvas([sticker(1, { x: 0.5, y: 0.5 }, 0), sticker(2, { x: 0.5, y: 0.5 }, 1)]), saves: 0, version: 5 };
@@ -196,3 +234,89 @@ test("TDD-WP4-STK-001 transforms, cycles, selects and reopens ordinary stickers"
writeEvidence("TDD-WP4-STK-001-transform-sticker", "pixel-diff.json", { canvas_and_saved_state_match: true, export_source_canvas_state_stable: true });
if (process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_EDITOR_ELEMENTS, "TDD-WP4-STK-001-transform-sticker", "transformed-sticker.png") });
});
test("POSTV1-08 keeps the canvas frame stable and previews drag before pointer release", async ({ page }) => {
await page.addInitScript(() => {
const counters = { height: 0, width: 0 };
Object.defineProperty(window, "__dadaCanvasDimensionWrites", { value: counters });
for (const key of ["height", "width"] as const) {
const descriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, key);
if (!descriptor?.get || !descriptor.set) throw new Error(`Canvas ${key} descriptor unavailable.`);
Object.defineProperty(HTMLCanvasElement.prototype, key, {
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
get: descriptor.get,
set(value: number) {
if (this.classList.contains("editor-canvas")) counters[key] += 1;
descriptor.set!.call(this, value);
},
});
}
});
const projectId = uuid(530);
const text = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, {
createdAt: "2026-08-03T08:00:00.000Z",
elementId: uuid(630),
}, 0, { position: { x: 0.5, y: 0.5 } });
const backend = { canvas: canvas([text]), saves: 0, version: 6 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
if (!bounds) throw new Error("Canvas bounds unavailable.");
const center = { x: bounds.x + bounds.width * 0.5, y: bounds.y + bounds.height * 0.5 };
await page.mouse.click(center.x, center.y);
await expect(page.getByLabel("文字内容")).toBeVisible();
await page.getByLabel("文字内容").fill("拖动中的文字");
await page.getByRole("spinbutton", { name: "有效字号", exact: true }).fill("64");
await page.getByLabel("文字填充色").fill("#FA5751");
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBe(1);
expect(backend.canvas.elements[0]).toMatchObject({
content: "拖动中的文字",
scale: { x: 64 / 48, y: 64 / 48 },
style_parameters: { fill_color: "#FA5751" },
});
const savesBeforeDrag = backend.saves;
const before = await page.evaluate(() => {
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
});
await page.mouse.move(center.x, center.y);
await page.mouse.down();
await page.waitForTimeout(650);
await expect(page.getByRole("menu")).toBeVisible();
await page.mouse.move(bounds.x + bounds.width * 0.68, center.y);
await expect(page.getByRole("menu")).toBeHidden();
await expect(page.getByLabel("文字内容")).toBeVisible();
const preview = await stage.evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas context unavailable.");
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
let totalX = 0;
for (let y = 0; y < canvas.height; y += 1) {
for (let x = 0; x < canvas.width; x += 1) {
const offset = (y * canvas.width + x) * 4;
if ((pixels[offset] ?? 255) < 20 && (pixels[offset + 1] ?? 0) >= 75 && (pixels[offset + 1] ?? 255) <= 120 && (pixels[offset + 2] ?? 0) >= 180) {
count += 1;
totalX += x;
}
}
}
return { blue_pixel_count: count, blue_x: count > 0 ? totalX / count / canvas.width : 0 };
});
const during = await page.evaluate(() => {
return structuredClone((window as typeof window & { __dadaCanvasDimensionWrites: { height: number; width: number } }).__dadaCanvasDimensionWrites);
});
expect(during).toEqual(before);
expect(preview.blue_pixel_count).toBeGreaterThan(100);
expect(preview.blue_x).toBeGreaterThan(0.60);
await page.mouse.up();
await expect.poll(() => backend.saves, { timeout: 4_000 }).toBeGreaterThan(savesBeforeDrag);
expect(backend.canvas.elements[0]?.position.x).toBeCloseTo(0.68, 2);
expect(backend.canvas.elements[0]?.content).toBe("拖动中的文字");
await expect(page.getByLabel("文字内容")).toBeVisible();
});
+211 -12
View File
@@ -43,6 +43,11 @@ interface Backend {
version: number;
}
interface EditorRouteOptions {
assetRequests?: string[];
failFontOnce?: string;
}
function writeEvidence(caseId: string, name: string, value: unknown) {
const root = process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR;
if (!root) return;
@@ -51,7 +56,7 @@ function writeEvidence(caseId: string, name: string, value: unknown) {
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
}
async function routeEditor(page: Page, projectId: string, backend: Backend) {
async function routeEditor(page: Page, projectId: string, backend: Backend, options: EditorRouteOptions = {}) {
const windowsFont = join(process.env.WINDIR ?? "C:\\Windows", "Fonts", "arial.ttf");
if (!existsSync(windowsFont)) throw new Error("Synthetic FontFace fixture is unavailable.");
const fontBytes = readFileSync(windowsFont);
@@ -76,29 +81,56 @@ async function routeEditor(page: Page, projectId: string, backend: Backend) {
backend.recent = [item, ...backend.recent.filter((entry) => entry.asset_id !== item.asset_id)];
await route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json", status: 200 });
});
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf", status: 200 }));
const imageFixture = '<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="#ff00ff"/></svg>';
let failedFontRequests = 0;
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => {
const assetId = new URL(route.request().url()).pathname.split("/").at(-1) ?? "";
options.assetRequests?.push(assetId);
if (options.failFontOnce === assetId && failedFontRequests++ === 0) return route.fulfill({ status: 503 });
const image = assetId.startsWith("TEXT-PREVIEW-") || assetId.startsWith("TEXT-IMAGE-");
return route.fulfill(image
? { body: imageFixture, contentType: "image/svg+xml", status: 200 }
: { body: fontBytes, contentType: "font/ttf", status: 200 });
});
}
test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and account recent use", async ({ page }) => {
async function magentaPixels(page: Page) {
return page.getByLabel("编辑画布").evaluate((canvas: HTMLCanvasElement) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas context unavailable.");
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let count = 0;
for (let index = 0; index < pixels.length; index += 4) {
if ((pixels[index] ?? 0) > 240 && (pixels[index + 1] ?? 255) < 20 && (pixels[index + 2] ?? 0) > 240) count += 1;
}
return count;
});
}
test("TDD-WP4-TXT-003 exposes the complete catalog, display-name search and account recent use", async ({ page }) => {
const projectId = uuid(730);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await expect(page.locator(".editor-template-grid button")).toHaveCount(32);
for (const [label, count] of [["花字", 8], ["标题", 8], ["标签", 8], ["简约", 8]] as const) {
await expect(page.locator(".editor-template-grid button")).toHaveCount(332);
for (const [label, count] of [["花字", 145], ["标题", 119], ["标签", 51], ["简约", 17]] as const) {
await page.getByRole("button", { name: label, exact: true }).click();
await expect(page.locator(".editor-template-grid button")).toHaveCount(count);
}
await page.getByRole("button", { name: "全部", exact: true }).click();
const search = page.getByPlaceholder("搜索文字模板显示名称");
await search.fill("生活");
await expect(page.locator(".editor-template-grid button")).toHaveCount(5);
await expect(page.locator(".editor-template-grid button")).toHaveCount(9);
await search.fill("FLOWER001");
await expect(page.locator(".editor-template-grid button")).toHaveCount(0);
await search.fill("");
expect(page.getByText("添加普通文字", { exact: true })).toHaveCount(0);
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
const flowerTemplate = page.getByRole("button", { name: /FLOWER001 春日计划/ });
await flowerTemplate.hover();
await expect(flowerTemplate).toHaveCSS("background-color", "rgb(255, 255, 214)");
await expect(flowerTemplate.locator(".editor-template-preview")).toHaveCSS("transform", "matrix(1.03, 0, 0, 1.03, 0, 0)");
await flowerTemplate.click();
await expect(page.getByText("对象 1 / 50")).toBeVisible();
await expect.poll(() => backend.recent).toEqual([{ asset_id: "FLOWER001", asset_kind: "text_template", resource_version: "p0a-complex-v1" }]);
await page.reload();
@@ -106,11 +138,93 @@ test("TDD-WP4-TXT-003 exposes the frozen catalog, display-name search and accoun
await expect(page.getByLabel("最近使用文字模板").getByText("FLOWER001", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
expect(page.getByPlaceholder("搜索普通贴纸")).toHaveCount(0);
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 8, simple: 8, tag: 8, title: 8 }, count: 32, first: "FLOWER001", last: "SIMPLE008" });
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 32, recent: backend.recent, unavailable_is_disabled: true });
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "catalog.json", { categories: { flower: 145, simple: 17, tag: 51, title: 119 }, count: 332, first: "FLOWER001", last: "SIMPLE017" });
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "response.json", { public_count: 332, recent: backend.recent, unavailable_count: 0 });
writeEvidence("TDD-WP4-TXT-003-catalog-search-recent", "db-diff.json", { account_user_id: userId, recent: backend.recent, search_did_not_write: true });
});
test("POSTV1-ASSET-ALL-16 retries a transient archived font failure without permanently disabling the template", async ({ page }) => {
const projectId = uuid(735);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
const failedFont = "TEXT-FONT-FLOWER001-01";
const assetRequests: string[] = [];
await routeEditor(page, projectId, backend, { assetRequests, failFontOnce: failedFont });
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect(page.getByText("素材暂不可用,未使用系统字体替代。", { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: /FLOWER001 春日计划 字体待重试/ })).toBeEnabled();
expect(backend.canvas.elements).toHaveLength(0);
await page.getByRole("button", { name: /FLOWER001 春日计划 字体待重试/ }).click();
await expect.poll(() => backend.canvas.elements.length).toBe(1);
expect(assetRequests.filter((assetId) => assetId === failedFont)).toHaveLength(2);
});
test("POSTV1-TEMPLATE-FIDELITY-17 renders archived styles instead of two-character preview placeholders", async ({ page }) => {
const projectId = uuid(737);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
const template = page.getByRole("button", { name: /FLOWER003 人生照片/ });
const preview = template.locator(".editor-template-live-preview");
await expect(preview).toBeVisible();
await expect(template.locator(".editor-template-mark")).toHaveCount(0);
await expect(preview).toContainText("#人生照片");
await expect(preview).toHaveCSS("font-family", /Dada_TEXT_FONT_FLOWER003_01/);
await expect(preview).toHaveCSS("color", "rgb(255, 255, 248)");
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_TEXT_FONT_FLOWER003_01"'))).toBe(true);
});
test("POSTV1-TEMPLATE-FIDELITY-17 isolates manual stroke state between templates", async ({ page }) => {
const projectId = uuid(738);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
await expect(page.getByLabel("启用描边")).not.toBeChecked();
await page.getByLabel("启用描边").check();
await expect.poll(() => backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
await page.getByRole("button", { name: /FLOWER002 笑不活了/ }).click();
await expect.poll(() => backend.canvas.elements).toHaveLength(2);
await expect(page.getByLabel("启用描边")).not.toBeChecked();
expect(backend.canvas.elements[0]?.style_parameters?.stroke_enabled).toBe(true);
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
await page.getByLabel("文字模板切换").selectOption("H003");
await expect(page.getByLabel("启用描边")).not.toBeChecked();
await expect.poll(() => backend.canvas.elements[1]?.template_or_asset_id).toBe("H003");
expect(backend.canvas.elements[1]?.style_parameters?.stroke_enabled).toBe(false);
});
test("POSTV1-ASSET-ALL-16 renders captured image, material, particle and underline decorations", async ({ page }) => {
const projectId = uuid(736);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 2 };
const assetRequests: string[] = [];
await routeEditor(page, projectId, backend, { assetRequests });
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
for (const template of [
{ id: "FLOWER001", name: "春日计划", image: "TEXT-IMAGE-FLOWER001-001" },
{ id: "FLOWER048", name: "糖", image: "TEXT-IMAGE-FLOWER048-001" },
{ id: "H013", name: "周末俱乐部", image: "TEXT-IMAGE-H013-001" },
{ id: "FLOWER121", name: "厨房和生活", image: "TEXT-IMAGE-FLOWER121-001" },
]) {
await page.getByRole("button", { name: new RegExp(`${template.id} ${template.name}`) }).click();
await expect.poll(() => assetRequests.includes(template.image)).toBe(true);
await expect.poll(() => magentaPixels(page)).toBeGreaterThan(20);
await page.getByRole("button", { name: "撤销" }).click();
await expect.poll(() => backend.canvas.elements.length).toBe(0);
}
});
test("TDD-WP4-TXT-001 preserves multiline content and transforms across a template switch", async ({ page }) => {
const projectId = uuid(740);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 3 };
@@ -197,18 +311,103 @@ test("TDD-WP4-TXT-002 waits for the archived font and commits exact style ranges
const element = backend.canvas.elements[0]!;
expect(element.opacity).toBe(1);
expect(element.font_override).toBe("FONT081");
expect(element.scale).toEqual({ x: 2, y: 2 });
expect(element.scale).toEqual({ x: 96 / 50, y: 96 / 50 });
expect(element.style_parameters).toMatchObject({ background_opacity: 0.35, letter_spacing: 20, line_height: 1.9, stroke_width: 12, text_align: "right" });
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("48");
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("50");
await page.getByRole("button", { name: "重做" }).click();
await expect(page.getByRole("spinbutton", { name: "有效字号", exact: true })).toHaveValue("96");
await page.reload();
await page.getByLabel("编辑画布").click({ position: { x: 270, y: 360 } });
const reopenedStage = page.getByLabel("编辑画布");
const reopenedBounds = await reopenedStage.boundingBox();
const reopenedText = backend.canvas.elements[0];
if (!reopenedBounds || !reopenedText) throw new Error("Reopened text geometry is unavailable.");
await reopenedStage.click({ position: {
x: reopenedBounds.width * reopenedText.position.x,
y: reopenedBounds.height * reopenedText.position.y,
} });
await expect(page.getByLabel("字体覆盖")).toHaveValue("FONT081");
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "font-load.json", { fallback: null, font_id: "FONT081", ready: true, source: "public_release_fixture" });
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "canvas-state.json", backend.canvas);
writeEvidence("TDD-WP4-TXT-002-font-metrics-ranges", "pixel-diff.json", { background_alpha_separate: true, clipped_visible_text: false, effective_font_size: 96 });
if (process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_TEXT_EDITOR, "TDD-WP4-TXT-002-font-metrics-ranges", "font-styles.png") });
});
test("POSTV1-07 keeps the canvas anchored when the text template panel opens", async ({ page }) => {
const projectId = uuid(760);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 5 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
const stage = page.getByLabel("编辑画布");
const before = await stage.boundingBox();
if (!before) throw new Error("Canvas geometry is unavailable before opening text templates.");
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await expect(page.locator(".editor-template-grid button")).toHaveCount(332);
const after = await stage.boundingBox();
if (!after) throw new Error("Canvas geometry is unavailable after opening text templates.");
const assetsPanelScroll = await page.getByLabel("素材与底图来源").evaluate((panel) => ({
clientHeight: panel.clientHeight,
overflowY: getComputedStyle(panel).overflowY,
scrollHeight: panel.scrollHeight,
}));
expect(Math.abs(after.x - before.x)).toBeLessThanOrEqual(1);
expect(Math.abs(after.y - before.y)).toBeLessThanOrEqual(1);
expect(Math.abs(after.width - before.width)).toBeLessThanOrEqual(1);
expect(Math.abs(after.height - before.height)).toBeLessThanOrEqual(1);
expect(assetsPanelScroll.overflowY).toBe("auto");
expect(assetsPanelScroll.scrollHeight).toBeGreaterThan(assetsPanelScroll.clientHeight);
});
test("POSTV1-07 keeps text selection stable and dismisses move feedback", async ({ page }) => {
const projectId = uuid(770);
const backend: Backend = { canvas: emptyCanvas(), recent: [], saves: 0, version: 6 };
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await page.getByRole("button", { name: /H003 生活分享家/ }).click();
await expect.poll(() => backend.canvas.elements.length).toBe(1);
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(0);
await page.reload();
const stage = page.getByLabel("编辑画布");
const bounds = await stage.boundingBox();
const element = backend.canvas.elements[0];
if (!bounds || !element) throw new Error("Text selection geometry is unavailable.");
const positionBefore = structuredClone(element.position);
const savesBefore = backend.saves;
const clientX = bounds.x + bounds.width * element.position.x;
const clientY = bounds.y + bounds.height * element.position.y;
await page.mouse.move(clientX, clientY);
await page.mouse.down();
await expect(page.getByLabel("字体覆盖")).toBeVisible();
const selectedBounds = await stage.boundingBox();
const inspectorScroll = await page.getByLabel("对象参数").evaluate((panel) => ({
clientHeight: panel.clientHeight,
overflowY: getComputedStyle(panel).overflowY,
scrollHeight: panel.scrollHeight,
}));
if (!selectedBounds) throw new Error("Canvas geometry is unavailable after selecting text.");
expect(Math.abs(selectedBounds.y - bounds.y)).toBeLessThanOrEqual(1);
expect(inspectorScroll.overflowY).toBe("auto");
expect(inspectorScroll.scrollHeight).toBeGreaterThan(inspectorScroll.clientHeight);
await page.mouse.move(clientX + 1, clientY);
await page.mouse.up();
await expect(page.getByLabel("字体覆盖")).toBeVisible();
await page.waitForTimeout(800);
expect(backend.canvas.elements[0]?.position).toEqual(positionBefore);
expect(backend.saves).toBe(savesBefore);
await page.mouse.move(clientX, clientY);
await page.mouse.down();
await page.mouse.move(clientX + 12, clientY);
await page.mouse.up();
await expect(page.getByText("对象位置已提交", { exact: true })).toBeVisible();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBeGreaterThan(savesBefore);
await expect(page.getByText("对象位置已提交", { exact: true })).toBeHidden({ timeout: 5_000 });
});
+4 -3
View File
@@ -29,8 +29,9 @@ const rawImages: Record<string, string[]> = {
"00000000-0000-4000-8000-000000000812": ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"],
};
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_interactive", "单模板归档", "templates");
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_text");
const assetRoot = join(homedir(), "Desktop", "sticker_web_replication_assets");
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(assetRoot, "sticker_interactive", "单模板归档", "templates");
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(assetRoot, "sticker_text");
const dynamicSourceAssets: Readonly<Record<string, { contentType: string; path: string }>> = {
"15974853bc3294ef68e7e6d58fe74fd7": { contentType: "font/ttf", path: join(dynamicRoot, "DYN002", "fonts", "15974853bc3294ef68e7e6d58fe74fd7", "0202b90o6r57rxed4027b5689e0dxe7e142r0yho9d0lljuqj.ttf") },
"46f8336813e4c48d06a1aef294fdccf6": { contentType: "font/ttf", path: join(dynamicRoot, "DYN016", "fonts", "46f8336813e4c48d06a1aef294fdccf6", "9fbfbb420cea1df916d7c7c7ac90b1c88b61e117-PingFang-SC-Semibold-2.ttf") },
@@ -136,7 +137,7 @@ test("TDD-WP4-COL-001 extracts once from raw pixels and refreshes only for a new
await routeEditor(page, projectId, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "色卡", exact: true }).click();
await expect(page.getByRole("button", { name: /^添加色卡/ })).toHaveCount(4);
await expect(page.getByRole("button", { name: /^添加色卡/ })).toHaveCount(16);
await expect(page.getByRole("button", { name: "色卡说明" })).toHaveAttribute("title", "色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化");
const placements = [
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowUp", times: 14 }],
+14 -11
View File
@@ -78,7 +78,7 @@ async function routeEditor(page: Page, projectId: string, backend: Backend, opti
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: readFileSync(windowsFont), contentType: "font/ttf" }));
}
test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and export", async ({ page }) => {
test("TDD-WP4-EXP-001 cancel keeps automatically saved text outside the export", async ({ page }) => {
const projectId = "00000000-0000-4000-8000-000000000920";
const assetId = "00000000-0000-4000-8000-000000000921";
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 2 };
@@ -87,27 +87,29 @@ test("TDD-WP4-EXP-001 cancel keeps a pending text edit outside history and expor
await page.getByRole("button", { name: "文字模板", exact: true }).click();
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
await page.getByLabel("文字内容").fill("尚未提交的导出文字");
await page.getByLabel("文字内容").fill("自动保存的导出文字");
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
const downloads: string[] = [];
page.on("download", (download) => downloads.push(download.suggestedFilename()));
await page.getByRole("button", { name: "导出", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出成品" });
await expect(dialog).toContainText("导出前需要提交当前修改");
await expect(dialog.getByText("将应用当前修改并导出", { exact: true })).toBeVisible();
await expect(dialog).not.toContainText("导出前需要提交当前修改");
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
await dialog.getByRole("button", { name: "取消" }).click();
await expect(dialog).toHaveCount(0);
await expect(page.getByLabel("文字内容")).toHaveValue("尚未提交的导出文字");
expect(backend.saves).toBe(1);
await expect(page.getByLabel("文字内容")).toHaveValue("自动保存的导出文字");
expect(backend.saves).toBe(2);
expect(backend.canvas.elements[0]?.content).toBe("自动保存的导出文字");
expect(backend.latestBodies).toHaveLength(0);
expect(downloads).toHaveLength(0);
const beforeUndo = { download_count: 0, latest_count: 0, save_count_after_cancel: backend.saves, state_version: backend.version };
await page.getByRole("button", { name: "撤销" }).click();
await expect(page.getByLabel("文字内容")).toHaveCount(0);
await expect(page.getByLabel("文字内容")).toHaveValue("春日计划");
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "network-timeline.json", { ...beforeUndo, compose_calls: 0, export_save_calls: 0 });
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "春日计划", first_undo_removed_initial_element: true, latest_exports_changed: false });
evidence("TDD-WP4-EXP-001-cancel-pending-edit", "db-diff.json", { committed_text_after_cancel: "自动保存的导出文字", first_undo_restored_initial_text: true, latest_exports_changed: false });
});
test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes", async ({ page }) => {
test("TDD-WP4-EXP-001 exports automatically saved text and saves the same bytes", async ({ page }) => {
const projectId = "00000000-0000-4000-8000-000000000930";
const assetId = "00000000-0000-4000-8000-000000000931";
const backend: Backend = { canvas: canvasForRatio("3:4", assetId), latestBodies: [], saves: 0, version: 4 };
@@ -117,6 +119,7 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(1);
await page.getByLabel("文字内容").fill("确认后进入导出");
await expect.poll(() => backend.saves, { timeout: 5_000 }).toBe(2);
await page.getByRole("button", { name: "导出", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出成品" });
if (process.env.DADA_EVIDENCE_DIR_EXPORT) {
@@ -124,14 +127,14 @@ test("TDD-WP4-EXP-001 confirm commits once, downloads, and saves the same bytes"
mkdirSync(dirname(screenshot), { recursive: true });
await page.screenshot({ fullPage: true, path: screenshot });
}
await dialog.getByRole("checkbox", { name: "将应用当前修改并导出" }).check();
await expect(dialog.getByRole("checkbox", { name: "将应用当前修改并导出" })).toHaveCount(0);
const downloadPromise = page.waitForEvent("download");
await dialog.getByRole("button", { name: "导出并下载" }).click();
const download = await downloadPromise;
const downloadPath = await download.path();
if (!downloadPath) throw new Error("Browser download did not expose a local path.");
await expect(dialog.getByRole("status")).toHaveText("已下载并保存为最新成品");
await expect.poll(() => backend.saves).toBe(2);
expect(backend.saves).toBe(2);
expect(backend.canvas.elements[0]?.content).toBe("确认后进入导出");
expect(backend.latestBodies).toHaveLength(1);
const downloaded = readFileSync(downloadPath);
+15 -18
View File
@@ -64,7 +64,10 @@ async function routeEditor(page: Page, backend: Backend) {
});
await page.route("**/api/v1/assets/recent?asset_kind=text_template", (route) => route.fulfill({ body: JSON.stringify({ items: [] }), contentType: "application/json" }));
await page.route("**/api/v1/assets/recent", (route) => route.fulfill({ body: JSON.stringify({ status: "recorded" }), contentType: "application/json" }));
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => route.fulfill({ body: fontBytes, contentType: "font/ttf" }));
await page.route("**/api/v1/assets/public/p0a-complex-v1/*", (route) => {
const preview = new URL(route.request().url()).pathname.includes("TEXT-PREVIEW-");
return route.fulfill(preview ? { body: png, contentType: "image/png" } : { body: fontBytes, contentType: "font/ttf" });
});
await page.route("**/api/v1/assets/public/p0a-static-v1/*", (route) => route.fulfill({ body: png, contentType: "image/png" }));
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`, (route) => route.fulfill({ body: rawSvg(), contentType: "image/svg+xml" }));
}
@@ -91,7 +94,7 @@ test.beforeAll(async () => {
test.afterAll(async () => vite.close());
test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }) => {
test("POSTV1-ASSET-ALL-16 exposes the complete complex asset catalog", async ({ page }) => {
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
await routeEditor(page, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
@@ -99,7 +102,6 @@ test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }
await page.getByRole("button", { name: "文字模板", exact: true }).click();
const textIds = await page.locator(".editor-template-grid button strong").allTextContents();
expect(textIds).toEqual(P0A_TEXT_TEMPLATE_IDS);
expect(textIds).not.toContain("FLOWER009");
await page.getByRole("button", { name: /FLOWER001 春日计划/ }).click();
await expect(page.getByText("对象 1 / 50")).toBeVisible();
await expect(page.getByLabel("字体覆盖")).toBeVisible();
@@ -109,12 +111,12 @@ test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }
await page.getByRole("button", { name: "色卡", exact: true }).click();
const colorIds = await page.locator(".editor-provider-grid button strong").allTextContents();
expect(colorIds).toEqual(P0A_COLOR_CARD_IDS);
expect(colorIds).not.toContain("COLOR003");
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
const dynamicIds = await page.locator(".editor-provider-grid button strong").allTextContents();
expect([...dynamicIds].sort()).toEqual([...P0A_DYNAMIC_STICKER_IDS].sort());
expect(dynamicIds).not.toContain("DYN005");
await page.getByRole("button", { name: /添加动态贴纸 DYN035/ }).click();
await expect.poll(() => backend.canvas.elements.some((element) => element.template_or_asset_id === "DYN035")).toBe(true);
await page.getByRole("button", { name: "普通贴纸", exact: true }).click();
await expect(page.getByText("共 1,407 张", { exact: true })).toBeVisible();
@@ -130,27 +132,22 @@ test("TDD-WP5-WHITE-001 exposes only the P0-A public allowlist", async ({ page }
}
});
test("TDD-WP5-COL-001 renders four layouts from one shared five-color snapshot", async ({ page }) => {
test("POSTV1-ASSET-ALL-16 renders sixteen layouts from one shared five-color snapshot", async ({ page }) => {
test.setTimeout(60_000);
const backend: Backend = { canvas: canvas(), saves: 0, version: 1 };
await routeEditor(page, backend);
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
await page.getByRole("button", { name: "色卡", exact: true }).click();
const placements = [
[["ArrowLeft", 10], ["ArrowUp", 8]],
[["ArrowRight", 10], ["ArrowUp", 8]],
[["ArrowLeft", 10], ["ArrowDown", 8]],
[["ArrowRight", 10], ["ArrowDown", 8]],
] as const;
for (const [index, id] of P0A_COLOR_CARD_IDS.entries()) {
await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click();
await expect.poll(() => backend.canvas.elements.length).toBe(index + 1);
for (const [key, times] of placements[index]!) {
for (let press = 0; press < times; press += 1) await page.getByLabel("编辑画布").press(`Shift+${key}`);
}
await expect(page.getByText(`对象 ${index + 1} / 50`)).toBeVisible();
}
await expect.poll(() => backend.canvas.elements.length, { timeout: 10_000 }).toBe(16);
const palettes = backend.canvas.elements.map((element) => element.colors);
expect(palettes.every((palette) => JSON.stringify(palette) === JSON.stringify(palettes[0]))).toBe(true);
expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(["style_01", "style_02", "style_08", "style_16"]);
expect(backend.canvas.elements.map((element) => element.style_id)).toEqual(
Array.from({ length: 16 }, (_, index) => `style_${String(index + 1).padStart(2, "0")}`),
);
const pixels = await page.getByLabel("编辑画布").evaluate((stage: HTMLCanvasElement) => {
const context = stage.getContext("2d");
if (!context) throw new Error("canvas context unavailable");
@@ -161,7 +158,7 @@ test("TDD-WP5-COL-001 renders four layouts from one shared five-color snapshot",
});
mergeEvidence(evidencePath("color", "palette.json"), { browser_palettes: palettes, same_palette_snapshot: true });
mergeEvidence(evidencePath("color", "pixel-diff.json"), {
...pixels, four_renderers_visible: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed",
...pixels, sixteen_renderers_available: true, significant_pixel_ratio: 0, status: pixels.opaque_pixels > 0 ? "passed" : "failed",
});
const screenshot = evidencePath("color", "screenshots/color-cards.png");
if (screenshot) {
+1 -1
View File
@@ -1,7 +1,7 @@
export const testBrowserSupportRelease = {
appVersion: "1.2.3-test",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7339.1" },
{ brand: "Google Chrome", fullVersion: "150.0.7339.1", supportedMajorVersions: [150, 151] },
{ brand: "Microsoft Edge", fullVersion: "150.0.4078.99" },
],
} as const;
@@ -22,6 +22,7 @@ import {
defaultLocalDataRoot,
initializeLocalDataRoot,
inspectInitializedLocalDataRoot,
readConfiguredAssetRoot,
resolvePathWithinRoot,
validateLocalDataRoot,
validateReadOnlyAssetRoot,
@@ -66,6 +67,17 @@ afterEach(() => {
});
describe("TDD-WP0-DATA-001-root-validation", () => {
it("reads only an absolute configured read-only asset root", () => {
const base = temporaryDirectory();
const configFile = join(base, "instance.json");
const assetRoot = join(base, "runtime-assets");
writeFileSync(configFile, JSON.stringify({ asset_root: assetRoot }));
expect(readConfiguredAssetRoot(configFile)).toBe(resolve(assetRoot));
writeFileSync(configFile, JSON.stringify({ asset_root: "relative-assets" }));
expect(() => readConfiguredAssetRoot(configFile)).toThrow("asset_root_configuration_invalid");
});
it("derives default data and configuration paths from LOCALAPPDATA without a hardcoded user", () => {
const localAppData = join(temporaryDirectory(), "LocalAppData");
expect(defaultLocalDataRoot({ LOCALAPPDATA: localAppData })).toBe(join(localAppData, "Dada", "P0A", "data"));
@@ -0,0 +1,14 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
test("POSTV1-11 release accepts the Codex embedded Chrome major", () => {
const release = JSON.parse(
fs.readFileSync(path.resolve("RELEASE.json"), "utf8"),
);
const chrome = release.browsers.find(({ brand }) => brand === "Google Chrome");
assert.ok(chrome, "release must include Google Chrome");
assert.equal(Number.parseInt(chrome.fullVersion.split(".")[0], 10), 151);
});
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import complexAssetCatalog from "../../apps/web/src/generated/complex-assets.json" with { type: "json" };
import {
createRuntimeAssetManifest,
deployRuntimeAssetPlan,
readRuntimeAssetManifest,
serializeRuntimeAssetManifest,
} from "../../scripts/lib/runtime-assets.mjs";
test("committed P0-A runtime manifest covers the frozen first-version binary assets", () => {
const manifest = readRuntimeAssetManifest("config/runtime-assets-manifest.json");
assert.equal(manifest.counts.dynamic_fonts, 18);
assert.equal(manifest.counts.dynamic_images, 43);
assert.equal(manifest.counts.font_panel_items, 86);
assert.equal(manifest.counts.static_stickers, 1407);
assert.equal(manifest.counts.text_fonts >= 332, true);
assert.equal(manifest.counts.text_images >= 300, true);
assert.equal(manifest.counts.text_previews, 261);
assert.equal(manifest.entries.length, Object.values(manifest.counts).reduce((sum, count) => sum + count, 0));
const publicAssetKeys = new Set(manifest.entries.map((entry) => `${entry.resourceVersion}\u0000${entry.assetId}`));
for (const template of complexAssetCatalog.text_templates) {
for (const fontId of template.render_model.text_layers.map((layer) => layer.font_id)) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${fontId}`), true, `${template.template_id}:${fontId}`);
}
for (const imageId of template.render_model.image_layers.map((layer) => layer.asset_id)) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
}
for (const imageId of template.render_model.particle_layers.map((layer) => layer.asset_id)) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
}
for (const imageId of template.render_model.text_layers.flatMap((layer) => layer.fill_pattern_asset_id ? [layer.fill_pattern_asset_id] : [])) {
assert.equal(publicAssetKeys.has(`p0a-complex-v1\u0000${imageId}`), true, `${template.template_id}:${imageId}`);
}
}
assert.doesNotMatch(serializeRuntimeAssetManifest(manifest), /[A-Za-z]:[\\/]/);
});
test("runtime asset deployment creates verified hardlinks and a path-free manifest", async (t) => {
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-"));
t.after(() => rm(root, { force: true, recursive: true }));
const sourceRoot = join(root, "source");
const assetRoot = join(root, "assets");
const sourcePath = join(sourceRoot, "sticker.png");
const bytes = Buffer.from("runtime asset fixture");
await mkdir(sourceRoot);
await writeFile(sourcePath, bytes);
const entry = {
assetId: "STK001",
mimeType: "image/png",
relativePath: "p0a-static-v1/STK001.png",
resourceVersion: "p0a-static-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update(bytes).digest("hex"),
};
const manifest = createRuntimeAssetManifest({
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_fonts: 0, text_images: 0, text_previews: 0 },
entries: [entry],
});
await deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] });
const targetPath = join(assetRoot, entry.relativePath);
const [sourceStat, targetStat] = await Promise.all([stat(sourcePath), stat(targetPath)]);
assert.equal(sourceStat.ino, targetStat.ino);
assert.deepEqual(await readFile(targetPath), bytes);
const writtenManifest = await readFile(join(assetRoot, "manifest.json"), "utf8");
assert.deepEqual(JSON.parse(writtenManifest), manifest);
assert.doesNotMatch(writtenManifest, /[A-Za-z]:[\\/]/);
});
test("runtime asset deployment refuses a mismatched existing target", async (t) => {
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-conflict-"));
t.after(() => rm(root, { force: true, recursive: true }));
const sourcePath = join(root, "source.png");
const assetRoot = join(root, "assets");
const targetPath = join(assetRoot, "p0a-static-v1", "STK001.png");
await mkdir(join(assetRoot, "p0a-static-v1"), { recursive: true });
await writeFile(sourcePath, "expected");
await writeFile(targetPath, "unexpected");
const entry = {
assetId: "STK001",
mimeType: "image/png",
relativePath: "p0a-static-v1/STK001.png",
resourceVersion: "p0a-static-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update("expected").digest("hex"),
};
const manifest = createRuntimeAssetManifest({
counts: { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 1, text_fonts: 0, text_images: 0, text_previews: 0 },
entries: [entry],
});
assert.throws(
() => deployRuntimeAssetPlan({ assetRoot, manifest, resources: [{ entry, sourcePath }] }),
/asset_target_conflict/,
);
});
test("runtime asset deployment upgrades only an unchanged file covered by its previous manifest", async (t) => {
const root = await mkdtemp(join(tmpdir(), "dada-runtime-assets-upgrade-"));
t.after(() => rm(root, { force: true, recursive: true }));
const assetRoot = join(root, "assets");
const firstSourcePath = join(root, "source-v1.ttf");
const secondSourcePath = join(root, "source-v2.ttf");
const entry = (bytes) => ({
assetId: "TEXT-FONT-FIXTURE-01",
mimeType: "font/ttf",
relativePath: "p0a-complex-v1/TEXT-FONT-FIXTURE-01.ttf",
resourceVersion: "p0a-complex-v1",
rootRef: "p0a_runtime_assets",
sha256: createHash("sha256").update(bytes).digest("hex"),
});
const counts = { dynamic_fonts: 0, dynamic_images: 0, font_panel_items: 0, static_stickers: 0, text_fonts: 1, text_images: 0, text_previews: 0 };
await writeFile(firstSourcePath, "version one");
const firstEntry = entry("version one");
const firstManifest = createRuntimeAssetManifest({ counts, entries: [firstEntry] });
deployRuntimeAssetPlan({ assetRoot, manifest: firstManifest, resources: [{ entry: firstEntry, sourcePath: firstSourcePath }] });
await writeFile(secondSourcePath, "version two");
const secondEntry = entry("version two");
const secondManifest = createRuntimeAssetManifest({ counts, entries: [secondEntry] });
deployRuntimeAssetPlan({ allowManagedUpdate: true, assetRoot, manifest: secondManifest, resources: [{ entry: secondEntry, sourcePath: secondSourcePath }] });
assert.equal(await readFile(join(assetRoot, secondEntry.relativePath), "utf8"), "version two");
assert.deepEqual(readRuntimeAssetManifest(join(assetRoot, "manifest.json")), secondManifest);
});
+104 -10
View File
@@ -1,10 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { spawn } from "node:child_process";
import { createServer } from "node:net";
const packageRoot = resolve(process.env.DADA_POSTV1_PACKAGE_ROOT ?? ".build/portable-release/Dada-P0A-0.0.0-win-x64");
const port = 43121;
@@ -43,31 +44,124 @@ async function stop(child) {
}
}
async function verifyWorkerStartup(configPath) {
const pipeName = `Dada.P0A.PostV1.${process.pid}.${Date.now()}`;
const pipePath = `\\\\.\\pipe\\${pipeName}`;
const server = createServer();
await new Promise((resolveListen, rejectListen) => {
server.once("error", rejectListen);
server.listen(pipePath, resolveListen);
});
const child = spawn(join(packageRoot, "runtime", "node.exe"), [
join(packageRoot, "server", "worker.mjs"),
"--dada-control-pipe", pipeName,
"--dada-credential-stdin",
], {
cwd: packageRoot,
env: {
...process.env,
DADA_INSTANCE_CONFIG_PATH: configPath,
DADA_SQLITE_NATIVE_BINDING: join(packageRoot, "server", "native", "better_sqlite3.node"),
},
stdio: ["pipe", "ignore", "ignore"],
windowsHide: true,
});
child.stdin.end(JSON.stringify({ "Dada/P0A/worker/ai-gateway": "synthetic-runtime-token" }));
try {
await new Promise((resolveReady, rejectReady) => {
let settled = false;
const finish = (error) => {
if (settled) return;
settled = true;
clearTimeout(deadline);
child.off("exit", onExit);
if (error) rejectReady(error); else resolveReady();
};
const deadline = setTimeout(() => finish(new Error("packaged worker ready timeout")), 15_000);
const onExit = (code) => finish(new Error(`packaged worker exited before ready: ${code}`));
child.once("exit", onExit);
server.once("connection", (connection) => {
connection.setEncoding("utf8");
let pending = "";
connection.on("data", (chunk) => {
pending += chunk;
while (pending.includes("\n")) {
const newline = pending.indexOf("\n");
const status = pending.slice(0, newline).trim();
pending = pending.slice(newline + 1);
if (status === "storage_unavailable") finish(new Error("packaged worker reported storage_unavailable"));
if (status === "ready") {
setTimeout(() => {
if (settled) return;
connection.write("shutdown\n");
finish();
}, 500);
}
}
});
});
});
const exitCode = await new Promise((resolveExit) => child.once("exit", resolveExit));
assert.equal(exitCode, 0);
} finally {
if (child.exitCode === null) child.kill();
await new Promise((resolveClose) => server.close(resolveClose));
}
}
test("portable package serves the product and keeps SQLite data across API restart", async () => {
assert.ok(existsSync(join(packageRoot, "Dada.exe")));
assert.ok(existsSync(join(packageRoot, "web", "index.html")));
assert.match(await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8"), /GenerationProcessor/);
const runtimeAssetManifest = JSON.parse(await readFile(join(packageRoot, "asset-metadata", "manifest.json"), "utf8"));
assert.deepEqual(runtimeAssetManifest.counts, {
dynamic_fonts: 18,
dynamic_images: 43,
font_panel_items: 86,
static_stickers: 1407,
text_previews: 261,
});
assert.equal(runtimeAssetManifest.entries.length, 1815);
const packagedWorker = await readFile(join(packageRoot, "server", "worker", "dist", "worker.js"), "utf8");
const packagedOneApiAdapter = await readFile(join(packageRoot, "server", "worker", "dist", "oneapi-generation-adapter.js"), "utf8");
assert.match(packagedWorker, /GenerationProcessor/);
assert.match(packagedWorker, /OneApiGenerationAdapter/);
assert.match(packagedOneApiAdapter, /oneapi\.intelligrow\.cn/);
assert.doesNotMatch(packagedWorker, /portable-mock-worker/);
const root = await mkdtemp(join(tmpdir(), "dada-postv1-"));
const dataRoot = join(root, "data");
await mkdir(join(dataRoot, "db"), { recursive: true });
const configPath = join(root, "instance.json");
await writeFile(configPath, JSON.stringify({ data_root: dataRoot, initialized: true, instance_id: "portable-test", schema_version: 1, secure_config_revision: 1, admin_allowlist_hashes: [], admin_recovery_hashes: [] }));
let api = startApi(configPath, dataRoot);
try {
await waitForHealth(api);
const initialPage = await fetch(`http://127.0.0.1:${port}/`, { headers: { host: `127.0.0.1:${port}` } });
assert.equal(initialPage.status, 200);
assert.match(await initialPage.text(), /当前浏览器无法使用 Dada/);
const support = await fetch(`http://127.0.0.1:${port}/api/v1/support/check`, {
method: "POST",
headers: { host: `127.0.0.1:${port}`, origin: `http://127.0.0.1:${port}`, "content-type": "application/json", "sec-ch-ua": '"Google Chrome";v="150"', "sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"', "sec-ch-ua-platform": '"Windows"' },
body: JSON.stringify({ brands: [{ brand: "Google Chrome", version: "150" }], full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }], platform: "Windows" }),
});
assert.ok([200, 426].includes(support.status));
if (support.status === 200) {
const cookie = support.headers.get("set-cookie")?.split(";", 1)[0];
const page = await fetch(`http://127.0.0.1:${port}/app`, { headers: { host: `127.0.0.1:${port}`, cookie } });
assert.equal(page.status, 200);
assert.match(await page.text(), /<div id="root"><\/div>/);
}
assert.equal(support.status, 200);
const cookie = support.headers.get("set-cookie")?.split(";", 1)[0];
assert.ok(cookie);
const page = await fetch(`http://127.0.0.1:${port}/app`, { headers: { host: `127.0.0.1:${port}`, cookie, "sec-ch-ua": '"Google Chrome";v="150"' } });
assert.equal(page.status, 200);
const pageHtml = await page.text();
assert.match(pageHtml, /<div id="root"><\/div>/);
const scriptPath = pageHtml.match(/<script[^>]+src="([^"]+)"/)?.[1];
const stylesheetPath = pageHtml.match(/<link[^>]+href="([^"]+)"/)?.[1];
assert.ok(scriptPath);
assert.ok(stylesheetPath);
const assetHeaders = { host: `127.0.0.1:${port}`, cookie, "sec-ch-ua": '"Google Chrome";v="150"' };
const script = await fetch(`http://127.0.0.1:${port}${scriptPath}`, { headers: assetHeaders });
const stylesheet = await fetch(`http://127.0.0.1:${port}${stylesheetPath}`, { headers: assetHeaders });
assert.equal(script.status, 200);
assert.match(script.headers.get("content-type") ?? "", /^(?:application|text)\/javascript\b/);
assert.equal(stylesheet.status, 200);
assert.match(stylesheet.headers.get("content-type") ?? "", /^text\/css\b/);
assert.ok(existsSync(join(dataRoot, "db", "dada.sqlite3")));
await verifyWorkerStartup(configPath);
} finally {
await stop(api);
}
+27 -1
View File
@@ -10,7 +10,7 @@ function release() {
return buildFinalReleaseRecord({
appVersion: "0.0.0",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7871.187" },
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151] },
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
],
buildCommit: "a".repeat(40),
@@ -26,6 +26,32 @@ test("TDD-WP7-REL-001 creates a browser-gate compatible first-version record", (
assert.equal(record.fixedPort, 43121);
assert.deepEqual(record.deferredExternalTasks, ["TASK-WP7-03", "TASK-WP7-04"]);
assert.deepEqual(record.browsers.map(({ brand }) => brand).sort(), ["Google Chrome", "Microsoft Edge"]);
assert.deepEqual(record.browsers[0].supportedMajorVersions, [150, 151]);
});
test("TDD-WP7-REL-001 rejects unsafe or duplicate browser major lists", () => {
assert.throws(() => buildFinalReleaseRecord({
appVersion: "0.0.0",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 150] },
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
],
buildCommit: "a".repeat(40),
frozenFromCommit: "b".repeat(40),
recordedAt: "2026-08-04T06:00:00.000Z",
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
}), /Google Chrome\.supportedMajorVersions/);
assert.throws(() => buildFinalReleaseRecord({
appVersion: "0.0.0",
browsers: [
{ brand: "Google Chrome", fullVersion: "150.0.7871.187", supportedMajorVersions: [150, 151, 152, 153, 154, 155, 156, 157] },
{ brand: "Microsoft Edge", fullVersion: "151.0.4129.59" },
],
buildCommit: "a".repeat(40),
frozenFromCommit: "b".repeat(40),
recordedAt: "2026-08-04T06:00:00.000Z",
windows: { arch: "x64", build: "26200.8875", displayVersion: "25H2" },
}), /supportedMajorVersions\.total/);
});
test("TDD-WP7-SEC-001 rejects credential shapes and absolute user paths", () => {
+144
View File
@@ -0,0 +1,144 @@
import { describe, expect, it } from "vitest";
import complexAssetCatalog from "../../apps/web/src/generated/complex-assets.json";
import { P0A_DYNAMIC_STICKERS } from "../../apps/web/src/dynamic-provider.js";
import { DYNAMIC_RENDER_MODELS } from "../../apps/web/src/dynamic-render-models.js";
import { P0A_COLOR_CARDS } from "../../apps/web/src/palette-provider.js";
import { P0A_FONT_OPTIONS, P0A_TEXT_TEMPLATES } from "../../apps/web/src/text-assets.js";
import { ninePatchSlices } from "../../apps/web/src/editor-stage.js";
import {
P0A_COLOR_CARD_IDS,
P0A_DYNAMIC_STICKER_IDS,
P0A_REQUIRED_FONT_PANEL_IDS,
P0A_TEXT_TEMPLATE_IDS,
} from "../../packages/template-registry/src/index.js";
function containsAbsolutePath(value: unknown): boolean {
if (typeof value === "string") return /^[A-Z]:[\\/]/i.test(value);
if (Array.isArray(value)) return value.some(containsAbsolutePath);
return value !== null && typeof value === "object" && Object.values(value).some(containsAbsolutePath);
}
describe("POSTV1-ASSET-ALL-16 complete asset catalog", () => {
it("publishes every normalized complex asset instead of the original alpha subset", () => {
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(332);
expect(P0A_TEXT_TEMPLATE_IDS[0]).toBe("FLOWER001");
expect(P0A_TEXT_TEMPLATE_IDS.at(-1)).toBe("SIMPLE017");
expect(P0A_REQUIRED_FONT_PANEL_IDS).toHaveLength(86);
expect(P0A_REQUIRED_FONT_PANEL_IDS[0]).toBe("FONT001");
expect(P0A_REQUIRED_FONT_PANEL_IDS.at(-1)).toBe("FONT086");
expect(P0A_COLOR_CARD_IDS).toHaveLength(16);
expect(P0A_COLOR_CARD_IDS[0]).toBe("COLOR001");
expect(P0A_COLOR_CARD_IDS.at(-1)).toBe("COLOR016");
expect(P0A_DYNAMIC_STICKER_IDS).toHaveLength(35);
expect(P0A_DYNAMIC_STICKER_IDS[0]).toBe("DYN001");
expect(P0A_DYNAMIC_STICKER_IDS.at(-1)).toBe("DYN035");
});
it("generates a sanitized browser catalog for every text, font, and dynamic definition", () => {
expect(complexAssetCatalog.schema_version).toBe("DadaComplexBrowserCatalog/v2");
expect(complexAssetCatalog.text_resource_revision).toMatch(/^[a-f0-9]{16}$/);
expect(complexAssetCatalog.text_templates).toHaveLength(332);
expect(complexAssetCatalog.font_panel_items).toHaveLength(86);
expect(complexAssetCatalog.dynamic_stickers).toHaveLength(35);
expect(complexAssetCatalog.text_templates.filter((item) => item.preview_asset_id)).toHaveLength(261);
expect(complexAssetCatalog.text_templates.every((item) => item.available)).toBe(true);
expect(complexAssetCatalog.text_templates.every((item) => item.render_model.text_layers.length > 0)).toBe(true);
expect(complexAssetCatalog.text_templates.filter((item) => item.resource_class === "zip_template")).toHaveLength(330);
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.image_layers.length, 0)).toBe(316);
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.particle_layers.length, 0)).toBe(9);
expect(complexAssetCatalog.text_templates.reduce((count, item) => count + item.render_model.text_layers.length, 0)).toBe(490);
expect(complexAssetCatalog.text_templates.filter((item) => item.render_model.text_layers.some((layer) => "fill_pattern_asset_id" in layer))).toHaveLength(14);
expect(containsAbsolutePath(complexAssetCatalog)).toBe(false);
});
it("keeps the captured template font, default text, style, and decoration layers", () => {
const byId = new Map(complexAssetCatalog.text_templates.map((item) => [item.template_id, item]));
const flower001 = byId.get("FLOWER001")!;
expect(flower001.default_font_id).toMatch(/^TEXT-FONT-FLOWER001-/);
expect(flower001.font_match_status).toBe("template_package");
expect(flower001.render_model.image_layers).toHaveLength(1);
expect(flower001.render_model.text_layers[0]).toMatchObject({
fill_color: "#FFC5D4",
stroke_color: "#FF69A6",
stroke_width: 5,
});
const flower008 = byId.get("FLOWER008")!;
expect(flower008.default_text).toBe("Vlog.");
expect(flower008.default_font_id).toMatch(/^TEXT-FONT-FLOWER008-/);
const heading001 = byId.get("H001")!;
expect(heading001.render_model.image_layers).toHaveLength(5);
expect(heading001.default_font_id).toMatch(/^TEXT-FONT-H001-/);
const materialText = byId.get("FLOWER048")!;
expect(materialText.render_model.text_layers[0]).toHaveProperty("fill_pattern_asset_id");
const underlinedText = byId.get("FLOWER121")!;
expect(underlinedText.render_model.image_layers).toHaveLength(1);
const particleText = byId.get("H013")!;
expect(particleText.render_model.particle_layers).toHaveLength(3);
const ninePatchLayers = complexAssetCatalog.text_templates.flatMap((item) => item.render_model.image_layers)
.filter((layer) => "nine_patch" in layer);
expect(ninePatchLayers.length).toBeGreaterThanOrEqual(100);
expect(byId.get("FLOWER023")!.render_model.image_layers[0]).toMatchObject({
nine_patch: { bottom: 16, left: 53, right: 25, source_height: 63, source_width: 159, top: 12 },
});
expect(byId.get("H001")!.render_model.image_layers).toEqual(expect.arrayContaining([
expect.objectContaining({ anchor_x: 0 }),
expect.objectContaining({ anchor_x: 1 }),
]));
expect(byId.get("H079")!.render_model.text_layers.slice(0, 3).every((layer) => Math.abs(layer.rotation + 10) < 0.001)).toBe(true);
expect(byId.get("H005")!.render_model.text_layers[1]).toMatchObject({ anchor_y: 0 });
const heading041 = byId.get("H041")!;
const heading041Text = heading041.render_model.text_layers[0]!;
const heading041Underline = heading041.render_model.image_layers.find((layer) => layer.asset_id === "TEXT-IMAGE-H041-004")!;
expect(heading041Text.stroke_width).toBe(0);
expect(heading041Underline.y).toBeGreaterThan(heading041Text.y);
expect(byId.get("FLOWER024")!.render_model.image_layers[0]).toMatchObject({ alpha: 0.4000000059604645 });
});
it("preserves nine-patch edges while stretching only the decoration center", () => {
const slices = ninePatchSlices(231, 63, {
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
});
expect(slices).toHaveLength(9);
expect(slices[0]).toEqual({ destination: { height: 12, width: 53, x: 0, y: 0 }, source: { height: 12, width: 53, x: 0, y: 0 } });
expect(slices[4]).toEqual({ destination: { height: 35, width: 153, x: 53, y: 12 }, source: { height: 35, width: 81, x: 53, y: 12 } });
expect(slices[8]).toEqual({ destination: { height: 16, width: 25, x: 206, y: 47 }, source: { height: 16, width: 25, x: 134, y: 47 } });
});
it("scales nine-patch source slices to the deployed bitmap without changing layout edges", () => {
const slices = ninePatchSlices(231, 63, {
bottom: 16, left: 53, right: 25, sourceHeight: 63, sourceWidth: 159, top: 12,
}, { height: 199, width: 509 });
expect(slices[0]!.destination).toEqual({ height: 12, width: 53, x: 0, y: 0 });
expect(slices[4]!.destination).toEqual({ height: 35, width: 153, x: 53, y: 12 });
expect(slices[8]!.destination).toEqual({ height: 16, width: 25, x: 206, y: 47 });
expect(slices[4]!.source.x).toBeCloseTo(169.667, 3);
expect(slices[4]!.source.y).toBeCloseTo(37.905, 3);
expect(slices[4]!.source.width).toBeCloseTo(259.302, 3);
expect(slices[4]!.source.height).toBeCloseTo(110.556, 3);
expect(slices[8]!.source.x).toBeCloseTo(428.969, 3);
expect(slices[8]!.source.y).toBeCloseTo(148.46, 2);
expect(slices[8]!.source.width).toBeCloseTo(80.031, 3);
expect(slices[8]!.source.height).toBeCloseTo(50.54, 2);
});
it("exposes every generated definition to the editor", () => {
expect(P0A_TEXT_TEMPLATES).toHaveLength(332);
expect(P0A_TEXT_TEMPLATES.every((item) => item.available && item.fontUrl)).toBe(true);
expect(P0A_FONT_OPTIONS).toHaveLength(86);
expect(P0A_COLOR_CARDS).toHaveLength(16);
expect(P0A_DYNAMIC_STICKERS).toHaveLength(35);
expect(Object.keys(DYNAMIC_RENDER_MODELS)).toHaveLength(35);
});
});
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { normalizeBrowserFontBytes } from "../../scripts/lib/text-template-assets.mjs";
function checksum(bytes: Buffer) {
let value = 0;
for (let index = 0; index < bytes.length; index += 4) {
let word = 0;
for (let byte = 0; byte < 4; byte += 1) word = (word << 8) | (bytes[index + byte] ?? 0);
value = (value + (word >>> 0)) >>> 0;
}
return value;
}
function fontFixture(verticalVersion: number) {
const bytes = Buffer.alloc(80);
bytes.writeUInt32BE(0x00010000, 0);
bytes.writeUInt16BE(3, 4);
bytes.write("head", 12, "ascii");
bytes.writeUInt32BE(60, 20);
bytes.writeUInt32BE(12, 24);
bytes.write("post", 28, "ascii");
bytes.writeUInt32BE(72, 36);
bytes.writeUInt32BE(4, 40);
bytes.write("vhea", 44, "ascii");
bytes.writeUInt32BE(76, 52);
bytes.writeUInt32BE(4, 56);
bytes.writeUInt32BE(0x00030000, 72);
bytes.writeUInt32BE(verticalVersion, 76);
return bytes;
}
function validFontFixture(verticalVersion: number) {
const bytes = fontFixture(verticalVersion);
bytes.writeUInt32BE((0xB1B0AFBA - checksum(bytes)) >>> 0, 68);
return bytes;
}
function tableRecord(bytes: Buffer, wantedTag: string) {
const tableCount = bytes.readUInt16BE(4);
for (let index = 0; index < tableCount; index += 1) {
const recordOffset = 12 + index * 16;
const tag = bytes.toString("ascii", recordOffset, recordOffset + 4);
if (tag === wantedTag) {
return {
length: bytes.readUInt32BE(recordOffset + 12),
offset: bytes.readUInt32BE(recordOffset + 8),
};
}
}
return undefined;
}
function invalidGlyphBoundsFixture() {
const bytes = Buffer.alloc(156);
bytes.writeUInt32BE(0x00010000, 0);
bytes.writeUInt16BE(4, 4);
const records = [
{ length: 10, offset: 76, tag: "glyf" },
{ length: 54, offset: 88, tag: "head" },
{ length: 4, offset: 144, tag: "loca" },
{ length: 6, offset: 148, tag: "maxp" },
];
records.forEach((record, index) => {
const directoryOffset = 12 + index * 16;
bytes.write(record.tag, directoryOffset, 4, "ascii");
bytes.writeUInt32BE(record.offset, directoryOffset + 8);
bytes.writeUInt32BE(record.length, directoryOffset + 12);
});
bytes.writeInt16BE(1, 76);
bytes.writeInt16BE(43, 78);
bytes.writeInt16BE(757, 80);
bytes.writeInt16BE(246, 82);
bytes.writeInt16BE(17, 84);
bytes.writeInt16BE(0, 138);
bytes.writeUInt16BE(0, 144);
bytes.writeUInt16BE(5, 146);
bytes.writeUInt32BE(0x00010000, 148);
bytes.writeUInt16BE(1, 152);
bytes.writeUInt32BE((0xB1B0AFBA - checksum(bytes)) >>> 0, 96);
return bytes;
}
describe("POSTV1-ASSET-ALL-16 browser font normalization", () => {
it("removes the captured malformed vertical metrics and recalculates SFNT checksums", () => {
const original = fontFixture(0x00010001);
const normalized = normalizeBrowserFontBytes(original);
expect(normalized).toBeDefined();
expect(original.readUInt32BE(76)).toBe(0x00010001);
expect(tableRecord(normalized!, "vhea")).toBeUndefined();
expect(tableRecord(normalized!, "vmtx")).toBeUndefined();
expect(tableRecord(normalized!, "head")).toEqual({ length: 12, offset: 44 });
expect(tableRecord(normalized!, "post")).toEqual({ length: 32, offset: 56 });
expect(normalized!.readUInt32BE(56)).toBe(0x00030000);
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
});
it("does not rewrite fonts whose vertical header is already valid", () => {
expect(normalizeBrowserFontBytes(validFontFixture(0x00010000))).toBeUndefined();
expect(normalizeBrowserFontBytes(validFontFixture(0x00011000))).toBeUndefined();
});
it("repairs a stale whole-font checksum without changing a valid vertical header", () => {
const normalized = normalizeBrowserFontBytes(fontFixture(0x00011000));
expect(normalized).toBeDefined();
const verticalHeader = tableRecord(normalized!, "vhea");
expect(verticalHeader).toBeDefined();
expect(normalized!.readUInt32BE(verticalHeader!.offset)).toBe(0x00011000);
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
});
it("repairs reversed glyph bounds without changing the glyph outline payload", () => {
const normalized = normalizeBrowserFontBytes(invalidGlyphBoundsFixture());
expect(normalized).toBeDefined();
const glyphs = tableRecord(normalized!, "glyf");
expect(glyphs).toBeDefined();
expect(normalized!.readInt16BE(glyphs!.offset + 2)).toBe(43);
expect(normalized!.readInt16BE(glyphs!.offset + 4)).toBe(17);
expect(normalized!.readInt16BE(glyphs!.offset + 6)).toBe(246);
expect(normalized!.readInt16BE(glyphs!.offset + 8)).toBe(757);
expect(checksum(normalized!)).toBe(0xB1B0AFBA);
});
});
+32
View File
@@ -1,8 +1,11 @@
import { describe, expect, it } from "vitest";
import {
adjustBackgroundPixels,
backgroundDrawPlan,
CanvasEditHistory,
createEditorCanvasState,
cssFilterForBackground,
defaultBackgroundAdjustments,
deserializeFabricCanvas,
switchBackground,
@@ -24,6 +27,35 @@ const element = {
};
describe("TDD-WP4-BG-001/002 canvas boundary", () => {
it("builds valid filters and distinct contain/crop draw plans", () => {
const adjustments = { ...defaultBackgroundAdjustments(), brightness: 25, contrast: 15, saturation: -20 };
expect(cssFilterForBackground(adjustments)).toBe("brightness(125%) contrast(115%) saturate(80%)");
expect(cssFilterForBackground(defaultBackgroundAdjustments())).toBe("none");
expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "fit" })).toEqual({
destination: { height: 50, width: 100, x: 0, y: 25 },
source: { height: 200, width: 400, x: 0, y: 0 },
});
expect(backgroundDrawPlan({ height: 200, width: 400 }, { height: 100, width: 100 }, { ...adjustments, fit: "crop" })).toEqual({
destination: { height: 100, width: 100, x: 0, y: 0 },
source: { height: 200, width: 200, x: 100, y: 0 },
});
});
it("changes pixels for temperature and sharpness without changing alpha", () => {
const flat = new Uint8ClampedArray([100, 100, 100, 255]);
expect([...adjustBackgroundPixels(flat, 1, 1, { sharpness: 0, temperature: 100 })]).toEqual([135, 108, 65, 255]);
const edged = new Uint8ClampedArray([
100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255,
100, 100, 100, 255, 180, 180, 180, 255, 100, 100, 100, 255,
100, 100, 100, 255, 100, 100, 100, 255, 100, 100, 100, 255,
]);
const sharpened = adjustBackgroundPixels(edged, 3, 3, { sharpness: 100, temperature: 0 });
expect(sharpened[16]).toBe(255);
expect(sharpened[19]).toBe(255);
});
it("switches background without moving overlays, resets processing, and re-extracts palette", () => {
const initial = createEditorCanvasState({ assetId: "00000000-0000-4000-8000-000000000010", ratio: "3:4" });
const withOverlay = { ...initial, background: { ...initial.background, adjustments: { ...initial.background.adjustments, brightness: 42, filter: "mono" } }, elements: [element] };
+17
View File
@@ -73,6 +73,10 @@ describe("TDD-WP4-CAN-001 canvas selection and limit", () => {
identity(1).elementId,
identity(2).elementId,
]);
expect(controller.selectAt({ x: 0.2, y: 0.2 }, { append: true, preserveSelection: true })).toEqual([
identity(1).elementId,
identity(2).elementId,
]);
});
});
@@ -97,6 +101,19 @@ describe("TDD-WP4-STK-001 sticker transformations", () => {
});
});
it("uses one bounded delta for distant selected elements", () => {
const controller = new CanvasElementController(state([
sticker(1, { position: { x: 0.1, y: 0.2 } }),
sticker(2, { position: { x: 0.9, y: 0.8 } }),
]));
controller.selectIds([identity(1).elementId, identity(2).elementId]);
controller.moveSelected({ x: 0.2, y: 0.3 }, { snap: false });
expect(controller.value.elements.map(({ position }) => position)).toEqual([
{ x: 0.2, y: 0.4 },
{ x: 1, y: 1 },
]);
});
it("duplicates, reorders and deletes selected stickers while preserving stable resource identity", () => {
const controller = new CanvasElementController(state([sticker(1), sticker(2, { position: { x: 0.7, y: 0.7 } })]));
controller.selectById(identity(1).elementId);
+18 -19
View File
@@ -3,9 +3,11 @@ import type { CanvasState } from "@dada/shared-contracts";
import {
P0A_TEXT_TEMPLATES,
P0A_FONT_OPTIONS,
TextEditSession,
createTextTemplateElement,
effectiveFontSize,
fontOption,
searchTextTemplates,
} from "../../apps/web/src/text-assets.js";
import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
@@ -13,19 +15,15 @@ import { elementHalfExtents } from "../../apps/web/src/editor-elements.js";
const identity = { createdAt: "2026-08-03T03:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000701" };
describe("TASK-WP4-03 text templates and properties", () => {
it("keeps the frozen 32-template allowlist in catalog order and searches display names only", () => {
expect(P0A_TEXT_TEMPLATES).toHaveLength(32);
expect(P0A_TEXT_TEMPLATES.map((template) => template.templateId)).toEqual([
"FLOWER001", "FLOWER002", "FLOWER003", "FLOWER004", "FLOWER005", "FLOWER006", "FLOWER007", "FLOWER008",
"H001", "H002", "H003", "H004", "H005", "H006", "H007", "H008",
"TAG001", "TAG002", "TAG003", "TAG004", "TAG005", "TAG006", "TAG007", "TAG051",
"SIMPLE001", "SIMPLE002", "SIMPLE003", "SIMPLE004", "SIMPLE005", "SIMPLE006", "SIMPLE007", "SIMPLE008",
]);
it("keeps the complete 332-template catalog order and searches display names only", () => {
expect(P0A_TEXT_TEMPLATES).toHaveLength(332);
expect(P0A_TEXT_TEMPLATES[0]?.templateId).toBe("FLOWER001");
expect(P0A_TEXT_TEMPLATES.at(-1)?.templateId).toBe("SIMPLE017");
expect(P0A_TEXT_TEMPLATES.reduce<Record<string, number>>((counts, template) => {
counts[template.category] = (counts[template.category] ?? 0) + 1;
return counts;
}, {})).toEqual({ flower: 8, simple: 8, tag: 8, title: 8 });
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]);
}, {})).toEqual({ flower: 145, simple: 17, tag: 51, title: 119 });
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { query: "生活" }).map((item) => item.templateId)).toEqual(expect.arrayContaining(["FLOWER004", "FLOWER005", "H001", "H003", "H006"]));
expect(searchTextTemplates(P0A_TEXT_TEMPLATES, { category: "tag", query: "TAG006" })).toEqual([]);
});
@@ -54,7 +52,7 @@ describe("TASK-WP4-03 text templates and properties", () => {
template_or_asset_id: "H003",
});
expect(switched.style_parameters).toMatchObject({
fill_color: "#111111", letter_spacing: 1, line_height: 1.2, stroke_enabled: false,
fill_color: "#FFFFFF", letter_spacing: 0, line_height: 1, stroke_enabled: false,
});
});
@@ -76,12 +74,12 @@ describe("TASK-WP4-03 text templates and properties", () => {
it("synchronizes numeric font size with the canvas scale", () => {
const element = createTextTemplateElement(P0A_TEXT_TEMPLATES[0]!, identity, 0, { scale: { x: 1.5, y: 1.5 } });
expect(effectiveFontSize(element)).toBe(72);
expect(effectiveFontSize(element)).toBe(P0A_TEXT_TEMPLATES[0]!.defaultFontSize * 1.5);
const edit = new TextEditSession(element, P0A_TEXT_TEMPLATES);
edit.setEffectiveFontSize(96);
const resized = edit.complete();
expect(resized.font_size).toBe(48);
expect(resized.scale).toEqual({ x: 2, y: 2 });
expect(resized.font_size).toBe(P0A_TEXT_TEMPLATES[0]!.defaultFontSize);
expect(resized.scale).toEqual({ x: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize, y: 96 / P0A_TEXT_TEMPLATES[0]!.defaultFontSize });
expect(effectiveFontSize(resized)).toBe(96);
});
@@ -100,11 +98,12 @@ describe("TASK-WP4-03 text templates and properties", () => {
expect(bounds.y).toBeGreaterThan(0.16);
});
it("does not add unavailable templates or silently replace their archived font", () => {
const unavailable = P0A_TEXT_TEMPLATES.find((template) => !template.available)!;
expect(unavailable).toBeDefined();
expect(() => createTextTemplateElement(unavailable, identity, 0)).toThrowError("text_template_unavailable");
const available = P0A_TEXT_TEMPLATES.find((template) => template.available)!;
it("adds every template with its captured default font", () => {
expect(P0A_TEXT_TEMPLATES.every((template) => template.available && template.fontUrl)).toBe(true);
expect(P0A_TEXT_TEMPLATES.every((template) => fontOption(template.defaultFontId)?.url === template.fontUrl)).toBe(true);
expect(P0A_TEXT_TEMPLATES.filter((template) => template.defaultFontId.startsWith("TEXT-FONT-"))).toHaveLength(332);
expect(P0A_FONT_OPTIONS).toHaveLength(86);
const available = P0A_TEXT_TEMPLATES[0]!;
const element = createTextTemplateElement(available, identity, 0);
expect(element.font_override).toBeUndefined();
expect((element.style_parameters as Record<string, unknown>).default_font_id).toBe(available.defaultFontId);
+16 -16
View File
@@ -45,8 +45,10 @@ describe("TASK-WP4-04 deterministic color cards", () => {
});
});
it("uses the four-item P0-A allowlist and produces a stable five-color MMCQ palette", () => {
expect(P0A_COLOR_CARDS.map((item) => item.cardId)).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]);
it("uses all sixteen color-card layouts and produces a stable five-color MMCQ palette", () => {
expect(P0A_COLOR_CARDS).toHaveLength(16);
expect(P0A_COLOR_CARDS[0]?.cardId).toBe("COLOR001");
expect(P0A_COLOR_CARDS.at(-1)?.cardId).toBe("COLOR016");
const pixels = colorPixels([
{ count: 50, rgb: [244, 32, 32] }, { count: 40, rgb: [32, 210, 96] }, { count: 30, rgb: [24, 96, 220] },
{ count: 20, rgb: [248, 210, 48] }, { count: 10, rgb: [120, 64, 180] },
@@ -78,10 +80,10 @@ describe("TASK-WP4-04 dynamic providers", () => {
profile: { creatorName: "Dada Creator", socialId: "@@dada" },
};
it("exposes exactly ten P0-A providers and snapshots time and identity", () => {
expect(P0A_DYNAMIC_STICKERS.map((item) => item.templateId)).toEqual([
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007", "DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
]);
it("exposes all thirty-five providers and snapshots time and identity", () => {
expect(P0A_DYNAMIC_STICKERS).toHaveLength(35);
expect(P0A_DYNAMIC_STICKERS[0]?.templateId).toBe("DYN001");
expect(P0A_DYNAMIC_STICKERS.at(-1)?.templateId).toBe("DYN035");
const time = createDynamicStickerElement("DYN012", context, identity, 0);
expect(time.formatted_value).toBe("09:07");
expect(time.dynamic_fields).toMatchObject({ font_substitution: "FONT081", hour: "09", minute: "07" });
@@ -92,11 +94,10 @@ describe("TASK-WP4-04 dynamic providers", () => {
});
it("uses the archived template categories and only the fields visibly consumed by each source", () => {
expect(P0A_DYNAMIC_STICKERS.map(({ category, templateId }) => [templateId, category])).toEqual([
["DYN001", "location"], ["DYN002", "location"], ["DYN003", "location"], ["DYN004", "location"],
["DYN007", "other"], ["DYN008", "time"], ["DYN011", "time"], ["DYN012", "time"],
["DYN015", "identity"], ["DYN016", "identity"],
]);
expect(P0A_DYNAMIC_STICKERS.reduce<Record<string, number>>((counts, item) => {
counts[item.category] = (counts[item.category] ?? 0) + 1;
return counts;
}, {})).toEqual({ identity: 21, location: 6, other: 1, time: 7 });
const other = createDynamicStickerElement("DYN007", context, identity, 0);
expect(other.dynamic_fields).toEqual({ nickname: "@dada" });
expect(other.formatted_value).toBe("@dada");
@@ -105,11 +106,10 @@ describe("TASK-WP4-04 dynamic providers", () => {
});
it("maps every enabled dynamic sticker to its archived source candidate and original resource version", () => {
expect(Object.entries(DYNAMIC_RENDER_MODELS).map(([id, model]) => [id, model.sourceCandidateId])).toEqual([
["DYN001", "l_POI01"], ["DYN002", "l_POI02"], ["DYN003", "l_POI03"], ["DYN004", "l_POI04"],
["DYN007", "diaoyu"], ["DYN008", "l_shijian2"], ["DYN011", "l_shijian6"], ["DYN012", "l_shijian7"],
["DYN015", "0721userna"], ["DYN016", "l_username00"],
]);
expect(Object.keys(DYNAMIC_RENDER_MODELS)).toHaveLength(35);
expect(DYNAMIC_RENDER_MODELS.DYN001?.sourceCandidateId).toBe("l_POI01");
expect(DYNAMIC_RENDER_MODELS.DYN012?.sourceCandidateId).toBe("l_shijian7");
expect(DYNAMIC_RENDER_MODELS.DYN035?.sourceCandidateId).toBe("l_username23");
const element = createDynamicStickerElement("DYN001", { ...context, location: { formattedValue: "温州" } }, identity, 0);
expect(element.resource_version).toBe(DYNAMIC_RESOURCE_VERSION);
expect(DYNAMIC_RENDER_MODELS.DYN001.imageLayers).toEqual([
+20 -21
View File
@@ -7,6 +7,7 @@ import type { StaticStickerCatalog } from "../../packages/static-sticker-catalog
import {
P0A_COLOR_CARD_IDS,
P0A_DYNAMIC_STICKER_IDS,
P0A_REQUIRED_FONT_PANEL_IDS,
P0A_TEXT_TEMPLATE_IDS,
createP0aPublicManifest,
} from "../../packages/template-registry/src/index.js";
@@ -116,29 +117,27 @@ function evidence(name: string, value: unknown) {
writeFileSync(resolve(root, name), `${JSON.stringify(value, null, 2)}\n`);
}
describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => {
it("registers the complete archive while publishing only the exact P0-A allowlist", () => {
describe("TDD-WP5-WHITE-001 complete public catalog", () => {
it("registers and publishes the complete normalized archive", () => {
const source = fullComplexManifest();
const before = structuredClone(source);
const manifest = createP0aPublicManifest({ complexManifest: source, staticCatalog: staticCatalog() });
expect(source).toEqual(before);
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(32);
expect(P0A_COLOR_CARD_IDS).toEqual(["COLOR001", "COLOR002", "COLOR008", "COLOR016"]);
expect(P0A_DYNAMIC_STICKER_IDS).toEqual([
"DYN001", "DYN002", "DYN003", "DYN004", "DYN007",
"DYN008", "DYN011", "DYN012", "DYN015", "DYN016",
]);
expect(P0A_TEXT_TEMPLATE_IDS).toHaveLength(332);
expect(P0A_REQUIRED_FONT_PANEL_IDS).toHaveLength(86);
expect(P0A_COLOR_CARD_IDS).toHaveLength(16);
expect(P0A_DYNAMIC_STICKER_IDS).toHaveLength(35);
expect(manifest.counts).toEqual({
color_cards: 4,
dynamic_stickers: 10,
font_panel_items: 11,
color_cards: 16,
dynamic_stickers: 35,
font_panel_items: 86,
static_parts: 25,
static_stickers: 1_407,
text_templates: 32,
text_templates: 332,
});
expect(manifest.assets.text_templates.map((item) => item.canonical_id)).toEqual(P0A_TEXT_TEMPLATE_IDS);
expect(manifest.assets.font_panel_items.map((item) => item.canonical_id)).toEqual([...referencedFontIds, "FONT081"]);
expect(manifest.assets.font_panel_items.map((item) => item.canonical_id)).toEqual(P0A_REQUIRED_FONT_PANEL_IDS);
expect(manifest.assets.color_cards.map((item) => item.canonical_id)).toEqual(P0A_COLOR_CARD_IDS);
expect(manifest.assets.dynamic_stickers.map((item) => item.canonical_id)).toEqual(P0A_DYNAMIC_STICKER_IDS);
expect(manifest.assets.static_stickers).toHaveLength(1_407);
@@ -154,10 +153,10 @@ describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => {
&& item.release_tier === "alpha_whitelist"
&& item.validation_status === "passed")).toBe(true);
const serialized = JSON.stringify(manifest);
expect(serialized).not.toContain("FLOWER009");
expect(serialized).not.toContain("COLOR003");
expect(serialized).not.toContain("DYN005");
evidence("unit-allowlist.json", { counts: manifest.counts, hidden: ["FLOWER009", "COLOR003", "DYN005"], status: "passed" });
expect(serialized).toContain("FLOWER145");
expect(serialized).toContain("COLOR016");
expect(serialized).toContain("DYN035");
evidence("unit-full-catalog.json", { counts: manifest.counts, status: "passed" });
});
it("rejects incomplete registration and any early full_p0 enablement", () => {
@@ -172,12 +171,12 @@ describe("TDD-WP5-WHITE-001 P0-A public allowlist", () => {
});
describe("TDD-WP5-COL-001 shared five-color renderer input", () => {
it("binds the four enabled layouts to one immutable palette snapshot", () => {
it("binds all sixteen layouts to one immutable palette snapshot", () => {
const palette = ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"] as const;
const plans = createP0aColorCardRenderPlans(palette);
expect(P0A_COLOR_CARD_DEFINITIONS.map((item) => [item.cardId, item.styleId])).toEqual([
["COLOR001", "style_01"], ["COLOR002", "style_02"], ["COLOR008", "style_08"], ["COLOR016", "style_16"],
]);
expect(P0A_COLOR_CARD_DEFINITIONS.map((item) => [item.cardId, item.styleId])).toEqual(
P0A_COLOR_CARD_IDS.map((cardId, index) => [cardId, `style_${String(index + 1).padStart(2, "0")}`]),
);
expect(plans.map((plan) => plan.cardId)).toEqual(P0A_COLOR_CARD_IDS);
expect(plans.every((plan) => plan.palette === plans[0]!.palette)).toBe(true);
expect(Object.isFrozen(plans[0]!.palette)).toBe(true);
@@ -0,0 +1,28 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { GenerationPollingLoop } from "../../apps/worker/src/generation-polling-loop.js";
describe("POSTV1-05 generation polling loop", () => {
afterEach(() => {
vi.useRealTimers();
});
it("does not start another processor call while the current call is unresolved", async () => {
vi.useFakeTimers();
let finishCurrentCall: (() => void) | undefined;
const processNext = vi.fn(() => new Promise<void>((resolve) => {
finishCurrentCall = resolve;
}));
const loop = new GenerationPollingLoop({ processNext }, 250);
await vi.advanceTimersByTimeAsync(1_000);
expect(processNext).toHaveBeenCalledTimes(1);
finishCurrentCall?.();
await Promise.resolve();
await vi.advanceTimersByTimeAsync(250);
expect(processNext).toHaveBeenCalledTimes(2);
loop.close();
});
});
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from "vitest";
import type { GenerationAdapterRequest } from "../../apps/worker/src/ai-adapter-contract.js";
import { runAiRuntimeProbe } from "../../apps/worker/src/ai-runtime-probe.js";
import { OneApiGenerationAdapter } from "../../apps/worker/src/oneapi-generation-adapter.js";
function request(overrides: Partial<GenerationAdapterRequest> = {}): GenerationAdapterRequest {
return {
configSnapshot: {},
generationId: "00000000-0000-4000-8000-000000000001",
modelId: "gemini-3.1-flash-image-preview",
prompt: "一张用于本机验收的抽象色彩图",
ratio: "1:1",
referenceAssetIds: [],
...overrides,
};
}
describe("POSTV1-02 OneAPI runtime adapter", () => {
it("uses the fixed Gemini gateway and normalizes one real-shaped response", async () => {
const source = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
const gateway = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
const headers = new Headers(init?.headers);
expect(headers.get("authorization")).toBe("Bearer synthetic-runtime-token");
expect(init?.redirect).toBe("error");
const payload = JSON.parse(String(init?.body)) as { messages: Array<{ content: unknown; role: string }> };
expect(payload.messages).toEqual([
{
content: "Generate exactly one image from the user's description. Return the generated image and do not answer with text only.",
role: "system",
},
{ content: "一张用于本机验收的抽象色彩图", role: "user" },
]);
return new Response(JSON.stringify({
choices: [{ message: { content: `![result](data:image/png;base64,${source.toString("base64")})` } }],
}), { headers: { "content-type": "application/json" }, status: 200 });
});
const credential = Buffer.from("synthetic-runtime-token");
const adapter = new OneApiGenerationAdapter({ credential, fetch: gateway as typeof fetch });
const result = await adapter.start(request());
expect(gateway).toHaveBeenCalledOnce();
expect(gateway.mock.calls[0]?.[0]).toBe("https://oneapi.intelligrow.cn/v1/chat/completions");
expect(result.status === "failed" ? result.sourceCategory : "completed").toBe("completed");
if (result.status === "completed") {
expect(result.outputs).toHaveLength(1);
expect(result.outputs[0]).toMatchObject({ mimeType: "image/png", pixelHeight: 1080, pixelWidth: 1080 });
expect(result.outputs[0]?.bytes.length).toBeGreaterThan(0);
}
expect(JSON.stringify(result)).not.toContain("synthetic-runtime-token");
adapter.dispose();
credential.fill(0);
});
it("fails closed instead of returning a mock image", async () => {
const adapter = new OneApiGenerationAdapter({
credential: Buffer.from("synthetic-runtime-token"),
fetch: vi.fn(async () => new Response(null, { status: 503 })) as typeof fetch,
});
await expect(adapter.start(request())).resolves.toEqual({
category: "upstream_failed",
sourceCategory: "upstream_http_503",
status: "failed",
});
adapter.dispose();
await expect(adapter.start(request())).resolves.toEqual({
category: "upstream_failed",
sourceCategory: "adapter_disposed",
status: "failed",
});
});
it("returns only a bounded probe summary and wipes generated bytes", async () => {
const bytes = Buffer.from("probe-output");
const result = await runAiRuntimeProbe({
async start() {
return { outputs: [{ bytes, mimeType: "image/png", pixelHeight: 1080, pixelWidth: 1080 }], status: "completed" };
},
});
expect(result).toEqual({
code: "ai_probe_passed",
mime_type: "image/png",
pixel_height: 1080,
pixel_width: 1080,
real_calls: 1,
success: true,
});
expect(bytes.every((value) => value === 0)).toBe(true);
});
});