Add v2 scene format types and Fabric v1→v2 converter
Scene serialization layer for the PixiJS migration: defines SceneData v2 types (Image/Video/Text/Group objects) and convertFabricToV2() which transforms existing Fabric canvas JSON to the new format. Handles asset key extraction from /api/images/ URLs, group flattening with coordinate transforms, filter extraction, and edge cases for missing fields.
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
// scene-format.ts — v2 scene format types and Fabric v1→v2 converter
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SceneObject {
|
||||
id: string;
|
||||
type: 'image' | 'video' | 'text' | 'group';
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
sx: number;
|
||||
sy: number;
|
||||
angle: number;
|
||||
z: number;
|
||||
opacity: number;
|
||||
locked: boolean;
|
||||
name: string;
|
||||
visible: boolean;
|
||||
flipX?: boolean;
|
||||
flipY?: boolean;
|
||||
}
|
||||
|
||||
export interface ImageObject extends SceneObject {
|
||||
type: 'image';
|
||||
asset: string;
|
||||
filters: string[];
|
||||
}
|
||||
|
||||
export interface VideoObject extends SceneObject {
|
||||
type: 'video';
|
||||
asset: string;
|
||||
muted: boolean;
|
||||
loop: boolean;
|
||||
}
|
||||
|
||||
export interface TextObject extends SceneObject {
|
||||
type: 'text';
|
||||
text: string;
|
||||
fontSize: number;
|
||||
fill: string;
|
||||
fontFamily: string;
|
||||
}
|
||||
|
||||
export interface GroupObject extends SceneObject {
|
||||
type: 'group';
|
||||
children: string[];
|
||||
}
|
||||
|
||||
export type AnySceneObject = ImageObject | VideoObject | TextObject | GroupObject;
|
||||
|
||||
export interface SceneData {
|
||||
v: 2;
|
||||
bg: string;
|
||||
objects: AnySceneObject[];
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
const API_IMAGES_PREFIX = '/api/images/';
|
||||
|
||||
/** Extract asset key from a Fabric image src URL.
|
||||
* `/api/images/boards/abc/def.png` → `boards/abc/def.png`
|
||||
* Falls back to full src if pattern doesn't match. */
|
||||
function extractAssetKey(src: string | undefined): string {
|
||||
if (!src) return '';
|
||||
const idx = src.indexOf(API_IMAGES_PREFIX);
|
||||
if (idx !== -1) return src.slice(idx + API_IMAGES_PREFIX.length);
|
||||
// Absolute URL with host — try stripping origin
|
||||
try {
|
||||
const url = new URL(src);
|
||||
const pathIdx = url.pathname.indexOf(API_IMAGES_PREFIX);
|
||||
if (pathIdx !== -1) return url.pathname.slice(pathIdx + API_IMAGES_PREFIX.length);
|
||||
} catch {
|
||||
// not a valid URL — fine
|
||||
}
|
||||
return src;
|
||||
}
|
||||
|
||||
/** Pull filter type names from Fabric's filter array. */
|
||||
function extractFilters(fabricFilters: any[] | undefined): string[] {
|
||||
if (!Array.isArray(fabricFilters)) return [];
|
||||
return fabricFilters
|
||||
.map((f) => f?.type ?? f?.Type ?? '')
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Build base SceneObject fields from a Fabric object. */
|
||||
function baseFields(fObj: any, index: number): SceneObject {
|
||||
return {
|
||||
id: fObj.id ?? crypto.randomUUID(),
|
||||
type: 'image', // caller overrides
|
||||
x: fObj.left ?? 0,
|
||||
y: fObj.top ?? 0,
|
||||
w: fObj.width ?? 0,
|
||||
h: fObj.height ?? 0,
|
||||
sx: fObj.scaleX ?? 1,
|
||||
sy: fObj.scaleY ?? 1,
|
||||
angle: fObj.angle ?? 0,
|
||||
z: index,
|
||||
opacity: fObj.opacity ?? 1,
|
||||
locked: fObj.selectable === false,
|
||||
name: fObj.name ?? '',
|
||||
visible: fObj.visible !== false,
|
||||
...(fObj.flipX ? { flipX: true } : {}),
|
||||
...(fObj.flipY ? { flipY: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Converter ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Convert Fabric.js canvas JSON (v1 format) to SceneData v2. */
|
||||
export function convertFabricToV2(fabricJson: any): SceneData {
|
||||
const objects: AnySceneObject[] = [];
|
||||
const srcObjects: any[] = fabricJson?.objects ?? [];
|
||||
let zCounter = 0;
|
||||
|
||||
for (const fObj of srcObjects) {
|
||||
const fabricType: string = fObj.type ?? '';
|
||||
|
||||
// Skip unsupported types (e.g. path from drawing tools)
|
||||
if (fabricType === 'path') continue;
|
||||
|
||||
if (fabricType === 'group') {
|
||||
convertGroup(fObj, objects, zCounter);
|
||||
// Group + its children all get z slots
|
||||
const childCount = Array.isArray(fObj.objects) ? fObj.objects.length : 0;
|
||||
zCounter += 1 + childCount;
|
||||
continue;
|
||||
}
|
||||
|
||||
const converted = convertSingle(fObj, zCounter);
|
||||
if (converted) {
|
||||
objects.push(converted);
|
||||
zCounter++;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
v: 2,
|
||||
bg: fabricJson?.background ?? fabricJson?.backgroundColor ?? '',
|
||||
objects,
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert a single non-group Fabric object. Returns null for unsupported types. */
|
||||
function convertSingle(fObj: any, z: number): AnySceneObject | null {
|
||||
const fabricType: string = fObj.type ?? '';
|
||||
|
||||
if (fabricType === 'image') {
|
||||
return {
|
||||
...baseFields(fObj, z),
|
||||
type: 'image',
|
||||
asset: extractAssetKey(fObj.src),
|
||||
filters: extractFilters(fObj.filters),
|
||||
} as ImageObject;
|
||||
}
|
||||
|
||||
if (fabricType === 'i-text' || fabricType === 'text' || fabricType === 'textbox') {
|
||||
return {
|
||||
...baseFields(fObj, z),
|
||||
type: 'text',
|
||||
text: fObj.text ?? '',
|
||||
fontSize: fObj.fontSize ?? 24,
|
||||
fill: fObj.fill ?? '#000000',
|
||||
fontFamily: fObj.fontFamily ?? 'sans-serif',
|
||||
} as TextObject;
|
||||
}
|
||||
|
||||
if (fabricType === 'video') {
|
||||
return {
|
||||
...baseFields(fObj, z),
|
||||
type: 'video',
|
||||
asset: extractAssetKey(fObj.src),
|
||||
muted: fObj.muted ?? true,
|
||||
loop: fObj.loop ?? true,
|
||||
} as VideoObject;
|
||||
}
|
||||
|
||||
// Unknown type — treat as image with empty asset (best-effort)
|
||||
if (fabricType) {
|
||||
return {
|
||||
...baseFields(fObj, z),
|
||||
type: 'image',
|
||||
asset: extractAssetKey(fObj.src),
|
||||
filters: [],
|
||||
} as ImageObject;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Convert a Fabric group: flatten children to top-level with absolute coords. */
|
||||
function convertGroup(
|
||||
fObj: any,
|
||||
out: AnySceneObject[],
|
||||
zStart: number,
|
||||
): void {
|
||||
const groupId = fObj.id ?? crypto.randomUUID();
|
||||
const groupLeft = fObj.left ?? 0;
|
||||
const groupTop = fObj.top ?? 0;
|
||||
const groupScaleX = fObj.scaleX ?? 1;
|
||||
const groupScaleY = fObj.scaleY ?? 1;
|
||||
const groupAngle = fObj.angle ?? 0;
|
||||
|
||||
const children: any[] = fObj.objects ?? [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
// Convert radians for rotation transform
|
||||
const rad = (groupAngle * Math.PI) / 180;
|
||||
const cos = Math.cos(rad);
|
||||
const sin = Math.sin(rad);
|
||||
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const child = children[i];
|
||||
if (child.type === 'path') continue;
|
||||
|
||||
const childId = child.id ?? crypto.randomUUID();
|
||||
// Fabric groups store children relative to group center.
|
||||
// Transform child position to canvas-absolute coordinates.
|
||||
const relX = (child.left ?? 0) * groupScaleX;
|
||||
const relY = (child.top ?? 0) * groupScaleY;
|
||||
const absX = groupLeft + relX * cos - relY * sin;
|
||||
const absY = groupTop + relX * sin + relY * cos;
|
||||
|
||||
const adjusted = {
|
||||
...child,
|
||||
id: childId,
|
||||
left: absX,
|
||||
top: absY,
|
||||
scaleX: (child.scaleX ?? 1) * groupScaleX,
|
||||
scaleY: (child.scaleY ?? 1) * groupScaleY,
|
||||
angle: (child.angle ?? 0) + groupAngle,
|
||||
};
|
||||
|
||||
const converted = convertSingle(adjusted, zStart + 1 + i);
|
||||
if (converted) {
|
||||
childIds.push(converted.id);
|
||||
out.push(converted);
|
||||
}
|
||||
}
|
||||
|
||||
// Push the group object itself (references children by ID)
|
||||
const group: GroupObject = {
|
||||
...baseFields(fObj, zStart),
|
||||
type: 'group',
|
||||
id: groupId,
|
||||
children: childIds,
|
||||
};
|
||||
out.push(group);
|
||||
}
|
||||
Reference in New Issue
Block a user