jacky3102 commited on
Commit
4f10050
·
verified ·
1 Parent(s): 48c9c18

Deploy Blender Headless API

Browse files
Files changed (1) hide show
  1. app.py +119 -234
app.py CHANGED
@@ -1,239 +1,124 @@
 
 
1
  import os
2
- import re
3
  import subprocess
4
- import tempfile
5
  from pathlib import Path
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
- import gradio as gr
8
- import torch
9
- import soundfile as sf
10
- import torchaudio
11
- from transformers import AutoProcessor, AutoModelForSpeechSeq2Seq
12
- from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
13
- from transformers import VitsModel
14
-
15
- ASR_MODEL = os.getenv("ASR_MODEL", "openai/whisper-large-v3-turbo")
16
- TRANSLATION_MODEL = os.getenv("TRANSLATION_MODEL", "Helsinki-NLP/opus-mt-zh-ms")
17
- TTS_MODEL = os.getenv("TTS_MODEL", "facebook/mms-tts-zlm")
18
-
19
- torch.set_num_threads(2)
20
- DEVICE = "cpu"
21
-
22
- _asr_processor = _asr_model = None
23
- _tr_tokenizer = _tr_model = None
24
- _tts_tokenizer = _tts_model = None
25
-
26
- def run(cmd):
27
- p = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
28
- if p.returncode:
29
- raise RuntimeError(p.stderr[-5000:])
30
- return p.stdout
31
-
32
- def video_duration(path):
33
- return float(run(["ffprobe","-v","error","-show_entries","format=duration",
34
- "-of","default=noprint_wrappers=1:nokey=1",str(path)]).strip())
35
-
36
- def extract_audio(video, wav):
37
- run(["ffmpeg","-y","-i",str(video),"-vn","-ac","1","-ar","16000",str(wav)])
38
-
39
- def load_asr():
40
- global _asr_processor, _asr_model
41
- if _asr_model is None:
42
- _asr_processor = AutoProcessor.from_pretrained(ASR_MODEL)
43
- _asr_model = AutoModelForSpeechSeq2Seq.from_pretrained(
44
- ASR_MODEL, low_cpu_mem_usage=True
45
- ).to(DEVICE).eval()
46
- return _asr_processor, _asr_model
47
-
48
- def transcribe(wav):
49
- processor, model = load_asr()
50
- audio, sr = sf.read(str(wav))
51
- if len(audio.shape) > 1:
52
- audio = audio.mean(axis=1)
53
- if sr != 16000:
54
- audio = torchaudio.functional.resample(
55
- torch.from_numpy(audio).float(), sr, 16000
56
- ).numpy()
57
-
58
- total = len(audio) / 16000
59
- pieces = []
60
- chunk = 30
61
-
62
- for start in range(0, int(total + 0.999), chunk):
63
- x = audio[start*16000:min(int(total*16000),(start+chunk)*16000)]
64
- if len(x) < 400:
65
- continue
66
- inputs = processor(x, sampling_rate=16000, return_tensors="pt")
67
- with torch.inference_mode():
68
- ids = model.generate(
69
- inputs.input_features,
70
- language="zh",
71
- task="transcribe",
72
- max_new_tokens=256,
73
- prompt="无人机 架 大疆 北约 大毛 小前线 杨栋梁"
74
- )
75
- text = processor.batch_decode(ids, skip_special_tokens=True)[0].strip()
76
- if text:
77
- pieces.append(text)
78
- return " ".join(pieces)
79
-
80
- def load_translation():
81
- global _tr_tokenizer, _tr_model
82
- if _tr_model is None:
83
- _tr_tokenizer = AutoTokenizer.from_pretrained(TRANSLATION_MODEL)
84
- _tr_model = AutoModelForSeq2SeqLM.from_pretrained(
85
- TRANSLATION_MODEL, low_cpu_mem_usage=True
86
- ).to(DEVICE).eval()
87
- return _tr_tokenizer, _tr_model
88
-
89
- def translate(text):
90
- tokenizer, model = load_translation()
91
- parts = [x.strip() for x in re.split(r'(?<=[。!��!?])\s*', text) if x.strip()]
92
- if not parts:
93
- parts = [text]
94
-
95
- result = []
96
- for part in parts:
97
- inputs = tokenizer(part, return_tensors="pt", truncation=True, max_length=256)
98
- with torch.inference_mode():
99
- ids = model.generate(**inputs, max_new_tokens=256, num_beams=4,
100
- renormalize_logits=True)
101
- result.append(tokenizer.decode(ids[0], skip_special_tokens=True).strip())
102
- return " ".join(result)
103
-
104
- def load_tts():
105
- global _tts_tokenizer, _tts_model
106
- if _tts_model is None:
107
- _tts_tokenizer = AutoTokenizer.from_pretrained(TTS_MODEL)
108
- _tts_model = VitsModel.from_pretrained(
109
- TTS_MODEL, low_cpu_mem_usage=True
110
- ).to(DEVICE).eval()
111
- return _tts_tokenizer, _tts_model
112
-
113
- def tts(text, wav):
114
- tokenizer, model = load_tts()
115
- inputs = tokenizer(text, return_tensors="pt", padding=True)
116
- with torch.inference_mode():
117
- audio = model(**inputs).waveform.cpu()
118
- sf.write(str(wav), audio.squeeze().numpy(), model.config.sampling_rate)
119
-
120
- def fit_audio(src, dst, target):
121
- target = max(float(target), 0.1)
122
- ratio = video_duration(src) / target
123
- filters = []
124
- while ratio > 2:
125
- filters.append("atempo=2.0")
126
- ratio /= 2
127
- while ratio < 0.5:
128
- filters.append("atempo=0.5")
129
- ratio /= 0.5
130
- filters.append(f"atempo={ratio:.6f}")
131
- run(["ffmpeg","-y","-i",str(src),"-filter:a",",".join(filters),
132
- "-t",f"{target:.3f}","-ar","24000","-ac","1",str(dst)])
133
-
134
- def make_dub(ms, total, work):
135
- parts = [x.strip() for x in re.split(r'(?<=[。!?!?])\s*|(?<=[,,])\s+', ms) if x.strip()]
136
- if not parts:
137
- parts = [ms]
138
- weights = [max(1,len(re.sub(r'\s+','',x))) for x in parts]
139
- total_w = sum(weights)
140
-
141
- # Create silent base
142
- silent = work/"silent.wav"
143
- run(["ffmpeg","-y","-f","lavfi","-i","anullsrc=r=24000:cl=mono",
144
- "-t",f"{total:.3f}","-ar","24000","-ac","1",str(silent)])
145
-
146
- # Generate and fit each TTS segment
147
- inputs = ["-i", str(silent)]
148
- filter_parts = []
149
- start = 0.0
150
-
151
- for i, part in enumerate(parts, 1):
152
- slot = total * weights[i-1] / total_w
153
- raw, fitted = work/f"tts_{i}.wav", work/f"fit_{i}.wav"
154
- tts(part, raw)
155
- fit_audio(raw, fitted, slot)
156
- inputs += ["-i", str(fitted)]
157
- delay = int(start*1000)
158
- filter_parts.append(f"[{i}:a]adelay={delay}|{delay}[d{i}]")
159
- start += slot
160
-
161
- # Build filter_complex: delay each TTS segment, then mix with silent base
162
- # Input 0 = silent, Input 1..n = TTS segments
163
- mix_inputs = "[0:a]" + "".join(f"[d{i}]" for i in range(1, len(parts)+1))
164
- filt = ";".join(filter_parts) + f";{mix_inputs}amix=inputs={len(parts)+1}:duration=first[a]"
165
-
166
- out = work/"dub.wav"
167
- run(["ffmpeg","-y",*inputs,"-filter_complex",filt,"-map","[a]",
168
- "-ar","24000","-ac","1",str(out)])
169
- return out
170
-
171
- def process(video, keep_original, original_volume, progress=gr.Progress()):
172
- if not video:
173
- raise gr.Error("请上传中文视频。")
174
- work = Path(tempfile.mkdtemp(prefix="zhms_cpu_"))
175
  try:
176
- src = Path(video)
177
- audio = work/"source.wav"
178
- progress(0.05, desc="提取声音…")
179
- extract_audio(src, audio)
180
- total = video_duration(src)
181
-
182
- progress(0.15, desc="中文语音识别(CPU)…")
183
- zh = transcribe(audio)
184
- if not zh:
185
- raise RuntimeError("没有识别到中文语音。")
186
-
187
- progress(0.42, desc="翻译成 Bahasa Melayu…")
188
- ms = translate(zh)
189
-
190
- progress(0.58, desc="生成马来文配音…")
191
- dub = make_dub(ms, total, work)
192
-
193
- out = work/"malay_dub.mp4"
194
- progress(0.9, desc="合成 MP4…")
195
- if keep_original:
196
- filt = f"[0:a]volume={float(original_volume):.3f}[o];[o][1:a]amix=inputs=2:duration=first[a]"
197
- run(["ffmpeg","-y","-i",str(src),"-i",str(dub),"-filter_complex",filt,
198
- "-map","0:v:0","-map","[a]","-c:v","copy","-c:a","aac","-b:a","160k",
199
- "-shortest",str(out)])
200
- else:
201
- run(["ffmpeg","-y","-i",str(src),"-i",str(dub),
202
- "-map","0:v:0","-map","1:a:0","-c:v","copy","-c:a","aac",
203
- "-b:a","160k","-shortest",str(out)])
204
-
205
- txt = work/"translation.txt"
206
- txt.write_text("=== 中文识别 ===\n"+zh+"\n\n=== Bahasa Melayu ===\n"+ms,encoding="utf-8")
207
- progress(1, desc="完成")
208
- return str(out), str(txt), zh, ms
209
- except Exception as e:
210
- raise gr.Error(str(e))
211
-
212
- with gr.Blocks(title="中文 → Bahasa Melayu(CPU Basic)") as demo:
213
- gr.Markdown("""# 🇨🇳 → 🇲🇾 中文视频 → Bahasa Melayu
214
- **Hugging Face CPU Basic 专用版本**
215
-
216
- 上传中文视频 → 中文语音识别 → 马来文翻译 → 马来文 TTS → 输出 MP4。
217
-
218
- - 不需要 GPU
219
- - 不需要 API Key
220
- - 不使用声音克隆
221
- - 使用专门的 Chinese → Malay 翻译模型
222
- - 使用 Meta MMS Malay TTS
223
-
224
- ⚠️ CPU Basic 只有 2 vCPU / 16 GB RAM,建议先测试 1–3 分钟视频。
225
- """)
226
- video = gr.Video(label="上传中文视频", sources=["upload"])
227
- with gr.Row():
228
- keep = gr.Checkbox(label="保留原声音作为背景", value=False)
229
- volume = gr.Slider(0,0.3,value=0.06,step=0.01,label="原声音量")
230
- button = gr.Button("🚀 开始翻译成马来文", variant="primary")
231
- output = gr.Video(label="🇲🇾 马来文视频")
232
- text_file = gr.File(label="识别 + 翻译文本")
233
- with gr.Row():
234
- zh_box = gr.Textbox(label="中文识别结果", lines=8)
235
- ms_box = gr.Textbox(label="Bahasa Melayu", lines=8)
236
- button.click(process,[video,keep,volume],[output,text_file,zh_box,ms_box])
237
-
238
- if __name__ == "__main__":
239
- demo.queue(max_size=2).launch()
 
1
+ import base64
2
+ import json
3
  import os
4
+ import shutil
5
  import subprocess
6
+ import uuid
7
  from pathlib import Path
8
+ from typing import Any, List, Optional
9
+
10
+ from fastapi import FastAPI, Header, HTTPException
11
+ from fastapi.responses import FileResponse, JSONResponse
12
+ from pydantic import BaseModel
13
+
14
+ APP_DIR = Path(__file__).parent
15
+ JOB_DIR = Path(os.environ.get("JOB_DIR", "/data/jobs"))
16
+ BLENDER_BIN = os.environ.get("BLENDER_BIN", "/opt/blender/blender")
17
+ JOB_TIMEOUT = int(os.environ.get("JOB_TIMEOUT_SECONDS", "180"))
18
+ WORKER_SCRIPT = APP_DIR / "worker.py"
19
+
20
+ # 可选的简单鉴权:设置了 API_KEY 环境变量后,/execute 需要带 X-API-Key 头
21
+ API_KEY = os.environ.get("API_KEY")
22
+
23
+ JOB_DIR.mkdir(parents=True, exist_ok=True)
24
+
25
+ app = FastAPI(title="Blender Headless API")
26
+
27
+
28
+ class ExecuteRequest(BaseModel):
29
+ ops: List[dict[str, Any]]
30
+ scene_b64: Optional[str] = None # 可选:输入 .blend 文件(base64)
31
+
32
+
33
+ def check_api_key(x_api_key: Optional[str]):
34
+ if API_KEY and x_api_key != API_KEY:
35
+ raise HTTPException(status_code=401, detail="invalid or missing X-API-Key")
36
+
37
+
38
+ @app.get("/health")
39
+ def health():
40
+ return {
41
+ "status": "ok",
42
+ "blender_bin": BLENDER_BIN,
43
+ "blender_exists": os.path.exists(BLENDER_BIN),
44
+ }
45
+
46
+
47
+ @app.post("/execute")
48
+ def execute(req: ExecuteRequest, x_api_key: Optional[str] = Header(default=None)):
49
+ check_api_key(x_api_key)
50
+
51
+ job_id = uuid.uuid4().hex[:12]
52
+ job_path = JOB_DIR / job_id
53
+ input_dir = job_path / "input"
54
+ output_dir = job_path / "output"
55
+ input_dir.mkdir(parents=True)
56
+ output_dir.mkdir(parents=True)
57
+
58
+ cmd = [BLENDER_BIN, "--background"]
59
+
60
+ if req.scene_b64:
61
+ scene_in_path = input_dir / "input.blend"
62
+ scene_in_path.write_bytes(base64.b64decode(req.scene_b64))
63
+ cmd.append(str(scene_in_path))
64
+
65
+ ops_path = input_dir / "ops.json"
66
+ ops_path.write_text(json.dumps(req.ops))
67
+
68
+ cmd += ["--python", str(WORKER_SCRIPT), "--", str(ops_path), str(output_dir)]
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  try:
71
+ result = subprocess.run(
72
+ cmd,
73
+ capture_output=True,
74
+ text=True,
75
+ timeout=JOB_TIMEOUT,
76
+ )
77
+ except subprocess.TimeoutExpired:
78
+ shutil.rmtree(job_path, ignore_errors=True)
79
+ raise HTTPException(status_code=504, detail="Blender job timed out")
80
+
81
+ (job_path / "blender.log").write_text(
82
+ (result.stdout or "") + "\n---STDERR---\n" + (result.stderr or "")
83
+ )
84
+
85
+ result_json_path = output_dir / "result.json"
86
+ if result.returncode != 0 or not result_json_path.exists():
87
+ raise HTTPException(
88
+ status_code=500,
89
+ detail={
90
+ "message": "Blender execution failed",
91
+ "returncode": result.returncode,
92
+ "log_tail": (result.stdout[-2000:] + result.stderr[-2000:]),
93
+ },
94
+ )
95
+
96
+ op_result = json.loads(result_json_path.read_text())
97
+ files = sorted(p.name for p in output_dir.iterdir() if p.name != "result.json")
98
+
99
+ return JSONResponse(
100
+ {
101
+ "job_id": job_id,
102
+ "ops_result": op_result,
103
+ "files": files,
104
+ "download_base": f"/files/{job_id}/",
105
+ }
106
+ )
107
+
108
+
109
+ @app.get("/files/{job_id}/{filename}")
110
+ def get_file(job_id: str, filename: str):
111
+ file_path = (JOB_DIR / job_id / "output" / filename).resolve()
112
+ if JOB_DIR.resolve() not in file_path.parents or not file_path.exists():
113
+ raise HTTPException(status_code=404, detail="File not found")
114
+ return FileResponse(file_path)
115
+
116
+
117
+ @app.delete("/jobs/{job_id}")
118
+ def delete_job(job_id: str, x_api_key: Optional[str] = Header(default=None)):
119
+ check_api_key(x_api_key)
120
+ job_path = JOB_DIR / job_id
121
+ if job_path.exists():
122
+ shutil.rmtree(job_path)
123
+ return {"deleted": job_id}
124
+ raise HTTPException(status_code=404, detail="Job not found")