fix: video thumbnail

This commit is contained in:
Nathnael
2026-08-09 13:42:28 +00:00
parent 5e0674e2fb
commit f9f4b22a21
6 changed files with 116 additions and 6 deletions

View File

@@ -30,7 +30,9 @@ export function EditorPane({
gap="md"
p="xl"
style={{
flex: 1,
// Basis keeps the editor readable; it wraps below the rail rather than
// being crushed when the window cannot fit both side by side.
flex: "1 1 480px",
minWidth: 0,
background: "#FFFFFF",
border: "1px solid var(--mantine-color-gray-2)",

View File

@@ -121,7 +121,11 @@ export function FaqWorkspace({
};
return (
<Group align="flex-start" gap="lg" wrap="nowrap">
// Wraps rather than nowrap: with a fixed 280px rail, a narrow window
// squeezed the editor down to ~80px and every embedded picture rendered as
// an unreadable sliver. Below roughly 800px the editor now drops under the
// list instead.
<Group align="flex-start" gap="lg" wrap="wrap">
<Stack gap="xs" style={{ flexShrink: 0 }}>
<DocumentRail
items={rows}

View File

@@ -1,4 +1,4 @@
import { PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { isPortalVideoSrc, PORTAL_MEDIA_URI_SCHEME } from "@edr/types";
import { Box, Button, Group, SegmentedControl, Stack, Text } from "@mantine/core";
import {
BlockTypeSelect,
@@ -27,6 +27,7 @@ import { portalContentService } from "@/services/portal-content.service";
import { Markdown } from "./Markdown";
import { MediaDialog } from "./MediaDialog";
import { videoPosterDataUrl, VIDEO_PLACEHOLDER } from "./video-poster";
// Undoes Tailwind's preflight inside the editor's content area — see the file.
import "./markdown-editor.css";
@@ -55,7 +56,12 @@ export function resolvePreview(url: string): Promise<string> {
if (!pending) {
pending = portalContentService
.mediaUrl(key)
.catch(() => url); // show a broken image rather than blowing up the editor
// The editor draws every embed as an <img>, so a video src rendered as a
// broken box. Swap in a frame grabbed from the video itself.
.then((signed) =>
isPortalVideoSrc(key) ? videoPosterDataUrl(signed) : signed,
)
.catch(() => (isPortalVideoSrc(key) ? VIDEO_PLACEHOLDER : url));
previewCache.set(key, pending);
}
return pending;

View File

@@ -46,7 +46,11 @@ export function SectionWorkspace({
};
return (
<Group align="flex-start" gap="lg" wrap="nowrap">
// Wraps rather than nowrap: with a fixed 280px rail, a narrow window
// squeezed the editor down to ~80px and every embedded picture rendered as
// an unreadable sliver. Below roughly 800px the editor now drops under the
// list instead.
<Group align="flex-start" gap="lg" wrap="wrap">
<DocumentRail
items={sections.map((section) => ({
id: section.id,

View File

@@ -102,8 +102,18 @@
border-radius: 3px;
}
/*
* MDXEditor renders its image node with `width="inherit" height="inherit"`
* attributes, which collapsed a 720px-wide picture into a ~56x124 sliver.
* The `!important` is aimed at those attributes rather than at another
* stylesheet — attribute-derived sizing otherwise wins here.
*/
.edr-md-content img {
display: block;
width: auto !important;
height: auto !important;
max-width: 100%;
height: auto;
max-height: 420px;
border-radius: 8px;
object-fit: contain;
}

View File

@@ -0,0 +1,84 @@
/**
* A grey card with a play triangle, used when a real frame cannot be grabbed
* (the object store did not send CORS headers, the codec will not decode, or
* the seek timed out). Better than the broken-image icon the editor showed
* before, and it still says "this is a video".
*/
export const VIDEO_PLACEHOLDER =
"data:image/svg+xml;charset=utf-8," +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="640" height="360">
<rect width="640" height="360" fill="#1f2933"/>
<circle cx="320" cy="180" r="46" fill="#ffffff" fill-opacity="0.9"/>
<path d="M305 156 l40 24 -40 24 z" fill="#1f2933"/>
<text x="320" y="272" font-family="sans-serif" font-size="20"
fill="#ffffff" fill-opacity="0.75" text-anchor="middle">Video</text>
</svg>`,
);
/** Give up rather than hang the editor on a file that will not decode. */
const POSTER_TIMEOUT_MS = 8000;
/**
* Grabs a single frame from a video URL and returns it as a data URL, so the
* editor can show a real thumbnail for an embedded video.
*
* Done in the browser at preview time on purpose: the alternative is
* generating posters server-side on upload, which means ffmpeg, a second
* stored object per video and a naming convention to tie them together — all
* to produce a picture only editors ever look at. Seeking with
* `preload="metadata"` makes the browser range-request just the bytes it needs
* rather than the whole file.
*
* `crossOrigin` is required or the canvas is tainted and `toDataURL` throws;
* if the object store does not allow it we fall back to the placeholder.
*/
export function videoPosterDataUrl(url: string): Promise<string> {
return new Promise((resolve) => {
const video = document.createElement("video");
let settled = false;
const finish = (result: string) => {
if (settled) return;
settled = true;
clearTimeout(timer);
video.removeAttribute("src");
video.load();
resolve(result);
};
const timer = setTimeout(() => finish(VIDEO_PLACEHOLDER), POSTER_TIMEOUT_MS);
video.crossOrigin = "anonymous";
video.preload = "metadata";
video.muted = true;
video.playsInline = true;
video.onloadedmetadata = () => {
// A frame just past the start: the very first frame of a screen
// recording is usually an empty desktop.
video.currentTime = Math.min(1, (video.duration || 2) / 2);
};
video.onseeked = () => {
try {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
if (!canvas.width || !canvas.height) return finish(VIDEO_PLACEHOLDER);
const context = canvas.getContext("2d");
if (!context) return finish(VIDEO_PLACEHOLDER);
context.drawImage(video, 0, 0, canvas.width, canvas.height);
finish(canvas.toDataURL("image/jpeg", 0.7));
} catch {
// Tainted canvas — the object store did not send CORS headers.
finish(VIDEO_PLACEHOLDER);
}
};
video.onerror = () => finish(VIDEO_PLACEHOLDER);
video.src = url;
});
}