Spaces:
Running
Running
Upload 9 files
Browse files- Dockerfile +45 -0
- requirements.txt +4 -0
- static/css/style.css +466 -0
- static/img/placeholder.png +0 -0
- static/js/script.js +278 -0
- templates/index.html +77 -0
- upload/readme.md +4 -0
- uploads/readme.md +4 -0
- vk-tunnel-config.json +14 -0
Dockerfile
ADDED
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# Base image with Python
|
2 |
+
FROM python:3.12
|
3 |
+
|
4 |
+
# Set environment variables to prevent Python from buffering stdout and stderr
|
5 |
+
ENV PYTHONUNBUFFERED=1
|
6 |
+
# Set environment variable for Gradio Client to allow Hugging Face Hub downloads
|
7 |
+
ENV GRADIO_DOWNLOAD_REQUEST_TIMEOUT=180
|
8 |
+
# (Optional) For Gradio/HF specific caching, though usually handled by Spaces.
|
9 |
+
# ENV HF_HOME="/app/.cache/huggingface"
|
10 |
+
# ENV GRADIO_TEMP_DIR="/app/.cache/gradio"
|
11 |
+
|
12 |
+
# Create a non-root user and group
|
13 |
+
RUN groupadd -r appgroup && useradd --no-log-init -r -g appgroup -u 1000 appuser
|
14 |
+
|
15 |
+
# Create app directory and necessary subdirectories, and set permissions
|
16 |
+
RUN mkdir -p /app/uploads /app/static /app/templates \
|
17 |
+
&& chown -R appuser:appgroup /app
|
18 |
+
|
19 |
+
# Set working directory
|
20 |
+
WORKDIR /app
|
21 |
+
|
22 |
+
# Switch to the non-root user
|
23 |
+
USER appuser
|
24 |
+
|
25 |
+
# Copy requirements first to leverage Docker cache
|
26 |
+
COPY --chown=appuser:appgroup ./requirements.txt requirements.txt
|
27 |
+
|
28 |
+
# Install dependencies for the non-root user
|
29 |
+
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
30 |
+
|
31 |
+
# (Optional) If you use a .env file for API_TOKEN_HF
|
32 |
+
# COPY --chown=appuser:appgroup .env .env
|
33 |
+
|
34 |
+
# Copy the rest of the application code
|
35 |
+
COPY --chown=appuser:appgroup . /app
|
36 |
+
|
37 |
+
# Expose the port the app runs on (matches CMD)
|
38 |
+
EXPOSE 7860
|
39 |
+
|
40 |
+
# Command to run the application using Gunicorn
|
41 |
+
# Gunicorn is a WSGI HTTP Server for UNIX, commonly used for deploying Flask apps.
|
42 |
+
# app:app refers to the 'app' Flask instance within your 'app.py' file.
|
43 |
+
# We bind to 0.0.0.0 so it's accessible from outside the container.
|
44 |
+
# The port 7860 is standard for Gradio apps on Hugging Face Spaces.
|
45 |
+
CMD ["gunicorn", "--workers", "1", "--bind", "0.0.0.0:7860", "app:app"]
|
requirements.txt
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
python-dotenv
|
2 |
+
gradio_client
|
3 |
+
flask
|
4 |
+
gunicorn
|
static/css/style.css
ADDED
@@ -0,0 +1,466 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
body {
|
2 |
+
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
3 |
+
display: flex;
|
4 |
+
flex-direction: column;
|
5 |
+
align-items: center;
|
6 |
+
min-height: 100vh;
|
7 |
+
background-color: #f0f2f5;
|
8 |
+
margin: 0;
|
9 |
+
padding: 20px;
|
10 |
+
box-sizing: border-box;
|
11 |
+
}
|
12 |
+
|
13 |
+
.page-header {
|
14 |
+
text-align: center;
|
15 |
+
margin-bottom: 25px;
|
16 |
+
color: #333;
|
17 |
+
width: 100%;
|
18 |
+
max-width: 860px; /* Match main content max-width */
|
19 |
+
}
|
20 |
+
|
21 |
+
.page-header h1 {
|
22 |
+
font-size: 2.2em;
|
23 |
+
margin-bottom: 8px;
|
24 |
+
color: #2c3e50;
|
25 |
+
}
|
26 |
+
|
27 |
+
.page-header p {
|
28 |
+
font-size: 1.1em;
|
29 |
+
color: #555;
|
30 |
+
line-height: 1.6;
|
31 |
+
}
|
32 |
+
|
33 |
+
.main-content-wrapper {
|
34 |
+
display: flex;
|
35 |
+
flex-direction: row;
|
36 |
+
gap: 25px;
|
37 |
+
width: 100%;
|
38 |
+
max-width: 860px; /* Adjusted max-width */
|
39 |
+
align-items: flex-start;
|
40 |
+
}
|
41 |
+
|
42 |
+
.aroma-info-panel {
|
43 |
+
flex-basis: 280px; /* Increased width for perfume details */
|
44 |
+
flex-shrink: 0;
|
45 |
+
background-color: #ffffff;
|
46 |
+
padding: 20px;
|
47 |
+
border-radius: 8px;
|
48 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
49 |
+
}
|
50 |
+
|
51 |
+
.perfume-details {
|
52 |
+
text-align: center;
|
53 |
+
margin-bottom: 20px;
|
54 |
+
padding-bottom: 15px;
|
55 |
+
border-bottom: 1px solid #e0e0e0;
|
56 |
+
}
|
57 |
+
.perfume-details h2 {
|
58 |
+
font-size: 1.6em;
|
59 |
+
color: #34495e;
|
60 |
+
margin-top: 0;
|
61 |
+
margin-bottom: 5px;
|
62 |
+
}
|
63 |
+
.perfume-details p {
|
64 |
+
font-size: 1em;
|
65 |
+
color: #666;
|
66 |
+
font-style: italic;
|
67 |
+
margin-top: 0;
|
68 |
+
}
|
69 |
+
|
70 |
+
|
71 |
+
.aroma-legend h3 { /* Changed from h2 */
|
72 |
+
font-size: 1.2em; /* Adjusted size */
|
73 |
+
color: #34495e;
|
74 |
+
margin-top: 0;
|
75 |
+
margin-bottom: 15px;
|
76 |
+
text-align: center;
|
77 |
+
}
|
78 |
+
|
79 |
+
.aroma-legend table {
|
80 |
+
width: 100%;
|
81 |
+
border-collapse: collapse;
|
82 |
+
}
|
83 |
+
|
84 |
+
.aroma-legend th, .aroma-legend td {
|
85 |
+
text-align: left;
|
86 |
+
padding: 8px 5px;
|
87 |
+
font-size: 0.9em; /* Slightly smaller for compactness */
|
88 |
+
border-bottom: 1px solid #f0f0f0;
|
89 |
+
vertical-align: middle;
|
90 |
+
}
|
91 |
+
.aroma-legend th {
|
92 |
+
color: #555;
|
93 |
+
font-weight: 600;
|
94 |
+
}
|
95 |
+
.aroma-legend tr:last-child td {
|
96 |
+
border-bottom: none;
|
97 |
+
}
|
98 |
+
.aroma-legend td:nth-child(1), .aroma-legend th:nth-child(1) { /* # column */
|
99 |
+
text-align: center;
|
100 |
+
width: 20px;
|
101 |
+
}
|
102 |
+
.aroma-legend td:nth-child(4), .aroma-legend th:nth-child(4) { /* Dose column */
|
103 |
+
text-align: right;
|
104 |
+
width: 40px;
|
105 |
+
font-weight: bold;
|
106 |
+
}
|
107 |
+
|
108 |
+
|
109 |
+
.color-swatch {
|
110 |
+
display: inline-block;
|
111 |
+
width: 16px; /* Adjusted size */
|
112 |
+
height: 16px;
|
113 |
+
border-radius: 3px;
|
114 |
+
border: 1px solid #ccc;
|
115 |
+
margin-right: 6px;
|
116 |
+
vertical-align: middle;
|
117 |
+
}
|
118 |
+
|
119 |
+
.app-area {
|
120 |
+
flex-grow: 1;
|
121 |
+
display: flex;
|
122 |
+
flex-direction: column;
|
123 |
+
align-items: center;
|
124 |
+
min-width: 0; /* Important for flex item to shrink properly */
|
125 |
+
}
|
126 |
+
|
127 |
+
/* Image Upload Section */
|
128 |
+
.image-upload-section {
|
129 |
+
background-color: #fff;
|
130 |
+
padding: 20px;
|
131 |
+
border-radius: 8px;
|
132 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
133 |
+
text-align: center;
|
134 |
+
margin-bottom: 20px;
|
135 |
+
width: 100%;
|
136 |
+
max-width: 400px; /* Match dispenser width */
|
137 |
+
}
|
138 |
+
|
139 |
+
.image-upload-label {
|
140 |
+
display: block;
|
141 |
+
width: 100%;
|
142 |
+
height: 200px; /* Adjust as needed */
|
143 |
+
border: 2px dashed #ccc;
|
144 |
+
border-radius: 6px;
|
145 |
+
cursor: pointer;
|
146 |
+
margin-bottom: 15px;
|
147 |
+
display: flex;
|
148 |
+
flex-direction: column;
|
149 |
+
align-items: center;
|
150 |
+
justify-content: center;
|
151 |
+
background-color: #f9f9f9;
|
152 |
+
overflow: hidden; /* To contain the image */
|
153 |
+
}
|
154 |
+
.image-upload-label:hover {
|
155 |
+
border-color: #999;
|
156 |
+
}
|
157 |
+
.image-upload-label img {
|
158 |
+
max-width: 100%;
|
159 |
+
max-height: 100%;
|
160 |
+
object-fit: contain; /* Changed from cover to contain for placeholder */
|
161 |
+
}
|
162 |
+
.image-upload-label span {
|
163 |
+
margin-top: 10px;
|
164 |
+
color: #777;
|
165 |
+
font-size: 0.9em;
|
166 |
+
}
|
167 |
+
.image-upload-section button {
|
168 |
+
padding: 10px 20px;
|
169 |
+
font-size: 1em;
|
170 |
+
background-color: #5cb85c;
|
171 |
+
color: white;
|
172 |
+
border: none;
|
173 |
+
border-radius: 5px;
|
174 |
+
cursor: pointer;
|
175 |
+
transition: background-color 0.2s;
|
176 |
+
}
|
177 |
+
.image-upload-section button:hover {
|
178 |
+
background-color: #4cae4c;
|
179 |
+
}
|
180 |
+
.image-upload-section button:disabled {
|
181 |
+
background-color: #ccc;
|
182 |
+
cursor: not-allowed;
|
183 |
+
}
|
184 |
+
#loadingIndicator {
|
185 |
+
margin-top: 10px;
|
186 |
+
color: #337ab7;
|
187 |
+
}
|
188 |
+
.error-message {
|
189 |
+
color: red;
|
190 |
+
font-size: 0.9em;
|
191 |
+
margin-top: 10px;
|
192 |
+
}
|
193 |
+
|
194 |
+
|
195 |
+
.dispenser-area {
|
196 |
+
position: relative;
|
197 |
+
width: 100%; /* Make it responsive within its container */
|
198 |
+
max-width: 400px; /* Max size of the dispenser visuals */
|
199 |
+
aspect-ratio: 400 / 450; /* Maintain aspect ratio */
|
200 |
+
height: auto; /* Let aspect-ratio define height */
|
201 |
+
display: flex;
|
202 |
+
flex-direction: column;
|
203 |
+
align-items: center;
|
204 |
+
justify-content: flex-start; /* Aligns drum to top */
|
205 |
+
margin-bottom: 20px;
|
206 |
+
border: 2px solid #b0b0b0;
|
207 |
+
padding: 5%; /* Use percentage padding */
|
208 |
+
background-color: #fdfdfd;
|
209 |
+
border-radius: 10px;
|
210 |
+
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
211 |
+
box-sizing: border-box;
|
212 |
+
}
|
213 |
+
|
214 |
+
.motor-base {
|
215 |
+
width: 15%; /* Relative width */
|
216 |
+
height: 7.5%; /* Relative height */
|
217 |
+
background-color: #6c757d;
|
218 |
+
border-radius: 5px;
|
219 |
+
position: absolute;
|
220 |
+
/* Position relative to drum */
|
221 |
+
top: calc(7.5% + 31.25% - 3.75%); /* drum.marginTop + drum.height/2 - motor.height/2 */
|
222 |
+
left: 50%;
|
223 |
+
transform: translateX(-50%);
|
224 |
+
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
225 |
+
z-index: 1;
|
226 |
+
}
|
227 |
+
|
228 |
+
.drum {
|
229 |
+
width: 62.5%; /* 250px / 400px */
|
230 |
+
height: 62.5%; /* 250px / 400px */
|
231 |
+
background-image: radial-gradient(circle, #e0e0e0, #c0c0c0 60%, #a0a0a0);
|
232 |
+
border-radius: 50%;
|
233 |
+
position: relative;
|
234 |
+
margin-top: 7.5%; /* 30px / 400px */
|
235 |
+
border: 1%; /* 4px / 400px */
|
236 |
+
border-style: solid;
|
237 |
+
border-color: #555;
|
238 |
+
box-shadow: 0 1% 3% rgba(0,0,0,0.25), inset 0 0 4.5% rgba(0,0,0,0.1);
|
239 |
+
transition: transform 0.7s ease-in-out;
|
240 |
+
transform-style: preserve-3d;
|
241 |
+
z-index: 2;
|
242 |
+
box-sizing: border-box;
|
243 |
+
}
|
244 |
+
|
245 |
+
.drum::before {
|
246 |
+
content: '';
|
247 |
+
position: absolute;
|
248 |
+
width: 14%; /* 35px / 250px drum width */
|
249 |
+
height: 14%;
|
250 |
+
background-color: #4a4a4a;
|
251 |
+
border-radius: 50%;
|
252 |
+
top: 50%;
|
253 |
+
left: 50%;
|
254 |
+
transform: translate(-50%, -50%);
|
255 |
+
border: 1.2%; /* 3px / 250px */
|
256 |
+
border-style: solid;
|
257 |
+
border-color: #333;
|
258 |
+
box-shadow: inset 0 0 3.2% rgba(0,0,0,0.6);
|
259 |
+
z-index: 3;
|
260 |
+
}
|
261 |
+
|
262 |
+
.bottle-slot {
|
263 |
+
position: absolute;
|
264 |
+
width: 9.6%; /* 24px / 250px drum width */
|
265 |
+
height: 50%; /* Drum radius */
|
266 |
+
left: calc(50% - 4.8%); /* 50% - (width/2) */
|
267 |
+
top: 50%;
|
268 |
+
transform-origin: center top;
|
269 |
+
z-index: 4;
|
270 |
+
}
|
271 |
+
|
272 |
+
.base-aroma-bottle {
|
273 |
+
width: 100%; /* Full width of slot */
|
274 |
+
height: 36%; /* 45px / 125px slot height */
|
275 |
+
border: 0.8%; /* 1px / 125px slot height */
|
276 |
+
border-style: solid;
|
277 |
+
border-color: #4a2d0a;
|
278 |
+
border-radius: 5px 5px 3px 3px; /* Keep somewhat fixed for shape */
|
279 |
+
position: absolute;
|
280 |
+
bottom: -4%; /* -5px / 125px */
|
281 |
+
left: 0;
|
282 |
+
display: flex;
|
283 |
+
align-items: center;
|
284 |
+
justify-content: center;
|
285 |
+
font-size: 0.7em; /* Make font responsive */
|
286 |
+
font-weight: bold;
|
287 |
+
color: white;
|
288 |
+
text-shadow: 1px 1px 1px rgba(0,0,0,0.9);
|
289 |
+
box-shadow: 1px 2px 5px rgba(0,0,0,0.3);
|
290 |
+
cursor: default;
|
291 |
+
box-sizing: border-box;
|
292 |
+
}
|
293 |
+
|
294 |
+
.base-aroma-bottle::before {
|
295 |
+
content: '';
|
296 |
+
position: absolute;
|
297 |
+
top: -15.5%; /* -7px / 45px bottle height */
|
298 |
+
left: 50%;
|
299 |
+
transform: translateX(-50%);
|
300 |
+
width: 66.6%; /* 16px / 24px bottle width */
|
301 |
+
height: 15.5%; /* 7px / 45px bottle height */
|
302 |
+
background-color: #282828;
|
303 |
+
border-radius: 3px 3px 0 0;
|
304 |
+
border: 0.4%; /* 1px / 250px drum width (approx for small elements) */
|
305 |
+
border-style: solid;
|
306 |
+
border-color: #111;
|
307 |
+
}
|
308 |
+
|
309 |
+
.perfume-flask {
|
310 |
+
width: 13.75%; /* 55px / 400px */
|
311 |
+
height: 20%; /* 90px / 450px */
|
312 |
+
background-color: rgba(200, 225, 255, 0.75);
|
313 |
+
border: 0.5%; /* 2px / 400px */
|
314 |
+
border-style: solid;
|
315 |
+
border-color: #adcdea;
|
316 |
+
border-radius: 6px 6px 25px 25px;
|
317 |
+
position: absolute;
|
318 |
+
bottom: 5.5%; /* 25px / 450px */
|
319 |
+
left: 50%;
|
320 |
+
transform: translateX(-50%);
|
321 |
+
box-shadow: 0 0.8% 1.6% rgba(0,0,0,0.1);
|
322 |
+
z-index: 3;
|
323 |
+
box-sizing: border-box;
|
324 |
+
}
|
325 |
+
.perfume-flask::before {
|
326 |
+
content: '';
|
327 |
+
position: absolute;
|
328 |
+
top: -20%; /* -18px / 90px flask height */
|
329 |
+
left: 50%;
|
330 |
+
transform: translateX(-50%);
|
331 |
+
width: 40%; /* 22px / 55px flask width */
|
332 |
+
height: 20%; /* 18px / 90px flask height */
|
333 |
+
background-color: rgba(200, 225, 255, 0.75);
|
334 |
+
border: 0.5%; /* 2px / 400px */
|
335 |
+
border-style: solid;
|
336 |
+
border-color: #adcdea;
|
337 |
+
border-top-left-radius: 4px;
|
338 |
+
border-top-right-radius: 4px;
|
339 |
+
border-bottom: none;
|
340 |
+
}
|
341 |
+
.perfume-liquid {
|
342 |
+
width: 100%;
|
343 |
+
height: 0%;
|
344 |
+
position: absolute;
|
345 |
+
bottom: 0;
|
346 |
+
border-radius: 0 0 23px 23px;
|
347 |
+
transition: height 0.5s ease-out, background-color 0.5s ease;
|
348 |
+
}
|
349 |
+
|
350 |
+
.dispensing-stream {
|
351 |
+
position: absolute;
|
352 |
+
width: 0.75%; /* 3px / 400px */
|
353 |
+
height: 0px;
|
354 |
+
left: 50%;
|
355 |
+
transform: translateX(-50%);
|
356 |
+
transition: height 0.25s ease-out;
|
357 |
+
z-index: 1; /* Behind drum */
|
358 |
+
border-radius: 2px;
|
359 |
+
}
|
360 |
+
|
361 |
+
.controls {
|
362 |
+
display: flex; /* Will be shown by JS */
|
363 |
+
flex-direction: column;
|
364 |
+
align-items: center;
|
365 |
+
gap: 12px;
|
366 |
+
padding: 20px;
|
367 |
+
background-color: #ffffff;
|
368 |
+
border-radius: 8px;
|
369 |
+
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
|
370 |
+
width: 100%;
|
371 |
+
max-width: 400px; /* Match dispenser max width */
|
372 |
+
margin-top: 10px; /* Space from dispenser if it's visible */
|
373 |
+
}
|
374 |
+
.control-group {
|
375 |
+
display: flex;
|
376 |
+
align-items: center;
|
377 |
+
gap: 10px;
|
378 |
+
width: 100%;
|
379 |
+
justify-content: space-between;
|
380 |
+
}
|
381 |
+
.control-group label, .control-group > span:not(#aromaNumberDisplay):not(#doseDisplay) {
|
382 |
+
flex-shrink: 0;
|
383 |
+
}
|
384 |
+
#doseSlider {
|
385 |
+
flex-grow: 1;
|
386 |
+
}
|
387 |
+
|
388 |
+
.controls button {
|
389 |
+
padding: 10px 18px;
|
390 |
+
font-size: 1em;
|
391 |
+
font-weight: 500;
|
392 |
+
cursor: pointer;
|
393 |
+
border: 1px solid #ced4da;
|
394 |
+
background-color: #f8f9fa;
|
395 |
+
color: #343a40;
|
396 |
+
border-radius: 5px;
|
397 |
+
transition: background-color 0.2s, box-shadow 0.2s;
|
398 |
+
}
|
399 |
+
.controls button:hover {
|
400 |
+
background-color: #e9ecef;
|
401 |
+
border-color: #adb5bd;
|
402 |
+
}
|
403 |
+
.controls button:active {
|
404 |
+
background-color: #dee2e6;
|
405 |
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.1);
|
406 |
+
}
|
407 |
+
.controls button:disabled {
|
408 |
+
background-color: #e9ecef;
|
409 |
+
color: #6c757d;
|
410 |
+
cursor: not-allowed;
|
411 |
+
}
|
412 |
+
.display { /* This is for the animation controls display */
|
413 |
+
padding: 10px 15px;
|
414 |
+
background-color: #343a40;
|
415 |
+
color: #90ee90;
|
416 |
+
border-radius: 4px;
|
417 |
+
width: calc(100% - 30px); /* Full width minus padding */
|
418 |
+
text-align: center;
|
419 |
+
font-family: 'Consolas', 'Courier New', monospace;
|
420 |
+
font-size: 1em;
|
421 |
+
border: 1px solid #495057;
|
422 |
+
margin-bottom: 5px;
|
423 |
+
}
|
424 |
+
|
425 |
+
/* Adaptive Design */
|
426 |
+
@media (max-width: 880px) { /* Slightly wider than main content for smoother transition */
|
427 |
+
.main-content-wrapper {
|
428 |
+
flex-direction: column;
|
429 |
+
align-items: center; /* Center items when stacked */
|
430 |
+
gap: 20px;
|
431 |
+
}
|
432 |
+
.aroma-info-panel {
|
433 |
+
flex-basis: auto; /* Allow it to take full width */
|
434 |
+
width: 100%;
|
435 |
+
max-width: 450px; /* Limit width when stacked */
|
436 |
+
}
|
437 |
+
.app-area {
|
438 |
+
width: 100%;
|
439 |
+
align-items: center; /* Ensure app area children are centered */
|
440 |
+
}
|
441 |
+
.image-upload-section, .dispenser-area, .controls {
|
442 |
+
max-width: 450px; /* Allow them to be a bit wider if info panel is full width */
|
443 |
+
}
|
444 |
+
}
|
445 |
+
|
446 |
+
@media (max-width: 480px) {
|
447 |
+
.page-header h1 {
|
448 |
+
font-size: 1.8em;
|
449 |
+
}
|
450 |
+
.page-header p {
|
451 |
+
font-size: 1em;
|
452 |
+
}
|
453 |
+
.aroma-info-panel, .image-upload-section, .dispenser-area, .controls {
|
454 |
+
padding: 15px;
|
455 |
+
}
|
456 |
+
.controls button, .display, .image-upload-section button {
|
457 |
+
font-size: 0.9em;
|
458 |
+
}
|
459 |
+
.aroma-legend th, .aroma-legend td {
|
460 |
+
font-size: 0.85em;
|
461 |
+
padding: 6px 3px;
|
462 |
+
}
|
463 |
+
.base-aroma-bottle {
|
464 |
+
font-size: 0.6em; /* Further reduce for very small screens */
|
465 |
+
}
|
466 |
+
}
|
static/img/placeholder.png
ADDED
![]() |
static/js/script.js
ADDED
@@ -0,0 +1,278 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// static/js/script.js
|
2 |
+
|
3 |
+
document.addEventListener('DOMContentLoaded', function () {
|
4 |
+
const drumElement = document.getElementById('drumElement');
|
5 |
+
const controlsDisplay = document.getElementById('controlsDisplay'); // For animation messages
|
6 |
+
const perfumeLiquid = document.getElementById('perfumeLiquid');
|
7 |
+
const perfumeFlask = document.getElementById('perfumeFlask');
|
8 |
+
const dispensingStream = document.getElementById('dispensingStream');
|
9 |
+
const dispenserArea = document.getElementById('dispenserArea'); // Parent of stream
|
10 |
+
const aromaLegendBody = document.getElementById('aromaLegendBody');
|
11 |
+
const controlsElement = document.querySelector('.controls'); // The whole controls div for animation
|
12 |
+
|
13 |
+
// Image Upload Elements
|
14 |
+
const imageUploadInput = document.getElementById('imageUploadInput');
|
15 |
+
const imagePreview = document.getElementById('imagePreview');
|
16 |
+
const analyzeImageBtn = document.getElementById('analyzeImageBtn');
|
17 |
+
const imageUploadLabelSpan = document.querySelector('.image-upload-label span');
|
18 |
+
const placeholderSrc = imagePreview.src; // Store initial placeholder
|
19 |
+
const loadingIndicator = document.getElementById('loadingIndicator');
|
20 |
+
const apiErrorDisplay = document.getElementById('apiErrorDisplay');
|
21 |
+
|
22 |
+
// Perfume Info Display Elements
|
23 |
+
const perfumeNameDisplay = document.getElementById('perfumeNameDisplay');
|
24 |
+
const perfumeSloganDisplay = document.getElementById('perfumeSloganDisplay');
|
25 |
+
|
26 |
+
const NUM_AROMAS = BASE_AROMAS_ORDERED.length;
|
27 |
+
const AROMA_COLORS_MAP = {
|
28 |
+
"Rose": '#FF69B4', "Ocean Breeze": '#1E90FF', "Fresh Cut Grass": '#32CD32',
|
29 |
+
"Lemon Zest": '#FFD700', "Lavender": '#BA55D3', "Sweet Orange": '#FF7F50',
|
30 |
+
"Cool Mint": '#00CED1', "Vanilla Bean": '#F4A460', "Wild Berry": '#DA70D6',
|
31 |
+
"Spring Rain": '#87CEEB'
|
32 |
+
};
|
33 |
+
|
34 |
+
// --- CORRECTED ---
|
35 |
+
const MAX_FLASK_CAPACITY_ML = 10.0; // The API aims for a total dose of 10.0 for 100% full
|
36 |
+
// --- END CORRECTED ---
|
37 |
+
|
38 |
+
let totalLiquidInFlask = 0;
|
39 |
+
|
40 |
+
function populateAromaLegend(apiAromasData = []) { // Ensure it's an array
|
41 |
+
aromaLegendBody.innerHTML = '';
|
42 |
+
const apiAromas = Array.isArray(apiAromasData) ? apiAromasData : [];
|
43 |
+
const apiAromasMap = new Map(apiAromas.map(a => [a.name, parseFloat(a.dose) || 0]));
|
44 |
+
|
45 |
+
|
46 |
+
BASE_AROMAS_ORDERED.forEach((aromaName, index) => {
|
47 |
+
const row = aromaLegendBody.insertRow();
|
48 |
+
const cellNum = row.insertCell();
|
49 |
+
const cellColor = row.insertCell();
|
50 |
+
const cellName = row.insertCell();
|
51 |
+
const cellDose = row.insertCell();
|
52 |
+
|
53 |
+
cellNum.textContent = index + 1;
|
54 |
+
const color = AROMA_COLORS_MAP[aromaName] || '#ccc';
|
55 |
+
cellColor.innerHTML = `<span class="color-swatch" style="background-color: ${color};"></span>`;
|
56 |
+
cellName.textContent = aromaName;
|
57 |
+
|
58 |
+
const doseValue = apiAromasMap.get(aromaName) || 0.0;
|
59 |
+
// Display dose with 1 or 2 decimal places, or "—" if 0
|
60 |
+
cellDose.textContent = doseValue > 0 ? doseValue.toFixed(doseValue % 1 === 0 ? 1 : 2) : "—";
|
61 |
+
});
|
62 |
+
}
|
63 |
+
|
64 |
+
function createBottles() {
|
65 |
+
drumElement.innerHTML = '';
|
66 |
+
const angleStep = 360 / NUM_AROMAS;
|
67 |
+
BASE_AROMAS_ORDERED.forEach((aromaName, i) => {
|
68 |
+
const angle = i * angleStep;
|
69 |
+
const slot = document.createElement('div');
|
70 |
+
slot.classList.add('bottle-slot');
|
71 |
+
slot.style.transform = `rotate(${angle}deg)`;
|
72 |
+
|
73 |
+
const bottle = document.createElement('div');
|
74 |
+
bottle.classList.add('base-aroma-bottle');
|
75 |
+
bottle.textContent = i + 1;
|
76 |
+
bottle.style.backgroundColor = AROMA_COLORS_MAP[aromaName] || '#ccc';
|
77 |
+
bottle.title = aromaName;
|
78 |
+
|
79 |
+
slot.appendChild(bottle);
|
80 |
+
drumElement.appendChild(slot);
|
81 |
+
});
|
82 |
+
}
|
83 |
+
|
84 |
+
function rotateDrum(aromaIndex) {
|
85 |
+
const angleStep = 360 / NUM_AROMAS;
|
86 |
+
let drumRotation = -(aromaIndex * angleStep);
|
87 |
+
drumElement.style.transform = `rotate(${drumRotation}deg)`;
|
88 |
+
}
|
89 |
+
|
90 |
+
function delay(ms) {
|
91 |
+
return new Promise(resolve => setTimeout(resolve, ms));
|
92 |
+
}
|
93 |
+
|
94 |
+
async function simulateDispensing(aromaName, dose) { // dose is now the absolute amount e.g. 0.4, 1.5 etc.
|
95 |
+
const aromaIndex = BASE_AROMAS_ORDERED.indexOf(aromaName);
|
96 |
+
if (aromaIndex === -1) {
|
97 |
+
console.warn(`Aroma "${aromaName}" not found in base list for simulation.`);
|
98 |
+
return;
|
99 |
+
}
|
100 |
+
|
101 |
+
controlsDisplay.textContent = `Selecting ${aromaName}...`;
|
102 |
+
rotateDrum(aromaIndex);
|
103 |
+
await delay(1000);
|
104 |
+
|
105 |
+
// --- CORRECTED ---
|
106 |
+
// Display the actual dose amount being dispensed.
|
107 |
+
// The API returns dose like 0.4, which means 0.4 units out of 10.0 total for the flask.
|
108 |
+
controlsDisplay.textContent = `Dispensing ${dose.toFixed(2)} units of ${aromaName}...`;
|
109 |
+
// --- END CORRECTED ---
|
110 |
+
|
111 |
+
const streamStartY_relative = drumElement.offsetTop;
|
112 |
+
const flaskRect = perfumeFlask.getBoundingClientRect();
|
113 |
+
const dispenserAreaRect = dispenserArea.getBoundingClientRect();
|
114 |
+
const flaskNeckTopY_relative = (flaskRect.top - dispenserAreaRect.top) - 18;
|
115 |
+
|
116 |
+
let streamHeight = flaskNeckTopY_relative - streamStartY_relative;
|
117 |
+
streamHeight = Math.max(10, streamHeight);
|
118 |
+
|
119 |
+
dispensingStream.style.top = `${streamStartY_relative}px`;
|
120 |
+
dispensingStream.style.backgroundColor = AROMA_COLORS_MAP[aromaName] || '#ccc';
|
121 |
+
dispensingStream.style.height = `${streamHeight}px`;
|
122 |
+
await delay(250);
|
123 |
+
|
124 |
+
// --- CORRECTED ---
|
125 |
+
totalLiquidInFlask += dose; // Add the absolute dose amount
|
126 |
+
// Calculate fill percentage based on MAX_FLASK_CAPACITY_ML which is 10.0
|
127 |
+
const fillPercentage = Math.min(100, (totalLiquidInFlask / MAX_FLASK_CAPACITY_ML) * 100);
|
128 |
+
// --- END CORRECTED ---
|
129 |
+
|
130 |
+
perfumeLiquid.style.backgroundColor = AROMA_COLORS_MAP[aromaName] || '#ccc';
|
131 |
+
perfumeLiquid.style.height = `${fillPercentage}%`;
|
132 |
+
await delay(600);
|
133 |
+
|
134 |
+
dispensingStream.style.height = '0px';
|
135 |
+
await delay(300);
|
136 |
+
}
|
137 |
+
|
138 |
+
async function runDispensingSequence(aromasToDispense) {
|
139 |
+
controlsElement.style.display = 'flex';
|
140 |
+
totalLiquidInFlask = 0;
|
141 |
+
perfumeLiquid.style.height = '0%';
|
142 |
+
perfumeLiquid.style.backgroundColor = 'transparent';
|
143 |
+
|
144 |
+
// Ensure aromasToDispense is an array
|
145 |
+
const aromas = Array.isArray(aromasToDispense) ? aromasToDispense : [];
|
146 |
+
|
147 |
+
for (const aroma of aromas) {
|
148 |
+
// Ensure dose is a number before using it
|
149 |
+
const doseValue = parseFloat(aroma.dose);
|
150 |
+
if (aroma.name && !isNaN(doseValue) && doseValue > 0) {
|
151 |
+
await simulateDispensing(aroma.name, doseValue);
|
152 |
+
} else {
|
153 |
+
console.warn("Skipping aroma with invalid name or dose:", aroma);
|
154 |
+
}
|
155 |
+
}
|
156 |
+
|
157 |
+
controlsDisplay.textContent = "Perfume Composition Complete!";
|
158 |
+
await delay(2000);
|
159 |
+
// controlsElement.style.display = 'none';
|
160 |
+
}
|
161 |
+
|
162 |
+
imageUploadInput.addEventListener('change', function(event) {
|
163 |
+
const file = event.target.files[0];
|
164 |
+
if (file) {
|
165 |
+
const reader = new FileReader();
|
166 |
+
reader.onload = function(e) {
|
167 |
+
imagePreview.src = e.target.result;
|
168 |
+
imageUploadLabelSpan.style.display = 'none';
|
169 |
+
}
|
170 |
+
reader.readAsDataURL(file);
|
171 |
+
analyzeImageBtn.disabled = false;
|
172 |
+
apiErrorDisplay.style.display = 'none';
|
173 |
+
}
|
174 |
+
});
|
175 |
+
|
176 |
+
analyzeImageBtn.addEventListener('click', async function() {
|
177 |
+
const file = imageUploadInput.files[0];
|
178 |
+
if (!file) {
|
179 |
+
apiErrorDisplay.textContent = "Please select an image first.";
|
180 |
+
apiErrorDisplay.style.display = 'block';
|
181 |
+
return;
|
182 |
+
}
|
183 |
+
|
184 |
+
analyzeImageBtn.disabled = true;
|
185 |
+
loadingIndicator.style.display = 'block';
|
186 |
+
apiErrorDisplay.style.display = 'none';
|
187 |
+
controlsElement.style.display = 'none'; // Hide animation controls during analysis
|
188 |
+
|
189 |
+
const formData = new FormData();
|
190 |
+
formData.append('imageFile', file);
|
191 |
+
|
192 |
+
try {
|
193 |
+
const response = await fetch('/analyze_image', {
|
194 |
+
method: 'POST',
|
195 |
+
body: formData
|
196 |
+
});
|
197 |
+
|
198 |
+
const responseText = await response.text(); // Get raw text first
|
199 |
+
loadingIndicator.style.display = 'none';
|
200 |
+
analyzeImageBtn.disabled = false;
|
201 |
+
|
202 |
+
let data;
|
203 |
+
if (!response.ok) {
|
204 |
+
// Try to parse error if backend sends JSON error
|
205 |
+
try {
|
206 |
+
data = JSON.parse(responseText);
|
207 |
+
apiErrorDisplay.textContent = `Error: ${data.error || response.statusText}`;
|
208 |
+
} catch (e) {
|
209 |
+
apiErrorDisplay.textContent = `Error: ${response.statusText} (Could not parse error response)`;
|
210 |
+
}
|
211 |
+
apiErrorDisplay.style.display = 'block';
|
212 |
+
updateUiWithPerfumeData(initialPerfumeData);
|
213 |
+
return;
|
214 |
+
}
|
215 |
+
|
216 |
+
// If response.ok, try to parse the expected JSON from the backend
|
217 |
+
try {
|
218 |
+
// Clean the response string if it's wrapped in markdown code blocks
|
219 |
+
let cleanedResponseText = responseText;
|
220 |
+
if (cleanedResponseText.startsWith("```json")) {
|
221 |
+
cleanedResponseText = cleanedResponseText.substring(7); // Remove ```json\n
|
222 |
+
if (cleanedResponseText.endsWith("```")) {
|
223 |
+
cleanedResponseText = cleanedResponseText.substring(0, cleanedResponseText.length - 3);
|
224 |
+
}
|
225 |
+
}
|
226 |
+
cleanedResponseText = cleanedResponseText.trim();
|
227 |
+
data = JSON.parse(cleanedResponseText);
|
228 |
+
} catch (e) {
|
229 |
+
console.error("Error parsing JSON from API:", e, "Raw text:", responseText);
|
230 |
+
apiErrorDisplay.textContent = "Error: Could not parse perfume data from AI. Please try a different image or prompt.";
|
231 |
+
apiErrorDisplay.style.display = 'block';
|
232 |
+
updateUiWithPerfumeData(initialPerfumeData);
|
233 |
+
return;
|
234 |
+
}
|
235 |
+
|
236 |
+
if (data.api_error || data.api_warning) { // Check for errors/warnings from backend logic
|
237 |
+
apiErrorDisplay.textContent = data.api_error || data.api_warning;
|
238 |
+
apiErrorDisplay.style.display = 'block';
|
239 |
+
}
|
240 |
+
|
241 |
+
updateUiWithPerfumeData(data);
|
242 |
+
if (data.aromas && data.aromas.length > 0) {
|
243 |
+
runDispensingSequence(data.aromas);
|
244 |
+
}
|
245 |
+
|
246 |
+
|
247 |
+
} catch (error) {
|
248 |
+
console.error('Error sending image or processing response:', error);
|
249 |
+
loadingIndicator.style.display = 'none';
|
250 |
+
analyzeImageBtn.disabled = false;
|
251 |
+
apiErrorDisplay.textContent = "An unexpected error occurred. Please try again.";
|
252 |
+
apiErrorDisplay.style.display = 'block';
|
253 |
+
updateUiWithPerfumeData(initialPerfumeData);
|
254 |
+
}
|
255 |
+
});
|
256 |
+
|
257 |
+
function updateUiWithPerfumeData(data) {
|
258 |
+
perfumeNameDisplay.textContent = data.perfume_name || "Untitled Creation";
|
259 |
+
perfumeSloganDisplay.innerHTML = `<em>${data.slogan || "An enigmatic essence."}</em>`;
|
260 |
+
populateAromaLegend(data.aromas || []); // Pass the aromas array
|
261 |
+
}
|
262 |
+
|
263 |
+
function initializeApp() {
|
264 |
+
updateUiWithPerfumeData(initialPerfumeData);
|
265 |
+
createBottles();
|
266 |
+
|
267 |
+
const initialAromaName = (initialPerfumeData.aromas && initialPerfumeData.aromas.length > 0)
|
268 |
+
? initialPerfumeData.aromas[0].name
|
269 |
+
: BASE_AROMAS_ORDERED[0];
|
270 |
+
|
271 |
+
let initialAromaIndex = BASE_AROMAS_ORDERED.indexOf(initialAromaName);
|
272 |
+
if(initialAromaIndex === -1) initialAromaIndex = 0;
|
273 |
+
|
274 |
+
rotateDrum(initialAromaIndex);
|
275 |
+
}
|
276 |
+
|
277 |
+
initializeApp();
|
278 |
+
});
|
templates/index.html
ADDED
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
<!DOCTYPE html>
|
2 |
+
<html lang="en">
|
3 |
+
<head>
|
4 |
+
<meta charset="UTF-8">
|
5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6 |
+
<title>AI Perfume Composer</title>
|
7 |
+
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
8 |
+
</head>
|
9 |
+
<body>
|
10 |
+
|
11 |
+
<header class="page-header">
|
12 |
+
<h1>AI Perfume Composer</h1>
|
13 |
+
<p>
|
14 |
+
Upload an image that inspires you. Our AI will analyze it and compose a unique perfume concept,
|
15 |
+
complete with a name, slogan, and base aroma blend. Then, watch the virtual dispenser create it!
|
16 |
+
</p>
|
17 |
+
</header>
|
18 |
+
|
19 |
+
<div class="main-content-wrapper">
|
20 |
+
<aside class="aroma-info-panel">
|
21 |
+
<div class="perfume-details">
|
22 |
+
<h2 id="perfumeNameDisplay">{{ initial_data.perfume_name }}</h2>
|
23 |
+
<p id="perfumeSloganDisplay"><em>{{ initial_data.slogan }}</em></p>
|
24 |
+
</div>
|
25 |
+
<div class="aroma-legend">
|
26 |
+
<h3>Base Aromas & Doses</h3>
|
27 |
+
<table>
|
28 |
+
<thead>
|
29 |
+
<tr>
|
30 |
+
<th>#</th>
|
31 |
+
<th>Color</th>
|
32 |
+
<th>Name</th>
|
33 |
+
<th>Dose</th>
|
34 |
+
</tr>
|
35 |
+
</thead>
|
36 |
+
<tbody id="aromaLegendBody">
|
37 |
+
<!-- Populated by JS -->
|
38 |
+
</tbody>
|
39 |
+
</table>
|
40 |
+
</div>
|
41 |
+
</aside>
|
42 |
+
|
43 |
+
<main class="app-area">
|
44 |
+
<div class="image-upload-section">
|
45 |
+
<label for="imageUploadInput" class="image-upload-label">
|
46 |
+
<img id="imagePreview" src="{{ url_for('static', filename='img/placeholder.png') }}" alt="Upload Image Preview">
|
47 |
+
<span>Click to Upload Image</span>
|
48 |
+
</label>
|
49 |
+
<input type="file" id="imageUploadInput" accept="image/*" style="display: none;">
|
50 |
+
<button id="analyzeImageBtn" disabled>Analyze Image & Compose</button>
|
51 |
+
<div id="loadingIndicator" style="display: none;">Analyzing Image... Please Wait...</div>
|
52 |
+
<div id="apiErrorDisplay" class="error-message" style="display: none;"></div>
|
53 |
+
</div>
|
54 |
+
|
55 |
+
<div class="dispenser-area" id="dispenserArea">
|
56 |
+
<div class="motor-base"></div>
|
57 |
+
<div class="drum" id="drumElement"></div>
|
58 |
+
<div class="perfume-flask" id="perfumeFlask">
|
59 |
+
<div class="perfume-liquid" id="perfumeLiquid"></div>
|
60 |
+
</div>
|
61 |
+
<div class="dispensing-stream" id="dispensingStream"></div>
|
62 |
+
</div>
|
63 |
+
|
64 |
+
<div class="controls" style="display: none;"> <!-- Hidden initially, shown during animation -->
|
65 |
+
<div class="display" id="controlsDisplay">Preparing...</div>
|
66 |
+
</div>
|
67 |
+
</main>
|
68 |
+
</div>
|
69 |
+
|
70 |
+
<script>
|
71 |
+
// Pass initial data from Flask to JavaScript
|
72 |
+
const initialPerfumeData = {{ initial_data | tojson }};
|
73 |
+
const BASE_AROMAS_ORDERED = {{ base_aromas_ordered | tojson }};
|
74 |
+
</script>
|
75 |
+
<script src="{{ url_for('static', filename='js/script.js') }}"></script>
|
76 |
+
</body>
|
77 |
+
</html>
|
upload/readme.md
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
python-dotenv
|
2 |
+
requests
|
3 |
+
gradio_client
|
4 |
+
flask
|
uploads/readme.md
ADDED
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
1 |
+
python-dotenv
|
2 |
+
requests
|
3 |
+
gradio_client
|
4 |
+
flask
|
vk-tunnel-config.json
ADDED
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
{
|
2 |
+
"endpoints": [
|
3 |
+
"analyze_image",
|
4 |
+
"static",
|
5 |
+
"css",
|
6 |
+
"js",
|
7 |
+
"templates",
|
8 |
+
"upload",
|
9 |
+
"uploads"
|
10 |
+
],
|
11 |
+
"port": "5001",
|
12 |
+
"http-protocol": "http",
|
13 |
+
"insecure": "1"
|
14 |
+
}
|