@petercjl/competitor-visual-canvas 0.1.0 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/workflow.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import {spawn} from 'node:child_process';
6
6
  import {createRequire} from 'node:module';
7
7
  const dependencyRequire=createRequire(import.meta.url);
8
- function bundledEntry(tool){try{const name=tool==='tbcli'?'@petercjl/tbcli':'@petercjl/sealseek-media-cli';const file=dependencyRequire.resolve(name+'/package.json');const pkg=dependencyRequire(name+'/package.json');return path.join(path.dirname(file),pkg.bin[tool]);}catch{return null;}}
8
+ function bundledEntry(tool){try{const name={'tbcli':'@petercjl/tbcli','sealseek-media':'@petercjl/sealseek-media-cli','boardmixcli':'@petercjl/boardmixcli'}[tool];const file=dependencyRequire.resolve(name+'/package.json');const pkg=dependencyRequire(name+'/package.json');return path.join(path.dirname(file),pkg.bin[tool]);}catch{return null;}}
9
9
 
10
10
  export const fail=(code,message)=>{throw Object.assign(new Error(message),{code});};
11
11
  export const uid=x=>crypto.createHash('sha256').update(JSON.stringify(x)).digest('hex').slice(0,24);
@@ -15,7 +15,7 @@ export async function create(p,v){if(await exists(p))fail('OUTPUT_EXISTS','Outpu
15
15
  export async function checkpoint(p,v){if(await exists(p)){const old=await read(p);if(old.run_id!==v.run_id)fail('STATE_CONFLICT','Run state ownership mismatch.');const backup=p+'.'+crypto.randomUUID()+'.backup';await fs.copyFile(p,backup,fs.constants.COPYFILE_EXCL);if((await fs.stat(backup)).size!==(await fs.stat(p)).size)fail('BACKUP_FAILED','Backup failed.');await fs.writeFile(p,JSON.stringify(v,null,2)+'\n',{mode:0o600});}else await create(p,v);}
16
16
  export const TOOLS_FILE=path.join(os.homedir(),'.config','competitor-visual-canvas','tools.json');
17
17
  export async function configureTool(tool,entry){
18
- if(!['tbcli','sealseek-media'].includes(tool))fail('INVALID_INPUT','Unknown dependency.');
18
+ if(!['tbcli','sealseek-media','boardmixcli'].includes(tool))fail('INVALID_INPUT','Unknown dependency.');
19
19
  if(!path.isAbsolute(entry)||!await exists(entry))fail('INVALID_INPUT','Entry must be an existing absolute CLI path.');
20
20
  const old=await exists(TOOLS_FILE)?await read(TOOLS_FILE):{run_id:'competitor-visual-canvas-tools',owner:'@petercjl/competitor-visual-canvas',entries:{}};
21
21
  if(old.owner!=='@petercjl/competitor-visual-canvas')fail('STATE_CONFLICT','User-owned tool configuration preserved.');
@@ -23,9 +23,9 @@ export async function configureTool(tool,entry){
23
23
  }
24
24
  export async function invoke(tool,args){
25
25
  const cfg=await exists(TOOLS_FILE)?await read(TOOLS_FILE):null;
26
- const entry=process.env[tool==='tbcli'?'TBCLI_ENTRY':'SEALSEEK_MEDIA_ENTRY']||cfg?.entries?.[tool]||bundledEntry(tool)||tool;
26
+ const entry=process.env[{'tbcli':'TBCLI_ENTRY','sealseek-media':'SEALSEEK_MEDIA_ENTRY','boardmixcli':'BOARDMIXCLI_ENTRY'}[tool]]||cfg?.entries?.[tool]||bundledEntry(tool)||tool;
27
27
  const node=/\.mjs$|\.js$/.test(entry),cmd=node?process.execPath:entry,argv=node?[entry,...args]:args;
28
- return new Promise((resolve,reject)=>{const child=spawn(cmd,argv,{env:{...process.env,TBCLI_UPDATE_CHECK:'0',SEALSEEK_MEDIA_AUTO_UPDATE:'0'},stdio:['ignore','pipe','pipe']});let out='',err='';const timer=setTimeout(()=>child.kill(),180000);child.stdout.on('data',x=>out+=x);child.stderr.on('data',x=>err+=x);child.on('error',reject);child.on('close',code=>{clearTimeout(timer);if(code!==0)return reject(Object.assign(new Error(tool+' operation failed: '+(err||out).slice(-1200)),{code:'DEPENDENCY_FAILED'}));try{resolve(JSON.parse(out));}catch{reject(Object.assign(new Error(tool+' returned invalid JSON'),{code:'OUTPUT_CONTRACT_FAILED'}));}});});
28
+ return new Promise((resolve,reject)=>{const child=spawn(cmd,argv,{env:{...process.env,TBCLI_UPDATE_CHECK:'0',SEALSEEK_MEDIA_AUTO_UPDATE:'0',BOARDMIXCLI_UPDATE_CHECK:'0'},stdio:['ignore','pipe','pipe']});let out='',err='';const timer=setTimeout(()=>child.kill(),180000);child.stdout.on('data',x=>out+=x);child.stderr.on('data',x=>err+=x);child.on('error',reject);child.on('close',code=>{clearTimeout(timer);if(code!==0)return reject(Object.assign(new Error(tool+' operation failed: '+(err||out).slice(-1200)),{code:'DEPENDENCY_FAILED'}));try{resolve(JSON.parse(out));}catch{reject(Object.assign(new Error(tool+' returned invalid JSON'),{code:'OUTPUT_CONTRACT_FAILED'}));}});});
29
29
  }
30
30
  export function normalizeInputs(inputs){
31
31
  const seen=new Set(),result=[];
@@ -35,17 +35,18 @@ export function normalizeInputs(inputs){
35
35
  }
36
36
  export function normalizeProduct(input,raw){
37
37
  const d=raw.detail;if(raw.itemId!==input.id||d?.itemId!==input.id)fail('ITEM_ID_MISMATCH','Collection identity mismatch.');if(!d.title||!d.shop?.shopName)fail('OUTPUT_CONTRACT_FAILED','Product title and shop name are required.');
38
- const make=(kind,values)=>{const seen=new Map();values.forEach((value,i)=>{const a=typeof value==='string'?{url:value}:value;if(!a?.url)return;const old=seen.get(a.url);if(old){if(a.skuOptions)old.skuOptions.push(...a.skuOptions);return;}seen.set(a.url,{...a,asset_id:uid([input.id,kind,a.url]),kind:kind==='main_videos'?'video':'image',source_index:i,...(a.skuOptions?{skuOptions:[...a.skuOptions]}:{})});});return [...seen.values()];};
38
+ const make=(kind,values)=>{const seen=new Map();values.forEach((value,i)=>{const a=typeof value==='string'?{url:value}:value;if(!a?.url)return;delete a.local_file;delete a.sha256;const old=seen.get(a.url);if(old){if(a.skuOptions)old.skuOptions.push(...a.skuOptions);return;}seen.set(a.url,{...a,asset_id:uid([input.id,kind,a.url]),kind:kind==='main_videos'?'video':'image',source_index:i,...(a.skuOptions?{skuOptions:[...a.skuOptions]}:{})});});return [...seen.values()];};
39
39
  return {...input,title:d.title,shop:d.shop,coverage:raw.coverage,assets:{
40
40
  main_images:make('main_images',d.mainImages||[]),main_videos:make('main_videos',raw.mainVideos?.records||[]),
41
41
  detail_images:make('detail_images',raw.description?.images||[]),
42
42
  sku_images:make('sku_images',(d.properties||[]).flatMap(p=>p.values.filter(v=>v.imageUrl).map(v=>({url:v.imageUrl,skuOptions:[{pid:p.pid,property:p.name,vid:v.vid,name:v.name,skuIds:(d.skus||[]).filter(s=>s.options.some(o=>o.pid===p.pid&&o.vid===v.vid)).map(s=>s.skuId)}]}))))
43
43
  }};
44
44
  }
45
- export async function collect(run,inputs,rawFiles=[]){
45
+ export async function collect(run,inputs,rawFiles=[],target='infinite-canvas'){
46
+ if(!['infinite-canvas','boardmix'].includes(target))fail('INVALID_TARGET','Choose infinite-canvas or boardmix.');
46
47
  if(await exists(run))fail('OUTPUT_EXISTS','Choose a new run directory.');
47
- const products=normalizeInputs(inputs),run_id=crypto.randomUUID();await fs.mkdir(run,{recursive:true,mode:0o700});await create(path.join(run,'inputs.json'),{run_id,products});
48
- const manifest={schema_version:1,run_id,captured_at:new Date().toISOString(),products:[]};
48
+ const products=normalizeInputs(inputs),run_id=crypto.randomUUID();await fs.mkdir(run,{recursive:true,mode:0o700});await create(path.join(run,'inputs.json'),{run_id,products,target});
49
+ const manifest={schema_version:1,run_id,target,captured_at:new Date().toISOString(),products:[]};
49
50
  for(const input of products){
50
51
  let raw;if(rawFiles.length){for(const file of rawFiles){const r=await read(file);if(r.itemId===input.id){raw=r;break;}}if(!raw)fail('INPUT_MISSING','No raw collection for '+input.id);}else{const out=path.join(run,'raw',input.id+'.json');await fs.mkdir(path.dirname(out),{recursive:true});await invoke('tbcli',['item','media','--item-id',input.id,'--out',out,'--json']);raw=await read(out);}
51
52
  if(rawFiles.length)await create(path.join(run,'raw',input.id+'.json'),raw);
@@ -58,8 +59,9 @@ export function layout(manifest,options={}){
58
59
  if(base&&(base.schema_version!==2||base.presentation_version!==1))fail('LAYOUT_MIGRATION_REQUIRED','Migrate the existing run with reflow before appending.');
59
60
  const {width=320,gap=32,padding=32,frameGap=96,regionGap=240}=base?.layout_options||options;
60
61
  if(![width,gap,padding,frameGap,regionGap].every(Number.isFinite)||width<=0||[gap,padding,frameGap,regionGap].some(x=>x<0))fail('INVALID_INPUT','Invalid layout dimensions.');
62
+ const categories=options.categories||base?.regions?.map(r=>r.category)||REGIONS;
61
63
  const local={};
62
- for(const category of REGIONS){local[category]=[];
64
+ for(const category of categories){local[category]=[];
63
65
  for(const product of manifest.products){
64
66
  const id='frame-'+uid([manifest.run_id,product.id,category]),placements=[];let localX=padding,localY=padding,maxX=padding,maxY=padding,rowHeight=0;
65
67
  for(const [i,a] of product.assets[category].entries()){
@@ -74,7 +76,7 @@ export function layout(manifest,options={}){
74
76
  }
75
77
  const frames=structuredClone(base?.batch.frames||[]),media=structuredClone(base?.batch.media||[]),regions=[];
76
78
  const reserve={main_images:2400,main_videos:1600,sku_images:1840,...options.columnWidths};let regionX=0;
77
- for(const category of REGIONS){
79
+ for(const category of categories){
78
80
  const previous=base?.regions.find(r=>r.category===category);
79
81
  const horizontal=category==='detail_images';
80
82
  const columnWidth=previous?.width??Math.max(reserve[category]||0,...local[category].map(v=>v.frame.width));
@@ -105,6 +107,9 @@ export function validateLayout(plan){
105
107
  return true;
106
108
  }
107
109
  export async function planRun(run,options={}){
110
+ const target=(await read(path.join(run,'assets.manifest.json'))).target||'infinite-canvas';
111
+ if(options.target&&options.target!==target)fail('TARGET_CONFLICT','Requested target differs from the collected run.');
112
+ if(target==='boardmix')return (await import('./boardmix.mjs')).planBoardmix(run,options);
108
113
  for(const file of ['layout.plan.json','canvas.batch.json'])if(await exists(path.join(run,file)))fail('OUTPUT_EXISTS','Plan already exists; preserve it or use a new run.');
109
114
  const manifest=await read(path.join(run,'assets.manifest.json'));let base,baseManifest,state;
110
115
  if(options.baseRun){
@@ -116,7 +121,7 @@ export async function planRun(run,options={}){
116
121
  const cache=new Map(),all=manifest.products.flatMap(p=>Object.values(p.assets).flat()),urls=[...new Map(all.map(a=>[a.url,a])).values()];
117
122
  for(let i=0;i<urls.length;i+=4)await Promise.all(urls.slice(i,i+4).map(async a=>cache.set(a.url,a.width>0&&a.height>0?{width:a.width,height:a.height}:await invoke('sealseek-media',['media','inspect','--url',a.url,'--kind',a.kind,'--json']))));
118
123
  for(const a of all){const d=cache.get(a.url);a.width=d.width;a.height=d.height;}
119
- const plan=layout(manifest,{...options,basePlan:base});
124
+ const plan=layout(manifest,{...options,basePlan:base});plan.target='infinite-canvas';
120
125
  if(base){plan.target_canvas_id=state.canvas_id;plan.base_snapshot=base.batch;manifest.products=[...baseManifest.products,...manifest.products];}
121
126
  await checkpoint(path.join(run,'assets.manifest.json'),manifest);
122
127
  await create(path.join(run,'layout.plan.json'),plan);await create(path.join(run,'canvas.batch.json'),plan.batch);
@@ -125,9 +130,10 @@ export async function planRun(run,options={}){
125
130
  export async function appendRun(run,baseRun,inputs,rawFiles=[]){
126
131
  const previous=await read(path.join(baseRun,'assets.manifest.json')),plan=await read(path.join(baseRun,'layout.plan.json'));
127
132
  if(plan.schema_version!==2)fail('LAYOUT_MIGRATION_REQUIRED','Run reflow on base canvas first.');
133
+ if(plan.target==='boardmix'){const s=await read(path.join(baseRun,'boardmix.state.json'));if(s.status!=='completed'||s.inflight)fail('STATE_CONFLICT','Base Boardmix run must be completed.');if(s.successor_run)fail('STALE_BASE_RUN','Append from the latest run: '+s.successor_run);}
128
134
  const fresh=normalizeInputs(inputs).filter(p=>!previous.products.some(old=>old.id===p.id));
129
135
  if(!fresh.length)return {changed:false,reason:'All requested products are already present.',base_run:baseRun};
130
- await collect(run,fresh.map(p=>p.input),rawFiles);
136
+ await collect(run,fresh.map(p=>p.input),rawFiles,plan.target||'infinite-canvas');
131
137
  return planRun(run,{baseRun});
132
138
  }
133
139
  export function matchesBatch(readback,batch){
@@ -146,7 +152,7 @@ export function reconcileBatch(readback,batch){
146
152
  return result;
147
153
  }
148
154
  export async function publishCanvas(run,submit=false){
149
- const plan=await read(path.join(run,'layout.plan.json'));validateLayout(plan);
155
+ const plan=await read(path.join(run,'layout.plan.json'));if(plan.target==='boardmix')fail('BOARDMIX_CONTEXT_REQUIRED','Use the Boardmix target dispatcher with explicit session and file key.');validateLayout(plan);
150
156
  const file=path.join(run,'canvas.batch.json');let batch=await read(file);if(JSON.stringify(batch)!==JSON.stringify(plan.batch))fail('STATE_CONFLICT','Batch differs from validated layout.');
151
157
  if(!submit)return {dry_run:true,title:plan.title,frames:batch.frames.length,media:batch.media.length};
152
158
  await invoke('sealseek-media',['canvas','batch','preview','--file',file,'--json']);