From f9f4b22a21d64d5ad3ae3d4c425b319aeef6bf31 Mon Sep 17 00:00:00 2001 From: Nathnael Date: Sun, 9 Aug 2026 13:42:28 +0000 Subject: [PATCH] fix: video thumbnail --- .../src/pages/portal_content/EditorPane.tsx | 4 +- .../src/pages/portal_content/FaqWorkspace.tsx | 6 +- .../pages/portal_content/MarkdownEditor.tsx | 10 ++- .../pages/portal_content/SectionWorkspace.tsx | 6 +- .../pages/portal_content/markdown-editor.css | 12 ++- .../src/pages/portal_content/video-poster.ts | 84 +++++++++++++++++++ 6 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/portal_content/video-poster.ts diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx index 8c2602867..2641fe1d1 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/EditorPane.tsx @@ -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)", diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx index 79029abf9..a6a9db56d 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/FaqWorkspace.tsx @@ -121,7 +121,11 @@ export function FaqWorkspace({ }; return ( - + // 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. + { 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 , 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; diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx b/apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx index b59e12660..d9876391e 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/SectionWorkspace.tsx @@ -46,7 +46,11 @@ export function SectionWorkspace({ }; return ( - + // 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. + ({ id: section.id, diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css b/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css index 0c5b0eb3f..e5766ff1a 100644 --- a/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/markdown-editor.css @@ -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; } diff --git a/apps/edr-freight-web/backoffice/src/pages/portal_content/video-poster.ts b/apps/edr-freight-web/backoffice/src/pages/portal_content/video-poster.ts new file mode 100644 index 000000000..646292744 --- /dev/null +++ b/apps/edr-freight-web/backoffice/src/pages/portal_content/video-poster.ts @@ -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( + ` + + + + Video + `, + ); + +/** 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 { + 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; + }); +}