├── .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 |
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.
{
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 |
--------------------------------------------------------------------------------