@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/INSTALL.md +7 -0
- package/README.md +33 -4
- package/UPDATE.md +7 -0
- package/bin/competitor-visual-canvas.mjs +33 -14
- package/capabilities.json +158 -0
- package/package.json +19 -6
- package/skills/competitor-visual-canvas/SKILL.md +24 -8
- package/skills/competitor-visual-canvas/adapters/codex.json +37 -1
- package/skills/competitor-visual-canvas/adapters/generic.json +82 -0
- package/skills/competitor-visual-canvas/adapters/sealseek.json +37 -1
- package/skills/competitor-visual-canvas/adapters/workbuddy.json +37 -1
- package/skills/competitor-visual-canvas/agents/openai.yaml +1 -1
- package/skills/competitor-visual-canvas/capabilities.json +250 -5
- package/skills/competitor-visual-canvas/references/boardmix.md +42 -0
- package/src/boardmix-batch.mjs +85 -0
- package/src/boardmix.mjs +249 -0
- package/src/canvas-target.mjs +104 -0
- package/src/lifecycle.mjs +6 -5
- package/src/skills.mjs +24 -17
- package/src/update.mjs +11 -9
- package/src/validate-package.mjs +7 -0
- package/src/workflow.mjs +19 -13
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=
|
|
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[
|
|
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
|
|
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
|
|
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']);
|