mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix: video thumbnail
This commit is contained in:
@@ -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)",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user