diff --git a/.gitignore b/.gitignore index b60f8604..37122328 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,8 @@ test-results e2e/report # Provisioned at build/test time from node_modules/pdfjs-dist public/pdf.worker.mjs +# Provisioned at build/test time from node_modules/@hevcjs/core +public/hevc/ # Generated by `yarn generate-test-pdfs` public/storybook/preview-files/pdf_with_links.pdf public/storybook/preview-files/pdf_with_js.pdf diff --git a/e2e/file-preview/hevc-video-preview.spec.tsx b/e2e/file-preview/hevc-video-preview.spec.tsx new file mode 100644 index 00000000..1091329f --- /dev/null +++ b/e2e/file-preview/hevc-video-preview.spec.tsx @@ -0,0 +1,53 @@ +import { test, expect } from "@playwright/experimental-ct-react"; +import { TestFilePreview } from "../helpers/mount-preview"; +import { hevcVideoFile } from "../helpers/fixtures"; + +test.describe("HEVC Video Preview", () => { + test("Renders the video player inside the video wrapper", async ({ + mount, + page, + }) => { + await mount(); + await expect(page.getByTestId("file-preview")).toBeVisible({ + timeout: 10000, + }); + + const wrapper = page.locator(".video-preview-viewer-container"); + await expect(wrapper).toBeVisible(); + await expect(wrapper.locator(".video-player")).toBeVisible(); + }); + + test("Plays the HEVC video (transcoding if needed) or falls back to download", async ({ + mount, + page, + }) => { + await mount(); + await expect(page.getByTestId("file-preview")).toBeVisible({ + timeout: 10000, + }); + + // Native-first, then a lazy-loaded WASM transcode — behaviour depends on + // the browser (native HEVC on Safari, transcode on Chrome, download + // fallback where WebCodecs H.264 isn't available). We assert it always + // resolves to a usable state and never gets stuck on the spinner: either a + // video with decoded frames, or the download fallback. + await expect + .poll( + async () => { + const fellBack = await page + .getByText("not supported for preview") + .isVisible() + .catch(() => false); + if (fellBack) return "fallback"; + + const decoded = await page + .locator("video.video-player__video") + .evaluate((el) => (el as HTMLVideoElement).videoWidth > 0) + .catch(() => false); + return decoded ? "playing" : "pending"; + }, + { timeout: 45000, intervals: [500] } + ) + .not.toBe("pending"); + }); +}); diff --git a/e2e/helpers/fixtures.ts b/e2e/helpers/fixtures.ts index ec69e29e..898600af 100644 --- a/e2e/helpers/fixtures.ts +++ b/e2e/helpers/fixtures.ts @@ -6,7 +6,7 @@ const file = ( id: string, title: string, mimetype: string, - extras: Partial = {}, + extras: Partial = {} ): FilePreviewType => ({ id, title, @@ -21,10 +21,14 @@ export const imageFile = file("img-monet", "monet_1.jpeg", "image/jpeg"); export const videoFile = file("vid-chantier", "chantier.mp4", "video/mp4"); +// HEVC/H.265 in a plain mp4 container (codec not in the mimetype, like a real +// upload). Played natively where supported, transcoded to H.264 otherwise. +export const hevcVideoFile = file("vid-hevc", "chantier-hevc.mp4", "video/mp4"); + export const audioFile = file( "aud-noise", "nuissance_sonores.mp3", - "audio/mpeg", + "audio/mpeg" ); export const pdfFile = file("pdf-pvcm", "pv_cm.pdf", "application/pdf"); @@ -32,37 +36,33 @@ export const pdfFile = file("pdf-pvcm", "pv_cm.pdf", "application/pdf"); export const pdfCmFile = file( "pdf-cm", "CR Conseil municipal vfvdef_maireok.pdf", - "application/pdf", + "application/pdf" ); export const pdfMixedFile = file( "pdf-mixed", "mixed_page_sizes.pdf", - "application/pdf", + "application/pdf" ); export const pdfLinksFile = file( "pdf-links", "pdf_with_links.pdf", - "application/pdf", + "application/pdf" ); -export const pdfJsFile = file( - "pdf-js", - "pdf_with_js.pdf", - "application/pdf", -); +export const pdfJsFile = file("pdf-js", "pdf_with_js.pdf", "application/pdf"); export const pdfJsLinkFile = file( "pdf-jslink", "pdf_with_js_link.pdf", - "application/pdf", + "application/pdf" ); export const pdfCorruptedFile = file( "pdf-corrupted", "pdf_corrupted.pdf", - "application/pdf", + "application/pdf" ); export const heicFile = file("heic-img", "IMG_7665.heic", "image/heic"); @@ -70,5 +70,5 @@ export const heicFile = file("heic-img", "IMG_7665.heic", "image/heic"); export const unsupportedFile = file( "bin-unsupported", "test-unsupported.bin", - "application/octet-stream", + "application/octet-stream" ); diff --git a/package.json b/package.json index 616b80d7..86745e85 100644 --- a/package.json +++ b/package.json @@ -36,12 +36,16 @@ "test": "vitest run", "build-theme": "cunningham -g css,scss,ts -o src && mv src/cunningham-tokens.scss src/cunningham-tokens-sass.scss", "copy-pdf-worker": "shx cp node_modules/pdfjs-dist/build/pdf.worker.mjs public/pdf.worker.mjs", - "prestorybook": "yarn copy-pdf-worker", + "copy-hevc-worker": "shx mkdir -p public/hevc && shx cp node_modules/@hevcjs/core/dist/transcode-worker.js node_modules/@hevcjs/core/dist/wasm/hevc-decode.js node_modules/@hevcjs/core/dist/wasm/hevc-decode.wasm public/hevc/", + "copy-preview-workers": "yarn copy-pdf-worker && yarn copy-hevc-worker", + "prestorybook": "yarn copy-preview-workers", "storybook": "storybook dev -p 6006", + "prebuild-storybook": "yarn copy-preview-workers", "build-storybook": "storybook build", "generate-icons": "tsx scripts/generate-icons.ts", - "prescalingo-postbuild": "yarn copy-pdf-worker", + "prescalingo-postbuild": "yarn copy-preview-workers", "scalingo-postbuild": "yarn build && yarn build-storybook", + "pretest:ct": "yarn copy-preview-workers", "test:ct": "playwright test -c playwright.config.ts", "test:ct:ui": "playwright test -c playwright.config.ts --ui", "generate-test-pdfs": "node scripts/generate-test-pdfs.mjs" @@ -54,20 +58,22 @@ "@gouvfr-lasuite/cunningham-react": "4.3.0", "@gouvfr-lasuite/cunningham-tokens": "3.1.0", "@gouvfr-lasuite/integration": "1.0.2", + "@hevcjs/core": "^1.3.0", "@types/node": "22.10.7", "clsx": "2.1.1", "cmdk": "1.0.4", + "mp4box": "^2.3.0", "react-arborist": "3.4.3", "react-aria-components": "1.16.0", + "react-pdf": "10.1.0", "react-resizable-panels": "2.1.7", "react-stately": "3.37.0", - "react-virtualized": "9.22.6", - "react-pdf": "10.1.0" + "react-virtualized": "9.22.6" }, "peerDependencies": { + "@tanstack/react-query": "^5", "react": "^19.1.2", - "react-dom": "^19.1.2", - "@tanstack/react-query": "^5" + "react-dom": "^19.1.2" }, "devDependencies": { "@babel/core": "7.26.0", @@ -76,6 +82,8 @@ "@babel/preset-typescript": "7.26.0", "@chromatic-com/storybook": "3.2.4", "@eslint/js": "9.17.0", + "@playwright/experimental-ct-react": "1.52.0", + "@playwright/test": "1.52.0", "@storybook/addon-a11y": "8.5.2", "@storybook/addon-essentials": "8.5.2", "@storybook/addon-interactions": "8.5.2", @@ -104,6 +112,7 @@ "glob": "11.0.0", "globals": "15.14.0", "jsdom": "25.0.1", + "pdf-lib": "1.17.1", "react": "19.1.2", "react-dom": "19.1.2", "react-hook-form": "7.54.2", @@ -122,10 +131,7 @@ "vite-tsconfig-paths": "5.1.4", "vitest": "2.1.8", "vitest-fetch-mock": "0.4.3", - "yup": "1.6.1", - "@playwright/experimental-ct-react": "1.52.0", - "@playwright/test": "1.52.0", - "pdf-lib": "1.17.1" + "yup": "1.6.1" }, "eslintConfig": { "extends": [ diff --git a/public/storybook/preview-files/chantier-hevc.mp4 b/public/storybook/preview-files/chantier-hevc.mp4 new file mode 100644 index 00000000..445a2e99 Binary files /dev/null and b/public/storybook/preview-files/chantier-hevc.mp4 differ diff --git a/public/storybook/preview-files/iphone-hevc.mov b/public/storybook/preview-files/iphone-hevc.mov new file mode 100644 index 00000000..8e32e5b1 Binary files /dev/null and b/public/storybook/preview-files/iphone-hevc.mov differ diff --git a/src/components/preview/FilePreview.tsx b/src/components/preview/FilePreview.tsx index 326e5ed8..68420c1b 100644 --- a/src/components/preview/FilePreview.tsx +++ b/src/components/preview/FilePreview.tsx @@ -54,7 +54,7 @@ const PreviewPdf = lazy(() => }; } throw err; - }), + }) ); export type { FilePreviewType } from "./types"; @@ -79,6 +79,11 @@ interface FilePreviewProps { hideCloseButton?: boolean; pdfWorkerSrc?: string; onOpenInEditor?: (file: FilePreviewType) => void; + /** + * Force HEVC videos through the in-browser transcoder even when the browser + * could decode them natively. Mainly for testing/demoing the transcode path. + */ + forceVideoTranscode?: boolean; } export const FilePreview = ({ @@ -97,6 +102,7 @@ export const FilePreview = ({ hideCloseButton, pdfWorkerSrc, onOpenInEditor, + forceVideoTranscode, }: FilePreviewProps) => { const { t } = useCustomTranslations(); const [currentIndex, setCurrentIndex] = useState(initialIndexFile); @@ -199,6 +205,19 @@ export const FilePreview = ({ src={currentFile.url_preview} className="file-preview__viewer" controls={true} + forceTranscode={forceVideoTranscode} + // Remount per file id so playback / transcoding restarts cleanly + // when switching between videos. + key={currentFile.id} + // HEVC/H.265 that the browser can't decode is transcoded to + // H.264 in-browser; if that also fails, offer a download. + unsupportedFallback={ + + } /> @@ -384,7 +403,7 @@ export const FilePreview = ({ className={clsx( "file-preview__container", isSidebarOpen && "file-preview__container--sidebar-open", - classNames, + classNames )} >
diff --git a/src/components/preview/stories/FilePreviewExample.tsx b/src/components/preview/stories/FilePreviewExample.tsx index 82fbb4f1..0ae0c056 100644 --- a/src/components/preview/stories/FilePreviewExample.tsx +++ b/src/components/preview/stories/FilePreviewExample.tsx @@ -12,6 +12,7 @@ interface FilePreviewExampleProps { initialFileId?: string; customHeaderActions?: (headerActions: ReactNode) => ReactNode; headerActionsMenuOptions?: (file: FilePreviewType) => MenuItemAction[]; + forceVideoTranscode?: boolean; } export const FilePreviewExample = ({ @@ -19,9 +20,10 @@ export const FilePreviewExample = ({ initialFileId, customHeaderActions, headerActionsMenuOptions, + forceVideoTranscode, }: FilePreviewExampleProps) => { const [openedFileId, setOpenedFileId] = useState( - initialFileId ?? files[0]?.id, + initialFileId ?? files[0]?.id ); useEffect(() => { @@ -52,6 +54,7 @@ export const FilePreviewExample = ({ } customHeaderActions={customHeaderActions} headerActionsMenuOptions={headerActionsMenuOptions} + forceVideoTranscode={forceVideoTranscode} />
diff --git a/src/components/preview/stories/file-preview.stories.tsx b/src/components/preview/stories/file-preview.stories.tsx index 5a303905..a59fa573 100644 --- a/src/components/preview/stories/file-preview.stories.tsx +++ b/src/components/preview/stories/file-preview.stories.tsx @@ -12,6 +12,8 @@ import { allFiles, audioFiles, heicFile, + hevcVideoFile, + iphoneVideoFile, imageFiles, pdfFiles, suspiciousFile, @@ -33,7 +35,7 @@ import { Button } from "@gouvfr-lasuite/cunningham-react"; * | Category | Viewer | Highlights | * |----------|--------|------------| * | Image | `ImageViewer` | Zoom (wheel, pinch, keyboard), pan, reset, fit-to-viewport | - * | Video | `VideoPlayer` | Custom controls, ±10s skip, volume + mute, fullscreen | + * | Video | `VideoPlayer` | Custom controls, ±10s skip, volume + mute, fullscreen. Native-first; HEVC/H.265 the browser can't decode is transcoded to H.264 in-browser via a lazy-loaded WASM codec (download fallback otherwise) | * | Audio | `AudioPlayer` | Title, seekable bar, ±10s skip, volume + mute | * | PDF | `PdfPreview` | Virtualized pages, zoom, page input, thumbnail sidebar (lazy-loaded) | * | HEIC | `NotSupportedPreview` | Fallback with download CTA (no native browser support) | @@ -262,6 +264,40 @@ export const Video: Story = { render: () => , }; +/** + * HEVC/H.265 video. The codec is not in the mimetype (`video/mp4`, like a real + * upload), so the player is native-first: it hands the file to a normal + * `