@yujp_vc/ppt-assistant 0.0.0-stage → 1.5.2

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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +131 -2
  3. package/asset_catalog.py +159 -0
  4. package/assets/base_template.pptx +0 -0
  5. package/assets/cover_deco.png +0 -0
  6. package/assets/logo.png +0 -0
  7. package/assets/slide2_right.png +0 -0
  8. package/assets/theme_apricot.jpg +0 -0
  9. package/assets/theme_blush.jpg +0 -0
  10. package/assets/theme_coral.jpg +0 -0
  11. package/assets/theme_glow.jpg +0 -0
  12. package/assets/theme_mauve.jpg +0 -0
  13. package/assets/theme_peach.jpg +0 -0
  14. package/assets/theme_rose.jpg +0 -0
  15. package/assets/theme_sunset.jpg +0 -0
  16. package/assets/topright_deco.png +0 -0
  17. package/build_deck.py +2288 -0
  18. package/charts.py +329 -0
  19. package/check.py +236 -0
  20. package/config.json +5 -0
  21. package/contact_sheet.py +88 -0
  22. package/cordis.patch.yml +457 -0
  23. package/deck_baseline.py +335 -0
  24. package/delivery.py +143 -0
  25. package/density-recipe.md +110 -0
  26. package/example.json +26 -0
  27. package/formula.py +125 -0
  28. package/gen_image.py +395 -0
  29. package/generate_consensus.py +892 -0
  30. package/generate_ppt.py +1356 -0
  31. package/grasp_evidence.py +150 -0
  32. package/image_check.py +148 -0
  33. package/image_fetch.py +253 -0
  34. package/ingest.py +93 -0
  35. package/media.py +470 -0
  36. package/package.json +68 -4
  37. package/preset.yml +2 -0
  38. package/quality_check.py +196 -0
  39. package/render_ppt.py +91 -0
  40. package/requirements.txt +7 -0
  41. package/restyle.py +80 -0
  42. package/run_pptfast.sh +27 -0
  43. package/run_py.sh +43 -0
  44. package/slide_ops.py +160 -0
  45. package/slide_patch.py +153 -0
  46. package/speaker_notes.py +245 -0
  47. package/style-recipes.md +166 -0
  48. package/video_gif.py +164 -0
  49. package/vision_check.py +79 -0
package/video_gif.py ADDED
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Convert the complete video to a GIF, or replace a named PPT picture with it."""
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import math
8
+ import subprocess
9
+ import tempfile
10
+ from io import BytesIO
11
+ from pathlib import Path
12
+
13
+ from PIL import Image
14
+ from pptx import Presentation
15
+ from pptx.dml.color import RGBColor
16
+ from pptx.util import Pt
17
+
18
+
19
+ def choose_speed(duration: float, target: float = 10, multiple: int = 5) -> int:
20
+ """Pick an integer speed with duration closest to target, preferring multiples."""
21
+ if not math.isfinite(duration) or duration <= 0 or not math.isfinite(target) or target <= 0 or multiple < 1:
22
+ raise ValueError("positive finite duration, target and speed multiple required")
23
+ ideal = duration / target
24
+ if ideal < multiple:
25
+ candidates = range(1, multiple + 1)
26
+ else:
27
+ lower = max(multiple, math.floor(ideal / multiple) * multiple)
28
+ candidates = (lower, lower + multiple)
29
+ return min(candidates, key=lambda speed: (abs(duration / speed - target), speed))
30
+
31
+
32
+ def gif_info(data: bytes) -> dict:
33
+ """Read frame count and actual encoded duration; reject static GIFs."""
34
+ with Image.open(BytesIO(data)) as image:
35
+ if image.format != "GIF" or image.n_frames < 2:
36
+ raise ValueError("expected an animated, multi-frame GIF")
37
+ duration = 0
38
+ for frame in range(image.n_frames):
39
+ image.seek(frame)
40
+ duration += image.info.get("duration", 0)
41
+ return {"frames": image.n_frames, "duration_seconds": duration / 1000, "size": list(image.size)}
42
+
43
+
44
+ def convert(source: Path, output: Path, *, speed: int | None = None,
45
+ target: float = 10, multiple: int = 5, fps: int = 20, size: int = 640,
46
+ timeout: float = 300) -> dict:
47
+ """Decode the entire first video stream; use speed rather than trimming."""
48
+ if source.resolve() == output.resolve() or output.suffix.lower() != ".gif":
49
+ raise ValueError("output must be a separate .gif file")
50
+ if fps < 1 or fps > 50 or size < 2 or timeout <= 0:
51
+ raise ValueError("fps must be 1–50; positive size and timeout required")
52
+ probe = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
53
+ "stream=duration:format=duration", "-of", "json", str(source.resolve())],
54
+ capture_output=True, text=True, check=True, timeout=timeout)
55
+ metadata = json.loads(probe.stdout)
56
+ if not metadata.get("streams"):
57
+ raise ValueError("input has no video stream")
58
+ raw_duration = metadata["streams"][0].get("duration")
59
+ if raw_duration in (None, "N/A", ""):
60
+ raw_duration = metadata.get("format", {}).get("duration")
61
+ if raw_duration in (None, "N/A", ""):
62
+ raise ValueError("input video duration is unavailable")
63
+ duration = float(raw_duration)
64
+ if not math.isfinite(duration) or duration <= 0:
65
+ raise ValueError("input video duration must be positive and finite")
66
+ selected = choose_speed(duration, target, multiple) if speed is None else speed
67
+ if isinstance(selected, bool) or not isinstance(selected, int) or selected < 1:
68
+ raise ValueError("speed must be a positive integer")
69
+ output.parent.mkdir(parents=True, exist_ok=True)
70
+ with tempfile.TemporaryDirectory(dir=output.parent, prefix=".gif-") as directory:
71
+ temporary = Path(directory) / "encoded.gif"
72
+ filters = (f"[0:v:0]setpts=(PTS-STARTPTS)/{selected},fps={fps},"
73
+ f"scale={size}:{size}:force_original_aspect_ratio=decrease:flags=lanczos,"
74
+ "split[a][b];[a]palettegen=stats_mode=full[p];[b][p]paletteuse=dither=sierra2_4a")
75
+ subprocess.run(["ffmpeg", "-v", "error", "-nostdin", "-i", str(source.resolve()),
76
+ "-filter_complex", filters, "-loop", "0", str(temporary)],
77
+ check=True, timeout=timeout)
78
+ info = gif_info(temporary.read_bytes())
79
+ temporary.replace(output)
80
+ return {"source_duration_seconds": duration, "speed": selected, "full_source": True, **info}
81
+
82
+
83
+ def insert(pptx: Path, gif: Path, output: Path, page: int, name: str, speed: int,
84
+ label_size: float = 24, color: str = "FFFFFF", background: str = "C4262E") -> dict:
85
+ """Replace one picture in place, retain aspect ratio, add editable nX label."""
86
+ if speed < 1 or label_size <= 0:
87
+ raise ValueError("positive speed and label size required")
88
+ info = gif_info(gif.read_bytes())
89
+ prs = Presentation(pptx)
90
+ if not 1 <= page <= len(prs.slides):
91
+ raise ValueError("page is outside deck")
92
+ slide = prs.slides[page - 1]
93
+ targets = [shape for shape in slide.shapes if shape.name == name]
94
+ if len(targets) != 1 or targets[0].shape_type != 13:
95
+ raise ValueError("expected exactly one named picture")
96
+ old = targets[0]
97
+ width, height = info["size"]
98
+ scale = min(old.width / width, old.height / height)
99
+ w, h = int(width * scale), int(height * scale)
100
+ x, y = old.left + (old.width - w) // 2, old.top + (old.height - h) // 2
101
+ picture = slide.shapes.add_picture(str(gif), x, y, w, h)
102
+ picture.name = name
103
+ old._element.addprevious(picture._element)
104
+ old._element.getparent().remove(old._element)
105
+ marker = f"{name} speed"
106
+ for shape in list(slide.shapes):
107
+ if shape.name == marker:
108
+ shape._element.getparent().remove(shape._element)
109
+ label = slide.shapes.add_textbox(x, y, Pt(max(60, len(str(speed)) * label_size + 35)), Pt(label_size * 1.6))
110
+ label.name = marker
111
+ label.fill.solid()
112
+ label.fill.fore_color.rgb = RGBColor.from_string(background)
113
+ label.text_frame.text = f"{speed}X"
114
+ paragraph = label.text_frame.paragraphs[0]
115
+ paragraph.font.size = Pt(label_size)
116
+ paragraph.font.bold = True
117
+ paragraph.font.color.rgb = RGBColor.from_string(color)
118
+ output.parent.mkdir(parents=True, exist_ok=True)
119
+ with tempfile.TemporaryDirectory(dir=output.parent, prefix=".gif-deck-") as directory:
120
+ temporary = Path(directory) / "deck.pptx"
121
+ prs.save(temporary)
122
+ readback = Presentation(temporary).slides[page - 1]
123
+ actual = next(s for s in readback.shapes if s.name == name)
124
+ if actual.image.blob != gif.read_bytes():
125
+ raise ValueError("GIF did not survive PPT round trip")
126
+ temporary.replace(output)
127
+ return {"page": page, "shape": name, "speed": speed, **info}
128
+
129
+
130
+ def main() -> int:
131
+ parser = argparse.ArgumentParser(description=__doc__)
132
+ commands = parser.add_subparsers(dest="command", required=True)
133
+ encoder = commands.add_parser("convert")
134
+ encoder.add_argument("source", type=Path)
135
+ encoder.add_argument("output", type=Path)
136
+ encoder.add_argument("--speed", type=int)
137
+ encoder.add_argument("--target-seconds", type=float, default=10)
138
+ encoder.add_argument("--speed-multiple", type=int, default=5)
139
+ encoder.add_argument("--fps", type=int, default=20)
140
+ encoder.add_argument("--size", type=int, default=640)
141
+ encoder.add_argument("--timeout", type=float, default=300)
142
+ inserter = commands.add_parser("insert")
143
+ inserter.add_argument("pptx", type=Path)
144
+ inserter.add_argument("gif", type=Path)
145
+ inserter.add_argument("output", type=Path)
146
+ inserter.add_argument("--page", type=int, required=True)
147
+ inserter.add_argument("--shape", required=True)
148
+ inserter.add_argument("--speed", type=int, required=True)
149
+ inserter.add_argument("--label-size", type=float, default=24)
150
+ inserter.add_argument("--color", default="FFFFFF")
151
+ inserter.add_argument("--background", default="C4262E")
152
+ args = parser.parse_args()
153
+ if args.command == "convert":
154
+ report = convert(args.source, args.output, speed=args.speed, target=args.target_seconds,
155
+ multiple=args.speed_multiple, fps=args.fps, size=args.size, timeout=args.timeout)
156
+ else:
157
+ report = insert(args.pptx, args.gif, args.output, args.page, args.shape, args.speed,
158
+ args.label_size, args.color, args.background)
159
+ print(json.dumps(report, ensure_ascii=False))
160
+ return 0
161
+
162
+
163
+ if __name__ == "__main__":
164
+ raise SystemExit(main())
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env python3
2
+ """用 DeepSeek 官方视觉模型(deepseek-v4-flash-vision-exp)审查渲染的 PPT 页面图片。"""
3
+ import sys, base64, json, os, urllib.request
4
+
5
+ BASE = "https://api.deepseek.com"
6
+ MODEL = "deepseek-v4-flash-vision-exp"
7
+
8
+
9
+ def _key():
10
+ """Resolve the vision key from env, then the active DSH credential store.
11
+
12
+ Both the legacy (~/.dsh) and the current (~/.dsh-linux) homes are checked;
13
+ the previous line scan only matched an unindented key and silently sent an
14
+ empty bearer token when the credential lived under `refs:`.
15
+ """
16
+ k = os.environ.get('DEEPSEEK_API_KEY', '').strip()
17
+ if k:
18
+ return k
19
+ homes = [os.environ.get('DSH_HOME', '').strip(),
20
+ os.path.expanduser('~/.dsh-linux'),
21
+ os.path.expanduser('~/.dsh')]
22
+ for home in homes:
23
+ if not home:
24
+ continue
25
+ cred = os.path.join(home, '.credentials.yaml')
26
+ if not os.path.isfile(cred):
27
+ continue
28
+ try:
29
+ for line in open(cred, encoding='utf-8'):
30
+ stripped = line.strip()
31
+ if stripped.startswith('DEEPSEEK_API_KEY:'):
32
+ value = stripped.split(':', 1)[1].strip().strip("'\"")
33
+ if value:
34
+ return value
35
+ except OSError:
36
+ continue
37
+ return ''
38
+
39
+
40
+ def _mime(path):
41
+ ext = os.path.splitext(path)[1].lower()
42
+ return {'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg',
43
+ 'webp': 'image/webp', 'gif': 'image/gif'}.get(ext, 'image/png')
44
+
45
+
46
+ def ask(image_path, question):
47
+ img = base64.b64encode(open(image_path, 'rb').read()).decode()
48
+ mime = _mime(image_path)
49
+ payload = {
50
+ 'model': MODEL,
51
+ 'messages': [{'role': 'user', 'content': [
52
+ {'type': 'text', 'text': question},
53
+ {'type': 'image_url', 'image_url': {'url': f'data:{mime};base64,' + img}},
54
+ ]}],
55
+ }
56
+ req = urllib.request.Request(BASE + '/chat/completions',
57
+ data=json.dumps(payload).encode(),
58
+ headers={'Authorization': 'Bearer ' + _key(), 'Content-Type': 'application/json'})
59
+ r = urllib.request.urlopen(req, timeout=120)
60
+ d = json.loads(r.read().decode())
61
+ try:
62
+ return d['choices'][0]['message']['content']
63
+ except Exception:
64
+ return json.dumps(d, ensure_ascii=False)
65
+
66
+
67
+ if __name__ == '__main__':
68
+ if len(sys.argv) < 2:
69
+ print("用法: python3 vision_check.py <图片> [问题]")
70
+ sys.exit(1)
71
+ q = sys.argv[2] if len(sys.argv) > 2 else "请用中文简短点评这张PPT页面的配色、版式、有没有文字溢出/遮挡/留白过多等问题。"
72
+ if not _key():
73
+ print("ERR: 未找到 DEEPSEEK_API_KEY(env、$DSH_HOME、~/.dsh-linux、~/.dsh 均未命中)")
74
+ sys.exit(2)
75
+ try:
76
+ print(ask(sys.argv[1], q))
77
+ except Exception as e:
78
+ print("ERR:", type(e).__name__, e)
79
+ sys.exit(2)