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