@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.
@@ -3,26 +3,42 @@
3
3
  "schema_version": "1.0.0",
4
4
  "skill": "competitor-visual-canvas",
5
5
  "target_platforms": [
6
- "codex",
7
- "sealseek",
8
- "workbuddy"
6
+ "generic"
9
7
  ],
10
8
  "capabilities": [
11
9
  {
12
10
  "id": "canvas.product.append",
13
11
  "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"],
12
+ "required": false,
13
+ "required_features": [
14
+ "canvas-list",
15
+ "canvas-get",
16
+ "source-metadata"
17
+ ],
18
+ "input_fields": [
19
+ "canvas_title_or_id_or_base_run",
20
+ "product_ids",
21
+ "new_run_directory"
22
+ ],
23
+ "output_fields": [
24
+ "canvas_id",
25
+ "recovered_base_run",
26
+ "cumulative_plan"
27
+ ],
28
+ "errors": [
29
+ "CANVAS_NOT_FOUND",
30
+ "AMBIGUOUS_CANVAS",
31
+ "CANVAS_STATE_UNSUPPORTED",
32
+ "CANVAS_CHANGED"
33
+ ],
19
34
  "side_effects": {
20
35
  "reads": "authenticated canvas list and content",
21
36
  "writes": "private recovery snapshot and new run files; canvas only on explicit submit",
22
37
  "authorization": "user requested appending products to the selected canvas",
23
38
  "external_mutation": true,
24
39
  "may_cost_money": false
25
- }
40
+ },
41
+ "required_when": "target=infinite-canvas"
26
42
  },
27
43
  {
28
44
  "id": "product.media.collect",
@@ -52,7 +68,7 @@
52
68
  {
53
69
  "id": "canvas.assets.write",
54
70
  "purpose": "Place explicit Frames and existing media",
55
- "required": true,
71
+ "required": false,
56
72
  "required_features": [
57
73
  "frame-batch",
58
74
  "text-batch",
@@ -75,7 +91,220 @@
75
91
  "authorization": "user requested canvas collection",
76
92
  "external_mutation": true,
77
93
  "may_cost_money": false
94
+ },
95
+ "required_when": "target=infinite-canvas"
96
+ },
97
+ {
98
+ "id": "boardmix.images.write",
99
+ "purpose": "Archive explicit competitor image placements into the selected Boardmix file",
100
+ "required": false,
101
+ "required_when": "target=boardmix",
102
+ "required_features": [
103
+ "four-media-regions",
104
+ "frame-create",
105
+ "text-create",
106
+ "image-download-inspect-import",
107
+ "explicit-placement",
108
+ "guid-readback",
109
+ "same-run-recovery",
110
+ "base-run-append"
111
+ ],
112
+ "input_fields": [
113
+ "run_directory",
114
+ "session_file",
115
+ "file_key"
116
+ ],
117
+ "output_fields": [
118
+ "canvas_url",
119
+ "object_guids",
120
+ "readback",
121
+ "native_video_resource_verification"
122
+ ],
123
+ "side_effects": {
124
+ "external_mutation": true,
125
+ "may_cost_money": false,
126
+ "authorization": "User explicitly requested competitor images in Boardmix"
127
+ },
128
+ "errors": [
129
+ "CAPABILITY_UNAVAILABLE",
130
+ "TARGET_CONFLICT",
131
+ "SUBMISSION_UNCERTAIN",
132
+ "CANVAS_CHANGED"
133
+ ]
134
+ }
135
+ ],
136
+ "routing": {
137
+ "default_target": "infinite-canvas",
138
+ "explicit_targets": [
139
+ "infinite-canvas",
140
+ "boardmix"
141
+ ],
142
+ "persisted_in": "inputs.json, assets.manifest.json and layout.plan.json",
143
+ "conflict": "TARGET_CONFLICT"
144
+ },
145
+ "host_resolution": {
146
+ "fallback_adapter": "generic"
147
+ },
148
+ "boardmixVideoContract": {
149
+ "version": 1,
150
+ "dependency": "boardmixcli videoContract v1",
151
+ "backfill": "boardmix videos --base-run BASE --run NEW [--raw FILE ...]",
152
+ "migration": "Preserve image identity and relative geometry; insert video region before SKU"
153
+ },
154
+ "installationLifecycle": {
155
+ "agentNeutral": true,
156
+ "targetRequired": true,
157
+ "hostRegistrationRequired": true,
158
+ "installationMode": "copy",
159
+ "sharedLinksAllowed": false,
160
+ "isolatedRuntimeRequired": true,
161
+ "skillRequired": true,
162
+ "updateRequiresSkillSync": true,
163
+ "required_components": [
164
+ "cli",
165
+ "skill"
166
+ ],
167
+ "installGuide": "INSTALL.md",
168
+ "updateGuide": "UPDATE.md",
169
+ "install": "skill install with explicit current Agent and --path",
170
+ "update": "update install with explicit current Agent and --path",
171
+ "verification": "CLI and target-owned runtime versions, managed SHA256 receipt, host discovery",
172
+ "installerIntegration": "postinstall notice and scripts-disabled INSTALL.md"
173
+ },
174
+ "automaticUpdates": {
175
+ "enabledByDefault": true,
176
+ "intervalHours": 24,
177
+ "timeoutMs": 3000,
178
+ "trigger": "ordinary CLI execution",
179
+ "defaultMode": "notify",
180
+ "disable": "COMPETITOR_CANVAS_AUTO_UPDATE=0"
181
+ },
182
+ "maintenanceContract": {
183
+ "schemaVersion": 1,
184
+ "agentEligibility": "valid-name-with-generic-fallback",
185
+ "hostDiscoveryAuthority": "host",
186
+ "targetOptions": {
187
+ "--path": {
188
+ "kind": "skill-directory"
78
189
  }
190
+ },
191
+ "source": {
192
+ "name": {
193
+ "field": "name",
194
+ "type": "string"
195
+ },
196
+ "package": {
197
+ "field": "package",
198
+ "type": "string"
199
+ },
200
+ "version": {
201
+ "field": "version",
202
+ "type": "string"
203
+ },
204
+ "path": {
205
+ "field": "source",
206
+ "type": "string"
207
+ }
208
+ },
209
+ "status": {
210
+ "name": {
211
+ "field": "name",
212
+ "type": "string"
213
+ },
214
+ "package": {
215
+ "field": "package",
216
+ "type": "string"
217
+ },
218
+ "version": {
219
+ "field": "version",
220
+ "type": "string"
221
+ },
222
+ "target": {
223
+ "field": "target",
224
+ "type": "string"
225
+ },
226
+ "installed": {
227
+ "field": "installed",
228
+ "type": "boolean"
229
+ },
230
+ "managed": {
231
+ "field": "managed",
232
+ "type": "boolean"
233
+ },
234
+ "current": {
235
+ "field": "current",
236
+ "type": "boolean"
237
+ },
238
+ "edited": {
239
+ "field": "edited",
240
+ "type": "boolean"
241
+ },
242
+ "verification": {
243
+ "field": "verification",
244
+ "type": "object"
245
+ },
246
+ "recovery": {
247
+ "field": "recovery",
248
+ "type": "object"
249
+ }
250
+ }
251
+ },
252
+ "commandContracts": {
253
+ "skill source": {
254
+ "options": {
255
+ "--json": {
256
+ "type": "boolean"
257
+ },
258
+ "--agent": {
259
+ "type": "string"
260
+ },
261
+ "--path": {
262
+ "type": "string"
263
+ }
264
+ },
265
+ "sideEffects": "none"
266
+ },
267
+ "skill status": {
268
+ "options": {
269
+ "--json": {
270
+ "type": "boolean"
271
+ },
272
+ "--agent": {
273
+ "type": "string"
274
+ },
275
+ "--path": {
276
+ "type": "string"
277
+ }
278
+ },
279
+ "sideEffects": "none"
280
+ },
281
+ "skill install": {
282
+ "options": {
283
+ "--json": {
284
+ "type": "boolean"
285
+ },
286
+ "--agent": {
287
+ "type": "string"
288
+ },
289
+ "--path": {
290
+ "type": "string"
291
+ }
292
+ },
293
+ "sideEffects": "local-write"
294
+ },
295
+ "skill update": {
296
+ "options": {
297
+ "--json": {
298
+ "type": "boolean"
299
+ },
300
+ "--agent": {
301
+ "type": "string"
302
+ },
303
+ "--path": {
304
+ "type": "string"
305
+ }
306
+ },
307
+ "sideEffects": "local-write"
79
308
  }
80
- ]
309
+ }
81
310
  }
@@ -0,0 +1,42 @@
1
+ # Boardmix 素材分支
2
+
3
+ 仅当用户明确要求把竞品素材收集到 Boardmix 时进入。商品来源、选取范围、主图顺序、SKU 五列网格、详情图等宽等比无间隙竖排仍由本应用负责。写入主图、主图视频、SKU 图、详情图四个区域,每商品每区域一个以店铺命名的 Frame;区域标题使用独立文字。主图视频按原顺序在商品 Frame 内横排,商品按输入顺序向下排列;使用原生 MP4 视频对象。
4
+
5
+ ## 输入和依赖
6
+
7
+ 输入为商品 ID/链接、可选画板名称或明确的 Boardmix 文件 key/链接;服务会话由当前 Agent 在本机建立。应用运行目录保存 target、原始采集、下载源文件、布局、GUID 对应和回执。Boardmix 保存画板内容。会话令牌属于 boardmixcli,应用只接收会话文件路径。
8
+
9
+ 1. 执行 `competitor-visual-canvas tool run tbcli skill source --json`,读取精确的当前 tbcli Skill,按其合同完成采集与登录处理。
10
+ 2. 执行 `competitor-visual-canvas tool run boardmixcli skill source --json`,读取返回目录的完整当前 `SKILL.md`,依需要加载其当前适配器和操作合同。要求当前 CLI videoContract v1、ffprobe 可用,能力包含 video.download/inspect/import、image.download/inspect/import、frame.create、text.create、object.get/move/resize、board.info/create/open/rename。依赖是逻辑 Skill 名 `tbcli` 和 `boardmixcli`;不能解析或能力缺失时返回 CAPABILITY_UNAVAILABLE,回到依赖检查。此分支不需要无限画板账户。
11
+ 3. 本应用依赖调用统一通过 `tool run` 的解析路径,支持已配置入口、npm 依赖和 PATH。当前 Agent 的执行、文件与原生桌面工具须满足 boardmixcli 的合同;操作系统限制依该能力包声明判断,不按 Agent 名称设执行白名单。
12
+
13
+ ## 执行主线
14
+
15
+ 1. 新运行采集:`competitor-visual-canvas collect --target boardmix --input ID_OR_URL --run NEW_DIR --json`。可重复 input 或 raw 复用来源。目标写入输入和素材清单,保留四类原始覆盖。采集故障返回 tbcli 分支,不能作为素材缺失继续写入。
16
+ 2. 规划:`competitor-visual-canvas plan --run DIR [--title TEXT] --json`。本应用调用 boardmixcli 下载明确的图片和视频 URL、检查真实格式、尺寸与视频时长,再计算四个区域。规划时排除已验证的 KISSY spaceball.gif 网页懒加载占位图,并将来源和排除原因写入 excluded_assets;原始采集结果保留。其他 GIF 或未知图片类型按能力合同处理,不按扩展名批量忽略。下载原图保留字节、SHA256、来源与顺序,写入 layout.plan.json 和 boardmix.batch.json。格式不支持或来源失效时停止受影响节点;修复后恢复同一运行,已有文件先校验再复用。
17
+ 3. 按 boardmixcli 的当前连接合同建立或复用本机会话。默认先通过能力 CLI 执行 `browser.ensure --json`,由 boardmixcli 启动或复用 9223 Chrome 并打开 Boardmix。需要 browserContract v2;能力缺失时更新选定的 boardmixcli 安装。返回 AUTH_REQUIRED 时向用户转达登录提示,用户完成登录后执行 `auth.wait --wait-ms 60000 --json`;只有 state:ready 才继续。返回 AUTH_STATE_UNKNOWN 时检查页面加载状态,保留当前任务记录。使用返回的 endpoint;用户明确选择其他 CDP 地址时保留该选择。随后通过 browser.targets 查找精确文件 key,再用 browser.open 和 browser.connect 连接源编辑器。连接只调用 CLI,不打开开发者工具;浏览器连接失败保留所选宿主并处理原因。若需新建文件,先连接一个明确的源编辑器,再执行 `competitor-visual-canvas boardmix create --run DIR --session SESSION_FILE --file-key SOURCE_KEY [--cdp-endpoint URL] --submit --json`。默认沿用该源文件的项目/文件夹上下文。命令先保存新文件 key,再打开文件;传入 cdp-endpoint 时应用自动打开并连接新编辑器,返回 needs_attachment:false。桌面分支按 needs_attachment 连接后收起对应的开发者工具窗口。已创建的文件再次调用只返回原 key。用户明确指定已有文件时直接使用其 key,保留既有文件名称;新建文件按本应用计划命名。
18
+ 4. 预览:`competitor-visual-canvas canvas --run DIR --json`。写入:同命令增加 `--session SESSION_FILE --file-key TARGET_KEY --submit`;浏览器模式保留 `--cdp-endpoint URL`,自动检查并复用连接。CLI 从已保存计划选择 Boardmix 分支,显式 target 与保存值不一致即停止。创建文字/Frame 并保存 GUID 后校正原生尺寸。探测到 batch contract v1 的 image.inspect.batch、image.import.batch、object.get.batch、object.place.batch 时,先批量校验源文件,随后每批最多 20 张导入并按既定布局批量设置尺寸与位置,最后批量读回全板。保留源图顺序和全部布局规则;单个编辑器的写入串行调度,文件准备与原生上传可并行。纯图片运行可使用逐张兼容流程;含视频运行需要批量读写位置和 video.import 能力。视频使用固定回执逐条上传,读回 MP4 类型、源 MD5、加载完成状态,再按计划定位。只有选定目标的能力被调用。
19
+ 5. 读回检查 Frame 名称、坐标、尺寸,图片资源身份与几何位置,核对素材清单的分类数量、来源及视频导入结果。Frame 内图片验收采用几何包含关系;当前版本不声称已核验原生父子关系。文字内容与整体可读性由原生桌面目视检查补足;结构读回与视觉验收分别报告。完成后返回主 Skill 的交付节点,提供 Boardmix 链接、分类数量和实际缺口。
20
+
21
+ ## 恢复和支持范围
22
+
23
+ 视频支持原生 MP4,单文件上限 100 MiB;格式和大小由能力合同验证。VIDEO_OUTCOME_UNRESOLVED 时保留原回执并检查实际结果,恢复时复用原回执。主图视频封面仅作来源信息,不能替代视频归档。
24
+
25
+ - 相同 run 的重复写入复用原 file key 和已验证 GUID。完成的图元被手工移动、改尺寸、改 Frame 名称或换图时返回 CANVAS_CHANGED;保留现场,交由应用和用户决定修订。
26
+ - 批量导入使用运行目录中的固定 input 和 receipt。恢复同一 run 时自动查询已有结果并复用 GUID;已导入未定位的图片先补齐定位。BATCH_OUTCOME_UNRESOLVED 保留现场并检查现有结果,不能通过新建 receipt 重复导入。performance.elapsed_ms 表示本次执行耗时,恢复执行不等于完整首轮耗时。
27
+ - 单个创建/导入结果未知时,先保留 inflight 回执并停止,不能重复提交。按 boardmixcli 的当前合同检查目标视图和操作结果。得到实际 GUID 后使用 `boardmix recover --run DIR --session SESSION_FILE --file-key KEY --session-id N --local-id N --submit` 绑定;图片须匹配源文件 MD5,Frame 须匹配名称和尺寸。文字须先目视核对,再添加 `--verified`;新建文件结果未知时同样目视确认精确文件 key 后以 `--verified` 绑定。恢复后返回写入节点4。
28
+ - 首版支持同一运行恢复。按 Boardmix 名称搜索和迁移旧布局返回 FEATURE_UNSUPPORTED,不切换到其他画板服务。用户给出文件链接时直接提取其 key。
29
+ - 完成后默认保留画板供用户查看。用户明确要求退出时,浏览器模式通过 browser.close,桌面模式通过 board.close 的保存检查和关闭命令执行,并目视核对文件标签已消失。
30
+ - 发展规则:布局规则回写本应用;图片格式、图元和服务接入问题修复 boardmixcli。新分支的原始测试记录保留在开发证据目录,普通运行不依赖私有知识库或测试样本。
31
+
32
+ ## 向当前 Boardmix 画板追加商品
33
+
34
+ 输入为最新已完成的 BASE 运行目录与新增商品 ID。执行 `competitor-visual-canvas append --base-run BASE --run NEW_DIR --input ID [--input MORE] --json`。命令从基准识别 Boardmix,跳过已存在的商品,只采集和下载新增素材,再使用与无限画板相同的累计布局逻辑:主图、主图视频、SKU Frame 向下追加,详情 Frame 向右追加。原 Frame、媒体、区域标题、坐标和文件 key 保持不变;新运行复制旧素材并继承 GUID,可独立恢复写入。
35
+
36
+ 按主线第4步预览,再对同一文件 key 提交。写入前校验旧图元身份与位置;CANVAS_CHANGED 时保留现场。完成后以 NEW_DIR 作为下一次追加基准。基准运行记录 successor_run;过期基准或并行分叉返回 STALE_BASE_RUN,并给出最新运行目录。重复提交同一新运行复用 GUID。全部商品已存在时返回 changed:false,不新建画板或运行目录。原图元验证、四类布局、源素材与新旧 GUID 一致性均通过后,返回主线第5步交付验收。
37
+
38
+ ## 为已有图片画板补充主图视频
39
+
40
+ 用户明确要求补充视频时,使用 `competitor-visual-canvas boardmix videos --base-run LATEST_COMPLETED_RUN --run NEW_DIR [--raw CURRENT_ITEM_MEDIA.json ...] --json`。缺少 raw 时调用 tbcli 重新采集全部已有商品;要求 mainVideos 覆盖为 captured。命令复用原图片字节,下载真实视频,为各商品建立视频 Frame。图片画板增加视频区域时按主图、视频、SKU、详情顺序规划,SKU/详情整体右移;保留旧 GUID、相对布局和资源哈希。新运行记录迁移前位置,提交前核验全部旧对象,支持中断后继续定位。
41
+
42
+ 按主线预览并执行 canvas --submit。完成后用新运行作为后续追加基准;重复提交复用视频 GUID,已有视频的运行返回 VIDEOS_ALREADY_PRESENT。核验四类数量、全板几何、视频资源身份和实际播放。原始采集证据及旧运行保留。
@@ -0,0 +1,85 @@
1
+ import path from 'node:path';
2
+ import crypto from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import {create,checkpoint,read,fail} from './workflow.mjs';
5
+
6
+ const near=(a,b)=>Number.isFinite(a)&&Math.abs(a-b)<0.02;
7
+ const guidKey=g=>g.sessionID+':'+g.localID;
8
+ export async function writeBatches({dir,plan,state,assets,ctx,call,descriptors,assetPath}){
9
+ const stateFile=path.join(dir,'boardmix.state.json');
10
+ const started=Date.now();
11
+ const filesDir=path.join(dir,'operations');await fs.mkdir(filesDir,{recursive:true});
12
+ async function input(kind,value){const file=path.join(filesDir,kind+'-'+crypto.randomUUID()+'.json');await create(file,value);return file;}
13
+ async function readObjects(values){
14
+ const objects=[];
15
+ for(let i=0;i<values.length;i+=200){const file=await input('read',values.slice(i,i+200).map(v=>v.guid));objects.push(...(await call('object.get.batch',[...ctx,'--input',file])).objects);}
16
+ return new Map(objects.map(o=>[guidKey(o.guid),o]));
17
+ }
18
+ function check(d,saved,o){
19
+ if(!o||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','Object identity differs: '+d.id);
20
+ if(saved.migration_from&&!state.migration_verified){const prior=saved.migration_from;if(!['x','y','width','height'].every(k=>near(o[k],prior[k])))fail('CANVAS_CHANGED','Pre-migration placement differs: '+d.id);}
21
+ if(saved.placed&&!(d.kind==='text'?['x','y']:['x','y','width','height']).every(k=>near(o[k],d[k])))fail('CANVAS_CHANGED','Object placement differs: '+d.id);
22
+ if(['image','video'].includes(d.kind)){
23
+ const a=assets.get(d.media.sourceMetadata.assetId);
24
+ if(d.kind==='video'&&(o.mediaType!=='video/mp4'||o.mediaLoading))fail('VIDEO_NOT_READY','Native video is not ready.');
25
+ if(Math.abs(o.width/o.height-a.width/a.height)>0.01)fail('IMAGE_ORIENTATION_MISMATCH','Image ratio differs from source.');
26
+ }
27
+ }
28
+ async function place(ds){
29
+ if(!ds.length)return;
30
+ const file=await input('place',ds.map(d=>({...state.objects[d.id].guid,x:d.x,y:d.y,width:d.width,height:d.height})));
31
+ const result=await call('object.place.batch',[...ctx,'--input',file]);
32
+ const objects=new Map(result.objects.map(o=>[guidKey(o.guid),o]));
33
+ for(const d of ds){const saved=state.objects[d.id],o=objects.get(guidKey(saved.guid));check(d,{...saved,placed:true},o);saved.placed=true;saved.readback=o;}
34
+ await checkpoint(stateFile,state);
35
+ }
36
+ async function acceptImport(inflight){
37
+ const out=await call('image.import.batch',[...ctx,'--input',inflight.input,'--receipt',inflight.receipt]);
38
+ const requested=await read(inflight.input),byKey=new Map(out.items?.map(i=>[i.key,i]));
39
+ if(byKey.size!==requested.length||new Set(out.items.map(i=>guidKey(i.object.guid))).size!==requested.length)fail('SUBMISSION_UNCERTAIN','Batch returned incomplete or repeated GUIDs.');
40
+ for(const item of requested){const result=byKey.get(item.key);if(!result?.object?.guid||result.asset.sha256!==item.sha256||result.object.resourceHash!==result.asset.md5)fail('SUBMISSION_UNCERTAIN','Batch image mapping differs.');state.objects[item.key]={guid:result.object.guid,kind:'image',placed:false,resource_hash:result.object.resourceHash};}
41
+ delete state.inflight;await checkpoint(stateFile,state);
42
+ }
43
+ // Verify every planned source before a new board mutation.
44
+ const images=descriptors.filter(d=>d.kind==='image');
45
+ for(const d of images){const a=assets.get(d.media.sourceMetadata.assetId);if(!a||plan.asset_hashes&&plan.asset_hashes[a.asset_id]!==a.sha256||a.url!==d.media.urls[0])fail('ASSET_CHANGED','Source manifest differs.');}
46
+ const sourceInput=await input('inspect',images.map(d=>assetPath(dir,assets.get(d.media.sourceMetadata.assetId))));
47
+ const inspected=(await call('image.inspect.batch',['--input',sourceInput])).images;
48
+ if(inspected.length!==images.length||images.some((d,i)=>inspected[i].sha256!==assets.get(d.media.sourceMetadata.assetId).sha256))fail('ASSET_CHANGED','Source bytes differ.');
49
+ for(const d of descriptors.filter(d=>d.kind==='video')){const a=assets.get(d.media.sourceMetadata.assetId);if(!a||plan.asset_hashes[a.asset_id]!==a.sha256||a.url!==d.media.urls[0])fail('ASSET_CHANGED','Video manifest differs.');const v=await call('video.inspect',['--file',assetPath(dir,a)]);if(v.sha256!==a.sha256)fail('ASSET_CHANGED','Video bytes differ.');}
50
+ async function acceptVideo(inflight){const d=descriptors.find(d=>d.id===inflight.key),a=assets.get(d.media.sourceMetadata.assetId);const out=await call('video.import',[...ctx,'--file',assetPath(dir,a),'--receipt',inflight.receipt]);if(out.asset.sha256!==a.sha256||out.object.resourceHash!==out.asset.md5||out.object.mediaType!=='video/mp4'||out.object.mediaLoading)fail('SUBMISSION_UNCERTAIN','Video identity/readiness differs.');state.objects[d.id]={guid:out.object.guid,kind:'video',placed:false,resource_hash:out.object.resourceHash};delete state.inflight;await checkpoint(stateFile,state);}
51
+ if(state.inflight?.kind==='video')await acceptVideo(state.inflight);
52
+ if(state.inflight){if(state.inflight.kind!=='image.batch')fail('SUBMISSION_UNCERTAIN','Inspect the previous atomic creation before continuing.');await acceptImport(state.inflight);}
53
+ const existing=await readObjects(Object.values(state.objects));
54
+ for(const d of descriptors){const saved=state.objects[d.id];if(saved)check(d,saved,existing.get(guidKey(saved.guid)));}
55
+ if(Object.values(state.objects).some(o=>o.migration_from)&&!state.migration_verified){state.migration_verified=true;await checkpoint(stateFile,state);}
56
+ // Caller-provided Frame and heading geometry stays outside the capability package.
57
+ for(const d of descriptors.filter(d=>['frame','text'].includes(d.kind))){
58
+ if(!state.objects[d.id]){
59
+ state.inflight={key:d.id,kind:d.kind};await checkpoint(stateFile,state);
60
+ const r=await call(d.kind+'.create',[...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)]:[])]);
61
+ const o=r.readback;if(!o?.guid)fail('SUBMISSION_UNCERTAIN','Created GUID missing.');
62
+ state.objects[d.id]={guid:o.guid,kind:d.kind,placed:false,readback:o};delete state.inflight;await checkpoint(stateFile,state);
63
+ if(d.kind==='frame')await place([d]);else{check(d,{placed:true},o);state.objects[d.id].placed=true;await checkpoint(stateFile,state);}
64
+ }else if(!state.objects[d.id].placed){
65
+ const saved=state.objects[d.id],o=existing.get(guidKey(saved.guid));
66
+ if(d.kind==='text'){const result=await call('object.move',[...ctx,'--session-id',String(saved.guid.sessionID),'--local-id',String(saved.guid.localID),'--x',String(d.x),'--y',String(d.y)]);check(d,{...saved,placed:true},result);saved.placed=true;saved.readback=result;await checkpoint(stateFile,state);}else await place([d]);
67
+ }
68
+ }
69
+ // Complete interrupted positioning before submitting another import batch.
70
+ const unplaced=images.filter(d=>state.objects[d.id]&&!state.objects[d.id].placed);
71
+ for(let i=0;i<unplaced.length;i+=20)await place(unplaced.slice(i,i+20));
72
+ const missing=images.filter(d=>!state.objects[d.id]);
73
+ for(let i=0;i<missing.length;i+=20){
74
+ const group=missing.slice(i,i+20);
75
+ const file=await input('import',group.map(d=>{const a=assets.get(d.media.sourceMetadata.assetId);return {key:d.id,file:assetPath(dir,a),sha256:a.sha256};}));
76
+ state.inflight={kind:'image.batch',keys:group.map(d=>d.id),input:file,receipt:file+'.receipt.json'};await checkpoint(stateFile,state);
77
+ await acceptImport(state.inflight);await place(group);
78
+ process.stderr.write(JSON.stringify({phase:'boardmix-import',placed:images.filter(d=>state.objects[d.id]?.placed).length,total:images.length})+'\n');
79
+ }
80
+ for(const d of descriptors.filter(d=>d.kind==='video')){if(!state.objects[d.id]){state.inflight={kind:'video',key:d.id,receipt:path.join(filesDir,'video-'+crypto.randomUUID()+'.receipt.json')};await checkpoint(stateFile,state);await acceptVideo(state.inflight);}if(!state.objects[d.id].placed)await place([d]);}
81
+ const final=await readObjects(Object.values(state.objects));
82
+ for(const d of descriptors){const saved=state.objects[d.id],o=final.get(guidKey(saved.guid));check(d,{...saved,placed:true},o);saved.readback=o;}
83
+ state.performance={mode:'batch',batch_size:20,elapsed_ms:Date.now()-started,images:images.length};
84
+ await checkpoint(stateFile,state);
85
+ }