From 613417143bdd7d48f8df278c974a11a93e024789 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 25 Sep 2026 10:29:23 +1000 Subject: [PATCH] fix: show relevant help for timeline preview errors --- .../components/clip/clip-component.ts | 31 ++++++++++--------- src/components/timeline/error-messages.ts | 8 +++-- src/styles/timeline/timeline.css | 6 +++- tests/error-messages.test.ts | 5 +++ tests/timeline-preview-errors.test.ts | 18 ++++++++++- 5 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/components/timeline/components/clip/clip-component.ts b/src/components/timeline/components/clip/clip-component.ts index 9ed86635..fd8cd5c6 100644 --- a/src/components/timeline/components/clip/clip-component.ts +++ b/src/components/timeline/components/clip/clip-component.ts @@ -258,25 +258,28 @@ export class ClipComponent { this.currentError = error; this.element.classList.add("ss-clip--error"); - // Create error badge if needed - if (!this.errorBadge) { - const badge = document.createElement("a"); + this.errorBadge?.remove(); + const message = formatClipErrorMessage(error.error, error.assetType); + const badge = document.createElement(error.error.startsWith("CORS may be blocking") ? "a" : "span"); + if (badge instanceof HTMLAnchorElement) { badge.href = "https://t.shotstack.io/cors"; badge.target = "_blank"; badge.rel = "noopener noreferrer"; badge.setAttribute("aria-label", "Fix this clip’s preview (opens in a new tab)"); - badge.addEventListener("pointerdown", e => e.stopPropagation()); - badge.addEventListener("keydown", e => e.stopPropagation()); - badge.addEventListener("click", e => e.stopPropagation()); - badge.addEventListener("contextmenu", e => e.stopPropagation()); - this.errorBadge = badge; - this.errorBadge.className = "ss-clip-error-badge"; - this.errorBadge.textContent = "⚠"; - this.element.appendChild(this.errorBadge); + } else { + badge.tabIndex = 0; + badge.setAttribute("role", "img"); + badge.setAttribute("aria-label", message); } - - // User-friendly tooltip - this.errorBadge.title = formatClipErrorMessage(error.error, error.assetType); + badge.addEventListener("pointerdown", e => e.stopPropagation()); + badge.addEventListener("keydown", e => e.stopPropagation()); + badge.addEventListener("click", e => e.stopPropagation()); + badge.addEventListener("contextmenu", e => e.stopPropagation()); + badge.className = "ss-clip-error-badge"; + badge.textContent = "⚠"; + badge.title = message; + this.errorBadge = badge; + this.element.appendChild(badge); } else if (!error && this.currentError) { // Clear error state this.currentError = null; diff --git a/src/components/timeline/error-messages.ts b/src/components/timeline/error-messages.ts index 00746fe2..4d96f5a4 100644 --- a/src/components/timeline/error-messages.ts +++ b/src/components/timeline/error-messages.ts @@ -24,6 +24,10 @@ export function extractFilenameFromError(error: string): string | null { * Detects wrong file type scenarios and provides helpful suggestions. */ export function formatClipErrorMessage(error: string, assetType: string): string { + if (error === "Overlapping keyframes detected.") { + return "⚠️ Animation keyframes overlap\n\nThis clip couldn't be loaded.\n\nEach animation segment must finish before the next begins, or meet it at the same time."; + } + const filename = extractFilenameFromError(error); const fileExt = filename?.split(".").pop()?.toLowerCase(); if (error.startsWith("CORS may be blocking")) { @@ -72,9 +76,9 @@ export function formatClipErrorMessage(error: string, assetType: string): string // Generic file load error if (filename) { - return `⚠️ Couldn't load file\n\n"${filename}" failed to load.\n\nCheck that the file exists and the link is correct.`; + return `⚠️ Couldn't load file\n\n"${filename}" failed to load.\n\nCheck that the file exists and the link is correct.\n\n${error}`; } // Fallback for unknown errors - return `⚠️ Something went wrong\n\nThis clip couldn't be loaded.\n\nPlease check your media files.`; + return `⚠️ Something went wrong\n\nThis clip couldn't be loaded.\n\n${error}`; } diff --git a/src/styles/timeline/timeline.css b/src/styles/timeline/timeline.css index 77101413..eedba347 100644 --- a/src/styles/timeline/timeline.css +++ b/src/styles/timeline/timeline.css @@ -794,11 +794,15 @@ font-size: 12px; color: #fff; z-index: 10; - cursor: pointer; + cursor: help; text-decoration: none; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); } +a.ss-clip-error-badge { + cursor: pointer; +} + .ss-clip-error-badge:focus-visible { outline: 2px solid #374151; outline-offset: 3px; diff --git a/tests/error-messages.test.ts b/tests/error-messages.test.ts index cf16ca53..7ab97233 100644 --- a/tests/error-messages.test.ts +++ b/tests/error-messages.test.ts @@ -121,6 +121,11 @@ describe("formatClipErrorMessage", () => { }); describe("generic errors", () => { + it("preserves the underlying failure when a source URL is present", () => { + const error = 'Decoder failed for "https://example.com/video.mp4"'; + expect(formatClipErrorMessage(error, "video")).toContain(error); + }); + it("should return generic message for unknown file with URL", () => { const error = 'Failed: "https://example.com/file.xyz"'; const message = formatClipErrorMessage(error, "image"); diff --git a/tests/timeline-preview-errors.test.ts b/tests/timeline-preview-errors.test.ts index 9e210db0..1b028296 100644 --- a/tests/timeline-preview-errors.test.ts +++ b/tests/timeline-preview-errors.test.ts @@ -8,6 +8,7 @@ it("updates a paused timeline on asset failure and recovery, with accessible hel const tracker = new AssetLoadTracker(); const events = new EventEmitter(); const source = "https://example.com/video.mp4"; + let loadError = { error: "Overlapping keyframes detected.", assetType: "svg" }; const config = { timeline: { tracks: [{ clips: [{ id: "clip-1", asset: { type: "video", src: source }, start: 0, length: 5 }] }] } }; const edit = { events, @@ -17,7 +18,7 @@ it("updates a paused timeline on asset failure and recovery, with accessible hel getEdit: () => config, isClipSelected: () => false, getClipGenerationState: () => undefined, - getClipError: () => (tracker.registry[source]?.status === "failed" ? { error: `CORS may be blocking '${source}'.`, assetType: "video" } : null), + getClipError: () => (tracker.registry[source]?.status === "failed" ? loadError : null), playbackTime: sec(0), totalDuration: sec(5), isPlaying: false @@ -29,6 +30,14 @@ it("updates a paused timeline on asset failure and recovery, with accessible hel await timeline.load(); tracker.registry[source] = { status: "failed", progress: 1 }; tracker.emit("onAssetLoadInfoUpdated", { registry: tracker.registry }); + const animationBadge = container.querySelector(".ss-clip-error-badge"); + expect(animationBadge?.title).toContain("Animation keyframes overlap"); + expect(animationBadge?.hasAttribute("href")).toBe(false); + expect(animationBadge?.tabIndex).toBe(0); + expect(animationBadge?.getAttribute("aria-label")).toContain("Animation keyframes overlap"); + + loadError = { error: `CORS may be blocking '${source}'.`, assetType: "video" }; + tracker.emit("onAssetLoadInfoUpdated", { registry: tracker.registry }); const badge = container.querySelector("a.ss-clip-error-badge"); expect(badge).not.toBeNull(); expect(badge?.getAttribute("aria-label")).toContain("preview"); @@ -45,6 +54,13 @@ it("updates a paused timeline on asset failure and recovery, with accessible hel expect(contextMenu.defaultPrevented).toBe(false); expect(badge?.href).toBe("https://t.shotstack.io/cors"); expect(badge?.target).toBe("_blank"); + loadError = { error: "Failed to load SVG image", assetType: "svg" }; + tracker.emit("onAssetLoadInfoUpdated", { registry: tracker.registry }); + const svgBadge = container.querySelector(".ss-clip-error-badge"); + expect(svgBadge?.hasAttribute("href")).toBe(false); + expect(svgBadge?.title).toContain(loadError.error); + expect(svgBadge?.getAttribute("aria-label")).toContain(loadError.error); + expect(container.querySelectorAll(".ss-clip-error-badge")).toHaveLength(1); tracker.registry[source] = { status: "success", progress: 1 }; tracker.emit("onAssetLoadInfoUpdated", { registry: tracker.registry }); expect(container.querySelector(".ss-clip-error-badge")).toBeNull();