Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/components/canvas/players/image-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import { Player } from "./player";
export class ImagePlayer extends Player {
private texture: pixi.Texture<pixi.ImageSource> | 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<void> {
Expand All @@ -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();
}

Expand All @@ -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 };
}

Expand Down
119 changes: 113 additions & 6 deletions src/components/canvas/players/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -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;

Expand All @@ -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();
Expand Down Expand Up @@ -370,6 +365,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;
Expand All @@ -392,6 +391,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";
Expand Down Expand Up @@ -439,7 +442,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);
Expand Down Expand Up @@ -691,4 +693,109 @@ 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 || !sprite.texture) 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;

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 scaleX = clipWidth / nativeWidth;
const scaleY = clipHeight / nativeHeight;

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);

const asset: any = this.clipConfiguration.asset;
const anchorRaw = (asset?.anchor as string) ?? "center";
const anchor = anchorRaw.toLowerCase();

const renderedWidth = nativeWidth * finalScale;
const renderedHeight = nativeHeight * finalScale;

const offsetX =
anchor.includes("left") || anchor === "left"
? 0
: anchor.includes("right") || anchor === "right"
? clipWidth - renderedWidth
: (clipWidth - renderedWidth) / 2;

const offsetY =
anchor.includes("top") || anchor === "top"
? 0
: anchor.includes("bottom") || anchor === "bottom"
? clipHeight - renderedHeight
: (clipHeight - renderedHeight) / 2;

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 (a.includes("left") || a === "left") {
offsetX = 0;
} else if (a.includes("right") || a === "right") {
offsetX = clipWidth - renderedWidth;
} else {
offsetX = (clipWidth - renderedWidth) / 2;
}

if (a.includes("top") || a === "top") {
offsetY = 0;
} else if (a.includes("bottom") || a === "bottom") {
offsetY = clipHeight - renderedHeight;
} else {
offsetY = (clipHeight - renderedHeight) / 2;
}

sprite.position.set(offsetX, offsetY);
}
}
16 changes: 16 additions & 0 deletions src/components/canvas/players/video-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export class VideoPlayer extends Player {
private texture: pixi.Texture<pixi.VideoSource> | null;
private sprite: pixi.Sprite | null;
private isPlaying: boolean;
private originalSize: Size | null;

private volumeKeyframeBuilder: KeyframeBuilder;

Expand All @@ -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;

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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 };
}

Expand Down
40 changes: 22 additions & 18 deletions src/core/schemas/clip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ClipAnchorSchema>;
export type Clip = zod.infer<typeof ClipSchema>;