Skip to content

Commit ce33121

Browse files
dcalhounclaude
andcommitted
feat: add GBKMediaUploadProcessor for GutenbergKit media uploads
Implements GutenbergKit's MediaUploadDelegate to process device media picked in the experimental block editor before upload, honoring the app's media settings the same way the legacy editor pipeline does: - Images are optimized per the max size/quality settings via WPMediaUtils.getOptimizedMedia, with GPS EXIF stripped when the strip-location setting is on, and the reported mime type/extension corrected to the actual output format (non-PNG inputs, including HEIC, re-encode to JPEG). - When processing would be a no-op, the original file passes through untouched, avoiding a needless lossy re-encode. - Sideways-captured images are rotated for self-hosted sites when optimization is off (WP.com rotates server-side; issue #5737). - GIFs always pass through to preserve animation. - Videos enforce the free-plan 5-minute duration limit and transcode via WPVideoUtils/m4m only when the optimize-video setting is on, keeping the transcoded file only when smaller than the original. Transcodes are serialized to bound codec/memory pressure. - File types disallowed by the site plan are rejected with a localized message relayed to the editor as a notice. Nothing wires the processor yet; that lands separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 01aa920 commit ce33121

1 file changed

Lines changed: 251 additions & 0 deletions

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package org.wordpress.android.ui.posts.editor
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import android.webkit.MimeTypeMap
6+
import kotlinx.coroutines.CoroutineDispatcher
7+
import kotlinx.coroutines.Dispatchers
8+
import kotlinx.coroutines.suspendCancellableCoroutine
9+
import kotlinx.coroutines.sync.Mutex
10+
import kotlinx.coroutines.sync.withLock
11+
import kotlinx.coroutines.withContext
12+
import org.m4m.IProgressListener
13+
import org.wordpress.android.R
14+
import org.wordpress.android.fluxc.model.SiteModel
15+
import org.wordpress.android.ui.prefs.AppPrefsWrapper
16+
import org.wordpress.android.util.AppLog
17+
import org.wordpress.android.util.MediaUtils
18+
import org.wordpress.android.util.MediaUtilsWrapper
19+
import org.wordpress.android.util.WPVideoUtils
20+
import org.wordpress.gutenberg.MediaUploadDelegate
21+
import org.wordpress.gutenberg.ProcessedProxyFile
22+
import java.io.File
23+
import kotlin.coroutines.resume
24+
25+
/**
26+
* Processes device media picked in the GutenbergKit editor before upload, honoring the app's
27+
* media settings (image optimization, quality, EXIF location stripping, video optimization) the
28+
* same way the legacy editor's upload pipeline does.
29+
*
30+
* Set as [org.wordpress.gutenberg.GutenbergView.mediaUploadDelegate]; GutenbergKit invokes
31+
* [processFile] for every editor upload and uploads the result itself (this class deliberately
32+
* does not override `uploadFile`, so GutenbergKit's default uploader posts to `/wp/v2/media`
33+
* and relays WordPress's raw response to the editor).
34+
*
35+
* Contract notes (see GutenbergKit's MediaUploadServer):
36+
* - Returning [ProcessedProxyFile.Original] makes GutenbergKit forward the original request body
37+
* byte-for-byte — mutations to the staged [File] are NOT uploaded. Any change intended for
38+
* WordPress must be returned as [ProcessedProxyFile.Processed].
39+
* - Processed output files are deleted by GutenbergKit after the upload, so they are written to
40+
* the cache dir and never registered in the app's media store.
41+
* - Thrown exceptions are relayed to the editor as an error notice showing the exception message,
42+
* so messages must be localized and user-facing.
43+
*/
44+
class GBKMediaUploadProcessor(
45+
private val site: SiteModel,
46+
private val appContext: Context,
47+
private val mediaUtilsWrapper: MediaUtilsWrapper,
48+
private val appPrefsWrapper: AppPrefsWrapper,
49+
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
50+
) : MediaUploadDelegate {
51+
/**
52+
* Serializes video transcodes. GutenbergKit's upload server handles requests concurrently,
53+
* but parallel m4m hardware transcodes are memory/codec-heavy; the legacy pipeline
54+
* effectively serialized them through the upload queue.
55+
*/
56+
private val transcodeMutex = Mutex()
57+
58+
override suspend fun processFile(
59+
file: File,
60+
mimeType: String,
61+
filename: String
62+
): ProcessedProxyFile = withContext(ioDispatcher) {
63+
val resolvedMimeType = resolveMimeType(mimeType, filename)
64+
65+
// Reject types the site's plan doesn't allow (e.g. audio on free WP.com plans) with a
66+
// localized message instead of an opaque server-side error.
67+
if (!mediaUtilsWrapper.isMimeTypeSupportedBySitePlan(site, resolvedMimeType)) {
68+
throw GBKMediaUploadException(appContext.getString(R.string.error_media_file_type_not_allowed))
69+
}
70+
71+
when {
72+
// Never re-encode GIFs — it would flatten animation. Passthrough skips even a copy.
73+
resolvedMimeType == MIME_GIF -> ProcessedProxyFile.Original
74+
mediaUtilsWrapper.isVideoMimeType(resolvedMimeType) -> processVideo(file, filename)
75+
resolvedMimeType.startsWith(MIME_IMAGE_PREFIX) -> processImage(file, resolvedMimeType, filename)
76+
// Non-media files (documents, archives, audio on paid plans) upload unchanged.
77+
else -> ProcessedProxyFile.Original
78+
}
79+
}
80+
81+
private suspend fun processVideo(file: File, filename: String): ProcessedProxyFile {
82+
if (mediaUtilsWrapper.isProhibitedVideoDuration(appContext, site, Uri.fromFile(file))) {
83+
throw GBKMediaUploadException(
84+
appContext.getString(R.string.error_media_video_duration_exceeds_limit)
85+
)
86+
}
87+
88+
// Match the legacy pipeline: transcode only when the user enabled video optimization.
89+
if (!appPrefsWrapper.isVideoOptimize) return ProcessedProxyFile.Original
90+
91+
val output = transcodeMutex.withLock { transcodeVideo(file) } ?: return ProcessedProxyFile.Original
92+
93+
// Match VideoOptimizer: only use the transcoded file when it is actually smaller.
94+
if (output.length() >= file.length()) {
95+
output.delete()
96+
return ProcessedProxyFile.Original
97+
}
98+
99+
return ProcessedProxyFile.Processed(
100+
file = output,
101+
mimeType = MIME_MP4,
102+
filename = "${filename.substringBeforeLast('.')}.mp4"
103+
)
104+
}
105+
106+
/**
107+
* Transcodes the video per the user's optimization settings, mirroring the legacy
108+
* [org.wordpress.android.ui.uploads.VideoOptimizer] semantics: any failure (no composer,
109+
* m4m error) resolves to null so the caller falls back to uploading the original.
110+
*/
111+
@Suppress("TooGenericExceptionCaught", "SwallowedException")
112+
private suspend fun transcodeVideo(input: File): File? = suspendCancellableCoroutine { continuation ->
113+
val output = File(appContext.cacheDir, MediaUtils.generateTimeStampedFileName(MIME_MP4))
114+
val listener = object : IProgressListener {
115+
override fun onMediaStart() = Unit
116+
override fun onMediaProgress(progress: Float) = Unit
117+
override fun onMediaPause() = Unit
118+
119+
// onMediaStop fires both on completion (before onMediaDone) and on manual stop, so
120+
// only onMediaDone/onError complete the coroutine, guarded against double-resume.
121+
override fun onMediaStop() = Unit
122+
123+
override fun onMediaDone() {
124+
if (continuation.isActive) continuation.resume(output)
125+
}
126+
127+
override fun onError(exception: Exception) {
128+
AppLog.e(AppLog.T.MEDIA, "GBKMediaUploadProcessor > video transcode failed", exception)
129+
output.delete()
130+
if (continuation.isActive) continuation.resume(null)
131+
}
132+
}
133+
134+
val composer = try {
135+
WPVideoUtils.getVideoOptimizationComposer(
136+
appContext,
137+
input.absolutePath,
138+
output.absolutePath,
139+
listener,
140+
appPrefsWrapper.videoOptimizeWidth,
141+
appPrefsWrapper.videoOptimizeQuality
142+
)
143+
} catch (npe: NullPointerException) {
144+
// m4m throws NPEs on some malformed inputs; the legacy pipeline guards this too.
145+
AppLog.w(AppLog.T.MEDIA, "GBKMediaUploadProcessor > NPE getting composer: ${npe.message}")
146+
null
147+
}
148+
149+
if (composer == null) {
150+
output.delete()
151+
continuation.resume(null)
152+
return@suspendCancellableCoroutine
153+
}
154+
155+
continuation.invokeOnCancellation {
156+
try {
157+
composer.stop()
158+
} catch (e: Exception) {
159+
AppLog.w(AppLog.T.MEDIA, "GBKMediaUploadProcessor > error stopping composer: ${e.message}")
160+
}
161+
output.delete()
162+
}
163+
164+
composer.start()
165+
}
166+
167+
private fun processImage(file: File, mimeType: String, filename: String): ProcessedProxyFile {
168+
// getOptimizedMedia returns null when optimization is disabled or a no-op. It can also
169+
// return the *input* path unchanged (GIF-like skips, decode failures inside
170+
// ImageUtils.optimizeImage) — treat that as "not optimized" too, otherwise the original
171+
// file would be mislabeled with a corrected JPEG mime type below.
172+
val optimizedPath = mediaUtilsWrapper.getOptimizedMedia(file.absolutePath, false)
173+
?.path
174+
?.takeIf { it != file.absolutePath }
175+
176+
if (optimizedPath != null) {
177+
return processedImage(File(optimizedPath), mimeType, filename)
178+
}
179+
180+
// With optimization off, WP.com rotates sideways-captured images server-side but
181+
// self-hosted sites don't, so rotate physically (legacy parity — see issue #5737).
182+
// Returns null when no rotation is needed.
183+
if (!site.isWPCom) {
184+
val rotatedPath = mediaUtilsWrapper.fixOrientationIssue(file.absolutePath, false)
185+
?.path
186+
?.takeIf { it != file.absolutePath }
187+
if (rotatedPath != null) {
188+
return processedImage(File(rotatedPath), mimeType, filename)
189+
}
190+
}
191+
192+
if (appPrefsWrapper.isStripImageLocation && mimeType in EXIF_MIME_TYPES) {
193+
// A copy is required: returning Original makes GutenbergKit forward the original
194+
// request body byte-for-byte, so stripping EXIF from the staged file in place would
195+
// silently upload the un-stripped bytes.
196+
val copy = File.createTempFile("gbk-media", ".${file.extension}", appContext.cacheDir)
197+
file.copyTo(copy, overwrite = true)
198+
mediaUtilsWrapper.stripImageLocation(copy.absolutePath)
199+
return ProcessedProxyFile.Processed(copy, mimeType, filename)
200+
}
201+
202+
// No-op: optimization off/unneeded, no rotation, no location strip. Passing the original
203+
// through avoids the needless lossy re-encode the legacy pipeline never did either.
204+
return ProcessedProxyFile.Original
205+
}
206+
207+
/**
208+
* Wraps an optimized/rotated image file, stripping GPS EXIF when enabled and correcting the
209+
* reported mime type and filename: ImageUtils re-encodes PNG to PNG and everything else
210+
* (including HEIC/WebP) to JPEG bytes while keeping the original file extension, so the
211+
* metadata sent to WordPress must reflect the actual output format.
212+
*/
213+
private fun processedImage(output: File, inputMimeType: String, filename: String): ProcessedProxyFile {
214+
if (appPrefsWrapper.isStripImageLocation) {
215+
// getOptimizedMedia copies the original's EXIF (including GPS) onto its output, so
216+
// the strip must run on the output — matching the legacy strip-at-upload behavior.
217+
mediaUtilsWrapper.stripImageLocation(output.absolutePath)
218+
}
219+
220+
val basename = filename.substringBeforeLast('.')
221+
return if (inputMimeType == MIME_PNG) {
222+
ProcessedProxyFile.Processed(output, MIME_PNG, "$basename.png")
223+
} else {
224+
ProcessedProxyFile.Processed(output, MIME_JPEG, "$basename.jpg")
225+
}
226+
}
227+
228+
private fun resolveMimeType(mimeType: String, filename: String): String {
229+
if (mimeType.isNotBlank() && mimeType != MIME_OCTET_STREAM) return mimeType
230+
val extension = filename.substringAfterLast('.', "").lowercase()
231+
return MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) ?: MIME_OCTET_STREAM
232+
}
233+
234+
companion object {
235+
private const val MIME_IMAGE_PREFIX = "image/"
236+
private const val MIME_GIF = "image/gif"
237+
private const val MIME_PNG = "image/png"
238+
private const val MIME_JPEG = "image/jpeg"
239+
private const val MIME_MP4 = "video/mp4"
240+
private const val MIME_OCTET_STREAM = "application/octet-stream"
241+
242+
/** Image formats that can carry EXIF GPS metadata. */
243+
private val EXIF_MIME_TYPES = setOf(MIME_JPEG, "image/heic", "image/heif", "image/webp")
244+
}
245+
}
246+
247+
/**
248+
* Thrown to reject an upload; GutenbergKit relays [message] to the editor as an error notice,
249+
* so it must be localized and user-facing.
250+
*/
251+
class GBKMediaUploadException(message: String) : Exception(message)

0 commit comments

Comments
 (0)