Add image compressor
This commit is contained in:
@@ -11,36 +11,79 @@ set(FFMPEG_INCLUDE_DIR
|
||||
set(FFMPEG_LIB_DIR
|
||||
${FFMPEG_ROOT}/lib)
|
||||
|
||||
# Output dari setup_image_compressor_android_*.sh:
|
||||
# cpp/third_party/android-image-compres/<ABI>/include
|
||||
# cpp/third_party/android-image-compres/<ABI>/lib
|
||||
set(IMAGE_COMPRESS_ROOT
|
||||
${CMAKE_SOURCE_DIR}/third_party/android-image-compres/${ANDROID_ABI})
|
||||
|
||||
set(IMAGE_COMPRESS_INCLUDE_DIR
|
||||
${IMAGE_COMPRESS_ROOT}/include)
|
||||
|
||||
set(IMAGE_COMPRESS_LIB_DIR
|
||||
${IMAGE_COMPRESS_ROOT}/lib)
|
||||
|
||||
add_library(kcompressor SHARED
|
||||
native_compressor.cpp)
|
||||
native_compressor.cpp
|
||||
compressor_common.cpp
|
||||
video_compressor.cpp
|
||||
image_compressor.cpp)
|
||||
|
||||
target_link_options(kcompressor PRIVATE
|
||||
-Wl,--allow-multiple-definition
|
||||
-Wl,--exclude-libs,libimagequant.a
|
||||
-Wl,--exclude-libs,liboxipng_ffi.a)
|
||||
|
||||
target_include_directories(kcompressor PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${FFMPEG_INCLUDE_DIR}
|
||||
${FFMPEG_INCLUDE_DIR}/x264
|
||||
${FFMPEG_INCLUDE_DIR}/x265)
|
||||
${FFMPEG_INCLUDE_DIR}/x265
|
||||
${IMAGE_COMPRESS_INCLUDE_DIR})
|
||||
|
||||
function(import_static_required target_name lib_dir lib_name)
|
||||
set(full_path ${lib_dir}/${lib_name})
|
||||
if (NOT EXISTS ${full_path})
|
||||
message(FATAL_ERROR "Missing static library: ${full_path}")
|
||||
endif()
|
||||
|
||||
function(import_ffmpeg_static target_name lib_name)
|
||||
add_library(${target_name} STATIC IMPORTED)
|
||||
set_target_properties(${target_name} PROPERTIES
|
||||
IMPORTED_LOCATION ${FFMPEG_LIB_DIR}/${lib_name})
|
||||
IMPORTED_LOCATION ${full_path})
|
||||
endfunction()
|
||||
|
||||
import_ffmpeg_static(avcodec libavcodec.a)
|
||||
import_ffmpeg_static(avfilter libavfilter.a)
|
||||
import_ffmpeg_static(avformat libavformat.a)
|
||||
import_ffmpeg_static(avutil libavutil.a)
|
||||
import_ffmpeg_static(swresample libswresample.a)
|
||||
import_ffmpeg_static(swscale libswscale.a)
|
||||
import_ffmpeg_static(x264 libx264.a)
|
||||
import_ffmpeg_static(x265 libx265.a)
|
||||
import_static_required(avcodec ${FFMPEG_LIB_DIR} libavcodec.a)
|
||||
import_static_required(avfilter ${FFMPEG_LIB_DIR} libavfilter.a)
|
||||
import_static_required(avformat ${FFMPEG_LIB_DIR} libavformat.a)
|
||||
import_static_required(avutil ${FFMPEG_LIB_DIR} libavutil.a)
|
||||
import_static_required(swresample ${FFMPEG_LIB_DIR} libswresample.a)
|
||||
import_static_required(swscale ${FFMPEG_LIB_DIR} libswscale.a)
|
||||
import_static_required(x264 ${FFMPEG_LIB_DIR} libx264.a)
|
||||
import_static_required(x265 ${FFMPEG_LIB_DIR} libx265.a)
|
||||
|
||||
import_static_required(mozjpeg_jpeg ${IMAGE_COMPRESS_LIB_DIR} libjpeg.a)
|
||||
import_static_required(mozjpeg_turbojpeg ${IMAGE_COMPRESS_LIB_DIR} libturbojpeg.a)
|
||||
import_static_required(webp ${IMAGE_COMPRESS_LIB_DIR} libwebp.a)
|
||||
import_static_required(sharpyuv ${IMAGE_COMPRESS_LIB_DIR} libsharpyuv.a)
|
||||
import_static_required(avif ${IMAGE_COMPRESS_LIB_DIR} libavif.a)
|
||||
import_static_required(aom ${IMAGE_COMPRESS_LIB_DIR} libaom.a)
|
||||
import_static_required(yuv ${IMAGE_COMPRESS_LIB_DIR} libyuv.a)
|
||||
import_static_required(imagequant ${IMAGE_COMPRESS_LIB_DIR} libimagequant.a)
|
||||
import_static_required(oxipng_ffi ${IMAGE_COMPRESS_LIB_DIR} liboxipng_ffi.a)
|
||||
import_static_required(kimage_png ${IMAGE_COMPRESS_LIB_DIR} libkimage_png.a)
|
||||
|
||||
target_compile_features(kcompressor PRIVATE cxx_std_17)
|
||||
|
||||
target_compile_options(kcompressor PRIVATE
|
||||
-Wall
|
||||
-Wextra
|
||||
-Wno-unused-parameter)
|
||||
-Wno-unused-parameter
|
||||
-Wno-deprecated-declarations)
|
||||
|
||||
# --start-group penting karena semua dependency berupa static archive.
|
||||
target_link_libraries(kcompressor
|
||||
-Wl,--start-group
|
||||
|
||||
avfilter
|
||||
avformat
|
||||
avcodec
|
||||
@@ -49,6 +92,18 @@ target_link_libraries(kcompressor
|
||||
avutil
|
||||
x264
|
||||
x265
|
||||
|
||||
mozjpeg_turbojpeg
|
||||
mozjpeg_jpeg
|
||||
webp
|
||||
sharpyuv
|
||||
avif
|
||||
aom
|
||||
yuv
|
||||
imagequant
|
||||
oxipng_ffi
|
||||
kimage_png
|
||||
|
||||
-Wl,--end-group
|
||||
|
||||
android
|
||||
@@ -56,4 +111,5 @@ target_link_libraries(kcompressor
|
||||
z
|
||||
m
|
||||
dl
|
||||
atomic
|
||||
c++_static)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#include "compressor_common.h"
|
||||
|
||||
#include <atomic>
|
||||
|
||||
static std::atomic_bool g_cancel_requested(false);
|
||||
|
||||
void set_cancel_requested(bool value) {
|
||||
g_cancel_requested.store(value, std::memory_order_release);
|
||||
}
|
||||
|
||||
void reset_cancel_requested() {
|
||||
set_cancel_requested(false);
|
||||
}
|
||||
|
||||
bool is_cancel_requested() {
|
||||
return g_cancel_requested.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
void callback_progress(JNIEnv *env, jobject callback, int percent, const std::string &message) {
|
||||
if (!env || !callback) return;
|
||||
|
||||
jclass cls = env->GetObjectClass(callback);
|
||||
if (!cls) return;
|
||||
|
||||
jmethodID mid = env->GetMethodID(cls, "onProgress", "(ILjava/lang/String;)V");
|
||||
if (!mid) {
|
||||
env->DeleteLocalRef(cls);
|
||||
return;
|
||||
}
|
||||
|
||||
jstring jmsg = env->NewStringUTF(message.c_str());
|
||||
if (jmsg) {
|
||||
env->CallVoidMethod(callback, mid, static_cast<jint>(percent), jmsg);
|
||||
env->DeleteLocalRef(jmsg);
|
||||
}
|
||||
|
||||
env->DeleteLocalRef(cls);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
|
||||
void set_cancel_requested(bool value);
|
||||
void reset_cancel_requested();
|
||||
bool is_cancel_requested();
|
||||
|
||||
void callback_progress(JNIEnv *env, jobject callback, int percent, const std::string &message);
|
||||
@@ -0,0 +1,735 @@
|
||||
#include "image_compressor.h"
|
||||
#include "compressor_common.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cerrno>
|
||||
#include <cmath>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <android/log.h>
|
||||
#include <fcntl.h>
|
||||
#include <unistd.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
#include <turbojpeg.h>
|
||||
#include <webp/decode.h>
|
||||
#include <webp/encode.h>
|
||||
#include <avif/avif.h>
|
||||
|
||||
extern "C" {
|
||||
#include <libimagequant.h>
|
||||
#include <oxipng_ffi.h>
|
||||
#include <kimage_png_codec.h>
|
||||
}
|
||||
|
||||
#include <lodepng.h>
|
||||
|
||||
#define LOG_TAG "KCompressorImage"
|
||||
#define LOGD(...) __android_log_print(ANDROID_LOG_DEBUG, LOG_TAG, __VA_ARGS__)
|
||||
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__)
|
||||
|
||||
struct RgbaImage {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
std::vector<uint8_t> pixels;
|
||||
};
|
||||
|
||||
static bool read_all_fd(int fd, std::vector<uint8_t> &out) {
|
||||
if (fd < 0) return false;
|
||||
|
||||
int owned_fd = dup(fd);
|
||||
if (owned_fd < 0) return false;
|
||||
|
||||
if (lseek64(owned_fd, 0, SEEK_SET) < 0) {
|
||||
LOGD("input image fd is not seekable at start: %s", strerror(errno));
|
||||
}
|
||||
|
||||
struct stat st {};
|
||||
if (fstat(owned_fd, &st) == 0 && st.st_size > 0 && st.st_size < 1024LL * 1024LL * 1024LL) {
|
||||
out.reserve(static_cast<size_t>(st.st_size));
|
||||
}
|
||||
|
||||
uint8_t buffer[64 * 1024];
|
||||
while (true) {
|
||||
if (is_cancel_requested()) {
|
||||
close(owned_fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
ssize_t ret = read(owned_fd, buffer, sizeof(buffer));
|
||||
if (ret == 0) break;
|
||||
if (ret < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
close(owned_fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.insert(out.end(), buffer, buffer + ret);
|
||||
}
|
||||
|
||||
close(owned_fd);
|
||||
return !out.empty();
|
||||
}
|
||||
|
||||
static bool write_all_fd(int fd, const std::vector<uint8_t> &data) {
|
||||
if (fd < 0 || data.empty()) return false;
|
||||
|
||||
int owned_fd = dup(fd);
|
||||
if (owned_fd < 0) return false;
|
||||
|
||||
if (lseek64(owned_fd, 0, SEEK_SET) < 0) {
|
||||
LOGD("output image fd is not seekable at start: %s", strerror(errno));
|
||||
}
|
||||
if (ftruncate(owned_fd, 0) < 0) {
|
||||
LOGD("ftruncate image output failed or unsupported: %s", strerror(errno));
|
||||
}
|
||||
|
||||
size_t total = 0;
|
||||
while (total < data.size()) {
|
||||
if (is_cancel_requested()) {
|
||||
close(owned_fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
ssize_t ret = write(owned_fd, data.data() + total, data.size() - total);
|
||||
if (ret < 0) {
|
||||
if (errno == EINTR) continue;
|
||||
close(owned_fd);
|
||||
return false;
|
||||
}
|
||||
if (ret == 0) {
|
||||
close(owned_fd);
|
||||
return false;
|
||||
}
|
||||
|
||||
total += static_cast<size_t>(ret);
|
||||
}
|
||||
|
||||
fsync(owned_fd);
|
||||
close(owned_fd);
|
||||
return true;
|
||||
}
|
||||
|
||||
static int clamp_int(int value, int min_value, int max_value) {
|
||||
return std::max(min_value, std::min(max_value, value));
|
||||
}
|
||||
|
||||
static bool has_prefix(const std::vector<uint8_t> &data, const uint8_t *magic, size_t magic_len) {
|
||||
return data.size() >= magic_len && memcmp(data.data(), magic, magic_len) == 0;
|
||||
}
|
||||
|
||||
static bool looks_like_jpeg(const std::vector<uint8_t> &data) {
|
||||
static const uint8_t magic[] = {0xFF, 0xD8};
|
||||
return has_prefix(data, magic, sizeof(magic));
|
||||
}
|
||||
|
||||
static bool looks_like_png(const std::vector<uint8_t> &data) {
|
||||
static const uint8_t magic[] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
|
||||
return has_prefix(data, magic, sizeof(magic));
|
||||
}
|
||||
|
||||
static bool looks_like_webp(const std::vector<uint8_t> &data) {
|
||||
return data.size() >= 12 &&
|
||||
memcmp(data.data(), "RIFF", 4) == 0 &&
|
||||
memcmp(data.data() + 8, "WEBP", 4) == 0;
|
||||
}
|
||||
|
||||
static bool looks_like_avif(const std::vector<uint8_t> &data) {
|
||||
if (data.size() < 16) return false;
|
||||
const size_t max_scan = std::min<size_t>(data.size(), 64);
|
||||
for (size_t i = 0; i + 8 <= max_scan; ++i) {
|
||||
if (memcmp(data.data() + i, "ftyp", 4) == 0) {
|
||||
const char *brand = reinterpret_cast<const char *>(data.data() + i + 4);
|
||||
return memcmp(brand, "avif", 4) == 0 ||
|
||||
memcmp(brand, "avis", 4) == 0 ||
|
||||
memcmp(brand, "mif1", 4) == 0 ||
|
||||
memcmp(brand, "msf1", 4) == 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool decode_jpeg_rgba(const std::vector<uint8_t> &input, RgbaImage &out) {
|
||||
tjhandle handle = tjInitDecompress();
|
||||
if (!handle) return false;
|
||||
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int subsamp = 0;
|
||||
int colorspace = 0;
|
||||
|
||||
int ret = tjDecompressHeader3(
|
||||
handle,
|
||||
const_cast<unsigned char *>(input.data()),
|
||||
static_cast<unsigned long>(input.size()),
|
||||
&width,
|
||||
&height,
|
||||
&subsamp,
|
||||
&colorspace
|
||||
);
|
||||
|
||||
if (ret != 0 || width <= 0 || height <= 0) {
|
||||
tjDestroy(handle);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = width;
|
||||
out.height = height;
|
||||
out.pixels.assign(static_cast<size_t>(width) * height * 4, 255);
|
||||
|
||||
ret = tjDecompress2(
|
||||
handle,
|
||||
const_cast<unsigned char *>(input.data()),
|
||||
static_cast<unsigned long>(input.size()),
|
||||
out.pixels.data(),
|
||||
width,
|
||||
width * 4,
|
||||
height,
|
||||
TJPF_RGBA,
|
||||
TJFLAG_FASTDCT
|
||||
);
|
||||
|
||||
tjDestroy(handle);
|
||||
return ret == 0;
|
||||
}
|
||||
|
||||
static bool decode_png_rgba(const std::vector<uint8_t> &input, RgbaImage &out) {
|
||||
uint8_t *rgba = nullptr;
|
||||
unsigned width = 0;
|
||||
unsigned height = 0;
|
||||
|
||||
int ret = kimg_png_decode_rgba(
|
||||
input.data(),
|
||||
input.size(),
|
||||
&rgba,
|
||||
&width,
|
||||
&height
|
||||
);
|
||||
|
||||
if (ret != 0 || !rgba || width == 0 || height == 0) {
|
||||
if (rgba) kimg_free(rgba);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = static_cast<int>(width);
|
||||
out.height = static_cast<int>(height);
|
||||
out.pixels.assign(rgba, rgba + static_cast<size_t>(width) * height * 4);
|
||||
kimg_free(rgba);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool decode_webp_rgba(const std::vector<uint8_t> &input, RgbaImage &out) {
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
if (!WebPGetInfo(input.data(), input.size(), &width, &height) || width <= 0 || height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
uint8_t *rgba = WebPDecodeRGBA(input.data(), input.size(), &width, &height);
|
||||
if (!rgba) return false;
|
||||
|
||||
out.width = width;
|
||||
out.height = height;
|
||||
out.pixels.assign(rgba, rgba + static_cast<size_t>(width) * height * 4);
|
||||
WebPFree(rgba);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool decode_avif_rgba(const std::vector<uint8_t> &input, RgbaImage &out) {
|
||||
avifDecoder *decoder = avifDecoderCreate();
|
||||
if (!decoder) return false;
|
||||
|
||||
avifImage *image = avifImageCreateEmpty();
|
||||
if (!image) {
|
||||
avifDecoderDestroy(decoder);
|
||||
return false;
|
||||
}
|
||||
|
||||
avifResult result = avifDecoderReadMemory(decoder, image, input.data(), input.size());
|
||||
if (result != AVIF_RESULT_OK || image->width == 0 || image->height == 0) {
|
||||
avifImageDestroy(image);
|
||||
avifDecoderDestroy(decoder);
|
||||
return false;
|
||||
}
|
||||
|
||||
avifRGBImage rgb;
|
||||
avifRGBImageSetDefaults(&rgb, image);
|
||||
rgb.format = AVIF_RGB_FORMAT_RGBA;
|
||||
rgb.depth = 8;
|
||||
|
||||
if (avifRGBImageAllocatePixels(&rgb) != AVIF_RESULT_OK) {
|
||||
avifImageDestroy(image);
|
||||
avifDecoderDestroy(decoder);
|
||||
return false;
|
||||
}
|
||||
|
||||
result = avifImageYUVToRGB(image, &rgb);
|
||||
if (result != AVIF_RESULT_OK) {
|
||||
avifRGBImageFreePixels(&rgb);
|
||||
avifImageDestroy(image);
|
||||
avifDecoderDestroy(decoder);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.width = static_cast<int>(image->width);
|
||||
out.height = static_cast<int>(image->height);
|
||||
out.pixels.assign(rgb.pixels, rgb.pixels + static_cast<size_t>(rgb.rowBytes) * image->height);
|
||||
|
||||
// Pastikan buffer contiguous width * height * 4, karena rowBytes bisa lebih besar.
|
||||
if (rgb.rowBytes != image->width * 4) {
|
||||
std::vector<uint8_t> compact(static_cast<size_t>(image->width) * image->height * 4);
|
||||
for (uint32_t y = 0; y < image->height; ++y) {
|
||||
memcpy(compact.data() + static_cast<size_t>(y) * image->width * 4,
|
||||
rgb.pixels + static_cast<size_t>(y) * rgb.rowBytes,
|
||||
static_cast<size_t>(image->width) * 4);
|
||||
}
|
||||
out.pixels.swap(compact);
|
||||
}
|
||||
|
||||
avifRGBImageFreePixels(&rgb);
|
||||
avifImageDestroy(image);
|
||||
avifDecoderDestroy(decoder);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool decode_input_rgba(const std::vector<uint8_t> &input,
|
||||
const std::string &input_mime,
|
||||
RgbaImage &out) {
|
||||
std::string mime = input_mime;
|
||||
std::transform(mime.begin(), mime.end(), mime.begin(), ::tolower);
|
||||
|
||||
if (mime.find("jpeg") != std::string::npos || mime.find("jpg") != std::string::npos || looks_like_jpeg(input)) {
|
||||
return decode_jpeg_rgba(input, out);
|
||||
}
|
||||
|
||||
if (mime.find("png") != std::string::npos || looks_like_png(input)) {
|
||||
return decode_png_rgba(input, out);
|
||||
}
|
||||
|
||||
if (mime.find("webp") != std::string::npos || looks_like_webp(input)) {
|
||||
return decode_webp_rgba(input, out);
|
||||
}
|
||||
|
||||
if (mime.find("avif") != std::string::npos || looks_like_avif(input)) {
|
||||
return decode_avif_rgba(input, out);
|
||||
}
|
||||
|
||||
// Fallback by magic.
|
||||
if (looks_like_jpeg(input)) return decode_jpeg_rgba(input, out);
|
||||
if (looks_like_png(input)) return decode_png_rgba(input, out);
|
||||
if (looks_like_webp(input)) return decode_webp_rgba(input, out);
|
||||
if (looks_like_avif(input)) return decode_avif_rgba(input, out);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static RgbaImage resize_rgba_if_needed(const RgbaImage &src, int max_long_side) {
|
||||
if (src.width <= 0 || src.height <= 0 || src.pixels.empty()) {
|
||||
return src;
|
||||
}
|
||||
|
||||
int long_side = std::max(src.width, src.height);
|
||||
if (max_long_side <= 0 || max_long_side >= long_side) {
|
||||
return src;
|
||||
}
|
||||
|
||||
double scale = static_cast<double>(max_long_side) / static_cast<double>(long_side);
|
||||
int dst_w = std::max(1, static_cast<int>(std::llround(src.width * scale)));
|
||||
int dst_h = std::max(1, static_cast<int>(std::llround(src.height * scale)));
|
||||
|
||||
RgbaImage dst;
|
||||
dst.width = dst_w;
|
||||
dst.height = dst_h;
|
||||
dst.pixels.assign(static_cast<size_t>(dst_w) * dst_h * 4, 0);
|
||||
|
||||
const double inv_x = static_cast<double>(src.width) / static_cast<double>(dst_w);
|
||||
const double inv_y = static_cast<double>(src.height) / static_cast<double>(dst_h);
|
||||
|
||||
for (int y = 0; y < dst_h; ++y) {
|
||||
int sy = std::min(src.height - 1, static_cast<int>(y * inv_y));
|
||||
for (int x = 0; x < dst_w; ++x) {
|
||||
int sx = std::min(src.width - 1, static_cast<int>(x * inv_x));
|
||||
|
||||
const uint8_t *sp = src.pixels.data() + (static_cast<size_t>(sy) * src.width + sx) * 4;
|
||||
uint8_t *dp = dst.pixels.data() + (static_cast<size_t>(y) * dst_w + x) * 4;
|
||||
|
||||
dp[0] = sp[0];
|
||||
dp[1] = sp[1];
|
||||
dp[2] = sp[2];
|
||||
dp[3] = sp[3];
|
||||
}
|
||||
}
|
||||
|
||||
return dst;
|
||||
}
|
||||
|
||||
static bool encode_jpeg_rgba(const RgbaImage &image, int quality, std::vector<uint8_t> &out) {
|
||||
tjhandle handle = tjInitCompress();
|
||||
if (!handle) return false;
|
||||
|
||||
unsigned char *jpeg_buf = nullptr;
|
||||
unsigned long jpeg_size = 0;
|
||||
|
||||
int ret = tjCompress2(
|
||||
handle,
|
||||
const_cast<uint8_t *>(image.pixels.data()),
|
||||
image.width,
|
||||
image.width * 4,
|
||||
image.height,
|
||||
TJPF_RGBA,
|
||||
&jpeg_buf,
|
||||
&jpeg_size,
|
||||
TJSAMP_420,
|
||||
clamp_int(quality, 1, 100),
|
||||
TJFLAG_FASTDCT
|
||||
);
|
||||
|
||||
if (ret != 0 || !jpeg_buf || jpeg_size == 0) {
|
||||
if (jpeg_buf) tjFree(jpeg_buf);
|
||||
tjDestroy(handle);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.assign(jpeg_buf, jpeg_buf + jpeg_size);
|
||||
tjFree(jpeg_buf);
|
||||
tjDestroy(handle);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool oxipng_optimize_vector(const std::vector<uint8_t> &png, std::vector<uint8_t> &out, uint8_t level) {
|
||||
uint8_t *optimized = nullptr;
|
||||
size_t optimized_len = 0;
|
||||
|
||||
int ret = oxipng_optimize_memory(
|
||||
png.data(),
|
||||
png.size(),
|
||||
level,
|
||||
1,
|
||||
0,
|
||||
&optimized,
|
||||
&optimized_len
|
||||
);
|
||||
|
||||
if (ret == 0 && optimized && optimized_len > 0) {
|
||||
out.assign(optimized, optimized + optimized_len);
|
||||
oxipng_free(optimized, optimized_len);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (optimized) {
|
||||
oxipng_free(optimized, optimized_len);
|
||||
}
|
||||
|
||||
out = png;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool encode_png_lossless_rgba(const RgbaImage &image, std::vector<uint8_t> &out) {
|
||||
uint8_t *png = nullptr;
|
||||
size_t png_len = 0;
|
||||
|
||||
int ret = kimg_png_encode_rgba(
|
||||
image.pixels.data(),
|
||||
static_cast<unsigned>(image.width),
|
||||
static_cast<unsigned>(image.height),
|
||||
&png,
|
||||
&png_len
|
||||
);
|
||||
|
||||
if (ret != 0 || !png || png_len == 0) {
|
||||
if (png) kimg_free(png);
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encoded(png, png + png_len);
|
||||
kimg_free(png);
|
||||
|
||||
return oxipng_optimize_vector(encoded, out, 3);
|
||||
}
|
||||
|
||||
static bool encode_png_lossy_quantized(const RgbaImage &image, int quality, std::vector<uint8_t> &out) {
|
||||
liq_attr *attr = liq_attr_create();
|
||||
if (!attr) return false;
|
||||
|
||||
const int max_quality = clamp_int(quality, 1, 100);
|
||||
const int min_quality = std::max(0, max_quality - 25);
|
||||
|
||||
liq_set_max_colors(attr, 256);
|
||||
liq_set_quality(attr, min_quality, max_quality);
|
||||
liq_set_speed(attr, 4);
|
||||
|
||||
liq_image *liq_img = liq_image_create_rgba(
|
||||
attr,
|
||||
const_cast<uint8_t *>(image.pixels.data()),
|
||||
image.width,
|
||||
image.height,
|
||||
0.0
|
||||
);
|
||||
|
||||
if (!liq_img) {
|
||||
liq_attr_destroy(attr);
|
||||
return false;
|
||||
}
|
||||
|
||||
liq_result *result = nullptr;
|
||||
liq_error err = liq_image_quantize(liq_img, attr, &result);
|
||||
if (err != LIQ_OK || !result) {
|
||||
liq_image_destroy(liq_img);
|
||||
liq_attr_destroy(attr);
|
||||
return false;
|
||||
}
|
||||
|
||||
liq_set_dithering_level(result, 0.7f);
|
||||
|
||||
std::vector<uint8_t> indexed(static_cast<size_t>(image.width) * image.height);
|
||||
err = liq_write_remapped_image(result, liq_img, indexed.data(), indexed.size());
|
||||
if (err != LIQ_OK) {
|
||||
liq_result_destroy(result);
|
||||
liq_image_destroy(liq_img);
|
||||
liq_attr_destroy(attr);
|
||||
return false;
|
||||
}
|
||||
|
||||
const liq_palette *palette = liq_get_palette(result);
|
||||
if (!palette || palette->count <= 0) {
|
||||
liq_result_destroy(result);
|
||||
liq_image_destroy(liq_img);
|
||||
liq_attr_destroy(attr);
|
||||
return false;
|
||||
}
|
||||
|
||||
lodepng::State state;
|
||||
state.encoder.auto_convert = 0;
|
||||
state.info_raw.colortype = LCT_PALETTE;
|
||||
state.info_raw.bitdepth = 8;
|
||||
state.info_png.color.colortype = LCT_PALETTE;
|
||||
state.info_png.color.bitdepth = 8;
|
||||
|
||||
for (unsigned int i = 0; i < palette->count; ++i) {
|
||||
const liq_color &c = palette->entries[i];
|
||||
lodepng_palette_add(&state.info_raw, c.r, c.g, c.b, c.a);
|
||||
lodepng_palette_add(&state.info_png.color, c.r, c.g, c.b, c.a);
|
||||
}
|
||||
|
||||
std::vector<unsigned char> png;
|
||||
unsigned lode_err = lodepng::encode(
|
||||
png,
|
||||
indexed,
|
||||
static_cast<unsigned>(image.width),
|
||||
static_cast<unsigned>(image.height),
|
||||
state
|
||||
);
|
||||
|
||||
liq_result_destroy(result);
|
||||
liq_image_destroy(liq_img);
|
||||
liq_attr_destroy(attr);
|
||||
|
||||
if (lode_err != 0 || png.empty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<uint8_t> encoded(png.begin(), png.end());
|
||||
return oxipng_optimize_vector(encoded, out, 4);
|
||||
}
|
||||
|
||||
static bool encode_webp_rgba(const RgbaImage &image, int quality, bool lossless, std::vector<uint8_t> &out) {
|
||||
uint8_t *webp = nullptr;
|
||||
size_t webp_size = 0;
|
||||
|
||||
if (lossless) {
|
||||
webp_size = WebPEncodeLosslessRGBA(
|
||||
image.pixels.data(),
|
||||
image.width,
|
||||
image.height,
|
||||
image.width * 4,
|
||||
&webp
|
||||
);
|
||||
} else {
|
||||
webp_size = WebPEncodeRGBA(
|
||||
image.pixels.data(),
|
||||
image.width,
|
||||
image.height,
|
||||
image.width * 4,
|
||||
static_cast<float>(clamp_int(quality, 1, 100)),
|
||||
&webp
|
||||
);
|
||||
}
|
||||
|
||||
if (!webp || webp_size == 0) {
|
||||
if (webp) WebPFree(webp);
|
||||
return false;
|
||||
}
|
||||
|
||||
out.assign(webp, webp + webp_size);
|
||||
WebPFree(webp);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool encode_avif_rgba(const RgbaImage &image, int quality, std::vector<uint8_t> &out) {
|
||||
avifImage *avif = avifImageCreate(
|
||||
static_cast<uint32_t>(image.width),
|
||||
static_cast<uint32_t>(image.height),
|
||||
8,
|
||||
AVIF_PIXEL_FORMAT_YUV444
|
||||
);
|
||||
|
||||
if (!avif) return false;
|
||||
|
||||
avifRGBImage rgb;
|
||||
avifRGBImageSetDefaults(&rgb, avif);
|
||||
rgb.format = AVIF_RGB_FORMAT_RGBA;
|
||||
rgb.depth = 8;
|
||||
rgb.pixels = const_cast<uint8_t *>(image.pixels.data());
|
||||
rgb.rowBytes = static_cast<uint32_t>(image.width * 4);
|
||||
|
||||
avifResult result = avifImageRGBToYUV(avif, &rgb);
|
||||
if (result != AVIF_RESULT_OK) {
|
||||
avifImageDestroy(avif);
|
||||
return false;
|
||||
}
|
||||
|
||||
avifEncoder *encoder = avifEncoderCreate();
|
||||
if (!encoder) {
|
||||
avifImageDestroy(avif);
|
||||
return false;
|
||||
}
|
||||
|
||||
encoder->maxThreads = 0;
|
||||
encoder->speed = 6;
|
||||
encoder->quality = clamp_int(quality, 1, 100);
|
||||
encoder->qualityAlpha = clamp_int(quality, 1, 100);
|
||||
|
||||
avifRWData encoded = AVIF_DATA_EMPTY;
|
||||
result = avifEncoderWrite(encoder, avif, &encoded);
|
||||
|
||||
if (result == AVIF_RESULT_OK && encoded.data && encoded.size > 0) {
|
||||
out.assign(encoded.data, encoded.data + encoded.size);
|
||||
avifRWDataFree(&encoded);
|
||||
avifEncoderDestroy(encoder);
|
||||
avifImageDestroy(avif);
|
||||
return true;
|
||||
}
|
||||
|
||||
avifRWDataFree(&encoded);
|
||||
avifEncoderDestroy(encoder);
|
||||
avifImageDestroy(avif);
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool encode_output(const RgbaImage &image,
|
||||
const std::string &output_format,
|
||||
int quality,
|
||||
std::vector<uint8_t> &out) {
|
||||
std::string fmt = output_format;
|
||||
std::transform(fmt.begin(), fmt.end(), fmt.begin(), ::tolower);
|
||||
|
||||
if (fmt == "jpeg" || fmt == "jpg") {
|
||||
return encode_jpeg_rgba(image, quality, out);
|
||||
}
|
||||
|
||||
if (fmt == "png_lossy") {
|
||||
if (encode_png_lossy_quantized(image, quality, out)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
LOGD("PNG lossy quantize failed, fallback to PNG lossless");
|
||||
return encode_png_lossless_rgba(image, out);
|
||||
}
|
||||
|
||||
if (fmt == "png" || fmt == "png_lossless") {
|
||||
return encode_png_lossless_rgba(image, out);
|
||||
}
|
||||
|
||||
if (fmt == "webp_lossless") {
|
||||
return encode_webp_rgba(image, quality, true, out);
|
||||
}
|
||||
|
||||
if (fmt == "webp") {
|
||||
return encode_webp_rgba(image, quality, false, out);
|
||||
}
|
||||
|
||||
if (fmt == "avif") {
|
||||
return encode_avif_rgba(image, quality, out);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
int compress_image_fd_impl(JNIEnv *env,
|
||||
int input_fd,
|
||||
int output_fd,
|
||||
const std::string &input_mime,
|
||||
const std::string &output_format,
|
||||
int quality,
|
||||
int max_long_side,
|
||||
jobject callback) {
|
||||
if (input_fd < 0 || output_fd < 0) {
|
||||
callback_progress(env, callback, 0, "FD gambar tidak valid");
|
||||
return -1;
|
||||
}
|
||||
|
||||
quality = clamp_int(quality, 1, 100);
|
||||
if (max_long_side < 0) max_long_side = 0;
|
||||
|
||||
callback_progress(env, callback, 0, "Membaca gambar...");
|
||||
|
||||
std::vector<uint8_t> input;
|
||||
if (!read_all_fd(input_fd, input)) {
|
||||
callback_progress(env, callback, 0, is_cancel_requested() ? "Canceled" : "Gagal membaca gambar");
|
||||
return is_cancel_requested() ? -125 : -2;
|
||||
}
|
||||
|
||||
if (is_cancel_requested()) {
|
||||
callback_progress(env, callback, 0, "Canceled");
|
||||
return -125;
|
||||
}
|
||||
|
||||
callback_progress(env, callback, 15, "Decode gambar...");
|
||||
|
||||
RgbaImage decoded;
|
||||
if (!decode_input_rgba(input, input_mime, decoded)) {
|
||||
callback_progress(env, callback, 0, "Format gambar tidak didukung");
|
||||
return -3;
|
||||
}
|
||||
|
||||
if (is_cancel_requested()) {
|
||||
callback_progress(env, callback, 0, "Canceled");
|
||||
return -125;
|
||||
}
|
||||
|
||||
callback_progress(env, callback, 35, "Resize gambar...");
|
||||
|
||||
RgbaImage work = resize_rgba_if_needed(decoded, max_long_side);
|
||||
|
||||
if (is_cancel_requested()) {
|
||||
callback_progress(env, callback, 0, "Canceled");
|
||||
return -125;
|
||||
}
|
||||
|
||||
callback_progress(env, callback, 60, "Encode gambar...");
|
||||
|
||||
std::vector<uint8_t> encoded;
|
||||
if (!encode_output(work, output_format, quality, encoded)) {
|
||||
callback_progress(env, callback, 0, "Gagal encode gambar");
|
||||
return -4;
|
||||
}
|
||||
|
||||
if (is_cancel_requested()) {
|
||||
callback_progress(env, callback, 0, "Canceled");
|
||||
return -125;
|
||||
}
|
||||
|
||||
callback_progress(env, callback, 92, "Menyimpan gambar...");
|
||||
|
||||
if (!write_all_fd(output_fd, encoded)) {
|
||||
callback_progress(env, callback, 0, "Gagal menulis output gambar");
|
||||
return -5;
|
||||
}
|
||||
|
||||
callback_progress(env, callback, 100, "Done");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
|
||||
int compress_image_fd_impl(JNIEnv *env,
|
||||
int input_fd,
|
||||
int output_fd,
|
||||
const std::string &input_mime,
|
||||
const std::string &output_format,
|
||||
int quality,
|
||||
int max_long_side,
|
||||
jobject callback);
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
#pragma once
|
||||
|
||||
#include <jni.h>
|
||||
#include <string>
|
||||
|
||||
int compress_video_fd_impl(JNIEnv *env,
|
||||
int input_fd,
|
||||
int output_fd,
|
||||
int target_short_side,
|
||||
int input_rotation_degrees,
|
||||
int crf,
|
||||
const std::string &encoder_name,
|
||||
const std::string &preset,
|
||||
const std::string &audio_mode,
|
||||
int audio_bitrate,
|
||||
jobject callback);
|
||||
@@ -6,6 +6,7 @@ import android.content.Intent;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.database.Cursor;
|
||||
import android.graphics.BitmapFactory;
|
||||
import android.media.MediaMetadataRetriever;
|
||||
import android.media.MediaExtractor;
|
||||
import android.media.MediaFormat;
|
||||
@@ -23,6 +24,7 @@ import android.widget.Spinner;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Locale;
|
||||
|
||||
@@ -30,6 +32,10 @@ public class MainActivity extends Activity {
|
||||
|
||||
private static final int REQ_PICK_VIDEO = 1001;
|
||||
private static final int REQ_WRITE_PERMISSION = 1002;
|
||||
private static final int REQ_PICK_IMAGE = 1003;
|
||||
|
||||
static final String JOB_TYPE_VIDEO = "video";
|
||||
static final String JOB_TYPE_IMAGE = "image";
|
||||
|
||||
static final String EXTRA_INPUT_URI = "input_uri";
|
||||
static final String EXTRA_TARGET_SHORT = "target_short";
|
||||
@@ -42,6 +48,13 @@ public class MainActivity extends Activity {
|
||||
static final String EXTRA_AUDIO_MODE = "audio_mode";
|
||||
static final String EXTRA_AUDIO_LABEL = "audio_label";
|
||||
static final String EXTRA_AUDIO_BITRATE = "audio_bitrate";
|
||||
static final String EXTRA_JOB_TYPE = "job_type";
|
||||
static final String EXTRA_INPUT_MIME = "input_mime";
|
||||
static final String EXTRA_IMAGE_OUTPUT_FORMAT = "image_output_format";
|
||||
static final String EXTRA_IMAGE_FORMAT_LABEL = "image_format_label";
|
||||
static final String EXTRA_IMAGE_QUALITY = "image_quality";
|
||||
static final String EXTRA_IMAGE_QUALITY_LABEL = "image_quality_label";
|
||||
static final String EXTRA_IMAGE_RESOLUTION_LABEL = "image_resolution_label";
|
||||
|
||||
private static final String PREF_NAME = "kcompressor_settings";
|
||||
private static final String KEY_CODEC_INDEX = "codec_index";
|
||||
@@ -50,13 +63,26 @@ public class MainActivity extends Activity {
|
||||
private static final String KEY_TARGET_SHORT = "target_short";
|
||||
private static final String KEY_AUDIO_INDEX = "audio_index";
|
||||
private static final String KEY_SETTINGS_EXPANDED = "settings_expanded";
|
||||
private static final String KEY_IMAGE_FORMAT_INDEX = "image_format_index";
|
||||
private static final String KEY_IMAGE_TARGET_SHORT = "image_target_short";
|
||||
private static final String KEY_IMAGE_QUALITY = "image_quality";
|
||||
private static final String KEY_IMAGE_SETTINGS_EXPANDED = "image_settings_expanded";
|
||||
|
||||
private static final int CRF_MIN = 18;
|
||||
private static final int CRF_MAX = 40;
|
||||
private static final int DEFAULT_CRF = 26;
|
||||
|
||||
private static final int IMAGE_QUALITY_MIN = 40;
|
||||
private static final int IMAGE_QUALITY_MAX = 100;
|
||||
private static final int DEFAULT_IMAGE_QUALITY = 82;
|
||||
private static final int DEFAULT_IMAGE_QUALITY_AVIF = 60;
|
||||
private static final int DEFAULT_IMAGE_QUALITY_LOSSY = 82;
|
||||
|
||||
private Uri inputUri;
|
||||
private VideoInfo currentVideoInfo;
|
||||
private Uri imageInputUri;
|
||||
private ImageInfo currentImageInfo;
|
||||
private String pendingPermissionJob = JOB_TYPE_VIDEO;
|
||||
|
||||
private TextView txtSelected;
|
||||
private TextView txtVideoInfo;
|
||||
@@ -69,16 +95,75 @@ public class MainActivity extends Activity {
|
||||
private Button btnPick;
|
||||
private Button btnCompress;
|
||||
|
||||
private TextView txtImageSelected;
|
||||
private TextView txtImageInfo;
|
||||
private TextView txtImageQualityValue;
|
||||
private TextView txtImageCompressionSummary;
|
||||
private TextView txtImageSettingsToggle;
|
||||
private View layoutImageSettings;
|
||||
private View layoutImageAdvancedSettings;
|
||||
private Button btnPickImage;
|
||||
private Button btnCompressImage;
|
||||
|
||||
private Spinner spCodec;
|
||||
private Spinner spResolution;
|
||||
private Spinner spPreset;
|
||||
private Spinner spAudio;
|
||||
private SeekBar seekCrf;
|
||||
|
||||
private Spinner spImageFormat;
|
||||
private Spinner spImageResolution;
|
||||
private SeekBar seekImageQuality;
|
||||
|
||||
private SharedPreferences prefs;
|
||||
private ArrayAdapter<ResolutionOption> resolutionAdapter;
|
||||
private ArrayAdapter<AudioOption> audioAdapter;
|
||||
private ArrayAdapter<ImageResolutionOption> imageResolutionAdapter;
|
||||
private boolean settingsExpanded = false;
|
||||
private boolean imageSettingsExpanded = false;
|
||||
|
||||
|
||||
private static final class ImageInfo {
|
||||
int width;
|
||||
int height;
|
||||
int shortSide;
|
||||
int longSide;
|
||||
long fileSizeBytes;
|
||||
String mime;
|
||||
String displayName;
|
||||
}
|
||||
|
||||
private static final class ImageFormatOption {
|
||||
final String label;
|
||||
final String format;
|
||||
final String mime;
|
||||
|
||||
ImageFormatOption(String label, String format, String mime) {
|
||||
this.label = label;
|
||||
this.format = format;
|
||||
this.mime = mime;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class ImageResolutionOption {
|
||||
final String label;
|
||||
final int maxLongSide;
|
||||
|
||||
ImageResolutionOption(String label, int maxLongSide) {
|
||||
this.label = label;
|
||||
this.maxLongSide = maxLongSide;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class VideoInfo {
|
||||
int codedWidth;
|
||||
@@ -167,19 +252,41 @@ public class MainActivity extends Activity {
|
||||
btnCompress.setVisibility(View.GONE);
|
||||
layoutCompressionSettings.setVisibility(View.GONE);
|
||||
|
||||
txtImageSelected = findViewById(R.id.txtImageSelected);
|
||||
txtImageInfo = findViewById(R.id.txtImageInfo);
|
||||
txtImageQualityValue = findViewById(R.id.txtImageQualityValue);
|
||||
txtImageCompressionSummary = findViewById(R.id.txtImageCompressionSummary);
|
||||
txtImageSettingsToggle = findViewById(R.id.txtImageSettingsToggle);
|
||||
layoutImageSettings = findViewById(R.id.layoutImageSettings);
|
||||
layoutImageAdvancedSettings = findViewById(R.id.layoutImageAdvancedSettings);
|
||||
btnPickImage = findViewById(R.id.btnPickImage);
|
||||
btnCompressImage = findViewById(R.id.btnCompressImage);
|
||||
btnCompressImage.setEnabled(false);
|
||||
btnCompressImage.setVisibility(View.GONE);
|
||||
layoutImageSettings.setVisibility(View.GONE);
|
||||
|
||||
spCodec = findViewById(R.id.spCodec);
|
||||
spResolution = findViewById(R.id.spResolution);
|
||||
spPreset = findViewById(R.id.spPreset);
|
||||
spAudio = findViewById(R.id.spAudio);
|
||||
seekCrf = findViewById(R.id.seekCrf);
|
||||
|
||||
spImageFormat = findViewById(R.id.spImageFormat);
|
||||
spImageResolution = findViewById(R.id.spImageResolution);
|
||||
seekImageQuality = findViewById(R.id.seekImageQuality);
|
||||
|
||||
setupSpinners();
|
||||
setupCrfSlider();
|
||||
setupImageSettings();
|
||||
setSettingsExpanded(false);
|
||||
setImageSettingsExpanded(false);
|
||||
updateCompressionSummary();
|
||||
updateImageCompressionSummary();
|
||||
|
||||
btnPick.setOnClickListener(v -> openVideoPicker());
|
||||
btnPickImage.setOnClickListener(v -> openImagePicker());
|
||||
txtSettingsToggle.setOnClickListener(v -> setSettingsExpanded(!settingsExpanded));
|
||||
txtImageSettingsToggle.setOnClickListener(v -> setImageSettingsExpanded(!imageSettingsExpanded));
|
||||
|
||||
btnCompress.setOnClickListener(v -> {
|
||||
if (inputUri == null) {
|
||||
@@ -190,6 +297,7 @@ public class MainActivity extends Activity {
|
||||
if (Build.VERSION.SDK_INT < 29 &&
|
||||
checkSelfPermissionCompat(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
pendingPermissionJob = JOB_TYPE_VIDEO;
|
||||
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQ_WRITE_PERMISSION);
|
||||
return;
|
||||
}
|
||||
@@ -197,6 +305,23 @@ public class MainActivity extends Activity {
|
||||
openProcessingActivity();
|
||||
});
|
||||
|
||||
btnCompressImage.setOnClickListener(v -> {
|
||||
if (imageInputUri == null) {
|
||||
toast("Pilih gambar dulu");
|
||||
return;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT < 29 &&
|
||||
checkSelfPermissionCompat(Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
pendingPermissionJob = JOB_TYPE_IMAGE;
|
||||
requestPermissions(new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, REQ_WRITE_PERMISSION);
|
||||
return;
|
||||
}
|
||||
|
||||
openImageProcessingActivity();
|
||||
});
|
||||
|
||||
txtProgress.setText("Developed by Kikyps");
|
||||
}
|
||||
|
||||
@@ -302,6 +427,193 @@ public class MainActivity extends Activity {
|
||||
});
|
||||
}
|
||||
|
||||
private void setupImageSettings() {
|
||||
ArrayList<ImageFormatOption> imageFormats = new ArrayList<>();
|
||||
imageFormats.add(new ImageFormatOption("JPEG / MozJPEG", "jpeg", "image/jpeg"));
|
||||
imageFormats.add(new ImageFormatOption("PNG Lossless / OxiPNG", "png_lossless", "image/png"));
|
||||
imageFormats.add(new ImageFormatOption("PNG TinyPNG Style", "png_lossy", "image/png"));
|
||||
imageFormats.add(new ImageFormatOption("WebP Lossy", "webp", "image/webp"));
|
||||
imageFormats.add(new ImageFormatOption("WebP Lossless", "webp_lossless", "image/webp"));
|
||||
imageFormats.add(new ImageFormatOption("AVIF", "avif", "image/avif"));
|
||||
|
||||
ArrayAdapter<ImageFormatOption> imageFormatAdapter = makeDarkAdapter(imageFormats);
|
||||
imageFormatAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
spImageFormat.setAdapter(imageFormatAdapter);
|
||||
spImageFormat.setSelection(clamp(prefs.getInt(KEY_IMAGE_FORMAT_INDEX, 0), 0, imageFormats.size() - 1));
|
||||
|
||||
imageResolutionAdapter = makeDarkAdapter(new ArrayList<>());
|
||||
imageResolutionAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);
|
||||
spImageResolution.setAdapter(imageResolutionAdapter);
|
||||
setImageResolutionOptions(null);
|
||||
|
||||
int savedQuality = prefs.getInt(KEY_IMAGE_QUALITY, DEFAULT_IMAGE_QUALITY);
|
||||
savedQuality = clamp(savedQuality, IMAGE_QUALITY_MIN, IMAGE_QUALITY_MAX);
|
||||
seekImageQuality.setMax(IMAGE_QUALITY_MAX - IMAGE_QUALITY_MIN);
|
||||
setImageQualitySlider(savedQuality);
|
||||
|
||||
spImageFormat.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||
applyRecommendedQualityForSelectedImageFormat();
|
||||
syncImageAdvancedVisibility();
|
||||
saveImageSettingsOnly();
|
||||
updateImageCompressionSummary();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {}
|
||||
});
|
||||
|
||||
spImageResolution.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
|
||||
@Override
|
||||
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
|
||||
saveImageSettingsOnly();
|
||||
updateImageCompressionSummary();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNothingSelected(AdapterView<?> parent) {}
|
||||
});
|
||||
|
||||
seekImageQuality.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() {
|
||||
@Override
|
||||
public void onProgressChanged(SeekBar seekBar, int progressValue, boolean fromUser) {
|
||||
updateImageQualityText(IMAGE_QUALITY_MIN + progressValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onStartTrackingTouch(SeekBar seekBar) {}
|
||||
|
||||
@Override
|
||||
public void onStopTrackingTouch(SeekBar seekBar) {
|
||||
saveImageSettingsOnly();
|
||||
updateImageCompressionSummary();
|
||||
}
|
||||
});
|
||||
|
||||
applyRecommendedQualityForSelectedImageFormat();
|
||||
syncImageAdvancedVisibility();
|
||||
updateImageCompressionSummary();
|
||||
}
|
||||
|
||||
|
||||
private void setImageQualitySlider(int quality) {
|
||||
if (seekImageQuality == null) {
|
||||
return;
|
||||
}
|
||||
quality = clamp(quality, IMAGE_QUALITY_MIN, IMAGE_QUALITY_MAX);
|
||||
seekImageQuality.setProgress(quality - IMAGE_QUALITY_MIN);
|
||||
updateImageQualityText(quality);
|
||||
}
|
||||
|
||||
private boolean isImageLosslessFormat(String format) {
|
||||
return "png_lossless".equals(format) || "webp_lossless".equals(format);
|
||||
}
|
||||
|
||||
private boolean selectedImageFormatNeedsQuality() {
|
||||
ImageFormatOption option = getSelectedImageFormat();
|
||||
return option == null || !isImageLosslessFormat(option.format);
|
||||
}
|
||||
|
||||
private int recommendedQualityForImageFormat(String format) {
|
||||
if ("avif".equals(format)) {
|
||||
return DEFAULT_IMAGE_QUALITY_AVIF;
|
||||
}
|
||||
return DEFAULT_IMAGE_QUALITY_LOSSY;
|
||||
}
|
||||
|
||||
private void applyRecommendedQualityForSelectedImageFormat() {
|
||||
ImageFormatOption option = getSelectedImageFormat();
|
||||
if (option == null || isImageLosslessFormat(option.format)) {
|
||||
return;
|
||||
}
|
||||
setImageQualitySlider(recommendedQualityForImageFormat(option.format));
|
||||
}
|
||||
|
||||
private void syncImageAdvancedVisibility() {
|
||||
boolean needsQuality = selectedImageFormatNeedsQuality();
|
||||
|
||||
if (txtImageSettingsToggle != null) {
|
||||
txtImageSettingsToggle.setVisibility(needsQuality ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
if (!needsQuality) {
|
||||
imageSettingsExpanded = false;
|
||||
if (layoutImageAdvancedSettings != null) {
|
||||
layoutImageAdvancedSettings.setVisibility(View.GONE);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (layoutImageAdvancedSettings != null) {
|
||||
layoutImageAdvancedSettings.setVisibility(imageSettingsExpanded ? View.VISIBLE : View.GONE);
|
||||
}
|
||||
|
||||
if (txtImageSettingsToggle != null) {
|
||||
txtImageSettingsToggle.setText(imageSettingsExpanded ? "Pengaturan lanjutan ▲" : "Pengaturan lanjutan ▼");
|
||||
}
|
||||
}
|
||||
|
||||
private void updateImageQualityText(int quality) {
|
||||
String hint;
|
||||
if (quality >= 90) {
|
||||
hint = "Kualitas tinggi";
|
||||
} else if (quality >= 75) {
|
||||
hint = "Seimbang";
|
||||
} else {
|
||||
hint = "Ukuran kecil";
|
||||
}
|
||||
|
||||
txtImageQualityValue.setText("Quality " + quality + " • " + hint);
|
||||
updateImageCompressionSummary();
|
||||
}
|
||||
|
||||
private void setImageSettingsExpanded(boolean expanded) {
|
||||
if (!selectedImageFormatNeedsQuality()) {
|
||||
imageSettingsExpanded = false;
|
||||
syncImageAdvancedVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
imageSettingsExpanded = expanded;
|
||||
syncImageAdvancedVisibility();
|
||||
}
|
||||
|
||||
private void updateImageCompressionSummary() {
|
||||
if (txtImageCompressionSummary == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String formatLabel = "JPEG / MozJPEG";
|
||||
Object fmtObj = spImageFormat != null ? spImageFormat.getSelectedItem() : null;
|
||||
if (fmtObj instanceof ImageFormatOption) {
|
||||
formatLabel = ((ImageFormatOption) fmtObj).label;
|
||||
}
|
||||
|
||||
String resolutionLabel = "Original";
|
||||
Object resObj = spImageResolution != null ? spImageResolution.getSelectedItem() : null;
|
||||
if (resObj instanceof ImageResolutionOption) {
|
||||
resolutionLabel = ((ImageResolutionOption) resObj).label;
|
||||
}
|
||||
|
||||
ImageFormatOption fmtOption = getSelectedImageFormat();
|
||||
String format = fmtOption != null ? fmtOption.format : "jpeg";
|
||||
|
||||
if (isImageLosslessFormat(format)) {
|
||||
txtImageCompressionSummary.setText(
|
||||
"Output: " + formatLabel + " • " + resolutionLabel + " • Lossless"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
int quality = seekImageQuality != null ? IMAGE_QUALITY_MIN + seekImageQuality.getProgress() : recommendedQualityForImageFormat(format);
|
||||
|
||||
txtImageCompressionSummary.setText(
|
||||
"Output: " + formatLabel + " • " + resolutionLabel + " • Q" + quality
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private void updateCrfText(int crf) {
|
||||
String hint;
|
||||
if (crf <= 22) {
|
||||
@@ -399,6 +711,47 @@ public class MainActivity extends Activity {
|
||||
spResolution.setSelection(selectedIndex);
|
||||
}
|
||||
|
||||
private void setImageResolutionOptions(ImageInfo info) {
|
||||
imageResolutionAdapter.clear();
|
||||
|
||||
if (info == null || info.longSide <= 0) {
|
||||
imageResolutionAdapter.add(new ImageResolutionOption("Original", 0));
|
||||
imageResolutionAdapter.notifyDataSetChanged();
|
||||
spImageResolution.setSelection(0);
|
||||
return;
|
||||
}
|
||||
|
||||
imageResolutionAdapter.add(new ImageResolutionOption("Original (" + info.width + "x" + info.height + ")", 0));
|
||||
|
||||
int[] candidates = new int[]{3840, 2560, 1920, 1600, 1280, 1024, 800, 640};
|
||||
|
||||
for (int candidate : candidates) {
|
||||
if (candidate < info.longSide) {
|
||||
imageResolutionAdapter.add(new ImageResolutionOption(formatImageResolutionName(candidate), candidate));
|
||||
}
|
||||
}
|
||||
|
||||
imageResolutionAdapter.notifyDataSetChanged();
|
||||
|
||||
int savedTarget = prefs.getInt(KEY_IMAGE_TARGET_SHORT, 0);
|
||||
int selectedIndex = 0;
|
||||
|
||||
for (int i = 0; i < imageResolutionAdapter.getCount(); i++) {
|
||||
ImageResolutionOption option = imageResolutionAdapter.getItem(i);
|
||||
if (option != null && option.maxLongSide == savedTarget) {
|
||||
selectedIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
spImageResolution.setSelection(selectedIndex);
|
||||
}
|
||||
|
||||
private String formatImageResolutionName(int maxLongSide) {
|
||||
return "Maks " + maxLongSide + " px";
|
||||
}
|
||||
|
||||
|
||||
private String formatResolutionName(int shortSide) {
|
||||
if (shortSide >= 2160) return "4K / 2160p";
|
||||
if (shortSide >= 1440) return "2K / 1440p";
|
||||
@@ -429,6 +782,34 @@ public class MainActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private void openImagePicker() {
|
||||
Intent intent;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent = new Intent(MediaStore.ACTION_PICK_IMAGES);
|
||||
intent.setType("image/*");
|
||||
} else {
|
||||
intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
|
||||
intent.setType("image/*");
|
||||
}
|
||||
|
||||
String[] supportedMimes = new String[]{
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/avif"
|
||||
};
|
||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, supportedMimes);
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
try {
|
||||
startActivityForResult(intent, REQ_PICK_IMAGE);
|
||||
} catch (Exception e) {
|
||||
toast("Media picker gambar tidak tersedia");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
@@ -456,7 +837,81 @@ public class MainActivity extends Activity {
|
||||
btnCompress.setVisibility(View.GONE);
|
||||
btnCompress.setEnabled(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestCode == REQ_PICK_IMAGE) {
|
||||
if (resultCode == RESULT_OK && data != null) {
|
||||
imageInputUri = data.getData();
|
||||
|
||||
if (imageInputUri != null) {
|
||||
currentImageInfo = readImageInfo(imageInputUri);
|
||||
setImageResolutionOptions(currentImageInfo);
|
||||
|
||||
if (!isSupportedImageMime(currentImageInfo.mime)) {
|
||||
toast("Format gambar belum didukung. Gunakan JPEG, PNG, WebP, atau AVIF.");
|
||||
imageInputUri = null;
|
||||
currentImageInfo = null;
|
||||
layoutImageSettings.setVisibility(View.GONE);
|
||||
btnCompressImage.setVisibility(View.GONE);
|
||||
btnCompressImage.setEnabled(false);
|
||||
return;
|
||||
}
|
||||
|
||||
autoSelectRecommendedImageOutput(currentImageInfo.mime);
|
||||
|
||||
txtImageSelected.setText("Gambar dipilih");
|
||||
txtImageInfo.setText(makeImageInfoText(currentImageInfo));
|
||||
layoutImageSettings.setVisibility(View.VISIBLE);
|
||||
btnCompressImage.setVisibility(View.VISIBLE);
|
||||
btnCompressImage.setEnabled(true);
|
||||
} else {
|
||||
layoutImageSettings.setVisibility(View.GONE);
|
||||
btnCompressImage.setVisibility(View.GONE);
|
||||
btnCompressImage.setEnabled(false);
|
||||
}
|
||||
} else if (imageInputUri == null) {
|
||||
layoutImageSettings.setVisibility(View.GONE);
|
||||
btnCompressImage.setVisibility(View.GONE);
|
||||
btnCompressImage.setEnabled(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void autoSelectRecommendedImageOutput(String inputMime) {
|
||||
if (spImageFormat == null || spImageFormat.getAdapter() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String recommended = recommendedOutputFormatForInputMime(inputMime);
|
||||
for (int i = 0; i < spImageFormat.getAdapter().getCount(); i++) {
|
||||
Object item = spImageFormat.getAdapter().getItem(i);
|
||||
if (item instanceof ImageFormatOption && recommended.equals(((ImageFormatOption) item).format)) {
|
||||
spImageFormat.setSelection(i);
|
||||
applyRecommendedQualityForSelectedImageFormat();
|
||||
syncImageAdvancedVisibility();
|
||||
updateImageCompressionSummary();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private String recommendedOutputFormatForInputMime(String inputMime) {
|
||||
String mime = normalizeImageMime(inputMime);
|
||||
if ("image/jpeg".equals(mime)) {
|
||||
return "jpeg";
|
||||
}
|
||||
if ("image/png".equals(mime)) {
|
||||
return "png_lossy";
|
||||
}
|
||||
if ("image/webp".equals(mime)) {
|
||||
return "webp";
|
||||
}
|
||||
if ("image/avif".equals(mime)) {
|
||||
return "avif";
|
||||
}
|
||||
return "webp";
|
||||
}
|
||||
|
||||
private VideoInfo readVideoInfo(Uri uri) {
|
||||
@@ -572,6 +1027,125 @@ public class MainActivity extends Activity {
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private ImageInfo readImageInfo(Uri uri) {
|
||||
ImageInfo info = new ImageInfo();
|
||||
info.mime = normalizeImageMime(safeString(getContentResolver().getType(uri), ""));
|
||||
info.fileSizeBytes = readOriginalFileSize(uri);
|
||||
info.displayName = readDisplayName(uri);
|
||||
|
||||
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||
options.inJustDecodeBounds = true;
|
||||
|
||||
InputStream input = null;
|
||||
try {
|
||||
input = getContentResolver().openInputStream(uri);
|
||||
BitmapFactory.decodeStream(input, null, options);
|
||||
info.width = Math.max(0, options.outWidth);
|
||||
info.height = Math.max(0, options.outHeight);
|
||||
|
||||
if ((info.mime == null || info.mime.length() == 0) && options.outMimeType != null) {
|
||||
info.mime = normalizeImageMime(options.outMimeType);
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
info.width = 0;
|
||||
info.height = 0;
|
||||
} finally {
|
||||
if (input != null) {
|
||||
try {
|
||||
input.close();
|
||||
} catch (Throwable ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (info.mime == null || info.mime.length() == 0) {
|
||||
info.mime = mimeFromDisplayName(info.displayName);
|
||||
}
|
||||
|
||||
info.shortSide = Math.min(info.width, info.height);
|
||||
info.longSide = Math.max(info.width, info.height);
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
private String makeImageInfoText(ImageInfo info) {
|
||||
if (info == null) {
|
||||
return "Info gambar tidak terbaca";
|
||||
}
|
||||
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("Resolusi: ");
|
||||
if (info.width > 0 && info.height > 0) {
|
||||
sb.append(info.width).append("x").append(info.height);
|
||||
} else {
|
||||
sb.append("-");
|
||||
}
|
||||
|
||||
sb.append("\nUkuran file: ").append(formatFileSize(info.fileSizeBytes));
|
||||
sb.append("\nFormat: ").append(formatImageMimeName(info.mime));
|
||||
|
||||
if (info.displayName != null && info.displayName.length() > 0) {
|
||||
sb.append("\nNama file: ").append(info.displayName);
|
||||
}
|
||||
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private String readDisplayName(Uri uri) {
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = getContentResolver().query(uri, null, null, null, null);
|
||||
if (cursor != null) {
|
||||
int nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME);
|
||||
if (nameIndex >= 0 && cursor.moveToFirst()) {
|
||||
return safeString(cursor.getString(nameIndex), "");
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
private boolean isSupportedImageMime(String mime) {
|
||||
String value = normalizeImageMime(mime);
|
||||
return "image/jpeg".equals(value) ||
|
||||
"image/png".equals(value) ||
|
||||
"image/webp".equals(value) ||
|
||||
"image/avif".equals(value);
|
||||
}
|
||||
|
||||
private String normalizeImageMime(String mime) {
|
||||
if (mime == null) return "";
|
||||
String value = mime.toLowerCase(Locale.US).trim();
|
||||
if ("image/jpg".equals(value)) return "image/jpeg";
|
||||
return value;
|
||||
}
|
||||
|
||||
private String mimeFromDisplayName(String name) {
|
||||
if (name == null) return "";
|
||||
|
||||
String lower = name.toLowerCase(Locale.US);
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (lower.endsWith(".png")) return "image/png";
|
||||
if (lower.endsWith(".webp")) return "image/webp";
|
||||
if (lower.endsWith(".avif")) return "image/avif";
|
||||
return "";
|
||||
}
|
||||
|
||||
private String formatImageMimeName(String mime) {
|
||||
String value = normalizeImageMime(mime);
|
||||
if ("image/jpeg".equals(value)) return "JPEG";
|
||||
if ("image/png".equals(value)) return "PNG";
|
||||
if ("image/webp".equals(value)) return "WebP";
|
||||
if ("image/avif".equals(value)) return "AVIF";
|
||||
return value.length() == 0 ? "-" : value;
|
||||
}
|
||||
|
||||
|
||||
private void readMediaExtractorInfo(Uri uri, VideoInfo info) {
|
||||
MediaExtractor extractor = new MediaExtractor();
|
||||
|
||||
@@ -754,6 +1328,7 @@ public class MainActivity extends Activity {
|
||||
saveSettings(targetShortSide, crf);
|
||||
|
||||
Intent intent = new Intent(this, ProcessingActivity.class);
|
||||
intent.putExtra(EXTRA_JOB_TYPE, JOB_TYPE_VIDEO);
|
||||
intent.putExtra(EXTRA_INPUT_URI, inputUri.toString());
|
||||
intent.putExtra(EXTRA_TARGET_SHORT, targetShortSide);
|
||||
intent.putExtra(EXTRA_VIDEO_ROTATION, currentVideoInfo != null ? currentVideoInfo.rotation : 0);
|
||||
@@ -768,6 +1343,63 @@ public class MainActivity extends Activity {
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void openImageProcessingActivity() {
|
||||
int maxLongSide = getSelectedImageMaxLongSide();
|
||||
int quality = IMAGE_QUALITY_MIN + seekImageQuality.getProgress();
|
||||
|
||||
ImageFormatOption formatOption = getSelectedImageFormat();
|
||||
String outputFormat = formatOption != null ? formatOption.format : "jpeg";
|
||||
String formatLabel = formatOption != null ? formatOption.label : "JPEG / MozJPEG";
|
||||
|
||||
ImageResolutionOption resOption = (ImageResolutionOption) spImageResolution.getSelectedItem();
|
||||
String resolutionLabel = resOption != null ? resOption.label : "Original";
|
||||
|
||||
String qualityLabel = isImageLosslessFormat(outputFormat) ? "Lossless" : "Quality " + quality;
|
||||
saveImageSettings(maxLongSide, quality);
|
||||
|
||||
Intent intent = new Intent(this, ProcessingActivity.class);
|
||||
intent.putExtra(EXTRA_JOB_TYPE, JOB_TYPE_IMAGE);
|
||||
intent.putExtra(EXTRA_INPUT_URI, imageInputUri.toString());
|
||||
intent.putExtra(EXTRA_INPUT_MIME, currentImageInfo != null ? currentImageInfo.mime : "");
|
||||
intent.putExtra(EXTRA_TARGET_SHORT, maxLongSide);
|
||||
intent.putExtra(EXTRA_IMAGE_OUTPUT_FORMAT, outputFormat);
|
||||
intent.putExtra(EXTRA_IMAGE_FORMAT_LABEL, formatLabel);
|
||||
intent.putExtra(EXTRA_IMAGE_RESOLUTION_LABEL, resolutionLabel);
|
||||
intent.putExtra(EXTRA_IMAGE_QUALITY, quality);
|
||||
intent.putExtra(EXTRA_IMAGE_QUALITY_LABEL, qualityLabel);
|
||||
startActivity(intent);
|
||||
}
|
||||
|
||||
private void saveImageSettings(int maxLongSide, int quality) {
|
||||
prefs.edit()
|
||||
.putInt(KEY_IMAGE_FORMAT_INDEX, spImageFormat != null ? spImageFormat.getSelectedItemPosition() : 0)
|
||||
.putInt(KEY_IMAGE_TARGET_SHORT, maxLongSide)
|
||||
.putInt(KEY_IMAGE_QUALITY, quality)
|
||||
.apply();
|
||||
}
|
||||
|
||||
private void saveImageSettingsOnly() {
|
||||
saveImageSettings(getSelectedImageMaxLongSide(),
|
||||
IMAGE_QUALITY_MIN + seekImageQuality.getProgress());
|
||||
}
|
||||
|
||||
private int getSelectedImageMaxLongSide() {
|
||||
Object item = spImageResolution.getSelectedItem();
|
||||
if (item instanceof ImageResolutionOption) {
|
||||
return ((ImageResolutionOption) item).maxLongSide;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private ImageFormatOption getSelectedImageFormat() {
|
||||
Object item = spImageFormat.getSelectedItem();
|
||||
if (item instanceof ImageFormatOption) {
|
||||
return (ImageFormatOption) item;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
private void saveSettings(int targetShortSide, int crf) {
|
||||
prefs.edit()
|
||||
.putInt(KEY_CODEC_INDEX, spCodec.getSelectedItemPosition())
|
||||
@@ -803,7 +1435,11 @@ public class MainActivity extends Activity {
|
||||
|
||||
if (requestCode == REQ_WRITE_PERMISSION) {
|
||||
if (grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED) {
|
||||
openProcessingActivity();
|
||||
if (JOB_TYPE_IMAGE.equals(pendingPermissionJob)) {
|
||||
openImageProcessingActivity();
|
||||
} else {
|
||||
openProcessingActivity();
|
||||
}
|
||||
} else {
|
||||
toast("Izin tulis storage ditolak");
|
||||
}
|
||||
|
||||
@@ -34,5 +34,33 @@ public final class NativeCompressor {
|
||||
ProgressCallback callback
|
||||
);
|
||||
|
||||
/**
|
||||
* outputFormat:
|
||||
* jpeg = MozJPEG/TurboJPEG output .jpg
|
||||
* png_lossless = PNG RGBA + OxiPNG lossless
|
||||
* png_lossy = libimagequant palette PNG + OxiPNG, mirip TinyPNG
|
||||
* webp = WebP lossy
|
||||
* webp_lossless= WebP lossless
|
||||
* avif = AVIF lossy
|
||||
*
|
||||
* quality:
|
||||
* 1..100 untuk JPEG/WebP lossy/AVIF/PNG lossy.
|
||||
* Tidak dipakai untuk PNG lossless dan WebP lossless.
|
||||
*
|
||||
* maxLongSide:
|
||||
* 0 = Original, tidak resize.
|
||||
* >0 = sisi terpanjang gambar maksimal, contoh 1920 berarti output tidak lebih dari 1920 px
|
||||
* di sisi terpanjang dan aspect ratio tetap dipertahankan.
|
||||
*/
|
||||
public static native int compressImageFd(
|
||||
int inputFd,
|
||||
int outputFd,
|
||||
String inputMime,
|
||||
String outputFormat,
|
||||
int quality,
|
||||
int maxLongSide,
|
||||
ProgressCallback callback
|
||||
);
|
||||
|
||||
public static native void cancelCurrentJob();
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import android.app.AlertDialog;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Intent;
|
||||
import android.content.res.AssetFileDescriptor;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.database.Cursor;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
@@ -16,6 +18,7 @@ import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.os.ParcelFileDescriptor;
|
||||
import android.provider.MediaStore;
|
||||
import android.provider.OpenableColumns;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.Window;
|
||||
@@ -35,6 +38,7 @@ public class ProcessingActivity extends Activity {
|
||||
private static final float DIM_BRIGHTNESS = 0.01f;
|
||||
|
||||
private CircleProgressView circleProgress;
|
||||
private TextView txtProcessingTitle;
|
||||
private TextView txtStatus;
|
||||
private TextView txtElapsed;
|
||||
private TextView txtConfig;
|
||||
@@ -50,6 +54,8 @@ public class ProcessingActivity extends Activity {
|
||||
|
||||
private Uri inputUri;
|
||||
private Uri outputUri;
|
||||
private String outputMime = "video/mp4";
|
||||
private boolean imageJob = false;
|
||||
|
||||
private int targetShortSide;
|
||||
private int inputRotationDegrees;
|
||||
@@ -62,6 +68,13 @@ public class ProcessingActivity extends Activity {
|
||||
private String audioLabel;
|
||||
private int audioBitrate;
|
||||
|
||||
private String inputMime;
|
||||
private String outputFormat;
|
||||
private String imageFormatLabel;
|
||||
private String imageResolutionLabel;
|
||||
private int imageQuality;
|
||||
private String imageQualityLabel;
|
||||
|
||||
private volatile boolean cancelRequested = false;
|
||||
private volatile boolean finished = false;
|
||||
private int lastDisplayedProgress = 0;
|
||||
@@ -113,13 +126,13 @@ public class ProcessingActivity extends Activity {
|
||||
|
||||
Window window = getWindow();
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
|
||||
|
||||
originalBrightness = window.getAttributes().screenBrightness;
|
||||
|
||||
setContentView(R.layout.activity_processing);
|
||||
|
||||
handler = new Handler(Looper.getMainLooper());
|
||||
|
||||
txtProcessingTitle = findViewById(R.id.txtProcessingTitle);
|
||||
circleProgress = findViewById(R.id.circleProgress);
|
||||
txtStatus = findViewById(R.id.txtStatus);
|
||||
txtElapsed = findViewById(R.id.txtElapsed);
|
||||
@@ -135,9 +148,13 @@ public class ProcessingActivity extends Activity {
|
||||
readIntentExtras();
|
||||
updateConfigCard();
|
||||
|
||||
txtProcessingTitle.setText(imageJob ? "Compressing Image" : "Compressing Video");
|
||||
btnShareVideo.setText(imageJob ? "Share Image" : "Share Video");
|
||||
btnShareDoc.setText("Share Doc");
|
||||
|
||||
btnCancel.setOnClickListener(v -> showCancelDialog());
|
||||
btnShareVideo.setOnClickListener(v -> shareCompressedVideo(false));
|
||||
btnShareDoc.setOnClickListener(v -> shareCompressedVideo(true));
|
||||
btnShareVideo.setOnClickListener(v -> shareCompressedFile(false));
|
||||
btnShareDoc.setOnClickListener(v -> shareCompressedFile(true));
|
||||
|
||||
restoreBrightness();
|
||||
scheduleDim();
|
||||
@@ -151,6 +168,9 @@ public class ProcessingActivity extends Activity {
|
||||
inputUri = Uri.parse(input);
|
||||
}
|
||||
|
||||
String jobType = getIntent().getStringExtra(MainActivity.EXTRA_JOB_TYPE);
|
||||
imageJob = MainActivity.JOB_TYPE_IMAGE.equals(jobType);
|
||||
|
||||
targetShortSide = getIntent().getIntExtra(MainActivity.EXTRA_TARGET_SHORT, 0);
|
||||
inputRotationDegrees = getIntent().getIntExtra(MainActivity.EXTRA_VIDEO_ROTATION, 0);
|
||||
crf = getIntent().getIntExtra(MainActivity.EXTRA_CRF, 26);
|
||||
@@ -162,15 +182,39 @@ public class ProcessingActivity extends Activity {
|
||||
audioLabel = getIntent().getStringExtra(MainActivity.EXTRA_AUDIO_LABEL);
|
||||
audioBitrate = getIntent().getIntExtra(MainActivity.EXTRA_AUDIO_BITRATE, 0);
|
||||
|
||||
inputMime = getIntent().getStringExtra(MainActivity.EXTRA_INPUT_MIME);
|
||||
outputFormat = getIntent().getStringExtra(MainActivity.EXTRA_IMAGE_OUTPUT_FORMAT);
|
||||
imageFormatLabel = getIntent().getStringExtra(MainActivity.EXTRA_IMAGE_FORMAT_LABEL);
|
||||
imageResolutionLabel = getIntent().getStringExtra(MainActivity.EXTRA_IMAGE_RESOLUTION_LABEL);
|
||||
imageQuality = getIntent().getIntExtra(MainActivity.EXTRA_IMAGE_QUALITY, 82);
|
||||
imageQualityLabel = getIntent().getStringExtra(MainActivity.EXTRA_IMAGE_QUALITY_LABEL);
|
||||
|
||||
if (encoderName == null) encoderName = "libx264";
|
||||
if (codecLabel == null) codecLabel = "H.264 / AVC";
|
||||
if (resolutionLabel == null) resolutionLabel = "Original";
|
||||
if (preset == null) preset = "medium";
|
||||
if (audioMode == null) audioMode = "copy";
|
||||
if (audioLabel == null) audioLabel = "Original / Copy";
|
||||
|
||||
if (inputMime == null) inputMime = "";
|
||||
if (outputFormat == null) outputFormat = "jpeg";
|
||||
if (imageFormatLabel == null) imageFormatLabel = "JPEG / MozJPEG";
|
||||
if (imageResolutionLabel == null) imageResolutionLabel = "Original";
|
||||
if (imageQualityLabel == null) imageQualityLabel = "Quality " + imageQuality;
|
||||
outputMime = imageJob ? mimeForImageFormat(outputFormat) : "video/mp4";
|
||||
}
|
||||
|
||||
private void updateConfigCard() {
|
||||
if (imageJob) {
|
||||
txtConfig.setText(
|
||||
"Format : " + imageFormatLabel + "\n" +
|
||||
"Resolusi : " + imageResolutionLabel + "\n" +
|
||||
"Quality : " + imageQualityLabel + "\n" +
|
||||
"Input MIME : " + (inputMime.length() == 0 ? "-" : inputMime)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
txtConfig.setText(
|
||||
"Codec : " + codecLabel + "\n" +
|
||||
"Resolusi : " + resolutionLabel + "\n" +
|
||||
@@ -208,28 +252,48 @@ public class ProcessingActivity extends Activity {
|
||||
throw new IllegalStateException("Input PFD null");
|
||||
}
|
||||
|
||||
String outputName = makeOutputName(encoderName, targetShortSide, crf);
|
||||
outputPfd = createOutputFile(outputName);
|
||||
final long originalSizeBytes = getUriSize(inputUri);
|
||||
|
||||
String outputName = imageJob
|
||||
? makeImageOutputName(outputFormat, targetShortSide, imageQuality)
|
||||
: makeVideoOutputName(encoderName, targetShortSide, crf);
|
||||
|
||||
outputPfd = createOutputFile(outputName, outputMime, imageJob);
|
||||
if (outputPfd == null) {
|
||||
throw new IllegalStateException("Output PFD null");
|
||||
}
|
||||
|
||||
final Uri finalOutputUri = outputUri;
|
||||
|
||||
int result = NativeCompressor.compressFd(
|
||||
inputPfd.getFd(),
|
||||
outputPfd.getFd(),
|
||||
targetShortSide,
|
||||
inputRotationDegrees,
|
||||
crf,
|
||||
encoderName,
|
||||
preset,
|
||||
audioMode,
|
||||
audioBitrate,
|
||||
(percent, message) -> runOnUiThread(() ->
|
||||
updateCompressionProgress(percent, message)
|
||||
)
|
||||
);
|
||||
int result;
|
||||
if (imageJob) {
|
||||
result = NativeCompressor.compressImageFd(
|
||||
inputPfd.getFd(),
|
||||
outputPfd.getFd(),
|
||||
inputMime,
|
||||
outputFormat,
|
||||
imageQuality,
|
||||
targetShortSide,
|
||||
(percent, message) -> runOnUiThread(() ->
|
||||
updateCompressionProgress(percent, message)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
result = NativeCompressor.compressFd(
|
||||
inputPfd.getFd(),
|
||||
outputPfd.getFd(),
|
||||
targetShortSide,
|
||||
inputRotationDegrees,
|
||||
crf,
|
||||
encoderName,
|
||||
preset,
|
||||
audioMode,
|
||||
audioBitrate,
|
||||
(percent, message) -> runOnUiThread(() ->
|
||||
updateCompressionProgress(percent, message)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
endTimeMs = System.currentTimeMillis();
|
||||
|
||||
@@ -248,7 +312,19 @@ public class ProcessingActivity extends Activity {
|
||||
}
|
||||
|
||||
if (result == 0) {
|
||||
publishOutput(finalOutputUri);
|
||||
// Tutup output descriptor lebih dulu agar ukuran file final sudah flush
|
||||
// sebelum dibaca untuk perbandingan original vs compressed.
|
||||
closeQuietly(outputPfd);
|
||||
outputPfd = null;
|
||||
|
||||
publishOutput(finalOutputUri, outputMime, imageJob);
|
||||
|
||||
final long compressedSizeBytes = getUriSize(finalOutputUri);
|
||||
final String outputResultText = buildOutputResultText(
|
||||
originalSizeBytes,
|
||||
compressedSizeBytes
|
||||
);
|
||||
|
||||
runOnUiThread(() -> {
|
||||
finished = true;
|
||||
handler.removeCallbacks(elapsedRunnable);
|
||||
@@ -258,30 +334,17 @@ public class ProcessingActivity extends Activity {
|
||||
circleProgress.setProgress(100);
|
||||
txtStatus.setText("Selesai");
|
||||
txtElapsed.setText("Total waktu: " + formatElapsed(endTimeMs - startTimeMs));
|
||||
txtOutput.setText("Video disimpan di gallery");
|
||||
txtOutput.setText(outputResultText);
|
||||
btnShareVideo.setVisibility(View.VISIBLE);
|
||||
btnShareDoc.setVisibility(View.VISIBLE);
|
||||
btnCancel.setText("Selesai");
|
||||
btnCancel.setEnabled(true);
|
||||
btnCancel.setOnClickListener(v -> finish());
|
||||
toast("Video disimpan di gallery");
|
||||
toast(imageJob ? "Gambar disimpan di gallery" : "Video disimpan di gallery");
|
||||
});
|
||||
} else {
|
||||
deleteFailedOutput(finalOutputUri);
|
||||
runOnUiThread(() -> {
|
||||
finished = true;
|
||||
handler.removeCallbacks(elapsedRunnable);
|
||||
handler.removeCallbacks(smoothProgressRunnable);
|
||||
allowScreenOffAfterProcessing();
|
||||
btnShareVideo.setVisibility(View.GONE);
|
||||
btnShareDoc.setVisibility(View.GONE);
|
||||
txtStatus.setText("Gagal compress. Code: " + result);
|
||||
txtElapsed.setText("Total waktu: " + formatElapsed(endTimeMs - startTimeMs));
|
||||
btnCancel.setText("Kembali");
|
||||
btnCancel.setEnabled(true);
|
||||
btnCancel.setOnClickListener(v -> finish());
|
||||
toast("Gagal compress");
|
||||
});
|
||||
runOnUiThread(() -> showFailed(result));
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -308,11 +371,26 @@ public class ProcessingActivity extends Activity {
|
||||
closeQuietly(inputPfd);
|
||||
closeQuietly(outputPfd);
|
||||
}
|
||||
}, "KCompressor-Processing");
|
||||
}, imageJob ? "KCompressor-Image" : "KCompressor-Video");
|
||||
|
||||
workerThread.start();
|
||||
}
|
||||
|
||||
private void showFailed(int result) {
|
||||
finished = true;
|
||||
handler.removeCallbacks(elapsedRunnable);
|
||||
handler.removeCallbacks(smoothProgressRunnable);
|
||||
allowScreenOffAfterProcessing();
|
||||
btnShareVideo.setVisibility(View.GONE);
|
||||
btnShareDoc.setVisibility(View.GONE);
|
||||
txtStatus.setText("Gagal compress. Code: " + result);
|
||||
txtElapsed.setText("Total waktu: " + formatElapsed(endTimeMs - startTimeMs));
|
||||
btnCancel.setText("Kembali");
|
||||
btnCancel.setEnabled(true);
|
||||
btnCancel.setOnClickListener(v -> finish());
|
||||
toast("Gagal compress");
|
||||
}
|
||||
|
||||
private void showCancelDialog() {
|
||||
if (finished) {
|
||||
finish();
|
||||
@@ -394,18 +472,14 @@ public class ProcessingActivity extends Activity {
|
||||
if (safePercent < 0) safePercent = 0;
|
||||
if (safePercent > 100) safePercent = 100;
|
||||
|
||||
// Jangan tampilkan 100 sebelum native benar-benar selesai.
|
||||
if (!finished && safePercent > 99) {
|
||||
safePercent = 99;
|
||||
}
|
||||
|
||||
// Target progress boleh naik, tetapi tidak boleh mundur.
|
||||
if (safePercent > targetProgress) {
|
||||
targetProgress = safePercent;
|
||||
}
|
||||
|
||||
// UI dibuat halus: jika native lompat dari 10 ke 40,
|
||||
// circle naik 11, 12, 13... sehingga terasa realtime dan tidak glitch.
|
||||
if (!smoothProgressRunning) {
|
||||
smoothProgressRunning = true;
|
||||
handler.post(smoothProgressRunnable);
|
||||
@@ -416,11 +490,28 @@ public class ProcessingActivity extends Activity {
|
||||
|
||||
private String normalizeProcessMessage(String message) {
|
||||
if (message == null || message.length() == 0) {
|
||||
return "Memproses video...";
|
||||
return imageJob ? "Memproses gambar..." : "Memproses video...";
|
||||
}
|
||||
|
||||
String lower = message.toLowerCase(Locale.US);
|
||||
|
||||
if (lower.contains("done")) {
|
||||
return "Selesai";
|
||||
}
|
||||
|
||||
if (lower.contains("canceled")) {
|
||||
return "Dibatalkan";
|
||||
}
|
||||
|
||||
if (imageJob) {
|
||||
if (lower.contains("membaca")) return "Membaca gambar...";
|
||||
if (lower.contains("decode")) return "Membuka gambar...";
|
||||
if (lower.contains("resize")) return "Mengubah resolusi gambar...";
|
||||
if (lower.contains("encode")) return "Mengompres gambar...";
|
||||
if (lower.contains("menyimpan")) return "Menyimpan gambar...";
|
||||
return message;
|
||||
}
|
||||
|
||||
if (lower.startsWith("encoding")) {
|
||||
return "Memproses video...";
|
||||
}
|
||||
@@ -437,31 +528,21 @@ public class ProcessingActivity extends Activity {
|
||||
return "Menyiapkan output...";
|
||||
}
|
||||
|
||||
if (lower.contains("done")) {
|
||||
return "Selesai";
|
||||
}
|
||||
|
||||
if (lower.contains("canceled")) {
|
||||
return "Dibatalkan";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
private void shareCompressedVideo(boolean asDocument) {
|
||||
private void shareCompressedFile(boolean asDocument) {
|
||||
if (outputUri == null) {
|
||||
toast("Video output belum tersedia");
|
||||
toast(imageJob ? "Gambar output belum tersedia" : "Video output belum tersedia");
|
||||
return;
|
||||
}
|
||||
|
||||
Intent sendIntent = new Intent(Intent.ACTION_SEND);
|
||||
|
||||
if (asDocument) {
|
||||
// Untuk WhatsApp/Telegram, MIME generic biasanya diperlakukan sebagai dokumen/file.
|
||||
sendIntent.setType("application/octet-stream");
|
||||
} else {
|
||||
// Share normal sebagai video agar aplikasi target mengenali preview/player video.
|
||||
sendIntent.setType("video/mp4");
|
||||
sendIntent.setType(outputMime);
|
||||
}
|
||||
|
||||
sendIntent.putExtra(Intent.EXTRA_STREAM, outputUri);
|
||||
@@ -469,17 +550,17 @@ public class ProcessingActivity extends Activity {
|
||||
|
||||
Intent chooser = Intent.createChooser(
|
||||
sendIntent,
|
||||
asDocument ? "Share as Doc" : "Share as Video"
|
||||
asDocument ? "Share as Doc" : (imageJob ? "Share as Image" : "Share as Video")
|
||||
);
|
||||
|
||||
try {
|
||||
startActivity(chooser);
|
||||
} catch (Exception e) {
|
||||
toast("Tidak ada aplikasi untuk membagikan video");
|
||||
toast("Tidak ada aplikasi untuk membagikan file");
|
||||
}
|
||||
}
|
||||
|
||||
private String makeOutputName(String encoderName, int targetShortSide, int crf) {
|
||||
private String makeVideoOutputName(String encoderName, int targetShortSide, int crf) {
|
||||
String codec = encoderName.equals("libx265") ? "hevc" : "avc";
|
||||
String res = targetShortSide <= 0 ? "original" : targetShortSide + "p";
|
||||
String stamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
|
||||
@@ -487,17 +568,51 @@ public class ProcessingActivity extends Activity {
|
||||
return "KCompressor_" + codec + "_" + res + "_crf" + crf + "_" + stamp + ".mp4";
|
||||
}
|
||||
|
||||
private ParcelFileDescriptor createOutputFile(String displayName) throws Exception {
|
||||
private String makeImageOutputName(String format, int targetShortSide, int quality) {
|
||||
String safeFormat = format == null ? "jpeg" : format.toLowerCase(Locale.US);
|
||||
String ext;
|
||||
String tag;
|
||||
|
||||
if (safeFormat.equals("png_lossy")) {
|
||||
ext = "png";
|
||||
tag = "png_quant";
|
||||
} else if (safeFormat.equals("png_lossless") || safeFormat.equals("png")) {
|
||||
ext = "png";
|
||||
tag = "png_lossless";
|
||||
} else if (safeFormat.equals("webp_lossless")) {
|
||||
ext = "webp";
|
||||
tag = "webp_lossless";
|
||||
} else if (safeFormat.equals("webp")) {
|
||||
ext = "webp";
|
||||
tag = "webp";
|
||||
} else if (safeFormat.equals("avif")) {
|
||||
ext = "avif";
|
||||
tag = "avif";
|
||||
} else {
|
||||
ext = "jpg";
|
||||
tag = "jpeg";
|
||||
}
|
||||
|
||||
String res = targetShortSide <= 0 ? "original" : targetShortSide + "p";
|
||||
String stamp = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(new Date());
|
||||
|
||||
return "KCompressor_" + tag + "_" + res + "_q" + quality + "_" + stamp + "." + ext;
|
||||
}
|
||||
|
||||
private ParcelFileDescriptor createOutputFile(String displayName, String mimeType, boolean image) throws Exception {
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Video.Media.DISPLAY_NAME, displayName);
|
||||
values.put(MediaStore.Video.Media.MIME_TYPE, "video/mp4");
|
||||
values.put(MediaStore.Video.Media.RELATIVE_PATH,
|
||||
values.put(image ? MediaStore.Images.Media.DISPLAY_NAME : MediaStore.Video.Media.DISPLAY_NAME, displayName);
|
||||
values.put(image ? MediaStore.Images.Media.MIME_TYPE : MediaStore.Video.Media.MIME_TYPE, mimeType);
|
||||
values.put(image ? MediaStore.Images.Media.RELATIVE_PATH : MediaStore.Video.Media.RELATIVE_PATH,
|
||||
Environment.DIRECTORY_DCIM + "/KCompressor");
|
||||
values.put(MediaStore.Video.Media.IS_PENDING, 1);
|
||||
values.put(image ? MediaStore.Images.Media.IS_PENDING : MediaStore.Video.Media.IS_PENDING, 1);
|
||||
|
||||
ContentResolver resolver = getContentResolver();
|
||||
outputUri = resolver.insert(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, values);
|
||||
outputUri = resolver.insert(
|
||||
image ? MediaStore.Images.Media.EXTERNAL_CONTENT_URI : MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
|
||||
values
|
||||
);
|
||||
|
||||
if (outputUri == null) {
|
||||
throw new IllegalStateException("Gagal membuat MediaStore output");
|
||||
@@ -532,23 +647,198 @@ public class ProcessingActivity extends Activity {
|
||||
}
|
||||
}
|
||||
|
||||
private void publishOutput(Uri uri) {
|
||||
private void publishOutput(Uri uri, String mimeType, boolean image) {
|
||||
if (uri == null) return;
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 29) {
|
||||
ContentValues values = new ContentValues();
|
||||
values.put(MediaStore.Video.Media.IS_PENDING, 0);
|
||||
values.put(image ? MediaStore.Images.Media.IS_PENDING : MediaStore.Video.Media.IS_PENDING, 0);
|
||||
getContentResolver().update(uri, values, null, null);
|
||||
} else {
|
||||
MediaScannerConnection.scanFile(
|
||||
this,
|
||||
new String[]{uri.getPath()},
|
||||
new String[]{"video/mp4"},
|
||||
new String[]{mimeType},
|
||||
null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private String mimeForImageFormat(String format) {
|
||||
if (format == null) return "image/jpeg";
|
||||
|
||||
String safeFormat = format.toLowerCase(Locale.US);
|
||||
if (safeFormat.startsWith("png")) return "image/png";
|
||||
if (safeFormat.startsWith("webp")) return "image/webp";
|
||||
if (safeFormat.equals("avif")) return "image/avif";
|
||||
return "image/jpeg";
|
||||
}
|
||||
|
||||
private long getUriSize(Uri uri) {
|
||||
if (uri == null) {
|
||||
return -1L;
|
||||
}
|
||||
|
||||
try {
|
||||
if ("file".equals(uri.getScheme())) {
|
||||
String path = uri.getPath();
|
||||
if (path == null) {
|
||||
return -1L;
|
||||
}
|
||||
|
||||
File file = new File(path);
|
||||
return file.exists() ? file.length() : -1L;
|
||||
}
|
||||
} catch (Throwable ignored) {}
|
||||
|
||||
Cursor cursor = null;
|
||||
try {
|
||||
cursor = getContentResolver().query(
|
||||
uri,
|
||||
new String[]{OpenableColumns.SIZE},
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
if (cursor != null && cursor.moveToFirst()) {
|
||||
int index = cursor.getColumnIndex(OpenableColumns.SIZE);
|
||||
if (index >= 0 && !cursor.isNull(index)) {
|
||||
long size = cursor.getLong(index);
|
||||
if (size > 0L) {
|
||||
return size;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} finally {
|
||||
if (cursor != null) {
|
||||
cursor.close();
|
||||
}
|
||||
}
|
||||
|
||||
AssetFileDescriptor afd = null;
|
||||
try {
|
||||
afd = getContentResolver().openAssetFileDescriptor(uri, "r");
|
||||
if (afd != null) {
|
||||
long length = afd.getLength();
|
||||
if (length > 0L) {
|
||||
return length;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} finally {
|
||||
if (afd != null) {
|
||||
try {
|
||||
afd.close();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
}
|
||||
|
||||
ParcelFileDescriptor pfd = null;
|
||||
try {
|
||||
pfd = getContentResolver().openFileDescriptor(uri, "r");
|
||||
if (pfd != null) {
|
||||
long statSize = pfd.getStatSize();
|
||||
if (statSize > 0L) {
|
||||
return statSize;
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} finally {
|
||||
closeQuietly(pfd);
|
||||
}
|
||||
|
||||
return -1L;
|
||||
}
|
||||
|
||||
private String buildOutputResultText(long originalSizeBytes, long compressedSizeBytes) {
|
||||
String savedText = imageJob ? "Gambar disimpan di gallery" : "Video disimpan di gallery";
|
||||
|
||||
if (originalSizeBytes <= 0L || compressedSizeBytes <= 0L) {
|
||||
return savedText + "\n\n" +
|
||||
"Original : tidak diketahui\n" +
|
||||
"Compressed : " + (compressedSizeBytes > 0L ? formatFileSize(compressedSizeBytes) : "tidak diketahui");
|
||||
}
|
||||
|
||||
long delta = originalSizeBytes - compressedSizeBytes;
|
||||
double finalRatioPercent = (compressedSizeBytes * 100.0) / originalSizeBytes;
|
||||
|
||||
StringBuilder builder = new StringBuilder();
|
||||
builder.append(savedText).append("\n\n");
|
||||
builder.append("Original : ").append(formatFileSize(originalSizeBytes)).append("\n");
|
||||
builder.append("Compressed : ").append(formatFileSize(compressedSizeBytes)).append("\n");
|
||||
|
||||
if (delta >= 0L) {
|
||||
double savedPercent = (delta * 100.0) / originalSizeBytes;
|
||||
builder.append("Hemat : ")
|
||||
.append(formatFileSize(delta))
|
||||
.append(" (")
|
||||
.append(formatPercent(savedPercent))
|
||||
.append("%)\n");
|
||||
builder.append("Ukuran akhir: ")
|
||||
.append(formatPercent(finalRatioPercent))
|
||||
.append("% dari original");
|
||||
} else {
|
||||
double largerPercent = ((-delta) * 100.0) / originalSizeBytes;
|
||||
builder.append("Hasil : lebih besar ")
|
||||
.append(formatFileSize(-delta))
|
||||
.append(" (+")
|
||||
.append(formatPercent(largerPercent))
|
||||
.append("%)\n");
|
||||
builder.append("Ukuran akhir: ")
|
||||
.append(formatPercent(finalRatioPercent))
|
||||
.append("% dari original");
|
||||
}
|
||||
|
||||
return builder.toString();
|
||||
}
|
||||
|
||||
private String formatFileSize(long bytes) {
|
||||
if (bytes < 0L) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (bytes < 1024L) {
|
||||
return bytes + " B";
|
||||
}
|
||||
|
||||
final String[] units = new String[]{"KB", "MB", "GB", "TB"};
|
||||
double value = bytes;
|
||||
int unitIndex = -1;
|
||||
|
||||
do {
|
||||
value /= 1024.0;
|
||||
unitIndex++;
|
||||
} while (value >= 1024.0 && unitIndex < units.length - 1);
|
||||
|
||||
if (value >= 100.0) {
|
||||
return String.format(Locale.US, "%.0f %s", value, units[unitIndex]);
|
||||
}
|
||||
|
||||
if (value >= 10.0) {
|
||||
return String.format(Locale.US, "%.1f %s", value, units[unitIndex]);
|
||||
}
|
||||
|
||||
return String.format(Locale.US, "%.2f %s", value, units[unitIndex]);
|
||||
}
|
||||
|
||||
private String formatPercent(double value) {
|
||||
if (Double.isNaN(value) || Double.isInfinite(value)) {
|
||||
return "0";
|
||||
}
|
||||
|
||||
if (value >= 100.0) {
|
||||
return String.format(Locale.US, "%.0f", value);
|
||||
}
|
||||
|
||||
if (value >= 10.0) {
|
||||
return String.format(Locale.US, "%.1f", value);
|
||||
}
|
||||
|
||||
return String.format(Locale.US, "%.2f", value);
|
||||
}
|
||||
|
||||
private void deleteFailedOutput(Uri uri) {
|
||||
if (uri == null) return;
|
||||
|
||||
|
||||
@@ -226,6 +226,179 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cardImageCompression"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:background="@drawable/bg_amoled_card"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Image"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtImageSelected"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:text="Belum ada gambar dipilih"
|
||||
android:textColor="#E5E7EB"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtImageInfo"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:text="Pilih gambar JPEG, PNG, WebP, atau AVIF untuk membaca resolusi, ukuran file, dan format."
|
||||
android:textColor="#9CA3AF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnPickImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:background="@drawable/bg_button_dark_secondary"
|
||||
android:text="Pilih Gambar"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="#FFFFFF" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutImageSettings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="#1F2937" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Pengaturan Kompresi"
|
||||
android:textColor="#FFFFFF"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:text="Format Output"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spImageFormat"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="@drawable/bg_spinner_dark"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="Ukuran Output"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/spImageResolution"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="4dp"
|
||||
android:background="@drawable/bg_spinner_dark"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtImageCompressionSummary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:text="Output: JPEG / MozJPEG • Original • Q82"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtImageSettingsToggle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:gravity="start"
|
||||
android:text="Pengaturan lanjutan ▼"
|
||||
android:textColor="#22C55E"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutImageAdvancedSettings"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtImageQualityValue"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Quality 82 • Seimbang"
|
||||
android:textColor="#9CA3AF"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<SeekBar
|
||||
android:id="@+id/seekImageQuality"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginTop="2dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:text="Catatan: PNG Lossless tidak menurunkan kualitas visual. PNG TinyPNG Style memakai quantization palette agar ukuran lebih kecil."
|
||||
android:textColor="#6B7280"
|
||||
android:textSize="12sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/btnCompressImage"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="54dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:background="@drawable/bg_button_green"
|
||||
android:enabled="false"
|
||||
android:text="Compress Image"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="#020617"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/txtProgress"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
Reference in New Issue
Block a user