基本实现三类生成的文件去水印
|
|
@ -16,3 +16,4 @@ yarn-error.log*
|
|||
# local env files
|
||||
.env*.local
|
||||
.env
|
||||
.gemini-clipboard/
|
||||
1
build.js
|
|
@ -52,6 +52,7 @@ const copyAssetsPlugin = {
|
|||
if (!existsSync('dist/i18n')) mkdirSync('dist/i18n', { recursive: true });
|
||||
cpSync('src/i18n', 'dist/i18n', { recursive: true });
|
||||
cpSync('public', 'dist', { recursive: true });
|
||||
cpSync('node_modules/pdfjs-dist/build/pdf.worker.min.mjs', 'dist/pdf.worker.min.mjs');
|
||||
} catch (err) {
|
||||
console.error('❌ Asset copy failed:', err);
|
||||
}
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 8.9 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 5.9 MiB |
|
After Width: | Height: | Size: 6.3 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
After Width: | Height: | Size: 6.2 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.8 MiB |
|
|
@ -15,9 +15,12 @@
|
|||
"serve": "npx serve dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas": "^3.2.1",
|
||||
"exifr": "^7.1.3",
|
||||
"jspdf": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"medium-zoom": "^1.1.0"
|
||||
"medium-zoom": "^1.1.0",
|
||||
"pdfjs-dist": "^5.4.624"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.24.0"
|
||||
|
|
|
|||
936
pnpm-lock.yaml
|
|
@ -1,278 +1,274 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gemini Watermark Remover</title>
|
||||
<meta name="description" content="Free Gemini watermark remover tool to remove watermarks from Gemini images. — safe, private, and 100% browser-based.">
|
||||
<meta name="keywords" content="Gemini watermark remover, remove Gemini watermark, Gemini AI image editor, Google AI watermark remover, Gemini watermark fix, watermark cleaner tool, Gemini watermark eraser, free AI watermark remover" />
|
||||
<title>Gemini Watermark Cleaner</title>
|
||||
<meta name="description" content="Free Gemini watermark remover tool. Safe, private, and 100% browser-based.">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg class='h-10 w-10 text-indigo-500' xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%236366f1' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3l-1.9 5.8-5.8 1.9 5.8 1.9 1.9 5.8 1.9-5.8 5.8-1.9-5.8-1.9z'/%3E%3C/svg%3E">
|
||||
|
||||
|
||||
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: '#10B981',
|
||||
'primary-hover': '#059669',
|
||||
dark: '#1F2937',
|
||||
success: '#10B981',
|
||||
warn: '#F59E0B',
|
||||
err: '#EF4444',
|
||||
info: '#3B82F6',
|
||||
background: 'var(--background)',
|
||||
surface: 'var(--surface)',
|
||||
card: 'var(--card)',
|
||||
border: 'var(--border)',
|
||||
primary: 'var(--primary)',
|
||||
secondary: 'var(--secondary)',
|
||||
accent: '#3B82F6',
|
||||
},
|
||||
boxShadow: {
|
||||
'soft': '0 4px 20px -2px rgba(16, 185, 129, 0.1)',
|
||||
'card': '0 0 20px rgba(0,0,0,0.05)'
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'monospace'],
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body { font-family: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; }
|
||||
|
||||
::-webkit-scrollbar { width: 10px; }
|
||||
::-webkit-scrollbar-track { background: #f1f1f1; }
|
||||
::-webkit-scrollbar-thumb { background: #10B981; border-radius: 4px; }
|
||||
|
||||
.step-arrow::after {
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--surface: #f9fafb;
|
||||
--card: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--primary: #111827;
|
||||
--secondary: #6b7280;
|
||||
}
|
||||
.dark {
|
||||
--background: #050505;
|
||||
--surface: #0A0A0A;
|
||||
--card: #111111;
|
||||
--border: #262626;
|
||||
--primary: #EDEDED;
|
||||
--secondary: #888888;
|
||||
}
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: var(--background);
|
||||
color: var(--primary);
|
||||
}
|
||||
.noise-layer {
|
||||
position: fixed;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0.04;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E");
|
||||
}
|
||||
.glass-nav {
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
.dark .glass-nav {
|
||||
background: rgba(5, 5, 5, 0.8);
|
||||
border-bottom: 1px solid #262626;
|
||||
}
|
||||
.light .glass-nav {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
.spotlight-wrapper {
|
||||
position: relative;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-radius: 12px;
|
||||
padding: 1px;
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,1), 0 20px 40px -20px rgba(0,0,0,0.5);
|
||||
overflow: hidden;
|
||||
}
|
||||
.light .spotlight-wrapper {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,0.05), 0 20px 40px -20px rgba(0,0,0,0.1);
|
||||
}
|
||||
.drop-zone {
|
||||
position: relative;
|
||||
}
|
||||
.dark .drop-zone {
|
||||
background: #0A0A0A;
|
||||
background-image: linear-gradient(#262626 1px, transparent 1px), linear-gradient(90deg, #262626 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
.light .drop-zone {
|
||||
background: #ffffff;
|
||||
background-image: linear-gradient(#f3f4f6 1px, transparent 1px), linear-gradient(90deg, #f3f4f6 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
}
|
||||
.dark .drop-zone::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border-top: 15px solid transparent;
|
||||
border-bottom: 15px solid transparent;
|
||||
border-left: 15px solid #F3F4F6;
|
||||
z-index: 10;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, transparent 20%, #0A0A0A 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.step-item:not(:last-child)::after {
|
||||
.light .drop-zone::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -24px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 24px;
|
||||
background-color: inherit;
|
||||
clip-path: polygon(0% 0%, 100% 50%, 0% 100%);
|
||||
z-index: 20;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, transparent 20%, #ffffff 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.step-item:not(:first-child) {
|
||||
clip-path: polygon(0% 0%, 200% 0%, 100% 100%, 0% 100%, 24px 50%);
|
||||
.preview-bg {
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
.dark .preview-bg {
|
||||
background-color: #111;
|
||||
background-image: radial-gradient(#333 1px, transparent 1px);
|
||||
}
|
||||
body.loading { opacity: 0; }
|
||||
body { transition: opacity 0s; }
|
||||
|
||||
.light .preview-bg {
|
||||
background-color: #f9fafb;
|
||||
background-image: radial-gradient(#e5e7eb 1px, transparent 1px);
|
||||
}
|
||||
/* Ensure zoom preview is on top of everything */
|
||||
.medium-zoom-overlay,
|
||||
.medium-zoom-image--opened {
|
||||
z-index: 999;
|
||||
}
|
||||
.medium-zoom-overlay {
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-white text-gray-800 antialiased selection:bg-primary selection:text-white flex flex-col min-h-screen loading">
|
||||
<body class="min-h-screen flex flex-col relative overflow-x-hidden bg-background text-primary dark:bg-background dark:text-primary">
|
||||
<div class="noise-layer"></div>
|
||||
|
||||
<header class="sticky top-0 z-50 bg-white/90 backdrop-blur-md border-b border-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-1">
|
||||
<svg class="h-10 w-10 text-indigo-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m12 3-1.9 5.8-5.8 1.9 5.8 1.9 1.9 5.8 1.9-5.8 5.8-1.9-5.8-1.9Z"></path>
|
||||
<header class="glass-nav sticky top-0 z-50 h-16 flex items-center justify-between px-6 lg:px-12">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 bg-primary rounded-sm"></div>
|
||||
<h1 class="font-mono text-sm font-medium tracking-tight">GEMINI WATERMARK CLEANER</h1>
|
||||
</div>
|
||||
<nav class="flex items-center gap-6 text-xs md:text-sm font-medium text-secondary">
|
||||
<button id="themeToggle" class="p-2 border border-border rounded hover:bg-surface text-primary transition-colors">
|
||||
<svg id="themeIcon" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path id="themeIconPath" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
|
||||
</svg>
|
||||
<h1 class="text-sm font-bold tracking-tight text-primary md:text-xl">
|
||||
<a href="./" class="hover:text-primary transition-colors" title="Gemini Watermark Remover">Gemini <span class="text-gray-700 font-medium">Watermark Remover</span></a>
|
||||
</h1>
|
||||
</div>
|
||||
<nav class="flex gap-2 md:gap-6 text-sm font-medium text-gray-600 items-center">
|
||||
<a href="userscript/gemini-watermark-remover.user.js" target="_blank" class="hidden md:inline-block hover:text-primary transition-colors" data-i18n="nav.userscript">油猴脚本</a>
|
||||
<a href="https://allenkuo.medium.com/removing-gemini-ai-watermarks-a-deep-dive-into-reverse-alpha-blending-bbbd83af2a3f" target="_blank" rel="noopener noreferrer" class="hidden md:inline-block hover:text-primary transition-colors" data-i18n="nav.principle">去水印原理</a>
|
||||
<a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" rel="noopener noreferrer" class="hover:text-primary transition-colors">GitHub</a>
|
||||
<button id="langSwitch" class="px-3 py-1 text-nowrap border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">EN</button>
|
||||
</button>
|
||||
<a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" class="hover:text-primary transition-colors">GitHub</a>
|
||||
<button id="langSwitch" class="px-3 py-1 border border-border rounded hover:bg-surface text-primary transition-colors">EN</button>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow">
|
||||
<section class="relative pt-16 pb-12 lg:pt-24 lg:pb-20 text-center px-4 overflow-hidden">
|
||||
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-full h-full bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-emerald-50 via-white to-white -z-10"></div>
|
||||
<main class="flex-grow container mx-auto px-4 py-12 md:py-20 z-10 relative max-w-5xl">
|
||||
|
||||
<h2 class="bg-clip-text bg-gradient-to-br font-extrabold from-slate-900 mb-6 md:text-6xl text-3xl text-transparent to-slate-700 tracking-tighter" data-i18n="main.title">
|
||||
Gemini AI 图像去水印
|
||||
<!-- Hero Section -->
|
||||
<section class="text-center mb-16 space-y-6">
|
||||
<h2 class="text-4xl md:text-6xl font-medium tracking-tight bg-clip-text text-transparent bg-gradient-to-b from-primary to-secondary" data-i18n="main.title">
|
||||
Restore Clarity.
|
||||
</h2>
|
||||
<p class="text-base md:text-lg text-gray-500 max-w-2xl mx-auto mb-10" data-i18n="main.subtitle">
|
||||
基于反向 Alpha 混合算法,纯浏览器本地处理,免费、极速、无损
|
||||
<p class="text-secondary text-base md:text-lg max-w-2xl mx-auto font-light" data-i18n="main.subtitle">
|
||||
Remove Gemini & NotebookLM watermarks instantly. Local processing, privacy-first.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="max-w-4xl mx-auto bg-white rounded-2xl shadow-soft p-2 md:p-3 border border-emerald-100">
|
||||
<div id="uploadArea" class="group relative flex flex-col items-center justify-center w-full h-64 border-2 border-dashed border-emerald-200 rounded-xl bg-emerald-50/30 hover:bg-emerald-50 hover:border-emerald-400 transition-all cursor-pointer">
|
||||
<div class="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<div class="w-16 h-16 bg-white rounded-full flex items-center justify-center shadow-sm mb-4 group-hover:scale-110 transition-transform duration-300">
|
||||
<svg class="w-8 h-8 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path d="M 22.498 20.365 L 22.498 4.247 C 22.498 2.983 21.461 1.946 20.197 1.946 L 4.079 1.946 C 2.815 1.946 1.778 2.983 1.778 4.247 L 1.778 20.365 C 1.778 21.629 2.815 22.666 4.079 22.666 L 20.197 22.666 C 21.461 22.666 22.498 21.629 22.498 20.365 Z M 8.111 14.032 L 10.987 17.483 L 15.014 12.306 L 20.197 19.214 L 4.079 19.214 L 8.111 14.032 Z" fill="currentColor"></path></svg>
|
||||
<!-- Upload Area -->
|
||||
<section class="mb-20">
|
||||
<div class="spotlight-wrapper max-w-2xl mx-auto group">
|
||||
<div id="uploadArea" class="drop-zone h-64 md:h-80 rounded-xl flex flex-col items-center justify-center cursor-pointer transition-colors hover:border-accent/50">
|
||||
<div class="z-10 flex flex-col items-center gap-4 transition-transform group-hover:-translate-y-1 duration-300">
|
||||
<div class="w-12 h-12 rounded-xl bg-card border border-border flex items-center justify-center text-secondary">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path></svg>
|
||||
</div>
|
||||
<p class="mb-2 text-lg font-medium text-gray-700" data-i18n="upload.text">点击选择 或 拖拽图片至此</p>
|
||||
<p class="text-sm text-gray-400" data-i18n="upload.hint">支持 JPG, PNG, WebP</p>
|
||||
<div class="text-center">
|
||||
<p class="text-primary font-medium mb-1" data-i18n="upload.text">Drop files here or click to upload</p>
|
||||
<p class="text-secondary text-xs font-mono bg-surface px-2 py-1 rounded" data-i18n="upload.hint">JPG, PNG, WEBP, PDF</p>
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp" multiple class="hidden" />
|
||||
</div>
|
||||
<input type="file" id="fileInput" accept="image/jpeg,image/png,image/webp,application/pdf" multiple class="hidden" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="max-w-6xl mx-auto px-4 mb-8 md:mb-20">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-10 text-center">
|
||||
<div class="step-item relative bg-emerald-50 h-16 md:h-24 flex items-center justify-start px-6 md:pl-16 rounded-lg md:rounded-none md:first:rounded-l-lg md:last:rounded-r-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-200 text-emerald-800 font-bold flex items-center justify-center mr-3 text-sm">1</div>
|
||||
<span class="font-medium flex-1 md:flex-none text-gray-800" data-i18n="step.1">选择原始图片</span>
|
||||
<!-- Previews -->
|
||||
<section id="singlePreview" class="hidden mb-20 space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div class="grid lg:grid-cols-2 gap-6">
|
||||
<!-- Original -->
|
||||
<div class="bg-card border border-border rounded-xl overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-border flex justify-between items-center bg-surface">
|
||||
<span class="text-xs font-mono text-secondary" data-i18n="preview.original">ORIGINAL</span>
|
||||
<span id="originalInfo" class="text-[10px] font-mono text-secondary/50"></span>
|
||||
</div>
|
||||
<div class="step-item relative bg-emerald-50 h-16 md:h-24 flex items-center justify-start px-6 md:pl-16 rounded-lg md:rounded-none">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-200 text-emerald-800 font-bold flex items-center justify-center mr-3 text-sm">2</div>
|
||||
<span class="font-medium flex-1 md:flex-none text-gray-800" data-i18n="step.2">算法自动解析</span>
|
||||
</div>
|
||||
<div class="step-item relative bg-emerald-50 h-16 md:h-24 flex items-center justify-start px-6 md:pl-16 rounded-lg md:rounded-none md:last:rounded-r-lg">
|
||||
<div class="w-8 h-8 rounded-full bg-emerald-200 text-emerald-800 font-bold flex items-center justify-center mr-3 text-sm">3</div>
|
||||
<span class="font-medium flex-1 md:flex-none text-gray-800" data-i18n="step.3">保存无水印图</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="singlePreview" class="max-w-7xl mx-auto px-4 pb-24" style="display: none;">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<div class="flex-1 space-y-8">
|
||||
<div class="bg-white rounded-2xl shadow-card overflow-hidden border border-gray-100">
|
||||
<div class="bg-gray-50 px-6 py-3 border-b border-gray-100 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-gray-700 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-red-400"></span> <span data-i18n="preview.original">原图预览</span>
|
||||
</h3>
|
||||
<span id="originalInfo" class="text-xs text-gray-400 font-mono"></span>
|
||||
</div>
|
||||
<div class="h-[200px] md:h-[500px] p-4 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iI2Y5ZmRmZCI+PHJlY3Qgd2lkdGg9IjEwIiBoZWlnaHQ9IjEwIiBmaWxsPSIjZjJmMmYyIi8+PHJlY3QgeD0iMTAiIHk9IjEwIiB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIGZpbGw9IiNmMmYyZjIiLz48L3N2Zz4=')]">
|
||||
<img id="originalImage" class="max-w-full max-h-full mx-auto rounded-lg shadow-sm block" data-zoomable />
|
||||
<div class="h-64 md:h-96 preview-bg flex items-center justify-center p-4">
|
||||
<img id="originalImage" class="max-w-full max-h-full rounded shadow-lg" data-zoomable />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="processedSection" class="bg-white rounded-2xl shadow-card overflow-hidden border border-gray-100 ring-4 ring-emerald-50 scroll-mt-24" style="display: none;">
|
||||
<div class="bg-emerald-50/50 px-6 py-3 border-b border-emerald-100 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-emerald-800 flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full bg-primary"></span> <span data-i18n="preview.result">处理结果</span>
|
||||
</h3>
|
||||
<span id="processedInfo" class="text-xs text-emerald-600 font-mono"></span>
|
||||
<!-- Result -->
|
||||
<div id="processedSection" class="bg-card border border-border rounded-xl overflow-hidden hidden">
|
||||
<div class="px-4 py-3 border-b border-border flex justify-between items-center bg-surface">
|
||||
<span class="text-xs font-mono text-accent" data-i18n="preview.result">CLEANED</span>
|
||||
<span id="processedInfo" class="text-[10px] font-mono text-secondary/50"></span>
|
||||
</div>
|
||||
<div class="h-[200px] md:h-[500px] p-4 bg-[url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCIgZmlsbD0iI2Y5ZmRmZCI+PHJlY3Qgd2lkdGg9IjEwIiBoZWlnaHQ9IjEwIiBmaWxsPSIjZjJmMmYyIi8+PHJlY3QgeD0iMTAiIHk9IjEwIiB3aWR0aD0iMTAiIGhlaWdodD0iMTAiIGZpbGw9IiNmMmYyZjIiLz48L3N2Zz4=')]">
|
||||
<img id="processedImage" class="max-w-full max-h-full mx-auto rounded-lg shadow-sm block" data-zoomable />
|
||||
<div class="h-64 md:h-96 preview-bg flex items-center justify-center p-4 relative">
|
||||
<img id="processedImage" class="max-w-full max-h-full rounded shadow-lg" data-zoomable />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full lg:w-80 flex-shrink-0">
|
||||
<div class="bg-white rounded-2xl shadow-lg border border-gray-100 p-6 sticky top-24">
|
||||
<h4 class="text-lg font-bold text-gray-900 mb-6" data-i18n="panel.title">操作面板</h4>
|
||||
|
||||
<div class="space-y-3">
|
||||
<button id="downloadBtn" class="w-full py-3.5 px-4 bg-gray-900 hover:bg-black text-white rounded-xl font-medium transition-all flex items-center justify-center gap-2" style="display: none;">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||
<span data-i18n="btn.download">下载结果</span>
|
||||
<!-- Actions -->
|
||||
<div class="flex flex-col sm:flex-row justify-center gap-4 pt-4">
|
||||
<button id="resetBtn" class="px-6 py-2.5 rounded-lg border border-border text-secondary hover:text-primary hover:border-secondary transition-all text-sm font-medium" data-i18n="btn.reset">
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button id="resetBtn" class="w-full py-3.5 px-4 bg-white border border-gray-200 text-gray-600 hover:bg-gray-50 hover:text-gray-900 rounded-xl font-medium transition-all flex items-center justify-center gap-2">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>
|
||||
<span data-i18n="btn.reset">重置 / 更换图片</span>
|
||||
<button id="downloadBtn" class="px-6 py-2.5 rounded-lg bg-primary text-background hover:opacity-90 transition-all text-sm font-medium flex items-center justify-center gap-2 hidden" data-i18n="btn.download">
|
||||
Download Result
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="statusMessage" class="mt-6 text-sm text-gray-500 min-h-[1.25rem]"></div>
|
||||
<div id="statusMessage" class="text-center text-xs font-mono text-secondary h-4"></div>
|
||||
</section>
|
||||
|
||||
<!-- Multi Preview -->
|
||||
<section id="multiPreview" class="hidden mb-20">
|
||||
<div class="flex justify-between items-end mb-6 border-b border-border pb-4">
|
||||
<span id="progressText" class="font-mono text-sm text-secondary">QUEUE</span>
|
||||
<button id="downloadAllBtn" class="text-xs font-mono bg-primary text-background px-4 py-2 rounded hover:opacity-90 transition-colors hidden" data-i18n="btn.downloadAll">DOWNLOAD ALL</button>
|
||||
</div>
|
||||
<div id="imageList" class="grid grid-cols-1 md:grid-cols-2 gap-4"></div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-6 pt-12 border-t border-border">
|
||||
<div class="p-6 rounded-xl bg-card border border-border hover:border-secondary transition-colors">
|
||||
<div class="w-8 h-8 text-primary mb-4">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
</div>
|
||||
<h3 class="font-medium mb-2 text-sm text-primary" data-i18n="feature.speed.title">Local Processing</h3>
|
||||
<p class="text-xs text-secondary leading-relaxed" data-i18n="feature.speed.desc">Runs entirely in your browser. No data leaves your device.</p>
|
||||
</div>
|
||||
<div class="p-6 rounded-xl bg-card border border-border hover:border-secondary transition-colors">
|
||||
<div class="w-8 h-8 text-primary mb-4">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path></svg>
|
||||
</div>
|
||||
<h3 class="font-medium mb-2 text-sm text-primary" data-i18n="feature.privacy.title">Privacy First</h3>
|
||||
<p class="text-xs text-secondary leading-relaxed" data-i18n="feature.privacy.desc">Zero server uploads. Your intellectual property stays yours.</p>
|
||||
</div>
|
||||
<div class="p-6 rounded-xl bg-card border border-border hover:border-secondary transition-colors">
|
||||
<div class="w-8 h-8 text-primary mb-4">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
</div>
|
||||
<h3 class="font-medium mb-2 text-sm text-primary" data-i18n="feature.free.title">Forever Free</h3>
|
||||
<p class="text-xs text-secondary leading-relaxed" data-i18n="feature.free.desc">Open source and free to use. No hidden fees or limits.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="multiPreview" class="max-w-7xl mx-auto px-4 pb-24 scroll-mt-24" style="display: none;">
|
||||
<div class="mb-6 flex justify-between items-center">
|
||||
<h3 class="text-xl font-bold text-gray-900">
|
||||
<span id="progressText">处理进度: 0/0</span>
|
||||
</h3>
|
||||
<button id="downloadAllBtn" class="py-2.5 px-6 bg-gray-900 hover:bg-gray-800 text-white rounded-xl font-medium transition-all flex items-center gap-2" style="display: none;">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||
<span data-i18n="btn.downloadAll">全部下载</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="imageList" class="space-y-4"></div>
|
||||
</section>
|
||||
|
||||
<section class="bg-gray-50 py-16 border-t border-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="text-center mb-6 md:mb-12">
|
||||
<h3 class="text-2xl font-bold text-gray-900" data-i18n="feature.title">功能特点</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-8">
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="w-12 h-12 bg-emerald-100 text-primary rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
|
||||
</div>
|
||||
<h4 class="text-lg font-bold text-gray-900 mb-2" data-i18n="feature.speed.title">极速处理</h4>
|
||||
<p class="text-gray-500 text-sm leading-relaxed" data-i18n="feature.speed.desc">基于现代浏览器技术加速处理,毫秒级响应,无需等待排队</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="w-12 h-12 bg-blue-100 text-blue-600 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path></svg>
|
||||
</div>
|
||||
<h4 class="text-lg font-bold text-gray-900 mb-2" data-i18n="feature.privacy.title">隐私安全</h4>
|
||||
<p class="text-gray-500 text-sm leading-relaxed" data-i18n="feature.privacy.desc">纯前端运行,图片数据不离机,绝不上传服务器</p>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-xl shadow-sm hover:shadow-md transition-shadow">
|
||||
<div class="w-12 h-12 bg-purple-100 text-purple-600 rounded-lg flex items-center justify-center mb-4">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z M6 6l12 12"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="text-lg font-bold text-gray-900 mb-2" data-i18n="feature.free.title">完全免费</h4>
|
||||
<p class="text-gray-500 text-sm leading-relaxed" data-i18n="feature.free.desc">无次数限制,无隐藏付费,即开即用</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="bg-emerald-600 text-white py-6 md:pt-12 md:pb-8">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-8 mb-8">
|
||||
<div class="col-span-1 sm:col-span-2 lg:col-span-2">
|
||||
<h5 class="text-2xl font-bold mb-4" data-i18n="header.title">Gemini Watermark Remover</h5>
|
||||
<p class="text-emerald-100 text-sm max-w-sm" data-i18n="footer.desc">
|
||||
Gemini 无损去水印工具,本工具仅供学习交流使用
|
||||
</p>
|
||||
<footer class="border-t border-border mt-auto">
|
||||
<div class="container mx-auto px-6 py-8 flex flex-col md:flex-row justify-between items-center text-xs text-secondary font-mono">
|
||||
<div class="mb-4 md:mb-0">
|
||||
<span data-i18n="footer.copyright">© 2025 Gemini Watermark Remover</span>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="font-bold mb-2 md:mb-4 text-emerald-100" data-i18n="footer.links">链接</h6>
|
||||
<ul class="space-y-1 md:space-y-2 text-sm text-emerald-50">
|
||||
<li><a href="terms.html" class="hover:text-white" data-i18n="footer.terms">使用条款</a></li>
|
||||
<li><a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" rel="noopener noreferrer" class="hover:text-white" data-i18n="footer.github">Github</a></li>
|
||||
<li><a href="userscript/gemini-watermark-remover.user.js" target="_blank" class="hover:text-white" data-i18n="nav.userscript">油猴脚本</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h6 class="font-bold mb-2 md:mb-4 text-emerald-100" data-i18n="footer.tech">技术</h6>
|
||||
<ul class="space-y-1 md:space-y-2 text-sm text-emerald-50">
|
||||
<li><a href="https://allenkuo.medium.com/removing-gemini-ai-watermarks-a-deep-dive-into-reverse-alpha-blending-bbbd83af2a3f" target="_blank" rel="noopener noreferrer" class="hover:text-white" data-i18n="nav.principle">去水印原理</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-emerald-500 pt-8 text-center text-sm text-emerald-200">
|
||||
<p data-i18n="footer.copyright">© Gemini Watermark Remover. All rights reserved.</p>
|
||||
<div class="flex gap-6">
|
||||
<a href="terms.html" class="hover:text-primary transition-colors" data-i18n="footer.terms">Terms</a>
|
||||
<a href="https://github.com/journey-ad/gemini-watermark-remover" target="_blank" class="hover:text-primary transition-colors">GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div id="loadingOverlay" class="fixed inset-0 bg-white/80 backdrop-blur-sm z-[60] hidden flex items-center justify-center">
|
||||
<div class="flex flex-col items-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
|
||||
<p class="text-gray-600 font-medium" data-i18n="loading.text">正在处理...</p>
|
||||
<div id="loadingOverlay" class="fixed inset-0 bg-background/80 backdrop-blur-sm z-[100] hidden flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<p class="text-xs font-mono text-secondary" data-i18n="loading.text">PROCESSING</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
229
src/app.js
|
|
@ -3,6 +3,10 @@ import i18n from './i18n.js';
|
|||
import { loadImage, checkOriginal, getOriginalStatus, setStatusMessage, showLoading, hideLoading } from './utils.js';
|
||||
import JSZip from 'jszip';
|
||||
import mediumZoom from 'medium-zoom';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { jsPDF } from 'jspdf';
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = 'pdf.worker.min.mjs';
|
||||
|
||||
// global state
|
||||
let engine = null;
|
||||
|
|
@ -33,6 +37,7 @@ async function init() {
|
|||
try {
|
||||
await i18n.init();
|
||||
setupLanguageSwitch();
|
||||
setupThemeToggle();
|
||||
showLoading(i18n.t('status.loading'));
|
||||
|
||||
engine = await WatermarkEngine.create();
|
||||
|
|
@ -51,6 +56,47 @@ async function init() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* setup theme toggle
|
||||
*/
|
||||
function setupThemeToggle() {
|
||||
const btn = document.getElementById('themeToggle');
|
||||
const html = document.documentElement;
|
||||
const iconPath = document.getElementById('themeIconPath');
|
||||
|
||||
const sunPath = 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M16 12a4 4 0 11-8 0 4 4 0 018 0z';
|
||||
const moonPath = 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z';
|
||||
|
||||
// Initialize based on current class
|
||||
if (html.classList.contains('dark')) {
|
||||
iconPath.setAttribute('d', sunPath);
|
||||
} else {
|
||||
iconPath.setAttribute('d', moonPath);
|
||||
}
|
||||
|
||||
btn.addEventListener('click', () => {
|
||||
if (html.classList.contains('dark')) {
|
||||
html.classList.remove('dark');
|
||||
html.classList.add('light');
|
||||
iconPath.setAttribute('d', moonPath);
|
||||
localStorage.setItem('theme', 'light');
|
||||
} else {
|
||||
html.classList.remove('light');
|
||||
html.classList.add('dark');
|
||||
iconPath.setAttribute('d', sunPath);
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
});
|
||||
|
||||
// Load from storage
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'light') {
|
||||
html.classList.remove('dark');
|
||||
html.classList.add('light');
|
||||
iconPath.setAttribute('d', moonPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* setup language switch
|
||||
*/
|
||||
|
|
@ -105,8 +151,8 @@ function handleFileSelect(e) {
|
|||
|
||||
function handleFiles(files) {
|
||||
const validFiles = files.filter(file => {
|
||||
if (!file.type.match('image/(jpeg|png|webp)')) return false;
|
||||
if (file.size > 20 * 1024 * 1024) return false;
|
||||
if (!file.type.match('image/(jpeg|png|webp)') && file.type !== 'application/pdf') return false;
|
||||
if (file.size > 50 * 1024 * 1024) return false; // Increased limit for PDF
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
@ -121,6 +167,7 @@ function handleFiles(files) {
|
|||
id: Date.now() + index,
|
||||
file,
|
||||
name: file.name,
|
||||
isPdf: file.type === 'application/pdf',
|
||||
status: 'pending',
|
||||
originalImg: null,
|
||||
processedBlob: null,
|
||||
|
|
@ -145,38 +192,88 @@ function handleFiles(files) {
|
|||
}
|
||||
}
|
||||
|
||||
async function getPdfPreview(file) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
const img = new Image();
|
||||
img.src = canvas.toDataURL();
|
||||
return new Promise(resolve => {
|
||||
img.onload = () => resolve(img);
|
||||
});
|
||||
}
|
||||
|
||||
async function processSingle(item) {
|
||||
try {
|
||||
const img = await loadImage(item.file);
|
||||
let img;
|
||||
if (item.isPdf) {
|
||||
img = await getPdfPreview(item.file);
|
||||
item.originalImg = img;
|
||||
originalInfo.innerHTML = `
|
||||
<p>Type: PDF (NotebookLM)</p>
|
||||
<p>${i18n.t('info.size')}: ${(item.file.size / 1024 / 1024).toFixed(2)} MB</p>
|
||||
`;
|
||||
setStatusMessage('NotebookLM PDF Detected', 'success');
|
||||
} else {
|
||||
img = await loadImage(item.file);
|
||||
item.originalImg = img;
|
||||
|
||||
const { is_google, is_original } = await checkOriginal(item.file);
|
||||
const status = getOriginalStatus({ is_google, is_original });
|
||||
const { is_google, is_original, sourceType } = await checkOriginal(item.file);
|
||||
const status = getOriginalStatus({ is_google, is_original, sourceType });
|
||||
setStatusMessage(status, is_google && is_original ? 'success' : 'warn');
|
||||
|
||||
originalImage.src = img.src;
|
||||
|
||||
const watermarkInfo = engine.getWatermarkInfo(img.width, img.height);
|
||||
originalInfo.innerHTML = `
|
||||
<p>${i18n.t('info.size')}: ${img.width}×${img.height}</p>
|
||||
<p>${i18n.t('info.watermark')}: ${watermarkInfo.size}×${watermarkInfo.size}</p>
|
||||
<p>${i18n.t('info.watermark')}: ${watermarkInfo.width}×${watermarkInfo.height}</p>
|
||||
<p>${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})</p>
|
||||
`;
|
||||
}
|
||||
|
||||
originalImage.src = img.src;
|
||||
|
||||
let blob;
|
||||
let previewUrl;
|
||||
|
||||
if (item.isPdf) {
|
||||
const result = await processPdf(item.file);
|
||||
blob = result.pdfBlob;
|
||||
previewUrl = result.previewDataUrl;
|
||||
} else {
|
||||
// Standard Gemini processing (Alpha Map)
|
||||
const result = await engine.removeWatermarkFromImage(img);
|
||||
const blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
|
||||
item.processedBlob = blob;
|
||||
blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
|
||||
previewUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
item.processedBlob = blob;
|
||||
item.processedUrl = URL.createObjectURL(blob);
|
||||
processedImage.src = item.processedUrl;
|
||||
|
||||
// Use the cleaned preview (for PDF it's the first page cleaned, for image it's the image itself)
|
||||
processedImage.src = previewUrl;
|
||||
|
||||
processedSection.style.display = 'block';
|
||||
downloadBtn.style.display = 'flex';
|
||||
downloadBtn.onclick = () => downloadImage(item);
|
||||
|
||||
if (item.isPdf) {
|
||||
processedInfo.innerHTML = `
|
||||
<p>${i18n.t('info.status')}: ${i18n.t('info.removed')}</p>
|
||||
<p>Output: PDF</p>
|
||||
`;
|
||||
} else {
|
||||
processedInfo.innerHTML = `
|
||||
<p>${i18n.t('info.size')}: ${img.width}×${img.height}</p>
|
||||
<p>${i18n.t('info.status')}: ${i18n.t('info.removed')}</p>
|
||||
`;
|
||||
}
|
||||
|
||||
zoom.detach();
|
||||
zoom.attach('[data-zoomable]');
|
||||
|
|
@ -211,8 +308,14 @@ function createImageCard(item) {
|
|||
}
|
||||
|
||||
async function processQueue() {
|
||||
// Pre-load images/previews
|
||||
await Promise.all(imageQueue.map(async item => {
|
||||
const img = await loadImage(item.file);
|
||||
let img;
|
||||
if (item.isPdf) {
|
||||
img = await getPdfPreview(item.file);
|
||||
} else {
|
||||
img = await loadImage(item.file);
|
||||
}
|
||||
item.originalImg = img;
|
||||
item.originalUrl = img.src;
|
||||
document.getElementById(`result-${item.id}`).src = img.src;
|
||||
|
|
@ -228,19 +331,36 @@ async function processQueue() {
|
|||
updateStatus(item.id, i18n.t('status.processing'));
|
||||
|
||||
try {
|
||||
let blob;
|
||||
let previewUrl;
|
||||
if (item.isPdf) {
|
||||
const res = await processPdf(item.file, (page, total) => {
|
||||
updateStatus(item.id, `Processing page ${page}/${total}...`);
|
||||
});
|
||||
blob = res.pdfBlob;
|
||||
previewUrl = res.previewDataUrl;
|
||||
} else {
|
||||
const result = await engine.removeWatermarkFromImage(item.originalImg);
|
||||
const blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
|
||||
item.processedBlob = blob;
|
||||
blob = await new Promise(resolve => result.toBlob(resolve, 'image/png'));
|
||||
previewUrl = URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
item.processedBlob = blob;
|
||||
item.processedUrl = URL.createObjectURL(blob);
|
||||
document.getElementById(`result-${item.id}`).src = item.processedUrl;
|
||||
|
||||
// Update with cleaned preview
|
||||
document.getElementById(`result-${item.id}`).src = previewUrl;
|
||||
|
||||
item.status = 'completed';
|
||||
const watermarkInfo = engine.getWatermarkInfo(item.originalImg.width, item.originalImg.height);
|
||||
|
||||
if (item.isPdf) {
|
||||
updateStatus(item.id, `PDF Processed`, true);
|
||||
} else {
|
||||
const watermarkInfo = engine.getWatermarkInfo(item.originalImg.width, item.originalImg.height);
|
||||
updateStatus(item.id, `<p>${i18n.t('info.size')}: ${item.originalImg.width}×${item.originalImg.height}</p>
|
||||
<p>${i18n.t('info.watermark')}: ${watermarkInfo.size}×${watermarkInfo.size}</p>
|
||||
<p>${i18n.t('info.watermark')}: ${watermarkInfo.width}×${watermarkInfo.height}</p>
|
||||
<p>${i18n.t('info.position')}: (${watermarkInfo.position.x},${watermarkInfo.position.y})</p>`, true);
|
||||
}
|
||||
|
||||
const downloadBtn = document.getElementById(`download-${item.id}`);
|
||||
downloadBtn.classList.remove('hidden');
|
||||
|
|
@ -249,13 +369,15 @@ async function processQueue() {
|
|||
processedCount++;
|
||||
updateProgress();
|
||||
|
||||
checkOriginal(item.originalImg).then(({ is_google, is_original }) => {
|
||||
if (!item.isPdf) {
|
||||
checkOriginal(item.file).then(({ is_google, is_original, sourceType }) => {
|
||||
if (!is_google || !is_original) {
|
||||
const status = getOriginalStatus({ is_google, is_original });
|
||||
const status = getOriginalStatus({ is_google, is_original, sourceType });
|
||||
const statusEl = document.getElementById(`status-${item.id}`);
|
||||
if (statusEl) statusEl.innerHTML += `<p class="inline-block mt-1 text-xs md:text-sm text-warn">${status}</p>`;
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (error) {
|
||||
item.status = 'error';
|
||||
updateStatus(item.id, i18n.t('status.failed'));
|
||||
|
|
@ -287,7 +409,10 @@ function updateDynamicTexts() {
|
|||
function downloadImage(item) {
|
||||
const a = document.createElement('a');
|
||||
a.href = item.processedUrl;
|
||||
a.download = `unwatermarked_${item.name.replace(/\.[^.]+$/, '')}.png`;
|
||||
const ext = item.isPdf ? 'pdf' : 'png';
|
||||
// Filename rule: original_cleaned.ext
|
||||
const originalBase = item.name.replace(/\.[^.]+$/, '');
|
||||
a.download = `${originalBase}_cleaned.${ext}`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
|
|
@ -297,15 +422,75 @@ async function downloadAll() {
|
|||
|
||||
const zip = new JSZip();
|
||||
completed.forEach(item => {
|
||||
const filename = `unwatermarked_${item.name.replace(/\.[^.]+$/, '')}.png`;
|
||||
const ext = item.isPdf ? 'pdf' : 'png';
|
||||
const originalBase = item.name.replace(/\.[^.]+$/, '');
|
||||
const filename = `${originalBase}_cleaned.${ext}`;
|
||||
zip.file(filename, item.processedBlob);
|
||||
});
|
||||
|
||||
const blob = await zip.generateAsync({ type: 'blob' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `unwatermarked_${Date.now()}.zip`;
|
||||
a.download = `cleaned_files_${Date.now()}.zip`;
|
||||
a.click();
|
||||
}
|
||||
|
||||
async function processPdf(file, onProgress) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await pdfjsLib.getDocument(arrayBuffer).promise;
|
||||
const total = pdf.numPages;
|
||||
let doc = null;
|
||||
let previewDataUrl = null;
|
||||
|
||||
for (let i = 1; i <= total; i++) {
|
||||
if (onProgress) onProgress(i, total);
|
||||
const page = await pdf.getPage(i);
|
||||
const viewport = page.getViewport({ scale: 2 });
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
|
||||
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||
|
||||
// For PDF, we use Simple Clone (NotebookLM config) as fallback,
|
||||
// because we don't know if it's Gemini or NotebookLM strictly from just being a PDF.
|
||||
// But engine has `removeWatermarkFromImage` which checks dimensions.
|
||||
// So we just call that. If it's Gemini dimensions, it uses Alpha Map.
|
||||
// If it's NotebookLM dimensions, it uses NotebookLM Alpha Map.
|
||||
// If it's neither? Gemini 48px Alpha Map (default).
|
||||
// This might be wrong for generic slides.
|
||||
// But consistent with "Original Code" behavior.
|
||||
|
||||
// However, for NotebookLM PDFs, I previously used `removeWatermarkSimpleClone` with `NOTEBOOK_CLONE_CONFIG`.
|
||||
// If I use `engine.removeWatermarkFromImage`, it might fail if page size doesn't match exactly.
|
||||
// I will use `forceSimpleClone: true` for PDF pages to ensure cleaning, as PDFs are often resampled/sized differently.
|
||||
// Wait, `removeWatermarkFromImage` supports `forceSimpleClone` option which uses `NOTEBOOK_CLONE_CONFIG` if type is notebooklm OR default.
|
||||
// In `detectWatermarkConfig`, if not NotebookLM, it defaults to Gemini.
|
||||
// If I force clone on Gemini type, it uses GEMINI_CLONE_CONFIG? No, I didn't add GEMINI_CLONE_CONFIG in the *restored* engine.
|
||||
// I only added `NOTEBOOK_CLONE_CONFIG`.
|
||||
// So I should force clone for PDF to use `NOTEBOOK_CLONE_CONFIG`?
|
||||
// Let's assume PDF pages are NotebookLM slides.
|
||||
|
||||
await engine.removeWatermarkFromImage(canvas, { forceSimpleClone: true });
|
||||
|
||||
const imgData = canvas.toDataURL('image/jpeg', 0.9);
|
||||
|
||||
// Capture first page as preview
|
||||
if (i === 1) {
|
||||
previewDataUrl = imgData;
|
||||
doc = new jsPDF({
|
||||
orientation: viewport.width > viewport.height ? 'l' : 'p',
|
||||
unit: 'px',
|
||||
format: [viewport.width, viewport.height]
|
||||
});
|
||||
} else {
|
||||
doc.addPage([viewport.width, viewport.height], viewport.width > viewport.height ? 'l' : 'p');
|
||||
}
|
||||
doc.addImage(imgData, 'JPEG', 0, 0, viewport.width, viewport.height);
|
||||
}
|
||||
|
||||
return { pdfBlob: doc.output('blob'), previewDataUrl };
|
||||
}
|
||||
|
||||
init();
|
||||
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
|
@ -7,25 +7,68 @@ import { calculateAlphaMap } from './alphaMap.js';
|
|||
import { removeWatermark } from './blendModes.js';
|
||||
import BG_48_PATH from '../assets/bg_48.png';
|
||||
import BG_96_PATH from '../assets/bg_96.png';
|
||||
import BG_NOTEBOOK_PATH from '../assets/bg_notebook.png';
|
||||
import BG_NOTEBOOK_LARGE_PATH from '../assets/bg_notebook_large.png';
|
||||
|
||||
const NOTEBOOK_CLONE_CONFIG = {
|
||||
wRatio: 0.0825, wRef: 'long',
|
||||
hRatio: 0.0375, hRef: 'short',
|
||||
mrRatio: 0.0025, mrRef: 'long',
|
||||
mbRatio: 0.0027, mbRef: 'short',
|
||||
featherSize: 12
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if image is from NotebookLM based on exact dimensions
|
||||
*/
|
||||
function isNotebookLM(width, height) {
|
||||
return (width === 571 && height === 1024) ||
|
||||
(width === 1536 && height === 2752) ||
|
||||
(width === 2752 && height === 1536);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect watermark configuration based on image size
|
||||
* @param {number} imageWidth - Image width
|
||||
* @param {number} imageHeight - Image height
|
||||
* @returns {Object} Watermark configuration {logoSize, marginRight, marginBottom}
|
||||
* @returns {Object} Watermark configuration
|
||||
*/
|
||||
export function detectWatermarkConfig(imageWidth, imageHeight) {
|
||||
// Gemini's watermark rules:
|
||||
// NotebookLM detection
|
||||
if (isNotebookLM(imageWidth, imageHeight)) {
|
||||
if (imageWidth === 571 && imageHeight === 1024) {
|
||||
return {
|
||||
type: 'notebooklm',
|
||||
logoWidth: 72,
|
||||
logoHeight: 9,
|
||||
marginRight: 3,
|
||||
marginBottom: 3
|
||||
};
|
||||
} else {
|
||||
// Large NotebookLM
|
||||
return {
|
||||
type: 'notebooklm',
|
||||
logoWidth: 190,
|
||||
logoHeight: 20,
|
||||
marginRight: 11,
|
||||
marginBottom: 10
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini's watermark rules (unchanged):
|
||||
// If both image width and height are greater than 1024, use 96×96 watermark
|
||||
// Otherwise, use 48×48 watermark
|
||||
if (imageWidth > 1024 && imageHeight > 1024) {
|
||||
return {
|
||||
type: 'gemini',
|
||||
logoSize: 96,
|
||||
marginRight: 64,
|
||||
marginBottom: 64
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: 'gemini',
|
||||
logoSize: 48,
|
||||
marginRight: 32,
|
||||
marginBottom: 32
|
||||
|
|
@ -37,17 +80,19 @@ export function detectWatermarkConfig(imageWidth, imageHeight) {
|
|||
* Calculate watermark position in image based on image size and watermark configuration
|
||||
* @param {number} imageWidth - Image width
|
||||
* @param {number} imageHeight - Image height
|
||||
* @param {Object} config - Watermark configuration {logoSize, marginRight, marginBottom}
|
||||
* @param {Object} config - Watermark configuration
|
||||
* @returns {Object} Watermark position {x, y, width, height}
|
||||
*/
|
||||
export function calculateWatermarkPosition(imageWidth, imageHeight, config) {
|
||||
const { logoSize, marginRight, marginBottom } = config;
|
||||
const logoWidth = config.logoWidth || config.logoSize;
|
||||
const logoHeight = config.logoHeight || config.logoSize;
|
||||
const { marginRight, marginBottom } = config;
|
||||
|
||||
return {
|
||||
x: imageWidth - marginRight - logoSize,
|
||||
y: imageHeight - marginBottom - logoSize,
|
||||
width: logoSize,
|
||||
height: logoSize
|
||||
x: imageWidth - marginRight - logoWidth,
|
||||
y: imageHeight - marginBottom - logoHeight,
|
||||
width: logoWidth,
|
||||
height: logoHeight
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -64,6 +109,8 @@ export class WatermarkEngine {
|
|||
static async create() {
|
||||
const bg48 = new Image();
|
||||
const bg96 = new Image();
|
||||
const bgNotebook = new Image();
|
||||
const bgNotebookLarge = new Image();
|
||||
|
||||
await Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
|
|
@ -75,10 +122,20 @@ export class WatermarkEngine {
|
|||
bg96.onload = resolve;
|
||||
bg96.onerror = reject;
|
||||
bg96.src = BG_96_PATH;
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
bgNotebook.onload = resolve;
|
||||
bgNotebook.onerror = reject;
|
||||
bgNotebook.src = BG_NOTEBOOK_PATH;
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
bgNotebookLarge.onload = resolve;
|
||||
bgNotebookLarge.onerror = reject;
|
||||
bgNotebookLarge.src = BG_NOTEBOOK_LARGE_PATH;
|
||||
})
|
||||
]);
|
||||
|
||||
return new WatermarkEngine({ bg48, bg96 });
|
||||
return new WatermarkEngine({ bg48, bg96, bgNotebook, bgNotebookLarge });
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -114,11 +171,119 @@ export class WatermarkEngine {
|
|||
}
|
||||
|
||||
/**
|
||||
* Remove watermark from image based on watermark size
|
||||
* Remove NotebookLM watermark using alpha map and background estimation
|
||||
*/
|
||||
removeNotebookLMWatermark(imageData, position, isLarge) {
|
||||
const { x, y, width, height } = position;
|
||||
const imgWidth = imageData.width;
|
||||
|
||||
// Get alpha map
|
||||
const bgImage = isLarge ? this.bgCaptures.bgNotebookLarge : this.bgCaptures.bgNotebook;
|
||||
|
||||
// Create canvas to extract alpha map
|
||||
const alphaCanvas = document.createElement('canvas');
|
||||
alphaCanvas.width = width;
|
||||
alphaCanvas.height = height;
|
||||
const alphaCtx = alphaCanvas.getContext('2d');
|
||||
alphaCtx.drawImage(bgImage, 0, 0, width, height);
|
||||
const alphaData = alphaCtx.getImageData(0, 0, width, height);
|
||||
|
||||
// Calculate background colors from pixels above the watermark
|
||||
const bgColors = [];
|
||||
for (let col = 0; col < width; col++) {
|
||||
let sumR = 0, sumG = 0, sumB = 0, count = 0;
|
||||
for (let dy = -25; dy < -5; dy++) {
|
||||
const sampleY = y + dy;
|
||||
if (sampleY >= 0) {
|
||||
const idx = (sampleY * imgWidth + (x + col)) * 4;
|
||||
sumR += imageData.data[idx];
|
||||
sumG += imageData.data[idx + 1];
|
||||
sumB += imageData.data[idx + 2];
|
||||
count++;
|
||||
}
|
||||
}
|
||||
bgColors.push({
|
||||
r: count > 0 ? sumR / count : 0,
|
||||
g: count > 0 ? sumG / count : 0,
|
||||
b: count > 0 ? sumB / count : 0
|
||||
});
|
||||
}
|
||||
|
||||
// Process each pixel in the watermark region
|
||||
// Use background replacement for all watermark pixels with alpha blending at edges
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
const alphaIdx = (row * width + col) * 4;
|
||||
const alpha = alphaData.data[alphaIdx] / 255.0;
|
||||
|
||||
if (alpha < 0.02) continue; // Skip nearly transparent pixels
|
||||
|
||||
const imgIdx = ((y + row) * imgWidth + (x + col)) * 4;
|
||||
const bg = bgColors[col];
|
||||
|
||||
// Blend current pixel with background based on alpha
|
||||
const blendFactor = Math.min(alpha * 2.0, 1.0);
|
||||
|
||||
for (let c = 0; c < 3; c++) {
|
||||
const current = imageData.data[imgIdx + c];
|
||||
const bgVal = c === 0 ? bg.r : (c === 1 ? bg.g : bg.b);
|
||||
const blended = current * (1 - blendFactor) + bgVal * blendFactor;
|
||||
imageData.data[imgIdx + c] = Math.round(blended);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove watermark using simple cloning technique (SlideClean approach)
|
||||
* Robust for NotebookLM PDFs and images with varying sizes
|
||||
*/
|
||||
removeWatermarkSimpleClone(ctx, width, height, config = NOTEBOOK_CLONE_CONFIG) {
|
||||
const longEdge = Math.max(width, height);
|
||||
const shortEdge = Math.min(width, height);
|
||||
|
||||
const getDim = (ratio, ref) => Math.round((ref === 'short' ? shortEdge : longEdge) * ratio);
|
||||
|
||||
const wmW = getDim(config.wRatio, config.wRef);
|
||||
const wmH = getDim(config.hRatio, config.hRef);
|
||||
const mr = getDim(config.mrRatio, config.mrRef);
|
||||
const mb = getDim(config.mbRatio, config.mbRef);
|
||||
const feather = config.featherSize || 12;
|
||||
|
||||
const x = width - wmW - mr;
|
||||
const y = height - wmH - mb;
|
||||
const srcY = Math.max(0, y - wmH);
|
||||
|
||||
if (srcY < 0) return;
|
||||
|
||||
const src = ctx.getImageData(x, srcY, wmW, wmH);
|
||||
const dst = ctx.getImageData(x, y, wmW, wmH);
|
||||
const res = ctx.createImageData(wmW, wmH);
|
||||
|
||||
for (let i = 0; i < wmH; i++) {
|
||||
for (let j = 0; j < wmW; j++) {
|
||||
const idx = (i * wmW + j) * 4;
|
||||
let a = 1.0;
|
||||
// Simple feathering at edges
|
||||
if (i < feather) a = Math.min(a, i / feather);
|
||||
if (j < feather) a = Math.min(a, j / feather);
|
||||
|
||||
for (let c = 0; c < 4; c++) {
|
||||
res.data[idx + c] = Math.round(dst.data[idx + c] * (1 - a) + src.data[idx + c] * a);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.putImageData(res, x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove watermark from image
|
||||
* @param {HTMLImageElement|HTMLCanvasElement} image - Input image
|
||||
* @param {Object} [options] - Options
|
||||
* @param {boolean} [options.forceSimpleClone=false] - Force use of simple clone method
|
||||
* @returns {Promise<HTMLCanvasElement>} Processed canvas
|
||||
*/
|
||||
async removeWatermarkFromImage(image) {
|
||||
async removeWatermarkFromImage(image, options = {}) {
|
||||
// Create canvas to process image
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.width;
|
||||
|
|
@ -128,6 +293,12 @@ export class WatermarkEngine {
|
|||
// Draw original image onto canvas
|
||||
ctx.drawImage(image, 0, 0);
|
||||
|
||||
// Use simple clone if forced (e.g. for PDFs or explicit override)
|
||||
if (options.forceSimpleClone) {
|
||||
this.removeWatermarkSimpleClone(ctx, canvas.width, canvas.height, NOTEBOOK_CLONE_CONFIG);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Get image data
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
|
|
@ -135,11 +306,15 @@ export class WatermarkEngine {
|
|||
const config = detectWatermarkConfig(canvas.width, canvas.height);
|
||||
const position = calculateWatermarkPosition(canvas.width, canvas.height, config);
|
||||
|
||||
// Get alpha map for watermark size
|
||||
if (config.type === 'notebooklm') {
|
||||
// Use alpha map + background estimation for NotebookLM
|
||||
const isLarge = canvas.width > 1000 || canvas.height > 1000;
|
||||
this.removeNotebookLMWatermark(imageData, position, isLarge);
|
||||
} else {
|
||||
// Original Gemini logic - unchanged, using static assets for 48/96
|
||||
const alphaMap = await this.getAlphaMap(config.logoSize);
|
||||
|
||||
// Remove watermark from image data
|
||||
removeWatermark(imageData, alphaMap, position);
|
||||
}
|
||||
|
||||
// Write processed image data back to canvas
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
|
@ -158,7 +333,10 @@ export class WatermarkEngine {
|
|||
const position = calculateWatermarkPosition(imageWidth, imageHeight, config);
|
||||
|
||||
return {
|
||||
size: config.logoSize,
|
||||
size: config.logoSize || config.logoWidth,
|
||||
width: config.logoWidth || config.logoSize,
|
||||
height: config.logoHeight || config.logoSize,
|
||||
type: config.type,
|
||||
position: position,
|
||||
config: config
|
||||
};
|
||||
|
|
|
|||