deck-shader 0.1.0__py3-none-any.whl
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.
- deck_shader/__init__.py +0 -0
- deck_shader/cli.py +283 -0
- deck_shader/presets/cinematic.json +1 -0
- deck_shader/presets/editorial.json +1 -0
- deck_shader/presets/signal.json +1 -0
- deck_shader/template.html +102 -0
- deck_shader-0.1.0.dist-info/METADATA +210 -0
- deck_shader-0.1.0.dist-info/RECORD +12 -0
- deck_shader-0.1.0.dist-info/WHEEL +5 -0
- deck_shader-0.1.0.dist-info/entry_points.txt +2 -0
- deck_shader-0.1.0.dist-info/licenses/LICENSE +21 -0
- deck_shader-0.1.0.dist-info/top_level.txt +1 -0
deck_shader/__init__.py
ADDED
|
File without changes
|
deck_shader/cli.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""deck-shader: deck.json -> WebGL 背景 -> 原生 pptx(每个元素都可编辑)。
|
|
3
|
+
|
|
4
|
+
画布 1280×720,绝对坐标,1 px = 0.75 pt,1 in = 96 px。
|
|
5
|
+
元素模型照抄「代码精准控制」那一套:text / rect / image,全部写死坐标。
|
|
6
|
+
|
|
7
|
+
用法:
|
|
8
|
+
python3 render.py deck.json --out out/deck.pptx
|
|
9
|
+
python3 render.py deck.json --png-only
|
|
10
|
+
python3 render.py deck.json --pages 2,3 # 只渲某几页
|
|
11
|
+
python3 render.py deck.json --preset cinematic
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
CHROME = os.environ.get("CHROME") or next((p for p in [
|
|
23
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
24
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
25
|
+
"/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser",
|
|
26
|
+
] if os.path.exists(p)), None)
|
|
27
|
+
|
|
28
|
+
CW, CH = 1280, 720 # 设计画布
|
|
29
|
+
SW, SH = 1920, 1080 # shader 渲染尺寸 = 画布 ×1.5
|
|
30
|
+
SCALE = SW / CW
|
|
31
|
+
DPI = 96 # 1 in = 96 px -> 1 px = 0.75 pt
|
|
32
|
+
SLIDE_W_IN, SLIDE_H_IN = 13.333, 7.5
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def px_in(v): return v / DPI
|
|
36
|
+
def px_pt(v): return v * 72.0 / DPI
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------- 背景渲染
|
|
40
|
+
|
|
41
|
+
def render_bg(cfg, out_png):
|
|
42
|
+
"""cfg = {preset, time};只画 shader,不带任何文字。"""
|
|
43
|
+
if not CHROME:
|
|
44
|
+
sys.exit("找不到 Chrome,请设置 CHROME=/path/to/chrome")
|
|
45
|
+
tpl = (Path(__file__).parent / "template.html").read_text(encoding="utf-8")
|
|
46
|
+
cfg = dict(cfg)
|
|
47
|
+
# template 里的坐标是 1920×1080 舞台系;把画布坐标 ×1.5 传进去
|
|
48
|
+
for k in ("kickerTop", "titleTop", "subTop", "bodyTop"):
|
|
49
|
+
if k in cfg:
|
|
50
|
+
cfg[k] = cfg[k] * SCALE
|
|
51
|
+
html = tpl.replace("__CFG__", json.dumps(cfg, ensure_ascii=False))
|
|
52
|
+
tmpdir = Path(tempfile.mkdtemp())
|
|
53
|
+
tmp = tmpdir / "s.html"
|
|
54
|
+
tmp.write_text(html, encoding="utf-8")
|
|
55
|
+
try:
|
|
56
|
+
subprocess.run([CHROME, "--headless=new", "--disable-gpu", "--use-gl=angle",
|
|
57
|
+
"--use-angle=swiftshader", f"--screenshot={out_png}",
|
|
58
|
+
f"--window-size={SW},{SH}", "--virtual-time-budget=2500",
|
|
59
|
+
f"file://{tmp}"], check=True,
|
|
60
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
61
|
+
finally:
|
|
62
|
+
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
63
|
+
return out_png
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------- 元素绘制
|
|
67
|
+
|
|
68
|
+
def _set_font(run, name):
|
|
69
|
+
"""python-pptx 的 font.name 只写 latin;中文要补 a:ea。"""
|
|
70
|
+
from pptx.oxml.ns import qn
|
|
71
|
+
run.font.name = name
|
|
72
|
+
rPr = run.font._rPr
|
|
73
|
+
for tag in ("a:ea", "a:cs"):
|
|
74
|
+
el = rPr.find(qn(tag))
|
|
75
|
+
if el is None:
|
|
76
|
+
el = rPr.makeelement(qn(tag), {})
|
|
77
|
+
rPr.append(el)
|
|
78
|
+
el.set("typeface", name)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _style_run(run, spec):
|
|
82
|
+
from pptx.util import Pt
|
|
83
|
+
from pptx.dml.color import RGBColor
|
|
84
|
+
f = run.font
|
|
85
|
+
f.size = Pt(px_pt(spec["sizePt"]))
|
|
86
|
+
f.bold = spec.get("bold", False)
|
|
87
|
+
f.italic = spec.get("italic", False)
|
|
88
|
+
if spec.get("color"):
|
|
89
|
+
f.color.rgb = RGBColor.from_string(spec["color"].lstrip("#").upper())
|
|
90
|
+
_set_font(run, spec.get("font", "PingFang SC"))
|
|
91
|
+
if spec.get("spc"):
|
|
92
|
+
run.font._rPr.set("spc", str(int(spec["spc"] * 100)))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _add_text(slide, el):
|
|
96
|
+
from pptx.util import Inches, Pt
|
|
97
|
+
from pptx.enum.text import MSO_ANCHOR, PP_ALIGN
|
|
98
|
+
|
|
99
|
+
tb = slide.shapes.add_textbox(Inches(px_in(el["x"])), Inches(px_in(el["y"])),
|
|
100
|
+
Inches(px_in(el["w"])), Inches(px_in(el.get("h", 20))))
|
|
101
|
+
tf = tb.text_frame
|
|
102
|
+
tf.word_wrap = el.get("wrap", True)
|
|
103
|
+
tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
|
|
104
|
+
tf.vertical_anchor = {"top": MSO_ANCHOR.TOP, "middle": MSO_ANCHOR.MIDDLE,
|
|
105
|
+
"bottom": MSO_ANCHOR.BOTTOM}.get(el.get("valign", "top"), MSO_ANCHOR.TOP)
|
|
106
|
+
|
|
107
|
+
paras = el.get("paragraphs")
|
|
108
|
+
if paras is None: # 简写:单段单 run
|
|
109
|
+
paras = [{"align": el.get("align", "left"),
|
|
110
|
+
"runs": [{"text": el.get("text", ""),
|
|
111
|
+
"sizePt": el.get("sizePt", 20),
|
|
112
|
+
"color": el.get("color", "#142B40"),
|
|
113
|
+
"font": el.get("font", "PingFang SC"),
|
|
114
|
+
"bold": el.get("bold", False)}]}]
|
|
115
|
+
|
|
116
|
+
amap = {"left": PP_ALIGN.LEFT, "right": PP_ALIGN.RIGHT,
|
|
117
|
+
"center": PP_ALIGN.CENTER, "justify": PP_ALIGN.JUSTIFY}
|
|
118
|
+
for i, p in enumerate(paras):
|
|
119
|
+
para = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
|
|
120
|
+
para.alignment = amap.get(p.get("align", "left"), PP_ALIGN.LEFT)
|
|
121
|
+
if p.get("lineSpacingPct"):
|
|
122
|
+
para.line_spacing = p["lineSpacingPct"] / 100.0
|
|
123
|
+
if p.get("spaceBeforePt"):
|
|
124
|
+
para.space_before = Pt(p["spaceBeforePt"])
|
|
125
|
+
for rs in p.get("runs", []):
|
|
126
|
+
run = para.add_run()
|
|
127
|
+
run.text = rs.get("text", "")
|
|
128
|
+
_style_run(run, rs)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _add_rect(slide, el):
|
|
132
|
+
from pptx.util import Inches, Pt
|
|
133
|
+
from pptx.enum.shapes import MSO_SHAPE
|
|
134
|
+
from pptx.dml.color import RGBColor
|
|
135
|
+
from pptx.oxml.ns import qn
|
|
136
|
+
|
|
137
|
+
sh = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, Inches(px_in(el["x"])),
|
|
138
|
+
Inches(px_in(el["y"])), Inches(px_in(el["w"])),
|
|
139
|
+
Inches(px_in(el.get("h", 1))))
|
|
140
|
+
sh.shadow.inherit = False
|
|
141
|
+
# 去掉主题自带的 <p:style>:它会在 LibreOffice / Keynote 里带出投影和圆角
|
|
142
|
+
style = sh._element.find(qn("p:style"))
|
|
143
|
+
if style is not None:
|
|
144
|
+
sh._element.remove(style)
|
|
145
|
+
if el.get("fill"):
|
|
146
|
+
sh.fill.solid()
|
|
147
|
+
sh.fill.fore_color.rgb = RGBColor.from_string(el["fill"].lstrip("#").upper())
|
|
148
|
+
else:
|
|
149
|
+
sh.fill.background()
|
|
150
|
+
st = el.get("stroke")
|
|
151
|
+
if st:
|
|
152
|
+
sh.line.color.rgb = RGBColor.from_string(st.get("color", "#D8DEE4").lstrip("#").upper())
|
|
153
|
+
sh.line.width = Pt(st.get("widthPt", 1))
|
|
154
|
+
else:
|
|
155
|
+
sh.line.fill.background()
|
|
156
|
+
return sh
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _add_image(slide, el, base):
|
|
160
|
+
from pptx.util import Inches
|
|
161
|
+
src = Path(el["src"])
|
|
162
|
+
if not src.is_absolute():
|
|
163
|
+
src = Path(base) / src
|
|
164
|
+
slide.shapes.add_picture(str(src), Inches(px_in(el["x"])), Inches(px_in(el["y"])),
|
|
165
|
+
Inches(px_in(el["w"])), Inches(px_in(el["h"])))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------- 组装
|
|
169
|
+
|
|
170
|
+
def _legacy(slide):
|
|
171
|
+
"""老的 title/titleTop 写法 -> elements。"""
|
|
172
|
+
out = []
|
|
173
|
+
if slide.get("kicker"):
|
|
174
|
+
out.append({"type": "text", "x": 72, "y": 44, "w": 700, "text": slide["kicker"],
|
|
175
|
+
"sizePt": 10, "color": "#8FA3B8", "font": "Menlo", "spc": 0.42})
|
|
176
|
+
if slide.get("title"):
|
|
177
|
+
out.append({"type": "text", "x": 72, "y": slide.get("titleTop", 300),
|
|
178
|
+
"w": 1136, "text": slide["title"], "sizePt": 66,
|
|
179
|
+
"color": "#FFFFFF", "font": "Songti SC", "bold": True})
|
|
180
|
+
if slide.get("sub"):
|
|
181
|
+
out.append({"type": "text", "x": 74, "y": slide.get("subTop", (slide.get("titleTop", 300)) + 110),
|
|
182
|
+
"w": 1136, "text": slide["sub"], "sizePt": 19,
|
|
183
|
+
"color": "#E8C9A8", "font": "PingFang SC"})
|
|
184
|
+
if slide.get("body"):
|
|
185
|
+
out.append({"type": "text", "x": 72, "y": slide.get("bodyTop", 520),
|
|
186
|
+
"w": 640, "text": slide["body"], "sizePt": 20,
|
|
187
|
+
"color": "#EDE3D6", "font": "PingFang SC"})
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def build_pptx(deck, out, only=None):
|
|
192
|
+
from pptx import Presentation
|
|
193
|
+
from pptx.util import Inches
|
|
194
|
+
|
|
195
|
+
prs = Presentation()
|
|
196
|
+
prs.slide_width = Inches(SLIDE_W_IN)
|
|
197
|
+
prs.slide_height = Inches(SLIDE_H_IN)
|
|
198
|
+
blank = prs.slide_layouts[6]
|
|
199
|
+
base = deck.get("_base", ".")
|
|
200
|
+
pngs = []
|
|
201
|
+
|
|
202
|
+
for i, s in enumerate(deck["slides"]):
|
|
203
|
+
if only and (i + 1) not in only:
|
|
204
|
+
continue
|
|
205
|
+
png = str(Path(out).with_suffix("")) + f"_bg{i}.png"
|
|
206
|
+
if not s.get("background"):
|
|
207
|
+
cfg = {"preset": s.get("preset") or deck.get("preset"),
|
|
208
|
+
"time": s.get("time", i * 1.7)}
|
|
209
|
+
render_bg(cfg, png)
|
|
210
|
+
pngs.append(png)
|
|
211
|
+
|
|
212
|
+
slide = prs.slides.add_slide(blank)
|
|
213
|
+
bg = s.get("background")
|
|
214
|
+
if bg and bg.startswith("#"):
|
|
215
|
+
from pptx.dml.color import RGBColor
|
|
216
|
+
fill = slide.background.fill
|
|
217
|
+
fill.solid()
|
|
218
|
+
fill.fore_color.rgb = RGBColor.from_string(bg.lstrip("#").upper())
|
|
219
|
+
elif bg:
|
|
220
|
+
p = Path(bg)
|
|
221
|
+
if not p.is_absolute():
|
|
222
|
+
p = Path(base) / p
|
|
223
|
+
slide.shapes.add_picture(str(p), 0, 0,
|
|
224
|
+
width=prs.slide_width, height=prs.slide_height)
|
|
225
|
+
else:
|
|
226
|
+
slide.shapes.add_picture(png, 0, 0,
|
|
227
|
+
width=prs.slide_width, height=prs.slide_height)
|
|
228
|
+
|
|
229
|
+
for el in (s.get("elements") or _legacy(s)):
|
|
230
|
+
kind = el.get("type")
|
|
231
|
+
if kind == "text":
|
|
232
|
+
_add_text(slide, el)
|
|
233
|
+
elif kind == "rect":
|
|
234
|
+
_add_rect(slide, el)
|
|
235
|
+
elif kind == "image":
|
|
236
|
+
_add_image(slide, el, base)
|
|
237
|
+
else:
|
|
238
|
+
print(f" ! 第 {i+1} 页未知元素类型 {kind}", file=sys.stderr)
|
|
239
|
+
|
|
240
|
+
if s.get("notes"):
|
|
241
|
+
slide.notes_slide.notes_text_frame.text = s["notes"]
|
|
242
|
+
|
|
243
|
+
prs.save(out)
|
|
244
|
+
return pngs
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main():
|
|
248
|
+
ap = argparse.ArgumentParser(description="deck.json -> 光影背景 -> 可编辑 pptx")
|
|
249
|
+
ap.add_argument("deck")
|
|
250
|
+
ap.add_argument("--out", default="out/deck.pptx")
|
|
251
|
+
ap.add_argument("--preset", default=None, help="cinematic / editorial / signal")
|
|
252
|
+
ap.add_argument("--png-only", action="store_true")
|
|
253
|
+
ap.add_argument("--pages", default="", help="只渲某几页,如 2,3")
|
|
254
|
+
args = ap.parse_args()
|
|
255
|
+
|
|
256
|
+
path = Path(args.deck).resolve()
|
|
257
|
+
deck = json.loads(path.read_text(encoding="utf-8"))
|
|
258
|
+
deck["_base"] = str(path.parent)
|
|
259
|
+
|
|
260
|
+
if args.preset:
|
|
261
|
+
pj = Path(__file__).parent / "presets" / f"{args.preset}.json"
|
|
262
|
+
if not pj.exists():
|
|
263
|
+
sys.exit(f"没有 preset: {args.preset}")
|
|
264
|
+
deck["preset"] = json.loads(pj.read_text(encoding="utf-8"))
|
|
265
|
+
|
|
266
|
+
only = {int(x) for x in args.pages.split(",") if x.strip()} or None
|
|
267
|
+
Path(args.out).parent.mkdir(parents=True, exist_ok=True)
|
|
268
|
+
|
|
269
|
+
if args.png_only:
|
|
270
|
+
pngs = [render_bg({"preset": s.get("preset") or deck.get("preset"),
|
|
271
|
+
"time": s.get("time", i * 1.7)},
|
|
272
|
+
str(Path(args.out).with_suffix("")) + f"_bg{i}.png")
|
|
273
|
+
for i, s in enumerate(deck["slides"])
|
|
274
|
+
if not only or (i + 1) in only]
|
|
275
|
+
print("done:", len(pngs), "张背景")
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
pngs = build_pptx(deck, args.out, only)
|
|
279
|
+
print(f"done: {args.out} | {len(pngs)} 页")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
if __name__ == "__main__":
|
|
283
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"light": "#ffc98a", "origin": [0.55, 0.85], "dir": [-0.42, -1.0], "intensity": 1.0}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"light": "#e8e2d6", "origin": [-0.60, 0.90], "dir": [0.38, -1.0], "intensity": 0.75}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"light": "#7fd1ff", "origin": [0.30, 0.95], "dir": [-0.18, -1.0], "intensity": 1.15}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><style>
|
|
2
|
+
html,body{margin:0;background:#05070d;overflow:hidden;width:1920px;height:1080px}
|
|
3
|
+
canvas{position:absolute;inset:0;width:1920px;height:1080px}
|
|
4
|
+
.layer{position:absolute;inset:0;color:#fff;font-family:"Songti SC","Noto Serif SC",serif}
|
|
5
|
+
.kicker{position:absolute;left:120px;font-family:"Helvetica Neue",sans-serif;font-size:15px;letter-spacing:.42em;color:rgba(255,225,190,.55)}
|
|
6
|
+
.rule{position:absolute;left:120px;width:70px;height:2px;background:linear-gradient(90deg,rgba(255,196,130,.95),rgba(255,196,130,0));box-shadow:0 0 20px rgba(255,170,90,.85)}
|
|
7
|
+
.title{position:absolute;left:120px;font-size:88px;line-height:1.16;margin:0;font-weight:600;letter-spacing:.01em;text-shadow:0 0 70px rgba(255,180,110,.20)}
|
|
8
|
+
.sub{position:absolute;left:124px;font-size:25px;letter-spacing:.18em;color:rgba(255,236,214,.55)}
|
|
9
|
+
.body{position:absolute;left:120px;width:780px;font-size:27px;line-height:1.9;color:rgba(255,244,232,.80)}
|
|
10
|
+
.foot{position:absolute;left:120px;bottom:66px;font-family:"Helvetica Neue",sans-serif;font-size:14px;letter-spacing:.3em;color:rgba(255,220,190,.32)}
|
|
11
|
+
.num{position:absolute;right:120px;bottom:58px;font-family:"Helvetica Neue",sans-serif;font-size:15px;letter-spacing:.2em;color:rgba(255,220,190,.42)}
|
|
12
|
+
</style></head><body>
|
|
13
|
+
<canvas id="c" width="1920" height="1080"></canvas>
|
|
14
|
+
<div class="layer" id="layer"></div>
|
|
15
|
+
<script>
|
|
16
|
+
const CFG = __CFG__;
|
|
17
|
+
const c=document.getElementById('c'),gl=c.getContext('webgl',{preserveDrawingBuffer:true});
|
|
18
|
+
const VS="attribute vec2 p;void main(){gl_Position=vec4(p,0.,1.);}";
|
|
19
|
+
const FS=`
|
|
20
|
+
precision highp float;
|
|
21
|
+
uniform vec2 R; uniform float T;
|
|
22
|
+
uniform vec3 LIGHT; uniform vec2 ORIGIN; uniform vec2 DIR; uniform float INT;
|
|
23
|
+
float h(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
|
|
24
|
+
float n(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
|
|
25
|
+
return mix(mix(h(i),h(i+vec2(1,0)),f.x),mix(h(i+vec2(0,1)),h(i+vec2(1,1)),f.x),f.y);}
|
|
26
|
+
float fbm(vec2 p){float v=0.,a=.5;for(int i=0;i<5;i++){v+=a*n(p);p*=2.03;a*=.5;}return v;}
|
|
27
|
+
vec3 beamAt(vec2 st,float T,float bloom){
|
|
28
|
+
vec2 dir=normalize(DIR); vec2 nor=vec2(-dir.y,dir.x);
|
|
29
|
+
float along=dot(st-ORIGIN,dir);
|
|
30
|
+
float d=dot(st-ORIGIN,nor);
|
|
31
|
+
d+=0.010*sin(along*3.1+T*0.4)+0.006*sin(along*7.7-T*0.23);
|
|
32
|
+
float core=exp(-(d*70.0)*(d*70.0));
|
|
33
|
+
float body=exp(-(d*10.0)*(d*10.0));
|
|
34
|
+
float halo=exp(-(d*2.5)*(d*2.5));
|
|
35
|
+
float taper=smoothstep(-0.45,0.70,along)*smoothstep(1.85,0.95,along);
|
|
36
|
+
float stri=0.55+0.65*fbm(vec2(d*22.0,along*3.4-T*0.05));
|
|
37
|
+
stri*=0.72+0.42*fbm(vec2(d*6.0+3.0,along*1.1-T*0.02));
|
|
38
|
+
return vec3(core*1.9+body*0.95+halo*0.24*bloom)*stri*taper;
|
|
39
|
+
}
|
|
40
|
+
void main(){
|
|
41
|
+
vec2 st=(gl_FragCoord.xy-0.5*R)/R.y;
|
|
42
|
+
vec3 air=beamAt(st,T,1.0);
|
|
43
|
+
vec3 airCol=vec3(0.018,0.025,0.042)+LIGHT*air*INT+vec3(0.25,0.40,0.75)*air*0.08;
|
|
44
|
+
|
|
45
|
+
float horizon=-0.17;
|
|
46
|
+
float k=max(horizon-st.y,0.0);
|
|
47
|
+
vec2 mir=vec2(st.x,horizon+k);
|
|
48
|
+
float rip=fbm(vec2(st.x*3.0,k*9.0-T*0.25));
|
|
49
|
+
mir.x+=(rip-0.5)*0.10*smoothstep(0.0,0.5,k);
|
|
50
|
+
vec3 refl=beamAt(mir,T*0.5,0.7)*0.55;
|
|
51
|
+
float cau=fbm(vec2(st.x*4.0,k*14.0+T*0.06)); cau=cau*cau*cau;
|
|
52
|
+
vec3 floorCol=vec3(0.012,0.016,0.027)
|
|
53
|
+
+LIGHT*refl*0.95
|
|
54
|
+
+LIGHT*cau*0.22
|
|
55
|
+
+vec3(0.030,0.026,0.022)*exp(-k*3.0);
|
|
56
|
+
|
|
57
|
+
float m=smoothstep(horizon+0.035,horizon-0.035,st.y);
|
|
58
|
+
vec3 col=mix(airCol,floorCol,m);
|
|
59
|
+
|
|
60
|
+
float dust=0.0;
|
|
61
|
+
for(int i=0;i<4;i++){
|
|
62
|
+
float fi=float(i);
|
|
63
|
+
vec2 g=st*vec2(16.0+fi*7.0,16.0+fi*7.0)+vec2(fi*13.0,fi*7.0)+vec2(0.,-T*0.035*(1.+fi*0.4));
|
|
64
|
+
vec2 id=floor(g); vec2 f=fract(g)-0.5;
|
|
65
|
+
float rnd=h(id);
|
|
66
|
+
if(rnd>0.955){
|
|
67
|
+
float rad=0.10+0.16*fract(rnd*37.0);
|
|
68
|
+
dust+=smoothstep(rad,0.0,length(f))*smoothstep(0.955,1.0,rnd)*(0.55+0.45*sin(T*1.3+rnd*30.0));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
col+=vec3(1.0,0.90,0.74)*dust*0.36*smoothstep(1.1,-0.1,length(st-ORIGIN));
|
|
72
|
+
col+=vec3(0.12,0.09,0.06)*exp(-((st.y-horizon)*14.0)*((st.y-horizon)*14.0))*0.35;
|
|
73
|
+
col*=1.0-0.60*smoothstep(0.12,-0.62,st.y);
|
|
74
|
+
col*=1.0-0.60*length(st*vec2(0.50,0.78));
|
|
75
|
+
col+=(h(gl_FragCoord.xy+T*11.0)-0.5)*0.010;
|
|
76
|
+
gl_FragColor=vec4(pow(max(col,0.0),vec3(0.92)),1.0);
|
|
77
|
+
}`;
|
|
78
|
+
function sh(t,s){const o=gl.createShader(t);gl.shaderSource(o,s);gl.compileShader(o);if(!gl.getShaderParameter(o,gl.COMPILE_STATUS))document.title='ERR '+gl.getShaderInfoLog(o);return o;}
|
|
79
|
+
const pr=gl.createProgram();gl.attachShader(pr,sh(gl.VERTEX_SHADER,VS));gl.attachShader(pr,sh(gl.FRAGMENT_SHADER,FS));gl.linkProgram(pr);gl.useProgram(pr);
|
|
80
|
+
const b=gl.createBuffer();gl.bindBuffer(gl.ARRAY_BUFFER,b);gl.bufferData(gl.ARRAY_BUFFER,new Float32Array([-1,-1,1,-1,-1,1,1,1]),gl.STATIC_DRAW);
|
|
81
|
+
const loc=gl.getAttribLocation(pr,'p');gl.enableVertexAttribArray(loc);gl.vertexAttribPointer(loc,2,gl.FLOAT,false,0,0);
|
|
82
|
+
const P=CFG.preset||{};
|
|
83
|
+
const rgb=x=>{const m=x.replace('#','');return [parseInt(m.slice(0,2),16)/255,parseInt(m.slice(2,4),16)/255,parseInt(m.slice(4,6),16)/255];};
|
|
84
|
+
gl.uniform2f(gl.getUniformLocation(pr,'R'),1920,1080);
|
|
85
|
+
gl.uniform1f(gl.getUniformLocation(pr,'T'),CFG.time||0);
|
|
86
|
+
gl.uniform3fv(gl.getUniformLocation(pr,'LIGHT'),rgb(P.light||'#ffc98a'));
|
|
87
|
+
gl.uniform2fv(gl.getUniformLocation(pr,'ORIGIN'),new Float32Array(P.origin||[0.55,0.85]));
|
|
88
|
+
gl.uniform2fv(gl.getUniformLocation(pr,'DIR'),new Float32Array(P.dir||[-0.42,-1.0]));
|
|
89
|
+
gl.uniform1f(gl.getUniformLocation(pr,'INT'),P.intensity==null?1:P.intensity);
|
|
90
|
+
gl.drawArrays(gl.TRIANGLE_STRIP,0,4);
|
|
91
|
+
const el=document.getElementById('layer');
|
|
92
|
+
const esc=s=>String(s).replace(/[&<>]/g,m=>({'&':'&','<':'<','>':'>'}[m]));
|
|
93
|
+
let html='';
|
|
94
|
+
if(CFG.kicker)html+=`<div class="kicker" style="top:${CFG.kickerTop||96}px">${esc(CFG.kicker)}</div>`;
|
|
95
|
+
if(CFG.title)html+=`<div class="rule" style="top:${CFG.titleTop-38}px"></div><h1 class="title" style="top:${CFG.titleTop}px">${esc(CFG.title).replace(/\n/g,'<br>')}</h1>`;
|
|
96
|
+
if(CFG.sub)html+=`<div class="sub" style="top:${CFG.subTop}px">${esc(CFG.sub)}</div>`;
|
|
97
|
+
if(CFG.body)html+=`<div class="body" style="top:${CFG.bodyTop}px">${CFG.body.split('\n').map(l=>`<div>${esc(l)}</div>`).join('')}</div>`;
|
|
98
|
+
if(CFG.foot)html+=`<div class="foot">${esc(CFG.foot)}</div>`;
|
|
99
|
+
if(CFG.num)html+=`<div class="num">${esc(CFG.num)}</div>`;
|
|
100
|
+
el.innerHTML=html;
|
|
101
|
+
document.title='READY';
|
|
102
|
+
</script></body></html>
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: deck-shader
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Decks whose light is computed, not painted — WebGL shader backgrounds exported to editable .pptx
|
|
5
|
+
Author: anzy-renlab-ai
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/anzy-renlab-ai/deck-shader
|
|
8
|
+
Project-URL: Repository, https://github.com/anzy-renlab-ai/deck-shader
|
|
9
|
+
Project-URL: Demo, https://anzy-renlab-ai.github.io/deck-shader/
|
|
10
|
+
Keywords: pptx,webgl,shader,slides,presentation,python-pptx
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Requires-Dist: python-pptx>=0.6.21
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
<div align="center">
|
|
18
|
+
|
|
19
|
+
<img src="docs/loop.gif" width="880" alt="deck-shader: a volumetric light beam rendered by a fragment shader, under editable slide text">
|
|
20
|
+
|
|
21
|
+
# deck-shader
|
|
22
|
+
|
|
23
|
+
**Decks whose light is computed, not painted.**
|
|
24
|
+
|
|
25
|
+
Write a JSON file. A shader renders the light — volumetric beams, caustics, drifting dust — and you get a `.pptx` where the text is still editable.
|
|
26
|
+
|
|
27
|
+
`python3 scripts/render.py examples/hello/deck.json`
|
|
28
|
+
|
|
29
|
+
<a href="https://anzy-renlab-ai.github.io/deck-shader/"><b>▶ Try it live in the browser — no install</b></a>
|
|
30
|
+
|
|
31
|
+
<sub>A 6-second render from `examples/hello`. The text is native PowerPoint text; only the light is baked.</sub>
|
|
32
|
+
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## The problem
|
|
38
|
+
|
|
39
|
+
Every slide background you have ever made is one of three things: a flat gradient, a stock photo, or a blurred blob someone spent twenty minutes on.
|
|
40
|
+
|
|
41
|
+
None of them have light. They have *the suggestion of* light — a Photoshop drop shadow, a radial gradient, an overlay at 12% opacity.
|
|
42
|
+
|
|
43
|
+
Light is a physical process. It scatters through dust, bends at a surface, casts caustics on a floor. That is a few lines of math, and your GPU has been able to do it in real time since 2010.
|
|
44
|
+
|
|
45
|
+
So: describe the slide in JSON, render the light with a fragment shader, and lay native PowerPoint text on top.
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
deck.json ──► WebGL shader ──► background.png ──► .pptx (editable text)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Why not just generate the background with a model
|
|
52
|
+
|
|
53
|
+
Because you can't point at it.
|
|
54
|
+
|
|
55
|
+
Ask a text-to-image model for "a warm beam of light cutting through a dark room" and you get something plausible. Ask it again and you get a different room. Change the color and the composition moves with it. There is no *"no, that beam, move it 40px left"* — there is only re-rolling and hoping.
|
|
56
|
+
|
|
57
|
+
A shader is the same picture, but every property is a named number you can edit.
|
|
58
|
+
|
|
59
|
+
| | text-to-image | this |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| change the light's position | re-roll, keep your fingers crossed | `"origin": [0.55, 0.85]` → `[0.42, 0.85]` |
|
|
62
|
+
| run it twice | two different pictures | byte-identical |
|
|
63
|
+
| brand color | drifts between generations | one hex, in one place |
|
|
64
|
+
| the deck's 10 slides look related | if you're lucky | because they share a preset |
|
|
65
|
+
| review a change | look at pixels | `git diff` |
|
|
66
|
+
| who can fix it in six months | someone with the same model | anyone who can read JSON |
|
|
67
|
+
| an agent can check its own work | no | re-render, assert the number |
|
|
68
|
+
|
|
69
|
+
<img src="docs/aigc-vs-shader.png" width="880" alt="Top row: three text-to-image renders from one prompt — three different rooms. Bottom row: three shader renders with only origin.x changed — the same room, the beam moved">
|
|
70
|
+
|
|
71
|
+
Top row: one prompt, three runs, three different rooms. Bottom row: one shader, one number changed, everything else identical. Nothing in the bottom row was re-rolled.
|
|
72
|
+
|
|
73
|
+
The honest tradeoff: a shader cannot give you a photograph. It gives you light, atmosphere, and texture — procedural things. For a person, a product, a street, use a photo. This is the layer that decides whether the photo sits in a scene or on a slide.
|
|
74
|
+
|
|
75
|
+
## Built for the one thing models are actually good at
|
|
76
|
+
|
|
77
|
+
Ask a model to "design a beautiful slide" and you get an opinion. Ask it to make a number 8% bigger, or move a rule 40px left, or swap the light from `#ffc98a` to `#7fd1ff` — and it is flawless.
|
|
78
|
+
|
|
79
|
+
Layout is a coordinate problem. Typography is a set of numbers. Light is math. All of it is expressible, all of it is checkable, none of it is a matter of taste once you've decided what the page says.
|
|
80
|
+
|
|
81
|
+
So the deck is a file with pixel coordinates and a shader, and the model works the way it works best: edit → render → look → edit. Every change compiles into something you can see and `git diff`.
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
you: 把第 3 页的光往左挪一点,标题再小一号
|
|
85
|
+
agent: [edits origin, sizePt] [renders] [opens the png]
|
|
86
|
+
origin x 0.55 → 0.42; title 40 → 36. beam now clears the text.
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
This is the part most "AI makes your slides" tools skip. They put the model in the role of *designer* — judgment, taste, one shot, no way to check. It is far better as a *craftsman*: exact, iterative, verifiable.
|
|
90
|
+
|
|
91
|
+
The deck is a file. The renderer is a script. Nothing in the loop is un-inspectable.
|
|
92
|
+
|
|
93
|
+
It ships with a `SKILL.md`, so Claude Code, WorkBuddy, or any host that reads skills can pick it up as-is.
|
|
94
|
+
|
|
95
|
+
## What you get
|
|
96
|
+
|
|
97
|
+
| | |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `.pptx` | 13.33 × 7.5 in, background is a rendered image, **all text is native and editable** |
|
|
100
|
+
| `.png` | 1920 × 1080 background plates, if you want to compose in Figma or Keynote |
|
|
101
|
+
| `notes` | per-slide speaker notes, written straight into the pptx |
|
|
102
|
+
|
|
103
|
+
## Quickstart
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pip install git+https://github.com/anzy-renlab-ai/deck-shader
|
|
107
|
+
deck-shader deck.json --out out/deck.pptx
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or from a checkout:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
git clone https://github.com/anzy-renlab-ai/deck-shader && cd deck-shader
|
|
114
|
+
pip install python-pptx
|
|
115
|
+
python3 scripts/render.py examples/hello/deck.json --out out/hello.pptx
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Requires Chrome (headless, for the WebGL pass). macOS path is auto-detected; override with `CHROME=/path/to/chrome`.
|
|
119
|
+
|
|
120
|
+
## A slide is a list of elements at exact coordinates
|
|
121
|
+
|
|
122
|
+
<img src="docs/precision-02.png" width="880" alt="A paper-white slide: kicker, title, two columns split by a 1px rule at x=656, one red sentence">
|
|
123
|
+
|
|
124
|
+
Content slides are not "title + bullets + a theme". Every element is placed by
|
|
125
|
+
hand — a text box at `x: 704`, a 1px rule at `x: 656`, a 96px number that is
|
|
126
|
+
allowed to be bigger than the title because on this page it is the subject.
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{ "type": "rect", "x": 656, "y": 208, "w": 1, "h": 116, "fill": "#D8DEE4" },
|
|
130
|
+
{ "type": "text", "x": 704, "y": 238, "w": 504, "h": 120, "paragraphs": [
|
|
131
|
+
{ "align": "left", "lineSpacingPct": 170, "runs": [
|
|
132
|
+
{ "text": "每一页只描述它自己要说的那件事。", "sizePt": 17,
|
|
133
|
+
"color": "#142B40", "font": "PingFang SC" } ] } ] }
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Three element types: `text` (paragraphs → runs, each with its own font, size,
|
|
137
|
+
color, letter-spacing), `rect` (hairlines, fills, dividers), `image`.
|
|
138
|
+
|
|
139
|
+
Canvas is **1280 × 720**. 1 px = 0.75 pt, 1 in = 96 px — the numbers are the
|
|
140
|
+
same ones you'd read off a Figma inspect panel, so you can measure once and
|
|
141
|
+
type it in.
|
|
142
|
+
|
|
143
|
+
<img src="docs/precision-03.png" width="880" alt="Three large numbers — 72, 1, 1 — each with a label and a two-line explanation">
|
|
144
|
+
|
|
145
|
+
The full example is `examples/precision/`.
|
|
146
|
+
|
|
147
|
+
### Why coordinates and not a layout engine
|
|
148
|
+
|
|
149
|
+
A layout engine chooses where things go. That is exactly the thing you cannot
|
|
150
|
+
delegate when the deck has to say something: the sentence that matters gets the
|
|
151
|
+
same weight as the sentence that doesn't, and all ten slides come out looking
|
|
152
|
+
like the same slide.
|
|
153
|
+
|
|
154
|
+
Templates are *selected*. Coordinates are *written*. The difference shows up as
|
|
155
|
+
whether a page can be louder than the page before it.
|
|
156
|
+
|
|
157
|
+
## A deck is a JSON file
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
{
|
|
161
|
+
"preset": { "light": "#ffc98a", "origin": [0.55, 0.85], "dir": [-0.42, -1.0], "intensity": 1.0 },
|
|
162
|
+
"style": { "font": "Songti SC", "titleColor": "FFFFFF" },
|
|
163
|
+
"slides": [
|
|
164
|
+
{
|
|
165
|
+
"kicker": "DECK · SHADER",
|
|
166
|
+
"title": "光线是可以计算的",
|
|
167
|
+
"titleTop": 330,
|
|
168
|
+
"sub": "把 PPT 的背景交给 WebGL",
|
|
169
|
+
"subTop": 470,
|
|
170
|
+
"notes": "开场:先让他们注意到光,再注意到字。"
|
|
171
|
+
}
|
|
172
|
+
]
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| field | meaning |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `preset.light` | light color |
|
|
179
|
+
| `preset.origin` | where the beam enters, in normalized stage coords |
|
|
180
|
+
| `preset.dir` | beam direction |
|
|
181
|
+
| `preset.intensity` | how hard it hits |
|
|
182
|
+
| `time` | shader time; change it and the dust moves, the beam breathes |
|
|
183
|
+
|
|
184
|
+
## Why this exists
|
|
185
|
+
|
|
186
|
+
Two observations.
|
|
187
|
+
|
|
188
|
+
**One.** A deck is read in three seconds, and the first thing anyone perceives is not your words — it is the light in the room. Slides with real light read as *staged*; slides with gradients read as *slides*.
|
|
189
|
+
|
|
190
|
+
**Two.** Agent skills that "generate decks" all stop at layout. They arrange boxes. None of them render anything. The moment you want a背景 that looks photographed, you are back in Photoshop.
|
|
191
|
+
|
|
192
|
+
This is the missing layer: a renderer, not a template.
|
|
193
|
+
|
|
194
|
+
## Roadmap
|
|
195
|
+
|
|
196
|
+
- [x] volumetric beam + floor caustics + dust, parameterized
|
|
197
|
+
- [x] JSON → PNG → editable `.pptx`
|
|
198
|
+
- [x] absolute element model: `text` / `rect` / `image` at 1280×720, 1px = 0.75pt
|
|
199
|
+
- [ ] presets: `editorial`, `glass`, `dawn`, `signal`
|
|
200
|
+
- [ ] video export (15s loop, for social covers)
|
|
201
|
+
- [x] HTML runtime (same shader, live in a browser)
|
|
202
|
+
- [ ] paper mode: arXiv PDF → deck outline → rendered deck
|
|
203
|
+
|
|
204
|
+
## Works as an agent skill
|
|
205
|
+
|
|
206
|
+
`SKILL.md` is included. Drop the folder into `~/.claude/skills/` (Claude Code), `~/.workbuddy/skills/` (WorkBuddy), or any host that reads `SKILL.md`, and the agent can render decks directly.
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
deck_shader/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
deck_shader/cli.py,sha256=mE1xG_Y2INIt8IAKAliHvbOXcrycTXNrTlCp-TzI44Y,10932
|
|
3
|
+
deck_shader/template.html,sha256=bXjsNmzBZoCBfapOab9vn2bgBqHiph7DZkjZC-fO_Aw,6132
|
|
4
|
+
deck_shader/presets/cinematic.json,sha256=b85aOKsvx5_ckB02Fz3sFK7ubJfHSyX4Q6wW5TnODro,85
|
|
5
|
+
deck_shader/presets/editorial.json,sha256=j_ODOVGhRMmDGlXFxdN9s86KsbhiAvTXoxlFFcE3O9A,86
|
|
6
|
+
deck_shader/presets/signal.json,sha256=ziXp-FU_e7BGuobDUZYYEQ8A8q1HcH7vCqHSQcV0QUI,86
|
|
7
|
+
deck_shader-0.1.0.dist-info/licenses/LICENSE,sha256=CTEtBIs1e2IO2SQcAcGUsW0Pt_jfixUfqkh8olTMpDU,1071
|
|
8
|
+
deck_shader-0.1.0.dist-info/METADATA,sha256=k_R6jBQ2wVL1_9dROXKCJlAYcBd-4-v9U67ZZQiNt50,9365
|
|
9
|
+
deck_shader-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
10
|
+
deck_shader-0.1.0.dist-info/entry_points.txt,sha256=ngQzlzZMpJpM8W60tVPSuMdvRW4bNfmeAgzVUw72sWM,53
|
|
11
|
+
deck_shader-0.1.0.dist-info/top_level.txt,sha256=RnPmygPMkL0YA1TIKkpUGSPMmDU4bdnHYcVxmgihap8,12
|
|
12
|
+
deck_shader-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 anzy-renlab-ai
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
deck_shader
|