daKhosa commited on
Commit
b840bd7
·
1 Parent(s): 144ea05

Switch to ZeroGPU POC pattern: duration=1 billing, subprocess retains CUDA context

Browse files

- app.py: pre-spawn generate.py from main process, @spaces.GPU(duration=1) only
for CUDA init signal; stream incremental logs from JSON result file
- generate.py: add --signal-path polling, --result-path incremental JSON logging,
add distillation LoRA (ltx-2.3-22b-distilled-lora-384.safetensors at 0.5),
experimental LoRA at 1.0; guidance_scale default 5.0

Files changed (2) hide show
  1. app.py +98 -69
  2. generate.py +44 -8
app.py CHANGED
@@ -1,38 +1,43 @@
1
  """
2
  Sulphur — Image to Video (HF Spaces).
3
- Clones Wan2GP and downloads models on first run.
4
- Generation is handled by generate.py called as a subprocess inside @spaces.GPU.
 
 
 
5
  """
6
 
 
 
7
  import os
8
- import sys
9
- import subprocess
10
  import shutil
 
 
11
  import tempfile
12
  import threading
13
- import json
14
  from pathlib import Path
15
 
16
  import gradio as gr
17
  import spaces
18
 
19
- _HF_TOKEN = os.environ.get("HF_TOKEN")
20
- _PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
21
- WAN2GP_ROOT = _PERSISTENT / "Wan2GP"
22
- CKPTS_DIR = WAN2GP_ROOT / "ckpts"
23
- LORAS_DIR = WAN2GP_ROOT / "loras" / "ltx2"
24
  FINETUNES_DIR = WAN2GP_ROOT / "finetunes"
25
- GENERATE_PY = Path(__file__).parent / "generate.py"
26
 
27
  SULPHUR_ASSETS = [
28
  ("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", CKPTS_DIR),
29
  ]
30
  LTX_ASSETS = [
31
  ("SulphurAI/Sulphur-2-base", "experimental/sulphur_experimental_lora_v1.safetensors", LORAS_DIR),
32
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b-distilled-lora-384.safetensors", LORAS_DIR),
33
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_vae.safetensors", CKPTS_DIR),
34
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_text_embedding_projection.safetensors", CKPTS_DIR),
35
- ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_embeddings_connector.safetensors", CKPTS_DIR),
36
  ]
37
 
38
  SULPHUR_FINETUNE = {
@@ -42,15 +47,14 @@ SULPHUR_FINETUNE = {
42
  "architecture": "ltx2_22B",
43
  "parent_model_type": "ltx2_22B",
44
  "description": "LTX-2.3 fine-tuned i2v. Distilled checkpoint.",
45
- # Full distilled model — do NOT also preload the rank-768 LoRA (README: use one or the other)
46
  "URLs": [str(CKPTS_DIR / "sulphur_distil_bf16.safetensors")],
47
  "preload_URLs": [],
48
  },
49
  "num_inference_steps": 8,
50
  "video_length": 81,
51
  "resolution": "832x480",
52
- "guidance_scale": 3.5,
53
- "alt_guidance_scale": 3.5,
54
  }
55
 
56
  _setup_lock = threading.Lock()
@@ -60,14 +64,13 @@ _setup_done = False
60
  def _download(repo_id, filename, dest_dir):
61
  from huggingface_hub import hf_hub_download
62
  dest_dir.mkdir(parents=True, exist_ok=True)
63
- dest = dest_dir / Path(filename).name # flat — strip any subfolder
64
  if dest.exists():
65
  print(f"[download] cached: {dest.name}")
66
  return
67
  print(f"[download] {repo_id}/{filename}")
68
  hf_hub_download(repo_id=repo_id, filename=filename,
69
  local_dir=str(dest_dir), token=_HF_TOKEN)
70
- # hf_hub_download preserves subfolder structure; flatten to dest_dir root
71
  downloaded = dest_dir / filename
72
  if downloaded.exists() and not dest.exists():
73
  shutil.move(str(downloaded), str(dest))
@@ -92,10 +95,9 @@ def setup():
92
  for repo, fname, dest in SULPHUR_ASSETS + LTX_ASSETS:
93
  _download(repo, fname, dest)
94
 
95
- # Gemma text encoder — must stay in its subfolder (Wan2GP looks there by name)
96
  _gemma_folder = "gemma-3-12b-it-qat-q4_0-unquantized"
97
- _gemma_file = f"{_gemma_folder}_quanto_bf16_int8.safetensors"
98
- gemma_dest = CKPTS_DIR / _gemma_folder / _gemma_file
99
  if not gemma_dest.exists():
100
  from huggingface_hub import hf_hub_download
101
  print("[download] Gemma text encoder...")
@@ -109,7 +111,9 @@ def setup():
109
  print("[download] cached: Gemma text encoder")
110
 
111
  FINETUNES_DIR.mkdir(parents=True, exist_ok=True)
112
- (FINETUNES_DIR / "sulphur_2_base.json").write_text(json.dumps(SULPHUR_FINETUNE, indent=2))
 
 
113
  print("[setup] Done.")
114
 
115
 
@@ -118,77 +122,102 @@ setup()
118
  RESOLUTIONS = ["832x480", "480x832", "640x640", "1024x576", "576x1024"]
119
 
120
 
121
- @spaces.GPU(duration=80)
 
 
 
 
 
 
122
  def generate_video(image, prompt, resolution, steps, guidance_scale, frames, seed):
123
  if image is None:
124
  raise gr.Error("Please upload an image.")
125
  if not prompt.strip():
126
  raise gr.Error("Please enter a prompt.")
127
 
128
- out_file = Path(tempfile.mkdtemp()) / "output.mp4"
129
- env = {**os.environ, "WAN2GP_ROOT": str(WAN2GP_ROOT)}
 
 
130
 
131
  cmd = [
132
  sys.executable, str(GENERATE_PY),
133
- "--image", image,
134
- "--prompt", prompt,
135
- "--output", str(out_file),
136
- "--model", "sulphur-2",
137
- "--seed", str(int(seed)),
138
- "--resolution", resolution,
139
- "--steps", str(int(steps)),
140
  "--guidance_scale", str(float(guidance_scale)),
141
- "--frames", str(int(frames)),
 
 
142
  ]
143
 
144
- log_lines = []
145
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
146
- text=True, bufsize=0, env=env)
147
-
148
- buf = ""
149
- while True:
150
- chunk = proc.stdout.read(256)
151
- if not chunk:
152
- break
153
- buf += chunk
154
- # Split on \r or \n — tqdm uses \r to overwrite progress lines
155
- parts = buf.replace("\r", "\n").split("\n")
156
- buf = parts[-1]
157
- for part in parts[:-1]:
158
- stripped = part.strip()
159
- if not stripped:
160
- continue
161
- # Overwrite last line if it looks like a progress bar update
162
- if log_lines and ("%" in stripped or "it/s" in stripped or "step" in stripped.lower()):
163
- log_lines[-1] = stripped
164
- else:
165
- log_lines.append(stripped)
166
- print(stripped)
167
- yield None, "\n".join(log_lines[-30:])
 
 
 
 
 
 
 
 
 
 
 
168
 
169
  proc.wait()
170
- log = "\n".join(log_lines)
171
 
172
- if proc.returncode != 0 or not out_file.exists():
173
- yield None, log + "\n\n[ERROR] Generation failed."
174
- return
 
 
175
 
176
- final = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
177
- shutil.copy2(out_file, final.name)
178
- yield final.name, log + "\n\n[DONE]"
 
 
 
179
 
180
 
181
  with gr.Blocks(title="Sulphur — Image to Video") as demo:
182
- gr.Markdown("# Sulphur — Image to Video\nUsing New Experimental Lora v1")
183
  with gr.Row():
184
  with gr.Column(scale=1):
185
  image_in = gr.Image(type="filepath", label="Input Image")
186
  prompt_in = gr.Textbox(label="Prompt", placeholder="Describe the motion…", lines=3)
187
  with gr.Accordion("Advanced", open=False):
188
  resolution_dd = gr.Dropdown(RESOLUTIONS, value="832x480", label="Resolution")
189
- steps_sl = gr.Slider(1, 50, value=8, step=1, label="Steps")
190
  guidance_sl = gr.Slider(1.0, 10.0, value=5.0, step=0.5, label="Guidance Scale")
191
- frames_sl = gr.Slider(17, 257, value=81, step=8, label="Frames")
192
  seed_num = gr.Number(value=-1, label="Seed (-1 = random)", precision=0)
193
  run_btn = gr.Button("Generate", variant="primary")
194
  with gr.Column(scale=1):
 
1
  """
2
  Sulphur — Image to Video (HF Spaces).
3
+
4
+ POC-proven pattern: subprocess is pre-spawned from the main Gradio process,
5
+ @spaces.GPU(duration=1) is used only to signal CUDA initialisation.
6
+ The subprocess retains GPU access for the full generation duration.
7
+ Billing cost: 1 second per generation regardless of inference time.
8
  """
9
 
10
+ import json
11
+ import multiprocessing
12
  import os
 
 
13
  import shutil
14
+ import subprocess
15
+ import sys
16
  import tempfile
17
  import threading
18
+ import time
19
  from pathlib import Path
20
 
21
  import gradio as gr
22
  import spaces
23
 
24
+ _HF_TOKEN = os.environ.get("HF_TOKEN")
25
+ _PERSISTENT = Path("/data") if Path("/data").exists() else Path(tempfile.gettempdir())
26
+ WAN2GP_ROOT = _PERSISTENT / "Wan2GP"
27
+ CKPTS_DIR = WAN2GP_ROOT / "ckpts"
28
+ LORAS_DIR = WAN2GP_ROOT / "loras" / "ltx2"
29
  FINETUNES_DIR = WAN2GP_ROOT / "finetunes"
30
+ GENERATE_PY = Path(__file__).parent / "generate.py"
31
 
32
  SULPHUR_ASSETS = [
33
  ("SulphurAI/Sulphur-2-base", "sulphur_distil_bf16.safetensors", CKPTS_DIR),
34
  ]
35
  LTX_ASSETS = [
36
  ("SulphurAI/Sulphur-2-base", "experimental/sulphur_experimental_lora_v1.safetensors", LORAS_DIR),
37
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b-distilled-lora-384.safetensors", LORAS_DIR),
38
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_vae.safetensors", CKPTS_DIR),
39
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_text_embedding_projection.safetensors", CKPTS_DIR),
40
+ ("DeepBeepMeep/LTX-2", "ltx-2.3-22b_embeddings_connector.safetensors", CKPTS_DIR),
41
  ]
42
 
43
  SULPHUR_FINETUNE = {
 
47
  "architecture": "ltx2_22B",
48
  "parent_model_type": "ltx2_22B",
49
  "description": "LTX-2.3 fine-tuned i2v. Distilled checkpoint.",
 
50
  "URLs": [str(CKPTS_DIR / "sulphur_distil_bf16.safetensors")],
51
  "preload_URLs": [],
52
  },
53
  "num_inference_steps": 8,
54
  "video_length": 81,
55
  "resolution": "832x480",
56
+ "guidance_scale": 5.0,
57
+ "alt_guidance_scale": 5.0,
58
  }
59
 
60
  _setup_lock = threading.Lock()
 
64
  def _download(repo_id, filename, dest_dir):
65
  from huggingface_hub import hf_hub_download
66
  dest_dir.mkdir(parents=True, exist_ok=True)
67
+ dest = dest_dir / Path(filename).name
68
  if dest.exists():
69
  print(f"[download] cached: {dest.name}")
70
  return
71
  print(f"[download] {repo_id}/{filename}")
72
  hf_hub_download(repo_id=repo_id, filename=filename,
73
  local_dir=str(dest_dir), token=_HF_TOKEN)
 
74
  downloaded = dest_dir / filename
75
  if downloaded.exists() and not dest.exists():
76
  shutil.move(str(downloaded), str(dest))
 
95
  for repo, fname, dest in SULPHUR_ASSETS + LTX_ASSETS:
96
  _download(repo, fname, dest)
97
 
 
98
  _gemma_folder = "gemma-3-12b-it-qat-q4_0-unquantized"
99
+ _gemma_file = f"{_gemma_folder}_quanto_bf16_int8.safetensors"
100
+ gemma_dest = CKPTS_DIR / _gemma_folder / _gemma_file
101
  if not gemma_dest.exists():
102
  from huggingface_hub import hf_hub_download
103
  print("[download] Gemma text encoder...")
 
111
  print("[download] cached: Gemma text encoder")
112
 
113
  FINETUNES_DIR.mkdir(parents=True, exist_ok=True)
114
+ (FINETUNES_DIR / "sulphur_2_base.json").write_text(
115
+ json.dumps(SULPHUR_FINETUNE, indent=2)
116
+ )
117
  print("[setup] Done.")
118
 
119
 
 
122
  RESOLUTIONS = ["832x480", "480x832", "640x640", "1024x576", "576x1024"]
123
 
124
 
125
+ @spaces.GPU(duration=1)
126
+ def _signal_cuda_init(signal_path):
127
+ """Acquire a 1-second GPU lease just long enough for the worker to init CUDA."""
128
+ Path(signal_path).write_text("go")
129
+ time.sleep(0.5)
130
+
131
+
132
  def generate_video(image, prompt, resolution, steps, guidance_scale, frames, seed):
133
  if image is None:
134
  raise gr.Error("Please upload an image.")
135
  if not prompt.strip():
136
  raise gr.Error("Please enter a prompt.")
137
 
138
+ signal_path = tempfile.mktemp(suffix=".signal")
139
+ result_path = tempfile.mktemp(suffix=".json")
140
+ out_dir = tempfile.mkdtemp()
141
+ out_file = os.path.join(out_dir, "output.mp4")
142
 
143
  cmd = [
144
  sys.executable, str(GENERATE_PY),
145
+ "--image", image,
146
+ "--prompt", prompt,
147
+ "--output", out_file,
148
+ "--model", "sulphur-2",
149
+ "--seed", str(int(seed)),
150
+ "--resolution", resolution,
151
+ "--steps", str(int(steps)),
152
  "--guidance_scale", str(float(guidance_scale)),
153
+ "--frames", str(int(frames)),
154
+ "--signal-path", signal_path,
155
+ "--result-path", result_path,
156
  ]
157
 
158
+ env = {**os.environ, "WAN2GP_ROOT": str(WAN2GP_ROOT)}
159
+
160
+ # Spawn from main Gradio process (not inside ZeroGPU daemon — avoids daemon-child restriction)
161
+ proc = subprocess.Popen(cmd, env=env)
162
+
163
+ log_lines = ["Worker spawned, acquiring GPU lease..."]
164
+ yield None, "\n".join(log_lines)
165
+
166
+ # 1-second lease — signals worker to init CUDA, then expires
167
+ _signal_cuda_init(signal_path)
168
+
169
+ log_lines.append("GPU lease expired. Worker retains CUDA context and is generating...")
170
+ yield None, "\n".join(log_lines)
171
+
172
+ last_log_len = 0
173
+ deadline = time.monotonic() + 600 # 10 min hard timeout
174
+
175
+ while time.monotonic() < deadline:
176
+ time.sleep(2)
177
+
178
+ if os.path.exists(result_path):
179
+ try:
180
+ with open(result_path) as f:
181
+ data = json.load(f)
182
+ new_entries = data.get("log", [])[last_log_len:]
183
+ if new_entries:
184
+ log_lines.extend(new_entries)
185
+ last_log_len += len(new_entries)
186
+ yield None, "\n".join(log_lines[-40:])
187
+ if data.get("done"):
188
+ break
189
+ except Exception:
190
+ pass
191
+ else:
192
+ yield None, "\n".join(log_lines)
193
 
194
  proc.wait()
 
195
 
196
+ for path in (signal_path, result_path):
197
+ try:
198
+ os.unlink(path)
199
+ except Exception:
200
+ pass
201
 
202
+ if os.path.exists(out_file):
203
+ final = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
204
+ shutil.copy2(out_file, final.name)
205
+ yield final.name, "\n".join(log_lines) + "\n\n[DONE]"
206
+ else:
207
+ yield None, "\n".join(log_lines) + "\n\n[ERROR] No output file produced."
208
 
209
 
210
  with gr.Blocks(title="Sulphur — Image to Video") as demo:
211
+ gr.Markdown("# Sulphur — Image to Video\nUsing Experimental LoRA v1 + Distillation LoRA")
212
  with gr.Row():
213
  with gr.Column(scale=1):
214
  image_in = gr.Image(type="filepath", label="Input Image")
215
  prompt_in = gr.Textbox(label="Prompt", placeholder="Describe the motion…", lines=3)
216
  with gr.Accordion("Advanced", open=False):
217
  resolution_dd = gr.Dropdown(RESOLUTIONS, value="832x480", label="Resolution")
218
+ steps_sl = gr.Slider(1, 50, value=8, step=1, label="Steps")
219
  guidance_sl = gr.Slider(1.0, 10.0, value=5.0, step=0.5, label="Guidance Scale")
220
+ frames_sl = gr.Slider(17, 257, value=81, step=8, label="Frames")
221
  seed_num = gr.Number(value=-1, label="Seed (-1 = random)", precision=0)
222
  run_btn = gr.Button("Generate", variant="primary")
223
  with gr.Column(scale=1):
generate.py CHANGED
@@ -1,11 +1,14 @@
1
  """
2
- HF Spaces version of generate.py — same logic, paths adapted for /tmp/Wan2GP.
3
- Called as a subprocess from app.py inside @spaces.GPU.
 
4
  """
5
 
6
  import argparse
 
7
  import os
8
  import sys
 
9
  from pathlib import Path
10
 
11
  WAN2GP_ROOT = Path(os.environ.get("WAN2GP_ROOT", "/tmp/Wan2GP"))
@@ -23,9 +26,21 @@ DEFAULTS = {
23
  },
24
  }
25
 
 
 
26
 
27
- def p(*args, **kwargs):
28
- print(*args, **kwargs, flush=True)
 
 
 
 
 
 
 
 
 
 
29
 
30
 
31
  def parse_args():
@@ -39,18 +54,32 @@ def parse_args():
39
  ap.add_argument("--frames", type=int, default=None)
40
  ap.add_argument("--resolution", default=None)
41
  ap.add_argument("--seed", type=int, default=-1)
 
 
42
  return ap.parse_args()
43
 
44
 
45
  def main():
 
 
46
  args = parse_args()
 
 
 
 
 
 
 
47
 
48
  model_type = MODEL_SHORTHANDS.get(args.model, args.model)
49
  defaults = DEFAULTS.get(model_type, DEFAULTS["sulphur_2_base"])
50
 
51
  image_path = str(Path(args.image.strip()).resolve())
52
  if not Path(image_path).exists():
53
- print(f"Fatal: image not found: {image_path}", flush=True)
 
 
 
54
  sys.exit(1)
55
 
56
  resolution = args.resolution or defaults["resolution"]
@@ -83,9 +112,10 @@ def main():
83
  "image_prompt_type": "S",
84
  "input_video_strength": 1.0,
85
  "activated_loras": [
 
86
  "sulphur_experimental_lora_v1.safetensors",
87
  ],
88
- "loras_multipliers": ["0.5"],
89
  }
90
 
91
  p(f"Model: {model_type}")
@@ -102,7 +132,7 @@ def main():
102
  output_dir = Path(args.output).parent
103
  output_dir.mkdir(parents=True, exist_ok=True)
104
 
105
- p("Starting session...")
106
  session = WanGPSession(root=WAN2GP_ROOT, output_dir=output_dir, console_output=True)
107
 
108
  p("Running generation...")
@@ -115,7 +145,6 @@ def main():
115
  if src and Path(src).exists():
116
  output_file = src
117
 
118
- # Fallback: scan the output dir for any video file Wan2GP may have written
119
  if output_file is None:
120
  candidates = sorted(output_dir.glob("**/*.mp4"), key=lambda f: f.stat().st_mtime, reverse=True)
121
  if candidates:
@@ -130,10 +159,17 @@ def main():
130
  p(f"No output found in {output_dir}")
131
  if result.errors:
132
  p(f"Errors: {result.errors}")
 
 
 
133
  sys.exit(1)
134
 
135
  session.close()
136
 
 
 
 
 
137
 
138
  if __name__ == "__main__":
139
  main()
 
1
  """
2
+ HF Spaces version of generate.py — paths adapted for /data/Wan2GP.
3
+ Called as a subprocess from app.py.
4
+ Waits for --signal-path before touching CUDA, writes incremental JSON to --result-path.
5
  """
6
 
7
  import argparse
8
+ import json
9
  import os
10
  import sys
11
+ import time
12
  from pathlib import Path
13
 
14
  WAN2GP_ROOT = Path(os.environ.get("WAN2GP_ROOT", "/tmp/Wan2GP"))
 
26
  },
27
  }
28
 
29
+ _log_entries = []
30
+ _result_path = None
31
 
32
+
33
+ def p(msg):
34
+ ts = time.strftime("%H:%M:%S")
35
+ entry = f"[{ts}] {msg}"
36
+ _log_entries.append(entry)
37
+ print(entry, flush=True)
38
+ if _result_path:
39
+ try:
40
+ with open(_result_path, "w") as f:
41
+ json.dump({"log": _log_entries, "done": False}, f)
42
+ except Exception:
43
+ pass
44
 
45
 
46
  def parse_args():
 
54
  ap.add_argument("--frames", type=int, default=None)
55
  ap.add_argument("--resolution", default=None)
56
  ap.add_argument("--seed", type=int, default=-1)
57
+ ap.add_argument("--signal-path", default=None, dest="signal_path")
58
+ ap.add_argument("--result-path", default=None, dest="result_path")
59
  return ap.parse_args()
60
 
61
 
62
  def main():
63
+ global _result_path
64
+
65
  args = parse_args()
66
+ _result_path = args.result_path
67
+
68
+ if args.signal_path:
69
+ p("Waiting for GPU lease signal...")
70
+ while not Path(args.signal_path).exists():
71
+ time.sleep(0.05)
72
+ p("Signal received — GPU lease active, initialising CUDA...")
73
 
74
  model_type = MODEL_SHORTHANDS.get(args.model, args.model)
75
  defaults = DEFAULTS.get(model_type, DEFAULTS["sulphur_2_base"])
76
 
77
  image_path = str(Path(args.image.strip()).resolve())
78
  if not Path(image_path).exists():
79
+ p(f"Fatal: image not found: {image_path}")
80
+ if _result_path:
81
+ with open(_result_path, "w") as f:
82
+ json.dump({"log": _log_entries, "done": True, "error": "image not found"}, f)
83
  sys.exit(1)
84
 
85
  resolution = args.resolution or defaults["resolution"]
 
112
  "image_prompt_type": "S",
113
  "input_video_strength": 1.0,
114
  "activated_loras": [
115
+ "ltx-2.3-22b-distilled-lora-384.safetensors",
116
  "sulphur_experimental_lora_v1.safetensors",
117
  ],
118
+ "loras_multipliers": ["0.5", "1.0"],
119
  }
120
 
121
  p(f"Model: {model_type}")
 
132
  output_dir = Path(args.output).parent
133
  output_dir.mkdir(parents=True, exist_ok=True)
134
 
135
+ p("Starting WanGPSession...")
136
  session = WanGPSession(root=WAN2GP_ROOT, output_dir=output_dir, console_output=True)
137
 
138
  p("Running generation...")
 
145
  if src and Path(src).exists():
146
  output_file = src
147
 
 
148
  if output_file is None:
149
  candidates = sorted(output_dir.glob("**/*.mp4"), key=lambda f: f.stat().st_mtime, reverse=True)
150
  if candidates:
 
159
  p(f"No output found in {output_dir}")
160
  if result.errors:
161
  p(f"Errors: {result.errors}")
162
+ if _result_path:
163
+ with open(_result_path, "w") as f:
164
+ json.dump({"log": _log_entries, "done": True, "error": "no output"}, f)
165
  sys.exit(1)
166
 
167
  session.close()
168
 
169
+ if _result_path:
170
+ with open(_result_path, "w") as f:
171
+ json.dump({"log": _log_entries, "done": True}, f)
172
+
173
 
174
  if __name__ == "__main__":
175
  main()