diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcc8952..f824584 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} with: args: > - -Dsonar.cfamily.build-wrapper-output=bw-output + -Dsonar.cfamily.compile-commands=bw-output/compile_commands.json -Dsonar.coverageReportPaths=coverage.xml build-web: @@ -78,6 +78,12 @@ jobs: needs: build-web # Only deploy on pushes to main, not PRs if: github.ref == 'refs/heads/main' && github.event_name == 'push' + permissions: + deployments: write + contents: read + environment: + name: production + url: ${{ steps.deploy.outputs.deployment_url }} steps: - uses: actions/checkout@v4 @@ -97,7 +103,10 @@ jobs: run: vercel build --prod --token=$VERCEL_TOKEN - name: Deploy Project Artifacts to Vercel - run: vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN + id: deploy + run: | + DEPLOY_URL=$(vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN --yes) + echo "deployment_url=$DEPLOY_URL" >> $GITHUB_OUTPUT env: VERCEL_ORG_ID: ${{ secrets.ORGID }} VERCEL_PROJECT_ID: ${{ secrets.PROJECTID }} diff --git a/CMakeLists.txt b/CMakeLists.txt index ba2d2a1..8b93c18 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -23,6 +23,16 @@ if(ENABLE_COVERAGE) endif() endif() +# --- Sanitizers --- +option(ENABLE_SANITIZERS "Enable ASan and UBSan" OFF) +if(ENABLE_SANITIZERS) + if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") + message(STATUS "Enabling AddressSanitizer and UndefinedBehaviorSanitizer") + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) + add_link_options(-fsanitize=address,undefined) + endif() +endif() + # --- Subdirectories --- add_subdirectory(core) add_subdirectory(apps/native) \ No newline at end of file diff --git a/Makefile b/Makefile index e409344..50864a8 100644 --- a/Makefile +++ b/Makefile @@ -3,7 +3,7 @@ WEB_COMPILER = emcc WEB_FLAGS = -Icore/inc -O3 \ -s WASM=1 \ -s ALLOW_MEMORY_GROWTH=1 \ - -s EXPORTED_FUNCTIONS='["_init_session", "_process_image", "_get_view_ptr", "_set_view_tint", "_get_psnr_y", "_get_psnr_cr", "_get_psnr_cb", "_get_ssim_y", "_get_ssim_cr", "_get_ssim_cb", "_malloc", "_free"]' \ + -s EXPORTED_FUNCTIONS='["_init_session", "_process_image", "_get_view_ptr", "_set_view_tint", "_get_psnr_y", "_get_psnr_cr", "_get_psnr_cb", "_get_ssim_y", "_get_ssim_cr", "_get_ssim_cb", "_inspect_block_data", "_malloc", "_free"]' \ -s EXPORTED_RUNTIME_METHODS='["cwrap", "ccall", "HEAPU8"]' # Source: Core C++ + Web Glue C++ (in src folder) @@ -70,6 +70,14 @@ coverage: # In gcovr 8.6, we use the search path at the end instead of --build-root gcovr --sonarqube -o coverage.xml -r . --filter core/src/ -e build/ --gcov-ignore-parse-errors=all build +# 6. Sanitize (ASan + UBSan) +sanitize: + @echo "๐Ÿ›ก๏ธ Running with Sanitizers..." + @mkdir -p build + @if [ -d build/CMakeCache.txt ]; then rm build/CMakeCache.txt; fi + @cd build && cmake .. -DENABLE_SANITIZERS=ON && make -j$(NPROCS) + @cd build && ctest --output-on-failure + # ------------------------------------ # ๐Ÿงน CLEANUP # ------------------------------------ diff --git a/apps/native/CodecExplorerApp.cpp b/apps/native/CodecExplorerApp.cpp index 9d36e04..4d4ee63 100644 --- a/apps/native/CodecExplorerApp.cpp +++ b/apps/native/CodecExplorerApp.cpp @@ -78,6 +78,7 @@ CodecExplorerApp::CodecExplorerApp(const std::string& imagePath, ImageCodec::Chr // Per OpenCV warning, use nullptr for the value pointer and set the position manually. cv::createTrackbar("Quality", m_state.windowName, nullptr, 100, onQualityChangeStatic, this); cv::setTrackbarPos("Quality", m_state.windowName, m_quality); + cv::setMouseCallback(m_state.windowName, onMouseStatic, this); } void CodecExplorerApp::run() { @@ -105,7 +106,9 @@ void CodecExplorerApp::handleKey(int key) { else if (key == 't') { m_useTint = !m_useTint; viewChanged = true; } else if (key == '4') { m_chromaSubsampling = ImageCodec::ChromaSubsampling::CS_444; codecChanged = true; } else if (key == '2') { m_chromaSubsampling = ImageCodec::ChromaSubsampling::CS_422; codecChanged = true; } + else if (key == '2') { m_chromaSubsampling = ImageCodec::ChromaSubsampling::CS_422; codecChanged = true; } else if (key == '0') { m_chromaSubsampling = ImageCodec::ChromaSubsampling::CS_420; codecChanged = true; } + else if (key == 'c') { m_showInspection = false; viewChanged = true; } if (codecChanged) { @@ -126,6 +129,72 @@ void CodecExplorerApp::onQualityChangeStatic(int quality, void* userdata) { void CodecExplorerApp::onQualityChange(int quality) { m_quality = std::max(1, quality); updateCodecOutput(); + updateCodecOutput(); + render(); +} + +void CodecExplorerApp::onMouseStatic(int event, int x, int y, int flags, void* userdata) { + auto* app = static_cast(userdata); + if (app) { + app->onMouse(event, x, y, flags); + } +} + +void CodecExplorerApp::onMouse(int event, int x, int y, int flags) { + if (event == cv::EVENT_LBUTTONDOWN) { + // Check if click is within the original image area (top-left) + if (x < m_state.originalImage.width() && y < m_state.originalImage.height()) { + inspectBlockAt(x, y); + } else { + // Hide inspection if clicked elsewhere + m_showInspection = false; + render(); + } + } +} + +void CodecExplorerApp::inspectBlockAt(int x, int y) { + m_selectedBlockX = x / 8; + m_selectedBlockY = y / 8; + m_showInspection = true; + + ImageCodec codec(m_quality, true, m_chromaSubsampling); + + // Determine which channel we are inspecting based on view mode + if (m_state.mode == AppState::ViewMode::Cr) { + // Need to pass the Cr channel. BUT wait, inspectBlock expects the input channel. + // We need to extract the channel from the ORIGINAL image first, as per logic + // But `inspectBlock` does internally convert if we passed the whole image? No. + // `inspectBlock` takes a channel Image. + + // We need the original YCrCb image to pass the correct channel + Image ycrcb = bgrToYCrCb(m_state.originalImage); + Image cr(ycrcb.width(), ycrcb.height(), 1); + const double* src = ycrcb.data(); + double* dst = cr.data(); + for(size_t i=0; i 50 for X, 20 -> 25 for Y + int px = offsetX + j * 45; + int py = offsetY + i * 25; + cv::putText(inspectionView, valStr, {px, py}, cv::FONT_HERSHEY_SIMPLEX, 0.35, color, 1); + } + } + }; + + // Adjusted positions + drawGrid(20, 50, "Original", m_inspectionData.original, true); + drawGrid(420, 50, "DCT", m_inspectionData.dct, false); + drawGrid(20, 300, "Quantized", m_inspectionData.quantized, true); + drawGrid(420, 300, "Reconstructed", m_inspectionData.reconstructed, true); + + cv::imshow("Block Inspection", inspectionView); + } else { + try { + cv::destroyWindow("Block Inspection"); + } catch(...) {} + } } \ No newline at end of file diff --git a/apps/native/CodecExplorerApp.h b/apps/native/CodecExplorerApp.h index 191cbc4..4dad452 100644 --- a/apps/native/CodecExplorerApp.h +++ b/apps/native/CodecExplorerApp.h @@ -48,8 +48,19 @@ class CodecExplorerApp { static void onQualityChangeStatic(int quality, void* userdata); void onQualityChange(int quality); + // Mouse Handling + static void onMouseStatic(int event, int x, int y, int flags, void* userdata); + void onMouse(int event, int x, int y, int flags); + void inspectBlockAt(int x, int y); + AppState m_state; int m_quality = 50; ImageCodec::ChromaSubsampling m_chromaSubsampling; bool m_useTint = true; + + // Inspection State + bool m_showInspection = false; + int m_selectedBlockX = 0; + int m_selectedBlockY = 0; + ImageCodec::BlockDebugData m_inspectionData; }; \ No newline at end of file diff --git a/core/inc/ImageCodec.h b/core/inc/ImageCodec.h index 08ab565..fb40aef 100644 --- a/core/inc/ImageCodec.h +++ b/core/inc/ImageCodec.h @@ -59,6 +59,17 @@ class ImageCodec { const double quantTable[8][8]); Image downsampleChannel(const Image& channel, ChromaSubsampling cs) const; Image upsampleChannel(const Image& channel, int targetWidth, int targetHeight, ChromaSubsampling cs) const; + +public: + struct BlockDebugData { + double original[8][8]; + double dct[8][8]; + double quantTable[8][8]; + double quantized[8][8]; + double reconstructed[8][8]; + }; + + BlockDebugData inspectBlock(const Image& channel, int blockX, int blockY, bool isChroma = false); }; #endif diff --git a/core/src/ImageCodec.cpp b/core/src/ImageCodec.cpp index 7711ad1..0e660fb 100644 --- a/core/src/ImageCodec.cpp +++ b/core/src/ImageCodec.cpp @@ -303,4 +303,86 @@ Image ImageCodec::process(const Image& bgrImage) } return ycrcbToBgr(merged); +} + +ImageCodec::BlockDebugData ImageCodec::inspectBlock(const Image& channel, int blockX, int blockY, bool isChroma) { + BlockDebugData data; + const double (*quantTable)[8] = isChroma ? m_chromaQuantTable : m_lumaQuantTable; + + // 1. Copy Quantization Table + for(int i=0; i<8; ++i) { + for(int j=0; j<8; ++j) { + data.quantTable[i][j] = quantTable[i][j]; + } + } + + // 2. Extract Original Block + // Ensure we don't go out of bounds + int startX = blockX * 8; + int startY = blockY * 8; + + // Fill with zeros first + for(int i=0; i<8; ++i) + for(int j=0; j<8; ++j) + data.original[i][j] = 0.0; + + const double* channelData = channel.data(); + int width = channel.width(); + int height = channel.height(); + + for(int i=0; i<8; ++i) { + for(int j=0; j<8; ++j) { + int y = startY + i; + int x = startX + j; + if (x < width && y < height) { + data.original[i][j] = channelData[y * width + x]; + } + } + } + + // 3. DCT + double blockCentered[8][8]; + for(int i=0; i<8; ++i) + for(int j=0; j<8; ++j) + blockCentered[i][j] = data.original[i][j] - 128.0; + + dct8x8(blockCentered, data.dct); + + // 4. Quantization + if (m_enableQuantization) { + for(int i=0; i<8; ++i) { + for(int j=0; j<8; ++j) { + double coeff = data.dct[i][j] / quantTable[i][j]; + data.quantized[i][j] = std::round(coeff); // Store integer index + // But for display in "Quantized" view, we might want the actual integer value + // In processChannel we do: dctBlock[i][j] = std::round(coeff) * quantTable[i][j]; + // effectively dequantizing immediately. + // Let's store the quantized integer index in `quantized` + } + } + } else { + for(int i=0; i<8; ++i) + for(int j=0; j<8; ++j) + data.quantized[i][j] = data.dct[i][j]; + } + + // 5. Dequantization & IDCT (Reconstruction) + double dequantized[8][8]; + for(int i=0; i<8; ++i) { + for(int j=0; j<8; ++j) { + if (m_enableQuantization) + dequantized[i][j] = data.quantized[i][j] * quantTable[i][j]; + else + dequantized[i][j] = data.quantized[i][j]; + } + } + + double reconBlock[8][8]; + idct8x8(dequantized, reconBlock); + + for(int i=0; i<8; ++i) + for(int j=0; j<8; ++j) + data.reconstructed[i][j] = reconBlock[i][j] + 128.0; + + return data; } \ No newline at end of file diff --git a/core/tests/CMakeLists.txt b/core/tests/CMakeLists.txt index fb30c29..b0ef3fe 100644 --- a/core/tests/CMakeLists.txt +++ b/core/tests/CMakeLists.txt @@ -20,6 +20,8 @@ add_executable(codec_core_tests image_test.cpp colorspace_test.cpp transform_test.cpp + test_imagecodec.cpp + test_codecanalysis.cpp ) target_link_libraries(codec_core_tests diff --git a/core/tests/test_codecanalysis.cpp b/core/tests/test_codecanalysis.cpp new file mode 100644 index 0000000..0e6556d --- /dev/null +++ b/core/tests/test_codecanalysis.cpp @@ -0,0 +1,72 @@ +#include +#include "CodecAnalysis.h" +#include "Image.h" +#include + +// Helper to create a flat color image +Image createFlatImage(int width, int height, double r, double g, double b) { + Image img(width, height, 3); + double* data = img.data(); + for (int i = 0; i < width * height; ++i) { + data[i * 3 + 0] = b; + data[i * 3 + 1] = g; + data[i * 3 + 2] = r; + } + return img; +} + +TEST(CodecAnalysisTest, PSNR_Identical) { + Image img = createFlatImage(16, 16, 100.0, 100.0, 100.0); + double psnr = CodecAnalysis::computePSNR(img, img); + // Expect very high PSNR (capped at 100.0 in implementation for perfect match) + EXPECT_GE(psnr, 99.0); +} + +TEST(CodecAnalysisTest, PSNR_Different) { + Image img1 = createFlatImage(16, 16, 100.0, 100.0, 100.0); + Image img2 = createFlatImage(16, 16, 110.0, 110.0, 110.0); // Slightly different + + double psnr = CodecAnalysis::computePSNR(img1, img2); + // Should be finite and less than perfect + EXPECT_LT(psnr, 99.0); + EXPECT_GT(psnr, 0.0); +} + +TEST(CodecAnalysisTest, SSIM_Identical) { + Image img = createFlatImage(16, 16, 100.0, 100.0, 100.0); + double ssim = CodecAnalysis::computeSSIM(img, img); + EXPECT_DOUBLE_EQ(ssim, 1.0); +} + +TEST(CodecAnalysisTest, SSIM_Different) { + Image img1 = createFlatImage(16, 16, 0.0, 0.0, 0.0); + Image img2 = createFlatImage(16, 16, 255.0, 255.0, 255.0); + + double ssim = CodecAnalysis::computeSSIM(img1, img2); + EXPECT_LT(ssim, 1.0); + EXPECT_GE(ssim, 0.0); +} + +TEST(CodecAnalysisTest, ArtifactMap_Dimensions) { + Image img1 = createFlatImage(20, 20, 100.0, 100.0, 100.0); + Image img2 = createFlatImage(20, 20, 105.0, 105.0, 105.0); + + Image artifact = CodecAnalysis::computeArtifactMap(img1, img2); + EXPECT_EQ(artifact.width(), 20); + EXPECT_EQ(artifact.height(), 20); + EXPECT_EQ(artifact.channels(), 3); +} + +TEST(CodecAnalysisTest, ComputeMetrics_Structure) { + Image img1 = createFlatImage(16, 16, 100.0, 50.0, 25.0); + Image img2 = createFlatImage(16, 16, 100.0, 50.0, 25.0); + + CodecMetrics metrics = CodecAnalysis::computeMetrics(img1, img2); + + EXPECT_GE(metrics.psnrY, 99.0); + EXPECT_GE(metrics.psnrCr, 99.0); + EXPECT_GE(metrics.psnrCb, 99.0); + EXPECT_DOUBLE_EQ(metrics.ssimY, 1.0); + EXPECT_DOUBLE_EQ(metrics.ssimCr, 1.0); + EXPECT_DOUBLE_EQ(metrics.ssimCb, 1.0); +} diff --git a/core/tests/test_imagecodec.cpp b/core/tests/test_imagecodec.cpp new file mode 100644 index 0000000..dbaba4a --- /dev/null +++ b/core/tests/test_imagecodec.cpp @@ -0,0 +1,93 @@ +#include +#include "ImageCodec.h" +#include "CodecAnalysis.h" +#include "Image.h" +#include + +// Helper to create a simple test image (gradient) +Image createTestImage(int width, int height) { + Image img(width, height, 3); + double* data = img.data(); + for (int y = 0; y < height; ++y) { + for (int x = 0; x < width; ++x) { + int idx = (y * width + x) * 3; + data[idx + 0] = static_cast(x % 256); // B + data[idx + 1] = static_cast(y % 256); // G + data[idx + 2] = static_cast((x + y) % 256); // R + } + } + return img; +} + +TEST(ImageCodecTest, ProcessDimensions) { + int w = 64; + int h = 64; + Image input = createTestImage(w, h); + + // Default 4:4:4 + ImageCodec codec(90.0); + Image output = codec.process(input); + + EXPECT_EQ(output.width(), w); + EXPECT_EQ(output.height(), h); + EXPECT_EQ(output.channels(), 3); +} + +TEST(ImageCodecTest, QualityImpact) { + int w = 32; + int h = 32; + Image input = createTestImage(w, h); + + // Low quality + ImageCodec lowQualCodec(10.0); + Image lowQualOutput = lowQualCodec.process(input); + + // High quality + ImageCodec highQualCodec(90.0); + Image highQualOutput = highQualCodec.process(input); + + // Measure PSNR + CodecMetrics lowMetrics = CodecAnalysis::computeMetrics(input, lowQualOutput); + CodecMetrics highMetrics = CodecAnalysis::computeMetrics(input, highQualOutput); + + // High quality should have better (higher) PSNR than low quality + // We check Y channel specifically as it's the most significant + EXPECT_GT(highMetrics.psnrY, lowMetrics.psnrY); + EXPECT_GT(highMetrics.psnrY, 20.0); // Basic sanity check +} + +TEST(ImageCodecTest, SubsamplingModes) { + int w = 32; + int h = 32; + Image input = createTestImage(w, h); // Multiple of 2 for clean 4:2:0 subsampling + + // 4:4:4 + { + ImageCodec codec(80.0, true, ImageCodec::ChromaSubsampling::CS_444); + Image output = codec.process(input); + EXPECT_EQ(output.width(), w); + EXPECT_EQ(output.height(), h); + CodecMetrics metrics = CodecAnalysis::computeMetrics(input, output); + EXPECT_GT(metrics.psnrY, 30.0); + } + + // 4:2:2 + { + ImageCodec codec(80.0, true, ImageCodec::ChromaSubsampling::CS_422); + Image output = codec.process(input); + EXPECT_EQ(output.width(), w); + EXPECT_EQ(output.height(), h); + CodecMetrics metrics = CodecAnalysis::computeMetrics(input, output); + EXPECT_GT(metrics.psnrY, 30.0); + } + + // 4:2:0 + { + ImageCodec codec(80.0, true, ImageCodec::ChromaSubsampling::CS_420); + Image output = codec.process(input); + EXPECT_EQ(output.width(), w); + EXPECT_EQ(output.height(), h); + CodecMetrics metrics = CodecAnalysis::computeMetrics(input, output); + EXPECT_GT(metrics.psnrY, 30.0); + } +} diff --git a/sonar-project.properties b/sonar-project.properties index 88d51e0..a65e08d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -7,7 +7,7 @@ sonar.tests=core/tests sonar.sourceEncoding=UTF-8 # C++ Analysis Requirements -sonar.cfamily.build-wrapper-output=bw-output +sonar.cfamily.compile-commands=bw-output/compile_commands.json # Use the XML report generated by gcovr in your GitHub Action sonar.coverageReportPaths=coverage.xml diff --git a/web/public/codec.js b/web/public/codec.js index 7e2697e..bccff8d 100644 --- a/web/public/codec.js +++ b/web/public/codec.js @@ -1 +1 @@ -var Module=typeof Module!="undefined"?Module:{};var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=globalThis.document?.currentScript?.src;if(typeof __filename!="undefined"){_scriptName=__filename}else if(ENVIRONMENT_IS_WORKER){_scriptName=self.location.href}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);if(typeof module!="undefined"){module["exports"]=Module}quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status)};xhr.onerror=reject;xhr.send(null)})}var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["e"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("codec.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var runDependencies=0;var dependenciesFulfilled=null;var removeRunDependency=id=>{runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}};var addRunDependency=id=>{runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _init_session,_process_image,_get_view_ptr,_malloc,_set_view_tint,_get_psnr_y,_get_psnr_cr,_get_psnr_cb,_get_ssim_y,_get_ssim_cr,_get_ssim_cb,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_init_session=Module["_init_session"]=wasmExports["f"];_process_image=Module["_process_image"]=wasmExports["g"];_get_view_ptr=Module["_get_view_ptr"]=wasmExports["h"];_malloc=Module["_malloc"]=wasmExports["i"];_set_view_tint=Module["_set_view_tint"]=wasmExports["j"];_get_psnr_y=Module["_get_psnr_y"]=wasmExports["k"];_get_psnr_cr=Module["_get_psnr_cr"]=wasmExports["l"];_get_psnr_cb=Module["_get_psnr_cb"]=wasmExports["m"];_get_ssim_y=Module["_get_ssim_y"]=wasmExports["n"];_get_ssim_cr=Module["_get_ssim_cr"]=wasmExports["o"];_get_ssim_cb=Module["_get_ssim_cb"]=wasmExports["p"];_free=Module["_free"]=wasmExports["q"];__emscripten_stack_restore=wasmExports["r"];__emscripten_stack_alloc=wasmExports["s"];_emscripten_stack_get_current=wasmExports["t"];memory=wasmMemory=wasmExports["d"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:___cxa_throw,b:__abort_js,c:_emscripten_resize_heap};function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;createWasm();run(); +var Module=typeof Module!="undefined"?Module:{};var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=globalThis.document?.currentScript?.src;if(typeof __filename!="undefined"){_scriptName=__filename}else if(ENVIRONMENT_IS_WORKER){_scriptName=self.location.href}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);if(typeof module!="undefined"){module["exports"]=Module}quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status)};xhr.onerror=reject;xhr.send(null)})}var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["e"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("codec.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var runDependencies=0;var dependenciesFulfilled=null;var removeRunDependency=id=>{runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}};var addRunDependency=id=>{runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _init_session,_process_image,_get_view_ptr,_malloc,_set_view_tint,_get_psnr_y,_get_psnr_cr,_get_psnr_cb,_get_ssim_y,_get_ssim_cr,_get_ssim_cb,_inspect_block_data,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_init_session=Module["_init_session"]=wasmExports["f"];_process_image=Module["_process_image"]=wasmExports["g"];_get_view_ptr=Module["_get_view_ptr"]=wasmExports["h"];_malloc=Module["_malloc"]=wasmExports["i"];_set_view_tint=Module["_set_view_tint"]=wasmExports["j"];_get_psnr_y=Module["_get_psnr_y"]=wasmExports["k"];_get_psnr_cr=Module["_get_psnr_cr"]=wasmExports["l"];_get_psnr_cb=Module["_get_psnr_cb"]=wasmExports["m"];_get_ssim_y=Module["_get_ssim_y"]=wasmExports["n"];_get_ssim_cr=Module["_get_ssim_cr"]=wasmExports["o"];_get_ssim_cb=Module["_get_ssim_cb"]=wasmExports["p"];_inspect_block_data=Module["_inspect_block_data"]=wasmExports["q"];_free=Module["_free"]=wasmExports["r"];__emscripten_stack_restore=wasmExports["s"];__emscripten_stack_alloc=wasmExports["t"];_emscripten_stack_get_current=wasmExports["u"];memory=wasmMemory=wasmExports["d"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:___cxa_throw,b:__abort_js,c:_emscripten_resize_heap};function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;createWasm();run(); diff --git a/web/public/codec.wasm b/web/public/codec.wasm index 99f37a4..80121e2 100755 Binary files a/web/public/codec.wasm and b/web/public/codec.wasm differ diff --git a/web/public/css/base.css b/web/public/css/base.css new file mode 100644 index 0000000..24c94a0 --- /dev/null +++ b/web/public/css/base.css @@ -0,0 +1,29 @@ +* { + box-sizing: border-box; +} + +body { + font-family: var(--font-sans); + background-color: var(--bg); + color: var(--text); + margin: 0; + padding: 20px; + line-height: 1.5; + display: flex; + flex-direction: column; + align-items: center; + transition: background-color 0.3s ease, color 0.3s ease; +} + +code, +.stat-value, +.file-size-values, +.stats { + font-family: var(--font-mono); +} + +@media (max-width: 768px) { + body { + padding: 16px; + } +} \ No newline at end of file diff --git a/web/public/css/components.css b/web/public/css/components.css new file mode 100644 index 0000000..e5088bf --- /dev/null +++ b/web/public/css/components.css @@ -0,0 +1,261 @@ +.controls { + overflow: visible; + display: flex; + flex-direction: column; + gap: 20px; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 8px; +} + +label { + font-weight: 600; + font-size: 0.9rem; + display: flex; + justify-content: space-between; +} + +input[type="range"] { + width: 100%; + cursor: pointer; +} + +@media (max-width: 768px) { + input[type="range"] { + height: 40px; + } +} + +/* Button Styles */ +button.primary { + background-color: var(--primary); + color: white; + border: none; + padding: 12px; + border-radius: 8px; + font-weight: 600; + font-size: 1rem; + cursor: pointer; + transition: background-color 0.2s; +} + +button.primary:hover { + background-color: var(--primary-hover); +} + +button.primary:active { + transform: translateY(1px); +} + +@media (max-width: 768px) { + button.primary { + padding: 16px; + } +} + +/* Stats Box */ +.stats { + margin-top: 10px; + padding: 12px; + background: var(--bg-secondary); + border-radius: 6px; + font-family: var(--font-mono); + font-size: 0.85rem; + white-space: pre-wrap; + border: 1px solid var(--border); +} + +/* Toggle Group (Segmented Control) */ +.group-label { + margin-bottom: 8px; + display: block; + font-size: 0.9rem; + color: var(--text); +} + +.toggle-group { + display: flex; + background: var(--toggle-bg); + padding: 4px; + border-radius: 8px; + gap: 4px; +} + +.toggle-group input[type="radio"], +input[type="checkbox"] { + display: none; +} + +/* Specific to toggle-group labels */ +.toggle-group label { + display: flex; + justify-content: center; + align-items: center; + flex: 1; + text-align: center; + padding: 8px 4px; + font-size: 0.85rem; + font-weight: 500; + border-radius: 6px; + cursor: pointer; + color: var(--toggle-label); + transition: all 0.2s ease; + user-select: none; +} + +.toggle-group label:hover { + background: var(--primary-subtle); + color: var(--text); +} + +.toggle-group input[type="radio"]:checked+label, +.toggle-group input[type="checkbox"]:checked+label { + background: var(--toggle-active-bg); + color: var(--primary); + box-shadow: 0 1px 3px var(--shadow-color); + font-weight: 600; +} + +/* Metrics Table */ +.metrics-table { + width: 100%; + border-collapse: collapse; + margin-top: 10px; + font-size: 0.9rem; + color: var(--text); +} + +.metrics-table th, +.metrics-table td { + border: 1px solid var(--border); + padding: 6px; + text-align: center; +} + +.metrics-table th { + background-color: var(--bg-secondary); + font-weight: 600; +} + +.metrics-table td { + background-color: var(--card-bg); +} + +/* Quality Presets */ +.preset-group { + display: flex; + gap: 6px; +} + +.preset-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 1px; + padding: 6px 4px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--card-bg); + cursor: pointer; + transition: all 0.2s ease; +} + +.preset-btn:hover:not(:disabled) { + border-color: var(--primary); + background: var(--primary-subtle); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(37, 99, 235, 0.12); +} + +.preset-btn:active:not(:disabled) { + transform: translateY(0); +} + +.preset-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.preset-btn.active { + border-color: var(--primary); + background: var(--primary-subtle); + box-shadow: 0 0 0 1px var(--primary); +} + +.preset-label { + font-size: 0.8rem; + font-weight: 700; + color: var(--text); +} + +.preset-desc { + font-size: 0.6rem; + color: var(--text-muted); + letter-spacing: 0.02em; +} + +/* File Size Estimation Bar */ +.file-size-bar-container { + margin-top: 12px; + padding: 10px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 8px; +} + +.file-size-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 6px; +} + +.file-size-title { + font-size: 0.78rem; + font-weight: 600; + color: var(--text); +} + +.file-size-values { + font-size: 0.78rem; + font-weight: 700; + font-family: var(--font-mono); + color: var(--primary); +} + +.file-size-track { + width: 100%; + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; +} + +.file-size-fill { + height: 100%; + background: linear-gradient(90deg, #22c55e, var(--primary)); + border-radius: 4px; + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); + min-width: 4px; +} + +.file-size-labels { + display: flex; + justify-content: space-between; + margin-top: 4px; +} + +.file-size-label-left, +.file-size-label-right { + font-size: 0.68rem; + color: var(--text-muted); +} + +.file-size-label-right { + font-weight: 600; + color: var(--success-color); +} \ No newline at end of file diff --git a/web/public/css/header.css b/web/public/css/header.css new file mode 100644 index 0000000..5fceec3 --- /dev/null +++ b/web/public/css/header.css @@ -0,0 +1,84 @@ +header { + text-align: center; + margin-bottom: 32px; +} + +h1 { + margin: 0 0 12px 0; + font-size: 1.8rem; + letter-spacing: -0.025em; +} + +.header-badges { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.privacy-badge { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: var(--success-bg); + color: var(--success-text); + padding: 6px 12px; + border-radius: 99px; + font-size: 0.85rem; + font-weight: 600; + box-shadow: 0 1px 2px var(--shadow-color); +} + +.github-link { + display: inline-flex; + align-items: center; + gap: 8px; + background-color: var(--bg-secondary); + color: var(--text-muted); + padding: 6px 12px; + border-radius: 99px; + font-size: 0.85rem; + font-weight: 600; + text-decoration: none; + box-shadow: 0 1px 2px var(--shadow-color); + transition: all 0.2s ease; +} + +.github-link:hover { + background-color: var(--bg-tertiary); + color: var(--text); + transform: translateY(-1px); +} + +/* Dark Mode Toggle */ +.theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: 1px solid var(--border); + border-radius: 99px; + background: var(--bg-secondary); + color: var(--text-muted); + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 1px 2px var(--shadow-color); +} + +.theme-toggle:hover { + background: var(--bg-tertiary); + color: var(--text); + transform: translateY(-1px); +} + +.theme-toggle svg { + width: 16px; + height: 16px; +} + +/* GitHub logo invert in dark mode */ +[data-theme="dark"] .github-link img { + filter: invert(1); +} \ No newline at end of file diff --git a/web/public/css/inspection.css b/web/public/css/inspection.css new file mode 100644 index 0000000..ba8aebe --- /dev/null +++ b/web/public/css/inspection.css @@ -0,0 +1,666 @@ +/* ===== Inline Inspector Panel ===== */ +.inspector-inline { + width: 100%; + max-width: 100%; + grid-column: 1 / -1; + margin: 0; + margin-top: -1px; + background-color: var(--card-bg); + border: 1px solid var(--border); + border-radius: 0 0 14px 14px; + box-shadow: + 0 8px 24px -6px rgba(0, 0, 0, 0.08), + 0 0 0 1px rgba(0, 0, 0, 0.02); + animation: inspectorFadeIn 0.3s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; +} + +@keyframes inspectorFadeIn { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ===== Header ===== */ +.inspector-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 20px 10px; + border-bottom: 1px solid var(--border); + background: linear-gradient(135deg, rgba(37, 99, 235, 0.03), rgba(139, 92, 246, 0.03)); + flex-wrap: wrap; + gap: 8px; +} + +.inspector-title-row { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.inspector-title { + margin: 0; + font-size: 1.05rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 8px; + color: var(--text); +} + +.inspector-badges { + display: flex; + gap: 6px; + align-items: center; +} + +.inspector-badge { + font-size: 0.68rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 99px; + display: inline-flex; + align-items: center; + gap: 4px; +} + +.badge-coords { + background: rgba(37, 99, 235, 0.08); + color: var(--primary); + border: 1px solid rgba(37, 99, 235, 0.15); +} + +.badge-qtable { + background: rgba(217, 119, 6, 0.08); + color: #b45309; + border: 1px solid rgba(217, 119, 6, 0.15); +} + +/* ===== Color Legend ===== */ +.inspector-legend { + display: flex; + gap: 12px; + flex-wrap: wrap; + font-size: 0.68rem; + color: var(--text-muted); +} + +.legend-item { + display: flex; + align-items: center; + gap: 4px; +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 2px; +} + +.dot-positive { + background: var(--cell-positive); +} + +.dot-negative { + background: var(--cell-negative); +} + +.dot-zero { + background: var(--cell-zero); + opacity: 0.5; +} + +.dot-intensity { + background: linear-gradient(135deg, #000, #fff); + border: 1px solid var(--border); +} + +/* ===== Placeholder ===== */ +.inspector-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 40px 20px; + color: var(--text-muted); + opacity: 0.7; +} + +.inspector-placeholder p { + margin: 0; + font-size: 0.85rem; +} + +.inspector-placeholder-small { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px 10px; + color: var(--text-muted); + opacity: 0.5; + font-size: 0.75rem; + text-align: center; + min-height: 120px; + border: 1.5px dashed var(--border); + border-radius: 10px; + background: rgba(0, 0, 0, 0.01); +} + +.inspector-placeholder-small p { + margin: 0; +} + +/* ===== Row Labels ===== */ +.inspector-row-label { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 20px 2px; + font-size: 0.72rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.inspector-row-label.label-encode { + color: #7c3aed; +} + +.inspector-row-label.label-decode { + color: #047857; + border-top: 1px solid var(--border); + padding-top: 12px; +} + +/* ===== Pipeline Rows (horizontal) ===== */ +.inspector-row { + display: flex; + align-items: center; + gap: 0; + padding: 6px 20px 14px; + overflow-x: auto; +} + +/* ===== Inspection Sections ===== */ +.inspection-section { + flex: 1; + min-width: 0; +} + +.header-with-tooltip { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; + flex-wrap: wrap; +} + +.header-with-tooltip h3 { + margin: 0; + font-size: 0.72rem; + font-weight: 700; + color: var(--text); + white-space: nowrap; +} + +/* ===== Tooltips ===== */ +.tooltip-container { + position: relative; + display: inline-flex; + align-items: center; +} + +.info-icon-small { + display: flex; + align-items: center; + justify-content: center; + width: 15px; + height: 15px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.06); + color: var(--text-muted); + font-size: 0.55rem; + font-weight: 700; + cursor: help; + transition: background 0.15s; + flex-shrink: 0; +} + +.info-icon-small:hover { + background: rgba(0, 0, 0, 0.12); +} + +.tooltip-content-small { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + font-size: 0.72rem; + color: var(--text); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1); + width: 220px; + z-index: 100; + line-height: 1.5; + margin-bottom: 6px; +} + +.tooltip-content-small.tooltip-wide { + width: 300px; +} + +.tooltip-container:hover .tooltip-content-small { + display: block; +} + +/* ===== 8ร—8 Grids ===== */ +.block-grid { + display: grid; + grid-template-columns: repeat(8, 1fr); + grid-template-rows: repeat(8, 1fr); + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + aspect-ratio: 1; + min-width: 120px; +} + +.grid-cell { + width: 100%; + height: 100%; + background-color: var(--card-bg); + display: flex; + justify-content: center; + align-items: center; + font-size: 0.52rem; + font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; + color: var(--text); + overflow: hidden; + transition: background-color 0.15s ease, transform 0.1s ease; + cursor: default; + position: relative; + border-right: 1px solid rgba(0, 0, 0, 0.04); + border-bottom: 1px solid rgba(0, 0, 0, 0.04); +} + +.grid-cell:hover { + z-index: 2; + transform: scale(1.2); + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2); + border-radius: 3px; +} + +.cell-zero { + color: var(--cell-zero, #94a3b8); + opacity: 0.6; +} + +/* ===== Horizontal Pipeline Arrows ===== */ +.pipeline-arrow-h { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + padding: 0 4px; + min-width: 50px; + flex-shrink: 0; + align-self: center; + position: relative; +} + +.pipeline-arrow-h .arrow-icon { + font-size: 1.1rem; + font-weight: 700; + line-height: 1; +} + +.pipeline-arrow-h .arrow-label { + font-size: 0.58rem; + font-weight: 600; + padding: 1px 6px; + border-radius: 99px; + letter-spacing: 0.02em; + white-space: nowrap; + text-align: center; +} + +.arrow-encode .arrow-icon { + color: #8b5cf6; +} + +.arrow-encode .arrow-label { + background: rgba(139, 92, 246, 0.08); + color: #7c3aed; + border: 1px solid rgba(139, 92, 246, 0.15); +} + +.arrow-decode .arrow-icon { + color: #059669; +} + +.arrow-decode .arrow-label { + background: rgba(5, 150, 105, 0.08); + color: #047857; + border: 1px solid rgba(5, 150, 105, 0.15); +} + +/* Arrow tooltips */ +.pipeline-arrow-h .tooltip-content-small { + bottom: auto; + top: 100%; + margin-top: 4px; +} + +/* ===== Analysis Row (3 columns) ===== */ +.inspector-analysis-row { + display: flex; + gap: 16px; + padding: 12px 20px 16px; + border-top: 1px solid var(--border); + align-items: flex-start; + background: linear-gradient(180deg, rgba(0, 0, 0, 0.01), transparent); +} + +.analysis-section { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; +} + +/* Stats inline (below error grid) */ +.inspector-stats-inline { + display: flex; + gap: 4px; + margin-top: 6px; + flex-wrap: wrap; +} + +.inspector-stats-inline .stat-item { + flex: 1; + min-width: 50px; +} + +/* ===== Stat Items (shared) ===== */ +.stat-item { + display: flex; + flex-direction: column; + align-items: center; + background: rgba(0, 0, 0, 0.02); + border: 1px solid var(--border); + border-radius: 6px; + padding: 4px 6px; +} + +.stat-label { + font-size: 0.58rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.stat-value { + font-size: 0.82rem; + font-weight: 700; + color: var(--text); + font-family: 'SF Mono', 'Menlo', monospace; +} + +/* ===== QTable Grid Variant ===== */ +.qtable-grid { + border-color: rgba(217, 119, 6, 0.25); +} + +/* ===== Cross-Grid Highlight ===== */ +.grid-cell.cell-highlight { + outline: 2px solid #f59e0b; + outline-offset: -1px; + z-index: 3; + border-radius: 2px; +} + +/* ===== Basis Pattern Viewer ===== */ +.basis-viewer { + border: 1px solid rgba(139, 92, 246, 0.2); + border-radius: 10px; + background: linear-gradient(135deg, rgba(139, 92, 246, 0.03), rgba(59, 130, 246, 0.03)); + overflow: hidden; + animation: basisSlideIn 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes basisSlideIn { + from { + opacity: 0; + transform: translateY(-8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +.basis-viewer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid rgba(139, 92, 246, 0.1); + background: rgba(139, 92, 246, 0.04); +} + +.basis-viewer-title { + margin: 0; + font-size: 0.72rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 6px; + color: var(--text); +} + +.basis-close { + background: rgba(0, 0, 0, 0.04); + border: 1px solid transparent; + border-radius: 6px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + color: var(--text-muted); + font-size: 14px; + transition: all 0.2s; +} + +.basis-close:hover { + background: rgba(0, 0, 0, 0.08); + border-color: var(--border); + color: var(--text); +} + +.basis-viewer-body { + display: flex; + gap: 10px; + padding: 10px; + align-items: flex-start; + flex-wrap: wrap; +} + +.basis-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: 3px; +} + +.basis-panel-label { + font-size: 0.62rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.basis-panel-desc { + font-size: 0.55rem; + color: var(--text-muted); + opacity: 0.7; + text-align: center; + max-width: 120px; +} + +.basis-panel canvas { + border: 1px solid var(--border); + border-radius: 4px; + image-rendering: pixelated; + width: 100px; + height: 100px; +} + +.basis-info { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; + min-width: 80px; +} + +.basis-info .stat-item { + padding: 4px 8px; +} + +.basis-info .stat-value { + font-size: 0.82rem; +} + +/* ===== Responsive: Mobile (vertical) ===== */ +@media (max-width: 800px) { + .inspector-row { + flex-direction: column; + align-items: stretch; + gap: 4px; + } + + .pipeline-arrow-h { + flex-direction: row; + padding: 2px 0; + min-width: auto; + gap: 6px; + } + + .pipeline-arrow-h .arrow-icon { + font-size: 0.9rem; + transform: rotate(90deg); + } + + .inspector-analysis-row { + flex-direction: column; + gap: 12px; + } + + .inspector-stats-inline { + flex-wrap: wrap; + } + + .stat-item { + flex: 1; + min-width: 60px; + } + + .block-grid { + min-width: 100%; + } +} + +/* Scrollbar styling */ +.inspector-inline::-webkit-scrollbar { + width: 6px; +} + +.inspector-inline::-webkit-scrollbar-track { + background: transparent; +} + +.inspector-inline::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.12); + border-radius: 3px; +} + +.inspector-inline::-webkit-scrollbar-thumb:hover { + background: rgba(0, 0, 0, 0.2); +} + +/* ===== Block Zoom Row ===== */ +.inspector-zoom-row { + display: flex; + align-items: center; + justify-content: center; + gap: 16px; + padding: 14px 20px 18px; + border-top: 1px solid var(--border); + background: linear-gradient(180deg, rgba(0, 0, 0, 0.015), transparent); +} + +.zoom-panel { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; +} + +.zoom-label { + font-size: 0.68rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); +} + +.zoom-panel canvas { + width: 120px; + height: 120px; + image-rendering: pixelated; + image-rendering: crisp-edges; + border: 2px solid var(--border); + border-radius: 6px; + background: var(--viewer-bg); +} + +.zoom-vs { + font-size: 0.78rem; + font-weight: 700; + color: var(--text-muted); + padding: 0 4px; + align-self: center; + margin-top: 18px; +} + +@media (max-width: 800px) { + .inspector-zoom-row { + gap: 10px; + } + + .zoom-panel canvas { + width: 100px; + height: 100px; + } +} \ No newline at end of file diff --git a/web/public/css/layout.css b/web/public/css/layout.css new file mode 100644 index 0000000..440843b --- /dev/null +++ b/web/public/css/layout.css @@ -0,0 +1,24 @@ +.container { + width: 100%; + max-width: 1280px; + display: grid; + grid-template-columns: 1fr 320px; + /* Sidebar layout */ + gap: 24px; + align-items: start; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 4px 6px -1px var(--shadow-color), 0 2px 4px -1px var(--shadow-color); + padding: 12px; +} + +@media (max-width: 768px) { + .container { + grid-template-columns: 1fr; + /* Stack vertically */ + } +} \ No newline at end of file diff --git a/web/public/css/onboarding.css b/web/public/css/onboarding.css new file mode 100644 index 0000000..b86822e --- /dev/null +++ b/web/public/css/onboarding.css @@ -0,0 +1,64 @@ +/* ===== Onboarding / Drop Zone ===== */ +.drop-zone { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 60px 32px; + border: 2px dashed var(--border); + border-radius: 16px; + background: var(--bg-secondary); + cursor: pointer; + transition: all 0.3s ease; + text-align: center; + min-height: 280px; +} + +.drop-zone:hover, +.drop-zone.drag-over { + border-color: var(--primary); + background: var(--primary-subtle); +} + +.drop-zone.drag-over { + transform: scale(1.01); + box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1); +} + +.drop-zone-icon { + width: 64px; + height: 64px; + color: var(--text-muted); + opacity: 0.6; + transition: all 0.3s ease; +} + +.drop-zone:hover .drop-zone-icon, +.drop-zone.drag-over .drop-zone-icon { + color: var(--primary); + opacity: 1; + transform: translateY(-4px); +} + +.drop-zone-text { + font-size: 1rem; + font-weight: 600; + color: var(--text); +} + +.drop-zone-hint { + font-size: 0.8rem; + color: var(--text-muted); +} + +.drop-zone-hint .browse-link { + color: var(--primary); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 2px; +} + +.drop-zone input[type="file"] { + display: none; +} \ No newline at end of file diff --git a/web/public/css/style.css b/web/public/css/style.css new file mode 100644 index 0000000..98ed9fd --- /dev/null +++ b/web/public/css/style.css @@ -0,0 +1,9 @@ +@import 'variables.css'; +@import 'base.css'; +@import 'layout.css'; +@import 'header.css'; +@import 'components.css'; +@import 'viewer.css'; +@import 'utilities.css'; +@import 'inspection.css'; +@import 'onboarding.css'; \ No newline at end of file diff --git a/web/public/css/utilities.css b/web/public/css/utilities.css new file mode 100644 index 0000000..70a5702 --- /dev/null +++ b/web/public/css/utilities.css @@ -0,0 +1,72 @@ +.tooltip-container { + position: relative; + display: flex; + align-items: center; + cursor: help; +} + +.info-icon { + display: flex; + align-items: center; + color: var(--text-muted); + transition: color 0.2s; +} + +.info-icon:hover { + color: var(--primary); +} + +.tooltip-content { + position: absolute; + bottom: 125%; + right: 0; + transform: translateY(10px); + width: 280px; + background-color: var(--tooltip-bg); + color: var(--tooltip-text); + padding: 10px 14px; + border-radius: 8px; + font-size: 0.75rem; + line-height: 1.4; + font-weight: normal; + text-align: left; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + opacity: 0; + visibility: hidden; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 1000; + pointer-events: none; +} + +.tooltip-content::after { + content: ""; + position: absolute; + top: 100%; + right: 6px; + border-width: 5px; + border-style: solid; + border-color: var(--tooltip-bg) transparent transparent transparent; +} + +.tooltip-container:hover .tooltip-content { + opacity: 1; + visibility: visible; + transform: translateY(0); +} + +@media (max-width: 350px) { + .tooltip-content { + width: 180px; + left: auto; + right: 0; + transform: translateY(10px); + } + + .tooltip-container:hover .tooltip-content { + transform: translateY(0); + } + + .tooltip-content::after { + left: 90%; + } +} \ No newline at end of file diff --git a/web/public/css/variables.css b/web/public/css/variables.css new file mode 100644 index 0000000..d718a0a --- /dev/null +++ b/web/public/css/variables.css @@ -0,0 +1,70 @@ +:root { + --primary: #2563eb; + --primary-hover: #1d4ed8; + --primary-subtle: rgba(37, 99, 235, 0.06); + + --bg: #f8fafc; + --bg-secondary: #f1f5f9; + --bg-tertiary: #e2e8f0; + + --card-bg: #ffffff; + --text: #1e293b; + --text-muted: #64748b; + --border: #e2e8f0; + + --success-bg: #dcfce7; + --success-text: #166534; + --success-color: #16a34a; + + --toggle-bg: #e2e8f0; + --toggle-active-bg: #ffffff; + --toggle-label: #64748b; + + --tooltip-bg: #1e293b; + --tooltip-text: #ffffff; + + --viewer-bg: #0f172a; + --shadow-color: rgba(0, 0, 0, 0.1); + + /* Inspector cell colors */ + --cell-positive: #ef4444; + --cell-negative: #3b82f6; + --cell-zero: #94a3b8; + + /* Fonts */ + --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + --font-mono: 'JetBrains Mono', 'SF Mono', 'Menlo', 'Monaco', monospace; +} + +[data-theme="dark"] { + --primary: #3b82f6; + --primary-hover: #60a5fa; + --primary-subtle: rgba(59, 130, 246, 0.1); + + --bg: #0f172a; + --bg-secondary: #1e293b; + --bg-tertiary: #334155; + + --card-bg: #1e293b; + --text: #e2e8f0; + --text-muted: #94a3b8; + --border: #334155; + + --success-bg: rgba(22, 163, 74, 0.15); + --success-text: #4ade80; + --success-color: #4ade80; + + --toggle-bg: #334155; + --toggle-active-bg: #475569; + --toggle-label: #94a3b8; + + --tooltip-bg: #334155; + --tooltip-text: #e2e8f0; + + --viewer-bg: #020617; + --shadow-color: rgba(0, 0, 0, 0.4); + + --cell-positive: #f87171; + --cell-negative: #60a5fa; + --cell-zero: #64748b; +} \ No newline at end of file diff --git a/web/public/css/viewer.css b/web/public/css/viewer.css new file mode 100644 index 0000000..a3dc9a9 --- /dev/null +++ b/web/public/css/viewer.css @@ -0,0 +1,115 @@ +canvas { + max-width: 100%; + height: auto; + display: block; +} + +.canvas-wrapper { + background: var(--viewer-bg); + border-radius: 12px; + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + transition: background-color 0.3s ease; +} + +.viewer-info { + text-align: center; + font-size: 0.85rem; + color: var(--text-muted); + user-select: none; +} + +.comparison-viewer { + position: relative; + width: 100%; + line-height: 0; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5); + cursor: col-resize; +} + +.comparison-viewer canvas { + display: block; + width: 100%; + height: auto; +} + +#processedCanvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + clip-path: polygon(50% 0, 100% 0, 100% 100%, 50% 100%); +} + +/* Slider */ +.slider-container { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px; +} + +.slider-container.disabled { + opacity: 0.5; + pointer-events: none; + filter: grayscale(100%); +} + +.slider-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + width: 80px; +} + +.slider-label:first-child { + text-align: right; +} + +.slider-label:last-child { + text-align: left; + color: var(--primary); +} + +.comparison-slider-input { + flex: 1; + -webkit-appearance: none; + appearance: none; + height: 6px; + background: linear-gradient(to right, var(--text-muted) 0%, var(--primary) 100%); + border-radius: 3px; + outline: none; + cursor: pointer; +} + +.comparison-slider-input::-webkit-slider-thumb { + -webkit-appearance: none; + width: 20px; + height: 20px; + background: white; + border: 2px solid var(--primary); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.1s; +} + +.comparison-slider-input::-webkit-slider-thumb:hover { + transform: scale(1.2); +} + +.comparison-slider-input::-moz-range-thumb { + width: 20px; + height: 20px; + background: white; + border: 2px solid var(--primary); + border-radius: 50%; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + transition: transform 0.1s; +} \ No newline at end of file diff --git a/web/public/index.html b/web/public/index.html index 8d6946e..b880875 100644 --- a/web/public/index.html +++ b/web/public/index.html @@ -26,25 +26,64 @@ - + + + + + + + + +

WASM Codec Explorer

-
- - - - - Processed locally on your device +
+
+ + + + + Processed locally on your device +
+ + GitHub Logo + View Source on GitHub + +
- - GitHub Logo - View Source on GitHub -
@@ -68,8 +107,16 @@

WASM Codec Explorer

-
- + +
+ + + + + +
Drop an image here
+
or browse to upload
@@ -77,6 +124,20 @@

WASM Codec Explorer

+
+ + + +
@@ -126,6 +187,21 @@

WASM Codec Explorer

+
+ +
+ + +
+
+
@@ -168,11 +244,358 @@

WASM Codec Explorer

+ + +
+ + +
- + diff --git a/web/public/js/image-manager.js b/web/public/js/image-manager.js new file mode 100644 index 0000000..dd32e7b --- /dev/null +++ b/web/public/js/image-manager.js @@ -0,0 +1,44 @@ +import { state } from './state.js'; +import { initSession } from './wasm-bridge.js'; + +export function handleFileSelect(file, onImageLoaded) { + if (!file) return; + + const img = new Image(); + img.onload = () => { + let scale = 1.0; + if (img.width > state.maxDim || img.height > state.maxDim) { + scale = Math.min(state.maxDim / img.width, state.maxDim / img.height); + } + + state.imgWidth = Math.floor(img.width * scale); + state.imgHeight = Math.floor(img.height * scale); + + const originalCanvas = document.getElementById('originalCanvas'); + const processedCanvas = document.getElementById('processedCanvas'); + + // Resize canvases + originalCanvas.width = state.imgWidth; + originalCanvas.height = state.imgHeight; + processedCanvas.width = state.imgWidth; + processedCanvas.height = state.imgHeight; + + const ctx = originalCanvas.getContext('2d'); + ctx.drawImage(img, 0, 0, state.imgWidth, state.imgHeight); + state.originalImageData = ctx.getImageData(0, 0, state.imgWidth, state.imgHeight); + + // Initialize WASM session with new image + initSession(); + + if (onImageLoaded) onImageLoaded(); + }; + img.src = URL.createObjectURL(file); +} + +export function getOriginalContext() { + return document.getElementById('originalCanvas').getContext('2d'); +} + +export function getProcessedContext() { + return document.getElementById('processedCanvas').getContext('2d', { willReadFrequently: true }); +} diff --git a/web/public/js/inspection.js b/web/public/js/inspection.js new file mode 100644 index 0000000..6e66dbe --- /dev/null +++ b/web/public/js/inspection.js @@ -0,0 +1,408 @@ +import { state, ViewMode } from './state.js'; +import { inspectBlockData } from './wasm-bridge.js'; + +// Store grid data globally for cross-grid highlighting and basis viewer +let cachedGridData = {}; +const ALL_GRID_IDS = [ + 'gridOriginal', 'gridDCT', 'gridQuantized', + 'gridQuantized2', 'gridDequantized', 'gridReconstructed', + 'gridQuantTable', 'gridError' +]; + +export function inspectBlock(blockX, blockY) { + // Track which block is being inspected + state.inspectedBlock = { x: blockX, y: blockY }; + + // Show inspector content, hide placeholder + const content = document.getElementById('inspectorContent'); + const placeholder = document.getElementById('inspectorPlaceholder'); + if (content) content.style.display = 'block'; + if (placeholder) placeholder.style.display = 'none'; + + // Hide basis viewer when inspecting a new block + const basisViewer = document.getElementById('basisViewer'); + if (basisViewer) basisViewer.style.display = 'none'; + const basisPlaceholder = document.getElementById('basisPlaceholder'); + if (basisPlaceholder) basisPlaceholder.style.display = 'flex'; + + const coordsSpan = document.getElementById('blockCoords'); + const qTableType = document.getElementById('qTableType'); + const qTableType2 = document.getElementById('qTableType2'); + + if (coordsSpan) coordsSpan.innerText = `${blockX * 8}, ${blockY * 8} (Block ${blockX},${blockY})`; + + let channelIndex = 0; // Default Y + if (state.currentViewMode === ViewMode.Cr) channelIndex = 1; + if (state.currentViewMode === ViewMode.Cb) channelIndex = 2; + + const tableLabel = (channelIndex === 0) ? "Luma" : "Chroma"; + if (qTableType) qTableType.innerText = tableLabel; + if (qTableType2) qTableType2.innerText = tableLabel; + + const qualitySlider = document.getElementById('qualitySlider'); + const quality = qualitySlider ? parseInt(qualitySlider.value) : 50; + + const ptr = inspectBlockData(blockX, blockY, channelIndex, quality); + if (!ptr) { + console.error("Failed to inspect block: Ptr is null"); + return; + } + + const blockSize = 64; + const readGrid = (offsetIdx) => { + const startBytes = ptr + (offsetIdx * blockSize * 8); + const data = []; + const dataView = new DataView(Module.HEAPU8.buffer); + + try { + for (let i = 0; i < blockSize; ++i) { + const val = dataView.getFloat64(startBytes + (i * 8), true); + data.push(val); + } + } catch (e) { + console.error("Error reading grid data:", e); + } + return data; + }; + + const originalData = readGrid(0); + const dctData = readGrid(1); + const qtData = readGrid(2); + const quantData = readGrid(3); + const reconData = readGrid(4); + + // Compute derived data + const errorData = new Float64Array(64); + for (let i = 0; i < 64; i++) { + errorData[i] = originalData[i] - reconData[i]; + } + + const dequantizedData = []; + for (let i = 0; i < 64; i++) { + dequantizedData.push(quantData[i] * qtData[i]); + } + + // Cache the data for basis viewer + cachedGridData = { dctData, qtData, quantData, dequantizedData }; + + // Compute summary stats + let mse = 0; + let peakError = 0; + let zeroCount = 0; + + for (let i = 0; i < 64; i++) { + mse += errorData[i] * errorData[i]; + peakError = Math.max(peakError, Math.abs(errorData[i])); + if (Math.abs(quantData[i]) < 0.5) zeroCount++; + } + mse /= 64; + + // Update stats display + const setStatText = (id, text) => { + const el = document.getElementById(id); + if (el) el.innerText = text; + }; + + setStatText('statMSE', mse.toFixed(2)); + setStatText('statPeakError', peakError.toFixed(1)); + setStatText('statZeros', `${zeroCount} / 64`); + setStatText('statCompression', `${Math.round((zeroCount / 64) * 100)}%`); + + // Render grids in pipeline order + renderGrid('gridOriginal', originalData, 'intensity', 'original'); + renderGrid('gridDCT', dctData, 'frequency', 'dct'); + renderGrid('gridQuantized', quantData, 'frequency', 'quantized'); + + renderGrid('gridQuantized2', quantData, 'frequency', 'quantized'); + renderGrid('gridDequantized', dequantizedData, 'frequency', 'dequantized'); + renderGrid('gridReconstructed', reconData, 'intensity', 'reconstructed'); + + renderGrid('gridQuantTable', qtData, 'qtable', 'qtable'); + renderGrid('gridError', errorData, 'error', 'error'); + + // Render zoom canvases (pixel-art enlarged previews) + renderZoomCanvas('zoomOriginal', originalData); + renderZoomCanvas('zoomReconstructed', reconData); + + // Setup basis viewer close button + const basisClose = document.querySelector('.basis-close'); + if (basisClose) { + basisClose.onclick = () => { + if (basisViewer) basisViewer.style.display = 'none'; + clearAllHighlights(); + }; + } +} + +function getCellDescription(row, col, gridType) { + if (gridType === 'dct' || gridType === 'dequantized' || gridType === 'quantized') { + if (row === 0 && col === 0) return 'DC coefficient (average brightness)'; + const freqLevel = row + col; + if (freqLevel <= 2) return 'Low frequency'; + if (freqLevel <= 5) return 'Mid frequency'; + return 'High frequency'; + } + if (gridType === 'original' || gridType === 'reconstructed') { + return 'Pixel intensity'; + } + if (gridType === 'error') { + return 'Error value'; + } + if (gridType === 'qtable') { + return 'Divisor'; + } + return ''; +} + +function getFreqLabel(row, col) { + if (row === 0 && col === 0) return 'DC'; + const level = row + col; + if (level <= 2) return 'Low Freq'; + if (level <= 5) return 'Mid Freq'; + return 'High Freq'; +} + +// ===== Cross-Grid Highlighting ===== +function clearAllHighlights() { + document.querySelectorAll('.grid-cell.cell-highlight').forEach(c => { + c.classList.remove('cell-highlight'); + }); +} + +function highlightAcrossGrids(row, col) { + clearAllHighlights(); + ALL_GRID_IDS.forEach(gridId => { + const grid = document.getElementById(gridId); + if (!grid) return; + const idx = row * 8 + col; + const cell = grid.children[idx]; + if (cell) cell.classList.add('cell-highlight'); + }); +} + +// ===== DCT Basis Pattern Computation ===== +function computeBasisPattern(u, v) { + const N = 8; + const pattern = new Float64Array(64); + const cu = (u === 0) ? 1 / Math.sqrt(2) : 1; + const cv = (v === 0) ? 1 / Math.sqrt(2) : 1; + + for (let y = 0; y < N; y++) { + for (let x = 0; x < N; x++) { + pattern[y * N + x] = (cu * cv / 4) * + Math.cos((2 * x + 1) * u * Math.PI / (2 * N)) * + Math.cos((2 * y + 1) * v * Math.PI / (2 * N)); + } + } + return pattern; +} + +function drawPatternOnCanvas(canvasId, data, mode) { + const canvas = document.getElementById(canvasId); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + const size = canvas.width; + const cellSize = size / 8; + + ctx.clearRect(0, 0, size, size); + + // Find min/max for normalization + let min = Infinity, max = -Infinity; + for (let i = 0; i < 64; i++) { + min = Math.min(min, data[i]); + max = Math.max(max, data[i]); + } + const range = Math.max(Math.abs(min), Math.abs(max)) || 1; + + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x++) { + const val = data[y * 8 + x]; + + if (mode === 'diverging') { + // Red-white-blue diverging colormap + const t = val / range; // -1 to 1 + let r, g, b; + if (t >= 0) { + // White to Red + r = 255; + g = Math.round(255 * (1 - t)); + b = Math.round(255 * (1 - t)); + } else { + // White to Blue + r = Math.round(255 * (1 + t)); + g = Math.round(255 * (1 + t)); + b = 255; + } + ctx.fillStyle = `rgb(${r}, ${g}, ${b})`; + } else { + // Grayscale + const norm = Math.round(((val - min) / (max - min || 1)) * 255); + ctx.fillStyle = `rgb(${norm}, ${norm}, ${norm})`; + } + + ctx.fillRect(x * cellSize, y * cellSize, cellSize, cellSize); + } + } + + // Draw grid lines + ctx.strokeStyle = 'rgba(0,0,0,0.08)'; + ctx.lineWidth = 0.5; + for (let i = 1; i < 8; i++) { + ctx.beginPath(); + ctx.moveTo(i * cellSize, 0); + ctx.lineTo(i * cellSize, size); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(0, i * cellSize); + ctx.lineTo(size, i * cellSize); + ctx.stroke(); + } +} + +function showBasisViewer(row, col) { + const viewer = document.getElementById('basisViewer'); + if (!viewer || !cachedGridData.dctData) return; + + const idx = row * 8 + col; + const dctVal = cachedGridData.dctData[idx]; + const quantVal = cachedGridData.quantData[idx]; + const qtVal = cachedGridData.qtData[idx]; + + // Update labels + const setEl = (id, text) => { + const el = document.getElementById(id); + if (el) el.innerText = text; + }; + + setEl('basisCoord', `(${row}, ${col})`); + setEl('basisFreqLabel', getFreqLabel(row, col)); + setEl('basisValue', dctVal.toFixed(2)); + setEl('basisQuantized', Math.round(quantVal).toString()); + setEl('basisDivisor', Math.round(qtVal).toString()); + + // Compute basis pattern for position (row, col) + // In DCT, row = v (vertical freq), col = u (horizontal freq) + const basisPattern = computeBasisPattern(col, row); + + // Contribution = coefficient * basis + const contribution = new Float64Array(64); + for (let i = 0; i < 64; i++) { + contribution[i] = dctVal * basisPattern[i]; + } + + // Draw canvases + drawPatternOnCanvas('basisCanvas', Array.from(basisPattern), 'diverging'); + drawPatternOnCanvas('contributionCanvas', Array.from(contribution), 'diverging'); + + // Show viewer, hide placeholder + viewer.style.display = 'block'; + const basisPlaceholder = document.getElementById('basisPlaceholder'); + if (basisPlaceholder) basisPlaceholder.style.display = 'none'; + + // Scroll viewer into view smoothly + viewer.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function renderGrid(elementId, data, type = 'number', gridType = '') { + const el = document.getElementById(elementId); + if (!el) return; + el.innerHTML = ''; + + for (let i = 0; i < 64; ++i) { + const val = data[i]; + const row = Math.floor(i / 8); + const col = i % 8; + const cell = document.createElement('div'); + cell.className = 'grid-cell'; + cell.dataset.row = row; + cell.dataset.col = col; + + let displayVal = val; + if (type === 'intensity' || type === 'qtable') { + displayVal = Math.round(val); + } else { + displayVal = val.toFixed(1); + if (val === 0) displayVal = "0"; + if (displayVal === "-0.0") displayVal = "0"; + } + + cell.innerText = displayVal; + + // Build tooltip + const desc = getCellDescription(row, col, gridType); + cell.title = `(${row}, ${col}) = ${val.toFixed(4)}${desc ? '\n' + desc : ''}`; + + // Cross-grid highlighting on hover + cell.addEventListener('mouseenter', () => { + highlightAcrossGrids(row, col); + }); + + // Click on frequency-domain cells opens basis viewer + if (gridType === 'dct' || gridType === 'quantized' || gridType === 'dequantized') { + cell.style.cursor = 'pointer'; + cell.addEventListener('click', (e) => { + e.stopPropagation(); + highlightAcrossGrids(row, col); + showBasisViewer(row, col); + }); + } + + // Apply cell coloring + if (type === 'intensity') { + const norm = Math.max(0, Math.min(255, val)); + cell.style.backgroundColor = `rgb(${norm}, ${norm}, ${norm})`; + cell.style.color = norm > 128 ? '#1e293b' : '#f1f5f9'; + } else if (type === 'qtable') { + const maxQt = 200; + const t = Math.min(1, val / maxQt); + const r = Math.round(255); + const g = Math.round(255 - t * 110); + const b = Math.round(255 - t * 200); + cell.style.backgroundColor = `rgb(${r}, ${g}, ${b})`; + cell.style.color = t > 0.5 ? '#7c2d12' : '#78350f'; + } else if (type === 'frequency' || type === 'error') { + if (Math.abs(val) < 0.5) { + cell.classList.add('cell-zero'); + } else { + const isPos = val > 0; + const visualMax = (type === 'error') ? 30 : 100; + let opacity = Math.min(1, Math.abs(val) / visualMax); + opacity = Math.max(0.1, opacity); + + if (isPos) { + cell.style.backgroundColor = `rgba(239, 68, 68, ${opacity})`; + cell.style.color = opacity > 0.5 ? '#fff' : 'var(--text)'; + } else { + cell.style.backgroundColor = `rgba(59, 130, 246, ${opacity})`; + cell.style.color = opacity > 0.5 ? '#fff' : 'var(--text)'; + } + } + } + el.appendChild(cell); + } + + // Clear highlights when mouse leaves a grid + el.addEventListener('mouseleave', () => { + clearAllHighlights(); + }); +} + +function renderZoomCanvas(canvasId, data) { + const canvas = document.getElementById(canvasId); + if (!canvas) return; + const ctx = canvas.getContext('2d'); + + // Canvas is 8ร—8 native, CSS scales it up with pixelated rendering + ctx.clearRect(0, 0, 8, 8); + const imgData = ctx.createImageData(8, 8); + + for (let i = 0; i < 64; i++) { + const v = Math.max(0, Math.min(255, Math.round(data[i]))); + imgData.data[i * 4 + 0] = v; // R + imgData.data[i * 4 + 1] = v; // G + imgData.data[i * 4 + 2] = v; // B + imgData.data[i * 4 + 3] = 255; // A + } + + ctx.putImageData(imgData, 0, 0); +} diff --git a/web/public/js/main.js b/web/public/js/main.js new file mode 100644 index 0000000..9d01ff3 --- /dev/null +++ b/web/public/js/main.js @@ -0,0 +1,151 @@ +import { state } from './state.js'; +import { setupWasm, getViewPtr, getStats, free, processImage } from './wasm-bridge.js'; +import { handleFileSelect, getProcessedContext, getOriginalContext } from './image-manager.js'; +import { setupControls, updateComparisonView, updateFileSizeEstimate } from './ui-controls.js'; + +// DOM Elements +const statusDiv = document.getElementById('status'); +const fileInput = document.getElementById('fileInput'); + +function onWasmReady() { + state.wasmReady = true; + if (statusDiv) { + statusDiv.innerText = "WASM Module Ready!"; + statusDiv.classList.add('ready'); + } + + // Enable controls + const controls = document.querySelectorAll('input:disabled, button:disabled'); + controls.forEach(el => el.disabled = false); + + console.log("WASM Runtime Initialized (Module)"); +} + +function render() { + if (!state.wasmReady || !state.originalImageData) return; + + const rgbaSize = state.imgWidth * state.imgHeight * 4; + let outputPtr = 0; + try { + outputPtr = getViewPtr(state.currentViewMode); + if (!outputPtr) throw new Error("WASM get_view_ptr returned null"); + + // Create ImageData from WASM memory + // outputPtr is simple offset in HEAPU8 + const dataView = new Uint8ClampedArray(Module.HEAPU8.buffer, outputPtr, rgbaSize); + const imageData = new ImageData(dataView, state.imgWidth, state.imgHeight); + + const ctx = getProcessedContext(); + ctx.putImageData(imageData, 0, 0); + + // Update Stats + const stats = getStats(); + + const setText = (id, val) => { + const el = document.getElementById(id); + if (el) el.textContent = val; + }; + + setText('psnrY', stats.psnr.y.toFixed(2)); + setText('psnrCr', stats.psnr.cr.toFixed(2)); + setText('psnrCb', stats.psnr.cb.toFixed(2)); + + setText('ssimY', stats.ssim.y.toFixed(4)); + setText('ssimCr', stats.ssim.cr.toFixed(4)); + setText('ssimCb', stats.ssim.cb.toFixed(4)); + + // Highlight Logic + const origCtx = getOriginalContext(); + if (origCtx) { + // Always redraw original to clear artifacts/highlights + origCtx.putImageData(state.originalImageData, 0, 0); + + if (state.isInspectMode && state.highlightBlock) { + const bx = state.highlightBlock.x * 8; + const by = state.highlightBlock.y * 8; + origCtx.strokeStyle = "#ff0000"; + origCtx.lineWidth = 1; + origCtx.strokeRect(bx, by, 8, 8); + } + } + + } catch (err) { + console.error("Render error:", err); + } finally { + if (outputPtr) free(outputPtr); + } +} + +// Initial Setup +setupWasm(onWasmReady); + +// ===== Theme Toggle ===== +const themeToggle = document.getElementById('themeToggle'); +function updateThemeIcons() { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + const sunIcon = themeToggle?.querySelector('.icon-sun'); + const moonIcon = themeToggle?.querySelector('.icon-moon'); + if (sunIcon) sunIcon.style.display = isDark ? 'none' : 'block'; + if (moonIcon) moonIcon.style.display = isDark ? 'block' : 'none'; +} +updateThemeIcons(); // sync on load + +if (themeToggle) { + themeToggle.addEventListener('click', () => { + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + if (isDark) { + document.documentElement.removeAttribute('data-theme'); + localStorage.setItem('theme', 'light'); + } else { + document.documentElement.setAttribute('data-theme', 'dark'); + localStorage.setItem('theme', 'dark'); + } + updateThemeIcons(); + }); +} + +// ===== Drop Zone / File Input ===== +const dropZone = document.getElementById('dropZone'); + +function loadFile(file) { + if (!file || !file.type.startsWith('image/')) return; + handleFileSelect(file, () => { + processImage(50, state.currentCsMode); + updateComparisonView(50); + render(); + updateFileSizeEstimate(); + // Hide drop zone after successful load + if (dropZone) dropZone.style.display = 'none'; + }); +} + +if (fileInput) { + fileInput.addEventListener('change', (e) => { + loadFile(e.target.files[0]); + }); +} + +if (dropZone) { + // Click drop zone to trigger file input + dropZone.addEventListener('click', () => { + if (fileInput) fileInput.click(); + }); + + // Drag events + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('drag-over'); + }); + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('drag-over'); + }); + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('drag-over'); + const file = e.dataTransfer?.files[0]; + loadFile(file); + }); +} + +// Setup UI Controls +setupControls(render); diff --git a/web/public/js/state.js b/web/public/js/state.js new file mode 100644 index 0000000..271b193 --- /dev/null +++ b/web/public/js/state.js @@ -0,0 +1,21 @@ +export const ViewMode = { + RGB: 0, + Artifacts: 1, + Y: 2, + Cr: 3, + Cb: 4 +}; + +export const state = { + originalImageData: null, + imgWidth: 0, + imgHeight: 0, + currentViewMode: ViewMode.RGB, + currentCsMode: 444, // 4:4:4 + maxDim: 1024, + wasmReady: false, + isInspectMode: false, + highlightBlock: null, + inspectedBlock: null, // { x, y } of currently inspected block + isDragging: false +}; diff --git a/web/public/js/ui-controls.js b/web/public/js/ui-controls.js new file mode 100644 index 0000000..2dfcfbe --- /dev/null +++ b/web/public/js/ui-controls.js @@ -0,0 +1,336 @@ +import { state, ViewMode } from './state.js'; +import { processImage, setViewTint, inspectBlockData } from './wasm-bridge.js'; +import { inspectBlock } from './inspection.js'; +import { getOriginalContext } from './image-manager.js'; + +export function setupControls(renderCallback) { + const qualitySlider = document.getElementById('qualitySlider'); + const qualityValue = document.getElementById('qualityValue'); + const viewModeRadios = document.querySelectorAll('input[name="view_mode"]'); + const csRadios = document.querySelectorAll('input[name="chroma_subsampling"]'); + const tintToggle = document.getElementById('tint_toggle'); + const comparisonSlider = document.getElementById('comparisonSlider'); + const comparisonViewer = document.querySelector('.comparison-viewer'); + const inspectToggle = document.getElementById('inspect_mode'); + const sliderContainer = document.querySelector('.slider-container'); + const inspectionPanel = document.getElementById('inspectionPanel'); + const inspectorContent = document.getElementById('inspectorContent'); + const inspectorPlaceholder = document.getElementById('inspectorPlaceholder'); + + // Debounce helper + function debounce(func, wait) { + let timeout; + return function executedFunction(...args) { + const later = () => { + clearTimeout(timeout); + func(...args); + }; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + }; + } + + // Re-inspect the current block after settings change + function reinspectIfNeeded() { + if (state.isInspectMode && state.inspectedBlock && state.wasmReady) { + inspectBlock(state.inspectedBlock.x, state.inspectedBlock.y); + } + } + + const debouncedUpdate = debounce(() => { + if (!state.wasmReady || !state.originalImageData) return; + processImage(parseInt(qualitySlider.value), state.currentCsMode); + renderCallback(); + reinspectIfNeeded(); + updateFileSizeEstimate(); + }, 150); + + if (qualitySlider) { + qualitySlider.addEventListener('input', () => { + qualityValue.textContent = qualitySlider.value; + updatePresetHighlight(parseInt(qualitySlider.value)); + if (state.originalImageData) debouncedUpdate(); + }); + } + + // Quality Presets + const presetBtns = document.querySelectorAll('.preset-btn'); + presetBtns.forEach(btn => { + btn.addEventListener('click', () => { + const q = parseInt(btn.dataset.quality); + if (qualitySlider) { + qualitySlider.value = q; + qualityValue.textContent = q; + } + updatePresetHighlight(q); + if (state.originalImageData) debouncedUpdate(); + }); + }); + + function updatePresetHighlight(quality) { + presetBtns.forEach(btn => { + const bq = parseInt(btn.dataset.quality); + btn.classList.toggle('active', bq === quality); + }); + } + + viewModeRadios.forEach(radio => { + radio.addEventListener('change', (e) => { + switch (e.target.value) { + case 'artifacts': state.currentViewMode = ViewMode.Artifacts; break; + case 'y': state.currentViewMode = ViewMode.Y; break; + case 'cr': state.currentViewMode = ViewMode.Cr; break; + case 'cb': state.currentViewMode = ViewMode.Cb; break; + default: state.currentViewMode = ViewMode.RGB; break; + } + if (state.originalImageData) { + renderCallback(); + reinspectIfNeeded(); + } + }); + }); + + csRadios.forEach(radio => { + radio.addEventListener('change', (e) => { + state.currentCsMode = parseInt(e.target.value, 10); + if (state.originalImageData) debouncedUpdate(); + }); + }); + + if (tintToggle) { + tintToggle.addEventListener('change', (e) => { + if (state.wasmReady) { + setViewTint(e.target.checked ? 1 : 0); + if (state.originalImageData) { + renderCallback(); + reinspectIfNeeded(); + } + } + }); + } + + if (inspectToggle) { + inspectToggle.addEventListener('change', (e) => { + state.isInspectMode = e.target.checked; + if (comparisonViewer) { + comparisonViewer.style.cursor = state.isInspectMode ? 'crosshair' : 'col-resize'; + } + + if (sliderContainer) { + if (state.isInspectMode) { + sliderContainer.classList.add('disabled'); + if (comparisonSlider) comparisonSlider.disabled = true; + } else { + sliderContainer.classList.remove('disabled'); + if (comparisonSlider) comparisonSlider.disabled = false; + } + } + + // Show/hide inline inspector panel + if (inspectionPanel) { + if (state.isInspectMode) { + inspectionPanel.style.display = 'block'; + // Reset to placeholder until a block is clicked + if (inspectorContent) inspectorContent.style.display = 'none'; + if (inspectorPlaceholder) inspectorPlaceholder.style.display = 'flex'; + } else { + inspectionPanel.style.display = 'none'; + } + } + + if (!state.isInspectMode) { + state.highlightBlock = null; + state.inspectedBlock = null; + } + renderCallback(); + }); + } + + // Comparison Viewer Interaction + if (comparisonViewer && comparisonSlider) { + // Slider update + comparisonSlider.addEventListener('input', (e) => { + updateComparisonView(e.target.value); + }); + + // Mouse/Touch interaction + const handleInteraction = (clientX) => { + const rect = comparisonViewer.getBoundingClientRect(); + const x = clientX - rect.left; + const percent = (x / rect.width) * 100; + updateComparisonView(percent); + }; + + comparisonViewer.addEventListener('mousedown', (e) => { + if (state.isInspectMode) return; + state.isDragging = true; + comparisonViewer.style.cursor = 'grabbing'; + handleInteraction(e.clientX); + }); + + document.addEventListener('mouseup', () => { + state.isDragging = false; + if (comparisonViewer && !state.isInspectMode) comparisonViewer.style.cursor = 'col-resize'; + }); + + document.addEventListener('mouseleave', () => { state.isDragging = false; }); + + document.addEventListener('mousemove', (e) => { + if (state.isDragging) handleInteraction(e.clientX); + + // Highlight Logic + if (state.isInspectMode && state.originalImageData && !state.isDragging) { + const rect = comparisonViewer.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // Scale to image coords + const originalCanvas = document.getElementById('originalCanvas'); // use dimensions from here + const scaleX = state.imgWidth / rect.width; + const scaleY = state.imgHeight / rect.height; + + const imgX = Math.floor(x * scaleX); + const imgY = Math.floor(y * scaleY); + + if (imgX < 0 || imgX >= state.imgWidth || imgY < 0 || imgY >= state.imgHeight) { + state.highlightBlock = null; + } else { + const blockX = Math.floor(imgX / 8); + const blockY = Math.floor(imgY / 8); + state.highlightBlock = { x: blockX, y: blockY }; + } + renderCallback(); // Re-render to show highlight + } + }); + + comparisonViewer.addEventListener('mouseleave', () => { + state.highlightBlock = null; + if (state.isInspectMode) renderCallback(); + }); + + comparisonViewer.addEventListener('click', (e) => { + if (state.isInspectMode && state.highlightBlock && state.wasmReady) { + inspectBlock(state.highlightBlock.x, state.highlightBlock.y); + } + }); + } + + // No close button needed for inline inspector +} + +export function updateComparisonView(percent) { + const comparisonSlider = document.getElementById('comparisonSlider'); + const processedCanvas = document.getElementById('processedCanvas'); + + const clampedPercent = Math.max(0, Math.min(100, percent)); + + if (comparisonSlider) comparisonSlider.value = clampedPercent; + if (processedCanvas) { + processedCanvas.style.clipPath = `polygon(${clampedPercent}% 0, 100% 0, 100% 100%, ${clampedPercent}% 100%)`; + } +} + +// ===== File Size Estimation ===== +export function updateFileSizeEstimate() { + if (!state.wasmReady || !state.originalImageData) return; + + const container = document.getElementById('fileSizeContainer'); + if (!container) return; + container.style.display = 'block'; + + const w = state.imgWidth; + const h = state.imgHeight; + const totalPixels = w * h; + + // Original: uncompressed RGB bytes + const originalBytes = totalPixels * 3; + + // Sample random blocks to estimate zero ratio + const blocksX = Math.floor(w / 8); + const blocksY = Math.floor(h / 8); + const totalBlocks = blocksX * blocksY; + + if (totalBlocks === 0) return; + + const qualitySlider = document.getElementById('qualitySlider'); + const quality = qualitySlider ? parseInt(qualitySlider.value) : 50; + + // Sample up to 16 blocks + const sampleCount = Math.min(16, totalBlocks); + let totalZeros = 0; + const sampledIndices = new Set(); + + // Use deterministic sampling (evenly spaced) for consistency + for (let i = 0; i < sampleCount; i++) { + const idx = Math.floor((i / sampleCount) * totalBlocks); + if (sampledIndices.has(idx)) continue; + sampledIndices.add(idx); + + const bx = idx % blocksX; + const by = Math.floor(idx / blocksX); + + try { + const ptr = inspectBlockData(bx, by, 0, quality); // Y channel + if (!ptr) continue; + + // Read quantized data (offset index 3 = quantized coefficients) + const blockSize = 64; + const startBytes = ptr + (3 * blockSize * 8); + const dataView = new DataView(Module.HEAPU8.buffer); + + let zeros = 0; + for (let j = 0; j < blockSize; j++) { + const val = dataView.getFloat64(startBytes + (j * 8), true); + if (Math.abs(val) < 0.5) zeros++; + } + totalZeros += zeros; + } catch (e) { + // Skip problematic blocks + } + } + + const actualSampled = sampledIndices.size; + if (actualSampled === 0) return; + + const avgZeroRatio = totalZeros / (actualSampled * 64); + + // Estimate: JPEG uses ~0.5-2 bits per non-zero coefficient, plus overhead + // With Huffman + RLE, zeros are nearly free + const bitsPerNonZero = 4.5; // average bits per non-zero DCT coefficient + const nonZeroRatio = 1 - avgZeroRatio; + const totalCoefficients = totalBlocks * 64 * 3; // Y + Cr + Cb channels + const estimatedBits = totalCoefficients * nonZeroRatio * bitsPerNonZero; + const headerOverhead = 600; // JPEG header bytes + let estimatedBytes = Math.round(estimatedBits / 8) + headerOverhead; + + // Apply chroma subsampling factor + if (state.currentCsMode === 422) { + estimatedBytes = Math.round(estimatedBytes * 0.75); + } else if (state.currentCsMode === 420) { + estimatedBytes = Math.round(estimatedBytes * 0.6); + } + + // Clamp minimum + estimatedBytes = Math.max(estimatedBytes, headerOverhead + 100); + + // Format sizes + const formatSize = (bytes) => { + if (bytes >= 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return bytes + ' B'; + }; + + const reduction = Math.max(0, Math.round((1 - estimatedBytes / originalBytes) * 100)); + const ratio = (originalBytes / estimatedBytes).toFixed(1); + + // Update DOM + const values = document.getElementById('fileSizeValues'); + const fill = document.getElementById('fileSizeFill'); + const origLabel = document.getElementById('fileSizeOrigLabel'); + const reductionLabel = document.getElementById('fileSizeReduction'); + + if (values) values.textContent = `${formatSize(originalBytes)} โ†’ ~${formatSize(estimatedBytes)}`; + if (fill) fill.style.width = `${Math.max(2, 100 - reduction)}%`; + if (origLabel) origLabel.textContent = `Original: ${formatSize(originalBytes)}`; + if (reductionLabel) reductionLabel.textContent = `~${reduction}% smaller (${ratio}ร— compression)`; +} diff --git a/web/public/js/wasm-bridge.js b/web/public/js/wasm-bridge.js new file mode 100644 index 0000000..eb46910 --- /dev/null +++ b/web/public/js/wasm-bridge.js @@ -0,0 +1,64 @@ +import { state } from './state.js'; + +// Setup WASM runtime callback +export function setupWasm(onReady) { + if (typeof Module !== 'undefined' && Module.calledRun) { + onReady(); + } else { + // Assume Module is available globally via codec.js + Module.onRuntimeInitialized = onReady; + } +} + +export function initSession() { + if (!state.originalImageData) return; + + const rgbaSize = state.originalImageData.data.length; + let inputPtr = 0; + try { + inputPtr = Module._malloc(rgbaSize); + Module.HEAPU8.set(state.originalImageData.data, inputPtr); + Module._init_session(inputPtr, state.imgWidth, state.imgHeight); + } finally { + if (inputPtr) Module._free(inputPtr); + } +} + +export function processImage(quality, csMode) { + Module._process_image(quality, csMode); +} + +export function getViewPtr(viewMode) { + return Module._get_view_ptr(viewMode); +} + +export function getStats() { + return { + psnr: { + y: Module._get_psnr_y(), + cr: Module._get_psnr_cr(), + cb: Module._get_psnr_cb() + }, + ssim: { + y: Module._get_ssim_y(), + cr: Module._get_ssim_cr(), + cb: Module._get_ssim_cb() + } + }; +} + +export function setViewTint(enabled) { + Module._set_view_tint(enabled ? 1 : 0); +} + +export function inspectBlockData(blockX, blockY, channelIndex, quality) { + return Module._inspect_block_data(blockX, blockY, channelIndex, quality); +} + +export function getHeapU8() { + return Module.HEAPU8; +} + +export function free(ptr) { + Module._free(ptr); +} diff --git a/web/public/main.js b/web/public/main.js deleted file mode 100644 index dc6a34c..0000000 --- a/web/public/main.js +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Codec Explorer: An interactive codec laboratory. - * Copyright (C) 2026 Abhinav Tanniru - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -let originalImageData = null; -let imgWidth = 0; -let imgHeight = 0; - -const ViewMode = { - RGB: 0, - Artifacts: 1, - Y: 2, - Cr: 3, - Cb: 4 -}; -let currentViewMode = ViewMode.RGB; -let currentCsMode = 444; -const maxDim = 1024; // Downscale large images for performance -let wasmReady = false; - -// DOM Elements -const fileInput = document.getElementById('fileInput'); -const qualitySlider = document.getElementById('qualitySlider'); -const qualityValue = document.getElementById('qualityValue'); -const statusDiv = document.getElementById('status'); -const originalCanvas = document.getElementById('originalCanvas'); -const processedCanvas = document.getElementById('processedCanvas'); -const origCtx = originalCanvas.getContext('2d'); -const procCtx = processedCanvas.getContext('2d', { willReadFrequently: true }); -const psnrY = document.getElementById('psnrY'); -const psnrCr = document.getElementById('psnrCr'); -const psnrCb = document.getElementById('psnrCb'); -const viewModeRadios = document.querySelectorAll('input[name="view_mode"]'); -const csRadios = document.querySelectorAll('input[name="chroma_subsampling"]'); -const comparisonSlider = document.getElementById('comparisonSlider'); -const comparisonViewer = document.querySelector('.comparison-viewer'); -const tintToggle = document.getElementById('tint_toggle'); - -// --- 1. WASM Initialization Handler --- -Module.onRuntimeInitialized = () => { - wasmReady = true; - if (statusDiv) { - statusDiv.innerText = "WASM Module Ready!"; - statusDiv.classList.add('ready'); - } - - // Enable controls now that WASM is ready - if (fileInput) fileInput.disabled = false; - if (qualitySlider) qualitySlider.disabled = false; - viewModeRadios.forEach(radio => radio.disabled = false); - csRadios.forEach(radio => radio.disabled = false); - if (comparisonSlider) comparisonSlider.disabled = false; - if (tintToggle) tintToggle.disabled = false; - - console.log("WASM Runtime Initialized"); -}; - -// --- 2. Handle File Input --- -fileInput.addEventListener('change', (e) => { - const file = e.target.files[0]; - if (!file) return; - - const img = new Image(); - img.onload = () => { - // Calculate scale to fit max dimensions - let scale = 1.0; - if (img.width > maxDim || img.height > maxDim) { - scale = Math.min(maxDim / img.width, maxDim / img.height); - } - - imgWidth = Math.floor(img.width * scale); - imgHeight = Math.floor(img.height * scale); - - // Resize canvases - originalCanvas.width = imgWidth; - originalCanvas.height = imgHeight; - processedCanvas.width = imgWidth; - processedCanvas.height = imgHeight; - - // Draw original image to canvas to get pixel data - origCtx.drawImage(img, 0, 0, imgWidth, imgHeight); - originalImageData = origCtx.getImageData(0, 0, imgWidth, imgHeight); - - // Reset comparison slider to a 50/50 view - if (comparisonSlider && processedCanvas) { - comparisonSlider.value = 50; - processedCanvas.style.clipPath = `polygon(50% 0, 100% 0, 100% 100%, 50% 100%)`; - } - - // Initialize a new session in WASM - initSession(); - }; - img.src = URL.createObjectURL(file); -}); - -// --- 3. Debounce Helper --- -function debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; -} - -// --- 4. Handle Controls --- -const debouncedUpdate = debounce(() => updateAndRender(), 150); - -qualitySlider.addEventListener('input', () => { - qualityValue.textContent = qualitySlider.value; - if (originalImageData) { - debouncedUpdate(); - } -}); - -viewModeRadios.forEach(radio => { - radio.addEventListener('change', (e) => { - switch (e.target.value) { - case 'artifacts': currentViewMode = ViewMode.Artifacts; break; - case 'y': currentViewMode = ViewMode.Y; break; - case 'cr': currentViewMode = ViewMode.Cr; break; - case 'cb': currentViewMode = ViewMode.Cb; break; - default: currentViewMode = ViewMode.RGB; break; - } - // Only re-render, don't re-process - if (originalImageData) { - render(); - } - }); -}); - -csRadios.forEach(radio => { - radio.addEventListener('change', (e) => { - currentCsMode = parseInt(e.target.value, 10); - // Re-process the image with the new setting - if (originalImageData) { - updateAndRender(); - } - }); -}); - -if (tintToggle) { - tintToggle.addEventListener('change', (e) => { - if (wasmReady) { - Module._set_view_tint(e.target.checked ? 1 : 0); - if (originalImageData) { - render(); - } - } - }); -} - -function updateComparisonView(percent) { - // Clamp the value between 0 and 100 - const clampedPercent = Math.max(0, Math.min(100, percent)); - - // Update the slider's visual position - comparisonSlider.value = clampedPercent; - - // Update the clip-path of the top canvas (processed) to reveal the bottom (original) - processedCanvas.style.clipPath = `polygon(${clampedPercent}% 0, 100% 0, 100% 100%, ${clampedPercent}% 100%)`; -} - -comparisonSlider.addEventListener('input', (e) => updateComparisonView(e.target.value)); - -let isDragging = false; - -function handleInteraction(clientX) { - if (!comparisonViewer) return; - const rect = comparisonViewer.getBoundingClientRect(); - const x = clientX - rect.left; - const percent = (x / rect.width) * 100; - updateComparisonView(percent); -} - -// --- Mouse Events for Direct Image Interaction --- -comparisonViewer.addEventListener('mousedown', (e) => { - isDragging = true; - comparisonViewer.style.cursor = 'grabbing'; - handleInteraction(e.clientX); -}); - -document.addEventListener('mouseup', () => { - isDragging = false; - if (comparisonViewer) comparisonViewer.style.cursor = 'col-resize'; -}); - -document.addEventListener('mouseleave', () => { isDragging = false; }); - -document.addEventListener('mousemove', (e) => { - if (isDragging) { - handleInteraction(e.clientX); - } -}); - -// --- Touch Events for Direct Image Interaction --- -comparisonViewer.addEventListener('touchstart', (e) => { - isDragging = true; - handleInteraction(e.touches[0].clientX); -}); - -document.addEventListener('touchend', () => { isDragging = false; }); -document.addEventListener('touchcancel', () => { isDragging = false; }); - -document.addEventListener('touchmove', (e) => { - if (isDragging) { - // Prevent scrolling while dragging on the image - e.preventDefault(); - handleInteraction(e.touches[0].clientX); - } -}, { passive: false }); // Required to allow preventDefault - -// --- 5. Core WASM Interaction Functions --- - -function initSession() { - if (!originalImageData) return; - - // The C++ side now expects a 4-channel RGBA buffer. - const rgbaSize = originalImageData.data.length; - - let inputPtr = 0; - try { - // Allocate memory in the WASM heap and copy the image data. - inputPtr = Module._malloc(rgbaSize); - Module.HEAPU8.set(originalImageData.data, inputPtr); - Module._init_session(inputPtr, imgWidth, imgHeight); - } finally { - if (inputPtr) Module._free(inputPtr); - } - - // Trigger initial processing and rendering - updateAndRender(); -} - -function updateAndRender() { - if (!wasmReady || !originalImageData) return; - - const quality = parseInt(qualitySlider.value); - // Call the updated C++ function with quality and chroma subsampling mode. - Module._process_image(quality, currentCsMode); - - // After updating, render the current view - render(); -} - -function render() { - if (!wasmReady || !originalImageData) return; - - const rgbaSize = imgWidth * imgHeight * 4; - let outputPtr = 0; - try { - // Get the pointer for the current view - outputPtr = Module._get_view_ptr(currentViewMode); - if (!outputPtr) throw new Error("WASM get_view_ptr returned null"); - - // The C++ module now returns a 4-channel RGBA buffer. - // We can create an ImageData object directly from this buffer. - const imageDataBytes = new Uint8ClampedArray(Module.HEAPU8.buffer, outputPtr, rgbaSize); - const imageData = new ImageData(imageDataBytes, imgWidth, imgHeight); - procCtx.putImageData(imageData, 0, 0); - - // Update stats - psnrY.textContent = Module._get_psnr_y().toFixed(2); - psnrCr.textContent = Module._get_psnr_cr().toFixed(2); - psnrCb.textContent = Module._get_psnr_cb().toFixed(2); - - // Update SSIM - document.getElementById('ssimY').textContent = Module._get_ssim_y().toFixed(4); - document.getElementById('ssimCr').textContent = Module._get_ssim_cr().toFixed(4); - document.getElementById('ssimCb').textContent = Module._get_ssim_cb().toFixed(4); - - } catch (err) { - console.error("WASM render error:", err); - } finally { - // The C++ code malloc'd this, so JS must free it. - if (outputPtr) Module._free(outputPtr); - } -} \ No newline at end of file diff --git a/web/public/style.css b/web/public/style.css deleted file mode 100644 index 368b850..0000000 --- a/web/public/style.css +++ /dev/null @@ -1,468 +0,0 @@ -/* web/public/style.css */ - -:root { - --primary: #2563eb; - /* Royal Blue */ - --primary-hover: #1d4ed8; - --bg: #f8fafc; - /* Light Slate Background */ - --card-bg: #ffffff; - --text: #1e293b; - /* Slate 800 */ - --text-muted: #64748b; - --border: #e2e8f0; - --success-bg: #dcfce7; - --success-text: #166534; -} - -* { - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - background-color: var(--bg); - color: var(--text); - margin: 0; - padding: 20px; - line-height: 1.5; - display: flex; - flex-direction: column; - align-items: center; -} - -/* --- Header & Privacy Badge --- */ -header { - text-align: center; - margin-bottom: 32px; -} - -h1 { - margin: 0 0 12px 0; - font-size: 1.8rem; - letter-spacing: -0.025em; -} - -.privacy-badge { - display: inline-flex; - align-items: center; - gap: 8px; - background-color: var(--success-bg); - color: var(--success-text); - padding: 6px 12px; - border-radius: 99px; - font-size: 0.85rem; - font-weight: 600; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); -} - -.github-link { - display: inline-flex; - align-items: center; - gap: 8px; - background-color: #f1f5f9; - /* Slate 100 */ - color: #475569; - /* Slate 600 */ - padding: 6px 12px; - border-radius: 99px; - font-size: 0.85rem; - font-weight: 600; - text-decoration: none; - box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); - transition: all 0.2s ease; - margin-left: 8px; -} - -.github-link:hover { - background-color: #e2e8f0; - /* Slate 200 */ - color: #1e293b; - /* Slate 800 */ - transform: translateY(-1px); -} - -/* --- Layout --- */ -.container { - width: 100%; - max-width: 1280px; - display: grid; - grid-template-columns: 1fr 320px; - /* Sidebar layout */ - gap: 24px; - align-items: start; -} - -/* --- Components --- */ -.card { - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: 12px; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - padding: 12px; -} - -.controls { - overflow: visible; -} - -canvas { - max-width: 100%; - height: auto; - display: block; -} - -/* --- Controls --- */ -.controls { - display: flex; - flex-direction: column; - gap: 20px; -} - -.control-group { - display: flex; - flex-direction: column; - gap: 8px; -} - -label { - font-weight: 600; - font-size: 0.9rem; - display: flex; - justify-content: space-between; -} - -input[type="range"] { - width: 100%; - cursor: pointer; -} - -button.primary { - background-color: var(--primary); - color: white; - border: none; - padding: 12px; - border-radius: 8px; - font-weight: 600; - font-size: 1rem; - cursor: pointer; - transition: background-color 0.2s; -} - -button.primary:hover { - background-color: var(--primary-hover); -} - -button.primary:active { - transform: translateY(1px); -} - -.stats { - margin-top: 10px; - padding: 12px; - background: #f1f5f9; - border-radius: 6px; - font-family: monospace; - font-size: 0.85rem; - white-space: pre-wrap; - border: 1px solid var(--border); -} - -/* --- Mobile Responsiveness --- */ -@media (max-width: 768px) { - .container { - grid-template-columns: 1fr; - /* Stack vertically */ - } - - body { - padding: 16px; - } - - /* Better touch targets */ - input[type="range"] { - height: 40px; - } - - button.primary { - padding: 16px; - } -} - -/* --- Segmented Control (Toggle Bar) --- */ - -.group-label { - margin-bottom: 8px; - display: block; - font-size: 0.9rem; - color: var(--text); -} - -.toggle-group { - display: flex; - background: #e2e8f0; - /* Light gray track */ - padding: 4px; - border-radius: 8px; - gap: 4px; - /* Space between buttons */ -} - -/* 1. Hide the actual radio input circle */ -.toggle-group input[type="radio"] { - display: none; -} - -/* 2. Style the Labels to look like Buttons */ -.toggle-group label { - display: flex; - /* 1. Make the label a container */ - justify-content: center; - /* 2. Center horizontally */ - align-items: center; - flex: 1; - /* Make them all equal width */ - text-align: center; - padding: 8px 4px; - font-size: 0.85rem; - font-weight: 500; - border-radius: 6px; - cursor: pointer; - color: #64748b; - /* Muted text */ - transition: all 0.2s ease; - user-select: none; - /* Prevent text highlighting on clicks */ -} - -/* 3. Hover Effect */ -.toggle-group label:hover { - background: rgba(255, 255, 255, 0.5); - color: var(--text); -} - -/* 4. Active (Checked) State */ -/* This magic selector targets the label immediately following a checked input */ -.toggle-group input[type="radio"]:checked+label { - background: #ffffff; - color: var(--primary); - /* Blue text */ - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - /* Subtle shadow pop */ - font-weight: 600; -} - -/* --- Comparison Viewer (Container) --- */ -.canvas-wrapper { - background: #0f172a; - border-radius: 12px; - padding: 16px; - display: flex; - flex-direction: column; - gap: 16px; -} - -.viewer-info { - text-align: center; - font-size: 0.85rem; - color: #94a3b8; - /* Muted slate */ - user-select: none; -} - -.comparison-viewer { - position: relative; - width: 100%; - /* Remove fixed height to let canvas define it */ - line-height: 0; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.5); - cursor: col-resize; -} - -.comparison-viewer canvas { - display: block; - width: 100%; - height: auto; -} - -/* Top Layer: Processed */ -#processedCanvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - /* Default to a 50/50 split view */ - clip-path: polygon(50% 0, 100% 0, 100% 100%, 50% 100%); -} - -/* --- The Crossfader Slider --- */ -.slider-container { - display: flex; - align-items: center; - gap: 12px; - padding: 0 8px; -} - -.slider-label { - font-size: 0.8rem; - font-weight: 600; - color: #94a3b8; - /* Muted slate */ - text-transform: uppercase; - letter-spacing: 0.05em; - width: 80px; - /* Fixed width prevents jitter */ -} - -.slider-label:first-child { - text-align: right; -} - -.slider-label:last-child { - text-align: left; - color: var(--primary); -} - -.comparison-slider-input { - flex: 1; - /* Take up remaining space */ - -webkit-appearance: none; - appearance: none; - height: 6px; - background: linear-gradient(to right, #64748b 0%, var(--primary) 100%); - border-radius: 3px; - outline: none; - cursor: pointer; -} - -/* Slider Thumb (Chrome/Safari) */ -.comparison-slider-input::-webkit-slider-thumb { - -webkit-appearance: none; - width: 20px; - height: 20px; - background: white; - border: 2px solid var(--primary); - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: transform 0.1s; -} - -/* --- Tooltip --- */ -.tooltip-container { - position: relative; - display: flex; - align-items: center; - cursor: help; -} - -.info-icon { - display: flex; - align-items: center; - color: var(--text-muted); - transition: color 0.2s; -} - -.info-icon:hover { - color: var(--primary); -} - -.tooltip-content { - position: absolute; - bottom: 125%; - right: 0; - transform: translateY(10px); - width: 280px; - background-color: #1e293b; - /* Dark Slate */ - color: #ffffff; - padding: 10px 14px; - border-radius: 8px; - font-size: 0.75rem; - line-height: 1.4; - font-weight: normal; - text-align: left; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); - opacity: 0; - visibility: hidden; - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); - z-index: 1000; - pointer-events: none; -} - -/* Tooltip Arrow */ -.tooltip-content::after { - content: ""; - position: absolute; - top: 100%; - right: 6px; - border-width: 5px; - border-style: solid; - border-color: #1e293b transparent transparent transparent; -} - -.tooltip-container:hover .tooltip-content { - opacity: 1; - visibility: visible; - transform: translateY(0); -} - -/* Adjust for narrow sidebar */ -@media (max-width: 350px) { - .tooltip-content { - width: 180px; - left: auto; - right: 0; - transform: translateY(10px); - } - - .tooltip-container:hover .tooltip-content { - transform: translateY(0); - } - - .tooltip-content::after { - left: 90%; - } -} - -.comparison-slider-input::-webkit-slider-thumb:hover { - transform: scale(1.2); -} - -/* Slider Thumb (Firefox) */ -.comparison-slider-input::-moz-range-thumb { - width: 20px; - height: 20px; - background: white; - border: 2px solid var(--primary); - border-radius: 50%; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); - transition: transform 0.1s; -}/* Existing styles... */ - -/* Add this to your existing css file */ -.metrics-table { - width: 100%; - border-collapse: collapse; - margin-top: 10px; - font-size: 0.9rem; - color: var(--text-color); -} - -.metrics-table th, -.metrics-table td { - border: 1px solid var(--border-color); - padding: 6px; - text-align: center; -} - -.metrics-table th { - background-color: var(--bg-secondary); - font-weight: 600; -} - -.metrics-table td { - background-color: var(--bg-primary); -} \ No newline at end of file diff --git a/web/src/codec_web.cpp b/web/src/codec_web.cpp index 66fd438..d516238 100644 --- a/web/src/codec_web.cpp +++ b/web/src/codec_web.cpp @@ -173,4 +173,38 @@ double get_ssim_cb() { return g_session.initialized ? g_session.metrics.ssimCb : 0.0; } + +// Re-declaring to match the plan's arguments +EMSCRIPTEN_KEEPALIVE +double* inspect_block_data(int blockX, int blockY, int channelIndex, int quality) { + if (!g_session.initialized) return nullptr; + + Image ycrcb = bgrToYCrCb(g_session.originalImage); + Image channel(ycrcb.width(), ycrcb.height(), 1); + const double* src = ycrcb.data(); + double* dst = channel.data(); + int offset = (channelIndex == 0) ? 0 : (channelIndex == 1 ? 1 : 2); // Y=0, Cr=1, Cb=2 + + const size_t numPixels = static_cast(ycrcb.width()) * ycrcb.height(); + for(size_t i=0; i < numPixels; ++i) { + dst[i] = src[i*3 + offset]; + } + + bool isChroma = (channelIndex != 0); + + // Create temp codec + // Note: CS mode doesn't affect the *inspection* of a single 8x8 block of the *source* image + // vis-a-vis the quantization tables. Subsampling logic happens before this in the pipeline typically, + // but `inspectBlock` treats the input image as the plane to be blocked. + // If we want to inspect the *subsampled* block, we would need to pass the subsampled image. + // For simplicity, let's inspect the Full Resolution block using the appropriate Quant Table. + ImageCodec codec(quality, true, ImageCodec::ChromaSubsampling::CS_444); + + static ImageCodec::BlockDebugData debugData; // Static to persist return pointer + debugData = codec.inspectBlock(channel, blockX, blockY, isChroma); + + // Return pointer to the start of the struct (which is just a sequence of double[8][8]) + return (double*)&debugData; +} + } // extern "C" \ No newline at end of file