webolavo commited on
Commit
09d178c
ยท
verified ยท
1 Parent(s): 493924c

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +38 -0
  2. app.py +469 -0
  3. index.html +1142 -0
  4. requirements.txt +13 -0
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ HF_HOME=/app/.cache/huggingface \
6
+ TRANSFORMERS_CACHE=/app/.cache/huggingface \
7
+ PORT=7860 \
8
+ PYTHONIOENCODING=utf-8
9
+
10
+ WORKDIR /app
11
+
12
+ RUN apt-get update && apt-get install -y \
13
+ git \
14
+ wget \
15
+ ffmpeg \
16
+ libglib2.0-0 \
17
+ libsm6 \
18
+ libxext6 \
19
+ libxrender-dev \
20
+ libgomp1 \
21
+ libgl1 \
22
+ && rm -rf /var/lib/apt/lists/*
23
+
24
+ COPY requirements.txt .
25
+
26
+ RUN pip install --no-cache-dir --upgrade pip && \
27
+ pip install --no-cache-dir -r requirements.txt
28
+
29
+ COPY app.py .
30
+ COPY index.html .
31
+
32
+ RUN mkdir -p /app/.cache/huggingface /tmp/video_filter && \
33
+ chmod -R 777 /app/.cache /tmp/video_filter
34
+
35
+ EXPOSE 7860
36
+
37
+ # โ† ุชุดุบูŠู„ ุจุฏูˆู† buffering ู„ุฑุคูŠุฉ ุงู„ู€ logs ููˆุฑุงู‹
38
+ CMD ["python", "-u", "app.py"]
app.py ADDED
@@ -0,0 +1,469 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ๏ปฟimport sys
2
+ import types
3
+ import importlib.util
4
+ import os
5
+ import time
6
+ import uuid
7
+ import threading
8
+ import subprocess
9
+ from io import BytesIO
10
+
11
+ import cv2
12
+ import torch
13
+ from PIL import Image
14
+ from contextlib import asynccontextmanager
15
+ from fastapi import FastAPI, HTTPException, UploadFile, File
16
+ from fastapi.middleware.cors import CORSMiddleware
17
+ from fastapi.responses import FileResponse, HTMLResponse
18
+ from transformers import (
19
+ BlipProcessor,
20
+ BlipForQuestionAnswering,
21
+ AutoProcessor,
22
+ AutoModelForCausalLM,
23
+ )
24
+
25
+ # --- flash_attn mock for CPU environments that don't provide it ---
26
+ flash_mock = types.ModuleType("flash_attn")
27
+ flash_mock.__version__ = "2.0.0"
28
+ flash_mock.__spec__ = importlib.util.spec_from_loader("flash_attn", loader=None)
29
+ sys.modules["flash_attn"] = flash_mock
30
+ sys.modules["flash_attn.flash_attn_interface"] = types.ModuleType("flash_attn.flash_attn_interface")
31
+ sys.modules["flash_attn.bert_padding"] = types.ModuleType("flash_attn.bert_padding")
32
+
33
+ # --- Configuration ---
34
+ BLIP_MODEL_ID = os.getenv("BLIP_MODEL_ID", "Salesforce/blip-vqa-base")
35
+ FLORENCE_MODEL_ID = os.getenv("FLORENCE_MODEL_ID", "microsoft/Florence-2-base-ft")
36
+ FRAMES_PER_SECOND = 1
37
+ TEMP_DIR = os.getenv("TEMP_DIR", "/tmp/video_filter")
38
+ os.makedirs(TEMP_DIR, exist_ok=True)
39
+
40
+ BLIP_QUESTIONS = [
41
+ "is there a person in this image?",
42
+ "is there a woman in this image?",
43
+ "is there a human body part in this image?",
44
+ "is there a hand or arm visible?",
45
+ "is there a face visible?",
46
+ "is there a leg or foot visible?",
47
+ "is there a belly or stomach visible?",
48
+ ]
49
+
50
+ FLORENCE_QUESTION = (
51
+ "Is there a woman or any part of a woman's body in this image? "
52
+ "Answer yes or no only."
53
+ )
54
+
55
+ MODEL_DATA = {}
56
+ MODEL_STATUS = {"status": "loading", "message": "ุฌุงุฑูŠ ุชุญู…ูŠู„ ุงู„ู†ู…ุงุฐุฌ..."}
57
+
58
+
59
+ def load_models():
60
+ try:
61
+ print("Loading BLIP...", flush=True)
62
+ MODEL_STATUS.update({"status": "loading", "message": "ุฌุงุฑูŠ ุชุญู…ูŠู„ BLIP..."})
63
+ start = time.time()
64
+ MODEL_DATA["blip_processor"] = BlipProcessor.from_pretrained(BLIP_MODEL_ID)
65
+ MODEL_DATA["blip_model"] = BlipForQuestionAnswering.from_pretrained(
66
+ BLIP_MODEL_ID,
67
+ torch_dtype=torch.float32,
68
+ ).eval()
69
+ print(f"BLIP ready in {time.time() - start:.1f}s", flush=True)
70
+
71
+ print("Loading Florence-2...", flush=True)
72
+ MODEL_STATUS.update({"status": "loading", "message": "ุฌุงุฑูŠ ุชุญู…ูŠู„ Florence-2..."})
73
+ start = time.time()
74
+ MODEL_DATA["florence_processor"] = AutoProcessor.from_pretrained(
75
+ FLORENCE_MODEL_ID,
76
+ trust_remote_code=True,
77
+ )
78
+ MODEL_DATA["florence_model"] = AutoModelForCausalLM.from_pretrained(
79
+ FLORENCE_MODEL_ID,
80
+ torch_dtype=torch.float32,
81
+ trust_remote_code=True,
82
+ attn_implementation="eager",
83
+ ).eval()
84
+ print(f"Florence-2 ready in {time.time() - start:.1f}s", flush=True)
85
+
86
+ MODEL_STATUS.update({"status": "ready", "message": "ุงู„ู†ู…ุงุฐุฌ ุฌุงู‡ุฒุฉ"})
87
+ print("All models loaded", flush=True)
88
+ except Exception as e:
89
+ MODEL_STATUS.update({"status": "error", "message": str(e)})
90
+ print(f"Error loading models: {e}", flush=True)
91
+
92
+
93
+ @asynccontextmanager
94
+ async def lifespan(app: FastAPI):
95
+ thread = threading.Thread(target=load_models, daemon=True)
96
+ thread.start()
97
+ print("Server started, models are loading in background", flush=True)
98
+ yield
99
+ MODEL_DATA.clear()
100
+
101
+
102
+ app = FastAPI(
103
+ title="Video Female Filter",
104
+ description="ุชุญู„ูŠู„ ุงู„ููŠุฏูŠูˆ ูˆุฅุฒุงู„ุฉ ุงู„ู…ู‚ุงุทุน ุบูŠุฑ ุงู„ู…ุฑุบูˆุจุฉ",
105
+ version="1.1.0",
106
+ lifespan=lifespan,
107
+ )
108
+
109
+ app.add_middleware(
110
+ CORSMiddleware,
111
+ allow_origins=["*"],
112
+ allow_credentials=False,
113
+ allow_methods=["*"],
114
+ allow_headers=["*"],
115
+ )
116
+
117
+
118
+ def ensure_models_ready():
119
+ if MODEL_STATUS["status"] != "ready":
120
+ raise HTTPException(
121
+ status_code=503,
122
+ detail=f"ุงู„ู†ู…ุงุฐุฌ ู„ู… ุชูƒุชู…ู„ ุจุนุฏ: {MODEL_STATUS['message']}",
123
+ )
124
+
125
+
126
+ def run_blip(image: Image.Image) -> dict:
127
+ processor = MODEL_DATA["blip_processor"]
128
+ model = MODEL_DATA["blip_model"]
129
+ yes_answers = {}
130
+ no_answers = {}
131
+
132
+ for question in BLIP_QUESTIONS:
133
+ inputs = processor(image, question, return_tensors="pt")
134
+ with torch.no_grad():
135
+ out = model.generate(**inputs, max_new_tokens=5)
136
+ answer = processor.decode(out[0], skip_special_tokens=True).strip().lower()
137
+ if answer == "yes" or answer.startswith("yes"):
138
+ yes_answers[question] = answer
139
+ else:
140
+ no_answers[question] = answer
141
+
142
+ return {"yes": yes_answers, "no": no_answers}
143
+
144
+
145
+ def run_florence(image: Image.Image) -> str:
146
+ processor = MODEL_DATA["florence_processor"]
147
+ model = MODEL_DATA["florence_model"]
148
+ task = "<VQA>"
149
+ prompt = f"{task}{FLORENCE_QUESTION}"
150
+ inputs = processor(text=prompt, images=image, return_tensors="pt")
151
+
152
+ with torch.no_grad():
153
+ generated_ids = model.generate(
154
+ input_ids=inputs["input_ids"],
155
+ pixel_values=inputs["pixel_values"],
156
+ max_new_tokens=10,
157
+ do_sample=False,
158
+ )
159
+
160
+ generated_text = processor.batch_decode(generated_ids, skip_special_tokens=False)[0]
161
+ parsed = processor.post_process_generation(
162
+ generated_text,
163
+ task=task,
164
+ image_size=(image.width, image.height),
165
+ )
166
+ return parsed.get(task, "").strip().lower()
167
+
168
+
169
+ def is_female_in_frame(image: Image.Image) -> tuple[bool, str]:
170
+ blip_result = run_blip(image)
171
+ yes_q = blip_result["yes"]
172
+
173
+ if "is there a woman in this image?" in yes_q:
174
+ return True, "blip_woman"
175
+
176
+ if not yes_q:
177
+ return False, "blip_clean"
178
+
179
+ florence_answer = run_florence(image)
180
+ if "yes" in florence_answer:
181
+ return True, "florence_confirmed"
182
+
183
+ return False, "florence_clean"
184
+
185
+
186
+ def run_cmd(command: list[str], fail_message: str):
187
+ result = subprocess.run(command, capture_output=True, text=True)
188
+ if result.returncode != 0:
189
+ stderr = (result.stderr or "").strip()
190
+ print(f"{fail_message}: {stderr}", flush=True)
191
+ raise RuntimeError(f"{fail_message}: {stderr or 'unknown ffmpeg error'}")
192
+
193
+
194
+ def normalize_segments(segments: list[list[float]], duration_sec: float) -> list[list[float]]:
195
+ clipped = []
196
+ for start, end in segments:
197
+ s = max(0.0, min(start, duration_sec))
198
+ e = max(0.0, min(end, duration_sec))
199
+ if e - s >= 0.05:
200
+ clipped.append([s, e])
201
+
202
+ if not clipped:
203
+ return []
204
+
205
+ clipped.sort(key=lambda x: x[0])
206
+ merged = [clipped[0]]
207
+
208
+ for s, e in clipped[1:]:
209
+ last = merged[-1]
210
+ if s <= last[1]:
211
+ last[1] = max(last[1], e)
212
+ else:
213
+ merged.append([s, e])
214
+
215
+ return merged
216
+
217
+
218
+ def build_keep_segments(female_segments: list[list[float]], duration_sec: float) -> list[list[float]]:
219
+ keep_segments = []
220
+ prev_end = 0.0
221
+
222
+ for s, e in female_segments:
223
+ if prev_end < s:
224
+ keep_segments.append([prev_end, s])
225
+ prev_end = e
226
+
227
+ if prev_end < duration_sec:
228
+ keep_segments.append([prev_end, duration_sec])
229
+
230
+ return [seg for seg in keep_segments if seg[1] - seg[0] >= 0.05]
231
+
232
+
233
+ def render_clean_video(input_path: str, output_path: str, keep_segments: list[list[float]]):
234
+ if not keep_segments:
235
+ raise RuntimeError("No clean segments to keep")
236
+
237
+ video_parts = []
238
+ audio_parts = []
239
+
240
+ for i, (start, end) in enumerate(keep_segments):
241
+ video_parts.append(
242
+ f"[0:v]trim=start={start:.3f}:end={end:.3f},setpts=PTS-STARTPTS[v{i}]"
243
+ )
244
+ audio_parts.append(
245
+ f"[0:a]atrim=start={start:.3f}:end={end:.3f},asetpts=PTS-STARTPTS[a{i}]"
246
+ )
247
+
248
+ video_concat_inputs = "".join(f"[v{i}]" for i in range(len(keep_segments)))
249
+ audio_concat_inputs = "".join(f"[a{i}]" for i in range(len(keep_segments)))
250
+
251
+ filter_with_audio = (
252
+ ";".join(video_parts + audio_parts)
253
+ + f";{video_concat_inputs}concat=n={len(keep_segments)}:v=1:a=0[vout]"
254
+ + f";{audio_concat_inputs}concat=n={len(keep_segments)}:v=0:a=1[aout]"
255
+ )
256
+
257
+ cmd_with_audio = [
258
+ "ffmpeg", "-y", "-i", input_path,
259
+ "-filter_complex", filter_with_audio,
260
+ "-map", "[vout]",
261
+ "-map", "[aout]",
262
+ "-c:v", "mpeg4", "-q:v", "4",
263
+ "-c:a", "aac", "-b:a", "128k",
264
+ "-movflags", "+faststart",
265
+ output_path,
266
+ ]
267
+
268
+ try:
269
+ run_cmd(cmd_with_audio, "ffmpeg failed while building clean video with audio")
270
+ return
271
+ except RuntimeError:
272
+ pass
273
+
274
+ filter_video_only = (
275
+ ";".join(video_parts)
276
+ + f";{video_concat_inputs}concat=n={len(keep_segments)}:v=1:a=0[vout]"
277
+ )
278
+
279
+ cmd_video_only = [
280
+ "ffmpeg", "-y", "-i", input_path,
281
+ "-filter_complex", filter_video_only,
282
+ "-map", "[vout]",
283
+ "-c:v", "mpeg4", "-q:v", "4",
284
+ "-an",
285
+ "-movflags", "+faststart",
286
+ output_path,
287
+ ]
288
+
289
+ run_cmd(cmd_video_only, "ffmpeg failed while building clean video without audio")
290
+
291
+
292
+ @app.get("/", response_class=HTMLResponse)
293
+ def root():
294
+ with open("index.html", "r", encoding="utf-8") as f:
295
+ return f.read()
296
+
297
+
298
+ @app.get("/health")
299
+ def health():
300
+ return {
301
+ "status": MODEL_STATUS["status"],
302
+ "message": MODEL_STATUS["message"],
303
+ "blip_loaded": "blip_model" in MODEL_DATA,
304
+ "florence_loaded": "florence_model" in MODEL_DATA,
305
+ }
306
+
307
+
308
+ @app.post("/analyze-file")
309
+ async def analyze_file(file: UploadFile = File(...)):
310
+ ensure_models_ready()
311
+
312
+ if not file.content_type or not file.content_type.startswith("image/"):
313
+ raise HTTPException(status_code=400, detail="ุงู„ู…ู„ู ู„ูŠุณ ุตูˆุฑุฉ")
314
+
315
+ try:
316
+ image_bytes = await file.read()
317
+ image = Image.open(BytesIO(image_bytes)).convert("RGB")
318
+ has_female, reason = is_female_in_frame(image)
319
+
320
+ return {
321
+ "has_female": has_female,
322
+ "decision": "BLOCK" if has_female else "ALLOW",
323
+ "reason": reason,
324
+ "status": "success",
325
+ }
326
+ except Exception as e:
327
+ raise HTTPException(status_code=500, detail=str(e))
328
+
329
+
330
+ @app.post("/analyze-video")
331
+ async def analyze_video(file: UploadFile = File(...)):
332
+ ensure_models_ready()
333
+
334
+ if not file.content_type or not file.content_type.startswith("video/"):
335
+ raise HTTPException(status_code=400, detail="ุงู„ู…ู„ู ู„ูŠุณ ููŠุฏูŠูˆ")
336
+
337
+ job_id = str(uuid.uuid4())[:8]
338
+ input_path = f"{TEMP_DIR}/{job_id}_input.mp4"
339
+ output_path = f"{TEMP_DIR}/{job_id}_output.mp4"
340
+
341
+ with open(input_path, "wb") as f:
342
+ f.write(await file.read())
343
+
344
+ cap = None
345
+ try:
346
+ cap = cv2.VideoCapture(input_path)
347
+ if not cap.isOpened():
348
+ raise HTTPException(status_code=400, detail="ุชุนุฐุฑ ู‚ุฑุงุกุฉ ู…ู„ู ุงู„ููŠุฏูŠูˆ")
349
+
350
+ fps = cap.get(cv2.CAP_PROP_FPS) or 25
351
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
352
+ if total_frames <= 0:
353
+ raise HTTPException(status_code=400, detail="ุงู„ููŠุฏูŠูˆ ูุงุฑุบ ุฃูˆ ุบูŠุฑ ู…ุฏุนูˆู…")
354
+
355
+ duration_sec = total_frames / fps
356
+ print(f"Video info: frames={total_frames}, fps={fps:.2f}, duration={duration_sec:.2f}s", flush=True)
357
+
358
+ frame_interval = max(1, int(fps / FRAMES_PER_SECOND))
359
+ female_segments = []
360
+ analysis_log = []
361
+ in_female_seg = False
362
+ seg_start = 0.0
363
+ frame_idx = 0
364
+ start_time = time.time()
365
+
366
+ while True:
367
+ ret, frame = cap.read()
368
+ if not ret:
369
+ break
370
+
371
+ if frame_idx % frame_interval == 0:
372
+ current_sec = frame_idx / fps
373
+ pil_image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
374
+ has_female, reason = is_female_in_frame(pil_image)
375
+
376
+ analysis_log.append({
377
+ "second": round(current_sec, 2),
378
+ "has_female": has_female,
379
+ "reason": reason,
380
+ })
381
+
382
+ if has_female and not in_female_seg:
383
+ in_female_seg = True
384
+ seg_start = max(0.0, current_sec - 0.5)
385
+ elif not has_female and in_female_seg:
386
+ in_female_seg = False
387
+ female_segments.append([seg_start, min(duration_sec, current_sec + 0.5)])
388
+
389
+ frame_idx += 1
390
+
391
+ if in_female_seg:
392
+ female_segments.append([seg_start, duration_sec])
393
+
394
+ elapsed_analysis = round(time.time() - start_time, 2)
395
+ female_segments = normalize_segments(female_segments, duration_sec)
396
+
397
+ if not female_segments:
398
+ return {
399
+ "has_female": False,
400
+ "female_segments": [],
401
+ "analysis_log": analysis_log,
402
+ "message": "ุงู„ููŠุฏูŠูˆ ู†ุธูŠู ูˆู„ุง ูŠุญุชูˆูŠ ุนู„ู‰ ุธู‡ูˆุฑ ู†ุณุงุก",
403
+ "analysis_time": elapsed_analysis,
404
+ "output_available": False,
405
+ "status": "success",
406
+ }
407
+
408
+ keep_segments = build_keep_segments(female_segments, duration_sec)
409
+
410
+ if not keep_segments:
411
+ return {
412
+ "has_female": True,
413
+ "female_segments": female_segments,
414
+ "analysis_log": analysis_log,
415
+ "message": "ุงู„ููŠุฏูŠูˆ ุจุงู„ูƒุงู…ู„ ูŠุญุชูˆูŠ ุนู„ู‰ ุธู‡ูˆุฑ ู†ุณุงุก",
416
+ "analysis_time": elapsed_analysis,
417
+ "output_available": False,
418
+ "status": "success",
419
+ }
420
+
421
+ render_clean_video(input_path, output_path, keep_segments)
422
+
423
+ if not os.path.exists(output_path):
424
+ raise RuntimeError("ุชุนุฐุฑ ุฅู†ุดุงุก ู…ู„ู ุงู„ููŠุฏูŠูˆ ุงู„ู†ู‡ุงุฆูŠ")
425
+
426
+ total_removed = sum(e - s for s, e in female_segments)
427
+
428
+ return {
429
+ "has_female": True,
430
+ "female_segments": female_segments,
431
+ "kept_segments": keep_segments,
432
+ "total_removed_sec": round(total_removed, 2),
433
+ "analysis_log": analysis_log,
434
+ "analysis_time": elapsed_analysis,
435
+ "output_available": True,
436
+ "output_job_id": job_id,
437
+ "download_url": f"/download/{job_id}",
438
+ "message": f"ุชู… ุญุฐู {round(total_removed, 1)} ุซุงู†ูŠุฉ ู…ู† ุงู„ููŠุฏูŠูˆ",
439
+ "status": "success",
440
+ }
441
+
442
+ except HTTPException:
443
+ raise
444
+ except Exception as e:
445
+ raise HTTPException(status_code=500, detail=str(e))
446
+ finally:
447
+ if cap is not None:
448
+ cap.release()
449
+ if os.path.exists(input_path):
450
+ os.remove(input_path)
451
+
452
+
453
+ @app.get("/download/{job_id}")
454
+ def download_video(job_id: str):
455
+ output_path = f"{TEMP_DIR}/{job_id}_output.mp4"
456
+ if not os.path.exists(output_path):
457
+ raise HTTPException(status_code=404, detail="ุงู„ููŠุฏูŠูˆ ุบูŠุฑ ู…ูˆุฌูˆุฏ")
458
+
459
+ return FileResponse(
460
+ output_path,
461
+ media_type="video/mp4",
462
+ filename="clean_video.mp4",
463
+ )
464
+
465
+
466
+ if __name__ == "__main__":
467
+ import uvicorn
468
+
469
+ uvicorn.run(app, host="0.0.0.0", port=7860)
index.html ADDED
@@ -0,0 +1,1142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ar" dir="rtl">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Video Filter AI</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=Tajawal:wght@300;400;700;900&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root {
10
+ --bg: #0a0a0f;
11
+ --surface: #111118;
12
+ --border: #1e1e2e;
13
+ --accent: #00ff88;
14
+ --accent2: #ff3366;
15
+ --accent3: #4488ff;
16
+ --text: #e8e8f0;
17
+ --muted: #555570;
18
+ --warn: #ffaa00;
19
+ }
20
+
21
+ * { margin: 0; padding: 0; box-sizing: border-box; }
22
+
23
+ body {
24
+ font-family: 'Tajawal', sans-serif;
25
+ background: var(--bg);
26
+ color: var(--text);
27
+ min-height: 100vh;
28
+ overflow-x: hidden;
29
+ }
30
+
31
+ /* โ”€โ”€โ”€ Grid Background โ”€โ”€โ”€ */
32
+ body::before {
33
+ content: '';
34
+ position: fixed;
35
+ inset: 0;
36
+ background-image:
37
+ linear-gradient(rgba(0,255,136,0.03) 1px, transparent 1px),
38
+ linear-gradient(90deg, rgba(0,255,136,0.03) 1px, transparent 1px);
39
+ background-size: 40px 40px;
40
+ pointer-events: none;
41
+ z-index: 0;
42
+ }
43
+
44
+ .container {
45
+ max-width: 900px;
46
+ margin: 0 auto;
47
+ padding: 40px 20px;
48
+ position: relative;
49
+ z-index: 1;
50
+ }
51
+
52
+ /* โ”€โ”€โ”€ Header โ”€โ”€โ”€ */
53
+ .header {
54
+ text-align: center;
55
+ margin-bottom: 48px;
56
+ }
57
+
58
+ .header-badge {
59
+ display: inline-block;
60
+ font-family: 'IBM Plex Mono', monospace;
61
+ font-size: 11px;
62
+ color: var(--accent);
63
+ border: 1px solid var(--accent);
64
+ padding: 4px 12px;
65
+ border-radius: 2px;
66
+ letter-spacing: 3px;
67
+ margin-bottom: 16px;
68
+ text-transform: uppercase;
69
+ }
70
+
71
+ .header h1 {
72
+ font-size: 42px;
73
+ font-weight: 900;
74
+ line-height: 1.1;
75
+ margin-bottom: 8px;
76
+ }
77
+
78
+ .header h1 span { color: var(--accent); }
79
+
80
+ .header p {
81
+ color: var(--muted);
82
+ font-size: 15px;
83
+ font-weight: 300;
84
+ }
85
+
86
+ /* โ”€โ”€โ”€ API Status โ”€โ”€โ”€ */
87
+ .api-status {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 8px;
91
+ justify-content: center;
92
+ margin-bottom: 32px;
93
+ font-family: 'IBM Plex Mono', monospace;
94
+ font-size: 12px;
95
+ }
96
+
97
+ .status-dot {
98
+ width: 8px; height: 8px;
99
+ border-radius: 50%;
100
+ background: var(--muted);
101
+ animation: none;
102
+ }
103
+ .status-dot.ready { background: var(--accent); animation: pulse 2s infinite; }
104
+ .status-dot.loading { background: var(--warn); animation: pulse 1s infinite; }
105
+ .status-dot.error { background: var(--accent2); }
106
+
107
+ @keyframes pulse {
108
+ 0%, 100% { opacity: 1; box-shadow: 0 0 0 0 currentColor; }
109
+ 50% { opacity: 0.7; box-shadow: 0 0 0 4px transparent; }
110
+ }
111
+
112
+ /* โ”€โ”€โ”€ Upload Zone โ”€โ”€โ”€ */
113
+ .upload-zone {
114
+ border: 1px dashed var(--border);
115
+ border-radius: 8px;
116
+ padding: 60px 40px;
117
+ text-align: center;
118
+ cursor: pointer;
119
+ transition: all 0.3s ease;
120
+ background: var(--surface);
121
+ position: relative;
122
+ overflow: hidden;
123
+ }
124
+
125
+ .upload-zone::before {
126
+ content: '';
127
+ position: absolute;
128
+ inset: 0;
129
+ background: linear-gradient(135deg, rgba(0,255,136,0.03), transparent);
130
+ opacity: 0;
131
+ transition: opacity 0.3s;
132
+ }
133
+
134
+ .upload-zone:hover, .upload-zone.drag-over {
135
+ border-color: var(--accent);
136
+ background: rgba(0,255,136,0.03);
137
+ }
138
+
139
+ .upload-zone:hover::before, .upload-zone.drag-over::before { opacity: 1; }
140
+
141
+ .upload-icon {
142
+ font-size: 48px;
143
+ margin-bottom: 16px;
144
+ display: block;
145
+ }
146
+
147
+ .upload-zone h3 {
148
+ font-size: 18px;
149
+ font-weight: 700;
150
+ margin-bottom: 8px;
151
+ }
152
+
153
+ .upload-zone p {
154
+ color: var(--muted);
155
+ font-size: 13px;
156
+ font-family: 'IBM Plex Mono', monospace;
157
+ }
158
+
159
+ #fileInput { display: none; }
160
+
161
+ /* โ”€โ”€โ”€ Video Preview โ”€โ”€โ”€ */
162
+ .video-preview {
163
+ display: none;
164
+ background: var(--surface);
165
+ border: 1px solid var(--border);
166
+ border-radius: 8px;
167
+ overflow: hidden;
168
+ margin-bottom: 24px;
169
+ }
170
+
171
+ .video-preview video {
172
+ width: 100%;
173
+ max-height: 300px;
174
+ display: block;
175
+ background: #000;
176
+ }
177
+
178
+ .video-meta {
179
+ padding: 16px 20px;
180
+ display: flex;
181
+ gap: 24px;
182
+ font-family: 'IBM Plex Mono', monospace;
183
+ font-size: 12px;
184
+ color: var(--muted);
185
+ border-top: 1px solid var(--border);
186
+ flex-wrap: wrap;
187
+ }
188
+
189
+ .video-meta span { display: flex; align-items: center; gap: 6px; }
190
+ .video-meta strong { color: var(--text); }
191
+
192
+ /* โ”€โ”€โ”€ Upload Progress โ”€โ”€โ”€ */
193
+ .upload-progress {
194
+ display: none;
195
+ margin-bottom: 24px;
196
+ }
197
+
198
+ .progress-label {
199
+ display: flex;
200
+ justify-content: space-between;
201
+ font-family: 'IBM Plex Mono', monospace;
202
+ font-size: 12px;
203
+ color: var(--muted);
204
+ margin-bottom: 8px;
205
+ }
206
+
207
+ .progress-bar {
208
+ height: 3px;
209
+ background: var(--border);
210
+ border-radius: 2px;
211
+ overflow: hidden;
212
+ }
213
+
214
+ .progress-fill {
215
+ height: 100%;
216
+ background: var(--accent);
217
+ border-radius: 2px;
218
+ transition: width 0.3s ease;
219
+ width: 0%;
220
+ box-shadow: 0 0 8px var(--accent);
221
+ }
222
+
223
+ /* โ”€โ”€โ”€ Action Buttons โ”€โ”€โ”€ */
224
+ .actions {
225
+ display: flex;
226
+ gap: 12px;
227
+ margin-bottom: 32px;
228
+ flex-wrap: wrap;
229
+ }
230
+
231
+ .btn {
232
+ flex: 1;
233
+ padding: 14px 24px;
234
+ border: none;
235
+ border-radius: 6px;
236
+ font-family: 'Tajawal', sans-serif;
237
+ font-size: 15px;
238
+ font-weight: 700;
239
+ cursor: pointer;
240
+ transition: all 0.2s ease;
241
+ display: flex;
242
+ align-items: center;
243
+ justify-content: center;
244
+ gap: 8px;
245
+ min-width: 160px;
246
+ }
247
+
248
+ .btn:disabled {
249
+ opacity: 0.3;
250
+ cursor: not-allowed;
251
+ transform: none !important;
252
+ }
253
+
254
+ .btn-primary {
255
+ background: var(--accent);
256
+ color: #000;
257
+ }
258
+ .btn-primary:not(:disabled):hover {
259
+ transform: translateY(-2px);
260
+ box-shadow: 0 8px 24px rgba(0,255,136,0.3);
261
+ }
262
+
263
+ .btn-danger {
264
+ background: transparent;
265
+ color: var(--accent2);
266
+ border: 1px solid var(--accent2);
267
+ }
268
+ .btn-danger:not(:disabled):hover {
269
+ background: rgba(255,51,102,0.1);
270
+ transform: translateY(-2px);
271
+ }
272
+
273
+ .btn-secondary {
274
+ background: transparent;
275
+ color: var(--accent3);
276
+ border: 1px solid var(--accent3);
277
+ }
278
+ .btn-secondary:not(:disabled):hover {
279
+ background: rgba(68,136,255,0.1);
280
+ transform: translateY(-2px);
281
+ }
282
+
283
+ /* โ”€โ”€โ”€ Timeline โ”€โ”€โ”€ */
284
+ .timeline-section {
285
+ display: none;
286
+ margin-bottom: 32px;
287
+ }
288
+
289
+ .timeline-header {
290
+ display: flex;
291
+ align-items: center;
292
+ gap: 12px;
293
+ margin-bottom: 20px;
294
+ }
295
+
296
+ .timeline-header h2 {
297
+ font-size: 16px;
298
+ font-weight: 700;
299
+ font-family: 'IBM Plex Mono', monospace;
300
+ color: var(--accent);
301
+ letter-spacing: 1px;
302
+ text-transform: uppercase;
303
+ }
304
+
305
+ .timeline {
306
+ position: relative;
307
+ padding-right: 32px;
308
+ }
309
+
310
+ .timeline::before {
311
+ content: '';
312
+ position: absolute;
313
+ right: 11px;
314
+ top: 0;
315
+ bottom: 0;
316
+ width: 1px;
317
+ background: var(--border);
318
+ }
319
+
320
+ .timeline-item {
321
+ position: relative;
322
+ padding: 0 24px 24px 0;
323
+ opacity: 0;
324
+ transform: translateX(10px);
325
+ transition: all 0.4s ease;
326
+ }
327
+
328
+ .timeline-item.visible {
329
+ opacity: 1;
330
+ transform: translateX(0);
331
+ }
332
+
333
+ .timeline-dot {
334
+ position: absolute;
335
+ right: -21px;
336
+ top: 4px;
337
+ width: 14px;
338
+ height: 14px;
339
+ border-radius: 50%;
340
+ border: 2px solid var(--border);
341
+ background: var(--bg);
342
+ transition: all 0.3s ease;
343
+ }
344
+
345
+ .timeline-item.status-done .timeline-dot {
346
+ background: var(--accent);
347
+ border-color: var(--accent);
348
+ box-shadow: 0 0 12px var(--accent);
349
+ }
350
+ .timeline-item.status-active .timeline-dot {
351
+ background: var(--warn);
352
+ border-color: var(--warn);
353
+ animation: pulse 1s infinite;
354
+ }
355
+ .timeline-item.status-error .timeline-dot {
356
+ background: var(--accent2);
357
+ border-color: var(--accent2);
358
+ }
359
+
360
+ .timeline-content {
361
+ background: var(--surface);
362
+ border: 1px solid var(--border);
363
+ border-radius: 6px;
364
+ padding: 14px 16px;
365
+ transition: border-color 0.3s;
366
+ }
367
+
368
+ .timeline-item.status-done .timeline-content { border-color: rgba(0,255,136,0.3); }
369
+ .timeline-item.status-active .timeline-content { border-color: rgba(255,170,0,0.3); }
370
+ .timeline-item.status-error .timeline-content { border-color: rgba(255,51,102,0.3); }
371
+
372
+ .timeline-title {
373
+ display: flex;
374
+ align-items: center;
375
+ gap: 8px;
376
+ margin-bottom: 4px;
377
+ }
378
+
379
+ .timeline-title strong {
380
+ font-size: 14px;
381
+ font-weight: 700;
382
+ }
383
+
384
+ .timeline-title .tag {
385
+ font-family: 'IBM Plex Mono', monospace;
386
+ font-size: 10px;
387
+ padding: 2px 8px;
388
+ border-radius: 2px;
389
+ background: rgba(255,255,255,0.05);
390
+ color: var(--muted);
391
+ letter-spacing: 1px;
392
+ }
393
+
394
+ .timeline-desc {
395
+ font-size: 13px;
396
+ color: var(--muted);
397
+ line-height: 1.5;
398
+ }
399
+
400
+ .timeline-details {
401
+ margin-top: 10px;
402
+ padding: 10px 12px;
403
+ background: rgba(0,0,0,0.3);
404
+ border-radius: 4px;
405
+ font-family: 'IBM Plex Mono', monospace;
406
+ font-size: 11px;
407
+ color: var(--muted);
408
+ max-height: 0;
409
+ overflow: hidden;
410
+ transition: max-height 0.4s ease;
411
+ line-height: 1.8;
412
+ }
413
+
414
+ .timeline-details.expanded { max-height: 400px; }
415
+ .timeline-details .highlight { color: var(--accent); }
416
+ .timeline-details .warn { color: var(--warn); }
417
+ .timeline-details .danger { color: var(--accent2); }
418
+
419
+ /* โ”€โ”€โ”€ Frame Log โ”€โ”€โ”€ */
420
+ .frame-log {
421
+ display: flex;
422
+ flex-wrap: wrap;
423
+ gap: 4px;
424
+ margin-top: 8px;
425
+ }
426
+
427
+ .frame-badge {
428
+ font-family: 'IBM Plex Mono', monospace;
429
+ font-size: 10px;
430
+ padding: 2px 6px;
431
+ border-radius: 2px;
432
+ border: 1px solid;
433
+ }
434
+
435
+ .frame-badge.clean {
436
+ color: var(--accent);
437
+ border-color: rgba(0,255,136,0.3);
438
+ background: rgba(0,255,136,0.05);
439
+ }
440
+
441
+ .frame-badge.female {
442
+ color: var(--accent2);
443
+ border-color: rgba(255,51,102,0.3);
444
+ background: rgba(255,51,102,0.05);
445
+ }
446
+
447
+ /* โ”€โ”€โ”€ Results โ”€โ”€โ”€ */
448
+ .results-section {
449
+ display: none;
450
+ margin-bottom: 32px;
451
+ }
452
+
453
+ .result-card {
454
+ background: var(--surface);
455
+ border: 1px solid var(--border);
456
+ border-radius: 8px;
457
+ overflow: hidden;
458
+ margin-bottom: 16px;
459
+ }
460
+
461
+ .result-card-header {
462
+ padding: 16px 20px;
463
+ display: flex;
464
+ align-items: center;
465
+ gap: 12px;
466
+ border-bottom: 1px solid var(--border);
467
+ }
468
+
469
+ .result-card-header h3 { font-size: 15px; font-weight: 700; }
470
+
471
+ .result-card-body { padding: 16px 20px; }
472
+
473
+ .verdict {
474
+ display: flex;
475
+ align-items: center;
476
+ gap: 16px;
477
+ padding: 20px;
478
+ border-radius: 6px;
479
+ margin-bottom: 16px;
480
+ }
481
+
482
+ .verdict.clean {
483
+ background: rgba(0,255,136,0.05);
484
+ border: 1px solid rgba(0,255,136,0.2);
485
+ }
486
+
487
+ .verdict.female {
488
+ background: rgba(255,51,102,0.05);
489
+ border: 1px solid rgba(255,51,102,0.2);
490
+ }
491
+
492
+ .verdict-icon { font-size: 32px; }
493
+ .verdict-text h4 { font-size: 18px; font-weight: 900; }
494
+ .verdict-text p { font-size: 13px; color: var(--muted); margin-top: 4px; }
495
+
496
+ .stats-grid {
497
+ display: grid;
498
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
499
+ gap: 12px;
500
+ margin-bottom: 16px;
501
+ }
502
+
503
+ .stat-box {
504
+ background: rgba(0,0,0,0.3);
505
+ border: 1px solid var(--border);
506
+ border-radius: 6px;
507
+ padding: 14px;
508
+ text-align: center;
509
+ }
510
+
511
+ .stat-box .val {
512
+ font-family: 'IBM Plex Mono', monospace;
513
+ font-size: 22px;
514
+ font-weight: 600;
515
+ color: var(--accent);
516
+ display: block;
517
+ }
518
+
519
+ .stat-box .lbl {
520
+ font-size: 12px;
521
+ color: var(--muted);
522
+ margin-top: 4px;
523
+ display: block;
524
+ }
525
+
526
+ /* โ”€โ”€โ”€ Video Timeline Bar โ”€โ”€โ”€ */
527
+ .video-timeline {
528
+ background: rgba(0,0,0,0.3);
529
+ border-radius: 4px;
530
+ height: 40px;
531
+ position: relative;
532
+ overflow: hidden;
533
+ margin: 16px 0;
534
+ }
535
+
536
+ .video-timeline .segment {
537
+ position: absolute;
538
+ height: 100%;
539
+ top: 0;
540
+ border-radius: 2px;
541
+ }
542
+
543
+ .video-timeline .seg-clean {
544
+ background: rgba(0,255,136,0.3);
545
+ border: 1px solid rgba(0,255,136,0.5);
546
+ }
547
+
548
+ .video-timeline .seg-female {
549
+ background: rgba(255,51,102,0.3);
550
+ border: 1px solid rgba(255,51,102,0.5);
551
+ }
552
+
553
+ .timeline-label {
554
+ display: flex;
555
+ justify-content: space-between;
556
+ font-family: 'IBM Plex Mono', monospace;
557
+ font-size: 11px;
558
+ color: var(--muted);
559
+ margin-top: 4px;
560
+ }
561
+
562
+ /* โ”€โ”€โ”€ Download โ”€โ”€โ”€ */
563
+ .download-section {
564
+ display: none;
565
+ text-align: center;
566
+ padding: 32px;
567
+ background: var(--surface);
568
+ border: 1px solid rgba(0,255,136,0.2);
569
+ border-radius: 8px;
570
+ margin-bottom: 32px;
571
+ }
572
+
573
+ .download-section h3 {
574
+ font-size: 20px;
575
+ font-weight: 900;
576
+ margin-bottom: 8px;
577
+ color: var(--accent);
578
+ }
579
+
580
+ .download-section p {
581
+ color: var(--muted);
582
+ font-size: 13px;
583
+ margin-bottom: 24px;
584
+ }
585
+
586
+ /* โ”€โ”€โ”€ Spinner โ”€โ”€โ”€ */
587
+ .spinner {
588
+ width: 16px; height: 16px;
589
+ border: 2px solid rgba(0,0,0,0.3);
590
+ border-top-color: currentColor;
591
+ border-radius: 50%;
592
+ animation: spin 0.8s linear infinite;
593
+ display: inline-block;
594
+ }
595
+
596
+ @keyframes spin { to { transform: rotate(360deg); } }
597
+
598
+ /* โ”€โ”€โ”€ Alert โ”€โ”€โ”€ */
599
+ .alert {
600
+ padding: 12px 16px;
601
+ border-radius: 6px;
602
+ font-size: 13px;
603
+ margin-bottom: 16px;
604
+ display: none;
605
+ }
606
+ .alert.show { display: block; }
607
+ .alert-warn { background: rgba(255,170,0,0.1); border: 1px solid rgba(255,170,0,0.3); color: var(--warn); }
608
+ .alert-error { background: rgba(255,51,102,0.1); border: 1px solid rgba(255,51,102,0.3); color: var(--accent2); }
609
+
610
+ </style>
611
+ </head>
612
+ <body>
613
+
614
+ <div class="container">
615
+
616
+ <!-- Header -->
617
+ <div class="header">
618
+ <div class="header-badge">AI VIDEO FILTER</div>
619
+ <h1>ุชู†ู‚ูŠุฉ <span>ุงู„ููŠุฏูŠูˆ</span> ุงู„ุฅุนู„ุงู†ูŠ</h1>
620
+ <p>ุฅุฒุงู„ุฉ ู…ู‚ุงุทุน ุงู„ู†ุณุงุก ุชู„ู‚ุงุฆูŠุงู‹ ุจุงุณุชุฎุฏุงู… BLIP + Florence-2</p>
621
+ </div>
622
+
623
+ <!-- API Status -->
624
+ <div class="api-status">
625
+ <div class="status-dot" id="statusDot"></div>
626
+ <span id="statusText" style="font-family:'IBM Plex Mono',monospace;font-size:12px;color:var(--muted)">ุฌุงุฑูŠ ุงู„ุงุชุตุงู„ ุจุงู„ู€ API...</span>
627
+ </div>
628
+
629
+ <!-- Alert -->
630
+ <div class="alert alert-warn" id="alertBox"></div>
631
+
632
+ <!-- Upload Zone -->
633
+ <div class="upload-zone" id="uploadZone">
634
+ <span class="upload-icon">๐ŸŽฌ</span>
635
+ <h3>ุงุณุญุจ ุงู„ููŠุฏูŠูˆ ู‡ู†ุง ุฃูˆ ุงุถุบุท ู„ู„ุงุฎุชูŠุงุฑ</h3>
636
+ <p>MP4 / MOV / AVI ยท ุญุฏ ุฃู‚ุตู‰ 200MB</p>
637
+ <input type="file" id="fileInput" accept="video/*">
638
+ </div>
639
+
640
+ <!-- Video Preview -->
641
+ <div class="video-preview" id="videoPreview">
642
+ <video id="videoPlayer" controls></video>
643
+ <div class="video-meta" id="videoMeta"></div>
644
+ </div>
645
+
646
+ <!-- Upload Progress -->
647
+ <div class="upload-progress" id="uploadProgress">
648
+ <div class="progress-label">
649
+ <span id="progressLabel">ุฌุงุฑูŠ ุงู„ุฑูุน...</span>
650
+ <span id="progressPct">0%</span>
651
+ </div>
652
+ <div class="progress-bar">
653
+ <div class="progress-fill" id="progressFill"></div>
654
+ </div>
655
+ </div>
656
+
657
+ <!-- Action Buttons -->
658
+ <div class="actions" id="actionsBar" style="display:none">
659
+ <button class="btn btn-danger" id="btnQuickCheck" disabled>
660
+ <span>๐Ÿ”</span> ูุญุต ุณุฑูŠุน
661
+ </button>
662
+ <button class="btn btn-primary" id="btnAnalyze" disabled>
663
+ <span>โš™๏ธ</span> ุชุญู„ูŠู„ ูˆุชู‚ุทูŠุน
664
+ </button>
665
+ <button class="btn btn-secondary" id="btnReset" onclick="resetAll()">
666
+ <span>โ†บ</span> ุฅุนุงุฏุฉ
667
+ </button>
668
+ </div>
669
+
670
+ <!-- Timeline -->
671
+ <div class="timeline-section" id="timelineSection">
672
+ <div class="timeline-header">
673
+ <h2>โฌก ุณุฌู„ ุงู„ุนู…ู„ูŠุงุช</h2>
674
+ </div>
675
+ <div class="timeline" id="timeline"></div>
676
+ </div>
677
+
678
+ <!-- Results -->
679
+ <div class="results-section" id="resultsSection"></div>
680
+
681
+ <!-- Download -->
682
+ <div class="download-section" id="downloadSection">
683
+ <h3>โœ… ุงู„ููŠุฏูŠูˆ ุงู„ู†ุธูŠู ุฌุงู‡ุฒ</h3>
684
+ <p id="downloadDesc"></p>
685
+ <button class="btn btn-primary" id="btnDownload" style="max-width:240px;margin:0 auto">
686
+ <span>โฌ‡๏ธ</span> ุชุญู…ูŠู„ ุงู„ููŠุฏูŠูˆ ุงู„ู†ุธูŠู
687
+ </button>
688
+ </div>
689
+
690
+ </div>
691
+
692
+ <script>
693
+ const API_BASE = window.location.origin;
694
+ let currentFile = null;
695
+ let currentJobId = null;
696
+ let apiReady = false;
697
+
698
+ // โ”€โ”€โ”€ API Status Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
699
+ async function checkApiStatus() {
700
+ try {
701
+ const res = await fetch(`${API_BASE}/health`);
702
+ const data = await res.json();
703
+ const dot = document.getElementById('statusDot');
704
+ const txt = document.getElementById('statusText');
705
+
706
+ if (data.status === 'ready') {
707
+ dot.className = 'status-dot ready';
708
+ txt.textContent = 'โœ… ุงู„ู†ู…ุงุฐุฌ ุฌุงู‡ุฒุฉ โ€” BLIP + Florence-2';
709
+ txt.style.color = 'var(--accent)';
710
+ apiReady = true;
711
+ if (currentFile) setButtonsDisabled(false);
712
+ } else if (data.status === 'loading') {
713
+ dot.className = 'status-dot loading';
714
+ txt.textContent = `โณ ${data.message}`;
715
+ txt.style.color = 'var(--warn)';
716
+ setTimeout(checkApiStatus, 3000);
717
+ } else {
718
+ dot.className = 'status-dot error';
719
+ txt.textContent = `โŒ ${data.message}`;
720
+ txt.style.color = 'var(--accent2)';
721
+ }
722
+ } catch (e) {
723
+ const dot = document.getElementById('statusDot');
724
+ dot.className = 'status-dot error';
725
+ document.getElementById('statusText').textContent = 'โŒ ู„ุง ูŠู…ูƒู† ุงู„ุงุชุตุงู„ ุจุงู„ู€ API';
726
+ setTimeout(checkApiStatus, 5000);
727
+ }
728
+ }
729
+
730
+ // โ”€โ”€โ”€ Upload Zone โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
731
+ const uploadZone = document.getElementById('uploadZone');
732
+ const fileInput = document.getElementById('fileInput');
733
+
734
+ uploadZone.addEventListener('click', () => fileInput.click());
735
+ uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('drag-over'); });
736
+ uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('drag-over'));
737
+ uploadZone.addEventListener('drop', e => {
738
+ e.preventDefault();
739
+ uploadZone.classList.remove('drag-over');
740
+ if (e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0]);
741
+ });
742
+ fileInput.addEventListener('change', e => { if (e.target.files[0]) handleFile(e.target.files[0]); });
743
+
744
+ function handleFile(file) {
745
+ if (!file.type.startsWith('video/')) {
746
+ showAlert('ุงู„ู…ู„ู ู„ูŠุณ ููŠุฏูŠูˆ! ูŠูู‚ุจู„ ูู‚ุท MP4, MOV, AVI', 'error');
747
+ return;
748
+ }
749
+ if (file.size > 200 * 1024 * 1024) {
750
+ showAlert('ุญุฌู… ุงู„ููŠุฏูŠูˆ ูŠุชุฌุงูˆุฒ 200MB', 'warn');
751
+ return;
752
+ }
753
+
754
+ currentFile = file;
755
+ showVideoPreview(file);
756
+ showTimeline();
757
+ addTimelineItem('ready', 'done', 'โธ ุจุงู†ุชุธุงุฑ ุงู„ุฅุฌุฑุงุก', 'READY',
758
+ 'ุงู„ููŠุฏูŠูˆ ุฌุงู‡ุฒ. ูŠู…ูƒู†ูƒ ุชุดุบูŠู„ "ูุญุต ุณุฑูŠุน" ุฃูˆ "ุชุญู„ูŠู„ ูˆุชู‚ุทูŠุน".');
759
+ document.getElementById('actionsBar').style.display = 'flex';
760
+ setButtonsDisabled(false);
761
+ }
762
+
763
+ function showVideoPreview(file) {
764
+ const preview = document.getElementById('videoPreview');
765
+ const player = document.getElementById('videoPlayer');
766
+ const meta = document.getElementById('videoMeta');
767
+
768
+ player.src = URL.createObjectURL(file);
769
+ preview.style.display = 'block';
770
+ uploadZone.style.display = 'none';
771
+
772
+ player.onloadedmetadata = () => {
773
+ const dur = formatDuration(player.duration);
774
+ const size = (file.size / 1024 / 1024).toFixed(1);
775
+ meta.innerHTML = `
776
+ <span>๐ŸŽฌ <strong>${file.name}</strong></span>
777
+ <span>โฑ ู…ุฏุฉ: <strong>${dur}</strong></span>
778
+ <span>๐Ÿ’พ ุงู„ุญุฌู…: <strong>${size} MB</strong></span>
779
+ <span>๐Ÿ“ ${player.videoWidth}ร—${player.videoHeight}</span>
780
+ `;
781
+ };
782
+ }
783
+
784
+ // โ”€โ”€โ”€ Quick Check โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
785
+ document.getElementById('btnQuickCheck').addEventListener('click', async () => {
786
+ if (!currentFile || !apiReady) return;
787
+ setButtonsDisabled(true);
788
+ addTimelineItem('quickcheck', 'active', '๐Ÿ” ูุญุต ุณุฑูŠุน ู„ู„ููŠุฏูŠูˆ', 'QUICK-CHECK',
789
+ 'ุฌุงุฑูŠ ูุญุต ุฃูˆู„ frame ู…ู† ุงู„ููŠุฏูŠูˆ ู„ู„ุชุญู‚ู‚ ุงู„ุณุฑูŠุน ู…ู† ูˆุฌูˆุฏ ู†ุณุงุก...');
790
+
791
+ try {
792
+ // ู†ุณุชุฎุฑุฌ frame ู…ุจูƒุฑ ู…ู† ุงู„ููŠุฏูŠูˆ
793
+ const canvas = document.createElement('canvas');
794
+ const video = document.getElementById('videoPlayer');
795
+ const seekTime = Math.min(1, Math.max(0, (video.duration || 1) * 0.1));
796
+ video.currentTime = seekTime;
797
+
798
+ await new Promise(r => { video.onseeked = r; });
799
+
800
+ canvas.width = video.videoWidth;
801
+ canvas.height = video.videoHeight;
802
+ canvas.getContext('2d').drawImage(video, 0, 0);
803
+
804
+ canvas.toBlob(async (blob) => {
805
+ const formData = new FormData();
806
+ formData.append('file', blob, 'frame.jpg');
807
+
808
+ const res = await fetch(`${API_BASE}/analyze-file`, { method: 'POST', body: formData });
809
+ const data = await res.json();
810
+ if (!res.ok) {
811
+ throw new Error(data.detail || 'ูุดู„ ุงู„ูุญุต ุงู„ุณุฑูŠุน');
812
+ }
813
+
814
+ if (data.decision === 'BLOCK' || data.decision === 'block') {
815
+ updateTimelineItem('quickcheck', 'done',
816
+ `๐Ÿ”ด ุชู… ุงูƒุชุดุงู ุงู…ุฑุฃุฉ ููŠ ุงู„ู€ frame ุงู„ุฃูˆู„ โ€” ูŠูุญุชู…ู„ ูˆุฌูˆุฏ ู†ุณุงุก ููŠ ุงู„ููŠุฏูŠูˆ`);
817
+ addTimelineItem('qc-result', 'active', 'โš ๏ธ ุชุญุฐูŠุฑ: ูŠูˆุฌุฏ ู…ุญุชูˆู‰ ุฃู†ุซูˆูŠ', 'DETECTED',
818
+ 'ุงู„ูุญุต ุงู„ุณุฑูŠุน ุงูƒุชุดู ุงู…ุฑุฃุฉ. ูŠูู†ุตุญ ุจุงู„ู…ุชุงุจุนุฉ ุฅู„ู‰ "ุชุญู„ูŠู„ ูˆุชู‚ุทูŠุน" ู„ู„ู…ุนุงู„ุฌุฉ ุงู„ูƒุงู…ู„ุฉ.');
819
+ } else {
820
+ updateTimelineItem('quickcheck', 'done',
821
+ `๐ŸŸข ุงู„ู€ frame ุงู„ุฃูˆู„ ู†ุธูŠู โ€” ู„ุง ูŠูˆุฌุฏ ู†ุณุงุก ููŠ ุงู„ุจุฏุงูŠุฉ`);
822
+ addTimelineItem('qc-result', 'done', 'โœ… ุงู„ูุญุต ุงู„ุฃูˆู„ูŠ ู†ุธูŠู', 'CLEAN',
823
+ 'ู„ู… ูŠููƒุชุดู ู…ุญุชูˆู‰ ุฃู†ุซูˆูŠ ููŠ ุงู„ู€ frame ุงู„ุฃูˆู„. ูŠูู†ุตุญ ุจุงู„ู…ุชุงุจุนุฉ ู„ุชุญู„ูŠู„ ูƒุงู…ู„ ุงู„ููŠุฏูŠูˆ.');
824
+ }
825
+
826
+ setButtonsDisabled(false);
827
+ }, 'image/jpeg', 0.9);
828
+
829
+ } catch (e) {
830
+ updateTimelineItem('quickcheck', 'error', `โŒ ุฎุทุฃ: ${e.message}`);
831
+ setButtonsDisabled(false);
832
+ }
833
+ });
834
+
835
+ // โ”€โ”€โ”€ Full Analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
836
+ document.getElementById('btnAnalyze').addEventListener('click', () => {
837
+ if (!currentFile || !apiReady) return;
838
+ setButtonsDisabled(true);
839
+
840
+ addTimelineItem('analyze', 'active', 'โš™๏ธ ุจุฏุก ุงู„ุชุญู„ูŠู„ ุงู„ูƒุงู…ู„', 'ANALYZING',
841
+ 'ุฌุงุฑูŠ ุฑูุน ุงู„ููŠุฏูŠูˆ ูˆุชุญู„ูŠู„ ูƒู„ frame...');
842
+
843
+ const formData = new FormData();
844
+ formData.append('file', currentFile);
845
+
846
+ const startTime = Date.now();
847
+ const uploadProgress = document.getElementById('uploadProgress');
848
+ const progressFill = document.getElementById('progressFill');
849
+ const progressPct = document.getElementById('progressPct');
850
+ const progressLabel = document.getElementById('progressLabel');
851
+ uploadProgress.style.display = 'block';
852
+ progressFill.style.width = '15%';
853
+ progressPct.textContent = '15%';
854
+ progressLabel.textContent = 'ุฌุงุฑูŠ ุฑูุน ุงู„ููŠุฏูŠูˆ...';
855
+
856
+ fetch(`${API_BASE}/analyze-video`, {
857
+ method: 'POST',
858
+ body: formData
859
+ })
860
+ .then(async res => {
861
+ const data = await res.json();
862
+ if (!res.ok) throw new Error(data.detail || 'ูุดู„ ุงู„ุชุญู„ูŠู„');
863
+ return data;
864
+ })
865
+ .then(data => {
866
+ uploadProgress.style.display = 'none';
867
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
868
+ currentJobId = data.output_job_id || null;
869
+ updateTimelineItem('analyze', 'done', `โœ… ุงูƒุชู…ู„ ุงู„ุชุญู„ูŠู„ ููŠ ${elapsed}s`);
870
+ handleAnalysisResult(data, elapsed);
871
+ setButtonsDisabled(false);
872
+ })
873
+ .catch(e => {
874
+ uploadProgress.style.display = 'none';
875
+ updateTimelineItem('analyze', 'error', `โŒ ุฎุทุฃ: ${e.message}`);
876
+ setButtonsDisabled(false);
877
+ });
878
+ });
879
+
880
+ // โ”€โ”€โ”€ Handle Analysis Result โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
881
+ function handleAnalysisResult(data, elapsed) {
882
+ const resultsSection = document.getElementById('resultsSection');
883
+ resultsSection.style.display = 'block';
884
+
885
+ if (!data.has_female) {
886
+ // โ”€โ”€ ู†ุธูŠู โ”€โ”€
887
+ addTimelineItem('result', 'done', '๐ŸŸข ุงู„ููŠุฏูŠูˆ ู†ุธูŠู ุชู…ุงู…ุงู‹', 'CLEAN',
888
+ 'ู„ู… ูŠููƒุชุดู ุฃูŠ ู…ุญุชูˆู‰ ุฃู†ุซูˆูŠ ููŠ ุงู„ููŠุฏูŠูˆ ูƒุงู…ู„ุงู‹. ุงู„ููŠุฏูŠูˆ ุฌุงู‡ุฒ ู„ู„ู†ุดุฑ.');
889
+
890
+ resultsSection.innerHTML = `
891
+ <div class="result-card">
892
+ <div class="result-card-header">
893
+ <span>๐Ÿ“Š</span>
894
+ <h3>ู†ุชูŠุฌุฉ ุงู„ุชุญู„ูŠู„</h3>
895
+ </div>
896
+ <div class="result-card-body">
897
+ <div class="verdict clean">
898
+ <div class="verdict-icon">โœ…</div>
899
+ <div class="verdict-text">
900
+ <h4 style="color:var(--accent)">ุงู„ููŠุฏูŠูˆ ู†ุธูŠู</h4>
901
+ <p>ู„ุง ูŠ๏ฟฝ๏ฟฝุชูˆูŠ ุนู„ู‰ ุฃูŠ ู…ุญุชูˆู‰ ุฃู†ุซูˆูŠ</p>
902
+ </div>
903
+ </div>
904
+ <div class="stats-grid">
905
+ <div class="stat-box">
906
+ <span class="val">${data.analysis_log ? data.analysis_log.length : 'โ€”'}</span>
907
+ <span class="lbl">frames ุชู… ุชุญู„ูŠู„ู‡ุง</span>
908
+ </div>
909
+ <div class="stat-box">
910
+ <span class="val">${data.analysis_time || elapsed || 'โ€”'}s</span>
911
+ <span class="lbl">ูˆู‚ุช ุงู„ุชุญู„ูŠู„</span>
912
+ </div>
913
+ <div class="stat-box">
914
+ <span class="val" style="color:var(--accent)">0</span>
915
+ <span class="lbl">ู…ู‚ุงุทุน ู…ุญุฐูˆูุฉ</span>
916
+ </div>
917
+ </div>
918
+ ${renderFrameLog(data.analysis_log)}
919
+ </div>
920
+ </div>
921
+ `;
922
+ return;
923
+ }
924
+
925
+ // โ”€โ”€ ูŠุญุชูˆูŠ ุนู„ู‰ ู†ุณุงุก โ”€โ”€
926
+ const femaleSegs = data.female_segments || [];
927
+ const keptSegs = data.kept_segments || [];
928
+ const totalRemoved = data.total_removed_sec || 0;
929
+
930
+ if (femaleSegs.length > 0) {
931
+ addTimelineItem('cutting', 'done', `โœ‚๏ธ ุชู… ุชู‚ุทูŠุน ${femaleSegs.length} ู…ู‚ุทุน`, 'CUTTING',
932
+ `ุชู… ุญุฐู ${totalRemoved.toFixed(1)} ุซุงู†ูŠุฉ ุชุญุชูˆูŠ ุนู„ู‰ ู†ุณุงุก ูˆุจู†ุงุก ุงู„ููŠุฏูŠูˆ ุงู„ู†ุธูŠู.`);
933
+ }
934
+
935
+ addTimelineItem('result', data.output_available ? 'done' : 'active',
936
+ data.output_available ? '๐Ÿ“ฆ ุงู„ููŠุฏูŠูˆ ุงู„ู†ุธูŠู ุฌุงู‡ุฒ' : 'โš ๏ธ ุงู„ููŠุฏูŠูˆ ูƒู„ู‡ ูŠุญุชูˆูŠ ู†ุณุงุก',
937
+ 'RESULT', data.message);
938
+
939
+ // ุญุณุงุจ ุงู„ู…ุฏุฉ ุงู„ุชู‚ุฑูŠุจูŠุฉ
940
+ const totalDur = femaleSegs.length > 0 && keptSegs.length > 0
941
+ ? (keptSegs[keptSegs.length-1][1] || 0)
942
+ : 0;
943
+
944
+ resultsSection.innerHTML = `
945
+ <div class="result-card">
946
+ <div class="result-card-header">
947
+ <span>๐Ÿ“Š</span>
948
+ <h3>ู†ุชูŠุฌุฉ ุงู„ุชุญู„ูŠู„</h3>
949
+ </div>
950
+ <div class="result-card-body">
951
+ <div class="verdict female">
952
+ <div class="verdict-icon">โš ๏ธ</div>
953
+ <div class="verdict-text">
954
+ <h4 style="color:var(--accent2)">ุชู… ุงูƒุชุดุงู ู…ุญุชูˆู‰ ุฃู†ุซูˆูŠ</h4>
955
+ <p>${femaleSegs.length} ู…ู‚ุทุน ูŠุญุชูˆูŠ ุนู„ู‰ ู†ุณุงุก โ€” ุชู… ุญุฐูู‡ุง</p>
956
+ </div>
957
+ </div>
958
+
959
+ <div class="stats-grid">
960
+ <div class="stat-box">
961
+ <span class="val" style="color:var(--accent2)">${femaleSegs.length}</span>
962
+ <span class="lbl">ู…ู‚ุงุทุน ู…ุญุฐูˆูุฉ</span>
963
+ </div>
964
+ <div class="stat-box">
965
+ <span class="val" style="color:var(--accent2)">${totalRemoved.toFixed(1)}s</span>
966
+ <span class="lbl">ู…ุฏุฉ ู…ุญุฐูˆูุฉ</span>
967
+ </div>
968
+ <div class="stat-box">
969
+ <span class="val">${data.analysis_log ? data.analysis_log.length : 'โ€”'}</span>
970
+ <span class="lbl">frames ุชู… ุชุญู„ูŠู„ู‡ุง</span>
971
+ </div>
972
+ <div class="stat-box">
973
+ <span class="val">${data.analysis_time || elapsed || 'โ€”'}s</span>
974
+ <span class="lbl">ูˆู‚ุช ุงู„ุชุญู„ูŠู„</span>
975
+ </div>
976
+ </div>
977
+
978
+ ${renderVideoTimeline(femaleSegs, keptSegs, totalDur)}
979
+ ${renderSegmentsTable(femaleSegs, 'female')}
980
+ ${renderFrameLog(data.analysis_log)}
981
+ </div>
982
+ </div>
983
+ `;
984
+
985
+ if (data.output_available && currentJobId) {
986
+ const dl = document.getElementById('downloadSection');
987
+ dl.style.display = 'block';
988
+ document.getElementById('downloadDesc').textContent =
989
+ `ุชู… ุญุฐู ${totalRemoved.toFixed(1)} ุซุงู†ูŠุฉ โ€” ุงู„ููŠุฏูŠูˆ ุงู„ู†ุธูŠู ุฌุงู‡ุฒ ู„ู„ุชุญู…ูŠู„`;
990
+
991
+ document.getElementById('btnDownload').onclick = () => {
992
+ window.location.href = `${API_BASE}/download/${currentJobId}`;
993
+ };
994
+ }
995
+ }
996
+
997
+ // โ”€โ”€โ”€ Render Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
998
+ function renderVideoTimeline(femaleSegs, keptSegs, totalDur) {
999
+ if (!totalDur || totalDur === 0) return '';
1000
+ const allSegs = [
1001
+ ...femaleSegs.map(s => ({start: s[0], end: s[1], type: 'female'})),
1002
+ ...keptSegs.map(s => ({start: s[0], end: s[1], type: 'clean'}))
1003
+ ].sort((a,b) => a.start - b.start);
1004
+
1005
+ const bars = allSegs.map(s => {
1006
+ const left = (s.start / totalDur * 100).toFixed(1);
1007
+ const width = ((s.end - s.start) / totalDur * 100).toFixed(1);
1008
+ return `<div class="segment seg-${s.type}" style="left:${left}%;width:${width}%" title="${s.type}: ${s.start.toFixed(1)}s - ${s.end.toFixed(1)}s"></div>`;
1009
+ }).join('');
1010
+
1011
+ return `
1012
+ <div style="margin:16px 0">
1013
+ <div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--muted);margin-bottom:6px">
1014
+ ุฎุฑูŠุทุฉ ุงู„ููŠุฏูŠูˆ โ€” <span style="color:var(--accent)">โ–  ู†ุธูŠู</span> <span style="color:var(--accent2)">โ–  ู…ุญุฐูˆู</span>
1015
+ </div>
1016
+ <div class="video-timeline">${bars}</div>
1017
+ <div class="timeline-label"><span>0s</span><span>${totalDur.toFixed(0)}s</span></div>
1018
+ </div>
1019
+ `;
1020
+ }
1021
+
1022
+ function renderSegmentsTable(segs, type) {
1023
+ if (!segs || segs.length === 0) return '';
1024
+ const color = type === 'female' ? 'var(--accent2)' : 'var(--accent)';
1025
+ const rows = segs.map((s, i) => `
1026
+ <tr>
1027
+ <td style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:${color};padding:6px 8px">#${i+1}</td>
1028
+ <td style="font-family:'IBM Plex Mono',monospace;font-size:11px;padding:6px 8px">${s[0].toFixed(1)}s</td>
1029
+ <td style="font-family:'IBM Plex Mono',monospace;font-size:11px;padding:6px 8px">${s[1].toFixed(1)}s</td>
1030
+ <td style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--muted);padding:6px 8px">${(s[1]-s[0]).toFixed(1)}s</td>
1031
+ </tr>
1032
+ `).join('');
1033
+
1034
+ return `
1035
+ <div style="margin-top:12px">
1036
+ <div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--muted);margin-bottom:6px">ุงู„ู…ู‚ุงุทุน ุงู„ู…ุญุฐูˆูุฉ</div>
1037
+ <table style="width:100%;border-collapse:collapse;background:rgba(0,0,0,0.3);border-radius:4px;overflow:hidden">
1038
+ <tr style="background:rgba(255,255,255,0.03)">
1039
+ <th style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted);padding:6px 8px;text-align:right;font-weight:400">#</th>
1040
+ <th style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted);padding:6px 8px;text-align:right;font-weight:400">ุงู„ุจุฏุงูŠุฉ</th>
1041
+ <th style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted);padding:6px 8px;text-align:right;font-weight:400">ุงู„ู†ู‡ุงูŠุฉ</th>
1042
+ <th style="font-family:'IBM Plex Mono',monospace;font-size:10px;color:var(--muted);padding:6px 8px;text-align:right;font-weight:400">ุงู„ู…ุฏุฉ</th>
1043
+ </tr>
1044
+ ${rows}
1045
+ </table>
1046
+ </div>
1047
+ `;
1048
+ }
1049
+
1050
+ function renderFrameLog(log) {
1051
+ if (!log || log.length === 0) return '';
1052
+ const badges = log.map(f =>
1053
+ `<span class="frame-badge ${f.has_female ? 'female' : 'clean'}" title="${f.reason || ''}">${f.second}s</span>`
1054
+ ).join('');
1055
+ return `
1056
+ <div style="margin-top:12px">
1057
+ <div style="font-family:'IBM Plex Mono',monospace;font-size:11px;color:var(--muted);margin-bottom:6px">
1058
+ ุณุฌู„ ุงู„ู€ frames โ€” <span style="color:var(--accent)">โ–  ู†ุธูŠู</span> <span style="color:var(--accent2)">โ–  ูŠุญุชูˆูŠ ู†ุณุงุก</span>
1059
+ </div>
1060
+ <div class="frame-log">${badges}</div>
1061
+ </div>
1062
+ `;
1063
+ }
1064
+
1065
+ // โ”€โ”€โ”€ Timeline Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1066
+ function showTimeline() {
1067
+ document.getElementById('timelineSection').style.display = 'block';
1068
+ }
1069
+
1070
+ function addTimelineItem(id, status, title, tag, desc) {
1071
+ const timeline = document.getElementById('timeline');
1072
+ const existing = document.getElementById(`tl-${id}`);
1073
+ if (existing) { updateTimelineItem(id, status, desc); return; }
1074
+
1075
+ const item = document.createElement('div');
1076
+ item.className = `timeline-item status-${status}`;
1077
+ item.id = `tl-${id}`;
1078
+ item.innerHTML = `
1079
+ <div class="timeline-dot"></div>
1080
+ <div class="timeline-content">
1081
+ <div class="timeline-title">
1082
+ <strong>${title}</strong>
1083
+ <span class="tag">${tag}</span>
1084
+ ${status === 'active' ? '<div class="spinner"></div>' : ''}
1085
+ </div>
1086
+ <div class="timeline-desc" id="tl-desc-${id}">${desc}</div>
1087
+ </div>
1088
+ `;
1089
+ timeline.appendChild(item);
1090
+ setTimeout(() => item.classList.add('visible'), 50);
1091
+ }
1092
+
1093
+ function updateTimelineItem(id, status, desc) {
1094
+ const item = document.getElementById(`tl-${id}`);
1095
+ if (!item) return;
1096
+ item.className = `timeline-item status-${status} visible`;
1097
+ const descEl = document.getElementById(`tl-desc-${id}`);
1098
+ if (descEl && desc) descEl.textContent = desc;
1099
+ // ุฅุฒุงู„ุฉ spinner
1100
+ const spinner = item.querySelector('.spinner');
1101
+ if (spinner) spinner.remove();
1102
+ }
1103
+
1104
+ // โ”€โ”€โ”€ Utils โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1105
+ function formatDuration(sec) {
1106
+ const m = Math.floor(sec / 60);
1107
+ const s = Math.floor(sec % 60);
1108
+ return `${m}:${s.toString().padStart(2,'0')}`;
1109
+ }
1110
+
1111
+ function showAlert(msg, type) {
1112
+ const box = document.getElementById('alertBox');
1113
+ box.textContent = msg;
1114
+ box.className = `alert alert-${type} show`;
1115
+ setTimeout(() => box.classList.remove('show'), 5000);
1116
+ }
1117
+
1118
+ function setButtonsDisabled(disabled) {
1119
+ document.getElementById('btnQuickCheck').disabled = disabled || !apiReady;
1120
+ document.getElementById('btnAnalyze').disabled = disabled || !apiReady;
1121
+ }
1122
+
1123
+ function resetAll() {
1124
+ currentFile = null;
1125
+ currentJobId = null;
1126
+ document.getElementById('uploadZone').style.display = 'block';
1127
+ document.getElementById('videoPreview').style.display = 'none';
1128
+ document.getElementById('actionsBar').style.display = 'none';
1129
+ document.getElementById('timelineSection').style.display = 'none';
1130
+ document.getElementById('resultsSection').style.display = 'none';
1131
+ document.getElementById('downloadSection').style.display = 'none';
1132
+ document.getElementById('timeline').innerHTML = '';
1133
+ document.getElementById('resultsSection').innerHTML = '';
1134
+ document.getElementById('videoPlayer').src = '';
1135
+ fileInput.value = '';
1136
+ }
1137
+
1138
+ // โ”€โ”€โ”€ Init โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
1139
+ checkApiStatus();
1140
+ </script>
1141
+ </body>
1142
+ </html>
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ transformers==4.40.0
2
+ timm
3
+ einops
4
+ torch
5
+ torchvision
6
+ pillow
7
+ fastapi
8
+ uvicorn
9
+ httpx
10
+ accelerate
11
+ opencv-python-headless
12
+ moviepy
13
+ python-multipart