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()