@petercjl/competitor-visual-canvas 0.1.1 → 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.
@@ -0,0 +1,249 @@
1
+ import {writeBatches} from './boardmix-batch.mjs';
2
+ import fs from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import {invoke,read,exists,create,checkpoint,fail,layout,validateLayout,uid,REGIONS,collect} from './workflow.mjs';
6
+
7
+ export const IMAGE_REGIONS=['main_images','sku_images','detail_images'];
8
+ const MEDIA_REGIONS=REGIONS;
9
+ const near=(a,b)=>Number.isFinite(a)&&Math.abs(a-b)<0.02;
10
+ const geometry=(a,b)=>['x','y','width','height'].every(k=>near(a[k],b[k]));
11
+ async function call(command,args=[],run=invoke){
12
+ const out=await run('boardmixcli',[command,...args]);
13
+ if(out.ok===false)fail('BOARDMIX_FAILED',out.error||out.message||'Boardmix operation failed.');
14
+ return out.result??out;
15
+ }
16
+ export async function requireBoardmix(run=invoke){
17
+ const caps=await call('capabilities',['--json'],run);
18
+ for(const c of ['image.download','image.inspect','image.import','frame.create','text.create','object.get','object.resize','object.move','board.info'])if(!caps.commands?.includes(c))fail('CAPABILITY_UNAVAILABLE','boardmixcli requires '+c+'; update its capability package.');
19
+ return caps;
20
+ }
21
+ export async function planBoardmix(dir,options={},run=invoke){
22
+ let base,baseManifest,baseState;
23
+ if(options.baseRun){
24
+ base=await loadPlan(options.baseRun);baseState=await loadState(options.baseRun,base);baseManifest=await read(path.join(options.baseRun,'assets.manifest.json'));
25
+ if(baseState.status!=='completed'||!baseState.file_key||baseState.inflight)fail('STATE_CONFLICT','Base Boardmix run must be completed.');
26
+ if(baseState.successor_run)fail('STALE_BASE_RUN','Append from the latest run: '+baseState.successor_run);
27
+ }
28
+ for(const f of ['layout.plan.json','boardmix.batch.json'])if(await exists(path.join(dir,f)))fail('OUTPUT_EXISTS','Plan already exists.');
29
+ await requireBoardmix(run);
30
+ const manifest=await read(path.join(dir,'assets.manifest.json'));
31
+ if(baseManifest&&manifest.products.some(p=>baseManifest.products.some(b=>b.id===p.id)))fail('PRODUCT_ALREADY_PRESENT','Product already exists in the base canvas.');
32
+ if(manifest.target!=='boardmix')fail('TARGET_CONFLICT','Collect with --target boardmix before planning.');
33
+ await fs.mkdir(path.join(dir,'media'),{recursive:true});
34
+ // This URL is a verified 1x1 lazy-loading placeholder, not product artwork.
35
+ let filtered=false;
36
+ for(const product of manifest.products){
37
+ const placeholder=product.assets.detail_images.filter(a=>a.url==='https://assets.alicdn.com/kissy/1.0.0/build/imglazyload/spaceball.gif');
38
+ if(placeholder.length){product.assets.detail_images=product.assets.detail_images.filter(a=>!placeholder.includes(a));product.excluded_assets=[...(product.excluded_assets||[]),...placeholder.map(a=>({...a,reason:'Known 1x1 lazy-loading placeholder'}))];filtered=true;}
39
+ }
40
+ if(filtered)await checkpoint(path.join(dir,'assets.manifest.json'),manifest);
41
+ for(const product of manifest.products)for(const category of MEDIA_REGIONS)for(const a of product.assets[category]){
42
+ const prefix=category==='main_videos'?'video':'image';
43
+ if(a.local_file){const data=await call(prefix+'.inspect',['--file',assetPath(dir,a)],run);if(data.sha256!==a.sha256)fail('ASSET_CHANGED','A saved source image changed.');continue;}
44
+ const pending=path.join(dir,'media',a.asset_id+'.download');
45
+ let data;
46
+ if(await exists(pending))data=await call(prefix+'.inspect',['--file',pending],run);
47
+ else data=await call(prefix+'.download',['--url',a.url,'--output',pending],run);
48
+ const ext={'video/mp4':'mp4','image/png':'png','image/jpeg':'jpg','image/webp':'webp'}[data.mime];if(!ext)fail('FEATURE_UNSUPPORTED','Unsupported image format.');
49
+ // Persist the downloaded receipt before rename, so an interrupted plan can inspect the same bytes.
50
+ const relative=path.join('media',a.asset_id+'.'+ext),dest=path.join(dir,relative);
51
+ if(await exists(dest)){
52
+ const old=await call(prefix+'.inspect',['--file',dest],run);if(old.sha256!==data.sha256)fail('ASSET_CHANGED','Downloaded source differs from saved destination.');
53
+ }else await fs.rename(pending,dest);
54
+ const final=await call(prefix+'.inspect',['--file',dest],run);
55
+ Object.assign(a,{local_file:relative,mime:final.mime,width:final.width,height:final.height,sha256:final.sha256,...(final.duration?{duration:final.duration}:{})});
56
+ await checkpoint(path.join(dir,'assets.manifest.json'),manifest);
57
+ }
58
+ if(base&&!base.regions.some(r=>r.category==='main_videos')&&manifest.products.some(p=>p.assets.main_videos.length))fail('LAYOUT_MIGRATION_REQUIRED','Run boardmix videos on the existing board before appending videos.');
59
+ const plan=layout(manifest,{...options,basePlan:base,categories:base?base.regions.map(r=>r.category):MEDIA_REGIONS});
60
+ if(base){
61
+ for(const product of baseManifest.products)for(const category of MEDIA_REGIONS)for(const a of product.assets[category]){
62
+ const source=assetPath(options.baseRun,a),dest=assetPath(dir,a);const bytes=await fs.readFile(source);
63
+ if(crypto.createHash('sha256').update(bytes).digest('hex')!==a.sha256)fail('ASSET_CHANGED','Base source image changed.');
64
+ if(await exists(dest)){if(crypto.createHash('sha256').update(await fs.readFile(dest)).digest('hex')!==a.sha256)fail('ASSET_CHANGED','Copied asset differs.');}
65
+ else await fs.copyFile(source,dest,fs.constants.COPYFILE_EXCL);
66
+ }
67
+ manifest.products=[...structuredClone(baseManifest.products),...manifest.products];
68
+ await checkpoint(path.join(dir,'assets.manifest.json'),manifest);
69
+ plan.target_file_key=baseState.file_key;
70
+ }
71
+ plan.target='boardmix';
72
+ plan.asset_hashes=Object.fromEntries(manifest.products.flatMap(p=>MEDIA_REGIONS.flatMap(c=>p.assets[c].map(a=>[a.asset_id,a.sha256]))));
73
+ // Boardmix Frame captions keep a screen-sized label; reserve extra heading clearance.
74
+ if(!base)for(const text of plan.batch.texts){text.y-=168;text.fontSize=48;text.height=72;}
75
+ plan.omitted_media=[];
76
+ await create(path.join(dir,'layout.plan.json'),plan);
77
+ await create(path.join(dir,'boardmix.batch.json'),plan.batch);
78
+ if(base)await create(statePath(dir),{run_id:plan.run_id,target:'boardmix',plan_hash:uid(plan),file_key:baseState.file_key,objects:structuredClone(baseState.objects),base_run:path.resolve(options.baseRun)});
79
+ return {run_id:plan.run_id,target:'boardmix',title:plan.title,frames:plan.batch.frames.length,images:plan.batch.media.filter(m=>m.kind==='image').length,videos:plan.batch.media.filter(m=>m.kind==='video').length,omitted_media:plan.omitted_media,...(base?{appending:true,file_key:baseState.file_key,base_run:path.resolve(options.baseRun)}:{})};
80
+ }
81
+ function assetPath(dir,a){if(!a?.local_file||!/^media[\\/][a-f0-9]{24}\.(png|jpg|webp|mp4)$/.test(a.local_file)||!/^([a-f0-9]{64})$/.test(a.sha256||''))fail('ASSET_MANIFEST_INVALID','Expected a managed downloaded image.');return path.join(dir,a.local_file);}
82
+ function context(options){if(!options.session||!options.fileKey)fail('BOARDMIX_CONTEXT_REQUIRED','Provide --session and the exact --file-key.');return ['--session',options.session,'--file-key',options.fileKey];}
83
+ async function lock(dir,action){
84
+ const p=path.join(dir,'boardmix.lock');
85
+ let handle;
86
+ try{handle=await fs.open(p,'wx',0o600);}catch(e){
87
+ if(e.code!=='EEXIST')throw e;
88
+ const old=await read(p);if(old.owner!=='@petercjl/competitor-visual-canvas'||!Number.isSafeInteger(old.pid))fail('RUN_LOCKED','Run lock ownership is unknown.');
89
+ try{process.kill(old.pid,0);fail('RUN_LOCKED','Another process owns this run.');}catch(err){if(err.code!=='ESRCH')throw err;}
90
+ await fs.rename(p,p+'.'+crypto.randomUUID()+'.backup');handle=await fs.open(p,'wx',0o600);
91
+ }
92
+ try{await handle.writeFile(JSON.stringify({owner:'@petercjl/competitor-visual-canvas',pid:process.pid}));return await action();}
93
+ finally{await handle.close();await fs.unlink(p);}
94
+ }
95
+ const statePath=dir=>path.join(dir,'boardmix.state.json');
96
+ async function loadState(dir,plan){
97
+ const state=await exists(statePath(dir))?await read(statePath(dir)):{run_id:plan.run_id,target:'boardmix',plan_hash:uid(plan),objects:{}};
98
+ if(state.run_id!==plan.run_id||state.target!=='boardmix'||state.plan_hash!==uid(plan))fail('STATE_CONFLICT','Boardmix state or plan identity differs.');return state;
99
+ }
100
+ async function loadPlan(dir){const plan=await read(path.join(dir,'layout.plan.json'));if(plan.target!=='boardmix')fail('TARGET_CONFLICT','This run targets Infinite Canvas.');validateLayout(plan);return plan;}
101
+ export async function createBoardmix(dir,options={},run=invoke){
102
+ const plan=await loadPlan(dir),ctx=context(options);await requireBoardmix(run);
103
+ return lock(dir,async()=>{
104
+ const state=await loadState(dir,plan);
105
+ async function browserReady(key){
106
+ await call('browser.open',['--endpoint',options.cdpEndpoint,'--file-key',key],run);
107
+ await call('browser.connect',['--endpoint',options.cdpEndpoint,'--session',options.session,'--file-key',key],run);
108
+ }
109
+ if(state.file_key){if(options.cdpEndpoint)await browserReady(state.file_key);return {target:'boardmix',file_key:state.file_key,canvas_url:'https://boardmix.cn/app/editor/'+state.file_key,needs_attachment:!options.cdpEndpoint,changed:false};}
110
+ if(state.inflight)fail('SUBMISSION_UNCERTAIN','Inspect the previous create outcome before binding an existing file key.');
111
+ const source=await call('board.info',ctx,run);
112
+ state.inflight={key:'board.create',kind:'board'};await checkpoint(statePath(dir),state);
113
+ const board=await call('board.create',[...ctx,'--team-id',String(source.teamId),'--project-id',String(source.projectId),'--folder-id',String(source.folderId||0)],run);
114
+ if(!board.fileKey)fail('SUBMISSION_UNCERTAIN','Boardmix did not return a file key.');
115
+ state.file_key=board.fileKey;state.created_by_run=true;delete state.inflight;await checkpoint(statePath(dir),state);
116
+ if(options.cdpEndpoint)await browserReady(board.fileKey);else await call('board.open',[...ctx,'--target-file-key',board.fileKey],run);
117
+ return {target:'boardmix',file_key:board.fileKey,canvas_url:'https://boardmix.cn/app/editor/'+board.fileKey,needs_attachment:!options.cdpEndpoint,next:options.cdpEndpoint?'Connected in browser; continue canvas --submit.':'Attach the returned editor to the same session, then canvas --submit.'};
118
+ });
119
+ }
120
+ function descriptors(plan){return [...plan.batch.texts.map(t=>({id:t.id,kind:'text',...t})),...plan.batch.frames.map(f=>({id:f.id,kind:'frame',...f})),...plan.batch.media.map(m=>({id:m.id,kind:m.kind,media:m,...m.placement.positions[0]}))];}
121
+ export async function writeBoardmix(dir,options={},run=invoke){
122
+ const plan=await loadPlan(dir),state=await loadState(dir,plan);
123
+ if(state.successor_run&&options.submit)fail('STALE_BASE_RUN','Use latest run: '+state.successor_run);
124
+ if(options.submit&&state.base_run)return lock(state.base_run,async()=>{
125
+ const parent=await read(statePath(state.base_run));
126
+ if(parent.successor_run&&parent.successor_run!==path.resolve(dir))fail('STALE_BASE_RUN','Use latest run: '+parent.successor_run);
127
+ const result=await writeBoardmixInner(dir,options,run);
128
+ if(!parent.successor_run){parent.successor_run=path.resolve(dir);await checkpoint(statePath(state.base_run),parent);}
129
+ return result;
130
+ });
131
+ return writeBoardmixInner(dir,options,run);
132
+ }
133
+ async function writeBoardmixInner(dir,options={},run=invoke){
134
+ const plan=await loadPlan(dir),batch=await read(path.join(dir,'boardmix.batch.json'));
135
+ if(JSON.stringify(batch)!==JSON.stringify(plan.batch))fail('STATE_CONFLICT','Batch differs from validated plan.');
136
+ if(!options.submit)return {dry_run:true,target:'boardmix',title:plan.title,frames:batch.frames.length,images:batch.media.filter(m=>m.kind==='image').length,videos:batch.media.filter(m=>m.kind==='video').length,omitted_media:plan.omitted_media};
137
+ const ctx=context(options);const caps=await requireBoardmix(run);
138
+ if(options.cdpEndpoint){await call('browser.open',['--endpoint',options.cdpEndpoint,'--file-key',options.fileKey],run);await call('browser.connect',['--endpoint',options.cdpEndpoint,...context(options)],run);}
139
+ if(batch.media.some(m=>m.kind==='video')&&!caps.commands.includes('video.import'))fail('CAPABILITY_UNAVAILABLE','Update boardmixcli for video.import.');
140
+ const fast=['image.inspect.batch','image.import.batch','object.get.batch','object.place.batch'].every(c=>caps.commands.includes(c));
141
+ return lock(dir,async()=>{
142
+ const state=await loadState(dir,plan);
143
+ if(state.file_key&&state.file_key!==options.fileKey)fail('TARGET_CONFLICT','The run is bound to another Boardmix file.');
144
+ if(state.inflight&&!(fast&&['image.batch','video'].includes(state.inflight.kind)))throw Object.assign(Error('Previous creation/import outcome must be inspected before recovery.'),{code:'SUBMISSION_UNCERTAIN',details:state.inflight});
145
+ const board=await call('board.info',ctx,run);if(board.fileKey!==options.fileKey)fail('TARGET_CONFLICT','Connected editor identity differs.');
146
+ state.file_key=options.fileKey;await checkpoint(statePath(dir),state);
147
+ const manifest=await read(path.join(dir,'assets.manifest.json'));
148
+ const assets=new Map(manifest.products.flatMap(p=>MEDIA_REGIONS.flatMap(c=>p.assets[c].map(a=>[a.asset_id,a]))));
149
+ if(!fast&&batch.media.some(m=>m.kind==='video'))fail('CAPABILITY_UNAVAILABLE','Video runs require batch placement capabilities.');
150
+ if(fast){
151
+ if(state.created_by_run&&board.name!==plan.title)await call('board.rename',[...ctx,'--name',plan.title],run);
152
+ await writeBatches({dir,plan,state,assets,ctx,call:(c,a)=>call(c,a,run),descriptors:descriptors(plan),assetPath});
153
+ }else{
154
+ for(const media of batch.media){const a=assets.get(media.sourceMetadata.assetId);if(!a||plan.asset_hashes&&plan.asset_hashes[a.asset_id]!==a.sha256||a.url!==media.urls[0])fail('ASSET_CHANGED','Source manifest differs from the immutable plan.');const info=await call('image.inspect',['--file',assetPath(dir,a)],run);if(info.sha256!==a.sha256)fail('ASSET_CHANGED','Source bytes changed before write.');}
155
+ if(state.created_by_run&&board.name!==plan.title)await call('board.rename',[...ctx,'--name',plan.title],run);
156
+ for(const d of descriptors(plan)){
157
+ let saved=state.objects[d.id];
158
+ const local=d.kind==='image'?assets.get(d.media.sourceMetadata.assetId):null;
159
+ if(local){if(plan.asset_hashes&&plan.asset_hashes[local.asset_id]!==local.sha256)fail('ASSET_CHANGED','Source manifest differs from the immutable image plan.');const info=await call('image.inspect',['--file',assetPath(dir,local)],run);if(info.sha256!==local.sha256)fail('ASSET_CHANGED','Local image bytes changed.');}
160
+ if(!saved){
161
+ state.inflight={key:d.id,kind:d.kind};await checkpoint(statePath(dir),state);
162
+ const result=await call(d.kind==='image'?'image.import':d.kind+'.create',d.kind==='image'?[...ctx,'--file',assetPath(dir,local)]:[...ctx,'--x',String(d.x),'--y',String(d.y),'--width',String(d.width),'--height',String(d.height),d.kind==='frame'?'--name':'--text',d.kind==='frame'?d.name:d.text,...(d.kind==='text'?['--font-size',String(d.fontSize||28)]:[])],run);
163
+ const obj=result.object??result.readback;if(!obj?.guid)fail('SUBMISSION_UNCERTAIN','Created object GUID missing.');
164
+ saved=state.objects[d.id]={guid:obj.guid,kind:d.kind,placed:false,resource_hash:obj.resourceHash};delete state.inflight;await checkpoint(statePath(dir),state);
165
+ }
166
+ const ids=['--session-id',String(saved.guid.sessionID),'--local-id',String(saved.guid.localID)];
167
+ let obj=await call('object.get',[...ctx,...ids],run);
168
+ if(local&&Math.abs(obj.width/obj.height-local.width/local.height)>0.01)fail('IMAGE_ORIENTATION_MISMATCH','Imported aspect ratio differs from source metadata; preserve the source and correct metadata before placement.');
169
+ if(saved.placed){
170
+ if(d.kind==='text'?!near(obj.x,d.x)||!near(obj.y,d.y):!geometry(obj,d))fail('CANVAS_CHANGED','An existing application object moved or resized.');
171
+ }else{
172
+ if(d.kind!=='text'&&(!near(obj.width,d.width)||!near(obj.height,d.height)))obj=await call('object.resize',[...ctx,...ids,'--width',String(d.width),'--height',String(d.height)],run);
173
+ if(!near(obj.x,d.x)||!near(obj.y,d.y))obj=await call('object.move',[...ctx,...ids,'--x',String(d.x),'--y',String(d.y)],run);
174
+ }
175
+ if(d.kind==='text'&&obj.fontSize!==d.fontSize)fail('CANVAS_CHANGED','Heading font size differs.');
176
+ if(d.kind==='frame'&&obj.name!==d.name)fail('CANVAS_CHANGED','Frame title differs.');
177
+ if(saved.resource_hash&&obj.resourceHash!==saved.resource_hash)fail('CANVAS_CHANGED','Image resource changed.');
178
+ if(d.kind==='text'?!near(obj.x,d.x)||!near(obj.y,d.y):!geometry(obj,d))fail('OUTPUT_CONTRACT_FAILED','Boardmix object readback differs.');
179
+ saved.placed=true;saved.readback=obj;await checkpoint(statePath(dir),state);
180
+ }
181
+ // Verify the final whole layout after all imports, which may trigger native container behavior.
182
+ for(const d of descriptors(plan)){const saved=state.objects[d.id],o=await call('object.get',[...ctx,'--session-id',String(saved.guid.sessionID),'--local-id',String(saved.guid.localID)],run);if(d.kind==='text'?!near(o.x,d.x)||!near(o.y,d.y):!geometry(o,d))fail('OUTPUT_CONTRACT_FAILED','Final layout readback differs.');if(d.kind==='text'&&o.fontSize!==d.fontSize||d.kind==='frame'&&o.name!==d.name||saved.resource_hash&&o.resourceHash!==saved.resource_hash)fail('CANVAS_CHANGED','Final object identity differs.');saved.readback=o;}
183
+ }
184
+ const report={run_id:plan.run_id,target:'boardmix',status:'completed',performance:state.performance,canvas_id:state.file_key,canvas_url:'https://boardmix.cn/app/editor/'+state.file_key,frames:batch.frames.length,images:batch.media.filter(m=>m.kind==='image').length,videos:batch.media.filter(m=>m.kind==='video').length,omitted_media:plan.omitted_media,products:manifest.products.map(p=>({id:p.id,title:p.title,shop:p.shop.shopName,coverage:p.coverage,excluded_assets:p.excluded_assets||[],counts:Object.fromEntries(MEDIA_REGIONS.map(c=>[c,p.assets[c].length]))})),verification:'GUID, image resource identity, frame names and geometry read back; frame containment is geometric. Text content and visual appearance require desktop inspection.',objects:state.objects};
185
+ state.status='completed';await checkpoint(statePath(dir),state);await checkpoint(path.join(dir,'collection-report.json'),report);return report;
186
+ });
187
+ }
188
+ export async function recoverBoardmix(dir,options={},run=invoke){
189
+ const plan=await loadPlan(dir),ctx=context(options);
190
+ return lock(dir,async()=>{
191
+ const state=await loadState(dir,plan);if(!state.inflight)fail('STATE_CONFLICT','No uncertain operation to recover.');
192
+ if(state.inflight.kind==='board'){
193
+ const info=await call('board.info',ctx,run);if(info.fileKey!==options.fileKey)fail('TARGET_CONFLICT','Connected file differs.');
194
+ if(!options.verified)fail('VERIFICATION_REQUIRED','Inspect the create outcome and confirm this exact file with --verified.');
195
+ state.file_key=options.fileKey;state.created_by_run=true;
196
+ }else{
197
+ if(state.file_key!==options.fileKey)fail('TARGET_CONFLICT','Run is bound to another file.');
198
+ if(!Number.isSafeInteger(options.sessionID)||!Number.isSafeInteger(options.localID))fail('GUID_REQUIRED','Provide the observed numeric object GUID.');
199
+ const d=descriptors(plan).find(x=>x.id===state.inflight.key),guid={sessionID:options.sessionID,localID:options.localID};
200
+ const obj=await call('object.get',[...ctx,'--session-id',String(guid.sessionID),'--local-id',String(guid.localID)],run);
201
+ if(d.kind==='frame'&&(obj.name!==d.name||!near(obj.width,d.width)||!near(obj.height,d.height)))fail('RECOVERY_MISMATCH','Observed Frame differs.');
202
+ if(d.kind==='text'&&!options.verified)fail('VERIFICATION_REQUIRED','Inspect visible text before --verified.');
203
+ if(d.kind==='image'){
204
+ const manifest=await read(path.join(dir,'assets.manifest.json')),a=manifest.products.flatMap(p=>MEDIA_REGIONS.flatMap(c=>p.assets[c])).find(x=>x.asset_id===d.media.sourceMetadata.assetId);
205
+ const bytes=await fs.readFile(assetPath(dir,a));if(crypto.createHash('sha256').update(bytes).digest('hex')!==a.sha256||plan.asset_hashes&&plan.asset_hashes[a.asset_id]!==a.sha256)fail('ASSET_CHANGED','Source image changed before recovery.');
206
+ const hash=crypto.createHash('md5').update(bytes).digest('hex');
207
+ if(obj.resourceHash!==hash)fail('RECOVERY_MISMATCH','Observed image source hash differs.');
208
+ }
209
+ state.objects[d.id]={guid,kind:d.kind,placed:false,resource_hash:obj.resourceHash};
210
+ }
211
+ delete state.inflight;await checkpoint(statePath(dir),state);return {recovered:true,file_key:state.file_key,next:'Resume canvas --submit with the same run and file key.'};
212
+ });
213
+ }
214
+
215
+ // Backfill videos for known products while preserving existing object identities.
216
+ export async function addBoardmixVideos(dir,baseDir,rawFiles=[],run=invoke){
217
+ if(!baseDir)fail('INVALID_INPUT','Provide --base-run.');
218
+ const base=await loadPlan(baseDir),state=await loadState(baseDir,base),old=await read(path.join(baseDir,'assets.manifest.json'));
219
+ if(state.status!=='completed'||state.inflight||state.successor_run)fail('STALE_BASE_RUN','Use the latest completed run.');
220
+ if(base.batch.media.some(m=>m.kind==='video'))fail('VIDEOS_ALREADY_PRESENT','Videos already exist; use the completed run.');
221
+ const manifest=await collect(dir,old.products.map(p=>p.id),rawFiles,'boardmix');
222
+ await fs.mkdir(path.join(dir,'media'),{recursive:true});
223
+ for(const product of manifest.products){
224
+ const prior=old.products.find(p=>p.id===product.id);
225
+ if(product.coverage?.mainVideos?.status!=='captured'||!product.assets.main_videos.length)fail('VIDEO_COVERAGE_INCOMPLETE','Video capture incomplete for '+product.id);
226
+ product.excluded_assets=prior.excluded_assets;
227
+ for(const c of IMAGE_REGIONS){product.assets[c]=structuredClone(prior.assets[c]);for(const a of product.assets[c]){const source=assetPath(baseDir,a),destination=assetPath(dir,a);const bytes=await fs.readFile(source);if(crypto.createHash('sha256').update(bytes).digest('hex')!==a.sha256)fail('ASSET_CHANGED','Base image changed.');await fs.copyFile(source,destination,fs.constants.COPYFILE_EXCL);}}
228
+ }
229
+ await checkpoint(path.join(dir,'assets.manifest.json'),manifest);
230
+ await planBoardmix(dir,{title:base.title,...base.layout_options},run);
231
+ const plan=await loadPlan(dir),objects=structuredClone(state.objects),mapping=new Map();
232
+ for(const region of base.regions){
233
+ const target=plan.regions.find(r=>r.category===region.category),dx=target.x-region.x;
234
+ if(target.frame_ids.length!==region.frame_ids.length)fail('MIGRATION_CONFLICT','Frame coverage differs.');
235
+ for(let i=0;i<region.frame_ids.length;i++)mapping.set(target.frame_ids[i],region.frame_ids[i]);
236
+ for(const oldId of region.frame_ids){const prior=base.batch.frames.find(f=>f.id===oldId),fresh=plan.batch.frames.find(f=>mapping.get(f.id)===oldId);Object.assign(fresh,{...prior,x:prior.x+dx});}
237
+ target.frame_ids=[...region.frame_ids];Object.assign(target,{next_y:region.next_y,bottom:region.bottom,next_x:region.next_x+dx,right:region.right+dx});
238
+ const priorText=base.batch.texts.find(t=>Math.abs(t.x-region.x)<0.02),freshText=plan.batch.texts.find(t=>Math.abs(t.x-target.x)<0.02);if(!priorText||!freshText)fail('MIGRATION_CONFLICT','Heading missing.');Object.assign(freshText,{...priorText,x:priorText.x+dx});
239
+ for(const m of plan.batch.media.filter(m=>m.sourceMetadata.category===region.category)){const prior=base.batch.media.find(b=>b.sourceMetadata.assetId===m.sourceMetadata.assetId&&b.sourceMetadata.productId===m.sourceMetadata.productId);if(!prior)fail('MIGRATION_CONFLICT','Image mapping missing.');Object.assign(m,structuredClone(prior));m.placement.positions[0].x+=dx;}
240
+ }
241
+ plan.target_file_key=state.file_key;plan.base_run_id=base.run_id;plan.migration='add-main-video-region';
242
+ for(const d of descriptors(plan)){const saved=objects[d.id];if(saved){const previous=saved.readback;if(!previous)fail('MIGRATION_CONFLICT','Missing prior readback.');if(!near(d.x,previous.x)||!near(d.y,previous.y)){saved.migration_from=previous;saved.placed=false;}}}
243
+ validateLayout(plan);
244
+ await checkpoint(path.join(dir,'layout.plan.json'),plan);
245
+ // Batch checkpoint is owned by this just-created run and preserved before replacement.
246
+ const batchFile=path.join(dir,'boardmix.batch.json');await fs.copyFile(batchFile,batchFile+'.'+crypto.randomUUID()+'.backup',fs.constants.COPYFILE_EXCL);await fs.writeFile(batchFile,JSON.stringify(plan.batch,null,2)+'\n');
247
+ await create(statePath(dir),{run_id:plan.run_id,target:'boardmix',plan_hash:uid(plan),file_key:state.file_key,objects,base_run:path.resolve(baseDir)});
248
+ return {run_id:plan.run_id,file_key:state.file_key,images:plan.batch.media.filter(m=>m.kind==='image').length,videos:plan.batch.media.filter(m=>m.kind==='video').length,moved_existing_objects:Object.values(objects).filter(o=>o.migration_from).length,next:'canvas --run DIR --session FILE --file-key KEY --submit'};
249
+ }
package/src/lifecycle.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  export {ROOT,META} from './core.mjs';
2
2
  import {SOURCE,status,install} from './skills.mjs';
3
- import {META} from './core.mjs';
4
- export async function skill(action,agent='codex',custom){
5
- if(action==='source')return {source:SOURCE,name:'competitor-visual-canvas',version:META.version};
6
- if(action==='status'){const s=await status(agent,custom);return {...s,target:s.destination};}
7
- if(['install','update'].includes(action)){const s=await install(agent,{path:custom});return {...s,target:s.destination};}
3
+ import {META,ROOT,readJson} from './core.mjs';
4
+ import path from 'node:path';
5
+ export async function skill(action,agent='generic',custom){
6
+ if(action==='source')return {ok:true,...await readJson(path.join(ROOT,'capabilities.json')),source:SOURCE,name:'competitor-visual-canvas',package:META.name,version:META.version};
7
+ if(action==='status'){const s=await status(agent,custom);return {...s,name:'competitor-visual-canvas',package:META.name,version:s.version||META.version,target:s.destination,edited:!!s.edited,verification:{verified:!!s.current,algorithm:'sha256'},recovery:{action:s.current?'none':'skill update with explicit --path'},hostDiscovery:'pending-host-verification'};}
8
+ if(['install','update'].includes(action)){const s=await install(agent,{path:custom});return {...s,name:'competitor-visual-canvas',package:META.name,version:s.version||META.version,target:s.destination,edited:!!s.edited,verification:{verified:!!s.current,algorithm:'sha256'},recovery:{action:s.current?'none':'skill update with explicit --path'},hostDiscovery:'pending-host-verification'};}
8
9
  throw Object.assign(Error('Use skill source/status/install/update'),{code:'INVALID_INPUT'});
9
10
  }
package/src/skills.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import fs from 'node:fs/promises';
2
- import {existsSync} from 'node:fs';
2
+ import {existsSync,readFileSync,lstatSync,realpathSync} from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import crypto from 'node:crypto';
@@ -9,18 +9,12 @@ export const SOURCE = path.join(ROOT, 'skills', 'competitor-visual-canvas');
9
9
  const MARKER = '.competitor-visual-canvas-install.json';
10
10
  export function renderSkill(text, agent) { return text; }
11
11
  export function destination(agent, custom) {
12
- const roots = { codex:'.codex', workbuddy:'.workbuddy', sealseek:'.sealseek' };
13
- requireValue(agent in roots, 'UNKNOWN_TARGET', 'Supported agents: codex, workbuddy, sealseek.');
14
- if(custom)return path.resolve(custom);
15
- if(agent==='sealseek'){
16
- const configured=process.env.SEALSEEK_SKILLS_DIR;
17
- if(configured)return path.join(path.resolve(configured),'competitor-visual-canvas');
18
- const home=process.env.SEALSEEK_HOME||path.join(os.homedir(),'.sealseek');
19
- const candidates=[path.join(home,'workspaces','default','skills'),path.join(home,'workspace','skills')].filter(p=>existsSync(p));
20
- if(candidates.length===1)return path.join(candidates[0],'competitor-visual-canvas');
21
- requireValue(false,'TARGET_UNRESOLVED','Specify the active SealSeek Skill root with SEALSEEK_SKILLS_DIR or --path.');
22
- }
23
- return path.join(os.homedir(), roots[agent], 'skills','competitor-visual-canvas');
12
+ requireValue(/^[a-z][a-z0-9-]{0,63}$/.test(agent||''), 'UNKNOWN_TARGET', 'Expected a valid Agent name.');
13
+ if(custom){requireValue(path.isAbsolute(custom),'TARGET_UNRESOLVED','Provide an absolute --path.');return path.resolve(custom);}
14
+ // Published updaters omit --path. Only migrate an already-owned legacy target.
15
+ const adapter=JSON.parse(readFileSync(path.join(SOURCE,'adapters',existsSync(path.join(SOURCE,'adapters',agent+'.json'))?agent+'.json':'generic.json'),'utf8'));
16
+ if(adapter.legacySkillRoot){const target=path.join(os.homedir(),adapter.legacySkillRoot,'competitor-visual-canvas');try{const st=lstatSync(target);const owned=st.isSymbolicLink()?realpathSync(target)===realpathSync(SOURCE):JSON.parse(readFileSync(path.join(target,MARKER),'utf8')).owner===META.name;if(owned)return target;}catch{}}
17
+ requireValue(false,'TARGET_UNRESOLVED','Resolve the current Agent Skill directory and provide its explicit absolute --path.');
24
18
  }
25
19
  export async function hashes(dir) {
26
20
  const result={};
@@ -34,19 +28,28 @@ export async function hashes(dir) {
34
28
  }
35
29
  await walk();return result;
36
30
  }
31
+ async function expectedHashes(agent) {
32
+ const result=await hashes(SOURCE);
33
+ result['SKILL.md']=hash(renderSkill(await fs.readFile(path.join(SOURCE,'SKILL.md'),'utf8'),agent));
34
+ for(const folder of ['bin','src'])for(const [name,digest]of Object.entries(await hashes(path.join(ROOT,folder))))result[path.join('.runtime',folder,name)]=digest;
35
+ for(const name of ['package.json','capabilities.json','INSTALL.md','UPDATE.md'])result[path.join('.runtime',name)]=hash(await fs.readFile(path.join(ROOT,name)));
36
+ for(const [name,digest]of Object.entries(await hashes(SOURCE)))result[path.join('.runtime','skills','competitor-visual-canvas',name)]=digest;
37
+ return result;
38
+ }
37
39
  export async function status(agent, custom) {
38
- const target=destination(agent,custom), stat=await exists(target), sourceHashes=await hashes(SOURCE);
40
+ const target=destination(agent,custom), stat=await exists(target), sourceHashes=await expectedHashes(agent);
39
41
  sourceHashes['SKILL.md'] = hash(renderSkill(await fs.readFile(path.join(SOURCE,'SKILL.md'),'utf8'),agent));
40
42
  if (!stat) return { ok:true, agent, destination:target, installed:false, managed:false, current:false };
41
43
  if (stat.isSymbolicLink()) {
42
44
  const actual=await fs.realpath(target).catch(()=>null);
43
- return { ok:true, agent, destination:target, installed:true, managed:actual === await fs.realpath(SOURCE), current:actual === await fs.realpath(SOURCE), mode:'link' };
45
+ return { ok:true, agent, destination:target, installed:true, managed:actual === await fs.realpath(SOURCE), current:false, mode:'link', recovery:'Migrate the verified managed link to an isolated physical copy.' };
44
46
  }
45
47
  const manifest=await readJson(path.join(target,MARKER)).catch(()=>null);
46
48
  const managed=manifest?.owner===META.name;
49
+ if(!managed)return {ok:true,agent,destination:target,installed:true,managed:false,current:false,mode:stat.isDirectory()?'directory':'file'};
47
50
  const actual=stat.isDirectory() ? await hashes(target) : {};
48
51
  const edited=managed && (Object.entries(manifest.hashes).some(([name,digest])=> actual[name]!==digest && name !== path.join('agents','openai.yaml')) || Object.keys(actual).some(name=>!(name in manifest.hashes)));
49
- return { ok:true,agent,destination:target,installed:true,managed,current:managed && !edited && manifest.version===META.version && manifest.source===SOURCE && Object.keys(actual).length===Object.keys(sourceHashes).length && Object.entries(sourceHashes).every(([name,digest])=> actual[name]===digest || name === path.join('agents','openai.yaml')), edited,mode:'copy',version:manifest?.version };
52
+ return { ok:true,agent,destination:target,installed:true,managed,current:managed && !edited && manifest.version===META.version && Object.keys(actual).length===Object.keys(sourceHashes).length && Object.entries(sourceHashes).every(([name,digest])=> actual[name]===digest || name === path.join('agents','openai.yaml')), edited,mode:'copy',version:manifest?.version };
50
53
  }
51
54
  export async function install(agent, options = {}) {
52
55
  const rename = options.rename || fs.rename;
@@ -66,9 +69,13 @@ export async function install(agent, options = {}) {
66
69
  }
67
70
  const staging=`${target}.staging-${crypto.randomUUID()}`;
68
71
  await fs.cp(SOURCE,staging,{ recursive:true,errorOnExist:true,force:false });
72
+ const runtime=path.join(staging,'.runtime');await fs.mkdir(runtime);
73
+ for(const folder of ['bin','src'])await fs.cp(path.join(ROOT,folder),path.join(runtime,folder),{recursive:true,errorOnExist:true,force:false});
74
+ for(const name of ['package.json','capabilities.json','INSTALL.md','UPDATE.md'])await fs.copyFile(path.join(ROOT,name),path.join(runtime,name),fs.constants.COPYFILE_EXCL);
75
+ await fs.cp(SOURCE,path.join(runtime,'skills','competitor-visual-canvas'),{recursive:true,errorOnExist:true,force:false});
69
76
  const skillText = await fs.readFile(path.join(staging,'SKILL.md'),'utf8');
70
77
  await fs.writeFile(path.join(staging,'SKILL.md'),renderSkill(skillText,agent));
71
- if (backup && await exists(path.join(target,'agents','openai.yaml'))) {
78
+ if (backup && info.mode!=='link' && await exists(path.join(target,'agents','openai.yaml'))) {
72
79
  const oldManifest=await readJson(path.join(target,MARKER));
73
80
  const name=path.join('agents','openai.yaml');
74
81
  if(hash(await fs.readFile(path.join(target,name)))!==oldManifest.hashes[name]) await fs.copyFile(path.join(target,name),path.join(staging,name));
package/src/update.mjs CHANGED
@@ -53,12 +53,10 @@ export async function globalInstallation(root=ROOT) {
53
53
  return (await fs.realpath(expected).catch(()=>null))===await fs.realpath(root);
54
54
  }
55
55
  export async function managedTargets() {
56
- const targets=[];
57
- for(const agent of ['codex','workbuddy','sealseek']) {
58
- const info=await status(agent).catch(e=>{if(e.code==='TARGET_UNRESOLVED')return {managed:false};throw e;});
59
- requireValue(!info.managed||!info.edited,'LOCAL_EDITS','A managed Skill has local edits; review it before updating.',{agent});
60
- if(info.managed)targets.push(agent);
61
- }return targets;
56
+ const agent=process.env.COMPETITOR_CANVAS_AGENT,selected=process.env.COMPETITOR_CANVAS_SKILL_PATH;
57
+ if(!agent||!selected)return [];
58
+ const info=await status(agent,selected);requireValue(!info.edited,'LOCAL_EDITS','Selected Skill has local edits.');
59
+ return [{agent,path:info.destination}];
62
60
  }
63
61
  export async function activeWork(dir=stateRoot()) {
64
62
  for(const folder of ['jobs','auth-sessions']) {
@@ -82,8 +80,9 @@ export async function installVersion(version,targets,{root=ROOT,dir=stateRoot(),
82
80
  await execute(await npmScript(),['install','--global',`${META.name}@${version}`,'--ignore-scripts','--no-audit','--no-fund']);
83
81
  const installed=await readJson(path.join(root,'package.json'));
84
82
  requireValue(installed.name===META.name&&installed.version===version,'UPDATE_VERIFICATION_FAILED','Installed package version mismatch.');
85
- for(const agent of targets){
86
- const r=synchronize?await synchronize(agent):spawnSync(process.execPath,[path.join(root,'bin','competitor-visual-canvas.mjs'),'skill','update','--agent',agent,'--json'],{encoding:'utf8',timeout:120000,env:{...process.env,COMPETITOR_CANVAS_AUTO_UPDATE:'0'},windowsHide:true});
83
+ for(const target of targets){
84
+ const agent=typeof target==='string'?target:target.agent;
85
+ const r=synchronize?await synchronize(agent):spawnSync(process.execPath,[path.join(root,'bin','competitor-visual-canvas.mjs'),'skill','update','--agent',agent,...(target.path?['--path',target.path]:[]),'--json'],{encoding:'utf8',timeout:120000,env:{...process.env,COMPETITOR_CANVAS_AUTO_UPDATE:'0'},windowsHide:true});
87
86
  requireValue(r.status===0,'SKILL_SYNC_FAILED','Package installed, but managed Skill synchronization failed.',{agent,backup});
88
87
  }
89
88
  return {version,backup,skills_synchronized:targets};
@@ -105,7 +104,10 @@ export async function performUpdate({automatic=true,dir=stateRoot(),root=ROOT,cu
105
104
  const selected=await targets();
106
105
  config.last_check=now;await store(config,dir);
107
106
  const version=await latest();config.latest=version;delete config.error;
108
- if(!newer(version,current)){await store(config,dir);return {ok:true,current,latest:version,updated:false};}
107
+ if(!newer(version,current)){if(!automatic){requireValue(selected.length,'TARGET_UNRESOLVED','Select --agent and --path before update.');for(const t of selected){const {install}=await import('./skills.mjs');await install(t.agent,{path:t.path});}}await store(config,dir);return {ok:true,current,latest:version,updated:false};}
108
+ const [major,minor]=current.split('.'),[nextMajor,nextMinor]=version.split('.');
109
+ if(automatic&&(env.COMPETITOR_CANVAS_AUTO_INSTALL!=='1'||major!==nextMajor||major==='0'&&minor!==nextMinor)){await store(config,dir);return {ok:true,current,latest:version,updateAvailable:true,updated:false,action:'update install --agent CURRENT --path RESOLVED --yes'};}
110
+ requireValue(selected.length,'TARGET_UNRESOLVED','Select --agent and --path before package replacement.');
109
111
  const result=await install(version,selected);config.last_update=Date.now();config.installed=version;delete config.error;await store(config,dir);
110
112
  return {ok:true,updated:true,...result};
111
113
  }catch(e){
@@ -0,0 +1,7 @@
1
+ import fs from 'node:fs/promises';
2
+ import {META,ROOT} from './core.mjs';
3
+ import path from 'node:path';
4
+ const caps=JSON.parse(await fs.readFile(path.join(ROOT,'skills/competitor-visual-canvas/capabilities.json')));
5
+ if(caps.skill!=='competitor-visual-canvas'||META.name!=='@petercjl/competitor-visual-canvas')throw Error('PACKAGE_IDENTITY_MISMATCH');
6
+ for(const name of ['INSTALL.md','UPDATE.md','skills/competitor-visual-canvas/SKILL.md'])if(!(await fs.readFile(path.join(ROOT,name),'utf8')).trim())throw Error('MISSING_RESOURCE');
7
+ console.error('Validated package identity '+META.name+'@'+META.version+'; Skill source and install receipts derive version from package metadata.');
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']);