react-native-libyuv-resizer 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/LibyuvResizer.podspec +20 -0
- package/README.md +188 -0
- package/android/CMakeLists.txt +30 -0
- package/android/build.gradle +100 -0
- package/android/src/androidTest/java/com/libyuvresizer/ExifCopierTest.kt +131 -0
- package/android/src/androidTest/java/com/libyuvresizer/FakePromise.kt +71 -0
- package/android/src/androidTest/java/com/libyuvresizer/FakeReactContext.kt +55 -0
- package/android/src/androidTest/java/com/libyuvresizer/LibyuvResizerModuleErrorTest.kt +135 -0
- package/android/src/androidTest/java/com/libyuvresizer/LibyuvResizerModuleExifTest.kt +140 -0
- package/android/src/androidTest/java/com/libyuvresizer/LibyuvResizerModuleFilterModeTest.kt +85 -0
- package/android/src/androidTest/java/com/libyuvresizer/LibyuvResizerModuleFormatTest.kt +146 -0
- package/android/src/androidTest/java/com/libyuvresizer/LibyuvResizerModuleIntegrationTest.kt +157 -0
- package/android/src/androidTest/java/com/libyuvresizer/LibyuvResizerModuleOutputPathTest.kt +96 -0
- package/android/src/androidTest/java/com/libyuvresizer/LibyuvResizerModuleRotationTest.kt +120 -0
- package/android/src/androidTest/java/com/libyuvresizer/TestFixtures.kt +48 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/cpp/LibyuvResizerModule.cpp +137 -0
- package/android/src/main/java/com/libyuvresizer/DimensionCalculator.kt +52 -0
- package/android/src/main/java/com/libyuvresizer/ExifCopier.kt +133 -0
- package/android/src/main/java/com/libyuvresizer/LibyuvResizerModule.kt +179 -0
- package/android/src/main/java/com/libyuvresizer/LibyuvResizerPackage.kt +30 -0
- package/android/src/main/java/com/libyuvresizer/ResizeValidator.kt +71 -0
- package/android/src/test/java/com/libyuvresizer/DimensionCalculatorTest.kt +181 -0
- package/android/src/test/java/com/libyuvresizer/ResizeValidatorTest.kt +203 -0
- package/ios/LibyuvResizer.h +6 -0
- package/ios/LibyuvResizer.mm +31 -0
- package/lib/module/NativeLibyuvResizer.js +28 -0
- package/lib/module/NativeLibyuvResizer.js.map +1 -0
- package/lib/module/index.js +20 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/resizer.js +15 -0
- package/lib/module/resizer.js.map +1 -0
- package/lib/module/resizer.native.js +110 -0
- package/lib/module/resizer.native.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/NativeLibyuvResizer.d.ts +52 -0
- package/lib/typescript/src/NativeLibyuvResizer.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +19 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/resizer.d.ts +13 -0
- package/lib/typescript/src/resizer.d.ts.map +1 -0
- package/lib/typescript/src/resizer.native.d.ts +119 -0
- package/lib/typescript/src/resizer.native.d.ts.map +1 -0
- package/package.json +184 -0
- package/src/NativeLibyuvResizer.ts +81 -0
- package/src/index.tsx +23 -0
- package/src/resizer.native.tsx +175 -0
- package/src/resizer.tsx +31 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
package com.libyuvresizer
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.graphics.BitmapFactory
|
|
5
|
+
import android.net.Uri
|
|
6
|
+
import android.os.Build
|
|
7
|
+
import com.facebook.react.bridge.Arguments
|
|
8
|
+
import com.facebook.react.bridge.Promise
|
|
9
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
10
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
11
|
+
import java.io.File
|
|
12
|
+
import java.io.FileOutputStream
|
|
13
|
+
import java.io.IOException
|
|
14
|
+
import java.util.UUID
|
|
15
|
+
import androidx.core.graphics.createBitmap
|
|
16
|
+
|
|
17
|
+
@ReactModule(name = LibyuvResizerModule.NAME)
|
|
18
|
+
class LibyuvResizerModule(reactContext: ReactApplicationContext) :
|
|
19
|
+
NativeLibyuvResizerSpec(reactContext) {
|
|
20
|
+
|
|
21
|
+
companion object {
|
|
22
|
+
const val NAME = NativeLibyuvResizerSpec.NAME
|
|
23
|
+
|
|
24
|
+
private val FILTER_MODE_MAP = mapOf("none" to 0, "linear" to 1, "bilinear" to 2, "box" to 3)
|
|
25
|
+
|
|
26
|
+
@Suppress("DEPRECATION")
|
|
27
|
+
fun formatToExtAndCompressFormat(format: String): Pair<String, Bitmap.CompressFormat> =
|
|
28
|
+
when (format) {
|
|
29
|
+
"png" -> "png" to Bitmap.CompressFormat.PNG
|
|
30
|
+
"webp" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
|
31
|
+
"webp" to Bitmap.CompressFormat.WEBP_LOSSY
|
|
32
|
+
} else {
|
|
33
|
+
"webp" to Bitmap.CompressFormat.WEBP
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
else -> "jpg" to Bitmap.CompressFormat.JPEG
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
init {
|
|
40
|
+
System.loadLibrary("libyuvresizer")
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private external fun nativeResize(srcBitmap: Bitmap, dstBitmap: Bitmap, filterMode: Int)
|
|
45
|
+
private external fun nativeResizeAndRotate(
|
|
46
|
+
srcBitmap: Bitmap,
|
|
47
|
+
dstBitmap: Bitmap,
|
|
48
|
+
rotation: Int,
|
|
49
|
+
filterMode: Int
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
override fun resize(
|
|
53
|
+
filePath: String,
|
|
54
|
+
targetWidth: Double,
|
|
55
|
+
targetHeight: Double,
|
|
56
|
+
quality: Double,
|
|
57
|
+
rotation: Double,
|
|
58
|
+
mode: String,
|
|
59
|
+
outputPath: String,
|
|
60
|
+
filterMode: String,
|
|
61
|
+
keepMeta: Boolean,
|
|
62
|
+
format: String,
|
|
63
|
+
promise: Promise
|
|
64
|
+
) {
|
|
65
|
+
try {
|
|
66
|
+
val targetW = targetWidth.toInt()
|
|
67
|
+
val targetH = targetHeight.toInt()
|
|
68
|
+
val q = quality.toInt()
|
|
69
|
+
val rot = rotation.toInt()
|
|
70
|
+
|
|
71
|
+
val params = ResizeParams(
|
|
72
|
+
filePath,
|
|
73
|
+
targetW,
|
|
74
|
+
targetH,
|
|
75
|
+
q,
|
|
76
|
+
rot,
|
|
77
|
+
mode,
|
|
78
|
+
outputPath,
|
|
79
|
+
filterMode,
|
|
80
|
+
keepMeta,
|
|
81
|
+
format
|
|
82
|
+
)
|
|
83
|
+
when (val result = ResizeValidator.validate(params)) {
|
|
84
|
+
is ValidationResult.Invalid -> {
|
|
85
|
+
promise.reject(result.code, result.message)
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
is ValidationResult.Valid -> Unit
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
val bitmapConfig = Bitmap.Config.ARGB_8888
|
|
93
|
+
|
|
94
|
+
val boundsOpts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
95
|
+
BitmapFactory.decodeFile(filePath, boundsOpts)
|
|
96
|
+
|
|
97
|
+
val decodeOpts = BitmapFactory.Options().apply {
|
|
98
|
+
inSampleSize =
|
|
99
|
+
DimensionCalculator.calculateInSampleSize(
|
|
100
|
+
boundsOpts.outWidth,
|
|
101
|
+
boundsOpts.outHeight,
|
|
102
|
+
targetW,
|
|
103
|
+
targetH
|
|
104
|
+
)
|
|
105
|
+
inPreferredConfig = bitmapConfig
|
|
106
|
+
}
|
|
107
|
+
val srcBitmap = BitmapFactory.decodeFile(filePath, decodeOpts)
|
|
108
|
+
?: run {
|
|
109
|
+
promise.reject("E_DECODE_FAILED", "Failed to decode image")
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// srcBitmap must be recycled even if dstBitmap allocation throws OOM
|
|
114
|
+
try {
|
|
115
|
+
val srcW = srcBitmap.width.toDouble()
|
|
116
|
+
val srcH = srcBitmap.height.toDouble()
|
|
117
|
+
|
|
118
|
+
// cover scales to fill target on both axes — no cropping applied by design
|
|
119
|
+
val (dstW, dstH) = DimensionCalculator.computeDstDims(
|
|
120
|
+
srcW,
|
|
121
|
+
srcH,
|
|
122
|
+
targetW,
|
|
123
|
+
targetH,
|
|
124
|
+
rot,
|
|
125
|
+
mode
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
val dstBitmap = createBitmap(dstW, dstH, bitmapConfig)
|
|
129
|
+
try {
|
|
130
|
+
val filterModeInt = FILTER_MODE_MAP.getValue(filterMode)
|
|
131
|
+
if (rot == 0) {
|
|
132
|
+
nativeResize(srcBitmap, dstBitmap, filterModeInt)
|
|
133
|
+
} else {
|
|
134
|
+
nativeResizeAndRotate(srcBitmap, dstBitmap, rot, filterModeInt)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
val (ext, compressFmt) = formatToExtAndCompressFormat(params.format)
|
|
138
|
+
val outFile = resolveOutputFile(filePath, outputPath, ext)
|
|
139
|
+
FileOutputStream(outFile).use { fos ->
|
|
140
|
+
dstBitmap.compress(compressFmt, q, fos)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (params.keepMeta && params.format == "jpeg") {
|
|
144
|
+
try {
|
|
145
|
+
ExifCopier.copy(filePath, outFile.absolutePath)
|
|
146
|
+
} catch (e: IOException) {
|
|
147
|
+
promise.reject("E_EXIF_WRITE_FAILED", e.message ?: "Failed to write EXIF metadata")
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
val result = Arguments.createMap().apply {
|
|
153
|
+
putString("path", outFile.absolutePath)
|
|
154
|
+
putString("uri", Uri.fromFile(outFile).toString())
|
|
155
|
+
putDouble("size", outFile.length().toDouble())
|
|
156
|
+
putString("name", outFile.name)
|
|
157
|
+
putInt("width", dstBitmap.width)
|
|
158
|
+
putInt("height", dstBitmap.height)
|
|
159
|
+
}
|
|
160
|
+
promise.resolve(result)
|
|
161
|
+
} finally {
|
|
162
|
+
dstBitmap.recycle()
|
|
163
|
+
}
|
|
164
|
+
} finally {
|
|
165
|
+
srcBitmap.recycle()
|
|
166
|
+
}
|
|
167
|
+
} catch (e: Exception) {
|
|
168
|
+
promise.reject("E_UNKNOWN", e.message ?: "Unknown error")
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun resolveOutputFile(inputFilePath: String, outputPath: String, ext: String): File {
|
|
173
|
+
if (outputPath.isEmpty()) {
|
|
174
|
+
return File(reactApplicationContext.cacheDir, "${UUID.randomUUID()}.$ext")
|
|
175
|
+
}
|
|
176
|
+
return File(outputPath, File(inputFilePath).name)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
package com.libyuvresizer
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.TurboReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.module.model.ReactModuleInfo
|
|
7
|
+
import com.facebook.react.module.model.ReactModuleInfoProvider
|
|
8
|
+
|
|
9
|
+
class LibyuvResizerPackage : TurboReactPackage() {
|
|
10
|
+
override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? {
|
|
11
|
+
return if (name == LibyuvResizerModule.NAME) {
|
|
12
|
+
LibyuvResizerModule(reactContext)
|
|
13
|
+
} else {
|
|
14
|
+
null
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
override fun getReactModuleInfoProvider() = ReactModuleInfoProvider {
|
|
19
|
+
mapOf(
|
|
20
|
+
LibyuvResizerModule.NAME to ReactModuleInfo(
|
|
21
|
+
name = LibyuvResizerModule.NAME,
|
|
22
|
+
className = LibyuvResizerModule.NAME,
|
|
23
|
+
canOverrideExistingModule = false,
|
|
24
|
+
needsEagerInit = false,
|
|
25
|
+
isCxxModule = false,
|
|
26
|
+
isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
|
|
27
|
+
)
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
package com.libyuvresizer
|
|
2
|
+
|
|
3
|
+
import java.io.File
|
|
4
|
+
|
|
5
|
+
data class ResizeParams(
|
|
6
|
+
val filePath: String,
|
|
7
|
+
val targetWidth: Int,
|
|
8
|
+
val targetHeight: Int,
|
|
9
|
+
val quality: Int,
|
|
10
|
+
val rotation: Int,
|
|
11
|
+
val mode: String,
|
|
12
|
+
val outputPath: String,
|
|
13
|
+
val filterMode: String,
|
|
14
|
+
val keepMeta: Boolean = false,
|
|
15
|
+
val format: String = "jpeg"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
sealed class ValidationResult {
|
|
19
|
+
object Valid : ValidationResult()
|
|
20
|
+
data class Invalid(val code: String, val message: String) : ValidationResult()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
object ResizeValidator {
|
|
24
|
+
private val VALID_MODES = setOf("contain", "cover", "stretch")
|
|
25
|
+
private val VALID_FILTER_MODES = setOf("none", "linear", "bilinear", "box")
|
|
26
|
+
private val VALID_ROTATIONS = setOf(0, 90, 180, 270)
|
|
27
|
+
private val VALID_FORMATS = setOf("jpeg", "png", "webp")
|
|
28
|
+
|
|
29
|
+
fun validate(params: ResizeParams): ValidationResult {
|
|
30
|
+
if (!File(params.filePath).exists())
|
|
31
|
+
return ValidationResult.Invalid("E_FILE_NOT_FOUND", "File not found: ${params.filePath}")
|
|
32
|
+
if (params.targetWidth <= 0 || params.targetHeight <= 0)
|
|
33
|
+
return ValidationResult.Invalid("E_INVALID_DIMS", "Invalid dimensions")
|
|
34
|
+
if (params.quality !in 1..100)
|
|
35
|
+
return ValidationResult.Invalid("E_INVALID_QUALITY", "Quality must be between 1 and 100")
|
|
36
|
+
if (params.rotation !in VALID_ROTATIONS)
|
|
37
|
+
return ValidationResult.Invalid(
|
|
38
|
+
"E_INVALID_ROTATION",
|
|
39
|
+
"rotation must be 0, 90, 180 or 270, got: ${params.rotation}"
|
|
40
|
+
)
|
|
41
|
+
if (params.mode !in VALID_MODES)
|
|
42
|
+
return ValidationResult.Invalid(
|
|
43
|
+
"E_INVALID_MODE",
|
|
44
|
+
"mode must be contain, cover, or stretch, got: ${params.mode}"
|
|
45
|
+
)
|
|
46
|
+
if (params.filterMode !in VALID_FILTER_MODES)
|
|
47
|
+
return ValidationResult.Invalid(
|
|
48
|
+
"E_INVALID_FILTER_MODE",
|
|
49
|
+
"filterMode must be none, linear, bilinear, or box, got: ${params.filterMode}"
|
|
50
|
+
)
|
|
51
|
+
if (params.format !in VALID_FORMATS)
|
|
52
|
+
return ValidationResult.Invalid(
|
|
53
|
+
"E_INVALID_FORMAT",
|
|
54
|
+
"format must be jpeg, png, or webp, got: ${params.format}"
|
|
55
|
+
)
|
|
56
|
+
if (params.outputPath.isNotEmpty()) {
|
|
57
|
+
val dir = File(params.outputPath)
|
|
58
|
+
if (!dir.exists())
|
|
59
|
+
return ValidationResult.Invalid(
|
|
60
|
+
"E_INVALID_OUTPUT_PATH",
|
|
61
|
+
"Output directory does not exist: ${params.outputPath}"
|
|
62
|
+
)
|
|
63
|
+
if (!dir.isDirectory)
|
|
64
|
+
return ValidationResult.Invalid(
|
|
65
|
+
"E_INVALID_OUTPUT_PATH",
|
|
66
|
+
"outputPath must be a directory, not a file: ${params.outputPath}"
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return ValidationResult.Valid
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
package com.libyuvresizer
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertTrue
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
|
|
7
|
+
class DimensionCalculatorTest {
|
|
8
|
+
|
|
9
|
+
// --- calculateInSampleSize ---
|
|
10
|
+
|
|
11
|
+
@Test
|
|
12
|
+
fun `returns 1 when src smaller than dst`() {
|
|
13
|
+
assertEquals(1, DimensionCalculator.calculateInSampleSize(100, 100, 200, 200))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Test
|
|
17
|
+
fun `returns 1 for equal dimensions`() {
|
|
18
|
+
assertEquals(1, DimensionCalculator.calculateInSampleSize(200, 200, 200, 200))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
@Test
|
|
22
|
+
fun `returns 2 when src is double dst`() {
|
|
23
|
+
assertEquals(2, DimensionCalculator.calculateInSampleSize(400, 400, 200, 200))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun `returns 4 when src is 4x dst`() {
|
|
28
|
+
assertEquals(4, DimensionCalculator.calculateInSampleSize(800, 800, 200, 200))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@Test
|
|
32
|
+
fun `returns 1 when only one axis exceeds but loop condition requires both`() {
|
|
33
|
+
// srcW=400 > dstW=200, but srcH=100 < dstH=200 — loop exits at sampleSize=1
|
|
34
|
+
assertEquals(1, DimensionCalculator.calculateInSampleSize(400, 100, 200, 200))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
@Test
|
|
38
|
+
fun `returns correct sampleSize for odd source dimensions`() {
|
|
39
|
+
// srcW=401 srcH=401: halfW=halfH=200 — same as 400x400 case
|
|
40
|
+
assertEquals(2, DimensionCalculator.calculateInSampleSize(401, 401, 200, 200))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// --- scaleBy ---
|
|
44
|
+
|
|
45
|
+
@Test
|
|
46
|
+
fun `scaleBy 2x doubles dimensions`() {
|
|
47
|
+
assertEquals(Pair(200, 100), DimensionCalculator.scaleBy(2.0, 100.0, 50.0))
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@Test
|
|
51
|
+
fun `scaleBy 0_5 halves dimensions`() {
|
|
52
|
+
assertEquals(Pair(50, 25), DimensionCalculator.scaleBy(0.5, 100.0, 50.0))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun `scaleBy minimum output is 1`() {
|
|
57
|
+
assertEquals(Pair(1, 1), DimensionCalculator.scaleBy(0.0001, 1.0, 1.0))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@Test
|
|
61
|
+
fun `scaleBy rounds correctly`() {
|
|
62
|
+
// 100 * 0.056 = 5.6 -> 6
|
|
63
|
+
assertEquals(Pair(6, 6), DimensionCalculator.scaleBy(0.056, 100.0, 100.0))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- computeDstDims: stretch ---
|
|
67
|
+
|
|
68
|
+
@Test
|
|
69
|
+
fun `stretch returns exact target dims`() {
|
|
70
|
+
assertEquals(Pair(100, 50), DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 50, 0, "stretch"))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
@Test
|
|
74
|
+
fun `stretch ignores src dimensions`() {
|
|
75
|
+
assertEquals(Pair(300, 200), DimensionCalculator.computeDstDims(10.0, 10.0, 300, 200, 0, "stretch"))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// --- computeDstDims: contain ---
|
|
79
|
+
|
|
80
|
+
@Test
|
|
81
|
+
fun `contain 16x9 src in square box constrains width`() {
|
|
82
|
+
// 1920x1080 contain in 100x100: scale = min(100/1920, 100/1080) = 0.052... (width-limited)
|
|
83
|
+
val (w, h) = DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 100, 0, "contain")
|
|
84
|
+
assertEquals(100, w)
|
|
85
|
+
assertEquals(56, h) // 1080 * (100/1920) ≈ 56.25 -> 56
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
@Test
|
|
89
|
+
fun `contain portrait src in landscape box constrains height`() {
|
|
90
|
+
// 100x200 contain in 200x100: scale = min(200/100, 100/200) = 0.5 (height-limited)
|
|
91
|
+
val (w, h) = DimensionCalculator.computeDstDims(100.0, 200.0, 200, 100, 0, "contain")
|
|
92
|
+
assertEquals(50, w) // 100 * 0.5
|
|
93
|
+
assertEquals(100, h) // 200 * 0.5
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun `contain output never exceeds target on either axis`() {
|
|
98
|
+
val (w, h) = DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 100, 0, "contain")
|
|
99
|
+
assertTrue("w=$w exceeds target", w <= 100)
|
|
100
|
+
assertTrue("h=$h exceeds target", h <= 100)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test
|
|
104
|
+
fun `contain never exceeds target for small integer dimensions`() {
|
|
105
|
+
// exhaustive check over small src and target combos — previously broken with roundToInt
|
|
106
|
+
for (sw in 1..20) for (sh in 1..20) for (tw in 1..20) for (th in 1..20) {
|
|
107
|
+
val (w, h) = DimensionCalculator.computeDstDims(sw.toDouble(), sh.toDouble(), tw, th, 0, "contain")
|
|
108
|
+
assertTrue("src=${sw}x${sh} target=${tw}x${th} → w=$w > tw=$tw", w <= tw)
|
|
109
|
+
assertTrue("src=${sw}x${sh} target=${tw}x${th} → h=$h > th=$th", h <= th)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// --- computeDstDims: cover ---
|
|
114
|
+
|
|
115
|
+
@Test
|
|
116
|
+
fun `cover 16x9 src in square box fills on both axes`() {
|
|
117
|
+
// 1920x1080 cover 100x100: scale = max(100/1920, 100/1080) = 0.0926... (height-driven)
|
|
118
|
+
val (w, h) = DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 100, 0, "cover")
|
|
119
|
+
assertEquals(100, h)
|
|
120
|
+
assertTrue("w=$w should exceed or equal target", w >= 100)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
@Test
|
|
124
|
+
fun `cover output meets target on at least one axis`() {
|
|
125
|
+
val (w, h) = DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 100, 0, "cover")
|
|
126
|
+
assertTrue("neither axis meets target: w=$w h=$h", w >= 100 || h >= 100)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@Test
|
|
130
|
+
fun `cover 16x9 src in square box exact output width`() {
|
|
131
|
+
// scale = max(100/1920, 100/1080) = 100/1080 = 0.09259...
|
|
132
|
+
// w = round(1920 * 0.09259...) = round(177.77...) = 178
|
|
133
|
+
// h = round(1080 * 0.09259...) = round(100.0) = 100
|
|
134
|
+
val (w, h) = DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 100, 0, "cover")
|
|
135
|
+
assertEquals(178, w)
|
|
136
|
+
assertEquals(100, h)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- computeDstDims: rotation ---
|
|
140
|
+
|
|
141
|
+
@Test
|
|
142
|
+
fun `rotation 90 swaps effective dims for contain`() {
|
|
143
|
+
// src 100x200, rotation=90 → effectiveW=200, effectiveH=100
|
|
144
|
+
// contain in 100x100: scale = min(100/200, 100/100) = 0.5
|
|
145
|
+
// result: scaleBy(0.5, 200, 100) = (100, 50)
|
|
146
|
+
val (w, h) = DimensionCalculator.computeDstDims(100.0, 200.0, 100, 100, 90, "contain")
|
|
147
|
+
assertEquals(100, w)
|
|
148
|
+
assertEquals(50, h)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@Test
|
|
152
|
+
fun `rotation 270 swaps effective dims same as 90`() {
|
|
153
|
+
val result90 = DimensionCalculator.computeDstDims(100.0, 200.0, 100, 100, 90, "contain")
|
|
154
|
+
val result270 = DimensionCalculator.computeDstDims(100.0, 200.0, 100, 100, 270, "contain")
|
|
155
|
+
assertEquals(result90, result270)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@Test
|
|
159
|
+
fun `rotation 0 does not swap dims for contain`() {
|
|
160
|
+
// src 100x200, rotation=0 → effectiveW=100, effectiveH=200
|
|
161
|
+
// contain in 100x100: scale = min(100/100, 100/200) = 0.5
|
|
162
|
+
// result: scaleBy(0.5, 100, 200) = (50, 100)
|
|
163
|
+
val (w, h) = DimensionCalculator.computeDstDims(100.0, 200.0, 100, 100, 0, "contain")
|
|
164
|
+
assertEquals(50, w)
|
|
165
|
+
assertEquals(100, h)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@Test
|
|
169
|
+
fun `rotation 180 does not swap dims`() {
|
|
170
|
+
val result0 = DimensionCalculator.computeDstDims(100.0, 200.0, 100, 100, 0, "contain")
|
|
171
|
+
val result180 = DimensionCalculator.computeDstDims(100.0, 200.0, 100, 100, 180, "contain")
|
|
172
|
+
assertEquals(result0, result180)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
@Test
|
|
176
|
+
fun `stretch ignores rotation`() {
|
|
177
|
+
val result0 = DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 50, 0, "stretch")
|
|
178
|
+
val result90 = DimensionCalculator.computeDstDims(1920.0, 1080.0, 100, 50, 90, "stretch")
|
|
179
|
+
assertEquals(result0, result90)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
package com.libyuvresizer
|
|
2
|
+
|
|
3
|
+
import org.junit.Assert.assertEquals
|
|
4
|
+
import org.junit.Assert.assertTrue
|
|
5
|
+
import org.junit.Rule
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
import org.junit.rules.TemporaryFolder
|
|
8
|
+
|
|
9
|
+
class ResizeValidatorTest {
|
|
10
|
+
|
|
11
|
+
@get:Rule
|
|
12
|
+
val tmp = TemporaryFolder()
|
|
13
|
+
|
|
14
|
+
private fun validParams(filePath: String, outputPath: String = "") = ResizeParams(
|
|
15
|
+
filePath = filePath,
|
|
16
|
+
targetWidth = 100,
|
|
17
|
+
targetHeight = 100,
|
|
18
|
+
quality = 80,
|
|
19
|
+
rotation = 0,
|
|
20
|
+
mode = "contain",
|
|
21
|
+
outputPath = outputPath,
|
|
22
|
+
filterMode = "bilinear"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
// --- happy path ---
|
|
26
|
+
|
|
27
|
+
@Test
|
|
28
|
+
fun `valid params with empty outputPath returns Valid`() {
|
|
29
|
+
val file = tmp.newFile("image.jpg")
|
|
30
|
+
assertEquals(ValidationResult.Valid, ResizeValidator.validate(validParams(file.absolutePath)))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
@Test
|
|
34
|
+
fun `valid params with directory outputPath returns Valid`() {
|
|
35
|
+
val file = tmp.newFile("image.jpg")
|
|
36
|
+
val dir = tmp.newFolder("out")
|
|
37
|
+
assertEquals(ValidationResult.Valid, ResizeValidator.validate(validParams(file.absolutePath, dir.absolutePath)))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Test
|
|
41
|
+
fun `all valid rotations accepted`() {
|
|
42
|
+
val file = tmp.newFile("image.jpg")
|
|
43
|
+
for (rot in listOf(0, 90, 180, 270)) {
|
|
44
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(rotation = rot))
|
|
45
|
+
assertEquals("rotation $rot should be Valid", ValidationResult.Valid, result)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
@Test
|
|
50
|
+
fun `all valid modes accepted`() {
|
|
51
|
+
val file = tmp.newFile("image.jpg")
|
|
52
|
+
for (mode in listOf("contain", "cover", "stretch")) {
|
|
53
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(mode = mode))
|
|
54
|
+
assertEquals("mode $mode should be Valid", ValidationResult.Valid, result)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@Test
|
|
59
|
+
fun `all valid filterModes accepted`() {
|
|
60
|
+
val file = tmp.newFile("image.jpg")
|
|
61
|
+
for (fm in listOf("none", "linear", "bilinear", "box")) {
|
|
62
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(filterMode = fm))
|
|
63
|
+
assertEquals("filterMode $fm should be Valid", ValidationResult.Valid, result)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Test
|
|
68
|
+
fun `all valid formats accepted`() {
|
|
69
|
+
val file = tmp.newFile("image.jpg")
|
|
70
|
+
for (fmt in listOf("jpeg", "png", "webp")) {
|
|
71
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(format = fmt))
|
|
72
|
+
assertEquals("format $fmt should be Valid", ValidationResult.Valid, result)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@Test
|
|
77
|
+
fun `quality boundary 1 accepted`() {
|
|
78
|
+
val file = tmp.newFile("image.jpg")
|
|
79
|
+
assertEquals(ValidationResult.Valid, ResizeValidator.validate(validParams(file.absolutePath).copy(quality = 1)))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@Test
|
|
83
|
+
fun `quality boundary 100 accepted`() {
|
|
84
|
+
val file = tmp.newFile("image.jpg")
|
|
85
|
+
assertEquals(ValidationResult.Valid, ResizeValidator.validate(validParams(file.absolutePath).copy(quality = 100)))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- error codes ---
|
|
89
|
+
|
|
90
|
+
private fun assertCode(expected: String, params: ResizeParams) {
|
|
91
|
+
val result = ResizeValidator.validate(params)
|
|
92
|
+
assertTrue("expected Invalid, got $result", result is ValidationResult.Invalid)
|
|
93
|
+
assertEquals(expected, (result as ValidationResult.Invalid).code)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
@Test
|
|
97
|
+
fun `missing file returns E_FILE_NOT_FOUND`() {
|
|
98
|
+
assertCode("E_FILE_NOT_FOUND", validParams("/no/such/file.jpg"))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
@Test
|
|
102
|
+
fun `zero width returns E_INVALID_DIMS`() {
|
|
103
|
+
val file = tmp.newFile("image.jpg")
|
|
104
|
+
assertCode("E_INVALID_DIMS", validParams(file.absolutePath).copy(targetWidth = 0))
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Test
|
|
108
|
+
fun `negative height returns E_INVALID_DIMS`() {
|
|
109
|
+
val file = tmp.newFile("image.jpg")
|
|
110
|
+
assertCode("E_INVALID_DIMS", validParams(file.absolutePath).copy(targetHeight = -1))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
@Test
|
|
114
|
+
fun `quality 0 returns E_INVALID_QUALITY`() {
|
|
115
|
+
val file = tmp.newFile("image.jpg")
|
|
116
|
+
assertCode("E_INVALID_QUALITY", validParams(file.absolutePath).copy(quality = 0))
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
@Test
|
|
120
|
+
fun `quality 101 returns E_INVALID_QUALITY`() {
|
|
121
|
+
val file = tmp.newFile("image.jpg")
|
|
122
|
+
assertCode("E_INVALID_QUALITY", validParams(file.absolutePath).copy(quality = 101))
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun `rotation 45 returns E_INVALID_ROTATION`() {
|
|
127
|
+
val file = tmp.newFile("image.jpg")
|
|
128
|
+
assertCode("E_INVALID_ROTATION", validParams(file.absolutePath).copy(rotation = 45))
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
@Test
|
|
132
|
+
fun `invalid mode returns E_INVALID_MODE`() {
|
|
133
|
+
val file = tmp.newFile("image.jpg")
|
|
134
|
+
assertCode("E_INVALID_MODE", validParams(file.absolutePath).copy(mode = "fill"))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
@Test
|
|
138
|
+
fun `invalid filterMode returns E_INVALID_FILTER_MODE`() {
|
|
139
|
+
val file = tmp.newFile("image.jpg")
|
|
140
|
+
assertCode("E_INVALID_FILTER_MODE", validParams(file.absolutePath).copy(filterMode = "cubic"))
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@Test
|
|
144
|
+
fun `invalid format returns E_INVALID_FORMAT`() {
|
|
145
|
+
val file = tmp.newFile("image.jpg")
|
|
146
|
+
assertCode("E_INVALID_FORMAT", validParams(file.absolutePath).copy(format = "gif"))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
@Test
|
|
150
|
+
fun `empty format returns E_INVALID_FORMAT`() {
|
|
151
|
+
val file = tmp.newFile("image.jpg")
|
|
152
|
+
assertCode("E_INVALID_FORMAT", validParams(file.absolutePath).copy(format = ""))
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Test
|
|
156
|
+
fun `nonexistent outputPath returns E_INVALID_OUTPUT_PATH`() {
|
|
157
|
+
val file = tmp.newFile("image.jpg")
|
|
158
|
+
assertCode("E_INVALID_OUTPUT_PATH", validParams(file.absolutePath, "/nonexistent/dir"))
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
@Test
|
|
162
|
+
fun `outputPath pointing to file returns E_INVALID_OUTPUT_PATH`() {
|
|
163
|
+
val file = tmp.newFile("image.jpg")
|
|
164
|
+
val notDir = tmp.newFile("output.jpg")
|
|
165
|
+
assertCode("E_INVALID_OUTPUT_PATH", validParams(file.absolutePath, notDir.absolutePath))
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// --- error message content ---
|
|
169
|
+
|
|
170
|
+
@Test
|
|
171
|
+
fun `E_FILE_NOT_FOUND message includes path`() {
|
|
172
|
+
val result = ResizeValidator.validate(validParams("/bad/path.jpg")) as ValidationResult.Invalid
|
|
173
|
+
assertTrue(result.message.contains("/bad/path.jpg"))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
@Test
|
|
177
|
+
fun `E_INVALID_ROTATION message includes got value`() {
|
|
178
|
+
val file = tmp.newFile("image.jpg")
|
|
179
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(rotation = 45)) as ValidationResult.Invalid
|
|
180
|
+
assertTrue(result.message.contains("45"))
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
@Test
|
|
184
|
+
fun `E_INVALID_MODE message includes got value`() {
|
|
185
|
+
val file = tmp.newFile("image.jpg")
|
|
186
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(mode = "fill")) as ValidationResult.Invalid
|
|
187
|
+
assertTrue(result.message.contains("fill"))
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
@Test
|
|
191
|
+
fun `E_INVALID_FILTER_MODE message includes got value`() {
|
|
192
|
+
val file = tmp.newFile("image.jpg")
|
|
193
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(filterMode = "cubic")) as ValidationResult.Invalid
|
|
194
|
+
assertTrue(result.message.contains("cubic"))
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
@Test
|
|
198
|
+
fun `E_INVALID_FORMAT message includes got value`() {
|
|
199
|
+
val file = tmp.newFile("image.jpg")
|
|
200
|
+
val result = ResizeValidator.validate(validParams(file.absolutePath).copy(format = "gif")) as ValidationResult.Invalid
|
|
201
|
+
assertTrue(result.message.contains("gif"))
|
|
202
|
+
}
|
|
203
|
+
}
|