Files
tyx_AI_xhs/apps/web/src/palette-provider.ts
T
suyx 4d1ec8ba7e
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m5s
feat: complete TASK-WP5-03 asset allowlist
2026-08-03 18:24:16 +08:00

188 lines
6.7 KiB
TypeScript

import { MMCQ } from "@vibrant/quantizer-mmcq";
import type { CanvasState } from "@dada/shared-contracts";
import { P0A_COLOR_CARD_DEFINITIONS, type ColorCardDefinition } from "@dada/asset-renderer";
import type { CanvasElementIdentity } from "./editor-elements.js";
type CanvasElement = CanvasState["elements"][number];
export type { ColorCardDefinition } from "@dada/asset-renderer";
export interface PaletteColor {
hex: string;
population: number;
rgbValue: number;
}
export const P0A_COLOR_CARDS: readonly ColorCardDefinition[] = P0A_COLOR_CARD_DEFINITIONS;
// 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);
});
}