feat(P0-A): 整合第一版并冻结最终发布 #1
@@ -0,0 +1,15 @@
|
||||
export interface AmapAdapter {
|
||||
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>;
|
||||
}
|
||||
|
||||
export class MockAmapAdapter implements AmapAdapter {
|
||||
readonly calls: Array<{ latitude: number; longitude: number }> = [];
|
||||
|
||||
async reverseGeocode(coordinates: { latitude: number; longitude: number }) {
|
||||
this.calls.push({ ...coordinates });
|
||||
return {
|
||||
formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`,
|
||||
serviceMode: "mock" as const,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ import {
|
||||
RecentAssetQuerySchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
ReverseGeocodeRequestSchema,
|
||||
ReverseGeocodeResponseSchema,
|
||||
RegistrationCompleteHeadersSchema,
|
||||
RegistrationCompleteRequestSchema,
|
||||
RegistrationCompleteResponseSchema,
|
||||
@@ -128,6 +130,7 @@ import {
|
||||
type ProjectStateSaveHeaders,
|
||||
type RecentAssetQuery,
|
||||
type RecentAssetRecordRequest,
|
||||
type ReverseGeocodeRequest,
|
||||
type RegistrationCompleteRequest,
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
@@ -171,6 +174,7 @@ import {
|
||||
} from "./registration-errors.js";
|
||||
import type { RegistrationService } from "./registration.js";
|
||||
import type { RecentAssetService } from "./recent-assets.js";
|
||||
import type { AmapAdapter } from "./amap-adapter.js";
|
||||
import { ModelConfigurationError } from "./model-configuration.js";
|
||||
import type { ModelConfigurationService } from "./model-configuration.js";
|
||||
|
||||
@@ -187,6 +191,7 @@ const defaultBootstrap: BootstrapResponse = {
|
||||
};
|
||||
|
||||
export interface CreateAppOptions {
|
||||
amap?: AmapAdapter;
|
||||
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
|
||||
browserGate?: boolean;
|
||||
browserSupportRelease?: BrowserSupportRelease;
|
||||
@@ -742,6 +747,8 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
RecentAssetListResponseSchema,
|
||||
RecentAssetRecordRequestSchema,
|
||||
RecentAssetRecordResponseSchema,
|
||||
ReverseGeocodeRequestSchema,
|
||||
ReverseGeocodeResponseSchema,
|
||||
FailedEmptyTrashRequestSchema,
|
||||
FailedEmptyTrashResponseSchema,
|
||||
ModelIdSchema,
|
||||
@@ -896,6 +903,40 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/location/reverse-geocode",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
body: Type.Ref(ReverseGeocodeRequestSchema),
|
||||
headers: Type.Ref(CsrfHeadersSchema),
|
||||
operationId: "reverseGeocodeLocation",
|
||||
response: {
|
||||
200: Type.Ref(ReverseGeocodeResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Null(),
|
||||
},
|
||||
tags: ["Location"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.amap) return reply.code(503).send(null);
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const csrfToken = headerValue(request.headers["x-csrf-token"]);
|
||||
if (!token || !csrfToken) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
|
||||
const result = await options.amap.reverseGeocode(request.body as ReverseGeocodeRequest);
|
||||
return { formatted_value: result.formattedValue, service_mode: result.serviceMode, status: "resolved" as const };
|
||||
} catch (error) {
|
||||
if (error instanceof RegistrationError) return registrationFailure(reply, request.id, error);
|
||||
return reply.code(503).send(null);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/admin-auth/login/send",
|
||||
{
|
||||
|
||||
@@ -17,6 +17,7 @@ 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 { MockAmapAdapter } from "./amap-adapter.js";
|
||||
|
||||
const credentialChannelEnabled = process.argv.includes("--dada-credential-stdin");
|
||||
let registration: RegistrationService | undefined;
|
||||
@@ -70,6 +71,7 @@ if (credentialChannelEnabled) {
|
||||
|
||||
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
|
||||
const app = await createApp({
|
||||
amap: new MockAmapAdapter(),
|
||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||
...(credits ? { credits } : {}),
|
||||
...(latestExports ? { latestExports } : {}),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { 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(() => {
|
||||
const canvas = ref.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
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 scale = Math.min(1, 146 / (half.width * 2), 62 / (half.height * 2));
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(scale, scale);
|
||||
drawColorCard(context, createColorCardElement(definition, previewPalette, {
|
||||
createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000001",
|
||||
}, 0));
|
||||
}, [definition]);
|
||||
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||
}
|
||||
|
||||
export function ColorCardPanel(props: { canAdd: boolean; hasBackground: boolean; onAdd: (definition: ColorCardDefinition) => void }) {
|
||||
return <section className="editor-provider-panel">
|
||||
<header><h2>色卡</h2><button aria-label="色卡说明" className="editor-info-button" title="色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化" type="button">i</button></header>
|
||||
<p className="editor-provider-note">基于当前原始底图</p>
|
||||
<div className="editor-provider-grid">
|
||||
{P0A_COLOR_CARDS.map((definition) => <button
|
||||
aria-label={`添加色卡 ${definition.cardId} ${definition.displayName}`}
|
||||
disabled={!props.canAdd || !props.hasBackground}
|
||||
key={definition.cardId}
|
||||
onClick={() => props.onAdd(definition)}
|
||||
type="button"
|
||||
><ColorCardPreview definition={definition} /><strong>{definition.cardId}</strong><span>{definition.displayName}</span></button>)}
|
||||
</div>
|
||||
{!props.hasBackground ? <p className="editor-limit" role="status">需要先有原始底图才能提取色卡。</p> : null}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export function DynamicInspector(props: { element: CanvasElement; onCommit: (value: string) => void }) {
|
||||
const [value, setValue] = useState(props.element.formatted_value ?? "");
|
||||
useEffect(() => setValue(props.element.formatted_value ?? ""), [props.element.element_id, props.element.formatted_value]);
|
||||
return <section className="editor-dynamic-inspector">
|
||||
{props.element.template_or_asset_id === "DYN012" ? <p className="editor-font-substitution" role="note">原 DIN_MediumAlternate.otf 缺失,当前明确使用 FONT081 · Lexend Deca 替代。</p> : null}
|
||||
<label>显示文字<textarea aria-label="动态贴纸显示文字" onChange={(event) => setValue(event.target.value)} value={value} /></label>
|
||||
<button className="editor-wide-command" disabled={!value.trim() || value === props.element.formatted_value} onClick={() => props.onCommit(value)} type="button">应用显示文字</button>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type DynamicCategory = "identity" | "location" | "other" | "time";
|
||||
export type DynamicTemplateId = "DYN001" | "DYN002" | "DYN003" | "DYN004" | "DYN007" | "DYN008" | "DYN011" | "DYN012" | "DYN015" | "DYN016";
|
||||
|
||||
export interface DynamicStickerDefinition {
|
||||
category: DynamicCategory;
|
||||
displayName: string;
|
||||
requiresLocationConsent: boolean;
|
||||
templateId: DynamicTemplateId;
|
||||
}
|
||||
|
||||
export interface DynamicProviderContext {
|
||||
location?: { formattedValue: string; latitude?: number; longitude?: number };
|
||||
now: Date;
|
||||
profile: { creatorName: string; socialId: string };
|
||||
}
|
||||
|
||||
// Declarative conversion of l_shijian7.prefab. The browser never executes the Android Prefab or Lua.
|
||||
export const DYN012_RENDER_LAYOUT = {
|
||||
background: "transparent",
|
||||
divider: { color: "#FFFFFF", height: 42, width: 7, x: 0, y: 0 },
|
||||
hour: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: -60, y: 0 },
|
||||
meridiem: { color: "#FFFFFF", fontId: "FONT081", fontSize: 20, x: 94, y: -65 },
|
||||
minute: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: 60, y: 0 },
|
||||
} as const;
|
||||
|
||||
export function dyn012DisplayParts(element: CanvasElement) {
|
||||
if (element.type !== "dynamic_sticker" || element.template_or_asset_id !== "DYN012") throw new Error("dyn012_element_required");
|
||||
const formatted = element.formatted_value?.match(/^(\d{1,2}):(\d{2})$/);
|
||||
const hour = formatted?.[1]?.padStart(2, "0") ?? String(element.dynamic_fields?.hour ?? "00").padStart(2, "0");
|
||||
const minute = formatted?.[2] ?? String(element.dynamic_fields?.minute ?? "00").padStart(2, "0");
|
||||
const hourNumber = Number(hour);
|
||||
const minuteNumber = Number(minute);
|
||||
return {
|
||||
hour,
|
||||
meridiem: hourNumber > 12 || (hourNumber === 12 && minuteNumber > 0) ? "PM" : "AM",
|
||||
minute,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export const P0A_DYNAMIC_STICKERS: 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;
|
||||
|
||||
function twoDigits(value: number) {
|
||||
return String(value).padStart(2, "0");
|
||||
}
|
||||
|
||||
export function normalizeSocialId(value: string) {
|
||||
return `@${value.trim().replace(/^@+/, "")}`;
|
||||
}
|
||||
|
||||
function snapshotFor(templateId: DynamicTemplateId, context: DynamicProviderContext) {
|
||||
const year = String(context.now.getFullYear());
|
||||
const month = twoDigits(context.now.getMonth() + 1);
|
||||
const day = twoDigits(context.now.getDate());
|
||||
const hour = twoDigits(context.now.getHours());
|
||||
const minute = twoDigits(context.now.getMinutes());
|
||||
if (templateId === "DYN001") return { fields: { title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "输入地点" };
|
||||
if (templateId === "DYN002") return { fields: { city_en: context.location?.formattedValue ?? "LOCATION", title: context.location?.formattedValue ?? "输入地点" }, value: (context.location?.formattedValue ?? "LOCATION").toUpperCase() };
|
||||
if (templateId === "DYN003") return { fields: { city: context.location?.formattedValue ?? "城市", title: context.location?.formattedValue ?? "输入地点" }, value: context.location?.formattedValue ?? "城市 · 输入地点" };
|
||||
if (templateId === "DYN004") {
|
||||
if (context.location?.latitude === undefined || context.location.longitude === undefined) throw new Error("dynamic_location_consent_required");
|
||||
return {
|
||||
fields: { city: context.location.formattedValue, latitude: context.location.latitude, longitude: context.location.longitude },
|
||||
value: `${context.location.formattedValue}\n${context.location.latitude.toFixed(4)}, ${context.location.longitude.toFixed(4)}`,
|
||||
};
|
||||
}
|
||||
if (templateId === "DYN007") {
|
||||
const nickname = normalizeSocialId(context.profile.socialId);
|
||||
return { fields: { nickname }, value: nickname };
|
||||
}
|
||||
if (templateId === "DYN008") return { fields: { hour, minute, month }, value: `${month}月 · ${hour}:${minute}` };
|
||||
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) };
|
||||
}
|
||||
|
||||
export function createDynamicStickerElement(
|
||||
templateId: DynamicTemplateId,
|
||||
context: DynamicProviderContext,
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
): CanvasElement {
|
||||
if (!P0A_DYNAMIC_STICKERS.some((item) => item.templateId === templateId)) throw new Error("dynamic_template_unavailable");
|
||||
const snapshot = snapshotFor(templateId, context);
|
||||
return {
|
||||
created_at: identity.createdAt,
|
||||
dynamic_fields: snapshot.fields,
|
||||
element_id: identity.elementId,
|
||||
...(templateId === "DYN004" && context.location?.latitude !== undefined && context.location.longitude !== undefined
|
||||
? { coordinates: { latitude: context.location.latitude, longitude: context.location.longitude } }
|
||||
: {}),
|
||||
...(templateId === "DYN012" ? { font_override: "FONT081" } : {}),
|
||||
formatted_value: snapshot.value,
|
||||
opacity: 1,
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resource_version: DYNAMIC_RESOURCE_VERSION,
|
||||
rotation: 0,
|
||||
scale: { x: 1, y: 1 },
|
||||
style_parameters: {
|
||||
provider: "typescript-declarative-v1",
|
||||
...(templateId === "DYN012" ? { font_substitution_disclosure: "DIN_MediumAlternate.otf missing; FONT081 Lexend Deca substitute" } : {}),
|
||||
},
|
||||
template_or_asset_id: templateId,
|
||||
type: "dynamic_sticker",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function overrideDynamicStickerValue(element: CanvasElement, formattedValue: string): CanvasElement {
|
||||
if (element.type !== "dynamic_sticker") throw new Error("dynamic_element_required");
|
||||
const value = formattedValue.trim();
|
||||
if (!value) throw new Error("dynamic_value_required");
|
||||
return {
|
||||
...structuredClone(element),
|
||||
dynamic_fields: { ...structuredClone(element.dynamic_fields ?? {}), display_override: value },
|
||||
formatted_value: value,
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocationResult {
|
||||
formattedValue: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export class LocationConsentGate {
|
||||
constructor(private readonly adapter: {
|
||||
geolocate: () => Promise<{ latitude: number; longitude: number }>;
|
||||
reverseGeocode: (coordinates: { latitude: number; longitude: number }) => Promise<string>;
|
||||
}) {}
|
||||
|
||||
reject() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async confirm(): Promise<LocationResult> {
|
||||
const coordinates = await this.adapter.geolocate();
|
||||
if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90
|
||||
|| !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) {
|
||||
throw new Error("dynamic_location_invalid");
|
||||
}
|
||||
const formattedValue = (await this.adapter.reverseGeocode(coordinates)).trim();
|
||||
if (!formattedValue) throw new Error("dynamic_location_unavailable");
|
||||
return { ...coordinates, formattedValue };
|
||||
}
|
||||
}
|
||||
|
||||
export function browserGeolocate() {
|
||||
return new Promise<{ latitude: number; longitude: number }>((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error("dynamic_location_unsupported"));
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => resolve({ latitude: position.coords.latitude, longitude: position.coords.longitude }),
|
||||
() => reject(new Error("dynamic_location_denied")),
|
||||
{ enableHighAccuracy: false, maximumAge: 0, timeout: 10_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { fontOption, type FontOption } from "./text-assets.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export type DynamicTextValue =
|
||||
| "city"
|
||||
| "city_en"
|
||||
| "day"
|
||||
| "hour"
|
||||
| "latitude_dms"
|
||||
| "longitude_dms"
|
||||
| "meridiem"
|
||||
| "minute"
|
||||
| "month"
|
||||
| "month_en"
|
||||
| "nickname"
|
||||
| "time"
|
||||
| "title"
|
||||
| "year_short";
|
||||
|
||||
export interface DynamicImageLayer {
|
||||
assetId: string;
|
||||
height: number;
|
||||
width: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DynamicTextLayer {
|
||||
align: "center" | "left";
|
||||
color: string;
|
||||
fontId: string;
|
||||
fontSize: number;
|
||||
value: DynamicTextValue;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface DynamicRenderModel {
|
||||
halfSize: { height: number; width: number };
|
||||
imageLayers: readonly DynamicImageLayer[];
|
||||
sourceCandidateId: string;
|
||||
textLayers: readonly DynamicTextLayer[];
|
||||
}
|
||||
|
||||
export const DYNAMIC_RESOURCE_VERSION = "wp4-dynamic-source-v1";
|
||||
|
||||
const dynamicFont = (fontId: string): FontOption => ({
|
||||
displayName: fontId,
|
||||
fontId,
|
||||
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;
|
||||
|
||||
export const DYNAMIC_RENDER_MODELS: Readonly<Record<DynamicTemplateId, DynamicRenderModel>> = {
|
||||
DYN001: {
|
||||
halfSize: { height: 42, width: 130 },
|
||||
imageLayers: [{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 }],
|
||||
sourceCandidateId: "l_POI01",
|
||||
textLayers: [{ align: "center", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 21, value: "title", x: 0, y: 0 }],
|
||||
},
|
||||
DYN002: {
|
||||
halfSize: { height: 48, width: 215 },
|
||||
imageLayers: [{ assetId: "DYN002-image29", height: 77, width: 232, x: 94.5, y: 0.5 }],
|
||||
sourceCandidateId: "l_POI02",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 19, value: "title", x: -94.5, y: 10.5 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 10, value: "city_en", x: 0, y: 36 },
|
||||
],
|
||||
},
|
||||
DYN003: {
|
||||
halfSize: { height: 54, width: 215 },
|
||||
imageLayers: [{ assetId: "DYN003-image30", height: 85, width: 235, x: -93.857, y: -23.635 }],
|
||||
sourceCandidateId: "l_POI03",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 18, value: "title", x: -54, y: 9 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 24, value: "city", x: -53, y: -16 },
|
||||
],
|
||||
},
|
||||
DYN004: {
|
||||
halfSize: { height: 48, width: 180 },
|
||||
imageLayers: [{ assetId: "DYN004-image32", height: 76, width: 39, x: -21.067, y: -1 }],
|
||||
sourceCandidateId: "l_POI04",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "dd25b35dcb7ba4476cbaa9a9592e39e2", fontSize: 26, value: "city", x: -28, y: 0 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "latitude_dms", x: -29.844, y: 36 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "15974853bc3294ef68e7e6d58fe74fd7", fontSize: 14, value: "longitude_dms", x: 71.523, y: 36 },
|
||||
],
|
||||
},
|
||||
DYN007: {
|
||||
halfSize: { height: 34, width: 190 },
|
||||
imageLayers: [],
|
||||
sourceCandidateId: "diaoyu",
|
||||
textLayers: [{ align: "center", color: "#295E8B", fontId: "53ca6b704728520da50c145eabb2e635", fontSize: 50, value: "nickname", x: 0, y: 0 }],
|
||||
},
|
||||
DYN008: {
|
||||
halfSize: { height: 180, width: 140 },
|
||||
imageLayers: [{ assetId: "DYN008-backendui0", height: 185, width: 5, x: -125, y: 0 }],
|
||||
sourceCandidateId: "l_shijian2",
|
||||
textLayers: [
|
||||
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 70, value: "month_en", x: -112, y: -32 },
|
||||
{ align: "left", color: "#FFFFFF", fontId: "f4bfd4132df2d6be97ceabadf3853505", fontSize: 130, value: "time", x: -114.024, y: 97 },
|
||||
],
|
||||
},
|
||||
DYN011: {
|
||||
halfSize: { height: 40, width: 130 },
|
||||
imageLayers: [{ assetId: "DYN011-backendui0", height: 12, width: 8, x: -120, y: -19 }],
|
||||
sourceCandidateId: "l_shijian6",
|
||||
textLayers: [
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "year_short", x: -85, y: 0 },
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "month", x: 0, y: 0 },
|
||||
{ align: "center", color: "#FF6D2F", fontId: "e4210c9872f0c279b35273f230809821", fontSize: 64, value: "day", x: 85, y: 0 },
|
||||
],
|
||||
},
|
||||
DYN012: {
|
||||
halfSize: { height: 78, width: 118 },
|
||||
imageLayers: [],
|
||||
sourceCandidateId: "l_shijian7",
|
||||
textLayers: [],
|
||||
},
|
||||
DYN015: {
|
||||
halfSize: { height: 42, width: 300 },
|
||||
imageLayers: [{ assetId: "DYN015-imager2", height: 60, width: 60, x: -140, y: 0 }],
|
||||
sourceCandidateId: "0721userna",
|
||||
textLayers: [{ align: "left", color: "#666666", fontId: "cca5efc0e02fb1bf62349bd68ef30fc1", fontSize: 40, value: "nickname", x: -100, y: 0 }],
|
||||
},
|
||||
DYN016: {
|
||||
halfSize: { height: 48, width: 160 },
|
||||
imageLayers: [{ assetId: "DYN016-image21", height: 59, width: 186, x: -61.842, y: 16.483 }],
|
||||
sourceCandidateId: "l_username00",
|
||||
textLayers: [{ align: "left", color: "#FFFFFF", fontId: "46f8336813e4c48d06a1aef294fdccf6", fontSize: 27, value: "nickname", x: 0, y: 0 }],
|
||||
},
|
||||
};
|
||||
|
||||
export function dynamicFontOptionsFor(templateId: string) {
|
||||
if (templateId === "DYN012") {
|
||||
const replacement = fontOption("FONT081");
|
||||
return replacement ? [replacement] : [];
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
export function dynamicImageUrl(resourceVersion: string, assetId: string) {
|
||||
return `/api/v1/assets/public/${encodeURIComponent(resourceVersion)}/${encodeURIComponent(assetId)}`;
|
||||
}
|
||||
|
||||
function numericField(element: CanvasElement, key: string) {
|
||||
const value = element.dynamic_fields?.[key];
|
||||
return typeof value === "number" ? value : Number(value);
|
||||
}
|
||||
|
||||
function coordinate(value: number, positive: string, negative: string) {
|
||||
if (!Number.isFinite(value)) return "";
|
||||
const absolute = Math.abs(value);
|
||||
const degrees = Math.floor(absolute);
|
||||
const minutesFloat = (absolute - degrees) * 60;
|
||||
const minutes = Math.floor(minutesFloat);
|
||||
const seconds = (minutesFloat - minutes) * 60;
|
||||
return `${degrees}°${String(minutes).padStart(2, "0")}'${seconds.toFixed(2).padStart(5, "0")}\"${value >= 0 ? positive : negative}`;
|
||||
}
|
||||
|
||||
export function dynamicTextValue(element: CanvasElement, value: DynamicTextValue) {
|
||||
const fields = element.dynamic_fields ?? {};
|
||||
const text = (key: string) => String(fields[key] ?? "");
|
||||
const override = fields.display_override;
|
||||
if (typeof override === "string" && (value === "nickname" || value === "title")) return element.formatted_value ?? override;
|
||||
if (value === "time") return `${text("hour")}:${text("minute")}`;
|
||||
if (value === "month_en") {
|
||||
const month = Math.max(1, Math.min(12, numericField(element, "month")));
|
||||
return `${["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"][month - 1]}.`;
|
||||
}
|
||||
if (value === "year_short") return text("year").slice(-2);
|
||||
if (value === "latitude_dms") return coordinate(numericField(element, "latitude"), "N", "S");
|
||||
if (value === "longitude_dms") return coordinate(numericField(element, "longitude"), "E", "W");
|
||||
if (value === "meridiem") {
|
||||
const hour = numericField(element, "hour");
|
||||
const minute = numericField(element, "minute");
|
||||
return hour > 12 || (hour === 12 && minute > 0) ? "PM" : "AM";
|
||||
}
|
||||
return text(value);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { createDynamicStickerElement, P0A_DYNAMIC_STICKERS, type DynamicTemplateId } from "./dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, dynamicImageUrl } from "./dynamic-render-models.js";
|
||||
import { drawDynamicSticker } from "./editor-stage.js";
|
||||
import type { ArchivedFontStatus } from "./text-font-loader.js";
|
||||
|
||||
const categoryLabels = { identity: "身份", location: "地点", other: "综合", time: "时间" } as const;
|
||||
|
||||
function loadImage(url: string) {
|
||||
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(undefined);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
function DynamicPreview(props: { fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; templateId: DynamicTemplateId }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
const statusKey = Object.entries(props.fontStatuses).map(([id, status]) => `${id}:${status}`).join("|");
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const model = DYNAMIC_RENDER_MODELS[props.templateId];
|
||||
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" },
|
||||
}, { createdAt: "2026-08-03T00:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000002" }, 0);
|
||||
void Promise.all(model.imageLayers.map(async (layer) => [layer.assetId, await loadImage(dynamicImageUrl(element.resource_version, layer.assetId))] as const)).then((entries) => {
|
||||
if (!active) return;
|
||||
const canvas = ref.current;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (!canvas || !context) return;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#30343b";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
const scale = Math.min(1, 146 / (model.halfSize.width * 2), 62 / (model.halfSize.height * 2));
|
||||
context.translate(canvas.width / 2, canvas.height / 2);
|
||||
context.scale(scale, scale);
|
||||
const images = Object.fromEntries(entries.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
|
||||
drawDynamicSticker(context, element, props.fontStatuses, images);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [props.templateId, statusKey]);
|
||||
return <canvas aria-hidden="true" className="editor-source-preview-canvas" height={72} ref={ref} width={160} />;
|
||||
}
|
||||
|
||||
export function DynamicStickerPanel(props: { canAdd: boolean; fontStatuses: Readonly<Record<string, ArchivedFontStatus>>; onAdd: (templateId: DynamicTemplateId) => void }) {
|
||||
return <section className="editor-provider-panel">
|
||||
<h2>动态贴纸</h2>
|
||||
<div className="editor-provider-groups">
|
||||
{(["location", "time", "identity", "other"] as const).map((category) => <section key={category}>
|
||||
<h3>{categoryLabels[category]}</h3>
|
||||
<div className="editor-provider-grid">
|
||||
{P0A_DYNAMIC_STICKERS.filter((item) => item.category === category).map((item) => <button
|
||||
aria-label={`添加动态贴纸 ${item.templateId} ${item.displayName}`}
|
||||
disabled={!props.canAdd}
|
||||
key={item.templateId}
|
||||
onClick={() => props.onAdd(item.templateId)}
|
||||
type="button"
|
||||
><DynamicPreview fontStatuses={props.fontStatuses} templateId={item.templateId} /><strong>{item.templateId}</strong><span>{item.displayName}</span>{item.templateId === "DYN012" ? <small>FONT081 替代字体</small> : null}</button>)}
|
||||
</div>
|
||||
</section>)}
|
||||
</div>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { isCanvasState, type CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { DYNAMIC_RENDER_MODELS } from "./dynamic-render-models.js";
|
||||
import type { DynamicTemplateId } from "./dynamic-provider.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export interface CanvasPoint {
|
||||
@@ -45,6 +48,22 @@ function textLineUnits(line: string) {
|
||||
}
|
||||
|
||||
export function elementHalfExtents(state: CanvasState, element: CanvasElement): CanvasPoint {
|
||||
if (element.type === "color_card") {
|
||||
const vertical = element.style_id === "style_01" || element.style_id === "style_02";
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, (vertical ? 62 : 175) / state.pixel_width) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, (vertical ? 155 : 50) / state.pixel_height) * element.scale.y,
|
||||
};
|
||||
}
|
||||
if (element.type === "dynamic_sticker") {
|
||||
const model = DYNAMIC_RENDER_MODELS[element.template_or_asset_id as DynamicTemplateId];
|
||||
const halfWidth = model?.halfSize.width ?? 170;
|
||||
const halfHeight = model?.halfSize.height ?? 62;
|
||||
return {
|
||||
x: Math.max(hitHalfExtent, halfWidth / state.pixel_width) * element.scale.x,
|
||||
y: Math.max(hitHalfExtent, halfHeight / state.pixel_height) * element.scale.y,
|
||||
};
|
||||
}
|
||||
if (element.type !== "text_template") {
|
||||
return { x: hitHalfExtent * element.scale.x, y: hitHalfExtent * element.scale.y };
|
||||
}
|
||||
|
||||
@@ -176,8 +176,7 @@
|
||||
|
||||
.editor-sticker-grid button:disabled { color: #62625d; background: #e8e8e5; cursor: not-allowed; }
|
||||
.editor-sticker-grid button > span:last-child { overflow-wrap: anywhere; font-size: 11px; }
|
||||
.editor-sticker-preview { display: block; width: 58px; height: 58px; border: 3px solid #111111; background: #f2f400; }
|
||||
.editor-sticker-preview.stk002 { border-radius: 50%; background: #1769aa; }
|
||||
.editor-sticker-preview { display: block; width: 58px; height: 58px; object-fit: contain; border: 1px solid #b9b9b3; background: #30343b; }
|
||||
.editor-limit { margin: 12px 0 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 12px; }
|
||||
|
||||
.editor-workspace {
|
||||
@@ -368,6 +367,46 @@
|
||||
.editor-template-mark.tag { background: #dbeafe; }
|
||||
.editor-template-mark.simple { background: #ffffff; }
|
||||
|
||||
.editor-provider-panel > header { display: flex; align-items: center; justify-content: space-between; }
|
||||
.editor-provider-panel > header h2 { margin: 0; }
|
||||
.editor-info-button { width: 28px; height: 28px; border: 1px solid #111111; border-radius: 50%; background: #ffffff; font: 700 13px Georgia, serif; }
|
||||
.editor-provider-note { margin: 8px 0 12px; color: #62625d; font-size: 11px; }
|
||||
.editor-provider-groups { display: grid; gap: 18px; }
|
||||
.editor-provider-groups section { display: grid; gap: 8px; }
|
||||
.editor-provider-groups h3 { margin: 0; font-size: 12px; }
|
||||
.editor-provider-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||||
.editor-provider-grid button {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 112px;
|
||||
grid-template-rows: 48px 16px minmax(18px, auto) auto;
|
||||
align-items: center;
|
||||
padding: 7px;
|
||||
overflow: hidden;
|
||||
border: 1px solid #85857f;
|
||||
border-radius: 0;
|
||||
background: #ffffff;
|
||||
color: #111111;
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.editor-provider-grid button:disabled { background: #e8e8e5; color: #62625d; cursor: not-allowed; }
|
||||
.editor-provider-grid strong { font-family: Consolas, monospace; font-size: 10px; }
|
||||
.editor-provider-grid small { color: #8f1d14; font-size: 9px; }
|
||||
.editor-color-card-preview { display: flex; width: 100%; height: 42px; align-items: stretch; border: 1px solid #111111; background: #ffffff; }
|
||||
.editor-color-card-preview i { flex: 1; }
|
||||
.editor-color-card-preview.style_01,
|
||||
.editor-color-card-preview.style_02 { width: 30px; height: 48px; flex-direction: column; justify-self: center; }
|
||||
.editor-dynamic-preview { display: grid; width: 100%; height: 44px; place-items: center; border: 1px solid #111111; background: #ffffff; font-size: 15px; font-weight: 800; }
|
||||
.editor-dynamic-preview.location { border-left: 6px solid #1769aa; }
|
||||
.editor-dynamic-preview.time { background: #FFE62C; }
|
||||
.editor-dynamic-preview.identity { background: #1769aa; color: #ffffff; }
|
||||
.editor-dynamic-preview.dyn012 { position: relative; display: flex; gap: 7px; justify-content: center; border-color: #111111; background: #30343b; color: #ffffff; font-family: Dada_FONT081, sans-serif; font-size: 17px; font-weight: 400; }
|
||||
.editor-dynamic-preview.dyn012 i { width: 2px; height: 15px; background: #ffffff; }
|
||||
.editor-dynamic-preview.dyn012 small { position: absolute; top: 3px; right: 6px; color: #ffffff; font-size: 6px; }
|
||||
.editor-source-preview-canvas { display: block; width: 100%; height: 72px; background: #30343b; }
|
||||
|
||||
.editor-text-inspector { display: grid; gap: 14px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||
.editor-text-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||
.editor-text-inspector textarea,
|
||||
@@ -385,6 +424,11 @@
|
||||
.editor-color-swatches button { width: 32px; height: 32px; border: 1px solid #111111; border-radius: 0; }
|
||||
.editor-check { display: flex !important; grid-template-columns: 18px 1fr; align-items: center; }
|
||||
.editor-check input { width: 18px; height: 18px; margin: 0; }
|
||||
.editor-dynamic-inspector { display: grid; gap: 10px; margin-top: 14px; padding-block: 14px; border-block: 1px solid #b9b9b3; }
|
||||
.editor-dynamic-inspector label { display: grid; gap: 5px; font-size: 12px; font-weight: 700; }
|
||||
.editor-dynamic-inspector textarea { min-height: 82px; padding: 7px 8px; border: 1px solid #85857f; border-radius: 0; resize: vertical; font: inherit; }
|
||||
.editor-dynamic-inspector .editor-wide-command { margin-top: 0; }
|
||||
.editor-font-substitution { margin: 0; padding: 8px; border-left: 3px solid #c92a24; background: #ffffff; color: #8f1d14; font-size: 11px; }
|
||||
|
||||
.editor-statusbar {
|
||||
display: flex;
|
||||
@@ -415,6 +459,9 @@
|
||||
.editor-confirm p { margin: 0; }
|
||||
.editor-confirm > div { display: flex; gap: 8px; margin-top: 24px; }
|
||||
.editor-confirm button { min-height: 40px; padding: 8px 14px; border: 1px solid #111111; border-radius: 0; background: #ffffff; font: inherit; font-weight: 700; }
|
||||
.editor-location-consent label { display: grid; gap: 6px; margin-top: 18px; font-size: 12px; font-weight: 700; }
|
||||
.editor-location-consent input { min-height: 40px; padding: 7px 9px; border: 1px solid #85857f; border-radius: 0; font: inherit; }
|
||||
.editor-location-actions { display: grid !important; grid-template-columns: 1fr; }
|
||||
|
||||
.editor-loading { display: grid; min-height: 100vh; place-items: center; background: #e8e8e5; }
|
||||
|
||||
|
||||
+161
-24
@@ -25,12 +25,29 @@ import {
|
||||
type TextTemplateDefinition,
|
||||
} from "./text-assets.js";
|
||||
import { TextTemplatePanel } from "./text-template-panel.js";
|
||||
import { ColorCardPanel } from "./color-card-panel.js";
|
||||
import { DynamicInspector } from "./dynamic-inspector.js";
|
||||
import { DynamicStickerPanel } from "./dynamic-sticker-panel.js";
|
||||
import {
|
||||
LocationConsentGate,
|
||||
P0A_DYNAMIC_STICKERS,
|
||||
browserGeolocate,
|
||||
createDynamicStickerElement,
|
||||
overrideDynamicStickerValue,
|
||||
type DynamicTemplateId,
|
||||
} from "./dynamic-provider.js";
|
||||
import { dynamicFontOptionsFor } from "./dynamic-render-models.js";
|
||||
import {
|
||||
createColorCardElement,
|
||||
extractPaletteFromImage,
|
||||
type ColorCardDefinition,
|
||||
} from "./palette-provider.js";
|
||||
|
||||
import "./editor-page.css";
|
||||
|
||||
type Ratio = CanvasState["ratio"];
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
type EditorAssetPanel = "background" | "history" | "stickers" | "text";
|
||||
type EditorAssetPanel = "background" | "color" | "dynamic" | "history" | "stickers" | "text";
|
||||
|
||||
interface TextEditState {
|
||||
draft: CanvasElement;
|
||||
@@ -40,7 +57,13 @@ interface TextEditState {
|
||||
|
||||
interface EditorSession {
|
||||
csrf_token: string;
|
||||
user: { creator_name: string; user_id: string };
|
||||
user: { creator_name: string; social_id: string; user_id: string };
|
||||
}
|
||||
|
||||
interface LocationDialogState {
|
||||
error?: string;
|
||||
manualValue: string;
|
||||
pending: boolean;
|
||||
}
|
||||
|
||||
interface EditorProject {
|
||||
@@ -70,14 +93,9 @@ function formatDate(value: string) {
|
||||
return new Intl.DateTimeFormat("zh-CN", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
|
||||
}
|
||||
|
||||
function paletteForAsset(assetId: string) {
|
||||
const seed = assetId.replaceAll("-", "");
|
||||
return [0, 1, 2, 3, 4].map((index) => `#${seed.slice(index * 6, index * 6 + 6).padEnd(6, "0")}`);
|
||||
}
|
||||
|
||||
const stickerFixtures = [
|
||||
{ assetId: "STK001", label: "方形标记" },
|
||||
{ assetId: "STK002", label: "圆形标记" },
|
||||
{ assetId: "STK001", label: "part1 原版草莓" },
|
||||
{ assetId: "STK002", label: "part2 原版小狗" },
|
||||
] as const;
|
||||
|
||||
function newElementIdentity(): CanvasElementIdentity {
|
||||
@@ -106,6 +124,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
const [recentTextIds, setRecentTextIds] = useState<string[]>([]);
|
||||
const [fontStatuses, setFontStatuses] = useState<Record<string, ArchivedFontStatus>>({});
|
||||
const [textEdit, setTextEdit] = useState<TextEditState>();
|
||||
const [locationDialog, setLocationDialog] = useState<LocationDialogState>();
|
||||
const queueRef = useRef<ProjectAutoSaveQueue | undefined>(undefined);
|
||||
const historyRef = useRef<CanvasEditHistory | undefined>(undefined);
|
||||
const elementControllerRef = useRef<CanvasElementController | undefined>(undefined);
|
||||
@@ -170,14 +189,19 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
useEffect(() => {
|
||||
if (!canvasState) return;
|
||||
for (const element of canvasState.elements) {
|
||||
if (element.type !== "text_template") continue;
|
||||
const fontId = fontIdForTextElement(element);
|
||||
const option = fontId ? fontOption(fontId) : undefined;
|
||||
if (fontId && option) void ensureFont(option.fontId, option.url);
|
||||
else if (fontId) setFontStatuses((current) => ({ ...current, [fontId]: "unavailable" }));
|
||||
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);
|
||||
}
|
||||
}, [canvasState?.elements.map((element) => `${element.element_id}:${element.font_override ?? ""}:${element.template_or_asset_id}`).join("|")]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activePanel !== "dynamic") return;
|
||||
const options = P0A_DYNAMIC_STICKERS.flatMap((definition) => dynamicFontOptionsFor(definition.templateId));
|
||||
for (const option of options) void ensureFont(option.fontId, option.url);
|
||||
}, [activePanel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasState || selectedIds.length !== 1) {
|
||||
setTextEdit(undefined);
|
||||
@@ -250,11 +274,26 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function confirmBackground() {
|
||||
async function paletteForAsset(assetId: string) {
|
||||
const image = new Image();
|
||||
const loaded = new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error("palette_image_unavailable"));
|
||||
});
|
||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(projectId)}/images/${encodeURIComponent(assetId)}`;
|
||||
return extractPaletteFromImage(await loaded);
|
||||
}
|
||||
|
||||
async function confirmBackground() {
|
||||
if (!canvasState || !pendingBackground) return;
|
||||
commitCanvas(switchBackground(canvasState, pendingBackground, paletteForAsset(pendingBackground)));
|
||||
setPendingBackground(undefined);
|
||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
try {
|
||||
const palette = await paletteForAsset(pendingBackground);
|
||||
commitCanvas(switchBackground(canvasState, pendingBackground, palette));
|
||||
setPendingBackground(undefined);
|
||||
setNotice("已更换底图,覆盖元素保留,底图处理已重置");
|
||||
} catch {
|
||||
setNotice("新底图无法读取,未更换底图或刷新色卡");
|
||||
}
|
||||
}
|
||||
|
||||
function controllerForCurrent() {
|
||||
@@ -291,6 +330,92 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function addColorCard(definition: ColorCardDefinition) {
|
||||
const assetId = canvasState?.background.asset_id;
|
||||
const controller = controllerForCurrent();
|
||||
if (!assetId || !canvasState || !controller) return;
|
||||
try {
|
||||
const palette = await paletteForAsset(assetId);
|
||||
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("无法从原始底图稳定提取五色,色卡未加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
async function addDynamicSticker(templateId: DynamicTemplateId, location?: { formattedValue: string; latitude?: number; longitude?: number }) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!canvasState || !controller || !session) return;
|
||||
if (templateId === "DYN012") {
|
||||
const font = fontOption("FONT081");
|
||||
if (!font || await ensureFont(font.fontId, font.url) !== "ready") {
|
||||
setNotice("DYN012 的 FONT081 替代字体不可用,未使用系统字体替代。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
try {
|
||||
controller.add(createDynamicStickerElement(templateId, {
|
||||
...(location ? { location } : {}),
|
||||
now: new Date(),
|
||||
profile: { creatorName: session.user.creator_name, socialId: session.user.social_id },
|
||||
}, newElementIdentity(), canvasState.elements.length));
|
||||
commitElementOperation(controller, "动态值已确认并加入画布");
|
||||
setLocationDialog(undefined);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === "canvas_element_limit_reached") setNotice("画布最多 50 个元素,请先删除现有元素。");
|
||||
else setNotice("动态贴纸未能加入画布");
|
||||
}
|
||||
}
|
||||
|
||||
function chooseDynamicSticker(templateId: DynamicTemplateId) {
|
||||
if (templateId === "DYN004") {
|
||||
setLocationDialog({ manualValue: "", pending: false });
|
||||
return;
|
||||
}
|
||||
void addDynamicSticker(templateId);
|
||||
}
|
||||
|
||||
async function confirmAutomaticLocation() {
|
||||
if (!session) return;
|
||||
setLocationDialog((current) => {
|
||||
if (!current) return current;
|
||||
const { error: _error, ...rest } = current;
|
||||
return { ...rest, pending: true };
|
||||
});
|
||||
const gate = new LocationConsentGate({
|
||||
geolocate: browserGeolocate,
|
||||
reverseGeocode: async (coordinates) => {
|
||||
const response = await fetch("/api/v1/location/reverse-geocode", {
|
||||
body: JSON.stringify(coordinates),
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json", "X-CSRF-Token": session.csrf_token },
|
||||
method: "POST",
|
||||
});
|
||||
if (!response.ok) throw new Error("dynamic_location_service_unavailable");
|
||||
const payload = await response.json() as { formatted_value: string };
|
||||
return payload.formatted_value;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await gate.confirm();
|
||||
await addDynamicSticker("DYN004", result);
|
||||
} catch {
|
||||
setLocationDialog((current) => current ? { ...current, error: "自动定位不可用,请改用手动地点贴纸。", pending: false } : current);
|
||||
}
|
||||
}
|
||||
|
||||
function commitDynamicOverride(element: CanvasElement, value: string) {
|
||||
const controller = controllerForCurrent();
|
||||
if (!controller) return;
|
||||
try {
|
||||
controller.replaceElement(overrideDynamicStickerValue(element, value));
|
||||
commitElementOperation(controller, "动态贴纸显示文字已更新");
|
||||
} catch {
|
||||
setNotice("动态贴纸显示文字不能为空");
|
||||
}
|
||||
}
|
||||
|
||||
async function recordRecentTextTemplate(templateId: string, resourceVersion: string) {
|
||||
if (!session) return;
|
||||
try {
|
||||
@@ -527,7 +652,7 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
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));
|
||||
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type === "static_sticker")
|
||||
const selectedStickerOpacity = selectedElements.length > 0 && selectedElements.every((element) => element.type !== "text_template")
|
||||
? Math.round((selectedElements[0]?.opacity ?? 1) * 100)
|
||||
: undefined;
|
||||
return (
|
||||
@@ -552,9 +677,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
{ label: "历史", panel: "history" as const },
|
||||
{ label: "文字模板", panel: "text" as const },
|
||||
{ label: "普通贴纸", panel: "stickers" as const },
|
||||
{ label: "色卡" },
|
||||
{ label: "动态贴纸" },
|
||||
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} disabled={!item.panel} key={item.label} onClick={() => { if (item.panel) setActivePanel(item.panel); }} type="button">{item.label}</button>)}
|
||||
{ label: "色卡", panel: "color" as const },
|
||||
{ label: "动态贴纸", panel: "dynamic" as const },
|
||||
]).map((item) => <button aria-current={item.panel === activePanel ? "page" : undefined} key={item.label} onClick={() => setActivePanel(item.panel)} type="button">{item.label}</button>)}
|
||||
</nav>
|
||||
{activePanel === "background" ? <section><h2>底图</h2><button className="editor-source active" type="button"><span className="editor-thumb" style={{ backgroundImage: `url(${imageUrl})` }} /><span>当前底图</span></button></section> : null}
|
||||
{activePanel === "history" ? <section><h2>历史</h2><div className="editor-history-list">
|
||||
@@ -573,8 +698,10 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
{...(templateCategory ? { category: templateCategory } : {})}
|
||||
/> : null}
|
||||
{activePanel === "stickers" ? <section><h2>普通贴纸</h2><div className="editor-sticker-grid">
|
||||
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><span className={`editor-sticker-preview ${sticker.assetId.toLowerCase()}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
|
||||
{stickerFixtures.map((sticker) => <button aria-label={`添加贴纸 ${sticker.assetId}`} disabled={!canEdit || canvasState.elements.length >= 50} key={sticker.assetId} onClick={() => addSticker(sticker.assetId)} type="button"><img alt="" className="editor-sticker-preview" src={`/api/v1/assets/public/fixture-v1/${sticker.assetId}`} /><strong>{sticker.assetId}</strong><span>{sticker.label}</span></button>)}
|
||||
</div>{canvasState.elements.length >= 50 ? <p className="editor-limit" role="status">画布最多 50 个元素,请先删除现有元素。</p> : null}</section> : null}
|
||||
{activePanel === "color" ? <ColorCardPanel canAdd={canEdit && canvasState.elements.length < 50} hasBackground={Boolean(canvasState.background.asset_id)} onAdd={(definition) => { void addColorCard(definition); }} /> : null}
|
||||
{activePanel === "dynamic" ? <DynamicStickerPanel canAdd={canEdit && canvasState.elements.length < 50} fontStatuses={fontStatuses} onAdd={chooseDynamicSticker} /> : null}
|
||||
</aside>
|
||||
<section aria-label="画布工作区" className="editor-workspace">
|
||||
<div className="editor-canvas-tools" role="toolbar" aria-label="画布选择工具">
|
||||
@@ -630,6 +757,9 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
onTemplate={(templateId) => { void changeTextTemplate(templateId); }}
|
||||
templates={P0A_TEXT_TEMPLATES}
|
||||
/> : <p className="editor-muted">版本冲突,文字属性只读</p> : null}
|
||||
{selectedElements.length === 1 && selectedElements[0]?.type === "dynamic_sticker" && canEdit
|
||||
? <DynamicInspector element={selectedElements[0]} onCommit={(value) => commitDynamicOverride(selectedElements[0]!, value)} />
|
||||
: null}
|
||||
<div className="editor-object-moves" role="group" aria-label="移动对象">
|
||||
<button aria-label="向左移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: -0.01, y: 0 }, { snap: false }); }, "对象位置已提交")} title="向左移动" type="button">←</button>
|
||||
<button aria-label="向上移动" disabled={!canEdit} onClick={() => transformSelection((controller) => { controller.moveSelected({ x: 0, y: -0.01 }, { snap: false }); }, "对象位置已提交")} title="向上移动" type="button">↑</button>
|
||||
@@ -657,7 +787,14 @@ export function EditorPage({ projectId }: { projectId: string }) {
|
||||
</aside>
|
||||
</main>
|
||||
<footer className="editor-statusbar"><span>画布 {canvasState.pixel_width} × {canvasState.pixel_height}</span><span>对象 {canvasState.elements.length} / 50</span><span>缩放 100%</span><span>本机保存 · state version {project.state_version}</span></footer>
|
||||
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm">更换底图</h2><p>覆盖元素会保留,底图编辑参数将重置。</p><div><button className="editor-primary" onClick={confirmBackground} type="button">确认更换</button><button onClick={() => setPendingBackground(undefined)} type="button">取消</button></div></section></div> : null}
|
||||
{pendingBackground ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-background-confirm" aria-modal="true" className="editor-confirm" role="dialog"><p>CHANGE BACKGROUND</p><h2 id="editor-background-confirm">更换底图</h2><p>覆盖元素会保留,底图编辑参数将重置。</p><div><button className="editor-primary" onClick={() => { void confirmBackground(); }} type="button">确认更换</button><button onClick={() => setPendingBackground(undefined)} type="button">取消</button></div></section></div> : null}
|
||||
{locationDialog ? <div className="editor-dialog-backdrop"><section aria-labelledby="editor-location-consent" aria-modal="true" className="editor-confirm editor-location-consent" role="dialog">
|
||||
<p>LOCATION PRIVACY</p><h2 id="editor-location-consent">使用自动定位</h2>
|
||||
<p>原始经纬度会保存到当前项目并显示在导出成品中。只有确认后才会请求浏览器定位,并由本地后端调用地点服务。</p>
|
||||
{locationDialog.error ? <p className="editor-limit" role="alert">{locationDialog.error}</p> : null}
|
||||
<label>手动地点文字<input aria-label="手动地点文字" disabled={locationDialog.pending} onChange={(event) => setLocationDialog((current) => current ? { ...current, manualValue: event.target.value } : current)} value={locationDialog.manualValue} /></label>
|
||||
<div className="editor-location-actions"><button className="editor-primary" disabled={locationDialog.pending} onClick={() => { void confirmAutomaticLocation(); }} type="button">{locationDialog.pending ? "正在定位" : "同意并自动定位"}</button><button disabled={!locationDialog.manualValue.trim() || locationDialog.pending} onClick={() => { void addDynamicSticker("DYN001", { formattedValue: locationDialog.manualValue.trim() }); }} type="button">改用手动地点贴纸</button><button disabled={locationDialog.pending} onClick={() => setLocationDialog(undefined)} type="button">暂不定位</button></div>
|
||||
</section></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+118
-29
@@ -2,9 +2,13 @@ import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } fr
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import { 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";
|
||||
|
||||
interface Gesture {
|
||||
append: boolean;
|
||||
@@ -116,11 +120,77 @@ function drawTextElement(context: CanvasRenderingContext2D, element: CanvasState
|
||||
});
|
||||
}
|
||||
|
||||
function drawDynamicUnavailable(context: CanvasRenderingContext2D, message: string) {
|
||||
context.fillStyle = "#e5e7eb";
|
||||
context.fillRect(-140, -42, 280, 84);
|
||||
context.fillStyle = "#9f1d1d";
|
||||
context.font = "600 18px Microsoft YaHei UI, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(message, 0, 0);
|
||||
}
|
||||
|
||||
export function drawDynamicSticker(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
const id = element.template_or_asset_id as DynamicTemplateId;
|
||||
const model = DYNAMIC_RENDER_MODELS[id];
|
||||
if (!model) {
|
||||
drawDynamicUnavailable(context, "动态贴纸模型不可用");
|
||||
return;
|
||||
}
|
||||
if (dynamicFontOptionsFor(id).some((font) => fontStatuses[font.fontId] !== "ready")) {
|
||||
drawDynamicUnavailable(context, id === "DYN012" ? "FONT081 替代字体不可用" : "原版字体不可用");
|
||||
return;
|
||||
}
|
||||
if (model.imageLayers.some((layer) => !resourceImages[layer.assetId])) {
|
||||
drawDynamicUnavailable(context, "原版图片资源不可用");
|
||||
return;
|
||||
}
|
||||
context.textBaseline = "middle";
|
||||
for (const layer of model.imageLayers) {
|
||||
const image = resourceImages[layer.assetId]!;
|
||||
context.drawImage(image, layer.x - layer.width / 2, layer.y - layer.height / 2, layer.width, layer.height);
|
||||
}
|
||||
if (id === "DYN012") {
|
||||
const parts = dyn012DisplayParts(element);
|
||||
const layout = DYN012_RENDER_LAYOUT;
|
||||
context.fillStyle = layout.divider.color;
|
||||
context.fillRect(layout.divider.x - layout.divider.width / 2, layout.divider.y - layout.divider.height / 2, layout.divider.width, layout.divider.height);
|
||||
for (const [part, layer] of [[parts.hour, layout.hour], [parts.minute, layout.minute], [parts.meridiem, layout.meridiem]] as const) {
|
||||
context.fillStyle = layer.color;
|
||||
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||
context.textAlign = "center";
|
||||
context.fillText(part, layer.x, layer.y);
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const layer of model.textLayers) {
|
||||
context.fillStyle = layer.color;
|
||||
context.font = `${layer.fontSize}px "${fontFamilyName(layer.fontId)}"`;
|
||||
context.textAlign = layer.align;
|
||||
context.fillText(dynamicTextValue(element, layer.value), layer.x, layer.y);
|
||||
}
|
||||
}
|
||||
|
||||
function elementSelectionHalfSize(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
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 };
|
||||
}
|
||||
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 fontId = fontIdForTextElement(element);
|
||||
if (!fontId || fontStatuses[fontId] !== "ready") return { height: 34 * element.scale.y, width: 110 * element.scale.x };
|
||||
@@ -130,7 +200,14 @@ function elementSelectionHalfSize(
|
||||
return { height: geometry.height * element.scale.y / 2, width: geometry.width * element.scale.x / 2 };
|
||||
}
|
||||
|
||||
function drawElement(context: CanvasRenderingContext2D, element: CanvasState["elements"][number], width: number, height: number, fontStatuses: Readonly<Record<string, ArchivedFontStatus>>) {
|
||||
function drawElement(
|
||||
context: CanvasRenderingContext2D,
|
||||
element: CanvasState["elements"][number],
|
||||
width: number,
|
||||
height: number,
|
||||
fontStatuses: Readonly<Record<string, ArchivedFontStatus>>,
|
||||
resourceImages: Readonly<Record<string, HTMLImageElement>>,
|
||||
) {
|
||||
const x = element.position.x * width;
|
||||
const y = element.position.y * height;
|
||||
context.save();
|
||||
@@ -139,28 +216,30 @@ function drawElement(context: CanvasRenderingContext2D, element: CanvasState["el
|
||||
context.rotate((element.rotation * Math.PI) / 180);
|
||||
const flip = element.style_parameters?.flip_horizontal === true ? -1 : 1;
|
||||
context.scale(element.scale.x * flip, element.scale.y);
|
||||
if (element.type === "color_card") {
|
||||
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(index * 48 - 120, -28, 44, 56);
|
||||
});
|
||||
} else if (element.type === "static_sticker") {
|
||||
context.fillStyle = element.template_or_asset_id.endsWith("2") ? "#1769aa" : "#f2f400";
|
||||
context.strokeStyle = "#111111";
|
||||
context.lineWidth = 5;
|
||||
context.beginPath();
|
||||
context.roundRect(-62, -62, 124, 124, element.template_or_asset_id.endsWith("2") ? 62 : 8);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
context.fillStyle = "#111111";
|
||||
context.font = "700 22px Consolas, monospace";
|
||||
context.textAlign = "center";
|
||||
context.fillText(element.template_or_asset_id, 0, 8);
|
||||
if (element.type === "color_card") drawColorCard(context, element);
|
||||
else if (element.type === "static_sticker") {
|
||||
const image = resourceImages[element.template_or_asset_id];
|
||||
if (!image) drawDynamicUnavailable(context, "原版贴纸资源不可用");
|
||||
else {
|
||||
const scale = 156 / Math.max(image.naturalWidth, image.naturalHeight);
|
||||
const width = image.naturalWidth * scale;
|
||||
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 === "dynamic_sticker") drawDynamicSticker(context, element, fontStatuses, resourceImages);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function loadCanvasImage(url: string) {
|
||||
return new Promise<HTMLImageElement | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(undefined);
|
||||
image.src = url;
|
||||
});
|
||||
}
|
||||
|
||||
export function EditorStage(props: EditorStageProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const gestureRef = useRef<Gesture | undefined>(undefined);
|
||||
@@ -168,20 +247,21 @@ export function EditorStage(props: EditorStageProps) {
|
||||
const [marquee, setMarquee] = useState<CanvasRect>();
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return undefined;
|
||||
canvas.width = props.canvasState.pixel_width;
|
||||
canvas.height = props.canvasState.pixel_height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return undefined;
|
||||
const render = (image?: HTMLImageElement) => {
|
||||
const render = (image: HTMLImageElement | undefined, resourceImages: Readonly<Record<string, HTMLImageElement>>) => {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.filter = cssFilterForBackground(props.canvasState.background.adjustments);
|
||||
if (image) context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
context.filter = "none";
|
||||
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses);
|
||||
for (const element of [...props.canvasState.elements].sort((left, right) => left.z_index - right.z_index)) drawElement(context, element, canvas.width, canvas.height, props.fontStatuses, resourceImages);
|
||||
context.lineWidth = 4;
|
||||
context.strokeStyle = "#005fcc";
|
||||
for (const element of props.canvasState.elements.filter((entry) => props.selectedIds.includes(entry.element_id))) {
|
||||
@@ -201,15 +281,24 @@ 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();
|
||||
};
|
||||
if (!props.assetId) {
|
||||
render();
|
||||
return undefined;
|
||||
const background = props.assetId
|
||||
? loadCanvasImage(`/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`)
|
||||
: Promise.resolve(undefined);
|
||||
const imageReferences = new Map<string, string>();
|
||||
for (const element of props.canvasState.elements) {
|
||||
if (element.type === "static_sticker") imageReferences.set(element.template_or_asset_id, dynamicImageUrl(element.resource_version, element.template_or_asset_id));
|
||||
if (element.type === "dynamic_sticker") {
|
||||
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));
|
||||
}
|
||||
}
|
||||
const image = new Image();
|
||||
image.onload = () => render(image);
|
||||
image.onerror = () => render();
|
||||
image.src = `/api/v1/private-assets/projects/${encodeURIComponent(props.projectId)}/images/${encodeURIComponent(props.assetId)}`;
|
||||
return () => { image.onload = null; image.onerror = null; };
|
||||
const dynamic = Promise.all([...imageReferences].map(async ([assetId, url]) => [assetId, await loadCanvasImage(url)] as const));
|
||||
void Promise.all([background, dynamic]).then(([image, loaded]) => {
|
||||
if (!active) return;
|
||||
const resourceImages = Object.fromEntries(loaded.filter((entry): entry is readonly [string, HTMLImageElement] => entry[1] !== undefined));
|
||||
render(image, resourceImages);
|
||||
});
|
||||
return () => { active = false; };
|
||||
}, [marquee, props.assetId, props.canvasState, props.fontStatuses, props.guides, props.projectId, props.selectedIds]);
|
||||
|
||||
useEffect(() => () => { if (longPressRef.current) clearTimeout(longPressRef.current); }, []);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Generated from openapi/openapi.json. Do not edit by hand.
|
||||
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
import type { CreditAdjustmentResponse, CreditAdjustmentRequest, BrowserSupportSuccess, BrowserSupportRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, BootstrapResponse, GenerationTaskResponse, SseEvent, ModelConfig, ModelConfigurationResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, RecentAssetListResponse, LogoutResponse, ProjectPurgeResponse, RecentAssetRecordResponse, RecentAssetRecordRequest, ProjectRenameResponse, ProjectRenameRequest, ModelConfigUpdateRequest, ProjectRestoreResponse, ReverseGeocodeResponse, ReverseGeocodeRequest, LatestExportSaveResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
|
||||
|
||||
export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; }
|
||||
|
||||
@@ -230,6 +230,15 @@ export async function restoreProject(options: ClientOptions = {}): Promise<Proje
|
||||
return response.json() as Promise<ProjectRestoreResponse>;
|
||||
}
|
||||
|
||||
export async function reverseGeocodeLocation(body: ReverseGeocodeRequest, options: ClientOptions = {}): Promise<ReverseGeocodeResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/location/reverse-geocode`, { body: JSON.stringify(body), method: "POST", headers });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json() as Promise<ReverseGeocodeResponse>;
|
||||
}
|
||||
|
||||
export async function saveLatestExport(body: FormData, options: ClientOptions = {}): Promise<LatestExportSaveResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const headers = new Headers(options.headers);
|
||||
|
||||
@@ -715,6 +715,17 @@ export type RegistrationSendResponse = {
|
||||
"status": "verification_sent";
|
||||
};
|
||||
|
||||
export type ReverseGeocodeRequest = {
|
||||
"latitude": number;
|
||||
"longitude": number;
|
||||
};
|
||||
|
||||
export type ReverseGeocodeResponse = {
|
||||
"formatted_value": string;
|
||||
"service_mode": "mock";
|
||||
"status": "resolved";
|
||||
};
|
||||
|
||||
export type SseEvent = {
|
||||
"entity_ref": string;
|
||||
"event_id": number;
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { MMCQ } from "@vibrant/quantizer-mmcq";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import type { CanvasElementIdentity } from "./editor-elements.js";
|
||||
|
||||
type CanvasElement = CanvasState["elements"][number];
|
||||
|
||||
export interface ColorCardDefinition {
|
||||
cardId: "COLOR001" | "COLOR002" | "COLOR008" | "COLOR016";
|
||||
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";
|
||||
}
|
||||
|
||||
export interface PaletteColor {
|
||||
hex: string;
|
||||
population: number;
|
||||
rgbValue: number;
|
||||
}
|
||||
|
||||
export const P0A_COLOR_CARDS: 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: "COLOR008", displayName: "横向标尺", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "horizontal_line", styleId: "style_08" },
|
||||
{ cardId: "COLOR016", displayName: "横向票条", mappingStatus: "stable_web_style_native_mapping_provisional", rendererName: "ticket_strip", styleId: "style_16" },
|
||||
] as const;
|
||||
|
||||
// Coordinates are translated from the archived 320x320 renderer around its 160px center.
|
||||
export const COLOR_CARD_SOURCE_GEOMETRY = {
|
||||
style_01: {
|
||||
bounds: { bottom: 76, left: -26, right: 26, top: -74 },
|
||||
palette: { bottom: 37, left: -23, right: 23, top: -71 },
|
||||
},
|
||||
style_02: { bounds: { bottom: 69, left: -18, right: 18, top: -77 } },
|
||||
style_08: { bounds: { bottom: 10, left: -73, right: 73, top: -9 } },
|
||||
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||
} as const;
|
||||
|
||||
function normalizedHex(value: string) {
|
||||
return value.toUpperCase();
|
||||
}
|
||||
|
||||
export function extractMmcqPalette(pixels: Uint8ClampedArray): PaletteColor[] {
|
||||
const swatches = MMCQ(pixels, { colorCount: 10 });
|
||||
if (swatches.length < 5) throw new Error("palette_five_colors_required");
|
||||
return swatches
|
||||
.map((swatch) => ({
|
||||
hex: normalizedHex(swatch.hex),
|
||||
population: swatch.population,
|
||||
rgbValue: (Math.round(swatch.r) << 16) + (Math.round(swatch.g) << 8) + Math.round(swatch.b),
|
||||
}))
|
||||
.sort((left, right) => right.population - left.population || left.rgbValue - right.rgbValue)
|
||||
.slice(0, 5);
|
||||
}
|
||||
|
||||
export function extractPaletteFromImage(image: CanvasImageSource & { height: number; width: number }) {
|
||||
const longestEdge = Math.max(image.width, image.height);
|
||||
if (longestEdge <= 0) throw new Error("palette_image_invalid");
|
||||
const scale = Math.min(1, 256 / longestEdge);
|
||||
const width = Math.max(1, Math.round(image.width * scale));
|
||||
const height = Math.max(1, Math.round(image.height * scale));
|
||||
const surface = document.createElement("canvas");
|
||||
surface.width = width;
|
||||
surface.height = height;
|
||||
const context = surface.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("palette_canvas_unavailable");
|
||||
context.drawImage(image, 0, 0, width, height);
|
||||
return extractMmcqPalette(context.getImageData(0, 0, width, height).data).map((entry) => entry.hex);
|
||||
}
|
||||
|
||||
function validatePalette(palette: readonly string[]) {
|
||||
if (palette.length !== 5 || palette.some((color) => !/^#[0-9A-Fa-f]{6}$/.test(color))) throw new Error("canvas_palette_invalid");
|
||||
return palette.map(normalizedHex);
|
||||
}
|
||||
|
||||
export function createColorCardElement(
|
||||
definition: ColorCardDefinition,
|
||||
palette: readonly string[],
|
||||
identity: CanvasElementIdentity,
|
||||
zIndex: number,
|
||||
): CanvasElement {
|
||||
return {
|
||||
colors: validatePalette(palette),
|
||||
created_at: identity.createdAt,
|
||||
element_id: identity.elementId,
|
||||
opacity: 1,
|
||||
position: { x: 0.5, y: 0.5 },
|
||||
resource_version: "wp4-provider-v1",
|
||||
rotation: 0,
|
||||
scale: { x: 1, y: 1 },
|
||||
style_id: definition.styleId,
|
||||
style_parameters: {
|
||||
mapping_status: definition.mappingStatus,
|
||||
palette_algorithm_version: "mmcq-v1",
|
||||
renderer_name: definition.rendererName,
|
||||
},
|
||||
template_or_asset_id: definition.cardId,
|
||||
type: "color_card",
|
||||
z_index: zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
export function refreshColorCards(state: CanvasState, palette: readonly string[]): CanvasState {
|
||||
const colors = validatePalette(palette);
|
||||
const next = structuredClone(state);
|
||||
next.elements = next.elements.map((element) => element.type === "color_card" ? { ...element, colors: [...colors] } : element);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function drawColorCard(context: CanvasRenderingContext2D, element: CanvasElement) {
|
||||
const colors = element.colors ?? ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
if (element.style_id === "style_01") {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(-26, -74, 52, 145);
|
||||
colors.forEach((color, index) => {
|
||||
const top = -71 + index * 21.6;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-23, top, 46, index === colors.length - 1 ? 37 - top : 21.6);
|
||||
});
|
||||
context.fillStyle = "#222222";
|
||||
context.font = "700 5px Arial, sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillText("COLOR", -20, 44);
|
||||
context.fillStyle = "#979797";
|
||||
context.fillRect(-20, 54, 24, 1);
|
||||
context.fillStyle = "#ffffff";
|
||||
for (let index = 0; index < 8; index += 1) {
|
||||
const left = -26 + index * 6.5;
|
||||
context.beginPath();
|
||||
context.moveTo(left, 71);
|
||||
context.lineTo(left + 3.25, 76);
|
||||
context.lineTo(left + 6.5, 71);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_02") {
|
||||
context.font = "4px Arial, sans-serif";
|
||||
context.textAlign = "left";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "#222222";
|
||||
context.fillText("C O L O R", -17, -77);
|
||||
colors.forEach((color, index) => {
|
||||
context.fillStyle = color;
|
||||
context.fillRect(-18, -71 + index * 28, 36, 27);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (element.style_id === "style_08") {
|
||||
colors.forEach((color, index) => {
|
||||
const left = -73 + index * 29.2;
|
||||
const center = -59 + index * 29;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(left, -2, index === colors.length - 1 ? 73 - left : 29.2, 4);
|
||||
context.beginPath();
|
||||
context.arc(center, -7, 2, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.lineWidth = 1;
|
||||
context.stroke();
|
||||
context.fillStyle = "#222222";
|
||||
context.font = "4px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText(`0${index + 1}`, center, 8);
|
||||
});
|
||||
return;
|
||||
}
|
||||
context.fillStyle = "#ffffff";
|
||||
context.beginPath();
|
||||
context.moveTo(-78, -9);
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const y = -9 + index * 4.5;
|
||||
context.lineTo(-74, y + 2.25);
|
||||
context.lineTo(-78, y + 4.5);
|
||||
}
|
||||
context.lineTo(-70, 9);
|
||||
context.lineTo(-70, -9);
|
||||
context.closePath();
|
||||
context.fill();
|
||||
context.fillStyle = "#222222";
|
||||
context.fillRect(-72, -9, 17, 18);
|
||||
context.fillStyle = "#ffffff";
|
||||
context.font = "700 5px Arial, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
context.fillText("C", -63.5, 0);
|
||||
const stripColors = [...colors, "#f2f2f2"];
|
||||
stripColors.forEach((color, index) => {
|
||||
const left = -55 + index * (133 / stripColors.length);
|
||||
context.fillStyle = color;
|
||||
context.fillRect(left, -9, index === stripColors.length - 1 ? 78 - left : 133 / stripColors.length, 18);
|
||||
});
|
||||
}
|
||||
@@ -4095,6 +4095,54 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ReverseGeocodeRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"latitude": {
|
||||
"maximum": 90,
|
||||
"minimum": -90,
|
||||
"type": "number"
|
||||
},
|
||||
"longitude": {
|
||||
"maximum": 180,
|
||||
"minimum": -180,
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"latitude",
|
||||
"longitude"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ReverseGeocodeResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"formatted_value": {
|
||||
"maxLength": 200,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"service_mode": {
|
||||
"enum": [
|
||||
"mock"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"enum": [
|
||||
"resolved"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"formatted_value",
|
||||
"service_mode",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SseEvent": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -9886,6 +9934,65 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/location/reverse-geocode": {
|
||||
"post": {
|
||||
"operationId": "reverseGeocodeLocation",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "header",
|
||||
"name": "x-csrf-token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"maxLength": 64,
|
||||
"minLength": 43,
|
||||
"pattern": "^[A-Za-z0-9_-]+$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReverseGeocodeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ReverseGeocodeResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"400": {
|
||||
"description": "Default Response"
|
||||
},
|
||||
"401": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ErrorEnvelope"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Default Response"
|
||||
},
|
||||
"503": {
|
||||
"description": "Default Response"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Location"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/v1/me/credit-ledger": {
|
||||
"get": {
|
||||
"operationId": "getMyCreditLedger",
|
||||
|
||||
+4
-2
@@ -14,7 +14,7 @@
|
||||
"test:integration": "vitest run tests/integration",
|
||||
"test:api": "pnpm check:openapi && vitest run tests/api",
|
||||
"test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts --config playwright.config.ts",
|
||||
"test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts tests/e2e/generation-terminal-actions.spec.ts tests/e2e/project-latest-exports.spec.ts tests/e2e/admin-models.spec.ts tests/e2e/wp4-01-editor-background.spec.ts tests/e2e/wp4-02-editor-elements.spec.ts tests/e2e/wp4-03-text-editor.spec.ts tests/e2e/wp4-04-color-dynamic.spec.ts --config playwright.config.ts",
|
||||
"test:visual": "node scripts/validate-layer-scope.mjs VISUAL",
|
||||
"test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE",
|
||||
"test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs",
|
||||
@@ -79,7 +79,9 @@
|
||||
"test:wp4-02": "node scripts/run-wp4-02-validation.mjs",
|
||||
"test:wp4-02:red": "node scripts/run-wp4-02-validation.mjs --phase red",
|
||||
"test:wp4-03": "node scripts/run-wp4-03-validation.mjs",
|
||||
"test:wp4-03:red": "node scripts/run-wp4-03-validation.mjs --phase red"
|
||||
"test:wp4-03:red": "node scripts/run-wp4-03-validation.mjs --phase red",
|
||||
"test:wp4-04": "node scripts/run-wp4-04-validation.mjs",
|
||||
"test:wp4-04:red": "node scripts/run-wp4-04-validation.mjs --phase red"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.0",
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./canvas.js";
|
||||
export * from "./credits.js";
|
||||
export * from "./events.js";
|
||||
export * from "./generations.js";
|
||||
export * from "./location.js";
|
||||
export * from "./projects.js";
|
||||
export * from "./registration-notice.js";
|
||||
export * from "./models.js";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Type, type Static } from "@sinclair/typebox";
|
||||
|
||||
export const ReverseGeocodeRequestSchema = Type.Object({
|
||||
latitude: Type.Number({ maximum: 90, minimum: -90 }),
|
||||
longitude: Type.Number({ maximum: 180, minimum: -180 }),
|
||||
}, { additionalProperties: false, $id: "ReverseGeocodeRequest" });
|
||||
|
||||
export const ReverseGeocodeResponseSchema = Type.Object({
|
||||
formatted_value: Type.String({ maxLength: 200, minLength: 1 }),
|
||||
service_mode: Type.Literal("mock"),
|
||||
status: Type.Literal("resolved"),
|
||||
}, { additionalProperties: false, $id: "ReverseGeocodeResponse" });
|
||||
|
||||
export type ReverseGeocodeRequest = Static<typeof ReverseGeocodeRequestSchema>;
|
||||
export type ReverseGeocodeResponse = Static<typeof ReverseGeocodeResponseSchema>;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { chromium } from "@playwright/test";
|
||||
|
||||
const baseUrl = process.env.DADA_MANUAL_PREVIEW_URL ?? "http://127.0.0.1:43122/app/projects/00000000-0000-4000-8000-000000000904/editor";
|
||||
const output = resolve(process.env.DADA_COMPARISON_DIR ?? "artifacts/manual-review/wp4-04-original-comparison");
|
||||
mkdirSync(output, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch({ channel: "msedge" });
|
||||
const page = await browser.newPage({ viewport: { height: 1200, width: 1500 } });
|
||||
await page.goto(baseUrl);
|
||||
await page.getByText("原版贴纸人工检查").waitFor();
|
||||
|
||||
async function capture(tab, filename, waitForFonts = false) {
|
||||
await page.getByRole("button", { name: tab, exact: true }).click();
|
||||
if (waitForFonts) {
|
||||
const ids = ["15974853bc3294ef68e7e6d58fe74fd7", "46f8336813e4c48d06a1aef294fdccf6", "53ca6b704728520da50c145eabb2e635", "cca5efc0e02fb1bf62349bd68ef30fc1", "dd25b35dcb7ba4476cbaa9a9592e39e2", "e4210c9872f0c279b35273f230809821", "f4bfd4132df2d6be97ceabadf3853505", "FONT081"];
|
||||
await page.waitForFunction((fontIds) => fontIds.every((id) => document.fonts.check(`16px "Dada_${id}"`)), ids, { timeout: 15_000 });
|
||||
}
|
||||
await page.waitForTimeout(800);
|
||||
const panel = page.locator(".editor-assets-panel");
|
||||
await panel.evaluate((element) => {
|
||||
const node = element;
|
||||
node.style.height = "max-content";
|
||||
node.style.maxHeight = "none";
|
||||
node.style.overflow = "visible";
|
||||
});
|
||||
await panel.screenshot({ path: resolve(output, filename) });
|
||||
}
|
||||
|
||||
await capture("普通贴纸", "web-ordinary.png");
|
||||
await capture("色卡", "web-colors.png");
|
||||
await capture("动态贴纸", "web-dynamic.png", true);
|
||||
await browser.close();
|
||||
console.log(output);
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
HOME = Path.home()
|
||||
DYNAMIC_ROOT = Path(os.environ.get("DADA_DYNAMIC_ASSET_ROOT", HOME / "Desktop" / "sticker_interactive" / "单模板归档" / "templates"))
|
||||
STICKER_ROOT = Path(os.environ.get("DADA_STATIC_STICKER_ROOT", HOME / "Desktop" / "贴纸素材"))
|
||||
COLOR_ROOT = Path(os.environ.get("DADA_COLOR_ASSET_ROOT", HOME / "Desktop" / "sticker_colour" / "单模板归档" / "styles"))
|
||||
TEXT_ROOT = Path(os.environ.get("DADA_TEXT_ASSET_ROOT", HOME / "Desktop" / "sticker_text"))
|
||||
UI_FONT = Path("C:/Windows/Fonts/msyh.ttc")
|
||||
FONT081 = TEXT_ROOT / "字体" / "面板全量采集" / "font_panel_full_20260722" / "resources" / "font_packages" / "FONT081_Lexend Deca" / "font_files" / "02034l0o6r57rxed4027b5689e0dxe7e142r0vi8920akeqto.ttf"
|
||||
|
||||
|
||||
def ui_font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont:
|
||||
candidate = Path("C:/Windows/Fonts/msyhbd.ttc") if bold else UI_FONT
|
||||
return ImageFont.truetype(candidate, size)
|
||||
|
||||
|
||||
FONT_PATHS = {
|
||||
"15974853bc3294ef68e7e6d58fe74fd7": DYNAMIC_ROOT / "DYN002" / "fonts" / "15974853bc3294ef68e7e6d58fe74fd7" / "0202b90o6r57rxed4027b5689e0dxe7e142r0yho9d0lljuqj.ttf",
|
||||
"46f8336813e4c48d06a1aef294fdccf6": DYNAMIC_ROOT / "DYN016" / "fonts" / "46f8336813e4c48d06a1aef294fdccf6" / "9fbfbb420cea1df916d7c7c7ac90b1c88b61e117-PingFang-SC-Semibold-2.ttf",
|
||||
"53ca6b704728520da50c145eabb2e635": DYNAMIC_ROOT / "DYN007" / "fonts" / "53ca6b704728520da50c145eabb2e635" / "fab6c26a0b21d5e9b57fb5238843ac1fb77a2ce6-HYZhengYuan.ttf",
|
||||
"cca5efc0e02fb1bf62349bd68ef30fc1": DYNAMIC_ROOT / "DYN015" / "fonts" / "cca5efc0e02fb1bf62349bd68ef30fc1" / "e11ced673fc7e63e8b0b4730166d29845d8bebae-NotoSansCJKsc-Regular.otf",
|
||||
"dd25b35dcb7ba4476cbaa9a9592e39e2": DYNAMIC_ROOT / "DYN001" / "fonts" / "dd25b35dcb7ba4476cbaa9a9592e39e2" / "0202b90o6r57rxed4027b5689e0dxe7e142r0ygbx80porvko.ttf",
|
||||
"e4210c9872f0c279b35273f230809821": DYNAMIC_ROOT / "DYN011" / "fonts" / "e4210c9872f0c279b35273f230809821" / "06b980259e2104e1211a6819a61bc5ddeca77dcb-DJB-Get-Digital-1.ttf",
|
||||
"f4bfd4132df2d6be97ceabadf3853505": DYNAMIC_ROOT / "DYN008" / "fonts" / "f4bfd4132df2d6be97ceabadf3853505" / "6ce05a147aedabbb610d9cb3e75bbe60c064c3f5-BarlowCondensed-SemiBold.ttf",
|
||||
"FONT081": FONT081,
|
||||
}
|
||||
|
||||
|
||||
MODELS = {
|
||||
"DYN001": ((130, 42), [("image28.png", -17.562, 2.203, 219, 67)], [("温州", 0, 0, 21, "dd25b35dcb7ba4476cbaa9a9592e39e2", "#FFFFFF", "mm")]),
|
||||
"DYN002": ((215, 48), [("image29.png", 94.5, 0.5, 232, 77)], [("温州", -94.5, 10.5, 19, "15974853bc3294ef68e7e6d58fe74fd7", "#FFFFFF", "lm"), ("WENZHOU", 0, 36, 10, "15974853bc3294ef68e7e6d58fe74fd7", "#FFFFFF", "lm")]),
|
||||
"DYN003": ((215, 54), [("image30.png", -93.857, -23.635, 235, 85)], [("温州", -54, 9, 18, "dd25b35dcb7ba4476cbaa9a9592e39e2", "#FFFFFF", "lm"), ("温州", -53, -16, 24, "dd25b35dcb7ba4476cbaa9a9592e39e2", "#FFFFFF", "lm")]),
|
||||
"DYN004": ((180, 48), [("image32.png", -21.067, -1, 39, 76)], [("温州", -28, 0, 26, "dd25b35dcb7ba4476cbaa9a9592e39e2", "#FFFFFF", "lm"), ("27°59'39.48\"N", -29.844, 36, 14, "15974853bc3294ef68e7e6d58fe74fd7", "#FFFFFF", "lm"), ("120°41'57.84\"E", 71.523, 36, 14, "15974853bc3294ef68e7e6d58fe74fd7", "#FFFFFF", "lm")]),
|
||||
"DYN007": ((190, 34), [], [("@dada", 0, 0, 50, "53ca6b704728520da50c145eabb2e635", "#295E8B", "mm")]),
|
||||
"DYN008": ((140, 180), [("backendui0.png", -125, 0, 5, 185)], [("Aug.", -112, -32, 70, "f4bfd4132df2d6be97ceabadf3853505", "#FFFFFF", "lm"), ("09:07", -114.024, 97, 130, "f4bfd4132df2d6be97ceabadf3853505", "#FFFFFF", "lm")]),
|
||||
"DYN011": ((130, 40), [("backendui0.png", -120, -19, 8, 12)], [("26", -85, 0, 64, "e4210c9872f0c279b35273f230809821", "#FF6D2F", "mm"), ("08", 0, 0, 64, "e4210c9872f0c279b35273f230809821", "#FF6D2F", "mm"), ("03", 85, 0, 64, "e4210c9872f0c279b35273f230809821", "#FF6D2F", "mm")]),
|
||||
"DYN012": ((118, 78), [], [("09", -60, 0, 100, "FONT081", "#FFFFFF", "mm"), ("07", 60, 0, 100, "FONT081", "#FFFFFF", "mm"), ("AM", 94, -65, 20, "FONT081", "#FFFFFF", "mm")]),
|
||||
"DYN015": ((300, 42), [("imager2_2.png", -140, 0, 60, 60)], [("Dada Creator", -100, 0, 40, "cca5efc0e02fb1bf62349bd68ef30fc1", "#666666", "lm")]),
|
||||
"DYN016": ((160, 48), [("image21.png", -61.842, 16.483, 186, 59)], [("@dada", 0, 0, 27, "46f8336813e4c48d06a1aef294fdccf6", "#FFFFFF", "lm")]),
|
||||
}
|
||||
|
||||
|
||||
def crop_rgba(image: Image.Image, padding: int = 8) -> Image.Image:
|
||||
bbox = image.getbbox()
|
||||
if not bbox:
|
||||
return image
|
||||
left, top, right, bottom = bbox
|
||||
return image.crop((max(0, left - padding), max(0, top - padding), min(image.width, right + padding), min(image.height, bottom + padding)))
|
||||
|
||||
|
||||
def render_dynamic(template_id: str) -> Image.Image:
|
||||
(half_width, half_height), image_layers, text_layers = MODELS[template_id]
|
||||
logical = Image.new("RGBA", (half_width * 2 + 40, half_height * 2 + 40), (0, 0, 0, 0))
|
||||
center = (logical.width / 2, logical.height / 2)
|
||||
for filename, x, y, width, height in image_layers:
|
||||
source = Image.open(DYNAMIC_ROOT / template_id / "resource" / filename).convert("RGBA").resize((width, height), Image.Resampling.LANCZOS)
|
||||
logical.alpha_composite(source, (round(center[0] + x - width / 2), round(center[1] + y - height / 2)))
|
||||
draw = ImageDraw.Draw(logical)
|
||||
if template_id == "DYN012":
|
||||
draw.rectangle((center[0] - 3.5, center[1] - 21, center[0] + 3.5, center[1] + 21), fill="#FFFFFF")
|
||||
for value, x, y, size, font_id, color, anchor in text_layers:
|
||||
draw.text((center[0] + x, center[1] + y), value, font=ImageFont.truetype(FONT_PATHS[font_id], size), fill=color, anchor=anchor)
|
||||
return crop_rgba(logical)
|
||||
|
||||
|
||||
def sheet(title: str, items: list[tuple[str, Image.Image]], output: Path, row_height: int = 140) -> None:
|
||||
width = 420
|
||||
image = Image.new("RGB", (width, 62 + row_height * len(items)), "#F4F5F7")
|
||||
draw = ImageDraw.Draw(image)
|
||||
draw.text((20, 18), title, font=ui_font(24, True), fill="#111111")
|
||||
for index, (label, item) in enumerate(items):
|
||||
top = 62 + index * row_height
|
||||
draw.rounded_rectangle((12, top + 6, width - 12, top + row_height - 6), radius=4, fill="#30343B")
|
||||
fitted = item.copy()
|
||||
fitted.thumbnail((330, row_height - 48), Image.Resampling.LANCZOS)
|
||||
image.paste(fitted, ((width - fitted.width) // 2, top + 12), fitted if fitted.mode == "RGBA" else None)
|
||||
draw.text((20, top + row_height - 30), label, font=ui_font(16, True), fill="#FFFFFF")
|
||||
image.save(output)
|
||||
|
||||
|
||||
def build_sources(output: Path) -> None:
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
ordinary = [
|
||||
("STK001 · part1 原版草莓", crop_rgba(Image.open(STICKER_ROOT / "sticker_part1" / "01af6384c2a962d17f55736f9895b505.png").convert("RGBA"))),
|
||||
("STK002 · part2 原版小狗", crop_rgba(Image.open(STICKER_ROOT / "sticker_part2" / "011db0fb6ac4184e4a708374003bce66.png").convert("RGBA"))),
|
||||
]
|
||||
colors = [(color_id, crop_rgba(Image.open(COLOR_ROOT / color_id / "preview.png").convert("RGBA"))) for color_id in ("COLOR001", "COLOR002", "COLOR008", "COLOR016")]
|
||||
dynamics = [(template_id + (" · 原字体缺失,同用 FONT081" if template_id == "DYN012" else ""), render_dynamic(template_id)) for template_id in MODELS]
|
||||
sheet("原始归档 PNG", ordinary, output / "source-ordinary.png", 190)
|
||||
sheet("原始归档 renderer_source.py 预览", colors, output / "source-colors.png", 150)
|
||||
sheet("原始 Prefab / PNG / 字体参数还原", dynamics, output / "source-dynamic.png", 150)
|
||||
|
||||
|
||||
def compose(source: Path, web: Path, destination: Path, title: str) -> None:
|
||||
left = Image.open(source).convert("RGB")
|
||||
right = Image.open(web).convert("RGB")
|
||||
column_width = 500
|
||||
left.thumbnail((column_width - 24, 2200), Image.Resampling.LANCZOS)
|
||||
right.thumbnail((column_width - 24, 2200), Image.Resampling.LANCZOS)
|
||||
height = max(left.height, right.height) + 108
|
||||
result = Image.new("RGB", (column_width * 2, height), "#ECEEF1")
|
||||
draw = ImageDraw.Draw(result)
|
||||
draw.text((24, 18), title, font=ui_font(28, True), fill="#111111")
|
||||
draw.text((24, 62), "左:原始归档参考", font=ui_font(18, True), fill="#434A54")
|
||||
draw.text((column_width + 24, 62), "右:当前网页实拍", font=ui_font(18, True), fill="#434A54")
|
||||
result.paste(left, ((column_width - left.width) // 2, 98))
|
||||
result.paste(right, (column_width + (column_width - right.width) // 2, 98))
|
||||
result.save(destination)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--compose", action="store_true")
|
||||
args = parser.parse_args()
|
||||
build_sources(args.output)
|
||||
if args.compose:
|
||||
for kind, title in (("ordinary", "普通贴纸对比"), ("colors", "颜色卡对比"), ("dynamic", "动态贴纸对比")):
|
||||
compose(args.output / f"source-{kind}.png", args.output / f"web-{kind}.png", args.output / f"comparison-{kind}.png", title)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,112 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { createServer } from "vite";
|
||||
|
||||
const host = "127.0.0.1";
|
||||
const port = Number(process.env.DADA_MANUAL_PREVIEW_PORT ?? 43122);
|
||||
const projectId = "00000000-0000-4000-8000-000000000904";
|
||||
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_interactive", "单模板归档", "templates");
|
||||
const stickerRoot = process.env.DADA_STATIC_STICKER_ROOT ?? join(homedir(), "Desktop", "贴纸素材");
|
||||
const textRoot = process.env.DADA_TEXT_ASSET_ROOT ?? join(homedir(), "Desktop", "sticker_text");
|
||||
|
||||
const publicAssets = {
|
||||
STK001: [join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"), "image/png"],
|
||||
STK002: [join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"), "image/png"],
|
||||
"15974853bc3294ef68e7e6d58fe74fd7": [join(dynamicRoot, "DYN002", "fonts", "15974853bc3294ef68e7e6d58fe74fd7", "0202b90o6r57rxed4027b5689e0dxe7e142r0yho9d0lljuqj.ttf"), "font/ttf"],
|
||||
"46f8336813e4c48d06a1aef294fdccf6": [join(dynamicRoot, "DYN016", "fonts", "46f8336813e4c48d06a1aef294fdccf6", "9fbfbb420cea1df916d7c7c7ac90b1c88b61e117-PingFang-SC-Semibold-2.ttf"), "font/ttf"],
|
||||
"53ca6b704728520da50c145eabb2e635": [join(dynamicRoot, "DYN007", "fonts", "53ca6b704728520da50c145eabb2e635", "fab6c26a0b21d5e9b57fb5238843ac1fb77a2ce6-HYZhengYuan.ttf"), "font/ttf"],
|
||||
"cca5efc0e02fb1bf62349bd68ef30fc1": [join(dynamicRoot, "DYN015", "fonts", "cca5efc0e02fb1bf62349bd68ef30fc1", "e11ced673fc7e63e8b0b4730166d29845d8bebae-NotoSansCJKsc-Regular.otf"), "font/otf"],
|
||||
"dd25b35dcb7ba4476cbaa9a9592e39e2": [join(dynamicRoot, "DYN001", "fonts", "dd25b35dcb7ba4476cbaa9a9592e39e2", "0202b90o6r57rxed4027b5689e0dxe7e142r0ygbx80porvko.ttf"), "font/ttf"],
|
||||
"e4210c9872f0c279b35273f230809821": [join(dynamicRoot, "DYN011", "fonts", "e4210c9872f0c279b35273f230809821", "06b980259e2104e1211a6819a61bc5ddeca77dcb-DJB-Get-Digital-1.ttf"), "font/ttf"],
|
||||
"f4bfd4132df2d6be97ceabadf3853505": [join(dynamicRoot, "DYN008", "fonts", "f4bfd4132df2d6be97ceabadf3853505", "6ce05a147aedabbb610d9cb3e75bbe60c064c3f5-BarlowCondensed-SemiBold.ttf"), "font/ttf"],
|
||||
"DYN001-image28": [join(dynamicRoot, "DYN001", "resource", "image28.png"), "image/png"],
|
||||
"DYN002-image29": [join(dynamicRoot, "DYN002", "resource", "image29.png"), "image/png"],
|
||||
"DYN003-image30": [join(dynamicRoot, "DYN003", "resource", "image30.png"), "image/png"],
|
||||
"DYN004-image32": [join(dynamicRoot, "DYN004", "resource", "image32.png"), "image/png"],
|
||||
"DYN008-backendui0": [join(dynamicRoot, "DYN008", "resource", "backendui0.png"), "image/png"],
|
||||
"DYN011-backendui0": [join(dynamicRoot, "DYN011", "resource", "backendui0.png"), "image/png"],
|
||||
"DYN015-imager2": [join(dynamicRoot, "DYN015", "resource", "imager2_2.png"), "image/png"],
|
||||
"DYN016-image21": [join(dynamicRoot, "DYN016", "resource", "image21.png"), "image/png"],
|
||||
FONT081: [join(textRoot, "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages", "FONT081_Lexend Deca", "font_files", "02034l0o6r57rxed4027b5689e0dxe7e142r0vi8920akeqto.ttf"), "font/ttf"],
|
||||
};
|
||||
|
||||
if (!Object.values(publicAssets).every(([path]) => existsSync(path))) throw new Error("WP4-04 original preview assets are unavailable.");
|
||||
|
||||
const session = {
|
||||
audience: "user", authenticated: true, credits: { available_balance: 10, reserved_balance: 0 },
|
||||
csrf_token: "csrf-wp4-manual-preview-0000000000000000000000000000", expires_at: "2026-09-03T08:00:00.000Z",
|
||||
user: { creator_name: "Dada Creator", role: "user", social_id: "@dada", status: "active", user_id: "00000000-0000-4000-8000-000000000901" },
|
||||
};
|
||||
const backgroundId = "00000000-0000-4000-8000-000000000902";
|
||||
let stateVersion = 1;
|
||||
let canvasState = {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: backgroundId },
|
||||
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
|
||||
const backgroundSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="1080" height="1440"><rect width="1080" height="1440" fill="#2b3038"/><rect x="0" y="0" width="216" height="1440" fill="#04d960"/><rect x="216" y="0" width="216" height="1440" fill="#0abf58"/><rect x="432" y="0" width="216" height="1440" fill="#5fd994"/><rect x="648" y="0" width="216" height="1440" fill="#a0f2c4"/><rect x="864" y="0" width="216" height="1440" fill="#d5f2e2"/><rect x="0" y="0" width="1080" height="1440" fill="#15181d" opacity=".58"/></svg>`;
|
||||
|
||||
function send(response, status, body, contentType) {
|
||||
response.statusCode = status;
|
||||
response.setHeader("Content-Type", contentType);
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendJson(response, value, status = 200) {
|
||||
send(response, status, JSON.stringify(value), "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
function readJson(request) {
|
||||
return new Promise((resolveBody, reject) => {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
try { resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch (error) { reject(error); }
|
||||
});
|
||||
request.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
const mockApi = {
|
||||
name: "wp4-manual-preview-api",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const url = new URL(request.url ?? "/", `http://${host}:${port}`);
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/auth/session") return sendJson(response, session);
|
||||
if (request.method === "GET" && url.pathname === "/api/v1/assets/recent") return sendJson(response, { items: [] });
|
||||
if (request.method === "GET" && url.pathname === `/api/v1/projects/${projectId}`) return sendJson(response, {
|
||||
canvas_state: canvasState, created_at: "2026-08-03T08:00:00.000Z", current_image_id: backgroundId,
|
||||
images: [{ created_at: "2026-08-03T08:00:00.000Z", generation_id: "00000000-0000-4000-8000-000000000903", image_id: backgroundId }],
|
||||
name: "原版贴纸人工检查", pixel_height: 1440, pixel_width: 1080, project_id: projectId, ratio: "3:4", state_version: stateVersion,
|
||||
});
|
||||
if (request.method === "PUT" && url.pathname === `/api/v1/projects/${projectId}/state`) {
|
||||
void readJson(request).then((body) => {
|
||||
canvasState = body.canvas_state;
|
||||
stateVersion += 1;
|
||||
sendJson(response, { save_status: "saved", state_version: stateVersion });
|
||||
}).catch(() => sendJson(response, { error: "invalid_state" }, 400));
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === `/api/v1/private-assets/projects/${projectId}/images/${backgroundId}`) return send(response, 200, backgroundSvg, "image/svg+xml");
|
||||
if (request.method === "POST" && url.pathname === "/api/v1/location/reverse-geocode") return sendJson(response, { formatted_value: "浙江省温州市", service_mode: "mock", status: "resolved" });
|
||||
const publicMatch = url.pathname.match(/^\/api\/v1\/assets\/public\/([^/]+)\/([^/]+)$/);
|
||||
if (request.method === "GET" && publicMatch) {
|
||||
const assetId = decodeURIComponent(publicMatch[2]);
|
||||
const asset = publicAssets[assetId];
|
||||
if (!asset) return sendJson(response, { error: "not_found" }, 404);
|
||||
return send(response, 200, readFileSync(asset[0]), asset[1]);
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
const vite = await createServer({
|
||||
configFile: resolve("apps/web/vite.config.ts"), plugins: [mockApi], root: resolve("apps/web"),
|
||||
server: { host, port, strictPort: true },
|
||||
});
|
||||
await vite.listen();
|
||||
console.log(`WP4-04 manual preview: http://${host}:${port}/app/projects/${projectId}/editor`);
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => { void vite.close().finally(() => process.exit(0)); });
|
||||
@@ -0,0 +1,142 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const phaseIndex = process.argv.indexOf("--phase");
|
||||
const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green";
|
||||
if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`);
|
||||
const manualReviewPassed = phase === "green" && process.env.DADA_WP4_04_MANUAL_REVIEW === "passed";
|
||||
const manualReviewNote = process.env.DADA_WP4_04_MANUAL_REVIEW_NOTE?.trim() ?? "";
|
||||
const manualComparisonRoot = resolve(process.env.DADA_WP4_04_COMPARISON_DIR ?? "artifacts/manual-review/wp4-04-original-comparison");
|
||||
const runId = process.env.DADA_TDD_RUN_ID ?? `wp4-04-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`;
|
||||
const runDirectory = resolve("artifacts", "tdd", runId);
|
||||
const casesDirectory = resolve(runDirectory, "cases");
|
||||
if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`);
|
||||
mkdirSync(casesDirectory, { recursive: true });
|
||||
|
||||
const cases = [
|
||||
{ acceptance_criteria: ["AC-15", "AC-16"], evidence: ["palette.json", "network-timeline.json", "pixel-diff.json", "db-diff.json", "trace.zip"], id: "TDD-WP4-COL-001-deterministic-palette", manual: true, requirements: ["COL-01", "COL-02", "COL-03", "COL-04", "COL-05"] },
|
||||
{ acceptance_criteria: ["AC-14", "AC-16"], evidence: ["canvas-state.json", "clock-trace.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-DYN-001-fixed-time", manual: false, requirements: ["DYN-01", "DYN-02", "DYN-03", "DYN-07"] },
|
||||
{ acceptance_criteria: ["AC-17", "AC-47"], evidence: ["external-calls.json", "canvas-state.json", "db-diff.json", "trace.zip"], id: "TDD-WP4-DYN-002-location-consent", manual: false, requirements: ["DYN-04", "DYN-05", "PRIV-03"] },
|
||||
{ acceptance_criteria: ["AC-18", "AC-19"], evidence: ["canvas-state.json", "db-diff.json", "font-load.json", "pixel-diff.json", "trace.zip"], id: "TDD-WP4-DYN-003-identity-font", manual: true, requirements: ["DYN-06", "DYN-07", "DYN-08", "DYN-09"] },
|
||||
];
|
||||
for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true });
|
||||
|
||||
const outputDirectory = resolve(runDirectory, "playwright-output");
|
||||
const sourceEvidence = resolve(runDirectory, "source-validation.json");
|
||||
const fontEvidence = resolve(runDirectory, "font-source.json");
|
||||
const environment = {
|
||||
...process.env,
|
||||
DADA_EVIDENCE_DIR_COLOR_DYNAMIC: casesDirectory,
|
||||
DADA_FONT_SOURCE_EVIDENCE: fontEvidence,
|
||||
DADA_PLAYWRIGHT_OUTPUT_DIR: outputDirectory,
|
||||
DADA_WP4_04_SOURCE_EVIDENCE: sourceEvidence,
|
||||
};
|
||||
const commands = phase === "red" ? [] : [
|
||||
["source", "pnpm exec node scripts/verify-wp4-04-source.mjs"],
|
||||
["font-source", "pnpm exec node scripts/verify-wp4-03-font-source.mjs"],
|
||||
["unit", "pnpm test:unit"],
|
||||
["api", "pnpm test:api"],
|
||||
["e2e", "pnpm test:e2e"],
|
||||
["visual", "pnpm test:visual"],
|
||||
["performance", "pnpm test:performance"],
|
||||
["tdd-trace", "pnpm validate:tdd-trace"],
|
||||
];
|
||||
const commandResults = [];
|
||||
for (const [name, command] of commands) {
|
||||
const started_at = new Date().toISOString();
|
||||
const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment });
|
||||
if (result.stdout) process.stdout.write(result.stdout);
|
||||
if (result.stderr) process.stderr.write(result.stderr);
|
||||
commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at });
|
||||
}
|
||||
|
||||
function findTraces(directory) {
|
||||
const traces = [];
|
||||
if (!existsSync(directory)) return traces;
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isDirectory()) traces.push(...findTraces(path));
|
||||
else if (entry.name === "trace.zip") traces.push(path);
|
||||
}
|
||||
return traces;
|
||||
}
|
||||
if (phase === "green") {
|
||||
const traces = findTraces(outputDirectory);
|
||||
const mappings = ["only-for-a-new-background", "clock-changes-and-reopen", "preserves-manual-fallback", "DYN012-FONT081-substitution"];
|
||||
mappings.forEach((needle, index) => {
|
||||
const trace = traces.find((path) => path.includes(needle));
|
||||
if (trace) copyFileSync(trace, resolve(casesDirectory, cases[index].id, "trace.zip"));
|
||||
});
|
||||
const fontLoadPath = resolve(casesDirectory, cases[3].id, "font-load.json");
|
||||
if (existsSync(fontLoadPath) && existsSync(fontEvidence)) {
|
||||
const browser = JSON.parse(readFileSync(fontLoadPath, "utf8"));
|
||||
const archive = JSON.parse(readFileSync(fontEvidence, "utf8"));
|
||||
writeFileSync(fontLoadPath, `${JSON.stringify({ ...browser, archive_verification: archive }, null, 2)}\n`);
|
||||
}
|
||||
if (manualReviewPassed) {
|
||||
const comparisonByCase = {
|
||||
"TDD-WP4-COL-001-deterministic-palette": "comparison-colors.png",
|
||||
"TDD-WP4-DYN-003-identity-font": "comparison-dynamic.png",
|
||||
};
|
||||
for (const item of cases.filter((candidate) => candidate.manual)) {
|
||||
const comparison = comparisonByCase[item.id];
|
||||
const source = resolve(manualComparisonRoot, comparison);
|
||||
if (!existsSync(source)) throw new Error(`Manual comparison evidence is unavailable: ${comparison}`);
|
||||
copyFileSync(source, resolve(casesDirectory, item.id, comparison));
|
||||
writeFileSync(resolve(casesDirectory, item.id, "manual-review.json"), `${JSON.stringify({
|
||||
comparison_evidence: comparison,
|
||||
note: manualReviewNote || "No material visual issue reported by the project owner.",
|
||||
reviewed_at: new Date().toISOString(),
|
||||
reviewer: "project_owner",
|
||||
source: "interactive_local_preview",
|
||||
status: "passed",
|
||||
}, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commandState = phase === "red" ? true : commandResults.every((result) => result.exit_code === 0);
|
||||
const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() };
|
||||
const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim();
|
||||
const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0;
|
||||
if (phase === "red") {
|
||||
const observation = {
|
||||
expected_failure: "Palette provider, dynamic provider, consent gate and enabled editor entries were absent before TASK-WP4-04",
|
||||
observed_commands: ["pnpm vitest run tests/unit/wp4-04-palette-dynamic.test.ts", "pnpm playwright test tests/e2e/wp4-04-color-dynamic.spec.ts --grep DYN-002"],
|
||||
observed_errors: ["Cannot find module palette-provider.js", "Cannot find module dynamic-provider.js", "动态贴纸 button was disabled"],
|
||||
status: "red_confirmed",
|
||||
};
|
||||
for (const item of cases) writeFileSync(resolve(casesDirectory, item.id, "red-observation.json"), `${JSON.stringify(observation, null, 2)}\n`);
|
||||
}
|
||||
|
||||
const summaries = [];
|
||||
for (const item of cases) {
|
||||
const directory = resolve(casesDirectory, item.id);
|
||||
const manualEvidence = item.manual && manualReviewPassed
|
||||
? ["manual-review.json", item.id.startsWith("TDD-WP4-COL") ? "comparison-colors.png" : "comparison-dynamic.png"]
|
||||
: [];
|
||||
const evidenceRefs = phase === "red" ? ["red-observation.json"] : [...item.evidence, ...manualEvidence];
|
||||
const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file)));
|
||||
const automatedStatus = commandState && missingEvidence.length === 0 ? phase === "red" ? "red_confirmed" : "passed" : "failed";
|
||||
const status = automatedStatus === "passed" && item.manual && !manualReviewPassed ? "awaiting_manual_review" : automatedStatus;
|
||||
writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||
writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({
|
||||
acceptance_criteria: item.acceptance_criteria, automation: item.manual ? ["automated", "manual_review"] : ["automated"], commit,
|
||||
evidence_refs: evidenceRefs, layer: ["UNIT", "E2E", "VIS-PERF", ...(item.manual ? ["MANUAL"] : [])],
|
||||
layer_notes: { MANUAL: item.manual ? manualReviewPassed ? "Passed by project owner using the local original-source comparison preview" : "Required by frozen known-alternative review; not yet performed" : "not_required_for_this_case", PERFORMANCE: "current root runner reports not_applicable for TASK-WP0-01", VISUAL: "focused E2E produces pixel evidence; current root runner reports not_applicable for TASK-WP0-01" },
|
||||
manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements, run_id: runId, status,
|
||||
task_id: "TASK-WP4-04", test_id: item.id, work_package: "WP-4", worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation",
|
||||
}, null, 2)}\n`);
|
||||
summaries.push({ manual_review_required: item.manual, missing_evidence: missingEvidence, status, test_id: item.id });
|
||||
}
|
||||
let status;
|
||||
if (phase === "red") status = summaries.every((item) => item.status === "red_confirmed") ? "red_confirmed" : "failed";
|
||||
else if (summaries.some((item) => item.status === "failed")) status = "failed";
|
||||
else if (summaries.some((item) => item.status === "awaiting_manual_review")) status = "awaiting_manual_review";
|
||||
else status = "passed";
|
||||
writeFileSync(resolve(runDirectory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`);
|
||||
writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ cases: summaries, phase, run_id: runId, status }, null, 2));
|
||||
if (status === "failed") process.exit(1);
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const colorRoot = process.env.DADA_COLOR_ASSET_ROOT ?? resolve(homedir(), "Desktop", "sticker_colour", "单模板归档", "styles");
|
||||
const dynamicRoot = process.env.DADA_DYNAMIC_ASSET_ROOT ?? resolve(homedir(), "Desktop", "sticker_interactive", "单模板归档", "templates");
|
||||
const colorIds = ["COLOR001", "COLOR002", "COLOR008", "COLOR016"];
|
||||
const dynamicIds = ["DYN001", "DYN002", "DYN003", "DYN004", "DYN007", "DYN008", "DYN011", "DYN012", "DYN015", "DYN016"];
|
||||
const expectedColorStyles = { COLOR001: "style_01", COLOR002: "style_02", COLOR008: "style_08", COLOR016: "style_16" };
|
||||
const expectedDynamicSources = {
|
||||
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",
|
||||
};
|
||||
const requiredDynamicKeys = {
|
||||
DYN001: ["title"], DYN002: ["city_en", "title"], DYN003: ["city", "title"], DYN004: ["city", "latitude", "longitude"],
|
||||
DYN007: ["nickname"], DYN008: ["hour", "minute", "month"],
|
||||
DYN011: ["day", "month", "year"], DYN012: ["hour", "minute"], DYN015: ["nickname"], DYN016: ["nickname"],
|
||||
};
|
||||
const paths = [
|
||||
...colorIds.map((id) => resolve(colorRoot, id, "metadata.json")),
|
||||
...dynamicIds.map((id) => resolve(dynamicRoot, id, "metadata.json")),
|
||||
];
|
||||
if (!paths.every(existsSync)) throw new Error("WP4-04 normative metadata is unavailable.");
|
||||
const hash = (bytes) => createHash("sha256").update(bytes).digest("hex").toUpperCase();
|
||||
const before = paths.map((path) => ({ bytes: readFileSync(path), mtime: statSync(path).mtimeMs }));
|
||||
const metadata = before.map((entry) => JSON.parse(entry.bytes.toString("utf8")));
|
||||
for (const id of colorIds) {
|
||||
const item = metadata.find((entry) => entry.canonical_id === id);
|
||||
if (!item || item.family !== "color_card" || item.resource_class !== "parameter_renderer"
|
||||
|| item.runtime?.stable_style_id !== expectedColorStyles[id]) throw new Error(`Color metadata verification failed for ${id}.`);
|
||||
}
|
||||
for (const id of dynamicIds) {
|
||||
const item = metadata.find((entry) => entry.canonical_id === id);
|
||||
if (!item || item.family !== "interactive_sticker" || item.resource_class !== "dynamic_resource"
|
||||
|| item.source_candidate_id !== expectedDynamicSources[id] || item.runtime?.has_dynamic_binding !== true
|
||||
|| !requiredDynamicKeys[id].every((key) => item.dynamic_keys.includes(key))
|
||||
|| ![...(item.files?.prefab ?? []), ...(item.files?.images ?? []), ...(item.files?.lua ?? [])]
|
||||
.every((relativePath) => existsSync(resolve(dynamicRoot, id, relativePath)))) {
|
||||
throw new Error(`Dynamic metadata verification failed for ${id}.`);
|
||||
}
|
||||
}
|
||||
const dyn012 = metadata.find((entry) => entry.canonical_id === "DYN012");
|
||||
const missingDin = dyn012.runtime.external_runtime_fonts?.some((entry) => entry.manifest_filename === "DIN_MediumAlternate.otf");
|
||||
if (!missingDin) throw new Error("DYN012 missing-font evidence is unavailable.");
|
||||
const unchanged = paths.every((path, index) => statSync(path).mtimeMs === before[index].mtime && hash(readFileSync(path)) === hash(before[index].bytes));
|
||||
if (!unchanged) throw new Error("Normative source changed during read-only verification.");
|
||||
const result = {
|
||||
color_ids: colorIds,
|
||||
color_metadata_sha256: hash(Buffer.concat(before.slice(0, colorIds.length).map((entry) => entry.bytes))),
|
||||
dyn012_original_font: "DIN_MediumAlternate.otf",
|
||||
dyn012_substitute_font: "FONT081 Lexend Deca",
|
||||
dynamic_ids: dynamicIds,
|
||||
dynamic_metadata_sha256: hash(Buffer.concat(before.slice(colorIds.length).map((entry) => entry.bytes))),
|
||||
lua_prefab_execution: false,
|
||||
source_access: "read_only_normative_metadata",
|
||||
source_unchanged: unchanged,
|
||||
status: "passed",
|
||||
};
|
||||
if (process.env.DADA_WP4_04_SOURCE_EVIDENCE) writeFileSync(process.env.DADA_WP4_04_SOURCE_EVIDENCE, `${JSON.stringify(result, null, 2)}\n`);
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import { createApp } from "../../apps/api/src/app.js";
|
||||
import { MockAmapAdapter } from "../../apps/api/src/amap-adapter.js";
|
||||
import { RegistrationService } from "../../apps/api/src/registration.js";
|
||||
import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js";
|
||||
|
||||
const resources: Array<{ close: () => Promise<void> | void }> = [];
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => {
|
||||
for (const resource of resources.splice(0).reverse()) await resource.close();
|
||||
for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
function registrationFixture() {
|
||||
const directory = mkdtempSync(join(tmpdir(), "dada-wp4-04-"));
|
||||
roots.push(directory);
|
||||
const registration = new RegistrationService({
|
||||
challengePepper: Buffer.alloc(32, 2), currentPrivacyNoticeVersion: "2026-07-24",
|
||||
databasePath: join(directory, "dada.sqlite3"), invitePepper: Buffer.alloc(32, 3), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 4),
|
||||
});
|
||||
resources.push({ close: () => registration.close() });
|
||||
const userId = "00000000-0000-4000-8000-000000000831";
|
||||
registration.database.prepare("INSERT INTO users (user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at) VALUES (?, 'location@example.invalid', 'user', 'active', 1, ?, ?)").run(userId, crypto.randomUUID(), Date.now());
|
||||
registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Location User', '@location')").run(userId);
|
||||
registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, Date.now());
|
||||
const session = registration.issueAuthenticatedSession(userId, "user");
|
||||
return { registration, session };
|
||||
}
|
||||
|
||||
describe("TASK-WP4-04 location adapter API", () => {
|
||||
it("requires mutation auth and forwards only coordinates to the local mock adapter", async () => {
|
||||
const fixture = registrationFixture();
|
||||
const amap = new MockAmapAdapter();
|
||||
const app = await createApp({ amap, browserGate: false, registration: fixture.registration });
|
||||
resources.push({ close: () => app.close() });
|
||||
const csrf = fixture.registration.issueUserCsrfToken(fixture.session.sessionToken);
|
||||
const response = await app.inject({
|
||||
headers: { cookie: `dada_session=${fixture.session.sessionToken}`, host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121", "x-csrf-token": csrf },
|
||||
method: "POST", payload: { latitude: 27.9943, longitude: 120.6994 }, url: "/api/v1/location/reverse-geocode",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ formatted_value: "模拟地点 27.9943, 120.6994", service_mode: "mock", status: "resolved" });
|
||||
expect(amap.calls).toEqual([{ latitude: 27.9943, longitude: 120.6994 }]);
|
||||
const anonymous = await app.inject({ headers: { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121", "x-csrf-token": "anonymous-csrf-fixture-000000000000000000000000" }, method: "POST", payload: { latitude: 27.9943, longitude: 120.6994 }, url: "/api/v1/location/reverse-geocode" });
|
||||
expect(anonymous.statusCode).toBe(401);
|
||||
expect(amap.calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,12 @@ function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
async function routeEditor(page: Page) {
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({ body: JSON.stringify(projectPayload()), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/**`, (route) => route.fulfill({ body: Buffer.from("not-an-image"), contentType: "image/png", status: 200 }));
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/**`, (route) => {
|
||||
const alternate = route.request().url().endsWith(alternateImageId);
|
||||
const colors = alternate ? ["#102030", "#405060", "#708090", "#A0B0C0", "#D0E0F0"] : ["#F42020", "#20D260", "#1860DC", "#F8D230", "#7840B4"];
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250">${colors.map((color, index) => `<rect x="${index * 50}" width="50" height="250" fill="${color}"/>`).join("")}</svg>`;
|
||||
return route.fulfill({ body: svg, contentType: "image/svg+xml", status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
test("TDD-WP4-BG-001 preserves overlays while switching the background", async ({ page }) => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
@@ -25,6 +26,12 @@ 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 originalStickerFixtures: Readonly<Record<string, string>> = {
|
||||
STK001: join(stickerRoot, "sticker_part1", "01af6384c2a962d17f55736f9895b505.png"),
|
||||
STK002: join(stickerRoot, "sticker_part2", "011db0fb6ac4184e4a708374003bce66.png"),
|
||||
};
|
||||
|
||||
function uuid(index: number) {
|
||||
return `00000000-0000-4000-8000-${String(index).padStart(12, "0")}`;
|
||||
}
|
||||
@@ -53,6 +60,7 @@ function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
}
|
||||
|
||||
async function routeEditor(page: Page, projectId: string, backend: { canvas: CanvasState; saves: number; version: number }) {
|
||||
if (!Object.values(originalStickerFixtures).every(existsSync)) throw new Error("Archived ordinary sticker fixture is unavailable.");
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json", status: 200 }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
@@ -68,6 +76,12 @@ async function routeEditor(page: Page, projectId: string, backend: { canvas: Can
|
||||
backend.version += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json", status: 200 });
|
||||
});
|
||||
await page.route("**/api/v1/assets/public/fixture-v1/*", (route) => {
|
||||
const assetId = decodeURIComponent(route.request().url().split("/").at(-1)!);
|
||||
const source = originalStickerFixtures[assetId];
|
||||
if (!source) return route.fulfill({ status: 404 });
|
||||
return route.fulfill({ body: readFileSync(source), contentType: "image/png", status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
test("TDD-WP4-CAN-001 keeps fifty elements editable and blocks the fifty-first", async ({ page }) => {
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import { createServer, type ViteDevServer } from "vite";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
let vite: ViteDevServer;
|
||||
let webUrl: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
vite = await createServer({ configFile: resolve("apps/web/vite.config.ts"), root: resolve("apps/web"), server: { host: "127.0.0.1", port: 0 } });
|
||||
await vite.listen();
|
||||
const address = vite.httpServer?.address();
|
||||
if (!address || typeof address === "string") throw new Error("Vite did not expose a test port.");
|
||||
webUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
test.afterAll(async () => vite.close());
|
||||
|
||||
const userId = "00000000-0000-4000-8000-000000000821";
|
||||
const session = {
|
||||
csrf_token: "csrf-wp4-04-fixture-0000000000000000000000000000",
|
||||
user: { creator_name: "Dada Creator", social_id: "@@dada", user_id: userId },
|
||||
};
|
||||
|
||||
const rawImages: Record<string, string[]> = {
|
||||
"00000000-0000-4000-8000-000000000811": ["#F42020", "#20D260", "#1860DC", "#F8D230", "#7840B4"],
|
||||
"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 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") },
|
||||
"53ca6b704728520da50c145eabb2e635": { contentType: "font/ttf", path: join(dynamicRoot, "DYN007", "fonts", "53ca6b704728520da50c145eabb2e635", "fab6c26a0b21d5e9b57fb5238843ac1fb77a2ce6-HYZhengYuan.ttf") },
|
||||
"cca5efc0e02fb1bf62349bd68ef30fc1": { contentType: "font/otf", path: join(dynamicRoot, "DYN015", "fonts", "cca5efc0e02fb1bf62349bd68ef30fc1", "e11ced673fc7e63e8b0b4730166d29845d8bebae-NotoSansCJKsc-Regular.otf") },
|
||||
"dd25b35dcb7ba4476cbaa9a9592e39e2": { contentType: "font/ttf", path: join(dynamicRoot, "DYN001", "fonts", "dd25b35dcb7ba4476cbaa9a9592e39e2", "0202b90o6r57rxed4027b5689e0dxe7e142r0ygbx80porvko.ttf") },
|
||||
"e4210c9872f0c279b35273f230809821": { contentType: "font/ttf", path: join(dynamicRoot, "DYN011", "fonts", "e4210c9872f0c279b35273f230809821", "06b980259e2104e1211a6819a61bc5ddeca77dcb-DJB-Get-Digital-1.ttf") },
|
||||
"f4bfd4132df2d6be97ceabadf3853505": { contentType: "font/ttf", path: join(dynamicRoot, "DYN008", "fonts", "f4bfd4132df2d6be97ceabadf3853505", "6ce05a147aedabbb610d9cb3e75bbe60c064c3f5-BarlowCondensed-SemiBold.ttf") },
|
||||
"DYN001-image28": { contentType: "image/png", path: join(dynamicRoot, "DYN001", "resource", "image28.png") },
|
||||
"DYN002-image29": { contentType: "image/png", path: join(dynamicRoot, "DYN002", "resource", "image29.png") },
|
||||
"DYN003-image30": { contentType: "image/png", path: join(dynamicRoot, "DYN003", "resource", "image30.png") },
|
||||
"DYN004-image32": { contentType: "image/png", path: join(dynamicRoot, "DYN004", "resource", "image32.png") },
|
||||
"DYN008-backendui0": { contentType: "image/png", path: join(dynamicRoot, "DYN008", "resource", "backendui0.png") },
|
||||
"DYN011-backendui0": { contentType: "image/png", path: join(dynamicRoot, "DYN011", "resource", "backendui0.png") },
|
||||
"DYN015-imager2": { contentType: "image/png", path: join(dynamicRoot, "DYN015", "resource", "imager2_2.png") },
|
||||
"DYN016-image21": { contentType: "image/png", path: join(dynamicRoot, "DYN016", "resource", "image21.png") },
|
||||
};
|
||||
const font081Path = join(textRoot, "字体", "面板全量采集", "font_panel_full_20260722", "resources", "font_packages", "FONT081_Lexend Deca", "font_files", "02034l0o6r57rxed4027b5689e0dxe7e142r0vi8920akeqto.ttf");
|
||||
|
||||
function rawSvg(colors: string[]) {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="250" height="250">${colors.map((color, index) => `<rect x="${index * 50}" y="0" width="50" height="250" fill="${color}"/>`).join("")}</svg>`;
|
||||
}
|
||||
|
||||
function emptyCanvas(assetId: string | null = null): CanvasState {
|
||||
return {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: assetId },
|
||||
elements: [], pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
interface Backend {
|
||||
canvas: CanvasState;
|
||||
saves: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
function writeEvidence(caseId: string, name: string, value: unknown) {
|
||||
const root = process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC;
|
||||
if (!root) return;
|
||||
const directory = resolve(root, caseId);
|
||||
mkdirSync(directory, { recursive: true });
|
||||
writeFileSync(resolve(directory, name), `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function routeEditor(page: Page, projectId: string, backend: Backend) {
|
||||
const sourceFiles = [...Object.values(dynamicSourceAssets).map((asset) => asset.path), font081Path];
|
||||
if (!sourceFiles.every(existsSync)) throw new Error("Archived dynamic source fixture is unavailable.");
|
||||
await page.route("**/api/v1/auth/session", (route) => route.fulfill({ body: JSON.stringify(session), contentType: "application/json" }));
|
||||
await page.route(`**/api/v1/projects/${projectId}`, (route) => route.fulfill({
|
||||
body: JSON.stringify({
|
||||
canvas_state: backend.canvas, created_at: "2026-08-03T04:00:00.000Z", current_image_id: backend.canvas.background.asset_id,
|
||||
images: Object.keys(rawImages).map((imageId, index) => ({ created_at: `2026-08-03T0${index + 4}:00:00.000Z`, generation_id: `00000000-0000-4000-8000-00000000081${index + 3}`, image_id: imageId })),
|
||||
name: "色卡动态画布", project_id: projectId, ratio: "3:4", state_version: backend.version,
|
||||
}), contentType: "application/json",
|
||||
}));
|
||||
await page.route(`**/api/v1/projects/${projectId}/state`, async (route) => {
|
||||
backend.canvas = (route.request().postDataJSON() as { canvas_state: CanvasState }).canvas_state;
|
||||
backend.saves += 1;
|
||||
backend.version += 1;
|
||||
await route.fulfill({ body: JSON.stringify({ save_status: "saved", state_version: backend.version }), contentType: "application/json" });
|
||||
});
|
||||
await page.route(`**/api/v1/private-assets/projects/${projectId}/images/*`, (route) => {
|
||||
const assetId = route.request().url().split("/").at(-1)!;
|
||||
const colors = rawImages[assetId];
|
||||
if (!colors) return route.fulfill({ status: 404 });
|
||||
return route.fulfill({ body: rawSvg(colors), contentType: "image/svg+xml", status: 200 });
|
||||
});
|
||||
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/public/wp4-fixture-v1/FONT081", (route) => route.fulfill({ body: readFileSync(font081Path), contentType: "font/ttf", status: 200 }));
|
||||
await page.route("**/api/v1/assets/public/wp4-dynamic-source-v1/*", (route) => {
|
||||
const assetId = decodeURIComponent(route.request().url().split("/").at(-1)!);
|
||||
const asset = dynamicSourceAssets[assetId];
|
||||
if (!asset) return route.fulfill({ status: 404 });
|
||||
return route.fulfill({ body: readFileSync(asset.path), contentType: asset.contentType, status: 200 });
|
||||
});
|
||||
}
|
||||
|
||||
async function stageInk(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 nonWhite = 0;
|
||||
for (let index = 0; index < pixels.length; index += 4) {
|
||||
if ((pixels[index] ?? 255) < 245 || (pixels[index + 1] ?? 255) < 245 || (pixels[index + 2] ?? 255) < 245) nonWhite += 1;
|
||||
}
|
||||
return { canvas_pixels: canvas.width * canvas.height, non_white_pixels: nonWhite };
|
||||
});
|
||||
}
|
||||
|
||||
async function nudgeSelected(page: Page, directions: Array<{ key: "ArrowDown" | "ArrowLeft" | "ArrowRight" | "ArrowUp"; times: number }>) {
|
||||
const stage = page.getByLabel("编辑画布");
|
||||
for (const direction of directions) {
|
||||
for (let index = 0; index < direction.times; index += 1) await stage.press(`Shift+${direction.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
test("TDD-WP4-COL-001 extracts once from raw pixels and refreshes only for a new background", async ({ page }) => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000830";
|
||||
const initialAsset = Object.keys(rawImages)[0]!;
|
||||
const backend: Backend = { canvas: emptyCanvas(initialAsset), saves: 0, version: 1 };
|
||||
const requests: Array<{ method: string; url: string }> = [];
|
||||
page.on("request", (request) => requests.push({ method: request.method(), url: request.url() }));
|
||||
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: "色卡说明" })).toHaveAttribute("title", "色卡基于原始底图,更换底图时更新,不随裁剪、调色和滤镜变化");
|
||||
const placements = [
|
||||
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowUp", times: 14 }],
|
||||
[{ key: "ArrowRight", times: 15 }, { key: "ArrowUp", times: 14 }],
|
||||
[{ key: "ArrowLeft", times: 15 }, { key: "ArrowDown", times: 14 }],
|
||||
[{ key: "ArrowRight", times: 15 }, { key: "ArrowDown", times: 14 }],
|
||||
] as const;
|
||||
for (const [index, id] of ["COLOR001", "COLOR002", "COLOR008", "COLOR016"].entries()) {
|
||||
await page.getByRole("button", { name: new RegExp(`添加色卡 ${id}`) }).click();
|
||||
await nudgeSelected(page, [...placements[index]!]);
|
||||
}
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(4);
|
||||
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.every((element) => element.style_parameters?.palette_algorithm_version === "mmcq-v1")).toBe(true);
|
||||
const beforeAdjustments = structuredClone(palettes[0]);
|
||||
await page.getByLabel("编辑画布").press("Escape");
|
||||
await page.getByRole("button", { name: "底图", exact: true }).click();
|
||||
await page.locator(".editor-inspector label").filter({ hasText: "亮度" }).locator('input[type="range"]').fill("40");
|
||||
await page.getByRole("button", { name: "应用调整" }).click();
|
||||
expect(backend.canvas.elements.every((element) => JSON.stringify(element.colors) === JSON.stringify(beforeAdjustments))).toBe(true);
|
||||
await page.getByRole("button", { name: "历史", exact: true }).click();
|
||||
await page.locator(".editor-history-list button").last().click();
|
||||
await page.getByRole("button", { name: "确认更换" }).click();
|
||||
await expect.poll(() => backend.canvas.background.asset_id).toBe(Object.keys(rawImages)[1]);
|
||||
expect(backend.canvas.elements.every((element) => JSON.stringify(element.colors) === JSON.stringify(backend.canvas.elements[0]?.colors))).toBe(true);
|
||||
expect(backend.canvas.elements[0]?.colors).not.toEqual(beforeAdjustments);
|
||||
const network = {
|
||||
image_gets: requests.filter((item) => item.method === "GET" && item.url.includes("/private-assets/")).length,
|
||||
image_uploads: requests.filter((item) => item.method !== "GET" && item.url.includes("/private-assets/")).length,
|
||||
};
|
||||
expect(network.image_uploads).toBe(0);
|
||||
const pixels = await stageInk(page);
|
||||
await page.getByLabel("编辑画布").press("Escape");
|
||||
await page.getByRole("button", { name: "色卡", exact: true }).click();
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "palette.json", { algorithm: "mmcq-v1", initial: beforeAdjustments, replacement: backend.canvas.elements[0]?.colors, styles: backend.canvas.elements.map((item) => item.style_id) });
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "network-timeline.json", network);
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "pixel-diff.json", { ...pixels, four_renderers_visible: true });
|
||||
writeEvidence("TDD-WP4-COL-001-deterministic-palette", "db-diff.json", { elements: backend.canvas.elements, save_count: backend.saves });
|
||||
if (process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC, "TDD-WP4-COL-001-deterministic-palette", "color-cards.png") });
|
||||
});
|
||||
|
||||
test("TDD-WP4-DYN-001 snapshots the confirmed local time across clock changes and reopen", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-08-03T09:07:00+08:00"));
|
||||
const projectId = "00000000-0000-4000-8000-000000000840";
|
||||
const backend: Backend = { canvas: emptyCanvas(), 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();
|
||||
for (const id of ["DYN007", "DYN008", "DYN011", "DYN012"]) await page.getByRole("button", { name: new RegExp(`添加动态贴纸 ${id}`) }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(4);
|
||||
const snapshots = backend.canvas.elements.map((element) => ({ fields: element.dynamic_fields, id: element.template_or_asset_id, value: element.formatted_value }));
|
||||
await page.clock.setFixedTime(new Date("2026-08-06T23:59:00+08:00"));
|
||||
await page.reload();
|
||||
expect(backend.canvas.elements.map((element) => ({ fields: element.dynamic_fields, id: element.template_or_asset_id, value: element.formatted_value }))).toEqual(snapshots);
|
||||
writeEvidence("TDD-WP4-DYN-001-fixed-time", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-DYN-001-fixed-time", "clock-trace.json", { inserted_at: "2026-08-03T09:07:00+08:00", reopened_at: "2026-08-06T23:59:00+08:00", snapshots });
|
||||
writeEvidence("TDD-WP4-DYN-001-fixed-time", "pixel-diff.json", { ...(await stageInk(page)), static_canvas_content: true, links: 0 });
|
||||
});
|
||||
|
||||
test("TDD-WP4-DYN-002 gates coordinates behind consent and preserves manual fallback", async ({ page }) => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000850";
|
||||
const backend: Backend = { canvas: emptyCanvas(), saves: 0, version: 3 };
|
||||
await page.addInitScript(() => {
|
||||
(window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls = 0;
|
||||
Object.defineProperty(navigator, "geolocation", {
|
||||
configurable: true,
|
||||
value: { getCurrentPosition(success: PositionCallback) { (window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls = ((window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls ?? 0) + 1; success({ coords: { latitude: 27.9943, longitude: 120.6994 } } as GeolocationPosition); } },
|
||||
});
|
||||
});
|
||||
let reverseCalls = 0;
|
||||
let servicePaused = false;
|
||||
await routeEditor(page, projectId, backend);
|
||||
await page.route("**/api/v1/location/reverse-geocode", async (route) => {
|
||||
reverseCalls += 1;
|
||||
if (servicePaused) return route.fulfill({ status: 503 });
|
||||
return route.fulfill({ body: JSON.stringify({ formatted_value: "浙江省温州市", service_mode: "mock", status: "resolved" }), contentType: "application/json" });
|
||||
});
|
||||
await page.goto(`${webUrl}/app/projects/${projectId}/editor`);
|
||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN004/ }).click();
|
||||
const dialog = page.getByRole("dialog", { name: "使用自动定位" });
|
||||
await expect(dialog).toContainText("原始经纬度会保存到当前项目并显示在导出成品中");
|
||||
await dialog.getByRole("button", { name: "暂不定位" }).click();
|
||||
expect(await page.evaluate(() => (window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls)).toBe(0);
|
||||
expect(reverseCalls).toBe(0);
|
||||
expect(backend.canvas.elements).toHaveLength(0);
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN004/ }).click();
|
||||
await page.getByRole("button", { name: "同意并自动定位" }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
||||
expect(backend.canvas.elements[0]?.coordinates).toEqual({ latitude: 27.9943, longitude: 120.6994 });
|
||||
expect(reverseCalls).toBe(1);
|
||||
await page.getByRole("button", { name: "删除" }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(0);
|
||||
servicePaused = true;
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN004/ }).click();
|
||||
await page.getByRole("button", { name: "同意并自动定位" }).click();
|
||||
await expect(page.getByText("自动定位不可用,请改用手动地点贴纸。")).toBeVisible();
|
||||
await page.getByLabel("手动地点文字").fill("温州手动地点");
|
||||
await page.getByRole("button", { name: "改用手动地点贴纸" }).click();
|
||||
await expect.poll(() => backend.canvas.elements.length).toBe(1);
|
||||
expect(backend.canvas.elements[0]?.template_or_asset_id).toBe("DYN001");
|
||||
expect(backend.canvas.elements[0]?.coordinates).toBeUndefined();
|
||||
const calls = { geolocation: await page.evaluate(() => (window as Window & { __dadaLocationCalls?: number }).__dadaLocationCalls), reverse_geocode: reverseCalls };
|
||||
writeEvidence("TDD-WP4-DYN-002-location-consent", "external-calls.json", { ...calls, rejected_dada_prompt_calls: 0, service_mode: "mock", service_paused_manual_available: true });
|
||||
writeEvidence("TDD-WP4-DYN-002-location-consent", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-DYN-002-location-consent", "db-diff.json", { coordinates_after_delete: null, manual_element: backend.canvas.elements[0] });
|
||||
});
|
||||
|
||||
test("TDD-WP4-DYN-003 keeps identity overrides local and discloses DYN012 FONT081 substitution", async ({ page }) => {
|
||||
await page.clock.setFixedTime(new Date("2026-08-03T09:07:00+08:00"));
|
||||
const projectId = "00000000-0000-4000-8000-000000000860";
|
||||
const backend: Backend = { canvas: emptyCanvas(Object.keys(rawImages)[0]!), saves: 0, version: 4 };
|
||||
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: /添加动态贴纸 DYN016/ }).click();
|
||||
await expect.poll(() => backend.canvas.elements[0]?.formatted_value).toBe("@dada");
|
||||
await page.getByLabel("动态贴纸显示文字").fill("@single-instance");
|
||||
await page.getByRole("button", { name: "应用显示文字" }).click();
|
||||
await expect.poll(() => backend.canvas.elements[0]?.formatted_value).toBe("@single-instance");
|
||||
await nudgeSelected(page, [{ key: "ArrowUp", times: 18 }]);
|
||||
await page.getByRole("button", { name: "动态贴纸", exact: true }).click();
|
||||
await page.getByRole("button", { name: /添加动态贴纸 DYN012/ }).click();
|
||||
await expect(page.getByText(/当前明确使用 FONT081 · Lexend Deca 替代/)).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => document.fonts.check('16px "Dada_FONT081"'))).toBe(true);
|
||||
await expect.poll(() => backend.canvas.elements.some((element) => element.template_or_asset_id === "DYN012")).toBe(true);
|
||||
expect(backend.canvas.elements.find((element) => element.template_or_asset_id === "DYN012")).toMatchObject({
|
||||
dynamic_fields: { font_substitution: "FONT081" }, font_override: "FONT081",
|
||||
});
|
||||
expect(session.user.social_id).toBe("@@dada");
|
||||
const pixels = await stageInk(page);
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "canvas-state.json", backend.canvas);
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "db-diff.json", { account_profile: session.user, instance_override: "@single-instance", profile_changed: false });
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "font-load.json", { fallback: null, font_id: "FONT081", original_font: "DIN_MediumAlternate.otf", ready: true, substitution_disclosed: true });
|
||||
writeEvidence("TDD-WP4-DYN-003-identity-font", "pixel-diff.json", { ...pixels, substitution_requires_manual_review: true });
|
||||
if (process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC) await page.screenshot({ fullPage: true, path: resolve(process.env.DADA_EVIDENCE_DIR_COLOR_DYNAMIC, "TDD-WP4-DYN-003-identity-font", "dyn012-substitution.png") });
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CanvasState } from "@dada/shared-contracts";
|
||||
|
||||
import {
|
||||
COLOR_CARD_SOURCE_GEOMETRY,
|
||||
P0A_COLOR_CARDS,
|
||||
createColorCardElement,
|
||||
extractMmcqPalette,
|
||||
refreshColorCards,
|
||||
} from "../../apps/web/src/palette-provider.js";
|
||||
import {
|
||||
DYN012_RENDER_LAYOUT,
|
||||
LocationConsentGate,
|
||||
P0A_DYNAMIC_STICKERS,
|
||||
createDynamicStickerElement,
|
||||
normalizeSocialId,
|
||||
overrideDynamicStickerValue,
|
||||
} from "../../apps/web/src/dynamic-provider.js";
|
||||
import { DYNAMIC_RENDER_MODELS, DYNAMIC_RESOURCE_VERSION } from "../../apps/web/src/dynamic-render-models.js";
|
||||
|
||||
const identity = { createdAt: "2026-08-03T04:00:00.000Z", elementId: "00000000-0000-4000-8000-000000000801" };
|
||||
|
||||
function canvas(elements: CanvasState["elements"] = []): CanvasState {
|
||||
return {
|
||||
background: { adjustments: { brightness: 0, contrast: 0, crop: null, filter: "none", fit: "fill", saturation: 0, sharpness: 0, temperature: 0 }, asset_id: "00000000-0000-4000-8000-000000000802" },
|
||||
elements, pixel_height: 1440, pixel_width: 1080, ratio: "3:4", schema_version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function colorPixels(colors: Array<{ count: number; rgb: [number, number, number] }>) {
|
||||
const values: number[] = [];
|
||||
for (const color of colors) {
|
||||
for (let index = 0; index < color.count; index += 1) values.push(...color.rgb, 255);
|
||||
}
|
||||
return new Uint8ClampedArray(values);
|
||||
}
|
||||
|
||||
describe("TASK-WP4-04 deterministic color cards", () => {
|
||||
it("keeps the archived 320px color-card geometry instead of enlarged approximations", () => {
|
||||
expect(COLOR_CARD_SOURCE_GEOMETRY).toMatchObject({
|
||||
style_01: { bounds: { bottom: 76, left: -26, right: 26, top: -74 }, palette: { bottom: 37, left: -23, right: 23, top: -71 } },
|
||||
style_02: { bounds: { bottom: 69, left: -18, right: 18, top: -77 } },
|
||||
style_08: { bounds: { bottom: 10, left: -73, right: 73, top: -9 } },
|
||||
style_16: { bounds: { bottom: 9, left: -78, right: 78, top: -9 } },
|
||||
});
|
||||
});
|
||||
|
||||
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"]);
|
||||
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] },
|
||||
]);
|
||||
const first = extractMmcqPalette(pixels);
|
||||
const second = extractMmcqPalette(pixels);
|
||||
expect(first).toEqual(second);
|
||||
expect(first).toHaveLength(5);
|
||||
expect(first).toEqual([...first].sort((left, right) => right.population - left.population || left.rgbValue - right.rgbValue));
|
||||
expect(first.every((entry) => /^#[0-9A-F]{6}$/.test(entry.hex))).toBe(true);
|
||||
});
|
||||
|
||||
it("snapshots five colors and only refreshes them when the raw background changes", () => {
|
||||
const initialPalette = ["#F42020", "#20D260", "#1860DC", "#F8D230", "#7840B4"];
|
||||
const replacement = ["#111111", "#333333", "#555555", "#777777", "#999999"];
|
||||
const element = createColorCardElement(P0A_COLOR_CARDS[0]!, initialPalette, identity, 0);
|
||||
const state = canvas([element]);
|
||||
expect(element.style_id).toBe("style_01");
|
||||
expect(element.style_parameters?.palette_algorithm_version).toBe("mmcq-v1");
|
||||
expect(element.colors).toEqual(initialPalette);
|
||||
expect(refreshColorCards(state, replacement).elements[0]?.colors).toEqual(replacement);
|
||||
expect(state.elements[0]?.colors).toEqual(initialPalette);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TASK-WP4-04 dynamic providers", () => {
|
||||
const context = {
|
||||
now: new Date("2026-08-03T09:07:00+08:00"),
|
||||
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",
|
||||
]);
|
||||
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" });
|
||||
expect(time.font_override).toBe("FONT081");
|
||||
const identitySticker = createDynamicStickerElement("DYN016", context, identity, 0);
|
||||
expect(identitySticker.formatted_value).toBe("@dada");
|
||||
expect(normalizeSocialId("@@@dada")).toBe("@dada");
|
||||
});
|
||||
|
||||
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"],
|
||||
]);
|
||||
const other = createDynamicStickerElement("DYN007", context, identity, 0);
|
||||
expect(other.dynamic_fields).toEqual({ nickname: "@dada" });
|
||||
expect(other.formatted_value).toBe("@dada");
|
||||
const monthTime = createDynamicStickerElement("DYN008", context, identity, 0);
|
||||
expect(monthTime.dynamic_fields).toEqual({ hour: "09", minute: "07", month: "08" });
|
||||
});
|
||||
|
||||
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"],
|
||||
]);
|
||||
const element = createDynamicStickerElement("DYN001", { ...context, location: { formattedValue: "温州" } }, identity, 0);
|
||||
expect(element.resource_version).toBe(DYNAMIC_RESOURCE_VERSION);
|
||||
expect(DYNAMIC_RENDER_MODELS.DYN001.imageLayers).toEqual([
|
||||
{ assetId: "DYN001-image28", height: 67, width: 219, x: -17.562, y: 2.203 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the source DYN012 split-clock composition instead of a generic time box", () => {
|
||||
expect(DYN012_RENDER_LAYOUT).toEqual({
|
||||
background: "transparent",
|
||||
divider: { color: "#FFFFFF", height: 42, width: 7, x: 0, y: 0 },
|
||||
hour: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: -60, y: 0 },
|
||||
meridiem: { color: "#FFFFFF", fontId: "FONT081", fontSize: 20, x: 94, y: -65 },
|
||||
minute: { color: "#FFFFFF", fontId: "FONT081", fontSize: 100, x: 60, y: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a per-instance override separate from the account profile", () => {
|
||||
const profile = structuredClone(context.profile);
|
||||
const element = createDynamicStickerElement("DYN015", context, identity, 0);
|
||||
const changed = overrideDynamicStickerValue(element, "Single Sticker Name");
|
||||
expect(changed.formatted_value).toBe("Single Sticker Name");
|
||||
expect(element.formatted_value).toBe("Dada Creator");
|
||||
expect(context.profile).toEqual(profile);
|
||||
});
|
||||
|
||||
it("does not call location or reverse geocoding until Dada consent is confirmed", async () => {
|
||||
const geolocate = vi.fn(async () => ({ latitude: 27.9943, longitude: 120.6994 }));
|
||||
const reverseGeocode = vi.fn(async () => "浙江省温州市");
|
||||
const gate = new LocationConsentGate({ geolocate, reverseGeocode });
|
||||
expect(gate.reject()).toBeUndefined();
|
||||
expect(geolocate).not.toHaveBeenCalled();
|
||||
expect(reverseGeocode).not.toHaveBeenCalled();
|
||||
await expect(gate.confirm()).resolves.toEqual({ formattedValue: "浙江省温州市", latitude: 27.9943, longitude: 120.6994 });
|
||||
expect(geolocate).toHaveBeenCalledTimes(1);
|
||||
expect(reverseGeocode).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user