@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.
- package/LICENSE +21 -0
- package/README.md +131 -2
- package/asset_catalog.py +159 -0
- package/assets/base_template.pptx +0 -0
- package/assets/cover_deco.png +0 -0
- package/assets/logo.png +0 -0
- package/assets/slide2_right.png +0 -0
- package/assets/theme_apricot.jpg +0 -0
- package/assets/theme_blush.jpg +0 -0
- package/assets/theme_coral.jpg +0 -0
- package/assets/theme_glow.jpg +0 -0
- package/assets/theme_mauve.jpg +0 -0
- package/assets/theme_peach.jpg +0 -0
- package/assets/theme_rose.jpg +0 -0
- package/assets/theme_sunset.jpg +0 -0
- package/assets/topright_deco.png +0 -0
- package/build_deck.py +2288 -0
- package/charts.py +329 -0
- package/check.py +236 -0
- package/config.json +5 -0
- package/contact_sheet.py +88 -0
- package/cordis.patch.yml +457 -0
- package/deck_baseline.py +335 -0
- package/delivery.py +143 -0
- package/density-recipe.md +110 -0
- package/example.json +26 -0
- package/formula.py +125 -0
- package/gen_image.py +395 -0
- package/generate_consensus.py +892 -0
- package/generate_ppt.py +1356 -0
- package/grasp_evidence.py +150 -0
- package/image_check.py +148 -0
- package/image_fetch.py +253 -0
- package/ingest.py +93 -0
- package/media.py +470 -0
- package/package.json +68 -4
- package/preset.yml +2 -0
- package/quality_check.py +196 -0
- package/render_ppt.py +91 -0
- package/requirements.txt +7 -0
- package/restyle.py +80 -0
- package/run_pptfast.sh +27 -0
- package/run_py.sh +43 -0
- package/slide_ops.py +160 -0
- package/slide_patch.py +153 -0
- package/speaker_notes.py +245 -0
- package/style-recipes.md +166 -0
- package/video_gif.py +164 -0
- 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())
|
package/vision_check.py
ADDED
|
@@ -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)
|