@petercjl/competitor-visual-canvas 0.1.0 → 0.1.1
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/README.md +15 -0
- package/bin/competitor-visual-canvas.mjs +9 -3
- package/package.json +1 -1
- package/skills/competitor-visual-canvas/SKILL.md +4 -0
- package/skills/competitor-visual-canvas/adapters/codex.json +16 -0
- package/skills/competitor-visual-canvas/adapters/sealseek.json +16 -0
- package/skills/competitor-visual-canvas/adapters/workbuddy.json +16 -0
- package/skills/competitor-visual-canvas/capabilities.json +16 -0
- package/src/canvas-target.mjs +104 -0
package/README.md
CHANGED
|
@@ -31,6 +31,21 @@ competitor-visual-canvas canvas --run NEW_DIR --submit --json
|
|
|
31
31
|
|
|
32
32
|
The new run records cumulative assets and uses the same canvas. Duplicate IDs are skipped. Old elements retain their positions; a stale base, manual changes or excessive column width stops before saving. Main-image/video/SKU columns reserve at least 2400/1600/1840 canvas units initially. Legacy vertical-region canvases require an explicitly requested `reflow --run DIR --submit`; normal append does not move existing elements.
|
|
33
33
|
|
|
34
|
+
Find an existing application canvas by its exact name and append without a previous local run:
|
|
35
|
+
|
|
36
|
+
```sh
|
|
37
|
+
competitor-visual-canvas find --title "My canvas" --json
|
|
38
|
+
competitor-visual-canvas append --title "My canvas" --run NEW_DIR --input NEW_PRODUCT_ID --json
|
|
39
|
+
competitor-visual-canvas canvas --run NEW_DIR --json
|
|
40
|
+
competitor-visual-canvas canvas --run NEW_DIR --submit --json
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Name lookup scans all result pages and matches the full title. Multiple matches return `AMBIGUOUS_CANVAS` with candidates; select with `append --canvas ID`. A missing name returns `CANVAS_NOT_FOUND`. Lookup never creates a canvas. ID and title may be combined to verify the chosen name; `--base-run` is an alternative selector.
|
|
44
|
+
|
|
45
|
+
Cloud recovery restores this application's four-region layout, product IDs and existing links from element source metadata, including empty Frames. Old coordinates stay intact and submission rechecks the saved snapshot. Incomplete or unrelated canvases return `CANVAS_STATE_UNSUPPORTED`. Recovery snapshots live in private application state, outside npm. Original product titles, SKU attributes and collection evidence unavailable in cloud metadata are marked unavailable; a completed local base run retains that richer evidence. The application controls these rules; the media CLI continues to provide generic canvas listing, reading and writing.
|
|
46
|
+
|
|
47
|
+
Agent prompt: “用竞品视觉素材画板工具,找到画板‘我的竞品素材’,追加商品 ID:123456789012。”
|
|
48
|
+
|
|
34
49
|
## Installation and updates
|
|
35
50
|
|
|
36
51
|
Install `@petercjl/competitor-visual-canvas` globally, then use `competitor-visual-canvas skill install --agent codex|sealseek|workbuddy --json` for the current Agent. Its npm dependencies provide compatible tbcli and sealseek-media CLIs; service authorization remains separate. SealSeek resolves the active default workspace or a configured SEALSEEK_SKILLS_DIR; ambiguous roots require an explicit --path.
|
|
@@ -4,6 +4,7 @@ import {spawnSync} from 'node:child_process';
|
|
|
4
4
|
import {performUpdate,latestVersion,updateStatus,setAutomatic} from '../src/update.mjs';
|
|
5
5
|
import {collect,planRun,publishCanvas,invoke,read,fail,configureTool,appendRun} from '../src/workflow.mjs';
|
|
6
6
|
import {reflowRun} from '../src/reflow.mjs';
|
|
7
|
+
import {findCanvases,appendToCanvas} from '../src/canvas-target.mjs';
|
|
7
8
|
import {ROOT,META,skill} from '../src/lifecycle.mjs';
|
|
8
9
|
const argv=process.argv.slice(2),command=argv.shift(),options={},args=[];
|
|
9
10
|
function parse(){for(let i=0;i<argv.length;i++){const k=argv[i];if(k==='--json')continue;if(['--submit','--yes'].includes(k)){options[k.slice(2)]=true;continue;}if(k.startsWith('--')){const key=k.slice(2),value=argv[++i];if(!value||value.startsWith('--'))fail('INVALID_INPUT','Missing '+k);if(!['input','raw','run','title','agent','canvas','tool','entry','base-run','path'].includes(key))fail('INVALID_INPUT','Unknown '+k);(options[key]??=[]).push(value);}else args.push(k);}}
|
|
@@ -11,7 +12,8 @@ const one=k=>options[k]?.at(-1);
|
|
|
11
12
|
const help=`competitor-visual-canvas ${META.version}
|
|
12
13
|
collect --input ID_OR_URL [--input MORE] --run NEW_DIR [--raw SAVED_COLLECTION.json ...] --json
|
|
13
14
|
plan --run DIR [--title TEXT] [--base-run COMPLETED_DIR] --json
|
|
14
|
-
|
|
15
|
+
find --title EXACT_TITLE --json (read-only exact-name lookup)
|
|
16
|
+
append (--base-run COMPLETED_DIR | --title EXACT_TITLE | --canvas ID) --run NEW_DIR --input ID_OR_URL [--input MORE] [--raw FILE ...] --json
|
|
15
17
|
reflow --run DIR [--submit] --json (migrate a legacy canvas to horizontal regions)
|
|
16
18
|
canvas --run DIR [--submit] --json
|
|
17
19
|
recover --run DIR --canvas ID --submit --json (bind verified existing canvas after uncertain creation)
|
|
@@ -39,10 +41,14 @@ async function main(){
|
|
|
39
41
|
if(args[0]==='install'){if(!options.yes)fail('AUTHORIZATION_REQUIRED','Use --yes for package update.');return performUpdate({automatic:false});}
|
|
40
42
|
fail('INVALID_INPUT','Use update check/install or auto status/on/off.');
|
|
41
43
|
}
|
|
44
|
+
if(command==='find')return findCanvases(one('title'));
|
|
42
45
|
const run=one('run');if(!run)fail('INVALID_INPUT','Provide --run DIR.');
|
|
43
46
|
if(command==='collect'){const result=await collect(path.resolve(run),options.input||[],options.raw||[]);return {run_id:result.run_id,run:path.resolve(run),products:result.products.map(p=>({id:p.id,title:p.title,counts:Object.fromEntries(Object.entries(p.assets).map(([k,v])=>[k,v.length]))}))};}
|
|
44
47
|
if(command==='plan')return planRun(path.resolve(run),{title:one('title'),baseRun:one('base-run')?path.resolve(one('base-run')):undefined});
|
|
45
|
-
if(command==='append'){
|
|
48
|
+
if(command==='append'){
|
|
49
|
+
if(one('base-run')){if(one('title')||one('canvas'))fail('INVALID_INPUT','Use a base run or a canvas selector.');return appendRun(path.resolve(run),path.resolve(one('base-run')),options.input||[],options.raw||[]);}
|
|
50
|
+
return appendToCanvas(path.resolve(run),{title:one('title'),canvas:one('canvas')},options.input||[],options.raw||[]);
|
|
51
|
+
}
|
|
46
52
|
if(command==='reflow')return reflowRun(path.resolve(run),!!options.submit);
|
|
47
53
|
if(command==='canvas')return publishCanvas(path.resolve(run),!!options.submit);
|
|
48
54
|
if(command==='recover'){if(!one('canvas')||!options.submit)fail('SUBMIT_REQUIRED','Recovery requires --canvas ID --submit.');const {checkpoint}=await import('../src/workflow.mjs');const plan=await read(path.join(run,'layout.plan.json')),board=await invoke('sealseek-media',['canvas','get',one('canvas'),'--json']);if(board.title!==plan.title)fail('STATE_CONFLICT','Recovered canvas title differs.');await checkpoint(path.join(run,'canvas.state.json'),{run_id:plan.run_id,canvas_id:board.canvas_id,canvas_url:board.canvas_url,status:'recovered'});return {recovered:true,...board};}
|
|
@@ -54,4 +60,4 @@ async function execute(){
|
|
|
54
60
|
}
|
|
55
61
|
return main();
|
|
56
62
|
}
|
|
57
|
-
execute().then(value=>console.log(JSON.stringify(value,null,2))).catch(e=>{console.error(JSON.stringify({ok:false,code:e.code??'FAILED',message:e.message}));process.exitCode=1;});
|
|
63
|
+
execute().then(value=>console.log(JSON.stringify(value,null,2))).catch(e=>{console.error(JSON.stringify({ok:false,code:e.code??'FAILED',message:e.message,...(e.details?{details:e.details}:{})}));process.exitCode=1;});
|
package/package.json
CHANGED
|
@@ -36,6 +36,10 @@ description: Collect Taobao or Tmall product main images, main videos, detail im
|
|
|
36
36
|
|
|
37
37
|
## 向已有画板追加商品
|
|
38
38
|
|
|
39
|
+
用户给出已有画板名称时,按完整名称精确查找:`competitor-visual-canvas find --title EXACT_TITLE --json`。唯一匹配时执行 `competitor-visual-canvas append --title EXACT_TITLE --run NEW_DIR --input ID_OR_URL [--input MORE] --json`,再按主线第6、7步预览、提交与核验。名称可包含空格与中文,作为完整参数传入。用户提供画板 ID 或链接时可使用 `append --canvas ID`。同名候选由用户选择,随后传 `--canvas ID`;也可同时传 `--title` 校验名称。CANVAS_NOT_FOUND 时报告未找到,不自动新建。
|
|
40
|
+
|
|
41
|
+
名称或 ID 追加从服务端读取当前内容,恢复本应用四区域 Frame、商品来源、已有素材及布局边界;无需旧对话或本地运行目录。恢复快照保存在本应用私有状态目录,输出 recovered_base_run。恢复依据元素的来源元数据及稳定 Frame 标识,支持空视频 Frame;保留已有元素的坐标、链接、归属和区域标题尺寸。原始采集证据、商品完整标题、SKU 属性等未保存在云端的信息标为 unavailable,不凭空补全。该方式适用于本应用当前四区域结构;缺少元数据、Frame 不完整或混入无法识别元素时返回 CANVAS_STATE_UNSUPPORTED,保留画板,使用可用的完整运行目录或交由用户决定如何整理。恢复和选择均只读,canvas --submit 才写入画板。
|
|
42
|
+
|
|
39
43
|
用户提出继续添加商品时,使用最新已完成运行目录 BASE,不新建画板。执行 `competitor-visual-canvas append --base-run BASE --run NEW_DIR --input ID_OR_URL [--input MORE] --json`,先采集并规划;预览 `canvas --run NEW_DIR --json`,再在用户授权范围内执行加 `--submit` 的同命令。可重复 `--raw FILE` 复用 item media 原始结果。
|
|
40
44
|
|
|
41
45
|
追加计划和素材清单为累计结果,沿用原画板 ID、标题、区域横向起点及旧元素 ID/位置;新商品主图、视频、SKU Frame 分别从对应区域底部继续向下,详情页 Frame 从最右侧继续向右。重复商品 ID 跳过;全部已存在时返回 changed:false。后续追加以 NEW_DIR 为最新基准,保留 BASE 作为恢复点。
|
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
"schema_version": "1.0.0",
|
|
4
4
|
"platform": "codex",
|
|
5
5
|
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"capability_id": "canvas.product.append",
|
|
8
|
+
"implementation": {
|
|
9
|
+
"type": "cli",
|
|
10
|
+
"command": "competitor-visual-canvas",
|
|
11
|
+
"discovery": "Current package CLI; find --title and append --title/--canvas/--base-run"
|
|
12
|
+
},
|
|
13
|
+
"features": [
|
|
14
|
+
"canvas-list",
|
|
15
|
+
"canvas-get",
|
|
16
|
+
"source-metadata"
|
|
17
|
+
],
|
|
18
|
+
"status": "implemented",
|
|
19
|
+
"evidence": [],
|
|
20
|
+
"normalization": "Exact-title candidates, structured ambiguity errors, validated cloud recovery and cumulative append plan; host runtime evidence is separate."
|
|
21
|
+
},
|
|
6
22
|
{
|
|
7
23
|
"capability_id": "product.media.collect",
|
|
8
24
|
"implementation": {
|
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
"schema_version": "1.0.0",
|
|
4
4
|
"platform": "sealseek",
|
|
5
5
|
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"capability_id": "canvas.product.append",
|
|
8
|
+
"implementation": {
|
|
9
|
+
"type": "cli",
|
|
10
|
+
"command": "competitor-visual-canvas",
|
|
11
|
+
"discovery": "Current package CLI; find --title and append --title/--canvas/--base-run"
|
|
12
|
+
},
|
|
13
|
+
"features": [
|
|
14
|
+
"canvas-list",
|
|
15
|
+
"canvas-get",
|
|
16
|
+
"source-metadata"
|
|
17
|
+
],
|
|
18
|
+
"status": "implemented",
|
|
19
|
+
"evidence": [],
|
|
20
|
+
"normalization": "Exact-title candidates, structured ambiguity errors, validated cloud recovery and cumulative append plan; host runtime evidence is separate."
|
|
21
|
+
},
|
|
6
22
|
{
|
|
7
23
|
"capability_id": "product.media.collect",
|
|
8
24
|
"implementation": {
|
|
@@ -3,6 +3,22 @@
|
|
|
3
3
|
"schema_version": "1.0.0",
|
|
4
4
|
"platform": "workbuddy",
|
|
5
5
|
"mappings": [
|
|
6
|
+
{
|
|
7
|
+
"capability_id": "canvas.product.append",
|
|
8
|
+
"implementation": {
|
|
9
|
+
"type": "cli",
|
|
10
|
+
"command": "competitor-visual-canvas",
|
|
11
|
+
"discovery": "Current package CLI; find --title and append --title/--canvas/--base-run"
|
|
12
|
+
},
|
|
13
|
+
"features": [
|
|
14
|
+
"canvas-list",
|
|
15
|
+
"canvas-get",
|
|
16
|
+
"source-metadata"
|
|
17
|
+
],
|
|
18
|
+
"status": "implemented",
|
|
19
|
+
"evidence": [],
|
|
20
|
+
"normalization": "Exact-title candidates, structured ambiguity errors, validated cloud recovery and cumulative append plan; host runtime evidence is separate."
|
|
21
|
+
},
|
|
6
22
|
{
|
|
7
23
|
"capability_id": "product.media.collect",
|
|
8
24
|
"implementation": {
|
|
@@ -8,6 +8,22 @@
|
|
|
8
8
|
"workbuddy"
|
|
9
9
|
],
|
|
10
10
|
"capabilities": [
|
|
11
|
+
{
|
|
12
|
+
"id": "canvas.product.append",
|
|
13
|
+
"purpose": "Resolve an exact canvas title or ID, recover application layout, and append products without moving existing assets",
|
|
14
|
+
"required": true,
|
|
15
|
+
"required_features": ["canvas-list", "canvas-get", "source-metadata"],
|
|
16
|
+
"input_fields": ["canvas_title_or_id_or_base_run", "product_ids", "new_run_directory"],
|
|
17
|
+
"output_fields": ["canvas_id", "recovered_base_run", "cumulative_plan"],
|
|
18
|
+
"errors": ["CANVAS_NOT_FOUND", "AMBIGUOUS_CANVAS", "CANVAS_STATE_UNSUPPORTED", "CANVAS_CHANGED"],
|
|
19
|
+
"side_effects": {
|
|
20
|
+
"reads": "authenticated canvas list and content",
|
|
21
|
+
"writes": "private recovery snapshot and new run files; canvas only on explicit submit",
|
|
22
|
+
"authorization": "user requested appending products to the selected canvas",
|
|
23
|
+
"external_mutation": true,
|
|
24
|
+
"may_cost_money": false
|
|
25
|
+
}
|
|
26
|
+
},
|
|
11
27
|
{
|
|
12
28
|
"id": "product.media.collect",
|
|
13
29
|
"purpose": "Read ordered product media and source evidence",
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import crypto from 'node:crypto';
|
|
3
|
+
import {stateRoot} from './core.mjs';
|
|
4
|
+
import {invoke,fail,uid,create,REGIONS,validateLayout,matchesBatch,appendRun} from './workflow.mjs';
|
|
5
|
+
|
|
6
|
+
const labels=['主图区','主图视频区','SKU区','详情页区'];
|
|
7
|
+
const geometry=e=>Object.fromEntries(['x','y','width','height'].map(k=>[k,e[k]]));
|
|
8
|
+
const reject=message=>fail('CANVAS_STATE_UNSUPPORTED',message+' Use a completed --base-run when available. Existing canvas content was preserved.');
|
|
9
|
+
|
|
10
|
+
// Discovery is read-only; service keyword search is fuzzy, application selection is exact.
|
|
11
|
+
export async function findCanvases(title,call=invoke){
|
|
12
|
+
if(typeof title!=='string'||!title.trim())fail('INVALID_INPUT','Provide an exact canvas title.');
|
|
13
|
+
const found=new Map();let page=1;
|
|
14
|
+
for(;;){
|
|
15
|
+
const result=await call('sealseek-media',['canvas','list','--keyword',title,'--page',String(page),'--limit','100','--json']);
|
|
16
|
+
if(!Array.isArray(result.canvases))fail('OUTPUT_CONTRACT_FAILED','Invalid canvas list.');
|
|
17
|
+
for(const b of result.canvases)if(b.title===title)found.set(String(b.canvas_id),b);
|
|
18
|
+
const pages=Number(result.pages??1);
|
|
19
|
+
if(!Number.isInteger(pages)||pages<0||pages>10000)fail('OUTPUT_CONTRACT_FAILED','Invalid canvas pagination.');
|
|
20
|
+
if(page>=pages)break;page++;
|
|
21
|
+
}
|
|
22
|
+
return {title,count:found.size,canvases:[...found.values()]};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function selectCanvas({title,canvas},call=invoke){
|
|
26
|
+
let id=canvas;
|
|
27
|
+
if(title&&!canvas){
|
|
28
|
+
const result=await findCanvases(title,call);
|
|
29
|
+
if(!result.count)fail('CANVAS_NOT_FOUND','No canvas has this exact title.');
|
|
30
|
+
if(result.count>1)throw Object.assign(new Error('Multiple canvases have this title; select a canvas ID.'),{code:'AMBIGUOUS_CANVAS',details:result});
|
|
31
|
+
id=result.canvases[0].canvas_id;
|
|
32
|
+
}
|
|
33
|
+
if(!/^\d+$/.test(id||''))fail('INVALID_INPUT','Provide --title TEXT or --canvas ID.');
|
|
34
|
+
const board=await call('sealseek-media',['canvas','get',String(id),'--json']);
|
|
35
|
+
if(String(board.canvas_id)!==String(id))fail('OUTPUT_CONTRACT_FAILED','Canvas identity mismatch.');
|
|
36
|
+
if(title&&board.title!==title)fail('CANVAS_CHANGED','Selected canvas title differs.');
|
|
37
|
+
return board;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The application reconstructs only its own four-region contract. No provider API is duplicated.
|
|
41
|
+
// Frame hashes bind even empty video Frames to their product, category and collection run.
|
|
42
|
+
export function restoreCanvas(board,run_id=crypto.randomUUID()){
|
|
43
|
+
if(!board.canvas_id||!Array.isArray(board.elements))reject('Canvas content is unavailable.');
|
|
44
|
+
const elements=board.elements.filter(e=>!e.isDeleted),images=elements.filter(e=>e.type==='image');
|
|
45
|
+
const texts=labels.map(text=>{const found=elements.filter(e=>e.type==='text'&&e.text===text&&typeof e.id==='string'&&e.id.startsWith('region-'));if(found.length!==1)reject('Four unique region headings are required.');const e=found[0];if(!['x','y','width','height','fontSize'].every(k=>Number.isFinite(e[k]))||e.width<=0||e.height<=0||e.fontSize<8||e.fontSize>200)reject('Invalid heading geometry.');return {id:e.id,text,...geometry(e),fontSize:e.fontSize};});
|
|
46
|
+
if(texts.some((t,i)=>i&&t.x<=texts[i-1].x))reject('Unsupported region order.');
|
|
47
|
+
const products=new Map(),identities=new Map(),media=[],taskIds=new Set();
|
|
48
|
+
for(const e of images){
|
|
49
|
+
const s=e.customData?.sourceMetadata,task=e.customData?.cliTaskId;
|
|
50
|
+
if(!s||!REGIONS.includes(s.category)||!/^\d+$/.test(s.productId||'')||typeof s.assetId!=='string'||typeof task!=='string'||e.customData.cliOutputIndex!==0)reject('An image lacks application source metadata.');
|
|
51
|
+
const suffix='-'+s.assetId+'-'+s.category;
|
|
52
|
+
if(!task.endsWith(suffix))reject('Invalid application media identity.');
|
|
53
|
+
if(taskIds.has(task))reject('Duplicate application media identity.');taskIds.add(task);
|
|
54
|
+
const origin=task.slice(0,-suffix.length),key=origin+'|'+s.productId;
|
|
55
|
+
if(!origin)reject('Missing collection identity.');
|
|
56
|
+
if(!products.has(s.productId))products.set(s.productId,{id:s.productId,title:'商品 '+s.productId,url:s.sourceUrl,shop:{shopId:s.shopId,shopName:''},coverage:{recovered_from_canvas:true,original_collection:'unavailable'},assets:Object.fromEntries(REGIONS.map(c=>[c,[]]))});
|
|
57
|
+
identities.set(key,{origin,productId:s.productId});
|
|
58
|
+
const kind=s.category==='main_videos'?'video':'image',url=kind==='video'?e.customData.videoUrl:e.customData.fileUrl;
|
|
59
|
+
if(!url||(kind==='video'&&!e.customData.fileUrl)||!['x','y','width','height'].every(k=>Number.isFinite(e[k]))||e.width<=0||e.height<=0)reject('Invalid media geometry or URL.');
|
|
60
|
+
media.push({id:task,kind,urls:[url],frameId:e.frameId,placement:{mode:'explicit',positions:[geometry(e)]},...(kind==='video'?{posterUrl:e.customData.fileUrl}:{}),sourceMetadata:structuredClone(s)});
|
|
61
|
+
products.get(s.productId).assets[s.category].push({asset_id:s.assetId,kind,url,width:e.width,height:e.height,recovered:true,source_index:0});
|
|
62
|
+
}
|
|
63
|
+
if(!products.size)reject('No recoverable products were found.');
|
|
64
|
+
const frames=[],members=new Map(REGIONS.map(c=>[c,[]])),frameKeys=new Set();
|
|
65
|
+
for(const e of elements.filter(e=>e.type==='frame')){
|
|
66
|
+
const candidates=[];
|
|
67
|
+
for(const v of identities.values())for(const category of REGIONS)if(e.id==='frame-'+uid([v.origin,v.productId,category]))candidates.push({...v,category});
|
|
68
|
+
if(candidates.length!==1)reject('A Frame cannot be bound to an application product.');
|
|
69
|
+
const identity=candidates[0],p=products.get(identity.productId);
|
|
70
|
+
const key=identity.productId+'|'+identity.category;
|
|
71
|
+
if(frameKeys.has(key)||typeof e.name!=='string'||!e.name.trim()||!['x','y','width','height'].every(k=>Number.isFinite(e[k]))||e.width<=0||e.height<=0)reject('Duplicate Frame identity or invalid geometry.');
|
|
72
|
+
frameKeys.add(key);
|
|
73
|
+
if(p.shop.shopName&&p.shop.shopName!==e.name)reject('Product shop names differ across Frames.');
|
|
74
|
+
p.shop.shopName=e.name;
|
|
75
|
+
const frame={id:e.id,name:e.name,...geometry(e)};frames.push(frame);members.get(identity.category).push(frame);
|
|
76
|
+
for(const m of media.filter(m=>m.frameId===e.id))if(m.sourceMetadata.productId!==identity.productId||m.sourceMetadata.category!==identity.category)reject('Media source and Frame identity disagree.');
|
|
77
|
+
}
|
|
78
|
+
if(frames.length!==products.size*4||REGIONS.some(c=>members.get(c).length!==products.size)||media.some(m=>!frames.some(f=>f.id===m.frameId)))reject('Product Frames are incomplete.');
|
|
79
|
+
const known=new Set([...frames,...texts].map(e=>e.id));
|
|
80
|
+
if(elements.some(e=>!known.has(e.id)&&e.type!=='image'&&!(e.type==='rectangle'&&e.customData?.type==='frame-background'&&frames.some(f=>f.id===e.customData.ownerFrameId))))reject('The canvas contains unsupported elements.');
|
|
81
|
+
const regions=REGIONS.map((category,i)=>{
|
|
82
|
+
const list=members.get(category),horizontal=category==='detail_images',x=texts[i].x,y=Math.min(...list.map(f=>f.y));
|
|
83
|
+
list.sort((a,b)=>horizontal?a.x-b.x:a.y-b.y);
|
|
84
|
+
if(list.some(f=>horizontal?f.y!==y:f.x!==x)||list[0].x!==x)reject('Unsupported Frame alignment.');
|
|
85
|
+
const width=horizontal?Math.max(...list.map(f=>f.width)):texts[i+1].x-x-240;
|
|
86
|
+
if(list.some(f=>!horizontal&&f.width>width)||width<=0)reject('Unsupported region bounds.');
|
|
87
|
+
const bottom=Math.max(...list.map(f=>f.y+f.height)),right=Math.max(...list.map(f=>f.x+f.width));
|
|
88
|
+
return {category,x,y,width,axis:horizontal?'horizontal':'vertical',next_x:horizontal?right+96:x,next_y:horizontal?y:bottom+96,bottom,right,frame_ids:list.map(f=>f.id)};
|
|
89
|
+
});
|
|
90
|
+
const plan={schema_version:2,presentation_version:1,run_id,title:board.title,layout_options:{width:320,gap:32,padding:32,frameGap:96,regionGap:240},regions,batch:{frames,media,texts},recovered_from_canvas:String(board.canvas_id)};
|
|
91
|
+
validateLayout(plan);
|
|
92
|
+
if(!matchesBatch(board,plan.batch))reject('Recovered canvas does not match its source content.');
|
|
93
|
+
return {plan,manifest:{schema_version:1,run_id,captured_at:new Date().toISOString(),recovered_from_canvas:String(board.canvas_id),products:[...products.values()]}};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function appendToCanvas(run,selector,inputs,rawFiles=[],call=invoke){
|
|
97
|
+
const board=await selectCanvas(selector,call),{plan,manifest}=restoreCanvas(board);
|
|
98
|
+
const base=path.join(stateRoot(),'recovered-canvases',plan.run_id);
|
|
99
|
+
await create(path.join(base,'layout.plan.json'),plan);
|
|
100
|
+
await create(path.join(base,'canvas.batch.json'),plan.batch);
|
|
101
|
+
await create(path.join(base,'assets.manifest.json'),manifest);
|
|
102
|
+
await create(path.join(base,'canvas.state.json'),{run_id:plan.run_id,status:'completed',canvas_id:String(board.canvas_id),canvas_url:board.canvas_url});
|
|
103
|
+
return {...await appendRun(run,base,inputs,rawFiles),canvas_id:String(board.canvas_id),title:board.title,recovered_base_run:base};
|
|
104
|
+
}
|