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.
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=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ deck-shader = deck_shader.cli:main
@@ -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