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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 17 additions & 14 deletions src/components/timeline/components/clip/clip-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/components/timeline/error-messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")) {
Expand Down Expand Up @@ -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}`;
}
6 changes: 5 additions & 1 deletion src/styles/timeline/timeline.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions tests/error-messages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
18 changes: 17 additions & 1 deletion tests/timeline-preview-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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<HTMLElement>(".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<HTMLAnchorElement>("a.ss-clip-error-badge");
expect(badge).not.toBeNull();
expect(badge?.getAttribute("aria-label")).toContain("preview");
Expand All @@ -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<HTMLElement>(".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();
Expand Down
Loading