Compare commits

..
11 Commits
Author SHA1 Message Date
asepharyana 036f67d05a chore: update ports to 4007/4008 2026-08-02 14:31:27 +07:00
asepharyana 3956b90c3c ci: add workflow_dispatch to notify-parent for manual triggering
Notify Parent Repo / dispatch (push) Canceled after 0s
2026-07-25 18:13:38 +07:00
asepharyana 2a10d89d4c fix: correct service name in notify-parent workflow 2026-07-25 15:28:42 +07:00
asepharyanaandClaude Code 06c466764c ci: add notify-parent workflow
Trigger repository_dispatch to parent hub repo on push to main.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-07-25 15:27:46 +07:00
asepharyanaandKilo e8b92ed157 feat: update PDF tool pages with interactive options, promote split/compress to phase 1
- pdf-split: page range input (1-3,5,7-9) with example
- pdf-compress: upload zone with description
- tool-grid: split/compress jadi phase 1 (backend udah implement)
- merge/images-to-pdf tetap phase 2 (butuh multi-file upload)

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 19:54:26 +07:00
asepharyanaandKilo d4bbde9320 feat: implement image tools (compress, resize, convert) and PDF tools (merge, split, images-to-pdf)
Image tools now actually process images:
- Compress: re-encode JPEG with configurable quality
- Resize: Lanczos3 resize with fit modes (inside/fill/crop)
- Convert: cross-format conversion (JPEG/PNG/WebP/GIF/BMP)

PDF tools:
- Merge: combine multiple PDFs into one document
- Split: extract page ranges via pagespec (1-3,5,7-9)
- Images to PDF: embed images as JPEG in A4 pages with scaling
- Compress: re-save PDF with optimized cross-reference table

Also fix metrics format to emit default zero values.

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 19:52:39 +07:00
asepharyanaandKilo 1a606d5c64 feat: add interactive resize/convert options (width, height, format, quality)
Resize page: width/height number inputs, aspect ratio lock, quality slider
Convert page: target format dropdown (JPEG/PNG/WebP/GIF/BMP), quality slider

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 19:07:05 +07:00
asepharyanaandKilo 5a64048ba2 fix: wrap contour detection and pipeline in catch_unwind
imageproc::find_contours panics on some edge images (called
Option::unwrap() on None). Wrap with catch_unwind and provide
a safe fallback (use full image bounds).

Also add catch_unwind around the entire pipeline so no panic
can crash the worker thread.

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 19:01:28 +07:00
asepharyanaandKilo d2bfcb83a0 fix: audit perbaikan NATS subject, worker spawn, frontend options, pipeline PDF output
Bug fixes:
1. NATS subject doubled prefix — gateway publish ke tools.tools.scan.*
   padahal workers subscribe ke tools.scan.*. Fix: pakai group() method
2. Worker subscriptions pake tokio::select! — cuma satu subscription
   yang diproses, sisanya di-cancel. Fix: ganti ke tokio::spawn + loop
3. WebSocket URL hardcoded ke localhost:3001 — gak jalan di prod.
   Fix: infer dari window.location atau env var
4. Frontend scan options hardcoded — user gak bisa atur OCR/enhance/format.
   Fix: interactive toggles, selects, slider
5. Frontend compress quality hardcoded — user gak bisa atur kualitas.
   Fix: quality range slider
6. Pipeline cuma output PNG — gak ada PDF/OCR.
   Fix: generate searchable PDF kalo tesseract feature enabled

Enhancements:
- Tool::group() method added — returns 'scan', 'image', 'pdf' dll
- Dockerfile: --features tesseract pas cargo build
- leptess OCR: proper API usage (set_image_from_mem, recognize, etc.)

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 18:24:22 +07:00
asepharyanaandKilo 37cc3a7486 fix: use bun for Next.js server, not node
Node.js not available in runtime image. Use bun instead which is
copied from frontend-builder stage.

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 17:55:37 +07:00
asepharyanaandKilo f2677d37e8 feat: add Next.js frontend to entrypoint
Start Next.js on port 3000 alongside gateway (3001) and workers.
Frontend proxies /api/* to localhost:3001.

Co-Authored-By: Kilo <kilo@kilo.ai>
2026-07-24 17:51:58 +07:00
28 changed files with 1193 additions and 322 deletions
+25
View File
@@ -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 }}"
}
+7 -7
View File
@@ -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",
} }
} }
+62 -38
View File
@@ -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!(
+2 -2
View File
@@ -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()))?;
+29
View File
@@ -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)
}
+57
View File
@@ -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)
}
+57 -7
View File
@@ -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(())
} }
+78
View File
@@ -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)
}
+8 -10
View File
@@ -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.
+1 -1
View File
@@ -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;
} }
+20
View File
@@ -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)
}
+101
View File
@@ -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)
}
+63
View File
@@ -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)
}
+51 -7
View File
@@ -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(())
} }
+8
View File
@@ -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(())
}
+64
View File
@@ -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)
}
+44 -23
View File
@@ -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
}
} }
+67 -70
View File
@@ -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,
})
} }
+94 -7
View File
@@ -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
+32 -10
View File
@@ -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" && (
+64 -28
View File
@@ -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>
); );
} }
+76 -28
View File
@@ -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>
); );
} }
+8 -27
View File
@@ -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>
); );
} }
+30 -29
View File
@@ -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>
); );
} }
+93 -10
View File
@@ -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>
)} )}
+4 -4
View File
@@ -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",
+8 -4
View File
@@ -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
View File
@@ -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