feat: require verified feature demo video in every release
This commit is contained in:
parent
ce84ea577c
commit
535834c060
|
|
@ -12,7 +12,7 @@ A working mobile-first bowel diary with an optional **photo-first AI assist**:
|
||||||
python3 scripts/build_release.py
|
python3 scripts/build_release.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The builder clones the committed `main` tree into an isolated directory, runs unit/security and both mobile acceptance suites, audits dependencies, checks syntax and secrets, excludes model weights and sensitive/generated media, and writes a checksummed archive plus `manifest.json` under `/root/timmy-releases/`. It refuses dirty or non-`main` source trees.
|
The builder clones the committed `main` tree into an isolated directory, runs unit/security and both mobile acceptance suites, audits dependencies, checks syntax and secrets, excludes model weights and sensitive/generated media, records a vertical feature demonstration from the working app, fully decodes and probes that MP4, and writes checksummed source/video artifacts plus `manifest.json` under `/root/timmy-releases/`. It refuses dirty or non-`main` source trees.
|
||||||
|
|
||||||
## Run with the self-hosted open-weight path
|
## Run with the self-hosted open-weight path
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,9 +27,9 @@ SECRET_PATTERNS = (
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run(args: list[str], cwd: Path, *, capture: bool = False) -> str:
|
def run(args: list[str], cwd: Path, *, capture: bool = False, env: dict[str, str] | None = None) -> str:
|
||||||
print("+", " ".join(args), flush=True)
|
print("+", " ".join(args), flush=True)
|
||||||
result = subprocess.run(args, cwd=cwd, check=True, text=True,
|
result = subprocess.run(args, cwd=cwd, check=True, text=True, env=env,
|
||||||
stdout=subprocess.PIPE if capture else None)
|
stdout=subprocess.PIPE if capture else None)
|
||||||
return result.stdout.strip() if capture else ""
|
return result.stdout.strip() if capture else ""
|
||||||
|
|
||||||
|
|
@ -109,6 +109,10 @@ def main() -> int:
|
||||||
raise SystemExit("Acceptance server did not become ready")
|
raise SystemExit("Acceptance server did not become ready")
|
||||||
run(["npm", "run", "test:ui"], tree)
|
run(["npm", "run", "test:ui"], tree)
|
||||||
run(["npm", "run", "test:photo"], tree)
|
run(["npm", "run", "test:photo"], tree)
|
||||||
|
demo_raw = release_dir / f"timmy-talking-turd-{version}-demo.raw.webm"
|
||||||
|
demo_env = dict(server_env)
|
||||||
|
demo_env["TIMMY_RELEASE_VERSION"] = version
|
||||||
|
run(["node", "scripts/record_release_demo.mjs", str(demo_raw)], tree, env=demo_env)
|
||||||
finally:
|
finally:
|
||||||
server.terminate()
|
server.terminate()
|
||||||
try:
|
try:
|
||||||
|
|
@ -117,6 +121,16 @@ def main() -> int:
|
||||||
server.kill()
|
server.kill()
|
||||||
server.wait(timeout=5)
|
server.wait(timeout=5)
|
||||||
server_log.close()
|
server_log.close()
|
||||||
|
demo = release_dir / f"timmy-talking-turd-{version}-demo.mp4"
|
||||||
|
run(["ffmpeg", "-y", "-v", "error", "-i", str(demo_raw), "-vf", "scale=720:1280:flags=lanczos", "-an", "-c:v", "libx264", "-preset", "fast", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", str(demo)], tree)
|
||||||
|
demo_raw.unlink()
|
||||||
|
run(["ffmpeg", "-v", "error", "-i", str(demo), "-f", "null", "-"], tree)
|
||||||
|
probe = json.loads(run(["ffprobe", "-v", "error", "-print_format", "json", "-show_streams", "-show_format", str(demo)], tree, capture=True))
|
||||||
|
video_streams = [stream for stream in probe.get("streams", []) if stream.get("codec_type") == "video"]
|
||||||
|
if len(video_streams) != 1 or video_streams[0].get("codec_name") != "h264" or video_streams[0].get("pix_fmt") != "yuv420p" or video_streams[0].get("width") != 720 or video_streams[0].get("height") != 1280:
|
||||||
|
raise SystemExit("Release demo media probe failed")
|
||||||
|
contact_sheet = release_dir / f"timmy-talking-turd-{version}-demo-contact-sheet.jpg"
|
||||||
|
run(["ffmpeg", "-y", "-v", "error", "-i", str(demo), "-vf", "fps=1/2,scale=180:-1,tile=4x3:padding=4:margin=4", "-frames:v", "1", str(contact_sheet)], tree)
|
||||||
run(["npm", "audit", "--audit-level=high"], tree)
|
run(["npm", "audit", "--audit-level=high"], tree)
|
||||||
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/vision-config.js", "src/vision-service.js"):
|
for file in ("app.js", "server.mjs", "service-worker.js", "src/analysis.js", "src/domain.js", "src/vision-config.js", "src/vision-service.js"):
|
||||||
run(["node", "--check", file], tree)
|
run(["node", "--check", file], tree)
|
||||||
|
|
@ -165,6 +179,7 @@ def main() -> int:
|
||||||
tar.addfile(info)
|
tar.addfile(info)
|
||||||
|
|
||||||
digest = sha256(archive)
|
digest = sha256(archive)
|
||||||
|
demo_digest = sha256(demo)
|
||||||
manifest = {
|
manifest = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"project": "Timmy the Talking Turd",
|
"project": "Timmy the Talking Turd",
|
||||||
|
|
@ -175,6 +190,17 @@ def main() -> int:
|
||||||
"artifact": archive.name,
|
"artifact": archive.name,
|
||||||
"bytes": archive.stat().st_size,
|
"bytes": archive.stat().st_size,
|
||||||
"sha256": digest,
|
"sha256": digest,
|
||||||
|
"feature_demo": {
|
||||||
|
"artifact": demo.name,
|
||||||
|
"bytes": demo.stat().st_size,
|
||||||
|
"sha256": demo_digest,
|
||||||
|
"width": 720,
|
||||||
|
"height": 1280,
|
||||||
|
"codec": "h264",
|
||||||
|
"pixel_format": "yuv420p",
|
||||||
|
"recorded_from_working_app": True,
|
||||||
|
"fixture_data": "synthetic-Type-4 and deterministic provider response",
|
||||||
|
},
|
||||||
"gates": {
|
"gates": {
|
||||||
"unit_security": "passed",
|
"unit_security": "passed",
|
||||||
"mobile_green_path": "passed",
|
"mobile_green_path": "passed",
|
||||||
|
|
@ -183,12 +209,14 @@ def main() -> int:
|
||||||
"syntax": "passed",
|
"syntax": "passed",
|
||||||
"secret_scan": "passed",
|
"secret_scan": "passed",
|
||||||
"forbidden_artifacts": "passed",
|
"forbidden_artifacts": "passed",
|
||||||
|
"feature_demo_full_decode": "passed",
|
||||||
|
"feature_demo_media_probe": "passed",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
manifest_path = release_dir / "manifest.json"
|
manifest_path = release_dir / "manifest.json"
|
||||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||||
(release_dir / "SHA256SUMS").write_text(f"{digest} {archive.name}\n", encoding="utf-8")
|
(release_dir / "SHA256SUMS").write_text(f"{digest} {archive.name}\n{demo_digest} {demo.name}\n", encoding="utf-8")
|
||||||
print(json.dumps({"release_dir": str(release_dir), "archive": str(archive), "manifest": str(manifest_path), "sha256": digest}, indent=2))
|
print(json.dumps({"release_dir": str(release_dir), "archive": str(archive), "feature_demo": str(demo), "manifest": str(manifest_path), "sha256": digest, "feature_demo_sha256": demo_digest}, indent=2))
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
124
scripts/record_release_demo.mjs
Normal file
124
scripts/record_release_demo.mjs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
const output = path.resolve(process.argv[2] || 'release-demo.webm');
|
||||||
|
const version = process.env.TIMMY_RELEASE_VERSION || 'review build';
|
||||||
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
await mkdir(path.dirname(output), { recursive: true });
|
||||||
|
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 405, height: 720 },
|
||||||
|
deviceScaleFactor: 1,
|
||||||
|
serviceWorkers: 'block',
|
||||||
|
recordVideo: { dir: path.dirname(output), size: { width: 405, height: 720 } },
|
||||||
|
colorScheme: 'light',
|
||||||
|
});
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors = [];
|
||||||
|
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.route('**/api/vision-status', route => route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
profile: 'selfhost',
|
||||||
|
processor: 'self-hosted',
|
||||||
|
model: 'SmolVLM2-2.2B-Instruct',
|
||||||
|
providerReady: true,
|
||||||
|
modelSeen: true,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await page.route('**/api/analyze', route => route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
status: 'suggestion',
|
||||||
|
isStool: true,
|
||||||
|
bristolType: 4,
|
||||||
|
color: 'brown',
|
||||||
|
confidence: 0.83,
|
||||||
|
imageQuality: 'good',
|
||||||
|
observations: 'Smooth, formed appearance.',
|
||||||
|
warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
|
||||||
|
await page.evaluate(() => localStorage.clear());
|
||||||
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
await page.addStyleTag({ content: `
|
||||||
|
#demo-caption{position:fixed;left:16px;right:16px;top:14px;z-index:20000;background:rgba(40,33,29,.94);color:#fff;padding:11px 14px;border-radius:14px;font:800 12px/1.25 system-ui;letter-spacing:.02em;text-align:center;box-shadow:0 8px 24px rgba(0,0,0,.2)}
|
||||||
|
#demo-touch{position:fixed;z-index:20001;width:48px;height:48px;border:4px solid #fff;background:rgba(245,201,91,.55);box-shadow:0 0 0 7px rgba(21,125,120,.28);border-radius:50%;pointer-events:none;transform:translate(-50%,-50%)}
|
||||||
|
` });
|
||||||
|
|
||||||
|
async function caption(text, duration = 1400) {
|
||||||
|
await page.evaluate(text => {
|
||||||
|
document.querySelector('#demo-caption')?.remove();
|
||||||
|
const node = document.createElement('div');
|
||||||
|
node.id = 'demo-caption';
|
||||||
|
node.textContent = text;
|
||||||
|
document.body.append(node);
|
||||||
|
node.animate([{ opacity: 0, transform: 'translateY(-8px)' }, { opacity: 1, transform: 'translateY(0)' }], { duration: 260, fill: 'forwards' });
|
||||||
|
}, text);
|
||||||
|
await sleep(duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tap(selector, after = 650) {
|
||||||
|
const target = page.locator(selector).first();
|
||||||
|
await target.scrollIntoViewIfNeeded();
|
||||||
|
const box = await target.boundingBox();
|
||||||
|
if (!box) throw new Error(`Missing demo target: ${selector}`);
|
||||||
|
await page.evaluate(({ x, y }) => {
|
||||||
|
document.querySelector('#demo-touch')?.remove();
|
||||||
|
const ring = document.createElement('div');
|
||||||
|
ring.id = 'demo-touch';
|
||||||
|
ring.style.left = `${x}px`;
|
||||||
|
ring.style.top = `${y}px`;
|
||||||
|
document.body.append(ring);
|
||||||
|
ring.animate([{ opacity: .2, transform: 'translate(-50%,-50%) scale(.55)' }, { opacity: 1, transform: 'translate(-50%,-50%) scale(1)' }], { duration: 400 });
|
||||||
|
setTimeout(() => ring.remove(), 550);
|
||||||
|
}, { x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||||
|
await sleep(220);
|
||||||
|
await target.click();
|
||||||
|
await sleep(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
await caption(`TIMMY ${version} • FEATURE DEMO`, 1300);
|
||||||
|
await caption('New path: private, photo-first stool logging', 1100);
|
||||||
|
await tap('[data-scan]', 500);
|
||||||
|
await page.getByText(/Self-hosted model ready/i).waitFor();
|
||||||
|
await caption('The self-hosted model is ready — no third-party moderation gate', 1300);
|
||||||
|
await page.locator('#ai-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg');
|
||||||
|
await caption('The photo stays unsaved until explicit consent', 1300);
|
||||||
|
await page.locator('#ai-consent').check();
|
||||||
|
await tap('#analyze-photo', 500);
|
||||||
|
await page.getByText(/83% confidence/i).waitFor();
|
||||||
|
await caption('Timmy suggests only visible form and broad color', 1500);
|
||||||
|
await tap('#use-suggestion', 600);
|
||||||
|
await caption('Nothing persists until the user reviews or corrects it', 1500);
|
||||||
|
await page.locator('[data-type="4"]').scrollIntoViewIfNeeded();
|
||||||
|
await sleep(700);
|
||||||
|
await page.evaluate(() => {
|
||||||
|
document.querySelector('#demo-caption')?.remove();
|
||||||
|
const outro = document.createElement('div');
|
||||||
|
outro.id = 'release-outro';
|
||||||
|
outro.innerHTML = '<img src="/assets/timmy.svg"><strong>SELF-HOSTED.<br>USER-CONFIRMED.</strong><span>Visual assistance — never a diagnosis.</span>';
|
||||||
|
Object.assign(outro.style, { position:'fixed', inset:'0', zIndex:'30000', background:'linear-gradient(145deg,#f7f3ea,#f5c95b)', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', textAlign:'center', color:'#28211d', fontFamily:'system-ui', opacity:'0' });
|
||||||
|
outro.querySelector('img').style.cssText = 'width:150px;height:150px;filter:drop-shadow(0 14px 16px rgba(63,37,30,.18))';
|
||||||
|
outro.querySelector('strong').style.cssText = 'font-size:31px;line-height:1.05;margin:18px 0 12px;letter-spacing:-.04em';
|
||||||
|
outro.querySelector('span').style.cssText = 'font-size:13px;font-weight:800;color:#176957';
|
||||||
|
document.body.append(outro);
|
||||||
|
outro.animate([{opacity:0},{opacity:1}], {duration:500,fill:'forwards'});
|
||||||
|
});
|
||||||
|
await sleep(2200);
|
||||||
|
|
||||||
|
if (errors.length) throw new Error(`Browser errors: ${errors.join(' | ')}`);
|
||||||
|
const recording = page.video();
|
||||||
|
await page.close();
|
||||||
|
await recording.saveAs(output);
|
||||||
|
await context.close();
|
||||||
|
await browser.close();
|
||||||
|
console.log(output);
|
||||||
Loading…
Reference in New Issue
Block a user