File size: 12,732 Bytes
b67a1e9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 | #!/usr/bin/env python3
import json, html, os, re, subprocess, textwrap
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
PROJECTS = ROOT / 'inventory' / 'projects.jsonl'
OUT = ROOT / 'explainers'
WEB = OUT / 'web'
VIDEO = OUT / 'video'
COVER = OUT / 'covers'
STORY = OUT / 'storyboards'
JOBS = ROOT / 'jobs' / 'pending'
for d in [WEB, VIDEO, COVER, STORY, JOBS]: d.mkdir(parents=True, exist_ok=True)
PALETTES = {
'agent': ('#020617','#22d3ee','#34d399','#fbbf24'),
'python': ('#0b1020','#60a5fa','#34d399','#f59e0b'),
'node': ('#08130c','#22c55e','#a3e635','#38bdf8'),
'unknown': ('#111827','#a78bfa','#94a3b8','#f472b6'),
}
def slug(s): return re.sub(r'[^a-zA-Z0-9_.-]+','-',s).strip('-').lower()[:100] or 'project'
def load_projects():
rows=[]
for line in PROJECTS.read_text(encoding='utf-8').splitlines():
if line.strip():
try: rows.append(json.loads(line))
except Exception: pass
# de-dupe project_id
seen=set(); out=[]
for r in rows:
pid=r.get('project_id') or slug(r.get('path') or r.get('name') or 'project')
if pid in seen: continue
seen.add(pid); r['project_id']=pid; out.append(r)
return out
def tech_kind(p):
ts=[str(x).lower() for x in p.get('technologies',[]) or p.get('project_type',[])]
if 'agent' in ts or 'agentized' in ts: return 'agent'
if 'python' in ts: return 'python'
if 'node' in ts or 'javascript' in ts or 'typescript' in ts: return 'node'
return 'unknown'
def sentence_from_readme(p):
s=(p.get('readme_summary') or '').replace('\n',' ').strip()
s=re.sub(r'#+\s*','',s)
if not s: return ''
parts=re.split(r'(?<=[.!?])\s+',s)
return parts[0][:220]
def infer(p):
name=p.get('name') or Path(p.get('path','project')).name
path=p.get('path','')
reasons=p.get('score_reasons',[]) or []
tech=', '.join(p.get('technologies',[]) or p.get('project_type',[]) or ['unknown'])
readme=sentence_from_readme(p)
lower=(name+' '+path+' '+readme).lower()
if readme:
idea=readme
elif any(k in lower for k in ['sidecar','adhs','coaching']):
idea='Ein Sidecar/Coaching-Layer, der AI-Coding-Sessions beobachtet und sicherer, fokussierter und weniger chaotisch macht.'
elif any(k in lower for k in ['dashboard','cockpit','zentrale','tower','panel']):
idea='Ein lokales Kontroll-Cockpit für Agenten, Projekte, Sessions, Handoffs und operative Übersicht.'
elif any(k in lower for k in ['harness','arena','eval']):
idea='Ein Harness/Evaluation-System, um Agentenläufe messbar, vergleichbar und verifizierbar zu machen.'
elif any(k in lower for k in ['uni','study','lernen']):
idea='Ein Uni-/Lernsupport-System für Planung, Briefings, Fortschritt und nächste Lernaktionen.'
else:
idea=f'Ein Projektartefakt mit Schwerpunkt {tech}, entdeckt über Repo-/Plan-/TODO-Signale.'
why='Gestartet, um wiederholte Arbeit zu operationalisieren: Kontext sammeln, Entscheidungen sichtbar machen und Agentenarbeit prüfbar machen.'
blockers=[]
if p.get('todo_fixme_count',0): blockers.append(f"{p.get('todo_fixme_count')} TODO/FIXME-Hinweise")
gs=p.get('git_status') or p.get('git',{})
if gs.get('dirty') or gs.get('dirty_count') or gs.get('dirty_tracked_count'): blockers.append('uncommitted changes')
if p.get('plan_hit_count',0): blockers.append(f"{p.get('plan_hit_count')} alte Pläne/Handoffs")
if not p.get('readme_path') and not p.get('readme_summary'): blockers.append('unklare/fehlende README-Evidenz')
not_finished='Nicht fertig, weil Abschluss-Signale fehlen: ' + ', '.join(blockers[:4]) if blockers else 'Nicht eindeutig unfertig; braucht fokussierte Verifikation statt Bauchgefühl.'
value=[]
if 'agent' in tech or any(k in lower for k in ['agent','tower','zentrale','cockpit','sidecar','hermes']): value.append('Agent-Control-System')
if p.get('score',0) >= 60: value.append('hoher Intake-Score')
if p.get('readme_path') or p.get('readme_summary'): value.append('dokumentierte Absicht')
if p.get('is_git_repo'): value.append('Git-Historie')
today='Heute wertvoll als ' + ', '.join(value[:4]) if value else 'Heute wertvoll als Ideenspeicher oder Archiv-Kandidat nach Review.'
return idea, why, not_finished, today
def html_page(p):
name=html.escape(p.get('name') or Path(p.get('path','project')).name)
pid=p['project_id']; kind=tech_kind(p); bg,primary,secondary,accent=PALETTES[kind]
idea,why,not_finished,today=[html.escape(x) for x in infer(p)]
reasons=''.join(f'<li>{html.escape(str(x))}</li>' for x in (p.get('score_reasons') or [])[:8]) or '<li>Keine Score-Reasons vorhanden</li>'
todos=''.join(f"<li><code>{html.escape(str(t.get('file','')))}:{t.get('line','')}</code> — {html.escape(str(t.get('text',''))[:180])}</li>" for t in (p.get('todo_fixme_snippets') or [])[:6]) or '<li>Keine TODO-Snippets im Intake-Kontext.</li>'
tech=', '.join(p.get('technologies',[]) or p.get('project_type',[]) or ['unknown'])
path=html.escape(p.get('path',''))
dirty=html.escape(str((p.get('git_status') or p.get('git',{})).get('dirty') or (p.get('git_status') or {}).get('dirty_tracked_count') or 'unknown'))
return f'''<!doctype html><html><head><meta charset="utf-8"><title>{name} — Visual Explainer</title><style>
body{{margin:0;background:{bg};color:#e5e7eb;font-family:JetBrains Mono,Menlo,monospace}}.wrap{{max-width:1180px;margin:auto;padding:42px}}.hero{{border:1px solid {primary};border-radius:22px;padding:30px;background:linear-gradient(135deg,rgba(255,255,255,.05),rgba(255,255,255,.01))}}h1{{color:{primary};font-size:44px}}h2{{color:{secondary};margin-top:32px}}.grid{{display:grid;grid-template-columns:1fr 1fr;gap:18px}}.card{{border:1px solid #334155;border-radius:16px;padding:18px;background:#0f172acc}}.accent{{color:{accent}}}code{{color:{primary}}}svg{{width:100%;height:330px;background:#020617;border-radius:18px;border:1px solid #1e293b}}li{{margin:8px 0}}a{{color:{primary}}}@media(max-width:800px){{.grid{{grid-template-columns:1fr}}}}</style></head><body><div class="wrap"><section class="hero"><p class="accent">ProjectIntakeTower Visual Explainer</p><h1>{name}</h1><p><code>{path}</code></p><p>Tech: {html.escape(tech)} · Score: {p.get('score','?')} · Risk: {html.escape(str(p.get('risk_level','?')))} · Dirty: {dirty}</p></section>
<h2>Systembild</h2><svg viewBox="0 0 1000 330"><defs><marker id="a" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto"><path d="M0,0 L0,6 L9,3 z" fill="{accent}"/></marker></defs><rect x="30" y="40" width="220" height="90" rx="14" fill="#0f172a" stroke="{primary}"/><text x="50" y="78" fill="{primary}" font-size="22">Originale Idee</text><text x="50" y="108" fill="#e5e7eb" font-size="14">Problem sichtbar machen</text><rect x="390" y="40" width="220" height="90" rx="14" fill="#0f172a" stroke="{secondary}"/><text x="410" y="78" fill="{secondary}" font-size="22">Build-Spuren</text><text x="410" y="108" fill="#e5e7eb" font-size="14">Git · README · Pläne · TODOs</text><rect x="750" y="40" width="220" height="90" rx="14" fill="#0f172a" stroke="{accent}"/><text x="770" y="78" fill="{accent}" font-size="22">Heutiger Wert</text><text x="770" y="108" fill="#e5e7eb" font-size="14">Weiterbauen oder archivieren</text><line x1="250" y1="85" x2="390" y2="85" stroke="{accent}" stroke-width="3" marker-end="url(#a)"/><line x1="610" y1="85" x2="750" y2="85" stroke="{accent}" stroke-width="3" marker-end="url(#a)"/><rect x="170" y="205" width="660" height="70" rx="14" fill="#111827" stroke="#64748b" stroke-dasharray="8 6"/><text x="200" y="248" fill="#cbd5e1" font-size="22">Offener Loop: Abschlussartefakt → Verifier → Issue/Handoff</text></svg>
<div class="grid"><div class="card"><h2>Was war die Idee?</h2><p>{idea}</p></div><div class="card"><h2>Warum angefangen?</h2><p>{why}</p></div><div class="card"><h2>Warum nicht fertig?</h2><p>{not_finished}</p></div><div class="card"><h2>Was ist heute wertvoll?</h2><p>{today}</p></div></div>
<h2>Evidence</h2><div class="grid"><div class="card"><h3>Score-Reasons</h3><ul>{reasons}</ul></div><div class="card"><h3>TODO/FIXME-Auszug</h3><ul>{todos}</ul></div></div>
<p>Storyboard: <a href="../storyboards/{pid}.md">{pid}.md</a> · Video: <a href="../video/{pid}.mp4">{pid}.mp4</a></p></div></body></html>'''
def storyboard(p):
idea,why,not_finished,today=infer(p)
return f"""# Video Storyboard — {p.get('name')}
Path: `{p.get('path')}`
## Scene 1 — Hook
Text: {p.get('name')} war nicht nur ein Ordner, sondern ein Versuch, ein wiederholtes Problem zu kontrollieren.
Visual: Projektname erscheint als leuchtender Knoten.
## Scene 2 — Originale Idee
Text: {idea}
Visual: Problem → System → Outcome.
## Scene 3 — Warum angefangen
Text: {why}
Visual: Chaos aus Plänen/TODOs wird zu einem geordneten Flow.
## Scene 4 — Warum nicht fertig
Text: {not_finished}
Visual: Loop bleibt offen bei Verifier/Closure.
## Scene 5 — Heutiger Wert
Text: {today}
Visual: Drei Optionen: Continue, Merge, Archive.
Verifier: MP4 existiert und HTML verweist darauf.
"""
def draw_cover(p):
pid=p['project_id']; kind=tech_kind(p); bg,primary,secondary,accent=PALETTES[kind]
img=Image.new('RGB',(1280,720),bg)
d=ImageDraw.Draw(img)
try:
font_big=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf',52)
font_med=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',28)
font_small=ImageFont.truetype('/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',22)
except Exception:
font_big=font_med=font_small=None
name=p.get('name') or pid
d.rounded_rectangle((60,60,1220,660),radius=28,outline=primary,width=4,fill=(15,23,42))
d.text((100,110),'VISUAL PROJECT EXPLAINER',fill=accent,font=font_small)
for i,line in enumerate(textwrap.wrap(name,24)[:2]): d.text((100,160+i*62),line,fill=primary,font=font_big)
idea,why,not_finished,today=infer(p)
y=330
for label,text,color in [('Idee',idea,secondary),('Blocker',not_finished,accent),('Wert',today,primary)]:
d.text((110,y),label+':',fill=color,font=font_med)
d.text((270,y),textwrap.shorten(text,width=72,placeholder='…'),fill=(229,231,235),font=font_small)
y+=70
d.text((100,610),str(p.get('path',''))[:95],fill=(148,163,184),font=font_small)
out=COVER/f'{pid}.png'; img.save(out); return out
def render_video(pid, cover):
out=VIDEO/f'{pid}.mp4'
cmd=['ffmpeg','-y','-loop','1','-i',str(cover),'-f','lavfi','-i','anullsrc=channel_layout=stereo:sample_rate=44100','-t','6','-vf','scale=1280:720,format=yuv420p','-c:v','libx264','-preset','ultrafast','-crf','28','-c:a','aac','-shortest',str(out)]
p=subprocess.run(cmd,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
return p.returncode==0 and out.exists() and out.stat().st_size>1000
def main():
projects=load_projects()
ok_video=0
for p in projects:
pid=p['project_id']
(WEB/f'{pid}.html').write_text(html_page(p),encoding='utf-8')
(STORY/f'{pid}.md').write_text(storyboard(p),encoding='utf-8')
cover=draw_cover(p)
if render_video(pid,cover): ok_video+=1
index=['<!doctype html><html><head><meta charset="utf-8"><title>Project Explainers</title><style>body{background:#020617;color:#e5e7eb;font-family:monospace;padding:30px}a{color:#22d3ee}.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:12px}.card{border:1px solid #334155;border-radius:12px;padding:14px;background:#0f172a}</style></head><body><h1>ProjectIntakeTower — Visual Explainers</h1><p>HTML-Seite + MP4 pro Projekt. Keine echten GitHub-Issues, keine Source-Projektänderungen.</p><div class="grid">']
for p in projects:
pid=p['project_id']; name=html.escape(p.get('name') or pid)
index.append(f'<div class="card"><h3>{name}</h3><p>{html.escape(str(p.get("path","")))}</p><a href="web/{pid}.html">Website</a> · <a href="video/{pid}.mp4">Video</a> · <a href="storyboards/{pid}.md">Storyboard</a></div>')
index.append('</div></body></html>')
(OUT/'index.html').write_text('\n'.join(index),encoding='utf-8')
result={'status':'succeeded','projects':len(projects),'web_pages':len(list(WEB.glob('*.html'))),'storyboards':len(list(STORY.glob('*.md'))),'covers':len(list(COVER.glob('*.png'))),'videos':len(list(VIDEO.glob('*.mp4'))),'video_render_ok':ok_video,'index':str(OUT/'index.html')}
(OUT/'result.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
print(json.dumps(result,indent=2))
if __name__=='__main__': main()
|