feat: Add image uploader and metadata viewer components
- Implemented ImageUploader.svelte for drag-and-drop image uploads with metadata extraction. - Created MetadataViewer.svelte to display extracted metadata in a user-friendly format. - Added PrivacyInfo.svelte to inform users about client-side processing and privacy risks. - Introduced ThemeToggle.svelte for dark mode functionality. - Set up main.js for application entry point and configured Svelte with vite. - Added Tailwind CSS for styling and configured Vite for asset management. - Established svelte.config.js and tailwind.config.js for project settings.
This commit is contained in:
1 parent
cf0d872dba
commit
cbce104341
18 files changed
+5263
No files matched your search
+333
@@ -0,0 +1,333 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import ImageUploader from "./lib/ImageUploader.svelte";
|
||||
import MetadataViewer from "./lib/MetadataViewer.svelte";
|
||||
import ImageProcessor from "./lib/ImageProcessor.svelte";
|
||||
import ThemeToggle from "./lib/ThemeToggle.svelte";
|
||||
import Header from "./lib/Header.svelte";
|
||||
import Footer from "./lib/Footer.svelte";
|
||||
import PrivacyInfo from "./lib/PrivacyInfo.svelte";
|
||||
|
||||
let selectedFile = null;
|
||||
let metadata = null;
|
||||
let processedImage = null;
|
||||
let isProcessing = false;
|
||||
let showMetadata = false;
|
||||
let showFileInfo = false;
|
||||
let showPrivacyInfo = false;
|
||||
let darkMode = false;
|
||||
|
||||
onMount(() => {
|
||||
const savedTheme = localStorage.getItem("theme");
|
||||
if (savedTheme) {
|
||||
darkMode = savedTheme === "dark";
|
||||
} else {
|
||||
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
updateTheme();
|
||||
});
|
||||
|
||||
function updateTheme() {
|
||||
if (darkMode) {
|
||||
document.documentElement.setAttribute("data-theme", "dark");
|
||||
} else {
|
||||
document.documentElement.removeAttribute("data-theme");
|
||||
}
|
||||
localStorage.setItem("theme", darkMode ? "dark" : "light");
|
||||
}
|
||||
|
||||
function handleThemeToggle() {
|
||||
darkMode = !darkMode;
|
||||
updateTheme();
|
||||
}
|
||||
|
||||
function handleFileSelected(event) {
|
||||
selectedFile = event.detail.file;
|
||||
metadata = event.detail.metadata;
|
||||
processedImage = null;
|
||||
showMetadata = false;
|
||||
}
|
||||
|
||||
function handleProcessingStart() {
|
||||
isProcessing = true;
|
||||
}
|
||||
|
||||
function handleProcessingComplete(event) {
|
||||
isProcessing = false;
|
||||
processedImage = event.detail.processedImage;
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
selectedFile = null;
|
||||
metadata = null;
|
||||
processedImage = null;
|
||||
isProcessing = false;
|
||||
showMetadata = false;
|
||||
}
|
||||
|
||||
function toggleMetadata() {
|
||||
showMetadata = !showMetadata;
|
||||
}
|
||||
|
||||
function toggleFileInfo() {
|
||||
showFileInfo = !showFileInfo;
|
||||
}
|
||||
|
||||
function togglePrivacyInfo() {
|
||||
showPrivacyInfo = !showPrivacyInfo;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ThemeToggle {darkMode} on:toggle={handleThemeToggle} />
|
||||
|
||||
<main class="container py-8">
|
||||
<Header />
|
||||
|
||||
<div class="grid gap-6">
|
||||
<section class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Upload Your Image</h2>
|
||||
</div>
|
||||
|
||||
<ImageUploader
|
||||
on:fileSelected={handleFileSelected}
|
||||
on:reset={handleReset}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Processing Section -->
|
||||
{#if selectedFile}
|
||||
<section class="grid grid-2 gap-6">
|
||||
<div class="card">
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<h3 class="text-lg font-semibold">Original</h3>
|
||||
{#if metadata}
|
||||
<button class="btn btn-ghost text-sm" on:click={toggleMetadata}>
|
||||
{showMetadata ? "Hide" : "Show"} Metadata
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="text-center mb-3">
|
||||
<img
|
||||
src={URL.createObjectURL(selectedFile)}
|
||||
alt="Original"
|
||||
class="image-preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-secondary">
|
||||
<p>
|
||||
{selectedFile.name} • {(selectedFile.size / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
{#if metadata && metadata.message && !metadata.hasMetadata}
|
||||
<p class="text-success">✓ No metadata detected</p>
|
||||
{:else if metadata}
|
||||
<p class="text-warning">
|
||||
⚠️ {Object.keys(metadata).filter(
|
||||
(key) => !["fileType", "hasMetadata", "message"].includes(key)
|
||||
).length} metadata entries found
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showMetadata && metadata}
|
||||
<div class="mt-4">
|
||||
<MetadataViewer {metadata} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 class="text-lg font-semibold mb-3">Clean Metadata</h3>
|
||||
<ImageProcessor
|
||||
file={selectedFile}
|
||||
on:processingStart={handleProcessingStart}
|
||||
on:processingComplete={handleProcessingComplete}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
|
||||
{#if isProcessing}
|
||||
<div class="mt-3">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: 100%;"></div>
|
||||
</div>
|
||||
<p class="text-xs text-secondary mt-1">Processing...</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if processedImage}
|
||||
<div class="mt-4 slide-up">
|
||||
<h4 class="font-medium mb-2">✅ Clean Image</h4>
|
||||
<div class="text-center mb-3">
|
||||
<img
|
||||
src={processedImage.url}
|
||||
alt="Cleaned"
|
||||
class="image-preview"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-secondary mb-3">
|
||||
<p>
|
||||
{(processedImage.size / 1024).toFixed(1)} KB •
|
||||
{processedImage.compressionRatio
|
||||
? `${processedImage.compressionRatio > 0 ? "-" : "+"}${Math.abs(processedImage.compressionRatio)}% size`
|
||||
: "Processed"}
|
||||
</p>
|
||||
{#if processedImage.wasSecondPass}
|
||||
<p class="status-warning">
|
||||
⚠️ Used enhanced cleaning (second pass)
|
||||
</p>
|
||||
{/if}
|
||||
<p class="status-success">✓ All EXIF metadata removed</p>
|
||||
<p class="status-success">✓ AI/C2PA provenance data stripped</p>
|
||||
<p class="status-success">✓ Location data cleared</p>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={processedImage.url}
|
||||
download={processedImage.filename}
|
||||
class="btn btn-success btn-lg w-full"
|
||||
>
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Download Cleaned Image
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="card">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">🔐 Privacy & Security</h2>
|
||||
<button
|
||||
class="btn btn-ghost text-sm"
|
||||
on:click={togglePrivacyInfo}
|
||||
type="button"
|
||||
>
|
||||
{showPrivacyInfo ? "Hide" : "Show"} Details
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<p class="text-sm text-secondary">
|
||||
<span class="text-success font-semibold">✓</span> 100% client-side
|
||||
processing
|
||||
<span class="mx-2">•</span>
|
||||
<span class="text-success font-semibold">✓</span> No data collection
|
||||
<span class="mx-2">•</span>
|
||||
<span class="text-success font-semibold">✓</span> Open source
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if showPrivacyInfo}
|
||||
<PrivacyInfo />
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
color: var(--warning-color, #f59e0b);
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 200px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-height: 150px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
@import "tailwindcss";
|
||||
:root {
|
||||
--primary-color: #2563eb;
|
||||
--primary-hover: #1d4ed8;
|
||||
--secondary-color: #64748b;
|
||||
--success-color: #10b981;
|
||||
--danger-color: #ef4444;
|
||||
--warning-color: #f59e0b;
|
||||
|
||||
--bg-primary: #ffffff;
|
||||
--bg-secondary: #f8fafc;
|
||||
--bg-tertiary: #f1f5f9;
|
||||
--text-primary: #0f172a;
|
||||
--text-secondary: #475569;
|
||||
--text-muted: #94a3b8;
|
||||
--border-color: #e2e8f0;
|
||||
--shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1),
|
||||
0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
|
||||
/* Dark mode colors */
|
||||
--dark-bg-primary: #0f172a;
|
||||
--dark-bg-secondary: #1e293b;
|
||||
--dark-bg-tertiary: #334155;
|
||||
--dark-text-primary: #f8fafc;
|
||||
--dark-text-secondary: #cbd5e1;
|
||||
--dark-text-muted: #64748b;
|
||||
--dark-border-color: #334155;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg-primary: var(--dark-bg-primary);
|
||||
--bg-secondary: var(--dark-bg-secondary);
|
||||
--bg-tertiary: var(--dark-bg-tertiary);
|
||||
--text-primary: var(--dark-text-primary);
|
||||
--text-secondary: var(--dark-text-secondary);
|
||||
--text-muted: var(--dark-text-muted);
|
||||
--border-color: var(--dark-border-color);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||
sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
text-decoration: none;
|
||||
outline: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn:focus-visible {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--bg-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background-color: #059669;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: var(--shadow);
|
||||
padding: 1.5rem;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: rgba(37, 99, 235, 0.05);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
margin: 0 auto 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.metadata-viewer {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
font-family: "Monaco", "Menlo", monospace;
|
||||
font-size: 0.875rem;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
max-width: 100%;
|
||||
max-height: 300px;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.status-message {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 500;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
background-color: rgba(16, 185, 129, 0.1);
|
||||
color: var(--success-color);
|
||||
border: 1px solid rgba(16, 185, 129, 0.2);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
color: var(--danger-color);
|
||||
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
color: var(--warning-color);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-4 {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.gap-6 {
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mb-6 {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.mt-6 {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.py-2 {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.py-8 {
|
||||
padding-top: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.px-4 {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.875rem 1.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.text-3xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Theme Toggle */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 1000;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50%;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: var(--shadow);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* Accessibility */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus styles for better accessibility */
|
||||
input[type="file"]:focus-visible + label {
|
||||
outline: 2px solid var(--primary-color);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
<footer class="mt-12 py-8 border-t border-color text-center">
|
||||
<div class="container">
|
||||
<div class="grid grid-2 gap-8 mb-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">About Untrace</h3>
|
||||
<p class="text-sm text-secondary">
|
||||
A privacy-first tool that removes EXIF metadata from images completely
|
||||
in your browser. No servers, no tracking, no data collection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Open Source</h3>
|
||||
<p class="text-sm text-secondary mb-2">
|
||||
Built with Svelte and deployed on GitHub Pages.
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/yourusername/untrace"
|
||||
class="btn btn-secondary text-sm"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
View Source
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-muted">
|
||||
<p>
|
||||
© 2025 Untrace. Built for privacy advocates, journalists, and digital
|
||||
rights.
|
||||
</p>
|
||||
<p class="mt-1">No analytics • No tracking • No data collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.border-t {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,54 @@
|
||||
<header class="text-center mb-6">
|
||||
<div class="flex items-center justify-center gap-3 mb-3">
|
||||
<div class="logo">
|
||||
<svg width="40" height="40" viewBox="0 0 48 48" fill="none">
|
||||
<rect width="48" height="48" rx="12" fill="var(--primary-color)" />
|
||||
<path
|
||||
d="M12 18h24v12H12V18z"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
fill="none"
|
||||
/>
|
||||
<path
|
||||
d="M16 22l4 4 8-8"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M32 14l4 4-4 4"
|
||||
stroke="white"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Untrace</h1>
|
||||
<p class="text-base text-secondary">Remove metadata. Protect privacy.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-secondary max-w-xl mx-auto">
|
||||
<strong>100% browser-based</strong> metadata removal tool. No uploads, no tracking.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.max-w-xl {
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
.mx-auto {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,287 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let file;
|
||||
export let disabled = false;
|
||||
|
||||
async function processImage() {
|
||||
if (!file || disabled) return;
|
||||
|
||||
dispatch("processingStart");
|
||||
|
||||
try {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = function () {
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
let outputType = "image/jpeg";
|
||||
let quality = 0.92;
|
||||
if (file.type === "image/png" && img.width * img.height < 1000000) {
|
||||
outputType = "image/png";
|
||||
quality = undefined;
|
||||
}
|
||||
|
||||
canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
verifyCleanImage(blob, file);
|
||||
} else {
|
||||
throw new Error("Failed to create blob from canvas");
|
||||
}
|
||||
},
|
||||
outputType,
|
||||
quality
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error processing with canvas:", error);
|
||||
handleFallback();
|
||||
}
|
||||
};
|
||||
|
||||
img.onerror = function () {
|
||||
console.error("Error loading image");
|
||||
handleFallback();
|
||||
};
|
||||
|
||||
img.src = URL.createObjectURL(file);
|
||||
} catch (error) {
|
||||
console.error("Error in processImage:", error);
|
||||
handleFallback();
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyCleanImage(cleanBlob, originalFile) {
|
||||
try {
|
||||
const { parse } = await import("exifr");
|
||||
|
||||
const cleanMetadata = await parse(cleanBlob, {
|
||||
tiff: true,
|
||||
exif: true,
|
||||
gps: true,
|
||||
xmp: true,
|
||||
iptc: true,
|
||||
icc: true,
|
||||
silentErrors: true,
|
||||
});
|
||||
|
||||
const hasRemainingMetadata =
|
||||
cleanMetadata && Object.keys(cleanMetadata).length > 0;
|
||||
|
||||
if (hasRemainingMetadata) {
|
||||
console.warn("Some metadata may still be present:", cleanMetadata);
|
||||
// For extra security, try a second pass with different format
|
||||
await performSecondPass(cleanBlob, originalFile);
|
||||
} else {
|
||||
console.log("✅ Image successfully cleaned - no metadata detected");
|
||||
finalizeCleanImage(cleanBlob, originalFile);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(
|
||||
"Could not verify metadata removal (this is usually okay):",
|
||||
error
|
||||
);
|
||||
// Proceed anyway - the canvas method should have worked
|
||||
finalizeCleanImage(cleanBlob, originalFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass cleaning for stubborn metadata
|
||||
async function performSecondPass(blob, originalFile) {
|
||||
try {
|
||||
console.log("Performing second pass metadata removal...");
|
||||
|
||||
const img = new Image();
|
||||
img.onload = function () {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
|
||||
// Use a different approach - explicitly clear and redraw
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = "#FFFFFF";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
// Force JPEG output for maximum metadata removal
|
||||
canvas.toBlob(
|
||||
(finalBlob) => {
|
||||
if (finalBlob) {
|
||||
console.log("✅ Second pass completed");
|
||||
finalizeCleanImage(finalBlob, originalFile, true);
|
||||
} else {
|
||||
finalizeCleanImage(blob, originalFile);
|
||||
}
|
||||
},
|
||||
"image/jpeg",
|
||||
0.95
|
||||
);
|
||||
};
|
||||
|
||||
img.onerror = () => finalizeCleanImage(blob, originalFile);
|
||||
img.src = URL.createObjectURL(blob);
|
||||
} catch (error) {
|
||||
console.error("Second pass failed:", error);
|
||||
finalizeCleanImage(blob, originalFile);
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize the cleaned image
|
||||
function finalizeCleanImage(blob, originalFile, wasSecondPass = false) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const originalExtension = originalFile.name.split(".").pop();
|
||||
const nameWithoutExt = originalFile.name.replace(
|
||||
new RegExp(`\\.${originalExtension}$`, "i"),
|
||||
""
|
||||
);
|
||||
|
||||
// Determine final extension based on blob type
|
||||
let finalExtension = originalExtension;
|
||||
if (blob.type === "image/jpeg") {
|
||||
finalExtension = "jpg";
|
||||
} else if (blob.type === "image/png") {
|
||||
finalExtension = "png";
|
||||
}
|
||||
|
||||
const filename = `${nameWithoutExt}-cleaned.${finalExtension}`;
|
||||
|
||||
dispatch("processingComplete", {
|
||||
processedImage: {
|
||||
url,
|
||||
filename,
|
||||
size: blob.size,
|
||||
wasSecondPass,
|
||||
originalSize: originalFile.size,
|
||||
compressionRatio: (
|
||||
((originalFile.size - blob.size) / originalFile.size) *
|
||||
100
|
||||
).toFixed(1),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleFallback() {
|
||||
// Fallback: return original file
|
||||
const url = URL.createObjectURL(file);
|
||||
const fileExtension = file.name.split(".").pop();
|
||||
const nameWithoutExt = file.name.replace(
|
||||
new RegExp(`\\.${fileExtension}$`, "i"),
|
||||
""
|
||||
);
|
||||
const filename = `${nameWithoutExt}-cleaned.${fileExtension}`;
|
||||
|
||||
dispatch("processingComplete", {
|
||||
processedImage: {
|
||||
url,
|
||||
filename,
|
||||
size: file.size,
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="processor-section">
|
||||
<div class="text-center mb-6">
|
||||
<p class="text-secondary mb-4">
|
||||
Remove all EXIF metadata from your image to protect your privacy. This
|
||||
process happens entirely in your browser - no data is sent to any server.
|
||||
</p>
|
||||
|
||||
<button class="btn btn-primary btn-lg" on:click={processImage} {disabled}>
|
||||
{#if disabled}
|
||||
<svg
|
||||
class="animate-spin"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4 2a2 2 0 00-2 2v11a2 2 0 002 2h12a2 2 0 002-2V4a2 2 0 00-2-2H4zm0 2h12v11H4V4z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Processing...
|
||||
{:else}
|
||||
<svg width="20" height="20" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Remove Metadata
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="privacy-features">
|
||||
<h4 class="font-semibold mb-3">What gets removed:</h4>
|
||||
<div class="grid grid-2 gap-3 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
GPS location data
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Camera make & model
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Date & time taken
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Camera settings
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Software information
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
AI generation tags
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.privacy-features {
|
||||
background-color: var(--bg-secondary);
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,731 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let disabled = false;
|
||||
|
||||
let dragOver = false;
|
||||
let fileInput;
|
||||
|
||||
const acceptedTypes = [
|
||||
"image/jpeg",
|
||||
"image/jpg",
|
||||
"image/png",
|
||||
"image/tiff",
|
||||
"image/heic",
|
||||
"image/avif",
|
||||
];
|
||||
|
||||
function handleDragOver(event) {
|
||||
event.preventDefault();
|
||||
if (!disabled) {
|
||||
dragOver = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(event) {
|
||||
event.preventDefault();
|
||||
dragOver = false;
|
||||
}
|
||||
|
||||
function handleDrop(event) {
|
||||
event.preventDefault();
|
||||
dragOver = false;
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = event.dataTransfer.files;
|
||||
if (files.length > 0) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileInput(event) {
|
||||
if (disabled) return;
|
||||
|
||||
const files = event.target.files;
|
||||
if (files.length > 0) {
|
||||
processFile(files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
async function processFile(file) {
|
||||
if (!acceptedTypes.includes(file.type)) {
|
||||
alert(
|
||||
"Please select a supported image file (JPEG, PNG, TIFF, HEIC, or AVIF)."
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const metadata = await extractMetadata(file);
|
||||
dispatch("fileSelected", { file, metadata });
|
||||
} catch (error) {
|
||||
console.error("Error processing file:", error);
|
||||
alert("Error processing the file. Please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
async function extractMetadata(file) {
|
||||
try {
|
||||
const { parse } = await import("exifr");
|
||||
|
||||
if (
|
||||
!file.type.includes("jpeg") &&
|
||||
!file.type.includes("jpg") &&
|
||||
!file.type.includes("tiff") &&
|
||||
!file.type.includes("png") &&
|
||||
!file.type.includes("heic") &&
|
||||
!file.type.includes("avif")
|
||||
) {
|
||||
console.log("File type doesn't typically contain EXIF data");
|
||||
return {
|
||||
fileType: "other",
|
||||
hasMetadata: false,
|
||||
message: "This file type typically doesn't contain EXIF metadata",
|
||||
};
|
||||
}
|
||||
|
||||
const exifData = await parse(file, {
|
||||
tiff: true,
|
||||
exif: true,
|
||||
gps: true,
|
||||
interop: true,
|
||||
iop: true,
|
||||
makerNote: true,
|
||||
userComment: true,
|
||||
|
||||
xmp: true,
|
||||
iptc: true,
|
||||
icc: true,
|
||||
|
||||
ihdr: true,
|
||||
|
||||
jfif: true,
|
||||
|
||||
chunked: true,
|
||||
firstChunkSize: 131072,
|
||||
chunkSize: 65536,
|
||||
|
||||
multiSegment: true,
|
||||
silentErrors: false,
|
||||
|
||||
segments: [
|
||||
"APP0",
|
||||
"APP1",
|
||||
"APP2",
|
||||
"APP3",
|
||||
"APP4",
|
||||
"APP5",
|
||||
"APP6",
|
||||
"APP7",
|
||||
"APP8",
|
||||
"APP9",
|
||||
"APP10",
|
||||
"APP11",
|
||||
"APP12",
|
||||
"APP13",
|
||||
"APP14",
|
||||
"APP15",
|
||||
],
|
||||
|
||||
mergeOutput: true,
|
||||
|
||||
translateKeys: true,
|
||||
translateValues: true,
|
||||
|
||||
reviveValues: true,
|
||||
|
||||
sanitize: false,
|
||||
|
||||
pick: [],
|
||||
skip: [],
|
||||
});
|
||||
|
||||
let allMetadata = exifData || {};
|
||||
let rawMetadata = {};
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const uint8Array = new Uint8Array(arrayBuffer);
|
||||
|
||||
rawMetadata = await extractRawMetadata(uint8Array, file.type);
|
||||
|
||||
allMetadata = { ...allMetadata, ...rawMetadata };
|
||||
|
||||
if (
|
||||
file.type.includes("png") &&
|
||||
(!allMetadata || Object.keys(allMetadata).length === 0)
|
||||
) {
|
||||
try {
|
||||
const pngMetadata = await parse(file, {
|
||||
ihdr: true,
|
||||
itxt: true,
|
||||
text: true,
|
||||
ztxt: true,
|
||||
time: true,
|
||||
phys: true,
|
||||
splt: true,
|
||||
hist: true,
|
||||
chrm: true,
|
||||
gama: true,
|
||||
iccp: true,
|
||||
sbit: true,
|
||||
srgb: true,
|
||||
bkgd: true,
|
||||
trns: true,
|
||||
mergeOutput: false,
|
||||
translateKeys: false,
|
||||
sanitize: false,
|
||||
});
|
||||
|
||||
if (pngMetadata && Object.keys(pngMetadata).length > 0) {
|
||||
allMetadata = { ...allMetadata, ...pngMetadata };
|
||||
}
|
||||
} catch (pngError) {
|
||||
console.log("PNG-specific parsing failed:", pngError);
|
||||
}
|
||||
}
|
||||
|
||||
if (!allMetadata || Object.keys(allMetadata).length === 0) {
|
||||
return {
|
||||
fileType: file.type.includes("png") ? "png" : "jpeg",
|
||||
hasMetadata: false,
|
||||
message: file.type.includes("png")
|
||||
? "PNG files typically don't contain EXIF metadata"
|
||||
: "No EXIF data found in this image - image appears to be clean",
|
||||
};
|
||||
}
|
||||
|
||||
// Organize the metadata into categories
|
||||
const metadata = {};
|
||||
|
||||
// C2PA/AI Provenance data (priority category)
|
||||
const c2paFields = Object.keys(allMetadata).filter(
|
||||
(key) =>
|
||||
key.toLowerCase().includes("c2pa") ||
|
||||
key.toLowerCase().includes("cai") ||
|
||||
key.toLowerCase().includes("provenance") ||
|
||||
key.toLowerCase().includes("manifest") ||
|
||||
key.toLowerCase().includes("signature") ||
|
||||
key.toLowerCase().includes("claim") ||
|
||||
key.toLowerCase().includes("assertion") ||
|
||||
key.toLowerCase().includes("chatgpt") ||
|
||||
key.toLowerCase().includes("openai") ||
|
||||
key.toLowerCase().includes("ai_generated") ||
|
||||
key.toLowerCase().includes("dall") ||
|
||||
key.toLowerCase().includes("midjourney") ||
|
||||
key.toLowerCase().includes("stable") ||
|
||||
key.toLowerCase().includes("generated")
|
||||
);
|
||||
|
||||
if (c2paFields.length > 0) {
|
||||
const c2paData = {};
|
||||
for (const field of c2paFields) {
|
||||
c2paData[field] = allMetadata[field];
|
||||
}
|
||||
metadata["C2PA/AI Provenance"] = c2paData;
|
||||
}
|
||||
|
||||
if (
|
||||
rawMetadata.C2PA_FOUND ||
|
||||
rawMetadata.AI_SIGNATURES ||
|
||||
rawMetadata.JUMBF_BOXES
|
||||
) {
|
||||
if (!metadata["C2PA/AI Provenance"]) {
|
||||
metadata["C2PA/AI Provenance"] = {};
|
||||
}
|
||||
|
||||
// Only add if not already present to avoid duplicates
|
||||
if (
|
||||
rawMetadata.TOTAL_SIGNATURES &&
|
||||
!metadata["C2PA/AI Provenance"]["TOTAL_SIGNATURES"]
|
||||
) {
|
||||
metadata["C2PA/AI Provenance"]["TOTAL_SIGNATURES"] =
|
||||
rawMetadata.TOTAL_SIGNATURES;
|
||||
}
|
||||
if (
|
||||
rawMetadata.C2PA_FOUND &&
|
||||
!metadata["C2PA/AI Provenance"]["C2PA_FOUND"]
|
||||
) {
|
||||
metadata["C2PA/AI Provenance"]["C2PA_FOUND"] = rawMetadata.C2PA_FOUND;
|
||||
}
|
||||
if (
|
||||
rawMetadata.AI_SIGNATURES &&
|
||||
!metadata["C2PA/AI Provenance"]["AI_SIGNATURES"]
|
||||
) {
|
||||
metadata["C2PA/AI Provenance"]["AI_SIGNATURES"] =
|
||||
rawMetadata.AI_SIGNATURES;
|
||||
}
|
||||
if (
|
||||
rawMetadata.JUMBF_BOXES &&
|
||||
!metadata["C2PA/AI Provenance"]["JUMBF_BOXES"]
|
||||
) {
|
||||
metadata["C2PA/AI Provenance"]["JUMBF_BOXES"] =
|
||||
rawMetadata.JUMBF_BOXES;
|
||||
}
|
||||
}
|
||||
|
||||
const gpsFields = [
|
||||
"latitude",
|
||||
"longitude",
|
||||
"GPSLatitude",
|
||||
"GPSLongitude",
|
||||
"GPSAltitude",
|
||||
"GPSSpeed",
|
||||
"GPSImgDirection",
|
||||
"GPSDestBearing",
|
||||
"GPSDateTime",
|
||||
"GPSLatitudeRef",
|
||||
"GPSLongitudeRef",
|
||||
];
|
||||
const gpsData = {};
|
||||
for (const field of gpsFields) {
|
||||
if (allMetadata[field] !== undefined) {
|
||||
gpsData[field] = allMetadata[field];
|
||||
}
|
||||
}
|
||||
if (Object.keys(gpsData).length > 0) {
|
||||
metadata.GPS = gpsData;
|
||||
}
|
||||
|
||||
// Camera/EXIF data
|
||||
const cameraFields = [
|
||||
"Make",
|
||||
"Model",
|
||||
"Software",
|
||||
"DateTime",
|
||||
"DateTimeOriginal",
|
||||
"DateTimeDigitized",
|
||||
"ExposureTime",
|
||||
"FNumber",
|
||||
"ISO",
|
||||
"Flash",
|
||||
"FocalLength",
|
||||
"WhiteBalance",
|
||||
"LensModel",
|
||||
"LensMake",
|
||||
"Artist",
|
||||
"Copyright",
|
||||
];
|
||||
const cameraData = {};
|
||||
for (const field of cameraFields) {
|
||||
if (allMetadata[field] !== undefined) {
|
||||
cameraData[field] = allMetadata[field];
|
||||
}
|
||||
}
|
||||
if (Object.keys(cameraData).length > 0) {
|
||||
metadata.Camera = cameraData;
|
||||
}
|
||||
|
||||
const technicalFields = [
|
||||
"ColorSpace",
|
||||
"Orientation",
|
||||
"ResolutionUnit",
|
||||
"XResolution",
|
||||
"YResolution",
|
||||
"ExifVersion",
|
||||
"ComponentsConfiguration",
|
||||
"ImageWidth",
|
||||
"ImageHeight",
|
||||
];
|
||||
const technicalData = {};
|
||||
for (const field of technicalFields) {
|
||||
if (allMetadata[field] !== undefined) {
|
||||
technicalData[field] = allMetadata[field];
|
||||
}
|
||||
}
|
||||
if (Object.keys(technicalData).length > 0) {
|
||||
metadata.Technical = technicalData;
|
||||
}
|
||||
|
||||
// XMP data (often contains AI/software info)
|
||||
const xmpFields = Object.keys(allMetadata).filter(
|
||||
(key) =>
|
||||
key.toLowerCase().includes("xmp") ||
|
||||
key.includes("Creator") ||
|
||||
key.includes("Subject")
|
||||
);
|
||||
if (xmpFields.length > 0) {
|
||||
const xmpData = {};
|
||||
for (const field of xmpFields) {
|
||||
xmpData[field] = allMetadata[field];
|
||||
}
|
||||
metadata.XMP = xmpData;
|
||||
}
|
||||
|
||||
// IPTC data
|
||||
const iptcFields = Object.keys(allMetadata).filter(
|
||||
(key) =>
|
||||
key.toLowerCase().includes("iptc") ||
|
||||
key.includes("Keywords") ||
|
||||
key.includes("Caption")
|
||||
);
|
||||
if (iptcFields.length > 0) {
|
||||
const iptcData = {};
|
||||
for (const field of iptcFields) {
|
||||
iptcData[field] = allMetadata[field];
|
||||
}
|
||||
metadata.IPTC = iptcData;
|
||||
}
|
||||
|
||||
// PNG-specific chunks
|
||||
const pngFields = Object.keys(allMetadata).filter(
|
||||
(key) =>
|
||||
key.toLowerCase().includes("text") ||
|
||||
key.toLowerCase().includes("itxt") ||
|
||||
key.toLowerCase().includes("ztxt") ||
|
||||
key.toLowerCase().includes("time") ||
|
||||
key.toLowerCase().includes("phys") ||
|
||||
key.toLowerCase().includes("hist") ||
|
||||
key.toLowerCase().includes("chrm") ||
|
||||
key.toLowerCase().includes("gama") ||
|
||||
key.toLowerCase().includes("srgb") ||
|
||||
key.toLowerCase().includes("iccp")
|
||||
);
|
||||
if (pngFields.length > 0) {
|
||||
const pngData = {};
|
||||
for (const field of pngFields) {
|
||||
pngData[field] = allMetadata[field];
|
||||
}
|
||||
metadata["PNG Chunks"] = pngData;
|
||||
}
|
||||
|
||||
// Color Profile data (ICC profiles - not privacy concerning)
|
||||
const colorProfileFields = Object.keys(allMetadata).filter(
|
||||
(key) =>
|
||||
key.includes("Profile") ||
|
||||
key.includes("ColorSpace") ||
|
||||
key.includes("WhitePoint") ||
|
||||
key.includes("PrimaryChromaticities") ||
|
||||
key.includes("ICC")
|
||||
);
|
||||
if (colorProfileFields.length > 0) {
|
||||
const profileData = {};
|
||||
for (const field of colorProfileFields) {
|
||||
profileData[field] = allMetadata[field];
|
||||
}
|
||||
metadata["Color Profile"] = profileData;
|
||||
}
|
||||
|
||||
// Other interesting fields that might contain privacy-sensitive data
|
||||
const otherPrivacyFields = Object.keys(allMetadata).filter(
|
||||
(key) =>
|
||||
!gpsFields.includes(key) &&
|
||||
!cameraFields.includes(key) &&
|
||||
!technicalFields.includes(key) &&
|
||||
!xmpFields.includes(key) &&
|
||||
!iptcFields.includes(key) &&
|
||||
!pngFields.includes(key) &&
|
||||
!c2paFields.includes(key) &&
|
||||
!colorProfileFields.includes(key) &&
|
||||
(key.includes("User") ||
|
||||
key.includes("Comment") ||
|
||||
key.includes("Description") ||
|
||||
key.includes("AI") ||
|
||||
key.includes("Generated") ||
|
||||
key.includes("Thumbnail") ||
|
||||
key.includes("Preview"))
|
||||
);
|
||||
|
||||
if (otherPrivacyFields.length > 0) {
|
||||
const otherData = {};
|
||||
for (const field of otherPrivacyFields) {
|
||||
otherData[field] = allMetadata[field];
|
||||
}
|
||||
metadata.Other = otherData;
|
||||
}
|
||||
|
||||
// Add raw metadata summary
|
||||
if (
|
||||
rawMetadata.TOTAL_SIGNATURES > 0 ||
|
||||
rawMetadata.METADATA_MARKERS > 0
|
||||
) {
|
||||
metadata["Raw Analysis"] = {
|
||||
"Total Signatures Found": rawMetadata.TOTAL_SIGNATURES || 0,
|
||||
"Metadata Markers": rawMetadata.METADATA_MARKERS || 0,
|
||||
"File Contains Binary Metadata": true,
|
||||
};
|
||||
}
|
||||
|
||||
console.log("Extracted metadata with exifr:", metadata);
|
||||
console.log("Raw exifr data:", allMetadata);
|
||||
console.log("Raw binary analysis:", rawMetadata);
|
||||
|
||||
return metadata;
|
||||
} catch (error) {
|
||||
console.log("Error extracting metadata:", error.message);
|
||||
return {
|
||||
fileType:
|
||||
file.type.includes("jpeg") || file.type.includes("jpg")
|
||||
? "jpeg"
|
||||
: file.type.includes("png")
|
||||
? "png"
|
||||
: "other",
|
||||
hasMetadata: false,
|
||||
message: file.type.includes("png")
|
||||
? "PNG files typically don't contain EXIF metadata"
|
||||
: error.message.includes("not supported") ||
|
||||
error.message.includes("invalid")
|
||||
? "No EXIF data found in this image"
|
||||
: "Image appears to be clean (no metadata found)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Function to extract raw metadata by scanning binary data for known signatures
|
||||
async function extractRawMetadata(uint8Array, fileType) {
|
||||
const rawMetadata = {
|
||||
TOTAL_SIGNATURES: 0,
|
||||
METADATA_MARKERS: 0,
|
||||
};
|
||||
|
||||
// Convert to string for text-based searching
|
||||
const textDecoder = new TextDecoder("utf-8", {
|
||||
ignoreBOM: true,
|
||||
fatal: false,
|
||||
});
|
||||
let textContent = "";
|
||||
|
||||
// Sample the file in chunks to avoid memory issues with large files
|
||||
const chunkSize = 1024;
|
||||
for (
|
||||
let i = 0;
|
||||
i < Math.min(uint8Array.length, 1024 * 1024);
|
||||
i += chunkSize
|
||||
) {
|
||||
const chunk = uint8Array.slice(
|
||||
i,
|
||||
Math.min(i + chunkSize, uint8Array.length)
|
||||
);
|
||||
try {
|
||||
textContent += textDecoder.decode(chunk, { stream: true });
|
||||
} catch (e) {
|
||||
// Skip chunks that can't be decoded as text
|
||||
}
|
||||
}
|
||||
|
||||
// C2PA/CAI signatures - more specific patterns
|
||||
const c2paSignatures = [
|
||||
"c2pa.manifest",
|
||||
"c2pa.signature",
|
||||
"c2pa.assertions",
|
||||
"contentauthenticity.org",
|
||||
"adobe.com/cai",
|
||||
"CASS",
|
||||
"c2pa.created",
|
||||
"c2pa.converted",
|
||||
];
|
||||
|
||||
// AI generation signatures - specific and definitive patterns only
|
||||
const aiSignatures = [
|
||||
"ChatGPT",
|
||||
"OpenAI API",
|
||||
"DALL-E",
|
||||
"dall-e-",
|
||||
"Midjourney",
|
||||
"Stable Diffusion",
|
||||
"AI-generated",
|
||||
"trainedAlgorithmicMedia",
|
||||
"digitalSourceType",
|
||||
"GPT-4",
|
||||
"generated",
|
||||
"synthesized",
|
||||
];
|
||||
|
||||
// JUMBF (JPEG Universal Metadata Box Format) signatures
|
||||
const jumbfSignatures = ["jumb", "JUMB", "jumd", "JUMD"];
|
||||
|
||||
// Search for C2PA signatures with more precise matching
|
||||
let c2paFound = [];
|
||||
for (const sig of c2paSignatures) {
|
||||
// Use case-sensitive matching for specific patterns
|
||||
if (
|
||||
textContent.includes(sig) ||
|
||||
textContent.toLowerCase().includes(sig.toLowerCase())
|
||||
) {
|
||||
c2paFound.push(sig);
|
||||
rawMetadata.TOTAL_SIGNATURES++;
|
||||
}
|
||||
}
|
||||
if (c2paFound.length > 0) {
|
||||
rawMetadata.C2PA_FOUND = c2paFound;
|
||||
}
|
||||
|
||||
// Search for AI signatures with exact matching
|
||||
let aiFound = [];
|
||||
for (const sig of aiSignatures) {
|
||||
// Use more precise matching to avoid false positives
|
||||
if (
|
||||
textContent.includes(sig) ||
|
||||
textContent.toLowerCase().includes(sig.toLowerCase())
|
||||
) {
|
||||
// Additional validation: ensure it's not just a fragment
|
||||
const context = textContent.toLowerCase();
|
||||
const sigLower = sig.toLowerCase();
|
||||
|
||||
// Only count if we find the signature in a meaningful context
|
||||
if (
|
||||
context.includes(sigLower) &&
|
||||
(sig.length > 4 || // Longer signatures are more reliable
|
||||
context.includes(sigLower + " ") || // Word boundary
|
||||
context.includes(" " + sigLower) || // Word boundary
|
||||
context.includes(sigLower + ":") || // Metadata format
|
||||
context.includes(sigLower + "=") || // Assignment format
|
||||
context.includes('"' + sigLower + '"')) // Quoted value
|
||||
) {
|
||||
aiFound.push(sig);
|
||||
rawMetadata.TOTAL_SIGNATURES++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (aiFound.length > 0) {
|
||||
rawMetadata.AI_SIGNATURES = aiFound;
|
||||
}
|
||||
|
||||
// Search for JUMBF boxes
|
||||
let jumbfFound = [];
|
||||
for (const sig of jumbfSignatures) {
|
||||
if (textContent.toLowerCase().includes(sig.toLowerCase())) {
|
||||
jumbfFound.push(sig);
|
||||
rawMetadata.TOTAL_SIGNATURES++;
|
||||
}
|
||||
}
|
||||
if (jumbfFound.length > 0) {
|
||||
rawMetadata.JUMBF_BOXES = jumbfFound;
|
||||
}
|
||||
|
||||
// Look for common metadata markers - be more specific
|
||||
const metadataMarkers = [
|
||||
"EXIF\x00", // EXIF with null terminator
|
||||
"IPTC",
|
||||
"XMP\x00", // XMP with null terminator
|
||||
"ICC_PROFILE",
|
||||
"Photoshop 3.0",
|
||||
"Adobe_CM",
|
||||
"GPS\x00", // GPS with null terminator
|
||||
];
|
||||
|
||||
for (const marker of metadataMarkers) {
|
||||
if (textContent.includes(marker)) {
|
||||
rawMetadata.METADATA_MARKERS++;
|
||||
}
|
||||
}
|
||||
|
||||
// For JPEG files, look for specific APP segments that might contain C2PA
|
||||
if (fileType.includes("jpeg") || fileType.includes("jpg")) {
|
||||
// Look for APP11 segment marker (often used for C2PA)
|
||||
const app11Marker = new Uint8Array([0xff, 0xeb]);
|
||||
// Look for APP2 segment marker (sometimes used for C2PA/CAI, but also ICC profiles)
|
||||
const app2Marker = new Uint8Array([0xff, 0xe2]);
|
||||
|
||||
let foundApp11 = false,
|
||||
foundApp2WithC2PA = false;
|
||||
|
||||
for (let i = 0; i < uint8Array.length - 10; i++) {
|
||||
if (
|
||||
uint8Array[i] === app11Marker[0] &&
|
||||
uint8Array[i + 1] === app11Marker[1]
|
||||
) {
|
||||
foundApp11 = true;
|
||||
break;
|
||||
}
|
||||
if (
|
||||
uint8Array[i] === app2Marker[0] &&
|
||||
uint8Array[i + 1] === app2Marker[1]
|
||||
) {
|
||||
// Check if this APP2 segment contains C2PA data (not just ICC profile)
|
||||
const segmentData = textContent.slice(i, i + 100).toLowerCase();
|
||||
if (
|
||||
segmentData.includes("c2pa") ||
|
||||
segmentData.includes("cai") ||
|
||||
segmentData.includes("jumb")
|
||||
) {
|
||||
foundApp2WithC2PA = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (foundApp11 || foundApp2WithC2PA) {
|
||||
if (!rawMetadata.JUMBF_BOXES) rawMetadata.JUMBF_BOXES = [];
|
||||
if (foundApp11) rawMetadata.JUMBF_BOXES.push("APP11_segment");
|
||||
if (foundApp2WithC2PA) rawMetadata.JUMBF_BOXES.push("APP2_segment");
|
||||
rawMetadata.TOTAL_SIGNATURES++;
|
||||
}
|
||||
}
|
||||
|
||||
return rawMetadata;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
if (fileInput) {
|
||||
fileInput.value = "";
|
||||
}
|
||||
dispatch("reset");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="upload-section">
|
||||
<div
|
||||
class="upload-area"
|
||||
class:dragover={dragOver}
|
||||
class:disabled
|
||||
on:dragover={handleDragOver}
|
||||
on:dragleave={handleDragLeave}
|
||||
on:drop={handleDrop}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
on:click={() => !disabled && fileInput.click()}
|
||||
on:keydown={(e) =>
|
||||
(e.key === "Enter" || e.key === " ") && !disabled && fileInput.click()}
|
||||
>
|
||||
<div class="upload-icon">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold mb-2">
|
||||
{dragOver ? "Drop your image here" : "Upload Image"}
|
||||
</h3>
|
||||
|
||||
<p class="text-secondary mb-4">
|
||||
Drag and drop your image here, or click to browse
|
||||
</p>
|
||||
|
||||
<p class="text-sm text-muted">
|
||||
Supports JPEG, PNG, TIFF, HEIC, and AVIF files
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".jpg,.jpeg,.png,.tiff,.tif,.heic,.avif"
|
||||
bind:this={fileInput}
|
||||
on:change={handleFileInput}
|
||||
class="hidden"
|
||||
{disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.upload-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled:hover {
|
||||
transform: none !important;
|
||||
border-color: var(--border-color) !important;
|
||||
background-color: var(--bg-secondary) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<script>
|
||||
export let metadata = {};
|
||||
|
||||
function formatMetadata(data) {
|
||||
if (!data || Object.keys(data).length === 0) {
|
||||
return "No metadata found";
|
||||
}
|
||||
|
||||
if (data.message && !data.hasMetadata) {
|
||||
return data.message;
|
||||
}
|
||||
|
||||
let formatted = "";
|
||||
|
||||
Object.entries(data).forEach(([section, values]) => {
|
||||
if (
|
||||
section === "fileType" ||
|
||||
section === "hasMetadata" ||
|
||||
section === "message"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
formatted += `[${section}]\n`;
|
||||
|
||||
if (typeof values === "object" && values !== null) {
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
let displayValue;
|
||||
if (Array.isArray(value)) {
|
||||
displayValue = `[${value.join(", ")}]`;
|
||||
} else if (typeof value === "string" && value.length > 100) {
|
||||
displayValue = value.substring(0, 100) + "...";
|
||||
} else {
|
||||
displayValue = value;
|
||||
}
|
||||
formatted += ` ${key}: ${displayValue}\n`;
|
||||
});
|
||||
}
|
||||
formatted += "\n";
|
||||
});
|
||||
|
||||
return formatted.trim();
|
||||
}
|
||||
|
||||
function getMetadataStats(metadata) {
|
||||
let totalEntries = 0;
|
||||
let sections = [];
|
||||
|
||||
if (metadata.message && !metadata.hasMetadata) {
|
||||
return { totalEntries: 0, sections: [] };
|
||||
}
|
||||
|
||||
Object.entries(metadata).forEach(([section, values]) => {
|
||||
if (
|
||||
section === "fileType" ||
|
||||
section === "hasMetadata" ||
|
||||
section === "message"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof values === "object" && values !== null) {
|
||||
const count = Object.keys(values).length;
|
||||
totalEntries += count;
|
||||
sections.push(`${section}: ${count} entries`);
|
||||
}
|
||||
});
|
||||
|
||||
return { totalEntries, sections };
|
||||
}
|
||||
|
||||
function hasAIMetadata(metadata) {
|
||||
if (metadata["C2PA/AI Provenance"]) {
|
||||
const c2paData = metadata["C2PA/AI Provenance"];
|
||||
|
||||
if (
|
||||
c2paData.AI_SIGNATURES &&
|
||||
Array.isArray(c2paData.AI_SIGNATURES) &&
|
||||
c2paData.AI_SIGNATURES.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
c2paData.C2PA_FOUND &&
|
||||
Array.isArray(c2paData.C2PA_FOUND) &&
|
||||
c2paData.C2PA_FOUND.length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (c2paData.JUMBF_BOXES && Array.isArray(c2paData.JUMBF_BOXES)) {
|
||||
const realJumbf = c2paData.JUMBF_BOXES.filter(
|
||||
(box) =>
|
||||
!box.includes("APP2_segment") ||
|
||||
box.includes("jumb") ||
|
||||
box.includes("JUMB") ||
|
||||
box.includes("jumd") ||
|
||||
box.includes("JUMD")
|
||||
);
|
||||
if (realJumbf.length > 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (c2paData.TOTAL_SIGNATURES && c2paData.TOTAL_SIGNATURES > 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const aiKeywords = [
|
||||
"chatgpt",
|
||||
"openai",
|
||||
"dall-e",
|
||||
"dalle",
|
||||
"midjourney",
|
||||
"stable diffusion",
|
||||
"artificial intelligence",
|
||||
"ai-generated",
|
||||
"machine learning",
|
||||
"neural network",
|
||||
"synthesized",
|
||||
"gpt-4",
|
||||
"trainedAlgorithmicMedia",
|
||||
];
|
||||
|
||||
let hasAI = false;
|
||||
|
||||
Object.entries(metadata).forEach(([section, values]) => {
|
||||
if (
|
||||
section === "fileType" ||
|
||||
section === "hasMetadata" ||
|
||||
section === "message" ||
|
||||
section === "Raw Analysis" ||
|
||||
section === "C2PA/AI Provenance"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof values === "object" && values !== null) {
|
||||
Object.entries(values).forEach(([key, value]) => {
|
||||
const valueString = String(value).toLowerCase();
|
||||
if (
|
||||
aiKeywords.some((keyword) =>
|
||||
valueString.includes(keyword.toLowerCase())
|
||||
)
|
||||
) {
|
||||
hasAI = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return hasAI;
|
||||
}
|
||||
|
||||
$: formattedMetadata = formatMetadata(metadata);
|
||||
$: containsAITags = hasAIMetadata(metadata);
|
||||
$: metadataStats = getMetadataStats(metadata);
|
||||
</script>
|
||||
|
||||
<div class="metadata-section">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h4 class="font-semibold">EXIF Metadata</h4>
|
||||
{#if containsAITags}
|
||||
<span class="status-warning py-1 px-2 text-xs">
|
||||
⚠️ Contains AI-related tags
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if Object.keys(metadata).length === 0 || (metadata.message && !metadata.hasMetadata)}
|
||||
<div class="status-success">
|
||||
{#if metadata.message}
|
||||
✅ {metadata.message}
|
||||
{:else}
|
||||
✅ No metadata found - image is already clean!
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if metadata.fileType === "png"}
|
||||
<div class="mt-3 p-3 bg-info rounded text-sm">
|
||||
<p><strong>ℹ️ About PNG files:</strong></p>
|
||||
<p>
|
||||
PNG files typically don't contain EXIF metadata. However, you can
|
||||
still process this image to ensure it's completely clean and remove
|
||||
any potential hidden data.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="mb-4">
|
||||
<div class="metadata-summary">
|
||||
<p class="text-sm text-secondary">
|
||||
<strong>Total entries:</strong>
|
||||
{metadataStats.totalEntries}
|
||||
</p>
|
||||
<div class="text-xs text-muted mt-1">
|
||||
{#each metadataStats.sections as section}
|
||||
<span class="section-tag">{section}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metadata-viewer">
|
||||
{formattedMetadata}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h5 class="font-medium mb-2">Privacy Concerns Found:</h5>
|
||||
<ul class="text-sm text-secondary space-y-1">
|
||||
{#if metadata.GPS}
|
||||
<li class="status-warning">📍 GPS location data</li>
|
||||
{/if}
|
||||
{#if metadata.Camera}
|
||||
<li class="status-warning">📷 Camera information</li>
|
||||
{/if}
|
||||
{#if metadata.Technical}
|
||||
<li class="status-warning">🖼️ Image creation details</li>
|
||||
{/if}
|
||||
{#if containsAITags}
|
||||
<li class="status-warning">🤖 AI generation indicators</li>
|
||||
{#if metadata["C2PA/AI Provenance"]}
|
||||
<ul class="ml-4 mt-1 text-xs">
|
||||
{#if metadata["C2PA/AI Provenance"].AI_SIGNATURES && metadata["C2PA/AI Provenance"].AI_SIGNATURES.length > 0}
|
||||
<li>
|
||||
• AI Tools: {metadata[
|
||||
"C2PA/AI Provenance"
|
||||
].AI_SIGNATURES.join(", ")}
|
||||
</li>
|
||||
{/if}
|
||||
{#if metadata["C2PA/AI Provenance"].C2PA_FOUND && metadata["C2PA/AI Provenance"].C2PA_FOUND.length > 0}
|
||||
<li>
|
||||
• C2PA Provenance: {metadata["C2PA/AI Provenance"].C2PA_FOUND
|
||||
.length} signature(s)
|
||||
</li>
|
||||
{/if}
|
||||
{#if metadata["C2PA/AI Provenance"].TOTAL_SIGNATURES && metadata["C2PA/AI Provenance"].TOTAL_SIGNATURES > 0}
|
||||
<li>
|
||||
• Total AI/C2PA markers: {metadata["C2PA/AI Provenance"]
|
||||
.TOTAL_SIGNATURES}
|
||||
</li>
|
||||
{/if}
|
||||
{#if metadata["C2PA/AI Provenance"].JUMBF_BOXES && metadata["C2PA/AI Provenance"].JUMBF_BOXES.length > 0}
|
||||
<li>
|
||||
• JUMBF metadata containers: {metadata["C2PA/AI Provenance"]
|
||||
.JUMBF_BOXES.length}
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if metadata.XMP}
|
||||
<li class="status-warning">📝 XMP metadata</li>
|
||||
{/if}
|
||||
{#if metadata.IPTC}
|
||||
<li class="status-warning">📋 IPTC metadata</li>
|
||||
{/if}
|
||||
{#if metadata.Other && Object.keys(metadata.Other).length > 0}
|
||||
<li class="status-warning">📄 Other metadata</li>
|
||||
{/if}
|
||||
</ul>
|
||||
|
||||
{#if metadata["Color Profile"]}
|
||||
<div class="mt-3 p-2 bg-info rounded text-xs">
|
||||
<p>
|
||||
<strong>ℹ️ Color Profile:</strong> Contains ICC color management data
|
||||
(not privacy-concerning)
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.space-y-1 > * + * {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.metadata-summary {
|
||||
background-color: var(--bg-tertiary);
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.section-tag {
|
||||
display: inline-block;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-right: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,108 @@
|
||||
<div class="grid grid-2 gap-6">
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Client-Side Processing
|
||||
</h3>
|
||||
<p class="text-sm text-secondary mb-4">
|
||||
Images never leave your browser. Processing happens locally.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
No Data Collection
|
||||
</h3>
|
||||
<p class="text-sm text-secondary mb-4">
|
||||
No analytics, tracking, or data collection.
|
||||
</p>
|
||||
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<span class="text-success">✓</span>
|
||||
Open Source
|
||||
</h3>
|
||||
<p class="text-sm text-secondary">
|
||||
Fully auditable codebase. Verify what happens to your data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="font-semibold mb-3">Why Remove Metadata?</h3>
|
||||
<div class="space-y-3 text-sm text-secondary">
|
||||
<div class="privacy-risk">
|
||||
<strong>📍 Location:</strong> GPS coordinates reveal photo locations
|
||||
</div>
|
||||
<div class="privacy-risk">
|
||||
<strong>📷 Device Info:</strong> Camera models can link photos to devices
|
||||
</div>
|
||||
<div class="privacy-risk">
|
||||
<strong>🕒 Timestamps:</strong> Enable detailed activity timelines
|
||||
</div>
|
||||
<div class="privacy-risk">
|
||||
<strong>🤖 AI Detection:</strong> Software tags reveal AI generation
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 p-3 bg-warning rounded">
|
||||
<p class="text-sm">
|
||||
<strong>⚠️</strong> Social media may strip some metadata, but it's safer
|
||||
to clean images yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-info rounded">
|
||||
<h4 class="font-semibold mb-2">Perfect for:</h4>
|
||||
<div class="grid grid-2 gap-2 text-sm">
|
||||
<div>• Journalists protecting sources</div>
|
||||
<div>• Activists maintaining anonymity</div>
|
||||
<div>• Content creators removing AI tags</div>
|
||||
<div>• Anyone valuing digital privacy</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.text-success {
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.space-y-3 > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.privacy-risk {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
border-radius: 0.375rem;
|
||||
border-left: 3px solid var(--warning-color);
|
||||
}
|
||||
|
||||
.bg-warning {
|
||||
background-color: rgba(245, 158, 11, 0.1);
|
||||
border: 1px solid rgba(245, 158, 11, 0.2);
|
||||
}
|
||||
|
||||
.bg-info {
|
||||
background-color: rgba(37, 99, 235, 0.05);
|
||||
border: 1px solid rgba(37, 99, 235, 0.1);
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script>
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let darkMode = false;
|
||||
|
||||
function toggle() {
|
||||
dispatch("toggle");
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="theme-toggle"
|
||||
on:click={toggle}
|
||||
aria-label="Toggle dark mode"
|
||||
title="Toggle dark mode"
|
||||
>
|
||||
{#if darkMode}
|
||||
<svg width="24" height="24" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="24" height="24" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
@@ -0,0 +1,8 @@
|
||||
import './app.css'
|
||||
import App from './App.svelte'
|
||||
|
||||
const app = new App({
|
||||
target: document.getElementById('app'),
|
||||
})
|
||||
|
||||
export default app
|
||||
Reference in new issue
Block a user