feat: initialize schema-driven poster editor
Verify editor package / test (push) Successful in 32s

This commit is contained in:
2026-09-19 01:14:43 +08:00
commit 88e1dd9308
9 changed files with 324 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
export const DOCUMENT_VERSION = 1;
export const LAYER_TYPES = Object.freeze([
'background', 'group', 'image', 'vector', 'text', 'sticker', 'brush'
]);
export const ADJUSTMENTS = Object.freeze([
'brightness', 'contrast', 'shadows', 'highlights', 'temperature',
'hue', 'saturation', 'vignette', 'sharpen', 'grain'
]);
export const LAYOUT_PRESETS = Object.freeze([
{ id: 'two-equal-columns', count: 2, tags: ['2', 'equal'], slots: [[0,0,0.5,1],[0.5,0,0.5,1]] },
{ id: 'two-equal-rows', count: 2, tags: ['2', 'equal'], slots: [[0,0,1,0.5],[0,0.5,1,0.5]] },
{ id: 'two-top-large', count: 2, tags: ['2', 'top-large'], slots: [[0,0,1,0.66],[0,0.66,1,0.34]] },
{ id: 'two-bottom-large', count: 2, tags: ['2', 'bottom-large'], slots: [[0,0,1,0.34],[0,0.34,1,0.66]] },
{ id: 'two-left-large', count: 2, tags: ['2', 'left-large'], slots: [[0,0,0.66,1],[0.66,0,0.34,1]] },
{ id: 'two-right-large', count: 2, tags: ['2', 'right-large'], slots: [[0,0,0.34,1],[0.34,0,0.66,1]] },
{ id: 'three-hero-left', count: 3, tags: ['3', 'hero'], slots: [[0,0,0.62,1],[0.62,0,0.38,0.5],[0.62,0.5,0.38,0.5]] },
{ id: 'four-grid', count: 4, tags: ['4', 'grid'], slots: [[0,0,0.5,0.5],[0.5,0,0.5,0.5],[0,0.5,0.5,0.5],[0.5,0.5,0.5,0.5]] },
{ id: 'five-hero', count: 5, tags: ['5', 'hero'], slots: [[0,0,1,0.56],[0,0.56,0.25,0.44],[0.25,0.56,0.25,0.44],[0.5,0.56,0.25,0.44],[0.75,0.56,0.25,0.44]] }
]);
export const BRUSH_PRESETS = Object.freeze([
{ id: 'round', engine: 'stroke', spacing: 0.08, jitter: 0 },
{ id: 'marker', engine: 'stroke', spacing: 0.05, opacity: 0.55 },
{ id: 'pencil', engine: 'texture', spacing: 0.06, texture: 'pencil-grain' },
{ id: 'chalk', engine: 'texture', spacing: 0.08, texture: 'chalk-grain', jitter: 0.08 },
{ id: 'spray', engine: 'particles', density: 18, jitter: 0.8 },
{ id: 'neon', engine: 'stroke', glow: 0.8 },
{ id: 'flowers', engine: 'stamp', stamp: 'flower', spacing: 0.7, angleJitter: 18, sizeJitter: 0.18 }
]);
export const TOOL_DEFINITIONS = Object.freeze({
filter: { accepts: ['image', 'background'], writes: ['effects'] },
text: { accepts: ['text'], writes: ['text', 'style'] },
sticker: { accepts: ['group'], creates: 'sticker' },
crop: { accepts: ['image', 'background'], writes: ['crop', 'mask'] },
adjust: { accepts: ['image', 'background'], writes: ['adjustments'] },
layout: { accepts: ['group'], writes: ['layout'] },
brush: { accepts: ['brush'], writes: ['strokes'] }
});
export function createDocument(options = {}) {
return {
schema: 'entroia.poster-document', version: DOCUMENT_VERSION,
id: options.id || cryptoId(), seed: options.seed || cryptoId(),
canvas: { width: options.width || 1080, height: options.height || 1440, background: options.background || '#ffffff' },
layers: options.layers ? structuredCloneSafe(options.layers) : [],
bindings: options.bindings ? structuredCloneSafe(options.bindings) : {},
meta: { title: options.title || '', createdAt: new Date().toISOString(), ...(options.meta || {}) }
};
}
export function createLayer(type, props = {}) {
if (!LAYER_TYPES.includes(type)) throw new Error(`Unsupported layer type: ${type}`);
return {
id: props.id || cryptoId(), type, name: props.name || type,
visible: props.visible !== false, locked: Boolean(props.locked), opacity: props.opacity ?? 1,
blendMode: props.blendMode || 'source-over', transform: props.transform || { x: 0, y: 0, scaleX: 1, scaleY: 1, rotation: 0 },
effects: props.effects || [], tags: props.tags || [], ...props
};
}
export function stableHash(value) {
let hash = 2166136261;
for (const char of String(value)) { hash ^= char.codePointAt(0); hash = Math.imul(hash, 16777619); }
return hash >>> 0;
}
export function deterministicGlyphAngle({ documentSeed, layerId, index, glyph, maxAngle = 7, mode = 'position' }) {
const key = mode === 'glyph' ? `${documentSeed}:${layerId}:${glyph}` : `${documentSeed}:${layerId}:${index}`;
return ((stableHash(key) / 0xffffffff) * 2 - 1) * Math.abs(maxAngle);
}
export function layoutDecoratedText(text, measure, style = {}) {
const glyphs = Array.from(text);
let cursor = 0;
const runs = glyphs.map((glyph, index) => {
const width = measure(glyph, style);
const run = { glyph, index, x: cursor + width / 2, width,
rotation: deterministicGlyphAngle({ documentSeed: style.documentSeed || '', layerId: style.layerId || '', index, glyph, maxAngle: style.maxAngle || 0, mode: style.rotationMode }) };
cursor += width + (style.letterSpacing || 0);
return run;
});
const paddingX = style.background?.paddingX ?? 20;
const paddingY = style.background?.paddingY ?? 12;
return { glyphs: runs, width: Math.max(0, cursor - (style.letterSpacing || 0)),
background: style.background ? { ...style.background, width: Math.max(0, cursor) + paddingX * 2, height: (style.lineHeight || style.fontSize || 48) + paddingY * 2, resizeMode: style.background.resizeMode || 'nine-slice' } : null };
}
export function resolveBinding(template, context) {
if (typeof template !== 'string') return template;
return template.replace(/\{([^}]+)\}/g, (_, path) => path.split('.').reduce((value, key) => value?.[key], context) ?? '');
}
export function resolveToolTargets(document, toolId, context = {}) {
const binding = document.bindings?.tools?.[toolId];
if (!binding) return [];
const ids = new Set((binding.layerIds || []).map(id => resolveBinding(id, context)));
return flattenLayers(document.layers).filter(layer =>
ids.has(layer.id) || (binding.tags || []).some(tag => layer.tags?.includes(resolveBinding(tag, context)))
);
}
export function selectCatalogItems(items, query = {}) {
const all = new Set(query.all || []), any = new Set(query.any || []), none = new Set(query.none || []);
return items.filter(item => {
const tags = new Set(item.tags || []);
return [...all].every(tag => tags.has(tag)) && (!any.size || [...any].some(tag => tags.has(tag))) && ![...none].some(tag => tags.has(tag));
});
}
export function applyLayout(presetId, group, gap = 0) {
const preset = LAYOUT_PRESETS.find(item => item.id === presetId);
if (!preset) throw new Error(`Unknown layout: ${presetId}`);
return preset.slots.map(([x,y,width,height], index) => ({ index, x: x + gap / 2, y: y + gap / 2, width: Math.max(0, width - gap), height: Math.max(0, height - gap), mask: group?.mask || 'rect' }));
}
export function validateDocument(document) {
const errors = [];
if (document?.schema !== 'entroia.poster-document') errors.push('schema must be entroia.poster-document');
if (document?.version !== DOCUMENT_VERSION) errors.push(`unsupported version: ${document?.version}`);
if (!(document?.canvas?.width > 0 && document?.canvas?.height > 0)) errors.push('canvas dimensions must be positive');
const ids = new Set();
for (const layer of document?.layers || []) {
if (!LAYER_TYPES.includes(layer.type)) errors.push(`unsupported layer type: ${layer.type}`);
if (!layer.id || ids.has(layer.id)) errors.push(`invalid or duplicate layer id: ${layer.id}`);
ids.add(layer.id);
}
return { valid: errors.length === 0, errors };
}
function cryptoId() { return globalThis.crypto?.randomUUID?.() || `layer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; }
function structuredCloneSafe(value) { return globalThis.structuredClone ? structuredClone(value) : JSON.parse(JSON.stringify(value)); }
function flattenLayers(layers) { return (layers || []).flatMap(layer => [layer, ...flattenLayers(layer.children)]); }
+65
View File
@@ -0,0 +1,65 @@
import { layoutDecoratedText } from './index.js';
export class CanvasRenderer {
constructor(context, assets = {}) { this.ctx = context; this.assets = assets; }
render(document) {
const ctx = this.ctx;
ctx.save(); ctx.fillStyle = document.canvas.background || '#fff'; ctx.fillRect(0, 0, document.canvas.width, document.canvas.height);
for (const layer of document.layers) this.renderLayer(layer, document);
ctx.restore();
}
renderLayer(layer, document) {
if (!layer.visible) return;
const ctx = this.ctx, t = layer.transform || {};
ctx.save(); ctx.globalAlpha *= layer.opacity ?? 1; ctx.globalCompositeOperation = layer.blendMode || 'source-over';
ctx.translate(t.x || 0, t.y || 0); ctx.rotate((t.rotation || 0) * Math.PI / 180); ctx.scale(t.scaleX ?? 1, t.scaleY ?? 1);
if (layer.type === 'background' || layer.type === 'vector') this.drawVector(layer);
if (layer.type === 'text') this.drawText(layer, document);
if (layer.type === 'image' || layer.type === 'sticker') this.drawImage(layer);
if (layer.type === 'brush') this.drawBrush(layer);
for (const child of layer.children || []) this.renderLayer(child, document);
ctx.restore();
}
drawVector(layer) {
const ctx = this.ctx;
for (const command of layer.commands || []) {
ctx.beginPath(); ctx.strokeStyle = command.stroke || 'transparent'; ctx.fillStyle = command.fill || 'transparent'; ctx.lineWidth = command.strokeWidth || 1;
if (command.kind === 'line') { ctx.moveTo(command.x1, command.y1); ctx.lineTo(command.x2, command.y2); ctx.stroke(); }
if (command.kind === 'rect') { if (command.radius && ctx.roundRect) ctx.roundRect(command.x, command.y, command.width, command.height, command.radius); else ctx.rect(command.x, command.y, command.width, command.height); if (command.fill) ctx.fill(); if (command.stroke) ctx.stroke(); }
if (command.kind === 'text') { ctx.font = command.font || '24px sans-serif'; ctx.textAlign = command.align || 'left'; ctx.fillText(command.text, command.x, command.y); }
}
}
drawText(layer, document) {
const ctx = this.ctx, style = layer.style || {};
ctx.font = `${style.fontWeight || 700} ${style.fontSize || 48}px ${style.fontFamily || 'sans-serif'}`;
const measured = layoutDecoratedText(layer.text || '', glyph => ctx.measureText(glyph).width, { ...style, documentSeed: document.seed, layerId: layer.id });
if (measured.background) this.drawStretchBackground(measured.background, style.background);
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = style.fill || '#111'; ctx.strokeStyle = style.stroke || 'transparent'; ctx.lineWidth = style.strokeWidth || 0;
for (const glyph of measured.glyphs) { ctx.save(); ctx.translate(glyph.x + (measured.background?.paddingX || 0), (style.lineHeight || style.fontSize || 48) / 2 + (measured.background?.paddingY || 0)); ctx.rotate(glyph.rotation * Math.PI / 180); if (style.strokeWidth) ctx.strokeText(glyph.glyph, 0, 0); ctx.fillText(glyph.glyph, 0, 0); ctx.restore(); }
}
drawStretchBackground(box, style = {}) {
const ctx = this.ctx; ctx.save(); ctx.fillStyle = style.fill || '#ffe36e';
const image = style.assetId && this.assets[style.assetId];
if (image && style.capInsets) this.drawNineSlice(image, box.width, box.height, style.capInsets);
else if (style.shape === 'pill') { ctx.beginPath(); if (ctx.roundRect) ctx.roundRect(0, 0, box.width, box.height, box.height / 2); else ctx.rect(0, 0, box.width, box.height); ctx.fill(); }
else ctx.fillRect(0, 0, box.width, box.height);
ctx.restore();
}
drawNineSlice(image, width, height, cap) {
const ctx = this.ctx, left = cap.left, right = cap.right, top = cap.top, bottom = cap.bottom;
const sx = [0, left, image.width - right], sy = [0, top, image.height - bottom];
const sw = [left, image.width - left - right, right], sh = [top, image.height - top - bottom, bottom];
const dx = [0, left, width - right], dy = [0, top, height - bottom];
const dw = [left, Math.max(0, width - left - right), right], dh = [top, Math.max(0, height - top - bottom), bottom];
for (let y = 0; y < 3; y += 1) for (let x = 0; x < 3; x += 1) ctx.drawImage(image, sx[x], sy[y], sw[x], sh[y], dx[x], dy[y], dw[x], dh[y]);
}
drawImage(layer) { const image = this.assets[layer.assetId]; if (image) this.ctx.drawImage(image, 0, 0, layer.width || image.width, layer.height || image.height); }
drawBrush(layer) {
const ctx = this.ctx;
for (const stroke of layer.strokes || []) {
const points = stroke.points || []; if (!points.length) continue;
if (stroke.engine === 'stamp') { for (let i = 0; i < points.length; i += Math.max(1, stroke.step || 3)) { const p = points[i], stamp = stroke.assetId && this.assets[stroke.assetId]; ctx.save(); ctx.translate(p.x,p.y); ctx.rotate((p.angle || 0)*Math.PI/180); if (stamp) { const size = p.size || stroke.size || 28; ctx.drawImage(stamp,-size/2,-size/2,size,size); } else { ctx.font = `${stroke.size || 28}px sans-serif`; ctx.fillText(stroke.stampGlyph || '✿',0,0); } ctx.restore(); } }
else { ctx.beginPath(); ctx.strokeStyle = stroke.color || '#111'; ctx.globalAlpha *= stroke.opacity ?? 1; ctx.lineWidth = stroke.size || 8; ctx.lineCap = 'round'; ctx.moveTo(points[0].x,points[0].y); for (const p of points.slice(1)) ctx.lineTo(p.x,p.y); ctx.stroke(); }
}
}
}