├── .gitignore ├── .node-version ├── .nvmrc ├── Dockerfile ├── README.md ├── components.json ├── docker-compose.yml ├── index.html ├── manifest.config.ts ├── manifest.json ├── options.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── vite.svg ├── src ├── assets │ ├── favicon.svg │ └── logo.svg ├── background.ts ├── components │ ├── core │ │ ├── replaceImage.ts │ │ └── segmentImage.ts │ ├── forms │ │ ├── field.tsx │ │ ├── input.tsx │ │ └── label.tsx │ └── ui │ │ ├── button.tsx │ │ ├── select.tsx │ │ └── slider.tsx ├── content_scripts │ └── content_script.tsx ├── globals.css ├── heuristics │ └── urlMightBeInvalid.ts ├── hooks │ ├── useImageDimension.ts │ └── useSettings.ts ├── options.tsx ├── popup.tsx ├── providers │ ├── huggingface.ts │ └── replicate.ts ├── react-circular-progress-bar.d.ts ├── types.ts ├── utils │ ├── cn.ts │ ├── convertBase64.ts │ ├── downloadImageToBase64.ts │ ├── elementIsVisible.ts │ ├── elementIsVisibleLegacy.ts │ ├── fileToBase64.ts │ ├── generateSeed.ts │ ├── getDefaultSettings.ts │ ├── getImageDimension.ts │ ├── getVisibleImages.ts │ ├── imageToBase64.ts │ ├── index.ts │ ├── runReplaceImages.ts │ ├── runScanImages.ts │ ├── sendMessage.ts │ └── sleep.ts └── vite-env.d.ts ├── tailwind.config.js ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | *.tsbuildinfo 16 | 17 | # Editor directories and files 18 | .vscode/* 19 | !.vscode/extensions.json 20 | .idea 21 | .DS_Store 22 | *.suo 23 | *.ntvs* 24 | *.njsproj 25 | *.sln 26 | *.sw? 27 | -------------------------------------------------------------------------------- /.node-version: -------------------------------------------------------------------------------- 1 | 20.9.0 -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v20.9.0 -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine 2 | 3 | WORKDIR /usr/src/app 4 | 5 | COPY package*.json ./ 6 | 7 | RUN yarn install 8 | 9 | COPY . . 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Atryon 2 | 3 | Atryon (pronounced "a-try-on", the name comes from "AI Try On") is a free Chrome extension to see yourself wearing the various clothes you see on e-commerce websites. 4 | 5 | ## Usage recommendations 6 | 7 | ### Recommendations for the model 8 | 9 | The plugin works by identifying pictures of clothes in the page (the "garment" pictures 10 | and replace them with your own photo (the "model" picture). 11 | 12 | Please only use a picture of yourself as model. 13 | 14 | ### Recommendations for the model model 15 | 16 | I recommend to use model pictures adapted to the kind of clothes you want to try. 17 | 18 | By default you should have least have a t-short and trousers. 19 | But for instance if you want to try a dress, you should also have a dress picture ready. 20 | 21 | ### Recommendations for the garment image 22 | 23 | Currently, the model works best with a clean garment image, with no body parts. 24 | If your image contains body parts (such as hair), those may appear in the final image. 25 | 26 | ## Instructions for developers 27 | 28 | ### run 29 | 30 | 1. run docker 31 | 32 | `docker compose up -d --build` 33 | 34 | 2. Turn on developer mode in the Chrome [Extension page](chrome://extensions/) and load dist file 35 | 36 | #### restart 37 | 38 | `docker restart extension` 39 | 40 | #### logs 41 | 42 | `docker logs extension -f` 43 | 44 | #### stop 45 | 46 | `docker stop extension` 47 | 48 | #### background(service-worker) development 49 | 50 | Clicking on service worker link in [chrome://extensions/](chrome://extensions/) launches the developer tool 51 | 52 | ### build 53 | 54 | #### Access within docker environment 55 | 56 | `docker compose exec extension ash` 57 | 58 | #### Run build command 59 | 60 | `/usr/src/app # yarn build` 61 | 62 | ### Deploying the backend 63 | 64 | #### Note about billing 65 | 66 | Running the try-on API can be expensive if you do too many requests, 67 | or leave the server on for too long. 68 | 69 | Regardless of the provider, please verify your payment capacity and only spend what you can afford. 70 | 71 | #### Deploy to Hugging Face 72 | 73 | First install [grog](https://github.com/multimodalart/grog), then follow grog's README.md instructions. 74 | 75 | To deploy the backend you will need two servers: 76 | 77 | ##### Segmentation server 78 | 79 | A segmentation server (only necessary whenever you want to add a new picture). 80 | You can stop this server from running whenever you are done with preparing your pictures. 81 | 82 | ``` 83 | python grog.py --replicate_model_id jbilcke/oot_diffusion_with_mask --run_type huggingface_spaces --huggingface_token YOUR_OWN_HUGGINGFACE_TOKEN --space_hardware a10g-small 84 | ``` 85 | 86 | ##### Substitution server 87 | 88 | Then you need the actual substitution server, which does the image substitution job. 89 | Be careful of costs! You are strongly recommended to stop the server once you do not use it anymore. 90 | 91 | ``` 92 | python grog.py --replicate_model_id viktorfa/oot_segmentation --run_type huggingface_spaces --huggingface_token YOUR_OWN_HUGGINGFACE_TOKEN --space_hardware a10g-small 93 | ``` 94 | 95 | 96 | #### Deploy to Replicate 97 | 98 | There is already a Replicate model deployed at [viktorfa/oot_diffusion](https://replicate.com/viktorfa/oot_diffusion). Follow Replicate's instructions if you want your own "always-on" server. 99 | 100 | 101 | ## Why using Atryon is better than a built-in try on widget that some websites have 102 | 103 | ### Compatible with all platforms 104 | 105 | A lot of e-commerce websites are a bit late when it comes to using AI technologies, 106 | and only a few are willing to develop or purchase a virtual try-on solution for their websites. 107 | 108 | With Atryon this becomes a non-issue: regardless of the technology used by each shopping website, 109 | images will be replaced by a picture of yourself, all automatically. 110 | 111 | ### Configure once, run everywhere 112 | 113 | With Atryon, you do not need to setup a virtual 3D avatar, send your picture to 114 | each of the e-commerce website your visit. 115 | 116 | Instead you do the setup once, and it "just works". 117 | 118 | ### Privacy first 119 | 120 | It can be a bit nerve wrecking to send your picture to a dozen of shopping platforms, 121 | you never know what they will do with it. 122 | 123 | With Atryon, you only need to send your picture to one provider. 124 | And the best thing? You get to choose which one, you can even run it locally if you want! 125 | 126 | By default we provide an access to Hugging Face, which preserves your privacy 127 | (images are not stored or logged). 128 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | extension: 5 | container_name: extension 6 | hostname: extension 7 | restart: always 8 | tty: true 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | ports: 13 | - 5173:5173 14 | volumes: 15 | - .:/usr/src/app 16 | command: yarn dev --host 17 | networks: 18 | - default 19 | platform: linux/amd64 20 | 21 | networks: 22 | default: 23 | 24 | 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Atryon: AI Virtual Try-On 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /manifest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineManifest } from "@crxjs/vite-plugin"; 2 | import packageJson from "./package.json"; 3 | const { version } = packageJson; 4 | 5 | // Convert from Semver (example: 0.1.0-beta6) 6 | const [major, minor, patch, label = "0"] = version 7 | // can only contain digits, dots, or dash 8 | .replace(/[^\d.-]+/g, "") 9 | // split into version parts 10 | .split(/[.-]/); 11 | 12 | export default defineManifest(async (env) => ({ 13 | manifest_version: 3, 14 | name: 15 | env.mode === "staging" 16 | ? "[INTERNAL] CRXJS Power Tools" 17 | : "CRXJS Power Tools", 18 | // up to four numbers separated by dots 19 | version: `${major}.${minor}.${patch}.${label}`, 20 | // semver is OK in "version_name" 21 | version_name: version, 22 | })); 23 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Atryon", 3 | "description": "AI Virtual Try-On", 4 | "version": "0.0.1", 5 | "manifest_version": 3, 6 | "action": { 7 | "default_popup": "index.html", 8 | "default_title": "Open Atryon" 9 | }, 10 | "content_scripts": [ 11 | { 12 | "matches": [""], 13 | "js": ["src/content_scripts/content_script.tsx"] 14 | } 15 | ], 16 | "background": { 17 | "service_worker": "src/background.ts", 18 | "type": "module" 19 | }, 20 | "options_page": "options.html", 21 | "permissions": [ 22 | "alarms", 23 | "background", 24 | "contextMenus", 25 | "bookmarks", 26 | "tabs", 27 | "storage", 28 | "unlimitedStorage", 29 | "history" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Atryon: AI Virtual Try-On 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "atryon", 3 | "description": "Atryon: AI Virtual Try-On", 4 | "private": true, 5 | "version": "0.0.0", 6 | "type": "module", 7 | "scripts": { 8 | "dev": "vite", 9 | "build": "tsc && vite build", 10 | "build:watch": "tsc && vite build --watch", 11 | "preview": "vite preview" 12 | }, 13 | "dependencies": { 14 | "@crxjs/vite-plugin": "^2.0.0-beta.23", 15 | "@radix-ui/react-select": "^2.0.0", 16 | "@radix-ui/react-slider": "^1.1.2", 17 | "@radix-ui/react-slot": "^1.0.2", 18 | "@tomickigrzegorz/react-circular-progress-bar": "^1.1.2", 19 | "@types/chrome": "^0.0.263", 20 | "@types/react": "^18.2.64", 21 | "@types/react-dom": "^18.2.21", 22 | "@vitejs/plugin-react": "^4.2.1", 23 | "autoprefixer": "^10.4.18", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.1.0", 26 | "lucide-react": "^0.353.0", 27 | "postcss": "^8.4.35", 28 | "react": "^18.2.0", 29 | "react-dom": "^18.2.0", 30 | "react-icons": "^5.0.1", 31 | "react-image-picker-editor": "^1.3.3", 32 | "tailwind-merge": "^2.2.1", 33 | "tailwindcss": "^3.4.1", 34 | "tailwindcss-animate": "^1.0.7", 35 | "typescript": "^5.4.2", 36 | "vite": "^5.1.5", 37 | "vite-plugin-svgr": "^4.2.0" 38 | }, 39 | "devDependencies": { 40 | "@types/node": "^20.11.25" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/assets/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/background.ts: -------------------------------------------------------------------------------- 1 | 2 | /* 3 | 4 | import { ImageURL, Settings, WorkerMessage } from "./types" 5 | import { runScanImages } from "./utils/runScanImages" 6 | import { replaceImage } from "./components/core/replaceImage" 7 | import { runReplaceImages } from "./utils/runReplaceImages" 8 | import { getDefaultSettings } from "./utils/getDefaultSettings" 9 | 10 | const state: { 11 | isActive: boolean 12 | isScanningImages: boolean 13 | isProcessingImages: boolean 14 | isReplacingImages: boolean 15 | images: Record 16 | } = { 17 | isActive: false, 18 | isScanningImages: false, 19 | isProcessingImages: false, 20 | isReplacingImages: false, 21 | images: {}, 22 | } 23 | 24 | const askToScanImages = async () => { 25 | console.log("background.ts: askToScanImages()") 26 | state.isScanningImages = true 27 | // first we check to see if there are any new images 28 | try { 29 | const images = await runScanImages() 30 | for (let image of images) { 31 | const existingImage = state.images[image.originalUri] 32 | if (existingImage) { continue } 33 | state.images[image.originalUri] = { 34 | ...image, 35 | status: "unprocessed", 36 | proposedUris: [], 37 | } 38 | } 39 | } catch (err) { 40 | console.error(err) 41 | } 42 | state.isScanningImages = false 43 | } 44 | const askToReplaceImages = async () => { 45 | console.log("background.ts: askToReplaceImages()") 46 | state.isReplacingImages = true 47 | // first we check to see if there are any new images 48 | try { 49 | const success = await runReplaceImages(Object.values(state.images)) 50 | } catch (err) { 51 | console.error(err) 52 | } 53 | state.isReplacingImages = false 54 | } 55 | const mainLoop = async () => { 56 | console.log("background.ts: mainLoop()") 57 | // update the image index to add new images 58 | await askToScanImages() 59 | 60 | // then we process the queue, X images at a time 61 | // (eg. 4 requests) 62 | let maxNbRequests = 1 63 | let nbRequests = 0 64 | state.isProcessingImages = true 65 | const allIndexedImages = Object.values(state.images) 66 | 67 | const unprocessedImages = allIndexedImages.filter(image => 68 | image.status !== "success" && 69 | image.status !== "failed" && 70 | image.status !== 'invalid' 71 | ) 72 | 73 | const settings = await chrome.storage.local.get(getDefaultSettings()) as Settings 74 | 75 | for (let image of unprocessedImages) { 76 | console.log(`background.ts: asking API to replace this image: ${image.originalUri}`) 77 | if (settings.engine) 78 | image.status = "processing" 79 | try { 80 | const proposedUris = await replaceImage({ 81 | modelImage: settings.modelImage, 82 | garmentImage: image.originalUri, 83 | }) 84 | 85 | console.log(`background.ts: got ${proposedUris.length} image replacements!`) 86 | image.proposedUris = [ 87 | ...image.proposedUris, 88 | ...proposedUris, 89 | ] 90 | image.status = "success" 91 | } catch (err) { 92 | console.log(`background.ts: failed to replace an image:`, err) 93 | image.status = "failed" 94 | } 95 | if (nbRequests++ >= maxNbRequests) { 96 | console.log("background.ts: reached the max number of requests") 97 | break 98 | } 99 | } 100 | state.isProcessingImages = false 101 | // try to replace the images 102 | await askToReplaceImages() 103 | if (state.isActive) { 104 | setTimeout(async () => { 105 | await mainLoop() 106 | }, 2000) 107 | } 108 | } 109 | 110 | chrome.runtime.onMessage.addListener(async function (msg, sender, sendResponse) { 111 | const action = `${msg.action || "UNKNOWN"}` as WorkerMessage 112 | 113 | self.console.log(`background.ts: action: ${action}`) 114 | 115 | if (action === "ENABLE") { 116 | state.isActive = true 117 | mainLoop() 118 | 119 | return Promise.resolve(true) 120 | } 121 | 122 | if (action === "DISABLE") { 123 | state.isActive = false 124 | 125 | 126 | return Promise.resolve(true) 127 | } 128 | }) 129 | */ 130 | 131 | self.console.log(`background.ts: this is the background service worker`); 132 | 133 | /* 134 | chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { 135 | console.log(`Change URL: ${tab.url}`); 136 | }); 137 | 138 | chrome.bookmarks.getRecent(10, (results) => { 139 | console.log(`bookmarks:`, results); 140 | }); 141 | */ 142 | 143 | export {}; 144 | -------------------------------------------------------------------------------- /src/components/core/replaceImage.ts: -------------------------------------------------------------------------------- 1 | import { ImageReplacer, Settings } from "@/types" 2 | import { getDefaultSettings } from "@/utils/getDefaultSettings" 3 | import { replaceImage as replaceImageWithReplicate } from "../../providers/replicate" 4 | import { replaceImage as replaceImageWithHuggingface } from "../../providers/huggingface" 5 | 6 | export const replaceImage: ImageReplacer = async (params) => { 7 | const settings = await chrome.storage.local.get(getDefaultSettings()) as Settings 8 | 9 | const fn: ImageReplacer = 10 | settings.engine === "REPLICATE" 11 | ? replaceImageWithReplicate 12 | : replaceImageWithHuggingface 13 | 14 | const results = await fn(params) 15 | 16 | return results 17 | } 18 | -------------------------------------------------------------------------------- /src/components/core/segmentImage.ts: -------------------------------------------------------------------------------- 1 | import { ImageSegmenter, Settings } from "@/types" 2 | import { getDefaultSettings } from "@/utils/getDefaultSettings" 3 | import { segmentImage as segmentImageWithReplicate } from "../../providers/replicate" 4 | import { segmentImage as segmentImageWithHuggingface } from "../../providers/huggingface" 5 | 6 | export const segmentImage: ImageSegmenter = async (params) => { 7 | const settings = await chrome.storage.local.get(getDefaultSettings()) as Settings 8 | 9 | const fn: ImageSegmenter = 10 | settings.engine === "REPLICATE" 11 | ? segmentImageWithReplicate 12 | : segmentImageWithHuggingface 13 | 14 | const results = await fn(params) 15 | 16 | return results 17 | } 18 | -------------------------------------------------------------------------------- /src/components/forms/field.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | 3 | export function Field({ children }: { children: ReactNode }) { 4 | return ( 5 |
{children}
6 | ) 7 | } -------------------------------------------------------------------------------- /src/components/forms/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /src/components/forms/label.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react" 2 | 3 | import { cn } from "@/utils" 4 | 5 | export function Label({ children, className = "" }: { children: ReactNode; className?: string }) { 6 | return ( 7 | 8 | ) 9 | } -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /src/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SelectPrimitive from "@radix-ui/react-select" 3 | import { Check, ChevronDown, ChevronUp } from "lucide-react" 4 | 5 | import { cn } from "@/utils" 6 | 7 | const Select = SelectPrimitive.Root 8 | 9 | const SelectGroup = SelectPrimitive.Group 10 | 11 | const SelectValue = SelectPrimitive.Value 12 | 13 | const SelectTrigger = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef 16 | >(({ className, children, ...props }, ref) => ( 17 | span]:line-clamp-1", 21 | className 22 | )} 23 | {...props} 24 | > 25 | {children} 26 | 27 | 28 | 29 | 30 | )) 31 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 32 | 33 | const SelectScrollUpButton = React.forwardRef< 34 | React.ElementRef, 35 | React.ComponentPropsWithoutRef 36 | >(({ className, ...props }, ref) => ( 37 | 45 | 46 | 47 | )) 48 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 49 | 50 | const SelectScrollDownButton = React.forwardRef< 51 | React.ElementRef, 52 | React.ComponentPropsWithoutRef 53 | >(({ className, ...props }, ref) => ( 54 | 62 | 63 | 64 | )) 65 | SelectScrollDownButton.displayName = 66 | SelectPrimitive.ScrollDownButton.displayName 67 | 68 | const SelectContent = React.forwardRef< 69 | React.ElementRef, 70 | React.ComponentPropsWithoutRef 71 | >(({ className, children, position = "popper", ...props }, ref) => ( 72 | 73 | 84 | 85 | 92 | {children} 93 | 94 | 95 | 96 | 97 | )) 98 | SelectContent.displayName = SelectPrimitive.Content.displayName 99 | 100 | const SelectLabel = React.forwardRef< 101 | React.ElementRef, 102 | React.ComponentPropsWithoutRef 103 | >(({ className, ...props }, ref) => ( 104 | 109 | )) 110 | SelectLabel.displayName = SelectPrimitive.Label.displayName 111 | 112 | const SelectItem = React.forwardRef< 113 | React.ElementRef, 114 | React.ComponentPropsWithoutRef 115 | >(({ className, children, ...props }, ref) => ( 116 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | {children} 131 | 132 | )) 133 | SelectItem.displayName = SelectPrimitive.Item.displayName 134 | 135 | const SelectSeparator = React.forwardRef< 136 | React.ElementRef, 137 | React.ComponentPropsWithoutRef 138 | >(({ className, ...props }, ref) => ( 139 | 144 | )) 145 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 146 | 147 | export { 148 | Select, 149 | SelectGroup, 150 | SelectValue, 151 | SelectTrigger, 152 | SelectContent, 153 | SelectLabel, 154 | SelectItem, 155 | SelectSeparator, 156 | SelectScrollUpButton, 157 | SelectScrollDownButton, 158 | } 159 | -------------------------------------------------------------------------------- /src/components/ui/slider.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SliderPrimitive from "@radix-ui/react-slider" 3 | 4 | import { cn } from "@/utils" 5 | 6 | const Slider = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 19 | 20 | 21 | 22 | 23 | )) 24 | Slider.displayName = SliderPrimitive.Root.displayName 25 | 26 | export { Slider } 27 | -------------------------------------------------------------------------------- /src/content_scripts/content_script.tsx: -------------------------------------------------------------------------------- 1 | import { urlMightBeInvalid } from "@/heuristics/urlMightBeInvalid" 2 | import { WorkerMessage, ImageURL } from "@/types" 3 | import { downloadImageToBase64 } from "@/utils/downloadImageToBase64" 4 | import { getImageDimension } from "@/utils/getImageDimension" 5 | import { getVisibleImages } from "@/utils/getVisibleImages" 6 | 7 | const state = { 8 | index: {} as Record 9 | } 10 | 11 | chrome.runtime.onMessage.addListener(function (msg, sender, sendResponse) { 12 | const message = JSON.parse(JSON.stringify(msg)) 13 | const action = `${message.action || "UNKNOWN"}` as WorkerMessage 14 | 15 | // console.log(`content_script.tsx: action: ${action}`) 16 | 17 | const fn = async () => { 18 | 19 | if (action === "SCAN_IMAGES") { 20 | 21 | let goodImages: ImageURL[] = [] 22 | 23 | try { 24 | 25 | 26 | // main job: to regularly update the currentImages object 27 | const visibleImages = getVisibleImages() 28 | console.log(`Found ${visibleImages.length} visible images:`, visibleImages) 29 | 30 | for (let element of visibleImages) { 31 | const originalUri = element.src || "" 32 | 33 | // we try to avoid obviously "wrong" images 34 | if (urlMightBeInvalid(originalUri)) { 35 | continue 36 | } 37 | 38 | // finally, to optimize things and avoid endless download, 39 | // we also skip images that are already in the index 40 | if (state.index[originalUri]) { 41 | continue 42 | } 43 | 44 | let goodCandidate = true 45 | 46 | let dataUri = "" 47 | 48 | // now, the issue is that the image element might contain a low resolution version 49 | // so we want to actually re-download the original 50 | try { 51 | 52 | // we try to first the "best" hi-res image 53 | // dataset?.oldRes is for Amazon, otherwise we use currentSrc 54 | // TODO we should look into the srcset, for an even higher resolution 55 | const hiResImageSrc = element.dataset?.oldHires || element.currentSrc 56 | 57 | const bestImageSrc = hiResImageSrc || element.src || "" 58 | 59 | dataUri = await downloadImageToBase64(bestImageSrc) 60 | } catch (err) { 61 | // console.log(`failed to download an image: ${err}`) 62 | goodCandidate = false 63 | } 64 | 65 | let resolution: { width: number; height: number } = { width: 0, height: 0 } 66 | 67 | try { 68 | resolution = await getImageDimension(dataUri) 69 | } catch (err) { 70 | // resolution = { width: 0, height: 0 } 71 | // console.error(`failed to detect resolution of the image: ${err}`) 72 | goodCandidate = false 73 | } 74 | 75 | const { width, height } = resolution 76 | 77 | // we want to be a bit strict here 78 | // however we can't be *too* strict either, 79 | // as some sites don't pass the 1024px size limit 80 | // eg. https://www.checkpoint-tshirt.com/cdn/shop/products/empire-wave-tee-shirt-homme_denim_2048x.jpg?v=1680850296 81 | // and on Shein, it can even be 800px.. 82 | if (width < 800) { 83 | goodCandidate = false 84 | } 85 | 86 | if (height < 800) { 87 | goodCandidate = false 88 | } 89 | 90 | 91 | const imageURL: ImageURL = { 92 | originalUri, 93 | dataUri, 94 | width, 95 | height, 96 | goodCandidate, 97 | status: goodCandidate ? "unprocessed" : "invalid", 98 | proposedUris: [], 99 | } 100 | 101 | state.index[originalUri] = imageURL 102 | 103 | if (goodCandidate) { 104 | goodImages.push(imageURL) 105 | } 106 | } 107 | 108 | } catch (err) { 109 | console.error(`failed to scan the images:`, err) 110 | } 111 | 112 | console.log(`Found ${goodImages.length} good candidates:`, goodImages) 113 | sendResponse(goodImages) 114 | } else if (action === "REPLACE_IMAGES") { 115 | 116 | console.log("Replacing images in the page with those:", message.images) 117 | const index: Record = {} 118 | const images = message.images as ImageURL[] 119 | for (let image of images) { 120 | index[image.originalUri] = image 121 | } 122 | 123 | let replacedImages: ImageURL[] = [] 124 | 125 | try { 126 | 127 | // note: here we replace images everywhere, even the invisible one 128 | // that's because the user might have scrolled within the page 129 | // while the request was running in the background 130 | const currentImages = document.images 131 | 132 | for (let i = 0, l = currentImages.length; i { 170 | fn() 171 | }, 10) 172 | 173 | // we return true as sendResponse will be async 174 | return true 175 | }) -------------------------------------------------------------------------------- /src/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 47.4% 11.2%; 9 | 10 | --muted: 210 40% 96.1%; 11 | --muted-foreground: 215.4 16.3% 46.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 222.2 47.4% 11.2%; 15 | 16 | --border: 214.3 31.8% 91.4%; 17 | --input: 214.3 31.8% 91.4%; 18 | 19 | --card: 0 0% 100%; 20 | --card-foreground: 222.2 47.4% 11.2%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --accent: 210 40% 96.1%; 29 | --accent-foreground: 222.2 47.4% 11.2%; 30 | 31 | --destructive: 0 100% 50%; 32 | --destructive-foreground: 210 40% 98%; 33 | 34 | --ring: 215 20.2% 65.1%; 35 | 36 | --radius: 0.5rem; 37 | } 38 | 39 | .dark { 40 | --background: 224 71% 4%; 41 | --foreground: 213 31% 91%; 42 | 43 | --muted: 223 47% 11%; 44 | --muted-foreground: 215.4 16.3% 56.9%; 45 | 46 | --accent: 216 34% 17%; 47 | --accent-foreground: 210 40% 98%; 48 | 49 | --popover: 224 71% 4%; 50 | --popover-foreground: 215 20.2% 65.1%; 51 | 52 | --border: 216 34% 17%; 53 | --input: 216 34% 17%; 54 | 55 | --card: 224 71% 4%; 56 | --card-foreground: 213 31% 91%; 57 | 58 | --primary: 210 40% 98%; 59 | --primary-foreground: 222.2 47.4% 1.2%; 60 | 61 | --secondary: 222.2 47.4% 11.2%; 62 | --secondary-foreground: 210 40% 98%; 63 | 64 | --destructive: 0 63% 31%; 65 | --destructive-foreground: 210 40% 98%; 66 | 67 | --ring: 216 34% 17%; 68 | 69 | --radius: 0.5rem; 70 | } 71 | } 72 | 73 | @layer base { 74 | * { 75 | @apply border-border; 76 | } 77 | body { 78 | @apply bg-background text-foreground; 79 | font-feature-settings: "rlig" 1, "calt" 1; 80 | } 81 | } -------------------------------------------------------------------------------- /src/heuristics/urlMightBeInvalid.ts: -------------------------------------------------------------------------------- 1 | 2 | // some heuristics about which kind of URLs are probably not garment 3 | const wrongKeywords = [ 4 | "Clickandcollect", 5 | "Clickandreserve", 6 | "paiement3X", 7 | "Newsletter", 8 | "Bannière", 9 | "Paypal", 10 | "shop-by-data.jpg", 11 | ].map(x => x.trim().toLowerCase()) 12 | 13 | /** 14 | * Try to determine if the URL is a good candidate or not for a garment image 15 | * 16 | * @param url 17 | */ 18 | export function urlMightBeInvalid(url: string) { 19 | 20 | // we skip data-uri since "normal" e-commerce websites will always use some kind of CDN 21 | // data uri since they are usually reserved to ads, trackers etc 22 | // it would look back in the index, too 23 | if (url.startsWith("data:")) { 24 | return true 25 | } 26 | 27 | for (const keyword of wrongKeywords) { 28 | if (url.toLowerCase().includes(keyword)) { 29 | return true 30 | } 31 | } 32 | 33 | return false 34 | } -------------------------------------------------------------------------------- /src/hooks/useImageDimension.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react" 2 | 3 | import { ImageDimension, getImageDimension } from "@/utils/getImageDimension" 4 | 5 | export function useImageDimension(src: string) { 6 | const [dimension, setDimension] = useState({ 7 | width: 0, 8 | height: 0, 9 | }) 10 | 11 | useEffect(() => { 12 | const compute = async () => { 13 | const newDimension = await getImageDimension(src) 14 | setDimension(newDimension) 15 | } 16 | compute() 17 | }, [src]) 18 | 19 | return dimension 20 | } -------------------------------------------------------------------------------- /src/hooks/useSettings.ts: -------------------------------------------------------------------------------- 1 | 2 | import { useEffect, useState } from "react" 3 | 4 | import { getDefaultSettings } from "@/utils/getDefaultSettings" 5 | import { Engine, SettingsSaveStatus } from "@/types" 6 | 7 | export function useSettings() { 8 | const defaultSettings = getDefaultSettings() 9 | 10 | const [status, setStatus] = useState("idle") 11 | 12 | // DEFAULT: default engine 13 | // GRADIO_API: url to local or remote gradio spaces 14 | // REPLICATE: url to replicate api(s) 15 | const [engine, setEngine] = useState(defaultSettings.engine) 16 | 17 | // api key of the Hugging Face account 18 | const [huggingfaceApiKey, setHuggingfaceApiKey] = useState(defaultSettings.huggingfaceApiKey) 19 | 20 | // url of the Hugging Face Space for segmentation (Gradio API) 21 | const [huggingfaceSegmentationSpaceUrl, setHuggingfaceSegmentationSpaceUrl] = useState(defaultSettings.huggingfaceSegmentationSpaceUrl) 22 | 23 | // url of the Hugging Face Space for substitution (Gradio API) 24 | const [huggingfaceSubstitutionSpaceUrl, setHuggingfaceSubstitutionSpaceUrl] = useState(defaultSettings.huggingfaceSubstitutionSpaceUrl) 25 | 26 | // Number of steps for the Hugging Face model 27 | const [huggingfaceNumberOfSteps, setHuggingfaceNumberOfSteps] = useState(defaultSettings.huggingfaceNumberOfSteps) 28 | 29 | // Guidance scale for the Hugging Face model 30 | const [huggingfaceGuidanceScale, setHuggingfaceGuidanceScale] = useState(defaultSettings.huggingfaceGuidanceScale) 31 | 32 | // Replicate.com api key 33 | const [replicateApiKey, setReplicateApiKey] = useState(defaultSettings.replicateApiKey) 34 | 35 | // replicate model name 36 | const [replicateSegmentationModel, setReplicateSegmentationModel] = useState(defaultSettings.replicateSegmentationModel) 37 | 38 | // Replicate model version 39 | const [replicateSegmentationModelVersion, setReplicateSegmentationModelVersion] = useState(defaultSettings.replicateSegmentationModelVersion) 40 | 41 | // replicate model name 42 | const [replicateSubstitutionModel, setReplicateSubstitutionModel] = useState(defaultSettings.replicateSubstitutionModel) 43 | 44 | // Replicate model version 45 | const [replicateSubstitutionModelVersion, setReplicateSubstitutionModelVersion] = useState(defaultSettings.replicateSubstitutionModelVersion) 46 | 47 | // Number of steps for the Replicate model 48 | const [replicateNumberOfSteps, setReplicateNumberOfSteps] = useState(defaultSettings.replicateNumberOfSteps) 49 | 50 | // Guidance scale for the Replicate model 51 | const [replicateGuidanceScale, setReplicateGuidanceScale] = useState(defaultSettings.replicateGuidanceScale) 52 | 53 | // api key for local usage (eg. for privacy or development purposes) 54 | const [customGradioApiKey, setCustomGradioApiKey] = useState(defaultSettings.customGradioApiKey) 55 | 56 | // url of the Hugging Face Space for segmentation (Gradio API) 57 | const [customGradioApiSegmentationSpaceUrl, setCustomGradioApiSegmentationSpaceUrl] = useState(defaultSettings.customGradioApiSegmentationSpaceUrl) 58 | 59 | // url of the local API for substitution (Gradio API) 60 | const [customGradioApiSubstitutionSpaceUrl, setCustomGradioApiSubstitutionSpaceUrl] = useState(defaultSettings.customGradioApiSubstitutionSpaceUrl) 61 | 62 | // Number of steps for the local model 63 | const [customGradioApiNumberOfSteps, setCustomGradioApiNumberOfSteps] = useState(defaultSettings.customGradioApiNumberOfSteps) 64 | 65 | // Guidance scale for the local model 66 | const [customGradioApiGuidanceScale, setCustomGradioApiGuidanceScale] = useState(defaultSettings.customGradioApiGuidanceScale) 67 | 68 | const [upperBodyModelImage, setUpperBodyModelImage] = useState(defaultSettings.upperBodyModelImage) 69 | const [upperBodyModelMaskImage, setUpperBodyModelMaskImage] = useState(defaultSettings.upperBodyModelMaskImage) 70 | const [fullBodyModelImage, setFullBodyModelImage] = useState(defaultSettings.fullBodyModelImage) 71 | const [fullBodyModelMaskImage, setFullBodyModelMaskImage] = useState(defaultSettings.fullBodyModelMaskImage) 72 | 73 | // DEPRECATED to enable or disable the substitution 74 | // const [isEnabled, setEnabled] = useState(defaultSettings.isEnabled) 75 | 76 | useEffect(() => { 77 | // Restores state using the preferences stored in chrome.storage. 78 | chrome.storage.local.get( 79 | getDefaultSettings(), 80 | (settings) => { 81 | setEngine(settings.engine) 82 | 83 | setHuggingfaceApiKey(settings.huggingfaceApiKey) 84 | setHuggingfaceSegmentationSpaceUrl(settings.huggingfaceSegmentationSpaceUrl) 85 | setHuggingfaceSubstitutionSpaceUrl(settings.huggingfaceSubstitutionSpaceUrl) 86 | setHuggingfaceNumberOfSteps(settings.huggingfaceNumberOfSteps) 87 | setHuggingfaceGuidanceScale(settings.huggingfaceGuidanceScale) 88 | 89 | setReplicateApiKey(settings.replicateApiKey) 90 | setReplicateSegmentationModel(settings.replicateSegmentationModel) 91 | setReplicateSegmentationModelVersion(settings.replicateSegmentationModelVersion) 92 | setReplicateSubstitutionModel(settings.replicateSubstitutionModel) 93 | setReplicateSubstitutionModelVersion(settings.replicateSubstitutionModelVersion) 94 | setReplicateNumberOfSteps(settings.replicateNumberOfSteps) 95 | setReplicateGuidanceScale(settings.replicateGuidanceScale) 96 | 97 | setCustomGradioApiKey(settings.customGradioApiKey) 98 | setCustomGradioApiSegmentationSpaceUrl(settings.customGradioApiSegmentationSpaceUrl) 99 | setCustomGradioApiSubstitutionSpaceUrl(settings.customGradioApiSubstitutionSpaceUrl) 100 | setCustomGradioApiNumberOfSteps(settings.customGradioApiNumberOfSteps) 101 | setCustomGradioApiGuidanceScale(settings.customGradioApiGuidanceScale) 102 | 103 | setUpperBodyModelImage(settings.upperBodyModelImage) 104 | setUpperBodyModelMaskImage(settings.upperBodyModelMaskImage) 105 | setFullBodyModelImage(settings.fullBodyModelImage) 106 | setFullBodyModelMaskImage(settings.fullBodyModelMaskImage) 107 | 108 | // DEPRECATED 109 | // setEnabled(settings.isEnabled) 110 | } 111 | ) 112 | }, []) 113 | 114 | const saveSettings = () => { 115 | setStatus("saving") 116 | // Saves options to chrome.storage.local. 117 | chrome.storage.local.set( 118 | { 119 | engine, 120 | 121 | huggingfaceApiKey, 122 | huggingfaceSegmentationSpaceUrl, 123 | huggingfaceSubstitutionSpaceUrl, 124 | huggingfaceNumberOfSteps, 125 | huggingfaceGuidanceScale, 126 | 127 | replicateApiKey, 128 | replicateSegmentationModel, 129 | replicateSegmentationModelVersion, 130 | replicateSubstitutionModel, 131 | replicateSubstitutionModelVersion, 132 | replicateNumberOfSteps, 133 | replicateGuidanceScale, 134 | 135 | customGradioApiKey, 136 | customGradioApiSegmentationSpaceUrl, 137 | customGradioApiSubstitutionSpaceUrl, 138 | customGradioApiNumberOfSteps, 139 | customGradioApiGuidanceScale, 140 | 141 | upperBodyModelImage, 142 | upperBodyModelMaskImage, 143 | fullBodyModelImage, 144 | fullBodyModelMaskImage, 145 | 146 | // DEPRECATED 147 | // isEnabled, 148 | }, 149 | () => { 150 | // Update status to let user know options were saved. 151 | setStatus("saved") 152 | 153 | let id = setTimeout(() => { 154 | setStatus("idle") 155 | }, 2000); 156 | 157 | return () => clearTimeout(id); 158 | } 159 | ) 160 | } 161 | 162 | useEffect(() => { 163 | console.log(`autosave settings..`) 164 | saveSettings() 165 | }, [ 166 | engine, 167 | huggingfaceApiKey, 168 | huggingfaceSegmentationSpaceUrl, 169 | huggingfaceSubstitutionSpaceUrl, 170 | huggingfaceNumberOfSteps, 171 | huggingfaceGuidanceScale, 172 | replicateApiKey, 173 | replicateSegmentationModel, 174 | replicateSegmentationModelVersion, 175 | replicateSubstitutionModel, 176 | replicateSubstitutionModelVersion, 177 | replicateNumberOfSteps, 178 | replicateGuidanceScale, 179 | customGradioApiKey, 180 | customGradioApiSegmentationSpaceUrl, 181 | customGradioApiSubstitutionSpaceUrl, 182 | customGradioApiNumberOfSteps, 183 | customGradioApiGuidanceScale, 184 | upperBodyModelImage, 185 | upperBodyModelMaskImage, 186 | fullBodyModelImage, 187 | fullBodyModelMaskImage, 188 | 189 | // DEPRECATED 190 | // isEnabled, 191 | ]) 192 | 193 | // set to true to disable validation 194 | const debugMode = false 195 | 196 | const hasValidDefaultCredentials = debugMode || (engine === "DEFAULT" && huggingfaceApiKey.length > 8) 197 | const hasValidCustomGradioApiCredentials = debugMode || (engine === "GRADIO_API" && customGradioApiKey.length > 8) 198 | const hasValidReplicateCredentials = debugMode || (engine === "REPLICATE" && replicateApiKey.length > 8) 199 | 200 | const hasValidCredentials = debugMode || ( 201 | hasValidDefaultCredentials || hasValidCustomGradioApiCredentials || hasValidReplicateCredentials 202 | ) 203 | 204 | const hasValidUpperBodyModel = debugMode || ( 205 | upperBodyModelImage.length > 100 && upperBodyModelMaskImage.length > 100 206 | ) 207 | 208 | const hasValidFullBodyModel = debugMode || ( 209 | fullBodyModelImage.length > 100 && fullBodyModelMaskImage.length > 100 210 | ) 211 | 212 | const hasValidBodyModels = debugMode || ( 213 | // for now we disable the upper body system, 214 | // so we only keep 1 image 215 | // hasValidUpperBodyModel && 216 | hasValidFullBodyModel 217 | ) 218 | 219 | return { 220 | defaultSettings, 221 | 222 | status, setStatus, 223 | 224 | // DEFAULT: default engine 225 | // GRADIO_API: url to local or remote gradio spaces 226 | // REPLICATE: url to replicate api(s) 227 | engine, setEngine, 228 | 229 | // api key of the Hugging Face account 230 | huggingfaceApiKey, setHuggingfaceApiKey, 231 | 232 | // url of the Hugging Face Spaces (Gradio API) 233 | huggingfaceSegmentationSpaceUrl, setHuggingfaceSegmentationSpaceUrl, 234 | huggingfaceSubstitutionSpaceUrl, setHuggingfaceSubstitutionSpaceUrl, 235 | 236 | // Number of steps for the Hugging Face model 237 | huggingfaceNumberOfSteps, setHuggingfaceNumberOfSteps, 238 | 239 | // Guidance scale for the Hugging Face model 240 | huggingfaceGuidanceScale, setHuggingfaceGuidanceScale, 241 | 242 | // Replicate.com api key 243 | replicateApiKey, setReplicateApiKey, 244 | 245 | // replicate model name 246 | replicateSegmentationModel, setReplicateSegmentationModel, 247 | 248 | // Replicate model version 249 | replicateSegmentationModelVersion, setReplicateSegmentationModelVersion, 250 | 251 | // replicate model name 252 | replicateSubstitutionModel, setReplicateSubstitutionModel, 253 | 254 | // Replicate model version 255 | replicateSubstitutionModelVersion, setReplicateSubstitutionModelVersion, 256 | 257 | // Number of steps for the Replicate model 258 | replicateNumberOfSteps, setReplicateNumberOfSteps, 259 | 260 | // Guidance scale for the Replicate model 261 | replicateGuidanceScale, setReplicateGuidanceScale, 262 | 263 | // api key for local usage (eg. for privacy or development purposes) 264 | customGradioApiKey, setCustomGradioApiKey, 265 | 266 | // url of the local APIs (eg. for privacy or development purposes) 267 | customGradioApiSegmentationSpaceUrl, setCustomGradioApiSegmentationSpaceUrl, 268 | customGradioApiSubstitutionSpaceUrl, setCustomGradioApiSubstitutionSpaceUrl, 269 | 270 | // Number of steps for the local model 271 | customGradioApiNumberOfSteps, setCustomGradioApiNumberOfSteps, 272 | 273 | // Guidance scale for the local model 274 | customGradioApiGuidanceScale, setCustomGradioApiGuidanceScale, 275 | 276 | upperBodyModelImage, setUpperBodyModelImage, 277 | upperBodyModelMaskImage, setUpperBodyModelMaskImage, 278 | fullBodyModelImage, setFullBodyModelImage, 279 | fullBodyModelMaskImage, setFullBodyModelMaskImage, 280 | 281 | // DEPRECATED to enable or disable the substitution 282 | // isEnabled, setEnabled, 283 | 284 | // trigger to save the options 285 | saveSettings, 286 | 287 | debugMode, 288 | 289 | hasValidDefaultCredentials, 290 | hasValidCustomGradioApiCredentials, 291 | hasValidReplicateCredentials, 292 | hasValidCredentials, 293 | hasValidUpperBodyModel, 294 | hasValidBodyModels, 295 | } 296 | } -------------------------------------------------------------------------------- /src/options.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react" 2 | import ReactDOM from "react-dom/client" 3 | 4 | import "@/globals.css" 5 | 6 | import { Engine } from "@/types" 7 | import { cn } from "@/utils" 8 | import { useSettings } from "@/hooks/useSettings" 9 | import { Button } from "@/components/ui/button" 10 | import { Slider } from "@/components/ui/slider" 11 | import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" 12 | import { Field } from "@/components/forms/field" 13 | import { Label } from "@/components/forms/label" 14 | import { Input } from "@/components/forms/input" 15 | import { fileToBase64 } from "./utils/fileToBase64" 16 | import { segmentImage } from "./components/core/segmentImage" 17 | 18 | 19 | function Options() { 20 | const settings = useSettings() 21 | 22 | return ( 23 |
28 |
33 |
34 |

Atryon Chrome Plugin: AI Virtual Try-On

35 |
36 | 37 |
38 | 39 |
40 |
1
41 |
Select the service provider
42 |
43 | 60 |
61 | 62 | {settings.engine === "DEFAULT" && <> 63 | 64 | 65 | { 70 | settings.setHuggingfaceApiKey(x.target.value) 71 | }} 72 | value={settings.huggingfaceApiKey} 73 | /> 74 | 75 | } 76 | 77 | {settings.engine === "REPLICATE" && <> 78 |

Note: it makes takes a few minutes for Replicate to warm-up the models if they haven't been used by in a while. So if segmentation or susbstitution fail, try again after 5 min.

79 | 80 | 81 | { 86 | settings.setReplicateApiKey(x.target.value) 87 | }} 88 | value={settings.replicateApiKey} 89 | /> 90 | 91 | 92 | 93 | { 98 | settings.setReplicateSegmentationModel(x.target.value) 99 | }} 100 | value={settings.replicateSegmentationModel} 101 | /> 102 | 103 | 104 | 105 | { 110 | settings.setReplicateSegmentationModelVersion(x.target.value) 111 | }} 112 | value={settings.replicateSegmentationModelVersion} 113 | /> 114 | 115 | 116 | 117 | { 122 | settings.setReplicateSubstitutionModel(x.target.value) 123 | }} 124 | value={settings.replicateSubstitutionModel} 125 | /> 126 | 127 | 128 | 129 | { 134 | settings.setReplicateSubstitutionModelVersion(x.target.value) 135 | }} 136 | value={settings.replicateSubstitutionModelVersion} 137 | /> 138 | 139 | } 140 | 141 | {settings.engine === "GRADIO_API" && <> 142 | 143 | 144 | { 149 | settings.setCustomGradioApiKey(x.target.value) 150 | }} 151 | value={settings.customGradioApiKey} 152 | /> 153 | 154 | 155 | 156 | { 161 | settings.setCustomGradioApiSegmentationSpaceUrl(x.target.value) 162 | }} 163 | value={settings.customGradioApiSegmentationSpaceUrl} 164 | /> 165 | 166 | 167 | 168 | { 173 | settings.setCustomGradioApiSubstitutionSpaceUrl(x.target.value) 174 | }} 175 | value={settings.customGradioApiSubstitutionSpaceUrl} 176 | /> 177 | 178 | } 179 |
180 | 181 | {settings.hasValidCredentials &&
182 |
183 |
2
184 |
Add pictures of yourself
185 |
186 | 187 |

188 | Please follow those instructions for best results: 189 |

190 | 191 |
    192 |
  • 👕 Wear simple clothes (eg. white t-shirt, trousers)
  • 193 |
  • 🧍 Face the camera, with arms hanging on the sides
  • 194 |
  • 💡 Use a well lit environment, with a neutral background
  • 195 |
  • 📸 Use a portrait orientation (beware of hidden photo settings!)
  • 196 |
197 | 198 | {/* 199 | 200 | 201 | 202 |
203 | {settings.upperBodyModelImage &&
204 | 208 |

Your upper body

209 |
} 210 | 211 | {settings.upperBodyModelMaskImage ?
212 | 216 |

Body mask

217 |
218 | :
219 | {settings.upperBodyModelMaskImage ? "Segmenting image, please wait.." : "Please select a file"} 220 |
} 221 |
222 | 223 | ) => { 227 | if (e.target.files && e.target.files.length > 0) { 228 | const file = e.target.files[0]; 229 | const newImageBase64 = await fileToBase64(file) 230 | settings.setUpperBodyModelImage(newImageBase64) 231 | 232 | if (!newImageBase64) { 233 | console.log(`no upper body image to segment, aborting`) 234 | return 235 | } 236 | console.log(`calling model image segmenter on the upper body..`) 237 | try { 238 | const segmentationResult = await segmentImage(newImageBase64) 239 | if (!segmentationResult) { throw new Error(`segmentationResult is empty`) } 240 | settings.setUpperBodyModelMaskImage(segmentationResult) 241 | } catch (err) { 242 | console.log(`failed to segment the upper body: `, err) 243 | } 244 | } 245 | }} 246 | accept="image/*" 247 | /> 248 |
249 | */} 250 | 251 | 252 | 253 | 254 | 255 |
256 | {settings.fullBodyModelImage &&
257 | 261 |

Your full body

262 |
} 263 | {settings.fullBodyModelMaskImage ?
264 | 268 |

Body mask

269 |
270 | :
271 | {settings.fullBodyModelImage ? "Segmenting image, please wait.." : "Please select a file"} 272 |
} 273 |
274 | ) => { 278 | if (e.target.files && e.target.files.length > 0) { 279 | const file = e.target.files[0]; 280 | const newImageBase64 = await fileToBase64(file) 281 | settings.setFullBodyModelImage(newImageBase64) 282 | 283 | if (!newImageBase64) { 284 | console.log(`no full body image to segment, aborting`) 285 | return 286 | } 287 | console.log(`calling model image segmenter on the full body..`) 288 | try { 289 | const segmentationResult = await segmentImage(newImageBase64) 290 | if (!segmentationResult) { throw new Error(`segmentationResult is empty`) } 291 | settings.setFullBodyModelMaskImage(segmentationResult) 292 | } catch (err) { 293 | console.log(`failed to segment the full body: `, err) 294 | } 295 | } 296 | }} 297 | accept="image/*" 298 | /> 299 |
300 | 301 |
} 302 | 303 | {settings.hasValidCredentials && settings.hasValidBodyModels &&
304 |
305 |
3
306 |
Customize provider settings (optional)
307 |
308 | 309 | {settings.engine === "DEFAULT" && <> 310 | 311 | 312 | { 317 | let nbSteps = Number(value[0]) 318 | nbSteps = !isNaN(value[0]) && isFinite(value[0]) ? nbSteps : 0 319 | nbSteps = Math.min(40, Math.max(1, nbSteps)) 320 | settings.setHuggingfaceNumberOfSteps(nbSteps) 321 | }} 322 | defaultValue={[settings.defaultSettings.huggingfaceNumberOfSteps]} 323 | value={[settings.huggingfaceNumberOfSteps]} 324 | /> 325 | 326 | 327 | 328 | { 333 | let guidanceScale = Number(value[0]) 334 | guidanceScale = !isNaN(value[0]) && isFinite(value[0]) ? guidanceScale : 0 335 | guidanceScale = Math.min(10, Math.max(1, guidanceScale)) 336 | settings.setHuggingfaceGuidanceScale(guidanceScale) 337 | }} 338 | defaultValue={[settings.defaultSettings.huggingfaceGuidanceScale]} 339 | value={[settings.huggingfaceGuidanceScale]} 340 | /> 341 | 342 | } 343 | 344 | {settings.engine === "REPLICATE" && <> 345 | 346 | 347 | { 352 | let nbSteps = Number(value[0]) 353 | nbSteps = !isNaN(value[0]) && isFinite(value[0]) ? nbSteps : 0 354 | nbSteps = Math.min(40, Math.max(1, nbSteps)) 355 | settings.setReplicateNumberOfSteps(nbSteps) 356 | }} 357 | defaultValue={[settings.defaultSettings.replicateNumberOfSteps]} 358 | value={[settings.replicateNumberOfSteps]} 359 | /> 360 | 361 | 362 | 363 | { 368 | let guidanceScale = Number(value[0]) 369 | guidanceScale = !isNaN(value[0]) && isFinite(value[0]) ? guidanceScale : 0 370 | guidanceScale = Math.min(10, Math.max(1, guidanceScale)) 371 | settings.setReplicateGuidanceScale(guidanceScale) 372 | }} 373 | defaultValue={[settings.defaultSettings.replicateGuidanceScale]} 374 | value={[settings.replicateGuidanceScale]} 375 | /> 376 | 377 | } 378 | 379 | {settings.engine === "GRADIO_API" && <> 380 | 381 | 382 | { 387 | let nbSteps = Number(value[0]) 388 | nbSteps = !isNaN(value[0]) && isFinite(value[0]) ? nbSteps : 0 389 | nbSteps = Math.min(40, Math.max(1, nbSteps)) 390 | settings.setCustomGradioApiNumberOfSteps(nbSteps) 391 | }} 392 | defaultValue={[settings.defaultSettings.customGradioApiNumberOfSteps]} 393 | value={[settings.customGradioApiNumberOfSteps]} 394 | /> 395 | 396 | 397 | 398 | { 403 | let guidanceScale = Number(value[0]) 404 | guidanceScale = !isNaN(value[0]) && isFinite(value[0]) ? guidanceScale : 0 405 | guidanceScale = Math.min(10, Math.max(1, guidanceScale)) 406 | settings.setCustomGradioApiGuidanceScale(guidanceScale) 407 | }} 408 | defaultValue={[settings.defaultSettings.customGradioApiGuidanceScale]} 409 | value={[settings.customGradioApiGuidanceScale]} 410 | /> 411 | 412 | } 413 | 414 |
} 415 | 416 |
417 |
418 | 428 |
429 |
430 |
431 |
432 | ); 433 | } 434 | 435 | const index = document.createElement("div"); 436 | index.id = "options"; 437 | document.body.appendChild(index); 438 | 439 | ReactDOM.createRoot(index).render( 440 | 441 | 442 | 443 | ); 444 | -------------------------------------------------------------------------------- /src/popup.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react" 2 | import ReactDOM from "react-dom/client" 3 | import { CircularProgressBar } from "@tomickigrzegorz/react-circular-progress-bar"; 4 | 5 | import "@/globals.css" 6 | 7 | import { cn, sleep } from "@/utils" 8 | 9 | import { ImageURL } from "./types" 10 | import { useSettings } from "./hooks/useSettings" 11 | import { runScanImages } from "./utils/runScanImages" 12 | import { replaceImage } from "./components/core/replaceImage" 13 | import { runReplaceImages } from "./utils/runReplaceImages" 14 | 15 | const initialCircularProgressConfig = { 16 | id: 0, // important 17 | percent: 0, 18 | colorSlice: "#E91E63", 19 | number: false, 20 | size: 192, 21 | }; 22 | 23 | function Popup() { 24 | const [updatedCircularProgressConfig, setCircularProgressConfig] = useState(initialCircularProgressConfig); 25 | const circularProgressConfig = { ...initialCircularProgressConfig, ...updatedCircularProgressConfig }; 26 | 27 | // do we still need this? 28 | const [currentURL, setCurrentURL] = useState() 29 | 30 | const settings = useSettings() 31 | 32 | const [isEnabled, setEnabled] = useState(false) 33 | 34 | const state = useRef<{ 35 | isEnabled: boolean 36 | isScanningImages: boolean 37 | isProcessingImages: boolean 38 | isReplacingImages: boolean 39 | images: Record 40 | nbProcessedImages: number 41 | nbImagesToProcess: number 42 | pageProcessingStartedAt: number 43 | currentImageProcessingStartedAt: number 44 | currentImageProcessingEndedAt: number 45 | pageProcessingEndedAt: number 46 | progressInterval?: NodeJS.Timeout 47 | }>({ 48 | isEnabled: false, 49 | isScanningImages: false, 50 | isProcessingImages: false, 51 | isReplacingImages: false, 52 | images: {}, 53 | nbProcessedImages: 0, 54 | nbImagesToProcess: 0, 55 | pageProcessingStartedAt: 0, 56 | currentImageProcessingStartedAt: 0, 57 | currentImageProcessingEndedAt: 0, 58 | pageProcessingEndedAt: 0, 59 | progressInterval: undefined, 60 | }) 61 | 62 | 63 | useEffect(() => { 64 | chrome.tabs.query({ active: true, currentWindow: true }, function (tabs) { 65 | setCurrentURL(tabs[0].url) 66 | }) 67 | }, []) 68 | 69 | const askToScanImages = async () => { 70 | console.log("askToScanImages()") 71 | state.current.isScanningImages = true 72 | 73 | // first we check to see if there are any new images 74 | try { 75 | const images = await runScanImages() 76 | console.log("popup.tsx: images:", images) 77 | 78 | for (let image of images) { 79 | const existingImage = state.current.images[image.originalUri] 80 | if (existingImage) { continue } 81 | 82 | state.current.images[image.originalUri] = { 83 | ...image, 84 | status: "unprocessed", 85 | proposedUris: [], 86 | } 87 | } 88 | } catch (err) { 89 | console.error(err) 90 | } 91 | 92 | state.current.isScanningImages = false 93 | } 94 | 95 | const askToReplaceImages = async () => { 96 | console.log("askToReplaceImages()") 97 | state.current.isReplacingImages = true 98 | 99 | // first we check to see if there are any new images 100 | try { 101 | const success = await runReplaceImages(Object.values(state.current.images)) 102 | console.log("success:", success) 103 | } catch (err) { 104 | console.error(err) 105 | } 106 | 107 | state.current.isReplacingImages = false 108 | } 109 | 110 | const mainLoop = async () => { 111 | console.log("mainLoop()") 112 | 113 | // update the image index to add new images 114 | await askToScanImages() 115 | 116 | // then we process the queue, X images at a time 117 | // (eg. 4 requests) 118 | 119 | let maxNbRequests = 1 120 | let nbRequests = 0 121 | 122 | state.current.isProcessingImages = true 123 | 124 | state.current.nbProcessedImages = 0 125 | state.current.nbImagesToProcess = 1 126 | 127 | // note: since we are reading the values from an object, 128 | // we want to sort them again 129 | const allIndexedImages = Object.values(state.current.images).sort((a, b) => { 130 | // calculates the total pixels of the image (width * height) 131 | const totalPixelsA = a.width * a.height; 132 | const totalPixelsB = b.width * b.height; 133 | 134 | // sort in descending order 135 | return totalPixelsB - totalPixelsA; 136 | }); 137 | 138 | const unprocessedImages = allIndexedImages.filter(image => 139 | image.status === "unprocessed" 140 | ) 141 | 142 | console.log("debug:", { 143 | "state.current.images:": state.current.images, 144 | allIndexedImages, 145 | unprocessedImages, 146 | }) 147 | 148 | for (let image of unprocessedImages) { 149 | 150 | // console.log(`asking API to replace this b64 image ${image.dataUri.slice(0, 60)}.. (${image.originalUri})`) 151 | image.status = "processing" 152 | 153 | state.current.currentImageProcessingStartedAt = Date.now() 154 | 155 | try { 156 | 157 | const proposedUris = await replaceImage(image.dataUri) 158 | 159 | // console.log(`got ${proposedUris.length} image replacements!`) 160 | 161 | image.proposedUris = [ 162 | ...image.proposedUris, 163 | ...proposedUris, 164 | ] 165 | 166 | image.status = "success" 167 | } catch (err) { 168 | console.log(`failed to replace an image:`, err) 169 | image.status = "failed" 170 | // image.proposedUris = [] // no need I think 171 | } 172 | 173 | 174 | state.current.currentImageProcessingEndedAt = Date.now() 175 | state.current.nbProcessedImages = state.current.nbProcessedImages + 1 176 | 177 | // we temporize a bit, to avoid being falsely throttled by "Google's algorithm" 178 | // (Chrome's DDoS detector gets angry if we do too many requests) 179 | await sleep(3000) 180 | 181 | if (nbRequests++ >= maxNbRequests) { 182 | console.log("reached the max number of images.. self-stopping") 183 | setEnabled(false) 184 | break 185 | } 186 | } 187 | 188 | state.current.isProcessingImages = false 189 | 190 | // try to replace the images 191 | try { 192 | await askToReplaceImages() 193 | } catch (err) { 194 | console.error(`failed to call askToReplaceImages()`) 195 | } 196 | 197 | // finally, we try to scan the page again for any new image 198 | if (state.current.isEnabled) { 199 | setTimeout(async () => { 200 | await mainLoop() 201 | }, 4000) 202 | } 203 | } 204 | useEffect(() => { 205 | if (isEnabled && !state.current.isEnabled) { 206 | state.current.isEnabled = true 207 | mainLoop() 208 | } else if (!isEnabled && state.current.isEnabled) { 209 | state.current.isEnabled = false 210 | } 211 | }, [JSON.stringify(settings)]) 212 | 213 | useEffect(() => { 214 | // we do a bit of reset 215 | clearInterval(state.current.progressInterval) 216 | 217 | if (isEnabled) { 218 | // we reset the counters 219 | state.current.nbProcessedImages = 0 220 | state.current.nbImagesToProcess = 0 221 | 222 | state.current.pageProcessingStartedAt = Date.now() 223 | state.current.currentImageProcessingStartedAt = Date.now() 224 | state.current.currentImageProcessingEndedAt = 0 225 | state.current.pageProcessingEndedAt = 0 226 | 227 | // we reset the circular progress config whenever we start or stop 228 | setCircularProgressConfig({ 229 | ...initialCircularProgressConfig, 230 | id: 0, // we indicate which component we want to change 231 | percent: 0 232 | }) 233 | 234 | state.current.progressInterval = setInterval(() => { 235 | // we compute the progress, which might include multiple phases (based on the nb of images to convert) 236 | 237 | // we use 100% percentage (we could also use seconds) 238 | const maxProgress = 100 239 | 240 | // COARSE ESTIMATOR 241 | // gives a value between 0 and 100 242 | // this will be like 0, 25, 50, 75, 100 etc.. if there are 4 images 243 | const coarseProgress = 244 | (state.current.nbProcessedImages < 1 || state.current.nbImagesToProcess < 1) 245 | ? 0 246 | : Math.max( 247 | 0, 248 | Math.min( 249 | maxProgress, 250 | maxProgress * (state.current.nbProcessedImages / state.current.nbImagesToProcess) 251 | )) 252 | 253 | // gives us how many percent is taken by an image 254 | // eg if 2 images -> 50%, if 3 images then 33.333..% etc 255 | let coarseResolution = 256 | (maxProgress < 1 || state.current.nbProcessedImages < 1) 257 | ? 100 258 | : (maxProgress / state.current.nbProcessedImages) 259 | 260 | // FINE ESTIMATOR 261 | // each image should take ~20 seconds with normal server usage 262 | const expectedTimeSpentOnCurrentImageInMs = 30 * 1000 263 | 264 | const timeSpentOnCurrentImageInMs = Date.now() - state.current.currentImageProcessingStartedAt 265 | 266 | // gives a value between 0 and coarseResolution 267 | // if job is taking longer than expected, then the progress bar will be "stuck" on the coarseResolution 268 | const fineProgress = Math.max(0, Math.min(coarseResolution, coarseResolution * (timeSpentOnCurrentImageInMs / expectedTimeSpentOnCurrentImageInMs))) 269 | 270 | // finally we compute the final progress value 271 | // it is expect that this progress bar gets stuck from tiem to time if the server is busy 272 | const finalProgress = Math.max(0, Math.min(maxProgress, coarseProgress + fineProgress)) 273 | 274 | // uncomment during development if you need to investigate 275 | /* 276 | console.log(`updating progress:`, { 277 | coarseProgress, 278 | coarseResolution, 279 | expectedTimeSpentOnCurrentImageInMs, 280 | timeSpentOnCurrentImageInMs, 281 | fineProgress, 282 | finalProgress, 283 | }) 284 | */ 285 | 286 | setCircularProgressConfig({ 287 | ...initialCircularProgressConfig, 288 | id: 0, // we indicate which component we want to change 289 | percent: finalProgress 290 | }); 291 | }, 500); 292 | } 293 | 294 | return () => clearInterval(state.current.progressInterval); 295 | }, [isEnabled]); 296 | 297 | return ( 298 |
306 | {settings.hasValidCredentials && settings.hasValidBodyModels &&
307 | 308 |
setEnabled(!isEnabled)}> 318 |
{isEnabled ? "Stop" : "Start"}
319 |
{ 320 | Math.round(circularProgressConfig.percent || 0) 321 | }%
322 |
323 |
324 |
} 325 | {(!settings.hasValidCredentials || !settings.hasValidBodyModels) &&
{ 327 | chrome.runtime.openOptionsPage() 328 | }} 329 | className="text-sm text-gray-300 italic hover:underline cursor-pointer"> 330 | { 331 | (!settings.hasValidCredentials && settings.hasValidBodyModels) 332 | ? 'Click here to finish the setup and provide pictures of yourself.' : 333 | (settings.hasValidCredentials && !settings.hasValidBodyModels) 334 | ? 'Click here to finish the setup and configure the service provider.' : 335 | 'Click here to configure the service provider and provide pictures of yourself.' 336 | } 337 |
} 338 |
339 | Note: closing this panel will stop
any pending image generation. 340 |
341 |
342 | ); 343 | } 344 | 345 | ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( 346 | 347 | 348 | 349 | ); 350 | -------------------------------------------------------------------------------- /src/providers/huggingface.ts: -------------------------------------------------------------------------------- 1 | import { ImageReplacer, ImageSegmenter, Settings } from "@/types" 2 | import { generateSeed } from "@/utils" 3 | import { getDefaultSettings } from "@/utils/getDefaultSettings" 4 | 5 | export const replaceImage: ImageReplacer = async (garmentImage) => { 6 | const settings = await chrome.storage.local.get(getDefaultSettings()) as Settings 7 | 8 | if (settings.engine !== "DEFAULT" && settings.engine !== "GRADIO_API") { 9 | throw new Error(`replaceImage(): can only be used with the DEFAULT or GRADIO_API engine`) 10 | } 11 | 12 | // TODO: detect the type of image: 13 | // lone garment? 14 | // no garment at all? 15 | // upper body or full body? 16 | // has a human model, so it needs segmentation or not? 17 | 18 | const modelImage = settings.fullBodyModelImage 19 | if (!modelImage) { 20 | throw new Error(`replaceImage(): the modelImage appears invalid`) 21 | } 22 | 23 | const modelMaskImage = settings.fullBodyModelMaskImage 24 | if (!modelMaskImage) { 25 | throw new Error(`replaceImage(): the modelMaskImage appears invalid`) 26 | } 27 | 28 | if (!garmentImage) { 29 | throw new Error(`replaceImage(): the garmentImage appears invalid`) 30 | } 31 | 32 | const apiKey = 33 | settings.engine === "GRADIO_API" 34 | ? settings.customGradioApiKey 35 | : settings.huggingfaceApiKey 36 | 37 | if (!apiKey) { 38 | throw new Error(`replaceImage(): the apiKey appears invalid`) 39 | } 40 | 41 | const numberOfSteps = 42 | settings.engine === "GRADIO_API" 43 | ? settings.customGradioApiNumberOfSteps 44 | : settings.huggingfaceNumberOfSteps 45 | 46 | const guidanceScale = 47 | settings.engine === "GRADIO_API" 48 | ? settings.customGradioApiGuidanceScale 49 | : settings.huggingfaceGuidanceScale 50 | 51 | const substitutionSpaceUrl = 52 | settings.engine === "GRADIO_API" 53 | ? settings.customGradioApiSubstitutionSpaceUrl 54 | : settings.huggingfaceSubstitutionSpaceUrl 55 | 56 | if (!substitutionSpaceUrl) { 57 | throw new Error(`replaceImage(): the substitutionSpaceUrl appears invalid`) 58 | } 59 | 60 | const seed = generateSeed() 61 | 62 | // we had to fork the oot server to make this possible, but this is worth it imho 63 | const nbSamples = 1 64 | 65 | const gradioUrl = substitutionSpaceUrl + (substitutionSpaceUrl.endsWith("/") ? "" : "/") + "api/predict" 66 | 67 | const params = { 68 | fn_index: 0, // <- important! 69 | data: [ 70 | modelImage, 71 | garmentImage, 72 | modelMaskImage, 73 | numberOfSteps, 74 | guidanceScale, 75 | seed, 76 | nbSamples, 77 | ] 78 | } 79 | 80 | console.log(`replaceImage(): calling fetch ${gradioUrl} with`, params) 81 | const res = await fetch(gradioUrl, { 82 | method: "POST", 83 | headers: { 84 | "Content-Type": "application/json", 85 | Authorization: `Bearer ${apiKey}`, 86 | }, 87 | body: JSON.stringify(params), 88 | cache: "no-store", 89 | }) 90 | 91 | const { data } = await res.json() 92 | 93 | if (res.status !== 200 || !Array.isArray(data)) { 94 | // This will activate the closest `error.js` Error Boundary 95 | throw new Error(`Failed to fetch data (status: ${res.status})`) 96 | } 97 | 98 | if (!data[0]) { 99 | throw new Error(`the returned image was empty`) 100 | } 101 | // console.log(`replaceImage:(): data = `, data) 102 | // we make sure to return only the strings 103 | return (data.filter(x => typeof x === "string" && x.length > 30)) as string[] 104 | } 105 | 106 | export const segmentImage: ImageSegmenter = async (modelImage) => { 107 | const settings = await chrome.storage.local.get(getDefaultSettings()) as Settings 108 | 109 | if (settings.engine !== "DEFAULT" && settings.engine !== "GRADIO_API") { 110 | throw new Error(`segmentImage(): can only be used with the DEFAULT or GRADIO_API engine`) 111 | } 112 | 113 | if (!modelImage) { 114 | throw new Error(`segmentImage(): the modelImage appears invalid`) 115 | } 116 | 117 | const apiKey = 118 | settings.engine === "GRADIO_API" 119 | ? settings.customGradioApiKey 120 | : settings.huggingfaceApiKey 121 | 122 | if (!apiKey) { 123 | throw new Error(`replaceImage(): the apiKey appears invalid`) 124 | } 125 | const segmentationSpaceUrl = 126 | settings.engine === "GRADIO_API" 127 | ? settings.customGradioApiSegmentationSpaceUrl 128 | : settings.huggingfaceSegmentationSpaceUrl 129 | 130 | 131 | if (!segmentationSpaceUrl) { 132 | throw new Error(`segmentImage(): the segmentationSpaceUrl appears invalid`) 133 | } 134 | 135 | const gradioUrl = segmentationSpaceUrl + (segmentationSpaceUrl.endsWith("/") ? "" : "/") + "api/predict" 136 | 137 | const params = { 138 | fn_index: 0, // <- important! 139 | data: [ 140 | modelImage 141 | ] 142 | } 143 | 144 | //console.log(`segmentImage(): calling fetch(${gradioUrl}, ${JSON.stringify(params, null, 2)})`) 145 | 146 | const res = await fetch(gradioUrl, { 147 | method: "POST", 148 | headers: { 149 | "Content-Type": "application/json", 150 | Authorization: `Bearer ${apiKey}`, 151 | }, 152 | body: JSON.stringify(params), 153 | cache: "no-store", 154 | }) 155 | 156 | const { data } = await res.json() 157 | 158 | if (res.status !== 200 || !Array.isArray(data)) { 159 | // This will activate the closest `error.js` Error Boundary 160 | throw new Error(`Failed to fetch data (status: ${res.status})`) 161 | } 162 | 163 | if (!data[0]) { 164 | throw new Error(`the returned image was empty`) 165 | } 166 | 167 | const { 168 | face_mask, 169 | mask, 170 | model_mask, 171 | model_parse, 172 | original_image 173 | } = data[0] as { 174 | face_mask: string 175 | mask: string 176 | 177 | // this represents the original image, minus the garment (which will become gray) 178 | model_mask: string 179 | model_parse: string 180 | original_image: string 181 | } 182 | 183 | return mask 184 | } -------------------------------------------------------------------------------- /src/providers/replicate.ts: -------------------------------------------------------------------------------- 1 | import { ImageReplacer, ImageSegmenter, PredictionReplaceImageWithReplicate, Settings } from "@/types" 2 | import { generateSeed, sleep } from "@/utils" 3 | import { getDefaultSettings } from "@/utils/getDefaultSettings" 4 | 5 | 6 | export const segmentImage: ImageSegmenter = async (modelImage) => { 7 | const settings = await chrome.storage.local.get(getDefaultSettings()) as Settings 8 | 9 | if (settings.engine !== "REPLICATE") { 10 | throw new Error(`segmentImage(): can only be used with the REPLICATE engine`) 11 | } 12 | 13 | if (!modelImage) { 14 | throw new Error(`segmentImage(): the modelImage appears invalid`) 15 | } 16 | 17 | if (!settings.replicateApiKey) { 18 | throw new Error(`segmentImage(): the replicateApiKey appears invalid`) 19 | } 20 | 21 | if (!settings.replicateSegmentationModel) { 22 | throw new Error(`segmentImage(): the replicateSegmentationModel appears invalid`) 23 | } 24 | 25 | if (!settings.replicateSegmentationModelVersion) { 26 | throw new Error(`segmentImage(): the replicateSegmentationModelVersion appears invalid`) 27 | } 28 | 29 | if (!settings.replicateSubstitutionModel) { 30 | throw new Error(`segmentImage(): the replicateSubstitutionModel appears invalid`) 31 | } 32 | 33 | if (!settings.replicateSubstitutionModelVersion) { 34 | throw new Error(`segmentImage(): the replicateSubstitutionModelVersion appears invalid`) 35 | } 36 | 37 | const response = await fetch("https://api.replicate.com/v1/predictions", { 38 | method: "POST", 39 | headers: { 40 | "Authorization": `Token ${settings.replicateApiKey}`, 41 | "Content-Type": "application/json", 42 | }, 43 | body: JSON.stringify({ 44 | version: settings.replicateSegmentationModelVersion, 45 | input: { 46 | model_image: modelImage, 47 | }, 48 | }), 49 | }); 50 | 51 | if (!response.ok) { 52 | throw new Error(`HTTP error! status: ${response.status}`); 53 | } 54 | 55 | const data = await response.json() 56 | const unresolvedPrediction = data as PredictionReplaceImageWithReplicate 57 | 58 | let pollingCount = 0 59 | do { 60 | await sleep(4000) 61 | console.log("segmentImage(): polling Replicate..") 62 | 63 | const response = await fetch(unresolvedPrediction.urls.get, { 64 | method: "GET", 65 | headers: { 66 | "Authorization": `Token ${settings.replicateApiKey}`, 67 | "Content-Type": "application/json", 68 | }, 69 | }); 70 | 71 | if (!response.ok) { 72 | throw new Error(`HTTP error! status: ${response.status}`); 73 | } 74 | 75 | const resolvedPrediction = (await response.json()) as PredictionReplaceImageWithReplicate 76 | 77 | if ( 78 | resolvedPrediction.status === "starting" || 79 | resolvedPrediction.status === "processing" 80 | ) { 81 | console.log("segmentImage(): Replicate is still busy.. maybe it is warming-up") 82 | } else if ( 83 | resolvedPrediction.status === "failed" || 84 | resolvedPrediction.status === "canceled" 85 | ) { 86 | throw new Error(`Failed to call Replicate: ${resolvedPrediction.logs || ""}`) 87 | } else if ( 88 | resolvedPrediction.status === "succeeded" 89 | ) { 90 | return typeof resolvedPrediction.output === "string" ? resolvedPrediction.output : "" 91 | } 92 | 93 | pollingCount++ 94 | 95 | // To prevent indefinite polling, we can stop after a certain number 96 | if (pollingCount >= 40) { 97 | throw new Error('Replicate request timed out.') 98 | } 99 | } while (true) 100 | } 101 | 102 | export const replaceImage: ImageReplacer = async (garmentImage) => { 103 | const settings = await chrome.storage.local.get(getDefaultSettings()) as Settings 104 | 105 | if (settings.engine !== "REPLICATE") { 106 | throw new Error(`replaceImage(): can only be used with the REPLICATE engine`) 107 | } 108 | 109 | if (!garmentImage) { 110 | throw new Error(`replaceImage(): the garmentImage appears invalid`) 111 | } 112 | 113 | const modelImage = settings.fullBodyModelImage 114 | if (!modelImage) { 115 | throw new Error(`replaceImage(): the modelImage appears invalid`) 116 | } 117 | 118 | const modelMaskImage = settings.fullBodyModelMaskImage 119 | if (!modelMaskImage) { 120 | throw new Error(`replaceImage(): the modelMaskImage appears invalid`) 121 | } 122 | 123 | if (!settings.replicateApiKey) { 124 | throw new Error(`replaceImage(): the replicateApiKey appears invalid`) 125 | } 126 | 127 | if (!settings.replicateSegmentationModel) { 128 | throw new Error(`replaceImage(): the replicateSegmentationModel appears invalid`) 129 | } 130 | 131 | if (!settings.replicateSegmentationModelVersion) { 132 | throw new Error(`replaceImage(): the replicateSegmentationModelVersion appears invalid`) 133 | } 134 | 135 | if (!settings.replicateSubstitutionModel) { 136 | throw new Error(`replaceImage(): the replicateSubstitutionModel appears invalid`) 137 | } 138 | 139 | if (!settings.replicateSubstitutionModelVersion) { 140 | throw new Error(`replaceImage(): the replicateSubstitutionModelVersion appears invalid`) 141 | } 142 | 143 | const response = await fetch("https://api.replicate.com/v1/predictions", { 144 | method: "POST", 145 | headers: { 146 | "Authorization": `Token ${settings.replicateApiKey}`, 147 | "Content-Type": "application/json", 148 | }, 149 | body: JSON.stringify({ 150 | version: settings.replicateSubstitutionModel, 151 | input: { 152 | model_image: modelImage, 153 | garment_image: garmentImage, 154 | person_mask: modelMaskImage, 155 | steps: settings.replicateNumberOfSteps, 156 | guidance_scale: settings.replicateGuidanceScale, 157 | seed: generateSeed() 158 | }, 159 | }), 160 | }); 161 | 162 | if (!response.ok) { 163 | throw new Error(`HTTP error! status: ${response.status}`); 164 | } 165 | 166 | const data = await response.json() 167 | const unresolvedPrediction = data as PredictionReplaceImageWithReplicate 168 | 169 | let pollingCount = 0 170 | do { 171 | await sleep(4000) 172 | console.log(`replaceImage(): polling Replicate..`) 173 | 174 | const response = await fetch(unresolvedPrediction.urls.get, { 175 | method: "GET", 176 | headers: { 177 | "Authorization": `Token ${settings.replicateApiKey}`, 178 | "Content-Type": "application/json", 179 | }, 180 | body: JSON.stringify({ 181 | version: settings.replicateSubstitutionModelVersion, 182 | }), 183 | }); 184 | 185 | if (!response.ok) { 186 | throw new Error(`HTTP error! status: ${response.status}`); 187 | } 188 | 189 | const resolvedPrediction = (await response.json()) as PredictionReplaceImageWithReplicate 190 | 191 | if ( 192 | resolvedPrediction.status === "starting" || 193 | resolvedPrediction.status === "processing" 194 | ) { 195 | console.log("replaceImage(): Replicate is still busy.. maybe it is warming-up") 196 | } else if ( 197 | resolvedPrediction.status === "failed" || 198 | resolvedPrediction.status === "canceled" 199 | ) { 200 | throw new Error(`Failed to call Replicate: ${resolvedPrediction.logs || ""}`) 201 | } else if ( 202 | resolvedPrediction.status === "succeeded" 203 | ) { 204 | return Array.isArray(resolvedPrediction.output) ? resolvedPrediction.output : [] 205 | } 206 | 207 | pollingCount++ 208 | 209 | // To prevent indefinite polling, we can stop after a certain number 210 | if (pollingCount >= 40) { 211 | throw new Error('Replicate request timed out.') 212 | } 213 | } while (true) 214 | } -------------------------------------------------------------------------------- /src/react-circular-progress-bar.d.ts: -------------------------------------------------------------------------------- 1 | declare module "@tomickigrzegorz/react-circular-progress-bar" { 2 | import { ReactNode } from "react"; 3 | 4 | export interface CircularProgressBarProps { 5 | percent: number; 6 | id?: number; 7 | speed?: number; 8 | colorSlice?: string; 9 | colorCircle?: string; 10 | stroke?: number; 11 | strokeBottom?: number; 12 | round?: boolean; 13 | inverse?: boolean; 14 | rotation?: number; 15 | number?: boolean; 16 | size?: number; 17 | cut?: number; 18 | unit?: string; 19 | fill?: string; 20 | strokeDasharray?: string; 21 | fontWeight?: number | string; 22 | fontSize?: string; 23 | fontColor?: string; 24 | animationOff?: boolean; 25 | styles?: React.CSSProperties; 26 | linearGradient?: string[]; 27 | textPosition?: string; 28 | animationSmooth?: string; 29 | children?: ReactNode; 30 | } 31 | 32 | const CircularProgressBar: React.ComponentType; 33 | 34 | export { CircularProgressBar }; 35 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type Engine = 2 | | "DEFAULT" // default engine 3 | | "GRADIO_API" // url to local or remote gradio spaces 4 | | "REPLICATE" // url to replicate api(s) 5 | 6 | export type SettingsSaveStatus = 7 | | "idle" 8 | | "saving" 9 | | "saved" 10 | 11 | export type ImageStatus = 12 | | "invalid" 13 | | "unprocessed" 14 | | "processing" 15 | | "success" 16 | | "failed" 17 | 18 | export type WorkerMessage = 19 | | "ENABLE" 20 | | "DISABLE" 21 | | "SCAN_IMAGES" 22 | | "SCANNED_IMAGES" 23 | | "REPLACE_IMAGES" 24 | | "SUCCESS" 25 | | "RESET" 26 | | "UNKNOWN" 27 | 28 | export type ImageURL = { 29 | originalUri: string 30 | dataUri: string 31 | width: number 32 | height: number 33 | goodCandidate: boolean 34 | status: ImageStatus 35 | 36 | // we often generate 4 variants 37 | proposedUris: string[] 38 | } 39 | 40 | export type ImageSegmenter = (modelImage: string) => Promise 41 | export type ImageReplacer = (garmentImage: string) => Promise 42 | 43 | export type ReplaceImageWithReplicate = { 44 | seed: number; 45 | steps: number; 46 | model_image: string; 47 | garment_image: string; 48 | guidance_scale: number; 49 | }; 50 | 51 | export type PredictionReplaceImageWithReplicate = { 52 | id: string 53 | model: string 54 | version: string 55 | input: ReplaceImageWithReplicate 56 | logs: string 57 | error?: string 58 | output?: string[] 59 | status: 60 | | "starting" 61 | | "processing" 62 | | "failed" 63 | | "canceled" 64 | | "succeeded" 65 | created_at: string 66 | urls: { 67 | cancel: string 68 | get: string 69 | } 70 | } 71 | 72 | export type Settings = { 73 | // DEFAULT: default engine 74 | // GRADIO_API: url to local or remote gradio spaces 75 | // REPLICATE: url to replicate api(s) 76 | engine: Engine 77 | 78 | // --------------- HUGGING FACE ---------------------------- 79 | 80 | // api key of the Hugging Face account 81 | huggingfaceApiKey: string 82 | 83 | // url of the Hugging Face Space for segmentation (Gradio API) 84 | huggingfaceSegmentationSpaceUrl: string 85 | 86 | // url of the Hugging Face Space for substitution (Gradio API) 87 | huggingfaceSubstitutionSpaceUrl: string 88 | 89 | // Number of steps for the Huging Face model 90 | huggingfaceNumberOfSteps: number 91 | 92 | // Guidance scale for the Hugging Face model 93 | huggingfaceGuidanceScale: number 94 | 95 | 96 | // --------------- REPLICATE-- ---------------------------- 97 | 98 | // Replicate.com api key 99 | replicateApiKey: string 100 | 101 | // replicate model name 102 | replicateSegmentationModel: string 103 | 104 | // Replicate model version 105 | replicateSegmentationModelVersion: string 106 | 107 | // replicate model name 108 | replicateSubstitutionModel: string 109 | 110 | // Replicate model version 111 | replicateSubstitutionModelVersion: string 112 | 113 | // Number of steps for the Replicate model 114 | replicateNumberOfSteps: number 115 | 116 | // Guidance scale for the Replicate model 117 | replicateGuidanceScale: number 118 | 119 | 120 | // --------------- LOCAL SERVER --------------------------- 121 | 122 | // optional api key in case local usage (eg. for privacy or development purposes) 123 | customGradioApiKey: string 124 | 125 | // url of the Hugging Face Space for segmentation (Gradio API) 126 | customGradioApiSegmentationSpaceUrl: string 127 | 128 | // url of the Hugging Face Space for substitution (Gradio API) 129 | customGradioApiSubstitutionSpaceUrl: string 130 | 131 | // Number of steps for the local model 132 | customGradioApiNumberOfSteps: number 133 | 134 | // Guidance scale for the local model 135 | customGradioApiGuidanceScale: number 136 | 137 | upperBodyModelImage: string 138 | upperBodyModelMaskImage: string 139 | 140 | fullBodyModelImage: string 141 | fullBodyModelMaskImage: string 142 | 143 | // DEPRECATED to enable or disable the substitution 144 | // isEnabled: boolean 145 | } -------------------------------------------------------------------------------- /src/utils/cn.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /src/utils/convertBase64.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Convert a base 64 data uri image (whcih can be in any format) 3 | * to a base64 (jpeg) 4 | * 5 | * @param originalImageBase64 6 | * @param desiredFormat 7 | * @returns 8 | */ 9 | export async function convertBase64( 10 | originalImageBase64: string, 11 | format = "image/jpeg", 12 | quality = 0.97 13 | ): Promise { 14 | return new Promise((resolve, reject) => { 15 | // Creating new image object 16 | const img = new Image(); 17 | // Setting source of the image as base64 string 18 | img.src = originalImageBase64; 19 | 20 | img.onload = function() { 21 | let outputBase64 = "" 22 | try { 23 | // Creating canvas and getting context 24 | const canvas = document.createElement('canvas'); 25 | const ctx = canvas.getContext('2d'); 26 | 27 | if (!ctx) { 28 | throw new Error(`cannot acquire 2D context`) 29 | } 30 | 31 | const dpr = window.devicePixelRatio || 1; 32 | // Applying DPR to the canvas size 33 | canvas.width = img.width * dpr; 34 | canvas.height = img.height * dpr; 35 | 36 | // Set the CSS size to the original image size 37 | canvas.style.width = `${img.width}px`; 38 | canvas.style.height = `${img.height}px`; 39 | 40 | ctx.scale(dpr, dpr); 41 | 42 | // Drawing the image into the canvas 43 | ctx.drawImage(img, 0, 0); 44 | 45 | // Converting the canvas data to base64 and resolving the promise 46 | outputBase64 = canvas.toDataURL(format, quality); 47 | } catch (err) { 48 | const errorMessage = `failed to convert input image to base64 ${format}: ${err}` 49 | reject(new Error(errorMessage)) 50 | return 51 | } 52 | 53 | resolve(outputBase64); 54 | }; 55 | 56 | img.onerror = function(err) { 57 | reject(`Error while loading the image: ${err}`); 58 | }; 59 | }); 60 | } -------------------------------------------------------------------------------- /src/utils/downloadImageToBase64.ts: -------------------------------------------------------------------------------- 1 | import { convertBase64 } from "./convertBase64"; 2 | 3 | export async function downloadImageToBase64(url: string, format = "image/jpeg", quality = 0.97): Promise { 4 | const response = await fetch(url) 5 | const blob = await response.blob() 6 | return new Promise((resolve, reject) => { 7 | const fileReader = new FileReader(); 8 | fileReader.readAsDataURL(blob); 9 | fileReader.onload = async () => { 10 | let base64Jpeg = "" 11 | try { 12 | // e-commerce websites supports a variety of format, 13 | // like webp or avif 14 | // for our own sanity, we convert everything to one format (jpeg) 15 | const base64InUnknownFormat = `${fileReader.result}` 16 | base64Jpeg = await convertBase64(base64InUnknownFormat) 17 | 18 | if (base64Jpeg.length < 256) { throw new Error(`the base64 data uri looks invalid`) } 19 | } catch (err) { 20 | const errorMessage = `Error: failed to convert ${url} to ${format}: ${err}` 21 | reject(new Error(errorMessage)) 22 | return 23 | } 24 | resolve(base64Jpeg) 25 | }; 26 | fileReader.onerror = (error) => { reject(error); }; 27 | }); 28 | } -------------------------------------------------------------------------------- /src/utils/elementIsVisible.ts: -------------------------------------------------------------------------------- 1 | export function elementIsVisible(elem: HTMLImageElement) { 2 | if (!(elem instanceof Element)) throw Error('DomUtil: elem is not an element.'); 3 | const style = getComputedStyle(elem); 4 | if (style.display === 'none') return false; 5 | if (style.visibility !== 'visible') return false; 6 | 7 | if (style.opacity === "inherit" || style.opacity === "initial" || style.opacity === "unset") { 8 | // undetermined 9 | } else if (Number(style.opacity || 0) < 0.1) { 10 | return false; 11 | } 12 | 13 | if (elem.offsetWidth + elem.offsetHeight + elem.getBoundingClientRect().height + 14 | elem.getBoundingClientRect().width === 0) { 15 | return false; 16 | } 17 | 18 | const elemCenter = { 19 | x: elem.getBoundingClientRect().left + elem.offsetWidth / 2, 20 | y: elem.getBoundingClientRect().top + elem.offsetHeight / 2 21 | }; 22 | if (elemCenter.x < 0) return false; 23 | if (elemCenter.x > (document.documentElement.clientWidth || window.innerWidth)) return false; 24 | if (elemCenter.y < 0) return false; 25 | if (elemCenter.y > (document.documentElement.clientHeight || window.innerHeight)) return false; 26 | let pointContainer = document.elementFromPoint(elemCenter.x, elemCenter.y) as any; 27 | do { 28 | if (pointContainer === elem) return true; 29 | } while (pointContainer = pointContainer.parentNode); 30 | return false; 31 | } -------------------------------------------------------------------------------- /src/utils/elementIsVisibleLegacy.ts: -------------------------------------------------------------------------------- 1 | export function elementIsVisible(elem: HTMLElement) { 2 | let bounding = elem.getBoundingClientRect(); 3 | return ( 4 | bounding.top >= 0 && 5 | bounding.left >= 0 && 6 | bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && 7 | bounding.right <= (window.innerWidth || document.documentElement.clientWidth) 8 | ); 9 | } -------------------------------------------------------------------------------- /src/utils/fileToBase64.ts: -------------------------------------------------------------------------------- 1 | export function fileToBase64(file: File | Blob): Promise { 2 | return new Promise((resolve, reject) => { 3 | const fileReader = new FileReader(); 4 | fileReader.readAsDataURL(file); 5 | fileReader.onload = () => { resolve(`${fileReader.result}`); }; 6 | fileReader.onerror = (error) => { reject(error); }; 7 | }); 8 | } -------------------------------------------------------------------------------- /src/utils/generateSeed.ts: -------------------------------------------------------------------------------- 1 | export function generateSeed() { 2 | return Math.floor(Math.random() * Math.pow(2, 31)); 3 | } -------------------------------------------------------------------------------- /src/utils/getDefaultSettings.ts: -------------------------------------------------------------------------------- 1 | import { Settings } from "@/types"; 2 | 3 | export function getDefaultSettings(): Settings { 4 | 5 | return { 6 | // DEFAULT: default engine 7 | // GRADIO_API: url to local or remote gradio spaces 8 | // REPLICATE: url to replicate api(s) 9 | engine: "DEFAULT", 10 | 11 | // api key of the Hugging Face account 12 | huggingfaceApiKey: "", 13 | 14 | // url of the Hugging Face Space for segmentation (Gradio API) 15 | huggingfaceSegmentationSpaceUrl: "https://jbilcke-hf-oot-segmentation.hf.space", 16 | 17 | // url of the Hugging Face Space for substitution (Gradio API) 18 | huggingfaceSubstitutionSpaceUrl: "https://jbilcke-hf-oot-diffusion-with-mask.hf.space", 19 | 20 | // Number of steps for the Hugging Face model 21 | huggingfaceNumberOfSteps: 20, 22 | 23 | // Guidance scale for the Hugging Face model 24 | huggingfaceGuidanceScale: 2, 25 | 26 | 27 | // Replicate.com api key 28 | replicateApiKey: "", 29 | 30 | // replicate model name 31 | replicateSegmentationModel: "viktorfa/oot_segmentation", 32 | 33 | // Replicate model version 34 | replicateSegmentationModelVersion: "029c7a3275615693983f1186a94d3c02a5a46750a763e5deb30c1b608b7c3003", 35 | 36 | // replicate model name 37 | replicateSubstitutionModel: "viktorfa/oot_diffusion_with_mask", 38 | 39 | // Replicate model version 40 | replicateSubstitutionModelVersion: "c890e02d8180bde7eeed1a138217ee154d8cdd8769a29f02bd51fea33d268385", 41 | 42 | // Number of steps for the Replicate model 43 | replicateNumberOfSteps: 20, 44 | 45 | // Guidance scale for the Replicate model 46 | replicateGuidanceScale: 2, 47 | 48 | // api key for local usage (eg. for privacy or development purposes) 49 | customGradioApiKey: "", 50 | 51 | // url of the Hugging Face Space for segmentation (Gradio API) 52 | customGradioApiSegmentationSpaceUrl: "http://localhost:7860", 53 | 54 | // url of the local API for substitution (Gradio API) 55 | customGradioApiSubstitutionSpaceUrl: "http://localhost:7861", 56 | 57 | // Number of steps for the local model 58 | customGradioApiNumberOfSteps: 20, 59 | 60 | // Guidance scale f or the local model 61 | customGradioApiGuidanceScale: 2, 62 | 63 | upperBodyModelImage: "", 64 | upperBodyModelMaskImage: "", 65 | 66 | fullBodyModelImage: "", 67 | fullBodyModelMaskImage: "", 68 | 69 | // DEPRECATED: to enable or disable the substitution 70 | // isEnabled: false, 71 | } 72 | } -------------------------------------------------------------------------------- /src/utils/getImageDimension.ts: -------------------------------------------------------------------------------- 1 | export interface ImageDimension { 2 | width: number 3 | height: number 4 | } 5 | 6 | export async function getImageDimension(src: string): Promise { 7 | if (!src) { 8 | return { width: 0, height: 0 } 9 | } 10 | const img = new Image() 11 | img.src = src 12 | await img.decode() 13 | const width = img.width 14 | const height = img.height 15 | return { width, height } 16 | } -------------------------------------------------------------------------------- /src/utils/getVisibleImages.ts: -------------------------------------------------------------------------------- 1 | import { elementIsVisible } from "./elementIsVisible" 2 | 3 | export function getVisibleImages(): HTMLImageElement[] { 4 | // document.getElementsByTagName("img") 5 | return Array.from(document.images) 6 | .filter((img) => elementIsVisible(img)) 7 | .sort((a, b) => { 8 | const areaA = a.clientWidth * a.clientHeight; 9 | const areaB = b.clientWidth * b.clientHeight; 10 | 11 | if (areaA === areaB) { 12 | return a.getBoundingClientRect().top - b.getBoundingClientRect().top; 13 | } 14 | 15 | return areaB - areaA; // For getting biggest first. 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/utils/imageToBase64.ts: -------------------------------------------------------------------------------- 1 | 2 | export function imageToBase64( 3 | img: HTMLImageElement, 4 | format = "image/jpeg" // could also be image/png I guess 5 | ) { 6 | var canvas = document.createElement("canvas") 7 | 8 | canvas.width = img.width 9 | canvas.height = img.height 10 | 11 | var ctx = canvas.getContext("2d") 12 | 13 | ctx!.drawImage(img, 0, 0) 14 | 15 | return canvas.toDataURL(format) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./cn" 2 | export * from "./generateSeed" 3 | export * from "./sleep" -------------------------------------------------------------------------------- /src/utils/runReplaceImages.ts: -------------------------------------------------------------------------------- 1 | import { ImageURL, WorkerMessage } from "../types" 2 | import { sendMessage } from "./sendMessage" 3 | 4 | export async function runReplaceImages(images: ImageURL[]): Promise { 5 | const result = await sendMessage<{ 6 | action: WorkerMessage 7 | images: ImageURL[] 8 | }, boolean>({ 9 | action: "REPLACE_IMAGES" as WorkerMessage, 10 | images, 11 | }) 12 | return result 13 | } 14 | -------------------------------------------------------------------------------- /src/utils/runScanImages.ts: -------------------------------------------------------------------------------- 1 | import { ImageURL, WorkerMessage } from "../types" 2 | import { sendMessage } from "./sendMessage" 3 | 4 | export async function runScanImages(): Promise { 5 | console.log(`runScanImages()`) 6 | const images = await sendMessage<{ 7 | action: WorkerMessage 8 | }, ImageURL[]>({ 9 | action: "SCAN_IMAGES" as WorkerMessage, 10 | }) 11 | return Array.isArray(images) ? images : [] 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/sendMessage.ts: -------------------------------------------------------------------------------- 1 | /* 2 | note: maybe we should use this instead: 3 | https://developer.chrome.com/docs/extensions/develop/concepts/messaging?hl=fr 4 | 5 | var port = chrome.runtime.connect({name: "knockknock"}); 6 | port.postMessage({joke: "Knock knock"}); 7 | port.onMessage.addListener(function(msg) { 8 | if (msg.question === "Who's there?") 9 | port.postMessage({answer: "Madame"}); 10 | else if (msg.question === "Madame who?") 11 | port.postMessage({answer: "Madame... Bovary"}); 12 | }); 13 | */ 14 | 15 | export async function sendMessage(data: T): Promise { 16 | return new Promise((resolve, reject) => { 17 | chrome.tabs.query({ active: true, currentWindow: true }, async function (tabs) { 18 | const tab = tabs[0] 19 | 20 | if (tab.id) { 21 | const result = await chrome.tabs.sendMessage( 22 | tab.id, 23 | data 24 | ) 25 | console.log("sendMessage: got a result!", result) 26 | resolve(result) 27 | } 28 | }) 29 | }) 30 | } -------------------------------------------------------------------------------- /src/utils/sleep.ts: -------------------------------------------------------------------------------- 1 | export const sleep = async (durationInMs: number) => 2 | new Promise((resolve) => { 3 | setTimeout(() => { 4 | resolve(true) 5 | }, durationInMs) 6 | }) -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { fontFamily } = require("tailwindcss/defaultTheme") 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | module.exports = { 5 | darkMode: ["class"], 6 | content: [ 7 | './index.html', 8 | './options.html', 9 | './src/**/*.{js,jsx,ts,tsx}' 10 | ], 11 | theme: { 12 | container: { 13 | center: true, 14 | padding: "2rem", 15 | screens: { 16 | "2xl": "1400px", 17 | }, 18 | }, 19 | extend: { 20 | colors: { 21 | border: "hsl(var(--border))", 22 | input: "hsl(var(--input))", 23 | ring: "hsl(var(--ring))", 24 | background: "hsl(var(--background))", 25 | foreground: "hsl(var(--foreground))", 26 | primary: { 27 | DEFAULT: "hsl(var(--primary))", 28 | foreground: "hsl(var(--primary-foreground))", 29 | }, 30 | secondary: { 31 | DEFAULT: "hsl(var(--secondary))", 32 | foreground: "hsl(var(--secondary-foreground))", 33 | }, 34 | destructive: { 35 | DEFAULT: "hsl(var(--destructive))", 36 | foreground: "hsl(var(--destructive-foreground))", 37 | }, 38 | muted: { 39 | DEFAULT: "hsl(var(--muted))", 40 | foreground: "hsl(var(--muted-foreground))", 41 | }, 42 | accent: { 43 | DEFAULT: "hsl(var(--accent))", 44 | foreground: "hsl(var(--accent-foreground))", 45 | }, 46 | popover: { 47 | DEFAULT: "hsl(var(--popover))", 48 | foreground: "hsl(var(--popover-foreground))", 49 | }, 50 | card: { 51 | DEFAULT: "hsl(var(--card))", 52 | foreground: "hsl(var(--card-foreground))", 53 | }, 54 | }, 55 | borderRadius: { 56 | lg: `var(--radius)`, 57 | md: `calc(var(--radius) - 2px)`, 58 | sm: "calc(var(--radius) - 4px)", 59 | }, 60 | fontFamily: { 61 | sans: ["var(--font-sans)", ...fontFamily.sans], 62 | }, 63 | keyframes: { 64 | "accordion-down": { 65 | from: { height: "0" }, 66 | to: { height: "var(--radix-accordion-content-height)" }, 67 | }, 68 | "accordion-up": { 69 | from: { height: "var(--radix-accordion-content-height)" }, 70 | to: { height: "0" }, 71 | }, 72 | }, 73 | animation: { 74 | "accordion-down": "accordion-down 0.2s ease-out", 75 | "accordion-up": "accordion-up 0.2s ease-out", 76 | }, 77 | }, 78 | }, 79 | plugins: [require("tailwindcss-animate")], 80 | } 81 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true, 7 | "target": "ESNext", 8 | "useDefineForClassFields": true, 9 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 10 | "allowJs": false, 11 | "skipLibCheck": true, 12 | "esModuleInterop": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "jsx": "react-jsx" , 19 | "baseUrl": ".", 20 | "paths": { 21 | "@/*": [ 22 | "./src/*" 23 | ] 24 | } 25 | }, 26 | "include": ["src", "vite.config.ts", "*.json"] 27 | } 28 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from "path" 2 | import { defineConfig } from "vite"; 3 | // import tailwindcss from 'tailwindcss'; 4 | import react from "@vitejs/plugin-react"; 5 | import { crx, ManifestV3Export } from "@crxjs/vite-plugin"; 6 | import manifest from "./manifest.json"; 7 | import svgr from "vite-plugin-svgr"; 8 | 9 | export default defineConfig({ 10 | build: { 11 | // right now we disable minification to make it easier to debug what's happening 12 | minify: false, 13 | }, 14 | plugins: [ 15 | svgr(), 16 | react(), 17 | crx({ manifest: manifest as unknown as ManifestV3Export }), 18 | ], 19 | resolve: { 20 | alias: { 21 | "@": path.resolve(__dirname, "./src"), 22 | }, 23 | }, 24 | }); 25 | --------------------------------------------------------------------------------