aiqtech commited on
Commit
7e0f45a
·
verified ·
1 Parent(s): 1ecc37d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +313 -422
app.py CHANGED
@@ -1,429 +1,320 @@
1
- # app.py
2
- import gradio as gr
3
- import json
4
- import os
5
- from pathlib import Path
6
- import shutil
7
-
8
- def create_game_html(model_path):
9
- html_template = f"""
10
- <!DOCTYPE html>
11
- <html>
12
- <head>
13
- <title>Advanced 3D Open World Game</title>
14
- <style>
15
- body {{ margin: 0; }}
16
- canvas {{ display: block; }}
17
- #info {{
18
- position: absolute;
19
- top: 10px;
20
- left: 10px;
21
- background: rgba(0,0,0,0.7);
22
- color: white;
23
- padding: 10px;
24
- border-radius: 5px;
25
- font-family: Arial, sans-serif;
26
- }}
27
- #weather-controls {{
28
- position: absolute;
29
- top: 10px;
30
- right: 10px;
31
- background: rgba(0,0,0,0.7);
32
- color: white;
33
- padding: 10px;
34
- border-radius: 5px;
35
- }}
36
- </style>
37
- <script async src="https://unpkg.com/[email protected]/dist/es-module-shims.js"></script>
38
- <script type="importmap">
39
- {{
40
- "imports": {{
41
- "three": "https://unpkg.com/[email protected]/build/three.module.js",
42
- "three/addons/": "https://unpkg.com/[email protected]/examples/jsm/"
43
- }}
44
- }}
45
- </script>
46
- </head>
47
- <body>
48
- <div id="info">
49
- Controls:<br>
50
- W/S/A/D - Move<br>
51
- SPACE - Jump<br>
52
- Mouse - Look Around<br>
53
- HP: <span id="hp">100</span>
54
- </div>
55
- <div id="weather-controls">
56
- Weather:
57
- <select id="weather-select" onchange="changeWeather(this.value)">
58
- <option value="clear">Clear</option>
59
- <option value="rain">Rain</option>
60
- <option value="fog">Fog</option>
61
- </select>
62
- </div>
63
- <script type="module">
64
- import * as THREE from 'three';
65
- import {{ GLTFLoader }} from 'three/addons/loaders/GLTFLoader.js';
66
- import {{ PointerLockControls }} from 'three/addons/controls/PointerLockControls.js';
67
-
68
- // 기본 설정
69
- const scene = new THREE.Scene();
70
- const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
71
- const renderer = new THREE.WebGLRenderer({{ antialias: true }});
72
- renderer.setSize(window.innerWidth, window.innerHeight);
73
- renderer.shadowMap.enabled = true;
74
- document.body.appendChild(renderer.domElement);
75
-
76
- // 물리 시스템 변수
77
- const gravity = -0.5;
78
- let velocity = new THREE.Vector3();
79
- let isJumping = false;
80
- let canJump = true;
81
- let playerHeight = 2;
82
-
83
- // 캐릭터 상태
84
- let hp = 100;
85
- const hpElement = document.getElementById('hp');
86
-
87
- // 포인터 락 컨트롤
88
- const controls = new PointerLockControls(camera, document.body);
89
-
90
- // 키보드 입력 처리
91
- const moveState = {{
92
- forward: false,
93
- backward: false,
94
- left: false,
95
- right: false,
96
- jump: false
97
- }};
98
-
99
- document.addEventListener('keydown', (event) => {{
100
- switch (event.code) {{
101
- case 'KeyW': moveState.forward = true; break;
102
- case 'KeyS': moveState.backward = true; break;
103
- case 'KeyA': moveState.left = true; break;
104
- case 'KeyD': moveState.right = true; break;
105
- case 'Space':
106
- if (canJump) {{
107
- moveState.jump = true;
108
- velocity.y = 10;
109
- isJumping = true;
110
- canJump = false;
111
- }}
112
- break;
113
- }}
114
- }});
115
-
116
- document.addEventListener('keyup', (event) => {{
117
- switch (event.code) {{
118
- case 'KeyW': moveState.forward = false; break;
119
- case 'KeyS': moveState.backward = false; break;
120
- case 'KeyA': moveState.left = false; break;
121
- case 'KeyD': moveState.right = false; break;
122
- case 'Space': moveState.jump = false; break;
123
- }}
124
- }});
125
-
126
- // 클릭으로 게임 시작
127
- document.addEventListener('click', () => {{
128
- controls.lock();
129
- }});
130
-
131
- // 날씨 시스템
132
- let raindrops = [];
133
- const rainCount = 1000;
134
- const rainGeometry = new THREE.BufferGeometry();
135
- const rainMaterial = new THREE.PointsMaterial({{
136
- color: 0xaaaaaa,
137
- size: 0.1,
138
- transparent: true
139
- }});
140
-
141
- function createRain() {{
142
- const positions = new Float32Array(rainCount * 3);
143
- for (let i = 0; i < rainCount * 3; i += 3) {{
144
- positions[i] = (Math.random() - 0.5) * 1000;
145
- positions[i + 1] = Math.random() * 500;
146
- positions[i + 2] = (Math.random() - 0.5) * 1000;
147
- }}
148
- rainGeometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
149
- const rain = new THREE.Points(rainGeometry, rainMaterial);
150
- scene.add(rain);
151
- return rain;
152
- }}
153
-
154
- let rain = createRain();
155
- rain.visible = false;
156
-
157
- // 안개 설정
158
- scene.fog = new THREE.Fog(0xcce0ff, 1, 1000);
159
- scene.fog.near = 1;
160
- scene.fog.far = 1000;
161
-
162
- // 날씨 변경 함수
163
- window.changeWeather = function(weather) {{
164
- switch(weather) {{
165
- case 'clear':
166
- rain.visible = false;
167
- scene.fog.near = 1;
168
- scene.fog.far = 1000;
169
- break;
170
- case 'rain':
171
- rain.visible = true;
172
- scene.fog.near = 1;
173
- scene.fog.far = 100;
174
- break;
175
- case 'fog':
176
- rain.visible = false;
177
- scene.fog.near = 1;
178
- scene.fog.far = 50;
179
- break;
180
- }}
181
- }};
182
-
183
- // NPC 시스템
184
- class NPC {{
185
- constructor(position) {{
186
- const geometry = new THREE.CapsuleGeometry(1, 2, 4, 8);
187
- const material = new THREE.MeshPhongMaterial({{ color: 0xff0000 }});
188
- this.mesh = new THREE.Mesh(geometry, material);
189
- this.mesh.position.copy(position);
190
- this.mesh.castShadow = true;
191
- this.velocity = new THREE.Vector3();
192
- this.direction = new THREE.Vector3();
193
- scene.add(this.mesh);
194
- }}
195
-
196
- update(playerPosition) {{
197
- // 플레이어 방향으로 이동
198
- this.direction.subVectors(playerPosition, this.mesh.position);
199
- this.direction.normalize();
200
- this.velocity.add(this.direction.multiplyScalar(0.1));
201
- this.velocity.multiplyScalar(0.95); // 감속
202
- this.mesh.position.add(this.velocity);
203
-
204
- // 플레이어와의 충돌 검사
205
- const distance = this.mesh.position.distanceTo(playerPosition);
206
- if (distance < 2) {{
207
- hp -= 1;
208
- hpElement.textContent = hp;
209
- if (hp <= 0) {{
210
- alert('Game Over!');
211
- location.reload();
212
- }}
213
- }}
214
- }}
215
- }}
216
-
217
- // NPC 생성
218
- const npcs = [];
219
- for (let i = 0; i < 5; i++) {{
220
- const position = new THREE.Vector3(
221
- (Math.random() - 0.5) * 100,
222
- 2,
223
- (Math.random() - 0.5) * 100
224
- );
225
- npcs.push(new NPC(position));
226
- }}
227
-
228
- // 지형 생성
229
- const terrainGeometry = new THREE.PlaneGeometry(1000, 1000, 100, 100);
230
- const terrainMaterial = new THREE.MeshStandardMaterial({{
231
- color: 0x3a8c3a,
232
- wireframe: false
233
- }});
234
- const terrain = new THREE.Mesh(terrainGeometry, terrainMaterial);
235
- terrain.rotation.x = -Math.PI / 2;
236
- terrain.receiveShadow = true;
237
- scene.add(terrain);
238
-
239
- // 지형 높낮이 설정
240
- const vertices = terrainGeometry.attributes.position.array;
241
- for (let i = 0; i < vertices.length; i += 3) {{
242
- vertices[i + 2] = Math.random() * 10;
243
- }}
244
- terrainGeometry.attributes.position.needsUpdate = true;
245
- terrainGeometry.computeVertexNormals();
246
-
247
- // 조명 설정
248
- const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
249
- scene.add(ambientLight);
250
-
251
- const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
252
- directionalLight.position.set(100, 100, 100);
253
- directionalLight.castShadow = true;
254
- scene.add(directionalLight);
255
-
256
- // 배경 하늘 설정
257
- const skyGeometry = new THREE.SphereGeometry(500, 32, 32);
258
- const skyMaterial = new THREE.MeshBasicMaterial({{
259
- color: 0x87ceeb,
260
- side: THREE.BackSide
261
- }});
262
- const sky = new THREE.Mesh(skyGeometry, skyMaterial);
263
- scene.add(sky);
264
-
265
- // 나무와 바위 추가
266
- function addEnvironmentObject(geometry, material, count, yOffset) {{
267
- for (let i = 0; i < count; i++) {{
268
- const mesh = new THREE.Mesh(geometry, material);
269
- const x = (Math.random() - 0.5) * 900;
270
- const z = (Math.random() - 0.5) * 900;
271
- const y = yOffset;
272
- mesh.position.set(x, y, z);
273
- mesh.castShadow = true;
274
- mesh.receiveShadow = true;
275
- scene.add(mesh);
276
- }}
277
- }}
278
-
279
- // 나무 생성
280
- const treeGeometry = new THREE.ConeGeometry(2, 8, 8);
281
- const treeMaterial = new THREE.MeshStandardMaterial({{ color: 0x0d5c0d }});
282
- addEnvironmentObject(treeGeometry, treeMaterial, 100, 4);
283
-
284
- // 바위 생성
285
- const rockGeometry = new THREE.DodecahedronGeometry(2);
286
- const rockMaterial = new THREE.MeshStandardMaterial({{ color: 0x666666 }});
287
- addEnvironmentObject(rockGeometry, rockMaterial, 50, 1);
288
-
289
- // 캐릭터 모델 로드
290
- let character;
291
- const loader = new GLTFLoader();
292
- loader.load('{model_path}', (gltf) => {{
293
- character = gltf.scene;
294
- character.scale.set(0.5, 0.5, 0.5);
295
- character.position.y = 2;
296
- character.castShadow = true;
297
- character.receiveShadow = true;
298
- scene.add(character);
299
-
300
- // 캐릭터 그림자 설정
301
- character.traverse((node) => {{
302
- if (node.isMesh) {{
303
- node.castShadow = true;
304
- node.receiveShadow = true;
305
- }}
306
- }});
307
- }});
308
-
309
- // 카메라 초기 위치 설정
310
- camera.position.set(0, playerHeight, 0);
311
-
312
- // 애니메이션 루프
313
- function animate() {{
314
- requestAnimationFrame(animate);
315
-
316
- if (controls.isLocked) {{
317
- // 이동 처리
318
- const direction = new THREE.Vector3();
319
- const rotation = camera.getWorldDirection(new THREE.Vector3());
320
-
321
- if (moveState.forward) direction.add(rotation);
322
- if (moveState.backward) direction.sub(rotation);
323
- if (moveState.left) direction.cross(camera.up).negate();
324
- if (moveState.right) direction.cross(camera.up);
325
-
326
- direction.y = 0;
327
- direction.normalize();
328
-
329
- // 물리 시스템 적용
330
- velocity.y += gravity;
331
- camera.position.y += velocity.y * 0.1;
332
-
333
- // 바닥 충돌 검사
334
- if (camera.position.y <= playerHeight) {{
335
- camera.position.y = playerHeight;
336
- velocity.y = 0;
337
- isJumping = false;
338
- canJump = true;
339
- }}
340
-
341
- // 이동 속도 적용
342
- const moveSpeed = 0.5;
343
- controls.moveRight(-direction.z * moveSpeed);
344
- controls.moveForward(direction.x * moveSpeed);
345
-
346
- // 캐릭터 모델 업데이트
347
- if (character) {{
348
- character.position.copy(camera.position);
349
- character.position.y -= playerHeight;
350
- if (direction.length() > 0) {{
351
- const angle = Math.atan2(direction.x, direction.z);
352
- character.rotation.y = angle;
353
- }}
354
- }}
355
-
356
- // 비 업데이트
357
- if (rain.visible) {{
358
- const positions = rain.geometry.attributes.position.array;
359
- for (let i = 1; i < positions.length; i += 3) {{
360
- positions[i] -= 2;
361
- if (positions[i] < 0) {{
362
- positions[i] = 500;
363
- }}
364
- }}
365
- rain.geometry.attributes.position.needsUpdate = true;
366
- }}
367
-
368
- // NPC 업데이트
369
- npcs.forEach(npc => npc.update(camera.position));
370
- }}
371
-
372
- renderer.render(scene, camera);
373
- }}
374
- animate();
375
-
376
- // 윈도우 리사이즈 처리
377
- window.addEventListener('resize', () => {{
378
- camera.aspect = window.innerWidth / window.innerHeight;
379
-
380
-
381
- camera.updateProjectionMatrix();
382
- renderer.setSize(window.innerWidth, window.innerHeight);
383
- }});
384
- </script>
385
- </body>
386
- </html>
387
- """
388
- return html_template
389
-
390
- def process_upload(glb_file):
391
- if glb_file is None:
392
- return None
393
-
394
- # 업로드된 파일 처리
395
- upload_dir = "uploads"
396
- os.makedirs(upload_dir, exist_ok=True)
397
 
398
- file_path = Path(glb_file)
399
- dest_path = Path(upload_dir) / file_path.name
400
- shutil.copy(file_path, dest_path)
401
 
402
- # 게임 HTML 생성
403
- game_html = create_game_html(str(dest_path))
 
 
 
404
 
405
- # HTML 파일 저장
406
- game_path = Path("game.html")
407
- with open(game_path, "w", encoding="utf-8") as f:
408
- f.write(game_html)
 
 
 
409
 
410
- return str(game_path)
 
 
 
 
 
 
 
 
 
411
 
412
- # Gradio 인터페이스 설정
413
- iface = gr.Interface(
414
- fn=process_upload,
415
- inputs=[
416
- gr.File(
417
- label="Upload Character Model (GLB)",
418
- type="filepath",
419
- file_types=[".glb"]
420
- )
 
421
  ],
422
- outputs=gr.HTML(label="3D Open World Game"),
423
- title="Advanced 3D Open World Game Generator",
424
- description="Upload your character model (GLB) to create a 3D open world game with advanced features!",
425
- cache_examples=False
426
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
427
 
428
- if __name__ == "__main__":
429
- iface.launch()
 
1
+ from flask import Flask, render_template, request, jsonify
2
+ import os, re, json
3
+
4
+ app = Flask(__name__)
5
+
6
+ # ────────────────────────── 1. CONFIGURATION ──────────────────────────
7
+ # Domains that commonly block iframes
8
+ BLOCKED_DOMAINS = [
9
+ "naver.com", "daum.net", "google.com",
10
+ "facebook.com", "instagram.com", "kakao.com",
11
+ "ycombinator.com"
12
+ ]
13
+
14
+ # ────────────────────────── 2. CURATED CATEGORIES ──────────────────────────
15
+ CATEGORIES = {
16
+ "Popular": [
17
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-REAL",
18
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored",
19
+ "https://huggingface.co/spaces/Dagfinn1962/Midjourney-Free", ####
20
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-photo",
21
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video2",
22
+ "https://huggingface.co/spaces/Heartsync/Novel-NSFW",
23
+
24
+ "https://huggingface.co/spaces/erax/EraX-NSFW-V1.0", ###
25
+ "https://huggingface.co/spaces/yoinked/da_nsfw_checker", #####
26
+ "https://huggingface.co/spaces/LearningnRunning/adult_image_detector", ###
27
+ ],
28
+ "BEST": [
29
+ "https://huggingface.co/spaces/Heartsync/adult",
30
+ "https://huggingface.co/spaces/ginigen/Flux-VIDEO",
31
+ "https://huggingface.co/spaces/openfree/DreamO-video",
32
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video",
33
+ "https://huggingface.co/spaces/Heartsync/NSFW-novels",
34
+ "https://huggingface.co/spaces/fantaxy/fantasy-novel",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
 
36
+ ],
 
 
37
 
38
+ "TEXT generate": [
39
+ "https://huggingface.co/spaces/Heartsync/Novel-NSFW",
40
+ "https://huggingface.co/spaces/fantaxy/fantasy-novel",
41
+ "https://huggingface.co/spaces/Heartsync/NSFW-novels",
42
+ ],
43
 
44
+ "TEXT TO IMAGE": [
45
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored",
46
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-photo",
47
+ "https://huggingface.co/spaces/Heartsync/adult",
48
+ "https://huggingface.co/spaces/Heartsync/NSFW-novels",
49
+ "https://huggingface.co/spaces/IbarakiDouji/WAI-NSFW-illustrious-SDXL", ###
50
+ "https://huggingface.co/spaces/armen425221356/UnfilteredAI-NSFW-gen-v2_self_parms", ####
51
 
52
+ ],
53
+ "IMAGE TO VIDEO": [
54
+ "https://huggingface.co/spaces/Heartsync/adult",
55
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video",
56
+ "https://huggingface.co/spaces/Heartsync/NSFW-Uncensored-video2",
57
+ "https://huggingface.co/spaces/openfree/DreamO-video",
58
+ "https://huggingface.co/spaces/Heartsync/wan2-1-fast-security",
59
+ "https://huggingface.co/spaces/ginigen/Flux-VIDEO",
60
+ "https://huggingface.co/spaces/Heartsync/WAN-VIDEO-AUDIO",
61
+ ],
62
 
63
+ "IMAGE IN/OUT-PAINTING": [
64
+ "https://huggingface.co/spaces/ginigen/FLUX-Ghibli-LoRA2",
65
+ "https://huggingface.co/spaces/davecarrau/nsfw-face-swap", ###
66
+ "https://huggingface.co/spaces/VIDraft/ReSize-Image-Outpainting",
67
+ "https://huggingface.co/spaces/aiqcamp/REMOVAL-TEXT-IMAGE",
68
+ "https://huggingface.co/spaces/ginigen/MagicFace-V3",
69
+ "https://huggingface.co/spaces/openfree/ColorRevive",
70
+ "https://huggingface.co/spaces/ginigen/VisualCloze",
71
+ "https://huggingface.co/spaces/fantos/textcutobject",
72
+
73
  ],
74
+
75
+ "Extension": [
76
+ "https://huggingface.co/spaces/erax/EraX-NSFW-V1.0", ###
77
+ "https://huggingface.co/spaces/yoinked/da_nsfw_checker", #####
78
+ "https://huggingface.co/spaces/LearningnRunning/adult_image_detector", ###
79
+
80
+ "https://huggingface.co/spaces/VIDraft/ACE-Singer",
81
+ "https://huggingface.co/spaces/VIDraft/Voice-Clone-Podcast",
82
+ "https://huggingface.co/spaces/ginigen/VoiceClone-TTS",
83
+ "https://huggingface.co/spaces/openfree/Multilingual-TTS",
84
+ "https://huggingface.co/spaces/fantaxy/Sound-AI-SFX",
85
+ "https://huggingface.co/spaces/ginigen/SFX-Sound-magic",
86
+ "https://huggingface.co/spaces/fantaxy/Remove-Video-Background",
87
+ "https://huggingface.co/spaces/VIDraft/stable-diffusion-3.5-large-turboX",
88
+
89
+ "https://huggingface.co/spaces/aiqtech/imaginpaint",
90
+ "https://huggingface.co/spaces/openfree/ultpixgen",
91
+ # "https://huggingface.co/spaces/ginipick/Change-Hair",
92
+ # "https://huggingface.co/spaces/ginigen/Every-Text",
93
+
94
+ ],
95
+
96
+ "Utility": [
97
+ "https://huggingface.co/spaces/openfree/Chart-GPT",
98
+ "https://huggingface.co/spaces/ginipick/AI-BOOK",
99
+ "https://huggingface.co/spaces/openfree/Live-Podcast",
100
+ "https://huggingface.co/spaces/openfree/AI-Podcast",
101
+ "https://huggingface.co/spaces/ginipick/FLUXllama",
102
+ "https://huggingface.co/spaces/VIDraft/Polaroid-Style",
103
+ "https://huggingface.co/spaces/ginigen/text3d-r1",
104
+ "https://huggingface.co/spaces/openfree/Naming",
105
+ "https://huggingface.co/spaces/ginigen/3D-LLAMA-V1",
106
+ "https://huggingface.co/spaces/fantaxy/flx-pulid",
107
+ ],
108
+ }
109
+
110
+ # ────────────────────────── 3. URL HELPERS ──────────────────────────
111
+ def direct_url(hf_url):
112
+ m = re.match(r"https?://huggingface\.co/spaces/([^/]+)/([^/?#]+)", hf_url)
113
+ if not m:
114
+ return hf_url
115
+ owner, name = m.groups()
116
+ owner = owner.lower()
117
+ name = name.replace('.', '-').replace('_', '-').lower()
118
+ return f"https://{owner}-{name}.hf.space"
119
+
120
+ def screenshot_url(url):
121
+ return f"https://image.thum.io/get/fullpage/{url}"
122
+
123
+ def process_url_for_preview(url):
124
+ """Returns (preview_url, mode)"""
125
+ # Handle blocked domains first
126
+ if any(d for d in BLOCKED_DOMAINS if d in url):
127
+ return screenshot_url(url), "snapshot"
128
+
129
+ # Special case handling for problematic URLs
130
+ if "vibe-coding-tetris" in url or "World-of-Tank-GAME" in url or "Minesweeper-Game" in url:
131
+ return screenshot_url(url), "snapshot"
132
+
133
+ # General HF space handling
134
+ try:
135
+ if "huggingface.co/spaces" in url:
136
+ parts = url.rstrip("/").split("/")
137
+ if len(parts) >= 5:
138
+ owner = parts[-2]
139
+ name = parts[-1]
140
+ embed_url = f"https://huggingface.co/spaces/{owner}/{name}/embed"
141
+ return embed_url, "iframe"
142
+ except Exception:
143
+ return screenshot_url(url), "snapshot"
144
+
145
+ # Default handling
146
+ return url, "iframe"
147
+
148
+ # ────────────────────────── 4. API ROUTES ──────────────────────────
149
+ @app.route('/api/category')
150
+ def api_category():
151
+ cat = request.args.get('name', '')
152
+ urls = CATEGORIES.get(cat, [])
153
+
154
+ # Add pagination for categories
155
+ page = int(request.args.get('page', 1))
156
+ per_page = int(request.args.get('per_page', 4)) # 4 per page for 2x2 grid
157
+
158
+ total_pages = max(1, (len(urls) + per_page - 1) // per_page)
159
+ start = (page - 1) * per_page
160
+ end = min(start + per_page, len(urls))
161
+
162
+ urls_page = urls[start:end]
163
+
164
+ items = [
165
+ {
166
+ "title": url.split('/')[-1],
167
+ "owner": url.split('/')[-2] if '/spaces/' in url else '',
168
+ "iframe": direct_url(url),
169
+ "shot": screenshot_url(url),
170
+ "hf": url
171
+ } for url in urls_page
172
+ ]
173
+
174
+ return jsonify({
175
+ "items": items,
176
+ "page": page,
177
+ "total_pages": total_pages
178
+ })
179
+
180
+ # ────────────────────────── 5. MAIN ROUTES ──────────────────────────
181
+ @app.route('/')
182
+ def home():
183
+ os.makedirs('templates', exist_ok=True)
184
+
185
+ with open('templates/index.html', 'w', encoding='utf-8') as fp:
186
+ fp.write(r'''<!DOCTYPE html>
187
+ <html>
188
+ <head>
189
+ <meta charset="utf-8">
190
+ <meta name="viewport" content="width=device-width, initial-scale=1">
191
+ <title>Free NSFW Hub</title>
192
+ <style>
193
+ @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@300;600&display=swap');
194
+ body{margin:0;font-family:Nunito,sans-serif;background:#f6f8fb;}
195
+ .tabs{display:flex;flex-wrap:wrap;gap:8px;padding:16px;}
196
+ .tab{padding:6px 14px;border:none;border-radius:18px;background:#e2e8f0;font-weight:600;cursor:pointer;}
197
+ .tab.active{background:#a78bfa;color:#1a202c;}
198
+ /* Updated grid to show 2x2 layout */
199
+ .grid{display:grid;grid-template-columns:repeat(2,1fr);gap:20px;padding:0 16px 60px;max-width:1200px;margin:0 auto;}
200
+ @media(max-width:800px){.grid{grid-template-columns:1fr;}}
201
+ /* Increased card height for larger display */
202
+ .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;}
203
+ .frame{flex:1;position:relative;overflow:hidden;}
204
+ .frame iframe{position:absolute;width:166.667%;height:166.667%;transform:scale(.6);transform-origin:top left;border:0;}
205
+ .frame img{width:100%;height:100%;object-fit:cover;}
206
+ .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);}
207
+ .label-live{background:linear-gradient(135deg, #00c6ff, #0072ff);color:white;}
208
+ .label-static{background:linear-gradient(135deg, #ff9a9e, #fad0c4);color:#333;}
209
+ .foot{height:44px;background:#fafafa;display:flex;align-items:center;justify-content:center;border-top:1px solid #eee;}
210
+ .foot a{font-size:.82rem;font-weight:700;color:#4a6dd8;text-decoration:none;}
211
+ .pagination{display:flex;justify-content:center;margin:20px 0;gap:10px;}
212
+ .pagination button{padding:5px 15px;border:none;border-radius:20px;background:#e2e8f0;cursor:pointer;}
213
+ .pagination button:disabled{opacity:0.5;cursor:not-allowed;}
214
+ </style>
215
+ </head>
216
+ <body>
217
+ <header style="text-align: center; padding: 20px; background: linear-gradient(135deg, #f6f8fb, #e2e8f0); border-bottom: 1px solid #ddd;">
218
+ <h1 style="margin-bottom: 10px;">🔥Free NSFW Hub</h1>
219
+ <p style="margin-bottom: 15px; color: #666; font-size: 14px;">
220
+ A curated collection of the most popular and polished NSFW Detection projects on Hugging Face Spaces,<br>
221
+ organized for easy visual exploration and discovery.
222
+ </p>
223
+ <p>
224
+ <a href="https://huggingface.co/spaces/Heartsync/FREE-NSFW-HUB" target="_blank"><img src="https://img.shields.io/static/v1?label=huggingface&message=FREE%20NSFW%20HUB&color=%230000ff&labelColor=%23800080&logo=huggingface&logoColor=%23ffa500&style=for-the-badge" alt="badge"></a>
225
+ </p>
226
+
227
+ </header>
228
+ <div class="tabs" id="tabs"></div>
229
+ <div id="content"></div>
230
+ <script>
231
+ // Basic configuration
232
+ const cats = {{cats|tojson}};
233
+ const tabs = document.getElementById('tabs');
234
+ const content = document.getElementById('content');
235
+ let active = "";
236
+ let currentPage = 1;
237
+ // Simple utility functions
238
+ function makeRequest(url, method, data, callback) {
239
+ const xhr = new XMLHttpRequest();
240
+ xhr.open(method, url, true);
241
+ xhr.onreadystatechange = function() {
242
+ if (xhr.readyState === 4 && xhr.status === 200) {
243
+ callback(JSON.parse(xhr.responseText));
244
+ }
245
+ };
246
+ if (method === 'POST') {
247
+ xhr.send(data);
248
+ } else {
249
+ xhr.send();
250
+ }
251
+ }
252
+ function updateTabs() {
253
+ Array.from(tabs.children).forEach(b => {
254
+ b.classList.toggle('active', b.dataset.c === active);
255
+ });
256
+ }
257
+ // Tab handler for categories
258
+ function loadCategory(cat, page) {
259
+ if(cat === active && currentPage === page) return;
260
+ active = cat;
261
+ currentPage = page || 1;
262
+ updateTabs();
263
+
264
+ content.innerHTML = '<p style="text-align:center;padding:40px">Loading…</p>';
265
+
266
+ makeRequest('/api/category?name=' + encodeURIComponent(cat) + '&page=' + currentPage + '&per_page=4', 'GET', null, function(data) {
267
+ let html = '<div class="grid">';
268
+
269
+ if(data.items.length === 0) {
270
+ html += '<p style="grid-column:1/-1;text-align:center;padding:40px">No items in this category.</p>';
271
+ } else {
272
+ data.items.forEach(item => {
273
+ html += `
274
+ <div class="card">
275
+ <div class="card-label label-live">LIVE</div>
276
+ <div class="frame">
277
+ <iframe src="${item.iframe}" loading="lazy" sandbox="allow-forms allow-modals allow-popups allow-same-origin allow-scripts allow-downloads"></iframe>
278
+ </div>
279
+ <div class="foot">
280
+ <a href="${item.hf}" target="_blank">${item.title}</a>
281
+ </div>
282
+ </div>
283
+ `;
284
+ });
285
+ }
286
+
287
+ html += '</div>';
288
+
289
+ // Add pagination
290
+ html += `
291
+ <div class="pagination">
292
+ <button ${currentPage <= 1 ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage-1})">« Previous</button>
293
+ <span>Page ${currentPage} of ${data.total_pages}</span>
294
+ <button ${currentPage >= data.total_pages ? 'disabled' : ''} onclick="loadCategory('${cat}', ${currentPage+1})">Next »</button>
295
+ </div>
296
+ `;
297
+
298
+ content.innerHTML = html;
299
+ });
300
+ }
301
+ // Create category tabs
302
+ cats.forEach(c => {
303
+ const b = document.createElement('button');
304
+ b.className = 'tab';
305
+ b.textContent = c;
306
+ b.dataset.c = c;
307
+ b.onclick = function() { loadCategory(c, 1); };
308
+ tabs.appendChild(b);
309
+ });
310
+ // Start with the first category (Productivity)
311
+ loadCategory(cats[0], 1);
312
+ </script>
313
+ </body>
314
+ </html>''')
315
+
316
+ # Return the rendered template
317
+ return render_template('index.html', cats=list(CATEGORIES.keys()))
318
 
319
+ if __name__ == '__main__':
320
+ app.run(host='0.0.0.0', port=7860)