From 8a6a4b2343ce5122ed330c52ec260dce64ffac3e Mon Sep 17 00:00:00 2001 From: Maxime Baconnais Date: Thu, 14 Mar 2024 00:29:58 +0100 Subject: [PATCH] Support web export --- README.md | 17 +++--- electron.vite.config.mjs | 40 ++++++------- package-lock.json | 18 ++++++ package.json | 1 + src/common/ffmpeg.js | 96 +++++++++++++++++++++++++++++++ src/main/actions.js | 17 +++++- src/main/core/ffmpeg.js | 95 ++---------------------------- src/renderer/actions/ffmpeg.js | 17 ++++++ src/renderer/actions/index.js | 99 +++++++++++++------------------- src/renderer/actions/projects.js | 19 ------ src/renderer/views/Export.jsx | 10 ++-- vite.config.mjs | 15 ++++- 12 files changed, 240 insertions(+), 204 deletions(-) create mode 100644 src/common/ffmpeg.js create mode 100644 src/renderer/actions/ffmpeg.js diff --git a/README.md b/README.md index 3a6a698..3aea91e 100644 --- a/README.md +++ b/README.md @@ -68,11 +68,12 @@ To improve the quality of **Eagle Animation**, runtime errors are automatically Some features are device-dependent or platform-limited. Here's a summary table. -| Feature | Windows | MacOS | Linux | Web (Chrome) | Web (Firefox) | Web (Safari) | -| -------------------------- | ------- | ----- | ----- | ------------ | ------------- | ------------ | -| Video export | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | -| Frames export | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | -| High-quality photo capture | ✅ | ✅ | ✅ | ✅ | ❌ | ❓ | -| Workshop features | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | -| Control webcam settings | ✅ | ❓ | ❌ | ✅ | ❌ | ❓ | -| Use Canon cameras | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Feature | Windows | MacOS | Linux | Web (Chrome / Edge) | Web (Firefox) | Web (Safari) | +| ------------------------------------------------- | ------- | ----- | ----- | ------------------- | ------------- | ------------ | +| Use webcam to take photos | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Export captured frames | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Video export | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| Improve quality by reducing the preview framerate | ✅ | ✅ | ✅ | ✅ | ❌ | ❓ | +| Control webcam settings | ✅ | ✅ | ✅ | ✅ | ❌ | ❓ | +| Workshop features | ✅ | ✅ | ✅ | ❌ | ❌ | ❌ | +| Use Canon camera to take photos | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | diff --git a/electron.vite.config.mjs b/electron.vite.config.mjs index 41743ef..caef4dd 100644 --- a/electron.vite.config.mjs +++ b/electron.vite.config.mjs @@ -1,27 +1,27 @@ -import { resolve } from 'node:path' -import { defineConfig, externalizeDepsPlugin } from 'electron-vite' -import react from '@vitejs/plugin-react' +import { resolve } from 'node:path'; +import { defineConfig, externalizeDepsPlugin } from 'electron-vite'; +import react from '@vitejs/plugin-react'; import { viteStaticCopy } from 'vite-plugin-static-copy'; import svgr from 'vite-plugin-svgr'; -import { normalizePath } from 'vite' +import { normalizePath } from 'vite'; export default defineConfig({ main: { build: { rollupOptions: { - external: ['@brick-a-brack/napi-canon-cameras'] - } + external: ['@brick-a-brack/napi-canon-cameras'], + }, }, - plugins: [externalizeDepsPlugin()] + plugins: [externalizeDepsPlugin()], }, preload: { - plugins: [externalizeDepsPlugin()] + plugins: [externalizeDepsPlugin()], }, renderer: { resolve: { alias: { - '~': resolve(__dirname) - } + '~': resolve(__dirname), + }, }, plugins: [ react(), @@ -29,16 +29,16 @@ export default defineConfig({ include: '**/*.svg?jsx', svgrOptions: { // svgr options - } + }, }), viteStaticCopy({ - targets: [ - { - src: normalizePath(resolve(__dirname, './resources/*')), - dest: '.' - } - ] - }) + targets: [ + { + src: normalizePath(resolve(__dirname, './resources/*')), + dest: '.', + }, + ], + }), ], - } -}) + }, +}); diff --git a/package-lock.json b/package-lock.json index 5edb13e..bf06f32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "@braintree/browser-detection": "^2.0.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@ffmpeg/core": "^0.12.6", + "@ffmpeg/core-mt": "^0.12.6", "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.1", "animated-scroll-to": "^2.3.0", @@ -1466,6 +1468,22 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@ffmpeg/core": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@ffmpeg/core/-/core-0.12.6.tgz", + "integrity": "sha512-PrjWBTfGn2WVn9T7wGnzfFwChbqWeZc7tM9vvJZVRadYFUDakfzy7W0LpYC0cvvK0xT82qlBsk38lQhJ/Hps5A==", + "engines": { + "node": ">=16.x" + } + }, + "node_modules/@ffmpeg/core-mt": { + "version": "0.12.6", + "resolved": "https://registry.npmjs.org/@ffmpeg/core-mt/-/core-mt-0.12.6.tgz", + "integrity": "sha512-f7wrOeUk24VFRi2Gfsp/mwwkK1hbDV0ajgm2fOU/oRi+IDullyzAYdHOagAWfpSZXcTPAGZ1Ild7HmBwr3k2tg==", + "engines": { + "node": ">=16.x" + } + }, "node_modules/@ffmpeg/ffmpeg": { "version": "0.12.10", "resolved": "https://registry.npmjs.org/@ffmpeg/ffmpeg/-/ffmpeg-0.12.10.tgz", diff --git a/package.json b/package.json index 55acebe..78bc4a1 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "@braintree/browser-detection": "^2.0.0", "@electron-toolkit/preload": "^3.0.0", "@electron-toolkit/utils": "^3.0.0", + "@ffmpeg/core": "^0.12.6", "@ffmpeg/ffmpeg": "^0.12.10", "@ffmpeg/util": "^0.12.1", "animated-scroll-to": "^2.3.0", diff --git a/src/common/ffmpeg.js b/src/common/ffmpeg.js new file mode 100644 index 0000000..bede691 --- /dev/null +++ b/src/common/ffmpeg.js @@ -0,0 +1,96 @@ +const profiles = { + h264: { + codec: 'libx264', + extension: 'mp4', + pix_fmt: 'yuv420p', + preset: 'faster', + }, + hevc: { + codec: 'libx265', + extension: 'mp4', + pix_fmt: 'yuv420p', + preset: 'faster', + }, + prores: { + codec: 'prores_ks', + extension: 'mov', + pix_fmt: 'yuva444p10le', + }, + vp8: { + codec: 'libvpx', + extension: 'webm', + pix_fmt: 'yuv420p', + }, + vp9: { + codec: 'libvpx-vp9', + extension: 'webm', + pix_fmt: 'yuv420p', + }, +}; + +export const getEncodingProfile = (format) => { + return profiles[format] || null; +}; + +export const getFFmpegArgs = (width = 1920, height = 1080, encodingProfile = false, outputFile = false, fps = 24, opts = {}) => { + if (typeof profiles[encodingProfile] === 'undefined') { + throw new Error('UNKNOWN_PROFILE'); + } + + // Get profile + const profile = profiles[encodingProfile]; + + // Invalid output file + if (outputFile === false) { + throw new Error('UNDEFINED_OUTPUT'); + } + + // Default -y to overwite + const args = ['-y']; + + // Input framerate + args.push('-r', `${Number(fps) > 0 && Number(fps) <= 240 ? Number(fps) : 12}`); + + // Add all images in the path + args.push('-i', 'frame-%06d.jpg'); + + // Output resolution + const customHeight = opts.resolution && opts.resolution !== 'original' ? `,scale=w=-2:h=${opts.resolution}:force_original_aspect_ratio=1` : ''; + + // AutoScale input to ratio + args.push('-vf', `scale=w=${width}:h=${height}:force_original_aspect_ratio=1,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2${customHeight}`); + + // Codec + args.push('-c:v', profile.codec); + + // Bitrate + //args.push('-b:v', '128M'); + + // Preset + if (profile.preset) { + args.push('-preset', profile.preset); + } + + // Fast start for streaming + if (profile.extension === 'mp4') { + args.push('-movflags', '+faststart'); + } + + // Custom output framerate + if (opts.customOutputFramerate && opts.customOutputFramerateNumber) { + args.push('-r', `${Number(opts.customOutputFramerateNumber)}`); + } + + // Pixel mode + args.push('-pix_fmt', profile.pix_fmt); + + // Prores flags + if (encodingProfile === 'prores') { + args.push('-profile:v', '3', '-vendor', 'apl0', '-bits_per_mb', '4000', '-f', 'mov'); + } + + // Output file + args.push(`${outputFile}`); + + return args; +}; diff --git a/src/main/actions.js b/src/main/actions.js index ce286ad..ccf799b 100644 --- a/src/main/actions.js +++ b/src/main/actions.js @@ -21,10 +21,11 @@ import { import { getSettings, saveSettings } from './core/settings'; import { selectFile, selectFolder } from './core/utils'; import { exportProjectScene, getSyncList, normalizePictures, saveSyncList } from './core/export'; -import { getProfile } from './core/ffmpeg'; + import { uploadFile } from './core/api'; import { existsSync } from 'fs'; import { flushCamera, getCamera, getCameras } from './cameras'; +import { getEncodingProfile } from '../common/ffmpeg'; const OLD_PROJECTS_PATH = join(homedir(), DIRECTORY_NAME); const PROJECTS_PATH = existsSync(OLD_PROJECTS_PATH) ? OLD_PROJECTS_PATH : envPaths(DIRECTORY_NAME, { suffix: '' }).data; @@ -203,7 +204,17 @@ const actions = { } }, APP_CAPABILITIES: async () => { - const capabilities = ['EXPORT_VIDEO', 'EXPORT_FRAMES', 'BACKGROUND_SYNC', 'LOW_FRAMERATE_QUALITY_IMPROVEMENT']; + const capabilities = [ + 'EXPORT_VIDEO', + 'EXPORT_FRAMES', + 'BACKGROUND_SYNC', + 'LOW_FRAMERATE_QUALITY_IMPROVEMENT', + 'EXPORT_VIDEO_H264', + 'EXPORT_VIDEO_HEVC', + 'EXPORT_VIDEO_PRORES', + 'EXPORT_VIDEO_VP8', + 'EXPORT_VIDEO_VP9', + ]; return capabilities; }, EXPORT: async ( @@ -242,7 +253,7 @@ const actions = { return true; } - const profile = getProfile(format); + const profile = getEncodingProfile(format); // Create sync folder if needed if (mode === 'send') { diff --git a/src/main/core/ffmpeg.js b/src/main/core/ffmpeg.js index 8abfd6e..8adfa43 100644 --- a/src/main/core/ffmpeg.js +++ b/src/main/core/ffmpeg.js @@ -1,99 +1,13 @@ import ffmpeg from 'ffmpeg-static'; import { execFile } from 'child_process'; +import { getFFmpegArgs } from '../../common/ffmpeg'; // eslint-disable-next-line const ffmpegPath = ffmpeg ? ffmpeg.replace('app.asar', 'app.asar.unpacked') : false; -const profiles = { - h264: { - codec: 'libx264', - extension: 'mp4', - pix_fmt: 'yuv420p', - preset: 'faster', - }, - hevc: { - codec: 'libx265', - extension: 'mp4', - pix_fmt: 'yuv420p', - preset: 'faster', - }, - prores: { - codec: 'prores_ks', - extension: 'mov', - pix_fmt: 'yuva444p10le', - }, - vp8: { - codec: 'libvpx', - extension: 'webm', - pix_fmt: 'yuv420p', - }, - vp9: { - codec: 'libvpx-vp9', - extension: 'webm', - pix_fmt: 'yuv420p', - }, -}; - -export const getProfile = (format) => { - return profiles[format] || null; -}; - -export const generate = (width = 1920, height = 1080, directory = false, outputProfil = false, outputFile = false, fps = 24, opts = {}) => - new Promise((resolve, reject) => { - if (typeof profiles[outputProfil] === 'undefined') return reject(new Error('UNKNOWN PROFILE')); - - // Get profile - const profile = profiles[outputProfil]; - - // Invalid output file - if (outputFile === false) return reject(new Error('UNDEFINED_OUTPUT')); - - // Default -y to overwite - const args = ['-y']; - - // Input framerate - args.push('-r', Number(fps) > 0 && Number(fps) <= 240 ? Number(fps) : 12); - - // Add all images in the path - args.push('-i', 'frame-%06d.jpg'); - - // Output resolution - const customHeight = opts.resolution && opts.resolution !== 'original' ? `,scale=w=-2:h=${opts.resolution}:force_original_aspect_ratio=1` : ''; - - // AutoScale input to ratio - args.push('-vf', `scale=w=${width}:h=${height}:force_original_aspect_ratio=1,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2${customHeight}`); - - // Codec - args.push('-c:v', profile.codec); - - // Bitrate - //args.push('-b:v', '128M'); - - // Preset - if (profile.preset) { - args.push('-preset', profile.preset); - } - - // Fast start for streaming - if (profile.extension === 'mp4') { - args.push('-movflags', '+faststart'); - } - - // Custom output framerate - if (opts.customOutputFramerate && opts.customOutputFramerateNumber) { - args.push('-r', `${Number(opts.customOutputFramerateNumber)}`); - } - - // Pixel mode - args.push('-pix_fmt', profile.pix_fmt); - - // Prores flags - if (outputProfil === 'prores') { - args.push('-profile:v', '3', '-vendor', 'apl0', '-bits_per_mb', '4000', '-f', 'mov'); - } - - // Output file - args.push(`${outputFile}`); +export const generate = (width = 1920, height = 1080, directory = false, outputProfil = false, outputFile = false, fps = 24, opts = {}) => { + return new Promise((resolve, reject) => { + const args = getFFmpegArgs(width, height, outputProfil, outputFile, fps, opts).catch(reject); console.log(`ffmpeg.exe ${args.map((e) => `"${e}"`).join(' ')}`); @@ -122,3 +36,4 @@ export const generate = (width = 1920, height = 1080, directory = false, outputP resolve(); }); }); +}; diff --git a/src/renderer/actions/ffmpeg.js b/src/renderer/actions/ffmpeg.js new file mode 100644 index 0000000..295cf48 --- /dev/null +++ b/src/renderer/actions/ffmpeg.js @@ -0,0 +1,17 @@ +import { FFmpeg } from '@ffmpeg/ffmpeg'; +import { toBlobURL } from '@ffmpeg/util'; + +export const getFFmpeg = async (callback = () => {}) => { + const baseURL = '/'; + const ffmpeg = new FFmpeg(); + ffmpeg.on('log', callback); + ffmpeg.on('error', callback); + ffmpeg.on('progress', callback); + await ffmpeg + .load({ + coreURL: await toBlobURL(`${baseURL}ffmpeg-core.js`, 'text/javascript'), + wasmURL: await toBlobURL(`${baseURL}ffmpeg-core.wasm`, 'application/wasm'), + }) + .catch(console.error); + return ffmpeg; +}; diff --git a/src/renderer/actions/index.js b/src/renderer/actions/index.js index 615f407..48230ab 100644 --- a/src/renderer/actions/index.js +++ b/src/renderer/actions/index.js @@ -16,6 +16,9 @@ import { updateSceneFPSValue, } from './projects'; import * as browserDetection from '@braintree/browser-detection'; +import { getFFmpeg } from './ffmpeg'; +import { getEncodingProfile, getFFmpegArgs } from '../../common/ffmpeg'; +import { fetchFile } from '@ffmpeg/util'; const getDefaultPreview = async (data) => { for (let i = 0; i < (data?.project?.scenes?.length || 0); i++) { @@ -155,11 +158,7 @@ const actions = { return null; }, APP_CAPABILITIES: async () => { - const capabilities = [ - //'EXPORT_VIDEO', - 'EXPORT_FRAMES', - //'BACKGROUND_SYNC' - ]; + const capabilities = ['EXPORT_VIDEO', 'EXPORT_VIDEO_H264', 'EXPORT_VIDEO_VP8', 'EXPORT_VIDEO_PRORES', 'EXPORT_FRAMES']; // Firefox don't support photo mode if (!browserDetection.isFirefox()) { @@ -174,82 +173,66 @@ const actions = { project_id, track_id, mode = 'video', - //format = 'h264', - //resolution = 'original', + format = 'h264', + resolution = 'original', duplicate_frames_copy = true, duplicate_frames_auto = false, duplicate_frames_auto_number = 2, - //custom_output_framerate = false, - //custom_output_framerate_number = 10, - //public_code = 'default', - //event_key = '', - /*translations = { - EXPORT_FRAMES: '', - EXPORT_VIDEO: '', - DEFAULT_FILE_NAME: '', - EXT_NAME: '', - },*/ + custom_output_framerate = false, + custom_output_framerate_number = 10, } ) => { - if (mode === 'frames') { - const frames = await normalizePictures(project_id, track_id, { - duplicateFramesCopy: duplicate_frames_copy, - duplicateFramesAuto: duplicate_frames_auto, - duplicateFramesAutoNumber: duplicate_frames_auto_number, - }); + const trackId = Number(track_id); + const project = await getProject(project_id); + const frames = await normalizePictures(project_id, trackId, { + duplicateFramesCopy: duplicate_frames_copy, + duplicateFramesAuto: duplicate_frames_auto, + duplicateFramesAutoNumber: duplicate_frames_auto_number, + }); + + if (mode === 'frames') { const zip = new JSZip(); for (let i = 0; i < frames.length; i++) { const frame = frames[i]; const blob = await getFrameBlob(frame.id); zip.file(`frame-${i.toString().padStart(6, '0')}.jpg`, blob); } - /* zip.file('Hello.txt', 'Hello World\n'); - var img = zip.folder('images'); - img.file('smile.gif', imgData, { base64: true });*/ - zip.generateAsync({ type: 'blob' }).then(function (content) { saveAs(content, 'frames.zip'); }); - - return true; } - /* + if (mode === 'video') { + const ffmpeg = await getFFmpeg(console.log); + console.log('FFMPEG READY', ffmpeg); - const profile = getProfile(format); + for (let i = 0; i < frames.length; i++) { + const frame = frames[i]; + const blob = await getFrameBlob(frame.id); + await ffmpeg.writeFile(`frame-${i.toString().padStart(6, '0')}.jpg`, await fetchFile(blob)); + } - // Create sync folder if needed - if (mode === 'send') { - await mkdirp(join(PROJECTS_PATH, '/.sync/')); - } + const profile = getEncodingProfile(format); + const output = `video.${profile.extension}`; - const path = mode === 'send' ? join(PROJECTS_PATH, '/.sync/', `${public_code}.${profile.extension}`) : await selectFile(translations.DEFAULT_FILE_NAME, profile.extension, translations.EXPORT_VIDEO, translations.EXT_NAME); - await exportProjectScene(join(PROJECTS_PATH, project_id), track_id, path, format, { - duplicateFramesCopy: duplicate_frames_copy, - duplicateFramesAuto: duplicate_frames_auto, - duplicateFramesAutoNumber: duplicate_frames_auto_number, - customOutputFramerate: custom_output_framerate, - customOutputFramerateNumber: custom_output_framerate_number, - resolution - }); + const args = getFFmpegArgs(1920, 1080, format, output, custom_output_framerate ? custom_output_framerate_number : project.project.scenes[trackId].framerate, { + duplicateFramesCopy: duplicate_frames_copy, + duplicateFramesAuto: duplicate_frames_auto, + duplicateFramesAutoNumber: duplicate_frames_auto_number, + customOutputFramerate: custom_output_framerate, + customOutputFramerateNumber: custom_output_framerate_number, + resolution, + }); - if (mode === 'send') { - const syncList = await getSyncList(PROJECTS_PATH); - await saveSyncList(PROJECTS_PATH, [ - ...syncList, { - apiKey: event_key, - publicCode: public_code, - fileName: `${public_code}.${profile.extension}`, - fileExtension: profile.extension, - isUploaded: false - } - ]); + console.log('FFMPEG RUN', args); - actions.SYNC(); - } + await ffmpeg.exec(args); + const data = await ffmpeg.readFile(output); + saveAs(new Blob([data.buffer], { type: 'application/octet-stream' }), output); + } - return true;*/ + return true; }, }; diff --git a/src/renderer/actions/projects.js b/src/renderer/actions/projects.js index ad2f733..f3299e9 100644 --- a/src/renderer/actions/projects.js +++ b/src/renderer/actions/projects.js @@ -149,22 +149,3 @@ export const normalizePictures = async (projectId, trackId, opts = {}) => { const files = frames?.reduce((acc, e, i) => [...acc, ...Array(getNumberOfFrames(e, i)).fill(e)], []); return files; }; - -/* -db.transaction('rw', db.friends, async() => { - - // Make sure we have something in DB: - if ((await db.friends.where({name: 'Josephine'}).count()) === 0) { - const id = await db.friends.add({name: "Josephine", age: 21}); - alert (`Addded friend with id ${id}`); - } - - // Query: - const youngFriends = await db.friends.where("age").below(25).toArray(); - - // Show result: - alert ("My young friends: " + JSON.stringify(youngFriends)); - -}).catch(e => { - alert(e.stack || e); -});*/ diff --git a/src/renderer/views/Export.jsx b/src/renderer/views/Export.jsx index 12b31b8..aab83e2 100644 --- a/src/renderer/views/Export.jsx +++ b/src/renderer/views/Export.jsx @@ -136,11 +136,11 @@ const Export = ({ t }) => { }; const formats = [ - { value: 'h264', label: t('H264 (Recommended)') }, - { value: 'hevc', label: t('HEVC (.mp4)') }, - { value: 'prores', label: t('ProRes (.mov)') }, - { value: 'vp8', label: t('VP8 (.webm)') }, - { value: 'vp9', label: t('VP9 (.webm)') }, + ...(capabilities.includes('EXPORT_VIDEO_H264') ? [{ value: 'h264', label: t('H264 (Recommended)') }] : []), + ...(capabilities.includes('EXPORT_VIDEO_HEVC') ? [{ value: 'hevc', label: t('HEVC (.mp4)') }] : []), + ...(capabilities.includes('EXPORT_VIDEO_PRORES') ? [{ value: 'prores', label: t('ProRes (.mov)') }] : []), + ...(capabilities.includes('EXPORT_VIDEO_VP8') ? [{ value: 'vp8', label: t('VP8 (.webm)') }] : []), + ...(capabilities.includes('EXPORT_VIDEO_VP9') ? [{ value: 'vp9', label: t('VP9 (.webm)') }] : []), ]; const resolutions = ['original', 2160, 1440, 1080, 720, 480, 360].map((e) => ({ value: e, label: e === 'original' ? t('Original (Recommended)') : t('{{resolution}}p', { resolution: e }) })); diff --git a/vite.config.mjs b/vite.config.mjs index 405d497..cea71a5 100644 --- a/vite.config.mjs +++ b/vite.config.mjs @@ -20,7 +20,10 @@ export default defineConfig({ main: resolve(__dirname, 'src/renderer/index.html'), }, },*/ - outDir:resolve(__dirname, 'out/web/'), + outDir: resolve(__dirname, 'out/web/'), + }, + optimizeDeps: { + exclude: ["@ffmpeg/ffmpeg", "@ffmpeg/util"], }, resolve: { alias: { @@ -52,7 +55,17 @@ export default defineConfig({ src: normalizePath(resolve(__dirname, './resources/*')), dest: '.', }, + { + src: normalizePath(resolve(__dirname, 'node_modules/@ffmpeg/core/dist/esm/*')), + dest: '.', + }, ], }), ], + server: { + headers: { + "Cross-Origin-Opener-Policy": "same-origin", + "Cross-Origin-Embedder-Policy": "require-corp", + }, + }, });