From e3b7864a8a7a9ff6058da3cc7500be77eec997b6 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 2 Dec 2025 23:07:16 +1100 Subject: [PATCH 001/463] fix: make border required in rich-text types for canvas compatibility --- src/components/timeline/types/assets.ts | 2 +- src/core/schemas/rich-text-asset.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/timeline/types/assets.ts b/src/components/timeline/types/assets.ts index 00e80232..9ddbb83d 100644 --- a/src/components/timeline/types/assets.ts +++ b/src/components/timeline/types/assets.ts @@ -100,7 +100,7 @@ export interface ValidatedRichTextAsset { background: { color?: string; opacity: number; - border?: { width: number; color: string; opacity: number }; + border: { width: number; color: string; opacity: number }; }; padding?: number | { top: number; right: number; bottom: number; left: number }; align: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" }; diff --git a/src/core/schemas/rich-text-asset.ts b/src/core/schemas/rich-text-asset.ts index a0089cca..ab4737b1 100644 --- a/src/core/schemas/rich-text-asset.ts +++ b/src/core/schemas/rich-text-asset.ts @@ -67,7 +67,7 @@ const RichTextBackgroundSchema = zod .object({ color: HexColorSchema.optional(), opacity: zod.number().min(0).max(1).default(1), - border: RichTextBorderSchema.optional() + border: RichTextBorderSchema.default({ width: 0, color: "#000000", opacity: 1 }) }) .strict(); From 51fa54a24feea67eca2c34d2399c0af0fa8310c1 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 2 Dec 2025 23:07:27 +1100 Subject: [PATCH 002/463] fix: remove redundant internal comment from timing types --- src/core/timing/types.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/core/timing/types.ts b/src/core/timing/types.ts index 1479b3ca..3e5f4c8c 100644 --- a/src/core/timing/types.ts +++ b/src/core/timing/types.ts @@ -1,8 +1,3 @@ -/** - * Timing types for auto/end clip resolution. - * @internal - */ - /** * A timing value can be a numeric value (in seconds) or a special string. * - "auto" for start: position after previous clip on track From dbb2c8fb0308afe21fe9eb5ced6436917d3f6835 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 2 Dec 2025 23:07:38 +1100 Subject: [PATCH 003/463] docs: update dev command from make to npm run --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 0c03c21a..a47819ef 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ import { Edit, Canvas, Controls, VideoExporter } from "./index"; /** * This is a simple example that implements the README quick start guide - * Run with `make dev` to see it in action + * Run with `npm run dev` to see it in action */ async function main() { try { From 7328da5b8718b2c4a381c14760d2e960e81cbfe9 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 3 Dec 2025 18:52:42 +1100 Subject: [PATCH 004/463] feat: rename to Shotstack Studio and implement responsive canvas zoom --- index.html | 3 +- package.json | 2 +- src/components/canvas/players/player.ts | 22 +++++++----- src/components/canvas/shotstack-canvas.ts | 44 ++++++++++++----------- src/core/edit.ts | 11 ++++++ src/main.ts | 2 +- src/styles/main.css | 9 ++++- 7 files changed, 60 insertions(+), 33 deletions(-) diff --git a/index.html b/index.html index 8780ea72..cec8974b 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,9 @@ + - Shotstack Canvas + Shotstack Studio
diff --git a/package.json b/package.json index b8340f43..10ad3c52 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "cpuccino", "dazzatron" ], - "version": "1.10.1", + "version": "2.0.0", "description": "A video editing library for creating and editing videos with Shotstack", "type": "module", "main": "dist/shotstack-studio.umd.js", diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index a38e23d8..543cc855 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -335,13 +335,13 @@ export abstract class Player extends Entity { return; } - const color = this.isHovering || this.isDragging ? 0x00ffff : 0xffffff; + const color = this.isHovering || this.isDragging ? 0x00ffff : 0x0d99ff; const size = this.getSize(); - const scale = this.getScale(); + const uiScale = this.getUIScale(); this.outline.clear(); - this.outline.strokeStyle = { width: Player.OutlineWidth / scale, color }; + this.outline.strokeStyle = { width: Player.OutlineWidth / uiScale, color }; this.outline.rect(0, 0, size.width, size.height); this.outline.stroke(); @@ -356,7 +356,7 @@ export abstract class Player extends Entity { this.bottomRightScaleHandle && this.bottomLeftScaleHandle ) { - const handleSize = (Player.ScaleHandleRadius * 2) / scale; + const handleSize = (Player.ScaleHandleRadius * 2) / uiScale; this.topLeftScaleHandle.fillStyle = { color }; this.topLeftScaleHandle.clear(); @@ -382,14 +382,14 @@ export abstract class Player extends Entity { // Draw rotation handle (for all asset types) if (this.rotationHandle) { const rotationHandleX = size.width / 2; - const rotationHandleY = -Player.RotationHandleOffset / scale; + const rotationHandleY = -Player.RotationHandleOffset / uiScale; this.rotationHandle.clear(); this.rotationHandle.fillStyle = { color }; - this.rotationHandle.circle(rotationHandleX, rotationHandleY, Player.RotationHandleRadius / scale); + this.rotationHandle.circle(rotationHandleX, rotationHandleY, Player.RotationHandleRadius / uiScale); this.rotationHandle.fill(); - this.outline.strokeStyle = { width: Player.OutlineWidth / scale, color }; + this.outline.strokeStyle = { width: Player.OutlineWidth / uiScale, color }; this.outline.moveTo(rotationHandleX, 0); this.outline.lineTo(rotationHandleX, rotationHandleY); this.outline.stroke(); @@ -397,8 +397,8 @@ export abstract class Player extends Entity { // Draw edge handles for text/rich-text assets if (this.supportsEdgeResize()) { - const edgeLength = Player.EdgeHandleLength / scale; - const edgeThickness = Player.EdgeHandleThickness / scale; + const edgeLength = Player.EdgeHandleLength / uiScale; + const edgeThickness = Player.EdgeHandleThickness / uiScale; // Left edge handle (vertical bar on left edge, centered) if (this.leftEdgeHandle) { @@ -563,6 +563,10 @@ export abstract class Player extends Entity { return (this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1) * this.getFitScale(); } + private getUIScale(): number { + return this.getScale() * this.edit.getCanvasZoom(); + } + protected getContainerScale(): Vector { if (this.clipConfiguration.width && this.clipConfiguration.height) { return { x: 1, y: 1 }; diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 5956a66b..1bea68cc 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -13,7 +13,7 @@ export class Canvas { private static extensionsRegistered = false; - private readonly size: Size; + private viewportSize: Size = { width: 0, height: 0 }; /** @internal */ public readonly application: pixi.Application; @@ -26,18 +26,17 @@ export class Canvas { private minZoom = 0.1; private maxZoom = 4; - private currentZoom = 0.8; + private currentZoom = 1; private onTickBound: (ticker: pixi.Ticker) => void; - constructor(size: Size, edit: Edit) { - this.size = size; + constructor(edit: Edit) { this.application = new pixi.Application(); - this.edit = edit; this.inspector = new Inspector(); - this.onTickBound = this.onTick.bind(this); + + edit.setCanvas(this); } public async load(): Promise { @@ -46,19 +45,22 @@ export class Canvas { throw new Error(`Shotstack canvas root element '${Canvas.CanvasSelector}' not found.`); } + const rect = root.getBoundingClientRect(); + this.viewportSize = + rect.width > 0 && rect.height > 0 ? { width: rect.width, height: rect.height } : { width: this.edit.size.width, height: this.edit.size.height }; + this.registerExtensions(); this.container = new pixi.Container(); this.background = new pixi.Graphics(); this.background.fillStyle = { color: "#424242" }; - this.background.rect(0, 0, this.size.width, this.size.height); + this.background.rect(0, 0, this.viewportSize.width, this.viewportSize.height); this.background.fill(); await this.configureApplication(); this.configureStage(); - this.setupTouchHandling(root); - this.edit.getContainer().scale = this.currentZoom; + this.zoomToFit(); root.appendChild(this.application.canvas); } @@ -116,13 +118,16 @@ export class Canvas { }; } - public zoomToFit(): void { + public zoomToFit(padding: number = 40): void { if (!this.edit) { return; } - const widthRatio = this.application.canvas.width / this.edit.size.width; - const heightRatio = this.application.canvas.height / this.edit.size.height; + const availableWidth = this.viewportSize.width - padding * 2; + const availableHeight = this.viewportSize.height - padding * 2; + + const widthRatio = availableWidth / this.edit.size.width; + const heightRatio = availableHeight / this.edit.size.height; const idealZoom = Math.min(widthRatio, heightRatio); @@ -142,6 +147,10 @@ export class Canvas { edit.scale.y = this.currentZoom; } + public getZoom(): number { + return this.currentZoom; + } + public registerTimeline(timeline: Timeline): void { this.timeline = timeline; } @@ -157,8 +166,8 @@ export class Canvas { private async configureApplication(): Promise { const options: Partial = { background: "#000000", - width: this.size.width, - height: this.size.height, + width: this.viewportSize.width, + height: this.viewportSize.height, antialias: true }; @@ -199,17 +208,12 @@ export class Canvas { this.application.stage.addChild(this.container); this.application.stage.eventMode = "static"; - this.application.stage.hitArea = new pixi.Rectangle(0, 0, this.size.width, this.size.height); + this.application.stage.hitArea = new pixi.Rectangle(0, 0, this.viewportSize.width, this.viewportSize.height); this.background.eventMode = "static"; this.background.on("pointerdown", this.onBackgroundClick.bind(this)); this.application.stage.on("click", this.onClick.bind(this)); - - this.edit.getContainer().position = { - x: this.application.canvas.width / 2 - (this.edit.size.width * this.currentZoom) / 2, - y: this.application.canvas.height / 2 - (this.edit.size.height * this.currentZoom) / 2 - }; } private onClick(): void { diff --git a/src/core/edit.ts b/src/core/edit.ts index e44af0e3..74fcc712 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1,3 +1,4 @@ +import type { Canvas } from "@canvas/shotstack-canvas"; import { AudioPlayer } from "@canvas/players/audio-player"; import { HtmlPlayer } from "@canvas/players/html-player"; import { ImagePlayer } from "@canvas/players/image-player"; @@ -72,6 +73,8 @@ export class Edit extends Entity { private cachedTimelineEnd: number = 0; private endLengthClips: Set = new Set(); + private canvas: Canvas | null = null; + constructor(size: Size, backgroundColor: string = "#ffffff") { super(); @@ -809,6 +812,14 @@ export class Edit extends Entity { return this.isExporting; } + public setCanvas(canvas: Canvas): void { + this.canvas = canvas; + } + + public getCanvasZoom(): number { + return this.canvas?.getZoom() ?? 1; + } + private setupIntentListeners(): void { this.events.on("timeline:clip:clicked", (data: { player: Player; trackIndex: number; clipIndex: number }) => { if (data.player) { diff --git a/src/main.ts b/src/main.ts index a47819ef..6037db2a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,7 +18,7 @@ async function main() { await edit.load(); // 3. Create a canvas to display the edit - const canvas = new Canvas(template.output.size, edit); + const canvas = new Canvas(edit); await canvas.load(); // Renders to [data-shotstack-studio] element // 4. Load the template diff --git a/src/styles/main.css b/src/styles/main.css index 822bf1df..77e60617 100644 --- a/src/styles/main.css +++ b/src/styles/main.css @@ -2,9 +2,16 @@ html, body { + height: 100%; overscroll-behavior: none; } .c-shotstack-studio { - width: 80%; + width: 100%; + height: calc(100vh - 300px); + min-height: 400px; +} + +.c-shotstack-timeline { + height: 300px; } From bf0f7a371e0beeb3e3d8c2522c4599707bf0b8b1 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 3 Dec 2025 19:48:50 +1100 Subject: [PATCH 005/463] refactor: simplify crop scaling logic by removing prescale and using direct max scale calculation --- src/components/canvas/players/player.ts | 29 +++---------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 543cc855..9dfade06 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -1010,35 +1010,12 @@ export abstract class Player extends Entity { break; } - // 🟢 crop → uniform fill but never downscale (only upscale if smaller) + // 🟢 crop → uniform fill using max scale (overflow is masked/cropped) case "crop": { - // Viewport (output) dimensions — same concept as backend "canvas" - const outW = this.edit.size.width; - const outH = this.edit.size.height; - - // 1) Pre-downscale to fit the viewport if the source is larger (preserve AR) - let prescale = 1; - if (nativeWidth > outW || nativeHeight > outH) { - prescale = Math.min(outW / nativeWidth, outH / nativeHeight); - } - - // Adjusted (virtual) native after prescale - const adjW = nativeWidth * prescale; - const adjH = nativeHeight * prescale; - - // 2) Uniform fill to cover the clip box (may overflow → mask crops) - const fill = Math.max(clipWidth / adjW, clipHeight / adjH); - - // 3) Effective scale to apply to the *original* texture: - // - Large images: prescale * fill (we normalized to viewport first) - // - Small images: never downscale below native => clamp to >= 1 - const effective = prescale < 1 ? prescale * fill : Math.max(1, fill); - - // Apply base fit (animation is applied separately via contentContainer in your code) - sprite.scale.set(effective, effective); + const cropScale = Math.max(clipWidth / nativeWidth, clipHeight / nativeHeight); + sprite.scale.set(cropScale, cropScale); sprite.anchor.set(0.5, 0.5); sprite.position.set(clipWidth / 2, clipHeight / 2); - break; } From 2bc42fbf5dc14305384f8c4eacca1a936a1b4964 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 3 Dec 2025 19:58:42 +1100 Subject: [PATCH 006/463] feat: add edge resize support to image and video players --- src/components/canvas/players/image-player.ts | 4 ++++ src/components/canvas/players/player.ts | 13 +++++++++---- src/components/canvas/players/video-player.ts | 4 ++++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index f63173cc..6471cdb5 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -80,6 +80,10 @@ export class ImagePlayer extends Player { return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; } + protected override supportsEdgeResize(): boolean { + return true; + } + private createCroppedTexture(texture: pixi.Texture): pixi.Texture { const imageAsset = this.clipConfiguration.asset as ImageAsset; diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 9dfade06..3207e9e8 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -985,14 +985,19 @@ export abstract class Player extends Entity { const nativeHeight = sprite.texture.height; const fit = this.clipConfiguration.fit || "crop"; - if (!this.contentContainer.mask) { - const clipMask = new pixi.Graphics(); - clipMask.rect(0, 0, clipWidth, clipHeight); - clipMask.fill(0xffffff); + // Get or create the mask + let clipMask = this.contentContainer.mask as pixi.Graphics; + if (!clipMask) { + clipMask = new pixi.Graphics(); this.contentContainer.addChild(clipMask); this.contentContainer.mask = clipMask; } + // Update mask to current dimensions + clipMask.clear(); + clipMask.rect(0, 0, clipWidth, clipHeight); + clipMask.fill(0xffffff); + // keep animation code exactly as-is const currentUserScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index cd19751a..900381e8 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -143,6 +143,10 @@ export class VideoPlayer extends Player { return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; } + protected override supportsEdgeResize(): boolean { + return true; + } + public getVolume(): number { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } From 074be899b2882feb1624eaf3a620cc03fa53e53b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 10:33:54 +1100 Subject: [PATCH 007/463] feat: add font configuration system and consolidate font handling --- public/assets/fonts/Montserrat.ttf | Bin 0 -> 688600 bytes public/assets/fonts/OpenSans.ttf | Bin 0 -> 529700 bytes public/assets/fonts/Roboto.ttf | Bin 0 -> 468308 bytes public/assets/fonts/WorkSans.ttf | Bin 0 -> 359628 bytes .../canvas/players/rich-text-player.ts | 163 +++++++----------- src/components/canvas/players/text-player.ts | 27 ++- src/core/fonts/font-config.ts | 71 ++++++++ src/core/schemas/text-asset.ts | 4 +- src/templates/hello.json | 72 ++++---- 9 files changed, 202 insertions(+), 135 deletions(-) create mode 100644 public/assets/fonts/Montserrat.ttf create mode 100644 public/assets/fonts/OpenSans.ttf create mode 100644 public/assets/fonts/Roboto.ttf create mode 100644 public/assets/fonts/WorkSans.ttf create mode 100644 src/core/fonts/font-config.ts diff --git a/public/assets/fonts/Montserrat.ttf b/public/assets/fonts/Montserrat.ttf new file mode 100644 index 0000000000000000000000000000000000000000..451e69288c17eb9823f8e96f1a2b9621094d2668 GIT binary patch literal 688600 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

i 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

Mvhq9sa> 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 TAh1bm+k{5T`8749S?d1)mqaIo literal 0 HcmV?d00001 diff --git a/public/assets/fonts/Roboto.ttf b/public/assets/fonts/Roboto.ttf new file mode 100644 index 0000000000000000000000000000000000000000..bba55f616c811c4ee31535667dd2ed30229e5e71 GIT binary patch literal 468308 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

Q(u6ZZl>(a08KItzpa5=rSPnQ_ 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

_ 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

j{ zE9Fi5@vHj4Ky9RP091O@tW(2TFM96a3z0Ho9t3)S`lXQHHY-%6(6##l>B*TIVjJ+F z{IyK=CC6El0jX&zV}8%qkM+|B8C?v!)Zp0Z`XVe%#4`X_qFKe<7?C+9WF2VgBhauGG>?%rC? zrUmo1vJ}@_I0H^Qc}Q@tKM&J%O<96$3rEbhaIVCvR9{b9q6%TZAV$a8td8ZHy`dKI zLkLY;Fxvq=={pDS$why(h>Yew{71&?lZx&N*XxtrR`P{gt>)=o(d&iwLZ11U`Q(M( zPn${so+-wo^Ljb?OHJfi9iCG3xGOv@7Xzzs-dhrgO?v?m_GJ?COj3FJv0H?Zp)NKo z%aj}i#PbnKS@J?^E>!b@k26et8XrqoJ}EW4kFI>@YoT|2I~NrHb%vt*XNE zP(tDkI!9sxAuwX!3!$HD6D9J`S&UMYOE{fKbmHC)(wO*o9(hW9*j0nFs!|gLjw$9( z`1Db;T9RE0{Xo_TB_Mzkff;n4{u_8vb{x;^MNKl-5SwtIxtn)>D^ z6n6Ome<$F}v~2c;;a$6NR~S;-PaFC`w9?6})lrG2>?o;%0GvVU7T}(d8SJ@sAIR9| zVbgyj=7&W4K1n)eFJtDuj_gjHeE{CvAHPN@Azhb&Jvu(EL$k*n?HPyIYVK$$*(# z_*1SAatNBJ|0|_a^iW;l*7bdX58%r6f!hmNiSOIJxf}Y$KnHCbSB@VKZ-m~ zpYKZA(GW5Qp{Bg8a;NB+S(iWH_1+kQfz;z{^)6t!5Uyo2LZ~Jbtl1bBzpp%;^~7Gc z-GN-Yk@w*L!i$vC{mt2^Q6%@T_!iz_{|8TBv_OYK9P<9$81V<2=Nm=!BVt3th2-tycEANh6_P?v+ZZieUa zL(B8{Vcxe_=VUrMdPiT5QJ+_y6z_Qa7r(%C(_yvoN~iOqk3KpOj&)=X?%uI&+qTYN zRYf=yudcpz<^ydN$@RS*D}yU9z8F#n=*{3B80 zgUOtOd%JtJ6OL7ObabGP(LogowhW66st$$rzj)6*_dK!jV06Tov17-+p!Ex>svAO~ z>g~_mci(-B4jeJ@yUpr0b=xuf_H~Dbj5y=GN#nNY!aq>;ZJ1dU5{*7H1FR~hI}*sjA*^Nqi=10(Sd0+Nc4noC!b&Jw7 zYxz}<1w#QJs-gHojhB6(nmxHo2};r*IhqJc;_TM?{{qehAy!Bh;TVp7=YI4GCO=$^i=H1-Yp zQmoC5Vn160V*(Gf&v+Zmrvmf;0nE<^=2rsqt1bDpC+3}W`Ri*|ZrGcS z)q&gV>mvKNty{U~^~*0Er?$Pj_>Xtt_3y8IxL1$6?Ad20PDHjcuxr=mj+RK~;I_7r z!{dRL`oRrH9C7NGuDRx#%O{;UvTe=Nf4ukJd;k5l1)0o()Jb=pd`6?ww`;@etMPjC zy{+4wQJ+3#_MGS5+8!H!)i?3_$~nXP-h5)tm%jAuvxu7l4Gq;!%W+O@Xyc|GU0Ezm z=azTheg1bCGCXJD`b~`^KXb+O>C-PiCHC^mG04nK+3#k*7ixXrspTK+PlXPAu>9!< zW}KV-9)5dM_D41UGUw@+-`kC4@7n#|vZv=bz-n;;ed?nYm;; z6y(NZkSNRmrc^;__=)rEF}Na;BuWkY$Y(&RwO$t6j0-k=2L8{mTsM=d?85+H$N-VY zDBlLi38v-?W+gu6pc0nTnE~ zbW!o!Ucz*fz!@fiMQ4zjgk0VVbNVofr^OYJX476UEY4y6c}r!K(MwPgvd)?6B0QsJ z#`{}*Sa&{{?AQp^{C>T)DnhNJ-HzIGRipUzoO^LHo(;t8R%X;I^zWTK>(;8+`LBE#WzNSw<}I)o|M z^x-@B3E5xM8jbsAo*dZw3gKz_%$y)M*!1k{Gd=s(dQoki(HL$m-r?-P+(YtJFzs*p z@<Rgq<%vP V$BGt0hpG?xH@A)?7r;A-{{v>9Kezw@ literal 0 HcmV?d00001 diff --git a/public/assets/fonts/WorkSans.ttf b/public/assets/fonts/WorkSans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..16293f14056e593cc317ec25c1945cfb02d68e7d GIT binary patch literal 359628 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

11NiHw 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 -} - const extractFontNames = (url: string): { full: string; base: string } => { const filename = url.split("/").pop() || ""; const withoutExtension = filename.replace(/\.(ttf|otf|woff|woff2)$/i, ""); @@ -42,70 +37,63 @@ export class RichTextPlayer extends Player { super(edit, clipConfiguration); } - private buildCanvasPayload(richTextAsset: RichTextAsset): any { + private buildCanvasPayload( + richTextAsset: RichTextAsset, + fontInfo?: { baseFontFamily: string; fontWeight: number } + ): any { const editData = this.edit.getEdit(); const width = this.clipConfiguration.width || editData?.output?.size?.width || this.edit.size.width; const height = this.clipConfiguration.height || editData?.output?.size?.height || this.edit.size.height; - // Build customFonts array internally - let customFonts: Array<{ src: string; family: string; weight: string }> | undefined; - if (Array.isArray(editData?.timeline?.fonts) && editData.timeline.fonts.length > 0) { - const requestedFamily = richTextAsset.font?.family; - if (requestedFamily) { - const matchingFont = editData.timeline.fonts?.find(font => { + // Use provided font info or parse fresh (for reconfigure/updateTextContent calls) + const requestedFamily = richTextAsset.font?.family; + const { baseFontFamily, fontWeight } = fontInfo + ?? (requestedFamily ? parseFontFamily(requestedFamily) : { baseFontFamily: requestedFamily, fontWeight: 400 }); + + // Find matching timeline font for customFonts payload + const timelineFonts = editData?.timeline?.fonts || []; + const matchingFont = requestedFamily + ? timelineFonts.find(font => { const { full, base } = extractFontNames(font.src); const requested = requestedFamily.toLowerCase(); return full.toLowerCase() === requested || base.toLowerCase() === requested; - }); + }) + : undefined; - if (matchingFont) { - customFonts = [ - { - src: matchingFont.src, - family: requestedFamily, - weight: richTextAsset.font?.weight?.toString() || "400" - } - ]; - } - } - } + const customFonts = matchingFont + ? [{ src: matchingFont.src, family: baseFontFamily || requestedFamily, weight: fontWeight.toString() }] + : undefined; - // Build payload with stroke extracted from font and placed at root level for canvas compatibility - const payload: any = { + return { ...richTextAsset, width, height, - // Extract stroke from font property and place at root level for canvas compatibility + font: richTextAsset.font + ? { ...richTextAsset.font, family: baseFontFamily, weight: fontWeight } + : undefined, stroke: richTextAsset.font?.stroke, ...(customFonts && { customFonts }) }; - - return payload; } - private createFontMapping(): Map { - const fontMap = new Map(); - - fontMap.set("Arapey", "/assets/fonts/Arapey-Regular.ttf"); - fontMap.set("ClearSans", "/assets/fonts/ClearSans-Regular.ttf"); - fontMap.set("Clear Sans", "/assets/fonts/ClearSans-Regular.ttf"); - fontMap.set("DidactGothic", "/assets/fonts/DidactGothic-Regular.ttf"); - fontMap.set("Didact Gothic", "/assets/fonts/DidactGothic-Regular.ttf"); - fontMap.set("Montserrat", "/assets/fonts/Montserrat-SemiBold.ttf"); - fontMap.set("MovLette", "/assets/fonts/MovLette.ttf"); - fontMap.set("OpenSans", "/assets/fonts/OpenSans-Bold.ttf"); - fontMap.set("Open Sans", "/assets/fonts/OpenSans-Bold.ttf"); - fontMap.set("PermanentMarker", "/assets/fonts/PermanentMarker-Regular.ttf"); - fontMap.set("Permanent Marker", "/assets/fonts/PermanentMarker-Regular.ttf"); - fontMap.set("Roboto", "/assets/fonts/Roboto-BlackItalic.ttf"); - fontMap.set("SueEllenFrancisco", "/assets/fonts/SueEllenFrancisco.ttf"); - fontMap.set("Sue Ellen Francisco", "/assets/fonts/SueEllenFrancisco.ttf"); - fontMap.set("UniNeue", "/assets/fonts/UniNeue-Bold.otf"); - fontMap.set("Uni Neue", "/assets/fonts/UniNeue-Bold.otf"); - fontMap.set("WorkSans", "/assets/fonts/WorkSans-Light.ttf"); - fontMap.set("Work Sans", "/assets/fonts/WorkSans-Light.ttf"); - - return fontMap; + private async registerFont( + family: string, + weight: number, + source: { type: "url"; path: string } | { type: "file"; path: string } + ): Promise { + if (!this.textEngine) return false; + try { + const fontDesc = { family, weight: weight.toString() }; + if (source.type === "url") { + await this.textEngine.registerFontFromUrl(source.path, fontDesc); + } else { + await this.textEngine.registerFontFromFile(source.path, fontDesc); + } + return true; + } catch (error) { + console.warn(`Failed to load font ${family}:`, error); + return false; + } } public override reconfigureAfterRestore(): void { @@ -138,7 +126,6 @@ export class RichTextPlayer extends Player { const editData = this.edit.getEdit(); this.targetFPS = editData?.output?.fps || 30; - // Validate the rich-text asset schema (without width, height, customFonts) const validationResult = RichTextAssetSchema.safeParse(richTextAsset); if (!validationResult.success) { console.error("Rich-text asset validation failed:", validationResult.error); @@ -146,8 +133,11 @@ export class RichTextPlayer extends Player { return; } - // Build canvas payload with dimensions and customFonts - const canvasPayload = this.buildCanvasPayload(richTextAsset); + // Parse font info once, reuse throughout + const requestedFamily = richTextAsset.font?.family; + const fontInfo = requestedFamily ? parseFontFamily(requestedFamily) : undefined; + + const canvasPayload = this.buildCanvasPayload(richTextAsset, fontInfo); this.textEngine = (await createTextEngine({ width: canvasPayload.width, @@ -158,53 +148,32 @@ export class RichTextPlayer extends Player { const { value: validated } = this.textEngine!.validate(canvasPayload); this.validatedAsset = validated; - const fontMap = this.createFontMapping(); - this.canvas = document.createElement("canvas"); this.canvas.width = canvasPayload.width; this.canvas.height = canvasPayload.height; this.renderer = this.textEngine!.createRenderer(this.canvas); - const timelineFonts = editData?.timeline?.fonts || []; - - if (timelineFonts.length > 0) { - const requestedFamily = richTextAsset.font?.family; - if (requestedFamily) { - const matchingFont = timelineFonts.find(font => { - const { full, base } = extractFontNames(font.src); - const requested = requestedFamily.toLowerCase(); - return full.toLowerCase() === requested || base.toLowerCase() === requested; - }); - - if (matchingFont) { - try { - const fontDesc = { - family: requestedFamily, - weight: richTextAsset.font?.weight?.toString() || "400" - }; - await this.textEngine!.registerFontFromUrl(matchingFont.src, fontDesc); - } catch (error) { - console.warn(`Failed to load font ${requestedFamily}:`, error); - } - } - } - } else if (richTextAsset.font?.family) { - const fontFamily = richTextAsset.font.family; - const fontPath = fontMap.get(fontFamily); - - if (fontPath) { - try { - const fontDesc = { - family: richTextAsset.font.family, - weight: richTextAsset.font.weight || "400" - }; - await this.textEngine!.registerFontFromFile(fontPath, fontDesc); - } catch (error) { - console.warn(`Failed to load local font: ${fontFamily}`, error); - } + // Register font: try timeline fonts first, then built-in fonts + if (fontInfo && requestedFamily) { + const { baseFontFamily, fontWeight } = fontInfo; + const timelineFonts = editData?.timeline?.fonts || []; + + const matchingFont = timelineFonts.find(font => { + const { full, base } = extractFontNames(font.src); + const requested = requestedFamily.toLowerCase(); + return full.toLowerCase() === requested || base.toLowerCase() === requested; + }); + + if (matchingFont) { + await this.registerFont(baseFontFamily, fontWeight, { type: "url", path: matchingFont.src }); } else { - console.warn(`Font ${fontFamily} not found in local assets. Available fonts:`, Array.from(fontMap.keys())); + const fontPath = resolveFontPath(requestedFamily); + if (fontPath) { + await this.registerFont(baseFontFamily, fontWeight, { type: "file", path: fontPath }); + } else { + console.warn(`Font ${requestedFamily} not found. Available:`, Object.keys(FONT_PATHS)); + } } } @@ -212,9 +181,7 @@ export class RichTextPlayer extends Player { this.configureKeyframes(); } catch (error) { console.error("Failed to initialize rich text player:", error); - this.cleanupResources(); - this.createFallbackText(richTextAsset); } } diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 199e932e..1e4edd10 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -1,5 +1,6 @@ import { Player } from "@canvas/players/player"; import { TextEditor } from "@canvas/text/text-editor"; +import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; import { type Size } from "@layouts/geometry"; import { type Clip } from "@schemas/clip"; import { type TextAsset } from "@schemas/text-asset"; @@ -19,6 +20,10 @@ export class TextPlayer extends Player { const textAsset = this.clipConfiguration.asset as TextAsset; + // Load the font before rendering + const fontFamily = textAsset.font?.family ?? "Open Sans"; + await this.loadFont(fontFamily); + // Create background if specified this.background = new pixi.Graphics(); if (textAsset.background) { @@ -86,11 +91,14 @@ export class TextPlayer extends Player { } private createTextStyle(textAsset: TextAsset): pixi.TextStyle { + const fontFamily = textAsset.font?.family ?? "Open Sans"; + const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily); + return new pixi.TextStyle({ - fontFamily: textAsset.font?.family ?? "Open Sans", + fontFamily: baseFontFamily, fontSize: textAsset.font?.size ?? 32, fill: textAsset.font?.color ?? "#ffffff", - fontWeight: (textAsset.font?.weight ?? "400").toString() as pixi.TextStyleFontWeight, + fontWeight: fontWeight.toString() as pixi.TextStyleFontWeight, wordWrap: true, wordWrapWidth: textAsset.width ?? this.edit.size.width, lineHeight: (textAsset.font?.lineHeight ?? 1) * (textAsset.font?.size ?? 32), @@ -127,4 +135,19 @@ export class TextPlayer extends Player { public updateTextContent(newText: string, initialConfig: Clip): void { this.edit.updateTextContent(this, newText, initialConfig); } + + private async loadFont(fontFamily: string): Promise { + const { baseFontFamily } = parseFontFamily(fontFamily); + + if (document.fonts.check(`16px "${baseFontFamily}"`)) { + return; + } + + const fontPath = resolveFontPath(fontFamily); + if (fontPath) { + const fontFace = new FontFace(baseFontFamily, `url(${fontPath})`); + await fontFace.load(); + document.fonts.add(fontFace); + } + } } diff --git a/src/core/fonts/font-config.ts b/src/core/fonts/font-config.ts new file mode 100644 index 00000000..d0a85876 --- /dev/null +++ b/src/core/fonts/font-config.ts @@ -0,0 +1,71 @@ +/** + * Shared font configuration for text and rich-text players + */ + +/** Font family name to file path mapping */ +export const FONT_PATHS: Record = { + Arapey: "/assets/fonts/Arapey-Regular.ttf", + "Clear Sans": "/assets/fonts/ClearSans-Regular.ttf", + "Didact Gothic": "/assets/fonts/DidactGothic-Regular.ttf", + Montserrat: "/assets/fonts/Montserrat.ttf", + MovLette: "/assets/fonts/MovLette.ttf", + "Open Sans": "/assets/fonts/OpenSans.ttf", + "Permanent Marker": "/assets/fonts/PermanentMarker-Regular.ttf", + Roboto: "/assets/fonts/Roboto.ttf", + "Sue Ellen Francisco": "/assets/fonts/SueEllenFrancisco.ttf", + "Uni Neue": "/assets/fonts/UniNeue-Bold.otf", + "Work Sans": "/assets/fonts/WorkSans.ttf" +}; + +/** Alternative names (camelCase, etc.) mapped to canonical names */ +export const FONT_ALIASES: Record = { + ClearSans: "Clear Sans", + DidactGothic: "Didact Gothic", + OpenSans: "Open Sans", + PermanentMarker: "Permanent Marker", + SueEllenFrancisco: "Sue Ellen Francisco", + UniNeue: "Uni Neue", + WorkSans: "Work Sans" +}; + +/** Weight modifier suffixes mapped to CSS font-weight values */ +export const WEIGHT_MODIFIERS: Record = { + Thin: 100, + ExtraLight: 200, + Light: 300, + Regular: 400, + Medium: 500, + SemiBold: 600, + Bold: 700, + ExtraBold: 800, + Black: 900 +}; + +/** + * Parse a font family name to extract base family and weight + * e.g., "Montserrat ExtraBold" → { baseFontFamily: "Montserrat", fontWeight: 800 } + * Case-insensitive matching for weight modifiers (handles "Extrabold", "ExtraBold", etc.) + */ +export function parseFontFamily(fontFamily: string): { baseFontFamily: string; fontWeight: number } { + const lowerFamily = fontFamily.toLowerCase(); + for (const [modifier, weight] of Object.entries(WEIGHT_MODIFIERS)) { + const lowerModifier = ` ${modifier.toLowerCase()}`; + if (lowerFamily.endsWith(lowerModifier)) { + return { + baseFontFamily: fontFamily.slice(0, -modifier.length - 1), + fontWeight: weight + }; + } + } + return { baseFontFamily: fontFamily, fontWeight: 400 }; +} + +/** + * Resolve a font family name to its file path + * Handles aliases and weight modifiers + */ +export function resolveFontPath(fontFamily: string): string | undefined { + const { baseFontFamily } = parseFontFamily(fontFamily); + const resolvedName = FONT_ALIASES[baseFontFamily] ?? baseFontFamily; + return FONT_PATHS[resolvedName]; +} diff --git a/src/core/schemas/text-asset.ts b/src/core/schemas/text-asset.ts index 21831df2..97a2d76c 100644 --- a/src/core/schemas/text-asset.ts +++ b/src/core/schemas/text-asset.ts @@ -6,7 +6,7 @@ export const TextAssetFontSchema = zod .object({ color: TextAssetColorSchema.optional(), family: zod.string().optional(), - size: zod.number().positive().optional(), + size: zod.coerce.number().positive().optional(), weight: zod.number().optional(), lineHeight: zod.number().optional() }) @@ -22,7 +22,7 @@ export const TextAssetAlignmentSchema = zod export const TextAssetBackgroundSchema = zod .object({ color: TextAssetColorSchema, - opacity: zod.number().min(0).max(1) + opacity: zod.number().min(0).max(1).default(1) }) .strict(); diff --git a/src/templates/hello.json b/src/templates/hello.json index d641063e..174e1498 100644 --- a/src/templates/hello.json +++ b/src/templates/hello.json @@ -1,57 +1,63 @@ { "timeline": { - "fonts": [ - { - "src": "https://shotstack-ingest-api-dev-sources.s3.ap-southeast-2.amazonaws.com/euo5r93oyr/zzz01k5s-0qvt9-ck9d4-950ts-hbsa9x/source.ttf" - } - ], + "background": "#FFFFFF", "tracks": [ { "clips": [ { "asset": { "type": "rich-text", - "text": "Hello World!", + "text": "Production", "font": { - "family": "Roboto", - "size": 48, + "family": "Montserrat Extrabold", + "size": 72, "weight": "400", - "color": "#ffffff", - "opacity": 1 - }, - "style": { - "letterSpacing": 0, - "lineHeight": 1.5, - "textTransform": "none" - }, - "background": { - "color": "#1a1a2e", + "color": "#000000", "opacity": 1 - }, - "align": { - "horizontal": "center", - "vertical": "middle" - }, - "animation": { - "preset": "typewriter", - "duration": 3, - "style": "character" } }, "start": 0, - "length": 4, + "length": 3, + "position": "center", + "fit": "crop", + "offset": { + "x": 0.06491932678222656, + "y": 0 + }, "width": 800, - "height": 400 + "height": 200 + } + ] + }, + { + "clips": [ + { + "asset": { + "type": "image", + "src": "https://shotstack-assets.s3.amazonaws.com/images/woods1.jpg" + }, + "start": 0, + "length": 3, + "position": "center", + "fit": "none", + "width": 500, + "height": 300 } ] } + ], + "fonts": [ + { + "src": "https://shotstack-assets.s3.amazonaws.com/fonts/Oswald-VariableFont.ttf" + } ] }, "output": { - "format": "mp4", "size": { - "width": 1920, - "height": 1080 - } + "width": 1280, + "height": 720 + }, + "fps": 25, + "format": "mp4" } } From 34f99d432012273594f004d82d5495ac82250562 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 11:34:41 +1100 Subject: [PATCH 008/463] feat: add content size distinction for fit scaling --- src/components/canvas/players/image-player.ts | 4 ++ src/components/canvas/players/player.ts | 63 +++++++++++++------ 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index 6471cdb5..9c31f9b3 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -80,6 +80,10 @@ export class ImagePlayer extends Player { return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; } + public override getContentSize(): Size { + return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; + } + protected override supportsEdgeResize(): boolean { return true; } diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 3207e9e8..0933bacb 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -519,6 +519,15 @@ export abstract class Player extends Entity { public abstract getSize(): Size; + /** + * Returns the source content dimensions (before fit scaling). + * Override in subclasses that have different source vs output sizes. + * Default implementation returns getSize(). + */ + public getContentSize(): Size { + return this.getSize(); + } + public getOpacity(): number { return this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; } @@ -538,21 +547,21 @@ export abstract class Player extends Entity { } protected getFitScale(): number { - if (this.clipConfiguration.width && this.clipConfiguration.height) { - return 1; - } + const targetWidth = this.clipConfiguration.width ?? this.edit.size.width; + const targetHeight = this.clipConfiguration.height ?? this.edit.size.height; + const contentSize = this.getContentSize(); switch (this.clipConfiguration.fit ?? "crop") { case "crop": { - const ratioX = this.edit.size.width / this.getSize().width; - const ratioY = this.edit.size.height / this.getSize().height; - const isPortrait = this.edit.size.height >= this.edit.size.width; + const ratioX = targetWidth / contentSize.width; + const ratioY = targetHeight / contentSize.height; + const isPortrait = targetHeight >= targetWidth; return isPortrait ? ratioY : ratioX; } case "cover": - return Math.max(this.edit.size.width / this.getSize().width, this.edit.size.height / this.getSize().height); + return Math.max(targetWidth / contentSize.width, targetHeight / contentSize.height); case "contain": - return Math.min(this.edit.size.width / this.getSize().width, this.edit.size.height / this.getSize().height); + return Math.min(targetWidth / contentSize.width, targetHeight / contentSize.height); case "none": default: return 1; @@ -568,20 +577,24 @@ export abstract class Player extends Entity { } protected getContainerScale(): Vector { + const baseScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; + + // When explicit dimensions are set, applyFixedDimensions() handles fit scaling internally if (this.clipConfiguration.width && this.clipConfiguration.height) { - return { x: 1, y: 1 }; + return { x: baseScale, y: baseScale }; } - const baseScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; - const size = this.getSize(); + const contentSize = this.getContentSize(); const fit = this.clipConfiguration.fit ?? "crop"; - if (size.width === 0 || size.height === 0) { + if (contentSize.width === 0 || contentSize.height === 0) { return { x: baseScale, y: baseScale }; } - const ratioX = this.edit.size.width / size.width; - const ratioY = this.edit.size.height / size.height; + const targetWidth = this.edit.size.width; + const targetHeight = this.edit.size.height; + const ratioX = targetWidth / contentSize.width; + const ratioY = targetHeight / contentSize.height; switch (fit) { case "contain": { @@ -589,7 +602,7 @@ export abstract class Player extends Entity { return { x: uniform, y: uniform }; } case "crop": { - const isPortrait = this.edit.size.height >= this.edit.size.width; + const isPortrait = targetHeight >= targetWidth; const uniform = (isPortrait ? ratioY : ratioX) * baseScale; return { x: uniform, y: uniform }; } @@ -687,13 +700,27 @@ export abstract class Player extends Entity { const timelinePoint = event.getLocalPosition(this.edit.getContainer()); this.edgeDragStart = timelinePoint; - const currentSize = this.getSize(); // Get current offset values from keyframe builders (handles both numeric and keyframe array cases) const currentOffsetX = this.offsetXKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0; const currentOffsetY = this.offsetYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0; + + // Use existing dimensions if set, otherwise calculate visual size from content + fit scaling + let width: number; + let height: number; + if (this.clipConfiguration.width && this.clipConfiguration.height) { + width = this.clipConfiguration.width; + height = this.clipConfiguration.height; + } else { + // Calculate the visual size (content scaled by fit) + const contentSize = this.getContentSize(); + const fitScale = this.getFitScale(); + width = contentSize.width * fitScale; + height = contentSize.height * fitScale; + } + this.originalDimensions = { - width: currentSize.width, - height: currentSize.height, + width, + height, offsetX: currentOffsetX, offsetY: currentOffsetY }; From d92e5f31441abe3fc619c67670d9820e4ee4af93 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 11:48:56 +1100 Subject: [PATCH 009/463] refactor: remove rotation handle and simplify edge resize to hit zone detection --- src/components/canvas/players/player.ts | 241 +++++------------------- 1 file changed, 47 insertions(+), 194 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 0933bacb..06b011ba 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -26,21 +26,16 @@ export abstract class Player extends Entity { private static readonly DiscardedFrameCount = Math.ceil((1 / 30) * 1000); - private static readonly ScaleHandleRadius = 10; - private static readonly RotationHandleRadius = 10; - private static readonly RotationHandleOffset = 50; - private static readonly OutlineWidth = 5; + private static readonly ScaleHandleRadius = 4; + private static readonly OutlineWidth = 1; private static readonly MinScale = 0.1; private static readonly MaxScale = 5; - private static readonly EdgeHandleLength = 30; - private static readonly EdgeHandleThickness = 8; + private static readonly EdgeHitZone = 8; private static readonly MinDimension = 50; private static readonly MaxDimension = 3840; - private static readonly RotationCursorSvg = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'%3E%3Cpath d='M10 3a7 7 0 1 0 7 7' fill='none' stroke='white' stroke-width='3' stroke-linecap='round'/%3E%3Cpath d='M10 3a7 7 0 1 0 7 7' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'/%3E%3Cpath d='M14 3 L10 0 L10 6 Z' fill='black' stroke='white' stroke-width='0.75' stroke-linejoin='round'/%3E%3C/svg%3E") 10 10, auto`; - public layer: number; public shouldDispose: boolean; @@ -62,12 +57,6 @@ export abstract class Player extends Entity { private topRightScaleHandle: pixi.Graphics | null; private bottomLeftScaleHandle: pixi.Graphics | null; private bottomRightScaleHandle: pixi.Graphics | null; - private rotationHandle: pixi.Graphics | null; - - private leftEdgeHandle: pixi.Graphics | null; - private rightEdgeHandle: pixi.Graphics | null; - private topEdgeHandle: pixi.Graphics | null; - private bottomEdgeHandle: pixi.Graphics | null; private isHovering: boolean; private isDragging: boolean; @@ -77,10 +66,6 @@ export abstract class Player extends Entity { private scaleStart: number | null; private scaleOffset: Vector; - private isRotating: boolean; - private rotationStart: number | null; - private rotationOffset: Vector; - private edgeDragDirection: "left" | "right" | "top" | "bottom" | null; private edgeDragStart: Vector; private originalDimensions: { width: number; height: number; offsetX: number; offsetY: number } | null; @@ -112,12 +97,6 @@ export abstract class Player extends Entity { this.topRightScaleHandle = null; this.bottomRightScaleHandle = null; this.bottomLeftScaleHandle = null; - this.rotationHandle = null; - - this.leftEdgeHandle = null; - this.rightEdgeHandle = null; - this.topEdgeHandle = null; - this.bottomEdgeHandle = null; this.isHovering = false; @@ -128,10 +107,6 @@ export abstract class Player extends Entity { this.scaleStart = null; this.scaleOffset = { x: 0, y: 0 }; - this.isRotating = false; - this.rotationStart = null; - this.rotationOffset = { x: 0, y: 0 }; - this.edgeDragDirection = null; this.edgeDragStart = { x: 0, y: 0 }; this.originalDimensions = null; @@ -231,43 +206,6 @@ export abstract class Player extends Entity { this.getContainer().addChild(this.bottomLeftScaleHandle); } - this.rotationHandle = new pixi.Graphics(); - this.rotationHandle.zIndex = 1000; - this.rotationHandle.eventMode = "static"; - this.rotationHandle.cursor = Player.RotationCursorSvg; - this.getContainer().addChild(this.rotationHandle); - - // Create edge handles for text/rich-text assets - if (this.supportsEdgeResize()) { - this.leftEdgeHandle = new pixi.Graphics(); - this.rightEdgeHandle = new pixi.Graphics(); - this.topEdgeHandle = new pixi.Graphics(); - this.bottomEdgeHandle = new pixi.Graphics(); - - this.leftEdgeHandle.zIndex = 1000; - this.rightEdgeHandle.zIndex = 1000; - this.topEdgeHandle.zIndex = 1000; - this.bottomEdgeHandle.zIndex = 1000; - - // Enable interactivity and set resize cursors - this.leftEdgeHandle.eventMode = "static"; - this.leftEdgeHandle.cursor = "ew-resize"; - - this.rightEdgeHandle.eventMode = "static"; - this.rightEdgeHandle.cursor = "ew-resize"; - - this.topEdgeHandle.eventMode = "static"; - this.topEdgeHandle.cursor = "ns-resize"; - - this.bottomEdgeHandle.eventMode = "static"; - this.bottomEdgeHandle.cursor = "ns-resize"; - - this.getContainer().addChild(this.leftEdgeHandle); - this.getContainer().addChild(this.rightEdgeHandle); - this.getContainer().addChild(this.topEdgeHandle); - this.getContainer().addChild(this.bottomEdgeHandle); - } - this.getContainer().sortableChildren = true; this.getContainer().cursor = "pointer"; @@ -327,11 +265,6 @@ export abstract class Player extends Entity { this.topRightScaleHandle?.clear(); this.bottomRightScaleHandle?.clear(); this.bottomLeftScaleHandle?.clear(); - this.rotationHandle?.clear(); - this.leftEdgeHandle?.clear(); - this.rightEdgeHandle?.clear(); - this.topEdgeHandle?.clear(); - this.bottomEdgeHandle?.clear(); return; } @@ -379,59 +312,6 @@ export abstract class Player extends Entity { this.bottomLeftScaleHandle.fill(); } - // Draw rotation handle (for all asset types) - if (this.rotationHandle) { - const rotationHandleX = size.width / 2; - const rotationHandleY = -Player.RotationHandleOffset / uiScale; - - this.rotationHandle.clear(); - this.rotationHandle.fillStyle = { color }; - this.rotationHandle.circle(rotationHandleX, rotationHandleY, Player.RotationHandleRadius / uiScale); - this.rotationHandle.fill(); - - this.outline.strokeStyle = { width: Player.OutlineWidth / uiScale, color }; - this.outline.moveTo(rotationHandleX, 0); - this.outline.lineTo(rotationHandleX, rotationHandleY); - this.outline.stroke(); - } - - // Draw edge handles for text/rich-text assets - if (this.supportsEdgeResize()) { - const edgeLength = Player.EdgeHandleLength / uiScale; - const edgeThickness = Player.EdgeHandleThickness / uiScale; - - // Left edge handle (vertical bar on left edge, centered) - if (this.leftEdgeHandle) { - this.leftEdgeHandle.clear(); - this.leftEdgeHandle.fillStyle = { color }; - this.leftEdgeHandle.rect(-edgeThickness / 2, size.height / 2 - edgeLength / 2, edgeThickness, edgeLength); - this.leftEdgeHandle.fill(); - } - - // Right edge handle (vertical bar on right edge, centered) - if (this.rightEdgeHandle) { - this.rightEdgeHandle.clear(); - this.rightEdgeHandle.fillStyle = { color }; - this.rightEdgeHandle.rect(size.width - edgeThickness / 2, size.height / 2 - edgeLength / 2, edgeThickness, edgeLength); - this.rightEdgeHandle.fill(); - } - - // Top edge handle (horizontal bar on top edge, centered) - if (this.topEdgeHandle) { - this.topEdgeHandle.clear(); - this.topEdgeHandle.fillStyle = { color }; - this.topEdgeHandle.rect(size.width / 2 - edgeLength / 2, -edgeThickness / 2, edgeLength, edgeThickness); - this.topEdgeHandle.fill(); - } - - // Bottom edge handle (horizontal bar on bottom edge, centered) - if (this.bottomEdgeHandle) { - this.bottomEdgeHandle.clear(); - this.bottomEdgeHandle.fillStyle = { color }; - this.bottomEdgeHandle.rect(size.width / 2 - edgeLength / 2, size.height - edgeThickness / 2, edgeLength, edgeThickness); - this.bottomEdgeHandle.fill(); - } - } } public override dispose(): void { @@ -450,21 +330,6 @@ export abstract class Player extends Entity { this.bottomRightScaleHandle?.destroy(); this.bottomRightScaleHandle = null; - this.rotationHandle?.destroy(); - this.rotationHandle = null; - - this.leftEdgeHandle?.destroy(); - this.leftEdgeHandle = null; - - this.rightEdgeHandle?.destroy(); - this.rightEdgeHandle = null; - - this.topEdgeHandle?.destroy(); - this.topEdgeHandle = null; - - this.bottomEdgeHandle?.destroy(); - this.bottomEdgeHandle = null; - this.contentContainer?.destroy(); } @@ -671,28 +536,32 @@ export abstract class Player extends Entity { return; } - const isRotating = this.rotationHandle?.getBounds().containsPoint(event.globalX, event.globalY); - if (isRotating) { - this.isRotating = true; - this.rotationStart = this.getRotation(); + // Check for edge resize interactions (for assets that support edge resize) + if (this.supportsEdgeResize()) { + this.edgeDragDirection = null; - const timelinePoint = event.getLocalPosition(this.edit.getContainer()); - this.rotationOffset = timelinePoint; + // Get local position within the container + const localPoint = event.getLocalPosition(this.getContainer()); + const size = this.getSize(); + const hitZone = Player.EdgeHitZone / this.getUIScale(); - return; - } + // Check if pointer is near any edge (within hit zone) + const nearLeft = localPoint.x >= -hitZone && localPoint.x <= hitZone; + const nearRight = localPoint.x >= size.width - hitZone && localPoint.x <= size.width + hitZone; + const nearTop = localPoint.y >= -hitZone && localPoint.y <= hitZone; + const nearBottom = localPoint.y >= size.height - hitZone && localPoint.y <= size.height + hitZone; - // Check for edge handle interactions (for text/rich-text assets) - if (this.supportsEdgeResize()) { - this.edgeDragDirection = null; + // Determine which edge (prioritize horizontal/vertical edges, not corners) + const withinVerticalRange = localPoint.y > hitZone && localPoint.y < size.height - hitZone; + const withinHorizontalRange = localPoint.x > hitZone && localPoint.x < size.width - hitZone; - if (this.leftEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) { + if (nearLeft && withinVerticalRange) { this.edgeDragDirection = "left"; - } else if (this.rightEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) { + } else if (nearRight && withinVerticalRange) { this.edgeDragDirection = "right"; - } else if (this.topEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) { + } else if (nearTop && withinHorizontalRange) { this.edgeDragDirection = "top"; - } else if (this.bottomEdgeHandle?.getBounds().containsPoint(event.globalX, event.globalY)) { + } else if (nearBottom && withinHorizontalRange) { this.edgeDragDirection = "bottom"; } @@ -759,42 +628,6 @@ export abstract class Player extends Entity { return; } - if (this.isRotating && this.rotationStart !== null) { - const timelinePoint = event.getLocalPosition(this.edit.getContainer()); - - const position = this.getPosition(); - const pivot = this.getPivot(); - - const center: Vector = { x: position.x + pivot.x, y: position.y + pivot.y }; - - const initialAngle = Math.atan2(this.rotationOffset.y - center.y, this.rotationOffset.x - center.x); - const currentAngle = Math.atan2(timelinePoint.y - center.y, timelinePoint.x - center.x); - - const angleDelta = (currentAngle - initialAngle) * (180 / Math.PI); - - let targetAngle = this.rotationStart + angleDelta; - const snapAngle = 45; - const angleModulo = targetAngle % snapAngle; - const snapThreshold = 2; - - if (Math.abs(angleModulo) < snapThreshold) { - targetAngle = Math.floor(targetAngle / snapAngle) * snapAngle; - } else if (Math.abs(angleModulo - snapAngle) < snapThreshold) { - targetAngle = Math.ceil(targetAngle / snapAngle) * snapAngle; - } - - if (!this.clipConfiguration.transform) { - this.clipConfiguration.transform = { rotate: { angle: 0 } }; - } - if (!this.clipConfiguration.transform.rotate) { - this.clipConfiguration.transform.rotate = { angle: 0 }; - } - this.clipConfiguration.transform.rotate.angle = targetAngle; - this.rotationKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.transform.rotate.angle, this.getLength()); - - return; - } - // Handle edge resize dragging if (this.edgeDragDirection !== null && this.originalDimensions !== null) { const timelinePoint = event.getLocalPosition(this.edit.getContainer()); @@ -924,11 +757,35 @@ export abstract class Player extends Entity { this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.x, this.getLength()); this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.y, this.getLength()); + return; + } + + // Update cursor based on edge proximity when not dragging (for edge resize) + if (this.supportsEdgeResize() && !this.isDragging && !this.scaleDirection && !this.edgeDragDirection) { + const localPoint = event.getLocalPosition(this.getContainer()); + const size = this.getSize(); + const hitZone = Player.EdgeHitZone / this.getUIScale(); + + const nearLeft = localPoint.x >= -hitZone && localPoint.x <= hitZone; + const nearRight = localPoint.x >= size.width - hitZone && localPoint.x <= size.width + hitZone; + const nearTop = localPoint.y >= -hitZone && localPoint.y <= hitZone; + const nearBottom = localPoint.y >= size.height - hitZone && localPoint.y <= size.height + hitZone; + + const withinVerticalRange = localPoint.y > hitZone && localPoint.y < size.height - hitZone; + const withinHorizontalRange = localPoint.x > hitZone && localPoint.x < size.width - hitZone; + + if ((nearLeft || nearRight) && withinVerticalRange) { + this.getContainer().cursor = "ew-resize"; + } else if ((nearTop || nearBottom) && withinHorizontalRange) { + this.getContainer().cursor = "ns-resize"; + } else { + this.getContainer().cursor = "pointer"; + } } } private onPointerUp(): void { - if ((this.isDragging || this.scaleDirection !== null || this.isRotating || this.edgeDragDirection !== null) && this.hasStateChanged()) { + if ((this.isDragging || this.scaleDirection !== null || this.edgeDragDirection !== null) && this.hasStateChanged()) { this.edit.setUpdatedClip(this, this.initialClipConfiguration, structuredClone(this.clipConfiguration)); } @@ -939,10 +796,6 @@ export abstract class Player extends Entity { this.scaleStart = null; this.scaleOffset = { x: 0, y: 0 }; - this.isRotating = false; - this.rotationStart = null; - this.rotationOffset = { x: 0, y: 0 }; - this.edgeDragDirection = null; this.edgeDragStart = { x: 0, y: 0 }; this.originalDimensions = null; From 570df22b5e298bd40685c8d814d13418f72ae7a6 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 11:56:30 +1100 Subject: [PATCH 010/463] feat: implement corner handle resize with offset adjustment --- src/components/canvas/players/player.ts | 110 ++++++++++++++++++++---- 1 file changed, 95 insertions(+), 15 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 06b011ba..7e3cc992 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -188,8 +188,8 @@ export abstract class Player extends Entity { this.outline = new pixi.Graphics(); this.getContainer().addChild(this.outline); - // Only create corner scale handles for assets that don't use edge resize - if (!this.supportsEdgeResize()) { + // Create corner resize handles for assets that support edge resize + if (this.supportsEdgeResize()) { this.topLeftScaleHandle = new pixi.Graphics(); this.topRightScaleHandle = new pixi.Graphics(); this.bottomRightScaleHandle = new pixi.Graphics(); @@ -200,6 +200,16 @@ export abstract class Player extends Entity { this.bottomRightScaleHandle.zIndex = 1000; this.bottomLeftScaleHandle.zIndex = 1000; + // Set resize cursors for corner handles + this.topLeftScaleHandle.eventMode = "static"; + this.topLeftScaleHandle.cursor = "nwse-resize"; + this.topRightScaleHandle.eventMode = "static"; + this.topRightScaleHandle.cursor = "nesw-resize"; + this.bottomRightScaleHandle.eventMode = "static"; + this.bottomRightScaleHandle.cursor = "nwse-resize"; + this.bottomLeftScaleHandle.eventMode = "static"; + this.bottomLeftScaleHandle.cursor = "nesw-resize"; + this.getContainer().addChild(this.topLeftScaleHandle); this.getContainer().addChild(this.topRightScaleHandle); this.getContainer().addChild(this.bottomRightScaleHandle); @@ -528,10 +538,32 @@ export abstract class Player extends Entity { } if (this.scaleDirection !== null) { - this.scaleStart = this.getScale() / this.getFitScale(); - const timelinePoint = event.getLocalPosition(this.edit.getContainer()); - this.scaleOffset = timelinePoint; + this.edgeDragStart = timelinePoint; + + // Get current offset values + const currentOffsetX = this.offsetXKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0; + const currentOffsetY = this.offsetYKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 0; + + // Use existing dimensions if set, otherwise calculate visual size + let width: number; + let height: number; + if (this.clipConfiguration.width && this.clipConfiguration.height) { + width = this.clipConfiguration.width; + height = this.clipConfiguration.height; + } else { + const contentSize = this.getContentSize(); + const fitScale = this.getFitScale(); + width = contentSize.width * fitScale; + height = contentSize.height * fitScale; + } + + this.originalDimensions = { + width, + height, + offsetX: currentOffsetX, + offsetY: currentOffsetY + }; return; } @@ -608,22 +640,70 @@ export abstract class Player extends Entity { } private onPointerMove(event: pixi.FederatedPointerEvent): void { - if (this.scaleDirection !== null && this.scaleStart !== null) { + // Handle corner resize dragging (two-axis resize) + if (this.scaleDirection !== null && this.originalDimensions !== null) { const timelinePoint = event.getLocalPosition(this.edit.getContainer()); - const position = this.getPosition(); - const pivot = this.getPivot(); + const deltaX = timelinePoint.x - this.edgeDragStart.x; + const deltaY = timelinePoint.y - this.edgeDragStart.y; - const center: Vector = { x: position.x + pivot.x, y: position.y + pivot.y }; + let newWidth = this.originalDimensions.width; + let newHeight = this.originalDimensions.height; + let newOffsetX = this.originalDimensions.offsetX; + let newOffsetY = this.originalDimensions.offsetY; - const initialDistance = Math.sqrt((this.scaleOffset.x - center.x) ** 2 + (this.scaleOffset.y - center.y) ** 2); - const currentDistance = Math.sqrt((timelinePoint.x - center.x) ** 2 + (timelinePoint.y - center.y) ** 2); + switch (this.scaleDirection) { + case "topLeft": + // Decrease width, decrease height, shift offset to keep bottom-right fixed + newWidth = this.originalDimensions.width - deltaX; + newHeight = this.originalDimensions.height - deltaY; + newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width; + newOffsetY = this.originalDimensions.offsetY - deltaY / 2 / this.edit.size.height; + break; + case "topRight": + // Increase width, decrease height, shift offset to keep bottom-left fixed + newWidth = this.originalDimensions.width + deltaX; + newHeight = this.originalDimensions.height - deltaY; + newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width; + newOffsetY = this.originalDimensions.offsetY - deltaY / 2 / this.edit.size.height; + break; + case "bottomLeft": + // Decrease width, increase height, shift offset to keep top-right fixed + newWidth = this.originalDimensions.width - deltaX; + newHeight = this.originalDimensions.height + deltaY; + newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width; + newOffsetY = this.originalDimensions.offsetY + deltaY / 2 / this.edit.size.height; + break; + case "bottomRight": + // Increase width, increase height, shift offset to keep top-left fixed + newWidth = this.originalDimensions.width + deltaX; + newHeight = this.originalDimensions.height + deltaY; + newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width; + newOffsetY = this.originalDimensions.offsetY + deltaY / 2 / this.edit.size.height; + break; + } + + // Clamp dimensions + newWidth = Math.max(Player.MinDimension, Math.min(newWidth, Player.MaxDimension)); + newHeight = Math.max(Player.MinDimension, Math.min(newHeight, Player.MaxDimension)); - const scaleRatio = currentDistance / initialDistance; - const targetScale = this.scaleStart * scaleRatio; + // Apply dimensions + this.clipConfiguration.width = newWidth; + this.clipConfiguration.height = newHeight; - this.clipConfiguration.scale = Math.max(Player.MinScale, Math.min(targetScale, Player.MaxScale)); - this.scaleKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.scale, this.getLength(), 1); + // Apply offset + if (!this.clipConfiguration.offset) { + this.clipConfiguration.offset = { x: 0, y: 0 }; + } + this.clipConfiguration.offset.x = newOffsetX; + this.clipConfiguration.offset.y = newOffsetY; + + // Update keyframe builders + this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.x, this.getLength()); + this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.y, this.getLength()); + + // Notify subclass about dimension change + this.onDimensionsChanged(); return; } From c5bb7466dbbefe1adc7547a8b900e1039a332ef9 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 12:44:39 +1100 Subject: [PATCH 011/463] feat: override getContainerScale in text players to prevent fit-scaling --- src/components/canvas/players/rich-text-player.ts | 8 +++++++- src/components/canvas/players/text-player.ts | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 93d9861e..bf59376d 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -1,6 +1,6 @@ import { Player } from "@canvas/players/player"; import { FONT_PATHS, parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; -import { type Size } from "@layouts/geometry"; +import { type Size, type Vector } from "@layouts/geometry"; import { RichTextAssetSchema, type RichTextAsset } from "@schemas/rich-text-asset"; import { createTextEngine } from "@shotstack/shotstack-canvas"; import { TextEngine, TextRenderer, ValidatedRichTextAsset } from "@timeline/types"; @@ -367,6 +367,12 @@ export class RichTextPlayer extends Player { return 1; } + protected override getContainerScale(): Vector { + // Rich text should not be fit-scaled - use only the user-defined scale + const scale = this.getScale(); + return { x: scale, y: scale }; + } + protected override supportsEdgeResize(): boolean { return true; } diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 1e4edd10..59de3705 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -1,7 +1,7 @@ import { Player } from "@canvas/players/player"; import { TextEditor } from "@canvas/text/text-editor"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; -import { type Size } from "@layouts/geometry"; +import { type Size, type Vector } from "@layouts/geometry"; import { type Clip } from "@schemas/clip"; import { type TextAsset } from "@schemas/text-asset"; import * as pixiFilters from "pixi-filters"; @@ -90,6 +90,13 @@ export class TextPlayer extends Player { return 1; } + protected override getContainerScale(): Vector { + // Text should not be fit-scaled - use only the user-defined scale + // getScale() returns keyframe scale * getFitScale(), and we override getFitScale() to return 1 + const scale = this.getScale(); + return { x: scale, y: scale }; + } + private createTextStyle(textAsset: TextAsset): pixi.TextStyle { const fontFamily = textAsset.font?.family ?? "Open Sans"; const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily); From 63d7bbb39f4768d2902f9c0ac6192415fef7e778 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 14:50:47 +1100 Subject: [PATCH 012/463] refactor: replace viewport mask with luma masking system --- src/components/canvas/players/luma-player.ts | 2 +- src/components/canvas/players/player.ts | 5 ++ src/core/edit.ts | 64 ++++++++++++++++---- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index 34c9be7e..ae4556b4 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -107,7 +107,7 @@ export class LumaPlayer extends Player { return 0; } - public getMask(): pixi.Sprite | null { + public getSprite(): pixi.Sprite | null { return this.sprite; } } diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 7e3cc992..445e08c9 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -403,6 +403,11 @@ export abstract class Player extends Entity { return this.getSize(); } + /** @internal */ + public getContentContainer(): pixi.Container { + return this.contentContainer; + } + public getOpacity(): number { return this.opacityKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; } diff --git a/src/core/edit.ts b/src/core/edit.ts index 74fcc712..c596dbc5 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1,4 +1,3 @@ -import type { Canvas } from "@canvas/shotstack-canvas"; import { AudioPlayer } from "@canvas/players/audio-player"; import { HtmlPlayer } from "@canvas/players/html-player"; import { ImagePlayer } from "@canvas/players/image-player"; @@ -8,6 +7,7 @@ import { RichTextPlayer } from "@canvas/players/rich-text-player"; import { ShapePlayer } from "@canvas/players/shape-player"; import { TextPlayer } from "@canvas/players/text-player"; import { VideoPlayer } from "@canvas/players/video-player"; +import type { Canvas } from "@canvas/shotstack-canvas"; import { AddClipCommand } from "@core/commands/add-clip-command"; import { AddTrackCommand } from "@core/commands/add-track-command"; import { ClearSelectionCommand } from "@core/commands/clear-selection-command"; @@ -74,6 +74,7 @@ export class Edit extends Entity { private endLengthClips: Set = new Set(); private canvas: Canvas | null = null; + private lumaMaskTextures: pixi.Texture[] = []; constructor(size: Size, backgroundColor: string = "#ffffff") { super(); @@ -151,13 +152,16 @@ export class Edit extends Entity { public override dispose(): void { this.clearClips(); - // Clean up mask + for (const texture of this.lumaMaskTextures) { + texture.destroy(true); + } + this.lumaMaskTextures = []; + if (this.viewportMask) { try { - // Remove mask first, then destroy the graphics this.getContainer().setMask(null as any); } catch { - // Intentionally ignore errors when removing the mask during dispose + // Ignore errors when removing mask during dispose } this.viewportMask.destroy(); this.viewportMask = undefined; @@ -222,12 +226,12 @@ export class Edit extends Entity { } } - // Resolve all timing after clips are loaded + this.finalizeLumaMasking(); + await this.resolveAllTiming(); this.updateTotalDuration(); - // Notify listeners that edit has been reloaded (use resolved values for Timeline) this.events.emit("timeline:updated", { current: this.getResolvedEdit() }); } public getEdit(): EditType { @@ -746,16 +750,54 @@ export class Edit extends Entity { trackContainer.addChild(clipToAdd.getContainer()); - const isClipMask = clipToAdd instanceof LumaPlayer; - await clipToAdd.load(); - if (isClipMask) { - trackContainer.setMask({ mask: clipToAdd.getMask(), inverse: true }); + this.updateTotalDuration(); + } + + private finalizeLumaMasking(): void { + if (!this.canvas) { + return; } - this.updateTotalDuration(); + for (const trackClips of this.tracks) { + const lumaPlayer = trackClips.find(clip => clip instanceof LumaPlayer) as LumaPlayer | undefined; + const lumaSprite = lumaPlayer?.getSprite(); + const contentClips = trackClips.filter(clip => !(clip instanceof LumaPlayer)); + + if (lumaPlayer && lumaSprite?.texture && contentClips.length > 0) { + this.applyLumaMasksToClips(lumaSprite.texture, contentClips); + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + } } + + private applyLumaMasksToClips(lumaTexture: pixi.Texture, contentClips: Player[]): void { + const { renderer } = this.canvas!.application; + + for (const clip of contentClips) { + const size = clip.getSize(); + + const tempContainer = new pixi.Container(); + const tempSprite = new pixi.Sprite(lumaTexture); + tempSprite.width = size.width; + tempSprite.height = size.height; + + const invertFilter = new pixi.ColorMatrixFilter(); + invertFilter.negative(false); + tempSprite.filters = [invertFilter]; + tempContainer.addChild(tempSprite); + + const maskTexture = renderer.generateTexture(tempContainer); + this.lumaMaskTextures.push(maskTexture); + tempContainer.destroy({ children: true }); + + const maskSprite = new pixi.Sprite(maskTexture); + clip.getContainer().addChild(maskSprite); + clip.getContentContainer().setMask({ mask: maskSprite }); + } + } + public selectClip(trackIndex: number, clipIndex: number): void { const command = new SelectClipCommand(trackIndex, clipIndex); this.executeCommand(command); From dd37222b4c4daa932398aca8587686aef5227c13 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 14:57:09 +1100 Subject: [PATCH 013/463] fix: add missing default cases in switch statements --- src/components/canvas/players/player.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 445e08c9..a05208e9 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -686,6 +686,8 @@ export abstract class Player extends Entity { newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width; newOffsetY = this.originalDimensions.offsetY + deltaY / 2 / this.edit.size.height; break; + default: + break; } // Clamp dimensions @@ -746,6 +748,8 @@ export abstract class Player extends Entity { newHeight = this.originalDimensions.height + deltaY; newOffsetY = this.originalDimensions.offsetY - deltaY / 2 / this.edit.size.height; break; + default: + break; } // Clamp dimensions to valid bounds From 79f6bb137db35dd5828952a9971ecb53a8b7656f Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 15:08:45 +1100 Subject: [PATCH 014/463] feat: implement font weight-aware caching for text rendering --- src/components/canvas/players/text-player.ts | 16 +++++++++++++--- src/core/edit.ts | 2 ++ src/core/fonts/font-config.ts | 11 ++++++++++- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 59de3705..6e18c00a 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -11,6 +11,8 @@ import * as pixi from "pixi.js"; * TextPlayer renders and manages editable text elements in the canvas */ export class TextPlayer extends Player { + private static loadedFonts = new Set(); + private background: pixi.Graphics | null = null; private text: pixi.Text | null = null; private textEditor: TextEditor | null = null; @@ -144,17 +146,25 @@ export class TextPlayer extends Player { } private async loadFont(fontFamily: string): Promise { - const { baseFontFamily } = parseFontFamily(fontFamily); + const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily); + const cacheKey = `${baseFontFamily}-${fontWeight}`; - if (document.fonts.check(`16px "${baseFontFamily}"`)) { + if (TextPlayer.loadedFonts.has(cacheKey)) { return; } const fontPath = resolveFontPath(fontFamily); if (fontPath) { - const fontFace = new FontFace(baseFontFamily, `url(${fontPath})`); + const fontFace = new FontFace(baseFontFamily, `url(${fontPath})`, { + weight: fontWeight.toString() + }); await fontFace.load(); document.fonts.add(fontFace); + TextPlayer.loadedFonts.add(cacheKey); } } + + public static resetFontCache(): void { + TextPlayer.loadedFonts.clear(); + } } diff --git a/src/core/edit.ts b/src/core/edit.ts index c596dbc5..acf22ae1 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -166,6 +166,8 @@ export class Edit extends Entity { this.viewportMask.destroy(); this.viewportMask = undefined; } + + TextPlayer.resetFontCache(); } public play(): void { diff --git a/src/core/fonts/font-config.ts b/src/core/fonts/font-config.ts index d0a85876..b070eae5 100644 --- a/src/core/fonts/font-config.ts +++ b/src/core/fonts/font-config.ts @@ -8,13 +8,17 @@ export const FONT_PATHS: Record = { "Clear Sans": "/assets/fonts/ClearSans-Regular.ttf", "Didact Gothic": "/assets/fonts/DidactGothic-Regular.ttf", Montserrat: "/assets/fonts/Montserrat.ttf", + "Montserrat ExtraBold": "/assets/fonts/Montserrat-ExtraBold.ttf", + "Montserrat SemiBold": "/assets/fonts/Montserrat-SemiBold.ttf", MovLette: "/assets/fonts/MovLette.ttf", "Open Sans": "/assets/fonts/OpenSans.ttf", + "Open Sans Bold": "/assets/fonts/OpenSans-Bold.ttf", "Permanent Marker": "/assets/fonts/PermanentMarker-Regular.ttf", Roboto: "/assets/fonts/Roboto.ttf", "Sue Ellen Francisco": "/assets/fonts/SueEllenFrancisco.ttf", "Uni Neue": "/assets/fonts/UniNeue-Bold.otf", - "Work Sans": "/assets/fonts/WorkSans.ttf" + "Work Sans": "/assets/fonts/WorkSans.ttf", + "Work Sans Light": "/assets/fonts/WorkSans-Light.ttf" }; /** Alternative names (camelCase, etc.) mapped to canonical names */ @@ -65,6 +69,11 @@ export function parseFontFamily(fontFamily: string): { baseFontFamily: string; f * Handles aliases and weight modifiers */ export function resolveFontPath(fontFamily: string): string | undefined { + // First try exact match (e.g., "Montserrat ExtraBold") + if (FONT_PATHS[fontFamily]) { + return FONT_PATHS[fontFamily]; + } + // Fall back to base family name const { baseFontFamily } = parseFontFamily(fontFamily); const resolvedName = FONT_ALIASES[baseFontFamily] ?? baseFontFamily; return FONT_PATHS[resolvedName]; From 4462a317481d9da0316fdd385dd82451855a3104 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 15:25:38 +1100 Subject: [PATCH 015/463] fix: apply user scale to fit-scaled dimensions and migrate scale to keyframe builder --- src/components/canvas/players/player.ts | 33 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index a05208e9..70b5dc97 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -293,12 +293,7 @@ export abstract class Player extends Entity { } // Draw corner scale handles (only for assets that don't support edge resize) - if ( - this.topLeftScaleHandle && - this.topRightScaleHandle && - this.bottomRightScaleHandle && - this.bottomLeftScaleHandle - ) { + if (this.topLeftScaleHandle && this.topRightScaleHandle && this.bottomRightScaleHandle && this.bottomLeftScaleHandle) { const handleSize = (Player.ScaleHandleRadius * 2) / uiScale; this.topLeftScaleHandle.fillStyle = { color }; @@ -321,7 +316,6 @@ export abstract class Player extends Entity { this.bottomLeftScaleHandle.rect(-handleSize / 2, size.height - handleSize / 2, handleSize, handleSize); this.bottomLeftScaleHandle.fill(); } - } public override dispose(): void { @@ -559,8 +553,16 @@ export abstract class Player extends Entity { } else { const contentSize = this.getContentSize(); const fitScale = this.getFitScale(); - width = contentSize.width * fitScale; - height = contentSize.height * fitScale; + const userScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; + width = contentSize.width * fitScale * userScale; + height = contentSize.height * fitScale * userScale; + + if (this.clipConfiguration.scale !== undefined) { + this.clipConfiguration.width = width; + this.clipConfiguration.height = height; + delete this.clipConfiguration.scale; + this.scaleKeyframeBuilder = new KeyframeBuilder(1, this.getLength(), 1); + } } this.originalDimensions = { @@ -617,11 +619,18 @@ export abstract class Player extends Entity { width = this.clipConfiguration.width; height = this.clipConfiguration.height; } else { - // Calculate the visual size (content scaled by fit) const contentSize = this.getContentSize(); const fitScale = this.getFitScale(); - width = contentSize.width * fitScale; - height = contentSize.height * fitScale; + const userScale = this.scaleKeyframeBuilder?.getValue(this.getPlaybackTime()) ?? 1; + width = contentSize.width * fitScale * userScale; + height = contentSize.height * fitScale * userScale; + + if (this.clipConfiguration.scale !== undefined) { + this.clipConfiguration.width = width; + this.clipConfiguration.height = height; + delete this.clipConfiguration.scale; + this.scaleKeyframeBuilder = new KeyframeBuilder(1, this.getLength(), 1); + } } this.originalDimensions = { From c5a612257b190507780ce7f519adbdaf09004e8e Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 15:29:16 +1100 Subject: [PATCH 016/463] fix: correct offset calculation sign in player resize handlers --- src/components/canvas/players/player.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 70b5dc97..4db73190 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -686,14 +686,14 @@ export abstract class Player extends Entity { newWidth = this.originalDimensions.width - deltaX; newHeight = this.originalDimensions.height + deltaY; newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width; - newOffsetY = this.originalDimensions.offsetY + deltaY / 2 / this.edit.size.height; + newOffsetY = this.originalDimensions.offsetY - deltaY / 2 / this.edit.size.height; break; case "bottomRight": // Increase width, increase height, shift offset to keep top-left fixed newWidth = this.originalDimensions.width + deltaX; newHeight = this.originalDimensions.height + deltaY; newOffsetX = this.originalDimensions.offsetX + deltaX / 2 / this.edit.size.width; - newOffsetY = this.originalDimensions.offsetY + deltaY / 2 / this.edit.size.height; + newOffsetY = this.originalDimensions.offsetY - deltaY / 2 / this.edit.size.height; break; default: break; From 1c4ccabd858c717e039b73489b80ed3c06f9a486 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 15:39:20 +1100 Subject: [PATCH 017/463] refactor: extract background drawing and support dynamic text resizing --- src/components/canvas/players/text-player.ts | 54 ++++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 6e18c00a..d4cc4a3c 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -26,17 +26,8 @@ export class TextPlayer extends Player { const fontFamily = textAsset.font?.family ?? "Open Sans"; await this.loadFont(fontFamily); - // Create background if specified this.background = new pixi.Graphics(); - if (textAsset.background) { - this.background.fillStyle = { - color: textAsset.background.color, - alpha: textAsset.background.opacity - }; - - this.background.rect(0, 0, textAsset.width ?? this.edit.size.width, textAsset.height ?? this.edit.size.height); - this.background.fill(); - } + this.drawBackground(); // Create and style text this.text = new pixi.Text(textAsset.text, this.createTextStyle(textAsset)); @@ -83,8 +74,8 @@ export class TextPlayer extends Player { const textAsset = this.clipConfiguration.asset as TextAsset; return { - width: textAsset.width ?? this.edit.size.width, - height: textAsset.height ?? this.edit.size.height + width: this.clipConfiguration.width ?? textAsset.width ?? this.edit.size.width, + height: this.clipConfiguration.height ?? textAsset.height ?? this.edit.size.height }; } @@ -99,9 +90,29 @@ export class TextPlayer extends Player { return { x: scale, y: scale }; } + protected override supportsEdgeResize(): boolean { + return true; + } + + protected override onDimensionsChanged(): void { + this.drawBackground(); + + if (this.text) { + const textAsset = this.clipConfiguration.asset as TextAsset; + this.text.style.wordWrapWidth = this.getSize().width; + this.positionText(textAsset); + } + } + + protected override applyFixedDimensions(): void { + // No-op: base implementation expects a Sprite with texture for fit/crop. + // Text uses Graphics + Text objects that size themselves via getSize(). + } + private createTextStyle(textAsset: TextAsset): pixi.TextStyle { const fontFamily = textAsset.font?.family ?? "Open Sans"; const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily); + const { width } = this.getSize(); return new pixi.TextStyle({ fontFamily: baseFontFamily, @@ -109,7 +120,7 @@ export class TextPlayer extends Player { fill: textAsset.font?.color ?? "#ffffff", fontWeight: fontWeight.toString() as pixi.TextStyleFontWeight, wordWrap: true, - wordWrapWidth: textAsset.width ?? this.edit.size.width, + wordWrapWidth: width, lineHeight: (textAsset.font?.lineHeight ?? 1) * (textAsset.font?.size ?? 32), align: textAsset.alignment?.horizontal ?? "center" }); @@ -120,8 +131,7 @@ export class TextPlayer extends Player { const textAlignmentHorizontal = textAsset.alignment?.horizontal ?? "center"; const textAlignmentVertical = textAsset.alignment?.vertical ?? "center"; - const containerWidth = textAsset.width ?? this.edit.size.width; - const containerHeight = textAsset.height ?? this.edit.size.height; + const { width: containerWidth, height: containerHeight } = this.getSize(); let textX = containerWidth / 2 - this.text.width / 2; let textY = containerHeight / 2 - this.text.height / 2; @@ -141,6 +151,20 @@ export class TextPlayer extends Player { this.text.position.set(textX, textY); } + private drawBackground(): void { + const textAsset = this.clipConfiguration.asset as TextAsset; + if (!this.background || !textAsset.background) return; + + const { width, height } = this.getSize(); + this.background.clear(); + this.background.fillStyle = { + color: textAsset.background.color, + alpha: textAsset.background.opacity + }; + this.background.rect(0, 0, width, height); + this.background.fill(); + } + public updateTextContent(newText: string, initialConfig: Clip): void { this.edit.updateTextContent(this, newText, initialConfig); } From 94d17850dd546bb93bbdcaa384a1ed353f10487a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 15:59:24 +1100 Subject: [PATCH 018/463] fix: remove frame discarding delay in player animation --- src/components/canvas/players/player.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 4db73190..2d14f246 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -24,7 +24,7 @@ import { Entity } from "../../../core/shared/entity"; export abstract class Player extends Entity { private static readonly SnapThreshold = 20; - private static readonly DiscardedFrameCount = Math.ceil((1 / 30) * 1000); + private static readonly DiscardedFrameCount = 0; private static readonly ScaleHandleRadius = 4; private static readonly OutlineWidth = 1; From f14cd5cbf8b9c85b6db2708e285acba9e89cf7d4 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 16:40:29 +1100 Subject: [PATCH 019/463] feat: add rotation support for player objects --- src/components/canvas/players/player.ts | 148 +++++++++++++++++++++--- 1 file changed, 132 insertions(+), 16 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 2d14f246..21bbe69c 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -33,6 +33,30 @@ export abstract class Player extends Entity { private static readonly MaxScale = 5; private static readonly EdgeHitZone = 8; + private static readonly RotationHitZone = 15; + private static readonly CornerNames = ["topLeft", "topRight", "bottomRight", "bottomLeft"] as const; + + // Curved arrow cursor path from rotation-cursors.svg + private static readonly RotationCursorPath = + "M1113.142,1956.331C1008.608,1982.71 887.611,2049.487 836.035,2213.487" + + "L891.955,2219.403L779,2396L705.496,2199.678L772.745,2206.792" + + "C832.051,1999.958 984.143,1921.272 1110.63,1892.641L1107.952,1824.711" + + "L1299,1911L1115.34,2012.065L1113.142,1956.331Z"; + + private static buildRotationCursor(rotateDeg: number): string { + const path = Player.RotationCursorPath; + const transform = rotateDeg === 0 ? "" : ``; + const closeTag = rotateDeg === 0 ? "" : ""; + const svg = `${transform}${closeTag}`; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto`; + } + + private static readonly RotationCursors: Record = { + topLeft: Player.buildRotationCursor(0), + topRight: Player.buildRotationCursor(90), + bottomRight: Player.buildRotationCursor(180), + bottomLeft: Player.buildRotationCursor(270) + }; private static readonly MinDimension = 50; private static readonly MaxDimension = 3840; @@ -70,6 +94,10 @@ export abstract class Player extends Entity { private edgeDragStart: Vector; private originalDimensions: { width: number; height: number; offsetX: number; offsetY: number } | null; + private isRotating: boolean; + private rotationStart: number | null; + private initialRotation: number; + private initialClipConfiguration: Clip | null; protected contentContainer: pixi.Container; @@ -111,6 +139,10 @@ export abstract class Player extends Entity { this.edgeDragStart = { x: 0, y: 0 }; this.originalDimensions = null; + this.isRotating = false; + this.rotationStart = null; + this.initialRotation = 0; + this.initialClipConfiguration = null; this.contentContainer = new pixi.Container(); @@ -283,6 +315,10 @@ export abstract class Player extends Entity { const uiScale = this.getUIScale(); + // Expand hit area to include rotation zones outside corners + const hitMargin = (Player.RotationHitZone + Player.ScaleHandleRadius) / uiScale; + this.getContainer().hitArea = new pixi.Rectangle(-hitMargin, -hitMargin, size.width + hitMargin * 2, size.height + hitMargin * 2); + this.outline.clear(); this.outline.strokeStyle = { width: Player.OutlineWidth / uiScale, color }; this.outline.rect(0, 0, size.width, size.height); @@ -501,6 +537,44 @@ export abstract class Player extends Entity { return this.getPlaybackTime() < Player.DiscardedFrameCount; } + private getRotationCorner(event: pixi.FederatedPointerEvent): string | null { + const localPoint = event.getLocalPosition(this.getContainer()); + const size = this.getSize(); + const uiScale = this.getUIScale(); + const handleRadius = Player.ScaleHandleRadius / uiScale; + const rotationZone = Player.RotationHitZone / uiScale; + + const cornerCoords = [ + { x: 0, y: 0 }, + { x: size.width, y: 0 }, + { x: size.width, y: size.height }, + { x: 0, y: size.height } + ]; + + const isOutsideContent = localPoint.x < 0 || localPoint.x > size.width || localPoint.y < 0 || localPoint.y > size.height; + if (!isOutsideContent) return null; + + for (let i = 0; i < cornerCoords.length; i += 1) { + const corner = cornerCoords[i]; + const dx = localPoint.x - corner.x; + const dy = localPoint.y - corner.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance > handleRadius && distance < handleRadius + rotationZone) { + return Player.CornerNames[i]; + } + } + return null; + } + + private getContentCenter(): Vector { + const bounds = this.contentContainer.getBounds(); + return { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2 + }; + } + private onPointerStart(event: pixi.FederatedPointerEvent): void { if (event.button !== Pointer.ButtonLeftClick) { return; @@ -516,6 +590,16 @@ export abstract class Player extends Entity { this.scaleDirection = null; + // Check for rotation zone click (outside corners) + const rotationCorner = this.getRotationCorner(event); + if (rotationCorner) { + this.isRotating = true; + const center = this.getContentCenter(); + this.rotationStart = Math.atan2(event.globalY - center.y, event.globalX - center.x); + this.initialRotation = this.getRotation(); + return; + } + const isTopLeftScaling = this.topLeftScaleHandle?.getBounds().containsPoint(event.globalX, event.globalY); if (isTopLeftScaling) { this.scaleDirection = "topLeft"; @@ -785,6 +869,23 @@ export abstract class Player extends Entity { return; } + // Handle rotation dragging + if (this.isRotating && this.rotationStart !== null) { + const center = this.getContentCenter(); + const currentAngle = Math.atan2(event.globalY - center.y, event.globalX - center.x); + const deltaAngle = (currentAngle - this.rotationStart) * (180 / Math.PI); + + const newRotation = this.initialRotation + deltaAngle; + + this.clipConfiguration.transform = { + ...this.clipConfiguration.transform, + rotate: { angle: newRotation } + }; + + this.rotationKeyframeBuilder = new KeyframeBuilder(newRotation, this.getLength()); + return; + } + if (this.isDragging) { const timelinePoint = event.getLocalPosition(this.edit.getContainer()); @@ -858,24 +959,36 @@ export abstract class Player extends Entity { return; } - // Update cursor based on edge proximity when not dragging (for edge resize) - if (this.supportsEdgeResize() && !this.isDragging && !this.scaleDirection && !this.edgeDragDirection) { - const localPoint = event.getLocalPosition(this.getContainer()); - const size = this.getSize(); - const hitZone = Player.EdgeHitZone / this.getUIScale(); + // Update cursor based on proximity when not dragging + if (!this.isDragging && !this.scaleDirection && !this.edgeDragDirection && !this.isRotating) { + // Check for rotation cursor (outside corners) + const rotationCorner = this.getRotationCorner(event); + if (rotationCorner && Player.RotationCursors[rotationCorner]) { + this.getContainer().cursor = Player.RotationCursors[rotationCorner]; + return; + } - const nearLeft = localPoint.x >= -hitZone && localPoint.x <= hitZone; - const nearRight = localPoint.x >= size.width - hitZone && localPoint.x <= size.width + hitZone; - const nearTop = localPoint.y >= -hitZone && localPoint.y <= hitZone; - const nearBottom = localPoint.y >= size.height - hitZone && localPoint.y <= size.height + hitZone; + // Check for edge resize cursor + if (this.supportsEdgeResize()) { + const localPoint = event.getLocalPosition(this.getContainer()); + const size = this.getSize(); + const hitZone = Player.EdgeHitZone / this.getUIScale(); - const withinVerticalRange = localPoint.y > hitZone && localPoint.y < size.height - hitZone; - const withinHorizontalRange = localPoint.x > hitZone && localPoint.x < size.width - hitZone; + const nearLeft = localPoint.x >= -hitZone && localPoint.x <= hitZone; + const nearRight = localPoint.x >= size.width - hitZone && localPoint.x <= size.width + hitZone; + const nearTop = localPoint.y >= -hitZone && localPoint.y <= hitZone; + const nearBottom = localPoint.y >= size.height - hitZone && localPoint.y <= size.height + hitZone; - if ((nearLeft || nearRight) && withinVerticalRange) { - this.getContainer().cursor = "ew-resize"; - } else if ((nearTop || nearBottom) && withinHorizontalRange) { - this.getContainer().cursor = "ns-resize"; + const withinVerticalRange = localPoint.y > hitZone && localPoint.y < size.height - hitZone; + const withinHorizontalRange = localPoint.x > hitZone && localPoint.x < size.width - hitZone; + + if ((nearLeft || nearRight) && withinVerticalRange) { + this.getContainer().cursor = "ew-resize"; + } else if ((nearTop || nearBottom) && withinHorizontalRange) { + this.getContainer().cursor = "ns-resize"; + } else { + this.getContainer().cursor = "pointer"; + } } else { this.getContainer().cursor = "pointer"; } @@ -883,7 +996,7 @@ export abstract class Player extends Entity { } private onPointerUp(): void { - if ((this.isDragging || this.scaleDirection !== null || this.edgeDragDirection !== null) && this.hasStateChanged()) { + if ((this.isDragging || this.scaleDirection !== null || this.edgeDragDirection !== null || this.isRotating) && this.hasStateChanged()) { this.edit.setUpdatedClip(this, this.initialClipConfiguration, structuredClone(this.clipConfiguration)); } @@ -898,6 +1011,9 @@ export abstract class Player extends Entity { this.edgeDragStart = { x: 0, y: 0 }; this.originalDimensions = null; + this.isRotating = false; + this.rotationStart = null; + this.initialClipConfiguration = null; } From 80163c0cac54591885d9945d90d2c82601fbedba Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 17:06:12 +1100 Subject: [PATCH 020/463] feat: add rotation-aware resize cursors and improve rotation interaction --- src/components/canvas/players/player.ts | 123 +++++++++++++++++------- 1 file changed, 89 insertions(+), 34 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 21bbe69c..80d20926 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -34,29 +34,58 @@ export abstract class Player extends Entity { private static readonly EdgeHitZone = 8; private static readonly RotationHitZone = 15; + private static readonly ExpandedHitArea = 10000; private static readonly CornerNames = ["topLeft", "topRight", "bottomRight", "bottomLeft"] as const; - // Curved arrow cursor path from rotation-cursors.svg + // Curved arrow for rotation cursor private static readonly RotationCursorPath = "M1113.142,1956.331C1008.608,1982.71 887.611,2049.487 836.035,2213.487" + "L891.955,2219.403L779,2396L705.496,2199.678L772.745,2206.792" + "C832.051,1999.958 984.143,1921.272 1110.63,1892.641L1107.952,1824.711" + "L1299,1911L1115.34,2012.065L1113.142,1956.331Z"; - private static buildRotationCursor(rotateDeg: number): string { + // Double-headed arrow for resize cursor + private static readonly ResizeCursorPath = + "M1320,2186L1085,2421L1120,2457L975,2496" + + "L1014,2351L1050,2386L1285,2151L1250,2115" + + "L1396,2075L1356,2221L1320,2186Z"; + private static readonly ResizeCursorMatrix = "matrix(0.807871,0.707107,-0.807871,0.707107,2111.872433,-206.020386)"; + + // Base angles for cursors (before clip rotation is applied) + private static readonly CursorBaseAngles: Record = { + // Rotation cursor angles + topLeft: 0, + topRight: 90, + bottomRight: 180, + bottomLeft: 270, + // Resize cursor angles (NW-SE diagonal = 45°, NE-SW = -45°, horizontal = 0°, vertical = 90°) + topLeftResize: 45, + topRightResize: -45, + bottomRightResize: 45, + bottomLeftResize: -45, + left: 0, + right: 0, + top: 90, + bottom: 90 + }; + + private static buildRotationCursor(angleDeg: number): string { const path = Player.RotationCursorPath; - const transform = rotateDeg === 0 ? "" : ``; - const closeTag = rotateDeg === 0 ? "" : ""; + const transform = angleDeg === 0 ? "" : ``; + const closeTag = angleDeg === 0 ? "" : ""; const svg = `${transform}${closeTag}`; return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto`; } - private static readonly RotationCursors: Record = { - topLeft: Player.buildRotationCursor(0), - topRight: Player.buildRotationCursor(90), - bottomRight: Player.buildRotationCursor(180), - bottomLeft: Player.buildRotationCursor(270) - }; + private static buildResizeCursor(angleDeg: number): string { + const path = Player.ResizeCursorPath; + const matrix = Player.ResizeCursorMatrix; + const svg = `` + + `` + + ``; + return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto`; + } + private static readonly MinDimension = 50; private static readonly MaxDimension = 3840; @@ -87,8 +116,6 @@ export abstract class Player extends Entity { private dragOffset: Vector; private scaleDirection: "topLeft" | "topRight" | "bottomLeft" | "bottomRight" | null; - private scaleStart: number | null; - private scaleOffset: Vector; private edgeDragDirection: "left" | "right" | "top" | "bottom" | null; private edgeDragStart: Vector; @@ -97,6 +124,7 @@ export abstract class Player extends Entity { private isRotating: boolean; private rotationStart: number | null; private initialRotation: number; + private rotationCorner: (typeof Player.CornerNames)[number] | null; private initialClipConfiguration: Clip | null; protected contentContainer: pixi.Container; @@ -132,8 +160,6 @@ export abstract class Player extends Entity { this.dragOffset = { x: 0, y: 0 }; this.scaleDirection = null; - this.scaleStart = null; - this.scaleOffset = { x: 0, y: 0 }; this.edgeDragDirection = null; this.edgeDragStart = { x: 0, y: 0 }; @@ -142,6 +168,7 @@ export abstract class Player extends Entity { this.isRotating = false; this.rotationStart = null; this.initialRotation = 0; + this.rotationCorner = null; this.initialClipConfiguration = null; @@ -232,15 +259,15 @@ export abstract class Player extends Entity { this.bottomRightScaleHandle.zIndex = 1000; this.bottomLeftScaleHandle.zIndex = 1000; - // Set resize cursors for corner handles + // Set resize cursors for corner handles (dynamic based on rotation) this.topLeftScaleHandle.eventMode = "static"; - this.topLeftScaleHandle.cursor = "nwse-resize"; + this.topLeftScaleHandle.cursor = this.getCornerResizeCursor("topLeft"); this.topRightScaleHandle.eventMode = "static"; - this.topRightScaleHandle.cursor = "nesw-resize"; + this.topRightScaleHandle.cursor = this.getCornerResizeCursor("topRight"); this.bottomRightScaleHandle.eventMode = "static"; - this.bottomRightScaleHandle.cursor = "nwse-resize"; + this.bottomRightScaleHandle.cursor = this.getCornerResizeCursor("bottomRight"); this.bottomLeftScaleHandle.eventMode = "static"; - this.bottomLeftScaleHandle.cursor = "nesw-resize"; + this.bottomLeftScaleHandle.cursor = this.getCornerResizeCursor("bottomLeft"); this.getContainer().addChild(this.topLeftScaleHandle); this.getContainer().addChild(this.topRightScaleHandle); @@ -316,8 +343,12 @@ export abstract class Player extends Entity { const uiScale = this.getUIScale(); // Expand hit area to include rotation zones outside corners - const hitMargin = (Player.RotationHitZone + Player.ScaleHandleRadius) / uiScale; - this.getContainer().hitArea = new pixi.Rectangle(-hitMargin, -hitMargin, size.width + hitMargin * 2, size.height + hitMargin * 2); + // During drag operations, keep the expanded hit area to capture mouse events anywhere + const isDraggingHandle = this.isRotating || this.scaleDirection !== null || this.edgeDragDirection !== null; + if (!isDraggingHandle) { + const hitMargin = (Player.RotationHitZone + Player.ScaleHandleRadius) / uiScale; + this.getContainer().hitArea = new pixi.Rectangle(-hitMargin, -hitMargin, size.width + hitMargin * 2, size.height + hitMargin * 2); + } this.outline.clear(); this.outline.strokeStyle = { width: Player.OutlineWidth / uiScale, color }; @@ -537,7 +568,7 @@ export abstract class Player extends Entity { return this.getPlaybackTime() < Player.DiscardedFrameCount; } - private getRotationCorner(event: pixi.FederatedPointerEvent): string | null { + private getRotationCorner(event: pixi.FederatedPointerEvent): (typeof Player.CornerNames)[number] | null { const localPoint = event.getLocalPosition(this.getContainer()); const size = this.getSize(); const uiScale = this.getUIScale(); @@ -575,6 +606,21 @@ export abstract class Player extends Entity { }; } + private getRotationCursor(corner: string): string { + const baseAngle = Player.CursorBaseAngles[corner] ?? 0; + return Player.buildRotationCursor(baseAngle + this.getRotation()); + } + + private getCornerResizeCursor(corner: string): string { + const baseAngle = Player.CursorBaseAngles[`${corner}Resize`] ?? 45; + return Player.buildResizeCursor(baseAngle + this.getRotation()); + } + + private getEdgeResizeCursor(edge: "left" | "right" | "top" | "bottom"): string { + const baseAngle = Player.CursorBaseAngles[edge] ?? 0; + return Player.buildResizeCursor(baseAngle + this.getRotation()); + } + private onPointerStart(event: pixi.FederatedPointerEvent): void { if (event.button !== Pointer.ButtonLeftClick) { return; @@ -594,9 +640,14 @@ export abstract class Player extends Entity { const rotationCorner = this.getRotationCorner(event); if (rotationCorner) { this.isRotating = true; + this.rotationCorner = rotationCorner; const center = this.getContentCenter(); this.rotationStart = Math.atan2(event.globalY - center.y, event.globalX - center.x); this.initialRotation = this.getRotation(); + + // Expand hit area to capture pointer events anywhere during rotation drag + const size = Player.ExpandedHitArea; + this.getContainer().hitArea = new pixi.Rectangle(-size, -size, size * 2, size * 2); return; } @@ -656,6 +707,9 @@ export abstract class Player extends Entity { offsetY: currentOffsetY }; + // Expand hit area to capture pointer events anywhere during resize drag + const hitSize = Player.ExpandedHitArea; + this.getContainer().hitArea = new pixi.Rectangle(-hitSize, -hitSize, hitSize * 2, hitSize * 2); return; } @@ -724,6 +778,9 @@ export abstract class Player extends Entity { offsetY: currentOffsetY }; + // Expand hit area to capture pointer events anywhere during resize drag + const hitSize = Player.ExpandedHitArea; + this.getContainer().hitArea = new pixi.Rectangle(-hitSize, -hitSize, hitSize * 2, hitSize * 2); return; } } @@ -883,6 +940,11 @@ export abstract class Player extends Entity { }; this.rotationKeyframeBuilder = new KeyframeBuilder(newRotation, this.getLength()); + + // Update cursor to follow the rotation + if (this.rotationCorner) { + this.getContainer().cursor = this.getRotationCursor(this.rotationCorner); + } return; } @@ -963,8 +1025,8 @@ export abstract class Player extends Entity { if (!this.isDragging && !this.scaleDirection && !this.edgeDragDirection && !this.isRotating) { // Check for rotation cursor (outside corners) const rotationCorner = this.getRotationCorner(event); - if (rotationCorner && Player.RotationCursors[rotationCorner]) { - this.getContainer().cursor = Player.RotationCursors[rotationCorner]; + if (rotationCorner) { + this.getContainer().cursor = this.getRotationCursor(rotationCorner); return; } @@ -983,9 +1045,9 @@ export abstract class Player extends Entity { const withinHorizontalRange = localPoint.x > hitZone && localPoint.x < size.width - hitZone; if ((nearLeft || nearRight) && withinVerticalRange) { - this.getContainer().cursor = "ew-resize"; + this.getContainer().cursor = this.getEdgeResizeCursor(nearLeft ? "left" : "right"); } else if ((nearTop || nearBottom) && withinHorizontalRange) { - this.getContainer().cursor = "ns-resize"; + this.getContainer().cursor = this.getEdgeResizeCursor(nearTop ? "top" : "bottom"); } else { this.getContainer().cursor = "pointer"; } @@ -1004,8 +1066,6 @@ export abstract class Player extends Entity { this.dragOffset = { x: 0, y: 0 }; this.scaleDirection = null; - this.scaleStart = null; - this.scaleOffset = { x: 0, y: 0 }; this.edgeDragDirection = null; this.edgeDragStart = { x: 0, y: 0 }; @@ -1013,6 +1073,7 @@ export abstract class Player extends Entity { this.isRotating = false; this.rotationStart = null; + this.rotationCorner = null; this.initialClipConfiguration = null; } @@ -1025,12 +1086,6 @@ export abstract class Player extends Entity { this.isHovering = false; } - private clipHasPresets(): boolean { - return ( - Boolean(this.clipConfiguration.effect) || Boolean(this.clipConfiguration.transition?.in) || Boolean(this.clipConfiguration.transition?.out) - ); - } - private clipHasKeyframes(): boolean { return [ this.clipConfiguration.scale, From 70db8356c3154f0d3e5ee50bbe8906670376cd2e Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 4 Dec 2025 22:52:17 +1100 Subject: [PATCH 021/463] feat: migrate fonts to CDN and expand font variant support --- src/core/fonts/font-config.ts | 43 ++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/src/core/fonts/font-config.ts b/src/core/fonts/font-config.ts index b070eae5..67e66f7a 100644 --- a/src/core/fonts/font-config.ts +++ b/src/core/fonts/font-config.ts @@ -2,23 +2,35 @@ * Shared font configuration for text and rich-text players */ +const FONT_CDN = "https://templates.shotstack.io/basic/asset/font"; + /** Font family name to file path mapping */ export const FONT_PATHS: Record = { - Arapey: "/assets/fonts/Arapey-Regular.ttf", - "Clear Sans": "/assets/fonts/ClearSans-Regular.ttf", - "Didact Gothic": "/assets/fonts/DidactGothic-Regular.ttf", - Montserrat: "/assets/fonts/Montserrat.ttf", - "Montserrat ExtraBold": "/assets/fonts/Montserrat-ExtraBold.ttf", - "Montserrat SemiBold": "/assets/fonts/Montserrat-SemiBold.ttf", - MovLette: "/assets/fonts/MovLette.ttf", - "Open Sans": "/assets/fonts/OpenSans.ttf", - "Open Sans Bold": "/assets/fonts/OpenSans-Bold.ttf", - "Permanent Marker": "/assets/fonts/PermanentMarker-Regular.ttf", - Roboto: "/assets/fonts/Roboto.ttf", - "Sue Ellen Francisco": "/assets/fonts/SueEllenFrancisco.ttf", - "Uni Neue": "/assets/fonts/UniNeue-Bold.otf", - "Work Sans": "/assets/fonts/WorkSans.ttf", - "Work Sans Light": "/assets/fonts/WorkSans-Light.ttf" + Arapey: `${FONT_CDN}/arapey-regular.ttf`, + "Clear Sans": `${FONT_CDN}/clearsans-regular.ttf`, + "Clear Sans Bold": `${FONT_CDN}/clearsans-bold.ttf`, + "Didact Gothic": `${FONT_CDN}/didactgothic-regular.ttf`, + Montserrat: `${FONT_CDN}/montserrat-regular.ttf`, + "Montserrat Bold": `${FONT_CDN}/montserrat-bold.ttf`, + "Montserrat ExtraBold": `${FONT_CDN}/montserrat-extrabold.ttf`, + "Montserrat SemiBold": `${FONT_CDN}/montserrat-semibold.ttf`, + "Montserrat Light": `${FONT_CDN}/montserrat-light.ttf`, + "Montserrat Medium": `${FONT_CDN}/montserrat-medium.ttf`, + "Montserrat Black": `${FONT_CDN}/montserrat-black.ttf`, + MovLette: `${FONT_CDN}/movlette.ttf`, + "Open Sans": `${FONT_CDN}/opensans-regular.ttf`, + "Open Sans Bold": `${FONT_CDN}/opensans-bold.ttf`, + "Open Sans ExtraBold": `${FONT_CDN}/opensans-extrabold.ttf`, + "Permanent Marker": `${FONT_CDN}/permanentmarker-regular.ttf`, + Roboto: `${FONT_CDN}/roboto-regular.ttf`, + "Roboto Bold": `${FONT_CDN}/roboto-bold.ttf`, + "Roboto Light": `${FONT_CDN}/roboto-light.ttf`, + "Roboto Medium": `${FONT_CDN}/roboto-medium.ttf`, + "Sue Ellen Francisco": `${FONT_CDN}/sueellenfrancisco-regular.ttf`, + "Work Sans": `${FONT_CDN}/worksans-regular.ttf`, + "Work Sans Bold": `${FONT_CDN}/worksans-bold.ttf`, + "Work Sans Light": `${FONT_CDN}/worksans-light.ttf`, + "Work Sans SemiBold": `${FONT_CDN}/worksans-semibold.ttf` }; /** Alternative names (camelCase, etc.) mapped to canonical names */ @@ -28,7 +40,6 @@ export const FONT_ALIASES: Record = { OpenSans: "Open Sans", PermanentMarker: "Permanent Marker", SueEllenFrancisco: "Sue Ellen Francisco", - UniNeue: "Uni Neue", WorkSans: "Work Sans" }; From 155ced2ee559ca30aca251a26ea5ef337abe2b37 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 00:36:42 +1100 Subject: [PATCH 022/463] feat: add viewport mask updates when edit output size changes --- src/core/edit.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/core/edit.ts b/src/core/edit.ts index acf22ae1..68008bd0 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -170,6 +170,14 @@ export class Edit extends Entity { TextPlayer.resetFontCache(); } + private updateViewportMask(): void { + if (this.viewportMask) { + this.viewportMask.clear(); + this.viewportMask.rect(0, 0, this.size.width, this.size.height); + this.viewportMask.fill(0xffffff); + } + } + public play(): void { this.isPlaying = true; this.events.emit("playback:play", {}); @@ -200,6 +208,13 @@ export class Edit extends Entity { // and resolved after all clips are loaded this.edit = EditSchema.parse(mergedEdit); + const newSize = this.edit.output?.size; + if (newSize && (newSize.width !== this.size.width || newSize.height !== this.size.height)) { + this.size = newSize; + this.updateViewportMask(); + this.canvas?.zoomToFit(); + } + this.backgroundColor = this.edit.timeline.background || "#000000"; if (this.background) { From 1bb6efae7e70aeedb7695271a36231bc4d6a1549 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 00:42:18 +1100 Subject: [PATCH 023/463] feat: add opacity, padding, borderRadius to text asset schemas and make stroke width optional --- src/core/schemas/text-asset.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/schemas/text-asset.ts b/src/core/schemas/text-asset.ts index 97a2d76c..299ddb14 100644 --- a/src/core/schemas/text-asset.ts +++ b/src/core/schemas/text-asset.ts @@ -8,7 +8,8 @@ export const TextAssetFontSchema = zod family: zod.string().optional(), size: zod.coerce.number().positive().optional(), weight: zod.number().optional(), - lineHeight: zod.number().optional() + lineHeight: zod.number().optional(), + opacity: zod.number().min(0).max(1).optional() }) .strict(); @@ -22,13 +23,15 @@ export const TextAssetAlignmentSchema = zod export const TextAssetBackgroundSchema = zod .object({ color: TextAssetColorSchema, - opacity: zod.number().min(0).max(1).default(1) + opacity: zod.number().min(0).max(1).default(1), + padding: zod.number().min(0).max(100).optional(), + borderRadius: zod.number().min(0).optional() }) .strict(); export const TextAssetStrokeSchema = zod .object({ - width: zod.number().positive(), + width: zod.number().positive().optional(), color: TextAssetColorSchema }) .strict(); From 6f7c054bbba5e6bedea556807ff9e48781a0c398 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 00:58:00 +1100 Subject: [PATCH 024/463] feat: add opentype.js for font family name extraction --- package.json | 3 +++ src/core/loaders/font-load-parser.ts | 16 +++++++++------- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 10ad3c52..25499e89 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/howler": "^2.2.12", "@types/jest": "^30.0.0", "@types/node": "^22.9.0", + "@types/opentype.js": "^1.3.8", "@typescript-eslint/eslint-plugin": "^7.18.0", "@typescript-eslint/parser": "^7.18.0", "eslint": "^8.57.1", @@ -81,6 +82,8 @@ "fast-deep-equal": "^3.1.3", "howler": "^2.2.4", "mediabunny": "^1.11.2", + "opentype": "^0.1.2", + "opentype.js": "^1.3.4", "pixi-filters": "^6.0.5", "pixi.js": "^8.5.2", "zod": "^3.23.8" diff --git a/src/core/loaders/font-load-parser.ts b/src/core/loaders/font-load-parser.ts index 3c8d6619..4061abc0 100644 --- a/src/core/loaders/font-load-parser.ts +++ b/src/core/loaders/font-load-parser.ts @@ -1,3 +1,4 @@ +import * as opentype from "opentype.js"; import * as pixi from "pixi.js"; type Woff2Decompressor = { @@ -33,13 +34,13 @@ export class FontLoadParser implements pixi.LoaderParser { } public async load(url: string, _?: pixi.ResolvedAsset, __?: pixi.Loader): Promise { - const urlWithoutQuery = url.split("?")[0] ?? ""; - const extension = urlWithoutQuery.split(".").pop()?.toLowerCase() ?? ""; - - const filename = urlWithoutQuery.split("/").pop() || ""; - const familyName = filename.replace(/\.(ttf|otf|woff|woff2)$/i, ""); + const extension = url.split("?")[0]?.split(".").pop()?.toLowerCase() ?? ""; + const buffer = await fetch(url).then(res => res.arrayBuffer()); if (extension !== "woff2") { + const font = opentype.parse(new Uint8Array(buffer).buffer); + const familyName = font.names.fontFamily["en"] || font.names.fontFamily[Object.keys(font.names.fontFamily)[0]]; + const fontFace = new FontFace(familyName, `url(${url})`); await fontFace.load(); @@ -47,8 +48,6 @@ export class FontLoadParser implements pixi.LoaderParser { return fontFace; } - const buffer = await fetch(url).then(res => res.arrayBuffer()); - await this.loadWoff2Decompressor(); if (!this.woff2Decompressor) { throw new Error("Cannot initialize Woff2 decompressor."); @@ -56,6 +55,9 @@ export class FontLoadParser implements pixi.LoaderParser { const decompressed = this.woff2Decompressor.decompress(buffer); + const font = opentype.parse(new Uint8Array(decompressed).buffer); + const familyName = font.names.fontFamily["en"] || font.names.fontFamily[Object.keys(font.names.fontFamily)[0]]; + const blob = new Blob([decompressed], { type: "font/ttf" }); const blobUrl = URL.createObjectURL(blob); From e13c8c0cc7c84d17eeb8a6f8b55ecb57e743dddb Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 10:04:03 +1100 Subject: [PATCH 025/463] feat: add smooth easing curve and improve carousel/slide transitions --- src/components/canvas/players/player.ts | 8 +- src/core/animations/curve-interpolator.ts | 4 + .../animations/transition-preset-builder.ts | 140 ++++++++++++++---- src/core/schemas/keyframe.ts | 1 + 4 files changed, 119 insertions(+), 34 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 80d20926..a4efbd27 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -218,11 +218,15 @@ export abstract class Player extends Entity { rotationKeyframes.push(...transitionKeyframeSet.rotationKeyframes); if (offsetXKeyframes.length) { - this.offsetXKeyframeBuilder = new KeyframeBuilder(offsetXKeyframes, this.getLength()); + const offsetX = this.clipConfiguration.offset?.x; + const initialOffsetX = typeof offsetX === "number" ? offsetX : 0; + this.offsetXKeyframeBuilder = new KeyframeBuilder(offsetXKeyframes, this.getLength(), initialOffsetX); } if (offsetYKeyframes.length) { - this.offsetYKeyframeBuilder = new KeyframeBuilder(offsetYKeyframes, this.getLength()); + const offsetY = this.clipConfiguration.offset?.y; + const initialOffsetY = typeof offsetY === "number" ? offsetY : 0; + this.offsetYKeyframeBuilder = new KeyframeBuilder(offsetYKeyframes, this.getLength(), initialOffsetY); } if (opacityKeyframes.length) { diff --git a/src/core/animations/curve-interpolator.ts b/src/core/animations/curve-interpolator.ts index 21e3de2f..2b9acaf8 100644 --- a/src/core/animations/curve-interpolator.ts +++ b/src/core/animations/curve-interpolator.ts @@ -7,6 +7,10 @@ export class CurveInterpolator { private initializeCurves(): void { this.curves = { + smooth: [ + [0.5, 0.0], + [0.5, 1.0] + ], ease: [ [0.25, 0.1], [0.25, 1.0] diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index b0be0455..e2dc39c6 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -60,7 +60,7 @@ export class TransitionPresetBuilder { case "fade": { const initialOpacity = 0; const targetOpacity = Math.max(0, Math.min((this.clipConfiguration.opacity as number) ?? 1, 1)); - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "linear" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -84,11 +84,11 @@ export class TransitionPresetBuilder { const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; const initialOffsetX = offsetX + 0.025; const targetOffsetX = offsetX; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeIn" }); + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); const initialOpacity = 0; const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -97,11 +97,11 @@ export class TransitionPresetBuilder { const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; const initialOffsetX = offsetX - 0.025; const targetOffsetX = offsetX; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeIn" }); + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); const initialOpacity = 0; const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -110,11 +110,11 @@ export class TransitionPresetBuilder { const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; const initialOffsetY = offsetY + 0.025; const targetOffsetY = offsetY; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeIn" }); + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); const initialOpacity = 0; const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -123,18 +123,50 @@ export class TransitionPresetBuilder { const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; const initialOffsetY = offsetY - 0.025; const targetOffsetY = offsetY; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeOut" }); + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); const initialOpacity = 0; const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); + + break; + } + case "carouselLeft": { + const rawOffsetX = this.clipConfiguration.offset?.x; + const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; + const initialOffsetX = offsetX + 1; + const targetOffsetX = offsetX; + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); + + break; + } + case "carouselRight": { + const rawOffsetX = this.clipConfiguration.offset?.x; + const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; + const initialOffsetX = offsetX - 1; + const targetOffsetX = offsetX; + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); + + break; + } + case "carouselUp": { + const rawOffsetY = this.clipConfiguration.offset?.y; + const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; + const initialOffsetY = offsetY - 1.05; + const targetOffsetY = offsetY; + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); + + break; + } + case "carouselDown": { + const rawOffsetY = this.clipConfiguration.offset?.y; + const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; + const initialOffsetY = offsetY + 1.05; + const targetOffsetY = offsetY; + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); break; } - case "carouselLeft": - case "carouselRight": - case "carouselUp": - case "carouselDown": case "shuffleTopRight": case "shuffleRightTop": case "shuffleRightBottom": @@ -170,7 +202,7 @@ export class TransitionPresetBuilder { case "fade": { const initialOpacity = Math.max(0, Math.min((this.clipConfiguration.opacity as number) ?? 1, 1)); const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "linear" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -194,11 +226,11 @@ export class TransitionPresetBuilder { const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; const initialOffsetX = offsetX; const targetOffsetX = offsetX - 0.025; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeOut" }); + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); const initialOpacity = 1; const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -207,11 +239,11 @@ export class TransitionPresetBuilder { const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; const initialOffsetX = offsetX; const targetOffsetX = offsetX + 0.025; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "easeOut" }); + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); const initialOpacity = 1; const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -220,11 +252,11 @@ export class TransitionPresetBuilder { const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; const initialOffsetY = offsetY; const targetOffsetY = offsetY - 0.025; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeIn" }); + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); const initialOpacity = 1; const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); break; } @@ -233,18 +265,50 @@ export class TransitionPresetBuilder { const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; const initialOffsetY = offsetY; const targetOffsetY = offsetY + 0.025; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "easeIn" }); + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); const initialOpacity = 1; const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" }); + opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); + + break; + } + case "carouselLeft": { + const rawOffsetX = this.clipConfiguration.offset?.x; + const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; + const initialOffsetX = offsetX; + const targetOffsetX = offsetX - 1; + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); + + break; + } + case "carouselRight": { + const rawOffsetX = this.clipConfiguration.offset?.x; + const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; + const initialOffsetX = offsetX; + const targetOffsetX = offsetX + 1; + offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); + + break; + } + case "carouselUp": { + const rawOffsetY = this.clipConfiguration.offset?.y; + const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; + const initialOffsetY = offsetY; + const targetOffsetY = offsetY + 1.1; + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); + + break; + } + case "carouselDown": { + const rawOffsetY = this.clipConfiguration.offset?.y; + const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; + const initialOffsetY = offsetY; + const targetOffsetY = offsetY - 1.1; + offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); break; } - case "carouselLeft": - case "carouselRight": - case "carouselUp": - case "carouselDown": case "shuffleTopRight": case "shuffleRightTop": case "shuffleRightBottom": @@ -272,32 +336,44 @@ export class TransitionPresetBuilder { } private getInPresetLength(): number { - const [, transitionSpeed] = (this.clipConfiguration.transition?.in ?? "").split(/(Slow|Fast|VeryFast)/); + const transitionIn = this.clipConfiguration.transition?.in ?? ""; + const [transitionName, transitionSpeed] = transitionIn.split(/(Slow|Fast|VeryFast)/); + const isCarousel = transitionName.startsWith("carousel"); + const isSlide = transitionName.startsWith("slide"); + const isZoom = transitionName === "zoom"; + + if (isZoom) return 0.4; switch (transitionSpeed) { case "Slow": return 2; case "Fast": - return 0.5; + return isCarousel || isSlide ? 0.25 : 0.5; case "VeryFast": return 0.25; default: - return 1; + return isCarousel || isSlide ? 0.5 : 1; } } private getOutPresetLength(): number { - const [, transitionSpeed] = (this.clipConfiguration.transition?.out ?? "").split(/(Slow|Fast|VeryFast)/); + const transitionOut = this.clipConfiguration.transition?.out ?? ""; + const [transitionName, transitionSpeed] = transitionOut.split(/(Slow|Fast|VeryFast)/); + const isCarousel = transitionName.startsWith("carousel"); + const isSlide = transitionName.startsWith("slide"); + const isZoom = transitionName === "zoom"; + + if (isZoom) return 0.4; switch (transitionSpeed) { case "Slow": return 2; case "Fast": - return 0.5; + return isCarousel || isSlide ? 0.25 : 0.5; case "VeryFast": return 0.25; default: - return 1; + return isCarousel || isSlide ? 0.5 : 1; } } } diff --git a/src/core/schemas/keyframe.ts b/src/core/schemas/keyframe.ts index bff42c5e..2ceb3557 100644 --- a/src/core/schemas/keyframe.ts +++ b/src/core/schemas/keyframe.ts @@ -3,6 +3,7 @@ import * as zod from "zod"; export const KeyframeInterpolationSchema = zod.enum(["linear", "bezier", "constant"]); export const KeyframeEasingSchema = zod.enum([ + "smooth", "ease", "easeIn", "easeOut", From fd0bb8f2a5c278bc15212082b4f045846ef65913 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 11:23:47 +1100 Subject: [PATCH 026/463] fix: improve .mov video error message and remove .webm support check --- src/components/canvas/players/video-player.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 900381e8..25bdc27f 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -33,9 +33,6 @@ export class VideoPlayer extends Player { this.skipVideoUpdate = false; } - /** - * TODO: Add support for .mov and .webm files - */ public override async load(): Promise { await super.load(); @@ -43,8 +40,11 @@ export class VideoPlayer extends Player { const identifier = videoAsset.src; - if (identifier.endsWith(".mov") || identifier.endsWith(".webm")) { - throw new Error(`Video source '${videoAsset.src}' is not supported. .mov and .webm files are currently not supported.`); + if (identifier.endsWith(".mov")) { + throw new Error( + `Video source '${videoAsset.src}' is not supported. ` + + `.mov files cannot be played in the browser. Please convert to .webm or .mp4 first.` + ); } const loadOptions: pixi.UnresolvedAsset = { src: identifier, data: { autoPlay: false, muted: false } }; From 990089c51c8f5556273f379481b0f96d8172f7dd Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 15:45:37 +1100 Subject: [PATCH 027/463] fix: validate stroke properties before applying filter --- src/components/canvas/players/text-player.ts | 4 ++-- src/core/schemas/clip.ts | 2 +- src/core/schemas/shape-asset.ts | 4 ++-- src/core/schemas/text-asset.ts | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index d4cc4a3c..390a0e93 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -35,8 +35,8 @@ export class TextPlayer extends Player { // Position text according to alignment this.positionText(textAsset); - // Apply stroke filter if specified - if (textAsset.stroke) { + // Apply stroke filter if specified with a positive width and color + if (textAsset.stroke?.width && textAsset.stroke.width > 0 && textAsset.stroke.color) { const textStrokeFilter = new pixiFilters.OutlineFilter({ thickness: textAsset.stroke.width, color: textAsset.stroke.color diff --git a/src/core/schemas/clip.ts b/src/core/schemas/clip.ts index e913025d..b552d5c3 100644 --- a/src/core/schemas/clip.ts +++ b/src/core/schemas/clip.ts @@ -12,7 +12,7 @@ const ClipAnchorSchema = zod.enum(["topLeft", "top", "topRight", "left", "center const ClipFitSchema = zod.enum(["crop", "cover", "contain", "none"]); -const ClipOffsetValueSchema = zod.number().min(-10).max(10).default(0); +const ClipOffsetValueSchema = zod.coerce.number().min(-10).max(10).default(0); const ClipOffsetXSchema = KeyframeSchema.extend({ from: ClipOffsetValueSchema, diff --git a/src/core/schemas/shape-asset.ts b/src/core/schemas/shape-asset.ts index 755089a3..a6e7f0ee 100644 --- a/src/core/schemas/shape-asset.ts +++ b/src/core/schemas/shape-asset.ts @@ -31,8 +31,8 @@ export const ShapeAssetFillSchema = zod export const ShapeAssetStrokeSchema = zod .object({ - color: ShapeAssetColorSchema, - width: zod.number().positive() + color: ShapeAssetColorSchema.optional(), + width: zod.number().min(0).optional() }) .strict(); diff --git a/src/core/schemas/text-asset.ts b/src/core/schemas/text-asset.ts index 299ddb14..d0ae973f 100644 --- a/src/core/schemas/text-asset.ts +++ b/src/core/schemas/text-asset.ts @@ -31,8 +31,8 @@ export const TextAssetBackgroundSchema = zod export const TextAssetStrokeSchema = zod .object({ - width: zod.number().positive().optional(), - color: TextAssetColorSchema + width: zod.number().min(0).optional(), + color: TextAssetColorSchema.optional() }) .strict(); From 11274c6e3f5cda3692be2f0bf61d7c5abed924f4 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 16:12:53 +1100 Subject: [PATCH 028/463] feat: add dynamic luma mask regeneration for video sources --- src/components/canvas/players/luma-player.ts | 4 ++ src/core/edit.ts | 72 +++++++++++++------- 2 files changed, 51 insertions(+), 25 deletions(-) diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index ae4556b4..b2384701 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -110,4 +110,8 @@ export class LumaPlayer extends Player { public getSprite(): pixi.Sprite | null { return this.sprite; } + + public isVideoSource(): boolean { + return this.texture?.source instanceof pixi.VideoSource; + } } diff --git a/src/core/edit.ts b/src/core/edit.ts index 68008bd0..c79c3838 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -74,7 +74,11 @@ export class Edit extends Entity { private endLengthClips: Set = new Set(); private canvas: Canvas | null = null; - private lumaMaskTextures: pixi.Texture[] = []; + private activeLumaMasks: Array<{ + lumaPlayer: LumaPlayer; + maskSprite: pixi.Sprite; + tempContainer: pixi.Container; + }> = []; constructor(size: Size, backgroundColor: string = "#ffffff") { super(); @@ -134,6 +138,9 @@ export class Edit extends Entity { this.disposeClips(); + // Update luma masks for video sources (regenerate mask texture each frame) + this.updateLumaMasks(); + if (this.isPlaying) { this.playbackTime = Math.max(0, Math.min(this.playbackTime + elapsed, this.totalDuration)); @@ -152,10 +159,11 @@ export class Edit extends Entity { public override dispose(): void { this.clearClips(); - for (const texture of this.lumaMaskTextures) { - texture.destroy(true); + for (const mask of this.activeLumaMasks) { + mask.tempContainer.destroy({ children: true }); + mask.maskSprite.texture.destroy(true); } - this.lumaMaskTextures = []; + this.activeLumaMasks = []; if (this.viewportMask) { try { @@ -772,10 +780,14 @@ export class Edit extends Entity { this.updateTotalDuration(); } + /** + * Luma mattes use grayscale video to mask content clips. + * PixiJS masks are inverted vs backend convention (white=visible, not transparent), + * so we bake a negative filter into the mask texture via generateTexture(). + * For video luma sources, we regenerate the mask texture each frame. + */ private finalizeLumaMasking(): void { - if (!this.canvas) { - return; - } + if (!this.canvas) return; for (const trackClips of this.tracks) { const lumaPlayer = trackClips.find(clip => clip instanceof LumaPlayer) as LumaPlayer | undefined; @@ -783,35 +795,45 @@ export class Edit extends Entity { const contentClips = trackClips.filter(clip => !(clip instanceof LumaPlayer)); if (lumaPlayer && lumaSprite?.texture && contentClips.length > 0) { - this.applyLumaMasksToClips(lumaSprite.texture, contentClips); + this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); } } } - private applyLumaMasksToClips(lumaTexture: pixi.Texture, contentClips: Player[]): void { + private setupLumaMask(lumaPlayer: LumaPlayer, lumaTexture: pixi.Texture, contentClip: Player): void { const { renderer } = this.canvas!.application; + const { width, height } = contentClip.getSize(); - for (const clip of contentClips) { - const size = clip.getSize(); + const tempContainer = new pixi.Container(); + const tempSprite = new pixi.Sprite(lumaTexture); + tempSprite.width = width; + tempSprite.height = height; - const tempContainer = new pixi.Container(); - const tempSprite = new pixi.Sprite(lumaTexture); - tempSprite.width = size.width; - tempSprite.height = size.height; + const invertFilter = new pixi.ColorMatrixFilter(); + invertFilter.negative(false); + tempSprite.filters = [invertFilter]; + tempContainer.addChild(tempSprite); - const invertFilter = new pixi.ColorMatrixFilter(); - invertFilter.negative(false); - tempSprite.filters = [invertFilter]; - tempContainer.addChild(tempSprite); + const maskTexture = renderer.generateTexture(tempContainer); + const maskSprite = new pixi.Sprite(maskTexture); + contentClip.getContainer().addChild(maskSprite); + contentClip.getContentContainer().setMask({ mask: maskSprite }); - const maskTexture = renderer.generateTexture(tempContainer); - this.lumaMaskTextures.push(maskTexture); - tempContainer.destroy({ children: true }); + this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer }); + } - const maskSprite = new pixi.Sprite(maskTexture); - clip.getContainer().addChild(maskSprite); - clip.getContentContainer().setMask({ mask: maskSprite }); + private updateLumaMasks(): void { + if (!this.canvas) return; + const { renderer } = this.canvas.application; + + for (const mask of this.activeLumaMasks) { + if (mask.lumaPlayer.isVideoSource()) { + const oldTexture = mask.maskSprite.texture; + mask.maskSprite.texture = renderer.generateTexture(mask.tempContainer); + + oldTexture.destroy(true); + } } } From 1e1e937697d1ca0852288451e1077312bf5b964e Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 16:30:51 +1100 Subject: [PATCH 029/463] feat: add soundtrack support to timeline with audio player integration --- src/core/edit.ts | 21 +++++++++++++++++++++ src/core/schemas/edit.ts | 13 ++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/core/edit.ts b/src/core/edit.ts index c79c3838..ac590daf 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -257,8 +257,29 @@ export class Edit extends Entity { this.updateTotalDuration(); + if (this.edit.timeline.soundtrack) { + await this.loadSoundtrack(this.edit.timeline.soundtrack); + } + this.events.emit("timeline:updated", { current: this.getResolvedEdit() }); } + + private async loadSoundtrack(soundtrack: { src: string; effect?: string; volume?: number }): Promise { + const clip = ClipSchema.parse({ + asset: { + type: "audio", + src: soundtrack.src, + effect: soundtrack.effect, + volume: soundtrack.volume ?? 1 + }, + start: 0, + length: this.totalDuration / 1000 + }); + + const player = new AudioPlayer(this, clip); + player.layer = this.tracks.length + 1; + await this.addPlayer(this.tracks.length, player); + } public getEdit(): EditType { return this.buildEditSnapshot(player => player.getTimingIntent()); } diff --git a/src/core/schemas/edit.ts b/src/core/schemas/edit.ts index 6f7db8e1..470b603c 100644 --- a/src/core/schemas/edit.ts +++ b/src/core/schemas/edit.ts @@ -10,11 +10,22 @@ export const FontSourceSchema = zod }) .strict(); +export const SoundtrackEffectSchema = zod.enum(["fadeIn", "fadeOut", "fadeInFadeOut"]); + +export const SoundtrackSchema = zod + .object({ + src: zod.string().url(), + effect: SoundtrackEffectSchema.optional(), + volume: zod.number().min(0).max(1).optional() + }) + .strict(); + export const TimelineSchema = zod .object({ background: zod.string().optional(), fonts: FontSourceSchema.array().optional(), - tracks: TrackSchema.array() + tracks: TrackSchema.array(), + soundtrack: SoundtrackSchema.optional() }) .strict(); From 15201692cad26da8cb931ffc87729b6de8d99608 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 16:46:58 +1100 Subject: [PATCH 030/463] fix: simplify crop scaling logic to use max ratio consistently --- src/components/canvas/players/player.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index a4efbd27..6402a833 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -497,12 +497,7 @@ export abstract class Player extends Entity { const contentSize = this.getContentSize(); switch (this.clipConfiguration.fit ?? "crop") { - case "crop": { - const ratioX = targetWidth / contentSize.width; - const ratioY = targetHeight / contentSize.height; - const isPortrait = targetHeight >= targetWidth; - return isPortrait ? ratioY : ratioX; - } + case "crop": case "cover": return Math.max(targetWidth / contentSize.width, targetHeight / contentSize.height); case "contain": @@ -547,8 +542,7 @@ export abstract class Player extends Entity { return { x: uniform, y: uniform }; } case "crop": { - const isPortrait = targetHeight >= targetWidth; - const uniform = (isPortrait ? ratioY : ratioX) * baseScale; + const uniform = Math.max(ratioX, ratioY) * baseScale; return { x: uniform, y: uniform }; } case "cover": { From 7899917e3827d31263a479b1b12d7fc36342cc25 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 19:07:28 +1100 Subject: [PATCH 031/463] fix: correct alpha channel rendering for WebM VP9 videos in PixiJS 8 --- src/components/canvas/players/luma-player.ts | 6 ++++++ src/components/canvas/players/video-player.ts | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index b2384701..36b18977 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -35,6 +35,12 @@ export class LumaPlayer extends Player { throw new Error(`Invalid luma source '${lumaAsset.src}'.`); } + // Fix alpha channel rendering for WebM VP9 videos + // PixiJS 8's auto-detection is buggy, causing invisible rendering + if (texture.source instanceof pixi.VideoSource) { + texture.source.alphaMode = "no-premultiply-alpha"; + } + this.texture = texture; this.sprite = new pixi.Sprite(this.texture); diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 25bdc27f..7fb2bb6b 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -55,6 +55,10 @@ export class VideoPlayer extends Player { throw new Error(`Invalid video source '${videoAsset.src}'.`); } + // Fix alpha channel rendering for WebM VP9 videos + // PixiJS 8's auto-detection is buggy, causing invisible rendering + texture.source.alphaMode = "no-premultiply-alpha"; + this.texture = this.createCroppedTexture(texture); this.sprite = new pixi.Sprite(this.texture); From abc2ba0592d21b79ca9af31b6ac9bdd437462165 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 5 Dec 2025 19:17:14 +1100 Subject: [PATCH 032/463] refactor: extract filler keyframe calculation into variables --- src/core/animations/keyframe-builder.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index 7c856b6e..a48b4773 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -111,7 +111,9 @@ export class KeyframeBuilder { const shouldFillMiddle = current.start + current.length !== next.start; if (shouldFillMiddle) { - const fillerKeyframe: Keyframe = { start: current.start + current.length, length: next.start, from: current.to, to: next.from }; + const fillerStart = current.start + current.length; + const fillerLength = next.start - fillerStart; + const fillerKeyframe: Keyframe = { start: fillerStart, length: fillerLength, from: current.to, to: next.from }; updatedKeyframes.push(fillerKeyframe); } } From 96b352edc502c17089d7e143ed653831edeb63cc Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sat, 6 Dec 2025 21:27:06 +1100 Subject: [PATCH 033/463] feat: add wipe/reveal transition mask animations --- src/components/canvas/players/player.ts | 47 +++++++++++++++++++ .../animations/transition-preset-builder.ts | 44 +++++++++++++---- 2 files changed, 82 insertions(+), 9 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 6402a833..2c0c7442 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -104,6 +104,9 @@ export abstract class Player extends Entity { private scaleKeyframeBuilder?: KeyframeBuilder; private opacityKeyframeBuilder?: KeyframeBuilder; private rotationKeyframeBuilder?: KeyframeBuilder; + private maskXKeyframeBuilder?: KeyframeBuilder; + + private wipeMask: pixi.Graphics | null; private outline: pixi.Graphics | null; private topLeftScaleHandle: pixi.Graphics | null; @@ -148,6 +151,8 @@ export abstract class Player extends Entity { const lengthValue = typeof clipConfiguration.length === "number" ? clipConfiguration.length * 1000 : 3000; this.resolvedTiming = { start: startValue, length: lengthValue }; + this.wipeMask = null; + this.outline = null; this.topLeftScaleHandle = null; this.topRightScaleHandle = null; @@ -196,6 +201,7 @@ export abstract class Player extends Entity { const opacityKeyframes: Keyframe[] = []; const scaleKeyframes: Keyframe[] = []; const rotationKeyframes: Keyframe[] = []; + const maskXKeyframes: Keyframe[] = []; const resolvedClipConfig: ResolvedClipConfig = { ...this.clipConfiguration, @@ -216,6 +222,7 @@ export abstract class Player extends Entity { opacityKeyframes.push(...transitionKeyframeSet.opacityKeyframes); scaleKeyframes.push(...transitionKeyframeSet.scaleKeyframes); rotationKeyframes.push(...transitionKeyframeSet.rotationKeyframes); + maskXKeyframes.push(...transitionKeyframeSet.maskXKeyframes); if (offsetXKeyframes.length) { const offsetX = this.clipConfiguration.offset?.x; @@ -240,6 +247,10 @@ export abstract class Player extends Entity { if (rotationKeyframes.length) { this.rotationKeyframeBuilder = new KeyframeBuilder(rotationKeyframes, this.getLength()); } + + if (maskXKeyframes.length) { + this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, this.getLength()); + } } public override async load(): Promise { @@ -318,11 +329,44 @@ export abstract class Player extends Entity { this.applyFixedDimensions(); } + // Update wipe/reveal mask animation + this.updateWipeMask(); + if (this.shouldDiscardFrame()) { this.contentContainer.alpha = 0; } } + private updateWipeMask(): void { + if (!this.maskXKeyframeBuilder) { + // No wipe transition, ensure mask is removed + if (this.wipeMask) { + this.getContainer().mask = null; + this.wipeMask.destroy(); + this.wipeMask = null; + } + return; + } + + const maskProgress = this.maskXKeyframeBuilder.getValue(this.getPlaybackTime()); + const size = this.getSize(); + + // Create mask if it doesn't exist + // Apply to main container (not contentContainer) to avoid conflict with fixed dimensions mask + if (!this.wipeMask) { + this.wipeMask = new pixi.Graphics(); + this.getContainer().addChild(this.wipeMask); + this.getContainer().mask = this.wipeMask; + } + + // Update mask to create wipe effect + // maskProgress 0 → 1 reveals content from left to right + // maskProgress 1 → 0 hides content from right to left + this.wipeMask.clear(); + this.wipeMask.rect(0, 0, size.width * maskProgress, size.height); + this.wipeMask.fill(0xffffff); + } + public override draw(): void { if (!this.outline) { return; @@ -405,6 +449,9 @@ export abstract class Player extends Entity { this.bottomRightScaleHandle?.destroy(); this.bottomRightScaleHandle = null; + this.wipeMask?.destroy(); + this.wipeMask = null; + this.contentContainer?.destroy(); } diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index e2dc39c6..e86e2e25 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -7,6 +7,7 @@ export type TransitionKeyframeSet = { opacityKeyframes: Keyframe[]; scaleKeyframes: Keyframe[]; rotationKeyframes: Keyframe[]; + maskXKeyframes: Keyframe[]; }; export class TransitionPresetBuilder { @@ -22,6 +23,7 @@ export class TransitionPresetBuilder { const opacityKeyframes: Keyframe[] = []; const scaleKeyframes: Keyframe[] = []; const rotationKeyframes: Keyframe[] = []; + const maskXKeyframes: Keyframe[] = []; const inPresetKeyframeSet = this.buildInPreset(); offsetXKeyframes.push(...inPresetKeyframeSet.offsetXKeyframes); @@ -29,6 +31,7 @@ export class TransitionPresetBuilder { opacityKeyframes.push(...inPresetKeyframeSet.opacityKeyframes); scaleKeyframes.push(...inPresetKeyframeSet.scaleKeyframes); rotationKeyframes.push(...inPresetKeyframeSet.rotationKeyframes); + maskXKeyframes.push(...inPresetKeyframeSet.maskXKeyframes); const outPresetKeyframeSet = this.buildOutPreset(); @@ -37,8 +40,9 @@ export class TransitionPresetBuilder { opacityKeyframes.push(...outPresetKeyframeSet.opacityKeyframes); scaleKeyframes.push(...outPresetKeyframeSet.scaleKeyframes); rotationKeyframes.push(...outPresetKeyframeSet.rotationKeyframes); + maskXKeyframes.push(...outPresetKeyframeSet.maskXKeyframes); - return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes }; + return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes, maskXKeyframes }; } private buildInPreset(): TransitionKeyframeSet { @@ -47,9 +51,10 @@ export class TransitionPresetBuilder { const opacityKeyframes: Keyframe[] = []; const scaleKeyframes: Keyframe[] = []; const rotationKeyframes: Keyframe[] = []; + const maskXKeyframes: Keyframe[] = []; if (!this.clipConfiguration.transition?.in) { - return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes }; + return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes, maskXKeyframes }; } const start = 0; @@ -167,6 +172,17 @@ export class TransitionPresetBuilder { break; } + case "reveal": + case "wipeRight": { + // Wipe/reveal left to right - mask progress 0 → 1 + maskXKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); + break; + } + case "wipeLeft": { + // Wipe from right to left - mask progress 1 → 0 + maskXKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); + break; + } case "shuffleTopRight": case "shuffleRightTop": case "shuffleRightBottom": @@ -180,7 +196,7 @@ export class TransitionPresetBuilder { break; } - return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes }; + return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes, maskXKeyframes }; } private buildOutPreset(): TransitionKeyframeSet { @@ -189,9 +205,10 @@ export class TransitionPresetBuilder { const opacityKeyframes: Keyframe[] = []; const scaleKeyframes: Keyframe[] = []; const rotationKeyframes: Keyframe[] = []; + const maskXKeyframes: Keyframe[] = []; if (!this.clipConfiguration.transition?.out) { - return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes }; + return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes, maskXKeyframes }; } const length = this.getOutPresetLength(); @@ -309,6 +326,17 @@ export class TransitionPresetBuilder { break; } + case "reveal": + case "wipeRight": { + // Wipe/reveal out left to right - mask progress 1 → 0 + maskXKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); + break; + } + case "wipeLeft": { + // Wipe out right to left - mask progress 0 → 1 + maskXKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); + break; + } case "shuffleTopRight": case "shuffleRightTop": case "shuffleRightBottom": @@ -322,7 +350,7 @@ export class TransitionPresetBuilder { break; } - return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes }; + return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes, maskXKeyframes }; } private getInPresetName(): string { @@ -340,9 +368,8 @@ export class TransitionPresetBuilder { const [transitionName, transitionSpeed] = transitionIn.split(/(Slow|Fast|VeryFast)/); const isCarousel = transitionName.startsWith("carousel"); const isSlide = transitionName.startsWith("slide"); - const isZoom = transitionName === "zoom"; - if (isZoom) return 0.4; + if (transitionName === "zoom") return 0.4; switch (transitionSpeed) { case "Slow": @@ -361,9 +388,8 @@ export class TransitionPresetBuilder { const [transitionName, transitionSpeed] = transitionOut.split(/(Slow|Fast|VeryFast)/); const isCarousel = transitionName.startsWith("carousel"); const isSlide = transitionName.startsWith("slide"); - const isZoom = transitionName === "zoom"; - if (isZoom) return 0.4; + if (transitionName === "zoom") return 0.4; switch (transitionSpeed) { case "Slow": From 2ce4e9e126a97ad09dba9e8d460f531299a8d510 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 06:13:47 +1100 Subject: [PATCH 034/463] feat: add loading overlay to edit asset loading --- src/core/edit.ts | 93 +++++++++++++++++++--------------- src/core/ui/loading-overlay.ts | 38 ++++++++++++++ 2 files changed, 89 insertions(+), 42 deletions(-) create mode 100644 src/core/ui/loading-overlay.ts diff --git a/src/core/edit.ts b/src/core/edit.ts index ac590daf..5d176b3b 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -22,6 +22,7 @@ import { applyMergeFields } from "@core/merge/merge-fields"; import { Entity } from "@core/shared/entity"; import { deepMerge } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; +import { LoadingOverlay } from "@core/ui/loading-overlay"; import type { Size } from "@layouts/geometry"; import { AssetLoader } from "@loaders/asset-loader"; import { FontLoadParser } from "@loaders/font-load-parser"; @@ -206,62 +207,70 @@ export class Edit extends Entity { } public async loadEdit(edit: EditType): Promise { - this.clearClips(); + const loading = new LoadingOverlay(); + loading.show(); - // Apply merge fields transparently (if present) - const mergeFields = edit.merge ?? []; - const mergedEdit = mergeFields.length > 0 ? applyMergeFields(edit, mergeFields) : edit; + const onProgress = () => loading.update(this.assetLoader.getProgress()); + this.assetLoader.loadTracker.on("onAssetLoadInfoUpdated", onProgress); - // Note: We no longer resolve smart-clips here - timing intent is preserved - // and resolved after all clips are loaded - this.edit = EditSchema.parse(mergedEdit); + try { + this.clearClips(); - const newSize = this.edit.output?.size; - if (newSize && (newSize.width !== this.size.width || newSize.height !== this.size.height)) { - this.size = newSize; - this.updateViewportMask(); - this.canvas?.zoomToFit(); - } + const mergeFields = edit.merge ?? []; + const mergedEdit = mergeFields.length > 0 ? applyMergeFields(edit, mergeFields) : edit; - this.backgroundColor = this.edit.timeline.background || "#000000"; + this.edit = EditSchema.parse(mergedEdit); - if (this.background) { - this.background.clear(); - this.background.fillStyle = { - color: this.backgroundColor - }; - this.background.rect(0, 0, this.size.width, this.size.height); - this.background.fill(); - } + const newSize = this.edit.output?.size; + if (newSize && (newSize.width !== this.size.width || newSize.height !== this.size.height)) { + this.size = newSize; + this.updateViewportMask(); + this.canvas?.zoomToFit(); + } + + this.backgroundColor = this.edit.timeline.background || "#000000"; - await Promise.all( - (this.edit.timeline.fonts ?? []).map(async font => { - const identifier = font.src; - const loadOptions: pixi.UnresolvedAsset = { src: identifier, loadParser: FontLoadParser.Name }; + if (this.background) { + this.background.clear(); + this.background.fillStyle = { + color: this.backgroundColor + }; + this.background.rect(0, 0, this.size.width, this.size.height); + this.background.fill(); + } - return this.assetLoader.load(identifier, loadOptions); - }) - ); + await Promise.all( + (this.edit.timeline.fonts ?? []).map(async font => { + const identifier = font.src; + const loadOptions: pixi.UnresolvedAsset = { src: identifier, loadParser: FontLoadParser.Name }; - for (const [trackIdx, track] of this.edit.timeline.tracks.entries()) { - for (const clip of track.clips) { - const clipPlayer = this.createPlayerFromAssetType(clip); - clipPlayer.layer = trackIdx + 1; - await this.addPlayer(trackIdx, clipPlayer); + return this.assetLoader.load(identifier, loadOptions); + }) + ); + + for (const [trackIdx, track] of this.edit.timeline.tracks.entries()) { + for (const clip of track.clips) { + const clipPlayer = this.createPlayerFromAssetType(clip); + clipPlayer.layer = trackIdx + 1; + await this.addPlayer(trackIdx, clipPlayer); + } } - } - this.finalizeLumaMasking(); + this.finalizeLumaMasking(); - await this.resolveAllTiming(); + await this.resolveAllTiming(); - this.updateTotalDuration(); + this.updateTotalDuration(); - if (this.edit.timeline.soundtrack) { - await this.loadSoundtrack(this.edit.timeline.soundtrack); - } + if (this.edit.timeline.soundtrack) { + await this.loadSoundtrack(this.edit.timeline.soundtrack); + } - this.events.emit("timeline:updated", { current: this.getResolvedEdit() }); + this.events.emit("timeline:updated", { current: this.getResolvedEdit() }); + } finally { + this.assetLoader.loadTracker.off("onAssetLoadInfoUpdated", onProgress); + loading.hide(); + } } private async loadSoundtrack(soundtrack: { src: string; effect?: string; volume?: number }): Promise { diff --git a/src/core/ui/loading-overlay.ts b/src/core/ui/loading-overlay.ts new file mode 100644 index 00000000..c130fa91 --- /dev/null +++ b/src/core/ui/loading-overlay.ts @@ -0,0 +1,38 @@ +export class LoadingOverlay { + private overlay: HTMLElement | null = null; + private bar: HTMLElement | null = null; + private pct: HTMLElement | null = null; + + show(): void { + this.overlay = document.createElement("div"); + this.overlay.style.cssText = + "position:fixed;inset:0;z-index:9999;background:#0a0a0a;display:flex;justify-content:center;align-items:center"; + this.overlay.innerHTML = ` +

+ `; + this.bar = this.overlay.querySelector("#bar") as HTMLElement; + this.pct = this.overlay.querySelector("#pct") as HTMLElement; + document.body.appendChild(this.overlay); + } + + update(progress: number): void { + const percent = Math.round(progress * 100); + if (this.bar) this.bar.style.width = `${percent}%`; + if (this.pct) this.pct.textContent = `${percent}%`; + } + + hide(): void { + this.overlay?.remove(); + this.overlay = null; + this.bar = null; + this.pct = null; + } +} From 979fbb7666af33a0eeb8d7beebe6806599924242 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 06:24:19 +1100 Subject: [PATCH 035/463] fix: handle missing background color in text player --- src/components/canvas/players/text-player.ts | 2 +- src/core/schemas/text-asset.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 390a0e93..9bbf97c6 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -153,7 +153,7 @@ export class TextPlayer extends Player { private drawBackground(): void { const textAsset = this.clipConfiguration.asset as TextAsset; - if (!this.background || !textAsset.background) return; + if (!this.background || !textAsset.background || !textAsset.background.color) return; const { width, height } = this.getSize(); this.background.clear(); diff --git a/src/core/schemas/text-asset.ts b/src/core/schemas/text-asset.ts index d0ae973f..37dc0234 100644 --- a/src/core/schemas/text-asset.ts +++ b/src/core/schemas/text-asset.ts @@ -22,7 +22,7 @@ export const TextAssetAlignmentSchema = zod export const TextAssetBackgroundSchema = zod .object({ - color: TextAssetColorSchema, + color: TextAssetColorSchema.optional(), opacity: zod.number().min(0).max(1).default(1), padding: zod.number().min(0).max(100).optional(), borderRadius: zod.number().min(0).optional() From 70123de0abff82a285b718b9871ed2462addb845 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 06:41:08 +1100 Subject: [PATCH 036/463] fix: make merge field replacement case-insensitive --- src/core/merge/merge-fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/merge/merge-fields.ts b/src/core/merge/merge-fields.ts index 9e510c48..57809556 100644 --- a/src/core/merge/merge-fields.ts +++ b/src/core/merge/merge-fields.ts @@ -20,7 +20,7 @@ function replaceMergeFieldsRecursive(obj: T, fields: MergeField[]): T { if (typeof obj === "string") { let result: string = obj; for (const { find, replace } of fields) { - result = result.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(find)}\\s*\\}\\}`, "g"), replace); + result = result.replace(new RegExp(`\\{\\{\\s*${escapeRegExp(find)}\\s*\\}\\}`, "gi"), replace); } return result as unknown as T; } From 10b6d848068be0f2bccbe8ca45e9b4ee4d0f3a43 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 14:38:04 +1100 Subject: [PATCH 037/463] refactor: use destructuring assignment for config properties --- src/components/timeline/interaction/collision-detector.ts | 2 +- src/components/timeline/interaction/snap-manager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/timeline/interaction/collision-detector.ts b/src/components/timeline/interaction/collision-detector.ts index cbeeae0f..b74347f7 100644 --- a/src/components/timeline/interaction/collision-detector.ts +++ b/src/components/timeline/interaction/collision-detector.ts @@ -68,7 +68,7 @@ export class CollisionDetector { .map(({ clip }) => { const config = clip.getClipConfig(); if (!config) return null; - const start = config.start; + const { start } = config; return { start, end: start + config.length diff --git a/src/components/timeline/interaction/snap-manager.ts b/src/components/timeline/interaction/snap-manager.ts index f73987d8..933c5b37 100644 --- a/src/components/timeline/interaction/snap-manager.ts +++ b/src/components/timeline/interaction/snap-manager.ts @@ -22,7 +22,7 @@ export class SnapManager { const clipConfig = clip.getClipConfig(); if (clipConfig) { - const start = clipConfig.start; + const { start } = clipConfig; snapPoints.push({ time: start, type: "clip-start", From 36cf884a2c26ba2ad6e92e7c745ce518d7880810 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 14:38:12 +1100 Subject: [PATCH 038/463] refactor: extract RichTextStrokeSchema to improve reusability --- src/core/schemas/rich-text-asset.ts | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/src/core/schemas/rich-text-asset.ts b/src/core/schemas/rich-text-asset.ts index 73bcc084..d0c5a746 100644 --- a/src/core/schemas/rich-text-asset.ts +++ b/src/core/schemas/rich-text-asset.ts @@ -17,6 +17,14 @@ const GradientSchema = zod }) .strict(); +const RichTextStrokeSchema = zod + .object({ + width: zod.number().min(0).default(0), + color: HexColorSchema.default("#000000"), + opacity: zod.number().min(0).max(1).default(1) + }) + .strict(); + const RichTextFontSchema = zod .object({ family: zod.string().default("Roboto"), @@ -25,14 +33,7 @@ const RichTextFontSchema = zod color: HexColorSchema.default("#000000"), opacity: zod.number().min(0).max(1).default(1), background: HexColorSchema.optional(), - stroke: zod - .object({ - width: zod.number().min(0).default(0), - color: HexColorSchema.default("#000000"), - opacity: zod.number().min(0).max(1).default(1) - }) - .strict() - .optional() + stroke: RichTextStrokeSchema.optional() }) .strict(); @@ -46,14 +47,6 @@ const RichTextStyleSchema = zod }) .strict(); -const RichTextStrokeSchema = zod - .object({ - width: zod.number().min(0).default(0), - color: HexColorSchema.default("#000000"), - opacity: zod.number().min(0).max(1).default(1) - }) - .strict(); - const RichTextShadowSchema = zod .object({ offsetX: zod.number().default(0), From 13f561d4b231a7c7c4c324ce2997853efdb33386 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 14:42:13 +1100 Subject: [PATCH 039/463] feat: simplify fit configuration logic in RichTextPlayer constructor --- src/components/canvas/players/rich-text-player.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index bf59376d..3517fae4 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -31,10 +31,8 @@ export class RichTextPlayer extends Player { constructor(edit: any, clipConfiguration: any) { // Default fit to "cover" for rich-text assets if not provided - if (!clipConfiguration.fit) { - clipConfiguration.fit = "cover"; - } - super(edit, clipConfiguration); + const config = clipConfiguration.fit ? clipConfiguration : { ...clipConfiguration, fit: "cover" }; + super(edit, config); } private buildCanvasPayload( From 0bf8fe5b4d62b7878fe79e38f8857084b0e299f8 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 15:11:18 +1100 Subject: [PATCH 040/463] feat: add alias reference resolution system with circular dependency detection --- src/core/alias/alias.ts | 165 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 src/core/alias/alias.ts diff --git a/src/core/alias/alias.ts b/src/core/alias/alias.ts new file mode 100644 index 00000000..578d8a91 --- /dev/null +++ b/src/core/alias/alias.ts @@ -0,0 +1,165 @@ +import type { Clip } from "@schemas/clip"; +import type { Edit } from "@schemas/edit"; + +const ALIAS_REFERENCE_REGEX = /^alias:\/\/([a-zA-Z0-9_-]+)$/; + +function forEachClip(edit: Edit, fn: (clip: Clip, trackIdx: number, clipIdx: number) => void): void { + for (let t = 0; t < edit.timeline.tracks.length; t += 1) { + const track = edit.timeline.tracks[t]; + for (let c = 0; c < track.clips.length; c += 1) { + fn(track.clips[c], t, c); + } + } +} + +function parseAliasReference(value: unknown): string | null { + if (typeof value !== "string") return null; + const match = value.match(ALIAS_REFERENCE_REGEX); + return match ? match[1] : null; +} + +function extractClipAliases(edit: Edit): Record { + const aliases: Record = {}; + + forEachClip(edit, (clip, trackIdx, clipIdx) => { + if (clip.alias) { + if (aliases[clip.alias]) { + console.warn(`Duplicate alias "${clip.alias}" at track ${trackIdx}, clip ${clipIdx} - overwriting previous`); + } + aliases[clip.alias] = clip; + } + }); + + return aliases; +} + +function buildAliasDependencyGraph(edit: Edit): Map> { + const dependencies = new Map>(); + + forEachClip(edit, (clip, trackIdx, clipIdx) => { + const clipId = clip.alias ?? `t${trackIdx}c${clipIdx}`; + const deps = new Set(); + + const startAlias = parseAliasReference(clip.start); + if (startAlias) deps.add(startAlias); + + const lengthAlias = parseAliasReference(clip.length); + if (lengthAlias) deps.add(lengthAlias); + + if (deps.size > 0) { + dependencies.set(clipId, deps); + } + }); + + return dependencies; +} + +function detectCircularReferences(dependencies: Map>): string[] | null { + const visited = new Set(); + const recursionStack = new Set(); + + function findCycle(node: string, path: string[]): string[] | null { + visited.add(node); + recursionStack.add(node); + + const deps = dependencies.get(node); + if (deps) { + for (const dep of deps) { + if (recursionStack.has(dep)) { + return [...path, dep]; + } + if (!visited.has(dep)) { + const cycle = findCycle(dep, [...path, dep]); + if (cycle) return cycle; + } + } + } + + recursionStack.delete(node); + return null; + } + + for (const node of dependencies.keys()) { + if (!visited.has(node)) { + const cycle = findCycle(node, [node]); + if (cycle) return cycle; + } + } + + return null; +} + +function topologicalSort(dependencies: Map>, aliases: Record): string[] { + const result: string[] = []; + const visited = new Set(); + const allClipIds = new Set([...dependencies.keys(), ...Object.keys(aliases)]); + + function visit(node: string): void { + if (visited.has(node)) return; + visited.add(node); + + const deps = dependencies.get(node); + if (deps) { + for (const dep of deps) { + visit(dep); + } + } + + result.push(node); + } + + for (const node of allClipIds) { + visit(node); + } + + return result; +} + +export function resolveAliasReferences(edit: Edit): void { + const dependencies = buildAliasDependencyGraph(edit); + if (dependencies.size === 0) return; + + const cycle = detectCircularReferences(dependencies); + if (cycle) { + throw new Error(`Circular alias reference detected: ${cycle.join(" -> ")}`); + } + + const aliases = extractClipAliases(edit); + const clipMap = new Map(); + + forEachClip(edit, (clip, trackIdx, clipIdx) => { + const clipId = clip.alias ?? `t${trackIdx}c${clipIdx}`; + clipMap.set(clipId, clip); + }); + + const resolveOrder = topologicalSort(dependencies, aliases); + + for (const clipId of resolveOrder) { + const clip = clipMap.get(clipId); + if (clip) { + const startAliasName = parseAliasReference(clip.start); + if (startAliasName) { + const targetClip = aliases[startAliasName]; + if (!targetClip) { + throw new Error(`Alias "${startAliasName}" not found. Available: ${Object.keys(aliases).join(", ") || "none"}`); + } + if (typeof targetClip.start !== "number") { + throw new Error(`Cannot resolve alias "${startAliasName}": target has unresolved start`); + } + (clip as { start: number }).start = targetClip.start; + } + + const lengthAliasName = parseAliasReference(clip.length); + if (lengthAliasName) { + const targetClip = aliases[lengthAliasName]; + if (!targetClip) { + throw new Error(`Alias "${lengthAliasName}" not found. Available: ${Object.keys(aliases).join(", ") || "none"}`); + } + if (typeof targetClip.length !== "number") { + throw new Error(`Cannot resolve alias "${lengthAliasName}": target has unresolved length`); + } + (clip as { length: number }).length = targetClip.length; + } + } + } +} From 720436293188a5146ecc9c999299021ca9ae51a8 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 15:13:57 +1100 Subject: [PATCH 041/463] refactor: rename ResolvedClipConfig to ResolvedClip throughout codebase --- src/components/canvas/players/audio-player.ts | 4 +- src/components/canvas/players/html-player.ts | 4 +- src/components/canvas/players/image-player.ts | 4 +- src/components/canvas/players/luma-player.ts | 4 +- src/components/canvas/players/player.ts | 10 +-- src/components/canvas/players/shape-player.ts | 4 +- src/components/canvas/players/text-player.ts | 4 +- src/components/canvas/players/video-player.ts | 4 +- src/components/canvas/text/text-editor.ts | 10 +-- src/components/timeline/interaction/types.ts | 6 +- .../timeline/managers/drag-preview-manager.ts | 6 +- src/components/timeline/timeline.ts | 6 +- src/components/timeline/types/timeline.ts | 6 +- src/components/timeline/visual/visual-clip.ts | 10 +-- .../timeline/visual/visual-track.ts | 6 +- src/core/alias/index.ts | 1 + src/core/animations/effect-preset-builder.ts | 6 +- .../animations/transition-preset-builder.ts | 6 +- src/core/commands/add-clip-command.ts | 8 +- src/core/commands/delete-track-command.ts | 5 +- src/core/commands/move-clip-command.ts | 2 +- src/core/commands/resize-clip-command.ts | 2 +- src/core/commands/set-updated-clip-command.ts | 5 +- src/core/commands/split-clip-command.ts | 8 +- src/core/commands/types.ts | 9 +-- .../commands/update-text-content-command.ts | 5 +- src/core/edit.ts | 81 ++++++++++--------- src/core/schemas/clip.ts | 14 ++-- src/core/schemas/edit.ts | 12 ++- src/core/schemas/index.ts | 3 +- src/core/schemas/track.ts | 8 +- src/core/timing/resolver.ts | 3 - 32 files changed, 139 insertions(+), 127 deletions(-) create mode 100644 src/core/alias/index.ts diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index dcb3d141..06c922bb 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -3,7 +3,7 @@ import type { Edit } from "@core/edit"; import { type Size } from "@layouts/geometry"; import { AudioLoadParser } from "@loaders/audio-load-parser"; import { type AudioAsset } from "@schemas/audio-asset"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type Keyframe } from "@schemas/keyframe"; import * as howler from "howler"; import * as pixi from "pixi.js"; @@ -18,7 +18,7 @@ export class AudioPlayer extends Player { private syncTimer: number; - constructor(edit: Edit, clipConfiguration: Clip) { + constructor(edit: Edit, clipConfiguration: ResolvedClip) { super(edit, clipConfiguration); this.audioResource = null; diff --git a/src/components/canvas/players/html-player.ts b/src/components/canvas/players/html-player.ts index cb527790..8e6b8247 100644 --- a/src/components/canvas/players/html-player.ts +++ b/src/components/canvas/players/html-player.ts @@ -1,5 +1,5 @@ import { type Size } from "@layouts/geometry"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type HtmlAsset, HtmlAssetPosition } from "@schemas/html-asset"; import type { Edit } from "core/edit"; import * as pixiFilters from "pixi-filters"; @@ -45,7 +45,7 @@ export class HtmlPlayer extends Player { private background: pixi.Graphics | null; private text: pixi.Text | null; - constructor(timeline: Edit, clipConfiguration: Clip) { + constructor(timeline: Edit, clipConfiguration: ResolvedClip) { super(timeline, clipConfiguration); this.background = null; diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index 9c31f9b3..25e03eea 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -1,6 +1,6 @@ import type { Edit } from "@core/edit"; import { type Size } from "@layouts/geometry"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type ImageAsset } from "@schemas/image-asset"; import * as pixi from "pixi.js"; @@ -11,7 +11,7 @@ export class ImagePlayer extends Player { private sprite: pixi.Sprite | null; private originalSize: Size | null; - constructor(timeline: Edit, clipConfiguration: Clip) { + constructor(timeline: Edit, clipConfiguration: ResolvedClip) { super(timeline, clipConfiguration); this.texture = null; diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index 36b18977..7d94a2b8 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -1,6 +1,6 @@ import type { Edit } from "@core/edit"; import { type Size } from "@layouts/geometry"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type LumaAsset } from "@schemas/luma-asset"; import * as pixi from "pixi.js"; @@ -13,7 +13,7 @@ export class LumaPlayer extends Player { private sprite: pixi.Sprite | null; private isPlaying: boolean; - constructor(edit: Edit, clipConfiguration: Clip) { + constructor(edit: Edit, clipConfiguration: ResolvedClip) { super(edit, clipConfiguration); this.texture = null; diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 2c0c7442..5115bf81 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -6,7 +6,7 @@ import { type ResolvedTiming, type TimingIntent } from "@core/timing/types"; import { Pointer } from "@inputs/pointer"; import { type Size, type Vector } from "@layouts/geometry"; import { PositionBuilder } from "@layouts/position-builder"; -import { type Clip, type ResolvedClipConfig } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type Keyframe } from "@schemas/keyframe"; import * as pixi from "pixi.js"; @@ -93,7 +93,7 @@ export abstract class Player extends Entity { public shouldDispose: boolean; protected edit: Edit; - public clipConfiguration: Clip; + public clipConfiguration: ResolvedClip; private timingIntent: TimingIntent; private resolvedTiming: ResolvedTiming; @@ -129,10 +129,10 @@ export abstract class Player extends Entity { private initialRotation: number; private rotationCorner: (typeof Player.CornerNames)[number] | null; - private initialClipConfiguration: Clip | null; + private initialClipConfiguration: ResolvedClip | null; protected contentContainer: pixi.Container; - constructor(edit: Edit, clipConfiguration: Clip) { + constructor(edit: Edit, clipConfiguration: ResolvedClip) { super(); this.edit = edit; @@ -203,7 +203,7 @@ export abstract class Player extends Entity { const rotationKeyframes: Keyframe[] = []; const maskXKeyframes: Keyframe[] = []; - const resolvedClipConfig: ResolvedClipConfig = { + const resolvedClipConfig: ResolvedClip = { ...this.clipConfiguration, start: this.getStart() / 1000, length: this.getLength() / 1000 diff --git a/src/components/canvas/players/shape-player.ts b/src/components/canvas/players/shape-player.ts index 51582d56..573835bb 100644 --- a/src/components/canvas/players/shape-player.ts +++ b/src/components/canvas/players/shape-player.ts @@ -1,6 +1,6 @@ import type { Edit } from "@core/edit"; import { type Size } from "@layouts/geometry"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type ShapeAsset } from "@schemas/shape-asset"; import * as pixiFilters from "pixi-filters"; import * as pixi from "pixi.js"; @@ -11,7 +11,7 @@ export class ShapePlayer extends Player { private shape: pixi.Graphics | null; private shapeBackground: pixi.Graphics | null; - constructor(timeline: Edit, clipConfiguration: Clip) { + constructor(timeline: Edit, clipConfiguration: ResolvedClip) { super(timeline, clipConfiguration); this.shape = null; diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 9bbf97c6..1d944b74 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -2,7 +2,7 @@ import { Player } from "@canvas/players/player"; import { TextEditor } from "@canvas/text/text-editor"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; import { type Size, type Vector } from "@layouts/geometry"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type TextAsset } from "@schemas/text-asset"; import * as pixiFilters from "pixi-filters"; import * as pixi from "pixi.js"; @@ -165,7 +165,7 @@ export class TextPlayer extends Player { this.background.fill(); } - public updateTextContent(newText: string, initialConfig: Clip): void { + public updateTextContent(newText: string, initialConfig: ResolvedClip): void { this.edit.updateTextContent(this, newText, initialConfig); } diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 7fb2bb6b..83361f3f 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -1,7 +1,7 @@ import { KeyframeBuilder } from "@animations/keyframe-builder"; import type { Edit } from "@core/edit"; import { type Size } from "@layouts/geometry"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type VideoAsset } from "@schemas/video-asset"; import * as pixi from "pixi.js"; @@ -18,7 +18,7 @@ export class VideoPlayer extends Player { private syncTimer: number; private skipVideoUpdate: boolean; - constructor(edit: Edit, clipConfiguration: Clip) { + constructor(edit: Edit, clipConfiguration: ResolvedClip) { super(edit, clipConfiguration); this.texture = null; diff --git a/src/components/canvas/text/text-editor.ts b/src/components/canvas/text/text-editor.ts index 5b31b702..8ae27daa 100644 --- a/src/components/canvas/text/text-editor.ts +++ b/src/components/canvas/text/text-editor.ts @@ -1,5 +1,5 @@ import type { TextPlayer } from "@canvas/players/text-player"; -import { type Clip } from "@schemas/clip"; +import { type ResolvedClip } from "@schemas/clip"; import { type TextAsset } from "@schemas/text-asset"; import * as pixi from "pixi.js"; @@ -26,7 +26,7 @@ export class TextEditor { private parent: TextPlayer; private targetText: pixi.Text; - private clipConfig: Clip; + private clipConfig: ResolvedClip; private isEditing: boolean = false; private lastClickTime: number = 0; @@ -36,7 +36,7 @@ export class TextEditor { private textInputHandler: TextInputHandler | null = null; private outsideClickHandler: ((e: MouseEvent) => void) | null = null; - constructor(parent: TextPlayer, targetText: pixi.Text, clipConfig: Clip) { + constructor(parent: TextPlayer, targetText: pixi.Text, clipConfig: ResolvedClip) { this.parent = parent; this.targetText = targetText; this.clipConfig = clipConfig; @@ -70,7 +70,7 @@ export class TextEditor { this.isEditing = true; } - private stopEditing(saveChanges = false, initialConfig?: Clip): void { + private stopEditing(saveChanges = false, initialConfig?: ResolvedClip): void { if (!this.isEditing) return; let newText = ""; @@ -116,7 +116,7 @@ export class TextEditor { this.lastClickTime = currentTime; }; - private setupOutsideClickHandler(initialConfig: Clip): void { + private setupOutsideClickHandler(initialConfig: ResolvedClip): void { this.outsideClickHandler = (e: MouseEvent) => { const container = this.parent.getContainer(); const bounds = container.getBounds(); diff --git a/src/components/timeline/interaction/types.ts b/src/components/timeline/interaction/types.ts index aee05c44..5a66ced9 100644 --- a/src/components/timeline/interaction/types.ts +++ b/src/components/timeline/interaction/types.ts @@ -4,12 +4,12 @@ import * as PIXI from "pixi.js"; import { TimelineTheme } from "../../../core/theme"; import { TimelineLayout } from "../timeline-layout"; -import { ResolvedClipConfig, TimelineOptions } from "../types/timeline"; +import { ResolvedClip, TimelineOptions } from "../types/timeline"; // Visual component interfaces export interface VisualClip { getContainer(): PIXI.Container; - getClipConfig(): ResolvedClipConfig | null; + getClipConfig(): ResolvedClip | null; setResizing(resizing: boolean): void; setPreviewWidth(width: number | null): void; } @@ -36,7 +36,7 @@ export interface TimelineInterface { getTheme(): TimelineTheme; getOptions(): TimelineOptions; getVisualTracks(): VisualTrack[]; - getClipData(trackIndex: number, clipIndex: number): ResolvedClipConfig | null; + getClipData(trackIndex: number, clipIndex: number): ResolvedClip | null; getPlayheadTime(): number; getExtendedTimelineWidth(): number; getContainer(): PIXI.Container; diff --git a/src/components/timeline/managers/drag-preview-manager.ts b/src/components/timeline/managers/drag-preview-manager.ts index 1774e759..8a6419b5 100644 --- a/src/components/timeline/managers/drag-preview-manager.ts +++ b/src/components/timeline/managers/drag-preview-manager.ts @@ -1,13 +1,13 @@ import * as PIXI from "pixi.js"; import { TimelineLayout } from "../timeline-layout"; -import { ResolvedClipConfig } from "../types/timeline"; +import { ResolvedClip } from "../types/timeline"; import { VisualTrack } from "../visual/visual-track"; export interface DraggedClipInfo { trackIndex: number; clipIndex: number; - clipConfig: ResolvedClipConfig; + clipConfig: ResolvedClip; } export class DragPreviewManager { @@ -23,7 +23,7 @@ export class DragPreviewManager { private getVisualTracks: () => VisualTrack[] ) {} - public showDragPreview(trackIndex: number, clipIndex: number, clipData: ResolvedClipConfig): void { + public showDragPreview(trackIndex: number, clipIndex: number, clipData: ResolvedClip): void { if (!clipData) return; this.draggedClipInfo = { trackIndex, clipIndex, clipConfig: clipData }; diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index 1e5f2551..c25db697 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -15,7 +15,7 @@ import { TimelineOptionsManager } from "./managers"; import { TimelineLayout } from "./timeline-layout"; -import { EditType, TimelineOptions, ClipInfo, ResolvedClipConfig } from "./types/timeline"; +import { EditType, TimelineOptions, ClipInfo, ResolvedClip } from "./types/timeline"; import { VisualTrack } from "./visual/visual-track"; export class Timeline extends Entity { @@ -211,10 +211,10 @@ export class Timeline extends Entity { return this.renderer.getOverlayLayer(); } - public getClipData(trackIndex: number, clipIndex: number): ResolvedClipConfig | null { + public getClipData(trackIndex: number, clipIndex: number): ResolvedClip | null { if (!this.currentEditType?.timeline?.tracks) return null; const track = this.currentEditType.timeline.tracks[trackIndex]; - return (track?.clips?.[clipIndex] as ResolvedClipConfig) || null; + return (track?.clips?.[clipIndex] as ResolvedClip) || null; } // Layout access for interactions diff --git a/src/components/timeline/types/timeline.ts b/src/components/timeline/types/timeline.ts index 5f61a8da..70110449 100644 --- a/src/components/timeline/types/timeline.ts +++ b/src/components/timeline/types/timeline.ts @@ -1,11 +1,11 @@ -import { ClipSchema, type ResolvedClipConfig } from "@core/schemas/clip"; +import { ClipSchema, type ResolvedClip } from "@core/schemas/clip"; import { EditSchema } from "@schemas/edit"; import { z } from "zod"; export type EditType = z.infer; export type ClipConfig = z.infer; -export type { ResolvedClipConfig }; +export type { ResolvedClip }; export interface TimelineOptions { width?: number; @@ -20,7 +20,7 @@ export interface TimelineOptions { export interface ClipInfo { trackIndex: number; clipIndex: number; - clipConfig: ResolvedClipConfig; + clipConfig: ResolvedClip; x: number; y: number; width: number; diff --git a/src/components/timeline/visual/visual-clip.ts b/src/components/timeline/visual/visual-clip.ts index 71ba418d..df37cb37 100644 --- a/src/components/timeline/visual/visual-clip.ts +++ b/src/components/timeline/visual/visual-clip.ts @@ -5,7 +5,7 @@ import { TimelineTheme } from "../../../core/theme"; import { CLIP_CONSTANTS } from "../constants"; import { SelectionOverlayRenderer } from "../managers/selection-overlay-renderer"; import { getAssetDisplayName, TimelineAsset } from "../types/assets"; -import { ResolvedClipConfig } from "../types/timeline"; +import { ResolvedClip } from "../types/timeline"; export interface VisualClipOptions { pixelsPerSecond: number; @@ -17,7 +17,7 @@ export interface VisualClipOptions { } export class VisualClip extends Entity { - private clipConfig: ResolvedClipConfig; + private clipConfig: ResolvedClip; private options: VisualClipOptions; private graphics: PIXI.Graphics; private background: PIXI.Graphics; @@ -38,7 +38,7 @@ export class VisualClip extends Entity { return this.options.theme.timeline.clips.radius || CLIP_CONSTANTS.CORNER_RADIUS; } - constructor(clipConfig: ResolvedClipConfig, options: VisualClipOptions) { + constructor(clipConfig: ResolvedClip, options: VisualClipOptions) { super(); this.clipConfig = clipConfig; this.options = options; @@ -86,7 +86,7 @@ export class VisualClip extends Entity { this.text.y = this.CLIP_PADDING; } - public updateFromConfig(newConfig: ResolvedClipConfig): void { + public updateFromConfig(newConfig: ResolvedClip): void { this.clipConfig = newConfig; this.updateVisualState(); } @@ -304,7 +304,7 @@ export class VisualClip extends Entity { } // Getters - public getClipConfig(): ResolvedClipConfig { + public getClipConfig(): ResolvedClip { return this.clipConfig; } diff --git a/src/components/timeline/visual/visual-track.ts b/src/components/timeline/visual/visual-track.ts index 140b0fed..4c30e2c8 100644 --- a/src/components/timeline/visual/visual-track.ts +++ b/src/components/timeline/visual/visual-track.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { TimelineTheme } from "../../../core/theme"; import { TRACK_CONSTANTS } from "../constants"; import { SelectionOverlayRenderer } from "../managers/selection-overlay-renderer"; -import { ResolvedClipConfig } from "../types/timeline"; +import { ResolvedClip } from "../types/timeline"; import { VisualClip, VisualClipOptions } from "./visual-clip"; @@ -102,7 +102,7 @@ export class VisualTrack extends Entity { selectionRenderer: this.options.selectionRenderer }; - const visualClip = new VisualClip(clipConfig as ResolvedClipConfig, visualClipOptions); + const visualClip = new VisualClip(clipConfig as ResolvedClip, visualClipOptions); this.addClip(visualClip); }); } @@ -144,7 +144,7 @@ export class VisualTrack extends Entity { } } - public updateClip(clipIndex: number, newClipConfig: ResolvedClipConfig): void { + public updateClip(clipIndex: number, newClipConfig: ResolvedClip): void { if (clipIndex >= 0 && clipIndex < this.clips.length) { const clip = this.clips[clipIndex]; clip.updateFromConfig(newClipConfig); diff --git a/src/core/alias/index.ts b/src/core/alias/index.ts new file mode 100644 index 00000000..d1a99616 --- /dev/null +++ b/src/core/alias/index.ts @@ -0,0 +1 @@ +export { resolveAliasReferences } from "./alias"; diff --git a/src/core/animations/effect-preset-builder.ts b/src/core/animations/effect-preset-builder.ts index 4f63487e..cce56ac8 100644 --- a/src/core/animations/effect-preset-builder.ts +++ b/src/core/animations/effect-preset-builder.ts @@ -1,5 +1,5 @@ import { type Size } from "../layouts/geometry"; -import { type ResolvedClipConfig } from "../schemas/clip"; +import { type ResolvedClip } from "../schemas/clip"; import { type Keyframe } from "../schemas/keyframe"; export type EffectKeyframeSet = { @@ -11,9 +11,9 @@ export type EffectKeyframeSet = { }; export class EffectPresetBuilder { - private clipConfiguration: ResolvedClipConfig; + private clipConfiguration: ResolvedClip; - constructor(clipConfiguration: ResolvedClipConfig) { + constructor(clipConfiguration: ResolvedClip) { this.clipConfiguration = clipConfiguration; } diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index e86e2e25..54941597 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -1,4 +1,4 @@ -import { type ResolvedClipConfig } from "../schemas/clip"; +import { type ResolvedClip } from "../schemas/clip"; import { type Keyframe } from "../schemas/keyframe"; export type TransitionKeyframeSet = { @@ -11,9 +11,9 @@ export type TransitionKeyframeSet = { }; export class TransitionPresetBuilder { - private clipConfiguration: ResolvedClipConfig; + private clipConfiguration: ResolvedClip; - constructor(clipConfiguration: ResolvedClipConfig) { + constructor(clipConfiguration: ResolvedClip) { this.clipConfiguration = clipConfiguration; } diff --git a/src/core/commands/add-clip-command.ts b/src/core/commands/add-clip-command.ts index 489e9af6..fc7b3c3c 100644 --- a/src/core/commands/add-clip-command.ts +++ b/src/core/commands/add-clip-command.ts @@ -1,10 +1,9 @@ import type { Player } from "@canvas/players/player"; -import { ClipSchema } from "@schemas/clip"; -import type { z } from "zod"; +import type { ResolvedClip } from "@schemas/clip"; import type { EditCommand, CommandContext } from "./types"; -type ClipType = z.infer; +type ClipType = ResolvedClip; export class AddClipCommand implements EditCommand { name = "addClip"; @@ -17,8 +16,7 @@ export class AddClipCommand implements EditCommand { async execute(context?: CommandContext): Promise { if (!context) return; // For backward compatibility - const validatedClip = ClipSchema.parse(this.clip); - const clipPlayer = context.createPlayerFromAssetType(validatedClip); + const clipPlayer = context.createPlayerFromAssetType(this.clip); clipPlayer.layer = this.trackIdx + 1; await context.addPlayer(this.trackIdx, clipPlayer); context.updateDuration(); diff --git a/src/core/commands/delete-track-command.ts b/src/core/commands/delete-track-command.ts index 5d28c455..46f2f7cd 100644 --- a/src/core/commands/delete-track-command.ts +++ b/src/core/commands/delete-track-command.ts @@ -1,10 +1,9 @@ -import type { ClipSchema } from "@schemas/clip"; +import type { ResolvedClip } from "@schemas/clip"; import * as pixi from "pixi.js"; -import type { z } from "zod"; import type { EditCommand, CommandContext } from "./types"; -type ClipType = z.infer; +type ClipType = ResolvedClip; export class DeleteTrackCommand implements EditCommand { name = "deleteTrack"; diff --git a/src/core/commands/move-clip-command.ts b/src/core/commands/move-clip-command.ts index 394717bf..3d5231b7 100644 --- a/src/core/commands/move-clip-command.ts +++ b/src/core/commands/move-clip-command.ts @@ -8,7 +8,7 @@ export class MoveClipCommand implements EditCommand { private player?: Player; private originalTrackIndex: number; private originalClipIndex: number; - private originalStart?: number | "auto"; + private originalStart?: number; private originalTimingIntent?: TimingIntent; constructor( diff --git a/src/core/commands/resize-clip-command.ts b/src/core/commands/resize-clip-command.ts index 7edb6064..e639d097 100644 --- a/src/core/commands/resize-clip-command.ts +++ b/src/core/commands/resize-clip-command.ts @@ -5,7 +5,7 @@ import type { EditCommand, CommandContext } from "./types"; export class ResizeClipCommand implements EditCommand { name = "resizeClip"; - private originalLength?: number | "auto" | "end"; + private originalLength?: number; private originalTimingIntent?: TimingIntent; private player?: Player; diff --git a/src/core/commands/set-updated-clip-command.ts b/src/core/commands/set-updated-clip-command.ts index 0953d26a..15bd92c2 100644 --- a/src/core/commands/set-updated-clip-command.ts +++ b/src/core/commands/set-updated-clip-command.ts @@ -1,10 +1,9 @@ import type { Player } from "@canvas/players/player"; -import { ClipSchema } from "@schemas/clip"; -import { z } from "zod"; +import type { ResolvedClip } from "@schemas/clip"; import type { EditCommand, CommandContext } from "./types"; -type ClipType = z.infer; +type ClipType = ResolvedClip; export class SetUpdatedClipCommand implements EditCommand { name = "setUpdatedClip"; diff --git a/src/core/commands/split-clip-command.ts b/src/core/commands/split-clip-command.ts index ab4766a3..1daca656 100644 --- a/src/core/commands/split-clip-command.ts +++ b/src/core/commands/split-clip-command.ts @@ -1,14 +1,14 @@ import type { Player } from "@canvas/players/player"; import type { AudioAsset } from "../schemas/audio-asset"; -import type { Clip } from "../schemas/clip"; +import type { ResolvedClip } from "../schemas/clip"; import type { VideoAsset } from "../schemas/video-asset"; import type { EditCommand, CommandContext } from "./types"; export class SplitClipCommand implements EditCommand { public readonly name = "SplitClip"; - private originalClipConfig: Clip | null = null; + private originalClipConfig: ResolvedClip | null = null; private rightClipPlayer: Player | null = null; private splitSuccessful = false; @@ -41,12 +41,12 @@ export class SplitClipCommand implements EditCommand { this.originalClipConfig = { ...clipConfig }; // Calculate left and right clip configurations - const leftClip: Clip = { + const leftClip: ResolvedClip = { ...clipConfig, length: splitPoint }; - const rightClip: Clip = { + const rightClip: ResolvedClip = { ...clipConfig, start: clipStart + splitPoint, length: clipLength - splitPoint diff --git a/src/core/commands/types.ts b/src/core/commands/types.ts index b4b9d315..631045b7 100644 --- a/src/core/commands/types.ts +++ b/src/core/commands/types.ts @@ -1,11 +1,10 @@ import type { Player } from "@canvas/players/player"; -import type { ClipSchema } from "@schemas/clip"; -import type { EditSchema } from "@schemas/edit"; +import type { ResolvedClip } from "@schemas/clip"; +import type { ResolvedEdit } from "@schemas/edit"; import type { Container } from "pixi.js"; -import type { z } from "zod"; -type ClipType = z.infer; -type EditType = z.infer; +type ClipType = ResolvedClip; +type EditType = ResolvedEdit; export interface TimelineUpdatedEvent { previous: { timeline: EditType }; diff --git a/src/core/commands/update-text-content-command.ts b/src/core/commands/update-text-content-command.ts index 88ab5066..dc338ad7 100644 --- a/src/core/commands/update-text-content-command.ts +++ b/src/core/commands/update-text-content-command.ts @@ -1,11 +1,10 @@ import type { Player } from "@canvas/players/player"; -import type { ClipSchema } from "@schemas/clip"; +import type { ResolvedClip } from "@schemas/clip"; import type { TextAsset } from "@schemas/text-asset"; -import type { z } from "zod"; import type { EditCommand, CommandContext } from "./types"; -type ClipType = z.infer; +type ClipType = ResolvedClip; export class UpdateTextContentCommand implements EditCommand { name = "updateTextContent"; diff --git a/src/core/edit.ts b/src/core/edit.ts index 5d176b3b..9c175123 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -8,6 +8,7 @@ import { ShapePlayer } from "@canvas/players/shape-player"; import { TextPlayer } from "@canvas/players/text-player"; import { VideoPlayer } from "@canvas/players/video-player"; import type { Canvas } from "@canvas/shotstack-canvas"; +import { resolveAliasReferences } from "@core/alias"; import { AddClipCommand } from "@core/commands/add-clip-command"; import { AddTrackCommand } from "@core/commands/add-track-command"; import { ClearSelectionCommand } from "@core/commands/clear-selection-command"; @@ -26,25 +27,20 @@ import { LoadingOverlay } from "@core/ui/loading-overlay"; import type { Size } from "@layouts/geometry"; import { AssetLoader } from "@loaders/asset-loader"; import { FontLoadParser } from "@loaders/font-load-parser"; -import { ClipSchema } from "@schemas/clip"; -import { EditSchema } from "@schemas/edit"; -import { TrackSchema } from "@schemas/track"; +import type { ResolvedClip } from "@schemas/clip"; +import { EditSchema, type Edit as EditConfig, type ResolvedEdit, type Soundtrack } from "@schemas/edit"; +import type { ResolvedTrack } from "@schemas/track"; import * as pixi from "pixi.js"; -import { z } from "zod"; import type { EditCommand, CommandContext } from "./commands/types"; -type EditType = z.infer; -type ClipType = z.infer; -type TrackType = z.infer; - export class Edit extends Entity { private static readonly ZIndexPadding = 100; public assetLoader: AssetLoader; public events: EventEmitter; - private edit: EditType | null; + private edit: ResolvedEdit | null; private tracks: Player[][]; private clipsToDispose: Player[]; private clips: Player[]; @@ -206,7 +202,7 @@ export class Edit extends Entity { this.seek(0); } - public async loadEdit(edit: EditType): Promise { + public async loadEdit(edit: ResolvedEdit): Promise { const loading = new LoadingOverlay(); loading.show(); @@ -219,7 +215,9 @@ export class Edit extends Entity { const mergeFields = edit.merge ?? []; const mergedEdit = mergeFields.length > 0 ? applyMergeFields(edit, mergeFields) : edit; - this.edit = EditSchema.parse(mergedEdit); + const parsedEdit = EditSchema.parse(mergedEdit); + resolveAliasReferences(parsedEdit); + this.edit = parsedEdit as ResolvedEdit; const newSize = this.edit.output?.size; if (newSize && (newSize.width !== this.size.width || newSize.height !== this.size.height)) { @@ -273,39 +271,29 @@ export class Edit extends Entity { } } - private async loadSoundtrack(soundtrack: { src: string; effect?: string; volume?: number }): Promise { - const clip = ClipSchema.parse({ + private async loadSoundtrack(soundtrack: Soundtrack): Promise { + const clip: ResolvedClip = { asset: { type: "audio", src: soundtrack.src, effect: soundtrack.effect, volume: soundtrack.volume ?? 1 }, + fit: "crop", start: 0, length: this.totalDuration / 1000 - }); + }; const player = new AudioPlayer(this, clip); player.layer = this.tracks.length + 1; await this.addPlayer(this.tracks.length, player); } - public getEdit(): EditType { - return this.buildEditSnapshot(player => player.getTimingIntent()); - } - - public getResolvedEdit(): EditType { - return this.buildEditSnapshot(player => ({ - start: player.getStart() / 1000, - length: player.getLength() / 1000 - })); - } - - private buildEditSnapshot(getClipTiming: (player: Player) => { start: number | "auto"; length: number | "auto" | "end" }): EditType { - const tracks: TrackType[] = this.tracks.map(track => ({ + public getEdit(): EditConfig { + const tracks = this.tracks.map(track => ({ clips: track .filter(player => player && !this.clipsToDispose.includes(player)) .map(player => { - const timing = getClipTiming(player); + const timing = player.getTimingIntent(); return { ...player.clipConfiguration, start: timing.start, @@ -314,6 +302,27 @@ export class Edit extends Entity { }) })); + return { + timeline: { + background: this.backgroundColor, + tracks, + fonts: this.edit?.timeline.fonts || [] + }, + output: this.edit?.output || { size: this.size, format: "mp4" } + } as EditConfig; + } + + public getResolvedEdit(): ResolvedEdit { + const tracks: ResolvedTrack[] = this.tracks.map(track => ({ + clips: track + .filter(player => player && !this.clipsToDispose.includes(player)) + .map(player => ({ + ...player.clipConfiguration, + start: player.getStart() / 1000, + length: player.getLength() / 1000 + })) + })); + return { timeline: { background: this.backgroundColor, @@ -324,11 +333,11 @@ export class Edit extends Entity { }; } - public addClip(trackIdx: number, clip: ClipType): void { + public addClip(trackIdx: number, clip: ResolvedClip): void { const command = new AddClipCommand(trackIdx, clip); this.executeCommand(command); } - public getClip(trackIdx: number, clipIdx: number): ClipType | null { + public getClip(trackIdx: number, clipIdx: number): ResolvedClip | null { const clipsByTrack = this.clips.filter((clip: Player) => clip.layer === trackIdx + 1); if (clipIdx < 0 || clipIdx >= clipsByTrack.length) return null; @@ -351,12 +360,12 @@ export class Edit extends Entity { this.executeCommand(command); } - public addTrack(trackIdx: number, track: TrackType): void { + public addTrack(trackIdx: number, track: ResolvedTrack): void { const command = new AddTrackCommand(trackIdx); this.executeCommand(command); track?.clips?.forEach(clip => this.addClip(trackIdx, clip)); } - public getTrack(trackIdx: number): TrackType | null { + public getTrack(trackIdx: number): ResolvedTrack | null { const trackClips = this.clips.filter((clip: Player) => clip.layer === trackIdx + 1); if (trackClips.length === 0) return null; @@ -395,12 +404,12 @@ export class Edit extends Entity { } } /** @internal */ - public setUpdatedClip(clip: Player, initialClipConfig: ClipType | null = null, finalClipConfig: ClipType | null = null): void { + public setUpdatedClip(clip: Player, initialClipConfig: ResolvedClip | null = null, finalClipConfig: ResolvedClip | null = null): void { const command = new SetUpdatedClipCommand(clip, initialClipConfig, finalClipConfig); this.executeCommand(command); } - public updateClip(trackIdx: number, clipIdx: number, updates: Partial): void { + public updateClip(trackIdx: number, clipIdx: number, updates: Partial): void { const clip = this.getPlayerClip(trackIdx, clipIdx); if (!clip) { console.warn(`Clip not found at track ${trackIdx}, index ${clipIdx}`); @@ -414,7 +423,7 @@ export class Edit extends Entity { } /** @internal */ - public updateTextContent(clip: Player, newText: string, initialConfig: ClipType): void { + public updateTextContent(clip: Player, newText: string, initialConfig: ResolvedClip): void { const command = new UpdateTextContentCommand(clip, newText, initialConfig); this.executeCommand(command); } @@ -734,7 +743,7 @@ export class Edit extends Entity { } toTrackContainer.addChild(player.getContainer()); } - private createPlayerFromAssetType(clipConfiguration: ClipType): Player { + private createPlayerFromAssetType(clipConfiguration: ResolvedClip): Player { if (!clipConfiguration.asset?.type) { throw new Error("Invalid clip configuration: missing asset type"); } diff --git a/src/core/schemas/clip.ts b/src/core/schemas/clip.ts index b552d5c3..d7ea4f87 100644 --- a/src/core/schemas/clip.ts +++ b/src/core/schemas/clip.ts @@ -3,10 +3,7 @@ import * as zod from "zod"; import { AssetSchema } from "./asset"; import { KeyframeSchema } from "./keyframe"; -/** - * TODO: Rename all these to clip configuration - * TODO: Change all default to optional - */ +const ALIAS_REFERENCE_PATTERN = /^alias:\/\/[a-zA-Z0-9_-]+$/; const ClipAnchorSchema = zod.enum(["topLeft", "top", "topRight", "left", "center", "right", "bottomLeft", "bottom", "bottomRight"]); @@ -79,8 +76,9 @@ const ClipTransformSchema = zod export const ClipSchema = zod .object({ asset: AssetSchema, - start: zod.union([zod.number().min(0), zod.literal("auto")]), - length: zod.union([zod.number().positive(), zod.literal("auto"), zod.literal("end")]), + start: zod.union([zod.number().min(0), zod.literal("auto"), zod.string().regex(ALIAS_REFERENCE_PATTERN)]), + length: zod.union([zod.number().positive(), zod.literal("auto"), zod.literal("end"), zod.string().regex(ALIAS_REFERENCE_PATTERN)]), + alias: zod.string().optional(), position: ClipAnchorSchema.default("center").optional(), fit: ClipFitSchema.optional(), offset: ClipOffsetSchema.default({ x: 0, y: 0 }).optional(), @@ -99,10 +97,10 @@ export const ClipSchema = zod })); export type ClipAnchor = zod.infer; + export type Clip = zod.infer; -/** Clip with resolved numeric timing values in seconds (no "auto" or "end") */ -export type ResolvedClipConfig = Omit & { +export type ResolvedClip = Omit & { start: number; length: number; }; diff --git a/src/core/schemas/edit.ts b/src/core/schemas/edit.ts index 470b603c..c6f570cc 100644 --- a/src/core/schemas/edit.ts +++ b/src/core/schemas/edit.ts @@ -1,6 +1,6 @@ import * as zod from "zod"; -import { TrackSchema } from "./track"; +import { TrackSchema, type ResolvedTrack } from "./track"; export const FontSourceUrlSchema = zod.string().url("Invalid image url format."); @@ -55,5 +55,13 @@ export const EditSchema = zod }) .strict(); -export type Track = zod.infer; export type MergeField = zod.infer; +export type Soundtrack = zod.infer; + +export type Edit = zod.infer; + +export type ResolvedEdit = Omit & { + timeline: Omit & { + tracks: ResolvedTrack[]; + }; +}; diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index e3152dbe..80d18be7 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -12,7 +12,6 @@ export type { Clip, ClipAnchor } from "./clip"; // Edit, Timeline, Output, Fonts export { FontSourceUrlSchema, FontSourceSchema, TimelineSchema, OutputSchema, EditSchema } from "./edit"; -export type { Track } from "./edit"; // HTML Asset export { HtmlAssetSchema } from "./html-asset"; @@ -59,7 +58,7 @@ export type { TextAsset } from "./text-asset"; // Track export { TrackSchema } from "./track"; -// Note: Track type is exported from "./edit" for historical reasons +export type { Track } from "./track"; // Video Asset export { VideoAssetUrlSchema, VideoAssetCropSchema, VideoAssetVolumeSchema, VideoAssetSchema } from "./video-asset"; diff --git a/src/core/schemas/track.ts b/src/core/schemas/track.ts index 681d322d..52d247f1 100644 --- a/src/core/schemas/track.ts +++ b/src/core/schemas/track.ts @@ -1,9 +1,15 @@ import * as zod from "zod"; -import { ClipSchema } from "./clip"; +import { ClipSchema, type ResolvedClip } from "./clip"; export const TrackSchema = zod .object({ clips: ClipSchema.array() }) .strict(); + +export type Track = zod.infer; + +export type ResolvedTrack = { + clips: ResolvedClip[]; +}; diff --git a/src/core/timing/resolver.ts b/src/core/timing/resolver.ts index 17b03e05..2c8fd891 100644 --- a/src/core/timing/resolver.ts +++ b/src/core/timing/resolver.ts @@ -70,7 +70,6 @@ export async function resolveClipTiming( clipIndex: number, tracks: Player[][] ): Promise { - // Resolve start let resolvedStart: number; if (intent.start === "auto") { resolvedStart = resolveAutoStart(trackIndex, clipIndex, tracks); @@ -78,12 +77,10 @@ export async function resolveClipTiming( resolvedStart = intent.start * 1000; } - // Resolve length (except "end" which needs a separate pass) let resolvedLength: number; if (intent.length === "auto") { resolvedLength = await resolveAutoLength(asset); } else if (intent.length === "end") { - // Placeholder - will be resolved in second pass resolvedLength = 0; } else { resolvedLength = intent.length * 1000; From c6794a1124d471541638f5e059d1e69e68900871 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 22:08:21 +1100 Subject: [PATCH 042/463] feat: add caption asset schema --- src/core/schemas/asset.ts | 8 ++++- src/core/schemas/caption-asset.ts | 60 +++++++++++++++++++++++++++++++ src/core/schemas/index.ts | 11 ++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/core/schemas/caption-asset.ts diff --git a/src/core/schemas/asset.ts b/src/core/schemas/asset.ts index 5d4722c1..ae1d0310 100644 --- a/src/core/schemas/asset.ts +++ b/src/core/schemas/asset.ts @@ -1,6 +1,7 @@ import * as zod from "zod"; import { AudioAssetSchema } from "./audio-asset"; +import { CaptionAssetSchema } from "./caption-asset"; import { HtmlAssetSchema } from "./html-asset"; import { ImageAssetSchema } from "./image-asset"; import { LumaAssetSchema } from "./luma-asset"; @@ -18,7 +19,8 @@ export const AssetSchema = zod ImageAssetSchema, VideoAssetSchema, LumaAssetSchema, - AudioAssetSchema + AudioAssetSchema, + CaptionAssetSchema ]) .refine(schema => { if (schema.type === "text") { @@ -53,6 +55,10 @@ export const AssetSchema = zod return AudioAssetSchema.safeParse(schema); } + if (schema.type === "caption") { + return CaptionAssetSchema.safeParse(schema); + } + return false; }); diff --git a/src/core/schemas/caption-asset.ts b/src/core/schemas/caption-asset.ts new file mode 100644 index 00000000..58d76ed6 --- /dev/null +++ b/src/core/schemas/caption-asset.ts @@ -0,0 +1,60 @@ +import * as zod from "zod"; + +export const CaptionAssetColorSchema = zod + .string() + .regex(/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|transparent$/, "Invalid color format."); + +export const CaptionAssetFontSchema = zod + .object({ + family: zod.string().optional(), + size: zod.coerce.number().min(1).max(512).optional(), + weight: zod.number().optional(), + color: CaptionAssetColorSchema.optional(), + lineHeight: zod.number().min(0).max(10).optional(), + opacity: zod.number().min(0).max(1).optional() + }) + .strict(); + +export const CaptionAssetStrokeSchema = zod + .object({ + width: zod.number().min(0).max(10).optional(), + color: CaptionAssetColorSchema.optional() + }) + .strict(); + +export const CaptionAssetBackgroundSchema = zod + .object({ + color: CaptionAssetColorSchema.optional(), + opacity: zod.number().min(0).max(1).optional(), + padding: zod.number().min(0).max(100).optional(), + borderRadius: zod.number().min(0).optional() + }) + .strict(); + +export const CaptionAssetAlignmentSchema = zod + .object({ + horizontal: zod.enum(["left", "center", "right"]).optional(), + vertical: zod.enum(["top", "center", "bottom"]).optional() + }) + .strict(); + +const ALIAS_REFERENCE_PATTERN = /^alias:\/\/[a-zA-Z0-9_-]+$/; + +export const CaptionAssetSchema = zod + .object({ + type: zod.literal("caption"), + src: zod.union([ + zod.string().url("Invalid subtitle URL format."), + zod.string().regex(ALIAS_REFERENCE_PATTERN, "Invalid alias reference format.") + ]), + font: CaptionAssetFontSchema.optional(), + stroke: CaptionAssetStrokeSchema.optional(), + background: CaptionAssetBackgroundSchema.optional(), + alignment: CaptionAssetAlignmentSchema.optional(), + width: zod.number().min(1).optional(), + height: zod.number().min(1).optional(), + trim: zod.number().min(0).optional() + }) + .strict(); + +export type CaptionAsset = zod.infer; diff --git a/src/core/schemas/index.ts b/src/core/schemas/index.ts index 80d18be7..07097c40 100644 --- a/src/core/schemas/index.ts +++ b/src/core/schemas/index.ts @@ -6,6 +6,17 @@ export type { Asset } from "./asset"; export { AudioAssetUrlSchema, AudioAssetVolumeSchema, AudioAssetSchema } from "./audio-asset"; export type { AudioAsset } from "./audio-asset"; +// Caption Asset +export { + CaptionAssetColorSchema, + CaptionAssetFontSchema, + CaptionAssetStrokeSchema, + CaptionAssetBackgroundSchema, + CaptionAssetAlignmentSchema, + CaptionAssetSchema +} from "./caption-asset"; +export type { CaptionAsset } from "./caption-asset"; + // Clip export { ClipSchema } from "./clip"; export type { Clip, ClipAnchor } from "./clip"; From ce2fdba40972eefde77bab05e4bc9353fc23bfe4 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 22:08:27 +1100 Subject: [PATCH 043/463] feat: add VTT/SRT subtitle parser and loader --- src/core/captions/index.ts | 19 ++++ src/core/captions/parser.ts | 128 +++++++++++++++++++++++ src/core/loaders/subtitle-load-parser.ts | 53 ++++++++++ 3 files changed, 200 insertions(+) create mode 100644 src/core/captions/index.ts create mode 100644 src/core/captions/parser.ts create mode 100644 src/core/loaders/subtitle-load-parser.ts diff --git a/src/core/captions/index.ts b/src/core/captions/index.ts new file mode 100644 index 00000000..2f79be14 --- /dev/null +++ b/src/core/captions/index.ts @@ -0,0 +1,19 @@ +export { parseSubtitle, parseVTT, parseSRT, findActiveCue, getCuesDuration, type Cue } from "./parser"; + +export { + TranscriptionService, + type TranscriptionProgress, + type TranscriptionResult, + type TranscriptionConfig, + type WhisperModel +} from "./transcription-service"; + +export { + isAliasReference, + parseAliasName, + findClipByAlias, + extractAudioUrl, + resolveTranscriptionAlias, + revokeVttUrl, + type AliasResolutionResult +} from "./alias-resolver"; diff --git a/src/core/captions/parser.ts b/src/core/captions/parser.ts new file mode 100644 index 00000000..94d43600 --- /dev/null +++ b/src/core/captions/parser.ts @@ -0,0 +1,128 @@ + +export interface Cue { + start: number; + end: number; + text: string; +} + +function parseTimestamp(timestamp: string): number { + const normalized = timestamp.trim().replace(",", "."); + const parts = normalized.split(":"); + + if (parts.length === 3) { + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const seconds = parseFloat(parts[2]); + return hours * 3600 + minutes * 60 + seconds; + } + + if (parts.length === 2) { + const minutes = parseInt(parts[0], 10); + const seconds = parseFloat(parts[1]); + return minutes * 60 + seconds; + } + + return parseFloat(normalized) || 0; +} + +export function parseVTT(content: string): Cue[] { + const cues: Cue[] = []; + const lines = content.split(/\r?\n/); + + let i = 0; + + while (i < lines.length && !lines[i].includes("-->")) { + i += 1; + } + + while (i < lines.length) { + const line = lines[i].trim(); + + if (line.includes("-->")) { + const [startStr, endStr] = line.split("-->").map(s => s.trim().split(" ")[0]); + const start = parseTimestamp(startStr); + const end = parseTimestamp(endStr); + + const textLines: string[] = []; + i += 1; + + while (i < lines.length && lines[i].trim() !== "" && !lines[i].includes("-->")) { + const textLine = lines[i].trim(); + if (!textLine.startsWith("NOTE")) { + textLines.push(textLine); + } + i += 1; + } + + if (textLines.length > 0) { + cues.push({ + start, + end, + text: textLines.join("\n") + }); + } + } else { + i += 1; + } + } + + return cues; +} + +export function parseSRT(content: string): Cue[] { + const cues: Cue[] = []; + const lines = content.split(/\r?\n/); + + let i = 0; + + while (i < lines.length) { + const line = lines[i].trim(); + + if (/^\d+$/.test(line) || line === "") { + i += 1; + } else if (line.includes("-->")) { + const [startStr, endStr] = line.split("-->").map(s => s.trim()); + const start = parseTimestamp(startStr); + const end = parseTimestamp(endStr); + + const textLines: string[] = []; + i += 1; + + while (i < lines.length && lines[i].trim() !== "") { + textLines.push(lines[i].trim()); + i += 1; + } + + if (textLines.length > 0) { + cues.push({ + start, + end, + text: textLines.join("\n") + }); + } + } else { + i += 1; + } + } + + return cues; +} + +export function parseSubtitle(content: string): Cue[] { + const trimmed = content.trim(); + + if (trimmed.startsWith("WEBVTT")) { + return parseVTT(content); + } + + return parseSRT(content); +} + +export function findActiveCue(cues: Cue[], time: number): Cue | null { + return cues.find(cue => time >= cue.start && time <= cue.end) ?? null; +} + +export function getCuesDuration(cues: Cue[]): number { + if (cues.length === 0) return 0; + return Math.max(...cues.map(cue => cue.end)); +} diff --git a/src/core/loaders/subtitle-load-parser.ts b/src/core/loaders/subtitle-load-parser.ts new file mode 100644 index 00000000..d671f558 --- /dev/null +++ b/src/core/loaders/subtitle-load-parser.ts @@ -0,0 +1,53 @@ +import * as pixi from "pixi.js"; + +import { type Cue, parseSubtitle } from "@core/captions"; + +export interface SubtitleAsset { + content: string; + cues: Cue[]; +} + +export class SubtitleLoadParser implements pixi.LoaderParser { + public static readonly Name = "SubtitleLoadParser"; + + public id: string; + public name: string; + public extension: pixi.ExtensionFormat; + private validExtensions: string[]; + + constructor() { + this.id = SubtitleLoadParser.Name; + this.name = SubtitleLoadParser.Name; + this.extension = { + type: [pixi.ExtensionType.LoadParser], + priority: pixi.LoaderParserPriority.Normal, + ref: null + }; + this.validExtensions = ["srt", "vtt"]; + } + + public test(url: string): boolean { + const extension = url.split("?")[0]?.split(".").pop()?.toLowerCase() ?? ""; + return this.validExtensions.includes(extension); + } + + public async load(url: string): Promise { + try { + const response = await fetch(url); + if (!response.ok) { + return null; + } + const content = await response.text(); + return { + content, + cues: parseSubtitle(content) + }; + } catch { + return null; + } + } + + public unload(_asset: SubtitleAsset | null): void { + // No cleanup needed for text content + } +} From a797a99a156257ea17e4f9ecc7f989ae1168f2ee Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 22:08:36 +1100 Subject: [PATCH 044/463] feat: add whisper transcription service with web worker --- package.json | 1 + src/core/captions/alias-resolver.ts | 123 ++++++++ src/core/captions/transcription-service.ts | 316 +++++++++++++++++++++ src/core/captions/transcription.worker.ts | 101 +++++++ 4 files changed, 541 insertions(+) create mode 100644 src/core/captions/alias-resolver.ts create mode 100644 src/core/captions/transcription-service.ts create mode 100644 src/core/captions/transcription.worker.ts diff --git a/package.json b/package.json index 25499e89..8ff399b2 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "vite-plugin-dts": "^4.5.4" }, "dependencies": { + "@huggingface/transformers": "^3.0.0", "@shotstack/shotstack-canvas": "^1.6.4", "fast-deep-equal": "^3.1.3", "howler": "^2.2.4", diff --git a/src/core/captions/alias-resolver.ts b/src/core/captions/alias-resolver.ts new file mode 100644 index 00000000..c3ee6696 --- /dev/null +++ b/src/core/captions/alias-resolver.ts @@ -0,0 +1,123 @@ +/** + * Resolves alias:// references in caption assets. + * Extracts audio from referenced clips and transcribes to VTT. + */ + +import type { ResolvedEdit } from "@schemas/edit"; + +import type { TranscriptionProgress } from "./transcription-service"; +import { TranscriptionService } from "./transcription-service"; + +/** + * Regex pattern for alias:// references. + * Aligns with pattern used in src/core/alias/alias.ts + */ +const ALIAS_REFERENCE_REGEX = /^alias:\/\/([a-zA-Z0-9_-]+)$/; + +/** + * Check if a source string is an alias reference. + */ +export function isAliasReference(src: unknown): boolean { + if (typeof src !== "string") return false; + return ALIAS_REFERENCE_REGEX.test(src); +} + +/** + * Extract the alias name from an alias:// reference. + */ +export function parseAliasName(src: unknown): string | null { + if (typeof src !== "string") return null; + const match = src.match(ALIAS_REFERENCE_REGEX); + return match ? match[1] : null; +} + +/** + * Find a clip by its alias in the edit. + */ +export function findClipByAlias( + edit: ResolvedEdit, + aliasName: string +): { clip: ResolvedEdit["timeline"]["tracks"][0]["clips"][0]; trackIndex: number; clipIndex: number } | null { + for (let trackIndex = 0; trackIndex < edit.timeline.tracks.length; trackIndex += 1) { + const track = edit.timeline.tracks[trackIndex]; + for (let clipIndex = 0; clipIndex < track.clips.length; clipIndex += 1) { + const clip = track.clips[clipIndex]; + if (clip.alias === aliasName) { + return { clip, trackIndex, clipIndex }; + } + } + } + return null; +} + +/** + * Extract audio URL from a clip's asset. + * Returns the src URL for audio/video assets. + */ +export function extractAudioUrl(asset: any): string | null { + if (!asset) return null; + + // Video and audio assets have a src property + if (asset.type === "video" || asset.type === "audio") { + return asset.src ?? null; + } + + return null; +} + +/** + * Result of resolving an alias reference. + */ +export interface AliasResolutionResult { + /** Blob URL to the generated VTT content */ + vttUrl: string; + /** VTT content as string */ + vttContent: string; +} + +/** + * Resolve an alias reference to a VTT URL. + * Finds the referenced clip, extracts audio, and transcribes it. + */ +export async function resolveTranscriptionAlias( + aliasRef: string, + edit: ResolvedEdit, + onProgress?: (p: TranscriptionProgress) => void +): Promise { + const aliasName = parseAliasName(aliasRef); + if (!aliasName) { + throw new Error(`Invalid alias reference: ${aliasRef}`); + } + + const result = findClipByAlias(edit, aliasName); + if (!result) { + throw new Error(`Alias "${aliasName}" not found in timeline`); + } + + const audioUrl = extractAudioUrl(result.clip.asset); + if (!audioUrl) { + throw new Error(`Cannot extract audio from clip "${aliasName}" - asset type ${result.clip.asset?.type} is not supported`); + } + + // Transcribe the audio + const service = new TranscriptionService(); + const transcription = await service.transcribe(audioUrl, onProgress); + + // Create blob URL for the VTT content + const blob = new Blob([transcription.vtt], { type: "text/vtt" }); + const vttUrl = URL.createObjectURL(blob); + + return { + vttUrl, + vttContent: transcription.vtt + }; +} + +/** + * Clean up a blob URL created by resolveTranscriptionAlias. + */ +export function revokeVttUrl(url: string): void { + if (url.startsWith("blob:")) { + URL.revokeObjectURL(url); + } +} diff --git a/src/core/captions/transcription-service.ts b/src/core/captions/transcription-service.ts new file mode 100644 index 00000000..ac9265b1 --- /dev/null +++ b/src/core/captions/transcription-service.ts @@ -0,0 +1,316 @@ +import type { Cue } from "./parser"; + +export interface TranscriptionProgress { + status: "loading" | "transcribing" | "complete" | "error"; + progress: number; + message?: string; +} + +export interface TranscriptionResult { + vtt: string; + cues: Cue[]; +} + +export type WhisperModel = "Xenova/whisper-tiny" | "Xenova/whisper-base" | "Xenova/whisper-small"; + +export interface TranscriptionConfig { + model?: WhisperModel; + language?: string; +} + +interface WorkerProgressMessage { + type: "progress"; + status: "loading" | "transcribing"; + progress: number; + message: string; +} + +interface WorkerCompleteMessage { + type: "complete"; + chunks: Array<{ text: string; timestamp: [number, number] }>; +} + +interface WorkerErrorMessage { + type: "error"; + message: string; +} + +type WorkerMessage = WorkerProgressMessage | WorkerCompleteMessage | WorkerErrorMessage; + +const MODEL_LOADING_WEIGHT = 0.4; // 0-40% +const TRANSCRIPTION_WEIGHT = 0.6; // 40-100% + +export class TranscriptionService { + private worker: Worker | null = null; + private modelId: WhisperModel; + + constructor(config: TranscriptionConfig = {}) { + this.modelId = config.model ?? "Xenova/whisper-tiny"; + } + + async transcribe(audioUrl: string, onProgress?: (p: TranscriptionProgress) => void): Promise { + if (!this.worker) { + try { + this.worker = new Worker(new URL("./transcription.worker.ts", import.meta.url), { + type: "module" + }); + } catch { + console.warn("Web Workers not available, falling back to main thread transcription"); + return this.transcribeOnMainThread(audioUrl, onProgress); + } + } + + onProgress?.({ + status: "loading", + progress: 0, + message: "Loading audio..." + }); + + const audioData = await this.decodeAudioFromUrl(audioUrl); + + onProgress?.({ + status: "loading", + progress: 5, + message: "Audio loaded, starting transcription..." + }); + + return new Promise((resolve, reject) => { + if (!this.worker) { + reject(new Error("Worker not initialized")); + return; + } + + this.worker.onmessage = (event: MessageEvent) => { + const data = event.data; + + switch (data.type) { + case "progress": + onProgress?.({ + status: data.status, + progress: data.progress, + message: data.message + }); + break; + + case "complete": { + const cues = this.chunksToVTTCues(data.chunks); + const vtt = this.cuesToVTT(cues); + + onProgress?.({ + status: "complete", + progress: 100, + message: "Transcription complete" + }); + + resolve({ vtt, cues }); + break; + } + + case "error": + onProgress?.({ + status: "error", + progress: 0, + message: data.message + }); + reject(new Error(data.message)); + break; + } + }; + + // Handle worker errors + this.worker.onerror = (error: ErrorEvent) => { + onProgress?.({ + status: "error", + progress: 0, + message: error.message || "Worker error" + }); + reject(new Error(error.message || "Worker error")); + }; + + // Send decoded audio to worker (transfer ownership for performance) + this.worker.postMessage( + { + type: "transcribe", + audioData, + modelId: this.modelId + }, + [audioData.buffer] + ); + }); + } + + private async decodeAudioFromUrl(audioUrl: string): Promise { + const response = await fetch(audioUrl); + const arrayBuffer = await response.arrayBuffer(); + + const audioContext = new AudioContext({ sampleRate: 16000 }); + + try { + const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); + + let audioData: Float32Array; + if (audioBuffer.numberOfChannels > 1) { + const left = audioBuffer.getChannelData(0); + const right = audioBuffer.getChannelData(1); + audioData = new Float32Array(left.length); + for (let i = 0; i < left.length; i++) { + audioData[i] = (left[i] + right[i]) / 2; + } + } else { + audioData = new Float32Array(audioBuffer.getChannelData(0)); + } + + return audioData; + } finally { + await audioContext.close(); + } + } + + private async transcribeOnMainThread( + audioUrl: string, + onProgress?: (p: TranscriptionProgress) => void + ): Promise { + onProgress?.({ + status: "loading", + progress: 0, + message: "Loading AI model..." + }); + + const { pipeline } = await import("@huggingface/transformers"); + + const transcriber = await pipeline("automatic-speech-recognition", this.modelId, { + progress_callback: (data: { progress?: number; status?: string }) => { + if (data.progress !== undefined) { + const scaledProgress = Math.round(data.progress * MODEL_LOADING_WEIGHT * 100); + onProgress?.({ + status: "loading", + progress: scaledProgress, + message: `Loading AI model... ${scaledProgress}%` + }); + } + } + }); + + onProgress?.({ + status: "transcribing", + progress: Math.round(MODEL_LOADING_WEIGHT * 100), + message: "Transcribing audio..." + }); + + const result = await transcriber(audioUrl, { + return_timestamps: "word", + chunk_length_s: 30, + stride_length_s: 5 + }); + + await new Promise(resolve => setTimeout(resolve, 0)); + + onProgress?.({ + status: "transcribing", + progress: Math.round((MODEL_LOADING_WEIGHT + TRANSCRIPTION_WEIGHT * 0.5) * 100), + message: "Processing results..." + }); + + const cues = this.chunksToVTTCues( + (result as { chunks?: Array<{ text: string; timestamp: [number, number] }> }).chunks ?? [] + ); + + await new Promise(resolve => setTimeout(resolve, 0)); + + const vtt = this.cuesToVTT(cues); + + onProgress?.({ + status: "complete", + progress: 100, + message: "Transcription complete" + }); + + return { vtt, cues }; + } + + dispose(): void { + if (this.worker) { + this.worker.terminate(); + this.worker = null; + } + } + + private chunksToVTTCues(chunks: Array<{ text: string; timestamp: [number, number] }>): Cue[] { + if (!chunks || chunks.length === 0) { + return []; + } + + const cues: Cue[] = []; + const WORDS_PER_CUE = 8; + + let currentWords: Array<{ text: string; start: number; end: number }> = []; + + for (const chunk of chunks) { + const [start, end] = chunk.timestamp; + const text = chunk.text.trim(); + + if (!text) { + if (currentWords.length > 0) { + cues.push(this.createCueFromWords(currentWords)); + currentWords = []; + } + } else { + currentWords.push({ text, start, end }); + + if (currentWords.length >= WORDS_PER_CUE) { + cues.push(this.createCueFromWords(currentWords)); + currentWords = []; + } + } + } + + if (currentWords.length > 0) { + cues.push(this.createCueFromWords(currentWords)); + } + + return cues; + } + + private createCueFromWords(words: Array<{ text: string; start: number; end: number }>): Cue { + return { + start: words[0].start, + end: words[words.length - 1].end, + text: words.map(w => w.text).join(" ") + }; + } + + private cuesToVTT(cues: Cue[]): string { + const lines: string[] = ["WEBVTT", ""]; + + for (const cue of cues) { + const startTime = this.formatVTTTimestamp(cue.start); + const endTime = this.formatVTTTimestamp(cue.end); + lines.push(`${startTime} --> ${endTime}`); + lines.push(cue.text); + lines.push(""); + } + + return lines.join("\n"); + } + + private formatVTTTimestamp(seconds: number): string { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + const hh = hours.toString().padStart(2, "0"); + const mm = minutes.toString().padStart(2, "0"); + const ss = secs.toFixed(3).padStart(6, "0"); + + return `${hh}:${mm}:${ss}`; + } + + static async isAvailable(): Promise { + try { + await import("@huggingface/transformers"); + return true; + } catch { + return false; + } + } +} diff --git a/src/core/captions/transcription.worker.ts b/src/core/captions/transcription.worker.ts new file mode 100644 index 00000000..fdbc8975 --- /dev/null +++ b/src/core/captions/transcription.worker.ts @@ -0,0 +1,101 @@ +interface TranscribeMessage { + type: "transcribe"; + audioData: Float32Array; + modelId: string; +} + +interface ProgressMessage { + type: "progress"; + status: "loading" | "transcribing"; + progress: number; + message: string; +} + +interface CompleteMessage { + type: "complete"; + chunks: Array<{ text: string; timestamp: [number, number] }>; +} + +interface ErrorMessage { + type: "error"; + message: string; +} + +type WorkerMessage = ProgressMessage | CompleteMessage | ErrorMessage; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let transcriber: any = null; +let currentModelId: string | null = null; + +const MODEL_LOADING_WEIGHT = 0.4; // 0-40% + +function postWorkerMessage(message: WorkerMessage): void { + self.postMessage(message); +} + +self.onmessage = async (event: MessageEvent) => { + const { type, audioData, modelId } = event.data; + + if (type !== "transcribe") { + return; + } + + try { + if (!transcriber || currentModelId !== modelId) { + postWorkerMessage({ + type: "progress", + status: "loading", + progress: 0, + message: "Loading AI model..." + }); + + const { pipeline } = await import("@huggingface/transformers"); + + transcriber = await pipeline("automatic-speech-recognition", modelId, { + progress_callback: (data: { progress?: number; status?: string }) => { + if (data.progress !== undefined) { + const scaledProgress = Math.round(data.progress * MODEL_LOADING_WEIGHT * 100); + postWorkerMessage({ + type: "progress", + status: "loading", + progress: scaledProgress, + message: `Loading AI model... ${scaledProgress}%` + }); + } + } + }); + + currentModelId = modelId; + + postWorkerMessage({ + type: "progress", + status: "loading", + progress: Math.round(MODEL_LOADING_WEIGHT * 100), + message: "AI model loaded" + }); + } + + postWorkerMessage({ + type: "progress", + status: "transcribing", + progress: Math.round(MODEL_LOADING_WEIGHT * 100), + message: "Transcribing audio..." + }); + + const result = await transcriber(audioData, { + return_timestamps: "word", + chunk_length_s: 30, + stride_length_s: 5 + }); + + postWorkerMessage({ + type: "complete", + chunks: (result as { chunks?: Array<{ text: string; timestamp: [number, number] }> }).chunks ?? [] + }); + } catch (error) { + postWorkerMessage({ + type: "error", + message: error instanceof Error ? error.message : "Transcription failed" + }); + } +}; From 132b964aac4ec1cd3e7d2d3b6aded723000f4dac Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 22:08:45 +1100 Subject: [PATCH 045/463] feat: add caption player for rendering subtitles --- .../canvas/players/caption-player.ts | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 src/components/canvas/players/caption-player.ts diff --git a/src/components/canvas/players/caption-player.ts b/src/components/canvas/players/caption-player.ts new file mode 100644 index 00000000..a0f17012 --- /dev/null +++ b/src/components/canvas/players/caption-player.ts @@ -0,0 +1,305 @@ +import { Player } from "@canvas/players/player"; +import { + type Cue, + findActiveCue, + isAliasReference, + resolveTranscriptionAlias, + revokeVttUrl +} from "@core/captions"; +import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; +import { SubtitleLoadParser, type SubtitleAsset } from "@loaders/subtitle-load-parser"; +import { type Size, type Vector } from "@layouts/geometry"; +import { type CaptionAsset } from "@schemas/caption-asset"; +import * as pixiFilters from "pixi-filters"; +import * as pixi from "pixi.js"; + +/** + * CaptionPlayer renders timed subtitle cues from SRT/VTT files. + * Captions are shown/hidden based on the current playback time. + * Transcription runs in the background without blocking timeline loading. + */ +export class CaptionPlayer extends Player { + private static loadedFonts = new Set(); + + private cues: Cue[] = []; + private currentCue: Cue | null = null; + private background: pixi.Graphics | null = null; + private text: pixi.Text | null = null; + private vttBlobUrl: string | null = null; + + private pendingTranscription: Promise | null = null; + private isTranscribing = false; + + public override async load(): Promise { + await super.load(); + + const captionAsset = this.clipConfiguration.asset as CaptionAsset; + + const fontFamily = captionAsset.font?.family ?? "Open Sans"; + await this.loadFont(fontFamily); + + if (isAliasReference(captionAsset.src)) { + this.isTranscribing = true; + this.pendingTranscription = this.loadTranscriptionInBackground(captionAsset.src); + } else { + await this.loadSubtitles(captionAsset.src); + } + + this.background = new pixi.Graphics(); + this.contentContainer.addChild(this.background); + + this.text = new pixi.Text({ text: "", style: this.createTextStyle(captionAsset) }); + this.text.visible = false; + + if (captionAsset.stroke?.width && captionAsset.stroke.width > 0 && captionAsset.stroke.color) { + const strokeFilter = new pixiFilters.OutlineFilter({ + thickness: captionAsset.stroke.width, + color: captionAsset.stroke.color + }); + this.text.filters = [strokeFilter]; + } + + this.contentContainer.addChild(this.text); + this.configureKeyframes(); + } + + private async loadTranscriptionInBackground(src: string): Promise { + try { + const originalEdit = this.edit.getOriginalEdit(); + if (!originalEdit) { + throw new Error("Cannot resolve alias: edit not loaded"); + } + + const result = await resolveTranscriptionAlias(src, originalEdit, (progress) => { + this.edit.events.emit("transcription:progress", { + clipAlias: this.clipConfiguration.alias, + ...progress + }); + }); + + this.vttBlobUrl = result.vttUrl; + + const loadOptions: pixi.UnresolvedAsset = { + src: result.vttUrl, + loadParser: SubtitleLoadParser.Name + }; + const subtitle = await this.edit.assetLoader.load(result.vttUrl, loadOptions); + + if (subtitle) { + this.cues = subtitle.cues; + } + + this.isTranscribing = false; + + this.edit.events.emit("transcription:complete", { + clipAlias: this.clipConfiguration.alias, + cueCount: this.cues.length + }); + } catch (error) { + this.isTranscribing = false; + console.error("Failed to transcribe:", error); + + this.edit.events.emit("transcription:error", { + clipAlias: this.clipConfiguration.alias, + error: error instanceof Error ? error.message : "Transcription failed" + }); + } + } + + public isTranscriptionPending(): boolean { + return this.isTranscribing; + } + + public async waitForTranscription(): Promise { + if (this.pendingTranscription) { + await this.pendingTranscription; + } + } + + public override update(deltaTime: number, elapsed: number): void { + super.update(deltaTime, elapsed); + + if (!this.text) return; + + const captionAsset = this.clipConfiguration.asset as CaptionAsset; + const trim = captionAsset.trim ?? 0; + + const time = this.getPlaybackTime() / 1000 + trim; + + const activeCue = findActiveCue(this.cues, time); + + if (activeCue !== this.currentCue) { + this.currentCue = activeCue; + this.updateDisplay(activeCue, captionAsset); + } + } + + public override dispose(): void { + super.dispose(); + + this.background?.destroy(); + this.background = null; + + this.text?.destroy(); + this.text = null; + + if (this.vttBlobUrl) { + revokeVttUrl(this.vttBlobUrl); + this.vttBlobUrl = null; + } + + this.cues = []; + this.currentCue = null; + } + + public override getSize(): Size { + const captionAsset = this.clipConfiguration.asset as CaptionAsset; + + return { + width: this.clipConfiguration.width ?? captionAsset.width ?? this.edit.size.width, + height: this.clipConfiguration.height ?? captionAsset.height ?? this.edit.size.height + }; + } + + protected override getFitScale(): number { + return 1; + } + + protected override getContainerScale(): Vector { + const scale = this.getScale(); + return { x: scale, y: scale }; + } + + private async loadSubtitles(src: string): Promise { + try { + const loadOptions: pixi.UnresolvedAsset = { + src, + loadParser: SubtitleLoadParser.Name + }; + const subtitle = await this.edit.assetLoader.load(src, loadOptions); + + if (subtitle) { + this.cues = subtitle.cues; + } else { + console.error("Failed to load subtitles"); + this.cues = []; + } + } catch (error) { + console.error("Failed to load subtitles:", error); + this.cues = []; + } + } + + private createTextStyle(captionAsset: CaptionAsset): pixi.TextStyle { + const fontFamily = captionAsset.font?.family ?? "Open Sans"; + const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily); + const fontSize = captionAsset.font?.size ?? 32; + const { width } = this.getSize(); + + return new pixi.TextStyle({ + fontFamily: baseFontFamily, + fontSize, + fill: captionAsset.font?.color ?? "#ffffff", + fontWeight: fontWeight.toString() as pixi.TextStyleFontWeight, + wordWrap: true, + wordWrapWidth: width * 0.9, + lineHeight: (captionAsset.font?.lineHeight ?? 1.2) * fontSize, + align: captionAsset.alignment?.horizontal ?? "center" + }); + } + + private updateDisplay(cue: Cue | null, captionAsset: CaptionAsset): void { + if (!this.text || !this.background) return; + + if (!cue) { + this.text.visible = false; + this.background.clear(); + return; + } + + this.text.text = cue.text; + this.text.visible = true; + + this.positionText(captionAsset); + + this.drawBackground(captionAsset); + } + + private positionText(captionAsset: CaptionAsset): void { + if (!this.text) return; + + const horizontalAlign = captionAsset.alignment?.horizontal ?? "center"; + const verticalAlign = captionAsset.alignment?.vertical ?? "bottom"; + const { width: containerWidth, height: containerHeight } = this.getSize(); + const padding = captionAsset.background?.padding ?? 10; + + let textX = containerWidth / 2 - this.text.width / 2; + if (horizontalAlign === "left") { + textX = padding; + } else if (horizontalAlign === "right") { + textX = containerWidth - this.text.width - padding; + } + + let textY = containerHeight - this.text.height - padding; + if (verticalAlign === "top") { + textY = padding; + } else if (verticalAlign === "center") { + textY = containerHeight / 2 - this.text.height / 2; + } + + this.text.position.set(textX, textY); + } + + private drawBackground(captionAsset: CaptionAsset): void { + if (!this.background || !this.text || !this.text.visible) { + this.background?.clear(); + return; + } + + const bgConfig = captionAsset.background; + if (!bgConfig?.color) { + this.background.clear(); + return; + } + + const padding = bgConfig.padding ?? 10; + const borderRadius = bgConfig.borderRadius ?? 4; + + const bgX = this.text.x - padding; + const bgY = this.text.y - padding; + const bgWidth = this.text.width + padding * 2; + const bgHeight = this.text.height + padding * 2; + + this.background.clear(); + this.background.fillStyle = { + color: bgConfig.color, + alpha: bgConfig.opacity ?? 0.8 + }; + + if (borderRadius > 0) { + this.background.roundRect(bgX, bgY, bgWidth, bgHeight, borderRadius); + } else { + this.background.rect(bgX, bgY, bgWidth, bgHeight); + } + this.background.fill(); + } + + private async loadFont(fontFamily: string): Promise { + const { baseFontFamily, fontWeight } = parseFontFamily(fontFamily); + const cacheKey = `${baseFontFamily}-${fontWeight}`; + + if (CaptionPlayer.loadedFonts.has(cacheKey)) { + return; + } + + const fontPath = resolveFontPath(fontFamily); + if (fontPath) { + const fontFace = new FontFace(baseFontFamily, `url(${fontPath})`, { + weight: fontWeight.toString() + }); + await fontFace.load(); + document.fonts.add(fontFace); + CaptionPlayer.loadedFonts.add(cacheKey); + } + } +} From e1fbcabedf94be5b0cb46e4859b65b817342dc17 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 22:08:55 +1100 Subject: [PATCH 046/463] feat: integrate transcription UI and export handling --- src/components/canvas/shotstack-canvas.ts | 40 ++++++-- src/core/edit.ts | 15 +++ src/core/export/export-coordinator.ts | 21 ++++- src/core/ui/transcription-indicator.ts | 106 ++++++++++++++++++++++ 4 files changed, 175 insertions(+), 7 deletions(-) create mode 100644 src/core/ui/transcription-indicator.ts diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 1bea68cc..5a3286ce 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,8 +1,10 @@ import { Inspector } from "@canvas/system/inspector"; +import { TranscriptionIndicator } from "@core/ui/transcription-indicator"; import { Edit } from "@core/edit"; import { type Size } from "@layouts/geometry"; import { AudioLoadParser } from "@loaders/audio-load-parser"; import { FontLoadParser } from "@loaders/font-load-parser"; +import { SubtitleLoadParser } from "@loaders/subtitle-load-parser"; import * as pixi from "pixi.js"; import type { Timeline } from "../timeline/timeline"; @@ -19,6 +21,7 @@ export class Canvas { private readonly edit: Edit; private readonly inspector: Inspector; + private readonly transcriptionIndicator: TranscriptionIndicator; private container?: pixi.Container; private background?: pixi.Graphics; @@ -29,12 +32,15 @@ export class Canvas { private currentZoom = 1; private onTickBound: (ticker: pixi.Ticker) => void; + private onBackgroundClickBound: (event: pixi.FederatedPointerEvent) => void; constructor(edit: Edit) { this.application = new pixi.Application(); this.edit = edit; this.inspector = new Inspector(); + this.transcriptionIndicator = new TranscriptionIndicator(); this.onTickBound = this.onTick.bind(this); + this.onBackgroundClickBound = this.onBackgroundClick.bind(this); edit.setCanvas(this); } @@ -58,6 +64,7 @@ export class Canvas { this.background.fill(); await this.configureApplication(); + await this.transcriptionIndicator.load(); this.configureStage(); this.setupTouchHandling(root); this.zoomToFit(); @@ -159,6 +166,7 @@ export class Canvas { if (!Canvas.extensionsRegistered) { pixi.extensions.add(new AudioLoadParser()); pixi.extensions.add(new FontLoadParser()); + pixi.extensions.add(new SubtitleLoadParser()); Canvas.extensionsRegistered = true; } } @@ -190,6 +198,8 @@ export class Canvas { this.inspector.update(ticker.deltaTime, ticker.deltaMS); this.inspector.draw(); + this.transcriptionIndicator.update(ticker.deltaTime, ticker.deltaMS); + if (this.timeline) { this.timeline.update(ticker.deltaTime, ticker.deltaMS); this.timeline.draw(); @@ -204,6 +214,9 @@ export class Canvas { this.container.addChild(this.background); this.container.addChild(this.edit.getContainer()); this.container.addChild(this.inspector.getContainer()); + this.container.addChild(this.transcriptionIndicator.getContainer()); + + this.transcriptionIndicator.setPosition(this.viewportSize.width - 10, 10); this.application.stage.addChild(this.container); @@ -211,13 +224,28 @@ export class Canvas { this.application.stage.hitArea = new pixi.Rectangle(0, 0, this.viewportSize.width, this.viewportSize.height); this.background.eventMode = "static"; - this.background.on("pointerdown", this.onBackgroundClick.bind(this)); + this.background.on("pointerdown", this.onBackgroundClickBound); - this.application.stage.on("click", this.onClick.bind(this)); + this.setupTranscriptionEventListeners(); } - private onClick(): void { - this.edit.pause(); + private setupTranscriptionEventListeners(): void { + this.edit.events.on("transcription:progress", (payload: { message?: string }) => { + const message = payload.message ?? "Transcribing..."; + this.transcriptionIndicator.show(message); + this.transcriptionIndicator.setPosition( + this.viewportSize.width - this.transcriptionIndicator.getWidth() - 10, + 10 + ); + }); + + this.edit.events.on("transcription:complete", () => { + this.transcriptionIndicator.hide(); + }); + + this.edit.events.on("transcription:error", () => { + this.transcriptionIndicator.hide(); + }); } private onBackgroundClick(event: pixi.FederatedPointerEvent): void { @@ -241,13 +269,13 @@ export class Canvas { } this.application.ticker.remove(this.onTickBound); - this.application.stage.off("click", this.onClick, this); - this.background?.off("pointerdown", this.onBackgroundClick, this); + this.background?.off("pointerdown", this.onBackgroundClickBound); this.background?.destroy(); this.container?.destroy(); this.inspector.dispose(); + this.transcriptionIndicator.dispose(); this.application.destroy(true, { children: true, texture: true }); } diff --git a/src/core/edit.ts b/src/core/edit.ts index 9c175123..7a7fd11b 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1,4 +1,5 @@ import { AudioPlayer } from "@canvas/players/audio-player"; +import { CaptionPlayer } from "@canvas/players/caption-player"; import { HtmlPlayer } from "@canvas/players/html-player"; import { ImagePlayer } from "@canvas/players/image-player"; import { LumaPlayer } from "@canvas/players/luma-player"; @@ -38,6 +39,7 @@ export class Edit extends Entity { private static readonly ZIndexPadding = 100; public assetLoader: AssetLoader; + // TODO: Create typed EditEventMap for SDK consumers (autocomplete, type-safe payloads) public events: EventEmitter; private edit: ResolvedEdit | null; @@ -333,6 +335,15 @@ export class Edit extends Entity { }; } + /** + * Get the original parsed edit configuration. + * Unlike getResolvedEdit(), this returns the edit as originally parsed, + * with all clips present regardless of loading state. + */ + public getOriginalEdit(): ResolvedEdit | null { + return this.edit; + } + public addClip(trackIdx: number, clip: ResolvedClip): void { const command = new AddClipCommand(trackIdx, clip); this.executeCommand(command); @@ -783,6 +794,10 @@ export class Edit extends Entity { player = new LumaPlayer(this, clipConfiguration); break; } + case "caption": { + player = new CaptionPlayer(this, clipConfiguration); + break; + } default: throw new Error(`Unsupported clip type: ${(clipConfiguration.asset as any).type}`); } diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 9593312f..8565d501 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -1,3 +1,4 @@ +import { CaptionPlayer } from "@canvas/players/caption-player"; import { Canvas } from "@canvas/shotstack-canvas"; import { ExportCommand } from "@core/commands/export-command"; import { Edit } from "@core/edit"; @@ -55,10 +56,12 @@ export class ExportCoordinator { this.progressUI.create(); this.canvas.pauseTicker(); + this.edit.executeEditCommand(this.exportCommand); + await this.waitForPendingTranscriptions(); + const cfg = this.prepareConfig(fps ?? this.edit.getEdit().output?.fps ?? 30); this.progressUI.update(0, 100, "Preparing..."); - this.edit.executeEditCommand(this.exportCommand); await this.videoProcessor.initialize(this.exportCommand.getClips()); this.progressUI.update(10, 100, "Video ready"); @@ -232,4 +235,20 @@ export class ExportCoordinator { const hasVideoTexture = texture?.source?.resource instanceof HTMLVideoElement; return hasVideoConstructor || hasVideoTexture; } + + private async waitForPendingTranscriptions(): Promise { + const clips = this.exportCommand.getClips(); + const transcriptionPromises: Promise[] = []; + + for (const clip of clips) { + if (clip instanceof CaptionPlayer && clip.isTranscriptionPending()) { + transcriptionPromises.push(clip.waitForTranscription()); + } + } + + if (transcriptionPromises.length > 0) { + this.progressUI.update(0, 100, "Waiting for transcription..."); + await Promise.all(transcriptionPromises); + } + } } diff --git a/src/core/ui/transcription-indicator.ts b/src/core/ui/transcription-indicator.ts new file mode 100644 index 00000000..34a63834 --- /dev/null +++ b/src/core/ui/transcription-indicator.ts @@ -0,0 +1,106 @@ +import { Entity } from "@core/shared/entity"; +import * as pixi from "pixi.js"; + +export class TranscriptionIndicator extends Entity { + private background: pixi.Graphics | null = null; + private spinner: pixi.Graphics | null = null; + private statusText: pixi.Text | null = null; + private spinnerAngle = 0; + + private isVisible = false; + private currentMessage = ""; + + public override async load(): Promise { + this.background = new pixi.Graphics(); + this.getContainer().addChild(this.background); + + this.spinner = new pixi.Graphics(); + this.getContainer().addChild(this.spinner); + + this.statusText = new pixi.Text({ + text: "", + style: { + fontFamily: "system-ui, -apple-system, sans-serif", + fontSize: 11, + fill: "#ffffff" + } + }); + this.getContainer().addChild(this.statusText); + + this.hide(); + } + + public show(message: string): void { + this.isVisible = true; + this.currentMessage = message; + this.getContainer().visible = true; + this.redraw(); + } + + public hide(): void { + this.isVisible = false; + this.getContainer().visible = false; + } + + public getIsVisible(): boolean { + return this.isVisible; + } + + public override update(deltaTime: number, _elapsed: number): void { + if (!this.isVisible || !this.spinner) return; + + this.spinnerAngle += deltaTime * 0.15; + this.spinner.rotation = this.spinnerAngle; + } + + public override draw(): void {} + + private redraw(): void { + if (!this.background || !this.spinner || !this.statusText) return; + + this.statusText.text = this.currentMessage; + + const textWidth = this.statusText.width; + const spinnerSize = 12; + const padding = 8; + const gap = 6; + const totalWidth = spinnerSize + gap + textWidth + padding * 2; + const height = 24; + + this.background.clear(); + this.background.fillStyle = { color: "#000000", alpha: 0.7 }; + this.background.roundRect(0, 0, totalWidth, height, height / 2); + this.background.fill(); + + this.spinner.clear(); + this.spinner.strokeStyle = { color: "#ffffff", width: 2 }; + this.spinner.arc(0, 0, spinnerSize / 2 - 1, 0, Math.PI * 1.5); + this.spinner.stroke(); + this.spinner.position.set(padding + spinnerSize / 2, height / 2); + + this.statusText.position.set(padding + spinnerSize + gap, (height - this.statusText.height) / 2); + } + + public setPosition(x: number, y: number): void { + this.getContainer().position.set(x, y); + } + + public getWidth(): number { + if (!this.statusText) return 0; + const spinnerSize = 12; + const padding = 8; + const gap = 6; + return spinnerSize + gap + this.statusText.width + padding * 2; + } + + public override dispose(): void { + this.background?.destroy(); + this.background = null; + + this.spinner?.destroy(); + this.spinner = null; + + this.statusText?.destroy(); + this.statusText = null; + } +} From 1e2fc80a639217522bd1c1f67755ef7a3850c8cb Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 22:22:48 +1100 Subject: [PATCH 047/463] fix: configure inline web worker for SDK compatibility --- src/core/captions/transcription-service.ts | 2 +- vite.config.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/captions/transcription-service.ts b/src/core/captions/transcription-service.ts index ac9265b1..1606ad30 100644 --- a/src/core/captions/transcription-service.ts +++ b/src/core/captions/transcription-service.ts @@ -51,7 +51,7 @@ export class TranscriptionService { async transcribe(audioUrl: string, onProgress?: (p: TranscriptionProgress) => void): Promise { if (!this.worker) { try { - this.worker = new Worker(new URL("./transcription.worker.ts", import.meta.url), { + this.worker = new Worker(new URL("./transcription.worker.ts?worker&inline", import.meta.url), { type: "module" }); } catch { diff --git a/vite.config.ts b/vite.config.ts index a8b08e3c..0d0db496 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -14,6 +14,9 @@ const globals = { }; export default defineConfig({ + worker: { + format: "es" + }, plugins: [ dts({ rollupTypes: true, From 970f2e4a2466a3f6f35dfdd50917838496e69ee1 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 7 Dec 2025 23:57:24 +1100 Subject: [PATCH 048/463] refactor: convert transcription worker to inline blob-based execution --- src/core/captions/transcription-service.ts | 91 ++-------------- .../captions/transcription-worker-code.ts | 49 +++++++++ src/core/captions/transcription.worker.ts | 101 ------------------ 3 files changed, 58 insertions(+), 183 deletions(-) create mode 100644 src/core/captions/transcription-worker-code.ts delete mode 100644 src/core/captions/transcription.worker.ts diff --git a/src/core/captions/transcription-service.ts b/src/core/captions/transcription-service.ts index 1606ad30..66a48c57 100644 --- a/src/core/captions/transcription-service.ts +++ b/src/core/captions/transcription-service.ts @@ -1,4 +1,5 @@ import type { Cue } from "./parser"; +import { TRANSCRIPTION_WORKER_CODE } from "./transcription-worker-code"; export interface TranscriptionProgress { status: "loading" | "transcribing" | "complete" | "error"; @@ -37,11 +38,9 @@ interface WorkerErrorMessage { type WorkerMessage = WorkerProgressMessage | WorkerCompleteMessage | WorkerErrorMessage; -const MODEL_LOADING_WEIGHT = 0.4; // 0-40% -const TRANSCRIPTION_WEIGHT = 0.6; // 40-100% - export class TranscriptionService { private worker: Worker | null = null; + private workerUrl: string | null = null; private modelId: WhisperModel; constructor(config: TranscriptionConfig = {}) { @@ -50,14 +49,9 @@ export class TranscriptionService { async transcribe(audioUrl: string, onProgress?: (p: TranscriptionProgress) => void): Promise { if (!this.worker) { - try { - this.worker = new Worker(new URL("./transcription.worker.ts?worker&inline", import.meta.url), { - type: "module" - }); - } catch { - console.warn("Web Workers not available, falling back to main thread transcription"); - return this.transcribeOnMainThread(audioUrl, onProgress); - } + const blob = new Blob([TRANSCRIPTION_WORKER_CODE], { type: "application/javascript" }); + this.workerUrl = URL.createObjectURL(blob); + this.worker = new Worker(this.workerUrl, { type: "module" }); } onProgress?.({ @@ -166,73 +160,15 @@ export class TranscriptionService { } } - private async transcribeOnMainThread( - audioUrl: string, - onProgress?: (p: TranscriptionProgress) => void - ): Promise { - onProgress?.({ - status: "loading", - progress: 0, - message: "Loading AI model..." - }); - - const { pipeline } = await import("@huggingface/transformers"); - - const transcriber = await pipeline("automatic-speech-recognition", this.modelId, { - progress_callback: (data: { progress?: number; status?: string }) => { - if (data.progress !== undefined) { - const scaledProgress = Math.round(data.progress * MODEL_LOADING_WEIGHT * 100); - onProgress?.({ - status: "loading", - progress: scaledProgress, - message: `Loading AI model... ${scaledProgress}%` - }); - } - } - }); - - onProgress?.({ - status: "transcribing", - progress: Math.round(MODEL_LOADING_WEIGHT * 100), - message: "Transcribing audio..." - }); - - const result = await transcriber(audioUrl, { - return_timestamps: "word", - chunk_length_s: 30, - stride_length_s: 5 - }); - - await new Promise(resolve => setTimeout(resolve, 0)); - - onProgress?.({ - status: "transcribing", - progress: Math.round((MODEL_LOADING_WEIGHT + TRANSCRIPTION_WEIGHT * 0.5) * 100), - message: "Processing results..." - }); - - const cues = this.chunksToVTTCues( - (result as { chunks?: Array<{ text: string; timestamp: [number, number] }> }).chunks ?? [] - ); - - await new Promise(resolve => setTimeout(resolve, 0)); - - const vtt = this.cuesToVTT(cues); - - onProgress?.({ - status: "complete", - progress: 100, - message: "Transcription complete" - }); - - return { vtt, cues }; - } - dispose(): void { if (this.worker) { this.worker.terminate(); this.worker = null; } + if (this.workerUrl) { + URL.revokeObjectURL(this.workerUrl); + this.workerUrl = null; + } } private chunksToVTTCues(chunks: Array<{ text: string; timestamp: [number, number] }>): Cue[] { @@ -304,13 +240,4 @@ export class TranscriptionService { return `${hh}:${mm}:${ss}`; } - - static async isAvailable(): Promise { - try { - await import("@huggingface/transformers"); - return true; - } catch { - return false; - } - } } diff --git a/src/core/captions/transcription-worker-code.ts b/src/core/captions/transcription-worker-code.ts new file mode 100644 index 00000000..3d7f6f63 --- /dev/null +++ b/src/core/captions/transcription-worker-code.ts @@ -0,0 +1,49 @@ +/** + * Inline worker code for transcription. + * Using a string literal avoids bundler issues with transformers.js. + * The library is loaded from CDN inside the worker. + */ +export const TRANSCRIPTION_WORKER_CODE = ` +let transcriber = null; +let currentModelId = null; + +function postWorkerMessage(message) { + self.postMessage(message); +} + +self.onmessage = async (event) => { + const { type, audioData, modelId } = event.data; + if (type !== "transcribe") return; + + try { + if (!transcriber || currentModelId !== modelId) { + postWorkerMessage({ type: "progress", status: "loading", progress: 0, message: "Loading AI model... 0%" }); + + const { pipeline } = await import("https://cdn.jsdelivr.net/npm/@huggingface/transformers@3"); + + transcriber = await pipeline("automatic-speech-recognition", modelId, { + progress_callback: (data) => { + if (data.progress !== undefined) { + const pct = Math.round(data.progress); + postWorkerMessage({ type: "progress", status: "loading", progress: pct, message: "Loading AI model... " + pct + "%" }); + } + } + }); + currentModelId = modelId; + postWorkerMessage({ type: "progress", status: "loading", progress: 100, message: "AI model loaded" }); + } + + postWorkerMessage({ type: "progress", status: "transcribing", progress: 0, message: "Transcribing audio..." }); + + const result = await transcriber(audioData, { + return_timestamps: "word", + chunk_length_s: 30, + stride_length_s: 5 + }); + + postWorkerMessage({ type: "complete", chunks: result.chunks || [] }); + } catch (error) { + postWorkerMessage({ type: "error", message: error.message || "Transcription failed" }); + } +}; +`; diff --git a/src/core/captions/transcription.worker.ts b/src/core/captions/transcription.worker.ts deleted file mode 100644 index fdbc8975..00000000 --- a/src/core/captions/transcription.worker.ts +++ /dev/null @@ -1,101 +0,0 @@ -interface TranscribeMessage { - type: "transcribe"; - audioData: Float32Array; - modelId: string; -} - -interface ProgressMessage { - type: "progress"; - status: "loading" | "transcribing"; - progress: number; - message: string; -} - -interface CompleteMessage { - type: "complete"; - chunks: Array<{ text: string; timestamp: [number, number] }>; -} - -interface ErrorMessage { - type: "error"; - message: string; -} - -type WorkerMessage = ProgressMessage | CompleteMessage | ErrorMessage; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -let transcriber: any = null; -let currentModelId: string | null = null; - -const MODEL_LOADING_WEIGHT = 0.4; // 0-40% - -function postWorkerMessage(message: WorkerMessage): void { - self.postMessage(message); -} - -self.onmessage = async (event: MessageEvent) => { - const { type, audioData, modelId } = event.data; - - if (type !== "transcribe") { - return; - } - - try { - if (!transcriber || currentModelId !== modelId) { - postWorkerMessage({ - type: "progress", - status: "loading", - progress: 0, - message: "Loading AI model..." - }); - - const { pipeline } = await import("@huggingface/transformers"); - - transcriber = await pipeline("automatic-speech-recognition", modelId, { - progress_callback: (data: { progress?: number; status?: string }) => { - if (data.progress !== undefined) { - const scaledProgress = Math.round(data.progress * MODEL_LOADING_WEIGHT * 100); - postWorkerMessage({ - type: "progress", - status: "loading", - progress: scaledProgress, - message: `Loading AI model... ${scaledProgress}%` - }); - } - } - }); - - currentModelId = modelId; - - postWorkerMessage({ - type: "progress", - status: "loading", - progress: Math.round(MODEL_LOADING_WEIGHT * 100), - message: "AI model loaded" - }); - } - - postWorkerMessage({ - type: "progress", - status: "transcribing", - progress: Math.round(MODEL_LOADING_WEIGHT * 100), - message: "Transcribing audio..." - }); - - const result = await transcriber(audioData, { - return_timestamps: "word", - chunk_length_s: 30, - stride_length_s: 5 - }); - - postWorkerMessage({ - type: "complete", - chunks: (result as { chunks?: Array<{ text: string; timestamp: [number, number] }> }).chunks ?? [] - }); - } catch (error) { - postWorkerMessage({ - type: "error", - message: error instanceof Error ? error.message : "Transcription failed" - }); - } -}; From c80ce5b2edd1de0d452ca7738f8e6caa8206f040 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 8 Dec 2025 10:44:46 +1100 Subject: [PATCH 049/463] fix: update loadParser to parser and crossovern to crossorigin in asset configurations --- src/components/canvas/players/audio-player.ts | 2 +- src/components/canvas/players/caption-player.ts | 4 ++-- src/components/canvas/players/image-player.ts | 2 +- src/core/edit.ts | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index 06c922bb..b5d19e68 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -41,7 +41,7 @@ export class AudioPlayer extends Player { const audioClipConfiguration = this.clipConfiguration.asset as AudioAsset; const identifier = audioClipConfiguration.src; - const loadOptions: pixi.UnresolvedAsset = { src: identifier, loadParser: AudioLoadParser.Name }; + const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: AudioLoadParser.Name }; const audioResource = await this.edit.assetLoader.load(identifier, loadOptions); const isValidAudioSource = audioResource instanceof howler.Howl; diff --git a/src/components/canvas/players/caption-player.ts b/src/components/canvas/players/caption-player.ts index a0f17012..138c7166 100644 --- a/src/components/canvas/players/caption-player.ts +++ b/src/components/canvas/players/caption-player.ts @@ -81,7 +81,7 @@ export class CaptionPlayer extends Player { const loadOptions: pixi.UnresolvedAsset = { src: result.vttUrl, - loadParser: SubtitleLoadParser.Name + parser: SubtitleLoadParser.Name }; const subtitle = await this.edit.assetLoader.load(result.vttUrl, loadOptions); @@ -174,7 +174,7 @@ export class CaptionPlayer extends Player { try { const loadOptions: pixi.UnresolvedAsset = { src, - loadParser: SubtitleLoadParser.Name + parser: SubtitleLoadParser.Name }; const subtitle = await this.edit.assetLoader.load(src, loadOptions); diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index 25e03eea..3fd4e0d7 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -27,7 +27,7 @@ export class ImagePlayer extends Player { const identifier = imageAsset.src; const loadOptions: pixi.UnresolvedAsset = { src: identifier, - crossovern: "anonymous", + crossorigin: "anonymous", data: {} }; const texture = await this.edit.assetLoader.load>(identifier, loadOptions); diff --git a/src/core/edit.ts b/src/core/edit.ts index 7a7fd11b..a41a820a 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -242,7 +242,7 @@ export class Edit extends Entity { await Promise.all( (this.edit.timeline.fonts ?? []).map(async font => { const identifier = font.src; - const loadOptions: pixi.UnresolvedAsset = { src: identifier, loadParser: FontLoadParser.Name }; + const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: FontLoadParser.Name }; return this.assetLoader.load(identifier, loadOptions); }) From 079376515559b70499f70e0d1abd9b5155dd08ba Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 8 Dec 2025 10:56:50 +1100 Subject: [PATCH 050/463] feat: add quantization and explicit task parameter to speech recognition pipeline --- src/core/captions/transcription-worker-code.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/captions/transcription-worker-code.ts b/src/core/captions/transcription-worker-code.ts index 3d7f6f63..53e7c787 100644 --- a/src/core/captions/transcription-worker-code.ts +++ b/src/core/captions/transcription-worker-code.ts @@ -22,6 +22,10 @@ self.onmessage = async (event) => { const { pipeline } = await import("https://cdn.jsdelivr.net/npm/@huggingface/transformers@3"); transcriber = await pipeline("automatic-speech-recognition", modelId, { + dtype: { + encoder_model: "q8", + decoder_model_merged: "q8", + }, progress_callback: (data) => { if (data.progress !== undefined) { const pct = Math.round(data.progress); @@ -36,6 +40,7 @@ self.onmessage = async (event) => { postWorkerMessage({ type: "progress", status: "transcribing", progress: 0, message: "Transcribing audio..." }); const result = await transcriber(audioData, { + task: "transcribe", return_timestamps: "word", chunk_length_s: 30, stride_length_s: 5 From 73002307322f8569e2ac623d92f1bc0844fb6aa3 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 8 Dec 2025 11:23:53 +1100 Subject: [PATCH 051/463] fix: rate-limit video sync checks to prevent audio stuttering --- src/components/canvas/players/video-player.ts | 17 ++++++++++++----- src/components/canvas/shotstack-canvas.ts | 5 +---- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 83361f3f..dc04cb99 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -16,6 +16,7 @@ export class VideoPlayer extends Player { private volumeKeyframeBuilder: KeyframeBuilder; private syncTimer: number; + private activeSyncTimer: number; private skipVideoUpdate: boolean; constructor(edit: Edit, clipConfiguration: ResolvedClip) { @@ -30,6 +31,7 @@ export class VideoPlayer extends Player { this.volumeKeyframeBuilder = new KeyframeBuilder(videoAsset.volume ?? 1, this.getLength()); this.syncTimer = 0; + this.activeSyncTimer = 0; this.skipVideoUpdate = false; } @@ -92,6 +94,7 @@ export class VideoPlayer extends Player { if (shouldClipPlay) { if (!this.isPlaying) { this.isPlaying = true; + this.activeSyncTimer = 0; this.texture.source.resource.currentTime = playbackTime / 1000 + trim; this.texture.source.resource.play().catch(console.error); } @@ -100,11 +103,15 @@ export class VideoPlayer extends Player { this.texture.source.resource.volume = this.getVolume(); } - const desyncThreshold = 100; - const shouldSync = Math.abs((this.texture.source.resource.currentTime - trim) * 1000 - playbackTime) > desyncThreshold; - - if (shouldSync) { - this.texture.source.resource.currentTime = playbackTime / 1000 + trim; + // Rate-limit sync checks to once per second to prevent audio stuttering + this.activeSyncTimer += elapsed; + if (this.activeSyncTimer > 1000) { + this.activeSyncTimer = 0; + const desyncThreshold = 300; + const drift = Math.abs((this.texture.source.resource.currentTime - trim) * 1000 - playbackTime); + if (drift > desyncThreshold) { + this.texture.source.resource.currentTime = playbackTime / 1000 + trim; + } } } diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 5a3286ce..ece56ddd 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -233,10 +233,7 @@ export class Canvas { this.edit.events.on("transcription:progress", (payload: { message?: string }) => { const message = payload.message ?? "Transcribing..."; this.transcriptionIndicator.show(message); - this.transcriptionIndicator.setPosition( - this.viewportSize.width - this.transcriptionIndicator.getWidth() - 10, - 10 - ); + this.transcriptionIndicator.setPosition(this.viewportSize.width - this.transcriptionIndicator.getWidth() - 10, 10); }); this.edit.events.on("transcription:complete", () => { From b85b5f93a068c34274fc49b730d0ca6986caebb9 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 8 Dec 2025 15:03:13 +1100 Subject: [PATCH 052/463] style: reformat code for consistency --- src/components/canvas/players/audio-player.ts | 6 +----- src/components/canvas/players/caption-player.ts | 12 +++--------- src/components/canvas/players/player.ts | 7 +++---- src/components/canvas/players/rich-text-player.ts | 13 ++++--------- src/components/canvas/players/video-player.ts | 3 +-- src/components/canvas/shotstack-canvas.ts | 2 +- src/core/captions/parser.ts | 1 - src/core/captions/transcription-service.ts | 7 +++++-- src/core/loaders/subtitle-load-parser.ts | 2 +- src/core/schemas/caption-asset.ts | 4 +--- src/core/schemas/rich-text-asset.ts | 2 +- src/core/ui/loading-overlay.ts | 3 +-- 12 files changed, 22 insertions(+), 40 deletions(-) diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index b5d19e68..49bff490 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -27,11 +27,7 @@ export class AudioPlayer extends Player { const audioAsset = clipConfiguration.asset as AudioAsset; const baseVolume = typeof audioAsset.volume === "number" ? audioAsset.volume : 1; - this.volumeKeyframeBuilder = new KeyframeBuilder( - this.createVolumeKeyframes(audioAsset, baseVolume), - this.getLength(), - baseVolume - ); + this.volumeKeyframeBuilder = new KeyframeBuilder(this.createVolumeKeyframes(audioAsset, baseVolume), this.getLength(), baseVolume); this.syncTimer = 0; } diff --git a/src/components/canvas/players/caption-player.ts b/src/components/canvas/players/caption-player.ts index 138c7166..c8d8ea1f 100644 --- a/src/components/canvas/players/caption-player.ts +++ b/src/components/canvas/players/caption-player.ts @@ -1,14 +1,8 @@ import { Player } from "@canvas/players/player"; -import { - type Cue, - findActiveCue, - isAliasReference, - resolveTranscriptionAlias, - revokeVttUrl -} from "@core/captions"; +import { type Cue, findActiveCue, isAliasReference, resolveTranscriptionAlias, revokeVttUrl } from "@core/captions"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; -import { SubtitleLoadParser, type SubtitleAsset } from "@loaders/subtitle-load-parser"; import { type Size, type Vector } from "@layouts/geometry"; +import { SubtitleLoadParser, type SubtitleAsset } from "@loaders/subtitle-load-parser"; import { type CaptionAsset } from "@schemas/caption-asset"; import * as pixiFilters from "pixi-filters"; import * as pixi from "pixi.js"; @@ -70,7 +64,7 @@ export class CaptionPlayer extends Player { throw new Error("Cannot resolve alias: edit not loaded"); } - const result = await resolveTranscriptionAlias(src, originalEdit, (progress) => { + const result = await resolveTranscriptionAlias(src, originalEdit, progress => { this.edit.events.emit("transcription:progress", { clipAlias: this.clipConfiguration.alias, ...progress diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 5115bf81..ad520864 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -46,9 +46,7 @@ export abstract class Player extends Entity { // Double-headed arrow for resize cursor private static readonly ResizeCursorPath = - "M1320,2186L1085,2421L1120,2457L975,2496" + - "L1014,2351L1050,2386L1285,2151L1250,2115" + - "L1396,2075L1356,2221L1320,2186Z"; + "M1320,2186L1085,2421L1120,2457L975,2496L1014,2351L1050,2386L1285,2151L1250,2115L1396,2075L1356,2221L1320,2186Z"; private static readonly ResizeCursorMatrix = "matrix(0.807871,0.707107,-0.807871,0.707107,2111.872433,-206.020386)"; // Base angles for cursors (before clip rotation is applied) @@ -80,7 +78,8 @@ export abstract class Player extends Entity { private static buildResizeCursor(angleDeg: number): string { const path = Player.ResizeCursorPath; const matrix = Player.ResizeCursorMatrix; - const svg = `` + + const svg = + `` + `` + ``; return `url("data:image/svg+xml,${encodeURIComponent(svg)}") 12 12, auto`; diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 3517fae4..7395292c 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -35,18 +35,15 @@ export class RichTextPlayer extends Player { super(edit, config); } - private buildCanvasPayload( - richTextAsset: RichTextAsset, - fontInfo?: { baseFontFamily: string; fontWeight: number } - ): any { + private buildCanvasPayload(richTextAsset: RichTextAsset, fontInfo?: { baseFontFamily: string; fontWeight: number }): any { const editData = this.edit.getEdit(); const width = this.clipConfiguration.width || editData?.output?.size?.width || this.edit.size.width; const height = this.clipConfiguration.height || editData?.output?.size?.height || this.edit.size.height; // Use provided font info or parse fresh (for reconfigure/updateTextContent calls) const requestedFamily = richTextAsset.font?.family; - const { baseFontFamily, fontWeight } = fontInfo - ?? (requestedFamily ? parseFontFamily(requestedFamily) : { baseFontFamily: requestedFamily, fontWeight: 400 }); + const { baseFontFamily, fontWeight } = + fontInfo ?? (requestedFamily ? parseFontFamily(requestedFamily) : { baseFontFamily: requestedFamily, fontWeight: 400 }); // Find matching timeline font for customFonts payload const timelineFonts = editData?.timeline?.fonts || []; @@ -66,9 +63,7 @@ export class RichTextPlayer extends Player { ...richTextAsset, width, height, - font: richTextAsset.font - ? { ...richTextAsset.font, family: baseFontFamily, weight: fontWeight } - : undefined, + font: richTextAsset.font ? { ...richTextAsset.font, family: baseFontFamily, weight: fontWeight } : undefined, stroke: richTextAsset.font?.stroke, ...(customFonts && { customFonts }) }; diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index dc04cb99..1de960f2 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -44,8 +44,7 @@ export class VideoPlayer extends Player { if (identifier.endsWith(".mov")) { throw new Error( - `Video source '${videoAsset.src}' is not supported. ` + - `.mov files cannot be played in the browser. Please convert to .webm or .mp4 first.` + `Video source '${videoAsset.src}' is not supported. .mov files cannot be played in the browser. Please convert to .webm or .mp4 first.` ); } diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index ece56ddd..0c7b179b 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,6 +1,6 @@ import { Inspector } from "@canvas/system/inspector"; -import { TranscriptionIndicator } from "@core/ui/transcription-indicator"; import { Edit } from "@core/edit"; +import { TranscriptionIndicator } from "@core/ui/transcription-indicator"; import { type Size } from "@layouts/geometry"; import { AudioLoadParser } from "@loaders/audio-load-parser"; import { FontLoadParser } from "@loaders/font-load-parser"; diff --git a/src/core/captions/parser.ts b/src/core/captions/parser.ts index 94d43600..9dded7bd 100644 --- a/src/core/captions/parser.ts +++ b/src/core/captions/parser.ts @@ -1,4 +1,3 @@ - export interface Cue { start: number; end: number; diff --git a/src/core/captions/transcription-service.ts b/src/core/captions/transcription-service.ts index 66a48c57..1926bc75 100644 --- a/src/core/captions/transcription-service.ts +++ b/src/core/captions/transcription-service.ts @@ -75,7 +75,7 @@ export class TranscriptionService { } this.worker.onmessage = (event: MessageEvent) => { - const data = event.data; + const {data} = event; switch (data.type) { case "progress": @@ -108,6 +108,9 @@ export class TranscriptionService { }); reject(new Error(data.message)); break; + + default: + break; } }; @@ -147,7 +150,7 @@ export class TranscriptionService { const left = audioBuffer.getChannelData(0); const right = audioBuffer.getChannelData(1); audioData = new Float32Array(left.length); - for (let i = 0; i < left.length; i++) { + for (let i = 0; i < left.length; i += 1) { audioData[i] = (left[i] + right[i]) / 2; } } else { diff --git a/src/core/loaders/subtitle-load-parser.ts b/src/core/loaders/subtitle-load-parser.ts index d671f558..8d41bbab 100644 --- a/src/core/loaders/subtitle-load-parser.ts +++ b/src/core/loaders/subtitle-load-parser.ts @@ -1,6 +1,6 @@ +import { type Cue, parseSubtitle } from "@core/captions"; import * as pixi from "pixi.js"; -import { type Cue, parseSubtitle } from "@core/captions"; export interface SubtitleAsset { content: string; diff --git a/src/core/schemas/caption-asset.ts b/src/core/schemas/caption-asset.ts index 58d76ed6..5d2cd416 100644 --- a/src/core/schemas/caption-asset.ts +++ b/src/core/schemas/caption-asset.ts @@ -1,8 +1,6 @@ import * as zod from "zod"; -export const CaptionAssetColorSchema = zod - .string() - .regex(/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|transparent$/, "Invalid color format."); +export const CaptionAssetColorSchema = zod.string().regex(/^#([A-Fa-f0-9]{8}|[A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})|transparent$/, "Invalid color format."); export const CaptionAssetFontSchema = zod .object({ diff --git a/src/core/schemas/rich-text-asset.ts b/src/core/schemas/rich-text-asset.ts index d0c5a746..fc18e0b3 100644 --- a/src/core/schemas/rich-text-asset.ts +++ b/src/core/schemas/rich-text-asset.ts @@ -62,7 +62,7 @@ const RichTextBorderSchema = zod width: zod.number().min(0).default(0), color: HexColorSchema.default("#000000"), opacity: zod.number().min(0).max(1).default(1), - radius: zod.number().min(0).default(0), + radius: zod.number().min(0).default(0) }) .strict(); diff --git a/src/core/ui/loading-overlay.ts b/src/core/ui/loading-overlay.ts index c130fa91..2aea3b17 100644 --- a/src/core/ui/loading-overlay.ts +++ b/src/core/ui/loading-overlay.ts @@ -5,8 +5,7 @@ export class LoadingOverlay { show(): void { this.overlay = document.createElement("div"); - this.overlay.style.cssText = - "position:fixed;inset:0;z-index:9999;background:#0a0a0a;display:flex;justify-content:center;align-items:center"; + this.overlay.style.cssText = "position:fixed;inset:0;z-index:9999;background:#0a0a0a;display:flex;justify-content:center;align-items:center"; this.overlay.innerHTML = `
From 74c58d409f9205541bab18fad5af3e88942f68ae Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Mon, 8 Dec 2025 20:05:42 +1100 Subject: [PATCH 053/463] fix: adjust caption vertical positioning to 90% of container height --- src/components/canvas/players/caption-player.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/canvas/players/caption-player.ts b/src/components/canvas/players/caption-player.ts index c8d8ea1f..7e3c6ead 100644 --- a/src/components/canvas/players/caption-player.ts +++ b/src/components/canvas/players/caption-player.ts @@ -234,7 +234,7 @@ export class CaptionPlayer extends Player { textX = containerWidth - this.text.width - padding; } - let textY = containerHeight - this.text.height - padding; + let textY = containerHeight * 0.9; if (verticalAlign === "top") { textY = padding; } else if (verticalAlign === "center") { From 03331d17468b2cbf0712dc4f208d764718c3122c Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 09:16:39 +1100 Subject: [PATCH 054/463] feat: add rich text toolbar with font and styling controls --- .../canvas/players/rich-text-player.ts | 107 +- src/components/canvas/shotstack-canvas.ts | 28 + src/core/ui/rich-text-toolbar.css.ts | 175 ++++ src/core/ui/rich-text-toolbar.ts | 932 ++++++++++++++++++ 4 files changed, 1221 insertions(+), 21 deletions(-) create mode 100644 src/core/ui/rich-text-toolbar.css.ts create mode 100644 src/core/ui/rich-text-toolbar.ts diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 7395292c..ded1bfea 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -4,6 +4,7 @@ import { type Size, type Vector } from "@layouts/geometry"; import { RichTextAssetSchema, type RichTextAsset } from "@schemas/rich-text-asset"; import { createTextEngine } from "@shotstack/shotstack-canvas"; import { TextEngine, TextRenderer, ValidatedRichTextAsset } from "@timeline/types"; +import * as opentype from "opentype.js"; import * as pixi from "pixi.js"; const extractFontNames = (url: string): { full: string; base: string } => { @@ -28,6 +29,7 @@ export class RichTextPlayer extends Player { private isRendering: boolean = false; private targetFPS: number = 30; private validatedAsset: ValidatedRichTextAsset | null = null; + private fontSupportsBold: boolean = false; constructor(edit: any, clipConfiguration: any) { // Default fit to "cover" for rich-text assets if not provided @@ -42,9 +44,15 @@ export class RichTextPlayer extends Player { // Use provided font info or parse fresh (for reconfigure/updateTextContent calls) const requestedFamily = richTextAsset.font?.family; - const { baseFontFamily, fontWeight } = + const { baseFontFamily, fontWeight: parsedWeight } = fontInfo ?? (requestedFamily ? parseFontFamily(requestedFamily) : { baseFontFamily: requestedFamily, fontWeight: 400 }); + // Use explicit font.weight if set, otherwise fall back to parsed weight from family name + const explicitWeight = richTextAsset.font?.weight; + const fontWeight = explicitWeight + ? (typeof explicitWeight === "string" ? parseInt(explicitWeight, 10) || parsedWeight : explicitWeight) + : parsedWeight; + // Find matching timeline font for customFonts payload const timelineFonts = editData?.timeline?.fonts || []; const matchingFont = requestedFamily @@ -89,8 +97,65 @@ export class RichTextPlayer extends Player { } } + private async checkFontCapabilities(fontUrl: string): Promise { + try { + const response = await fetch(fontUrl); + const buffer = await response.arrayBuffer(); + const font = opentype.parse(buffer); + + // Check for fvar table (variable font) with weight axis + const fvar = font.tables["fvar"] as { axes?: Array<{ tag: string }> } | undefined; + if (fvar?.axes) { + const weightAxis = fvar.axes.find(axis => axis.tag === "wght"); + this.fontSupportsBold = !!weightAxis; + } else { + this.fontSupportsBold = false; + } + } catch (error) { + console.warn("Failed to check font capabilities:", error); + this.fontSupportsBold = false; + } + } + + public supportsBold(): boolean { + return this.fontSupportsBold; + } + + private resolveFont(family: string): { url: string; baseFontFamily: string; fontWeight: number } | null { + const { baseFontFamily, fontWeight } = parseFontFamily(family); + const editData = this.edit.getEdit(); + const timelineFonts = editData?.timeline?.fonts || []; + + const matchingFont = timelineFonts.find(font => { + const { full, base } = extractFontNames(font.src); + const requested = family.toLowerCase(); + return full.toLowerCase() === requested || base.toLowerCase() === requested; + }); + + if (matchingFont) { + return { url: matchingFont.src, baseFontFamily, fontWeight }; + } + + const builtInPath = resolveFontPath(family); + if (builtInPath) { + return { url: builtInPath, baseFontFamily, fontWeight }; + } + + return null; + } + public override reconfigureAfterRestore(): void { super.reconfigureAfterRestore(); + this.reconfigure(this.clipConfiguration.asset as RichTextAsset); + } + + private async reconfigure(richTextAsset: RichTextAsset): Promise { + const fontUrl = await this.ensureFontRegistered(richTextAsset); + + if (fontUrl) { + await this.checkFontCapabilities(fontUrl); + this.edit.events.emit("font:capabilities:changed", { supportsBold: this.fontSupportsBold }); + } for (const texture of this.cachedFrames.values()) { texture.destroy(); @@ -98,7 +163,6 @@ export class RichTextPlayer extends Player { this.cachedFrames.clear(); this.lastRenderedTime = -1; - const richTextAsset = this.clipConfiguration.asset as RichTextAsset; if (this.textEngine) { const canvasPayload = this.buildCanvasPayload(richTextAsset); const { value: validated } = this.textEngine.validate(canvasPayload); @@ -110,6 +174,19 @@ export class RichTextPlayer extends Player { } } + private async ensureFontRegistered(richTextAsset: RichTextAsset): Promise { + if (!this.textEngine) return null; + + const family = richTextAsset.font?.family; + if (!family) return null; + + const resolved = this.resolveFont(family); + if (!resolved) return null; + + await this.registerFont(resolved.baseFontFamily, resolved.fontWeight, { type: "url", path: resolved.url }); + return resolved.url; + } + public override async load(): Promise { await super.load(); @@ -147,26 +224,14 @@ export class RichTextPlayer extends Player { this.renderer = this.textEngine!.createRenderer(this.canvas); - // Register font: try timeline fonts first, then built-in fonts - if (fontInfo && requestedFamily) { - const { baseFontFamily, fontWeight } = fontInfo; - const timelineFonts = editData?.timeline?.fonts || []; - - const matchingFont = timelineFonts.find(font => { - const { full, base } = extractFontNames(font.src); - const requested = requestedFamily.toLowerCase(); - return full.toLowerCase() === requested || base.toLowerCase() === requested; - }); - - if (matchingFont) { - await this.registerFont(baseFontFamily, fontWeight, { type: "url", path: matchingFont.src }); + // Register font and check capabilities + if (requestedFamily) { + const resolved = this.resolveFont(requestedFamily); + if (resolved) { + await this.registerFont(resolved.baseFontFamily, resolved.fontWeight, { type: "url", path: resolved.url }); + await this.checkFontCapabilities(resolved.url); } else { - const fontPath = resolveFontPath(requestedFamily); - if (fontPath) { - await this.registerFont(baseFontFamily, fontWeight, { type: "file", path: fontPath }); - } else { - console.warn(`Font ${requestedFamily} not found. Available:`, Object.keys(FONT_PATHS)); - } + console.warn(`Font ${requestedFamily} not found. Available:`, Object.keys(FONT_PATHS)); } } diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 0c7b179b..a2fa884f 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,5 +1,6 @@ import { Inspector } from "@canvas/system/inspector"; import { Edit } from "@core/edit"; +import { RichTextToolbar } from "@core/ui/rich-text-toolbar"; import { TranscriptionIndicator } from "@core/ui/transcription-indicator"; import { type Size } from "@layouts/geometry"; import { AudioLoadParser } from "@loaders/audio-load-parser"; @@ -22,6 +23,7 @@ export class Canvas { private readonly edit: Edit; private readonly inspector: Inspector; private readonly transcriptionIndicator: TranscriptionIndicator; + private readonly richTextToolbar: RichTextToolbar; private container?: pixi.Container; private background?: pixi.Graphics; @@ -39,6 +41,7 @@ export class Canvas { this.edit = edit; this.inspector = new Inspector(); this.transcriptionIndicator = new TranscriptionIndicator(); + this.richTextToolbar = new RichTextToolbar(edit); this.onTickBound = this.onTick.bind(this); this.onBackgroundClickBound = this.onBackgroundClick.bind(this); @@ -70,6 +73,9 @@ export class Canvas { this.zoomToFit(); root.appendChild(this.application.canvas); + + this.richTextToolbar.mount(root); + this.setupRichTextToolbarListeners(); } private setupTouchHandling(root: HTMLDivElement): void { @@ -78,6 +84,12 @@ export class Canvas { root.addEventListener( "wheel", (e: WheelEvent) => { + // Allow scrolling in toolbar popups + const target = e.target as HTMLElement; + if (target.closest(".ss-toolbar-popup")) { + return; + } + e.preventDefault(); e.stopPropagation(); @@ -245,6 +257,21 @@ export class Canvas { }); } + private setupRichTextToolbarListeners(): void { + this.edit.events.on("clip:selected", ({ trackIndex, clipIndex }) => { + const player = this.edit.getPlayerClip(trackIndex, clipIndex); + if (player?.clipConfiguration.asset.type === "rich-text") { + this.richTextToolbar.show(trackIndex, clipIndex); + } else { + this.richTextToolbar.hide(); + } + }); + + this.edit.events.on("selection:cleared", () => { + this.richTextToolbar.hide(); + }); + } + private onBackgroundClick(event: pixi.FederatedPointerEvent): void { if (event.target === this.background) { this.edit.events.emit("canvas:background:clicked", {}); @@ -273,6 +300,7 @@ export class Canvas { this.inspector.dispose(); this.transcriptionIndicator.dispose(); + this.richTextToolbar.dispose(); this.application.destroy(true, { children: true, texture: true }); } diff --git a/src/core/ui/rich-text-toolbar.css.ts b/src/core/ui/rich-text-toolbar.css.ts new file mode 100644 index 00000000..85c54aa0 --- /dev/null +++ b/src/core/ui/rich-text-toolbar.css.ts @@ -0,0 +1,175 @@ +export const TOOLBAR_STYLES = ` +.ss-toolbar { + display: none; + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(24, 24, 27, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 6px 8px; + gap: 2px; + z-index: 100; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + align-items: center; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.ss-toolbar-group { display: flex; align-items: center; gap: 1px; } +.ss-toolbar-group--bordered { background: rgba(255, 255, 255, 0.04); border-radius: 6px; padding: 2px; } +.ss-toolbar-divider { width: 1px; height: 20px; background: rgba(255, 255, 255, 0.1); margin: 0 6px; } + +.ss-toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: none; + border-radius: 6px; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} +.ss-toolbar-btn:hover { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.95); } +.ss-toolbar-btn.active { background: rgba(255, 255, 255, 0.15); color: #fff; } +.ss-toolbar-btn--text { font-size: 13px; font-weight: 600; letter-spacing: -0.01em; } +.ss-toolbar-btn--underline { text-decoration: underline; text-underline-offset: 2px; } + +.ss-toolbar-value { min-width: 32px; text-align: center; font-size: 12px; font-weight: 500; color: rgba(255, 255, 255, 0.9); font-variant-numeric: tabular-nums; letter-spacing: -0.02em; } + +.ss-toolbar-color-wrap { position: relative; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; } +.ss-toolbar-color { width: 22px; height: 22px; padding: 0; border: none; border-radius: 50%; cursor: pointer; background: transparent; } +.ss-toolbar-color::-webkit-color-swatch-wrapper { padding: 0; } +.ss-toolbar-color::-webkit-color-swatch { border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); } +.ss-toolbar-color::-moz-color-swatch { border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; } + +.ss-toolbar-dropdown { position: relative; } + +.ss-toolbar-popup { + display: none; + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: rgba(32, 32, 36, 0.98); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 14px 16px; + min-width: 200px; + z-index: 200; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2); +} +.ss-toolbar-popup::before { + content: ""; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: rgba(32, 32, 36, 0.98); + border-left: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +.ss-toolbar-popup-header { font-size: 11px; font-weight: 600; color: rgba(255, 255, 255, 0.5); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; } +.ss-toolbar-popup-row { display: flex; align-items: center; gap: 12px; } +.ss-toolbar-popup-row--buttons { gap: 6px; } +.ss-toolbar-popup-value { min-width: 32px; text-align: right; font-size: 13px; font-weight: 500; color: rgba(255, 255, 255, 0.9); font-variant-numeric: tabular-nums; } +.ss-toolbar-popup--wide { min-width: 240px; } +.ss-toolbar-popup-section { margin-bottom: 16px; } +.ss-toolbar-popup-section:last-child { margin-bottom: 0; } +.ss-toolbar-popup-label { font-size: 13px; font-weight: 500; color: rgba(255, 255, 255, 0.9); margin-bottom: 8px; } +.ss-toolbar-popup-divider { height: 1px; background: rgba(255, 255, 255, 0.1); margin: 16px 0; } + +.ss-toolbar-slider { + -webkit-appearance: none; + appearance: none; + flex: 1; + height: 4px; + background: transparent; + border-radius: 2px; + cursor: pointer; +} +.ss-toolbar-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; background: #fff; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); transition: transform 0.15s ease; margin-top: -6px; } +.ss-toolbar-slider::-webkit-slider-thumb:hover { transform: scale(1.1); } +.ss-toolbar-slider::-moz-range-thumb { width: 16px; height: 16px; background: #fff; border: none; border-radius: 50%; cursor: pointer; box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); } +.ss-toolbar-slider::-webkit-slider-runnable-track { height: 4px; background: rgba(255, 255, 255, 0.15); border-radius: 2px; } +.ss-toolbar-slider::-moz-range-track { height: 4px; background: rgba(255, 255, 255, 0.15); border-radius: 2px; } + +.ss-toolbar-anchor-btn { + flex: 1; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: rgba(255, 255, 255, 0.6); + cursor: pointer; + transition: all 0.15s ease; +} +.ss-toolbar-anchor-btn:hover { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.9); } +.ss-toolbar-anchor-btn.active { background: rgba(255, 255, 255, 0.18); border-color: rgba(255, 255, 255, 0.2); color: #fff; } + +.ss-toolbar-btn--font { width: auto; min-width: 48px; padding: 0 8px; gap: 4px; } +.ss-toolbar-font-preview { font-size: 13px; font-weight: 500; letter-spacing: -0.01em; max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ss-toolbar-chevron { opacity: 0.5; flex-shrink: 0; } + +.ss-toolbar-popup--font { min-width: 220px; max-height: 340px; overflow-y: auto; padding: 8px; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.2) transparent; } +.ss-toolbar-popup--font::-webkit-scrollbar { width: 6px; } +.ss-toolbar-popup--font::-webkit-scrollbar-track { background: transparent; } +.ss-toolbar-popup--font::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 3px; } +.ss-toolbar-popup--font::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } + +.ss-toolbar-font-section { margin-bottom: 8px; } +.ss-toolbar-font-section:last-child { margin-bottom: 0; } +.ss-toolbar-font-section-header { font-size: 10px; font-weight: 600; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.08em; padding: 6px 10px 8px; } +.ss-toolbar-font-item { display: flex; align-items: center; justify-content: space-between; padding: 10px 12px; border-radius: 8px; cursor: pointer; transition: all 0.12s ease; color: rgba(255, 255, 255, 0.85); font-size: 14px; } +.ss-toolbar-font-item:hover { background: rgba(255, 255, 255, 0.08); } +.ss-toolbar-font-item.active { background: rgba(255, 255, 255, 0.12); } +.ss-toolbar-font-item.active::after { content: ""; width: 6px; height: 6px; background: #fff; border-radius: 50%; flex-shrink: 0; margin-left: 8px; } +.ss-toolbar-font-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +.ss-toolbar-dropdown--size { position: relative; } +.ss-toolbar-size-input { width: 36px; text-align: center; font-size: 12px; font-weight: 500; color: rgba(255, 255, 255, 0.9); background: transparent; border: none; outline: none; font-variant-numeric: tabular-nums; cursor: pointer; padding: 4px 2px; border-radius: 4px; } +.ss-toolbar-size-input:hover { background: rgba(255, 255, 255, 0.08); } +.ss-toolbar-size-input:focus { background: rgba(255, 255, 255, 0.1); cursor: text; } + +.ss-toolbar-popup--size { min-width: 80px; max-height: 280px; overflow-y: auto; padding: 6px; } +.ss-toolbar-size-item { padding: 8px 12px; text-align: center; font-size: 13px; color: rgba(255, 255, 255, 0.85); border-radius: 6px; cursor: pointer; transition: background 0.12s ease; } +.ss-toolbar-size-item:hover { background: rgba(255, 255, 255, 0.08); } +.ss-toolbar-size-item.active { background: rgba(255, 255, 255, 0.12); } + +.ss-toolbar-btn--text-edit { width: auto; min-width: 56px; padding: 0 10px; gap: 6px; } +.ss-toolbar-btn--text-edit span { font-size: 12px; font-weight: 500; } + +.ss-toolbar-popup--text-edit { min-width: 280px; padding: 14px 16px; } +.ss-toolbar-text-area { + width: 100%; + min-height: 80px; + max-height: 200px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + padding: 10px 12px; + color: rgba(255, 255, 255, 0.9); + font-size: 14px; + font-family: inherit; + line-height: 1.5; + resize: vertical; + outline: none; + box-sizing: border-box; +} +.ss-toolbar-text-area:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); } +.ss-toolbar-text-area::placeholder { color: rgba(255, 255, 255, 0.4); } +`; diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts new file mode 100644 index 00000000..aeb7f90e --- /dev/null +++ b/src/core/ui/rich-text-toolbar.ts @@ -0,0 +1,932 @@ +import type { Edit } from "@core/edit"; +import { FONT_PATHS } from "@core/fonts/font-config"; +import type { ResolvedClip } from "@schemas/clip"; +import type { RichTextAsset } from "@schemas/rich-text-asset"; + +import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; + +/** Built-in font families (base names only, without weight variants) */ +const BUILT_IN_FONTS = [ + "Arapey", + "Clear Sans", + "Didact Gothic", + "Montserrat", + "MovLette", + "Open Sans", + "Permanent Marker", + "Roboto", + "Sue Ellen Francisco", + "Work Sans" +]; + +/** Preset font sizes for the dropdown */ +const FONT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 96, 128]; + +export class RichTextToolbar { + private container: HTMLDivElement | null = null; + private edit: Edit; + private selectedTrackIdx = -1; + private selectedClipIdx = -1; + + private fontBtn: HTMLButtonElement | null = null; + private fontPopup: HTMLDivElement | null = null; + private fontPreview: HTMLSpanElement | null = null; + private sizeInput: HTMLInputElement | null = null; + private sizePopup: HTMLDivElement | null = null; + private boldBtn: HTMLButtonElement | null = null; + private colorInput: HTMLInputElement | null = null; + private opacityBtn: HTMLButtonElement | null = null; + private opacityPopup: HTMLDivElement | null = null; + private opacitySlider: HTMLInputElement | null = null; + private opacityValue: HTMLSpanElement | null = null; + private spacingBtn: HTMLButtonElement | null = null; + private spacingPopup: HTMLDivElement | null = null; + private letterSpacingSlider: HTMLInputElement | null = null; + private letterSpacingValue: HTMLSpanElement | null = null; + private lineHeightSlider: HTMLInputElement | null = null; + private lineHeightValue: HTMLSpanElement | null = null; + private anchorTopBtn: HTMLButtonElement | null = null; + private anchorMiddleBtn: HTMLButtonElement | null = null; + private anchorBottomBtn: HTMLButtonElement | null = null; + private alignBtn: HTMLButtonElement | null = null; + private alignIcon: SVGElement | null = null; + private transformBtn: HTMLButtonElement | null = null; + private underlineBtn: HTMLButtonElement | null = null; + private textEditBtn: HTMLButtonElement | null = null; + private textEditPopup: HTMLDivElement | null = null; + private textEditArea: HTMLTextAreaElement | null = null; + private textEditDebounceTimer: ReturnType | null = null; + private borderBtn: HTMLButtonElement | null = null; + private borderPopup: HTMLDivElement | null = null; + private borderWidthSlider: HTMLInputElement | null = null; + private borderWidthValue: HTMLSpanElement | null = null; + private borderColorInput: HTMLInputElement | null = null; + private borderOpacitySlider: HTMLInputElement | null = null; + private borderOpacityValue: HTMLSpanElement | null = null; + private borderRadiusSlider: HTMLInputElement | null = null; + private borderRadiusValue: HTMLSpanElement | null = null; + + private styleElement: HTMLStyleElement | null = null; + + constructor(edit: Edit) { + this.edit = edit; + } + + mount(parent: HTMLElement): void { + this.injectStyles(); + + this.container = document.createElement("div"); + this.container.className = "ss-toolbar"; + + this.container.innerHTML = ` +
+ +
+
Edit Text
+ +
+
+ +
+ +
+ +
+
+ +
+ + + +
+ +
+
+ +
+ +
+
+ +
+ +
+
Transparency
+
+ + 100 +
+
+
+ +
+ +
+
+
Letter spacing
+
+ + 0 +
+
+
+
Line spacing
+
+ + 1.2 +
+
+
+
+
Anchor text box
+
+ + + +
+
+
+
+ +
+ +
+
+
Width
+
+ + 0 +
+
+
+
Color & Opacity
+
+
+ +
+ + 100 +
+
+
+
Corner Rounding
+
+ + 0 +
+
+
+
+ +
+ + + + + + `; + + this.sizeInput = this.container.querySelector("[data-size-input]"); + this.sizePopup = this.container.querySelector("[data-size-popup]"); + this.boldBtn = this.container.querySelector("[data-action='bold']"); + this.fontBtn = this.container.querySelector("[data-action='font-toggle']"); + this.fontPopup = this.container.querySelector("[data-font-popup]"); + this.fontPreview = this.container.querySelector("[data-font-preview]"); + this.colorInput = this.container.querySelector("[data-action='color']"); + this.alignBtn = this.container.querySelector("[data-action='align-cycle']"); + this.alignIcon = this.container.querySelector("[data-align-icon]"); + this.transformBtn = this.container.querySelector("[data-action='transform']"); + this.underlineBtn = this.container.querySelector("[data-action='underline']"); + this.textEditBtn = this.container.querySelector("[data-action='text-edit-toggle']"); + this.textEditPopup = this.container.querySelector("[data-text-edit-popup]"); + this.textEditArea = this.container.querySelector("[data-text-edit-area]"); + + this.container.addEventListener("click", this.handleClick.bind(this)); + + // Size input handlers + this.sizeInput?.addEventListener("click", (e) => { + e.stopPropagation(); + this.toggleSizePopup(); + }); + this.sizeInput?.addEventListener("blur", () => this.applyManualSize()); + this.sizeInput?.addEventListener("keydown", (e) => { + if (e.key === "Enter") { + this.applyManualSize(); + this.sizeInput?.blur(); + if (this.sizePopup) this.sizePopup.style.display = "none"; + } + }); + this.buildSizePopup(); + + this.colorInput?.addEventListener("input", (e) => { + const color = (e.target as HTMLInputElement).value; + this.updateClipProperty({ font: { color } }); + }); + + this.opacityBtn = this.container.querySelector("[data-action='opacity-toggle']"); + this.opacityPopup = this.container.querySelector("[data-opacity-popup]"); + this.opacitySlider = this.container.querySelector("[data-opacity-slider]"); + this.opacityValue = this.container.querySelector("[data-opacity-value]"); + + this.opacitySlider?.addEventListener("input", (e) => { + const value = parseInt((e.target as HTMLInputElement).value, 10); + const opacity = value / 100; + if (this.opacityValue) { + this.opacityValue.textContent = String(value); + } + this.updateClipProperty({ font: { opacity } }); + }); + + this.spacingBtn = this.container.querySelector("[data-action='spacing-toggle']"); + this.spacingPopup = this.container.querySelector("[data-spacing-popup]"); + this.letterSpacingSlider = this.container.querySelector("[data-letter-spacing-slider]"); + this.letterSpacingValue = this.container.querySelector("[data-letter-spacing-value]"); + this.lineHeightSlider = this.container.querySelector("[data-line-height-slider]"); + this.lineHeightValue = this.container.querySelector("[data-line-height-value]"); + this.anchorTopBtn = this.container.querySelector("[data-action='anchor-top']"); + this.anchorMiddleBtn = this.container.querySelector("[data-action='anchor-middle']"); + this.anchorBottomBtn = this.container.querySelector("[data-action='anchor-bottom']"); + + this.letterSpacingSlider?.addEventListener("input", (e) => { + const value = parseInt((e.target as HTMLInputElement).value, 10); + if (this.letterSpacingValue) { + this.letterSpacingValue.textContent = String(value); + } + this.updateClipProperty({ style: { letterSpacing: value } }); + }); + + this.lineHeightSlider?.addEventListener("input", (e) => { + const value = parseFloat((e.target as HTMLInputElement).value) / 10; + if (this.lineHeightValue) { + this.lineHeightValue.textContent = value.toFixed(1); + } + this.updateClipProperty({ style: { lineHeight: value } }); + }); + + this.borderBtn = this.container.querySelector("[data-action='border-toggle']"); + this.borderPopup = this.container.querySelector("[data-border-popup]"); + this.borderWidthSlider = this.container.querySelector("[data-border-width-slider]"); + this.borderWidthValue = this.container.querySelector("[data-border-width-value]"); + this.borderColorInput = this.container.querySelector("[data-border-color]"); + this.borderOpacitySlider = this.container.querySelector("[data-border-opacity-slider]"); + this.borderOpacityValue = this.container.querySelector("[data-border-opacity-value]"); + this.borderRadiusSlider = this.container.querySelector("[data-border-radius-slider]"); + this.borderRadiusValue = this.container.querySelector("[data-border-radius-value]"); + + this.borderWidthSlider?.addEventListener("input", (e) => { + const width = parseInt((e.target as HTMLInputElement).value, 10); + if (this.borderWidthValue) { + this.borderWidthValue.textContent = String(width); + } + this.updateBorderProperty({ width }); + }); + + this.borderColorInput?.addEventListener("input", (e) => { + const color = (e.target as HTMLInputElement).value; + this.updateBorderProperty({ color }); + }); + + this.borderOpacitySlider?.addEventListener("input", (e) => { + const value = parseInt((e.target as HTMLInputElement).value, 10); + const opacity = value / 100; + if (this.borderOpacityValue) { + this.borderOpacityValue.textContent = String(value); + } + this.updateBorderProperty({ opacity }); + }); + + this.borderRadiusSlider?.addEventListener("input", (e) => { + const radius = parseInt((e.target as HTMLInputElement).value, 10); + if (this.borderRadiusValue) { + this.borderRadiusValue.textContent = String(radius); + } + this.updateBorderProperty({ radius }); + }); + + // Text edit area handlers + this.textEditArea?.addEventListener("input", () => this.debouncedApplyTextEdit()); + this.textEditArea?.addEventListener("keydown", (e) => { + // Apply on Ctrl/Cmd+Enter (allow normal Enter for newlines) + if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + this.textEditDebounceTimer = null; + } + this.applyTextEdit(); + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + } + // Close on Escape + if (e.key === "Escape") { + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + } + }); + + document.addEventListener("click", (e) => { + const target = e.target as Node; + if (this.sizePopup && this.sizePopup.style.display !== "none") { + if (!this.sizeInput?.contains(target) && !this.sizePopup.contains(target)) { + this.sizePopup.style.display = "none"; + } + } + if (this.opacityPopup && this.opacityPopup.style.display !== "none") { + if (!this.opacityBtn?.contains(target) && !this.opacityPopup.contains(target)) { + this.opacityPopup.style.display = "none"; + } + } + if (this.spacingPopup && this.spacingPopup.style.display !== "none") { + if (!this.spacingBtn?.contains(target) && !this.spacingPopup.contains(target)) { + this.spacingPopup.style.display = "none"; + } + } + if (this.borderPopup && this.borderPopup.style.display !== "none") { + if (!this.borderBtn?.contains(target) && !this.borderPopup.contains(target)) { + this.borderPopup.style.display = "none"; + } + } + if (this.fontPopup && this.fontPopup.style.display !== "none") { + if (!this.fontBtn?.contains(target) && !this.fontPopup.contains(target)) { + this.fontPopup.style.display = "none"; + } + } + if (this.textEditPopup && this.textEditPopup.style.display !== "none") { + if (!this.textEditBtn?.contains(target) && !this.textEditPopup.contains(target)) { + this.textEditPopup.style.display = "none"; + } + } + }); + + parent.style.position = "relative"; + parent.insertBefore(this.container, parent.firstChild); + + // Re-sync when font capabilities change (async operation) + this.edit.events.on("font:capabilities:changed", () => { + if (this.container?.style.display !== "none") { + this.syncState(); + } + }); + } + + private injectStyles(): void { + if (document.getElementById("ss-toolbar-styles")) return; + + this.styleElement = document.createElement("style"); + this.styleElement.id = "ss-toolbar-styles"; + this.styleElement.textContent = TOOLBAR_STYLES; + document.head.appendChild(this.styleElement); + } + + private handleClick(e: MouseEvent): void { + const target = e.target as HTMLElement; + const button = target.closest("button"); + if (!button) return; + + const action = button.dataset["action"]; + if (!action) return; + + const asset = this.getCurrentAsset(); + if (!asset) return; + + switch (action) { + case "size-down": + this.updateSize((asset.font?.size ?? 48) - 4); + break; + case "size-up": + this.updateSize((asset.font?.size ?? 48) + 4); + break; + case "bold": + this.toggleBold(asset); + break; + case "font-toggle": + this.toggleFontPopup(); + break; + case "text-edit-toggle": + this.toggleTextEditPopup(); + break; + case "opacity-toggle": + this.toggleOpacityPopup(); + break; + case "spacing-toggle": + this.toggleSpacingPopup(); + break; + case "border-toggle": + this.toggleBorderPopup(); + break; + case "anchor-top": + this.updateVerticalAlign("top"); + break; + case "anchor-middle": + this.updateVerticalAlign("middle"); + break; + case "anchor-bottom": + this.updateVerticalAlign("bottom"); + break; + case "align-cycle": + this.cycleAlignment(asset); + break; + case "transform": + this.cycleTransform(asset); + break; + case "underline": + this.toggleUnderline(asset); + break; + } + } + + private getCurrentAsset(): RichTextAsset | null { + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!player) return null; + return player.clipConfiguration.asset as RichTextAsset; + } + + private updateSize(newSize: number): void { + const clampedSize = Math.max(8, Math.min(500, newSize)); + this.updateClipProperty({ font: { size: clampedSize } }); + } + + private toggleBold(asset: RichTextAsset): void { + const currentWeight = String(asset.font?.weight ?? "400"); + const isBold = currentWeight === "700" || currentWeight === "bold"; + this.updateClipProperty({ font: { weight: isBold ? "400" : "700" } }); + } + + private toggleSizePopup(): void { + if (!this.sizePopup) return; + if (this.opacityPopup) this.opacityPopup.style.display = "none"; + if (this.spacingPopup) this.spacingPopup.style.display = "none"; + if (this.fontPopup) this.fontPopup.style.display = "none"; + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + if (this.borderPopup) this.borderPopup.style.display = "none"; + const isVisible = this.sizePopup.style.display !== "none"; + if (!isVisible) { + this.buildSizePopup(); + } + this.sizePopup.style.display = isVisible ? "none" : "block"; + } + + private buildSizePopup(): void { + if (!this.sizePopup) return; + const asset = this.getCurrentAsset(); + const currentSize = asset?.font?.size ?? 48; + + this.sizePopup.innerHTML = FONT_SIZES.map( + size => `
${size}
` + ).join(""); + + this.sizePopup.querySelectorAll("[data-size]").forEach(item => { + item.addEventListener("click", () => { + const size = parseInt((item as HTMLElement).dataset["size"]!, 10); + this.updateSize(size); + this.sizePopup!.style.display = "none"; + }); + }); + } + + private applyManualSize(): void { + if (!this.sizeInput) return; + const value = parseInt(this.sizeInput.value, 10); + if (!isNaN(value) && value > 0) { + this.updateSize(value); + } + this.syncState(); + } + + private toggleOpacityPopup(): void { + if (!this.opacityPopup) return; + if (this.sizePopup) this.sizePopup.style.display = "none"; + if (this.spacingPopup) this.spacingPopup.style.display = "none"; + if (this.fontPopup) this.fontPopup.style.display = "none"; + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + if (this.borderPopup) this.borderPopup.style.display = "none"; + const isVisible = this.opacityPopup.style.display !== "none"; + this.opacityPopup.style.display = isVisible ? "none" : "block"; + } + + private toggleSpacingPopup(): void { + if (!this.spacingPopup) return; + if (this.sizePopup) this.sizePopup.style.display = "none"; + if (this.opacityPopup) this.opacityPopup.style.display = "none"; + if (this.fontPopup) this.fontPopup.style.display = "none"; + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + if (this.borderPopup) this.borderPopup.style.display = "none"; + const isVisible = this.spacingPopup.style.display !== "none"; + this.spacingPopup.style.display = isVisible ? "none" : "block"; + } + + private toggleBorderPopup(): void { + if (!this.borderPopup) return; + if (this.sizePopup) this.sizePopup.style.display = "none"; + if (this.opacityPopup) this.opacityPopup.style.display = "none"; + if (this.fontPopup) this.fontPopup.style.display = "none"; + if (this.spacingPopup) this.spacingPopup.style.display = "none"; + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + const isVisible = this.borderPopup.style.display !== "none"; + this.borderPopup.style.display = isVisible ? "none" : "block"; + } + + private toggleFontPopup(): void { + if (!this.fontPopup) return; + if (this.sizePopup) this.sizePopup.style.display = "none"; + if (this.opacityPopup) this.opacityPopup.style.display = "none"; + if (this.spacingPopup) this.spacingPopup.style.display = "none"; + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + if (this.borderPopup) this.borderPopup.style.display = "none"; + const isVisible = this.fontPopup.style.display !== "none"; + if (!isVisible) { + this.buildFontList(); + } + this.fontPopup.style.display = isVisible ? "none" : "block"; + } + + private toggleTextEditPopup(): void { + if (!this.textEditPopup) return; + if (this.sizePopup) this.sizePopup.style.display = "none"; + if (this.fontPopup) this.fontPopup.style.display = "none"; + if (this.opacityPopup) this.opacityPopup.style.display = "none"; + if (this.spacingPopup) this.spacingPopup.style.display = "none"; + if (this.borderPopup) this.borderPopup.style.display = "none"; + + const isVisible = this.textEditPopup.style.display !== "none"; + if (!isVisible && this.textEditArea) { + const asset = this.getCurrentAsset(); + this.textEditArea.value = asset?.text ?? ""; + } + this.textEditPopup.style.display = isVisible ? "none" : "block"; + if (!isVisible) { + this.textEditArea?.focus(); + } + } + + private debouncedApplyTextEdit(): void { + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + } + this.textEditDebounceTimer = setTimeout(() => { + this.applyTextEdit(); + this.textEditDebounceTimer = null; + }, 150); + } + + private applyTextEdit(): void { + if (!this.textEditArea) return; + const newText = this.textEditArea.value; + this.updateClipProperty({ text: newText }); + } + + private buildFontList(): void { + if (!this.fontPopup) return; + + const asset = this.getCurrentAsset(); + const currentFont = asset?.font?.family ?? "Roboto"; + const customFonts = this.getCustomFonts(); + + let html = ""; + + if (customFonts.length > 0) { + html += `
+
Custom
+ ${customFonts.map(f => this.renderFontItem(f, currentFont)).join("")} +
`; + } + + html += `
+
Built-in
+ ${BUILT_IN_FONTS.map(f => this.renderFontItem(f, currentFont)).join("")} +
`; + + this.fontPopup.innerHTML = html; + + // Attach click handlers to font items + this.fontPopup.querySelectorAll("[data-font-family]").forEach(item => { + item.addEventListener("click", () => { + const family = (item as HTMLElement).dataset["fontFamily"]; + if (family) { + this.selectFont(family); + } + }); + }); + } + + private renderFontItem(fontFamily: string, currentFont: string): string { + const isActive = fontFamily === currentFont; + const displayName = this.getDisplayName(fontFamily); + return `
+ ${displayName} +
`; + } + + private getDisplayName(fontFamily: string): string { + // Clean up font names: "Oswald-VariableFont" → "Oswald" + return fontFamily.replace(/-VariableFont$/i, "").replace(/-/g, " "); + } + + private getCustomFonts(): string[] { + const edit = this.edit.getEdit(); + return (edit.timeline.fonts ?? []).map(f => { + const filename = f.src.split("/").pop() || ""; + return filename.replace(/\.(ttf|otf|woff2?)$/i, ""); + }); + } + + private selectFont(fontFamily: string): void { + this.updateClipProperty({ font: { family: fontFamily } }); + if (this.fontPopup) { + this.fontPopup.style.display = "none"; + } + } + + private updateVerticalAlign(align: "top" | "middle" | "bottom"): void { + this.updateClipProperty({ align: { vertical: align } }); + } + + private cycleAlignment(asset: RichTextAsset): void { + const current = asset.align?.horizontal ?? "center"; + const cycle: Array<"left" | "center" | "right"> = ["left", "center", "right"]; + const currentIdx = cycle.indexOf(current as "left" | "center" | "right"); + const nextIdx = (currentIdx + 1) % cycle.length; + this.updateAlignment(cycle[nextIdx]); + } + + private updateAlignment(align: "left" | "center" | "right"): void { + this.updateClipProperty({ align: { horizontal: align } }); + this.updateAlignIcon(align); + } + + private updateAlignIcon(align: "left" | "center" | "right"): void { + if (!this.alignIcon) return; + const paths: Record = { + left: "M3 5h18v2H3V5zm0 4h12v2H3V9zm0 4h18v2H3v-2zm0 4h12v2H3v-2z", + center: "M3 5h18v2H3V5zm3 4h12v2H6V9zm-3 4h18v2H3v-2zm3 4h12v2H6v-2z", + right: "M3 5h18v2H3V5zm6 4h12v2H9V9zm-6 4h18v2H3v-2zm6 4h12v2H9v-2z" + }; + const path = this.alignIcon.querySelector("path"); + if (path) { + path.setAttribute("d", paths[align]); + } + } + + private cycleTransform(asset: RichTextAsset): void { + const current = asset.style?.textTransform ?? "none"; + const cycle: Array<"none" | "uppercase" | "lowercase"> = ["none", "uppercase", "lowercase"]; + const currentIdx = cycle.indexOf(current as "none" | "uppercase" | "lowercase"); + const nextIdx = (currentIdx + 1) % cycle.length; + this.updateClipProperty({ style: { textTransform: cycle[nextIdx] } }); + } + + private toggleUnderline(asset: RichTextAsset): void { + const current = asset.style?.textDecoration ?? "none"; + const newValue = current === "underline" ? "none" : "underline"; + this.updateClipProperty({ style: { textDecoration: newValue } }); + } + + private updateBorderProperty(updates: Partial<{ width: number; color: string; opacity: number; radius: number }>): void { + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!player) return; + + const asset = player.clipConfiguration.asset as RichTextAsset; + const currentBorder = asset.border || { width: 0, color: "#000000", opacity: 1, radius: 0 }; + + const updatedBorder = { ...currentBorder, ...updates }; + this.updateClipProperty({ border: updatedBorder }); + } + + private updateClipProperty(assetUpdates: Record): void { + const updates: Partial = { asset: assetUpdates as ResolvedClip["asset"] }; + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + this.syncState(); + } + + show(trackIdx: number, clipIdx: number): void { + this.selectedTrackIdx = trackIdx; + this.selectedClipIdx = clipIdx; + if (this.container) { + this.container.style.display = "flex"; + } + this.syncState(); + } + + hide(): void { + if (this.container) { + this.container.style.display = "none"; + } + if (this.sizePopup) { + this.sizePopup.style.display = "none"; + } + if (this.opacityPopup) { + this.opacityPopup.style.display = "none"; + } + if (this.spacingPopup) { + this.spacingPopup.style.display = "none"; + } + if (this.borderPopup) { + this.borderPopup.style.display = "none"; + } + if (this.fontPopup) { + this.fontPopup.style.display = "none"; + } + if (this.textEditPopup) { + this.textEditPopup.style.display = "none"; + } + } + + private syncState(): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + // Check if bold is supported by the current font + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + const supportsBold = (player as { supportsBold?: () => boolean })?.supportsBold?.() ?? true; + if (this.boldBtn) { + this.boldBtn.style.display = supportsBold ? "" : "none"; + } + + if (this.sizeInput) { + this.sizeInput.value = String(asset.font?.size ?? 48); + } + + if (this.fontPreview) { + const fontFamily = asset.font?.family ?? "Roboto"; + this.fontPreview.textContent = this.getDisplayName(fontFamily); + this.fontPreview.style.fontFamily = `'${fontFamily}', sans-serif`; + } + + if (this.colorInput) { + this.colorInput.value = asset.font?.color ?? "#000000"; + } + + if (this.opacitySlider && this.opacityValue) { + const opacity = Math.round((asset.font?.opacity ?? 1) * 100); + this.opacitySlider.value = String(opacity); + this.opacityValue.textContent = String(opacity); + } + + if (this.letterSpacingSlider && this.letterSpacingValue) { + const letterSpacing = asset.style?.letterSpacing ?? 0; + this.letterSpacingSlider.value = String(letterSpacing); + this.letterSpacingValue.textContent = String(letterSpacing); + } + + if (this.lineHeightSlider && this.lineHeightValue) { + const lineHeight = asset.style?.lineHeight ?? 1.2; + this.lineHeightSlider.value = String(Math.round(lineHeight * 10)); + this.lineHeightValue.textContent = lineHeight.toFixed(1); + } + + const verticalAlign = asset.align?.vertical ?? "middle"; + this.setButtonActive(this.anchorTopBtn, verticalAlign === "top"); + this.setButtonActive(this.anchorMiddleBtn, verticalAlign === "middle"); + this.setButtonActive(this.anchorBottomBtn, verticalAlign === "bottom"); + + const isBold = String(asset.font?.weight ?? "400") === "700" || String(asset.font?.weight ?? "400") === "bold"; + this.setButtonActive(this.boldBtn, isBold); + + const align = asset.align?.horizontal ?? "center"; + this.updateAlignIcon(align as "left" | "center" | "right"); + + const transform = asset.style?.textTransform ?? "none"; + if (this.transformBtn) { + this.transformBtn.textContent = transform === "uppercase" ? "AA" : transform === "lowercase" ? "aa" : "Aa"; + this.setButtonActive(this.transformBtn, transform !== "none"); + } + + const isUnderline = asset.style?.textDecoration === "underline"; + this.setButtonActive(this.underlineBtn, isUnderline); + + const border = asset.border || { width: 0, color: "#000000", opacity: 1, radius: 0 }; + if (this.borderWidthSlider && this.borderWidthValue) { + this.borderWidthSlider.value = String(border.width); + this.borderWidthValue.textContent = String(border.width); + } + if (this.borderColorInput) { + this.borderColorInput.value = border.color; + } + if (this.borderOpacitySlider && this.borderOpacityValue) { + const opacityPercent = Math.round(border.opacity * 100); + this.borderOpacitySlider.value = String(opacityPercent); + this.borderOpacityValue.textContent = String(opacityPercent); + } + if (this.borderRadiusSlider && this.borderRadiusValue) { + this.borderRadiusSlider.value = String(border.radius); + this.borderRadiusValue.textContent = String(border.radius); + } + } + + private setButtonActive(btn: HTMLButtonElement | null, active: boolean): void { + if (!btn) return; + btn.classList.toggle("active", active); + } + + dispose(): void { + this.container?.remove(); + this.container = null; + this.sizeInput = null; + this.sizePopup = null; + this.boldBtn = null; + this.fontBtn = null; + this.fontPopup = null; + this.fontPreview = null; + this.colorInput = null; + this.opacityBtn = null; + this.opacityPopup = null; + this.opacitySlider = null; + this.opacityValue = null; + this.spacingBtn = null; + this.spacingPopup = null; + this.letterSpacingSlider = null; + this.letterSpacingValue = null; + this.lineHeightSlider = null; + this.lineHeightValue = null; + this.anchorTopBtn = null; + this.anchorMiddleBtn = null; + this.anchorBottomBtn = null; + this.alignBtn = null; + this.alignIcon = null; + this.transformBtn = null; + this.underlineBtn = null; + this.textEditBtn = null; + this.textEditPopup = null; + this.textEditArea = null; + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + this.textEditDebounceTimer = null; + } + this.borderBtn = null; + this.borderPopup = null; + this.borderWidthSlider = null; + this.borderWidthValue = null; + this.borderColorInput = null; + this.borderOpacitySlider = null; + this.borderOpacityValue = null; + this.borderRadiusSlider = null; + this.borderRadiusValue = null; + + this.styleElement?.remove(); + this.styleElement = null; + } +} From c33b34032e077864677aab35a87a9b79f8255518 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 10:07:30 +1100 Subject: [PATCH 055/463] feat: add background color picker to rich text toolbar --- src/core/ui/background-color-picker.css.ts | 112 +++++++++++++++++++++ src/core/ui/background-color-picker.ts | 111 ++++++++++++++++++++ src/core/ui/rich-text-toolbar.ts | 88 ++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 src/core/ui/background-color-picker.css.ts create mode 100644 src/core/ui/background-color-picker.ts diff --git a/src/core/ui/background-color-picker.css.ts b/src/core/ui/background-color-picker.css.ts new file mode 100644 index 00000000..6e8d0911 --- /dev/null +++ b/src/core/ui/background-color-picker.css.ts @@ -0,0 +1,112 @@ +export const BACKGROUND_COLOR_PICKER_STYLES = ` +.ss-color-picker { + padding: 16px; +} + +.ss-color-picker-header { + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 14px; +} + +.ss-color-picker-color-section, +.ss-color-picker-opacity-section { + margin-bottom: 16px; +} + +.ss-color-picker-color-section:last-child, +.ss-color-picker-opacity-section:last-child { + margin-bottom: 0; +} + +.ss-color-picker-label { + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + margin-bottom: 8px; +} + +.ss-color-picker-color-wrap { + display: flex; + align-items: center; + gap: 12px; +} + +.ss-color-picker-color { + width: 100%; + height: 40px; + padding: 0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + cursor: pointer; + background: transparent; +} + +.ss-color-picker-color::-webkit-color-swatch-wrapper { + padding: 4px; +} + +.ss-color-picker-color::-webkit-color-swatch { + border: none; + border-radius: 4px; +} + +.ss-color-picker-color::-moz-color-swatch { + border: none; + border-radius: 4px; +} + +.ss-color-picker-opacity-row { + display: flex; + align-items: center; + gap: 12px; +} + +.ss-color-picker-opacity { + -webkit-appearance: none; + appearance: none; + flex: 1; + height: 4px; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0) 0%, + rgba(255, 255, 255, 1) 100% + ); + border-radius: 2px; + cursor: pointer; + outline: none; +} + +.ss-color-picker-opacity::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.ss-color-picker-opacity::-moz-range-thumb { + width: 14px; + height: 14px; + background: white; + border: none; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3); +} + +.ss-color-picker-opacity-value { + min-width: 42px; + text-align: right; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + font-variant-numeric: tabular-nums; +} +`; diff --git a/src/core/ui/background-color-picker.ts b/src/core/ui/background-color-picker.ts new file mode 100644 index 00000000..0f096841 --- /dev/null +++ b/src/core/ui/background-color-picker.ts @@ -0,0 +1,111 @@ +import { BACKGROUND_COLOR_PICKER_STYLES } from "./background-color-picker.css"; + +type ColorChangeCallback = (color: string, opacity: number) => void; + +export class BackgroundColorPicker { + private container: HTMLDivElement | null = null; + private colorInput: HTMLInputElement | null = null; + private opacitySlider: HTMLInputElement | null = null; + private opacityValue: HTMLSpanElement | null = null; + private styleElement: HTMLStyleElement | null = null; + + private onColorChange: ColorChangeCallback | null = null; + + constructor() { + this.injectStyles(); + } + + private injectStyles(): void { + if (document.getElementById("ss-background-color-picker-styles")) return; + + this.styleElement = document.createElement("style"); + this.styleElement.id = "ss-background-color-picker-styles"; + this.styleElement.textContent = BACKGROUND_COLOR_PICKER_STYLES; + document.head.appendChild(this.styleElement); + } + + mount(parent: HTMLElement): void { + this.container = document.createElement("div"); + this.container.className = "ss-color-picker"; + + this.container.innerHTML = ` +
Background Fill
+
+
Color
+
+ +
+
+
+
Opacity
+
+ + 100% +
+
+ `; + + parent.appendChild(this.container); + + this.colorInput = this.container.querySelector(".ss-color-picker-color"); + this.opacitySlider = this.container.querySelector(".ss-color-picker-opacity"); + this.opacityValue = this.container.querySelector(".ss-color-picker-opacity-value"); + + this.colorInput?.addEventListener("input", this.handleColorChange.bind(this)); + this.opacitySlider?.addEventListener("input", this.handleOpacityChange.bind(this)); + } + + private handleColorChange(): void { + this.emitColorChange(); + } + + private handleOpacityChange(e: Event): void { + const opacity = parseInt((e.target as HTMLInputElement).value, 10); + if (this.opacityValue) { + this.opacityValue.textContent = `${opacity}%`; + } + this.emitColorChange(); + } + + private emitColorChange(): void { + if (this.onColorChange && this.colorInput && this.opacitySlider) { + const color = this.colorInput.value; + const opacity = parseInt(this.opacitySlider.value, 10) / 100; + this.onColorChange(color, opacity); + } + } + + // Public API + setColor(hex: string): void { + if (this.colorInput) { + this.colorInput.value = hex.toUpperCase(); + } + } + + setOpacity(opacity: number): void { + const opacityPercent = Math.round(Math.max(0, Math.min(100, opacity))); + if (this.opacitySlider) { + this.opacitySlider.value = String(opacityPercent); + } + if (this.opacityValue) { + this.opacityValue.textContent = `${opacityPercent}%`; + } + } + + onChange(callback: ColorChangeCallback): void { + this.onColorChange = callback; + } + + dispose(): void { + this.container?.remove(); + this.container = null; + this.colorInput = null; + this.opacitySlider = null; + this.opacityValue = null; + + this.styleElement?.remove(); + this.styleElement = null; + + this.onColorChange = null; + } +} diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index aeb7f90e..344e34e7 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -4,6 +4,7 @@ import type { ResolvedClip } from "@schemas/clip"; import type { RichTextAsset } from "@schemas/rich-text-asset"; import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; +import { BackgroundColorPicker } from "./background-color-picker"; /** Built-in font families (base names only, without weight variants) */ const BUILT_IN_FONTS = [ @@ -65,6 +66,9 @@ export class RichTextToolbar { private borderOpacityValue: HTMLSpanElement | null = null; private borderRadiusSlider: HTMLInputElement | null = null; private borderRadiusValue: HTMLSpanElement | null = null; + private backgroundBtn: HTMLButtonElement | null = null; + private backgroundPopup: HTMLDivElement | null = null; + private backgroundColorPicker: BackgroundColorPicker | null = null; private styleElement: HTMLStyleElement | null = null; @@ -127,6 +131,18 @@ export class RichTextToolbar {
+
+ +
+
+
+
+
+
+ +
+
Padding
+ +
+
Top
+
+ + 0 +
+
+ +
+
Right
+
+ + 0 +
+
+ +
+
Bottom
+
+ + 0 +
+
+ +
+
Left
+
+ + 0 +
+
+
+
+
+ +
+ +
+
+
Color
+ +
+
+
Opacity
+
+ + 100% +
+
+
+
Highlight
+ +
+
+ +
+
+
Coming soon...
+
+
+ `; + + parent.appendChild(this.container); + + // Query tab buttons + this.colorTab = this.container.querySelector('[data-tab="color"]'); + this.gradientTab = this.container.querySelector('[data-tab="gradient"]'); + + // Query tab content + this.colorContent = this.container.querySelector('[data-content="color"]'); + this.gradientContent = this.container.querySelector('[data-content="gradient"]'); + + // Query color elements + this.colorInput = this.container.querySelector("[data-color-input]"); + this.colorOpacitySlider = this.container.querySelector("[data-color-opacity]"); + this.colorOpacityValue = this.container.querySelector("[data-color-opacity-value]"); + + // Query highlight elements + this.highlightColorInput = this.container.querySelector("[data-highlight-color]"); + + // Setup event listeners + this.colorTab?.addEventListener("click", () => this.setMode("color")); + this.gradientTab?.addEventListener("click", () => this.setMode("gradient")); + + this.colorInput?.addEventListener("input", () => this.handleColorChange()); + this.colorOpacitySlider?.addEventListener("input", (e) => this.handleColorOpacityChange(e)); + + this.highlightColorInput?.addEventListener("input", () => this.handleHighlightChange()); + } + + private handleColorChange(): void { + this.emitColorChange(); + } + + private handleColorOpacityChange(e: Event): void { + const opacity = parseInt((e.target as HTMLInputElement).value, 10); + if (this.colorOpacityValue) { + this.colorOpacityValue.textContent = `${opacity}%`; + } + this.emitColorChange(); + } + + private handleHighlightChange(): void { + this.emitHighlightChange(); + } + + private emitColorChange(): void { + if (this.onColorChange && this.colorInput && this.colorOpacitySlider) { + const color = this.colorInput.value; + const opacity = parseInt(this.colorOpacitySlider.value, 10) / 100; + this.onColorChange({ color, opacity }); + } + } + + private emitHighlightChange(): void { + if (this.onColorChange && this.highlightColorInput) { + const background = this.highlightColorInput.value; + this.onColorChange({ background }); + } + } + + setMode(mode: ColorMode): void { + this.currentMode = mode; + + // Update tab buttons + if (mode === "color") { + this.colorTab?.classList.add("active"); + this.gradientTab?.classList.remove("active"); + this.colorContent?.classList.add("active"); + this.gradientContent?.classList.remove("active"); + } else { + this.colorTab?.classList.remove("active"); + this.gradientTab?.classList.add("active"); + this.colorContent?.classList.remove("active"); + this.gradientContent?.classList.add("active"); + } + } + + setColor(color: string, opacity: number): void { + if (this.colorInput) { + this.colorInput.value = color.toUpperCase(); + } + const opacityPercent = Math.round(Math.max(0, Math.min(100, opacity * 100))); + if (this.colorOpacitySlider) { + this.colorOpacitySlider.value = String(opacityPercent); + } + if (this.colorOpacityValue) { + this.colorOpacityValue.textContent = `${opacityPercent}%`; + } + } + + setHighlight(color: string): void { + if (this.highlightColorInput) { + this.highlightColorInput.value = color.toUpperCase(); + } + } + + onChange(callback: FontColorChangeCallback): void { + this.onColorChange = callback; + } + + dispose(): void { + this.container?.remove(); + this.container = null; + this.colorTab = null; + this.gradientTab = null; + this.colorContent = null; + this.gradientContent = null; + this.colorInput = null; + this.colorOpacitySlider = null; + this.colorOpacityValue = null; + this.highlightColorInput = null; + + this.styleElement?.remove(); + this.styleElement = null; + + this.onColorChange = null; + } +} diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 81d3f1f1..f6a011aa 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -5,6 +5,7 @@ import type { RichTextAsset } from "@schemas/rich-text-asset"; import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; import { BackgroundColorPicker } from "./background-color-picker"; +import { FontColorPicker } from "./font-color-picker"; /** Built-in font families (base names only, without weight variants) */ const BUILT_IN_FONTS = [ @@ -35,7 +36,6 @@ export class RichTextToolbar { private sizeInput: HTMLInputElement | null = null; private sizePopup: HTMLDivElement | null = null; private boldBtn: HTMLButtonElement | null = null; - private colorInput: HTMLInputElement | null = null; private opacityBtn: HTMLButtonElement | null = null; private opacityPopup: HTMLDivElement | null = null; private opacitySlider: HTMLInputElement | null = null; @@ -81,6 +81,11 @@ export class RichTextToolbar { private paddingLeftSlider: HTMLInputElement | null = null; private paddingLeftValue: HTMLSpanElement | null = null; + private fontColorBtn: HTMLButtonElement | null = null; + private fontColorPopup: HTMLDivElement | null = null; + private fontColorPicker: FontColorPicker | null = null; + private colorDisplay: HTMLButtonElement | null = null; + private styleElement: HTMLStyleElement | null = null; constructor(edit: Edit) { @@ -138,8 +143,10 @@ export class RichTextToolbar {
- -
+ +
+
+
@@ -354,10 +361,19 @@ export class RichTextToolbar { }); this.buildSizePopup(); - this.colorInput?.addEventListener("input", (e) => { - const color = (e.target as HTMLInputElement).value; - this.updateClipProperty({ font: { color } }); - }); + // Font color picker + this.fontColorBtn = this.container.querySelector("[data-action='font-color-toggle']"); + this.colorDisplay = this.container.querySelector("[data-color-display]"); + this.fontColorPopup = this.container.querySelector("[data-font-color-popup]"); + const fontColorPickerContainer = this.container.querySelector("[data-font-color-picker]"); + + if (fontColorPickerContainer) { + this.fontColorPicker = new FontColorPicker(); + this.fontColorPicker.mount(fontColorPickerContainer as HTMLElement); + this.fontColorPicker.onChange((updates) => { + this.updateFontColorProperty(updates); + }); + } this.opacityBtn = this.container.querySelector("[data-action='opacity-toggle']"); this.opacityPopup = this.container.querySelector("[data-opacity-popup]"); @@ -539,6 +555,11 @@ export class RichTextToolbar { this.paddingPopup.style.display = "none"; } } + if (this.fontColorPopup && this.fontColorPopup.style.display !== "none") { + if (!this.fontColorBtn?.contains(target) && !this.fontColorPopup.contains(target)) { + this.fontColorPopup.style.display = "none"; + } + } if (this.fontPopup && this.fontPopup.style.display !== "none") { if (!this.fontBtn?.contains(target) && !this.fontPopup.contains(target)) { this.fontPopup.style.display = "none"; @@ -613,6 +634,9 @@ export class RichTextToolbar { case "padding-toggle": this.togglePaddingPopup(); break; + case "font-color-toggle": + this.toggleFontColorPopup(); + break; case "anchor-top": this.updateVerticalAlign("top"); break; @@ -701,6 +725,7 @@ export class RichTextToolbar { if (this.borderPopup) this.borderPopup.style.display = "none"; if (this.backgroundPopup) this.backgroundPopup.style.display = "none"; if (this.paddingPopup) this.paddingPopup.style.display = "none"; + if (this.fontColorPopup) this.fontColorPopup.style.display = "none"; const isVisible = this.opacityPopup.style.display !== "none"; this.opacityPopup.style.display = isVisible ? "none" : "block"; } @@ -714,6 +739,7 @@ export class RichTextToolbar { if (this.borderPopup) this.borderPopup.style.display = "none"; if (this.backgroundPopup) this.backgroundPopup.style.display = "none"; if (this.paddingPopup) this.paddingPopup.style.display = "none"; + if (this.fontColorPopup) this.fontColorPopup.style.display = "none"; const isVisible = this.spacingPopup.style.display !== "none"; this.spacingPopup.style.display = isVisible ? "none" : "block"; } @@ -727,6 +753,7 @@ export class RichTextToolbar { if (this.textEditPopup) this.textEditPopup.style.display = "none"; if (this.backgroundPopup) this.backgroundPopup.style.display = "none"; if (this.paddingPopup) this.paddingPopup.style.display = "none"; + if (this.fontColorPopup) this.fontColorPopup.style.display = "none"; const isVisible = this.borderPopup.style.display !== "none"; this.borderPopup.style.display = isVisible ? "none" : "block"; } @@ -740,6 +767,7 @@ export class RichTextToolbar { if (this.borderPopup) this.borderPopup.style.display = "none"; if (this.paddingPopup) this.paddingPopup.style.display = "none"; if (this.textEditPopup) this.textEditPopup.style.display = "none"; + if (this.fontColorPopup) this.fontColorPopup.style.display = "none"; const isVisible = this.backgroundPopup.style.display !== "none"; this.backgroundPopup.style.display = isVisible ? "none" : "block"; @@ -764,11 +792,46 @@ export class RichTextToolbar { if (this.borderPopup) this.borderPopup.style.display = "none"; if (this.backgroundPopup) this.backgroundPopup.style.display = "none"; if (this.textEditPopup) this.textEditPopup.style.display = "none"; + if (this.fontColorPopup) this.fontColorPopup.style.display = "none"; const isVisible = this.paddingPopup.style.display !== "none"; this.paddingPopup.style.display = isVisible ? "none" : "block"; } + private toggleFontColorPopup(): void { + if (!this.fontColorPopup) return; + + // Close other popups + if (this.sizePopup) this.sizePopup.style.display = "none"; + if (this.opacityPopup) this.opacityPopup.style.display = "none"; + if (this.spacingPopup) this.spacingPopup.style.display = "none"; + if (this.paddingPopup) this.paddingPopup.style.display = "none"; + if (this.borderPopup) this.borderPopup.style.display = "none"; + if (this.backgroundPopup) this.backgroundPopup.style.display = "none"; + if (this.textEditPopup) this.textEditPopup.style.display = "none"; + if (this.fontPopup) this.fontPopup.style.display = "none"; + + const isVisible = this.fontColorPopup.style.display !== "none"; + this.fontColorPopup.style.display = isVisible ? "none" : "block"; + + // Sync state when opening + if (!isVisible && this.fontColorPicker) { + const asset = this.getCurrentAsset(); + const font = asset?.font; + + // Set color and opacity + this.fontColorPicker.setColor( + font?.color || "#000000", + font?.opacity ?? 1 + ); + + // Set highlight if present + if (font?.background) { + this.fontColorPicker.setHighlight(font.background); + } + } + } + private toggleFontPopup(): void { if (!this.fontPopup) return; if (this.sizePopup) this.sizePopup.style.display = "none"; @@ -778,6 +841,7 @@ export class RichTextToolbar { if (this.textEditPopup) this.textEditPopup.style.display = "none"; if (this.borderPopup) this.borderPopup.style.display = "none"; if (this.backgroundPopup) this.backgroundPopup.style.display = "none"; + if (this.fontColorPopup) this.fontColorPopup.style.display = "none"; const isVisible = this.fontPopup.style.display !== "none"; if (!isVisible) { this.buildFontList(); @@ -794,6 +858,7 @@ export class RichTextToolbar { if (this.borderPopup) this.borderPopup.style.display = "none"; if (this.backgroundPopup) this.backgroundPopup.style.display = "none"; if (this.paddingPopup) this.paddingPopup.style.display = "none"; + if (this.fontColorPopup) this.fontColorPopup.style.display = "none"; const isVisible = this.textEditPopup.style.display !== "none"; if (!isVisible && this.textEditArea) { @@ -1006,6 +1071,43 @@ export class RichTextToolbar { } } + private updateFontColorProperty(updates: { + color?: string; + opacity?: number; + background?: string; + backgroundOpacity?: number; + }): void { + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!player) return; + + const asset = player.clipConfiguration.asset as RichTextAsset; + const currentFont = asset.font || {}; + + let fontUpdates: Record = { ...currentFont }; + + // Handle solid color and opacity + if (updates.color !== undefined) { + fontUpdates.color = updates.color; + } + if (updates.opacity !== undefined) { + fontUpdates.opacity = updates.opacity; + } + + // Handle text highlight (font.background) + if (updates.background !== undefined) { + fontUpdates.background = updates.background; + } + if (updates.backgroundOpacity !== undefined) { + // For now, just store it - will need schema update to fully support + // Alternative: encode in hex color as #RRGGBBAA + } + + // Apply updates + this.updateClipProperty({ + font: fontUpdates + }); + } + private updateClipProperty(assetUpdates: Record): void { const updates: Partial = { asset: assetUpdates as ResolvedClip["asset"] }; this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); @@ -1072,8 +1174,9 @@ export class RichTextToolbar { this.fontPreview.style.fontFamily = `'${fontFamily}', sans-serif`; } - if (this.colorInput) { - this.colorInput.value = asset.font?.color ?? "#000000"; + if (this.colorDisplay) { + const color = asset.font?.color ?? "#000000"; + this.colorDisplay.style.backgroundColor = color; } if (this.opacitySlider && this.opacityValue) { @@ -1178,7 +1281,13 @@ export class RichTextToolbar { this.fontBtn = null; this.fontPopup = null; this.fontPreview = null; - this.colorInput = null; + + this.fontColorPicker?.dispose(); + this.fontColorPicker = null; + this.fontColorBtn = null; + this.fontColorPopup = null; + this.colorDisplay = null; + this.opacityBtn = null; this.opacityPopup = null; this.opacitySlider = null; From c0bf639a80f4b40ad324036d71ae7c3bfd21f7aa Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 11:10:49 +1100 Subject: [PATCH 058/463] feat: add gradient presets to font color picker --- src/core/ui/font-color-picker.css.ts | 37 +++++++ src/core/ui/font-color-picker.ts | 102 +++++++++++++++++- src/core/ui/rich-text-toolbar.ts | 149 +++++++-------------------- 3 files changed, 175 insertions(+), 113 deletions(-) diff --git a/src/core/ui/font-color-picker.css.ts b/src/core/ui/font-color-picker.css.ts index 64bca399..4e406364 100644 --- a/src/core/ui/font-color-picker.css.ts +++ b/src/core/ui/font-color-picker.css.ts @@ -125,4 +125,41 @@ export const FONT_COLOR_PICKER_STYLES = ` color: rgba(255, 255, 255, 0.9); font-variant-numeric: tabular-nums; } + +.ss-gradient-category { + margin-bottom: 16px; +} + +.ss-gradient-category:last-child { + margin-bottom: 0; +} + +.ss-gradient-category-name { + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 8px; +} + +.ss-gradient-swatches { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 6px; +} + +.ss-gradient-swatch { + aspect-ratio: 1; + border: none; + border-radius: 6px; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.ss-gradient-swatch:hover { + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); +} `; diff --git a/src/core/ui/font-color-picker.ts b/src/core/ui/font-color-picker.ts index 7e317bb2..46761bd4 100644 --- a/src/core/ui/font-color-picker.ts +++ b/src/core/ui/font-color-picker.ts @@ -1,11 +1,73 @@ import { FONT_COLOR_PICKER_STYLES } from "./font-color-picker.css"; +type GradientPreset = { + type: "linear"; + angle: number; + stops: Array<{ offset: number; color: string }>; +}; + +const GRADIENT_PRESETS: Array<{ name: string; gradients: GradientPreset[] }> = [ + { + name: "Cool Tones", + gradients: [ + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#8B5CF6" }, { offset: 1, color: "#06B6D4" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#3B82F6" }, { offset: 1, color: "#8B5CF6" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#06B6D4" }, { offset: 1, color: "#3B82F6" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#3B82F6" }, { offset: 1, color: "#6366F1" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#06B6D4" }, { offset: 1, color: "#14B8A6" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#0EA5E9" }, { offset: 1, color: "#38BDF8" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#8B5CF6" }, { offset: 0.5, color: "#3B82F6" }, { offset: 1, color: "#06B6D4" }] } + ] + }, + { + name: "Warm Tones", + gradients: [ + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EF4444" }, { offset: 1, color: "#F97316" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#F97316" }, { offset: 1, color: "#EAB308" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EC4899" }, { offset: 1, color: "#F43F5E" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EF4444" }, { offset: 1, color: "#EC4899" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#F97316" }, { offset: 1, color: "#F59E0B" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EC4899" }, { offset: 1, color: "#F97316" }] }, + { type: "linear", angle: 45, stops: [{ offset: 0, color: "#8B5CF6" }, { offset: 0.5, color: "#EC4899" }, { offset: 1, color: "#EAB308" }] } + ] + }, + { + name: "Monochromatic", + gradients: [ + // Neutrals row + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#878274" }, { offset: 1, color: "#24221a" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#dfdcda" }, { offset: 1, color: "#858176" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#fffcf5" }, { offset: 1, color: "#d8d5ca" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#feffff" }, { offset: 1, color: "#c5c5c5" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#e9f0f3" }, { offset: 1, color: "#a2a5ac" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#a5acb9" }, { offset: 1, color: "#303643" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#6d7486" }, { offset: 1, color: "#0a0d13" }] }, + // Dark to light colors row + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#731919" }, { offset: 1, color: "#e52b2b" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#963e15" }, { offset: 1, color: "#f4773e" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#997300" }, { offset: 1, color: "#ffc000" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#226214" }, { offset: 1, color: "#43cc25" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#004d48" }, { offset: 1, color: "#3ff3e7" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#001f65" }, { offset: 1, color: "#6895fd" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#450050" }, { offset: 1, color: "#e753fe" }] }, + // Pastels row + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#ff6767" }, { offset: 1, color: "#ffd1d1" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#ff9869" }, { offset: 1, color: "#ffd2bd" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#ffda6a" }, { offset: 1, color: "#fff7de" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#7cce6b" }, { offset: 1, color: "#d8ffd0" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#7af6ee" }, { offset: 1, color: "#eafffe" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#84a9ff" }, { offset: 1, color: "#f5f8ff" }] }, + { type: "linear", angle: 180, stops: [{ offset: 0, color: "#f093ff" }, { offset: 1, color: "#fdf1ff" }] } + ] + } +]; + type ColorMode = "color" | "gradient"; type FontColorChangeCallback = (updates: { color?: string; opacity?: number; background?: string; - backgroundOpacity?: number; + gradient?: { type: "linear" | "radial"; angle: number; stops: Array<{ offset: number; color: string }> }; }) => void; export class FontColorPicker { @@ -73,9 +135,7 @@ export class FontColorPicker {
-
-
Coming soon...
-
+ ${this.buildGradientHTML()}
`; @@ -105,6 +165,14 @@ export class FontColorPicker { this.colorOpacitySlider?.addEventListener("input", (e) => this.handleColorOpacityChange(e)); this.highlightColorInput?.addEventListener("input", () => this.handleHighlightChange()); + + // Setup gradient swatch click handlers + this.container.querySelectorAll("[data-cat]").forEach((btn) => { + btn.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLButtonElement; + this.handleGradientClick(parseInt(el.dataset["cat"] || "0"), parseInt(el.dataset["idx"] || "0")); + }); + }); } private handleColorChange(): void { @@ -138,6 +206,32 @@ export class FontColorPicker { } } + private buildCSSGradient(gradient: GradientPreset): string { + const stops = gradient.stops.map(s => `${s.color} ${Math.round(s.offset * 100)}%`).join(", "); + return `linear-gradient(${gradient.angle}deg, ${stops})`; + } + + private buildGradientHTML(): string { + let html = ''; + GRADIENT_PRESETS.forEach((category, catIdx) => { + html += `
+
${category.name}
+
`; + category.gradients.forEach((g, idx) => { + html += ``; + }); + html += `
`; + }); + return html; + } + + private handleGradientClick(catIdx: number, idx: number): void { + const gradient = GRADIENT_PRESETS[catIdx]?.gradients[idx]; + if (gradient && this.onColorChange) { + this.onColorChange({ gradient: { type: gradient.type, angle: gradient.angle, stops: gradient.stops } }); + } + } + setMode(mode: ColorMode): void { this.currentMode = mode; diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index f6a011aa..b7c5673a 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -36,10 +36,6 @@ export class RichTextToolbar { private sizeInput: HTMLInputElement | null = null; private sizePopup: HTMLDivElement | null = null; private boldBtn: HTMLButtonElement | null = null; - private opacityBtn: HTMLButtonElement | null = null; - private opacityPopup: HTMLDivElement | null = null; - private opacitySlider: HTMLInputElement | null = null; - private opacityValue: HTMLSpanElement | null = null; private spacingBtn: HTMLButtonElement | null = null; private spacingPopup: HTMLDivElement | null = null; private letterSpacingSlider: HTMLInputElement | null = null; @@ -204,36 +200,6 @@ export class RichTextToolbar { -
- -
-
Transparency
-
- - 100 -
-
-
-
+
+ +
+
+
+ Enable Shadow + +
+
+
+
Offset X
+
+ + 0 +
+
+
+
Offset Y
+
+ + 0 +
+
+
+
Blur
+
+ + 0 +
+
+
+
Color & Opacity
+
+
+ +
+ + 50 +
+
+
+
+
+ `; this.sizeInput = this.container.querySelector("[data-size-input]"); @@ -368,6 +374,7 @@ export class RichTextToolbar { this.alignIcon = this.container.querySelector("[data-align-icon]"); this.transformBtn = this.container.querySelector("[data-action='transform']"); this.underlineBtn = this.container.querySelector("[data-action='underline']"); + this.linethroughBtn = this.container.querySelector("[data-action='linethrough']"); this.textEditBtn = this.container.querySelector("[data-action='text-edit-toggle']"); this.textEditPopup = this.container.querySelector("[data-text-edit-popup]"); this.textEditArea = this.container.querySelector("[data-text-edit-area]"); @@ -727,6 +734,9 @@ export class RichTextToolbar { case "underline": this.toggleUnderline(asset); break; + case "linethrough": + this.toggleLinethrough(asset); + break; } } @@ -1069,6 +1079,12 @@ export class RichTextToolbar { this.updateClipProperty({ style: { textDecoration: newValue } }); } + private toggleLinethrough(asset: RichTextAsset): void { + const current = asset.style?.textDecoration ?? "none"; + const newValue = current === "line-through" ? "none" : "line-through"; + this.updateClipProperty({ style: { textDecoration: newValue } }); + } + private updateBorderProperty(updates: Partial<{ width: number; color: string; opacity: number; radius: number }>): void { const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); if (!player) return; @@ -1314,6 +1330,9 @@ export class RichTextToolbar { const isUnderline = asset.style?.textDecoration === "underline"; this.setButtonActive(this.underlineBtn, isUnderline); + const isLinethrough = asset.style?.textDecoration === "line-through"; + this.setButtonActive(this.linethroughBtn, isLinethrough); + const border = asset.border || { width: 0, color: "#000000", opacity: 1, radius: 0 }; if (this.borderWidthSlider && this.borderWidthValue) { this.borderWidthSlider.value = String(border.width); @@ -1426,6 +1445,7 @@ export class RichTextToolbar { this.alignIcon = null; this.transformBtn = null; this.underlineBtn = null; + this.linethroughBtn = null; this.textEditBtn = null; this.textEditPopup = null; this.textEditArea = null; From 3bc3e33585858f041afd30d0775db38040642644 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 12:31:36 +1100 Subject: [PATCH 061/463] feat: add animation controls to rich text toolbar with preset, duration, style, and direction options --- src/core/schemas/rich-text-asset.ts | 2 +- src/core/ui/rich-text-toolbar.css.ts | 18 +++ src/core/ui/rich-text-toolbar.ts | 181 +++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 1 deletion(-) diff --git a/src/core/schemas/rich-text-asset.ts b/src/core/schemas/rich-text-asset.ts index fc18e0b3..16b68281 100644 --- a/src/core/schemas/rich-text-asset.ts +++ b/src/core/schemas/rich-text-asset.ts @@ -94,7 +94,7 @@ const RichTextAlignmentSchema = zod const RichTextAnimationSchema = zod .object({ - preset: zod.enum(["fadeIn", "slideIn", "typewriter", "shift", "ascend", "movingLetters", "bounce", "elastic", "pulse"]), + preset: zod.enum(["typewriter", "fadeIn", "slideIn", "ascend", "shift"]), duration: zod.number().min(0.1).max(60).optional(), style: zod.enum(["character", "word"]).optional(), direction: zod.enum(["left", "right", "up", "down"]).optional() diff --git a/src/core/ui/rich-text-toolbar.css.ts b/src/core/ui/rich-text-toolbar.css.ts index 2e270188..9bf30ed1 100644 --- a/src/core/ui/rich-text-toolbar.css.ts +++ b/src/core/ui/rich-text-toolbar.css.ts @@ -179,4 +179,22 @@ export const TOOLBAR_STYLES = ` cursor: pointer; accent-color: #007AFF; } + +.ss-toolbar-popup--animation { min-width: 240px; } +.ss-animation-presets { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; } +.ss-animation-preset { + padding: 10px 6px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + transition: all 0.15s ease; + font-size: 11px; + font-weight: 500; + text-align: center; + white-space: nowrap; +} +.ss-animation-preset:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.9); } +.ss-animation-preset.active { background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.25); color: #fff; } `; diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index bc79488d..4979e1c9 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -97,6 +97,13 @@ export class RichTextToolbar { private shadowOpacityValue: HTMLSpanElement | null = null; private lastShadowConfig: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number } | null = null; + private animationBtn: HTMLButtonElement | null = null; + private animationPopup: HTMLDivElement | null = null; + private animationDurationSlider: HTMLInputElement | null = null; + private animationDurationValue: HTMLSpanElement | null = null; + private animationStyleSection: HTMLDivElement | null = null; + private animationDirectionSection: HTMLDivElement | null = null; + private styleElement: HTMLStyleElement | null = null; constructor(edit: Edit) { @@ -347,6 +354,49 @@ export class RichTextToolbar { +
+ +
+
+
Preset
+
+ + + + + +
+
+
+
Duration
+
+ + 1.0s +
+
+
+
Writing Style
+
+ + +
+
+
+
Direction
+
+ + + + +
+
+
+ +
+
+
+
+
Presets
+ ${RESOLUTION_PRESETS.map( + (preset) => ` +
+
+ ${preset.label} + ${preset.sublabel} +
+ ${ICONS.check} +
+ ` + ).join("")} +
+
Custom
+
+ + × + +
+
+ + +
+ + +
+ +
+
+ +
+
+ ${COLOR_SWATCHES.map( + (color) => ` +
+ ` + ).join("")} +
+
+
+ +
+ + +
+ +
+ ${FPS_OPTIONS.map( + (fps) => ` +
+ ${fps} fps + ${ICONS.check} +
+ ` + ).join("")} +
+
+ `; + + parent.appendChild(this.container); + + // Query elements + this.resolutionBtn = this.container.querySelector('[data-action="resolution"]'); + this.backgroundBtn = this.container.querySelector('[data-action="background"]'); + this.fpsBtn = this.container.querySelector('[data-action="fps"]'); + + this.resolutionPopup = this.container.querySelector('[data-popup="resolution"]'); + this.backgroundPopup = this.container.querySelector('[data-popup="background"]'); + this.fpsPopup = this.container.querySelector('[data-popup="fps"]'); + + this.resolutionLabel = this.container.querySelector("[data-resolution-label]"); + this.fpsLabel = this.container.querySelector("[data-fps-label]"); + this.bgColorDot = this.container.querySelector("[data-bg-preview]"); + + this.customWidthInput = this.container.querySelector("[data-custom-width]"); + this.customHeightInput = this.container.querySelector("[data-custom-height]"); + this.colorInput = this.container.querySelector("[data-color-input]"); + + // Setup event listeners + this.setupEventListeners(); + this.updateActiveStates(); + } + + private setupEventListeners(): void { + // Toggle popups + this.resolutionBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("resolution"); + }); + this.backgroundBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("background"); + }); + this.fpsBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("fps"); + }); + + // Resolution preset clicks + this.resolutionPopup?.querySelectorAll("[data-width]").forEach((item) => { + item.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLElement; + const width = parseInt(el.dataset["width"] || "1920", 10); + const height = parseInt(el.dataset["height"] || "1080", 10); + this.handleResolutionSelect(width, height); + }); + }); + + // Custom size inputs + this.customWidthInput?.addEventListener("change", () => this.handleCustomSizeChange()); + this.customHeightInput?.addEventListener("change", () => this.handleCustomSizeChange()); + + // FPS clicks + this.fpsPopup?.querySelectorAll("[data-fps]").forEach((item) => { + item.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLElement; + const fps = parseInt(el.dataset["fps"] || "30", 10); + this.handleFpsSelect(fps); + }); + }); + + // Color input + this.colorInput?.addEventListener("input", () => { + if (this.colorInput) { + this.handleColorChange(this.colorInput.value); + } + }); + + // Color swatches + this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach((swatch) => { + swatch.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLElement; + const color = el.dataset["swatchColor"] || "#000000"; + this.handleColorChange(color); + }); + }); + + // Click outside to close + this.clickOutsideHandler = (e: MouseEvent) => { + if (!this.container?.contains(e.target as Node)) { + this.closeAllPopups(); + } + }; + document.addEventListener("click", this.clickOutsideHandler); + } + + private togglePopup(popup: "resolution" | "background" | "fps"): void { + const popupMap = { + resolution: { popup: this.resolutionPopup, btn: this.resolutionBtn }, + background: { popup: this.backgroundPopup, btn: this.backgroundBtn }, + fps: { popup: this.fpsPopup, btn: this.fpsBtn } + }; + + const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); + + // Close all popups + this.closeAllPopups(); + + // If it wasn't open, open it + if (!isCurrentlyOpen) { + popupMap[popup].popup?.classList.add("visible"); + popupMap[popup].btn?.classList.add("active"); + + // Sync custom inputs when opening resolution popup + if (popup === "resolution") { + if (this.customWidthInput) this.customWidthInput.value = String(this.currentWidth); + if (this.customHeightInput) this.customHeightInput.value = String(this.currentHeight); + } + } + } + + private closeAllPopups(): void { + this.resolutionPopup?.classList.remove("visible"); + this.backgroundPopup?.classList.remove("visible"); + this.fpsPopup?.classList.remove("visible"); + this.resolutionBtn?.classList.remove("active"); + this.backgroundBtn?.classList.remove("active"); + this.fpsBtn?.classList.remove("active"); + } + + private handleResolutionSelect(width: number, height: number): void { + this.currentWidth = width; + this.currentHeight = height; + this.updateResolutionLabel(); + this.updateActiveStates(); + this.closeAllPopups(); + + if (this.resolutionChangeCallback) { + this.resolutionChangeCallback(width, height); + } + } + + private handleCustomSizeChange(): void { + if (this.customWidthInput && this.customHeightInput) { + const width = parseInt(this.customWidthInput.value, 10); + const height = parseInt(this.customHeightInput.value, 10); + if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { + this.currentWidth = width; + this.currentHeight = height; + this.updateResolutionLabel(); + this.updateActiveStates(); + + if (this.resolutionChangeCallback) { + this.resolutionChangeCallback(width, height); + } + } + } + } + + private handleFpsSelect(fps: number): void { + this.currentFps = fps; + this.updateFpsLabel(); + this.updateActiveStates(); + this.closeAllPopups(); + + if (this.fpsChangeCallback) { + this.fpsChangeCallback(fps); + } + } + + private handleColorChange(color: string): void { + this.currentBgColor = color; + this.updateColorPreview(); + this.updateActiveStates(); + + if (this.colorInput) { + this.colorInput.value = color; + } + + if (this.backgroundChangeCallback) { + this.backgroundChangeCallback(color); + } + } + + private updateResolutionLabel(): void { + if (this.resolutionLabel) { + this.resolutionLabel.textContent = `${this.currentWidth} × ${this.currentHeight}`; + } + } + + private updateFpsLabel(): void { + if (this.fpsLabel) { + this.fpsLabel.textContent = `${this.currentFps} fps`; + } + } + + private updateColorPreview(): void { + if (this.bgColorDot) { + this.bgColorDot.style.background = this.currentBgColor; + } + } + + private updateActiveStates(): void { + // Update resolution presets + this.resolutionPopup?.querySelectorAll("[data-width]").forEach((item) => { + const el = item as HTMLElement; + const width = parseInt(el.dataset["width"] || "0", 10); + const height = parseInt(el.dataset["height"] || "0", 10); + el.classList.toggle("active", width === this.currentWidth && height === this.currentHeight); + }); + + // Update FPS options + this.fpsPopup?.querySelectorAll("[data-fps]").forEach((item) => { + const el = item as HTMLElement; + const fps = parseInt(el.dataset["fps"] || "0", 10); + el.classList.toggle("active", fps === this.currentFps); + }); + + // Update color swatches + this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach((swatch) => { + const el = swatch as HTMLElement; + const color = el.dataset["swatchColor"] || ""; + el.classList.toggle("active", color.toLowerCase() === this.currentBgColor.toLowerCase()); + }); + } + + setResolution(width: number, height: number): void { + this.currentWidth = Math.round(width); + this.currentHeight = Math.round(height); + this.updateResolutionLabel(); + this.updateActiveStates(); + + if (this.customWidthInput) this.customWidthInput.value = String(this.currentWidth); + if (this.customHeightInput) this.customHeightInput.value = String(this.currentHeight); + } + + setFps(fps: number): void { + this.currentFps = fps; + this.updateFpsLabel(); + this.updateActiveStates(); + } + + setBackground(color: string): void { + const hexColor = color.startsWith("#") ? color : `#${color}`; + this.currentBgColor = hexColor; + this.updateColorPreview(); + this.updateActiveStates(); + + if (this.colorInput) { + this.colorInput.value = hexColor; + } + } + + onResolutionChange(callback: ResolutionChangeCallback): void { + this.resolutionChangeCallback = callback; + } + + onFpsChange(callback: FpsChangeCallback): void { + this.fpsChangeCallback = callback; + } + + onBackgroundChange(callback: BackgroundChangeCallback): void { + this.backgroundChangeCallback = callback; + } + + dispose(): void { + if (this.clickOutsideHandler) { + document.removeEventListener("click", this.clickOutsideHandler); + this.clickOutsideHandler = null; + } + + this.container?.remove(); + this.container = null; + + this.resolutionPopup = null; + this.backgroundPopup = null; + this.fpsPopup = null; + this.resolutionBtn = null; + this.backgroundBtn = null; + this.fpsBtn = null; + this.resolutionLabel = null; + this.fpsLabel = null; + this.bgColorDot = null; + this.customWidthInput = null; + this.customHeightInput = null; + this.colorInput = null; + + this.resolutionChangeCallback = null; + this.fpsChangeCallback = null; + this.backgroundChangeCallback = null; + } +} From 0e7c64840e95d25371d0db1aa4c74b92fe361339 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 14:26:17 +1100 Subject: [PATCH 063/463] feat: add media toolbar for video and image clip editing --- src/components/canvas/shotstack-canvas.ts | 19 +- src/core/ui/media-toolbar.css.ts | 486 +++++++++++++ src/core/ui/media-toolbar.ts | 800 ++++++++++++++++++++++ 3 files changed, 1302 insertions(+), 3 deletions(-) create mode 100644 src/core/ui/media-toolbar.css.ts create mode 100644 src/core/ui/media-toolbar.ts diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 37ac1023..49c678c4 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,6 +1,7 @@ import { Inspector } from "@canvas/system/inspector"; import { Edit } from "@core/edit"; import { CanvasToolbar } from "@core/ui/canvas-toolbar"; +import { MediaToolbar } from "@core/ui/media-toolbar"; import { RichTextToolbar } from "@core/ui/rich-text-toolbar"; import { TranscriptionIndicator } from "@core/ui/transcription-indicator"; import { type Size } from "@layouts/geometry"; @@ -25,6 +26,7 @@ export class Canvas { private readonly inspector: Inspector; private readonly transcriptionIndicator: TranscriptionIndicator; private readonly richTextToolbar: RichTextToolbar; + private readonly mediaToolbar: MediaToolbar; private readonly canvasToolbar: CanvasToolbar; private container?: pixi.Container; @@ -44,6 +46,7 @@ export class Canvas { this.inspector = new Inspector(); this.transcriptionIndicator = new TranscriptionIndicator(); this.richTextToolbar = new RichTextToolbar(edit); + this.mediaToolbar = new MediaToolbar(edit); this.canvasToolbar = new CanvasToolbar(); this.onTickBound = this.onTick.bind(this); this.onBackgroundClickBound = this.onBackgroundClick.bind(this); @@ -78,7 +81,8 @@ export class Canvas { root.appendChild(this.application.canvas); this.richTextToolbar.mount(root); - this.setupRichTextToolbarListeners(); + this.mediaToolbar.mount(root); + this.setupClipToolbarListeners(); this.canvasToolbar.mount(root); this.setupCanvasToolbarListeners(); @@ -264,18 +268,26 @@ export class Canvas { }); } - private setupRichTextToolbarListeners(): void { + private setupClipToolbarListeners(): void { this.edit.events.on("clip:selected", ({ trackIndex, clipIndex }) => { const player = this.edit.getPlayerClip(trackIndex, clipIndex); - if (player?.clipConfiguration.asset.type === "rich-text") { + const assetType = player?.clipConfiguration.asset.type; + + if (assetType === "rich-text") { + this.mediaToolbar.hide(); this.richTextToolbar.show(trackIndex, clipIndex); + } else if (assetType === "video" || assetType === "image") { + this.richTextToolbar.hide(); + this.mediaToolbar.show(trackIndex, clipIndex, assetType === "video"); } else { this.richTextToolbar.hide(); + this.mediaToolbar.hide(); } }); this.edit.events.on("selection:cleared", () => { this.richTextToolbar.hide(); + this.mediaToolbar.hide(); }); } @@ -333,6 +345,7 @@ export class Canvas { this.inspector.dispose(); this.transcriptionIndicator.dispose(); this.richTextToolbar.dispose(); + this.mediaToolbar.dispose(); this.canvasToolbar.dispose(); this.application.destroy(true, { children: true, texture: true }); diff --git a/src/core/ui/media-toolbar.css.ts b/src/core/ui/media-toolbar.css.ts new file mode 100644 index 00000000..06edc136 --- /dev/null +++ b/src/core/ui/media-toolbar.css.ts @@ -0,0 +1,486 @@ +export const MEDIA_TOOLBAR_STYLES = ` +.ss-media-toolbar { + display: none; + position: absolute; + top: 12px; + left: 50%; + transform: translateX(-50%); + background: rgba(24, 24, 27, 0.95); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 6px 8px; + gap: 2px; + z-index: 100; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + align-items: center; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.ss-media-toolbar.visible { + display: flex; +} + +.ss-media-toolbar-group { + display: flex; + align-items: center; + gap: 1px; +} + +.ss-media-toolbar-group--bordered { + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + padding: 2px; +} + +.ss-media-toolbar-divider { + width: 1px; + height: 20px; + background: rgba(255, 255, 255, 0.1); + margin: 0 6px; +} + +.ss-media-toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-width: 32px; + height: 32px; + padding: 0 10px; + background: transparent; + border: none; + border-radius: 6px; + color: rgba(255, 255, 255, 0.65); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + position: relative; + white-space: nowrap; +} + +.ss-media-toolbar-btn:hover { + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.95); +} + +.ss-media-toolbar-btn.active { + background: rgba(255, 255, 255, 0.15); + color: #fff; +} + +.ss-media-toolbar-btn svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.ss-media-toolbar-btn .chevron { + width: 10px; + height: 10px; + opacity: 0.5; + margin-left: -2px; +} + +.ss-media-toolbar-value { + min-width: 36px; + text-align: center; + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + font-variant-numeric: tabular-nums; +} + +/* Dropdown wrapper */ +.ss-media-toolbar-dropdown { + position: relative; +} + +/* Popup styling */ +.ss-media-toolbar-popup { + display: none; + position: absolute; + top: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background: rgba(32, 32, 36, 0.98); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + padding: 8px; + min-width: 180px; + z-index: 200; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2); +} + +.ss-media-toolbar-popup.visible { + display: block; +} + +.ss-media-toolbar-popup::before { + content: ""; + position: absolute; + top: -6px; + left: 50%; + transform: translateX(-50%) rotate(45deg); + width: 10px; + height: 10px; + background: rgba(32, 32, 36, 0.98); + border-left: 1px solid rgba(255, 255, 255, 0.1); + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Popup items for dropdowns */ +.ss-media-toolbar-popup-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s ease; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); +} + +.ss-media-toolbar-popup-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.ss-media-toolbar-popup-item.active { + background: rgba(255, 255, 255, 0.12); +} + +.ss-media-toolbar-popup-item .checkmark { + width: 14px; + height: 14px; + opacity: 0; + color: #fff; +} + +.ss-media-toolbar-popup-item.active .checkmark { + opacity: 1; +} + +.ss-media-toolbar-popup-item-label { + display: flex; + flex-direction: column; + gap: 2px; +} + +.ss-media-toolbar-popup-item-sublabel { + font-size: 11px; + font-weight: 400; + color: rgba(255, 255, 255, 0.4); +} + +/* Slider popup */ +.ss-media-toolbar-popup--slider { + min-width: 200px; + padding: 14px 16px; +} + +.ss-media-toolbar-popup-header { + font-size: 11px; + font-weight: 600; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 12px; +} + +.ss-media-toolbar-slider-row { + display: flex; + align-items: center; + gap: 12px; +} + +.ss-media-toolbar-slider { + -webkit-appearance: none; + appearance: none; + flex: 1; + height: 4px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; + cursor: pointer; +} + +.ss-media-toolbar-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 16px; + height: 16px; + background: #fff; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + transition: transform 0.15s ease; + margin-top: -6px; +} + +.ss-media-toolbar-slider::-webkit-slider-thumb:hover { + transform: scale(1.1); +} + +.ss-media-toolbar-slider::-moz-range-thumb { + width: 16px; + height: 16px; + background: #fff; + border: none; + border-radius: 50%; + cursor: pointer; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); +} + +.ss-media-toolbar-slider::-webkit-slider-runnable-track { + height: 4px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; +} + +.ss-media-toolbar-slider::-moz-range-track { + height: 4px; + background: rgba(255, 255, 255, 0.15); + border-radius: 2px; +} + +.ss-media-toolbar-slider-value { + min-width: 42px; + text-align: right; + font-size: 13px; + font-weight: 500; + color: rgba(255, 255, 255, 0.9); + font-variant-numeric: tabular-nums; +} + +/* Preset buttons */ +.ss-media-toolbar-presets { + display: flex; + gap: 6px; + margin-top: 12px; +} + +.ss-media-toolbar-preset { + flex: 1; + padding: 8px 6px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + color: rgba(255, 255, 255, 0.7); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.ss-media-toolbar-preset:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.9); +} + +.ss-media-toolbar-preset.active { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.25); + color: #fff; +} + +/* Volume section - hidden for images */ +.ss-media-toolbar-volume { + display: flex; + align-items: center; +} + +.ss-media-toolbar-volume.hidden { + display: none; +} + +/* Transition popup - tabbed design */ +.ss-media-toolbar-popup--transition { + min-width: 220px; + padding: 12px; +} + +/* Segmented toggle for IN/OUT */ +.ss-transition-tabs { + display: flex; + background: rgba(255, 255, 255, 0.06); + border-radius: 6px; + padding: 2px; + margin-bottom: 12px; +} + +.ss-transition-tab { + flex: 1; + padding: 6px 12px; + background: transparent; + border: none; + border-radius: 4px; + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.ss-transition-tab:hover { + color: rgba(255, 255, 255, 0.7); +} + +.ss-transition-tab.active { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +/* Effect grid - 3 columns */ +.ss-transition-effects { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 4px; +} + +.ss-transition-effect { + padding: 8px 4px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + color: rgba(255, 255, 255, 0.6); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.ss-transition-effect:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.ss-transition-effect.active { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Direction row - progressive disclosure */ +.ss-transition-direction-row { + display: none; + align-items: center; + gap: 6px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.ss-transition-direction-row.visible { + display: flex; +} + +.ss-transition-label { + font-size: 10px; + font-weight: 500; + color: rgba(255, 255, 255, 0.4); + min-width: 52px; +} + +.ss-transition-directions { + display: flex; + gap: 4px; + flex: 1; +} + +.ss-transition-dir { + flex: 1; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 5px; + color: rgba(255, 255, 255, 0.5); + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.ss-transition-dir:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.ss-transition-dir.active { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; +} + +.ss-transition-dir.hidden { + display: none; +} + +/* Speed row - stepper design */ +.ss-transition-speed-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.ss-transition-speed-stepper { + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + overflow: hidden; +} + +.ss-transition-speed-btn { + width: 28px; + height: 26px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.ss-transition-speed-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.ss-transition-speed-btn:active { + background: rgba(255, 255, 255, 0.12); +} + +.ss-transition-speed-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.ss-transition-speed-value { + min-width: 42px; + padding: 0 4px; + text-align: center; + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); + font-variant-numeric: tabular-nums; + border-left: 1px solid rgba(255, 255, 255, 0.06); + border-right: 1px solid rgba(255, 255, 255, 0.06); +} +`; diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts new file mode 100644 index 00000000..ffd01f69 --- /dev/null +++ b/src/core/ui/media-toolbar.ts @@ -0,0 +1,800 @@ +import type { Edit } from "@core/edit"; + +import { MEDIA_TOOLBAR_STYLES } from "./media-toolbar.css"; + +type FitValue = "crop" | "cover" | "contain" | "none"; + +interface FitOption { + value: FitValue; + label: string; + description: string; +} + +const FIT_OPTIONS: FitOption[] = [ + { value: "crop", label: "Crop", description: "Fill frame, clip overflow" }, + { value: "cover", label: "Cover", description: "Fill frame, keep ratio" }, + { value: "contain", label: "Contain", description: "Fit inside frame" }, + { value: "none", label: "None", description: "Original size" } +]; + +const ICONS = { + fit: ``, + opacity: ``, + scale: ``, + volume: ``, + volumeMute: ``, + transition: ``, + chevron: ``, + check: `` +}; + +export class MediaToolbar { + private container: HTMLDivElement | null = null; + private styleElement: HTMLStyleElement | null = null; + private edit: Edit; + + // Current clip info + private currentTrackIndex: number = -1; + private currentClipIndex: number = -1; + private isVideoClip: boolean = false; + + // Current values + private currentFit: FitValue = "crop"; + private currentOpacity: number = 100; + private currentScale: number = 100; + private currentVolume: number = 100; + + // Transition state (tabbed design) + private activeTransitionTab: "in" | "out" = "in"; + private transitionInEffect: string = ""; + private transitionInDirection: string = ""; + private transitionInSpeed: number = 1.0; // Speed in seconds + private transitionOutEffect: string = ""; + private transitionOutDirection: string = ""; + private transitionOutSpeed: number = 1.0; // Speed in seconds + + // Speed step values in seconds + private readonly SPEED_VALUES = [0.25, 0.5, 1.0, 2.0]; + + // Transition popup elements + private directionRow: HTMLDivElement | null = null; + private speedValueLabel: HTMLSpanElement | null = null; + + // Button elements + private fitBtn: HTMLButtonElement | null = null; + private opacityBtn: HTMLButtonElement | null = null; + private scaleBtn: HTMLButtonElement | null = null; + private volumeBtn: HTMLButtonElement | null = null; + private transitionBtn: HTMLButtonElement | null = null; + + // Popup elements + private fitPopup: HTMLDivElement | null = null; + private opacityPopup: HTMLDivElement | null = null; + private scalePopup: HTMLDivElement | null = null; + private volumePopup: HTMLDivElement | null = null; + private transitionPopup: HTMLDivElement | null = null; + + // Slider elements + private opacitySlider: HTMLInputElement | null = null; + private scaleSlider: HTMLInputElement | null = null; + private volumeSlider: HTMLInputElement | null = null; + + // Value display elements + private fitLabel: HTMLSpanElement | null = null; + private opacityValue: HTMLSpanElement | null = null; + private scaleValue: HTMLSpanElement | null = null; + private volumeValue: HTMLSpanElement | null = null; + + // Volume section + private volumeSection: HTMLDivElement | null = null; + + // Click outside handler + private clickOutsideHandler: ((e: MouseEvent) => void) | null = null; + + constructor(edit: Edit) { + this.edit = edit; + this.injectStyles(); + } + + private injectStyles(): void { + if (document.getElementById("ss-media-toolbar-styles")) return; + + this.styleElement = document.createElement("style"); + this.styleElement.id = "ss-media-toolbar-styles"; + this.styleElement.textContent = MEDIA_TOOLBAR_STYLES; + document.head.appendChild(this.styleElement); + } + + mount(parent: HTMLElement): void { + this.container = document.createElement("div"); + this.container.className = "ss-media-toolbar"; + + this.container.innerHTML = ` + +
+ +
+ ${FIT_OPTIONS.map( + (opt) => ` +
+
+ ${opt.label} + ${opt.description} +
+ ${ICONS.check} +
+ ` + ).join("")} +
+
+ +
+ + +
+ +
+
Opacity
+
+ + 100% +
+
+
+ +
+ + +
+ +
+
Scale
+
+ + 100% +
+
+
+ + +
+
+
+ +
+
Volume
+
+ + 100% +
+
+
+
+ +
+ + +
+ +
+ +
+ + +
+ + +
+ + + + + + +
+ + +
+ Direction +
+ + + + +
+
+ + +
+ Speed +
+ + 1.0s + +
+
+
+
+ `; + + parent.insertBefore(this.container, parent.firstChild); + + // Query elements + this.fitBtn = this.container.querySelector('[data-action="fit"]'); + this.opacityBtn = this.container.querySelector('[data-action="opacity"]'); + this.scaleBtn = this.container.querySelector('[data-action="scale"]'); + this.volumeBtn = this.container.querySelector('[data-action="volume"]'); + this.transitionBtn = this.container.querySelector('[data-action="transition"]'); + + this.fitPopup = this.container.querySelector('[data-popup="fit"]'); + this.opacityPopup = this.container.querySelector('[data-popup="opacity"]'); + this.scalePopup = this.container.querySelector('[data-popup="scale"]'); + this.volumePopup = this.container.querySelector('[data-popup="volume"]'); + this.transitionPopup = this.container.querySelector('[data-popup="transition"]'); + + this.fitLabel = this.container.querySelector("[data-fit-label]"); + this.opacityValue = this.container.querySelector("[data-opacity-value]"); + this.scaleValue = this.container.querySelector("[data-scale-value]"); + this.volumeValue = this.container.querySelector("[data-volume-value]"); + + this.opacitySlider = this.container.querySelector("[data-opacity-slider]"); + this.scaleSlider = this.container.querySelector("[data-scale-slider]"); + this.volumeSlider = this.container.querySelector("[data-volume-slider]"); + + this.volumeSection = this.container.querySelector("[data-volume-section]"); + + // Transition elements + this.directionRow = this.container.querySelector("[data-direction-row]"); + this.speedValueLabel = this.container.querySelector("[data-speed-value]"); + + this.setupEventListeners(); + } + + private setupEventListeners(): void { + // Toggle popups + this.fitBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("fit"); + }); + this.opacityBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("opacity"); + }); + this.scaleBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("scale"); + }); + this.volumeBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("volume"); + }); + this.transitionBtn?.addEventListener("click", (e) => { + e.stopPropagation(); + this.togglePopup("transition"); + }); + + // Fit options + this.fitPopup?.querySelectorAll("[data-fit]").forEach((item) => { + item.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLElement; + const fit = el.dataset["fit"] as FitValue; + this.handleFitChange(fit); + }); + }); + + // Opacity slider + this.opacitySlider?.addEventListener("input", () => { + const value = parseInt(this.opacitySlider!.value, 10); + this.handleOpacityChange(value); + }); + + // Scale slider + this.scaleSlider?.addEventListener("input", () => { + const value = parseInt(this.scaleSlider!.value, 10); + this.handleScaleChange(value); + }); + + // Volume slider + this.volumeSlider?.addEventListener("input", () => { + const value = parseInt(this.volumeSlider!.value, 10); + this.handleVolumeChange(value); + }); + + // Transition tabs + this.transitionPopup?.querySelectorAll("[data-tab]").forEach((tab) => { + tab.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLElement; + const tabValue = el.dataset["tab"] as "in" | "out"; + this.handleTabChange(tabValue); + }); + }); + + // Effect buttons + this.transitionPopup?.querySelectorAll("[data-effect]").forEach((btn) => { + btn.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLElement; + const effect = el.dataset["effect"] || ""; + this.handleEffectSelect(effect); + }); + }); + + // Direction buttons + this.transitionPopup?.querySelectorAll("[data-dir]").forEach((btn) => { + btn.addEventListener("click", (e) => { + const el = e.currentTarget as HTMLElement; + const dir = el.dataset["dir"] || ""; + this.handleDirectionSelect(dir); + }); + }); + + // Speed stepper buttons + const speedDecrease = this.transitionPopup?.querySelector("[data-speed-decrease]"); + const speedIncrease = this.transitionPopup?.querySelector("[data-speed-increase]"); + speedDecrease?.addEventListener("click", () => this.handleSpeedStep(-1)); + speedIncrease?.addEventListener("click", () => this.handleSpeedStep(1)); + + // Click outside to close + this.clickOutsideHandler = (e: MouseEvent) => { + if (!this.container?.contains(e.target as Node)) { + this.closeAllPopups(); + } + }; + document.addEventListener("click", this.clickOutsideHandler); + } + + private togglePopup(popup: "fit" | "opacity" | "scale" | "volume" | "transition"): void { + const popupMap = { + fit: { popup: this.fitPopup, btn: this.fitBtn }, + opacity: { popup: this.opacityPopup, btn: this.opacityBtn }, + scale: { popup: this.scalePopup, btn: this.scaleBtn }, + volume: { popup: this.volumePopup, btn: this.volumeBtn }, + transition: { popup: this.transitionPopup, btn: this.transitionBtn } + }; + + const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); + this.closeAllPopups(); + + if (!isCurrentlyOpen) { + popupMap[popup].popup?.classList.add("visible"); + popupMap[popup].btn?.classList.add("active"); + } + } + + private closeAllPopups(): void { + this.fitPopup?.classList.remove("visible"); + this.opacityPopup?.classList.remove("visible"); + this.scalePopup?.classList.remove("visible"); + this.volumePopup?.classList.remove("visible"); + this.transitionPopup?.classList.remove("visible"); + this.fitBtn?.classList.remove("active"); + this.opacityBtn?.classList.remove("active"); + this.scaleBtn?.classList.remove("active"); + this.volumeBtn?.classList.remove("active"); + this.transitionBtn?.classList.remove("active"); + } + + private handleFitChange(fit: FitValue): void { + this.currentFit = fit; + this.updateFitDisplay(); + this.updateFitActiveState(); + this.closeAllPopups(); + this.applyClipUpdate({ fit }); + } + + private handleOpacityChange(value: number): void { + this.currentOpacity = value; + this.updateOpacityDisplay(); + this.applyClipUpdate({ opacity: value / 100 }); + } + + private handleScaleChange(value: number): void { + this.currentScale = value; + this.updateScaleDisplay(); + this.applyClipUpdate({ scale: value / 100 }); + } + + private handleVolumeChange(value: number): void { + this.currentVolume = value; + this.updateVolumeDisplay(); + + // Volume is on the asset, not the clip + const player = this.edit.getPlayerClip(this.currentTrackIndex, this.currentClipIndex); + if (player && player.clipConfiguration.asset.type === "video") { + this.edit.updateClip(this.currentTrackIndex, this.currentClipIndex, { + asset: { + ...player.clipConfiguration.asset, + volume: value / 100 + } + }); + } + } + + // ==================== Transition Handlers ==================== + + private handleTabChange(tab: "in" | "out"): void { + this.activeTransitionTab = tab; + this.updateTransitionUI(); + } + + private handleEffectSelect(effect: string): void { + const tab = this.activeTransitionTab; + + if (tab === "in") { + this.transitionInEffect = effect; + this.transitionInDirection = this.getDefaultDirection(effect); + } else { + this.transitionOutEffect = effect; + this.transitionOutDirection = this.getDefaultDirection(effect); + } + + this.updateTransitionUI(); + this.applyTransitionUpdate(); + } + + private handleDirectionSelect(direction: string): void { + const tab = this.activeTransitionTab; + + if (tab === "in") { + this.transitionInDirection = direction; + } else { + this.transitionOutDirection = direction; + } + + this.updateTransitionUI(); + this.applyTransitionUpdate(); + } + + private handleSpeedStep(direction: number): void { + const tab = this.activeTransitionTab; + const currentSpeed = tab === "in" ? this.transitionInSpeed : this.transitionOutSpeed; + + // Find current index in speed values + let currentIdx = this.SPEED_VALUES.indexOf(currentSpeed); + if (currentIdx === -1) { + // Find closest value + currentIdx = this.SPEED_VALUES.findIndex((v) => v >= currentSpeed); + if (currentIdx === -1) currentIdx = this.SPEED_VALUES.length - 1; + } + + // Calculate new index + const newIdx = Math.max(0, Math.min(this.SPEED_VALUES.length - 1, currentIdx + direction)); + const newSpeed = this.SPEED_VALUES[newIdx]; + + if (tab === "in") { + this.transitionInSpeed = newSpeed; + } else { + this.transitionOutSpeed = newSpeed; + } + + this.updateTransitionUI(); + this.applyTransitionUpdate(); + } + + private needsDirection(effect: string): boolean { + return ["slide", "wipe", "carousel"].includes(effect); + } + + private getDefaultDirection(effect: string): string { + if (this.needsDirection(effect)) { + return "Right"; + } + return ""; + } + + private speedToSuffix(speed: number, effect: string): string { + // For slide/carousel: default is 0.5s (Fast), so mapping is different + // No suffix → 0.5s, Slow → 1.0s, Fast → 0.25s + // For others (fade, zoom, wipe): default is 1.0s + // No suffix → 1.0s, Slow → 2.0s, Fast → 0.5s + + const isSlideOrCarousel = effect === "slide" || effect === "carousel"; + + if (isSlideOrCarousel) { + if (speed === 0.5) return ""; // Default for slide/carousel + if (speed === 1.0) return "Slow"; + if (speed === 0.25) return "Fast"; + if (speed === 2.0) return "Slow"; // Approximate + } else { + if (speed === 1.0) return ""; // Default for fade/zoom/wipe + if (speed === 2.0) return "Slow"; + if (speed === 0.5) return "Fast"; + if (speed === 0.25) return "Fast"; // Approximate + } + return ""; + } + + private buildTransitionValue(effect: string, direction: string, speed: number): string { + if (!effect) return ""; + + const speedSuffix = this.speedToSuffix(speed, effect); + + // For effects without direction (fade, zoom) + if (!this.needsDirection(effect)) { + return effect + speedSuffix; + } + + // For directional effects: slide + Right = slideRight + return effect + direction + speedSuffix; + } + + private suffixToSpeed(suffix: string, effect: string): number { + // Reverse mapping from speedToSuffix + const isSlideOrCarousel = effect === "slide" || effect === "carousel"; + + if (isSlideOrCarousel) { + if (suffix === "") return 0.5; // Default for slide/carousel + if (suffix === "Slow") return 1.0; + if (suffix === "Fast") return 0.25; + } else { + if (suffix === "") return 1.0; // Default for fade/zoom/wipe + if (suffix === "Slow") return 2.0; + if (suffix === "Fast") return 0.5; + } + return 1.0; + } + + private parseTransitionValue(value: string): { effect: string; direction: string; speed: number } { + if (!value) return { effect: "", direction: "", speed: 1.0 }; + + // Extract speed suffix first + let speedSuffix = ""; + let base = value; + if (value.endsWith("Fast")) { + speedSuffix = "Fast"; + base = value.slice(0, -4); + } else if (value.endsWith("Slow")) { + speedSuffix = "Slow"; + base = value.slice(0, -4); + } + + // Check for directional effects + const directions = ["Left", "Right", "Up", "Down"]; + for (const dir of directions) { + if (base.endsWith(dir)) { + const effect = base.slice(0, -dir.length); + const speed = this.suffixToSpeed(speedSuffix, effect); + return { effect, direction: dir, speed }; + } + } + + // Non-directional effect (fade, zoom) + const speed = this.suffixToSpeed(speedSuffix, base); + return { effect: base, direction: "", speed }; + } + + private applyTransitionUpdate(): void { + const transitionIn = this.buildTransitionValue(this.transitionInEffect, this.transitionInDirection, this.transitionInSpeed); + const transitionOut = this.buildTransitionValue(this.transitionOutEffect, this.transitionOutDirection, this.transitionOutSpeed); + + const transition: { in?: string; out?: string } = {}; + if (transitionIn) { + transition.in = transitionIn; + } + if (transitionOut) { + transition.out = transitionOut; + } + + if (!transitionIn && !transitionOut) { + this.applyClipUpdate({ transition: undefined }); + } else { + this.applyClipUpdate({ transition }); + } + } + + private updateTransitionUI(): void { + const tab = this.activeTransitionTab; + const effect = tab === "in" ? this.transitionInEffect : this.transitionOutEffect; + const direction = tab === "in" ? this.transitionInDirection : this.transitionOutDirection; + const speed = tab === "in" ? this.transitionInSpeed : this.transitionOutSpeed; + + // Update tab active states + this.transitionPopup?.querySelectorAll("[data-tab]").forEach((el) => { + const tabEl = el as HTMLElement; + tabEl.classList.toggle("active", tabEl.dataset["tab"] === tab); + }); + + // Update effect active states + this.transitionPopup?.querySelectorAll("[data-effect]").forEach((el) => { + const effectEl = el as HTMLElement; + effectEl.classList.toggle("active", effectEl.dataset["effect"] === effect); + }); + + // Update direction visibility and active states + const showDirection = this.needsDirection(effect); + this.directionRow?.classList.toggle("visible", showDirection); + + // Hide Up/Down for wipe (only Left/Right) + this.transitionPopup?.querySelectorAll("[data-dir]").forEach((el) => { + const dirEl = el as HTMLElement; + const dir = dirEl.dataset["dir"] || ""; + const isVertical = dir === "Up" || dir === "Down"; + dirEl.classList.toggle("hidden", effect === "wipe" && isVertical); + dirEl.classList.toggle("active", dir === direction); + }); + + // Update speed display in seconds (2 decimal places) + if (this.speedValueLabel) { + this.speedValueLabel.textContent = `${speed.toFixed(2)}s`; + } + + // Update stepper button disabled states + const speedIdx = this.SPEED_VALUES.indexOf(speed); + const decreaseBtn = this.transitionPopup?.querySelector("[data-speed-decrease]") as HTMLButtonElement | null; + const increaseBtn = this.transitionPopup?.querySelector("[data-speed-increase]") as HTMLButtonElement | null; + if (decreaseBtn) decreaseBtn.disabled = speedIdx <= 0; + if (increaseBtn) increaseBtn.disabled = speedIdx >= this.SPEED_VALUES.length - 1; + } + + private applyClipUpdate(updates: Record): void { + if (this.currentTrackIndex >= 0 && this.currentClipIndex >= 0) { + this.edit.updateClip(this.currentTrackIndex, this.currentClipIndex, updates); + } + } + + private updateFitDisplay(): void { + if (this.fitLabel) { + const option = FIT_OPTIONS.find((o) => o.value === this.currentFit); + this.fitLabel.textContent = option?.label || "Crop"; + } + } + + private updateOpacityDisplay(): void { + const text = `${this.currentOpacity}%`; + if (this.opacityValue) this.opacityValue.textContent = text; + if (this.opacitySlider) this.opacitySlider.value = String(this.currentOpacity); + + const display = this.opacityPopup?.querySelector("[data-opacity-display]"); + if (display) display.textContent = text; + } + + private updateScaleDisplay(): void { + const text = `${this.currentScale}%`; + if (this.scaleValue) this.scaleValue.textContent = text; + if (this.scaleSlider) this.scaleSlider.value = String(this.currentScale); + + const display = this.scalePopup?.querySelector("[data-scale-display]"); + if (display) display.textContent = text; + } + + private updateVolumeDisplay(): void { + const text = `${this.currentVolume}%`; + if (this.volumeValue) this.volumeValue.textContent = text; + if (this.volumeSlider) this.volumeSlider.value = String(this.currentVolume); + + const display = this.volumePopup?.querySelector("[data-volume-display]"); + if (display) display.textContent = text; + + // Update icon + const iconContainer = this.container?.querySelector("[data-volume-icon]"); + if (iconContainer) { + iconContainer.innerHTML = this.currentVolume === 0 ? ICONS.volumeMute : ICONS.volume; + } + } + + private updateFitActiveState(): void { + this.fitPopup?.querySelectorAll("[data-fit]").forEach((item) => { + const el = item as HTMLElement; + el.classList.toggle("active", el.dataset["fit"] === this.currentFit); + }); + } + + show(trackIndex: number, clipIndex: number, isVideo: boolean): void { + this.currentTrackIndex = trackIndex; + this.currentClipIndex = clipIndex; + this.isVideoClip = isVideo; + + // Get current clip values + const player = this.edit.getPlayerClip(trackIndex, clipIndex); + if (player) { + const clip = player.clipConfiguration; + + // Fit + this.currentFit = (clip.fit as FitValue) || "crop"; + + // Opacity (convert from 0-1 to 0-100) + const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; + this.currentOpacity = Math.round(opacity * 100); + + // Scale (convert from 0-1 to percentage) + const scale = typeof clip.scale === "number" ? clip.scale : 1; + this.currentScale = Math.round(scale * 100); + + // Volume (video only) + if (isVideo && clip.asset.type === "video") { + const volume = typeof clip.asset.volume === "number" ? clip.asset.volume : 1; + this.currentVolume = Math.round(volume * 100); + } + + // Transition - parse effect, direction, and speed + const parsedIn = this.parseTransitionValue(clip.transition?.in || ""); + const parsedOut = this.parseTransitionValue(clip.transition?.out || ""); + this.transitionInEffect = parsedIn.effect; + this.transitionInDirection = parsedIn.direction; + this.transitionInSpeed = parsedIn.speed; + this.transitionOutEffect = parsedOut.effect; + this.transitionOutDirection = parsedOut.direction; + this.transitionOutSpeed = parsedOut.speed; + } + + // Update displays + this.updateFitDisplay(); + this.updateOpacityDisplay(); + this.updateScaleDisplay(); + this.updateVolumeDisplay(); + + // Update active states + this.updateFitActiveState(); + + // Reset to IN tab and update transition UI + this.activeTransitionTab = "in"; + this.updateTransitionUI(); + + // Show/hide volume section based on asset type + if (this.volumeSection) { + this.volumeSection.classList.toggle("hidden", !isVideo); + } + + // Show toolbar + this.container?.classList.add("visible"); + } + + hide(): void { + this.container?.classList.remove("visible"); + this.closeAllPopups(); + this.currentTrackIndex = -1; + this.currentClipIndex = -1; + } + + dispose(): void { + if (this.clickOutsideHandler) { + document.removeEventListener("click", this.clickOutsideHandler); + this.clickOutsideHandler = null; + } + + this.container?.remove(); + this.container = null; + + this.fitBtn = null; + this.opacityBtn = null; + this.scaleBtn = null; + this.volumeBtn = null; + this.transitionBtn = null; + + this.fitPopup = null; + this.opacityPopup = null; + this.scalePopup = null; + this.volumePopup = null; + this.transitionPopup = null; + + this.opacitySlider = null; + this.scaleSlider = null; + this.volumeSlider = null; + + this.fitLabel = null; + this.opacityValue = null; + this.scaleValue = null; + this.volumeValue = null; + + this.volumeSection = null; + + // Transition elements + this.directionRow = null; + this.speedValueLabel = null; + } +} From 9f732e2347b7d4e1106b6e07f974670c48174bd4 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 14:28:51 +1100 Subject: [PATCH 064/463] feat: update resolution presets and default fps to 25 --- src/core/ui/canvas-toolbar.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts index 4ba48260..dfad808c 100644 --- a/src/core/ui/canvas-toolbar.ts +++ b/src/core/ui/canvas-toolbar.ts @@ -12,11 +12,11 @@ interface ResolutionPreset { } const RESOLUTION_PRESETS: ResolutionPreset[] = [ - { label: "1920 × 1080", sublabel: "16:9 • HD", width: 1920, height: 1080 }, + { label: "1920 × 1080", sublabel: "16:9 • 1080p", width: 1920, height: 1080 }, + { label: "1280 × 720", sublabel: "16:9 • 720p", width: 1280, height: 720 }, { label: "1080 × 1920", sublabel: "9:16 • Vertical", width: 1080, height: 1920 }, { label: "1080 × 1080", sublabel: "1:1 • Square", width: 1080, height: 1080 }, - { label: "1080 × 1350", sublabel: "4:5 • Portrait", width: 1080, height: 1350 }, - { label: "3840 × 2160", sublabel: "16:9 • 4K", width: 3840, height: 2160 } + { label: "1080 × 1350", sublabel: "4:5 • Portrait", width: 1080, height: 1350 } ]; const FPS_OPTIONS = [24, 25, 30, 60]; @@ -50,7 +50,7 @@ export class CanvasToolbar { // Current state private currentWidth: number = 1920; private currentHeight: number = 1080; - private currentFps: number = 30; + private currentFps: number = 25; private currentBgColor: string = "#000000"; // Popup elements From 7257431918adff563dad702a3b8a9e6f3e24339b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 14:31:38 +1100 Subject: [PATCH 065/463] style: reformat code for consistency --- .../canvas/players/rich-text-player.ts | 4 +- src/components/canvas/shotstack-canvas.ts | 6 +- src/core/captions/transcription-service.ts | 2 +- src/core/loaders/subtitle-load-parser.ts | 1 - src/core/ui/canvas-toolbar.ts | 30 +- src/core/ui/font-color-picker.ts | 325 +++++++++++++++--- src/core/ui/media-toolbar.ts | 40 +-- src/core/ui/rich-text-toolbar.ts | 85 +++-- 8 files changed, 368 insertions(+), 125 deletions(-) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index ded1bfea..8e1d2a59 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -50,7 +50,9 @@ export class RichTextPlayer extends Player { // Use explicit font.weight if set, otherwise fall back to parsed weight from family name const explicitWeight = richTextAsset.font?.weight; const fontWeight = explicitWeight - ? (typeof explicitWeight === "string" ? parseInt(explicitWeight, 10) || parsedWeight : explicitWeight) + ? typeof explicitWeight === "string" + ? parseInt(explicitWeight, 10) || parsedWeight + : explicitWeight : parsedWeight; // Find matching timeline font for customFonts payload diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 49c678c4..01af2671 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -296,17 +296,17 @@ export class Canvas { this.edit.setOutputSize(width, height); }); - this.canvasToolbar.onFpsChange((fps) => { + this.canvasToolbar.onFpsChange(fps => { this.edit.setOutputFps(fps); }); - this.canvasToolbar.onBackgroundChange((color) => { + this.canvasToolbar.onBackgroundChange(color => { this.edit.setTimelineBackground(color); }); } private syncCanvasToolbarState(): void { - const size = this.edit.size; + const { size } = this.edit; this.canvasToolbar.setResolution(size.width, size.height); const fps = this.edit.getOutputFps(); diff --git a/src/core/captions/transcription-service.ts b/src/core/captions/transcription-service.ts index 1926bc75..36bc338e 100644 --- a/src/core/captions/transcription-service.ts +++ b/src/core/captions/transcription-service.ts @@ -75,7 +75,7 @@ export class TranscriptionService { } this.worker.onmessage = (event: MessageEvent) => { - const {data} = event; + const { data } = event; switch (data.type) { case "progress": diff --git a/src/core/loaders/subtitle-load-parser.ts b/src/core/loaders/subtitle-load-parser.ts index 8d41bbab..548c85a6 100644 --- a/src/core/loaders/subtitle-load-parser.ts +++ b/src/core/loaders/subtitle-load-parser.ts @@ -1,7 +1,6 @@ import { type Cue, parseSubtitle } from "@core/captions"; import * as pixi from "pixi.js"; - export interface SubtitleAsset { content: string; cues: Cue[]; diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts index dfad808c..ea711a6b 100644 --- a/src/core/ui/canvas-toolbar.ts +++ b/src/core/ui/canvas-toolbar.ts @@ -111,7 +111,7 @@ export class CanvasToolbar {
Presets
${RESOLUTION_PRESETS.map( - (preset) => ` + preset => `
${preset.label} @@ -145,7 +145,7 @@ export class CanvasToolbar {
${COLOR_SWATCHES.map( - (color) => ` + color => `
` ).join("")} @@ -163,7 +163,7 @@ export class CanvasToolbar {
${FPS_OPTIONS.map( - (fps) => ` + fps => `
${fps} fps ${ICONS.check} @@ -200,22 +200,22 @@ export class CanvasToolbar { private setupEventListeners(): void { // Toggle popups - this.resolutionBtn?.addEventListener("click", (e) => { + this.resolutionBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("resolution"); }); - this.backgroundBtn?.addEventListener("click", (e) => { + this.backgroundBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("background"); }); - this.fpsBtn?.addEventListener("click", (e) => { + this.fpsBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("fps"); }); // Resolution preset clicks - this.resolutionPopup?.querySelectorAll("[data-width]").forEach((item) => { - item.addEventListener("click", (e) => { + this.resolutionPopup?.querySelectorAll("[data-width]").forEach(item => { + item.addEventListener("click", e => { const el = e.currentTarget as HTMLElement; const width = parseInt(el.dataset["width"] || "1920", 10); const height = parseInt(el.dataset["height"] || "1080", 10); @@ -228,8 +228,8 @@ export class CanvasToolbar { this.customHeightInput?.addEventListener("change", () => this.handleCustomSizeChange()); // FPS clicks - this.fpsPopup?.querySelectorAll("[data-fps]").forEach((item) => { - item.addEventListener("click", (e) => { + this.fpsPopup?.querySelectorAll("[data-fps]").forEach(item => { + item.addEventListener("click", e => { const el = e.currentTarget as HTMLElement; const fps = parseInt(el.dataset["fps"] || "30", 10); this.handleFpsSelect(fps); @@ -244,8 +244,8 @@ export class CanvasToolbar { }); // Color swatches - this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach((swatch) => { - swatch.addEventListener("click", (e) => { + this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach(swatch => { + swatch.addEventListener("click", e => { const el = e.currentTarget as HTMLElement; const color = el.dataset["swatchColor"] || "#000000"; this.handleColorChange(color); @@ -369,7 +369,7 @@ export class CanvasToolbar { private updateActiveStates(): void { // Update resolution presets - this.resolutionPopup?.querySelectorAll("[data-width]").forEach((item) => { + this.resolutionPopup?.querySelectorAll("[data-width]").forEach(item => { const el = item as HTMLElement; const width = parseInt(el.dataset["width"] || "0", 10); const height = parseInt(el.dataset["height"] || "0", 10); @@ -377,14 +377,14 @@ export class CanvasToolbar { }); // Update FPS options - this.fpsPopup?.querySelectorAll("[data-fps]").forEach((item) => { + this.fpsPopup?.querySelectorAll("[data-fps]").forEach(item => { const el = item as HTMLElement; const fps = parseInt(el.dataset["fps"] || "0", 10); el.classList.toggle("active", fps === this.currentFps); }); // Update color swatches - this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach((swatch) => { + this.backgroundPopup?.querySelectorAll("[data-swatch-color]").forEach(swatch => { const el = swatch as HTMLElement; const color = el.dataset["swatchColor"] || ""; el.classList.toggle("active", color.toLowerCase() === this.currentBgColor.toLowerCase()); diff --git a/src/core/ui/font-color-picker.ts b/src/core/ui/font-color-picker.ts index 46761bd4..f42dfd89 100644 --- a/src/core/ui/font-color-picker.ts +++ b/src/core/ui/font-color-picker.ts @@ -10,54 +10,301 @@ const GRADIENT_PRESETS: Array<{ name: string; gradients: GradientPreset[] }> = [ { name: "Cool Tones", gradients: [ - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#8B5CF6" }, { offset: 1, color: "#06B6D4" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#3B82F6" }, { offset: 1, color: "#8B5CF6" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#06B6D4" }, { offset: 1, color: "#3B82F6" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#3B82F6" }, { offset: 1, color: "#6366F1" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#06B6D4" }, { offset: 1, color: "#14B8A6" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#0EA5E9" }, { offset: 1, color: "#38BDF8" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#8B5CF6" }, { offset: 0.5, color: "#3B82F6" }, { offset: 1, color: "#06B6D4" }] } + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#8B5CF6" }, + { offset: 1, color: "#06B6D4" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#3B82F6" }, + { offset: 1, color: "#8B5CF6" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#06B6D4" }, + { offset: 1, color: "#3B82F6" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#3B82F6" }, + { offset: 1, color: "#6366F1" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#06B6D4" }, + { offset: 1, color: "#14B8A6" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#0EA5E9" }, + { offset: 1, color: "#38BDF8" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#8B5CF6" }, + { offset: 0.5, color: "#3B82F6" }, + { offset: 1, color: "#06B6D4" } + ] + } ] }, { name: "Warm Tones", gradients: [ - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EF4444" }, { offset: 1, color: "#F97316" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#F97316" }, { offset: 1, color: "#EAB308" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EC4899" }, { offset: 1, color: "#F43F5E" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EF4444" }, { offset: 1, color: "#EC4899" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#F97316" }, { offset: 1, color: "#F59E0B" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#EC4899" }, { offset: 1, color: "#F97316" }] }, - { type: "linear", angle: 45, stops: [{ offset: 0, color: "#8B5CF6" }, { offset: 0.5, color: "#EC4899" }, { offset: 1, color: "#EAB308" }] } + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EF4444" }, + { offset: 1, color: "#F97316" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#F97316" }, + { offset: 1, color: "#EAB308" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EC4899" }, + { offset: 1, color: "#F43F5E" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EF4444" }, + { offset: 1, color: "#EC4899" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#F97316" }, + { offset: 1, color: "#F59E0B" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#EC4899" }, + { offset: 1, color: "#F97316" } + ] + }, + { + type: "linear", + angle: 45, + stops: [ + { offset: 0, color: "#8B5CF6" }, + { offset: 0.5, color: "#EC4899" }, + { offset: 1, color: "#EAB308" } + ] + } ] }, { name: "Monochromatic", gradients: [ // Neutrals row - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#878274" }, { offset: 1, color: "#24221a" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#dfdcda" }, { offset: 1, color: "#858176" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#fffcf5" }, { offset: 1, color: "#d8d5ca" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#feffff" }, { offset: 1, color: "#c5c5c5" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#e9f0f3" }, { offset: 1, color: "#a2a5ac" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#a5acb9" }, { offset: 1, color: "#303643" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#6d7486" }, { offset: 1, color: "#0a0d13" }] }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#878274" }, + { offset: 1, color: "#24221a" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#dfdcda" }, + { offset: 1, color: "#858176" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#fffcf5" }, + { offset: 1, color: "#d8d5ca" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#feffff" }, + { offset: 1, color: "#c5c5c5" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#e9f0f3" }, + { offset: 1, color: "#a2a5ac" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#a5acb9" }, + { offset: 1, color: "#303643" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#6d7486" }, + { offset: 1, color: "#0a0d13" } + ] + }, // Dark to light colors row - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#731919" }, { offset: 1, color: "#e52b2b" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#963e15" }, { offset: 1, color: "#f4773e" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#997300" }, { offset: 1, color: "#ffc000" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#226214" }, { offset: 1, color: "#43cc25" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#004d48" }, { offset: 1, color: "#3ff3e7" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#001f65" }, { offset: 1, color: "#6895fd" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#450050" }, { offset: 1, color: "#e753fe" }] }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#731919" }, + { offset: 1, color: "#e52b2b" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#963e15" }, + { offset: 1, color: "#f4773e" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#997300" }, + { offset: 1, color: "#ffc000" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#226214" }, + { offset: 1, color: "#43cc25" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#004d48" }, + { offset: 1, color: "#3ff3e7" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#001f65" }, + { offset: 1, color: "#6895fd" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#450050" }, + { offset: 1, color: "#e753fe" } + ] + }, // Pastels row - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#ff6767" }, { offset: 1, color: "#ffd1d1" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#ff9869" }, { offset: 1, color: "#ffd2bd" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#ffda6a" }, { offset: 1, color: "#fff7de" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#7cce6b" }, { offset: 1, color: "#d8ffd0" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#7af6ee" }, { offset: 1, color: "#eafffe" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#84a9ff" }, { offset: 1, color: "#f5f8ff" }] }, - { type: "linear", angle: 180, stops: [{ offset: 0, color: "#f093ff" }, { offset: 1, color: "#fdf1ff" }] } + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#ff6767" }, + { offset: 1, color: "#ffd1d1" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#ff9869" }, + { offset: 1, color: "#ffd2bd" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#ffda6a" }, + { offset: 1, color: "#fff7de" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#7cce6b" }, + { offset: 1, color: "#d8ffd0" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#7af6ee" }, + { offset: 1, color: "#eafffe" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#84a9ff" }, + { offset: 1, color: "#f5f8ff" } + ] + }, + { + type: "linear", + angle: 180, + stops: [ + { offset: 0, color: "#f093ff" }, + { offset: 1, color: "#fdf1ff" } + ] + } ] } ]; @@ -162,13 +409,13 @@ export class FontColorPicker { this.gradientTab?.addEventListener("click", () => this.setMode("gradient")); this.colorInput?.addEventListener("input", () => this.handleColorChange()); - this.colorOpacitySlider?.addEventListener("input", (e) => this.handleColorOpacityChange(e)); + this.colorOpacitySlider?.addEventListener("input", e => this.handleColorOpacityChange(e)); this.highlightColorInput?.addEventListener("input", () => this.handleHighlightChange()); // Setup gradient swatch click handlers - this.container.querySelectorAll("[data-cat]").forEach((btn) => { - btn.addEventListener("click", (e) => { + this.container.querySelectorAll("[data-cat]").forEach(btn => { + btn.addEventListener("click", e => { const el = e.currentTarget as HTMLButtonElement; this.handleGradientClick(parseInt(el.dataset["cat"] || "0"), parseInt(el.dataset["idx"] || "0")); }); @@ -212,7 +459,7 @@ export class FontColorPicker { } private buildGradientHTML(): string { - let html = ''; + let html = ""; GRADIENT_PRESETS.forEach((category, catIdx) => { html += `
${category.name}
diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index ffd01f69..a8339c09 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -119,7 +119,7 @@ export class MediaToolbar {
${FIT_OPTIONS.map( - (opt) => ` + opt => `
${opt.label} @@ -268,30 +268,30 @@ export class MediaToolbar { private setupEventListeners(): void { // Toggle popups - this.fitBtn?.addEventListener("click", (e) => { + this.fitBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("fit"); }); - this.opacityBtn?.addEventListener("click", (e) => { + this.opacityBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("opacity"); }); - this.scaleBtn?.addEventListener("click", (e) => { + this.scaleBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("scale"); }); - this.volumeBtn?.addEventListener("click", (e) => { + this.volumeBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("volume"); }); - this.transitionBtn?.addEventListener("click", (e) => { + this.transitionBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopup("transition"); }); // Fit options - this.fitPopup?.querySelectorAll("[data-fit]").forEach((item) => { - item.addEventListener("click", (e) => { + this.fitPopup?.querySelectorAll("[data-fit]").forEach(item => { + item.addEventListener("click", e => { const el = e.currentTarget as HTMLElement; const fit = el.dataset["fit"] as FitValue; this.handleFitChange(fit); @@ -317,8 +317,8 @@ export class MediaToolbar { }); // Transition tabs - this.transitionPopup?.querySelectorAll("[data-tab]").forEach((tab) => { - tab.addEventListener("click", (e) => { + this.transitionPopup?.querySelectorAll("[data-tab]").forEach(tab => { + tab.addEventListener("click", e => { const el = e.currentTarget as HTMLElement; const tabValue = el.dataset["tab"] as "in" | "out"; this.handleTabChange(tabValue); @@ -326,8 +326,8 @@ export class MediaToolbar { }); // Effect buttons - this.transitionPopup?.querySelectorAll("[data-effect]").forEach((btn) => { - btn.addEventListener("click", (e) => { + this.transitionPopup?.querySelectorAll("[data-effect]").forEach(btn => { + btn.addEventListener("click", e => { const el = e.currentTarget as HTMLElement; const effect = el.dataset["effect"] || ""; this.handleEffectSelect(effect); @@ -335,8 +335,8 @@ export class MediaToolbar { }); // Direction buttons - this.transitionPopup?.querySelectorAll("[data-dir]").forEach((btn) => { - btn.addEventListener("click", (e) => { + this.transitionPopup?.querySelectorAll("[data-dir]").forEach(btn => { + btn.addEventListener("click", e => { const el = e.currentTarget as HTMLElement; const dir = el.dataset["dir"] || ""; this.handleDirectionSelect(dir); @@ -468,7 +468,7 @@ export class MediaToolbar { let currentIdx = this.SPEED_VALUES.indexOf(currentSpeed); if (currentIdx === -1) { // Find closest value - currentIdx = this.SPEED_VALUES.findIndex((v) => v >= currentSpeed); + currentIdx = this.SPEED_VALUES.findIndex(v => v >= currentSpeed); if (currentIdx === -1) currentIdx = this.SPEED_VALUES.length - 1; } @@ -604,13 +604,13 @@ export class MediaToolbar { const speed = tab === "in" ? this.transitionInSpeed : this.transitionOutSpeed; // Update tab active states - this.transitionPopup?.querySelectorAll("[data-tab]").forEach((el) => { + this.transitionPopup?.querySelectorAll("[data-tab]").forEach(el => { const tabEl = el as HTMLElement; tabEl.classList.toggle("active", tabEl.dataset["tab"] === tab); }); // Update effect active states - this.transitionPopup?.querySelectorAll("[data-effect]").forEach((el) => { + this.transitionPopup?.querySelectorAll("[data-effect]").forEach(el => { const effectEl = el as HTMLElement; effectEl.classList.toggle("active", effectEl.dataset["effect"] === effect); }); @@ -620,7 +620,7 @@ export class MediaToolbar { this.directionRow?.classList.toggle("visible", showDirection); // Hide Up/Down for wipe (only Left/Right) - this.transitionPopup?.querySelectorAll("[data-dir]").forEach((el) => { + this.transitionPopup?.querySelectorAll("[data-dir]").forEach(el => { const dirEl = el as HTMLElement; const dir = dirEl.dataset["dir"] || ""; const isVertical = dir === "Up" || dir === "Down"; @@ -649,7 +649,7 @@ export class MediaToolbar { private updateFitDisplay(): void { if (this.fitLabel) { - const option = FIT_OPTIONS.find((o) => o.value === this.currentFit); + const option = FIT_OPTIONS.find(o => o.value === this.currentFit); this.fitLabel.textContent = option?.label || "Crop"; } } @@ -688,7 +688,7 @@ export class MediaToolbar { } private updateFitActiveState(): void { - this.fitPopup?.querySelectorAll("[data-fit]").forEach((item) => { + this.fitPopup?.querySelectorAll("[data-fit]").forEach(item => { const el = item as HTMLElement; el.classList.toggle("active", el.dataset["fit"] === this.currentFit); }); diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 4979e1c9..1a2ce5a7 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -3,9 +3,9 @@ import { FONT_PATHS } from "@core/fonts/font-config"; import type { ResolvedClip } from "@schemas/clip"; import type { RichTextAsset } from "@schemas/rich-text-asset"; -import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; import { BackgroundColorPicker } from "./background-color-picker"; import { FontColorPicker } from "./font-color-picker"; +import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; /** Built-in font families (base names only, without weight variants) */ const BUILT_IN_FONTS = [ @@ -432,12 +432,12 @@ export class RichTextToolbar { this.container.addEventListener("click", this.handleClick.bind(this)); // Size input handlers - this.sizeInput?.addEventListener("click", (e) => { + this.sizeInput?.addEventListener("click", e => { e.stopPropagation(); this.toggleSizePopup(); }); this.sizeInput?.addEventListener("blur", () => this.applyManualSize()); - this.sizeInput?.addEventListener("keydown", (e) => { + this.sizeInput?.addEventListener("keydown", e => { if (e.key === "Enter") { this.applyManualSize(); this.sizeInput?.blur(); @@ -455,7 +455,7 @@ export class RichTextToolbar { if (fontColorPickerContainer) { this.fontColorPicker = new FontColorPicker(); this.fontColorPicker.mount(fontColorPickerContainer as HTMLElement); - this.fontColorPicker.onChange((updates) => { + this.fontColorPicker.onChange(updates => { this.updateFontColorProperty(updates); }); } @@ -470,7 +470,7 @@ export class RichTextToolbar { this.anchorMiddleBtn = this.container.querySelector("[data-action='anchor-middle']"); this.anchorBottomBtn = this.container.querySelector("[data-action='anchor-bottom']"); - this.letterSpacingSlider?.addEventListener("input", (e) => { + this.letterSpacingSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.letterSpacingValue) { this.letterSpacingValue.textContent = String(value); @@ -478,7 +478,7 @@ export class RichTextToolbar { this.updateClipProperty({ style: { letterSpacing: value } }); }); - this.lineHeightSlider?.addEventListener("input", (e) => { + this.lineHeightSlider?.addEventListener("input", e => { const value = parseFloat((e.target as HTMLInputElement).value) / 10; if (this.lineHeightValue) { this.lineHeightValue.textContent = value.toFixed(1); @@ -496,7 +496,7 @@ export class RichTextToolbar { this.borderRadiusSlider = this.container.querySelector("[data-border-radius-slider]"); this.borderRadiusValue = this.container.querySelector("[data-border-radius-value]"); - this.borderWidthSlider?.addEventListener("input", (e) => { + this.borderWidthSlider?.addEventListener("input", e => { const width = parseInt((e.target as HTMLInputElement).value, 10); if (this.borderWidthValue) { this.borderWidthValue.textContent = String(width); @@ -504,12 +504,12 @@ export class RichTextToolbar { this.updateBorderProperty({ width }); }); - this.borderColorInput?.addEventListener("input", (e) => { + this.borderColorInput?.addEventListener("input", e => { const color = (e.target as HTMLInputElement).value; this.updateBorderProperty({ color }); }); - this.borderOpacitySlider?.addEventListener("input", (e) => { + this.borderOpacitySlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); const opacity = value / 100; if (this.borderOpacityValue) { @@ -518,7 +518,7 @@ export class RichTextToolbar { this.updateBorderProperty({ opacity }); }); - this.borderRadiusSlider?.addEventListener("input", (e) => { + this.borderRadiusSlider?.addEventListener("input", e => { const radius = parseInt((e.target as HTMLInputElement).value, 10); if (this.borderRadiusValue) { this.borderRadiusValue.textContent = String(radius); @@ -540,7 +540,7 @@ export class RichTextToolbar { this.shadowOpacitySlider = this.container.querySelector("[data-shadow-opacity]"); this.shadowOpacityValue = this.container.querySelector("[data-shadow-opacity-value]"); - this.shadowToggle?.addEventListener("change", (e) => { + this.shadowToggle?.addEventListener("change", e => { const enabled = (e.target as HTMLInputElement).checked; if (enabled) { // Restore previous config or use defaults @@ -556,29 +556,29 @@ export class RichTextToolbar { } }); - this.shadowOffsetXSlider?.addEventListener("input", (e) => { + this.shadowOffsetXSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.shadowOffsetXValue) this.shadowOffsetXValue.textContent = String(value); this.updateShadowProperty({ offsetX: value }); }); - this.shadowOffsetYSlider?.addEventListener("input", (e) => { + this.shadowOffsetYSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.shadowOffsetYValue) this.shadowOffsetYValue.textContent = String(value); this.updateShadowProperty({ offsetY: value }); }); - this.shadowBlurSlider?.addEventListener("input", (e) => { + this.shadowBlurSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.shadowBlurValue) this.shadowBlurValue.textContent = String(value); this.updateShadowProperty({ blur: value }); }); - this.shadowColorInput?.addEventListener("input", (e) => { + this.shadowColorInput?.addEventListener("input", e => { this.updateShadowProperty({ color: (e.target as HTMLInputElement).value }); }); - this.shadowOpacitySlider?.addEventListener("input", (e) => { + this.shadowOpacitySlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.shadowOpacityValue) this.shadowOpacityValue.textContent = String(value); this.updateShadowProperty({ opacity: value / 100 }); @@ -593,7 +593,7 @@ export class RichTextToolbar { this.animationDirectionSection = this.container.querySelector("[data-animation-direction-section]"); // Preset buttons - this.container.querySelectorAll("[data-preset]").forEach((btn) => { + this.container.querySelectorAll("[data-preset]").forEach(btn => { btn.addEventListener("click", () => { const preset = btn.dataset["preset"] as "typewriter" | "fadeIn" | "slideIn" | "ascend" | "shift"; if (preset) this.updateAnimationProperty({ preset }); @@ -601,14 +601,14 @@ export class RichTextToolbar { }); // Duration slider - this.animationDurationSlider?.addEventListener("input", (e) => { + this.animationDurationSlider?.addEventListener("input", e => { const value = parseFloat((e.target as HTMLInputElement).value); if (this.animationDurationValue) this.animationDurationValue.textContent = `${value.toFixed(1)}s`; this.updateAnimationProperty({ duration: value }); }); // Style buttons - this.container.querySelectorAll("[data-animation-style]").forEach((btn) => { + this.container.querySelectorAll("[data-animation-style]").forEach(btn => { btn.addEventListener("click", () => { const style = btn.dataset["animationStyle"] as "character" | "word"; if (style) this.updateAnimationProperty({ style }); @@ -616,7 +616,7 @@ export class RichTextToolbar { }); // Direction buttons - this.container.querySelectorAll("[data-animation-direction]").forEach((btn) => { + this.container.querySelectorAll("[data-animation-direction]").forEach(btn => { btn.addEventListener("click", () => { const direction = btn.dataset["animationDirection"] as "left" | "right" | "up" | "down"; if (direction) this.updateAnimationProperty({ direction }); @@ -648,25 +648,25 @@ export class RichTextToolbar { this.paddingLeftValue = this.container.querySelector("[data-padding-left-value]"); // Event listeners - this.paddingTopSlider?.addEventListener("input", (e) => { + this.paddingTopSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.paddingTopValue) this.paddingTopValue.textContent = String(value); this.updatePaddingProperty({ top: value }); }); - this.paddingRightSlider?.addEventListener("input", (e) => { + this.paddingRightSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.paddingRightValue) this.paddingRightValue.textContent = String(value); this.updatePaddingProperty({ right: value }); }); - this.paddingBottomSlider?.addEventListener("input", (e) => { + this.paddingBottomSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.paddingBottomValue) this.paddingBottomValue.textContent = String(value); this.updatePaddingProperty({ bottom: value }); }); - this.paddingLeftSlider?.addEventListener("input", (e) => { + this.paddingLeftSlider?.addEventListener("input", e => { const value = parseInt((e.target as HTMLInputElement).value, 10); if (this.paddingLeftValue) this.paddingLeftValue.textContent = String(value); this.updatePaddingProperty({ left: value }); @@ -674,7 +674,7 @@ export class RichTextToolbar { // Text edit area handlers this.textEditArea?.addEventListener("input", () => this.debouncedApplyTextEdit()); - this.textEditArea?.addEventListener("keydown", (e) => { + this.textEditArea?.addEventListener("keydown", e => { // Apply on Ctrl/Cmd+Enter (allow normal Enter for newlines) if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -691,7 +691,7 @@ export class RichTextToolbar { } }); - document.addEventListener("click", (e) => { + document.addEventListener("click", e => { const target = e.target as Node; if (this.sizePopup && this.sizePopup.style.display !== "none") { if (!this.sizeInput?.contains(target) && !this.sizePopup.contains(target)) { @@ -1033,10 +1033,7 @@ export class RichTextToolbar { } else { // Otherwise show color tab with current values this.fontColorPicker.setMode("color"); - this.fontColorPicker.setColor( - font?.color || "#000000", - font?.opacity ?? 1 - ); + this.fontColorPicker.setColor(font?.color || "#000000", font?.opacity ?? 1); // Set highlight if present if (font?.background) { @@ -1311,9 +1308,7 @@ export class RichTextToolbar { // Check if all sides are equal (can simplify to uniform padding) const allEqual = - updatedPadding.top === updatedPadding.right && - updatedPadding.right === updatedPadding.bottom && - updatedPadding.bottom === updatedPadding.left; + updatedPadding.top === updatedPadding.right && updatedPadding.right === updatedPadding.bottom && updatedPadding.bottom === updatedPadding.left; // If all sides are 0, remove padding entirely if (updatedPadding.top === 0 && updatedPadding.right === 0 && updatedPadding.bottom === 0 && updatedPadding.left === 0) { @@ -1342,7 +1337,7 @@ export class RichTextToolbar { const asset = player.clipConfiguration.asset as RichTextAsset; const currentFont = asset.font || {}; - let fontUpdates: Record = { ...currentFont }; + const fontUpdates: Record = { ...currentFont }; // Handle solid color and opacity if (updates.color !== undefined) { @@ -1368,8 +1363,8 @@ export class RichTextToolbar { } // Clear gradient when setting solid color - const currentStyle = asset.style || {} as Record; - if ((updates.color !== undefined || updates.opacity !== undefined) && currentStyle["gradient"]) { + const currentStyle = asset.style || ({} as Record); + if ((updates.color !== undefined || updates.opacity !== undefined) && currentStyle.gradient) { this.updateClipProperty({ font: fontUpdates, style: { ...currentStyle, gradient: undefined } @@ -1508,7 +1503,7 @@ export class RichTextToolbar { } // Shadow - const shadow = asset.shadow; + const { shadow } = asset; if (this.shadowToggle) { this.shadowToggle.checked = !!shadow; } @@ -1536,8 +1531,8 @@ export class RichTextToolbar { } // Animation - const animation = asset.animation; - this.container?.querySelectorAll("[data-preset]").forEach((btn) => { + const { animation } = asset; + this.container?.querySelectorAll("[data-preset]").forEach(btn => { this.setButtonActive(btn, btn.dataset["preset"] === animation?.preset); }); if (this.animationDurationSlider && this.animationDurationValue) { @@ -1545,20 +1540,20 @@ export class RichTextToolbar { this.animationDurationSlider.value = String(duration); this.animationDurationValue.textContent = `${duration.toFixed(1)}s`; } - this.container?.querySelectorAll("[data-animation-style]").forEach((btn) => { + this.container?.querySelectorAll("[data-animation-style]").forEach(btn => { this.setButtonActive(btn, btn.dataset["animationStyle"] === animation?.style); }); - this.container?.querySelectorAll("[data-animation-direction]").forEach((btn) => { + this.container?.querySelectorAll("[data-animation-direction]").forEach(btn => { this.setButtonActive(btn, btn.dataset["animationDirection"] === animation?.direction); }); this.updateAnimationSections(animation?.preset); // Padding if (this.paddingTopSlider && this.paddingRightSlider && this.paddingBottomSlider && this.paddingLeftSlider) { - let top = 0, - right = 0, - bottom = 0, - left = 0; + let top = 0; + let right = 0; + let bottom = 0; + let left = 0; if (typeof asset.padding === "number") { // Uniform padding From da840cd69076174d6b5a7499acb2663c6e90280a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 15:29:28 +1100 Subject: [PATCH 066/463] feat: add asset toolbar to canvas with text and media insertion --- src/components/canvas/shotstack-canvas.ts | 13 +++ src/core/ui/asset-toolbar.css.ts | 100 +++++++++++++++++++ src/core/ui/asset-toolbar.ts | 115 ++++++++++++++++++++++ 3 files changed, 228 insertions(+) create mode 100644 src/core/ui/asset-toolbar.css.ts create mode 100644 src/core/ui/asset-toolbar.ts diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 01af2671..b9a5ed1a 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -1,5 +1,6 @@ import { Inspector } from "@canvas/system/inspector"; import { Edit } from "@core/edit"; +import { AssetToolbar } from "@core/ui/asset-toolbar"; import { CanvasToolbar } from "@core/ui/canvas-toolbar"; import { MediaToolbar } from "@core/ui/media-toolbar"; import { RichTextToolbar } from "@core/ui/rich-text-toolbar"; @@ -28,6 +29,7 @@ export class Canvas { private readonly richTextToolbar: RichTextToolbar; private readonly mediaToolbar: MediaToolbar; private readonly canvasToolbar: CanvasToolbar; + private readonly assetToolbar: AssetToolbar; private container?: pixi.Container; private background?: pixi.Graphics; @@ -48,6 +50,7 @@ export class Canvas { this.richTextToolbar = new RichTextToolbar(edit); this.mediaToolbar = new MediaToolbar(edit); this.canvasToolbar = new CanvasToolbar(); + this.assetToolbar = new AssetToolbar(edit); this.onTickBound = this.onTick.bind(this); this.onBackgroundClickBound = this.onBackgroundClick.bind(this); @@ -87,6 +90,9 @@ export class Canvas { this.canvasToolbar.mount(root); this.setupCanvasToolbarListeners(); this.syncCanvasToolbarState(); + + this.assetToolbar.mount(root); + this.updateAssetToolbarPosition(); } private setupTouchHandling(root: HTMLDivElement): void { @@ -168,6 +174,12 @@ export class Canvas { edit.scale.y = this.currentZoom; this.centerEdit(); + this.updateAssetToolbarPosition(); + } + + private updateAssetToolbarPosition(): void { + const editContainer = this.edit.getContainer(); + this.assetToolbar.setPosition(editContainer.position.x); } public setZoom(zoom: number): void { @@ -347,6 +359,7 @@ export class Canvas { this.richTextToolbar.dispose(); this.mediaToolbar.dispose(); this.canvasToolbar.dispose(); + this.assetToolbar.dispose(); this.application.destroy(true, { children: true, texture: true }); } diff --git a/src/core/ui/asset-toolbar.css.ts b/src/core/ui/asset-toolbar.css.ts new file mode 100644 index 00000000..c2c95da4 --- /dev/null +++ b/src/core/ui/asset-toolbar.css.ts @@ -0,0 +1,100 @@ +export const ASSET_TOOLBAR_STYLES = ` +.ss-asset-toolbar { + position: absolute; + left: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + flex-direction: column; + gap: 2px; + padding: 6px; + background: rgba(255, 255, 255, 0.98); + border: 1px solid rgba(0, 0, 0, 0.06); + border-radius: 14px; + box-shadow: + 0 2px 8px rgba(0, 0, 0, 0.06), + 0 8px 24px rgba(0, 0, 0, 0.08); + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; + z-index: 50; + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); +} + +.ss-asset-toolbar-btn { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 8px; + color: rgba(0, 0, 0, 0.65); + cursor: pointer; + transition: all 0.15s ease; + position: relative; +} + +.ss-asset-toolbar-btn:hover { + background: rgba(0, 0, 0, 0.06); + color: rgba(0, 0, 0, 0.9); +} + +.ss-asset-toolbar-btn:active { + background: rgba(0, 0, 0, 0.1); + transform: scale(0.95); +} + +.ss-asset-toolbar-btn svg { + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.ss-asset-toolbar-divider { + width: 24px; + height: 1px; + background: rgba(0, 0, 0, 0.08); + margin: 4px auto; +} + +/* Tooltip */ +.ss-asset-toolbar-btn::after { + content: attr(data-tooltip); + position: absolute; + left: calc(100% + 10px); + top: 50%; + transform: translateY(-50%); + padding: 6px 10px; + background: rgba(24, 24, 27, 0.95); + color: #fff; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + border-radius: 6px; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.ss-asset-toolbar-btn::before { + content: ""; + position: absolute; + left: calc(100% + 4px); + top: 50%; + transform: translateY(-50%); + border: 5px solid transparent; + border-right-color: rgba(24, 24, 27, 0.95); + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; +} + +.ss-asset-toolbar-btn:hover::after, +.ss-asset-toolbar-btn:hover::before { + opacity: 1; + visibility: visible; +} +`; diff --git a/src/core/ui/asset-toolbar.ts b/src/core/ui/asset-toolbar.ts new file mode 100644 index 00000000..f8bf95fe --- /dev/null +++ b/src/core/ui/asset-toolbar.ts @@ -0,0 +1,115 @@ +import type { Edit } from "@core/edit"; + +import { ASSET_TOOLBAR_STYLES } from "./asset-toolbar.css"; + +const ICONS = { + text: ``, + media: `` +}; + +export class AssetToolbar { + private container: HTMLDivElement | null = null; + private styleElement: HTMLStyleElement | null = null; + private edit: Edit; + private padding = 12; + + constructor(edit: Edit) { + this.edit = edit; + this.injectStyles(); + } + + setPosition(leftOffset: number): void { + if (this.container) { + this.container.style.left = `${Math.max(this.padding, leftOffset - 48 - this.padding)}px`; + } + } + + private injectStyles(): void { + if (document.getElementById("ss-asset-toolbar-styles")) return; + + this.styleElement = document.createElement("style"); + this.styleElement.id = "ss-asset-toolbar-styles"; + this.styleElement.textContent = ASSET_TOOLBAR_STYLES; + document.head.appendChild(this.styleElement); + } + + mount(parent: HTMLElement): void { + this.container = document.createElement("div"); + this.container.className = "ss-asset-toolbar"; + + this.container.innerHTML = ` + +
+ + `; + + parent.appendChild(this.container); + this.setupEventListeners(); + } + + private setupEventListeners(): void { + this.container?.querySelectorAll("[data-action]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const { action } = el.dataset; + + switch (action) { + case "rich-text": + this.addRichTextClip(); + break; + case "media": + this.requestMediaUpload(); + break; + default: + break; + } + }); + }); + } + + private addRichTextClip(): void { + const newTrackIndex = 0; + + // Add new track at top (index 0) + this.edit.addTrack(newTrackIndex, { clips: [] }); + + // Add rich-text clip + this.edit.addClip(newTrackIndex, { + asset: { + type: "rich-text", + text: "Title", + font: { + family: "Open Sans Bold", + size: 72, + weight: 700, + color: "#ffffff", + opacity: 1 + }, + align: { + horizontal: "center", + vertical: "middle" + } + }, + start: this.edit.playbackTime, + length: 5, + width: 500, + height: 200, + fit: "none" + }); + } + + private requestMediaUpload(): void { + this.edit.events.emit("upload:requested", { + position: this.edit.playbackTime + }); + } + + dispose(): void { + this.container?.remove(); + this.container = null; + } +} From 60e7dd3345733c9179f6fef4a68b4339dcdeda84 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 16:05:55 +1100 Subject: [PATCH 067/463] feat: reposition canvas toolbar to vertical layout on right edge with tooltip support --- src/components/canvas/shotstack-canvas.ts | 8 ++ src/core/ui/canvas-toolbar.css.ts | 122 +++++++++++++++------- src/core/ui/canvas-toolbar.ts | 24 +++-- 3 files changed, 110 insertions(+), 44 deletions(-) diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index b9a5ed1a..b86a1933 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -90,6 +90,7 @@ export class Canvas { this.canvasToolbar.mount(root); this.setupCanvasToolbarListeners(); this.syncCanvasToolbarState(); + this.updateCanvasToolbarPosition(); this.assetToolbar.mount(root); this.updateAssetToolbarPosition(); @@ -175,6 +176,7 @@ export class Canvas { this.centerEdit(); this.updateAssetToolbarPosition(); + this.updateCanvasToolbarPosition(); } private updateAssetToolbarPosition(): void { @@ -182,6 +184,12 @@ export class Canvas { this.assetToolbar.setPosition(editContainer.position.x); } + private updateCanvasToolbarPosition(): void { + const editContainer = this.edit.getContainer(); + const editRightEdge = editContainer.position.x + this.edit.size.width * this.currentZoom; + this.canvasToolbar.setPosition(this.viewportSize.width, editRightEdge); + } + public setZoom(zoom: number): void { this.currentZoom = Math.min(Math.max(zoom, this.minZoom), this.maxZoom); const edit = this.edit.getContainer(); diff --git a/src/core/ui/canvas-toolbar.css.ts b/src/core/ui/canvas-toolbar.css.ts index 770b31c6..6b5edc95 100644 --- a/src/core/ui/canvas-toolbar.css.ts +++ b/src/core/ui/canvas-toolbar.css.ts @@ -1,16 +1,17 @@ export const CANVAS_TOOLBAR_STYLES = ` .ss-canvas-toolbar { position: absolute; - bottom: 20px; - left: 50%; - transform: translateX(-50%); + right: 8px; + top: 50%; + transform: translateY(-50%); display: flex; - align-items: center; + flex-direction: column; + align-items: stretch; gap: 2px; - padding: 5px 6px; + padding: 6px; background: rgba(255, 255, 255, 0.98); border: 1px solid rgba(0, 0, 0, 0.06); - border-radius: 50px; + border-radius: 14px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06), 0 8px 24px rgba(0, 0, 0, 0.08); @@ -21,24 +22,28 @@ export const CANVAS_TOOLBAR_STYLES = ` } .ss-canvas-toolbar-btn { + width: 36px; + height: 36px; display: flex; align-items: center; - gap: 6px; - padding: 7px 12px; + justify-content: center; background: transparent; border: none; - border-radius: 40px; - color: #1a1a1a; - font-size: 13px; - font-weight: 500; - letter-spacing: -0.01em; + border-radius: 8px; + color: rgba(0, 0, 0, 0.65); cursor: pointer; - transition: background 0.15s ease; - white-space: nowrap; + transition: all 0.15s ease; + position: relative; } .ss-canvas-toolbar-btn:hover { - background: rgba(0, 0, 0, 0.05); + background: rgba(0, 0, 0, 0.06); + color: rgba(0, 0, 0, 0.9); +} + +.ss-canvas-toolbar-btn:active { + background: rgba(0, 0, 0, 0.1); + transform: scale(0.95); } .ss-canvas-toolbar-btn.active { @@ -46,31 +51,78 @@ export const CANVAS_TOOLBAR_STYLES = ` } .ss-canvas-toolbar-btn svg { - width: 16px; - height: 16px; + width: 18px; + height: 18px; flex-shrink: 0; } -.ss-canvas-toolbar-btn svg.chevron { - width: 12px; - height: 12px; - opacity: 0.5; - margin-left: -2px; +/* Tooltip */ +.ss-canvas-toolbar-btn::after { + content: attr(data-tooltip); + position: absolute; + right: calc(100% + 10px); + top: 50%; + transform: translateY(-50%); + padding: 6px 10px; + background: rgba(24, 24, 27, 0.95); + color: #fff; + font-size: 12px; + font-weight: 500; + white-space: nowrap; + border-radius: 6px; + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.ss-canvas-toolbar-btn::before { + content: ""; + position: absolute; + right: calc(100% + 4px); + top: 50%; + transform: translateY(-50%); + border: 5px solid transparent; + border-left-color: rgba(24, 24, 27, 0.95); + opacity: 0; + visibility: hidden; + transition: opacity 0.15s ease, visibility 0.15s ease; +} + +.ss-canvas-toolbar-btn:hover::after, +.ss-canvas-toolbar-btn:hover::before { + opacity: 1; + visibility: visible; +} + +/* Hide tooltip when popup is open */ +.ss-canvas-toolbar-btn.active::after, +.ss-canvas-toolbar-btn.active::before { + opacity: 0; + visibility: hidden; +} + +.ss-canvas-toolbar-fps-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.01em; } .ss-canvas-toolbar-color-dot { - width: 16px; - height: 16px; + width: 18px; + height: 18px; border-radius: 50%; - border: 1.5px solid rgba(0, 0, 0, 0.12); + border: 1.5px solid rgba(0, 0, 0, 0.15); flex-shrink: 0; } .ss-canvas-toolbar-divider { - width: 1px; - height: 20px; + width: 24px; + height: 1px; background: rgba(0, 0, 0, 0.08); - margin: 0 4px; + margin: 4px auto; flex-shrink: 0; } @@ -83,9 +135,9 @@ export const CANVAS_TOOLBAR_STYLES = ` .ss-canvas-toolbar-popup { display: none; position: absolute; - bottom: calc(100% + 10px); - left: 50%; - transform: translateX(-50%); + right: calc(100% + 10px); + top: 50%; + transform: translateY(-50%); background: #fff; border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 14px; @@ -104,9 +156,9 @@ export const CANVAS_TOOLBAR_STYLES = ` .ss-canvas-toolbar-popup::after { content: ""; position: absolute; - bottom: -6px; - left: 50%; - transform: translateX(-50%) rotate(45deg); + right: -6px; + top: 50%; + transform: translateY(-50%) rotate(45deg); width: 10px; height: 10px; background: #fff; diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts index ea711a6b..5a450974 100644 --- a/src/core/ui/canvas-toolbar.ts +++ b/src/core/ui/canvas-toolbar.ts @@ -39,7 +39,6 @@ const COLOR_SWATCHES = [ // SVG Icons const ICONS = { monitor: ``, - chevron: ``, check: `` }; @@ -83,10 +82,21 @@ export class CanvasToolbar { // Click outside handler private clickOutsideHandler: ((e: MouseEvent) => void) | null = null; + // Positioning + private padding = 12; + constructor() { this.injectStyles(); } + setPosition(viewportWidth: number, editRightEdge: number): void { + if (this.container) { + const toolbarWidth = this.container.offsetWidth || 48; + const rightOffset = viewportWidth - editRightEdge; + this.container.style.right = `${Math.max(this.padding, rightOffset - toolbarWidth - this.padding)}px`; + } + } + private injectStyles(): void { if (document.getElementById("ss-canvas-toolbar-styles")) return; @@ -103,10 +113,8 @@ export class CanvasToolbar { this.container.innerHTML = `
-
Presets
@@ -135,9 +143,8 @@ export class CanvasToolbar {
-
@@ -157,9 +164,8 @@ export class CanvasToolbar {
-
${FPS_OPTIONS.map( From 09a252866adf83d827bae2b3b47954e543ad048a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 17:49:36 +1100 Subject: [PATCH 068/463] feat: add canvas resize method with renderer and layout updates --- src/components/canvas/shotstack-canvas.ts | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index b86a1933..ad65ee19 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -179,6 +179,32 @@ export class Canvas { this.updateCanvasToolbarPosition(); } + public resize(): void { + const root = document.querySelector(Canvas.CanvasSelector); + if (!root) return; + + const rect = root.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + this.viewportSize = { width: rect.width, height: rect.height }; + + // Resize Pixi renderer + this.application.renderer.resize(rect.width, rect.height); + + // Redraw background + if (this.background) { + this.background.clear(); + this.background.rect(0, 0, this.viewportSize.width, this.viewportSize.height); + this.background.fill({ color: 0x424242 }); + } + + // Update stage hit area + this.application.stage.hitArea = new pixi.Rectangle(0, 0, this.viewportSize.width, this.viewportSize.height); + + // Reposition content and UI elements + this.zoomToFit(); + } + private updateAssetToolbarPosition(): void { const editContainer = this.edit.getContainer(); this.assetToolbar.setPosition(editContainer.position.x); From 67baddce8fbe179b626b5f6e1fc92abf531542ea Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 10 Dec 2025 20:21:41 +1100 Subject: [PATCH 069/463] feat: make toolbar buttons extensible via registry system --- src/core/edit.ts | 30 +++++++++ src/core/ui/asset-toolbar.ts | 101 ++++++++++------------------ src/core/ui/toolbar-button.types.ts | 12 ++++ src/main.ts | 39 +++++++++++ 4 files changed, 116 insertions(+), 66 deletions(-) create mode 100644 src/core/ui/toolbar-button.types.ts diff --git a/src/core/edit.ts b/src/core/edit.ts index 5c9174cc..d86059e1 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -25,6 +25,7 @@ import { Entity } from "@core/shared/entity"; import { deepMerge } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; import { LoadingOverlay } from "@core/ui/loading-overlay"; +import type { ToolbarButtonConfig } from "@core/ui/toolbar-button.types"; import type { Size } from "@layouts/geometry"; import { AssetLoader } from "@loaders/asset-loader"; import { FontLoadParser } from "@loaders/font-load-parser"; @@ -72,6 +73,9 @@ export class Edit extends Entity { private cachedTimelineEnd: number = 0; private endLengthClips: Set = new Set(); + // Toolbar button registry + private toolbarButtons: ToolbarButtonConfig[] = []; + private canvas: Canvas | null = null; private activeLumaMasks: Array<{ lumaPlayer: LumaPlayer; @@ -1017,6 +1021,32 @@ export class Edit extends Entity { return this.backgroundColor; } + // ─── Toolbar Button Registry ───────────────────────────────────────────────── + + public registerToolbarButton(config: ToolbarButtonConfig): void { + const existing = this.toolbarButtons.findIndex(b => b.id === config.id); + if (existing >= 0) { + this.toolbarButtons[existing] = config; + } else { + this.toolbarButtons.push(config); + } + this.events.emit("toolbar:buttons:changed", { buttons: this.toolbarButtons }); + } + + public unregisterToolbarButton(id: string): void { + const index = this.toolbarButtons.findIndex(b => b.id === id); + if (index >= 0) { + this.toolbarButtons.splice(index, 1); + this.events.emit("toolbar:buttons:changed", { buttons: this.toolbarButtons }); + } + } + + public getToolbarButtons(): ToolbarButtonConfig[] { + return [...this.toolbarButtons]; + } + + // ─── Intent Listeners ──────────────────────────────────────────────────────── + private setupIntentListeners(): void { this.events.on("timeline:clip:clicked", (data: { player: Player; trackIndex: number; clipIndex: number }) => { if (data.player) { diff --git a/src/core/ui/asset-toolbar.ts b/src/core/ui/asset-toolbar.ts index f8bf95fe..cf5fd4c1 100644 --- a/src/core/ui/asset-toolbar.ts +++ b/src/core/ui/asset-toolbar.ts @@ -2,11 +2,6 @@ import type { Edit } from "@core/edit"; import { ASSET_TOOLBAR_STYLES } from "./asset-toolbar.css"; -const ICONS = { - text: ``, - media: `` -}; - export class AssetToolbar { private container: HTMLDivElement | null = null; private styleElement: HTMLStyleElement | null = null; @@ -37,74 +32,48 @@ export class AssetToolbar { this.container = document.createElement("div"); this.container.className = "ss-asset-toolbar"; - this.container.innerHTML = ` - -
- - `; + this.render(); parent.appendChild(this.container); - this.setupEventListeners(); - } - private setupEventListeners(): void { - this.container?.querySelectorAll("[data-action]").forEach(btn => { - btn.addEventListener("click", e => { - const el = e.currentTarget as HTMLElement; - const { action } = el.dataset; - - switch (action) { - case "rich-text": - this.addRichTextClip(); - break; - case "media": - this.requestMediaUpload(); - break; - default: - break; - } - }); - }); + this.edit.events.on("toolbar:buttons:changed", () => this.render()); } - private addRichTextClip(): void { - const newTrackIndex = 0; - - // Add new track at top (index 0) - this.edit.addTrack(newTrackIndex, { clips: [] }); - - // Add rich-text clip - this.edit.addClip(newTrackIndex, { - asset: { - type: "rich-text", - text: "Title", - font: { - family: "Open Sans Bold", - size: 72, - weight: 700, - color: "#ffffff", - opacity: 1 - }, - align: { - horizontal: "center", - vertical: "middle" - } - }, - start: this.edit.playbackTime, - length: 5, - width: 500, - height: 200, - fit: "none" - }); + private render(): void { + if (!this.container) return; + + const buttons = this.edit.getToolbarButtons(); + + // Hide toolbar if no buttons registered + this.container.style.display = buttons.length === 0 ? "none" : "flex"; + + this.container.innerHTML = buttons + .map( + btn => ` + ${btn.dividerBefore ? '
' : ""} + + ` + ) + .join(""); + + this.setupEventListeners(); } - private requestMediaUpload(): void { - this.edit.events.emit("upload:requested", { - position: this.edit.playbackTime + private setupEventListeners(): void { + this.container?.querySelectorAll("[data-button-id]").forEach(btn => { + btn.addEventListener("click", () => { + const id = (btn as HTMLElement).dataset["buttonId"]; + const config = this.edit.getToolbarButtons().find(b => b.id === id); + if (!config) return; + + const selectedClip = this.edit.getSelectedClipInfo(); + this.edit.events.emit(config.event, { + position: this.edit.playbackTime, + selectedClip: selectedClip ? { trackIndex: selectedClip.trackIndex, clipIndex: selectedClip.clipIndex } : null + }); + }); }); } diff --git a/src/core/ui/toolbar-button.types.ts b/src/core/ui/toolbar-button.types.ts new file mode 100644 index 00000000..1ed9c406 --- /dev/null +++ b/src/core/ui/toolbar-button.types.ts @@ -0,0 +1,12 @@ +export interface ToolbarButtonConfig { + id: string; + icon: string; + tooltip: string; + event: string; + dividerBefore?: boolean; +} + +export interface ToolbarButtonEventPayload { + position: number; + selectedClip: { trackIndex: number; clipIndex: number } | null; +} diff --git a/src/main.ts b/src/main.ts index 6037db2a..d1c7c3fb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,6 +17,45 @@ async function main() { const edit = new Edit(template.output.size, template.timeline.background); await edit.load(); + // 2b. Register toolbar buttons + edit.registerToolbarButton({ + id: "text", + icon: ``, + tooltip: "Add Text", + event: "text:requested" + }); + + edit.registerToolbarButton({ + id: "media", + icon: ``, + tooltip: "Add Media", + dividerBefore: true, + event: "upload:requested" + }); + + edit.registerToolbarButton({ + id: "code", + icon: ``, + tooltip: "Add Code", + event: "code:requested" + }); + + // Handle text:requested event - adds a text clip + edit.events.on("text:requested", ({ position }: { position: number }) => { + edit.addTrack(0, { clips: [] }); + edit.addClip(0, { + asset: { + type: "rich-text", + text: "Title", + font: { family: "Open Sans Bold", size: 72, weight: 700, color: "#ffffff", opacity: 1 }, + align: { horizontal: "center", vertical: "middle" } + }, + start: position, + length: 5, + fit: "none" + }); + }); + // 3. Create a canvas to display the edit const canvas = new Canvas(edit); await canvas.load(); // Renders to [data-shotstack-studio] element From 53eda08eb18c9ef7e23ec84fec9f50d881bb7fe7 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 15:14:42 +1100 Subject: [PATCH 070/463] refactor: reorganize merge field management and add dynamic source support --- src/components/canvas/players/image-player.ts | 79 ++-- src/components/canvas/players/player.ts | 38 +- src/components/canvas/players/video-player.ts | 99 ++-- src/components/canvas/shotstack-canvas.ts | 2 +- src/core/commands/add-track-command.ts | 7 + src/core/commands/delete-track-command.ts | 6 + src/core/commands/set-merge-field-command.ts | 115 +++++ src/core/commands/set-updated-clip-command.ts | 44 +- src/core/commands/types.ts | 9 + src/core/edit.ts | 423 +++++++++++++++++- src/core/merge/index.ts | 16 + src/core/merge/merge-field-service.ts | 154 +++++++ src/core/merge/merge-fields.ts | 15 +- src/core/merge/types.ts | 39 ++ src/core/shared/utils.ts | 64 +++ src/core/ui/canvas-toolbar.css.ts | 121 +++++ src/core/ui/canvas-toolbar.ts | 140 +++++- src/core/ui/media-toolbar.css.ts | 177 ++++++++ src/core/ui/media-toolbar.ts | 230 +++++++++- src/core/ui/rich-text-toolbar.css.ts | 44 ++ src/core/ui/rich-text-toolbar.ts | 193 +++++++- 21 files changed, 1897 insertions(+), 118 deletions(-) create mode 100644 src/core/commands/set-merge-field-command.ts create mode 100644 src/core/merge/index.ts create mode 100644 src/core/merge/merge-field-service.ts create mode 100644 src/core/merge/types.ts diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index 3fd4e0d7..370d5644 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -9,43 +9,17 @@ 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: ResolvedClip) { - super(timeline, clipConfiguration); + constructor(edit: Edit, clipConfiguration: ResolvedClip) { + super(edit, clipConfiguration); this.texture = null; this.sprite = null; - this.originalSize = null; } public override async load(): Promise { await super.load(); - - const imageAsset = this.clipConfiguration.asset as ImageAsset; - - const identifier = imageAsset.src; - const loadOptions: pixi.UnresolvedAsset = { - src: identifier, - crossorigin: "anonymous", - data: {} - }; - const texture = await this.edit.assetLoader.load>(identifier, loadOptions); - - const isValidImageSource = texture?.source instanceof pixi.ImageSource; - if (!isValidImageSource) { - throw new Error(`Invalid image source '${imageAsset.src}'.`); - } - - this.texture = this.createCroppedTexture(texture); - this.sprite = new pixi.Sprite(this.texture); - - this.contentContainer.addChild(this.sprite); - - if (this.clipConfiguration.width && this.clipConfiguration.height) { - this.applyFixedDimensions(); - } - + await this.loadTexture(); this.configureKeyframes(); } @@ -59,14 +33,7 @@ export class ImagePlayer extends Player { public override dispose(): void { super.dispose(); - - this.sprite?.destroy(); - this.sprite = null; - - this.texture?.destroy(); - this.texture = null; - - this.originalSize = null; + this.disposeTexture(); } public override getSize(): Size { @@ -84,6 +51,44 @@ export class ImagePlayer extends Player { return { width: this.sprite?.width ?? 0, height: this.sprite?.height ?? 0 }; } + /** Reload the image asset when asset.src changes (e.g., merge field update) */ + public override async reloadAsset(): Promise { + this.disposeTexture(); + await this.loadTexture(); + } + + private async loadTexture(): Promise { + const imageAsset = this.clipConfiguration.asset as ImageAsset; + const { src } = imageAsset; + + const loadOptions: pixi.UnresolvedAsset = { src, crossorigin: "anonymous", data: {} }; + const texture = await this.edit.assetLoader.load>(src, loadOptions); + + if (!(texture?.source instanceof pixi.ImageSource)) { + throw new Error(`Invalid image source '${src}'.`); + } + + this.texture = this.createCroppedTexture(texture); + this.sprite = new pixi.Sprite(this.texture); + this.contentContainer.addChild(this.sprite); + + if (this.clipConfiguration.width && this.clipConfiguration.height) { + this.applyFixedDimensions(); + } + } + + private disposeTexture(): void { + if (this.sprite) { + this.contentContainer.removeChild(this.sprite); + this.sprite.destroy(); + this.sprite = null; + } + if (this.texture) { + this.texture.destroy(); + this.texture = null; + } + } + protected override supportsEdgeResize(): boolean { return true; } diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index ad520864..e01aeff6 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -29,9 +29,6 @@ export abstract class Player extends Entity { private static readonly ScaleHandleRadius = 4; private static readonly OutlineWidth = 1; - private static readonly MinScale = 0.1; - private static readonly MaxScale = 5; - private static readonly EdgeHitZone = 8; private static readonly RotationHitZone = 15; private static readonly ExpandedHitArea = 10000; @@ -184,6 +181,15 @@ export abstract class Player extends Entity { this.configureKeyframes(); } + /** + * Reload the asset for this player (e.g., when asset.src changes). + * Override in subclasses that have loadable assets (image, video). + * Default implementation is a no-op. + */ + public async reloadAsset(): Promise { + // Default: no-op. Override in ImagePlayer, VideoPlayer, etc. + } + protected configureKeyframes() { this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset?.x ?? 0, this.getLength()); this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset?.y ?? 0, this.getLength()); @@ -258,6 +264,27 @@ export abstract class Player extends Entity { this.getContainer().addChild(this.contentContainer); } + if (this.outline) { + this.outline.destroy(); + this.outline = null; + } + if (this.topLeftScaleHandle) { + this.topLeftScaleHandle.destroy(); + this.topLeftScaleHandle = null; + } + if (this.topRightScaleHandle) { + this.topRightScaleHandle.destroy(); + this.topRightScaleHandle = null; + } + if (this.bottomRightScaleHandle) { + this.bottomRightScaleHandle.destroy(); + this.bottomRightScaleHandle = null; + } + if (this.bottomLeftScaleHandle) { + this.bottomLeftScaleHandle.destroy(); + this.bottomLeftScaleHandle = null; + } + this.outline = new pixi.Graphics(); this.getContainer().addChild(this.outline); @@ -1171,8 +1198,9 @@ export abstract class Player extends Entity { const clipHeight = this.clipConfiguration.height; if (!clipWidth || !clipHeight) return; - const sprite = this.contentContainer.children[0] as pixi.Sprite; - if (!sprite || !sprite.texture) return; + // Find sprite by type, not index (mask may be children[0] after refresh) + const sprite = this.contentContainer.children.find(child => child instanceof pixi.Sprite) as pixi.Sprite | undefined; + if (!sprite?.texture) return; const nativeWidth = sprite.texture.width; const nativeHeight = sprite.texture.height; diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 1de960f2..21496468 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -11,7 +11,6 @@ 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; @@ -25,7 +24,6 @@ export class VideoPlayer extends Player { this.texture = null; this.sprite = null; this.isPlaying = false; - this.originalSize = null; const videoAsset = this.clipConfiguration.asset as VideoAsset; @@ -37,38 +35,7 @@ export class VideoPlayer extends Player { public override async load(): Promise { await super.load(); - - const videoAsset = this.clipConfiguration.asset as VideoAsset; - - const identifier = videoAsset.src; - - if (identifier.endsWith(".mov")) { - throw new Error( - `Video source '${videoAsset.src}' is not supported. .mov files cannot be played in the browser. Please convert to .webm or .mp4 first.` - ); - } - - const loadOptions: pixi.UnresolvedAsset = { src: identifier, data: { autoPlay: false, muted: false } }; - const texture = await this.edit.assetLoader.load>(identifier, loadOptions); - - const isValidVideoSource = texture?.source instanceof pixi.VideoSource; - if (!isValidVideoSource) { - throw new Error(`Invalid video source '${videoAsset.src}'.`); - } - - // Fix alpha channel rendering for WebM VP9 videos - // PixiJS 8's auto-detection is buggy, causing invisible rendering - texture.source.alphaMode = "no-premultiply-alpha"; - - this.texture = this.createCroppedTexture(texture); - this.sprite = new pixi.Sprite(this.texture); - - this.contentContainer.addChild(this.sprite); - - if (this.clipConfiguration.width && this.clipConfiguration.height) { - this.applyFixedDimensions(); - } - + await this.loadVideo(); this.configureKeyframes(); } @@ -132,14 +99,7 @@ export class VideoPlayer extends Player { public override dispose(): void { super.dispose(); - - this.sprite?.destroy(); - this.sprite = null; - - this.texture?.destroy(); - this.texture = null; - - this.originalSize = null; + this.disposeVideo(); } public override getSize(): Size { @@ -157,6 +117,61 @@ export class VideoPlayer extends Player { return true; } + /** Reload the video asset when asset.src changes (e.g., merge field update) */ + public override async reloadAsset(): Promise { + this.skipVideoUpdate = true; + this.disposeVideo(); + await this.loadVideo(); + this.isPlaying = false; + this.syncTimer = 0; + this.activeSyncTimer = 0; + this.skipVideoUpdate = false; + } + + private async loadVideo(): Promise { + const videoAsset = this.clipConfiguration.asset as VideoAsset; + const { src } = videoAsset; + + if (src.endsWith(".mov")) { + throw new Error( + `Video source '${src}' is not supported. .mov files cannot be played in the browser. Please convert to .webm or .mp4 first.` + ); + } + + const loadOptions: pixi.UnresolvedAsset = { src, data: { autoPlay: false, muted: false } }; + const texture = await this.edit.assetLoader.load>(src, loadOptions); + + if (!(texture?.source instanceof pixi.VideoSource)) { + throw new Error(`Invalid video source '${src}'.`); + } + + // Fix alpha channel rendering for WebM VP9 videos (PixiJS 8 auto-detection is buggy) + texture.source.alphaMode = "no-premultiply-alpha"; + + this.texture = this.createCroppedTexture(texture); + this.sprite = new pixi.Sprite(this.texture); + this.contentContainer.addChild(this.sprite); + + if (this.clipConfiguration.width && this.clipConfiguration.height) { + this.applyFixedDimensions(); + } + } + + private disposeVideo(): void { + if (this.texture?.source?.resource) { + this.texture.source.resource.pause(); + } + if (this.sprite) { + this.contentContainer.removeChild(this.sprite); + this.sprite.destroy(); + this.sprite = null; + } + if (this.texture) { + this.texture.destroy(); + this.texture = null; + } + } + public getVolume(): number { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index ad65ee19..9b54102f 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -49,7 +49,7 @@ export class Canvas { this.transcriptionIndicator = new TranscriptionIndicator(); this.richTextToolbar = new RichTextToolbar(edit); this.mediaToolbar = new MediaToolbar(edit); - this.canvasToolbar = new CanvasToolbar(); + this.canvasToolbar = new CanvasToolbar(edit); this.assetToolbar = new AssetToolbar(edit); this.onTickBound = this.onTick.bind(this); this.onBackgroundClickBound = this.onBackgroundClick.bind(this); diff --git a/src/core/commands/add-track-command.ts b/src/core/commands/add-track-command.ts index 6dd5399f..7a60098d 100644 --- a/src/core/commands/add-track-command.ts +++ b/src/core/commands/add-track-command.ts @@ -14,6 +14,9 @@ export class AddTrackCommand implements EditCommand { tracks.splice(this.trackIdx, 0, []); + // Sync originalEdit - insert empty track at same index + context.insertOriginalEditTrack(this.trackIdx); + // Update layers for all clips that are on tracks at or after the insertion point // Since we're inserting a track, all tracks at or after trackIdx shift down clips.forEach(clip => { @@ -53,6 +56,10 @@ export class AddTrackCommand implements EditCommand { const tracks = context.getTracks(); const clips = context.getClips(); tracks.splice(this.trackIdx, 1); + + // Sync originalEdit - remove the track we added + context.removeOriginalEditTrack(this.trackIdx); + clips.forEach(clip => { if (clip.layer > this.trackIdx) { // eslint-disable-next-line no-param-reassign diff --git a/src/core/commands/delete-track-command.ts b/src/core/commands/delete-track-command.ts index 46f2f7cd..8e2345a7 100644 --- a/src/core/commands/delete-track-command.ts +++ b/src/core/commands/delete-track-command.ts @@ -27,6 +27,9 @@ export class DeleteTrackCommand implements EditCommand { tracks.splice(this.trackIdx, 1); + // Sync originalEdit - remove the track at same index + context.removeOriginalEditTrack(this.trackIdx); + const remainingClips = context.getClips(); const container = context.getContainer(); @@ -56,6 +59,9 @@ export class DeleteTrackCommand implements EditCommand { tracks.splice(this.trackIdx, 0, []); + // Sync originalEdit - re-insert the track at same index + context.insertOriginalEditTrack(this.trackIdx); + clips.forEach((clip, index) => { if (clip.layer >= this.trackIdx + 1) { clips[index].layer += 1; diff --git a/src/core/commands/set-merge-field-command.ts b/src/core/commands/set-merge-field-command.ts new file mode 100644 index 00000000..64ba00a8 --- /dev/null +++ b/src/core/commands/set-merge-field-command.ts @@ -0,0 +1,115 @@ +import type { Player } from "@canvas/players/player"; +import { setNestedValue } from "@core/shared/utils"; + +import type { EditCommand, CommandContext } from "./types"; + +/** + * Command to apply or remove a merge field on a clip property. + * Handles both the template (for export) and resolved value (for rendering) atomically. + * + * This command supports undo/redo and ensures: + * - Player's clipConfiguration gets the resolved value (for rendering) + * - Template edit (originalEdit) gets the {{ FIELD }} template (for export) + * - Merge field registry is updated appropriately + */ +export class SetMergeFieldCommand implements EditCommand { + name = "setMergeField"; + + private storedPreviousValue: string; + private storedNewValue: string; + private trackIndex: number; + private clipIndex: number; + + constructor( + private clip: Player, + private propertyPath: string, + private fieldName: string | null, + private previousFieldName: string | null, + private previousValue: string, + private newValue: string, + trackIndex: number, + clipIndex: number + ) { + this.storedPreviousValue = previousValue; + this.storedNewValue = newValue; + this.trackIndex = trackIndex; + this.clipIndex = clipIndex; + } + + async execute(context?: CommandContext): Promise { + if (!context) return; + + const mergeFields = context.getMergeFields(); + + // 1. Update player's clipConfiguration with resolved value + setNestedValue(this.clip.clipConfiguration, this.propertyPath, this.storedNewValue); + + // 2. Update template edit with template or raw value + const templateValue = this.fieldName ? mergeFields.createTemplate(this.fieldName) : this.storedNewValue; + context.setTemplateClipProperty(this.trackIndex, this.clipIndex, this.propertyPath, templateValue); + + // 3. Register/update merge field if applying (silent to prevent reload) + if (this.fieldName) { + mergeFields.register({ name: this.fieldName, defaultValue: this.storedNewValue }, { silent: true }); + } else if (this.previousFieldName) { + // Removing merge field - remove from registry + mergeFields.remove(this.previousFieldName, { silent: true }); + } + + // 4. Reconfigure player and reload asset if needed + const isSrcChange = this.propertyPath === "asset.src" || this.propertyPath.endsWith(".src"); + if (isSrcChange) { + await this.clip.reloadAsset(); + } + this.clip.reconfigureAfterRestore(); + this.clip.draw(); + + // 5. Emit event + context.emitEvent("mergefield:applied", { + clip: this.clip, + propertyPath: this.propertyPath, + fieldName: this.fieldName, + trackIndex: this.trackIndex, + clipIndex: this.clipIndex + }); + } + + async undo(context?: CommandContext): Promise { + if (!context) return; + + const mergeFields = context.getMergeFields(); + + // 1. Restore player's clipConfiguration with previous value + setNestedValue(this.clip.clipConfiguration, this.propertyPath, this.storedPreviousValue); + + // 2. Restore template edit + const templateValue = this.previousFieldName + ? mergeFields.createTemplate(this.previousFieldName) + : this.storedPreviousValue; + context.setTemplateClipProperty(this.trackIndex, this.clipIndex, this.propertyPath, templateValue); + + // 3. Re-register previous field or update current (silent to prevent reload) + if (this.previousFieldName) { + mergeFields.register({ name: this.previousFieldName, defaultValue: this.storedPreviousValue }, { silent: true }); + } + // If we applied a new field and are undoing, we could remove it + // But we keep it for now to allow redo + + // 4. Reconfigure player and reload asset if needed + const isSrcChange = this.propertyPath === "asset.src" || this.propertyPath.endsWith(".src"); + if (isSrcChange) { + await this.clip.reloadAsset(); + } + this.clip.reconfigureAfterRestore(); + this.clip.draw(); + + // 5. Emit event + context.emitEvent("mergefield:removed", { + clip: this.clip, + propertyPath: this.propertyPath, + fieldName: this.previousFieldName, + trackIndex: this.trackIndex, + clipIndex: this.clipIndex + }); + } +} diff --git a/src/core/commands/set-updated-clip-command.ts b/src/core/commands/set-updated-clip-command.ts index 15bd92c2..0b2df2bf 100644 --- a/src/core/commands/set-updated-clip-command.ts +++ b/src/core/commands/set-updated-clip-command.ts @@ -5,18 +5,34 @@ import type { EditCommand, CommandContext } from "./types"; type ClipType = ResolvedClip; +export interface SetUpdatedClipOptions { + trackIndex?: number; + clipIndex?: number; + templateConfig?: ClipType; // If provided, sync to originalEdit +} + export class SetUpdatedClipCommand implements EditCommand { name = "setUpdatedClip"; private storedInitialConfig: ClipType | null; private storedFinalConfig: ClipType; + private storedInitialTemplateConfig: ClipType | null = null; + private storedFinalTemplateConfig: ClipType | null = null; + private trackIndex: number; + private clipIndex: number; constructor( private clip: Player, private initialClipConfig: ClipType | null, - private finalClipConfig: ClipType | null + private finalClipConfig: ClipType | null, + options?: SetUpdatedClipOptions ) { this.storedInitialConfig = initialClipConfig ? structuredClone(initialClipConfig) : null; this.storedFinalConfig = finalClipConfig ? structuredClone(finalClipConfig) : structuredClone(this.clip.clipConfiguration); + this.trackIndex = options?.trackIndex ?? -1; + this.clipIndex = options?.clipIndex ?? -1; + if (options?.templateConfig) { + this.storedFinalTemplateConfig = structuredClone(options.templateConfig); + } } async execute(context?: CommandContext): Promise { @@ -27,10 +43,22 @@ export class SetUpdatedClipCommand implements EditCommand { context.setUpdatedClip(this.clip); - const trackIndex = this.clip.layer - 1; + // Use provided indices or calculate from clip + const trackIndex = this.trackIndex >= 0 ? this.trackIndex : this.clip.layer - 1; const clips = context.getClips(); const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer); - const clipIndex = clipsByTrack.indexOf(this.clip); + const clipIndex = this.clipIndex >= 0 ? this.clipIndex : clipsByTrack.indexOf(this.clip); + + // Sync originalEdit if template config provided + if (this.storedFinalTemplateConfig && trackIndex >= 0 && clipIndex >= 0) { + // Store previous template for undo + const prevTemplate = context.getTemplateClip(trackIndex, clipIndex); + if (prevTemplate) { + this.storedInitialTemplateConfig = structuredClone(prevTemplate); + } + // Update originalEdit with template version + context.syncTemplateClip(trackIndex, clipIndex, this.storedFinalTemplateConfig); + } // Check if asset src changed const previousAsset = this.storedInitialConfig?.asset as { src?: string } | undefined; @@ -57,10 +85,16 @@ export class SetUpdatedClipCommand implements EditCommand { context.setUpdatedClip(this.clip); - const trackIndex = this.clip.layer - 1; + // Use provided indices or calculate from clip + const trackIndex = this.trackIndex >= 0 ? this.trackIndex : this.clip.layer - 1; const clips = context.getClips(); const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer); - const clipIndex = clipsByTrack.indexOf(this.clip); + const clipIndex = this.clipIndex >= 0 ? this.clipIndex : clipsByTrack.indexOf(this.clip); + + // Restore originalEdit if we modified it + if (this.storedInitialTemplateConfig && trackIndex >= 0 && clipIndex >= 0) { + context.syncTemplateClip(trackIndex, clipIndex, this.storedInitialTemplateConfig); + } // Check if asset src changed (reverse direction) const previousAsset = this.storedFinalConfig?.asset as { src?: string } | undefined; diff --git a/src/core/commands/types.ts b/src/core/commands/types.ts index 631045b7..f8d0d3a6 100644 --- a/src/core/commands/types.ts +++ b/src/core/commands/types.ts @@ -1,4 +1,5 @@ import type { Player } from "@canvas/players/player"; +import type { MergeFieldService } from "@core/merge"; import type { ResolvedClip } from "@schemas/clip"; import type { ResolvedEdit } from "@schemas/edit"; import type { Container } from "pixi.js"; @@ -42,4 +43,12 @@ export type CommandContext = { resolveClipAutoLength(clip: Player): Promise; untrackEndLengthClip(clip: Player): void; trackEndLengthClip(clip: Player): void; + // Merge field context + getMergeFields(): MergeFieldService; + getTemplateClip(trackIndex: number, clipIndex: number): ClipType | null; + setTemplateClipProperty(trackIndex: number, clipIndex: number, propertyPath: string, value: unknown): void; + syncTemplateClip(trackIndex: number, clipIndex: number, templateClip: ClipType): void; + // originalEdit track sync (for track add/delete commands) + insertOriginalEditTrack(trackIdx: number): void; + removeOriginalEditTrack(trackIdx: number): void; }; diff --git a/src/core/edit.ts b/src/core/edit.ts index d86059e1..dbcae7e5 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -20,9 +20,9 @@ import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command"; import { SplitClipCommand } from "@core/commands/split-clip-command"; import { UpdateTextContentCommand } from "@core/commands/update-text-content-command"; import { EventEmitter } from "@core/events/event-emitter"; -import { applyMergeFields } from "@core/merge/merge-fields"; +import { applyMergeFields, MergeFieldService, type MergeField } from "@core/merge"; import { Entity } from "@core/shared/entity"; -import { deepMerge } from "@core/shared/utils"; +import { deepMerge, getNestedValue, setNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; import { LoadingOverlay } from "@core/ui/loading-overlay"; import type { ToolbarButtonConfig } from "@core/ui/toolbar-button.types"; @@ -34,6 +34,7 @@ import { EditSchema, type Edit as EditConfig, type ResolvedEdit, type Soundtrack import type { ResolvedTrack } from "@schemas/track"; import * as pixi from "pixi.js"; +import { SetMergeFieldCommand } from "./commands/set-merge-field-command"; import type { EditCommand, CommandContext } from "./commands/types"; export class Edit extends Entity { @@ -44,6 +45,7 @@ export class Edit extends Entity { public events: EventEmitter; private edit: ResolvedEdit | null; + private originalEdit: ResolvedEdit | null; private tracks: Player[][]; private clipsToDispose: Player[]; private clips: Player[]; @@ -76,6 +78,9 @@ export class Edit extends Entity { // Toolbar button registry private toolbarButtons: ToolbarButtonConfig[] = []; + /** Merge field service for managing dynamic content placeholders */ + public mergeFields: MergeFieldService; + private canvas: Canvas | null = null; private activeLumaMasks: Array<{ lumaPlayer: LumaPlayer; @@ -88,12 +93,14 @@ export class Edit extends Entity { this.assetLoader = new AssetLoader(); this.edit = null; + this.originalEdit = null; this.tracks = []; this.clipsToDispose = []; this.clips = []; this.events = new EventEmitter(); + this.mergeFields = new MergeFieldService(this.events); this.size = size; @@ -218,8 +225,15 @@ export class Edit extends Entity { try { this.clearClips(); - const mergeFields = edit.merge ?? []; - const mergedEdit = mergeFields.length > 0 ? applyMergeFields(edit, mergeFields) : edit; + // Store original (unresolved) edit for re-resolution on merge field changes + this.originalEdit = structuredClone(edit); + + // Load merge fields from edit payload into service + const serializedMergeFields = edit.merge ?? []; + this.mergeFields.loadFromSerialized(serializedMergeFields); + + // Apply merge field substitutions for initial load + const mergedEdit = serializedMergeFields.length > 0 ? applyMergeFields(edit, serializedMergeFields) : edit; const parsedEdit = EditSchema.parse(mergedEdit); resolveAliasReferences(parsedEdit); @@ -295,13 +309,18 @@ export class Edit extends Entity { await this.addPlayer(this.tracks.length, player); } public getEdit(): EditConfig { - const tracks = this.tracks.map(track => ({ + const tracks = this.tracks.map((track, trackIdx) => ({ clips: track .filter(player => player && !this.clipsToDispose.includes(player)) - .map(player => { + .map((player, clipIdx) => { const timing = player.getTimingIntent(); + + // Use asset from originalEdit to preserve merge field templates + const originalAsset = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]?.asset; + return { ...player.clipConfiguration, + asset: originalAsset ?? player.clipConfiguration.asset, start: timing.start, length: timing.length }; @@ -314,7 +333,8 @@ export class Edit extends Entity { tracks, fonts: this.edit?.timeline.fonts || [] }, - output: this.edit?.output || { size: this.size, format: "mp4" } + output: this.edit?.output || { size: this.size, format: "mp4" }, + merge: this.mergeFields.toSerializedArray() } as EditConfig; } @@ -365,6 +385,12 @@ export class Edit extends Entity { return clipsByTrack[clipIdx]; } + + /** Get the original (unresolved) asset for a clip, preserving merge field templates */ + public getOriginalAsset(trackIndex: number, clipIndex: number): unknown | undefined { + return this.originalEdit?.timeline.tracks[trackIndex]?.clips[clipIndex]?.asset; + } + public deleteClip(trackIdx: number, clipIdx: number): void { const command = new DeleteClipCommand(trackIdx, clipIdx); this.executeCommand(command); @@ -437,6 +463,44 @@ export class Edit extends Entity { this.setUpdatedClip(clip, initialConfig, mergedConfig); } + /** + * Update a clip with separate resolved and template configurations. + * Use this when the resolved value (for rendering) differs from the template value (for export). + * This is typically used when a property contains a merge field template. + * + * @param trackIdx - Track index + * @param clipIdx - Clip index within the track + * @param resolvedUpdates - Updates with resolved values (for clipConfiguration/rendering) + * @param templateUpdates - Updates with template values (for originalEdit/export) + */ + public updateClipWithTemplate( + trackIdx: number, + clipIdx: number, + resolvedUpdates: Partial, + templateUpdates: Partial + ): void { + const clip = this.getPlayerClip(trackIdx, clipIdx); + if (!clip) { + console.warn(`Clip not found at track ${trackIdx}, index ${clipIdx}`); + return; + } + + const initialConfig = structuredClone(clip.clipConfiguration); + const mergedResolved = deepMerge(structuredClone(initialConfig), resolvedUpdates); + + const templateClip = this.getTemplateClip(trackIdx, clipIdx); + const mergedTemplate = templateClip + ? deepMerge(structuredClone(templateClip), templateUpdates) + : deepMerge(structuredClone(initialConfig), templateUpdates); + + const command = new SetUpdatedClipCommand(clip, initialConfig, mergedResolved, { + trackIndex: trackIdx, + clipIndex: clipIdx, + templateConfig: mergedTemplate + }); + this.executeCommand(command); + } + /** @internal */ public updateTextContent(clip: Player, newText: string, initialConfig: ResolvedClip): void { const command = new UpdateTextContentCommand(clip, newText, initialConfig); @@ -487,6 +551,15 @@ export class Edit extends Entity { } } track.splice(insertIdx, 0, clip); + + // Sync originalEdit - re-insert clip template at same index + if (this.originalEdit?.timeline.tracks[trackIdx]?.clips) { + this.originalEdit.timeline.tracks[trackIdx].clips.splice( + insertIdx, + 0, + structuredClone(clip.clipConfiguration) + ); + } } this.addPlayerToContainer(trackIdx, clip); @@ -521,7 +594,17 @@ export class Edit extends Entity { propagateTimingChanges: (trackIndex, startFromClipIndex) => this.propagateTimingChanges(trackIndex, startFromClipIndex), resolveClipAutoLength: clip => this.resolveClipAutoLength(clip), untrackEndLengthClip: clip => this.endLengthClips.delete(clip), - trackEndLengthClip: clip => this.endLengthClips.add(clip) + trackEndLengthClip: clip => this.endLengthClips.add(clip), + // Merge field context + getMergeFields: () => this.mergeFields, + getTemplateClip: (trackIndex, clipIndex) => this.getTemplateClip(trackIndex, clipIndex), + setTemplateClipProperty: (trackIndex, clipIndex, propertyPath, value) => + this.setTemplateClipProperty(trackIndex, clipIndex, propertyPath, value), + syncTemplateClip: (trackIndex, clipIndex, templateClip) => + this.syncTemplateClip(trackIndex, clipIndex, templateClip), + // originalEdit track sync + insertOriginalEditTrack: trackIdx => this.insertOriginalEditTrack(trackIdx), + removeOriginalEditTrack: trackIdx => this.removeOriginalEditTrack(trackIdx) }; } @@ -545,6 +628,11 @@ export class Edit extends Entity { const clipIdx = this.tracks[trackIdx].indexOf(clip); if (clipIdx !== -1) { this.tracks[trackIdx].splice(clipIdx, 1); + + // Sync originalEdit - remove from template data to keep aligned with tracks array + if (this.originalEdit?.timeline.tracks[trackIdx]?.clips) { + this.originalEdit.timeline.tracks[trackIdx].clips.splice(clipIdx, 1); + } } } } @@ -815,6 +903,13 @@ export class Edit extends Entity { this.tracks[trackIdx].push(clipToAdd); + // Sync originalEdit with new clip to keep template data aligned with tracks array + if (this.originalEdit?.timeline.tracks[trackIdx]) { + this.originalEdit.timeline.tracks[trackIdx].clips.push( + structuredClone(clipToAdd.clipConfiguration) + ); + } + this.clips.push(clipToAdd); if (clipToAdd.getTimingIntent().length === "end") { @@ -1045,6 +1140,318 @@ export class Edit extends Entity { return [...this.toolbarButtons]; } + // ─── Template Edit Access (for merge field commands) ─────────────────────── + + /** Get the template clip from originalEdit */ + private getTemplateClip(trackIndex: number, clipIndex: number): ResolvedClip | null { + return this.originalEdit?.timeline.tracks[trackIndex]?.clips[clipIndex] ?? null; + } + + /** Get the text content from the template clip (with merge field placeholders) */ + public getTemplateClipText(trackIdx: number, clipIdx: number): string | null { + const templateClip = this.getTemplateClip(trackIdx, clipIdx); + if (!templateClip) return null; + const asset = templateClip.asset as { text?: string } | undefined; + return asset?.text ?? null; + } + + /** Set a property on the template clip in originalEdit using dot notation */ + public setTemplateClipProperty(trackIndex: number, clipIndex: number, propertyPath: string, value: unknown): void { + const clip = this.originalEdit?.timeline.tracks[trackIndex]?.clips[clipIndex]; + if (!clip) return; + setNestedValue(clip, propertyPath, value); + } + + /** Sync the entire template clip in originalEdit with a new clip configuration */ + private syncTemplateClip(trackIndex: number, clipIndex: number, templateClip: ResolvedClip): void { + if (!this.originalEdit?.timeline.tracks[trackIndex]?.clips) return; + this.originalEdit.timeline.tracks[trackIndex].clips[clipIndex] = structuredClone(templateClip); + } + + /** Insert an empty track into originalEdit at the specified index */ + private insertOriginalEditTrack(trackIdx: number): void { + if (!this.originalEdit?.timeline.tracks) return; + this.originalEdit.timeline.tracks.splice(trackIdx, 0, { clips: [] }); + } + + /** Remove a track from originalEdit at the specified index */ + private removeOriginalEditTrack(trackIdx: number): void { + if (!this.originalEdit?.timeline.tracks) return; + this.originalEdit.timeline.tracks.splice(trackIdx, 1); + } + + // ─── Merge Field API (command-based) ─────────────────────────────────────── + + /** + * Apply a merge field to a clip property. + * Creates a command for undo/redo support. + * + * @param trackIndex - Track index + * @param clipIndex - Clip index within the track + * @param propertyPath - Dot-notation path to property (e.g., "asset.src", "asset.color") + * @param fieldName - Name of the merge field (e.g., "MEDIA_URL") + * @param value - The resolved value to apply + * @param originalValue - Optional: the original value before merge field (for undo) + */ + public applyMergeField( + trackIndex: number, + clipIndex: number, + propertyPath: string, + fieldName: string, + value: string, + originalValue?: string + ): void { + const player = this.getPlayerClip(trackIndex, clipIndex); + if (!player) return; + + // Get current value from player for undo + const currentValue = getNestedValue(player.clipConfiguration, propertyPath); + const previousValue = originalValue ?? (typeof currentValue === "string" ? currentValue : ""); + + // Check if there's already a merge field on this property + const templateClip = this.getTemplateClip(trackIndex, clipIndex); + const templateValue = templateClip ? getNestedValue(templateClip, propertyPath) : null; + const previousFieldName = + typeof templateValue === "string" ? this.mergeFields.extractFieldName(templateValue) : null; + + const command = new SetMergeFieldCommand( + player, + propertyPath, + fieldName, + previousFieldName, + previousValue, + value, + trackIndex, + clipIndex + ); + this.executeCommand(command); + } + + /** + * Remove a merge field from a clip property, restoring the original value. + * + * @param trackIndex - Track index + * @param clipIndex - Clip index within the track + * @param propertyPath - Dot-notation path to property (e.g., "asset.src") + * @param restoreValue - The value to restore (original pre-merge-field value) + */ + public removeMergeField(trackIndex: number, clipIndex: number, propertyPath: string, restoreValue: string): void { + const player = this.getPlayerClip(trackIndex, clipIndex); + if (!player) return; + + // Get current merge field name + const templateClip = this.getTemplateClip(trackIndex, clipIndex); + const templateValue = templateClip ? getNestedValue(templateClip, propertyPath) : null; + const currentFieldName = + typeof templateValue === "string" ? this.mergeFields.extractFieldName(templateValue) : null; + + if (!currentFieldName) return; // No merge field to remove + + const command = new SetMergeFieldCommand( + player, + propertyPath, + null, // Removing merge field + currentFieldName, + restoreValue, + restoreValue, // New value is the restore value + trackIndex, + clipIndex + ); + this.executeCommand(command); + } + + /** + * Get the merge field name for a clip property, if any. + * + * @returns The field name if a merge field is applied, null otherwise + */ + public getMergeFieldForProperty(trackIndex: number, clipIndex: number, propertyPath: string): string | null { + const templateClip = this.getTemplateClip(trackIndex, clipIndex); + if (!templateClip) return null; + + const value = getNestedValue(templateClip, propertyPath); + return typeof value === "string" ? this.mergeFields.extractFieldName(value) : null; + } + + /** + * Update the value of a merge field. Updates all clips using this field in-place. + * This does NOT use the command pattern (no undo) - it's for live preview updates. + */ + public updateMergeFieldValueLive(fieldName: string, newValue: string): void { + // Update the field in the service + const field = this.mergeFields.get(fieldName); + if (!field) return; + this.mergeFields.register({ ...field, defaultValue: newValue }, { silent: true }); + + // Find and update all clips using this field + for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { + for (let clipIdx = 0; clipIdx < this.tracks[trackIdx].length; clipIdx += 1) { + const templateClip = this.getTemplateClip(trackIdx, clipIdx); + if (templateClip) { + // Check all string properties for this field + this.updateMergeFieldInObject( + this.tracks[trackIdx][clipIdx].clipConfiguration, + templateClip, + fieldName, + newValue + ); + } + } + } + } + + /** Helper: Update merge field occurrences in an object */ + private updateMergeFieldInObject(target: unknown, template: unknown, fieldName: string, newValue: string): void { + if (!target || !template || typeof target !== "object" || typeof template !== "object") return; + + for (const key of Object.keys(template as Record)) { + const templateVal = (template as Record)[key]; + const targetObj = target as Record; + + if (typeof templateVal === "string") { + const extractedField = this.mergeFields.extractFieldName(templateVal); + if (extractedField === fieldName) { + targetObj[key] = newValue; + } + } else if (templateVal && typeof templateVal === "object") { + this.updateMergeFieldInObject(targetObj[key], templateVal, fieldName, newValue); + } + } + } + + /** + * Redraw all clips that use a specific merge field. + * Call this after updateMergeFieldValueLive() to refresh the canvas. + * Handles both text redraws and asset reloads for URL changes. + */ + public redrawMergeFieldClips(fieldName: string): void { + for (const track of this.tracks) { + for (const player of track) { + const indices = this.findClipIndices(player); + if (indices) { + const templateClip = this.getTemplateClip(indices.trackIndex, indices.clipIndex); + if (templateClip) { + // Check if this clip uses the merge field and where + const usageInfo = this.getMergeFieldUsage(templateClip, fieldName); + if (usageInfo.used) { + // If the merge field is used for asset.src, reload the asset + if (usageInfo.isSrcField) { + player.reloadAsset(); + } + player.reconfigureAfterRestore(); + player.draw(); + } + } + } + } + } + } + + /** Helper: Check if and how a clip uses a specific merge field */ + private getMergeFieldUsage( + clip: unknown, + fieldName: string, + path: string = "" + ): { used: boolean; isSrcField: boolean } { + if (!clip || typeof clip !== "object") return { used: false, isSrcField: false }; + + for (const [key, value] of Object.entries(clip as Record)) { + const currentPath = path ? `${path}.${key}` : key; + + if (typeof value === "string") { + const extractedField = this.mergeFields.extractFieldName(value); + if (extractedField === fieldName) { + // Check if this is an asset.src property + const isSrcField = currentPath === "asset.src" || currentPath.endsWith(".src"); + return { used: true, isSrcField }; + } + } else if (typeof value === "object" && value !== null) { + const nested = this.getMergeFieldUsage(value, fieldName, currentPath); + if (nested.used) return nested; + } + } + return { used: false, isSrcField: false }; + } + + /** + * Check if a merge field is used for asset.src in any clip. + * Used by UI to determine if URL validation should be applied. + */ + public isSrcMergeField(fieldName: string): boolean { + for (const track of this.tracks) { + for (const player of track) { + const indices = this.findClipIndices(player); + if (indices) { + const templateClip = this.getTemplateClip(indices.trackIndex, indices.clipIndex); + if (templateClip) { + const usageInfo = this.getMergeFieldUsage(templateClip, fieldName); + if (usageInfo.used && usageInfo.isSrcField) { + return true; + } + } + } + } + } + return false; + } + + // ─── Global Merge Field Operations ────────────────────────────────────────── + + /** + * Remove a merge field globally from all clips and the registry. + * Restores all affected clip properties to the merge field's default value. + * + * @param fieldName - The merge field name to remove + */ + public deleteMergeFieldGlobally(fieldName: string): void { + const field = this.mergeFields.get(fieldName); + if (!field) return; + + const template = this.mergeFields.createTemplate(fieldName); + const restoreValue = field.defaultValue; + + // Find and restore all clips using this merge field + for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { + for (let clipIdx = 0; clipIdx < this.tracks[trackIdx].length; clipIdx += 1) { + const templateClip = this.getTemplateClip(trackIdx, clipIdx); + if (templateClip) { + // Find properties with this template and restore them + this.restoreMergeFieldInClip(trackIdx, clipIdx, templateClip, template, restoreValue); + } + } + } + + // Remove from registry + this.mergeFields.remove(fieldName); + } + + /** + * Helper: Find and restore merge field occurrences in a clip + */ + private restoreMergeFieldInClip( + trackIdx: number, + clipIdx: number, + templateClip: unknown, + template: string, + restoreValue: string, + path: string = "" + ): void { + if (!templateClip || typeof templateClip !== "object") return; + + for (const key of Object.keys(templateClip as Record)) { + const value = (templateClip as Record)[key]; + const propertyPath = path ? `${path}.${key}` : key; + + if (typeof value === "string" && value === template) { + // Found a property using this merge field - use removeMergeField for proper handling + this.removeMergeField(trackIdx, clipIdx, propertyPath, restoreValue); + } else if (typeof value === "object" && value !== null) { + // Recurse into nested objects + this.restoreMergeFieldInClip(trackIdx, clipIdx, value, template, restoreValue, propertyPath); + } + } + } + // ─── Intent Listeners ──────────────────────────────────────────────────────── private setupIntentListeners(): void { diff --git a/src/core/merge/index.ts b/src/core/merge/index.ts new file mode 100644 index 00000000..1a585f57 --- /dev/null +++ b/src/core/merge/index.ts @@ -0,0 +1,16 @@ +/** + * Merge field module for the Shotstack Studio SDK. + * + * Provides types, services, and utilities for merge field management. + */ + +// Types +export type { MergeField, SerializedMergeField } from "./types"; +export { toSerialized, fromSerialized } from "./types"; + +// Service +export { MergeFieldService, MERGE_FIELD_PATTERN, MERGE_FIELD_TEST_PATTERN } from "./merge-field-service"; +export type { MergeFieldEvents } from "./merge-field-service"; + +// Utility +export { applyMergeFields } from "./merge-fields"; diff --git a/src/core/merge/merge-field-service.ts b/src/core/merge/merge-field-service.ts new file mode 100644 index 00000000..eb0cb61f --- /dev/null +++ b/src/core/merge/merge-field-service.ts @@ -0,0 +1,154 @@ +/** + * Centralized service for merge field management. + * + * Provides CRUD operations, string resolution, and event emission + * for merge fields throughout the SDK. + */ + +import type { EventEmitter } from "@core/events/event-emitter"; + +import { type MergeField, type SerializedMergeField, fromSerialized, toSerialized } from "./types"; + +/** Regex pattern for merge field detection and extraction */ +export const MERGE_FIELD_PATTERN = /\{\{\s*([A-Z_0-9]+)\s*\}\}/gi; + +/** Regex pattern for testing if a string contains any merge field */ +export const MERGE_FIELD_TEST_PATTERN = /\{\{\s*[A-Z_0-9]+\s*\}\}/i; + +/** Event payloads emitted by the merge field service */ +export interface MergeFieldEvents { + "mergefield:registered": { field: MergeField }; + "mergefield:updated": { field: MergeField }; + "mergefield:removed": { name: string }; + "mergefield:changed": { fields: MergeField[] }; +} + +export class MergeFieldService { + private fields: Map = new Map(); + private events: EventEmitter; + + constructor(events: EventEmitter) { + this.events = events; + } + + // ─── CRUD Operations ──────────────────────────────────────────────────────── + + /** + * Register or update a merge field. + * @param field The merge field to register + * @param options.silent If true, suppresses event emission (for command-based operations) + */ + register(field: MergeField, options?: { silent?: boolean }): void { + const isNew = !this.fields.has(field.name); + this.fields.set(field.name, field); + + if (!options?.silent) { + this.events.emit(isNew ? "mergefield:registered" : "mergefield:updated", { field }); + this.events.emit("mergefield:changed", { fields: this.getAll() }); + } + } + + /** + * Remove a merge field by name. + * @param name The field name to remove + * @param options.silent If true, suppresses event emission (for command-based operations) + */ + remove(name: string, options?: { silent?: boolean }): boolean { + const removed = this.fields.delete(name); + if (removed && !options?.silent) { + this.events.emit("mergefield:removed", { name }); + this.events.emit("mergefield:changed", { fields: this.getAll() }); + } + return removed; + } + + /** Get a merge field by name */ + get(name: string): MergeField | undefined { + return this.fields.get(name); + } + + /** Get all registered merge fields */ + getAll(): MergeField[] { + return Array.from(this.fields.values()); + } + + /** Clear all merge fields */ + clear(): void { + this.fields.clear(); + } + + // ─── String Operations ────────────────────────────────────────────────────── + + /** + * Apply merge field substitutions to a string. + * Replaces {{ FIELD_NAME }} patterns with their default values. + */ + resolve(input: string): string { + if (!input || this.fields.size === 0) return input; + + return input.replace(MERGE_FIELD_PATTERN, (match, fieldName: string) => { + const field = this.fields.get(fieldName); + return field?.defaultValue ?? match; + }); + } + + /** + * Check if a string contains unresolved merge fields. + * Returns true if any {{ FIELD_NAME }} patterns remain after resolution. + */ + hasUnresolved(input: string): boolean { + if (!input) return false; + const resolved = this.resolve(input); + return MERGE_FIELD_TEST_PATTERN.test(resolved); + } + + /** + * Extract the first merge field name from a string. + * Returns null if no merge field pattern is found. + */ + extractFieldName(input: string): string | null { + if (!input) return null; + const match = MERGE_FIELD_TEST_PATTERN.exec(input); + if (!match) return null; + + const nameMatch = match[0].match(/\{\{\s*([A-Z_0-9]+)\s*\}\}/i); + return nameMatch ? nameMatch[1] : null; + } + + /** Check if a string is a merge field template (contains {{ FIELD }}) */ + isMergeFieldTemplate(input: string): boolean { + return MERGE_FIELD_TEST_PATTERN.test(input); + } + + /** Create a merge field template string from a field name */ + createTemplate(fieldName: string): string { + return `{{ ${fieldName} }}`; + } + + // ─── Serialization ────────────────────────────────────────────────────────── + + /** Export fields in Shotstack API format ({ find, replace }) */ + toSerializedArray(): SerializedMergeField[] { + return this.getAll().map(toSerialized); + } + + /** Import fields from Shotstack API format (does not emit event - called during loadEdit) */ + loadFromSerialized(fields: SerializedMergeField[]): void { + this.fields.clear(); + for (const f of fields) { + this.fields.set(f.find, fromSerialized(f)); + } + } + + // ─── Utility ──────────────────────────────────────────────────────────────── + + /** Generate a unique field name with a given prefix (e.g., MEDIA_1, MEDIA_2) */ + generateUniqueName(prefix: string): string { + const existingNames = new Set(this.fields.keys()); + let counter = 1; + while (existingNames.has(`${prefix}_${counter}`)) { + counter += 1; + } + return `${prefix}_${counter}`; + } +} diff --git a/src/core/merge/merge-fields.ts b/src/core/merge/merge-fields.ts index 57809556..8ef9adad 100644 --- a/src/core/merge/merge-fields.ts +++ b/src/core/merge/merge-fields.ts @@ -1,13 +1,10 @@ /** * Merge field replacement utility for Shotstack Studio SDK. - * Replaces {{ VARIABLE_NAME }} placeholders with actual values. + * Applies merge field substitutions to entire data structures. * @internal */ -export interface MergeField { - find: string; - replace: string; -} +import type { SerializedMergeField } from "./types"; /** * Escapes special regex characters in a string. @@ -16,7 +13,7 @@ function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -function replaceMergeFieldsRecursive(obj: T, fields: MergeField[]): T { +function replaceMergeFieldsRecursive(obj: T, fields: SerializedMergeField[]): T { if (typeof obj === "string") { let result: string = obj; for (const { find, replace } of fields) { @@ -42,8 +39,12 @@ function replaceMergeFieldsRecursive(obj: T, fields: MergeField[]): T { /** * Applies merge field replacements to any data structure. * Recursively traverses objects and arrays, replacing placeholders in strings. + * + * @param data - The data structure to process + * @param mergeFields - Array of { find, replace } pairs + * @returns A deep clone of the data with all merge fields replaced */ -export function applyMergeFields(data: T, mergeFields: MergeField[]): T { +export function applyMergeFields(data: T, mergeFields: SerializedMergeField[]): T { if (!mergeFields?.length) return data; return replaceMergeFieldsRecursive(structuredClone(data), mergeFields); } diff --git a/src/core/merge/types.ts b/src/core/merge/types.ts new file mode 100644 index 00000000..bf04f219 --- /dev/null +++ b/src/core/merge/types.ts @@ -0,0 +1,39 @@ +/** + * Merge field types for the Shotstack Studio SDK. + * + * Merge fields allow dynamic content substitution using {{ FIELD_NAME }} syntax. + * Values are replaced at render time, enabling template-based video generation. + */ + +/** + * A merge field definition used throughout the SDK. + */ +export interface MergeField { + /** Field identifier (uppercase convention: MY_FIELD) */ + name: string; + + /** Default value used for preview when no runtime value is provided */ + defaultValue: string; + + /** Optional description for UI display */ + description?: string; +} + +/** + * Serialized format for JSON export (matches Shotstack API). + * Conversion happens at serialization boundary only. + */ +export interface SerializedMergeField { + find: string; + replace: string; +} + +/** Convert internal MergeField to serialized API format */ +export function toSerialized(field: MergeField): SerializedMergeField { + return { find: field.name, replace: field.defaultValue }; +} + +/** Convert serialized API format to internal MergeField */ +export function fromSerialized(field: SerializedMergeField): MergeField { + return { name: field.find, defaultValue: field.replace }; +} diff --git a/src/core/shared/utils.ts b/src/core/shared/utils.ts index 04c5a563..93c73200 100644 --- a/src/core/shared/utils.ts +++ b/src/core/shared/utils.ts @@ -36,3 +36,67 @@ export function deepMerge, U extends Record)[parts[i]]; + } + if (current !== null && current !== undefined && typeof current === "object") { + (current as Record)[parts[parts.length - 1]] = value; + } +} + +/** + * Get a nested value from an object using dot notation. + * e.g., getNestedValue(obj, "asset.src") returns obj.asset.src + * @internal + */ +export function getNestedValue(obj: unknown, path: string): unknown { + const parts = path.split("."); + let current: unknown = obj; + for (const part of parts) { + if (current === null || current === undefined || typeof current !== "object") return undefined; + current = (current as Record)[part]; + } + return current; +} + +export interface UrlValidationResult { + valid: boolean; + error?: string; +} + +/** + * Validate that a URL is accessible before attempting to load it as an asset. + * Uses HEAD request to check CORS and availability without downloading the full asset. + * @internal + */ +export async function validateAssetUrl(url: string): Promise { + // Basic URL format validation + try { + // eslint-disable-next-line no-new -- URL constructor validates format + new URL(url); + } catch { + return { valid: false, error: "Invalid URL format" }; + } + + // Check accessibility via HEAD request + try { + const response = await fetch(url, { method: "HEAD", mode: "cors" }); + if (!response.ok) { + return { valid: false, error: `URL returned ${response.status} ${response.statusText}` }; + } + return { valid: true }; + } catch (error) { + const message = error instanceof Error ? error.message : "URL not accessible"; + return { valid: false, error: message }; + } +} diff --git a/src/core/ui/canvas-toolbar.css.ts b/src/core/ui/canvas-toolbar.css.ts index 6b5edc95..9e463705 100644 --- a/src/core/ui/canvas-toolbar.css.ts +++ b/src/core/ui/canvas-toolbar.css.ts @@ -318,4 +318,125 @@ export const CANVAS_TOOLBAR_STYLES = ` .ss-canvas-toolbar-color-swatch.active { border-color: rgba(0, 0, 0, 0.3); } + +/* Variables popup */ +.ss-canvas-toolbar-popup--variables { + min-width: 260px; +} + +.ss-variables-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.ss-variables-add-btn { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.06); + border: none; + border-radius: 6px; + color: rgba(0, 0, 0, 0.65); + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; +} + +.ss-variables-add-btn:hover { + background: rgba(0, 0, 0, 0.1); + color: rgba(0, 0, 0, 0.9); +} + +.ss-variables-list { + display: flex; + flex-direction: column; + gap: 6px; + max-height: 240px; + overflow-y: auto; +} + +.ss-variables-empty { + padding: 16px 12px; + text-align: center; + font-size: 13px; + color: rgba(0, 0, 0, 0.4); +} + +.ss-variable-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: rgba(0, 0, 0, 0.03); + border-radius: 8px; +} + +.ss-variable-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.ss-variable-name { + font-size: 12px; + font-weight: 600; + color: rgba(99, 102, 241, 0.9); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; +} + +.ss-variable-value { + width: 100%; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.8); + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 6px; + font-size: 12px; + color: #1a1a1a; + outline: none; +} + +.ss-variable-value:focus { + border-color: rgba(99, 102, 241, 0.5); + background: #fff; +} + +.ss-variable-value::placeholder { + color: rgba(0, 0, 0, 0.35); +} + +.ss-variable-value.error { + border-color: rgba(239, 68, 68, 0.6); + background: rgba(239, 68, 68, 0.15); +} + +.ss-variable-value.error:focus { + border-color: rgba(239, 68, 68, 0.8); +} + +.ss-variable-delete { + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + border-radius: 6px; + color: rgba(0, 0, 0, 0.35); + font-size: 16px; + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.ss-variable-delete:hover { + background: rgba(239, 68, 68, 0.1); + color: #ef4444; +} `; diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts index 5a450974..3dfda424 100644 --- a/src/core/ui/canvas-toolbar.ts +++ b/src/core/ui/canvas-toolbar.ts @@ -1,3 +1,7 @@ +import type { Edit } from "@core/edit"; +import type { MergeField } from "@core/merge"; +import { validateAssetUrl } from "@core/shared/utils"; + import { CANVAS_TOOLBAR_STYLES } from "./canvas-toolbar.css"; type ResolutionChangeCallback = (width: number, height: number) => void; @@ -39,12 +43,14 @@ const COLOR_SWATCHES = [ // SVG Icons const ICONS = { monitor: ``, - check: `` + check: ``, + variables: `` }; export class CanvasToolbar { private container: HTMLDivElement | null = null; private styleElement: HTMLStyleElement | null = null; + private edit: Edit | null = null; // Current state private currentWidth: number = 1920; @@ -56,11 +62,17 @@ export class CanvasToolbar { private resolutionPopup: HTMLDivElement | null = null; private backgroundPopup: HTMLDivElement | null = null; private fpsPopup: HTMLDivElement | null = null; + private variablesPopup: HTMLDivElement | null = null; // Button elements private resolutionBtn: HTMLButtonElement | null = null; private backgroundBtn: HTMLButtonElement | null = null; private fpsBtn: HTMLButtonElement | null = null; + private variablesBtn: HTMLButtonElement | null = null; + + // Variables elements + private variablesList: HTMLDivElement | null = null; + private variablesEmpty: HTMLDivElement | null = null; // Label elements private resolutionLabel: HTMLSpanElement | null = null; @@ -85,7 +97,8 @@ export class CanvasToolbar { // Positioning private padding = 12; - constructor() { + constructor(edit?: Edit) { + this.edit = edit ?? null; this.injectStyles(); } @@ -178,6 +191,23 @@ export class CanvasToolbar { ).join("")}
+ +
+ + +
+ +
+
+ Merge Fields + +
+
+
No merge fields defined
+
+
`; parent.appendChild(this.container); @@ -186,10 +216,15 @@ export class CanvasToolbar { this.resolutionBtn = this.container.querySelector('[data-action="resolution"]'); this.backgroundBtn = this.container.querySelector('[data-action="background"]'); this.fpsBtn = this.container.querySelector('[data-action="fps"]'); + this.variablesBtn = this.container.querySelector('[data-action="variables"]'); this.resolutionPopup = this.container.querySelector('[data-popup="resolution"]'); this.backgroundPopup = this.container.querySelector('[data-popup="background"]'); this.fpsPopup = this.container.querySelector('[data-popup="fps"]'); + this.variablesPopup = this.container.querySelector('[data-popup="variables"]'); + + this.variablesList = this.container.querySelector("[data-variables-list]"); + this.variablesEmpty = this.container.querySelector("[data-variables-empty]"); this.resolutionLabel = this.container.querySelector("[data-resolution-label]"); this.fpsLabel = this.container.querySelector("[data-fps-label]"); @@ -218,6 +253,17 @@ export class CanvasToolbar { e.stopPropagation(); this.togglePopup("fps"); }); + this.variablesBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup("variables"); + this.renderVariablesList(); + }); + + // Variables - Add button + this.variablesPopup?.querySelector('[data-action="add-variable"]')?.addEventListener("click", e => { + e.stopPropagation(); + this.addVariable(); + }); // Resolution preset clicks this.resolutionPopup?.querySelectorAll("[data-width]").forEach(item => { @@ -267,11 +313,12 @@ export class CanvasToolbar { document.addEventListener("click", this.clickOutsideHandler); } - private togglePopup(popup: "resolution" | "background" | "fps"): void { + private togglePopup(popup: "resolution" | "background" | "fps" | "variables"): void { const popupMap = { resolution: { popup: this.resolutionPopup, btn: this.resolutionBtn }, background: { popup: this.backgroundPopup, btn: this.backgroundBtn }, - fps: { popup: this.fpsPopup, btn: this.fpsBtn } + fps: { popup: this.fpsPopup, btn: this.fpsBtn }, + variables: { popup: this.variablesPopup, btn: this.variablesBtn } }; const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); @@ -296,9 +343,11 @@ export class CanvasToolbar { this.resolutionPopup?.classList.remove("visible"); this.backgroundPopup?.classList.remove("visible"); this.fpsPopup?.classList.remove("visible"); + this.variablesPopup?.classList.remove("visible"); this.resolutionBtn?.classList.remove("active"); this.backgroundBtn?.classList.remove("active"); this.fpsBtn?.classList.remove("active"); + this.variablesBtn?.classList.remove("active"); } private handleResolutionSelect(width: number, height: number): void { @@ -317,7 +366,7 @@ export class CanvasToolbar { if (this.customWidthInput && this.customHeightInput) { const width = parseInt(this.customWidthInput.value, 10); const height = parseInt(this.customHeightInput.value, 10); - if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) { + if (!Number.isNaN(width) && !Number.isNaN(height) && width > 0 && height > 0) { this.currentWidth = width; this.currentHeight = height; this.updateResolutionLabel(); @@ -397,6 +446,83 @@ export class CanvasToolbar { }); } + private renderVariablesList(): void { + if (!this.variablesList || !this.variablesEmpty || !this.edit) return; + + const fields = this.edit.mergeFields.getAll(); + + if (fields.length === 0) { + this.variablesList.innerHTML = ""; + this.variablesList.style.display = "none"; + this.variablesEmpty.style.display = "block"; + return; + } + + this.variablesEmpty.style.display = "none"; + this.variablesList.style.display = "block"; + this.variablesList.innerHTML = fields + .map( + (f: MergeField) => ` +
+
+ {{ ${f.name} }} + +
+ +
+ ` + ) + .join(""); + + // Add event listeners for value changes and delete buttons + this.variablesList.querySelectorAll("[data-var-input]").forEach(input => { + input.addEventListener("change", async e => { + const el = e.target as HTMLInputElement; + const name = el.dataset["varInput"]; + if (name && this.edit) { + // Validate URL if this is a src-type merge field + if (this.edit.isSrcMergeField(name)) { + const validation = await validateAssetUrl(el.value); + if (!validation.valid) { + el.classList.add("error"); + el.title = validation.error || "Invalid URL"; + return; + } + el.classList.remove("error"); + el.title = ""; + } + + // Update the merge field value and refresh affected clips + this.edit.updateMergeFieldValueLive(name, el.value); + this.edit.redrawMergeFieldClips(name); + } + }); + }); + + this.variablesList.querySelectorAll("[data-delete-var]").forEach(btn => { + btn.addEventListener("click", e => { + e.stopPropagation(); + const el = e.target as HTMLElement; + const name = el.dataset["deleteVar"]; + if (name) { + this.edit?.deleteMergeFieldGlobally(name); + this.renderVariablesList(); + } + }); + }); + } + + private addVariable(): void { + if (!this.edit) return; + + const name = prompt("Variable name:"); + if (!name || !name.trim()) return; + + const sanitizedName = name.trim().toUpperCase().replace(/\s+/g, "_"); + this.edit.mergeFields.register({ name: sanitizedName, defaultValue: "" }); + this.renderVariablesList(); + } + setResolution(width: number, height: number): void { this.currentWidth = Math.round(width); this.currentHeight = Math.round(height); @@ -448,9 +574,13 @@ export class CanvasToolbar { this.resolutionPopup = null; this.backgroundPopup = null; this.fpsPopup = null; + this.variablesPopup = null; this.resolutionBtn = null; this.backgroundBtn = null; this.fpsBtn = null; + this.variablesBtn = null; + this.variablesList = null; + this.variablesEmpty = null; this.resolutionLabel = null; this.fpsLabel = null; this.bgColorDot = null; diff --git a/src/core/ui/media-toolbar.css.ts b/src/core/ui/media-toolbar.css.ts index 06edc136..5a5b6741 100644 --- a/src/core/ui/media-toolbar.css.ts +++ b/src/core/ui/media-toolbar.css.ts @@ -483,4 +483,181 @@ export const MEDIA_TOOLBAR_STYLES = ` border-left: 1px solid rgba(255, 255, 255, 0.06); border-right: 1px solid rgba(255, 255, 255, 0.06); } + +/* Advanced menu button - icon only */ +.ss-media-toolbar-btn--icon { + min-width: 32px; + padding: 0 8px; +} + +/* Advanced popup */ +.ss-media-toolbar-popup--advanced { + min-width: 200px; + padding: 12px; +} + +/* Advanced option row */ +.ss-advanced-option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.ss-advanced-label { + font-size: 12px; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); +} + +/* Toggle switch */ +.ss-toggle { + position: relative; + display: inline-block; + width: 36px; + height: 20px; + flex-shrink: 0; +} + +.ss-toggle input { + opacity: 0; + width: 0; + height: 0; +} + +.ss-toggle-slider { + position: absolute; + cursor: pointer; + inset: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 10px; + transition: 0.2s ease; +} + +.ss-toggle-slider::before { + content: ''; + position: absolute; + width: 16px; + height: 16px; + left: 2px; + top: 2px; + background: rgba(255, 255, 255, 0.9); + border-radius: 50%; + transition: 0.2s ease; +} + +.ss-toggle input:checked + .ss-toggle-slider { + background: rgba(99, 102, 241, 0.8); +} + +.ss-toggle input:checked + .ss-toggle-slider::before { + transform: translateX(16px); +} + +/* Dynamic source panel */ +.ss-dynamic-panel { + display: none; + padding-top: 12px; + margin-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +.ss-dynamic-input-wrapper { + position: relative; +} + +.ss-dynamic-input { + width: 100%; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + padding: 8px 12px; + color: rgba(255, 255, 255, 0.9); + font-size: 12px; + font-family: inherit; + outline: none; + box-sizing: border-box; +} + +.ss-dynamic-input:focus { + border-color: rgba(255, 255, 255, 0.2); + background: rgba(255, 255, 255, 0.08); +} + +.ss-dynamic-input::placeholder { + color: rgba(255, 255, 255, 0.4); +} + +.ss-dynamic-input.error { + border-color: rgba(239, 68, 68, 0.6); + background: rgba(239, 68, 68, 0.1); +} + +.ss-dynamic-input.error:focus { + border-color: rgba(239, 68, 68, 0.8); +} + +/* Dynamic dropdown */ +.ss-dynamic-dropdown { + display: none; + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + background: rgba(32, 32, 36, 0.98); + backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 6px; + max-height: 160px; + overflow-y: auto; + z-index: 200; +} + +.ss-dynamic-dropdown.visible { + display: block; +} + +.ss-dynamic-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + cursor: pointer; + transition: background 0.1s ease; +} + +.ss-dynamic-item:hover, +.ss-dynamic-item.selected { + background: rgba(255, 255, 255, 0.1); +} + +.ss-dynamic-item-name { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + font-size: 12px; + color: rgba(99, 102, 241, 0.9); +} + +.ss-dynamic-item-preview { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ss-dynamic-item--create { + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.ss-dynamic-item--create .ss-dynamic-item-name { + color: rgba(52, 211, 153, 0.9); +} + +.ss-dynamic-empty { + padding: 12px; + text-align: center; + font-size: 11px; + color: rgba(255, 255, 255, 0.4); +} `; diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index a8339c09..a1689b6e 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -1,4 +1,5 @@ import type { Edit } from "@core/edit"; +import { validateAssetUrl } from "@core/shared/utils"; import { MEDIA_TOOLBAR_STYLES } from "./media-toolbar.css"; @@ -25,7 +26,8 @@ const ICONS = { volumeMute: ``, transition: ``, chevron: ``, - check: `` + check: ``, + moreVertical: `` }; export class MediaToolbar { @@ -88,6 +90,18 @@ export class MediaToolbar { // Volume section private volumeSection: HTMLDivElement | null = null; + // Advanced menu elements + private advancedBtn: HTMLButtonElement | null = null; + private advancedPopup: HTMLDivElement | null = null; + private dynamicToggle: HTMLInputElement | null = null; + private dynamicPanel: HTMLDivElement | null = null; + private dynamicInput: HTMLInputElement | null = null; + + // Dynamic source state + private isDynamicSource: boolean = false; + private dynamicFieldName: string = ""; + private originalSrc: string = ""; + // Click outside handler private clickOutsideHandler: ((e: MouseEvent) => void) | null = null; @@ -231,6 +245,30 @@ export class MediaToolbar {
+ +
+ + +
+ +
+
+ Dynamic Source + +
+
+ +
+
+
`; parent.insertBefore(this.container, parent.firstChild); @@ -263,6 +301,13 @@ export class MediaToolbar { this.directionRow = this.container.querySelector("[data-direction-row]"); this.speedValueLabel = this.container.querySelector("[data-speed-value]"); + // Advanced menu elements + this.advancedBtn = this.container.querySelector('[data-action="advanced"]'); + this.advancedPopup = this.container.querySelector('[data-popup="advanced"]'); + this.dynamicToggle = this.container.querySelector("[data-dynamic-toggle]"); + this.dynamicPanel = this.container.querySelector("[data-dynamic-panel]"); + this.dynamicInput = this.container.querySelector("[data-dynamic-input]"); + this.setupEventListeners(); } @@ -288,6 +333,13 @@ export class MediaToolbar { e.stopPropagation(); this.togglePopup("transition"); }); + this.advancedBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup("advanced"); + }); + + // Dynamic source handlers + this.setupDynamicSourceHandlers(); // Fit options this.fitPopup?.querySelectorAll("[data-fit]").forEach(item => { @@ -358,13 +410,14 @@ export class MediaToolbar { document.addEventListener("click", this.clickOutsideHandler); } - private togglePopup(popup: "fit" | "opacity" | "scale" | "volume" | "transition"): void { + private togglePopup(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "advanced"): void { const popupMap = { fit: { popup: this.fitPopup, btn: this.fitBtn }, opacity: { popup: this.opacityPopup, btn: this.opacityBtn }, scale: { popup: this.scalePopup, btn: this.scaleBtn }, volume: { popup: this.volumePopup, btn: this.volumeBtn }, - transition: { popup: this.transitionPopup, btn: this.transitionBtn } + transition: { popup: this.transitionPopup, btn: this.transitionBtn }, + advanced: { popup: this.advancedPopup, btn: this.advancedBtn } }; const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); @@ -382,11 +435,13 @@ export class MediaToolbar { this.scalePopup?.classList.remove("visible"); this.volumePopup?.classList.remove("visible"); this.transitionPopup?.classList.remove("visible"); + this.advancedPopup?.classList.remove("visible"); this.fitBtn?.classList.remove("active"); this.opacityBtn?.classList.remove("active"); this.scaleBtn?.classList.remove("active"); this.volumeBtn?.classList.remove("active"); this.transitionBtn?.classList.remove("active"); + this.advancedBtn?.classList.remove("active"); } private handleFitChange(fit: FitValue): void { @@ -647,6 +702,165 @@ export class MediaToolbar { } } + // ─── Dynamic Source Handlers ───────────────────────────────────────────────── + + private setupDynamicSourceHandlers(): void { + // Toggle handler + this.dynamicToggle?.addEventListener("change", () => { + const checked = this.dynamicToggle?.checked || false; + this.isDynamicSource = checked; + + if (this.dynamicPanel) { + this.dynamicPanel.style.display = checked ? "block" : "none"; + } + + if (checked) { + this.dynamicInput?.focus(); + } else { + // Revert to original src using Edit API + this.clearDynamicSource(); + } + }); + + // On Enter key, apply the URL as dynamic source + this.dynamicInput?.addEventListener("keydown", e => { + if (e.key === "Enter") { + e.preventDefault(); + this.applyDynamicUrl(); + } else if (e.key === "Escape") { + this.dynamicInput?.blur(); + } + }); + + // On blur, also apply the URL + this.dynamicInput?.addEventListener("blur", () => { + this.applyDynamicUrl(); + }); + } + + /** + * Apply dynamic source using the new Edit.applyMergeField() API. + * This uses the command pattern for undo/redo support and in-place asset reloading. + * Validates the URL before applying to prevent CORS/404 errors. + */ + private async applyDynamicUrl(): Promise { + const url = (this.dynamicInput?.value || "").trim(); + if (!url) return; + + // Validate URL before applying + const validation = await validateAssetUrl(url); + if (!validation.valid) { + this.showUrlError(validation.error || "Invalid URL"); + return; + } + + // Clear any previous error state + this.clearUrlError(); + + // If already a dynamic source, update the field value via live update + if (this.dynamicFieldName) { + this.edit.updateMergeFieldValueLive(this.dynamicFieldName, url); + // Also reload the asset to show the new image/video + const player = this.edit.getPlayerClip(this.currentTrackIndex, this.currentClipIndex); + if (player) { + player.reloadAsset(); + } + return; + } + + // Generate unique field name and apply merge field + const fieldName = this.edit.mergeFields.generateUniqueName("MEDIA"); + + // Use Edit API to apply merge field (handles template + resolved value atomically) + this.edit.applyMergeField( + this.currentTrackIndex, + this.currentClipIndex, + "asset.src", + fieldName, + url, + this.originalSrc // Pass original src for undo + ); + + this.dynamicFieldName = fieldName; + } + + private showUrlError(message: string): void { + if (this.dynamicInput) { + this.dynamicInput.classList.add("error"); + this.dynamicInput.title = message; + } + } + + private clearUrlError(): void { + if (this.dynamicInput) { + this.dynamicInput.classList.remove("error"); + this.dynamicInput.title = ""; + } + } + + /** + * Remove dynamic source using the new Edit.removeMergeField() API. + * Restores the original src value. + */ + private clearDynamicSource(): void { + if (!this.dynamicFieldName) return; + + // Use Edit API to remove merge field (handles undo and asset reload) + this.edit.removeMergeField( + this.currentTrackIndex, + this.currentClipIndex, + "asset.src", + this.originalSrc // Restore original src + ); + + this.dynamicFieldName = ""; + if (this.dynamicInput) { + this.dynamicInput.value = ""; + } + } + + /** + * Update UI based on whether this clip has a dynamic source applied. + * Uses the new Edit.getMergeFieldForProperty() API. + */ + private updateDynamicSourceUI(): void { + const player = this.edit.getPlayerClip(this.currentTrackIndex, this.currentClipIndex); + if (!player) return; + + // Use Edit API to check if this property has a merge field + const fieldName = this.edit.getMergeFieldForProperty( + this.currentTrackIndex, + this.currentClipIndex, + "asset.src" + ); + + if (fieldName) { + // Has dynamic source + this.isDynamicSource = true; + this.dynamicFieldName = fieldName; + if (this.dynamicToggle) this.dynamicToggle.checked = true; + if (this.dynamicPanel) this.dynamicPanel.style.display = "block"; + + // Show the default URL value + const mergeField = this.edit.mergeFields.get(fieldName); + if (this.dynamicInput) { + this.dynamicInput.value = mergeField?.defaultValue || ""; + } + } else { + // No dynamic source - store original src for later restoration + this.isDynamicSource = false; + this.dynamicFieldName = ""; + + // Get current resolved src as the original value + const asset = player.clipConfiguration.asset as { src?: string }; + this.originalSrc = asset?.src || ""; + + if (this.dynamicToggle) this.dynamicToggle.checked = false; + if (this.dynamicPanel) this.dynamicPanel.style.display = "none"; + if (this.dynamicInput) this.dynamicInput.value = ""; + } + } + private updateFitDisplay(): void { if (this.fitLabel) { const option = FIT_OPTIONS.find(o => o.value === this.currentFit); @@ -745,6 +959,9 @@ export class MediaToolbar { this.activeTransitionTab = "in"; this.updateTransitionUI(); + // Update dynamic source state + this.updateDynamicSourceUI(); + // Show/hide volume section based on asset type if (this.volumeSection) { this.volumeSection.classList.toggle("hidden", !isVideo); @@ -796,5 +1013,12 @@ export class MediaToolbar { // Transition elements this.directionRow = null; this.speedValueLabel = null; + + // Advanced menu elements + this.advancedBtn = null; + this.advancedPopup = null; + this.dynamicToggle = null; + this.dynamicPanel = null; + this.dynamicInput = null; } } diff --git a/src/core/ui/rich-text-toolbar.css.ts b/src/core/ui/rich-text-toolbar.css.ts index 9bf30ed1..1c693343 100644 --- a/src/core/ui/rich-text-toolbar.css.ts +++ b/src/core/ui/rich-text-toolbar.css.ts @@ -154,6 +154,7 @@ export const TOOLBAR_STYLES = ` .ss-toolbar-btn--text-edit span { font-size: 12px; font-weight: 500; } .ss-toolbar-popup--text-edit { min-width: 280px; padding: 14px 16px; } +.ss-toolbar-text-area-wrapper { position: relative; } .ss-toolbar-text-area { width: 100%; min-height: 80px; @@ -173,6 +174,49 @@ export const TOOLBAR_STYLES = ` .ss-toolbar-text-area:focus { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.08); } .ss-toolbar-text-area::placeholder { color: rgba(255, 255, 255, 0.4); } +/* Autocomplete popup for merge field variables */ +.ss-autocomplete-popup { + display: none; + position: absolute; + bottom: calc(100% + 4px); + left: 0; + right: 0; + background: rgba(32, 32, 36, 0.98); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + max-height: 160px; + overflow-y: auto; + z-index: 300; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); +} +.ss-autocomplete-popup.visible { display: block; } +.ss-autocomplete-item { + padding: 8px 12px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + transition: background 0.1s ease; +} +.ss-autocomplete-item:hover, +.ss-autocomplete-item.selected { background: rgba(255, 255, 255, 0.1); } +.ss-autocomplete-var { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + color: rgba(99, 102, 241, 0.9); + font-size: 13px; + font-weight: 500; +} +.ss-autocomplete-preview { + color: rgba(255, 255, 255, 0.4); + font-size: 12px; + max-width: 100px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .ss-toolbar-checkbox { width: 18px; height: 18px; diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 1a2ce5a7..78e7e68a 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -1,5 +1,6 @@ import type { Edit } from "@core/edit"; import { FONT_PATHS } from "@core/fonts/font-config"; +import type { MergeField } from "@core/merge"; import type { ResolvedClip } from "@schemas/clip"; import type { RichTextAsset } from "@schemas/rich-text-asset"; @@ -54,6 +55,14 @@ export class RichTextToolbar { private textEditPopup: HTMLDivElement | null = null; private textEditArea: HTMLTextAreaElement | null = null; private textEditDebounceTimer: ReturnType | null = null; + + // Autocomplete for merge field variables + private autocompletePopup: HTMLDivElement | null = null; + private autocompleteItems: HTMLDivElement | null = null; + private autocompleteVisible: boolean = false; + private autocompleteFilter: string = ""; + private autocompleteStartPos: number = 0; + private selectedAutocompleteIndex: number = 0; private borderBtn: HTMLButtonElement | null = null; private borderPopup: HTMLDivElement | null = null; private borderWidthSlider: HTMLInputElement | null = null; @@ -126,7 +135,12 @@ export class RichTextToolbar {
Edit Text
- +
+ +
+
+
+
@@ -428,6 +442,8 @@ export class RichTextToolbar { this.textEditBtn = this.container.querySelector("[data-action='text-edit-toggle']"); this.textEditPopup = this.container.querySelector("[data-text-edit-popup]"); this.textEditArea = this.container.querySelector("[data-text-edit-area]"); + this.autocompletePopup = this.container.querySelector("[data-autocomplete-popup]"); + this.autocompleteItems = this.container.querySelector("[data-autocomplete-items]"); this.container.addEventListener("click", this.handleClick.bind(this)); @@ -673,8 +689,38 @@ export class RichTextToolbar { }); // Text edit area handlers - this.textEditArea?.addEventListener("input", () => this.debouncedApplyTextEdit()); + this.textEditArea?.addEventListener("input", () => { + this.checkAutocomplete(); + this.debouncedApplyTextEdit(); + }); this.textEditArea?.addEventListener("keydown", e => { + // Handle autocomplete navigation when visible + if (this.autocompleteVisible) { + if (e.key === "ArrowDown") { + e.preventDefault(); + const count = this.getFilteredFieldCount(); + this.selectedAutocompleteIndex = Math.min(this.selectedAutocompleteIndex + 1, count - 1); + this.showAutocomplete(); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + this.selectedAutocompleteIndex = Math.max(this.selectedAutocompleteIndex - 1, 0); + this.showAutocomplete(); + return; + } + if (e.key === "Enter" || e.key === "Tab") { + e.preventDefault(); + this.insertSelectedVariable(); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + this.hideAutocomplete(); + return; + } + } + // Apply on Ctrl/Cmd+Enter (allow normal Enter for newlines) if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -1071,8 +1117,14 @@ export class RichTextToolbar { const isVisible = this.textEditPopup.style.display !== "none"; if (!isVisible && this.textEditArea) { + // Read from originalEdit (template) to show merge field placeholders + const templateText = this.edit.getTemplateClipText( + this.selectedTrackIdx, + this.selectedClipIdx + ); + // Fallback to resolved text if no template available const asset = this.getCurrentAsset(); - this.textEditArea.value = asset?.text ?? ""; + this.textEditArea.value = templateText ?? asset?.text ?? ""; } this.textEditPopup.style.display = isVisible ? "none" : "block"; if (!isVisible) { @@ -1092,8 +1144,139 @@ export class RichTextToolbar { private applyTextEdit(): void { if (!this.textEditArea) return; - const newText = this.textEditArea.value; - this.updateClipProperty({ text: newText }); + const templateText = this.textEditArea.value; + + // Resolve any merge field templates in the text for canvas rendering + const resolvedText = this.edit.mergeFields.resolve(templateText); + + // Update both stores: resolved for canvas, template for export + this.edit.updateClipWithTemplate( + this.selectedTrackIdx, + this.selectedClipIdx, + { asset: { text: resolvedText } as ResolvedClip["asset"] }, + { asset: { text: templateText } as ResolvedClip["asset"] } + ); + this.syncState(); + } + + // ─── Autocomplete for Merge Field Variables ───────────────────────────────── + + private checkAutocomplete(): void { + if (!this.textEditArea) return; + + const pos = this.textEditArea.selectionStart; + const text = this.textEditArea.value.substring(0, pos); + const match = text.match(/\{\{\s*([A-Z_0-9]*)$/i); + + if (match) { + this.autocompleteStartPos = pos - match[0].length; + this.autocompleteFilter = match[1].toUpperCase(); + this.showAutocomplete(); + } else { + this.hideAutocomplete(); + } + } + + private showAutocomplete(): void { + if (!this.autocompletePopup || !this.autocompleteItems) return; + + const fields = this.edit.mergeFields.getAll(); + const filtered = fields.filter((f: MergeField) => f.name.toUpperCase().includes(this.autocompleteFilter)); + + if (filtered.length === 0) { + this.hideAutocomplete(); + return; + } + + // Reset selection if out of bounds + if (this.selectedAutocompleteIndex >= filtered.length) { + this.selectedAutocompleteIndex = 0; + } + + this.autocompleteItems.innerHTML = filtered + .map( + (f: MergeField, i: number) => ` +
+ {{ ${f.name} }} + ${f.defaultValue ? `${f.defaultValue}` : ""} +
+ ` + ) + .join(""); + + // Add click handlers + this.autocompleteItems.querySelectorAll(".ss-autocomplete-item").forEach(item => { + item.addEventListener("click", e => { + e.stopPropagation(); + const el = e.currentTarget as HTMLElement; + const { varName } = el.dataset; + if (varName) { + this.insertVariable(varName); + } + }); + }); + + this.autocompletePopup.classList.add("visible"); + this.autocompleteVisible = true; + } + + private hideAutocomplete(): void { + if (this.autocompletePopup) { + this.autocompletePopup.classList.remove("visible"); + } + this.autocompleteVisible = false; + this.selectedAutocompleteIndex = 0; + } + + private insertVariable(varName: string): void { + if (!this.textEditArea) return; + + const before = this.textEditArea.value.substring(0, this.autocompleteStartPos); + const after = this.textEditArea.value.substring(this.textEditArea.selectionStart); + + // Build template string (keeps {{ VAR }}) + const templateText = `${before}{{ ${varName} }}${after}`; + + // Resolve for clipConfiguration (canvas rendering) + const field = this.edit.mergeFields.get(varName); + const resolvedValue = field?.defaultValue ?? `{{ ${varName} }}`; + const resolvedText = `${before}${resolvedValue}${after}`; + + // Keep template in text area (user can see merge fields) + this.textEditArea.value = templateText; + + // Position cursor after inserted template + const newPos = this.autocompleteStartPos + varName.length + 6; // "{{ " + name + " }}" + this.textEditArea.selectionStart = newPos; + this.textEditArea.selectionEnd = newPos; + this.textEditArea.focus(); + + this.hideAutocomplete(); + + // Update both stores: resolved for canvas, template for export + this.edit.updateClipWithTemplate( + this.selectedTrackIdx, + this.selectedClipIdx, + { asset: { text: resolvedText } as ResolvedClip["asset"] }, + { asset: { text: templateText } as ResolvedClip["asset"] } + ); + this.syncState(); + } + + private insertSelectedVariable(): void { + const selected = this.autocompleteItems?.querySelector(".selected") as HTMLElement | null; + if (!selected) return; + + const { varName } = selected.dataset; + if (varName) { + this.insertVariable(varName); + } + } + + private getFilteredFieldCount(): number { + const fields = this.edit.mergeFields.getAll(); + return fields.filter((f: MergeField) => f.name.toUpperCase().includes(this.autocompleteFilter)).length; } private buildFontList(): void { From b9606267ccd6397212d406f65f93359408e06318 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 15:29:20 +1100 Subject: [PATCH 071/463] feat: add divider before code button in toolbar --- src/main.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main.ts b/src/main.ts index d1c7c3fb..f3f93808 100644 --- a/src/main.ts +++ b/src/main.ts @@ -37,6 +37,7 @@ async function main() { id: "code", icon: ``, tooltip: "Add Code", + dividerBefore: true, event: "code:requested" }); From d77ee054dfab88b119b468707e8e57e3175eda2f Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 17:05:27 +1100 Subject: [PATCH 072/463] feat: add HTML/CSS timeline component with interaction support --- .../components/clip/clip-component.ts | 188 +++++++ .../components/playhead/playhead-component.ts | 122 +++++ .../components/ruler/ruler-component.ts | 109 +++++ .../components/toolbar/toolbar-component.ts | 159 ++++++ .../components/track/track-component.ts | 121 +++++ .../components/track/track-list.ts | 148 ++++++ .../core/state/timeline-state.ts | 142 ++++++ .../timeline-html/core/timeline-entity.ts | 77 +++ src/components/timeline-html/html-timeline.ts | 443 +++++++++++++++++ .../timeline-html/html-timeline.types.ts | 128 +++++ src/components/timeline-html/index.ts | 24 + .../interaction/interaction-controller.ts | 459 ++++++++++++++++++ .../timeline-html/styles/timeline.css.ts | 395 +++++++++++++++ src/index.ts | 2 + src/main.ts | 42 +- 15 files changed, 2547 insertions(+), 12 deletions(-) create mode 100644 src/components/timeline-html/components/clip/clip-component.ts create mode 100644 src/components/timeline-html/components/playhead/playhead-component.ts create mode 100644 src/components/timeline-html/components/ruler/ruler-component.ts create mode 100644 src/components/timeline-html/components/toolbar/toolbar-component.ts create mode 100644 src/components/timeline-html/components/track/track-component.ts create mode 100644 src/components/timeline-html/components/track/track-list.ts create mode 100644 src/components/timeline-html/core/state/timeline-state.ts create mode 100644 src/components/timeline-html/core/timeline-entity.ts create mode 100644 src/components/timeline-html/html-timeline.ts create mode 100644 src/components/timeline-html/html-timeline.types.ts create mode 100644 src/components/timeline-html/index.ts create mode 100644 src/components/timeline-html/interaction/interaction-controller.ts create mode 100644 src/components/timeline-html/styles/timeline.css.ts diff --git a/src/components/timeline-html/components/clip/clip-component.ts b/src/components/timeline-html/components/clip/clip-component.ts new file mode 100644 index 00000000..4b7c9f3e --- /dev/null +++ b/src/components/timeline-html/components/clip/clip-component.ts @@ -0,0 +1,188 @@ +import { TimelineEntity } from "../../core/timeline-entity"; +import type { ResolvedClip } from "@schemas/clip"; +import type { ClipState, ClipRenderer } from "../../html-timeline.types"; + +export interface ClipComponentOptions { + showBadges: boolean; + onSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void; + getRenderer: (type: string) => ClipRenderer | undefined; +} + +/** Renders a single clip element */ +export class ClipComponent extends TimelineEntity { + private readonly options: ClipComponentOptions; + private currentState: ClipState | null = null; + private needsUpdate = true; + + constructor(clip: ClipState, options: ClipComponentOptions) { + super("div", "ss-clip"); + this.options = options; + this.buildElement(clip); + this.currentState = clip; + this.element.dataset["clipId"] = clip.id; + } + + private buildElement(clip: ClipState): void { + // Content container + const content = document.createElement("div"); + content.className = "ss-clip-content"; + + const label = document.createElement("span"); + label.className = "ss-clip-label"; + content.appendChild(label); + + this.element.appendChild(content); + + // Timing badge + if (this.options.showBadges) { + const badge = document.createElement("div"); + badge.className = "ss-clip-badge"; + this.element.appendChild(badge); + } + + // Resize handles + const leftHandle = document.createElement("div"); + leftHandle.className = "ss-clip-resize-handle left"; + this.element.appendChild(leftHandle); + + const rightHandle = document.createElement("div"); + rightHandle.className = "ss-clip-resize-handle right"; + this.element.appendChild(rightHandle); + + // Set up interaction handlers + this.setupInteraction(clip); + } + + private setupInteraction(clip: ClipState): void { + this.element.addEventListener("pointerdown", e => { + // Check if clicking on resize handle + const target = e.target as HTMLElement; + if (target.classList.contains("ss-clip-resize-handle")) { + // Resize will be handled by InteractionController + return; + } + + // Select clip + const addToSelection = e.shiftKey || e.ctrlKey || e.metaKey; + this.options.onSelect(clip.trackIndex, clip.clipIndex, addToSelection); + }); + } + + public async load(): Promise { + // No async initialization needed + } + + public update(_deltaTime: number, _elapsed: number): void { + // State is updated via updateClip() + } + + public draw(): void { + if (!this.needsUpdate || !this.currentState) return; + this.needsUpdate = false; + + const clip = this.currentState; + const config = clip.config; + const assetType = this.getAssetType(config); + + // Update data attributes + this.element.dataset["assetType"] = assetType; + this.element.dataset["trackIndex"] = String(clip.trackIndex); + this.element.dataset["clipIndex"] = String(clip.clipIndex); + + // Update CSS custom properties for positioning + this.element.style.setProperty("--clip-start", String(config.start)); + this.element.style.setProperty("--clip-length", String(config.length)); + + // Update visual state classes + this.element.classList.toggle("selected", clip.visualState === "selected"); + this.element.classList.toggle("dragging", clip.visualState === "dragging"); + this.element.classList.toggle("resizing", clip.visualState === "resizing"); + + // Update label + const label = this.element.querySelector(".ss-clip-label") as HTMLElement; + if (label) { + label.textContent = this.getClipLabel(config); + } + + // Update timing badge + if (this.options.showBadges) { + const badge = this.element.querySelector(".ss-clip-badge") as HTMLElement; + if (badge) { + this.updateBadge(badge, clip.timingIntent); + } + } + + // Apply custom renderer if available + const renderer = this.options.getRenderer(assetType); + if (renderer) { + renderer.render(config, this.element); + } + } + + public dispose(): void { + // Call dispose on custom renderer if exists + if (this.currentState) { + const assetType = this.getAssetType(this.currentState.config); + const renderer = this.options.getRenderer(assetType); + if (renderer?.dispose) { + renderer.dispose(this.element); + } + } + + this.element.remove(); + } + + /** Update clip state and mark for re-render */ + public updateClip(clip: ClipState): void { + this.currentState = clip; + this.needsUpdate = true; + } + + private updateBadge(badge: HTMLElement, timingIntent: ClipState["timingIntent"]): void { + let icon = ""; + let intent = "fixed"; + let tooltip = ""; + + if (timingIntent.length === "auto") { + icon = "↔"; + intent = "auto"; + tooltip = "Auto length (from asset)"; + } else if (timingIntent.length === "end") { + icon = "→"; + intent = "end"; + tooltip = "Extends to timeline end"; + } + + badge.textContent = icon; + badge.dataset["intent"] = intent; + badge.title = tooltip; + } + + private getAssetType(clip: ResolvedClip): string { + const asset = clip.asset; + if (!asset) return "unknown"; + return asset.type || "unknown"; + } + + private getClipLabel(clip: ResolvedClip): string { + const asset = clip.asset; + if (!asset) return "Clip"; + + // Try to get a meaningful label + if ("src" in asset && typeof asset.src === "string") { + const src = asset.src; + const filename = src.split("/").pop() || src; + return filename.split("?")[0]; + } + + if ("text" in asset && typeof asset.text === "string") { + return asset.text.substring(0, 20) + (asset.text.length > 20 ? "..." : ""); + } + + return asset.type || "Clip"; + } + + public getState(): ClipState | null { + return this.currentState; + } +} diff --git a/src/components/timeline-html/components/playhead/playhead-component.ts b/src/components/timeline-html/components/playhead/playhead-component.ts new file mode 100644 index 00000000..3352aa45 --- /dev/null +++ b/src/components/timeline-html/components/playhead/playhead-component.ts @@ -0,0 +1,122 @@ +import { TimelineEntity } from "../../core/timeline-entity"; + +export interface PlayheadOptions { + onSeek: (timeMs: number) => void; +} + +/** Playhead indicator with drag support */ +export class PlayheadComponent extends TimelineEntity { + private readonly options: PlayheadOptions; + private currentTimeMs = 0; + private pixelsPerSecond = 50; + private isDragging = false; + private containerRect: DOMRect | null = null; + private scrollLeft = 0; + private needsUpdate = true; + + constructor(options: PlayheadOptions) { + super("div", "ss-playhead"); + this.options = options; + this.buildElement(); + } + + private buildElement(): void { + const line = document.createElement("div"); + line.className = "ss-playhead-line"; + this.element.appendChild(line); + + const handle = document.createElement("div"); + handle.className = "ss-playhead-handle"; + this.element.appendChild(handle); + + // Make playhead draggable + this.setupDrag(handle); + } + + private setupDrag(handle: HTMLElement): void { + const onPointerDown = (e: PointerEvent) => { + this.isDragging = true; + handle.setPointerCapture(e.pointerId); + e.preventDefault(); + + // Cache container rect for drag calculation + const container = this.element.parentElement; + if (container) { + this.containerRect = container.getBoundingClientRect(); + this.scrollLeft = container.scrollLeft; + } + }; + + const onPointerMove = (e: PointerEvent) => { + if (!this.isDragging || !this.containerRect) return; + + const x = e.clientX - this.containerRect.left + this.scrollLeft; + const time = Math.max(0, x / this.pixelsPerSecond); + + // Update position immediately for smooth feedback + this.setPosition(time * 1000); + + // Emit seek event + this.options.onSeek(time * 1000); + }; + + const onPointerUp = (e: PointerEvent) => { + if (this.isDragging) { + this.isDragging = false; + handle.releasePointerCapture(e.pointerId); + this.containerRect = null; + } + }; + + handle.addEventListener("pointerdown", onPointerDown); + handle.addEventListener("pointermove", onPointerMove); + handle.addEventListener("pointerup", onPointerUp); + handle.addEventListener("pointercancel", onPointerUp); + } + + public async load(): Promise { + // No async initialization needed + } + + public update(_deltaTime: number, _elapsed: number): void { + // State is updated via setTime() + } + + public draw(): void { + if (!this.needsUpdate) return; + this.needsUpdate = false; + + const timeSec = this.currentTimeMs / 1000; + const x = timeSec * this.pixelsPerSecond; + + this.element.style.setProperty("--playhead-time", String(timeSec)); + this.element.style.left = `${x}px`; + } + + public dispose(): void { + this.element.remove(); + } + + public setPixelsPerSecond(pps: number): void { + this.pixelsPerSecond = pps; + this.needsUpdate = true; + } + + public setTime(timeMs: number): void { + if (this.isDragging) return; // Don't update during drag + + this.currentTimeMs = timeMs; + this.needsUpdate = true; + } + + private setPosition(timeMs: number): void { + this.currentTimeMs = timeMs; + this.needsUpdate = true; + // Immediate draw for responsive drag feedback + this.draw(); + } + + public getTime(): number { + return this.currentTimeMs; + } +} diff --git a/src/components/timeline-html/components/ruler/ruler-component.ts b/src/components/timeline-html/components/ruler/ruler-component.ts new file mode 100644 index 00000000..3982df5f --- /dev/null +++ b/src/components/timeline-html/components/ruler/ruler-component.ts @@ -0,0 +1,109 @@ +import { TimelineEntity } from "../../core/timeline-entity"; + +/** Time ruler component for the timeline */ +export class RulerComponent extends TimelineEntity { + private readonly contentElement: HTMLElement; + private currentPixelsPerSecond = 50; + private currentDuration = 60; + private needsRender = true; + + constructor() { + super("div", "ss-timeline-ruler"); + this.contentElement = this.buildElement(); + } + + private buildElement(): HTMLElement { + const content = document.createElement("div"); + content.className = "ss-ruler-content"; + this.element.appendChild(content); + return content; + } + + public async load(): Promise { + // No async initialization needed + } + + public update(_deltaTime: number, _elapsed: number): void { + // State is updated via update(pps, duration) + } + + public draw(): void { + if (!this.needsRender) return; + this.needsRender = false; + + const pps = this.currentPixelsPerSecond; + const duration = this.currentDuration; + + // Calculate appropriate interval based on zoom level + let interval = 1; // seconds + if (pps < 20) interval = 10; + else if (pps < 40) interval = 5; + else if (pps < 80) interval = 2; + else if (pps > 150) interval = 0.5; + + // Clear existing markers + this.contentElement.innerHTML = ""; + + // Generate markers + for (let t = 0; t <= duration; t += interval) { + const marker = document.createElement("div"); + marker.className = "ss-ruler-marker"; + marker.style.left = `${t * pps}px`; + + const line = document.createElement("div"); + line.className = "ss-ruler-marker-line"; + marker.appendChild(line); + + const label = document.createElement("div"); + label.className = "ss-ruler-marker-label"; + label.textContent = this.formatTime(t); + marker.appendChild(label); + + this.contentElement.appendChild(marker); + + // Add minor markers between major ones + if (interval >= 1) { + const minorInterval = interval / 4; + for (let mt = t + minorInterval; mt < t + interval && mt <= duration; mt += minorInterval) { + const minorMarker = document.createElement("div"); + minorMarker.className = "ss-ruler-marker minor"; + minorMarker.style.left = `${mt * pps}px`; + + const minorLine = document.createElement("div"); + minorLine.className = "ss-ruler-marker-line"; + minorMarker.appendChild(minorLine); + + this.contentElement.appendChild(minorMarker); + } + } + } + } + + /** Update ruler parameters and mark for re-render */ + public updateRuler(pixelsPerSecond: number, duration: number): void { + if (pixelsPerSecond === this.currentPixelsPerSecond && duration === this.currentDuration) { + return; + } + + this.currentPixelsPerSecond = pixelsPerSecond; + this.currentDuration = duration; + this.needsRender = true; + } + + private formatTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + if (mins > 0) { + return `${mins}:${secs.toString().padStart(2, "0")}`; + } + return `${secs}s`; + } + + public syncScroll(scrollX: number): void { + this.contentElement.style.transform = `translateX(${-scrollX}px)`; + } + + public dispose(): void { + this.element.remove(); + } +} diff --git a/src/components/timeline-html/components/toolbar/toolbar-component.ts b/src/components/timeline-html/components/toolbar/toolbar-component.ts new file mode 100644 index 00000000..b1e597b7 --- /dev/null +++ b/src/components/timeline-html/components/toolbar/toolbar-component.ts @@ -0,0 +1,159 @@ +import { TimelineEntity } from "../../core/timeline-entity"; + +export interface ToolbarOptions { + onPlay: () => void; + onPause: () => void; + onZoomChange: (pixelsPerSecond: number) => void; +} + +/** Timeline toolbar with playback controls and zoom */ +export class ToolbarComponent extends TimelineEntity { + private readonly options: ToolbarOptions; + private timeDisplayElement: HTMLElement | null = null; + private playButton: HTMLButtonElement | null = null; + private zoomSlider: HTMLInputElement | null = null; + private isPlaying = false; + private currentTimeMs = 0; + private durationMs = 0; + + constructor(options: ToolbarOptions, initialZoom: number = 50) { + super("div", "ss-timeline-toolbar"); + this.options = options; + this.buildElement(initialZoom); + } + + public async load(): Promise { + // No async initialization needed + } + + public update(_deltaTime: number, _elapsed: number): void { + // State is updated via updatePlayState/updateTimeDisplay + } + + public draw(): void { + // Update play button state + if (this.playButton) { + this.playButton.innerHTML = this.isPlaying ? this.getPauseIcon() : this.getPlayIcon(); + } + + // Update time display + if (this.timeDisplayElement) { + this.timeDisplayElement.textContent = `${this.formatTime(this.currentTimeMs)} / ${this.formatTime(this.durationMs)}`; + } + } + + public dispose(): void { + this.element.remove(); + } + + private buildElement(initialZoom: number): void { + // Left section - playback controls + const leftSection = document.createElement("div"); + leftSection.className = "ss-toolbar-section"; + + this.playButton = this.createButton("play", this.getPlayIcon(), () => { + if (this.isPlaying) { + this.options.onPause(); + } else { + this.options.onPlay(); + } + }); + leftSection.appendChild(this.playButton); + + this.element.appendChild(leftSection); + + // Center section - time display + const centerSection = document.createElement("div"); + centerSection.className = "ss-toolbar-section"; + + this.timeDisplayElement = document.createElement("span"); + this.timeDisplayElement.className = "ss-time-display"; + this.timeDisplayElement.textContent = "00:00.000 / 00:00.000"; + centerSection.appendChild(this.timeDisplayElement); + + this.element.appendChild(centerSection); + + // Right section - zoom controls + const rightSection = document.createElement("div"); + rightSection.className = "ss-toolbar-section"; + + const zoomOutBtn = this.createButton("zoom-out", this.getZoomOutIcon(), () => { + const current = parseInt(this.zoomSlider?.value || "50", 10); + const newZoom = Math.max(10, current / 1.2); + this.setZoom(newZoom); + this.options.onZoomChange(newZoom); + }); + rightSection.appendChild(zoomOutBtn); + + this.zoomSlider = document.createElement("input"); + this.zoomSlider.type = "range"; + this.zoomSlider.className = "ss-zoom-slider"; + this.zoomSlider.min = "10"; + this.zoomSlider.max = "200"; + this.zoomSlider.value = String(initialZoom); + this.zoomSlider.addEventListener("input", () => { + const value = parseInt(this.zoomSlider?.value || "50", 10); + this.options.onZoomChange(value); + }); + rightSection.appendChild(this.zoomSlider); + + const zoomInBtn = this.createButton("zoom-in", this.getZoomInIcon(), () => { + const current = parseInt(this.zoomSlider?.value || "50", 10); + const newZoom = Math.min(200, current * 1.2); + this.setZoom(newZoom); + this.options.onZoomChange(newZoom); + }); + rightSection.appendChild(zoomInBtn); + + this.element.appendChild(rightSection); + } + + private createButton(name: string, icon: string, onClick: () => void): HTMLButtonElement { + const btn = document.createElement("button"); + btn.className = "ss-toolbar-btn"; + btn.dataset["action"] = name; + btn.innerHTML = icon; + btn.addEventListener("click", onClick); + return btn; + } + + public updatePlayState(isPlaying: boolean): void { + this.isPlaying = isPlaying; + } + + public updateTimeDisplay(currentTimeMs: number, durationMs: number): void { + this.currentTimeMs = currentTimeMs; + this.durationMs = durationMs; + } + + public setZoom(pixelsPerSecond: number): void { + if (this.zoomSlider) { + this.zoomSlider.value = String(Math.round(pixelsPerSecond)); + } + } + + private formatTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const milliseconds = Math.floor(ms % 1000); + return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${milliseconds.toString().padStart(3, "0")}`; + } + + // Icon SVGs + private getPlayIcon(): string { + return ``; + } + + private getPauseIcon(): string { + return ``; + } + + private getZoomInIcon(): string { + return ``; + } + + private getZoomOutIcon(): string { + return ``; + } +} diff --git a/src/components/timeline-html/components/track/track-component.ts b/src/components/timeline-html/components/track/track-component.ts new file mode 100644 index 00000000..746d00fe --- /dev/null +++ b/src/components/timeline-html/components/track/track-component.ts @@ -0,0 +1,121 @@ +import { TimelineEntity } from "../../core/timeline-entity"; +import type { TrackState, ClipState, ClipRenderer } from "../../html-timeline.types"; +import { ClipComponent } from "../clip/clip-component"; + +export interface TrackComponentOptions { + showBadges: boolean; + onClipSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void; + getClipRenderer: (type: string) => ClipRenderer | undefined; +} + +/** Renders a single track with its clips */ +export class TrackComponent extends TimelineEntity { + private readonly clipComponents = new Map(); + private readonly options: TrackComponentOptions; + private trackIndex: number; + + // Current state for draw + private currentTrack: TrackState | null = null; + private currentPixelsPerSecond = 50; + private needsUpdate = true; + + constructor(trackIndex: number, options: TrackComponentOptions) { + super("div", "ss-track"); + this.trackIndex = trackIndex; + this.options = options; + this.element.dataset["trackIndex"] = String(trackIndex); + } + + public async load(): Promise { + await this.loadChildren(); + } + + public update(_deltaTime: number, _elapsed: number): void { + this.updateChildren(_deltaTime, _elapsed); + } + + public draw(): void { + if (!this.needsUpdate || !this.currentTrack) { + // Still need to draw clip components even when data hasn't changed + for (const clipComponent of this.clipComponents.values()) { + clipComponent.draw(); + } + this.drawChildren(); + return; + } + this.needsUpdate = false; + + const track = this.currentTrack; + this.trackIndex = track.index; + this.element.dataset["trackIndex"] = String(track.index); + + const processedIds = new Set(); + + // Update or create clips + for (const clipState of track.clips) { + processedIds.add(clipState.id); + + let clipComponent = this.clipComponents.get(clipState.id); + if (!clipComponent) { + clipComponent = new ClipComponent(clipState, { + showBadges: this.options.showBadges, + onSelect: this.options.onClipSelect, + getRenderer: this.options.getClipRenderer + }); + this.clipComponents.set(clipState.id, clipComponent); + this.element.appendChild(clipComponent.element); + } + + clipComponent.updateClip(clipState); + } + + // Remove clips that no longer exist + for (const [id, component] of this.clipComponents) { + if (!processedIds.has(id)) { + component.dispose(); + this.clipComponents.delete(id); + } + } + + // Draw all clip components (they're not in children array) + for (const clipComponent of this.clipComponents.values()) { + clipComponent.draw(); + } + + this.drawChildren(); + } + + public dispose(): void { + for (const component of this.clipComponents.values()) { + component.dispose(); + } + this.clipComponents.clear(); + this.element.remove(); + } + + /** Update track state and mark for re-render */ + public updateTrack(track: TrackState, pixelsPerSecond: number): void { + this.currentTrack = track; + this.currentPixelsPerSecond = pixelsPerSecond; + this.needsUpdate = true; + } + + public getClipComponent(clipId: string): ClipComponent | undefined { + return this.clipComponents.get(clipId); + } + + public getClipAtPosition(x: number, pixelsPerSecond: number): ClipState | null { + for (const component of this.clipComponents.values()) { + const state = component.getState(); + if (!state) continue; + + const clipStart = state.config.start * pixelsPerSecond; + const clipEnd = (state.config.start + state.config.length) * pixelsPerSecond; + + if (x >= clipStart && x <= clipEnd) { + return state; + } + } + return null; + } +} diff --git a/src/components/timeline-html/components/track/track-list.ts b/src/components/timeline-html/components/track/track-list.ts new file mode 100644 index 00000000..c3424825 --- /dev/null +++ b/src/components/timeline-html/components/track/track-list.ts @@ -0,0 +1,148 @@ +import { TimelineEntity } from "../../core/timeline-entity"; +import type { TrackState, ClipState, ClipRenderer } from "../../html-timeline.types"; +import { TrackComponent } from "./track-component"; + +export interface TrackListOptions { + showBadges: boolean; + onClipSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void; + getClipRenderer: (type: string) => ClipRenderer | undefined; +} + +/** Container for all track components with virtualization support */ +export class TrackListComponent extends TimelineEntity { + public readonly contentElement: HTMLElement; + private readonly trackComponents: TrackComponent[] = []; + private readonly options: TrackListOptions; + + // Current state for draw + private currentTracks: TrackState[] = []; + private currentTimelineWidth = 0; + private currentPixelsPerSecond = 50; + private needsUpdate = true; + + // Scroll sync callback + private onScroll?: (scrollX: number, scrollY: number) => void; + + constructor(options: TrackListOptions) { + super("div", "ss-timeline-tracks"); + this.options = options; + this.contentElement = this.buildElement(); + } + + private buildElement(): HTMLElement { + this.element.tabIndex = 0; // Make focusable for keyboard events + + const content = document.createElement("div"); + content.className = "ss-tracks-content"; + this.element.appendChild(content); + + // Set up scroll event + this.element.addEventListener("scroll", () => { + this.onScroll?.(this.element.scrollLeft, this.element.scrollTop); + }); + + return content; + } + + public async load(): Promise { + await this.loadChildren(); + } + + public update(_deltaTime: number, _elapsed: number): void { + this.updateChildren(_deltaTime, _elapsed); + } + + public draw(): void { + if (!this.needsUpdate) { + // Still need to draw track components even when data hasn't changed + for (const trackComponent of this.trackComponents) { + trackComponent.draw(); + } + this.drawChildren(); + return; + } + this.needsUpdate = false; + + const tracks = this.currentTracks; + const pixelsPerSecond = this.currentPixelsPerSecond; + + // Set content width for scrolling + this.contentElement.style.width = `${this.currentTimelineWidth}px`; + + // Add/remove track components as needed + while (this.trackComponents.length < tracks.length) { + const trackIndex = this.trackComponents.length; + const trackComponent = new TrackComponent(trackIndex, { + showBadges: this.options.showBadges, + onClipSelect: this.options.onClipSelect, + getClipRenderer: this.options.getClipRenderer + }); + this.trackComponents.push(trackComponent); + this.contentElement.appendChild(trackComponent.element); + } + + while (this.trackComponents.length > tracks.length) { + const trackComponent = this.trackComponents.pop(); + trackComponent?.dispose(); + } + + // Update each track and draw (tracks are not in children array) + tracks.forEach((track, index) => { + this.trackComponents[index].updateTrack(track, pixelsPerSecond); + this.trackComponents[index].draw(); + }); + + this.drawChildren(); + } + + public dispose(): void { + for (const track of this.trackComponents) { + track.dispose(); + } + this.trackComponents.length = 0; + this.element.remove(); + } + + public setScrollHandler(handler: (scrollX: number, scrollY: number) => void): void { + this.onScroll = handler; + } + + /** Update track list state and mark for re-render */ + public updateTracks(tracks: TrackState[], timelineWidth: number, pixelsPerSecond: number): void { + this.currentTracks = tracks; + this.currentTimelineWidth = timelineWidth; + this.currentPixelsPerSecond = pixelsPerSecond; + this.needsUpdate = true; + } + + public getTrackComponent(trackIndex: number): TrackComponent | undefined { + return this.trackComponents[trackIndex]; + } + + public findClipAtPosition(x: number, y: number, trackHeight: number, pixelsPerSecond: number): ClipState | null { + const scrollY = this.element.scrollTop; + const relativeY = y + scrollY; + const trackIndex = Math.floor(relativeY / trackHeight); + + if (trackIndex < 0 || trackIndex >= this.trackComponents.length) { + return null; + } + + const scrollX = this.element.scrollLeft; + const relativeX = x + scrollX; + + return this.trackComponents[trackIndex].getClipAtPosition(relativeX, pixelsPerSecond); + } + + public getScrollPosition(): { scrollX: number; scrollY: number } { + return { + scrollX: this.element.scrollLeft, + scrollY: this.element.scrollTop + }; + } + + public setScrollPosition(scrollX: number, scrollY: number): void { + this.element.scrollLeft = scrollX; + this.element.scrollTop = scrollY; + } +} diff --git a/src/components/timeline-html/core/state/timeline-state.ts b/src/components/timeline-html/core/state/timeline-state.ts new file mode 100644 index 00000000..759da95f --- /dev/null +++ b/src/components/timeline-html/core/state/timeline-state.ts @@ -0,0 +1,142 @@ +import type { Edit } from "@core/edit"; +import type { ResolvedClip } from "@schemas/clip"; +import type { ResolvedTrack } from "@schemas/track"; +import type { TrackState, ClipState, ViewportState, PlaybackState } from "../../html-timeline.types"; + +type ClipVisualState = "normal" | "selected" | "dragging" | "resizing"; + +/** Simplified state manager - only holds UI state, derives data from Edit */ +export class TimelineStateManager { + // UI-only state (not in Edit) + private viewport: ViewportState; + private clipVisualStates = new Map(); + + constructor( + private readonly edit: Edit, + initialViewport: Partial = {} + ) { + this.viewport = { + scrollX: 0, + scrollY: 0, + pixelsPerSecond: initialViewport.pixelsPerSecond ?? 50, + width: initialViewport.width ?? 800, + height: initialViewport.height ?? 400 + }; + } + + // ========== Derived from Edit (no caching) ========== + + public getTracks(): TrackState[] { + const resolvedEdit = this.edit.getResolvedEdit(); + if (!resolvedEdit?.timeline?.tracks) return []; + + return resolvedEdit.timeline.tracks.map((track: ResolvedTrack, trackIndex: number) => ({ + index: trackIndex, + clips: (track.clips || []).map((clip: ResolvedClip, clipIndex: number) => + this.createClipState(clip, trackIndex, clipIndex) + ) + })); + } + + public getPlayback(): PlaybackState { + return { + time: this.edit.playbackTime, + isPlaying: this.edit.isPlaying, + duration: this.edit.totalDuration + }; + } + + public getClipAt(trackIndex: number, clipIndex: number): ClipState | undefined { + const tracks = this.getTracks(); + return tracks[trackIndex]?.clips.find(c => c.clipIndex === clipIndex); + } + + // ========== UI State (local only) ========== + + public getViewport(): ViewportState { + return this.viewport; + } + + public setViewport(updates: Partial): void { + this.viewport = { ...this.viewport, ...updates }; + } + + public setPixelsPerSecond(pps: number): void { + this.viewport.pixelsPerSecond = Math.max(10, Math.min(200, pps)); + } + + public setScroll(scrollX: number, scrollY: number): void { + this.viewport.scrollX = scrollX; + this.viewport.scrollY = scrollY; + } + + public setClipVisualState(trackIndex: number, clipIndex: number, state: ClipVisualState): void { + this.clipVisualStates.set(`${trackIndex}-${clipIndex}`, state); + } + + public getClipVisualState(trackIndex: number, clipIndex: number): ClipVisualState { + return this.clipVisualStates.get(`${trackIndex}-${clipIndex}`) ?? "normal"; + } + + public clearVisualStates(): void { + this.clipVisualStates.clear(); + } + + // ========== Selection (delegate to Edit) ========== + + public selectClip(trackIndex: number, clipIndex: number, _addToSelection: boolean): void { + // Delegate to Edit - it owns selection state + this.edit.selectClip(trackIndex, clipIndex); + } + + public clearSelection(): void { + this.edit.clearSelection(); + } + + public isClipSelected(trackIndex: number, clipIndex: number): boolean { + return this.edit.isClipSelected(trackIndex, clipIndex); + } + + // ========== Utilities ========== + + public getTimelineDuration(): number { + return this.edit.totalDuration / 1000; + } + + public getExtendedDuration(): number { + return Math.max(60, this.getTimelineDuration() * 1.5); + } + + public getTimelineWidth(): number { + return Math.max(this.getExtendedDuration() * this.viewport.pixelsPerSecond, this.viewport.width); + } + + public dispose(): void { + this.clipVisualStates.clear(); + } + + // ========== Private ========== + + private createClipState(clip: ResolvedClip, trackIndex: number, clipIndex: number): ClipState { + const unresolvedEdit = this.edit.getEdit(); + const unresolvedClip = unresolvedEdit?.timeline?.tracks?.[trackIndex]?.clips?.[clipIndex]; + + const isSelected = this.edit.isClipSelected(trackIndex, clipIndex); + const visualState = this.clipVisualStates.get(`${trackIndex}-${clipIndex}`) ?? (isSelected ? "selected" : "normal"); + + return { + id: `${trackIndex}-${clipIndex}`, + trackIndex, + clipIndex, + config: clip, + visualState, + timingIntent: { + start: unresolvedClip?.start === "auto" ? "auto" : clip.start, + length: + unresolvedClip?.length === "auto" || unresolvedClip?.length === "end" + ? unresolvedClip.length + : clip.length + } + }; + } +} diff --git a/src/components/timeline-html/core/timeline-entity.ts b/src/components/timeline-html/core/timeline-entity.ts new file mode 100644 index 00000000..1745d161 --- /dev/null +++ b/src/components/timeline-html/core/timeline-entity.ts @@ -0,0 +1,77 @@ +/** + * Base class for HTML-based timeline components. + * Mirrors the Entity pattern used by PixiJS components (load/update/draw/dispose lifecycle). + */ +export abstract class TimelineEntity { + public readonly element: HTMLElement; + protected children: TimelineEntity[] = []; + + constructor(tagName: keyof HTMLElementTagNameMap = "div", className?: string) { + this.element = document.createElement(tagName); + if (className) { + this.element.className = className; + } + } + + /** Initialize the component and its children */ + public abstract load(): Promise; + + /** Update component state (called each frame during active rendering) */ + public abstract update(deltaTime: number, elapsed: number): void; + + /** Render/draw component to DOM (called each frame after update) */ + public abstract draw(): void; + + /** Clean up resources and remove from DOM */ + public abstract dispose(): void; + + /** Add a child entity */ + protected addChild(child: TimelineEntity): void { + this.children.push(child); + this.element.appendChild(child.element); + } + + /** Remove a child entity */ + protected removeChild(child: TimelineEntity): void { + const index = this.children.indexOf(child); + if (index !== -1) { + this.children.splice(index, 1); + child.dispose(); + } + } + + /** Remove all children */ + protected removeAllChildren(): void { + for (const child of this.children) { + child.dispose(); + } + this.children = []; + } + + /** Load all children */ + protected async loadChildren(): Promise { + await Promise.all(this.children.map(child => child.load())); + } + + /** Update all children */ + protected updateChildren(deltaTime: number, elapsed: number): void { + for (const child of this.children) { + child.update(deltaTime, elapsed); + } + } + + /** Draw all children */ + protected drawChildren(): void { + for (const child of this.children) { + child.draw(); + } + } + + /** Dispose all children */ + protected disposeChildren(): void { + for (const child of this.children) { + child.dispose(); + } + this.children = []; + } +} diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts new file mode 100644 index 00000000..fd151fdc --- /dev/null +++ b/src/components/timeline-html/html-timeline.ts @@ -0,0 +1,443 @@ +import type { Edit } from "@core/edit"; + +import { TimelineEntity } from "./core/timeline-entity"; +import { TimelineStateManager } from "./core/state/timeline-state"; +import { TrackListComponent } from "./components/track/track-list"; +import { RulerComponent } from "./components/ruler/ruler-component"; +import { PlayheadComponent } from "./components/playhead/playhead-component"; +import { ToolbarComponent } from "./components/toolbar/toolbar-component"; +import { InteractionController } from "./interaction/interaction-controller"; +import { getTimelineStyles } from "./styles/timeline.css"; +import type { + HtmlTimelineOptions, + HtmlTimelineFeatures, + ClipRenderer, + ClipInfo +} from "./html-timeline.types"; + +/** HTML/CSS-based Timeline component extending TimelineEntity for SDK consistency */ +export class HtmlTimeline extends TimelineEntity { + private readonly container: HTMLElement; + private readonly stateManager: TimelineStateManager; + + // Feature flags + private features: Required; + + // Custom renderers + private clipRenderers = new Map(); + + // Components (stored separately from children for typed access) + private toolbar: ToolbarComponent | null = null; + private ruler: RulerComponent | null = null; + private trackList: TrackListComponent | null = null; + private playhead: PlayheadComponent | null = null; + private feedbackLayer: HTMLElement | null = null; + private interactionController: InteractionController | null = null; + + // Style element for scoped CSS + private styleElement: HTMLStyleElement | null = null; + + // Hybrid render loop state + private animationFrameId: number | null = null; + private isRenderLoopActive = false; + private lastFrameTime = 0; + private isInteracting = false; + private isLoaded = false; + + // Bound event handlers for cleanup + private readonly handleTimelineUpdated: () => void; + private readonly handlePlaybackPlay: () => void; + private readonly handlePlaybackPause: () => void; + private readonly handlePlaybackStop: () => void; + + constructor( + private readonly edit: Edit, + container: HTMLElement, + options: HtmlTimelineOptions = {} + ) { + super("div", "ss-html-timeline"); + + this.container = container; + + // Merge default features with provided options + this.features = { + toolbar: options.features?.toolbar ?? true, + ruler: options.features?.ruler ?? true, + playhead: options.features?.playhead ?? true, + snap: options.features?.snap ?? true, + badges: options.features?.badges ?? true, + multiSelect: options.features?.multiSelect ?? true + }; + + // Configure root element to fill container + this.element.style.width = "100%"; + this.element.style.height = "100%"; + + // Create state manager with placeholder size (will be updated in load()) + this.stateManager = new TimelineStateManager(edit, { + width: 800, // placeholder, updated in load() + height: 300, // placeholder, updated in load() + pixelsPerSecond: options.pixelsPerSecond ?? 50 + }); + + // Bind event handlers + this.handleTimelineUpdated = () => this.requestRender(); + this.handlePlaybackPlay = () => this.startRenderLoop(); + this.handlePlaybackPause = () => this.stopRenderLoop(); + this.handlePlaybackStop = () => this.stopRenderLoop(); + } + + /** Initialize and mount the timeline */ + public async load(): Promise { + if (this.isLoaded) return; + + // Inject styles + this.injectStyles(); + + // Mount to container first so we can measure + this.container.appendChild(this.element); + + // Get actual size from container + const rect = this.container.getBoundingClientRect(); + const width = rect.width || 800; + const height = rect.height || 300; + this.stateManager.setViewport({ width, height }); + + // Build component structure + this.buildComponents(); + + // Set up event listeners for hybrid render loop + this.setupEventListeners(); + + // Initial render (data is derived from Edit on-demand) + this.update(0, performance.now()); + this.draw(); + + this.isLoaded = true; + } + + /** Update component state (called each frame during active rendering) */ + public update(_deltaTime: number, _elapsed: number): void { + // State manager already syncs with Edit via events + // This method is here for TimelineEntity conformance + // Children that extend TimelineEntity will be updated via updateChildren() + } + + /** Render/draw component to DOM (called each frame after update) */ + public draw(): void { + // Derive state from Edit on-demand (single source of truth) + const viewport = this.stateManager.getViewport(); + const playback = this.stateManager.getPlayback(); + const tracks = this.stateManager.getTracks(); + + // Update CSS variable for clip/playhead positioning + this.element.style.setProperty("--ss-timeline-pixels-per-second", String(viewport.pixelsPerSecond)); + + // Update toolbar + this.toolbar?.updatePlayState(playback.isPlaying); + this.toolbar?.updateTimeDisplay(playback.time, playback.duration); + + // Update ruler and draw + this.ruler?.updateRuler(viewport.pixelsPerSecond, this.stateManager.getExtendedDuration()); + this.ruler?.draw(); + + // Update tracks and draw + this.trackList?.updateTracks(tracks, this.stateManager.getTimelineWidth(), viewport.pixelsPerSecond); + this.trackList?.draw(); + + // Update playhead + this.playhead?.setTime(playback.time); + } + + /** Clean up and unmount the timeline */ + public dispose(): void { + // Stop animation loop + this.stopRenderLoop(); + + // Remove event listeners + this.removeEventListeners(); + + // Dispose state manager + this.stateManager.dispose(); + + // Dispose components + this.disposeComponents(); + + // Clean up custom renderers + this.clipRenderers.clear(); + + // Remove DOM + this.element.remove(); + + // Remove styles + if (this.styleElement) { + this.styleElement.remove(); + this.styleElement = null; + } + + this.isLoaded = false; + } + + // ========== Hybrid Render Loop ========== + + private setupEventListeners(): void { + // Listen for timeline data changes (single render when idle) + this.edit.events.on("timeline:updated", this.handleTimelineUpdated); + + // Listen for playback state changes (start/stop render loop) + this.edit.events.on("playback:play", this.handlePlaybackPlay); + this.edit.events.on("playback:pause", this.handlePlaybackPause); + this.edit.events.on("playback:stop", this.handlePlaybackStop); + } + + private removeEventListeners(): void { + this.edit.events.off("timeline:updated", this.handleTimelineUpdated); + this.edit.events.off("playback:play", this.handlePlaybackPlay); + this.edit.events.off("playback:pause", this.handlePlaybackPause); + this.edit.events.off("playback:stop", this.handlePlaybackStop); + } + + /** Start continuous render loop (during playback or interaction) */ + private startRenderLoop(): void { + if (this.isRenderLoopActive) return; + this.isRenderLoopActive = true; + this.lastFrameTime = performance.now(); + this.tick(); + } + + /** Stop continuous render loop */ + private stopRenderLoop(): void { + this.isRenderLoopActive = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + } + + /** Animation frame callback */ + private tick = (): void => { + if (!this.isRenderLoopActive) return; + + const now = performance.now(); + const deltaTime = now - this.lastFrameTime; + this.lastFrameTime = now; + + this.update(deltaTime, now); + this.draw(); + + // Continue loop if playing or interacting + if (this.edit.isPlaying || this.isInteracting) { + this.animationFrameId = requestAnimationFrame(this.tick); + } else { + this.isRenderLoopActive = false; + this.animationFrameId = null; + } + }; + + /** Request a single render (used when idle and data changes) */ + private requestRender(): void { + if (this.isRenderLoopActive) return; // Loop already running + this.update(0, performance.now()); + this.draw(); + } + + /** Mark interaction as started (enables render loop) */ + public beginInteraction(): void { + this.isInteracting = true; + this.startRenderLoop(); + } + + /** Mark interaction as ended (may stop render loop) */ + public endInteraction(): void { + this.isInteracting = false; + // Loop will stop on next tick if not playing + } + + // ========== Component Building ========== + + private injectStyles(): void { + this.styleElement = document.createElement("style"); + this.styleElement.textContent = getTimelineStyles(); + document.head.appendChild(this.styleElement); + } + + private buildComponents(): void { + // Clear existing content + this.element.innerHTML = ""; + + const viewport = this.stateManager.getViewport(); + + // Build toolbar + if (this.features.toolbar) { + this.toolbar = new ToolbarComponent( + { + onPlay: () => this.edit.play(), + onPause: () => this.edit.pause(), + onZoomChange: pps => this.setZoom(pps) + }, + viewport.pixelsPerSecond + ); + this.element.appendChild(this.toolbar.element); + } + + // Build ruler + if (this.features.ruler) { + this.ruler = new RulerComponent(); + this.element.appendChild(this.ruler.element); + } + + // Build track list + this.trackList = new TrackListComponent({ + showBadges: this.features.badges, + onClipSelect: (trackIndex, clipIndex, addToSelection) => { + if (this.features.multiSelect && addToSelection) { + this.stateManager.selectClip(trackIndex, clipIndex, true); + } else { + this.stateManager.selectClip(trackIndex, clipIndex, false); + } + this.edit.selectClip(trackIndex, clipIndex); + }, + getClipRenderer: type => this.clipRenderers.get(type) + }); + + // Set up scroll sync + this.trackList.setScrollHandler((scrollX, scrollY) => { + this.stateManager.setScroll(scrollX, scrollY); + this.ruler?.syncScroll(scrollX); + }); + + this.element.appendChild(this.trackList.element); + + // Build playhead + if (this.features.playhead) { + this.playhead = new PlayheadComponent({ + onSeek: timeMs => this.edit.seek(timeMs) + }); + this.playhead.setPixelsPerSecond(viewport.pixelsPerSecond); + this.trackList.contentElement.appendChild(this.playhead.element); + } + + // Build feedback layer + this.feedbackLayer = document.createElement("div"); + this.feedbackLayer.className = "ss-feedback-layer"; + this.element.appendChild(this.feedbackLayer); + + // Initialize interaction controller + this.interactionController = new InteractionController( + this.edit, + this.stateManager, + this.trackList.element, + this.feedbackLayer, + { snapThreshold: this.features.snap ? 10 : 0 } + ); + } + + private disposeComponents(): void { + this.interactionController?.dispose(); + this.interactionController = null; + + this.toolbar?.dispose(); + this.toolbar = null; + + this.ruler?.dispose(); + this.ruler = null; + + this.playhead?.dispose(); + this.playhead = null; + + this.trackList?.dispose(); + this.trackList = null; + + this.feedbackLayer?.remove(); + this.feedbackLayer = null; + } + + // ========== Public API ========== + + public setZoom(pixelsPerSecond: number): void { + this.stateManager.setPixelsPerSecond(pixelsPerSecond); + this.toolbar?.setZoom(pixelsPerSecond); + this.playhead?.setPixelsPerSecond(pixelsPerSecond); + this.requestRender(); + } + + public zoomIn(): void { + const current = this.stateManager.getViewport().pixelsPerSecond; + this.setZoom(Math.min(200, current * 1.2)); + } + + public zoomOut(): void { + const current = this.stateManager.getViewport().pixelsPerSecond; + this.setZoom(Math.max(10, current / 1.2)); + } + + public scrollTo(time: number): void { + if (!this.trackList) return; + + const pps = this.stateManager.getViewport().pixelsPerSecond; + this.trackList.setScrollPosition(time * pps, 0); + } + + /** Recalculate size from container and re-render */ + public resize(): void { + const rect = this.container.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + this.stateManager.setViewport({ width: rect.width, height: rect.height }); + this.requestRender(); + } + + public selectClip(trackIndex: number, clipIndex: number): void { + this.stateManager.selectClip(trackIndex, clipIndex, false); + this.edit.selectClip(trackIndex, clipIndex); + } + + public clearSelection(): void { + this.stateManager.clearSelection(); + this.edit.clearSelection(); + } + + public enableFeature(feature: keyof HtmlTimelineFeatures): void { + this.features[feature] = true; + this.disposeComponents(); + this.buildComponents(); + this.requestRender(); + } + + public disableFeature(feature: keyof HtmlTimelineFeatures): void { + this.features[feature] = false; + this.disposeComponents(); + this.buildComponents(); + this.requestRender(); + } + + public registerClipRenderer(type: string, renderer: ClipRenderer): void { + this.clipRenderers.set(type, renderer); + } + + public getEdit(): Edit { + return this.edit; + } + + public findClipAtPosition(x: number, y: number): ClipInfo | null { + if (!this.trackList) return null; + + const rect = this.trackList.element.getBoundingClientRect(); + const relativeX = x - rect.left; + const relativeY = y - rect.top; + const viewport = this.stateManager.getViewport(); + const trackHeight = 64; // TODO: get from theme + + const clipState = this.trackList.findClipAtPosition(relativeX, relativeY, trackHeight, viewport.pixelsPerSecond); + + if (clipState) { + return { + trackIndex: clipState.trackIndex, + clipIndex: clipState.clipIndex, + config: clipState.config + }; + } + + return null; + } +} diff --git a/src/components/timeline-html/html-timeline.types.ts b/src/components/timeline-html/html-timeline.types.ts new file mode 100644 index 00000000..825e2882 --- /dev/null +++ b/src/components/timeline-html/html-timeline.types.ts @@ -0,0 +1,128 @@ +import type { ResolvedClip } from "@schemas/clip"; + +/** Configuration options for HtmlTimeline */ +export interface HtmlTimelineOptions { + /** Feature toggles */ + features?: HtmlTimelineFeatures; + /** Interaction configuration */ + interaction?: HtmlTimelineInteractionConfig; + /** Initial pixels per second (zoom level) */ + pixelsPerSecond?: number; + /** Track height in pixels */ + trackHeight?: number; +} + +/** Feature toggles for HtmlTimeline */ +export interface HtmlTimelineFeatures { + /** Show toolbar with playback controls */ + toolbar?: boolean; + /** Show time ruler */ + ruler?: boolean; + /** Show playhead indicator */ + playhead?: boolean; + /** Enable snap-to-grid/clips */ + snap?: boolean; + /** Show timing intent badges on clips */ + badges?: boolean; + /** Enable multi-select with shift/ctrl+click */ + multiSelect?: boolean; +} + +/** Interaction configuration */ +export interface HtmlTimelineInteractionConfig { + /** Minimum pixels to move before starting drag */ + dragThreshold?: number; + /** Snap distance in pixels */ + snapThreshold?: number; + /** Width of resize zone at clip edges */ + resizeZone?: number; +} + +/** Internal state for a clip */ +export interface ClipState { + /** Unique identifier for keyed updates */ + id: string; + /** Track index */ + trackIndex: number; + /** Clip index within track */ + clipIndex: number; + /** Resolved clip configuration */ + config: ResolvedClip; + /** Visual state */ + visualState: "normal" | "selected" | "dragging" | "resizing"; + /** Original timing intent before resolution */ + timingIntent: { + start: "auto" | number; + length: "auto" | "end" | number; + }; +} + +/** Internal state for a track */ +export interface TrackState { + /** Track index */ + index: number; + /** Clips in this track */ + clips: ClipState[]; +} + +/** Viewport state */ +export interface ViewportState { + /** Horizontal scroll position in pixels */ + scrollX: number; + /** Vertical scroll position in pixels */ + scrollY: number; + /** Zoom level (pixels per second) */ + pixelsPerSecond: number; + /** Viewport width */ + width: number; + /** Viewport height */ + height: number; +} + +/** Playback state */ +export interface PlaybackState { + /** Current playback time in milliseconds */ + time: number; + /** Whether playback is active */ + isPlaying: boolean; + /** Total timeline duration in milliseconds */ + duration: number; +} + +/** Clip info for interactions */ +export interface ClipInfo { + trackIndex: number; + clipIndex: number; + config: ResolvedClip; +} + +/** Custom clip renderer interface */ +export interface ClipRenderer { + /** Render custom content inside clip element */ + render(clip: ResolvedClip, element: HTMLElement): void; + /** Optional cleanup when clip is removed */ + dispose?(element: HTMLElement): void; +} + +/** Default feature settings */ +export const DEFAULT_FEATURES: Required = { + toolbar: true, + ruler: true, + playhead: true, + snap: true, + badges: true, + multiSelect: true +}; + +/** Default interaction settings */ +export const DEFAULT_INTERACTION: Required = { + dragThreshold: 3, + snapThreshold: 10, + resizeZone: 12 +}; + +/** Default timeline settings */ +export const DEFAULT_PIXELS_PER_SECOND = 50; +export const DEFAULT_TRACK_HEIGHT = 64; +export const DEFAULT_TOOLBAR_HEIGHT = 40; +export const DEFAULT_RULER_HEIGHT = 32; diff --git a/src/components/timeline-html/index.ts b/src/components/timeline-html/index.ts new file mode 100644 index 00000000..5d0c6144 --- /dev/null +++ b/src/components/timeline-html/index.ts @@ -0,0 +1,24 @@ +/** HTML/CSS Timeline Component */ + +export { HtmlTimeline } from "./html-timeline"; + +export type { + HtmlTimelineOptions, + HtmlTimelineFeatures, + HtmlTimelineInteractionConfig, + ClipState, + TrackState, + ViewportState, + PlaybackState, + ClipInfo, + ClipRenderer +} from "./html-timeline.types"; + +export { + DEFAULT_FEATURES, + DEFAULT_INTERACTION, + DEFAULT_PIXELS_PER_SECOND, + DEFAULT_TRACK_HEIGHT, + DEFAULT_TOOLBAR_HEIGHT, + DEFAULT_RULER_HEIGHT +} from "./html-timeline.types"; diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts new file mode 100644 index 00000000..a3de771f --- /dev/null +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -0,0 +1,459 @@ +import type { Edit } from "@core/edit"; +import type { ClipState, HtmlTimelineInteractionConfig } from "../html-timeline.types"; +import { TimelineStateManager } from "../core/state/timeline-state"; +import { MoveClipCommand } from "@core/commands/move-clip-command"; +import { ResizeClipCommand } from "@core/commands/resize-clip-command"; + +/** Point coordinates */ +interface Point { + x: number; + y: number; +} + +/** Clip reference */ +interface ClipRef { + trackIndex: number; + clipIndex: number; +} + +/** Snap point for alignment */ +interface SnapPoint { + time: number; + type: "clip-start" | "clip-end" | "playhead"; +} + +/** Interaction state machine */ +type InteractionState = + | { type: "idle" } + | { type: "pending"; startPoint: Point; clipRef: ClipRef; originalTime: number } + | { type: "dragging"; clipRef: ClipRef; ghost: HTMLElement; startTime: number; originalTrack: number } + | { type: "resizing"; clipRef: ClipRef; edge: "left" | "right"; originalStart: number; originalLength: number }; + +/** Configuration defaults */ +const DEFAULT_CONFIG: Required = { + dragThreshold: 3, + snapThreshold: 10, + resizeZone: 12 +}; + +/** Controller for timeline interactions (drag, resize, selection) */ +export class InteractionController { + private state: InteractionState = { type: "idle" }; + private readonly config: Required; + private snapPoints: SnapPoint[] = []; + + // DOM references + private readonly feedbackLayer: HTMLElement; + private snapLine: HTMLElement | null = null; + private dragGhost: HTMLElement | null = null; + + // Bound handlers for cleanup + private readonly handlePointerMove: (e: PointerEvent) => void; + private readonly handlePointerUp: (e: PointerEvent) => void; + + constructor( + private readonly edit: Edit, + private readonly stateManager: TimelineStateManager, + private readonly tracksContainer: HTMLElement, + feedbackLayer: HTMLElement, + config?: Partial + ) { + this.feedbackLayer = feedbackLayer; + this.config = { ...DEFAULT_CONFIG, ...config }; + + // Bind handlers + this.handlePointerMove = this.onPointerMove.bind(this); + this.handlePointerUp = this.onPointerUp.bind(this); + + this.setupEventListeners(); + } + + private setupEventListeners(): void { + this.tracksContainer.addEventListener("pointerdown", this.onPointerDown.bind(this)); + document.addEventListener("pointermove", this.handlePointerMove); + document.addEventListener("pointerup", this.handlePointerUp); + } + + private onPointerDown(e: PointerEvent): void { + const target = e.target as HTMLElement; + + // Find clip element + const clipEl = target.closest(".ss-clip") as HTMLElement; + if (!clipEl) { + // Click on empty space - clear selection + this.stateManager.clearSelection(); + return; + } + + const trackIndex = parseInt(clipEl.dataset["trackIndex"] || "0", 10); + const clipIndex = parseInt(clipEl.dataset["clipIndex"] || "0", 10); + + // Check if clicking on resize handle + if (target.classList.contains("ss-clip-resize-handle")) { + const edge = target.classList.contains("left") ? "left" : "right"; + this.startResize(e, { trackIndex, clipIndex }, edge); + return; + } + + // Start potential drag + this.startPending(e, { trackIndex, clipIndex }); + } + + private startPending(e: PointerEvent, clipRef: ClipRef): void { + const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + if (!clip) return; + + this.state = { + type: "pending", + startPoint: { x: e.clientX, y: e.clientY }, + clipRef, + originalTime: clip.config.start + }; + } + + private startResize(e: PointerEvent, clipRef: ClipRef, edge: "left" | "right"): void { + const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + if (!clip) return; + + this.state = { + type: "resizing", + clipRef, + edge, + originalStart: clip.config.start, + originalLength: clip.config.length + }; + + this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "resizing"); + this.buildSnapPoints(clipRef); + + e.preventDefault(); + } + + private onPointerMove(e: PointerEvent): void { + switch (this.state.type) { + case "pending": + this.handlePendingMove(e); + break; + case "dragging": + this.handleDragMove(e); + break; + case "resizing": + this.handleResizeMove(e); + break; + } + } + + private handlePendingMove(e: PointerEvent): void { + if (this.state.type !== "pending") return; + + const dx = e.clientX - this.state.startPoint.x; + const dy = e.clientY - this.state.startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance >= this.config.dragThreshold) { + this.transitionToDragging(e); + } + } + + private transitionToDragging(e: PointerEvent): void { + if (this.state.type !== "pending") return; + + const { clipRef, originalTime } = this.state; + const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + if (!clip) { + this.state = { type: "idle" }; + return; + } + + // Create drag ghost + const ghost = this.createDragGhost(clip); + this.feedbackLayer.appendChild(ghost); + + this.state = { + type: "dragging", + clipRef, + ghost, + startTime: originalTime, + originalTrack: clipRef.trackIndex + }; + + this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "dragging"); + this.buildSnapPoints(clipRef); + } + + private createDragGhost(clip: ClipState): HTMLElement { + const ghost = document.createElement("div"); + ghost.className = "ss-drag-ghost ss-clip"; + ghost.dataset["assetType"] = clip.config.asset?.type || "unknown"; + + const pps = this.stateManager.getViewport().pixelsPerSecond; + const width = clip.config.length * pps; + + ghost.style.width = `${width}px`; + ghost.style.height = "56px"; // Track height - padding + ghost.style.position = "absolute"; + ghost.style.pointerEvents = "none"; + ghost.style.opacity = "0.8"; + + return ghost; + } + + private handleDragMove(e: PointerEvent): void { + if (this.state.type !== "dragging") return; + + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const scrollY = this.tracksContainer.scrollTop; + const pps = this.stateManager.getViewport().pixelsPerSecond; + const trackHeight = 64; + + // Calculate new position + const x = e.clientX - rect.left + scrollX; + const y = e.clientY - rect.top + scrollY; + + let time = Math.max(0, x / pps); + const trackIndex = Math.max(0, Math.floor(y / trackHeight)); + + // Apply snapping + const snappedTime = this.applySnap(time); + if (snappedTime !== null) { + time = snappedTime; + this.showSnapLine(time); + } else { + this.hideSnapLine(); + } + + // Update ghost position + this.state.ghost.style.left = `${time * pps}px`; + this.state.ghost.style.top = `${trackIndex * trackHeight + 4}px`; + } + + private handleResizeMove(e: PointerEvent): void { + if (this.state.type !== "resizing") return; + + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const pps = this.stateManager.getViewport().pixelsPerSecond; + + const x = e.clientX - rect.left + scrollX; + let time = Math.max(0, x / pps); + + // Apply snapping + const snappedTime = this.applySnap(time); + if (snappedTime !== null) { + time = snappedTime; + this.showSnapLine(time); + } else { + this.hideSnapLine(); + } + + // Calculate new dimensions based on edge + const { clipRef, edge, originalStart, originalLength } = this.state; + + if (edge === "left") { + // Resize from left edge + const newStart = Math.min(time, originalStart + originalLength - 0.1); + const newLength = originalStart + originalLength - newStart; + + // Update clip visually (temporary state during resize) + const clipEl = this.tracksContainer.querySelector( + `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` + ) as HTMLElement; + if (clipEl) { + clipEl.style.setProperty("--clip-start", String(newStart)); + clipEl.style.setProperty("--clip-length", String(newLength)); + } + } else { + // Resize from right edge + const newLength = Math.max(0.1, time - originalStart); + + const clipEl = this.tracksContainer.querySelector( + `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` + ) as HTMLElement; + if (clipEl) { + clipEl.style.setProperty("--clip-length", String(newLength)); + } + } + } + + private onPointerUp(e: PointerEvent): void { + switch (this.state.type) { + case "pending": + // Was just a click, selection already handled + this.state = { type: "idle" }; + break; + case "dragging": + this.completeDrag(e); + break; + case "resizing": + this.completeResize(e); + break; + } + } + + private completeDrag(e: PointerEvent): void { + if (this.state.type !== "dragging") return; + + const { clipRef, ghost, startTime, originalTrack } = this.state; + + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const scrollY = this.tracksContainer.scrollTop; + const pps = this.stateManager.getViewport().pixelsPerSecond; + const trackHeight = 64; + + const x = e.clientX - rect.left + scrollX; + const y = e.clientY - rect.top + scrollY; + + let newTime = Math.max(0, x / pps); + const newTrackIndex = Math.max(0, Math.floor(y / trackHeight)); + + // Apply snapping + const snappedTime = this.applySnap(newTime); + if (snappedTime !== null) { + newTime = snappedTime; + } + + // Execute move command if position changed + if (newTime !== startTime || newTrackIndex !== originalTrack) { + const command = new MoveClipCommand( + originalTrack, + clipRef.clipIndex, + newTrackIndex, + newTime + ); + this.edit.executeEditCommand(command); + } + + // Cleanup + ghost.remove(); + this.hideSnapLine(); + this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); + this.state = { type: "idle" }; + } + + private completeResize(e: PointerEvent): void { + if (this.state.type !== "resizing") return; + + const { clipRef, edge, originalStart, originalLength } = this.state; + + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const pps = this.stateManager.getViewport().pixelsPerSecond; + + const x = e.clientX - rect.left + scrollX; + let time = Math.max(0, x / pps); + + // Apply snapping + const snappedTime = this.applySnap(time); + if (snappedTime !== null) { + time = snappedTime; + } + + let newStart = originalStart; + let newLength = originalLength; + + if (edge === "left") { + newStart = Math.min(time, originalStart + originalLength - 0.1); + newLength = originalStart + originalLength - newStart; + } else { + newLength = Math.max(0.1, time - originalStart); + } + + // Execute resize command if dimensions changed + if (newLength !== originalLength) { + const command = new ResizeClipCommand( + clipRef.trackIndex, + clipRef.clipIndex, + newLength + ); + this.edit.executeEditCommand(command); + + // TODO: For left-edge resize (start changed), also need MoveClipCommand + // Currently ResizeClipCommand only handles length changes + } + + // Cleanup + this.hideSnapLine(); + this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); + this.state = { type: "idle" }; + } + + private buildSnapPoints(excludeClip: ClipRef): void { + this.snapPoints = []; + + // Add playhead position + const playback = this.stateManager.getPlayback(); + this.snapPoints.push({ + time: playback.time / 1000, + type: "playhead" + }); + + // Add clip edges + const tracks = this.stateManager.getTracks(); + for (const track of tracks) { + for (const clip of track.clips) { + // Skip the clip being dragged/resized + if (clip.trackIndex === excludeClip.trackIndex && clip.clipIndex === excludeClip.clipIndex) { + continue; + } + + this.snapPoints.push({ + time: clip.config.start, + type: "clip-start" + }); + this.snapPoints.push({ + time: clip.config.start + clip.config.length, + type: "clip-end" + }); + } + } + } + + private applySnap(time: number): number | null { + const pps = this.stateManager.getViewport().pixelsPerSecond; + const threshold = this.config.snapThreshold / pps; // Convert pixels to seconds + + for (const point of this.snapPoints) { + if (Math.abs(time - point.time) <= threshold) { + return point.time; + } + } + + return null; + } + + private showSnapLine(time: number): void { + if (!this.snapLine) { + this.snapLine = document.createElement("div"); + this.snapLine.className = "ss-snap-line"; + this.feedbackLayer.appendChild(this.snapLine); + } + + const pps = this.stateManager.getViewport().pixelsPerSecond; + const x = time * pps - this.tracksContainer.scrollLeft; + this.snapLine.style.left = `${x}px`; + this.snapLine.style.display = "block"; + } + + private hideSnapLine(): void { + if (this.snapLine) { + this.snapLine.style.display = "none"; + } + } + + public dispose(): void { + document.removeEventListener("pointermove", this.handlePointerMove); + document.removeEventListener("pointerup", this.handlePointerUp); + + if (this.snapLine) { + this.snapLine.remove(); + this.snapLine = null; + } + + if (this.dragGhost) { + this.dragGhost.remove(); + this.dragGhost = null; + } + } +} diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts new file mode 100644 index 00000000..5009286f --- /dev/null +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -0,0 +1,395 @@ +/** Main timeline styles - dark theme only, no CSS variables for theming */ +export const TIMELINE_STYLES = ` +/* Main container */ +.ss-html-timeline { + --ss-timeline-pixels-per-second: 50; + position: relative; + display: flex; + flex-direction: column; + background: #18181b; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + font-size: 12px; + color: #fafafa; + overflow: hidden; + user-select: none; + -webkit-user-select: none; +} + +/* Toolbar */ +.ss-timeline-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + height: 40px; + padding: 0 12px; + background: #18181b; + border-bottom: 1px solid #27272a; + flex-shrink: 0; +} + +.ss-toolbar-section { + display: flex; + align-items: center; + gap: 8px; +} + +.ss-toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: none; + border-radius: 6px; + color: #a1a1aa; + cursor: pointer; + transition: background 0.1s ease, color 0.1s ease; +} + +.ss-toolbar-btn:hover { + background: #3f3f46; + color: #fafafa; +} + +.ss-toolbar-btn:active, +.ss-toolbar-btn.active { + background: #52525b; +} + +.ss-toolbar-btn svg { + width: 16px; + height: 16px; +} + +.ss-time-display { + font-variant-numeric: tabular-nums; + font-size: 11px; + color: #a1a1aa; + min-width: 120px; + text-align: center; +} + +.ss-zoom-slider { + width: 80px; + height: 4px; + -webkit-appearance: none; + appearance: none; + background: #27272a; + border-radius: 2px; + cursor: pointer; +} + +.ss-zoom-slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 12px; + height: 12px; + background: #fafafa; + border-radius: 50%; + cursor: grab; +} + +/* Ruler */ +.ss-timeline-ruler { + position: relative; + height: 32px; + background: #18181b; + border-bottom: 1px solid #27272a; + overflow: hidden; + flex-shrink: 0; +} + +.ss-ruler-content { + position: relative; + height: 100%; +} + +.ss-ruler-marker { + position: absolute; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + transform: translateX(-50%); +} + +.ss-ruler-marker-line { + width: 1px; + height: 8px; + background: #3f3f46; +} + +.ss-ruler-marker-label { + font-size: 10px; + color: #71717a; + white-space: nowrap; + margin-bottom: 2px; +} + +.ss-ruler-marker.minor .ss-ruler-marker-line { + height: 4px; +} + +.ss-ruler-marker.minor .ss-ruler-marker-label { + display: none; +} + +/* Tracks container */ +.ss-timeline-tracks { + position: relative; + flex: 1; + overflow: auto; + outline: none; +} + +.ss-tracks-content { + position: relative; + min-height: 100%; +} + +/* Track */ +.ss-track { + position: relative; + height: 64px; + border-bottom: 1px solid #3f3f46; +} + +.ss-track:nth-child(odd) { + background: #1f1f23; +} + +.ss-track:nth-child(even) { + background: #27272a; +} + +.ss-track.drop-target { + background: rgba(59, 130, 246, 0.15); +} + +/* Clip */ +.ss-clip { + position: absolute; + top: 4px; + height: calc(64px - 8px); + left: calc(var(--clip-start, 0) * var(--ss-timeline-pixels-per-second) * 1px); + width: calc(var(--clip-length, 1) * var(--ss-timeline-pixels-per-second) * 1px); + min-width: 20px; + background: var(--clip-color, #71717a); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + cursor: grab; + overflow: hidden; + transition: box-shadow 0.1s ease, opacity 0.1s ease; +} + +.ss-clip:hover { + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +.ss-clip.selected { + outline: 2px solid #3b82f6; + outline-offset: -1px; + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); +} + +.ss-clip.dragging { + opacity: 0.5; + cursor: grabbing; +} + +.ss-clip.resizing { + cursor: ew-resize; +} + +/* Clip asset type colors */ +.ss-clip[data-asset-type="video"] { --clip-color: #8b5cf6; } +.ss-clip[data-asset-type="audio"] { --clip-color: #10b981; } +.ss-clip[data-asset-type="image"] { --clip-color: #3b82f6; } +.ss-clip[data-asset-type="text"] { --clip-color: #f59e0b; } +.ss-clip[data-asset-type="rich-text"] { --clip-color: #f59e0b; } +.ss-clip[data-asset-type="shape"] { --clip-color: #ec4899; } +.ss-clip[data-asset-type="html"] { --clip-color: #06b6d4; } +.ss-clip[data-asset-type="luma"] { --clip-color: #6366f1; } +.ss-clip[data-asset-type="caption"] { --clip-color: #14b8a6; } + +/* Clip content */ +.ss-clip-content { + display: flex; + align-items: center; + padding: 0 8px; + height: 100%; + gap: 4px; +} + +.ss-clip-label { + font-size: 11px; + font-weight: 500; + color: white; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); +} + +/* Timing badge */ +.ss-clip-badge { + position: absolute; + top: 4px; + right: 4px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + border-radius: 3px; + font-size: 10px; + color: white; + opacity: 0.7; + transition: opacity 0.1s ease; +} + +.ss-clip:hover .ss-clip-badge { + opacity: 1; +} + +.ss-clip-badge[data-intent="fixed"] { + display: none; +} + +/* Resize handle */ +.ss-clip-resize-handle { + position: absolute; + top: 0; + bottom: 0; + width: 12px; + cursor: ew-resize; + z-index: 10; +} + +.ss-clip-resize-handle.left { + left: 0; + border-radius: 4px 0 0 4px; +} + +.ss-clip-resize-handle.right { + right: 0; + border-radius: 0 4px 4px 0; +} + +.ss-clip-resize-handle:hover { + background: rgba(255, 255, 255, 0.1); +} + +/* Playhead */ +.ss-playhead { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--playhead-time, 0) * var(--ss-timeline-pixels-per-second) * 1px); + width: 2px; + pointer-events: none; + z-index: 50; +} + +.ss-playhead-line { + position: absolute; + top: 0; + bottom: 0; + left: 0; + width: 2px; + background: #ef4444; +} + +.ss-playhead-handle { + position: absolute; + top: -4px; + left: 50%; + transform: translateX(-50%); + width: 12px; + height: 12px; + background: #ef4444; + border-radius: 2px 2px 50% 50%; + cursor: grab; + pointer-events: auto; +} + +.ss-playhead-handle:hover { + transform: translateX(-50%) scale(1.1); +} + +.ss-playhead-handle:active { + cursor: grabbing; +} + +/* Feedback layer */ +.ss-feedback-layer { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + pointer-events: none; + z-index: 100; +} + +/* Snap line */ +.ss-snap-line { + position: absolute; + top: 0; + bottom: 0; + width: 2px; + background: #22c55e; + box-shadow: 0 0 4px #22c55e; +} + +/* Drop zone indicator */ +.ss-drop-zone { + position: absolute; + left: 0; + right: 0; + height: 4px; + background: #3b82f6; + box-shadow: 0 0 8px #3b82f6; + animation: ss-pulse 0.8s ease-in-out infinite; +} + +@keyframes ss-pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } +} + +/* Drag ghost */ +.ss-drag-ghost { + position: absolute; + pointer-events: none; + opacity: 0.8; + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.3); + z-index: 200; +} + +/* Selection box */ +.ss-selection-box { + position: absolute; + border: 1px solid #3b82f6; + background: rgba(59, 130, 246, 0.15); + pointer-events: none; + z-index: 150; +} + +/* Empty state */ +.ss-timeline-empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: #a1a1aa; + font-size: 13px; +} +`; + +/** Get all timeline styles as a single string */ +export function getTimelineStyles(): string { + return TIMELINE_STYLES; +} diff --git a/src/index.ts b/src/index.ts index 870cbb39..09bae3ab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,9 +5,11 @@ export { Canvas } from "@canvas/shotstack-canvas"; export { Controls } from "@core/inputs/controls"; export { VideoExporter } from "@core/export"; export { Timeline } from "./components/timeline/timeline"; +export { HtmlTimeline } from "./components/timeline-html"; // Export theme types for library users export type { TimelineTheme, TimelineThemeInput } from "./core/theme/theme.types"; +export type { HtmlTimelineOptions, HtmlTimelineFeatures } from "./components/timeline-html"; // Export Zod schemas for library users export * from "./core/schemas"; diff --git a/src/main.ts b/src/main.ts index f3f93808..5fed257c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { Timeline } from "./components/timeline"; +import { HtmlTimeline } from "./components/timeline-html"; import theme from "./themes/minimal.json"; import { Edit, Canvas, Controls, VideoExporter } from "./index"; @@ -64,18 +65,35 @@ async function main() { // 4. Load the template await edit.loadEdit(template); - // 5. Initialize the Timeline with size and theme - const timeline = new Timeline( - edit, - { - width: template.output.size.width, - height: 300 - }, - { - theme // Uses imported theme from JSON - } - ); - await timeline.load(); // Renders to [data-shotstack-timeline] element + // // 5. Initialize the Timeline with size and theme + // const timeline = new Timeline( + // edit, + // { + // width: template.output.size.width, + // height: 300 + // }, + // { + // theme // Uses imported theme from JSON + // } + // ); + // await timeline.load(); // Renders to [data-shotstack-timeline] element + + // 5b. Initialize the HTML Timeline (new implementation) + const htmlTimelineContainer = document.querySelector("[data-shotstack-timeline]") as HTMLElement; + if (htmlTimelineContainer) { + const htmlTimeline = new HtmlTimeline(edit, htmlTimelineContainer, { + features: { + toolbar: true, + ruler: true, + playhead: true, + snap: true, + badges: true, + multiSelect: true + } + }); + await htmlTimeline.load(); + console.log("HTML Timeline loaded!"); + } // 6. Add keyboard controls const controls = new Controls(edit); From 070b6881ac0bef991be3dde74ef43b347db75f9a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 17:19:43 +1100 Subject: [PATCH 073/463] feat: add asset type icons and variable track heights based on content type --- .../components/clip/clip-component.ts | 26 ++++ .../components/track/track-component.ts | 12 ++ .../components/track/track-list.ts | 46 ++++++- .../core/state/timeline-state.ts | 19 ++- .../timeline-html/html-timeline.types.ts | 23 +++- src/components/timeline-html/index.ts | 4 +- .../interaction/interaction-controller.ts | 39 ++++-- .../timeline-html/styles/timeline.css.ts | 118 ++++++++++++------ src/templates/hello.json | 59 ++------- 9 files changed, 246 insertions(+), 100 deletions(-) diff --git a/src/components/timeline-html/components/clip/clip-component.ts b/src/components/timeline-html/components/clip/clip-component.ts index 4b7c9f3e..96b933c6 100644 --- a/src/components/timeline-html/components/clip/clip-component.ts +++ b/src/components/timeline-html/components/clip/clip-component.ts @@ -27,6 +27,11 @@ export class ClipComponent extends TimelineEntity { const content = document.createElement("div"); content.className = "ss-clip-content"; + // Icon for asset type + const icon = document.createElement("span"); + icon.className = "ss-clip-icon"; + content.appendChild(icon); + const label = document.createElement("span"); label.className = "ss-clip-label"; content.appendChild(label); @@ -98,6 +103,12 @@ export class ClipComponent extends TimelineEntity { this.element.classList.toggle("dragging", clip.visualState === "dragging"); this.element.classList.toggle("resizing", clip.visualState === "resizing"); + // Update icon + const icon = this.element.querySelector(".ss-clip-icon") as HTMLElement; + if (icon) { + icon.textContent = this.getAssetIcon(assetType); + } + // Update label const label = this.element.querySelector(".ss-clip-label") as HTMLElement; if (label) { @@ -164,6 +175,21 @@ export class ClipComponent extends TimelineEntity { return asset.type || "unknown"; } + private getAssetIcon(type: string): string { + const icons: Record = { + video: "▶", + image: "◻", + audio: "♪", + text: "T", + "rich-text": "T", + shape: "◇", + caption: "≡", + html: "<>", + luma: "◐" + }; + return icons[type] ?? "•"; + } + private getClipLabel(clip: ResolvedClip): string { const asset = clip.asset; if (!asset) return "Clip"; diff --git a/src/components/timeline-html/components/track/track-component.ts b/src/components/timeline-html/components/track/track-component.ts index 746d00fe..77e19ce6 100644 --- a/src/components/timeline-html/components/track/track-component.ts +++ b/src/components/timeline-html/components/track/track-component.ts @@ -1,5 +1,6 @@ import { TimelineEntity } from "../../core/timeline-entity"; import type { TrackState, ClipState, ClipRenderer } from "../../html-timeline.types"; +import { getTrackHeight } from "../../html-timeline.types"; import { ClipComponent } from "../clip/clip-component"; export interface TrackComponentOptions { @@ -97,9 +98,20 @@ export class TrackComponent extends TimelineEntity { public updateTrack(track: TrackState, pixelsPerSecond: number): void { this.currentTrack = track; this.currentPixelsPerSecond = pixelsPerSecond; + + // Set height based on primary asset type + const height = getTrackHeight(track.primaryAssetType); + this.element.style.height = `${height}px`; + this.element.dataset["assetType"] = track.primaryAssetType; + this.needsUpdate = true; } + /** Get the current track state */ + public getCurrentTrack(): TrackState | null { + return this.currentTrack; + } + public getClipComponent(clipId: string): ClipComponent | undefined { return this.clipComponents.get(clipId); } diff --git a/src/components/timeline-html/components/track/track-list.ts b/src/components/timeline-html/components/track/track-list.ts index c3424825..7b6d1ed9 100644 --- a/src/components/timeline-html/components/track/track-list.ts +++ b/src/components/timeline-html/components/track/track-list.ts @@ -1,5 +1,6 @@ import { TimelineEntity } from "../../core/timeline-entity"; import type { TrackState, ClipState, ClipRenderer } from "../../html-timeline.types"; +import { getTrackHeight } from "../../html-timeline.types"; import { TrackComponent } from "./track-component"; export interface TrackListOptions { @@ -119,10 +120,23 @@ export class TrackListComponent extends TimelineEntity { return this.trackComponents[trackIndex]; } - public findClipAtPosition(x: number, y: number, trackHeight: number, pixelsPerSecond: number): ClipState | null { + public findClipAtPosition(x: number, y: number, _trackHeight: number, pixelsPerSecond: number): ClipState | null { const scrollY = this.element.scrollTop; const relativeY = y + scrollY; - const trackIndex = Math.floor(relativeY / trackHeight); + + // Find track at y position using variable heights + let currentY = 0; + let trackIndex = -1; + for (let i = 0; i < this.trackComponents.length; i++) { + const track = this.trackComponents[i].getCurrentTrack(); + const height = getTrackHeight(track?.primaryAssetType ?? "default"); + + if (relativeY >= currentY && relativeY < currentY + height) { + trackIndex = i; + break; + } + currentY += height; + } if (trackIndex < 0 || trackIndex >= this.trackComponents.length) { return null; @@ -134,6 +148,34 @@ export class TrackListComponent extends TimelineEntity { return this.trackComponents[trackIndex].getClipAtPosition(relativeX, pixelsPerSecond); } + /** Get the track index at a given y position */ + public getTrackIndexAtY(y: number): number { + const scrollY = this.element.scrollTop; + const relativeY = y + scrollY; + + let currentY = 0; + for (let i = 0; i < this.trackComponents.length; i++) { + const track = this.trackComponents[i].getCurrentTrack(); + const height = getTrackHeight(track?.primaryAssetType ?? "default"); + + if (relativeY >= currentY && relativeY < currentY + height) { + return i; + } + currentY += height; + } + return -1; + } + + /** Get the Y position of a track by index */ + public getTrackYPosition(trackIndex: number): number { + let y = 0; + for (let i = 0; i < trackIndex && i < this.trackComponents.length; i++) { + const track = this.trackComponents[i].getCurrentTrack(); + y += getTrackHeight(track?.primaryAssetType ?? "default"); + } + return y; + } + public getScrollPosition(): { scrollX: number; scrollY: number } { return { scrollX: this.element.scrollLeft, diff --git a/src/components/timeline-html/core/state/timeline-state.ts b/src/components/timeline-html/core/state/timeline-state.ts index 759da95f..65cc3e34 100644 --- a/src/components/timeline-html/core/state/timeline-state.ts +++ b/src/components/timeline-html/core/state/timeline-state.ts @@ -30,12 +30,21 @@ export class TimelineStateManager { const resolvedEdit = this.edit.getResolvedEdit(); if (!resolvedEdit?.timeline?.tracks) return []; - return resolvedEdit.timeline.tracks.map((track: ResolvedTrack, trackIndex: number) => ({ - index: trackIndex, - clips: (track.clips || []).map((clip: ResolvedClip, clipIndex: number) => + return resolvedEdit.timeline.tracks.map((track: ResolvedTrack, trackIndex: number) => { + const clips = (track.clips || []).map((clip: ResolvedClip, clipIndex: number) => this.createClipState(clip, trackIndex, clipIndex) - ) - })); + ); + + // Derive primary asset type from first clip + const primaryAssetType = + clips.length > 0 && clips[0].config.asset ? clips[0].config.asset.type || "unknown" : "empty"; + + return { + index: trackIndex, + clips, + primaryAssetType + }; + }); } public getPlayback(): PlaybackState { diff --git a/src/components/timeline-html/html-timeline.types.ts b/src/components/timeline-html/html-timeline.types.ts index 825e2882..d9bd0876 100644 --- a/src/components/timeline-html/html-timeline.types.ts +++ b/src/components/timeline-html/html-timeline.types.ts @@ -63,6 +63,8 @@ export interface TrackState { index: number; /** Clips in this track */ clips: ClipState[]; + /** Primary asset type (from first clip, determines track height) */ + primaryAssetType: string; } /** Viewport state */ @@ -123,6 +125,25 @@ export const DEFAULT_INTERACTION: Required = { /** Default timeline settings */ export const DEFAULT_PIXELS_PER_SECOND = 50; -export const DEFAULT_TRACK_HEIGHT = 64; +export const DEFAULT_TRACK_HEIGHT = 48; export const DEFAULT_TOOLBAR_HEIGHT = 40; export const DEFAULT_RULER_HEIGHT = 32; + +/** Track heights by asset type */ +export const TRACK_HEIGHTS: Record = { + video: 72, + image: 72, + audio: 48, + text: 36, + "rich-text": 36, + shape: 36, + caption: 36, + html: 48, + luma: 48, + default: 48 +}; + +/** Get track height for an asset type */ +export function getTrackHeight(assetType: string): number { + return TRACK_HEIGHTS[assetType] ?? TRACK_HEIGHTS["default"]; +} diff --git a/src/components/timeline-html/index.ts b/src/components/timeline-html/index.ts index 5d0c6144..5d6850d9 100644 --- a/src/components/timeline-html/index.ts +++ b/src/components/timeline-html/index.ts @@ -20,5 +20,7 @@ export { DEFAULT_PIXELS_PER_SECOND, DEFAULT_TRACK_HEIGHT, DEFAULT_TOOLBAR_HEIGHT, - DEFAULT_RULER_HEIGHT + DEFAULT_RULER_HEIGHT, + TRACK_HEIGHTS, + getTrackHeight } from "./html-timeline.types"; diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index a3de771f..9b858194 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -1,5 +1,6 @@ import type { Edit } from "@core/edit"; import type { ClipState, HtmlTimelineInteractionConfig } from "../html-timeline.types"; +import { getTrackHeight, TRACK_HEIGHTS } from "../html-timeline.types"; import { TimelineStateManager } from "../core/state/timeline-state"; import { MoveClipCommand } from "@core/commands/move-clip-command"; import { ResizeClipCommand } from "@core/commands/resize-clip-command"; @@ -184,13 +185,15 @@ export class InteractionController { private createDragGhost(clip: ClipState): HTMLElement { const ghost = document.createElement("div"); ghost.className = "ss-drag-ghost ss-clip"; - ghost.dataset["assetType"] = clip.config.asset?.type || "unknown"; + const assetType = clip.config.asset?.type || "unknown"; + ghost.dataset["assetType"] = assetType; const pps = this.stateManager.getViewport().pixelsPerSecond; const width = clip.config.length * pps; + const trackHeight = getTrackHeight(assetType); ghost.style.width = `${width}px`; - ghost.style.height = "56px"; // Track height - padding + ghost.style.height = `${trackHeight - 8}px`; // Track height - padding ghost.style.position = "absolute"; ghost.style.pointerEvents = "none"; ghost.style.opacity = "0.8"; @@ -205,14 +208,13 @@ export class InteractionController { const scrollX = this.tracksContainer.scrollLeft; const scrollY = this.tracksContainer.scrollTop; const pps = this.stateManager.getViewport().pixelsPerSecond; - const trackHeight = 64; // Calculate new position const x = e.clientX - rect.left + scrollX; const y = e.clientY - rect.top + scrollY; let time = Math.max(0, x / pps); - const trackIndex = Math.max(0, Math.floor(y / trackHeight)); + const trackIndex = Math.max(0, this.getTrackIndexAtY(y)); // Apply snapping const snappedTime = this.applySnap(time); @@ -225,7 +227,7 @@ export class InteractionController { // Update ghost position this.state.ghost.style.left = `${time * pps}px`; - this.state.ghost.style.top = `${trackIndex * trackHeight + 4}px`; + this.state.ghost.style.top = `${this.getTrackYPosition(trackIndex) + 4}px`; } private handleResizeMove(e: PointerEvent): void { @@ -300,13 +302,12 @@ export class InteractionController { const scrollX = this.tracksContainer.scrollLeft; const scrollY = this.tracksContainer.scrollTop; const pps = this.stateManager.getViewport().pixelsPerSecond; - const trackHeight = 64; const x = e.clientX - rect.left + scrollX; const y = e.clientY - rect.top + scrollY; let newTime = Math.max(0, x / pps); - const newTrackIndex = Math.max(0, Math.floor(y / trackHeight)); + const newTrackIndex = Math.max(0, this.getTrackIndexAtY(y)); // Apply snapping const snappedTime = this.applySnap(newTime); @@ -442,6 +443,30 @@ export class InteractionController { } } + /** Get track index at a given Y position (accounting for variable heights) */ + private getTrackIndexAtY(y: number): number { + const tracks = this.stateManager.getTracks(); + let currentY = 0; + for (let i = 0; i < tracks.length; i++) { + const height = getTrackHeight(tracks[i].primaryAssetType); + if (y >= currentY && y < currentY + height) { + return i; + } + currentY += height; + } + return Math.max(0, tracks.length - 1); + } + + /** Get Y position of a track by index (accounting for variable heights) */ + private getTrackYPosition(trackIndex: number): number { + const tracks = this.stateManager.getTracks(); + let y = 0; + for (let i = 0; i < trackIndex && i < tracks.length; i++) { + y += getTrackHeight(tracks[i].primaryAssetType); + } + return y; + } + public dispose(): void { document.removeEventListener("pointermove", this.handlePointerMove); document.removeEventListener("pointerup", this.handlePointerUp); diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index 5009286f..b5d325f9 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -148,70 +148,107 @@ export const TIMELINE_STYLES = ` min-height: 100%; } -/* Track */ +/* Track - height set dynamically via inline style */ .ss-track { position: relative; - height: 64px; - border-bottom: 1px solid #3f3f46; -} - -.ss-track:nth-child(odd) { - background: #1f1f23; -} - -.ss-track:nth-child(even) { - background: #27272a; -} + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + transition: background 0.15s ease; +} + +/* Track backgrounds by asset type - subtle tints */ +.ss-track[data-asset-type="video"] { background: rgba(232, 222, 248, 0.06); } +.ss-track[data-asset-type="image"] { background: rgba(209, 232, 255, 0.06); } +.ss-track[data-asset-type="audio"] { background: rgba(184, 230, 212, 0.06); } +.ss-track[data-asset-type="text"], +.ss-track[data-asset-type="rich-text"] { background: rgba(255, 228, 201, 0.06); } +.ss-track[data-asset-type="shape"] { background: rgba(255, 243, 184, 0.06); } +.ss-track[data-asset-type="caption"] { background: rgba(225, 190, 231, 0.06); } +.ss-track[data-asset-type="html"] { background: rgba(179, 229, 252, 0.06); } +.ss-track[data-asset-type="luma"] { background: rgba(207, 216, 220, 0.06); } +.ss-track[data-asset-type="empty"] { background: #1f1f23; } .ss-track.drop-target { background: rgba(59, 130, 246, 0.15); } -/* Clip */ +/* Clip - height adjusts to track via top/bottom */ .ss-clip { position: absolute; top: 4px; - height: calc(64px - 8px); + bottom: 4px; left: calc(var(--clip-start, 0) * var(--ss-timeline-pixels-per-second) * 1px); width: calc(var(--clip-length, 1) * var(--ss-timeline-pixels-per-second) * 1px); min-width: 20px; - background: var(--clip-color, #71717a); - border: 1px solid rgba(255, 255, 255, 0.1); + background: var(--clip-bg, #71717a); + border-left: 3px solid var(--clip-border, #555); border-radius: 4px; cursor: grab; overflow: hidden; - transition: box-shadow 0.1s ease, opacity 0.1s ease; + transition: box-shadow 0.15s ease, transform 0.1s ease; } .ss-clip:hover { - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25); } .ss-clip.selected { outline: 2px solid #3b82f6; outline-offset: -1px; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15); + box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); } .ss-clip.dragging { - opacity: 0.5; + opacity: 0.6; cursor: grabbing; + transform: scale(1.02); } .ss-clip.resizing { cursor: ew-resize; } -/* Clip asset type colors */ -.ss-clip[data-asset-type="video"] { --clip-color: #8b5cf6; } -.ss-clip[data-asset-type="audio"] { --clip-color: #10b981; } -.ss-clip[data-asset-type="image"] { --clip-color: #3b82f6; } -.ss-clip[data-asset-type="text"] { --clip-color: #f59e0b; } -.ss-clip[data-asset-type="rich-text"] { --clip-color: #f59e0b; } -.ss-clip[data-asset-type="shape"] { --clip-color: #ec4899; } -.ss-clip[data-asset-type="html"] { --clip-color: #06b6d4; } -.ss-clip[data-asset-type="luma"] { --clip-color: #6366f1; } -.ss-clip[data-asset-type="caption"] { --clip-color: #14b8a6; } +/* Clip colors - light pastel backgrounds with dark text */ +.ss-clip[data-asset-type="video"] { + --clip-bg: #E8DEF8; + --clip-fg: #4A148C; + --clip-border: #7C4DFF; +} +.ss-clip[data-asset-type="image"] { + --clip-bg: #D1E8FF; + --clip-fg: #0D47A1; + --clip-border: #2196F3; +} +.ss-clip[data-asset-type="audio"] { + --clip-bg: #B8E6D4; + --clip-fg: #004D40; + --clip-border: #00897B; +} +.ss-clip[data-asset-type="text"], +.ss-clip[data-asset-type="rich-text"] { + --clip-bg: #FFE4C9; + --clip-fg: #BF360C; + --clip-border: #E65100; +} +.ss-clip[data-asset-type="shape"] { + --clip-bg: #FFF3B8; + --clip-fg: #F57F17; + --clip-border: #F9A825; +} +.ss-clip[data-asset-type="caption"] { + --clip-bg: #E1BEE7; + --clip-fg: #4A148C; + --clip-border: #8E24AA; +} +.ss-clip[data-asset-type="html"] { + --clip-bg: #B3E5FC; + --clip-fg: #006064; + --clip-border: #0097A7; +} +.ss-clip[data-asset-type="luma"] { + --clip-bg: #CFD8DC; + --clip-fg: #37474F; + --clip-border: #546E7A; +} /* Clip content */ .ss-clip-content { @@ -219,17 +256,26 @@ export const TIMELINE_STYLES = ` align-items: center; padding: 0 8px; height: 100%; - gap: 4px; + gap: 6px; +} + +/* Clip icon */ +.ss-clip-icon { + font-size: 12px; + color: var(--clip-fg, #333); + opacity: 0.7; + flex-shrink: 0; + font-weight: 600; } +/* Clip label - dark text on light background */ .ss-clip-label { font-size: 11px; font-weight: 500; - color: white; + color: var(--clip-fg, #333); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3); } /* Timing badge */ @@ -242,11 +288,11 @@ export const TIMELINE_STYLES = ` display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.4); + background: rgba(0, 0, 0, 0.15); border-radius: 3px; font-size: 10px; - color: white; - opacity: 0.7; + color: var(--clip-fg, #333); + opacity: 0.6; transition: opacity 0.1s ease; } diff --git a/src/templates/hello.json b/src/templates/hello.json index 174e1498..b394e0fc 100644 --- a/src/templates/hello.json +++ b/src/templates/hello.json @@ -1,63 +1,26 @@ { + "output": { + "format": "mp4", + "size": { + "width": 1920, + "height": 1080 + } + }, "timeline": { - "background": "#FFFFFF", + "background": "#000000", "tracks": [ { "clips": [ { "asset": { - "type": "rich-text", - "text": "Production", - "font": { - "family": "Montserrat Extrabold", - "size": 72, - "weight": "400", - "color": "#000000", - "opacity": 1 - } + "type": "video", + "src": "https://shotstack-assets.s3.amazonaws.com/footage/night-sky.mp4" }, "start": 0, - "length": 3, - "position": "center", - "fit": "crop", - "offset": { - "x": 0.06491932678222656, - "y": 0 - }, - "width": 800, - "height": 200 + "length": 10 } ] - }, - { - "clips": [ - { - "asset": { - "type": "image", - "src": "https://shotstack-assets.s3.amazonaws.com/images/woods1.jpg" - }, - "start": 0, - "length": 3, - "position": "center", - "fit": "none", - "width": 500, - "height": 300 - } - ] - } - ], - "fonts": [ - { - "src": "https://shotstack-assets.s3.amazonaws.com/fonts/Oswald-VariableFont.ttf" } ] - }, - "output": { - "size": { - "width": 1280, - "height": 720 - }, - "fps": 25, - "format": "mp4" } } From f8c70966783234207d28688cd75a5efc1b0aa8f7 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 18:40:37 +1100 Subject: [PATCH 074/463] feat: add skip controls and switch to light theme --- .../components/toolbar/toolbar-component.ts | 45 ++++++-- src/components/timeline-html/html-timeline.ts | 14 ++- .../timeline-html/styles/timeline.css.ts | 107 ++++++++++++------ src/core/commands/add-clip-command.ts | 2 + 4 files changed, 121 insertions(+), 47 deletions(-) diff --git a/src/components/timeline-html/components/toolbar/toolbar-component.ts b/src/components/timeline-html/components/toolbar/toolbar-component.ts index b1e597b7..598e0d7b 100644 --- a/src/components/timeline-html/components/toolbar/toolbar-component.ts +++ b/src/components/timeline-html/components/toolbar/toolbar-component.ts @@ -3,6 +3,8 @@ import { TimelineEntity } from "../../core/timeline-entity"; export interface ToolbarOptions { onPlay: () => void; onPause: () => void; + onSkipBack: () => void; + onSkipForward: () => void; onZoomChange: (pixelsPerSecond: number) => void; } @@ -47,10 +49,22 @@ export class ToolbarComponent extends TimelineEntity { } private buildElement(initialZoom: number): void { - // Left section - playback controls + // Left section - empty for balance const leftSection = document.createElement("div"); leftSection.className = "ss-toolbar-section"; + this.element.appendChild(leftSection); + + // Center section - playback controls + time display + const centerSection = document.createElement("div"); + centerSection.className = "ss-toolbar-section ss-playback-controls"; + // Skip back button + const skipBackBtn = this.createButton("skip-back", this.getSkipBackIcon(), () => { + this.options.onSkipBack(); + }); + centerSection.appendChild(skipBackBtn); + + // Play/pause button (larger circular) this.playButton = this.createButton("play", this.getPlayIcon(), () => { if (this.isPlaying) { this.options.onPause(); @@ -58,17 +72,19 @@ export class ToolbarComponent extends TimelineEntity { this.options.onPlay(); } }); - leftSection.appendChild(this.playButton); - - this.element.appendChild(leftSection); + this.playButton.classList.add("ss-play-btn"); + centerSection.appendChild(this.playButton); - // Center section - time display - const centerSection = document.createElement("div"); - centerSection.className = "ss-toolbar-section"; + // Skip forward button + const skipForwardBtn = this.createButton("skip-forward", this.getSkipForwardIcon(), () => { + this.options.onSkipForward(); + }); + centerSection.appendChild(skipForwardBtn); + // Time display this.timeDisplayElement = document.createElement("span"); this.timeDisplayElement.className = "ss-time-display"; - this.timeDisplayElement.textContent = "00:00.000 / 00:00.000"; + this.timeDisplayElement.textContent = "00:00.0 / 00:00.0"; centerSection.appendChild(this.timeDisplayElement); this.element.appendChild(centerSection); @@ -133,11 +149,10 @@ export class ToolbarComponent extends TimelineEntity { } private formatTime(ms: number): string { - const totalSeconds = Math.floor(ms / 1000); + const totalSeconds = ms / 1000; const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; - const milliseconds = Math.floor(ms % 1000); - return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}.${milliseconds.toString().padStart(3, "0")}`; + return `${minutes.toString().padStart(2, "0")}:${seconds.toFixed(1).padStart(4, "0")}`; } // Icon SVGs @@ -156,4 +171,12 @@ export class ToolbarComponent extends TimelineEntity { private getZoomOutIcon(): string { return ``; } + + private getSkipBackIcon(): string { + return ``; + } + + private getSkipForwardIcon(): string { + return ``; + } } diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index fd151fdc..dec90a7e 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -83,8 +83,14 @@ export class HtmlTimeline extends TimelineEntity { // Bind event handlers this.handleTimelineUpdated = () => this.requestRender(); this.handlePlaybackPlay = () => this.startRenderLoop(); - this.handlePlaybackPause = () => this.stopRenderLoop(); - this.handlePlaybackStop = () => this.stopRenderLoop(); + this.handlePlaybackPause = () => { + this.stopRenderLoop(); + this.requestRender(); // Final render to update UI with paused state + }; + this.handlePlaybackStop = () => { + this.stopRenderLoop(); + this.requestRender(); // Final render to update UI with stopped state + }; } /** Initialize and mount the timeline */ @@ -136,6 +142,7 @@ export class HtmlTimeline extends TimelineEntity { // Update toolbar this.toolbar?.updatePlayState(playback.isPlaying); this.toolbar?.updateTimeDisplay(playback.time, playback.duration); + this.toolbar?.draw(); // Update ruler and draw this.ruler?.updateRuler(viewport.pixelsPerSecond, this.stateManager.getExtendedDuration()); @@ -147,6 +154,7 @@ export class HtmlTimeline extends TimelineEntity { // Update playhead this.playhead?.setTime(playback.time); + this.playhead?.draw(); } /** Clean up and unmount the timeline */ @@ -273,6 +281,8 @@ export class HtmlTimeline extends TimelineEntity { { onPlay: () => this.edit.play(), onPause: () => this.edit.pause(), + onSkipBack: () => this.edit.seek(Math.max(0, this.edit.playbackTime - 1000)), + onSkipForward: () => this.edit.seek(this.edit.playbackTime + 1000), onZoomChange: pps => this.setZoom(pps) }, viewport.pixelsPerSecond diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index b5d325f9..9bca07e5 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -1,4 +1,4 @@ -/** Main timeline styles - dark theme only, no CSS variables for theming */ +/** Main timeline styles - light theme */ export const TIMELINE_STYLES = ` /* Main container */ .ss-html-timeline { @@ -6,10 +6,10 @@ export const TIMELINE_STYLES = ` position: relative; display: flex; flex-direction: column; - background: #18181b; + background: #ffffff; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 12px; - color: #fafafa; + color: #1f2937; overflow: hidden; user-select: none; -webkit-user-select: none; @@ -19,12 +19,13 @@ export const TIMELINE_STYLES = ` .ss-timeline-toolbar { display: flex; align-items: center; - justify-content: space-between; - height: 40px; - padding: 0 12px; - background: #18181b; - border-bottom: 1px solid #27272a; + justify-content: center; + height: 64px; + padding: 12px; + background: #ffffff; + border-bottom: 1px solid #e5e7eb; flex-shrink: 0; + position: relative; } .ss-toolbar-section { @@ -33,6 +34,42 @@ export const TIMELINE_STYLES = ` gap: 8px; } +/* Left section - positioned absolute to not affect center */ +.ss-toolbar-section:first-child { + position: absolute; + left: 12px; +} + +/* Right section - positioned absolute to not affect center */ +.ss-toolbar-section:last-child { + position: absolute; + right: 12px; +} + +/* Playback controls group - centered */ +.ss-playback-controls { + gap: 8px; +} + +/* Large circular play button */ +.ss-toolbar-btn.ss-play-btn { + width: 40px; + height: 40px; + background: #374151; + border-radius: 50%; + color: #ffffff; +} + +.ss-toolbar-btn.ss-play-btn:hover { + background: #4b5563; + color: #ffffff; +} + +.ss-toolbar-btn.ss-play-btn svg { + width: 20px; + height: 20px; +} + .ss-toolbar-btn { display: flex; align-items: center; @@ -43,19 +80,19 @@ export const TIMELINE_STYLES = ` background: transparent; border: none; border-radius: 6px; - color: #a1a1aa; + color: #6b7280; cursor: pointer; transition: background 0.1s ease, color 0.1s ease; } .ss-toolbar-btn:hover { - background: #3f3f46; - color: #fafafa; + background: #e5e7eb; + color: #1f2937; } .ss-toolbar-btn:active, .ss-toolbar-btn.active { - background: #52525b; + background: #d1d5db; } .ss-toolbar-btn svg { @@ -66,7 +103,7 @@ export const TIMELINE_STYLES = ` .ss-time-display { font-variant-numeric: tabular-nums; font-size: 11px; - color: #a1a1aa; + color: #6b7280; min-width: 120px; text-align: center; } @@ -76,7 +113,7 @@ export const TIMELINE_STYLES = ` height: 4px; -webkit-appearance: none; appearance: none; - background: #27272a; + background: #e5e7eb; border-radius: 2px; cursor: pointer; } @@ -85,7 +122,7 @@ export const TIMELINE_STYLES = ` -webkit-appearance: none; width: 12px; height: 12px; - background: #fafafa; + background: #3b82f6; border-radius: 50%; cursor: grab; } @@ -94,8 +131,8 @@ export const TIMELINE_STYLES = ` .ss-timeline-ruler { position: relative; height: 32px; - background: #18181b; - border-bottom: 1px solid #27272a; + background: #ffffff; + border-bottom: 1px solid #e5e7eb; overflow: hidden; flex-shrink: 0; } @@ -117,12 +154,12 @@ export const TIMELINE_STYLES = ` .ss-ruler-marker-line { width: 1px; height: 8px; - background: #3f3f46; + background: #d1d5db; } .ss-ruler-marker-label { font-size: 10px; - color: #71717a; + color: #6b7280; white-space: nowrap; margin-bottom: 2px; } @@ -141,6 +178,7 @@ export const TIMELINE_STYLES = ` flex: 1; overflow: auto; outline: none; + background: #ffffff; } .ss-tracks-content { @@ -151,21 +189,22 @@ export const TIMELINE_STYLES = ` /* Track - height set dynamically via inline style */ .ss-track { position: relative; - border-bottom: 1px solid rgba(255, 255, 255, 0.06); + border-bottom: 1px solid #f3f4f6; transition: background 0.15s ease; + background: #ffffff; } -/* Track backgrounds by asset type - subtle tints */ -.ss-track[data-asset-type="video"] { background: rgba(232, 222, 248, 0.06); } -.ss-track[data-asset-type="image"] { background: rgba(209, 232, 255, 0.06); } -.ss-track[data-asset-type="audio"] { background: rgba(184, 230, 212, 0.06); } +/* Track backgrounds by asset type - subtle tints on white */ +.ss-track[data-asset-type="video"] { background: rgba(232, 222, 248, 0.3); } +.ss-track[data-asset-type="image"] { background: rgba(209, 232, 255, 0.3); } +.ss-track[data-asset-type="audio"] { background: rgba(184, 230, 212, 0.3); } .ss-track[data-asset-type="text"], -.ss-track[data-asset-type="rich-text"] { background: rgba(255, 228, 201, 0.06); } -.ss-track[data-asset-type="shape"] { background: rgba(255, 243, 184, 0.06); } -.ss-track[data-asset-type="caption"] { background: rgba(225, 190, 231, 0.06); } -.ss-track[data-asset-type="html"] { background: rgba(179, 229, 252, 0.06); } -.ss-track[data-asset-type="luma"] { background: rgba(207, 216, 220, 0.06); } -.ss-track[data-asset-type="empty"] { background: #1f1f23; } +.ss-track[data-asset-type="rich-text"] { background: rgba(255, 228, 201, 0.3); } +.ss-track[data-asset-type="shape"] { background: rgba(255, 243, 184, 0.3); } +.ss-track[data-asset-type="caption"] { background: rgba(225, 190, 231, 0.3); } +.ss-track[data-asset-type="html"] { background: rgba(179, 229, 252, 0.3); } +.ss-track[data-asset-type="luma"] { background: rgba(207, 216, 220, 0.3); } +.ss-track[data-asset-type="empty"] { background: #f9fafb; } .ss-track.drop-target { background: rgba(59, 130, 246, 0.15); @@ -325,7 +364,7 @@ export const TIMELINE_STYLES = ` } .ss-clip-resize-handle:hover { - background: rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.08); } /* Playhead */ @@ -345,7 +384,7 @@ export const TIMELINE_STYLES = ` bottom: 0; left: 0; width: 2px; - background: #ef4444; + background: #3b82f6; } .ss-playhead-handle { @@ -355,7 +394,7 @@ export const TIMELINE_STYLES = ` transform: translateX(-50%); width: 12px; height: 12px; - background: #ef4444; + background: #3b82f6; border-radius: 2px 2px 50% 50%; cursor: grab; pointer-events: auto; @@ -430,7 +469,7 @@ export const TIMELINE_STYLES = ` align-items: center; justify-content: center; flex: 1; - color: #a1a1aa; + color: #9ca3af; font-size: 13px; } `; diff --git a/src/core/commands/add-clip-command.ts b/src/core/commands/add-clip-command.ts index fc7b3c3c..c335aeca 100644 --- a/src/core/commands/add-clip-command.ts +++ b/src/core/commands/add-clip-command.ts @@ -20,6 +20,7 @@ export class AddClipCommand implements EditCommand { clipPlayer.layer = this.trackIdx + 1; await context.addPlayer(this.trackIdx, clipPlayer); context.updateDuration(); + context.emitEvent("timeline:updated", { current: context.getEditState() }); this.addedPlayer = clipPlayer; } @@ -28,5 +29,6 @@ export class AddClipCommand implements EditCommand { if (!context || !this.addedPlayer) return; context.queueDisposeClip(this.addedPlayer); context.updateDuration(); + context.emitEvent("timeline:updated", { current: context.getEditState() }); } } From 8cc674e5dc462c1c1a8c8347ef0e1dfb34da9cfb Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 19:20:29 +1100 Subject: [PATCH 075/463] feat: improve playhead positioning and scroll synchronization --- .../components/playhead/playhead-component.ts | 12 +++++++--- .../components/track/track-component.ts | 13 ++++++---- src/components/timeline-html/html-timeline.ts | 24 +++++++++++++++---- .../timeline-html/styles/timeline.css.ts | 13 ++++++++-- src/core/inputs/controls.ts | 1 + 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/components/timeline-html/components/playhead/playhead-component.ts b/src/components/timeline-html/components/playhead/playhead-component.ts index 3352aa45..5ea749de 100644 --- a/src/components/timeline-html/components/playhead/playhead-component.ts +++ b/src/components/timeline-html/components/playhead/playhead-component.ts @@ -2,6 +2,7 @@ import { TimelineEntity } from "../../core/timeline-entity"; export interface PlayheadOptions { onSeek: (timeMs: number) => void; + getScrollX?: () => number; } /** Playhead indicator with drag support */ @@ -11,7 +12,7 @@ export class PlayheadComponent extends TimelineEntity { private pixelsPerSecond = 50; private isDragging = false; private containerRect: DOMRect | null = null; - private scrollLeft = 0; + private currentScrollX = 0; private needsUpdate = true; constructor(options: PlayheadOptions) { @@ -43,14 +44,15 @@ export class PlayheadComponent extends TimelineEntity { const container = this.element.parentElement; if (container) { this.containerRect = container.getBoundingClientRect(); - this.scrollLeft = container.scrollLeft; } }; const onPointerMove = (e: PointerEvent) => { if (!this.isDragging || !this.containerRect) return; - const x = e.clientX - this.containerRect.left + this.scrollLeft; + // Get current scroll from callback or stored value + const scrollX = this.options.getScrollX?.() ?? this.currentScrollX; + const x = e.clientX - this.containerRect.left + scrollX; const time = Math.max(0, x / this.pixelsPerSecond); // Update position immediately for smooth feedback @@ -119,4 +121,8 @@ export class PlayheadComponent extends TimelineEntity { public getTime(): number { return this.currentTimeMs; } + + public setScrollX(scrollX: number): void { + this.currentScrollX = scrollX; + } } diff --git a/src/components/timeline-html/components/track/track-component.ts b/src/components/timeline-html/components/track/track-component.ts index 77e19ce6..bb4e6c5f 100644 --- a/src/components/timeline-html/components/track/track-component.ts +++ b/src/components/timeline-html/components/track/track-component.ts @@ -96,13 +96,18 @@ export class TrackComponent extends TimelineEntity { /** Update track state and mark for re-render */ public updateTrack(track: TrackState, pixelsPerSecond: number): void { + // Only update height if asset type changed (not every frame) + const prevAssetType = this.currentTrack?.primaryAssetType; + this.currentTrack = track; this.currentPixelsPerSecond = pixelsPerSecond; - // Set height based on primary asset type - const height = getTrackHeight(track.primaryAssetType); - this.element.style.height = `${height}px`; - this.element.dataset["assetType"] = track.primaryAssetType; + // Set height only when asset type changes + if (track.primaryAssetType !== prevAssetType) { + const height = getTrackHeight(track.primaryAssetType); + this.element.style.height = `${height}px`; + this.element.dataset["assetType"] = track.primaryAssetType; + } this.needsUpdate = true; } diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index dec90a7e..321953c8 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -28,6 +28,7 @@ export class HtmlTimeline extends TimelineEntity { // Components (stored separately from children for typed access) private toolbar: ToolbarComponent | null = null; + private rulerTracksWrapper: HTMLElement | null = null; private ruler: RulerComponent | null = null; private trackList: TrackListComponent | null = null; private playhead: PlayheadComponent | null = null; @@ -290,10 +291,15 @@ export class HtmlTimeline extends TimelineEntity { this.element.appendChild(this.toolbar.element); } + // Create wrapper for ruler + tracks + playhead (so playhead can span both) + this.rulerTracksWrapper = document.createElement("div"); + this.rulerTracksWrapper.className = "ss-ruler-tracks-wrapper"; + this.element.appendChild(this.rulerTracksWrapper); + // Build ruler if (this.features.ruler) { this.ruler = new RulerComponent(); - this.element.appendChild(this.ruler.element); + this.rulerTracksWrapper.appendChild(this.ruler.element); } // Build track list @@ -310,21 +316,26 @@ export class HtmlTimeline extends TimelineEntity { getClipRenderer: type => this.clipRenderers.get(type) }); - // Set up scroll sync + // Set up scroll sync (also sync playhead) this.trackList.setScrollHandler((scrollX, scrollY) => { this.stateManager.setScroll(scrollX, scrollY); this.ruler?.syncScroll(scrollX); + // Sync playhead with track scroll + if (this.playhead) { + this.playhead.element.style.transform = `translateX(${-scrollX}px)`; + this.playhead.setScrollX(scrollX); + } }); - this.element.appendChild(this.trackList.element); + this.rulerTracksWrapper.appendChild(this.trackList.element); - // Build playhead + // Build playhead (at wrapper level so it spans ruler + tracks) if (this.features.playhead) { this.playhead = new PlayheadComponent({ onSeek: timeMs => this.edit.seek(timeMs) }); this.playhead.setPixelsPerSecond(viewport.pixelsPerSecond); - this.trackList.contentElement.appendChild(this.playhead.element); + this.rulerTracksWrapper.appendChild(this.playhead.element); } // Build feedback layer @@ -358,6 +369,9 @@ export class HtmlTimeline extends TimelineEntity { this.trackList?.dispose(); this.trackList = null; + this.rulerTracksWrapper?.remove(); + this.rulerTracksWrapper = null; + this.feedbackLayer?.remove(); this.feedbackLayer = null; } diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index 9bca07e5..3478d1ce 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -127,6 +127,15 @@ export const TIMELINE_STYLES = ` cursor: grab; } +/* Ruler + Tracks Wrapper (for playhead to span both) */ +.ss-ruler-tracks-wrapper { + position: relative; + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + /* Ruler */ .ss-timeline-ruler { position: relative; @@ -389,7 +398,7 @@ export const TIMELINE_STYLES = ` .ss-playhead-handle { position: absolute; - top: -4px; + top: 0; left: 50%; transform: translateX(-50%); width: 12px; @@ -401,7 +410,7 @@ export const TIMELINE_STYLES = ` } .ss-playhead-handle:hover { - transform: translateX(-50%) scale(1.1); + transform: translateX(-50%) scale(1.15); } .ss-playhead-handle:active { diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index 589df268..d1a2af82 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -28,6 +28,7 @@ export class Controls { switch (event.code) { case "Space": { + event.preventDefault(); if (!this.edit.isPlaying) { this.edit.play(); } else { From 189827bc68749e19988762f9a317f982faac3f0b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 19:26:38 +1100 Subject: [PATCH 076/463] fix: convert playback time from milliseconds to seconds in asset toolbar event --- src/core/ui/asset-toolbar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/ui/asset-toolbar.ts b/src/core/ui/asset-toolbar.ts index cf5fd4c1..881a955e 100644 --- a/src/core/ui/asset-toolbar.ts +++ b/src/core/ui/asset-toolbar.ts @@ -70,7 +70,7 @@ export class AssetToolbar { const selectedClip = this.edit.getSelectedClipInfo(); this.edit.events.emit(config.event, { - position: this.edit.playbackTime, + position: this.edit.playbackTime / 1000, selectedClip: selectedClip ? { trackIndex: selectedClip.trackIndex, clipIndex: selectedClip.clipIndex } : null }); }); From e1f06710fbfbdd3540b37eec0ad91715c913bd5d Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 20:18:18 +1100 Subject: [PATCH 077/463] feat: add drag-to-create-track UI with drop zone indicators and coordinate offset tracking --- src/components/timeline-html/html-timeline.ts | 4 +- .../interaction/interaction-controller.ts | 184 +++++++++++++++--- .../create-track-and-move-clip-command.ts | 4 +- src/core/commands/delete-track-command.ts | 2 +- src/core/commands/move-clip-command.ts | 49 ++++- 5 files changed, 210 insertions(+), 33 deletions(-) diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 321953c8..10fcf6d2 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -338,10 +338,10 @@ export class HtmlTimeline extends TimelineEntity { this.rulerTracksWrapper.appendChild(this.playhead.element); } - // Build feedback layer + // Build feedback layer (inside rulerTracksWrapper so coordinates align with tracks) this.feedbackLayer = document.createElement("div"); this.feedbackLayer.className = "ss-feedback-layer"; - this.element.appendChild(this.feedbackLayer); + this.rulerTracksWrapper.appendChild(this.feedbackLayer); // Initialize interaction controller this.interactionController = new InteractionController( diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 9b858194..b5d4c03c 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -4,6 +4,7 @@ import { getTrackHeight, TRACK_HEIGHTS } from "../html-timeline.types"; import { TimelineStateManager } from "../core/state/timeline-state"; import { MoveClipCommand } from "@core/commands/move-clip-command"; import { ResizeClipCommand } from "@core/commands/resize-clip-command"; +import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command"; /** Point coordinates */ interface Point { @@ -23,11 +24,25 @@ interface SnapPoint { type: "clip-start" | "clip-end" | "playhead"; } +/** Drag target - either an existing track or an insertion point between tracks */ +type DragTarget = + | { type: "track"; trackIndex: number } + | { type: "insert"; insertionIndex: number }; + /** Interaction state machine */ type InteractionState = | { type: "idle" } | { type: "pending"; startPoint: Point; clipRef: ClipRef; originalTime: number } - | { type: "dragging"; clipRef: ClipRef; ghost: HTMLElement; startTime: number; originalTrack: number } + | { + type: "dragging"; + clipRef: ClipRef; + ghost: HTMLElement; + startTime: number; + originalTrack: number; + dragTarget: DragTarget; + dragOffsetX: number; // Pixel offset from ghost left edge to mouse + dragOffsetY: number; // Pixel offset from ghost top to mouse + } | { type: "resizing"; clipRef: ClipRef; edge: "left" | "right"; originalStart: number; originalLength: number }; /** Configuration defaults */ @@ -47,6 +62,7 @@ export class InteractionController { private readonly feedbackLayer: HTMLElement; private snapLine: HTMLElement | null = null; private dragGhost: HTMLElement | null = null; + private dropZone: HTMLElement | null = null; // Bound handlers for cleanup private readonly handlePointerMove: (e: PointerEvent) => void; @@ -166,6 +182,24 @@ export class InteractionController { return; } + // Calculate drag offsets - distance from mouse to clip's top-left corner + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const scrollY = this.tracksContainer.scrollTop; + const pps = this.stateManager.getViewport().pixelsPerSecond; + + // Mouse position in content space + const mouseX = e.clientX - rect.left + scrollX; + const mouseY = e.clientY - rect.top + scrollY; + + // Clip position in content space + const clipLeft = clip.config.start * pps; + const clipTop = this.getTrackYPosition(clipRef.trackIndex) + 4; // +4 for padding + + // Offsets from clip corner to mouse + const dragOffsetX = mouseX - clipLeft; + const dragOffsetY = mouseY - clipTop; + // Create drag ghost const ghost = this.createDragGhost(clip); this.feedbackLayer.appendChild(ghost); @@ -175,7 +209,10 @@ export class InteractionController { clipRef, ghost, startTime: originalTime, - originalTrack: clipRef.trackIndex + originalTrack: clipRef.trackIndex, + dragTarget: { type: "track", trackIndex: clipRef.trackIndex }, + dragOffsetX, + dragOffsetY }; this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "dragging"); @@ -209,25 +246,43 @@ export class InteractionController { const scrollY = this.tracksContainer.scrollTop; const pps = this.stateManager.getViewport().pixelsPerSecond; - // Calculate new position - const x = e.clientX - rect.left + scrollX; - const y = e.clientY - rect.top + scrollY; + // Mouse position in content space + const mouseX = e.clientX - rect.left + scrollX; + const mouseY = e.clientY - rect.top + scrollY; - let time = Math.max(0, x / pps); - const trackIndex = Math.max(0, this.getTrackIndexAtY(y)); + // Calculate ghost position (mouse minus offset = clip corner position) + const ghostX = mouseX - this.state.dragOffsetX; + const ghostY = mouseY - this.state.dragOffsetY; - // Apply snapping - const snappedTime = this.applySnap(time); + // Calculate new clip start time from ghost position + let clipTime = Math.max(0, ghostX / pps); + + // Determine drag target based on mouse Y (not ghost Y) + const dragTarget = this.getDragTargetAtY(mouseY); + this.state.dragTarget = dragTarget; + + // Apply snapping to clip time (not mouse position) + const snappedTime = this.applySnap(clipTime); if (snappedTime !== null) { - time = snappedTime; - this.showSnapLine(time); + clipTime = snappedTime; + this.showSnapLine(clipTime); } else { this.hideSnapLine(); } - // Update ghost position - this.state.ghost.style.left = `${time * pps}px`; - this.state.ghost.style.top = `${this.getTrackYPosition(trackIndex) + 4}px`; + // Get offset for positioning in feedback layer (accounts for ruler height) + const tracksOffset = this.getTracksOffsetInFeedbackLayer(); + + // Position ghost freely - X follows clip time (with snap), Y follows mouse + this.state.ghost.style.left = `${clipTime * pps}px`; + this.state.ghost.style.top = `${ghostY + tracksOffset}px`; + + // Show drop zone indicator when over insertion zone + if (dragTarget.type === "insert") { + this.showDropZone(dragTarget.insertionIndex); + } else { + this.hideDropZone(); + } } private handleResizeMove(e: PointerEvent): void { @@ -296,18 +351,18 @@ export class InteractionController { private completeDrag(e: PointerEvent): void { if (this.state.type !== "dragging") return; - const { clipRef, ghost, startTime, originalTrack } = this.state; + const { clipRef, ghost, startTime, originalTrack, dragTarget, dragOffsetX } = this.state; const rect = this.tracksContainer.getBoundingClientRect(); const scrollX = this.tracksContainer.scrollLeft; - const scrollY = this.tracksContainer.scrollTop; const pps = this.stateManager.getViewport().pixelsPerSecond; - const x = e.clientX - rect.left + scrollX; - const y = e.clientY - rect.top + scrollY; + // Calculate ghost position (mouse minus offset = clip corner position) + const mouseX = e.clientX - rect.left + scrollX; + const ghostX = mouseX - dragOffsetX; - let newTime = Math.max(0, x / pps); - const newTrackIndex = Math.max(0, this.getTrackIndexAtY(y)); + // Calculate new clip start time from ghost position + let newTime = Math.max(0, ghostX / pps); // Apply snapping const snappedTime = this.applySnap(newTime); @@ -315,12 +370,22 @@ export class InteractionController { newTime = snappedTime; } - // Execute move command if position changed - if (newTime !== startTime || newTrackIndex !== originalTrack) { + // Execute appropriate command based on drag target + if (dragTarget.type === "insert") { + // Create new track and move clip to it + const command = new CreateTrackAndMoveClipCommand( + dragTarget.insertionIndex, + originalTrack, + clipRef.clipIndex, + newTime + ); + this.edit.executeEditCommand(command); + } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { + // Move to existing track const command = new MoveClipCommand( originalTrack, clipRef.clipIndex, - newTrackIndex, + dragTarget.trackIndex, newTime ); this.edit.executeEditCommand(command); @@ -329,6 +394,7 @@ export class InteractionController { // Cleanup ghost.remove(); this.hideSnapLine(); + this.hideDropZone(); this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); this.state = { type: "idle" }; } @@ -443,6 +509,37 @@ export class InteractionController { } } + private showDropZone(insertionIndex: number): void { + if (!this.dropZone) { + this.dropZone = document.createElement("div"); + this.dropZone.className = "ss-drop-zone"; + this.feedbackLayer.appendChild(this.dropZone); + } + + const y = this.getTrackYPosition(insertionIndex); + const tracksOffset = this.getTracksOffsetInFeedbackLayer(); + this.dropZone.style.top = `${y - 2 + tracksOffset}px`; + this.dropZone.style.display = "block"; + } + + /** Get the Y offset of tracks container relative to feedback layer's parent */ + private getTracksOffsetInFeedbackLayer(): number { + // Feedback layer and tracks container are siblings inside rulerTracksWrapper + // The ruler sits above the tracks, so we need this offset for correct positioning + const feedbackParent = this.feedbackLayer.parentElement; + if (!feedbackParent) return 0; + + const parentRect = feedbackParent.getBoundingClientRect(); + const tracksRect = this.tracksContainer.getBoundingClientRect(); + return tracksRect.top - parentRect.top; + } + + private hideDropZone(): void { + if (this.dropZone) { + this.dropZone.style.display = "none"; + } + } + /** Get track index at a given Y position (accounting for variable heights) */ private getTrackIndexAtY(y: number): number { const tracks = this.stateManager.getTracks(); @@ -457,6 +554,42 @@ export class InteractionController { return Math.max(0, tracks.length - 1); } + /** Get drag target at Y position - either an existing track or an insertion point between tracks */ + private getDragTargetAtY(y: number): DragTarget { + const tracks = this.stateManager.getTracks(); + const insertZoneSize = 12; // pixels at track edges for insert detection + let currentY = 0; + + // Top edge - insert above first track + if (y < insertZoneSize / 2) { + return { type: "insert", insertionIndex: 0 }; + } + + for (let i = 0; i < tracks.length; i++) { + const height = getTrackHeight(tracks[i].primaryAssetType); + + // Top edge insert zone (between this track and previous) + if (i > 0 && y >= currentY - insertZoneSize / 2 && y < currentY + insertZoneSize / 2) { + return { type: "insert", insertionIndex: i }; + } + + // Inside track (not in edge zones) + if (y >= currentY + insertZoneSize / 2 && y < currentY + height - insertZoneSize / 2) { + return { type: "track", trackIndex: i }; + } + + currentY += height; + } + + // Bottom edge - insert after last track + if (y >= currentY - insertZoneSize / 2) { + return { type: "insert", insertionIndex: tracks.length }; + } + + // Default to last track + return { type: "track", trackIndex: Math.max(0, tracks.length - 1) }; + } + /** Get Y position of a track by index (accounting for variable heights) */ private getTrackYPosition(trackIndex: number): number { const tracks = this.stateManager.getTracks(); @@ -480,5 +613,10 @@ export class InteractionController { this.dragGhost.remove(); this.dragGhost = null; } + + if (this.dropZone) { + this.dropZone.remove(); + this.dropZone = null; + } } } diff --git a/src/core/commands/create-track-and-move-clip-command.ts b/src/core/commands/create-track-and-move-clip-command.ts index 3edb503c..10532f38 100644 --- a/src/core/commands/create-track-and-move-clip-command.ts +++ b/src/core/commands/create-track-and-move-clip-command.ts @@ -52,11 +52,11 @@ export class CreateTrackAndMoveClipCommand implements EditCommand { } } - undo(context?: CommandContext): void { + async undo(context?: CommandContext): Promise { if (!context || !this.wasExecuted) return; // Undo in reverse order - this.moveClipCommand.undo(context); + await this.moveClipCommand.undo(context); this.addTrackCommand.undo(context); this.wasExecuted = false; diff --git a/src/core/commands/delete-track-command.ts b/src/core/commands/delete-track-command.ts index 8e2345a7..1261932e 100644 --- a/src/core/commands/delete-track-command.ts +++ b/src/core/commands/delete-track-command.ts @@ -53,7 +53,7 @@ export class DeleteTrackCommand implements EditCommand { } async undo(context?: CommandContext): Promise { - if (!context || this.deletedClips.length === 0) return; + if (!context) return; const tracks = context.getTracks(); const clips = context.getClips(); diff --git a/src/core/commands/move-clip-command.ts b/src/core/commands/move-clip-command.ts index 3d5231b7..f077f651 100644 --- a/src/core/commands/move-clip-command.ts +++ b/src/core/commands/move-clip-command.ts @@ -2,6 +2,7 @@ import type { Player } from "@canvas/players/player"; import type { TimingIntent } from "@core/timing/types"; import type { EditCommand, CommandContext } from "./types"; +import { DeleteTrackCommand } from "./delete-track-command"; export class MoveClipCommand implements EditCommand { name = "moveClip"; @@ -10,6 +11,8 @@ export class MoveClipCommand implements EditCommand { private originalClipIndex: number; private originalStart?: number; private originalTimingIntent?: TimingIntent; + private deleteTrackCommand?: DeleteTrackCommand; + private sourceTrackWasDeleted = false; constructor( private fromTrackIndex: number, @@ -78,6 +81,19 @@ export class MoveClipCommand implements EditCommand { // Store the new clip index for undo this.originalClipIndex = insertIndex; + + // Check if source track is now empty and delete it + if (fromTrack.length === 0) { + this.deleteTrackCommand = new DeleteTrackCommand(this.fromTrackIndex); + this.deleteTrackCommand.execute(context); + this.sourceTrackWasDeleted = true; + + // Adjust destination track index if it was after the deleted track + if (this.toTrackIndex > this.fromTrackIndex) { + this.toTrackIndex -= 1; + this.player.layer = this.toTrackIndex + 1; + } + } } else { // Same track - need to reorder if position changed const track = fromTrack; @@ -124,7 +140,10 @@ export class MoveClipCommand implements EditCommand { } // Move the player container to the new track container if needed - context.movePlayerToTrackContainer(this.player, this.fromTrackIndex, this.toTrackIndex); + // Skip if source track was deleted - the container move is handled by DeleteTrackCommand + if (!this.sourceTrackWasDeleted) { + context.movePlayerToTrackContainer(this.player, this.fromTrackIndex, this.toTrackIndex); + } // Reconfigure and redraw the player this.player.reconfigureAfterRestore(); @@ -134,8 +153,8 @@ export class MoveClipCommand implements EditCommand { context.updateDuration(); // If we moved tracks, we need to update all clips in both tracks - if (this.fromTrackIndex !== this.toTrackIndex) { - // Force all clips in the affected tracks to redraw + if (this.fromTrackIndex !== this.toTrackIndex && !this.sourceTrackWasDeleted) { + // Force all clips in the affected tracks to redraw (skip if source was deleted) const sourceTrack = tracks[this.fromTrackIndex]; const destTrack = tracks[this.toTrackIndex]; @@ -144,11 +163,19 @@ export class MoveClipCommand implements EditCommand { clip.draw(); } }); + } else if (this.sourceTrackWasDeleted) { + // Only redraw destination track clips + const destTrack = tracks[this.toTrackIndex]; + destTrack?.forEach(clip => { + if (clip && clip !== this.player) { + clip.draw(); + } + }); } // Propagate timing changes to dependent clips // Need to propagate on both source and destination tracks if they differ - if (this.fromTrackIndex !== this.toTrackIndex) { + if (this.fromTrackIndex !== this.toTrackIndex && !this.sourceTrackWasDeleted) { context.propagateTimingChanges(this.fromTrackIndex, this.fromClipIndex - 1); } context.propagateTimingChanges(this.toTrackIndex, this.originalClipIndex); @@ -175,9 +202,21 @@ export class MoveClipCommand implements EditCommand { }); } - undo(context?: CommandContext): void { + async undo(context?: CommandContext): Promise { if (!context || !this.player || this.originalStart === undefined) return; + // If source track was deleted, recreate it first + if (this.sourceTrackWasDeleted && this.deleteTrackCommand) { + await this.deleteTrackCommand.undo(context); + this.sourceTrackWasDeleted = false; + + // Re-adjust track indices that were modified during execute + if (this.toTrackIndex >= this.fromTrackIndex) { + this.toTrackIndex += 1; + this.player.layer = this.toTrackIndex + 1; + } + } + const tracks = context.getTracks(); // If we moved tracks, move it back From 890a3f6d47edda6e9ce469d9d720733df6c7a0a9 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 20:22:47 +1100 Subject: [PATCH 078/463] feat: add drag time tooltip display during clip dragging --- .../interaction/interaction-controller.ts | 37 +++++++++++++++++++ .../timeline-html/styles/timeline.css.ts | 17 +++++++++ 2 files changed, 54 insertions(+) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index b5d4c03c..8f30bc1b 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -63,6 +63,7 @@ export class InteractionController { private snapLine: HTMLElement | null = null; private dragGhost: HTMLElement | null = null; private dropZone: HTMLElement | null = null; + private dragTimeTooltip: HTMLElement | null = null; // Bound handlers for cleanup private readonly handlePointerMove: (e: PointerEvent) => void; @@ -277,6 +278,9 @@ export class InteractionController { this.state.ghost.style.left = `${clipTime * pps}px`; this.state.ghost.style.top = `${ghostY + tracksOffset}px`; + // Show timestamp tooltip above ghost + this.showDragTimeTooltip(clipTime, clipTime * pps, ghostY + tracksOffset); + // Show drop zone indicator when over insertion zone if (dragTarget.type === "insert") { this.showDropZone(dragTarget.insertionIndex); @@ -395,6 +399,7 @@ export class InteractionController { ghost.remove(); this.hideSnapLine(); this.hideDropZone(); + this.hideDragTimeTooltip(); this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); this.state = { type: "idle" }; } @@ -540,6 +545,33 @@ export class InteractionController { } } + /** Format time for drag tooltip display (MM:SS.T) */ + private formatDragTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const tenths = Math.floor((seconds % 1) * 10); + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${tenths}`; + } + + private showDragTimeTooltip(time: number, x: number, y: number): void { + if (!this.dragTimeTooltip) { + this.dragTimeTooltip = document.createElement("div"); + this.dragTimeTooltip.className = "ss-drag-time-tooltip"; + this.feedbackLayer.appendChild(this.dragTimeTooltip); + } + + this.dragTimeTooltip.textContent = this.formatDragTime(time); + this.dragTimeTooltip.style.left = `${x}px`; + this.dragTimeTooltip.style.top = `${y - 28}px`; + this.dragTimeTooltip.style.display = "block"; + } + + private hideDragTimeTooltip(): void { + if (this.dragTimeTooltip) { + this.dragTimeTooltip.style.display = "none"; + } + } + /** Get track index at a given Y position (accounting for variable heights) */ private getTrackIndexAtY(y: number): number { const tracks = this.stateManager.getTracks(); @@ -618,5 +650,10 @@ export class InteractionController { this.dropZone.remove(); this.dropZone = null; } + + if (this.dragTimeTooltip) { + this.dragTimeTooltip.remove(); + this.dragTimeTooltip = null; + } } } diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index 3478d1ce..a42d38d8 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -463,6 +463,23 @@ export const TIMELINE_STYLES = ` z-index: 200; } +/* Drag time tooltip */ +.ss-drag-time-tooltip { + position: absolute; + background: #1a1a2e; + color: #fff; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + font-family: monospace; + white-space: nowrap; + pointer-events: none; + z-index: 250; + transform: translateX(-50%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + /* Selection box */ .ss-selection-box { position: absolute; From a2b6f6230e551dde039844e3b28e8ec1ee37e489 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 20:52:49 +1100 Subject: [PATCH 079/463] feat: add ruler seek functionality and improve drag ghost track height detection --- .../components/ruler/ruler-component.ts | 24 ++++++++++++++++++- src/components/timeline-html/html-timeline.ts | 4 +++- .../interaction/interaction-controller.ts | 12 ++++++---- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/components/timeline-html/components/ruler/ruler-component.ts b/src/components/timeline-html/components/ruler/ruler-component.ts index 3982df5f..75f1eada 100644 --- a/src/components/timeline-html/components/ruler/ruler-component.ts +++ b/src/components/timeline-html/components/ruler/ruler-component.ts @@ -1,15 +1,37 @@ import { TimelineEntity } from "../../core/timeline-entity"; +interface RulerOptions { + onSeek?: (timeMs: number) => void; +} + /** Time ruler component for the timeline */ export class RulerComponent extends TimelineEntity { private readonly contentElement: HTMLElement; + private readonly options: RulerOptions; private currentPixelsPerSecond = 50; private currentDuration = 60; private needsRender = true; + private scrollX = 0; - constructor() { + constructor(options: RulerOptions = {}) { super("div", "ss-timeline-ruler"); + this.options = options; this.contentElement = this.buildElement(); + this.setupClickHandler(); + } + + private setupClickHandler(): void { + this.element.addEventListener("click", this.handleClick.bind(this)); + } + + private handleClick(e: MouseEvent): void { + if (!this.options.onSeek) return; + + const rect = this.element.getBoundingClientRect(); + const x = e.clientX - rect.left + this.scrollX; + const time = Math.max(0, x / this.currentPixelsPerSecond); + + this.options.onSeek(time * 1000); } private buildElement(): HTMLElement { diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 10fcf6d2..2287b6d1 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -298,7 +298,9 @@ export class HtmlTimeline extends TimelineEntity { // Build ruler if (this.features.ruler) { - this.ruler = new RulerComponent(); + this.ruler = new RulerComponent({ + onSeek: timeMs => this.edit.seek(timeMs) + }); this.rulerTracksWrapper.appendChild(this.ruler.element); } diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 8f30bc1b..7c9f337c 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -202,7 +202,7 @@ export class InteractionController { const dragOffsetY = mouseY - clipTop; // Create drag ghost - const ghost = this.createDragGhost(clip); + const ghost = this.createDragGhost(clip, clipRef.trackIndex); this.feedbackLayer.appendChild(ghost); this.state = { @@ -220,15 +220,17 @@ export class InteractionController { this.buildSnapPoints(clipRef); } - private createDragGhost(clip: ClipState): HTMLElement { + private createDragGhost(clip: ClipState, trackIndex: number): HTMLElement { const ghost = document.createElement("div"); ghost.className = "ss-drag-ghost ss-clip"; - const assetType = clip.config.asset?.type || "unknown"; - ghost.dataset["assetType"] = assetType; + const clipAssetType = clip.config.asset?.type || "unknown"; + ghost.dataset["assetType"] = clipAssetType; const pps = this.stateManager.getViewport().pixelsPerSecond; const width = clip.config.length * pps; - const trackHeight = getTrackHeight(assetType); + const track = this.stateManager.getTracks()[trackIndex]; + const trackAssetType = track?.primaryAssetType ?? clipAssetType; + const trackHeight = getTrackHeight(trackAssetType); ghost.style.width = `${width}px`; ghost.style.height = `${trackHeight - 8}px`; // Track height - padding From 2d4857d582885a63dad8467950a8b2f44d0430aa Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 21:11:45 +1100 Subject: [PATCH 080/463] fix: ensure render updates on clip selection and clearing selection --- src/components/timeline-html/html-timeline.ts | 3 +++ .../timeline-html/styles/timeline.css.ts | 21 ++++++++++++++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 2287b6d1..17f8ac59 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -314,6 +314,7 @@ export class HtmlTimeline extends TimelineEntity { this.stateManager.selectClip(trackIndex, clipIndex, false); } this.edit.selectClip(trackIndex, clipIndex); + this.requestRender(); }, getClipRenderer: type => this.clipRenderers.get(type) }); @@ -416,11 +417,13 @@ export class HtmlTimeline extends TimelineEntity { public selectClip(trackIndex: number, clipIndex: number): void { this.stateManager.selectClip(trackIndex, clipIndex, false); this.edit.selectClip(trackIndex, clipIndex); + this.requestRender(); } public clearSelection(): void { this.stateManager.clearSelection(); this.edit.clearSelection(); + this.requestRender(); } public enableFeature(feature: keyof HtmlTimelineFeatures): void { diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index a42d38d8..0f42ee2a 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -240,9 +240,15 @@ export const TIMELINE_STYLES = ` } .ss-clip.selected { - outline: 2px solid #3b82f6; - outline-offset: -1px; - box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2); + /* Solid selection ring - professional, non-distracting */ + outline: 2px solid var(--clip-border, #3b82f6); + outline-offset: 0px; + + /* Subtle lift with soft glow */ + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.3) inset, + 0 2px 8px rgba(0, 0, 0, 0.2), + 0 0 0 3px color-mix(in srgb, var(--clip-border, #3b82f6) 25%, transparent); } .ss-clip.dragging { @@ -376,6 +382,15 @@ export const TIMELINE_STYLES = ` background: rgba(0, 0, 0, 0.08); } +/* Visible resize handles when selected */ +.ss-clip.selected .ss-clip-resize-handle { + background: rgba(0, 0, 0, 0.06); +} + +.ss-clip.selected .ss-clip-resize-handle:hover { + background: rgba(0, 0, 0, 0.12); +} + /* Playhead */ .ss-playhead { position: absolute; From 71fd5a3faf4dc25db8cf981038167d1624f27644 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 21:21:45 +1100 Subject: [PATCH 081/463] refactor: separate dragging clip element from drop preview ghost --- .../interaction/interaction-controller.ts | 133 ++++++++++++------ .../timeline-html/styles/timeline.css.ts | 9 +- 2 files changed, 99 insertions(+), 43 deletions(-) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 7c9f337c..fa484bbc 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -36,12 +36,14 @@ type InteractionState = | { type: "dragging"; clipRef: ClipRef; - ghost: HTMLElement; + clipElement: HTMLElement; // Original clip element (follows mouse) + ghost: HTMLElement; // Drop preview (shows snap target) startTime: number; originalTrack: number; dragTarget: DragTarget; - dragOffsetX: number; // Pixel offset from ghost left edge to mouse - dragOffsetY: number; // Pixel offset from ghost top to mouse + dragOffsetX: number; // Pixel offset from clip left edge to mouse + dragOffsetY: number; // Pixel offset from clip top to mouse + originalStyles: { position: string; left: string; top: string; zIndex: string; pointerEvents: string }; } | { type: "resizing"; clipRef: ClipRef; edge: "left" | "right"; originalStart: number; originalLength: number }; @@ -183,40 +185,65 @@ export class InteractionController { return; } - // Calculate drag offsets - distance from mouse to clip's top-left corner - const rect = this.tracksContainer.getBoundingClientRect(); - const scrollX = this.tracksContainer.scrollLeft; - const scrollY = this.tracksContainer.scrollTop; - const pps = this.stateManager.getViewport().pixelsPerSecond; - - // Mouse position in content space - const mouseX = e.clientX - rect.left + scrollX; - const mouseY = e.clientY - rect.top + scrollY; + // Find the actual clip DOM element + const clipElement = this.tracksContainer.querySelector( + `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` + ) as HTMLElement | null; + if (!clipElement) { + this.state = { type: "idle" }; + return; + } - // Clip position in content space - const clipLeft = clip.config.start * pps; - const clipTop = this.getTrackYPosition(clipRef.trackIndex) + 4; // +4 for padding + // Store original styles for restoration later + const originalStyles = { + position: clipElement.style.position, + left: clipElement.style.left, + top: clipElement.style.top, + zIndex: clipElement.style.zIndex, + pointerEvents: clipElement.style.pointerEvents + }; - // Offsets from clip corner to mouse - const dragOffsetX = mouseX - clipLeft; - const dragOffsetY = mouseY - clipTop; + // Get clip element's current screen position + const clipRect = clipElement.getBoundingClientRect(); - // Create drag ghost + // Calculate drag offsets - distance from mouse to clip's top-left corner + const dragOffsetX = e.clientX - clipRect.left; + const dragOffsetY = e.clientY - clipRect.top; + + // Make clip element follow mouse with position: fixed + clipElement.style.position = "fixed"; + clipElement.style.left = `${clipRect.left}px`; + clipElement.style.top = `${clipRect.top}px`; + clipElement.style.width = `${clipRect.width}px`; + clipElement.style.height = `${clipRect.height}px`; + clipElement.style.zIndex = "1000"; + clipElement.style.pointerEvents = "none"; + clipElement.classList.add("dragging"); + + // Create ghost as drop preview (shows where clip will land) const ghost = this.createDragGhost(clip, clipRef.trackIndex); this.feedbackLayer.appendChild(ghost); + const pps = this.stateManager.getViewport().pixelsPerSecond; + this.state = { type: "dragging", clipRef, + clipElement, ghost, startTime: originalTime, originalTrack: clipRef.trackIndex, dragTarget: { type: "track", trackIndex: clipRef.trackIndex }, dragOffsetX, - dragOffsetY + dragOffsetY, + originalStyles }; - this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "dragging"); + // Position ghost at current clip position initially + const tracksOffset = this.getTracksOffsetInFeedbackLayer(); + ghost.style.left = `${clip.config.start * pps}px`; + ghost.style.top = `${this.getTrackYPosition(clipRef.trackIndex) + 4 + tracksOffset}px`; + this.buildSnapPoints(clipRef); } @@ -249,22 +276,23 @@ export class InteractionController { const scrollY = this.tracksContainer.scrollTop; const pps = this.stateManager.getViewport().pixelsPerSecond; - // Mouse position in content space + // Move the actual clip element freely with the mouse (position: fixed) + this.state.clipElement.style.left = `${e.clientX - this.state.dragOffsetX}px`; + this.state.clipElement.style.top = `${e.clientY - this.state.dragOffsetY}px`; + + // Mouse position in content space (for calculating target position) const mouseX = e.clientX - rect.left + scrollX; const mouseY = e.clientY - rect.top + scrollY; - // Calculate ghost position (mouse minus offset = clip corner position) - const ghostX = mouseX - this.state.dragOffsetX; - const ghostY = mouseY - this.state.dragOffsetY; - - // Calculate new clip start time from ghost position - let clipTime = Math.max(0, ghostX / pps); + // Calculate clip position from mouse (accounting for drag offset in content space) + const clipX = mouseX - this.state.dragOffsetX; + let clipTime = Math.max(0, clipX / pps); - // Determine drag target based on mouse Y (not ghost Y) + // Determine drag target based on mouse Y const dragTarget = this.getDragTargetAtY(mouseY); this.state.dragTarget = dragTarget; - // Apply snapping to clip time (not mouse position) + // Apply snapping to clip time const snappedTime = this.applySnap(clipTime); if (snappedTime !== null) { clipTime = snappedTime; @@ -276,12 +304,28 @@ export class InteractionController { // Get offset for positioning in feedback layer (accounts for ruler height) const tracksOffset = this.getTracksOffsetInFeedbackLayer(); - // Position ghost freely - X follows clip time (with snap), Y follows mouse + // Calculate target track Y position and height for the ghost + const tracks = this.stateManager.getTracks(); + let targetTrackY: number; + let targetHeight: number; + if (dragTarget.type === "track") { + targetTrackY = this.getTrackYPosition(dragTarget.trackIndex) + 4; // +4 for clip padding + const targetTrack = tracks[dragTarget.trackIndex]; + targetHeight = getTrackHeight(targetTrack?.primaryAssetType ?? "default") - 8; + } else { + // For insertion, show ghost at the insertion line position with original track height + targetTrackY = this.getTrackYPosition(dragTarget.insertionIndex); + const originalTrack = tracks[this.state.originalTrack]; + targetHeight = getTrackHeight(originalTrack?.primaryAssetType ?? "default") - 8; + } + + // Position and size ghost at snapped target position (shows where clip will land) this.state.ghost.style.left = `${clipTime * pps}px`; - this.state.ghost.style.top = `${ghostY + tracksOffset}px`; + this.state.ghost.style.top = `${targetTrackY + tracksOffset}px`; + this.state.ghost.style.height = `${targetHeight}px`; - // Show timestamp tooltip above ghost - this.showDragTimeTooltip(clipTime, clipTime * pps, ghostY + tracksOffset); + // Show timestamp tooltip near the ghost + this.showDragTimeTooltip(clipTime, clipTime * pps, targetTrackY + tracksOffset); // Show drop zone indicator when over insertion zone if (dragTarget.type === "insert") { @@ -357,18 +401,18 @@ export class InteractionController { private completeDrag(e: PointerEvent): void { if (this.state.type !== "dragging") return; - const { clipRef, ghost, startTime, originalTrack, dragTarget, dragOffsetX } = this.state; + const { clipRef, clipElement, ghost, startTime, originalTrack, dragTarget, dragOffsetX, originalStyles } = this.state; const rect = this.tracksContainer.getBoundingClientRect(); const scrollX = this.tracksContainer.scrollLeft; const pps = this.stateManager.getViewport().pixelsPerSecond; - // Calculate ghost position (mouse minus offset = clip corner position) + // Calculate clip position from mouse (accounting for drag offset) const mouseX = e.clientX - rect.left + scrollX; - const ghostX = mouseX - dragOffsetX; + const clipX = mouseX - dragOffsetX; - // Calculate new clip start time from ghost position - let newTime = Math.max(0, ghostX / pps); + // Calculate new clip start time from clip position + let newTime = Math.max(0, clipX / pps); // Apply snapping const snappedTime = this.applySnap(newTime); @@ -376,6 +420,16 @@ export class InteractionController { newTime = snappedTime; } + // Restore clip element to normal flow before executing command + clipElement.style.position = originalStyles.position; + clipElement.style.left = originalStyles.left; + clipElement.style.top = originalStyles.top; + clipElement.style.zIndex = originalStyles.zIndex; + clipElement.style.pointerEvents = originalStyles.pointerEvents; + clipElement.style.width = ""; + clipElement.style.height = ""; + clipElement.classList.remove("dragging"); + // Execute appropriate command based on drag target if (dragTarget.type === "insert") { // Create new track and move clip to it @@ -402,7 +456,6 @@ export class InteractionController { this.hideSnapLine(); this.hideDropZone(); this.hideDragTimeTooltip(); - this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); this.state = { type: "idle" }; } diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index 0f42ee2a..1b5048f4 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -469,12 +469,15 @@ export const TIMELINE_STYLES = ` 50% { opacity: 1; } } -/* Drag ghost */ +/* Drag ghost - drop target preview */ .ss-drag-ghost { position: absolute; pointer-events: none; - opacity: 0.8; - box-shadow: 0 10px 15px rgba(0, 0, 0, 0.3); + opacity: 0.4; + background: transparent !important; + border: 2px dashed var(--clip-border, #6b7280); + border-left-width: 2px; + box-shadow: none; z-index: 200; } From aee55871302ea9c4ccc346f972ea4c2a17f65d77 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 23:20:00 +1100 Subject: [PATCH 082/463] feat: add collision detection and clip pushing for drag operations --- .../interaction/interaction-controller.ts | 177 ++++++++++++++++-- .../commands/move-clip-with-push-command.ts | 96 ++++++++++ 2 files changed, 253 insertions(+), 20 deletions(-) create mode 100644 src/core/commands/move-clip-with-push-command.ts diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index fa484bbc..4e977a29 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -3,6 +3,7 @@ import type { ClipState, HtmlTimelineInteractionConfig } from "../html-timeline. import { getTrackHeight, TRACK_HEIGHTS } from "../html-timeline.types"; import { TimelineStateManager } from "../core/state/timeline-state"; import { MoveClipCommand } from "@core/commands/move-clip-command"; +import { MoveClipWithPushCommand } from "@core/commands/move-clip-with-push-command"; import { ResizeClipCommand } from "@core/commands/resize-clip-command"; import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command"; @@ -24,6 +25,13 @@ interface SnapPoint { type: "clip-start" | "clip-end" | "playhead"; } +/** Collision resolution result */ +interface CollisionResult { + newStartTime: number; + pushOffset: number; + firstPushedClipIndex: number | null; +} + /** Drag target - either an existing track or an insertion point between tracks */ type DragTarget = | { type: "track"; trackIndex: number } @@ -44,6 +52,8 @@ type InteractionState = dragOffsetX: number; // Pixel offset from clip left edge to mouse dragOffsetY: number; // Pixel offset from clip top to mouse originalStyles: { position: string; left: string; top: string; zIndex: string; pointerEvents: string }; + draggedClipLength: number; // Length of the clip being dragged + collisionResult: CollisionResult; // Current collision resolution } | { type: "resizing"; clipRef: ClipRef; edge: "left" | "right"; originalStart: number; originalLength: number }; @@ -236,7 +246,9 @@ export class InteractionController { dragTarget: { type: "track", trackIndex: clipRef.trackIndex }, dragOffsetX, dragOffsetY, - originalStyles + originalStyles, + draggedClipLength: clip.config.length, + collisionResult: { newStartTime: originalTime, pushOffset: 0, firstPushedClipIndex: null } }; // Position ghost at current clip position initially @@ -301,6 +313,24 @@ export class InteractionController { this.hideSnapLine(); } + // Apply collision detection for track targets + if (dragTarget.type === "track") { + // Calculate mouse time (mouse position in seconds, for left/right half detection) + const mouseTime = mouseX / pps; + const collisionResult = this.resolveClipCollision( + dragTarget.trackIndex, + clipTime, + this.state.draggedClipLength, + this.state.clipRef, + mouseTime + ); + clipTime = collisionResult.newStartTime; + this.state.collisionResult = collisionResult; + } else { + // No collision for insertion targets (new track) + this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0, firstPushedClipIndex: null }; + } + // Get offset for positioning in feedback layer (accounts for ruler height) const tracksOffset = this.getTracksOffsetInFeedbackLayer(); @@ -398,27 +428,13 @@ export class InteractionController { } } - private completeDrag(e: PointerEvent): void { + private completeDrag(_e: PointerEvent): void { if (this.state.type !== "dragging") return; - const { clipRef, clipElement, ghost, startTime, originalTrack, dragTarget, dragOffsetX, originalStyles } = this.state; + const { clipRef, clipElement, ghost, startTime, originalTrack, dragTarget, originalStyles, collisionResult } = this.state; - const rect = this.tracksContainer.getBoundingClientRect(); - const scrollX = this.tracksContainer.scrollLeft; - const pps = this.stateManager.getViewport().pixelsPerSecond; - - // Calculate clip position from mouse (accounting for drag offset) - const mouseX = e.clientX - rect.left + scrollX; - const clipX = mouseX - dragOffsetX; - - // Calculate new clip start time from clip position - let newTime = Math.max(0, clipX / pps); - - // Apply snapping - const snappedTime = this.applySnap(newTime); - if (snappedTime !== null) { - newTime = snappedTime; - } + // Use the collision-resolved time from the last drag move + const newTime = collisionResult.newStartTime; // Restore clip element to normal flow before executing command clipElement.style.position = originalStyles.position; @@ -440,8 +456,19 @@ export class InteractionController { newTime ); this.edit.executeEditCommand(command); + } else if (collisionResult.pushOffset > 0 && collisionResult.firstPushedClipIndex !== null) { + // Need to push clips forward - use MoveClipWithPushCommand + const command = new MoveClipWithPushCommand( + originalTrack, + clipRef.clipIndex, + dragTarget.trackIndex, + newTime, + collisionResult.pushOffset, + collisionResult.firstPushedClipIndex + ); + this.edit.executeEditCommand(command); } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { - // Move to existing track + // Simple move without push const command = new MoveClipCommand( originalTrack, clipRef.clipIndex, @@ -506,6 +533,116 @@ export class InteractionController { this.state = { type: "idle" }; } + /** + * Resolve clip collision when dragging to a target position + * Returns adjusted start time and push offset if clips need to be pushed + * @param mouseTime - Mouse X position in seconds (used to detect left/right half of target clip) + */ + private resolveClipCollision( + trackIndex: number, + desiredStart: number, + clipLength: number, + excludeClip: ClipRef, + mouseTime: number + ): CollisionResult { + const tracks = this.stateManager.getTracks(); + const track = tracks[trackIndex]; + if (!track) { + return { newStartTime: desiredStart, pushOffset: 0, firstPushedClipIndex: null }; + } + + // Get clips on target track, excluding the dragged clip if on same track + const clips = track.clips + .filter(c => !(c.trackIndex === excludeClip.trackIndex && c.clipIndex === excludeClip.clipIndex)) + .sort((a, b) => a.config.start - b.config.start); + + if (clips.length === 0) { + return { newStartTime: desiredStart, pushOffset: 0, firstPushedClipIndex: null }; + } + + const desiredEnd = desiredStart + clipLength; + + // Find the clip that the mouse is hovering over + for (let i = 0; i < clips.length; i += 1) { + const clip = clips[i]; + const clipStart = clip.config.start; + const clipEnd = clipStart + clip.config.length; + + // Check if mouse is over this clip + if (mouseTime >= clipStart && mouseTime < clipEnd) { + const clipMidpoint = clipStart + clip.config.length / 2; + const isRightHalf = mouseTime >= clipMidpoint; + + if (isRightHalf) { + // Snap to RIGHT of this clip + const newStartTime = clipEnd; + const newEndTime = newStartTime + clipLength; + + // Check if there's a next clip that would be overlapped + const nextClip = clips[i + 1]; + if (nextClip && newEndTime > nextClip.config.start) { + // Need to push the next clip forward + const pushAmount = newEndTime - nextClip.config.start; + return { + newStartTime, + pushOffset: pushAmount, + firstPushedClipIndex: nextClip.clipIndex + }; + } + + // No collision after snapping to right + return { newStartTime, pushOffset: 0, firstPushedClipIndex: null }; + } + // Left half - snap to LEFT of this clip + const prevClipEnd = i > 0 ? clips[i - 1].config.start + clips[i - 1].config.length : 0; + const availableSpace = clipStart - prevClipEnd; + + if (availableSpace >= clipLength) { + // Space available - snap to just before this clip + return { + newStartTime: clipStart - clipLength, + pushOffset: 0, + firstPushedClipIndex: null + }; + } + // No space - need to push this clip forward + const newStartTime = prevClipEnd; + const pushAmount = newStartTime + clipLength - clipStart; + return { + newStartTime, + pushOffset: pushAmount, + firstPushedClipIndex: clip.clipIndex + }; + } + + // Check if there would be overlap with desired position (mouse not directly over clip) + if (desiredStart < clipEnd && desiredEnd > clipStart) { + // Calculate available space before this clip + const prevClipEnd = i > 0 ? clips[i - 1].config.start + clips[i - 1].config.length : 0; + const availableSpace = clipStart - prevClipEnd; + + if (availableSpace >= clipLength) { + // Space available - snap to just before this clip + return { + newStartTime: clipStart - clipLength, + pushOffset: 0, + firstPushedClipIndex: null + }; + } + // No space - need to push clips forward + const pushAmount = desiredEnd - clipStart; + return { + newStartTime: desiredStart, + pushOffset: pushAmount, + firstPushedClipIndex: clip.clipIndex + }; + } + } + + // No collision + return { newStartTime: desiredStart, pushOffset: 0, firstPushedClipIndex: null }; + } + private buildSnapPoints(excludeClip: ClipRef): void { this.snapPoints = []; diff --git a/src/core/commands/move-clip-with-push-command.ts b/src/core/commands/move-clip-with-push-command.ts new file mode 100644 index 00000000..c99e99db --- /dev/null +++ b/src/core/commands/move-clip-with-push-command.ts @@ -0,0 +1,96 @@ +import type { Player } from "@canvas/players/player"; + +import type { EditCommand, CommandContext } from "./types"; +import { MoveClipCommand } from "./move-clip-command"; + +/** + * Command to move a clip while pushing other clips forward to make room. + * Used when dropping a clip would overlap with existing clips and there's + * no space available before them. + */ +export class MoveClipWithPushCommand implements EditCommand { + name = "moveClipWithPush"; + private moveCommand: MoveClipCommand; + private pushedClips: Array<{ player: Player; originalStart: number }> = []; + + constructor( + private fromTrackIndex: number, + private fromClipIndex: number, + private toTrackIndex: number, + private newStart: number, + private pushOffset: number, + private firstPushedClipIndex: number + ) { + // The underlying move command handles the clip movement + this.moveCommand = new MoveClipCommand(fromTrackIndex, fromClipIndex, toTrackIndex, newStart); + } + + execute(context?: CommandContext): void { + if (!context) return; + + const tracks = context.getTracks(); + const targetTrack = tracks[this.toTrackIndex]; + if (!targetTrack) return; + + // First, push all clips starting from firstPushedClipIndex forward + this.pushedClips = []; + for (let i = 0; i < targetTrack.length; i += 1) { + const player = targetTrack[i]; + const clipStart = player.clipConfiguration.start; + + // Push clips that start at or after the first pushed clip + // Also need to exclude the clip we're moving if it's on the same track + const isMovingClip = this.fromTrackIndex === this.toTrackIndex && i === this.fromClipIndex; + if (!isMovingClip && i >= this.firstPushedClipIndex) { + // Store original position for undo + this.pushedClips.push({ player, originalStart: clipStart }); + + // Push forward + const newClipStart = clipStart + this.pushOffset; + player.clipConfiguration.start = newClipStart; + player.setResolvedTiming({ + start: newClipStart * 1000, + length: player.getLength() + }); + player.setTimingIntent({ + start: newClipStart, + length: player.getTimingIntent().length + }); + player.reconfigureAfterRestore(); + player.draw(); + } + } + + // Now execute the move command to place the dragged clip + this.moveCommand.execute(context); + + // Propagate timing changes + context.propagateTimingChanges(this.toTrackIndex, 0); + } + + async undo(context?: CommandContext): Promise { + if (!context) return; + + // First, undo the move + await this.moveCommand.undo(context); + + // Then restore all pushed clips to their original positions + for (const { player, originalStart } of this.pushedClips) { + player.clipConfiguration.start = originalStart; + player.setResolvedTiming({ + start: originalStart * 1000, + length: player.getLength() + }); + player.setTimingIntent({ + start: originalStart, + length: player.getTimingIntent().length + }); + player.reconfigureAfterRestore(); + player.draw(); + } + + // Propagate timing changes + context.propagateTimingChanges(this.toTrackIndex, 0); + context.updateDuration(); + } +} From 507e85ec02c57915b37d7adc462dcd600f036e38 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 23:27:19 +1100 Subject: [PATCH 083/463] refactor: remove unused firstPushedClipIndex and simplify collision resolution logic --- .../interaction/interaction-controller.ts | 176 +++++++++--------- .../commands/move-clip-with-push-command.ts | 75 +++----- 2 files changed, 115 insertions(+), 136 deletions(-) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 4e977a29..c1c6a483 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -29,7 +29,6 @@ interface SnapPoint { interface CollisionResult { newStartTime: number; pushOffset: number; - firstPushedClipIndex: number | null; } /** Drag target - either an existing track or an insertion point between tracks */ @@ -248,7 +247,7 @@ export class InteractionController { dragOffsetY, originalStyles, draggedClipLength: clip.config.length, - collisionResult: { newStartTime: originalTime, pushOffset: 0, firstPushedClipIndex: null } + collisionResult: { newStartTime: originalTime, pushOffset: 0 } }; // Position ghost at current clip position initially @@ -328,7 +327,7 @@ export class InteractionController { this.state.collisionResult = collisionResult; } else { // No collision for insertion targets (new track) - this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0, firstPushedClipIndex: null }; + this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0 }; } // Get offset for positioning in feedback layer (accounts for ruler height) @@ -456,15 +455,14 @@ export class InteractionController { newTime ); this.edit.executeEditCommand(command); - } else if (collisionResult.pushOffset > 0 && collisionResult.firstPushedClipIndex !== null) { + } else if (collisionResult.pushOffset > 0) { // Need to push clips forward - use MoveClipWithPushCommand const command = new MoveClipWithPushCommand( originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime, - collisionResult.pushOffset, - collisionResult.firstPushedClipIndex + collisionResult.pushOffset ); this.edit.executeEditCommand(command); } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { @@ -533,114 +531,112 @@ export class InteractionController { this.state = { type: "idle" }; } - /** - * Resolve clip collision when dragging to a target position - * Returns adjusted start time and push offset if clips need to be pushed - * @param mouseTime - Mouse X position in seconds (used to detect left/right half of target clip) - */ - private resolveClipCollision( - trackIndex: number, - desiredStart: number, + /** Default result when no collision detected */ + private static readonly NO_COLLISION: CollisionResult = { + newStartTime: 0, + pushOffset: 0 + }; + + /** Get sorted clips on a track, excluding the dragged clip */ + private getTrackClips(trackIndex: number, excludeClip: ClipRef): ClipState[] { + const track = this.stateManager.getTracks()[trackIndex]; + if (!track) return []; + + return track.clips + .filter(c => !(c.trackIndex === excludeClip.trackIndex && c.clipIndex === excludeClip.clipIndex)) + .sort((a, b) => a.config.start - b.config.start); + } + + /** Find which clip (if any) the mouse is directly over */ + private findClipUnderMouse(clips: ClipState[], mouseTime: number): { clip: ClipState; index: number } | null { + for (let i = 0; i < clips.length; i += 1) { + const clip = clips[i]; + if (mouseTime >= clip.config.start && mouseTime < clip.config.start + clip.config.length) { + return { clip, index: i }; + } + } + return null; + } + + /** Resolve snap position when mouse is over a clip (left/right half logic) */ + private resolveMouseOverSnap( + targetClip: ClipState, + targetIndex: number, clipLength: number, - excludeClip: ClipRef, - mouseTime: number + isRightHalf: boolean, + clips: ClipState[] ): CollisionResult { - const tracks = this.stateManager.getTracks(); - const track = tracks[trackIndex]; - if (!track) { - return { newStartTime: desiredStart, pushOffset: 0, firstPushedClipIndex: null }; + const clipStart = targetClip.config.start; + const clipEnd = clipStart + targetClip.config.length; + + if (isRightHalf) { + // Snap to RIGHT of target clip + const newStartTime = clipEnd; + const newEndTime = newStartTime + clipLength; + const nextClip = clips[targetIndex + 1]; + + if (nextClip && newEndTime > nextClip.config.start) { + return { newStartTime, pushOffset: newEndTime - nextClip.config.start }; + } + return { newStartTime, pushOffset: 0 }; } - // Get clips on target track, excluding the dragged clip if on same track - const clips = track.clips - .filter(c => !(c.trackIndex === excludeClip.trackIndex && c.clipIndex === excludeClip.clipIndex)) - .sort((a, b) => a.config.start - b.config.start); + // Snap to LEFT of target clip + const prevClipEnd = targetIndex > 0 ? clips[targetIndex - 1].config.start + clips[targetIndex - 1].config.length : 0; + const availableSpace = clipStart - prevClipEnd; - if (clips.length === 0) { - return { newStartTime: desiredStart, pushOffset: 0, firstPushedClipIndex: null }; + if (availableSpace >= clipLength) { + return { newStartTime: clipStart - clipLength, pushOffset: 0 }; } + // No space - push target clip forward + const newStartTime = prevClipEnd; + return { newStartTime, pushOffset: newStartTime + clipLength - clipStart }; + } + + /** Resolve collision when dragged clip overlaps another (mouse not directly over any clip) */ + private resolveOverlapCollision(desiredStart: number, clipLength: number, clips: ClipState[]): CollisionResult { const desiredEnd = desiredStart + clipLength; - // Find the clip that the mouse is hovering over for (let i = 0; i < clips.length; i += 1) { const clip = clips[i]; const clipStart = clip.config.start; const clipEnd = clipStart + clip.config.length; - // Check if mouse is over this clip - if (mouseTime >= clipStart && mouseTime < clipEnd) { - const clipMidpoint = clipStart + clip.config.length / 2; - const isRightHalf = mouseTime >= clipMidpoint; - - if (isRightHalf) { - // Snap to RIGHT of this clip - const newStartTime = clipEnd; - const newEndTime = newStartTime + clipLength; - - // Check if there's a next clip that would be overlapped - const nextClip = clips[i + 1]; - if (nextClip && newEndTime > nextClip.config.start) { - // Need to push the next clip forward - const pushAmount = newEndTime - nextClip.config.start; - return { - newStartTime, - pushOffset: pushAmount, - firstPushedClipIndex: nextClip.clipIndex - }; - } - - // No collision after snapping to right - return { newStartTime, pushOffset: 0, firstPushedClipIndex: null }; - } - // Left half - snap to LEFT of this clip + if (desiredStart < clipEnd && desiredEnd > clipStart) { const prevClipEnd = i > 0 ? clips[i - 1].config.start + clips[i - 1].config.length : 0; const availableSpace = clipStart - prevClipEnd; if (availableSpace >= clipLength) { - // Space available - snap to just before this clip - return { - newStartTime: clipStart - clipLength, - pushOffset: 0, - firstPushedClipIndex: null - }; + return { newStartTime: clipStart - clipLength, pushOffset: 0 }; } - // No space - need to push this clip forward - const newStartTime = prevClipEnd; - const pushAmount = newStartTime + clipLength - clipStart; - return { - newStartTime, - pushOffset: pushAmount, - firstPushedClipIndex: clip.clipIndex - }; + return { newStartTime: desiredStart, pushOffset: desiredEnd - clipStart }; } + } + return { newStartTime: desiredStart, pushOffset: 0 }; + } - // Check if there would be overlap with desired position (mouse not directly over clip) - if (desiredStart < clipEnd && desiredEnd > clipStart) { - // Calculate available space before this clip - const prevClipEnd = i > 0 ? clips[i - 1].config.start + clips[i - 1].config.length : 0; - const availableSpace = clipStart - prevClipEnd; + /** Resolve clip collision - orchestrates detection and resolution */ + private resolveClipCollision( + trackIndex: number, + desiredStart: number, + clipLength: number, + excludeClip: ClipRef, + mouseTime: number + ): CollisionResult { + const clips = this.getTrackClips(trackIndex, excludeClip); + if (clips.length === 0) { + return { ...InteractionController.NO_COLLISION, newStartTime: desiredStart }; + } - if (availableSpace >= clipLength) { - // Space available - snap to just before this clip - return { - newStartTime: clipStart - clipLength, - pushOffset: 0, - firstPushedClipIndex: null - }; - } - // No space - need to push clips forward - const pushAmount = desiredEnd - clipStart; - return { - newStartTime: desiredStart, - pushOffset: pushAmount, - firstPushedClipIndex: clip.clipIndex - }; - } + const mouseTarget = this.findClipUnderMouse(clips, mouseTime); + if (mouseTarget) { + const midpoint = mouseTarget.clip.config.start + mouseTarget.clip.config.length / 2; + const isRightHalf = mouseTime >= midpoint; + return this.resolveMouseOverSnap(mouseTarget.clip, mouseTarget.index, clipLength, isRightHalf, clips); } - // No collision - return { newStartTime: desiredStart, pushOffset: 0, firstPushedClipIndex: null }; + return this.resolveOverlapCollision(desiredStart, clipLength, clips); } private buildSnapPoints(excludeClip: ClipRef): void { diff --git a/src/core/commands/move-clip-with-push-command.ts b/src/core/commands/move-clip-with-push-command.ts index c99e99db..820ff932 100644 --- a/src/core/commands/move-clip-with-push-command.ts +++ b/src/core/commands/move-clip-with-push-command.ts @@ -5,8 +5,7 @@ import { MoveClipCommand } from "./move-clip-command"; /** * Command to move a clip while pushing other clips forward to make room. - * Used when dropping a clip would overlap with existing clips and there's - * no space available before them. + * Self-contained: calculates which clips to push based on the move destination. */ export class MoveClipWithPushCommand implements EditCommand { name = "moveClipWithPush"; @@ -18,10 +17,8 @@ export class MoveClipWithPushCommand implements EditCommand { private fromClipIndex: number, private toTrackIndex: number, private newStart: number, - private pushOffset: number, - private firstPushedClipIndex: number + private pushOffset: number ) { - // The underlying move command handles the clip movement this.moveCommand = new MoveClipCommand(fromTrackIndex, fromClipIndex, toTrackIndex, newStart); } @@ -30,66 +27,52 @@ export class MoveClipWithPushCommand implements EditCommand { const tracks = context.getTracks(); const targetTrack = tracks[this.toTrackIndex]; - if (!targetTrack) return; + const sourceTrack = tracks[this.fromTrackIndex]; + if (!targetTrack || !sourceTrack) return; - // First, push all clips starting from firstPushedClipIndex forward + // Get the clip being moved to know its length + const movingClip = sourceTrack[this.fromClipIndex]; + if (!movingClip) return; + + const newEnd = this.newStart + movingClip.clipConfiguration.length; + + // Find and push clips that would overlap with the new position this.pushedClips = []; - for (let i = 0; i < targetTrack.length; i += 1) { - const player = targetTrack[i]; - const clipStart = player.clipConfiguration.start; + for (const player of targetTrack) { + // Skip the clip we're moving + if (player === movingClip) continue; - // Push clips that start at or after the first pushed clip - // Also need to exclude the clip we're moving if it's on the same track - const isMovingClip = this.fromTrackIndex === this.toTrackIndex && i === this.fromClipIndex; - if (!isMovingClip && i >= this.firstPushedClipIndex) { - // Store original position for undo + const clipStart = player.clipConfiguration.start; + // Push clips that start before our new end position and would overlap + if (clipStart >= this.newStart && clipStart < newEnd) { this.pushedClips.push({ player, originalStart: clipStart }); - - // Push forward - const newClipStart = clipStart + this.pushOffset; - player.clipConfiguration.start = newClipStart; - player.setResolvedTiming({ - start: newClipStart * 1000, - length: player.getLength() - }); - player.setTimingIntent({ - start: newClipStart, - length: player.getTimingIntent().length - }); - player.reconfigureAfterRestore(); - player.draw(); + this.pushClip(player, clipStart + this.pushOffset); } } - // Now execute the move command to place the dragged clip + // Execute the move this.moveCommand.execute(context); - - // Propagate timing changes context.propagateTimingChanges(this.toTrackIndex, 0); } + private pushClip(player: Player, newStart: number): void { + player.clipConfiguration.start = newStart; + player.setResolvedTiming({ start: newStart * 1000, length: player.getLength() }); + player.setTimingIntent({ start: newStart, length: player.getTimingIntent().length }); + player.reconfigureAfterRestore(); + player.draw(); + } + async undo(context?: CommandContext): Promise { if (!context) return; - // First, undo the move await this.moveCommand.undo(context); - // Then restore all pushed clips to their original positions + // Restore pushed clips for (const { player, originalStart } of this.pushedClips) { - player.clipConfiguration.start = originalStart; - player.setResolvedTiming({ - start: originalStart * 1000, - length: player.getLength() - }); - player.setTimingIntent({ - start: originalStart, - length: player.getTimingIntent().length - }); - player.reconfigureAfterRestore(); - player.draw(); + this.pushClip(player, originalStart); } - // Propagate timing changes context.propagateTimingChanges(this.toTrackIndex, 0); context.updateDuration(); } From a09e280f1511da197e0746a2fedd09b84538b75c Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 23:38:46 +1100 Subject: [PATCH 084/463] refactor: improve code readability and consistency with formatting and logic simplifications --- .../canvas/players/rich-text-player.ts | 9 +- src/components/canvas/players/video-player.ts | 4 +- .../components/clip/clip-component.ts | 13 ++- .../components/track/track-component.ts | 12 +-- .../components/track/track-list.ts | 7 +- .../core/state/timeline-state.ts | 13 +-- src/components/timeline-html/html-timeline.ts | 25 ++--- .../interaction/interaction-controller.ts | 94 ++++++------------- src/core/commands/move-clip-command.ts | 2 +- .../commands/move-clip-with-push-command.ts | 31 +++--- src/core/commands/set-merge-field-command.ts | 4 +- src/core/edit.ts | 45 ++------- src/core/ui/font-color-picker.ts | 2 +- src/core/ui/media-toolbar.ts | 6 +- src/core/ui/rich-text-toolbar.ts | 30 ++++-- src/main.ts | 15 --- 16 files changed, 112 insertions(+), 200 deletions(-) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 8e1d2a59..99bc20c1 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -49,11 +49,10 @@ export class RichTextPlayer extends Player { // Use explicit font.weight if set, otherwise fall back to parsed weight from family name const explicitWeight = richTextAsset.font?.weight; - const fontWeight = explicitWeight - ? typeof explicitWeight === "string" - ? parseInt(explicitWeight, 10) || parsedWeight - : explicitWeight - : parsedWeight; + let fontWeight = parsedWeight; + if (explicitWeight) { + fontWeight = typeof explicitWeight === "string" ? parseInt(explicitWeight, 10) || parsedWeight : explicitWeight; + } // Find matching timeline font for customFonts payload const timelineFonts = editData?.timeline?.fonts || []; diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index 21496468..fb17a444 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -133,9 +133,7 @@ export class VideoPlayer extends Player { const { src } = videoAsset; if (src.endsWith(".mov")) { - throw new Error( - `Video source '${src}' is not supported. .mov files cannot be played in the browser. Please convert to .webm or .mp4 first.` - ); + throw new Error(`Video source '${src}' is not supported. .mov files cannot be played in the browser. Please convert to .webm or .mp4 first.`); } const loadOptions: pixi.UnresolvedAsset = { src, data: { autoPlay: false, muted: false } }; diff --git a/src/components/timeline-html/components/clip/clip-component.ts b/src/components/timeline-html/components/clip/clip-component.ts index 96b933c6..51907696 100644 --- a/src/components/timeline-html/components/clip/clip-component.ts +++ b/src/components/timeline-html/components/clip/clip-component.ts @@ -1,5 +1,6 @@ -import { TimelineEntity } from "../../core/timeline-entity"; import type { ResolvedClip } from "@schemas/clip"; + +import { TimelineEntity } from "../../core/timeline-entity"; import type { ClipState, ClipRenderer } from "../../html-timeline.types"; export interface ClipComponentOptions { @@ -86,7 +87,7 @@ export class ClipComponent extends TimelineEntity { this.needsUpdate = false; const clip = this.currentState; - const config = clip.config; + const { config } = clip; const assetType = this.getAssetType(config); // Update data attributes @@ -164,13 +165,15 @@ export class ClipComponent extends TimelineEntity { tooltip = "Extends to timeline end"; } + /* eslint-disable no-param-reassign -- Intentional DOM element mutation */ badge.textContent = icon; badge.dataset["intent"] = intent; badge.title = tooltip; + /* eslint-enable no-param-reassign */ } private getAssetType(clip: ResolvedClip): string { - const asset = clip.asset; + const { asset } = clip; if (!asset) return "unknown"; return asset.type || "unknown"; } @@ -191,12 +194,12 @@ export class ClipComponent extends TimelineEntity { } private getClipLabel(clip: ResolvedClip): string { - const asset = clip.asset; + const { asset } = clip; if (!asset) return "Clip"; // Try to get a meaningful label if ("src" in asset && typeof asset.src === "string") { - const src = asset.src; + const { src } = asset; const filename = src.split("/").pop() || src; return filename.split("?")[0]; } diff --git a/src/components/timeline-html/components/track/track-component.ts b/src/components/timeline-html/components/track/track-component.ts index bb4e6c5f..2c22a257 100644 --- a/src/components/timeline-html/components/track/track-component.ts +++ b/src/components/timeline-html/components/track/track-component.ts @@ -124,13 +124,13 @@ export class TrackComponent extends TimelineEntity { public getClipAtPosition(x: number, pixelsPerSecond: number): ClipState | null { for (const component of this.clipComponents.values()) { const state = component.getState(); - if (!state) continue; + if (state) { + const clipStart = state.config.start * pixelsPerSecond; + const clipEnd = (state.config.start + state.config.length) * pixelsPerSecond; - const clipStart = state.config.start * pixelsPerSecond; - const clipEnd = (state.config.start + state.config.length) * pixelsPerSecond; - - if (x >= clipStart && x <= clipEnd) { - return state; + if (x >= clipStart && x <= clipEnd) { + return state; + } } } return null; diff --git a/src/components/timeline-html/components/track/track-list.ts b/src/components/timeline-html/components/track/track-list.ts index 7b6d1ed9..46026b25 100644 --- a/src/components/timeline-html/components/track/track-list.ts +++ b/src/components/timeline-html/components/track/track-list.ts @@ -1,6 +1,7 @@ import { TimelineEntity } from "../../core/timeline-entity"; import type { TrackState, ClipState, ClipRenderer } from "../../html-timeline.types"; import { getTrackHeight } from "../../html-timeline.types"; + import { TrackComponent } from "./track-component"; export interface TrackListOptions { @@ -127,7 +128,7 @@ export class TrackListComponent extends TimelineEntity { // Find track at y position using variable heights let currentY = 0; let trackIndex = -1; - for (let i = 0; i < this.trackComponents.length; i++) { + for (let i = 0; i < this.trackComponents.length; i += 1) { const track = this.trackComponents[i].getCurrentTrack(); const height = getTrackHeight(track?.primaryAssetType ?? "default"); @@ -154,7 +155,7 @@ export class TrackListComponent extends TimelineEntity { const relativeY = y + scrollY; let currentY = 0; - for (let i = 0; i < this.trackComponents.length; i++) { + for (let i = 0; i < this.trackComponents.length; i += 1) { const track = this.trackComponents[i].getCurrentTrack(); const height = getTrackHeight(track?.primaryAssetType ?? "default"); @@ -169,7 +170,7 @@ export class TrackListComponent extends TimelineEntity { /** Get the Y position of a track by index */ public getTrackYPosition(trackIndex: number): number { let y = 0; - for (let i = 0; i < trackIndex && i < this.trackComponents.length; i++) { + for (let i = 0; i < trackIndex && i < this.trackComponents.length; i += 1) { const track = this.trackComponents[i].getCurrentTrack(); y += getTrackHeight(track?.primaryAssetType ?? "default"); } diff --git a/src/components/timeline-html/core/state/timeline-state.ts b/src/components/timeline-html/core/state/timeline-state.ts index 65cc3e34..778a6677 100644 --- a/src/components/timeline-html/core/state/timeline-state.ts +++ b/src/components/timeline-html/core/state/timeline-state.ts @@ -1,6 +1,7 @@ import type { Edit } from "@core/edit"; import type { ResolvedClip } from "@schemas/clip"; import type { ResolvedTrack } from "@schemas/track"; + import type { TrackState, ClipState, ViewportState, PlaybackState } from "../../html-timeline.types"; type ClipVisualState = "normal" | "selected" | "dragging" | "resizing"; @@ -31,13 +32,10 @@ export class TimelineStateManager { if (!resolvedEdit?.timeline?.tracks) return []; return resolvedEdit.timeline.tracks.map((track: ResolvedTrack, trackIndex: number) => { - const clips = (track.clips || []).map((clip: ResolvedClip, clipIndex: number) => - this.createClipState(clip, trackIndex, clipIndex) - ); + const clips = (track.clips || []).map((clip: ResolvedClip, clipIndex: number) => this.createClipState(clip, trackIndex, clipIndex)); // Derive primary asset type from first clip - const primaryAssetType = - clips.length > 0 && clips[0].config.asset ? clips[0].config.asset.type || "unknown" : "empty"; + const primaryAssetType = clips.length > 0 && clips[0].config.asset ? clips[0].config.asset.type || "unknown" : "empty"; return { index: trackIndex, @@ -141,10 +139,7 @@ export class TimelineStateManager { visualState, timingIntent: { start: unresolvedClip?.start === "auto" ? "auto" : clip.start, - length: - unresolvedClip?.length === "auto" || unresolvedClip?.length === "end" - ? unresolvedClip.length - : clip.length + length: unresolvedClip?.length === "auto" || unresolvedClip?.length === "end" ? unresolvedClip.length : clip.length } }; } diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 17f8ac59..9adc41ad 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -1,19 +1,14 @@ import type { Edit } from "@core/edit"; -import { TimelineEntity } from "./core/timeline-entity"; -import { TimelineStateManager } from "./core/state/timeline-state"; -import { TrackListComponent } from "./components/track/track-list"; -import { RulerComponent } from "./components/ruler/ruler-component"; import { PlayheadComponent } from "./components/playhead/playhead-component"; +import { RulerComponent } from "./components/ruler/ruler-component"; import { ToolbarComponent } from "./components/toolbar/toolbar-component"; +import { TrackListComponent } from "./components/track/track-list"; +import { TimelineStateManager } from "./core/state/timeline-state"; +import { TimelineEntity } from "./core/timeline-entity"; +import type { HtmlTimelineOptions, HtmlTimelineFeatures, ClipRenderer, ClipInfo } from "./html-timeline.types"; import { InteractionController } from "./interaction/interaction-controller"; import { getTimelineStyles } from "./styles/timeline.css"; -import type { - HtmlTimelineOptions, - HtmlTimelineFeatures, - ClipRenderer, - ClipInfo -} from "./html-timeline.types"; /** HTML/CSS-based Timeline component extending TimelineEntity for SDK consistency */ export class HtmlTimeline extends TimelineEntity { @@ -347,13 +342,9 @@ export class HtmlTimeline extends TimelineEntity { this.rulerTracksWrapper.appendChild(this.feedbackLayer); // Initialize interaction controller - this.interactionController = new InteractionController( - this.edit, - this.stateManager, - this.trackList.element, - this.feedbackLayer, - { snapThreshold: this.features.snap ? 10 : 0 } - ); + this.interactionController = new InteractionController(this.edit, this.stateManager, this.trackList.element, this.feedbackLayer, { + snapThreshold: this.features.snap ? 10 : 0 + }); } private disposeComponents(): void { diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index c1c6a483..d68cc9ca 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -1,11 +1,12 @@ -import type { Edit } from "@core/edit"; -import type { ClipState, HtmlTimelineInteractionConfig } from "../html-timeline.types"; -import { getTrackHeight, TRACK_HEIGHTS } from "../html-timeline.types"; -import { TimelineStateManager } from "../core/state/timeline-state"; +import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command"; import { MoveClipCommand } from "@core/commands/move-clip-command"; import { MoveClipWithPushCommand } from "@core/commands/move-clip-with-push-command"; import { ResizeClipCommand } from "@core/commands/resize-clip-command"; -import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command"; +import type { Edit } from "@core/edit"; + +import { TimelineStateManager } from "../core/state/timeline-state"; +import type { ClipState, HtmlTimelineInteractionConfig } from "../html-timeline.types"; +import { getTrackHeight } from "../html-timeline.types"; /** Point coordinates */ interface Point { @@ -32,9 +33,7 @@ interface CollisionResult { } /** Drag target - either an existing track or an insertion point between tracks */ -type DragTarget = - | { type: "track"; trackIndex: number } - | { type: "insert"; insertionIndex: number }; +type DragTarget = { type: "track"; trackIndex: number } | { type: "insert"; insertionIndex: number }; /** Interaction state machine */ type InteractionState = @@ -169,6 +168,8 @@ export class InteractionController { case "resizing": this.handleResizeMove(e); break; + default: + break; } } @@ -316,13 +317,7 @@ export class InteractionController { if (dragTarget.type === "track") { // Calculate mouse time (mouse position in seconds, for left/right half detection) const mouseTime = mouseX / pps; - const collisionResult = this.resolveClipCollision( - dragTarget.trackIndex, - clipTime, - this.state.draggedClipLength, - this.state.clipRef, - mouseTime - ); + const collisionResult = this.resolveClipCollision(dragTarget.trackIndex, clipTime, this.state.draggedClipLength, this.state.clipRef, mouseTime); clipTime = collisionResult.newStartTime; this.state.collisionResult = collisionResult; } else { @@ -424,6 +419,8 @@ export class InteractionController { case "resizing": this.completeResize(e); break; + default: + break; } } @@ -448,31 +445,15 @@ export class InteractionController { // Execute appropriate command based on drag target if (dragTarget.type === "insert") { // Create new track and move clip to it - const command = new CreateTrackAndMoveClipCommand( - dragTarget.insertionIndex, - originalTrack, - clipRef.clipIndex, - newTime - ); + const command = new CreateTrackAndMoveClipCommand(dragTarget.insertionIndex, originalTrack, clipRef.clipIndex, newTime); this.edit.executeEditCommand(command); } else if (collisionResult.pushOffset > 0) { // Need to push clips forward - use MoveClipWithPushCommand - const command = new MoveClipWithPushCommand( - originalTrack, - clipRef.clipIndex, - dragTarget.trackIndex, - newTime, - collisionResult.pushOffset - ); + const command = new MoveClipWithPushCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime, collisionResult.pushOffset); this.edit.executeEditCommand(command); } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { // Simple move without push - const command = new MoveClipCommand( - originalTrack, - clipRef.clipIndex, - dragTarget.trackIndex, - newTime - ); + const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); this.edit.executeEditCommand(command); } @@ -514,11 +495,7 @@ export class InteractionController { // Execute resize command if dimensions changed if (newLength !== originalLength) { - const command = new ResizeClipCommand( - clipRef.trackIndex, - clipRef.clipIndex, - newLength - ); + const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); this.edit.executeEditCommand(command); // TODO: For left-edge resize (start changed), also need MoveClipCommand @@ -654,18 +631,17 @@ export class InteractionController { for (const track of tracks) { for (const clip of track.clips) { // Skip the clip being dragged/resized - if (clip.trackIndex === excludeClip.trackIndex && clip.clipIndex === excludeClip.clipIndex) { - continue; + const isExcluded = clip.trackIndex === excludeClip.trackIndex && clip.clipIndex === excludeClip.clipIndex; + if (!isExcluded) { + this.snapPoints.push({ + time: clip.config.start, + type: "clip-start" + }); + this.snapPoints.push({ + time: clip.config.start + clip.config.length, + type: "clip-end" + }); } - - this.snapPoints.push({ - time: clip.config.start, - type: "clip-start" - }); - this.snapPoints.push({ - time: clip.config.start + clip.config.length, - type: "clip-end" - }); } } } @@ -760,20 +736,6 @@ export class InteractionController { } } - /** Get track index at a given Y position (accounting for variable heights) */ - private getTrackIndexAtY(y: number): number { - const tracks = this.stateManager.getTracks(); - let currentY = 0; - for (let i = 0; i < tracks.length; i++) { - const height = getTrackHeight(tracks[i].primaryAssetType); - if (y >= currentY && y < currentY + height) { - return i; - } - currentY += height; - } - return Math.max(0, tracks.length - 1); - } - /** Get drag target at Y position - either an existing track or an insertion point between tracks */ private getDragTargetAtY(y: number): DragTarget { const tracks = this.stateManager.getTracks(); @@ -785,7 +747,7 @@ export class InteractionController { return { type: "insert", insertionIndex: 0 }; } - for (let i = 0; i < tracks.length; i++) { + for (let i = 0; i < tracks.length; i += 1) { const height = getTrackHeight(tracks[i].primaryAssetType); // Top edge insert zone (between this track and previous) @@ -814,7 +776,7 @@ export class InteractionController { private getTrackYPosition(trackIndex: number): number { const tracks = this.stateManager.getTracks(); let y = 0; - for (let i = 0; i < trackIndex && i < tracks.length; i++) { + for (let i = 0; i < trackIndex && i < tracks.length; i += 1) { y += getTrackHeight(tracks[i].primaryAssetType); } return y; diff --git a/src/core/commands/move-clip-command.ts b/src/core/commands/move-clip-command.ts index f077f651..57a564c8 100644 --- a/src/core/commands/move-clip-command.ts +++ b/src/core/commands/move-clip-command.ts @@ -1,8 +1,8 @@ import type { Player } from "@canvas/players/player"; import type { TimingIntent } from "@core/timing/types"; -import type { EditCommand, CommandContext } from "./types"; import { DeleteTrackCommand } from "./delete-track-command"; +import type { EditCommand, CommandContext } from "./types"; export class MoveClipCommand implements EditCommand { name = "moveClip"; diff --git a/src/core/commands/move-clip-with-push-command.ts b/src/core/commands/move-clip-with-push-command.ts index 820ff932..0d57ee9f 100644 --- a/src/core/commands/move-clip-with-push-command.ts +++ b/src/core/commands/move-clip-with-push-command.ts @@ -1,7 +1,7 @@ import type { Player } from "@canvas/players/player"; -import type { EditCommand, CommandContext } from "./types"; import { MoveClipCommand } from "./move-clip-command"; +import type { EditCommand, CommandContext } from "./types"; /** * Command to move a clip while pushing other clips forward to make room. @@ -40,13 +40,13 @@ export class MoveClipWithPushCommand implements EditCommand { this.pushedClips = []; for (const player of targetTrack) { // Skip the clip we're moving - if (player === movingClip) continue; - - const clipStart = player.clipConfiguration.start; - // Push clips that start before our new end position and would overlap - if (clipStart >= this.newStart && clipStart < newEnd) { - this.pushedClips.push({ player, originalStart: clipStart }); - this.pushClip(player, clipStart + this.pushOffset); + if (player !== movingClip) { + const clipStart = player.clipConfiguration.start; + // Push clips that start before our new end position and would overlap + if (clipStart >= this.newStart && clipStart < newEnd) { + this.pushedClips.push({ player, originalStart: clipStart }); + this.updateClipStart(player, clipStart + this.pushOffset); + } } } @@ -55,12 +55,13 @@ export class MoveClipWithPushCommand implements EditCommand { context.propagateTimingChanges(this.toTrackIndex, 0); } - private pushClip(player: Player, newStart: number): void { - player.clipConfiguration.start = newStart; - player.setResolvedTiming({ start: newStart * 1000, length: player.getLength() }); - player.setTimingIntent({ start: newStart, length: player.getTimingIntent().length }); - player.reconfigureAfterRestore(); - player.draw(); + private updateClipStart(clip: Player, newStart: number): void { + // eslint-disable-next-line no-param-reassign -- Intentional mutation of clip state + clip.clipConfiguration.start = newStart; + clip.setResolvedTiming({ start: newStart * 1000, length: clip.getLength() }); + clip.setTimingIntent({ start: newStart, length: clip.getTimingIntent().length }); + clip.reconfigureAfterRestore(); + clip.draw(); } async undo(context?: CommandContext): Promise { @@ -70,7 +71,7 @@ export class MoveClipWithPushCommand implements EditCommand { // Restore pushed clips for (const { player, originalStart } of this.pushedClips) { - this.pushClip(player, originalStart); + this.updateClipStart(player, originalStart); } context.propagateTimingChanges(this.toTrackIndex, 0); diff --git a/src/core/commands/set-merge-field-command.ts b/src/core/commands/set-merge-field-command.ts index 64ba00a8..abb80aab 100644 --- a/src/core/commands/set-merge-field-command.ts +++ b/src/core/commands/set-merge-field-command.ts @@ -83,9 +83,7 @@ export class SetMergeFieldCommand implements EditCommand { setNestedValue(this.clip.clipConfiguration, this.propertyPath, this.storedPreviousValue); // 2. Restore template edit - const templateValue = this.previousFieldName - ? mergeFields.createTemplate(this.previousFieldName) - : this.storedPreviousValue; + const templateValue = this.previousFieldName ? mergeFields.createTemplate(this.previousFieldName) : this.storedPreviousValue; context.setTemplateClipProperty(this.trackIndex, this.clipIndex, this.propertyPath, templateValue); // 3. Re-register previous field or update current (silent to prevent reload) diff --git a/src/core/edit.ts b/src/core/edit.ts index dbcae7e5..7852867b 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -20,7 +20,7 @@ import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command"; import { SplitClipCommand } from "@core/commands/split-clip-command"; import { UpdateTextContentCommand } from "@core/commands/update-text-content-command"; import { EventEmitter } from "@core/events/event-emitter"; -import { applyMergeFields, MergeFieldService, type MergeField } from "@core/merge"; +import { applyMergeFields, MergeFieldService } from "@core/merge"; import { Entity } from "@core/shared/entity"; import { deepMerge, getNestedValue, setNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; @@ -554,11 +554,7 @@ export class Edit extends Entity { // Sync originalEdit - re-insert clip template at same index if (this.originalEdit?.timeline.tracks[trackIdx]?.clips) { - this.originalEdit.timeline.tracks[trackIdx].clips.splice( - insertIdx, - 0, - structuredClone(clip.clipConfiguration) - ); + this.originalEdit.timeline.tracks[trackIdx].clips.splice(insertIdx, 0, structuredClone(clip.clipConfiguration)); } } @@ -600,8 +596,7 @@ export class Edit extends Entity { getTemplateClip: (trackIndex, clipIndex) => this.getTemplateClip(trackIndex, clipIndex), setTemplateClipProperty: (trackIndex, clipIndex, propertyPath, value) => this.setTemplateClipProperty(trackIndex, clipIndex, propertyPath, value), - syncTemplateClip: (trackIndex, clipIndex, templateClip) => - this.syncTemplateClip(trackIndex, clipIndex, templateClip), + syncTemplateClip: (trackIndex, clipIndex, templateClip) => this.syncTemplateClip(trackIndex, clipIndex, templateClip), // originalEdit track sync insertOriginalEditTrack: trackIdx => this.insertOriginalEditTrack(trackIdx), removeOriginalEditTrack: trackIdx => this.removeOriginalEditTrack(trackIdx) @@ -905,9 +900,7 @@ export class Edit extends Entity { // Sync originalEdit with new clip to keep template data aligned with tracks array if (this.originalEdit?.timeline.tracks[trackIdx]) { - this.originalEdit.timeline.tracks[trackIdx].clips.push( - structuredClone(clipToAdd.clipConfiguration) - ); + this.originalEdit.timeline.tracks[trackIdx].clips.push(structuredClone(clipToAdd.clipConfiguration)); } this.clips.push(clipToAdd); @@ -1211,19 +1204,9 @@ export class Edit extends Entity { // Check if there's already a merge field on this property const templateClip = this.getTemplateClip(trackIndex, clipIndex); const templateValue = templateClip ? getNestedValue(templateClip, propertyPath) : null; - const previousFieldName = - typeof templateValue === "string" ? this.mergeFields.extractFieldName(templateValue) : null; + const previousFieldName = typeof templateValue === "string" ? this.mergeFields.extractFieldName(templateValue) : null; - const command = new SetMergeFieldCommand( - player, - propertyPath, - fieldName, - previousFieldName, - previousValue, - value, - trackIndex, - clipIndex - ); + const command = new SetMergeFieldCommand(player, propertyPath, fieldName, previousFieldName, previousValue, value, trackIndex, clipIndex); this.executeCommand(command); } @@ -1242,8 +1225,7 @@ export class Edit extends Entity { // Get current merge field name const templateClip = this.getTemplateClip(trackIndex, clipIndex); const templateValue = templateClip ? getNestedValue(templateClip, propertyPath) : null; - const currentFieldName = - typeof templateValue === "string" ? this.mergeFields.extractFieldName(templateValue) : null; + const currentFieldName = typeof templateValue === "string" ? this.mergeFields.extractFieldName(templateValue) : null; if (!currentFieldName) return; // No merge field to remove @@ -1289,12 +1271,7 @@ export class Edit extends Entity { const templateClip = this.getTemplateClip(trackIdx, clipIdx); if (templateClip) { // Check all string properties for this field - this.updateMergeFieldInObject( - this.tracks[trackIdx][clipIdx].clipConfiguration, - templateClip, - fieldName, - newValue - ); + this.updateMergeFieldInObject(this.tracks[trackIdx][clipIdx].clipConfiguration, templateClip, fieldName, newValue); } } } @@ -1348,11 +1325,7 @@ export class Edit extends Entity { } /** Helper: Check if and how a clip uses a specific merge field */ - private getMergeFieldUsage( - clip: unknown, - fieldName: string, - path: string = "" - ): { used: boolean; isSrcField: boolean } { + private getMergeFieldUsage(clip: unknown, fieldName: string, path: string = ""): { used: boolean; isSrcField: boolean } { if (!clip || typeof clip !== "object") return { used: false, isSrcField: false }; for (const [key, value] of Object.entries(clip as Record)) { diff --git a/src/core/ui/font-color-picker.ts b/src/core/ui/font-color-picker.ts index f42dfd89..1bb3aff6 100644 --- a/src/core/ui/font-color-picker.ts +++ b/src/core/ui/font-color-picker.ts @@ -417,7 +417,7 @@ export class FontColorPicker { this.container.querySelectorAll("[data-cat]").forEach(btn => { btn.addEventListener("click", e => { const el = e.currentTarget as HTMLButtonElement; - this.handleGradientClick(parseInt(el.dataset["cat"] || "0"), parseInt(el.dataset["idx"] || "0")); + this.handleGradientClick(parseInt(el.dataset["cat"] || "0", 10), parseInt(el.dataset["idx"] || "0", 10)); }); }); } diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index a1689b6e..e19adce9 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -828,11 +828,7 @@ export class MediaToolbar { if (!player) return; // Use Edit API to check if this property has a merge field - const fieldName = this.edit.getMergeFieldForProperty( - this.currentTrackIndex, - this.currentClipIndex, - "asset.src" - ); + const fieldName = this.edit.getMergeFieldForProperty(this.currentTrackIndex, this.currentClipIndex, "asset.src"); if (fieldName) { // Has dynamic source diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 78e7e68a..c30d0dea 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -1,5 +1,4 @@ import type { Edit } from "@core/edit"; -import { FONT_PATHS } from "@core/fonts/font-config"; import type { MergeField } from "@core/merge"; import type { ResolvedClip } from "@schemas/clip"; import type { RichTextAsset } from "@schemas/rich-text-asset"; @@ -791,6 +790,7 @@ export class RichTextToolbar { } }); + // eslint-disable-next-line no-param-reassign -- Intentional DOM parent styling parent.style.position = "relative"; parent.insertBefore(this.container, parent.firstChild); @@ -816,7 +816,7 @@ export class RichTextToolbar { const button = target.closest("button"); if (!button) return; - const action = button.dataset["action"]; + const { action } = button.dataset; if (!action) return; const asset = this.getCurrentAsset(); @@ -882,7 +882,11 @@ export class RichTextToolbar { break; case "animation-clear": this.updateClipProperty({ animation: undefined }); - this.animationPopup && (this.animationPopup.style.display = "none"); + if (this.animationPopup) { + this.animationPopup.style.display = "none"; + } + break; + default: break; } } @@ -938,7 +942,7 @@ export class RichTextToolbar { private applyManualSize(): void { if (!this.sizeInput) return; const value = parseInt(this.sizeInput.value, 10); - if (!isNaN(value) && value > 0) { + if (!Number.isNaN(value) && value > 0) { this.updateSize(value); } this.syncState(); @@ -1118,10 +1122,7 @@ export class RichTextToolbar { const isVisible = this.textEditPopup.style.display !== "none"; if (!isVisible && this.textEditArea) { // Read from originalEdit (template) to show merge field placeholders - const templateText = this.edit.getTemplateClipText( - this.selectedTrackIdx, - this.selectedClipIdx - ); + const templateText = this.edit.getTemplateClipText(this.selectedTrackIdx, this.selectedClipIdx); // Fallback to resolved text if no template available const asset = this.getCurrentAsset(); this.textEditArea.value = templateText ?? asset?.text ?? ""; @@ -1657,7 +1658,13 @@ export class RichTextToolbar { const transform = asset.style?.textTransform ?? "none"; if (this.transformBtn) { - this.transformBtn.textContent = transform === "uppercase" ? "AA" : transform === "lowercase" ? "aa" : "Aa"; + let transformLabel = "Aa"; + if (transform === "uppercase") { + transformLabel = "AA"; + } else if (transform === "lowercase") { + transformLabel = "aa"; + } + this.transformBtn.textContent = transformLabel; this.setButtonActive(this.transformBtn, transform !== "none"); } @@ -1740,7 +1747,10 @@ export class RichTextToolbar { if (typeof asset.padding === "number") { // Uniform padding - top = right = bottom = left = asset.padding; + top = asset.padding; + right = asset.padding; + bottom = asset.padding; + left = asset.padding; } else if (asset.padding) { // Object padding top = asset.padding.top ?? 0; diff --git a/src/main.ts b/src/main.ts index 5fed257c..c70163b0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,4 @@ -import { Timeline } from "./components/timeline"; import { HtmlTimeline } from "./components/timeline-html"; -import theme from "./themes/minimal.json"; import { Edit, Canvas, Controls, VideoExporter } from "./index"; @@ -65,19 +63,6 @@ async function main() { // 4. Load the template await edit.loadEdit(template); - // // 5. Initialize the Timeline with size and theme - // const timeline = new Timeline( - // edit, - // { - // width: template.output.size.width, - // height: 300 - // }, - // { - // theme // Uses imported theme from JSON - // } - // ); - // await timeline.load(); // Renders to [data-shotstack-timeline] element - // 5b. Initialize the HTML Timeline (new implementation) const htmlTimelineContainer = document.querySelector("[data-shotstack-timeline]") as HTMLElement; if (htmlTimelineContainer) { From 0cca60a0f75912e1f6f8caf00d2527a1db1e84d3 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 11 Dec 2025 23:54:30 +1100 Subject: [PATCH 085/463] refactor: simplify clip collision detection to use boundary overlap instead of mouse position --- .../interaction/interaction-controller.ts | 81 ++++++++----------- 1 file changed, 33 insertions(+), 48 deletions(-) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index d68cc9ca..88b9dc44 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -315,9 +315,7 @@ export class InteractionController { // Apply collision detection for track targets if (dragTarget.type === "track") { - // Calculate mouse time (mouse position in seconds, for left/right half detection) - const mouseTime = mouseX / pps; - const collisionResult = this.resolveClipCollision(dragTarget.trackIndex, clipTime, this.state.draggedClipLength, this.state.clipRef, mouseTime); + const collisionResult = this.resolveClipCollision(dragTarget.trackIndex, clipTime, this.state.draggedClipLength, this.state.clipRef); clipTime = collisionResult.newStartTime; this.state.collisionResult = collisionResult; } else { @@ -524,31 +522,43 @@ export class InteractionController { .sort((a, b) => a.config.start - b.config.start); } - /** Find which clip (if any) the mouse is directly over */ - private findClipUnderMouse(clips: ClipState[], mouseTime: number): { clip: ClipState; index: number } | null { + /** Find which clip (if any) the dragged clip overlaps */ + private findOverlappingClip( + clips: ClipState[], + desiredStart: number, + clipLength: number + ): { clip: ClipState; index: number } | null { + const desiredEnd = desiredStart + clipLength; for (let i = 0; i < clips.length; i += 1) { const clip = clips[i]; - if (mouseTime >= clip.config.start && mouseTime < clip.config.start + clip.config.length) { + const clipStart = clip.config.start; + const clipEnd = clipStart + clip.config.length; + if (desiredStart < clipEnd && desiredEnd > clipStart) { return { clip, index: i }; } } return null; } - /** Resolve snap position when mouse is over a clip (left/right half logic) */ - private resolveMouseOverSnap( + /** Resolve snap position when dragged clip overlaps another (uses clip centers for direction) */ + private resolveOverlapSnap( targetClip: ClipState, targetIndex: number, + desiredStart: number, clipLength: number, - isRightHalf: boolean, clips: ClipState[] ): CollisionResult { - const clipStart = targetClip.config.start; - const clipEnd = clipStart + targetClip.config.length; + const targetStart = targetClip.config.start; + const targetEnd = targetStart + targetClip.config.length; - if (isRightHalf) { + // Determine snap direction based on dragged clip center vs target clip center + const draggedCenter = desiredStart + clipLength / 2; + const targetCenter = targetStart + targetClip.config.length / 2; + const snapRight = draggedCenter >= targetCenter; + + if (snapRight) { // Snap to RIGHT of target clip - const newStartTime = clipEnd; + const newStartTime = targetEnd; const newEndTime = newStartTime + clipLength; const nextClip = clips[targetIndex + 1]; @@ -560,60 +570,35 @@ export class InteractionController { // Snap to LEFT of target clip const prevClipEnd = targetIndex > 0 ? clips[targetIndex - 1].config.start + clips[targetIndex - 1].config.length : 0; - const availableSpace = clipStart - prevClipEnd; + const availableSpace = targetStart - prevClipEnd; if (availableSpace >= clipLength) { - return { newStartTime: clipStart - clipLength, pushOffset: 0 }; + return { newStartTime: targetStart - clipLength, pushOffset: 0 }; } - // No space - push target clip forward + // No space on left - push target clip forward const newStartTime = prevClipEnd; - return { newStartTime, pushOffset: newStartTime + clipLength - clipStart }; + return { newStartTime, pushOffset: newStartTime + clipLength - targetStart }; } - /** Resolve collision when dragged clip overlaps another (mouse not directly over any clip) */ - private resolveOverlapCollision(desiredStart: number, clipLength: number, clips: ClipState[]): CollisionResult { - const desiredEnd = desiredStart + clipLength; - - for (let i = 0; i < clips.length; i += 1) { - const clip = clips[i]; - const clipStart = clip.config.start; - const clipEnd = clipStart + clip.config.length; - - if (desiredStart < clipEnd && desiredEnd > clipStart) { - const prevClipEnd = i > 0 ? clips[i - 1].config.start + clips[i - 1].config.length : 0; - const availableSpace = clipStart - prevClipEnd; - - if (availableSpace >= clipLength) { - return { newStartTime: clipStart - clipLength, pushOffset: 0 }; - } - return { newStartTime: desiredStart, pushOffset: desiredEnd - clipStart }; - } - } - return { newStartTime: desiredStart, pushOffset: 0 }; - } - - /** Resolve clip collision - orchestrates detection and resolution */ + /** Resolve clip collision based on clip boundaries */ private resolveClipCollision( trackIndex: number, desiredStart: number, clipLength: number, - excludeClip: ClipRef, - mouseTime: number + excludeClip: ClipRef ): CollisionResult { const clips = this.getTrackClips(trackIndex, excludeClip); if (clips.length === 0) { return { ...InteractionController.NO_COLLISION, newStartTime: desiredStart }; } - const mouseTarget = this.findClipUnderMouse(clips, mouseTime); - if (mouseTarget) { - const midpoint = mouseTarget.clip.config.start + mouseTarget.clip.config.length / 2; - const isRightHalf = mouseTime >= midpoint; - return this.resolveMouseOverSnap(mouseTarget.clip, mouseTarget.index, clipLength, isRightHalf, clips); + const overlap = this.findOverlappingClip(clips, desiredStart, clipLength); + if (overlap) { + return this.resolveOverlapSnap(overlap.clip, overlap.index, desiredStart, clipLength, clips); } - return this.resolveOverlapCollision(desiredStart, clipLength, clips); + return { newStartTime: desiredStart, pushOffset: 0 }; } private buildSnapPoints(excludeClip: ClipRef): void { From ab192747e1dfad28a340e474b5f7aa8082d52351 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 00:00:11 +1100 Subject: [PATCH 086/463] refactor: simplify drag ghost positioning logic by consolidating track and insertion cases --- .../interaction/interaction-controller.ts | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 88b9dc44..c76e93fd 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -326,34 +326,25 @@ export class InteractionController { // Get offset for positioning in feedback layer (accounts for ruler height) const tracksOffset = this.getTracksOffsetInFeedbackLayer(); - // Calculate target track Y position and height for the ghost - const tracks = this.stateManager.getTracks(); - let targetTrackY: number; - let targetHeight: number; + // Position ghost and drop zone based on target type if (dragTarget.type === "track") { - targetTrackY = this.getTrackYPosition(dragTarget.trackIndex) + 4; // +4 for clip padding + // Show ghost for track targets + this.state.ghost.style.display = "block"; + const tracks = this.stateManager.getTracks(); + const targetTrackY = this.getTrackYPosition(dragTarget.trackIndex) + 4; // +4 for clip padding const targetTrack = tracks[dragTarget.trackIndex]; - targetHeight = getTrackHeight(targetTrack?.primaryAssetType ?? "default") - 8; - } else { - // For insertion, show ghost at the insertion line position with original track height - targetTrackY = this.getTrackYPosition(dragTarget.insertionIndex); - const originalTrack = tracks[this.state.originalTrack]; - targetHeight = getTrackHeight(originalTrack?.primaryAssetType ?? "default") - 8; - } + const targetHeight = getTrackHeight(targetTrack?.primaryAssetType ?? "default") - 8; - // Position and size ghost at snapped target position (shows where clip will land) - this.state.ghost.style.left = `${clipTime * pps}px`; - this.state.ghost.style.top = `${targetTrackY + tracksOffset}px`; - this.state.ghost.style.height = `${targetHeight}px`; + this.state.ghost.style.left = `${clipTime * pps}px`; + this.state.ghost.style.top = `${targetTrackY + tracksOffset}px`; + this.state.ghost.style.height = `${targetHeight}px`; - // Show timestamp tooltip near the ghost - this.showDragTimeTooltip(clipTime, clipTime * pps, targetTrackY + tracksOffset); - - // Show drop zone indicator when over insertion zone - if (dragTarget.type === "insert") { - this.showDropZone(dragTarget.insertionIndex); - } else { + this.showDragTimeTooltip(clipTime, clipTime * pps, targetTrackY + tracksOffset); this.hideDropZone(); + } else { + // Hide ghost for insertion targets - drop zone indicator is sufficient + this.state.ghost.style.display = "none"; + this.showDropZone(dragTarget.insertionIndex); } } From 192dfc898eafc0dbace087c9cd05bfb9e0fdaa6a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 00:02:28 +1100 Subject: [PATCH 087/463] feat: add wheel scroll support to ruler component --- .../timeline-html/components/ruler/ruler-component.ts | 10 ++++++++++ src/components/timeline-html/html-timeline.ts | 8 +++++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/components/timeline-html/components/ruler/ruler-component.ts b/src/components/timeline-html/components/ruler/ruler-component.ts index 75f1eada..48eae3f6 100644 --- a/src/components/timeline-html/components/ruler/ruler-component.ts +++ b/src/components/timeline-html/components/ruler/ruler-component.ts @@ -2,6 +2,7 @@ import { TimelineEntity } from "../../core/timeline-entity"; interface RulerOptions { onSeek?: (timeMs: number) => void; + onWheel?: (e: WheelEvent) => void; } /** Time ruler component for the timeline */ @@ -22,6 +23,15 @@ export class RulerComponent extends TimelineEntity { private setupClickHandler(): void { this.element.addEventListener("click", this.handleClick.bind(this)); + this.element.addEventListener( + "wheel", + e => { + if (this.options.onWheel) { + this.options.onWheel(e); + } + }, + { passive: true } + ); } private handleClick(e: MouseEvent): void { diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 9adc41ad..1603c4c2 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -294,7 +294,13 @@ export class HtmlTimeline extends TimelineEntity { // Build ruler if (this.features.ruler) { this.ruler = new RulerComponent({ - onSeek: timeMs => this.edit.seek(timeMs) + onSeek: timeMs => this.edit.seek(timeMs), + onWheel: e => { + if (this.trackList) { + this.trackList.element.scrollTop += e.deltaY; + this.trackList.element.scrollLeft += e.deltaX; + } + } }); this.rulerTracksWrapper.appendChild(this.ruler.element); } From e242e8f0b1b45ea42921a75c821ccfa25b5ad9b3 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 00:06:43 +1100 Subject: [PATCH 088/463] feat: add playhead ghost hover preview --- src/components/timeline-html/html-timeline.ts | 14 ++++++++++++++ .../timeline-html/styles/timeline.css.ts | 17 +++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 1603c4c2..c48c4746 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -27,6 +27,7 @@ export class HtmlTimeline extends TimelineEntity { private ruler: RulerComponent | null = null; private trackList: TrackListComponent | null = null; private playhead: PlayheadComponent | null = null; + private playheadGhost: HTMLElement | null = null; private feedbackLayer: HTMLElement | null = null; private interactionController: InteractionController | null = null; @@ -340,6 +341,19 @@ export class HtmlTimeline extends TimelineEntity { }); this.playhead.setPixelsPerSecond(viewport.pixelsPerSecond); this.rulerTracksWrapper.appendChild(this.playhead.element); + + // Build playhead ghost (hover preview) + this.playheadGhost = document.createElement("div"); + this.playheadGhost.className = "ss-playhead-ghost"; + this.rulerTracksWrapper.appendChild(this.playheadGhost); + + this.rulerTracksWrapper.addEventListener("mousemove", e => { + if (!this.playheadGhost || !this.rulerTracksWrapper) return; + const rect = this.rulerTracksWrapper.getBoundingClientRect(); + const scrollX = this.trackList?.element.scrollLeft ?? 0; + const x = e.clientX - rect.left + scrollX; + this.playheadGhost.style.left = `${x}px`; + }); } // Build feedback layer (inside rulerTracksWrapper so coordinates align with tracks) diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index 1b5048f4..e9c1299d 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -432,6 +432,23 @@ export const TIMELINE_STYLES = ` cursor: grabbing; } +/* Playhead ghost (hover preview) */ +.ss-playhead-ghost { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: rgba(59, 130, 246, 0.4); + pointer-events: none; + z-index: 49; + opacity: 0; + transition: opacity 0.15s; +} + +.ss-ruler-tracks-wrapper:hover .ss-playhead-ghost { + opacity: 1; +} + /* Feedback layer */ .ss-feedback-layer { position: absolute; From f4ed3848af1bf101b39cd10b3afc15df3c10a798 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 00:16:18 +1100 Subject: [PATCH 089/463] feat: add copy-paste functionality for clips --- src/core/edit.ts | 35 +++++++++++++++++++++++++++++++++++ src/core/inputs/controls.ts | 17 +++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/src/core/edit.ts b/src/core/edit.ts index 7852867b..ce2c863c 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -63,6 +63,8 @@ export class Edit extends Entity { /** @internal */ private selectedClip: Player | null; /** @internal */ + private copiedClip: { trackIndex: number; clipConfiguration: ResolvedClip } | null = null; + /** @internal */ private updatedClip: Player | null; /** @internal */ private viewportMask?: pixi.Graphics; @@ -1007,6 +1009,39 @@ export class Edit extends Entity { return { trackIndex, clipIndex, player: this.selectedClip }; } + + /** + * Copy a clip to the internal clipboard + */ + public copyClip(trackIdx: number, clipIdx: number): void { + const player = this.getClipAt(trackIdx, clipIdx); + if (player) { + this.copiedClip = { + trackIndex: trackIdx, + clipConfiguration: structuredClone(player.clipConfiguration) + }; + this.events.emit("clip:copied", { trackIndex: trackIdx, clipIndex: clipIdx }); + } + } + + /** + * Paste the copied clip at the current playhead position + */ + public pasteClip(): void { + if (!this.copiedClip) return; + + const pastedClip = structuredClone(this.copiedClip.clipConfiguration); + pastedClip.start = this.playbackTime / 1000; // Paste at playhead position + + this.addClip(this.copiedClip.trackIndex, pastedClip); + } + + /** + * Check if there is a clip in the clipboard + */ + public hasCopiedClip(): boolean { + return this.copiedClip !== null; + } public findClipIndices(player: Player): { trackIndex: number; clipIndex: number } | null { for (let trackIndex = 0; trackIndex < this.tracks.length; trackIndex += 1) { const clipIndex = this.tracks[trackIndex].indexOf(player); diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index d1a2af82..59e585b4 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -96,6 +96,23 @@ export class Controls { } break; } + case "KeyC": { + if (event.metaKey || event.ctrlKey) { + event.preventDefault(); + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + this.edit.copyClip(selected.trackIndex, selected.clipIndex); + } + } + break; + } + case "KeyV": { + if (event.metaKey || event.ctrlKey) { + event.preventDefault(); + this.edit.pasteClip(); + } + break; + } default: { break; } From cf917f395468e140de734d0b5f45056241caa733 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 00:21:16 +1100 Subject: [PATCH 090/463] feat: skip collision detection for luma assets to allow overlaying --- .../interaction/interaction-controller.ts | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index c76e93fd..3070a4db 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -313,11 +313,19 @@ export class InteractionController { this.hideSnapLine(); } - // Apply collision detection for track targets + // Apply collision detection for track targets (skip for luma assets - they overlay) if (dragTarget.type === "track") { - const collisionResult = this.resolveClipCollision(dragTarget.trackIndex, clipTime, this.state.draggedClipLength, this.state.clipRef); - clipTime = collisionResult.newStartTime; - this.state.collisionResult = collisionResult; + const draggedClip = this.stateManager.getClipAt(this.state.clipRef.trackIndex, this.state.clipRef.clipIndex); + const draggedAssetType = draggedClip?.config.asset?.type; + + if (draggedAssetType === "luma") { + // Luma assets can overlay other clips - skip collision detection + this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0 }; + } else { + const collisionResult = this.resolveClipCollision(dragTarget.trackIndex, clipTime, this.state.draggedClipLength, this.state.clipRef); + clipTime = collisionResult.newStartTime; + this.state.collisionResult = collisionResult; + } } else { // No collision for insertion targets (new track) this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0 }; @@ -586,6 +594,10 @@ export class InteractionController { const overlap = this.findOverlappingClip(clips, desiredStart, clipLength); if (overlap) { + // Skip collision for luma assets - they should be overlayable + if (overlap.clip.config.asset?.type === "luma") { + return { newStartTime: desiredStart, pushOffset: 0 }; + } return this.resolveOverlapSnap(overlap.clip, overlap.index, desiredStart, clipLength, clips); } From 448194261b2bb7440e5758c12ef3e280012af6d7 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 09:23:02 +1100 Subject: [PATCH 091/463] feat: add luma mask attachment and visibility toggling for timeline clips --- .../components/clip/clip-component.ts | 43 ++++++- .../components/track/track-component.ts | 76 ++++++++++-- .../components/track/track-list.ts | 17 ++- .../core/state/timeline-state.ts | 117 ++++++++++++++++++ src/components/timeline-html/html-timeline.ts | 20 ++- .../interaction/interaction-controller.ts | 113 ++++++++++++++++- .../timeline-html/styles/timeline.css.ts | 23 ++++ 7 files changed, 388 insertions(+), 21 deletions(-) diff --git a/src/components/timeline-html/components/clip/clip-component.ts b/src/components/timeline-html/components/clip/clip-component.ts index 51907696..040ff148 100644 --- a/src/components/timeline-html/components/clip/clip-component.ts +++ b/src/components/timeline-html/components/clip/clip-component.ts @@ -3,16 +3,28 @@ import type { ResolvedClip } from "@schemas/clip"; import { TimelineEntity } from "../../core/timeline-entity"; import type { ClipState, ClipRenderer } from "../../html-timeline.types"; +/** Reference to an attached luma clip */ +interface LumaRef { + trackIndex: number; + clipIndex: number; +} + export interface ClipComponentOptions { showBadges: boolean; onSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void; getRenderer: (type: string) => ClipRenderer | undefined; + /** Reference to attached luma (if this clip has a mask) */ + attachedLuma?: LumaRef; + /** Callback when mask badge is clicked - passes the CONTENT clip indices */ + onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void; } /** Renders a single clip element */ export class ClipComponent extends TimelineEntity { private readonly options: ClipComponentOptions; private currentState: ClipState | null = null; + private currentLumaRef: LumaRef | undefined = undefined; + private maskBadge: HTMLElement | null = null; private needsUpdate = true; constructor(clip: ClipState, options: ClipComponentOptions) { @@ -124,6 +136,9 @@ export class ClipComponent extends TimelineEntity { } } + // Update mask badge (show if clip has attached luma) + this.updateMaskBadge(); + // Apply custom renderer if available const renderer = this.options.getRenderer(assetType); if (renderer) { @@ -131,6 +146,31 @@ export class ClipComponent extends TimelineEntity { } } + /** Show/hide mask badge based on attached luma */ + private updateMaskBadge(): void { + if (this.currentLumaRef && this.currentState) { + // Create badge if it doesn't exist + if (!this.maskBadge) { + this.maskBadge = document.createElement("div"); + this.maskBadge.className = "ss-clip-mask-badge"; + this.maskBadge.textContent = "◐"; + this.maskBadge.title = "Luma mask attached - click to toggle"; + this.maskBadge.addEventListener("click", e => { + e.stopPropagation(); + // Pass the CONTENT clip indices (this clip), not the luma indices + if (this.currentState && this.options.onMaskClick) { + this.options.onMaskClick(this.currentState.trackIndex, this.currentState.clipIndex); + } + }); + this.element.appendChild(this.maskBadge); + } + this.maskBadge.style.display = "flex"; + } else if (this.maskBadge) { + // Hide badge if no luma attached + this.maskBadge.style.display = "none"; + } + } + public dispose(): void { // Call dispose on custom renderer if exists if (this.currentState) { @@ -145,8 +185,9 @@ export class ClipComponent extends TimelineEntity { } /** Update clip state and mark for re-render */ - public updateClip(clip: ClipState): void { + public updateClip(clip: ClipState, attachedLuma?: LumaRef): void { this.currentState = clip; + this.currentLumaRef = attachedLuma; this.needsUpdate = true; } diff --git a/src/components/timeline-html/components/track/track-component.ts b/src/components/timeline-html/components/track/track-component.ts index 2c22a257..75efb4ed 100644 --- a/src/components/timeline-html/components/track/track-component.ts +++ b/src/components/timeline-html/components/track/track-component.ts @@ -7,6 +7,16 @@ export interface TrackComponentOptions { showBadges: boolean; onClipSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void; getClipRenderer: (type: string) => ClipRenderer | undefined; + /** Check if a luma clip is attached (and should be hidden) */ + isLumaAttached?: (trackIndex: number, clipIndex: number) => boolean; + /** Get attached luma for a content clip (to show badge) */ + getAttachedLuma?: (trackIndex: number, clipIndex: number) => { trackIndex: number; clipIndex: number } | null; + /** Callback when mask badge is clicked on a content clip */ + onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void; + /** Check if attached luma is currently visible for editing */ + isLumaVisibleForEditing?: (contentTrackIndex: number, contentClipIndex: number) => boolean; + /** Get the content clip that a luma is attached to */ + getContentClipForLuma?: (lumaTrack: number, lumaClip: number) => { trackIndex: number; clipIndex: number } | null; } /** Renders a single track with its clips */ @@ -54,20 +64,60 @@ export class TrackComponent extends TimelineEntity { // Update or create clips for (const clipState of track.clips) { - processedIds.add(clipState.id); - - let clipComponent = this.clipComponents.get(clipState.id); - if (!clipComponent) { - clipComponent = new ClipComponent(clipState, { - showBadges: this.options.showBadges, - onSelect: this.options.onClipSelect, - getRenderer: this.options.getClipRenderer - }); - this.clipComponents.set(clipState.id, clipComponent); - this.element.appendChild(clipComponent.element); - } + // Check if this is an attached luma clip + const isLumaClip = clipState.config.asset?.type === "luma"; + const isAttachedLuma = isLumaClip && this.options.isLumaAttached?.(clipState.trackIndex, clipState.clipIndex); + + if (isAttachedLuma) { + // Check if it should be visible for editing + const contentClip = this.options.getContentClipForLuma?.(clipState.trackIndex, clipState.clipIndex); + const isVisibleForEditing = contentClip && this.options.isLumaVisibleForEditing?.(contentClip.trackIndex, contentClip.clipIndex); + + if (isVisibleForEditing) { + // Render the luma clip (it's visible for editing) + processedIds.add(clipState.id); + + let clipComponent = this.clipComponents.get(clipState.id); + if (!clipComponent) { + clipComponent = new ClipComponent(clipState, { + showBadges: this.options.showBadges, + onSelect: this.options.onClipSelect, + getRenderer: this.options.getClipRenderer + }); + this.clipComponents.set(clipState.id, clipComponent); + this.element.appendChild(clipComponent.element); + } + clipComponent.updateClip(clipState, undefined); + } else { + // Hide attached luma - remove clip component if it exists + const existingComponent = this.clipComponents.get(clipState.id); + if (existingComponent) { + existingComponent.dispose(); + this.clipComponents.delete(clipState.id); + } + } + } else { + // Normal clip rendering (non-luma or unattached luma) + processedIds.add(clipState.id); + + // Check if this content clip has an attached luma (for badge display) + const attachedLuma = this.options.getAttachedLuma?.(clipState.trackIndex, clipState.clipIndex); + + let clipComponent = this.clipComponents.get(clipState.id); + if (!clipComponent) { + clipComponent = new ClipComponent(clipState, { + showBadges: this.options.showBadges, + onSelect: this.options.onClipSelect, + getRenderer: this.options.getClipRenderer, + attachedLuma: attachedLuma ?? undefined, + onMaskClick: this.options.onMaskClick + }); + this.clipComponents.set(clipState.id, clipComponent); + this.element.appendChild(clipComponent.element); + } - clipComponent.updateClip(clipState); + clipComponent.updateClip(clipState, attachedLuma ?? undefined); + } } // Remove clips that no longer exist diff --git a/src/components/timeline-html/components/track/track-list.ts b/src/components/timeline-html/components/track/track-list.ts index 46026b25..56de9582 100644 --- a/src/components/timeline-html/components/track/track-list.ts +++ b/src/components/timeline-html/components/track/track-list.ts @@ -8,6 +8,16 @@ export interface TrackListOptions { showBadges: boolean; onClipSelect: (trackIndex: number, clipIndex: number, addToSelection: boolean) => void; getClipRenderer: (type: string) => ClipRenderer | undefined; + /** Check if a luma clip is attached (and should be hidden) */ + isLumaAttached?: (trackIndex: number, clipIndex: number) => boolean; + /** Get attached luma for a content clip (to show badge) */ + getAttachedLuma?: (trackIndex: number, clipIndex: number) => { trackIndex: number; clipIndex: number } | null; + /** Callback when mask badge is clicked on a content clip */ + onMaskClick?: (contentTrackIndex: number, contentClipIndex: number) => void; + /** Check if attached luma is currently visible for editing */ + isLumaVisibleForEditing?: (contentTrackIndex: number, contentClipIndex: number) => boolean; + /** Get the content clip that a luma is attached to */ + getContentClipForLuma?: (lumaTrack: number, lumaClip: number) => { trackIndex: number; clipIndex: number } | null; } /** Container for all track components with virtualization support */ @@ -77,7 +87,12 @@ export class TrackListComponent extends TimelineEntity { const trackComponent = new TrackComponent(trackIndex, { showBadges: this.options.showBadges, onClipSelect: this.options.onClipSelect, - getClipRenderer: this.options.getClipRenderer + getClipRenderer: this.options.getClipRenderer, + isLumaAttached: this.options.isLumaAttached, + getAttachedLuma: this.options.getAttachedLuma, + onMaskClick: this.options.onMaskClick, + isLumaVisibleForEditing: this.options.isLumaVisibleForEditing, + getContentClipForLuma: this.options.getContentClipForLuma }); this.trackComponents.push(trackComponent); this.contentElement.appendChild(trackComponent.element); diff --git a/src/components/timeline-html/core/state/timeline-state.ts b/src/components/timeline-html/core/state/timeline-state.ts index 778a6677..a1ef13d6 100644 --- a/src/components/timeline-html/core/state/timeline-state.ts +++ b/src/components/timeline-html/core/state/timeline-state.ts @@ -6,12 +6,24 @@ import type { TrackState, ClipState, ViewportState, PlaybackState } from "../../ type ClipVisualState = "normal" | "selected" | "dragging" | "resizing"; +/** Clip reference for luma attachments */ +interface LumaAttachmentRef { + trackIndex: number; + clipIndex: number; +} + /** Simplified state manager - only holds UI state, derives data from Edit */ export class TimelineStateManager { // UI-only state (not in Edit) private viewport: ViewportState; private clipVisualStates = new Map(); + // Luma attachment map: key = "trackIndex:clipIndex" of content clip, value = luma clip reference + private lumaAttachments = new Map(); + + // Track which attached lumas are currently visible for editing + private lumaEditingVisible = new Set(); + constructor( private readonly edit: Edit, initialViewport: Partial = {} @@ -118,8 +130,113 @@ export class TimelineStateManager { return Math.max(this.getExtendedDuration() * this.viewport.pixelsPerSecond, this.viewport.width); } + // ========== Luma Attachments ========== + + /** Attach a luma clip to a content clip */ + public attachLuma(contentTrack: number, contentClip: number, lumaTrack: number, lumaClip: number): void { + const key = `${contentTrack}:${contentClip}`; + this.lumaAttachments.set(key, { trackIndex: lumaTrack, clipIndex: lumaClip }); + } + + /** Detach luma from a content clip */ + public detachLuma(contentTrack: number, contentClip: number): void { + const key = `${contentTrack}:${contentClip}`; + this.lumaAttachments.delete(key); + } + + /** Get attached luma for a content clip */ + public getAttachedLuma(trackIndex: number, clipIndex: number): LumaAttachmentRef | null { + return this.lumaAttachments.get(`${trackIndex}:${clipIndex}`) ?? null; + } + + /** Check if a luma clip is attached to any content clip */ + public isLumaAttached(lumaTrack: number, lumaClip: number): boolean { + for (const ref of this.lumaAttachments.values()) { + if (ref.trackIndex === lumaTrack && ref.clipIndex === lumaClip) return true; + } + return false; + } + + /** Get the content clip that a luma is attached to */ + public getContentClipForLuma(lumaTrack: number, lumaClip: number): LumaAttachmentRef | null { + for (const [key, ref] of this.lumaAttachments.entries()) { + if (ref.trackIndex === lumaTrack && ref.clipIndex === lumaClip) { + const [track, clip] = key.split(":").map(Number); + return { trackIndex: track, clipIndex: clip }; + } + } + return null; + } + + /** Clear all luma attachments */ + public clearLumaAttachments(): void { + this.lumaAttachments.clear(); + this.lumaEditingVisible.clear(); + } + + /** Toggle visibility of attached luma for editing */ + public toggleLumaVisibility(contentTrack: number, contentClip: number): boolean { + const key = `${contentTrack}:${contentClip}`; + if (this.lumaEditingVisible.has(key)) { + this.lumaEditingVisible.delete(key); + return false; // Now hidden + } + this.lumaEditingVisible.add(key); + return true; // Now visible + } + + /** Check if attached luma is currently visible for editing */ + public isLumaVisibleForEditing(contentTrack: number, contentClip: number): boolean { + return this.lumaEditingVisible.has(`${contentTrack}:${contentClip}`); + } + + /** Auto-detect and register luma attachments based on clip overlap */ + public detectAndAttachLumas(): void { + // Preserve existing visibility states for attachments that still exist + const previousVisibility = new Set(this.lumaEditingVisible); + + // Clear existing attachments (will re-detect) + this.lumaAttachments.clear(); + this.lumaEditingVisible.clear(); + + const tracks = this.getTracks(); + + // Find all luma clips and attach them to overlapping content clips + for (const track of tracks) { + for (const clip of track.clips) { + if (clip.config.asset?.type === "luma") { + const contentClip = this.findContentClipInSameTrack(clip, tracks); + if (contentClip) { + const key = `${contentClip.trackIndex}:${contentClip.clipIndex}`; + this.attachLuma(contentClip.trackIndex, contentClip.clipIndex, clip.trackIndex, clip.clipIndex); + + // Restore visibility state if it existed before + if (previousVisibility.has(key)) { + this.lumaEditingVisible.add(key); + } + } + } + } + } + } + + /** Find the content clip in the same track as the luma clip */ + private findContentClipInSameTrack(lumaClip: ClipState, tracks: TrackState[]): ClipState | null { + const lumaTrack = tracks[lumaClip.trackIndex]; + if (!lumaTrack) return null; + + for (const clip of lumaTrack.clips) { + if (clip.config.asset?.type !== "luma") { + return clip; + } + } + return null; + } + public dispose(): void { this.clipVisualStates.clear(); + this.lumaAttachments.clear(); + this.lumaEditingVisible.clear(); } // ========== Private ========== diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index c48c4746..642e19ca 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -78,7 +78,11 @@ export class HtmlTimeline extends TimelineEntity { }); // Bind event handlers - this.handleTimelineUpdated = () => this.requestRender(); + this.handleTimelineUpdated = () => { + // Re-detect luma attachments in case clips were added/removed/moved + this.stateManager.detectAndAttachLumas(); + this.requestRender(); + }; this.handlePlaybackPlay = () => this.startRenderLoop(); this.handlePlaybackPause = () => { this.stopRenderLoop(); @@ -318,7 +322,16 @@ export class HtmlTimeline extends TimelineEntity { this.edit.selectClip(trackIndex, clipIndex); this.requestRender(); }, - getClipRenderer: type => this.clipRenderers.get(type) + getClipRenderer: type => this.clipRenderers.get(type), + isLumaAttached: (trackIndex, clipIndex) => this.stateManager.isLumaAttached(trackIndex, clipIndex), + getAttachedLuma: (trackIndex, clipIndex) => this.stateManager.getAttachedLuma(trackIndex, clipIndex), + onMaskClick: (contentTrackIndex, contentClipIndex) => { + this.stateManager.toggleLumaVisibility(contentTrackIndex, contentClipIndex); + this.requestRender(); + }, + isLumaVisibleForEditing: (contentTrackIndex, contentClipIndex) => + this.stateManager.isLumaVisibleForEditing(contentTrackIndex, contentClipIndex), + getContentClipForLuma: (lumaTrack, lumaClip) => this.stateManager.getContentClipForLuma(lumaTrack, lumaClip) }); // Set up scroll sync (also sync playhead) @@ -365,6 +378,9 @@ export class HtmlTimeline extends TimelineEntity { this.interactionController = new InteractionController(this.edit, this.stateManager, this.trackList.element, this.feedbackLayer, { snapThreshold: this.features.snap ? 10 : 0 }); + + // Auto-detect luma attachments from existing clips (e.g., on template load) + this.stateManager.detectAndAttachLumas(); } private disposeComponents(): void { diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 3070a4db..30668306 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -426,9 +426,6 @@ export class InteractionController { const { clipRef, clipElement, ghost, startTime, originalTrack, dragTarget, originalStyles, collisionResult } = this.state; - // Use the collision-resolved time from the last drag move - const newTime = collisionResult.newStartTime; - // Restore clip element to normal flow before executing command clipElement.style.position = originalStyles.position; clipElement.style.left = originalStyles.left; @@ -439,19 +436,100 @@ export class InteractionController { clipElement.style.height = ""; clipElement.classList.remove("dragging"); - // Execute appropriate command based on drag target + // Get dragged clip's asset type + const draggedClip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + const draggedAssetType = draggedClip?.config.asset?.type; + + // Use the collision-resolved time from the last drag move + let newTime = collisionResult.newStartTime; + + // Handle luma clip drop - must attach to a content clip + if (draggedAssetType === "luma" && dragTarget.type === "track") { + const targetContentClip = this.findContentClipAtPosition(dragTarget.trackIndex, newTime); + + if (!targetContentClip) { + // No valid target content clip - cancel drop + ghost.remove(); + this.hideSnapLine(); + this.hideDropZone(); + this.hideDragTimeTooltip(); + this.state = { type: "idle" }; + return; + } + + // Snap luma timing to content clip + newTime = targetContentClip.config.start; + + // Move luma to target position + if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { + const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(command); + } + + // Register attachment in state manager + this.stateManager.attachLuma( + targetContentClip.trackIndex, + targetContentClip.clipIndex, + dragTarget.trackIndex, + clipRef.clipIndex + ); + + // Cleanup and return early + ghost.remove(); + this.hideSnapLine(); + this.hideDropZone(); + this.hideDragTimeTooltip(); + this.state = { type: "idle" }; + return; + } + + // Check if this content clip has an attached luma that needs to sync + const attachedLuma = this.stateManager.getAttachedLuma(clipRef.trackIndex, clipRef.clipIndex); + + // Execute appropriate command based on drag target (non-luma clips) if (dragTarget.type === "insert") { // Create new track and move clip to it const command = new CreateTrackAndMoveClipCommand(dragTarget.insertionIndex, originalTrack, clipRef.clipIndex, newTime); this.edit.executeEditCommand(command); + + // Also move attached luma to the new track and time + if (attachedLuma) { + const lumaCommand = new MoveClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, dragTarget.insertionIndex, newTime); + this.edit.executeEditCommand(lumaCommand); + // Update attachment reference to new track + this.stateManager.detachLuma(clipRef.trackIndex, clipRef.clipIndex); + this.stateManager.attachLuma(dragTarget.insertionIndex, clipRef.clipIndex, dragTarget.insertionIndex, attachedLuma.clipIndex); + } } else if (collisionResult.pushOffset > 0) { // Need to push clips forward - use MoveClipWithPushCommand const command = new MoveClipWithPushCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime, collisionResult.pushOffset); this.edit.executeEditCommand(command); + + // Also move attached luma to new position + if (attachedLuma) { + const lumaCommand = new MoveClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(lumaCommand); + // Update attachment reference if track changed + if (dragTarget.trackIndex !== originalTrack) { + this.stateManager.detachLuma(clipRef.trackIndex, clipRef.clipIndex); + this.stateManager.attachLuma(dragTarget.trackIndex, clipRef.clipIndex, dragTarget.trackIndex, attachedLuma.clipIndex); + } + } } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { // Simple move without push const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); this.edit.executeEditCommand(command); + + // Also move attached luma to new position + if (attachedLuma) { + const lumaCommand = new MoveClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(lumaCommand); + // Update attachment reference if track changed + if (dragTarget.trackIndex !== originalTrack) { + this.stateManager.detachLuma(clipRef.trackIndex, clipRef.clipIndex); + this.stateManager.attachLuma(dragTarget.trackIndex, clipRef.clipIndex, dragTarget.trackIndex, attachedLuma.clipIndex); + } + } } // Cleanup @@ -495,6 +573,13 @@ export class InteractionController { const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); this.edit.executeEditCommand(command); + // Also resize attached luma to match + const attachedLuma = this.stateManager.getAttachedLuma(clipRef.trackIndex, clipRef.clipIndex); + if (attachedLuma) { + const lumaResizeCommand = new ResizeClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, newLength); + this.edit.executeEditCommand(lumaResizeCommand); + } + // TODO: For left-edge resize (start changed), also need MoveClipCommand // Currently ResizeClipCommand only handles length changes } @@ -604,6 +689,26 @@ export class InteractionController { return { newStartTime: desiredStart, pushOffset: 0 }; } + /** Find a non-luma content clip at the given position on a track */ + private findContentClipAtPosition(trackIndex: number, time: number): ClipState | null { + const track = this.stateManager.getTracks()[trackIndex]; + if (!track) return null; + + for (const clip of track.clips) { + // Only consider non-luma content clips + if (clip.config.asset?.type !== "luma") { + const clipStart = clip.config.start; + const clipEnd = clipStart + clip.config.length; + + // Check if time falls within this clip + if (time >= clipStart && time < clipEnd) { + return clip; + } + } + } + return null; + } + private buildSnapPoints(excludeClip: ClipRef): void { this.snapPoints = []; diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline-html/styles/timeline.css.ts index e9c1299d..adff4f49 100644 --- a/src/components/timeline-html/styles/timeline.css.ts +++ b/src/components/timeline-html/styles/timeline.css.ts @@ -358,6 +358,29 @@ export const TIMELINE_STYLES = ` display: none; } +/* Mask badge (for clips with attached luma) */ +.ss-clip-mask-badge { + position: absolute; + top: 4px; + left: 4px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + border-radius: 3px; + font-size: 11px; + color: #fff; + cursor: pointer; + z-index: 5; + transition: background 0.1s ease; +} + +.ss-clip-mask-badge:hover { + background: rgba(0, 0, 0, 0.6); +} + /* Resize handle */ .ss-clip-resize-handle { position: absolute; From 6d269453477e01a12b0e7037a7901f6d128786a8 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 10:17:56 +1100 Subject: [PATCH 092/463] feat: add luma mask synchronization and cleanup for clip operations --- src/components/canvas/players/luma-player.ts | 3 +- src/components/timeline-html/html-timeline.ts | 7 ++ src/core/commands/delete-clip-command.ts | 12 ++ src/core/edit.ts | 111 +++++++++++++++++- 4 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index 7d94a2b8..1d0289b8 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -101,7 +101,8 @@ export class LumaPlayer extends Player { this.sprite?.destroy(); this.sprite = null; - this.texture?.destroy(); + // DON'T destroy the texture - it's managed by Assets + // The unloadClipAssets() method in Edit already calls Assets.unload() this.texture = null; } diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 642e19ca..378f5ba5 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -327,6 +327,13 @@ export class HtmlTimeline extends TimelineEntity { getAttachedLuma: (trackIndex, clipIndex) => this.stateManager.getAttachedLuma(trackIndex, clipIndex), onMaskClick: (contentTrackIndex, contentClipIndex) => { this.stateManager.toggleLumaVisibility(contentTrackIndex, contentClipIndex); + + // Select the luma clip when toggling mask visibility + const lumaRef = this.stateManager.getAttachedLuma(contentTrackIndex, contentClipIndex); + if (lumaRef) { + this.edit.selectClip(lumaRef.trackIndex, lumaRef.clipIndex); + } + this.requestRender(); }, isLumaVisibleForEditing: (contentTrackIndex, contentClipIndex) => diff --git a/src/core/commands/delete-clip-command.ts b/src/core/commands/delete-clip-command.ts index c3943cf7..b724a912 100644 --- a/src/core/commands/delete-clip-command.ts +++ b/src/core/commands/delete-clip-command.ts @@ -25,6 +25,12 @@ export class DeleteClipCommand implements EditCommand { // Propagate timing changes to clips that were after the deleted clip // Use clipIdx - 1 because the clip at clipIdx no longer exists context.propagateTimingChanges(this.trackIdx, this.clipIdx - 1); + + // Emit event so luma masking and other listeners can update + context.emitEvent("clip:deleted", { + trackIndex: this.trackIdx, + clipIndex: this.clipIdx + }); } } @@ -34,5 +40,11 @@ export class DeleteClipCommand implements EditCommand { // Propagate timing changes after restoring the clip context.propagateTimingChanges(this.trackIdx, this.clipIdx); + + // Emit event so luma masking can rebuild after restore + context.emitEvent("clip:restored", { + trackIndex: this.trackIdx, + clipIndex: this.clipIdx + }); } } diff --git a/src/core/edit.ts b/src/core/edit.ts index ce2c863c..cbead30b 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -88,8 +88,12 @@ export class Edit extends Entity { lumaPlayer: LumaPlayer; maskSprite: pixi.Sprite; tempContainer: pixi.Container; + contentClip: Player; }> = []; + // Queue for deferred mask sprite cleanup - must wait for PixiJS to finish rendering + private pendingMaskCleanup: Array<{ maskSprite: pixi.Sprite; frameCount: number }> = []; + constructor(size: Size, backgroundColor: string = "#ffffff") { super(); @@ -153,6 +157,10 @@ export class Edit extends Entity { // Update luma masks for video sources (regenerate mask texture each frame) this.updateLumaMasks(); + // Process pending mask cleanup AFTER updateLumaMasks + // This ensures sprites are destroyed only after PixiJS has finished with them + this.processPendingMaskCleanup(); + if (this.isPlaying) { this.playbackTime = Math.max(0, Math.min(this.playbackTime + elapsed, this.totalDuration)); @@ -177,6 +185,16 @@ export class Edit extends Entity { } this.activeLumaMasks = []; + for (const item of this.pendingMaskCleanup) { + try { + item.maskSprite.parent?.removeChild(item.maskSprite); + item.maskSprite.destroy({ texture: true }); + } catch { + // Ignore cleanup errors during dispose + } + } + this.pendingMaskCleanup = []; + if (this.viewportMask) { try { this.getContainer().setMask(null as any); @@ -277,6 +295,7 @@ export class Edit extends Entity { } this.finalizeLumaMasking(); + this.setupLumaMaskEventListeners(); await this.resolveAllTiming(); @@ -613,6 +632,13 @@ export class Edit extends Entity { return; } + // Clean up luma masks for any luma players being deleted + for (const clip of this.clipsToDispose) { + if (clip instanceof LumaPlayer) { + this.cleanupLumaMaskForPlayer(clip); + } + } + for (const clip of this.clipsToDispose) { this.disposeClip(clip); } @@ -968,7 +994,7 @@ export class Edit extends Entity { contentClip.getContainer().addChild(maskSprite); contentClip.getContentContainer().setMask({ mask: maskSprite }); - this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer }); + this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer, contentClip }); } private updateLumaMasks(): void { @@ -985,6 +1011,89 @@ export class Edit extends Entity { } } + /** + * Set up event listeners for luma mask synchronization. + * Ensures canvas masking stays in sync with clip operations. + */ + private setupLumaMaskEventListeners(): void { + // Rebuild masks after clip moves (luma might have moved to new track) + this.events.on("clip:updated", () => { + this.rebuildLumaMasksIfNeeded(); + }); + + // Rebuild masks after clip deletion undo (luma might be restored) + this.events.on("clip:restored", () => { + this.rebuildLumaMasksIfNeeded(); + }); + } + + /** Clean up luma mask when a luma player is deleted. */ + private cleanupLumaMaskForPlayer(player: Player): void { + const maskIndex = this.activeLumaMasks.findIndex(mask => mask.lumaPlayer === player); + if (maskIndex === -1) return; + + const mask = this.activeLumaMasks[maskIndex]; + + // Clear mask (PixiJS 8 requires direct assignment, not setMask(null)) + if (mask.contentClip) { + mask.contentClip.getContentContainer().mask = null; + } + + mask.maskSprite.parent?.removeChild(mask.maskSprite); + mask.tempContainer.destroy({ children: true }); + this.activeLumaMasks.splice(maskIndex, 1); + + // Defer maskSprite destruction until PixiJS finishes rendering + this.pendingMaskCleanup.push({ maskSprite: mask.maskSprite, frameCount: 0 }); + } + + /** + * Process pending mask cleanup queue. + * Sprites are destroyed after 3 frames to ensure PixiJS has finished rendering. + * Called at the end of update() after updateLumaMasks(). + */ + private processPendingMaskCleanup(): void { + for (let i = this.pendingMaskCleanup.length - 1; i >= 0; i -= 1) { + const item = this.pendingMaskCleanup[i]; + item.frameCount += 1; + + if (item.frameCount >= 3) { + try { + item.maskSprite.parent?.removeChild(item.maskSprite); + item.maskSprite.destroy({ texture: true }); + } catch { + // Ignore cleanup errors + } + this.pendingMaskCleanup.splice(i, 1); + } + } + } + + /** + * Rebuild luma masks for any tracks that need masking but don't have it set up. + * Called after clip operations (move, delete, etc.) to ensure canvas stays in sync. + */ + private rebuildLumaMasksIfNeeded(): void { + if (!this.canvas) return; + + // Check each track for luma+content pairs that need masking + for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { + const trackClips = this.tracks[trackIdx]; + const lumaPlayer = trackClips.find(clip => clip instanceof LumaPlayer) as LumaPlayer | undefined; + const contentClips = trackClips.filter(clip => !(clip instanceof LumaPlayer)); + + const existingMask = lumaPlayer && this.activeLumaMasks.find(m => m.lumaPlayer === lumaPlayer); + + if (lumaPlayer && !existingMask && contentClips.length > 0) { + const lumaSprite = lumaPlayer.getSprite(); + if (lumaSprite?.texture) { + this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + } + } + } + public selectClip(trackIndex: number, clipIndex: number): void { const command = new SelectClipCommand(trackIndex, clipIndex); this.executeCommand(command); From 9072e1a321a23adea85f38cd0967caa3be76dfc6 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 10:25:49 +1100 Subject: [PATCH 093/463] feat: make rebuildLumaMasksIfNeeded async and handle sprite reload on undo --- src/core/edit.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/core/edit.ts b/src/core/edit.ts index cbead30b..ce2a4851 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1073,10 +1073,9 @@ export class Edit extends Entity { * Rebuild luma masks for any tracks that need masking but don't have it set up. * Called after clip operations (move, delete, etc.) to ensure canvas stays in sync. */ - private rebuildLumaMasksIfNeeded(): void { + private async rebuildLumaMasksIfNeeded(): Promise { if (!this.canvas) return; - // Check each track for luma+content pairs that need masking for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { const trackClips = this.tracks[trackIdx]; const lumaPlayer = trackClips.find(clip => clip instanceof LumaPlayer) as LumaPlayer | undefined; @@ -1085,6 +1084,11 @@ export class Edit extends Entity { const existingMask = lumaPlayer && this.activeLumaMasks.find(m => m.lumaPlayer === lumaPlayer); if (lumaPlayer && !existingMask && contentClips.length > 0) { + // If sprite was destroyed (undo after delete), wait for reload + if (!lumaPlayer.getSprite()) { + await lumaPlayer.load(); + } + const lumaSprite = lumaPlayer.getSprite(); if (lumaSprite?.texture) { this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); From ae631c688aae5af38f71116814fd7134f5602423 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 13:28:30 +1100 Subject: [PATCH 094/463] feat: add text asset toolbar with reconfiguration support --- src/components/canvas/players/text-player.ts | 36 + src/components/canvas/shotstack-canvas.ts | 15 +- src/core/ui/base-toolbar.ts | 192 ++++++ src/core/ui/media-toolbar.ts | 241 +++---- src/core/ui/rich-text-toolbar.css.ts | 3 + src/core/ui/rich-text-toolbar.ts | 109 +-- src/core/ui/text-toolbar.css.ts | 2 + src/core/ui/text-toolbar.ts | 665 +++++++++++++++++++ 8 files changed, 1028 insertions(+), 235 deletions(-) create mode 100644 src/core/ui/base-toolbar.ts create mode 100644 src/core/ui/text-toolbar.css.ts create mode 100644 src/core/ui/text-toolbar.ts diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index 1d944b74..c8502203 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -57,6 +57,42 @@ export class TextPlayer extends Player { super.update(deltaTime, elapsed); } + public override reconfigureAfterRestore(): void { + super.reconfigureAfterRestore(); + this.reconfigure(); + } + + private async reconfigure(): Promise { + const textAsset = this.clipConfiguration.asset as TextAsset; + + // Load font if changed + const fontFamily = textAsset.font?.family ?? "Open Sans"; + await this.loadFont(fontFamily); + + // Update background + this.drawBackground(); + + // Update text content and style + if (this.text) { + this.text.text = textAsset.text; + this.text.style = this.createTextStyle(textAsset); + + // Update stroke filter + if (textAsset.stroke?.width && textAsset.stroke.width > 0 && textAsset.stroke.color) { + const textStrokeFilter = new pixiFilters.OutlineFilter({ + thickness: textAsset.stroke.width, + color: textAsset.stroke.color + }); + this.text.filters = [textStrokeFilter]; + } else { + this.text.filters = []; + } + + // Reposition text based on alignment + this.positionText(textAsset); + } + } + public override dispose(): void { super.dispose(); diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 9b54102f..d17e3e7c 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -4,6 +4,7 @@ import { AssetToolbar } from "@core/ui/asset-toolbar"; import { CanvasToolbar } from "@core/ui/canvas-toolbar"; import { MediaToolbar } from "@core/ui/media-toolbar"; import { RichTextToolbar } from "@core/ui/rich-text-toolbar"; +import { TextToolbar } from "@core/ui/text-toolbar"; import { TranscriptionIndicator } from "@core/ui/transcription-indicator"; import { type Size } from "@layouts/geometry"; import { AudioLoadParser } from "@loaders/audio-load-parser"; @@ -27,6 +28,7 @@ export class Canvas { private readonly inspector: Inspector; private readonly transcriptionIndicator: TranscriptionIndicator; private readonly richTextToolbar: RichTextToolbar; + private readonly textToolbar: TextToolbar; private readonly mediaToolbar: MediaToolbar; private readonly canvasToolbar: CanvasToolbar; private readonly assetToolbar: AssetToolbar; @@ -48,6 +50,7 @@ export class Canvas { this.inspector = new Inspector(); this.transcriptionIndicator = new TranscriptionIndicator(); this.richTextToolbar = new RichTextToolbar(edit); + this.textToolbar = new TextToolbar(edit); this.mediaToolbar = new MediaToolbar(edit); this.canvasToolbar = new CanvasToolbar(edit); this.assetToolbar = new AssetToolbar(edit); @@ -84,6 +87,7 @@ export class Canvas { root.appendChild(this.application.canvas); this.richTextToolbar.mount(root); + this.textToolbar.mount(root); this.mediaToolbar.mount(root); this.setupClipToolbarListeners(); @@ -321,18 +325,26 @@ export class Canvas { if (assetType === "rich-text") { this.mediaToolbar.hide(); + this.textToolbar.hide(); this.richTextToolbar.show(trackIndex, clipIndex); + } else if (assetType === "text") { + this.mediaToolbar.hide(); + this.richTextToolbar.hide(); + this.textToolbar.show(trackIndex, clipIndex); } else if (assetType === "video" || assetType === "image") { this.richTextToolbar.hide(); - this.mediaToolbar.show(trackIndex, clipIndex, assetType === "video"); + this.textToolbar.hide(); + this.mediaToolbar.showMedia(trackIndex, clipIndex, assetType === "video"); } else { this.richTextToolbar.hide(); + this.textToolbar.hide(); this.mediaToolbar.hide(); } }); this.edit.events.on("selection:cleared", () => { this.richTextToolbar.hide(); + this.textToolbar.hide(); this.mediaToolbar.hide(); }); } @@ -391,6 +403,7 @@ export class Canvas { this.inspector.dispose(); this.transcriptionIndicator.dispose(); this.richTextToolbar.dispose(); + this.textToolbar.dispose(); this.mediaToolbar.dispose(); this.canvasToolbar.dispose(); this.assetToolbar.dispose(); diff --git a/src/core/ui/base-toolbar.ts b/src/core/ui/base-toolbar.ts new file mode 100644 index 00000000..73eebc31 --- /dev/null +++ b/src/core/ui/base-toolbar.ts @@ -0,0 +1,192 @@ +import type { Edit } from "@core/edit"; + +/** Preset font sizes used by text toolbars */ +export const FONT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 96, 128]; + +/** Built-in font families */ +export const BUILT_IN_FONTS = [ + "Arapey", + "Clear Sans", + "Didact Gothic", + "Montserrat", + "MovLette", + "Open Sans", + "Permanent Marker", + "Roboto", + "Sue Ellen Francisco", + "Work Sans" +]; + +/** Shared SVG icon paths for toolbars */ +export const TOOLBAR_ICONS = { + alignLeft: ``, + alignCenter: ``, + alignRight: ``, + anchorTop: ``, + anchorMiddle: ``, + anchorBottom: ``, + sizeUp: ``, + sizeDown: ``, + spacing: ``, + fontColor: ``, + background: ``, + stroke: ``, + edit: ``, + chevron: `` +}; + +/** + * Abstract base class for toolbars providing shared lifecycle, popup management, + * and UI helper methods. + */ +export abstract class BaseToolbar { + protected container: HTMLDivElement | null = null; + protected edit: Edit; + protected selectedTrackIdx = -1; + protected selectedClipIdx = -1; + protected styleElement: HTMLStyleElement | null = null; + protected clickOutsideHandler: ((e: MouseEvent) => void) | null = null; + + constructor(edit: Edit) { + this.edit = edit; + } + + /** + * Mount the toolbar to a parent element. + * Subclasses must implement to build their specific HTML structure. + */ + abstract mount(parent: HTMLElement): void; + + /** + * Show the toolbar for a specific clip. + * Subclasses can override to add custom behavior. + */ + show(trackIndex: number, clipIndex: number): void { + this.selectedTrackIdx = trackIndex; + this.selectedClipIdx = clipIndex; + this.syncState(); + if (this.container) { + this.container.classList.add("visible"); + this.container.style.display = ""; // Clear inline style, let CSS control + } + } + + /** + * Hide the toolbar. + */ + hide(): void { + if (this.container) { + this.container.classList.remove("visible"); + this.container.style.display = ""; // Clear inline style, let CSS control + } + this.closeAllPopups(); + this.selectedTrackIdx = -1; + this.selectedClipIdx = -1; + } + + /** + * Dispose the toolbar and clean up resources. + * Subclasses should call super.dispose() and then null their own references. + */ + dispose(): void { + if (this.clickOutsideHandler) { + document.removeEventListener("click", this.clickOutsideHandler); + this.clickOutsideHandler = null; + } + + this.container?.remove(); + this.container = null; + + this.styleElement?.remove(); + this.styleElement = null; + } + + /** + * Inject styles into the document head if not already present. + */ + protected injectStyles(styleId: string, styleContent: string): void { + if (document.getElementById(styleId)) return; + + this.styleElement = document.createElement("style"); + this.styleElement.id = styleId; + this.styleElement.textContent = styleContent; + document.head.appendChild(this.styleElement); + } + + /** + * Toggle a popup's visibility, closing all others first. + * Uses CSS class-based visibility. + */ + protected togglePopup(popup: HTMLElement | null): void { + const isOpen = popup?.classList.contains("visible"); + + this.closeAllPopups(); + + if (!isOpen && popup) { + popup.classList.add("visible"); + popup.style.display = ""; // Clear inline style, let CSS control + } + } + + /** + * Close all popups. + * Uses CSS class-based visibility. + */ + protected closeAllPopups(): void { + for (const popup of this.getPopupList()) { + if (popup) { + popup.classList.remove("visible"); + popup.style.display = ""; // Clear inline style, let CSS control + } + } + } + + /** + * Set up a document click handler to close popups when clicking outside. + */ + protected setupOutsideClickHandler(): void { + this.clickOutsideHandler = (e: MouseEvent) => { + if (!this.container?.contains(e.target as Node)) { + this.closeAllPopups(); + } + }; + document.addEventListener("click", this.clickOutsideHandler); + } + + /** + * Create a slider input handler with value display update. + */ + protected createSliderHandler( + slider: HTMLInputElement | null, + valueDisplay: HTMLSpanElement | null, + callback: (value: number) => void, + formatter: (value: number) => string = String + ): void { + slider?.addEventListener("input", e => { + const val = parseFloat((e.target as HTMLInputElement).value); + if (valueDisplay) { + Object.assign(valueDisplay, { textContent: formatter(val) }); + } + callback(val); + }); + } + + /** + * Set active state on a button element. + */ + protected setButtonActive(btn: HTMLElement | null, active: boolean): void { + btn?.classList.toggle("active", active); + } + + /** + * Sync UI state with current clip configuration. + * Subclasses must implement. + */ + protected abstract syncState(): void; + + /** + * Get the list of popup elements for popup management. + * Subclasses must implement. + */ + protected abstract getPopupList(): (HTMLElement | null)[]; +} diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index e19adce9..c9b9a274 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -1,6 +1,6 @@ -import type { Edit } from "@core/edit"; import { validateAssetUrl } from "@core/shared/utils"; +import { BaseToolbar } from "./base-toolbar"; import { MEDIA_TOOLBAR_STYLES } from "./media-toolbar.css"; type FitValue = "crop" | "cover" | "contain" | "none"; @@ -30,14 +30,7 @@ const ICONS = { moreVertical: `` }; -export class MediaToolbar { - private container: HTMLDivElement | null = null; - private styleElement: HTMLStyleElement | null = null; - private edit: Edit; - - // Current clip info - private currentTrackIndex: number = -1; - private currentClipIndex: number = -1; +export class MediaToolbar extends BaseToolbar { private isVideoClip: boolean = false; // Current values @@ -102,24 +95,9 @@ export class MediaToolbar { private dynamicFieldName: string = ""; private originalSrc: string = ""; - // Click outside handler - private clickOutsideHandler: ((e: MouseEvent) => void) | null = null; - - constructor(edit: Edit) { - this.edit = edit; - this.injectStyles(); - } - - private injectStyles(): void { - if (document.getElementById("ss-media-toolbar-styles")) return; + override mount(parent: HTMLElement): void { + this.injectStyles("ss-media-toolbar-styles", MEDIA_TOOLBAR_STYLES); - this.styleElement = document.createElement("style"); - this.styleElement.id = "ss-media-toolbar-styles"; - this.styleElement.textContent = MEDIA_TOOLBAR_STYLES; - document.head.appendChild(this.styleElement); - } - - mount(parent: HTMLElement): void { this.container = document.createElement("div"); this.container.className = "ss-media-toolbar"; @@ -309,33 +287,34 @@ export class MediaToolbar { this.dynamicInput = this.container.querySelector("[data-dynamic-input]"); this.setupEventListeners(); + this.setupOutsideClickHandler(); } private setupEventListeners(): void { // Toggle popups this.fitBtn?.addEventListener("click", e => { e.stopPropagation(); - this.togglePopup("fit"); + this.togglePopupByName("fit"); }); this.opacityBtn?.addEventListener("click", e => { e.stopPropagation(); - this.togglePopup("opacity"); + this.togglePopupByName("opacity"); }); this.scaleBtn?.addEventListener("click", e => { e.stopPropagation(); - this.togglePopup("scale"); + this.togglePopupByName("scale"); }); this.volumeBtn?.addEventListener("click", e => { e.stopPropagation(); - this.togglePopup("volume"); + this.togglePopupByName("volume"); }); this.transitionBtn?.addEventListener("click", e => { e.stopPropagation(); - this.togglePopup("transition"); + this.togglePopupByName("transition"); }); this.advancedBtn?.addEventListener("click", e => { e.stopPropagation(); - this.togglePopup("advanced"); + this.togglePopupByName("advanced"); }); // Dynamic source handlers @@ -400,17 +379,9 @@ export class MediaToolbar { const speedIncrease = this.transitionPopup?.querySelector("[data-speed-increase]"); speedDecrease?.addEventListener("click", () => this.handleSpeedStep(-1)); speedIncrease?.addEventListener("click", () => this.handleSpeedStep(1)); - - // Click outside to close - this.clickOutsideHandler = (e: MouseEvent) => { - if (!this.container?.contains(e.target as Node)) { - this.closeAllPopups(); - } - }; - document.addEventListener("click", this.clickOutsideHandler); } - private togglePopup(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "advanced"): void { + private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "advanced"): void { const popupMap = { fit: { popup: this.fitPopup, btn: this.fitBtn }, opacity: { popup: this.opacityPopup, btn: this.opacityBtn }, @@ -424,18 +395,15 @@ export class MediaToolbar { this.closeAllPopups(); if (!isCurrentlyOpen) { - popupMap[popup].popup?.classList.add("visible"); + this.togglePopup(popupMap[popup].popup); popupMap[popup].btn?.classList.add("active"); } } - private closeAllPopups(): void { - this.fitPopup?.classList.remove("visible"); - this.opacityPopup?.classList.remove("visible"); - this.scalePopup?.classList.remove("visible"); - this.volumePopup?.classList.remove("visible"); - this.transitionPopup?.classList.remove("visible"); - this.advancedPopup?.classList.remove("visible"); + protected override closeAllPopups(): void { + super.closeAllPopups(); + + // Also remove active state from buttons this.fitBtn?.classList.remove("active"); this.opacityBtn?.classList.remove("active"); this.scaleBtn?.classList.remove("active"); @@ -444,6 +412,66 @@ export class MediaToolbar { this.advancedBtn?.classList.remove("active"); } + protected override getPopupList(): (HTMLElement | null)[] { + return [this.fitPopup, this.opacityPopup, this.scalePopup, this.volumePopup, this.transitionPopup, this.advancedPopup]; + } + + protected override syncState(): void { + // Get current clip values + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (player) { + const clip = player.clipConfiguration; + + // Fit + this.currentFit = (clip.fit as FitValue) || "crop"; + + // Opacity (convert from 0-1 to 0-100) + const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; + this.currentOpacity = Math.round(opacity * 100); + + // Scale (convert from 0-1 to percentage) + const scale = typeof clip.scale === "number" ? clip.scale : 1; + this.currentScale = Math.round(scale * 100); + + // Volume (video only) + if (this.isVideoClip && clip.asset.type === "video") { + const volume = typeof clip.asset.volume === "number" ? clip.asset.volume : 1; + this.currentVolume = Math.round(volume * 100); + } + + // Transition - parse effect, direction, and speed + const parsedIn = this.parseTransitionValue(clip.transition?.in || ""); + const parsedOut = this.parseTransitionValue(clip.transition?.out || ""); + this.transitionInEffect = parsedIn.effect; + this.transitionInDirection = parsedIn.direction; + this.transitionInSpeed = parsedIn.speed; + this.transitionOutEffect = parsedOut.effect; + this.transitionOutDirection = parsedOut.direction; + this.transitionOutSpeed = parsedOut.speed; + } + + // Update displays + this.updateFitDisplay(); + this.updateOpacityDisplay(); + this.updateScaleDisplay(); + this.updateVolumeDisplay(); + + // Update active states + this.updateFitActiveState(); + + // Reset to IN tab and update transition UI + this.activeTransitionTab = "in"; + this.updateTransitionUI(); + + // Update dynamic source state + this.updateDynamicSourceUI(); + + // Show/hide volume section based on asset type + if (this.volumeSection) { + this.volumeSection.classList.toggle("hidden", !this.isVideoClip); + } + } + private handleFitChange(fit: FitValue): void { this.currentFit = fit; this.updateFitDisplay(); @@ -469,9 +497,9 @@ export class MediaToolbar { this.updateVolumeDisplay(); // Volume is on the asset, not the clip - const player = this.edit.getPlayerClip(this.currentTrackIndex, this.currentClipIndex); + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); if (player && player.clipConfiguration.asset.type === "video") { - this.edit.updateClip(this.currentTrackIndex, this.currentClipIndex, { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { asset: { ...player.clipConfiguration.asset, volume: value / 100 @@ -697,8 +725,8 @@ export class MediaToolbar { } private applyClipUpdate(updates: Record): void { - if (this.currentTrackIndex >= 0 && this.currentClipIndex >= 0) { - this.edit.updateClip(this.currentTrackIndex, this.currentClipIndex, updates); + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); } } @@ -761,7 +789,7 @@ export class MediaToolbar { if (this.dynamicFieldName) { this.edit.updateMergeFieldValueLive(this.dynamicFieldName, url); // Also reload the asset to show the new image/video - const player = this.edit.getPlayerClip(this.currentTrackIndex, this.currentClipIndex); + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); if (player) { player.reloadAsset(); } @@ -772,14 +800,7 @@ export class MediaToolbar { const fieldName = this.edit.mergeFields.generateUniqueName("MEDIA"); // Use Edit API to apply merge field (handles template + resolved value atomically) - this.edit.applyMergeField( - this.currentTrackIndex, - this.currentClipIndex, - "asset.src", - fieldName, - url, - this.originalSrc // Pass original src for undo - ); + this.edit.applyMergeField(this.selectedTrackIdx, this.selectedClipIdx, "asset.src", fieldName, url, this.originalSrc); this.dynamicFieldName = fieldName; } @@ -806,12 +827,7 @@ export class MediaToolbar { if (!this.dynamicFieldName) return; // Use Edit API to remove merge field (handles undo and asset reload) - this.edit.removeMergeField( - this.currentTrackIndex, - this.currentClipIndex, - "asset.src", - this.originalSrc // Restore original src - ); + this.edit.removeMergeField(this.selectedTrackIdx, this.selectedClipIdx, "asset.src", this.originalSrc); this.dynamicFieldName = ""; if (this.dynamicInput) { @@ -824,11 +840,11 @@ export class MediaToolbar { * Uses the new Edit.getMergeFieldForProperty() API. */ private updateDynamicSourceUI(): void { - const player = this.edit.getPlayerClip(this.currentTrackIndex, this.currentClipIndex); + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); if (!player) return; // Use Edit API to check if this property has a merge field - const fieldName = this.edit.getMergeFieldForProperty(this.currentTrackIndex, this.currentClipIndex, "asset.src"); + const fieldName = this.edit.getMergeFieldForProperty(this.selectedTrackIdx, this.selectedClipIdx, "asset.src"); if (fieldName) { // Has dynamic source @@ -904,84 +920,19 @@ export class MediaToolbar { }); } - show(trackIndex: number, clipIndex: number, isVideo: boolean): void { - this.currentTrackIndex = trackIndex; - this.currentClipIndex = clipIndex; + /** + * Show the toolbar for a specific clip. + * @param trackIndex - Track index + * @param clipIndex - Clip index + * @param isVideo - Whether the clip is a video (optional, defaults to false) + */ + showMedia(trackIndex: number, clipIndex: number, isVideo: boolean = false): void { this.isVideoClip = isVideo; - - // Get current clip values - const player = this.edit.getPlayerClip(trackIndex, clipIndex); - if (player) { - const clip = player.clipConfiguration; - - // Fit - this.currentFit = (clip.fit as FitValue) || "crop"; - - // Opacity (convert from 0-1 to 0-100) - const opacity = typeof clip.opacity === "number" ? clip.opacity : 1; - this.currentOpacity = Math.round(opacity * 100); - - // Scale (convert from 0-1 to percentage) - const scale = typeof clip.scale === "number" ? clip.scale : 1; - this.currentScale = Math.round(scale * 100); - - // Volume (video only) - if (isVideo && clip.asset.type === "video") { - const volume = typeof clip.asset.volume === "number" ? clip.asset.volume : 1; - this.currentVolume = Math.round(volume * 100); - } - - // Transition - parse effect, direction, and speed - const parsedIn = this.parseTransitionValue(clip.transition?.in || ""); - const parsedOut = this.parseTransitionValue(clip.transition?.out || ""); - this.transitionInEffect = parsedIn.effect; - this.transitionInDirection = parsedIn.direction; - this.transitionInSpeed = parsedIn.speed; - this.transitionOutEffect = parsedOut.effect; - this.transitionOutDirection = parsedOut.direction; - this.transitionOutSpeed = parsedOut.speed; - } - - // Update displays - this.updateFitDisplay(); - this.updateOpacityDisplay(); - this.updateScaleDisplay(); - this.updateVolumeDisplay(); - - // Update active states - this.updateFitActiveState(); - - // Reset to IN tab and update transition UI - this.activeTransitionTab = "in"; - this.updateTransitionUI(); - - // Update dynamic source state - this.updateDynamicSourceUI(); - - // Show/hide volume section based on asset type - if (this.volumeSection) { - this.volumeSection.classList.toggle("hidden", !isVideo); - } - - // Show toolbar - this.container?.classList.add("visible"); + super.show(trackIndex, clipIndex); } - hide(): void { - this.container?.classList.remove("visible"); - this.closeAllPopups(); - this.currentTrackIndex = -1; - this.currentClipIndex = -1; - } - - dispose(): void { - if (this.clickOutsideHandler) { - document.removeEventListener("click", this.clickOutsideHandler); - this.clickOutsideHandler = null; - } - - this.container?.remove(); - this.container = null; + override dispose(): void { + super.dispose(); this.fitBtn = null; this.opacityBtn = null; diff --git a/src/core/ui/rich-text-toolbar.css.ts b/src/core/ui/rich-text-toolbar.css.ts index 1c693343..fb70b91f 100644 --- a/src/core/ui/rich-text-toolbar.css.ts +++ b/src/core/ui/rich-text-toolbar.css.ts @@ -17,6 +17,7 @@ export const TOOLBAR_STYLES = ` align-items: center; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(0, 0, 0, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.05); } +.ss-toolbar.visible { display: flex; } .ss-toolbar-group { display: flex; align-items: center; gap: 1px; } .ss-toolbar-group--bordered { background: rgba(255, 255, 255, 0.04); border-radius: 6px; padding: 2px; } @@ -48,6 +49,7 @@ export const TOOLBAR_STYLES = ` .ss-toolbar-color::-webkit-color-swatch-wrapper { padding: 0; } .ss-toolbar-color::-webkit-color-swatch { border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); } .ss-toolbar-color::-moz-color-swatch { border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; } +.ss-toolbar-color-btn { width: 22px; height: 22px; padding: 0; border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 50%; cursor: pointer; box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3); } .ss-toolbar-dropdown { position: relative; } @@ -67,6 +69,7 @@ export const TOOLBAR_STYLES = ` z-index: 200; box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(0, 0, 0, 0.2); } +.ss-toolbar-popup.visible { display: block; } .ss-toolbar-popup::before { content: ""; position: absolute; diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index c30d0dea..96d1138f 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -1,34 +1,13 @@ -import type { Edit } from "@core/edit"; import type { MergeField } from "@core/merge"; import type { ResolvedClip } from "@schemas/clip"; import type { RichTextAsset } from "@schemas/rich-text-asset"; import { BackgroundColorPicker } from "./background-color-picker"; +import { BaseToolbar, BUILT_IN_FONTS, FONT_SIZES } from "./base-toolbar"; import { FontColorPicker } from "./font-color-picker"; import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; -/** Built-in font families (base names only, without weight variants) */ -const BUILT_IN_FONTS = [ - "Arapey", - "Clear Sans", - "Didact Gothic", - "Montserrat", - "MovLette", - "Open Sans", - "Permanent Marker", - "Roboto", - "Sue Ellen Francisco", - "Work Sans" -]; - -/** Preset font sizes for the dropdown */ -const FONT_SIZES = [6, 8, 10, 12, 14, 16, 18, 21, 24, 28, 32, 36, 42, 48, 56, 64, 72, 96, 128]; - -export class RichTextToolbar { - private container: HTMLDivElement | null = null; - private edit: Edit; - private selectedTrackIdx = -1; - private selectedClipIdx = -1; +export class RichTextToolbar extends BaseToolbar { private fontBtn: HTMLButtonElement | null = null; private fontPopup: HTMLDivElement | null = null; @@ -112,14 +91,8 @@ export class RichTextToolbar { private animationStyleSection: HTMLDivElement | null = null; private animationDirectionSection: HTMLDivElement | null = null; - private styleElement: HTMLStyleElement | null = null; - - constructor(edit: Edit) { - this.edit = edit; - } - - mount(parent: HTMLElement): void { - this.injectStyles(); + override mount(parent: HTMLElement): void { + this.injectStyles("ss-toolbar-styles", TOOLBAR_STYLES); this.container = document.createElement("div"); this.container.className = "ss-toolbar"; @@ -802,15 +775,6 @@ export class RichTextToolbar { }); } - private injectStyles(): void { - if (document.getElementById("ss-toolbar-styles")) return; - - this.styleElement = document.createElement("style"); - this.styleElement.id = "ss-toolbar-styles"; - this.styleElement.textContent = TOOLBAR_STYLES; - document.head.appendChild(this.styleElement); - } - private handleClick(e: MouseEvent): void { const target = e.target as HTMLElement; const button = target.closest("button"); @@ -1568,46 +1532,22 @@ export class RichTextToolbar { this.syncState(); } - show(trackIdx: number, clipIdx: number): void { - this.selectedTrackIdx = trackIdx; - this.selectedClipIdx = clipIdx; - if (this.container) { - this.container.style.display = "flex"; - } - this.syncState(); - } - - hide(): void { - if (this.container) { - this.container.style.display = "none"; - } - if (this.sizePopup) { - this.sizePopup.style.display = "none"; - } - if (this.spacingPopup) { - this.spacingPopup.style.display = "none"; - } - if (this.borderPopup) { - this.borderPopup.style.display = "none"; - } - if (this.shadowPopup) { - this.shadowPopup.style.display = "none"; - } - if (this.backgroundPopup) { - this.backgroundPopup.style.display = "none"; - } - if (this.paddingPopup) { - this.paddingPopup.style.display = "none"; - } - if (this.fontPopup) { - this.fontPopup.style.display = "none"; - } - if (this.textEditPopup) { - this.textEditPopup.style.display = "none"; - } + protected override getPopupList(): (HTMLElement | null)[] { + return [ + this.sizePopup, + this.spacingPopup, + this.borderPopup, + this.shadowPopup, + this.backgroundPopup, + this.paddingPopup, + this.fontPopup, + this.textEditPopup, + this.fontColorPopup, + this.animationPopup + ]; } - private syncState(): void { + protected override syncState(): void { const asset = this.getCurrentAsset(); if (!asset) return; @@ -1773,14 +1713,8 @@ export class RichTextToolbar { } } - private setButtonActive(btn: HTMLButtonElement | null, active: boolean): void { - if (!btn) return; - btn.classList.toggle("active", active); - } - - dispose(): void { - this.container?.remove(); - this.container = null; + override dispose(): void { + super.dispose(); this.sizeInput = null; this.sizePopup = null; this.boldBtn = null; @@ -1861,8 +1795,5 @@ export class RichTextToolbar { this.paddingBottomValue = null; this.paddingLeftSlider = null; this.paddingLeftValue = null; - - this.styleElement?.remove(); - this.styleElement = null; } } diff --git a/src/core/ui/text-toolbar.css.ts b/src/core/ui/text-toolbar.css.ts new file mode 100644 index 00000000..cd182cfe --- /dev/null +++ b/src/core/ui/text-toolbar.css.ts @@ -0,0 +1,2 @@ +// Re-export the shared toolbar styles - text toolbar uses the same design system +export { TOOLBAR_STYLES as TEXT_TOOLBAR_STYLES } from "./rich-text-toolbar.css"; diff --git a/src/core/ui/text-toolbar.ts b/src/core/ui/text-toolbar.ts new file mode 100644 index 00000000..02ef8a53 --- /dev/null +++ b/src/core/ui/text-toolbar.ts @@ -0,0 +1,665 @@ +import type { TextAsset } from "@schemas/text-asset"; + +import { BaseToolbar, BUILT_IN_FONTS, FONT_SIZES, TOOLBAR_ICONS } from "./base-toolbar"; +import { TEXT_TOOLBAR_STYLES } from "./text-toolbar.css"; + +export class TextToolbar extends BaseToolbar { + // Text edit + private textEditBtn: HTMLButtonElement | null = null; + private textEditPopup: HTMLDivElement | null = null; + private textEditArea: HTMLTextAreaElement | null = null; + private textEditDebounceTimer: ReturnType | null = null; + + // Font size + private sizeInput: HTMLInputElement | null = null; + private sizePopup: HTMLDivElement | null = null; + + // Font family + private fontBtn: HTMLButtonElement | null = null; + private fontPopup: HTMLDivElement | null = null; + private fontPreview: HTMLSpanElement | null = null; + + // Bold + private boldBtn: HTMLButtonElement | null = null; + + // Font color + private fontColorBtn: HTMLButtonElement | null = null; + private fontColorPopup: HTMLDivElement | null = null; + private fontColorInput: HTMLInputElement | null = null; + private colorDisplay: HTMLButtonElement | null = null; + + // Spacing (line height + vertical anchor) + private spacingBtn: HTMLButtonElement | null = null; + private spacingPopup: HTMLDivElement | null = null; + private lineHeightSlider: HTMLInputElement | null = null; + private lineHeightValue: HTMLSpanElement | null = null; + private anchorTopBtn: HTMLButtonElement | null = null; + private anchorMiddleBtn: HTMLButtonElement | null = null; + private anchorBottomBtn: HTMLButtonElement | null = null; + + // Horizontal alignment + private alignBtn: HTMLButtonElement | null = null; + private alignIcon: SVGElement | null = null; + + // Background + private backgroundBtn: HTMLButtonElement | null = null; + private backgroundPopup: HTMLDivElement | null = null; + private bgColorInput: HTMLInputElement | null = null; + private bgOpacitySlider: HTMLInputElement | null = null; + private bgOpacityValue: HTMLSpanElement | null = null; + + // Stroke + private strokeBtn: HTMLButtonElement | null = null; + private strokePopup: HTMLDivElement | null = null; + private strokeWidthSlider: HTMLInputElement | null = null; + private strokeWidthValue: HTMLSpanElement | null = null; + private strokeColorInput: HTMLInputElement | null = null; + + override mount(parent: HTMLElement): void { + this.injectStyles("ss-text-toolbar-styles", TEXT_TOOLBAR_STYLES); + + this.container = document.createElement("div"); + this.container.className = "ss-toolbar ss-text-toolbar"; + + this.container.innerHTML = ` +
+ +
+
Edit Text
+
+ +
+
+
+ +
+ +
+ +
+
+ +
+ + + +
+ +
+
+ +
+ +
+
Font Color
+
+
Color
+
+ +
+
+
+
+ +
+ +
+
+
Line spacing
+
+ + 1.2 +
+
+
+
+
Anchor text box
+
+ + + +
+
+
+
+ +
+ + + +
+ +
+
Background
+
+
Color
+
+ +
+
+
+
Opacity
+
+ + 100 +
+
+
+
+ +
+ +
+
Text Stroke
+
+
Width
+
+ + 0 +
+
+
+
Color
+
+ +
+
+
+
+ `; + + parent.insertBefore(this.container, parent.firstChild); + + this.bindElements(); + this.buildFontPopup(); + this.buildSizePopup(); + this.setupEventListeners(); + } + + private bindElements(): void { + if (!this.container) return; + + // Text edit + this.textEditBtn = this.container.querySelector("[data-action='text-edit-toggle']"); + this.textEditPopup = this.container.querySelector("[data-text-edit-popup]"); + this.textEditArea = this.container.querySelector("[data-text-edit-area]"); + + // Font size + this.sizeInput = this.container.querySelector("[data-size-input]"); + this.sizePopup = this.container.querySelector("[data-size-popup]"); + + // Font family + this.fontBtn = this.container.querySelector("[data-action='font-toggle']"); + this.fontPopup = this.container.querySelector("[data-font-popup]"); + this.fontPreview = this.container.querySelector("[data-font-preview]"); + + // Bold + this.boldBtn = this.container.querySelector("[data-action='bold']"); + + // Font color + this.fontColorBtn = this.container.querySelector("[data-action='font-color-toggle']"); + this.fontColorPopup = this.container.querySelector("[data-font-color-popup]"); + this.fontColorInput = this.container.querySelector("[data-font-color]"); + this.colorDisplay = this.container.querySelector("[data-color-display]"); + + // Spacing + this.spacingBtn = this.container.querySelector("[data-action='spacing-toggle']"); + this.spacingPopup = this.container.querySelector("[data-spacing-popup]"); + this.lineHeightSlider = this.container.querySelector("[data-line-height-slider]"); + this.lineHeightValue = this.container.querySelector("[data-line-height-value]"); + this.anchorTopBtn = this.container.querySelector("[data-action='anchor-top']"); + this.anchorMiddleBtn = this.container.querySelector("[data-action='anchor-middle']"); + this.anchorBottomBtn = this.container.querySelector("[data-action='anchor-bottom']"); + + // Alignment + this.alignBtn = this.container.querySelector("[data-action='align-cycle']"); + this.alignIcon = this.container.querySelector("[data-align-icon]"); + + // Background + this.backgroundBtn = this.container.querySelector("[data-action='background-toggle']"); + this.backgroundPopup = this.container.querySelector("[data-background-popup]"); + this.bgColorInput = this.container.querySelector("[data-bg-color]"); + this.bgOpacitySlider = this.container.querySelector("[data-bg-opacity]"); + this.bgOpacityValue = this.container.querySelector("[data-bg-opacity-value]"); + + // Stroke + this.strokeBtn = this.container.querySelector("[data-action='stroke-toggle']"); + this.strokePopup = this.container.querySelector("[data-stroke-popup]"); + this.strokeWidthSlider = this.container.querySelector("[data-stroke-width]"); + this.strokeWidthValue = this.container.querySelector("[data-stroke-width-value]"); + this.strokeColorInput = this.container.querySelector("[data-stroke-color]"); + } + + private setupEventListeners(): void { + this.container?.addEventListener("click", this.handleClick.bind(this)); + + // Text edit + this.textEditArea?.addEventListener("input", () => this.debouncedApplyTextEdit()); + + // Size input + this.sizeInput?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopup(this.sizePopup); + }); + this.sizeInput?.addEventListener("blur", () => this.applyManualSize()); + this.sizeInput?.addEventListener("keydown", e => { + if (e.key === "Enter") { + this.applyManualSize(); + this.sizeInput?.blur(); + this.closeAllPopups(); + } + }); + + // Font color + this.fontColorInput?.addEventListener("input", () => this.handleFontColorChange()); + + // Line height - use base class helper + this.createSliderHandler(this.lineHeightSlider, this.lineHeightValue, value => { + const lineHeight = value / 10; + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, lineHeight } }); + }, value => (value / 10).toFixed(1)); + + // Background color + this.bgColorInput?.addEventListener("input", () => this.handleBackgroundChange()); + + // Background opacity - use base class helper + this.createSliderHandler(this.bgOpacitySlider, this.bgOpacityValue, () => { + this.handleBackgroundChange(); + }); + + // Stroke width - use base class helper + this.createSliderHandler(this.strokeWidthSlider, this.strokeWidthValue, () => { + this.handleStrokeChange(); + }); + this.strokeColorInput?.addEventListener("input", () => this.handleStrokeChange()); + + // Use base class outside click handler + this.setupOutsideClickHandler(); + } + + private handleClick(e: Event): void { + const target = e.target as HTMLElement; + const btn = target.closest("[data-action]") as HTMLElement | null; + if (!btn) return; + + const { action } = btn.dataset; + e.stopPropagation(); + + switch (action) { + case "text-edit-toggle": + this.togglePopup(this.textEditPopup); + break; + case "size-down": + this.adjustSize(-1); + break; + case "size-up": + this.adjustSize(1); + break; + case "bold": + this.toggleBold(); + break; + case "font-toggle": + this.togglePopup(this.fontPopup); + break; + case "font-color-toggle": + this.togglePopup(this.fontColorPopup); + break; + case "spacing-toggle": + this.togglePopup(this.spacingPopup); + break; + case "anchor-top": + this.setVerticalAnchor("top"); + break; + case "anchor-middle": + this.setVerticalAnchor("center"); + break; + case "anchor-bottom": + this.setVerticalAnchor("bottom"); + break; + case "align-cycle": + this.cycleAlignment(); + break; + case "background-toggle": + this.togglePopup(this.backgroundPopup); + break; + case "stroke-toggle": + this.togglePopup(this.strokePopup); + break; + default: + break; + } + } + + protected override getPopupList(): (HTMLElement | null)[] { + return [ + this.textEditPopup, + this.sizePopup, + this.fontPopup, + this.fontColorPopup, + this.spacingPopup, + this.backgroundPopup, + this.strokePopup + ]; + } + + private buildFontPopup(): void { + if (!this.fontPopup) return; + + const html = `
+
Built-in Fonts
+ ${BUILT_IN_FONTS.map(f => `
${f}
`).join("")} +
`; + + this.fontPopup.innerHTML = html; + + this.fontPopup.querySelectorAll("[data-font]").forEach(item => { + item.addEventListener("click", () => { + const { font } = (item as HTMLElement).dataset; + if (font) this.setFont(font); + this.closeAllPopups(); + }); + }); + } + + private buildSizePopup(): void { + if (!this.sizePopup) return; + + this.sizePopup.innerHTML = FONT_SIZES.map(size => `
${size}
`).join(""); + + this.sizePopup.querySelectorAll("[data-size]").forEach(item => { + item.addEventListener("click", () => { + const size = parseInt((item as HTMLElement).dataset["size"] || "32", 10); + this.setSize(size); + this.closeAllPopups(); + }); + }); + } + + private getCurrentAsset(): TextAsset | null { + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!player || player.clipConfiguration.asset.type !== "text") return null; + return player.clipConfiguration.asset as TextAsset; + } + + private updateAssetProperty(updates: Partial): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { ...asset, ...updates } as TextAsset + }); + } + + // Text content + private debouncedApplyTextEdit(): void { + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + } + this.textEditDebounceTimer = setTimeout(() => { + const newText = this.textEditArea?.value ?? ""; + this.updateAssetProperty({ text: newText }); + }, 150); + } + + // Font size + private adjustSize(direction: number): void { + const asset = this.getCurrentAsset(); + const currentSize = asset?.font?.size ?? 32; + const currentIdx = FONT_SIZES.findIndex(s => s >= currentSize); + const idx = Math.max(0, Math.min(FONT_SIZES.length - 1, (currentIdx === -1 ? FONT_SIZES.length - 1 : currentIdx) + direction)); + this.setSize(FONT_SIZES[idx]); + } + + private setSize(size: number): void { + if (this.sizeInput) this.sizeInput.value = String(size); + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, size } }); + } + + private applyManualSize(): void { + const size = parseInt(this.sizeInput?.value || "32", 10); + if (!Number.isNaN(size) && size > 0) { + this.setSize(size); + } + } + + // Font family + private setFont(font: string): void { + if (this.fontPreview) this.fontPreview.textContent = "Aa"; + if (this.fontPreview) this.fontPreview.style.fontFamily = `'${font}'`; + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, family: font } }); + this.updateFontActiveState(font); + } + + private updateFontActiveState(currentFont: string): void { + this.fontPopup?.querySelectorAll("[data-font]").forEach(item => { + item.classList.toggle("active", (item as HTMLElement).dataset["font"] === currentFont); + }); + } + + // Bold + private toggleBold(): void { + const asset = this.getCurrentAsset(); + const currentWeight = asset?.font?.weight ?? 400; + const newWeight = currentWeight >= 700 ? 400 : 700; + this.updateAssetProperty({ font: { ...asset?.font, weight: newWeight } }); + this.setButtonActive(this.boldBtn, newWeight >= 700); + } + + // Font color + private handleFontColorChange(): void { + const color = this.fontColorInput?.value ?? "#FFFFFF"; + if (this.colorDisplay) { + this.colorDisplay.style.backgroundColor = color; + } + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, color } }); + } + + // Vertical anchor + private setVerticalAnchor(anchor: "top" | "center" | "bottom"): void { + this.updateAssetProperty({ alignment: { ...this.getCurrentAsset()?.alignment, vertical: anchor } }); + this.updateAnchorActiveState(anchor); + } + + private updateAnchorActiveState(anchor: string): void { + this.setButtonActive(this.anchorTopBtn, anchor === "top"); + this.setButtonActive(this.anchorMiddleBtn, anchor === "center"); + this.setButtonActive(this.anchorBottomBtn, anchor === "bottom"); + } + + // Horizontal alignment + private cycleAlignment(): void { + const asset = this.getCurrentAsset(); + const current = asset?.alignment?.horizontal ?? "center"; + const cycle: Array<"left" | "center" | "right"> = ["left", "center", "right"]; + const idx = cycle.indexOf(current as "left" | "center" | "right"); + const next = cycle[(idx + 1) % cycle.length]; + + this.updateAssetProperty({ alignment: { ...asset?.alignment, horizontal: next } }); + this.updateAlignmentIcon(next); + } + + private updateAlignmentIcon(alignment: string): void { + if (!this.alignIcon) return; + + const icons: Record = { + left: TOOLBAR_ICONS.alignLeft, + center: TOOLBAR_ICONS.alignCenter, + right: TOOLBAR_ICONS.alignRight + }; + + this.alignIcon.innerHTML = icons[alignment] || icons["center"]; + } + + // Background + private handleBackgroundChange(): void { + const color = this.bgColorInput?.value ?? "#000000"; + const opacity = parseInt(this.bgOpacitySlider?.value ?? "100", 10) / 100; + + this.updateAssetProperty({ + background: { color, opacity } + }); + } + + // Stroke + private handleStrokeChange(): void { + const width = parseInt(this.strokeWidthSlider?.value ?? "0", 10); + const color = this.strokeColorInput?.value ?? "#000000"; + + this.updateAssetProperty({ + stroke: { width, color } + }); + } + + // Sync UI with current clip state + protected override syncState(): void { + const asset = this.getCurrentAsset(); + if (!asset) return; + + // Text + if (this.textEditArea) { + this.textEditArea.value = asset.text || ""; + } + + // Size + if (this.sizeInput) { + this.sizeInput.value = String(asset.font?.size ?? 32); + } + + // Font family + const fontFamily = asset.font?.family ?? "Open Sans"; + if (this.fontPreview) { + this.fontPreview.style.fontFamily = `'${fontFamily}'`; + } + this.updateFontActiveState(fontFamily); + + // Bold + const weight = asset.font?.weight ?? 400; + this.setButtonActive(this.boldBtn, weight >= 700); + + // Font color + if (this.fontColorInput && asset.font?.color) { + this.fontColorInput.value = asset.font.color; + } + if (this.colorDisplay) { + this.colorDisplay.style.backgroundColor = asset.font?.color ?? "#FFFFFF"; + } + + // Line height + const lineHeight = asset.font?.lineHeight ?? 1.2; + if (this.lineHeightSlider) this.lineHeightSlider.value = String(Math.round(lineHeight * 10)); + if (this.lineHeightValue) this.lineHeightValue.textContent = lineHeight.toFixed(1); + + // Vertical anchor + const verticalAnchor = asset.alignment?.vertical ?? "center"; + this.updateAnchorActiveState(verticalAnchor); + + // Horizontal alignment + const horizontalAlign = asset.alignment?.horizontal ?? "center"; + this.updateAlignmentIcon(horizontalAlign); + + // Background + if (this.bgColorInput && asset.background?.color) { + this.bgColorInput.value = asset.background.color; + } + const bgOpacity = Math.round((asset.background?.opacity ?? 1) * 100); + if (this.bgOpacitySlider) this.bgOpacitySlider.value = String(bgOpacity); + if (this.bgOpacityValue) this.bgOpacityValue.textContent = String(bgOpacity); + + // Stroke + const strokeWidth = asset.stroke?.width ?? 0; + if (this.strokeWidthSlider) this.strokeWidthSlider.value = String(strokeWidth); + if (this.strokeWidthValue) this.strokeWidthValue.textContent = String(strokeWidth); + if (this.strokeColorInput && asset.stroke?.color) { + this.strokeColorInput.value = asset.stroke.color; + } + } + + override dispose(): void { + if (this.textEditDebounceTimer) { + clearTimeout(this.textEditDebounceTimer); + } + + // Call base dispose + super.dispose(); + + // Null all element references + this.textEditBtn = null; + this.textEditPopup = null; + this.textEditArea = null; + this.sizeInput = null; + this.sizePopup = null; + this.fontBtn = null; + this.fontPopup = null; + this.fontPreview = null; + this.boldBtn = null; + this.fontColorBtn = null; + this.fontColorPopup = null; + this.fontColorInput = null; + this.colorDisplay = null; + this.spacingBtn = null; + this.spacingPopup = null; + this.lineHeightSlider = null; + this.lineHeightValue = null; + this.anchorTopBtn = null; + this.anchorMiddleBtn = null; + this.anchorBottomBtn = null; + this.alignBtn = null; + this.alignIcon = null; + this.backgroundBtn = null; + this.backgroundPopup = null; + this.bgColorInput = null; + this.bgOpacitySlider = null; + this.bgOpacityValue = null; + this.strokeBtn = null; + this.strokePopup = null; + this.strokeWidthSlider = null; + this.strokeWidthValue = null; + this.strokeColorInput = null; + } +} From a22c5fd6dd5adf9741d6da060e0ccdf58804ae10 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 20:44:05 +1100 Subject: [PATCH 095/463] feat: add audio support to media toolbar with visual controls toggling --- .../canvas/players/rich-text-player.ts | 2 +- src/components/canvas/shotstack-canvas.ts | 4 +- src/core/ui/media-toolbar.css.ts | 9 + src/core/ui/media-toolbar.ts | 217 ++++++++++-------- 4 files changed, 128 insertions(+), 104 deletions(-) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 99bc20c1..bc6c05f8 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -480,6 +480,6 @@ export class RichTextPlayer extends Player { } private getCurrentTime(): number { - return this.edit.playbackTime; + return this.edit.playbackTime - this.getStart(); } } diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index d17e3e7c..5306f060 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -331,10 +331,10 @@ export class Canvas { this.mediaToolbar.hide(); this.richTextToolbar.hide(); this.textToolbar.show(trackIndex, clipIndex); - } else if (assetType === "video" || assetType === "image") { + } else if (assetType === "video" || assetType === "image" || assetType === "audio") { this.richTextToolbar.hide(); this.textToolbar.hide(); - this.mediaToolbar.showMedia(trackIndex, clipIndex, assetType === "video"); + this.mediaToolbar.showMedia(trackIndex, clipIndex, assetType); } else { this.richTextToolbar.hide(); this.textToolbar.hide(); diff --git a/src/core/ui/media-toolbar.css.ts b/src/core/ui/media-toolbar.css.ts index 5a5b6741..014091af 100644 --- a/src/core/ui/media-toolbar.css.ts +++ b/src/core/ui/media-toolbar.css.ts @@ -22,6 +22,15 @@ export const MEDIA_TOOLBAR_STYLES = ` display: flex; } +.ss-media-toolbar-visual { + display: flex; + align-items: center; +} + +.ss-media-toolbar-visual.hidden { + display: none; +} + .ss-media-toolbar-group { display: flex; align-items: center; diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index c9b9a274..ebd2d433 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -30,8 +30,10 @@ const ICONS = { moreVertical: `` }; +type MediaAssetType = "video" | "image" | "audio"; + export class MediaToolbar extends BaseToolbar { - private isVideoClip: boolean = false; + private assetType: MediaAssetType = "image"; // Current values private currentFit: FitValue = "crop"; @@ -83,6 +85,9 @@ export class MediaToolbar extends BaseToolbar { // Volume section private volumeSection: HTMLDivElement | null = null; + // Visual controls section (fit, opacity, scale, transition - hidden for audio) + private visualSection: HTMLDivElement | null = null; + // Advanced menu elements private advancedBtn: HTMLButtonElement | null = null; private advancedPopup: HTMLDivElement | null = null; @@ -102,63 +107,114 @@ export class MediaToolbar extends BaseToolbar { this.container.className = "ss-media-toolbar"; this.container.innerHTML = ` - -
- -
- ${FIT_OPTIONS.map( - opt => ` -
-
- ${opt.label} - ${opt.description} + +
+ +
+ +
+ ${FIT_OPTIONS.map( + opt => ` +
+
+ ${opt.label} + ${opt.description} +
+ ${ICONS.check}
- ${ICONS.check} + ` + ).join("")} +
+
+ +
+ + +
+ +
+
Opacity
+
+ + 100%
- ` - ).join("")} +
-
-
+
- -
- -
-
Opacity
-
- - 100% + +
+ +
+
Scale
+
+ + 100% +
-
-
+
- -
- -
-
Scale
-
- - 100% + +
+ +
+ +
+ + +
+ + +
+ + + + + + +
+ + +
+ Direction +
+ + + + +
+
+ + +
+ Speed +
+ + 1.0s + +
+
- +
@@ -176,55 +232,7 @@ export class MediaToolbar extends BaseToolbar {
-
- - -
- -
- -
- - -
- - -
- - - - - - -
- - -
- Direction -
- - - - -
-
- - -
- Speed -
- - 1.0s - -
-
-
-
- -
+
@@ -274,6 +282,7 @@ export class MediaToolbar extends BaseToolbar { this.volumeSlider = this.container.querySelector("[data-volume-slider]"); this.volumeSection = this.container.querySelector("[data-volume-section]"); + this.visualSection = this.container.querySelector("[data-visual-section]"); // Transition elements this.directionRow = this.container.querySelector("[data-direction-row]"); @@ -433,8 +442,8 @@ export class MediaToolbar extends BaseToolbar { const scale = typeof clip.scale === "number" ? clip.scale : 1; this.currentScale = Math.round(scale * 100); - // Volume (video only) - if (this.isVideoClip && clip.asset.type === "video") { + // Volume (video and audio only) + if ((this.assetType === "video" || this.assetType === "audio") && (clip.asset.type === "video" || clip.asset.type === "audio")) { const volume = typeof clip.asset.volume === "number" ? clip.asset.volume : 1; this.currentVolume = Math.round(volume * 100); } @@ -466,9 +475,14 @@ export class MediaToolbar extends BaseToolbar { // Update dynamic source state this.updateDynamicSourceUI(); - // Show/hide volume section based on asset type + // Show/hide visual section (hidden for audio) + if (this.visualSection) { + this.visualSection.classList.toggle("hidden", this.assetType === "audio"); + } + + // Show/hide volume section (hidden for image) if (this.volumeSection) { - this.volumeSection.classList.toggle("hidden", !this.isVideoClip); + this.volumeSection.classList.toggle("hidden", this.assetType === "image"); } } @@ -924,10 +938,10 @@ export class MediaToolbar extends BaseToolbar { * Show the toolbar for a specific clip. * @param trackIndex - Track index * @param clipIndex - Clip index - * @param isVideo - Whether the clip is a video (optional, defaults to false) + * @param assetType - The asset type (video, image, or audio) */ - showMedia(trackIndex: number, clipIndex: number, isVideo: boolean = false): void { - this.isVideoClip = isVideo; + showMedia(trackIndex: number, clipIndex: number, assetType: MediaAssetType = "image"): void { + this.assetType = assetType; super.show(trackIndex, clipIndex); } @@ -956,6 +970,7 @@ export class MediaToolbar extends BaseToolbar { this.volumeValue = null; this.volumeSection = null; + this.visualSection = null; // Transition elements this.directionRow = null; From f3503b477f31953d49bf4253134aad98c2935845 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 20:44:12 +1100 Subject: [PATCH 096/463] chore: update @shotstack/shotstack-canvas to 1.6.5 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8ff399b2..78645459 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ }, "dependencies": { "@huggingface/transformers": "^3.0.0", - "@shotstack/shotstack-canvas": "^1.6.4", + "@shotstack/shotstack-canvas": "^1.6.5", "fast-deep-equal": "^3.1.3", "howler": "^2.2.4", "mediabunny": "^1.11.2", From 2b55d596b1d00c4e22232385a44afa3cdd699a7a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 21:59:18 +1100 Subject: [PATCH 097/463] refactor: migrate luma attachment tracking from indices to Player references --- .../core/state/timeline-state.ts | 104 +++++++++++------- .../interaction/interaction-controller.ts | 59 +++++----- src/core/commands/delete-clip-command.ts | 24 +++- src/core/edit.ts | 49 +++++++++ 4 files changed, 168 insertions(+), 68 deletions(-) diff --git a/src/components/timeline-html/core/state/timeline-state.ts b/src/components/timeline-html/core/state/timeline-state.ts index a1ef13d6..c062a835 100644 --- a/src/components/timeline-html/core/state/timeline-state.ts +++ b/src/components/timeline-html/core/state/timeline-state.ts @@ -1,3 +1,4 @@ +import type { Player } from "@canvas/players/player"; import type { Edit } from "@core/edit"; import type { ResolvedClip } from "@schemas/clip"; import type { ResolvedTrack } from "@schemas/track"; @@ -6,23 +7,17 @@ import type { TrackState, ClipState, ViewportState, PlaybackState } from "../../ type ClipVisualState = "normal" | "selected" | "dragging" | "resizing"; -/** Clip reference for luma attachments */ -interface LumaAttachmentRef { - trackIndex: number; - clipIndex: number; -} - /** Simplified state manager - only holds UI state, derives data from Edit */ export class TimelineStateManager { // UI-only state (not in Edit) private viewport: ViewportState; private clipVisualStates = new Map(); - // Luma attachment map: key = "trackIndex:clipIndex" of content clip, value = luma clip reference - private lumaAttachments = new Map(); + // Luma attachment map: contentPlayer → lumaPlayer (stable across index changes) + private lumaAttachments = new Map(); - // Track which attached lumas are currently visible for editing - private lumaEditingVisible = new Set(); + // Track which content Players have visible lumas for editing + private lumaEditingVisible = new Set(); constructor( private readonly edit: Edit, @@ -132,37 +127,64 @@ export class TimelineStateManager { // ========== Luma Attachments ========== - /** Attach a luma clip to a content clip */ + /** Attach a luma Player to a content Player */ + public attachLumaByPlayer(contentPlayer: Player, lumaPlayer: Player): void { + this.lumaAttachments.set(contentPlayer, lumaPlayer); + } + + /** Attach a luma clip to a content clip by indices (resolves to Players) */ public attachLuma(contentTrack: number, contentClip: number, lumaTrack: number, lumaClip: number): void { - const key = `${contentTrack}:${contentClip}`; - this.lumaAttachments.set(key, { trackIndex: lumaTrack, clipIndex: lumaClip }); + const contentPlayer = this.edit.getPlayerClip(contentTrack, contentClip); + const lumaPlayer = this.edit.getPlayerClip(lumaTrack, lumaClip); + if (contentPlayer && lumaPlayer) { + this.lumaAttachments.set(contentPlayer, lumaPlayer); + } } - /** Detach luma from a content clip */ + /** Detach luma from a content clip by indices */ public detachLuma(contentTrack: number, contentClip: number): void { - const key = `${contentTrack}:${contentClip}`; - this.lumaAttachments.delete(key); + const contentPlayer = this.edit.getPlayerClip(contentTrack, contentClip); + if (contentPlayer) { + this.lumaAttachments.delete(contentPlayer); + } } - /** Get attached luma for a content clip */ - public getAttachedLuma(trackIndex: number, clipIndex: number): LumaAttachmentRef | null { - return this.lumaAttachments.get(`${trackIndex}:${clipIndex}`) ?? null; + /** Detach luma from a content Player */ + public detachLumaByPlayer(contentPlayer: Player): void { + this.lumaAttachments.delete(contentPlayer); + } + + /** Get attached luma Player for a content clip (by indices) */ + public getAttachedLumaPlayer(trackIndex: number, clipIndex: number): Player | null { + const contentPlayer = this.edit.getPlayerClip(trackIndex, clipIndex); + if (!contentPlayer) return null; + return this.lumaAttachments.get(contentPlayer) ?? null; + } + + /** Get attached luma indices for a content clip (derives from Player references) */ + public getAttachedLuma(trackIndex: number, clipIndex: number): { trackIndex: number; clipIndex: number } | null { + const lumaPlayer = this.getAttachedLumaPlayer(trackIndex, clipIndex); + if (!lumaPlayer) return null; + return this.edit.findClipIndices(lumaPlayer); } /** Check if a luma clip is attached to any content clip */ public isLumaAttached(lumaTrack: number, lumaClip: number): boolean { - for (const ref of this.lumaAttachments.values()) { - if (ref.trackIndex === lumaTrack && ref.clipIndex === lumaClip) return true; + const lumaPlayer = this.edit.getPlayerClip(lumaTrack, lumaClip); + if (!lumaPlayer) return false; + for (const attachedLuma of this.lumaAttachments.values()) { + if (attachedLuma === lumaPlayer) return true; } return false; } /** Get the content clip that a luma is attached to */ - public getContentClipForLuma(lumaTrack: number, lumaClip: number): LumaAttachmentRef | null { - for (const [key, ref] of this.lumaAttachments.entries()) { - if (ref.trackIndex === lumaTrack && ref.clipIndex === lumaClip) { - const [track, clip] = key.split(":").map(Number); - return { trackIndex: track, clipIndex: clip }; + public getContentClipForLuma(lumaTrack: number, lumaClip: number): { trackIndex: number; clipIndex: number } | null { + const lumaPlayer = this.edit.getPlayerClip(lumaTrack, lumaClip); + if (!lumaPlayer) return null; + for (const [contentPlayer, attachedLuma] of this.lumaAttachments.entries()) { + if (attachedLuma === lumaPlayer) { + return this.edit.findClipIndices(contentPlayer); } } return null; @@ -174,20 +196,23 @@ export class TimelineStateManager { this.lumaEditingVisible.clear(); } - /** Toggle visibility of attached luma for editing */ + /** Toggle visibility of attached luma for editing (by indices) */ public toggleLumaVisibility(contentTrack: number, contentClip: number): boolean { - const key = `${contentTrack}:${contentClip}`; - if (this.lumaEditingVisible.has(key)) { - this.lumaEditingVisible.delete(key); + const contentPlayer = this.edit.getPlayerClip(contentTrack, contentClip); + if (!contentPlayer) return false; + if (this.lumaEditingVisible.has(contentPlayer)) { + this.lumaEditingVisible.delete(contentPlayer); return false; // Now hidden } - this.lumaEditingVisible.add(key); + this.lumaEditingVisible.add(contentPlayer); return true; // Now visible } /** Check if attached luma is currently visible for editing */ public isLumaVisibleForEditing(contentTrack: number, contentClip: number): boolean { - return this.lumaEditingVisible.has(`${contentTrack}:${contentClip}`); + const contentPlayer = this.edit.getPlayerClip(contentTrack, contentClip); + if (!contentPlayer) return false; + return this.lumaEditingVisible.has(contentPlayer); } /** Auto-detect and register luma attachments based on clip overlap */ @@ -207,12 +232,15 @@ export class TimelineStateManager { if (clip.config.asset?.type === "luma") { const contentClip = this.findContentClipInSameTrack(clip, tracks); if (contentClip) { - const key = `${contentClip.trackIndex}:${contentClip.clipIndex}`; - this.attachLuma(contentClip.trackIndex, contentClip.clipIndex, clip.trackIndex, clip.clipIndex); - - // Restore visibility state if it existed before - if (previousVisibility.has(key)) { - this.lumaEditingVisible.add(key); + const contentPlayer = this.edit.getPlayerClip(contentClip.trackIndex, contentClip.clipIndex); + const lumaPlayer = this.edit.getPlayerClip(clip.trackIndex, clip.clipIndex); + if (contentPlayer && lumaPlayer) { + this.lumaAttachments.set(contentPlayer, lumaPlayer); + + // Restore visibility state if it existed before + if (previousVisibility.has(contentPlayer)) { + this.lumaEditingVisible.add(contentPlayer); + } } } } diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 30668306..ca03e8bb 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -483,8 +483,8 @@ export class InteractionController { return; } - // Check if this content clip has an attached luma that needs to sync - const attachedLuma = this.stateManager.getAttachedLuma(clipRef.trackIndex, clipRef.clipIndex); + // Get attached luma Player reference BEFORE any move (stable across index changes) + const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); // Execute appropriate command based on drag target (non-luma clips) if (dragTarget.type === "insert") { @@ -492,27 +492,25 @@ export class InteractionController { const command = new CreateTrackAndMoveClipCommand(dragTarget.insertionIndex, originalTrack, clipRef.clipIndex, newTime); this.edit.executeEditCommand(command); - // Also move attached luma to the new track and time - if (attachedLuma) { - const lumaCommand = new MoveClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, dragTarget.insertionIndex, newTime); - this.edit.executeEditCommand(lumaCommand); - // Update attachment reference to new track - this.stateManager.detachLuma(clipRef.trackIndex, clipRef.clipIndex); - this.stateManager.attachLuma(dragTarget.insertionIndex, clipRef.clipIndex, dragTarget.insertionIndex, attachedLuma.clipIndex); + // Move attached luma - get fresh indices AFTER content move + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.insertionIndex, newTime); + this.edit.executeEditCommand(lumaCommand); + } } } else if (collisionResult.pushOffset > 0) { // Need to push clips forward - use MoveClipWithPushCommand const command = new MoveClipWithPushCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime, collisionResult.pushOffset); this.edit.executeEditCommand(command); - // Also move attached luma to new position - if (attachedLuma) { - const lumaCommand = new MoveClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, dragTarget.trackIndex, newTime); - this.edit.executeEditCommand(lumaCommand); - // Update attachment reference if track changed - if (dragTarget.trackIndex !== originalTrack) { - this.stateManager.detachLuma(clipRef.trackIndex, clipRef.clipIndex); - this.stateManager.attachLuma(dragTarget.trackIndex, clipRef.clipIndex, dragTarget.trackIndex, attachedLuma.clipIndex); + // Move attached luma - get fresh indices AFTER content move + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(lumaCommand); } } } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { @@ -520,14 +518,12 @@ export class InteractionController { const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); this.edit.executeEditCommand(command); - // Also move attached luma to new position - if (attachedLuma) { - const lumaCommand = new MoveClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, dragTarget.trackIndex, newTime); - this.edit.executeEditCommand(lumaCommand); - // Update attachment reference if track changed - if (dragTarget.trackIndex !== originalTrack) { - this.stateManager.detachLuma(clipRef.trackIndex, clipRef.clipIndex); - this.stateManager.attachLuma(dragTarget.trackIndex, clipRef.clipIndex, dragTarget.trackIndex, attachedLuma.clipIndex); + // Move attached luma - get fresh indices AFTER content move + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(lumaCommand); } } } @@ -568,16 +564,21 @@ export class InteractionController { newLength = Math.max(0.1, time - originalStart); } + // Get attached luma Player reference BEFORE resize (stable across index changes) + const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); + // Execute resize command if dimensions changed if (newLength !== originalLength) { const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); this.edit.executeEditCommand(command); // Also resize attached luma to match - const attachedLuma = this.stateManager.getAttachedLuma(clipRef.trackIndex, clipRef.clipIndex); - if (attachedLuma) { - const lumaResizeCommand = new ResizeClipCommand(attachedLuma.trackIndex, attachedLuma.clipIndex, newLength); - this.edit.executeEditCommand(lumaResizeCommand); + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); + this.edit.executeEditCommand(lumaResizeCommand); + } } // TODO: For left-edge resize (start changed), also need MoveClipCommand diff --git a/src/core/commands/delete-clip-command.ts b/src/core/commands/delete-clip-command.ts index b724a912..cb49d5d6 100644 --- a/src/core/commands/delete-clip-command.ts +++ b/src/core/commands/delete-clip-command.ts @@ -1,10 +1,13 @@ import type { Player } from "@canvas/players/player"; +import { DeleteTrackCommand } from "./delete-track-command"; import type { EditCommand, CommandContext } from "./types"; export class DeleteClipCommand implements EditCommand { name = "deleteClip"; private deletedClip?: Player; + private deleteTrackCommand?: DeleteTrackCommand; + private trackWasDeleted = false; constructor( private trackIdx: number, @@ -14,6 +17,7 @@ export class DeleteClipCommand implements EditCommand { execute(context?: CommandContext): void { if (!context) return; // For backward compatibility const clips = context.getClips(); + const tracks = context.getTracks(); const trackClips = clips.filter((c: Player) => c.layer === this.trackIdx + 1); this.deletedClip = trackClips[this.clipIdx]; @@ -26,6 +30,17 @@ export class DeleteClipCommand implements EditCommand { // Use clipIdx - 1 because the clip at clipIdx no longer exists context.propagateTimingChanges(this.trackIdx, this.clipIdx - 1); + // Check if track is now empty and delete it (same pattern as MoveClipCommand) + const track = tracks[this.trackIdx]; + if (track && track.length === 0) { + this.deleteTrackCommand = new DeleteTrackCommand(this.trackIdx); + this.deleteTrackCommand.execute(context); + this.trackWasDeleted = true; + + // Emit timeline:updated so timeline UI refreshes after track deletion + context.emitEvent("timeline:updated", { current: context.getEditState() }); + } + // Emit event so luma masking and other listeners can update context.emitEvent("clip:deleted", { trackIndex: this.trackIdx, @@ -34,8 +49,15 @@ export class DeleteClipCommand implements EditCommand { } } - undo(context?: CommandContext): void { + async undo(context?: CommandContext): Promise { if (!context || !this.deletedClip) return; + + // Restore deleted track first if it was deleted + if (this.trackWasDeleted && this.deleteTrackCommand) { + await this.deleteTrackCommand.undo(context); + this.trackWasDeleted = false; + } + context.undeleteClip(this.trackIdx, this.deletedClip); // Propagate timing changes after restoring the clip diff --git a/src/core/edit.ts b/src/core/edit.ts index ce2a4851..09f8ceee 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -413,6 +413,36 @@ export class Edit extends Entity { } public deleteClip(trackIdx: number, clipIdx: number): void { + const track = this.tracks[trackIdx]; + if (!track) return; + + // Get the clip being deleted + const clipToDelete = track[clipIdx]; + if (!clipToDelete) return; + + // Check if this is a content clip (not a luma) + const isContentClip = !(clipToDelete instanceof LumaPlayer); + + if (isContentClip) { + // Find attached luma in the same track + const lumaIndex = track.findIndex(clip => clip instanceof LumaPlayer); + + if (lumaIndex !== -1) { + // Delete luma first (handles index shifting correctly) + // If luma comes before content clip, content clip index shifts after luma deletion + const adjustedContentIdx = lumaIndex < clipIdx ? clipIdx - 1 : clipIdx; + + const lumaCommand = new DeleteClipCommand(trackIdx, lumaIndex); + this.executeCommand(lumaCommand); + + // Now delete content clip with adjusted index + const contentCommand = new DeleteClipCommand(trackIdx, adjustedContentIdx); + this.executeCommand(contentCommand); + return; + } + } + + // No luma attachment or deleting a luma directly - just delete the clip const command = new DeleteClipCommand(trackIdx, clipIdx); this.executeCommand(command); } @@ -1025,6 +1055,17 @@ export class Edit extends Entity { this.events.on("clip:restored", () => { this.rebuildLumaMasksIfNeeded(); }); + + // Rebuild masks after clip deletion (track shift may re-add luma to scene) + this.events.on("clip:deleted", () => { + this.rebuildLumaMasksIfNeeded(); + }); + + // Rebuild masks after any timeline change (clips added/removed/tracks changed) + // This handles the case where AddTrackCommand re-adds luma players to scene + this.events.on("timeline:updated", () => { + this.rebuildLumaMasksIfNeeded(); + }); } /** Clean up luma mask when a luma player is deleted. */ @@ -1072,6 +1113,7 @@ export class Edit extends Entity { /** * Rebuild luma masks for any tracks that need masking but don't have it set up. * Called after clip operations (move, delete, etc.) to ensure canvas stays in sync. + * Also ensures luma players are hidden from display even if mask already exists. */ private async rebuildLumaMasksIfNeeded(): Promise { if (!this.canvas) return; @@ -1081,6 +1123,12 @@ export class Edit extends Entity { const lumaPlayer = trackClips.find(clip => clip instanceof LumaPlayer) as LumaPlayer | undefined; const contentClips = trackClips.filter(clip => !(clip instanceof LumaPlayer)); + // ALWAYS hide luma player if it has a parent (even if mask exists) + // This handles the case where AddTrackCommand re-adds luma to scene + if (lumaPlayer) { + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + const existingMask = lumaPlayer && this.activeLumaMasks.find(m => m.lumaPlayer === lumaPlayer); if (lumaPlayer && !existingMask && contentClips.length > 0) { @@ -1092,6 +1140,7 @@ export class Edit extends Entity { const lumaSprite = lumaPlayer.getSprite(); if (lumaSprite?.texture) { this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); + // Already removed above, but kept for safety lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); } } From bdbe3f82fce96c542b1f9f341353cfddb9a454f4 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 22:49:29 +1100 Subject: [PATCH 098/463] feat: listen to clip selection events and trigger timeline rerender --- src/components/timeline-html/html-timeline.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts index 378f5ba5..16aa60d7 100644 --- a/src/components/timeline-html/html-timeline.ts +++ b/src/components/timeline-html/html-timeline.ts @@ -46,6 +46,7 @@ export class HtmlTimeline extends TimelineEntity { private readonly handlePlaybackPlay: () => void; private readonly handlePlaybackPause: () => void; private readonly handlePlaybackStop: () => void; + private readonly handleClipSelected: () => void; constructor( private readonly edit: Edit, @@ -92,6 +93,7 @@ export class HtmlTimeline extends TimelineEntity { this.stopRenderLoop(); this.requestRender(); // Final render to update UI with stopped state }; + this.handleClipSelected = () => this.requestRender(); } /** Initialize and mount the timeline */ @@ -197,6 +199,9 @@ export class HtmlTimeline extends TimelineEntity { this.edit.events.on("playback:play", this.handlePlaybackPlay); this.edit.events.on("playback:pause", this.handlePlaybackPause); this.edit.events.on("playback:stop", this.handlePlaybackStop); + + // Listen for selection changes (from canvas or other sources) + this.edit.events.on("clip:selected", this.handleClipSelected); } private removeEventListeners(): void { @@ -204,6 +209,7 @@ export class HtmlTimeline extends TimelineEntity { this.edit.events.off("playback:play", this.handlePlaybackPlay); this.edit.events.off("playback:pause", this.handlePlaybackPause); this.edit.events.off("playback:stop", this.handlePlaybackStop); + this.edit.events.off("clip:selected", this.handleClipSelected); } /** Start continuous render loop (during playback or interaction) */ From 1d6494e5cad20653cbd1d712f06cb7dff320bc6a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 12 Dec 2025 23:12:12 +1100 Subject: [PATCH 099/463] feat: add effect controls with zoom and slide transitions to media toolbar --- src/core/ui/media-toolbar.css.ts | 216 ++++++++++++++++++++++++++ src/core/ui/media-toolbar.ts | 258 ++++++++++++++++++++++++++++++- 2 files changed, 471 insertions(+), 3 deletions(-) diff --git a/src/core/ui/media-toolbar.css.ts b/src/core/ui/media-toolbar.css.ts index 014091af..d47cb4bb 100644 --- a/src/core/ui/media-toolbar.css.ts +++ b/src/core/ui/media-toolbar.css.ts @@ -669,4 +669,220 @@ export const MEDIA_TOOLBAR_STYLES = ` font-size: 11px; color: rgba(255, 255, 255, 0.4); } + +/* Effect popup - progressive disclosure design */ +.ss-media-toolbar-popup--effect { + min-width: 200px; + padding: 12px; +} + +/* Effect types - horizontal row */ +.ss-effect-types { + display: flex; + gap: 6px; +} + +.ss-effect-type { + flex: 1; + padding: 10px 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.ss-effect-type:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.ss-effect-type.active { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Variant row (for Zoom) - progressive disclosure */ +.ss-effect-variant-row { + display: none; + align-items: center; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + animation: fadeSlideIn 0.15s ease; +} + +.ss-effect-variant-row.visible { + display: flex; +} + +.ss-effect-variants { + display: flex; + gap: 4px; + flex: 1; +} + +.ss-effect-variant { + flex: 1; + padding: 6px 12px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + color: rgba(255, 255, 255, 0.5); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.ss-effect-variant:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.ss-effect-variant.active { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Direction row (for Slide) - progressive disclosure */ +.ss-effect-direction-row { + display: none; + align-items: center; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + animation: fadeSlideIn 0.15s ease; +} + +.ss-effect-direction-row.visible { + display: flex; +} + +.ss-effect-directions { + display: flex; + gap: 4px; + flex: 1; +} + +.ss-effect-dir { + flex: 1; + padding: 6px 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + color: rgba(255, 255, 255, 0.5); + font-size: 13px; + cursor: pointer; + transition: all 0.15s ease; + text-align: center; +} + +.ss-effect-dir:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.ss-effect-dir.active { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); + color: #fff; +} + +/* Speed row - shows when effect selected */ +.ss-effect-speed-row { + display: none; + align-items: center; + gap: 8px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.06); + animation: fadeSlideIn 0.15s ease; +} + +.ss-effect-speed-row.visible { + display: flex; +} + +/* Shared label style */ +.ss-effect-label { + font-size: 10px; + font-weight: 500; + color: rgba(255, 255, 255, 0.4); + text-transform: uppercase; + letter-spacing: 0.03em; + min-width: 52px; +} + +/* Speed stepper */ +.ss-effect-speed-stepper { + display: flex; + align-items: center; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 6px; + overflow: hidden; +} + +.ss-effect-speed-btn { + width: 28px; + height: 26px; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.5); + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + display: flex; + align-items: center; + justify-content: center; +} + +.ss-effect-speed-btn:hover { + background: rgba(255, 255, 255, 0.08); + color: rgba(255, 255, 255, 0.9); +} + +.ss-effect-speed-btn:active { + background: rgba(255, 255, 255, 0.12); +} + +.ss-effect-speed-btn:disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.ss-effect-speed-value { + min-width: 42px; + padding: 0 4px; + text-align: center; + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.85); + font-variant-numeric: tabular-nums; + border-left: 1px solid rgba(255, 255, 255, 0.06); + border-right: 1px solid rgba(255, 255, 255, 0.06); +} + +/* Subtle entrance animation */ +@keyframes fadeSlideIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} `; diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index ebd2d433..ec4770eb 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -27,7 +27,8 @@ const ICONS = { transition: ``, chevron: ``, check: ``, - moreVertical: `` + moreVertical: ``, + effect: `` }; type MediaAssetType = "video" | "image" | "audio"; @@ -57,6 +58,21 @@ export class MediaToolbar extends BaseToolbar { private directionRow: HTMLDivElement | null = null; private speedValueLabel: HTMLSpanElement | null = null; + // Effect state - progressive disclosure design + private effectType: "" | "zoom" | "slide" = ""; + private effectVariant: "In" | "Out" = "In"; // For zoom + private effectDirection: "Left" | "Right" | "Up" | "Down" = "Right"; // For slide + private effectSpeed: number = 1.0; + private readonly EFFECT_SPEED_VALUES = [0.5, 1.0, 2.0]; + + // Effect popup elements + private effectBtn: HTMLButtonElement | null = null; + private effectPopup: HTMLDivElement | null = null; + private effectVariantRow: HTMLDivElement | null = null; + private effectDirectionRow: HTMLDivElement | null = null; + private effectSpeedRow: HTMLDivElement | null = null; + private effectSpeedValueLabel: HTMLSpanElement | null = null; + // Button elements private fitBtn: HTMLButtonElement | null = null; private opacityBtn: HTMLButtonElement | null = null; @@ -212,6 +228,54 @@ export class MediaToolbar extends BaseToolbar {
+ +
+ + +
+ +
+ +
+ + + +
+ + +
+ Variant +
+ + +
+
+ + +
+ Direction +
+ + + + +
+
+ + +
+ Speed +
+ + 1s + +
+
+
+
@@ -288,6 +352,14 @@ export class MediaToolbar extends BaseToolbar { this.directionRow = this.container.querySelector("[data-direction-row]"); this.speedValueLabel = this.container.querySelector("[data-speed-value]"); + // Effect elements + this.effectBtn = this.container.querySelector('[data-action="effect"]'); + this.effectPopup = this.container.querySelector('[data-popup="effect"]'); + this.effectVariantRow = this.container.querySelector("[data-effect-variant-row]"); + this.effectDirectionRow = this.container.querySelector("[data-effect-direction-row]"); + this.effectSpeedRow = this.container.querySelector("[data-effect-speed-row]"); + this.effectSpeedValueLabel = this.container.querySelector("[data-effect-speed-value]"); + // Advanced menu elements this.advancedBtn = this.container.querySelector('[data-action="advanced"]'); this.advancedPopup = this.container.querySelector('[data-popup="advanced"]'); @@ -321,6 +393,10 @@ export class MediaToolbar extends BaseToolbar { e.stopPropagation(); this.togglePopupByName("transition"); }); + this.effectBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopupByName("effect"); + }); this.advancedBtn?.addEventListener("click", e => { e.stopPropagation(); this.togglePopupByName("advanced"); @@ -388,15 +464,49 @@ export class MediaToolbar extends BaseToolbar { const speedIncrease = this.transitionPopup?.querySelector("[data-speed-increase]"); speedDecrease?.addEventListener("click", () => this.handleSpeedStep(-1)); speedIncrease?.addEventListener("click", () => this.handleSpeedStep(1)); + + // Effect type buttons + this.effectPopup?.querySelectorAll("[data-effect-type]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const effectType = el.dataset["effectType"] || ""; + this.handleEffectTypeSelect(effectType as "" | "zoom" | "slide"); + }); + }); + + // Effect variant buttons (for Zoom) + this.effectPopup?.querySelectorAll("[data-variant]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const variant = el.dataset["variant"] || "In"; + this.handleEffectVariantSelect(variant as "In" | "Out"); + }); + }); + + // Effect direction buttons (for Slide) + this.effectPopup?.querySelectorAll("[data-effect-dir]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const dir = el.dataset["effectDir"] || "Right"; + this.handleEffectDirectionSelect(dir as "Left" | "Right" | "Up" | "Down"); + }); + }); + + // Effect speed buttons + const effectSpeedDecrease = this.effectPopup?.querySelector("[data-effect-speed-decrease]"); + const effectSpeedIncrease = this.effectPopup?.querySelector("[data-effect-speed-increase]"); + effectSpeedDecrease?.addEventListener("click", () => this.handleEffectSpeedStep(-1)); + effectSpeedIncrease?.addEventListener("click", () => this.handleEffectSpeedStep(1)); } - private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "advanced"): void { + private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced"): void { const popupMap = { fit: { popup: this.fitPopup, btn: this.fitBtn }, opacity: { popup: this.opacityPopup, btn: this.opacityBtn }, scale: { popup: this.scalePopup, btn: this.scaleBtn }, volume: { popup: this.volumePopup, btn: this.volumeBtn }, transition: { popup: this.transitionPopup, btn: this.transitionBtn }, + effect: { popup: this.effectPopup, btn: this.effectBtn }, advanced: { popup: this.advancedPopup, btn: this.advancedBtn } }; @@ -418,11 +528,12 @@ export class MediaToolbar extends BaseToolbar { this.scaleBtn?.classList.remove("active"); this.volumeBtn?.classList.remove("active"); this.transitionBtn?.classList.remove("active"); + this.effectBtn?.classList.remove("active"); this.advancedBtn?.classList.remove("active"); } protected override getPopupList(): (HTMLElement | null)[] { - return [this.fitPopup, this.opacityPopup, this.scalePopup, this.volumePopup, this.transitionPopup, this.advancedPopup]; + return [this.fitPopup, this.opacityPopup, this.scalePopup, this.volumePopup, this.transitionPopup, this.effectPopup, this.advancedPopup]; } protected override syncState(): void { @@ -457,6 +568,9 @@ export class MediaToolbar extends BaseToolbar { this.transitionOutEffect = parsedOut.effect; this.transitionOutDirection = parsedOut.direction; this.transitionOutSpeed = parsedOut.speed; + + // Effect - parse type, variant/direction, and speed from stored value + this.parseEffectValue(clip.effect || ""); } // Update displays @@ -472,6 +586,9 @@ export class MediaToolbar extends BaseToolbar { this.activeTransitionTab = "in"; this.updateTransitionUI(); + // Update effect UI + this.updateEffectUI(); + // Update dynamic source state this.updateDynamicSourceUI(); @@ -738,6 +855,133 @@ export class MediaToolbar extends BaseToolbar { if (increaseBtn) increaseBtn.disabled = speedIdx >= this.SPEED_VALUES.length - 1; } + // ==================== Effect Handlers ==================== + + private handleEffectTypeSelect(effectType: "" | "zoom" | "slide"): void { + this.effectType = effectType; + this.updateEffectUI(); + this.applyEffect(); + } + + private handleEffectVariantSelect(variant: "In" | "Out"): void { + this.effectVariant = variant; + this.updateEffectUI(); + this.applyEffect(); + } + + private handleEffectDirectionSelect(direction: "Left" | "Right" | "Up" | "Down"): void { + this.effectDirection = direction; + this.updateEffectUI(); + this.applyEffect(); + } + + private handleEffectSpeedStep(direction: number): void { + const currentIndex = this.EFFECT_SPEED_VALUES.indexOf(this.effectSpeed); + const newIndex = Math.max(0, Math.min(this.EFFECT_SPEED_VALUES.length - 1, currentIndex + direction)); + this.effectSpeed = this.EFFECT_SPEED_VALUES[newIndex]; + this.updateEffectUI(); + this.applyEffect(); + } + + private updateEffectUI(): void { + // Update active state on effect type buttons + this.effectPopup?.querySelectorAll("[data-effect-type]").forEach(btn => { + const type = (btn as HTMLElement).dataset["effectType"] || ""; + btn.classList.toggle("active", type === this.effectType); + }); + + // Show/hide variant row (for Zoom) + this.effectVariantRow?.classList.toggle("visible", this.effectType === "zoom"); + + // Update variant active states + this.effectPopup?.querySelectorAll("[data-variant]").forEach(btn => { + const variant = (btn as HTMLElement).dataset["variant"] || ""; + btn.classList.toggle("active", variant === this.effectVariant); + }); + + // Show/hide direction row (for Slide) + this.effectDirectionRow?.classList.toggle("visible", this.effectType === "slide"); + + // Update direction active states + this.effectPopup?.querySelectorAll("[data-effect-dir]").forEach(btn => { + const dir = (btn as HTMLElement).dataset["effectDir"] || ""; + btn.classList.toggle("active", dir === this.effectDirection); + }); + + // Show/hide speed row (when effect is selected) + this.effectSpeedRow?.classList.toggle("visible", this.effectType !== ""); + + // Update speed display + if (this.effectSpeedValueLabel) { + this.effectSpeedValueLabel.textContent = `${this.effectSpeed}s`; + } + + // Update stepper button disabled states + const speedIdx = this.EFFECT_SPEED_VALUES.indexOf(this.effectSpeed); + const decreaseBtn = this.effectPopup?.querySelector("[data-effect-speed-decrease]") as HTMLButtonElement | null; + const increaseBtn = this.effectPopup?.querySelector("[data-effect-speed-increase]") as HTMLButtonElement | null; + if (decreaseBtn) decreaseBtn.disabled = speedIdx <= 0; + if (increaseBtn) increaseBtn.disabled = speedIdx >= this.EFFECT_SPEED_VALUES.length - 1; + } + + private buildEffectValue(): string { + if (this.effectType === "") return ""; + + let value = ""; + if (this.effectType === "zoom") { + value = `zoom${this.effectVariant}`; // "zoomIn" or "zoomOut" + } else if (this.effectType === "slide") { + value = `slide${this.effectDirection}`; // "slideRight", "slideLeft", etc. + } + + // Add speed suffix + if (this.effectSpeed === 0.5) value += "Fast"; + else if (this.effectSpeed === 2.0) value += "Slow"; + + return value; + } + + private applyEffect(): void { + const effectValue = this.buildEffectValue(); + if (!effectValue) { + this.applyClipUpdate({ effect: undefined }); + } else { + this.applyClipUpdate({ effect: effectValue }); + } + } + + private parseEffectValue(effect: string): void { + if (!effect) { + this.effectType = ""; + this.effectSpeed = 1.0; + return; + } + + // Extract speed suffix first + let base = effect; + if (effect.endsWith("Slow")) { + this.effectSpeed = 2.0; + base = effect.slice(0, -4); + } else if (effect.endsWith("Fast")) { + this.effectSpeed = 0.5; + base = effect.slice(0, -4); + } else { + this.effectSpeed = 1.0; + } + + // Parse type and variant/direction + if (base.startsWith("zoom")) { + this.effectType = "zoom"; + this.effectVariant = base === "zoomOut" ? "Out" : "In"; + } else if (base.startsWith("slide")) { + this.effectType = "slide"; + const dir = base.replace("slide", ""); + this.effectDirection = (dir as "Left" | "Right" | "Up" | "Down") || "Right"; + } else { + this.effectType = ""; + } + } + private applyClipUpdate(updates: Record): void { if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); @@ -976,6 +1220,14 @@ export class MediaToolbar extends BaseToolbar { this.directionRow = null; this.speedValueLabel = null; + // Effect elements + this.effectBtn = null; + this.effectPopup = null; + this.effectVariantRow = null; + this.effectDirectionRow = null; + this.effectSpeedRow = null; + this.effectSpeedValueLabel = null; + // Advanced menu elements this.advancedBtn = null; this.advancedPopup = null; From c3591ab4c7d3c18c5346766afbf1512b684bc57b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 14:18:43 +1100 Subject: [PATCH 100/463] feat: add audio fade effect controls to media toolbar --- src/core/ui/media-toolbar.css.ts | 83 ++++++++++++++++++ src/core/ui/media-toolbar.ts | 142 +++++++++++++++++++++++++++++-- 2 files changed, 219 insertions(+), 6 deletions(-) diff --git a/src/core/ui/media-toolbar.css.ts b/src/core/ui/media-toolbar.css.ts index d47cb4bb..f70214da 100644 --- a/src/core/ui/media-toolbar.css.ts +++ b/src/core/ui/media-toolbar.css.ts @@ -885,4 +885,87 @@ export const MEDIA_TOOLBAR_STYLES = ` transform: translateY(0); } } + +/* Audio section - only visible for audio assets */ +.ss-media-toolbar-audio { + display: flex; + align-items: center; +} + +.ss-media-toolbar-audio.hidden { + display: none; +} + +/* Audio fade popup */ +.ss-media-toolbar-popup--audio-fade { + min-width: 180px; + padding: 8px; +} + +/* Audio fade options - 2x2 grid for balanced layout */ +.ss-audio-fade-options { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 6px; +} + +/* Audio fade button - icon + label stacked */ +.ss-audio-fade-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 12px 8px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; +} + +.ss-audio-fade-btn:hover { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.12); +} + +.ss-audio-fade-btn.active { + background: rgba(255, 255, 255, 0.12); + border-color: rgba(255, 255, 255, 0.2); +} + +/* Icon container */ +.ss-audio-fade-icon { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 16px; + color: rgba(255, 255, 255, 0.5); + transition: color 0.15s ease; +} + +.ss-audio-fade-icon svg { + width: 32px; + height: 16px; +} + +.ss-audio-fade-btn:hover .ss-audio-fade-icon, +.ss-audio-fade-btn.active .ss-audio-fade-icon { + color: rgba(255, 255, 255, 0.9); +} + +/* Label */ +.ss-audio-fade-label { + font-size: 10px; + font-weight: 500; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 0.02em; + transition: color 0.15s ease; +} + +.ss-audio-fade-btn:hover .ss-audio-fade-label, +.ss-audio-fade-btn.active .ss-audio-fade-label { + color: rgba(255, 255, 255, 0.9); +} `; diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index ec4770eb..472602f5 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -28,7 +28,12 @@ const ICONS = { chevron: ``, check: ``, moreVertical: ``, - effect: `` + effect: ``, + // Audio fade icons - waveform-inspired shapes + fadeIn: ``, + fadeOut: ``, + fadeInOut: ``, + fadeNone: `` }; type MediaAssetType = "video" | "image" | "audio"; @@ -104,6 +109,14 @@ export class MediaToolbar extends BaseToolbar { // Visual controls section (fit, opacity, scale, transition - hidden for audio) private visualSection: HTMLDivElement | null = null; + // Audio fade state + private audioFadeEffect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut" = ""; + + // Audio section element references + private audioSection: HTMLDivElement | null = null; + private audioFadeBtn: HTMLButtonElement | null = null; + private audioFadePopup: HTMLDivElement | null = null; + // Advanced menu elements private advancedBtn: HTMLButtonElement | null = null; private advancedPopup: HTMLDivElement | null = null; @@ -296,6 +309,38 @@ export class MediaToolbar extends BaseToolbar {
+ +
+
+
+ +
+
+ + + + +
+
+
+
+
@@ -360,6 +405,11 @@ export class MediaToolbar extends BaseToolbar { this.effectSpeedRow = this.container.querySelector("[data-effect-speed-row]"); this.effectSpeedValueLabel = this.container.querySelector("[data-effect-speed-value]"); + // Audio section elements + this.audioSection = this.container.querySelector("[data-audio-section]"); + this.audioFadeBtn = this.container.querySelector('[data-action="audio-fade"]'); + this.audioFadePopup = this.container.querySelector('[data-popup="audio-fade"]'); + // Advanced menu elements this.advancedBtn = this.container.querySelector('[data-action="advanced"]'); this.advancedPopup = this.container.querySelector('[data-popup="advanced"]'); @@ -497,9 +547,24 @@ export class MediaToolbar extends BaseToolbar { const effectSpeedIncrease = this.effectPopup?.querySelector("[data-effect-speed-increase]"); effectSpeedDecrease?.addEventListener("click", () => this.handleEffectSpeedStep(-1)); effectSpeedIncrease?.addEventListener("click", () => this.handleEffectSpeedStep(1)); + + // Audio fade button + this.audioFadeBtn?.addEventListener("click", e => { + e.stopPropagation(); + this.togglePopupByName("audio-fade"); + }); + + // Audio fade options + this.audioFadePopup?.querySelectorAll("[data-audio-fade]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const fadeValue = el.dataset["audioFade"] || ""; + this.handleAudioFadeSelect(fadeValue as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut"); + }); + }); } - private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced"): void { + private togglePopupByName(popup: "fit" | "opacity" | "scale" | "volume" | "transition" | "effect" | "advanced" | "audio-fade"): void { const popupMap = { fit: { popup: this.fitPopup, btn: this.fitBtn }, opacity: { popup: this.opacityPopup, btn: this.opacityBtn }, @@ -507,7 +572,8 @@ export class MediaToolbar extends BaseToolbar { volume: { popup: this.volumePopup, btn: this.volumeBtn }, transition: { popup: this.transitionPopup, btn: this.transitionBtn }, effect: { popup: this.effectPopup, btn: this.effectBtn }, - advanced: { popup: this.advancedPopup, btn: this.advancedBtn } + advanced: { popup: this.advancedPopup, btn: this.advancedBtn }, + "audio-fade": { popup: this.audioFadePopup, btn: this.audioFadeBtn } }; const isCurrentlyOpen = popupMap[popup].popup?.classList.contains("visible"); @@ -530,10 +596,11 @@ export class MediaToolbar extends BaseToolbar { this.transitionBtn?.classList.remove("active"); this.effectBtn?.classList.remove("active"); this.advancedBtn?.classList.remove("active"); + this.audioFadeBtn?.classList.remove("active"); } protected override getPopupList(): (HTMLElement | null)[] { - return [this.fitPopup, this.opacityPopup, this.scalePopup, this.volumePopup, this.transitionPopup, this.effectPopup, this.advancedPopup]; + return [this.fitPopup, this.opacityPopup, this.scalePopup, this.volumePopup, this.transitionPopup, this.effectPopup, this.advancedPopup, this.audioFadePopup]; } protected override syncState(): void { @@ -571,6 +638,11 @@ export class MediaToolbar extends BaseToolbar { // Effect - parse type, variant/direction, and speed from stored value this.parseEffectValue(clip.effect || ""); + + // Audio fade effect (for audio assets) + if (clip.asset.type === "audio") { + this.audioFadeEffect = (clip.asset.effect as "" | "fadeIn" | "fadeOut" | "fadeInFadeOut") || ""; + } } // Update displays @@ -589,6 +661,9 @@ export class MediaToolbar extends BaseToolbar { // Update effect UI this.updateEffectUI(); + // Update audio fade UI + this.updateAudioFadeUI(); + // Update dynamic source state this.updateDynamicSourceUI(); @@ -601,6 +676,11 @@ export class MediaToolbar extends BaseToolbar { if (this.volumeSection) { this.volumeSection.classList.toggle("hidden", this.assetType === "image"); } + + // Show/hide audio section (only visible for audio) + if (this.audioSection) { + this.audioSection.classList.toggle("hidden", this.assetType !== "audio"); + } } private handleFitChange(fit: FitValue): void { @@ -627,9 +707,9 @@ export class MediaToolbar extends BaseToolbar { this.currentVolume = value; this.updateVolumeDisplay(); - // Volume is on the asset, not the clip + // Volume is on the asset, not the clip (applies to both video and audio) const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); - if (player && player.clipConfiguration.asset.type === "video") { + if (player && (player.clipConfiguration.asset.type === "video" || player.clipConfiguration.asset.type === "audio")) { this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { asset: { ...player.clipConfiguration.asset, @@ -982,6 +1062,51 @@ export class MediaToolbar extends BaseToolbar { } } + // ─── Audio Fade Handlers ─────────────────────────────────────────────────── + + private handleAudioFadeSelect(effect: "" | "fadeIn" | "fadeOut" | "fadeInFadeOut"): void { + this.audioFadeEffect = effect; + this.updateAudioFadeUI(); + this.applyAudioFade(); + } + + private applyAudioFade(): void { + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (!player || player.clipConfiguration.asset.type !== "audio") return; + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { + ...player.clipConfiguration.asset, + effect: this.audioFadeEffect || undefined + } + }); + } + + private updateAudioFadeUI(): void { + if (!this.audioFadePopup) return; + + // Update active states on buttons + const buttons = this.audioFadePopup.querySelectorAll("[data-audio-fade]"); + buttons.forEach(btn => { + const fadeValue = (btn as HTMLElement).dataset["audioFade"] || ""; + btn.classList.toggle("active", fadeValue === this.audioFadeEffect); + }); + + // Update button icon to show current selection + if (this.audioFadeBtn) { + const iconMap: Record = { + "": ICONS.fadeNone, + fadeIn: ICONS.fadeIn, + fadeOut: ICONS.fadeOut, + fadeInFadeOut: ICONS.fadeInOut + }; + const svg = this.audioFadeBtn.querySelector("svg"); + if (svg) { + svg.outerHTML = iconMap[this.audioFadeEffect] || ICONS.fadeNone; + } + } + } + private applyClipUpdate(updates: Record): void { if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); @@ -1228,6 +1353,11 @@ export class MediaToolbar extends BaseToolbar { this.effectSpeedRow = null; this.effectSpeedValueLabel = null; + // Audio section elements + this.audioSection = null; + this.audioFadeBtn = null; + this.audioFadePopup = null; + // Advanced menu elements this.advancedBtn = null; this.advancedPopup = null; From f3ba4e71b457466c895f78902890257286f8fe71 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 14:33:15 +1100 Subject: [PATCH 101/463] feat: optimize luma mask rendering with frame change detection and reduced resolution --- src/components/canvas/players/luma-player.ts | 7 +++++ src/core/edit.ts | 27 ++++++++++++++++---- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index 1d0289b8..738f7cfb 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -121,4 +121,11 @@ export class LumaPlayer extends Player { public isVideoSource(): boolean { return this.texture?.source instanceof pixi.VideoSource; } + + public getVideoCurrentTime(): number { + if (this.texture?.source instanceof pixi.VideoSource) { + return this.texture.source.resource.currentTime; + } + return -1; + } } diff --git a/src/core/edit.ts b/src/core/edit.ts index 09f8ceee..3e35282f 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -89,6 +89,7 @@ export class Edit extends Entity { maskSprite: pixi.Sprite; tempContainer: pixi.Container; contentClip: Player; + lastVideoTime: number; }> = []; // Queue for deferred mask sprite cleanup - must wait for PixiJS to finish rendering @@ -1019,24 +1020,40 @@ export class Edit extends Entity { tempSprite.filters = [invertFilter]; tempContainer.addChild(tempSprite); - const maskTexture = renderer.generateTexture(tempContainer); + const maskTexture = renderer.generateTexture({ + target: tempContainer, + resolution: 0.5, + }); const maskSprite = new pixi.Sprite(maskTexture); contentClip.getContainer().addChild(maskSprite); contentClip.getContentContainer().setMask({ mask: maskSprite }); - this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer, contentClip }); + this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer, contentClip, lastVideoTime: -1 }); } private updateLumaMasks(): void { if (!this.canvas) return; const { renderer } = this.canvas.application; + const frameInterval = 1 / 30; // 30fps threshold + for (const mask of this.activeLumaMasks) { if (mask.lumaPlayer.isVideoSource()) { - const oldTexture = mask.maskSprite.texture; - mask.maskSprite.texture = renderer.generateTexture(mask.tempContainer); + const videoTime = mask.lumaPlayer.getVideoCurrentTime(); + + // Only regenerate if frame has changed (within threshold) + const frameChanged = Math.abs(videoTime - mask.lastVideoTime) >= frameInterval; + if (frameChanged) { + mask.lastVideoTime = videoTime; - oldTexture.destroy(true); + const oldTexture = mask.maskSprite.texture; + mask.maskSprite.texture = renderer.generateTexture({ + target: mask.tempContainer, + resolution: 0.5, + }); + + oldTexture.destroy(true); + } } } } From 990c8b9b668c947712bc67e968fb63f0ac9a20e6 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 15:20:25 +1100 Subject: [PATCH 102/463] feat: add comprehensive playback health and memory diagnostics --- src/components/canvas/players/audio-player.ts | 9 + .../canvas/players/rich-text-player.ts | 4 + src/components/canvas/players/video-player.ts | 9 + src/components/canvas/shotstack-canvas.ts | 25 ++ src/components/canvas/system/inspector.ts | 300 +++++++++++++++++- src/core/edit.ts | 279 ++++++++++++++++ 6 files changed, 613 insertions(+), 13 deletions(-) diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index 49bff490..e9238ad2 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -82,6 +82,7 @@ export class AudioPlayer extends Player { if (shouldSync) { this.audioResource.seek(playbackTime / 1000 + trim); + this.edit.recordSyncCorrection(); } } @@ -114,6 +115,14 @@ export class AudioPlayer extends Player { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } + public getCurrentDrift(): number { + if (!this.audioResource) return 0; + const { trim = 0 } = this.clipConfiguration.asset as AudioAsset; + const audioTime = this.audioResource.seek() as number; + const playbackTime = this.getPlaybackTime(); + return Math.abs((audioTime - trim) * 1000 - playbackTime); + } + private createVolumeKeyframes(asset: AudioAsset, baseVolume: number): Keyframe[] | number { const { effect, volume } = asset; diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index bc6c05f8..c7c19a42 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -482,4 +482,8 @@ export class RichTextPlayer extends Player { private getCurrentTime(): number { return this.edit.playbackTime - this.getStart(); } + + public getCacheSize(): number { + return this.cachedFrames.size; + } } diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index fb17a444..d0611b50 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -77,6 +77,7 @@ export class VideoPlayer extends Player { const drift = Math.abs((this.texture.source.resource.currentTime - trim) * 1000 - playbackTime); if (drift > desyncThreshold) { this.texture.source.resource.currentTime = playbackTime / 1000 + trim; + this.edit.recordSyncCorrection(); } } } @@ -174,6 +175,14 @@ export class VideoPlayer extends Player { return this.volumeKeyframeBuilder.getValue(this.getPlaybackTime()); } + public getCurrentDrift(): number { + if (!this.texture?.source?.resource) return 0; + const { trim = 0 } = this.clipConfiguration.asset as VideoAsset; + const videoTime = this.texture.source.resource.currentTime; + const playbackTime = this.getPlaybackTime(); + return Math.abs((videoTime - trim) * 1000 - playbackTime); + } + private createCroppedTexture(texture: pixi.Texture): pixi.Texture { const videoAsset = this.clipConfiguration.asset as VideoAsset; diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 5306f060..9a756a3c 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -43,6 +43,7 @@ export class Canvas { private onTickBound: (ticker: pixi.Ticker) => void; private onBackgroundClickBound: (event: pixi.FederatedPointerEvent) => void; + private lastInspectorUpdate: number = 0; constructor(edit: Edit) { this.application = new pixi.Application(); @@ -79,6 +80,7 @@ export class Canvas { this.background.fill(); await this.configureApplication(); + await this.inspector.load(); await this.transcriptionIndicator.load(); this.configureStage(); this.setupTouchHandling(root); @@ -268,6 +270,29 @@ export class Canvas { this.inspector.playbackDuration = this.edit.totalDuration; this.inspector.isPlaying = this.edit.isPlaying; + // Pass comprehensive memory stats (throttled - every 500ms) + const now = performance.now(); + if (now - this.lastInspectorUpdate > 500) { + this.lastInspectorUpdate = now; + const comprehensiveStats = this.edit.getComprehensiveMemoryStats(); + this.inspector.textureStats = comprehensiveStats.textureStats; + this.inspector.assetDetails = comprehensiveStats.assetDetails; + this.inspector.systemStats = comprehensiveStats.systemStats; + + // Pass playback health stats + this.inspector.playbackHealth = this.edit.getPlaybackHealth(); + + // Also update legacy stats for backward compatibility + const memoryStats = this.edit.getMemoryStats(); + this.inspector.clipCounts = memoryStats.clipCounts; + this.inspector.totalClips = memoryStats.totalClips; + this.inspector.richTextCacheStats = memoryStats.richTextCacheStats; + this.inspector.textPlayerCount = memoryStats.textPlayerCount; + this.inspector.lumaMaskCount = memoryStats.lumaMaskCount; + this.inspector.commandHistorySize = memoryStats.commandHistorySize; + this.inspector.trackCount = memoryStats.trackCount; + } + this.inspector.update(ticker.deltaTime, ticker.deltaMS); this.inspector.draw(); diff --git a/src/components/canvas/system/inspector.ts b/src/components/canvas/system/inspector.ts index b695231c..9de16e77 100644 --- a/src/components/canvas/system/inspector.ts +++ b/src/components/canvas/system/inspector.ts @@ -7,18 +7,87 @@ type MemoryInfo = { heapSizeLimit?: number; }; +export interface AssetMemoryInfo { + id: string; + type: "video" | "image" | "text" | "rich-text" | "luma" | "audio" | "html" | "shape" | "caption" | "unknown"; + label: string; + width: number; + height: number; + estimatedMB: number; +} + +export interface TextureStats { + videos: { count: number; totalMB: number; avgDimensions: string }; + images: { count: number; totalMB: number; avgDimensions: string }; + text: { count: number; totalMB: number }; + richText: { count: number; totalMB: number }; + luma: { count: number; totalMB: number }; + animated: { count: number; frames: number; totalMB: number }; + totalTextures: number; + totalMB: number; +} + +export interface SystemStats { + clipCount: number; + trackCount: number; + commandCount: number; + spriteCount: number; + containerCount: number; +} + +interface MemorySnapshot { + timestamp: number; + jsHeapUsed: number; + gpuEstimate: number; +} + +export interface PlaybackHealth { + activePlayerCount: number; + totalPlayerCount: number; + videoMaxDrift: number; + audioMaxDrift: number; + syncCorrections: number; +} + export class Inspector extends Entity { - private static readonly Width = 250; - private static readonly Height = 100; + private static readonly Width = 340; + private static readonly Height = 380; + // Playback state public fps: number; public playbackTime: number; public playbackDuration: number; public isPlaying: boolean; + // Comprehensive stats (set by Canvas) + public textureStats: TextureStats | null = null; + public assetDetails: AssetMemoryInfo[] = []; + public systemStats: SystemStats | null = null; + public playbackHealth: PlaybackHealth | null = null; + + // Legacy stats (for backward compatibility) + public clipCounts: Record = {}; + public totalClips: number = 0; + public richTextCacheStats: { clips: number; totalFrames: number } = { clips: 0, totalFrames: 0 }; + public textPlayerCount: number = 0; + public lumaMaskCount: number = 0; + public commandHistorySize: number = 0; + public trackCount: number = 0; + private background: pixi.Graphics | null; private text: pixi.Text | null; + // History tracking (inline implementation) + private historySamples: MemorySnapshot[] = []; + private readonly maxSamples = 20; // 10 seconds at 2 samples/sec + private lastSampleTime: number = 0; + private readonly sampleInterval = 500; // ms + + // Frame timing tracking + private frameTimes: number[] = []; + private readonly frameTimeWindow = 60; // Track last 60 frames (1 second at 60fps) + private readonly jankThreshold = 33; // >33ms = below 30fps = jank + constructor() { super(); @@ -33,7 +102,7 @@ export class Inspector extends Entity { public override async load(): Promise { const background = new pixi.Graphics(); - background.fillStyle = { color: "#424242", alpha: 0.5 }; + background.fillStyle = { color: "#424242", alpha: 0.85 }; background.rect(0, 0, Inspector.Width, Inspector.Height); background.fill(); @@ -44,35 +113,220 @@ export class Inspector extends Entity { text.text = ""; text.style = { fontFamily: "monospace", - fontSize: 14, + fontSize: 10, fill: "#ffffff", wordWrap: true, - wordWrapWidth: Inspector.Width + wordWrapWidth: Inspector.Width - 10, + lineHeight: 12 }; + text.x = 5; + text.y = 5; this.getContainer().addChild(text); this.text = text; } - public override update(_: number, __: number): void { + public override update(_: number, deltaMS: number): void { if (!this.text) { return; } + // Track frame timing + this.trackFrameTime(deltaMS); + + // Sample history at interval + const now = performance.now(); + if (now - this.lastSampleTime > this.sampleInterval) { + const memoryInfo = this.getMemoryInfo(); + const gpuEstimate = this.textureStats?.totalMB ?? this.estimateGpuMemory(); + + this.addHistorySample({ + timestamp: now, + jsHeapUsed: memoryInfo.usedHeapSize ?? 0, + gpuEstimate + }); + this.lastSampleTime = now; + } + + this.renderStats(); + } + + private trackFrameTime(deltaMS: number): void { + this.frameTimes.push(deltaMS); + if (this.frameTimes.length > this.frameTimeWindow) { + this.frameTimes.shift(); + } + } + + private getFrameStats(): { avgFrameTime: number; maxFrameTime: number; jankCount: number } { + if (this.frameTimes.length === 0) { + return { avgFrameTime: 0, maxFrameTime: 0, jankCount: 0 }; + } + const avg = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length; + const max = Math.max(...this.frameTimes); + const jankCount = this.frameTimes.filter(t => t > this.jankThreshold).length; + return { avgFrameTime: avg, maxFrameTime: max, jankCount }; + } + + private getFrameTimeSparkline(): string { + if (this.frameTimes.length === 0) return ""; + + const chars = "▁▂▃▄▅▆▇█"; + // Normalize against 33ms (jank threshold) for better visualization + const maxScale = 50; // Cap at 50ms for sparkline visualization + return this.frameTimes + .slice(-20) // Show last 20 frames + .map(t => chars[Math.min(7, Math.floor((t / maxScale) * 7))]) + .join(""); + } + + private addHistorySample(snapshot: MemorySnapshot): void { + this.historySamples.push(snapshot); + if (this.historySamples.length > this.maxSamples) { + this.historySamples.shift(); + } + } + + private getSparkline(metric: "jsHeapUsed" | "gpuEstimate"): string { + if (this.historySamples.length === 0) return ""; + + const chars = "▁▂▃▄▅▆▇█"; + const values = this.historySamples.map(s => s[metric]); + const max = Math.max(...values); + const min = Math.min(...values); + const range = max - min || 1; + + return values.map(v => chars[Math.min(7, Math.floor(((v - min) / range) * 7))]).join(""); + } + + private renderStats(): void { + if (!this.text) return; + const memoryInfo = this.getMemoryInfo(); + const jsSparkline = this.getSparkline("jsHeapUsed"); + const gpuSparkline = this.getSparkline("gpuEstimate"); + + const jsHeapMB = memoryInfo.usedHeapSize ? this.bytesToMegabytes(memoryInfo.usedHeapSize) : 0; + const jsLimitMB = memoryInfo.heapSizeLimit ? this.bytesToMegabytes(memoryInfo.heapSizeLimit) : 0; + const gpuEstMB = this.textureStats?.totalMB ?? this.estimateGpuMemory(); + const totalEstMB = jsHeapMB + gpuEstMB; + + // Frame timing stats + const frameStats = this.getFrameStats(); + const frameSparkline = this.getFrameTimeSparkline(); - const stats = [ - `FPS: ${this.fps}`, - `Playback: ${(this.playbackTime / 1000).toFixed(2)}/${(this.playbackDuration / 1000).toFixed(2)}`, - `Playing: ${this.isPlaying}`, - `Total Heap Size: ${memoryInfo.totalHeapSize ? `${this.bytesToMegabytes(memoryInfo.totalHeapSize)}MB` : "N/A"}`, - `Used Heap Size: ${memoryInfo.usedHeapSize ? `${this.bytesToMegabytes(memoryInfo.usedHeapSize)}MB` : "N/A"}`, - `Heap Size Limit: ${memoryInfo.heapSizeLimit ? `${this.bytesToMegabytes(memoryInfo.heapSizeLimit)}MB` : "N/A"}` + const stats: string[] = [ + // Header row with FPS and playback time + `FPS: ${this.fps} ${this.isPlaying ? "▶" : "⏸"} ${(this.playbackTime / 1000).toFixed(1)}s / ${(this.playbackDuration / 1000).toFixed(1)}s`, + // Frame timing row + `Frame: ${frameStats.avgFrameTime.toFixed(0)}ms avg ${frameStats.maxFrameTime.toFixed(0)}ms max ${frameSparkline} Jank: ${frameStats.jankCount}`, + `` ]; + // Playback Health section + if (this.playbackHealth) { + stats.push(`── PLAYBACK HEALTH ──`); + stats.push(` Active: ${this.playbackHealth.activePlayerCount}/${this.playbackHealth.totalPlayerCount} players`); + + const videoStatus = this.getSyncStatusIcon(this.playbackHealth.videoMaxDrift); + const audioStatus = this.getSyncStatusIcon(this.playbackHealth.audioMaxDrift); + + stats.push(` Video sync: ${videoStatus} (drift: ${Math.round(this.playbackHealth.videoMaxDrift)}ms)`); + stats.push(` Audio sync: ${audioStatus} (drift: ${Math.round(this.playbackHealth.audioMaxDrift)}ms)`); + stats.push(` Sync corrections: ${this.playbackHealth.syncCorrections} this session`); + stats.push(``); + } + + stats.push(`── MEMORY SUMMARY ──`); + stats.push(` JS Heap: ${jsHeapMB}MB / ${jsLimitMB}MB ${jsSparkline}`); + stats.push(` GPU Est: ~${gpuEstMB.toFixed(1)}MB ${gpuSparkline}`); + stats.push(` Total Est: ~${totalEstMB.toFixed(1)}MB`); + stats.push(``); + + // GPU Textures section + if (this.textureStats) { + stats.push(`── GPU TEXTURES (est) ──`); + stats.push(` Videos: ${this.textureStats.videos.count} clip${this.textureStats.videos.count !== 1 ? "s" : ""} ${this.textureStats.videos.avgDimensions} ~${this.textureStats.videos.totalMB.toFixed(1)}MB`); + stats.push(` Images: ${this.textureStats.images.count} clip${this.textureStats.images.count !== 1 ? "s" : ""} ${this.textureStats.images.avgDimensions} ~${this.textureStats.images.totalMB.toFixed(1)}MB`); + stats.push(` Text: ${this.textureStats.text.count} clip${this.textureStats.text.count !== 1 ? "s" : ""} (static) ~${this.textureStats.text.totalMB.toFixed(1)}MB`); + stats.push(` RichTxt: ${this.textureStats.richText.count} clip${this.textureStats.richText.count !== 1 ? "s" : ""} ~${this.textureStats.richText.totalMB.toFixed(1)}MB`); + stats.push(` Luma: ${this.textureStats.luma.count} mask${this.textureStats.luma.count !== 1 ? "s" : ""} ~${this.textureStats.luma.totalMB.toFixed(1)}MB`); + stats.push(` Animated: ${this.textureStats.animated.count} clip${this.textureStats.animated.count !== 1 ? "s" : ""} ${this.textureStats.animated.frames} frames ~${this.textureStats.animated.totalMB.toFixed(1)}MB`); + stats.push(` ─────────────────────────────`); + stats.push(` Subtotal: ${this.textureStats.totalTextures} textures ~${this.textureStats.totalMB.toFixed(1)}MB`); + stats.push(``); + } else { + // Fallback to legacy stats + stats.push(`── GPU TEXTURES ──`); + stats.push(` Text clips: ${this.textPlayerCount} (static)`); + stats.push(` Animated text: ${this.richTextCacheStats.clips} clips`); + stats.push(` Cached frames: ${this.richTextCacheStats.totalFrames} (~${this.estimateLegacyTextureMemory()}MB)`); + stats.push(` Luma masks: ${this.lumaMaskCount}`); + stats.push(``); + } + + // Asset Details section (show top 6) + if (this.assetDetails.length > 0) { + stats.push(`── ASSET DETAILS ──`); + const displayAssets = this.assetDetails.slice(0, 6); + for (const asset of displayAssets) { + const typeIcon = this.getAssetTypeIcon(asset.type); + const dims = asset.width > 0 && asset.height > 0 ? `${asset.width}×${asset.height}` : ""; + const label = asset.label.length > 18 ? `${asset.label.substring(0, 15)}...` : asset.label; + stats.push(` ${typeIcon} ${label.padEnd(18)} ${dims.padEnd(10)} ~${asset.estimatedMB.toFixed(1)}MB`); + } + if (this.assetDetails.length > 6) { + stats.push(` ... +${this.assetDetails.length - 6} more`); + } + stats.push(``); + } + + // System section + if (this.systemStats) { + stats.push(`── SYSTEM ──`); + stats.push(` Clips: ${this.systemStats.clipCount} Tracks: ${this.systemStats.trackCount} Commands: ${this.systemStats.commandCount}`); + stats.push(` Sprites: ${this.systemStats.spriteCount} Containers: ${this.systemStats.containerCount}`); + } else { + // Fallback to legacy + stats.push(`── SYSTEM ──`); + stats.push(` Clips: ${this.totalClips} Tracks: ${this.trackCount} Commands: ${this.commandHistorySize}`); + stats.push(` ${this.formatClipCounts()}`); + } + this.text.text = stats.join("\n"); } + private getSyncStatusIcon(driftMs: number): string { + if (driftMs < 50) return "✓ OK"; + if (driftMs < 100) return "⚠ DRIFT"; + return "🔴 DESYNC"; + } + + private getAssetTypeIcon(type: string): string { + switch (type) { + case "video": + return "[V]"; + case "image": + return "[I]"; + case "text": + return "[T]"; + case "rich-text": + return "[R]"; + case "luma": + return "[L]"; + case "audio": + return "[A]"; + case "html": + return "[H]"; + case "shape": + return "[S]"; + case "caption": + return "[C]"; + default: + return "[?]"; + } + } + public override draw(): void {} public override dispose(): void { @@ -83,6 +337,26 @@ export class Inspector extends Entity { this.text = null; } + private formatClipCounts(): string { + const types = ["video", "image", "text", "audio", "luma", "html", "title"]; + const counts = types.filter(t => (this.clipCounts[t] || 0) > 0).map(t => `${t}: ${this.clipCounts[t]}`); + return counts.length > 0 ? counts.join(" ") : "none"; + } + + private estimateLegacyTextureMemory(): number { + // Assume 1080x1080 @ 4 bytes/pixel = ~4.5MB per frame + const bytesPerFrame = 1080 * 1080 * 4; + return Math.round((this.richTextCacheStats.totalFrames * bytesPerFrame) / 1024 / 1024); + } + + private estimateGpuMemory(): number { + // Legacy estimation when textureStats not available + const textMemory = this.estimateLegacyTextureMemory(); + const lumaMaskMemory = this.lumaMaskCount * 1; // ~1MB per luma mask + const textPlayerMemory = this.textPlayerCount * 0.5; // ~0.5MB per static text + return textMemory + lumaMaskMemory + textPlayerMemory; + } + private getMemoryInfo(): MemoryInfo { const memoryInfo: MemoryInfo = {}; diff --git a/src/core/edit.ts b/src/core/edit.ts index 3e35282f..6f5c72dd 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -77,6 +77,9 @@ export class Edit extends Entity { private cachedTimelineEnd: number = 0; private endLengthClips: Set = new Set(); + // Playback health tracking + private syncCorrectionCount: number = 0; + // Toolbar button registry private toolbarButtons: ToolbarButtonConfig[] = []; @@ -475,6 +478,282 @@ export class Edit extends Entity { return this.totalDuration; } + public getMemoryStats(): { + clipCounts: Record; + totalClips: number; + richTextCacheStats: { clips: number; totalFrames: number }; + textPlayerCount: number; + lumaMaskCount: number; + commandHistorySize: number; + trackCount: number; + } { + // Count clips by type + const clipCounts: Record = {}; + for (const clip of this.clips) { + const type = clip.clipConfiguration.asset?.type || "unknown"; + clipCounts[type] = (clipCounts[type] || 0) + 1; + } + + // Count text players and RichText cache frames + let richTextClips = 0; + let totalFrames = 0; + let textPlayerCount = 0; + for (const clip of this.clips) { + if (clip instanceof RichTextPlayer) { + richTextClips += 1; + totalFrames += clip.getCacheSize(); + } + if (clip instanceof TextPlayer) { + textPlayerCount += 1; + } + } + + return { + clipCounts, + totalClips: this.clips.length, + richTextCacheStats: { clips: richTextClips, totalFrames }, + textPlayerCount, + lumaMaskCount: this.activeLumaMasks.length, + commandHistorySize: this.commandHistory.length, + trackCount: this.tracks.length + }; + } + + public getComprehensiveMemoryStats(): { + textureStats: { + videos: { count: number; totalMB: number; avgDimensions: string }; + images: { count: number; totalMB: number; avgDimensions: string }; + text: { count: number; totalMB: number }; + richText: { count: number; totalMB: number }; + luma: { count: number; totalMB: number }; + animated: { count: number; frames: number; totalMB: number }; + totalTextures: number; + totalMB: number; + }; + assetDetails: Array<{ + id: string; + type: "video" | "image" | "text" | "rich-text" | "luma" | "audio" | "html" | "shape" | "caption" | "unknown"; + label: string; + width: number; + height: number; + estimatedMB: number; + }>; + systemStats: { + clipCount: number; + trackCount: number; + commandCount: number; + spriteCount: number; + containerCount: number; + }; + } { + type AssetType = "video" | "image" | "text" | "rich-text" | "luma" | "audio" | "html" | "shape" | "caption" | "unknown"; + + const assetDetails: Array<{ + id: string; + type: AssetType; + label: string; + width: number; + height: number; + estimatedMB: number; + }> = []; + + const stats = { + videos: { count: 0, totalMB: 0, dimensions: [] as Array<{ width: number; height: number }> }, + images: { count: 0, totalMB: 0, dimensions: [] as Array<{ width: number; height: number }> }, + text: { count: 0, totalMB: 0 }, + richText: { count: 0, totalMB: 0 }, + luma: { count: 0, totalMB: 0 }, + animated: { count: 0, frames: 0, totalMB: 0 } + }; + + for (const clip of this.clips) { + const { asset } = clip.clipConfiguration; + const rawType = asset?.type || "unknown"; + const type = (["video", "image", "text", "rich-text", "luma", "audio", "html", "shape", "caption"].includes(rawType) + ? rawType + : "unknown") as AssetType; + const size = clip.getSize(); + const estimatedMB = this.estimateTextureMB(size.width, size.height); + + // Get label for asset + const label = this.getAssetLabel(clip); + + assetDetails.push({ + id: clip.clipConfiguration.asset?.type || "unknown", + type, + label, + width: size.width, + height: size.height, + estimatedMB + }); + + // Aggregate by type + if (type === "video") { + stats.videos.count += 1; + stats.videos.totalMB += estimatedMB; + stats.videos.dimensions.push({ width: size.width, height: size.height }); + } else if (type === "image") { + stats.images.count += 1; + stats.images.totalMB += estimatedMB; + stats.images.dimensions.push({ width: size.width, height: size.height }); + } else if (type === "text") { + stats.text.count += 1; + stats.text.totalMB += estimatedMB; + } else if (type === "rich-text") { + stats.richText.count += 1; + stats.richText.totalMB += estimatedMB; + } else if (type === "luma") { + stats.luma.count += 1; + stats.luma.totalMB += estimatedMB; + } + } + + // Add animated text frame caches (RichTextPlayer) + for (const clip of this.clips) { + if (clip instanceof RichTextPlayer) { + const frames = clip.getCacheSize(); + if (frames > 0) { + stats.animated.count += 1; + stats.animated.frames += frames; + // Estimate based on output size for cached frames + stats.animated.totalMB += frames * this.estimateTextureMB(this.size.width, this.size.height); + } + } + } + + // Calculate average dimensions + const calcAvgDimensions = (dims: Array<{ width: number; height: number }>): string => { + if (dims.length === 0) return ""; + if (dims.length === 1) return `${dims[0].width}×${dims[0].height}`; + const avgW = Math.round(dims.reduce((s, d) => s + d.width, 0) / dims.length); + const avgH = Math.round(dims.reduce((s, d) => s + d.height, 0) / dims.length); + return `avg ${avgW}×${avgH}`; + }; + + const totalTextures = + stats.videos.count + stats.images.count + stats.text.count + stats.richText.count + stats.luma.count + stats.animated.count; + + const totalMB = + stats.videos.totalMB + + stats.images.totalMB + + stats.text.totalMB + + stats.richText.totalMB + + stats.luma.totalMB + + stats.animated.totalMB; + + // Count sprites and containers in scene graph + const spriteCount = this.countInstancesInContainer(this.getContainer(), pixi.Sprite); + const containerCount = this.countInstancesInContainer(this.getContainer(), pixi.Container); + + return { + textureStats: { + videos: { + count: stats.videos.count, + totalMB: stats.videos.totalMB, + avgDimensions: calcAvgDimensions(stats.videos.dimensions) + }, + images: { + count: stats.images.count, + totalMB: stats.images.totalMB, + avgDimensions: calcAvgDimensions(stats.images.dimensions) + }, + text: { count: stats.text.count, totalMB: stats.text.totalMB }, + richText: { count: stats.richText.count, totalMB: stats.richText.totalMB }, + luma: { count: stats.luma.count, totalMB: stats.luma.totalMB }, + animated: { count: stats.animated.count, frames: stats.animated.frames, totalMB: stats.animated.totalMB }, + totalTextures, + totalMB + }, + assetDetails, + systemStats: { + clipCount: this.clips.length, + trackCount: this.tracks.length, + commandCount: this.commandHistory.length, + spriteCount, + containerCount + } + }; + } + + private estimateTextureMB(width: number, height: number): number { + // GPU Memory (MB) = width × height × 4 (RGBA bytes) / 1024 / 1024 + return (width * height * 4) / (1024 * 1024); + } + + private getAssetLabel(clip: Player): string { + const asset = clip.clipConfiguration.asset as Record | undefined; + if (!asset) return "unknown"; + + // For media assets with src, extract filename + const srcValue = asset["src"]; + if ("src" in asset && typeof srcValue === "string") { + const filename = srcValue.split("/").pop() || srcValue; + // Remove query params + return filename.split("?")[0]; + } + + // For text assets, use the text content + const textValue = asset["text"]; + if ("text" in asset && typeof textValue === "string") { + return textValue.length > 20 ? `${textValue.substring(0, 17)}...` : textValue; + } + + return asset["type"]?.toString() || "unknown"; + } + + private countInstancesInContainer(container: pixi.Container, type: typeof pixi.Sprite | typeof pixi.Container): number { + let count = 0; + for (const child of container.children) { + if (child instanceof type) { + count += 1; + } + if (child instanceof pixi.Container) { + count += this.countInstancesInContainer(child, type); + } + } + return count; + } + + public getPlaybackHealth(): { + activePlayerCount: number; + totalPlayerCount: number; + videoMaxDrift: number; + audioMaxDrift: number; + syncCorrections: number; + } { + let activeCount = 0; + let videoMaxDrift = 0; + let audioMaxDrift = 0; + + for (const clip of this.clips) { + if (clip.isActive()) { + activeCount += 1; + + if (clip instanceof VideoPlayer) { + const drift = clip.getCurrentDrift(); + videoMaxDrift = Math.max(videoMaxDrift, drift); + } + + if (clip instanceof AudioPlayer) { + const drift = clip.getCurrentDrift(); + audioMaxDrift = Math.max(audioMaxDrift, drift); + } + } + } + + return { + activePlayerCount: activeCount, + totalPlayerCount: this.clips.length, + videoMaxDrift, + audioMaxDrift, + syncCorrections: this.syncCorrectionCount + }; + } + + public recordSyncCorrection(): void { + this.syncCorrectionCount += 1; + } + public undo(): void { if (this.commandIndex >= 0) { const command = this.commandHistory[this.commandIndex]; From ddf3a3ad9865719c63deaa0e2814896626b5dacf Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 15:24:22 +1100 Subject: [PATCH 103/463] feat: add movingLetters animation preset to rich text animations --- src/core/schemas/rich-text-asset.ts | 2 +- src/core/ui/rich-text-toolbar.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/schemas/rich-text-asset.ts b/src/core/schemas/rich-text-asset.ts index 16b68281..527254e8 100644 --- a/src/core/schemas/rich-text-asset.ts +++ b/src/core/schemas/rich-text-asset.ts @@ -94,7 +94,7 @@ const RichTextAlignmentSchema = zod const RichTextAnimationSchema = zod .object({ - preset: zod.enum(["typewriter", "fadeIn", "slideIn", "ascend", "shift"]), + preset: zod.enum(["typewriter", "fadeIn", "slideIn", "ascend", "shift", "movingLetters"]), duration: zod.number().min(0.1).max(60).optional(), style: zod.enum(["character", "word"]).optional(), direction: zod.enum(["left", "right", "up", "down"]).optional() diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 96d1138f..0df36015 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -583,7 +583,7 @@ export class RichTextToolbar extends BaseToolbar { // Preset buttons this.container.querySelectorAll("[data-preset]").forEach(btn => { btn.addEventListener("click", () => { - const preset = btn.dataset["preset"] as "typewriter" | "fadeIn" | "slideIn" | "ascend" | "shift"; + const preset = btn.dataset["preset"] as "typewriter" | "fadeIn" | "slideIn" | "ascend" | "shift" | "movingLetters"; if (preset) this.updateAnimationProperty({ preset }); }); }); From fda493755c67e7a2ca6af6c80b8941c17d689fd7 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 15:28:41 +1100 Subject: [PATCH 104/463] feat: resize inspector background to fit content --- src/components/canvas/system/inspector.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/components/canvas/system/inspector.ts b/src/components/canvas/system/inspector.ts index 9de16e77..24dd4842 100644 --- a/src/components/canvas/system/inspector.ts +++ b/src/components/canvas/system/inspector.ts @@ -294,6 +294,14 @@ export class Inspector extends Entity { } this.text.text = stats.join("\n"); + + // Resize background to fit content + if (this.background) { + this.background.clear(); + this.background.fillStyle = { color: "#424242", alpha: 0.85 }; + this.background.rect(0, 0, Inspector.Width, this.text.height + 10); + this.background.fill(); + } } private getSyncStatusIcon(driftMs: number): string { From d3d5670a9ea0f4638d8054b5e82a375a68b6bbf1 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 15:51:16 +1100 Subject: [PATCH 105/463] feat: sync ruler scroll position state --- src/components/timeline-html/components/ruler/ruler-component.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/timeline-html/components/ruler/ruler-component.ts b/src/components/timeline-html/components/ruler/ruler-component.ts index 48eae3f6..4e346f7d 100644 --- a/src/components/timeline-html/components/ruler/ruler-component.ts +++ b/src/components/timeline-html/components/ruler/ruler-component.ts @@ -132,6 +132,7 @@ export class RulerComponent extends TimelineEntity { } public syncScroll(scrollX: number): void { + this.scrollX = scrollX; this.contentElement.style.transform = `translateX(${-scrollX}px)`; } From 127ea328322bdd286c53c6f97d7f651eab39c391 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 15:57:07 +1100 Subject: [PATCH 106/463] feat: simplify inspector memory stats display and remove GPU estimation --- src/components/canvas/shotstack-canvas.ts | 20 +-- src/components/canvas/system/inspector.ts | 141 +++++----------------- 2 files changed, 42 insertions(+), 119 deletions(-) diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 9a756a3c..f17a2eb5 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -270,25 +270,31 @@ export class Canvas { this.inspector.playbackDuration = this.edit.totalDuration; this.inspector.isPlaying = this.edit.isPlaying; - // Pass comprehensive memory stats (throttled - every 500ms) + // Pass stats to inspector (throttled - every 500ms) const now = performance.now(); if (now - this.lastInspectorUpdate > 500) { this.lastInspectorUpdate = now; + + // Get clip and system stats const comprehensiveStats = this.edit.getComprehensiveMemoryStats(); - this.inspector.textureStats = comprehensiveStats.textureStats; - this.inspector.assetDetails = comprehensiveStats.assetDetails; + this.inspector.clipStats = { + videos: comprehensiveStats.textureStats.videos.count, + images: comprehensiveStats.textureStats.images.count, + text: comprehensiveStats.textureStats.text.count, + richText: comprehensiveStats.textureStats.richText.count, + luma: comprehensiveStats.textureStats.luma.count, + animatedClips: comprehensiveStats.textureStats.animated.count, + cachedFrames: comprehensiveStats.textureStats.animated.frames + }; this.inspector.systemStats = comprehensiveStats.systemStats; // Pass playback health stats this.inspector.playbackHealth = this.edit.getPlaybackHealth(); - // Also update legacy stats for backward compatibility + // Legacy stats for backward compatibility const memoryStats = this.edit.getMemoryStats(); this.inspector.clipCounts = memoryStats.clipCounts; this.inspector.totalClips = memoryStats.totalClips; - this.inspector.richTextCacheStats = memoryStats.richTextCacheStats; - this.inspector.textPlayerCount = memoryStats.textPlayerCount; - this.inspector.lumaMaskCount = memoryStats.lumaMaskCount; this.inspector.commandHistorySize = memoryStats.commandHistorySize; this.inspector.trackCount = memoryStats.trackCount; } diff --git a/src/components/canvas/system/inspector.ts b/src/components/canvas/system/inspector.ts index 24dd4842..83ff4eaa 100644 --- a/src/components/canvas/system/inspector.ts +++ b/src/components/canvas/system/inspector.ts @@ -7,24 +7,14 @@ type MemoryInfo = { heapSizeLimit?: number; }; -export interface AssetMemoryInfo { - id: string; - type: "video" | "image" | "text" | "rich-text" | "luma" | "audio" | "html" | "shape" | "caption" | "unknown"; - label: string; - width: number; - height: number; - estimatedMB: number; -} - -export interface TextureStats { - videos: { count: number; totalMB: number; avgDimensions: string }; - images: { count: number; totalMB: number; avgDimensions: string }; - text: { count: number; totalMB: number }; - richText: { count: number; totalMB: number }; - luma: { count: number; totalMB: number }; - animated: { count: number; frames: number; totalMB: number }; - totalTextures: number; - totalMB: number; +export interface ClipStats { + videos: number; + images: number; + text: number; + richText: number; + luma: number; + animatedClips: number; + cachedFrames: number; } export interface SystemStats { @@ -38,7 +28,6 @@ export interface SystemStats { interface MemorySnapshot { timestamp: number; jsHeapUsed: number; - gpuEstimate: number; } export interface PlaybackHealth { @@ -59,25 +48,21 @@ export class Inspector extends Entity { public playbackDuration: number; public isPlaying: boolean; - // Comprehensive stats (set by Canvas) - public textureStats: TextureStats | null = null; - public assetDetails: AssetMemoryInfo[] = []; + // Stats (set by Canvas) + public clipStats: ClipStats | null = null; public systemStats: SystemStats | null = null; public playbackHealth: PlaybackHealth | null = null; // Legacy stats (for backward compatibility) public clipCounts: Record = {}; public totalClips: number = 0; - public richTextCacheStats: { clips: number; totalFrames: number } = { clips: 0, totalFrames: 0 }; - public textPlayerCount: number = 0; - public lumaMaskCount: number = 0; public commandHistorySize: number = 0; public trackCount: number = 0; private background: pixi.Graphics | null; private text: pixi.Text | null; - // History tracking (inline implementation) + // History tracking private historySamples: MemorySnapshot[] = []; private readonly maxSamples = 20; // 10 seconds at 2 samples/sec private lastSampleTime: number = 0; @@ -138,12 +123,10 @@ export class Inspector extends Entity { const now = performance.now(); if (now - this.lastSampleTime > this.sampleInterval) { const memoryInfo = this.getMemoryInfo(); - const gpuEstimate = this.textureStats?.totalMB ?? this.estimateGpuMemory(); this.addHistorySample({ timestamp: now, - jsHeapUsed: memoryInfo.usedHeapSize ?? 0, - gpuEstimate + jsHeapUsed: memoryInfo.usedHeapSize ?? 0 }); this.lastSampleTime = now; } @@ -187,11 +170,11 @@ export class Inspector extends Entity { } } - private getSparkline(metric: "jsHeapUsed" | "gpuEstimate"): string { + private getJsHeapSparkline(): string { if (this.historySamples.length === 0) return ""; const chars = "▁▂▃▄▅▆▇█"; - const values = this.historySamples.map(s => s[metric]); + const values = this.historySamples.map(s => s.jsHeapUsed); const max = Math.max(...values); const min = Math.min(...values); const range = max - min || 1; @@ -203,13 +186,10 @@ export class Inspector extends Entity { if (!this.text) return; const memoryInfo = this.getMemoryInfo(); - const jsSparkline = this.getSparkline("jsHeapUsed"); - const gpuSparkline = this.getSparkline("gpuEstimate"); + const jsSparkline = this.getJsHeapSparkline(); const jsHeapMB = memoryInfo.usedHeapSize ? this.bytesToMegabytes(memoryInfo.usedHeapSize) : 0; const jsLimitMB = memoryInfo.heapSizeLimit ? this.bytesToMegabytes(memoryInfo.heapSizeLimit) : 0; - const gpuEstMB = this.textureStats?.totalMB ?? this.estimateGpuMemory(); - const totalEstMB = jsHeapMB + gpuEstMB; // Frame timing stats const frameStats = this.getFrameStats(); @@ -219,7 +199,7 @@ export class Inspector extends Entity { // Header row with FPS and playback time `FPS: ${this.fps} ${this.isPlaying ? "▶" : "⏸"} ${(this.playbackTime / 1000).toFixed(1)}s / ${(this.playbackDuration / 1000).toFixed(1)}s`, // Frame timing row - `Frame: ${frameStats.avgFrameTime.toFixed(0)}ms avg ${frameStats.maxFrameTime.toFixed(0)}ms max ${frameSparkline} Jank: ${frameStats.jankCount}`, + `Frame: ${frameStats.avgFrameTime.toFixed(0)}/${frameStats.maxFrameTime.toFixed(0)}ms ${frameSparkline} Jank: ${frameStats.jankCount}`, `` ]; @@ -237,46 +217,22 @@ export class Inspector extends Entity { stats.push(``); } - stats.push(`── MEMORY SUMMARY ──`); + stats.push(`── MEMORY ──`); stats.push(` JS Heap: ${jsHeapMB}MB / ${jsLimitMB}MB ${jsSparkline}`); - stats.push(` GPU Est: ~${gpuEstMB.toFixed(1)}MB ${gpuSparkline}`); - stats.push(` Total Est: ~${totalEstMB.toFixed(1)}MB`); stats.push(``); - // GPU Textures section - if (this.textureStats) { - stats.push(`── GPU TEXTURES (est) ──`); - stats.push(` Videos: ${this.textureStats.videos.count} clip${this.textureStats.videos.count !== 1 ? "s" : ""} ${this.textureStats.videos.avgDimensions} ~${this.textureStats.videos.totalMB.toFixed(1)}MB`); - stats.push(` Images: ${this.textureStats.images.count} clip${this.textureStats.images.count !== 1 ? "s" : ""} ${this.textureStats.images.avgDimensions} ~${this.textureStats.images.totalMB.toFixed(1)}MB`); - stats.push(` Text: ${this.textureStats.text.count} clip${this.textureStats.text.count !== 1 ? "s" : ""} (static) ~${this.textureStats.text.totalMB.toFixed(1)}MB`); - stats.push(` RichTxt: ${this.textureStats.richText.count} clip${this.textureStats.richText.count !== 1 ? "s" : ""} ~${this.textureStats.richText.totalMB.toFixed(1)}MB`); - stats.push(` Luma: ${this.textureStats.luma.count} mask${this.textureStats.luma.count !== 1 ? "s" : ""} ~${this.textureStats.luma.totalMB.toFixed(1)}MB`); - stats.push(` Animated: ${this.textureStats.animated.count} clip${this.textureStats.animated.count !== 1 ? "s" : ""} ${this.textureStats.animated.frames} frames ~${this.textureStats.animated.totalMB.toFixed(1)}MB`); - stats.push(` ─────────────────────────────`); - stats.push(` Subtotal: ${this.textureStats.totalTextures} textures ~${this.textureStats.totalMB.toFixed(1)}MB`); - stats.push(``); - } else { - // Fallback to legacy stats - stats.push(`── GPU TEXTURES ──`); - stats.push(` Text clips: ${this.textPlayerCount} (static)`); - stats.push(` Animated text: ${this.richTextCacheStats.clips} clips`); - stats.push(` Cached frames: ${this.richTextCacheStats.totalFrames} (~${this.estimateLegacyTextureMemory()}MB)`); - stats.push(` Luma masks: ${this.lumaMaskCount}`); - stats.push(``); - } - - // Asset Details section (show top 6) - if (this.assetDetails.length > 0) { - stats.push(`── ASSET DETAILS ──`); - const displayAssets = this.assetDetails.slice(0, 6); - for (const asset of displayAssets) { - const typeIcon = this.getAssetTypeIcon(asset.type); - const dims = asset.width > 0 && asset.height > 0 ? `${asset.width}×${asset.height}` : ""; - const label = asset.label.length > 18 ? `${asset.label.substring(0, 15)}...` : asset.label; - stats.push(` ${typeIcon} ${label.padEnd(18)} ${dims.padEnd(10)} ~${asset.estimatedMB.toFixed(1)}MB`); - } - if (this.assetDetails.length > 6) { - stats.push(` ... +${this.assetDetails.length - 6} more`); + // Clip counts by type + if (this.clipStats) { + stats.push(`── CLIPS ──`); + const clipLines: string[] = []; + if (this.clipStats.videos > 0) clipLines.push(`Video: ${this.clipStats.videos}`); + if (this.clipStats.images > 0) clipLines.push(`Image: ${this.clipStats.images}`); + if (this.clipStats.text > 0) clipLines.push(`Text: ${this.clipStats.text}`); + if (this.clipStats.richText > 0) clipLines.push(`RichText: ${this.clipStats.richText}`); + if (this.clipStats.luma > 0) clipLines.push(`Luma: ${this.clipStats.luma}`); + stats.push(` ${clipLines.join(" ")}`); + if (this.clipStats.animatedClips > 0) { + stats.push(` Animated: ${this.clipStats.animatedClips} clips ${this.clipStats.cachedFrames} cached frames`); } stats.push(``); } @@ -310,31 +266,6 @@ export class Inspector extends Entity { return "🔴 DESYNC"; } - private getAssetTypeIcon(type: string): string { - switch (type) { - case "video": - return "[V]"; - case "image": - return "[I]"; - case "text": - return "[T]"; - case "rich-text": - return "[R]"; - case "luma": - return "[L]"; - case "audio": - return "[A]"; - case "html": - return "[H]"; - case "shape": - return "[S]"; - case "caption": - return "[C]"; - default: - return "[?]"; - } - } - public override draw(): void {} public override dispose(): void { @@ -351,20 +282,6 @@ export class Inspector extends Entity { return counts.length > 0 ? counts.join(" ") : "none"; } - private estimateLegacyTextureMemory(): number { - // Assume 1080x1080 @ 4 bytes/pixel = ~4.5MB per frame - const bytesPerFrame = 1080 * 1080 * 4; - return Math.round((this.richTextCacheStats.totalFrames * bytesPerFrame) / 1024 / 1024); - } - - private estimateGpuMemory(): number { - // Legacy estimation when textureStats not available - const textMemory = this.estimateLegacyTextureMemory(); - const lumaMaskMemory = this.lumaMaskCount * 1; // ~1MB per luma mask - const textPlayerMemory = this.textPlayerCount * 0.5; // ~0.5MB per static text - return textMemory + lumaMaskMemory + textPlayerMemory; - } - private getMemoryInfo(): MemoryInfo { const memoryInfo: MemoryInfo = {}; From d6c3943a6861faf23d64e4d38bf956586016046f Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 16:03:56 +1100 Subject: [PATCH 107/463] refactor: remove unused sprite and container counting from system stats --- src/components/canvas/system/inspector.ts | 3 --- src/core/edit.ts | 23 +---------------------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/src/components/canvas/system/inspector.ts b/src/components/canvas/system/inspector.ts index 83ff4eaa..27174d6e 100644 --- a/src/components/canvas/system/inspector.ts +++ b/src/components/canvas/system/inspector.ts @@ -21,8 +21,6 @@ export interface SystemStats { clipCount: number; trackCount: number; commandCount: number; - spriteCount: number; - containerCount: number; } interface MemorySnapshot { @@ -241,7 +239,6 @@ export class Inspector extends Entity { if (this.systemStats) { stats.push(`── SYSTEM ──`); stats.push(` Clips: ${this.systemStats.clipCount} Tracks: ${this.systemStats.trackCount} Commands: ${this.systemStats.commandCount}`); - stats.push(` Sprites: ${this.systemStats.spriteCount} Containers: ${this.systemStats.containerCount}`); } else { // Fallback to legacy stats.push(`── SYSTEM ──`); diff --git a/src/core/edit.ts b/src/core/edit.ts index 6f5c72dd..06bb4ee0 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -542,8 +542,6 @@ export class Edit extends Entity { clipCount: number; trackCount: number; commandCount: number; - spriteCount: number; - containerCount: number; }; } { type AssetType = "video" | "image" | "text" | "rich-text" | "luma" | "audio" | "html" | "shape" | "caption" | "unknown"; @@ -641,10 +639,6 @@ export class Edit extends Entity { stats.luma.totalMB + stats.animated.totalMB; - // Count sprites and containers in scene graph - const spriteCount = this.countInstancesInContainer(this.getContainer(), pixi.Sprite); - const containerCount = this.countInstancesInContainer(this.getContainer(), pixi.Container); - return { textureStats: { videos: { @@ -668,9 +662,7 @@ export class Edit extends Entity { systemStats: { clipCount: this.clips.length, trackCount: this.tracks.length, - commandCount: this.commandHistory.length, - spriteCount, - containerCount + commandCount: this.commandHistory.length } }; } @@ -701,19 +693,6 @@ export class Edit extends Entity { return asset["type"]?.toString() || "unknown"; } - private countInstancesInContainer(container: pixi.Container, type: typeof pixi.Sprite | typeof pixi.Container): number { - let count = 0; - for (const child of container.children) { - if (child instanceof type) { - count += 1; - } - if (child instanceof pixi.Container) { - count += this.countInstancesInContainer(child, type); - } - } - return count; - } - public getPlaybackHealth(): { activePlayerCount: number; totalPlayerCount: number; From b8190fb4794c47f2e50417be25b1e3df964a27f5 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 16:08:50 +1100 Subject: [PATCH 108/463] refactor: extract edit data accessors to dedicated methods --- src/components/canvas/players/rich-text-player.ts | 10 ++++------ src/core/edit.ts | 4 ++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index c7c19a42..56a412a3 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -38,9 +38,8 @@ export class RichTextPlayer extends Player { } private buildCanvasPayload(richTextAsset: RichTextAsset, fontInfo?: { baseFontFamily: string; fontWeight: number }): any { - const editData = this.edit.getEdit(); - const width = this.clipConfiguration.width || editData?.output?.size?.width || this.edit.size.width; - const height = this.clipConfiguration.height || editData?.output?.size?.height || this.edit.size.height; + const width = this.clipConfiguration.width || this.edit.size.width; + const height = this.clipConfiguration.height || this.edit.size.height; // Use provided font info or parse fresh (for reconfigure/updateTextContent calls) const requestedFamily = richTextAsset.font?.family; @@ -55,7 +54,7 @@ export class RichTextPlayer extends Player { } // Find matching timeline font for customFonts payload - const timelineFonts = editData?.timeline?.fonts || []; + const timelineFonts = this.edit.getTimelineFonts(); const matchingFont = requestedFamily ? timelineFonts.find(font => { const { full, base } = extractFontNames(font.src); @@ -373,8 +372,7 @@ export class RichTextPlayer extends Player { if (this.textEngine && this.renderer && !this.isRendering) { const currentTimeSeconds = this.getCurrentTime() / 1000; - const editData = this.edit.getEdit(); - const targetFPS = editData?.output?.fps || 30; + const targetFPS = this.edit.getOutputFps(); const frameInterval = 1 / targetFPS; if (Math.abs(currentTimeSeconds - this.lastRenderedTime) > frameInterval) { diff --git a/src/core/edit.ts b/src/core/edit.ts index 06bb4ee0..7a6c660e 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1557,6 +1557,10 @@ export class Edit extends Entity { return this.edit?.output?.fps ?? 30; } + public getTimelineFonts(): Array<{ src: string }> { + return this.edit?.timeline?.fonts ?? []; + } + public setTimelineBackground(color: string): void { this.backgroundColor = color; From 9a3bd1e528b2caa5a6687dde2577d3148bc99fae Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 16:50:13 +1100 Subject: [PATCH 109/463] feat: add PlayerType enum and use it for player type checking instead of instanceof --- src/components/canvas/players/audio-player.ts | 4 +-- .../canvas/players/caption-player.ts | 8 ++++- src/components/canvas/players/html-player.ts | 4 +-- src/components/canvas/players/image-player.ts | 4 +-- src/components/canvas/players/luma-player.ts | 4 +-- src/components/canvas/players/player.ts | 16 ++++++++- .../canvas/players/rich-text-player.ts | 4 +-- src/components/canvas/players/shape-player.ts | 4 +-- src/components/canvas/players/text-player.ts | 7 +++- src/components/canvas/players/video-player.ts | 4 +-- src/components/canvas/shotstack-canvas.ts | 7 ---- src/core/edit.ts | 36 +++++++++---------- src/core/export/audio-processor.ts | 13 ++++--- src/core/export/export-coordinator.ts | 5 +-- 14 files changed, 69 insertions(+), 51 deletions(-) diff --git a/src/components/canvas/players/audio-player.ts b/src/components/canvas/players/audio-player.ts index e9238ad2..f9f7ce90 100644 --- a/src/components/canvas/players/audio-player.ts +++ b/src/components/canvas/players/audio-player.ts @@ -8,7 +8,7 @@ import { type Keyframe } from "@schemas/keyframe"; import * as howler from "howler"; import * as pixi from "pixi.js"; -import { Player } from "./player"; +import { Player, PlayerType } from "./player"; export class AudioPlayer extends Player { private audioResource: howler.Howl | null; @@ -19,7 +19,7 @@ export class AudioPlayer extends Player { private syncTimer: number; constructor(edit: Edit, clipConfiguration: ResolvedClip) { - super(edit, clipConfiguration); + super(edit, clipConfiguration, PlayerType.Audio); this.audioResource = null; this.isPlaying = false; diff --git a/src/components/canvas/players/caption-player.ts b/src/components/canvas/players/caption-player.ts index 7e3c6ead..c85e2ec4 100644 --- a/src/components/canvas/players/caption-player.ts +++ b/src/components/canvas/players/caption-player.ts @@ -1,4 +1,6 @@ -import { Player } from "@canvas/players/player"; +import { Player, PlayerType } from "@canvas/players/player"; +import type { Edit } from "@core/edit"; +import { type ResolvedClip } from "@schemas/clip"; import { type Cue, findActiveCue, isAliasReference, resolveTranscriptionAlias, revokeVttUrl } from "@core/captions"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; import { type Size, type Vector } from "@layouts/geometry"; @@ -24,6 +26,10 @@ export class CaptionPlayer extends Player { private pendingTranscription: Promise | null = null; private isTranscribing = false; + constructor(edit: Edit, clipConfiguration: ResolvedClip) { + super(edit, clipConfiguration, PlayerType.Caption); + } + public override async load(): Promise { await super.load(); diff --git a/src/components/canvas/players/html-player.ts b/src/components/canvas/players/html-player.ts index 8e6b8247..f4c776ea 100644 --- a/src/components/canvas/players/html-player.ts +++ b/src/components/canvas/players/html-player.ts @@ -5,7 +5,7 @@ import type { Edit } from "core/edit"; import * as pixiFilters from "pixi-filters"; import * as pixi from "pixi.js"; -import { Player } from "./player"; +import { Player, PlayerType } from "./player"; type HtmlDocumentFont = { color?: string; @@ -46,7 +46,7 @@ export class HtmlPlayer extends Player { private text: pixi.Text | null; constructor(timeline: Edit, clipConfiguration: ResolvedClip) { - super(timeline, clipConfiguration); + super(timeline, clipConfiguration, PlayerType.Html); this.background = null; this.text = null; diff --git a/src/components/canvas/players/image-player.ts b/src/components/canvas/players/image-player.ts index 370d5644..ec622395 100644 --- a/src/components/canvas/players/image-player.ts +++ b/src/components/canvas/players/image-player.ts @@ -4,14 +4,14 @@ import { type ResolvedClip } from "@schemas/clip"; import { type ImageAsset } from "@schemas/image-asset"; import * as pixi from "pixi.js"; -import { Player } from "./player"; +import { Player, PlayerType } from "./player"; export class ImagePlayer extends Player { private texture: pixi.Texture | null; private sprite: pixi.Sprite | null; constructor(edit: Edit, clipConfiguration: ResolvedClip) { - super(edit, clipConfiguration); + super(edit, clipConfiguration, PlayerType.Image); this.texture = null; this.sprite = null; diff --git a/src/components/canvas/players/luma-player.ts b/src/components/canvas/players/luma-player.ts index 738f7cfb..d9449a16 100644 --- a/src/components/canvas/players/luma-player.ts +++ b/src/components/canvas/players/luma-player.ts @@ -4,7 +4,7 @@ import { type ResolvedClip } from "@schemas/clip"; import { type LumaAsset } from "@schemas/luma-asset"; import * as pixi from "pixi.js"; -import { Player } from "./player"; +import { Player, PlayerType } from "./player"; type LumaSource = pixi.ImageSource | pixi.VideoSource; @@ -14,7 +14,7 @@ export class LumaPlayer extends Player { private isPlaying: boolean; constructor(edit: Edit, clipConfiguration: ResolvedClip) { - super(edit, clipConfiguration); + super(edit, clipConfiguration, PlayerType.Luma); this.texture = null; this.sprite = null; diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index e01aeff6..3c394ecb 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -12,6 +12,18 @@ import * as pixi from "pixi.js"; import { Entity } from "../../../core/shared/entity"; +export enum PlayerType { + Video = "video", + Image = "image", + Audio = "audio", + Text = "text", + RichText = "rich-text", + Luma = "luma", + Html = "html", + Shape = "shape", + Caption = "caption" +} + /** * TODO: Move handles on UI level (screen space) * TODO: Handle overlapping frames - ex: length of a clip is 1.5s but there's an in (1s) and out (1s) transition @@ -87,6 +99,7 @@ export abstract class Player extends Entity { public layer: number; public shouldDispose: boolean; + public readonly playerType: PlayerType; protected edit: Edit; public clipConfiguration: ResolvedClip; @@ -128,12 +141,13 @@ export abstract class Player extends Entity { private initialClipConfiguration: ResolvedClip | null; protected contentContainer: pixi.Container; - constructor(edit: Edit, clipConfiguration: ResolvedClip) { + constructor(edit: Edit, clipConfiguration: ResolvedClip, playerType: PlayerType) { super(); this.edit = edit; this.layer = 0; this.shouldDispose = false; + this.playerType = playerType; this.clipConfiguration = clipConfiguration; this.positionBuilder = new PositionBuilder(edit.size); diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 56a412a3..2fa09b50 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -1,4 +1,4 @@ -import { Player } from "@canvas/players/player"; +import { Player, PlayerType } from "@canvas/players/player"; import { FONT_PATHS, parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; import { type Size, type Vector } from "@layouts/geometry"; import { RichTextAssetSchema, type RichTextAsset } from "@schemas/rich-text-asset"; @@ -34,7 +34,7 @@ export class RichTextPlayer extends Player { constructor(edit: any, clipConfiguration: any) { // Default fit to "cover" for rich-text assets if not provided const config = clipConfiguration.fit ? clipConfiguration : { ...clipConfiguration, fit: "cover" }; - super(edit, config); + super(edit, config, PlayerType.RichText); } private buildCanvasPayload(richTextAsset: RichTextAsset, fontInfo?: { baseFontFamily: string; fontWeight: number }): any { diff --git a/src/components/canvas/players/shape-player.ts b/src/components/canvas/players/shape-player.ts index 573835bb..4320a455 100644 --- a/src/components/canvas/players/shape-player.ts +++ b/src/components/canvas/players/shape-player.ts @@ -5,14 +5,14 @@ import { type ShapeAsset } from "@schemas/shape-asset"; import * as pixiFilters from "pixi-filters"; import * as pixi from "pixi.js"; -import { Player } from "./player"; +import { Player, PlayerType } from "./player"; export class ShapePlayer extends Player { private shape: pixi.Graphics | null; private shapeBackground: pixi.Graphics | null; constructor(timeline: Edit, clipConfiguration: ResolvedClip) { - super(timeline, clipConfiguration); + super(timeline, clipConfiguration, PlayerType.Shape); this.shape = null; this.shapeBackground = null; diff --git a/src/components/canvas/players/text-player.ts b/src/components/canvas/players/text-player.ts index c8502203..298edd5d 100644 --- a/src/components/canvas/players/text-player.ts +++ b/src/components/canvas/players/text-player.ts @@ -1,4 +1,5 @@ -import { Player } from "@canvas/players/player"; +import { Player, PlayerType } from "@canvas/players/player"; +import type { Edit } from "@core/edit"; import { TextEditor } from "@canvas/text/text-editor"; import { parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; import { type Size, type Vector } from "@layouts/geometry"; @@ -17,6 +18,10 @@ export class TextPlayer extends Player { private text: pixi.Text | null = null; private textEditor: TextEditor | null = null; + constructor(edit: Edit, clipConfiguration: ResolvedClip) { + super(edit, clipConfiguration, PlayerType.Text); + } + public override async load(): Promise { await super.load(); diff --git a/src/components/canvas/players/video-player.ts b/src/components/canvas/players/video-player.ts index d0611b50..9bf799b3 100644 --- a/src/components/canvas/players/video-player.ts +++ b/src/components/canvas/players/video-player.ts @@ -5,7 +5,7 @@ import { type ResolvedClip } from "@schemas/clip"; import { type VideoAsset } from "@schemas/video-asset"; import * as pixi from "pixi.js"; -import { Player } from "./player"; +import { Player, PlayerType } from "./player"; export class VideoPlayer extends Player { private texture: pixi.Texture | null; @@ -19,7 +19,7 @@ export class VideoPlayer extends Player { private skipVideoUpdate: boolean; constructor(edit: Edit, clipConfiguration: ResolvedClip) { - super(edit, clipConfiguration); + super(edit, clipConfiguration, PlayerType.Video); this.texture = null; this.sprite = null; diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index f17a2eb5..9f023bb8 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -290,13 +290,6 @@ export class Canvas { // Pass playback health stats this.inspector.playbackHealth = this.edit.getPlaybackHealth(); - - // Legacy stats for backward compatibility - const memoryStats = this.edit.getMemoryStats(); - this.inspector.clipCounts = memoryStats.clipCounts; - this.inspector.totalClips = memoryStats.totalClips; - this.inspector.commandHistorySize = memoryStats.commandHistorySize; - this.inspector.trackCount = memoryStats.trackCount; } this.inspector.update(ticker.deltaTime, ticker.deltaMS); diff --git a/src/core/edit.ts b/src/core/edit.ts index 7a6c660e..236d90e1 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -3,7 +3,7 @@ import { CaptionPlayer } from "@canvas/players/caption-player"; import { HtmlPlayer } from "@canvas/players/html-player"; import { ImagePlayer } from "@canvas/players/image-player"; import { LumaPlayer } from "@canvas/players/luma-player"; -import type { Player } from "@canvas/players/player"; +import { type Player, PlayerType } from "@canvas/players/player"; import { RichTextPlayer } from "@canvas/players/rich-text-player"; import { ShapePlayer } from "@canvas/players/shape-player"; import { TextPlayer } from "@canvas/players/text-player"; @@ -425,11 +425,11 @@ export class Edit extends Entity { if (!clipToDelete) return; // Check if this is a content clip (not a luma) - const isContentClip = !(clipToDelete instanceof LumaPlayer); + const isContentClip = clipToDelete.playerType !== PlayerType.Luma; if (isContentClip) { // Find attached luma in the same track - const lumaIndex = track.findIndex(clip => clip instanceof LumaPlayer); + const lumaIndex = track.findIndex(clip => clip.playerType === PlayerType.Luma); if (lumaIndex !== -1) { // Delete luma first (handles index shifting correctly) @@ -499,11 +499,11 @@ export class Edit extends Entity { let totalFrames = 0; let textPlayerCount = 0; for (const clip of this.clips) { - if (clip instanceof RichTextPlayer) { + if (clip.playerType === PlayerType.RichText) { richTextClips += 1; - totalFrames += clip.getCacheSize(); + totalFrames += (clip as RichTextPlayer).getCacheSize(); } - if (clip instanceof TextPlayer) { + if (clip.playerType === PlayerType.Text) { textPlayerCount += 1; } } @@ -608,8 +608,8 @@ export class Edit extends Entity { // Add animated text frame caches (RichTextPlayer) for (const clip of this.clips) { - if (clip instanceof RichTextPlayer) { - const frames = clip.getCacheSize(); + if (clip.playerType === PlayerType.RichText) { + const frames = (clip as RichTextPlayer).getCacheSize(); if (frames > 0) { stats.animated.count += 1; stats.animated.frames += frames; @@ -708,13 +708,13 @@ export class Edit extends Entity { if (clip.isActive()) { activeCount += 1; - if (clip instanceof VideoPlayer) { - const drift = clip.getCurrentDrift(); + if (clip.playerType === PlayerType.Video) { + const drift = (clip as VideoPlayer).getCurrentDrift(); videoMaxDrift = Math.max(videoMaxDrift, drift); } - if (clip instanceof AudioPlayer) { - const drift = clip.getCurrentDrift(); + if (clip.playerType === PlayerType.Audio) { + const drift = (clip as AudioPlayer).getCurrentDrift(); audioMaxDrift = Math.max(audioMaxDrift, drift); } } @@ -923,8 +923,8 @@ export class Edit extends Entity { // Clean up luma masks for any luma players being deleted for (const clip of this.clipsToDispose) { - if (clip instanceof LumaPlayer) { - this.cleanupLumaMaskForPlayer(clip); + if (clip.playerType === PlayerType.Luma) { + this.cleanupLumaMaskForPlayer(clip as LumaPlayer); } } @@ -1253,9 +1253,9 @@ export class Edit extends Entity { if (!this.canvas) return; for (const trackClips of this.tracks) { - const lumaPlayer = trackClips.find(clip => clip instanceof LumaPlayer) as LumaPlayer | undefined; + const lumaPlayer = trackClips.find(clip => clip.playerType === PlayerType.Luma) as LumaPlayer | undefined; const lumaSprite = lumaPlayer?.getSprite(); - const contentClips = trackClips.filter(clip => !(clip instanceof LumaPlayer)); + const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); if (lumaPlayer && lumaSprite?.texture && contentClips.length > 0) { this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); @@ -1395,8 +1395,8 @@ export class Edit extends Entity { for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { const trackClips = this.tracks[trackIdx]; - const lumaPlayer = trackClips.find(clip => clip instanceof LumaPlayer) as LumaPlayer | undefined; - const contentClips = trackClips.filter(clip => !(clip instanceof LumaPlayer)); + const lumaPlayer = trackClips.find(clip => clip.playerType === PlayerType.Luma) as LumaPlayer | undefined; + const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); // ALWAYS hide luma player if it has a parent (even if mask exists) // This handles the case where AddTrackCommand re-adds luma to scene diff --git a/src/core/export/audio-processor.ts b/src/core/export/audio-processor.ts index e46b8aa7..1beedbc5 100644 --- a/src/core/export/audio-processor.ts +++ b/src/core/export/audio-processor.ts @@ -1,4 +1,5 @@ import { AudioPlayer } from "@canvas/players/audio-player"; +import { PlayerType } from "@canvas/players/player"; import type { AudioAsset } from "@schemas/audio-asset"; import { Output, AudioSampleSource, AudioSample } from "mediabunny"; @@ -87,13 +88,11 @@ export class AudioProcessor { } private isAudioPlayer(clip: unknown): clip is AudioPlayer { - if (clip instanceof AudioPlayer) return true; if (!clip || typeof clip !== "object") return false; - const c = clip as Record; - const hasAudioConstructor = c.constructor?.name === "AudioPlayer"; - const config = c["clipConfiguration"] as Record | undefined; - const asset = config?.["asset"] as Record | undefined; - const hasAudioAsset = asset?.["type"] === "audio"; - return hasAudioConstructor || hasAudioAsset; + const c = clip as { playerType?: string }; + if (c.playerType === PlayerType.Audio) return true; + // Fallback for cases where playerType might not exist + const config = (clip as { clipConfiguration?: { asset?: { type?: string } } }).clipConfiguration; + return config?.asset?.type === "audio"; } } diff --git a/src/core/export/export-coordinator.ts b/src/core/export/export-coordinator.ts index 8565d501..6fd14fd8 100644 --- a/src/core/export/export-coordinator.ts +++ b/src/core/export/export-coordinator.ts @@ -1,4 +1,5 @@ import { CaptionPlayer } from "@canvas/players/caption-player"; +import { PlayerType } from "@canvas/players/player"; import { Canvas } from "@canvas/shotstack-canvas"; import { ExportCommand } from "@core/commands/export-command"; import { Edit } from "@core/edit"; @@ -241,8 +242,8 @@ export class ExportCoordinator { const transcriptionPromises: Promise[] = []; for (const clip of clips) { - if (clip instanceof CaptionPlayer && clip.isTranscriptionPending()) { - transcriptionPromises.push(clip.waitForTranscription()); + if (clip.playerType === PlayerType.Caption && (clip as CaptionPlayer).isTranscriptionPending()) { + transcriptionPromises.push((clip as CaptionPlayer).waitForTranscription()); } } From 119c9c1dfe18f22efd6f30234945efb57c0d529c Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 17:01:48 +1100 Subject: [PATCH 110/463] fix: skip rendering when rich text player is inactive --- src/components/canvas/players/rich-text-player.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 2fa09b50..b1915f18 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -370,6 +370,10 @@ export class RichTextPlayer extends Player { this.lastRenderedTime = -1; } + if (!this.isActive()) { + return; + } + if (this.textEngine && this.renderer && !this.isRendering) { const currentTimeSeconds = this.getCurrentTime() / 1000; const targetFPS = this.edit.getOutputFps(); From 72f842128a1a2f2f02e049e9d9055d79e2631d93 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 19:41:30 +1100 Subject: [PATCH 111/463] feat: use fixed 60fps preview rendering and show cached frames while rendering --- .../canvas/players/rich-text-player.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index b1915f18..b0385337 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -19,6 +19,7 @@ const extractFontNames = (url: string): { full: string; base: string } => { }; export class RichTextPlayer extends Player { + private static readonly PREVIEW_FPS = 60; private textEngine: TextEngine | null = null; private renderer: TextRenderer | null = null; private canvas: HTMLCanvasElement | null = null; @@ -27,7 +28,6 @@ export class RichTextPlayer extends Player { private lastRenderedTime: number = -1; private cachedFrames = new Map(); private isRendering: boolean = false; - private targetFPS: number = 30; private validatedAsset: ValidatedRichTextAsset | null = null; private fontSupportsBold: boolean = false; @@ -193,9 +193,6 @@ export class RichTextPlayer extends Player { const richTextAsset = this.clipConfiguration.asset as RichTextAsset; try { - const editData = this.edit.getEdit(); - this.targetFPS = editData?.output?.fps || 30; - const validationResult = RichTextAssetSchema.safeParse(richTextAsset); if (!validationResult.success) { console.error("Rich-text asset validation failed:", validationResult.error); @@ -212,7 +209,7 @@ export class RichTextPlayer extends Player { this.textEngine = (await createTextEngine({ width: canvasPayload.width, height: canvasPayload.height, - fps: this.targetFPS + fps: RichTextPlayer.PREVIEW_FPS })) as TextEngine; const { value: validated } = this.textEngine!.validate(canvasPayload); @@ -262,7 +259,7 @@ export class RichTextPlayer extends Player { private async renderFrame(timeSeconds: number): Promise { if (!this.textEngine || !this.renderer || !this.canvas || !this.validatedAsset) return; - const cacheKey = Math.floor(timeSeconds * this.targetFPS); + const cacheKey = Math.floor(timeSeconds * RichTextPlayer.PREVIEW_FPS); if (this.cachedFrames.has(cacheKey)) { const cachedTexture = this.cachedFrames.get(cacheKey)!; @@ -351,7 +348,15 @@ export class RichTextPlayer extends Player { this.contentContainer.addChild(fallbackText); } private renderFrameSafe(timeSeconds: number): void { - if (this.isRendering) return; + if (this.isRendering) { + // Show nearest cached frame instead of skipping entirely + const cacheKey = Math.floor(timeSeconds * RichTextPlayer.PREVIEW_FPS); + const cachedTexture = this.cachedFrames.get(cacheKey); + if (cachedTexture && this.sprite && this.sprite.texture !== cachedTexture) { + this.sprite.texture = cachedTexture; + } + return; + } this.isRendering = true; this.renderFrame(timeSeconds) @@ -376,8 +381,7 @@ export class RichTextPlayer extends Player { if (this.textEngine && this.renderer && !this.isRendering) { const currentTimeSeconds = this.getCurrentTime() / 1000; - const targetFPS = this.edit.getOutputFps(); - const frameInterval = 1 / targetFPS; + const frameInterval = 1 / 60; // Always render at 60fps for smooth preview if (Math.abs(currentTimeSeconds - this.lastRenderedTime) > frameInterval) { this.renderFrameSafe(currentTimeSeconds); @@ -393,7 +397,7 @@ export class RichTextPlayer extends Player { } this.cachedFrames.clear(); - if (this.texture && !this.cachedFrames.has(Math.floor(this.lastRenderedTime * this.targetFPS))) { + if (this.texture && !this.cachedFrames.has(Math.floor(this.lastRenderedTime * RichTextPlayer.PREVIEW_FPS))) { this.texture.destroy(); } this.texture = null; From 99fe9595eef6fec03d668511210fb6a09f878b1b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 20:31:25 +1100 Subject: [PATCH 112/463] feat: rebuild merge fields popup with proper scroll architecture and improved styling --- src/components/canvas/shotstack-canvas.ts | 2 +- src/core/ui/canvas-toolbar.css.ts | 138 ++++++++++++++++------ src/core/ui/canvas-toolbar.ts | 6 +- 3 files changed, 103 insertions(+), 43 deletions(-) diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 9f023bb8..ce17d271 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -110,7 +110,7 @@ export class Canvas { (e: WheelEvent) => { // Allow scrolling in toolbar popups const target = e.target as HTMLElement; - if (target.closest(".ss-toolbar-popup")) { + if (target.closest(".ss-toolbar-popup") || target.closest(".ss-canvas-toolbar-popup")) { return; } diff --git a/src/core/ui/canvas-toolbar.css.ts b/src/core/ui/canvas-toolbar.css.ts index 9e463705..03b441c3 100644 --- a/src/core/ui/canvas-toolbar.css.ts +++ b/src/core/ui/canvas-toolbar.css.ts @@ -319,117 +319,177 @@ export const CANVAS_TOOLBAR_STYLES = ` border-color: rgba(0, 0, 0, 0.3); } -/* Variables popup */ +/* =========================================== + MERGE FIELDS POPUP + Rebuilt with proper scroll architecture + =========================================== */ + +/* Container - Override transform positioning to fix scroll */ .ss-canvas-toolbar-popup--variables { - min-width: 260px; + width: 300px; + max-height: 400px; + /* Override transform-based centering - use top offset instead */ + transform: none; + top: calc(50% - 200px); + flex-direction: column; + overflow: hidden; +} + +/* Only display as flex when visible */ +.ss-canvas-toolbar-popup--variables.visible { + display: flex; +} + +/* Reposition the arrow for non-transform positioning */ +.ss-canvas-toolbar-popup--variables::after { + top: 200px; + transform: rotate(45deg); } +/* Header - fixed, never scrolls */ .ss-variables-header { + flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; + padding: 10px 12px 8px; } .ss-variables-add-btn { - width: 24px; - height: 24px; + width: 26px; + height: 26px; display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.06); + background: rgba(0, 0, 0, 0.05); border: none; - border-radius: 6px; - color: rgba(0, 0, 0, 0.65); - font-size: 16px; - font-weight: 500; + border-radius: 8px; + color: rgba(0, 0, 0, 0.5); + font-size: 18px; + font-weight: 400; cursor: pointer; transition: all 0.15s ease; } .ss-variables-add-btn:hover { background: rgba(0, 0, 0, 0.1); - color: rgba(0, 0, 0, 0.9); + color: rgba(0, 0, 0, 0.8); } +/* THE scroll container - single source of scroll */ .ss-variables-list { + flex: 1 1 auto; + min-height: 0; /* Critical for flex scroll */ display: flex; flex-direction: column; gap: 6px; - max-height: 240px; + padding: 4px 8px 8px; overflow-y: auto; + overflow-x: hidden; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} + +/* Custom scrollbar */ +.ss-variables-list::-webkit-scrollbar { + width: 5px; +} + +.ss-variables-list::-webkit-scrollbar-track { + background: transparent; + margin: 4px 0; } +.ss-variables-list::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.12); + border-radius: 3px; +} + +.ss-variables-list::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.2); +} + +/* Empty state */ .ss-variables-empty { - padding: 16px 12px; + padding: 20px 12px; text-align: center; font-size: 13px; - color: rgba(0, 0, 0, 0.4); + color: rgba(0, 0, 0, 0.35); } +/* Field item container */ .ss-variable-item { + flex-shrink: 0; display: flex; - align-items: center; - gap: 8px; - padding: 8px 10px; - background: rgba(0, 0, 0, 0.03); - border-radius: 8px; + flex-direction: column; + gap: 6px; + padding: 10px; + background: rgba(0, 0, 0, 0.025); + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.04); } -.ss-variable-info { - flex: 1; +/* Field header row */ +.ss-variable-item-header { display: flex; - flex-direction: column; - gap: 4px; - min-width: 0; + align-items: center; + justify-content: space-between; + gap: 8px; } +/* Variable name label */ .ss-variable-name { - font-size: 12px; + font-size: 11px; font-weight: 600; - color: rgba(99, 102, 241, 0.9); + color: rgba(99, 102, 241, 0.85); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace; + letter-spacing: 0.02em; } +/* Value input */ .ss-variable-value { width: 100%; - padding: 6px 8px; - background: rgba(255, 255, 255, 0.8); - border: 1px solid rgba(0, 0, 0, 0.08); - border-radius: 6px; - font-size: 12px; + padding: 8px 10px; + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 8px; + font-size: 13px; color: #1a1a1a; outline: none; + transition: border-color 0.15s ease, box-shadow 0.15s ease; } .ss-variable-value:focus { border-color: rgba(99, 102, 241, 0.5); - background: #fff; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.08); } .ss-variable-value::placeholder { - color: rgba(0, 0, 0, 0.35); + color: rgba(0, 0, 0, 0.3); } .ss-variable-value.error { - border-color: rgba(239, 68, 68, 0.6); - background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.5); + background: rgba(239, 68, 68, 0.03); } .ss-variable-value.error:focus { - border-color: rgba(239, 68, 68, 0.8); + border-color: rgba(239, 68, 68, 0.7); + box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.08); } +/* Delete button */ .ss-variable-delete { - width: 24px; - height: 24px; + width: 22px; + height: 22px; display: flex; align-items: center; justify-content: center; background: transparent; border: none; border-radius: 6px; - color: rgba(0, 0, 0, 0.35); - font-size: 16px; + color: rgba(0, 0, 0, 0.25); + font-size: 14px; cursor: pointer; transition: all 0.15s ease; flex-shrink: 0; diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts index 3dfda424..e4a00ad4 100644 --- a/src/core/ui/canvas-toolbar.ts +++ b/src/core/ui/canvas-toolbar.ts @@ -464,11 +464,11 @@ export class CanvasToolbar { .map( (f: MergeField) => `
-
+
{{ ${f.name} }} - +
- +
` ) From 87f155f268c65e27834d59963e1fe6b8b920841e Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 20:41:24 +1100 Subject: [PATCH 113/463] style: update canvas toolbar color input styling --- src/core/ui/canvas-toolbar.css.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/ui/canvas-toolbar.css.ts b/src/core/ui/canvas-toolbar.css.ts index 03b441c3..77aefbcf 100644 --- a/src/core/ui/canvas-toolbar.css.ts +++ b/src/core/ui/canvas-toolbar.css.ts @@ -274,7 +274,7 @@ export const CANVAS_TOOLBAR_STYLES = ` .ss-canvas-toolbar-color-input { width: 100%; height: 120px; - border: none; + border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 10px; cursor: pointer; padding: 0; @@ -286,12 +286,12 @@ export const CANVAS_TOOLBAR_STYLES = ` .ss-canvas-toolbar-color-input::-webkit-color-swatch { border: none; - border-radius: 10px; + border-radius: 9px; } .ss-canvas-toolbar-color-input::-moz-color-swatch { border: none; - border-radius: 10px; + border-radius: 9px; } /* Color swatches grid */ From b8fda57d0785ac7ab44b16990311ec03820e294b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 20:53:33 +1100 Subject: [PATCH 114/463] style: update canvas toolbar popup shadow and border styling --- src/core/ui/canvas-toolbar.css.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/core/ui/canvas-toolbar.css.ts b/src/core/ui/canvas-toolbar.css.ts index 77aefbcf..0494cd3d 100644 --- a/src/core/ui/canvas-toolbar.css.ts +++ b/src/core/ui/canvas-toolbar.css.ts @@ -139,14 +139,11 @@ export const CANVAS_TOOLBAR_STYLES = ` top: 50%; transform: translateY(-50%); background: #fff; - border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 14px; padding: 8px; - box-shadow: - 0 4px 12px rgba(0, 0, 0, 0.08), - 0 12px 40px rgba(0, 0, 0, 0.12); min-width: 180px; z-index: 100; + filter: drop-shadow(0 4px 12px rgba(0, 0, 0, 0.15)); } .ss-canvas-toolbar-popup.visible { @@ -159,11 +156,9 @@ export const CANVAS_TOOLBAR_STYLES = ` right: -6px; top: 50%; transform: translateY(-50%) rotate(45deg); - width: 10px; - height: 10px; + width: 12px; + height: 12px; background: #fff; - border-right: 1px solid rgba(0, 0, 0, 0.08); - border-bottom: 1px solid rgba(0, 0, 0, 0.08); } /* Popup items */ @@ -332,7 +327,7 @@ export const CANVAS_TOOLBAR_STYLES = ` transform: none; top: calc(50% - 200px); flex-direction: column; - overflow: hidden; + overflow: visible; } /* Only display as flex when visible */ @@ -388,6 +383,7 @@ export const CANVAS_TOOLBAR_STYLES = ` overflow-x: hidden; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; + border-radius: 0 0 10px 10px; } /* Custom scrollbar */ From a0ca70e25dd19be6762566340649ed3224243ec6 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 21:42:21 +1100 Subject: [PATCH 115/463] feat: add transition and effect controls to toolbars --- src/core/ui/base-toolbar.ts | 19 +- src/core/ui/rich-text-toolbar.css.ts | 67 ++- src/core/ui/rich-text-toolbar.ts | 854 +++++++++++++++++++-------- src/core/ui/text-toolbar.ts | 564 +++++++++++++++++- 4 files changed, 1238 insertions(+), 266 deletions(-) diff --git a/src/core/ui/base-toolbar.ts b/src/core/ui/base-toolbar.ts index 73eebc31..5664329f 100644 --- a/src/core/ui/base-toolbar.ts +++ b/src/core/ui/base-toolbar.ts @@ -32,7 +32,9 @@ export const TOOLBAR_ICONS = { background: ``, stroke: ``, edit: ``, - chevron: `` + chevron: ``, + transition: ``, + effect: `` }; /** @@ -113,18 +115,27 @@ export abstract class BaseToolbar { document.head.appendChild(this.styleElement); } + /** + * Check if a popup is currently visible (handles both CSS class and inline style patterns). + */ + protected isPopupOpen(popup: HTMLElement | null): boolean { + if (!popup) return false; + return popup.classList.contains("visible") || popup.style.display === "block"; + } + /** * Toggle a popup's visibility, closing all others first. - * Uses CSS class-based visibility. + * Uses CSS class-based visibility. Optional callback fires when popup opens. */ - protected togglePopup(popup: HTMLElement | null): void { - const isOpen = popup?.classList.contains("visible"); + protected togglePopup(popup: HTMLElement | null, onOpen?: () => void): void { + const isOpen = this.isPopupOpen(popup); this.closeAllPopups(); if (!isOpen && popup) { popup.classList.add("visible"); popup.style.display = ""; // Clear inline style, let CSS control + onOpen?.(); } } diff --git a/src/core/ui/rich-text-toolbar.css.ts b/src/core/ui/rich-text-toolbar.css.ts index fb70b91f..b8ca298c 100644 --- a/src/core/ui/rich-text-toolbar.css.ts +++ b/src/core/ui/rich-text-toolbar.css.ts @@ -153,8 +153,8 @@ export const TOOLBAR_STYLES = ` .ss-toolbar-size-item:hover { background: rgba(255, 255, 255, 0.08); } .ss-toolbar-size-item.active { background: rgba(255, 255, 255, 0.12); } -.ss-toolbar-btn--text-edit { width: auto; min-width: 56px; padding: 0 10px; gap: 6px; } -.ss-toolbar-btn--text-edit span { font-size: 12px; font-weight: 500; } +.ss-toolbar-btn.ss-toolbar-btn--text-edit { width: auto; min-width: auto; padding: 0 10px; gap: 6px; } +.ss-toolbar-btn--text-edit span { font-size: 12px; font-weight: 500; white-space: nowrap; } .ss-toolbar-popup--text-edit { min-width: 280px; padding: 14px 16px; } .ss-toolbar-text-area-wrapper { position: relative; } @@ -244,4 +244,67 @@ export const TOOLBAR_STYLES = ` } .ss-animation-preset:hover { background: rgba(255, 255, 255, 0.1); border-color: rgba(255, 255, 255, 0.15); color: rgba(255, 255, 255, 0.9); } .ss-animation-preset.active { background: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.25); color: #fff; } + +/* Transition popup - tabbed design */ +.ss-toolbar-popup--transition { min-width: 220px; padding: 12px; } + +.ss-transition-tabs { display: flex; background: rgba(255, 255, 255, 0.06); border-radius: 6px; padding: 2px; margin-bottom: 12px; } +.ss-transition-tab { flex: 1; padding: 6px 12px; background: transparent; border: none; border-radius: 4px; color: rgba(255, 255, 255, 0.5); font-size: 11px; font-weight: 600; cursor: pointer; transition: all 0.15s ease; text-transform: uppercase; letter-spacing: 0.03em; } +.ss-transition-tab:hover { color: rgba(255, 255, 255, 0.7); } +.ss-transition-tab.active { background: rgba(255, 255, 255, 0.12); color: #fff; } + +.ss-transition-effects { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; } +.ss-transition-effect { padding: 8px 4px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 6px; color: rgba(255, 255, 255, 0.6); font-size: 11px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; text-align: center; } +.ss-transition-effect:hover { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); } +.ss-transition-effect.active { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.2); color: #fff; } + +.ss-transition-direction-row { display: none; align-items: center; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.06); } +.ss-transition-direction-row.visible { display: flex; } +.ss-transition-label { font-size: 10px; font-weight: 500; color: rgba(255, 255, 255, 0.4); min-width: 52px; } +.ss-transition-directions { display: flex; gap: 4px; flex: 1; } +.ss-transition-dir { flex: 1; padding: 6px 8px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 5px; color: rgba(255, 255, 255, 0.5); font-size: 12px; cursor: pointer; transition: all 0.15s ease; text-align: center; } +.ss-transition-dir:hover { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); } +.ss-transition-dir.active { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.2); color: #fff; } +.ss-transition-dir.hidden { display: none; } + +.ss-transition-speed-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255, 255, 255, 0.06); } +.ss-transition-speed-stepper { display: flex; align-items: center; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 6px; overflow: hidden; } +.ss-transition-speed-btn { width: 28px; height: 26px; background: transparent; border: none; color: rgba(255, 255, 255, 0.5); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; display: flex; align-items: center; justify-content: center; } +.ss-transition-speed-btn:hover { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); } +.ss-transition-speed-btn:active { background: rgba(255, 255, 255, 0.12); } +.ss-transition-speed-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.ss-transition-speed-value { min-width: 42px; padding: 0 4px; text-align: center; font-size: 11px; font-weight: 500; color: rgba(255, 255, 255, 0.85); font-variant-numeric: tabular-nums; border-left: 1px solid rgba(255, 255, 255, 0.06); border-right: 1px solid rgba(255, 255, 255, 0.06); } + +/* Effect popup - progressive disclosure design */ +.ss-toolbar-popup--effect { min-width: 200px; padding: 12px; } +.ss-effect-types { display: flex; gap: 6px; } +.ss-effect-type { flex: 1; padding: 10px 8px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 8px; color: rgba(255, 255, 255, 0.6); font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; text-align: center; } +.ss-effect-type:hover { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); } +.ss-effect-type.active { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.2); color: #fff; } + +.ss-effect-variant-row { display: none; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.06); animation: fadeSlideIn 0.15s ease; } +.ss-effect-variant-row.visible { display: flex; } +.ss-effect-variants { display: flex; gap: 4px; flex: 1; } +.ss-effect-variant { flex: 1; padding: 6px 12px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 6px; color: rgba(255, 255, 255, 0.5); font-size: 11px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; text-align: center; } +.ss-effect-variant:hover { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); } +.ss-effect-variant.active { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.2); color: #fff; } + +.ss-effect-direction-row { display: none; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.06); animation: fadeSlideIn 0.15s ease; } +.ss-effect-direction-row.visible { display: flex; } +.ss-effect-directions { display: flex; gap: 4px; flex: 1; } +.ss-effect-dir { flex: 1; padding: 6px 8px; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 6px; color: rgba(255, 255, 255, 0.5); font-size: 13px; cursor: pointer; transition: all 0.15s ease; text-align: center; } +.ss-effect-dir:hover { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); } +.ss-effect-dir.active { background: rgba(255, 255, 255, 0.12); border-color: rgba(255, 255, 255, 0.2); color: #fff; } + +.ss-effect-speed-row { display: none; align-items: center; gap: 8px; margin-top: 12px; padding-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.06); animation: fadeSlideIn 0.15s ease; } +.ss-effect-speed-row.visible { display: flex; } +.ss-effect-label { font-size: 10px; font-weight: 500; color: rgba(255, 255, 255, 0.4); text-transform: uppercase; letter-spacing: 0.03em; min-width: 52px; } +.ss-effect-speed-stepper { display: flex; align-items: center; background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 6px; overflow: hidden; } +.ss-effect-speed-btn { width: 28px; height: 26px; background: transparent; border: none; color: rgba(255, 255, 255, 0.5); font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.15s ease; display: flex; align-items: center; justify-content: center; } +.ss-effect-speed-btn:hover { background: rgba(255, 255, 255, 0.08); color: rgba(255, 255, 255, 0.9); } +.ss-effect-speed-btn:active { background: rgba(255, 255, 255, 0.12); } +.ss-effect-speed-btn:disabled { opacity: 0.3; cursor: not-allowed; } +.ss-effect-speed-value { min-width: 42px; padding: 0 4px; text-align: center; font-size: 11px; font-weight: 500; color: rgba(255, 255, 255, 0.85); font-variant-numeric: tabular-nums; border-left: 1px solid rgba(255, 255, 255, 0.06); border-right: 1px solid rgba(255, 255, 255, 0.06); } + +@keyframes fadeSlideIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } } `; diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 0df36015..178320db 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -9,13 +9,11 @@ import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; export class RichTextToolbar extends BaseToolbar { - private fontBtn: HTMLButtonElement | null = null; private fontPopup: HTMLDivElement | null = null; private fontPreview: HTMLSpanElement | null = null; private sizeInput: HTMLInputElement | null = null; private sizePopup: HTMLDivElement | null = null; private boldBtn: HTMLButtonElement | null = null; - private spacingBtn: HTMLButtonElement | null = null; private spacingPopup: HTMLDivElement | null = null; private letterSpacingSlider: HTMLInputElement | null = null; private letterSpacingValue: HTMLSpanElement | null = null; @@ -24,12 +22,10 @@ export class RichTextToolbar extends BaseToolbar { private anchorTopBtn: HTMLButtonElement | null = null; private anchorMiddleBtn: HTMLButtonElement | null = null; private anchorBottomBtn: HTMLButtonElement | null = null; - private alignBtn: HTMLButtonElement | null = null; private alignIcon: SVGElement | null = null; private transformBtn: HTMLButtonElement | null = null; private underlineBtn: HTMLButtonElement | null = null; private linethroughBtn: HTMLButtonElement | null = null; - private textEditBtn: HTMLButtonElement | null = null; private textEditPopup: HTMLDivElement | null = null; private textEditArea: HTMLTextAreaElement | null = null; private textEditDebounceTimer: ReturnType | null = null; @@ -41,7 +37,6 @@ export class RichTextToolbar extends BaseToolbar { private autocompleteFilter: string = ""; private autocompleteStartPos: number = 0; private selectedAutocompleteIndex: number = 0; - private borderBtn: HTMLButtonElement | null = null; private borderPopup: HTMLDivElement | null = null; private borderWidthSlider: HTMLInputElement | null = null; private borderWidthValue: HTMLSpanElement | null = null; @@ -50,11 +45,9 @@ export class RichTextToolbar extends BaseToolbar { private borderOpacityValue: HTMLSpanElement | null = null; private borderRadiusSlider: HTMLInputElement | null = null; private borderRadiusValue: HTMLSpanElement | null = null; - private backgroundBtn: HTMLButtonElement | null = null; private backgroundPopup: HTMLDivElement | null = null; private backgroundColorPicker: BackgroundColorPicker | null = null; - private paddingBtn: HTMLButtonElement | null = null; private paddingPopup: HTMLDivElement | null = null; private paddingTopSlider: HTMLInputElement | null = null; private paddingTopValue: HTMLSpanElement | null = null; @@ -65,12 +58,10 @@ export class RichTextToolbar extends BaseToolbar { private paddingLeftSlider: HTMLInputElement | null = null; private paddingLeftValue: HTMLSpanElement | null = null; - private fontColorBtn: HTMLButtonElement | null = null; private fontColorPopup: HTMLDivElement | null = null; private fontColorPicker: FontColorPicker | null = null; private colorDisplay: HTMLButtonElement | null = null; - private shadowBtn: HTMLButtonElement | null = null; private shadowPopup: HTMLDivElement | null = null; private shadowToggle: HTMLInputElement | null = null; private shadowOffsetXSlider: HTMLInputElement | null = null; @@ -84,13 +75,41 @@ export class RichTextToolbar extends BaseToolbar { private shadowOpacityValue: HTMLSpanElement | null = null; private lastShadowConfig: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number } | null = null; - private animationBtn: HTMLButtonElement | null = null; private animationPopup: HTMLDivElement | null = null; private animationDurationSlider: HTMLInputElement | null = null; private animationDurationValue: HTMLSpanElement | null = null; private animationStyleSection: HTMLDivElement | null = null; private animationDirectionSection: HTMLDivElement | null = null; + // Transition state + private activeTransitionTab: "in" | "out" = "in"; + private transitionInEffect: string = ""; + private transitionInDirection: string = ""; + private transitionInSpeed: number = 1.0; + private transitionOutEffect: string = ""; + private transitionOutDirection: string = ""; + private transitionOutSpeed: number = 1.0; + private readonly SPEED_VALUES = [0.25, 0.5, 1.0, 2.0]; + + // Transition elements + private transitionPopup: HTMLDivElement | null = null; + private directionRow: HTMLDivElement | null = null; + private speedValueLabel: HTMLSpanElement | null = null; + + // Effect state + private effectType: "" | "zoom" | "slide" = ""; + private effectVariant: "In" | "Out" = "In"; + private effectDirection: "Left" | "Right" | "Up" | "Down" = "Right"; + private effectSpeed: number = 1.0; + private readonly EFFECT_SPEED_VALUES = [0.5, 1.0, 2.0]; + + // Effect elements + private effectPopup: HTMLDivElement | null = null; + private effectVariantRow: HTMLDivElement | null = null; + private effectDirectionRow: HTMLDivElement | null = null; + private effectSpeedRow: HTMLDivElement | null = null; + private effectSpeedValueLabel: HTMLSpanElement | null = null; + override mount(parent: HTMLElement): void { this.injectStyles("ss-toolbar-styles", TOOLBAR_STYLES); @@ -383,6 +402,86 @@ export class RichTextToolbar extends BaseToolbar {
+
+ +
+
+ + +
+
+ + + + + + +
+
+ Direction +
+ + + + +
+
+
+ Speed +
+ + 1.00s + +
+
+
+
+ +
+ +
+
+ + + +
+
+ Variant +
+ + +
+
+
+ Direction +
+ + + + +
+
+
+ Speed +
+ + 1s + +
+
+
+
+
+ +
+ +
+ +
+
+ + +
+
+ + + + + + +
+
+ Direction +
+ + + + +
+
+
+ Speed +
+ + 1.00s + +
+
+
+
+ +
+ +
+
+ + + +
+
+ Variant +
+ + +
+
+
+ Direction +
+ + + + +
+
+
+ Speed +
+ + 1s + +
+
+
+
`; parent.insertBefore(this.container, parent.firstChild); @@ -274,6 +387,20 @@ export class TextToolbar extends BaseToolbar { this.strokeWidthSlider = this.container.querySelector("[data-stroke-width]"); this.strokeWidthValue = this.container.querySelector("[data-stroke-width-value]"); this.strokeColorInput = this.container.querySelector("[data-stroke-color]"); + + // Transition + this.transitionBtn = this.container.querySelector("[data-action='transition-toggle']"); + this.transitionPopup = this.container.querySelector("[data-transition-popup]"); + this.directionRow = this.container.querySelector("[data-direction-row]"); + this.speedValueLabel = this.container.querySelector("[data-speed-value]"); + + // Effect + this.effectBtn = this.container.querySelector("[data-action='effect-toggle']"); + this.effectPopup = this.container.querySelector("[data-effect-popup]"); + this.effectVariantRow = this.container.querySelector("[data-effect-variant-row]"); + this.effectDirectionRow = this.container.querySelector("[data-effect-direction-row]"); + this.effectSpeedRow = this.container.querySelector("[data-effect-speed-row]"); + this.effectSpeedValueLabel = this.container.querySelector("[data-effect-speed-value]"); } private setupEventListeners(): void { @@ -319,6 +446,72 @@ export class TextToolbar extends BaseToolbar { }); this.strokeColorInput?.addEventListener("input", () => this.handleStrokeChange()); + // Transition tab handlers + this.transitionPopup?.querySelectorAll("[data-tab]").forEach(tab => { + tab.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const tabValue = el.dataset["tab"] as "in" | "out"; + this.handleTabChange(tabValue); + }); + }); + + // Transition effect handlers + this.transitionPopup?.querySelectorAll("[data-effect]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const effect = el.dataset["effect"] || ""; + this.handleTransitionEffectSelect(effect); + }); + }); + + // Transition direction handlers + this.transitionPopup?.querySelectorAll("[data-dir]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const dir = el.dataset["dir"] || ""; + this.handleDirectionSelect(dir); + }); + }); + + // Transition speed stepper + const speedDecrease = this.transitionPopup?.querySelector("[data-speed-decrease]"); + const speedIncrease = this.transitionPopup?.querySelector("[data-speed-increase]"); + speedDecrease?.addEventListener("click", () => this.handleSpeedStep(-1)); + speedIncrease?.addEventListener("click", () => this.handleSpeedStep(1)); + + // Effect type handlers + this.effectPopup?.querySelectorAll("[data-effect-type]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const effectType = el.dataset["effectType"] || ""; + this.handleEffectTypeSelect(effectType as "" | "zoom" | "slide"); + }); + }); + + // Effect variant handlers (for Zoom) + this.effectPopup?.querySelectorAll("[data-variant]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const variant = el.dataset["variant"] || "In"; + this.handleEffectVariantSelect(variant as "In" | "Out"); + }); + }); + + // Effect direction handlers (for Slide) + this.effectPopup?.querySelectorAll("[data-effect-dir]").forEach(btn => { + btn.addEventListener("click", e => { + const el = e.currentTarget as HTMLElement; + const dir = el.dataset["effectDir"] || "Right"; + this.handleEffectDirectionSelect(dir as "Left" | "Right" | "Up" | "Down"); + }); + }); + + // Effect speed stepper + const effectSpeedDecrease = this.effectPopup?.querySelector("[data-effect-speed-decrease]"); + const effectSpeedIncrease = this.effectPopup?.querySelector("[data-effect-speed-increase]"); + effectSpeedDecrease?.addEventListener("click", () => this.handleEffectSpeedStep(-1)); + effectSpeedIncrease?.addEventListener("click", () => this.handleEffectSpeedStep(1)); + // Use base class outside click handler this.setupOutsideClickHandler(); } @@ -371,6 +564,12 @@ export class TextToolbar extends BaseToolbar { case "stroke-toggle": this.togglePopup(this.strokePopup); break; + case "transition-toggle": + this.togglePopup(this.transitionPopup); + break; + case "effect-toggle": + this.togglePopup(this.effectPopup); + break; default: break; } @@ -384,7 +583,9 @@ export class TextToolbar extends BaseToolbar { this.fontColorPopup, this.spacingPopup, this.backgroundPopup, - this.strokePopup + this.strokePopup, + this.transitionPopup, + this.effectPopup ]; } @@ -556,6 +757,333 @@ export class TextToolbar extends BaseToolbar { }); } + // ==================== Transition Handlers ==================== + + private handleTabChange(tab: "in" | "out"): void { + this.activeTransitionTab = tab; + this.updateTransitionUI(); + } + + private handleTransitionEffectSelect(effect: string): void { + const tab = this.activeTransitionTab; + + if (tab === "in") { + this.transitionInEffect = effect; + this.transitionInDirection = this.getDefaultDirection(effect); + } else { + this.transitionOutEffect = effect; + this.transitionOutDirection = this.getDefaultDirection(effect); + } + + this.updateTransitionUI(); + this.applyTransitionUpdate(); + } + + private handleDirectionSelect(direction: string): void { + const tab = this.activeTransitionTab; + + if (tab === "in") { + this.transitionInDirection = direction; + } else { + this.transitionOutDirection = direction; + } + + this.updateTransitionUI(); + this.applyTransitionUpdate(); + } + + private handleSpeedStep(direction: number): void { + const tab = this.activeTransitionTab; + const currentSpeed = tab === "in" ? this.transitionInSpeed : this.transitionOutSpeed; + + let currentIdx = this.SPEED_VALUES.indexOf(currentSpeed); + if (currentIdx === -1) { + currentIdx = this.SPEED_VALUES.findIndex(v => v >= currentSpeed); + if (currentIdx === -1) currentIdx = this.SPEED_VALUES.length - 1; + } + + const newIdx = Math.max(0, Math.min(this.SPEED_VALUES.length - 1, currentIdx + direction)); + const newSpeed = this.SPEED_VALUES[newIdx]; + + if (tab === "in") { + this.transitionInSpeed = newSpeed; + } else { + this.transitionOutSpeed = newSpeed; + } + + this.updateTransitionUI(); + this.applyTransitionUpdate(); + } + + private needsDirection(effect: string): boolean { + return ["slide", "wipe", "carousel"].includes(effect); + } + + private getDefaultDirection(effect: string): string { + if (this.needsDirection(effect)) { + return "Right"; + } + return ""; + } + + private speedToSuffix(speed: number, effect: string): string { + const isSlideOrCarousel = effect === "slide" || effect === "carousel"; + + if (isSlideOrCarousel) { + if (speed === 0.5) return ""; + if (speed === 1.0) return "Slow"; + if (speed === 0.25) return "Fast"; + if (speed === 2.0) return "Slow"; + } else { + if (speed === 1.0) return ""; + if (speed === 2.0) return "Slow"; + if (speed === 0.5) return "Fast"; + if (speed === 0.25) return "Fast"; + } + return ""; + } + + private buildTransitionValue(effect: string, direction: string, speed: number): string { + if (!effect) return ""; + + const speedSuffix = this.speedToSuffix(speed, effect); + + if (!this.needsDirection(effect)) { + return effect + speedSuffix; + } + + return effect + direction + speedSuffix; + } + + private suffixToSpeed(suffix: string, effect: string): number { + const isSlideOrCarousel = effect === "slide" || effect === "carousel"; + + if (isSlideOrCarousel) { + if (suffix === "") return 0.5; + if (suffix === "Slow") return 1.0; + if (suffix === "Fast") return 0.25; + } else { + if (suffix === "") return 1.0; + if (suffix === "Slow") return 2.0; + if (suffix === "Fast") return 0.5; + } + return 1.0; + } + + private parseTransitionValue(value: string): { effect: string; direction: string; speed: number } { + if (!value) return { effect: "", direction: "", speed: 1.0 }; + + let speedSuffix = ""; + let base = value; + if (value.endsWith("Fast")) { + speedSuffix = "Fast"; + base = value.slice(0, -4); + } else if (value.endsWith("Slow")) { + speedSuffix = "Slow"; + base = value.slice(0, -4); + } + + const directions = ["Left", "Right", "Up", "Down"]; + for (const dir of directions) { + if (base.endsWith(dir)) { + const effect = base.slice(0, -dir.length); + const speed = this.suffixToSpeed(speedSuffix, effect); + return { effect, direction: dir, speed }; + } + } + + const speed = this.suffixToSpeed(speedSuffix, base); + return { effect: base, direction: "", speed }; + } + + private applyTransitionUpdate(): void { + const transitionIn = this.buildTransitionValue(this.transitionInEffect, this.transitionInDirection, this.transitionInSpeed); + const transitionOut = this.buildTransitionValue(this.transitionOutEffect, this.transitionOutDirection, this.transitionOutSpeed); + + const transition: { in?: string; out?: string } = {}; + if (transitionIn) transition.in = transitionIn; + if (transitionOut) transition.out = transitionOut; + + if (!transitionIn && !transitionOut) { + this.applyClipUpdate({ transition: undefined }); + } else { + this.applyClipUpdate({ transition }); + } + } + + private updateTransitionUI(): void { + const tab = this.activeTransitionTab; + const effect = tab === "in" ? this.transitionInEffect : this.transitionOutEffect; + const direction = tab === "in" ? this.transitionInDirection : this.transitionOutDirection; + const speed = tab === "in" ? this.transitionInSpeed : this.transitionOutSpeed; + + // Update tab active states + this.transitionPopup?.querySelectorAll("[data-tab]").forEach(el => { + const tabEl = el as HTMLElement; + tabEl.classList.toggle("active", tabEl.dataset["tab"] === tab); + }); + + // Update effect active states + this.transitionPopup?.querySelectorAll("[data-effect]").forEach(el => { + const effectEl = el as HTMLElement; + effectEl.classList.toggle("active", effectEl.dataset["effect"] === effect); + }); + + // Update direction visibility and active states + const showDirection = this.needsDirection(effect); + this.directionRow?.classList.toggle("visible", showDirection); + + this.transitionPopup?.querySelectorAll("[data-dir]").forEach(el => { + const dirEl = el as HTMLElement; + const dir = dirEl.dataset["dir"] || ""; + const isVertical = dir === "Up" || dir === "Down"; + dirEl.classList.toggle("hidden", effect === "wipe" && isVertical); + dirEl.classList.toggle("active", dir === direction); + }); + + // Update speed display + if (this.speedValueLabel) { + this.speedValueLabel.textContent = `${speed.toFixed(2)}s`; + } + + // Update stepper button disabled states + const speedIdx = this.SPEED_VALUES.indexOf(speed); + const decreaseBtn = this.transitionPopup?.querySelector("[data-speed-decrease]") as HTMLButtonElement | null; + const increaseBtn = this.transitionPopup?.querySelector("[data-speed-increase]") as HTMLButtonElement | null; + if (decreaseBtn) decreaseBtn.disabled = speedIdx <= 0; + if (increaseBtn) increaseBtn.disabled = speedIdx >= this.SPEED_VALUES.length - 1; + } + + // ==================== Effect Handlers ==================== + + private handleEffectTypeSelect(effectType: "" | "zoom" | "slide"): void { + this.effectType = effectType; + this.updateEffectUI(); + this.applyEffect(); + } + + private handleEffectVariantSelect(variant: "In" | "Out"): void { + this.effectVariant = variant; + this.updateEffectUI(); + this.applyEffect(); + } + + private handleEffectDirectionSelect(direction: "Left" | "Right" | "Up" | "Down"): void { + this.effectDirection = direction; + this.updateEffectUI(); + this.applyEffect(); + } + + private handleEffectSpeedStep(direction: number): void { + const currentIndex = this.EFFECT_SPEED_VALUES.indexOf(this.effectSpeed); + const newIndex = Math.max(0, Math.min(this.EFFECT_SPEED_VALUES.length - 1, currentIndex + direction)); + this.effectSpeed = this.EFFECT_SPEED_VALUES[newIndex]; + this.updateEffectUI(); + this.applyEffect(); + } + + private updateEffectUI(): void { + // Update active state on effect type buttons + this.effectPopup?.querySelectorAll("[data-effect-type]").forEach(btn => { + const type = (btn as HTMLElement).dataset["effectType"] || ""; + btn.classList.toggle("active", type === this.effectType); + }); + + // Show/hide variant row (for Zoom) + this.effectVariantRow?.classList.toggle("visible", this.effectType === "zoom"); + + // Update variant active states + this.effectPopup?.querySelectorAll("[data-variant]").forEach(btn => { + const variant = (btn as HTMLElement).dataset["variant"] || ""; + btn.classList.toggle("active", variant === this.effectVariant); + }); + + // Show/hide direction row (for Slide) + this.effectDirectionRow?.classList.toggle("visible", this.effectType === "slide"); + + // Update direction active states + this.effectPopup?.querySelectorAll("[data-effect-dir]").forEach(btn => { + const dir = (btn as HTMLElement).dataset["effectDir"] || ""; + btn.classList.toggle("active", dir === this.effectDirection); + }); + + // Show/hide speed row (when effect is selected) + this.effectSpeedRow?.classList.toggle("visible", this.effectType !== ""); + + // Update speed display + if (this.effectSpeedValueLabel) { + this.effectSpeedValueLabel.textContent = `${this.effectSpeed}s`; + } + + // Update stepper button disabled states + const speedIdx = this.EFFECT_SPEED_VALUES.indexOf(this.effectSpeed); + const decreaseBtn = this.effectPopup?.querySelector("[data-effect-speed-decrease]") as HTMLButtonElement | null; + const increaseBtn = this.effectPopup?.querySelector("[data-effect-speed-increase]") as HTMLButtonElement | null; + if (decreaseBtn) decreaseBtn.disabled = speedIdx <= 0; + if (increaseBtn) increaseBtn.disabled = speedIdx >= this.EFFECT_SPEED_VALUES.length - 1; + } + + private buildEffectValue(): string { + if (this.effectType === "") return ""; + + let value = ""; + if (this.effectType === "zoom") { + value = `zoom${this.effectVariant}`; + } else if (this.effectType === "slide") { + value = `slide${this.effectDirection}`; + } + + if (this.effectSpeed === 0.5) value += "Fast"; + else if (this.effectSpeed === 2.0) value += "Slow"; + + return value; + } + + private applyEffect(): void { + const effectValue = this.buildEffectValue(); + if (!effectValue) { + this.applyClipUpdate({ effect: undefined }); + } else { + this.applyClipUpdate({ effect: effectValue }); + } + } + + private parseEffectValue(effect: string): void { + if (!effect) { + this.effectType = ""; + this.effectSpeed = 1.0; + return; + } + + let base = effect; + if (effect.endsWith("Slow")) { + this.effectSpeed = 2.0; + base = effect.slice(0, -4); + } else if (effect.endsWith("Fast")) { + this.effectSpeed = 0.5; + base = effect.slice(0, -4); + } else { + this.effectSpeed = 1.0; + } + + if (base.startsWith("zoom")) { + this.effectType = "zoom"; + this.effectVariant = base === "zoomOut" ? "Out" : "In"; + } else if (base.startsWith("slide")) { + this.effectType = "slide"; + const dir = base.replace("slide", ""); + this.effectDirection = (dir as "Left" | "Right" | "Up" | "Down") || "Right"; + } else { + this.effectType = ""; + } + } + + private applyClipUpdate(updates: Record): void { + if (this.selectedTrackIdx >= 0 && this.selectedClipIdx >= 0) { + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, updates); + } + } + // Sync UI with current clip state protected override syncState(): void { const asset = this.getCurrentAsset(); @@ -618,6 +1146,26 @@ export class TextToolbar extends BaseToolbar { if (this.strokeColorInput && asset.stroke?.color) { this.strokeColorInput.value = asset.stroke.color; } + + // Get clip for transition and effect values + const clip = this.edit.getClip(this.selectedTrackIdx, this.selectedClipIdx); + + // Transition + const transitionIn = this.parseTransitionValue((clip?.transition as { in?: string })?.in ?? ""); + const transitionOut = this.parseTransitionValue((clip?.transition as { out?: string })?.out ?? ""); + + this.transitionInEffect = transitionIn.effect; + this.transitionInDirection = transitionIn.direction; + this.transitionInSpeed = transitionIn.speed; + this.transitionOutEffect = transitionOut.effect; + this.transitionOutDirection = transitionOut.direction; + this.transitionOutSpeed = transitionOut.speed; + + this.updateTransitionUI(); + + // Effect + this.parseEffectValue((clip?.effect as string) ?? ""); + this.updateEffectUI(); } override dispose(): void { @@ -661,5 +1209,19 @@ export class TextToolbar extends BaseToolbar { this.strokeWidthSlider = null; this.strokeWidthValue = null; this.strokeColorInput = null; + + // Transition elements + this.transitionBtn = null; + this.transitionPopup = null; + this.directionRow = null; + this.speedValueLabel = null; + + // Effect elements + this.effectBtn = null; + this.effectPopup = null; + this.effectVariantRow = null; + this.effectDirectionRow = null; + this.effectSpeedRow = null; + this.effectSpeedValueLabel = null; } } From 2084f99b78aaf21ac2e6b33ca40813401b0e11b8 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Sun, 14 Dec 2025 22:06:59 +1100 Subject: [PATCH 116/463] refactor: simplify track container movement and enable z-index sorting --- src/core/commands/move-clip-command.ts | 7 ++----- src/core/edit.ts | 3 +++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/commands/move-clip-command.ts b/src/core/commands/move-clip-command.ts index 57a564c8..b8654b69 100644 --- a/src/core/commands/move-clip-command.ts +++ b/src/core/commands/move-clip-command.ts @@ -139,11 +139,8 @@ export class MoveClipCommand implements EditCommand { context.untrackEndLengthClip(this.player); } - // Move the player container to the new track container if needed - // Skip if source track was deleted - the container move is handled by DeleteTrackCommand - if (!this.sourceTrackWasDeleted) { - context.movePlayerToTrackContainer(this.player, this.fromTrackIndex, this.toTrackIndex); - } + // Move the player container to the new track container + context.movePlayerToTrackContainer(this.player, this.fromTrackIndex, this.toTrackIndex); // Reconfigure and redraw the player this.player.reconfigureAfterRestore(); diff --git a/src/core/edit.ts b/src/core/edit.ts index 236d90e1..062f1516 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -127,6 +127,9 @@ export class Edit extends Entity { } public override async load(): Promise { + // Enable z-index sorting so track containers render in correct layer order + this.getContainer().sortableChildren = true; + const background = new pixi.Graphics(); this.background = background; background.fillStyle = { From 9d6d945db96244bd843e815b9eadb1e04d140313 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 10:22:20 +1100 Subject: [PATCH 117/463] fix: remove redundant positioning overrides from variables popup, inherit from base popup styles --- src/core/ui/canvas-toolbar.css.ts | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/core/ui/canvas-toolbar.css.ts b/src/core/ui/canvas-toolbar.css.ts index 0494cd3d..e264f760 100644 --- a/src/core/ui/canvas-toolbar.css.ts +++ b/src/core/ui/canvas-toolbar.css.ts @@ -319,13 +319,10 @@ export const CANVAS_TOOLBAR_STYLES = ` Rebuilt with proper scroll architecture =========================================== */ -/* Container - Override transform positioning to fix scroll */ +/* Variables popup - sizing and layout only, inherits positioning from base popup */ .ss-canvas-toolbar-popup--variables { width: 300px; max-height: 400px; - /* Override transform-based centering - use top offset instead */ - transform: none; - top: calc(50% - 200px); flex-direction: column; overflow: visible; } @@ -335,12 +332,6 @@ export const CANVAS_TOOLBAR_STYLES = ` display: flex; } -/* Reposition the arrow for non-transform positioning */ -.ss-canvas-toolbar-popup--variables::after { - top: 200px; - transform: rotate(45deg); -} - /* Header - fixed, never scrolls */ .ss-variables-header { flex-shrink: 0; From a3ff60dbf19427b0516b6a50932c80e355356e54 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 10:29:26 +1100 Subject: [PATCH 118/463] fix: use regex substitution for merge field replacement instead of direct assignment --- src/core/edit.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/core/edit.ts b/src/core/edit.ts index 062f1516..f892dbaa 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1767,7 +1767,11 @@ export class Edit extends Entity { if (typeof templateVal === "string") { const extractedField = this.mergeFields.extractFieldName(templateVal); if (extractedField === fieldName) { - targetObj[key] = newValue; + // Apply proper substitution - replace {{ FIELD }} with newValue, preserving surrounding text + targetObj[key] = templateVal.replace( + new RegExp(`\\{\\{\\s*${fieldName}\\s*\\}\\}`, "gi"), + newValue + ); } } else if (templateVal && typeof templateVal === "object") { this.updateMergeFieldInObject(targetObj[key], templateVal, fieldName, newValue); From 72221dd5eedb8b0d721d8f72f20010b063b46706 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 10:41:18 +1100 Subject: [PATCH 119/463] feat: improve merge field restoration with partial template matching and substitution --- src/core/edit.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/core/edit.ts b/src/core/edit.ts index f892dbaa..0b3bc75d 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1898,9 +1898,17 @@ export class Edit extends Entity { const value = (templateClip as Record)[key]; const propertyPath = path ? `${path}.${key}` : key; - if (typeof value === "string" && value === template) { - // Found a property using this merge field - use removeMergeField for proper handling - this.removeMergeField(trackIdx, clipIdx, propertyPath, restoreValue); + if (typeof value === "string") { + const extractedField = this.mergeFields.extractFieldName(value); + const templateFieldName = this.mergeFields.extractFieldName(template); + if (extractedField && templateFieldName && extractedField === templateFieldName) { + // Apply proper substitution - replace {{ FIELD }} with restoreValue, preserving surrounding text + const substitutedValue = value.replace( + new RegExp(`\\{\\{\\s*${extractedField}\\s*\\}\\}`, "gi"), + restoreValue + ); + this.removeMergeField(trackIdx, clipIdx, propertyPath, substitutedValue); + } } else if (typeof value === "object" && value !== null) { // Recurse into nested objects this.restoreMergeFieldInClip(trackIdx, clipIdx, value, template, restoreValue, propertyPath); From b107bb10986b2e41bd4d4e3a429350ec1b96af6e Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 11:25:15 +1100 Subject: [PATCH 120/463] style: restructure font button styles and markup --- src/core/ui/rich-text-toolbar.css.ts | 18 +++++++++++++++--- src/core/ui/rich-text-toolbar.ts | 6 +++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/src/core/ui/rich-text-toolbar.css.ts b/src/core/ui/rich-text-toolbar.css.ts index b8ca298c..655ad913 100644 --- a/src/core/ui/rich-text-toolbar.css.ts +++ b/src/core/ui/rich-text-toolbar.css.ts @@ -124,9 +124,21 @@ export const TOOLBAR_STYLES = ` .ss-toolbar-anchor-btn:hover { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.9); } .ss-toolbar-anchor-btn.active { background: rgba(255, 255, 255, 0.18); border-color: rgba(255, 255, 255, 0.2); color: #fff; } -.ss-toolbar-btn--font { width: auto; min-width: 48px; padding: 0 8px; gap: 4px; } -.ss-toolbar-font-preview { font-size: 13px; font-weight: 500; letter-spacing: -0.01em; max-width: 80px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.ss-toolbar-chevron { opacity: 0.5; flex-shrink: 0; } +.ss-toolbar-font-btn { + display: flex; + align-items: center; + height: 32px; + padding: 0 8px; + gap: 4px; + background: transparent; + border: none; + border-radius: 6px; + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + font-size: 13px; + font-weight: 500; +} +.ss-toolbar-font-btn:hover { background: rgba(255, 255, 255, 0.1); color: rgba(255, 255, 255, 0.95); } .ss-toolbar-popup--font { min-width: 220px; max-height: 340px; overflow-y: auto; padding: 8px; scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.2) transparent; } .ss-toolbar-popup--font::-webkit-scrollbar { width: 6px; } diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 178320db..ecfe819f 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -156,9 +156,9 @@ export class RichTextToolbar extends BaseToolbar {
- From eb36622840837591b83724bac00d6a28f8e4a04a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 11:44:15 +1100 Subject: [PATCH 121/463] feat: implement alignment guides for drag snapping --- src/components/canvas/players/player.ts | 138 ++++++++++++++---- .../canvas/system/alignment-guides.ts | 86 +++++++++++ src/core/edit.ts | 47 ++++++ 3 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 src/components/canvas/system/alignment-guides.ts diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 3c394ecb..e70d34cc 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -1041,54 +1041,133 @@ export abstract class Player extends Entity { const cursorPosition: Vector = { x: timelinePoint.x - this.dragOffset.x, y: timelinePoint.y - this.dragOffset.y }; const updatedPosition: Vector = { x: cursorPosition.x - pivot.x, y: cursorPosition.y - pivot.y }; - const timelineCorners = [ - { x: 0, y: 0 }, - { x: this.edit.size.width, y: 0 }, - { x: 0, y: this.edit.size.height }, - { x: this.edit.size.width, y: this.edit.size.height } - ]; - const timelineCenter = { x: this.edit.size.width / 2, y: this.edit.size.height / 2 }; - const timelineSnapPositions: Vector[] = [...timelineCorners, timelineCenter]; - - const clipCorners = [ - { x: updatedPosition.x, y: updatedPosition.y }, - { x: updatedPosition.x + this.getSize().width, y: updatedPosition.y }, - { x: updatedPosition.x, y: updatedPosition.y + this.getSize().height }, - { x: updatedPosition.x + this.getSize().width, y: updatedPosition.y + this.getSize().height } - ]; - const clipCenter = { x: updatedPosition.x + this.getSize().width / 2, y: updatedPosition.y + this.getSize().height / 2 }; - const clipSnapPositions: Vector[] = [...clipCorners, clipCenter]; + // Clear guides before drawing new ones + this.edit.clearAlignmentGuides(); + + // Canvas snap positions (corners + center + edges) + const canvasSnapPositionsX = [0, this.edit.size.width / 2, this.edit.size.width]; + const canvasSnapPositionsY = [0, this.edit.size.height / 2, this.edit.size.height]; + + // Current clip snap positions (corners + center + edges) + const mySize = this.getSize(); + const myLeft = updatedPosition.x; + const myRight = updatedPosition.x + mySize.width; + const myCenterX = updatedPosition.x + mySize.width / 2; + const myTop = updatedPosition.y; + const myBottom = updatedPosition.y + mySize.height; + const myCenterY = updatedPosition.y + mySize.height / 2; + + const clipSnapPositionsX = [myLeft, myCenterX, myRight]; + const clipSnapPositionsY = [myTop, myCenterY, myBottom]; let closestDistanceX = Player.SnapThreshold; let closestDistanceY = Player.SnapThreshold; - let snapPositionX: number | null = null; let snapPositionY: number | null = null; + let snapTypeX: "canvas" | "clip" | null = null; + let snapTypeY: "canvas" | "clip" | null = null; + let snapTargetX: number | null = null; + let snapTargetY: number | null = null; + let clipBoundsX: { start: number; end: number } | null = null; + let clipBoundsY: { start: number; end: number } | null = null; + + // Check canvas snapping + for (const clipX of clipSnapPositionsX) { + for (const canvasX of canvasSnapPositionsX) { + const distance = Math.abs(clipX - canvasX); + if (distance < closestDistanceX) { + closestDistanceX = distance; + snapPositionX = updatedPosition.x + (canvasX - clipX); + snapTypeX = "canvas"; + snapTargetX = canvasX; + } + } + } + + for (const clipY of clipSnapPositionsY) { + for (const canvasY of canvasSnapPositionsY) { + const distance = Math.abs(clipY - canvasY); + if (distance < closestDistanceY) { + closestDistanceY = distance; + snapPositionY = updatedPosition.y + (canvasY - clipY); + snapTypeY = "canvas"; + snapTargetY = canvasY; + } + } + } - for (const clipSnapPosition of clipSnapPositions) { - for (const timelineSnapPosition of timelineSnapPositions) { - const distanceX = Math.abs(clipSnapPosition.x - timelineSnapPosition.x); - if (distanceX < closestDistanceX) { - closestDistanceX = distanceX; - snapPositionX = updatedPosition.x + (timelineSnapPosition.x - clipSnapPosition.x); + // Check clip-to-clip snapping + const otherPlayers = this.edit.getActivePlayersExcept(this); + for (const other of otherPlayers) { + const otherPos = other.getContainer().position; + const otherSize = other.getSize(); + const otherLeft = otherPos.x; + const otherRight = otherPos.x + otherSize.width; + const otherCenterX = otherPos.x + otherSize.width / 2; + const otherTop = otherPos.y; + const otherBottom = otherPos.y + otherSize.height; + const otherCenterY = otherPos.y + otherSize.height / 2; + + const otherSnapX = [otherLeft, otherCenterX, otherRight]; + const otherSnapY = [otherTop, otherCenterY, otherBottom]; + + for (const clipX of clipSnapPositionsX) { + for (const targetX of otherSnapX) { + const distance = Math.abs(clipX - targetX); + if (distance < closestDistanceX) { + closestDistanceX = distance; + snapPositionX = updatedPosition.x + (targetX - clipX); + snapTypeX = "clip"; + snapTargetX = targetX; + // Bounds for the dotted line: from top of higher clip to bottom of lower + const minY = Math.min(myTop, otherTop); + const maxY = Math.max(myBottom, otherBottom); + clipBoundsX = { start: minY, end: maxY }; + } } + } - const distanceY = Math.abs(clipSnapPosition.y - timelineSnapPosition.y); - if (distanceY < closestDistanceY) { - closestDistanceY = distanceY; - snapPositionY = updatedPosition.y + (timelineSnapPosition.y - clipSnapPosition.y); + for (const clipY of clipSnapPositionsY) { + for (const targetY of otherSnapY) { + const distance = Math.abs(clipY - targetY); + if (distance < closestDistanceY) { + closestDistanceY = distance; + snapPositionY = updatedPosition.y + (targetY - clipY); + snapTypeY = "clip"; + snapTargetY = targetY; + // Bounds for the dotted line: from left of leftmost clip to right of rightmost + const minX = Math.min(myLeft, otherLeft); + const maxX = Math.max(myRight, otherRight); + clipBoundsY = { start: minX, end: maxX }; + } } } } + // Apply snaps if (snapPositionX !== null) { updatedPosition.x = snapPositionX; } - if (snapPositionY !== null) { updatedPosition.y = snapPositionY; } + // Draw alignment guides for active snaps + if (snapTypeX !== null && snapTargetX !== null) { + if (snapTypeX === "canvas") { + this.edit.showAlignmentGuide("canvas", "x", snapTargetX); + } else if (clipBoundsX) { + this.edit.showAlignmentGuide("clip", "x", snapTargetX, clipBoundsX); + } + } + if (snapTypeY !== null && snapTargetY !== null) { + if (snapTypeY === "canvas") { + this.edit.showAlignmentGuide("canvas", "y", snapTargetY); + } else if (clipBoundsY) { + this.edit.showAlignmentGuide("clip", "y", snapTargetY, clipBoundsY); + } + } + const updatedRelativePosition = this.positionBuilder.absoluteToRelative( this.getSize(), this.clipConfiguration.position ?? "center", @@ -1149,6 +1228,7 @@ export abstract class Player extends Entity { this.isDragging = false; this.dragOffset = { x: 0, y: 0 }; + this.edit.clearAlignmentGuides(); this.scaleDirection = null; diff --git a/src/components/canvas/system/alignment-guides.ts b/src/components/canvas/system/alignment-guides.ts new file mode 100644 index 00000000..0adc2445 --- /dev/null +++ b/src/components/canvas/system/alignment-guides.ts @@ -0,0 +1,86 @@ +import * as pixi from "pixi.js"; + +const GUIDE_COLOR = 0xff00ff; // Bright magenta +const GUIDE_WIDTH = 2; +const DASH_LENGTH = 6; +const GAP_LENGTH = 4; + +export class AlignmentGuides { + private graphics: pixi.Graphics; + private canvasWidth: number; + private canvasHeight: number; + + constructor(container: pixi.Container, canvasWidth: number, canvasHeight: number) { + this.canvasWidth = canvasWidth; + this.canvasHeight = canvasHeight; + + this.graphics = new pixi.Graphics(); + this.graphics.zIndex = 999999; // Above everything + container.addChild(this.graphics); + } + + clear(): void { + this.graphics.clear(); + } + + /** + * Draw a solid guide line for canvas alignment (extends full canvas width/height) + */ + drawCanvasGuide(axis: "x" | "y", position: number): void { + this.graphics.strokeStyle = { width: GUIDE_WIDTH, color: GUIDE_COLOR }; + + if (axis === "x") { + // Vertical line at x position + this.graphics.moveTo(position, 0); + this.graphics.lineTo(position, this.canvasHeight); + } else { + // Horizontal line at y position + this.graphics.moveTo(0, position); + this.graphics.lineTo(this.canvasWidth, position); + } + this.graphics.stroke(); + } + + /** + * Draw a dotted guide line for clip-to-clip alignment (bounded to clip area) + */ + drawClipGuide(axis: "x" | "y", position: number, start: number, end: number): void { + if (axis === "x") { + // Vertical dotted line + this.drawDashedLine(position, start, position, end); + } else { + // Horizontal dotted line + this.drawDashedLine(start, position, end, position); + } + } + + private drawDashedLine(x1: number, y1: number, x2: number, y2: number): void { + const dx = x2 - x1; + const dy = y2 - y1; + const length = Math.sqrt(dx * dx + dy * dy); + const dashCount = Math.floor(length / (DASH_LENGTH + GAP_LENGTH)); + + const unitX = dx / length; + const unitY = dy / length; + + this.graphics.strokeStyle = { width: GUIDE_WIDTH, color: GUIDE_COLOR }; + + for (let i = 0; i < dashCount; i += 1) { + const startOffset = i * (DASH_LENGTH + GAP_LENGTH); + const endOffset = startOffset + DASH_LENGTH; + + const startX = x1 + unitX * startOffset; + const startY = y1 + unitY * startOffset; + const endX = x1 + unitX * Math.min(endOffset, length); + const endY = y1 + unitY * Math.min(endOffset, length); + + this.graphics.moveTo(startX, startY); + this.graphics.lineTo(endX, endY); + } + this.graphics.stroke(); + } + + dispose(): void { + this.graphics.destroy(); + } +} diff --git a/src/core/edit.ts b/src/core/edit.ts index 0b3bc75d..6175b5da 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1,4 +1,5 @@ import { AudioPlayer } from "@canvas/players/audio-player"; +import { AlignmentGuides } from "@canvas/system/alignment-guides"; import { CaptionPlayer } from "@canvas/players/caption-player"; import { HtmlPlayer } from "@canvas/players/html-player"; import { ImagePlayer } from "@canvas/players/image-player"; @@ -87,6 +88,9 @@ export class Edit extends Entity { public mergeFields: MergeFieldService; private canvas: Canvas | null = null; + + /** @internal */ + private alignmentGuides: AlignmentGuides | null = null; private activeLumaMasks: Array<{ lumaPlayer: LumaPlayer; maskSprite: pixi.Sprite; @@ -147,6 +151,9 @@ export class Edit extends Entity { this.viewportMask.fill(0xffffff); this.getContainer().addChild(this.viewportMask); this.getContainer().setMask({ mask: this.viewportMask }); + + // Initialize alignment guides (rendered above all clips) + this.alignmentGuides = new AlignmentGuides(this.getContainer(), this.size.width, this.size.height); } /** @internal */ @@ -1507,6 +1514,46 @@ export class Edit extends Entity { if (this.isExporting) return false; return this.selectedClip === player; } + + /** + * Get all active players except the specified one. + * Used for clip-to-clip alignment snapping. + * @internal + */ + public getActivePlayersExcept(excludePlayer: Player): Player[] { + const active: Player[] = []; + for (const track of this.tracks) { + for (const player of track) { + if (player !== excludePlayer && player.isActive()) { + active.push(player); + } + } + } + return active; + } + + /** + * Show an alignment guide line. + * @internal + */ + public showAlignmentGuide(type: "canvas" | "clip", axis: "x" | "y", position: number, bounds?: { start: number; end: number }): void { + if (!this.alignmentGuides) return; + + if (type === "canvas") { + this.alignmentGuides.drawCanvasGuide(axis, position); + } else if (bounds) { + this.alignmentGuides.drawClipGuide(axis, position, bounds.start, bounds.end); + } + } + + /** + * Clear all alignment guides. + * @internal + */ + public clearAlignmentGuides(): void { + this.alignmentGuides?.clear(); + } + public setExportMode(exporting: boolean): void { this.isExporting = exporting; } From 29fbdae69f9519e26086271b6d834f93ef099fff Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 11:48:00 +1100 Subject: [PATCH 122/463] feat: add rotation snapping to fixed angles during player rotation --- src/components/canvas/players/player.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index e70d34cc..e2f0e354 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -35,6 +35,8 @@ export enum PlayerType { export abstract class Player extends Entity { private static readonly SnapThreshold = 20; + private static readonly RotationSnapThreshold = 5; // degrees + private static readonly RotationSnapAngles = [0, 45, 90, 135, 180, 225, 270, 315]; private static readonly DiscardedFrameCount = 0; @@ -1017,7 +1019,19 @@ export abstract class Player extends Entity { const currentAngle = Math.atan2(event.globalY - center.y, event.globalX - center.x); const deltaAngle = (currentAngle - this.rotationStart) * (180 / Math.PI); - const newRotation = this.initialRotation + deltaAngle; + let newRotation = this.initialRotation + deltaAngle; + + // Snap to fixed angles + const normalizedRotation = ((newRotation % 360) + 360) % 360; + for (const snapAngle of Player.RotationSnapAngles) { + const distance = Math.abs(normalizedRotation - snapAngle); + const wrappedDistance = Math.min(distance, 360 - distance); + if (wrappedDistance < Player.RotationSnapThreshold) { + const fullRotations = Math.round(newRotation / 360) * 360; + newRotation = fullRotations + snapAngle; + break; + } + } this.clipConfiguration.transform = { ...this.clipConfiguration.transform, From edfdff10b4117482199fa4510cda1754e3260c48 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 11:59:45 +1100 Subject: [PATCH 123/463] feat: add keyboard arrow key positioning for selected clips --- src/components/canvas/players/player.ts | 24 +++++++++++++++++++ src/core/edit.ts | 16 +++++++++++++ src/core/inputs/controls.ts | 32 +++++++++++++++++++++++-- 3 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index e2f0e354..68e96954 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -580,6 +580,30 @@ export abstract class Player extends Entity { return { x: size.width / 2, y: size.height / 2 }; } + /** + * Move the clip by a pixel delta. Used for keyboard arrow key positioning. + * @internal + */ + public moveBy(deltaX: number, deltaY: number): void { + const currentPos = this.getPosition(); + const newAbsolutePos = { x: currentPos.x + deltaX, y: currentPos.y + deltaY }; + + const relativePos = this.positionBuilder.absoluteToRelative( + this.getSize(), + this.clipConfiguration.position ?? "center", + newAbsolutePos + ); + + if (!this.clipConfiguration.offset) { + this.clipConfiguration.offset = { x: 0, y: 0 }; + } + this.clipConfiguration.offset.x = relativePos.x; + this.clipConfiguration.offset.y = relativePos.y; + + this.offsetXKeyframeBuilder = new KeyframeBuilder(relativePos.x, this.getLength()); + this.offsetYKeyframeBuilder = new KeyframeBuilder(relativePos.y, this.getLength()); + } + protected getFitScale(): number { const targetWidth = this.clipConfiguration.width ?? this.edit.size.width; const targetHeight = this.clipConfiguration.height ?? this.edit.size.height; diff --git a/src/core/edit.ts b/src/core/edit.ts index 6175b5da..d11ca2be 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1554,6 +1554,22 @@ export class Edit extends Entity { this.alignmentGuides?.clear(); } + /** + * Move the selected clip by a pixel delta. + * Used for keyboard arrow key positioning. + */ + public moveSelectedClip(deltaX: number, deltaY: number): void { + const info = this.getSelectedClipInfo(); + if (!info) return; + + const { player } = info; + const initialConfig = structuredClone(player.clipConfiguration); + + player.moveBy(deltaX, deltaY); + + this.setUpdatedClip(player, initialConfig, structuredClone(player.clipConfiguration)); + } + public setExportMode(exporting: boolean): void { this.isExporting = exporting; } diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index 59e585b4..722d71bd 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -37,7 +37,12 @@ export class Controls { break; } case "ArrowLeft": { - if (event.metaKey) { + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(-delta, 0); + } else if (event.metaKey) { this.edit.seek(0); } else { const seekAmount = event.shiftKey ? this.seekDistanceLarge : this.seekDistance; @@ -46,7 +51,12 @@ export class Controls { break; } case "ArrowRight": { - if (event.metaKey) { + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(delta, 0); + } else if (event.metaKey) { this.edit.seek(this.edit.getTotalDuration()); } else { const seekAmount = event.shiftKey ? this.seekDistanceLarge : this.seekDistance; @@ -54,6 +64,24 @@ export class Controls { } break; } + case "ArrowUp": { + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(0, -delta); + } + break; + } + case "ArrowDown": { + const selected = this.edit.getSelectedClipInfo(); + if (selected) { + event.preventDefault(); + const delta = event.shiftKey ? 10 : 1; + this.edit.moveSelectedClip(0, delta); + } + break; + } case "KeyJ": { this.edit.stop(); break; From 32641bd988f56055b98fa7bee0469d8626b304a7 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 13:35:37 +1100 Subject: [PATCH 124/463] fix: remove inspector preload and make async operations awaitable --- src/components/canvas/shotstack-canvas.ts | 1 - src/core/edit.ts | 12 +++++++----- src/core/ui/loading-overlay.ts | 23 ++++------------------- 3 files changed, 11 insertions(+), 25 deletions(-) diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index ce17d271..98fd33d6 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -80,7 +80,6 @@ export class Canvas { this.background.fill(); await this.configureApplication(); - await this.inspector.load(); await this.transcriptionIndicator.load(); this.configureStage(); this.setupTouchHandling(root); diff --git a/src/core/edit.ts b/src/core/edit.ts index d11ca2be..d0232038 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -403,9 +403,9 @@ export class Edit extends Entity { return this.edit; } - public addClip(trackIdx: number, clip: ResolvedClip): void { + public addClip(trackIdx: number, clip: ResolvedClip): void | Promise { const command = new AddClipCommand(trackIdx, clip); - this.executeCommand(command); + return this.executeCommand(command); } public getClip(trackIdx: number, clipIdx: number): ResolvedClip | null { const clipsByTrack = this.clips.filter((clip: Player) => clip.layer === trackIdx + 1); @@ -466,10 +466,12 @@ export class Edit extends Entity { this.executeCommand(command); } - public addTrack(trackIdx: number, track: ResolvedTrack): void { + public async addTrack(trackIdx: number, track: ResolvedTrack): Promise { const command = new AddTrackCommand(trackIdx); - this.executeCommand(command); - track?.clips?.forEach(clip => this.addClip(trackIdx, clip)); + await this.executeCommand(command); + for (const clip of track?.clips ?? []) { + await this.addClip(trackIdx, clip); + } } public getTrack(trackIdx: number): ResolvedTrack | null { const trackClips = this.clips.filter((clip: Player) => clip.layer === trackIdx + 1); diff --git a/src/core/ui/loading-overlay.ts b/src/core/ui/loading-overlay.ts index 2aea3b17..6b39a3dc 100644 --- a/src/core/ui/loading-overlay.ts +++ b/src/core/ui/loading-overlay.ts @@ -1,37 +1,22 @@ export class LoadingOverlay { private overlay: HTMLElement | null = null; - private bar: HTMLElement | null = null; - private pct: HTMLElement | null = null; show(): void { this.overlay = document.createElement("div"); this.overlay.style.cssText = "position:fixed;inset:0;z-index:9999;background:#0a0a0a;display:flex;justify-content:center;align-items:center"; this.overlay.innerHTML = ` -
-
- LOADING - 0% -
-
-
-
-
+
+ `; - this.bar = this.overlay.querySelector("#bar") as HTMLElement; - this.pct = this.overlay.querySelector("#pct") as HTMLElement; document.body.appendChild(this.overlay); } - update(progress: number): void { - const percent = Math.round(progress * 100); - if (this.bar) this.bar.style.width = `${percent}%`; - if (this.pct) this.pct.textContent = `${percent}%`; + update(_progress: number): void { + // No-op for spinner } hide(): void { this.overlay?.remove(); this.overlay = null; - this.bar = null; - this.pct = null; } } From 16f3c1246a88ac97825df36c2b660ecbfb6266c3 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 13:44:47 +1100 Subject: [PATCH 125/463] feat: show drag time tooltip when resizing clips --- .../timeline-html/interaction/interaction-controller.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index ca03e8bb..6c7eb9ec 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -391,6 +391,7 @@ export class InteractionController { clipEl.style.setProperty("--clip-start", String(newStart)); clipEl.style.setProperty("--clip-length", String(newLength)); } + this.showDragTimeTooltip(newStart + newLength, e.clientX - rect.left, e.clientY - rect.top); } else { // Resize from right edge const newLength = Math.max(0.1, time - originalStart); @@ -401,6 +402,7 @@ export class InteractionController { if (clipEl) { clipEl.style.setProperty("--clip-length", String(newLength)); } + this.showDragTimeTooltip(originalStart + newLength, e.clientX - rect.left, e.clientY - rect.top); } } @@ -587,6 +589,7 @@ export class InteractionController { // Cleanup this.hideSnapLine(); + this.hideDragTimeTooltip(); this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); this.state = { type: "idle" }; } From 4f2a979555f578f615bb9dcd582c3c4f6743d12a Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 14:32:26 +1100 Subject: [PATCH 126/463] fix: handle left-edge clip resize with start position and move command --- .../interaction/interaction-controller.ts | 79 ++++++++++++------- 1 file changed, 52 insertions(+), 27 deletions(-) diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts index 6c7eb9ec..24151563 100644 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ b/src/components/timeline-html/interaction/interaction-controller.ts @@ -379,11 +379,11 @@ export class InteractionController { const { clipRef, edge, originalStart, originalLength } = this.state; if (edge === "left") { - // Resize from left edge - const newStart = Math.min(time, originalStart + originalLength - 0.1); - const newLength = originalStart + originalLength - newStart; + // Resize from left edge (keep end fixed, change start and length) + const originalEnd = originalStart + originalLength; + const newStart = Math.max(0, Math.min(time, originalEnd - 0.1)); + const newLength = originalEnd - newStart; - // Update clip visually (temporary state during resize) const clipEl = this.tracksContainer.querySelector( `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` ) as HTMLElement; @@ -391,7 +391,7 @@ export class InteractionController { clipEl.style.setProperty("--clip-start", String(newStart)); clipEl.style.setProperty("--clip-length", String(newLength)); } - this.showDragTimeTooltip(newStart + newLength, e.clientX - rect.left, e.clientY - rect.top); + this.showDragTimeTooltip(newStart, e.clientX - rect.left, e.clientY - rect.top); } else { // Resize from right edge const newLength = Math.max(0.1, time - originalStart); @@ -556,35 +556,60 @@ export class InteractionController { time = snappedTime; } - let newStart = originalStart; - let newLength = originalLength; + // Get attached luma Player reference BEFORE changes (stable across index changes) + const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); if (edge === "left") { - newStart = Math.min(time, originalStart + originalLength - 0.1); - newLength = originalStart + originalLength - newStart; - } else { - newLength = Math.max(0.1, time - originalStart); - } - - // Get attached luma Player reference BEFORE resize (stable across index changes) - const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); + // Resize from left edge (keep end fixed, change start and length) + const originalEnd = originalStart + originalLength; + const newStart = Math.max(0, Math.min(time, originalEnd - 0.1)); + const newLength = originalEnd - newStart; + + if (newStart !== originalStart || newLength !== originalLength) { + // Move clip to new start position + if (newStart !== originalStart) { + const moveCommand = new MoveClipCommand(clipRef.trackIndex, clipRef.clipIndex, clipRef.trackIndex, newStart); + this.edit.executeEditCommand(moveCommand); + } - // Execute resize command if dimensions changed - if (newLength !== originalLength) { - const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); - this.edit.executeEditCommand(command); + // Resize clip to new length + if (newLength !== originalLength) { + const resizeCommand = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); + this.edit.executeEditCommand(resizeCommand); + } - // Also resize attached luma to match - if (lumaPlayer) { - const lumaIndices = this.edit.findClipIndices(lumaPlayer); - if (lumaIndices) { - const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); - this.edit.executeEditCommand(lumaResizeCommand); + // Also update attached luma clip + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + if (newStart !== originalStart) { + const lumaMoveCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, lumaIndices.trackIndex, newStart); + this.edit.executeEditCommand(lumaMoveCommand); + } + if (newLength !== originalLength) { + const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); + this.edit.executeEditCommand(lumaResizeCommand); + } + } } } + } else { + // Resize from right edge (keep start fixed, change length) + const newLength = Math.max(0.1, time - originalStart); - // TODO: For left-edge resize (start changed), also need MoveClipCommand - // Currently ResizeClipCommand only handles length changes + if (newLength !== originalLength) { + const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); + this.edit.executeEditCommand(command); + + // Also resize attached luma to match + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); + this.edit.executeEditCommand(lumaResizeCommand); + } + } + } } // Cleanup From 6c4b6936a00995cd359f0d0c5a13832157318692 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 17:44:34 +1100 Subject: [PATCH 127/463] refactor: consolidate timeline implementations into single HTML/CSS-based component --- .../canvas/players/rich-text-player.ts | 43 +- src/components/canvas/shotstack-canvas.ts | 3 +- src/components/timeline-html/html-timeline.ts | 512 -------- src/components/timeline-html/index.ts | 26 - .../interaction/interaction-controller.ts | 931 --------------- .../components/clip/clip-component.ts | 2 +- .../components/playhead/playhead-component.ts | 0 .../components/ruler/ruler-component.ts | 0 .../components/toolbar/toolbar-component.ts | 0 .../components/track/track-component.ts | 4 +- .../components/track/track-list.ts | 4 +- src/components/timeline/constants.ts | 46 - .../core/state/timeline-state.ts | 2 +- .../core/timeline-entity.ts | 0 src/components/timeline/features/index.ts | 18 - .../timeline/features/playhead-feature.ts | 119 -- .../timeline/features/ruler-feature.ts | 207 ---- .../timeline/features/scroll-manager.ts | 233 ---- src/components/timeline/features/types.ts | 76 -- src/components/timeline/index.ts | 41 +- .../interaction/collision-detector.ts | 105 -- .../timeline/interaction/drag-handler.ts | 286 ----- src/components/timeline/interaction/index.ts | 22 - .../interaction/interaction-controller.ts | 1055 ++++++++++++++--- .../timeline/interaction/resize-handler.ts | 147 --- .../timeline/interaction/snap-manager.ts | 140 --- src/components/timeline/interaction/types.ts | 148 --- .../interaction/visual-feedback-manager.ts | 131 -- .../timeline/managers/drag-preview-manager.ts | 136 --- src/components/timeline/managers/index.ts | 18 - .../managers/selection-overlay-renderer.ts | 82 -- .../managers/timeline-event-handler.ts | 80 -- .../managers/timeline-feature-manager.ts | 170 --- .../managers/timeline-options-manager.ts | 134 --- .../timeline/managers/timeline-renderer.ts | 124 -- .../timeline/managers/viewport-manager.ts | 101 -- .../timeline/managers/visual-track-manager.ts | 173 --- .../styles/timeline.css.ts | 0 src/components/timeline/timeline-layout.ts | 203 ---- src/components/timeline/timeline-toolbar.ts | 158 --- src/components/timeline/timeline.ts | 790 ++++++------ .../timeline.types.ts} | 18 +- .../toolbar/components/edit-controls.ts | 143 --- .../toolbar/components/playback-controls.ts | 126 -- .../toolbar/components/time-display.ts | 102 -- .../toolbar/components/toolbar-button.ts | 193 --- src/components/timeline/toolbar/constants.ts | 65 - .../timeline/toolbar/icons/icon-factory.ts | 106 -- src/components/timeline/toolbar/index.ts | 9 - .../timeline/toolbar/toolbar-layout.ts | 65 - src/components/timeline/toolbar/types.ts | 88 -- src/components/timeline/types/assets.ts | 245 ---- src/components/timeline/types/index.ts | 3 - src/components/timeline/types/timeline.ts | 35 - src/components/timeline/visual/visual-clip.ts | 378 ------ .../timeline/visual/visual-track.ts | 304 ----- src/index.ts | 5 +- src/main.ts | 14 +- 58 files changed, 1363 insertions(+), 7006 deletions(-) delete mode 100644 src/components/timeline-html/html-timeline.ts delete mode 100644 src/components/timeline-html/index.ts delete mode 100644 src/components/timeline-html/interaction/interaction-controller.ts rename src/components/{timeline-html => timeline}/components/clip/clip-component.ts (99%) rename src/components/{timeline-html => timeline}/components/playhead/playhead-component.ts (100%) rename src/components/{timeline-html => timeline}/components/ruler/ruler-component.ts (100%) rename src/components/{timeline-html => timeline}/components/toolbar/toolbar-component.ts (100%) rename src/components/{timeline-html => timeline}/components/track/track-component.ts (98%) rename src/components/{timeline-html => timeline}/components/track/track-list.ts (98%) delete mode 100644 src/components/timeline/constants.ts rename src/components/{timeline-html => timeline}/core/state/timeline-state.ts (99%) rename src/components/{timeline-html => timeline}/core/timeline-entity.ts (100%) delete mode 100644 src/components/timeline/features/index.ts delete mode 100644 src/components/timeline/features/playhead-feature.ts delete mode 100644 src/components/timeline/features/ruler-feature.ts delete mode 100644 src/components/timeline/features/scroll-manager.ts delete mode 100644 src/components/timeline/features/types.ts delete mode 100644 src/components/timeline/interaction/collision-detector.ts delete mode 100644 src/components/timeline/interaction/drag-handler.ts delete mode 100644 src/components/timeline/interaction/index.ts delete mode 100644 src/components/timeline/interaction/resize-handler.ts delete mode 100644 src/components/timeline/interaction/snap-manager.ts delete mode 100644 src/components/timeline/interaction/types.ts delete mode 100644 src/components/timeline/interaction/visual-feedback-manager.ts delete mode 100644 src/components/timeline/managers/drag-preview-manager.ts delete mode 100644 src/components/timeline/managers/index.ts delete mode 100644 src/components/timeline/managers/selection-overlay-renderer.ts delete mode 100644 src/components/timeline/managers/timeline-event-handler.ts delete mode 100644 src/components/timeline/managers/timeline-feature-manager.ts delete mode 100644 src/components/timeline/managers/timeline-options-manager.ts delete mode 100644 src/components/timeline/managers/timeline-renderer.ts delete mode 100644 src/components/timeline/managers/viewport-manager.ts delete mode 100644 src/components/timeline/managers/visual-track-manager.ts rename src/components/{timeline-html => timeline}/styles/timeline.css.ts (100%) delete mode 100644 src/components/timeline/timeline-layout.ts delete mode 100644 src/components/timeline/timeline-toolbar.ts rename src/components/{timeline-html/html-timeline.types.ts => timeline/timeline.types.ts} (88%) delete mode 100644 src/components/timeline/toolbar/components/edit-controls.ts delete mode 100644 src/components/timeline/toolbar/components/playback-controls.ts delete mode 100644 src/components/timeline/toolbar/components/time-display.ts delete mode 100644 src/components/timeline/toolbar/components/toolbar-button.ts delete mode 100644 src/components/timeline/toolbar/constants.ts delete mode 100644 src/components/timeline/toolbar/icons/icon-factory.ts delete mode 100644 src/components/timeline/toolbar/index.ts delete mode 100644 src/components/timeline/toolbar/toolbar-layout.ts delete mode 100644 src/components/timeline/toolbar/types.ts delete mode 100644 src/components/timeline/types/assets.ts delete mode 100644 src/components/timeline/types/index.ts delete mode 100644 src/components/timeline/types/timeline.ts delete mode 100644 src/components/timeline/visual/visual-clip.ts delete mode 100644 src/components/timeline/visual/visual-track.ts diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index b0385337..23211dfd 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -2,11 +2,50 @@ import { Player, PlayerType } from "@canvas/players/player"; import { FONT_PATHS, parseFontFamily, resolveFontPath } from "@core/fonts/font-config"; import { type Size, type Vector } from "@layouts/geometry"; import { RichTextAssetSchema, type RichTextAsset } from "@schemas/rich-text-asset"; -import { createTextEngine } from "@shotstack/shotstack-canvas"; -import { TextEngine, TextRenderer, ValidatedRichTextAsset } from "@timeline/types"; +import { createTextEngine, type DrawOp } from "@shotstack/shotstack-canvas"; import * as opentype from "opentype.js"; import * as pixi from "pixi.js"; +// Types for the text engine from @shotstack/shotstack-canvas +type FontDescriptor = { family: string; weight: string | number }; +type TextRenderer = { render: (ops: DrawOp[]) => Promise }; +type TextEngine = { + validate: (asset: unknown) => { value: ValidatedRichTextAsset; error?: unknown }; + renderFrame: (asset: ValidatedRichTextAsset, time: number) => Promise; + createRenderer: (canvas: HTMLCanvasElement) => TextRenderer; + registerFontFromUrl: (url: string, desc: FontDescriptor) => Promise; + registerFontFromFile: (path: string, desc: FontDescriptor) => Promise; + destroy: () => void; +}; +type ValidatedRichTextAsset = { + type: "rich-text"; + text: string; + width: number; + height: number; + font: { family: string; size: number; weight: string | number; color: string; opacity: number }; + style: { + letterSpacing: number; + lineHeight: number; + textTransform: "none" | "uppercase" | "lowercase" | "capitalize"; + textDecoration: "none" | "underline" | "line-through"; + gradient?: { type: "linear" | "radial"; angle: number; stops: { offset: number; color: string }[] }; + }; + stroke: { width: number; color: string; opacity: number }; + shadow: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number }; + background: { color?: string; opacity: number }; + border: { width: number; color: string; opacity: number; radius: number }; + padding?: number | { top: number; right: number; bottom: number; left: number }; + align: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" }; + animation: { + preset: "fadeIn" | "slideIn" | "typewriter" | "shift" | "ascend" | "movingLetters"; + speed: number; + duration?: number; + style?: "character" | "word"; + direction?: "left" | "right" | "up" | "down"; + }; + customFonts: { src: string; family: string; weight?: string | number; originalFamily?: string }[]; +}; + const extractFontNames = (url: string): { full: string; base: string } => { const filename = url.split("/").pop() || ""; const withoutExtension = filename.replace(/\.(ttf|otf|woff|woff2)$/i, ""); diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index 98fd33d6..f189779f 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -10,10 +10,9 @@ import { type Size } from "@layouts/geometry"; import { AudioLoadParser } from "@loaders/audio-load-parser"; import { FontLoadParser } from "@loaders/font-load-parser"; import { SubtitleLoadParser } from "@loaders/subtitle-load-parser"; +import type { Timeline } from "@timeline/index"; import * as pixi from "pixi.js"; -import type { Timeline } from "../timeline/timeline"; - export class Canvas { /** @internal */ public static readonly CanvasSelector = "[data-shotstack-studio]"; diff --git a/src/components/timeline-html/html-timeline.ts b/src/components/timeline-html/html-timeline.ts deleted file mode 100644 index 16aa60d7..00000000 --- a/src/components/timeline-html/html-timeline.ts +++ /dev/null @@ -1,512 +0,0 @@ -import type { Edit } from "@core/edit"; - -import { PlayheadComponent } from "./components/playhead/playhead-component"; -import { RulerComponent } from "./components/ruler/ruler-component"; -import { ToolbarComponent } from "./components/toolbar/toolbar-component"; -import { TrackListComponent } from "./components/track/track-list"; -import { TimelineStateManager } from "./core/state/timeline-state"; -import { TimelineEntity } from "./core/timeline-entity"; -import type { HtmlTimelineOptions, HtmlTimelineFeatures, ClipRenderer, ClipInfo } from "./html-timeline.types"; -import { InteractionController } from "./interaction/interaction-controller"; -import { getTimelineStyles } from "./styles/timeline.css"; - -/** HTML/CSS-based Timeline component extending TimelineEntity for SDK consistency */ -export class HtmlTimeline extends TimelineEntity { - private readonly container: HTMLElement; - private readonly stateManager: TimelineStateManager; - - // Feature flags - private features: Required; - - // Custom renderers - private clipRenderers = new Map(); - - // Components (stored separately from children for typed access) - private toolbar: ToolbarComponent | null = null; - private rulerTracksWrapper: HTMLElement | null = null; - private ruler: RulerComponent | null = null; - private trackList: TrackListComponent | null = null; - private playhead: PlayheadComponent | null = null; - private playheadGhost: HTMLElement | null = null; - private feedbackLayer: HTMLElement | null = null; - private interactionController: InteractionController | null = null; - - // Style element for scoped CSS - private styleElement: HTMLStyleElement | null = null; - - // Hybrid render loop state - private animationFrameId: number | null = null; - private isRenderLoopActive = false; - private lastFrameTime = 0; - private isInteracting = false; - private isLoaded = false; - - // Bound event handlers for cleanup - private readonly handleTimelineUpdated: () => void; - private readonly handlePlaybackPlay: () => void; - private readonly handlePlaybackPause: () => void; - private readonly handlePlaybackStop: () => void; - private readonly handleClipSelected: () => void; - - constructor( - private readonly edit: Edit, - container: HTMLElement, - options: HtmlTimelineOptions = {} - ) { - super("div", "ss-html-timeline"); - - this.container = container; - - // Merge default features with provided options - this.features = { - toolbar: options.features?.toolbar ?? true, - ruler: options.features?.ruler ?? true, - playhead: options.features?.playhead ?? true, - snap: options.features?.snap ?? true, - badges: options.features?.badges ?? true, - multiSelect: options.features?.multiSelect ?? true - }; - - // Configure root element to fill container - this.element.style.width = "100%"; - this.element.style.height = "100%"; - - // Create state manager with placeholder size (will be updated in load()) - this.stateManager = new TimelineStateManager(edit, { - width: 800, // placeholder, updated in load() - height: 300, // placeholder, updated in load() - pixelsPerSecond: options.pixelsPerSecond ?? 50 - }); - - // Bind event handlers - this.handleTimelineUpdated = () => { - // Re-detect luma attachments in case clips were added/removed/moved - this.stateManager.detectAndAttachLumas(); - this.requestRender(); - }; - this.handlePlaybackPlay = () => this.startRenderLoop(); - this.handlePlaybackPause = () => { - this.stopRenderLoop(); - this.requestRender(); // Final render to update UI with paused state - }; - this.handlePlaybackStop = () => { - this.stopRenderLoop(); - this.requestRender(); // Final render to update UI with stopped state - }; - this.handleClipSelected = () => this.requestRender(); - } - - /** Initialize and mount the timeline */ - public async load(): Promise { - if (this.isLoaded) return; - - // Inject styles - this.injectStyles(); - - // Mount to container first so we can measure - this.container.appendChild(this.element); - - // Get actual size from container - const rect = this.container.getBoundingClientRect(); - const width = rect.width || 800; - const height = rect.height || 300; - this.stateManager.setViewport({ width, height }); - - // Build component structure - this.buildComponents(); - - // Set up event listeners for hybrid render loop - this.setupEventListeners(); - - // Initial render (data is derived from Edit on-demand) - this.update(0, performance.now()); - this.draw(); - - this.isLoaded = true; - } - - /** Update component state (called each frame during active rendering) */ - public update(_deltaTime: number, _elapsed: number): void { - // State manager already syncs with Edit via events - // This method is here for TimelineEntity conformance - // Children that extend TimelineEntity will be updated via updateChildren() - } - - /** Render/draw component to DOM (called each frame after update) */ - public draw(): void { - // Derive state from Edit on-demand (single source of truth) - const viewport = this.stateManager.getViewport(); - const playback = this.stateManager.getPlayback(); - const tracks = this.stateManager.getTracks(); - - // Update CSS variable for clip/playhead positioning - this.element.style.setProperty("--ss-timeline-pixels-per-second", String(viewport.pixelsPerSecond)); - - // Update toolbar - this.toolbar?.updatePlayState(playback.isPlaying); - this.toolbar?.updateTimeDisplay(playback.time, playback.duration); - this.toolbar?.draw(); - - // Update ruler and draw - this.ruler?.updateRuler(viewport.pixelsPerSecond, this.stateManager.getExtendedDuration()); - this.ruler?.draw(); - - // Update tracks and draw - this.trackList?.updateTracks(tracks, this.stateManager.getTimelineWidth(), viewport.pixelsPerSecond); - this.trackList?.draw(); - - // Update playhead - this.playhead?.setTime(playback.time); - this.playhead?.draw(); - } - - /** Clean up and unmount the timeline */ - public dispose(): void { - // Stop animation loop - this.stopRenderLoop(); - - // Remove event listeners - this.removeEventListeners(); - - // Dispose state manager - this.stateManager.dispose(); - - // Dispose components - this.disposeComponents(); - - // Clean up custom renderers - this.clipRenderers.clear(); - - // Remove DOM - this.element.remove(); - - // Remove styles - if (this.styleElement) { - this.styleElement.remove(); - this.styleElement = null; - } - - this.isLoaded = false; - } - - // ========== Hybrid Render Loop ========== - - private setupEventListeners(): void { - // Listen for timeline data changes (single render when idle) - this.edit.events.on("timeline:updated", this.handleTimelineUpdated); - - // Listen for playback state changes (start/stop render loop) - this.edit.events.on("playback:play", this.handlePlaybackPlay); - this.edit.events.on("playback:pause", this.handlePlaybackPause); - this.edit.events.on("playback:stop", this.handlePlaybackStop); - - // Listen for selection changes (from canvas or other sources) - this.edit.events.on("clip:selected", this.handleClipSelected); - } - - private removeEventListeners(): void { - this.edit.events.off("timeline:updated", this.handleTimelineUpdated); - this.edit.events.off("playback:play", this.handlePlaybackPlay); - this.edit.events.off("playback:pause", this.handlePlaybackPause); - this.edit.events.off("playback:stop", this.handlePlaybackStop); - this.edit.events.off("clip:selected", this.handleClipSelected); - } - - /** Start continuous render loop (during playback or interaction) */ - private startRenderLoop(): void { - if (this.isRenderLoopActive) return; - this.isRenderLoopActive = true; - this.lastFrameTime = performance.now(); - this.tick(); - } - - /** Stop continuous render loop */ - private stopRenderLoop(): void { - this.isRenderLoopActive = false; - if (this.animationFrameId !== null) { - cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = null; - } - } - - /** Animation frame callback */ - private tick = (): void => { - if (!this.isRenderLoopActive) return; - - const now = performance.now(); - const deltaTime = now - this.lastFrameTime; - this.lastFrameTime = now; - - this.update(deltaTime, now); - this.draw(); - - // Continue loop if playing or interacting - if (this.edit.isPlaying || this.isInteracting) { - this.animationFrameId = requestAnimationFrame(this.tick); - } else { - this.isRenderLoopActive = false; - this.animationFrameId = null; - } - }; - - /** Request a single render (used when idle and data changes) */ - private requestRender(): void { - if (this.isRenderLoopActive) return; // Loop already running - this.update(0, performance.now()); - this.draw(); - } - - /** Mark interaction as started (enables render loop) */ - public beginInteraction(): void { - this.isInteracting = true; - this.startRenderLoop(); - } - - /** Mark interaction as ended (may stop render loop) */ - public endInteraction(): void { - this.isInteracting = false; - // Loop will stop on next tick if not playing - } - - // ========== Component Building ========== - - private injectStyles(): void { - this.styleElement = document.createElement("style"); - this.styleElement.textContent = getTimelineStyles(); - document.head.appendChild(this.styleElement); - } - - private buildComponents(): void { - // Clear existing content - this.element.innerHTML = ""; - - const viewport = this.stateManager.getViewport(); - - // Build toolbar - if (this.features.toolbar) { - this.toolbar = new ToolbarComponent( - { - onPlay: () => this.edit.play(), - onPause: () => this.edit.pause(), - onSkipBack: () => this.edit.seek(Math.max(0, this.edit.playbackTime - 1000)), - onSkipForward: () => this.edit.seek(this.edit.playbackTime + 1000), - onZoomChange: pps => this.setZoom(pps) - }, - viewport.pixelsPerSecond - ); - this.element.appendChild(this.toolbar.element); - } - - // Create wrapper for ruler + tracks + playhead (so playhead can span both) - this.rulerTracksWrapper = document.createElement("div"); - this.rulerTracksWrapper.className = "ss-ruler-tracks-wrapper"; - this.element.appendChild(this.rulerTracksWrapper); - - // Build ruler - if (this.features.ruler) { - this.ruler = new RulerComponent({ - onSeek: timeMs => this.edit.seek(timeMs), - onWheel: e => { - if (this.trackList) { - this.trackList.element.scrollTop += e.deltaY; - this.trackList.element.scrollLeft += e.deltaX; - } - } - }); - this.rulerTracksWrapper.appendChild(this.ruler.element); - } - - // Build track list - this.trackList = new TrackListComponent({ - showBadges: this.features.badges, - onClipSelect: (trackIndex, clipIndex, addToSelection) => { - if (this.features.multiSelect && addToSelection) { - this.stateManager.selectClip(trackIndex, clipIndex, true); - } else { - this.stateManager.selectClip(trackIndex, clipIndex, false); - } - this.edit.selectClip(trackIndex, clipIndex); - this.requestRender(); - }, - getClipRenderer: type => this.clipRenderers.get(type), - isLumaAttached: (trackIndex, clipIndex) => this.stateManager.isLumaAttached(trackIndex, clipIndex), - getAttachedLuma: (trackIndex, clipIndex) => this.stateManager.getAttachedLuma(trackIndex, clipIndex), - onMaskClick: (contentTrackIndex, contentClipIndex) => { - this.stateManager.toggleLumaVisibility(contentTrackIndex, contentClipIndex); - - // Select the luma clip when toggling mask visibility - const lumaRef = this.stateManager.getAttachedLuma(contentTrackIndex, contentClipIndex); - if (lumaRef) { - this.edit.selectClip(lumaRef.trackIndex, lumaRef.clipIndex); - } - - this.requestRender(); - }, - isLumaVisibleForEditing: (contentTrackIndex, contentClipIndex) => - this.stateManager.isLumaVisibleForEditing(contentTrackIndex, contentClipIndex), - getContentClipForLuma: (lumaTrack, lumaClip) => this.stateManager.getContentClipForLuma(lumaTrack, lumaClip) - }); - - // Set up scroll sync (also sync playhead) - this.trackList.setScrollHandler((scrollX, scrollY) => { - this.stateManager.setScroll(scrollX, scrollY); - this.ruler?.syncScroll(scrollX); - // Sync playhead with track scroll - if (this.playhead) { - this.playhead.element.style.transform = `translateX(${-scrollX}px)`; - this.playhead.setScrollX(scrollX); - } - }); - - this.rulerTracksWrapper.appendChild(this.trackList.element); - - // Build playhead (at wrapper level so it spans ruler + tracks) - if (this.features.playhead) { - this.playhead = new PlayheadComponent({ - onSeek: timeMs => this.edit.seek(timeMs) - }); - this.playhead.setPixelsPerSecond(viewport.pixelsPerSecond); - this.rulerTracksWrapper.appendChild(this.playhead.element); - - // Build playhead ghost (hover preview) - this.playheadGhost = document.createElement("div"); - this.playheadGhost.className = "ss-playhead-ghost"; - this.rulerTracksWrapper.appendChild(this.playheadGhost); - - this.rulerTracksWrapper.addEventListener("mousemove", e => { - if (!this.playheadGhost || !this.rulerTracksWrapper) return; - const rect = this.rulerTracksWrapper.getBoundingClientRect(); - const scrollX = this.trackList?.element.scrollLeft ?? 0; - const x = e.clientX - rect.left + scrollX; - this.playheadGhost.style.left = `${x}px`; - }); - } - - // Build feedback layer (inside rulerTracksWrapper so coordinates align with tracks) - this.feedbackLayer = document.createElement("div"); - this.feedbackLayer.className = "ss-feedback-layer"; - this.rulerTracksWrapper.appendChild(this.feedbackLayer); - - // Initialize interaction controller - this.interactionController = new InteractionController(this.edit, this.stateManager, this.trackList.element, this.feedbackLayer, { - snapThreshold: this.features.snap ? 10 : 0 - }); - - // Auto-detect luma attachments from existing clips (e.g., on template load) - this.stateManager.detectAndAttachLumas(); - } - - private disposeComponents(): void { - this.interactionController?.dispose(); - this.interactionController = null; - - this.toolbar?.dispose(); - this.toolbar = null; - - this.ruler?.dispose(); - this.ruler = null; - - this.playhead?.dispose(); - this.playhead = null; - - this.trackList?.dispose(); - this.trackList = null; - - this.rulerTracksWrapper?.remove(); - this.rulerTracksWrapper = null; - - this.feedbackLayer?.remove(); - this.feedbackLayer = null; - } - - // ========== Public API ========== - - public setZoom(pixelsPerSecond: number): void { - this.stateManager.setPixelsPerSecond(pixelsPerSecond); - this.toolbar?.setZoom(pixelsPerSecond); - this.playhead?.setPixelsPerSecond(pixelsPerSecond); - this.requestRender(); - } - - public zoomIn(): void { - const current = this.stateManager.getViewport().pixelsPerSecond; - this.setZoom(Math.min(200, current * 1.2)); - } - - public zoomOut(): void { - const current = this.stateManager.getViewport().pixelsPerSecond; - this.setZoom(Math.max(10, current / 1.2)); - } - - public scrollTo(time: number): void { - if (!this.trackList) return; - - const pps = this.stateManager.getViewport().pixelsPerSecond; - this.trackList.setScrollPosition(time * pps, 0); - } - - /** Recalculate size from container and re-render */ - public resize(): void { - const rect = this.container.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return; - - this.stateManager.setViewport({ width: rect.width, height: rect.height }); - this.requestRender(); - } - - public selectClip(trackIndex: number, clipIndex: number): void { - this.stateManager.selectClip(trackIndex, clipIndex, false); - this.edit.selectClip(trackIndex, clipIndex); - this.requestRender(); - } - - public clearSelection(): void { - this.stateManager.clearSelection(); - this.edit.clearSelection(); - this.requestRender(); - } - - public enableFeature(feature: keyof HtmlTimelineFeatures): void { - this.features[feature] = true; - this.disposeComponents(); - this.buildComponents(); - this.requestRender(); - } - - public disableFeature(feature: keyof HtmlTimelineFeatures): void { - this.features[feature] = false; - this.disposeComponents(); - this.buildComponents(); - this.requestRender(); - } - - public registerClipRenderer(type: string, renderer: ClipRenderer): void { - this.clipRenderers.set(type, renderer); - } - - public getEdit(): Edit { - return this.edit; - } - - public findClipAtPosition(x: number, y: number): ClipInfo | null { - if (!this.trackList) return null; - - const rect = this.trackList.element.getBoundingClientRect(); - const relativeX = x - rect.left; - const relativeY = y - rect.top; - const viewport = this.stateManager.getViewport(); - const trackHeight = 64; // TODO: get from theme - - const clipState = this.trackList.findClipAtPosition(relativeX, relativeY, trackHeight, viewport.pixelsPerSecond); - - if (clipState) { - return { - trackIndex: clipState.trackIndex, - clipIndex: clipState.clipIndex, - config: clipState.config - }; - } - - return null; - } -} diff --git a/src/components/timeline-html/index.ts b/src/components/timeline-html/index.ts deleted file mode 100644 index 5d6850d9..00000000 --- a/src/components/timeline-html/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** HTML/CSS Timeline Component */ - -export { HtmlTimeline } from "./html-timeline"; - -export type { - HtmlTimelineOptions, - HtmlTimelineFeatures, - HtmlTimelineInteractionConfig, - ClipState, - TrackState, - ViewportState, - PlaybackState, - ClipInfo, - ClipRenderer -} from "./html-timeline.types"; - -export { - DEFAULT_FEATURES, - DEFAULT_INTERACTION, - DEFAULT_PIXELS_PER_SECOND, - DEFAULT_TRACK_HEIGHT, - DEFAULT_TOOLBAR_HEIGHT, - DEFAULT_RULER_HEIGHT, - TRACK_HEIGHTS, - getTrackHeight -} from "./html-timeline.types"; diff --git a/src/components/timeline-html/interaction/interaction-controller.ts b/src/components/timeline-html/interaction/interaction-controller.ts deleted file mode 100644 index 24151563..00000000 --- a/src/components/timeline-html/interaction/interaction-controller.ts +++ /dev/null @@ -1,931 +0,0 @@ -import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command"; -import { MoveClipCommand } from "@core/commands/move-clip-command"; -import { MoveClipWithPushCommand } from "@core/commands/move-clip-with-push-command"; -import { ResizeClipCommand } from "@core/commands/resize-clip-command"; -import type { Edit } from "@core/edit"; - -import { TimelineStateManager } from "../core/state/timeline-state"; -import type { ClipState, HtmlTimelineInteractionConfig } from "../html-timeline.types"; -import { getTrackHeight } from "../html-timeline.types"; - -/** Point coordinates */ -interface Point { - x: number; - y: number; -} - -/** Clip reference */ -interface ClipRef { - trackIndex: number; - clipIndex: number; -} - -/** Snap point for alignment */ -interface SnapPoint { - time: number; - type: "clip-start" | "clip-end" | "playhead"; -} - -/** Collision resolution result */ -interface CollisionResult { - newStartTime: number; - pushOffset: number; -} - -/** Drag target - either an existing track or an insertion point between tracks */ -type DragTarget = { type: "track"; trackIndex: number } | { type: "insert"; insertionIndex: number }; - -/** Interaction state machine */ -type InteractionState = - | { type: "idle" } - | { type: "pending"; startPoint: Point; clipRef: ClipRef; originalTime: number } - | { - type: "dragging"; - clipRef: ClipRef; - clipElement: HTMLElement; // Original clip element (follows mouse) - ghost: HTMLElement; // Drop preview (shows snap target) - startTime: number; - originalTrack: number; - dragTarget: DragTarget; - dragOffsetX: number; // Pixel offset from clip left edge to mouse - dragOffsetY: number; // Pixel offset from clip top to mouse - originalStyles: { position: string; left: string; top: string; zIndex: string; pointerEvents: string }; - draggedClipLength: number; // Length of the clip being dragged - collisionResult: CollisionResult; // Current collision resolution - } - | { type: "resizing"; clipRef: ClipRef; edge: "left" | "right"; originalStart: number; originalLength: number }; - -/** Configuration defaults */ -const DEFAULT_CONFIG: Required = { - dragThreshold: 3, - snapThreshold: 10, - resizeZone: 12 -}; - -/** Controller for timeline interactions (drag, resize, selection) */ -export class InteractionController { - private state: InteractionState = { type: "idle" }; - private readonly config: Required; - private snapPoints: SnapPoint[] = []; - - // DOM references - private readonly feedbackLayer: HTMLElement; - private snapLine: HTMLElement | null = null; - private dragGhost: HTMLElement | null = null; - private dropZone: HTMLElement | null = null; - private dragTimeTooltip: HTMLElement | null = null; - - // Bound handlers for cleanup - private readonly handlePointerMove: (e: PointerEvent) => void; - private readonly handlePointerUp: (e: PointerEvent) => void; - - constructor( - private readonly edit: Edit, - private readonly stateManager: TimelineStateManager, - private readonly tracksContainer: HTMLElement, - feedbackLayer: HTMLElement, - config?: Partial - ) { - this.feedbackLayer = feedbackLayer; - this.config = { ...DEFAULT_CONFIG, ...config }; - - // Bind handlers - this.handlePointerMove = this.onPointerMove.bind(this); - this.handlePointerUp = this.onPointerUp.bind(this); - - this.setupEventListeners(); - } - - private setupEventListeners(): void { - this.tracksContainer.addEventListener("pointerdown", this.onPointerDown.bind(this)); - document.addEventListener("pointermove", this.handlePointerMove); - document.addEventListener("pointerup", this.handlePointerUp); - } - - private onPointerDown(e: PointerEvent): void { - const target = e.target as HTMLElement; - - // Find clip element - const clipEl = target.closest(".ss-clip") as HTMLElement; - if (!clipEl) { - // Click on empty space - clear selection - this.stateManager.clearSelection(); - return; - } - - const trackIndex = parseInt(clipEl.dataset["trackIndex"] || "0", 10); - const clipIndex = parseInt(clipEl.dataset["clipIndex"] || "0", 10); - - // Check if clicking on resize handle - if (target.classList.contains("ss-clip-resize-handle")) { - const edge = target.classList.contains("left") ? "left" : "right"; - this.startResize(e, { trackIndex, clipIndex }, edge); - return; - } - - // Start potential drag - this.startPending(e, { trackIndex, clipIndex }); - } - - private startPending(e: PointerEvent, clipRef: ClipRef): void { - const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); - if (!clip) return; - - this.state = { - type: "pending", - startPoint: { x: e.clientX, y: e.clientY }, - clipRef, - originalTime: clip.config.start - }; - } - - private startResize(e: PointerEvent, clipRef: ClipRef, edge: "left" | "right"): void { - const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); - if (!clip) return; - - this.state = { - type: "resizing", - clipRef, - edge, - originalStart: clip.config.start, - originalLength: clip.config.length - }; - - this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "resizing"); - this.buildSnapPoints(clipRef); - - e.preventDefault(); - } - - private onPointerMove(e: PointerEvent): void { - switch (this.state.type) { - case "pending": - this.handlePendingMove(e); - break; - case "dragging": - this.handleDragMove(e); - break; - case "resizing": - this.handleResizeMove(e); - break; - default: - break; - } - } - - private handlePendingMove(e: PointerEvent): void { - if (this.state.type !== "pending") return; - - const dx = e.clientX - this.state.startPoint.x; - const dy = e.clientY - this.state.startPoint.y; - const distance = Math.sqrt(dx * dx + dy * dy); - - if (distance >= this.config.dragThreshold) { - this.transitionToDragging(e); - } - } - - private transitionToDragging(e: PointerEvent): void { - if (this.state.type !== "pending") return; - - const { clipRef, originalTime } = this.state; - const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); - if (!clip) { - this.state = { type: "idle" }; - return; - } - - // Find the actual clip DOM element - const clipElement = this.tracksContainer.querySelector( - `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` - ) as HTMLElement | null; - if (!clipElement) { - this.state = { type: "idle" }; - return; - } - - // Store original styles for restoration later - const originalStyles = { - position: clipElement.style.position, - left: clipElement.style.left, - top: clipElement.style.top, - zIndex: clipElement.style.zIndex, - pointerEvents: clipElement.style.pointerEvents - }; - - // Get clip element's current screen position - const clipRect = clipElement.getBoundingClientRect(); - - // Calculate drag offsets - distance from mouse to clip's top-left corner - const dragOffsetX = e.clientX - clipRect.left; - const dragOffsetY = e.clientY - clipRect.top; - - // Make clip element follow mouse with position: fixed - clipElement.style.position = "fixed"; - clipElement.style.left = `${clipRect.left}px`; - clipElement.style.top = `${clipRect.top}px`; - clipElement.style.width = `${clipRect.width}px`; - clipElement.style.height = `${clipRect.height}px`; - clipElement.style.zIndex = "1000"; - clipElement.style.pointerEvents = "none"; - clipElement.classList.add("dragging"); - - // Create ghost as drop preview (shows where clip will land) - const ghost = this.createDragGhost(clip, clipRef.trackIndex); - this.feedbackLayer.appendChild(ghost); - - const pps = this.stateManager.getViewport().pixelsPerSecond; - - this.state = { - type: "dragging", - clipRef, - clipElement, - ghost, - startTime: originalTime, - originalTrack: clipRef.trackIndex, - dragTarget: { type: "track", trackIndex: clipRef.trackIndex }, - dragOffsetX, - dragOffsetY, - originalStyles, - draggedClipLength: clip.config.length, - collisionResult: { newStartTime: originalTime, pushOffset: 0 } - }; - - // Position ghost at current clip position initially - const tracksOffset = this.getTracksOffsetInFeedbackLayer(); - ghost.style.left = `${clip.config.start * pps}px`; - ghost.style.top = `${this.getTrackYPosition(clipRef.trackIndex) + 4 + tracksOffset}px`; - - this.buildSnapPoints(clipRef); - } - - private createDragGhost(clip: ClipState, trackIndex: number): HTMLElement { - const ghost = document.createElement("div"); - ghost.className = "ss-drag-ghost ss-clip"; - const clipAssetType = clip.config.asset?.type || "unknown"; - ghost.dataset["assetType"] = clipAssetType; - - const pps = this.stateManager.getViewport().pixelsPerSecond; - const width = clip.config.length * pps; - const track = this.stateManager.getTracks()[trackIndex]; - const trackAssetType = track?.primaryAssetType ?? clipAssetType; - const trackHeight = getTrackHeight(trackAssetType); - - ghost.style.width = `${width}px`; - ghost.style.height = `${trackHeight - 8}px`; // Track height - padding - ghost.style.position = "absolute"; - ghost.style.pointerEvents = "none"; - ghost.style.opacity = "0.8"; - - return ghost; - } - - private handleDragMove(e: PointerEvent): void { - if (this.state.type !== "dragging") return; - - const rect = this.tracksContainer.getBoundingClientRect(); - const scrollX = this.tracksContainer.scrollLeft; - const scrollY = this.tracksContainer.scrollTop; - const pps = this.stateManager.getViewport().pixelsPerSecond; - - // Move the actual clip element freely with the mouse (position: fixed) - this.state.clipElement.style.left = `${e.clientX - this.state.dragOffsetX}px`; - this.state.clipElement.style.top = `${e.clientY - this.state.dragOffsetY}px`; - - // Mouse position in content space (for calculating target position) - const mouseX = e.clientX - rect.left + scrollX; - const mouseY = e.clientY - rect.top + scrollY; - - // Calculate clip position from mouse (accounting for drag offset in content space) - const clipX = mouseX - this.state.dragOffsetX; - let clipTime = Math.max(0, clipX / pps); - - // Determine drag target based on mouse Y - const dragTarget = this.getDragTargetAtY(mouseY); - this.state.dragTarget = dragTarget; - - // Apply snapping to clip time - const snappedTime = this.applySnap(clipTime); - if (snappedTime !== null) { - clipTime = snappedTime; - this.showSnapLine(clipTime); - } else { - this.hideSnapLine(); - } - - // Apply collision detection for track targets (skip for luma assets - they overlay) - if (dragTarget.type === "track") { - const draggedClip = this.stateManager.getClipAt(this.state.clipRef.trackIndex, this.state.clipRef.clipIndex); - const draggedAssetType = draggedClip?.config.asset?.type; - - if (draggedAssetType === "luma") { - // Luma assets can overlay other clips - skip collision detection - this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0 }; - } else { - const collisionResult = this.resolveClipCollision(dragTarget.trackIndex, clipTime, this.state.draggedClipLength, this.state.clipRef); - clipTime = collisionResult.newStartTime; - this.state.collisionResult = collisionResult; - } - } else { - // No collision for insertion targets (new track) - this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0 }; - } - - // Get offset for positioning in feedback layer (accounts for ruler height) - const tracksOffset = this.getTracksOffsetInFeedbackLayer(); - - // Position ghost and drop zone based on target type - if (dragTarget.type === "track") { - // Show ghost for track targets - this.state.ghost.style.display = "block"; - const tracks = this.stateManager.getTracks(); - const targetTrackY = this.getTrackYPosition(dragTarget.trackIndex) + 4; // +4 for clip padding - const targetTrack = tracks[dragTarget.trackIndex]; - const targetHeight = getTrackHeight(targetTrack?.primaryAssetType ?? "default") - 8; - - this.state.ghost.style.left = `${clipTime * pps}px`; - this.state.ghost.style.top = `${targetTrackY + tracksOffset}px`; - this.state.ghost.style.height = `${targetHeight}px`; - - this.showDragTimeTooltip(clipTime, clipTime * pps, targetTrackY + tracksOffset); - this.hideDropZone(); - } else { - // Hide ghost for insertion targets - drop zone indicator is sufficient - this.state.ghost.style.display = "none"; - this.showDropZone(dragTarget.insertionIndex); - } - } - - private handleResizeMove(e: PointerEvent): void { - if (this.state.type !== "resizing") return; - - const rect = this.tracksContainer.getBoundingClientRect(); - const scrollX = this.tracksContainer.scrollLeft; - const pps = this.stateManager.getViewport().pixelsPerSecond; - - const x = e.clientX - rect.left + scrollX; - let time = Math.max(0, x / pps); - - // Apply snapping - const snappedTime = this.applySnap(time); - if (snappedTime !== null) { - time = snappedTime; - this.showSnapLine(time); - } else { - this.hideSnapLine(); - } - - // Calculate new dimensions based on edge - const { clipRef, edge, originalStart, originalLength } = this.state; - - if (edge === "left") { - // Resize from left edge (keep end fixed, change start and length) - const originalEnd = originalStart + originalLength; - const newStart = Math.max(0, Math.min(time, originalEnd - 0.1)); - const newLength = originalEnd - newStart; - - const clipEl = this.tracksContainer.querySelector( - `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` - ) as HTMLElement; - if (clipEl) { - clipEl.style.setProperty("--clip-start", String(newStart)); - clipEl.style.setProperty("--clip-length", String(newLength)); - } - this.showDragTimeTooltip(newStart, e.clientX - rect.left, e.clientY - rect.top); - } else { - // Resize from right edge - const newLength = Math.max(0.1, time - originalStart); - - const clipEl = this.tracksContainer.querySelector( - `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` - ) as HTMLElement; - if (clipEl) { - clipEl.style.setProperty("--clip-length", String(newLength)); - } - this.showDragTimeTooltip(originalStart + newLength, e.clientX - rect.left, e.clientY - rect.top); - } - } - - private onPointerUp(e: PointerEvent): void { - switch (this.state.type) { - case "pending": - // Was just a click, selection already handled - this.state = { type: "idle" }; - break; - case "dragging": - this.completeDrag(e); - break; - case "resizing": - this.completeResize(e); - break; - default: - break; - } - } - - private completeDrag(_e: PointerEvent): void { - if (this.state.type !== "dragging") return; - - const { clipRef, clipElement, ghost, startTime, originalTrack, dragTarget, originalStyles, collisionResult } = this.state; - - // Restore clip element to normal flow before executing command - clipElement.style.position = originalStyles.position; - clipElement.style.left = originalStyles.left; - clipElement.style.top = originalStyles.top; - clipElement.style.zIndex = originalStyles.zIndex; - clipElement.style.pointerEvents = originalStyles.pointerEvents; - clipElement.style.width = ""; - clipElement.style.height = ""; - clipElement.classList.remove("dragging"); - - // Get dragged clip's asset type - const draggedClip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); - const draggedAssetType = draggedClip?.config.asset?.type; - - // Use the collision-resolved time from the last drag move - let newTime = collisionResult.newStartTime; - - // Handle luma clip drop - must attach to a content clip - if (draggedAssetType === "luma" && dragTarget.type === "track") { - const targetContentClip = this.findContentClipAtPosition(dragTarget.trackIndex, newTime); - - if (!targetContentClip) { - // No valid target content clip - cancel drop - ghost.remove(); - this.hideSnapLine(); - this.hideDropZone(); - this.hideDragTimeTooltip(); - this.state = { type: "idle" }; - return; - } - - // Snap luma timing to content clip - newTime = targetContentClip.config.start; - - // Move luma to target position - if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { - const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); - this.edit.executeEditCommand(command); - } - - // Register attachment in state manager - this.stateManager.attachLuma( - targetContentClip.trackIndex, - targetContentClip.clipIndex, - dragTarget.trackIndex, - clipRef.clipIndex - ); - - // Cleanup and return early - ghost.remove(); - this.hideSnapLine(); - this.hideDropZone(); - this.hideDragTimeTooltip(); - this.state = { type: "idle" }; - return; - } - - // Get attached luma Player reference BEFORE any move (stable across index changes) - const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); - - // Execute appropriate command based on drag target (non-luma clips) - if (dragTarget.type === "insert") { - // Create new track and move clip to it - const command = new CreateTrackAndMoveClipCommand(dragTarget.insertionIndex, originalTrack, clipRef.clipIndex, newTime); - this.edit.executeEditCommand(command); - - // Move attached luma - get fresh indices AFTER content move - if (lumaPlayer) { - const lumaIndices = this.edit.findClipIndices(lumaPlayer); - if (lumaIndices) { - const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.insertionIndex, newTime); - this.edit.executeEditCommand(lumaCommand); - } - } - } else if (collisionResult.pushOffset > 0) { - // Need to push clips forward - use MoveClipWithPushCommand - const command = new MoveClipWithPushCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime, collisionResult.pushOffset); - this.edit.executeEditCommand(command); - - // Move attached luma - get fresh indices AFTER content move - if (lumaPlayer) { - const lumaIndices = this.edit.findClipIndices(lumaPlayer); - if (lumaIndices) { - const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.trackIndex, newTime); - this.edit.executeEditCommand(lumaCommand); - } - } - } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { - // Simple move without push - const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); - this.edit.executeEditCommand(command); - - // Move attached luma - get fresh indices AFTER content move - if (lumaPlayer) { - const lumaIndices = this.edit.findClipIndices(lumaPlayer); - if (lumaIndices) { - const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.trackIndex, newTime); - this.edit.executeEditCommand(lumaCommand); - } - } - } - - // Cleanup - ghost.remove(); - this.hideSnapLine(); - this.hideDropZone(); - this.hideDragTimeTooltip(); - this.state = { type: "idle" }; - } - - private completeResize(e: PointerEvent): void { - if (this.state.type !== "resizing") return; - - const { clipRef, edge, originalStart, originalLength } = this.state; - - const rect = this.tracksContainer.getBoundingClientRect(); - const scrollX = this.tracksContainer.scrollLeft; - const pps = this.stateManager.getViewport().pixelsPerSecond; - - const x = e.clientX - rect.left + scrollX; - let time = Math.max(0, x / pps); - - // Apply snapping - const snappedTime = this.applySnap(time); - if (snappedTime !== null) { - time = snappedTime; - } - - // Get attached luma Player reference BEFORE changes (stable across index changes) - const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); - - if (edge === "left") { - // Resize from left edge (keep end fixed, change start and length) - const originalEnd = originalStart + originalLength; - const newStart = Math.max(0, Math.min(time, originalEnd - 0.1)); - const newLength = originalEnd - newStart; - - if (newStart !== originalStart || newLength !== originalLength) { - // Move clip to new start position - if (newStart !== originalStart) { - const moveCommand = new MoveClipCommand(clipRef.trackIndex, clipRef.clipIndex, clipRef.trackIndex, newStart); - this.edit.executeEditCommand(moveCommand); - } - - // Resize clip to new length - if (newLength !== originalLength) { - const resizeCommand = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); - this.edit.executeEditCommand(resizeCommand); - } - - // Also update attached luma clip - if (lumaPlayer) { - const lumaIndices = this.edit.findClipIndices(lumaPlayer); - if (lumaIndices) { - if (newStart !== originalStart) { - const lumaMoveCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, lumaIndices.trackIndex, newStart); - this.edit.executeEditCommand(lumaMoveCommand); - } - if (newLength !== originalLength) { - const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); - this.edit.executeEditCommand(lumaResizeCommand); - } - } - } - } - } else { - // Resize from right edge (keep start fixed, change length) - const newLength = Math.max(0.1, time - originalStart); - - if (newLength !== originalLength) { - const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); - this.edit.executeEditCommand(command); - - // Also resize attached luma to match - if (lumaPlayer) { - const lumaIndices = this.edit.findClipIndices(lumaPlayer); - if (lumaIndices) { - const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); - this.edit.executeEditCommand(lumaResizeCommand); - } - } - } - } - - // Cleanup - this.hideSnapLine(); - this.hideDragTimeTooltip(); - this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); - this.state = { type: "idle" }; - } - - /** Default result when no collision detected */ - private static readonly NO_COLLISION: CollisionResult = { - newStartTime: 0, - pushOffset: 0 - }; - - /** Get sorted clips on a track, excluding the dragged clip */ - private getTrackClips(trackIndex: number, excludeClip: ClipRef): ClipState[] { - const track = this.stateManager.getTracks()[trackIndex]; - if (!track) return []; - - return track.clips - .filter(c => !(c.trackIndex === excludeClip.trackIndex && c.clipIndex === excludeClip.clipIndex)) - .sort((a, b) => a.config.start - b.config.start); - } - - /** Find which clip (if any) the dragged clip overlaps */ - private findOverlappingClip( - clips: ClipState[], - desiredStart: number, - clipLength: number - ): { clip: ClipState; index: number } | null { - const desiredEnd = desiredStart + clipLength; - for (let i = 0; i < clips.length; i += 1) { - const clip = clips[i]; - const clipStart = clip.config.start; - const clipEnd = clipStart + clip.config.length; - if (desiredStart < clipEnd && desiredEnd > clipStart) { - return { clip, index: i }; - } - } - return null; - } - - /** Resolve snap position when dragged clip overlaps another (uses clip centers for direction) */ - private resolveOverlapSnap( - targetClip: ClipState, - targetIndex: number, - desiredStart: number, - clipLength: number, - clips: ClipState[] - ): CollisionResult { - const targetStart = targetClip.config.start; - const targetEnd = targetStart + targetClip.config.length; - - // Determine snap direction based on dragged clip center vs target clip center - const draggedCenter = desiredStart + clipLength / 2; - const targetCenter = targetStart + targetClip.config.length / 2; - const snapRight = draggedCenter >= targetCenter; - - if (snapRight) { - // Snap to RIGHT of target clip - const newStartTime = targetEnd; - const newEndTime = newStartTime + clipLength; - const nextClip = clips[targetIndex + 1]; - - if (nextClip && newEndTime > nextClip.config.start) { - return { newStartTime, pushOffset: newEndTime - nextClip.config.start }; - } - return { newStartTime, pushOffset: 0 }; - } - - // Snap to LEFT of target clip - const prevClipEnd = targetIndex > 0 ? clips[targetIndex - 1].config.start + clips[targetIndex - 1].config.length : 0; - const availableSpace = targetStart - prevClipEnd; - - if (availableSpace >= clipLength) { - return { newStartTime: targetStart - clipLength, pushOffset: 0 }; - } - - // No space on left - push target clip forward - const newStartTime = prevClipEnd; - return { newStartTime, pushOffset: newStartTime + clipLength - targetStart }; - } - - /** Resolve clip collision based on clip boundaries */ - private resolveClipCollision( - trackIndex: number, - desiredStart: number, - clipLength: number, - excludeClip: ClipRef - ): CollisionResult { - const clips = this.getTrackClips(trackIndex, excludeClip); - if (clips.length === 0) { - return { ...InteractionController.NO_COLLISION, newStartTime: desiredStart }; - } - - const overlap = this.findOverlappingClip(clips, desiredStart, clipLength); - if (overlap) { - // Skip collision for luma assets - they should be overlayable - if (overlap.clip.config.asset?.type === "luma") { - return { newStartTime: desiredStart, pushOffset: 0 }; - } - return this.resolveOverlapSnap(overlap.clip, overlap.index, desiredStart, clipLength, clips); - } - - return { newStartTime: desiredStart, pushOffset: 0 }; - } - - /** Find a non-luma content clip at the given position on a track */ - private findContentClipAtPosition(trackIndex: number, time: number): ClipState | null { - const track = this.stateManager.getTracks()[trackIndex]; - if (!track) return null; - - for (const clip of track.clips) { - // Only consider non-luma content clips - if (clip.config.asset?.type !== "luma") { - const clipStart = clip.config.start; - const clipEnd = clipStart + clip.config.length; - - // Check if time falls within this clip - if (time >= clipStart && time < clipEnd) { - return clip; - } - } - } - return null; - } - - private buildSnapPoints(excludeClip: ClipRef): void { - this.snapPoints = []; - - // Add playhead position - const playback = this.stateManager.getPlayback(); - this.snapPoints.push({ - time: playback.time / 1000, - type: "playhead" - }); - - // Add clip edges - const tracks = this.stateManager.getTracks(); - for (const track of tracks) { - for (const clip of track.clips) { - // Skip the clip being dragged/resized - const isExcluded = clip.trackIndex === excludeClip.trackIndex && clip.clipIndex === excludeClip.clipIndex; - if (!isExcluded) { - this.snapPoints.push({ - time: clip.config.start, - type: "clip-start" - }); - this.snapPoints.push({ - time: clip.config.start + clip.config.length, - type: "clip-end" - }); - } - } - } - } - - private applySnap(time: number): number | null { - const pps = this.stateManager.getViewport().pixelsPerSecond; - const threshold = this.config.snapThreshold / pps; // Convert pixels to seconds - - for (const point of this.snapPoints) { - if (Math.abs(time - point.time) <= threshold) { - return point.time; - } - } - - return null; - } - - private showSnapLine(time: number): void { - if (!this.snapLine) { - this.snapLine = document.createElement("div"); - this.snapLine.className = "ss-snap-line"; - this.feedbackLayer.appendChild(this.snapLine); - } - - const pps = this.stateManager.getViewport().pixelsPerSecond; - const x = time * pps - this.tracksContainer.scrollLeft; - this.snapLine.style.left = `${x}px`; - this.snapLine.style.display = "block"; - } - - private hideSnapLine(): void { - if (this.snapLine) { - this.snapLine.style.display = "none"; - } - } - - private showDropZone(insertionIndex: number): void { - if (!this.dropZone) { - this.dropZone = document.createElement("div"); - this.dropZone.className = "ss-drop-zone"; - this.feedbackLayer.appendChild(this.dropZone); - } - - const y = this.getTrackYPosition(insertionIndex); - const tracksOffset = this.getTracksOffsetInFeedbackLayer(); - this.dropZone.style.top = `${y - 2 + tracksOffset}px`; - this.dropZone.style.display = "block"; - } - - /** Get the Y offset of tracks container relative to feedback layer's parent */ - private getTracksOffsetInFeedbackLayer(): number { - // Feedback layer and tracks container are siblings inside rulerTracksWrapper - // The ruler sits above the tracks, so we need this offset for correct positioning - const feedbackParent = this.feedbackLayer.parentElement; - if (!feedbackParent) return 0; - - const parentRect = feedbackParent.getBoundingClientRect(); - const tracksRect = this.tracksContainer.getBoundingClientRect(); - return tracksRect.top - parentRect.top; - } - - private hideDropZone(): void { - if (this.dropZone) { - this.dropZone.style.display = "none"; - } - } - - /** Format time for drag tooltip display (MM:SS.T) */ - private formatDragTime(seconds: number): string { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - const tenths = Math.floor((seconds % 1) * 10); - return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${tenths}`; - } - - private showDragTimeTooltip(time: number, x: number, y: number): void { - if (!this.dragTimeTooltip) { - this.dragTimeTooltip = document.createElement("div"); - this.dragTimeTooltip.className = "ss-drag-time-tooltip"; - this.feedbackLayer.appendChild(this.dragTimeTooltip); - } - - this.dragTimeTooltip.textContent = this.formatDragTime(time); - this.dragTimeTooltip.style.left = `${x}px`; - this.dragTimeTooltip.style.top = `${y - 28}px`; - this.dragTimeTooltip.style.display = "block"; - } - - private hideDragTimeTooltip(): void { - if (this.dragTimeTooltip) { - this.dragTimeTooltip.style.display = "none"; - } - } - - /** Get drag target at Y position - either an existing track or an insertion point between tracks */ - private getDragTargetAtY(y: number): DragTarget { - const tracks = this.stateManager.getTracks(); - const insertZoneSize = 12; // pixels at track edges for insert detection - let currentY = 0; - - // Top edge - insert above first track - if (y < insertZoneSize / 2) { - return { type: "insert", insertionIndex: 0 }; - } - - for (let i = 0; i < tracks.length; i += 1) { - const height = getTrackHeight(tracks[i].primaryAssetType); - - // Top edge insert zone (between this track and previous) - if (i > 0 && y >= currentY - insertZoneSize / 2 && y < currentY + insertZoneSize / 2) { - return { type: "insert", insertionIndex: i }; - } - - // Inside track (not in edge zones) - if (y >= currentY + insertZoneSize / 2 && y < currentY + height - insertZoneSize / 2) { - return { type: "track", trackIndex: i }; - } - - currentY += height; - } - - // Bottom edge - insert after last track - if (y >= currentY - insertZoneSize / 2) { - return { type: "insert", insertionIndex: tracks.length }; - } - - // Default to last track - return { type: "track", trackIndex: Math.max(0, tracks.length - 1) }; - } - - /** Get Y position of a track by index (accounting for variable heights) */ - private getTrackYPosition(trackIndex: number): number { - const tracks = this.stateManager.getTracks(); - let y = 0; - for (let i = 0; i < trackIndex && i < tracks.length; i += 1) { - y += getTrackHeight(tracks[i].primaryAssetType); - } - return y; - } - - public dispose(): void { - document.removeEventListener("pointermove", this.handlePointerMove); - document.removeEventListener("pointerup", this.handlePointerUp); - - if (this.snapLine) { - this.snapLine.remove(); - this.snapLine = null; - } - - if (this.dragGhost) { - this.dragGhost.remove(); - this.dragGhost = null; - } - - if (this.dropZone) { - this.dropZone.remove(); - this.dropZone = null; - } - - if (this.dragTimeTooltip) { - this.dragTimeTooltip.remove(); - this.dragTimeTooltip = null; - } - } -} diff --git a/src/components/timeline-html/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts similarity index 99% rename from src/components/timeline-html/components/clip/clip-component.ts rename to src/components/timeline/components/clip/clip-component.ts index 040ff148..822b7bb6 100644 --- a/src/components/timeline-html/components/clip/clip-component.ts +++ b/src/components/timeline/components/clip/clip-component.ts @@ -1,7 +1,7 @@ import type { ResolvedClip } from "@schemas/clip"; import { TimelineEntity } from "../../core/timeline-entity"; -import type { ClipState, ClipRenderer } from "../../html-timeline.types"; +import type { ClipState, ClipRenderer } from "../../timeline.types"; /** Reference to an attached luma clip */ interface LumaRef { diff --git a/src/components/timeline-html/components/playhead/playhead-component.ts b/src/components/timeline/components/playhead/playhead-component.ts similarity index 100% rename from src/components/timeline-html/components/playhead/playhead-component.ts rename to src/components/timeline/components/playhead/playhead-component.ts diff --git a/src/components/timeline-html/components/ruler/ruler-component.ts b/src/components/timeline/components/ruler/ruler-component.ts similarity index 100% rename from src/components/timeline-html/components/ruler/ruler-component.ts rename to src/components/timeline/components/ruler/ruler-component.ts diff --git a/src/components/timeline-html/components/toolbar/toolbar-component.ts b/src/components/timeline/components/toolbar/toolbar-component.ts similarity index 100% rename from src/components/timeline-html/components/toolbar/toolbar-component.ts rename to src/components/timeline/components/toolbar/toolbar-component.ts diff --git a/src/components/timeline-html/components/track/track-component.ts b/src/components/timeline/components/track/track-component.ts similarity index 98% rename from src/components/timeline-html/components/track/track-component.ts rename to src/components/timeline/components/track/track-component.ts index 75efb4ed..724fd37d 100644 --- a/src/components/timeline-html/components/track/track-component.ts +++ b/src/components/timeline/components/track/track-component.ts @@ -1,6 +1,6 @@ import { TimelineEntity } from "../../core/timeline-entity"; -import type { TrackState, ClipState, ClipRenderer } from "../../html-timeline.types"; -import { getTrackHeight } from "../../html-timeline.types"; +import type { TrackState, ClipState, ClipRenderer } from "../../timeline.types"; +import { getTrackHeight } from "../../timeline.types"; import { ClipComponent } from "../clip/clip-component"; export interface TrackComponentOptions { diff --git a/src/components/timeline-html/components/track/track-list.ts b/src/components/timeline/components/track/track-list.ts similarity index 98% rename from src/components/timeline-html/components/track/track-list.ts rename to src/components/timeline/components/track/track-list.ts index 56de9582..11033fe9 100644 --- a/src/components/timeline-html/components/track/track-list.ts +++ b/src/components/timeline/components/track/track-list.ts @@ -1,6 +1,6 @@ import { TimelineEntity } from "../../core/timeline-entity"; -import type { TrackState, ClipState, ClipRenderer } from "../../html-timeline.types"; -import { getTrackHeight } from "../../html-timeline.types"; +import type { TrackState, ClipState, ClipRenderer } from "../../timeline.types"; +import { getTrackHeight } from "../../timeline.types"; import { TrackComponent } from "./track-component"; diff --git a/src/components/timeline/constants.ts b/src/components/timeline/constants.ts deleted file mode 100644 index 63208990..00000000 --- a/src/components/timeline/constants.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Shared constants for timeline components - */ - -// Visual constants for clips -export const CLIP_CONSTANTS = { - MIN_WIDTH: 50, - PADDING: 4, - DEFAULT_ALPHA: 1.0, - DRAG_OPACITY: 0.6, - RESIZE_OPACITY: 0.9, - HOVER_OPACITY: 0.7, - BORDER_WIDTH: 2, - CORNER_RADIUS: 4, - SELECTED_BORDER_MULTIPLIER: 2, - TEXT_FONT_SIZE: 12, - TEXT_TRUNCATE_SUFFIX_LENGTH: 3 -} as const; - -// Visual constants for tracks -export const TRACK_CONSTANTS = { - PADDING: 2, - LABEL_PADDING: 8, - DEFAULT_OPACITY: 0.8, - BORDER_WIDTH: 1 -} as const; - -// Layout constants -export const LAYOUT_CONSTANTS = { - TOOLBAR_HEIGHT_RATIO: 0.12, // 12% of timeline height - RULER_HEIGHT_RATIO: 0.133, // 13.3% of timeline height - TOOLBAR_HEIGHT_DEFAULT: 36, - RULER_HEIGHT_DEFAULT: 40, - TRACK_HEIGHT_DEFAULT: 80, - BORDER_WIDTH: 2, - CORNER_RADIUS: 4, - CLIP_PADDING: 4, - LABEL_PADDING: 8, - TRACK_PADDING: 2, - MIN_CLIP_WIDTH: 50 -} as const; - -// Type for constants -export type ClipConstants = typeof CLIP_CONSTANTS; -export type TrackConstants = typeof TRACK_CONSTANTS; -export type LayoutConstants = typeof LAYOUT_CONSTANTS; diff --git a/src/components/timeline-html/core/state/timeline-state.ts b/src/components/timeline/core/state/timeline-state.ts similarity index 99% rename from src/components/timeline-html/core/state/timeline-state.ts rename to src/components/timeline/core/state/timeline-state.ts index c062a835..4abf91b8 100644 --- a/src/components/timeline-html/core/state/timeline-state.ts +++ b/src/components/timeline/core/state/timeline-state.ts @@ -3,7 +3,7 @@ import type { Edit } from "@core/edit"; import type { ResolvedClip } from "@schemas/clip"; import type { ResolvedTrack } from "@schemas/track"; -import type { TrackState, ClipState, ViewportState, PlaybackState } from "../../html-timeline.types"; +import type { TrackState, ClipState, ViewportState, PlaybackState } from "../../timeline.types"; type ClipVisualState = "normal" | "selected" | "dragging" | "resizing"; diff --git a/src/components/timeline-html/core/timeline-entity.ts b/src/components/timeline/core/timeline-entity.ts similarity index 100% rename from src/components/timeline-html/core/timeline-entity.ts rename to src/components/timeline/core/timeline-entity.ts diff --git a/src/components/timeline/features/index.ts b/src/components/timeline/features/index.ts deleted file mode 100644 index ee982424..00000000 --- a/src/components/timeline/features/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -// Import the classes for the interface -import type { PlayheadFeature as PlayheadFeatureType } from "./playhead-feature"; -import type { RulerFeature as RulerFeatureType } from "./ruler-feature"; -import type { ScrollManager as ScrollManagerType } from "./scroll-manager"; - -export { RulerFeature } from "./ruler-feature"; -export { PlayheadFeature } from "./playhead-feature"; -export { ScrollManager } from "./scroll-manager"; - -export type { TimelineFeatureEvents, RulerFeatureOptions, PlayheadFeatureOptions, ScrollManagerOptions, TimelineReference } from "./types"; - -export { TIMELINE_CONSTANTS } from "./types"; - -export interface TimelineFeatures { - ruler: RulerFeatureType; - playhead: PlayheadFeatureType; - scroll: ScrollManagerType; -} diff --git a/src/components/timeline/features/playhead-feature.ts b/src/components/timeline/features/playhead-feature.ts deleted file mode 100644 index e80c8dd1..00000000 --- a/src/components/timeline/features/playhead-feature.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { EventEmitter } from "@core/events/event-emitter"; -import { Entity } from "@core/shared/entity"; -import * as PIXI from "pixi.js"; - -import { TIMELINE_CONSTANTS, TimelineFeatureEvents, PlayheadFeatureOptions } from "./types"; - -export class PlayheadFeature extends Entity { - public events = new EventEmitter(); - private graphics: PIXI.Graphics; - private currentTime = 0; - private isDragging = false; - - constructor(private options: PlayheadFeatureOptions) { - super(); - this.graphics = new PIXI.Graphics(); - } - - async load(): Promise { - this.setupPlayhead(); - this.draw(); - } - - private setupPlayhead(): void { - this.graphics.label = "playhead"; - this.graphics.eventMode = "static"; - this.graphics.cursor = "pointer"; - - // Single set of event listeners - this.graphics - .on("pointerdown", this.onPointerDown.bind(this)) - .on("pointermove", this.onPointerMove.bind(this)) - .on("pointerup", this.onPointerUp.bind(this)) - .on("pointerupoutside", this.onPointerUp.bind(this)); - - this.getContainer().addChild(this.graphics); - } - - /** @internal */ - private drawPlayhead(): void { - const x = this.currentTime * this.options.pixelsPerSecond; - const playheadColor = this.options.theme?.timeline.playhead ?? 0xff4444; - const lineWidth = TIMELINE_CONSTANTS.PLAYHEAD.LINE_WIDTH; - const centerX = x + lineWidth / 2; - - this.graphics.clear(); - this.graphics.fill(playheadColor); - - // Draw line - this.graphics.rect(x, 0, lineWidth, this.options.timelineHeight); - - // Draw triangle (centered on line) - const triangleSize = 8; - const triangleHeight = 10; - this.graphics.moveTo(centerX, triangleHeight); - this.graphics.lineTo(centerX - triangleSize, 0); - this.graphics.lineTo(centerX + triangleSize, 0); - this.graphics.closePath(); - - this.graphics.fill(); - } - - /** @internal */ - private onPointerDown(event: PIXI.FederatedPointerEvent): void { - this.isDragging = true; - this.graphics.cursor = "grabbing"; - this.updateTimeFromPointer(event); - } - - /** @internal */ - private onPointerMove(event: PIXI.FederatedPointerEvent): void { - if (this.isDragging) { - this.updateTimeFromPointer(event); - } - } - - /** @internal */ - private onPointerUp(): void { - this.isDragging = false; - this.graphics.cursor = "pointer"; - } - - /** @internal */ - private updateTimeFromPointer(event: PIXI.FederatedPointerEvent): void { - if (!this.graphics.parent) return; - const localPos = this.graphics.parent.toLocal(event.global); - const newTime = Math.max(0, localPos.x / this.options.pixelsPerSecond); - this.setTime(newTime); - this.events.emit("playhead:seeked" as keyof TimelineFeatureEvents, { time: newTime }); - } - - public setTime(time: number): void { - this.currentTime = time; - this.draw(); - this.events.emit("playhead:timeChanged" as keyof TimelineFeatureEvents, { time }); - } - - public getTime(): number { - return this.currentTime; - } - - /** @internal */ - public updatePlayhead(pixelsPerSecond: number, timelineHeight: number): void { - this.options.pixelsPerSecond = pixelsPerSecond; - this.options.timelineHeight = timelineHeight; - this.draw(); - } - - public update(): void {} // Event-driven, no frame updates needed - - /** @internal */ - public draw(): void { - this.drawPlayhead(); - } - - public dispose(): void { - this.graphics.removeAllListeners(); - this.events.clear("*"); - } -} diff --git a/src/components/timeline/features/ruler-feature.ts b/src/components/timeline/features/ruler-feature.ts deleted file mode 100644 index a68e2e2e..00000000 --- a/src/components/timeline/features/ruler-feature.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { EventEmitter } from "@core/events/event-emitter"; -import { Entity } from "@core/shared/entity"; -import * as PIXI from "pixi.js"; - -import { TimelineTheme } from "../../../core/theme"; - -import { TIMELINE_CONSTANTS, TimelineFeatureEvents, RulerFeatureOptions } from "./types"; - -export class RulerFeature extends Entity { - public events: EventEmitter; - private rulerContainer: PIXI.Container; - private rulerBackground: PIXI.Graphics; - private timeMarkers: PIXI.Graphics; - private timeLabels: PIXI.Container; - - private pixelsPerSecond: number; - private timelineDuration: number; - private rulerHeight: number; - private theme?: TimelineTheme; - - constructor(options: RulerFeatureOptions) { - super(); - this.events = new EventEmitter(); - this.pixelsPerSecond = options.pixelsPerSecond; - this.timelineDuration = options.timelineDuration; - this.rulerHeight = options.rulerHeight ?? TIMELINE_CONSTANTS.RULER.DEFAULT_HEIGHT; - this.theme = options.theme; - - this.rulerContainer = new PIXI.Container(); - this.rulerBackground = new PIXI.Graphics(); - this.timeMarkers = new PIXI.Graphics(); - this.timeLabels = new PIXI.Container(); - } - - async load(): Promise { - this.setupRuler(); - this.draw(); - } - - private setupRuler(): void { - this.rulerContainer.label = "ruler"; - this.rulerContainer.addChild(this.rulerBackground); - this.rulerContainer.addChild(this.timeMarkers); - this.rulerContainer.addChild(this.timeLabels); - - // Make ruler interactive for click-to-seek - this.rulerContainer.eventMode = "static"; - this.rulerContainer.cursor = "pointer"; - - this.rulerContainer.on("pointerdown", this.onRulerPointerDown.bind(this)); - - this.getContainer().addChild(this.rulerContainer); - } - - private drawRulerBackground(): void { - this.rulerBackground.clear(); - const rulerWidth = this.calculateRulerWidth(); - - const rulerColor = this.theme?.timeline.ruler.background || 0x404040; - const borderColor = this.theme?.timeline.tracks.border || 0x606060; - - this.rulerBackground.rect(0, 0, rulerWidth, this.rulerHeight); - this.rulerBackground.fill(rulerColor); - this.rulerBackground.rect(0, this.rulerHeight - 1, rulerWidth, 1); - this.rulerBackground.fill(borderColor); - } - - private drawTimeMarkers(): void { - this.timeMarkers.clear(); - - const interval = this.getTimeInterval(); - const visibleDuration = this.getVisibleDuration(); - const dotColor = this.theme?.timeline.ruler.markers || 0x666666; - const dotY = this.rulerHeight * 0.5; - - // Determine number of dots between labels based on interval - let dotsPerInterval = 4; // Default for most intervals - if (interval === 10) dotsPerInterval = 9; - else if (interval === 30 || interval === 60) dotsPerInterval = 5; - - const dotSpacing = interval / (dotsPerInterval + 1); - - // Draw dots between time labels - for (let time = 0; time <= visibleDuration; time += interval) { - // Draw dots after this time marker - for (let i = 1; i <= dotsPerInterval; i += 1) { - const dotTime = time + i * dotSpacing; - if (dotTime <= visibleDuration) { - const x = dotTime * this.pixelsPerSecond; - this.timeMarkers.circle(x, dotY, 1.5); - this.timeMarkers.fill(dotColor); - } - } - } - } - - private drawTimeLabels(): void { - this.timeLabels.removeChildren(); - - const interval = this.getTimeInterval(); - const visibleDuration = this.getVisibleDuration(); - const textColor = this.theme?.timeline.ruler.text || 0xffffff; - - // Create label style - const labelStyle = { - fontSize: TIMELINE_CONSTANTS.RULER.LABEL_FONT_SIZE, - fill: textColor, - fontFamily: "Arial" - }; - - // Draw time labels at intervals - for (let seconds = 0; seconds <= visibleDuration; seconds += interval) { - const label = new PIXI.Text({ - text: this.formatTime(seconds), - style: labelStyle - }); - - // Position label - const x = seconds * this.pixelsPerSecond; - if (seconds === 0) { - label.anchor.set(0, 0.5); - label.x = x + TIMELINE_CONSTANTS.RULER.LABEL_PADDING_X; - } else { - label.anchor.set(0.5, 0.5); - label.x = x; - } - label.y = this.rulerHeight * 0.5; - - this.timeLabels.addChild(label); - } - } - - private onRulerPointerDown(event: PIXI.FederatedPointerEvent): void { - // Convert global to local coordinates within the ruler - const localPos = this.rulerContainer.toLocal(event.global); - const time = Math.max(0, localPos.x / this.pixelsPerSecond); - this.events.emit("ruler:seeked" as keyof TimelineFeatureEvents, { time }); - } - - public updateRuler(pixelsPerSecond: number, timelineDuration: number): void { - this.pixelsPerSecond = pixelsPerSecond; - this.timelineDuration = timelineDuration; - this.draw(); - } - - public update(_deltaTime: number, _elapsed: number): void { - // Ruler is static unless parameters change - } - - public draw(): void { - this.drawRulerBackground(); - this.drawTimeMarkers(); - this.drawTimeLabels(); - } - - public dispose(): void { - this.timeLabels.removeChildren(); - this.rulerContainer.removeChildren(); - this.events.clear("*"); - } - - private getViewportWidth(): number { - return this.getContainer().parent?.width || 800; - } - - private calculateRulerWidth(): number { - const calculatedWidth = this.timelineDuration * this.pixelsPerSecond; - return Math.max(calculatedWidth, this.getViewportWidth()); - } - - private getVisibleDuration(): number { - return Math.max(this.timelineDuration, this.getViewportWidth() / this.pixelsPerSecond); - } - - private getTimeInterval(): number { - // Choose appropriate time interval based on zoom level - const intervals = [1, 5, 10, 30, 60, 120, 300, 600]; - const minPixelSpacing = 80; - - for (const interval of intervals) { - const pixelSpacing = interval * this.pixelsPerSecond; - if (pixelSpacing >= minPixelSpacing) { - return interval; - } - } - - // If extremely zoomed out, use larger intervals - return Math.ceil(this.getVisibleDuration() / 10); - } - - private formatTime(seconds: number): string { - if (seconds === 0) return "0s"; - - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - - if (seconds < 60) { - return `${seconds}s`; - } - if (remainingSeconds === 0) { - return `${minutes}m`; - } - // Format as M:SS for times with seconds - const formattedSeconds = remainingSeconds.toString().padStart(2, "0"); - return `${minutes}:${formattedSeconds}`; - } -} diff --git a/src/components/timeline/features/scroll-manager.ts b/src/components/timeline/features/scroll-manager.ts deleted file mode 100644 index f1c62e0f..00000000 --- a/src/components/timeline/features/scroll-manager.ts +++ /dev/null @@ -1,233 +0,0 @@ -import { EventEmitter } from "@core/events/event-emitter"; - -import { TIMELINE_CONSTANTS, TimelineFeatureEvents, ScrollManagerOptions, TimelineReference } from "./types"; - -export class ScrollManager { - public events: EventEmitter; - private timeline: TimelineReference; - private abortController?: AbortController; - - // Scroll state - private scrollX = 0; - private scrollY = 0; - - constructor(options: ScrollManagerOptions) { - this.events = new EventEmitter(); - this.timeline = options.timeline; - } - - public async initialize(): Promise { - this.setupEventListeners(); - } - - private setupEventListeners(): void { - this.abortController = new AbortController(); - - // Get the PIXI canvas element - const { canvas } = this.timeline.getPixiApp(); - - // Add wheel event listener for scrolling - canvas.addEventListener("wheel", this.handleWheel.bind(this), { - passive: false, - signal: this.abortController.signal - }); - - // Keyboard navigation disabled for now - // document.addEventListener('keydown', this.handleKeydown.bind(this), { - // signal: this.abortController.signal - // }); - } - - private handleWheel(event: WheelEvent): void { - event.preventDefault(); - - // Check for Ctrl/Cmd key for zoom - if (event.ctrlKey || event.metaKey) { - this.handleZoom(event); - return; - } - - this.handleScroll(event); - } - - private handleZoom(event: WheelEvent): void { - // Handle zoom - const zoomDirection = event.deltaY > 0 ? "out" : "in"; - - // Get playhead time position - const playheadTime = this.timeline.getPlayheadTime(); - - // Get actual edit duration (not extended duration) - // The timeline.timeRange.endTime includes the 1.5x buffer, we need the actual duration - const actualEditDuration = this.timeline.getActualEditDuration(); - - // Perform zoom - if (zoomDirection === "in") { - this.timeline.zoomIn(); - } else { - this.timeline.zoomOut(); - } - - // Get new pixels per second after zoom - const newPixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50; - - // Calculate playhead position in pixels after zoom - const playheadXAfterZoom = playheadTime * newPixelsPerSecond; - - // Calculate viewport dimensions - const viewportWidth = this.timeline.getOptions().width || 800; - - // Use the extended duration for content width (includes buffer space) - const extendedDuration = this.timeline.timeRange.endTime; - const contentWidth = extendedDuration * newPixelsPerSecond; - - // But ensure playhead doesn't go beyond actual edit duration - const maxPlayheadX = actualEditDuration * newPixelsPerSecond; - - // Calculate the new scroll position to keep playhead in view - const newScrollX = this.calculateZoomScrollPosition({ - playheadXAfterZoom, - viewportWidth, - contentWidth, - maxPlayheadX, - actualEditDuration, - playheadTime - }); - - // Update scroll position - this.scrollX = newScrollX; - this.timeline.setScroll(this.scrollX, this.scrollY); - - // Emit zoom event with actual focus position - const actualFocusX = playheadXAfterZoom - newScrollX; - this.events.emit("zoom" as keyof TimelineFeatureEvents, { - pixelsPerSecond: newPixelsPerSecond, - focusX: actualFocusX, - focusTime: playheadTime - }); - } - - private handleScroll(event: WheelEvent): void { - let { deltaX } = event; - let { deltaY } = event; - - // Shift key converts vertical scroll to horizontal scroll - if (event.shiftKey) { - deltaX = deltaY; - deltaY = 0; - } - - // Different scroll speeds for horizontal vs vertical - const horizontalScrollSpeed = TIMELINE_CONSTANTS.SCROLL.HORIZONTAL_SPEED; - const verticalScrollSpeed = TIMELINE_CONSTANTS.SCROLL.VERTICAL_SPEED; - - // Update scroll position - this.scrollX += deltaX * horizontalScrollSpeed; - this.scrollY += deltaY * verticalScrollSpeed; - - // Apply bounds (prevent negative scrolling and limit based on content) - this.scrollX = this.clampScrollX(this.scrollX); - this.scrollY = this.clampScrollY(this.scrollY); - - // Update timeline viewport - this.timeline.setScroll(this.scrollX, this.scrollY); - - // Emit scroll event - this.events.emit("scroll" as keyof TimelineFeatureEvents, { x: this.scrollX, y: this.scrollY }); - } - - public setScroll(x: number, y: number): void { - this.scrollX = this.clampScrollX(x); - this.scrollY = this.clampScrollY(y); - this.timeline.setScroll(this.scrollX, this.scrollY); - this.events.emit("scroll" as keyof TimelineFeatureEvents, { x: this.scrollX, y: this.scrollY }); - } - - private clampScrollX(x: number): number { - // Calculate max scroll based on extended content width - const contentWidth = this.timeline.getExtendedTimelineWidth(); - const viewportWidth = this.timeline.getOptions().width || 0; - const maxScroll = Math.max(0, contentWidth - viewportWidth); - - return Math.max(0, Math.min(x, maxScroll)); - } - - private clampScrollY(y: number): number { - const layout = this.timeline.getLayout(); - const trackCount = this.timeline.getVisualTracks().length; - const height = this.timeline.getOptions().height || 0; - const maxScroll = Math.max(0, trackCount * layout.trackHeight - (height - layout.rulerHeight)); - return Math.max(0, Math.min(y, maxScroll)); - } - - public getScroll(): { x: number; y: number } { - return { x: this.scrollX, y: this.scrollY }; - } - - private calculateZoomScrollPosition(params: { - playheadXAfterZoom: number; - viewportWidth: number; - contentWidth: number; - maxPlayheadX: number; - actualEditDuration: number; - playheadTime: number; - }): number { - const { playheadXAfterZoom, viewportWidth, contentWidth, maxPlayheadX, actualEditDuration, playheadTime } = params; - - // Calculate ideal scroll to center playhead - const idealScrollX = playheadXAfterZoom - viewportWidth / 2; - - // Calculate scroll bounds - const maxScroll = Math.max(0, contentWidth - viewportWidth); - - // Determine the best scroll position - let newScrollX: number; - - // First, check if we're trying to show beyond the actual edit duration - const rightEdgeOfViewport = idealScrollX + viewportWidth; - const maxAllowedScroll = Math.max(0, maxPlayheadX - viewportWidth); - - if (contentWidth <= viewportWidth) { - // Content fits in viewport, no scroll needed - newScrollX = 0; - } else if (idealScrollX < 0) { - // Would scroll past start, align to start - newScrollX = 0; - } else if (rightEdgeOfViewport > maxPlayheadX && playheadTime <= actualEditDuration) { - // Would show beyond actual edit duration, limit scroll - // Position viewport so its right edge aligns with the actual edit end - newScrollX = Math.min(maxAllowedScroll, maxScroll); - } else if (idealScrollX > maxScroll) { - // Would scroll past end of extended timeline - newScrollX = maxScroll; - } else { - // Can center playhead normally - newScrollX = idealScrollX; - } - - // Double-check that playhead remains visible - const playheadInViewport = playheadXAfterZoom - newScrollX; - if (playheadInViewport < 0 || playheadInViewport > viewportWidth) { - // This shouldn't happen, but if it does, adjust to keep playhead visible - if (playheadXAfterZoom > contentWidth - viewportWidth) { - // Playhead near end, show it at right edge of viewport - newScrollX = Math.max(0, playheadXAfterZoom - viewportWidth + 50); // 50px padding from edge - } else { - // Show playhead with some padding from left edge - newScrollX = Math.max(0, playheadXAfterZoom - 50); - } - // Re-clamp to valid bounds - newScrollX = Math.max(0, Math.min(newScrollX, maxScroll)); - } - - return newScrollX; - } - - public dispose(): void { - if (this.abortController) { - this.abortController.abort(); - this.abortController = undefined; - } - this.events.clear("*"); - } -} diff --git a/src/components/timeline/features/types.ts b/src/components/timeline/features/types.ts deleted file mode 100644 index bfc6b690..00000000 --- a/src/components/timeline/features/types.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { TimelineTheme } from "../../../core/theme"; -import { VisualTrack } from "../visual/visual-track"; - -// Constants for timeline features -export const TIMELINE_CONSTANTS = { - RULER: { - DEFAULT_HEIGHT: 40, - MAJOR_MARKER_HEIGHT_RATIO: 0.8, - MINOR_MARKER_HEIGHT_RATIO: 0.6, - MINOR_MARKER_TENTH_HEIGHT_RATIO: 0.3, - LABEL_FONT_SIZE: 10, - LABEL_PADDING_X: 2, - LABEL_PADDING_Y: 2, - MINOR_MARKER_ZOOM_THRESHOLD: 20, - LABEL_INTERVAL_ZOOMED: 1, - LABEL_INTERVAL_DEFAULT: 5, - LABEL_ZOOM_THRESHOLD: 50 - }, - PLAYHEAD: { - LINE_WIDTH: 2, - HANDLE_WIDTH: 10, - HANDLE_HEIGHT: 10, - HANDLE_OFFSET_Y: -10, - HANDLE_OFFSET_X: 5 - }, - SCROLL: { - HORIZONTAL_SPEED: 2, - VERTICAL_SPEED: 0.5 - } -} as const; - -// Type-safe event definitions -export interface TimelineFeatureEvents { - "ruler:seeked": { time: number }; - "playhead:seeked": { time: number }; - "playhead:timeChanged": { time: number }; - scroll: { x: number; y: number }; - zoom: { pixelsPerSecond: number; focusX: number; focusTime: number }; -} - -// Parameter object interfaces -export interface RulerFeatureOptions { - pixelsPerSecond: number; - timelineDuration: number; - rulerHeight?: number; - theme?: TimelineTheme; -} - -export interface PlayheadFeatureOptions { - pixelsPerSecond: number; - timelineHeight: number; - theme?: TimelineTheme; -} - -// Interface to avoid circular dependency -export interface TimelineReference { - getTimeDisplay(): { updateTimeDisplay(): void }; - updateTime(time: number, emit?: boolean): void; - timeRange: { startTime: number; endTime: number }; - viewportHeight: number; - zoomLevelIndex: number; - getPixiApp(): { canvas: HTMLCanvasElement }; - setScroll(x: number, y: number): void; - getExtendedTimelineWidth(): number; - getOptions(): { width?: number; height?: number; pixelsPerSecond?: number }; - getLayout(): { trackHeight: number; rulerHeight: number }; - getVisualTracks(): VisualTrack[]; - zoomIn(): void; - zoomOut(): void; - getPlayheadTime(): number; - getActualEditDuration(): number; -} - -export interface ScrollManagerOptions { - timeline: TimelineReference; -} diff --git a/src/components/timeline/index.ts b/src/components/timeline/index.ts index c61ceb95..13e65e3a 100644 --- a/src/components/timeline/index.ts +++ b/src/components/timeline/index.ts @@ -1,21 +1,26 @@ -// Timeline v2 Core -export { Timeline } from "./timeline"; +/** Timeline Component */ -// Timeline v2 Visual Components -export { VisualClip } from "./visual/visual-clip"; -export { VisualTrack } from "./visual/visual-track"; +export { Timeline } from "@timeline/timeline"; -// Timeline v2 Types -export type { EditType, TimelineOptions, ClipConfig, ClipInfo, DropPosition } from "./types"; +export type { + TimelineOptions, + TimelineFeatures, + TimelineInteractionConfig, + ClipState, + TrackState, + ViewportState, + PlaybackState, + ClipInfo, + ClipRenderer +} from "@timeline/timeline.types"; -// Timeline v2 Features and Layout -export { RulerFeature, PlayheadFeature, ScrollManager } from "./features"; -export { TimelineLayout } from "./timeline-layout"; - -// Timeline v2 Interaction -export { InteractionController } from "./interaction"; - -// Note: Additional components will be exported as they are implemented -// export { DragTool } from './drag-tool'; -// export { SelectionTool } from './selection-tool'; -// export { ResizeTool } from './resize-tool'; +export { + DEFAULT_FEATURES, + DEFAULT_INTERACTION, + DEFAULT_PIXELS_PER_SECOND, + DEFAULT_TRACK_HEIGHT, + DEFAULT_TOOLBAR_HEIGHT, + DEFAULT_RULER_HEIGHT, + TRACK_HEIGHTS, + getTrackHeight +} from "@timeline/timeline.types"; diff --git a/src/components/timeline/interaction/collision-detector.ts b/src/components/timeline/interaction/collision-detector.ts deleted file mode 100644 index b74347f7..00000000 --- a/src/components/timeline/interaction/collision-detector.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { TimelineInterface } from "./types"; - -interface ClipBounds { - start: number; - end: number; -} - -export interface CollisionResult { - validTime: number; - wouldOverlap: boolean; -} - -export class CollisionDetector { - private timeline: TimelineInterface; - - constructor(timeline: TimelineInterface) { - this.timeline = timeline; - } - - public getValidDropPosition(time: number, duration: number, trackIndex: number, excludeClipIndex?: number): CollisionResult { - const track = this.timeline.getVisualTracks()[trackIndex]; - if (!track) return { validTime: time, wouldOverlap: false }; - - // Get all clips except the one being dragged - const otherClips = this.getOtherClipBounds(track, excludeClipIndex); - - // Find the first overlap - const dragEnd = time + duration; - const overlap = otherClips.find( - clip => !(dragEnd <= clip.start || time >= clip.end) // Not if completely before or after - ); - - if (!overlap) { - return { validTime: time, wouldOverlap: false }; - } - - // Find nearest valid position - const beforeGap = overlap.start - duration; - const afterGap = overlap.end; - - // Choose position closest to original intent - const validTime = Math.abs(time - beforeGap) < Math.abs(time - afterGap) && beforeGap >= 0 ? beforeGap : afterGap; - - // Recursively check if new position is valid - const recursiveCheck = this.getValidDropPosition(validTime, duration, trackIndex, excludeClipIndex); - - return { - validTime: recursiveCheck.validTime, - wouldOverlap: true - }; - } - - public checkOverlap(time: number, duration: number, trackIndex: number, excludeClipIndex?: number): boolean { - const track = this.timeline.getVisualTracks()[trackIndex]; - if (!track) return false; - - const otherClips = this.getOtherClipBounds(track, excludeClipIndex); - const clipEnd = time + duration; - - return otherClips.some(clip => !(clipEnd <= clip.start || time >= clip.end)); - } - - private getOtherClipBounds(track: import("./types").VisualTrack, excludeClipIndex?: number): ClipBounds[] { - return track - .getClips() - .map((clip, index) => ({ clip, index })) - .filter(({ index }: { index: number }) => index !== excludeClipIndex) - .map(({ clip }) => { - const config = clip.getClipConfig(); - if (!config) return null; - const { start } = config; - return { - start, - end: start + config.length - }; - }) - .filter((clip: ClipBounds | null): clip is ClipBounds => clip !== null) - .sort((a: ClipBounds, b: ClipBounds) => a.start - b.start); - } - - public findAvailableGaps(trackIndex: number, minDuration: number): Array<{ start: number; end: number }> { - const track = this.timeline.getVisualTracks()[trackIndex]; - if (!track) return []; - - const clips = this.getOtherClipBounds(track); - const gaps: Array<{ start: number; end: number }> = []; - - // Check gap before first clip - if (clips.length > 0 && clips[0].start >= minDuration) { - gaps.push({ start: 0, end: clips[0].start }); - } - - // Check gaps between clips - for (let i = 0; i < clips.length - 1; i += 1) { - const gap = clips[i + 1].start - clips[i].end; - if (gap >= minDuration) { - gaps.push({ start: clips[i].end, end: clips[i + 1].start }); - } - } - - // Note: We don't add a gap after the last clip as timeline extends infinitely - - return gaps; - } -} diff --git a/src/components/timeline/interaction/drag-handler.ts b/src/components/timeline/interaction/drag-handler.ts deleted file mode 100644 index 1efb17fb..00000000 --- a/src/components/timeline/interaction/drag-handler.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command"; -import { MoveClipCommand } from "@core/commands/move-clip-command"; -import * as PIXI from "pixi.js"; - -import { CollisionDetector } from "./collision-detector"; -import { SnapManager } from "./snap-manager"; -import { TimelineInterface, DragInfo, Point, ClipInfo, DropZone, InteractionThresholds, InteractionHandler } from "./types"; -import { VisualFeedbackManager } from "./visual-feedback-manager"; - -export class DragHandler implements InteractionHandler { - private timeline: TimelineInterface; - private thresholds: InteractionThresholds; - private snapManager: SnapManager; - private collisionDetector: CollisionDetector; - private visualFeedback: VisualFeedbackManager; - - private dragInfo: DragInfo | null = null; - private currentDropZone: DropZone | null = null; - - constructor( - timeline: TimelineInterface, - thresholds: InteractionThresholds, - snapManager: SnapManager, - collisionDetector: CollisionDetector, - visualFeedback: VisualFeedbackManager - ) { - this.timeline = timeline; - this.thresholds = thresholds; - this.snapManager = snapManager; - this.collisionDetector = collisionDetector; - this.visualFeedback = visualFeedback; - } - - public activate(): void { - // Handler activation if needed - } - - public deactivate(): void { - this.endDrag(); - } - - public canStartDrag(startPos: Point, currentPos: Point): boolean { - const distance = Math.sqrt((currentPos.x - startPos.x) ** 2 + (currentPos.y - startPos.y) ** 2); - - const { trackHeight } = this.timeline.getLayout(); - const threshold = trackHeight < 20 ? 2 : this.thresholds.drag.base; - - return distance > threshold; - } - - public startDrag(clipInfo: ClipInfo, event: PIXI.FederatedPointerEvent): boolean { - const clipData = this.timeline.getClipData(clipInfo.trackIndex, clipInfo.clipIndex); - if (!clipData) { - console.warn(`Clip data not found for track ${clipInfo.trackIndex}, clip ${clipInfo.clipIndex}`); - return false; - } - - // Calculate offset from clip start to mouse position - const localPos = this.timeline.getContainer().toLocal(event.global); - const layout = this.timeline.getLayout(); - const clipStart = clipData.start; - const clipStartX = layout.getXAtTime(clipStart); - // Use relative position within tracks area, not absolute position - const clipStartY = clipInfo.trackIndex * layout.trackHeight; - - this.dragInfo = { - trackIndex: clipInfo.trackIndex, - clipIndex: clipInfo.clipIndex, - startTime: clipStart, - offsetX: localPos.x - clipStartX, - offsetY: localPos.y - clipStartY - }; - - // Set cursor - this.timeline.getPixiApp().canvas.style.cursor = "grabbing"; - - // Emit drag started event - this.timeline.getEdit().events.emit("drag:started", this.dragInfo); - - return true; - } - - public updateDrag(event: PIXI.FederatedPointerEvent): void { - if (!this.dragInfo) return; - - const position = this.calculateDragPosition(event); - const dropZone = this.detectDropZone(position.y); - - if (dropZone) { - this.handleDropZonePreview(dropZone, position); - } else { - this.handleNormalDragPreview(position); - } - - this.emitDragUpdate(position, dropZone); - } - - public completeDrag(event: PIXI.FederatedPointerEvent): void { - if (!this.dragInfo) return; - - const dragInfo = { ...this.dragInfo }; - const position = this.calculateDragPosition(event); - const dropZone = this.detectDropZone(position.y); - - // End drag to ensure visual cleanup happens first - this.endDrag(); - - if (dropZone) { - this.executeDropZoneMove(dropZone, dragInfo, position); - } else { - this.executeNormalMove(dragInfo, position); - } - } - - private calculateDragPosition(event: PIXI.FederatedPointerEvent): { x: number; y: number; time: number; track: number; ghostY: number } { - if (!this.dragInfo) throw new Error("No drag info available"); - - const localPos = this.timeline.getContainer().toLocal(event.global); - const layout = this.timeline.getLayout(); - - const rawTime = Math.max(0, layout.getTimeAtX(localPos.x - this.dragInfo.offsetX)); - const dragY = localPos.y - this.dragInfo.offsetY; - - // Calculate which track the clip center is over - const clipCenterY = dragY + layout.trackHeight / 2; - const dragTrack = Math.max(0, Math.floor(clipCenterY / layout.trackHeight)); - - // Ensure within bounds - const maxTrackIndex = this.timeline.getVisualTracks().length - 1; - const boundedTrack = Math.max(0, Math.min(maxTrackIndex, dragTrack)); - - return { - x: localPos.x, - y: localPos.y + layout.viewportY, // For drop zone detection - time: rawTime, - track: boundedTrack, - ghostY: dragY // Free Y position for ghost - }; - } - - private detectDropZone(y: number): DropZone | null { - const layout = this.timeline.getLayout(); - const tracks = this.timeline.getVisualTracks(); - const threshold = layout.trackHeight * this.thresholds.dropZone.ratio; - - // Check each potential insertion point - for (let i = 0; i <= tracks.length; i += 1) { - const boundaryY = layout.tracksY + i * layout.trackHeight; - if (Math.abs(y - boundaryY) < threshold) { - let type: "above" | "below" | "between"; - if (i === 0) { - type = "above"; - } else if (i === tracks.length) { - type = "below"; - } else { - type = "between"; - } - return { - type, - position: i - }; - } - } - - return null; - } - - private handleDropZonePreview(dropZone: DropZone, _position: { time: number }): void { - if (!this.currentDropZone || this.currentDropZone.type !== dropZone.type || this.currentDropZone.position !== dropZone.position) { - this.currentDropZone = dropZone; - this.visualFeedback.showDropZone(dropZone); - } - this.timeline.hideDragGhost(); - this.visualFeedback.hideSnapGuidelines(); - this.visualFeedback.hideTargetTrack(); - } - - private handleNormalDragPreview(position: { time: number; track: number; ghostY?: number }): void { - if (!this.dragInfo) return; - - // Hide drop zone if showing - if (this.currentDropZone) { - this.visualFeedback.hideDropZone(); - this.currentDropZone = null; - } - - // Get clip duration for calculations - const clipConfig = this.timeline.getClipData(this.dragInfo.trackIndex, this.dragInfo.clipIndex); - if (!clipConfig) return; - const clipDuration = clipConfig.length; - - // Calculate final position with snapping and collision prevention - const excludeIndex = position.track === this.dragInfo.trackIndex ? this.dragInfo.clipIndex : undefined; - const finalTime = this.calculateFinalPosition(position.time, position.track, clipDuration, excludeIndex); - - // Update snap guidelines - const alignments = this.snapManager.findAlignedElements(finalTime, clipDuration, position.track, excludeIndex); - if (alignments.length > 0) { - this.visualFeedback.showSnapGuidelines(alignments); - } else { - this.visualFeedback.hideSnapGuidelines(); - } - - // Show visual indicator for target track - this.visualFeedback.showTargetTrack(position.track); - - // Show drag preview with free Y position - this.timeline.showDragGhost(position.track, finalTime, position.ghostY); - } - - private calculateFinalPosition(time: number, track: number, clipDuration: number, excludeIndex?: number, originalTrackIndex?: number): number { - const snapResult = this.snapManager.calculateSnapPosition(time, track, clipDuration, excludeIndex); - - const sourceTrack = originalTrackIndex ?? this.dragInfo?.trackIndex; - if (sourceTrack !== undefined && track === sourceTrack) { - return Math.max(0, snapResult.time); - } - - const validPosition = this.collisionDetector.getValidDropPosition(snapResult.time, clipDuration, track, excludeIndex); - - return validPosition.validTime; - } - - private emitDragUpdate(position: { time: number; track: number }, dropZone: DropZone | null): void { - if (!this.dragInfo) return; - - const clipConfig = this.timeline.getClipData(this.dragInfo.trackIndex, this.dragInfo.clipIndex); - if (!clipConfig) return; - const clipDuration = clipConfig.length; - - const finalTime = dropZone - ? position.time - : this.calculateFinalPosition( - position.time, - position.track, - clipDuration, - position.track === this.dragInfo.trackIndex ? this.dragInfo.clipIndex : undefined - ); - - this.timeline.getEdit().events.emit("drag:moved", { - ...this.dragInfo, - currentTime: finalTime, - currentTrack: dropZone ? -1 : position.track - }); - } - - private executeDropZoneMove(dropZone: DropZone, dragInfo: DragInfo, position: { time: number }): void { - const command = new CreateTrackAndMoveClipCommand(dropZone.position, dragInfo.trackIndex, dragInfo.clipIndex, position.time); - this.timeline.getEdit().executeEditCommand(command); - } - - private executeNormalMove(dragInfo: DragInfo, position: { time: number; track: number }): void { - const clipConfig = this.timeline.getClipData(dragInfo.trackIndex, dragInfo.clipIndex); - if (!clipConfig) return; - - const clipDuration = clipConfig.length; - const excludeIndex = position.track === dragInfo.trackIndex ? dragInfo.clipIndex : undefined; - // Pass dragInfo.trackIndex as originalTrackIndex since this.dragInfo is cleared before this call - const finalTime = this.calculateFinalPosition(position.time, position.track, clipDuration, excludeIndex, dragInfo.trackIndex); - - // Only execute if position changed - const hasChanged = position.track !== dragInfo.trackIndex || Math.abs(finalTime - dragInfo.startTime) > 0.01; - - if (hasChanged) { - const command = new MoveClipCommand(dragInfo.trackIndex, dragInfo.clipIndex, position.track, finalTime); - this.timeline.getEdit().executeEditCommand(command); - } - } - - private endDrag(): void { - this.dragInfo = null; - this.currentDropZone = null; - - this.visualFeedback.hideAll(); - this.timeline.getPixiApp().canvas.style.cursor = "default"; - this.timeline.getEdit().events.emit("drag:ended", {}); - } - - public getDragInfo(): DragInfo | null { - return this.dragInfo; - } - - public dispose(): void { - this.endDrag(); - } -} diff --git a/src/components/timeline/interaction/index.ts b/src/components/timeline/interaction/index.ts deleted file mode 100644 index b52bb310..00000000 --- a/src/components/timeline/interaction/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -export { InteractionController } from "./interaction-controller"; -export { DragHandler } from "./drag-handler"; -export { ResizeHandler } from "./resize-handler"; -export { SnapManager } from "./snap-manager"; -export { CollisionDetector } from "./collision-detector"; -export { VisualFeedbackManager } from "./visual-feedback-manager"; - -export type { - InteractionState, - Point, - ClipInfo, - DragInfo, - ResizeInfo, - DropZone, - SnapPoint, - SnapResult, - AlignmentInfo, - InteractionThresholds, - InteractionEvents, - InteractionHandler, - TimelineInterface -} from "./types"; diff --git a/src/components/timeline/interaction/interaction-controller.ts b/src/components/timeline/interaction/interaction-controller.ts index 91fed2de..24c554d1 100644 --- a/src/components/timeline/interaction/interaction-controller.ts +++ b/src/components/timeline/interaction/interaction-controller.ts @@ -1,270 +1,931 @@ -import * as PIXI from "pixi.js"; +import { CreateTrackAndMoveClipCommand } from "@core/commands/create-track-and-move-clip-command"; +import { MoveClipCommand } from "@core/commands/move-clip-command"; +import { MoveClipWithPushCommand } from "@core/commands/move-clip-with-push-command"; +import { ResizeClipCommand } from "@core/commands/resize-clip-command"; +import type { Edit } from "@core/edit"; +import type { ClipState, TimelineInteractionConfig } from "@timeline/timeline.types"; +import { getTrackHeight } from "@timeline/timeline.types"; + +import { TimelineStateManager } from "../core/state/timeline-state"; + +/** Point coordinates */ +interface Point { + x: number; + y: number; +} + +/** Clip reference */ +interface ClipRef { + trackIndex: number; + clipIndex: number; +} -import { CollisionDetector } from "./collision-detector"; -import { DragHandler } from "./drag-handler"; -import { ResizeHandler } from "./resize-handler"; -import { SnapManager } from "./snap-manager"; -import { TimelineInterface, InteractionState, ClipInfo, InteractionThresholds } from "./types"; -import { VisualFeedbackManager } from "./visual-feedback-manager"; +/** Snap point for alignment */ +interface SnapPoint { + time: number; + type: "clip-start" | "clip-end" | "playhead"; +} + +/** Collision resolution result */ +interface CollisionResult { + newStartTime: number; + pushOffset: number; +} +/** Drag target - either an existing track or an insertion point between tracks */ +type DragTarget = { type: "track"; trackIndex: number } | { type: "insert"; insertionIndex: number }; + +/** Interaction state machine */ +type InteractionState = + | { type: "idle" } + | { type: "pending"; startPoint: Point; clipRef: ClipRef; originalTime: number } + | { + type: "dragging"; + clipRef: ClipRef; + clipElement: HTMLElement; // Original clip element (follows mouse) + ghost: HTMLElement; // Drop preview (shows snap target) + startTime: number; + originalTrack: number; + dragTarget: DragTarget; + dragOffsetX: number; // Pixel offset from clip left edge to mouse + dragOffsetY: number; // Pixel offset from clip top to mouse + originalStyles: { position: string; left: string; top: string; zIndex: string; pointerEvents: string }; + draggedClipLength: number; // Length of the clip being dragged + collisionResult: CollisionResult; // Current collision resolution + } + | { type: "resizing"; clipRef: ClipRef; edge: "left" | "right"; originalStart: number; originalLength: number }; + +/** Configuration defaults */ +const DEFAULT_CONFIG: Required = { + dragThreshold: 3, + snapThreshold: 10, + resizeZone: 12 +}; + +/** Controller for timeline interactions (drag, resize, selection) */ export class InteractionController { - private timeline: TimelineInterface; - /** @internal */ private state: InteractionState = { type: "idle" }; - private abortController?: AbortController; - - // Handlers - private dragHandler: DragHandler; - private resizeHandler: ResizeHandler; - private snapManager: SnapManager; - private collisionDetector: CollisionDetector; - private visualFeedback: VisualFeedbackManager; - - // Default thresholds - private thresholds: InteractionThresholds = { - drag: { - base: 3, - small: 2 - }, - resize: { - min: 12, - max: 20, - ratio: 0.4 - }, - dropZone: { - ratio: 0.15 - }, - snap: { - pixels: 10, - time: 0.1 - } - }; + private readonly config: Required; + private snapPoints: SnapPoint[] = []; + + // DOM references + private readonly feedbackLayer: HTMLElement; + private snapLine: HTMLElement | null = null; + private dragGhost: HTMLElement | null = null; + private dropZone: HTMLElement | null = null; + private dragTimeTooltip: HTMLElement | null = null; + + // Bound handlers for cleanup + private readonly handlePointerMove: (e: PointerEvent) => void; + private readonly handlePointerUp: (e: PointerEvent) => void; + + constructor( + private readonly edit: Edit, + private readonly stateManager: TimelineStateManager, + private readonly tracksContainer: HTMLElement, + feedbackLayer: HTMLElement, + config?: Partial + ) { + this.feedbackLayer = feedbackLayer; + this.config = { ...DEFAULT_CONFIG, ...config }; + + // Bind handlers + this.handlePointerMove = this.onPointerMove.bind(this); + this.handlePointerUp = this.onPointerUp.bind(this); - constructor(timeline: TimelineInterface, thresholds?: Partial) { - this.timeline = timeline; + this.setupEventListeners(); + } - // Deep merge custom thresholds using structuredClone - if (thresholds) { - const merged = structuredClone(this.thresholds); + private setupEventListeners(): void { + this.tracksContainer.addEventListener("pointerdown", this.onPointerDown.bind(this)); + document.addEventListener("pointermove", this.handlePointerMove); + document.addEventListener("pointerup", this.handlePointerUp); + } - // Manually merge each nested object - if (thresholds.drag) { - Object.assign(merged.drag, thresholds.drag); - } - if (thresholds.resize) { - Object.assign(merged.resize, thresholds.resize); - } - if (thresholds.dropZone) { - Object.assign(merged.dropZone, thresholds.dropZone); - } - if (thresholds.snap) { - Object.assign(merged.snap, thresholds.snap); - } + private onPointerDown(e: PointerEvent): void { + const target = e.target as HTMLElement; - this.thresholds = merged; + // Find clip element + const clipEl = target.closest(".ss-clip") as HTMLElement; + if (!clipEl) { + // Click on empty space - clear selection + this.stateManager.clearSelection(); + return; } - // Initialize managers - this.snapManager = new SnapManager(timeline, this.thresholds); - this.collisionDetector = new CollisionDetector(timeline); - this.visualFeedback = new VisualFeedbackManager(timeline); - - // Initialize handlers - this.dragHandler = new DragHandler(timeline, this.thresholds, this.snapManager, this.collisionDetector, this.visualFeedback); + const trackIndex = parseInt(clipEl.dataset["trackIndex"] || "0", 10); + const clipIndex = parseInt(clipEl.dataset["clipIndex"] || "0", 10); - this.resizeHandler = new ResizeHandler(timeline, this.thresholds); - } - - public activate(): void { - this.abortController = new AbortController(); - this.setupEventListeners(); - this.dragHandler.activate(); - this.resizeHandler.activate(); - } - - public deactivate(): void { - if (this.abortController) { - this.abortController.abort(); - this.abortController = undefined; + // Check if clicking on resize handle + if (target.classList.contains("ss-clip-resize-handle")) { + const edge = target.classList.contains("left") ? "left" : "right"; + this.startResize(e, { trackIndex, clipIndex }, edge); + return; } - this.resetState(); - this.dragHandler.deactivate(); - this.resizeHandler.deactivate(); - } - /** @internal */ - private setupEventListeners(): void { - const pixiApp = this.timeline.getPixiApp(); + // Start potential drag + this.startPending(e, { trackIndex, clipIndex }); + } - pixiApp.stage.interactive = true; + private startPending(e: PointerEvent, clipRef: ClipRef): void { + const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + if (!clip) return; - pixiApp.stage.on("pointerdown", this.handlePointerDown.bind(this), { - signal: this.abortController?.signal - }); - pixiApp.stage.on("pointermove", this.handlePointerMove.bind(this), { - signal: this.abortController?.signal - }); - pixiApp.stage.on("pointerup", this.handlePointerUp.bind(this), { - signal: this.abortController?.signal - }); - pixiApp.stage.on("pointerupoutside", this.handlePointerUp.bind(this), { - signal: this.abortController?.signal - }); + this.state = { + type: "pending", + startPoint: { x: e.clientX, y: e.clientY }, + clipRef, + originalTime: clip.config.start + }; } - /** @internal */ - private handlePointerDown(event: PIXI.FederatedPointerEvent): void { - const target = event.target as PIXI.Container; - - // Check if clicked on a clip - if (target.label) { - const clipInfo = this.parseClipLabel(target.label); - if (clipInfo) { - // Check if clicking on resize edge - if (this.resizeHandler.isOnClipRightEdge(clipInfo, event)) { - if (this.resizeHandler.startResize(clipInfo, event)) { - const resizeInfo = this.resizeHandler.getResizeInfo(); - if (resizeInfo) { - this.state = { type: "resizing", resizeInfo }; - } - } - return; - } + private startResize(e: PointerEvent, clipRef: ClipRef, edge: "left" | "right"): void { + const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + if (!clip) return; - // Start selection (potential drag) - this.state = { - type: "selecting", - startPos: { x: event.global.x, y: event.global.y }, - clipInfo - }; + this.state = { + type: "resizing", + clipRef, + edge, + originalStart: clip.config.start, + originalLength: clip.config.length + }; - // Set cursor to indicate draggable - this.timeline.getPixiApp().canvas.style.cursor = "grab"; - return; - } - } + this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "resizing"); + this.buildSnapPoints(clipRef); - // Clicked on empty space - clear selection - this.timeline.getEdit().clearSelection(); + e.preventDefault(); } - /** @internal */ - private handlePointerMove(event: PIXI.FederatedPointerEvent): void { + private onPointerMove(e: PointerEvent): void { switch (this.state.type) { - case "selecting": - this.handleSelectingMove(event); + case "pending": + this.handlePendingMove(e); break; case "dragging": - this.timeline.getPixiApp().canvas.style.cursor = "grabbing"; - this.dragHandler.updateDrag(event); + this.handleDragMove(e); break; case "resizing": - this.timeline.getPixiApp().canvas.style.cursor = "ew-resize"; - this.resizeHandler.updateResize(event); - break; - case "idle": - this.updateCursorForPosition(event); + this.handleResizeMove(e); break; default: - // No action needed for other states break; } } - /** @internal */ - private handlePointerUp(event: PIXI.FederatedPointerEvent): void { + private handlePendingMove(e: PointerEvent): void { + if (this.state.type !== "pending") return; + + const dx = e.clientX - this.state.startPoint.x; + const dy = e.clientY - this.state.startPoint.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance >= this.config.dragThreshold) { + this.transitionToDragging(e); + } + } + + private transitionToDragging(e: PointerEvent): void { + if (this.state.type !== "pending") return; + + const { clipRef, originalTime } = this.state; + const clip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + if (!clip) { + this.state = { type: "idle" }; + return; + } + + // Find the actual clip DOM element + const clipElement = this.tracksContainer.querySelector( + `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` + ) as HTMLElement | null; + if (!clipElement) { + this.state = { type: "idle" }; + return; + } + + // Store original styles for restoration later + const originalStyles = { + position: clipElement.style.position, + left: clipElement.style.left, + top: clipElement.style.top, + zIndex: clipElement.style.zIndex, + pointerEvents: clipElement.style.pointerEvents + }; + + // Get clip element's current screen position + const clipRect = clipElement.getBoundingClientRect(); + + // Calculate drag offsets - distance from mouse to clip's top-left corner + const dragOffsetX = e.clientX - clipRect.left; + const dragOffsetY = e.clientY - clipRect.top; + + // Make clip element follow mouse with position: fixed + clipElement.style.position = "fixed"; + clipElement.style.left = `${clipRect.left}px`; + clipElement.style.top = `${clipRect.top}px`; + clipElement.style.width = `${clipRect.width}px`; + clipElement.style.height = `${clipRect.height}px`; + clipElement.style.zIndex = "1000"; + clipElement.style.pointerEvents = "none"; + clipElement.classList.add("dragging"); + + // Create ghost as drop preview (shows where clip will land) + const ghost = this.createDragGhost(clip, clipRef.trackIndex); + this.feedbackLayer.appendChild(ghost); + + const pps = this.stateManager.getViewport().pixelsPerSecond; + + this.state = { + type: "dragging", + clipRef, + clipElement, + ghost, + startTime: originalTime, + originalTrack: clipRef.trackIndex, + dragTarget: { type: "track", trackIndex: clipRef.trackIndex }, + dragOffsetX, + dragOffsetY, + originalStyles, + draggedClipLength: clip.config.length, + collisionResult: { newStartTime: originalTime, pushOffset: 0 } + }; + + // Position ghost at current clip position initially + const tracksOffset = this.getTracksOffsetInFeedbackLayer(); + ghost.style.left = `${clip.config.start * pps}px`; + ghost.style.top = `${this.getTrackYPosition(clipRef.trackIndex) + 4 + tracksOffset}px`; + + this.buildSnapPoints(clipRef); + } + + private createDragGhost(clip: ClipState, trackIndex: number): HTMLElement { + const ghost = document.createElement("div"); + ghost.className = "ss-drag-ghost ss-clip"; + const clipAssetType = clip.config.asset?.type || "unknown"; + ghost.dataset["assetType"] = clipAssetType; + + const pps = this.stateManager.getViewport().pixelsPerSecond; + const width = clip.config.length * pps; + const track = this.stateManager.getTracks()[trackIndex]; + const trackAssetType = track?.primaryAssetType ?? clipAssetType; + const trackHeight = getTrackHeight(trackAssetType); + + ghost.style.width = `${width}px`; + ghost.style.height = `${trackHeight - 8}px`; // Track height - padding + ghost.style.position = "absolute"; + ghost.style.pointerEvents = "none"; + ghost.style.opacity = "0.8"; + + return ghost; + } + + private handleDragMove(e: PointerEvent): void { + if (this.state.type !== "dragging") return; + + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const scrollY = this.tracksContainer.scrollTop; + const pps = this.stateManager.getViewport().pixelsPerSecond; + + // Move the actual clip element freely with the mouse (position: fixed) + this.state.clipElement.style.left = `${e.clientX - this.state.dragOffsetX}px`; + this.state.clipElement.style.top = `${e.clientY - this.state.dragOffsetY}px`; + + // Mouse position in content space (for calculating target position) + const mouseX = e.clientX - rect.left + scrollX; + const mouseY = e.clientY - rect.top + scrollY; + + // Calculate clip position from mouse (accounting for drag offset in content space) + const clipX = mouseX - this.state.dragOffsetX; + let clipTime = Math.max(0, clipX / pps); + + // Determine drag target based on mouse Y + const dragTarget = this.getDragTargetAtY(mouseY); + this.state.dragTarget = dragTarget; + + // Apply snapping to clip time + const snappedTime = this.applySnap(clipTime); + if (snappedTime !== null) { + clipTime = snappedTime; + this.showSnapLine(clipTime); + } else { + this.hideSnapLine(); + } + + // Apply collision detection for track targets (skip for luma assets - they overlay) + if (dragTarget.type === "track") { + const draggedClip = this.stateManager.getClipAt(this.state.clipRef.trackIndex, this.state.clipRef.clipIndex); + const draggedAssetType = draggedClip?.config.asset?.type; + + if (draggedAssetType === "luma") { + // Luma assets can overlay other clips - skip collision detection + this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0 }; + } else { + const collisionResult = this.resolveClipCollision(dragTarget.trackIndex, clipTime, this.state.draggedClipLength, this.state.clipRef); + clipTime = collisionResult.newStartTime; + this.state.collisionResult = collisionResult; + } + } else { + // No collision for insertion targets (new track) + this.state.collisionResult = { newStartTime: clipTime, pushOffset: 0 }; + } + + // Get offset for positioning in feedback layer (accounts for ruler height) + const tracksOffset = this.getTracksOffsetInFeedbackLayer(); + + // Position ghost and drop zone based on target type + if (dragTarget.type === "track") { + // Show ghost for track targets + this.state.ghost.style.display = "block"; + const tracks = this.stateManager.getTracks(); + const targetTrackY = this.getTrackYPosition(dragTarget.trackIndex) + 4; // +4 for clip padding + const targetTrack = tracks[dragTarget.trackIndex]; + const targetHeight = getTrackHeight(targetTrack?.primaryAssetType ?? "default") - 8; + + this.state.ghost.style.left = `${clipTime * pps}px`; + this.state.ghost.style.top = `${targetTrackY + tracksOffset}px`; + this.state.ghost.style.height = `${targetHeight}px`; + + this.showDragTimeTooltip(clipTime, clipTime * pps, targetTrackY + tracksOffset); + this.hideDropZone(); + } else { + // Hide ghost for insertion targets - drop zone indicator is sufficient + this.state.ghost.style.display = "none"; + this.showDropZone(dragTarget.insertionIndex); + } + } + + private handleResizeMove(e: PointerEvent): void { + if (this.state.type !== "resizing") return; + + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const pps = this.stateManager.getViewport().pixelsPerSecond; + + const x = e.clientX - rect.left + scrollX; + let time = Math.max(0, x / pps); + + // Apply snapping + const snappedTime = this.applySnap(time); + if (snappedTime !== null) { + time = snappedTime; + this.showSnapLine(time); + } else { + this.hideSnapLine(); + } + + // Calculate new dimensions based on edge + const { clipRef, edge, originalStart, originalLength } = this.state; + + if (edge === "left") { + // Resize from left edge (keep end fixed, change start and length) + const originalEnd = originalStart + originalLength; + const newStart = Math.max(0, Math.min(time, originalEnd - 0.1)); + const newLength = originalEnd - newStart; + + const clipEl = this.tracksContainer.querySelector( + `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` + ) as HTMLElement; + if (clipEl) { + clipEl.style.setProperty("--clip-start", String(newStart)); + clipEl.style.setProperty("--clip-length", String(newLength)); + } + this.showDragTimeTooltip(newStart, e.clientX - rect.left, e.clientY - rect.top); + } else { + // Resize from right edge + const newLength = Math.max(0.1, time - originalStart); + + const clipEl = this.tracksContainer.querySelector( + `[data-track-index="${clipRef.trackIndex}"][data-clip-index="${clipRef.clipIndex}"]` + ) as HTMLElement; + if (clipEl) { + clipEl.style.setProperty("--clip-length", String(newLength)); + } + this.showDragTimeTooltip(originalStart + newLength, e.clientX - rect.left, e.clientY - rect.top); + } + } + + private onPointerUp(e: PointerEvent): void { switch (this.state.type) { - case "selecting": - // Complete selection - this.timeline.getEdit().selectClip(this.state.clipInfo.trackIndex, this.state.clipInfo.clipIndex); + case "pending": + // Was just a click, selection already handled + this.state = { type: "idle" }; break; case "dragging": - this.dragHandler.completeDrag(event); + this.completeDrag(e); break; case "resizing": - this.resizeHandler.completeResize(event); + this.completeResize(e); break; default: - // No action needed for other states break; } + } + + private completeDrag(_e: PointerEvent): void { + if (this.state.type !== "dragging") return; + + const { clipRef, clipElement, ghost, startTime, originalTrack, dragTarget, originalStyles, collisionResult } = this.state; + + // Restore clip element to normal flow before executing command + clipElement.style.position = originalStyles.position; + clipElement.style.left = originalStyles.left; + clipElement.style.top = originalStyles.top; + clipElement.style.zIndex = originalStyles.zIndex; + clipElement.style.pointerEvents = originalStyles.pointerEvents; + clipElement.style.width = ""; + clipElement.style.height = ""; + clipElement.classList.remove("dragging"); + + // Get dragged clip's asset type + const draggedClip = this.stateManager.getClipAt(clipRef.trackIndex, clipRef.clipIndex); + const draggedAssetType = draggedClip?.config.asset?.type; + + // Use the collision-resolved time from the last drag move + let newTime = collisionResult.newStartTime; + + // Handle luma clip drop - must attach to a content clip + if (draggedAssetType === "luma" && dragTarget.type === "track") { + const targetContentClip = this.findContentClipAtPosition(dragTarget.trackIndex, newTime); + + if (!targetContentClip) { + // No valid target content clip - cancel drop + ghost.remove(); + this.hideSnapLine(); + this.hideDropZone(); + this.hideDragTimeTooltip(); + this.state = { type: "idle" }; + return; + } + + // Snap luma timing to content clip + newTime = targetContentClip.config.start; + + // Move luma to target position + if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { + const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(command); + } + + // Register attachment in state manager + this.stateManager.attachLuma( + targetContentClip.trackIndex, + targetContentClip.clipIndex, + dragTarget.trackIndex, + clipRef.clipIndex + ); + + // Cleanup and return early + ghost.remove(); + this.hideSnapLine(); + this.hideDropZone(); + this.hideDragTimeTooltip(); + this.state = { type: "idle" }; + return; + } + + // Get attached luma Player reference BEFORE any move (stable across index changes) + const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); + + // Execute appropriate command based on drag target (non-luma clips) + if (dragTarget.type === "insert") { + // Create new track and move clip to it + const command = new CreateTrackAndMoveClipCommand(dragTarget.insertionIndex, originalTrack, clipRef.clipIndex, newTime); + this.edit.executeEditCommand(command); + + // Move attached luma - get fresh indices AFTER content move + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.insertionIndex, newTime); + this.edit.executeEditCommand(lumaCommand); + } + } + } else if (collisionResult.pushOffset > 0) { + // Need to push clips forward - use MoveClipWithPushCommand + const command = new MoveClipWithPushCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime, collisionResult.pushOffset); + this.edit.executeEditCommand(command); + + // Move attached luma - get fresh indices AFTER content move + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(lumaCommand); + } + } + } else if (newTime !== startTime || dragTarget.trackIndex !== originalTrack) { + // Simple move without push + const command = new MoveClipCommand(originalTrack, clipRef.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(command); + + // Move attached luma - get fresh indices AFTER content move + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, dragTarget.trackIndex, newTime); + this.edit.executeEditCommand(lumaCommand); + } + } + } + + // Cleanup + ghost.remove(); + this.hideSnapLine(); + this.hideDropZone(); + this.hideDragTimeTooltip(); + this.state = { type: "idle" }; + } + + private completeResize(e: PointerEvent): void { + if (this.state.type !== "resizing") return; + + const { clipRef, edge, originalStart, originalLength } = this.state; + + const rect = this.tracksContainer.getBoundingClientRect(); + const scrollX = this.tracksContainer.scrollLeft; + const pps = this.stateManager.getViewport().pixelsPerSecond; + + const x = e.clientX - rect.left + scrollX; + let time = Math.max(0, x / pps); + + // Apply snapping + const snappedTime = this.applySnap(time); + if (snappedTime !== null) { + time = snappedTime; + } + + // Get attached luma Player reference BEFORE changes (stable across index changes) + const lumaPlayer = this.stateManager.getAttachedLumaPlayer(clipRef.trackIndex, clipRef.clipIndex); + + if (edge === "left") { + // Resize from left edge (keep end fixed, change start and length) + const originalEnd = originalStart + originalLength; + const newStart = Math.max(0, Math.min(time, originalEnd - 0.1)); + const newLength = originalEnd - newStart; + + if (newStart !== originalStart || newLength !== originalLength) { + // Move clip to new start position + if (newStart !== originalStart) { + const moveCommand = new MoveClipCommand(clipRef.trackIndex, clipRef.clipIndex, clipRef.trackIndex, newStart); + this.edit.executeEditCommand(moveCommand); + } + + // Resize clip to new length + if (newLength !== originalLength) { + const resizeCommand = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); + this.edit.executeEditCommand(resizeCommand); + } + + // Also update attached luma clip + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + if (newStart !== originalStart) { + const lumaMoveCommand = new MoveClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, lumaIndices.trackIndex, newStart); + this.edit.executeEditCommand(lumaMoveCommand); + } + if (newLength !== originalLength) { + const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); + this.edit.executeEditCommand(lumaResizeCommand); + } + } + } + } + } else { + // Resize from right edge (keep start fixed, change length) + const newLength = Math.max(0.1, time - originalStart); + + if (newLength !== originalLength) { + const command = new ResizeClipCommand(clipRef.trackIndex, clipRef.clipIndex, newLength); + this.edit.executeEditCommand(command); + + // Also resize attached luma to match + if (lumaPlayer) { + const lumaIndices = this.edit.findClipIndices(lumaPlayer); + if (lumaIndices) { + const lumaResizeCommand = new ResizeClipCommand(lumaIndices.trackIndex, lumaIndices.clipIndex, newLength); + this.edit.executeEditCommand(lumaResizeCommand); + } + } + } + } + + // Cleanup + this.hideSnapLine(); + this.hideDragTimeTooltip(); + this.stateManager.setClipVisualState(clipRef.trackIndex, clipRef.clipIndex, "normal"); + this.state = { type: "idle" }; + } + + /** Default result when no collision detected */ + private static readonly NO_COLLISION: CollisionResult = { + newStartTime: 0, + pushOffset: 0 + }; + + /** Get sorted clips on a track, excluding the dragged clip */ + private getTrackClips(trackIndex: number, excludeClip: ClipRef): ClipState[] { + const track = this.stateManager.getTracks()[trackIndex]; + if (!track) return []; + + return track.clips + .filter(c => !(c.trackIndex === excludeClip.trackIndex && c.clipIndex === excludeClip.clipIndex)) + .sort((a, b) => a.config.start - b.config.start); + } + + /** Find which clip (if any) the dragged clip overlaps */ + private findOverlappingClip( + clips: ClipState[], + desiredStart: number, + clipLength: number + ): { clip: ClipState; index: number } | null { + const desiredEnd = desiredStart + clipLength; + for (let i = 0; i < clips.length; i += 1) { + const clip = clips[i]; + const clipStart = clip.config.start; + const clipEnd = clipStart + clip.config.length; + if (desiredStart < clipEnd && desiredEnd > clipStart) { + return { clip, index: i }; + } + } + return null; + } + + /** Resolve snap position when dragged clip overlaps another (uses clip centers for direction) */ + private resolveOverlapSnap( + targetClip: ClipState, + targetIndex: number, + desiredStart: number, + clipLength: number, + clips: ClipState[] + ): CollisionResult { + const targetStart = targetClip.config.start; + const targetEnd = targetStart + targetClip.config.length; + + // Determine snap direction based on dragged clip center vs target clip center + const draggedCenter = desiredStart + clipLength / 2; + const targetCenter = targetStart + targetClip.config.length / 2; + const snapRight = draggedCenter >= targetCenter; + + if (snapRight) { + // Snap to RIGHT of target clip + const newStartTime = targetEnd; + const newEndTime = newStartTime + clipLength; + const nextClip = clips[targetIndex + 1]; + + if (nextClip && newEndTime > nextClip.config.start) { + return { newStartTime, pushOffset: newEndTime - nextClip.config.start }; + } + return { newStartTime, pushOffset: 0 }; + } + + // Snap to LEFT of target clip + const prevClipEnd = targetIndex > 0 ? clips[targetIndex - 1].config.start + clips[targetIndex - 1].config.length : 0; + const availableSpace = targetStart - prevClipEnd; - this.resetState(); + if (availableSpace >= clipLength) { + return { newStartTime: targetStart - clipLength, pushOffset: 0 }; + } + + // No space on left - push target clip forward + const newStartTime = prevClipEnd; + return { newStartTime, pushOffset: newStartTime + clipLength - targetStart }; } - /** @internal */ - private handleSelectingMove(event: PIXI.FederatedPointerEvent): void { - if (this.state.type !== "selecting") return; + /** Resolve clip collision based on clip boundaries */ + private resolveClipCollision( + trackIndex: number, + desiredStart: number, + clipLength: number, + excludeClip: ClipRef + ): CollisionResult { + const clips = this.getTrackClips(trackIndex, excludeClip); + if (clips.length === 0) { + return { ...InteractionController.NO_COLLISION, newStartTime: desiredStart }; + } + + const overlap = this.findOverlappingClip(clips, desiredStart, clipLength); + if (overlap) { + // Skip collision for luma assets - they should be overlayable + if (overlap.clip.config.asset?.type === "luma") { + return { newStartTime: desiredStart, pushOffset: 0 }; + } + return this.resolveOverlapSnap(overlap.clip, overlap.index, desiredStart, clipLength, clips); + } - const currentPos = { x: event.global.x, y: event.global.y }; + return { newStartTime: desiredStart, pushOffset: 0 }; + } - if (this.dragHandler.canStartDrag(this.state.startPos, currentPos)) { - if (this.dragHandler.startDrag(this.state.clipInfo, event)) { - const dragInfo = this.dragHandler.getDragInfo(); - if (dragInfo) { - this.state = { - type: "dragging", - dragInfo - }; + /** Find a non-luma content clip at the given position on a track */ + private findContentClipAtPosition(trackIndex: number, time: number): ClipState | null { + const track = this.stateManager.getTracks()[trackIndex]; + if (!track) return null; + + for (const clip of track.clips) { + // Only consider non-luma content clips + if (clip.config.asset?.type !== "luma") { + const clipStart = clip.config.start; + const clipEnd = clipStart + clip.config.length; + + // Check if time falls within this clip + if (time >= clipStart && time < clipEnd) { + return clip; } } } + return null; } - /** @internal */ - private updateCursorForPosition(event: PIXI.FederatedPointerEvent): void { - const target = event.target as PIXI.Container; + private buildSnapPoints(excludeClip: ClipRef): void { + this.snapPoints = []; - if (target.label) { - const clipInfo = this.parseClipLabel(target.label); - if (clipInfo) { - const resizeCursor = this.resizeHandler.getCursorForPosition(clipInfo, event); - if (resizeCursor) { - this.timeline.getPixiApp().canvas.style.cursor = resizeCursor; - return; + // Add playhead position + const playback = this.stateManager.getPlayback(); + this.snapPoints.push({ + time: playback.time / 1000, + type: "playhead" + }); + + // Add clip edges + const tracks = this.stateManager.getTracks(); + for (const track of tracks) { + for (const clip of track.clips) { + // Skip the clip being dragged/resized + const isExcluded = clip.trackIndex === excludeClip.trackIndex && clip.clipIndex === excludeClip.clipIndex; + if (!isExcluded) { + this.snapPoints.push({ + time: clip.config.start, + type: "clip-start" + }); + this.snapPoints.push({ + time: clip.config.start + clip.config.length, + type: "clip-end" + }); } - // Show grab cursor for draggable clips - this.timeline.getPixiApp().canvas.style.cursor = "grab"; - return; } } + } + + private applySnap(time: number): number | null { + const pps = this.stateManager.getViewport().pixelsPerSecond; + const threshold = this.config.snapThreshold / pps; // Convert pixels to seconds + + for (const point of this.snapPoints) { + if (Math.abs(time - point.time) <= threshold) { + return point.time; + } + } + + return null; + } + + private showSnapLine(time: number): void { + if (!this.snapLine) { + this.snapLine = document.createElement("div"); + this.snapLine.className = "ss-snap-line"; + this.feedbackLayer.appendChild(this.snapLine); + } - // Default cursor - this.timeline.getPixiApp().canvas.style.cursor = "default"; + const pps = this.stateManager.getViewport().pixelsPerSecond; + const x = time * pps - this.tracksContainer.scrollLeft; + this.snapLine.style.left = `${x}px`; + this.snapLine.style.display = "block"; } - /** @internal */ - private parseClipLabel(label: string): ClipInfo | null { - if (!label?.startsWith("clip-")) { - return null; + private hideSnapLine(): void { + if (this.snapLine) { + this.snapLine.style.display = "none"; } + } - const parts = label.split("-"); - if (parts.length !== 3) { - return null; + private showDropZone(insertionIndex: number): void { + if (!this.dropZone) { + this.dropZone = document.createElement("div"); + this.dropZone.className = "ss-drop-zone"; + this.feedbackLayer.appendChild(this.dropZone); } - const trackIndex = parseInt(parts[1], 10); - const clipIndex = parseInt(parts[2], 10); + const y = this.getTrackYPosition(insertionIndex); + const tracksOffset = this.getTracksOffsetInFeedbackLayer(); + this.dropZone.style.top = `${y - 2 + tracksOffset}px`; + this.dropZone.style.display = "block"; + } + + /** Get the Y offset of tracks container relative to feedback layer's parent */ + private getTracksOffsetInFeedbackLayer(): number { + // Feedback layer and tracks container are siblings inside rulerTracksWrapper + // The ruler sits above the tracks, so we need this offset for correct positioning + const feedbackParent = this.feedbackLayer.parentElement; + if (!feedbackParent) return 0; - if (Number.isNaN(trackIndex) || Number.isNaN(clipIndex)) { - return null; + const parentRect = feedbackParent.getBoundingClientRect(); + const tracksRect = this.tracksContainer.getBoundingClientRect(); + return tracksRect.top - parentRect.top; + } + + private hideDropZone(): void { + if (this.dropZone) { + this.dropZone.style.display = "none"; } + } - return { trackIndex, clipIndex }; + /** Format time for drag tooltip display (MM:SS.T) */ + private formatDragTime(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + const tenths = Math.floor((seconds % 1) * 10); + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}.${tenths}`; } - /** @internal */ - private resetState(): void { - this.state = { type: "idle" }; - this.visualFeedback.hideAll(); - this.timeline.getPixiApp().canvas.style.cursor = "default"; + private showDragTimeTooltip(time: number, x: number, y: number): void { + if (!this.dragTimeTooltip) { + this.dragTimeTooltip = document.createElement("div"); + this.dragTimeTooltip.className = "ss-drag-time-tooltip"; + this.feedbackLayer.appendChild(this.dragTimeTooltip); + } + + this.dragTimeTooltip.textContent = this.formatDragTime(time); + this.dragTimeTooltip.style.left = `${x}px`; + this.dragTimeTooltip.style.top = `${y - 28}px`; + this.dragTimeTooltip.style.display = "block"; + } + + private hideDragTimeTooltip(): void { + if (this.dragTimeTooltip) { + this.dragTimeTooltip.style.display = "none"; + } + } + + /** Get drag target at Y position - either an existing track or an insertion point between tracks */ + private getDragTargetAtY(y: number): DragTarget { + const tracks = this.stateManager.getTracks(); + const insertZoneSize = 12; // pixels at track edges for insert detection + let currentY = 0; + + // Top edge - insert above first track + if (y < insertZoneSize / 2) { + return { type: "insert", insertionIndex: 0 }; + } + + for (let i = 0; i < tracks.length; i += 1) { + const height = getTrackHeight(tracks[i].primaryAssetType); + + // Top edge insert zone (between this track and previous) + if (i > 0 && y >= currentY - insertZoneSize / 2 && y < currentY + insertZoneSize / 2) { + return { type: "insert", insertionIndex: i }; + } + + // Inside track (not in edge zones) + if (y >= currentY + insertZoneSize / 2 && y < currentY + height - insertZoneSize / 2) { + return { type: "track", trackIndex: i }; + } + + currentY += height; + } + + // Bottom edge - insert after last track + if (y >= currentY - insertZoneSize / 2) { + return { type: "insert", insertionIndex: tracks.length }; + } + + // Default to last track + return { type: "track", trackIndex: Math.max(0, tracks.length - 1) }; + } + + /** Get Y position of a track by index (accounting for variable heights) */ + private getTrackYPosition(trackIndex: number): number { + const tracks = this.stateManager.getTracks(); + let y = 0; + for (let i = 0; i < trackIndex && i < tracks.length; i += 1) { + y += getTrackHeight(tracks[i].primaryAssetType); + } + return y; } public dispose(): void { - this.deactivate(); - this.dragHandler.dispose(); - this.resizeHandler.dispose(); - this.visualFeedback.dispose(); + document.removeEventListener("pointermove", this.handlePointerMove); + document.removeEventListener("pointerup", this.handlePointerUp); + + if (this.snapLine) { + this.snapLine.remove(); + this.snapLine = null; + } + + if (this.dragGhost) { + this.dragGhost.remove(); + this.dragGhost = null; + } + + if (this.dropZone) { + this.dropZone.remove(); + this.dropZone = null; + } + + if (this.dragTimeTooltip) { + this.dragTimeTooltip.remove(); + this.dragTimeTooltip = null; + } } } diff --git a/src/components/timeline/interaction/resize-handler.ts b/src/components/timeline/interaction/resize-handler.ts deleted file mode 100644 index bc841c05..00000000 --- a/src/components/timeline/interaction/resize-handler.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { ResizeClipCommand } from "@core/commands/resize-clip-command"; -import * as PIXI from "pixi.js"; - -import { TimelineInterface, ResizeInfo, ClipInfo, InteractionThresholds, InteractionHandler } from "./types"; - -export class ResizeHandler implements InteractionHandler { - private timeline: TimelineInterface; - private thresholds: InteractionThresholds; - private resizeInfo: ResizeInfo | null = null; - - constructor(timeline: TimelineInterface, thresholds: InteractionThresholds) { - this.timeline = timeline; - this.thresholds = thresholds; - } - - public activate(): void { - // Handler activation if needed - } - - public deactivate(): void { - this.endResize(); - } - - public isOnClipRightEdge(clipInfo: ClipInfo, event: PIXI.FederatedPointerEvent): boolean { - const track = this.timeline.getVisualTracks()[clipInfo.trackIndex]; - if (!track) return false; - - const clip = track.getClip(clipInfo.clipIndex); - if (!clip) return false; - - // Get the clip's right edge position in global coordinates - const clipContainer = clip.getContainer(); - const clipBounds = clipContainer.getBounds(); - const rightEdgeX = clipBounds.x + clipBounds.width; - - // Check if mouse is within threshold of right edge - const distance = Math.abs(event.global.x - rightEdgeX); - const threshold = this.getResizeThreshold(); - - return distance <= threshold; - } - - public startResize(clipInfo: ClipInfo, event: PIXI.FederatedPointerEvent): boolean { - const clipData = this.timeline.getClipData(clipInfo.trackIndex, clipInfo.clipIndex); - if (!clipData) return false; - - this.resizeInfo = { - trackIndex: clipInfo.trackIndex, - clipIndex: clipInfo.clipIndex, - originalLength: clipData.length, - startX: event.global.x - }; - - // Set cursor - this.timeline.getPixiApp().canvas.style.cursor = "ew-resize"; - - // Set visual feedback on the clip - const track = this.timeline.getVisualTracks()[clipInfo.trackIndex]; - if (track) { - const clip = track.getClip(clipInfo.clipIndex); - if (clip) { - clip.setResizing(true); - } - } - - this.timeline.getEdit().events.emit("resize:started", this.resizeInfo); - return true; - } - - public updateResize(event: PIXI.FederatedPointerEvent): void { - if (!this.resizeInfo) return; - - // Calculate new duration based on mouse movement - const deltaX = event.global.x - this.resizeInfo.startX; - const pixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50; - const deltaTime = deltaX / pixelsPerSecond; - const newLength = Math.max(0.1, this.resizeInfo.originalLength + deltaTime); - - // Update visual preview - const track = this.timeline.getVisualTracks()[this.resizeInfo.trackIndex]; - if (track) { - const clip = track.getClip(this.resizeInfo.clipIndex); - if (clip) { - const newWidth = newLength * pixelsPerSecond; - clip.setPreviewWidth(newWidth); - - this.timeline.getEdit().events.emit("resize:updated", { width: newWidth }); - } - } - } - - public completeResize(event: PIXI.FederatedPointerEvent): void { - if (!this.resizeInfo) return; - - // Calculate final duration - const deltaX = event.global.x - this.resizeInfo.startX; - const pixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50; - const deltaTime = deltaX / pixelsPerSecond; - const newLength = Math.max(0.1, this.resizeInfo.originalLength + deltaTime); - - // Clear visual preview first - const track = this.timeline.getVisualTracks()[this.resizeInfo.trackIndex]; - if (track) { - const clip = track.getClip(this.resizeInfo.clipIndex); - if (clip) { - clip.setResizing(false); - clip.setPreviewWidth(null); - } - } - - // Execute resize command if length changed significantly - if (Math.abs(newLength - this.resizeInfo.originalLength) > 0.01) { - const command = new ResizeClipCommand(this.resizeInfo.trackIndex, this.resizeInfo.clipIndex, newLength); - this.timeline.getEdit().executeEditCommand(command); - - this.timeline.getEdit().events.emit("resize:ended", { newLength }); - } - - this.endResize(); - } - - public getCursorForPosition(clipInfo: ClipInfo | null, event: PIXI.FederatedPointerEvent): string { - if (clipInfo && this.isOnClipRightEdge(clipInfo, event)) { - return "ew-resize"; - } - return ""; - } - - private getResizeThreshold(): number { - const { trackHeight } = this.timeline.getLayout(); - // More generous scaling for smaller tracks - return Math.max(this.thresholds.resize.min, Math.min(this.thresholds.resize.max, trackHeight * this.thresholds.resize.ratio)); - } - - private endResize(): void { - this.resizeInfo = null; - this.timeline.getPixiApp().canvas.style.cursor = "default"; - } - - public getResizeInfo(): ResizeInfo | null { - return this.resizeInfo; - } - - public dispose(): void { - this.endResize(); - } -} diff --git a/src/components/timeline/interaction/snap-manager.ts b/src/components/timeline/interaction/snap-manager.ts deleted file mode 100644 index 933c5b37..00000000 --- a/src/components/timeline/interaction/snap-manager.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { TimelineInterface, SnapPoint, SnapResult, AlignmentInfo, InteractionThresholds } from "./types"; - -export class SnapManager { - private timeline: TimelineInterface; - private thresholds: InteractionThresholds; - - constructor(timeline: TimelineInterface, thresholds: InteractionThresholds) { - this.timeline = timeline; - this.thresholds = thresholds; - } - - public getAllSnapPoints(currentTrackIndex: number, excludeClipIndex?: number): SnapPoint[] { - const snapPoints: SnapPoint[] = []; - - // Get clips from ALL tracks for cross-track alignment - const tracks = this.timeline.getVisualTracks(); - tracks.forEach((track, trackIdx) => { - const clips = track.getClips(); - clips.forEach((clip, clipIdx) => { - // Skip the clip being dragged - if (trackIdx === currentTrackIndex && clipIdx === excludeClipIndex) return; - - const clipConfig = clip.getClipConfig(); - if (clipConfig) { - const { start } = clipConfig; - snapPoints.push({ - time: start, - type: "clip-start", - trackIndex: trackIdx, - clipIndex: clipIdx - }); - snapPoints.push({ - time: start + clipConfig.length, - type: "clip-end", - trackIndex: trackIdx, - clipIndex: clipIdx - }); - } - }); - }); - - // Add playhead position - const playheadTime = this.timeline.getPlayheadTime(); - snapPoints.push({ time: playheadTime, type: "playhead" }); - - return snapPoints; - } - - public getTrackSnapPoints(trackIndex: number, excludeClipIndex?: number): SnapPoint[] { - return this.getAllSnapPoints(trackIndex, excludeClipIndex).filter(point => point.trackIndex === undefined || point.trackIndex === trackIndex); - } - - public calculateSnapPosition(dragTime: number, dragTrack: number, clipDuration: number, excludeClipIndex?: number): SnapResult { - const pixelsPerSecond = this.timeline.getOptions().pixelsPerSecond || 50; - const snapThresholdTime = this.thresholds.snap.pixels / pixelsPerSecond; - - // Get potential snap points for this track - const snapPoints = this.getTrackSnapPoints(dragTrack, excludeClipIndex); - - // Check snap points for both clip start and clip end - let closestSnap: { time: number; type: SnapPoint["type"]; distance: number } | null = null; - - for (const snapPoint of snapPoints) { - // Check snap for clip start - const startDistance = Math.abs(dragTime - snapPoint.time); - if (startDistance < snapThresholdTime) { - if (!closestSnap || startDistance < closestSnap.distance) { - closestSnap = { time: snapPoint.time, type: snapPoint.type, distance: startDistance }; - } - } - - // Check snap for clip end - const endDistance = Math.abs(dragTime + clipDuration - snapPoint.time); - if (endDistance < snapThresholdTime) { - if (!closestSnap || endDistance < closestSnap.distance) { - // Adjust time so clip end aligns with snap point - closestSnap = { - time: snapPoint.time - clipDuration, - type: snapPoint.type, - distance: endDistance - }; - } - } - } - - if (closestSnap) { - return { time: closestSnap.time, snapped: true, snapType: closestSnap.type }; - } - - return { time: dragTime, snapped: false }; - } - - public findAlignedElements(clipStart: number, clipDuration: number, currentTrack: number, excludeClipIndex?: number): AlignmentInfo[] { - const SNAP_THRESHOLD = 0.1; - const clipEnd = clipStart + clipDuration; - const alignments = new Map; isPlayhead: boolean }>(); - - // Check all tracks for alignments - this.timeline.getVisualTracks().forEach((track, trackIdx) => { - track.getClips().forEach((clip, clipIdx) => { - if (trackIdx === currentTrack && clipIdx === excludeClipIndex) return; - - const config = clip.getClipConfig(); - if (!config) return; - - const otherStart = config.start; - const otherEnd = otherStart + config.length; - - // Check alignments - [ - { time: otherStart, aligns: [clipStart, clipEnd] }, - { time: otherEnd, aligns: [clipStart, clipEnd] } - ].forEach(({ time, aligns }) => { - if (aligns.some(t => Math.abs(t - time) < SNAP_THRESHOLD)) { - if (!alignments.has(time)) { - alignments.set(time, { tracks: new Set(), isPlayhead: false }); - } - alignments.get(time)!.tracks.add(trackIdx); - } - }); - }); - }); - - // Check playhead alignment - const playheadTime = this.timeline.getPlayheadTime(); - if (Math.abs(clipStart - playheadTime) < SNAP_THRESHOLD || Math.abs(clipEnd - playheadTime) < SNAP_THRESHOLD) { - if (!alignments.has(playheadTime)) { - alignments.set(playheadTime, { tracks: new Set(), isPlayhead: true }); - } - alignments.get(playheadTime)!.isPlayhead = true; - } - - // Convert to array format - return Array.from(alignments.entries()).map(([time, data]) => ({ - time, - tracks: Array.from(data.tracks).concat(currentTrack), - isPlayhead: data.isPlayhead - })); - } -} diff --git a/src/components/timeline/interaction/types.ts b/src/components/timeline/interaction/types.ts deleted file mode 100644 index 5a66ced9..00000000 --- a/src/components/timeline/interaction/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { EditCommand } from "@core/commands/types"; -import { EventEmitter } from "@core/events/event-emitter"; -import * as PIXI from "pixi.js"; - -import { TimelineTheme } from "../../../core/theme"; -import { TimelineLayout } from "../timeline-layout"; -import { ResolvedClip, TimelineOptions } from "../types/timeline"; - -// Visual component interfaces -export interface VisualClip { - getContainer(): PIXI.Container; - getClipConfig(): ResolvedClip | null; - setResizing(resizing: boolean): void; - setPreviewWidth(width: number | null): void; -} - -export interface VisualTrack { - getClips(): VisualClip[]; - getClip(index: number): VisualClip | null; -} - -// Edit interface -export interface EditInterface { - clearSelection(): void; - selectClip(trackIndex: number, clipIndex: number): void; - executeEditCommand(command: EditCommand): void; - events: EventEmitter; -} - -// ClipConfig is imported from ../types/timeline which uses Zod schema - -// Core interfaces for dependency injection -export interface TimelineInterface { - getPixiApp(): PIXI.Application; - getLayout(): TimelineLayout; - getTheme(): TimelineTheme; - getOptions(): TimelineOptions; - getVisualTracks(): VisualTrack[]; - getClipData(trackIndex: number, clipIndex: number): ResolvedClip | null; - getPlayheadTime(): number; - getExtendedTimelineWidth(): number; - getContainer(): PIXI.Container; - getEdit(): EditInterface; - showDragGhost(track: number, time: number, freeY?: number): void; - hideDragGhost(): void; -} - -// State types using discriminated unions -export type InteractionState = - | { type: "idle" } - | { type: "selecting"; startPos: Point; clipInfo: ClipInfo } - | { type: "dragging"; dragInfo: DragInfo } - | { type: "resizing"; resizeInfo: ResizeInfo }; - -export interface Point { - x: number; - y: number; -} - -export interface ClipInfo { - trackIndex: number; - clipIndex: number; -} - -export interface DragInfo { - trackIndex: number; - clipIndex: number; - startTime: number; - offsetX: number; - offsetY: number; -} - -export interface ResizeInfo { - trackIndex: number; - clipIndex: number; - originalLength: number; - startX: number; -} - -export interface DropZone { - type: "above" | "between" | "below"; - position: number; -} - -// Snap-related types -export interface SnapPoint { - time: number; - type: "clip-start" | "clip-end" | "playhead"; - trackIndex?: number; - clipIndex?: number; -} - -export interface SnapResult { - time: number; - snapped: boolean; - snapType?: "clip-start" | "clip-end" | "playhead"; -} - -export interface AlignmentInfo { - time: number; - tracks: number[]; - isPlayhead: boolean; -} - -// Threshold configuration -export interface InteractionThresholds { - drag: { - base: number; - small: number; - }; - resize: { - min: number; - max: number; - ratio: number; - }; - dropZone: { - ratio: number; - }; - snap: { - pixels: number; - time: number; - }; -} - -// Event types -export interface InteractionEvents { - "drag:started": DragInfo; - "drag:moved": { - trackIndex: number; - clipIndex: number; - startTime: number; - offsetX: number; - offsetY: number; - currentTime: number; - currentTrack: number; - }; - "drag:ended": void; - "resize:started": ResizeInfo; - "resize:updated": { width: number }; - "resize:ended": { newLength: number }; -} - -// Handler interfaces -export interface InteractionHandler { - activate(): void; - deactivate(): void; - dispose(): void; -} diff --git a/src/components/timeline/interaction/visual-feedback-manager.ts b/src/components/timeline/interaction/visual-feedback-manager.ts deleted file mode 100644 index afde0f17..00000000 --- a/src/components/timeline/interaction/visual-feedback-manager.ts +++ /dev/null @@ -1,131 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { TimelineInterface, DropZone, AlignmentInfo } from "./types"; - -export class VisualFeedbackManager { - private timeline: TimelineInterface; - private graphics: Map = new Map(); - - constructor(timeline: TimelineInterface) { - this.timeline = timeline; - } - - public showDropZone(dropZone: DropZone): void { - this.hideDropZone(); // Clear existing - - const graphics = new PIXI.Graphics(); - const layout = this.timeline.getLayout(); - const width = this.timeline.getExtendedTimelineWidth(); - const y = dropZone.position * layout.trackHeight; - - const theme = this.timeline.getTheme(); - const color = theme.timeline.trackInsertion; - - // Draw highlighted line with thickness - graphics.setStrokeStyle({ width: 4, color, alpha: 0.8 }); - graphics.moveTo(0, y); - graphics.lineTo(width, y); - graphics.stroke(); - - // Add subtle glow effect - graphics.setStrokeStyle({ width: 8, color, alpha: 0.3 }); - graphics.moveTo(0, y); - graphics.lineTo(width, y); - graphics.stroke(); - - this.timeline.getContainer().addChild(graphics); - this.graphics.set("dropZone", graphics); - } - - public hideDropZone(): void { - this.hideGraphics("dropZone"); - } - - public showSnapGuidelines(alignments: AlignmentInfo[]): void { - this.hideSnapGuidelines(); // Clear existing - - const graphics = new PIXI.Graphics(); - const layout = this.timeline.getLayout(); - const theme = this.timeline.getTheme(); - - alignments.forEach(({ time, tracks, isPlayhead }) => { - const x = layout.getXAtTime(time); - const minTrack = Math.min(...tracks); - const maxTrack = Math.max(...tracks); - - const startY = minTrack * layout.trackHeight; - const endY = (maxTrack + 1) * layout.trackHeight; - - const color = isPlayhead ? theme.timeline.playhead : theme.timeline.snapGuide; - - // Glow effect - graphics.setStrokeStyle({ width: 3, color, alpha: 0.3 }); - graphics.moveTo(x, startY); - graphics.lineTo(x, endY); - graphics.stroke(); - - // Core line - graphics.setStrokeStyle({ width: 1, color, alpha: 0.8 }); - graphics.moveTo(x, startY); - graphics.lineTo(x, endY); - graphics.stroke(); - }); - - this.timeline.getContainer().addChild(graphics); - this.graphics.set("snapGuidelines", graphics); - } - - public hideSnapGuidelines(): void { - this.hideGraphics("snapGuidelines"); - } - - public showTargetTrack(trackIndex: number): void { - this.hideTargetTrack(); // Clear existing - - const graphics = new PIXI.Graphics(); - const layout = this.timeline.getLayout(); - const width = this.timeline.getExtendedTimelineWidth(); - const y = trackIndex * layout.trackHeight; - const height = layout.trackHeight; - - const theme = this.timeline.getTheme(); - const color = theme.timeline.dropZone; - - // Draw subtle highlight for target track - graphics.rect(0, y, width, height); - graphics.fill({ color, alpha: 0.1 }); - - // Add subtle border - graphics.setStrokeStyle({ width: 1, color, alpha: 0.3 }); - graphics.rect(0, y, width, height); - graphics.stroke(); - - this.timeline.getContainer().addChild(graphics); - this.graphics.set("targetTrack", graphics); - } - - public hideTargetTrack(): void { - this.hideGraphics("targetTrack"); - } - - public hideAll(): void { - this.graphics.forEach((_, key) => this.hideGraphics(key)); - } - - private hideGraphics(key: string): void { - const graphics = this.graphics.get(key); - if (graphics) { - graphics.clear(); - if (graphics.parent) { - graphics.parent.removeChild(graphics); - } - graphics.destroy(); - this.graphics.delete(key); - } - } - - public dispose(): void { - this.hideAll(); - this.graphics.clear(); - } -} diff --git a/src/components/timeline/managers/drag-preview-manager.ts b/src/components/timeline/managers/drag-preview-manager.ts deleted file mode 100644 index 8a6419b5..00000000 --- a/src/components/timeline/managers/drag-preview-manager.ts +++ /dev/null @@ -1,136 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { TimelineLayout } from "../timeline-layout"; -import { ResolvedClip } from "../types/timeline"; -import { VisualTrack } from "../visual/visual-track"; - -export interface DraggedClipInfo { - trackIndex: number; - clipIndex: number; - clipConfig: ResolvedClip; -} - -export class DragPreviewManager { - private dragPreviewContainer: PIXI.Container | null = null; - private dragPreviewGraphics: PIXI.Graphics | null = null; - private draggedClipInfo: DraggedClipInfo | null = null; - - constructor( - private container: PIXI.Container, - private layout: TimelineLayout, - private getPixelsPerSecond: () => number, - private getTrackHeight: () => number, - private getVisualTracks: () => VisualTrack[] - ) {} - - public showDragPreview(trackIndex: number, clipIndex: number, clipData: ResolvedClip): void { - if (!clipData) return; - - this.draggedClipInfo = { trackIndex, clipIndex, clipConfig: clipData }; - - // Create drag preview - this.dragPreviewContainer = new PIXI.Container(); - this.dragPreviewGraphics = new PIXI.Graphics(); - this.dragPreviewContainer.addChild(this.dragPreviewGraphics); - this.container.addChild(this.dragPreviewContainer); - - // Set original clip to dragging state - const visualTracks = this.getVisualTracks(); - visualTracks[trackIndex]?.getClip(clipIndex)?.setDragging(true); - - // Draw initial preview - this.drawDragPreview(trackIndex, clipData.start); - } - - /** @internal */ - public drawDragPreview(trackIndex: number, time: number): void { - if (!this.dragPreviewContainer || !this.dragPreviewGraphics || !this.draggedClipInfo) return; - - const { clipConfig } = this.draggedClipInfo; - const x = this.layout.getXAtTime(time); - const y = trackIndex * this.layout.trackHeight; - const width = clipConfig.length * this.getPixelsPerSecond(); - - // Clear and redraw - this.dragPreviewGraphics.clear(); - this.dragPreviewGraphics.roundRect(0, 0, width, this.getTrackHeight(), 4); - this.dragPreviewGraphics.fill({ color: 0x8e8e93, alpha: 0.6 }); - this.dragPreviewGraphics.stroke({ width: 2, color: 0x00ff00 }); - - // Position - this.dragPreviewContainer.position.set(x, y); - } - - /** @internal */ - private drawDragPreviewAtPosition(time: number, freeY: number, targetTrack: number): void { - if (!this.dragPreviewContainer || !this.dragPreviewGraphics || !this.draggedClipInfo) return; - - const { clipConfig } = this.draggedClipInfo; - const x = this.layout.getXAtTime(time); - const width = clipConfig.length * this.getPixelsPerSecond(); - const height = this.getTrackHeight(); - - // Clear and redraw - this.dragPreviewGraphics.clear(); - - // Draw the ghost preview with free positioning - this.dragPreviewGraphics.roundRect(0, 0, width, height, 4); - this.dragPreviewGraphics.fill({ color: 0x8e8e93, alpha: 0.6 }); - - // Different border color to indicate target track - const targetY = targetTrack * this.layout.trackHeight; - const isAligned = Math.abs(freeY - targetY) < 5; // Within 5 pixels of target - const borderColor = isAligned ? 0x00ff00 : 0xffaa00; // Green if aligned, orange if not - - this.dragPreviewGraphics.stroke({ width: 2, color: borderColor }); - - // Position at free Y - this.dragPreviewContainer.position.set(x, freeY); - } - - public hideDragPreview(): void { - if (this.dragPreviewContainer) { - this.dragPreviewContainer.destroy({ children: true }); - this.dragPreviewContainer = null; - this.dragPreviewGraphics = null; - } - - // Reset original clip appearance - if (this.draggedClipInfo) { - const visualTracks = this.getVisualTracks(); - visualTracks[this.draggedClipInfo.trackIndex]?.getClip(this.draggedClipInfo.clipIndex)?.setDragging(false); - this.draggedClipInfo = null; - } - } - - public hideDragGhost(): void { - if (this.dragPreviewContainer) { - this.dragPreviewContainer.visible = false; - } - } - - public showDragGhost(trackIndex: number, time: number, freeY?: number): void { - if (!this.dragPreviewContainer || !this.draggedClipInfo) return; - this.dragPreviewContainer.visible = true; - - if (freeY !== undefined) { - // Use free Y position for ghost - this.drawDragPreviewAtPosition(time, freeY, trackIndex); - } else { - // Use track-aligned position - this.drawDragPreview(trackIndex, time); - } - } - - public getDraggedClipInfo(): DraggedClipInfo | null { - return this.draggedClipInfo; - } - - public hasActivePreview(): boolean { - return this.dragPreviewContainer !== null; - } - - public dispose(): void { - this.hideDragPreview(); - } -} diff --git a/src/components/timeline/managers/index.ts b/src/components/timeline/managers/index.ts deleted file mode 100644 index fafddb79..00000000 --- a/src/components/timeline/managers/index.ts +++ /dev/null @@ -1,18 +0,0 @@ -export { DragPreviewManager } from "./drag-preview-manager"; -export type { DraggedClipInfo } from "./drag-preview-manager"; - -export { ViewportManager } from "./viewport-manager"; -export type { ViewportState } from "./viewport-manager"; - -export { VisualTrackManager } from "./visual-track-manager"; - -export { TimelineEventHandler } from "./timeline-event-handler"; -export type { TimelineEventCallbacks } from "./timeline-event-handler"; - -export { TimelineRenderer } from "./timeline-renderer"; -export type { RendererOptions } from "./timeline-renderer"; - -export { TimelineFeatureManager } from "./timeline-feature-manager"; -export type { TimelineFeatures } from "./timeline-feature-manager"; - -export { TimelineOptionsManager } from "./timeline-options-manager"; diff --git a/src/components/timeline/managers/selection-overlay-renderer.ts b/src/components/timeline/managers/selection-overlay-renderer.ts deleted file mode 100644 index 4deb011d..00000000 --- a/src/components/timeline/managers/selection-overlay-renderer.ts +++ /dev/null @@ -1,82 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { TimelineTheme } from "../../../core/theme"; -import { CLIP_CONSTANTS } from "../constants"; - -export interface SelectionBounds { - x: number; - y: number; - width: number; - height: number; - cornerRadius: number; - borderWidth: number; -} - -export class SelectionOverlayRenderer { - private selectionGraphics: Map = new Map(); - - constructor( - private overlay: PIXI.Container, - private theme: TimelineTheme - ) {} - - public renderSelection(clipId: string, bounds: SelectionBounds, isSelected: boolean): void { - if (!isSelected) { - this.clearSelection(clipId); - return; - } - - let graphics = this.selectionGraphics.get(clipId); - if (!graphics) { - graphics = new PIXI.Graphics(); - graphics.label = `selection-border-${clipId}`; - this.selectionGraphics.set(clipId, graphics); - this.overlay.addChild(graphics); - } - - // Update position - graphics.position.set(bounds.x, bounds.y); - - // Clear and redraw - graphics.clear(); - - // Draw selection border - graphics.setStrokeStyle({ - width: bounds.borderWidth * CLIP_CONSTANTS.SELECTED_BORDER_MULTIPLIER, - color: this.theme.timeline.clips.selected - }); - graphics.roundRect(0, 0, bounds.width, bounds.height, bounds.cornerRadius); - graphics.stroke(); - } - - public clearSelection(clipId: string): void { - const graphics = this.selectionGraphics.get(clipId); - if (graphics) { - this.overlay.removeChild(graphics); - graphics.destroy(); - this.selectionGraphics.delete(clipId); - } - } - - public clearAllSelections(): void { - this.selectionGraphics.forEach((_graphics, clipId) => { - this.clearSelection(clipId); - }); - } - - public updateTheme(theme: TimelineTheme): void { - this.theme = theme; - // Force redraw of all selections with new theme - this.selectionGraphics.forEach(graphics => { - graphics.clear(); // Will be redrawn on next render - }); - } - - public getOverlay(): PIXI.Container { - return this.overlay; - } - - public dispose(): void { - this.clearAllSelections(); - } -} diff --git a/src/components/timeline/managers/timeline-event-handler.ts b/src/components/timeline/managers/timeline-event-handler.ts deleted file mode 100644 index 2b3d4304..00000000 --- a/src/components/timeline/managers/timeline-event-handler.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Edit } from "@core/edit"; - -import { EditType, ClipConfig } from "../types/timeline"; - -export interface TimelineEventCallbacks { - onEditChange: (editType?: EditType) => Promise; - onSeek: (time: number) => void; - onClipSelected: (trackIndex: number, clipIndex: number) => void; - onSelectionCleared: () => void; - onDragStarted: (trackIndex: number, clipIndex: number) => void; - onDragEnded: () => void; -} - -export class TimelineEventHandler { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private boundHandlers = new Map void>(); - - constructor( - private edit: Edit, - private callbacks: TimelineEventCallbacks - ) {} - - public setupEventListeners(): void { - const events = [ - ["timeline:updated", this.handleTimelineUpdated], - ["clip:updated", this.handleClipUpdated], - ["clip:selected", this.handleClipSelected], - ["selection:cleared", this.handleSelectionCleared], - ["drag:started", this.handleDragStarted], - ["drag:ended", this.handleDragEnded], - ["track:created", this.handleTrackCreated] - ] as const; - - for (const [event, handler] of events) { - const bound = handler.bind(this); - this.boundHandlers.set(event, bound); - this.edit.events.on(event, bound); - } - } - - private async handleTimelineUpdated(event: { current: EditType }): Promise { - await this.callbacks.onEditChange(event.current); - } - - private async handleClipUpdated(): Promise { - await this.callbacks.onEditChange(); - } - - private handleClipSelected(event: { clip: ClipConfig; trackIndex: number; clipIndex: number }): void { - this.callbacks.onClipSelected(event.trackIndex, event.clipIndex); - } - - private handleSelectionCleared(): void { - this.callbacks.onSelectionCleared(); - } - - private handleDragStarted(event: { trackIndex: number; clipIndex: number; startTime: number; offsetX: number; offsetY: number }): void { - this.callbacks.onDragStarted(event.trackIndex, event.clipIndex); - } - - private handleDragEnded(): void { - this.callbacks.onDragEnded(); - } - - private async handleTrackCreated(): Promise { - await this.callbacks.onEditChange(); - } - - public handleSeek(event: { time: number }): void { - // Convert timeline seconds to edit milliseconds - this.callbacks.onSeek(event.time * 1000); - } - - public dispose(): void { - for (const [event, handler] of this.boundHandlers) { - this.edit.events.off(event, handler); - } - this.boundHandlers.clear(); - } -} diff --git a/src/components/timeline/managers/timeline-feature-manager.ts b/src/components/timeline/managers/timeline-feature-manager.ts deleted file mode 100644 index b4891d40..00000000 --- a/src/components/timeline/managers/timeline-feature-manager.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { Edit } from "@core/edit"; - -import { TimelineTheme } from "../../../core/theme"; -import { - RulerFeature, - PlayheadFeature, - ScrollManager, - RulerFeatureOptions, - PlayheadFeatureOptions, - ScrollManagerOptions, - TimelineReference -} from "../features"; -import { TimelineLayout } from "../timeline-layout"; -import { TimelineToolbar } from "../timeline-toolbar"; - -import { TimelineEventHandler } from "./timeline-event-handler"; -import { TimelineRenderer } from "./timeline-renderer"; -import { ViewportManager } from "./viewport-manager"; - -export interface TimelineFeatures { - toolbar: TimelineToolbar; - ruler: RulerFeature; - playhead: PlayheadFeature; - scroll: ScrollManager; -} - -export class TimelineFeatureManager { - private toolbar!: TimelineToolbar; - private ruler!: RulerFeature; - private playhead!: PlayheadFeature; - private scroll!: ScrollManager; - - constructor( - private edit: Edit, - private layout: TimelineLayout, - private renderer: TimelineRenderer, - private viewportManager: ViewportManager, - private eventHandler: TimelineEventHandler, - private getTimelineContext: () => TimelineReference // Reference back to timeline for scroll - ) {} - - public async setupTimelineFeatures( - theme: TimelineTheme, - pixelsPerSecond: number, - width: number, - height: number, - extendedDuration: number - ): Promise { - // Create toolbar - this.toolbar = new TimelineToolbar(this.edit, theme, this.layout, width); - this.renderer.getStage().addChild(this.toolbar); - - // Create ruler feature with extended duration for display - const rulerOptions: RulerFeatureOptions = { - pixelsPerSecond, - timelineDuration: extendedDuration, - rulerHeight: this.layout.rulerHeight, - theme - }; - this.ruler = new RulerFeature(rulerOptions); - await this.ruler.load(); - this.ruler.getContainer().y = this.layout.rulerY; - this.viewportManager.getRulerViewport().addChild(this.ruler.getContainer()); - - // Connect ruler seek events - this.ruler.events.on("ruler:seeked", this.eventHandler.handleSeek.bind(this.eventHandler)); - - // Create playhead feature (should span full height including ruler) - const playheadOptions: PlayheadFeatureOptions = { - pixelsPerSecond, - timelineHeight: height, - theme - }; - this.playhead = new PlayheadFeature(playheadOptions); - await this.playhead.load(); - // Position playhead to start from top of ruler - this.playhead.getContainer().y = this.layout.rulerY; - // Add playhead to dedicated container that renders above ruler - this.viewportManager.getPlayheadContainer().addChild(this.playhead.getContainer()); - - // Connect playhead seek events - this.playhead.events.on("playhead:seeked", this.eventHandler.handleSeek.bind(this.eventHandler)); - - // Create scroll manager for handling scroll events - const scrollOptions: ScrollManagerOptions = { - timeline: this.getTimelineContext() - }; - this.scroll = new ScrollManager(scrollOptions); - await this.scroll.initialize(); - - // Position viewport and apply initial transform - this.viewportManager.updateViewportTransform(); - } - - public recreateTimelineFeatures(theme: TimelineTheme, pixelsPerSecond: number, height: number, extendedDuration: number): void { - if (this.ruler) { - this.ruler.dispose(); - const { rulerHeight } = this.layout; - const rulerOptions: RulerFeatureOptions = { - pixelsPerSecond, - timelineDuration: extendedDuration, - rulerHeight, - theme - }; - this.ruler = new RulerFeature(rulerOptions); - this.ruler.load(); - this.ruler.getContainer().y = this.layout.rulerY; - this.viewportManager.getRulerViewport().addChild(this.ruler.getContainer()); - this.ruler.events.on("ruler:seeked", this.eventHandler.handleSeek.bind(this.eventHandler)); - } - - if (this.playhead) { - this.playhead.dispose(); - const playheadOptions: PlayheadFeatureOptions = { - pixelsPerSecond, - timelineHeight: height, - theme - }; - this.playhead = new PlayheadFeature(playheadOptions); - this.playhead.load(); - // Position playhead to start from top of ruler - this.playhead.getContainer().y = this.layout.rulerY; - // Add playhead to dedicated container that renders above ruler - this.viewportManager.getPlayheadContainer().addChild(this.playhead.getContainer()); - this.playhead.events.on("playhead:seeked", this.eventHandler.handleSeek.bind(this.eventHandler)); - } - } - - public updateRuler(pixelsPerSecond: number, extendedDuration: number): void { - this.ruler.updateRuler(pixelsPerSecond, extendedDuration); - } - - public updatePlayhead(pixelsPerSecond: number, timelineHeight: number): void { - if (this.playhead) { - this.playhead.updatePlayhead(pixelsPerSecond, timelineHeight); - } - } - - public getFeatures(): TimelineFeatures { - return { - toolbar: this.toolbar, - ruler: this.ruler, - playhead: this.playhead, - scroll: this.scroll - }; - } - - public getToolbar(): TimelineToolbar { - return this.toolbar; - } - - public getPlayhead(): PlayheadFeature { - return this.playhead; - } - - public dispose(): void { - if (this.toolbar) { - this.toolbar.destroy(); - } - if (this.ruler) { - this.ruler.dispose(); - } - if (this.playhead) { - this.playhead.dispose(); - } - if (this.scroll) { - this.scroll.dispose(); - } - } -} diff --git a/src/components/timeline/managers/timeline-options-manager.ts b/src/components/timeline/managers/timeline-options-manager.ts deleted file mode 100644 index 565741c2..00000000 --- a/src/components/timeline/managers/timeline-options-manager.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { TimelineTheme } from "../../../core/theme"; -import { TimelineLayout } from "../timeline-layout"; -import { TimelineOptions } from "../types/timeline"; - -export class TimelineOptionsManager { - private pixelsPerSecond: number; - private trackHeight: number; - private backgroundColor: number; - private antialias: boolean; - private resolution: number; - private width: number; - private height: number; - - // Zoom constraints - private static readonly MIN_PIXELS_PER_SECOND = 10; - private static readonly MAX_PIXELS_PER_SECOND = 500; - private static readonly ZOOM_FACTOR = 1.1; // 10% zoom per step - - constructor( - size: { width: number; height: number }, - theme: TimelineTheme, - private layout: TimelineLayout, - private onResize?: (width: number) => void - ) { - // Set dimensions from size parameter - this.width = size.width; - this.height = size.height; - - // Set default values for other properties (some from theme) - this.pixelsPerSecond = 50; - // Enforce minimum track height of 40px for usability - const themeTrackHeight = theme.timeline.tracks.height || TimelineLayout.TRACK_HEIGHT_DEFAULT; - this.trackHeight = Math.max(40, themeTrackHeight); - this.backgroundColor = theme.timeline.background; - this.antialias = true; - this.resolution = window.devicePixelRatio || 1; - } - - public getOptions(): TimelineOptions { - return { - width: this.width, - height: this.height, - pixelsPerSecond: this.pixelsPerSecond, - trackHeight: this.trackHeight, - backgroundColor: this.backgroundColor, - antialias: this.antialias, - resolution: this.resolution - }; - } - - public setOptions(options: Partial): void { - if (options.width !== undefined) { - this.width = options.width; - // Notify about width change - if (this.onResize) { - this.onResize(this.width); - } - } - if (options.height !== undefined) this.height = options.height; - if (options.pixelsPerSecond !== undefined) this.pixelsPerSecond = options.pixelsPerSecond; - if (options.trackHeight !== undefined) this.trackHeight = options.trackHeight; - if (options.backgroundColor !== undefined) this.backgroundColor = options.backgroundColor; - if (options.antialias !== undefined) this.antialias = options.antialias; - if (options.resolution !== undefined) this.resolution = options.resolution; - - // Update layout with new options - this.layout.updateOptions(this.getOptions() as Required); - } - - public updateFromTheme(theme: TimelineTheme): void { - // Update backgroundColor from theme - this.backgroundColor = theme.timeline.background; - - // Update trackHeight from theme (with minimum of 40px) - const themeTrackHeight = theme.timeline.tracks.height || TimelineLayout.TRACK_HEIGHT_DEFAULT; - this.trackHeight = Math.max(40, themeTrackHeight); - - // Update layout with new options and theme - this.layout.updateOptions(this.getOptions() as Required, theme); - } - - // Individual getters - public getWidth(): number { - return this.width; - } - public getHeight(): number { - return this.height; - } - public getPixelsPerSecond(): number { - return this.pixelsPerSecond; - } - public getTrackHeight(): number { - return this.trackHeight; - } - public getBackgroundColor(): number { - return this.backgroundColor; - } - public getAntialias(): boolean { - return this.antialias; - } - public getResolution(): number { - return this.resolution; - } - - // Zoom methods - public zoomIn(): void { - const newPixelsPerSecond = Math.min(this.pixelsPerSecond * TimelineOptionsManager.ZOOM_FACTOR, TimelineOptionsManager.MAX_PIXELS_PER_SECOND); - this.setPixelsPerSecond(newPixelsPerSecond); - } - - public zoomOut(): void { - const newPixelsPerSecond = Math.max(this.pixelsPerSecond / TimelineOptionsManager.ZOOM_FACTOR, TimelineOptionsManager.MIN_PIXELS_PER_SECOND); - this.setPixelsPerSecond(newPixelsPerSecond); - } - - public setPixelsPerSecond(pixelsPerSecond: number): void { - // Clamp to valid range - this.pixelsPerSecond = Math.max( - TimelineOptionsManager.MIN_PIXELS_PER_SECOND, - Math.min(TimelineOptionsManager.MAX_PIXELS_PER_SECOND, pixelsPerSecond) - ); - - // Update layout with new options - this.layout.updateOptions(this.getOptions() as Required); - } - - public canZoomIn(): boolean { - return this.pixelsPerSecond < TimelineOptionsManager.MAX_PIXELS_PER_SECOND; - } - - public canZoomOut(): boolean { - return this.pixelsPerSecond > TimelineOptionsManager.MIN_PIXELS_PER_SECOND; - } -} diff --git a/src/components/timeline/managers/timeline-renderer.ts b/src/components/timeline/managers/timeline-renderer.ts deleted file mode 100644 index e1f72ecc..00000000 --- a/src/components/timeline/managers/timeline-renderer.ts +++ /dev/null @@ -1,124 +0,0 @@ -import * as PIXI from "pixi.js"; - -export interface RendererOptions { - width: number; - height: number; - backgroundColor: number; - antialias: boolean; - resolution: number; -} - -export class TimelineRenderer { - private app!: PIXI.Application; - private trackLayer!: PIXI.Container; - private overlayLayer!: PIXI.Container; - private animationFrameId: number | null = null; - - constructor( - private options: RendererOptions, - private onUpdate: (deltaTime: number, elapsed: number) => void - ) {} - - public async initializePixiApp(): Promise { - this.app = new PIXI.Application(); - - await this.app.init({ - width: this.options.width, - height: this.options.height, - backgroundColor: this.options.backgroundColor, - antialias: this.options.antialias, - resolution: this.options.resolution, - autoDensity: true, - preference: "webgl" - }); - - // Find timeline container element and attach canvas - const timelineElement = document.querySelector("[data-shotstack-timeline]") as HTMLElement; - if (!timelineElement) { - throw new Error("Timeline container element [data-shotstack-timeline] not found"); - } - - timelineElement.appendChild(this.app.canvas); - } - - public async setupRenderLayers(): Promise { - // Create ordered layers for proper z-ordering - this.trackLayer = new PIXI.Container(); - this.overlayLayer = new PIXI.Container(); - - // Set up layer properties - this.trackLayer.label = "track-layer"; - this.overlayLayer.label = "overlay-layer"; - - // Add layers to stage in correct order - this.app.stage.addChild(this.trackLayer); - this.app.stage.addChild(this.overlayLayer); - } - - /** @internal */ - public startAnimationLoop(): void { - let lastTime = performance.now(); - - const animate = (currentTime: number) => { - const deltaMS = currentTime - lastTime; - lastTime = currentTime; - - // Convert to PIXI-style deltaTime (frame-based) - const deltaTime = deltaMS / 16.667; - - this.onUpdate(deltaTime, deltaMS); - this.draw(); - - this.animationFrameId = requestAnimationFrame(animate); - }; - - this.animationFrameId = requestAnimationFrame(animate); - } - - /** @internal */ - public draw(): void { - // Render the PIXI application - this.app.render(); - } - - public render(): void { - this.app.render(); - } - - public updateBackgroundColor(color: number): void { - if (this.app) { - this.app.renderer.background.color = color; - } - } - - public getApp(): PIXI.Application { - return this.app; - } - - public getStage(): PIXI.Container { - return this.app.stage; - } - - /** @internal */ - public getTrackLayer(): PIXI.Container { - return this.trackLayer; - } - - /** @internal */ - public getOverlayLayer(): PIXI.Container { - return this.overlayLayer; - } - - public dispose(): void { - // Stop animation loop - if (this.animationFrameId !== null) { - cancelAnimationFrame(this.animationFrameId); - this.animationFrameId = null; - } - - // Destroy PIXI application - if (this.app) { - this.app.destroy(true); - } - } -} diff --git a/src/components/timeline/managers/viewport-manager.ts b/src/components/timeline/managers/viewport-manager.ts deleted file mode 100644 index e6b3bb8f..00000000 --- a/src/components/timeline/managers/viewport-manager.ts +++ /dev/null @@ -1,101 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { TimelineLayout } from "../timeline-layout"; - -export interface ViewportState { - x: number; - y: number; - zoom: number; -} - -export class ViewportManager { - private scrollX = 0; - private scrollY = 0; - private zoomLevel = 1; - - private viewport!: PIXI.Container; - private rulerViewport!: PIXI.Container; - private playheadContainer!: PIXI.Container; - - constructor( - private layout: TimelineLayout, - private trackLayer: PIXI.Container, - private overlayLayer: PIXI.Container, - private entityContainer: PIXI.Container, - private onRender: () => void - ) {} - - public async setupViewport(): Promise { - // Create ruler viewport for horizontal scrolling - this.rulerViewport = new PIXI.Container(); - this.rulerViewport.label = "ruler-viewport"; - this.overlayLayer.addChild(this.rulerViewport); - - // Create playhead container in overlay layer (above ruler) - this.playheadContainer = new PIXI.Container(); - this.playheadContainer.label = "playhead-container"; - this.overlayLayer.addChild(this.playheadContainer); - - // Create main viewport for tracks - this.viewport = new PIXI.Container(); - this.viewport.label = "viewport"; - - // Add viewport to track layer for scrolling - this.trackLayer.addChild(this.viewport); - - // Add our Entity container to viewport (this is where visual tracks will go) - this.viewport.addChild(this.entityContainer); - } - - /** @internal */ - public updateViewportTransform(): void { - // Apply scroll transform using layout calculations - const position = this.layout.calculateViewportPosition(this.scrollX, this.scrollY); - this.viewport.position.set(position.x, position.y); - this.viewport.scale.set(this.zoomLevel, this.zoomLevel); - - // Sync ruler horizontal scroll (no vertical scroll for ruler) - this.rulerViewport.position.x = position.x; - this.rulerViewport.scale.x = this.zoomLevel; - - // Sync playhead horizontal scroll - this.playheadContainer.position.x = position.x; - this.playheadContainer.scale.x = this.zoomLevel; - } - - public setScroll(x: number, y: number): void { - this.scrollX = x; - this.scrollY = y; - this.updateViewportTransform(); - this.onRender(); - } - - public setZoom(zoom: number): void { - this.zoomLevel = Math.max(0.1, Math.min(10, zoom)); - this.updateViewportTransform(); - this.onRender(); - } - - public getViewport(): ViewportState { - return { - x: this.scrollX, - y: this.scrollY, - zoom: this.zoomLevel - }; - } - - /** @internal */ - public getMainViewport(): PIXI.Container { - return this.viewport; - } - - /** @internal */ - public getRulerViewport(): PIXI.Container { - return this.rulerViewport; - } - - /** @internal */ - public getPlayheadContainer(): PIXI.Container { - return this.playheadContainer; - } -} diff --git a/src/components/timeline/managers/visual-track-manager.ts b/src/components/timeline/managers/visual-track-manager.ts deleted file mode 100644 index 4a170e87..00000000 --- a/src/components/timeline/managers/visual-track-manager.ts +++ /dev/null @@ -1,173 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { TimelineTheme } from "../../../core/theme"; -import { TimelineLayout } from "../timeline-layout"; -import { EditType, ClipInfo } from "../types/timeline"; -import { VisualTrack, VisualTrackOptions } from "../visual/visual-track"; - -import { SelectionOverlayRenderer } from "./selection-overlay-renderer"; - -export class VisualTrackManager { - private visualTracks: VisualTrack[] = []; - private selectionOverlay: PIXI.Container; - private selectionRenderer: SelectionOverlayRenderer; - - constructor( - private container: PIXI.Container, - private layout: TimelineLayout, - private theme: TimelineTheme, - private getPixelsPerSecond: () => number, - private getExtendedTimelineWidth: () => number - ) { - // Create selection overlay container - this.selectionOverlay = new PIXI.Container(); - this.selectionOverlay.label = "selectionOverlay"; - - // Add as last child to ensure it renders on top - this.container.addChild(this.selectionOverlay); - - // Create selection renderer - this.selectionRenderer = new SelectionOverlayRenderer(this.selectionOverlay, this.theme); - } - - public async rebuildFromEdit(editType: EditType, pixelsPerSecond: number): Promise { - // Create visual representation directly from event payload - if (!editType?.timeline?.tracks) { - return; - } - - // Clear all existing visual tracks first to avoid stale event handlers - this.clearAllVisualState(); - - // Create visual tracks - for (let trackIndex = 0; trackIndex < editType.timeline.tracks.length; trackIndex += 1) { - const trackData = editType.timeline.tracks[trackIndex]; - - const visualTrackOptions: VisualTrackOptions = { - pixelsPerSecond, - trackHeight: this.layout.trackHeight, - trackIndex, - width: this.getExtendedTimelineWidth(), - theme: this.theme, - selectionRenderer: this.selectionRenderer - }; - - const visualTrack = new VisualTrack(visualTrackOptions); - await visualTrack.load(); - - // Rebuild track with track data - visualTrack.rebuildFromTrackData(trackData, pixelsPerSecond); - - // Add to container and track array - this.container.addChild(visualTrack.getContainer()); - this.visualTracks.push(visualTrack); - } - - // Ensure selection overlay stays on top - this.container.setChildIndex(this.selectionOverlay, this.container.children.length - 1); - } - - public clearAllVisualState(): void { - // Dispose all visual tracks - this.visualTracks.forEach(track => { - this.container.removeChild(track.getContainer()); - track.dispose(); - }); - - this.visualTracks = []; - - // Clear all selections - this.selectionRenderer.clearAllSelections(); - } - - public updateVisualSelection(trackIndex: number, clipIndex: number): void { - // Clear all existing selections first - this.clearVisualSelection(); - - // Set the specified clip as selected - const track = this.visualTracks[trackIndex]; - if (track) { - const clip = track.getClip(clipIndex); - if (clip) { - clip.setSelected(true); - } - } - } - - public clearVisualSelection(): void { - // Clear selection from all clips - this.visualTracks.forEach(track => { - const clips = track.getClips(); - clips.forEach(clip => { - clip.setSelected(false); - }); - }); - } - - public findClipAtPosition(x: number, y: number): ClipInfo | null { - // Hit test using visual tracks for accurate positioning - const trackIndex = Math.floor(y / this.layout.trackHeight); - - if (trackIndex < 0 || trackIndex >= this.visualTracks.length) { - return null; - } - - const visualTrack = this.visualTracks[trackIndex]; - const relativeY = y - trackIndex * this.layout.trackHeight; - - const result = visualTrack.findClipAtPosition(x, relativeY); - if (result) { - const clipConfig = result.clip.getClipConfig(); - return { - trackIndex, - clipIndex: result.clipIndex, - clipConfig, - x: clipConfig.start * this.getPixelsPerSecond(), - y: trackIndex * this.layout.trackHeight, - width: clipConfig.length * this.getPixelsPerSecond(), - height: this.layout.trackHeight - }; - } - - return null; - } - - public updateTrackWidths(extendedWidth: number): void { - this.visualTracks.forEach(track => { - track.setWidth(extendedWidth); - }); - } - - public getVisualTracks(): VisualTrack[] { - return this.visualTracks; - } - - public updatePixelsPerSecond(pixelsPerSecond: number): void { - // Update pixels per second for all existing tracks without rebuilding - this.visualTracks.forEach(track => { - track.setPixelsPerSecond(pixelsPerSecond); - }); - } - - public getSelectionOverlay(): PIXI.Container { - return this.selectionOverlay; - } - - public getSelectionRenderer(): SelectionOverlayRenderer { - return this.selectionRenderer; - } - - public dispose(): void { - this.clearAllVisualState(); - - // Clean up selection renderer and overlay - if (this.selectionRenderer) { - this.selectionRenderer.dispose(); - } - - if (this.selectionOverlay && this.container) { - this.container.removeChild(this.selectionOverlay); - this.selectionOverlay.destroy(); - } - } -} diff --git a/src/components/timeline-html/styles/timeline.css.ts b/src/components/timeline/styles/timeline.css.ts similarity index 100% rename from src/components/timeline-html/styles/timeline.css.ts rename to src/components/timeline/styles/timeline.css.ts diff --git a/src/components/timeline/timeline-layout.ts b/src/components/timeline/timeline-layout.ts deleted file mode 100644 index 5cdc3800..00000000 --- a/src/components/timeline/timeline-layout.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { TimelineTheme } from "../../core/theme"; - -import { LAYOUT_CONSTANTS } from "./constants"; -import { TimelineOptions } from "./types/timeline"; - -export interface TimelineLayoutConfig { - toolbarHeight: number; - rulerHeight: number; - trackHeight: number; - toolbarY: number; - rulerY: number; - tracksY: number; - gridY: number; - playheadY: number; - viewportY: number; -} - -export class TimelineLayout { - // Use constants from centralized location - public static readonly TOOLBAR_HEIGHT_RATIO = LAYOUT_CONSTANTS.TOOLBAR_HEIGHT_RATIO; - public static readonly RULER_HEIGHT_RATIO = LAYOUT_CONSTANTS.RULER_HEIGHT_RATIO; - public static readonly TOOLBAR_HEIGHT_DEFAULT = LAYOUT_CONSTANTS.TOOLBAR_HEIGHT_DEFAULT; - public static readonly RULER_HEIGHT_DEFAULT = LAYOUT_CONSTANTS.RULER_HEIGHT_DEFAULT; - public static readonly TRACK_HEIGHT_DEFAULT = LAYOUT_CONSTANTS.TRACK_HEIGHT_DEFAULT; - public static readonly CLIP_PADDING = LAYOUT_CONSTANTS.CLIP_PADDING; - public static readonly BORDER_WIDTH = LAYOUT_CONSTANTS.BORDER_WIDTH; - public static readonly CORNER_RADIUS = LAYOUT_CONSTANTS.CORNER_RADIUS; - public static readonly LABEL_PADDING = LAYOUT_CONSTANTS.LABEL_PADDING; - public static readonly TRACK_PADDING = LAYOUT_CONSTANTS.TRACK_PADDING; - - private config: TimelineLayoutConfig; - - constructor( - private options: Required, - private theme?: TimelineTheme - ) { - this.config = this.calculateLayout(); - } - - private calculateLayout(): TimelineLayoutConfig { - // Calculate proportional heights based on timeline height - const timelineHeight = this.options.height; - - // Calculate toolbar and ruler heights proportionally - // Use theme values if available, otherwise calculate from timeline height - let toolbarHeight = this.theme?.timeline.toolbar.height || Math.round(timelineHeight * TimelineLayout.TOOLBAR_HEIGHT_RATIO); - let rulerHeight = this.theme?.timeline.ruler.height || Math.round(timelineHeight * TimelineLayout.RULER_HEIGHT_RATIO); - - // Apply minimum heights to ensure usability - toolbarHeight = Math.max(toolbarHeight, TimelineLayout.TOOLBAR_HEIGHT_DEFAULT); - rulerHeight = Math.max(rulerHeight, TimelineLayout.RULER_HEIGHT_DEFAULT); - - // Track height from options (already validated in Timeline) - const { trackHeight } = this.options; - - return { - toolbarHeight, - rulerHeight, - trackHeight, - toolbarY: 0, - rulerY: toolbarHeight, - tracksY: toolbarHeight + rulerHeight, - gridY: toolbarHeight + rulerHeight, - playheadY: toolbarHeight, - viewportY: toolbarHeight + rulerHeight - }; - } - - // Layout getters - get toolbarHeight(): number { - return this.config.toolbarHeight; - } - - get toolbarY(): number { - return this.config.toolbarY; - } - - get rulerHeight(): number { - return this.config.rulerHeight; - } - - get trackHeight(): number { - return this.config.trackHeight; - } - - get rulerY(): number { - return this.config.rulerY; - } - - get tracksY(): number { - return this.config.tracksY; - } - - get gridY(): number { - return this.config.gridY; - } - - get playheadY(): number { - return this.config.playheadY; - } - - get viewportY(): number { - return this.config.viewportY; - } - - // Positioning methods - public positionTrack(trackIndex: number): number { - return trackIndex * this.trackHeight; - } - - public positionClip(startTime: number): number { - return startTime * this.options.pixelsPerSecond; - } - - public calculateClipWidth(duration: number): number { - return Math.max(LAYOUT_CONSTANTS.MIN_CLIP_WIDTH, duration * this.options.pixelsPerSecond); - } - - public calculateDropPosition(globalX: number, globalY: number): { track: number; time: number; x: number; y: number } { - // Adjust Y to account for ruler - const adjustedY = globalY - this.tracksY; - - const trackIndex = Math.floor(adjustedY / this.trackHeight); - const time = Math.max(0, globalX / this.options.pixelsPerSecond); - - return { - track: Math.max(0, trackIndex), - time, - x: globalX, - y: adjustedY - }; - } - - public getTrackAtY(y: number): number { - // Adjust Y to account for ruler - const adjustedY = y - this.tracksY; - return Math.floor(adjustedY / this.trackHeight); - } - - public getTimeAtX(x: number): number { - return x / this.options.pixelsPerSecond; - } - - public getXAtTime(time: number): number { - return time * this.options.pixelsPerSecond; - } - - public getYAtTrack(trackIndex: number): number { - return this.tracksY + trackIndex * this.trackHeight; - } - - // Grid and ruler dimensions - public getGridHeight(): number { - return this.options.height - this.toolbarHeight - this.rulerHeight; - } - - public getRulerWidth(): number { - return this.options.width; - } - - public getGridWidth(): number { - return this.options.width; - } - - // Viewport scroll calculations - public calculateViewportPosition(scrollX: number, scrollY: number): { x: number; y: number } { - return { - x: -scrollX, - y: this.viewportY - scrollY - }; - } - - // Update layout when options or theme change - public updateOptions(options: Required, theme?: TimelineTheme): void { - this.options = options; - this.theme = theme; - this.config = this.calculateLayout(); - } - - // Utility methods - public isPointInToolbar(_x: number, y: number): boolean { - return y >= this.toolbarY && y <= this.toolbarY + this.toolbarHeight; - } - - public isPointInRuler(_x: number, y: number): boolean { - return y >= this.rulerY && y <= this.rulerY + this.rulerHeight; - } - - public isPointInTracks(_x: number, y: number): boolean { - return y >= this.tracksY && y <= this.options.height; - } - - public getVisibleTrackRange(scrollY: number, viewportHeight: number): { start: number; end: number } { - const adjustedScrollY = scrollY; - const startTrack = Math.floor(adjustedScrollY / this.trackHeight); - const endTrack = Math.ceil((adjustedScrollY + viewportHeight) / this.trackHeight); - - return { - start: Math.max(0, startTrack), - end: Math.max(0, endTrack) - }; - } -} diff --git a/src/components/timeline/timeline-toolbar.ts b/src/components/timeline/timeline-toolbar.ts deleted file mode 100644 index 80af28b9..00000000 --- a/src/components/timeline/timeline-toolbar.ts +++ /dev/null @@ -1,158 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { Edit } from "../../core/edit"; -import { TimelineTheme } from "../../core/theme"; - -import { TimelineLayout } from "./timeline-layout"; -import { TOOLBAR_CONSTANTS, PlaybackControls, TimeDisplay, EditControls, ToolbarLayout } from "./toolbar"; - -export class TimelineToolbar extends PIXI.Container { - private background!: PIXI.Graphics; - private playbackControls!: PlaybackControls; - private timeDisplay!: TimeDisplay; - private editControls!: EditControls; - private toolbarLayout!: ToolbarLayout; - - private toolbarWidth: number; - private toolbarHeight: number; - - public override get width(): number { - return this.toolbarWidth; - } - - public override get height(): number { - return this.toolbarHeight; - } - - constructor( - private edit: Edit, - private theme: TimelineTheme, - private layout: TimelineLayout, - width: number - ) { - super(); - this.toolbarWidth = width; - this.toolbarHeight = layout.toolbarHeight; - - // Position at top of timeline - this.position.set(0, layout.toolbarY); - - // Initialize layout manager - this.toolbarLayout = new ToolbarLayout(width, this.toolbarHeight); - - // Create components - this.createBackground(); - this.createComponents(); - this.positionComponents(); - - // Subscribe to edit events for updates - this.subscribeToEditEvents(); - } - - private createBackground(): void { - this.background = new PIXI.Graphics(); - this.drawBackground(); - this.addChild(this.background); - } - - private drawBackground(): void { - this.background.clear(); - this.background.rect(0, 0, this.toolbarWidth, this.toolbarHeight); - this.background.fill({ color: this.theme.timeline.toolbar.background }); - - // Add subtle bottom border to separate from ruler - this.background.setStrokeStyle({ - width: 1, - color: this.theme.timeline.toolbar.divider, - alpha: TOOLBAR_CONSTANTS.DIVIDER_ALPHA - }); - this.background.moveTo(0, this.toolbarHeight - 0.5); - this.background.lineTo(this.toolbarWidth, this.toolbarHeight - 0.5); - this.background.stroke(); - } - - private createComponents(): void { - // Create playback controls - this.playbackControls = new PlaybackControls(this.edit, this.theme, this.toolbarHeight); - this.addChild(this.playbackControls); - - // Create time display - this.timeDisplay = new TimeDisplay(this.edit, this.theme); - this.addChild(this.timeDisplay); - - // Create edit controls - this.editControls = new EditControls(this.edit, this.theme); - this.addChild(this.editControls); - } - - private positionComponents(): void { - // Position playback controls - const playbackPos = this.toolbarLayout.getPlaybackControlsPosition(); - this.playbackControls.position.set(playbackPos.x, playbackPos.y); - - // Position time display - const timePos = this.toolbarLayout.getTimeDisplayPosition(this.playbackControls.getWidth()); - this.timeDisplay.position.set(timePos.x, timePos.y); - - // Position edit controls - const editPos = this.toolbarLayout.getEditControlsPosition(); - this.editControls.position.set(editPos.x, editPos.y); - } - - private subscribeToEditEvents(): void { - // Listen for selection changes to update edit controls - this.edit.events.on("clip:selected", this.updateEditControls); - this.edit.events.on("selection:cleared", this.updateEditControls); - } - - private updateEditControls = (): void => { - this.editControls.update(); - }; - - public resize(width: number): void { - this.toolbarWidth = width; - - // Update layout - this.toolbarLayout.updateWidth(width); - - // Redraw background - this.drawBackground(); - - // Reposition components - this.positionComponents(); - - // Notify components of resize - this.playbackControls.resize(width); - this.timeDisplay.resize(width); - this.editControls.resize(width); - } - - public updateTheme(theme: TimelineTheme): void { - this.theme = theme; - - // Update background - this.drawBackground(); - - // Update all components - this.playbackControls.updateTheme(theme); - this.timeDisplay.updateTheme(theme); - this.editControls.updateTheme(theme); - } - - public updateTimeDisplay = (): void => { - this.timeDisplay.update(); - }; - - public override destroy(): void { - // Unsubscribe from events - this.edit.events.off("clip:selected", this.updateEditControls); - this.edit.events.off("selection:cleared", this.updateEditControls); - - // Destroy components - this.playbackControls.destroy(); - this.timeDisplay.destroy(); - this.editControls.destroy(); - - super.destroy(); - } -} diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index c25db697..ad254c56 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -1,470 +1,512 @@ -import { Edit } from "@core/edit"; -import { Entity } from "@core/shared/entity"; -import * as PIXI from "pixi.js"; - -import { TimelineTheme, TimelineThemeOptions, TimelineThemeResolver } from "../../core/theme"; - -import { InteractionController } from "./interaction"; -import { - DragPreviewManager, - ViewportManager, - VisualTrackManager, - TimelineEventHandler, - TimelineRenderer, - TimelineFeatureManager, - TimelineOptionsManager -} from "./managers"; -import { TimelineLayout } from "./timeline-layout"; -import { EditType, TimelineOptions, ClipInfo, ResolvedClip } from "./types/timeline"; -import { VisualTrack } from "./visual/visual-track"; - -export class Timeline extends Entity { - private currentEditType: EditType | null = null; - private layout: TimelineLayout; - private theme: TimelineTheme; - private lastPlaybackTime = 0; - - // Timeline constants - private static readonly TIMELINE_BUFFER_MULTIPLIER = 1.5; // 50% buffer for scrolling - - // Managers - private interaction!: InteractionController; - private dragPreviewManager!: DragPreviewManager; - private viewportManager!: ViewportManager; - private visualTrackManager!: VisualTrackManager; - private eventHandler!: TimelineEventHandler; - private renderer!: TimelineRenderer; - private featureManager!: TimelineFeatureManager; - private optionsManager!: TimelineOptionsManager; +import type { Edit } from "@core/edit"; + +import { PlayheadComponent } from "./components/playhead/playhead-component"; +import { RulerComponent } from "./components/ruler/ruler-component"; +import { ToolbarComponent } from "./components/toolbar/toolbar-component"; +import { TrackListComponent } from "./components/track/track-list"; +import { TimelineStateManager } from "./core/state/timeline-state"; +import { TimelineEntity } from "./core/timeline-entity"; +import type { TimelineOptions, TimelineFeatures, ClipRenderer, ClipInfo } from "@timeline/timeline.types"; +import { InteractionController } from "./interaction/interaction-controller"; +import { getTimelineStyles } from "./styles/timeline.css"; + +/** HTML/CSS-based Timeline component extending TimelineEntity for SDK consistency */ +export class Timeline extends TimelineEntity { + private readonly container: HTMLElement; + private readonly stateManager: TimelineStateManager; + + // Feature flags + private features: Required; + + // Custom renderers + private clipRenderers = new Map(); + + // Components (stored separately from children for typed access) + private toolbar: ToolbarComponent | null = null; + private rulerTracksWrapper: HTMLElement | null = null; + private ruler: RulerComponent | null = null; + private trackList: TrackListComponent | null = null; + private playhead: PlayheadComponent | null = null; + private playheadGhost: HTMLElement | null = null; + private feedbackLayer: HTMLElement | null = null; + private interactionController: InteractionController | null = null; + + // Style element for scoped CSS + private styleElement: HTMLStyleElement | null = null; + + // Hybrid render loop state + private animationFrameId: number | null = null; + private isRenderLoopActive = false; + private lastFrameTime = 0; + private isInteracting = false; + private isLoaded = false; + + // Bound event handlers for cleanup + private readonly handleTimelineUpdated: () => void; + private readonly handlePlaybackPlay: () => void; + private readonly handlePlaybackPause: () => void; + private readonly handlePlaybackStop: () => void; + private readonly handleClipSelected: () => void; constructor( - private edit: Edit, - size: { width: number; height: number }, - themeOptions?: TimelineThemeOptions + private readonly edit: Edit, + container: HTMLElement, + options: TimelineOptions = {} ) { - super(); - - // Resolve theme from options - this.theme = TimelineThemeResolver.resolveTheme(themeOptions); - - // Create layout first as it's needed by options manager - this.layout = new TimelineLayout( - { - width: size.width, - height: size.height, - pixelsPerSecond: 50, - trackHeight: Math.max(40, this.theme.timeline.tracks.height || TimelineLayout.TRACK_HEIGHT_DEFAULT), - backgroundColor: this.theme.timeline.background, - antialias: true, - resolution: window.devicePixelRatio || 1 - }, - this.theme - ); - - // Initialize options manager - this.optionsManager = new TimelineOptionsManager(size, this.theme, this.layout, width => this.featureManager?.getToolbar()?.resize(width)); + super("div", "ss-html-timeline"); + + this.container = container; + + // Merge default features with provided options + this.features = { + toolbar: options.features?.toolbar ?? true, + ruler: options.features?.ruler ?? true, + playhead: options.features?.playhead ?? true, + snap: options.features?.snap ?? true, + badges: options.features?.badges ?? true, + multiSelect: options.features?.multiSelect ?? true + }; - this.initializeManagers(); - this.setupInteraction(); - } + // Configure root element to fill container + this.element.style.width = "100%"; + this.element.style.height = "100%"; - private initializeManagers(): void { - const options = this.optionsManager.getOptions(); - - // Initialize renderer with required properties - this.renderer = new TimelineRenderer( - { - width: options.width || 800, - height: options.height || 600, - backgroundColor: options.backgroundColor || 0x000000, - antialias: options.antialias ?? true, - resolution: options.resolution || window.devicePixelRatio || 1 - }, - (deltaTime, elapsed) => this.update(deltaTime, elapsed) - ); - - // Initialize event handler - this.eventHandler = new TimelineEventHandler(this.edit, { - onEditChange: this.handleEditChange.bind(this), - onSeek: time => this.edit.seek(time), - onClipSelected: (trackIndex, clipIndex) => this.visualTrackManager.updateVisualSelection(trackIndex, clipIndex), - onSelectionCleared: () => this.visualTrackManager.clearVisualSelection(), - onDragStarted: (trackIndex, clipIndex) => { - const clipData = this.getClipData(trackIndex, clipIndex); - if (clipData) { - this.dragPreviewManager.showDragPreview(trackIndex, clipIndex, clipData); - } - }, - onDragEnded: () => this.dragPreviewManager.hideDragPreview() + // Create state manager with placeholder size (will be updated in load()) + this.stateManager = new TimelineStateManager(edit, { + width: 800, // placeholder, updated in load() + height: 300, // placeholder, updated in load() + pixelsPerSecond: options.pixelsPerSecond ?? 50 }); - this.eventHandler.setupEventListeners(); + // Bind event handlers + this.handleTimelineUpdated = () => { + // Re-detect luma attachments in case clips were added/removed/moved + this.stateManager.detectAndAttachLumas(); + this.requestRender(); + }; + this.handlePlaybackPlay = () => this.startRenderLoop(); + this.handlePlaybackPause = () => { + this.stopRenderLoop(); + this.requestRender(); // Final render to update UI with paused state + }; + this.handlePlaybackStop = () => { + this.stopRenderLoop(); + this.requestRender(); // Final render to update UI with stopped state + }; + this.handleClipSelected = () => this.requestRender(); } + /** Initialize and mount the timeline */ public async load(): Promise { - await this.renderer.initializePixiApp(); - await this.renderer.setupRenderLayers(); - await this.setupViewport(); - await this.setupTimelineFeatures(); - - // Activate interaction system after PIXI is ready - this.interaction.activate(); - - // Try to render initial state from Edit - try { - const currentEdit = this.edit.getResolvedEdit(); - if (currentEdit) { - // Cache the initial state for tools to query - this.currentEditType = currentEdit; - await this.rebuildFromEdit(currentEdit); - } - } catch { - // Silently handle error - timeline will show empty state - } + if (this.isLoaded) return; - // Start animation loop for continuous rendering - this.renderer.startAnimationLoop(); - } + // Inject styles + this.injectStyles(); - private async setupViewport(): Promise { - // Initialize viewport manager - this.viewportManager = new ViewportManager(this.layout, this.renderer.getTrackLayer(), this.renderer.getOverlayLayer(), this.getContainer(), () => - this.renderer.render() - ); - - await this.viewportManager.setupViewport(); - - // Initialize visual track manager - this.visualTrackManager = new VisualTrackManager( - this.getContainer(), - this.layout, - this.theme, - () => this.optionsManager.getPixelsPerSecond(), - () => this.getExtendedTimelineWidth() - ); - - // Initialize drag preview manager - this.dragPreviewManager = new DragPreviewManager( - this.getContainer(), - this.layout, - () => this.optionsManager.getPixelsPerSecond(), - () => this.optionsManager.getTrackHeight(), - () => this.visualTrackManager.getVisualTracks() - ); - - // Initialize feature manager - this.featureManager = new TimelineFeatureManager(this.edit, this.layout, this.renderer, this.viewportManager, this.eventHandler, () => this); - - // Initial viewport positioning will be done in setupTimelineFeatures - // after ruler height is known - } + // Mount to container first so we can measure + this.container.appendChild(this.element); - private async setupTimelineFeatures(): Promise { - const extendedDuration = this.getExtendedTimelineDuration(); + // Get actual size from container + const rect = this.container.getBoundingClientRect(); + const width = rect.width || 800; + const height = rect.height || 300; + this.stateManager.setViewport({ width, height }); - await this.featureManager.setupTimelineFeatures( - this.theme, - this.optionsManager.getPixelsPerSecond(), - this.optionsManager.getWidth(), - this.optionsManager.getHeight(), - extendedDuration - ); - } + // Build component structure + this.buildComponents(); - private recreateTimelineFeatures(): void { - const extendedDuration = this.getExtendedTimelineDuration(); + // Set up event listeners for hybrid render loop + this.setupEventListeners(); - this.featureManager.recreateTimelineFeatures( - this.theme, - this.optionsManager.getPixelsPerSecond(), - this.optionsManager.getHeight(), - extendedDuration - ); - } + // Initial render (data is derived from Edit on-demand) + this.update(0, performance.now()); + this.draw(); - // Viewport management methods for tools - public setScroll(x: number, y: number): void { - this.viewportManager.setScroll(x, y); + this.isLoaded = true; } - public setZoom(zoom: number): void { - this.viewportManager.setZoom(zoom); + /** Update component state (called each frame during active rendering) */ + public update(_deltaTime: number, _elapsed: number): void { + // State manager already syncs with Edit via events + // This method is here for TimelineEntity conformance + // Children that extend TimelineEntity will be updated via updateChildren() } - public getViewport(): { x: number; y: number; zoom: number } { - return this.viewportManager.getViewport(); - } + /** Render/draw component to DOM (called each frame after update) */ + public draw(): void { + // Derive state from Edit on-demand (single source of truth) + const viewport = this.stateManager.getViewport(); + const playback = this.stateManager.getPlayback(); + const tracks = this.stateManager.getTracks(); - // Combined getter for PIXI resources - /** @internal */ - public getPixiApp(): PIXI.Application { - return this.renderer.getApp(); - } + // Update CSS variable for clip/playhead positioning + this.element.style.setProperty("--ss-timeline-pixels-per-second", String(viewport.pixelsPerSecond)); - /** @internal */ - public getTrackLayer(): PIXI.Container { - return this.renderer.getTrackLayer(); - } + // Update toolbar + this.toolbar?.updatePlayState(playback.isPlaying); + this.toolbar?.updateTimeDisplay(playback.time, playback.duration); + this.toolbar?.draw(); - /** @internal */ - public getOverlayLayer(): PIXI.Container { - return this.renderer.getOverlayLayer(); - } + // Update ruler and draw + this.ruler?.updateRuler(viewport.pixelsPerSecond, this.stateManager.getExtendedDuration()); + this.ruler?.draw(); - public getClipData(trackIndex: number, clipIndex: number): ResolvedClip | null { - if (!this.currentEditType?.timeline?.tracks) return null; - const track = this.currentEditType.timeline.tracks[trackIndex]; - return (track?.clips?.[clipIndex] as ResolvedClip) || null; - } + // Update tracks and draw + this.trackList?.updateTracks(tracks, this.stateManager.getTimelineWidth(), viewport.pixelsPerSecond); + this.trackList?.draw(); - // Layout access for interactions - public getLayout(): TimelineLayout { - return this.layout; + // Update playhead + this.playhead?.setTime(playback.time); + this.playhead?.draw(); } - // Visual tracks access for interactions - public getVisualTracks(): VisualTrack[] { - return this.visualTrackManager.getVisualTracks(); - } + /** Clean up and unmount the timeline */ + public dispose(): void { + // Stop animation loop + this.stopRenderLoop(); - // Edit access for interactions - public getEdit(): Edit { - return this.edit; - } + // Remove event listeners + this.removeEventListeners(); - // Extended timeline dimensions - public getExtendedTimelineWidth(): number { - const calculatedWidth = this.getExtendedTimelineDuration() * this.optionsManager.getPixelsPerSecond(); - const viewportWidth = this.optionsManager.getWidth(); - // Ensure width is at least as wide as the viewport - return Math.max(calculatedWidth, viewportWidth); - } + // Dispose state manager + this.stateManager.dispose(); - // Drag ghost control methods for TimelineInteraction - public hideDragGhost(): void { - this.dragPreviewManager.hideDragGhost(); - } + // Dispose components + this.disposeComponents(); - public showDragGhost(trackIndex: number, time: number, freeY?: number): void { - this.dragPreviewManager.showDragGhost(trackIndex, time, freeY); - } + // Clean up custom renderers + this.clipRenderers.clear(); - // Playhead control methods - public setPlayheadTime(time: number): void { - this.featureManager.getPlayhead().setTime(time); + // Remove DOM + this.element.remove(); + + // Remove styles + if (this.styleElement) { + this.styleElement.remove(); + this.styleElement = null; + } + + this.isLoaded = false; } - public getPlayheadTime(): number { - return this.featureManager.getPlayhead().getTime(); + // ========== Hybrid Render Loop ========== + + private setupEventListeners(): void { + // Listen for timeline data changes (single render when idle) + this.edit.events.on("timeline:updated", this.handleTimelineUpdated); + + // Listen for playback state changes (start/stop render loop) + this.edit.events.on("playback:play", this.handlePlaybackPlay); + this.edit.events.on("playback:pause", this.handlePlaybackPause); + this.edit.events.on("playback:stop", this.handlePlaybackStop); + + // Listen for selection changes (from canvas or other sources) + this.edit.events.on("clip:selected", this.handleClipSelected); } - public getActualEditDuration(): number { - // Return the actual edit duration in seconds (without the 1.5x buffer) - return this.edit.totalDuration / 1000 || 60; + private removeEventListeners(): void { + this.edit.events.off("timeline:updated", this.handleTimelineUpdated); + this.edit.events.off("playback:play", this.handlePlaybackPlay); + this.edit.events.off("playback:pause", this.handlePlaybackPause); + this.edit.events.off("playback:stop", this.handlePlaybackStop); + this.edit.events.off("clip:selected", this.handleClipSelected); } - /** @internal */ - private setupInteraction(): void { - this.interaction = new InteractionController(this); + /** Start continuous render loop (during playback or interaction) */ + private startRenderLoop(): void { + if (this.isRenderLoopActive) return; + this.isRenderLoopActive = true; + this.lastFrameTime = performance.now(); + this.tick(); + } - // Interaction will be activated in the load() method after PIXI is ready + /** Stop continuous render loop */ + private stopRenderLoop(): void { + this.isRenderLoopActive = false; + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } } - /** @internal */ - private async handleEditChange(editType?: EditType): Promise { - // Clean up drag preview before rebuilding - this.dragPreviewManager.hideDragPreview(); + /** Animation frame callback */ + private tick = (): void => { + if (!this.isRenderLoopActive) return; - // Get current edit state (always use resolved values for positioning) - const currentEdit = editType || this.edit.getResolvedEdit(); - if (!currentEdit) return; + const now = performance.now(); + const deltaTime = now - this.lastFrameTime; + this.lastFrameTime = now; - // Cache current state - this.currentEditType = currentEdit; + this.update(deltaTime, now); + this.draw(); - // Update ruler with new timeline duration - this.updateRulerDuration(); + // Continue loop if playing or interacting + if (this.edit.isPlaying || this.isInteracting) { + this.animationFrameId = requestAnimationFrame(this.tick); + } else { + this.isRenderLoopActive = false; + this.animationFrameId = null; + } + }; - // Rebuild visuals from event data - this.clearAllVisualState(); - await this.rebuildFromEdit(currentEdit); + /** Request a single render (used when idle and data changes) */ + private requestRender(): void { + if (this.isRenderLoopActive) return; // Loop already running + this.update(0, performance.now()); + this.draw(); } - /** @internal */ - private getExtendedTimelineDuration(): number { - const duration = this.edit.totalDuration / 1000 || 60; - return Math.max(60, duration * Timeline.TIMELINE_BUFFER_MULTIPLIER); + /** Mark interaction as started (enables render loop) */ + public beginInteraction(): void { + this.isInteracting = true; + this.startRenderLoop(); } - /** @internal */ - private updateRulerDuration(): void { - const extendedDuration = this.getExtendedTimelineDuration(); - const extendedWidth = this.getExtendedTimelineWidth(); + /** Mark interaction as ended (may stop render loop) */ + public endInteraction(): void { + this.isInteracting = false; + // Loop will stop on next tick if not playing + } - // Update ruler with extended duration - this.featureManager.updateRuler(this.optionsManager.getPixelsPerSecond(), extendedDuration); + // ========== Component Building ========== - // Update track widths - this.visualTrackManager.updateTrackWidths(extendedWidth); + private injectStyles(): void { + this.styleElement = document.createElement("style"); + this.styleElement.textContent = getTimelineStyles(); + document.head.appendChild(this.styleElement); } - /** @internal */ - private clearAllVisualState(): void { - // Make sure drag preview is cleaned up - this.dragPreviewManager.hideDragPreview(); + private buildComponents(): void { + // Clear existing content + this.element.innerHTML = ""; - // Clear all visual timeline components - this.visualTrackManager.clearAllVisualState(); - } + const viewport = this.stateManager.getViewport(); - /** @internal */ - private async rebuildFromEdit(editType: EditType): Promise { - await this.visualTrackManager.rebuildFromEdit(editType, this.optionsManager.getPixelsPerSecond()); - // Force a render - this.renderer.render(); - } + // Build toolbar + if (this.features.toolbar) { + this.toolbar = new ToolbarComponent( + { + onPlay: () => this.edit.play(), + onPause: () => this.edit.pause(), + onSkipBack: () => this.edit.seek(Math.max(0, this.edit.playbackTime - 1000)), + onSkipForward: () => this.edit.seek(this.edit.playbackTime + 1000), + onZoomChange: pps => this.setZoom(pps) + }, + viewport.pixelsPerSecond + ); + this.element.appendChild(this.toolbar.element); + } - // Public API for tools to query cached state - public findClipAtPosition(x: number, y: number): ClipInfo | null { - if (!this.currentEditType) return null; - return this.visualTrackManager.findClipAtPosition(x, y); - } + // Create wrapper for ruler + tracks + playhead (so playhead can span both) + this.rulerTracksWrapper = document.createElement("div"); + this.rulerTracksWrapper.className = "ss-ruler-tracks-wrapper"; + this.element.appendChild(this.rulerTracksWrapper); + + // Build ruler + if (this.features.ruler) { + this.ruler = new RulerComponent({ + onSeek: timeMs => this.edit.seek(timeMs), + onWheel: e => { + if (this.trackList) { + this.trackList.element.scrollTop += e.deltaY; + this.trackList.element.scrollLeft += e.deltaX; + } + } + }); + this.rulerTracksWrapper.appendChild(this.ruler.element); + } - // Theme management methods - public setTheme(themeOptions: TimelineThemeOptions): void { - this.theme = TimelineThemeResolver.resolveTheme(themeOptions); + // Build track list + this.trackList = new TrackListComponent({ + showBadges: this.features.badges, + onClipSelect: (trackIndex, clipIndex, addToSelection) => { + if (this.features.multiSelect && addToSelection) { + this.stateManager.selectClip(trackIndex, clipIndex, true); + } else { + this.stateManager.selectClip(trackIndex, clipIndex, false); + } + this.edit.selectClip(trackIndex, clipIndex); + this.requestRender(); + }, + getClipRenderer: type => this.clipRenderers.get(type), + isLumaAttached: (trackIndex, clipIndex) => this.stateManager.isLumaAttached(trackIndex, clipIndex), + getAttachedLuma: (trackIndex, clipIndex) => this.stateManager.getAttachedLuma(trackIndex, clipIndex), + onMaskClick: (contentTrackIndex, contentClipIndex) => { + this.stateManager.toggleLumaVisibility(contentTrackIndex, contentClipIndex); + + // Select the luma clip when toggling mask visibility + const lumaRef = this.stateManager.getAttachedLuma(contentTrackIndex, contentClipIndex); + if (lumaRef) { + this.edit.selectClip(lumaRef.trackIndex, lumaRef.clipIndex); + } - // Update options manager with new theme - this.optionsManager.updateFromTheme(this.theme); + this.requestRender(); + }, + isLumaVisibleForEditing: (contentTrackIndex, contentClipIndex) => + this.stateManager.isLumaVisibleForEditing(contentTrackIndex, contentClipIndex), + getContentClipForLuma: (lumaTrack, lumaClip) => this.stateManager.getContentClipForLuma(lumaTrack, lumaClip) + }); - // Update toolbar theme - if (this.featureManager.getToolbar()) { - this.featureManager.getToolbar().updateTheme(this.theme); + // Set up scroll sync (also sync playhead) + this.trackList.setScrollHandler((scrollX, scrollY) => { + this.stateManager.setScroll(scrollX, scrollY); + this.ruler?.syncScroll(scrollX); + // Sync playhead with track scroll + if (this.playhead) { + this.playhead.element.style.transform = `translateX(${-scrollX}px)`; + this.playhead.setScrollX(scrollX); + } + }); + + this.rulerTracksWrapper.appendChild(this.trackList.element); + + // Build playhead (at wrapper level so it spans ruler + tracks) + if (this.features.playhead) { + this.playhead = new PlayheadComponent({ + onSeek: timeMs => this.edit.seek(timeMs) + }); + this.playhead.setPixelsPerSecond(viewport.pixelsPerSecond); + this.rulerTracksWrapper.appendChild(this.playhead.element); + + // Build playhead ghost (hover preview) + this.playheadGhost = document.createElement("div"); + this.playheadGhost.className = "ss-playhead-ghost"; + this.rulerTracksWrapper.appendChild(this.playheadGhost); + + this.rulerTracksWrapper.addEventListener("mousemove", e => { + if (!this.playheadGhost || !this.rulerTracksWrapper) return; + const rect = this.rulerTracksWrapper.getBoundingClientRect(); + const scrollX = this.trackList?.element.scrollLeft ?? 0; + const x = e.clientX - rect.left + scrollX; + this.playheadGhost.style.left = `${x}px`; + }); } - // Recreate timeline features with new theme and dimensions - this.recreateTimelineFeatures(); + // Build feedback layer (inside rulerTracksWrapper so coordinates align with tracks) + this.feedbackLayer = document.createElement("div"); + this.feedbackLayer.className = "ss-feedback-layer"; + this.rulerTracksWrapper.appendChild(this.feedbackLayer); - // Rebuild visuals with new theme - if (this.currentEditType) { - this.clearAllVisualState(); - this.rebuildFromEdit(this.currentEditType); - } + // Initialize interaction controller + this.interactionController = new InteractionController(this.edit, this.stateManager, this.trackList.element, this.feedbackLayer, { + snapThreshold: this.features.snap ? 10 : 0 + }); - // Update PIXI app background - this.renderer.updateBackgroundColor(this.optionsManager.getBackgroundColor()); - this.renderer.render(); + // Auto-detect luma attachments from existing clips (e.g., on template load) + this.stateManager.detectAndAttachLumas(); } - public getTheme(): TimelineTheme { - return this.theme; - } + private disposeComponents(): void { + this.interactionController?.dispose(); + this.interactionController = null; - // Getters for current state - public getCurrentEditType(): EditType | null { - return this.currentEditType; - } + this.toolbar?.dispose(); + this.toolbar = null; - public getOptions(): TimelineOptions { - return this.optionsManager.getOptions(); - } + this.ruler?.dispose(); + this.ruler = null; - public setOptions(options: Partial): void { - this.optionsManager.setOptions(options); - } + this.playhead?.dispose(); + this.playhead = null; - // Required Entity methods - /** @internal */ - public update(_deltaTime: number, _elapsed: number): void { - // Sync playhead with Edit playback time - if (this.edit.isPlaying || this.lastPlaybackTime !== this.edit.playbackTime) { - this.featureManager.getPlayhead().setTime(this.edit.playbackTime / 1000); - this.lastPlaybackTime = this.edit.playbackTime; - - // Update toolbar time display - if (this.featureManager.getToolbar()) { - this.featureManager.getToolbar().updateTimeDisplay(); - } - } - } + this.trackList?.dispose(); + this.trackList = null; - /** @internal */ - public draw(): void { - // Render the PIXI application - this.renderer.draw(); - } + this.rulerTracksWrapper?.remove(); + this.rulerTracksWrapper = null; - // Methods for TimelineReference interface - public getTimeDisplay(): { updateTimeDisplay(): void } { - return this.featureManager.getToolbar(); + this.feedbackLayer?.remove(); + this.feedbackLayer = null; } - public updateTime(time: number, emit?: boolean): void { - this.setPlayheadTime(time); - if (emit) { - this.edit.seek(time * 1000); // Convert to milliseconds - } + // ========== Public API ========== + + public setZoom(pixelsPerSecond: number): void { + this.stateManager.setPixelsPerSecond(pixelsPerSecond); + this.toolbar?.setZoom(pixelsPerSecond); + this.playhead?.setPixelsPerSecond(pixelsPerSecond); + this.requestRender(); } - public get timeRange(): { startTime: number; endTime: number } { - return { - startTime: 0, - endTime: this.getExtendedTimelineDuration() - }; + public zoomIn(): void { + const current = this.stateManager.getViewport().pixelsPerSecond; + this.setZoom(Math.min(200, current * 1.2)); } - public get viewportHeight(): number { - return this.optionsManager.getHeight(); + public zoomOut(): void { + const current = this.stateManager.getViewport().pixelsPerSecond; + this.setZoom(Math.max(10, current / 1.2)); } - public get zoomLevelIndex(): number { - // Convert zoom level to index (simplified - you may want to map this to actual zoom levels) - const viewport = this.viewportManager.getViewport(); - return Math.round(Math.log2(viewport.zoom) + 5); + public scrollTo(time: number): void { + if (!this.trackList) return; + + const pps = this.stateManager.getViewport().pixelsPerSecond; + this.trackList.setScrollPosition(time * pps, 0); } - public zoomIn(): void { - this.optionsManager.zoomIn(); - this.onZoomChanged(); + /** Recalculate size from container and re-render */ + public resize(): void { + const rect = this.container.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + this.stateManager.setViewport({ width: rect.width, height: rect.height }); + this.requestRender(); } - public zoomOut(): void { - this.optionsManager.zoomOut(); - this.onZoomChanged(); + public selectClip(trackIndex: number, clipIndex: number): void { + this.stateManager.selectClip(trackIndex, clipIndex, false); + this.edit.selectClip(trackIndex, clipIndex); + this.requestRender(); } - private onZoomChanged(): void { - const pixelsPerSecond = this.optionsManager.getPixelsPerSecond(); + public clearSelection(): void { + this.stateManager.clearSelection(); + this.edit.clearSelection(); + this.requestRender(); + } - // Update visual tracks without rebuilding to preserve event handlers - this.visualTrackManager.updatePixelsPerSecond(pixelsPerSecond); + public enableFeature(feature: keyof TimelineFeatures): void { + this.features[feature] = true; + this.disposeComponents(); + this.buildComponents(); + this.requestRender(); + } - // Update track widths to match new zoom level - const extendedWidth = this.getExtendedTimelineWidth(); - this.visualTrackManager.updateTrackWidths(extendedWidth); + public disableFeature(feature: keyof TimelineFeatures): void { + this.features[feature] = false; + this.disposeComponents(); + this.buildComponents(); + this.requestRender(); + } - // Update timeline features - this.featureManager.updateRuler(pixelsPerSecond, this.getExtendedTimelineDuration()); - this.featureManager.updatePlayhead(pixelsPerSecond, this.optionsManager.getHeight()); + public registerClipRenderer(type: string, renderer: ClipRenderer): void { + this.clipRenderers.set(type, renderer); + } - // Force a render - this.renderer.render(); + public getEdit(): Edit { + return this.edit; } - /** @internal */ - public dispose(): void { - // Clean up managers - this.dragPreviewManager.dispose(); - this.visualTrackManager.dispose(); - this.eventHandler.dispose(); - this.featureManager.dispose(); - - // Clean up interaction system - if (this.interaction) { - this.interaction.dispose(); + public findClipAtPosition(x: number, y: number): ClipInfo | null { + if (!this.trackList) return null; + + const rect = this.trackList.element.getBoundingClientRect(); + const relativeX = x - rect.left; + const relativeY = y - rect.top; + const viewport = this.stateManager.getViewport(); + const trackHeight = 64; // TODO: get from theme + + const clipState = this.trackList.findClipAtPosition(relativeX, relativeY, trackHeight, viewport.pixelsPerSecond); + + if (clipState) { + return { + trackIndex: clipState.trackIndex, + clipIndex: clipState.clipIndex, + config: clipState.config + }; } - // Destroy renderer - this.renderer.dispose(); + return null; } } diff --git a/src/components/timeline-html/html-timeline.types.ts b/src/components/timeline/timeline.types.ts similarity index 88% rename from src/components/timeline-html/html-timeline.types.ts rename to src/components/timeline/timeline.types.ts index d9bd0876..6e13b588 100644 --- a/src/components/timeline-html/html-timeline.types.ts +++ b/src/components/timeline/timeline.types.ts @@ -1,19 +1,19 @@ import type { ResolvedClip } from "@schemas/clip"; -/** Configuration options for HtmlTimeline */ -export interface HtmlTimelineOptions { +/** Configuration options for Timeline */ +export interface TimelineOptions { /** Feature toggles */ - features?: HtmlTimelineFeatures; + features?: TimelineFeatures; /** Interaction configuration */ - interaction?: HtmlTimelineInteractionConfig; + interaction?: TimelineInteractionConfig; /** Initial pixels per second (zoom level) */ pixelsPerSecond?: number; /** Track height in pixels */ trackHeight?: number; } -/** Feature toggles for HtmlTimeline */ -export interface HtmlTimelineFeatures { +/** Feature toggles for Timeline */ +export interface TimelineFeatures { /** Show toolbar with playback controls */ toolbar?: boolean; /** Show time ruler */ @@ -29,7 +29,7 @@ export interface HtmlTimelineFeatures { } /** Interaction configuration */ -export interface HtmlTimelineInteractionConfig { +export interface TimelineInteractionConfig { /** Minimum pixels to move before starting drag */ dragThreshold?: number; /** Snap distance in pixels */ @@ -107,7 +107,7 @@ export interface ClipRenderer { } /** Default feature settings */ -export const DEFAULT_FEATURES: Required = { +export const DEFAULT_FEATURES: Required = { toolbar: true, ruler: true, playhead: true, @@ -117,7 +117,7 @@ export const DEFAULT_FEATURES: Required = { }; /** Default interaction settings */ -export const DEFAULT_INTERACTION: Required = { +export const DEFAULT_INTERACTION: Required = { dragThreshold: 3, snapThreshold: 10, resizeZone: 12 diff --git a/src/components/timeline/toolbar/components/edit-controls.ts b/src/components/timeline/toolbar/components/edit-controls.ts deleted file mode 100644 index a9d1f1e4..00000000 --- a/src/components/timeline/toolbar/components/edit-controls.ts +++ /dev/null @@ -1,143 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { Edit } from "../../../../core/edit"; -import { TimelineTheme } from "../../../../core/theme"; -import { TOOLBAR_CONSTANTS } from "../constants"; -import { ToolbarComponent } from "../types"; - -export class EditControls extends PIXI.Container implements ToolbarComponent { - private edit: Edit; - private theme: TimelineTheme; - private cutButton!: PIXI.Container; - private cutButtonBackground!: PIXI.Graphics; - private cutButtonText!: PIXI.Text; - - constructor(edit: Edit, theme: TimelineTheme) { - super(); - - this.edit = edit; - this.theme = theme; - - this.createCutButton(); - } - - private createCutButton(): void { - this.cutButton = new PIXI.Container(); - this.cutButton.eventMode = "static"; - this.cutButton.cursor = "pointer"; - - const { WIDTH, HEIGHT, FONT_SIZE } = TOOLBAR_CONSTANTS.CUT_BUTTON; - - // Create background - this.cutButtonBackground = new PIXI.Graphics(); - this.cutButtonBackground.roundRect(0, 0, WIDTH, HEIGHT, TOOLBAR_CONSTANTS.BORDER_RADIUS); - this.cutButtonBackground.fill({ color: this.theme.timeline.toolbar.surface || 0x444444 }); - this.cutButtonBackground.stroke({ - color: this.theme.timeline.tracks.border || 0x666666, - width: 1 - }); - this.cutButton.addChild(this.cutButtonBackground); - - // Create text - const textStyle = new PIXI.TextStyle({ - fontFamily: "Arial", - fontSize: FONT_SIZE, - fill: this.theme.timeline.toolbar.text || 0xffffff - }); - this.cutButtonText = new PIXI.Text("SPLIT", textStyle); - this.cutButtonText.anchor.set(0.5); - this.cutButtonText.position.set(WIDTH / 2, HEIGHT / 2); - this.cutButton.addChild(this.cutButtonText); - - // Add event listeners - this.cutButton.on("click", this.handleCutClick, this); - this.cutButton.on("pointerdown", this.handlePointerDown, this); - this.cutButton.on("pointerover", this.handlePointerOver, this); - this.cutButton.on("pointerout", this.handlePointerOut, this); - - this.addChild(this.cutButton); - } - - private handleCutClick = (event: PIXI.FederatedPointerEvent): void => { - event.stopPropagation(); - this.performCutClip(); - }; - - private handlePointerDown = (event: PIXI.FederatedPointerEvent): void => { - event.stopPropagation(); - this.updateButtonVisual(true, false); - }; - - private handlePointerOver = (): void => { - this.updateButtonVisual(false, true); - }; - - private handlePointerOut = (): void => { - this.updateButtonVisual(false, false); - }; - - private updateButtonVisual(pressed: boolean, hovering: boolean): void { - this.cutButtonBackground.clear(); - this.cutButtonBackground.roundRect( - 0, - 0, - TOOLBAR_CONSTANTS.CUT_BUTTON.WIDTH, - TOOLBAR_CONSTANTS.CUT_BUTTON.HEIGHT, - TOOLBAR_CONSTANTS.BORDER_RADIUS - ); - - let fillColor = this.theme.timeline.toolbar.surface || 0x444444; - const alpha = 1; - - if (pressed) { - fillColor = this.theme.timeline.toolbar.active || 0x333333; - } else if (hovering) { - fillColor = this.theme.timeline.toolbar.hover || 0x555555; - } - - this.cutButtonBackground.fill({ color: fillColor, alpha }); - this.cutButtonBackground.stroke({ - color: this.theme.timeline.tracks.border || 0x666666, - width: 1 - }); - } - - private performCutClip(): void { - const selectedInfo = this.edit.getSelectedClipInfo(); - if (!selectedInfo) { - return; - } - - const { trackIndex, clipIndex } = selectedInfo; - const playheadTime = this.edit.playbackTime / 1000; - - this.edit.splitClip(trackIndex, clipIndex, playheadTime); - } - - public update(): void { - // Update button state based on selection - const hasSelection = this.edit.getSelectedClipInfo() !== null; - this.cutButton.alpha = hasSelection ? 1 : 0.5; - this.cutButton.eventMode = hasSelection ? "static" : "none"; - this.cutButton.cursor = hasSelection ? "pointer" : "default"; - } - - public resize(_width: number): void { - // Edit controls maintain their size - } - - public updateTheme(theme: TimelineTheme): void { - this.theme = theme; - this.updateButtonVisual(false, false); - this.cutButtonText.style.fill = theme.timeline.toolbar.text || 0xffffff; - } - - public override destroy(): void { - this.cutButton.removeAllListeners(); - super.destroy(); - } - - public getWidth(): number { - return TOOLBAR_CONSTANTS.CUT_BUTTON.WIDTH; - } -} diff --git a/src/components/timeline/toolbar/components/playback-controls.ts b/src/components/timeline/toolbar/components/playback-controls.ts deleted file mode 100644 index 986501b8..00000000 --- a/src/components/timeline/toolbar/components/playback-controls.ts +++ /dev/null @@ -1,126 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { Edit } from "../../../../core/edit"; -import { TimelineTheme } from "../../../../core/theme"; -import { TOOLBAR_CONSTANTS } from "../constants"; -import { ToolbarComponent, IconType } from "../types"; - -import { ToolbarButton } from "./toolbar-button"; - -export class PlaybackControls extends PIXI.Container implements ToolbarComponent { - private edit: Edit; - private theme: TimelineTheme; - private toolbarHeight: number; - - private frameBackButton!: ToolbarButton; - private playPauseButton!: ToolbarButton; - private frameForwardButton!: ToolbarButton; - - constructor(edit: Edit, theme: TimelineTheme, toolbarHeight?: number) { - super(); - - this.edit = edit; - this.theme = theme; - this.toolbarHeight = toolbarHeight || 36; // Default height - - this.createButtons(); - this.subscribeToEditEvents(); - this.updatePlayPauseState(); - } - - private createButtons(): void { - const sizes = this.calculateButtonSizes(); - const centerY = (sizes.playButton - sizes.regularButton) / 2; - - // Create buttons with their configurations - const createButton = (iconType: IconType, onClick: () => void, tooltip: string, size: number) => - new ToolbarButton({ iconType, onClick, tooltip, theme: this.theme, size }); - - // Frame back button - this.frameBackButton = createButton("frame-back", () => this.handleFrameBack(), "Previous frame", sizes.regularButton); - this.frameBackButton.position.set(0, centerY); - - // Play/Pause button - this.playPauseButton = new ToolbarButton({ - iconType: "play", - alternateIconType: "pause", - onClick: () => this.handlePlayPause(), - tooltip: "Play/Pause", - theme: this.theme, - size: sizes.playButton - }); - this.playPauseButton.position.set(sizes.regularButton + sizes.spacing, 0); - - // Frame forward button - this.frameForwardButton = createButton("frame-forward", () => this.handleFrameForward(), "Next frame", sizes.regularButton); - this.frameForwardButton.position.set(sizes.regularButton + sizes.spacing + sizes.playButton + sizes.spacing, centerY); - - // Add all buttons - this.addChild(this.frameBackButton, this.playPauseButton, this.frameForwardButton); - } - - private calculateButtonSizes() { - const regularButton = Math.round(this.toolbarHeight * 0.5); - return { - regularButton, - playButton: Math.round(regularButton * 1.5), - spacing: Math.round(this.toolbarHeight * 0.15) - }; - } - - private handleFrameBack(): void { - this.edit.seek(this.edit.playbackTime - TOOLBAR_CONSTANTS.FRAME_TIME_MS); - } - - private handlePlayPause(): void { - if (this.edit.isPlaying) { - this.edit.pause(); - } else { - this.edit.play(); - } - } - - private handleFrameForward(): void { - this.edit.seek(this.edit.playbackTime + TOOLBAR_CONSTANTS.FRAME_TIME_MS); - } - - private subscribeToEditEvents(): void { - this.edit.events.on("playback:play", this.updatePlayPauseState); - this.edit.events.on("playback:pause", this.updatePlayPauseState); - } - - private updatePlayPauseState = (): void => { - this.playPauseButton.setActive(this.edit.isPlaying); - }; - - public update(): void { - // Update any dynamic state if needed - } - - public resize(_width: number): void { - // Controls maintain fixed size, no resize needed - } - - public updateTheme(theme: TimelineTheme): void { - this.theme = theme; - this.frameBackButton.updateTheme(theme); - this.playPauseButton.updateTheme(theme); - this.frameForwardButton.updateTheme(theme); - } - - public override destroy(): void { - this.edit.events.off("playback:play", this.updatePlayPauseState); - this.edit.events.off("playback:pause", this.updatePlayPauseState); - - this.frameBackButton.destroy(); - this.playPauseButton.destroy(); - this.frameForwardButton.destroy(); - - super.destroy(); - } - - public getWidth(): number { - const sizes = this.calculateButtonSizes(); - return sizes.regularButton * 2 + sizes.playButton + sizes.spacing * 2; - } -} diff --git a/src/components/timeline/toolbar/components/time-display.ts b/src/components/timeline/toolbar/components/time-display.ts deleted file mode 100644 index 5d9735da..00000000 --- a/src/components/timeline/toolbar/components/time-display.ts +++ /dev/null @@ -1,102 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { Edit } from "../../../../core/edit"; -import { TimelineTheme } from "../../../../core/theme"; -import { TOOLBAR_CONSTANTS } from "../constants"; -import { ToolbarComponent, TimeFormatOptions } from "../types"; - -export class TimeDisplay extends PIXI.Container implements ToolbarComponent { - private edit: Edit; - private theme: TimelineTheme; - private timeText!: PIXI.Text; - private formatOptions: TimeFormatOptions; - - constructor(edit: Edit, theme: TimelineTheme, formatOptions: TimeFormatOptions = {}) { - super(); - - this.edit = edit; - this.theme = theme; - this.formatOptions = { - showMilliseconds: false, - showHours: false, - ...formatOptions - }; - - this.createDisplay(); - this.subscribeToEditEvents(); - this.updateTimeDisplay(); - } - - private createDisplay(): void { - const textStyle = new PIXI.TextStyle({ - fontFamily: TOOLBAR_CONSTANTS.TIME_DISPLAY.FONT_FAMILY, - fontSize: TOOLBAR_CONSTANTS.TIME_DISPLAY.FONT_SIZE, - fill: this.theme.timeline.toolbar.text - }); - - this.timeText = new PIXI.Text("0:00 / 0:00", textStyle); - this.timeText.anchor.set(0, 0.5); - this.addChild(this.timeText); - } - - private subscribeToEditEvents(): void { - this.edit.events.on("playback:time", this.updateTimeDisplay); - this.edit.events.on("duration:changed", this.updateTimeDisplay); - } - - private updateTimeDisplay = (): void => { - const currentTime = this.formatTime(this.edit.playbackTime / 1000); - const duration = this.formatTime(this.edit.getTotalDuration() / 1000); - this.timeText.text = `${currentTime} / ${duration}`; - }; - - private formatTime(seconds: number): string { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = Math.floor(seconds % 60); - const tenths = Math.floor((seconds % 1) * 10); - - let formatted = ""; - - if (this.formatOptions.showHours || hours > 0) { - formatted += `${hours}:${minutes.toString().padStart(2, "0")}`; - } else { - formatted += `${minutes}`; - } - - formatted += `:${secs.toString().padStart(2, "0")}`; - - if (this.formatOptions.showMilliseconds) { - formatted += `.${tenths}`; - } else { - // Default behavior from original - show tenths - formatted += `.${tenths}`; - } - - return formatted; - } - - public update(): void { - this.updateTimeDisplay(); - } - - public resize(_width: number): void { - // Time display maintains its size - } - - public updateTheme(theme: TimelineTheme): void { - this.theme = theme; - this.timeText.style.fill = theme.timeline.toolbar.text; - } - - public override destroy(): void { - this.edit.events.off("playback:time", this.updateTimeDisplay); - this.edit.events.off("duration:changed", this.updateTimeDisplay); - - super.destroy(); - } - - public getWidth(): number { - return this.timeText.width; - } -} diff --git a/src/components/timeline/toolbar/components/toolbar-button.ts b/src/components/timeline/toolbar/components/toolbar-button.ts deleted file mode 100644 index ce537dc2..00000000 --- a/src/components/timeline/toolbar/components/toolbar-button.ts +++ /dev/null @@ -1,193 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { TimelineTheme } from "../../../../core/theme"; -import { TOOLBAR_CONSTANTS } from "../constants"; -import { IconFactory } from "../icons/icon-factory"; -import { ButtonState, IconType } from "../types"; - -export interface ToolbarButtonOptions { - size?: number; - onClick: () => void; - tooltip?: string; - iconType?: IconType; - alternateIconType?: IconType; - theme: TimelineTheme; -} - -export class ToolbarButton extends PIXI.Container { - private background: PIXI.Graphics; - private hoverBackground: PIXI.Graphics; - private icon?: PIXI.Graphics; - private alternateIcon?: PIXI.Graphics; - private state: ButtonState = { - isHovering: false, - isPressed: false, - isActive: false - }; - - private size: number; - private theme: TimelineTheme; - private onClick: () => void; - - constructor(options: ToolbarButtonOptions) { - super(); - - this.size = options.size || TOOLBAR_CONSTANTS.BUTTON_SIZE; - this.theme = options.theme; - this.onClick = options.onClick; - - this.eventMode = "static"; - this.cursor = "pointer"; - - // Create background - this.background = new PIXI.Graphics(); - this.addChild(this.background); - - // Create hover background - this.hoverBackground = new PIXI.Graphics(); - this.addChild(this.hoverBackground); - - // Create icon(s) - scaled to 60% of button size - const iconScale = 0.6; - const iconSize = this.size * iconScale; - const iconOffset = (this.size - iconSize) / 2; - - if (options.iconType) { - this.icon = IconFactory.createIcon(options.iconType, this.theme, iconSize); - this.icon.position.set(iconOffset, iconOffset); - this.addChild(this.icon); - } - - if (options.alternateIconType) { - this.alternateIcon = IconFactory.createIcon(options.alternateIconType, this.theme, iconSize); - this.alternateIcon.position.set(iconOffset, iconOffset); - this.alternateIcon.visible = false; - this.addChild(this.alternateIcon); - } - - // Set up event listeners - this.setupEventListeners(); - - // Initial render - this.updateVisuals(); - } - - private setupEventListeners(): void { - this.on("pointerdown", this.handlePointerDown, this); - this.on("pointerup", this.handlePointerUp, this); - this.on("pointerupoutside", this.handlePointerUp, this); - this.on("pointerover", this.handlePointerOver, this); - this.on("pointerout", this.handlePointerOut, this); - } - - private handlePointerDown(): void { - this.state.isPressed = true; - this.updateVisuals(); - } - - private handlePointerUp(): void { - if (this.state.isPressed) { - this.onClick(); - } - this.state.isPressed = false; - this.updateVisuals(); - } - - private handlePointerOver(): void { - this.state.isHovering = true; - this.updateVisuals(); - } - - private handlePointerOut(): void { - this.state.isHovering = false; - this.state.isPressed = false; - this.updateVisuals(); - } - - private updateVisuals(): void { - const padding = TOOLBAR_CONSTANTS.BUTTON_HOVER_PADDING; - const radius = this.size / 2; - - // Clear and redraw circular button background - this.background.clear(); - this.background.circle(radius, radius, radius); - this.background.fill({ - color: this.theme.timeline.toolbar.surface, - alpha: 0.8 - }); - - // Update hover background as a larger circle - this.hoverBackground.clear(); - this.hoverBackground.circle(radius, radius, radius + padding); - - if (this.state.isPressed) { - this.hoverBackground.fill({ - color: this.theme.timeline.toolbar.active, - alpha: TOOLBAR_CONSTANTS.ACTIVE_ANIMATION_ALPHA - }); - } else if (this.state.isHovering) { - this.hoverBackground.fill({ - color: this.theme.timeline.toolbar.hover, - alpha: TOOLBAR_CONSTANTS.HOVER_ANIMATION_ALPHA - }); - } else { - this.hoverBackground.fill({ - color: this.theme.timeline.toolbar.hover, - alpha: 0 - }); - } - } - - public setActive(active: boolean): void { - this.state.isActive = active; - - // Toggle icon visibility if we have alternate icon - if (this.icon && this.alternateIcon) { - this.icon.visible = !active; - this.alternateIcon.visible = active; - } - } - - public updateTheme(theme: TimelineTheme): void { - this.theme = theme; - - // Recreate icons with new theme - const iconScale = 0.6; - const iconSize = this.size * iconScale; - const iconOffset = (this.size - iconSize) / 2; - - if (this.icon) { - const iconType = this.getIconType(this.icon); - if (iconType) { - this.removeChild(this.icon); - this.icon = IconFactory.createIcon(iconType, theme, iconSize); - this.icon.position.set(iconOffset, iconOffset); - this.addChild(this.icon); - } - } - - if (this.alternateIcon) { - const iconType = this.getIconType(this.alternateIcon); - if (iconType) { - this.removeChild(this.alternateIcon); - this.alternateIcon = IconFactory.createIcon(iconType, theme, iconSize); - this.alternateIcon.position.set(iconOffset, iconOffset); - this.alternateIcon.visible = this.state.isActive; - this.addChild(this.alternateIcon); - } - } - - this.updateVisuals(); - } - - private getIconType(_icon: PIXI.Graphics): IconType | null { - // This is a simplified approach - in a real implementation, - // we'd store the icon type as metadata on the Graphics object - return null; - } - - public override destroy(): void { - this.removeAllListeners(); - super.destroy(); - } -} diff --git a/src/components/timeline/toolbar/constants.ts b/src/components/timeline/toolbar/constants.ts deleted file mode 100644 index c67c7749..00000000 --- a/src/components/timeline/toolbar/constants.ts +++ /dev/null @@ -1,65 +0,0 @@ -export const TOOLBAR_CONSTANTS = { - // Layout - BUTTON_SIZE: 24, - BUTTON_SPACING: 8, - BUTTON_HOVER_PADDING: 4, - BORDER_RADIUS: 4, - TEXT_SPACING: 16, - EDGE_MARGIN: 10, - - // Playback - FRAME_TIME_MS: 16.67, // milliseconds per frame - - // Icon dimensions - ICON: { - // Play icon (triangle) - PLAY: { - LEFT: 6, - TOP: 4, - RIGHT: 18, - MIDDLE: 12, - BOTTOM: 20 - }, - // Pause icon (two rectangles) - PAUSE: { - RECT1_X: 6, - RECT2_X: 14, - TOP: 4, - WIDTH: 4, - HEIGHT: 16 - }, - // Frame back/forward (double triangles) - FRAME_STEP: { - TRIANGLE1: { - BACK: { LEFT: 11, RIGHT: 3, MIDDLE: 12 }, - FORWARD: { LEFT: 4, RIGHT: 12, MIDDLE: 12 } - }, - TRIANGLE2: { - BACK: { LEFT: 20, RIGHT: 12, MIDDLE: 12 }, - FORWARD: { LEFT: 13, RIGHT: 21, MIDDLE: 12 } - }, - TOP: 4, - BOTTOM: 20 - } - }, - - // Cut button - CUT_BUTTON: { - WIDTH: 60, - HEIGHT: 24, - FONT_SIZE: 12 - }, - - // Time display - TIME_DISPLAY: { - FONT_SIZE: 14, - FONT_FAMILY: "monospace" - }, - - // Animation - HOVER_ANIMATION_ALPHA: 1, - ACTIVE_ANIMATION_ALPHA: 0.3, - DIVIDER_ALPHA: 0.5 -} as const; - -export type ToolbarConstants = typeof TOOLBAR_CONSTANTS; diff --git a/src/components/timeline/toolbar/icons/icon-factory.ts b/src/components/timeline/toolbar/icons/icon-factory.ts deleted file mode 100644 index e33e7c3f..00000000 --- a/src/components/timeline/toolbar/icons/icon-factory.ts +++ /dev/null @@ -1,106 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { TimelineTheme } from "../../../../core/theme"; -import { TOOLBAR_CONSTANTS } from "../constants"; -import { IconType } from "../types"; - -export class IconFactory { - static createIcon(type: IconType, theme: TimelineTheme, size?: number): PIXI.Graphics { - const scale = size ? size / TOOLBAR_CONSTANTS.BUTTON_SIZE : 1; - - switch (type) { - case "play": - return this.createPlayIcon(theme, scale); - case "pause": - return this.createPauseIcon(theme, scale); - case "frame-back": - return this.createFrameBackIcon(theme, scale); - case "frame-forward": - return this.createFrameForwardIcon(theme, scale); - default: - throw new Error(`Unknown icon type: ${type}`); - } - } - - static createPlayIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics { - const icon = new PIXI.Graphics(); - const { PLAY } = TOOLBAR_CONSTANTS.ICON; - - icon.fill({ color: theme.timeline.toolbar.icon }); - icon.moveTo(PLAY.LEFT * scale, PLAY.TOP * scale); - icon.lineTo(PLAY.RIGHT * scale, PLAY.MIDDLE * scale); - icon.lineTo(PLAY.LEFT * scale, PLAY.BOTTOM * scale); - icon.closePath(); - icon.fill(); - - return icon; - } - - static createPauseIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics { - const icon = new PIXI.Graphics(); - const { PAUSE } = TOOLBAR_CONSTANTS.ICON; - - icon.fill({ color: theme.timeline.toolbar.icon }); - icon.rect(PAUSE.RECT1_X * scale, PAUSE.TOP * scale, PAUSE.WIDTH * scale, PAUSE.HEIGHT * scale); - icon.rect(PAUSE.RECT2_X * scale, PAUSE.TOP * scale, PAUSE.WIDTH * scale, PAUSE.HEIGHT * scale); - icon.fill(); - - return icon; - } - - static createFrameBackIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics { - const icon = new PIXI.Graphics(); - const { FRAME_STEP } = TOOLBAR_CONSTANTS.ICON; - - icon.fill({ color: theme.timeline.toolbar.icon }); - - // First triangle - icon.moveTo(FRAME_STEP.TRIANGLE1.BACK.LEFT * scale, FRAME_STEP.TOP * scale); - icon.lineTo(FRAME_STEP.TRIANGLE1.BACK.RIGHT * scale, FRAME_STEP.TRIANGLE1.BACK.MIDDLE * scale); - icon.lineTo(FRAME_STEP.TRIANGLE1.BACK.LEFT * scale, FRAME_STEP.BOTTOM * scale); - icon.closePath(); - - // Second triangle - icon.moveTo(FRAME_STEP.TRIANGLE2.BACK.LEFT * scale, FRAME_STEP.TOP * scale); - icon.lineTo(FRAME_STEP.TRIANGLE2.BACK.RIGHT * scale, FRAME_STEP.TRIANGLE2.BACK.MIDDLE * scale); - icon.lineTo(FRAME_STEP.TRIANGLE2.BACK.LEFT * scale, FRAME_STEP.BOTTOM * scale); - icon.closePath(); - - icon.fill(); - - return icon; - } - - static createFrameForwardIcon(theme: TimelineTheme, scale: number = 1): PIXI.Graphics { - const icon = new PIXI.Graphics(); - const { FRAME_STEP } = TOOLBAR_CONSTANTS.ICON; - - icon.fill({ color: theme.timeline.toolbar.icon }); - - // First triangle - icon.moveTo(FRAME_STEP.TRIANGLE1.FORWARD.LEFT * scale, FRAME_STEP.TOP * scale); - icon.lineTo(FRAME_STEP.TRIANGLE1.FORWARD.RIGHT * scale, FRAME_STEP.TRIANGLE1.FORWARD.MIDDLE * scale); - icon.lineTo(FRAME_STEP.TRIANGLE1.FORWARD.LEFT * scale, FRAME_STEP.BOTTOM * scale); - icon.closePath(); - - // Second triangle - icon.moveTo(FRAME_STEP.TRIANGLE2.FORWARD.LEFT * scale, FRAME_STEP.TOP * scale); - icon.lineTo(FRAME_STEP.TRIANGLE2.FORWARD.RIGHT * scale, FRAME_STEP.TRIANGLE2.FORWARD.MIDDLE * scale); - icon.lineTo(FRAME_STEP.TRIANGLE2.FORWARD.LEFT * scale, FRAME_STEP.BOTTOM * scale); - icon.closePath(); - - icon.fill(); - - return icon; - } - - static updateIconColor(icon: PIXI.Graphics, _theme: TimelineTheme): void { - // Clear and redraw with new color - const bounds = icon.getBounds(); - icon.clear(); - icon.position.set(bounds.x, bounds.y); - - // This is a simplified update - in practice, we'd need to store - // the icon type and recreate it with the new theme - } -} diff --git a/src/components/timeline/toolbar/index.ts b/src/components/timeline/toolbar/index.ts deleted file mode 100644 index 407928ed..00000000 --- a/src/components/timeline/toolbar/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Barrel exports for toolbar module -export { TOOLBAR_CONSTANTS } from "./constants"; -export * from "./types"; -export { IconFactory } from "./icons/icon-factory"; -export { ToolbarButton } from "./components/toolbar-button"; -export { PlaybackControls } from "./components/playback-controls"; -export { TimeDisplay } from "./components/time-display"; -export { EditControls } from "./components/edit-controls"; -export { ToolbarLayout } from "./toolbar-layout"; diff --git a/src/components/timeline/toolbar/toolbar-layout.ts b/src/components/timeline/toolbar/toolbar-layout.ts deleted file mode 100644 index 5b0912b5..00000000 --- a/src/components/timeline/toolbar/toolbar-layout.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { TOOLBAR_CONSTANTS } from "./constants"; -import { ComponentPosition, ToolbarLayoutConfig } from "./types"; - -export class ToolbarLayout { - private config: ToolbarLayoutConfig; - - constructor(width: number, height: number) { - this.config = { - width, - height, - buttonSize: Math.round(height * 0.5), - buttonSpacing: Math.round(height * 0.15), - edgeMargin: TOOLBAR_CONSTANTS.EDGE_MARGIN - }; - } - - public getPlaybackControlsPosition(): ComponentPosition { - // Center playback controls horizontally and vertically - const controlsWidth = this.calculatePlaybackControlsWidth(); - const x = (this.config.width - controlsWidth) / 2; - // Center the entire control group vertically - const y = (this.config.height - this.getMaxButtonHeight()) / 2; - - return { x, y }; - } - - private getMaxButtonHeight(): number { - // The play button is the tallest - const regularButtonSize = this.config.buttonSize; - const playButtonSize = Math.round(regularButtonSize * 1.5); - return playButtonSize; - } - - public getTimeDisplayPosition(playbackControlsWidth: number): ComponentPosition { - // Position time display to the right of playback controls - const playbackX = (this.config.width - playbackControlsWidth) / 2; - const x = playbackX + playbackControlsWidth + TOOLBAR_CONSTANTS.TEXT_SPACING; - const y = this.config.height / 2; - - return { x, y }; - } - - public getEditControlsPosition(): ComponentPosition { - // Position edit controls on the right edge - const x = this.config.width - TOOLBAR_CONSTANTS.CUT_BUTTON.WIDTH - this.config.edgeMargin; - const y = (this.config.height - TOOLBAR_CONSTANTS.CUT_BUTTON.HEIGHT) / 2; - - return { x, y }; - } - - public calculatePlaybackControlsWidth(): number { - // 2 regular buttons + 1 play button (50% larger) with 2 spaces between them - const regularButtonSize = this.config.buttonSize; - const playButtonSize = Math.round(regularButtonSize * 1.5); - return regularButtonSize * 2 + playButtonSize + this.config.buttonSpacing * 2; - } - - public updateWidth(width: number): void { - this.config.width = width; - } - - public getConfig(): ToolbarLayoutConfig { - return { ...this.config }; - } -} diff --git a/src/components/timeline/toolbar/types.ts b/src/components/timeline/toolbar/types.ts deleted file mode 100644 index 7ed5d47d..00000000 --- a/src/components/timeline/toolbar/types.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as PIXI from "pixi.js"; - -import { Edit } from "../../../core/edit"; -import { TimelineTheme } from "../../../core/theme"; -import { TimelineLayout } from "../timeline-layout"; - -// Button types -export type ButtonType = "play-pause" | "frame-back" | "frame-forward" | "cut"; - -export interface ButtonConfig { - type: ButtonType; - tooltip?: string; - onClick: () => void; - size?: number; -} - -export interface IconButtonConfig extends ButtonConfig { - getIcon: (theme: TimelineTheme) => PIXI.Graphics; - getAlternateIcon?: (theme: TimelineTheme) => PIXI.Graphics; -} - -export interface TextButtonConfig extends ButtonConfig { - text: string; - width: number; - height: number; -} - -// State types -export type ToolbarState = { type: "idle" } | { type: "playing" } | { type: "paused" }; - -export interface ButtonState { - isHovering: boolean; - isPressed: boolean; - isActive: boolean; -} - -// Component interfaces -export interface ToolbarComponent { - update(): void; - resize(width: number): void; - updateTheme(theme: TimelineTheme): void; - destroy(): void; -} - -export interface ToolbarOptions { - edit: Edit; - theme: TimelineTheme; - layout: TimelineLayout; - width: number; -} - -// Layout types -export interface ToolbarLayoutConfig { - width: number; - height: number; - buttonSize: number; - buttonSpacing: number; - edgeMargin: number; -} - -export interface ComponentPosition { - x: number; - y: number; - width?: number; - height?: number; -} - -// Event types -export interface ToolbarEventMap { - "button:click": { button: ButtonType }; - "button:hover": { button: ButtonType; hovering: boolean }; - "state:change": { state: ToolbarState }; -} - -// Icon types -export type IconType = "play" | "pause" | "frame-back" | "frame-forward" | "cut"; - -export interface IconConfig { - type: IconType; - color: number; - size?: number; -} - -// Time formatting -export interface TimeFormatOptions { - showMilliseconds?: boolean; - showHours?: boolean; -} diff --git a/src/components/timeline/types/assets.ts b/src/components/timeline/types/assets.ts deleted file mode 100644 index bb64d821..00000000 --- a/src/components/timeline/types/assets.ts +++ /dev/null @@ -1,245 +0,0 @@ -/** - * Type definitions for timeline assets - */ - -import { DrawOp } from "@shotstack/shotstack-canvas"; - -// Volume keyframe for audio/video assets -export interface VolumeKeyframe { - from: number; - to: number; - start: number; - length: number; - interpolation?: "linear" | "bezier" | "constant"; - easing?: string; -} - -// Video asset -export interface VideoAsset { - type: "video"; - src: string; - trim?: number; - volume?: number | VolumeKeyframe[]; -} - -export type TextRenderer = { - render: (ops: DrawOp[]) => Promise; -}; - -export type TextEngine = { - validate: (asset: unknown) => { value: ValidatedRichTextAsset; error?: unknown }; - renderFrame: (asset: ValidatedRichTextAsset, time: number) => Promise; - createRenderer: (canvas: HTMLCanvasElement) => TextRenderer; - registerFontFromUrl: (url: string, desc: FontDescriptor) => Promise; - registerFontFromFile: (path: string, desc: FontDescriptor) => Promise; - destroy: () => void; -}; - -export type FontDescriptor = { - family: string; - weight: string | number; -}; - -// Audio asset -export interface AudioAsset { - type: "audio"; - src: string; - trim?: number; - volume?: number | VolumeKeyframe[]; -} - -// Image asset -export interface ImageAsset { - type: "image"; - src: string; -} - -// Text asset (basic) -export interface TextAsset { - type: "text"; - text: string; - font?: { - color?: string; - family?: string; - size?: number; - weight?: number; - lineHeight?: number; - }; - alignment?: { - horizontal?: "left" | "center" | "right"; - vertical?: "top" | "center" | "bottom"; - }; -} - -// Rich Text asset (advanced) -export interface ValidatedRichTextAsset { - type: "rich-text"; - text: string; - width: number; - height: number; - font: { - family: string; - size: number; - weight: string | number; - color: string; - opacity: number; - }; - style: { - letterSpacing: number; - lineHeight: number; - textTransform: "none" | "uppercase" | "lowercase" | "capitalize"; - textDecoration: "none" | "underline" | "line-through"; - gradient?: { - type: "linear" | "radial"; - angle: number; - stops: { offset: number; color: string }[]; - }; - }; - stroke: { width: number; color: string; opacity: number }; - shadow: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number }; - background: { - color?: string; - opacity: number; - }; - border: { width: number; color: string; opacity: number; radius: number }; - padding?: number | { top: number; right: number; bottom: number; left: number }; - align: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" }; - animation: { - preset: "fadeIn" | "slideIn" | "typewriter" | "shift" | "ascend" | "movingLetters"; - speed: number; - duration?: number; - style?: "character" | "word"; - direction?: "left" | "right" | "up" | "down"; - }; - customFonts: { src: string; family: string; weight?: string | number; originalFamily?: string }[]; -} - -// Shape asset -export interface ShapeAsset { - type: "shape"; - shape: "rectangle" | "ellipse" | "polygon" | "star"; - color?: string; - borderColor?: string; - borderWidth?: number; -} - -// HTML asset -export interface HtmlAsset { - type: "html"; - html: string; - css?: string; -} - -// Rich text asset -export interface RichTextAsset { - type: "rich-text"; - text: string; - width?: number; - height?: number; - font?: { - family: string; - size: number; - weight: string | number; - color: string; - opacity: number; - }; - style?: { - bold: boolean; - italic: boolean; - underline: boolean; - lineThrough: boolean; - uppercase: boolean; - letterSpacing: number; - lineHeight: number; - }; - stroke?: { width: number; color: string; opacity: number }; - shadow?: { offsetX: number; offsetY: number; blur: number; color: string; opacity: number }; - background?: { - color?: string; - opacity: number; - }; - border?: { width: number; color: string; opacity: number; radius: number }; - padding?: number | { top: number; right: number; bottom: number; left: number }; - align?: { horizontal: "left" | "center" | "right"; vertical: "top" | "middle" | "bottom" }; - animation?: { - preset: "fadeIn" | "slideIn" | "typewriter" | "shift" | "ascend" | "movingLetters"; - speed: number; - duration?: number; - style?: "character" | "word"; - direction?: "left" | "right" | "up" | "down"; - }; - customFonts?: { src: string; family: string; weight?: string | number; originalFamily?: string }[]; -} - -// Luma asset -export interface LumaAsset { - type: "luma"; - src: string; - trim?: number; -} - -// Union type for all assets -export type TimelineAsset = VideoAsset | AudioAsset | ImageAsset | TextAsset | RichTextAsset | ShapeAsset | HtmlAsset | LumaAsset; - -// Type guards -export function isVideoAsset(asset: TimelineAsset): asset is VideoAsset { - return asset.type === "video"; -} - -export function isAudioAsset(asset: TimelineAsset): asset is AudioAsset { - return asset.type === "audio"; -} - -export function isImageAsset(asset: TimelineAsset): asset is ImageAsset { - return asset.type === "image"; -} - -export function isTextAsset(asset: TimelineAsset): asset is TextAsset { - return asset.type === "text"; -} - -export function isRichTextAsset(asset: TimelineAsset): asset is RichTextAsset { - return asset.type === "rich-text"; -} - -export function isShapeAsset(asset: TimelineAsset): asset is ShapeAsset { - return asset.type === "shape"; -} - -export function isHtmlAsset(asset: TimelineAsset): asset is HtmlAsset { - return asset.type === "html"; -} - -export function isLumaAsset(asset: TimelineAsset): asset is LumaAsset { - return asset.type === "luma"; -} - -// Helper to extract filename from path -function getFilenameFromPath(path: string): string { - const parts = path.split("/"); - return parts[parts.length - 1] || path; -} - -// Helper to get display name for asset -export function getAssetDisplayName(asset: TimelineAsset): string { - switch (asset.type) { - case "video": - return asset.src ? getFilenameFromPath(asset.src) : "Video"; - case "audio": - return asset.src ? getFilenameFromPath(asset.src) : "Audio"; - case "image": - return asset.src ? getFilenameFromPath(asset.src) : "Image"; - case "text": - return asset.text || "Text"; - case "rich-text": - return asset.text || "Rich Text"; - case "shape": - return asset.shape || "Shape"; - case "html": - return "HTML"; - case "luma": - return asset.src ? getFilenameFromPath(asset.src) : "Luma"; - default: - return "Unknown Asset"; - } -} diff --git a/src/components/timeline/types/index.ts b/src/components/timeline/types/index.ts deleted file mode 100644 index 458fe961..00000000 --- a/src/components/timeline/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -// Export all timeline types -export * from "./timeline"; -export * from "./assets"; diff --git a/src/components/timeline/types/timeline.ts b/src/components/timeline/types/timeline.ts deleted file mode 100644 index 70110449..00000000 --- a/src/components/timeline/types/timeline.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { ClipSchema, type ResolvedClip } from "@core/schemas/clip"; -import { EditSchema } from "@schemas/edit"; -import { z } from "zod"; - -export type EditType = z.infer; -export type ClipConfig = z.infer; - -export type { ResolvedClip }; - -export interface TimelineOptions { - width?: number; - height?: number; - pixelsPerSecond?: number; - trackHeight?: number; - backgroundColor?: number; - antialias?: boolean; - resolution?: number; -} - -export interface ClipInfo { - trackIndex: number; - clipIndex: number; - clipConfig: ResolvedClip; - x: number; - y: number; - width: number; - height: number; -} - -export interface DropPosition { - track: number; - time: number; - x: number; - y: number; -} diff --git a/src/components/timeline/visual/visual-clip.ts b/src/components/timeline/visual/visual-clip.ts deleted file mode 100644 index df37cb37..00000000 --- a/src/components/timeline/visual/visual-clip.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { Entity } from "@core/shared/entity"; -import * as PIXI from "pixi.js"; - -import { TimelineTheme } from "../../../core/theme"; -import { CLIP_CONSTANTS } from "../constants"; -import { SelectionOverlayRenderer } from "../managers/selection-overlay-renderer"; -import { getAssetDisplayName, TimelineAsset } from "../types/assets"; -import { ResolvedClip } from "../types/timeline"; - -export interface VisualClipOptions { - pixelsPerSecond: number; - trackHeight: number; - trackIndex: number; - clipIndex: number; - theme: TimelineTheme; - selectionRenderer?: SelectionOverlayRenderer; -} - -export class VisualClip extends Entity { - private clipConfig: ResolvedClip; - private options: VisualClipOptions; - private graphics: PIXI.Graphics; - private background: PIXI.Graphics; - private text: PIXI.Text; - private selectionRenderer: SelectionOverlayRenderer | undefined; - private lastGlobalX: number = -1; - private lastGlobalY: number = -1; - /** @internal */ - private visualState: { - mode: "normal" | "selected" | "dragging" | "resizing"; - previewWidth?: number; - } = { mode: "normal" }; - - // Visual constants (some from theme) - private readonly CLIP_PADDING = CLIP_CONSTANTS.PADDING; - private readonly BORDER_WIDTH = CLIP_CONSTANTS.BORDER_WIDTH; - private get CORNER_RADIUS() { - return this.options.theme.timeline.clips.radius || CLIP_CONSTANTS.CORNER_RADIUS; - } - - constructor(clipConfig: ResolvedClip, options: VisualClipOptions) { - super(); - this.clipConfig = clipConfig; - this.options = options; - this.selectionRenderer = options.selectionRenderer; - this.graphics = new PIXI.Graphics(); - this.background = new PIXI.Graphics(); - this.text = new PIXI.Text(); - - this.setupContainer(); - } - - public async load(): Promise { - this.setupGraphics(); - this.updateVisualState(); - } - - private setupContainer(): void { - const container = this.getContainer(); - - // Set up container with label for later tool integration - container.label = `clip-${this.options.trackIndex}-${this.options.clipIndex}`; - - // Make container interactive for click events - container.interactive = true; - container.cursor = "pointer"; - - container.addChild(this.background); - container.addChild(this.graphics); - container.addChild(this.text); - } - - private setupGraphics(): void { - // Set up text style using theme colors - this.text.style = new PIXI.TextStyle({ - fontSize: CLIP_CONSTANTS.TEXT_FONT_SIZE, - fill: this.options.theme.timeline.toolbar.text, - fontWeight: "bold", - wordWrap: false, - fontFamily: "Arial, sans-serif" - }); - - // Position text - this.text.anchor.set(0, 0); - this.text.x = this.CLIP_PADDING; - this.text.y = this.CLIP_PADDING; - } - - public updateFromConfig(newConfig: ResolvedClip): void { - this.clipConfig = newConfig; - this.updateVisualState(); - } - - /** @internal */ - private updateVisualState(): void { - this.updatePosition(); - this.updateAppearance(); - this.updateSize(); - this.updateText(); - } - - private setVisualState(updates: Partial): void { - // Create new state object instead of mutating - this.visualState = { - ...this.visualState, - ...updates - }; - this.updateVisualState(); - } - - /** @internal */ - private updatePosition(): void { - const container = this.getContainer(); - const startTime = this.clipConfig.start; - container.x = startTime * this.options.pixelsPerSecond; - // Clip should be positioned at y=0 relative to its parent track - // The track itself handles the trackIndex positioning - container.y = 0; - } - - /** @internal */ - private updateSize(): void { - const width = this.getEffectiveWidth(); - const height = this.options.trackHeight; - - this.drawClipBackground(width, height); - this.drawClipBorder(width, height); - } - - private getEffectiveWidth(): number { - // Use preview width if available, otherwise calculate from duration - if (this.visualState.previewWidth !== undefined) { - return this.visualState.previewWidth; - } - - const duration = this.clipConfig.length; - const calculatedWidth = duration * this.options.pixelsPerSecond; - return Math.max(CLIP_CONSTANTS.MIN_WIDTH, calculatedWidth); - } - - private drawClipBackground(width: number, height: number): void { - const color = this.getClipColor(); - const styles = this.getStateStyles(); - - this.background.clear(); - this.background.roundRect(0, 0, width, height, this.CORNER_RADIUS); - this.background.fill({ color, alpha: styles.alpha }); - } - - private drawClipBorder(width: number, height: number): void { - const styles = this.getStateStyles(); - - // Always draw the basic border in the clip container - const borderWidth = this.BORDER_WIDTH; - this.graphics.clear(); - this.graphics.roundRect(0, 0, width, height, this.CORNER_RADIUS); - this.graphics.stroke({ width: borderWidth, color: styles.borderColor }); - - // Handle selection highlight via renderer - this.updateSelectionState(width, height); - } - - private updateSelectionState(width: number, height: number): void { - if (!this.selectionRenderer) return; - - const isSelected = this.visualState.mode === "selected"; - const clipId = this.getClipId(); - - if (!isSelected) { - this.selectionRenderer.clearSelection(clipId); - return; - } - - // Calculate global position only if position has changed - const container = this.getContainer(); - const globalPos = container.toGlobal(new PIXI.Point(0, 0)); - - // Convert to overlay coordinates - const overlayContainer = this.selectionRenderer.getOverlay(); - const overlayPos = overlayContainer.toLocal(globalPos); - - // Update selection via renderer - this.selectionRenderer.renderSelection( - clipId, - { - x: overlayPos.x, - y: overlayPos.y, - width, - height, - cornerRadius: this.CORNER_RADIUS, - borderWidth: this.BORDER_WIDTH - }, - isSelected - ); - } - - private getClipColor(): number { - // Color based on asset type using theme - const assetType = this.clipConfig.asset?.type; - const themeClips = this.options.theme.timeline.clips; - - switch (assetType) { - case "video": - return themeClips.video; - case "audio": - return themeClips.audio; - case "image": - return themeClips.image; - case "text": - return themeClips.text; - case "rich-text": - return themeClips["rich-text"] || themeClips.text; - case "shape": - return themeClips.shape; - case "html": - return themeClips.html; - case "luma": - return themeClips.luma; - default: - return themeClips.default; - } - } - - /** @internal */ - private updateAppearance(): void { - const container = this.getContainer(); - - // Apply container-level opacity - container.alpha = this.visualState.mode === "dragging" ? CLIP_CONSTANTS.DRAG_OPACITY : CLIP_CONSTANTS.DEFAULT_ALPHA; - } - - /** @internal */ - private updateText(): void { - // Get text content using type-safe helper - const displayText = this.clipConfig.asset ? getAssetDisplayName(this.clipConfig.asset as TimelineAsset) : "Clip"; - - this.text.text = displayText; - - // Ensure text fits within clip bounds - const clipWidth = this.clipConfig.length * this.options.pixelsPerSecond; - const maxTextWidth = clipWidth - this.CLIP_PADDING * 2; - - if (this.text.width > maxTextWidth) { - // Truncate text if too long - const ratio = maxTextWidth / this.text.width; - const truncatedLength = Math.floor(displayText.length * ratio) - CLIP_CONSTANTS.TEXT_TRUNCATE_SUFFIX_LENGTH; - this.text.text = `${displayText.substring(0, Math.max(1, truncatedLength))}...`; - } - } - - private getStateStyles() { - const { theme } = this.options; - - switch (this.visualState.mode) { - case "dragging": - return { alpha: CLIP_CONSTANTS.DRAG_OPACITY, borderColor: theme.timeline.tracks.border }; - case "resizing": - return { alpha: CLIP_CONSTANTS.RESIZE_OPACITY, borderColor: theme.timeline.dropZone }; - case "selected": - return { alpha: CLIP_CONSTANTS.DEFAULT_ALPHA, borderColor: theme.timeline.clips.selected }; - default: - return { alpha: CLIP_CONSTANTS.DEFAULT_ALPHA, borderColor: theme.timeline.tracks.border }; - } - } - - // Public state management methods - public setSelected(selected: boolean): void { - this.setVisualState({ mode: selected ? "selected" : "normal" }); - } - - public setDragging(dragging: boolean): void { - this.setVisualState({ mode: dragging ? "dragging" : "normal" }); - } - - public setResizing(resizing: boolean): void { - this.setVisualState({ - mode: resizing ? "resizing" : "normal", - ...(resizing ? {} : { previewWidth: undefined }) - }); - } - - public setPreviewWidth(width: number | null): void { - this.setVisualState({ previewWidth: width || undefined }); - } - - public setPixelsPerSecond(pixelsPerSecond: number): void { - this.updateOptions({ pixelsPerSecond }); - - // Update selection state with new dimensions - if (this.visualState.mode === "selected") { - const width = this.getEffectiveWidth(); - const height = this.options.trackHeight; - this.updateSelectionState(width, height); - } - } - - public updateOptions(updates: Partial): void { - // Create new options object with updates - this.options = { - ...this.options, - ...updates - }; - this.updateVisualState(); - } - - // Getters - public getClipConfig(): ResolvedClip { - return this.clipConfig; - } - - public getOptions(): VisualClipOptions { - // Return a defensive copy to prevent external mutations - return { ...this.options }; - } - - public getVisualState(): { mode: "normal" | "selected" | "dragging" | "resizing"; previewWidth?: number } { - // Return a defensive copy to prevent external mutations - return { ...this.visualState }; - } - - public getSelected(): boolean { - return this.visualState.mode === "selected"; - } - - public getClipId(): string { - return `${this.options.trackIndex}-${this.options.clipIndex}`; - } - - public getDragging(): boolean { - return this.visualState.mode === "dragging"; - } - - public getRightEdgeX(): number { - const width = this.getEffectiveWidth(); - const startTime = this.clipConfig.start; - return startTime * this.options.pixelsPerSecond + width; - } - - // Required Entity methods - /** @internal */ - public update(_deltaTime: number, _elapsed: number): void { - // Update selection position if selected and position has changed - if (this.visualState.mode === "selected" && this.selectionRenderer) { - const container = this.getContainer(); - const globalPos = container.toGlobal(new PIXI.Point(0, 0)); - - // Check if position has actually changed to avoid unnecessary updates - if (globalPos.x !== this.lastGlobalX || globalPos.y !== this.lastGlobalY) { - this.lastGlobalX = globalPos.x; - this.lastGlobalY = globalPos.y; - - const width = this.getEffectiveWidth(); - const height = this.options.trackHeight; - this.updateSelectionState(width, height); - } - } - } - - /** @internal */ - public draw(): void { - // Draw is called by the Entity system - // Currently empty as updates happen immediately via state changes - // This prevents redundant drawing when draw() is called repeatedly - } - - /** @internal */ - public dispose(): void { - // Clean up selection via renderer - if (this.selectionRenderer) { - this.selectionRenderer.clearSelection(this.getClipId()); - } - - // Clean up graphics resources - this.background.destroy(); - this.graphics.destroy(); - this.text.destroy(); - } -} diff --git a/src/components/timeline/visual/visual-track.ts b/src/components/timeline/visual/visual-track.ts deleted file mode 100644 index 4c30e2c8..00000000 --- a/src/components/timeline/visual/visual-track.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { TrackSchema } from "@core/schemas/track"; -import { Entity } from "@core/shared/entity"; -import * as PIXI from "pixi.js"; -import { z } from "zod"; - -import { TimelineTheme } from "../../../core/theme"; -import { TRACK_CONSTANTS } from "../constants"; -import { SelectionOverlayRenderer } from "../managers/selection-overlay-renderer"; -import { ResolvedClip } from "../types/timeline"; - -import { VisualClip, VisualClipOptions } from "./visual-clip"; - -type TrackType = z.infer; - -export interface VisualTrackOptions { - pixelsPerSecond: number; - trackHeight: number; - trackIndex: number; - width: number; - theme: TimelineTheme; - selectionRenderer?: SelectionOverlayRenderer; -} - -export class VisualTrack extends Entity { - private clips: VisualClip[] = []; - private options: VisualTrackOptions; - private background: PIXI.Graphics; - - // Visual constants - private readonly TRACK_PADDING = TRACK_CONSTANTS.PADDING; - private readonly LABEL_PADDING = TRACK_CONSTANTS.LABEL_PADDING; - - constructor(options: VisualTrackOptions) { - super(); - this.options = options; - this.background = new PIXI.Graphics(); - - this.setupContainer(); - } - - public async load(): Promise { - this.updateTrackAppearance(); - } - - private setupContainer(): void { - const container = this.getContainer(); - - // Set up container with label for later tool integration - container.label = `track-${this.options.trackIndex}`; - - container.addChild(this.background); - // Track labels removed - container.addChild(this.trackLabel); - - // Position track at correct vertical position - container.y = this.options.trackIndex * this.options.trackHeight; - } - - /** @internal */ - private updateTrackAppearance(): void { - const { width } = this.options; - const height = this.options.trackHeight; - const { theme } = this.options; - - // Draw track background - this.background.clear(); - - // Alternating track colors using theme - const bgColor = this.options.trackIndex % 2 === 0 ? theme.timeline.tracks.surface : theme.timeline.tracks.surfaceAlt; - - this.background.rect(0, 0, width, height); - this.background.fill({ color: bgColor, alpha: TRACK_CONSTANTS.DEFAULT_OPACITY }); - - // Draw track border using theme - this.background.rect(0, 0, width, height); - this.background.stroke({ width: TRACK_CONSTANTS.BORDER_WIDTH, color: theme.timeline.tracks.border }); - - // Draw track separator line at bottom using theme - this.background.moveTo(0, height - 1); - this.background.lineTo(width, height - 1); - this.background.stroke({ width: TRACK_CONSTANTS.BORDER_WIDTH, color: theme.timeline.divider }); - } - - public rebuildFromTrackData(trackData: TrackType, pixelsPerSecond: number): void { - // Update options with new pixels per second - this.options = { - ...this.options, - pixelsPerSecond - }; - - // Clear existing clips - this.clearAllClips(); - - // Create new clips from track data - if (trackData.clips) { - trackData.clips.forEach((clipConfig, clipIndex) => { - const visualClipOptions: VisualClipOptions = { - pixelsPerSecond: this.options.pixelsPerSecond, - trackHeight: this.options.trackHeight, - trackIndex: this.options.trackIndex, - clipIndex, - theme: this.options.theme, - selectionRenderer: this.options.selectionRenderer - }; - - const visualClip = new VisualClip(clipConfig as ResolvedClip, visualClipOptions); - this.addClip(visualClip); - }); - } - - // Update track appearance - this.updateTrackAppearance(); - } - - private async addClip(visualClip: VisualClip): Promise { - this.clips.push(visualClip); - await visualClip.load(); - - // Add clip to container - const container = this.getContainer(); - container.addChild(visualClip.getContainer()); - } - - private clearAllClips(): void { - // Remove all clips from container and dispose them - const container = this.getContainer(); - - for (const clip of this.clips) { - container.removeChild(clip.getContainer()); - clip.dispose(); - } - - this.clips = []; - } - - public removeClip(clipIndex: number): void { - if (clipIndex >= 0 && clipIndex < this.clips.length) { - const clip = this.clips[clipIndex]; - const container = this.getContainer(); - - container.removeChild(clip.getContainer()); - clip.dispose(); - - this.clips.splice(clipIndex, 1); - } - } - - public updateClip(clipIndex: number, newClipConfig: ResolvedClip): void { - if (clipIndex >= 0 && clipIndex < this.clips.length) { - const clip = this.clips[clipIndex]; - clip.updateFromConfig(newClipConfig); - } - } - - public setPixelsPerSecond(pixelsPerSecond: number): void { - // Create new options object instead of mutating - this.options = { - ...this.options, - pixelsPerSecond - }; - - // Update all clips with new pixels per second - this.clips.forEach(clip => { - clip.setPixelsPerSecond(pixelsPerSecond); - }); - - // Don't update appearance here - it will be updated when setWidth is called - } - - public setWidth(width: number): void { - // Create new options object instead of mutating - this.options = { - ...this.options, - width - }; - this.updateTrackAppearance(); - } - - public setTrackIndex(trackIndex: number): void { - // Create new options object instead of mutating - this.options = { - ...this.options, - trackIndex - }; - - // Update container position - const container = this.getContainer(); - container.y = trackIndex * this.options.trackHeight; - - // Track labels removed - // this.trackLabel.text = `Track ${trackIndex + 1}`; - - // Update all clips with new track index - this.clips.forEach((clip, _clipIndex) => { - clip.updateOptions({ trackIndex }); - }); - } - - // Selection methods - public selectClip(clipIndex: number): void { - // Clear all selections first - this.clearAllSelections(); - - // Select the specified clip - if (clipIndex >= 0 && clipIndex < this.clips.length) { - this.clips[clipIndex].setSelected(true); - } - } - - public clearAllSelections(): void { - this.clips.forEach(clip => { - clip.setSelected(false); - }); - } - - public getSelectedClip(): VisualClip | null { - return this.clips.find(clip => clip.getSelected()) || null; - } - - public getSelectedClipIndex(): number { - return this.clips.findIndex(clip => clip.getSelected()); - } - - // Getters - public getClips(): VisualClip[] { - return [...this.clips]; - } - - public getClip(clipIndex: number): VisualClip | null { - return this.clips[clipIndex] || null; - } - - public getClipCount(): number { - return this.clips.length; - } - - public getTrackIndex(): number { - return this.options.trackIndex; - } - - public getTrackHeight(): number { - return this.options.trackHeight; - } - - public getOptions(): VisualTrackOptions { - // Return a defensive copy to prevent external mutations - return { ...this.options }; - } - - // Hit testing - public findClipAtPosition(x: number, y: number): { clip: VisualClip; clipIndex: number } | null { - // Check if y is within track bounds - if (y < 0 || y > this.options.trackHeight) { - return null; - } - - // Convert x to time - const time = x / this.options.pixelsPerSecond; - - // Find clip at this time - for (let i = 0; i < this.clips.length; i += 1) { - const clip = this.clips[i]; - const clipConfig = clip.getClipConfig(); - const clipStart = clipConfig.start; - const clipEnd = clipStart + clipConfig.length; - - if (time >= clipStart && time <= clipEnd) { - return { clip, clipIndex: i }; - } - } - - return null; - } - - // Required Entity methods - /** @internal */ - public update(_deltaTime: number, _elapsed: number): void { - // VisualTrack doesn't need frame-based updates - // All updates are driven by state changes - - // Update all clips - this.clips.forEach(clip => { - clip.update(_deltaTime, _elapsed); - }); - } - - /** @internal */ - public draw(): void { - // Draw is called by the Entity system - // Track appearance is updated when properties change - // Only propagate draw to clips - this.clips.forEach(clip => { - clip.draw(); - }); - } - - /** @internal */ - public dispose(): void { - // Clean up all clips - this.clearAllClips(); - - // Clean up graphics resources - this.background.destroy(); - } -} diff --git a/src/index.ts b/src/index.ts index 09bae3ab..9a1354b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,12 +4,11 @@ export { Edit } from "@core/edit"; export { Canvas } from "@canvas/shotstack-canvas"; export { Controls } from "@core/inputs/controls"; export { VideoExporter } from "@core/export"; -export { Timeline } from "./components/timeline/timeline"; -export { HtmlTimeline } from "./components/timeline-html"; +export { Timeline } from "@timeline/index"; // Export theme types for library users export type { TimelineTheme, TimelineThemeInput } from "./core/theme/theme.types"; -export type { HtmlTimelineOptions, HtmlTimelineFeatures } from "./components/timeline-html"; +export type { TimelineOptions, TimelineFeatures } from "@timeline/index"; // Export Zod schemas for library users export * from "./core/schemas"; diff --git a/src/main.ts b/src/main.ts index c70163b0..f07c54f5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { HtmlTimeline } from "./components/timeline-html"; +import { Timeline } from "@timeline/index"; import { Edit, Canvas, Controls, VideoExporter } from "./index"; @@ -63,10 +63,10 @@ async function main() { // 4. Load the template await edit.loadEdit(template); - // 5b. Initialize the HTML Timeline (new implementation) - const htmlTimelineContainer = document.querySelector("[data-shotstack-timeline]") as HTMLElement; - if (htmlTimelineContainer) { - const htmlTimeline = new HtmlTimeline(edit, htmlTimelineContainer, { + // 5b. Initialize the Timeline + const timelineContainer = document.querySelector("[data-shotstack-timeline]") as HTMLElement; + if (timelineContainer) { + const timeline = new Timeline(edit, timelineContainer, { features: { toolbar: true, ruler: true, @@ -76,8 +76,8 @@ async function main() { multiSelect: true } }); - await htmlTimeline.load(); - console.log("HTML Timeline loaded!"); + await timeline.load(); + console.log("Timeline loaded!"); } // 6. Add keyboard controls From 7b787ba2bfe34a6e69cc24e60cec40cb2ed9ee5b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 18:08:50 +1100 Subject: [PATCH 128/463] feat: merge original and current assets to preserve merge fields and runtime changes --- package.json | 2 +- src/core/edit.ts | 7 ++- src/core/shared/merge-asset.ts | 10 ++++ tests/merge-asset.test.ts | 102 +++++++++++++++++++++++++++++++++ 4 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 src/core/shared/merge-asset.ts create mode 100644 tests/merge-asset.test.ts diff --git a/package.json b/package.json index 78645459..ef058589 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "build": "npm run build:main && npm run build:schema", "build:main": "vite build", "build:schema": "vite build --config vite.config.schema.ts", - "test": "npm run build && jest", + "test": "jest && npm run build", "test:watch": "jest --watch", "test:package": "node test-package.js", "typecheck": "tsc --noEmit && tsc --project tsconfig.test.json --noEmit", diff --git a/src/core/edit.ts b/src/core/edit.ts index d0232038..d92c9546 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -23,6 +23,7 @@ import { UpdateTextContentCommand } from "@core/commands/update-text-content-com import { EventEmitter } from "@core/events/event-emitter"; import { applyMergeFields, MergeFieldService } from "@core/merge"; import { Entity } from "@core/shared/entity"; +import { mergeAssetForExport } from "@core/shared/merge-asset"; import { deepMerge, getNestedValue, setNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; import { LoadingOverlay } from "@core/ui/loading-overlay"; @@ -350,12 +351,14 @@ export class Edit extends Entity { .map((player, clipIdx) => { const timing = player.getTimingIntent(); - // Use asset from originalEdit to preserve merge field templates + // Merge original asset (for merge field templates) with current asset (for runtime changes like animation) const originalAsset = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]?.asset; + const currentAsset = player.clipConfiguration.asset; + const mergedAsset = mergeAssetForExport(originalAsset, currentAsset); return { ...player.clipConfiguration, - asset: originalAsset ?? player.clipConfiguration.asset, + asset: mergedAsset, start: timing.start, length: timing.length }; diff --git a/src/core/shared/merge-asset.ts b/src/core/shared/merge-asset.ts new file mode 100644 index 00000000..5f85d78b --- /dev/null +++ b/src/core/shared/merge-asset.ts @@ -0,0 +1,10 @@ +import type { Asset } from "@schemas/asset"; + +/** + * Merges original asset (with merge field templates) with current asset (with runtime changes). + * Current asset properties override original, preserving both merge fields and runtime changes. + */ +export function mergeAssetForExport(originalAsset: Asset | undefined, currentAsset: Asset): Asset { + if (!originalAsset) return currentAsset; + return { ...originalAsset, ...currentAsset } as Asset; +} diff --git a/tests/merge-asset.test.ts b/tests/merge-asset.test.ts new file mode 100644 index 00000000..3a305b08 --- /dev/null +++ b/tests/merge-asset.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from "@jest/globals"; +import { mergeAssetForExport } from "../src/core/shared/merge-asset"; +import type { Asset } from "../src/core/schemas/asset"; + +describe("mergeAssetForExport", () => { + it("returns current asset when no original exists", () => { + const currentAsset: Asset = { + type: "text", + text: "Hello" + }; + + const result = mergeAssetForExport(undefined, currentAsset); + + expect(result).toEqual(currentAsset); + }); + + it("includes animation added at runtime to rich-text asset", () => { + const originalAsset = { + type: "rich-text" as const, + text: "Title", + font: { family: "Open Sans", size: 48 } + }; + const currentAsset = { + type: "rich-text" as const, + text: "Title", + font: { family: "Open Sans", size: 48 }, + animation: { preset: "fadeIn" as const, duration: 1 } + }; + + const result = mergeAssetForExport(originalAsset as Asset, currentAsset as Asset); + + expect(result).toHaveProperty("animation"); + expect((result as any).animation.preset).toBe("fadeIn"); + }); + + it("current asset properties override original properties", () => { + const originalAsset: Asset = { + type: "text", + text: "Original" + }; + const currentAsset: Asset = { + type: "text", + text: "Updated" + }; + + const result = mergeAssetForExport(originalAsset, currentAsset); + + expect(result.text).toBe("Updated"); + }); + + it("preserves original properties not present in current (shallow merge)", () => { + const originalAsset = { + type: "rich-text" as const, + text: "{{title}}", + font: { family: "Arial", size: 24 }, + customProperty: "preserved" + }; + const currentAsset = { + type: "rich-text" as const, + text: "Hello", + font: { family: "Arial", size: 24 } + }; + + const result = mergeAssetForExport(originalAsset as Asset, currentAsset as Asset); + + // Custom property from original should be preserved since it's not in current + expect((result as any).customProperty).toBe("preserved"); + }); + + it("handles video asset with src merge field", () => { + const originalAsset: Asset = { + type: "video", + src: "{{videoUrl}}" + }; + const currentAsset: Asset = { + type: "video", + src: "https://example.com/video.mp4", + volume: 0.8 + }; + + const result = mergeAssetForExport(originalAsset, currentAsset); + + expect(result.type).toBe("video"); + expect((result as any).src).toBe("https://example.com/video.mp4"); + expect((result as any).volume).toBe(0.8); + }); + + it("type field always comes from current asset", () => { + const originalAsset: Asset = { + type: "text", + text: "Hello" + }; + const currentAsset: Asset = { + type: "rich-text", + text: "Hello" + }; + + const result = mergeAssetForExport(originalAsset, currentAsset); + + expect(result.type).toBe("rich-text"); + }); +}); From babd63267d43f82295eeac62f94a64ff66762b81 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 19:44:42 +1100 Subject: [PATCH 129/463] style: update canvas background color to light gray --- src/components/canvas/shotstack-canvas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/canvas/shotstack-canvas.ts b/src/components/canvas/shotstack-canvas.ts index f189779f..1fafea92 100644 --- a/src/components/canvas/shotstack-canvas.ts +++ b/src/components/canvas/shotstack-canvas.ts @@ -74,7 +74,7 @@ export class Canvas { this.container = new pixi.Container(); this.background = new pixi.Graphics(); - this.background.fillStyle = { color: "#424242" }; + this.background.fillStyle = { color: "#F0F1F5" }; this.background.rect(0, 0, this.viewportSize.width, this.viewportSize.height); this.background.fill(); @@ -199,7 +199,7 @@ export class Canvas { if (this.background) { this.background.clear(); this.background.rect(0, 0, this.viewportSize.width, this.viewportSize.height); - this.background.fill({ color: 0x424242 }); + this.background.fill({ color: 0xf0f1f5 }); } // Update stage hit area From 4297434b87d08ff3a70031adfd4efcc5c3d04982 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Tue, 16 Dec 2025 20:42:18 +1100 Subject: [PATCH 130/463] refactor: simplify function calls and improve code readability across multiple files --- src/components/canvas/players/player.ts | 6 +--- .../canvas/players/rich-text-player.ts | 17 +++++++++-- .../interaction/interaction-controller.ts | 20 ++----------- src/core/edit.ts | 30 ++++++------------- src/core/ui/media-toolbar.ts | 19 ++++++++---- src/core/ui/rich-text-toolbar.ts | 1 - src/core/ui/text-toolbar.ts | 13 +++++--- 7 files changed, 51 insertions(+), 55 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 68e96954..c23dacdf 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -588,11 +588,7 @@ export abstract class Player extends Entity { const currentPos = this.getPosition(); const newAbsolutePos = { x: currentPos.x + deltaX, y: currentPos.y + deltaY }; - const relativePos = this.positionBuilder.absoluteToRelative( - this.getSize(), - this.clipConfiguration.position ?? "center", - newAbsolutePos - ); + const relativePos = this.positionBuilder.absoluteToRelative(this.getSize(), this.clipConfiguration.position ?? "center", newAbsolutePos); if (!this.clipConfiguration.offset) { this.clipConfiguration.offset = { x: 0, y: 0 }; diff --git a/src/components/canvas/players/rich-text-player.ts b/src/components/canvas/players/rich-text-player.ts index 23211dfd..ed3d2d02 100644 --- a/src/components/canvas/players/rich-text-player.ts +++ b/src/components/canvas/players/rich-text-player.ts @@ -222,7 +222,14 @@ export class RichTextPlayer extends Player { const resolved = this.resolveFont(family); if (!resolved) return null; - await this.registerFont(resolved.baseFontFamily, resolved.fontWeight, { type: "url", path: resolved.url }); + // Use explicit weight from asset if set, otherwise use parsed weight from family name + const explicitWeight = richTextAsset.font?.weight; + let { fontWeight } = resolved; + if (explicitWeight) { + fontWeight = typeof explicitWeight === "string" ? parseInt(explicitWeight, 10) || resolved.fontWeight : explicitWeight; + } + + await this.registerFont(resolved.baseFontFamily, fontWeight, { type: "url", path: resolved.url }); return resolved.url; } @@ -264,7 +271,13 @@ export class RichTextPlayer extends Player { if (requestedFamily) { const resolved = this.resolveFont(requestedFamily); if (resolved) { - await this.registerFont(resolved.baseFontFamily, resolved.fontWeight, { type: "url", path: resolved.url }); + // Use explicit weight from asset if set, otherwise use parsed weight from family name + const explicitWeight = richTextAsset.font?.weight; + let { fontWeight } = resolved; + if (explicitWeight) { + fontWeight = typeof explicitWeight === "string" ? parseInt(explicitWeight, 10) || resolved.fontWeight : explicitWeight; + } + await this.registerFont(resolved.baseFontFamily, fontWeight, { type: "url", path: resolved.url }); await this.checkFontCapabilities(resolved.url); } else { console.warn(`Font ${requestedFamily} not found. Available:`, Object.keys(FONT_PATHS)); diff --git a/src/components/timeline/interaction/interaction-controller.ts b/src/components/timeline/interaction/interaction-controller.ts index 24c554d1..29de4ccf 100644 --- a/src/components/timeline/interaction/interaction-controller.ts +++ b/src/components/timeline/interaction/interaction-controller.ts @@ -469,12 +469,7 @@ export class InteractionController { } // Register attachment in state manager - this.stateManager.attachLuma( - targetContentClip.trackIndex, - targetContentClip.clipIndex, - dragTarget.trackIndex, - clipRef.clipIndex - ); + this.stateManager.attachLuma(targetContentClip.trackIndex, targetContentClip.clipIndex, dragTarget.trackIndex, clipRef.clipIndex); // Cleanup and return early ghost.remove(); @@ -636,11 +631,7 @@ export class InteractionController { } /** Find which clip (if any) the dragged clip overlaps */ - private findOverlappingClip( - clips: ClipState[], - desiredStart: number, - clipLength: number - ): { clip: ClipState; index: number } | null { + private findOverlappingClip(clips: ClipState[], desiredStart: number, clipLength: number): { clip: ClipState; index: number } | null { const desiredEnd = desiredStart + clipLength; for (let i = 0; i < clips.length; i += 1) { const clip = clips[i]; @@ -695,12 +686,7 @@ export class InteractionController { } /** Resolve clip collision based on clip boundaries */ - private resolveClipCollision( - trackIndex: number, - desiredStart: number, - clipLength: number, - excludeClip: ClipRef - ): CollisionResult { + private resolveClipCollision(trackIndex: number, desiredStart: number, clipLength: number, excludeClip: ClipRef): CollisionResult { const clips = this.getTrackClips(trackIndex, excludeClip); if (clips.length === 0) { return { ...InteractionController.NO_COLLISION, newStartTime: desiredStart }; diff --git a/src/core/edit.ts b/src/core/edit.ts index d92c9546..e85c4ca8 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -582,9 +582,9 @@ export class Edit extends Entity { for (const clip of this.clips) { const { asset } = clip.clipConfiguration; const rawType = asset?.type || "unknown"; - const type = (["video", "image", "text", "rich-text", "luma", "audio", "html", "shape", "caption"].includes(rawType) - ? rawType - : "unknown") as AssetType; + const type = ( + ["video", "image", "text", "rich-text", "luma", "audio", "html", "shape", "caption"].includes(rawType) ? rawType : "unknown" + ) as AssetType; const size = clip.getSize(); const estimatedMB = this.estimateTextureMB(size.width, size.height); @@ -643,16 +643,10 @@ export class Edit extends Entity { return `avg ${avgW}×${avgH}`; }; - const totalTextures = - stats.videos.count + stats.images.count + stats.text.count + stats.richText.count + stats.luma.count + stats.animated.count; + const totalTextures = stats.videos.count + stats.images.count + stats.text.count + stats.richText.count + stats.luma.count + stats.animated.count; const totalMB = - stats.videos.totalMB + - stats.images.totalMB + - stats.text.totalMB + - stats.richText.totalMB + - stats.luma.totalMB + - stats.animated.totalMB; + stats.videos.totalMB + stats.images.totalMB + stats.text.totalMB + stats.richText.totalMB + stats.luma.totalMB + stats.animated.totalMB; return { textureStats: { @@ -1295,7 +1289,7 @@ export class Edit extends Entity { const maskTexture = renderer.generateTexture({ target: tempContainer, - resolution: 0.5, + resolution: 0.5 }); const maskSprite = new pixi.Sprite(maskTexture); contentClip.getContainer().addChild(maskSprite); @@ -1322,7 +1316,7 @@ export class Edit extends Entity { const oldTexture = mask.maskSprite.texture; mask.maskSprite.texture = renderer.generateTexture({ target: mask.tempContainer, - resolution: 0.5, + resolution: 0.5 }); oldTexture.destroy(true); @@ -1836,10 +1830,7 @@ export class Edit extends Entity { const extractedField = this.mergeFields.extractFieldName(templateVal); if (extractedField === fieldName) { // Apply proper substitution - replace {{ FIELD }} with newValue, preserving surrounding text - targetObj[key] = templateVal.replace( - new RegExp(`\\{\\{\\s*${fieldName}\\s*\\}\\}`, "gi"), - newValue - ); + targetObj[key] = templateVal.replace(new RegExp(`\\{\\{\\s*${fieldName}\\s*\\}\\}`, "gi"), newValue); } } else if (templateVal && typeof templateVal === "object") { this.updateMergeFieldInObject(targetObj[key], templateVal, fieldName, newValue); @@ -1971,10 +1962,7 @@ export class Edit extends Entity { const templateFieldName = this.mergeFields.extractFieldName(template); if (extractedField && templateFieldName && extractedField === templateFieldName) { // Apply proper substitution - replace {{ FIELD }} with restoreValue, preserving surrounding text - const substitutedValue = value.replace( - new RegExp(`\\{\\{\\s*${extractedField}\\s*\\}\\}`, "gi"), - restoreValue - ); + const substitutedValue = value.replace(new RegExp(`\\{\\{\\s*${extractedField}\\s*\\}\\}`, "gi"), restoreValue); this.removeMergeField(trackIdx, clipIdx, propertyPath, substitutedValue); } } else if (typeof value === "object" && value !== null) { diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index 472602f5..bc27c018 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -65,8 +65,8 @@ export class MediaToolbar extends BaseToolbar { // Effect state - progressive disclosure design private effectType: "" | "zoom" | "slide" = ""; - private effectVariant: "In" | "Out" = "In"; // For zoom - private effectDirection: "Left" | "Right" | "Up" | "Down" = "Right"; // For slide + private effectVariant: "In" | "Out" = "In"; // For zoom + private effectDirection: "Left" | "Right" | "Up" | "Down" = "Right"; // For slide private effectSpeed: number = 1.0; private readonly EFFECT_SPEED_VALUES = [0.5, 1.0, 2.0]; @@ -600,7 +600,16 @@ export class MediaToolbar extends BaseToolbar { } protected override getPopupList(): (HTMLElement | null)[] { - return [this.fitPopup, this.opacityPopup, this.scalePopup, this.volumePopup, this.transitionPopup, this.effectPopup, this.advancedPopup, this.audioFadePopup]; + return [ + this.fitPopup, + this.opacityPopup, + this.scalePopup, + this.volumePopup, + this.transitionPopup, + this.effectPopup, + this.advancedPopup, + this.audioFadePopup + ]; } protected override syncState(): void { @@ -1009,9 +1018,9 @@ export class MediaToolbar extends BaseToolbar { let value = ""; if (this.effectType === "zoom") { - value = `zoom${this.effectVariant}`; // "zoomIn" or "zoomOut" + value = `zoom${this.effectVariant}`; // "zoomIn" or "zoomOut" } else if (this.effectType === "slide") { - value = `slide${this.effectDirection}`; // "slideRight", "slideLeft", etc. + value = `slide${this.effectDirection}`; // "slideRight", "slideLeft", etc. } // Add speed suffix diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index ecfe819f..222f3142 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -8,7 +8,6 @@ import { FontColorPicker } from "./font-color-picker"; import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; export class RichTextToolbar extends BaseToolbar { - private fontPopup: HTMLDivElement | null = null; private fontPreview: HTMLSpanElement | null = null; private sizeInput: HTMLInputElement | null = null; diff --git a/src/core/ui/text-toolbar.ts b/src/core/ui/text-toolbar.ts index 2017613f..bed64730 100644 --- a/src/core/ui/text-toolbar.ts +++ b/src/core/ui/text-toolbar.ts @@ -427,10 +427,15 @@ export class TextToolbar extends BaseToolbar { this.fontColorInput?.addEventListener("input", () => this.handleFontColorChange()); // Line height - use base class helper - this.createSliderHandler(this.lineHeightSlider, this.lineHeightValue, value => { - const lineHeight = value / 10; - this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, lineHeight } }); - }, value => (value / 10).toFixed(1)); + this.createSliderHandler( + this.lineHeightSlider, + this.lineHeightValue, + value => { + const lineHeight = value / 10; + this.updateAssetProperty({ font: { ...this.getCurrentAsset()?.font, lineHeight } }); + }, + value => (value / 10).toFixed(1) + ); // Background color this.bgColorInput?.addEventListener("input", () => this.handleBackgroundChange()); From 74b33b604ca7d5224df0d0b3c3e75259e6185a4d Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 10:42:17 +1100 Subject: [PATCH 131/463] feat: sync clip timing state on configuration updates --- src/core/commands/set-updated-clip-command.ts | 35 ++++++++++++++++++ src/core/edit.ts | 36 ++++++++++++++++--- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/src/core/commands/set-updated-clip-command.ts b/src/core/commands/set-updated-clip-command.ts index 0b2df2bf..8a5f7c85 100644 --- a/src/core/commands/set-updated-clip-command.ts +++ b/src/core/commands/set-updated-clip-command.ts @@ -19,6 +19,7 @@ export class SetUpdatedClipCommand implements EditCommand { private storedFinalTemplateConfig: ClipType | null = null; private trackIndex: number; private clipIndex: number; + private storedInitialTiming: { start: number; length: number } | null = null; constructor( private clip: Player, @@ -41,6 +42,28 @@ export class SetUpdatedClipCommand implements EditCommand { context.restoreClipConfiguration(this.clip, this.storedFinalConfig); } + // Sync timing state if start or length actually changed + const startChanged = this.storedFinalConfig.start !== this.storedInitialConfig?.start; + const lengthChanged = this.storedFinalConfig.length !== this.storedInitialConfig?.length; + + if (startChanged || lengthChanged) { + // Store initial timing for undo + this.storedInitialTiming = { + start: this.clip.getStart() / 1000, + length: this.clip.getLength() / 1000 + }; + + this.clip.setTimingIntent({ + start: this.storedFinalConfig.start, + length: this.storedFinalConfig.length + }); + + this.clip.setResolvedTiming({ + start: this.storedFinalConfig.start * 1000, + length: this.storedFinalConfig.length * 1000 + }); + } + context.setUpdatedClip(this.clip); // Use provided indices or calculate from clip @@ -83,6 +106,18 @@ export class SetUpdatedClipCommand implements EditCommand { context.restoreClipConfiguration(this.clip, this.storedInitialConfig); + // Restore timing state if we modified it + if (this.storedInitialTiming) { + this.clip.setTimingIntent({ + start: this.storedInitialTiming.start, + length: this.storedInitialTiming.length + }); + this.clip.setResolvedTiming({ + start: this.storedInitialTiming.start * 1000, + length: this.storedInitialTiming.length * 1000 + }); + } + context.setUpdatedClip(this.clip); // Use provided indices or calculate from clip diff --git a/src/core/edit.ts b/src/core/edit.ts index e85c4ca8..64d32476 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -351,13 +351,14 @@ export class Edit extends Entity { .map((player, clipIdx) => { const timing = player.getTimingIntent(); - // Merge original asset (for merge field templates) with current asset (for runtime changes like animation) - const originalAsset = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]?.asset; + // Use original clip as base to preserve user input without Zod defaults + const originalClip = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; + const originalAsset = originalClip?.asset; const currentAsset = player.clipConfiguration.asset; const mergedAsset = mergeAssetForExport(originalAsset, currentAsset); return { - ...player.clipConfiguration, + ...(originalClip ?? player.clipConfiguration), asset: mergedAsset, start: timing.start, length: timing.length @@ -765,7 +766,22 @@ export class Edit extends Entity { } /** @internal */ public setUpdatedClip(clip: Player, initialClipConfig: ResolvedClip | null = null, finalClipConfig: ResolvedClip | null = null): void { - const command = new SetUpdatedClipCommand(clip, initialClipConfig, finalClipConfig); + // Find track and clip indices + const trackIdx = clip.layer - 1; + const track = this.tracks[trackIdx]; + const clipIdx = track ? track.indexOf(clip) : -1; + + // Sync to originalEdit so getEdit() returns updated values + const originalClip = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; + const templateConfig = finalClipConfig && originalClip + ? deepMerge(structuredClone(originalClip), finalClipConfig) + : finalClipConfig; + + const command = new SetUpdatedClipCommand(clip, initialClipConfig, finalClipConfig, { + trackIndex: trackIdx, + clipIndex: clipIdx, + templateConfig: templateConfig ?? undefined + }); this.executeCommand(command); } @@ -779,7 +795,17 @@ export class Edit extends Entity { const initialConfig = structuredClone(clip.clipConfiguration); const currentConfig = structuredClone(clip.clipConfiguration); const mergedConfig = deepMerge(currentConfig, updates); - this.setUpdatedClip(clip, initialConfig, mergedConfig); + + // Also sync to originalEdit so getEdit() returns updated values + const originalClip = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; + const mergedTemplate = originalClip ? deepMerge(structuredClone(originalClip), updates) : mergedConfig; + + const command = new SetUpdatedClipCommand(clip, initialConfig, mergedConfig, { + trackIndex: trackIdx, + clipIndex: clipIdx, + templateConfig: mergedTemplate + }); + this.executeCommand(command); } /** From d3cbfa88498dc3220a31ed954910e4f973c8e0bf Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 11:20:30 +1100 Subject: [PATCH 132/463] refactor: extract edit serialization logic into dedicated module --- src/core/edit.ts | 45 ++--- src/core/shared/serialize-edit.ts | 47 +++++ tests/schema.test.ts | 22 +-- tests/serialize-edit.test.ts | 281 ++++++++++++++++++++++++++++++ 4 files changed, 353 insertions(+), 42 deletions(-) create mode 100644 src/core/shared/serialize-edit.ts create mode 100644 tests/serialize-edit.test.ts diff --git a/src/core/edit.ts b/src/core/edit.ts index 64d32476..94564ca9 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -23,7 +23,7 @@ import { UpdateTextContentCommand } from "@core/commands/update-text-content-com import { EventEmitter } from "@core/events/event-emitter"; import { applyMergeFields, MergeFieldService } from "@core/merge"; import { Entity } from "@core/shared/entity"; -import { mergeAssetForExport } from "@core/shared/merge-asset"; +import { serializeEditForExport, type ClipExportData } from "@core/shared/serialize-edit"; import { deepMerge, getNestedValue, setNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; import { LoadingOverlay } from "@core/ui/loading-overlay"; @@ -345,36 +345,23 @@ export class Edit extends Entity { await this.addPlayer(this.tracks.length, player); } public getEdit(): EditConfig { - const tracks = this.tracks.map((track, trackIdx) => ({ - clips: track + const clipData: ClipExportData[][] = this.tracks.map(track => + track .filter(player => player && !this.clipsToDispose.includes(player)) - .map((player, clipIdx) => { - const timing = player.getTimingIntent(); - - // Use original clip as base to preserve user input without Zod defaults - const originalClip = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; - const originalAsset = originalClip?.asset; - const currentAsset = player.clipConfiguration.asset; - const mergedAsset = mergeAssetForExport(originalAsset, currentAsset); - - return { - ...(originalClip ?? player.clipConfiguration), - asset: mergedAsset, - start: timing.start, - length: timing.length - }; - }) - })); + .map(player => ({ + clipConfiguration: player.clipConfiguration, + getTimingIntent: () => player.getTimingIntent() + })) + ); - return { - timeline: { - background: this.backgroundColor, - tracks, - fonts: this.edit?.timeline.fonts || [] - }, - output: this.edit?.output || { size: this.size, format: "mp4" }, - merge: this.mergeFields.toSerializedArray() - } as EditConfig; + return serializeEditForExport( + clipData, + this.originalEdit, + this.backgroundColor, + this.edit?.timeline.fonts || [], + this.edit?.output || { size: this.size, format: "mp4" }, + this.mergeFields.toSerializedArray() + ); } public getResolvedEdit(): ResolvedEdit { diff --git a/src/core/shared/serialize-edit.ts b/src/core/shared/serialize-edit.ts new file mode 100644 index 00000000..da5a3689 --- /dev/null +++ b/src/core/shared/serialize-edit.ts @@ -0,0 +1,47 @@ +import type { Clip, ResolvedClip } from "@schemas/clip"; +import type { Edit as EditConfig, ResolvedEdit } from "@schemas/edit"; + +import { mergeAssetForExport } from "./merge-asset"; + +export interface ClipExportData { + clipConfiguration: ResolvedClip; + getTimingIntent: () => { start: number | "auto" | string; length: number | "auto" | "end" | string }; +} + +export function serializeClipForExport(clip: ClipExportData, originalClip: Clip | undefined): Clip { + const timing = clip.getTimingIntent(); + const mergedAsset = mergeAssetForExport(originalClip?.asset, clip.clipConfiguration.asset); + + return { + ...(originalClip ?? clip.clipConfiguration), + asset: mergedAsset, + start: timing.start, + length: timing.length + } as Clip; +} + +export function serializeEditForExport( + clips: ClipExportData[][], + originalEdit: ResolvedEdit | null, + backgroundColor: string, + fonts: Array<{ src: string }>, + output: EditConfig["output"], + mergeFields: Array<{ find: string; replace: string }> +): EditConfig { + const tracks = clips.map((track, trackIdx) => ({ + clips: track.map((clip, clipIdx) => { + const originalClip = originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; + return serializeClipForExport(clip, originalClip); + }) + })); + + return { + timeline: { + background: backgroundColor, + tracks, + fonts + }, + output, + merge: mergeFields + }; +} diff --git a/tests/schema.test.ts b/tests/schema.test.ts index c7f7320f..8494a16a 100644 --- a/tests/schema.test.ts +++ b/tests/schema.test.ts @@ -1,17 +1,13 @@ import { describe, it, expect } from "@jest/globals"; -import { - ClipSchema, - EditSchema, - VideoAssetSchema, - AudioAssetSchema, - TextAssetSchema, - ImageAssetSchema, - ShapeAssetSchema, - HtmlAssetSchema, - TrackSchema, - TimelineSchema, - OutputSchema -} from "@shotstack/shotstack-studio/schema"; +import { ClipSchema } from "../src/core/schemas/clip"; +import { EditSchema, TimelineSchema, OutputSchema } from "../src/core/schemas/edit"; +import { TrackSchema } from "../src/core/schemas/track"; +import { VideoAssetSchema } from "../src/core/schemas/video-asset"; +import { AudioAssetSchema } from "../src/core/schemas/audio-asset"; +import { TextAssetSchema } from "../src/core/schemas/text-asset"; +import { ImageAssetSchema } from "../src/core/schemas/image-asset"; +import { ShapeAssetSchema } from "../src/core/schemas/shape-asset"; +import { HtmlAssetSchema } from "../src/core/schemas/html-asset"; describe("Schema Imports", () => { it("should import all schemas without WASM errors", () => { diff --git a/tests/serialize-edit.test.ts b/tests/serialize-edit.test.ts new file mode 100644 index 00000000..4288ceaa --- /dev/null +++ b/tests/serialize-edit.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from "@jest/globals"; +import { serializeClipForExport, serializeEditForExport, type ClipExportData } from "../src/core/shared/serialize-edit"; +import { EditSchema } from "../src/core/schemas/edit"; +import type { ResolvedClip, Clip } from "../src/core/schemas/clip"; + +describe("serializeClipForExport", () => { + it("preserves timing intent string 'auto' for start", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "text", text: "Hello" }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: "auto", length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.start).toBe("auto"); + expect(result.length).toBe(5); + }); + + it("preserves timing intent string 'end' for length", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "text", text: "Hello" }, + start: 0, + length: 10, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: "end" }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.start).toBe(0); + expect(result.length).toBe("end"); + }); + + it("preserves alias:// references in timing", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "video", src: "https://example.com/video.mp4" }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: "alias://intro", length: "alias://intro" }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.start).toBe("alias://intro"); + expect(result.length).toBe("alias://intro"); + }); + + it("merges original asset with current asset", () => { + const originalClip = { + asset: { type: "video", src: "{{ URL }}" }, + start: 0, + length: 5 + } as Clip; + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "video", src: "https://example.com/video.mp4" }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, originalClip); + + // Current asset values override original + expect((result.asset as { src: string }).src).toBe("https://example.com/video.mp4"); + }); + + it("includes animation added at runtime", () => { + const originalClip = { + asset: { type: "rich-text", text: "Title" }, + start: 0, + length: 3 + } as Clip; + const clip: ClipExportData = { + clipConfiguration: { + asset: { + type: "rich-text", + text: "Title", + animation: { preset: "fadeIn", duration: 1 } + }, + start: 0, + length: 3, + fit: "cover" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 3 }) + }; + + const result = serializeClipForExport(clip, originalClip); + + expect((result.asset as { animation?: { preset: string } }).animation).toBeDefined(); + expect((result.asset as { animation: { preset: string } }).animation.preset).toBe("fadeIn"); + }); + + it("uses current clipConfiguration when no originalClip", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "text", text: "New Text" }, + start: 2, + length: 4, + fit: "crop", + position: "center" + } as ResolvedClip, + getTimingIntent: () => ({ start: 2, length: 4 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.asset.type).toBe("text"); + expect((result.asset as { text: string }).text).toBe("New Text"); + }); +}); + +describe("serializeEditForExport", () => { + it("output passes EditSchema validation", () => { + const clips: ClipExportData[][] = [ + [ + { + clipConfiguration: { + asset: { type: "text", text: "Hello" }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + } + ] + ]; + + const result = serializeEditForExport( + clips, + null, + "#000000", + [], + { size: { width: 1920, height: 1080 }, format: "mp4" }, + [] + ); + + expect(() => EditSchema.parse(result)).not.toThrow(); + }); + + it("preserves merge field array", () => { + const mergeFields = [ + { find: "NAME", replace: "John" }, + { find: "TITLE", replace: "Welcome" } + ]; + + const result = serializeEditForExport( + [], + null, + "#000000", + [], + { size: { width: 1920, height: 1080 }, format: "mp4" }, + mergeFields + ); + + expect(result.merge).toEqual(mergeFields); + }); + + it("handles missing originalEdit gracefully", () => { + const clips: ClipExportData[][] = [ + [ + { + clipConfiguration: { + asset: { type: "text", text: "Test" }, + start: 0, + length: 3, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: "auto", length: 3 }) + } + ] + ]; + + const result = serializeEditForExport( + clips, + null, + "#ffffff", + [], + { size: { width: 1280, height: 720 }, format: "mp4" }, + [] + ); + + expect(result.timeline.tracks[0].clips[0].start).toBe("auto"); + }); + + it("includes fonts in output", () => { + const fonts = [ + { src: "https://fonts.example.com/open-sans.ttf" }, + { src: "https://fonts.example.com/roboto.ttf" } + ]; + + const result = serializeEditForExport( + [], + null, + "#000000", + fonts, + { size: { width: 1920, height: 1080 }, format: "mp4" }, + [] + ); + + expect(result.timeline.fonts).toEqual(fonts); + }); + + it("includes background color in output", () => { + const result = serializeEditForExport( + [], + null, + "#ff5500", + [], + { size: { width: 1920, height: 1080 }, format: "mp4" }, + [] + ); + + expect(result.timeline.background).toBe("#ff5500"); + }); + + it("preserves output configuration", () => { + const output = { + size: { width: 1280, height: 720 }, + format: "gif", + fps: 15 + }; + + const result = serializeEditForExport([], null, "#000", [], output, []); + + expect(result.output).toEqual(output); + }); + + it("serializes multiple tracks correctly", () => { + const clips: ClipExportData[][] = [ + [ + { + clipConfiguration: { + asset: { type: "text", text: "Track 1 Clip 1" }, + start: 0, + length: 3, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 3 }) + } + ], + [ + { + clipConfiguration: { + asset: { type: "text", text: "Track 2 Clip 1" }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: "auto", length: 5 }) + } + ] + ]; + + const result = serializeEditForExport( + clips, + null, + "#000", + [], + { size: { width: 1920, height: 1080 }, format: "mp4" }, + [] + ); + + expect(result.timeline.tracks.length).toBe(2); + expect(result.timeline.tracks[0].clips.length).toBe(1); + expect(result.timeline.tracks[1].clips.length).toBe(1); + expect(result.timeline.tracks[1].clips[0].start).toBe("auto"); + }); +}); From 383d263796fe2b0c8be5ae041472f4cd1a72f759 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 11:58:35 +1100 Subject: [PATCH 133/463] refactor: extract luma mask logic into dedicated controller --- src/core/edit.ts | 227 ++--------------------------- src/core/luma-mask-controller.ts | 241 +++++++++++++++++++++++++++++++ 2 files changed, 253 insertions(+), 215 deletions(-) create mode 100644 src/core/luma-mask-controller.ts diff --git a/src/core/edit.ts b/src/core/edit.ts index 94564ca9..07e4948c 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -21,6 +21,7 @@ import { SetUpdatedClipCommand } from "@core/commands/set-updated-clip-command"; import { SplitClipCommand } from "@core/commands/split-clip-command"; import { UpdateTextContentCommand } from "@core/commands/update-text-content-command"; import { EventEmitter } from "@core/events/event-emitter"; +import { LumaMaskController } from "@core/luma-mask-controller"; import { applyMergeFields, MergeFieldService } from "@core/merge"; import { Entity } from "@core/shared/entity"; import { serializeEditForExport, type ClipExportData } from "@core/shared/serialize-edit"; @@ -92,16 +93,7 @@ export class Edit extends Entity { /** @internal */ private alignmentGuides: AlignmentGuides | null = null; - private activeLumaMasks: Array<{ - lumaPlayer: LumaPlayer; - maskSprite: pixi.Sprite; - tempContainer: pixi.Container; - contentClip: Player; - lastVideoTime: number; - }> = []; - - // Queue for deferred mask sprite cleanup - must wait for PixiJS to finish rendering - private pendingMaskCleanup: Array<{ maskSprite: pixi.Sprite; frameCount: number }> = []; + private lumaMaskController: LumaMaskController; constructor(size: Size, backgroundColor: string = "#ffffff") { super(); @@ -116,6 +108,11 @@ export class Edit extends Entity { this.events = new EventEmitter(); this.mergeFields = new MergeFieldService(this.events); + this.lumaMaskController = new LumaMaskController( + () => this.canvas, + () => this.tracks, + this.events + ); this.size = size; @@ -169,12 +166,7 @@ export class Edit extends Entity { this.disposeClips(); - // Update luma masks for video sources (regenerate mask texture each frame) - this.updateLumaMasks(); - - // Process pending mask cleanup AFTER updateLumaMasks - // This ensures sprites are destroyed only after PixiJS has finished with them - this.processPendingMaskCleanup(); + this.lumaMaskController.update(); if (this.isPlaying) { this.playbackTime = Math.max(0, Math.min(this.playbackTime + elapsed, this.totalDuration)); @@ -193,22 +185,7 @@ export class Edit extends Entity { /** @internal */ public override dispose(): void { this.clearClips(); - - for (const mask of this.activeLumaMasks) { - mask.tempContainer.destroy({ children: true }); - mask.maskSprite.texture.destroy(true); - } - this.activeLumaMasks = []; - - for (const item of this.pendingMaskCleanup) { - try { - item.maskSprite.parent?.removeChild(item.maskSprite); - item.maskSprite.destroy({ texture: true }); - } catch { - // Ignore cleanup errors during dispose - } - } - this.pendingMaskCleanup = []; + this.lumaMaskController.dispose(); if (this.viewportMask) { try { @@ -309,8 +286,7 @@ export class Edit extends Entity { } } - this.finalizeLumaMasking(); - this.setupLumaMaskEventListeners(); + this.lumaMaskController.initialize(); await this.resolveAllTiming(); @@ -516,7 +492,7 @@ export class Edit extends Entity { totalClips: this.clips.length, richTextCacheStats: { clips: richTextClips, totalFrames }, textPlayerCount, - lumaMaskCount: this.activeLumaMasks.length, + lumaMaskCount: this.lumaMaskController.getActiveMaskCount(), commandHistorySize: this.commandHistory.length, trackCount: this.tracks.length }; @@ -946,7 +922,7 @@ export class Edit extends Entity { // Clean up luma masks for any luma players being deleted for (const clip of this.clipsToDispose) { if (clip.playerType === PlayerType.Luma) { - this.cleanupLumaMaskForPlayer(clip as LumaPlayer); + this.lumaMaskController.cleanupForPlayer(clip); } } @@ -1265,185 +1241,6 @@ export class Edit extends Entity { this.updateTotalDuration(); } - /** - * Luma mattes use grayscale video to mask content clips. - * PixiJS masks are inverted vs backend convention (white=visible, not transparent), - * so we bake a negative filter into the mask texture via generateTexture(). - * For video luma sources, we regenerate the mask texture each frame. - */ - private finalizeLumaMasking(): void { - if (!this.canvas) return; - - for (const trackClips of this.tracks) { - const lumaPlayer = trackClips.find(clip => clip.playerType === PlayerType.Luma) as LumaPlayer | undefined; - const lumaSprite = lumaPlayer?.getSprite(); - const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); - - if (lumaPlayer && lumaSprite?.texture && contentClips.length > 0) { - this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); - lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); - } - } - } - - private setupLumaMask(lumaPlayer: LumaPlayer, lumaTexture: pixi.Texture, contentClip: Player): void { - const { renderer } = this.canvas!.application; - const { width, height } = contentClip.getSize(); - - const tempContainer = new pixi.Container(); - const tempSprite = new pixi.Sprite(lumaTexture); - tempSprite.width = width; - tempSprite.height = height; - - const invertFilter = new pixi.ColorMatrixFilter(); - invertFilter.negative(false); - tempSprite.filters = [invertFilter]; - tempContainer.addChild(tempSprite); - - const maskTexture = renderer.generateTexture({ - target: tempContainer, - resolution: 0.5 - }); - const maskSprite = new pixi.Sprite(maskTexture); - contentClip.getContainer().addChild(maskSprite); - contentClip.getContentContainer().setMask({ mask: maskSprite }); - - this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer, contentClip, lastVideoTime: -1 }); - } - - private updateLumaMasks(): void { - if (!this.canvas) return; - const { renderer } = this.canvas.application; - - const frameInterval = 1 / 30; // 30fps threshold - - for (const mask of this.activeLumaMasks) { - if (mask.lumaPlayer.isVideoSource()) { - const videoTime = mask.lumaPlayer.getVideoCurrentTime(); - - // Only regenerate if frame has changed (within threshold) - const frameChanged = Math.abs(videoTime - mask.lastVideoTime) >= frameInterval; - if (frameChanged) { - mask.lastVideoTime = videoTime; - - const oldTexture = mask.maskSprite.texture; - mask.maskSprite.texture = renderer.generateTexture({ - target: mask.tempContainer, - resolution: 0.5 - }); - - oldTexture.destroy(true); - } - } - } - } - - /** - * Set up event listeners for luma mask synchronization. - * Ensures canvas masking stays in sync with clip operations. - */ - private setupLumaMaskEventListeners(): void { - // Rebuild masks after clip moves (luma might have moved to new track) - this.events.on("clip:updated", () => { - this.rebuildLumaMasksIfNeeded(); - }); - - // Rebuild masks after clip deletion undo (luma might be restored) - this.events.on("clip:restored", () => { - this.rebuildLumaMasksIfNeeded(); - }); - - // Rebuild masks after clip deletion (track shift may re-add luma to scene) - this.events.on("clip:deleted", () => { - this.rebuildLumaMasksIfNeeded(); - }); - - // Rebuild masks after any timeline change (clips added/removed/tracks changed) - // This handles the case where AddTrackCommand re-adds luma players to scene - this.events.on("timeline:updated", () => { - this.rebuildLumaMasksIfNeeded(); - }); - } - - /** Clean up luma mask when a luma player is deleted. */ - private cleanupLumaMaskForPlayer(player: Player): void { - const maskIndex = this.activeLumaMasks.findIndex(mask => mask.lumaPlayer === player); - if (maskIndex === -1) return; - - const mask = this.activeLumaMasks[maskIndex]; - - // Clear mask (PixiJS 8 requires direct assignment, not setMask(null)) - if (mask.contentClip) { - mask.contentClip.getContentContainer().mask = null; - } - - mask.maskSprite.parent?.removeChild(mask.maskSprite); - mask.tempContainer.destroy({ children: true }); - this.activeLumaMasks.splice(maskIndex, 1); - - // Defer maskSprite destruction until PixiJS finishes rendering - this.pendingMaskCleanup.push({ maskSprite: mask.maskSprite, frameCount: 0 }); - } - - /** - * Process pending mask cleanup queue. - * Sprites are destroyed after 3 frames to ensure PixiJS has finished rendering. - * Called at the end of update() after updateLumaMasks(). - */ - private processPendingMaskCleanup(): void { - for (let i = this.pendingMaskCleanup.length - 1; i >= 0; i -= 1) { - const item = this.pendingMaskCleanup[i]; - item.frameCount += 1; - - if (item.frameCount >= 3) { - try { - item.maskSprite.parent?.removeChild(item.maskSprite); - item.maskSprite.destroy({ texture: true }); - } catch { - // Ignore cleanup errors - } - this.pendingMaskCleanup.splice(i, 1); - } - } - } - - /** - * Rebuild luma masks for any tracks that need masking but don't have it set up. - * Called after clip operations (move, delete, etc.) to ensure canvas stays in sync. - * Also ensures luma players are hidden from display even if mask already exists. - */ - private async rebuildLumaMasksIfNeeded(): Promise { - if (!this.canvas) return; - - for (let trackIdx = 0; trackIdx < this.tracks.length; trackIdx += 1) { - const trackClips = this.tracks[trackIdx]; - const lumaPlayer = trackClips.find(clip => clip.playerType === PlayerType.Luma) as LumaPlayer | undefined; - const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); - - // ALWAYS hide luma player if it has a parent (even if mask exists) - // This handles the case where AddTrackCommand re-adds luma to scene - if (lumaPlayer) { - lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); - } - - const existingMask = lumaPlayer && this.activeLumaMasks.find(m => m.lumaPlayer === lumaPlayer); - - if (lumaPlayer && !existingMask && contentClips.length > 0) { - // If sprite was destroyed (undo after delete), wait for reload - if (!lumaPlayer.getSprite()) { - await lumaPlayer.load(); - } - - const lumaSprite = lumaPlayer.getSprite(); - if (lumaSprite?.texture) { - this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); - // Already removed above, but kept for safety - lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); - } - } - } - } - public selectClip(trackIndex: number, clipIndex: number): void { const command = new SelectClipCommand(trackIndex, clipIndex); this.executeCommand(command); diff --git a/src/core/luma-mask-controller.ts b/src/core/luma-mask-controller.ts new file mode 100644 index 00000000..a9320413 --- /dev/null +++ b/src/core/luma-mask-controller.ts @@ -0,0 +1,241 @@ +import { LumaPlayer } from "@canvas/players/luma-player"; +import { type Player, PlayerType } from "@canvas/players/player"; +import type { Canvas } from "@canvas/shotstack-canvas"; +import * as pixi from "pixi.js"; + +import type { EventEmitter } from "./events/event-emitter"; + +interface ActiveLumaMask { + lumaPlayer: LumaPlayer; + maskSprite: pixi.Sprite; + tempContainer: pixi.Container; + contentClip: Player; + lastVideoTime: number; +} + +interface PendingMaskCleanup { + maskSprite: pixi.Sprite; + frameCount: number; +} + +/** + * Manages luma mask setup, updates, and cleanup for the Edit class. + * Luma masks apply grayscale video/image textures as alpha masks to content clips. + */ +export class LumaMaskController { + private activeLumaMasks: ActiveLumaMask[] = []; + private pendingMaskCleanup: PendingMaskCleanup[] = []; + + constructor( + private getCanvas: () => Canvas | null, + private getTracks: () => Player[][], + private events: EventEmitter + ) {} + + /** + * Initialize luma masking after clips are loaded. + * Sets up masks and event listeners. + */ + initialize(): void { + this.finalizeLumaMasking(); + this.setupEventListeners(); + } + + /** + * Update luma masks each frame. For video sources, regenerates mask texture. + */ + update(): void { + this.updateLumaMasks(); + this.processPendingMaskCleanup(); + } + + /** + * Get the number of active luma masks. + */ + getActiveMaskCount(): number { + return this.activeLumaMasks.length; + } + + /** + * Clean up all luma masks. + */ + dispose(): void { + for (const mask of this.activeLumaMasks) { + mask.tempContainer.destroy({ children: true }); + mask.maskSprite.destroy({ texture: true }); + } + this.activeLumaMasks = []; + + for (const item of this.pendingMaskCleanup) { + try { + item.maskSprite.parent?.removeChild(item.maskSprite); + item.maskSprite.destroy({ texture: true }); + } catch { + // Ignore cleanup errors during dispose + } + } + this.pendingMaskCleanup = []; + } + + /** + * Clean up luma mask when a luma player is being deleted. + */ + cleanupForPlayer(player: Player): void { + const maskIndex = this.activeLumaMasks.findIndex(mask => mask.lumaPlayer === player); + if (maskIndex === -1) return; + + const mask = this.activeLumaMasks[maskIndex]; + + if (mask.contentClip) { + mask.contentClip.getContentContainer().mask = null; + } + + mask.maskSprite.parent?.removeChild(mask.maskSprite); + mask.tempContainer.destroy({ children: true }); + this.activeLumaMasks.splice(maskIndex, 1); + + this.pendingMaskCleanup.push({ maskSprite: mask.maskSprite, frameCount: 0 }); + } + + /** + * Set up luma masks for all tracks. + * PixiJS masks are inverted vs backend convention (white=visible, not transparent), + * so we bake a negative filter into the mask texture via generateTexture(). + */ + private finalizeLumaMasking(): void { + const canvas = this.getCanvas(); + if (!canvas) return; + + const tracks = this.getTracks(); + for (const trackClips of tracks) { + const lumaPlayer = trackClips.find(clip => clip.playerType === PlayerType.Luma) as LumaPlayer | undefined; + const lumaSprite = lumaPlayer?.getSprite(); + const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); + + if (lumaPlayer && lumaSprite?.texture && contentClips.length > 0) { + this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + } + } + + private setupLumaMask(lumaPlayer: LumaPlayer, lumaTexture: pixi.Texture, contentClip: Player): void { + const canvas = this.getCanvas(); + if (!canvas) return; + + const { renderer } = canvas.application; + const { width, height } = contentClip.getSize(); + + const tempContainer = new pixi.Container(); + const tempSprite = new pixi.Sprite(lumaTexture); + tempSprite.width = width; + tempSprite.height = height; + + const invertFilter = new pixi.ColorMatrixFilter(); + invertFilter.negative(false); + tempSprite.filters = [invertFilter]; + tempContainer.addChild(tempSprite); + + const maskTexture = renderer.generateTexture({ + target: tempContainer, + resolution: 0.5 + }); + const maskSprite = new pixi.Sprite(maskTexture); + contentClip.getContainer().addChild(maskSprite); + contentClip.getContentContainer().setMask({ mask: maskSprite }); + + this.activeLumaMasks.push({ lumaPlayer, maskSprite, tempContainer, contentClip, lastVideoTime: -1 }); + } + + private updateLumaMasks(): void { + const canvas = this.getCanvas(); + if (!canvas) return; + + const { renderer } = canvas.application; + const frameInterval = 1 / 30; + + for (const mask of this.activeLumaMasks) { + if (mask.lumaPlayer.isVideoSource()) { + const videoTime = mask.lumaPlayer.getVideoCurrentTime(); + const frameChanged = Math.abs(videoTime - mask.lastVideoTime) >= frameInterval; + + if (frameChanged) { + mask.lastVideoTime = videoTime; + + const oldTexture = mask.maskSprite.texture; + mask.maskSprite.texture = renderer.generateTexture({ + target: mask.tempContainer, + resolution: 0.5 + }); + + oldTexture.destroy(true); + } + } + } + } + + private setupEventListeners(): void { + this.events.on("clip:updated", () => { + this.rebuildLumaMasksIfNeeded(); + }); + + this.events.on("clip:restored", () => { + this.rebuildLumaMasksIfNeeded(); + }); + + this.events.on("clip:deleted", () => { + this.rebuildLumaMasksIfNeeded(); + }); + + this.events.on("timeline:updated", () => { + this.rebuildLumaMasksIfNeeded(); + }); + } + + private processPendingMaskCleanup(): void { + for (let i = this.pendingMaskCleanup.length - 1; i >= 0; i -= 1) { + const item = this.pendingMaskCleanup[i]; + item.frameCount += 1; + + if (item.frameCount >= 3) { + try { + item.maskSprite.parent?.removeChild(item.maskSprite); + item.maskSprite.destroy({ texture: true }); + } catch { + // Ignore cleanup errors + } + this.pendingMaskCleanup.splice(i, 1); + } + } + } + + private async rebuildLumaMasksIfNeeded(): Promise { + const canvas = this.getCanvas(); + if (!canvas) return; + + const tracks = this.getTracks(); + for (let trackIdx = 0; trackIdx < tracks.length; trackIdx += 1) { + const trackClips = tracks[trackIdx]; + const lumaPlayer = trackClips.find(clip => clip.playerType === PlayerType.Luma) as LumaPlayer | undefined; + const contentClips = trackClips.filter(clip => clip.playerType !== PlayerType.Luma); + + if (lumaPlayer) { + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + + const existingMask = lumaPlayer && this.activeLumaMasks.find(m => m.lumaPlayer === lumaPlayer); + + if (lumaPlayer && !existingMask && contentClips.length > 0) { + if (!lumaPlayer.getSprite()) { + await lumaPlayer.load(); + } + + const lumaSprite = lumaPlayer.getSprite(); + if (lumaSprite?.texture) { + this.setupLumaMask(lumaPlayer, lumaSprite.texture, contentClips[0]); + lumaPlayer.getContainer().parent?.removeChild(lumaPlayer.getContainer()); + } + } + } + } +} From acfc595f400fda63789af7b2aa8bb0b7f60c80fc Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 12:39:31 +1100 Subject: [PATCH 134/463] feat: add path aliases to Jest config and luma mask controller tests --- jest.config.js | 15 +- tests/luma-mask-controller.test.ts | 638 +++++++++++++++++++++++++++++ 2 files changed, 652 insertions(+), 1 deletion(-) create mode 100644 tests/luma-mask-controller.test.ts diff --git a/jest.config.js b/jest.config.js index fb6c4485..94afc3f0 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,7 +4,20 @@ export default { extensionsToTreatAsEsm: [".ts"], moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", - "^@shotstack/shotstack-studio/schema$": "/dist/schema/index.cjs" + "^@shotstack/shotstack-studio/schema$": "/dist/schema/index.cjs", + "^@core/(.*)$": "/src/core/$1", + "^@canvas/(.*)$": "/src/components/canvas/$1", + "^@timeline/(.*)$": "/src/components/timeline/$1", + "^@shared/(.*)$": "/src/core/shared/$1", + "^@schemas/(.*)$": "/src/core/schemas/$1", + "^@layouts/(.*)$": "/src/core/layouts/$1", + "^@animations/(.*)$": "/src/core/animations/$1", + "^@events/(.*)$": "/src/core/events/$1", + "^@inputs/(.*)$": "/src/core/inputs/$1", + "^@loaders/(.*)$": "/src/core/loaders/$1", + "^@export/(.*)$": "/src/core/export/$1", + "^@styles/(.*)$": "/src/styles/$1", + "^@templates/(.*)$": "/src/templates/$1" }, testPathIgnorePatterns: ["/node_modules/", "/dist/"], transform: { diff --git a/tests/luma-mask-controller.test.ts b/tests/luma-mask-controller.test.ts new file mode 100644 index 00000000..71ed573e --- /dev/null +++ b/tests/luma-mask-controller.test.ts @@ -0,0 +1,638 @@ +import { describe, it, expect, jest, beforeEach } from "@jest/globals"; +import { LumaMaskController } from "../src/core/luma-mask-controller"; + +// Define PlayerType enum locally since we're mocking the module +enum PlayerType { + Video = "video", + Image = "image", + Audio = "audio", + Text = "text", + Html = "html", + Shape = "shape", + Caption = "caption", + Luma = "luma", + RichText = "rich-text" +} + +// Mock pixi.js before any imports that use it +jest.mock("pixi.js", () => { + const createMockPixiContainer = () => { + const children: unknown[] = []; + return { + children, + parent: null as unknown, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + child.parent = createMockPixiContainer(); + }), + removeChild: jest.fn(), + destroy: jest.fn() + }; + }; + + return { + Container: jest.fn().mockImplementation(createMockPixiContainer), + Sprite: jest.fn().mockImplementation((texture: unknown) => ({ + texture, + width: 100, + height: 100, + filters: null, + parent: null, + destroy: jest.fn() + })), + Texture: jest.fn(), + ColorMatrixFilter: jest.fn(() => ({ + negative: jest.fn() + })) + }; +}); + +// Mock LumaPlayer to avoid loading the full player dependency chain +jest.mock("../src/components/canvas/players/luma-player", () => ({ + LumaPlayer: jest.fn() +})); + +// Mock the player module +jest.mock("../src/components/canvas/players/player", () => ({ + PlayerType: { + Video: "video", + Image: "image", + Audio: "audio", + Text: "text", + Html: "html", + Shape: "shape", + Caption: "caption", + Luma: "luma", + RichText: "rich-text" + } +})); + +// Mock PixiJS objects +function createMockTexture() { + return { + destroy: jest.fn() + }; +} + +function createMockSprite(texture = createMockTexture()) { + const sprite: Record = { + texture, + width: 100, + height: 100, + filters: null, + parent: null, + destroy: jest.fn() + }; + return sprite; +} + +interface MockContainer { + children: unknown[]; + parent: unknown; + addChild: jest.Mock; + removeChild: jest.Mock; + destroy: jest.Mock; +} + +function createMockContainer(): MockContainer { + const children: unknown[] = []; + const container: MockContainer = { + children, + parent: null, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + child.parent = container; + }), + removeChild: jest.fn((child: { parent?: unknown }) => { + const idx = children.indexOf(child); + if (idx >= 0) children.splice(idx, 1); + child.parent = null; + }), + destroy: jest.fn() + }; + return container; +} + +interface MockContentContainer { + mask: unknown; + setMask: (opts: { mask: unknown }) => void; +} + +function createMockContentContainer(): MockContentContainer { + const contentContainer: MockContentContainer = { + mask: null, + setMask: jest.fn() + }; + contentContainer.setMask = jest.fn((opts: { mask: unknown }) => { + contentContainer.mask = opts.mask; + }); + return contentContainer; +} + +function delay(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +function createMockLumaPlayer(options: { isVideo?: boolean; videoTime?: number; hasSprite?: boolean } = {}) { + const { isVideo = false, videoTime = 0, hasSprite = true } = options; + const container = createMockContainer(); + const sprite = hasSprite ? createMockSprite() : null; + + return { + playerType: PlayerType.Luma, + getSprite: jest.fn(() => sprite), + getContainer: jest.fn(() => container), + isVideoSource: jest.fn(() => isVideo), + getVideoCurrentTime: jest.fn(() => videoTime), + load: jest.fn(() => Promise.resolve()), + getSize: jest.fn(() => ({ width: 100, height: 100 })), + getContentContainer: jest.fn(() => createMockContentContainer()) + }; +} + +function createMockContentPlayer() { + const container = createMockContainer(); + const contentContainer = createMockContentContainer(); + + return { + playerType: PlayerType.Video, + getContainer: jest.fn(() => container), + getContentContainer: jest.fn(() => contentContainer), + getSize: jest.fn(() => ({ width: 200, height: 200 })) + }; +} + +function createMockCanvas() { + return { + application: { + renderer: { + generateTexture: jest.fn(() => createMockTexture()) + } + } + }; +} + +function createMockEventEmitter() { + const listeners: Record void>> = {}; + return { + on: jest.fn((event: string, callback: () => void) => { + if (!listeners[event]) listeners[event] = []; + listeners[event].push(callback); + }), + emit: (event: string) => { + if (listeners[event]) { + listeners[event].forEach(cb => cb()); + } + }, + getListeners: () => listeners + }; +} + +describe("LumaMaskController", () => { + describe("initialization and state", () => { + it("getActiveMaskCount returns 0 initially", () => { + const controller = new LumaMaskController( + () => null, + () => [], + createMockEventEmitter() as never + ); + + expect(controller.getActiveMaskCount()).toBe(0); + }); + + it("initialize sets up event listeners", () => { + const events = createMockEventEmitter(); + const controller = new LumaMaskController( + () => null, + () => [], + events as never + ); + + controller.initialize(); + + expect(events.on).toHaveBeenCalledWith("clip:updated", expect.any(Function)); + expect(events.on).toHaveBeenCalledWith("clip:restored", expect.any(Function)); + expect(events.on).toHaveBeenCalledWith("clip:deleted", expect.any(Function)); + expect(events.on).toHaveBeenCalledWith("timeline:updated", expect.any(Function)); + }); + }); + + describe("finalizeLumaMasking (via initialize)", () => { + it("creates mask when track has luma player and content clip", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + expect(controller.getActiveMaskCount()).toBe(1); + expect(canvas.application.renderer.generateTexture).toHaveBeenCalled(); + }); + + it("does NOT create mask when track has only luma player", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const tracks = [[lumaPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + expect(controller.getActiveMaskCount()).toBe(0); + }); + + it("does NOT create mask when track has only content clips", () => { + const canvas = createMockCanvas(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + expect(controller.getActiveMaskCount()).toBe(0); + }); + + it("removes luma player container from parent after setup", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + + // Simulate luma player being in a parent container + const parentContainer = createMockContainer(); + const lumaContainer = lumaPlayer.getContainer(); + parentContainer.addChild(lumaContainer); + + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + expect(parentContainer.removeChild).toHaveBeenCalledWith(lumaContainer); + }); + + it("handles multiple tracks independently", () => { + const canvas = createMockCanvas(); + const lumaPlayer1 = createMockLumaPlayer(); + const contentPlayer1 = createMockContentPlayer(); + const lumaPlayer2 = createMockLumaPlayer(); + const contentPlayer2 = createMockContentPlayer(); + + const tracks = [ + [lumaPlayer1, contentPlayer1], + [lumaPlayer2, contentPlayer2] + ]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + expect(controller.getActiveMaskCount()).toBe(2); + }); + + it("does not create mask when canvas is null", () => { + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => null, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + expect(controller.getActiveMaskCount()).toBe(0); + }); + + it("does not create mask when luma player has no sprite", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer({ hasSprite: false }); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + expect(controller.getActiveMaskCount()).toBe(0); + }); + }); + + describe("cleanupForPlayer", () => { + let controller: LumaMaskController; + let lumaPlayer: ReturnType; + let contentPlayer: ReturnType; + + beforeEach(() => { + const canvas = createMockCanvas(); + lumaPlayer = createMockLumaPlayer(); + contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + }); + + it("removes mask from content clip", () => { + const contentContainer = contentPlayer.getContentContainer(); + + controller.cleanupForPlayer(lumaPlayer as never); + + expect(contentContainer.mask).toBeNull(); + }); + + it("removes mask from activeLumaMasks array", () => { + expect(controller.getActiveMaskCount()).toBe(1); + + controller.cleanupForPlayer(lumaPlayer as never); + + expect(controller.getActiveMaskCount()).toBe(0); + }); + + it("no-op if player not found in active masks", () => { + const otherPlayer = createMockLumaPlayer(); + + controller.cleanupForPlayer(otherPlayer as never); + + expect(controller.getActiveMaskCount()).toBe(1); + }); + }); + + describe("processPendingMaskCleanup (via update)", () => { + it("does NOT destroy until 3 frames elapsed", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + controller.cleanupForPlayer(lumaPlayer as never); + + // First two updates should not destroy + controller.update(); + controller.update(); + + // Mask sprite destroy should not have been called yet for cleanup + // (the mask is in pending cleanup, not yet destroyed) + expect(controller.getActiveMaskCount()).toBe(0); + }); + + it("destroys sprite after 3 frames", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + controller.cleanupForPlayer(lumaPlayer as never); + + // Three updates to reach cleanup threshold + controller.update(); + controller.update(); + controller.update(); + + // After 3 frames, cleanup should have occurred + expect(controller.getActiveMaskCount()).toBe(0); + }); + }); + + describe("dispose", () => { + it("clears activeLumaMasks array", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + expect(controller.getActiveMaskCount()).toBe(1); + + controller.dispose(); + + expect(controller.getActiveMaskCount()).toBe(0); + }); + + it("clears pendingMaskCleanup array", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + controller.cleanupForPlayer(lumaPlayer as never); + + // Now there's a pending cleanup item + controller.dispose(); + + // After dispose, update should not throw even though pending items were cleared + expect(() => controller.update()).not.toThrow(); + }); + + it("handles errors during cleanup gracefully", () => { + const canvas = createMockCanvas(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + // Should not throw even if internal destroy fails + expect(() => controller.dispose()).not.toThrow(); + }); + }); + + describe("updateLumaMasks (via update)", () => { + it("does not throw when canvas is null", () => { + const controller = new LumaMaskController( + () => null, + () => [], + createMockEventEmitter() as never + ); + + expect(() => controller.update()).not.toThrow(); + }); + + it("updates video source mask when frame changes", () => { + const canvas = createMockCanvas(); + let videoTime = 0; + const lumaPlayer = createMockLumaPlayer({ isVideo: true }); + (lumaPlayer.getVideoCurrentTime as jest.Mock).mockImplementation(() => videoTime); + + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + const initialCalls = (canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length; + + // Advance video time by more than 1/30 second + videoTime = 0.05; + controller.update(); + + expect((canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length).toBeGreaterThan( + initialCalls + ); + }); + + it("does NOT update when frame has not changed enough", () => { + const canvas = createMockCanvas(); + let videoTime = 0; + const lumaPlayer = createMockLumaPlayer({ isVideo: true }); + (lumaPlayer.getVideoCurrentTime as jest.Mock).mockImplementation(() => videoTime); + + const contentPlayer = createMockContentPlayer(); + const tracks = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + createMockEventEmitter() as never + ); + + controller.initialize(); + + // First update to set lastVideoTime + videoTime = 0.05; + controller.update(); + const callsAfterFirstUpdate = (canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length; + + // Small time change (less than 1/30 second) + videoTime = 0.06; + controller.update(); + + expect((canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length).toBe( + callsAfterFirstUpdate + ); + }); + }); + + describe("event listeners trigger rebuild", () => { + it("clip:updated triggers rebuildLumaMasksIfNeeded", async () => { + const canvas = createMockCanvas(); + const events = createMockEventEmitter(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + let tracks: unknown[][] = [[contentPlayer]]; // Initially no luma + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + events as never + ); + + controller.initialize(); + expect(controller.getActiveMaskCount()).toBe(0); + + // Add luma player to track + tracks = [[lumaPlayer, contentPlayer]]; + + // Trigger clip:updated event + events.emit("clip:updated"); + + // Allow async rebuild to complete + await delay(10); + + expect(controller.getActiveMaskCount()).toBe(1); + }); + + it("clip:deleted triggers rebuildLumaMasksIfNeeded", async () => { + const canvas = createMockCanvas(); + const events = createMockEventEmitter(); + const lumaPlayer = createMockLumaPlayer(); + const contentPlayer = createMockContentPlayer(); + const tracks: unknown[][] = [[lumaPlayer, contentPlayer]]; + + const controller = new LumaMaskController( + () => canvas as never, + () => tracks as never, + events as never + ); + + controller.initialize(); + expect(controller.getActiveMaskCount()).toBe(1); + + // Note: The controller checks for existing masks by lumaPlayer reference, + // so the mask stays until cleanupForPlayer is called or dispose is called + // This tests that the event fires without causing errors + + events.emit("clip:deleted"); + + await delay(10); + + // Mask should still exist because we didn't call cleanupForPlayer + // This tests that the event fires, not that it removes masks + expect(controller.getActiveMaskCount()).toBe(1); + }); + }); +}); From f042e86f385b596940cc672631c2bf98f4d787f5 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 12:50:10 +1100 Subject: [PATCH 135/463] test: add comprehensive test coverage for serializeClipForExport asset handling and clip properties --- tests/serialize-edit.test.ts | 281 +++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) diff --git a/tests/serialize-edit.test.ts b/tests/serialize-edit.test.ts index 4288ceaa..b0fd8c23 100644 --- a/tests/serialize-edit.test.ts +++ b/tests/serialize-edit.test.ts @@ -279,3 +279,284 @@ describe("serializeEditForExport", () => { expect(result.timeline.tracks[1].clips[0].start).toBe("auto"); }); }); + +describe("asset merge behavior", () => { + it("current src overrides original template (shallow merge)", () => { + const originalClip = { + asset: { type: "video", src: "{{ VIDEO_URL }}" }, + start: 0, + length: 5 + } as Clip; + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "video", src: "https://resolved.example.com/video.mp4" }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, originalClip); + + // Current asset values override original (shallow merge behavior) + expect((result.asset as { src: string }).src).toBe("https://resolved.example.com/video.mp4"); + }); + + it("current text overrides original template", () => { + const originalClip = { + asset: { type: "text", text: "Hello {{ NAME }}" }, + start: 0, + length: 3 + } as Clip; + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "text", text: "Hello John" }, + start: 0, + length: 3, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 3 }) + }; + + const result = serializeClipForExport(clip, originalClip); + + // Current text overrides original template + expect((result.asset as { text: string }).text).toBe("Hello John"); + }); + + it("preserves original properties not in current asset", () => { + const originalClip = { + asset: { + type: "video", + src: "{{ URL }}", + volume: 0.5, + customProp: "preserved" + }, + start: 0, + length: 5 + } as Clip; + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "video", src: "https://example.com/video.mp4" }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, originalClip); + + // Properties from original that aren't in current should be preserved + expect((result.asset as { customProp: string }).customProp).toBe("preserved"); + }); +}); + +describe("asset type coverage", () => { + it("serializes video asset with all properties", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { + type: "video", + src: "https://example.com/video.mp4", + trim: 2, + volume: 0.8, + crop: { top: 0.1, bottom: 0.1, left: 0, right: 0 } + }, + start: 0, + length: 10, + fit: "cover", + scale: 1.5, + opacity: 0.9 + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 10 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.asset.type).toBe("video"); + expect((result.asset as { trim: number }).trim).toBe(2); + expect((result.asset as { volume: number }).volume).toBe(0.8); + }); + + it("serializes image asset with crop", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { + type: "image", + src: "https://example.com/image.jpg", + crop: { top: 0.1, bottom: 0.1, left: 0.1, right: 0.1 } + }, + start: 0, + length: 5, + fit: "contain" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.asset.type).toBe("image"); + expect((result.asset as { crop: object }).crop).toBeDefined(); + }); + + it("serializes audio asset", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { + type: "audio", + src: "https://example.com/audio.mp3", + trim: 5, + volume: 0.5 + }, + start: 0, + length: 30, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 30 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.asset.type).toBe("audio"); + expect((result.asset as { volume: number }).volume).toBe(0.5); + }); + + it("serializes shape asset", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { + type: "shape", + shape: "rectangle", + rectangle: { width: 100, height: 50 }, + fill: { color: "#ff0000", opacity: 1 } + }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.asset.type).toBe("shape"); + expect((result.asset as { shape: string }).shape).toBe("rectangle"); + }); + + it("serializes html asset", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { + type: "html", + html: "
Hello
", + css: "div { color: red; }", + width: 400, + height: 200 + }, + start: 0, + length: 5, + fit: "crop" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.asset.type).toBe("html"); + expect((result.asset as { html: string }).html).toBe("
Hello
"); + }); +}); + +describe("clip properties preservation", () => { + it("preserves position property", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "text", text: "Test" }, + start: 0, + length: 5, + fit: "crop", + position: "topRight" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.position).toBe("topRight"); + }); + + it("preserves offset property", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "text", text: "Test" }, + start: 0, + length: 5, + fit: "crop", + offset: { x: 0.1, y: -0.2 } + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.offset).toEqual({ x: 0.1, y: -0.2 }); + }); + + it("preserves scale and opacity", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "image", src: "https://example.com/img.png" }, + start: 0, + length: 5, + fit: "cover", + scale: 1.5, + opacity: 0.8 + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.scale).toBe(1.5); + expect(result.opacity).toBe(0.8); + }); + + it("preserves transition configuration", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "text", text: "Fade In" }, + start: 0, + length: 5, + fit: "crop", + transition: { + in: "fade", + out: "slideRight" + } + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 5 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.transition).toEqual({ in: "fade", out: "slideRight" }); + }); + + it("preserves filter effects", () => { + const clip: ClipExportData = { + clipConfiguration: { + asset: { type: "video", src: "https://example.com/video.mp4" }, + start: 0, + length: 10, + fit: "cover", + filter: "greyscale" + } as ResolvedClip, + getTimingIntent: () => ({ start: 0, length: 10 }) + }; + + const result = serializeClipForExport(clip, undefined); + + expect(result.filter).toBe("greyscale"); + }); +}); From f31330567344f36a1ac0f163bbe113979ddbda76 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 15:19:15 +1100 Subject: [PATCH 136/463] test: add comprehensive test suites for edit class core operations --- tests/edit-clip-operations.test.ts | 783 +++++++++++++++++++++++++++++ tests/edit-commands.test.ts | 757 ++++++++++++++++++++++++++++ tests/edit-playback.test.ts | 443 ++++++++++++++++ tests/edit-timing.test.ts | 610 ++++++++++++++++++++++ 4 files changed, 2593 insertions(+) create mode 100644 tests/edit-clip-operations.test.ts create mode 100644 tests/edit-commands.test.ts create mode 100644 tests/edit-playback.test.ts create mode 100644 tests/edit-timing.test.ts diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts new file mode 100644 index 00000000..e0fb4488 --- /dev/null +++ b/tests/edit-clip-operations.test.ts @@ -0,0 +1,783 @@ +/** + * Edit Class Clip Operations Tests + * + * Tests clip CRUD operations: addClip, deleteClip, updateClip, splitClip + * These are the core editing operations that modify timeline content. + */ + +import { Edit } from "@core/edit"; +import { PlayerType } from "@canvas/players/player"; +import type { EventEmitter } from "@core/events/event-emitter"; +import type { ResolvedClip } from "@schemas/clip"; + +// Mock pixi-filters +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +// Mock pixi.js +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + const self = { + children, + sortableChildren: true, + parent: null as unknown, + label: null as string | null, + zIndex: 0, + visible: true, + destroyed: false, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + if (typeof child === "object" && child !== null) { + child.parent = self; + } + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx !== -1) children.splice(idx, 1); + return child; + }), + removeChildAt: jest.fn(), + getChildByLabel: jest.fn(() => null), + getChildIndex: jest.fn(() => 0), + destroy: jest.fn(() => { + self.destroyed = true; + }), + setMask: jest.fn() + }; + return self; + }; + + const createMockGraphics = (): Record => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + stroke: jest.fn().mockReturnThis(), + strokeStyle: {}, + destroy: jest.fn() + }); + + return { + Container: jest.fn().mockImplementation(createMockContainer), + Graphics: jest.fn().mockImplementation(createMockGraphics), + Sprite: jest.fn().mockImplementation(() => ({ + texture: {}, + width: 100, + height: 100, + parent: null, + anchor: { set: jest.fn() }, + scale: { set: jest.fn() }, + position: { set: jest.fn() }, + destroy: jest.fn() + })), + Texture: { from: jest.fn() }, + Assets: { load: jest.fn().mockResolvedValue({}), unload: jest.fn() }, + ColorMatrixFilter: jest.fn(() => ({ negative: jest.fn() })), + Rectangle: jest.fn() + }; +}); + +// Mock AssetLoader +jest.mock("@loaders/asset-loader", () => ({ + AssetLoader: jest.fn().mockImplementation(() => ({ + load: jest.fn().mockResolvedValue({}), + unload: jest.fn(), + getProgress: jest.fn().mockReturnValue(100), + loadTracker: { on: jest.fn(), off: jest.fn() } + })) +})); + +// Mock LumaMaskController +jest.mock("@core/luma-mask-controller", () => ({ + LumaMaskController: jest.fn().mockImplementation(() => ({ + initialize: jest.fn(), + update: jest.fn(), + dispose: jest.fn(), + cleanupForPlayer: jest.fn(), + getActiveMaskCount: jest.fn().mockReturnValue(0) + })) +})); + +// Mock LoadingOverlay +jest.mock("@core/ui/loading-overlay", () => ({ + LoadingOverlay: jest.fn().mockImplementation(() => ({ + show: jest.fn(), + hide: jest.fn(), + update: jest.fn() + })) +})); + +// Mock AlignmentGuides +jest.mock("@canvas/system/alignment-guides", () => ({ + AlignmentGuides: jest.fn().mockImplementation(() => ({ + drawCanvasGuide: jest.fn(), + drawClipGuide: jest.fn(), + clear: jest.fn() + })) +})); + +// Create mock container for players +const createMockPlayerContainer = () => { + const children: unknown[] = []; + return { + children, + parent: null, + visible: true, + zIndex: 0, + addChild: jest.fn((child: unknown) => { + children.push(child); + return child; + }), + removeChild: jest.fn(), + destroy: jest.fn(), + setMask: jest.fn() + }; +}; + +// Mock player factory - create functional mock players +const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => { + const container = createMockPlayerContainer(); + const contentContainer = createMockPlayerContainer(); + + const startMs = typeof config.start === "number" ? config.start * 1000 : 0; + const lengthMs = typeof config.length === "number" ? config.length * 1000 : 3000; + + let resolvedTiming = { start: startMs, length: lengthMs }; + let timingIntent = { start: config.start, length: config.length }; + + return { + clipConfiguration: config, + layer: 0, + playerType: type, + shouldDispose: false, + getContainer: () => container, + getContentContainer: () => contentContainer, + getStart: () => resolvedTiming.start, + getLength: () => resolvedTiming.length, + getEnd: () => resolvedTiming.start + resolvedTiming.length, + getSize: () => ({ width: 1920, height: 1080 }), + getTimingIntent: () => ({ ...timingIntent }), + setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + if (intent.start !== undefined) timingIntent.start = intent.start; + if (intent.length !== undefined) timingIntent.length = intent.length; + }), + getResolvedTiming: () => ({ ...resolvedTiming }), + setResolvedTiming: jest.fn((timing: { start: number; length: number }) => { + resolvedTiming = { ...timing }; + }), + load: jest.fn().mockResolvedValue(undefined), + draw: jest.fn(), + update: jest.fn(), + reconfigureAfterRestore: jest.fn(), + reloadAsset: jest.fn().mockResolvedValue(undefined), + dispose: jest.fn(), + isActive: () => true, + convertToFixedTiming: jest.fn() + }; +}; + +// Mock all player types +jest.mock("@canvas/players/video-player", () => ({ + VideoPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Video) + ) +})); + +jest.mock("@canvas/players/image-player", () => ({ + ImagePlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Image) + ) +})); + +jest.mock("@canvas/players/text-player", () => ({ + TextPlayer: Object.assign( + jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Text) + ), + { resetFontCache: jest.fn() } + ) +})); + +jest.mock("@canvas/players/audio-player", () => ({ + AudioPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Audio) + ) +})); + +jest.mock("@canvas/players/luma-player", () => ({ + LumaPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Luma) + ) +})); + +jest.mock("@canvas/players/shape-player", () => ({ + ShapePlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Shape) + ) +})); + +jest.mock("@canvas/players/html-player", () => ({ + HtmlPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Html) + ) +})); + +jest.mock("@canvas/players/rich-text-player", () => ({ + RichTextPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.RichText) + ) +})); + +jest.mock("@canvas/players/caption-player", () => ({ + CaptionPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Caption) + ) +})); + +/** + * Helper to access private Edit state for testing. + */ +function getEditState(edit: Edit): { + tracks: unknown[][]; + clips: unknown[]; + originalEdit: unknown; +} { + const anyEdit = edit as unknown as { + tracks: unknown[][]; + clips: unknown[]; + originalEdit: unknown; + }; + return { + tracks: anyEdit.tracks, + clips: anyEdit.clips, + originalEdit: anyEdit.originalEdit + }; +} + +/** + * Create a simple video clip config for testing. + */ +function createVideoClip(start: number, length: number): ResolvedClip { + return { + asset: { type: "video", src: "https://example.com/video.mp4" }, + start, + length, + fit: "crop" + }; +} + +/** + * Create a simple image clip config for testing. + */ +function createImageClip(start: number, length: number): ResolvedClip { + return { + asset: { type: "image", src: "https://example.com/image.jpg" }, + start, + length, + fit: "crop" + }; +} + +/** + * Create a text clip config for testing. + */ +function createTextClip(start: number, length: number, text: string = "Hello"): ResolvedClip { + return { + asset: { type: "text", text, style: "minimal" }, + start, + length, + fit: "none" + }; +} + +describe("Edit Clip Operations", () => { + let edit: Edit; + let events: EventEmitter; + let emitSpy: jest.SpyInstance; + + beforeEach(async () => { + edit = new Edit({ width: 1920, height: 1080 }); + await edit.load(); + events = edit.events; + emitSpy = jest.spyOn(events, "emit"); + }); + + afterEach(() => { + edit.dispose(); + jest.clearAllMocks(); + }); + + describe("addClip()", () => { + it("adds clip to specified track", async () => { + const clip = createVideoClip(0, 5); + + await edit.addClip(0, clip); + + const { tracks } = getEditState(edit); + expect(tracks.length).toBeGreaterThanOrEqual(1); + expect(tracks[0].length).toBe(1); + }); + + it("creates new track if trackIdx exceeds current tracks", async () => { + const clip = createVideoClip(0, 5); + + await edit.addClip(2, clip); // Track index 2 when no tracks exist + + const { tracks } = getEditState(edit); + expect(tracks.length).toBeGreaterThanOrEqual(3); + expect(tracks[2].length).toBe(1); + }); + + it("emits timeline:updated event", async () => { + const clip = createVideoClip(0, 5); + emitSpy.mockClear(); + + await edit.addClip(0, clip); + + expect(emitSpy).toHaveBeenCalledWith("timeline:updated", expect.anything()); + }); + + it("updates totalDuration", async () => { + expect(edit.getTotalDuration()).toBe(0); + + await edit.addClip(0, createVideoClip(0, 5)); + + expect(edit.getTotalDuration()).toBe(5000); // 5 seconds in ms + }); + + it("adds multiple clips to same track", async () => { + await edit.addClip(0, createVideoClip(0, 3)); + await edit.addClip(0, createVideoClip(3, 2)); + + const { tracks } = getEditState(edit); + expect(tracks[0].length).toBe(2); + expect(edit.getTotalDuration()).toBe(5000); + }); + + it("is undoable - clip removed on undo", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + const { tracks: beforeUndo } = getEditState(edit); + expect(beforeUndo[0].length).toBe(1); + + edit.undo(); + + // After undo, the clip should be queued for disposal + // The actual removal happens on next update cycle + edit.update(0, 0); + + const { tracks: afterUndo } = getEditState(edit); + expect(afterUndo[0]?.length ?? 0).toBe(0); + }); + }); + + describe("deleteClip()", () => { + beforeEach(async () => { + // Set up a track with a clip + await edit.addClip(0, createVideoClip(0, 5)); + }); + + it("removes clip from track", () => { + const { tracks: before } = getEditState(edit); + expect(before[0].length).toBe(1); + + edit.deleteClip(0, 0); + + const { tracks: after } = getEditState(edit); + expect(after[0]?.length ?? 0).toBe(0); + }); + + it("emits clip:deleted event", () => { + emitSpy.mockClear(); + + edit.deleteClip(0, 0); + + expect(emitSpy).toHaveBeenCalledWith("clip:deleted", expect.anything()); + }); + + it("updates totalDuration after deletion", async () => { + await edit.addClip(0, createVideoClip(5, 3)); // Add second clip + expect(edit.getTotalDuration()).toBe(8000); + + edit.deleteClip(0, 1); // Delete second clip + + expect(edit.getTotalDuration()).toBe(5000); + }); + + it("is undoable - clip restored on undo", async () => { + edit.deleteClip(0, 0); + + const { tracks: afterDelete } = getEditState(edit); + expect(afterDelete[0]?.length ?? 0).toBe(0); + + edit.undo(); + // Flush microtask queue - undo is async internally due to DeleteTrackCommand + await Promise.resolve(); + + const { tracks: afterUndo } = getEditState(edit); + expect(afterUndo[0].length).toBe(1); + }); + + it("handles non-existent track gracefully", () => { + expect(() => edit.deleteClip(99, 0)).not.toThrow(); + }); + + it("handles non-existent clip gracefully", () => { + expect(() => edit.deleteClip(0, 99)).not.toThrow(); + }); + }); + + describe("updateClip()", () => { + beforeEach(async () => { + await edit.addClip(0, createTextClip(0, 5, "Original")); + }); + + it("merges partial updates with existing config", () => { + const clipBefore = edit.getClip(0, 0); + expect((clipBefore?.asset as { text: string }).text).toBe("Original"); + + edit.updateClip(0, 0, { + asset: { type: "text", text: "Updated", style: "minimal" } + }); + + const clipAfter = edit.getClip(0, 0); + expect((clipAfter?.asset as { text: string }).text).toBe("Updated"); + }); + + it("emits clip:updated event with previous/current", () => { + emitSpy.mockClear(); + + edit.updateClip(0, 0, { + opacity: 0.5 + }); + + expect(emitSpy).toHaveBeenCalledWith("clip:updated", expect.objectContaining({ + previous: expect.anything(), + current: expect.anything() + })); + }); + + it("is undoable - restores original config on undo", () => { + edit.updateClip(0, 0, { + asset: { type: "text", text: "Changed", style: "minimal" } + }); + + const clipChanged = edit.getClip(0, 0); + expect((clipChanged?.asset as { text: string }).text).toBe("Changed"); + + edit.undo(); + + const clipRestored = edit.getClip(0, 0); + expect((clipRestored?.asset as { text: string }).text).toBe("Original"); + }); + + it("handles position updates", () => { + edit.updateClip(0, 0, { + position: "topLeft" + }); + + const clip = edit.getClip(0, 0); + expect(clip?.position).toBe("topLeft"); + }); + + it("handles offset updates", () => { + edit.updateClip(0, 0, { + offset: { x: 0.1, y: -0.2 } + }); + + const clip = edit.getClip(0, 0); + expect(clip?.offset?.x).toBe(0.1); + expect(clip?.offset?.y).toBe(-0.2); + }); + + it("warns for non-existent clip", () => { + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + + edit.updateClip(99, 99, { opacity: 0.5 }); + + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + }); + + describe("track management", () => { + beforeEach(async () => { + await edit.addClip(0, createVideoClip(0, 3)); + await edit.addClip(0, createImageClip(3, 2)); + await edit.addClip(1, createTextClip(0, 4, "Track 2")); + }); + + it("getClip returns correct clip configuration", () => { + const clip = edit.getClip(0, 0); + expect(clip?.asset?.type).toBe("video"); + + const clip2 = edit.getClip(0, 1); + expect(clip2?.asset?.type).toBe("image"); + + const clip3 = edit.getClip(1, 0); + expect(clip3?.asset?.type).toBe("text"); + }); + + it("getClip returns null for invalid indices", () => { + expect(edit.getClip(-1, 0)).toBeNull(); + expect(edit.getClip(0, -1)).toBeNull(); + expect(edit.getClip(99, 0)).toBeNull(); + expect(edit.getClip(0, 99)).toBeNull(); + }); + + it("getPlayerClip returns player instance", () => { + const player = edit.getPlayerClip(0, 0); + expect(player).not.toBeNull(); + expect(player?.clipConfiguration.asset?.type).toBe("video"); + }); + + it("getTrack returns track configuration", () => { + const track = edit.getTrack(0); + expect(track).not.toBeNull(); + expect(track?.clips.length).toBe(2); + }); + + it("getTrack returns null for invalid index", () => { + expect(edit.getTrack(99)).toBeNull(); + }); + }); + + describe("clip operations undo integration", () => { + it("addClip undo removes the added clip", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + expect(edit.getClip(0, 0)).not.toBeNull(); + + edit.undo(); + edit.update(0, 0); // Process disposal + + expect(edit.getClip(0, 0)).toBeNull(); + }); + + it("deleteClip undo restores clip", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + edit.deleteClip(0, 0); + expect(edit.getClip(0, 0)).toBeNull(); + + edit.undo(); + // Flush microtask queue - undo is async internally due to DeleteTrackCommand + await Promise.resolve(); + + expect(edit.getClip(0, 0)).not.toBeNull(); + }); + + it("updateClip undo restores original configuration", async () => { + await edit.addClip(0, createTextClip(0, 5, "Before")); + + edit.updateClip(0, 0, { + asset: { type: "text", text: "After", style: "minimal" } + }); + expect((edit.getClip(0, 0)?.asset as { text: string }).text).toBe("After"); + + edit.undo(); + + expect((edit.getClip(0, 0)?.asset as { text: string }).text).toBe("Before"); + }); + + it("multiple operations can be undone in sequence", async () => { + await edit.addClip(0, createVideoClip(0, 3)); + await edit.addClip(0, createImageClip(3, 2)); + + const { tracks: withTwo } = getEditState(edit); + expect(withTwo[0].length).toBe(2); + + edit.undo(); // Undo second add + edit.update(0, 0); + + const { tracks: withOne } = getEditState(edit); + expect(withOne[0].length).toBe(1); + + edit.undo(); // Undo first add + edit.update(0, 0); + + const { tracks: withNone } = getEditState(edit); + expect(withNone[0]?.length ?? 0).toBe(0); + }); + + it("redo re-applies undone operations", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + + edit.undo(); + edit.update(0, 0); + expect(edit.getClip(0, 0)).toBeNull(); + + edit.redo(); + + expect(edit.getClip(0, 0)).not.toBeNull(); + }); + }); + + describe("copy/paste operations", () => { + beforeEach(async () => { + await edit.addClip(0, createVideoClip(0, 5)); + }); + + it("copyClip stores clip configuration", () => { + expect(edit.hasCopiedClip()).toBe(false); + + edit.copyClip(0, 0); + + expect(edit.hasCopiedClip()).toBe(true); + }); + + it("copyClip emits clip:copied event", () => { + emitSpy.mockClear(); + + edit.copyClip(0, 0); + + expect(emitSpy).toHaveBeenCalledWith("clip:copied", expect.objectContaining({ + trackIndex: 0, + clipIndex: 0 + })); + }); + + it("pasteClip adds clip at playhead position", () => { + edit.copyClip(0, 0); + edit.playbackTime = 5000; // 5 seconds + + edit.pasteClip(); + + const { tracks } = getEditState(edit); + expect(tracks[0].length).toBe(2); + + // The pasted clip should start at playhead time + const pastedClip = edit.getClip(0, 1); + expect(pastedClip?.start).toBe(5); // 5 seconds + }); + + it("pasteClip does nothing without copied clip", () => { + const { tracks: before } = getEditState(edit); + const countBefore = before[0].length; + + edit.pasteClip(); // No copied clip + + const { tracks: after } = getEditState(edit); + expect(after[0].length).toBe(countBefore); + }); + }); + + describe("selection operations", () => { + beforeEach(async () => { + await edit.addClip(0, createVideoClip(0, 5)); + await edit.addClip(1, createImageClip(0, 3)); + }); + + it("selectClip updates selected clip", () => { + expect(edit.isClipSelected(0, 0)).toBe(false); + + edit.selectClip(0, 0); + + expect(edit.isClipSelected(0, 0)).toBe(true); + expect(edit.isClipSelected(1, 0)).toBe(false); + }); + + it("selectClip deselects previous selection", () => { + edit.selectClip(0, 0); + expect(edit.isClipSelected(0, 0)).toBe(true); + + edit.selectClip(1, 0); + + expect(edit.isClipSelected(0, 0)).toBe(false); + expect(edit.isClipSelected(1, 0)).toBe(true); + }); + + it("clearSelection clears selected clip", () => { + edit.selectClip(0, 0); + expect(edit.isClipSelected(0, 0)).toBe(true); + + edit.clearSelection(); + + expect(edit.isClipSelected(0, 0)).toBe(false); + }); + + it("getSelectedClipInfo returns correct info", () => { + expect(edit.getSelectedClipInfo()).toBeNull(); + + edit.selectClip(1, 0); + + const info = edit.getSelectedClipInfo(); + expect(info).not.toBeNull(); + expect(info?.trackIndex).toBe(1); + expect(info?.clipIndex).toBe(0); + }); + }); + + describe("duration calculations", () => { + it("totalDuration reflects longest track", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + await edit.addClip(1, createImageClip(0, 8)); + + expect(edit.getTotalDuration()).toBe(8000); + }); + + it("duration updates when clips change", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + expect(edit.getTotalDuration()).toBe(5000); + + await edit.addClip(0, createVideoClip(5, 3)); + expect(edit.getTotalDuration()).toBe(8000); + + edit.deleteClip(0, 1); + expect(edit.getTotalDuration()).toBe(5000); + }); + + it("duration is 0 with no clips", () => { + expect(edit.getTotalDuration()).toBe(0); + }); + }); + + describe("edge cases", () => { + it("handles rapid add/delete cycles", async () => { + for (let i = 0; i < 5; i++) { + await edit.addClip(0, createVideoClip(i, 1)); + } + + const { tracks: withFive } = getEditState(edit); + expect(withFive[0].length).toBe(5); + + for (let i = 4; i >= 0; i--) { + edit.deleteClip(0, i); + } + + const { tracks: withNone } = getEditState(edit); + expect(withNone[0]?.length ?? 0).toBe(0); + }); + + it("handles updates to deleted clips gracefully", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + edit.deleteClip(0, 0); + + // Should not throw + const warnSpy = jest.spyOn(console, "warn").mockImplementation(); + edit.updateClip(0, 0, { opacity: 0.5 }); + warnSpy.mockRestore(); + }); + + it("maintains track integrity across operations", async () => { + await edit.addClip(0, createVideoClip(0, 2)); + await edit.addClip(0, createImageClip(2, 2)); + await edit.addClip(0, createTextClip(4, 2, "Test")); + + // Delete middle clip + edit.deleteClip(0, 1); + + const { tracks } = getEditState(edit); + expect(tracks[0].length).toBe(2); + + // First clip should still be video + expect(edit.getClip(0, 0)?.asset?.type).toBe("video"); + // Second clip should now be text (was at index 2, now at index 1) + expect(edit.getClip(0, 1)?.asset?.type).toBe("text"); + }); + }); +}); diff --git a/tests/edit-commands.test.ts b/tests/edit-commands.test.ts new file mode 100644 index 00000000..bc6357b7 --- /dev/null +++ b/tests/edit-commands.test.ts @@ -0,0 +1,757 @@ +/** + * Edit Class Command History Tests + * + * Tests the undo/redo mechanics and command pattern behavior. + * The command system is the foundation for all editing operations. + */ + +import { Edit } from "@core/edit"; +import type { EditCommand, CommandContext } from "@core/commands/types"; +import type { EventEmitter } from "@core/events/event-emitter"; + +// Mock pixi-filters (must be before pixi.js since it extends pixi classes) +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +// Mock pixi.js to prevent WebGL initialization +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + return { + children, + sortableChildren: true, + parent: null as unknown, + label: null as string | null, + zIndex: 0, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + if (typeof child === "object" && child !== null) { + child.parent = createMockContainer(); + } + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx !== -1) children.splice(idx, 1); + return child; + }), + removeChildAt: jest.fn(), + getChildByLabel: jest.fn(() => null), + getChildIndex: jest.fn(() => 0), + destroy: jest.fn(), + setMask: jest.fn() + }; + }; + + const createMockGraphics = (): Record => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + destroy: jest.fn() + }); + + return { + Container: jest.fn().mockImplementation(createMockContainer), + Graphics: jest.fn().mockImplementation(createMockGraphics), + Sprite: jest.fn().mockImplementation(() => ({ + texture: {}, + width: 100, + height: 100, + parent: null, + destroy: jest.fn() + })), + Texture: { from: jest.fn() }, + Assets: { load: jest.fn(), unload: jest.fn() }, + ColorMatrixFilter: jest.fn(() => ({ negative: jest.fn() })) + }; +}); + +// Mock AssetLoader +jest.mock("@loaders/asset-loader", () => ({ + AssetLoader: jest.fn().mockImplementation(() => ({ + load: jest.fn().mockResolvedValue({}), + unload: jest.fn(), + getProgress: jest.fn().mockReturnValue(100), + loadTracker: { + on: jest.fn(), + off: jest.fn() + } + })) +})); + +// Mock LumaMaskController +jest.mock("@core/luma-mask-controller", () => ({ + LumaMaskController: jest.fn().mockImplementation(() => ({ + initialize: jest.fn(), + update: jest.fn(), + dispose: jest.fn(), + cleanupForPlayer: jest.fn(), + getActiveMaskCount: jest.fn().mockReturnValue(0) + })) +})); + +// Mock LoadingOverlay +jest.mock("@core/ui/loading-overlay", () => ({ + LoadingOverlay: jest.fn().mockImplementation(() => ({ + show: jest.fn(), + hide: jest.fn(), + update: jest.fn() + })) +})); + +// Mock TextPlayer font cache +jest.mock("@canvas/players/text-player", () => ({ + TextPlayer: { + resetFontCache: jest.fn() + } +})); + +// Mock AlignmentGuides +jest.mock("@canvas/system/alignment-guides", () => ({ + AlignmentGuides: jest.fn().mockImplementation(() => ({ + drawCanvasGuide: jest.fn(), + drawClipGuide: jest.fn(), + clear: jest.fn() + })) +})); + +/** + * Simple test command for verifying command mechanics without complex state. + */ +class TestCommand implements EditCommand { + readonly name: string; + executeCount = 0; + undoCount = 0; + lastContext: CommandContext | undefined; + + constructor(name: string = "TestCommand") { + this.name = name; + } + + execute(context?: CommandContext): void { + this.executeCount++; + this.lastContext = context; + } + + undo(context?: CommandContext): void { + this.undoCount++; + this.lastContext = context; + } +} + +/** + * Async test command for testing async command behavior. + */ +class AsyncTestCommand implements EditCommand { + readonly name = "AsyncTestCommand"; + executeCount = 0; + undoCount = 0; + resolveDelay: number; + + constructor(resolveDelay: number = 10) { + this.resolveDelay = resolveDelay; + } + + async execute(): Promise { + await new Promise(resolve => setTimeout(resolve, this.resolveDelay)); + this.executeCount++; + } + + async undo(): Promise { + await new Promise(resolve => setTimeout(resolve, this.resolveDelay)); + this.undoCount++; + } +} + +/** + * Command without undo for testing optional undo behavior. + */ +class NoUndoCommand implements EditCommand { + readonly name = "NoUndoCommand"; + executeCount = 0; + + execute(): void { + this.executeCount++; + } + // Intentionally no undo method +} + +/** + * Helper to access private Edit properties for testing. + */ +function getCommandState(edit: Edit): { history: EditCommand[]; index: number } { + const anyEdit = edit as unknown as { commandHistory: EditCommand[]; commandIndex: number }; + return { + history: anyEdit.commandHistory, + index: anyEdit.commandIndex + }; +} + +describe("Edit Command History", () => { + let edit: Edit; + let events: EventEmitter; + let emitSpy: jest.SpyInstance; + + beforeEach(async () => { + edit = new Edit({ width: 1920, height: 1080 }); + await edit.load(); + + events = edit.events; + emitSpy = jest.spyOn(events, "emit"); + }); + + afterEach(() => { + edit.dispose(); + jest.clearAllMocks(); + }); + + describe("executeCommand() behavior", () => { + it("adds command to history", () => { + const cmd = new TestCommand(); + + edit.executeEditCommand(cmd); + + const { history } = getCommandState(edit); + expect(history).toContain(cmd); + expect(history.length).toBe(1); + }); + + it("increments commandIndex", () => { + const { index: initialIndex } = getCommandState(edit); + expect(initialIndex).toBe(-1); // Starts at -1 + + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(0); + + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(1); + + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(2); + }); + + it("calls command.execute() with context", () => { + const cmd = new TestCommand(); + + edit.executeEditCommand(cmd); + + expect(cmd.executeCount).toBe(1); + expect(cmd.lastContext).toBeDefined(); + // Verify context has expected methods + expect(typeof cmd.lastContext?.getClips).toBe("function"); + expect(typeof cmd.lastContext?.getTracks).toBe("function"); + expect(typeof cmd.lastContext?.emitEvent).toBe("function"); + }); + + it("returns void for sync commands", () => { + const cmd = new TestCommand(); + const result = edit.executeEditCommand(cmd); + expect(result).toBeUndefined(); + }); + + it("returns Promise for async commands", async () => { + const cmd = new AsyncTestCommand(5); + const result = edit.executeEditCommand(cmd); + + expect(result).toBeInstanceOf(Promise); + await result; + expect(cmd.executeCount).toBe(1); + }); + }); + + describe("undo() method", () => { + it("calls command.undo() with context", () => { + const cmd = new TestCommand(); + edit.executeEditCommand(cmd); + + edit.undo(); + + expect(cmd.undoCount).toBe(1); + expect(cmd.lastContext).toBeDefined(); + }); + + it("decrements commandIndex after undo", () => { + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(1); + + edit.undo(); + expect(getCommandState(edit).index).toBe(0); + + edit.undo(); + expect(getCommandState(edit).index).toBe(-1); + }); + + it("emits edit:undo event with command name", () => { + const cmd = new TestCommand("MyTestCmd"); + edit.executeEditCommand(cmd); + emitSpy.mockClear(); + + edit.undo(); + + expect(emitSpy).toHaveBeenCalledWith("edit:undo", { command: "MyTestCmd" }); + }); + + it("is no-op when commandIndex is -1 (empty history)", () => { + expect(getCommandState(edit).index).toBe(-1); + emitSpy.mockClear(); + + edit.undo(); + + expect(getCommandState(edit).index).toBe(-1); + expect(emitSpy).not.toHaveBeenCalledWith("edit:undo", expect.anything()); + }); + + it("is no-op when command has no undo method", () => { + const cmd = new NoUndoCommand(); + edit.executeEditCommand(cmd); + const { index: afterExec } = getCommandState(edit); + emitSpy.mockClear(); + + edit.undo(); + + // Index should not change since undo is undefined + expect(getCommandState(edit).index).toBe(afterExec); + expect(emitSpy).not.toHaveBeenCalledWith("edit:undo", expect.anything()); + }); + + it("allows multiple sequential undos", () => { + const cmd1 = new TestCommand("Cmd1"); + const cmd2 = new TestCommand("Cmd2"); + const cmd3 = new TestCommand("Cmd3"); + + edit.executeEditCommand(cmd1); + edit.executeEditCommand(cmd2); + edit.executeEditCommand(cmd3); + + edit.undo(); + expect(cmd3.undoCount).toBe(1); + expect(getCommandState(edit).index).toBe(1); + + edit.undo(); + expect(cmd2.undoCount).toBe(1); + expect(getCommandState(edit).index).toBe(0); + + edit.undo(); + expect(cmd1.undoCount).toBe(1); + expect(getCommandState(edit).index).toBe(-1); + }); + }); + + describe("redo() method", () => { + it("increments commandIndex before execute", () => { + edit.executeEditCommand(new TestCommand()); + edit.undo(); + expect(getCommandState(edit).index).toBe(-1); + + edit.redo(); + + expect(getCommandState(edit).index).toBe(0); + }); + + it("calls command.execute() with context", () => { + const cmd = new TestCommand(); + edit.executeEditCommand(cmd); + edit.undo(); + cmd.executeCount = 0; // Reset after initial execute + + edit.redo(); + + expect(cmd.executeCount).toBe(1); + expect(cmd.lastContext).toBeDefined(); + }); + + it("emits edit:redo event with command name", () => { + const cmd = new TestCommand("MyRedoCmd"); + edit.executeEditCommand(cmd); + edit.undo(); + emitSpy.mockClear(); + + edit.redo(); + + expect(emitSpy).toHaveBeenCalledWith("edit:redo", { command: "MyRedoCmd" }); + }); + + it("is no-op when at end of history", () => { + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(0); + emitSpy.mockClear(); + + edit.redo(); + + expect(getCommandState(edit).index).toBe(0); + expect(emitSpy).not.toHaveBeenCalledWith("edit:redo", expect.anything()); + }); + + it("allows multiple sequential redos", () => { + const cmd1 = new TestCommand("Cmd1"); + const cmd2 = new TestCommand("Cmd2"); + const cmd3 = new TestCommand("Cmd3"); + + edit.executeEditCommand(cmd1); + edit.executeEditCommand(cmd2); + edit.executeEditCommand(cmd3); + edit.undo(); + edit.undo(); + edit.undo(); + + // Reset execute counts + cmd1.executeCount = 0; + cmd2.executeCount = 0; + cmd3.executeCount = 0; + + edit.redo(); + expect(cmd1.executeCount).toBe(1); + expect(getCommandState(edit).index).toBe(0); + + edit.redo(); + expect(cmd2.executeCount).toBe(1); + expect(getCommandState(edit).index).toBe(1); + + edit.redo(); + expect(cmd3.executeCount).toBe(1); + expect(getCommandState(edit).index).toBe(2); + }); + }); + + describe("history truncation", () => { + it("truncates future commands when executing after undo", () => { + const cmdA = new TestCommand("A"); + const cmdB = new TestCommand("B"); + const cmdC = new TestCommand("C"); + const cmdD = new TestCommand("D"); + + edit.executeEditCommand(cmdA); + edit.executeEditCommand(cmdB); + edit.executeEditCommand(cmdC); + // History: [A, B, C], index = 2 + + edit.undo(); // index = 1 + edit.undo(); // index = 0 + // History still [A, B, C], but index = 0 + + edit.executeEditCommand(cmdD); + // Should truncate B and C, leaving [A, D] + + const { history, index } = getCommandState(edit); + expect(history.length).toBe(2); + expect(history[0]).toBe(cmdA); + expect(history[1]).toBe(cmdD); + expect(index).toBe(1); + }); + + it("preserves commands before current index", () => { + const cmdA = new TestCommand("A"); + const cmdB = new TestCommand("B"); + const cmdC = new TestCommand("C"); + const cmdNew = new TestCommand("New"); + + edit.executeEditCommand(cmdA); + edit.executeEditCommand(cmdB); + edit.executeEditCommand(cmdC); + + edit.undo(); // index = 1 (at B) + + edit.executeEditCommand(cmdNew); + + const { history } = getCommandState(edit); + expect(history).toContain(cmdA); + expect(history).toContain(cmdB); + expect(history).toContain(cmdNew); + expect(history).not.toContain(cmdC); + }); + + it("clears entire redo stack on new command", () => { + const cmd1 = new TestCommand("1"); + const cmd2 = new TestCommand("2"); + const cmd3 = new TestCommand("3"); + const cmd4 = new TestCommand("4"); + const cmdNew = new TestCommand("New"); + + edit.executeEditCommand(cmd1); + edit.executeEditCommand(cmd2); + edit.executeEditCommand(cmd3); + edit.executeEditCommand(cmd4); + // Undo all the way back + edit.undo(); + edit.undo(); + edit.undo(); + edit.undo(); + // index = -1, but history = [1, 2, 3, 4] + + edit.executeEditCommand(cmdNew); + + const { history, index } = getCommandState(edit); + expect(history.length).toBe(1); + expect(history[0]).toBe(cmdNew); + expect(index).toBe(0); + }); + }); + + describe("commandIndex tracking", () => { + it("starts at -1 (no commands)", () => { + const { index } = getCommandState(edit); + expect(index).toBe(-1); + }); + + it("equals 0 after first command", () => { + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(0); + }); + + it("equals history.length - 1 after multiple commands", () => { + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + + const { history, index } = getCommandState(edit); + expect(index).toBe(history.length - 1); + expect(index).toBe(4); + }); + + it("decrements on undo", () => { + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(1); + + edit.undo(); + expect(getCommandState(edit).index).toBe(0); + }); + + it("increments on redo", () => { + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + edit.undo(); + edit.undo(); + expect(getCommandState(edit).index).toBe(-1); + + edit.redo(); + expect(getCommandState(edit).index).toBe(0); + }); + + it("resets correctly after truncation", () => { + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + edit.executeEditCommand(new TestCommand()); + // index = 2, history.length = 3 + + edit.undo(); + edit.undo(); + // index = 0, history.length = 3 + + edit.executeEditCommand(new TestCommand()); + // Should be: index = 1, history.length = 2 + + const { history, index } = getCommandState(edit); + expect(index).toBe(1); + expect(history.length).toBe(2); + expect(index).toBe(history.length - 1); + }); + }); + + describe("state restoration", () => { + it("undo restores previous state via command.undo()", () => { + // Use a command that tracks state changes + let stateValue = 0; + + const stateCmd: EditCommand = { + name: "StateCmd", + execute: () => { + stateValue = 42; + }, + undo: () => { + stateValue = 0; + } + }; + + expect(stateValue).toBe(0); + edit.executeEditCommand(stateCmd); + expect(stateValue).toBe(42); + + edit.undo(); + expect(stateValue).toBe(0); + }); + + it("redo re-applies state change", () => { + let stateValue = 0; + + const stateCmd: EditCommand = { + name: "StateCmd", + execute: () => { + stateValue = 100; + }, + undo: () => { + stateValue = 0; + } + }; + + edit.executeEditCommand(stateCmd); + edit.undo(); + expect(stateValue).toBe(0); + + edit.redo(); + expect(stateValue).toBe(100); + }); + + it("multiple undo/redo cycles preserve state integrity", () => { + const stateHistory: number[] = []; + + const incrementCmd: EditCommand = { + name: "Increment", + execute: () => { + stateHistory.push(stateHistory.length); + }, + undo: () => { + stateHistory.pop(); + } + }; + + // Execute 3 times + edit.executeEditCommand(incrementCmd); + edit.executeEditCommand(incrementCmd); + edit.executeEditCommand(incrementCmd); + expect(stateHistory).toEqual([0, 1, 2]); + + // Undo twice + edit.undo(); + edit.undo(); + expect(stateHistory).toEqual([0]); + + // Redo once + edit.redo(); + expect(stateHistory).toEqual([0, 1]); + + // Undo once + edit.undo(); + expect(stateHistory).toEqual([0]); + + // Redo twice + edit.redo(); + edit.redo(); + expect(stateHistory).toEqual([0, 1, 2]); + }); + }); + + describe("edge cases", () => { + it("handles empty history gracefully", () => { + // Verify no errors thrown + expect(() => edit.undo()).not.toThrow(); + expect(() => edit.redo()).not.toThrow(); + + const { history, index } = getCommandState(edit); + expect(history.length).toBe(0); + expect(index).toBe(-1); + }); + + it("undo at beginning is idempotent", () => { + edit.executeEditCommand(new TestCommand()); + edit.undo(); + expect(getCommandState(edit).index).toBe(-1); + + // Multiple undos at beginning should not change state + edit.undo(); + edit.undo(); + edit.undo(); + + expect(getCommandState(edit).index).toBe(-1); + }); + + it("redo at end is idempotent", () => { + edit.executeEditCommand(new TestCommand()); + expect(getCommandState(edit).index).toBe(0); + + // Multiple redos at end should not change state + edit.redo(); + edit.redo(); + edit.redo(); + + expect(getCommandState(edit).index).toBe(0); + }); + + it("mixed undo/redo/execute sequence", () => { + const cmdA = new TestCommand("A"); + const cmdB = new TestCommand("B"); + const cmdC = new TestCommand("C"); + const cmdD = new TestCommand("D"); + const cmdE = new TestCommand("E"); + + // Execute A, B, C + edit.executeEditCommand(cmdA); + edit.executeEditCommand(cmdB); + edit.executeEditCommand(cmdC); + expect(getCommandState(edit).history.map(c => c.name)).toEqual(["A", "B", "C"]); + + // Undo to B + edit.undo(); + expect(getCommandState(edit).index).toBe(1); + + // Execute D (should truncate C) + edit.executeEditCommand(cmdD); + expect(getCommandState(edit).history.map(c => c.name)).toEqual(["A", "B", "D"]); + + // Undo D and B + edit.undo(); + edit.undo(); + expect(getCommandState(edit).index).toBe(0); + + // Redo B + edit.redo(); + expect(getCommandState(edit).index).toBe(1); + + // Execute E (should truncate D) + edit.executeEditCommand(cmdE); + expect(getCommandState(edit).history.map(c => c.name)).toEqual(["A", "B", "E"]); + expect(getCommandState(edit).index).toBe(2); + }); + }); + + describe("event emission patterns", () => { + it("undo emits exactly one edit:undo event", () => { + edit.executeEditCommand(new TestCommand("Test")); + emitSpy.mockClear(); + + edit.undo(); + + const undoEvents = emitSpy.mock.calls.filter(call => call[0] === "edit:undo"); + expect(undoEvents.length).toBe(1); + }); + + it("redo emits exactly one edit:redo event", () => { + edit.executeEditCommand(new TestCommand("Test")); + edit.undo(); + emitSpy.mockClear(); + + edit.redo(); + + const redoEvents = emitSpy.mock.calls.filter(call => call[0] === "edit:redo"); + expect(redoEvents.length).toBe(1); + }); + + it("no-op undo does not emit event", () => { + emitSpy.mockClear(); + + edit.undo(); // Empty history + + const undoEvents = emitSpy.mock.calls.filter(call => call[0] === "edit:undo"); + expect(undoEvents.length).toBe(0); + }); + + it("no-op redo does not emit event", () => { + edit.executeEditCommand(new TestCommand()); + emitSpy.mockClear(); + + edit.redo(); // Already at end + + const redoEvents = emitSpy.mock.calls.filter(call => call[0] === "edit:redo"); + expect(redoEvents.length).toBe(0); + }); + }); +}); diff --git a/tests/edit-playback.test.ts b/tests/edit-playback.test.ts new file mode 100644 index 00000000..d1306862 --- /dev/null +++ b/tests/edit-playback.test.ts @@ -0,0 +1,443 @@ +/** + * Edit Class Playback Tests + * + * Tests the playback state machine: play(), pause(), seek(), stop(), and update() + * These methods control timeline playback and emit events for UI synchronization. + */ + +import { Edit } from "@core/edit"; +import type { EventEmitter } from "@core/events/event-emitter"; + +// Mock pixi-filters (must be before pixi.js since it extends pixi classes) +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +// Mock pixi.js to prevent WebGL initialization +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + return { + children, + sortableChildren: true, + parent: null as unknown, + label: null as string | null, + zIndex: 0, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + if (typeof child === "object" && child !== null) { + child.parent = createMockContainer(); + } + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx !== -1) children.splice(idx, 1); + return child; + }), + removeChildAt: jest.fn(), + getChildByLabel: jest.fn(() => null), + getChildIndex: jest.fn(() => 0), + destroy: jest.fn(), + setMask: jest.fn() + }; + }; + + const createMockGraphics = (): Record => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + destroy: jest.fn() + }); + + return { + Container: jest.fn().mockImplementation(createMockContainer), + Graphics: jest.fn().mockImplementation(createMockGraphics), + Sprite: jest.fn().mockImplementation(() => ({ + texture: {}, + width: 100, + height: 100, + parent: null, + destroy: jest.fn() + })), + Texture: { from: jest.fn() }, + Assets: { load: jest.fn(), unload: jest.fn() }, + ColorMatrixFilter: jest.fn(() => ({ negative: jest.fn() })) + }; +}); + +// Mock AssetLoader +jest.mock("@loaders/asset-loader", () => ({ + AssetLoader: jest.fn().mockImplementation(() => ({ + load: jest.fn().mockResolvedValue({}), + unload: jest.fn(), + getProgress: jest.fn().mockReturnValue(100), + loadTracker: { + on: jest.fn(), + off: jest.fn() + } + })) +})); + +// Mock LumaMaskController +jest.mock("@core/luma-mask-controller", () => ({ + LumaMaskController: jest.fn().mockImplementation(() => ({ + initialize: jest.fn(), + update: jest.fn(), + dispose: jest.fn(), + cleanupForPlayer: jest.fn(), + getActiveMaskCount: jest.fn().mockReturnValue(0) + })) +})); + +// Mock LoadingOverlay +jest.mock("@core/ui/loading-overlay", () => ({ + LoadingOverlay: jest.fn().mockImplementation(() => ({ + show: jest.fn(), + hide: jest.fn(), + update: jest.fn() + })) +})); + +// Mock TextPlayer font cache +jest.mock("@canvas/players/text-player", () => ({ + TextPlayer: { + resetFontCache: jest.fn() + } +})); + +// Mock AlignmentGuides +jest.mock("@canvas/system/alignment-guides", () => ({ + AlignmentGuides: jest.fn().mockImplementation(() => ({ + drawCanvasGuide: jest.fn(), + drawClipGuide: jest.fn(), + clear: jest.fn() + })) +})); + +describe("Edit Playback", () => { + let edit: Edit; + let events: EventEmitter; + let emitSpy: jest.SpyInstance; + + beforeEach(async () => { + // Create Edit instance with default size + edit = new Edit({ width: 1920, height: 1080 }); + + // Initialize the container (normally done by Canvas) + await edit.load(); + + // Access the internal events emitter + events = edit.events; + emitSpy = jest.spyOn(events, "emit"); + + // Set up a mock duration for testing + edit.totalDuration = 10000; // 10 seconds in milliseconds + edit.playbackTime = 0; + edit.isPlaying = false; + }); + + afterEach(() => { + edit.dispose(); + jest.clearAllMocks(); + }); + + describe("play()", () => { + it("sets isPlaying to true", () => { + expect(edit.isPlaying).toBe(false); + + edit.play(); + + expect(edit.isPlaying).toBe(true); + }); + + it("emits playback:play event", () => { + edit.play(); + + expect(emitSpy).toHaveBeenCalledWith("playback:play", {}); + }); + + it("does not change playbackTime", () => { + edit.playbackTime = 5000; + + edit.play(); + + expect(edit.playbackTime).toBe(5000); + }); + }); + + describe("pause()", () => { + it("sets isPlaying to false", () => { + edit.isPlaying = true; + + edit.pause(); + + expect(edit.isPlaying).toBe(false); + }); + + it("emits playback:pause event", () => { + edit.pause(); + + expect(emitSpy).toHaveBeenCalledWith("playback:pause", {}); + }); + + it("does not change playbackTime", () => { + edit.playbackTime = 5000; + edit.isPlaying = true; + + edit.pause(); + + expect(edit.playbackTime).toBe(5000); + }); + }); + + describe("seek()", () => { + it("updates playbackTime to target", () => { + edit.seek(5000); + + expect(edit.playbackTime).toBe(5000); + }); + + it("clamps negative time to 0", () => { + edit.seek(-1000); + + expect(edit.playbackTime).toBe(0); + }); + + it("clamps time beyond duration to totalDuration", () => { + edit.seek(20000); // Beyond 10 second duration + + expect(edit.playbackTime).toBe(10000); + }); + + it("pauses playback when seeking", () => { + edit.isPlaying = true; + + edit.seek(5000); + + expect(edit.isPlaying).toBe(false); + }); + + it("emits playback:pause event", () => { + edit.isPlaying = true; + + edit.seek(5000); + + expect(emitSpy).toHaveBeenCalledWith("playback:pause", {}); + }); + + it("seeks to exact boundary values", () => { + edit.seek(0); + expect(edit.playbackTime).toBe(0); + + edit.seek(10000); + expect(edit.playbackTime).toBe(10000); + }); + }); + + describe("stop()", () => { + it("resets playbackTime to 0", () => { + edit.playbackTime = 5000; + + edit.stop(); + + expect(edit.playbackTime).toBe(0); + }); + + it("pauses playback", () => { + edit.isPlaying = true; + + edit.stop(); + + expect(edit.isPlaying).toBe(false); + }); + }); + + describe("update() playback advancement", () => { + it("advances playbackTime by elapsed when playing", () => { + edit.isPlaying = true; + edit.playbackTime = 0; + + edit.update(16, 100); // 100ms elapsed + + expect(edit.playbackTime).toBe(100); + }); + + it("does not advance playbackTime when paused", () => { + edit.isPlaying = false; + edit.playbackTime = 1000; + + edit.update(16, 100); + + expect(edit.playbackTime).toBe(1000); + }); + + it("clamps playbackTime to totalDuration", () => { + edit.isPlaying = true; + edit.playbackTime = 9950; + + edit.update(16, 100); // Would advance to 10050, but should clamp to 10000 + + expect(edit.playbackTime).toBe(10000); + }); + + it("auto-pauses when reaching end of timeline", () => { + edit.isPlaying = true; + edit.playbackTime = 9950; + + edit.update(16, 100); // Reaches end + + expect(edit.isPlaying).toBe(false); + expect(emitSpy).toHaveBeenCalledWith("playback:pause", {}); + }); + + it("clamps negative elapsed values to 0", () => { + edit.isPlaying = true; + edit.playbackTime = 5000; + + edit.update(16, -100); // Negative elapsed + + // playbackTime should be clamped to 0 minimum, but since we're at 5000 + // and elapsed is -100, result would be 4900, clamped to max(0, 4900) = 4900 + // Actually looking at the code: Math.max(0, Math.min(this.playbackTime + elapsed, this.totalDuration)) + // So playbackTime + (-100) = 4900, min(4900, 10000) = 4900, max(0, 4900) = 4900 + expect(edit.playbackTime).toBe(4900); + }); + }); + + describe("playback edge cases", () => { + it("handles empty timeline (duration 0)", () => { + edit.totalDuration = 0; + edit.playbackTime = 0; + edit.isPlaying = true; + + edit.update(16, 100); + + // Should immediately hit end and pause + expect(edit.playbackTime).toBe(0); + expect(edit.isPlaying).toBe(false); + }); + + it("seek while playing stops playback", () => { + edit.isPlaying = true; + edit.playbackTime = 2000; + + edit.seek(8000); + + expect(edit.isPlaying).toBe(false); + expect(edit.playbackTime).toBe(8000); + }); + + it("play at end of timeline (no advancement)", () => { + edit.playbackTime = 10000; + edit.isPlaying = true; + + edit.update(16, 100); + + // Already at end, should pause immediately + expect(edit.playbackTime).toBe(10000); + expect(edit.isPlaying).toBe(false); + }); + }); + + describe("playback state sequences", () => { + it("play → pause → play preserves position", () => { + edit.playbackTime = 3000; + + edit.play(); + expect(edit.isPlaying).toBe(true); + expect(edit.playbackTime).toBe(3000); + + edit.pause(); + expect(edit.isPlaying).toBe(false); + expect(edit.playbackTime).toBe(3000); + + edit.play(); + expect(edit.isPlaying).toBe(true); + expect(edit.playbackTime).toBe(3000); + }); + + it("play → seek → play restarts from seek position", () => { + edit.play(); + edit.playbackTime = 2000; // Simulate some advancement + + edit.seek(7000); + expect(edit.isPlaying).toBe(false); + expect(edit.playbackTime).toBe(7000); + + edit.play(); + expect(edit.isPlaying).toBe(true); + expect(edit.playbackTime).toBe(7000); + }); + + it("stop always returns to beginning", () => { + edit.play(); + edit.playbackTime = 5000; + + edit.stop(); + expect(edit.playbackTime).toBe(0); + expect(edit.isPlaying).toBe(false); + + edit.play(); + edit.playbackTime = 8000; + + edit.stop(); + expect(edit.playbackTime).toBe(0); + }); + + it("repeated stops are idempotent", () => { + edit.playbackTime = 5000; + + edit.stop(); + expect(edit.playbackTime).toBe(0); + + edit.stop(); + expect(edit.playbackTime).toBe(0); + + edit.stop(); + expect(edit.playbackTime).toBe(0); + }); + }); + + describe("event emission patterns", () => { + it("pause emits single event per call", () => { + edit.pause(); + edit.pause(); + edit.pause(); + + const pauseEvents = emitSpy.mock.calls.filter(call => call[0] === "playback:pause"); + expect(pauseEvents.length).toBe(3); + }); + + it("play emits single event per call", () => { + edit.play(); + edit.play(); + + const playEvents = emitSpy.mock.calls.filter(call => call[0] === "playback:play"); + expect(playEvents.length).toBe(2); + }); + + it("seek emits pause event (via internal pause call)", () => { + edit.isPlaying = true; + emitSpy.mockClear(); + + edit.seek(5000); + + expect(emitSpy).toHaveBeenCalledWith("playback:pause", {}); + }); + + it("stop emits pause event (via seek → pause)", () => { + edit.isPlaying = true; + emitSpy.mockClear(); + + edit.stop(); + + expect(emitSpy).toHaveBeenCalledWith("playback:pause", {}); + }); + }); +}); diff --git a/tests/edit-timing.test.ts b/tests/edit-timing.test.ts new file mode 100644 index 00000000..7d4947d8 --- /dev/null +++ b/tests/edit-timing.test.ts @@ -0,0 +1,610 @@ +/** + * Edit Class Timing Resolution Tests + * + * Tests the timing system that resolves "auto" and "end" values to numeric milliseconds. + * Covers pure resolver functions and Edit class integration for propagation. + */ + +import { Edit } from "@core/edit"; +import { PlayerType } from "@canvas/players/player"; +import type { EventEmitter } from "@core/events/event-emitter"; +import type { ResolvedClip } from "@schemas/clip"; +import { + resolveAutoStart, + resolveAutoLength, + resolveEndLength, + calculateTimelineEnd +} from "@core/timing/resolver"; + +// Mock probeMediaDuration since document.createElement doesn't work in Node +jest.mock("@core/timing/resolver", () => ({ + ...jest.requireActual("@core/timing/resolver"), + probeMediaDuration: jest.fn().mockResolvedValue(5.0) // 5 seconds +})); + +// Mock pixi-filters (must be before pixi.js since it extends pixi classes) +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +// Mock pixi.js +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + const self = { + children, + sortableChildren: true, + parent: null as unknown, + label: null as string | null, + zIndex: 0, + visible: true, + destroyed: false, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + if (typeof child === "object" && child !== null) { + child.parent = self; + } + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx !== -1) children.splice(idx, 1); + return child; + }), + removeChildAt: jest.fn(), + getChildByLabel: jest.fn(() => null), + getChildIndex: jest.fn(() => 0), + destroy: jest.fn(() => { + self.destroyed = true; + }), + setMask: jest.fn() + }; + return self; + }; + + const createMockGraphics = (): Record => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + stroke: jest.fn().mockReturnThis(), + strokeStyle: {}, + destroy: jest.fn() + }); + + return { + Container: jest.fn().mockImplementation(createMockContainer), + Graphics: jest.fn().mockImplementation(createMockGraphics), + Sprite: jest.fn().mockImplementation(() => ({ + texture: {}, + width: 100, + height: 100, + parent: null, + anchor: { set: jest.fn() }, + scale: { set: jest.fn() }, + position: { set: jest.fn() }, + destroy: jest.fn() + })), + Texture: { from: jest.fn() }, + Assets: { load: jest.fn().mockResolvedValue({}), unload: jest.fn() }, + ColorMatrixFilter: jest.fn(() => ({ negative: jest.fn() })), + Rectangle: jest.fn() + }; +}); + +// Mock AssetLoader +jest.mock("@loaders/asset-loader", () => ({ + AssetLoader: jest.fn().mockImplementation(() => ({ + load: jest.fn().mockResolvedValue({}), + unload: jest.fn(), + getProgress: jest.fn().mockReturnValue(100), + loadTracker: { on: jest.fn(), off: jest.fn() } + })) +})); + +// Mock LumaMaskController +jest.mock("@core/luma-mask-controller", () => ({ + LumaMaskController: jest.fn().mockImplementation(() => ({ + initialize: jest.fn(), + update: jest.fn(), + dispose: jest.fn(), + cleanupForPlayer: jest.fn(), + getActiveMaskCount: jest.fn().mockReturnValue(0) + })) +})); + +// Mock LoadingOverlay +jest.mock("@core/ui/loading-overlay", () => ({ + LoadingOverlay: jest.fn().mockImplementation(() => ({ + show: jest.fn(), + hide: jest.fn(), + update: jest.fn() + })) +})); + +// Mock AlignmentGuides +jest.mock("@canvas/system/alignment-guides", () => ({ + AlignmentGuides: jest.fn().mockImplementation(() => ({ + drawCanvasGuide: jest.fn(), + drawClipGuide: jest.fn(), + clear: jest.fn() + })) +})); + +// Create mock container for players +const createMockPlayerContainer = () => { + const children: unknown[] = []; + return { + children, + parent: null, + visible: true, + zIndex: 0, + addChild: jest.fn((child: unknown) => { + children.push(child); + return child; + }), + removeChild: jest.fn(), + destroy: jest.fn(), + setMask: jest.fn() + }; +}; + +// Mock player factory with timing intent support +const createMockPlayer = ( + edit: Edit, + config: ResolvedClip, + type: PlayerType +) => { + const container = createMockPlayerContainer(); + const contentContainer = createMockPlayerContainer(); + + // Parse timing intent from config + const startIntent = config.start; + const lengthIntent = config.length; + + // Calculate initial resolved values + const startMs = typeof startIntent === "number" ? startIntent * 1000 : 0; + const lengthMs = typeof lengthIntent === "number" ? lengthIntent * 1000 : 3000; + + let resolvedTiming = { start: startMs, length: lengthMs }; + let timingIntent = { start: startIntent, length: lengthIntent }; + + return { + clipConfiguration: config, + layer: 0, + playerType: type, + shouldDispose: false, + getContainer: () => container, + getContentContainer: () => contentContainer, + getStart: () => resolvedTiming.start, + getLength: () => resolvedTiming.length, + getEnd: () => resolvedTiming.start + resolvedTiming.length, + getSize: () => ({ width: 1920, height: 1080 }), + getTimingIntent: () => ({ ...timingIntent }), + setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + if (intent.start !== undefined) timingIntent.start = intent.start; + if (intent.length !== undefined) timingIntent.length = intent.length; + }), + getResolvedTiming: () => ({ ...resolvedTiming }), + setResolvedTiming: jest.fn((timing: { start: number; length: number }) => { + resolvedTiming = { ...timing }; + }), + load: jest.fn().mockResolvedValue(undefined), + draw: jest.fn(), + update: jest.fn(), + reconfigureAfterRestore: jest.fn(), + reloadAsset: jest.fn().mockResolvedValue(undefined), + dispose: jest.fn(), + isActive: () => true, + convertToFixedTiming: jest.fn() + }; +}; + +// Mock all player types +jest.mock("@canvas/players/video-player", () => ({ + VideoPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Video) + ) +})); + +jest.mock("@canvas/players/image-player", () => ({ + ImagePlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Image) + ) +})); + +jest.mock("@canvas/players/text-player", () => ({ + TextPlayer: Object.assign( + jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Text) + ), + { resetFontCache: jest.fn() } + ) +})); + +jest.mock("@canvas/players/audio-player", () => ({ + AudioPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Audio) + ) +})); + +jest.mock("@canvas/players/html-player", () => ({ + HtmlPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Html) + ) +})); + +jest.mock("@canvas/players/luma-player", () => ({ + LumaPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Luma) + ) +})); + +jest.mock("@canvas/players/shape-player", () => ({ + ShapePlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Shape) + ) +})); + +jest.mock("@canvas/players/caption-player", () => ({ + CaptionPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Caption) + ) +})); + +/** + * Create a mock player-like object for unit testing resolver functions. + */ +function createMockPlayerForResolver(startMs: number, lengthMs: number, lengthIntent: number | "auto" | "end" = lengthMs / 1000) { + return { + getStart: () => startMs, + getLength: () => lengthMs, + getEnd: () => startMs + lengthMs, + getTimingIntent: () => ({ start: startMs / 1000, length: lengthIntent }) + }; +} + +/** + * Helper to access private Edit state. + */ +function getEditState(edit: Edit): { + tracks: unknown[][]; + clips: unknown[]; + endLengthClips: Set; + cachedTimelineEnd: number; +} { + const anyEdit = edit as unknown as { + tracks: unknown[][]; + clips: unknown[]; + endLengthClips: Set; + cachedTimelineEnd: number; + }; + return { + tracks: anyEdit.tracks, + clips: anyEdit.clips, + endLengthClips: anyEdit.endLengthClips, + cachedTimelineEnd: anyEdit.cachedTimelineEnd + }; +} + +/** + * Create a video clip config. + */ +function createVideoClip(start: number | "auto", length: number | "auto" | "end"): ResolvedClip { + return { + asset: { type: "video", src: "https://example.com/video.mp4" }, + start, + length, + fit: "crop" + } as ResolvedClip; +} + +/** + * Create a text clip config. + */ +function createTextClip(start: number | "auto", length: number | "auto" | "end", text: string = "Hello"): ResolvedClip { + return { + asset: { type: "text", text, style: "minimal" }, + start, + length, + fit: "none" + } as ResolvedClip; +} + +// ============================================================================ +// UNIT TESTS: Pure Resolver Functions +// ============================================================================ + +describe("Timing Resolver Functions", () => { + describe("resolveAutoStart()", () => { + it("returns 0 for first clip on track", () => { + const tracks = [[createMockPlayerForResolver(0, 5000)]]; + + const result = resolveAutoStart(0, 0, tracks as never); + + expect(result).toBe(0); + }); + + it("returns previous clip end for subsequent clips", () => { + const tracks = [ + [ + createMockPlayerForResolver(0, 5000), + createMockPlayerForResolver(5000, 3000) + ] + ]; + + const result = resolveAutoStart(0, 1, tracks as never); + + expect(result).toBe(5000); // Previous clip ends at 5000ms + }); + + it("handles non-contiguous clips (with gaps)", () => { + const tracks = [ + [ + createMockPlayerForResolver(0, 2000), + createMockPlayerForResolver(5000, 3000) // Gap from 2000 to 5000 + ] + ]; + + const result = resolveAutoStart(0, 1, tracks as never); + + // Returns previous clip's END, not its actual position + expect(result).toBe(2000); + }); + + it("works independently across tracks", () => { + const tracks = [ + [createMockPlayerForResolver(0, 10000)], + [createMockPlayerForResolver(0, 3000)] + ]; + + const resultTrack0 = resolveAutoStart(0, 0, tracks as never); + const resultTrack1 = resolveAutoStart(1, 0, tracks as never); + + expect(resultTrack0).toBe(0); + expect(resultTrack1).toBe(0); + }); + }); + + describe("resolveAutoLength()", () => { + it("falls back to 3000ms for non-media assets", async () => { + const asset = { type: "text", text: "Hello" }; + + const result = await resolveAutoLength(asset); + + expect(result).toBe(3000); + }); + + it("falls back to 3000ms for assets without src", async () => { + const asset = { type: "video" }; // No src + + const result = await resolveAutoLength(asset); + + expect(result).toBe(3000); + }); + }); + + describe("resolveEndLength()", () => { + it("returns timeline end minus clip start", () => { + const result = resolveEndLength(2000, 10000); + + expect(result).toBe(8000); + }); + + it("never returns negative value", () => { + const result = resolveEndLength(15000, 10000); + + expect(result).toBe(0); + }); + + it("returns 0 when clip starts at timeline end", () => { + const result = resolveEndLength(10000, 10000); + + expect(result).toBe(0); + }); + + it("handles clip starting at 0", () => { + const result = resolveEndLength(0, 5000); + + expect(result).toBe(5000); + }); + }); + + describe("calculateTimelineEnd()", () => { + it("returns max end time of all clips", () => { + const tracks = [ + [createMockPlayerForResolver(0, 5000)], + [createMockPlayerForResolver(0, 8000)], + [createMockPlayerForResolver(2000, 3000)] + ]; + + const result = calculateTimelineEnd(tracks as never); + + expect(result).toBe(8000); + }); + + it("excludes clips with length: 'end' to prevent circular dependency", () => { + const tracks = [ + [createMockPlayerForResolver(0, 5000)], + [createMockPlayerForResolver(0, 15000, "end")] // Should be excluded + ]; + + const result = calculateTimelineEnd(tracks as never); + + expect(result).toBe(5000); // Only the first clip counts + }); + + it("returns 0 for empty tracks", () => { + const tracks: unknown[][] = []; + + const result = calculateTimelineEnd(tracks as never); + + expect(result).toBe(0); + }); + + it("returns 0 when all clips have length: 'end'", () => { + const tracks = [ + [createMockPlayerForResolver(0, 10000, "end")] + ]; + + const result = calculateTimelineEnd(tracks as never); + + expect(result).toBe(0); + }); + }); +}); + +// ============================================================================ +// INTEGRATION TESTS: Edit Class Timing +// ============================================================================ + +describe("Edit Timing Integration", () => { + let edit: Edit; + let events: EventEmitter; + let emitSpy: jest.SpyInstance; + + beforeEach(async () => { + edit = new Edit({ width: 1920, height: 1080 }); + await edit.load(); + events = edit.events; + emitSpy = jest.spyOn(events, "emit"); + }); + + afterEach(() => { + edit.dispose(); + jest.clearAllMocks(); + }); + + describe("start: 'auto' resolution", () => { + it("first clip with auto start resolves to 0ms", async () => { + // When adding a clip with start: "auto", it defaults to 0 for first clip + await edit.addClip(0, createVideoClip("auto", 5)); + + const clip = edit.getPlayerClip(0, 0); + expect(clip?.getStart()).toBe(0); + }); + + it("preserves timing intent for auto start clips", async () => { + await edit.addClip(0, createVideoClip("auto", 5)); + + const clip = edit.getPlayerClip(0, 0); + expect(clip?.getTimingIntent().start).toBe("auto"); + }); + + it("each track has independent first clip at 0", async () => { + await edit.addClip(0, createVideoClip(0, 10)); // Track 0 + await edit.addClip(1, createVideoClip("auto", 5)); // Track 1 - first clip + + const clipTrack1 = edit.getPlayerClip(1, 0); + expect(clipTrack1?.getStart()).toBe(0); // First on its track + }); + }); + + describe("length: 'auto' resolution", () => { + it("defaults to 3000ms for text assets without duration", async () => { + await edit.addClip(0, createTextClip(0, "auto")); + + const clip = edit.getPlayerClip(0, 0); + expect(clip?.getLength()).toBe(3000); + }); + + it("preserves timing intent for auto length clips", async () => { + await edit.addClip(0, createTextClip(0, "auto")); + + const clip = edit.getPlayerClip(0, 0); + expect(clip?.getTimingIntent().length).toBe("auto"); + }); + }); + + describe("length: 'end' intent", () => { + it("preserves 'end' in timing intent", async () => { + await edit.addClip(0, createVideoClip(0, 5)); // Establish timeline + await edit.addClip(1, createTextClip(0, "end")); + + const endClip = edit.getPlayerClip(1, 0); + expect(endClip?.getTimingIntent().length).toBe("end"); + }); + + it("tracks clip in endLengthClips set", async () => { + await edit.addClip(0, createTextClip(0, "end")); + + const { endLengthClips } = getEditState(edit); + expect(endLengthClips.size).toBe(1); + }); + }); + + describe("endLengthClips tracking", () => { + it("adds clip to set when length is 'end'", async () => { + await edit.addClip(0, createTextClip(0, "end")); + + const { endLengthClips } = getEditState(edit); + expect(endLengthClips.size).toBe(1); + }); + + it("removes clip from set when deleted", async () => { + await edit.addClip(0, createTextClip(0, "end")); + const { endLengthClips: before } = getEditState(edit); + expect(before.size).toBe(1); + + edit.deleteClip(0, 0); + + const { endLengthClips: after } = getEditState(edit); + expect(after.size).toBe(0); + }); + + it("does not add fixed-length clips to set", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + + const { endLengthClips } = getEditState(edit); + expect(endLengthClips.size).toBe(0); + }); + }); + + describe("clip updates", () => { + it("updateClip emits clip:updated event", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + emitSpy.mockClear(); + + edit.updateClip(0, 0, { start: 1 }); + + expect(emitSpy).toHaveBeenCalledWith("clip:updated", expect.anything()); + }); + + it("updateClip preserves timing intent type", async () => { + await edit.addClip(0, createVideoClip("auto", 5)); + + // Updating other properties shouldn't change timing intent + edit.updateClip(0, 0, { opacity: 0.5 }); + + const clip = edit.getPlayerClip(0, 0); + expect(clip?.getTimingIntent().start).toBe("auto"); + }); + }); + + describe("duration calculations with timing", () => { + it("totalDuration reflects max clip end", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + await edit.addClip(1, createVideoClip(0, 8)); + + expect(edit.getTotalDuration()).toBe(8000); + }); + + it("duration is 0 with no clips", () => { + expect(edit.getTotalDuration()).toBe(0); + }); + + it("duration updates when clip is deleted", async () => { + await edit.addClip(0, createVideoClip(0, 5)); + await edit.addClip(1, createVideoClip(0, 8)); + expect(edit.getTotalDuration()).toBe(8000); + + edit.deleteClip(1, 0); + + expect(edit.getTotalDuration()).toBe(5000); + }); + }); +}); From c4f79dce0c3e7b1d600239b2c2c85ec775220c2b Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 16:18:51 +1100 Subject: [PATCH 137/463] test: add comprehensive merge field tests for Edit class --- tests/edit-merge-fields.test.ts | 812 ++++++++++++++++++++++++++++++++ 1 file changed, 812 insertions(+) create mode 100644 tests/edit-merge-fields.test.ts diff --git a/tests/edit-merge-fields.test.ts b/tests/edit-merge-fields.test.ts new file mode 100644 index 00000000..cfa16a22 --- /dev/null +++ b/tests/edit-merge-fields.test.ts @@ -0,0 +1,812 @@ +/** + * Edit Class Merge Field Tests + * + * Tests the merge field system: applyMergeField, removeMergeField, getMergeFieldForProperty, + * updateMergeFieldValueLive, deleteMergeFieldGlobally. + * + * Merge fields enable dynamic content substitution using {{ FIELD_NAME }} templates. + * The system uses dual storage: originalEdit stores templates, clipConfiguration stores resolved values. + */ + +import { Edit } from "@core/edit"; +import { PlayerType } from "@canvas/players/player"; +import type { EventEmitter } from "@core/events/event-emitter"; +import type { ResolvedClip } from "@schemas/clip"; + +// Mock pixi-filters +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +// Mock pixi.js +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + const self = { + children, + sortableChildren: true, + parent: null as unknown, + label: null as string | null, + zIndex: 0, + visible: true, + destroyed: false, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + if (typeof child === "object" && child !== null) { + child.parent = self; + } + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx !== -1) children.splice(idx, 1); + return child; + }), + removeChildAt: jest.fn(), + getChildByLabel: jest.fn(() => null), + getChildIndex: jest.fn(() => 0), + destroy: jest.fn(() => { + self.destroyed = true; + }), + setMask: jest.fn() + }; + return self; + }; + + const createMockGraphics = (): Record => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + stroke: jest.fn().mockReturnThis(), + strokeStyle: {}, + destroy: jest.fn() + }); + + return { + Container: jest.fn().mockImplementation(createMockContainer), + Graphics: jest.fn().mockImplementation(createMockGraphics), + Sprite: jest.fn().mockImplementation(() => ({ + texture: {}, + width: 100, + height: 100, + parent: null, + anchor: { set: jest.fn() }, + scale: { set: jest.fn() }, + position: { set: jest.fn() }, + destroy: jest.fn() + })), + Texture: { from: jest.fn() }, + Assets: { load: jest.fn().mockResolvedValue({}), unload: jest.fn() }, + ColorMatrixFilter: jest.fn(() => ({ negative: jest.fn() })), + Rectangle: jest.fn() + }; +}); + +// Mock AssetLoader +jest.mock("@loaders/asset-loader", () => ({ + AssetLoader: jest.fn().mockImplementation(() => ({ + load: jest.fn().mockResolvedValue({}), + unload: jest.fn(), + getProgress: jest.fn().mockReturnValue(100), + loadTracker: { on: jest.fn(), off: jest.fn() } + })) +})); + +// Mock LumaMaskController +jest.mock("@core/luma-mask-controller", () => ({ + LumaMaskController: jest.fn().mockImplementation(() => ({ + initialize: jest.fn(), + update: jest.fn(), + dispose: jest.fn(), + cleanupForPlayer: jest.fn(), + getActiveMaskCount: jest.fn().mockReturnValue(0) + })) +})); + +// Mock LoadingOverlay +jest.mock("@core/ui/loading-overlay", () => ({ + LoadingOverlay: jest.fn().mockImplementation(() => ({ + show: jest.fn(), + hide: jest.fn(), + update: jest.fn() + })) +})); + +// Mock AlignmentGuides +jest.mock("@canvas/system/alignment-guides", () => ({ + AlignmentGuides: jest.fn().mockImplementation(() => ({ + drawCanvasGuide: jest.fn(), + drawClipGuide: jest.fn(), + clear: jest.fn() + })) +})); + +// Create mock container for players +const createMockPlayerContainer = () => { + const children: unknown[] = []; + return { + children, + parent: null, + visible: true, + zIndex: 0, + addChild: jest.fn((child: unknown) => { + children.push(child); + return child; + }), + removeChild: jest.fn(), + destroy: jest.fn(), + setMask: jest.fn() + }; +}; + +// Mock player factory - create functional mock players +const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => { + const container = createMockPlayerContainer(); + const contentContainer = createMockPlayerContainer(); + + const startMs = typeof config.start === "number" ? config.start * 1000 : 0; + const lengthMs = typeof config.length === "number" ? config.length * 1000 : 3000; + + let resolvedTiming = { start: startMs, length: lengthMs }; + let timingIntent = { start: config.start, length: config.length }; + + return { + clipConfiguration: config, + layer: 0, + playerType: type, + shouldDispose: false, + getContainer: () => container, + getContentContainer: () => contentContainer, + getStart: () => resolvedTiming.start, + getLength: () => resolvedTiming.length, + getEnd: () => resolvedTiming.start + resolvedTiming.length, + getSize: () => ({ width: 1920, height: 1080 }), + getTimingIntent: () => ({ ...timingIntent }), + setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + if (intent.start !== undefined) timingIntent.start = intent.start; + if (intent.length !== undefined) timingIntent.length = intent.length; + }), + getResolvedTiming: () => ({ ...resolvedTiming }), + setResolvedTiming: jest.fn((timing: { start: number; length: number }) => { + resolvedTiming = { ...timing }; + }), + load: jest.fn().mockResolvedValue(undefined), + draw: jest.fn(), + update: jest.fn(), + reconfigureAfterRestore: jest.fn(), + reloadAsset: jest.fn().mockResolvedValue(undefined), + dispose: jest.fn(), + isActive: () => true, + convertToFixedTiming: jest.fn() + }; +}; + +// Mock all player types +jest.mock("@canvas/players/video-player", () => ({ + VideoPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Video) + ) +})); + +jest.mock("@canvas/players/image-player", () => ({ + ImagePlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Image) + ) +})); + +jest.mock("@canvas/players/text-player", () => ({ + TextPlayer: Object.assign( + jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Text) + ), + { resetFontCache: jest.fn() } + ) +})); + +jest.mock("@canvas/players/audio-player", () => ({ + AudioPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Audio) + ) +})); + +jest.mock("@canvas/players/luma-player", () => ({ + LumaPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Luma) + ) +})); + +jest.mock("@canvas/players/shape-player", () => ({ + ShapePlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Shape) + ) +})); + +jest.mock("@canvas/players/html-player", () => ({ + HtmlPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Html) + ) +})); + +jest.mock("@canvas/players/rich-text-player", () => ({ + RichTextPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.RichText) + ) +})); + +jest.mock("@canvas/players/caption-player", () => ({ + CaptionPlayer: jest.fn().mockImplementation((edit, config) => + createMockPlayer(edit, config, PlayerType.Caption) + ) +})); + +/** + * Helper to access private Edit state for testing. + */ +function getEditState(edit: Edit): { + tracks: unknown[][]; + originalEdit: { timeline: { tracks: { clips: ResolvedClip[] }[] } } | null; + commandHistory: unknown[]; + commandIndex: number; +} { + const anyEdit = edit as unknown as { + tracks: unknown[][]; + originalEdit: { timeline: { tracks: { clips: ResolvedClip[] }[] } } | null; + commandHistory: unknown[]; + commandIndex: number; + }; + return { + tracks: anyEdit.tracks, + originalEdit: anyEdit.originalEdit, + commandHistory: anyEdit.commandHistory, + commandIndex: anyEdit.commandIndex + }; +} + +/** + * Create a simple image clip config for testing. + */ +function createImageClip(start: number, length: number, src: string = "https://example.com/image.jpg"): ResolvedClip { + return { + asset: { type: "image", src }, + start, + length, + fit: "crop" + }; +} + +/** + * Create a text clip config for testing. + */ +function createTextClip(start: number, length: number, text: string = "Hello World"): ResolvedClip { + return { + asset: { type: "text", text, style: "minimal" }, + start, + length, + fit: "none" + }; +} + +describe("Edit Merge Fields", () => { + let edit: Edit; + let events: EventEmitter; + let emitSpy: jest.SpyInstance; + + beforeEach(async () => { + edit = new Edit({ width: 1920, height: 1080 }); + await edit.load(); + + // Initialize originalEdit with tracks so merge field templates can be stored + // When addClip is called, it syncs to originalEdit.timeline.tracks[trackIdx] + // We pre-create empty track objects so the sync will work + await edit.loadEdit({ + timeline: { + tracks: [ + { clips: [] }, // Track 0 + { clips: [] } // Track 1 + ] + }, + output: { + size: { width: 1920, height: 1080 }, + format: "mp4" + } + }); + + events = edit.events; + emitSpy = jest.spyOn(events, "emit"); + }); + + afterEach(() => { + edit.dispose(); + jest.clearAllMocks(); + }); + + describe("applyMergeField()", () => { + it("stores {{ FIELD }} template in originalEdit", async () => { + // Add a clip first + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + // Apply merge field + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://cdn.example.com/new.jpg"); + + // Check originalEdit has template + const { originalEdit } = getEditState(edit); + const templateClip = originalEdit?.timeline.tracks[0].clips[0]; + expect(templateClip?.asset).toHaveProperty("src", "{{ MEDIA_URL }}"); + }); + + it("updates clipConfiguration with resolved value", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://cdn.example.com/new.jpg"); + + // Check player's clipConfiguration has resolved value + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://cdn.example.com/new.jpg"); + }); + + it("handles asset.src path", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "IMAGE_SRC", "https://example.com/image.png"); + + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/image.png"); + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("IMAGE_SRC"); + }); + + it("handles asset.text path", async () => { + const clip = createTextClip(0, 3, "Original text"); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.text", "HEADLINE", "New headline text"); + + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("text", "New headline text"); + expect(edit.getMergeFieldForProperty(0, 0, "asset.text")).toBe("HEADLINE"); + }); + + it("is undoable - restores previous value", async () => { + const originalSrc = "https://example.com/original.jpg"; + const clip = createImageClip(0, 3, originalSrc); + await edit.addClip(0, clip); + + // Apply merge field + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://cdn.example.com/new.jpg", originalSrc); + + // Undo + edit.undo(); + await Promise.resolve(); // Flush async operations + + // Check restored value + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", originalSrc); + }); + + it("registers field in merge field service", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "NEW_FIELD", "https://example.com/value.jpg"); + + const field = edit.mergeFields.get("NEW_FIELD"); + expect(field).toBeDefined(); + expect(field?.defaultValue).toBe("https://example.com/value.jpg"); + }); + + it("emits mergefield:applied event", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + emitSpy.mockClear(); + + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://example.com/new.jpg"); + // Flush microtask queue - command.execute() is async + await Promise.resolve(); + + expect(emitSpy).toHaveBeenCalledWith( + "mergefield:applied", + expect.objectContaining({ + propertyPath: "asset.src", + fieldName: "MEDIA_URL", + trackIndex: 0, + clipIndex: 0 + }) + ); + }); + + it("replaces existing merge field on same property", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + // Apply first merge field + edit.applyMergeField(0, 0, "asset.src", "FIELD_A", "https://a.example.com/a.jpg"); + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("FIELD_A"); + + // Apply second merge field to same property + edit.applyMergeField(0, 0, "asset.src", "FIELD_B", "https://b.example.com/b.jpg"); + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("FIELD_B"); + }); + + it("calls reloadAsset for src field changes", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + const player = edit.getPlayerClip(0, 0); + const reloadSpy = jest.spyOn(player!, "reloadAsset"); + + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://example.com/new.jpg"); + + expect(reloadSpy).toHaveBeenCalled(); + }); + + it("is no-op for invalid track/clip indices", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + const { commandIndex: beforeIndex } = getEditState(edit); + + // Try to apply to non-existent clip + edit.applyMergeField(99, 99, "asset.src", "FIELD", "value"); + + const { commandIndex: afterIndex } = getEditState(edit); + // Command should not have been added + expect(afterIndex).toBe(beforeIndex); + }); + }); + + describe("removeMergeField()", () => { + it("removes {{ FIELD }} from originalEdit", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + // Apply merge field first + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://example.com/new.jpg"); + + // Remove merge field + edit.removeMergeField(0, 0, "asset.src", "https://example.com/restored.jpg"); + + const { originalEdit } = getEditState(edit); + const templateClip = originalEdit?.timeline.tracks[0].clips[0]; + expect(templateClip?.asset).toHaveProperty("src", "https://example.com/restored.jpg"); + }); + + it("sets restoreValue in clipConfiguration", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://example.com/new.jpg"); + edit.removeMergeField(0, 0, "asset.src", "https://example.com/restored.jpg"); + + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/restored.jpg"); + }); + + it("removes field from registry", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "REMOVE_ME", "https://example.com/value.jpg"); + expect(edit.mergeFields.get("REMOVE_ME")).toBeDefined(); + + edit.removeMergeField(0, 0, "asset.src", "https://example.com/restored.jpg"); + expect(edit.mergeFields.get("REMOVE_ME")).toBeUndefined(); + }); + + it("is undoable - re-applies field", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "UNDO_TEST", "https://example.com/merged.jpg"); + edit.removeMergeField(0, 0, "asset.src", "https://example.com/original.jpg"); + + // Undo the remove + edit.undo(); + await Promise.resolve(); + + // Field should be back + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("UNDO_TEST"); + }); + + it("is no-op when no merge field exists", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + const { commandIndex: beforeIndex } = getEditState(edit); + + // Try to remove non-existent merge field + edit.removeMergeField(0, 0, "asset.src", "https://example.com/restored.jpg"); + + const { commandIndex: afterIndex } = getEditState(edit); + // No command should have been added + expect(afterIndex).toBe(beforeIndex); + }); + + it("emits mergefield:removed event on undo", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://example.com/new.jpg"); + emitSpy.mockClear(); + + edit.undo(); + await Promise.resolve(); + + expect(emitSpy).toHaveBeenCalledWith( + "mergefield:removed", + expect.objectContaining({ + propertyPath: "asset.src", + trackIndex: 0, + clipIndex: 0 + }) + ); + }); + }); + + describe("getMergeFieldForProperty()", () => { + it("returns field name when merge field applied", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "IMAGE_URL", "https://example.com/image.jpg"); + + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("IMAGE_URL"); + }); + + it("returns null when no merge field", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBeNull(); + }); + + it("returns null for invalid track/clip indices", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + expect(edit.getMergeFieldForProperty(99, 0, "asset.src")).toBeNull(); + expect(edit.getMergeFieldForProperty(0, 99, "asset.src")).toBeNull(); + }); + + it("returns null for non-merge-field properties", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + // asset.src is not a merge field, so should return null + expect(edit.getMergeFieldForProperty(0, 0, "start")).toBeNull(); + }); + }); + + describe("updateMergeFieldValueLive()", () => { + it("updates field defaultValue in registry", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "LIVE_UPDATE", "https://example.com/initial.jpg"); + + edit.updateMergeFieldValueLive("LIVE_UPDATE", "https://example.com/updated.jpg"); + + const field = edit.mergeFields.get("LIVE_UPDATE"); + expect(field?.defaultValue).toBe("https://example.com/updated.jpg"); + }); + + it("updates clipConfiguration with new value", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "LIVE_UPDATE", "https://example.com/initial.jpg"); + + edit.updateMergeFieldValueLive("LIVE_UPDATE", "https://example.com/updated.jpg"); + + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/updated.jpg"); + }); + + it("does NOT create undo entry (command history unchanged)", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "NO_UNDO", "https://example.com/initial.jpg"); + + const { commandIndex: beforeIndex } = getEditState(edit); + + edit.updateMergeFieldValueLive("NO_UNDO", "https://example.com/updated.jpg"); + + const { commandIndex: afterIndex } = getEditState(edit); + expect(afterIndex).toBe(beforeIndex); + }); + + it("is no-op for non-existent field", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + const player = edit.getPlayerClip(0, 0); + const originalSrc = (player?.clipConfiguration.asset as { src: string }).src; + + edit.updateMergeFieldValueLive("NONEXISTENT", "https://example.com/new.jpg"); + + // Original value should be unchanged + expect(player?.clipConfiguration.asset).toHaveProperty("src", originalSrc); + }); + + it("updates all clips using the same field", async () => { + // Add two clips + const clip1 = createImageClip(0, 3); + const clip2 = createImageClip(3, 3); + await edit.addClip(0, clip1); + await edit.addClip(0, clip2); + + // Apply same merge field to both + edit.applyMergeField(0, 0, "asset.src", "SHARED_FIELD", "https://example.com/initial.jpg"); + edit.applyMergeField(0, 1, "asset.src", "SHARED_FIELD", "https://example.com/initial.jpg"); + + // Update the field value + edit.updateMergeFieldValueLive("SHARED_FIELD", "https://example.com/updated.jpg"); + + // Both clips should be updated + const player1 = edit.getPlayerClip(0, 0); + const player2 = edit.getPlayerClip(0, 1); + expect(player1?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/updated.jpg"); + expect(player2?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/updated.jpg"); + }); + }); + + describe("deleteMergeFieldGlobally()", () => { + it("removes field from registry", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "DELETE_ME", "https://example.com/value.jpg"); + expect(edit.mergeFields.get("DELETE_ME")).toBeDefined(); + + edit.deleteMergeFieldGlobally("DELETE_ME"); + expect(edit.mergeFields.get("DELETE_ME")).toBeUndefined(); + }); + + it("restores defaultValue to affected clips", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + edit.applyMergeField(0, 0, "asset.src", "RESTORE_TEST", "https://example.com/merged.jpg"); + + edit.deleteMergeFieldGlobally("RESTORE_TEST"); + + // The clip should have the default value restored + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/merged.jpg"); + + // Merge field should be removed from property + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBeNull(); + }); + + it("removes field from all clips using it", async () => { + // Add two clips + const clip1 = createImageClip(0, 3); + const clip2 = createImageClip(3, 3); + await edit.addClip(0, clip1); + await edit.addClip(0, clip2); + + // Apply same merge field to both + edit.applyMergeField(0, 0, "asset.src", "SHARED", "https://example.com/shared.jpg"); + edit.applyMergeField(0, 1, "asset.src", "SHARED", "https://example.com/shared.jpg"); + + // Delete globally + edit.deleteMergeFieldGlobally("SHARED"); + + // Both should have merge field removed + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBeNull(); + expect(edit.getMergeFieldForProperty(0, 1, "asset.src")).toBeNull(); + }); + + it("is no-op for non-existent field", async () => { + const clip = createImageClip(0, 3); + await edit.addClip(0, clip); + + const { commandIndex: beforeIndex } = getEditState(edit); + + edit.deleteMergeFieldGlobally("NONEXISTENT"); + + const { commandIndex: afterIndex } = getEditState(edit); + // No commands added for non-existent field + expect(afterIndex).toBe(beforeIndex); + }); + }); + + describe("MergeFieldService integration", () => { + it("generateUniqueName creates unique field names", async () => { + // Register some fields + edit.mergeFields.register({ name: "MEDIA_1", defaultValue: "value1" }); + edit.mergeFields.register({ name: "MEDIA_2", defaultValue: "value2" }); + + // Should skip existing and return MEDIA_3 + const uniqueName = edit.mergeFields.generateUniqueName("MEDIA"); + expect(uniqueName).toBe("MEDIA_3"); + }); + + it("createTemplate formats {{ FIELD }} correctly", () => { + const template = edit.mergeFields.createTemplate("MY_FIELD"); + expect(template).toBe("{{ MY_FIELD }}"); + }); + + it("extractFieldName parses template string", () => { + expect(edit.mergeFields.extractFieldName("{{ MY_FIELD }}")).toBe("MY_FIELD"); + expect(edit.mergeFields.extractFieldName("{{MY_FIELD}}")).toBe("MY_FIELD"); + expect(edit.mergeFields.extractFieldName("{{ MY_FIELD }}")).toBe("MY_FIELD"); + expect(edit.mergeFields.extractFieldName("no field here")).toBeNull(); + }); + + it("isMergeFieldTemplate detects template strings", () => { + expect(edit.mergeFields.isMergeFieldTemplate("{{ FIELD }}")).toBe(true); + expect(edit.mergeFields.isMergeFieldTemplate("{{FIELD}}")).toBe(true); + expect(edit.mergeFields.isMergeFieldTemplate("no template")).toBe(false); + expect(edit.mergeFields.isMergeFieldTemplate("")).toBe(false); + }); + + it("resolve substitutes field values in strings", () => { + edit.mergeFields.register({ name: "NAME", defaultValue: "John" }); + edit.mergeFields.register({ name: "GREETING", defaultValue: "Hello" }); + + const result = edit.mergeFields.resolve("{{ GREETING }}, {{ NAME }}!"); + expect(result).toBe("Hello, John!"); + }); + + it("toSerializedArray exports in Shotstack format", () => { + edit.mergeFields.register({ name: "FIELD_A", defaultValue: "value_a" }); + edit.mergeFields.register({ name: "FIELD_B", defaultValue: "value_b" }); + + const serialized = edit.mergeFields.toSerializedArray(); + expect(serialized).toContainEqual({ find: "FIELD_A", replace: "value_a" }); + expect(serialized).toContainEqual({ find: "FIELD_B", replace: "value_b" }); + }); + }); + + describe("undo/redo sequences", () => { + it("undo then redo preserves merge field state", async () => { + const clip = createImageClip(0, 3, "https://original.example.com/image.jpg"); + await edit.addClip(0, clip); + + const newValue = "https://new.example.com/image.jpg"; + edit.applyMergeField(0, 0, "asset.src", "SEQUENCE_TEST", newValue, "https://original.example.com/image.jpg"); + + // Verify applied + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("SEQUENCE_TEST"); + + // Undo + edit.undo(); + await Promise.resolve(); + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBeNull(); + + // Redo + edit.redo(); + await Promise.resolve(); + expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("SEQUENCE_TEST"); + }); + + it("multiple merge field operations can be undone in sequence", async () => { + const clip = createTextClip(0, 3, "Original"); + await edit.addClip(0, clip); + + // Apply first merge field + edit.applyMergeField(0, 0, "asset.text", "FIELD_1", "Value 1", "Original"); + + // Apply second merge field (replaces first) + edit.applyMergeField(0, 0, "asset.text", "FIELD_2", "Value 2"); + + // Undo second + edit.undo(); + await Promise.resolve(); + expect(edit.getMergeFieldForProperty(0, 0, "asset.text")).toBe("FIELD_1"); + + // Undo first + edit.undo(); + await Promise.resolve(); + expect(edit.getMergeFieldForProperty(0, 0, "asset.text")).toBeNull(); + }); + }); +}); From 073f5bd7cb4d7516b9cff3523d6d119ed6007723 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 17:05:44 +1100 Subject: [PATCH 138/463] test: add comprehensive loadEdit method test suite --- tests/edit-load.test.ts | 851 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 851 insertions(+) create mode 100644 tests/edit-load.test.ts diff --git a/tests/edit-load.test.ts b/tests/edit-load.test.ts new file mode 100644 index 00000000..65ba70a5 --- /dev/null +++ b/tests/edit-load.test.ts @@ -0,0 +1,851 @@ +/** + * Edit Class Load Tests + * + * Tests the loadEdit() method - the entry point for loading edit configurations. + * This includes player creation, track management, timing resolution, merge field handling, + * font loading, and state initialization. + * + * loadEdit() workflow: + * 1. Show loading overlay + * 2. Clear existing clips + * 3. Clone edit → originalEdit (for merge field templates) + * 4. Load merge fields from edit.merge array + * 5. Apply merge field substitutions + * 6. Parse & validate via EditSchema + * 7. Resolve alias references + * 8. Update canvas size if output.size differs + * 9. Set background color + * 10. Load fonts (parallel via Promise.all) + * 11. Create players per asset type (createPlayerFromAssetType) + * 12. Initialize luma mask controller + * 13. Resolve timing (two-pass) + * 14. Calculate total duration + * 15. Load soundtrack if present + * 16. Emit timeline:updated event + * 17. Hide loading overlay (in finally block) + */ + +import { Edit } from "@core/edit"; +import { PlayerType } from "@canvas/players/player"; +import type { EventEmitter } from "@core/events/event-emitter"; +import type { ResolvedClip, ResolvedEdit } from "@schemas/clip"; + +// Mock pixi-filters +jest.mock("pixi-filters", () => ({ + AdjustmentFilter: jest.fn().mockImplementation(() => ({})), + BloomFilter: jest.fn().mockImplementation(() => ({})), + GlowFilter: jest.fn().mockImplementation(() => ({})), + OutlineFilter: jest.fn().mockImplementation(() => ({})), + DropShadowFilter: jest.fn().mockImplementation(() => ({})) +})); + +// Mock pixi.js +jest.mock("pixi.js", () => { + const createMockContainer = (): Record => { + const children: unknown[] = []; + const self = { + children, + sortableChildren: true, + parent: null as unknown, + label: null as string | null, + zIndex: 0, + visible: true, + destroyed: false, + addChild: jest.fn((child: { parent?: unknown }) => { + children.push(child); + if (typeof child === "object" && child !== null) { + child.parent = self; + } + return child; + }), + removeChild: jest.fn((child: unknown) => { + const idx = children.indexOf(child); + if (idx !== -1) children.splice(idx, 1); + return child; + }), + removeChildAt: jest.fn(), + getChildByLabel: jest.fn(() => null), + getChildIndex: jest.fn(() => 0), + destroy: jest.fn(() => { + self.destroyed = true; + }), + setMask: jest.fn() + }; + return self; + }; + + const createMockGraphics = (): Record => ({ + fillStyle: {}, + rect: jest.fn().mockReturnThis(), + fill: jest.fn().mockReturnThis(), + clear: jest.fn().mockReturnThis(), + stroke: jest.fn().mockReturnThis(), + strokeStyle: {}, + destroy: jest.fn() + }); + + return { + Container: jest.fn().mockImplementation(createMockContainer), + Graphics: jest.fn().mockImplementation(createMockGraphics), + Sprite: jest.fn().mockImplementation(() => ({ + texture: {}, + width: 100, + height: 100, + parent: null, + anchor: { set: jest.fn() }, + scale: { set: jest.fn() }, + position: { set: jest.fn() }, + destroy: jest.fn() + })), + Texture: { from: jest.fn() }, + Assets: { load: jest.fn().mockResolvedValue({}), unload: jest.fn() }, + ColorMatrixFilter: jest.fn(() => ({ negative: jest.fn() })), + Rectangle: jest.fn() + }; +}); + +// Mock AssetLoader with font loading support +const mockAssetLoader = { + load: jest.fn().mockResolvedValue({}), + unload: jest.fn(), + getProgress: jest.fn().mockReturnValue(100), + loadTracker: { on: jest.fn(), off: jest.fn() } +}; + +jest.mock("@loaders/asset-loader", () => ({ + AssetLoader: jest.fn().mockImplementation(() => mockAssetLoader) +})); + +// Mock LumaMaskController +const mockLumaMaskController = { + initialize: jest.fn(), + update: jest.fn(), + dispose: jest.fn(), + cleanupForPlayer: jest.fn(), + getActiveMaskCount: jest.fn().mockReturnValue(0) +}; + +jest.mock("@core/luma-mask-controller", () => ({ + LumaMaskController: jest.fn().mockImplementation(() => mockLumaMaskController) +})); + +// Mock LoadingOverlay +const mockLoadingOverlay = { + show: jest.fn(), + hide: jest.fn(), + update: jest.fn() +}; + +jest.mock("@core/ui/loading-overlay", () => ({ + LoadingOverlay: jest.fn().mockImplementation(() => mockLoadingOverlay) +})); + +// Mock AlignmentGuides +jest.mock("@canvas/system/alignment-guides", () => ({ + AlignmentGuides: jest.fn().mockImplementation(() => ({ + drawCanvasGuide: jest.fn(), + drawClipGuide: jest.fn(), + clear: jest.fn() + })) +})); + +// Create mock container for players +const createMockPlayerContainer = () => { + const children: unknown[] = []; + return { + children, + parent: null, + visible: true, + zIndex: 0, + addChild: jest.fn((child: unknown) => { + children.push(child); + return child; + }), + removeChild: jest.fn(), + destroy: jest.fn(), + setMask: jest.fn() + }; +}; + +// Track player instances by type for assertions +const createdPlayers: Map = new Map(); + +// Mock player factory - create functional mock players +const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => { + // Track player creation + createdPlayers.set(type, (createdPlayers.get(type) || 0) + 1); + + const container = createMockPlayerContainer(); + const contentContainer = createMockPlayerContainer(); + + const startMs = typeof config.start === "number" ? config.start * 1000 : 0; + const lengthMs = typeof config.length === "number" ? config.length * 1000 : 3000; + + let resolvedTiming = { start: startMs, length: lengthMs }; + let timingIntent = { start: config.start, length: config.length }; + + return { + clipConfiguration: config, + layer: 0, + playerType: type, + shouldDispose: false, + getContainer: () => container, + getContentContainer: () => contentContainer, + getStart: () => resolvedTiming.start, + getLength: () => resolvedTiming.length, + getEnd: () => resolvedTiming.start + resolvedTiming.length, + getSize: () => ({ width: 1920, height: 1080 }), + getTimingIntent: () => ({ ...timingIntent }), + setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + if (intent.start !== undefined) timingIntent.start = intent.start; + if (intent.length !== undefined) timingIntent.length = intent.length; + }), + getResolvedTiming: () => ({ ...resolvedTiming }), + setResolvedTiming: jest.fn((timing: { start: number; length: number }) => { + resolvedTiming = { ...timing }; + }), + load: jest.fn().mockResolvedValue(undefined), + draw: jest.fn(), + update: jest.fn(), + reconfigureAfterRestore: jest.fn(), + reloadAsset: jest.fn().mockResolvedValue(undefined), + dispose: jest.fn(), + isActive: () => true, + convertToFixedTiming: jest.fn() + }; +}; + +// Mock all player types +jest.mock("@canvas/players/video-player", () => ({ + VideoPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Video)) +})); + +jest.mock("@canvas/players/image-player", () => ({ + ImagePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Image)) +})); + +jest.mock("@canvas/players/text-player", () => ({ + TextPlayer: Object.assign( + jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Text)), + { resetFontCache: jest.fn() } + ) +})); + +jest.mock("@canvas/players/audio-player", () => ({ + AudioPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Audio)) +})); + +jest.mock("@canvas/players/luma-player", () => ({ + LumaPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Luma)) +})); + +jest.mock("@canvas/players/shape-player", () => ({ + ShapePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Shape)) +})); + +jest.mock("@canvas/players/html-player", () => ({ + HtmlPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Html)) +})); + +jest.mock("@canvas/players/rich-text-player", () => ({ + RichTextPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.RichText)) +})); + +jest.mock("@canvas/players/caption-player", () => ({ + CaptionPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Caption)) +})); + +// Import mocked player constructors for assertions +import { VideoPlayer } from "@canvas/players/video-player"; +import { ImagePlayer } from "@canvas/players/image-player"; +import { TextPlayer } from "@canvas/players/text-player"; +import { AudioPlayer } from "@canvas/players/audio-player"; +import { LumaPlayer } from "@canvas/players/luma-player"; +import { ShapePlayer } from "@canvas/players/shape-player"; +import { HtmlPlayer } from "@canvas/players/html-player"; +import { RichTextPlayer } from "@canvas/players/rich-text-player"; +import { CaptionPlayer } from "@canvas/players/caption-player"; + +/** + * Helper to access private Edit state for testing. + */ +function getEditState(edit: Edit): { + tracks: unknown[][]; + originalEdit: { timeline: { tracks: { clips: ResolvedClip[] }[] } } | null; + backgroundColor: string; + size: { width: number; height: number }; +} { + const anyEdit = edit as unknown as { + tracks: unknown[][]; + originalEdit: { timeline: { tracks: { clips: ResolvedClip[] }[] } } | null; + backgroundColor: string; + size: { width: number; height: number }; + }; + return { + tracks: anyEdit.tracks, + originalEdit: anyEdit.originalEdit, + backgroundColor: anyEdit.backgroundColor, + size: anyEdit.size + }; +} + +/** + * Create a minimal valid edit configuration. + */ +function createMinimalEdit(tracks: { clips: ResolvedClip[] }[] = []): ResolvedEdit { + return { + timeline: { + tracks + }, + output: { + size: { width: 1920, height: 1080 }, + format: "mp4" + } + }; +} + +describe("Edit loadEdit()", () => { + let edit: Edit; + let events: EventEmitter; + let emitSpy: jest.SpyInstance; + + beforeEach(async () => { + // Reset player creation tracking + createdPlayers.clear(); + + // Reset all mocks + jest.clearAllMocks(); + + edit = new Edit({ width: 1920, height: 1080 }); + await edit.load(); + + events = edit.events; + emitSpy = jest.spyOn(events, "emit"); + }); + + afterEach(() => { + edit.dispose(); + jest.clearAllMocks(); + }); + + describe("player creation", () => { + it("creates VideoPlayer for video assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "video", src: "https://example.com/video.mp4" }, start: 0, length: 5, fit: "crop" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(VideoPlayer).toHaveBeenCalledTimes(1); + expect(VideoPlayer).toHaveBeenCalledWith(edit, expect.objectContaining({ asset: { type: "video", src: "https://example.com/video.mp4" } })); + }); + + it("creates ImagePlayer for image assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "image", src: "https://example.com/image.jpg" }, start: 0, length: 3, fit: "crop" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(ImagePlayer).toHaveBeenCalledTimes(1); + }); + + it("creates TextPlayer for text assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "text", text: "Hello World" }, start: 0, length: 3, fit: "none" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(TextPlayer).toHaveBeenCalledTimes(1); + }); + + it("creates AudioPlayer for audio assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "audio", src: "https://example.com/audio.mp3" }, start: 0, length: 10, fit: "crop" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(AudioPlayer).toHaveBeenCalledTimes(1); + }); + + it("creates LumaPlayer for luma assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "luma", src: "https://example.com/luma.mp4" }, start: 0, length: 3, fit: "crop" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(LumaPlayer).toHaveBeenCalledTimes(1); + }); + + it("creates ShapePlayer for shape assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "shape", shape: "rectangle", rectangle: { width: 100, height: 100 } }, start: 0, length: 3, fit: "none" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(ShapePlayer).toHaveBeenCalledTimes(1); + }); + + it("creates HtmlPlayer for html assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "html", html: "

Test

", css: "p { color: red; }" }, start: 0, length: 3, fit: "none" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(HtmlPlayer).toHaveBeenCalledTimes(1); + }); + + it("creates RichTextPlayer for rich-text assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "rich-text", text: "Hello World" }, start: 0, length: 3, fit: "none" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(RichTextPlayer).toHaveBeenCalledTimes(1); + }); + + it("creates CaptionPlayer for caption assets", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "caption", src: "https://example.com/captions.srt" }, start: 0, length: 10, fit: "none" }] + } + ]); + + await edit.loadEdit(editConfig); + + expect(CaptionPlayer).toHaveBeenCalledTimes(1); + }); + + it("creates multiple players for multi-clip edit", async () => { + const editConfig = createMinimalEdit([ + { + clips: [ + { asset: { type: "video", src: "https://example.com/video.mp4" }, start: 0, length: 5, fit: "crop" }, + { asset: { type: "image", src: "https://example.com/image.jpg" }, start: 5, length: 3, fit: "crop" } + ] + } + ]); + + await edit.loadEdit(editConfig); + + expect(VideoPlayer).toHaveBeenCalledTimes(1); + expect(ImagePlayer).toHaveBeenCalledTimes(1); + }); + }); + + describe("track management", () => { + it("creates tracks array matching input track count", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img1.jpg" }, start: 0, length: 3, fit: "crop" }] }, + { clips: [{ asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 0, length: 3, fit: "crop" }] }, + { clips: [{ asset: { type: "image", src: "https://example.com/img3.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + const { tracks } = getEditState(edit); + expect(tracks.length).toBe(3); + }); + + it("assigns correct layer index per track (trackIdx + 1)", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img1.jpg" }, start: 0, length: 3, fit: "crop" }] }, + { clips: [{ asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + // Players are created with layer = trackIdx + 1 + // Track 0 → layer 1, Track 1 → layer 2 + const player0 = edit.getPlayerClip(0, 0); + const player1 = edit.getPlayerClip(1, 0); + + expect(player0?.layer).toBe(1); + expect(player1?.layer).toBe(2); + }); + + it("handles multiple clips per track", async () => { + const editConfig = createMinimalEdit([ + { + clips: [ + { asset: { type: "image", src: "https://example.com/img1.jpg" }, start: 0, length: 3, fit: "crop" }, + { asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 3, length: 3, fit: "crop" }, + { asset: { type: "image", src: "https://example.com/img3.jpg" }, start: 6, length: 3, fit: "crop" } + ] + } + ]); + + await edit.loadEdit(editConfig); + + const { tracks } = getEditState(edit); + expect(tracks[0].length).toBe(3); + }); + + it("preserves original clip data in originalEdit", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + const { originalEdit } = getEditState(edit); + // Verify originalEdit contains the clip data (note: loadEdit clones edit + addPlayer syncs) + expect(originalEdit?.timeline.tracks[0].clips.length).toBeGreaterThanOrEqual(1); + expect(originalEdit?.timeline.tracks[0].clips[0].asset).toHaveProperty("src", "https://example.com/img.jpg"); + }); + }); + + describe("timing resolution", () => { + it("resolves start: 'auto' for first clip to 0", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: "auto", length: 3, fit: "crop" }] + } + ]); + + await edit.loadEdit(editConfig); + + const player = edit.getPlayerClip(0, 0); + expect(player?.getStart()).toBe(0); + }); + + it("resolves start: 'auto' to previous clip end", async () => { + const editConfig = createMinimalEdit([ + { + clips: [ + { asset: { type: "image", src: "https://example.com/img1.jpg" }, start: 0, length: 3, fit: "crop" }, + { asset: { type: "image", src: "https://example.com/img2.jpg" }, start: "auto", length: 2, fit: "crop" } + ] + } + ]); + + await edit.loadEdit(editConfig); + + // Second clip should start at 3000ms (end of first clip) + const player2 = edit.getPlayerClip(0, 1); + expect(player2?.getStart()).toBe(3000); + }); + + it("resolves length: 'auto' with default value", async () => { + const editConfig = createMinimalEdit([ + { + clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: "auto", fit: "crop" }] + } + ]); + + await edit.loadEdit(editConfig); + + const player = edit.getPlayerClip(0, 0); + // Default auto length for non-media assets is 3000ms + expect(player?.getLength()).toBe(3000); + }); + + it("sets totalDuration to max clip end time", async () => { + const editConfig = createMinimalEdit([ + { + clips: [ + { asset: { type: "image", src: "https://example.com/img1.jpg" }, start: 0, length: 5, fit: "crop" }, + { asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 3, length: 4, fit: "crop" } + ] + } + ]); + + await edit.loadEdit(editConfig); + + // Second clip ends at 3 + 4 = 7 seconds = 7000ms + expect(edit.totalDuration).toBe(7000); + }); + + it("initializes luma mask controller after clips loaded", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + expect(mockLumaMaskController.initialize).toHaveBeenCalled(); + }); + + it("handles multiple tracks with different timing", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img1.jpg" }, start: 0, length: 5, fit: "crop" }] }, + { clips: [{ asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 2, length: 10, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + // Track 1 clip ends at 2 + 10 = 12 seconds = 12000ms + expect(edit.totalDuration).toBe(12000); + }); + }); + + describe("merge field handling", () => { + it("stores original edit with {{ FIELD }} templates in originalEdit", async () => { + const editConfig: ResolvedEdit = { + timeline: { + tracks: [ + { + clips: [{ asset: { type: "image", src: "{{ MEDIA_URL }}" }, start: 0, length: 3, fit: "crop" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "MEDIA_URL", replace: "https://resolved.example.com/img.jpg" }] + }; + + await edit.loadEdit(editConfig); + + const { originalEdit } = getEditState(edit); + // Original should preserve the template + expect(originalEdit?.timeline.tracks[0].clips[0].asset).toHaveProperty("src", "{{ MEDIA_URL }}"); + }); + + it("loads merge fields into service from edit.merge array", async () => { + const editConfig: ResolvedEdit = { + timeline: { tracks: [] }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [ + { find: "FIELD_A", replace: "value_a" }, + { find: "FIELD_B", replace: "value_b" } + ] + }; + + await edit.loadEdit(editConfig); + + expect(edit.mergeFields.get("FIELD_A")).toBeDefined(); + expect(edit.mergeFields.get("FIELD_B")).toBeDefined(); + }); + + it("substitutes merge field values in resolved edit", async () => { + const editConfig: ResolvedEdit = { + timeline: { + tracks: [ + { + clips: [{ asset: { type: "image", src: "{{ MEDIA_URL }}" }, start: 0, length: 3, fit: "crop" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "MEDIA_URL", replace: "https://resolved.example.com/img.jpg" }] + }; + + await edit.loadEdit(editConfig); + + // Player should have resolved value + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://resolved.example.com/img.jpg"); + }); + + it("handles edit without merge fields", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + // Should load without errors + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/img.jpg"); + }); + }); + + describe("fonts", () => { + it("loads all fonts from timeline.fonts array", async () => { + const editConfig: ResolvedEdit = { + timeline: { + tracks: [], + fonts: [{ src: "https://example.com/font1.ttf" }, { src: "https://example.com/font2.woff2" }] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }; + + await edit.loadEdit(editConfig); + + // AssetLoader.load should be called for each font + expect(mockAssetLoader.load).toHaveBeenCalledWith("https://example.com/font1.ttf", expect.any(Object)); + expect(mockAssetLoader.load).toHaveBeenCalledWith("https://example.com/font2.woff2", expect.any(Object)); + }); + + it("handles empty fonts array", async () => { + const editConfig: ResolvedEdit = { + timeline: { + tracks: [], + fonts: [] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }; + + await edit.loadEdit(editConfig); + + // Should not call load for fonts + expect(mockAssetLoader.load).not.toHaveBeenCalled(); + }); + + it("handles edit without fonts property", async () => { + const editConfig = createMinimalEdit([]); + + await edit.loadEdit(editConfig); + + // Should load without errors + expect(mockAssetLoader.load).not.toHaveBeenCalled(); + }); + }); + + describe("events and state", () => { + it("emits timeline:updated event on completion", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + expect(emitSpy).toHaveBeenCalledWith("timeline:updated", expect.objectContaining({ current: expect.any(Object) })); + }); + + it("sets background color from timeline.background", async () => { + const editConfig: ResolvedEdit = { + timeline: { + tracks: [], + background: "#FF5500" + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }; + + await edit.loadEdit(editConfig); + + const { backgroundColor } = getEditState(edit); + expect(backgroundColor).toBe("#FF5500"); + }); + + it("updates canvas size from output.size", async () => { + // Start with default size + expect(getEditState(edit).size).toEqual({ width: 1920, height: 1080 }); + + const editConfig: ResolvedEdit = { + timeline: { tracks: [] }, + output: { size: { width: 1280, height: 720 }, format: "mp4" } + }; + + await edit.loadEdit(editConfig); + + const { size } = getEditState(edit); + expect(size).toEqual({ width: 1280, height: 720 }); + }); + }); + + describe("edge cases", () => { + it("handles empty edit (no tracks)", async () => { + const editConfig = createMinimalEdit([]); + + await edit.loadEdit(editConfig); + + const { tracks } = getEditState(edit); + expect(tracks.length).toBe(0); + expect(edit.totalDuration).toBe(0); + }); + + it("handles edit with empty tracks", async () => { + const editConfig = createMinimalEdit([{ clips: [] }, { clips: [] }]); + + await edit.loadEdit(editConfig); + + const { tracks } = getEditState(edit); + // Empty tracks are not created (no players added) + expect(tracks.length).toBe(0); + }); + + it("clears existing clips before loading new edit", async () => { + // Load first edit + const editConfig1 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img1.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(editConfig1); + + expect(getEditState(edit).tracks[0].length).toBe(1); + + // Load second edit + const editConfig2 = createMinimalEdit([ + { + clips: [ + { asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 0, length: 3, fit: "crop" }, + { asset: { type: "image", src: "https://example.com/img3.jpg" }, start: 3, length: 3, fit: "crop" } + ] + } + ]); + await edit.loadEdit(editConfig2); + + // Should only have clips from second edit + expect(getEditState(edit).tracks[0].length).toBe(2); + }); + + it("shows and hides loading overlay", async () => { + const editConfig = createMinimalEdit([]); + + await edit.loadEdit(editConfig); + + expect(mockLoadingOverlay.show).toHaveBeenCalled(); + expect(mockLoadingOverlay.hide).toHaveBeenCalled(); + }); + }); + + describe("soundtrack", () => { + it("loads soundtrack as AudioPlayer on last track", async () => { + const editConfig: ResolvedEdit = { + timeline: { + tracks: [{ clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 5, fit: "crop" }] }], + soundtrack: { src: "https://example.com/music.mp3", effect: "fadeIn" } + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }; + + await edit.loadEdit(editConfig); + + // Soundtrack creates one AudioPlayer (main track is image, not audio) + expect(AudioPlayer).toHaveBeenCalledTimes(1); + expect(AudioPlayer).toHaveBeenCalledWith( + edit, + expect.objectContaining({ + asset: expect.objectContaining({ + type: "audio", + src: "https://example.com/music.mp3", + effect: "fadeIn" + }) + }) + ); + }); + + it("handles edit without soundtrack", async () => { + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + + await edit.loadEdit(editConfig); + + // Only ImagePlayer should be created, no AudioPlayer + expect(ImagePlayer).toHaveBeenCalledTimes(1); + expect(AudioPlayer).not.toHaveBeenCalled(); + }); + }); +}); From 9376e690f1f000e6e9868ad0f393006ab2c5c922 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 19:14:24 +1100 Subject: [PATCH 139/463] feat: add multi-provider destination support to output schema --- src/core/schemas/edit.ts | 103 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 102 insertions(+), 1 deletion(-) diff --git a/src/core/schemas/edit.ts b/src/core/schemas/edit.ts index c6f570cc..598310bd 100644 --- a/src/core/schemas/edit.ts +++ b/src/core/schemas/edit.ts @@ -29,6 +29,106 @@ export const TimelineSchema = zod }) .strict(); +const ShotstackDestinationSchema = zod + .object({ + provider: zod.literal("shotstack"), + exclude: zod.boolean().optional() + }) + .strict(); + +const S3DestinationSchema = zod + .object({ + provider: zod.literal("s3"), + options: zod + .object({ + region: zod.string(), + bucket: zod.string().min(3).max(63), + prefix: zod.string().optional(), + filename: zod.string().optional(), + acl: zod.string().optional() + }) + .strict() + }) + .strict(); + +const MuxDestinationSchema = zod + .object({ + provider: zod.literal("mux"), + options: zod + .object({ + playbackPolicy: zod.array(zod.enum(["public", "signed"])).optional(), + passthrough: zod.string().max(255).optional() + }) + .strict() + .optional() + }) + .strict(); + +const GoogleCloudStorageDestinationSchema = zod + .object({ + provider: zod.literal("google-cloud-storage"), + options: zod + .object({ + bucket: zod.string().optional(), + prefix: zod.string().optional(), + filename: zod.string().optional() + }) + .strict() + .optional() + }) + .strict(); + +const GoogleDriveDestinationSchema = zod + .object({ + provider: zod.literal("google-drive"), + options: zod + .object({ + filename: zod.string().optional(), + folderId: zod.string().optional() + }) + .strict() + .optional() + }) + .strict(); + +const VimeoDestinationSchema = zod + .object({ + provider: zod.literal("vimeo"), + options: zod + .object({ + name: zod.string().optional(), + description: zod.string().optional(), + privacy: zod + .object({ + view: zod.enum(["anybody", "nobody", "contacts", "password", "unlisted"]).optional(), + embed: zod.enum(["public", "private", "whitelist"]).optional(), + comments: zod.enum(["anybody", "nobody", "contacts"]).optional() + }) + .strict() + .optional(), + folderUri: zod.string().optional() + }) + .strict() + .optional() + }) + .strict(); + +const TiktokDestinationSchema = zod + .object({ + provider: zod.literal("tiktok") + }) + .strict(); + +const DestinationSchema = zod.union([ + ShotstackDestinationSchema, + S3DestinationSchema, + MuxDestinationSchema, + GoogleCloudStorageDestinationSchema, + GoogleDriveDestinationSchema, + VimeoDestinationSchema, + TiktokDestinationSchema +]); + export const OutputSchema = zod .object({ size: zod @@ -38,7 +138,8 @@ export const OutputSchema = zod }) .strict(), fps: zod.number().positive().optional(), - format: zod.string() + format: zod.string(), + destinations: zod.array(DestinationSchema).optional() }) .strict(); From 32d6a4b95ed90e805a3a3d08d6a04fcb310f3d9e Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 21:24:44 +1100 Subject: [PATCH 140/463] feat: add validation schemas for output configuration --- src/core/edit.ts | 90 ++++++++++++++++++++++++++++++++++------ src/core/schemas/edit.ts | 45 +++++++++++++++----- 2 files changed, 112 insertions(+), 23 deletions(-) diff --git a/src/core/edit.ts b/src/core/edit.ts index 07e4948c..263ccd48 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1,5 +1,4 @@ import { AudioPlayer } from "@canvas/players/audio-player"; -import { AlignmentGuides } from "@canvas/system/alignment-guides"; import { CaptionPlayer } from "@canvas/players/caption-player"; import { HtmlPlayer } from "@canvas/players/html-player"; import { ImagePlayer } from "@canvas/players/image-player"; @@ -10,6 +9,7 @@ import { ShapePlayer } from "@canvas/players/shape-player"; import { TextPlayer } from "@canvas/players/text-player"; import { VideoPlayer } from "@canvas/players/video-player"; import type { Canvas } from "@canvas/shotstack-canvas"; +import { AlignmentGuides } from "@canvas/system/alignment-guides"; import { resolveAliasReferences } from "@core/alias"; import { AddClipCommand } from "@core/commands/add-clip-command"; import { AddTrackCommand } from "@core/commands/add-track-command"; @@ -33,7 +33,18 @@ import type { Size } from "@layouts/geometry"; import { AssetLoader } from "@loaders/asset-loader"; import { FontLoadParser } from "@loaders/font-load-parser"; import type { ResolvedClip } from "@schemas/clip"; -import { EditSchema, type Edit as EditConfig, type ResolvedEdit, type Soundtrack } from "@schemas/edit"; +import { + DestinationSchema, + EditSchema, + HexColorSchema, + OutputFormatSchema, + OutputFpsSchema, + OutputSizeSchema, + type Destination, + type Edit as EditConfig, + type ResolvedEdit, + type Soundtrack +} from "@schemas/edit"; import type { ResolvedTrack } from "@schemas/track"; import * as pixi from "pixi.js"; @@ -736,9 +747,7 @@ export class Edit extends Entity { // Sync to originalEdit so getEdit() returns updated values const originalClip = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; - const templateConfig = finalClipConfig && originalClip - ? deepMerge(structuredClone(originalClip), finalClipConfig) - : finalClipConfig; + const templateConfig = finalClipConfig && originalClip ? deepMerge(structuredClone(originalClip), finalClipConfig) : finalClipConfig; const command = new SetUpdatedClipCommand(clip, initialClipConfig, finalClipConfig, { trackIndex: trackIdx, @@ -1395,12 +1404,17 @@ export class Edit extends Entity { } public setOutputSize(width: number, height: number): void { - this.size = { width, height }; + const result = OutputSizeSchema.safeParse({ width, height }); + if (!result.success) { + throw new Error(`Invalid size: ${result.error.errors[0]?.message}`); + } + + this.size = result.data; if (this.edit) { this.edit.output = { ...this.edit.output, - size: { width, height } + size: result.data }; } @@ -1410,18 +1424,23 @@ export class Edit extends Entity { if (this.background) { this.background.clear(); this.background.fillStyle = { color: this.backgroundColor }; - this.background.rect(0, 0, width, height); + this.background.rect(0, 0, result.data.width, result.data.height); this.background.fill(); } - this.events.emit("output:size:changed", { width, height }); + this.events.emit("output:size:changed", result.data); } public setOutputFps(fps: number): void { + const result = OutputFpsSchema.safeParse(fps); + if (!result.success) { + throw new Error(`Invalid fps: ${result.error.errors[0]?.message}`); + } + if (this.edit) { this.edit.output = { ...this.edit.output, - fps + fps: result.data }; } @@ -1432,17 +1451,62 @@ export class Edit extends Entity { return this.edit?.output?.fps ?? 30; } + public setOutputFormat(format: string): void { + const result = OutputFormatSchema.safeParse(format); + if (!result.success) { + throw new Error(`Invalid format: ${result.error.errors[0]?.message}`); + } + + if (this.edit) { + this.edit.output = { + ...this.edit.output, + format: result.data + }; + } + + this.events.emit("output:format:changed", { format: result.data }); + } + + public getOutputFormat(): string { + return this.edit?.output?.format ?? "mp4"; + } + + public setOutputDestinations(destinations: Destination[]): void { + const result = DestinationSchema.array().safeParse(destinations); + if (!result.success) { + throw new Error(`Invalid destinations: ${result.error.message}`); + } + + if (this.edit) { + this.edit.output = { + ...this.edit.output, + destinations: result.data + }; + } + + this.events.emit("output:destinations:changed", { destinations: result.data }); + } + + public getOutputDestinations(): Destination[] { + return this.edit?.output?.destinations ?? []; + } + public getTimelineFonts(): Array<{ src: string }> { return this.edit?.timeline?.fonts ?? []; } public setTimelineBackground(color: string): void { - this.backgroundColor = color; + const result = HexColorSchema.safeParse(color); + if (!result.success) { + throw new Error(`Invalid color: ${result.error.errors[0]?.message}`); + } + + this.backgroundColor = result.data; if (this.edit) { this.edit.timeline = { ...this.edit.timeline, - background: color + background: result.data }; } @@ -1453,7 +1517,7 @@ export class Edit extends Entity { this.background.fill(); } - this.events.emit("timeline:background:changed", { color }); + this.events.emit("timeline:background:changed", { color: result.data }); } public getTimelineBackground(): string { diff --git a/src/core/schemas/edit.ts b/src/core/schemas/edit.ts index 598310bd..db3df5ae 100644 --- a/src/core/schemas/edit.ts +++ b/src/core/schemas/edit.ts @@ -20,9 +20,11 @@ export const SoundtrackSchema = zod }) .strict(); +export const HexColorSchema = zod.string().regex(/^#[A-Fa-f0-9]{6}$/, "Must be a valid hex color (e.g., #000000)"); + export const TimelineSchema = zod .object({ - background: zod.string().optional(), + background: HexColorSchema.optional(), fonts: FontSourceSchema.array().optional(), tracks: TrackSchema.array(), soundtrack: SoundtrackSchema.optional() @@ -119,7 +121,7 @@ const TiktokDestinationSchema = zod }) .strict(); -const DestinationSchema = zod.union([ +export const DestinationSchema = zod.union([ ShotstackDestinationSchema, S3DestinationSchema, MuxDestinationSchema, @@ -129,16 +131,38 @@ const DestinationSchema = zod.union([ TiktokDestinationSchema ]); +export const OutputFormatSchema = zod.enum(["mp4", "gif", "mp3", "jpg", "png", "bmp"], { + errorMap: () => ({ message: "Must be one of mp4, gif, mp3, jpg, png, bmp" }) +}); + +const VALID_FPS = [12, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60] as const; + +export const OutputFpsSchema = zod + .number() + .refine((val): val is (typeof VALID_FPS)[number] => VALID_FPS.includes(val as (typeof VALID_FPS)[number]), { + message: "Must be one of 12, 15, 23.976, 24, 25, 29.97, 30, 48, 50, 59.94, 60" + }); + +export const OutputSizeSchema = zod + .object({ + width: zod + .number({ message: "Width must be a number" }) + .int({ message: "Width must be an integer" }) + .min(1, { message: "Width must be at least 1" }) + .max(3840, { message: "Width must be at most 3840" }), + height: zod + .number({ message: "Height must be a number" }) + .int({ message: "Height must be an integer" }) + .min(1, { message: "Height must be at least 1" }) + .max(3840, { message: "Height must be at most 3840" }) + }) + .strict(); + export const OutputSchema = zod .object({ - size: zod - .object({ - width: zod.number().positive(), - height: zod.number().positive() - }) - .strict(), - fps: zod.number().positive().optional(), - format: zod.string(), + size: OutputSizeSchema, + fps: OutputFpsSchema.optional(), + format: OutputFormatSchema, destinations: zod.array(DestinationSchema).optional() }) .strict(); @@ -158,6 +182,7 @@ export const EditSchema = zod export type MergeField = zod.infer; export type Soundtrack = zod.infer; +export type Destination = zod.infer; export type Edit = zod.infer; From e293fb68c317f5a5b550e5b77d3957c5cc5f42ed Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 21:28:58 +1100 Subject: [PATCH 141/463] refactor: extract keyboard event ignore logic into reusable method --- src/core/inputs/controls.ts | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/core/inputs/controls.ts b/src/core/inputs/controls.ts index 722d71bd..7575d8db 100644 --- a/src/core/inputs/controls.ts +++ b/src/core/inputs/controls.ts @@ -21,8 +21,26 @@ export class Controls { document.removeEventListener("keyup", this.handleKeyUp); } + private shouldIgnoreKeyboardEvent(event: KeyboardEvent): boolean { + const target = event.target as HTMLElement; + + if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { + return true; + } + + if (target.isContentEditable) { + return true; + } + + if (target.getAttribute?.("role") === "textbox") { + return true; + } + + return false; + } + private handleKeyDown = (event: KeyboardEvent): void => { - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + if (this.shouldIgnoreKeyboardEvent(event)) { return; } @@ -148,8 +166,7 @@ export class Controls { }; private handleKeyUp = (event: KeyboardEvent): void => { - // Skip if inside input elements - if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + if (this.shouldIgnoreKeyboardEvent(event)) { return; } From cd71945bdb96b43f846107bbc2bbc011b7cdaba9 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 17 Dec 2025 23:05:53 +1100 Subject: [PATCH 142/463] fix: prevent toolbar container duplication on remount --- src/core/ui/asset-toolbar.ts | 2 ++ src/core/ui/canvas-toolbar.ts | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/core/ui/asset-toolbar.ts b/src/core/ui/asset-toolbar.ts index 881a955e..84875a84 100644 --- a/src/core/ui/asset-toolbar.ts +++ b/src/core/ui/asset-toolbar.ts @@ -29,6 +29,8 @@ export class AssetToolbar { } mount(parent: HTMLElement): void { + this.container?.remove(); + this.container = document.createElement("div"); this.container.className = "ss-asset-toolbar"; diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts index e4a00ad4..c2dcef8b 100644 --- a/src/core/ui/canvas-toolbar.ts +++ b/src/core/ui/canvas-toolbar.ts @@ -120,6 +120,8 @@ export class CanvasToolbar { } mount(parent: HTMLElement): void { + this.container?.remove(); + this.container = document.createElement("div"); this.container.className = "ss-canvas-toolbar"; From a96b5e984ea82e1e058710e2c0464445a27623ee Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 00:17:57 +1100 Subject: [PATCH 143/463] feat: implement layered animation composition for effects and transitions --- src/components/canvas/players/player.ts | 147 ++++----- .../animations/composed-keyframe-builder.ts | 68 +++++ src/core/animations/effect-preset-builder.ts | 53 ++-- .../animations/transition-preset-builder.ts | 288 +++++------------- 4 files changed, 245 insertions(+), 311 deletions(-) create mode 100644 src/core/animations/composed-keyframe-builder.ts diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index c23dacdf..ed3ffac4 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -1,3 +1,4 @@ +import { ComposedKeyframeBuilder } from "@animations/composed-keyframe-builder"; import { EffectPresetBuilder } from "@animations/effect-preset-builder"; import { KeyframeBuilder } from "@animations/keyframe-builder"; import { TransitionPresetBuilder } from "@animations/transition-preset-builder"; @@ -110,12 +111,12 @@ export abstract class Player extends Entity { private resolvedTiming: ResolvedTiming; private positionBuilder: PositionBuilder; - private offsetXKeyframeBuilder?: KeyframeBuilder; - private offsetYKeyframeBuilder?: KeyframeBuilder; - private scaleKeyframeBuilder?: KeyframeBuilder; - private opacityKeyframeBuilder?: KeyframeBuilder; - private rotationKeyframeBuilder?: KeyframeBuilder; - private maskXKeyframeBuilder?: KeyframeBuilder; + private offsetXKeyframeBuilder?: ComposedKeyframeBuilder; + private offsetYKeyframeBuilder?: ComposedKeyframeBuilder; + private scaleKeyframeBuilder?: ComposedKeyframeBuilder; + private opacityKeyframeBuilder?: ComposedKeyframeBuilder; + private rotationKeyframeBuilder?: ComposedKeyframeBuilder; + private maskXKeyframeBuilder?: KeyframeBuilder; // maskX doesn't need composition private wipeMask: pixi.Graphics | null; @@ -207,70 +208,72 @@ export abstract class Player extends Entity { } protected configureKeyframes() { - this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset?.x ?? 0, this.getLength()); - this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset?.y ?? 0, this.getLength()); - this.scaleKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.scale ?? 1, this.getLength(), 1); - this.opacityKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.opacity ?? 1, this.getLength(), 1); - this.rotationKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.transform?.rotate?.angle ?? 0, this.getLength()); - + const length = this.getLength(); + const config = this.clipConfiguration; + + // Extract base values from clip configuration + const baseOffsetX = typeof config.offset?.x === "number" ? config.offset.x : 0; + const baseOffsetY = typeof config.offset?.y === "number" ? config.offset.y : 0; + const baseScale = typeof config.scale === "number" ? config.scale : 1; + const baseOpacity = typeof config.opacity === "number" ? config.opacity : 1; + const baseRotation = typeof config.transform?.rotate?.angle === "number" ? config.transform.rotate.angle : 0; + + // Create composed builders with base values + // Offsets use additive composition (base + effect delta + transition delta) + this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetX, length, "additive"); + this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(baseOffsetY, length, "additive"); + // Scale and opacity use multiplicative composition (base × effect factor × transition factor) + this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(baseScale, length, "multiplicative"); + this.opacityKeyframeBuilder = new ComposedKeyframeBuilder(baseOpacity, length, "multiplicative", { min: 0, max: 1 }); + // Rotation uses additive composition + this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(baseRotation, length, "additive"); + + // If user has custom keyframes, don't add effect/transition layers if (this.clipHasKeyframes()) { return; } - const offsetXKeyframes: Keyframe[] = []; - const offsetYKeyframes: Keyframe[] = []; - const opacityKeyframes: Keyframe[] = []; - const scaleKeyframes: Keyframe[] = []; - const rotationKeyframes: Keyframe[] = []; - const maskXKeyframes: Keyframe[] = []; - + // Build resolved clip config for preset builders const resolvedClipConfig: ResolvedClip = { - ...this.clipConfiguration, + ...config, start: this.getStart() / 1000, - length: this.getLength() / 1000 + length: length / 1000 }; - const effectKeyframeSet = new EffectPresetBuilder(resolvedClipConfig).build(this.edit.size, this.getSize()); - offsetXKeyframes.push(...effectKeyframeSet.offsetXKeyframes); - offsetYKeyframes.push(...effectKeyframeSet.offsetYKeyframes); - opacityKeyframes.push(...effectKeyframeSet.opacityKeyframes); - scaleKeyframes.push(...effectKeyframeSet.scaleKeyframes); - rotationKeyframes.push(...effectKeyframeSet.rotationKeyframes); - - const transitionKeyframeSet = new TransitionPresetBuilder(resolvedClipConfig).build(); - offsetXKeyframes.push(...transitionKeyframeSet.offsetXKeyframes); - offsetYKeyframes.push(...transitionKeyframeSet.offsetYKeyframes); - opacityKeyframes.push(...transitionKeyframeSet.opacityKeyframes); - scaleKeyframes.push(...transitionKeyframeSet.scaleKeyframes); - rotationKeyframes.push(...transitionKeyframeSet.rotationKeyframes); - maskXKeyframes.push(...transitionKeyframeSet.maskXKeyframes); - - if (offsetXKeyframes.length) { - const offsetX = this.clipConfiguration.offset?.x; - const initialOffsetX = typeof offsetX === "number" ? offsetX : 0; - this.offsetXKeyframeBuilder = new KeyframeBuilder(offsetXKeyframes, this.getLength(), initialOffsetX); - } - - if (offsetYKeyframes.length) { - const offsetY = this.clipConfiguration.offset?.y; - const initialOffsetY = typeof offsetY === "number" ? offsetY : 0; - this.offsetYKeyframeBuilder = new KeyframeBuilder(offsetYKeyframes, this.getLength(), initialOffsetY); - } - - if (opacityKeyframes.length) { - this.opacityKeyframeBuilder = new KeyframeBuilder(opacityKeyframes, this.getLength(), 1); - } - - if (scaleKeyframes.length) { - this.scaleKeyframeBuilder = new KeyframeBuilder(scaleKeyframes, this.getLength(), 1); - } - - if (rotationKeyframes.length) { - this.rotationKeyframeBuilder = new KeyframeBuilder(rotationKeyframes, this.getLength()); - } - + // Build relative effect keyframes (factors/deltas) + const effectSet = new EffectPresetBuilder(resolvedClipConfig).buildRelative(this.edit.size, this.getSize()); + + // Build relative transition keyframes (separate in/out sets) + const transitionSet = new TransitionPresetBuilder(resolvedClipConfig).buildRelative(); + + // Add effect layer (runs for full clip duration) + this.offsetXKeyframeBuilder.addLayer(effectSet.offsetXKeyframes); + this.offsetYKeyframeBuilder.addLayer(effectSet.offsetYKeyframes); + this.scaleKeyframeBuilder.addLayer(effectSet.scaleKeyframes); + this.opacityKeyframeBuilder.addLayer(effectSet.opacityKeyframes); + this.rotationKeyframeBuilder.addLayer(effectSet.rotationKeyframes); + + // Add transition-in layer (runs at clip start) + this.offsetXKeyframeBuilder.addLayer(transitionSet.in.offsetXKeyframes); + this.offsetYKeyframeBuilder.addLayer(transitionSet.in.offsetYKeyframes); + this.scaleKeyframeBuilder.addLayer(transitionSet.in.scaleKeyframes); + this.opacityKeyframeBuilder.addLayer(transitionSet.in.opacityKeyframes); + this.rotationKeyframeBuilder.addLayer(transitionSet.in.rotationKeyframes); + + // Add transition-out layer (runs at clip end) + this.offsetXKeyframeBuilder.addLayer(transitionSet.out.offsetXKeyframes); + this.offsetYKeyframeBuilder.addLayer(transitionSet.out.offsetYKeyframes); + this.scaleKeyframeBuilder.addLayer(transitionSet.out.scaleKeyframes); + this.opacityKeyframeBuilder.addLayer(transitionSet.out.opacityKeyframes); + this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes); + + // Mask keyframes (wipe/reveal effects) - still use KeyframeBuilder directly + const maskXKeyframes: Keyframe[] = [ + ...transitionSet.in.maskXKeyframes, + ...transitionSet.out.maskXKeyframes + ]; if (maskXKeyframes.length) { - this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, this.getLength()); + this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, length); } } @@ -596,8 +599,8 @@ export abstract class Player extends Entity { this.clipConfiguration.offset.x = relativePos.x; this.clipConfiguration.offset.y = relativePos.y; - this.offsetXKeyframeBuilder = new KeyframeBuilder(relativePos.x, this.getLength()); - this.offsetYKeyframeBuilder = new KeyframeBuilder(relativePos.y, this.getLength()); + this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(relativePos.x, this.getLength(), "additive"); + this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(relativePos.y, this.getLength(), "additive"); } protected getFitScale(): number { @@ -803,7 +806,7 @@ export abstract class Player extends Entity { this.clipConfiguration.width = width; this.clipConfiguration.height = height; delete this.clipConfiguration.scale; - this.scaleKeyframeBuilder = new KeyframeBuilder(1, this.getLength(), 1); + this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(1, this.getLength(), "multiplicative"); } } @@ -874,7 +877,7 @@ export abstract class Player extends Entity { this.clipConfiguration.width = width; this.clipConfiguration.height = height; delete this.clipConfiguration.scale; - this.scaleKeyframeBuilder = new KeyframeBuilder(1, this.getLength(), 1); + this.scaleKeyframeBuilder = new ComposedKeyframeBuilder(1, this.getLength(), "multiplicative"); } } @@ -963,8 +966,8 @@ export abstract class Player extends Entity { this.clipConfiguration.offset.y = newOffsetY; // Update keyframe builders - this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.x, this.getLength()); - this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.y, this.getLength()); + this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(this.clipConfiguration.offset.x, this.getLength(), "additive"); + this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(this.clipConfiguration.offset.y, this.getLength(), "additive"); // Notify subclass about dimension change this.onDimensionsChanged(); @@ -1024,8 +1027,8 @@ export abstract class Player extends Entity { this.clipConfiguration.offset.y = newOffsetY; // Update keyframe builders for position - this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.x, this.getLength()); - this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.y, this.getLength()); + this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(this.clipConfiguration.offset.x, this.getLength(), "additive"); + this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(this.clipConfiguration.offset.y, this.getLength(), "additive"); // Notify subclass about dimension change for re-rendering this.onDimensionsChanged(); @@ -1058,7 +1061,7 @@ export abstract class Player extends Entity { rotate: { angle: newRotation } }; - this.rotationKeyframeBuilder = new KeyframeBuilder(newRotation, this.getLength()); + this.rotationKeyframeBuilder = new ComposedKeyframeBuilder(newRotation, this.getLength(), "additive"); // Update cursor to follow the rotation if (this.rotationCorner) { @@ -1214,8 +1217,8 @@ export abstract class Player extends Entity { this.clipConfiguration.offset.x = updatedRelativePosition.x; this.clipConfiguration.offset.y = updatedRelativePosition.y; - this.offsetXKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.x, this.getLength()); - this.offsetYKeyframeBuilder = new KeyframeBuilder(this.clipConfiguration.offset.y, this.getLength()); + this.offsetXKeyframeBuilder = new ComposedKeyframeBuilder(this.clipConfiguration.offset.x, this.getLength(), "additive"); + this.offsetYKeyframeBuilder = new ComposedKeyframeBuilder(this.clipConfiguration.offset.y, this.getLength(), "additive"); return; } diff --git a/src/core/animations/composed-keyframe-builder.ts b/src/core/animations/composed-keyframe-builder.ts new file mode 100644 index 00000000..b558791c --- /dev/null +++ b/src/core/animations/composed-keyframe-builder.ts @@ -0,0 +1,68 @@ +import { type Keyframe } from "../schemas/keyframe"; + +import { KeyframeBuilder } from "./keyframe-builder"; + +type CompositionMode = "additive" | "multiplicative"; + +/** + * Composes multiple keyframe layers into a single value using additive or multiplicative blending. + * + * - **Additive mode**: `base + Σ(layer deltas)` - used for offset and rotation + * - **Multiplicative mode**: `base × Π(layer factors)` - used for scale and opacity + * + * This enables effects and transitions to run simultaneously without conflicts. + */ +export class ComposedKeyframeBuilder { + private readonly baseValue: number; + private readonly mode: CompositionMode; + private readonly layers: KeyframeBuilder[] = []; + private readonly length: number; + private readonly clampRange?: { min: number; max: number }; + + constructor(baseValue: number, length: number, mode: CompositionMode, clampRange?: { min: number; max: number }) { + this.baseValue = baseValue; + this.length = length; + this.mode = mode; + this.clampRange = clampRange; + } + + /** + * Add a keyframe layer to the composition. + * For additive mode, keyframes should represent deltas (e.g., 0 → 0.1 means "move by 0.1") + * For multiplicative mode, keyframes should represent factors (e.g., 1 → 1.3 means "scale by 1.3x") + */ + addLayer(keyframes: Keyframe[]): void { + if (keyframes.length === 0) return; + + const neutralValue = this.mode === "additive" ? 0 : 1; + this.layers.push(new KeyframeBuilder(keyframes, this.length, neutralValue)); + } + + /** + * Get the composed value at a specific time. + * Combines base value with all layer values using the composition mode. + */ + getValue(time: number): number { + if (this.layers.length === 0) { + return this.baseValue; + } + + if (this.mode === "additive") { + let result = this.baseValue; + for (const layer of this.layers) { + result += layer.getValue(time); + } + return result; + } else { + let result = this.baseValue; + for (const layer of this.layers) { + result *= layer.getValue(time); + } + // Clamp to range if specified (e.g., [0, 1] for opacity) + if (this.clampRange) { + result = Math.max(this.clampRange.min, Math.min(this.clampRange.max, result)); + } + return result; + } + } +} diff --git a/src/core/animations/effect-preset-builder.ts b/src/core/animations/effect-preset-builder.ts index cce56ac8..376670e3 100644 --- a/src/core/animations/effect-preset-builder.ts +++ b/src/core/animations/effect-preset-builder.ts @@ -10,6 +10,14 @@ export type EffectKeyframeSet = { rotationKeyframes: Keyframe[]; }; +/** + * Relative keyframe set for composition with other animation layers. + * - Offset keyframes are deltas (added to base position) + * - Scale keyframes are factors (multiplied with base scale) + * - Opacity keyframes are factors (multiplied with base opacity) + */ +export type RelativeEffectKeyframeSet = EffectKeyframeSet; + export class EffectPresetBuilder { private clipConfiguration: ResolvedClip; @@ -17,7 +25,13 @@ export class EffectPresetBuilder { this.clipConfiguration = clipConfiguration; } - public build(editSize: Size, clipSize: Size): EffectKeyframeSet { + /** + * Build keyframes with relative values for composition. + * - Scale values are factors (e.g., 1.0 → 1.3 means "multiply base by 1.0, then by 1.3") + * - Offset values are deltas (e.g., 0.12 → -0.12 means "add 0.12 to base, then add -0.12") + * These can be composed with transition keyframes without conflicts. + */ + public buildRelative(editSize: Size, clipSize: Size): RelativeEffectKeyframeSet { const offsetXKeyframes: Keyframe[] = []; const offsetYKeyframes: Keyframe[] = []; const opacityKeyframes: Keyframe[] = []; @@ -36,49 +50,35 @@ export class EffectPresetBuilder { switch (effectName) { case "zoomIn": { const zoomSpeed = this.getZoomSpeed(); - const rawScale = this.clipConfiguration.scale; - const scale = typeof rawScale === "number" ? rawScale : 1; - - const initialScale = 1 * scale; - const targetScale = zoomSpeed * scale; - - scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "linear" }); - + // Factor: starts at 1x, ends at zoomSpeed (e.g., 1.3x) + scaleKeyframes.push({ from: 1, to: zoomSpeed, start, length, interpolation: "linear" }); break; } case "zoomOut": { const zoomSpeed = this.getZoomSpeed(); - const rawScale = this.clipConfiguration.scale; - const scale = typeof rawScale === "number" ? rawScale : 1; - - const initialScale = zoomSpeed * scale; - const targetScale = 1 * scale; - - scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "linear" }); - + // Factor: starts at zoomSpeed (e.g., 1.3x), ends at 1x + scaleKeyframes.push({ from: zoomSpeed, to: 1, start, length, interpolation: "linear" }); break; } case "slideLeft": { const fittedSize = this.getFittedSize(editSize, clipSize); let targetOffsetX = this.getSlideStart(); - const minScaleWidth = editSize.width + editSize.width * targetOffsetX * 2; if (fittedSize.width < minScaleWidth) { const scaleFactorWidth = minScaleWidth / fittedSize.width; + // Scale factor (constant) to ensure content fills during slide scaleKeyframes.push({ from: scaleFactorWidth, to: scaleFactorWidth, start, length, interpolation: "linear" }); } else { targetOffsetX = (fittedSize.width - editSize.width) / 2 / editSize.width; } - + // Offset delta: slides from right (+) to left (-) offsetXKeyframes.push({ from: targetOffsetX, to: -targetOffsetX, start, length }); - break; } case "slideRight": { const fittedSize = this.getFittedSize(editSize, clipSize); let targetOffsetX = this.getSlideStart(); - const minScaleWidth = editSize.width + editSize.width * targetOffsetX * 2; if (fittedSize.width < minScaleWidth) { @@ -87,15 +87,13 @@ export class EffectPresetBuilder { } else { targetOffsetX = (fittedSize.width - editSize.width) / 2 / editSize.width; } - + // Offset delta: slides from left (-) to right (+) offsetXKeyframes.push({ from: -targetOffsetX, to: targetOffsetX, start, length }); - break; } case "slideUp": { const fittedSize = this.getFittedSize(editSize, clipSize); let targetOffsetY = this.getSlideStart(); - const minScaleHeight = editSize.height + editSize.height * targetOffsetY * 2; if (fittedSize.height < minScaleHeight) { @@ -104,15 +102,13 @@ export class EffectPresetBuilder { } else { targetOffsetY = (fittedSize.height - editSize.height) / 2 / editSize.height; } - + // Offset delta: slides from bottom (+) to top (-) offsetYKeyframes.push({ from: targetOffsetY, to: -targetOffsetY, start, length }); - break; } case "slideDown": { const fittedSize = this.getFittedSize(editSize, clipSize); let targetOffsetY = this.getSlideStart(); - const minScaleHeight = editSize.height + editSize.height * targetOffsetY * 2; if (fittedSize.height < minScaleHeight) { @@ -121,9 +117,8 @@ export class EffectPresetBuilder { } else { targetOffsetY = (fittedSize.height - editSize.height) / 2 / editSize.height; } - + // Offset delta: slides from top (-) to bottom (+) offsetYKeyframes.push({ from: -targetOffsetY, to: targetOffsetY, start, length }); - break; } default: diff --git a/src/core/animations/transition-preset-builder.ts b/src/core/animations/transition-preset-builder.ts index 54941597..840d5048 100644 --- a/src/core/animations/transition-preset-builder.ts +++ b/src/core/animations/transition-preset-builder.ts @@ -10,6 +10,18 @@ export type TransitionKeyframeSet = { maskXKeyframes: Keyframe[]; }; +/** + * Relative keyframe sets for composition with effects. + * Separates in/out transitions so they can be added as independent layers. + * - Offset keyframes are deltas (added to base position) + * - Scale keyframes are factors (multiplied with base scale) + * - Opacity keyframes are factors (multiplied with base opacity) + */ +export type RelativeTransitionKeyframeSet = { + in: TransitionKeyframeSet; + out: TransitionKeyframeSet; +}; + export class TransitionPresetBuilder { private clipConfiguration: ResolvedClip; @@ -17,35 +29,21 @@ export class TransitionPresetBuilder { this.clipConfiguration = clipConfiguration; } - public build(): TransitionKeyframeSet { - const offsetXKeyframes: Keyframe[] = []; - const offsetYKeyframes: Keyframe[] = []; - const opacityKeyframes: Keyframe[] = []; - const scaleKeyframes: Keyframe[] = []; - const rotationKeyframes: Keyframe[] = []; - const maskXKeyframes: Keyframe[] = []; - - const inPresetKeyframeSet = this.buildInPreset(); - offsetXKeyframes.push(...inPresetKeyframeSet.offsetXKeyframes); - offsetYKeyframes.push(...inPresetKeyframeSet.offsetYKeyframes); - opacityKeyframes.push(...inPresetKeyframeSet.opacityKeyframes); - scaleKeyframes.push(...inPresetKeyframeSet.scaleKeyframes); - rotationKeyframes.push(...inPresetKeyframeSet.rotationKeyframes); - maskXKeyframes.push(...inPresetKeyframeSet.maskXKeyframes); - - const outPresetKeyframeSet = this.buildOutPreset(); - - offsetXKeyframes.push(...outPresetKeyframeSet.offsetXKeyframes); - offsetYKeyframes.push(...outPresetKeyframeSet.offsetYKeyframes); - opacityKeyframes.push(...outPresetKeyframeSet.opacityKeyframes); - scaleKeyframes.push(...outPresetKeyframeSet.scaleKeyframes); - rotationKeyframes.push(...outPresetKeyframeSet.rotationKeyframes); - maskXKeyframes.push(...outPresetKeyframeSet.maskXKeyframes); - - return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes, maskXKeyframes }; + /** + * Build keyframes with relative values for composition. + * Returns separate in/out keyframe sets that can be added as independent layers. + * - Offset values are deltas (e.g., 0.025 → 0 means "start 0.025 right of base, end at base") + * - Scale values are factors (e.g., 10 → 1 means "start at 10x base, end at 1x base") + * - Opacity values are factors (e.g., 0 → 1 means "fade from invisible to full opacity") + */ + public buildRelative(): RelativeTransitionKeyframeSet { + return { + in: this.buildInPresetRelative(), + out: this.buildOutPresetRelative() + }; } - private buildInPreset(): TransitionKeyframeSet { + private buildInPresetRelative(): TransitionKeyframeSet { const offsetXKeyframes: Keyframe[] = []; const offsetYKeyframes: Keyframe[] = []; const opacityKeyframes: Keyframe[] = []; @@ -63,143 +61,78 @@ export class TransitionPresetBuilder { switch (transitionName) { case "fade": { - const initialOpacity = 0; - const targetOpacity = Math.max(0, Math.min((this.clipConfiguration.opacity as number) ?? 1, 1)); - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Opacity factor: 0 (invisible) → 1 (fully visible) + opacityKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "zoom": { - const zoomScaleDistance = 9; - const rawScale = this.clipConfiguration.scale; - const scale = typeof rawScale === "number" ? rawScale : 1; - - const initialScale = scale + zoomScaleDistance; - const targetScale = scale; - scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "bezier", easing: "easeIn" }); - - const initialOpacity = 0; - const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeIn" }); - + // Scale factor: 10x → 1x (zooms from very large to normal) + scaleKeyframes.push({ from: 10, to: 1, start, length, interpolation: "bezier", easing: "easeIn" }); + // Opacity factor: 0 → 1 + opacityKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "easeIn" }); break; } case "slideLeft": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX + 0.025; - const targetOffsetX = offsetX; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); - - const initialOpacity = 0; - const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: +0.025 → 0 (slides from right to center) + offsetXKeyframes.push({ from: 0.025, to: 0, start, length, interpolation: "linear" }); + opacityKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "slideRight": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX - 0.025; - const targetOffsetX = offsetX; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); - - const initialOpacity = 0; - const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: -0.025 → 0 (slides from left to center) + offsetXKeyframes.push({ from: -0.025, to: 0, start, length, interpolation: "linear" }); + opacityKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "slideUp": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY + 0.025; - const targetOffsetY = offsetY; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); - - const initialOpacity = 0; - const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: +0.025 → 0 (slides from bottom to center) + offsetYKeyframes.push({ from: 0.025, to: 0, start, length, interpolation: "linear" }); + opacityKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "slideDown": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY - 0.025; - const targetOffsetY = offsetY; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); - - const initialOpacity = 0; - const targetOpacity = 1; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: -0.025 → 0 (slides from top to center) + offsetYKeyframes.push({ from: -0.025, to: 0, start, length, interpolation: "linear" }); + opacityKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "carouselLeft": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX + 1; - const targetOffsetX = offsetX; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); - + // Offset delta: +1 → 0 (slides from far right to center) + offsetXKeyframes.push({ from: 1, to: 0, start, length, interpolation: "linear" }); break; } case "carouselRight": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX - 1; - const targetOffsetX = offsetX; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "linear" }); - + // Offset delta: -1 → 0 (slides from far left to center) + offsetXKeyframes.push({ from: -1, to: 0, start, length, interpolation: "linear" }); break; } case "carouselUp": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY - 1.05; - const targetOffsetY = offsetY; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); - + // Offset delta: -1.05 → 0 (slides from top to center) + offsetYKeyframes.push({ from: -1.05, to: 0, start, length, interpolation: "linear" }); break; } case "carouselDown": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY + 1.05; - const targetOffsetY = offsetY; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "linear" }); - + // Offset delta: +1.05 → 0 (slides from bottom to center) + offsetYKeyframes.push({ from: 1.05, to: 0, start, length, interpolation: "linear" }); break; } case "reveal": case "wipeRight": { - // Wipe/reveal left to right - mask progress 0 → 1 maskXKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "wipeLeft": { - // Wipe from right to left - mask progress 1 → 0 maskXKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); break; } - case "shuffleTopRight": - case "shuffleRightTop": - case "shuffleRightBottom": - case "shuffleBottomRight": - case "shuffleBottomLeft": - case "shuffleLeftBottom": - case "shuffleLeftTop": - case "shuffleTopLeft": default: - console.warn(`Unimplemented transition:in preset "${this.clipConfiguration.transition.in}"`); break; } return { offsetXKeyframes, offsetYKeyframes, opacityKeyframes, scaleKeyframes, rotationKeyframes, maskXKeyframes }; } - private buildOutPreset(): TransitionKeyframeSet { + private buildOutPresetRelative(): TransitionKeyframeSet { const offsetXKeyframes: Keyframe[] = []; const offsetYKeyframes: Keyframe[] = []; const opacityKeyframes: Keyframe[] = []; @@ -217,136 +150,71 @@ export class TransitionPresetBuilder { switch (transitionName) { case "fade": { - const initialOpacity = Math.max(0, Math.min((this.clipConfiguration.opacity as number) ?? 1, 1)); - const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Opacity factor: 1 (visible) → 0 (invisible) + opacityKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "zoom": { - const zoomScaleDistance = 9; - const rawScale = this.clipConfiguration.scale; - const scale = typeof rawScale === "number" ? rawScale : 1; - - const initialScale = scale; - const targetScale = scale + zoomScaleDistance; - scaleKeyframes.push({ from: initialScale, to: targetScale, start, length, interpolation: "bezier", easing: "easeOut" }); - - const initialOpacity = 1; - const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "easeOut" }); - + // Scale factor: 1x → 10x (zooms from normal to very large) + scaleKeyframes.push({ from: 1, to: 10, start, length, interpolation: "bezier", easing: "easeOut" }); + // Opacity factor: 1 → 0 + opacityKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "easeOut" }); break; } case "slideLeft": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX; - const targetOffsetX = offsetX - 0.025; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); - - const initialOpacity = 1; - const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → -0.025 (slides from center to left) + offsetXKeyframes.push({ from: 0, to: -0.025, start, length, interpolation: "bezier", easing: "smooth" }); + opacityKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "slideRight": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX; - const targetOffsetX = offsetX + 0.025; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); - - const initialOpacity = 1; - const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → +0.025 (slides from center to right) + offsetXKeyframes.push({ from: 0, to: 0.025, start, length, interpolation: "bezier", easing: "smooth" }); + opacityKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "slideUp": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY; - const targetOffsetY = offsetY - 0.025; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); - - const initialOpacity = 1; - const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → -0.025 (slides from center to top) + offsetYKeyframes.push({ from: 0, to: -0.025, start, length, interpolation: "bezier", easing: "smooth" }); + opacityKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "slideDown": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY; - const targetOffsetY = offsetY + 0.025; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); - - const initialOpacity = 1; - const targetOpacity = 0; - opacityKeyframes.push({ from: initialOpacity, to: targetOpacity, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → +0.025 (slides from center to bottom) + offsetYKeyframes.push({ from: 0, to: 0.025, start, length, interpolation: "bezier", easing: "smooth" }); + opacityKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "carouselLeft": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX; - const targetOffsetX = offsetX - 1; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → -1 (slides from center to far left) + offsetXKeyframes.push({ from: 0, to: -1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "carouselRight": { - const rawOffsetX = this.clipConfiguration.offset?.x; - const offsetX = typeof rawOffsetX === "number" ? rawOffsetX : 0; - const initialOffsetX = offsetX; - const targetOffsetX = offsetX + 1; - offsetXKeyframes.push({ from: initialOffsetX, to: targetOffsetX, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → +1 (slides from center to far right) + offsetXKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "carouselUp": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY; - const targetOffsetY = offsetY + 1.1; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → +1.1 (slides from center to bottom) + offsetYKeyframes.push({ from: 0, to: 1.1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "carouselDown": { - const rawOffsetY = this.clipConfiguration.offset?.y; - const offsetY = typeof rawOffsetY === "number" ? rawOffsetY : 0; - const initialOffsetY = offsetY; - const targetOffsetY = offsetY - 1.1; - offsetYKeyframes.push({ from: initialOffsetY, to: targetOffsetY, start, length, interpolation: "bezier", easing: "smooth" }); - + // Offset delta: 0 → -1.1 (slides from center to top) + offsetYKeyframes.push({ from: 0, to: -1.1, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "reveal": case "wipeRight": { - // Wipe/reveal out left to right - mask progress 1 → 0 maskXKeyframes.push({ from: 1, to: 0, start, length, interpolation: "bezier", easing: "smooth" }); break; } case "wipeLeft": { - // Wipe out right to left - mask progress 0 → 1 maskXKeyframes.push({ from: 0, to: 1, start, length, interpolation: "bezier", easing: "smooth" }); break; } - case "shuffleTopRight": - case "shuffleRightTop": - case "shuffleRightBottom": - case "shuffleBottomRight": - case "shuffleBottomLeft": - case "shuffleLeftBottom": - case "shuffleLeftTop": - case "shuffleTopLeft": default: - console.warn(`Unimplemented transition:out preset "${this.clipConfiguration.transition.out}"`); break; } From c3f17f2c86d2c540ba2f5868dbfe3da4123e0cd8 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 01:12:08 +1100 Subject: [PATCH 144/463] style: format mock implementations and test assertions --- src/components/canvas/players/player.ts | 5 +- tests/edit-clip-operations.test.ts | 58 +++++++++------------ tests/edit-merge-fields.test.ts | 38 ++++---------- tests/edit-timing.test.ts | 67 ++++++------------------- tests/luma-mask-controller.test.ts | 8 +-- tests/serialize-edit.test.ts | 59 +++------------------- 6 files changed, 57 insertions(+), 178 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index ed3ffac4..708d707d 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -268,10 +268,7 @@ export abstract class Player extends Entity { this.rotationKeyframeBuilder.addLayer(transitionSet.out.rotationKeyframes); // Mask keyframes (wipe/reveal effects) - still use KeyframeBuilder directly - const maskXKeyframes: Keyframe[] = [ - ...transitionSet.in.maskXKeyframes, - ...transitionSet.out.maskXKeyframes - ]; + const maskXKeyframes: Keyframe[] = [...transitionSet.in.maskXKeyframes, ...transitionSet.out.maskXKeyframes]; if (maskXKeyframes.length) { this.maskXKeyframeBuilder = new KeyframeBuilder(maskXKeyframes, length); } diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index e0fb4488..ec50ac33 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -185,60 +185,42 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => // Mock all player types jest.mock("@canvas/players/video-player", () => ({ - VideoPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Video) - ) + VideoPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Video)) })); jest.mock("@canvas/players/image-player", () => ({ - ImagePlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Image) - ) + ImagePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Image)) })); jest.mock("@canvas/players/text-player", () => ({ TextPlayer: Object.assign( - jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Text) - ), + jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Text)), { resetFontCache: jest.fn() } ) })); jest.mock("@canvas/players/audio-player", () => ({ - AudioPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Audio) - ) + AudioPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Audio)) })); jest.mock("@canvas/players/luma-player", () => ({ - LumaPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Luma) - ) + LumaPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Luma)) })); jest.mock("@canvas/players/shape-player", () => ({ - ShapePlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Shape) - ) + ShapePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Shape)) })); jest.mock("@canvas/players/html-player", () => ({ - HtmlPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Html) - ) + HtmlPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Html)) })); jest.mock("@canvas/players/rich-text-player", () => ({ - RichTextPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.RichText) - ) + RichTextPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.RichText)) })); jest.mock("@canvas/players/caption-player", () => ({ - CaptionPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Caption) - ) + CaptionPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Caption)) })); /** @@ -457,10 +439,13 @@ describe("Edit Clip Operations", () => { opacity: 0.5 }); - expect(emitSpy).toHaveBeenCalledWith("clip:updated", expect.objectContaining({ - previous: expect.anything(), - current: expect.anything() - })); + expect(emitSpy).toHaveBeenCalledWith( + "clip:updated", + expect.objectContaining({ + previous: expect.anything(), + current: expect.anything() + }) + ); }); it("is undoable - restores original config on undo", () => { @@ -635,10 +620,13 @@ describe("Edit Clip Operations", () => { edit.copyClip(0, 0); - expect(emitSpy).toHaveBeenCalledWith("clip:copied", expect.objectContaining({ - trackIndex: 0, - clipIndex: 0 - })); + expect(emitSpy).toHaveBeenCalledWith( + "clip:copied", + expect.objectContaining({ + trackIndex: 0, + clipIndex: 0 + }) + ); }); it("pasteClip adds clip at playhead position", () => { diff --git a/tests/edit-merge-fields.test.ts b/tests/edit-merge-fields.test.ts index cfa16a22..955762fe 100644 --- a/tests/edit-merge-fields.test.ts +++ b/tests/edit-merge-fields.test.ts @@ -188,60 +188,42 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => // Mock all player types jest.mock("@canvas/players/video-player", () => ({ - VideoPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Video) - ) + VideoPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Video)) })); jest.mock("@canvas/players/image-player", () => ({ - ImagePlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Image) - ) + ImagePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Image)) })); jest.mock("@canvas/players/text-player", () => ({ TextPlayer: Object.assign( - jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Text) - ), + jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Text)), { resetFontCache: jest.fn() } ) })); jest.mock("@canvas/players/audio-player", () => ({ - AudioPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Audio) - ) + AudioPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Audio)) })); jest.mock("@canvas/players/luma-player", () => ({ - LumaPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Luma) - ) + LumaPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Luma)) })); jest.mock("@canvas/players/shape-player", () => ({ - ShapePlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Shape) - ) + ShapePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Shape)) })); jest.mock("@canvas/players/html-player", () => ({ - HtmlPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Html) - ) + HtmlPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Html)) })); jest.mock("@canvas/players/rich-text-player", () => ({ - RichTextPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.RichText) - ) + RichTextPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.RichText)) })); jest.mock("@canvas/players/caption-player", () => ({ - CaptionPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Caption) - ) + CaptionPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Caption)) })); /** @@ -307,7 +289,7 @@ describe("Edit Merge Fields", () => { timeline: { tracks: [ { clips: [] }, // Track 0 - { clips: [] } // Track 1 + { clips: [] } // Track 1 ] }, output: { diff --git a/tests/edit-timing.test.ts b/tests/edit-timing.test.ts index 7d4947d8..d81e833f 100644 --- a/tests/edit-timing.test.ts +++ b/tests/edit-timing.test.ts @@ -9,12 +9,7 @@ import { Edit } from "@core/edit"; import { PlayerType } from "@canvas/players/player"; import type { EventEmitter } from "@core/events/event-emitter"; import type { ResolvedClip } from "@schemas/clip"; -import { - resolveAutoStart, - resolveAutoLength, - resolveEndLength, - calculateTimelineEnd -} from "@core/timing/resolver"; +import { resolveAutoStart, resolveAutoLength, resolveEndLength, calculateTimelineEnd } from "@core/timing/resolver"; // Mock probeMediaDuration since document.createElement doesn't work in Node jest.mock("@core/timing/resolver", () => ({ @@ -154,11 +149,7 @@ const createMockPlayerContainer = () => { }; // Mock player factory with timing intent support -const createMockPlayer = ( - edit: Edit, - config: ResolvedClip, - type: PlayerType -) => { +const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => { const container = createMockPlayerContainer(); const contentContainer = createMockPlayerContainer(); @@ -206,54 +197,38 @@ const createMockPlayer = ( // Mock all player types jest.mock("@canvas/players/video-player", () => ({ - VideoPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Video) - ) + VideoPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Video)) })); jest.mock("@canvas/players/image-player", () => ({ - ImagePlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Image) - ) + ImagePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Image)) })); jest.mock("@canvas/players/text-player", () => ({ TextPlayer: Object.assign( - jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Text) - ), + jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Text)), { resetFontCache: jest.fn() } ) })); jest.mock("@canvas/players/audio-player", () => ({ - AudioPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Audio) - ) + AudioPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Audio)) })); jest.mock("@canvas/players/html-player", () => ({ - HtmlPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Html) - ) + HtmlPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Html)) })); jest.mock("@canvas/players/luma-player", () => ({ - LumaPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Luma) - ) + LumaPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Luma)) })); jest.mock("@canvas/players/shape-player", () => ({ - ShapePlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Shape) - ) + ShapePlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Shape)) })); jest.mock("@canvas/players/caption-player", () => ({ - CaptionPlayer: jest.fn().mockImplementation((edit, config) => - createMockPlayer(edit, config, PlayerType.Caption) - ) + CaptionPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Caption)) })); /** @@ -330,12 +305,7 @@ describe("Timing Resolver Functions", () => { }); it("returns previous clip end for subsequent clips", () => { - const tracks = [ - [ - createMockPlayerForResolver(0, 5000), - createMockPlayerForResolver(5000, 3000) - ] - ]; + const tracks = [[createMockPlayerForResolver(0, 5000), createMockPlayerForResolver(5000, 3000)]]; const result = resolveAutoStart(0, 1, tracks as never); @@ -357,10 +327,7 @@ describe("Timing Resolver Functions", () => { }); it("works independently across tracks", () => { - const tracks = [ - [createMockPlayerForResolver(0, 10000)], - [createMockPlayerForResolver(0, 3000)] - ]; + const tracks = [[createMockPlayerForResolver(0, 10000)], [createMockPlayerForResolver(0, 3000)]]; const resultTrack0 = resolveAutoStart(0, 0, tracks as never); const resultTrack1 = resolveAutoStart(1, 0, tracks as never); @@ -416,11 +383,7 @@ describe("Timing Resolver Functions", () => { describe("calculateTimelineEnd()", () => { it("returns max end time of all clips", () => { - const tracks = [ - [createMockPlayerForResolver(0, 5000)], - [createMockPlayerForResolver(0, 8000)], - [createMockPlayerForResolver(2000, 3000)] - ]; + const tracks = [[createMockPlayerForResolver(0, 5000)], [createMockPlayerForResolver(0, 8000)], [createMockPlayerForResolver(2000, 3000)]]; const result = calculateTimelineEnd(tracks as never); @@ -447,9 +410,7 @@ describe("Timing Resolver Functions", () => { }); it("returns 0 when all clips have length: 'end'", () => { - const tracks = [ - [createMockPlayerForResolver(0, 10000, "end")] - ]; + const tracks = [[createMockPlayerForResolver(0, 10000, "end")]]; const result = calculateTimelineEnd(tracks as never); diff --git a/tests/luma-mask-controller.test.ts b/tests/luma-mask-controller.test.ts index 71ed573e..c4021441 100644 --- a/tests/luma-mask-controller.test.ts +++ b/tests/luma-mask-controller.test.ts @@ -540,9 +540,7 @@ describe("LumaMaskController", () => { videoTime = 0.05; controller.update(); - expect((canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length).toBeGreaterThan( - initialCalls - ); + expect((canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length).toBeGreaterThan(initialCalls); }); it("does NOT update when frame has not changed enough", () => { @@ -571,9 +569,7 @@ describe("LumaMaskController", () => { videoTime = 0.06; controller.update(); - expect((canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length).toBe( - callsAfterFirstUpdate - ); + expect((canvas.application.renderer.generateTexture as jest.Mock).mock.calls.length).toBe(callsAfterFirstUpdate); }); }); diff --git a/tests/serialize-edit.test.ts b/tests/serialize-edit.test.ts index b0fd8c23..1272be11 100644 --- a/tests/serialize-edit.test.ts +++ b/tests/serialize-edit.test.ts @@ -138,14 +138,7 @@ describe("serializeEditForExport", () => { ] ]; - const result = serializeEditForExport( - clips, - null, - "#000000", - [], - { size: { width: 1920, height: 1080 }, format: "mp4" }, - [] - ); + const result = serializeEditForExport(clips, null, "#000000", [], { size: { width: 1920, height: 1080 }, format: "mp4" }, []); expect(() => EditSchema.parse(result)).not.toThrow(); }); @@ -156,14 +149,7 @@ describe("serializeEditForExport", () => { { find: "TITLE", replace: "Welcome" } ]; - const result = serializeEditForExport( - [], - null, - "#000000", - [], - { size: { width: 1920, height: 1080 }, format: "mp4" }, - mergeFields - ); + const result = serializeEditForExport([], null, "#000000", [], { size: { width: 1920, height: 1080 }, format: "mp4" }, mergeFields); expect(result.merge).toEqual(mergeFields); }); @@ -183,45 +169,21 @@ describe("serializeEditForExport", () => { ] ]; - const result = serializeEditForExport( - clips, - null, - "#ffffff", - [], - { size: { width: 1280, height: 720 }, format: "mp4" }, - [] - ); + const result = serializeEditForExport(clips, null, "#ffffff", [], { size: { width: 1280, height: 720 }, format: "mp4" }, []); expect(result.timeline.tracks[0].clips[0].start).toBe("auto"); }); it("includes fonts in output", () => { - const fonts = [ - { src: "https://fonts.example.com/open-sans.ttf" }, - { src: "https://fonts.example.com/roboto.ttf" } - ]; + const fonts = [{ src: "https://fonts.example.com/open-sans.ttf" }, { src: "https://fonts.example.com/roboto.ttf" }]; - const result = serializeEditForExport( - [], - null, - "#000000", - fonts, - { size: { width: 1920, height: 1080 }, format: "mp4" }, - [] - ); + const result = serializeEditForExport([], null, "#000000", fonts, { size: { width: 1920, height: 1080 }, format: "mp4" }, []); expect(result.timeline.fonts).toEqual(fonts); }); it("includes background color in output", () => { - const result = serializeEditForExport( - [], - null, - "#ff5500", - [], - { size: { width: 1920, height: 1080 }, format: "mp4" }, - [] - ); + const result = serializeEditForExport([], null, "#ff5500", [], { size: { width: 1920, height: 1080 }, format: "mp4" }, []); expect(result.timeline.background).toBe("#ff5500"); }); @@ -264,14 +226,7 @@ describe("serializeEditForExport", () => { ] ]; - const result = serializeEditForExport( - clips, - null, - "#000", - [], - { size: { width: 1920, height: 1080 }, format: "mp4" }, - [] - ); + const result = serializeEditForExport(clips, null, "#000", [], { size: { width: 1920, height: 1080 }, format: "mp4" }, []); expect(result.timeline.tracks.length).toBe(2); expect(result.timeline.tracks[0].clips.length).toBe(1); From cde857bf19995cb3dd59d78f38cd3f15bd6b5960 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 01:12:28 +1100 Subject: [PATCH 145/463] feat: add smart loadEdit with unified events and structural diffing --- src/core/edit.ts | 248 ++++++++++++++++++++++++--------- src/core/ui/loading-overlay.ts | 22 --- 2 files changed, 186 insertions(+), 84 deletions(-) delete mode 100644 src/core/ui/loading-overlay.ts diff --git a/src/core/edit.ts b/src/core/edit.ts index 263ccd48..619a3592 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -27,7 +27,6 @@ import { Entity } from "@core/shared/entity"; import { serializeEditForExport, type ClipExportData } from "@core/shared/serialize-edit"; import { deepMerge, getNestedValue, setNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; -import { LoadingOverlay } from "@core/ui/loading-overlay"; import type { ToolbarButtonConfig } from "@core/ui/toolbar-button.types"; import type { Size } from "@layouts/geometry"; import { AssetLoader } from "@loaders/asset-loader"; @@ -90,6 +89,7 @@ export class Edit extends Entity { // Performance optimization: cache timeline end and track "end" length clips private cachedTimelineEnd: number = 0; private endLengthClips: Set = new Set(); + private isBatchingEvents: boolean = false; // Playback health tracking private syncCorrectionCount: number = 0; @@ -239,79 +239,78 @@ export class Edit extends Entity { } public async loadEdit(edit: ResolvedEdit): Promise { - const loading = new LoadingOverlay(); - loading.show(); - - const onProgress = () => loading.update(this.assetLoader.getProgress()); - this.assetLoader.loadTracker.on("onAssetLoadInfoUpdated", onProgress); + // Smart diff: only do full reload when structure changes (track/clip count, asset type) + if (this.edit && !this.hasStructuralChanges(edit)) { + this.isBatchingEvents = true; + this.applyGranularChanges(edit); + this.isBatchingEvents = false; + this.emitEditChanged("loadEdit:granular"); + return; + } - try { - this.clearClips(); + this.clearClips(); - // Store original (unresolved) edit for re-resolution on merge field changes - this.originalEdit = structuredClone(edit); + // Store original (unresolved) edit for re-resolution on merge field changes + this.originalEdit = structuredClone(edit); - // Load merge fields from edit payload into service - const serializedMergeFields = edit.merge ?? []; - this.mergeFields.loadFromSerialized(serializedMergeFields); + // Load merge fields from edit payload into service + const serializedMergeFields = edit.merge ?? []; + this.mergeFields.loadFromSerialized(serializedMergeFields); - // Apply merge field substitutions for initial load - const mergedEdit = serializedMergeFields.length > 0 ? applyMergeFields(edit, serializedMergeFields) : edit; + // Apply merge field substitutions for initial load + const mergedEdit = serializedMergeFields.length > 0 ? applyMergeFields(edit, serializedMergeFields) : edit; - const parsedEdit = EditSchema.parse(mergedEdit); - resolveAliasReferences(parsedEdit); - this.edit = parsedEdit as ResolvedEdit; + const parsedEdit = EditSchema.parse(mergedEdit); + resolveAliasReferences(parsedEdit); + this.edit = parsedEdit as ResolvedEdit; - const newSize = this.edit.output?.size; - if (newSize && (newSize.width !== this.size.width || newSize.height !== this.size.height)) { - this.size = newSize; - this.updateViewportMask(); - this.canvas?.zoomToFit(); - } + const newSize = this.edit.output?.size; + if (newSize && (newSize.width !== this.size.width || newSize.height !== this.size.height)) { + this.size = newSize; + this.updateViewportMask(); + this.canvas?.zoomToFit(); + } - this.backgroundColor = this.edit.timeline.background || "#000000"; + this.backgroundColor = this.edit.timeline.background || "#000000"; - if (this.background) { - this.background.clear(); - this.background.fillStyle = { - color: this.backgroundColor - }; - this.background.rect(0, 0, this.size.width, this.size.height); - this.background.fill(); - } + if (this.background) { + this.background.clear(); + this.background.fillStyle = { + color: this.backgroundColor + }; + this.background.rect(0, 0, this.size.width, this.size.height); + this.background.fill(); + } - await Promise.all( - (this.edit.timeline.fonts ?? []).map(async font => { - const identifier = font.src; - const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: FontLoadParser.Name }; + await Promise.all( + (this.edit.timeline.fonts ?? []).map(async font => { + const identifier = font.src; + const loadOptions: pixi.UnresolvedAsset = { src: identifier, parser: FontLoadParser.Name }; - return this.assetLoader.load(identifier, loadOptions); - }) - ); + return this.assetLoader.load(identifier, loadOptions); + }) + ); - for (const [trackIdx, track] of this.edit.timeline.tracks.entries()) { - for (const clip of track.clips) { - const clipPlayer = this.createPlayerFromAssetType(clip); - clipPlayer.layer = trackIdx + 1; - await this.addPlayer(trackIdx, clipPlayer); - } + for (const [trackIdx, track] of this.edit.timeline.tracks.entries()) { + for (const clip of track.clips) { + const clipPlayer = this.createPlayerFromAssetType(clip); + clipPlayer.layer = trackIdx + 1; + await this.addPlayer(trackIdx, clipPlayer); } + } - this.lumaMaskController.initialize(); - - await this.resolveAllTiming(); + this.lumaMaskController.initialize(); - this.updateTotalDuration(); + await this.resolveAllTiming(); - if (this.edit.timeline.soundtrack) { - await this.loadSoundtrack(this.edit.timeline.soundtrack); - } + this.updateTotalDuration(); - this.events.emit("timeline:updated", { current: this.getResolvedEdit() }); - } finally { - this.assetLoader.loadTracker.off("onAssetLoadInfoUpdated", onProgress); - loading.hide(); + if (this.edit.timeline.soundtrack) { + await this.loadSoundtrack(this.edit.timeline.soundtrack); } + + this.events.emit("timeline:updated", { current: this.getResolvedEdit() }); + this.emitEditChanged("loadEdit"); } private async loadSoundtrack(soundtrack: Soundtrack): Promise { @@ -351,6 +350,27 @@ export class Edit extends Entity { ); } + /** + * Validates an edit configuration without applying it. + * Use this to pre-validate user input before calling loadEdit(). + * + * @param edit - The edit configuration to validate + * @returns Validation result with valid boolean and any errors + */ + public validateEdit(edit: unknown): { valid: boolean; errors: Array<{ path: string; message: string }> } { + const result = EditSchema.safeParse(edit); + if (result.success) { + return { valid: true, errors: [] }; + } + return { + valid: false, + errors: result.error.issues.map(issue => ({ + path: issue.path.join("."), + message: issue.message + })) + }; + } + public getResolvedEdit(): ResolvedEdit { const tracks: ResolvedTrack[] = this.tracks.map(track => ({ clips: track @@ -725,6 +745,7 @@ export class Edit extends Entity { command.undo(context); this.commandIndex -= 1; this.events.emit("edit:undo", { command: command.name }); + this.emitEditChanged(`undo:${command.name}`); } } } @@ -736,6 +757,7 @@ export class Edit extends Entity { const context = this.createCommandContext(); command.execute(context); this.events.emit("edit:redo", { command: command.name }); + this.emitEditChanged(`redo:${command.name}`); } } /** @internal */ @@ -834,9 +856,111 @@ export class Edit extends Entity { this.commandHistory = this.commandHistory.slice(0, this.commandIndex + 1); this.commandHistory.push(command); this.commandIndex += 1; + + // Handle both sync and async commands + if (result instanceof Promise) { + return result.then(() => this.emitEditChanged(command.name)); + } + this.emitEditChanged(command.name); return result; } + /** + * Emits a unified `edit:changed` event after any state mutation. + * Consumers can subscribe to this single event instead of tracking 31+ granular events. + */ + private emitEditChanged(source: string): void { + if (this.isBatchingEvents) return; + this.events.emit("edit:changed", { source, timestamp: Date.now() }); + } + + /** + * Checks if edit has structural changes requiring full reload. + * Structural = track count, clip count, or asset type changed. + */ + private hasStructuralChanges(newEdit: ResolvedEdit): boolean { + if (!this.edit || !this.originalEdit) return true; + + const currentTracks = this.originalEdit.timeline.tracks; + const newTracks = newEdit.timeline.tracks; + + // Different track count = structural + if (currentTracks.length !== newTracks.length) return true; + + // Check each track + for (let t = 0; t < currentTracks.length; t++) { + // Different clip count = structural + if (currentTracks[t].clips.length !== newTracks[t].clips.length) return true; + + // Asset TYPE change = structural (ImagePlayer vs VideoPlayer) + for (let c = 0; c < currentTracks[t].clips.length; c++) { + const currentType = (currentTracks[t].clips[c]?.asset as { type?: string })?.type; + const newType = (newTracks[t].clips[c]?.asset as { type?: string })?.type; + if (currentType !== newType) return true; + } + } + + // Merge fields changed = structural (affects asset resolution) + if (JSON.stringify(this.originalEdit.merge ?? []) !== JSON.stringify(newEdit.merge ?? [])) { + return true; + } + + return false; + } + + /** + * Applies granular changes without full reload (preserves undo history, no flash). + * Only called when structure is unchanged (same track/clip counts). + */ + private applyGranularChanges(newEdit: ResolvedEdit): void { + const currentOutput = this.edit?.output; + const newOutput = newEdit.output; + + // 1. Apply output changes + if (newOutput?.size && (currentOutput?.size?.width !== newOutput.size.width || currentOutput?.size?.height !== newOutput.size.height)) { + this.setOutputSize(newOutput.size.width, newOutput.size.height); + } + + if (newOutput?.fps !== undefined && currentOutput?.fps !== newOutput.fps) { + this.setOutputFps(newOutput.fps); + } + + if (newOutput?.format !== undefined && currentOutput?.format !== newOutput.format) { + this.setOutputFormat(newOutput.format); + } + + if (newOutput?.destinations && JSON.stringify(currentOutput?.destinations) !== JSON.stringify(newOutput.destinations)) { + this.setOutputDestinations(newOutput.destinations); + } + + const newBg = newEdit.timeline?.background; + if (newBg && this.backgroundColor !== newBg) { + this.setTimelineBackground(newBg); + } + + // 2. Diff and update each clip + const currentTracks = this.originalEdit!.timeline.tracks; + const newTracks = newEdit.timeline.tracks; + + for (let trackIdx = 0; trackIdx < newTracks.length; trackIdx++) { + const currentClips = currentTracks[trackIdx].clips; + const newClips = newTracks[trackIdx].clips; + + for (let clipIdx = 0; clipIdx < newClips.length; clipIdx++) { + const currentClip = currentClips[clipIdx]; + const newClip = newClips[clipIdx]; + + // Only update if clip changed + if (JSON.stringify(currentClip) !== JSON.stringify(newClip)) { + this.updateClip(trackIdx, clipIdx, newClip); + } + } + } + + // 3. Update originalEdit to reflect new state + this.originalEdit = structuredClone(newEdit); + } + private createCommandContext(): CommandContext { return { getClips: () => this.clips, @@ -1222,11 +1346,6 @@ export class Edit extends Entity { this.tracks[trackIdx].push(clipToAdd); - // Sync originalEdit with new clip to keep template data aligned with tracks array - if (this.originalEdit?.timeline.tracks[trackIdx]) { - this.originalEdit.timeline.tracks[trackIdx].clips.push(structuredClone(clipToAdd.clipConfiguration)); - } - this.clips.push(clipToAdd); if (clipToAdd.getTimingIntent().length === "end") { @@ -1429,6 +1548,7 @@ export class Edit extends Entity { } this.events.emit("output:size:changed", result.data); + this.emitEditChanged("output:size"); } public setOutputFps(fps: number): void { @@ -1445,6 +1565,7 @@ export class Edit extends Entity { } this.events.emit("output:fps:changed", { fps }); + this.emitEditChanged("output:fps"); } public getOutputFps(): number { @@ -1465,6 +1586,7 @@ export class Edit extends Entity { } this.events.emit("output:format:changed", { format: result.data }); + this.emitEditChanged("output:format"); } public getOutputFormat(): string { @@ -1485,6 +1607,7 @@ export class Edit extends Entity { } this.events.emit("output:destinations:changed", { destinations: result.data }); + this.emitEditChanged("output:destinations"); } public getOutputDestinations(): Destination[] { @@ -1518,6 +1641,7 @@ export class Edit extends Entity { } this.events.emit("timeline:background:changed", { color: result.data }); + this.emitEditChanged("timeline:background"); } public getTimelineBackground(): string { diff --git a/src/core/ui/loading-overlay.ts b/src/core/ui/loading-overlay.ts deleted file mode 100644 index 6b39a3dc..00000000 --- a/src/core/ui/loading-overlay.ts +++ /dev/null @@ -1,22 +0,0 @@ -export class LoadingOverlay { - private overlay: HTMLElement | null = null; - - show(): void { - this.overlay = document.createElement("div"); - this.overlay.style.cssText = "position:fixed;inset:0;z-index:9999;background:#0a0a0a;display:flex;justify-content:center;align-items:center"; - this.overlay.innerHTML = ` -
- - `; - document.body.appendChild(this.overlay); - } - - update(_progress: number): void { - // No-op for spinner - } - - hide(): void { - this.overlay?.remove(); - this.overlay = null; - } -} From 3301618be51bfb29f5dd1a89cf60e2e6787a74fc Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 01:21:08 +1100 Subject: [PATCH 146/463] test: remove LoadingOverlay mocks after deletion --- tests/edit-clip-operations.test.ts | 9 --------- tests/edit-commands.test.ts | 9 --------- tests/edit-load.test.ts | 19 ------------------- tests/edit-merge-fields.test.ts | 9 --------- tests/edit-playback.test.ts | 9 --------- tests/edit-timing.test.ts | 9 --------- 6 files changed, 64 deletions(-) diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index ec50ac33..43ec2b44 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -105,15 +105,6 @@ jest.mock("@core/luma-mask-controller", () => ({ })) })); -// Mock LoadingOverlay -jest.mock("@core/ui/loading-overlay", () => ({ - LoadingOverlay: jest.fn().mockImplementation(() => ({ - show: jest.fn(), - hide: jest.fn(), - update: jest.fn() - })) -})); - // Mock AlignmentGuides jest.mock("@canvas/system/alignment-guides", () => ({ AlignmentGuides: jest.fn().mockImplementation(() => ({ diff --git a/tests/edit-commands.test.ts b/tests/edit-commands.test.ts index bc6357b7..ede3f480 100644 --- a/tests/edit-commands.test.ts +++ b/tests/edit-commands.test.ts @@ -96,15 +96,6 @@ jest.mock("@core/luma-mask-controller", () => ({ })) })); -// Mock LoadingOverlay -jest.mock("@core/ui/loading-overlay", () => ({ - LoadingOverlay: jest.fn().mockImplementation(() => ({ - show: jest.fn(), - hide: jest.fn(), - update: jest.fn() - })) -})); - // Mock TextPlayer font cache jest.mock("@canvas/players/text-player", () => ({ TextPlayer: { diff --git a/tests/edit-load.test.ts b/tests/edit-load.test.ts index 65ba70a5..82011eda 100644 --- a/tests/edit-load.test.ts +++ b/tests/edit-load.test.ts @@ -129,17 +129,6 @@ jest.mock("@core/luma-mask-controller", () => ({ LumaMaskController: jest.fn().mockImplementation(() => mockLumaMaskController) })); -// Mock LoadingOverlay -const mockLoadingOverlay = { - show: jest.fn(), - hide: jest.fn(), - update: jest.fn() -}; - -jest.mock("@core/ui/loading-overlay", () => ({ - LoadingOverlay: jest.fn().mockImplementation(() => mockLoadingOverlay) -})); - // Mock AlignmentGuides jest.mock("@canvas/system/alignment-guides", () => ({ AlignmentGuides: jest.fn().mockImplementation(() => ({ @@ -800,14 +789,6 @@ describe("Edit loadEdit()", () => { expect(getEditState(edit).tracks[0].length).toBe(2); }); - it("shows and hides loading overlay", async () => { - const editConfig = createMinimalEdit([]); - - await edit.loadEdit(editConfig); - - expect(mockLoadingOverlay.show).toHaveBeenCalled(); - expect(mockLoadingOverlay.hide).toHaveBeenCalled(); - }); }); describe("soundtrack", () => { diff --git a/tests/edit-merge-fields.test.ts b/tests/edit-merge-fields.test.ts index 955762fe..9f903ff1 100644 --- a/tests/edit-merge-fields.test.ts +++ b/tests/edit-merge-fields.test.ts @@ -108,15 +108,6 @@ jest.mock("@core/luma-mask-controller", () => ({ })) })); -// Mock LoadingOverlay -jest.mock("@core/ui/loading-overlay", () => ({ - LoadingOverlay: jest.fn().mockImplementation(() => ({ - show: jest.fn(), - hide: jest.fn(), - update: jest.fn() - })) -})); - // Mock AlignmentGuides jest.mock("@canvas/system/alignment-guides", () => ({ AlignmentGuides: jest.fn().mockImplementation(() => ({ diff --git a/tests/edit-playback.test.ts b/tests/edit-playback.test.ts index d1306862..f27cb103 100644 --- a/tests/edit-playback.test.ts +++ b/tests/edit-playback.test.ts @@ -95,15 +95,6 @@ jest.mock("@core/luma-mask-controller", () => ({ })) })); -// Mock LoadingOverlay -jest.mock("@core/ui/loading-overlay", () => ({ - LoadingOverlay: jest.fn().mockImplementation(() => ({ - show: jest.fn(), - hide: jest.fn(), - update: jest.fn() - })) -})); - // Mock TextPlayer font cache jest.mock("@canvas/players/text-player", () => ({ TextPlayer: { diff --git a/tests/edit-timing.test.ts b/tests/edit-timing.test.ts index d81e833f..8ab7d6c4 100644 --- a/tests/edit-timing.test.ts +++ b/tests/edit-timing.test.ts @@ -112,15 +112,6 @@ jest.mock("@core/luma-mask-controller", () => ({ })) })); -// Mock LoadingOverlay -jest.mock("@core/ui/loading-overlay", () => ({ - LoadingOverlay: jest.fn().mockImplementation(() => ({ - show: jest.fn(), - hide: jest.fn(), - update: jest.fn() - })) -})); - // Mock AlignmentGuides jest.mock("@canvas/system/alignment-guides", () => ({ AlignmentGuides: jest.fn().mockImplementation(() => ({ From b91de35297187f02edc2cd2189dd9b106487bf4e Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 01:21:14 +1100 Subject: [PATCH 147/463] fix: sync originalEdit in AddClipCommand for merge field support --- src/core/commands/add-clip-command.ts | 8 ++++++++ src/core/commands/types.ts | 3 +++ src/core/edit.ts | 17 ++++++++++++++++- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/core/commands/add-clip-command.ts b/src/core/commands/add-clip-command.ts index c335aeca..483dbc0b 100644 --- a/src/core/commands/add-clip-command.ts +++ b/src/core/commands/add-clip-command.ts @@ -19,6 +19,10 @@ export class AddClipCommand implements EditCommand { const clipPlayer = context.createPlayerFromAssetType(this.clip); clipPlayer.layer = this.trackIdx + 1; await context.addPlayer(this.trackIdx, clipPlayer); + + // Sync to originalEdit + context.pushOriginalEditClip(this.trackIdx, this.clip); + context.updateDuration(); context.emitEvent("timeline:updated", { current: context.getEditState() }); @@ -28,6 +32,10 @@ export class AddClipCommand implements EditCommand { async undo(context?: CommandContext): Promise { if (!context || !this.addedPlayer) return; context.queueDisposeClip(this.addedPlayer); + + // Remove from originalEdit + context.popOriginalEditClip(this.trackIdx); + context.updateDuration(); context.emitEvent("timeline:updated", { current: context.getEditState() }); } diff --git a/src/core/commands/types.ts b/src/core/commands/types.ts index f8d0d3a6..81ef884e 100644 --- a/src/core/commands/types.ts +++ b/src/core/commands/types.ts @@ -51,4 +51,7 @@ export type CommandContext = { // originalEdit track sync (for track add/delete commands) insertOriginalEditTrack(trackIdx: number): void; removeOriginalEditTrack(trackIdx: number): void; + // originalEdit clip sync (for clip add/delete commands) + pushOriginalEditClip(trackIdx: number, clip: ClipType): void; + popOriginalEditClip(trackIdx: number): void; }; diff --git a/src/core/edit.ts b/src/core/edit.ts index 619a3592..968bccb2 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1040,7 +1040,10 @@ export class Edit extends Entity { syncTemplateClip: (trackIndex, clipIndex, templateClip) => this.syncTemplateClip(trackIndex, clipIndex, templateClip), // originalEdit track sync insertOriginalEditTrack: trackIdx => this.insertOriginalEditTrack(trackIdx), - removeOriginalEditTrack: trackIdx => this.removeOriginalEditTrack(trackIdx) + removeOriginalEditTrack: trackIdx => this.removeOriginalEditTrack(trackIdx), + // originalEdit clip sync + pushOriginalEditClip: (trackIdx, clip) => this.pushOriginalEditClip(trackIdx, clip), + popOriginalEditClip: trackIdx => this.popOriginalEditClip(trackIdx) }; } @@ -1712,6 +1715,18 @@ export class Edit extends Entity { this.originalEdit.timeline.tracks.splice(trackIdx, 1); } + /** Push a clip to originalEdit track (for AddClipCommand) */ + private pushOriginalEditClip(trackIdx: number, clip: ResolvedClip): void { + if (!this.originalEdit?.timeline.tracks[trackIdx]) return; + this.originalEdit.timeline.tracks[trackIdx].clips.push(structuredClone(clip)); + } + + /** Pop a clip from originalEdit track (for AddClipCommand undo) */ + private popOriginalEditClip(trackIdx: number): void { + if (!this.originalEdit?.timeline.tracks[trackIdx]?.clips) return; + this.originalEdit.timeline.tracks[trackIdx].clips.pop(); + } + // ─── Merge Field API (command-based) ─────────────────────────────────────── /** From 21ec6849b9059daa0644345421f09ea6bf582244 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 01:29:10 +1100 Subject: [PATCH 148/463] fix: ensure originalEdit tracks exist before syncing clips and optimize test execution --- package.json | 2 +- src/core/edit.ts | 6 +- tests/edit-clip-operations.test.ts | 81 ++++++++++++ tests/edit-load.test.ts | 197 +++++++++++++++++++++++++++++ 4 files changed, 284 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ef058589..e8e57729 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "build": "npm run build:main && npm run build:schema", "build:main": "vite build", "build:schema": "vite build --config vite.config.schema.ts", - "test": "jest && npm run build", + "test": "jest", "test:watch": "jest --watch", "test:package": "node test-package.js", "typecheck": "tsc --noEmit && tsc --project tsconfig.test.json --noEmit", diff --git a/src/core/edit.ts b/src/core/edit.ts index 968bccb2..67ecfc78 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -1717,7 +1717,11 @@ export class Edit extends Entity { /** Push a clip to originalEdit track (for AddClipCommand) */ private pushOriginalEditClip(trackIdx: number, clip: ResolvedClip): void { - if (!this.originalEdit?.timeline.tracks[trackIdx]) return; + if (!this.originalEdit) return; + // Ensure track exists (mirrors ensureTrack behavior) + while (this.originalEdit.timeline.tracks.length <= trackIdx) { + this.originalEdit.timeline.tracks.push({ clips: [] }); + } this.originalEdit.timeline.tracks[trackIdx].clips.push(structuredClone(clip)); } diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index 43ec2b44..30cf77e3 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -715,6 +715,87 @@ describe("Edit Clip Operations", () => { }); }); + describe("AddClipCommand originalEdit sync", () => { + const baseEdit = { + timeline: { tracks: [] }, + output: { format: "mp4" as const, fps: 25, size: { width: 1920, height: 1080 } } + }; + + it("syncs clip to originalEdit on addClip", async () => { + // Load an initial edit so originalEdit is populated + await edit.loadEdit(baseEdit); + + const clip = createVideoClip(0, 5); + await edit.addClip(0, clip); + + const { originalEdit } = getEditState(edit) as { + originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; + }; + expect(originalEdit.timeline.tracks[0].clips).toHaveLength(1); + }); + + it("removes clip from originalEdit on undo", async () => { + // Load an initial edit so originalEdit is populated + await edit.loadEdit(baseEdit); + + const clip = createVideoClip(0, 5); + await edit.addClip(0, clip); + + const { originalEdit: afterAdd } = getEditState(edit) as { + originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; + }; + expect(afterAdd.timeline.tracks[0].clips).toHaveLength(1); + + edit.undo(); + edit.update(0, 0); // Process disposal + + const { originalEdit: afterUndo } = getEditState(edit) as { + originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; + }; + expect(afterUndo.timeline.tracks[0].clips).toHaveLength(0); + }); + + it("syncs multiple clips to originalEdit", async () => { + await edit.loadEdit(baseEdit); + + await edit.addClip(0, createVideoClip(0, 3)); + await edit.addClip(0, createImageClip(3, 2)); + + const { originalEdit } = getEditState(edit) as { + originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; + }; + expect(originalEdit.timeline.tracks[0].clips).toHaveLength(2); + }); + + it("maintains sync with originalEdit across multiple undo operations", async () => { + await edit.loadEdit(baseEdit); + + await edit.addClip(0, createVideoClip(0, 3)); + await edit.addClip(0, createImageClip(3, 2)); + + const { originalEdit: withTwo } = getEditState(edit) as { + originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; + }; + expect(withTwo.timeline.tracks[0].clips).toHaveLength(2); + + edit.undo(); // Undo second add + edit.update(0, 0); + + const { originalEdit: withOne } = getEditState(edit) as { + originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; + }; + expect(withOne.timeline.tracks[0].clips).toHaveLength(1); + + edit.undo(); // Undo first add + edit.update(0, 0); + + const { originalEdit: withNone } = getEditState(edit) as { + originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; + }; + expect(withNone.timeline.tracks[0].clips).toHaveLength(0); + }); + }); + describe("edge cases", () => { it("handles rapid add/delete cycles", async () => { for (let i = 0; i < 5; i++) { diff --git a/tests/edit-load.test.ts b/tests/edit-load.test.ts index 82011eda..592e4b59 100644 --- a/tests/edit-load.test.ts +++ b/tests/edit-load.test.ts @@ -829,4 +829,201 @@ describe("Edit loadEdit()", () => { expect(AudioPlayer).not.toHaveBeenCalled(); }); }); + + describe("smart loadEdit diffing", () => { + describe("structural change detection", () => { + it("detects different track count as structural change", async () => { + // Load 1-track edit + const edit1 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit1); + const initialCallCount = (ImagePlayer as jest.Mock).mock.calls.length; + + // Load 2-track edit - should trigger full reload + const edit2 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] }, + { clips: [{ asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit2); + + // Should have created new players (full reload) + expect((ImagePlayer as jest.Mock).mock.calls.length).toBeGreaterThan(initialCallCount); + }); + + it("detects different clip count as structural change", async () => { + // Load edit with 1 clip + const edit1 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit1); + const initialCallCount = (ImagePlayer as jest.Mock).mock.calls.length; + + // Load edit with 2 clips - should trigger full reload + const edit2 = createMinimalEdit([ + { + clips: [ + { asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }, + { asset: { type: "image", src: "https://example.com/img2.jpg" }, start: 3, length: 3, fit: "crop" } + ] + } + ]); + await edit.loadEdit(edit2); + + // Should have created new players (full reload) + expect((ImagePlayer as jest.Mock).mock.calls.length).toBeGreaterThan(initialCallCount); + }); + + it("detects asset type change as structural change", async () => { + // Load edit with image clip + const edit1 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit1); + const imageCallCount = (ImagePlayer as jest.Mock).mock.calls.length; + + // Load edit with video clip - should trigger full reload + const edit2 = createMinimalEdit([ + { clips: [{ asset: { type: "video", src: "https://example.com/video.mp4" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit2); + + // Video player should be created (not just updating image player) + expect(VideoPlayer).toHaveBeenCalled(); + }); + + it("uses granular path for property-only changes", async () => { + // Load initial edit + const edit1 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit1); + const initialCallCount = (ImagePlayer as jest.Mock).mock.calls.length; + + // Load edit with only length changed - should use granular path + const edit2 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 10, fit: "crop" }] } + ]); + await edit.loadEdit(edit2); + + // Should NOT create new players (granular path) + expect((ImagePlayer as jest.Mock).mock.calls.length).toBe(initialCallCount); + }); + }); + + describe("granular updates", () => { + it("updates clip properties without rebuilding players", async () => { + // Load initial edit + const edit1 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit1); + + // Get player reference + const playerBefore = edit.getPlayerClip(0, 0); + + // Load edit with changed property + const edit2 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 10, fit: "crop" }] } + ]); + await edit.loadEdit(edit2); + + // Same player instance should be used + const playerAfter = edit.getPlayerClip(0, 0); + expect(playerAfter).toBe(playerBefore); + }); + + it("updates output settings via granular path", async () => { + // Load initial edit + const edit1: ResolvedEdit = { + timeline: { + tracks: [{ clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] }] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4", fps: 25 } + }; + await edit.loadEdit(edit1); + const initialCallCount = (ImagePlayer as jest.Mock).mock.calls.length; + + // Change fps only + const edit2: ResolvedEdit = { + timeline: { + tracks: [{ clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] }] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4", fps: 30 } + }; + await edit.loadEdit(edit2); + + // Should NOT rebuild players + expect((ImagePlayer as jest.Mock).mock.calls.length).toBe(initialCallCount); + // FPS should be updated + expect(edit.getOutputFps()).toBe(30); + }); + + it("updates background color via granular path", async () => { + // Load initial edit + const edit1: ResolvedEdit = { + timeline: { + tracks: [{ clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] }], + background: "#000000" + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }; + await edit.loadEdit(edit1); + const initialCallCount = (ImagePlayer as jest.Mock).mock.calls.length; + + // Change background only + const edit2: ResolvedEdit = { + timeline: { + tracks: [{ clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] }], + background: "#ff0000" + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" } + }; + await edit.loadEdit(edit2); + + // Should NOT rebuild players + expect((ImagePlayer as jest.Mock).mock.calls.length).toBe(initialCallCount); + // Background should be updated + expect(getEditState(edit).backgroundColor).toBe("#ff0000"); + }); + }); + + describe("event emission", () => { + it("emits edit:changed event for full reload", async () => { + const editChangedHandler = jest.fn(); + events.on("edit:changed", editChangedHandler); + + const editConfig = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(editConfig); + + expect(editChangedHandler).toHaveBeenCalledWith(expect.objectContaining({ source: "loadEdit" })); + }); + + it("emits single edit:changed event for granular updates", async () => { + // Load initial edit + const edit1 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } + ]); + await edit.loadEdit(edit1); + + // Reset and track events + const editChangedHandler = jest.fn(); + events.on("edit:changed", editChangedHandler); + + // Load edit with property change (granular path) + const edit2 = createMinimalEdit([ + { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 10, fit: "crop" }] } + ]); + await edit.loadEdit(edit2); + + // Should emit exactly 1 event with granular source + const granularEvents = editChangedHandler.mock.calls.filter( + (call: [{ source: string }]) => call[0].source === "loadEdit:granular" + ); + expect(granularEvents.length).toBe(1); + }); + }); + }); }); From b46144324079f90a9ea825540200f305d0ca1514 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 12:06:26 +1100 Subject: [PATCH 149/463] feat: refactor merge field tracking to use player bindings instead of originalEdit --- src/components/canvas/players/player.ts | 86 ++++- src/core/commands/add-clip-command.ts | 6 - src/core/commands/add-track-command.ts | 6 - src/core/commands/delete-track-command.ts | 23 +- src/core/commands/set-merge-field-command.ts | 33 +- src/core/commands/set-updated-clip-command.ts | 41 +-- src/core/commands/split-clip-command.ts | 11 +- src/core/commands/types.ts | 9 - src/core/edit.ts | 271 +++++++-------- src/core/ui/rich-text-toolbar.ts | 40 ++- src/core/ui/text-toolbar.ts | 23 +- tests/edit-clip-operations.test.ts | 90 ++--- tests/edit-load.test.ts | 59 +++- tests/edit-merge-fields.test.ts | 319 +++++++++++++++++- tests/edit-timing.test.ts | 31 +- 15 files changed, 740 insertions(+), 308 deletions(-) diff --git a/src/components/canvas/players/player.ts b/src/components/canvas/players/player.ts index 708d707d..63b52ee5 100644 --- a/src/components/canvas/players/player.ts +++ b/src/components/canvas/players/player.ts @@ -3,16 +3,28 @@ import { EffectPresetBuilder } from "@animations/effect-preset-builder"; import { KeyframeBuilder } from "@animations/keyframe-builder"; import { TransitionPresetBuilder } from "@animations/transition-preset-builder"; import { type Edit } from "@core/edit"; +import { getNestedValue, setNestedValue } from "@core/shared/utils"; import { type ResolvedTiming, type TimingIntent } from "@core/timing/types"; import { Pointer } from "@inputs/pointer"; import { type Size, type Vector } from "@layouts/geometry"; import { PositionBuilder } from "@layouts/position-builder"; -import { type ResolvedClip } from "@schemas/clip"; +import { type Clip, type ResolvedClip } from "@schemas/clip"; import { type Keyframe } from "@schemas/keyframe"; import * as pixi from "pixi.js"; import { Entity } from "../../../core/shared/entity"; +/** + * Tracks a merge field binding for a specific property path. + * Used to restore placeholders on export for properties that haven't changed. + */ +export interface MergeFieldBinding { + /** The original placeholder string, e.g., "{{ HERO_IMAGE }}" */ + placeholder: string; + /** The resolved value at binding time, used for change detection */ + resolvedValue: string; +} + export enum PlayerType { Video = "video", Image = "image", @@ -144,6 +156,12 @@ export abstract class Player extends Entity { private initialClipConfiguration: ResolvedClip | null; protected contentContainer: pixi.Container; + /** + * Tracks which properties came from merge field templates. + * Key: property path (e.g., "asset.src"), Value: binding info + */ + private mergeFieldBindings: Map = new Map(); + constructor(edit: Edit, clipConfiguration: ResolvedClip, playerType: PlayerType) { super(); @@ -537,6 +555,72 @@ export abstract class Player extends Entity { }; } + // ─── Merge Field Binding Methods ───────────────────────────────────────────── + + /** + * Set a merge field binding for a property path. + * Called when a property is resolved from a merge field template. + */ + public setMergeFieldBinding(path: string, binding: MergeFieldBinding): void { + this.mergeFieldBindings.set(path, binding); + } + + /** + * Get the merge field binding for a property path, if any. + */ + public getMergeFieldBinding(path: string): MergeFieldBinding | undefined { + return this.mergeFieldBindings.get(path); + } + + /** + * Remove a merge field binding (e.g., when user changes the value). + */ + public removeMergeFieldBinding(path: string): void { + this.mergeFieldBindings.delete(path); + } + + /** + * Get all merge field bindings for this player. + */ + public getMergeFieldBindings(): Map { + return this.mergeFieldBindings; + } + + /** + * Bulk set bindings during player initialization. + */ + public setInitialBindings(bindings: Map): void { + this.mergeFieldBindings = new Map(bindings); + } + + /** + * Get the exportable clip configuration with merge field placeholders restored. + * For properties that haven't changed from their resolved value, the original + * placeholder (e.g., "{{ HERO_IMAGE }}") is restored for export. + */ + public getExportableClip(): Clip { + const exported = structuredClone(this.clipConfiguration) as Record; + + // Restore merge field placeholders for unchanged values + for (const [path, { placeholder, resolvedValue }] of this.mergeFieldBindings) { + const currentValue = getNestedValue(exported, path); + if (currentValue === resolvedValue) { + // Value unchanged - restore the placeholder for export + setNestedValue(exported, path, placeholder); + } + // If value changed, leave current value (binding is broken) + } + + // Apply timing intent (preserves "auto", "end" strings) + const intent = this.getTimingIntent(); + exported["start"] = intent.start; + exported["length"] = intent.length; + + return exported as Clip; + } + + // ───────────────────────────────────────────────────────────────────────────── + public getPlaybackTime(): number { const clipTime = this.edit.playbackTime - this.getStart(); diff --git a/src/core/commands/add-clip-command.ts b/src/core/commands/add-clip-command.ts index 483dbc0b..91b2ff4e 100644 --- a/src/core/commands/add-clip-command.ts +++ b/src/core/commands/add-clip-command.ts @@ -20,9 +20,6 @@ export class AddClipCommand implements EditCommand { clipPlayer.layer = this.trackIdx + 1; await context.addPlayer(this.trackIdx, clipPlayer); - // Sync to originalEdit - context.pushOriginalEditClip(this.trackIdx, this.clip); - context.updateDuration(); context.emitEvent("timeline:updated", { current: context.getEditState() }); @@ -33,9 +30,6 @@ export class AddClipCommand implements EditCommand { if (!context || !this.addedPlayer) return; context.queueDisposeClip(this.addedPlayer); - // Remove from originalEdit - context.popOriginalEditClip(this.trackIdx); - context.updateDuration(); context.emitEvent("timeline:updated", { current: context.getEditState() }); } diff --git a/src/core/commands/add-track-command.ts b/src/core/commands/add-track-command.ts index 7a60098d..f03724a5 100644 --- a/src/core/commands/add-track-command.ts +++ b/src/core/commands/add-track-command.ts @@ -14,9 +14,6 @@ export class AddTrackCommand implements EditCommand { tracks.splice(this.trackIdx, 0, []); - // Sync originalEdit - insert empty track at same index - context.insertOriginalEditTrack(this.trackIdx); - // Update layers for all clips that are on tracks at or after the insertion point // Since we're inserting a track, all tracks at or after trackIdx shift down clips.forEach(clip => { @@ -57,9 +54,6 @@ export class AddTrackCommand implements EditCommand { const clips = context.getClips(); tracks.splice(this.trackIdx, 1); - // Sync originalEdit - remove the track we added - context.removeOriginalEditTrack(this.trackIdx); - clips.forEach(clip => { if (clip.layer > this.trackIdx) { // eslint-disable-next-line no-param-reassign diff --git a/src/core/commands/delete-track-command.ts b/src/core/commands/delete-track-command.ts index 1261932e..a1c6824e 100644 --- a/src/core/commands/delete-track-command.ts +++ b/src/core/commands/delete-track-command.ts @@ -1,3 +1,4 @@ +import type { MergeFieldBinding } from "@canvas/players/player"; import type { ResolvedClip } from "@schemas/clip"; import * as pixi from "pixi.js"; @@ -7,7 +8,7 @@ type ClipType = ResolvedClip; export class DeleteTrackCommand implements EditCommand { name = "deleteTrack"; - private deletedClips: Array<{ config: ClipType }> = []; + private deletedClips: Array<{ config: ClipType; bindings: Map }> = []; constructor(private trackIdx: number) {} @@ -16,7 +17,13 @@ export class DeleteTrackCommand implements EditCommand { const clips = context.getClips(); const tracks = context.getTracks(); - this.deletedClips = clips.filter(c => c.layer === this.trackIdx + 1).map(c => ({ config: structuredClone(c.clipConfiguration) })); + // Save config and bindings for undo + this.deletedClips = clips + .filter(c => c.layer === this.trackIdx + 1) + .map(c => ({ + config: structuredClone(c.clipConfiguration), + bindings: new Map(c.getMergeFieldBindings()) + })); clips.forEach((clip, index) => { if (clip.layer === this.trackIdx + 1) { @@ -27,9 +34,6 @@ export class DeleteTrackCommand implements EditCommand { tracks.splice(this.trackIdx, 1); - // Sync originalEdit - remove the track at same index - context.removeOriginalEditTrack(this.trackIdx); - const remainingClips = context.getClips(); const container = context.getContainer(); @@ -59,18 +63,19 @@ export class DeleteTrackCommand implements EditCommand { tracks.splice(this.trackIdx, 0, []); - // Sync originalEdit - re-insert the track at same index - context.insertOriginalEditTrack(this.trackIdx); - clips.forEach((clip, index) => { if (clip.layer >= this.trackIdx + 1) { clips[index].layer += 1; } }); - for (const { config } of this.deletedClips) { + for (const { config, bindings } of this.deletedClips) { const player = context.createPlayerFromAssetType(config); player.layer = this.trackIdx + 1; + // Restore merge field bindings + if (bindings.size > 0) { + player.setInitialBindings(bindings); + } await context.addPlayer(this.trackIdx, player); } context.updateDuration(); diff --git a/src/core/commands/set-merge-field-command.ts b/src/core/commands/set-merge-field-command.ts index abb80aab..d61ad46f 100644 --- a/src/core/commands/set-merge-field-command.ts +++ b/src/core/commands/set-merge-field-command.ts @@ -1,15 +1,15 @@ -import type { Player } from "@canvas/players/player"; +import type { MergeFieldBinding, Player } from "@canvas/players/player"; import { setNestedValue } from "@core/shared/utils"; import type { EditCommand, CommandContext } from "./types"; /** * Command to apply or remove a merge field on a clip property. - * Handles both the template (for export) and resolved value (for rendering) atomically. + * Handles both the player binding (for export) and resolved value (for rendering) atomically. * * This command supports undo/redo and ensures: * - Player's clipConfiguration gets the resolved value (for rendering) - * - Template edit (originalEdit) gets the {{ FIELD }} template (for export) + * - Player's mergeFieldBindings track the placeholder (for export) * - Merge field registry is updated appropriately */ export class SetMergeFieldCommand implements EditCommand { @@ -19,6 +19,7 @@ export class SetMergeFieldCommand implements EditCommand { private storedNewValue: string; private trackIndex: number; private clipIndex: number; + private storedPreviousBinding: MergeFieldBinding | undefined; constructor( private clip: Player, @@ -41,12 +42,23 @@ export class SetMergeFieldCommand implements EditCommand { const mergeFields = context.getMergeFields(); + // Save previous binding for undo + this.storedPreviousBinding = this.clip.getMergeFieldBinding(this.propertyPath); + // 1. Update player's clipConfiguration with resolved value setNestedValue(this.clip.clipConfiguration, this.propertyPath, this.storedNewValue); - // 2. Update template edit with template or raw value - const templateValue = this.fieldName ? mergeFields.createTemplate(this.fieldName) : this.storedNewValue; - context.setTemplateClipProperty(this.trackIndex, this.clipIndex, this.propertyPath, templateValue); + // 2. Update player binding + if (this.fieldName) { + // Applying a merge field - create binding with template + this.clip.setMergeFieldBinding(this.propertyPath, { + placeholder: mergeFields.createTemplate(this.fieldName), + resolvedValue: this.storedNewValue + }); + } else { + // Removing merge field - remove binding + this.clip.removeMergeFieldBinding(this.propertyPath); + } // 3. Register/update merge field if applying (silent to prevent reload) if (this.fieldName) { @@ -82,9 +94,12 @@ export class SetMergeFieldCommand implements EditCommand { // 1. Restore player's clipConfiguration with previous value setNestedValue(this.clip.clipConfiguration, this.propertyPath, this.storedPreviousValue); - // 2. Restore template edit - const templateValue = this.previousFieldName ? mergeFields.createTemplate(this.previousFieldName) : this.storedPreviousValue; - context.setTemplateClipProperty(this.trackIndex, this.clipIndex, this.propertyPath, templateValue); + // 2. Restore previous binding + if (this.storedPreviousBinding) { + this.clip.setMergeFieldBinding(this.propertyPath, this.storedPreviousBinding); + } else { + this.clip.removeMergeFieldBinding(this.propertyPath); + } // 3. Re-register previous field or update current (silent to prevent reload) if (this.previousFieldName) { diff --git a/src/core/commands/set-updated-clip-command.ts b/src/core/commands/set-updated-clip-command.ts index 8a5f7c85..32efde44 100644 --- a/src/core/commands/set-updated-clip-command.ts +++ b/src/core/commands/set-updated-clip-command.ts @@ -1,4 +1,5 @@ -import type { Player } from "@canvas/players/player"; +import { getNestedValue } from "@core/shared/utils"; +import type { MergeFieldBinding, Player } from "@canvas/players/player"; import type { ResolvedClip } from "@schemas/clip"; import type { EditCommand, CommandContext } from "./types"; @@ -8,15 +9,13 @@ type ClipType = ResolvedClip; export interface SetUpdatedClipOptions { trackIndex?: number; clipIndex?: number; - templateConfig?: ClipType; // If provided, sync to originalEdit } export class SetUpdatedClipCommand implements EditCommand { name = "setUpdatedClip"; private storedInitialConfig: ClipType | null; private storedFinalConfig: ClipType; - private storedInitialTemplateConfig: ClipType | null = null; - private storedFinalTemplateConfig: ClipType | null = null; + private storedInitialBindings: Map = new Map(); private trackIndex: number; private clipIndex: number; private storedInitialTiming: { start: number; length: number } | null = null; @@ -31,13 +30,14 @@ export class SetUpdatedClipCommand implements EditCommand { this.storedFinalConfig = finalClipConfig ? structuredClone(finalClipConfig) : structuredClone(this.clip.clipConfiguration); this.trackIndex = options?.trackIndex ?? -1; this.clipIndex = options?.clipIndex ?? -1; - if (options?.templateConfig) { - this.storedFinalTemplateConfig = structuredClone(options.templateConfig); - } } async execute(context?: CommandContext): Promise { if (!context) return; + + // Save bindings before modification (for undo) + this.storedInitialBindings = new Map(this.clip.getMergeFieldBindings()); + if (this.storedFinalConfig) { context.restoreClipConfiguration(this.clip, this.storedFinalConfig); } @@ -66,23 +66,20 @@ export class SetUpdatedClipCommand implements EditCommand { context.setUpdatedClip(this.clip); + // Detect broken bindings - if value changed from resolvedValue, remove the binding + for (const [path, { resolvedValue }] of this.storedInitialBindings) { + const currentValue = getNestedValue(this.clip.clipConfiguration, path); + if (currentValue !== resolvedValue) { + this.clip.removeMergeFieldBinding(path); + } + } + // Use provided indices or calculate from clip const trackIndex = this.trackIndex >= 0 ? this.trackIndex : this.clip.layer - 1; const clips = context.getClips(); const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer); const clipIndex = this.clipIndex >= 0 ? this.clipIndex : clipsByTrack.indexOf(this.clip); - // Sync originalEdit if template config provided - if (this.storedFinalTemplateConfig && trackIndex >= 0 && clipIndex >= 0) { - // Store previous template for undo - const prevTemplate = context.getTemplateClip(trackIndex, clipIndex); - if (prevTemplate) { - this.storedInitialTemplateConfig = structuredClone(prevTemplate); - } - // Update originalEdit with template version - context.syncTemplateClip(trackIndex, clipIndex, this.storedFinalTemplateConfig); - } - // Check if asset src changed const previousAsset = this.storedInitialConfig?.asset as { src?: string } | undefined; const currentAsset = this.storedFinalConfig?.asset as { src?: string } | undefined; @@ -120,17 +117,15 @@ export class SetUpdatedClipCommand implements EditCommand { context.setUpdatedClip(this.clip); + // Restore saved bindings + this.clip.setInitialBindings(this.storedInitialBindings); + // Use provided indices or calculate from clip const trackIndex = this.trackIndex >= 0 ? this.trackIndex : this.clip.layer - 1; const clips = context.getClips(); const clipsByTrack = clips.filter((c: Player) => c.layer === this.clip.layer); const clipIndex = this.clipIndex >= 0 ? this.clipIndex : clipsByTrack.indexOf(this.clip); - // Restore originalEdit if we modified it - if (this.storedInitialTemplateConfig && trackIndex >= 0 && clipIndex >= 0) { - context.syncTemplateClip(trackIndex, clipIndex, this.storedInitialTemplateConfig); - } - // Check if asset src changed (reverse direction) const previousAsset = this.storedFinalConfig?.asset as { src?: string } | undefined; const currentAsset = this.storedInitialConfig?.asset as { src?: string } | undefined; diff --git a/src/core/commands/split-clip-command.ts b/src/core/commands/split-clip-command.ts index 1daca656..ec2e7347 100644 --- a/src/core/commands/split-clip-command.ts +++ b/src/core/commands/split-clip-command.ts @@ -1,4 +1,4 @@ -import type { Player } from "@canvas/players/player"; +import type { MergeFieldBinding, Player } from "@canvas/players/player"; import type { AudioAsset } from "../schemas/audio-asset"; import type { ResolvedClip } from "../schemas/clip"; @@ -11,6 +11,7 @@ export class SplitClipCommand implements EditCommand { private originalClipConfig: ResolvedClip | null = null; private rightClipPlayer: Player | null = null; private splitSuccessful = false; + private originalBindings: Map = new Map(); constructor( private trackIndex: number, @@ -37,8 +38,9 @@ export class SplitClipCommand implements EditCommand { throw new Error("Cannot split clip: split point too close to clip boundaries"); } - // Store original configuration for undo + // Store original configuration and bindings for undo this.originalClipConfig = { ...clipConfig }; + this.originalBindings = new Map(player.getMergeFieldBindings()); // Calculate left and right clip configurations const leftClip: ResolvedClip = { @@ -90,6 +92,11 @@ export class SplitClipCommand implements EditCommand { this.rightClipPlayer.layer = this.trackIndex + 1; + // Copy merge field bindings to right clip (both clips inherit same bindings) + if (this.originalBindings.size > 0) { + this.rightClipPlayer.setInitialBindings(this.originalBindings); + } + // Insert right clip after the current clip const track = context.getTrack(this.trackIndex); if (!track) { diff --git a/src/core/commands/types.ts b/src/core/commands/types.ts index 81ef884e..85a36208 100644 --- a/src/core/commands/types.ts +++ b/src/core/commands/types.ts @@ -45,13 +45,4 @@ export type CommandContext = { trackEndLengthClip(clip: Player): void; // Merge field context getMergeFields(): MergeFieldService; - getTemplateClip(trackIndex: number, clipIndex: number): ClipType | null; - setTemplateClipProperty(trackIndex: number, clipIndex: number, propertyPath: string, value: unknown): void; - syncTemplateClip(trackIndex: number, clipIndex: number, templateClip: ClipType): void; - // originalEdit track sync (for track add/delete commands) - insertOriginalEditTrack(trackIdx: number): void; - removeOriginalEditTrack(trackIdx: number): void; - // originalEdit clip sync (for clip add/delete commands) - pushOriginalEditClip(trackIdx: number, clip: ClipType): void; - popOriginalEditClip(trackIdx: number): void; }; diff --git a/src/core/edit.ts b/src/core/edit.ts index 67ecfc78..78954b66 100644 --- a/src/core/edit.ts +++ b/src/core/edit.ts @@ -3,7 +3,7 @@ import { CaptionPlayer } from "@canvas/players/caption-player"; import { HtmlPlayer } from "@canvas/players/html-player"; import { ImagePlayer } from "@canvas/players/image-player"; import { LumaPlayer } from "@canvas/players/luma-player"; -import { type Player, PlayerType } from "@canvas/players/player"; +import { type MergeFieldBinding, type Player, PlayerType } from "@canvas/players/player"; import { RichTextPlayer } from "@canvas/players/rich-text-player"; import { ShapePlayer } from "@canvas/players/shape-player"; import { TextPlayer } from "@canvas/players/text-player"; @@ -22,10 +22,9 @@ import { SplitClipCommand } from "@core/commands/split-clip-command"; import { UpdateTextContentCommand } from "@core/commands/update-text-content-command"; import { EventEmitter } from "@core/events/event-emitter"; import { LumaMaskController } from "@core/luma-mask-controller"; -import { applyMergeFields, MergeFieldService } from "@core/merge"; +import { applyMergeFields, MergeFieldService, type SerializedMergeField } from "@core/merge"; import { Entity } from "@core/shared/entity"; -import { serializeEditForExport, type ClipExportData } from "@core/shared/serialize-edit"; -import { deepMerge, getNestedValue, setNestedValue } from "@core/shared/utils"; +import { deepMerge, getNestedValue } from "@core/shared/utils"; import { calculateTimelineEnd, resolveAutoLength, resolveAutoStart, resolveEndLength } from "@core/timing/resolver"; import type { ToolbarButtonConfig } from "@core/ui/toolbar-button.types"; import type { Size } from "@layouts/geometry"; @@ -58,7 +57,6 @@ export class Edit extends Entity { public events: EventEmitter; private edit: ResolvedEdit | null; - private originalEdit: ResolvedEdit | null; private tracks: Player[][]; private clipsToDispose: Player[]; private clips: Player[]; @@ -111,7 +109,6 @@ export class Edit extends Entity { this.assetLoader = new AssetLoader(); this.edit = null; - this.originalEdit = null; this.tracks = []; this.clipsToDispose = []; @@ -250,13 +247,13 @@ export class Edit extends Entity { this.clearClips(); - // Store original (unresolved) edit for re-resolution on merge field changes - this.originalEdit = structuredClone(edit); - // Load merge fields from edit payload into service const serializedMergeFields = edit.merge ?? []; this.mergeFields.loadFromSerialized(serializedMergeFields); + // Detect merge field bindings BEFORE substitution (preserves placeholder info) + const bindingsPerClip = this.detectMergeFieldBindings(edit, serializedMergeFields); + // Apply merge field substitutions for initial load const mergedEdit = serializedMergeFields.length > 0 ? applyMergeFields(edit, serializedMergeFields) : edit; @@ -292,9 +289,16 @@ export class Edit extends Entity { ); for (const [trackIdx, track] of this.edit.timeline.tracks.entries()) { - for (const clip of track.clips) { + for (const [clipIdx, clip] of track.clips.entries()) { const clipPlayer = this.createPlayerFromAssetType(clip); clipPlayer.layer = trackIdx + 1; + + // Pass merge field bindings to the player + const bindings = bindingsPerClip.get(`${trackIdx}-${clipIdx}`); + if (bindings && bindings.size > 0) { + clipPlayer.setInitialBindings(bindings); + } + await this.addPlayer(trackIdx, clipPlayer); } } @@ -331,23 +335,19 @@ export class Edit extends Entity { await this.addPlayer(this.tracks.length, player); } public getEdit(): EditConfig { - const clipData: ClipExportData[][] = this.tracks.map(track => - track - .filter(player => player && !this.clipsToDispose.includes(player)) - .map(player => ({ - clipConfiguration: player.clipConfiguration, - getTimingIntent: () => player.getTimingIntent() - })) - ); + const tracks = this.tracks.map(track => ({ + clips: track.filter(player => player && !this.clipsToDispose.includes(player)).map(player => player.getExportableClip()) + })); - return serializeEditForExport( - clipData, - this.originalEdit, - this.backgroundColor, - this.edit?.timeline.fonts || [], - this.edit?.output || { size: this.size, format: "mp4" }, - this.mergeFields.toSerializedArray() - ); + return { + timeline: { + background: this.backgroundColor, + tracks, + fonts: this.edit?.timeline.fonts || [] + }, + output: this.edit?.output || { size: this.size, format: "mp4" }, + merge: this.mergeFields.toSerializedArray() + }; } /** @@ -419,9 +419,11 @@ export class Edit extends Entity { return clipsByTrack[clipIdx]; } - /** Get the original (unresolved) asset for a clip, preserving merge field templates */ + /** Get the exportable asset for a clip, preserving merge field templates */ public getOriginalAsset(trackIndex: number, clipIndex: number): unknown | undefined { - return this.originalEdit?.timeline.tracks[trackIndex]?.clips[clipIndex]?.asset; + const player = this.getPlayerClip(trackIndex, clipIndex); + if (!player) return undefined; + return player.getExportableClip()?.asset; } public deleteClip(trackIdx: number, clipIdx: number): void { @@ -767,14 +769,9 @@ export class Edit extends Entity { const track = this.tracks[trackIdx]; const clipIdx = track ? track.indexOf(clip) : -1; - // Sync to originalEdit so getEdit() returns updated values - const originalClip = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; - const templateConfig = finalClipConfig && originalClip ? deepMerge(structuredClone(originalClip), finalClipConfig) : finalClipConfig; - const command = new SetUpdatedClipCommand(clip, initialClipConfig, finalClipConfig, { trackIndex: trackIdx, - clipIndex: clipIdx, - templateConfig: templateConfig ?? undefined + clipIndex: clipIdx }); this.executeCommand(command); } @@ -790,52 +787,9 @@ export class Edit extends Entity { const currentConfig = structuredClone(clip.clipConfiguration); const mergedConfig = deepMerge(currentConfig, updates); - // Also sync to originalEdit so getEdit() returns updated values - const originalClip = this.originalEdit?.timeline.tracks[trackIdx]?.clips[clipIdx]; - const mergedTemplate = originalClip ? deepMerge(structuredClone(originalClip), updates) : mergedConfig; - const command = new SetUpdatedClipCommand(clip, initialConfig, mergedConfig, { trackIndex: trackIdx, - clipIndex: clipIdx, - templateConfig: mergedTemplate - }); - this.executeCommand(command); - } - - /** - * Update a clip with separate resolved and template configurations. - * Use this when the resolved value (for rendering) differs from the template value (for export). - * This is typically used when a property contains a merge field template. - * - * @param trackIdx - Track index - * @param clipIdx - Clip index within the track - * @param resolvedUpdates - Updates with resolved values (for clipConfiguration/rendering) - * @param templateUpdates - Updates with template values (for originalEdit/export) - */ - public updateClipWithTemplate( - trackIdx: number, - clipIdx: number, - resolvedUpdates: Partial, - templateUpdates: Partial - ): void { - const clip = this.getPlayerClip(trackIdx, clipIdx); - if (!clip) { - console.warn(`Clip not found at track ${trackIdx}, index ${clipIdx}`); - return; - } - - const initialConfig = structuredClone(clip.clipConfiguration); - const mergedResolved = deepMerge(structuredClone(initialConfig), resolvedUpdates); - - const templateClip = this.getTemplateClip(trackIdx, clipIdx); - const mergedTemplate = templateClip - ? deepMerge(structuredClone(templateClip), templateUpdates) - : deepMerge(structuredClone(initialConfig), templateUpdates); - - const command = new SetUpdatedClipCommand(clip, initialConfig, mergedResolved, { - trackIndex: trackIdx, - clipIndex: clipIdx, - templateConfig: mergedTemplate + clipIndex: clipIdx }); this.executeCommand(command); } @@ -874,14 +828,93 @@ export class Edit extends Entity { this.events.emit("edit:changed", { source, timestamp: Date.now() }); } + /** + * Detects merge field placeholders in the raw edit before substitution. + * Returns a map of clip keys ("trackIdx-clipIdx") to their merge field bindings. + * Each binding maps a property path to its placeholder and resolved value. + */ + private detectMergeFieldBindings(edit: ResolvedEdit, mergeFields: SerializedMergeField[]): Map> { + const result = new Map>(); + + if (!mergeFields.length) return result; + + // Build lookup map: FIELD_NAME -> replacement value + const fieldValues = new Map(); + for (const { find, replace } of mergeFields) { + fieldValues.set(find.toUpperCase(), replace); + } + + // Walk each clip and detect placeholder strings + for (const [trackIdx, track] of edit.timeline.tracks.entries()) { + for (const [clipIdx, clip] of track.clips.entries()) { + const bindings = this.detectBindingsInObject(clip, "", fieldValues); + if (bindings.size > 0) { + result.set(`${trackIdx}-${clipIdx}`, bindings); + } + } + } + + return result; + } + + /** + * Recursively walks an object to find merge field placeholders. + * Returns a map of property paths to their bindings. + */ + private detectBindingsInObject(obj: unknown, basePath: string, fieldValues: Map): Map { + const bindings = new Map(); + + if (typeof obj === "string") { + // Check if this string contains a merge field placeholder + // Use exec with a fresh regex to get capture groups (MERGE_FIELD_PATTERN has 'g' flag) + const regex = /\{\{\s*([A-Z_0-9]+)\s*\}\}/i; + const match = obj.match(regex); + if (match && match[1]) { + // Extract the field name (without braces/whitespace) + const fieldName = match[1].toUpperCase(); + const resolvedValue = fieldValues.get(fieldName); + if (resolvedValue !== undefined) { + bindings.set(basePath, { + placeholder: obj, + resolvedValue + }); + } + } + return bindings; + } + + if (Array.isArray(obj)) { + for (let i = 0; i < obj.length; i++) { + const path = basePath ? `${basePath}[${i}]` : `[${i}]`; + const childBindings = this.detectBindingsInObject(obj[i], path, fieldValues); + for (const [p, b] of childBindings) { + bindings.set(p, b); + } + } + return bindings; + } + + if (obj !== null && typeof obj === "object") { + for (const [key, value] of Object.entries(obj)) { + const path = basePath ? `${basePath}.${key}` : key; + const childBindings = this.detectBindingsInObject(value, path, fieldValues); + for (const [p, b] of childBindings) { + bindings.set(p, b); + } + } + } + + return bindings; + } + /** * Checks if edit has structural changes requiring full reload. * Structural = track count, clip count, or asset type changed. */ private hasStructuralChanges(newEdit: ResolvedEdit): boolean { - if (!this.edit || !this.originalEdit) return true; + if (!this.edit) return true; - const currentTracks = this.originalEdit.timeline.tracks; + const currentTracks = this.edit.timeline.tracks; const newTracks = newEdit.timeline.tracks; // Different track count = structural @@ -901,7 +934,7 @@ export class Edit extends Entity { } // Merge fields changed = structural (affects asset resolution) - if (JSON.stringify(this.originalEdit.merge ?? []) !== JSON.stringify(newEdit.merge ?? [])) { + if (JSON.stringify(this.edit.merge ?? []) !== JSON.stringify(newEdit.merge ?? [])) { return true; } @@ -939,7 +972,7 @@ export class Edit extends Entity { } // 2. Diff and update each clip - const currentTracks = this.originalEdit!.timeline.tracks; + const currentTracks = this.edit!.timeline.tracks; const newTracks = newEdit.timeline.tracks; for (let trackIdx = 0; trackIdx < newTracks.length; trackIdx++) { @@ -956,9 +989,6 @@ export class Edit extends Entity { } } } - - // 3. Update originalEdit to reflect new state - this.originalEdit = structuredClone(newEdit); } private createCommandContext(): CommandContext { @@ -992,11 +1022,6 @@ export class Edit extends Entity { } } track.splice(insertIdx, 0, clip); - - // Sync originalEdit - re-insert clip template at same index - if (this.originalEdit?.timeline.tracks[trackIdx]?.clips) { - this.originalEdit.timeline.tracks[trackIdx].clips.splice(insertIdx, 0, structuredClone(clip.clipConfiguration)); - } } this.addPlayerToContainer(trackIdx, clip); @@ -1033,17 +1058,7 @@ export class Edit extends Entity { untrackEndLengthClip: clip => this.endLengthClips.delete(clip), trackEndLengthClip: clip => this.endLengthClips.add(clip), // Merge field context - getMergeFields: () => this.mergeFields, - getTemplateClip: (trackIndex, clipIndex) => this.getTemplateClip(trackIndex, clipIndex), - setTemplateClipProperty: (trackIndex, clipIndex, propertyPath, value) => - this.setTemplateClipProperty(trackIndex, clipIndex, propertyPath, value), - syncTemplateClip: (trackIndex, clipIndex, templateClip) => this.syncTemplateClip(trackIndex, clipIndex, templateClip), - // originalEdit track sync - insertOriginalEditTrack: trackIdx => this.insertOriginalEditTrack(trackIdx), - removeOriginalEditTrack: trackIdx => this.removeOriginalEditTrack(trackIdx), - // originalEdit clip sync - pushOriginalEditClip: (trackIdx, clip) => this.pushOriginalEditClip(trackIdx, clip), - popOriginalEditClip: trackIdx => this.popOriginalEditClip(trackIdx) + getMergeFields: () => this.mergeFields }; } @@ -1074,11 +1089,6 @@ export class Edit extends Entity { const clipIdx = this.tracks[trackIdx].indexOf(clip); if (clipIdx !== -1) { this.tracks[trackIdx].splice(clipIdx, 1); - - // Sync originalEdit - remove from template data to keep aligned with tracks array - if (this.originalEdit?.timeline.tracks[trackIdx]?.clips) { - this.originalEdit.timeline.tracks[trackIdx].clips.splice(clipIdx, 1); - } } } } @@ -1675,11 +1685,13 @@ export class Edit extends Entity { return [...this.toolbarButtons]; } - // ─── Template Edit Access (for merge field commands) ─────────────────────── + // ─── Template Edit Access (via player bindings) ─────────────────────────── - /** Get the template clip from originalEdit */ + /** Get the exportable clip (with merge field placeholders restored) */ private getTemplateClip(trackIndex: number, clipIndex: number): ResolvedClip | null { - return this.originalEdit?.timeline.tracks[trackIndex]?.clips[clipIndex] ?? null; + const player = this.getPlayerClip(trackIndex, clipIndex); + if (!player) return null; + return player.getExportableClip() as ResolvedClip; } /** Get the text content from the template clip (with merge field placeholders) */ @@ -1690,47 +1702,6 @@ export class Edit extends Entity { return asset?.text ?? null; } - /** Set a property on the template clip in originalEdit using dot notation */ - public setTemplateClipProperty(trackIndex: number, clipIndex: number, propertyPath: string, value: unknown): void { - const clip = this.originalEdit?.timeline.tracks[trackIndex]?.clips[clipIndex]; - if (!clip) return; - setNestedValue(clip, propertyPath, value); - } - - /** Sync the entire template clip in originalEdit with a new clip configuration */ - private syncTemplateClip(trackIndex: number, clipIndex: number, templateClip: ResolvedClip): void { - if (!this.originalEdit?.timeline.tracks[trackIndex]?.clips) return; - this.originalEdit.timeline.tracks[trackIndex].clips[clipIndex] = structuredClone(templateClip); - } - - /** Insert an empty track into originalEdit at the specified index */ - private insertOriginalEditTrack(trackIdx: number): void { - if (!this.originalEdit?.timeline.tracks) return; - this.originalEdit.timeline.tracks.splice(trackIdx, 0, { clips: [] }); - } - - /** Remove a track from originalEdit at the specified index */ - private removeOriginalEditTrack(trackIdx: number): void { - if (!this.originalEdit?.timeline.tracks) return; - this.originalEdit.timeline.tracks.splice(trackIdx, 1); - } - - /** Push a clip to originalEdit track (for AddClipCommand) */ - private pushOriginalEditClip(trackIdx: number, clip: ResolvedClip): void { - if (!this.originalEdit) return; - // Ensure track exists (mirrors ensureTrack behavior) - while (this.originalEdit.timeline.tracks.length <= trackIdx) { - this.originalEdit.timeline.tracks.push({ clips: [] }); - } - this.originalEdit.timeline.tracks[trackIdx].clips.push(structuredClone(clip)); - } - - /** Pop a clip from originalEdit track (for AddClipCommand undo) */ - private popOriginalEditClip(trackIdx: number): void { - if (!this.originalEdit?.timeline.tracks[trackIdx]?.clips) return; - this.originalEdit.timeline.tracks[trackIdx].clips.pop(); - } - // ─── Merge Field API (command-based) ─────────────────────────────────────── /** diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index 222f3142..b0d16fd1 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -1100,13 +1100,20 @@ export class RichTextToolbar extends BaseToolbar { // Resolve any merge field templates in the text for canvas rendering const resolvedText = this.edit.mergeFields.resolve(templateText); - // Update both stores: resolved for canvas, template for export - this.edit.updateClipWithTemplate( - this.selectedTrackIdx, - this.selectedClipIdx, - { asset: { text: resolvedText } as ResolvedClip["asset"] }, - { asset: { text: templateText } as ResolvedClip["asset"] } - ); + // Update merge field binding for export to preserve templates + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (player && this.edit.mergeFields.isMergeFieldTemplate(templateText)) { + player.setMergeFieldBinding("asset.text", { + placeholder: templateText, + resolvedValue: resolvedText + }); + } else if (player) { + player.removeMergeFieldBinding("asset.text"); + } + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { text: resolvedText } as ResolvedClip["asset"] + }); this.syncState(); } @@ -1205,13 +1212,18 @@ export class RichTextToolbar extends BaseToolbar { this.hideAutocomplete(); - // Update both stores: resolved for canvas, template for export - this.edit.updateClipWithTemplate( - this.selectedTrackIdx, - this.selectedClipIdx, - { asset: { text: resolvedText } as ResolvedClip["asset"] }, - { asset: { text: templateText } as ResolvedClip["asset"] } - ); + // Update merge field binding for export to preserve templates + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (player) { + player.setMergeFieldBinding("asset.text", { + placeholder: templateText, + resolvedValue: resolvedText + }); + } + + this.edit.updateClip(this.selectedTrackIdx, this.selectedClipIdx, { + asset: { text: resolvedText } as ResolvedClip["asset"] + }); this.syncState(); } diff --git a/src/core/ui/text-toolbar.ts b/src/core/ui/text-toolbar.ts index bed64730..564d8b97 100644 --- a/src/core/ui/text-toolbar.ts +++ b/src/core/ui/text-toolbar.ts @@ -648,8 +648,21 @@ export class TextToolbar extends BaseToolbar { clearTimeout(this.textEditDebounceTimer); } this.textEditDebounceTimer = setTimeout(() => { - const newText = this.textEditArea?.value ?? ""; - this.updateAssetProperty({ text: newText }); + const rawText = this.textEditArea?.value ?? ""; + const resolvedText = this.edit.mergeFields.resolve(rawText); + + // Update merge field binding + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + if (player && this.edit.mergeFields.isMergeFieldTemplate(rawText)) { + player.setMergeFieldBinding("asset.text", { + placeholder: rawText, + resolvedValue: resolvedText + }); + } else if (player) { + player.removeMergeFieldBinding("asset.text"); + } + + this.updateAssetProperty({ text: resolvedText }); }, 150); } @@ -1094,9 +1107,11 @@ export class TextToolbar extends BaseToolbar { const asset = this.getCurrentAsset(); if (!asset) return; - // Text + // Text - show merge field placeholder if present, otherwise resolved value if (this.textEditArea) { - this.textEditArea.value = asset.text || ""; + const player = this.edit.getPlayerClip(this.selectedTrackIdx, this.selectedClipIdx); + const binding = player?.getMergeFieldBinding("asset.text"); + this.textEditArea.value = binding?.placeholder ?? asset.text ?? ""; } // Size diff --git a/tests/edit-clip-operations.test.ts b/tests/edit-clip-operations.test.ts index 30cf77e3..b2e01c5e 100644 --- a/tests/edit-clip-operations.test.ts +++ b/tests/edit-clip-operations.test.ts @@ -141,7 +141,10 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => const lengthMs = typeof config.length === "number" ? config.length * 1000 : 3000; let resolvedTiming = { start: startMs, length: lengthMs }; - let timingIntent = { start: config.start, length: config.length }; + const timingIntent: { start: number | string; length: number | string } = { start: config.start, length: config.length }; + + // Merge field bindings support + const mergeFieldBindings = new Map(); return { clipConfiguration: config, @@ -155,7 +158,7 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => getEnd: () => resolvedTiming.start + resolvedTiming.length, getSize: () => ({ width: 1920, height: 1080 }), getTimingIntent: () => ({ ...timingIntent }), - setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + setTimingIntent: jest.fn((intent: { start?: number | string; length?: number | string }) => { if (intent.start !== undefined) timingIntent.start = intent.start; if (intent.length !== undefined) timingIntent.length = intent.length; }), @@ -170,7 +173,29 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => reloadAsset: jest.fn().mockResolvedValue(undefined), dispose: jest.fn(), isActive: () => true, - convertToFixedTiming: jest.fn() + convertToFixedTiming: jest.fn(), + // Merge field binding methods + getMergeFieldBindings: () => mergeFieldBindings, + getMergeFieldBinding: (path: string) => mergeFieldBindings.get(path), + setMergeFieldBinding: (path: string, binding: { placeholder: string; resolvedValue: string }) => { + mergeFieldBindings.set(path, binding); + }, + removeMergeFieldBinding: (path: string) => { + mergeFieldBindings.delete(path); + }, + setInitialBindings: (bindings: Map) => { + mergeFieldBindings.clear(); + for (const [k, v] of bindings) { + mergeFieldBindings.set(k, v); + } + }, + getExportableClip: () => { + const exported = structuredClone(config); + // Apply timing intent + if (timingIntent.start !== undefined) exported.start = timingIntent.start; + if (timingIntent.length !== undefined) exported.length = timingIntent.length; + return exported; + } }; }; @@ -715,84 +740,73 @@ describe("Edit Clip Operations", () => { }); }); - describe("AddClipCommand originalEdit sync", () => { + describe("AddClipCommand export state sync", () => { const baseEdit = { timeline: { tracks: [] }, output: { format: "mp4" as const, fps: 25, size: { width: 1920, height: 1080 } } }; - it("syncs clip to originalEdit on addClip", async () => { - // Load an initial edit so originalEdit is populated + it("tracks clip state after addClip", async () => { + // Load an initial edit await edit.loadEdit(baseEdit); const clip = createVideoClip(0, 5); await edit.addClip(0, clip); - const { originalEdit } = getEditState(edit) as { - originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; - }; - expect(originalEdit.timeline.tracks[0].clips).toHaveLength(1); + // Verify clip is tracked in player + const player = edit.getPlayerClip(0, 0); + expect(player).not.toBeNull(); + expect(player?.clipConfiguration.asset?.type).toBe("video"); }); - it("removes clip from originalEdit on undo", async () => { - // Load an initial edit so originalEdit is populated + it("removes clip state on undo", async () => { + // Load an initial edit await edit.loadEdit(baseEdit); const clip = createVideoClip(0, 5); await edit.addClip(0, clip); - const { originalEdit: afterAdd } = getEditState(edit) as { - originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; - }; - expect(afterAdd.timeline.tracks[0].clips).toHaveLength(1); + // Verify clip exists + expect(edit.getPlayerClip(0, 0)).not.toBeNull(); edit.undo(); edit.update(0, 0); // Process disposal - const { originalEdit: afterUndo } = getEditState(edit) as { - originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; - }; - expect(afterUndo.timeline.tracks[0].clips).toHaveLength(0); + // Verify clip is removed + expect(edit.getPlayerClip(0, 0)).toBeNull(); }); - it("syncs multiple clips to originalEdit", async () => { + it("tracks multiple clips after addClip", async () => { await edit.loadEdit(baseEdit); await edit.addClip(0, createVideoClip(0, 3)); await edit.addClip(0, createImageClip(3, 2)); - const { originalEdit } = getEditState(edit) as { - originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; - }; - expect(originalEdit.timeline.tracks[0].clips).toHaveLength(2); + // Verify both clips are tracked + const { tracks } = getEditState(edit); + expect(tracks[0]).toHaveLength(2); }); - it("maintains sync with originalEdit across multiple undo operations", async () => { + it("maintains state across multiple undo operations", async () => { await edit.loadEdit(baseEdit); await edit.addClip(0, createVideoClip(0, 3)); await edit.addClip(0, createImageClip(3, 2)); - const { originalEdit: withTwo } = getEditState(edit) as { - originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; - }; - expect(withTwo.timeline.tracks[0].clips).toHaveLength(2); + const { tracks: withTwo } = getEditState(edit); + expect(withTwo[0]).toHaveLength(2); edit.undo(); // Undo second add edit.update(0, 0); - const { originalEdit: withOne } = getEditState(edit) as { - originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; - }; - expect(withOne.timeline.tracks[0].clips).toHaveLength(1); + const { tracks: withOne } = getEditState(edit); + expect(withOne[0]).toHaveLength(1); edit.undo(); // Undo first add edit.update(0, 0); - const { originalEdit: withNone } = getEditState(edit) as { - originalEdit: { timeline: { tracks: Array<{ clips: unknown[] }> } }; - }; - expect(withNone.timeline.tracks[0].clips).toHaveLength(0); + const { tracks: withNone } = getEditState(edit); + expect(withNone[0]?.length ?? 0).toBe(0); }); }); diff --git a/tests/edit-load.test.ts b/tests/edit-load.test.ts index 592e4b59..ad54d9ff 100644 --- a/tests/edit-load.test.ts +++ b/tests/edit-load.test.ts @@ -171,7 +171,10 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => const lengthMs = typeof config.length === "number" ? config.length * 1000 : 3000; let resolvedTiming = { start: startMs, length: lengthMs }; - let timingIntent = { start: config.start, length: config.length }; + const timingIntent: { start: number | string; length: number | string } = { start: config.start, length: config.length }; + + // Merge field bindings support + const mergeFieldBindings = new Map(); return { clipConfiguration: config, @@ -185,7 +188,7 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => getEnd: () => resolvedTiming.start + resolvedTiming.length, getSize: () => ({ width: 1920, height: 1080 }), getTimingIntent: () => ({ ...timingIntent }), - setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + setTimingIntent: jest.fn((intent: { start?: number | string; length?: number | string }) => { if (intent.start !== undefined) timingIntent.start = intent.start; if (intent.length !== undefined) timingIntent.length = intent.length; }), @@ -200,7 +203,29 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => reloadAsset: jest.fn().mockResolvedValue(undefined), dispose: jest.fn(), isActive: () => true, - convertToFixedTiming: jest.fn() + convertToFixedTiming: jest.fn(), + // Merge field binding methods + getMergeFieldBindings: () => mergeFieldBindings, + getMergeFieldBinding: (path: string) => mergeFieldBindings.get(path), + setMergeFieldBinding: (path: string, binding: { placeholder: string; resolvedValue: string }) => { + mergeFieldBindings.set(path, binding); + }, + removeMergeFieldBinding: (path: string) => { + mergeFieldBindings.delete(path); + }, + setInitialBindings: (bindings: Map) => { + mergeFieldBindings.clear(); + for (const [k, v] of bindings) { + mergeFieldBindings.set(k, v); + } + }, + getExportableClip: () => { + const exported = structuredClone(config); + // Apply timing intent + if (timingIntent.start !== undefined) exported.start = timingIntent.start; + if (timingIntent.length !== undefined) exported.length = timingIntent.length; + return exported; + } }; }; @@ -492,17 +517,16 @@ describe("Edit loadEdit()", () => { expect(tracks[0].length).toBe(3); }); - it("preserves original clip data in originalEdit", async () => { + it("preserves clip data in player configuration", async () => { const editConfig = createMinimalEdit([ { clips: [{ asset: { type: "image", src: "https://example.com/img.jpg" }, start: 0, length: 3, fit: "crop" }] } ]); await edit.loadEdit(editConfig); - const { originalEdit } = getEditState(edit); - // Verify originalEdit contains the clip data (note: loadEdit clones edit + addPlayer syncs) - expect(originalEdit?.timeline.tracks[0].clips.length).toBeGreaterThanOrEqual(1); - expect(originalEdit?.timeline.tracks[0].clips[0].asset).toHaveProperty("src", "https://example.com/img.jpg"); + // Player should have the resolved clip data + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://example.com/img.jpg"); }); }); @@ -591,7 +615,7 @@ describe("Edit loadEdit()", () => { }); describe("merge field handling", () => { - it("stores original edit with {{ FIELD }} templates in originalEdit", async () => { + it("stores merge field bindings on player", async () => { const editConfig: ResolvedEdit = { timeline: { tracks: [ @@ -606,9 +630,15 @@ describe("Edit loadEdit()", () => { await edit.loadEdit(editConfig); - const { originalEdit } = getEditState(edit); - // Original should preserve the template - expect(originalEdit?.timeline.tracks[0].clips[0].asset).toHaveProperty("src", "{{ MEDIA_URL }}"); + // Player should have resolved value + const player = edit.getPlayerClip(0, 0); + expect(player?.clipConfiguration.asset).toHaveProperty("src", "https://resolved.example.com/img.jpg"); + + // Player should track the merge field binding + const binding = player?.getMergeFieldBinding("asset.src"); + expect(binding).toBeDefined(); + expect(binding?.placeholder).toBe("{{ MEDIA_URL }}"); + expect(binding?.resolvedValue).toBe("https://resolved.example.com/img.jpg"); }); it("loads merge fields into service from edit.merge array", async () => { @@ -788,7 +818,6 @@ describe("Edit loadEdit()", () => { // Should only have clips from second edit expect(getEditState(edit).tracks[0].length).toBe(2); }); - }); describe("soundtrack", () => { @@ -1019,9 +1048,7 @@ describe("Edit loadEdit()", () => { await edit.loadEdit(edit2); // Should emit exactly 1 event with granular source - const granularEvents = editChangedHandler.mock.calls.filter( - (call: [{ source: string }]) => call[0].source === "loadEdit:granular" - ); + const granularEvents = editChangedHandler.mock.calls.filter((call: [{ source: string }]) => call[0].source === "loadEdit:granular"); expect(granularEvents.length).toBe(1); }); }); diff --git a/tests/edit-merge-fields.test.ts b/tests/edit-merge-fields.test.ts index 9f903ff1..7bf65592 100644 --- a/tests/edit-merge-fields.test.ts +++ b/tests/edit-merge-fields.test.ts @@ -144,9 +144,12 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => const lengthMs = typeof config.length === "number" ? config.length * 1000 : 3000; let resolvedTiming = { start: startMs, length: lengthMs }; - let timingIntent = { start: config.start, length: config.length }; + const timingIntent: { start: number | string; length: number | string } = { start: config.start, length: config.length }; - return { + // Merge field bindings storage + const mergeFieldBindings = new Map(); + + const player = { clipConfiguration: config, layer: 0, playerType: type, @@ -158,7 +161,7 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => getEnd: () => resolvedTiming.start + resolvedTiming.length, getSize: () => ({ width: 1920, height: 1080 }), getTimingIntent: () => ({ ...timingIntent }), - setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + setTimingIntent: jest.fn((intent: { start?: number | string; length?: number | string }) => { if (intent.start !== undefined) timingIntent.start = intent.start; if (intent.length !== undefined) timingIntent.length = intent.length; }), @@ -173,10 +176,63 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => reloadAsset: jest.fn().mockResolvedValue(undefined), dispose: jest.fn(), isActive: () => true, - convertToFixedTiming: jest.fn() + convertToFixedTiming: jest.fn(), + // Merge field binding methods + getMergeFieldBindings: () => mergeFieldBindings, + getMergeFieldBinding: (path: string) => mergeFieldBindings.get(path), + setMergeFieldBinding: (path: string, binding: { placeholder: string; resolvedValue: string }) => { + mergeFieldBindings.set(path, binding); + }, + removeMergeFieldBinding: (path: string) => { + mergeFieldBindings.delete(path); + }, + setInitialBindings: (bindings: Map) => { + mergeFieldBindings.clear(); + for (const [k, v] of bindings) { + mergeFieldBindings.set(k, v); + } + }, + getExportableClip: () => { + // Clone the config and restore placeholders for unchanged values + const exported = structuredClone(player.clipConfiguration) as Record; + for (const [path, { placeholder, resolvedValue }] of mergeFieldBindings) { + const current = getNestedValue(exported, path); + if (current === resolvedValue) { + setNestedValue(exported, path, placeholder); + } + } + exported["start"] = timingIntent.start; + exported["length"] = timingIntent.length; + return exported as ResolvedClip; + } }; + + return player; }; +// Helper to get/set nested values for mock player +function getNestedValue(obj: Record, path: string): unknown { + const keys = path.split("."); + let current: unknown = obj; + for (const key of keys) { + if (current === null || current === undefined) return undefined; + current = (current as Record)[key]; + } + return current; +} + +function setNestedValue(obj: Record, path: string, value: unknown): void { + const keys = path.split("."); + let current = obj; + for (let i = 0; i < keys.length - 1; i += 1) { + if (!(keys[i] in current)) { + current[keys[i]] = {}; + } + current = current[keys[i]] as Record; + } + current[keys[keys.length - 1]] = value; +} + // Mock all player types jest.mock("@canvas/players/video-player", () => ({ VideoPlayer: jest.fn().mockImplementation((edit, config) => createMockPlayer(edit, config, PlayerType.Video)) @@ -222,19 +278,16 @@ jest.mock("@canvas/players/caption-player", () => ({ */ function getEditState(edit: Edit): { tracks: unknown[][]; - originalEdit: { timeline: { tracks: { clips: ResolvedClip[] }[] } } | null; commandHistory: unknown[]; commandIndex: number; } { const anyEdit = edit as unknown as { tracks: unknown[][]; - originalEdit: { timeline: { tracks: { clips: ResolvedClip[] }[] } } | null; commandHistory: unknown[]; commandIndex: number; }; return { tracks: anyEdit.tracks, - originalEdit: anyEdit.originalEdit, commandHistory: anyEdit.commandHistory, commandIndex: anyEdit.commandIndex }; @@ -299,7 +352,7 @@ describe("Edit Merge Fields", () => { }); describe("applyMergeField()", () => { - it("stores {{ FIELD }} template in originalEdit", async () => { + it("stores {{ FIELD }} template in player bindings", async () => { // Add a clip first const clip = createImageClip(0, 3); await edit.addClip(0, clip); @@ -307,10 +360,13 @@ describe("Edit Merge Fields", () => { // Apply merge field edit.applyMergeField(0, 0, "asset.src", "MEDIA_URL", "https://cdn.example.com/new.jpg"); - // Check originalEdit has template - const { originalEdit } = getEditState(edit); - const templateClip = originalEdit?.timeline.tracks[0].clips[0]; - expect(templateClip?.asset).toHaveProperty("src", "{{ MEDIA_URL }}"); + // Check player has binding with template + const player = edit.getPlayerClip(0, 0) as { + getMergeFieldBinding: (path: string) => { placeholder: string; resolvedValue: string } | undefined; + }; + const binding = player?.getMergeFieldBinding("asset.src"); + expect(binding?.placeholder).toBe("{{ MEDIA_URL }}"); + expect(binding?.resolvedValue).toBe("https://cdn.example.com/new.jpg"); }); it("updates clipConfiguration with resolved value", async () => { @@ -435,7 +491,7 @@ describe("Edit Merge Fields", () => { }); describe("removeMergeField()", () => { - it("removes {{ FIELD }} from originalEdit", async () => { + it("removes binding from player", async () => { const clip = createImageClip(0, 3); await edit.addClip(0, clip); @@ -445,9 +501,12 @@ describe("Edit Merge Fields", () => { // Remove merge field edit.removeMergeField(0, 0, "asset.src", "https://example.com/restored.jpg"); - const { originalEdit } = getEditState(edit); - const templateClip = originalEdit?.timeline.tracks[0].clips[0]; - expect(templateClip?.asset).toHaveProperty("src", "https://example.com/restored.jpg"); + // Check binding was removed + const player = edit.getPlayerClip(0, 0) as { + getMergeFieldBinding: (path: string) => { placeholder: string; resolvedValue: string } | undefined; + }; + const binding = player?.getMergeFieldBinding("asset.src"); + expect(binding).toBeUndefined(); }); it("sets restoreValue in clipConfiguration", async () => { @@ -483,8 +542,12 @@ describe("Edit Merge Fields", () => { edit.undo(); await Promise.resolve(); - // Field should be back - expect(edit.getMergeFieldForProperty(0, 0, "asset.src")).toBe("UNDO_TEST"); + // Binding should be back on player + const player = edit.getPlayerClip(0, 0) as { + getMergeFieldBinding: (path: string) => { placeholder: string; resolvedValue: string } | undefined; + }; + const binding = player?.getMergeFieldBinding("asset.src"); + expect(binding?.placeholder).toBe("{{ UNDO_TEST }}"); }); it("is no-op when no merge field exists", async () => { @@ -739,6 +802,226 @@ describe("Edit Merge Fields", () => { }); }); + describe("text asset merge field display", () => { + it("player binding stores placeholder for text asset display", async () => { + // Load edit with text merge field - this tests the scenario where + // a text toolbar needs to show {{ TITLE }} instead of resolved value + await edit.loadEdit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "text", text: "{{ TITLE }}" }, start: 0, length: 3, fit: "none" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "TITLE", replace: "Resolved Title" }] + }); + + const player = edit.getPlayerClip(0, 0); + + // Resolved value in clipConfiguration (for rendering) + expect((player?.clipConfiguration.asset as { text?: string }).text).toBe("Resolved Title"); + + // Placeholder accessible via binding (for UI display) + const binding = player?.getMergeFieldBinding("asset.text"); + expect(binding?.placeholder).toBe("{{ TITLE }}"); + expect(binding?.resolvedValue).toBe("Resolved Title"); + }); + + it("editing text with merge fields resolves them for canvas rendering", async () => { + // Load edit with merge field + await edit.loadEdit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "text", text: "{{ TITLE }}" }, start: 0, length: 3, fit: "none" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "TITLE", replace: "Hello World" }] + }); + + const player = edit.getPlayerClip(0, 0); + + // Simulate text edit in toolbar: user types "{{ TITLE }} extra text" + const rawText = "{{ TITLE }} extra text"; + const resolvedText = edit.mergeFields.resolve(rawText); // "Hello World extra text" + + // Update binding (what toolbar should do) + player?.setMergeFieldBinding("asset.text", { + placeholder: rawText, + resolvedValue: resolvedText + }); + + // Update clip with resolved text (what toolbar should do) + edit.updateClip(0, 0, { asset: { type: "text", text: resolvedText } }); + + // Canvas should show resolved value + expect((player?.clipConfiguration.asset as { text?: string }).text).toBe("Hello World extra text"); + + // Export should restore placeholder + const exported = player?.getExportableClip(); + expect((exported?.asset as { text?: string }).text).toBe("{{ TITLE }} extra text"); + }); + + it("removing merge fields from text removes the binding", async () => { + // Load edit with merge field + await edit.loadEdit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "text", text: "{{ TITLE }}" }, start: 0, length: 3, fit: "none" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "TITLE", replace: "Hello World" }] + }); + + const player = edit.getPlayerClip(0, 0); + + // Initially has binding + expect(player?.getMergeFieldBinding("asset.text")).toBeDefined(); + + // Simulate text edit removing merge field: user types plain text + const plainText = "Just plain text"; + + // Remove binding (what toolbar should do when no merge field) + player?.removeMergeFieldBinding("asset.text"); + + // Update clip with plain text + edit.updateClip(0, 0, { asset: { type: "text", text: plainText } }); + + // No binding should exist + expect(player?.getMergeFieldBinding("asset.text")).toBeUndefined(); + + // Export should show plain text + const exported = player?.getExportableClip(); + expect((exported?.asset as { text?: string }).text).toBe("Just plain text"); + }); + }); + + describe("media asset merge field display", () => { + it("image source with merge field preserves placeholder for export", async () => { + // Load edit with image merge field + await edit.loadEdit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "image", src: "{{ IMAGE_URL }}" }, start: 0, length: 3, fit: "crop" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "IMAGE_URL", replace: "https://cdn.example.com/image.jpg" }] + }); + + const player = edit.getPlayerClip(0, 0); + + // Resolved value in clipConfiguration (for rendering) + expect((player?.clipConfiguration.asset as { src?: string }).src).toBe("https://cdn.example.com/image.jpg"); + + // Binding stores placeholder for export + const binding = player?.getMergeFieldBinding("asset.src"); + expect(binding?.placeholder).toBe("{{ IMAGE_URL }}"); + expect(binding?.resolvedValue).toBe("https://cdn.example.com/image.jpg"); + + // Export restores placeholder + const exported = player?.getExportableClip(); + expect((exported?.asset as { src?: string }).src).toBe("{{ IMAGE_URL }}"); + }); + + it("video source with merge field preserves placeholder for export", async () => { + // Load edit with video merge field + await edit.loadEdit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "video", src: "{{ VIDEO_URL }}" }, start: 0, length: 5, fit: "crop" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "VIDEO_URL", replace: "https://cdn.example.com/video.mp4" }] + }); + + const player = edit.getPlayerClip(0, 0); + + // Resolved value in clipConfiguration (for rendering) + expect((player?.clipConfiguration.asset as { src?: string }).src).toBe("https://cdn.example.com/video.mp4"); + + // Binding stores placeholder for export + const binding = player?.getMergeFieldBinding("asset.src"); + expect(binding?.placeholder).toBe("{{ VIDEO_URL }}"); + + // Export restores placeholder + const exported = player?.getExportableClip(); + expect((exported?.asset as { src?: string }).src).toBe("{{ VIDEO_URL }}"); + }); + + it("changing source to different URL while binding exists updates export correctly", async () => { + // Load edit with merge field + await edit.loadEdit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "image", src: "{{ IMAGE_URL }}" }, start: 0, length: 3, fit: "crop" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "IMAGE_URL", replace: "https://cdn.example.com/original.jpg" }] + }); + + const player = edit.getPlayerClip(0, 0); + + // Simulate toolbar changing source to a completely different URL + const newSrc = "https://different-cdn.example.com/new-image.jpg"; + + // Update clip with new source (toolbar would do this) + edit.updateClip(0, 0, { asset: { type: "image", src: newSrc } }); + + // Canvas should show new URL + expect((player?.clipConfiguration.asset as { src?: string }).src).toBe(newSrc); + + // Export should show new URL (not placeholder) since value changed + const exported = player?.getExportableClip(); + expect((exported?.asset as { src?: string }).src).toBe(newSrc); + }); + + it("removing merge field binding from source exports literal URL", async () => { + // Load edit with merge field + await edit.loadEdit({ + timeline: { + tracks: [ + { + clips: [{ asset: { type: "image", src: "{{ IMAGE_URL }}" }, start: 0, length: 3, fit: "crop" }] + } + ] + }, + output: { size: { width: 1920, height: 1080 }, format: "mp4" }, + merge: [{ find: "IMAGE_URL", replace: "https://cdn.example.com/image.jpg" }] + }); + + const player = edit.getPlayerClip(0, 0); + + // Initially has binding + expect(player?.getMergeFieldBinding("asset.src")).toBeDefined(); + + // Remove binding (simulating user removing merge field association) + player?.removeMergeFieldBinding("asset.src"); + + // No binding should exist + expect(player?.getMergeFieldBinding("asset.src")).toBeUndefined(); + + // Export should show literal URL + const exported = player?.getExportableClip(); + expect((exported?.asset as { src?: string }).src).toBe("https://cdn.example.com/image.jpg"); + }); + }); + describe("undo/redo sequences", () => { it("undo then redo preserves merge field state", async () => { const clip = createImageClip(0, 3, "https://original.example.com/image.jpg"); diff --git a/tests/edit-timing.test.ts b/tests/edit-timing.test.ts index 8ab7d6c4..de377dde 100644 --- a/tests/edit-timing.test.ts +++ b/tests/edit-timing.test.ts @@ -153,7 +153,10 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => const lengthMs = typeof lengthIntent === "number" ? lengthIntent * 1000 : 3000; let resolvedTiming = { start: startMs, length: lengthMs }; - let timingIntent = { start: startIntent, length: lengthIntent }; + const timingIntent: { start: number | string; length: number | string } = { start: startIntent, length: lengthIntent }; + + // Merge field bindings support + const mergeFieldBindings = new Map(); return { clipConfiguration: config, @@ -167,7 +170,7 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => getEnd: () => resolvedTiming.start + resolvedTiming.length, getSize: () => ({ width: 1920, height: 1080 }), getTimingIntent: () => ({ ...timingIntent }), - setTimingIntent: jest.fn((intent: { start?: unknown; length?: unknown }) => { + setTimingIntent: jest.fn((intent: { start?: number | string; length?: number | string }) => { if (intent.start !== undefined) timingIntent.start = intent.start; if (intent.length !== undefined) timingIntent.length = intent.length; }), @@ -182,7 +185,29 @@ const createMockPlayer = (edit: Edit, config: ResolvedClip, type: PlayerType) => reloadAsset: jest.fn().mockResolvedValue(undefined), dispose: jest.fn(), isActive: () => true, - convertToFixedTiming: jest.fn() + convertToFixedTiming: jest.fn(), + // Merge field binding methods + getMergeFieldBindings: () => mergeFieldBindings, + getMergeFieldBinding: (path: string) => mergeFieldBindings.get(path), + setMergeFieldBinding: (path: string, binding: { placeholder: string; resolvedValue: string }) => { + mergeFieldBindings.set(path, binding); + }, + removeMergeFieldBinding: (path: string) => { + mergeFieldBindings.delete(path); + }, + setInitialBindings: (bindings: Map) => { + mergeFieldBindings.clear(); + for (const [k, v] of bindings) { + mergeFieldBindings.set(k, v); + } + }, + getExportableClip: () => { + const exported = structuredClone(config); + // Apply timing intent + if (timingIntent.start !== undefined) exported.start = timingIntent.start; + if (timingIntent.length !== undefined) exported.length = timingIntent.length; + return exported; + } }; }; From 9d4cc4383bbe281b425810c7e8fc444e4e9e8214 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Thu, 18 Dec 2025 12:55:56 +1100 Subject: [PATCH 150/463] refactor: consolidate style injection into centralized module --- jest.config.js | 1 + package.json | 1 + src/components/timeline/timeline.ts | 19 +--- src/core/ui/asset-toolbar.ts | 15 +-- src/core/ui/background-color-picker.ts | 18 +--- src/core/ui/base-toolbar.ts | 16 --- src/core/ui/canvas-toolbar.ts | 15 +-- src/core/ui/font-color-picker.ts | 21 +--- src/core/ui/media-toolbar.ts | 4 +- src/core/ui/rich-text-toolbar.ts | 4 +- src/core/ui/text-toolbar.css.ts | 2 - src/core/ui/text-toolbar.ts | 4 +- src/styles/index.css | 17 ++++ src/styles/inject.ts | 37 +++++++ .../timeline/timeline.css} | 8 -- .../ui/asset-toolbar.css} | 2 - .../ui/background-color-picker.css} | 2 - .../ui/canvas-toolbar.css} | 2 - .../ui/font-color-picker.css} | 2 - .../ui/media-toolbar.css} | 2 - .../ui/rich-text-toolbar.css} | 2 - tests/__mocks__/css-inline.js | 23 +++++ tests/css-classes.test.ts | 98 +++++++++++++++++++ tests/style-injection.test.ts | 64 ++++++++++++ 24 files changed, 257 insertions(+), 122 deletions(-) delete mode 100644 src/core/ui/text-toolbar.css.ts create mode 100644 src/styles/index.css create mode 100644 src/styles/inject.ts rename src/{components/timeline/styles/timeline.css.ts => styles/timeline/timeline.css} (98%) rename src/{core/ui/asset-toolbar.css.ts => styles/ui/asset-toolbar.css} (98%) rename src/{core/ui/background-color-picker.css.ts => styles/ui/background-color-picker.css} (97%) rename src/{core/ui/canvas-toolbar.css.ts => styles/ui/canvas-toolbar.css} (99%) rename src/{core/ui/font-color-picker.css.ts => styles/ui/font-color-picker.css} (98%) rename src/{core/ui/media-toolbar.css.ts => styles/ui/media-toolbar.css} (99%) rename src/{core/ui/rich-text-toolbar.css.ts => styles/ui/rich-text-toolbar.css} (99%) create mode 100644 tests/__mocks__/css-inline.js create mode 100644 tests/css-classes.test.ts create mode 100644 tests/style-injection.test.ts diff --git a/jest.config.js b/jest.config.js index 94afc3f0..de050d3b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,6 +4,7 @@ export default { extensionsToTreatAsEsm: [".ts"], moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1", + "\\.css\\?inline$": "/tests/__mocks__/css-inline.js", "^@shotstack/shotstack-studio/schema$": "/dist/schema/index.cjs", "^@core/(.*)$": "/src/core/$1", "^@canvas/(.*)$": "/src/components/canvas/$1", diff --git a/package.json b/package.json index e8e57729..9df2da02 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-import": "^2.31.0", "jest": "^30.2.0", + "jest-environment-jsdom": "^30.2.0", "prettier": "^3.6.2", "ts-jest": "^29.4.5", "typescript": "^5.6.2", diff --git a/src/components/timeline/timeline.ts b/src/components/timeline/timeline.ts index ad254c56..92a1011a 100644 --- a/src/components/timeline/timeline.ts +++ b/src/components/timeline/timeline.ts @@ -1,4 +1,5 @@ import type { Edit } from "@core/edit"; +import { injectShotstackStyles } from "@styles/inject"; import { PlayheadComponent } from "./components/playhead/playhead-component"; import { RulerComponent } from "./components/ruler/ruler-component"; @@ -8,7 +9,6 @@ import { TimelineStateManager } from "./core/state/timeline-state"; import { TimelineEntity } from "./core/timeline-entity"; import type { TimelineOptions, TimelineFeatures, ClipRenderer, ClipInfo } from "@timeline/timeline.types"; import { InteractionController } from "./interaction/interaction-controller"; -import { getTimelineStyles } from "./styles/timeline.css"; /** HTML/CSS-based Timeline component extending TimelineEntity for SDK consistency */ export class Timeline extends TimelineEntity { @@ -31,9 +31,6 @@ export class Timeline extends TimelineEntity { private feedbackLayer: HTMLElement | null = null; private interactionController: InteractionController | null = null; - // Style element for scoped CSS - private styleElement: HTMLStyleElement | null = null; - // Hybrid render loop state private animationFrameId: number | null = null; private isRenderLoopActive = false; @@ -101,7 +98,7 @@ export class Timeline extends TimelineEntity { if (this.isLoaded) return; // Inject styles - this.injectStyles(); + injectShotstackStyles(); // Mount to container first so we can measure this.container.appendChild(this.element); @@ -180,12 +177,6 @@ export class Timeline extends TimelineEntity { // Remove DOM this.element.remove(); - // Remove styles - if (this.styleElement) { - this.styleElement.remove(); - this.styleElement = null; - } - this.isLoaded = false; } @@ -270,12 +261,6 @@ export class Timeline extends TimelineEntity { // ========== Component Building ========== - private injectStyles(): void { - this.styleElement = document.createElement("style"); - this.styleElement.textContent = getTimelineStyles(); - document.head.appendChild(this.styleElement); - } - private buildComponents(): void { // Clear existing content this.element.innerHTML = ""; diff --git a/src/core/ui/asset-toolbar.ts b/src/core/ui/asset-toolbar.ts index 84875a84..3312f2bc 100644 --- a/src/core/ui/asset-toolbar.ts +++ b/src/core/ui/asset-toolbar.ts @@ -1,16 +1,14 @@ import type { Edit } from "@core/edit"; - -import { ASSET_TOOLBAR_STYLES } from "./asset-toolbar.css"; +import { injectShotstackStyles } from "@styles/inject"; export class AssetToolbar { private container: HTMLDivElement | null = null; - private styleElement: HTMLStyleElement | null = null; private edit: Edit; private padding = 12; constructor(edit: Edit) { this.edit = edit; - this.injectStyles(); + injectShotstackStyles(); } setPosition(leftOffset: number): void { @@ -19,15 +17,6 @@ export class AssetToolbar { } } - private injectStyles(): void { - if (document.getElementById("ss-asset-toolbar-styles")) return; - - this.styleElement = document.createElement("style"); - this.styleElement.id = "ss-asset-toolbar-styles"; - this.styleElement.textContent = ASSET_TOOLBAR_STYLES; - document.head.appendChild(this.styleElement); - } - mount(parent: HTMLElement): void { this.container?.remove(); diff --git a/src/core/ui/background-color-picker.ts b/src/core/ui/background-color-picker.ts index 0f096841..e3fb4bdf 100644 --- a/src/core/ui/background-color-picker.ts +++ b/src/core/ui/background-color-picker.ts @@ -1,4 +1,4 @@ -import { BACKGROUND_COLOR_PICKER_STYLES } from "./background-color-picker.css"; +import { injectShotstackStyles } from "@styles/inject"; type ColorChangeCallback = (color: string, opacity: number) => void; @@ -7,21 +7,11 @@ export class BackgroundColorPicker { private colorInput: HTMLInputElement | null = null; private opacitySlider: HTMLInputElement | null = null; private opacityValue: HTMLSpanElement | null = null; - private styleElement: HTMLStyleElement | null = null; private onColorChange: ColorChangeCallback | null = null; constructor() { - this.injectStyles(); - } - - private injectStyles(): void { - if (document.getElementById("ss-background-color-picker-styles")) return; - - this.styleElement = document.createElement("style"); - this.styleElement.id = "ss-background-color-picker-styles"; - this.styleElement.textContent = BACKGROUND_COLOR_PICKER_STYLES; - document.head.appendChild(this.styleElement); + injectShotstackStyles(); } mount(parent: HTMLElement): void { @@ -102,10 +92,6 @@ export class BackgroundColorPicker { this.colorInput = null; this.opacitySlider = null; this.opacityValue = null; - - this.styleElement?.remove(); - this.styleElement = null; - this.onColorChange = null; } } diff --git a/src/core/ui/base-toolbar.ts b/src/core/ui/base-toolbar.ts index 5664329f..f24f3ca6 100644 --- a/src/core/ui/base-toolbar.ts +++ b/src/core/ui/base-toolbar.ts @@ -46,7 +46,6 @@ export abstract class BaseToolbar { protected edit: Edit; protected selectedTrackIdx = -1; protected selectedClipIdx = -1; - protected styleElement: HTMLStyleElement | null = null; protected clickOutsideHandler: ((e: MouseEvent) => void) | null = null; constructor(edit: Edit) { @@ -98,21 +97,6 @@ export abstract class BaseToolbar { this.container?.remove(); this.container = null; - - this.styleElement?.remove(); - this.styleElement = null; - } - - /** - * Inject styles into the document head if not already present. - */ - protected injectStyles(styleId: string, styleContent: string): void { - if (document.getElementById(styleId)) return; - - this.styleElement = document.createElement("style"); - this.styleElement.id = styleId; - this.styleElement.textContent = styleContent; - document.head.appendChild(this.styleElement); } /** diff --git a/src/core/ui/canvas-toolbar.ts b/src/core/ui/canvas-toolbar.ts index c2dcef8b..7026adaa 100644 --- a/src/core/ui/canvas-toolbar.ts +++ b/src/core/ui/canvas-toolbar.ts @@ -1,8 +1,7 @@ import type { Edit } from "@core/edit"; import type { MergeField } from "@core/merge"; import { validateAssetUrl } from "@core/shared/utils"; - -import { CANVAS_TOOLBAR_STYLES } from "./canvas-toolbar.css"; +import { injectShotstackStyles } from "@styles/inject"; type ResolutionChangeCallback = (width: number, height: number) => void; type FpsChangeCallback = (fps: number) => void; @@ -49,7 +48,6 @@ const ICONS = { export class CanvasToolbar { private container: HTMLDivElement | null = null; - private styleElement: HTMLStyleElement | null = null; private edit: Edit | null = null; // Current state @@ -99,7 +97,7 @@ export class CanvasToolbar { constructor(edit?: Edit) { this.edit = edit ?? null; - this.injectStyles(); + injectShotstackStyles(); } setPosition(viewportWidth: number, editRightEdge: number): void { @@ -110,15 +108,6 @@ export class CanvasToolbar { } } - private injectStyles(): void { - if (document.getElementById("ss-canvas-toolbar-styles")) return; - - this.styleElement = document.createElement("style"); - this.styleElement.id = "ss-canvas-toolbar-styles"; - this.styleElement.textContent = CANVAS_TOOLBAR_STYLES; - document.head.appendChild(this.styleElement); - } - mount(parent: HTMLElement): void { this.container?.remove(); diff --git a/src/core/ui/font-color-picker.ts b/src/core/ui/font-color-picker.ts index 1bb3aff6..d61baebf 100644 --- a/src/core/ui/font-color-picker.ts +++ b/src/core/ui/font-color-picker.ts @@ -1,4 +1,4 @@ -import { FONT_COLOR_PICKER_STYLES } from "./font-color-picker.css"; +import { injectShotstackStyles } from "@styles/inject"; type GradientPreset = { type: "linear"; @@ -319,8 +319,6 @@ type FontColorChangeCallback = (updates: { export class FontColorPicker { private container: HTMLDivElement | null = null; - private currentMode: ColorMode = "color"; - private styleElement: HTMLStyleElement | null = null; // Tab buttons private colorTab: HTMLButtonElement | null = null; @@ -341,16 +339,7 @@ export class FontColorPicker { private onColorChange: FontColorChangeCallback | null = null; constructor() { - this.injectStyles(); - } - - private injectStyles(): void { - if (document.getElementById("ss-font-color-picker-styles")) return; - - this.styleElement = document.createElement("style"); - this.styleElement.id = "ss-font-color-picker-styles"; - this.styleElement.textContent = FONT_COLOR_PICKER_STYLES; - document.head.appendChild(this.styleElement); + injectShotstackStyles(); } mount(parent: HTMLElement): void { @@ -480,8 +469,6 @@ export class FontColorPicker { } setMode(mode: ColorMode): void { - this.currentMode = mode; - // Update tab buttons if (mode === "color") { this.colorTab?.classList.add("active"); @@ -530,10 +517,6 @@ export class FontColorPicker { this.colorOpacitySlider = null; this.colorOpacityValue = null; this.highlightColorInput = null; - - this.styleElement?.remove(); - this.styleElement = null; - this.onColorChange = null; } } diff --git a/src/core/ui/media-toolbar.ts b/src/core/ui/media-toolbar.ts index bc27c018..5dd56d84 100644 --- a/src/core/ui/media-toolbar.ts +++ b/src/core/ui/media-toolbar.ts @@ -1,7 +1,7 @@ import { validateAssetUrl } from "@core/shared/utils"; +import { injectShotstackStyles } from "@styles/inject"; import { BaseToolbar } from "./base-toolbar"; -import { MEDIA_TOOLBAR_STYLES } from "./media-toolbar.css"; type FitValue = "crop" | "cover" | "contain" | "none"; @@ -130,7 +130,7 @@ export class MediaToolbar extends BaseToolbar { private originalSrc: string = ""; override mount(parent: HTMLElement): void { - this.injectStyles("ss-media-toolbar-styles", MEDIA_TOOLBAR_STYLES); + injectShotstackStyles(); this.container = document.createElement("div"); this.container.className = "ss-media-toolbar"; diff --git a/src/core/ui/rich-text-toolbar.ts b/src/core/ui/rich-text-toolbar.ts index b0d16fd1..59e0b25b 100644 --- a/src/core/ui/rich-text-toolbar.ts +++ b/src/core/ui/rich-text-toolbar.ts @@ -1,11 +1,11 @@ import type { MergeField } from "@core/merge"; import type { ResolvedClip } from "@schemas/clip"; import type { RichTextAsset } from "@schemas/rich-text-asset"; +import { injectShotstackStyles } from "@styles/inject"; import { BackgroundColorPicker } from "./background-color-picker"; import { BaseToolbar, BUILT_IN_FONTS, FONT_SIZES } from "./base-toolbar"; import { FontColorPicker } from "./font-color-picker"; -import { TOOLBAR_STYLES } from "./rich-text-toolbar.css"; export class RichTextToolbar extends BaseToolbar { private fontPopup: HTMLDivElement | null = null; @@ -110,7 +110,7 @@ export class RichTextToolbar extends BaseToolbar { private effectSpeedValueLabel: HTMLSpanElement | null = null; override mount(parent: HTMLElement): void { - this.injectStyles("ss-toolbar-styles", TOOLBAR_STYLES); + injectShotstackStyles(); this.container = document.createElement("div"); this.container.className = "ss-toolbar"; diff --git a/src/core/ui/text-toolbar.css.ts b/src/core/ui/text-toolbar.css.ts deleted file mode 100644 index cd182cfe..00000000 --- a/src/core/ui/text-toolbar.css.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export the shared toolbar styles - text toolbar uses the same design system -export { TOOLBAR_STYLES as TEXT_TOOLBAR_STYLES } from "./rich-text-toolbar.css"; diff --git a/src/core/ui/text-toolbar.ts b/src/core/ui/text-toolbar.ts index 564d8b97..d539ea8a 100644 --- a/src/core/ui/text-toolbar.ts +++ b/src/core/ui/text-toolbar.ts @@ -1,7 +1,7 @@ import type { TextAsset } from "@schemas/text-asset"; +import { injectShotstackStyles } from "@styles/inject"; import { BaseToolbar, BUILT_IN_FONTS, FONT_SIZES, TOOLBAR_ICONS } from "./base-toolbar"; -import { TEXT_TOOLBAR_STYLES } from "./text-toolbar.css"; export class TextToolbar extends BaseToolbar { // Text edit @@ -87,7 +87,7 @@ export class TextToolbar extends BaseToolbar { private effectSpeedValueLabel: HTMLSpanElement | null = null; override mount(parent: HTMLElement): void { - this.injectStyles("ss-text-toolbar-styles", TEXT_TOOLBAR_STYLES); + injectShotstackStyles(); this.container = document.createElement("div"); this.container.className = "ss-toolbar ss-text-toolbar"; diff --git a/src/styles/index.css b/src/styles/index.css new file mode 100644 index 00000000..6b32afc6 --- /dev/null +++ b/src/styles/index.css @@ -0,0 +1,17 @@ +/** + * Shotstack Studio SDK Styles + * + * All component styles are imported here and bundled together. + * The order matters - shared styles first, then components. + */ + +/* UI Component Styles */ +@import "./ui/rich-text-toolbar.css"; +@import "./ui/media-toolbar.css"; +@import "./ui/canvas-toolbar.css"; +@import "./ui/asset-toolbar.css"; +@import "./ui/font-color-picker.css"; +@import "./ui/background-color-picker.css"; + +/* Timeline Styles */ +@import "./timeline/timeline.css"; diff --git a/src/styles/inject.ts b/src/styles/inject.ts new file mode 100644 index 00000000..4f7eef82 --- /dev/null +++ b/src/styles/inject.ts @@ -0,0 +1,37 @@ +import styles from "./index.css?inline"; + +let injected = false; + +/** + * Injects the Shotstack Studio SDK styles into the document head. + * This function is idempotent - calling it multiple times has no effect. + * + * The styles are bundled inline via Vite's ?inline import and injected + * as a single

kKH~W?c845P6D*_Z(<;I1!I;NNxzbmK`8`k! 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

}xboZlAZm-|6xDSr9Q7a5%cm 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