From f54175331f571412be8baf1d7b729382167dfd2d Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Mon, 13 Oct 2025 02:10:19 +0500 Subject: [PATCH 1/3] feat: added width, height and anchor property for clip and assets. --- src/components/canvas/players/image-player.ts | 16 ++ src/components/canvas/players/player.ts | 156 +++++++++++++++++- src/components/canvas/players/video-player.ts | 16 ++ src/core/schemas/clip.ts | 40 +++-- src/core/schemas/image-asset.ts | 3 +- src/core/schemas/video-asset.ts | 3 +- 6 files changed, 208 insertions(+), 26 deletions(-) diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index a83d13fb..f63173cc 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -9,12 +9,14 @@ import { Player } from "./player"; export class ImagePlayer extends Player { private texture: pixi.Texture | null; private sprite: pixi.Sprite | null; + private originalSize: Size | null; constructor(timeline: Edit, clipConfiguration: Clip) { super(timeline, clipConfiguration); this.texture = null; this.sprite = null; + this.originalSize = null; } public override async load(): Promise { @@ -39,6 +41,11 @@ export class ImagePlayer extends Player { this.sprite = new pixi.Sprite(this.texture); this.contentContainer.addChild(this.sprite); + + if (this.clipConfiguration.width && this.clipConfiguration.height) { + this.applyFixedDimensions(); + } + this.configureKeyframes(); } @@ -58,9 +65,18 @@ export class ImagePlayer extends Player { this.texture?.destroy(); this.texture = null; + + this.originalSize = null; } public override getSize(): Size { + if (this.clipConfiguration.width && this.clipConfiguration.height) { + return { + width: this.clipConfiguration.width, + height: this.clipConfiguration.height + }; + } + return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; } diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 74f36e62..7fe32379 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -100,7 +100,6 @@ export abstract class Player extends Entity { this.initialClipConfiguration = null; - // Create content container for actual player content this.contentContainer = new pixi.Container(); this.getContainer().addChild(this.contentContainer); } @@ -171,7 +170,6 @@ export abstract class Player extends Entity { this.bottomLeftScaleHandle = new pixi.Graphics(); this.rotationHandle = new pixi.Graphics(); - // Set high zIndex on handles so they appear above other content this.topLeftScaleHandle.zIndex = 1000; this.topRightScaleHandle.zIndex = 1000; this.bottomRightScaleHandle.zIndex = 1000; @@ -184,7 +182,6 @@ export abstract class Player extends Entity { this.getContainer().addChild(this.bottomLeftScaleHandle); this.getContainer().addChild(this.rotationHandle); - // Enable sortable children to respect zIndex this.getContainer().sortableChildren = true; this.getContainer().cursor = "pointer"; @@ -217,7 +214,6 @@ export abstract class Player extends Entity { const angle = this.getRotation(); - // Apply opacity only to content, not to selection UI this.contentContainer.alpha = this.getOpacity(); this.getContainer().angle = angle; @@ -231,7 +227,6 @@ export abstract class Player extends Entity { return; } - // Check if this clip is selected using clean API const isSelected = this.edit.isPlayerSelected(this); const isExporting = this.edit.isInExportMode(); @@ -361,6 +356,11 @@ export abstract class Player extends Entity { y: this.offsetYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0 }; + const asset = this.clipConfiguration.asset as any; + if (this.clipConfiguration.width && this.clipConfiguration.height && asset.anchor) { + return this.positionBuilder.relativeToAbsolute(this.getSize(), asset.anchor, offset); + } + return this.positionBuilder.relativeToAbsolute(this.getSize(), this.clipConfiguration.position ?? "center", offset); } @@ -370,6 +370,10 @@ export abstract class Player extends Entity { } protected getFitScale(): number { + if (this.clipConfiguration.width && this.clipConfiguration.height) { + return 1; + } + switch (this.clipConfiguration.fit ?? "crop") { case "crop": { const ratioX = this.edit.size.width / this.getSize().width; @@ -392,6 +396,10 @@ export abstract class Player extends Entity { } protected getContainerScale(): Vector { + if (this.clipConfiguration.width && this.clipConfiguration.height) { + return { x: 1, y: 1 }; + } + const baseScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; const size = this.getSize(); const fit = this.clipConfiguration.fit ?? "crop"; @@ -439,7 +447,6 @@ export abstract class Player extends Entity { return; } - // Emit intent event for canvas click this.edit.events.emit("canvas:clip:clicked", { player: this }); this.initialClipConfiguration = structuredClone(this.clipConfiguration); @@ -691,4 +698,141 @@ export abstract class Player extends Entity { currentRotation !== initialRotation ); } + + protected applyFixedDimensions(): void { + const clipWidth = this.clipConfiguration.width; + const clipHeight = this.clipConfiguration.height; + + if (!clipWidth || !clipHeight) { + return; + } + + const sprite = this.contentContainer.children[0] as pixi.Sprite; + if (!sprite) return; + + const nativeWidth = sprite.texture.width; + const nativeHeight = sprite.texture.height; + + const fit = this.clipConfiguration.fit || "crop"; + const userScale = typeof this.clipConfiguration.scale === "number" ? this.clipConfiguration.scale : 1; + + let finalScaleX = 1; + let finalScaleY = 1; + + if (fit === "cover") { + const scaleX = clipWidth / nativeWidth; + const scaleY = clipHeight / nativeHeight; + finalScaleX = scaleX * userScale; + finalScaleY = scaleY * userScale; + } else if (fit === "crop") { + const scaleX = clipWidth / nativeWidth; + const scaleY = clipHeight / nativeHeight; + const baseScale = Math.max(scaleX, scaleY); + finalScaleX = baseScale * userScale; + finalScaleY = baseScale * userScale; + + const scaledWidth = nativeWidth * baseScale; + const scaledHeight = nativeHeight * baseScale; + + if (scaledWidth > clipWidth || scaledHeight > clipHeight) { + let cropLeft = 0; + let cropRight = 0; + let cropTop = 0; + let cropBottom = 0; + + if (scaledWidth > clipWidth) { + const overflow = (scaledWidth - clipWidth) / scaledWidth; + cropLeft = cropRight = overflow / 2; + } + + if (scaledHeight > clipHeight) { + const overflow = (scaledHeight - clipHeight) / scaledHeight; + cropTop = cropBottom = overflow / 2; + } + + const maskWidth = nativeWidth * (1 - cropLeft - cropRight); + const maskHeight = nativeHeight * (1 - cropTop - cropBottom); + const maskX = nativeWidth * cropLeft; + const maskY = nativeHeight * cropTop; + + const mask = new pixi.Graphics(); + mask.rect(maskX, maskY, maskWidth, maskHeight); + mask.fill(0xffffff); + sprite.mask = mask; + sprite.addChild(mask); + } + } else if (fit === "contain") { + const scaleX = clipWidth / nativeWidth; + const scaleY = clipHeight / nativeHeight; + const baseScale = Math.min(scaleX, scaleY); + finalScaleX = baseScale * userScale; + finalScaleY = baseScale * userScale; + } else if (fit === "none") { + finalScaleX = userScale; + finalScaleY = userScale; + + const scaledWidth = nativeWidth * userScale; + const scaledHeight = nativeHeight * userScale; + + if (scaledWidth > clipWidth || scaledHeight > clipHeight) { + let cropLeft = 0; + let cropRight = 0; + let cropTop = 0; + let cropBottom = 0; + + if (scaledWidth > clipWidth) { + const overflow = (scaledWidth - clipWidth) / scaledWidth; + cropLeft = cropRight = overflow / 2; + } + + if (scaledHeight > clipHeight) { + const overflow = (scaledHeight - clipHeight) / scaledHeight; + cropTop = cropBottom = overflow / 2; + } + + const maskWidth = nativeWidth * (1 - cropLeft - cropRight); + const maskHeight = nativeHeight * (1 - cropTop - cropBottom); + const maskX = nativeWidth * cropLeft; + const maskY = nativeHeight * cropTop; + + const mask = new pixi.Graphics(); + mask.rect(maskX, maskY, maskWidth, maskHeight); + mask.fill(0xffffff); + sprite.mask = mask; + sprite.addChild(mask); + } + } + + sprite.scale.set(finalScaleX, finalScaleY); + + const asset = this.clipConfiguration.asset as any; + const anchor = asset.anchor || "center"; + this.applyAnchorPositioning(anchor, clipWidth, clipHeight, sprite); + } + + protected applyAnchorPositioning(anchor: string, clipWidth: number, clipHeight: number, sprite: pixi.Sprite): void { + const renderedWidth = sprite.width; + const renderedHeight = sprite.height; + + let offsetX = 0; + let offsetY = 0; + + if (anchor.includes("Left") || anchor === "left") { + offsetX = 0; + } else if (anchor.includes("Right") || anchor === "right") { + offsetX = clipWidth - renderedWidth; + } else { + offsetX = (clipWidth - renderedWidth) / 2; + } + + if (anchor.includes("top") || anchor === "top") { + offsetY = 0; + } else if (anchor.includes("bottom") || anchor === "bottom") { + offsetY = clipHeight - renderedHeight; + } else { + offsetY = (clipHeight - renderedHeight) / 2; + } + + sprite.position.set(offsetX, offsetY); + } } diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 6c97e2a7..cd19751a 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -11,6 +11,7 @@ export class VideoPlayer extends Player { private texture: pixi.Texture | null; private sprite: pixi.Sprite | null; private isPlaying: boolean; + private originalSize: Size | null; private volumeKeyframeBuilder: KeyframeBuilder; @@ -23,6 +24,7 @@ export class VideoPlayer extends Player { this.texture = null; this.sprite = null; this.isPlaying = false; + this.originalSize = null; const videoAsset = this.clipConfiguration.asset as VideoAsset; @@ -57,6 +59,11 @@ export class VideoPlayer extends Player { this.sprite = new pixi.Sprite(this.texture); this.contentContainer.addChild(this.sprite); + + if (this.clipConfiguration.width && this.clipConfiguration.height) { + this.applyFixedDimensions(); + } + this.configureKeyframes(); } @@ -121,9 +128,18 @@ export class VideoPlayer extends Player { this.texture?.destroy(); this.texture = null; + + this.originalSize = null; } public override getSize(): Size { + if (this.clipConfiguration.width && this.clipConfiguration.height) { + return { + width: this.clipConfiguration.width, + height: this.clipConfiguration.height + }; + } + return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; } diff --git a/src/core/schemas/clip.ts b/src/core/schemas/clip.ts index 20767ce1..cf4eb8d7 100644 --- a/src/core/schemas/clip.ts +++ b/src/core/schemas/clip.ts @@ -68,24 +68,28 @@ const ClipTransformSchema = zod.object({ rotate: ClipTransformRotationSchema.default({ angle: 0 }) }); -export const ClipSchema = zod.object({ - asset: AssetSchema, - start: zod.number().min(0), - length: zod.number().positive(), - position: ClipAnchorSchema.default("center").optional(), - fit: ClipFitSchema.optional(), - offset: ClipOffsetSchema.default({ x: 0, y: 0 }).optional(), - opacity: ClipOpacitySchema.default(1).optional(), - scale: ClipScaleSchema.default(1).optional(), - transform: ClipTransformSchema.default({ rotate: { angle: 0 } }).optional(), - effect: ClipEffectSchema.optional(), - transition: ClipTransitionSchema.optional() -}).transform((data) => { - if (data.fit === undefined) { - data.fit = data.asset.type === "rich-text" ? "none" : "crop"; - } - return data; -}); +export const ClipSchema = zod + .object({ + asset: AssetSchema, + start: zod.number().min(0), + length: zod.number().positive(), + position: ClipAnchorSchema.default("center").optional(), + fit: ClipFitSchema.optional(), + offset: ClipOffsetSchema.default({ x: 0, y: 0 }).optional(), + opacity: ClipOpacitySchema.default(1).optional(), + scale: ClipScaleSchema.default(1).optional(), + transform: ClipTransformSchema.default({ rotate: { angle: 0 } }).optional(), + effect: ClipEffectSchema.optional(), + transition: ClipTransitionSchema.optional(), + width: zod.number().min(1).max(3840).optional(), + height: zod.number().min(1).max(2160).optional() + }) + .transform(data => { + if (data.fit === undefined) { + data.fit = data.asset.type === "rich-text" ? "none" : "crop"; + } + return data; + }); export type ClipAnchor = zod.infer; export type Clip = zod.infer; diff --git a/src/core/schemas/image-asset.ts b/src/core/schemas/image-asset.ts index 64f0a52f..352bab96 100644 --- a/src/core/schemas/image-asset.ts +++ b/src/core/schemas/image-asset.ts @@ -12,7 +12,8 @@ export const ImageAssetCropSchema = zod.object({ export const ImageAssetSchema = zod.object({ type: zod.literal("image"), src: ImageAssetUrlSchema, - crop: ImageAssetCropSchema.optional() + crop: ImageAssetCropSchema.optional(), + anchor: zod.enum(["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"]).optional() }); export type ImageAsset = zod.infer; diff --git a/src/core/schemas/video-asset.ts b/src/core/schemas/video-asset.ts index 5084ae72..a59cf0ad 100644 --- a/src/core/schemas/video-asset.ts +++ b/src/core/schemas/video-asset.ts @@ -23,7 +23,8 @@ export const VideoAssetSchema = zod.object({ src: VideoAssetUrlSchema, trim: zod.number().optional(), crop: VideoAssetCropSchema.optional(), - volume: VideoAssetVolumeSchema.optional() + volume: VideoAssetVolumeSchema.optional(), + anchor: zod.enum(["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"]).optional() }); export type VideoAsset = zod.infer; From b4023591ab713c4888c613c010d71c5c7c73296a Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Tue, 14 Oct 2025 21:55:01 +0500 Subject: [PATCH 2/3] updated anchor property to be applied on assets --- src/components/canvas/players/player.ts | 161 +++++++++--------------- 1 file changed, 62 insertions(+), 99 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 7fe32379..594e43c8 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -356,11 +356,6 @@ export abstract class Player extends Entity { y: this.offsetYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0 }; - const asset = this.clipConfiguration.asset as any; - if (this.clipConfiguration.width && this.clipConfiguration.height && asset.anchor) { - return this.positionBuilder.relativeToAbsolute(this.getSize(), asset.anchor, offset); - } - return this.positionBuilder.relativeToAbsolute(this.getSize(), this.clipConfiguration.position ?? "center", offset); } @@ -702,13 +697,10 @@ export abstract class Player extends Entity { protected applyFixedDimensions(): void { const clipWidth = this.clipConfiguration.width; const clipHeight = this.clipConfiguration.height; - - if (!clipWidth || !clipHeight) { - return; - } + if (!clipWidth || !clipHeight) return; const sprite = this.contentContainer.children[0] as pixi.Sprite; - if (!sprite) return; + if (!sprite || !sprite.texture) return; const nativeWidth = sprite.texture.width; const nativeHeight = sprite.texture.height; @@ -716,118 +708,89 @@ export abstract class Player extends Entity { const fit = this.clipConfiguration.fit || "crop"; const userScale = typeof this.clipConfiguration.scale === "number" ? this.clipConfiguration.scale : 1; - let finalScaleX = 1; - let finalScaleY = 1; - - if (fit === "cover") { - const scaleX = clipWidth / nativeWidth; - const scaleY = clipHeight / nativeHeight; - finalScaleX = scaleX * userScale; - finalScaleY = scaleY * userScale; - } else if (fit === "crop") { - const scaleX = clipWidth / nativeWidth; - const scaleY = clipHeight / nativeHeight; - const baseScale = Math.max(scaleX, scaleY); - finalScaleX = baseScale * userScale; - finalScaleY = baseScale * userScale; - - const scaledWidth = nativeWidth * baseScale; - const scaledHeight = nativeHeight * baseScale; - - if (scaledWidth > clipWidth || scaledHeight > clipHeight) { - let cropLeft = 0; - let cropRight = 0; - let cropTop = 0; - let cropBottom = 0; - - if (scaledWidth > clipWidth) { - const overflow = (scaledWidth - clipWidth) / scaledWidth; - cropLeft = cropRight = overflow / 2; - } - - if (scaledHeight > clipHeight) { - const overflow = (scaledHeight - clipHeight) / scaledHeight; - cropTop = cropBottom = overflow / 2; - } + if (this.contentContainer.mask) { + const oldMask = this.contentContainer.mask as pixi.Graphics; + try { + oldMask.destroy(); + } catch {} + this.contentContainer.mask = null as any; + } + const clipMask = new pixi.Graphics(); + clipMask.rect(0, 0, clipWidth, clipHeight); + clipMask.fill(0xffffff); + this.contentContainer.addChild(clipMask); + this.contentContainer.mask = clipMask; - const maskWidth = nativeWidth * (1 - cropLeft - cropRight); - const maskHeight = nativeHeight * (1 - cropTop - cropBottom); - const maskX = nativeWidth * cropLeft; - const maskY = nativeHeight * cropTop; + const scaleX = clipWidth / nativeWidth; + const scaleY = clipHeight / nativeHeight; - const mask = new pixi.Graphics(); - mask.rect(maskX, maskY, maskWidth, maskHeight); - mask.fill(0xffffff); - sprite.mask = mask; - sprite.addChild(mask); - } - } else if (fit === "contain") { - const scaleX = clipWidth / nativeWidth; - const scaleY = clipHeight / nativeHeight; - const baseScale = Math.min(scaleX, scaleY); - finalScaleX = baseScale * userScale; - finalScaleY = baseScale * userScale; - } else if (fit === "none") { - finalScaleX = userScale; - finalScaleY = userScale; - - const scaledWidth = nativeWidth * userScale; - const scaledHeight = nativeHeight * userScale; - - if (scaledWidth > clipWidth || scaledHeight > clipHeight) { - let cropLeft = 0; - let cropRight = 0; - let cropTop = 0; - let cropBottom = 0; - - if (scaledWidth > clipWidth) { - const overflow = (scaledWidth - clipWidth) / scaledWidth; - cropLeft = cropRight = overflow / 2; - } + let baseScale = 1; + switch (fit) { + case "cover": + case "crop": + baseScale = Math.max(scaleX, scaleY); + break; + case "contain": + baseScale = Math.min(scaleX, scaleY); + break; + case "none": + default: + baseScale = 1; + break; + } + const finalScale = baseScale * userScale; + sprite.scale.set(finalScale, finalScale); - if (scaledHeight > clipHeight) { - const overflow = (scaledHeight - clipHeight) / scaledHeight; - cropTop = cropBottom = overflow / 2; - } + const asset: any = this.clipConfiguration.asset; + const anchorRaw = (asset?.anchor as string) ?? "center"; + const anchor = anchorRaw.toLowerCase(); - const maskWidth = nativeWidth * (1 - cropLeft - cropRight); - const maskHeight = nativeHeight * (1 - cropTop - cropBottom); - const maskX = nativeWidth * cropLeft; - const maskY = nativeHeight * cropTop; + const renderedWidth = nativeWidth * finalScale; + const renderedHeight = nativeHeight * finalScale; - const mask = new pixi.Graphics(); - mask.rect(maskX, maskY, maskWidth, maskHeight); - mask.fill(0xffffff); - sprite.mask = mask; - sprite.addChild(mask); - } - } + const offsetX = + anchor.includes("left") || anchor === "left" + ? 0 + : anchor.includes("right") || anchor === "right" + ? clipWidth - renderedWidth + : (clipWidth - renderedWidth) / 2; - sprite.scale.set(finalScaleX, finalScaleY); + const offsetY = + anchor.includes("top") || anchor === "top" + ? 0 + : anchor.includes("bottom") || anchor === "bottom" + ? clipHeight - renderedHeight + : (clipHeight - renderedHeight) / 2; - const asset = this.clipConfiguration.asset as any; - const anchor = asset.anchor || "center"; - this.applyAnchorPositioning(anchor, clipWidth, clipHeight, sprite); + sprite.position.set(offsetX, offsetY); } protected applyAnchorPositioning(anchor: string, clipWidth: number, clipHeight: number, sprite: pixi.Sprite): void { const renderedWidth = sprite.width; const renderedHeight = sprite.height; + const hasMask = Boolean(sprite.mask); + if (hasMask) { + sprite.position.set(0, 0); + return; + } + + const a = (anchor ?? "center").toLowerCase(); + let offsetX = 0; let offsetY = 0; - if (anchor.includes("Left") || anchor === "left") { + if (a.includes("left") || a === "left") { offsetX = 0; - } else if (anchor.includes("Right") || anchor === "right") { + } else if (a.includes("right") || a === "right") { offsetX = clipWidth - renderedWidth; } else { offsetX = (clipWidth - renderedWidth) / 2; } - if (anchor.includes("top") || anchor === "top") { + if (a.includes("top") || a === "top") { offsetY = 0; - } else if (anchor.includes("bottom") || anchor === "bottom") { + } else if (a.includes("bottom") || a === "bottom") { offsetY = clipHeight - renderedHeight; } else { offsetY = (clipHeight - renderedHeight) / 2; From 2815fb6558fd3bc06f4f5b2bd60b73f9caa0de7c Mon Sep 17 00:00:00 2001 From: Kratos2k7 Date: Wed, 15 Oct 2025 04:22:36 +0500 Subject: [PATCH 3/3] removed anchor property --- src/core/schemas/image-asset.ts | 3 +-- src/core/schemas/video-asset.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/core/schemas/image-asset.ts b/src/core/schemas/image-asset.ts index 352bab96..64f0a52f 100644 --- a/src/core/schemas/image-asset.ts +++ b/src/core/schemas/image-asset.ts @@ -12,8 +12,7 @@ export const ImageAssetCropSchema = zod.object({ export const ImageAssetSchema = zod.object({ type: zod.literal("image"), src: ImageAssetUrlSchema, - crop: ImageAssetCropSchema.optional(), - anchor: zod.enum(["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"]).optional() + crop: ImageAssetCropSchema.optional() }); export type ImageAsset = zod.infer; diff --git a/src/core/schemas/video-asset.ts b/src/core/schemas/video-asset.ts index a59cf0ad..5084ae72 100644 --- a/src/core/schemas/video-asset.ts +++ b/src/core/schemas/video-asset.ts @@ -23,8 +23,7 @@ export const VideoAssetSchema = zod.object({ src: VideoAssetUrlSchema, trim: zod.number().optional(), crop: VideoAssetCropSchema.optional(), - volume: VideoAssetVolumeSchema.optional(), - anchor: zod.enum(["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"]).optional() + volume: VideoAssetVolumeSchema.optional() }); export type VideoAsset = zod.infer;