386 lines
11 KiB
Svelte
386 lines
11 KiB
Svelte
<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 {
|
|
// Enhanced AI content credential detection before processing
|
|
await detectAndLogAICredentials();
|
|
|
|
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;
|
|
|
|
// Enhanced clearing for AI content credentials
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
|
|
// Create a new clean image without any metadata containers
|
|
ctx.globalCompositeOperation = "source-over";
|
|
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();
|
|
}
|
|
}
|
|
|
|
// Enhanced AI credential detection
|
|
async function detectAndLogAICredentials() {
|
|
try {
|
|
const { parse } = await import("exifr");
|
|
|
|
const fullMetadata = await parse(file, {
|
|
tiff: true,
|
|
exif: true,
|
|
gps: true,
|
|
xmp: true,
|
|
iptc: true,
|
|
icc: true,
|
|
jfif: true,
|
|
ihdr: true,
|
|
iptc: true,
|
|
photoshop: true,
|
|
silentErrors: true,
|
|
});
|
|
|
|
if (fullMetadata) {
|
|
console.log("🔍 Detected metadata before cleaning:", fullMetadata);
|
|
|
|
// Check for AI-specific metadata
|
|
const aiTags = [];
|
|
if (
|
|
fullMetadata.Software &&
|
|
typeof fullMetadata.Software === "string"
|
|
) {
|
|
const software = fullMetadata.Software.toLowerCase();
|
|
if (
|
|
software.includes("dall-e") ||
|
|
software.includes("dalle") ||
|
|
software.includes("chatgpt") ||
|
|
software.includes("openai") ||
|
|
software.includes("midjourney") ||
|
|
software.includes("stable diffusion")
|
|
) {
|
|
aiTags.push("AI Software Signature");
|
|
}
|
|
}
|
|
|
|
// Check for C2PA content credentials
|
|
if (
|
|
fullMetadata.Description &&
|
|
fullMetadata.Description.includes("c2pa")
|
|
) {
|
|
aiTags.push("C2PA Content Credentials");
|
|
}
|
|
|
|
if (
|
|
fullMetadata.Creator &&
|
|
(fullMetadata.Creator.includes("AI") ||
|
|
fullMetadata.Creator.includes("artificial intelligence"))
|
|
) {
|
|
aiTags.push("AI Creator Tags");
|
|
}
|
|
|
|
if (aiTags.length > 0) {
|
|
console.log("🤖 AI content credentials detected:", aiTags);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.log("Could not analyze AI credentials:", error);
|
|
}
|
|
}
|
|
|
|
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, AI content credentials, and provenance data from
|
|
your image to protect your privacy. This process happens entirely in your
|
|
browser - no data is sent to any server. Enhanced detection for C2PA
|
|
signatures, DALL-E watermarks, and other AI generation markers.
|
|
</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 class="flex items-center gap-2">
|
|
<span class="text-success">✓</span>
|
|
C2PA content credentials
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-success">✓</span>
|
|
DALL-E/ChatGPT signatures
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-success">✓</span>
|
|
Midjourney watermarks
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-success">✓</span>
|
|
Stable Diffusion metadata
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-success">✓</span>
|
|
Adobe AI provenance
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<span class="text-success">✓</span>
|
|
JUMBF metadata containers
|
|
</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>
|