186 lines
9.0 KiB
TypeScript
186 lines
9.0 KiB
TypeScript
import type { CanvasState } from "@dada/shared-contracts";
|
|
import { P0A_DYNAMIC_STICKER_IDS } from "@dada/template-registry";
|
|
|
|
import { DYNAMIC_RESOURCE_VERSION } from "./dynamic-render-models.js";
|
|
import type { CanvasElementIdentity } from "./editor-elements.js";
|
|
|
|
type CanvasElement = CanvasState["elements"][number];
|
|
|
|
export type DynamicCategory = "identity" | "location" | "other" | "time";
|
|
export type DynamicTemplateId = typeof P0A_DYNAMIC_STICKER_IDS[number];
|
|
|
|
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;
|
|
}
|
|
|
|
const dynamicDefinitions: readonly DynamicStickerDefinition[] = [
|
|
{ category: "location", displayName: "地点标题", requiresLocationConsent: false, templateId: "DYN001" },
|
|
{ category: "location", displayName: "英文地点", requiresLocationConsent: false, templateId: "DYN002" },
|
|
{ category: "location", displayName: "城市地点", requiresLocationConsent: false, templateId: "DYN003" },
|
|
{ category: "location", displayName: "经纬地点", requiresLocationConsent: true, templateId: "DYN004" },
|
|
{ category: "other", displayName: "用户名组合", requiresLocationConsent: false, templateId: "DYN007" },
|
|
{ category: "time", displayName: "月与时间", requiresLocationConsent: false, templateId: "DYN008" },
|
|
{ category: "time", displayName: "完整日期", requiresLocationConsent: false, templateId: "DYN011" },
|
|
{ category: "time", displayName: "数字时间", requiresLocationConsent: false, templateId: "DYN012" },
|
|
{ category: "identity", displayName: "创作署名", requiresLocationConsent: false, templateId: "DYN015" },
|
|
{ category: "identity", displayName: "社交 ID", requiresLocationConsent: false, templateId: "DYN016" },
|
|
] as const;
|
|
|
|
export const P0A_DYNAMIC_STICKERS: readonly DynamicStickerDefinition[] = P0A_DYNAMIC_STICKER_IDS.map((templateId) => {
|
|
const definition = dynamicDefinitions.find((item) => item.templateId === templateId);
|
|
if (!definition) throw new Error(`missing dynamic sticker definition ${templateId}`);
|
|
return definition;
|
|
});
|
|
|
|
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 },
|
|
);
|
|
});
|
|
}
|