doevent commited on
Commit
5d88515
·
verified ·
1 Parent(s): 0612834

Upload 9 files

Browse files
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
+ }