Files
probably-rendering-one-more…/src/lib/snapshots.ts
T
codex-bot 2a5cff3bec
PROMPT Docker CI and deployment / verify-and-deploy (push) Failing after 11m9s
Expose screenshot worker progress and allow complete series rendering in CI
2026-10-23 12:26:58 +08:00

58 lines
5.5 KiB
TypeScript

import {chromium} from "playwright-core";
import {createHash,randomUUID} from "node:crypto";
import {mkdtemp,rm} from "node:fs/promises";
import {tmpdir} from "node:os";
import {join} from "node:path";
import {db,transaction} from "./db";
import {putFile} from "./storage";
import {chapters} from "../content/learning-skills/data";
export async function snapshotState(slug:string){
const post=(await db().query('SELECT id,page_key FROM posts WHERE slug=$1',[slug])).rows[0];
if(!post||!chapters.some(c=>c.key===post.page_key))return null;
const pages=(await db().query('SELECT page_index,asset_id,rendered_at,revision FROM page_snapshots WHERE post_id=$1 ORDER BY page_index',[post.id])).rows;
const job=(await db().query('SELECT status,error,revision,attempts,target_page FROM render_jobs WHERE post_id=$1',[post.id])).rows[0]||null;
return {postId:post.id,pages,job};
}
export async function queueSnapshots(postId:string,pageIndex?:number){
await db().query(`INSERT INTO render_jobs(post_id,status,revision,target_page) VALUES($1,'pending',$2,$3) ON CONFLICT(post_id) DO UPDATE SET status='pending',revision=excluded.revision,target_page=excluded.target_page,attempts=0,error=NULL WHERE render_jobs.status NOT IN ('pending','processing')`,[postId,process.env.RELEASE_SHA||'development',pageIndex??null]);
}
export async function renderOne(){
const job=await transaction(async c=>(await c.query(`UPDATE render_jobs SET status='processing',attempts=attempts+1,locked_at=now() WHERE post_id=(SELECT post_id FROM render_jobs WHERE status='pending' OR (status='processing' AND locked_at<now()-interval '10 minutes') ORDER BY post_id FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING *`)).rows[0]);
if(!job)return false;
const dir=await mkdtemp(join(tmpdir(),'prompt-snapshots-'));let browser;
try{
const post=(await db().query('SELECT slug,page_key FROM posts WHERE id=$1',[job.post_id])).rows[0];
if(!chapters.some(c=>c.key===post.page_key&&c.slug===post.slug))throw Error('Unknown coded page');
browser=await chromium.launch({executablePath:process.env.CHROMIUM_PATH||'/usr/bin/chromium',args:['--no-sandbox','--disable-dev-shm-usage']});
const context=await browser.newContext({viewport:{width:1080,height:1440},deviceScaleFactor:1,locale:'zh-CN',timezoneId:'Asia/Shanghai',reducedMotion:'reduce'});
const origin=new URL(process.env.RENDER_ORIGIN||'http://web:3000').origin;
await context.route('**/*',route=>new URL(route.request().url()).origin===origin?route.continue():route.abort());
const page=await context.newPage();const output:{index:number;id:string}[]=[];
for(let index=job.target_page||1;index<=(job.target_page||8);index++){
console.log('Rendering canvas',post.slug,index);
const response=await page.goto(`${origin}/p/${post.slug}/pages/${index}`,{waitUntil:'networkidle',timeout:45000});
if(!response?.ok())throw Error('Canvas page unavailable');
await page.locator('[data-render-page]').waitFor();await page.evaluate(async()=>{await document.fonts.ready;});
console.log('Canvas loaded',post.slug,index);
const overflow=await page.evaluate(()=>{const sheet=document.querySelector('.learning-sheet');if(!sheet)return false;const footer=sheet.querySelector('footer')!;return Array.from(sheet.children).filter(c=>c!==footer).some(c=>c.getBoundingClientRect().bottom>footer.getBoundingClientRect().top-2)});
if(overflow)throw Error(`Canvas ${index} overlaps its footer`);
const path=join(dir,`${index}.png`);const bytes=await page.locator('[data-render-page]').screenshot({path,animations:'disabled'});
const hash=createHash('sha256').update(bytes).digest('hex');const key=`snapshots/${hash}.png`;const size=await putFile(key,path,'image/png');
const asset=(await db().query(`INSERT INTO assets(id,kind,filename,storage_key,mime_type,byte_size,sha256,width,height) VALUES($1,'image',$2,$3,'image/png',$4,$5,1080,1440) ON CONFLICT(storage_key) DO UPDATE SET storage_key=excluded.storage_key RETURNING id`,[randomUUID(),`图文-${index}.png`,key,size,hash])).rows[0];output.push({index,id:asset.id});console.log('Canvas stored',post.slug,index);
}
await transaction(async c=>{
const current=(await c.query('SELECT revision FROM render_jobs WHERE post_id=$1 FOR UPDATE',[job.post_id])).rows[0];
if(current.revision!==job.revision)return;
for(const result of output){
await c.query(`INSERT INTO page_snapshots(post_id,page_index,asset_id,revision) VALUES($1,$2,$3,$4) ON CONFLICT(post_id,page_index) DO UPDATE SET asset_id=excluded.asset_id,revision=excluded.revision,rendered_at=now()`,[job.post_id,result.index,result.id,job.revision]);
await c.query(`INSERT INTO post_assets(post_id,asset_id,position,role) VALUES($1,$2,$3,'content') ON CONFLICT(post_id,position) DO UPDATE SET asset_id=excluded.asset_id,role='content'`,[job.post_id,result.id,result.index===1?0:result.index+1]);
}
// Position zero remains the shared series cover reference; captions remain at one.
if(output[0].index===1)await c.query('UPDATE posts SET cover_asset_id=$2,updated_at=now() WHERE id=$1',[job.post_id,output[0].id]);
await c.query("UPDATE render_jobs SET status='ready',error=NULL WHERE post_id=$1",[job.post_id]);
});
}catch(e){await db().query("UPDATE render_jobs SET status=$2,error=$3 WHERE post_id=$1 AND revision=$4",[job.post_id,job.attempts<3?'pending':'failed',e instanceof Error?e.message:'Screenshot failed',job.revision]);console.error('Page screenshot job failed',job.post_id,e instanceof Error?e.message:'');await new Promise(r=>setTimeout(r,3000));}
finally{await browser?.close();await rm(dir,{recursive:true,force:true});}
return true;
}