UNPKG

@wordpress/upload-media

Version:
1,123 lines (1,122 loc) 31.3 kB
// packages/upload-media/src/store/private-actions.ts import { v4 as uuidv4 } from "uuid"; import { createBlobURL, isBlobURL, revokeBlobURL } from "@wordpress/blob"; import { __ } from "@wordpress/i18n"; import { cloneFile, convertBlobToFile, isAnimatedGif, renameFile } from "../utils.mjs"; import { canvasConvertToJpeg } from "../canvas-utils.mjs"; import { getUnappliedExifOrientation } from "../heic-parser.mjs"; import { isClientSideMediaSupported, exceedsClientProcessingMemory } from "../feature-detection.mjs"; import { getImageDimensions } from "../get-image-dimensions.mjs"; import { CLIENT_SIDE_SUPPORTED_MIME_TYPES, HEIC_MIME_TYPES } from "./constants.mjs"; import { StubFile } from "../stub-file.mjs"; import { ErrorCode, UploadError } from "../upload-error.mjs"; import { measure } from "./utils/debug-logger.mjs"; import { vipsResizeImage, vipsRotateImage, vipsConvertImageFormat, vipsHasTransparency, vipsGetUltraHdrInfo, terminateVipsWorker, maybeRecycleVipsWorker } from "./utils/index.mjs"; import { convertGifToVideo, isUnsupportedConversionError, terminateVideoConversionWorker } from "./utils/video-conversion.mjs"; import { ItemStatus, OperationType, Type } from "./types.mjs"; import { clearRetryTimer } from "./utils/retry.mjs"; var DEFAULT_OUTPUT_QUALITY = 0.82; var ultraHdrItems = /* @__PURE__ */ new Set(); function addItem({ file: fileOrBlob, batchId, onChange, onSuccess, onBatchSuccess, onError, additionalData = {}, sourceUrl, sourceAttachmentId, abortController, operations }) { return async ({ dispatch }) => { const itemId = uuidv4(); const file = convertBlobToFile(fileOrBlob); let blobUrl; if (!(file instanceof StubFile)) { blobUrl = createBlobURL(file); dispatch({ type: Type.CacheBlobUrl, id: itemId, blobUrl }); } dispatch({ type: Type.Add, item: { id: itemId, batchId, status: ItemStatus.Processing, sourceFile: cloneFile(file), file, attachment: { url: blobUrl }, additionalData: { generate_sub_sizes: false, ...additionalData }, onChange, onSuccess, onBatchSuccess, onError, sourceUrl, sourceAttachmentId, abortController: abortController || new AbortController(), operations: Array.isArray(operations) ? operations : [OperationType.Prepare] } }); dispatch.processItem(itemId); }; } function addSideloadItem({ file, onChange, additionalData, operations, batchId, parentId }) { return ({ dispatch }) => { const itemId = uuidv4(); dispatch({ type: Type.Add, item: { id: itemId, batchId, status: ItemStatus.Processing, sourceFile: cloneFile(file), file, onChange, additionalData: { ...additionalData }, parentId, operations: Array.isArray(operations) ? operations : [OperationType.Prepare], abortController: new AbortController() } }); dispatch.processItem(itemId); }; } function processItem(id) { return async ({ select, dispatch }) => { if (select.isPaused()) { return; } const item = select.getItem(id); if (!item) { return; } const { attachment, onChange, onSuccess, onBatchSuccess, batchId, parentId } = item; const operation = Array.isArray(item.operations?.[0]) ? item.operations[0][0] : item.operations?.[0]; const operationArgs = Array.isArray(item.operations?.[0]) ? item.operations[0][1] : void 0; if (operation === OperationType.Upload) { const settings = select.getSettings(); const activeCount = select.getActiveUploadCount(); if (activeCount >= settings.maxConcurrentUploads) { return; } } if (operation === OperationType.ResizeCrop || operation === OperationType.Rotate) { const settings = select.getSettings(); const activeCount = select.getActiveImageProcessingCount(); if (activeCount >= settings.maxConcurrentImageProcessing) { return; } } if (operation === OperationType.TranscodeGif) { const activeCount = select.getActiveVideoProcessingCount(); if (activeCount >= 1) { return; } } if (attachment) { const isHeicUrl = attachment.url && /\.hei[cf]$/i.test(attachment.url); if (!isHeicUrl) { onChange?.([attachment]); } } if (!operation) { if (parentId || !parentId && !select.hasPendingItemsByParentId(id)) { if (attachment) { onSuccess?.([attachment]); } dispatch.removeItem(id); dispatch.revokeBlobUrls(id); if (batchId && select.isBatchUploaded(batchId)) { onBatchSuccess?.(); } } if (parentId && batchId && select.isBatchUploaded(batchId)) { const parentItem = select.getItem(parentId); if (!parentItem) { return; } if (parentItem.operations && parentItem.operations.length > 0) { dispatch.processItem(parentId); return; } if (attachment) { parentItem.onSuccess?.([attachment]); } dispatch.removeItem(parentId); dispatch.revokeBlobUrls(parentId); if (parentItem.batchId && select.isBatchUploaded(parentItem.batchId)) { parentItem.onBatchSuccess?.(); } } return; } if (operation === OperationType.Finalize && select.hasPendingItemsByParentId(id)) { return; } dispatch({ type: Type.OperationStart, id, operation }); switch (operation) { case OperationType.Prepare: dispatch.prepareItem(item.id); break; case OperationType.ResizeCrop: dispatch.resizeCropItem( item.id, operationArgs ); break; case OperationType.Rotate: dispatch.rotateItem( item.id, operationArgs ); break; case OperationType.TranscodeImage: dispatch.transcodeImageItem( item.id, operationArgs ); break; case OperationType.TranscodeGif: dispatch.transcodeGifItem( item.id, operationArgs ); break; case OperationType.Upload: if (item.parentId) { dispatch.sideloadItem(id); } else { dispatch.uploadItem(id); } break; case OperationType.ThumbnailGeneration: dispatch.generateThumbnails(id); break; case OperationType.Finalize: dispatch.finalizeItem(id); break; case OperationType.DetectUltraHdr: dispatch.detectUltraHdr(id); break; } }; } function pauseQueue() { return { type: Type.PauseQueue }; } function resumeQueue() { return async ({ select, dispatch }) => { dispatch({ type: Type.ResumeQueue }); for (const item of select.getAllItems()) { if (item.status === ItemStatus.PendingRetry) { dispatch.executeRetry(item.id); } else { dispatch.processItem(item.id); } } }; } function pauseItem(id) { return async ({ dispatch }) => { dispatch({ type: Type.PauseItem, id }); }; } function removeItem(id) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } ultraHdrItems.delete(id); clearRetryTimer(id); dispatch({ type: Type.Remove, id }); if (select.getAllItems().length === 0) { terminateVipsWorker(); terminateVideoConversionWorker(); } }; } function finishOperation(id, updates) { return async ({ select, dispatch }) => { const item = select.getItem(id); const previousOperation = item?.currentOperation; dispatch({ type: Type.OperationFinish, id, item: updates }); dispatch.processItem(id); if (previousOperation === OperationType.Upload) { const pendingUploads = select.getPendingUploads(); for (const pendingItem of pendingUploads) { dispatch.processItem(pendingItem.id); } } if (previousOperation === OperationType.ResizeCrop || previousOperation === OperationType.Rotate) { const pendingItems = select.getPendingImageProcessing(); for (const pendingItem of pendingItems) { dispatch.processItem(pendingItem.id); } } if (previousOperation === OperationType.TranscodeGif) { const pendingItems = select.getPendingVideoProcessing(); for (const pendingItem of pendingItems) { dispatch.processItem(pendingItem.id); } } if (previousOperation === OperationType.ResizeCrop || previousOperation === OperationType.Rotate || previousOperation === OperationType.TranscodeImage) { maybeRecycleVipsWorker(select.getActiveImageProcessingCount()); } }; } var VALID_IMAGE_FORMATS = ["jpeg", "webp", "avif", "png", "gif"]; function isValidImageFormat(format) { return VALID_IMAGE_FORMATS.includes(format); } async function getTranscodeImageOperation(file, outputMimeType, interlaced = false, quality = DEFAULT_OUTPUT_QUALITY) { if (file.type === "image/png" && outputMimeType === "image/jpeg") { const blobUrl = createBlobURL(file); try { const hasAlpha = await vipsHasTransparency(blobUrl); if (hasAlpha) { return null; } } catch { return null; } finally { revokeBlobURL(blobUrl); } } const formatPart = outputMimeType.split("/")[1]; if (!isValidImageFormat(formatPart)) { return null; } return [ OperationType.TranscodeImage, { outputFormat: formatPart, outputQuality: quality, interlaced } ]; } function prepareItem(id) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } const { file } = item; const operations = []; const settings = select.getSettings(); if (file.type === "image/gif" && settings.gifConvert !== false && typeof ImageDecoder !== "undefined" && typeof VideoEncoder !== "undefined") { let isAnimated = false; try { isAnimated = isAnimatedGif(await file.arrayBuffer()); } catch { isAnimated = false; } if (isAnimated) { let hasTransparency = false; const blobUrl = createBlobURL(file); try { hasTransparency = await vipsHasTransparency(blobUrl); } catch { hasTransparency = true; } finally { revokeBlobURL(blobUrl); } if (!hasTransparency) { operations.push( OperationType.Upload, OperationType.ThumbnailGeneration, OperationType.Finalize ); dispatch({ type: Type.AddOperations, id, operations }); dispatch.finishOperation(id, { animatedGifFile: item.file }); return; } } } let heicJpeg = null; const isImage = file.type.startsWith("image/"); const isVipsSupported = CLIENT_SIDE_SUPPORTED_MIME_TYPES.includes( file.type ); const isHeic = HEIC_MIME_TYPES.includes(file.type); let tooLargeForClient = false; if (isImage && isVipsSupported) { const dimensions = await getImageDimensions(file); if (dimensions && exceedsClientProcessingMemory(dimensions)) { tooLargeForClient = true; } } if (file.type === "image/jpeg" && !tooLargeForClient) { operations.push(OperationType.DetectUltraHdr); } if (isImage && isVipsSupported && !tooLargeForClient) { operations.push( OperationType.Upload, OperationType.ThumbnailGeneration, OperationType.Finalize ); } else if (isImage && isHeic) { try { heicJpeg = await canvasConvertToJpeg( file, settings.imageQuality ?? DEFAULT_OUTPUT_QUALITY ); } catch { dispatch.cancelItem( id, new UploadError({ code: ErrorCode.HEIC_DECODE_ERROR, message: "This browser cannot decode HEIC images and the server does not support them either. Please convert to JPEG before uploading.", file }) ); return; } operations.push( OperationType.Upload, OperationType.ThumbnailGeneration, OperationType.Finalize ); } else { operations.push(OperationType.Upload); } dispatch({ type: Type.AddOperations, id, operations }); let updates; if (isHeic && heicJpeg) { const vipsAvailable = isClientSideMediaSupported(); updates = { file: heicJpeg, sourceFile: heicJpeg, originalHeicFile: item.file, additionalData: { ...item.additionalData, generate_sub_sizes: !vipsAvailable, convert_format: true } }; } else if (!isVipsSupported || !isImage || tooLargeForClient) { updates = { additionalData: { ...item.additionalData, generate_sub_sizes: true, convert_format: true } }; } else { updates = { additionalData: { ...item.additionalData, generate_sub_sizes: false } }; } dispatch.finishOperation(id, updates); }; } function detectUltraHdr(id) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } let info; try { const buffer = await item.file.arrayBuffer(); info = await vipsGetUltraHdrInfo(buffer); } catch { } if (info) { ultraHdrItems.add(id); } dispatch.finishOperation(id, {}); }; } function uploadItem(id) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } const startTime = performance.now(); let finished = false; const finishUpload = (attachment) => { if (finished) { return; } finished = true; measure({ measureName: `Upload ${item.file.name}`, startTime, tooltipText: item.file.name, properties: [ ["Item ID", item.id], ["File name", item.file.name] ] }); dispatch.finishOperation(id, { attachment }); }; select.getSettings().mediaUpload({ filesList: [item.file], additionalData: item.additionalData, signal: item.abortController?.signal, onFileChange: ([attachment]) => { if (attachment && !isBlobURL(attachment.url)) { finishUpload(attachment); } }, onSuccess: ([attachment]) => { finishUpload(attachment); }, onError: (error) => { dispatch.cancelItem(id, error); } }); }; } function sideloadItem(id) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } const { post, ...additionalData } = item.additionalData; const mediaSideload = select.getSettings().mediaSideload; if (!mediaSideload) { dispatch.finishOperation(id, {}); return; } const startTime = performance.now(); mediaSideload({ file: item.file, attachmentId: post, additionalData, signal: item.abortController?.signal, onSuccess: (subSize) => { measure({ measureName: `Sideload ${item.file.name}`, startTime, tooltipText: item.file.name, properties: [ ["Item ID", item.id], ["File name", item.file.name] ] }); if (item.parentId) { dispatch({ type: Type.AccumulateSubSize, id: item.parentId, subSize }); } dispatch.finishOperation(id, {}); }, onError: (error) => { dispatch.cancelItem(id, error); } }); }; } function resizeCropItem(id, args) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } if (!args?.resize) { dispatch.finishOperation(id, { file: item.file }); return; } const startTime = performance.now(); const addSuffix = Boolean(item.parentId); const scaledSuffix = Boolean(args.isThresholdResize); try { const file = await vipsResizeImage( item.id, item.file, args.resize, false, // smartCrop addSuffix, item.abortController?.signal, scaledSuffix, args.quality ); measure({ measureName: `ResizeCrop ${item.file.name}`, startTime, tooltipText: item.file.name, properties: [ ["Item ID", item.id], ["File name", item.file.name] ] }); const blobUrl = createBlobURL(file); dispatch({ type: Type.CacheBlobUrl, id, blobUrl }); dispatch.finishOperation(id, { file, attachment: { url: blobUrl } }); } catch (error) { dispatch.cancelItem( id, new UploadError({ code: ErrorCode.IMAGE_TRANSCODING_ERROR, message: __( "The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading." ), file: item.file, cause: error instanceof Error ? error : void 0 }) ); } }; } function rotateItem(id, args) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } if (!args?.orientation || args.orientation === 1) { dispatch.finishOperation(id, { file: item.file }); return; } const startTime = performance.now(); try { const file = await vipsRotateImage( item.id, item.file, args.orientation, item.abortController?.signal ); measure({ measureName: `Rotate ${item.file.name}`, startTime, tooltipText: item.file.name, properties: [ ["Item ID", item.id], ["File name", item.file.name] ] }); const blobUrl = createBlobURL(file); dispatch({ type: Type.CacheBlobUrl, id, blobUrl }); dispatch.finishOperation(id, { file, attachment: { url: blobUrl } }); } catch (error) { dispatch.cancelItem( id, new UploadError({ code: ErrorCode.IMAGE_ROTATION_ERROR, message: __( "The web server cannot generate responsive image sizes for this image. Convert it to JPEG or PNG before uploading." ), file: item.file, cause: error instanceof Error ? error : void 0 }) ); } }; } function transcodeImageItem(id, args) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } if (!args?.outputFormat) { dispatch.finishOperation(id, { file: item.file }); return; } const startTime = performance.now(); const outputMimeType = `image/${args.outputFormat}`; const quality = args.outputQuality ?? DEFAULT_OUTPUT_QUALITY; const interlaced = args.interlaced ?? false; try { const file = await vipsConvertImageFormat( item.id, item.file, outputMimeType, quality, interlaced ); measure({ measureName: `Transcode ${item.file.name}`, startTime, tooltipText: item.file.name, properties: [ ["Item ID", item.id], ["File name", item.file.name] ] }); const blobUrl = createBlobURL(file); dispatch({ type: Type.CacheBlobUrl, id, blobUrl }); dispatch.finishOperation(id, { file, attachment: { url: blobUrl } }); } catch (error) { dispatch.cancelItem( id, new UploadError({ code: ErrorCode.MEDIA_TRANSCODING_ERROR, message: "Image could not be transcoded to the target format", file: item.file, cause: error instanceof Error ? error : void 0 }) ); } }; } function transcodeGifItem(id, args) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } const outputFormat = args?.outputFormat ?? "mp4"; const outputMimeType = `video/${outputFormat}`; const gifFile = item.file; try { const file = await convertGifToVideo( item.id, gifFile, outputMimeType ); dispatch.finishOperation(id, { file }); dispatch.addSideloadItem({ file: gifFile, batchId: uuidv4(), parentId: item.parentId, additionalData: { post: item.additionalData?.post, image_size: "animated_video_poster", convert_format: false }, operations: [ [ OperationType.TranscodeImage, { outputFormat: "jpeg", outputQuality: DEFAULT_OUTPUT_QUALITY, interlaced: false } ], OperationType.Upload ] }); } catch (error) { if (isUnsupportedConversionError(error)) { dispatch.cancelItem( id, new Error("Animated GIF conversion unsupported"), true ); return; } console.error( "[video-conversion] GIF to video conversion failed:", error ); dispatch.cancelItem( id, new UploadError({ code: ErrorCode.GIF_TRANSCODING_ERROR, message: "Animated GIF could not be converted to video", file: item.file, cause: error instanceof Error ? error : void 0 }), true ); } }; } function generateThumbnails(id) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } if (!item.attachment) { dispatch.finishOperation(id, {}); return; } const attachment = item.attachment; const settings = select.getSettings(); if (item.originalHeicFile && attachment.id) { dispatch.addSideloadItem({ file: item.originalHeicFile, batchId: uuidv4(), parentId: item.id, additionalData: { post: attachment.id, image_size: "source_original", convert_format: false }, operations: [OperationType.Upload] }); } if (item.animatedGifFile && attachment.id) { const outputFormat = settings.videoOutputFormat === "video/webm" ? "webm" : "mp4"; dispatch.addSideloadItem({ file: item.animatedGifFile, batchId: uuidv4(), parentId: item.id, additionalData: { post: attachment.id, image_size: "animated_video", convert_format: false }, operations: [ [ OperationType.TranscodeGif, { outputFormat } ], OperationType.Upload ] }); } let exifOrientation = attachment.exif_orientation || 1; const sourceType = item.sourceFile.type; const isHeifFamily = sourceType === "image/avif" || sourceType === "image/heif"; let needsClientRotation = false; if (isHeifFamily) { exifOrientation = getUnappliedExifOrientation( await item.sourceFile.arrayBuffer() ); needsClientRotation = exifOrientation !== 1; } let rotatedSource; { const needsRotation = exifOrientation !== 1 && !item.file.name.includes("-scaled"); if ((needsRotation || needsClientRotation) && attachment.id) { try { rotatedSource = await vipsRotateImage( item.id, item.sourceFile, exifOrientation, item.abortController?.signal ); } catch { console.warn( "Failed to rotate image, continuing with thumbnails" ); } } if (needsRotation && rotatedSource && attachment.id) { dispatch.addSideloadItem({ file: rotatedSource, batchId: uuidv4(), parentId: item.id, additionalData: { post: attachment.id, image_size: "original", convert_format: false }, operations: [OperationType.Upload] }); } } if (!item.parentId && attachment.missing_image_sizes && attachment.missing_image_sizes.length > 0) { const allImageSizes = settings.allImageSizes || {}; const sizesToGenerate = attachment.missing_image_sizes; const thumbnailSource = needsClientRotation && rotatedSource ? rotatedSource : item.sourceFile; const file = attachment.filename ? renameFile(thumbnailSource, attachment.filename) : thumbnailSource; const batchId = uuidv4(); const isUltraHdr = ultraHdrItems.has(item.id); const outputMimeType = attachment.image_output_format; const interlaced = attachment.image_save_progressive ?? false; const imageQuality = attachment.image_quality; const fallbackQuality = settings.imageQuality ?? DEFAULT_OUTPUT_QUALITY; const defaultQuality = typeof imageQuality?.default === "number" ? imageQuality.default / 100 : fallbackQuality; const qualityForSize = (sizeName) => { const sized = imageQuality?.sizes?.[sizeName]; if (typeof sized === "number") { return sized / 100; } return defaultQuality; }; let thumbnailTranscodeOperation = null; if (!isUltraHdr && outputMimeType) { thumbnailTranscodeOperation = await getTranscodeImageOperation( thumbnailSource, outputMimeType, interlaced, defaultQuality ); } const dimensionGroups = /* @__PURE__ */ new Map(); for (const name of sizesToGenerate) { const imageSize = allImageSizes[name]; if (!imageSize) { console.warn( `Image size "${name}" not found in configuration` ); continue; } const key = `${imageSize.width}x${imageSize.height}x${imageSize.crop}`; const group = dimensionGroups.get(key); if (group) { group.push(name); } else { dimensionGroups.set(key, [name]); } } for (const [, names] of dimensionGroups) { const imageSize = allImageSizes[names[0]]; const sizeQuality = qualityForSize(names[0]); const thumbnailOperations = [ [ OperationType.ResizeCrop, { resize: imageSize, quality: sizeQuality } ] ]; if (!isUltraHdr && thumbnailTranscodeOperation) { thumbnailOperations.push([ thumbnailTranscodeOperation[0], { ...thumbnailTranscodeOperation[1], outputQuality: sizeQuality } ]); } thumbnailOperations.push(OperationType.Upload); const imageSizeParam = names.length === 1 ? names[0] : names; dispatch.addSideloadItem({ file, batchId, parentId: item.id, additionalData: { // Sideloading does not use the parent post ID but the // attachment ID as the image sizes need to be added to it. post: attachment.id, image_size: imageSizeParam, convert_format: false }, operations: thumbnailOperations }); } { const { bigImageSizeThreshold } = settings; if (bigImageSizeThreshold && attachment.id) { const bitmap = await createImageBitmap(thumbnailSource); const needsScaling = bitmap.width > bigImageSizeThreshold || bitmap.height > bigImageSizeThreshold; bitmap.close(); if (needsScaling) { const sourceForScaled = attachment.filename ? renameFile(thumbnailSource, attachment.filename) : thumbnailSource; const scaledOperations = [ [ OperationType.ResizeCrop, { resize: { width: bigImageSizeThreshold, height: bigImageSizeThreshold }, isThresholdResize: true, quality: defaultQuality } ] ]; if (!isUltraHdr && thumbnailTranscodeOperation) { scaledOperations.push( thumbnailTranscodeOperation ); } scaledOperations.push(OperationType.Upload); dispatch.addSideloadItem({ file: sourceForScaled, batchId, parentId: item.id, additionalData: { post: attachment.id, image_size: "scaled", convert_format: false }, operations: scaledOperations }); } } } } dispatch.finishOperation(id, {}); }; } function finalizeItem(id) { return async ({ select, dispatch }) => { const item = select.getItem(id); if (!item) { return; } const attachment = item.attachment; const { mediaFinalize } = select.getSettings(); const updates = {}; if (attachment?.id && mediaFinalize) { try { const updatedAttachment = await mediaFinalize( attachment.id, item.subSizes || [] ); if (updatedAttachment) { updates.attachment = updatedAttachment; } } catch (error) { console.warn("Media finalization failed:", error); } } dispatch.finishOperation(id, updates); }; } function revokeBlobUrls(id) { return async ({ select, dispatch }) => { const blobUrls = select.getBlobUrls(id); for (const blobUrl of blobUrls) { revokeBlobURL(blobUrl); } dispatch({ type: Type.RevokeBlobUrls, id }); }; } function updateItemProgress(id, progress) { return async ({ dispatch }) => { dispatch({ type: Type.UpdateProgress, id, progress }); }; } function updateSettings(settings) { return { type: Type.UpdateSettings, settings }; } export { addItem, addSideloadItem, detectUltraHdr, finalizeItem, finishOperation, generateThumbnails, getTranscodeImageOperation, pauseItem, pauseQueue, prepareItem, processItem, removeItem, resizeCropItem, resumeQueue, revokeBlobUrls, rotateItem, sideloadItem, transcodeGifItem, transcodeImageItem, updateItemProgress, updateSettings, uploadItem }; //# sourceMappingURL=private-actions.mjs.map