58 lines
5.5 KiB
TypeScript
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;
|
|
}
|