Compare commits
3 Commits
burn/18-we
...
burn/fix-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fec94a8a | ||
|
|
682c16e0e2 | ||
|
|
f6a46b99ca |
24
.gitea/workflows/smoke.yml
Normal file
24
.gitea/workflows/smoke.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Smoke Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Parse check
|
||||
run: |
|
||||
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
echo "PASS: All files parse"
|
||||
- name: Secret scan
|
||||
run: |
|
||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
|
||||
echo "PASS: No secrets"
|
||||
240
build/build.py
240
build/build.py
@@ -176,8 +176,244 @@ def compile_pdf():
|
||||
except Exception as e:
|
||||
print(f" PDF FAILED: {e}")
|
||||
|
||||
print(" PDF SKIPPED: no PDF engine found (install MacTeX or fix weasyprint)")
|
||||
return False
|
||||
# Fallback 2: reportlab (pure Python, no system deps)
|
||||
return _compile_pdf_reportlab()
|
||||
|
||||
|
||||
def _compile_pdf_reportlab():
|
||||
"""Generate PDF using reportlab — pure Python, no external dependencies."""
|
||||
try:
|
||||
from reportlab.lib.pagesizes import letter
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import inch
|
||||
from reportlab.lib.colors import HexColor
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, PageBreak,
|
||||
Image as RLImage, Table, TableStyle, HRFlowable
|
||||
)
|
||||
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
|
||||
import io
|
||||
try:
|
||||
import qrcode
|
||||
HAS_QRCODE = True
|
||||
except ImportError:
|
||||
HAS_QRCODE = False
|
||||
except ImportError:
|
||||
print(" PDF SKIPPED: no PDF engine found (install MacTeX, fix weasyprint, or pip install reportlab)")
|
||||
return False
|
||||
|
||||
print(" Building PDF (reportlab)...")
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(
|
||||
'BookTitle', parent=styles['Title'],
|
||||
fontSize=28, leading=34, spaceAfter=20,
|
||||
textColor=HexColor('#1a1a2e'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'BookAuthor', parent=styles['Normal'],
|
||||
fontSize=14, leading=18, spaceAfter=40,
|
||||
textColor=HexColor('#555555'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'PartTitle', parent=styles['Heading1'],
|
||||
fontSize=22, leading=28, spaceBefore=40, spaceAfter=12,
|
||||
textColor=HexColor('#16213e'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'PartDesc', parent=styles['Normal'],
|
||||
fontSize=11, leading=15, spaceAfter=30,
|
||||
textColor=HexColor('#666666'), alignment=TA_CENTER, italics=1
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'ChapterTitle', parent=styles['Heading1'],
|
||||
fontSize=20, leading=26, spaceBefore=30, spaceAfter=16,
|
||||
textColor=HexColor('#1a1a2e'), alignment=TA_CENTER
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'BodyText2', parent=styles['Normal'],
|
||||
fontSize=11, leading=16, spaceAfter=8,
|
||||
alignment=TA_JUSTIFY, firstLineIndent=24
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'BodyNoIndent', parent=styles['Normal'],
|
||||
fontSize=11, leading=16, spaceAfter=8,
|
||||
alignment=TA_JUSTIFY
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'SectionBreak', parent=styles['Normal'],
|
||||
fontSize=14, leading=18, spaceBefore=20, spaceAfter=20,
|
||||
alignment=TA_CENTER, textColor=HexColor('#999999')
|
||||
))
|
||||
styles.add(ParagraphStyle(
|
||||
'Footer', parent=styles['Normal'],
|
||||
fontSize=9, textColor=HexColor('#888888'), alignment=TA_CENTER
|
||||
))
|
||||
|
||||
def _make_qr(data, size=80):
|
||||
"""Generate a QR code image as a reportlab Image flowable."""
|
||||
if not HAS_QRCODE:
|
||||
return None
|
||||
qr = qrcode.QRCode(version=1, box_size=4, border=1)
|
||||
qr.add_data(data)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
return RLImage(buf, width=size, height=size)
|
||||
|
||||
def _parse_md_to_flowables(md_text):
|
||||
"""Convert markdown text to reportlab flowables."""
|
||||
flowables = []
|
||||
lines = md_text.split('\n')
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
stripped = line.strip()
|
||||
|
||||
# Horizontal rule
|
||||
if stripped in ('---', '***', '___'):
|
||||
flowables.append(HRFlowable(width="60%", thickness=1,
|
||||
spaceAfter=20, spaceBefore=20, color=HexColor('#cccccc')))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# H1
|
||||
if stripped.startswith('# ') and not stripped.startswith('## '):
|
||||
text = stripped[2:].strip()
|
||||
# Check if it's a part divider or chapter
|
||||
if text.upper().startswith('PART '):
|
||||
flowables.append(PageBreak())
|
||||
flowables.append(Paragraph(text, styles['PartTitle']))
|
||||
elif text.upper().startswith('CHAPTER '):
|
||||
flowables.append(PageBreak())
|
||||
flowables.append(Paragraph(text, styles['ChapterTitle']))
|
||||
elif 'THE TESTAMENT' in text.upper():
|
||||
flowables.append(Spacer(1, 2*inch))
|
||||
flowables.append(Paragraph(text, styles['BookTitle']))
|
||||
else:
|
||||
flowables.append(Spacer(1, 0.3*inch))
|
||||
flowables.append(Paragraph(text, styles['Heading1']))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# H2
|
||||
if stripped.startswith('## '):
|
||||
text = stripped[3:].strip()
|
||||
flowables.append(Spacer(1, 0.2*inch))
|
||||
flowables.append(Paragraph(text, styles['Heading2']))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Italic-only line (part descriptions, epigraphs)
|
||||
if stripped.startswith('*') and stripped.endswith('*') and len(stripped) > 2:
|
||||
text = stripped.strip('*').strip()
|
||||
flowables.append(Paragraph(f'<i>{_escape(text)}</i>', styles['PartDesc']))
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Empty line
|
||||
if not stripped:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Bold text: **text** -> <b>text</b>
|
||||
# Italic text: *text* -> <i>text</i>
|
||||
# Regular paragraph
|
||||
para_text = _md_inline_to_rml(stripped)
|
||||
flowables.append(Paragraph(para_text, styles['BodyText2']))
|
||||
i += 1
|
||||
|
||||
return flowables
|
||||
|
||||
def _escape(text):
|
||||
"""Escape XML special characters."""
|
||||
return (text.replace('&', '&')
|
||||
.replace('<', '<')
|
||||
.replace('>', '>'))
|
||||
|
||||
def _md_inline_to_rml(text):
|
||||
"""Convert inline markdown to reportlab XML markup."""
|
||||
text = _escape(text)
|
||||
# Bold: **text**
|
||||
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
||||
# Italic: *text*
|
||||
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
||||
return text
|
||||
|
||||
# Build the PDF
|
||||
doc = SimpleDocTemplate(
|
||||
str(OUT_PDF),
|
||||
pagesize=letter,
|
||||
leftMargin=1.0*inch,
|
||||
rightMargin=1.0*inch,
|
||||
topMargin=0.8*inch,
|
||||
bottomMargin=0.8*inch,
|
||||
title="The Testament",
|
||||
author="Alexander Whitestone with Timmy",
|
||||
)
|
||||
|
||||
story = []
|
||||
|
||||
# Read the compiled markdown
|
||||
if not OUT_MD.exists():
|
||||
compile_markdown()
|
||||
md_text = OUT_MD.read_text()
|
||||
|
||||
# Parse into flowables
|
||||
story = _parse_md_to_flowables(md_text)
|
||||
|
||||
# Add QR codes page at the end
|
||||
qr_links = {
|
||||
"Read Online": "https://timmyfoundation.org/the-testament",
|
||||
"The Door (Game)": "https://timmyfoundation.org/the-door",
|
||||
"Soundtrack": "https://timmyfoundation.org/soundtrack",
|
||||
"Source Code": "https://forge.alexanderwhitestone.com/Timmy_Foundation/the-testament",
|
||||
}
|
||||
|
||||
if HAS_QRCODE:
|
||||
story.append(PageBreak())
|
||||
story.append(Paragraph("Experience More", styles['PartTitle']))
|
||||
story.append(Spacer(1, 0.3*inch))
|
||||
|
||||
qr_items = []
|
||||
for label, url in qr_links.items():
|
||||
qr_img = _make_qr(url, size=72)
|
||||
if qr_img:
|
||||
cell_content = []
|
||||
cell_content.append(qr_img)
|
||||
cell_content.append(Spacer(1, 6))
|
||||
cell_content.append(Paragraph(f'<b>{label}</b>', styles['Footer']))
|
||||
qr_items.append(cell_content)
|
||||
|
||||
if qr_items:
|
||||
# Arrange QR codes in a 2x2 table
|
||||
rows = []
|
||||
for i in range(0, len(qr_items), 2):
|
||||
row = qr_items[i:i+2]
|
||||
if len(row) == 1:
|
||||
row.append('')
|
||||
rows.append(row)
|
||||
qr_table = Table(rows, colWidths=[2.5*inch, 2.5*inch])
|
||||
qr_table.setStyle(TableStyle([
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 12),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 12),
|
||||
]))
|
||||
story.append(qr_table)
|
||||
|
||||
# Build
|
||||
try:
|
||||
doc.build(story)
|
||||
size = OUT_PDF.stat().st_size
|
||||
print(f" PDF: {OUT_PDF.name} ({size:,} bytes, {size/(1024*1024):.1f} MB)")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" PDF (reportlab) FAILED: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def compile_html():
|
||||
|
||||
@@ -73,8 +73,9 @@ arithmetic. You can fight a judge. You can fight a lawyer. You can't
|
||||
fight confidence intervals.
|
||||
|
||||
He lost custody of Maya. She was four. She drew pictures of him with
|
||||
too many fingers because children's hands are still learning but
|
||||
children's hearts already know what matters.
|
||||
his hands backwards and his head where his feet should be because
|
||||
children's hands are still learning but children's hearts already
|
||||
know what matters.
|
||||
|
||||
David kept the pictures.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ sense something and can't name it. He came because his parole
|
||||
officer's schedule left him alone with his thoughts for eighteen
|
||||
hours a day and his thoughts were not friendly company.
|
||||
|
||||
Robert: fifty-eight, retired after thirty-four years at a plant
|
||||
Robert: seventy-one, retired after thirty-four years at a plant
|
||||
that closed, pension cut in half when the company declared bankruptcy.
|
||||
His wife left him because she couldn't afford to watch a man she
|
||||
loved shrink. He came because his kids were in another state and had
|
||||
|
||||
55
render_batch.py
Normal file
55
render_batch.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import json
|
||||
import os
|
||||
import torch
|
||||
from diffusers import AutoPipelineForText2Image
|
||||
|
||||
# Configuration
|
||||
MANIFEST_PATH = '/Users/apayne/the-testament/visual_manifest.json'
|
||||
OUTPUT_DIR = '/Users/apayne/the-testament/assets/visuals'
|
||||
MODEL_ID = "stabilityai/sd-turbo"
|
||||
|
||||
def load_manifest():
|
||||
with open(MANIFEST_PATH, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
def save_manifest(manifest):
|
||||
with open(MANIFEST_PATH, 'w') as f:
|
||||
json.dump(manifest, f, indent=2)
|
||||
|
||||
def run_batch_render(limit=10):
|
||||
manifest = load_manifest()
|
||||
os.makedirs(OUTPUT_DIR, exist_ok=True)
|
||||
|
||||
print(f"Loading {MODEL_ID} on MPS...")
|
||||
pipe = AutoPipelineForText2Image.from_pretrained(
|
||||
MODEL_ID, torch_dtype=torch.float16, variant="fp16"
|
||||
).to("mps")
|
||||
|
||||
count = 0
|
||||
for beat in manifest:
|
||||
if count >= limit:
|
||||
break
|
||||
if beat.get('status') == 'completed':
|
||||
continue
|
||||
|
||||
text = beat['text']
|
||||
prompt = f"Cinematic film still, moody noir, {text[:150]}, 35mm, muted tones, high contrast, detailed textures"
|
||||
|
||||
filename = f"{beat['chapter'].replace(' ', '_').replace('#', '').replace('—', '_')}_p{beat['paragraph']}.png"
|
||||
path = os.path.join(OUTPUT_DIR, filename)
|
||||
|
||||
try:
|
||||
image = pipe(prompt=prompt, num_inference_steps=1, guidance_scale=0.0).images[0]
|
||||
image.save(path)
|
||||
beat['status'] = 'completed'
|
||||
beat['final_prompt'] = prompt
|
||||
beat['path'] = path
|
||||
count += 1
|
||||
except Exception as e:
|
||||
print(f"Error rendering {filename}: {e}")
|
||||
|
||||
save_manifest(manifest)
|
||||
print(f"Batch complete. Rendered {count} images.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_batch_render(limit=5)
|
||||
41
render_pipeline.py
Normal file
41
render_pipeline.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import torch
|
||||
from diffusers import AutoPipelineForText2Image
|
||||
import os
|
||||
import json
|
||||
|
||||
class SovereignRenderer:
|
||||
def __init__(self, model_id="stabilityai/sd-turbo", output_dir="./the-testament/assets/visuals"):
|
||||
self.output_dir = output_dir
|
||||
os.makedirs(self.output_dir, exist_ok=True)
|
||||
|
||||
print(f"Loading local pipeline: {model_id}...")
|
||||
# Use torch.float16 for Metal performance
|
||||
self.pipe = AutoPipelineForText2Image.from_pretrained(
|
||||
model_id,
|
||||
torch_dtype=torch.float16,
|
||||
variant="fp16"
|
||||
).to("mps") # Metal Performance Shaders
|
||||
print("Pipeline loaded successfully.")
|
||||
|
||||
def generate_image(self, chapter, paragraph, prompt):
|
||||
filename = f"{chapter.replace(' ', '_').replace('#', '').replace('—', '_')}_p{paragraph}.png"
|
||||
path = os.path.join(self.output_dir, filename)
|
||||
|
||||
print(f"Generating: {filename}...")
|
||||
# SD-Turbo is very fast, usually 1-4 steps
|
||||
image = self.pipe(prompt=prompt, num_inference_steps=1, guidance_scale=0.0).images[0]
|
||||
image.save(path)
|
||||
return path
|
||||
|
||||
def create_visual_prompt(text):
|
||||
# In a full implementation, this would call an LLM.
|
||||
# For the prototype, we use a cinematic template.
|
||||
return f"Cinematic film still, moody and melancholic, {text[:100]}, muted colors, high grain, 8k, shot on 35mm"
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage for Chapter 1, Para 1
|
||||
renderer = SovereignRenderer()
|
||||
test_text = "The rain didn't fall so much as it gave up. Mist over a grey city bridge."
|
||||
prompt = create_visual_prompt(test_text)
|
||||
img_path = renderer.generate_image("Chapter 1", 1, prompt)
|
||||
print(f"Saved to {img_path}")
|
||||
6001
visual_manifest.json
Normal file
6001
visual_manifest.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user