Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions e2e/file-preview/hevc-video-preview.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<TestFilePreview files={[hevcVideoFile]} />);
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(<TestFilePreview files={[hevcVideoFile]} />);
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");
});
});
26 changes: 13 additions & 13 deletions e2e/helpers/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const file = (
id: string,
title: string,
mimetype: string,
extras: Partial<FilePreviewType> = {},
extras: Partial<FilePreviewType> = {}
): FilePreviewType => ({
id,
title,
Expand All @@ -21,54 +21,54 @@ 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");

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");

export const unsupportedFile = file(
"bin-unsupported",
"test-unsupported.bin",
"application/octet-stream",
"application/octet-stream"
);
26 changes: 16 additions & 10 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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": [
Expand Down
Binary file added public/storybook/preview-files/chantier-hevc.mp4
Binary file not shown.
Binary file added public/storybook/preview-files/iphone-hevc.mov
Binary file not shown.
23 changes: 21 additions & 2 deletions src/components/preview/FilePreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
};
}
throw err;
}),
})
);

export type { FilePreviewType } from "./types";
Expand All @@ -79,6 +79,11 @@
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 = ({
Expand All @@ -97,6 +102,7 @@
hideCloseButton,
pdfWorkerSrc,
onOpenInEditor,
forceVideoTranscode,
}: FilePreviewProps) => {
const { t } = useCustomTranslations();
const [currentIndex, setCurrentIndex] = useState(initialIndexFile);
Expand Down Expand Up @@ -199,6 +205,19 @@
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={
<NotSupportedPreview
title={t("components.filePreview.unsupported.videoTitle")}
file={currentFile}
onDownload={handleDownload}
/>
}
/>
</div>
</div>
Expand Down Expand Up @@ -283,7 +302,7 @@
if (currentFile) {
onFileOpen?.(currentFile);
}
}, [currentFile]);

Check warning on line 305 in src/components/preview/FilePreview.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'onChangeFile' and 'onFileOpen'. Either include them or remove the dependency array. If 'onChangeFile' changes too often, find the parent component that defines it and wrap that definition in useCallback

useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
Expand All @@ -303,7 +322,7 @@

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentIndex, data.length, currentFile?.category]);

Check warning on line 325 in src/components/preview/FilePreview.tsx

View workflow job for this annotation

GitHub Actions / lint

React Hook useEffect has missing dependencies: 'goToNext' and 'goToPrevious'. Either include them or remove the dependency array

useEffect(() => {
if (!isOpen || !currentFile) return;
Expand Down Expand Up @@ -384,7 +403,7 @@
className={clsx(
"file-preview__container",
isSidebarOpen && "file-preview__container--sidebar-open",
classNames,
classNames
)}
>
<div className="file-preview__header">
Expand Down
5 changes: 4 additions & 1 deletion src/components/preview/stories/FilePreviewExample.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,18 @@ interface FilePreviewExampleProps {
initialFileId?: string;
customHeaderActions?: (headerActions: ReactNode) => ReactNode;
headerActionsMenuOptions?: (file: FilePreviewType) => MenuItemAction[];
forceVideoTranscode?: boolean;
}

export const FilePreviewExample = ({
files,
initialFileId,
customHeaderActions,
headerActionsMenuOptions,
forceVideoTranscode,
}: FilePreviewExampleProps) => {
const [openedFileId, setOpenedFileId] = useState<string | undefined>(
initialFileId ?? files[0]?.id,
initialFileId ?? files[0]?.id
);

useEffect(() => {
Expand Down Expand Up @@ -52,6 +54,7 @@ export const FilePreviewExample = ({
}
customHeaderActions={customHeaderActions}
headerActionsMenuOptions={headerActionsMenuOptions}
forceVideoTranscode={forceVideoTranscode}
/>
</div>
</QueryClientProvider>
Expand Down
38 changes: 37 additions & 1 deletion src/components/preview/stories/file-preview.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
allFiles,
audioFiles,
heicFile,
hevcVideoFile,
iphoneVideoFile,
imageFiles,
pdfFiles,
suspiciousFile,
Expand All @@ -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) |
Expand Down Expand Up @@ -262,6 +264,40 @@ export const Video: Story = {
render: () => <FilePreviewExample files={videoFiles} />,
};

/**
* 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
* `<video>` and only when that fails to decode does it lazy-load a WASM
* transcoder (`@hevcjs/core`) that converts the video to H.264 on the fly and
* plays it through Media Source Extensions, with the original audio passed
* through untouched. Safari plays HEVC natively (no transcode); browsers
* without WebCodecs fall back to a download prompt. A brief "preparing"
* spinner shows while the first segments transcode.
*
* Two samples (prev/next): a standard 8-bit clip, and a real iPhone capture
* (10-bit HDR, portrait, QuickTime container with AAC). The transcoder carries
* the source rotation, real duration and AAC audio across, so the result
* renders the same whether played natively or transcoded.
*/
export const HevcVideo: Story = {
render: () => <FilePreviewExample files={[hevcVideoFile, iphoneVideoFile]} />,
};

/**
* Forces the in-browser HEVC → H.264 transcoding path (via `forceVideoTranscode`),
* skipping the native attempt. Renders through the same `FilePreview` as every
* other story (identical chrome), so it's useful to exercise the transcoder on
* browsers that decode HEVC natively (Safari, or Chrome/Brave on Apple Silicon)
* where the native-first stories would just play the file directly. Uses the
* standard 8-bit clip; the iPhone sample in `HevcVideo` exercises the full case
* (rotation + audio + seekbar + HDR colour).
*/
export const HevcVideoForcedTranscode: Story = {
render: () => (
<FilePreviewExample files={[hevcVideoFile]} forceVideoTranscode />
),
};

/**
* Audio player with a title display, seekable duration bar, play/pause,
* 10-second skip forward/backward, and volume controls with mute toggle.
Expand Down
Loading
Loading