fantaxy commited on
Commit
baf7ca7
·
verified ·
1 Parent(s): 3a578db

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +810 -388
app.py CHANGED
@@ -1,402 +1,824 @@
1
- import spaces
2
- from functools import lru_cache
3
- import gradio as gr
4
- from gradio_toggle import Toggle
5
- import torch
6
- from huggingface_hub import snapshot_download
7
- from transformers import CLIPProcessor, CLIPModel, pipeline
8
- import random
9
- from xora.models.autoencoders.causal_video_autoencoder import CausalVideoAutoencoder
10
- from xora.models.transformers.transformer3d import Transformer3DModel
11
- from xora.models.transformers.symmetric_patchifier import SymmetricPatchifier
12
- from xora.schedulers.rf import RectifiedFlowScheduler
13
- from xora.pipelines.pipeline_xora_video import XoraVideoPipeline
14
- from transformers import T5EncoderModel, T5Tokenizer
15
- from xora.utils.conditioning_method import ConditioningMethod
16
- from pathlib import Path
17
- import safetensors.torch
18
- import json
19
- import numpy as np
20
- import cv2
21
- from PIL import Image
22
- import tempfile
23
- import os
24
- import gc
25
- import csv
26
- from datetime import datetime
27
- from openai import OpenAI
28
 
29
- # 한글-영어 번역기 초기화
30
- translator = pipeline("translation", model="Helsinki-NLP/opus-mt-ko-en")
31
 
32
- torch.backends.cuda.matmul.allow_tf32 = False
33
- torch.backends.cuda.matmul.allow_bf16_reduced_precision_reduction = False
34
- torch.backends.cuda.matmul.allow_fp16_reduced_precision_reduction = False
35
- torch.backends.cudnn.allow_tf32 = False
36
- torch.backends.cudnn.deterministic = False
37
- torch.backends.cuda.preferred_blas_library="cublas"
38
- torch.set_float32_matmul_precision("highest")
39
 
40
- MAX_SEED = np.iinfo(np.int32).max
41
-
42
- # Load Hugging Face token if needed
43
- hf_token = os.getenv("HF_TOKEN")
44
- openai_api_key = os.getenv("OPENAI_API_KEY")
45
- client = OpenAI(api_key=openai_api_key)
46
-
47
- system_prompt_t2v_path = "assets/system_prompt_t2v.txt"
48
- with open(system_prompt_t2v_path, "r") as f:
49
- system_prompt_t2v = f.read()
50
-
51
- # Set model download directory within Hugging Face Spaces
52
- model_path = "asset"
53
-
54
- commit_hash='c7c8ad4c2ddba847b94e8bfaefbd30bd8669fafc'
55
-
56
- if not os.path.exists(model_path):
57
- snapshot_download("Lightricks/LTX-Video", revision=commit_hash, local_dir=model_path, repo_type="model", token=hf_token)
58
-
59
- # Global variables to load components
60
- vae_dir = Path(model_path) / "vae"
61
- unet_dir = Path(model_path) / "unet"
62
- scheduler_dir = Path(model_path) / "scheduler"
63
-
64
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
65
-
66
- clip_model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32", cache_dir=model_path).to(torch.device("cuda:0"))
67
- clip_processor = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32", cache_dir=model_path)
68
-
69
- def process_prompt(prompt):
70
- # 한글이 포함되어 있는지 확인
71
- if any(ord('가') <= ord(char) <= ord('힣') for char in prompt):
72
- # 한글을 영어로 번역
73
- translated = translator(prompt)[0]['translation_text']
74
- return translated
75
- return prompt
76
-
77
- def compute_clip_embedding(text=None):
78
- inputs = clip_processor(text=text, return_tensors="pt", padding=True).to(device)
79
- outputs = clip_model.get_text_features(**inputs)
80
- embedding = outputs.detach().cpu().numpy().flatten().tolist()
81
- return embedding
82
-
83
- def load_vae(vae_dir):
84
- vae_ckpt_path = vae_dir / "vae_diffusion_pytorch_model.safetensors"
85
- vae_config_path = vae_dir / "config.json"
86
- with open(vae_config_path, "r") as f:
87
- vae_config = json.load(f)
88
- vae = CausalVideoAutoencoder.from_config(vae_config)
89
- vae_state_dict = safetensors.torch.load_file(vae_ckpt_path)
90
- vae.load_state_dict(vae_state_dict)
91
- return vae.to(device).to(torch.bfloat16)
92
-
93
- def load_unet(unet_dir):
94
- unet_ckpt_path = unet_dir / "unet_diffusion_pytorch_model.safetensors"
95
- unet_config_path = unet_dir / "config.json"
96
- transformer_config = Transformer3DModel.load_config(unet_config_path)
97
- transformer = Transformer3DModel.from_config(transformer_config)
98
- unet_state_dict = safetensors.torch.load_file(unet_ckpt_path)
99
- transformer.load_state_dict(unet_state_dict, strict=True)
100
- return transformer.to(device).to(torch.bfloat16)
101
-
102
- def load_scheduler(scheduler_dir):
103
- scheduler_config_path = scheduler_dir / "scheduler_config.json"
104
- scheduler_config = RectifiedFlowScheduler.load_config(scheduler_config_path)
105
- return RectifiedFlowScheduler.from_config(scheduler_config)
106
-
107
- # Preset options for resolution and frame configuration
108
- preset_options = [
109
- {"label": "1216x704, 41 frames", "width": 1216, "height": 704, "num_frames": 41},
110
- {"label": "1088x704, 49 frames", "width": 1088, "height": 704, "num_frames": 49},
111
- {"label": "1056x640, 57 frames", "width": 1056, "height": 640, "num_frames": 57},
112
- {"label": "448x448, 100 frames", "width": 448, "height": 448, "num_frames": 100},
113
- {"label": "448x448, 200 frames", "width": 448, "height": 448, "num_frames": 200},
114
- {"label": "448x448, 300 frames", "width": 448, "height": 448, "num_frames": 300},
115
- {"label": "640x640, 80 frames", "width": 640, "height": 640, "num_frames": 80},
116
- {"label": "640x640, 120 frames", "width": 640, "height": 640, "num_frames": 120},
117
- {"label": "768x768, 64 frames", "width": 768, "height": 768, "num_frames": 64},
118
- {"label": "768x768, 90 frames", "width": 768, "height": 768, "num_frames": 90},
119
- {"label": "720x720, 64 frames", "width": 768, "height": 768, "num_frames": 64},
120
- {"label": "720x720, 100 frames", "width": 768, "height": 768, "num_frames": 100},
121
- {"label": "768x512, 97 frames", "width": 768, "height": 512, "num_frames": 97},
122
- {"label": "512x512, 160 frames", "width": 512, "height": 512, "num_frames": 160},
123
- {"label": "512x512, 200 frames", "width": 512, "height": 512, "num_frames": 200},
124
  ]
125
 
126
- def preset_changed(preset):
127
- if preset != "Custom":
128
- selected = next(item for item in preset_options if item["label"] == preset)
129
- return (
130
- selected["height"],
131
- selected["width"],
132
- selected["num_frames"],
133
- gr.update(visible=False),
134
- gr.update(visible=False),
135
- gr.update(visible=False),
136
- )
137
- else:
138
- return (
139
- None,
140
- None,
141
- None,
142
- gr.update(visible=True),
143
- gr.update(visible=True),
144
- gr.update(visible=True),
145
- )
146
-
147
- # Load models
148
- vae = load_vae(vae_dir)
149
- unet = load_unet(unet_dir)
150
- scheduler = load_scheduler(scheduler_dir)
151
- patchifier = SymmetricPatchifier(patch_size=1)
152
- text_encoder = T5EncoderModel.from_pretrained("PixArt-alpha/PixArt-XL-2-1024-MS", subfolder="text_encoder").to(torch.device("cuda:0"))
153
- tokenizer = T5Tokenizer.from_pretrained("PixArt-alpha/PixArt-XL-2-1024-MS", subfolder="tokenizer")
154
-
155
- pipeline = XoraVideoPipeline(
156
- transformer=unet,
157
- patchifier=patchifier,
158
- text_encoder=text_encoder,
159
- tokenizer=tokenizer,
160
- scheduler=scheduler,
161
- vae=vae,
162
- ).to(torch.device("cuda:0"))
163
-
164
- def enhance_prompt_if_enabled(prompt, enhance_toggle):
165
- if not enhance_toggle:
166
- print("Enhance toggle is off, Prompt: ", prompt)
167
- return prompt
168
 
169
- messages = [
170
- {"role": "system", "content": system_prompt_t2v},
171
- {"role": "user", "content": prompt},
172
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  try:
175
- response = client.chat.completions.create(
176
- model="gpt-4-mini",
177
- messages=messages,
178
- max_tokens=200,
179
- )
180
- print("Enhanced Prompt: ", response.choices[0].message.content.strip())
181
- return response.choices[0].message.content.strip()
182
- except Exception as e:
183
- print(f"Error: {e}")
184
- return prompt
185
-
186
- @spaces.GPU(duration=90)
187
- def generate_video_from_text_90(
188
- prompt="",
189
- enhance_prompt_toggle=False,
190
- negative_prompt="",
191
- frame_rate=25,
192
- seed=random.randint(0, MAX_SEED),
193
- num_inference_steps=30,
194
- guidance_scale=3.2,
195
- height=768,
196
- width=768,
197
- num_frames=60,
198
- progress=gr.Progress(),
199
- ):
200
- # 프롬프트 전처리 (한글 -> 영어)
201
- prompt = process_prompt(prompt)
202
- negative_prompt = process_prompt(negative_prompt)
203
-
204
- if len(prompt.strip()) < 50:
205
- raise gr.Error(
206
- "Prompt must be at least 50 characters long. Please provide more details for the best results.",
207
- duration=5,
208
- )
209
-
210
- prompt = enhance_prompt_if_enabled(prompt, enhance_prompt_toggle)
211
-
212
- sample = {
213
- "prompt": prompt,
214
- "prompt_attention_mask": None,
215
- "negative_prompt": negative_prompt,
216
- "negative_prompt_attention_mask": None,
217
- "media_items": None,
218
- }
219
-
220
- generator = torch.Generator(device="cuda").manual_seed(seed)
221
-
222
- def gradio_progress_callback(self, step, timestep, kwargs):
223
- progress((step + 1) / num_inference_steps)
224
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  try:
226
- with torch.no_grad():
227
- images = pipeline(
228
- num_inference_steps=num_inference_steps,
229
- num_images_per_prompt=1,
230
- guidance_scale=guidance_scale,
231
- generator=generator,
232
- output_type="pt",
233
- height=height,
234
- width=width,
235
- num_frames=num_frames,
236
- frame_rate=frame_rate,
237
- **sample,
238
- is_video=True,
239
- vae_per_channel_normalize=True,
240
- conditioning_method=ConditioningMethod.UNCONDITIONAL,
241
- mixed_precision=True,
242
- callback_on_step_end=gradio_progress_callback,
243
- ).images
244
  except Exception as e:
245
- raise gr.Error(
246
- f"An error occurred while generating the video. Please try again. Error: {e}",
247
- duration=5,
248
- )
249
  finally:
250
- torch.cuda.empty_cache()
251
- gc.collect()
252
-
253
- output_path = tempfile.mktemp(suffix=".mp4")
254
- video_np = images.squeeze(0).permute(1, 2, 3, 0).cpu().float().numpy()
255
- video_np = (video_np * 255).astype(np.uint8)
256
- height, width = video_np.shape[1:3]
257
- out = cv2.VideoWriter(output_path, cv2.VideoWriter_fourcc(*"mp4v"), frame_rate, (width, height))
258
- for frame in video_np[..., ::-1]:
259
- out.write(frame)
260
- out.release()
261
- del images
262
- del video_np
263
- torch.cuda.empty_cache()
264
- return output_path
265
-
266
- def create_advanced_options():
267
- with gr.Accordion("Step 4: Advanced Options (Optional)", open=False):
268
- seed = gr.Slider(label="4.1 Seed", minimum=0, maximum=1000000, step=1, value=646373)
269
- inference_steps = gr.Slider(label="4.2 Inference Steps", minimum=5, maximum=150, step=5, value=40)
270
- guidance_scale = gr.Slider(label="4.3 Guidance Scale", minimum=1.0, maximum=10.0, step=0.1, value=4.2)
271
-
272
- height_slider = gr.Slider(
273
- label="4.4 Height",
274
- minimum=256,
275
- maximum=1024,
276
- step=64,
277
- value=768,
278
- visible=False,
279
- )
280
- width_slider = gr.Slider(
281
- label="4.5 Width",
282
- minimum=256,
283
- maximum=1024,
284
- step=64,
285
- value=768,
286
- visible=False,
287
- )
288
- num_frames_slider = gr.Slider(
289
- label="4.5 Number of Frames",
290
- minimum=1,
291
- maximum=500,
292
- step=1,
293
- value=60,
294
- visible=False,
295
- )
296
-
297
- return [
298
- seed,
299
- inference_steps,
300
- guidance_scale,
301
- height_slider,
302
- width_slider,
303
- num_frames_slider,
304
- ]
305
-
306
- css = """
307
- footer {
308
- visibility: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
309
  }
310
-
311
- /* 비디오 출력 컨테이너 크기 조정 */
312
- .video-output-container {
313
- max-width: 50%;
314
- margin-left: auto;
315
- margin-right: auto;
 
 
 
 
 
 
 
316
  }
317
-
318
- /* 비디오 플레이어 크기 조정 */
319
- .video-player {
320
- width: 100%;
321
- max-height: 50vh;
322
- object-fit: contain;
323
  }
324
- """
325
-
326
-
327
-
328
-
329
- with gr.Blocks(theme="soft", css=css) as iface:
330
- with gr.Row():
331
- # 입력 섹션 (왼쪽)
332
- with gr.Column(scale=1):
333
- txt2vid_prompt = gr.Textbox(
334
- label="Step 1: Enter Your Prompt (한글 또는 영어)",
335
- placeholder="Describe the video you want to create (at least 50 characters)...",
336
- value="A sleek vintage classic car is driving along a Hawaiian coastal road, seen from a low-angle front bumper camera view, with the ocean waves and palm trees rolling by in the background.",
337
- lines=5,
338
- )
339
-
340
- txt2vid_enhance_toggle = Toggle(
341
- label="Enhance Prompt",
342
- value=False,
343
- interactive=True,
344
- )
345
-
346
- txt2vid_negative_prompt = gr.Textbox(
347
- label="Step 2: Enter Negative Prompt",
348
- placeholder="Describe the elements you do not want in the video...",
349
- value="low quality, worst quality, deformed, distorted, damaged, motion blur, motion artifacts, fused fingers, incorrect anatomy, strange hands, ugly",
350
- lines=2,
351
- )
352
-
353
- txt2vid_preset = gr.Dropdown(
354
- choices=[p["label"] for p in preset_options],
355
- value="512x512, 160 frames",
356
- label="Step 3.1: Choose Resolution Preset",
357
- )
358
-
359
- txt2vid_frame_rate = gr.Slider(
360
- label="Step 3.2: Frame Rate",
361
- minimum=6,
362
- maximum=60,
363
- step=1,
364
- value=20,
365
- )
366
-
367
- txt2vid_advanced = create_advanced_options()
368
- txt2vid_generate = gr.Button(
369
- "Step 5: Generate Video",
370
- variant="primary",
371
- size="lg",
372
- )
373
-
374
- # 출력 섹션 (오른쪽)
375
- with gr.Column(scale=1):
376
- txt2vid_output = gr.Video(
377
- label="Generated Output",
378
- elem_classes=["video-output-container", "video-player"]
379
- )
380
-
381
- txt2vid_preset.change(
382
- fn=preset_changed,
383
- inputs=[txt2vid_preset],
384
- outputs=txt2vid_advanced[3:],
385
- )
386
-
387
- txt2vid_generate.click(
388
- fn=generate_video_from_text_90,
389
- inputs=[
390
- txt2vid_prompt,
391
- txt2vid_enhance_toggle,
392
- txt2vid_negative_prompt,
393
- txt2vid_frame_rate,
394
- *txt2vid_advanced,
395
- ],
396
- outputs=txt2vid_output,
397
- concurrency_limit=1,
398
- concurrency_id="generate_video",
399
- queue=True,
400
- )
401
-
402
- iface.queue(max_size=64, default_concurrency_limit=1, api_open=False).launch(share=True, show_api=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Flask, render_template, request, jsonify
2
+ import os, re, json, sqlite3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
+ app = Flask(__name__)
 
5
 
6
+ # ────────────────────────── 1. CONFIGURATION ──────────────────────────
7
+ DB_FILE = "favorite_sites.json" # JSON file for backward compatibility
8
+ SQLITE_DB = "favorite_sites.db" # SQLite database for persistence
 
 
 
 
9
 
10
+ # Domains that commonly block iframes
11
+ BLOCKED_DOMAINS = [
12
+ "naver.com", "daum.net", "google.com",
13
+ "facebook.com", "instagram.com", "kakao.com",
14
+ "ycombinator.com"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  ]
16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
+ # ────────────────────────── 2. CURATED CATEGORIES ──────────────────────────
19
+ CATEGORIES = {
20
+ "Productivity": [
21
+ "https://huggingface.co/spaces/openfree/Chart-GPT",
22
+ "https://huggingface.co/spaces/ginipick/AI-BOOK",
23
+ "https://huggingface.co/spaces/VIDraft/Voice-Clone-Podcast",
24
+ "https://huggingface.co/spaces/ginipick/PDF-EXAM",
25
+ "https://huggingface.co/spaces/ginigen/perflexity-clone",
26
+ "https://huggingface.co/spaces/ginipick/IDEA-DESIGN",
27
+ "https://huggingface.co/spaces/ginipick/10m-marketing",
28
+
29
+ "https://huggingface.co/spaces/openfree/Live-Podcast",
30
+ "https://huggingface.co/spaces/openfree/AI-Podcast",
31
+ "https://huggingface.co/spaces/ginipick/QR-Canvas-plus",
32
+ "https://huggingface.co/spaces/openfree/Badge",
33
+ "https://huggingface.co/spaces/VIDraft/mouse-webgen",
34
+ "https://huggingface.co/spaces/openfree/Vibe-Game",
35
+ "https://huggingface.co/spaces/VIDraft/NH-Prediction",
36
+ "https://huggingface.co/spaces/ginipick/NH-Korea",
37
+ "https://huggingface.co/spaces/openfree/Naming",
38
+ "https://huggingface.co/spaces/ginipick/Change-Hair",
39
+ ],
40
+ "Multimodal": [
41
+ "https://huggingface.co/spaces/Heartsync/adult",
42
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored",
43
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video2",
44
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video",
45
+ "https://huggingface.co/spaces/Heartsync/WAN-VIDEO-AUDIO",
46
+ "https://huggingface.co/spaces/Heartsync/wan2-1-fast-security",
47
+ "https://huggingface.co/spaces/ginigen/Flux-VIDEO",
48
+ "https://huggingface.co/spaces/ginigen/3D-LLAMA-V1",
49
+ "https://huggingface.co/spaces/ginigen/Flux-VIDEO",
50
+ "https://huggingface.co/spaces/openfree/Multilingual-TTS",
51
+ "https://huggingface.co/spaces/VIDraft/ACE-Singer",
52
+ "https://huggingface.co/spaces/openfree/DreamO-video",
53
+ "https://huggingface.co/spaces/fantaxy/Sound-AI-SFX",
54
+ "https://huggingface.co/spaces/ginigen/SFX-Sound-magic",
55
+ "https://huggingface.co/spaces/ginigen/VoiceClone-TTS",
56
+ "https://huggingface.co/spaces/aiqcamp/ENGLISH-Speaking-Scoring",
57
+ "https://huggingface.co/spaces/fantaxy/Remove-Video-Background",
58
+ ],
59
+ "Professional": [
60
+ "https://huggingface.co/spaces/Heartsync/Novel-NSFW",
61
+ "https://huggingface.co/spaces/fantaxy/fantasy-novel",
62
+ "https://huggingface.co/spaces/VIDraft/money-radar",
63
+ "https://huggingface.co/spaces/immunobiotech/drug-discovery",
64
+ "https://huggingface.co/spaces/immunobiotech/Gemini-MICHELIN",
65
+ "https://huggingface.co/spaces/openfree/Cycle-Navigator",
66
+ "https://huggingface.co/spaces/VIDraft/Fashion-Fit",
67
+ "https://huggingface.co/spaces/openfree/Stock-Trading-Analysis",
68
+ "https://huggingface.co/spaces/ginipick/AgentX-Papers",
69
+ "https://huggingface.co/spaces/Heartsync/Papers-Leaderboard",
70
+ "https://huggingface.co/spaces/VIDraft/PapersImpact",
71
+ "https://huggingface.co/spaces/ginigen/multimodal-chat-mbti-korea",
72
+ ],
73
+ "Image": [
74
+ "https://huggingface.co/spaces/ginigen/FLUX-Ghibli-LoRA2",
75
+ "https://huggingface.co/spaces/aiqcamp/REMOVAL-TEXT-IMAGE",
76
+ "https://huggingface.co/spaces/VIDraft/BAGEL-Websearch",
77
+ "https://huggingface.co/spaces/ginigen/Every-Text",
78
+ "https://huggingface.co/spaces/ginigen/text3d-r1",
79
+ "https://huggingface.co/spaces/ginipick/FLUXllama",
80
+ "https://huggingface.co/spaces/ginigen/Workflow-Canvas",
81
+ "https://huggingface.co/spaces/ginigen/canvas-studio",
82
+ "https://huggingface.co/spaces/VIDraft/ReSize-Image-Outpainting",
83
+ "https://huggingface.co/spaces/Heartsync/FLUX-Vision",
84
+ "https://huggingface.co/spaces/fantos/textcutobject",
85
+ "https://huggingface.co/spaces/aiqtech/imaginpaint",
86
+ "https://huggingface.co/spaces/openfree/ColorRevive",
87
+ "https://huggingface.co/spaces/openfree/ultpixgen",
88
+ "https://huggingface.co/spaces/VIDraft/Polaroid-Style",
89
+ "https://huggingface.co/spaces/ginigen/VisualCloze",
90
+ "https://huggingface.co/spaces/fantaxy/ofai-flx-logo",
91
+ "https://huggingface.co/spaces/ginigen/interior-design",
92
+ "https://huggingface.co/spaces/ginigen/MagicFace-V3",
93
+ "https://huggingface.co/spaces/fantaxy/flx-pulid",
94
+ "https://huggingface.co/spaces/seawolf2357/Ghibli-Multilingual-Text-rendering",
95
+ "https://huggingface.co/spaces/VIDraft/Open-Meme-Studio",
96
+ "https://huggingface.co/spaces/VIDraft/stable-diffusion-3.5-large-turboX",
97
+ "https://huggingface.co/spaces/aiqtech/flxgif",
98
+ "https://huggingface.co/spaces/openfree/VectorFlow",
99
+
100
+ # "https://huggingface.co/spaces/ginigen/3D-LLAMA",
101
+ # "https://huggingface.co/spaces/ginigen/Multi-LoRAgen",
102
+
103
+ ],
104
+ "LLM / VLM": [
105
+ "https://huggingface.co/spaces/ginigen/deepseek-r1-0528-API",
106
+ "https://huggingface.co/spaces/aiqcamp/Mistral-Devstral-API"
107
+ "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528",
108
+ "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528-qwen3-8b",
109
+ "https://huggingface.co/spaces/aiqcamp/deepseek-r1-0528",
110
+ "https://huggingface.co/spaces/aiqcamp/Mistral-Devstral-API",
111
+ "https://huggingface.co/spaces/VIDraft/Mistral-RAG-BitSix",
112
+ "https://huggingface.co/spaces/VIDraft/Gemma-3-R1984-4B",
113
+ "https://huggingface.co/spaces/VIDraft/Gemma-3-R1984-12B",
114
+ "https://huggingface.co/spaces/ginigen/Mistral-Perflexity",
115
+ "https://huggingface.co/spaces/aiqcamp/gemini-2.5-flash-preview",
116
+ "https://huggingface.co/spaces/openfree/qwen3-30b-a3b-research",
117
+ "https://huggingface.co/spaces/openfree/qwen3-235b-a22b-research",
118
+ "https://huggingface.co/spaces/openfree/Llama-4-Maverick-17B-Research",
119
+ ],
120
+ }
121
 
122
+ # ────────────────────────── 3. DATABASE FUNCTIONS ──────────────────────────
123
+ def init_db():
124
+ # Initialize JSON file if it doesn't exist
125
+ if not os.path.exists(DB_FILE):
126
+ with open(DB_FILE, "w", encoding="utf-8") as f:
127
+ json.dump([], f, ensure_ascii=False)
128
+
129
+ # Initialize SQLite database
130
+ conn = sqlite3.connect(SQLITE_DB)
131
+ cursor = conn.cursor()
132
+ cursor.execute('''
133
+ CREATE TABLE IF NOT EXISTS urls (
134
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
135
+ url TEXT UNIQUE NOT NULL,
136
+ date_added TIMESTAMP DEFAULT CURRENT_TIMESTAMP
137
+ )
138
+ ''')
139
+ conn.commit()
140
+
141
+ # If we have data in JSON but not in SQLite (first run with new SQLite DB),
142
+ # migrate the data from JSON to SQLite
143
+ json_urls = load_json()
144
+ if json_urls:
145
+ db_urls = load_db_sqlite()
146
+ for url in json_urls:
147
+ if url not in db_urls:
148
+ add_url_to_sqlite(url)
149
+
150
+ conn.close()
151
+
152
+ def load_json():
153
+ """Load URLs from JSON file (for backward compatibility)"""
154
  try:
155
+ with open(DB_FILE, "r", encoding="utf-8") as f:
156
+ raw = json.load(f)
157
+ return raw if isinstance(raw, list) else []
158
+ except Exception:
159
+ return []
160
+
161
+ def save_json(lst):
162
+ """Save URLs to JSON file (for backward compatibility)"""
163
+ try:
164
+ with open(DB_FILE, "w", encoding="utf-8") as f:
165
+ json.dump(lst, f, ensure_ascii=False, indent=2)
166
+ return True
167
+ except Exception:
168
+ return False
169
+
170
+ def load_db_sqlite():
171
+ """Load URLs from SQLite database"""
172
+ conn = sqlite3.connect(SQLITE_DB)
173
+ cursor = conn.cursor()
174
+ cursor.execute("SELECT url FROM urls ORDER BY date_added DESC")
175
+ urls = [row[0] for row in cursor.fetchall()]
176
+ conn.close()
177
+ return urls
178
+
179
+ def add_url_to_sqlite(url):
180
+ """Add a URL to SQLite database"""
181
+ conn = sqlite3.connect(SQLITE_DB)
182
+ cursor = conn.cursor()
183
+ try:
184
+ cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,))
185
+ conn.commit()
186
+ success = True
187
+ except sqlite3.IntegrityError:
188
+ # URL already exists
189
+ success = False
190
+ conn.close()
191
+ return success
192
+
193
+ def update_url_in_sqlite(old_url, new_url):
194
+ """Update a URL in SQLite database"""
195
+ conn = sqlite3.connect(SQLITE_DB)
196
+ cursor = conn.cursor()
197
+ try:
198
+ cursor.execute("UPDATE urls SET url = ? WHERE url = ?", (new_url, old_url))
199
+ if cursor.rowcount > 0:
200
+ conn.commit()
201
+ success = True
202
+ else:
203
+ success = False
204
+ except sqlite3.IntegrityError:
205
+ # New URL already exists
206
+ success = False
207
+ conn.close()
208
+ return success
209
+
210
+ def delete_url_from_sqlite(url):
211
+ """Delete a URL from SQLite database"""
212
+ conn = sqlite3.connect(SQLITE_DB)
213
+ cursor = conn.cursor()
214
+ cursor.execute("DELETE FROM urls WHERE url = ?", (url,))
215
+ if cursor.rowcount > 0:
216
+ conn.commit()
217
+ success = True
218
+ else:
219
+ success = False
220
+ conn.close()
221
+ return success
222
+
223
+ def load_db():
224
+ """Primary function to load URLs - prioritizes SQLite DB but falls back to JSON"""
225
+ urls = load_db_sqlite()
226
+ if not urls:
227
+ # If SQLite DB is empty, try loading from JSON
228
+ urls = load_json()
229
+ # If we found URLs in JSON, migrate them to SQLite
230
+ for url in urls:
231
+ add_url_to_sqlite(url)
232
+ return urls
233
+
234
+ def save_db(lst):
235
+ """Save URLs to both SQLite and JSON"""
236
+ # Get existing URLs from SQLite for comparison
237
+ existing_urls = load_db_sqlite()
238
+
239
+ # Clear all URLs from SQLite and add the new list
240
+ conn = sqlite3.connect(SQLITE_DB)
241
+ cursor = conn.cursor()
242
+ cursor.execute("DELETE FROM urls")
243
+ for url in lst:
244
+ cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,))
245
+ conn.commit()
246
+ conn.close()
247
+
248
+ # Also save to JSON for backward compatibility
249
+ return save_json(lst)
250
+
251
+ # ────────────────────────── 4. URL HELPERS ──────────────────────────
252
+ def direct_url(hf_url):
253
+ m = re.match(r"https?://huggingface\.co/spaces/([^/]+)/([^/?#]+)", hf_url)
254
+ if not m:
255
+ return hf_url
256
+ owner, name = m.groups()
257
+ owner = owner.lower()
258
+ name = name.replace('.', '-').replace('_', '-').lower()
259
+ return f"https://{owner}-{name}.hf.space"
260
+
261
+ def screenshot_url(url):
262
+ return f"https://image.thum.io/get/fullpage/{url}"
263
+
264
+ def process_url_for_preview(url):
265
+ """Returns (preview_url, mode)"""
266
+ # Handle blocked domains first
267
+ if any(d for d in BLOCKED_DOMAINS if d in url):
268
+ return screenshot_url(url), "snapshot"
269
+
270
+ # Special case handling for problematic URLs
271
+ if "vibe-coding-tetris" in url or "World-of-Tank-GAME" in url or "Minesweeper-Game" in url:
272
+ return screenshot_url(url), "snapshot"
273
+
274
+ # General HF space handling
275
+ try:
276
+ if "huggingface.co/spaces" in url:
277
+ parts = url.rstrip("/").split("/")
278
+ if len(parts) >= 5:
279
+ owner = parts[-2]
280
+ name = parts[-1]
281
+ embed_url = f"https://huggingface.co/spaces/{owner}/{name}/embed"
282
+ return embed_url, "iframe"
283
+ except Exception:
284
+ return screenshot_url(url), "snapshot"
285
+
286
+ # Default handling
287
+ return url, "iframe"
288
+
289
+ # ────────────────────────── 5. API ROUTES ──────────────────────────
290
+ @app.route('/api/category')
291
+ def api_category():
292
+ cat = request.args.get('name', '')
293
+ urls = CATEGORIES.get(cat, [])
294
+
295
+ # Add pagination for categories as well
296
+ page = int(request.args.get('page', 1))
297
+ per_page = int(request.args.get('per_page', 4)) # Changed to 4 per page
298
+
299
+ total_pages = max(1, (len(urls) + per_page - 1) // per_page)
300
+ start = (page - 1) * per_page
301
+ end = min(start + per_page, len(urls))
302
+
303
+ urls_page = urls[start:end]
304
+
305
+ items = [
306
+ {
307
+ "title": url.split('/')[-1],
308
+ "owner": url.split('/')[-2] if '/spaces/' in url else '',
309
+ "iframe": direct_url(url),
310
+ "shot": screenshot_url(url),
311
+ "hf": url
312
+ } for url in urls_page
313
+ ]
314
+
315
+ return jsonify({
316
+ "items": items,
317
+ "page": page,
318
+ "total_pages": total_pages
319
+ })
320
+
321
+ @app.route('/api/favorites')
322
+ def api_favorites():
323
+ # Load URLs from SQLite database
324
+ urls = load_db()
325
+
326
+ page = int(request.args.get('page', 1))
327
+ per_page = int(request.args.get('per_page', 4)) # Changed to 4 per page
328
+
329
+ total_pages = max(1, (len(urls) + per_page - 1) // per_page)
330
+ start = (page - 1) * per_page
331
+ end = min(start + per_page, len(urls))
332
+
333
+ urls_page = urls[start:end]
334
+
335
+ result = []
336
+ for url in urls_page:
337
+ try:
338
+ preview_url, mode = process_url_for_preview(url)
339
+ result.append({
340
+ "title": url.split('/')[-1],
341
+ "url": url,
342
+ "preview_url": preview_url,
343
+ "mode": mode
344
+ })
345
+ except Exception:
346
+ # Fallback to screenshot mode
347
+ result.append({
348
+ "title": url.split('/')[-1],
349
+ "url": url,
350
+ "preview_url": screenshot_url(url),
351
+ "mode": "snapshot"
352
+ })
353
+
354
+ return jsonify({
355
+ "items": result,
356
+ "page": page,
357
+ "total_pages": total_pages
358
+ })
359
+
360
+ @app.route('/api/url/add', methods=['POST'])
361
+ def add_url():
362
+ url = request.form.get('url', '').strip()
363
+ if not url:
364
+ return jsonify({"success": False, "message": "URL is required"})
365
+
366
+ # SQLite에 추가 시도
367
+ conn = sqlite3.connect(SQLITE_DB)
368
+ cursor = conn.cursor()
369
  try:
370
+ cursor.execute("INSERT INTO urls (url) VALUES (?)", (url,))
371
+ conn.commit()
372
+ success = True
373
+ except sqlite3.IntegrityError:
374
+ # URL이 이미 존재하는 경우
375
+ success = False
 
 
 
 
 
 
 
 
 
 
 
 
376
  except Exception as e:
377
+ print(f"SQLite error: {str(e)}")
378
+ success = False
 
 
379
  finally:
380
+ conn.close()
381
+
382
+ if not success:
383
+ return jsonify({"success": False, "message": "URL already exists or could not be added"})
384
+
385
+ # JSON 파일에도 추가 (백업용)
386
+ data = load_json()
387
+ if url not in data:
388
+ data.insert(0, url)
389
+ save_json(data)
390
+
391
+ return jsonify({"success": True, "message": "URL added successfully"})
392
+
393
+ @app.route('/api/url/update', methods=['POST'])
394
+ def update_url():
395
+ old = request.form.get('old', '')
396
+ new = request.form.get('new', '').strip()
397
+
398
+ if not new:
399
+ return jsonify({"success": False, "message": "New URL is required"})
400
+
401
+ # Update in SQLite DB
402
+ if not update_url_in_sqlite(old, new):
403
+ return jsonify({"success": False, "message": "URL not found or new URL already exists"})
404
+
405
+ # Also update JSON file for backward compatibility
406
+ data = load_json()
407
+ try:
408
+ idx = data.index(old)
409
+ data[idx] = new
410
+ save_json(data)
411
+ except ValueError:
412
+ # If URL not in JSON, add it
413
+ data.append(new)
414
+ save_json(data)
415
+
416
+ return jsonify({"success": True, "message": "URL updated successfully"})
417
+
418
+ @app.route('/api/url/delete', methods=['POST'])
419
+ def delete_url():
420
+ url = request.form.get('url', '')
421
+
422
+ # Delete from SQLite DB
423
+ if not delete_url_from_sqlite(url):
424
+ return jsonify({"success": False, "message": "URL not found"})
425
+
426
+ # Also update JSON file for backward compatibility
427
+ data = load_json()
428
+ try:
429
+ data.remove(url)
430
+ save_json(data)
431
+ except ValueError:
432
+ pass
433
+
434
+ return jsonify({"success": True, "message": "URL deleted successfully"})
435
+
436
+ # ────────────────────────── 6. MAIN ROUTES ──────────────────────────
437
+ @app.route('/')
438
+ def home():
439
+ os.makedirs('templates', exist_ok=True)
440
+
441
+ with open('templates/index.html', 'w', encoding='utf-8') as fp:
442
+ fp.write(r'''<!DOCTYPE html>
443
+ <html>
444
+ <head>
445
+ <meta charset="utf-8">
446
+ <meta name="viewport" content="width=device-width, initial-scale=1">
447
+ <title>Web Gallery</title>
448
+ <style>
449
+ @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;600&display=swap');
450
+ body{margin:0;font-family:Nunito,sans-serif;background:#f6f8fb;}
451
+ .tabs{display:flex;flex-wrap:wrap;gap:8px;padding:16px;}
452
+ .tab{padding:6px 14px;border:none;border-radius:18px;background:#e2e8f0;font-weight:600;cursor:pointer;}
453
+ .tab.active{background:#a78bfa;color:#1a202c;}
454
+ .tab.manage{background:#ff6e91;color:white;}
455
+ .tab.manage.active{background:#ff2d62;color:white;}
456
+ /* Updated grid to show 2x2 layout */
457
+ .grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;padding:0 16px 60px;max-width:1200px;margin:0 auto;}
458
+ @media(max-width:800px){.grid{grid-template-columns:1fr;}}
459
+ /* Increased card height for larger display */
460
+ .card{background:#fff;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.08);overflow:hidden;height:540px;display:flex;flex-direction:column;position:relative;}
461
+ .frame{flex:1;position:relative;overflow:hidden;}
462
+ .frame iframe{position:absolute;width:166.667%;height:166.667%;transform:scale(.6);transform-origin:top left;border:0;}
463
+ .frame img{width:100%;height:100%;object-fit:cover;}
464
+ .card-label{position:absolute;top:10px;left:10px;padding:4px 8px;border-radius:4px;font-size:11px;font-weight:bold;z-index:100;text-transform:uppercase;letter-spacing:0.5px;box-shadow:0 2px 4px rgba(0,0,0,0.2);}
465
+ .label-live{background:linear-gradient(135deg, #00c6ff, #0072ff);color:white;}
466
+ .label-static{background:linear-gradient(135deg, #ff9a9e, #fad0c4);color:#333;}
467
+ .foot{height:44px;background:#fafafa;display:flex;align-items:center;justify-content:center;border-top:1px solid #eee;}
468
+ .foot a{font-size:.82rem;font-weight:700;color:#4a6dd8;text-decoration:none;}
469
+ .pagination{display:flex;justify-content:center;margin:20px 0;gap:10px;}
470
+ .pagination button{padding:5px 15px;border:none;border-radius:20px;background:#e2e8f0;cursor:pointer;}
471
+ .pagination button:disabled{opacity:0.5;cursor:not-allowed;}
472
+ .manage-panel{background:white;border-radius:12px;box-shadow:0 2px 8px rgba(0,0,0,.08);margin:16px;padding:20px;}
473
+ .form-group{margin-bottom:15px;}
474
+ .form-group label{display:block;margin-bottom:5px;font-weight:600;}
475
+ .form-control{width:100%;padding:8px;border:1px solid #ddd;border-radius:4px;box-sizing:border-box;}
476
+ .btn{padding:8px 15px;border:none;border-radius:4px;cursor:pointer;font-weight:600;}
477
+ .btn-primary{background:#4a6dd8;color:white;}
478
+ .btn-danger{background:#e53e3e;color:white;}
479
+ .btn-success{background:#38a169;color:white;}
480
+ .status{padding:10px;margin:10px 0;border-radius:4px;display:none;}
481
+ .status.success{display:block;background:#c6f6d5;color:#22543d;}
482
+ .status.error{display:block;background:#fed7d7;color:#822727;}
483
+ .url-list{margin:20px 0;border:1px solid #eee;border-radius:4px;max-height:300px;overflow-y:auto;}
484
+ .url-item{padding:10px;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;}
485
+ .url-item:last-child{border-bottom:none;}
486
+ .url-controls{display:flex;gap:5px;}
487
+ </style>
488
+ </head>
489
+ <body>
490
+ <header style="text-align: center; padding: 20px; background: linear-gradient(135deg, #f6f8fb, #e2e8f0); border-bottom: 1px solid #ddd;">
491
+ <h1 style="margin-bottom: 10px;">🌟Open Free AI Playground</h1>
492
+ <p>
493
+ <a href="https://discord.gg/openfreeai" target="_blank"><img src="https://img.shields.io/static/v1?label=Discord&message=Openfree%20AI&color=%230000ff&labelColor=%23800080&logo=discord&logoColor=white&style=for-the-badge" alt="badge"></a>
494
+ </p>
495
+ </header>
496
+ <div class="tabs" id="tabs"></div>
497
+ <div id="content"></div>
498
+ <script>
499
+ // Basic configuration
500
+ const cats = {{cats|tojson}};
501
+ const tabs = document.getElementById('tabs');
502
+ const content = document.getElementById('content');
503
+ let active = "";
504
+ let currentPage = 1;
505
+ // Simple utility functions
506
+ function loadHTML(url, callback) {
507
+ const xhr = new XMLHttpRequest();
508
+ xhr.open('GET', url, true);
509
+ xhr.onreadystatechange = function() {
510
+ if (xhr.readyState === 4 && xhr.status === 200) {
511
+ callback(xhr.responseText);
512
+ }
513
+ };
514
+ xhr.send();
515
  }
516
+ function makeRequest(url, method, data, callback) {
517
+ const xhr = new XMLHttpRequest();
518
+ xhr.open(method, url, true);
519
+ xhr.onreadystatechange = function() {
520
+ if (xhr.readyState === 4 && xhr.status === 200) {
521
+ callback(JSON.parse(xhr.responseText));
522
+ }
523
+ };
524
+ if (method === 'POST') {
525
+ xhr.send(data);
526
+ } else {
527
+ xhr.send();
528
+ }
529
  }
530
+ function updateTabs() {
531
+ Array.from(tabs.children).forEach(b => {
532
+ b.classList.toggle('active', b.dataset.c === active);
533
+ });
 
 
534
  }
535
+ // Tab handlers
536
+ function loadCategory(cat, page) {
537
+ if(cat === active && currentPage === page) return;
538
+ active = cat;
539
+ currentPage = page || 1;
540
+ updateTabs();
541
+
542
+ content.innerHTML = '<p style="text-align:center;padding:40px">Loading…</p>';
543
+
544
+ makeRequest('/api/category?name=' + encodeURIComponent(cat) + '&page=' + currentPage + '&per_page=4', 'GET', null, function(data) {
545
+ let html = '<div class="grid">';
546
+
547
+ if(data.items.length === 0) {
548
+ html += '<p style="grid-column:1/-1;text-align:center;padding:40px">No items in this category.</p>';
549
+ } else {
550
+ data.items.forEach(item => {
551
+ html += `
552
+ <div class="card">
553
+ <div class="card-label label-live">LIVE</div>
554
+ <div class="frame">
555
+ <iframe src="${item.iframe}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>
556
+ </div>
557
+ <div class="foot">
558
+ <a href="${item.hf}" target="_blank">${item.title}</a>
559
+ </div>
560
+ </div>
561
+ `;
562
+ });
563
+ }
564
+
565
+ html += '</div>';
566
+
567
+ // Add pagination
568
+ html += `
569
+ <div class="pagination">
570
+ <button ${currentPage <= 1 ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage-1})">« Previous</button>
571
+ <span>Page ${currentPage} of ${data.total_pages}</span>
572
+ <button ${currentPage >= data.total_pages ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage+1})">Next »</button>
573
+ </div>
574
+ `;
575
+
576
+ content.innerHTML = html;
577
+ });
578
+ }
579
+ function loadFavorites(page) {
580
+ if(active === 'Favorites' && currentPage === page) return;
581
+ active = 'Favorites';
582
+ currentPage = page || 1;
583
+ updateTabs();
584
+
585
+ content.innerHTML = '<p style="text-align:center;padding:40px">Loading…</p>';
586
+
587
+ makeRequest('/api/favorites?page=' + currentPage + '&per_page=4', 'GET', null, function(data) {
588
+ let html = '<div class="grid">';
589
+
590
+ if(data.items.length === 0) {
591
+ html += '<p style="grid-column:1/-1;text-align:center;padding:40px">No favorites saved yet.</p>';
592
+ } else {
593
+ data.items.forEach(item => {
594
+ if(item.mode === 'snapshot') {
595
+ html += `
596
+ <div class="card">
597
+ <div class="card-label label-static">Static</div>
598
+ <div class="frame">
599
+ <img src="${item.preview_url}" loading="lazy">
600
+ </div>
601
+ <div class="foot">
602
+ <a href="${item.url}" target="_blank">${item.title}</a>
603
+ </div>
604
+ </div>
605
+ `;
606
+ } else {
607
+ html += `
608
+ <div class="card">
609
+ <div class="card-label label-live">LIVE</div>
610
+ <div class="frame">
611
+ <iframe src="${item.preview_url}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>
612
+ </div>
613
+ <div class="foot">
614
+ <a href="${item.url}" target="_blank">${item.title}</a>
615
+ </div>
616
+ </div>
617
+ `;
618
+ }
619
+ });
620
+ }
621
+
622
+ html += '</div>';
623
+
624
+ // Add pagination
625
+ html += `
626
+ <div class="pagination">
627
+ <button ${currentPage <= 1 ? 'disabled' : ''} onclick="loadFavorites(${currentPage-1})">« Previous</button>
628
+ <span>Page ${currentPage} of ${data.total_pages}</span>
629
+ <button ${currentPage >= data.total_pages ? 'disabled' : ''} onclick="loadFavorites(${currentPage+1})">Next »</button>
630
+ </div>
631
+ `;
632
+
633
+ content.innerHTML = html;
634
+ });
635
+ }
636
+ function loadManage() {
637
+ if(active === 'Manage') return;
638
+ active = 'Manage';
639
+ updateTabs();
640
+
641
+ content.innerHTML = `
642
+ <div class="manage-panel">
643
+ <h2>Add New URL</h2>
644
+ <div class="form-group">
645
+ <label for="new-url">URL</label>
646
+ <input type="text" id="new-url" class="form-control" placeholder="https://example.com">
647
+ </div>
648
+ <button onclick="addUrl()" class="btn btn-primary">Add URL</button>
649
+ <div id="add-status" class="status"></div>
650
+
651
+ <h2>Manage Saved URLs</h2>
652
+ <div id="url-list" class="url-list">Loading...</div>
653
+ </div>
654
+ `;
655
+
656
+ loadUrlList();
657
+ }
658
+ // URL management functions
659
+ function loadUrlList() {
660
+ makeRequest('/api/favorites?per_page=100', 'GET', null, function(data) {
661
+ const urlList = document.getElementById('url-list');
662
+
663
+ if(data.items.length === 0) {
664
+ urlList.innerHTML = '<p style="text-align:center;padding:20px">No URLs saved yet.</p>';
665
+ return;
666
+ }
667
+
668
+ let html = '';
669
+ data.items.forEach(item => {
670
+ // Escape the URL to prevent JavaScript injection when used in onclick handlers
671
+ const escapedUrl = item.url.replace(/'/g, "\\'");
672
+
673
+ html += `
674
+ <div class="url-item">
675
+ <div>${item.url}</div>
676
+ <div class="url-controls">
677
+ <button class="btn" onclick="editUrl('${escapedUrl}')">Edit</button>
678
+ <button class="btn btn-danger" onclick="deleteUrl('${escapedUrl}')">Delete</button>
679
+ </div>
680
+ </div>
681
+ `;
682
+ });
683
+
684
+ urlList.innerHTML = html;
685
+ });
686
+ }
687
+ function addUrl() {
688
+ const url = document.getElementById('new-url').value.trim();
689
+
690
+ if(!url) {
691
+ showStatus('add-status', 'Please enter a URL', false);
692
+ return;
693
+ }
694
+
695
+ const formData = new FormData();
696
+ formData.append('url', url);
697
+
698
+ makeRequest('/api/url/add', 'POST', formData, function(data) {
699
+ showStatus('add-status', data.message, data.success);
700
+ if(data.success) {
701
+ document.getElementById('new-url').value = '';
702
+ loadUrlList();
703
+ // If currently in Favorites tab, reload to see changes immediately
704
+ if(active === 'Favorites') {
705
+ loadFavorites(currentPage);
706
+ }
707
+ }
708
+ });
709
+ }
710
+ function editUrl(url) {
711
+ // Decode URL if it was previously escaped
712
+ const decodedUrl = url.replace(/\\'/g, "'");
713
+ const newUrl = prompt('Edit URL:', decodedUrl);
714
+
715
+ if(!newUrl || newUrl === decodedUrl) return;
716
+
717
+ const formData = new FormData();
718
+ formData.append('old', decodedUrl);
719
+ formData.append('new', newUrl);
720
+
721
+ makeRequest('/api/url/update', 'POST', formData, function(data) {
722
+ if(data.success) {
723
+ loadUrlList();
724
+ // If currently in Favorites tab, reload to see changes immediately
725
+ if(active === 'Favorites') {
726
+ loadFavorites(currentPage);
727
+ }
728
+ } else {
729
+ alert(data.message);
730
+ }
731
+ });
732
+ }
733
+ function deleteUrl(url) {
734
+ // Decode URL if it was previously escaped
735
+ const decodedUrl = url.replace(/\\'/g, "'");
736
+ if(!confirm('Are you sure you want to delete this URL?')) return;
737
+
738
+ const formData = new FormData();
739
+ formData.append('url', decodedUrl);
740
+
741
+ makeRequest('/api/url/delete', 'POST', formData, function(data) {
742
+ if(data.success) {
743
+ loadUrlList();
744
+ // If currently in Favorites tab, reload to see changes immediately
745
+ if(active === 'Favorites') {
746
+ loadFavorites(currentPage);
747
+ }
748
+ } else {
749
+ alert(data.message);
750
+ }
751
+ });
752
+ }
753
+ function showStatus(id, message, success) {
754
+ const status = document.getElementById(id);
755
+ status.textContent = message;
756
+ status.className = success ? 'status success' : 'status error';
757
+ setTimeout(() => {
758
+ status.className = 'status';
759
+ }, 3000);
760
+ }
761
+ // Create tabs
762
+ // Favorites tab first
763
+ const favTab = document.createElement('button');
764
+ favTab.className = 'tab';
765
+ favTab.textContent = 'Favorites';
766
+ favTab.dataset.c = 'Favorites';
767
+ favTab.onclick = function() { loadFavorites(1); };
768
+ tabs.appendChild(favTab);
769
+ // Category tabs
770
+ cats.forEach(c => {
771
+ const b = document.createElement('button');
772
+ b.className = 'tab';
773
+ b.textContent = c;
774
+ b.dataset.c = c;
775
+ b.onclick = function() { loadCategory(c, 1); };
776
+ tabs.appendChild(b);
777
+ });
778
+ // Manage tab last
779
+ const manageTab = document.createElement('button');
780
+ manageTab.className = 'tab manage';
781
+ manageTab.textContent = 'Manage';
782
+ manageTab.dataset.c = 'Manage';
783
+ manageTab.onclick = function() { loadManage(); };
784
+ tabs.appendChild(manageTab);
785
+ // Start with Favorites tab
786
+ loadFavorites(1);
787
+ </script>
788
+ </body>
789
+ </html>''')
790
+
791
+ # Return the rendered template
792
+ return render_template('index.html', cats=list(CATEGORIES.keys()))
793
+
794
+ # Initialize database on startup
795
+ init_db()
796
+
797
+ # Define a function to ensure database consistency
798
+ def ensure_db_consistency():
799
+ # Make sure we have the latest data in both JSON and SQLite
800
+ urls = load_db_sqlite()
801
+ save_json(urls)
802
+
803
+ # For Flask 2.0+ compatibility
804
+ @app.before_request
805
+ def before_request_func():
806
+ # Use a flag to run this only once
807
+ if not hasattr(app, '_got_first_request'):
808
+ ensure_db_consistency()
809
+ app._got_first_request = True
810
+
811
+ if __name__ == '__main__':
812
+ # 앱 시작 전에 명시적으로 DB 초기화
813
+ print("Initializing database...")
814
+ init_db()
815
+
816
+ # 데이터베이스 파일 경로 및 존재 여부 확인
817
+ db_path = os.path.abspath(SQLITE_DB)
818
+ print(f"SQLite DB path: {db_path}")
819
+ if os.path.exists(SQLITE_DB):
820
+ print(f"Database file exists, size: {os.path.getsize(SQLITE_DB)} bytes")
821
+ else:
822
+ print("Warning: Database file does not exist after initialization!")
823
+
824
+ app.run(host='0.0.0.0', port=7860)