wuhp commited on
Commit
4a5831f
Β·
verified Β·
1 Parent(s): 054b532

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +114 -49
app.py CHANGED
@@ -1,82 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
  import os
2
  import struct
3
  import tempfile
 
4
  from typing import BinaryIO, Iterator
5
 
6
  import gradio as gr
 
7
 
8
  PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
9
-
10
-
11
- # ────────────────────────────────────────────────────────── helpers ──
 
 
 
 
 
 
 
 
 
12
  def _read_chunks(fp: BinaryIO) -> Iterator[tuple[bytes, bytes]]:
13
  """
14
  Yield (raw_chunk_bytes, chunk_type_bytes) for every chunk in *fp*.
15
- `raw_chunk_bytes` includes length, type, data and CRC exactly as in file,
16
- so we can copy them verbatim later.
17
- The PNG signature must already be consumed by the caller.
18
  """
19
  while True:
20
  length_bytes = fp.read(4)
21
  if not length_bytes:
22
  break # EOF
23
  if len(length_bytes) != 4:
24
- raise ValueError("Corrupted PNG: unexpected EOF while reading chunk length.")
25
 
26
- length = struct.unpack(">I", length_bytes)[0] # ← fixed (no stray space)
27
  chunk_type = fp.read(4)
28
- data = fp.read(length)
29
- crc = fp.read(4)
30
 
31
  if len(chunk_type) != 4 or len(data) != length or len(crc) != 4:
32
- raise ValueError("Corrupted PNG: truncated chunk.")
33
 
34
  yield length_bytes + chunk_type + data + crc, chunk_type
35
 
36
 
37
- def clone_metadata(src_path: str, tgt_path: str, out_path: str) -> str:
38
- """
39
- Copy all ancillary chunks (anything except IHDR / IDAT / IEND) from *src_path*
40
- into *tgt_path* immediately after its IHDR, then save to *out_path*.
41
- Pixel data of the target stays untouched.
42
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  with open(src_path, "rb") as fs, open(tgt_path, "rb") as ft:
44
  if fs.read(8) != PNG_SIGNATURE or ft.read(8) != PNG_SIGNATURE:
45
- raise ValueError("Both files must be valid PNG images.")
46
 
47
  src_chunks = list(_read_chunks(fs))
48
  tgt_chunks = list(_read_chunks(ft))
49
 
50
- # take every non-critical chunk from source
51
- ancillary = [
52
- raw for raw, tp in src_chunks
53
- if tp not in (b"IHDR", b"IDAT", b"IEND")
54
- ]
55
 
56
- out_chunks: list[bytes] = []
57
  for raw, tp in tgt_chunks:
58
- out_chunks.append(raw)
59
- if tp == b"IHDR": # inject right after IHDR
60
- out_chunks.extend(ancillary)
 
 
 
 
 
 
 
 
 
 
61
 
62
  with open(out_path, "wb") as fo:
63
  fo.write(PNG_SIGNATURE)
64
- for chunk in out_chunks:
65
  fo.write(chunk)
66
 
 
 
 
 
 
 
 
67
  return out_path
68
 
69
 
70
- # ────────────────────────────────────────────────────────── gradio ui ──
71
- def process(src_file, tgt_file):
72
- """Gradio wrapper β†’ returns path to the merged PNG."""
73
  if not src_file or not tgt_file:
74
- raise gr.Error("Please upload both images.")
 
 
 
 
 
 
75
 
76
  tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
77
- tmp.close() # we only need the path
78
  try:
79
- clone_metadata(src_file, tgt_file, tmp.name)
80
  except Exception as e:
81
  os.remove(tmp.name)
82
  raise gr.Error(str(e))
@@ -84,33 +150,32 @@ def process(src_file, tgt_file):
84
  return tmp.name
85
 
86
 
87
- with gr.Blocks(title="Kovaaks PNG Metadata Cloner") as demo:
88
  gr.Markdown(
89
  """
90
- ## Kovaaks PNG Metadata Cloner 🏹
91
-
92
- **Step 1.** Upload a *metadata source* PNG that already works in-game
93
- **Step 2.** Upload your *target* PNG (new 50 Γ— 50 crosshair)
94
- **Step 3.** Click **Clone metadata** and download the result.
95
 
96
- Put the output file into
97
  `%LOCALAPPDATA%\\FPSAimTrainer\\Saved\\MyImport\\crosshairs`
98
  """
99
  )
100
-
101
  with gr.Row():
102
- src = gr.File(label="Image 1 – source (metadata)", type="filepath")
103
- tgt = gr.File(label="Image 2 – target (pixels)", type="filepath")
104
-
105
  out = gr.File(label="Output PNG", interactive=False)
106
  btn = gr.Button("Clone metadata β†’")
107
 
108
- btn.click(fn=process, inputs=[src, tgt], outputs=[out])
109
 
110
  gr.Markdown(
111
- "βœ”οΈ Ancillary chunks (`sRGB`, `gAMA`, `pHYs`, `eXIf`, `tEXt`, …) are copied verbatim. "
112
- "Pixel data from the target image is left untouched."
113
  )
114
 
115
  if __name__ == "__main__":
116
- demo.launch(show_api=False, share=False)
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Kovaaks PNG Metadata Cloner β€” v2
4
+ ↓ Improvements
5
+ β€’ enforces PNG chunk order (IHDR β†’ sRGB β†’ gAMA β†’ pHYs β†’ eXIf β†’ (…) β†’ IDAT β†’ IEND)
6
+ β€’ keeps only a single instance of each ancillary chunk (no duplicates)
7
+ β€’ validates / auto-converts target to 50 Γ— 50 RGBA if the user ticks a checkbox
8
+ β€’ warns when resulting file > 256 B (Kovaaks soft limit)
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
  import os
14
  import struct
15
  import tempfile
16
+ from pathlib import Path
17
  from typing import BinaryIO, Iterator
18
 
19
  import gradio as gr
20
+ from PIL import Image
21
 
22
  PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
23
+ # ancillary chunks Kovaaks cares about, in desired order
24
+ ANCILLARY_ORDER = (
25
+ b"sRGB",
26
+ b"gAMA",
27
+ b"pHYs",
28
+ b"eXIf", # empty container in template crosshair
29
+ b"iTXt",
30
+ b"tEXt",
31
+ b"zTXt",
32
+ )
33
+
34
+ # ───────────────────────────────────────────────── helpers ──
35
  def _read_chunks(fp: BinaryIO) -> Iterator[tuple[bytes, bytes]]:
36
  """
37
  Yield (raw_chunk_bytes, chunk_type_bytes) for every chunk in *fp*.
38
+ Caller must have consumed the 8-byte PNG signature already.
 
 
39
  """
40
  while True:
41
  length_bytes = fp.read(4)
42
  if not length_bytes:
43
  break # EOF
44
  if len(length_bytes) != 4:
45
+ raise ValueError("Corrupted PNG: could not read length")
46
 
47
+ length = struct.unpack(">I", length_bytes)[0]
48
  chunk_type = fp.read(4)
49
+ data = fp.read(length)
50
+ crc = fp.read(4)
51
 
52
  if len(chunk_type) != 4 or len(data) != length or len(crc) != 4:
53
+ raise ValueError("Corrupted PNG: truncated chunk")
54
 
55
  yield length_bytes + chunk_type + data + crc, chunk_type
56
 
57
 
58
+ def _clean_ancillary(src_chunks: list[tuple[bytes, bytes]]) -> list[bytes]:
59
+ """Return at most one instance of each ordered ancillary chunk."""
60
+ kept: dict[bytes, bytes] = {}
61
+ for raw, tp in src_chunks:
62
+ if tp in ANCILLARY_ORDER and tp not in kept:
63
+ kept[tp] = raw
64
+ # return in canonical order
65
+ return [kept[tag] for tag in ANCILLARY_ORDER if tag in kept]
66
+
67
+
68
+ def _validate_or_resize(path: str, auto_fix: bool) -> None:
69
+ """Ensure 50Γ—50 RGBA; optionally fix in-place (overwrites file)."""
70
+ img = Image.open(path)
71
+ if img.size == (50, 50) and img.mode == "RGBA":
72
+ return # already good
73
+
74
+ if not auto_fix:
75
+ raise ValueError("Target must be 50Γ—50 px, RGBA (enable auto-fix to convert).")
76
+
77
+ # auto-convert
78
+ img = img.convert("RGBA")
79
+ img = img.resize((50, 50), Image.LANCZOS)
80
+ img.save(path, optimize=True)
81
+
82
+
83
+ def clone_metadata(
84
+ src_path: str, tgt_path: str, out_path: str, auto_fix: bool = True
85
+ ) -> str:
86
+ """Clone cleaned ancillary chunks from src β†’ tgt. Save to out_path."""
87
  with open(src_path, "rb") as fs, open(tgt_path, "rb") as ft:
88
  if fs.read(8) != PNG_SIGNATURE or ft.read(8) != PNG_SIGNATURE:
89
+ raise ValueError("Both uploads must be valid PNG images.")
90
 
91
  src_chunks = list(_read_chunks(fs))
92
  tgt_chunks = list(_read_chunks(ft))
93
 
94
+ ancillary = _clean_ancillary(src_chunks)
95
+
96
+ # Remove from target any ancillary tags we will replace
97
+ replace_tags = {tp for _, tp in ancillary}
98
+ rebuilt: list[bytes] = []
99
 
100
+ inserted = False
101
  for raw, tp in tgt_chunks:
102
+ if tp == b"IHDR":
103
+ rebuilt.append(raw)
104
+ # insert ordered ancillary exactly once
105
+ if not inserted:
106
+ rebuilt.extend(ancillary)
107
+ inserted = True
108
+ continue
109
+
110
+ if tp in replace_tags:
111
+ # skip existing duplicate in target
112
+ continue
113
+
114
+ rebuilt.append(raw)
115
 
116
  with open(out_path, "wb") as fo:
117
  fo.write(PNG_SIGNATURE)
118
+ for chunk in rebuilt:
119
  fo.write(chunk)
120
 
121
+ # sanity - size warning
122
+ if os.path.getsize(out_path) > 256:
123
+ print("⚠️ Output > 256 bytes; Kovaaks may still load it but smaller is safer.")
124
+
125
+ # optional validation / resize after writing (pixel data unchanged)
126
+ _validate_or_resize(out_path, auto_fix=False) # ensure still 50Γ—50 RGBA
127
+
128
  return out_path
129
 
130
 
131
+ # ────────────────────────────────────────────────── Gradio UI ──
132
+ def process(src_file: Path, tgt_file: Path, auto_fix: bool):
 
133
  if not src_file or not tgt_file:
134
+ raise gr.Error("Upload both images first.")
135
+
136
+ # optional in-place auto-fix for target
137
+ try:
138
+ _validate_or_resize(str(tgt_file), auto_fix)
139
+ except Exception as e:
140
+ raise gr.Error(str(e))
141
 
142
  tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
143
+ tmp.close()
144
  try:
145
+ clone_metadata(str(src_file), str(tgt_file), tmp.name, auto_fix=False)
146
  except Exception as e:
147
  os.remove(tmp.name)
148
  raise gr.Error(str(e))
 
150
  return tmp.name
151
 
152
 
153
+ with gr.Blocks(title="Kovaaks PNG Metadata Cloner v2") as demo:
154
  gr.Markdown(
155
  """
156
+ # 🏹 Kovaaks PNG Metadata Cloner v2
157
+ 1. **Image 1 (source)** – a PNG that already works in-game
158
+ 2. **Image 2 (target)** – your new crosshair (any size/format)
159
+ 3. Tick **auto-fix** to force 50 Γ— 50 RGBA conversion
160
+ 4. Click **Clone** β†’ download the ready file
161
 
162
+ Put the result into
163
  `%LOCALAPPDATA%\\FPSAimTrainer\\Saved\\MyImport\\crosshairs`
164
  """
165
  )
 
166
  with gr.Row():
167
+ src = gr.File(label="Image 1 β€” metadata source (PNG)", type="filepath")
168
+ tgt = gr.File(label="Image 2 β€” target crosshair", type="filepath")
169
+ auto = gr.Checkbox(True, label="Auto-fix target to 50Γ—50 RGBA")
170
  out = gr.File(label="Output PNG", interactive=False)
171
  btn = gr.Button("Clone metadata β†’")
172
 
173
+ btn.click(fn=process, inputs=[src, tgt, auto], outputs=[out])
174
 
175
  gr.Markdown(
176
+ "Ancillary chunks (sRGB, gAMA, pHYs, eXIf, etc.) are copied verbatim, "
177
+ "duplicates removed, order normalised. Pixel data comes solely from Image 2."
178
  )
179
 
180
  if __name__ == "__main__":
181
+ demo.launch(show_api=False)