mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 05:25:41 +00:00
fix: video thumbnail
This commit is contained in:
@@ -30,7 +30,9 @@ export function EditorPane({
|
|||||||
gap="md"
|
gap="md"
|
||||||
p="xl"
|
p="xl"
|
||||||
style={{
|
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,
|
minWidth: 0,
|
||||||
background: "#FFFFFF",
|
background: "#FFFFFF",
|
||||||
border: "1px solid var(--mantine-color-gray-2)",
|
border: "1px solid var(--mantine-color-gray-2)",
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ export function FaqWorkspace({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 }}>
|
<Stack gap="xs" style={{ flexShrink: 0 }}>
|
||||||
<DocumentRail
|
<DocumentRail
|
||||||
items={rows}
|
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 { Box, Button, Group, SegmentedControl, Stack, Text } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
BlockTypeSelect,
|
BlockTypeSelect,
|
||||||
@@ -27,6 +27,7 @@ import { portalContentService } from "@/services/portal-content.service";
|
|||||||
|
|
||||||
import { Markdown } from "./Markdown";
|
import { Markdown } from "./Markdown";
|
||||||
import { MediaDialog } from "./MediaDialog";
|
import { MediaDialog } from "./MediaDialog";
|
||||||
|
import { videoPosterDataUrl, VIDEO_PLACEHOLDER } from "./video-poster";
|
||||||
// Undoes Tailwind's preflight inside the editor's content area — see the file.
|
// Undoes Tailwind's preflight inside the editor's content area — see the file.
|
||||||
import "./markdown-editor.css";
|
import "./markdown-editor.css";
|
||||||
|
|
||||||
@@ -55,7 +56,12 @@ export function resolvePreview(url: string): Promise<string> {
|
|||||||
if (!pending) {
|
if (!pending) {
|
||||||
pending = portalContentService
|
pending = portalContentService
|
||||||
.mediaUrl(key)
|
.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);
|
previewCache.set(key, pending);
|
||||||
}
|
}
|
||||||
return pending;
|
return pending;
|
||||||
|
|||||||
@@ -46,7 +46,11 @@ export function SectionWorkspace({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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
|
<DocumentRail
|
||||||
items={sections.map((section) => ({
|
items={sections.map((section) => ({
|
||||||
id: section.id,
|
id: section.id,
|
||||||
|
|||||||
@@ -102,8 +102,18 @@
|
|||||||
border-radius: 3px;
|
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 {
|
.edr-md-content img {
|
||||||
|
display: block;
|
||||||
|
width: auto !important;
|
||||||
|
height: auto !important;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: auto;
|
max-height: 420px;
|
||||||
border-radius: 8px;
|
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