sorcgcs-server 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,71 @@
1
+ # Dependencies
2
+ node_modules/
3
+ .pnp
4
+ .pnp.js
5
+
6
+ # Testing
7
+ coverage/
8
+
9
+ # Next.js
10
+ .next/
11
+ out/
12
+
13
+ # Production
14
+ build/
15
+ dist/
16
+
17
+ # Misc
18
+ .DS_Store
19
+ *.pem
20
+
21
+ # Debug
22
+ npm-debug.log*
23
+ yarn-debug.log*
24
+ yarn-error.log*
25
+
26
+ # Local env files
27
+ .env*.local
28
+ .env
29
+
30
+ # Vercel
31
+ .vercel
32
+
33
+ # TypeScript
34
+ *.tsbuildinfo
35
+ next-env.d.ts
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.swp
41
+ *.swo
42
+
43
+ # Examples folder - not needed in deployment
44
+ Examples/
45
+
46
+ # Memory bank - project documentation only
47
+ # memory-bank/
48
+
49
+ # Debug viewport log
50
+ debug-viewport.log
51
+
52
+ # Debug logs
53
+ _debug/
54
+ _debug_history/
55
+
56
+ # Electron build output
57
+ release/
58
+ build-output/
59
+ installer-output*/
60
+
61
+ # Update token (sensitive)
62
+ update-token.txt
63
+
64
+ # CorridorKey test environment (cloned repo + model weights)
65
+ scripts/corridor-key/CorridorKey/
66
+
67
+ # Rigging debug logs
68
+ logs/
69
+
70
+ # Animation debug logs
71
+ docs/anim-debug/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SorcGCS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: sorcgcs-server
3
+ Version: 0.1.0
4
+ Summary: Local development server for SorcGCS — game preview, file management, and AI agent support
5
+ Project-URL: Homepage, https://sorceress.app
6
+ Project-URL: Repository, https://github.com/SorcGCS/sorcgcs-server
7
+ Author: SorcGCS
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: game-preview,gamedev,local-server,sorcgcs
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Games/Entertainment
17
+ Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
18
+ Requires-Python: >=3.8
19
+ Description-Content-Type: text/markdown
20
+
21
+ # sorcgcs-server
22
+
23
+ Local development server for [SorcGCS (Sorceress)](https://sorceress.app) — game preview, file management, and AI code agent support.
24
+
25
+ ## Quick Start
26
+
27
+ ```bash
28
+ # Using uv (fastest)
29
+ uvx sorcgcs-server
30
+
31
+ # Using pipx
32
+ pipx run sorcgcs-server
33
+
34
+ # Or install and run
35
+ pip install sorcgcs-server
36
+ sorcgcs-server
37
+ ```
38
+
39
+ ## Options
40
+
41
+ ```
42
+ sorcgcs-server --port 8080 # Custom port (default: 8080)
43
+ sorcgcs-server --dir ./games # Serve a specific directory
44
+ sorcgcs-server --version # Show version
45
+ ```
46
+
47
+ ## What It Does
48
+
49
+ This is a lightweight local HTTP server (Python stdlib only, no dependencies) that provides:
50
+
51
+ - **Game Preview** — Serve HTML5 games with live reload support
52
+ - **File API** — Read, write, list, search, and manage project files
53
+ - **FFmpeg Integration** — Audio/video format conversion (if ffmpeg is installed)
54
+ - **AI Agent Support** — Endpoints for the SorcGCS AI code agent
55
+ - **Checkpoint System** — Create and restore file snapshots
56
+
57
+ ## API Endpoints
58
+
59
+ | Method | Path | Description |
60
+ |--------|------|-------------|
61
+ | GET | `/api/status` | Server status and config |
62
+ | GET | `/api/games` | List available games |
63
+ | GET | `/preview/*` | Serve game preview files |
64
+ | GET | `/games/*` | Serve game files |
65
+ | POST | `/api/read-file` | Read a file |
66
+ | POST | `/api/write-file` | Write a file |
67
+ | POST | `/api/list-dir` | List directory contents |
68
+ | POST | `/api/search-files` | Search files by glob pattern |
69
+ | POST | `/api/run-command` | Execute a shell command |
70
+ | POST | `/api/create-dir` | Create a directory |
71
+ | POST | `/api/delete-file` | Delete a file |
72
+ | POST | `/api/rename-file` | Rename/move a file |
73
+ | POST | `/api/ffmpeg-process` | Process media with FFmpeg |
74
+
75
+ ## Requirements
76
+
77
+ - Python 3.8+
78
+ - No additional dependencies (uses Python standard library only)
79
+ - FFmpeg (optional, for media processing)
80
+
81
+ ## License
82
+
83
+ MIT
@@ -0,0 +1,63 @@
1
+ # sorcgcs-server
2
+
3
+ Local development server for [SorcGCS (Sorceress)](https://sorceress.app) — game preview, file management, and AI code agent support.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ # Using uv (fastest)
9
+ uvx sorcgcs-server
10
+
11
+ # Using pipx
12
+ pipx run sorcgcs-server
13
+
14
+ # Or install and run
15
+ pip install sorcgcs-server
16
+ sorcgcs-server
17
+ ```
18
+
19
+ ## Options
20
+
21
+ ```
22
+ sorcgcs-server --port 8080 # Custom port (default: 8080)
23
+ sorcgcs-server --dir ./games # Serve a specific directory
24
+ sorcgcs-server --version # Show version
25
+ ```
26
+
27
+ ## What It Does
28
+
29
+ This is a lightweight local HTTP server (Python stdlib only, no dependencies) that provides:
30
+
31
+ - **Game Preview** — Serve HTML5 games with live reload support
32
+ - **File API** — Read, write, list, search, and manage project files
33
+ - **FFmpeg Integration** — Audio/video format conversion (if ffmpeg is installed)
34
+ - **AI Agent Support** — Endpoints for the SorcGCS AI code agent
35
+ - **Checkpoint System** — Create and restore file snapshots
36
+
37
+ ## API Endpoints
38
+
39
+ | Method | Path | Description |
40
+ |--------|------|-------------|
41
+ | GET | `/api/status` | Server status and config |
42
+ | GET | `/api/games` | List available games |
43
+ | GET | `/preview/*` | Serve game preview files |
44
+ | GET | `/games/*` | Serve game files |
45
+ | POST | `/api/read-file` | Read a file |
46
+ | POST | `/api/write-file` | Write a file |
47
+ | POST | `/api/list-dir` | List directory contents |
48
+ | POST | `/api/search-files` | Search files by glob pattern |
49
+ | POST | `/api/run-command` | Execute a shell command |
50
+ | POST | `/api/create-dir` | Create a directory |
51
+ | POST | `/api/delete-file` | Delete a file |
52
+ | POST | `/api/rename-file` | Rename/move a file |
53
+ | POST | `/api/ffmpeg-process` | Process media with FFmpeg |
54
+
55
+ ## Requirements
56
+
57
+ - Python 3.8+
58
+ - No additional dependencies (uses Python standard library only)
59
+ - FFmpeg (optional, for media processing)
60
+
61
+ ## License
62
+
63
+ MIT
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sorcgcs-server"
7
+ version = "0.1.0"
8
+ description = "Local development server for SorcGCS — game preview, file management, and AI agent support"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.8"
12
+ authors = [
13
+ { name = "SorcGCS" },
14
+ ]
15
+ keywords = ["gamedev", "local-server", "game-preview", "sorcgcs"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Environment :: Console",
19
+ "Intended Audience :: Developers",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Games/Entertainment",
23
+ "Topic :: Internet :: WWW/HTTP :: HTTP Servers",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://sorceress.app"
28
+ Repository = "https://github.com/SorcGCS/sorcgcs-server"
29
+
30
+ [project.scripts]
31
+ sorcgcs-server = "sorcgcs_server.cli:main"
@@ -0,0 +1,3 @@
1
+ """SorcGCS Local Server — game preview, file management, and AI agent support."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,44 @@
1
+ """CLI entry point for sorcgcs-server."""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from . import __version__
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(
11
+ prog="sorcgcs-server",
12
+ description="SorcGCS Local Server — game preview, file management, and AI agent support",
13
+ )
14
+ parser.add_argument(
15
+ "--port", type=int, default=8080, help="Port to listen on (default: 8080)"
16
+ )
17
+ parser.add_argument(
18
+ "--host",
19
+ type=str,
20
+ default="0.0.0.0",
21
+ help="Host to bind to (default: 0.0.0.0)",
22
+ )
23
+ parser.add_argument(
24
+ "--dir",
25
+ type=str,
26
+ default=None,
27
+ help="Games/project directory to serve (default: current directory)",
28
+ )
29
+ parser.add_argument(
30
+ "--version", action="version", version=f"sorcgcs-server {__version__}"
31
+ )
32
+ args = parser.parse_args()
33
+
34
+ from .server import run_server
35
+
36
+ try:
37
+ run_server(port=args.port, host=args.host, games_dir=args.dir)
38
+ except KeyboardInterrupt:
39
+ print("\n Server stopped.")
40
+ sys.exit(0)
41
+
42
+
43
+ if __name__ == "__main__":
44
+ main()
@@ -0,0 +1,583 @@
1
+ """
2
+ SorcGCS Local Server
3
+
4
+ Provides:
5
+ - Static file serving for game preview (with HTML error injection)
6
+ - REST API for file read/write/list/search/delete/rename
7
+ - Game directory browsing and management
8
+ - Script proxy for cross-origin JS loading
9
+ - FFmpeg media processing (if ffmpeg is in PATH)
10
+ - Checkpoint create/restore/list
11
+ - Command execution for AI agent integration
12
+
13
+ Source: https://github.com/SorcGCS/sorcgcs-server
14
+ """
15
+
16
+ import http.server
17
+ import socketserver
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import glob
22
+ import mimetypes
23
+ import re
24
+ import base64
25
+ import threading
26
+ import uuid
27
+ import time
28
+ from urllib.parse import quote, unquote, parse_qs, urlparse
29
+ import urllib.request
30
+
31
+ CFG = {"games": os.getcwd(), "preview": None}
32
+ FFMPEG_JOBS = {}
33
+
34
+ ERROR_CAPTURE_SCRIPT = r'''<script>(function(){var cs=function(s){return s?s.replace(/https?:\/\/[^/]+\/api\/script-proxy\?url=[^:]+%2F([^:%]+\.js)(:\d+)?(:\d+)?/g,"$1$2$3").replace(/https?:\/\/[^/]+\/api\/script-proxy\?url=[^\s]+%2F([^%\s]+)/g,"$1"):s};var cf=function(s){if(!s)return"";if(s.indexOf("/api/script-proxy")>-1){var m=s.match(/%2F([^%]+\.js)/);if(m)return m[1]}return s.split("/").pop().split("?")[0]};var _p=function(d){try{window.parent.postMessage(d,"*")}catch(e){}};var lastE=null;window.addEventListener("error",function(e){lastE=e;var msg=e.error&&e.error.stack?cs(e.error.stack):e.error&&e.error.message?e.error.message:e.message||"Script error";var src=e.filename?cf(e.filename):"";_p({type:"error",level:"error",message:msg,source:src,line:e.lineno||0})},true);window.onerror=function(m,s,l,c,e){if(lastE&&lastE.message===m){lastE=null;return false}var msg=e&&e.stack?cs(e.stack):m;_p({type:"error",level:"error",message:msg,source:cf(s),line:l||0});return false};window.onunhandledrejection=function(e){var msg="Promise: "+(e.reason&&e.reason.stack?cs(e.reason.stack):e.reason&&e.reason.message?e.reason.message:String(e.reason||"Unknown"));_p({type:"error",level:"error",message:msg})};["error","warn","log"].forEach(function(lvl){var o=console[lvl];console[lvl]=function(){var a=[];for(var i=0;i<arguments.length;i++){var x=arguments[i];a.push(x instanceof Error?cs(x.stack||x.message):x&&x.stack?cs(x.stack):typeof x==="object"?JSON.stringify(x):String(x))}_p({type:"console",level:lvl,message:a.join(" ")});o.apply(console,arguments)}})})();</script>'''
35
+
36
+
37
+ class Handler(http.server.SimpleHTTPRequestHandler):
38
+ def __init__(self, *args, **kwargs):
39
+ super().__init__(*args, directory=CFG["games"], **kwargs)
40
+
41
+ def end_headers(self):
42
+ self.send_header("Access-Control-Allow-Origin", "*")
43
+ self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
44
+ self.send_header("Access-Control-Allow-Headers", "*")
45
+ super().end_headers()
46
+
47
+ def do_OPTIONS(self):
48
+ self.send_response(200)
49
+ self.end_headers()
50
+
51
+ def jres(self, d, c=200):
52
+ self.send_response(c)
53
+ self.send_header("Content-Type", "application/json")
54
+ self.end_headers()
55
+ self.wfile.write(json.dumps(d).encode())
56
+
57
+ def rbody(self):
58
+ length = int(self.headers.get("Content-Length", 0))
59
+ return json.loads(self.rfile.read(length)) if length > 0 else {}
60
+
61
+ def rpath(self, p, pp):
62
+ return p if os.path.isabs(p) else os.path.normpath(os.path.join(pp or CFG["games"], p))
63
+
64
+ # ── FFmpeg processing ────────────────────────────────────────────
65
+
66
+ def handle_ffmpeg_process(self):
67
+ import tempfile
68
+ import shutil
69
+
70
+ ffmpeg_path = shutil.which("ffmpeg")
71
+ if not ffmpeg_path:
72
+ self.jres({"error": "FFmpeg not found in PATH"}, 500)
73
+ return
74
+
75
+ content_type = self.headers.get("Content-Type", "")
76
+ if "multipart/form-data" not in content_type:
77
+ self.jres({"error": "Expected multipart/form-data"}, 400)
78
+ return
79
+
80
+ boundary = None
81
+ for part in content_type.split(";"):
82
+ part = part.strip()
83
+ if part.startswith("boundary="):
84
+ boundary = part[9:].strip('"')
85
+ break
86
+
87
+ if not boundary:
88
+ self.jres({"error": "No boundary found"}, 400)
89
+ return
90
+
91
+ length = int(self.headers.get("Content-Length", 0))
92
+ body = self.rfile.read(length)
93
+
94
+ parts = body.split(("--" + boundary).encode())
95
+ file_data = None
96
+ file_name = None
97
+ options = {}
98
+ CRLF = bytes([13, 10])
99
+ CRLF2 = bytes([13, 10, 13, 10])
100
+
101
+ for part in parts:
102
+ if b"Content-Disposition" not in part:
103
+ continue
104
+ header_end = part.find(CRLF2)
105
+ if header_end == -1:
106
+ continue
107
+ header_section = part[:header_end].decode("utf-8", errors="replace")
108
+ content = part[header_end + 4:]
109
+ if content.endswith(b"--" + CRLF):
110
+ content = content[:-6]
111
+ elif content.endswith(CRLF):
112
+ content = content[:-2]
113
+ if 'name="file"' in header_section:
114
+ file_data = content
115
+ crlf_str = chr(13) + chr(10)
116
+ for line in header_section.split(crlf_str):
117
+ if "filename=" in line:
118
+ start = line.find('filename="') + 10
119
+ end = line.find('"', start)
120
+ file_name = line[start:end]
121
+ elif 'name="options"' in header_section:
122
+ try:
123
+ options = json.loads(content.decode("utf-8"))
124
+ except Exception:
125
+ pass
126
+
127
+ if not file_data or not file_name:
128
+ self.jres({"error": "No file uploaded"}, 400)
129
+ return
130
+
131
+ with tempfile.NamedTemporaryFile(delete=False, suffix=os.path.splitext(file_name)[1]) as tmp_in:
132
+ tmp_in.write(file_data)
133
+ input_path = tmp_in.name
134
+
135
+ job_id = str(uuid.uuid4())
136
+ FFMPEG_JOBS[job_id] = {"status": "processing", "file_name": file_name}
137
+ print(f" [FFmpeg] Job {job_id[:8]} queued for {file_name}")
138
+
139
+ def run_ffmpeg():
140
+ try:
141
+ opt_type = options.get("type", "")
142
+ if opt_type == "format-convert":
143
+ out_ext = "." + options.get("outputFormat", "mp4")
144
+ elif opt_type in ("video-resize", "webm-to-mp4"):
145
+ out_ext = ".mp4"
146
+ elif opt_type == "audio-compress":
147
+ out_ext = ".mp3"
148
+ else:
149
+ out_ext = os.path.splitext(file_name)[1]
150
+
151
+ with tempfile.NamedTemporaryFile(delete=False, suffix=out_ext) as tmp_out:
152
+ output_path = tmp_out.name
153
+
154
+ cmd = [ffmpeg_path, "-y", "-i", input_path]
155
+ if opt_type == "audio-compress":
156
+ cmd += ["-b:a", options.get("bitrate", "128k"), "-ac", str(options.get("channels", 2)), output_path]
157
+ elif opt_type == "video-resize":
158
+ cmd += ["-vf", f"scale=-2:{options.get('height', 720)}", "-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", output_path]
159
+ elif opt_type == "webm-to-mp4":
160
+ crf = str(options.get("crf", 18))
161
+ fps = str(options.get("fps", 30))
162
+ cmd += ["-r", fps, "-c:v", "libx264", "-crf", crf, "-pix_fmt", "yuv420p", "-an", output_path]
163
+ elif opt_type == "volume-adjust":
164
+ cmd += ["-af", f"volume={options.get('volume', 1.0)}", output_path]
165
+ elif opt_type == "format-convert":
166
+ fmt = options.get("outputFormat", "mp4")
167
+ FORMAT_ARGS = {
168
+ "mp4": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-preset", "medium", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"],
169
+ "webm": ["-c:v", "libvpx-vp9", "-crf", "30", "-b:v", "0", "-c:a", "libopus", "-b:a", "128k"],
170
+ "mov": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
171
+ "mkv": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
172
+ "avi": ["-c:v", "libx264", "-crf", str(options.get("crf", 23)), "-c:a", "aac", "-b:a", "192k"],
173
+ "gif": ["-vf", "fps=15,scale=480:-1:flags=lanczos,split[s0][s1];[s0]palettegen[p];[s1][p]paletteuse", "-loop", "0"],
174
+ "mp3": ["-vn", "-c:a", "libmp3lame", "-q:a", "2"],
175
+ "wav": ["-vn", "-c:a", "pcm_s16le"],
176
+ "ogg": ["-vn", "-c:a", "libvorbis", "-q:a", "4"],
177
+ "flac": ["-vn", "-c:a", "flac"],
178
+ "aac": ["-vn", "-c:a", "aac", "-b:a", "192k"],
179
+ "m4a": ["-vn", "-c:a", "aac", "-b:a", "192k", "-movflags", "+faststart"],
180
+ }
181
+ cmd += FORMAT_ARGS.get(fmt, []) + [output_path]
182
+ else:
183
+ FFMPEG_JOBS[job_id] = {"status": "error", "error": f"Unknown type: {opt_type}"}
184
+ os.unlink(input_path)
185
+ return
186
+
187
+ print(f" [FFmpeg] Job {job_id[:8]} running: {' '.join(cmd)}")
188
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
189
+
190
+ if result.returncode != 0:
191
+ print(f" [FFmpeg] Job {job_id[:8]} error: {result.stderr[:200]}")
192
+ FFMPEG_JOBS[job_id] = {"status": "error", "error": result.stderr[:200]}
193
+ os.unlink(input_path)
194
+ if os.path.exists(output_path):
195
+ os.unlink(output_path)
196
+ return
197
+
198
+ output_size = os.path.getsize(output_path)
199
+ ext = os.path.splitext(file_name)[1].lower()
200
+ ctype_map = {
201
+ ".mp3": "audio/mpeg", ".wav": "audio/wav", ".ogg": "audio/ogg",
202
+ ".flac": "audio/flac", ".m4a": "audio/mp4", ".aac": "audio/aac",
203
+ ".mp4": "video/mp4", ".m4v": "video/mp4", ".webm": "video/webm",
204
+ ".mov": "video/quicktime", ".mkv": "video/x-matroska",
205
+ ".avi": "video/x-msvideo", ".gif": "image/gif",
206
+ }
207
+ if opt_type == "format-convert":
208
+ ctype = ctype_map.get("." + options.get("outputFormat", "mp4"), "application/octet-stream")
209
+ elif opt_type in ("video-resize", "webm-to-mp4"):
210
+ ctype = "video/mp4"
211
+ elif opt_type == "audio-compress":
212
+ ctype = "audio/mpeg"
213
+ else:
214
+ ctype = ctype_map.get(ext, "application/octet-stream")
215
+
216
+ FFMPEG_JOBS[job_id] = {
217
+ "status": "done", "output_path": output_path,
218
+ "output_size": output_size, "content_type": ctype,
219
+ "file_name": file_name,
220
+ }
221
+ print(f" [FFmpeg] Job {job_id[:8]} done! {output_size} bytes")
222
+ os.unlink(input_path)
223
+
224
+ def cleanup():
225
+ time.sleep(600)
226
+ job = FFMPEG_JOBS.pop(job_id, None)
227
+ if job and job.get("output_path") and os.path.exists(job["output_path"]):
228
+ os.unlink(job["output_path"])
229
+
230
+ threading.Thread(target=cleanup, daemon=True).start()
231
+
232
+ except Exception as e:
233
+ print(f" [FFmpeg] Job {job_id[:8]} exception: {e}")
234
+ FFMPEG_JOBS[job_id] = {"status": "error", "error": str(e)}
235
+ if os.path.exists(input_path):
236
+ os.unlink(input_path)
237
+
238
+ threading.Thread(target=run_ffmpeg, daemon=True).start()
239
+ self.jres({"jobId": job_id, "status": "processing"})
240
+
241
+ def handle_ffmpeg_status(self, job_id):
242
+ job = FFMPEG_JOBS.get(job_id)
243
+ if not job:
244
+ self.jres({"error": "Job not found"}, 404)
245
+ return
246
+ self.jres({
247
+ "jobId": job_id, "status": job["status"],
248
+ "error": job.get("error"), "outputSize": job.get("output_size"),
249
+ "fileName": job.get("file_name"),
250
+ })
251
+
252
+ def handle_ffmpeg_download(self, job_id):
253
+ job = FFMPEG_JOBS.get(job_id)
254
+ if not job:
255
+ self.jres({"error": "Job not found"}, 404)
256
+ return
257
+ if job["status"] != "done":
258
+ self.jres({"error": "Not ready", "status": job["status"]}, 400)
259
+ return
260
+ output_path = job.get("output_path", "")
261
+ if not output_path or not os.path.exists(output_path):
262
+ self.jres({"error": "Output file missing"}, 404)
263
+ return
264
+ try:
265
+ with open(output_path, "rb") as f:
266
+ data = f.read()
267
+ self.send_response(200)
268
+ self.send_header("Content-Type", job.get("content_type", "application/octet-stream"))
269
+ self.send_header("Content-Length", str(len(data)))
270
+ self.end_headers()
271
+ self.wfile.write(data)
272
+ print(f" [FFmpeg] Job {job_id[:8]} downloaded ({len(data)} bytes)")
273
+ FFMPEG_JOBS.pop(job_id, None)
274
+ os.unlink(output_path)
275
+ except Exception as e:
276
+ print(f" [FFmpeg] Download error: {e}")
277
+
278
+ # ── POST endpoints ───────────────────────────────────────────────
279
+
280
+ def do_POST(self):
281
+ try:
282
+ if self.path == "/api/ffmpeg-process":
283
+ self.handle_ffmpeg_process()
284
+ return
285
+
286
+ b = self.rbody()
287
+ pp = b.get("projectPath", CFG["games"])
288
+
289
+ if self.path == "/api/read-file":
290
+ fp = self.rpath(b.get("path", ""), pp)
291
+ if os.path.isfile(fp):
292
+ if b.get("binary"):
293
+ self.jres({"content": base64.b64encode(open(fp, "rb").read()).decode("ascii"), "binary": True})
294
+ else:
295
+ self.jres({"content": open(fp, "r", encoding="utf-8", errors="replace").read()})
296
+ else:
297
+ self.jres({"error": "File not found"}, 404)
298
+
299
+ elif self.path == "/api/write-file":
300
+ fp = self.rpath(b.get("path", ""), pp)
301
+ pd = os.path.dirname(fp)
302
+ if pd:
303
+ os.makedirs(pd, exist_ok=True)
304
+ open(fp, "w", encoding="utf-8").write(b.get("content", ""))
305
+ self.jres({"success": True})
306
+
307
+ elif self.path == "/api/list-dir":
308
+ dp = self.rpath(b.get("path", "."), pp)
309
+ if os.path.isdir(dp):
310
+ entries = []
311
+ for n in os.listdir(dp):
312
+ full_path = os.path.join(dp, n)
313
+ entries.append({
314
+ "name": n,
315
+ "path": os.path.relpath(full_path, pp).replace("\\", "/"),
316
+ "isDirectory": os.path.isdir(full_path),
317
+ })
318
+ self.jres({"entries": entries})
319
+ else:
320
+ self.jres({"error": "Not found"}, 404)
321
+
322
+ elif self.path == "/api/search-files":
323
+ pattern = b.get("pattern", "**/*")
324
+ files = [
325
+ os.path.relpath(f, pp).replace("\\", "/")
326
+ for f in glob.glob(os.path.join(pp, pattern), recursive=True)
327
+ if os.path.isfile(f)
328
+ ][:500]
329
+ self.jres({"files": files})
330
+
331
+ elif self.path == "/api/run-command":
332
+ r = subprocess.run(
333
+ b.get("command", ""), shell=True,
334
+ cwd=b.get("cwd", pp),
335
+ capture_output=True, text=True, timeout=60,
336
+ )
337
+ self.jres({"exitCode": r.returncode, "stdout": r.stdout, "stderr": r.stderr})
338
+
339
+ elif self.path == "/api/create-dir":
340
+ os.makedirs(self.rpath(b.get("path", ""), pp), exist_ok=True)
341
+ self.jres({"success": True})
342
+
343
+ elif self.path == "/api/delete-file":
344
+ fp = self.rpath(b.get("path", ""), pp)
345
+ if os.path.isfile(fp):
346
+ os.remove(fp)
347
+ self.jres({"success": True})
348
+ else:
349
+ self.jres({"error": "Not found"}, 404)
350
+
351
+ elif self.path == "/api/rename-file":
352
+ op = self.rpath(b.get("oldPath", ""), pp)
353
+ np_path = self.rpath(b.get("newPath", ""), pp)
354
+ if os.path.exists(op):
355
+ os.rename(op, np_path)
356
+ self.jres({"success": True, "newPath": np_path})
357
+ else:
358
+ self.jres({"error": "Not found"}, 404)
359
+
360
+ elif self.path == "/api/write-file-binary":
361
+ fp = self.rpath(b.get("path", ""), pp)
362
+ pd = os.path.dirname(fp)
363
+ if pd:
364
+ os.makedirs(pd, exist_ok=True)
365
+ with open(fp, "wb") as f:
366
+ f.write(base64.b64decode(b.get("content", "")))
367
+ self.jres({"success": True})
368
+
369
+ elif self.path == "/api/set-preview-path":
370
+ CFG["preview"] = b.get("path", CFG["games"])
371
+ self.jres({"success": True, "previewPath": CFG["preview"]})
372
+
373
+ elif self.path == "/api/set-games-dir":
374
+ CFG["games"] = b.get("path", CFG["games"])
375
+ self.jres({"success": True, "gamesDir": CFG["games"]})
376
+
377
+ elif self.path == "/api/checkpoint/create":
378
+ cid = b.get("checkpointId", "")
379
+ cp = b.get("checkpoint", {})
380
+ cpdir = os.path.join(pp, ".sorceress", "checkpoints", cid)
381
+ os.makedirs(cpdir, exist_ok=True)
382
+ with open(os.path.join(cpdir, "checkpoint.json"), "w", encoding="utf-8") as f:
383
+ json.dump(cp, f)
384
+ self.jres({"success": True, "checkpointId": cid})
385
+
386
+ elif self.path == "/api/checkpoint/restore-file":
387
+ f_data = b.get("file", {})
388
+ fp = self.rpath(f_data.get("path", ""), pp)
389
+ if f_data.get("action") == "created":
390
+ if os.path.isfile(fp):
391
+ os.remove(fp)
392
+ elif f_data.get("action") in ["modified", "deleted"]:
393
+ if f_data.get("previousContent") is not None:
394
+ pd = os.path.dirname(fp)
395
+ if pd:
396
+ os.makedirs(pd, exist_ok=True)
397
+ with open(fp, "w", encoding="utf-8") as wf:
398
+ wf.write(f_data.get("previousContent", ""))
399
+ self.jres({"success": True})
400
+
401
+ elif self.path == "/api/checkpoint/list":
402
+ cpbase = os.path.join(pp, ".sorceress", "checkpoints")
403
+ cps = []
404
+ if os.path.exists(cpbase):
405
+ for d in os.listdir(cpbase):
406
+ cpf = os.path.join(cpbase, d, "checkpoint.json")
407
+ if os.path.exists(cpf):
408
+ with open(cpf, "r", encoding="utf-8") as rf:
409
+ cps.append(json.load(rf))
410
+ cps.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
411
+ self.jres({"checkpoints": cps[:50]})
412
+
413
+ else:
414
+ self.jres({"error": "Unknown endpoint"}, 404)
415
+
416
+ except Exception as e:
417
+ self.jres({"error": str(e)}, 500)
418
+
419
+ # ── File serving ─────────────────────────────────────────────────
420
+
421
+ def serve_file(self, fp):
422
+ if os.path.isdir(fp):
423
+ fp = os.path.join(fp, "index.html")
424
+ if not os.path.isfile(fp):
425
+ self.send_error(404)
426
+ return
427
+ ct = mimetypes.guess_type(fp)[0] or "application/octet-stream"
428
+ self.send_response(200)
429
+ self.send_header("Content-Type", ct)
430
+ self.end_headers()
431
+ with open(fp, "rb") as f:
432
+ content = f.read()
433
+ if ct == "text/html":
434
+ html = content.decode("utf-8", errors="replace")
435
+ if "<head>" in html:
436
+ html = html.replace("<head>", "<head>" + ERROR_CAPTURE_SCRIPT)
437
+ elif "<html" in html:
438
+ idx = html.index("<html")
439
+ end_idx = idx + html[idx:].index(">") + 1
440
+ html = html[:end_idx] + ERROR_CAPTURE_SCRIPT + html[end_idx:]
441
+ else:
442
+ html = ERROR_CAPTURE_SCRIPT + html
443
+ content = html.encode("utf-8")
444
+ self.wfile.write(content)
445
+
446
+ # ── GET endpoints ────────────────────────────────────────────────
447
+
448
+ def do_GET(self):
449
+ import shutil as _shutil
450
+
451
+ if self.path == "/api/status":
452
+ ffmpeg_path = _shutil.which("ffmpeg")
453
+ self.jres({
454
+ "connected": True,
455
+ "gamesDir": CFG["games"],
456
+ "previewPath": CFG["preview"],
457
+ "port": CFG.get("port", 8080),
458
+ "agent": True,
459
+ "ffmpeg": ffmpeg_path is not None,
460
+ })
461
+
462
+ elif self.path.startswith("/api/ffmpeg-status/"):
463
+ job_id = self.path.split("/api/ffmpeg-status/")[1].split("?")[0]
464
+ self.handle_ffmpeg_status(job_id)
465
+
466
+ elif self.path.startswith("/api/ffmpeg-download/"):
467
+ job_id = self.path.split("/api/ffmpeg-download/")[1].split("?")[0]
468
+ self.handle_ffmpeg_download(job_id)
469
+
470
+ elif self.path == "/api/browse-folder":
471
+ try:
472
+ import tkinter as tk
473
+ from tkinter import filedialog
474
+ root = tk.Tk()
475
+ root.withdraw()
476
+ root.attributes("-topmost", True)
477
+ folder = filedialog.askdirectory(title="Select Project Folder", initialdir=CFG["games"])
478
+ root.destroy()
479
+ if folder:
480
+ self.jres({"success": True, "path": folder.replace("/", os.sep)})
481
+ else:
482
+ self.jres({"success": False, "cancelled": True})
483
+ except Exception as e:
484
+ self.jres({"success": False, "error": str(e)})
485
+
486
+ elif self.path.startswith("/api/script-proxy"):
487
+ try:
488
+ qs = parse_qs(urlparse(self.path).query)
489
+ url = unquote(qs.get("url", [""])[0])
490
+ if not url or not url.startswith(("http://", "https://")):
491
+ self.send_error(400, "Invalid URL")
492
+ return
493
+ req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
494
+ with urllib.request.urlopen(req, timeout=15) as resp:
495
+ script_content = resp.read()
496
+ self.send_response(200)
497
+ self.send_header("Content-Type", "application/javascript")
498
+ self.send_header("Cache-Control", "public, max-age=300")
499
+ self.end_headers()
500
+ self.wfile.write(script_content)
501
+ except Exception as e:
502
+ self.send_error(502, "Failed to fetch script: " + str(e))
503
+
504
+ elif self.path == "/api/games":
505
+ games = []
506
+ if os.path.exists(CFG["games"]):
507
+ for n in os.listdir(CFG["games"]):
508
+ game_path = os.path.join(CFG["games"], n)
509
+ if os.path.isdir(game_path) and os.path.exists(os.path.join(game_path, "index.html")):
510
+ games.append({"name": n, "path": n})
511
+ self.jres({"games": games})
512
+
513
+ elif self.path.startswith("/api/checkpoint/get"):
514
+ qs = parse_qs(urlparse(self.path).query)
515
+ cid = qs.get("id", [""])[0]
516
+ cppath = qs.get("projectPath", [CFG["games"]])[0]
517
+ cpf = os.path.join(cppath, ".sorceress", "checkpoints", cid, "checkpoint.json")
518
+ if os.path.exists(cpf):
519
+ with open(cpf, "r", encoding="utf-8") as rf:
520
+ self.jres(json.load(rf))
521
+ else:
522
+ self.jres({"error": "Not found"}, 404)
523
+
524
+ elif self.path.startswith("/preview"):
525
+ rp = self.path[8:].lstrip("/").split("?")[0] if len(self.path) > 8 else ""
526
+ rp = rp.replace("/", os.sep)
527
+ fp = os.path.normpath(os.path.join(CFG["preview"], rp)) if rp else CFG["preview"]
528
+ self.serve_file(fp)
529
+
530
+ elif self.path == "/games" or self.path == "/games/":
531
+ self.send_response(200)
532
+ self.send_header("Content-Type", "text/html")
533
+ self.end_headers()
534
+ html = '<html><body style="background:#111;color:#fff;font-family:sans-serif;"><h1>Games</h1><ul>'
535
+ if os.path.exists(CFG["games"]):
536
+ for name in sorted(os.listdir(CFG["games"])):
537
+ game_path = os.path.join(CFG["games"], name)
538
+ if os.path.isdir(game_path) and os.path.exists(os.path.join(game_path, "index.html")):
539
+ html += f'<li><a href="/games/{name}/" style="color:#a855f7">{name}</a></li>'
540
+ html += "</ul></body></html>"
541
+ self.wfile.write(html.encode())
542
+
543
+ elif self.path.startswith("/games/"):
544
+ path = self.path[7:].split("?")[0]
545
+ path = path.replace("/", os.sep)
546
+ file_path = os.path.normpath(os.path.join(CFG["games"], path))
547
+ if os.path.isdir(file_path):
548
+ index_path = os.path.join(file_path, "index.html")
549
+ if os.path.exists(index_path):
550
+ file_path = index_path
551
+ self.serve_file(file_path)
552
+
553
+ else:
554
+ super().do_GET()
555
+
556
+ def log_message(self, format, *args):
557
+ pass # Quiet by default; startup banner is enough
558
+
559
+
560
+ class ThreadedServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
561
+ allow_reuse_address = True
562
+ daemon_threads = True
563
+
564
+
565
+ def run_server(port=8080, host="0.0.0.0", games_dir=None):
566
+ if games_dir:
567
+ CFG["games"] = os.path.abspath(games_dir)
568
+ CFG["preview"] = CFG["games"]
569
+ CFG["port"] = port
570
+
571
+ print("")
572
+ print(" SORCGCS LOCAL SERVER")
573
+ print(" ====================")
574
+ print(f" Games: {CFG['games']}")
575
+ print(f" URL: http://localhost:{port}")
576
+ print("")
577
+ print(" Tip: Games folder can be changed from Settings or the Games page.")
578
+ print("")
579
+ print(" Press Ctrl+C to stop")
580
+ print("")
581
+
582
+ with ThreadedServer((host, port), Handler) as httpd:
583
+ httpd.serve_forever()