Rebuild on the RealBrain design system and retarget SEO to content credentials
Search Console (Jun 4 – Sep 3) shows the page ranking on the first results page for "content credentials" queries while converting badly: "ai credentials remover" at 113 impressions and 0.88% CTR from position 6.7, "remove content credentials from image online" at 2.15% from position 5.0, "remove ai content credentials from image" at 0% from position 7.8. The title led with "EXIF metadata", a phrase that appears in none of the top queries, so the snippet never matched the intent behind the impressions. SEO - Title, description and H1 rebuilt around "remove content credentials from an image"; EXIF kept as a secondary term. - Added a how-to section and a nine-question FAQ answering the question queries that had no content behind them, including the LinkedIn AI label and the Instagram AI tag. - Structured data as an @graph: WebSite (alternateName "Untrace AI" for the unserved brand query), Organization, WebApplication, HowTo, FAQPage. - Fixed sitemap.xml, which pointed at /untrace/ — a URL that does not exist. - Regenerated the Open Graph image to match the new design. Design - Vendored the design system at src/tokens.css and composed the app from it. The old styling broke most of its rules: gradients, font weights 800 and 900, raw #2563eb, Inter, and emoji as the only status signal. - Reworked the tool into a Scan / Review / Download flow that lists what it found before changing anything, with the primary action pinned within thumb reach on small screens. - Dark by default with a light theme, applied before first paint so the page never flashes the wrong surface. - Replaced the floating donation widget, which painted an off-token circle over the page and covered the mobile action bar, with a footer link. Structure - Moved detection into src/lib/metadata.js and removal into src/lib/clean.js; the canvas callbacks are now a promise chain. - Dropped Tailwind and PostCSS. Both were configured twice, and no Tailwind utility was used. Stylesheet is 6.2 kB gzipped with the whole system in it. Fixes - The stepper never advanced: stepState(index) read `current` from the closure, which Svelte 4 cannot track as a template dependency. - The cleaner's fallback returned the original, uncleaned file while the UI reported "All EXIF metadata removed". It now reports the failure instead. Verified against a JPEG carrying EXIF, GPS, XMP and an APP11 JUMBF C2PA manifest: the output retains only APP0 and the sRGB ICC profile, with every provenance marker gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018pRSdR57Ze9M2meDfYZgYE
No files matched your search
@@ -0,0 +1,129 @@
|
|||||||
|
# RealBrain Design System — instructions for coding agents
|
||||||
|
|
||||||
|
Copy this file into a RealBrain repository as `AGENTS.md` (or append it to `CLAUDE.md`). It tells an agent how to apply the design system without being reminded each time. Adjust the one path in "Source of truth" to wherever the repository vendors `tokens.css`.
|
||||||
|
|
||||||
|
## Source of truth
|
||||||
|
|
||||||
|
- In this repository: `src/tokens.css` — a vendored copy of https://realbrain.cc/design-system/tokens.css. Never edit it by hand; refresh it with `curl -o src/tokens.css https://realbrain.cc/design-system/tokens.css`.
|
||||||
|
- Full guide: https://realbrain.cc/design-system/design-system.md
|
||||||
|
- Short reference for agents: https://realbrain.cc/design-system/llms.txt
|
||||||
|
- Tokens as JSON (DTCG): https://realbrain.cc/design-system/tokens.json
|
||||||
|
|
||||||
|
Read the guide's §2 (non-negotiables) and §6 (components) before writing or editing any CSS, component, or UI copy.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Tokens only.** Every colour, size, radius, shadow, duration, and easing in component code is a `var(--token)` from `tokens.css`. No raw hex, no raw px, no `rgba()`.
|
||||||
|
2. **One accent per screen.** Exactly one `.btn-primary` (or one accent-coloured action). Everything else is `.btn-secondary`, `.btn-ghost`, or neutral text.
|
||||||
|
3. **Weight ≤ 600.** Never `font-weight: 700` or `bold`. The font import does not even load 700.
|
||||||
|
4. **Flat.** No `linear-gradient`, `radial-gradient`, `backdrop-filter`, `text-shadow`, glow, or decorative `box-shadow`. `--shadow-*` is only for surfaces that are actually elevated (menus, modals, toasts).
|
||||||
|
5. **Never white on accent.** Text and icons on `--accent` use `--accent-fg`.
|
||||||
|
6. **Semantic colour is never the only signal.** Use `.status.ok|warn|err|info` (dot plus text) or an icon plus text.
|
||||||
|
7. **Copy:** sentence case everywhere. Buttons are verbs ("Sign in", "Create project"). "Sign in", never "Log in" or "Login". Errors say what happened and what to do next.
|
||||||
|
8. **One density per product.** `.density-comfortable` (default, 44px controls) or `.density-compact` (32px) on a container. Never both.
|
||||||
|
9. **Use the existing component classes** (`.btn`, `.field`, `.input`, `.select`, `.checkbox`, `.radio`, `.toggle`, `.card`, `dialog.modal`, `.toast`, `.tabs`, `.table`, `.status`, `.scaffold`, `.empty`, `.skeleton`). Do not re-style them locally. Do not build a div-based fake of a native control.
|
||||||
|
10. **Motion goes through `--dur-1/2/3` and `--ease`.** Nothing else. Reduced motion is handled globally by `tokens.css`.
|
||||||
|
|
||||||
|
## When the system doesn't have what you need
|
||||||
|
|
||||||
|
Do not invent a value. Build the piece from existing tokens if you can. If you genuinely need a new token or component, ship the work with a clearly marked proposal in your final message:
|
||||||
|
|
||||||
|
```
|
||||||
|
⚑ NEW TOKEN --{category}-{name}: {value}
|
||||||
|
why nothing existing fits: …
|
||||||
|
```
|
||||||
|
|
||||||
|
Do the same for drift you notice in existing code (`⚑ DRIFT file:line #3b82f6 → --accent`) rather than silently patching it.
|
||||||
|
|
||||||
|
## Self-check before you finish
|
||||||
|
|
||||||
|
Run these from the repository root (adjust `src` and the excluded path). Every command should print nothing:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# raw colours or px outside the token file
|
||||||
|
grep -rnE --include=*.css --include=*.vue --include=*.jsx --include=*.tsx --include=*.svelte \
|
||||||
|
--exclude=tokens.css '#[0-9a-fA-F]{3,8}\b|rgba?\(|\b[0-9]+px\b' src
|
||||||
|
|
||||||
|
# forbidden weight and effects
|
||||||
|
grep -rnE --include=*.css --include=*.vue --include=*.jsx --include=*.tsx --include=*.svelte \
|
||||||
|
--exclude=tokens.css 'font-weight:\s*(700|800|900|bold)|linear-gradient|radial-gradient|backdrop-filter|text-shadow' src
|
||||||
|
|
||||||
|
# copy rules
|
||||||
|
grep -rniE --exclude-dir=node_modules 'log ?in|Sign In\b|Log In\b' src
|
||||||
|
```
|
||||||
|
|
||||||
|
Then check by eye: one `.btn-primary` per screen, focus rings visible when tabbing, and every status or badge has text next to its colour.
|
||||||
|
|
||||||
|
## Quick reference
|
||||||
|
|
||||||
|
<!-- BEGIN GENERATED: quickref -->
|
||||||
|
```
|
||||||
|
--bg #080808 light: #F7F7F9
|
||||||
|
--surface #121216 light: #FFFFFF
|
||||||
|
--elevated #1A1A20 light: #FFFFFF
|
||||||
|
--text-1 #F4F4F6 light: #16161C
|
||||||
|
--text-2 #A9A9B4 light: #4C4C58
|
||||||
|
--text-3 #83838F light: #6E6E7A
|
||||||
|
--accent #8FA8FF light: #3450C8
|
||||||
|
--accent-fg #080808 light: #FFFFFF
|
||||||
|
--accent-hover #9EB5FF light: #4C6BD2
|
||||||
|
--accent-pressed #6173B1 light: #21358A
|
||||||
|
--success #3ECF8E light: #177A4E
|
||||||
|
--warning #E8B23D light: #8A5A00
|
||||||
|
--error #F26D6D light: #C2373C
|
||||||
|
--info #62B0F5 light: #1D62A8
|
||||||
|
--border #26262E light: #E3E3E9
|
||||||
|
--border-strong #3A3A46 light: #C6C6D0
|
||||||
|
--font-ui 'IBM Plex Sans', system-ui, sans-serif
|
||||||
|
--font-mono 'IBM Plex Mono', ui-monospace, monospace
|
||||||
|
type steps display:60/64 8:40/48 7:30/38 6:24/32 5:20/28 4:17/26 3:15/24 2:13/20 1:12/16
|
||||||
|
weights 400 · 500 · 600 (never 700)
|
||||||
|
space 4px 8px 12px 16px 24px 32px 48px 64px 96px
|
||||||
|
radius sm 6px · md 10px · lg 16px · full 999px
|
||||||
|
motion 120ms · 200ms · 360ms · ease cubic-bezier(.22,.61,.36,1)
|
||||||
|
layout marketing 1200px · app 1440px · control-h 44px (compact 32px)
|
||||||
|
```
|
||||||
|
<!-- END GENERATED: quickref -->
|
||||||
|
|
||||||
|
Light theme: `document.documentElement.setAttribute('data-theme', 'light')`. Dark: remove the attribute.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Untrace specifics
|
||||||
|
|
||||||
|
Applied to this repository on 2026-09-05, design system v2.0.0.
|
||||||
|
|
||||||
|
- `src/tokens.css` is the vendored copy. `src/app.css` imports it and adds only
|
||||||
|
app-level composition (layout, dropzone, stepper, disclosure, tiles, the
|
||||||
|
mobile action bar). No component from `tokens.css` is restyled locally.
|
||||||
|
- Density is **comfortable** throughout — this is a consumer surface.
|
||||||
|
- Dark is the default. `src/lib/theme.js` reads a saved choice first and falls
|
||||||
|
back to the operating system, and `index.html` applies the result before
|
||||||
|
first paint so the page never flashes the wrong surface.
|
||||||
|
- One `.btn-primary` per step of the tool: the dropzone step has none, review
|
||||||
|
has "Clean image", download has "Download clean image".
|
||||||
|
- There is no Tailwind and no PostCSS. The design system is plain CSS and the
|
||||||
|
app needs nothing on top of it.
|
||||||
|
|
||||||
|
### Open token proposals
|
||||||
|
|
||||||
|
```
|
||||||
|
NEW TOKEN --border-w: 1px
|
||||||
|
why nothing existing fits: every surface in the system draws a 1px
|
||||||
|
hairline, but the width itself is only ever written literally inside
|
||||||
|
tokens.css. Defined locally in src/app.css until the system adopts it.
|
||||||
|
|
||||||
|
NEW TOKEN --topbar-h: 56px
|
||||||
|
why nothing existing fits: no layout token covers a sticky product bar.
|
||||||
|
Derived locally as calc(var(--space-7) + var(--space-2)).
|
||||||
|
|
||||||
|
NEW TOKEN breakpoints — 560px / 768px / 900px
|
||||||
|
why nothing existing fits: the system documents container maximums
|
||||||
|
(--max-marketing, --max-app) but no breakpoint scale. Custom properties
|
||||||
|
cannot be used in a media query, so a literal is unavoidable; this app
|
||||||
|
pins itself to exactly these three widths.
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/lib/clean.js` contains one literal hex, `JPEG_MATTE = "#FFFFFF"`. That is
|
||||||
|
image data written to a canvas, not interface styling, so it is intentionally
|
||||||
|
not a token.
|
||||||
@@ -1,98 +1,80 @@
|
|||||||
# Untrace - Advanced EXIF Metadata & AI Content Credential Remover
|
# Untrace
|
||||||
|
|
||||||
**Remove all metadata from images. Keep your privacy.**
|
**Remove content credentials from an image.**
|
||||||
|
|
||||||
Clean your photos from location data, AI signatures, C2PA content credentials, and other tracking information. Works 100% in your browser - no uploads, no tracking.
|
Untrace strips C2PA content credentials, AI generation tags, GPS coordinates
|
||||||
|
and EXIF metadata from a photo. It runs entirely in the browser — the file is
|
||||||
|
read and rewritten in the tab, never uploaded.
|
||||||
|
|
||||||
## Features
|
<https://untrace.realbrain.cc/>
|
||||||
|
|
||||||
**[•] Complete Privacy Protection**
|
## What it removes
|
||||||
|
|
||||||
- Removes GPS location data
|
| | |
|
||||||
- Strips camera information
|
|---|---|
|
||||||
- Cleans AI generation tags (ChatGPT, DALL-E, Midjourney, Stable Diffusion)
|
| C2PA content credentials | Manifests, claims, assertions and signatures from the Content Authenticity Initiative |
|
||||||
- Eliminates C2PA content credentials and provenance data
|
| AI generation tags | ChatGPT, DALL·E, Midjourney, Stable Diffusion, Adobe Firefly, Google Imagen |
|
||||||
- Removes JUMBF metadata containers
|
| JUMBF containers | The APP11 and APP2 boxes that carry provenance inside a JPEG |
|
||||||
- Clears Adobe Firefly and Google Imagen signatures
|
| GPS location | Latitude, longitude, altitude, bearing and their timestamp |
|
||||||
- Eliminates timestamps and device info
|
| Camera and device | Make, model, lens, serial numbers, exposure and shooting settings |
|
||||||
|
| Timestamps | When the photo was taken, digitised and last modified |
|
||||||
|
| XMP and IPTC | Creator names, captions, keywords, copyright, editing history |
|
||||||
|
| Comments and thumbnails | User comments, maker notes, embedded previews |
|
||||||
|
| PNG text chunks | tEXt, iTXt and zTXt blocks, including generation prompts |
|
||||||
|
|
||||||
**[✓] Enhanced AI Content Credential Removal**
|
Supported input: JPEG, PNG, TIFF, HEIC, AVIF. Output is JPEG, or PNG when the
|
||||||
|
original was a small PNG.
|
||||||
|
|
||||||
- Advanced detection of AI generation markers
|
## How it works
|
||||||
- C2PA (Content Authenticity Initiative) signature removal
|
|
||||||
- DALL-E and OpenAI watermark stripping
|
|
||||||
- Midjourney metadata cleaning
|
|
||||||
- Stable Diffusion tag removal
|
|
||||||
- Adobe Content Authenticity removal
|
|
||||||
- JUMBF (JPEG Universal Metadata Box Format) cleaning
|
|
||||||
|
|
||||||
**[✓] Secure & Private**
|
Detection and removal are two separate passes.
|
||||||
|
|
||||||
- No server uploads - everything happens in your browser
|
**Detection** (`src/lib/metadata.js`) reads the file twice: once through
|
||||||
- No tracking or data collection
|
[exifr](https://github.com/MikeKovarik/exifr) for structured segments, and once
|
||||||
- Supports JPEG, PNG, TIFF, HEIC, AVIF
|
as raw bytes to find C2PA strings and JUMBF boxes that exifr does not surface.
|
||||||
|
The results are merged and bucketed into named sections, then turned into the
|
||||||
|
findings list the review step renders.
|
||||||
|
|
||||||
**[↕] Easy to Use**
|
**Removal** (`src/lib/clean.js`) decodes the image to pixels on a canvas and
|
||||||
|
re-encodes it. Nothing carries over, so every metadata container is dropped at
|
||||||
|
once rather than deleting known tags one by one and leaving the rest behind.
|
||||||
|
The output is then re-read to confirm it is clean; if anything survived, a
|
||||||
|
second pass forces JPEG on a white matte.
|
||||||
|
|
||||||
- Drag & drop interface
|
If the browser cannot decode or re-encode the file, the original bytes are
|
||||||
- Instant preview of what data will be removed
|
returned and the interface says so — it never reports a clean that did not
|
||||||
- One-click cleaning
|
happen.
|
||||||
- Mobile-friendly design
|
|
||||||
|
|
||||||
## Quick Start
|
## Design
|
||||||
|
|
||||||
1. **Upload** - Drag your image to the page
|
The interface follows the
|
||||||
2. **Review** - See what metadata was found
|
[RealBrain Design System](https://realbrain.cc/design-system/) v2.0. Tokens are
|
||||||
3. **Clean** - Click "Remove Metadata"
|
vendored at `src/tokens.css`; refresh with:
|
||||||
4. **Download** - Save your privacy-safe image
|
|
||||||
|
|
||||||
## For Developers
|
```bash
|
||||||
|
curl -o src/tokens.css https://realbrain.cc/design-system/tokens.css
|
||||||
|
```
|
||||||
|
|
||||||
|
`AGENTS.md` carries the rules, the self-check commands, and this repository's
|
||||||
|
open token proposals. Read it before touching any CSS.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev # vite dev server
|
||||||
|
npm run build # production build to dist/
|
||||||
npm run build
|
npm run preview # serve the build
|
||||||
```
|
```
|
||||||
|
|
||||||
**Built with:** Svelte + Vite + exifr
|
Stack: Svelte 4, Vite 5, exifr. No CSS framework — the design system is plain
|
||||||
|
CSS.
|
||||||
|
|
||||||
## Why Use Untrace?
|
## Who it is for
|
||||||
|
|
||||||
Modern images contain hidden data that can compromise your privacy:
|
Journalists protecting sources, activists maintaining anonymity, photographers
|
||||||
|
selling stock, and anyone who would rather a photo not carry its own history.
|
||||||
- **Location data** reveals where photos were taken
|
|
||||||
- **AI signatures** show which tools generated images (DALL-E, Midjourney, ChatGPT, Stable Diffusion)
|
|
||||||
- **C2PA content credentials** track the entire creation and editing history
|
|
||||||
- **Device info** identifies your camera/phone
|
|
||||||
- **Timestamps** reveal when images were created
|
|
||||||
- **JUMBF containers** store complex metadata structures
|
|
||||||
- **Content Authenticity** markers link images to specific AI tools
|
|
||||||
- **Provenance data** creates a digital paper trail
|
|
||||||
|
|
||||||
Untrace removes all this data while keeping your images looking exactly the same.
|
|
||||||
|
|
||||||
## Supported AI Tools & Formats
|
|
||||||
|
|
||||||
**AI Generation Platforms:**
|
|
||||||
|
|
||||||
- OpenAI DALL-E & ChatGPT
|
|
||||||
- Midjourney
|
|
||||||
- Stable Diffusion
|
|
||||||
- Adobe Firefly
|
|
||||||
- Google Imagen
|
|
||||||
- Runway ML
|
|
||||||
- NightCafe
|
|
||||||
- Artbreeder
|
|
||||||
|
|
||||||
**Content Credential Standards:**
|
|
||||||
|
|
||||||
- C2PA (Content Provenance and Authenticity)
|
|
||||||
- Content Authenticity Initiative (CAI)
|
|
||||||
- JUMBF (JPEG Universal Metadata Box Format)
|
|
||||||
- Adobe Content Credentials
|
|
||||||
- Project Origin signatures
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License - Free for personal and commercial use.
|
MIT.
|
||||||
@@ -1,132 +1,268 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
|
||||||
<!-- Favicons -->
|
<title>Remove content credentials from an image – free online remover | Untrace</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/svg+xml"
|
|
||||||
sizes="16x16"
|
|
||||||
href="favicon-16x16.svg"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="icon"
|
|
||||||
type="image/svg+xml"
|
|
||||||
sizes="32x32"
|
|
||||||
href="favicon-32x32.svg"
|
|
||||||
/>
|
|
||||||
<link rel="apple-touch-icon" href="favicon.svg" />
|
|
||||||
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
|
|
||||||
<!-- SEO Meta Tags -->
|
|
||||||
<title>
|
|
||||||
Untrace - Remove EXIF Metadata & AI Credentials from Images Online |
|
|
||||||
Privacy Tool
|
|
||||||
</title>
|
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Free online tool to remove EXIF metadata, GPS location data, AI generation tags, and content credentials from images. Protect your privacy with 100% client-side processing. No uploads, no tracking."
|
content="Free tool to remove C2PA content credentials, AI generation tags and EXIF metadata from an image. Runs in your browser, so the photo is never uploaded. Works with JPEG, PNG, TIFF, HEIC and AVIF."
|
||||||
/>
|
/>
|
||||||
<meta
|
|
||||||
name="keywords"
|
|
||||||
content="EXIF metadata remover, remove GPS from photos, AI content credentials, C2PA remover, image privacy tool, photo metadata cleaner, location data removal, AI signature stripper, DALL-E metadata, ChatGPT image tags, Midjourney metadata, privacy protection, secure image processing"
|
|
||||||
/>
|
|
||||||
<meta name="author" content="Untrace" />
|
|
||||||
<meta name="robots" content="index, follow" />
|
|
||||||
<link rel="canonical" href="https://untrace.realbrain.cc/" />
|
<link rel="canonical" href="https://untrace.realbrain.cc/" />
|
||||||
|
<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1" />
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
<!-- Theme: applied before first paint so the page never flashes the wrong
|
||||||
<meta property="og:type" content="website" />
|
surface. Mirrors the logic in src/lib/theme.js. -->
|
||||||
<meta property="og:url" content="https://untrace.realbrain.cc/" />
|
<meta name="color-scheme" content="dark light" />
|
||||||
<meta
|
<meta name="theme-color" content="#080808" media="(prefers-color-scheme: dark)" />
|
||||||
property="og:title"
|
<meta name="theme-color" content="#F7F7F9" media="(prefers-color-scheme: light)" />
|
||||||
content="Untrace - Remove EXIF Metadata & AI Credentials from Images"
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var saved = localStorage.getItem("untrace-theme");
|
||||||
|
var theme =
|
||||||
|
saved === "light" || saved === "dark"
|
||||||
|
? saved
|
||||||
|
: window.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
? "light"
|
||||||
|
: "dark";
|
||||||
|
if (theme === "light")
|
||||||
|
document.documentElement.setAttribute("data-theme", "light");
|
||||||
|
} catch (e) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="16x16" href="/favicon-16x16.svg" />
|
||||||
|
<link rel="icon" type="image/svg+xml" sizes="32x32" href="/favicon-32x32.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|
||||||
|
<!-- The design system pulls IBM Plex from Google Fonts; starting the
|
||||||
|
handshake here removes it from the stylesheet's critical path. -->
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<meta property="og:type" content="website" />
|
||||||
|
<meta property="og:site_name" content="Untrace" />
|
||||||
|
<meta property="og:url" content="https://untrace.realbrain.cc/" />
|
||||||
|
<meta property="og:title" content="Remove content credentials from an image" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Free privacy tool to remove all metadata, GPS data, and AI generation tags from your images. Works 100% in your browser - no uploads required."
|
content="Free browser tool that strips C2PA content credentials, AI generation tags and EXIF metadata. Nothing is uploaded."
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="og:image"
|
|
||||||
content="https://untrace.realbrain.cc/thumb.jpg"
|
|
||||||
/>
|
/>
|
||||||
|
<meta property="og:image" content="https://untrace.realbrain.cc/thumb.jpg" />
|
||||||
<meta property="og:image:width" content="1200" />
|
<meta property="og:image:width" content="1200" />
|
||||||
<meta property="og:image:height" content="630" />
|
<meta property="og:image:height" content="630" />
|
||||||
<meta property="og:site_name" content="Untrace" />
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
<!-- Twitter -->
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta property="twitter:card" content="summary_large_image" />
|
<meta name="twitter:title" content="Remove content credentials from an image" />
|
||||||
<meta property="twitter:url" content="https://untrace.realbrain.cc/" />
|
|
||||||
<meta
|
<meta
|
||||||
property="twitter:title"
|
name="twitter:description"
|
||||||
content="Untrace - Remove EXIF Metadata & AI Credentials from Images"
|
content="Free browser tool that strips C2PA content credentials, AI generation tags and EXIF metadata. Nothing is uploaded."
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="twitter:description"
|
|
||||||
content="Free privacy tool to remove all metadata, GPS data, and AI generation tags from your images. Works 100% in your browser."
|
|
||||||
/>
|
|
||||||
<meta
|
|
||||||
property="twitter:image"
|
|
||||||
content="https://untrace.realbrain.cc/thumb.jpg"
|
|
||||||
/>
|
/>
|
||||||
|
<meta name="twitter:image" content="https://untrace.realbrain.cc/thumb.jpg" />
|
||||||
|
|
||||||
<!-- Additional SEO -->
|
|
||||||
<meta name="theme-color" content="#3b82f6" />
|
|
||||||
<meta name="application-name" content="Untrace" />
|
<meta name="application-name" content="Untrace" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Untrace" />
|
<meta name="apple-mobile-web-app-title" content="Untrace" />
|
||||||
<link rel="manifest" href="manifest.json" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
|
||||||
<!-- Structured Data -->
|
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "WebApplication",
|
"@graph": [
|
||||||
"name": "Untrace",
|
{
|
||||||
"description": "Remove EXIF metadata, GPS location data, AI generation tags, and content credentials from images to protect your privacy",
|
"@type": "WebSite",
|
||||||
"url": "https://untrace.realbrain.cc/",
|
"@id": "https://untrace.realbrain.cc/#website",
|
||||||
"applicationCategory": "UtilitiesApplication",
|
"url": "https://untrace.realbrain.cc/",
|
||||||
"operatingSystem": "Any",
|
"name": "Untrace",
|
||||||
"offers": {
|
"alternateName": [
|
||||||
"@type": "Offer",
|
"Untrace AI",
|
||||||
"price": "0",
|
"Untrace content credentials remover"
|
||||||
"priceCurrency": "USD"
|
],
|
||||||
},
|
"description": "Remove C2PA content credentials, AI generation tags and EXIF metadata from images in the browser.",
|
||||||
"creator": {
|
"inLanguage": "en",
|
||||||
"@type": "Organization",
|
"publisher": {
|
||||||
"name": "Untrace"
|
"@id": "https://untrace.realbrain.cc/#org"
|
||||||
},
|
}
|
||||||
"featureList": [
|
},
|
||||||
"Remove GPS location data",
|
{
|
||||||
"Strip camera information",
|
"@type": "Organization",
|
||||||
"Clean AI generation tags",
|
"@id": "https://untrace.realbrain.cc/#org",
|
||||||
"Eliminate C2PA content credentials",
|
"name": "Untrace",
|
||||||
"Remove timestamps and device info",
|
"url": "https://untrace.realbrain.cc/",
|
||||||
"100% client-side processing",
|
"logo": "https://untrace.realbrain.cc/favicon.svg",
|
||||||
"No data uploads or tracking",
|
"sameAs": [
|
||||||
"Support for JPEG, PNG, TIFF, HEIC, AVIF"
|
"https://github.com/developersharif/untrace",
|
||||||
|
"https://realbrain.cc/"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "WebApplication",
|
||||||
|
"@id": "https://untrace.realbrain.cc/#app",
|
||||||
|
"name": "Untrace",
|
||||||
|
"url": "https://untrace.realbrain.cc/",
|
||||||
|
"applicationCategory": "UtilitiesApplication",
|
||||||
|
"applicationSubCategory": "Image privacy tool",
|
||||||
|
"operatingSystem": "Any browser",
|
||||||
|
"browserRequirements": "Requires JavaScript and the HTML canvas API",
|
||||||
|
"isAccessibleForFree": true,
|
||||||
|
"offers": {
|
||||||
|
"@type": "Offer",
|
||||||
|
"price": "0",
|
||||||
|
"priceCurrency": "USD"
|
||||||
|
},
|
||||||
|
"publisher": {
|
||||||
|
"@id": "https://untrace.realbrain.cc/#org"
|
||||||
|
},
|
||||||
|
"description": "Free browser tool that removes C2PA content credentials, AI generation tags, GPS coordinates and EXIF metadata from images without uploading them.",
|
||||||
|
"featureList": [
|
||||||
|
"Remove C2PA content credentials",
|
||||||
|
"Remove AI generation tags from ChatGPT, DALL-E, Midjourney, Stable Diffusion, Adobe Firefly and Google Imagen",
|
||||||
|
"Remove JUMBF provenance containers",
|
||||||
|
"Remove GPS location data",
|
||||||
|
"Remove camera make, model and settings",
|
||||||
|
"Remove capture timestamps",
|
||||||
|
"Remove XMP, IPTC and Photoshop metadata",
|
||||||
|
"Remove PNG text chunks",
|
||||||
|
"Client-side processing with no upload",
|
||||||
|
"Supports JPEG, PNG, TIFF, HEIC and AVIF"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "HowTo",
|
||||||
|
"@id": "https://untrace.realbrain.cc/#howto",
|
||||||
|
"name": "How to remove content credentials from an image",
|
||||||
|
"description": "Strip C2PA content credentials and AI generation tags from a photo in three steps, without uploading the file.",
|
||||||
|
"totalTime": "PT1M",
|
||||||
|
"tool": [
|
||||||
|
{
|
||||||
|
"@type": "HowToTool",
|
||||||
|
"name": "Untrace"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"step": [
|
||||||
|
{
|
||||||
|
"@type": "HowToStep",
|
||||||
|
"position": 1,
|
||||||
|
"name": "Add the image",
|
||||||
|
"text": "Drop a JPEG, PNG, TIFF, HEIC or AVIF file onto Untrace, choose one from your device, or paste it from the clipboard.",
|
||||||
|
"url": "https://untrace.realbrain.cc/#tool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "HowToStep",
|
||||||
|
"position": 2,
|
||||||
|
"name": "Review what is hidden",
|
||||||
|
"text": "Untrace lists every metadata block it finds, including C2PA content credentials, AI generation tags, GPS coordinates, camera details and timestamps.",
|
||||||
|
"url": "https://untrace.realbrain.cc/#tool"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "HowToStep",
|
||||||
|
"position": 3,
|
||||||
|
"name": "Clean and download",
|
||||||
|
"text": "Choose Clean image. The photo is decoded to pixels and written back out, leaving every metadata container behind, then download the clean copy.",
|
||||||
|
"url": "https://untrace.realbrain.cc/#tool"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"@id": "https://untrace.realbrain.cc/#faq",
|
||||||
|
"mainEntity": [
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "What are content credentials on an image?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Content credentials are a signed record of how an image was made and edited, written into the file by the C2PA standard. They can name the tool that generated the image, list every edit, and identify the account that created it. Platforms read that record to show an “AI info” or “Content credentials” label next to the picture."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "How do I remove content credentials from an image?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Add the image to Untrace, review what it found, then choose Clean image and download the result. Untrace decodes the picture to raw pixels and writes a new file, so the C2PA manifest, the JUMBF container holding it, and every other metadata block are simply not carried over."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Does this remove the AI label on LinkedIn?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "LinkedIn shows its AI label when it reads C2PA content credentials in the file you upload. Cleaning the image before you upload removes the data the label is generated from. It does not change a label on a post that is already published — delete that post and upload the clean copy instead."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Does it remove the AI tag on Instagram or Facebook?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Meta reads the same C2PA signals plus its own classifiers. Removing the credentials removes the metadata signal, so the automatic “AI info” tag is far less likely to appear. Visual detection is separate and no metadata tool can affect it."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "What is C2PA and JUMBF?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "C2PA is the Coalition for Content Provenance and Authenticity specification that defines the provenance record. JUMBF is the JPEG box format that record is stored in, usually inside an APP11 segment. Untrace scans for both and reports them before cleaning."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Is my image uploaded anywhere?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "No. Reading the metadata and writing the clean file both happen in your browser using the File and Canvas APIs. Nothing is sent over the network, there is no account, and Untrace has no server to store an image on."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Does cleaning reduce image quality?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Small PNGs are rewritten as PNG with no quality loss. Everything else is re-encoded as JPEG at quality 0.92, which is visually indistinguishable at normal viewing sizes. The tool shows the size change so you can see exactly what happened."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Which formats are supported?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "JPEG, PNG, TIFF, HEIC and AVIF. The cleaned file is returned as JPEG, or as PNG when the original was a small PNG."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "Does it also remove EXIF and GPS data?",
|
||||||
|
"acceptedAnswer": {
|
||||||
|
"@type": "Answer",
|
||||||
|
"text": "Yes. EXIF, GPS, XMP, IPTC, Photoshop blocks, maker notes and embedded thumbnails all go at the same time as the content credentials, because the whole metadata section is dropped rather than edited."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<script
|
|
||||||
data-name="BMC-Widget"
|
|
||||||
data-cfasync="false"
|
|
||||||
src="https://cdnjs.buymeacoffee.com/1.0.0/widget.prod.min.js"
|
|
||||||
data-id="developersharif"
|
|
||||||
data-description="Support me on Buy me a coffee!"
|
|
||||||
data-message=""
|
|
||||||
data-color="#26B0A1"
|
|
||||||
data-position="Right"
|
|
||||||
data-x_margin="18"
|
|
||||||
data-y_margin="18"
|
|
||||||
></script>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="src/main.js"></script>
|
<noscript>
|
||||||
|
<p style="padding: 24px; font-family: system-ui, sans-serif">
|
||||||
|
Untrace removes C2PA content credentials, AI generation tags and EXIF
|
||||||
|
metadata from images. It reads and rewrites the file inside your
|
||||||
|
browser, so JavaScript has to be enabled for it to work.
|
||||||
|
</p>
|
||||||
|
</noscript>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -12,30 +12,12 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"gh-pages": "^6.1.0",
|
"gh-pages": "^6.1.0",
|
||||||
"postcss": "^8.5.6",
|
"lightningcss": "^1.30.1",
|
||||||
"svelte": "^4.2.0",
|
"svelte": "^4.2.0",
|
||||||
"tailwindcss": "^4.1.10",
|
|
||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@alloc/quick-lru": {
|
|
||||||
"version": "5.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
|
||||||
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@ampproject/remapping": {
|
"node_modules/@ampproject/remapping": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||||
@@ -441,19 +423,6 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/fs-minipass": {
|
|
||||||
"version": "4.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
|
|
||||||
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"minipass": "^7.0.4"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@jridgewell/gen-mapping": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.8",
|
"version": "0.3.8",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz",
|
||||||
@@ -866,313 +835,6 @@
|
|||||||
"vite": "^5.0.0"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@ampproject/remapping": "^2.3.0",
|
|
||||||
"enhanced-resolve": "^5.18.1",
|
|
||||||
"jiti": "^2.4.2",
|
|
||||||
"lightningcss": "1.30.1",
|
|
||||||
"magic-string": "^0.30.17",
|
|
||||||
"source-map-js": "^1.2.1",
|
|
||||||
"tailwindcss": "4.1.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==",
|
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"detect-libc": "^2.0.4",
|
|
||||||
"tar": "^7.4.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
},
|
|
||||||
"optionalDependencies": {
|
|
||||||
"@tailwindcss/oxide-android-arm64": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-darwin-arm64": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-darwin-x64": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-freebsd-x64": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-linux-arm64-musl": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-linux-x64-musl": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.1.10",
|
|
||||||
"@tailwindcss/oxide-win32-x64-msvc": "4.1.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"android"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"darwin"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"freebsd"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"linux"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==",
|
|
||||||
"bundleDependencies": [
|
|
||||||
"@napi-rs/wasm-runtime",
|
|
||||||
"@emnapi/core",
|
|
||||||
"@emnapi/runtime",
|
|
||||||
"@tybys/wasm-util",
|
|
||||||
"@emnapi/wasi-threads",
|
|
||||||
"tslib"
|
|
||||||
],
|
|
||||||
"cpu": [
|
|
||||||
"wasm32"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/core": "^1.4.3",
|
|
||||||
"@emnapi/runtime": "^1.4.3",
|
|
||||||
"@emnapi/wasi-threads": "^1.0.2",
|
|
||||||
"@napi-rs/wasm-runtime": "^0.2.10",
|
|
||||||
"@tybys/wasm-util": "^0.9.0",
|
|
||||||
"tslib": "^2.8.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==",
|
|
||||||
"cpu": [
|
|
||||||
"arm64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==",
|
|
||||||
"cpu": [
|
|
||||||
"x64"
|
|
||||||
],
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"os": [
|
|
||||||
"win32"
|
|
||||||
],
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/postcss": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@alloc/quick-lru": "^5.2.0",
|
|
||||||
"@tailwindcss/node": "4.1.10",
|
|
||||||
"@tailwindcss/oxide": "4.1.10",
|
|
||||||
"postcss": "^8.4.41",
|
|
||||||
"tailwindcss": "4.1.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/typography": {
|
|
||||||
"version": "0.5.16",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz",
|
|
||||||
"integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"lodash.castarray": "^4.4.0",
|
|
||||||
"lodash.isplainobject": "^4.0.6",
|
|
||||||
"lodash.merge": "^4.6.2",
|
|
||||||
"postcss-selector-parser": "6.0.10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/vite": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-QWnD5HDY2IADv+vYR82lOhqOlS1jSCUUAmfem52cXAhRTKxpDh3ARX8TTXJTCCO7Rv7cD2Nlekabv02bwP3a2A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@tailwindcss/node": "4.1.10",
|
|
||||||
"@tailwindcss/oxide": "4.1.10",
|
|
||||||
"tailwindcss": "4.1.10"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"vite": "^5.2.0 || ^6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1220,44 +882,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/autoprefixer": {
|
|
||||||
"version": "10.4.21",
|
|
||||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
|
|
||||||
"integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/postcss/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/autoprefixer"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"browserslist": "^4.24.4",
|
|
||||||
"caniuse-lite": "^1.0.30001702",
|
|
||||||
"fraction.js": "^4.3.7",
|
|
||||||
"normalize-range": "^0.1.2",
|
|
||||||
"picocolors": "^1.1.1",
|
|
||||||
"postcss-value-parser": "^4.2.0"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"autoprefixer": "bin/autoprefixer"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^10 || ^12 || >=14"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"postcss": "^8.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/axobject-query": {
|
"node_modules/axobject-query": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||||
@@ -1281,70 +905,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/browserslist": {
|
|
||||||
"version": "4.25.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz",
|
|
||||||
"integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"caniuse-lite": "^1.0.30001718",
|
|
||||||
"electron-to-chromium": "^1.5.160",
|
|
||||||
"node-releases": "^2.0.19",
|
|
||||||
"update-browserslist-db": "^1.1.3"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"browserslist": "cli.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/caniuse-lite": {
|
|
||||||
"version": "1.0.30001724",
|
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001724.tgz",
|
|
||||||
"integrity": "sha512-WqJo7p0TbHDOythNTqYujmaJTvtYRZrjpP8TCvH6Vb9CYJerJNKamKzIWOM4BkQatWj9H2lYulpdAQNBe7QhNA==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "CC-BY-4.0"
|
|
||||||
},
|
|
||||||
"node_modules/chownr": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/code-red": {
|
"node_modules/code-red": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/code-red/-/code-red-1.0.4.tgz",
|
||||||
@@ -1390,19 +950,6 @@
|
|||||||
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cssesc": {
|
|
||||||
"version": "3.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
|
||||||
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"cssesc": "bin/cssesc"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
|
||||||
@@ -1454,13 +1001,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/electron-to-chromium": {
|
|
||||||
"version": "1.5.171",
|
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.171.tgz",
|
|
||||||
"integrity": "sha512-scWpzXEJEMrGJa4Y6m/tVotb0WuvNmasv3wWVzUAeCgKU0ToFOhUW6Z+xWnRQANMYGxN4ngJXIThgBJOqzVPCQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC"
|
|
||||||
},
|
|
||||||
"node_modules/email-addresses": {
|
"node_modules/email-addresses": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/email-addresses/-/email-addresses-5.0.0.tgz",
|
||||||
@@ -1468,20 +1008,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
|
||||||
"version": "5.18.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz",
|
|
||||||
"integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"graceful-fs": "^4.2.4",
|
|
||||||
"tapable": "^2.2.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@@ -1521,16 +1047,6 @@
|
|||||||
"@esbuild/win32-x64": "0.21.5"
|
"@esbuild/win32-x64": "0.21.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/escalade": {
|
|
||||||
"version": "3.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
|
||||||
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/escape-string-regexp": {
|
"node_modules/escape-string-regexp": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
|
||||||
@@ -1657,20 +1173,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fraction.js": {
|
|
||||||
"version": "4.3.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
|
||||||
"integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://github.com/sponsors/rawify"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/fs-extra": {
|
"node_modules/fs-extra": {
|
||||||
"version": "11.3.0",
|
"version": "11.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
|
||||||
@@ -1818,16 +1320,6 @@
|
|||||||
"@types/estree": "^1.0.6"
|
"@types/estree": "^1.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jiti": {
|
|
||||||
"version": "2.4.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
|
||||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jsonfile": {
|
"node_modules/jsonfile": {
|
||||||
"version": "6.1.0",
|
"version": "6.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
|
||||||
@@ -2110,27 +1602,6 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lodash.castarray": {
|
|
||||||
"version": "4.4.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz",
|
|
||||||
"integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.isplainobject": {
|
|
||||||
"version": "4.0.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
|
|
||||||
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lodash.merge": {
|
|
||||||
"version": "4.6.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
|
||||||
"integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/magic-string": {
|
"node_modules/magic-string": {
|
||||||
"version": "0.30.17",
|
"version": "0.30.17",
|
||||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
|
||||||
@@ -2188,45 +1659,6 @@
|
|||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minipass": {
|
|
||||||
"version": "7.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
|
||||||
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16 || 14 >=14.17"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/minizlib": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"minipass": "^7.1.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mkdirp": {
|
|
||||||
"version": "3.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
|
|
||||||
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"bin": {
|
|
||||||
"mkdirp": "dist/cjs/src/bin.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=10"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -2253,23 +1685,6 @@
|
|||||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/node-releases": {
|
|
||||||
"version": "2.0.19",
|
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
|
|
||||||
"integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/normalize-range": {
|
|
||||||
"version": "0.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
|
|
||||||
"integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/p-limit": {
|
"node_modules/p-limit": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
@@ -2403,27 +1818,6 @@
|
|||||||
"node": "^10 || ^12 || >=14"
|
"node": "^10 || ^12 || >=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss-selector-parser": {
|
|
||||||
"version": "6.0.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz",
|
|
||||||
"integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"cssesc": "^3.0.0",
|
|
||||||
"util-deprecate": "^1.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/postcss-value-parser": {
|
|
||||||
"version": "4.2.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
|
|
||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -2602,41 +1996,6 @@
|
|||||||
"svelte": "^3.19.0 || ^4.0.0"
|
"svelte": "^3.19.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tailwindcss": {
|
|
||||||
"version": "4.1.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.10.tgz",
|
|
||||||
"integrity": "sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/tapable": {
|
|
||||||
"version": "2.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
|
|
||||||
"integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/tar": {
|
|
||||||
"version": "7.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz",
|
|
||||||
"integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/fs-minipass": "^4.0.0",
|
|
||||||
"chownr": "^3.0.0",
|
|
||||||
"minipass": "^7.1.2",
|
|
||||||
"minizlib": "^3.0.1",
|
|
||||||
"mkdirp": "^3.0.1",
|
|
||||||
"yallist": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@@ -2673,44 +2032,6 @@
|
|||||||
"node": ">= 10.0.0"
|
"node": ">= 10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
|
||||||
"version": "1.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
|
||||||
"integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
|
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tidelift",
|
|
||||||
"url": "https://tidelift.com/funding/github/npm/browserslist"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/ai"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"escalade": "^3.2.0",
|
|
||||||
"picocolors": "^1.1.1"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"update-browserslist-db": "cli.js"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"browserslist": ">= 4.21.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/util-deprecate": {
|
|
||||||
"version": "1.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.19",
|
"version": "5.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz",
|
||||||
@@ -2785,16 +2106,6 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/yallist": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "untrace",
|
"name": "untrace",
|
||||||
"description": "Clean your images. Keep your identity.",
|
"description": "Remove C2PA content credentials, AI generation tags and EXIF metadata from images, entirely in the browser.",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -11,18 +11,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"@tailwindcss/postcss": "^4.1.10",
|
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
|
||||||
"@tailwindcss/vite": "^4.1.10",
|
|
||||||
"autoprefixer": "^10.4.21",
|
|
||||||
"gh-pages": "^6.1.0",
|
"gh-pages": "^6.1.0",
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"svelte": "^4.2.0",
|
"svelte": "^4.2.0",
|
||||||
"tailwindcss": "^4.1.10",
|
"vite": "^5.0.0",
|
||||||
"vite": "^5.0.0"
|
"lightningcss": "^1.30.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"exifr": "^7.1.3"
|
"exifr": "^7.1.3"
|
||||||
},
|
},
|
||||||
"homepage": "https://developersharif.github.io/untrace"
|
"homepage": "https://untrace.realbrain.cc/"
|
||||||
}
|
}
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
'@tailwindcss/postcss': {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,5 @@
|
|||||||
<svg width="16" height="16" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
|
||||||
<!-- Simplified version for 16x16 -->
|
<rect width="48" height="48" rx="10" fill="#3450C8"/>
|
||||||
<defs>
|
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace"
|
||||||
<linearGradient id="logoGradient16" x1="0%" y1="0%" x2="100%" y2="100%">
|
font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>
|
||||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#1E40AF;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Main circle background -->
|
|
||||||
<circle cx="24" cy="24" r="22" fill="url(#logoGradient16)" />
|
|
||||||
|
|
||||||
<!-- Simplified image icon -->
|
|
||||||
<rect x="12" y="16" width="24" height="16" rx="2" fill="white" opacity="0.9" />
|
|
||||||
|
|
||||||
<!-- Strike-through line -->
|
|
||||||
<line x1="8" y1="8" x2="40" y2="40" stroke="#EF4444" stroke-width="4" stroke-linecap="round" />
|
|
||||||
<line x1="8" y1="8" x2="40" y2="40" stroke="white" stroke-width="2" stroke-linecap="round" />
|
|
||||||
|
|
||||||
<!-- Shield -->
|
|
||||||
<circle cx="38" cy="18" r="6" fill="#10B981" stroke="white" stroke-width="1.5" />
|
|
||||||
<path d="M36 18 L37 19 L40 16" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 347 B |
@@ -1,27 +1,5 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
|
||||||
<!-- Medium detail version for 32x32 -->
|
<rect width="48" height="48" rx="10" fill="#3450C8"/>
|
||||||
<defs>
|
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace"
|
||||||
<linearGradient id="logoGradient32" x1="0%" y1="0%" x2="100%" y2="100%">
|
font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>
|
||||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#1E40AF;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Main circle background -->
|
|
||||||
<circle cx="24" cy="24" r="22" fill="url(#logoGradient32)" />
|
|
||||||
|
|
||||||
<!-- Image/photo icon -->
|
|
||||||
<rect x="11" y="15" width="26" height="18" rx="2" fill="white" opacity="0.9" />
|
|
||||||
<rect x="12" y="16" width="24" height="16" rx="2" fill="none" stroke="#1E40AF" stroke-width="1.5" />
|
|
||||||
|
|
||||||
<!-- Simple landscape -->
|
|
||||||
<path d="M12 28 L18 23 L24 26 L30 21 L36 26 V30 H12 Z" fill="#E5E7EB" opacity="0.6" />
|
|
||||||
|
|
||||||
<!-- Strike-through line -->
|
|
||||||
<line x1="8" y1="8" x2="40" y2="40" stroke="#EF4444" stroke-width="3" stroke-linecap="round" opacity="0.8" />
|
|
||||||
<line x1="8" y1="8" x2="40" y2="40" stroke="white" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
|
|
||||||
<!-- Shield -->
|
|
||||||
<path d="M37 12 L41 12 C41.5 12 42 12.5 42 13 L42 16 C42 18.5 40.5 20.8 38.2 22.1 L37 22.8 L35.8 22.1 C33.5 20.8 32 18.5 32 16 L32 13 C32 12.5 32.5 12 33 12 L37 12 Z" fill="#10B981" stroke="white" stroke-width="1" />
|
|
||||||
<path d="M35 15.5 L36.5 17 L39 14.5" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 347 B |
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
|
||||||
|
<rect width="48" height="48" fill="#3450C8"/>
|
||||||
|
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace"
|
||||||
|
font-size="22" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 339 B |
@@ -1,32 +1,5 @@
|
|||||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" role="img" aria-label="Untrace">
|
||||||
<!-- Background circle with gradient -->
|
<rect width="48" height="48" rx="10" fill="#3450C8"/>
|
||||||
<defs>
|
<text x="24" y="24" fill="#FFFFFF" font-family="'IBM Plex Mono', ui-monospace, monospace"
|
||||||
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
font-size="28" font-weight="600" text-anchor="middle" dominant-baseline="central">U</text>
|
||||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#1E40AF;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Main circle background -->
|
|
||||||
<circle cx="24" cy="24" r="22" fill="url(#logoGradient)" />
|
|
||||||
|
|
||||||
<!-- Image/photo icon base -->
|
|
||||||
<rect x="10" y="14" width="28" height="20" rx="3" fill="white" opacity="0.9" />
|
|
||||||
<rect x="12" y="16" width="24" height="16" rx="2" fill="none" stroke="#1E40AF" stroke-width="1.5" />
|
|
||||||
|
|
||||||
<!-- Mountain/landscape in photo -->
|
|
||||||
<path d="M12 28 L18 22 L22 25 L28 20 L36 26 V30 H12 Z" fill="#E5E7EB" opacity="0.6" />
|
|
||||||
|
|
||||||
<!-- Sun/circle in photo -->
|
|
||||||
<circle cx="30" cy="20" r="2.5" fill="#FCD34D" opacity="0.7" />
|
|
||||||
|
|
||||||
<!-- Strike-through line (indicating removal) -->
|
|
||||||
<line x1="8" y1="8" x2="40" y2="40" stroke="#EF4444" stroke-width="3" stroke-linecap="round" opacity="0.8" />
|
|
||||||
<line x1="8" y1="8" x2="40" y2="40" stroke="white" stroke-width="1.5" stroke-linecap="round" />
|
|
||||||
|
|
||||||
<!-- Shield icon overlay (protection) -->
|
|
||||||
<path d="M38 10 L42 10 C43.1 10 44 10.9 44 12 L44 16 C44 19.3 42.3 22.4 39.5 24.2 L38 25 L36.5 24.2 C33.7 22.4 32 19.3 32 16 L32 12 C32 10.9 32.9 10 34 10 L38 10 Z" fill="#10B981" stroke="white" stroke-width="1" />
|
|
||||||
|
|
||||||
<!-- Checkmark in shield -->
|
|
||||||
<path d="M36 15 L37.5 16.5 L40 14" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 347 B |
@@ -1,19 +1,29 @@
|
|||||||
{
|
{
|
||||||
"name": "Untrace",
|
"id": "/",
|
||||||
|
"name": "Untrace — content credentials and metadata remover",
|
||||||
"short_name": "Untrace",
|
"short_name": "Untrace",
|
||||||
"description": "EXIF Metadata & AI Content Credential Remover",
|
"description": "Remove C2PA content credentials, AI generation tags and EXIF metadata from images, entirely in your browser.",
|
||||||
"start_url": "/",
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#080808",
|
||||||
"theme_color": "#3b82f6",
|
"theme_color": "#080808",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "vite.svg",
|
"src": "/favicon.svg",
|
||||||
"sizes": "any",
|
"sizes": "any",
|
||||||
"type": "image/svg+xml"
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon-maskable.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"categories": ["productivity", "utilities", "photo"],
|
"categories": ["productivity", "utilities", "photo"],
|
||||||
"lang": "en",
|
"lang": "en",
|
||||||
|
"dir": "ltr",
|
||||||
"orientation": "any"
|
"orientation": "any"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
<url>
|
<url>
|
||||||
<loc>https://untrace.realbrain.cc/untrace/</loc>
|
<loc>https://untrace.realbrain.cc/</loc>
|
||||||
<lastmod>2025-06-23</lastmod>
|
<lastmod>2026-09-05</lastmod>
|
||||||
<changefreq>weekly</changefreq>
|
<changefreq>weekly</changefreq>
|
||||||
<priority>1.0</priority>
|
<priority>1.0</priority>
|
||||||
</url>
|
</url>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 262 KiB After Width: | Height: | Size: 47 KiB |
@@ -1,6 +0,0 @@
|
|||||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="32" height="32" rx="8" fill="#2563eb"/>
|
|
||||||
<path d="M8 12h16v8H8v-8z" stroke="white" stroke-width="1.5" fill="none"/>
|
|
||||||
<path d="M11 15l3 3 6-6" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
<path d="M22 9l3 3-3 3" stroke="white" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 454 B |
@@ -1,335 +1,126 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import ImageUploader from "./lib/ImageUploader.svelte";
|
import { theme } from "./lib/theme.js";
|
||||||
import MetadataViewer from "./lib/MetadataViewer.svelte";
|
import TopBar from "./lib/TopBar.svelte";
|
||||||
import ImageProcessor from "./lib/ImageProcessor.svelte";
|
import Tool from "./lib/Tool.svelte";
|
||||||
import ThemeToggle from "./lib/ThemeToggle.svelte";
|
import Removals from "./lib/Removals.svelte";
|
||||||
import Header from "./lib/Header.svelte";
|
import HowItWorks from "./lib/HowItWorks.svelte";
|
||||||
|
import Faq from "./lib/Faq.svelte";
|
||||||
import Footer from "./lib/Footer.svelte";
|
import Footer from "./lib/Footer.svelte";
|
||||||
import PrivacyInfo from "./lib/PrivacyInfo.svelte";
|
import Toast from "./lib/Toast.svelte";
|
||||||
|
|
||||||
let selectedFile = null;
|
let toasts = [];
|
||||||
let metadata = null;
|
let nextId = 0;
|
||||||
let processedImage = null;
|
|
||||||
let isProcessing = false;
|
|
||||||
let showMetadata = false;
|
|
||||||
let showFileInfo = false;
|
|
||||||
let showPrivacyInfo = false;
|
|
||||||
let darkMode = false;
|
|
||||||
|
|
||||||
onMount(() => {
|
const assurances = [
|
||||||
const savedTheme = localStorage.getItem("theme");
|
{
|
||||||
if (savedTheme) {
|
title: "Runs in your browser",
|
||||||
darkMode = savedTheme === "dark";
|
detail: "No upload, no queue, no server that could keep a copy.",
|
||||||
} else {
|
},
|
||||||
darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
{
|
||||||
}
|
title: "Reads the whole file",
|
||||||
updateTheme();
|
detail: "EXIF, XMP, IPTC and the JUMBF boxes that hold C2PA data.",
|
||||||
});
|
},
|
||||||
|
{
|
||||||
|
title: "Shows its work",
|
||||||
|
detail: "Every finding is listed before anything is changed.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function updateTheme() {
|
function notify(event) {
|
||||||
if (darkMode) {
|
const id = nextId++;
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
toasts = [...toasts, { id, ...event.detail }];
|
||||||
} else {
|
setTimeout(() => dismiss(id), 5000);
|
||||||
document.documentElement.removeAttribute("data-theme");
|
|
||||||
}
|
|
||||||
localStorage.setItem("theme", darkMode ? "dark" : "light");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleThemeToggle() {
|
function dismiss(id) {
|
||||||
darkMode = !darkMode;
|
toasts = toasts.filter((toast) => toast.id !== id);
|
||||||
updateTheme();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleFileSelected(event) {
|
onMount(() => theme.init());
|
||||||
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>
|
</script>
|
||||||
|
|
||||||
<ThemeToggle {darkMode} on:toggle={handleThemeToggle} />
|
<TopBar />
|
||||||
|
|
||||||
<main class="container py-8">
|
<main>
|
||||||
<Header />
|
<div class="wrap">
|
||||||
|
<section class="lede">
|
||||||
<div class="grid gap-6">
|
<p class="eyebrow">C2PA · AI credentials · EXIF</p>
|
||||||
<section class="card">
|
<h1>Remove content credentials from an image</h1>
|
||||||
<div class="flex justify-between items-center mb-4">
|
<p>
|
||||||
<h2 class="text-xl font-bold">Upload Your Image</h2>
|
Untrace strips C2PA content credentials, AI generation tags, GPS
|
||||||
|
coordinates and EXIF metadata from a photo — free, and without the file
|
||||||
|
ever leaving your device.
|
||||||
|
</p>
|
||||||
|
<div class="chip-row">
|
||||||
|
<span class="chip">Free</span>
|
||||||
|
<span class="chip">No sign-in</span>
|
||||||
|
<span class="chip">Open source</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ImageUploader
|
|
||||||
on:fileSelected={handleFileSelected}
|
|
||||||
on:reset={handleReset}
|
|
||||||
disabled={isProcessing}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Processing Section -->
|
<Tool on:notify={notify} />
|
||||||
{#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">
|
<ul class="assurances">
|
||||||
<img
|
{#each assurances as item}
|
||||||
src={URL.createObjectURL(selectedFile)}
|
<li>
|
||||||
alt="Original"
|
<h2>{item.title}</h2>
|
||||||
class="image-preview"
|
<p>{item.detail}</p>
|
||||||
/>
|
</li>
|
||||||
</div>
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<div class="text-xs text-secondary">
|
<div class="stack sections">
|
||||||
<p>
|
<Removals />
|
||||||
{selectedFile.name} • {(selectedFile.size / 1024).toFixed(1)} KB
|
<HowItWorks />
|
||||||
</p>
|
<Faq />
|
||||||
{#if metadata && metadata.message && !metadata.hasMetadata}
|
</div>
|
||||||
<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">✓ GPS location data cleared</p>
|
|
||||||
<p class="status-success">✓ AI content credentials stripped</p>
|
|
||||||
<p class="status-success">✓ C2PA provenance data removed</p>
|
|
||||||
<p class="status-success">✓ Camera & device info 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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|
||||||
|
<Toast {toasts} {dismiss} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.w-full {
|
main {
|
||||||
width: 100%;
|
padding-bottom: var(--space-8);
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-warning {
|
.assurances {
|
||||||
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;
|
display: grid;
|
||||||
gap: 1.5rem;
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
list-style: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-2 {
|
@media (min-width: 768px) {
|
||||||
grid-template-columns: 1fr 1fr;
|
.assurances {
|
||||||
}
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
@media (max-width: 768px) {
|
|
||||||
.grid-2 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-preview {
|
|
||||||
max-height: 150px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-secondary {
|
.assurances li {
|
||||||
color: var(--text-secondary);
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
border-left: var(--border-w) solid var(--border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assurances h2 {
|
||||||
|
font-size: var(--fs-2);
|
||||||
|
line-height: var(--lh-2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assurances p {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
line-height: var(--lh-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sections {
|
||||||
|
margin-top: var(--space-9);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import { ACCEPTED_EXTENSIONS, isAccepted } from "./metadata.js";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let busy = false;
|
||||||
|
|
||||||
|
let dragging = false;
|
||||||
|
let fileInput;
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
function offer(file) {
|
||||||
|
if (!file) return;
|
||||||
|
if (!isAccepted(file)) {
|
||||||
|
dispatch("reject", {
|
||||||
|
message: `Untrace cannot read ${file.type || "that file type"}. Choose a JPEG, PNG, TIFF, HEIC or AVIF image.`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dispatch("select", { file });
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
depth = 0;
|
||||||
|
dragging = false;
|
||||||
|
if (busy) return;
|
||||||
|
offer(event.dataTransfer?.files?.[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnter(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
depth += 1;
|
||||||
|
dragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragLeave(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
depth = Math.max(0, depth - 1);
|
||||||
|
if (depth === 0) dragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChange(event) {
|
||||||
|
offer(event.target.files?.[0]);
|
||||||
|
// Reset so choosing the same file twice still fires a change event.
|
||||||
|
event.target.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste(event) {
|
||||||
|
if (busy) return;
|
||||||
|
const item = [...(event.clipboardData?.items || [])].find((entry) =>
|
||||||
|
entry.type.startsWith("image/")
|
||||||
|
);
|
||||||
|
if (item) offer(item.getAsFile());
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window
|
||||||
|
on:paste={onPaste}
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:drop|preventDefault
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="dropzone"
|
||||||
|
data-drag={dragging}
|
||||||
|
disabled={busy}
|
||||||
|
on:click={() => fileInput.click()}
|
||||||
|
on:dragenter={onDragEnter}
|
||||||
|
on:dragover|preventDefault
|
||||||
|
on:dragleave={onDragLeave}
|
||||||
|
on:drop={onDrop}
|
||||||
|
>
|
||||||
|
<span class="glyph"><Icon name="upload" size="lg" /></span>
|
||||||
|
<span class="title">
|
||||||
|
{dragging ? "Drop to scan it" : "Drop an image, or choose a file"}
|
||||||
|
</span>
|
||||||
|
<span class="hint">
|
||||||
|
Nothing is uploaded. The image is read and rewritten inside this tab.
|
||||||
|
</span>
|
||||||
|
<span class="chip-row formats">
|
||||||
|
<span class="chip">JPEG</span>
|
||||||
|
<span class="chip">PNG</span>
|
||||||
|
<span class="chip">TIFF</span>
|
||||||
|
<span class="chip">HEIC</span>
|
||||||
|
<span class="chip">AVIF</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p class="paste-hint">
|
||||||
|
You can also paste an image with <kbd>Ctrl</kbd>+<kbd>V</kbd>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="visually-hidden"
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
|
bind:this={fileInput}
|
||||||
|
on:change={onChange}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.formats {
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-hint {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
line-height: var(--lh-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
color: var(--text-2);
|
||||||
|
padding: 0 var(--space-1);
|
||||||
|
border: var(--border-w) solid var(--border-strong);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script>
|
||||||
|
import { faqs } from "./faq-data.js";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="section" id="faq" aria-labelledby="faq-heading">
|
||||||
|
<div class="section-head">
|
||||||
|
<p class="eyebrow">Questions</p>
|
||||||
|
<h2 id="faq-heading">Content credentials, answered</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="list">
|
||||||
|
{#each faqs as item, index}
|
||||||
|
<details class="disclosure" open={index === 0}>
|
||||||
|
<summary>
|
||||||
|
<span>{item.q}</span>
|
||||||
|
<span class="caret"><Icon name="chevron" size="sm" /></span>
|
||||||
|
</summary>
|
||||||
|
<div class="body"><p>{item.a}</p></div>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section {
|
||||||
|
scroll-margin-top: calc(var(--topbar-h) + var(--space-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
max-width: calc(var(--space-9) * 8);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,48 +1,142 @@
|
|||||||
<footer class="mt-12 py-8 border-t border-color text-center">
|
<script>
|
||||||
<div class="container">
|
import Icon from "./Icon.svelte";
|
||||||
<div class="grid grid-2 gap-8 mb-6">
|
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<footer class="site-footer">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="cols">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold mb-3">About Untrace</h3>
|
<p class="wordmark">
|
||||||
<p class="text-sm text-secondary">
|
<span class="r-mark" aria-hidden="true">U</span>
|
||||||
A privacy-first tool that removes EXIF metadata from images completely
|
<span>Untrace</span>
|
||||||
in your browser. No servers, no tracking, no data collection.
|
</p>
|
||||||
|
<p class="blurb">
|
||||||
|
A privacy tool that removes content credentials, AI generation tags
|
||||||
|
and EXIF metadata from images. Built and run entirely in the browser.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<nav aria-label="Sections">
|
||||||
<h3 class="font-semibold mb-3">Open Source</h3>
|
<h2>On this page</h2>
|
||||||
<p class="text-sm text-secondary mb-2">
|
<ul>
|
||||||
Built with Svelte and deployed on GitHub Pages.
|
<li><a href="#tool">Clean an image</a></li>
|
||||||
</p>
|
<li><a href="#what-is-removed">What is removed</a></li>
|
||||||
|
<li><a href="#how-it-works">How it works</a></li>
|
||||||
|
<li><a href="#faq">Questions</a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<a
|
<nav aria-label="Project">
|
||||||
href="https://github.com/developersharif/untrace"
|
<h2>Project</h2>
|
||||||
class="btn btn-secondary text-sm"
|
<ul>
|
||||||
target="_blank"
|
<li>
|
||||||
rel="noopener noreferrer"
|
<a
|
||||||
>
|
href="https://github.com/developersharif/untrace"
|
||||||
<svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
|
target="_blank"
|
||||||
<path
|
rel="noopener noreferrer"
|
||||||
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"
|
>
|
||||||
/>
|
<Icon name="github" size="sm" />
|
||||||
</svg>
|
Source on GitHub
|
||||||
View Source
|
</a>
|
||||||
</a>
|
</li>
|
||||||
</div>
|
<li>
|
||||||
|
<a href="https://realbrain.cc/" target="_blank" rel="noopener noreferrer">
|
||||||
|
RealBrain
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="https://www.buymeacoffee.com/developersharif"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Buy me a coffee
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="text-sm text-muted">
|
<hr class="divider" />
|
||||||
<p>
|
|
||||||
© 2025 Untrace. Built for privacy advocates, journalists, and digital
|
<div class="base">
|
||||||
rights.
|
<p>© {year} Untrace. MIT licensed.</p>
|
||||||
|
<p class="claims">
|
||||||
|
<span class="status ok">No uploads</span>
|
||||||
|
<span class="status ok">No accounts</span>
|
||||||
|
<span class="status ok">No analytics</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">No analytics • No tracking • No data collection</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.border-t {
|
.cols {
|
||||||
border-top: 1px solid var(--border-color);
|
display: grid;
|
||||||
|
gap: var(--space-6);
|
||||||
|
margin-bottom: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.cols {
|
||||||
|
grid-template-columns: 2fr 1fr 1fr;
|
||||||
|
gap: var(--space-7);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.blurb {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
max-width: calc(var(--space-9) * 4);
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
line-height: var(--lh-1);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-2);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
ul a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.base {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.claims {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-4);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
<header class="text-center mb-6 header-container">
|
|
||||||
<div class="flex items-center justify-center gap-4 mb-4 header-title">
|
|
||||||
<div class="logo-container">
|
|
||||||
<svg
|
|
||||||
width="56"
|
|
||||||
height="56"
|
|
||||||
viewBox="0 0 48 48"
|
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
class="logo-svg"
|
|
||||||
>
|
|
||||||
<!-- Background circle with gradient -->
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="logoGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
|
||||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
|
||||||
<stop offset="100%" style="stop-color:#1E40AF;stop-opacity:1" />
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
|
|
||||||
<feDropShadow
|
|
||||||
dx="0"
|
|
||||||
dy="2"
|
|
||||||
stdDeviation="3"
|
|
||||||
flood-color="rgba(0,0,0,0.1)"
|
|
||||||
/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
<!-- Main circle background -->
|
|
||||||
<circle
|
|
||||||
cx="24"
|
|
||||||
cy="24"
|
|
||||||
r="22"
|
|
||||||
fill="url(#logoGradient)"
|
|
||||||
filter="url(#shadow)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Image/photo icon base -->
|
|
||||||
<rect
|
|
||||||
x="10"
|
|
||||||
y="14"
|
|
||||||
width="28"
|
|
||||||
height="20"
|
|
||||||
rx="3"
|
|
||||||
fill="white"
|
|
||||||
opacity="0.9"
|
|
||||||
/>
|
|
||||||
<rect
|
|
||||||
x="12"
|
|
||||||
y="16"
|
|
||||||
width="24"
|
|
||||||
height="16"
|
|
||||||
rx="2"
|
|
||||||
fill="none"
|
|
||||||
stroke="#1E40AF"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Mountain/landscape in photo -->
|
|
||||||
<path
|
|
||||||
d="M12 28 L18 22 L22 25 L28 20 L36 26 V30 H12 Z"
|
|
||||||
fill="#E5E7EB"
|
|
||||||
opacity="0.6"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Sun/circle in photo -->
|
|
||||||
<circle cx="30" cy="20" r="2.5" fill="#FCD34D" opacity="0.7" />
|
|
||||||
|
|
||||||
<!-- Strike-through line (indicating removal) -->
|
|
||||||
<line
|
|
||||||
x1="8"
|
|
||||||
y1="8"
|
|
||||||
x2="40"
|
|
||||||
y2="40"
|
|
||||||
stroke="#EF4444"
|
|
||||||
stroke-width="3"
|
|
||||||
stroke-linecap="round"
|
|
||||||
opacity="0.8"
|
|
||||||
/>
|
|
||||||
<line
|
|
||||||
x1="8"
|
|
||||||
y1="8"
|
|
||||||
x2="40"
|
|
||||||
y2="40"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Shield icon overlay (protection) -->
|
|
||||||
<path
|
|
||||||
d="M38 10 L42 10 C43.1 10 44 10.9 44 12 L44 16 C44 19.3 42.3 22.4 39.5 24.2 L38 25 L36.5 24.2 C33.7 22.4 32 19.3 32 16 L32 12 C32 10.9 32.9 10 34 10 L38 10 Z"
|
|
||||||
fill="#10B981"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="1"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Checkmark in shield -->
|
|
||||||
<path
|
|
||||||
d="M36 15 L37.5 16.5 L40 14"
|
|
||||||
stroke="white"
|
|
||||||
stroke-width="1.5"
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<div class="brand-text">
|
|
||||||
<h1 class="brand-name">
|
|
||||||
<span class="brand-primary">Untrace</span><span class="brand-secondary"
|
|
||||||
>trace</span
|
|
||||||
>
|
|
||||||
</h1>
|
|
||||||
<p class="brand-tagline">
|
|
||||||
Remove EXIF metadata & AI credentials. Protect privacy.
|
|
||||||
</p>
|
|
||||||
<div class="brand-badge">
|
|
||||||
<span class="badge-text">Professional Privacy Tool</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="text-sm text-secondary max-w-2xl mx-auto header-description">
|
|
||||||
<strong>Free online EXIF metadata remover</strong> that strips GPS location
|
|
||||||
data, camera information, AI content credentials, and C2PA provenance from
|
|
||||||
images. <strong>100% browser-based processing</strong>
|
|
||||||
- no uploads, no tracking, no data collection.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-4 text-xs text-muted feature-tags">
|
|
||||||
<span class="feature-tag">
|
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
EXIF Removal
|
|
||||||
</span>
|
|
||||||
<span class="feature-tag">
|
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
AI Tag Stripper
|
|
||||||
</span>
|
|
||||||
<span class="feature-tag">
|
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
GPS Cleaner
|
|
||||||
</span>
|
|
||||||
<span class="feature-tag">
|
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M3 6a3 3 0 013-3h10a1 1 0 01.8 1.6L14.25 8l2.55 3.4A1 1 0 0116 13H6a1 1 0 00-1 1v3a1 1 0 11-2 0V6z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
C2PA Remover
|
|
||||||
</span>
|
|
||||||
<span class="feature-tag">
|
|
||||||
<svg width="12" height="12" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z"
|
|
||||||
clip-rule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Instant Processing
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.header-container {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Professional Logo and Branding Styles */
|
|
||||||
.logo-container {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-svg {
|
|
||||||
transition: transform 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-svg:hover {
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text {
|
|
||||||
text-align: left;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-name {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
line-height: 1;
|
|
||||||
margin: 0;
|
|
||||||
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 50%, #1e3a8a 100%);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
background-clip: text;
|
|
||||||
font-family:
|
|
||||||
system-ui,
|
|
||||||
-apple-system,
|
|
||||||
sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-primary {
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-secondary {
|
|
||||||
font-weight: 700;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-tagline {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-badge {
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-text {
|
|
||||||
display: inline-block;
|
|
||||||
background: linear-gradient(135deg, #10b981, #059669);
|
|
||||||
color: white;
|
|
||||||
padding: 0.125rem 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
font-size: 0.625rem;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.max-w-2xl {
|
|
||||||
max-width: 42rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mx-auto {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-tag {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.25rem;
|
|
||||||
background-color: var(--bg-secondary);
|
|
||||||
color: var(--text-secondary);
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
border-radius: 0.375rem;
|
|
||||||
margin: 0.125rem;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-tag svg {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-muted {
|
|
||||||
color: var(--text-muted, #6b7280);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Mobile optimizations */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.header-container {
|
|
||||||
padding-top: 1.5rem;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 1.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-text {
|
|
||||||
text-align: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-name {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-tagline {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-text {
|
|
||||||
font-size: 0.5rem;
|
|
||||||
padding: 0.1rem 0.4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-description {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
padding: 0 1rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-tags {
|
|
||||||
margin-top: 1rem;
|
|
||||||
padding: 0 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-tag {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
padding: 0.125rem 0.375rem;
|
|
||||||
margin: 0.0625rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.header-container {
|
|
||||||
padding-top: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-title {
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-name {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.brand-tagline {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo-svg {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-description {
|
|
||||||
font-size: 0.6875rem;
|
|
||||||
padding: 0 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-tags {
|
|
||||||
padding: 0 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<section class="section" id="how-it-works" aria-labelledby="how-heading">
|
||||||
|
<div class="section-head">
|
||||||
|
<p class="eyebrow">How to</p>
|
||||||
|
<h2 id="how-heading">How to remove content credentials from an image</h2>
|
||||||
|
<p>
|
||||||
|
Three steps, no account and no upload. The whole process runs on your own
|
||||||
|
device, so the image never reaches a server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol class="howto">
|
||||||
|
<li>
|
||||||
|
<h3>Add the image</h3>
|
||||||
|
<p>
|
||||||
|
Drop a JPEG, PNG, TIFF, HEIC or AVIF file onto the tool, choose one from
|
||||||
|
your device, or paste from the clipboard.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>Review what is hidden</h3>
|
||||||
|
<p>
|
||||||
|
Untrace lists every metadata block it finds — C2PA content credentials,
|
||||||
|
AI generation tags, GPS coordinates, camera details and timestamps.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>Clean and download</h3>
|
||||||
|
<p>
|
||||||
|
The image is decoded to pixels and written back out, which leaves every
|
||||||
|
metadata container behind. Download the clean copy.
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section {
|
||||||
|
scroll-margin-top: calc(var(--topbar-h) + var(--space-4));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script>
|
||||||
|
/**
|
||||||
|
* Single stroke-icon set. Icons inherit currentColor and are decorative by
|
||||||
|
* default — every one of them sits next to a text label.
|
||||||
|
*/
|
||||||
|
export let name;
|
||||||
|
export let size = "md";
|
||||||
|
|
||||||
|
const paths = {
|
||||||
|
upload:
|
||||||
|
"M12 16V4m0 0L8 8m4-4 4 4M4 15v2a3 3 0 0 0 3 3h10a3 3 0 0 0 3-3v-2",
|
||||||
|
shield: "M12 3l7 3v6c0 4-3 7-7 8-4-1-7-4-7-8V6l7-3Zm-2.5 8.5 2 2 3.5-3.5",
|
||||||
|
download: "M12 4v11m0 0-4-4m4 4 4-4M5 19h14",
|
||||||
|
sparkle:
|
||||||
|
"M12 3v4m0 10v4M3 12h4m10 0h4M6.2 6.2l2.6 2.6m6.4 6.4 2.6 2.6m0-11.6-2.6 2.6m-6.4 6.4-2.6 2.6",
|
||||||
|
pin: "M12 21s7-5.4 7-11a7 7 0 1 0-14 0c0 5.6 7 11 7 11Zm0-8.5a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5Z",
|
||||||
|
camera:
|
||||||
|
"M4 8h3l1.5-2h7L17 8h3v11H4V8Zm8 3.5a3.5 3.5 0 1 0 0 7 3.5 3.5 0 0 0 0-7Z",
|
||||||
|
clock: "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Zm0-14v5l3.5 2",
|
||||||
|
file: "M6 3h8l4 4v14H6V3Zm8 0v4h4M9 12h6M9 16h6",
|
||||||
|
chevron: "m6 9 6 6 6-6",
|
||||||
|
sun: "M12 17a5 5 0 1 0 0-10 5 5 0 0 0 0 10Zm0-14v2m0 14v2M3 12h2m14 0h2M5.6 5.6l1.4 1.4m10 10 1.4 1.4m0-12.8-1.4 1.4m-10 10-1.4 1.4",
|
||||||
|
moon: "M20 14.5A8.5 8.5 0 0 1 9.5 4a8.5 8.5 0 1 0 10.5 10.5Z",
|
||||||
|
github:
|
||||||
|
"M9 19c-4 1.4-4-2.1-6-2.6m12 5v-3.5c0-1 .1-1.4-.5-2 2.8-.3 5.5-1.4 5.5-6a4.7 4.7 0 0 0-1.3-3.2 4.3 4.3 0 0 0-.1-3.3s-1.1-.3-3.5 1.3a12 12 0 0 0-6.2 0C6.5 3.1 5.4 3.4 5.4 3.4a4.3 4.3 0 0 0-.1 3.3A4.7 4.7 0 0 0 4 9.9c0 4.6 2.7 5.7 5.5 6-.6.6-.6 1.2-.5 2V21",
|
||||||
|
refresh: "M20 11a8 8 0 1 0-.9 4.5M20 5v6h-6",
|
||||||
|
close: "m6 6 12 12M18 6 6 18",
|
||||||
|
check: "m5 13 4 4L19 7",
|
||||||
|
scan: "M4 8V6a2 2 0 0 1 2-2h2m8 0h2a2 2 0 0 1 2 2v2m0 8v2a2 2 0 0 1-2 2h-2m-8 0H6a2 2 0 0 1-2-2v-2m0-4h16",
|
||||||
|
lock: "M7 11V8a5 5 0 0 1 10 0v3m-11 0h12v9H6v-9Z",
|
||||||
|
image:
|
||||||
|
"M4 5h16v14H4V5Zm0 10 4.5-4.5 4 4L15 12l5 5",
|
||||||
|
};
|
||||||
|
|
||||||
|
const boxes = { sm: "16", md: "20", lg: "24" };
|
||||||
|
$: box = boxes[size] || boxes.md;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width={box}
|
||||||
|
height={box}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.6"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
>
|
||||||
|
<path d={paths[name]} />
|
||||||
|
</svg>
|
||||||
@@ -1,385 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,731 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
<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",
|
|
||||||
"adobe firefly",
|
|
||||||
"google imagen",
|
|
||||||
"anthropic claude",
|
|
||||||
"runway ml",
|
|
||||||
"artbreeder",
|
|
||||||
"deepdream",
|
|
||||||
"nightcafe",
|
|
||||||
"starryai",
|
|
||||||
"jasper art",
|
|
||||||
"canva ai",
|
|
||||||
"remove.bg",
|
|
||||||
"upscayl",
|
|
||||||
"real-esrgan",
|
|
||||||
"waifu2x",
|
|
||||||
"topaz gigapixel",
|
|
||||||
"c2pa",
|
|
||||||
"content credentials",
|
|
||||||
"provenance",
|
|
||||||
"content authenticity",
|
|
||||||
"jumbf",
|
|
||||||
"cai",
|
|
||||||
"project origin",
|
|
||||||
];
|
|
||||||
|
|
||||||
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>
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
<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 tools
|
|
||||||
</div>
|
|
||||||
<div class="privacy-risk">
|
|
||||||
<strong>🔗 C2PA Credentials:</strong> Content provenance tracks creation
|
|
||||||
history
|
|
||||||
</div>
|
|
||||||
<div class="privacy-risk">
|
|
||||||
<strong>🏷️ Software Tags:</strong> DALL-E, Midjourney, ChatGPT watermarks
|
|
||||||
</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>• AI artists removing generation tags</div>
|
|
||||||
<div>• Content creators stripping watermarks</div>
|
|
||||||
<div>• Social media users protecting privacy</div>
|
|
||||||
<div>• Anyone valuing digital anonymity</div>
|
|
||||||
<div>• Photographers selling stock images</div>
|
|
||||||
<div>• Removing C2PA content credentials</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 p-4 bg-secondary rounded">
|
|
||||||
<h4 class="font-semibold mb-2">🤖 AI Content Credential Support:</h4>
|
|
||||||
<div class="grid grid-3 gap-2 text-sm">
|
|
||||||
<div>• OpenAI DALL-E</div>
|
|
||||||
<div>• Midjourney</div>
|
|
||||||
<div>• Stable Diffusion</div>
|
|
||||||
<div>• Adobe Firefly</div>
|
|
||||||
<div>• ChatGPT Images</div>
|
|
||||||
<div>• Google Imagen</div>
|
|
||||||
<div>• C2PA Signatures</div>
|
|
||||||
<div>• Content Authenticity</div>
|
|
||||||
<div>• JUMBF Containers</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);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-secondary {
|
|
||||||
background-color: rgba(16, 185, 129, 0.05);
|
|
||||||
border: 1px solid rgba(16, 185, 129, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rounded {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-2 {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-3 {
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.grid-2 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
.grid-3 {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.grid-3 {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script>
|
||||||
|
const groups = [
|
||||||
|
{
|
||||||
|
title: "C2PA content credentials",
|
||||||
|
detail:
|
||||||
|
"Manifests, claims, assertions and signatures written by the Content Authenticity Initiative.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "AI generation tags",
|
||||||
|
detail:
|
||||||
|
"ChatGPT, DALL·E, Midjourney, Stable Diffusion, Adobe Firefly and Google Imagen markers.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "JUMBF containers",
|
||||||
|
detail:
|
||||||
|
"The APP11 and APP2 boxes that carry provenance data inside a JPEG.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "GPS location",
|
||||||
|
detail:
|
||||||
|
"Latitude, longitude, altitude, bearing and the timestamp attached to them.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Camera and device",
|
||||||
|
detail: "Make, model, lens, serial numbers, exposure and shooting settings.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Timestamps",
|
||||||
|
detail: "When the photo was taken, digitised and last modified.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "XMP and IPTC",
|
||||||
|
detail: "Creator names, captions, keywords, copyright and editing history.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Comments and thumbnails",
|
||||||
|
detail:
|
||||||
|
"User comments, maker notes and the embedded preview that can differ from the visible image.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "PNG text chunks",
|
||||||
|
detail: "tEXt, iTXt and zTXt blocks, including generation prompts.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="section" id="what-is-removed" aria-labelledby="removed-heading">
|
||||||
|
<div class="section-head">
|
||||||
|
<p class="eyebrow">Coverage</p>
|
||||||
|
<h2 id="removed-heading">What Untrace removes</h2>
|
||||||
|
<p>
|
||||||
|
Re-encoding drops every container at once, rather than deleting known tags
|
||||||
|
one by one and leaving the rest behind.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tiles">
|
||||||
|
{#each groups as group}
|
||||||
|
<article class="tile">
|
||||||
|
<h3>{group.title}</h3>
|
||||||
|
<p>{group.detail}</p>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.section {
|
||||||
|
scroll-margin-top: calc(var(--topbar-h) + var(--space-4));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script>
|
||||||
|
import { formatBytes } from "./clean.js";
|
||||||
|
|
||||||
|
export let result;
|
||||||
|
export let previewUrl;
|
||||||
|
|
||||||
|
const removed = [
|
||||||
|
"EXIF and TIFF tags",
|
||||||
|
"GPS coordinates",
|
||||||
|
"Camera make, model and settings",
|
||||||
|
"Capture timestamps",
|
||||||
|
"XMP, IPTC and Photoshop blocks",
|
||||||
|
"C2PA content credentials",
|
||||||
|
"JUMBF containers",
|
||||||
|
"AI generation tags",
|
||||||
|
];
|
||||||
|
|
||||||
|
$: delta = Math.round(result.sizeDelta);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="result">
|
||||||
|
<div class="side">
|
||||||
|
<p class="cap">Before</p>
|
||||||
|
<figure class="preview">
|
||||||
|
<img src={previewUrl} alt="Original, before cleaning" />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side">
|
||||||
|
<p class="cap">
|
||||||
|
After <span class="status ok">clean</span>
|
||||||
|
</p>
|
||||||
|
<figure class="preview">
|
||||||
|
<img src={result.url} alt="Result, after cleaning" />
|
||||||
|
</figure>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">File name</span>
|
||||||
|
<span class="v" title={result.filename}>{result.filename}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">Size</span>
|
||||||
|
<span class="v">
|
||||||
|
{formatBytes(result.size)}
|
||||||
|
<span class="delta">
|
||||||
|
{delta === 0 ? "no change" : `${delta > 0 ? "+" : ""}${delta}%`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if result.wasSecondPass}
|
||||||
|
<p class="note">
|
||||||
|
<span class="status warn">Second pass used</span>
|
||||||
|
Metadata survived the first rewrite, so the image was re-encoded as JPEG
|
||||||
|
on a white background. Transparency is not preserved.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<ul class="removed">
|
||||||
|
{#each removed as item}
|
||||||
|
<li><span class="status ok">{item}</span></li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.result {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.result {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) minmax(0, 1fr);
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.side {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
line-height: var(--lh-1);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta {
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-left: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .v {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
line-height: var(--lh-2);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.removed {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script>
|
||||||
|
import {
|
||||||
|
countEntries,
|
||||||
|
findings,
|
||||||
|
formatMetadata,
|
||||||
|
isClean,
|
||||||
|
} from "./metadata.js";
|
||||||
|
import { formatBytes } from "./clean.js";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
export let file;
|
||||||
|
export let metadata;
|
||||||
|
export let previewUrl;
|
||||||
|
|
||||||
|
$: clean = isClean(metadata);
|
||||||
|
$: list = findings(metadata);
|
||||||
|
$: entries = countEntries(metadata);
|
||||||
|
$: raw = formatMetadata(metadata);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="review">
|
||||||
|
<figure class="preview">
|
||||||
|
<img src={previewUrl} alt="Preview of the file you selected" />
|
||||||
|
</figure>
|
||||||
|
|
||||||
|
<div class="detail">
|
||||||
|
<div class="rows">
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">File</span>
|
||||||
|
<span class="v" title={file.name}>{file.name}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">Size</span>
|
||||||
|
<span class="v">{formatBytes(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<span class="k">Metadata entries</span>
|
||||||
|
<span class="v">{entries}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if clean}
|
||||||
|
<div class="verdict">
|
||||||
|
<span class="status ok">Already clean</span>
|
||||||
|
<p>
|
||||||
|
{metadata?.message ||
|
||||||
|
"No metadata found in this image."} You can still rewrite it to be certain nothing is hiding in a container Untrace cannot read.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="verdict">
|
||||||
|
<h3>What is hidden in this image</h3>
|
||||||
|
<ul class="found">
|
||||||
|
{#each list as item}
|
||||||
|
<li>
|
||||||
|
<span class="status {item.severity}">{item.label}</span>
|
||||||
|
<span class="detail-text">{item.detail}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="disclosure">
|
||||||
|
<summary>
|
||||||
|
Show the raw metadata
|
||||||
|
<span class="caret"><Icon name="chevron" size="sm" /></span>
|
||||||
|
</summary>
|
||||||
|
<div class="body">
|
||||||
|
<pre class="raw">{raw}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.review {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.review {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
gap: var(--space-5);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row .v {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict h3 {
|
||||||
|
font-size: var(--fs-3);
|
||||||
|
line-height: var(--lh-3);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verdict p {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: var(--fs-2);
|
||||||
|
line-height: var(--lh-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.found {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.found li {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-text {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: var(--fs-1);
|
||||||
|
line-height: var(--lh-1);
|
||||||
|
padding-left: var(--space-4);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,34 +1,26 @@
|
|||||||
<script>
|
<script>
|
||||||
import { createEventDispatcher } from "svelte";
|
import { theme } from "./theme.js";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
const dispatch = createEventDispatcher();
|
|
||||||
|
|
||||||
export let darkMode = false;
|
|
||||||
|
|
||||||
function toggle() {
|
|
||||||
dispatch("toggle");
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="theme-toggle"
|
class="theme-toggle"
|
||||||
on:click={toggle}
|
type="button"
|
||||||
aria-label="Toggle dark mode"
|
on:click={() => theme.toggle()}
|
||||||
title="Toggle dark mode"
|
aria-label="Switch to {$theme === 'light' ? 'dark' : 'light'} theme"
|
||||||
>
|
>
|
||||||
{#if darkMode}
|
<Icon name={$theme === "light" ? "moon" : "sun"} size="sm" />
|
||||||
<svg width="24" height="24" fill="currentColor" viewBox="0 0 20 20">
|
<span class="text">{$theme === "light" ? "Dark" : "Light"}</span>
|
||||||
<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>
|
</button>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script>
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { motionSafe } from "./theme.js";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
/** @type {{id: number, message: string, tone: 'ok'|'err'}[]} */
|
||||||
|
export let toasts = [];
|
||||||
|
export let dismiss = () => {};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="toast-region" role="status" aria-live="polite">
|
||||||
|
{#each toasts as toast (toast.id)}
|
||||||
|
<div
|
||||||
|
class="toast"
|
||||||
|
data-tone={toast.tone}
|
||||||
|
out:fade={{ duration: motionSafe(200) }}
|
||||||
|
>
|
||||||
|
<span class="icon" aria-hidden="true">
|
||||||
|
<Icon name={toast.tone === "err" ? "close" : "check"} size="sm" />
|
||||||
|
</span>
|
||||||
|
<span class="message">{toast.message}</span>
|
||||||
|
<button type="button" on:click={() => dismiss(toast.id)} aria-label="Dismiss">
|
||||||
|
<Icon name="close" size="sm" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.toast-region {
|
||||||
|
left: var(--space-4);
|
||||||
|
right: var(--space-4);
|
||||||
|
top: calc(var(--topbar-h) + var(--space-3));
|
||||||
|
bottom: auto;
|
||||||
|
justify-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.toast-region {
|
||||||
|
left: auto;
|
||||||
|
top: auto;
|
||||||
|
right: var(--space-5);
|
||||||
|
bottom: var(--space-5);
|
||||||
|
max-width: calc(var(--space-9) * 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast[data-tone="err"] {
|
||||||
|
border-left-color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast[data-tone="err"] .icon {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast button {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<script>
|
||||||
|
import { createEventDispatcher, onDestroy } from "svelte";
|
||||||
|
import { fade } from "svelte/transition";
|
||||||
|
import { extractMetadata } from "./metadata.js";
|
||||||
|
import { cleanImage } from "./clean.js";
|
||||||
|
import { motionSafe } from "./theme.js";
|
||||||
|
import Dropzone from "./Dropzone.svelte";
|
||||||
|
import ReviewPanel from "./ReviewPanel.svelte";
|
||||||
|
import ResultPanel from "./ResultPanel.svelte";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
const steps = ["Scan", "Review", "Download"];
|
||||||
|
|
||||||
|
/** @type {'idle'|'scanning'|'review'|'cleaning'|'done'} */
|
||||||
|
let stage = "idle";
|
||||||
|
let file = null;
|
||||||
|
let metadata = null;
|
||||||
|
let previewUrl = null;
|
||||||
|
let result = null;
|
||||||
|
|
||||||
|
$: current = stage === "idle" || stage === "scanning" ? 0 : stage === "done" ? 2 : 1;
|
||||||
|
$: busy = stage === "scanning" || stage === "cleaning";
|
||||||
|
|
||||||
|
function stepState(index, active) {
|
||||||
|
if (index < active) return "done";
|
||||||
|
if (index === active) return "current";
|
||||||
|
return "upcoming";
|
||||||
|
}
|
||||||
|
|
||||||
|
function release() {
|
||||||
|
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||||
|
if (result?.url) URL.revokeObjectURL(result.url);
|
||||||
|
previewUrl = null;
|
||||||
|
result = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSelect(event) {
|
||||||
|
release();
|
||||||
|
file = event.detail.file;
|
||||||
|
previewUrl = URL.createObjectURL(file);
|
||||||
|
metadata = null;
|
||||||
|
stage = "scanning";
|
||||||
|
|
||||||
|
try {
|
||||||
|
metadata = await extractMetadata(file);
|
||||||
|
stage = "review";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Scan failed:", error);
|
||||||
|
stage = "idle";
|
||||||
|
dispatch("notify", {
|
||||||
|
tone: "err",
|
||||||
|
message: "That image could not be read. Try a different file.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clean() {
|
||||||
|
if (!file) return;
|
||||||
|
stage = "cleaning";
|
||||||
|
result = await cleanImage(file);
|
||||||
|
stage = "done";
|
||||||
|
|
||||||
|
if (result.fallback) {
|
||||||
|
dispatch("notify", {
|
||||||
|
tone: "err",
|
||||||
|
message:
|
||||||
|
"This image could not be re-encoded, so nothing was removed. Try converting it to JPEG or PNG first.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
dispatch("notify", { tone: "ok", message: "Metadata removed." });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
release();
|
||||||
|
file = null;
|
||||||
|
metadata = null;
|
||||||
|
stage = "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(release);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="card tool" id="tool" aria-labelledby="tool-heading">
|
||||||
|
<h2 class="visually-hidden" id="tool-heading">Clean an image</h2>
|
||||||
|
|
||||||
|
<ol class="stepper">
|
||||||
|
{#each steps as label, index}
|
||||||
|
<li class="step" data-state={stepState(index, current)}>
|
||||||
|
<span class="num" aria-hidden="true">
|
||||||
|
{#if stepState(index, current) === "done"}
|
||||||
|
<Icon name="check" size="sm" />
|
||||||
|
{:else}
|
||||||
|
{index + 1}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="label">{label}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div class="panel" aria-live="polite">
|
||||||
|
{#if stage === "idle"}
|
||||||
|
<div in:fade={{ duration: motionSafe(200) }}>
|
||||||
|
<Dropzone
|
||||||
|
{busy}
|
||||||
|
on:select={onSelect}
|
||||||
|
on:reject={(e) => dispatch("notify", { tone: "err", message: e.detail.message })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{:else if stage === "scanning"}
|
||||||
|
<div class="loading" in:fade={{ duration: motionSafe(200) }}>
|
||||||
|
<div class="skeleton block"></div>
|
||||||
|
<div class="lines">
|
||||||
|
<div class="skeleton line"></div>
|
||||||
|
<div class="skeleton line short"></div>
|
||||||
|
<div class="skeleton line"></div>
|
||||||
|
</div>
|
||||||
|
<p class="loading-text">Reading metadata from {file.name}…</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div in:fade={{ duration: motionSafe(200) }}>
|
||||||
|
{#if stage === "done" && result}
|
||||||
|
<ResultPanel {result} {previewUrl} />
|
||||||
|
{:else}
|
||||||
|
<ReviewPanel {file} {metadata} {previewUrl} />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if stage !== "idle" && stage !== "scanning"}
|
||||||
|
<div class="action-bar">
|
||||||
|
{#if stage === "done" && result}
|
||||||
|
{#if result.fallback}
|
||||||
|
<button type="button" class="btn btn-primary btn-block" on:click={reset}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Try another image
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a
|
||||||
|
class="btn btn-primary btn-block"
|
||||||
|
href={result.url}
|
||||||
|
download={result.filename}
|
||||||
|
>
|
||||||
|
<Icon name="download" size="sm" />
|
||||||
|
Download clean image
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-secondary btn-block" on:click={reset}>
|
||||||
|
<Icon name="refresh" size="sm" />
|
||||||
|
Clean another image
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-block"
|
||||||
|
aria-disabled={stage === "cleaning"}
|
||||||
|
on:click={clean}
|
||||||
|
>
|
||||||
|
{#if stage === "cleaning"}
|
||||||
|
<span class="spinner" aria-hidden="true"></span>
|
||||||
|
Cleaning…
|
||||||
|
{:else}
|
||||||
|
<Icon name="shield" size="sm" />
|
||||||
|
Clean image
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-ghost btn-block"
|
||||||
|
disabled={stage === "cleaning"}
|
||||||
|
on:click={reset}
|
||||||
|
>
|
||||||
|
Choose a different image
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tool {
|
||||||
|
padding: var(--space-4);
|
||||||
|
scroll-margin-top: calc(var(--topbar-h) + var(--space-4));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.tool {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
min-height: calc(var(--space-9) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.loading {
|
||||||
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.block {
|
||||||
|
height: calc(var(--space-9) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.line {
|
||||||
|
height: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.line.short {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: var(--fs-2);
|
||||||
|
line-height: var(--lh-2);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* On small screens the bar sticks to the bottom of the viewport so the one
|
||||||
|
primary action is always within thumb reach; it becomes an inline row at
|
||||||
|
the tablet breakpoint. */
|
||||||
|
.action-bar {
|
||||||
|
margin-top: var(--space-5);
|
||||||
|
margin-bottom: calc(var(--space-4) * -1);
|
||||||
|
border-bottom-left-radius: var(--radius-md);
|
||||||
|
border-bottom-right-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.action-bar {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<script>
|
||||||
|
import ThemeToggle from "./ThemeToggle.svelte";
|
||||||
|
import Icon from "./Icon.svelte";
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="wrap inner">
|
||||||
|
<a class="wordmark" href="/" aria-label="Untrace home">
|
||||||
|
<span class="r-mark" aria-hidden="true">U</span>
|
||||||
|
<span>Untrace</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<nav class="actions" aria-label="Site">
|
||||||
|
<a
|
||||||
|
class="btn btn-ghost repo"
|
||||||
|
href="https://github.com/developersharif/untrace"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Icon name="github" size="sm" />
|
||||||
|
<span>Source</span>
|
||||||
|
</a>
|
||||||
|
<ThemeToggle />
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo {
|
||||||
|
height: var(--space-8);
|
||||||
|
padding-inline: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 560px) {
|
||||||
|
.repo span {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* Metadata removal.
|
||||||
|
*
|
||||||
|
* Everything runs on a canvas in the browser: decoding the image to pixels and
|
||||||
|
* re-encoding it drops every metadata container the original carried — EXIF,
|
||||||
|
* XMP, IPTC, ICC, and the JUMBF boxes that hold C2PA content credentials.
|
||||||
|
* Nothing is uploaded.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const JPEG_QUALITY = 0.92;
|
||||||
|
/**
|
||||||
|
* Matte painted under transparent pixels before a forced JPEG re-encode.
|
||||||
|
* This is image data, not interface styling, so it is deliberately not a
|
||||||
|
* design token — JPEG has no alpha channel and unpainted pixels come out black.
|
||||||
|
*/
|
||||||
|
const JPEG_MATTE = "#FFFFFF";
|
||||||
|
const SECOND_PASS_QUALITY = 0.95;
|
||||||
|
/** Below this pixel count a PNG stays a PNG; larger ones re-encode to JPEG. */
|
||||||
|
const PNG_PIXEL_LIMIT = 1000000;
|
||||||
|
|
||||||
|
function loadImage(source) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const url = URL.createObjectURL(source);
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve({ img, url });
|
||||||
|
img.onerror = () => {
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
reject(new Error("The browser could not decode this image."));
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function redraw(img, { type, quality, fillWhite = false }) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
|
// A white base stops transparent areas turning black when the second pass
|
||||||
|
// forces JPEG, which has no alpha channel.
|
||||||
|
if (fillWhite) {
|
||||||
|
ctx.fillStyle = JPEG_MATTE;
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "source-over";
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
|
||||||
|
canvas.toBlob(
|
||||||
|
(blob) =>
|
||||||
|
blob
|
||||||
|
? resolve(blob)
|
||||||
|
: reject(new Error("The browser could not re-encode this image.")),
|
||||||
|
type,
|
||||||
|
quality
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-reads the output to confirm nothing survived the re-encode. */
|
||||||
|
async function stillHasMetadata(blob) {
|
||||||
|
try {
|
||||||
|
const { parse } = await import("exifr");
|
||||||
|
const remaining = await parse(blob, {
|
||||||
|
tiff: true,
|
||||||
|
exif: true,
|
||||||
|
gps: true,
|
||||||
|
xmp: true,
|
||||||
|
iptc: true,
|
||||||
|
icc: true,
|
||||||
|
silentErrors: true,
|
||||||
|
});
|
||||||
|
return Boolean(remaining && Object.keys(remaining).length > 0);
|
||||||
|
} catch {
|
||||||
|
// exifr throwing here means it found nothing parseable, which is the
|
||||||
|
// outcome we want.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function outputName(originalName, blobType) {
|
||||||
|
const extension = originalName.split(".").pop();
|
||||||
|
const base = originalName.replace(new RegExp(`\\.${extension}$`, "i"), "");
|
||||||
|
|
||||||
|
let finalExtension = extension;
|
||||||
|
if (blobType === "image/jpeg") finalExtension = "jpg";
|
||||||
|
else if (blobType === "image/png") finalExtension = "png";
|
||||||
|
|
||||||
|
return `${base}-cleaned.${finalExtension}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {Promise<{url, filename, size, originalSize, sizeDelta,
|
||||||
|
* wasSecondPass, fallback}>}
|
||||||
|
* `fallback: true` means the image could not be re-encoded and the original
|
||||||
|
* bytes are being returned untouched. The caller must not claim success.
|
||||||
|
*/
|
||||||
|
export async function cleanImage(file) {
|
||||||
|
let objectUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { img, url } = await loadImage(file);
|
||||||
|
objectUrl = url;
|
||||||
|
|
||||||
|
const keepPng =
|
||||||
|
file.type === "image/png" && img.width * img.height < PNG_PIXEL_LIMIT;
|
||||||
|
|
||||||
|
let blob = await redraw(img, {
|
||||||
|
type: keepPng ? "image/png" : "image/jpeg",
|
||||||
|
quality: keepPng ? undefined : JPEG_QUALITY,
|
||||||
|
});
|
||||||
|
let wasSecondPass = false;
|
||||||
|
|
||||||
|
if (await stillHasMetadata(blob)) {
|
||||||
|
const second = await loadImage(blob);
|
||||||
|
try {
|
||||||
|
blob = await redraw(second.img, {
|
||||||
|
type: "image/jpeg",
|
||||||
|
quality: SECOND_PASS_QUALITY,
|
||||||
|
fillWhite: true,
|
||||||
|
});
|
||||||
|
wasSecondPass = true;
|
||||||
|
} finally {
|
||||||
|
URL.revokeObjectURL(second.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result(blob, file, { wasSecondPass, fallback: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cleaning failed, returning the original file:", error);
|
||||||
|
return result(file, file, { wasSecondPass: false, fallback: true });
|
||||||
|
} finally {
|
||||||
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function result(blob, originalFile, { wasSecondPass, fallback }) {
|
||||||
|
return {
|
||||||
|
url: URL.createObjectURL(blob),
|
||||||
|
filename: outputName(originalFile.name, blob.type),
|
||||||
|
size: blob.size,
|
||||||
|
originalSize: originalFile.size,
|
||||||
|
sizeDelta: originalFile.size
|
||||||
|
? ((blob.size - originalFile.size) / originalFile.size) * 100
|
||||||
|
: 0,
|
||||||
|
wasSecondPass,
|
||||||
|
fallback,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes) {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* FAQ content. Kept in one place because the same questions and answers are
|
||||||
|
* mirrored as FAQPage structured data in index.html — edit both together.
|
||||||
|
*/
|
||||||
|
export const faqs = [
|
||||||
|
{
|
||||||
|
q: "What are content credentials on an image?",
|
||||||
|
a: "Content credentials are a signed record of how an image was made and edited, written into the file by the C2PA standard. They can name the tool that generated the image, list every edit, and identify the account that created it. Platforms read that record to show an “AI info” or “Content credentials” label next to the picture.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "How do I remove content credentials from an image?",
|
||||||
|
a: "Add the image to Untrace, review what it found, then choose Clean image and download the result. Untrace decodes the picture to raw pixels and writes a new file, so the C2PA manifest, the JUMBF container holding it, and every other metadata block are simply not carried over.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Does this remove the AI label on LinkedIn?",
|
||||||
|
a: "LinkedIn shows its AI label when it reads C2PA content credentials in the file you upload. Cleaning the image before you upload removes the data the label is generated from. It does not change a label on a post that is already published — delete that post and upload the clean copy instead.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Does it remove the AI tag on Instagram or Facebook?",
|
||||||
|
a: "Meta reads the same C2PA signals plus its own classifiers. Removing the credentials removes the metadata signal, so the automatic “AI info” tag is far less likely to appear. Visual detection is separate and no metadata tool can affect it.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "What is C2PA and JUMBF?",
|
||||||
|
a: "C2PA is the Coalition for Content Provenance and Authenticity specification that defines the provenance record. JUMBF is the JPEG box format that record is stored in, usually inside an APP11 segment. Untrace scans for both and reports them before cleaning.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Is my image uploaded anywhere?",
|
||||||
|
a: "No. Reading the metadata and writing the clean file both happen in your browser using the File and Canvas APIs. Nothing is sent over the network, there is no account, and Untrace has no server to store an image on.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Does cleaning reduce image quality?",
|
||||||
|
a: "Small PNGs are rewritten as PNG with no quality loss. Everything else is re-encoded as JPEG at quality 0.92, which is visually indistinguishable at normal viewing sizes. The tool shows the size change so you can see exactly what happened.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Which formats are supported?",
|
||||||
|
a: "JPEG, PNG, TIFF, HEIC and AVIF. The cleaned file is returned as JPEG, or as PNG when the original was a small PNG.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Does it also remove EXIF and GPS data?",
|
||||||
|
a: "Yes. EXIF, GPS, XMP, IPTC, Photoshop blocks, maker notes and embedded thumbnails all go at the same time as the content credentials, because the whole metadata section is dropped rather than edited.",
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,671 @@
|
|||||||
|
/**
|
||||||
|
* Metadata extraction.
|
||||||
|
*
|
||||||
|
* Reads a file twice: once through exifr for structured segments, once as raw
|
||||||
|
* bytes to catch C2PA / JUMBF / AI provenance markers that exifr does not
|
||||||
|
* surface. The two results are merged and bucketed into named sections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const ACCEPTED_TYPES = [
|
||||||
|
"image/jpeg",
|
||||||
|
"image/jpg",
|
||||||
|
"image/png",
|
||||||
|
"image/tiff",
|
||||||
|
"image/heic",
|
||||||
|
"image/avif",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ACCEPTED_EXTENSIONS = ".jpg,.jpeg,.png,.tiff,.tif,.heic,.avif";
|
||||||
|
|
||||||
|
/** Sections that describe the result rather than holding metadata values. */
|
||||||
|
const META_KEYS = ["fileType", "hasMetadata", "message"];
|
||||||
|
|
||||||
|
const GPS_FIELDS = [
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
|
"GPSLatitude",
|
||||||
|
"GPSLongitude",
|
||||||
|
"GPSAltitude",
|
||||||
|
"GPSSpeed",
|
||||||
|
"GPSImgDirection",
|
||||||
|
"GPSDestBearing",
|
||||||
|
"GPSDateTime",
|
||||||
|
"GPSLatitudeRef",
|
||||||
|
"GPSLongitudeRef",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CAMERA_FIELDS = [
|
||||||
|
"Make",
|
||||||
|
"Model",
|
||||||
|
"Software",
|
||||||
|
"DateTime",
|
||||||
|
"DateTimeOriginal",
|
||||||
|
"DateTimeDigitized",
|
||||||
|
"ExposureTime",
|
||||||
|
"FNumber",
|
||||||
|
"ISO",
|
||||||
|
"Flash",
|
||||||
|
"FocalLength",
|
||||||
|
"WhiteBalance",
|
||||||
|
"LensModel",
|
||||||
|
"LensMake",
|
||||||
|
"Artist",
|
||||||
|
"Copyright",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TECHNICAL_FIELDS = [
|
||||||
|
"ColorSpace",
|
||||||
|
"Orientation",
|
||||||
|
"ResolutionUnit",
|
||||||
|
"XResolution",
|
||||||
|
"YResolution",
|
||||||
|
"ExifVersion",
|
||||||
|
"ComponentsConfiguration",
|
||||||
|
"ImageWidth",
|
||||||
|
"ImageHeight",
|
||||||
|
];
|
||||||
|
|
||||||
|
const C2PA_KEY_HINTS = [
|
||||||
|
"c2pa",
|
||||||
|
"cai",
|
||||||
|
"provenance",
|
||||||
|
"manifest",
|
||||||
|
"signature",
|
||||||
|
"claim",
|
||||||
|
"assertion",
|
||||||
|
"chatgpt",
|
||||||
|
"openai",
|
||||||
|
"ai_generated",
|
||||||
|
"dall",
|
||||||
|
"midjourney",
|
||||||
|
"stable",
|
||||||
|
"generated",
|
||||||
|
];
|
||||||
|
|
||||||
|
const C2PA_SIGNATURES = [
|
||||||
|
"c2pa.manifest",
|
||||||
|
"c2pa.signature",
|
||||||
|
"c2pa.assertions",
|
||||||
|
"contentauthenticity.org",
|
||||||
|
"adobe.com/cai",
|
||||||
|
"CASS",
|
||||||
|
"c2pa.created",
|
||||||
|
"c2pa.converted",
|
||||||
|
];
|
||||||
|
|
||||||
|
const AI_SIGNATURES = [
|
||||||
|
"ChatGPT",
|
||||||
|
"OpenAI API",
|
||||||
|
"DALL-E",
|
||||||
|
"dall-e-",
|
||||||
|
"Midjourney",
|
||||||
|
"Stable Diffusion",
|
||||||
|
"AI-generated",
|
||||||
|
"trainedAlgorithmicMedia",
|
||||||
|
"digitalSourceType",
|
||||||
|
"GPT-4",
|
||||||
|
"generated",
|
||||||
|
"synthesized",
|
||||||
|
];
|
||||||
|
|
||||||
|
const JUMBF_SIGNATURES = ["jumb", "JUMB", "jumd", "JUMD"];
|
||||||
|
|
||||||
|
const METADATA_MARKERS = [
|
||||||
|
"EXIF\x00",
|
||||||
|
"IPTC",
|
||||||
|
"XMP\x00",
|
||||||
|
"ICC_PROFILE",
|
||||||
|
"Photoshop 3.0",
|
||||||
|
"Adobe_CM",
|
||||||
|
"GPS\x00",
|
||||||
|
];
|
||||||
|
|
||||||
|
const AI_VALUE_KEYWORDS = [
|
||||||
|
"chatgpt",
|
||||||
|
"openai",
|
||||||
|
"dall-e",
|
||||||
|
"dalle",
|
||||||
|
"midjourney",
|
||||||
|
"stable diffusion",
|
||||||
|
"artificial intelligence",
|
||||||
|
"ai-generated",
|
||||||
|
"machine learning",
|
||||||
|
"neural network",
|
||||||
|
"synthesized",
|
||||||
|
"gpt-4",
|
||||||
|
"trainedalgorithmicmedia",
|
||||||
|
"adobe firefly",
|
||||||
|
"google imagen",
|
||||||
|
"anthropic claude",
|
||||||
|
"runway ml",
|
||||||
|
"artbreeder",
|
||||||
|
"deepdream",
|
||||||
|
"nightcafe",
|
||||||
|
"starryai",
|
||||||
|
"jasper art",
|
||||||
|
"canva ai",
|
||||||
|
"remove.bg",
|
||||||
|
"upscayl",
|
||||||
|
"real-esrgan",
|
||||||
|
"waifu2x",
|
||||||
|
"topaz gigapixel",
|
||||||
|
"c2pa",
|
||||||
|
"content credentials",
|
||||||
|
"provenance",
|
||||||
|
"content authenticity",
|
||||||
|
"jumbf",
|
||||||
|
"cai",
|
||||||
|
"project origin",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function isAccepted(file) {
|
||||||
|
return Boolean(file) && ACCEPTED_TYPES.includes(file.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(source, fields) {
|
||||||
|
const out = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
if (source[field] !== undefined) out[field] = source[field];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assign(target, section, values) {
|
||||||
|
if (Object.keys(values).length > 0) target[section] = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function extractMetadata(file) {
|
||||||
|
try {
|
||||||
|
const { parse } = await import("exifr");
|
||||||
|
|
||||||
|
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: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const rawMetadata = await extractRawMetadata(
|
||||||
|
new Uint8Array(buffer),
|
||||||
|
file.type
|
||||||
|
);
|
||||||
|
|
||||||
|
let allMetadata = { ...(exifData || {}), ...rawMetadata };
|
||||||
|
|
||||||
|
if (file.type.includes("png") && 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 {
|
||||||
|
/* PNG-specific parsing is best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(allMetadata).length === 0) {
|
||||||
|
return cleanResult(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
return categorise(allMetadata, rawMetadata);
|
||||||
|
} catch (error) {
|
||||||
|
return cleanResult(file, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanResult(file, error) {
|
||||||
|
const isPng = file.type.includes("png");
|
||||||
|
const isJpeg = file.type.includes("jpeg") || file.type.includes("jpg");
|
||||||
|
let message;
|
||||||
|
|
||||||
|
if (isPng) {
|
||||||
|
message = "PNG files rarely carry EXIF metadata.";
|
||||||
|
} else if (
|
||||||
|
error &&
|
||||||
|
(error.message?.includes("not supported") ||
|
||||||
|
error.message?.includes("invalid"))
|
||||||
|
) {
|
||||||
|
message = "No EXIF data found in this image.";
|
||||||
|
} else {
|
||||||
|
message = "No metadata found. This image is already clean.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileType: isPng ? "png" : isJpeg ? "jpeg" : "other",
|
||||||
|
hasMetadata: false,
|
||||||
|
message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function categorise(allMetadata, rawMetadata) {
|
||||||
|
const metadata = {};
|
||||||
|
const keys = Object.keys(allMetadata);
|
||||||
|
|
||||||
|
const c2paFields = keys.filter((key) =>
|
||||||
|
C2PA_KEY_HINTS.some((hint) => key.toLowerCase().includes(hint))
|
||||||
|
);
|
||||||
|
assign(metadata, "C2PA/AI Provenance", pick(allMetadata, c2paFields));
|
||||||
|
|
||||||
|
if (
|
||||||
|
rawMetadata.C2PA_FOUND ||
|
||||||
|
rawMetadata.AI_SIGNATURES ||
|
||||||
|
rawMetadata.JUMBF_BOXES
|
||||||
|
) {
|
||||||
|
const bucket = metadata["C2PA/AI Provenance"] || {};
|
||||||
|
for (const key of [
|
||||||
|
"TOTAL_SIGNATURES",
|
||||||
|
"C2PA_FOUND",
|
||||||
|
"AI_SIGNATURES",
|
||||||
|
"JUMBF_BOXES",
|
||||||
|
]) {
|
||||||
|
if (rawMetadata[key] && bucket[key] === undefined) {
|
||||||
|
bucket[key] = rawMetadata[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
metadata["C2PA/AI Provenance"] = bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
assign(metadata, "GPS", pick(allMetadata, GPS_FIELDS));
|
||||||
|
assign(metadata, "Camera", pick(allMetadata, CAMERA_FIELDS));
|
||||||
|
assign(metadata, "Technical", pick(allMetadata, TECHNICAL_FIELDS));
|
||||||
|
|
||||||
|
const xmpFields = keys.filter(
|
||||||
|
(key) =>
|
||||||
|
key.toLowerCase().includes("xmp") ||
|
||||||
|
key.includes("Creator") ||
|
||||||
|
key.includes("Subject")
|
||||||
|
);
|
||||||
|
assign(metadata, "XMP", pick(allMetadata, xmpFields));
|
||||||
|
|
||||||
|
const iptcFields = keys.filter(
|
||||||
|
(key) =>
|
||||||
|
key.toLowerCase().includes("iptc") ||
|
||||||
|
key.includes("Keywords") ||
|
||||||
|
key.includes("Caption")
|
||||||
|
);
|
||||||
|
assign(metadata, "IPTC", pick(allMetadata, iptcFields));
|
||||||
|
|
||||||
|
const pngFields = keys.filter((key) =>
|
||||||
|
[
|
||||||
|
"text",
|
||||||
|
"itxt",
|
||||||
|
"ztxt",
|
||||||
|
"time",
|
||||||
|
"phys",
|
||||||
|
"hist",
|
||||||
|
"chrm",
|
||||||
|
"gama",
|
||||||
|
"srgb",
|
||||||
|
"iccp",
|
||||||
|
].some((hint) => key.toLowerCase().includes(hint))
|
||||||
|
);
|
||||||
|
assign(metadata, "PNG Chunks", pick(allMetadata, pngFields));
|
||||||
|
|
||||||
|
const colorProfileFields = keys.filter(
|
||||||
|
(key) =>
|
||||||
|
key.includes("Profile") ||
|
||||||
|
key.includes("ColorSpace") ||
|
||||||
|
key.includes("WhitePoint") ||
|
||||||
|
key.includes("PrimaryChromaticities") ||
|
||||||
|
key.includes("ICC")
|
||||||
|
);
|
||||||
|
assign(metadata, "Color Profile", pick(allMetadata, colorProfileFields));
|
||||||
|
|
||||||
|
const otherFields = keys.filter(
|
||||||
|
(key) =>
|
||||||
|
!GPS_FIELDS.includes(key) &&
|
||||||
|
!CAMERA_FIELDS.includes(key) &&
|
||||||
|
!TECHNICAL_FIELDS.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"))
|
||||||
|
);
|
||||||
|
assign(metadata, "Other", pick(allMetadata, otherFields));
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scans the first megabyte of the file for known provenance signatures.
|
||||||
|
* exifr cannot see JUMBF boxes or bare C2PA strings, so this fills the gap.
|
||||||
|
*/
|
||||||
|
export async function extractRawMetadata(uint8Array, fileType) {
|
||||||
|
const rawMetadata = { TOTAL_SIGNATURES: 0, METADATA_MARKERS: 0 };
|
||||||
|
|
||||||
|
const textDecoder = new TextDecoder("utf-8", {
|
||||||
|
ignoreBOM: true,
|
||||||
|
fatal: false,
|
||||||
|
});
|
||||||
|
const chunkSize = 1024;
|
||||||
|
const limit = Math.min(uint8Array.length, 1024 * 1024);
|
||||||
|
let textContent = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < limit; i += chunkSize) {
|
||||||
|
const chunk = uint8Array.slice(i, Math.min(i + chunkSize, limit));
|
||||||
|
try {
|
||||||
|
textContent += textDecoder.decode(chunk, { stream: true });
|
||||||
|
} catch {
|
||||||
|
/* undecodable chunk — skip it */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerContent = textContent.toLowerCase();
|
||||||
|
|
||||||
|
const c2paFound = C2PA_SIGNATURES.filter(
|
||||||
|
(sig) =>
|
||||||
|
textContent.includes(sig) || lowerContent.includes(sig.toLowerCase())
|
||||||
|
);
|
||||||
|
if (c2paFound.length > 0) {
|
||||||
|
rawMetadata.C2PA_FOUND = c2paFound;
|
||||||
|
rawMetadata.TOTAL_SIGNATURES += c2paFound.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare substring match produces false positives on short signatures, so
|
||||||
|
// anything four characters or shorter has to sit on a real boundary.
|
||||||
|
const aiFound = AI_SIGNATURES.filter((sig) => {
|
||||||
|
const lower = sig.toLowerCase();
|
||||||
|
if (!lowerContent.includes(lower)) return false;
|
||||||
|
return (
|
||||||
|
sig.length > 4 ||
|
||||||
|
lowerContent.includes(lower + " ") ||
|
||||||
|
lowerContent.includes(" " + lower) ||
|
||||||
|
lowerContent.includes(lower + ":") ||
|
||||||
|
lowerContent.includes(lower + "=") ||
|
||||||
|
lowerContent.includes('"' + lower + '"')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
if (aiFound.length > 0) {
|
||||||
|
rawMetadata.AI_SIGNATURES = aiFound;
|
||||||
|
rawMetadata.TOTAL_SIGNATURES += aiFound.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jumbfFound = JUMBF_SIGNATURES.filter((sig) =>
|
||||||
|
lowerContent.includes(sig.toLowerCase())
|
||||||
|
);
|
||||||
|
if (jumbfFound.length > 0) {
|
||||||
|
rawMetadata.JUMBF_BOXES = jumbfFound;
|
||||||
|
rawMetadata.TOTAL_SIGNATURES += jumbfFound.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const marker of METADATA_MARKERS) {
|
||||||
|
if (textContent.includes(marker)) rawMetadata.METADATA_MARKERS++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType.includes("jpeg") || fileType.includes("jpg")) {
|
||||||
|
const segments = findC2paSegments(uint8Array, textContent);
|
||||||
|
if (segments.length > 0) {
|
||||||
|
rawMetadata.JUMBF_BOXES = [
|
||||||
|
...(rawMetadata.JUMBF_BOXES || []),
|
||||||
|
...segments,
|
||||||
|
];
|
||||||
|
rawMetadata.TOTAL_SIGNATURES++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** APP11 always signals JUMBF; APP2 only counts when C2PA strings sit inside it. */
|
||||||
|
function findC2paSegments(uint8Array, textContent) {
|
||||||
|
const found = [];
|
||||||
|
let foundApp2 = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < uint8Array.length - 10; i++) {
|
||||||
|
if (uint8Array[i] !== 0xff) continue;
|
||||||
|
|
||||||
|
if (uint8Array[i + 1] === 0xeb) {
|
||||||
|
found.push("APP11_segment");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uint8Array[i + 1] === 0xe2 && !foundApp2) {
|
||||||
|
const segment = textContent.slice(i, i + 100).toLowerCase();
|
||||||
|
if (
|
||||||
|
segment.includes("c2pa") ||
|
||||||
|
segment.includes("cai") ||
|
||||||
|
segment.includes("jumb")
|
||||||
|
) {
|
||||||
|
foundApp2 = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (foundApp2) found.push("APP2_segment");
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------
|
||||||
|
Presentation helpers
|
||||||
|
------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export function isClean(metadata) {
|
||||||
|
if (!metadata) return true;
|
||||||
|
if (metadata.message && !metadata.hasMetadata) return true;
|
||||||
|
return sections(metadata).length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sections(metadata) {
|
||||||
|
if (!metadata) return [];
|
||||||
|
return Object.entries(metadata).filter(
|
||||||
|
([name, values]) =>
|
||||||
|
!META_KEYS.includes(name) && typeof values === "object" && values !== null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countEntries(metadata) {
|
||||||
|
return sections(metadata).reduce(
|
||||||
|
(total, [, values]) => total + Object.keys(values).length,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAiCredentials(metadata) {
|
||||||
|
if (!metadata) return false;
|
||||||
|
|
||||||
|
const provenance = metadata["C2PA/AI Provenance"];
|
||||||
|
if (provenance) {
|
||||||
|
if (provenance.AI_SIGNATURES?.length > 0) return true;
|
||||||
|
if (provenance.C2PA_FOUND?.length > 0) return true;
|
||||||
|
if (provenance.TOTAL_SIGNATURES > 1) return true;
|
||||||
|
|
||||||
|
const realJumbf = (provenance.JUMBF_BOXES || []).filter(
|
||||||
|
(box) => !box.includes("APP2_segment")
|
||||||
|
);
|
||||||
|
if (realJumbf.length > 0) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections(metadata).some(
|
||||||
|
([name, values]) =>
|
||||||
|
name !== "Raw Analysis" &&
|
||||||
|
name !== "C2PA/AI Provenance" &&
|
||||||
|
Object.values(values).some((value) => {
|
||||||
|
const text = String(value).toLowerCase();
|
||||||
|
return AI_VALUE_KEYWORDS.some((keyword) => text.includes(keyword));
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the raw sections into the findings list the review step renders.
|
||||||
|
* `severity` drives the status dot; every finding also carries text, so
|
||||||
|
* colour is never the only signal.
|
||||||
|
*/
|
||||||
|
export function findings(metadata) {
|
||||||
|
if (!metadata) return [];
|
||||||
|
|
||||||
|
const list = [];
|
||||||
|
const push = (severity, label, detail) =>
|
||||||
|
list.push({ severity, label, detail });
|
||||||
|
|
||||||
|
if (hasAiCredentials(metadata)) {
|
||||||
|
const provenance = metadata["C2PA/AI Provenance"] || {};
|
||||||
|
const tools = provenance.AI_SIGNATURES || [];
|
||||||
|
push(
|
||||||
|
"err",
|
||||||
|
"AI content credentials",
|
||||||
|
tools.length > 0
|
||||||
|
? tools.join(", ")
|
||||||
|
: "C2PA provenance markers detected in the file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.GPS) {
|
||||||
|
push(
|
||||||
|
"err",
|
||||||
|
"GPS location",
|
||||||
|
`${Object.keys(metadata.GPS).length} coordinate fields`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.Camera) {
|
||||||
|
push(
|
||||||
|
"warn",
|
||||||
|
"Camera and device",
|
||||||
|
cameraName(metadata.Camera) || "Make, model and capture settings"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.Camera?.DateTimeOriginal || metadata.Camera?.DateTime) {
|
||||||
|
push("warn", "Capture timestamp", "When the photo was taken");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.XMP) {
|
||||||
|
push("warn", "XMP", `${Object.keys(metadata.XMP).length} fields`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.IPTC) {
|
||||||
|
push("warn", "IPTC", `${Object.keys(metadata.IPTC).length} fields`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata["PNG Chunks"]) {
|
||||||
|
push(
|
||||||
|
"warn",
|
||||||
|
"PNG text chunks",
|
||||||
|
`${Object.keys(metadata["PNG Chunks"]).length} chunks`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.Other && Object.keys(metadata.Other).length > 0) {
|
||||||
|
push("warn", "Comments and thumbnails", `${Object.keys(metadata.Other).length} fields`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata.Technical) {
|
||||||
|
push("info", "Technical", "Dimensions, orientation and colour space");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (metadata["Color Profile"]) {
|
||||||
|
push("info", "Colour profile", "ICC data — not privacy sensitive");
|
||||||
|
}
|
||||||
|
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Most cameras repeat the make inside the model ("Canon" + "Canon EOS R5"). */
|
||||||
|
function cameraName({ Make, Model }) {
|
||||||
|
const make = String(Make || "").trim();
|
||||||
|
const model = String(Model || "").trim();
|
||||||
|
if (make && model) {
|
||||||
|
return model.toLowerCase().startsWith(make.toLowerCase())
|
||||||
|
? model
|
||||||
|
: `${make} ${model}`;
|
||||||
|
}
|
||||||
|
return model || make;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain-text dump for the expandable raw view. */
|
||||||
|
export function formatMetadata(metadata) {
|
||||||
|
if (!metadata || Object.keys(metadata).length === 0) return "No metadata found";
|
||||||
|
if (metadata.message && !metadata.hasMetadata) return metadata.message;
|
||||||
|
|
||||||
|
return sections(metadata)
|
||||||
|
.map(([name, values]) => {
|
||||||
|
const lines = Object.entries(values).map(([key, value]) => {
|
||||||
|
let display;
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
display = `[${value.join(", ")}]`;
|
||||||
|
} else if (typeof value === "string" && value.length > 100) {
|
||||||
|
display = value.slice(0, 100) + "…";
|
||||||
|
} else {
|
||||||
|
display = value;
|
||||||
|
}
|
||||||
|
return ` ${key}: ${display}`;
|
||||||
|
});
|
||||||
|
return `[${name}]\n${lines.join("\n")}`;
|
||||||
|
})
|
||||||
|
.join("\n\n");
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "untrace-theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The design system is dark by default and opts into light with
|
||||||
|
* data-theme="light" on <html>. A saved choice always wins; otherwise the
|
||||||
|
* operating system decides, falling back to dark.
|
||||||
|
*/
|
||||||
|
function preferredTheme() {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (saved === "light" || saved === "dark") return saved;
|
||||||
|
} catch {
|
||||||
|
/* storage can be blocked — fall through to the media query */
|
||||||
|
}
|
||||||
|
return window.matchMedia("(prefers-color-scheme: light)").matches
|
||||||
|
? "light"
|
||||||
|
: "dark";
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(theme) {
|
||||||
|
if (theme === "light") {
|
||||||
|
document.documentElement.setAttribute("data-theme", "light");
|
||||||
|
} else {
|
||||||
|
document.documentElement.removeAttribute("data-theme");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTheme() {
|
||||||
|
const { subscribe, set } = writable("dark");
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscribe,
|
||||||
|
init() {
|
||||||
|
const theme = preferredTheme();
|
||||||
|
apply(theme);
|
||||||
|
set(theme);
|
||||||
|
},
|
||||||
|
toggle() {
|
||||||
|
const next =
|
||||||
|
document.documentElement.getAttribute("data-theme") === "light"
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
apply(next);
|
||||||
|
set(next);
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, next);
|
||||||
|
} catch {
|
||||||
|
/* a blocked store just means the choice is not remembered */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const theme = createTheme();
|
||||||
|
|
||||||
|
/** Duration helper so Svelte transitions honour prefers-reduced-motion. */
|
||||||
|
export function motionSafe(ms) {
|
||||||
|
if (typeof window === "undefined") return ms;
|
||||||
|
return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms;
|
||||||
|
}
|
||||||
@@ -0,0 +1,404 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
RealBrain Design System v2.0 — copy-paste source of truth
|
||||||
|
==========================================================================
|
||||||
|
Extracted and curated from "RealBrain Design System v2 (standalone).html"
|
||||||
|
(v2.0 · July 2026). See ../design-system/README.md for the full guide —
|
||||||
|
token tables, component states, patterns, usage rules, accessibility notes.
|
||||||
|
|
||||||
|
RULE: components reference tokens only. No raw hex/px in component CSS.
|
||||||
|
If a value you need doesn't exist below, stop and flag it (⚑ NEW TOKEN)
|
||||||
|
instead of inventing one — see README.md "Governance".
|
||||||
|
|
||||||
|
This file is trimmed of the original artifact's own style-guide chrome
|
||||||
|
(swatch grids, type ramps, spacing rulers, motion-demo widgets, the
|
||||||
|
guide's topbar/cover, and the demo-frame wrappers used only to present
|
||||||
|
this document). What remains is everything meant to ship in a product:
|
||||||
|
tokens, base reset, brand chrome, components, and composed patterns.
|
||||||
|
|
||||||
|
PUBLISHED at https://realbrain.cc/design-system/tokens.css (canonical URL).
|
||||||
|
The style guide is at https://realbrain.cc/design-system/ and the agent
|
||||||
|
index at https://realbrain.cc/design-system/llms.txt. Link this file or
|
||||||
|
vendor it; the version line above is the only thing to compare when you
|
||||||
|
update a vendored copy.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Fonts — IBM Plex Sans (UI) + IBM Plex Mono (code/data). Weights 400/500/600
|
||||||
|
ONLY — never 700, anywhere (see README.md "Content & usage rules").
|
||||||
|
Google Fonts CDN; font-family fallback chain below degrades to
|
||||||
|
system-ui/ui-monospace if the CDN is unreachable. */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
|
||||||
|
|
||||||
|
/* ---------- 1. Tokens ---------- */
|
||||||
|
:root {
|
||||||
|
/* color · dark (default) */
|
||||||
|
--bg: #080808;
|
||||||
|
--surface: #121216;
|
||||||
|
--elevated: #1A1A20;
|
||||||
|
--text-1: #F4F4F6;
|
||||||
|
--text-2: #A9A9B4;
|
||||||
|
--text-3: #83838F;
|
||||||
|
--accent: #8FA8FF;
|
||||||
|
--accent-fg: #080808; /* dark text ON accent — never white on periwinkle */
|
||||||
|
--accent-hover: color-mix(in oklab, var(--accent), white 14%);
|
||||||
|
--accent-pressed: color-mix(in oklab, var(--accent), black 24%);
|
||||||
|
--success: #3ECF8E;
|
||||||
|
--warning: #E8B23D;
|
||||||
|
--error: #F26D6D;
|
||||||
|
--info: #62B0F5;
|
||||||
|
--border: #26262E;
|
||||||
|
--border-strong: #3A3A46;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(0,0,0,.45);
|
||||||
|
--shadow-md: 0 4px 14px rgba(0,0,0,.5);
|
||||||
|
--shadow-lg: 0 16px 40px rgba(0,0,0,.55);
|
||||||
|
|
||||||
|
/* typography */
|
||||||
|
--font-ui: 'IBM Plex Sans', system-ui, sans-serif;
|
||||||
|
--font-mono: 'IBM Plex Mono', ui-monospace, monospace;
|
||||||
|
--fs-1: 12px; --lh-1: 16px;
|
||||||
|
--fs-2: 13px; --lh-2: 20px;
|
||||||
|
--fs-3: 15px; --lh-3: 24px;
|
||||||
|
--fs-4: 17px; --lh-4: 26px;
|
||||||
|
--fs-5: 20px; --lh-5: 28px;
|
||||||
|
--fs-6: 24px; --lh-6: 32px;
|
||||||
|
--fs-7: 30px; --lh-7: 38px;
|
||||||
|
--fs-8: 40px; --lh-8: 48px;
|
||||||
|
--fs-display: 60px; --lh-display: 64px;
|
||||||
|
|
||||||
|
/* spacing */
|
||||||
|
--space-1: 4px; --space-2: 8px; --space-3: 12px;
|
||||||
|
--space-4: 16px; --space-5: 24px; --space-6: 32px;
|
||||||
|
--space-7: 48px; --space-8: 64px; --space-9: 96px;
|
||||||
|
|
||||||
|
/* radius */
|
||||||
|
--radius-sm: 6px; --radius-md: 10px; --radius-lg: 16px; --radius-full: 999px;
|
||||||
|
|
||||||
|
/* motion */
|
||||||
|
--dur-1: 120ms; --dur-2: 200ms; --dur-3: 360ms;
|
||||||
|
--ease: cubic-bezier(.22,.61,.36,1);
|
||||||
|
|
||||||
|
/* layout */
|
||||||
|
--max-marketing: 1200px;
|
||||||
|
--max-app: 1440px;
|
||||||
|
|
||||||
|
/* density (comfortable is default) */
|
||||||
|
--control-h: 44px;
|
||||||
|
--control-pad-x: var(--space-4);
|
||||||
|
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="light"] {
|
||||||
|
--bg: #F7F7F9;
|
||||||
|
--surface: #FFFFFF;
|
||||||
|
--elevated: #FFFFFF;
|
||||||
|
--text-1: #16161C;
|
||||||
|
--text-2: #4C4C58;
|
||||||
|
--text-3: #6E6E7A;
|
||||||
|
--accent: #3450C8;
|
||||||
|
--accent-fg: #FFFFFF;
|
||||||
|
--success: #177A4E;
|
||||||
|
--warning: #8A5A00;
|
||||||
|
--error: #C2373C;
|
||||||
|
--info: #1D62A8;
|
||||||
|
--border: #E3E3E9;
|
||||||
|
--border-strong: #C6C6D0;
|
||||||
|
--shadow-sm: 0 1px 2px rgba(20,20,30,.08);
|
||||||
|
--shadow-md: 0 4px 14px rgba(20,20,30,.10);
|
||||||
|
--shadow-lg: 0 16px 40px rgba(20,20,30,.14);
|
||||||
|
color-scheme: light;
|
||||||
|
/* --accent-hover / --accent-pressed are NOT redefined here — they're
|
||||||
|
color-mix() formulas relative to --accent, so they recompute
|
||||||
|
automatically for whichever theme is active. */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* density modes — never mixed within a product */
|
||||||
|
.density-comfortable { --control-h: 44px; --control-pad-x: var(--space-4); }
|
||||||
|
.density-compact { --control-h: 32px; --control-pad-x: var(--space-3); }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:root { --dur-1: 0ms; --dur-2: 0ms; --dur-3: 0ms; }
|
||||||
|
*, *::before, *::after { animation-duration: 0ms !important; transition-duration: 0ms !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 2. Base ---------- */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
html { scroll-behavior: smooth; }
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text-1);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: var(--fs-3);
|
||||||
|
line-height: var(--lh-3);
|
||||||
|
font-weight: 400;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
::selection { background: var(--accent); color: var(--accent-fg); }
|
||||||
|
a { color: var(--accent); text-decoration: none; }
|
||||||
|
a:hover { text-decoration: underline; }
|
||||||
|
code, .mono { font-family: var(--font-mono); }
|
||||||
|
|
||||||
|
/* ---------- 3. Brand chrome ---------- */
|
||||||
|
/* "R" mark — small (topbars, inline) and large (hero/cover) variants */
|
||||||
|
.r-mark {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; flex: none;
|
||||||
|
background: var(--accent); color: var(--accent-fg);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-mono); font-weight: 600; font-size: var(--fs-3);
|
||||||
|
}
|
||||||
|
.r-mark-lg {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 96px; height: 96px;
|
||||||
|
background: var(--accent); color: var(--accent-fg);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
font-family: var(--font-mono); font-weight: 600; font-size: var(--fs-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-toggle {
|
||||||
|
display: inline-flex; align-items: center; gap: var(--space-2);
|
||||||
|
height: 32px; padding: 0 var(--space-3);
|
||||||
|
background: var(--surface); color: var(--text-1);
|
||||||
|
border: 1px solid var(--border); border-radius: var(--radius-full);
|
||||||
|
font-family: var(--font-ui); font-size: var(--fs-2); font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--dur-1) var(--ease), background var(--dur-1) var(--ease);
|
||||||
|
}
|
||||||
|
.theme-toggle:hover { border-color: var(--border-strong); }
|
||||||
|
.theme-toggle .dot { width: 8px; height: 8px; border-radius: var(--radius-full); background: var(--accent); }
|
||||||
|
|
||||||
|
/* ---------- 4. Components ---------- */
|
||||||
|
|
||||||
|
/* Button */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: var(--space-2);
|
||||||
|
height: var(--control-h); padding: 0 var(--control-pad-x);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500;
|
||||||
|
border: 1px solid transparent; cursor: pointer; white-space: nowrap;
|
||||||
|
transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease), color var(--dur-1) var(--ease);
|
||||||
|
}
|
||||||
|
.btn:disabled, .btn[aria-disabled="true"] { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
.btn-primary { background: var(--accent); color: var(--accent-fg); }
|
||||||
|
.btn-primary:hover:not(:disabled), .btn-primary.is-hover { background: var(--accent-hover); }
|
||||||
|
.btn-primary:active:not(:disabled), .btn-primary.is-active { background: var(--accent-pressed); }
|
||||||
|
|
||||||
|
.btn-secondary { background: var(--surface); color: var(--text-1); border-color: var(--border-strong); }
|
||||||
|
.btn-secondary:hover:not(:disabled), .btn-secondary.is-hover { background: var(--elevated); border-color: var(--text-3); }
|
||||||
|
.btn-secondary:active:not(:disabled), .btn-secondary.is-active { background: var(--bg); }
|
||||||
|
|
||||||
|
.btn-ghost { background: transparent; color: var(--text-2); }
|
||||||
|
.btn-ghost:hover:not(:disabled), .btn-ghost.is-hover { background: var(--surface); color: var(--text-1); }
|
||||||
|
.btn-ghost:active:not(:disabled), .btn-ghost.is-active { background: var(--elevated); }
|
||||||
|
|
||||||
|
.btn-destructive { background: transparent; color: var(--error); border-color: var(--error); }
|
||||||
|
.btn-destructive:hover:not(:disabled), .btn-destructive.is-hover { background: color-mix(in oklab, var(--error), transparent 88%); }
|
||||||
|
.btn-destructive:active:not(:disabled), .btn-destructive.is-active { background: color-mix(in oklab, var(--error), transparent 78%); }
|
||||||
|
|
||||||
|
.is-focus { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 14px; height: 14px; flex: none;
|
||||||
|
border: 2px solid color-mix(in oklab, currentColor, transparent 70%);
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
animation: spin 800ms linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Field / Input / Select */
|
||||||
|
.field { display: grid; gap: var(--space-2); max-width: 320px; }
|
||||||
|
.field label { font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500; }
|
||||||
|
.field .help { font-size: var(--fs-1); line-height: var(--lh-1); color: var(--text-3); }
|
||||||
|
.field .error-msg { font-size: var(--fs-1); line-height: var(--lh-1); color: var(--error); display: flex; gap: var(--space-1); align-items: center; }
|
||||||
|
.input, .select {
|
||||||
|
height: var(--control-h); padding: 0 var(--space-3);
|
||||||
|
background: var(--surface); color: var(--text-1);
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-ui); font-size: var(--fs-2); width: 100%;
|
||||||
|
transition: border-color var(--dur-1) var(--ease);
|
||||||
|
}
|
||||||
|
.input::placeholder { color: var(--text-3); }
|
||||||
|
.input:hover:not(:disabled), .select:hover:not(:disabled) { border-color: var(--text-3); }
|
||||||
|
.input:disabled, .select:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.input.is-error { border-color: var(--error); }
|
||||||
|
.select {
|
||||||
|
appearance: none;
|
||||||
|
background-image: linear-gradient(45deg, transparent 50%, var(--text-3) 50%), linear-gradient(135deg, var(--text-3) 50%, transparent 50%);
|
||||||
|
background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%;
|
||||||
|
background-size: 5px 5px; background-repeat: no-repeat;
|
||||||
|
padding-right: var(--space-6); cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox / Radio */
|
||||||
|
.check-row { display: flex; align-items: center; gap: var(--space-2); font-size: var(--fs-2); line-height: var(--lh-2); min-height: 24px; }
|
||||||
|
.checkbox, .radio {
|
||||||
|
appearance: none; width: 18px; height: 18px; flex: none; margin: 0;
|
||||||
|
border: 1px solid var(--border-strong); background: var(--surface); cursor: pointer;
|
||||||
|
display: inline-grid; place-content: center;
|
||||||
|
transition: background var(--dur-1) var(--ease), border-color var(--dur-1) var(--ease);
|
||||||
|
}
|
||||||
|
.checkbox { border-radius: var(--radius-sm); }
|
||||||
|
.radio { border-radius: var(--radius-full); }
|
||||||
|
.checkbox:checked, .radio:checked { background: var(--accent); border-color: var(--accent); }
|
||||||
|
.checkbox:checked::before { content: ""; width: 10px; height: 10px; background: var(--accent-fg); clip-path: polygon(14% 52%, 0 66%, 38% 100%, 100% 20%, 86% 8%, 38% 71%); }
|
||||||
|
.radio:checked::before { content: ""; width: 8px; height: 8px; border-radius: var(--radius-full); background: var(--accent-fg); }
|
||||||
|
.checkbox:disabled, .radio:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Toggle */
|
||||||
|
.toggle { position: relative; display: inline-flex; align-items: center; cursor: pointer; }
|
||||||
|
.toggle input { position: absolute; opacity: 0; width: 40px; height: 24px; margin: 0; cursor: pointer; }
|
||||||
|
.toggle .track {
|
||||||
|
width: 40px; height: 24px; border-radius: var(--radius-full);
|
||||||
|
background: var(--border-strong);
|
||||||
|
transition: background var(--dur-2) var(--ease);
|
||||||
|
display: flex; align-items: center; padding: 3px;
|
||||||
|
}
|
||||||
|
.toggle .knob { width: 18px; height: 18px; border-radius: var(--radius-full); background: var(--text-1); transition: transform var(--dur-2) var(--ease); }
|
||||||
|
.toggle input:checked + .track { background: var(--accent); }
|
||||||
|
.toggle input:checked + .track .knob { transform: translateX(16px); background: var(--accent-fg); }
|
||||||
|
.toggle input:focus-visible + .track { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
|
.toggle input:disabled + .track { opacity: .45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-5); }
|
||||||
|
.card.elevated { background: var(--elevated); box-shadow: var(--shadow-md); }
|
||||||
|
.card h4 { font-size: var(--fs-3); line-height: var(--lh-3); font-weight: 600; margin-bottom: var(--space-1); }
|
||||||
|
.card p { font-size: var(--fs-2); line-height: var(--lh-2); color: var(--text-2); }
|
||||||
|
|
||||||
|
/* Modal — native <dialog>: focus trap + Esc for free */
|
||||||
|
dialog.modal {
|
||||||
|
background: var(--elevated); color: var(--text-1);
|
||||||
|
border: 1px solid var(--border-strong); border-radius: var(--radius-lg);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
padding: var(--space-6); max-width: 440px; width: calc(100% - 32px);
|
||||||
|
}
|
||||||
|
dialog.modal::backdrop { background: rgba(0,0,0,.6); }
|
||||||
|
dialog.modal h4 { font-size: var(--fs-5); line-height: var(--lh-5); font-weight: 600; margin-bottom: var(--space-2); }
|
||||||
|
dialog.modal p { color: var(--text-2); font-size: var(--fs-2); line-height: var(--lh-2); margin-bottom: var(--space-5); }
|
||||||
|
dialog.modal .actions { display: flex; gap: var(--space-3); justify-content: flex-end; }
|
||||||
|
|
||||||
|
/* Toast */
|
||||||
|
.toast-region { position: fixed; bottom: var(--space-5); right: var(--space-5); display: grid; gap: var(--space-2); z-index: 100; }
|
||||||
|
.toast {
|
||||||
|
display: flex; align-items: center; gap: var(--space-3);
|
||||||
|
background: var(--elevated); color: var(--text-1);
|
||||||
|
border: 1px solid var(--border-strong); border-left: 3px solid var(--success);
|
||||||
|
border-radius: var(--radius-md); box-shadow: var(--shadow-md);
|
||||||
|
padding: var(--space-3) var(--space-4); font-size: var(--fs-2); line-height: var(--lh-2);
|
||||||
|
animation: toast-in var(--dur-3) var(--ease);
|
||||||
|
}
|
||||||
|
.toast .icon { color: var(--success); font-weight: 600; }
|
||||||
|
.toast button { background: none; border: none; color: var(--text-3); cursor: pointer; font-size: var(--fs-3); padding: var(--space-1); line-height: 1; border-radius: var(--radius-sm); }
|
||||||
|
.toast button:hover { color: var(--text-1); }
|
||||||
|
@keyframes toast-in { from { transform: translateY(8px); opacity: 0; } to { transform: none; opacity: 1; } }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.tabs [role="tablist"] { display: flex; gap: var(--space-1); border-bottom: 1px solid var(--border); }
|
||||||
|
.tabs [role="tab"] {
|
||||||
|
background: none; border: none; cursor: pointer;
|
||||||
|
padding: var(--space-2) var(--space-3); margin-bottom: -1px;
|
||||||
|
font-family: var(--font-ui); font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500;
|
||||||
|
color: var(--text-2); border-bottom: 2px solid transparent;
|
||||||
|
transition: color var(--dur-1) var(--ease);
|
||||||
|
}
|
||||||
|
.tabs [role="tab"]:hover { color: var(--text-1); }
|
||||||
|
.tabs [role="tab"][aria-selected="true"] { color: var(--text-1); border-bottom-color: var(--accent); }
|
||||||
|
.tabs [role="tabpanel"] { padding: var(--space-4) 0; font-size: var(--fs-2); line-height: var(--lh-2); color: var(--text-2); }
|
||||||
|
|
||||||
|
/* Table — border dividers only, no zebra striping */
|
||||||
|
.table { width: 100%; border-collapse: collapse; font-size: var(--fs-2); line-height: var(--lh-2); background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; }
|
||||||
|
.table-wrap { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: auto; background: var(--surface); }
|
||||||
|
.table-wrap .table { border: none; }
|
||||||
|
.table th { text-align: left; font-weight: 600; color: var(--text-2); font-size: var(--fs-1); line-height: var(--lh-1); padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border-strong); }
|
||||||
|
.table td { padding: var(--space-3) var(--space-4); border-bottom: 1px solid var(--border); }
|
||||||
|
.table tr:last-child td { border-bottom: none; }
|
||||||
|
.density-compact .table th, .density-compact .table td { padding: var(--space-2) var(--space-3); }
|
||||||
|
.table .num { font-family: var(--font-mono); text-align: right; }
|
||||||
|
|
||||||
|
/* Status — semantic color always paired with icon/dot + text */
|
||||||
|
.status { display: inline-flex; align-items: center; gap: var(--space-2); font-size: var(--fs-1); line-height: var(--lh-1); font-weight: 500; }
|
||||||
|
.status::before { content: ""; width: 8px; height: 8px; border-radius: var(--radius-full); background: currentColor; flex: none; }
|
||||||
|
.status.ok { color: var(--success); }
|
||||||
|
.status.warn { color: var(--warning); }
|
||||||
|
.status.err { color: var(--error); }
|
||||||
|
.status.info { color: var(--info); }
|
||||||
|
|
||||||
|
/* App scaffold — top bar + side nav */
|
||||||
|
.scaffold { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; background: var(--bg); }
|
||||||
|
.scaffold .topbar { display: flex; align-items: center; gap: var(--space-3); height: 48px; padding: 0 var(--space-4); background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||||
|
.scaffold .topbar .title { font-weight: 600; font-size: var(--fs-2); }
|
||||||
|
.scaffold .body { display: grid; grid-template-columns: 200px 1fr; min-height: 240px; }
|
||||||
|
.scaffold .sidenav { background: var(--surface); border-right: 1px solid var(--border); padding: var(--space-3); display: grid; gap: 2px; align-content: start; }
|
||||||
|
.scaffold .sidenav a {
|
||||||
|
display: flex; align-items: center; gap: var(--space-2);
|
||||||
|
padding: var(--space-2) var(--space-3); border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-2); font-size: var(--fs-2); line-height: var(--lh-2); font-weight: 500;
|
||||||
|
}
|
||||||
|
.scaffold .sidenav a:hover { background: var(--elevated); color: var(--text-1); text-decoration: none; }
|
||||||
|
.scaffold .sidenav a[aria-current="page"] { background: var(--elevated); color: var(--text-1); box-shadow: inset 2px 0 0 var(--accent); }
|
||||||
|
.scaffold .main { padding: var(--space-5); }
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.empty { display: grid; justify-items: center; text-align: center; gap: var(--space-2); padding: var(--space-8) var(--space-5); border: 1px dashed var(--border-strong); border-radius: var(--radius-md); }
|
||||||
|
.empty .glyph { width: 40px; height: 40px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); display: grid; place-content: center; color: var(--text-3); font-family: var(--font-mono); margin-bottom: var(--space-2); }
|
||||||
|
.empty h4 { font-size: var(--fs-3); font-weight: 600; }
|
||||||
|
.empty p { font-size: var(--fs-2); line-height: var(--lh-2); color: var(--text-3); max-width: 300px; }
|
||||||
|
|
||||||
|
/* Skeleton */
|
||||||
|
.skeleton { position: relative; overflow: hidden; background: var(--elevated); border-radius: var(--radius-sm); }
|
||||||
|
.skeleton::after {
|
||||||
|
content: ""; position: absolute; inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, color-mix(in oklab, var(--text-3), transparent 82%), transparent);
|
||||||
|
animation: shimmer 1.4s var(--ease) infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
|
||||||
|
|
||||||
|
/* ---------- 5. Patterns (composed screens) ---------- */
|
||||||
|
|
||||||
|
/* Marketing hero */
|
||||||
|
.hero { background: var(--bg); padding: var(--space-9) var(--space-6); text-align: center; }
|
||||||
|
.hero .eyebrow { font-family: var(--font-mono); font-size: var(--fs-1); line-height: var(--lh-1); color: var(--accent); letter-spacing: .1em; margin-bottom: var(--space-4); }
|
||||||
|
.hero h1 { font-size: var(--fs-8); line-height: var(--lh-8); font-weight: 600; letter-spacing: -0.01em; max-width: 680px; margin: 0 auto var(--space-4); }
|
||||||
|
.hero p { color: var(--text-2); font-size: var(--fs-4); line-height: var(--lh-4); max-width: 520px; margin: 0 auto var(--space-6); }
|
||||||
|
.hero .cta-row { display: flex; gap: var(--space-3); justify-content: center; }
|
||||||
|
|
||||||
|
/* BG Remover — editor shell (maps to bgremover.realbrain.cc) */
|
||||||
|
.bgr { background: var(--bg); display: grid; grid-template-rows: auto 1fr; min-height: 380px; }
|
||||||
|
.bgr-top { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) var(--space-4); height: 48px; background: var(--surface); border-bottom: 1px solid var(--border); }
|
||||||
|
.bgr-body { display: grid; grid-template-columns: 1fr 240px; }
|
||||||
|
.bgr-canvas { display: grid; place-content: center; padding: var(--space-6); }
|
||||||
|
.checkerboard {
|
||||||
|
width: 300px; height: 200px; border-radius: var(--radius-md); border: 1px solid var(--border-strong);
|
||||||
|
background: repeating-conic-gradient(var(--elevated) 0% 25%, var(--surface) 0% 50%) 0 0 / 24px 24px;
|
||||||
|
display: grid; place-content: center; position: relative;
|
||||||
|
}
|
||||||
|
.bgr-subject { width: 96px; height: 140px; background: var(--text-3); border-radius: 48px 48px 8px 8px; opacity: .9; }
|
||||||
|
.bgr-side { background: var(--surface); border-left: 1px solid var(--border); padding: var(--space-4); display: grid; gap: var(--space-4); align-content: start; }
|
||||||
|
|
||||||
|
/* Compiler — dev-tool shell (compact, mono) */
|
||||||
|
.compiler { background: var(--bg); font-family: var(--font-mono); min-height: 360px; display: grid; grid-template-rows: auto 1fr auto; }
|
||||||
|
.compiler-top { display: flex; align-items: center; gap: var(--space-3); padding: 0 var(--space-3); height: 40px; border-bottom: 1px solid var(--border); font-size: var(--fs-1); color: var(--text-3); }
|
||||||
|
.compiler-panes { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }
|
||||||
|
.compiler-pane { background: var(--surface); padding: var(--space-4); font-size: var(--fs-2); line-height: var(--lh-2); overflow: hidden; }
|
||||||
|
.compiler-pane .pane-head { font-size: var(--fs-1); color: var(--text-3); margin-bottom: var(--space-3); display: flex; justify-content: space-between; }
|
||||||
|
.compiler-status { display: flex; align-items: center; gap: var(--space-4); height: 32px; padding: 0 var(--space-3); border-top: 1px solid var(--border); font-size: var(--fs-1); color: var(--text-3); background: var(--surface); }
|
||||||
|
.code-line { white-space: pre; color: var(--text-2); }
|
||||||
|
.code-line .kw { color: var(--accent); }
|
||||||
|
.code-line .str { color: var(--success); }
|
||||||
|
.code-line .cm { color: var(--text-3); }
|
||||||
|
|
||||||
|
/* Security dashboard — semantic color always paired with icon + text */
|
||||||
|
.secdash { background: var(--bg); min-height: 360px; display: grid; grid-template-columns: 180px 1fr; }
|
||||||
|
.secdash-nav { background: var(--surface); border-right: 1px solid var(--border); padding: var(--space-3); display: grid; gap: 2px; align-content: start; }
|
||||||
|
.secdash-main { padding: var(--space-5); display: grid; gap: var(--space-4); align-content: start; }
|
||||||
|
.stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); }
|
||||||
|
.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-4); }
|
||||||
|
.stat-card .k { font-size: var(--fs-1); line-height: var(--lh-1); color: var(--text-3); margin-bottom: var(--space-1); }
|
||||||
|
.stat-card .v { font-family: var(--font-mono); font-size: var(--fs-6); line-height: var(--lh-6); font-weight: 600; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.scaffold .body, .bgr-body, .secdash { grid-template-columns: 1fr; }
|
||||||
|
.compiler-panes { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [tailwindcss(),svelte()],
|
plugins: [svelte()],
|
||||||
base: '/',
|
base: '/',
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
assetsDir: 'assets'
|
assetsDir: 'assets',
|
||||||
}
|
cssMinify: 'lightningcss',
|
||||||
|
},
|
||||||
})
|
})
|
||||||