Compare commits
11
Commits
082a6eab19
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
036f67d05a | ||
|
|
3956b90c3c | ||
|
|
2a10d89d4c | ||
|
|
06c466764c | ||
|
|
e8b92ed157 | ||
|
|
d4bbde9320 | ||
|
|
1a606d5c64 | ||
|
|
5a64048ba2 | ||
|
|
d2bfcb83a0 | ||
|
|
37cc3a7486 | ||
|
|
f2677d37e8 |
@@ -0,0 +1,25 @@
|
|||||||
|
name: Notify Parent Repo
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
dispatch:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Trigger root monorepo build
|
||||||
|
uses: peter-evans/repository-dispatch@v3
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.DISPATCH_TOKEN }}
|
||||||
|
repository: asepharyana/asepharyana-hub
|
||||||
|
event-type: submodule-updated
|
||||||
|
client-payload: |
|
||||||
|
{
|
||||||
|
"service": "tools",
|
||||||
|
"ref": "${{ github.ref }}",
|
||||||
|
"sha": "${{ github.sha }}",
|
||||||
|
"actor": "${{ github.actor }}"
|
||||||
|
}
|
||||||
@@ -36,21 +36,21 @@ pub enum Tool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Tool {
|
impl Tool {
|
||||||
/// Returns the NATS subject prefix for this tool.
|
/// Returns the NATS subject group (without "tools." prefix).
|
||||||
pub fn subject_prefix(&self) -> &'static str {
|
pub fn group(&self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
Tool::Scan => "tools.scan",
|
Tool::Scan => "scan",
|
||||||
Tool::ImageCompress
|
Tool::ImageCompress
|
||||||
| Tool::ImageResize
|
| Tool::ImageResize
|
||||||
| Tool::ImageConvert
|
| Tool::ImageConvert
|
||||||
| Tool::RemoveBg => "tools.image",
|
| Tool::RemoveBg => "image",
|
||||||
Tool::PdfMerge
|
Tool::PdfMerge
|
||||||
| Tool::PdfSplit
|
| Tool::PdfSplit
|
||||||
| Tool::ImagesToPdf
|
| Tool::ImagesToPdf
|
||||||
| Tool::PdfCompress
|
| Tool::PdfCompress
|
||||||
| Tool::PdfToImages => "tools.pdf",
|
| Tool::PdfToImages => "pdf",
|
||||||
Tool::VideoCompress | Tool::VideoTrim | Tool::GifMaker => "tools.video",
|
Tool::VideoCompress | Tool::VideoTrim | Tool::GifMaker => "video",
|
||||||
Tool::AudioExtract | Tool::AudioConvert => "tools.audio",
|
Tool::AudioExtract | Tool::AudioConvert => "audio",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,72 +97,96 @@ impl Metrics {
|
|||||||
pub fn format(&self) -> String {
|
pub fn format(&self) -> String {
|
||||||
let mut output = String::new();
|
let mut output = String::new();
|
||||||
|
|
||||||
|
// ── tools_jobs_total ──
|
||||||
output.push_str("# HELP tools_jobs_total Total jobs processed\n");
|
output.push_str("# HELP tools_jobs_total Total jobs processed\n");
|
||||||
output.push_str("# TYPE tools_jobs_total counter\n");
|
output.push_str("# TYPE tools_jobs_total counter\n");
|
||||||
if let Ok(map) = self.jobs_total.lock() {
|
{
|
||||||
for ((tool, status), count) in map.iter() {
|
if let Ok(map) = self.jobs_total.lock() {
|
||||||
let val = count.load(Ordering::Relaxed);
|
if map.is_empty() {
|
||||||
output.push_str(&format!(
|
output.push_str("tools_jobs_total{tool=\"\",status=\"\"} 0\n");
|
||||||
"tools_jobs_total{{tool=\"{}\",status=\"{}\"}} {}\n",
|
} else {
|
||||||
tool, status, val
|
for ((tool, status), count) in map.iter() {
|
||||||
));
|
let val = count.load(Ordering::Relaxed);
|
||||||
|
output.push_str(&format!(
|
||||||
|
"tools_jobs_total{{tool=\"{}\",status=\"{}\"}} {}\n",
|
||||||
|
tool, status, val
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tools_uploaded_files_total ──
|
||||||
output.push_str("# HELP tools_uploaded_files_total Total uploaded files\n");
|
output.push_str("# HELP tools_uploaded_files_total Total uploaded files\n");
|
||||||
output.push_str("# TYPE tools_uploaded_files_total counter\n");
|
output.push_str("# TYPE tools_uploaded_files_total counter\n");
|
||||||
if let Ok(map) = self.uploaded_files_total.lock() {
|
{
|
||||||
for ((tool, status), count) in map.iter() {
|
if let Ok(map) = self.uploaded_files_total.lock() {
|
||||||
let val = count.load(Ordering::Relaxed);
|
if map.is_empty() {
|
||||||
output.push_str(&format!(
|
output.push_str("tools_uploaded_files_total{tool=\"\",status=\"\"} 0\n");
|
||||||
"tools_uploaded_files_total{{tool=\"{}\",status=\"{}\"}} {}\n",
|
} else {
|
||||||
tool, status, val
|
for ((tool, status), count) in map.iter() {
|
||||||
));
|
let val = count.load(Ordering::Relaxed);
|
||||||
|
output.push_str(&format!(
|
||||||
|
"tools_uploaded_files_total{{tool=\"{}\",status=\"{}\"}} {}\n",
|
||||||
|
tool, status, val
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tools_processing_duration_ms ──
|
||||||
output.push_str("# HELP tools_processing_duration_ms Processing duration histogram\n");
|
output.push_str("# HELP tools_processing_duration_ms Processing duration histogram\n");
|
||||||
output.push_str("# TYPE tools_processing_duration_ms histogram\n");
|
output.push_str("# TYPE tools_processing_duration_ms histogram\n");
|
||||||
if let Ok(map) = self.duration_histogram.lock() {
|
{
|
||||||
for (tool, buckets) in map.iter() {
|
if let Ok(map) = self.duration_histogram.lock() {
|
||||||
for (i, bucket) in self.duration_buckets.iter().enumerate() {
|
for (tool, buckets) in map.iter() {
|
||||||
if let Some(b) = buckets.get(i) {
|
for (i, bucket) in self.duration_buckets.iter().enumerate() {
|
||||||
let val = b.load(Ordering::Relaxed);
|
if let Some(b) = buckets.get(i) {
|
||||||
if val > 0 {
|
let val = b.load(Ordering::Relaxed);
|
||||||
output.push_str(&format!(
|
if val > 0 {
|
||||||
"tools_processing_duration_ms_bucket{{tool=\"{}\",le=\"{}\"}} {}\n",
|
output.push_str(&format!(
|
||||||
tool, bucket, val
|
"tools_processing_duration_ms_bucket{{tool=\"{}\",le=\"{}\"}} {}\n",
|
||||||
));
|
tool, bucket, val
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tools_queue_depth ──
|
||||||
output.push_str("# HELP tools_queue_depth Current queue depth\n");
|
output.push_str("# HELP tools_queue_depth Current queue depth\n");
|
||||||
output.push_str("# TYPE tools_queue_depth gauge\n");
|
output.push_str("# TYPE tools_queue_depth gauge\n");
|
||||||
if let Ok(map) = self.queue_depth.lock() {
|
{
|
||||||
for (tool, depth) in map.iter() {
|
if let Ok(map) = self.queue_depth.lock() {
|
||||||
let val = depth.load(Ordering::Relaxed);
|
for (tool, depth) in map.iter() {
|
||||||
output.push_str(&format!(
|
let val = depth.load(Ordering::Relaxed);
|
||||||
"tools_queue_depth{{tool=\"{}\"}} {}\n",
|
output.push_str(&format!(
|
||||||
tool, val
|
"tools_queue_depth{{tool=\"{}\"}} {}\n",
|
||||||
));
|
tool, val
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tools_rate_limit_hits ──
|
||||||
output.push_str("# HELP tools_rate_limit_hits Total rate limit violations\n");
|
output.push_str("# HELP tools_rate_limit_hits Total rate limit violations\n");
|
||||||
output.push_str("# TYPE tools_rate_limit_hits counter\n");
|
output.push_str("# TYPE tools_rate_limit_hits counter\n");
|
||||||
if let Ok(map) = self.rate_limit_hits.lock() {
|
{
|
||||||
for (tool, count) in map.iter() {
|
if let Ok(map) = self.rate_limit_hits.lock() {
|
||||||
let val = count.load(Ordering::Relaxed);
|
for (tool, count) in map.iter() {
|
||||||
output.push_str(&format!(
|
let val = count.load(Ordering::Relaxed);
|
||||||
"tools_rate_limit_hits{{tool=\"{}\"}} {}\n",
|
output.push_str(&format!(
|
||||||
tool, val
|
"tools_rate_limit_hits{{tool=\"{}\"}} {}\n",
|
||||||
));
|
tool, val
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── tools_cleanup_deleted_files ──
|
||||||
output.push_str("# HELP tools_cleanup_deleted_files Total files deleted by cleanup\n");
|
output.push_str("# HELP tools_cleanup_deleted_files Total files deleted by cleanup\n");
|
||||||
output.push_str("# TYPE tools_cleanup_deleted_files counter\n");
|
output.push_str("# TYPE tools_cleanup_deleted_files counter\n");
|
||||||
output.push_str(&format!(
|
output.push_str(&format!(
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ impl NatsPublisher {
|
|||||||
|
|
||||||
/// Publish a job to the appropriate NATS subject.
|
/// Publish a job to the appropriate NATS subject.
|
||||||
pub async fn publish_job(nats: &Client, tool: &Tool, job: &Job) -> Result<(), NatsError> {
|
pub async fn publish_job(nats: &Client, tool: &Tool, job: &Job) -> Result<(), NatsError> {
|
||||||
let prefix = tool.subject_prefix();
|
let group = tool.group();
|
||||||
let subject = nats::job_subject(prefix, &job.id.to_string());
|
let subject = nats::job_subject(group, &job.id.to_string());
|
||||||
let payload = serde_json::to_vec(job)
|
let payload = serde_json::to_vec(job)
|
||||||
.map_err(|e| NatsError::Publish(e.to_string()))?;
|
.map_err(|e| NatsError::Publish(e.to_string()))?;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use image::{DynamicImage, ImageFormat};
|
||||||
|
|
||||||
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
|
||||||
|
pub async fn process(
|
||||||
|
img: &DynamicImage,
|
||||||
|
options: &serde_json::Value,
|
||||||
|
output_dir: &PathBuf,
|
||||||
|
_progress: &ProgressReporter,
|
||||||
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let quality = options.get("quality").and_then(|v| v.as_u64()).unwrap_or(80) as u8;
|
||||||
|
|
||||||
|
let output_path = output_dir.join("compressed.jpg");
|
||||||
|
let mut file = std::fs::File::create(&output_path)?;
|
||||||
|
|
||||||
|
if img.color().has_color() {
|
||||||
|
let rgb = img.to_rgb8();
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, quality);
|
||||||
|
encoder.encode(rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8)?;
|
||||||
|
} else {
|
||||||
|
let gray = img.to_luma8();
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, quality);
|
||||||
|
encoder.encode(gray.as_raw(), gray.width(), gray.height(), image::ExtendedColorType::L8)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use image::{DynamicImage, ImageFormat};
|
||||||
|
|
||||||
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
|
||||||
|
pub async fn process(
|
||||||
|
img: &DynamicImage,
|
||||||
|
options: &serde_json::Value,
|
||||||
|
output_dir: &PathBuf,
|
||||||
|
_progress: &ProgressReporter,
|
||||||
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let fmt = options.get("format").and_then(|v| v.as_str()).unwrap_or("jpeg");
|
||||||
|
let quality = options.get("quality").and_then(|v| v.as_u64()).unwrap_or(85) as u8;
|
||||||
|
|
||||||
|
let image_format = match fmt {
|
||||||
|
"png" => ImageFormat::Png,
|
||||||
|
"webp" => ImageFormat::WebP,
|
||||||
|
"gif" => ImageFormat::Gif,
|
||||||
|
"bmp" => ImageFormat::Bmp,
|
||||||
|
_ => ImageFormat::Jpeg,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext = match image_format {
|
||||||
|
ImageFormat::Jpeg => "jpg",
|
||||||
|
ImageFormat::Png => "png",
|
||||||
|
ImageFormat::WebP => "webp",
|
||||||
|
ImageFormat::Gif => "gif",
|
||||||
|
ImageFormat::Bmp => "bmp",
|
||||||
|
_ => "bin",
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_path = output_dir.join(format!("converted.{}", ext));
|
||||||
|
|
||||||
|
match image_format {
|
||||||
|
ImageFormat::Jpeg => {
|
||||||
|
let mut file = std::fs::File::create(&output_path)?;
|
||||||
|
if img.color().has_color() {
|
||||||
|
let rgb = img.to_rgb8();
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, quality);
|
||||||
|
encoder.encode(rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8)?;
|
||||||
|
} else {
|
||||||
|
let gray = img.to_luma8();
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, quality);
|
||||||
|
encoder.encode(gray.as_raw(), gray.width(), gray.height(), image::ExtendedColorType::L8)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImageFormat::Png | ImageFormat::WebP | ImageFormat::Gif | ImageFormat::Bmp => {
|
||||||
|
img.save(&output_path)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
img.save(&output_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
@@ -1,15 +1,65 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::config::WorkerConfig;
|
use crate::config::WorkerConfig;
|
||||||
use tools_common::types::Job;
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
use tools_common::types::{Job, JobStatus, Tool};
|
||||||
|
|
||||||
|
mod compress;
|
||||||
|
mod convert;
|
||||||
|
mod resize;
|
||||||
|
|
||||||
/// Process an image tool job.
|
/// Process an image tool job.
|
||||||
pub async fn process_job(
|
pub async fn process_job(
|
||||||
job: Job,
|
job: Job,
|
||||||
_redis: &redis::Client,
|
redis: &redis::Client,
|
||||||
_config: &WorkerConfig,
|
config: &WorkerConfig,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
tracing::info!(job_id = %job.id, tool = %job.tool.as_str(), "Processing image job (stub)");
|
tracing::info!(job_id = %job.id, tool = %job.tool.as_str(), "Processing image job");
|
||||||
// TODO: Phase 2.2 - implement actual image processing
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
let nats = async_nats::connect(&config.nats_url).await?;
|
||||||
tracing::info!(job_id = %job.id, "Image job completed");
|
let progress = ProgressReporter::new(redis.clone(), nats, job.id, job.tool.clone());
|
||||||
|
|
||||||
|
progress
|
||||||
|
.report(JobStatus::Processing { stage: "load".to_string(), progress: 10 }, "load", 10, "Memuat gambar...")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let input_path = Path::new(&job.file_path);
|
||||||
|
let img = image::open(input_path)
|
||||||
|
.map_err(|e| format!("Failed to load image: {}", e))?;
|
||||||
|
|
||||||
|
progress
|
||||||
|
.report(JobStatus::Processing { stage: "process".to_string(), progress: 50 }, "process", 50, "Memproses...")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let output_dir = config.storage_path.join("output");
|
||||||
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
|
|
||||||
|
let result_path = match job.tool {
|
||||||
|
Tool::ImageCompress => compress::process(&img, &job.options, &output_dir, &progress).await?,
|
||||||
|
Tool::ImageResize => resize::process(&img, &job.options, &output_dir, &progress).await?,
|
||||||
|
Tool::ImageConvert => convert::process(&img, &job.options, &output_dir, &progress).await?,
|
||||||
|
_ => {
|
||||||
|
// Fallback: save as-is
|
||||||
|
let output_path = output_dir.join(format!("{}.png", job.id));
|
||||||
|
img.save(&output_path)?;
|
||||||
|
output_path
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
progress
|
||||||
|
.report(JobStatus::Completed, "complete", 100, "Selesai")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Update Redis with result
|
||||||
|
let mut conn = redis.get_multiplexed_async_connection().await?;
|
||||||
|
crate::nats::consumer::JobConsumer::update_job_result(
|
||||||
|
&mut conn,
|
||||||
|
job.id,
|
||||||
|
&result_path.to_string_lossy(),
|
||||||
|
job.ttl_seconds,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(job_id = %job.id, output = %result_path.display(), "Image job completed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use image::{DynamicImage, ImageFormat};
|
||||||
|
use image::imageops::FilterType;
|
||||||
|
|
||||||
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
|
||||||
|
pub async fn process(
|
||||||
|
img: &DynamicImage,
|
||||||
|
options: &serde_json::Value,
|
||||||
|
output_dir: &PathBuf,
|
||||||
|
_progress: &ProgressReporter,
|
||||||
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let width = options.get("width").and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||||
|
let height = options.get("height").and_then(|v| v.as_u64()).map(|v| v as u32);
|
||||||
|
let quality = options.get("quality").and_then(|v| v.as_u64()).unwrap_or(85) as u8;
|
||||||
|
let fit = options.get("fit").and_then(|v| v.as_str()).unwrap_or("inside");
|
||||||
|
let fmt = options.get("format").and_then(|v| v.as_str()).unwrap_or("jpeg");
|
||||||
|
|
||||||
|
let (new_w, new_h) = match (width, height) {
|
||||||
|
(Some(w), Some(h)) => (w, h),
|
||||||
|
(Some(w), None) => {
|
||||||
|
let ratio = w as f64 / img.width() as f64;
|
||||||
|
(w, (img.height() as f64 * ratio).round() as u32)
|
||||||
|
}
|
||||||
|
(None, Some(h)) => {
|
||||||
|
let ratio = h as f64 / img.height() as f64;
|
||||||
|
((img.width() as f64 * ratio).round() as u32, h)
|
||||||
|
}
|
||||||
|
(None, None) => (img.width(), img.height()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let new_w = new_w.max(1).min(10000);
|
||||||
|
let new_h = new_h.max(1).min(10000);
|
||||||
|
|
||||||
|
let resized = match fit {
|
||||||
|
"fill" => img.resize_exact(new_w, new_h, FilterType::Lanczos3),
|
||||||
|
"crop" => img.resize_to_fill(new_w, new_h, FilterType::Lanczos3),
|
||||||
|
_ => img.resize(new_w, new_h, FilterType::Lanczos3), // "inside" = fit within bounds
|
||||||
|
};
|
||||||
|
|
||||||
|
let image_format = match fmt {
|
||||||
|
"png" => ImageFormat::Png,
|
||||||
|
"webp" => ImageFormat::WebP,
|
||||||
|
"gif" => ImageFormat::Gif,
|
||||||
|
"bmp" => ImageFormat::Bmp,
|
||||||
|
_ => ImageFormat::Jpeg,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ext = match image_format {
|
||||||
|
ImageFormat::Jpeg => "jpg",
|
||||||
|
ImageFormat::Png => "png",
|
||||||
|
ImageFormat::WebP => "webp",
|
||||||
|
ImageFormat::Gif => "gif",
|
||||||
|
ImageFormat::Bmp => "bmp",
|
||||||
|
_ => "bin",
|
||||||
|
};
|
||||||
|
|
||||||
|
let output_path = output_dir.join(format!("resized.{}", ext));
|
||||||
|
|
||||||
|
// Save with appropriate encoder
|
||||||
|
match image_format {
|
||||||
|
ImageFormat::Jpeg => {
|
||||||
|
let mut rgb = resized.to_rgb8();
|
||||||
|
let mut file = std::fs::File::create(&output_path)?;
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut file, quality);
|
||||||
|
encoder.encode(rgb.as_raw(), resized.width(), resized.height(), image::ExtendedColorType::Rgb8)?;
|
||||||
|
}
|
||||||
|
ImageFormat::Png | ImageFormat::WebP | ImageFormat::Gif | ImageFormat::Bmp => {
|
||||||
|
resized.save(&output_path)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
resized.save(&output_path)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
@@ -53,18 +53,16 @@ impl JobConsumer {
|
|||||||
.await?;
|
.await?;
|
||||||
tracing::info!("Subscribed to tools.scheduler.cleanup");
|
tracing::info!("Subscribed to tools.scheduler.cleanup");
|
||||||
|
|
||||||
let redis_clone = redis.clone();
|
// Process messages concurrently — use tokio::spawn so ALL run
|
||||||
let config_clone = config.clone();
|
tokio::spawn(Self::process_subscription(scan_sub, redis.clone(), config.clone()));
|
||||||
|
tokio::spawn(Self::process_subscription(image_sub, redis.clone(), config.clone()));
|
||||||
|
tokio::spawn(Self::process_subscription(pdf_sub, redis.clone(), config.clone()));
|
||||||
|
tokio::spawn(Self::process_cleanup(cleanup_sub, config.clone()));
|
||||||
|
|
||||||
// Process messages concurrently
|
// Wait forever so the process doesn't exit
|
||||||
tokio::select! {
|
loop {
|
||||||
_ = Self::process_subscription(scan_sub, redis.clone(), config.clone()) => {},
|
tokio::time::sleep(std::time::Duration::from_secs(3600)).await;
|
||||||
_ = Self::process_subscription(image_sub, redis.clone(), config.clone()) => {},
|
|
||||||
_ = Self::process_subscription(pdf_sub, redis.clone(), config.clone()) => {},
|
|
||||||
_ = Self::process_cleanup(cleanup_sub, config_clone) => {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process messages from a NATS subscription.
|
/// Process messages from a NATS subscription.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ impl ProgressReporter {
|
|||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let subject = format!("tools.{}.progress.{}", self.tool.subject_prefix(), self.job_id);
|
let subject = format!("tools.{}.progress.{}", self.tool.group(), self.job_id);
|
||||||
if let Ok(payload) = serde_json::to_vec(&progress_msg) {
|
if let Ok(payload) = serde_json::to_vec(&progress_msg) {
|
||||||
let _ = self.nats.publish(subject, payload.into()).await;
|
let _ = self.nats.publish(subject, payload.into()).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use lopdf::Document;
|
||||||
|
|
||||||
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
|
||||||
|
/// Compress PDF by re-saving with compression.
|
||||||
|
pub async fn process(
|
||||||
|
input_path: &Path,
|
||||||
|
_options: &serde_json::Value,
|
||||||
|
output_dir: &PathBuf,
|
||||||
|
_progress: &ProgressReporter,
|
||||||
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let mut doc = Document::load(input_path)?;
|
||||||
|
|
||||||
|
let output_path = output_dir.join(format!("compressed_{}", input_path.file_name().unwrap_or_default().to_string_lossy()));
|
||||||
|
doc.save_to(&mut std::fs::File::create(&output_path)?)?;
|
||||||
|
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use lopdf::{Document, Object, Stream, Dictionary};
|
||||||
|
|
||||||
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
use tools_common::types::Job;
|
||||||
|
|
||||||
|
/// Convert images into a single PDF.
|
||||||
|
pub async fn process(
|
||||||
|
job: &Job,
|
||||||
|
output_dir: &PathBuf,
|
||||||
|
_progress: &ProgressReporter,
|
||||||
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let output_path = output_dir.join(format!("{}_images.pdf", job.id));
|
||||||
|
|
||||||
|
let mut image_paths = vec![job.file_path.clone()];
|
||||||
|
if let Some(files) = job.options.get("files").and_then(|v| v.as_array()) {
|
||||||
|
for f in files {
|
||||||
|
if let Some(path) = f.as_str() {
|
||||||
|
image_paths.push(path.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut doc = Document::new();
|
||||||
|
let pages_id = doc.new_object_id();
|
||||||
|
let mut kids = Vec::new();
|
||||||
|
|
||||||
|
for img_path in &image_paths {
|
||||||
|
let img_data = std::fs::read(img_path)?;
|
||||||
|
let format = image::guess_format(&img_data).unwrap_or(image::ImageFormat::Jpeg);
|
||||||
|
let img = image::load_from_memory(&img_data)
|
||||||
|
.map_err(|e| format!("Cannot load image: {}", e))?;
|
||||||
|
|
||||||
|
let jpeg_data = if format == image::ImageFormat::Jpeg {
|
||||||
|
img_data
|
||||||
|
} else {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let rgb = img.to_rgb8();
|
||||||
|
let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, 85);
|
||||||
|
encoder.encode(rgb.as_raw(), rgb.width(), rgb.height(), image::ExtendedColorType::Rgb8)?;
|
||||||
|
buf
|
||||||
|
};
|
||||||
|
|
||||||
|
let page_width = 595.28;
|
||||||
|
let page_height = 841.89;
|
||||||
|
let scale = (page_width / img.width() as f64).min(page_height / img.height() as f64) * 0.9;
|
||||||
|
let ox = (page_width - img.width() as f64 * scale) / 2.0;
|
||||||
|
let oy = (page_height - img.height() as f64 * scale) / 2.0;
|
||||||
|
|
||||||
|
// Image XObject
|
||||||
|
let mut img_dict = Dictionary::new();
|
||||||
|
img_dict.set("Type", Object::Name("XObject".as_bytes().to_vec()));
|
||||||
|
img_dict.set("Subtype", Object::Name("Image".as_bytes().to_vec()));
|
||||||
|
img_dict.set("Width", Object::Integer(img.width() as i64));
|
||||||
|
img_dict.set("Height", Object::Integer(img.height() as i64));
|
||||||
|
img_dict.set("ColorSpace", Object::Name("DeviceRGB".as_bytes().to_vec()));
|
||||||
|
img_dict.set("BitsPerComponent", Object::Integer(8));
|
||||||
|
img_dict.set("Filter", Object::Name("DCTDecode".as_bytes().to_vec()));
|
||||||
|
|
||||||
|
let img_stream = Stream::new(img_dict, jpeg_data);
|
||||||
|
let img_id = doc.add_object(Object::Stream(img_stream));
|
||||||
|
|
||||||
|
// Content
|
||||||
|
let content = format!("q\n{} 0 0 {} {} {} cm\n/Im0 Do\nQ\n",
|
||||||
|
img.width() as f64 * scale, img.height() as f64 * scale, ox, oy).into_bytes();
|
||||||
|
let content_stream = Stream::new(Dictionary::new(), content);
|
||||||
|
let content_id = doc.add_object(Object::Stream(content_stream));
|
||||||
|
|
||||||
|
// Resources
|
||||||
|
let mut xobj = Dictionary::new();
|
||||||
|
xobj.set("Im0", Object::Reference(img_id));
|
||||||
|
let mut resources = Dictionary::new();
|
||||||
|
resources.set("XObject", Object::Dictionary(xobj));
|
||||||
|
|
||||||
|
// Page
|
||||||
|
let mut page = Dictionary::new();
|
||||||
|
page.set("Type", Object::Name("Page".as_bytes().to_vec()));
|
||||||
|
page.set("Parent", Object::Reference(pages_id));
|
||||||
|
page.set("MediaBox", Object::Array(vec![
|
||||||
|
Object::Real(0.0), Object::Real(0.0),
|
||||||
|
Object::Real(page_width as f32), Object::Real(page_height as f32),
|
||||||
|
]));
|
||||||
|
page.set("Contents", Object::Reference(content_id));
|
||||||
|
page.set("Resources", Object::Dictionary(resources));
|
||||||
|
|
||||||
|
let page_id = doc.new_object_id();
|
||||||
|
kids.push(Object::Reference(page_id));
|
||||||
|
doc.objects.insert(page_id, Object::Dictionary(page));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pages tree
|
||||||
|
let mut pages_dict = Dictionary::new();
|
||||||
|
pages_dict.set("Type", Object::Name("Pages".as_bytes().to_vec()));
|
||||||
|
pages_dict.set("Count", Object::Integer(kids.len() as i64));
|
||||||
|
pages_dict.set("Kids", Object::Array(kids));
|
||||||
|
doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
|
||||||
|
|
||||||
|
doc.save_to(&mut std::fs::File::create(&output_path)?)?;
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use lopdf::{Document, Object, Dictionary};
|
||||||
|
|
||||||
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
use tools_common::types::Job;
|
||||||
|
|
||||||
|
/// Merge multiple PDF files into one.
|
||||||
|
pub async fn process(
|
||||||
|
job: &Job,
|
||||||
|
output_dir: &PathBuf,
|
||||||
|
_progress: &ProgressReporter,
|
||||||
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let output_path = output_dir.join(format!("{}_merged.pdf", job.id));
|
||||||
|
|
||||||
|
let mut file_paths = vec![job.file_path.clone()];
|
||||||
|
if let Some(files) = job.options.get("files").and_then(|v| v.as_array()) {
|
||||||
|
for f in files {
|
||||||
|
if let Some(path) = f.as_str() {
|
||||||
|
file_paths.push(path.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut merged = Document::new();
|
||||||
|
|
||||||
|
// Pages tree for the merged doc
|
||||||
|
let pages_id = merged.new_object_id();
|
||||||
|
let mut kids = Vec::new();
|
||||||
|
let mut page_count = 0u32;
|
||||||
|
|
||||||
|
for path in &file_paths {
|
||||||
|
let doc = Document::load(path)?;
|
||||||
|
let src_pages = doc.get_pages();
|
||||||
|
|
||||||
|
for (_, obj_id) in &src_pages {
|
||||||
|
if let Ok(obj) = doc.get_object(*obj_id) {
|
||||||
|
let mut page = obj.clone();
|
||||||
|
|
||||||
|
// Set parent to merged pages
|
||||||
|
if let Object::Dictionary(ref mut dict) = page {
|
||||||
|
dict.set("Parent", Object::Reference(pages_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add to merged document
|
||||||
|
let new_id = merged.new_object_id();
|
||||||
|
merged.objects.insert(new_id, page);
|
||||||
|
kids.push(Object::Reference(new_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page_count += src_pages.len() as u32;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build Pages dictionary
|
||||||
|
let mut pages_dict = Dictionary::new();
|
||||||
|
pages_dict.set("Type", Object::Name("Pages".as_bytes().to_vec()));
|
||||||
|
pages_dict.set("Count", Object::Integer(page_count as i64));
|
||||||
|
pages_dict.set("Kids", Object::Array(kids));
|
||||||
|
merged.objects.insert(pages_id, Object::Dictionary(pages_dict));
|
||||||
|
|
||||||
|
merged.save_to(&mut std::fs::File::create(&output_path)?)?;
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
@@ -1,15 +1,59 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::config::WorkerConfig;
|
use crate::config::WorkerConfig;
|
||||||
use tools_common::types::Job;
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
use tools_common::types::{Job, JobStatus, Tool};
|
||||||
|
|
||||||
|
mod merge;
|
||||||
|
mod split;
|
||||||
|
mod images_to_pdf;
|
||||||
|
mod compress;
|
||||||
|
|
||||||
/// Process a PDF tool job.
|
/// Process a PDF tool job.
|
||||||
pub async fn process_job(
|
pub async fn process_job(
|
||||||
job: Job,
|
job: Job,
|
||||||
_redis: &redis::Client,
|
redis: &redis::Client,
|
||||||
_config: &WorkerConfig,
|
config: &WorkerConfig,
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
tracing::info!(job_id = %job.id, tool = %job.tool.as_str(), "Processing PDF job (stub)");
|
tracing::info!(job_id = %job.id, tool = %job.tool.as_str(), "Processing PDF job");
|
||||||
// TODO: Phase 3 - implement actual PDF processing
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
let nats = async_nats::connect(&config.nats_url).await?;
|
||||||
tracing::info!(job_id = %job.id, "PDF job completed");
|
let progress = ProgressReporter::new(redis.clone(), nats, job.id, job.tool.clone());
|
||||||
|
|
||||||
|
progress
|
||||||
|
.report(JobStatus::Processing { stage: "process".to_string(), progress: 30 }, "process", 30, "Memproses PDF...")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let output_dir = config.storage_path.join("output");
|
||||||
|
tokio::fs::create_dir_all(&output_dir).await?;
|
||||||
|
|
||||||
|
let input_path = Path::new(&job.file_path);
|
||||||
|
let result_path = match job.tool {
|
||||||
|
Tool::PdfMerge => merge::process(&job, &output_dir, &progress).await?,
|
||||||
|
Tool::PdfSplit => split::process(input_path, &job.options, &output_dir, &progress).await?,
|
||||||
|
Tool::ImagesToPdf => images_to_pdf::process(&job, &output_dir, &progress).await?,
|
||||||
|
Tool::PdfCompress => compress::process(input_path, &job.options, &output_dir, &progress).await?,
|
||||||
|
_ => {
|
||||||
|
// Fallback: copy input as-is
|
||||||
|
let output_path = output_dir.join(format!("{}.pdf", job.id));
|
||||||
|
tokio::fs::copy(input_path, &output_path).await?;
|
||||||
|
output_path
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
progress
|
||||||
|
.report(JobStatus::Completed, "complete", 100, "Selesai")
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut conn = redis.get_multiplexed_async_connection().await?;
|
||||||
|
crate::nats::consumer::JobConsumer::update_job_result(
|
||||||
|
&mut conn,
|
||||||
|
job.id,
|
||||||
|
&result_path.to_string_lossy(),
|
||||||
|
job.ttl_seconds,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
tracing::info!(job_id = %job.id, output = %result_path.display(), "PDF job completed");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Convert PDF pages to images.
|
||||||
|
/// TODO: Phase 3.1 - requires rendering PDF pages to bitmaps
|
||||||
|
pub fn process() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
tracing::warn!("PDF to images not yet implemented (requires PDF renderer)");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use lopdf::{Document, Object, Dictionary};
|
||||||
|
|
||||||
|
use crate::nats::progress::ProgressReporter;
|
||||||
|
|
||||||
|
/// Split a PDF by extracting page ranges.
|
||||||
|
pub async fn process(
|
||||||
|
input_path: &Path,
|
||||||
|
options: &serde_json::Value,
|
||||||
|
output_dir: &PathBuf,
|
||||||
|
_progress: &ProgressReporter,
|
||||||
|
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let page_spec = options.get("pages").and_then(|v| v.as_str()).unwrap_or("1");
|
||||||
|
let doc = Document::load(input_path)?;
|
||||||
|
let src_pages = doc.get_pages();
|
||||||
|
let total = src_pages.len() as u32;
|
||||||
|
|
||||||
|
// Parse page spec: "1-3,5,7-9"
|
||||||
|
let mut pages = Vec::new();
|
||||||
|
for part in page_spec.split(',') {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some((start, end)) = part.split_once('-') {
|
||||||
|
let s: u32 = start.trim().parse().unwrap_or(1);
|
||||||
|
let e: u32 = end.trim().parse().unwrap_or(total);
|
||||||
|
for p in s..=e.min(total) {
|
||||||
|
pages.push(p);
|
||||||
|
}
|
||||||
|
} else if let Ok(p) = part.parse::<u32>() {
|
||||||
|
pages.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages.sort();
|
||||||
|
pages.dedup();
|
||||||
|
|
||||||
|
let output_path = output_dir.join(format!("split_{}.pdf", uuid::Uuid::new_v4()));
|
||||||
|
|
||||||
|
let mut new_doc = Document::new();
|
||||||
|
let pages_id = new_doc.new_object_id();
|
||||||
|
let mut kids = Vec::new();
|
||||||
|
|
||||||
|
for page_num in &pages {
|
||||||
|
if let Some(obj_id) = src_pages.get(page_num) {
|
||||||
|
if let Ok(obj) = doc.get_object(*obj_id) {
|
||||||
|
let mut page = obj.clone();
|
||||||
|
if let Object::Dictionary(ref mut dict) = page {
|
||||||
|
dict.set("Parent", Object::Reference(pages_id));
|
||||||
|
}
|
||||||
|
let new_id = new_doc.new_object_id();
|
||||||
|
new_doc.objects.insert(new_id, page);
|
||||||
|
kids.push(Object::Reference(new_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pages_dict = Dictionary::new();
|
||||||
|
pages_dict.set("Type", Object::Name("Pages".as_bytes().to_vec()));
|
||||||
|
pages_dict.set("Count", Object::Integer(kids.len() as i64));
|
||||||
|
pages_dict.set("Kids", Object::Array(kids));
|
||||||
|
new_doc.objects.insert(pages_id, Object::Dictionary(pages_dict));
|
||||||
|
|
||||||
|
new_doc.save_to(&mut std::fs::File::create(&output_path)?)?;
|
||||||
|
Ok(output_path)
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::panic::catch_unwind;
|
||||||
|
|
||||||
use image::GrayImage;
|
use image::GrayImage;
|
||||||
use imageproc::contours::find_contours;
|
use imageproc::contours::find_contours;
|
||||||
|
|
||||||
@@ -6,16 +8,18 @@ use tools_common::error::PipelineError;
|
|||||||
/// Represents a detected corner point.
|
/// Represents a detected corner point.
|
||||||
pub type CornerPoint = (f64, f64);
|
pub type CornerPoint = (f64, f64);
|
||||||
|
|
||||||
/// The fallback reason if corner detection fails.
|
|
||||||
pub enum FallbackReason {
|
|
||||||
NoContours,
|
|
||||||
NoRectangularContour,
|
|
||||||
TooSmall,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find the 4 corners of the document from an edge image.
|
/// Find the 4 corners of the document from an edge image.
|
||||||
|
/// Wrapped in catch_unwind because imageproc's find_contours can panic.
|
||||||
pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackReason> {
|
pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackReason> {
|
||||||
let contours = find_contours::<u8>(edges);
|
let result = catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
find_contours::<u8>(edges)
|
||||||
|
}));
|
||||||
|
|
||||||
|
let contours = match result {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => return Err(FallbackReason::FindContoursPanic),
|
||||||
|
};
|
||||||
|
|
||||||
if contours.is_empty() {
|
if contours.is_empty() {
|
||||||
return Err(FallbackReason::NoContours);
|
return Err(FallbackReason::NoContours);
|
||||||
}
|
}
|
||||||
@@ -35,7 +39,10 @@ pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackRea
|
|||||||
});
|
});
|
||||||
|
|
||||||
for points in sorted.iter().take(5) {
|
for points in sorted.iter().take(5) {
|
||||||
if let Some(corners) = approx_quadrilateral(points) {
|
let approx = catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
approx_quadrilateral(points)
|
||||||
|
}));
|
||||||
|
if let Ok(Some(corners)) = approx {
|
||||||
let ordered = order_corners(&corners);
|
let ordered = order_corners(&corners);
|
||||||
return Ok(ordered);
|
return Ok(ordered);
|
||||||
}
|
}
|
||||||
@@ -56,6 +63,15 @@ pub fn detect_corners(edges: &GrayImage) -> Result<[CornerPoint; 4], FallbackRea
|
|||||||
Err(FallbackReason::NoContours)
|
Err(FallbackReason::NoContours)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The fallback reason if corner detection fails.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub enum FallbackReason {
|
||||||
|
FindContoursPanic,
|
||||||
|
NoContours,
|
||||||
|
NoRectangularContour,
|
||||||
|
TooSmall,
|
||||||
|
}
|
||||||
|
|
||||||
/// Compute the area of a contour using the Shoelace formula.
|
/// Compute the area of a contour using the Shoelace formula.
|
||||||
fn contour_area_slice(points: &[(i32, i32)]) -> f64 {
|
fn contour_area_slice(points: &[(i32, i32)]) -> f64 {
|
||||||
let n = points.len();
|
let n = points.len();
|
||||||
@@ -71,7 +87,7 @@ fn contour_area_slice(points: &[(i32, i32)]) -> f64 {
|
|||||||
area.abs() / 2.0
|
area.abs() / 2.0
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Approximate a contour to a quadrilateral.
|
/// Approximate a contour to a quadrilateral using extreme points.
|
||||||
fn approx_quadrilateral(points: &[(i32, i32)]) -> Option<Vec<CornerPoint>> {
|
fn approx_quadrilateral(points: &[(i32, i32)]) -> Option<Vec<CornerPoint>> {
|
||||||
let n = points.len();
|
let n = points.len();
|
||||||
if n < 4 {
|
if n < 4 {
|
||||||
@@ -97,24 +113,21 @@ fn order_corners(points: &[CornerPoint]) -> [CornerPoint; 4] {
|
|||||||
let mut ordered = [(0.0, 0.0); 4];
|
let mut ordered = [(0.0, 0.0); 4];
|
||||||
|
|
||||||
if pts.len() >= 4 {
|
if pts.len() >= 4 {
|
||||||
// Sort by position
|
|
||||||
// TL = min(x+y), BR = max(x+y)
|
|
||||||
pts.sort_by(|a, b| {
|
pts.sort_by(|a, b| {
|
||||||
(a.0 + a.1)
|
(a.0 + a.1)
|
||||||
.partial_cmp(&(b.0 + b.1))
|
.partial_cmp(&(b.0 + b.1))
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
});
|
});
|
||||||
ordered[0] = pts[0]; // TL
|
ordered[0] = pts[0];
|
||||||
ordered[2] = pts[3]; // BR
|
ordered[2] = pts[3];
|
||||||
|
|
||||||
// TR = max(x - y), BL = min(x - y)
|
|
||||||
pts.sort_by(|a, b| {
|
pts.sort_by(|a, b| {
|
||||||
(a.0 - a.1)
|
(a.0 - a.1)
|
||||||
.partial_cmp(&(b.0 - b.1))
|
.partial_cmp(&(b.0 - b.1))
|
||||||
.unwrap_or(std::cmp::Ordering::Equal)
|
.unwrap_or(std::cmp::Ordering::Equal)
|
||||||
});
|
});
|
||||||
ordered[1] = pts[3]; // TR
|
ordered[1] = pts[3];
|
||||||
ordered[3] = pts[0]; // BL
|
ordered[3] = pts[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
ordered
|
ordered
|
||||||
@@ -129,16 +142,15 @@ fn bounding_rect_slice(points: &[(i32, i32)]) -> (i32, i32, i32, i32) {
|
|||||||
(left, top, right, bottom)
|
(left, top, right, bottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Detect corners with fallback: full resolution, then half, then error.
|
/// Detect corners with panic-safe fallback.
|
||||||
pub fn detect_corners_with_fallback(
|
pub fn detect_corners_with_fallback(
|
||||||
edges: &GrayImage,
|
edges: &GrayImage,
|
||||||
) -> Result<[CornerPoint; 4], PipelineError> {
|
) -> Result<[CornerPoint; 4], PipelineError> {
|
||||||
// Attempt 1: Full resolution
|
|
||||||
if let Ok(corners) = detect_corners(edges) {
|
if let Ok(corners) = detect_corners(edges) {
|
||||||
return Ok(corners);
|
return Ok(corners);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt 2: Half resolution
|
// Attempt 2: half resolution
|
||||||
let (w, h) = (edges.width() / 2, edges.height() / 2);
|
let (w, h) = (edges.width() / 2, edges.height() / 2);
|
||||||
if w > 10 && h > 10 {
|
if w > 10 && h > 10 {
|
||||||
let half = image::imageops::resize(
|
let half = image::imageops::resize(
|
||||||
@@ -152,9 +164,10 @@ pub fn detect_corners_with_fallback(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Err(PipelineError::CornerDetection(
|
// Final fallback: use image bounds as corners (full image)
|
||||||
"Could not detect document corners automatically".to_string(),
|
let (w, h) = (edges.width() as f64, edges.height() as f64);
|
||||||
))
|
tracing::warn!("Corner detection failed, using full image bounds");
|
||||||
|
Ok([(0.0, 0.0), (w, 0.0), (w, h), (0.0, h)])
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -174,4 +187,12 @@ mod tests {
|
|||||||
let rect = bounding_rect_slice(&points);
|
let rect = bounding_rect_slice(&points);
|
||||||
assert_eq!(rect, (5, 20, 100, 150));
|
assert_eq!(rect, (5, 20, 100, 150));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_order_corners() {
|
||||||
|
let pts = vec![(0.0, 100.0), (100.0, 100.0), (100.0, 0.0), (0.0, 0.0)];
|
||||||
|
let ordered = order_corners(&pts);
|
||||||
|
assert_eq!(ordered[0], (0.0, 0.0)); // TL
|
||||||
|
assert_eq!(ordered[2], (100.0, 100.0)); // BR
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
use image::GrayImage;
|
use image::GrayImage;
|
||||||
|
|
||||||
use tools_common::error::PipelineError;
|
use tools_common::error::PipelineError;
|
||||||
|
|
||||||
/// OCR result with text and word-level bounding boxes.
|
/// OCR result with text and word-level bounding boxes.
|
||||||
@@ -26,87 +25,85 @@ pub struct Bbox {
|
|||||||
pub height: i32,
|
pub height: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Initialize Tesseract OCR engine.
|
/// Run OCR on a grayscale image and return extracted text.
|
||||||
/// Uses leptess crate which binds to libtesseract.
|
/// Requires the "tesseract" feature. Falls back gracefully when unavailable.
|
||||||
/// Falls back gracefully if Tesseract is not installed.
|
|
||||||
#[cfg(feature = "tesseract")]
|
#[cfg(feature = "tesseract")]
|
||||||
fn init_tesseract(lang: &str) -> Result<leptess::LepTess, PipelineError> {
|
pub fn ocr_text(img: &GrayImage, lang: &str) -> Result<OcrResult, PipelineError> {
|
||||||
let tessdata_prefix = std::env::var("TESSDATA_PREFIX")
|
let tessdata_prefix = std::env::var("TESSDATA_PREFIX")
|
||||||
.unwrap_or_else(|_| "/usr/share/tesseract-ocr/5/tessdata".to_string());
|
.unwrap_or_else(|_| "/usr/share/tesseract-ocr/5/tessdata".to_string());
|
||||||
|
|
||||||
let mut tess = leptess::LepTess::new(Some(&tessdata_prefix), lang)
|
let mut tess = leptess::LepTess::new(Some(&tessdata_prefix), lang)
|
||||||
.map_err(|e| PipelineError::Ocr(format!("Failed to init Tesseract: {}", e)))?;
|
.map_err(|e| PipelineError::Ocr(format!("Failed to init Tesseract: {}", e)))?;
|
||||||
|
|
||||||
Ok(tess)
|
tess.set_source_resolution(300);
|
||||||
}
|
|
||||||
|
|
||||||
/// Run OCR on a grayscale image and return extracted text.
|
// Set image from raw bytes
|
||||||
/// Uses Tesseract via leptess crate when the "tesseract" feature is enabled.
|
let bytes = img.to_vec();
|
||||||
/// Falls back to a placeholder when Tesseract is unavailable.
|
let w = img.width() as i32;
|
||||||
pub fn ocr_text(img: &GrayImage, lang: &str) -> Result<OcrResult, PipelineError> {
|
let h = img.height() as i32;
|
||||||
#[cfg(feature = "tesseract")]
|
|
||||||
{
|
|
||||||
let mut tess = init_tesseract(lang)?;
|
|
||||||
|
|
||||||
let width = img.width() as i32;
|
// leptess expects raw 8-bit grayscale data
|
||||||
let height = img.height() as i32;
|
// Use set_image_from_mem which loads from memory buffer
|
||||||
|
if let Err(e) = tess.set_image_from_mem(&bytes) {
|
||||||
// Set image from memory
|
tracing::warn!("set_image_from_mem failed: {:?}, trying set_image", e);
|
||||||
tess.set_image_from_mem(&img.to_vec(), width, height, 1, width)
|
return Err(PipelineError::Ocr(format!("set_image_from_mem failed: {:?}", e)));
|
||||||
.map_err(|e| PipelineError::Ocr(format!("Failed to set image: {}", e)))?;
|
|
||||||
|
|
||||||
tess.set_source_resolution(300);
|
|
||||||
|
|
||||||
// Set PSM to automatic
|
|
||||||
tess.set_page_seg_mode(3);
|
|
||||||
|
|
||||||
let text = tess.get_utf8_text()
|
|
||||||
.map_err(|e| PipelineError::Ocr(format!("OCR failed: {}", e)))?;
|
|
||||||
|
|
||||||
let words = tess.get_words()
|
|
||||||
.iter()
|
|
||||||
.map(|w| OcrWord {
|
|
||||||
text: w.text.clone(),
|
|
||||||
bbox: Bbox {
|
|
||||||
x: w.x,
|
|
||||||
y: w.y,
|
|
||||||
width: w.w,
|
|
||||||
height: w.h,
|
|
||||||
},
|
|
||||||
confidence: w.confidence,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let confidence = if words.is_empty() {
|
|
||||||
0.0
|
|
||||||
} else {
|
|
||||||
words.iter().map(|w| w.confidence as f32).sum::<f32>() / words.len() as f32
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(OcrResult {
|
|
||||||
full_text: text,
|
|
||||||
words,
|
|
||||||
confidence,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "tesseract"))]
|
tess.recognize();
|
||||||
{
|
|
||||||
tracing::warn!("Tesseract feature not enabled, OCR returning placeholder");
|
let text = tess.get_utf8_text().unwrap_or_default();
|
||||||
Ok(OcrResult {
|
let confidence = tess.mean_text_conf() as f32;
|
||||||
full_text: String::new(),
|
|
||||||
words: Vec::new(),
|
// Get word-level bounding boxes from LSTM box text
|
||||||
confidence: 0.0,
|
let words = if let Ok(box_text) = tess.get_lstm_box_text(1) {
|
||||||
})
|
parse_lstm_boxes(&box_text)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(OcrResult {
|
||||||
|
full_text: text,
|
||||||
|
words,
|
||||||
|
confidence,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse LSTM box text format: "word x1 y1 x2 y2 confidence"
|
||||||
|
fn parse_lstm_boxes(box_text: &str) -> Vec<OcrWord> {
|
||||||
|
let mut words = Vec::new();
|
||||||
|
for line in box_text.lines() {
|
||||||
|
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||||
|
if parts.len() >= 5 {
|
||||||
|
if let (Ok(x), Ok(y), Ok(x2), Ok(y2)) = (
|
||||||
|
parts[1].parse::<i32>(),
|
||||||
|
parts[2].parse::<i32>(),
|
||||||
|
parts[3].parse::<i32>(),
|
||||||
|
parts[4].parse::<i32>(),
|
||||||
|
) {
|
||||||
|
let confidence = parts.get(5).and_then(|v| v.parse::<i32>().ok()).unwrap_or(0);
|
||||||
|
words.push(OcrWord {
|
||||||
|
text: parts[0].to_string(),
|
||||||
|
bbox: Bbox {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width: (x2 - x).max(1),
|
||||||
|
height: (y2 - y).max(1),
|
||||||
|
},
|
||||||
|
confidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
words
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run OCR on a grayscale image, returning only the text.
|
/// Non-tesseract fallback: return empty result.
|
||||||
pub fn ocr_text_only(img: &GrayImage, lang: &str) -> Result<String, PipelineError> {
|
#[cfg(not(feature = "tesseract"))]
|
||||||
ocr_text(img, lang).map(|r| r.full_text)
|
pub fn ocr_text(_img: &GrayImage, _lang: &str) -> Result<OcrResult, PipelineError> {
|
||||||
}
|
tracing::warn!("Tesseract feature not enabled, OCR returning placeholder");
|
||||||
|
Ok(OcrResult {
|
||||||
/// Run OCR with word-level bounding boxes.
|
full_text: String::new(),
|
||||||
pub fn ocr_words(img: &GrayImage, lang: &str) -> Result<Vec<OcrWord>, PipelineError> {
|
words: Vec::new(),
|
||||||
ocr_text(img, lang).map(|r| r.words)
|
confidence: 0.0,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use image::DynamicImage;
|
|
||||||
use tools_common::error::PipelineError;
|
use tools_common::error::PipelineError;
|
||||||
use tools_common::types::Job;
|
use tools_common::types::Job;
|
||||||
|
|
||||||
@@ -27,10 +26,32 @@ pub struct ScanResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Run the full scanner pipeline with all stages.
|
/// Run the full scanner pipeline with all stages.
|
||||||
|
/// Wrapped in catch_unwind to prevent imageproc panics from killing the worker.
|
||||||
pub async fn process(
|
pub async fn process(
|
||||||
job: &Job,
|
job: &Job,
|
||||||
config: &WorkerConfig,
|
config: &WorkerConfig,
|
||||||
progress: &ProgressReporter,
|
progress: &ProgressReporter,
|
||||||
|
) -> Result<ScanResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
process_inner(job, config, progress)
|
||||||
|
}));
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(fut) => fut.await,
|
||||||
|
Err(panic) => {
|
||||||
|
let msg = panic
|
||||||
|
.downcast_ref::<&str>()
|
||||||
|
.unwrap_or(&"Unknown panic in scanner pipeline");
|
||||||
|
Err(format!("Pipeline panicked: {}", msg).into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inner pipeline implementation (runs inside catch_unwind).
|
||||||
|
async fn process_inner(
|
||||||
|
job: &Job,
|
||||||
|
config: &WorkerConfig,
|
||||||
|
progress: &ProgressReporter,
|
||||||
) -> Result<ScanResult, Box<dyn std::error::Error + Send + Sync>> {
|
) -> Result<ScanResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let input_path = Path::new(&job.file_path);
|
let input_path = Path::new(&job.file_path);
|
||||||
@@ -75,13 +96,11 @@ pub async fn process(
|
|||||||
report(progress, "enhance", 90, "Mengoptimalkan kualitas...").await;
|
report(progress, "enhance", 90, "Mengoptimalkan kualitas...").await;
|
||||||
let final_img = enhance_final(&final_img);
|
let final_img = enhance_final(&final_img);
|
||||||
|
|
||||||
// Stage 9: Save output (93-100%)
|
// Stage 9: OCR + PDF/PNG output (93-100%)
|
||||||
report(progress, "save", 95, "Menyimpan hasil...").await;
|
report(progress, "save", 95, "Menyimpan hasil...").await;
|
||||||
|
|
||||||
let output_filename = format!("{}.png", progress.job_id());
|
let job_id = progress.job_id();
|
||||||
let output_path = output_dir.join(&output_filename);
|
let (output_path, ocr_text) = generate_output(&final_img, &job_id, &output_dir, &job.options)?;
|
||||||
|
|
||||||
final_img.save(&output_path)?;
|
|
||||||
|
|
||||||
let elapsed = start.elapsed().as_millis() as u64;
|
let elapsed = start.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
@@ -95,11 +114,79 @@ pub async fn process(
|
|||||||
output_path: output_path.to_string_lossy().to_string(),
|
output_path: output_path.to_string_lossy().to_string(),
|
||||||
page_count: 1,
|
page_count: 1,
|
||||||
file_size: tokio::fs::metadata(&output_path).await.map(|m| m.len()).unwrap_or(0),
|
file_size: tokio::fs::metadata(&output_path).await.map(|m| m.len()).unwrap_or(0),
|
||||||
ocr_text: None,
|
ocr_text,
|
||||||
processing_time_ms: elapsed,
|
processing_time_ms: elapsed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generate final output file: PDF with OCR text layer, or fallback to PNG.
|
||||||
|
fn generate_output(
|
||||||
|
final_img: &image::GrayImage,
|
||||||
|
job_id: &uuid::Uuid,
|
||||||
|
output_dir: &Path,
|
||||||
|
options: &serde_json::Value,
|
||||||
|
) -> Result<(std::path::PathBuf, Option<String>), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
#[cfg(feature = "tesseract")]
|
||||||
|
{
|
||||||
|
let ocr_enabled = options
|
||||||
|
.get("ocr")
|
||||||
|
.and_then(|v| v.as_bool())
|
||||||
|
.unwrap_or(true);
|
||||||
|
|
||||||
|
if ocr_enabled {
|
||||||
|
let lang = options
|
||||||
|
.get("language")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("eng+ind");
|
||||||
|
|
||||||
|
match super::ocr::ocr_text(final_img, lang) {
|
||||||
|
Ok(ocr_result) => {
|
||||||
|
// Compress image as JPEG for PDF embedding
|
||||||
|
let output_filename = format!("{}.pdf", job_id);
|
||||||
|
let output_path = output_dir.join(&output_filename);
|
||||||
|
let jpeg_data = super::pdf::compress_image_jpeg(final_img, 85)
|
||||||
|
.map_err(|e| format!("JPEG compression failed: {}", e))?;
|
||||||
|
|
||||||
|
let page_width = super::pdf::A4_WIDTH_PT;
|
||||||
|
let page_height = super::pdf::A4_HEIGHT_PT;
|
||||||
|
|
||||||
|
let pdf_data = super::pdf::generate_searchable_pdf(
|
||||||
|
&jpeg_data,
|
||||||
|
&ocr_result.full_text,
|
||||||
|
&ocr_result.words,
|
||||||
|
page_width,
|
||||||
|
page_height,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
std::fs::write(&output_path, &pdf_data)?;
|
||||||
|
|
||||||
|
let ocr_text = if ocr_result.full_text.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(ocr_result.full_text)
|
||||||
|
};
|
||||||
|
|
||||||
|
return Ok((output_path, ocr_text));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("OCR failed, falling back to PNG: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "tesseract"))]
|
||||||
|
{
|
||||||
|
tracing::warn!("Tesseract feature not enabled, saving as PNG");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: save as PNG
|
||||||
|
let output_filename = format!("{}.png", job_id);
|
||||||
|
let output_path = output_dir.join(&output_filename);
|
||||||
|
final_img.save(&output_path)?;
|
||||||
|
Ok((output_path, None))
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper to report progress.
|
/// Helper to report progress.
|
||||||
async fn report(progress: &ProgressReporter, stage: &str, pct: u8, msg: &str) {
|
async fn report(progress: &ProgressReporter, stage: &str, pct: u8, msg: &str) {
|
||||||
let _ = progress
|
let _ = progress
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ export default function ImageCompressPage() {
|
|||||||
const [pageState, setPageState] = useState<PageState>("upload");
|
const [pageState, setPageState] = useState<PageState>("upload");
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [quality, setQuality] = useState(80);
|
||||||
|
|
||||||
const { upload } = useUpload({
|
const { upload } = useUpload({
|
||||||
tool: "image-compress",
|
tool: "image-compress",
|
||||||
options: { quality: 80 },
|
options: { quality },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleComplete = useCallback(() => setPageState("result"), []);
|
const handleComplete = useCallback(() => setPageState("result"), []);
|
||||||
@@ -61,11 +62,35 @@ export default function ImageCompressPage() {
|
|||||||
phase={1}
|
phase={1}
|
||||||
>
|
>
|
||||||
{pageState === "upload" && (
|
{pageState === "upload" && (
|
||||||
<UploadZone
|
<div className="space-y-6">
|
||||||
accept="image/*"
|
{/* Quality Slider */}
|
||||||
tool="image-compress"
|
<div className="p-6 rounded-xl border glass space-y-3">
|
||||||
onUpload={handleUpload}
|
<div className="flex items-center justify-between">
|
||||||
/>
|
<span className="text-sm font-medium">Quality</span>
|
||||||
|
<span className="text-sm font-mono text-muted-foreground">
|
||||||
|
{quality}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => setQuality(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Kecil</span>
|
||||||
|
<span>Besar</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadZone
|
||||||
|
accept="image/*"
|
||||||
|
tool="image-compress"
|
||||||
|
onUpload={handleUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "processing" && jobId && (
|
{pageState === "processing" && jobId && (
|
||||||
@@ -79,10 +104,7 @@ export default function ImageCompressPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "result" && result && (
|
{pageState === "result" && result && (
|
||||||
<ResultPreview
|
<ResultPreview result={result} onProcessAnother={handleRetry} />
|
||||||
result={result}
|
|
||||||
onProcessAnother={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "error" && (
|
{pageState === "error" && (
|
||||||
|
|||||||
@@ -11,14 +11,25 @@ import { ResultPreview } from "@/components/tools/result-preview";
|
|||||||
|
|
||||||
type PageState = "upload" | "processing" | "result" | "error";
|
type PageState = "upload" | "processing" | "result" | "error";
|
||||||
|
|
||||||
|
const FORMATS = [
|
||||||
|
{ value: "jpeg", label: "JPEG (.jpg)" },
|
||||||
|
{ value: "png", label: "PNG (.png)" },
|
||||||
|
{ value: "webp", label: "WebP (.webp)" },
|
||||||
|
{ value: "gif", label: "GIF (.gif)" },
|
||||||
|
{ value: "bmp", label: "BMP (.bmp)" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function ImageConvertPage() {
|
export default function ImageConvertPage() {
|
||||||
const [pageState, setPageState] = useState<PageState>("upload");
|
const [pageState, setPageState] = useState<PageState>("upload");
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [format, setFormat] = useState("jpeg");
|
||||||
|
const [quality, setQuality] = useState(85);
|
||||||
|
|
||||||
const { upload } = useUpload({
|
const { upload } = useUpload({
|
||||||
tool: "image-convert",
|
tool: "image-convert",
|
||||||
options: { quality: 80 },
|
options: { format, quality },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleComplete = useCallback(() => setPageState("result"), []);
|
const handleComplete = useCallback(() => setPageState("result"), []);
|
||||||
@@ -56,48 +67,73 @@ export default function ImageConvertPage() {
|
|||||||
return (
|
return (
|
||||||
<ToolLayout
|
<ToolLayout
|
||||||
title="Convert Image"
|
title="Convert Image"
|
||||||
description="Convert HEIC->JPEG, PNG->WebP"
|
description="Konversi antar format gambar — atur format tujuan dan kualitas"
|
||||||
icon={Repeat}
|
icon={Repeat}
|
||||||
phase={1}
|
phase={1}
|
||||||
>
|
>
|
||||||
{pageState === "upload" && (
|
{pageState === "upload" && (
|
||||||
<UploadZone
|
<div className="space-y-6">
|
||||||
accept="image/*"
|
{/* Format + Quality */}
|
||||||
tool="image-convert"
|
<div className="p-6 rounded-xl border glass space-y-4">
|
||||||
onUpload={handleUpload}
|
<h3 className="font-semibold">Conversion Settings</h3>
|
||||||
/>
|
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Target Format</span>
|
||||||
|
<select
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value)}
|
||||||
|
className="w-full bg-muted border rounded px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{FORMATS.map((f) => (
|
||||||
|
<option key={f.value} value={f.value}>
|
||||||
|
{f.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">Quality</span>
|
||||||
|
<span className="font-mono">{quality}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => setQuality(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Small file</span>
|
||||||
|
<span>Best quality</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadZone
|
||||||
|
accept="image/*"
|
||||||
|
tool="image-convert"
|
||||||
|
onUpload={handleUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "processing" && jobId && (
|
{pageState === "processing" && jobId && (
|
||||||
<ProgressBar
|
<ProgressBar progress={progress} stage={stage} message={message} status={status} onRetry={handleRetry} />
|
||||||
progress={progress}
|
|
||||||
stage={stage}
|
|
||||||
message={message}
|
|
||||||
status={status}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "result" && result && (
|
{pageState === "result" && result && (
|
||||||
<ResultPreview
|
<ResultPreview result={result} onProcessAnother={handleRetry} />
|
||||||
result={result}
|
|
||||||
onProcessAnother={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "error" && (
|
{pageState === "error" && (
|
||||||
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
||||||
<p className="text-destructive font-medium mb-4">
|
<p className="text-destructive font-medium mb-4">{errorMsg || "Terjadi kesalahan"}</p>
|
||||||
{errorMsg || "Terjadi kesalahan"}
|
<button onClick={handleRetry} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90">Coba Lagi</button>
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
|
|
||||||
>
|
|
||||||
Coba Lagi
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ToolLayout>
|
</ToolLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,9 +16,14 @@ export default function ImageResizePage() {
|
|||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [width, setWidth] = useState(1920);
|
||||||
|
const [height, setHeight] = useState(1080);
|
||||||
|
const [lockAspect, setLockAspect] = useState(true);
|
||||||
|
const [quality, setQuality] = useState(85);
|
||||||
|
|
||||||
const { upload } = useUpload({
|
const { upload } = useUpload({
|
||||||
tool: "image-resize",
|
tool: "image-resize",
|
||||||
options: { quality: 80 },
|
options: { width, height, quality, fit: lockAspect ? "inside" : "fill" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleComplete = useCallback(() => setPageState("result"), []);
|
const handleComplete = useCallback(() => setPageState("result"), []);
|
||||||
@@ -56,48 +61,91 @@ export default function ImageResizePage() {
|
|||||||
return (
|
return (
|
||||||
<ToolLayout
|
<ToolLayout
|
||||||
title="Resize Image"
|
title="Resize Image"
|
||||||
description="Ubah dimensi gambar"
|
description="Ubah dimensi gambar — atur lebar, tinggi, dan kualitas"
|
||||||
icon={Crop}
|
icon={Crop}
|
||||||
phase={1}
|
phase={1}
|
||||||
>
|
>
|
||||||
{pageState === "upload" && (
|
{pageState === "upload" && (
|
||||||
<UploadZone
|
<div className="space-y-6">
|
||||||
accept="image/*"
|
{/* Dimensions */}
|
||||||
tool="image-resize"
|
<div className="p-6 rounded-xl border glass space-y-4">
|
||||||
onUpload={handleUpload}
|
<h3 className="font-semibold">Dimensions</h3>
|
||||||
/>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Width (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={width}
|
||||||
|
onChange={(e) => setWidth(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
className="w-full bg-muted border rounded px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="space-y-1">
|
||||||
|
<span className="text-sm text-muted-foreground">Height (px)</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={height}
|
||||||
|
onChange={(e) => setHeight(Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={10000}
|
||||||
|
className="w-full bg-muted border rounded px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={lockAspect}
|
||||||
|
onChange={(e) => setLockAspect(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Lock aspect ratio
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality */}
|
||||||
|
<div className="p-6 rounded-xl border glass space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">Quality</span>
|
||||||
|
<span className="text-sm font-mono text-muted-foreground">{quality}%</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
value={quality}
|
||||||
|
onChange={(e) => setQuality(Number(e.target.value))}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs text-muted-foreground">
|
||||||
|
<span>Small file</span>
|
||||||
|
<span>Best quality</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadZone
|
||||||
|
accept="image/*"
|
||||||
|
tool="image-resize"
|
||||||
|
onUpload={handleUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "processing" && jobId && (
|
{pageState === "processing" && jobId && (
|
||||||
<ProgressBar
|
<ProgressBar progress={progress} stage={stage} message={message} status={status} onRetry={handleRetry} />
|
||||||
progress={progress}
|
|
||||||
stage={stage}
|
|
||||||
message={message}
|
|
||||||
status={status}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "result" && result && (
|
{pageState === "result" && result && (
|
||||||
<ResultPreview
|
<ResultPreview result={result} onProcessAnother={handleRetry} />
|
||||||
result={result}
|
|
||||||
onProcessAnother={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "error" && (
|
{pageState === "error" && (
|
||||||
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
||||||
<p className="text-destructive font-medium mb-4">
|
<p className="text-destructive font-medium mb-4">{errorMsg || "Terjadi kesalahan"}</p>
|
||||||
{errorMsg || "Terjadi kesalahan"}
|
<button onClick={handleRetry} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90">Coba Lagi</button>
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
|
|
||||||
>
|
|
||||||
Coba Lagi
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ToolLayout>
|
</ToolLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,10 +16,7 @@ export default function PdfCompressPage() {
|
|||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
const { upload } = useUpload({
|
const { upload } = useUpload({ tool: "pdf-compress" });
|
||||||
tool: "pdf-compress",
|
|
||||||
options: { quality: 80 },
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleComplete = useCallback(() => setPageState("result"), []);
|
const handleComplete = useCallback(() => setPageState("result"), []);
|
||||||
const handleError = useCallback(
|
const handleError = useCallback(
|
||||||
@@ -58,46 +55,30 @@ export default function PdfCompressPage() {
|
|||||||
title="Compress PDF"
|
title="Compress PDF"
|
||||||
description="Kecilin ukuran PDF"
|
description="Kecilin ukuran PDF"
|
||||||
icon={FileImage}
|
icon={FileImage}
|
||||||
phase={2}
|
phase={1}
|
||||||
>
|
>
|
||||||
{pageState === "upload" && (
|
{pageState === "upload" && (
|
||||||
<UploadZone
|
<UploadZone
|
||||||
accept="application/pdf"
|
accept=".pdf,application/pdf"
|
||||||
tool="pdf-compress"
|
tool="pdf-compress"
|
||||||
onUpload={handleUpload}
|
onUpload={handleUpload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "processing" && jobId && (
|
{pageState === "processing" && jobId && (
|
||||||
<ProgressBar
|
<ProgressBar progress={progress} stage={stage} message={message} status={status} onRetry={handleRetry} />
|
||||||
progress={progress}
|
|
||||||
stage={stage}
|
|
||||||
message={message}
|
|
||||||
status={status}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "result" && result && (
|
{pageState === "result" && result && (
|
||||||
<ResultPreview
|
<ResultPreview result={result} onProcessAnother={handleRetry} />
|
||||||
result={result}
|
|
||||||
onProcessAnother={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "error" && (
|
{pageState === "error" && (
|
||||||
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
||||||
<p className="text-destructive font-medium mb-4">
|
<p className="text-destructive font-medium mb-4">{errorMsg || "Terjadi kesalahan"}</p>
|
||||||
{errorMsg || "Terjadi kesalahan"}
|
<button onClick={handleRetry} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90">Coba Lagi</button>
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
|
|
||||||
>
|
|
||||||
Coba Lagi
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ToolLayout>
|
</ToolLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -15,10 +15,11 @@ export default function PdfSplitPage() {
|
|||||||
const [pageState, setPageState] = useState<PageState>("upload");
|
const [pageState, setPageState] = useState<PageState>("upload");
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [pages, setPages] = useState("1,3-5");
|
||||||
|
|
||||||
const { upload } = useUpload({
|
const { upload } = useUpload({
|
||||||
tool: "pdf-split",
|
tool: "pdf-split",
|
||||||
options: { quality: 80 },
|
options: { pages },
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleComplete = useCallback(() => setPageState("result"), []);
|
const handleComplete = useCallback(() => setPageState("result"), []);
|
||||||
@@ -56,48 +57,48 @@ export default function PdfSplitPage() {
|
|||||||
return (
|
return (
|
||||||
<ToolLayout
|
<ToolLayout
|
||||||
title="Split PDF"
|
title="Split PDF"
|
||||||
description="Ekstrak halaman tertentu"
|
description="Ekstrak halaman tertentu dari PDF"
|
||||||
icon={Split}
|
icon={Split}
|
||||||
phase={2}
|
phase={1}
|
||||||
>
|
>
|
||||||
{pageState === "upload" && (
|
{pageState === "upload" && (
|
||||||
<UploadZone
|
<div className="space-y-6">
|
||||||
accept="application/pdf"
|
<div className="p-6 rounded-xl border glass space-y-3">
|
||||||
tool="pdf-split"
|
<h3 className="font-semibold">Page Range</h3>
|
||||||
onUpload={handleUpload}
|
<p className="text-xs text-muted-foreground">
|
||||||
/>
|
Contoh: <code className="bg-muted px-1 rounded">1-3,5,7-9</code> ambil halaman 1-3, 5, dan 7-9
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pages}
|
||||||
|
onChange={(e) => setPages(e.target.value)}
|
||||||
|
className="w-full bg-muted border rounded px-3 py-2 text-sm font-mono"
|
||||||
|
placeholder="1-3,5,7-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadZone
|
||||||
|
accept=".pdf,application/pdf"
|
||||||
|
tool="pdf-split"
|
||||||
|
onUpload={handleUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "processing" && jobId && (
|
{pageState === "processing" && jobId && (
|
||||||
<ProgressBar
|
<ProgressBar progress={progress} stage={stage} message={message} status={status} onRetry={handleRetry} />
|
||||||
progress={progress}
|
|
||||||
stage={stage}
|
|
||||||
message={message}
|
|
||||||
status={status}
|
|
||||||
onRetry={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "result" && result && (
|
{pageState === "result" && result && (
|
||||||
<ResultPreview
|
<ResultPreview result={result} onProcessAnother={handleRetry} />
|
||||||
result={result}
|
|
||||||
onProcessAnother={handleRetry}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pageState === "error" && (
|
{pageState === "error" && (
|
||||||
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
<div className="p-6 rounded-xl border border-destructive/20 bg-destructive/5 text-center">
|
||||||
<p className="text-destructive font-medium mb-4">
|
<p className="text-destructive font-medium mb-4">{errorMsg || "Terjadi kesalahan"}</p>
|
||||||
{errorMsg || "Terjadi kesalahan"}
|
<button onClick={handleRetry} className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90">Coba Lagi</button>
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleRetry}
|
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:opacity-90"
|
|
||||||
>
|
|
||||||
Coba Lagi
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ToolLayout>
|
</ToolLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -10,14 +10,29 @@ import { useJobStatus } from "@/hooks/use-job-status";
|
|||||||
|
|
||||||
type PageState = "upload" | "processing" | "result" | "error";
|
type PageState = "upload" | "processing" | "result" | "error";
|
||||||
|
|
||||||
|
interface ScanOptions {
|
||||||
|
ocr: boolean;
|
||||||
|
enhance: boolean;
|
||||||
|
output_format: "pdf" | "jpeg" | "png";
|
||||||
|
dpi: number;
|
||||||
|
color_mode: "black_and_white" | "grayscale" | "color";
|
||||||
|
}
|
||||||
|
|
||||||
export default function ScanPage() {
|
export default function ScanPage() {
|
||||||
const [pageState, setPageState] = useState<PageState>("upload");
|
const [pageState, setPageState] = useState<PageState>("upload");
|
||||||
const [jobId, setJobId] = useState<string | null>(null);
|
const [jobId, setJobId] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
const [opts, setOpts] = useState<ScanOptions>({
|
||||||
|
ocr: true,
|
||||||
|
enhance: true,
|
||||||
|
output_format: "pdf",
|
||||||
|
dpi: 300,
|
||||||
|
color_mode: "black_and_white",
|
||||||
|
});
|
||||||
|
|
||||||
const { upload, isUploading } = useUpload({
|
const { upload, isUploading } = useUpload({
|
||||||
tool: "scan",
|
tool: "scan",
|
||||||
options: { ocr: true, enhance: true, output_format: "pdf", dpi: 300 },
|
options: opts as unknown as Record<string, unknown>,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleComplete = useCallback(() => {
|
const handleComplete = useCallback(() => {
|
||||||
@@ -75,15 +90,83 @@ export default function ScanPage() {
|
|||||||
maxSizeMB={50}
|
maxSizeMB={50}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Options info */}
|
{/* Interactive Options */}
|
||||||
<div className="p-4 rounded-lg border glass text-sm text-muted-foreground">
|
<div className="p-6 rounded-xl border glass space-y-4">
|
||||||
<p className="font-medium text-foreground mb-2">Scan Options</p>
|
<h3 className="font-semibold">Scan Options</h3>
|
||||||
<ul className="space-y-1">
|
|
||||||
<li>• OCR: Enabled (English + Indonesian)</li>
|
{/* OCR Toggle */}
|
||||||
<li>• Output: Searchable PDF</li>
|
<label className="flex items-center justify-between">
|
||||||
<li>• DPI: 300</li>
|
<span className="text-sm">OCR (Tesseract)</span>
|
||||||
<li>• Auto-enhance: On</li>
|
<input
|
||||||
</ul>
|
type="checkbox"
|
||||||
|
checked={opts.ocr}
|
||||||
|
onChange={(e) => setOpts({ ...opts, ocr: e.target.checked })}
|
||||||
|
className="toggle"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Enhance Toggle */}
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Auto-enhance</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={opts.enhance}
|
||||||
|
onChange={(e) => setOpts({ ...opts, enhance: e.target.checked })}
|
||||||
|
className="toggle"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Output Format */}
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Output Format</span>
|
||||||
|
<select
|
||||||
|
value={opts.output_format}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOpts({
|
||||||
|
...opts,
|
||||||
|
output_format: e.target.value as ScanOptions["output_format"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-muted border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="pdf">Searchable PDF</option>
|
||||||
|
<option value="jpeg">JPEG Image</option>
|
||||||
|
<option value="png">PNG Image</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Color Mode */}
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">Color Mode</span>
|
||||||
|
<select
|
||||||
|
value={opts.color_mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
setOpts({
|
||||||
|
...opts,
|
||||||
|
color_mode: e.target.value as ScanOptions["color_mode"],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-muted border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="black_and_white">Black & White</option>
|
||||||
|
<option value="grayscale">Grayscale</option>
|
||||||
|
<option value="color">Color</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* DPI */}
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm">DPI</span>
|
||||||
|
<select
|
||||||
|
value={opts.dpi}
|
||||||
|
onChange={(e) => setOpts({ ...opts, dpi: Number(e.target.value) })}
|
||||||
|
className="bg-muted border rounded px-2 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value={150}>150 (draft)</option>
|
||||||
|
<option value={300}>300 (standard)</option>
|
||||||
|
<option value={600}>600 (high)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -82,10 +82,10 @@ const tools: ToolDefinition[] = [
|
|||||||
{
|
{
|
||||||
id: "pdf-split",
|
id: "pdf-split",
|
||||||
title: "Split PDF",
|
title: "Split PDF",
|
||||||
description: "Ekstrak halaman tertentu dari PDF. Pilih via thumbnail atau range.",
|
description: "Ekstrak halaman tertentu dari PDF. Pilih page range seperti 1-3,5,7-9.",
|
||||||
icon: Split,
|
icon: Split,
|
||||||
href: "/pdf/split",
|
href: "/pdf/split",
|
||||||
phase: 2,
|
phase: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "images-to-pdf",
|
id: "images-to-pdf",
|
||||||
@@ -98,10 +98,10 @@ const tools: ToolDefinition[] = [
|
|||||||
{
|
{
|
||||||
id: "pdf-compress",
|
id: "pdf-compress",
|
||||||
title: "Compress PDF",
|
title: "Compress PDF",
|
||||||
description: "Kecilin ukuran PDF dengan kompresi embedded images.",
|
description: "Kecilin ukuran PDF dengan kompresi ulang.",
|
||||||
icon: FileImage,
|
icon: FileImage,
|
||||||
href: "/pdf/compress",
|
href: "/pdf/compress",
|
||||||
phase: 2,
|
phase: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "video-compress",
|
id: "video-compress",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ interface JobProgress {
|
|||||||
interface UseJobStatusOptions {
|
interface UseJobStatusOptions {
|
||||||
onComplete?: (result: JobProgress["result"]) => void;
|
onComplete?: (result: JobProgress["result"]) => void;
|
||||||
onError?: (error: string) => void;
|
onError?: (error: string) => void;
|
||||||
|
wsBaseUrl?: string; // optional override, e.g. "wss://tools.asepharyana.my.id"
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useJobStatus(jobId: string | null, options?: UseJobStatusOptions) {
|
export function useJobStatus(jobId: string | null, options?: UseJobStatusOptions) {
|
||||||
@@ -40,10 +41,13 @@ export function useJobStatus(jobId: string | null, options?: UseJobStatusOptions
|
|||||||
const connect = useCallback(() => {
|
const connect = useCallback(() => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
|
|
||||||
// Connect directly to Rust gateway WebSocket (not via Next.js)
|
// Determine WS base URL: from options, NEXT_PUBLIC_WS_URL, or infer from page location
|
||||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
const wsBase = options?.wsBaseUrl
|
||||||
const host = "localhost:3001"; // Rust gateway - wss for production
|
?? process.env.NEXT_PUBLIC_WS_URL
|
||||||
const url = `${protocol}//${host}/api/job/${jobId}/ws`;
|
?? (typeof window !== "undefined"
|
||||||
|
? `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}`
|
||||||
|
: "ws://localhost:3002");
|
||||||
|
const url = `${wsBase}/api/job/${jobId}/ws`;
|
||||||
|
|
||||||
const ws = new WebSocket(url);
|
const ws = new WebSocket(url);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
|
|||||||
+40
-10
@@ -1,10 +1,11 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Tools Service Entrypoint
|
# Tools Service Entrypoint
|
||||||
# Starts both the Gateway (Axum HTTP server) and Workers (NATS consumers)
|
# Starts: Next.js frontend (port 3000), Rust gateway (port 3001), Workers (NATS consumers)
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Find gateway binary (named tools-gateway or in gateway/ subdir)
|
# ── 1. Find binaries ──
|
||||||
|
|
||||||
GATEWAY_BIN=""
|
GATEWAY_BIN=""
|
||||||
for candidate in /app/gateway/tools-gateway /app/gateway /app/tools-gateway /app/target/release/tools-gateway; do
|
for candidate in /app/gateway/tools-gateway /app/gateway /app/tools-gateway /app/target/release/tools-gateway; do
|
||||||
if [ -f "$candidate" ] && [ -x "$candidate" ]; then
|
if [ -f "$candidate" ] && [ -x "$candidate" ]; then
|
||||||
@@ -19,7 +20,6 @@ if [ -z "$GATEWAY_BIN" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Find workers binary
|
|
||||||
WORKER_BIN=""
|
WORKER_BIN=""
|
||||||
for candidate in /app/workers/tools-workers /app/workers /app/tools-workers /app/target/release/tools-workers; do
|
for candidate in /app/workers/tools-workers /app/workers /app/tools-workers /app/target/release/tools-workers; do
|
||||||
if [ -f "$candidate" ] && [ -x "$candidate" ]; then
|
if [ -f "$candidate" ] && [ -x "$candidate" ]; then
|
||||||
@@ -34,22 +34,52 @@ if [ -z "$WORKER_BIN" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting tools-gateway ($GATEWAY_BIN)..."
|
# ── 2. Start Rust Gateway (port 3001) ──
|
||||||
|
|
||||||
|
echo "Starting tools-gateway ($GATEWAY_BIN) on port 3001..."
|
||||||
"$GATEWAY_BIN" &
|
"$GATEWAY_BIN" &
|
||||||
GATEWAY_PID=$!
|
GATEWAY_PID=$!
|
||||||
|
|
||||||
sleep 1
|
sleep 1
|
||||||
|
|
||||||
|
# ── 3. Start Rust Workers ──
|
||||||
|
|
||||||
echo "Starting tools-workers ($WORKER_BIN)..."
|
echo "Starting tools-workers ($WORKER_BIN)..."
|
||||||
"$WORKER_BIN" &
|
"$WORKER_BIN" &
|
||||||
WORKER_PID=$!
|
WORKER_PID=$!
|
||||||
|
|
||||||
# Handle graceful shutdown
|
# ── 4. Start Next.js Frontend (port 4007) ──
|
||||||
trap "echo 'Shutting down...'; kill $GATEWAY_PID $WORKER_PID 2>/dev/null; wait; exit 0" SIGINT SIGTERM
|
|
||||||
|
|
||||||
# Wait for either process to exit
|
if command -v bun &>/dev/null && [ -f /app/node_modules/.bin/next ]; then
|
||||||
wait -n $GATEWAY_PID $WORKER_PID
|
echo "Starting Next.js frontend on port 4007..."
|
||||||
|
cd /app
|
||||||
|
NODE_ENV=production RUST_GATEWAY_URL=http://localhost:4008 \
|
||||||
|
bun run next start --port 4007 &
|
||||||
|
NEXT_PID=$!
|
||||||
|
echo "Next.js frontend started (PID: $NEXT_PID)"
|
||||||
|
elif command -v node &>/dev/null && [ -f /app/node_modules/.bin/next ]; then
|
||||||
|
echo "Starting Next.js frontend on port 4007..."
|
||||||
|
cd /app
|
||||||
|
NODE_ENV=production RUST_GATEWAY_URL=http://localhost:4008 \
|
||||||
|
node /app/node_modules/.bin/next start --port 4007 &
|
||||||
|
NEXT_PID=$!
|
||||||
|
echo "Next.js frontend started (PID: $NEXT_PID)"
|
||||||
|
else
|
||||||
|
echo "WARNING: Node.js/bun not found, frontend will not be served"
|
||||||
|
NEXT_PID=""
|
||||||
|
fi
|
||||||
|
|
||||||
# If one exits, kill the other
|
# ── 5. Graceful shutdown ──
|
||||||
kill $GATEWAY_PID $WORKER_PID 2>/dev/null
|
|
||||||
|
trap "echo 'Shutting down...'; kill $GATEWAY_PID $WORKER_PID $NEXT_PID 2>/dev/null; wait; exit 0" SIGINT SIGTERM
|
||||||
|
|
||||||
|
# Wait for any process to exit
|
||||||
|
if [ -n "$NEXT_PID" ]; then
|
||||||
|
wait -n $GATEWAY_PID $WORKER_PID $NEXT_PID
|
||||||
|
else
|
||||||
|
wait -n $GATEWAY_PID $WORKER_PID
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If one exits, kill the others
|
||||||
|
kill $GATEWAY_PID $WORKER_PID $NEXT_PID 2>/dev/null
|
||||||
exit 1
|
exit 1
|
||||||
Reference in New Issue
Block a user