@petercjl/infinite-canvas-cli 0.0.0-bootstrap.0 → 0.1.0
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/LICENSE +9 -0
- package/README.md +44 -1
- package/bin/infinite-canvas.mjs +58 -0
- package/capabilities.json +219 -0
- package/package.json +32 -8
- package/skills/infinite-canvas-video/SKILL.md +59 -0
- package/skills/infinite-canvas-video/adapters/codex.json +24 -0
- package/skills/infinite-canvas-video/adapters/generic.json +24 -0
- package/skills/infinite-canvas-video/agents/openai.yaml +4 -0
- package/skills/infinite-canvas-video/capabilities.json +52 -0
- package/skills/infinite-canvas-video/references/runtime.md +30 -0
- package/src/browser.mjs +133 -0
- package/src/contracts.mjs +55 -0
- package/src/core.mjs +52 -0
- package/src/frame.mjs +35 -0
- package/src/lifecycle.mjs +80 -0
- package/src/media-cli.mjs +13 -0
- package/src/state.mjs +47 -0
- package/src/sync-contract.mjs +13 -0
- package/src/workflow.mjs +110 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Peter CJL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,46 @@
|
|
|
1
1
|
# Infinite Canvas CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
`@petercjl/infinite-canvas-cli` uses an existing SealSeek Infinite Canvas board to generate videos through its native Agent conversation. It imports ordered reference images, sends the supplied prompt in a fresh conversation, downloads the video, and groups the original image nodes and video node in a Frame.
|
|
4
|
+
|
|
5
|
+
This package requires Node.js 22+, the independent ecommerce Chrome launcher and an installed `sealseek-media` CLI supporting image upload and canvas get/add. The browser uses port 9223 and the dedicated ecommerce profile. The website supplies the selected model and authenticated account. Each authorized generation can incur charges.
|
|
6
|
+
|
|
7
|
+
Each board file uses exactly one browser tab. Concurrent videos run in separate Agent conversations within that tab; UI submissions and canvas writes are queued while remote generation runs in parallel. Duplicate tabs must be resolved before submission or saving.
|
|
8
|
+
|
|
9
|
+
## Agent usage
|
|
10
|
+
|
|
11
|
+
Provide the board ID, prompt and reference image attachments in their intended order. Send the Agent:
|
|
12
|
+
|
|
13
|
+
> 使用 infinite-canvas,画板 ID 为「画板ID」,按所附图片顺序作为图1、图2,生成视频:「完整提示词」。
|
|
14
|
+
|
|
15
|
+
To install:
|
|
16
|
+
|
|
17
|
+
> 安装 @petercjl/infinite-canvas-cli,并将配套 Skill 安装到本机 Codex。
|
|
18
|
+
|
|
19
|
+
To update:
|
|
20
|
+
|
|
21
|
+
> 更新 @petercjl/infinite-canvas-cli 及其本机 Codex 配套 Skill。
|
|
22
|
+
|
|
23
|
+
To recover:
|
|
24
|
+
|
|
25
|
+
> 使用 infinite-canvas,恢复任务「任务ID」。
|
|
26
|
+
|
|
27
|
+
To update the selected local Codex Skill:
|
|
28
|
+
|
|
29
|
+
> 将 infinite-canvas 当前安装包配套的 Skill 同步到本机 Codex。
|
|
30
|
+
|
|
31
|
+
Codex/macOS has completed six live video tasks across two boards. Other Agent adapters require runtime verification.
|
|
32
|
+
|
|
33
|
+
## Developer entry points
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
npm install
|
|
37
|
+
npm test
|
|
38
|
+
node bin/infinite-canvas.mjs --help
|
|
39
|
+
node bin/infinite-canvas.mjs doctor --json
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
See the bundled [Skill](skills/infinite-canvas-video/SKILL.md) and [runtime commands](skills/infinite-canvas-video/references/runtime.md). `capabilities --json` exposes machine-readable contracts. Video generation defaults to dry run; `--submit` starts a background job and returns its task ID. Resume never clicks Send again after a submission intent has been recorded.
|
|
43
|
+
|
|
44
|
+
Canvas operations reuse the existing helper CLI. Frame grouping provides the missing atomic operation for reparenting existing nodes, with backup, fresh-read conflict checks and save/readback verification. Website-authenticated read and canvas operations are isolated in one browser adapter. The adapter does not directly submit generation endpoints.
|
|
45
|
+
|
|
46
|
+
The service has no documented compare-and-swap canvas save contract. Avoid concurrent manual editing during a job; detected unsaved or conflicting changes stop progress for recovery. Canvas synchronization relies on the current webpage editor adapter and must be revalidated when the webpage changes.
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import {META,ROOT,need,parse,readJSON,execute,stateRoot,newJSON,redact,safeId} from '../src/core.mjs';
|
|
7
|
+
import {manifest,HELP} from '../src/contracts.mjs';
|
|
8
|
+
import {automaticCheck,checkUpdate,installUpdate,updatePolicy,SKILL,skillStatus,installSkill} from '../src/lifecycle.mjs';
|
|
9
|
+
|
|
10
|
+
const out=v=>console.log(JSON.stringify(v,null,2));
|
|
11
|
+
async function main(argv){
|
|
12
|
+
const [command,action,...rest]=argv;
|
|
13
|
+
if(!command||['help','--help','-h'].includes(command))return console.log(HELP);
|
|
14
|
+
need(Number(process.versions.node.split('.')[0])>=22,'CAPABILITY_UNAVAILABLE','Node.js 22 or newer is required.');
|
|
15
|
+
if(['version','--version','-v'].includes(command)){const {args,options}=parse(argv.slice(1),{booleans:['json']});need(!args.length,'INVALID_ARGUMENT','Unexpected arguments.');return options.json?out({ok:true,name:META.name,version:META.version,entry:path.join(ROOT,'bin/infinite-canvas.mjs')}):console.log('infinite-canvas '+META.version);}
|
|
16
|
+
if(argv.includes('--help')||argv.includes('-h')){need(['video','task','frame','browser','board','skill','update','doctor','capabilities'].includes(command),'UNKNOWN_COMMAND','Unknown command.');return console.log(HELP);}
|
|
17
|
+
if(command==='_worker'){need(argv.length===2,'INVALID_ARGUMENT','Invalid worker invocation.');const {processJob}=await import('../src/workflow.mjs');return out(await processJob(action));}
|
|
18
|
+
if(command==='skill'){
|
|
19
|
+
const {options:o,args}=parse(rest,{values:['agent','path'],booleans:['json']});need(!args.length,'INVALID_ARGUMENT','Unexpected arguments.');
|
|
20
|
+
if(action==='source')return out({ok:true,source:SKILL,name:'infinite-canvas-video',version:META.version,installation:{named_agents:['codex'],fallback_agent:'generic',custom_path_required:true,scope:'selected Agent only'}});
|
|
21
|
+
if(action==='status')return out(await skillStatus(o.agent||'codex',o.path));
|
|
22
|
+
need(['install','update'].includes(action),'UNKNOWN_COMMAND','Use skill source/status/install/update.');return out(await installSkill(o.agent||'codex',o.path));
|
|
23
|
+
}
|
|
24
|
+
if(command==='update'){
|
|
25
|
+
if(action==='auto'){const [mode,...flags]=rest;const {args}=parse(flags,{booleans:['json']});need(['on','off','status'].includes(mode)&&!args.length,'INVALID_ARGUMENT','Use update auto on/off/status.');return out(await updatePolicy(mode));}
|
|
26
|
+
const {options:o,args}=parse(rest,{values:['agent','path'],booleans:['yes','json']});need(!args.length,'INVALID_ARGUMENT','Unexpected arguments.');
|
|
27
|
+
if(action==='check')return out({ok:true,...await checkUpdate({force:true})});
|
|
28
|
+
need(action==='install','UNKNOWN_COMMAND','Use update check/install.');return out(await installUpdate({yes:o.yes,agent:o.agent,destination:o.path}));
|
|
29
|
+
}
|
|
30
|
+
if(command==='capabilities'){const {args}=parse(argv.slice(1),{booleans:['json']});need(!args.length,'INVALID_ARGUMENT','Unexpected arguments.');await automaticCheck();return out({ok:true,...manifest});}
|
|
31
|
+
if(command==='doctor'){
|
|
32
|
+
const {options:o,args}=parse(argv.slice(1),{values:['canvas'],booleans:['live','json']});need(!args.length,'INVALID_ARGUMENT','Unexpected arguments.');await automaticCheck();const checks={node:process.version,entry:path.join(ROOT,'bin/infinite-canvas.mjs'),platform:process.platform,skill:SKILL};
|
|
33
|
+
for(const library of ['playwright-core','image-size']){try{await import(library);checks[library]={ok:true};}catch{checks[library]={ok:false};}}
|
|
34
|
+
try{checks.media={ok:true,version:(await execute(process.env.INFINITE_CANVAS_MEDIA_BIN||'sealseek-media',['--version'],{timeout:15000})).stdout.trim()};}catch{checks.media={ok:false};}
|
|
35
|
+
try{checks.browser=JSON.parse((await execute(process.env.INFINITE_CANVAS_BROWSER_BIN||path.join(os.homedir(),'.local','bin','ecommerce-browser'),['status','--json'],{timeout:15000})).stdout);}catch{checks.browser={ok:false};}
|
|
36
|
+
if(o.live){need(o.canvas,'INVALID_ARGUMENT','Live doctor requires --canvas ID.');const {BrowserDriver,LOCATORS}=await import('../src/browser.mjs');const driver=new BrowserDriver();try{await driver.connect(o.canvas,{open:false});const board=await driver.getBoard();checks.live={canvas_id:String(board.id),title:board.title,locators:{}};for(const [k,s]of Object.entries(LOCATORS))checks.live.locators[k]=await driver.page.locator(s).count();}finally{await driver.close();}}
|
|
37
|
+
const ok=checks.media.ok&&checks['playwright-core'].ok&&checks['image-size'].ok&&checks.browser.state==='ready';if(!ok)process.exitCode=1;return out({ok,checks,paid_action:false,end_to_end:'not-tested-by-doctor'});
|
|
38
|
+
}
|
|
39
|
+
if(command==='browser'&&action==='status'){const {args}=parse(rest,{booleans:['json']});need(!args.length,'INVALID_ARGUMENT','Unexpected arguments.');const r=await execute(process.env.INFINITE_CANVAS_BROWSER_BIN||path.join(os.homedir(),'.local','bin','ecommerce-browser'),['status','--json'],{timeout:15000});return out(JSON.parse(r.stdout));}
|
|
40
|
+
if(command==='board'&&action==='inspect'){const {options:o,args}=parse(rest,{values:['canvas'],booleans:['json']});need(!args.length&&o.canvas,'INVALID_ARGUMENT','Provide --canvas ID.');const {MediaCLI}=await import('../src/media-cli.mjs');return out(await new MediaCLI().get(o.canvas));}
|
|
41
|
+
if(command==='video'&&action==='generate'){
|
|
42
|
+
const {options:o,args}=parse(rest,{values:['canvas','prompt','prompt-file','output','request-id'],repeat:['reference'],booleans:['new','submit','dry-run','json']});need(!args.length&&!(o.submit&&o['dry-run']),'INVALID_ARGUMENT','Conflicting options or extra arguments.');need(!!o.prompt!==!!o['prompt-file'],'INVALID_ARGUMENT','Provide exactly one prompt source.');need(!(o.new&&o['request-id']),'INVALID_ARGUMENT','Use new or request-id.');
|
|
43
|
+
const {prepareInput,createJob,summary}=await import('../src/state.mjs');const input=await prepareInput({canvas:o.canvas,prompt:o.prompt??await fs.readFile(o['prompt-file'],'utf8'),references:o.reference,output:o.output});await automaticCheck();
|
|
44
|
+
if(!o.submit)return out({ok:true,dry_run:true,paid_action:false,canvas_id:input.canvasId,prompt:input.prompt,references:input.references.map((r,i)=>({label:'图'+(i+1),source:r.source,sha256:r.sha256||null})),workflow:['import-references','new-native-conversation','ordered-attachments','send-prompt','follow-run','download','deduplicate-video','group-original-nodes-in-frame'],generation_submission:'webpage Agent Send button',model:'inherit webpage settings'});
|
|
45
|
+
const {job,reused}=await createJob(input,{requestId:o['request-id'],newRequest:!!o.new});if(!reused){const {spawnWorker}=await import('../src/workflow.mjs');await spawnWorker(job.id);}return out({...summary(job),reused});
|
|
46
|
+
}
|
|
47
|
+
if(command==='task'){
|
|
48
|
+
const {options:o,args}=parse(rest,{values:['timeout'],booleans:['json']});need(args.length===1,'INVALID_ARGUMENT','Provide one task ID.');const {getJob,summary}=await import('../src/state.mjs');
|
|
49
|
+
if(action==='get'){need(!o.timeout,'INVALID_ARGUMENT','timeout applies only to wait.');return out(summary(await getJob(args[0])));}
|
|
50
|
+
const {resumeJob,waitJob,syncJob}=await import('../src/workflow.mjs');if(['resume','sync'].includes(action)){need(!o.timeout,'INVALID_ARGUMENT','timeout applies only to wait.');return out(await (action==='sync'?syncJob:resumeJob)(args[0]));}need(action==='wait','UNKNOWN_COMMAND','Use task get/wait/resume/sync.');return out(await waitJob(args[0],Number(o.timeout||30)));
|
|
51
|
+
}
|
|
52
|
+
if(command==='frame'&&action==='group'){
|
|
53
|
+
const {options:o,args}=parse(rest,{values:['canvas','elements','frame-id','name'],booleans:['submit','json']});need(!args.length&&o.canvas&&o.elements&&o['frame-id'],'INVALID_ARGUMENT','Provide canvas, elements JSON file, and frame-id.');safeId(o.canvas,'canvas ID');safeId(o['frame-id'],'Frame ID');const ids=await readJSON(o.elements);need(Array.isArray(ids)&&ids.length>0&&ids.length<=30&&new Set(ids).size===ids.length,'INVALID_INPUT','Provide unique element IDs.');ids.forEach(id=>safeId(id,'element ID'));await automaticCheck();
|
|
54
|
+
const {BrowserDriver}=await import('../src/browser.mjs');const {groupIntoFrame}=await import('../src/frame.mjs');const driver=new BrowserDriver();try{await driver.connect(o.canvas);const group=content=>groupIntoFrame(content,{ids,frameId:o['frame-id'],name:o.name});if(!o.submit){const board=await driver.getBoard();const preview=group(board.content);delete preview.content;return out({ok:true,dry_run:true,...preview});}const directory=path.join(stateRoot(),'frame-operations',crypto.randomUUID());return out({ok:true,...await driver.saveFrame(group,directory),content:undefined});}finally{await driver.close();}
|
|
55
|
+
}
|
|
56
|
+
need(false,'UNKNOWN_COMMAND','Unknown command. Use --help.');
|
|
57
|
+
}
|
|
58
|
+
main(process.argv.slice(2)).catch(e=>{out({ok:false,error:redact(e)});process.exitCode=1;});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "infinite-canvas-capabilities",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"package": "@petercjl/infinite-canvas-cli",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"input": {
|
|
7
|
+
"required": [
|
|
8
|
+
"canvas_id",
|
|
9
|
+
"prompt",
|
|
10
|
+
"references"
|
|
11
|
+
],
|
|
12
|
+
"references": "ordered PNG/JPEG/WebP local files or HTTPS URLs"
|
|
13
|
+
},
|
|
14
|
+
"output": {
|
|
15
|
+
"terminal": "local MP4 + saved Frame containing original reference nodes and one video node",
|
|
16
|
+
"fields": [
|
|
17
|
+
"task_id",
|
|
18
|
+
"status",
|
|
19
|
+
"canvas_id",
|
|
20
|
+
"session_id",
|
|
21
|
+
"run_id",
|
|
22
|
+
"frame_id",
|
|
23
|
+
"reference_element_ids",
|
|
24
|
+
"video_element_id",
|
|
25
|
+
"artifacts"
|
|
26
|
+
]
|
|
27
|
+
},
|
|
28
|
+
"operations": [
|
|
29
|
+
"browser.status",
|
|
30
|
+
"board.inspect",
|
|
31
|
+
"video.generate.native-agent",
|
|
32
|
+
"task.inspect",
|
|
33
|
+
"task.wait",
|
|
34
|
+
"task.resume",
|
|
35
|
+
"task.sync",
|
|
36
|
+
"frame.group-existing"
|
|
37
|
+
],
|
|
38
|
+
"generationTransport": {
|
|
39
|
+
"kind": "native-browser-ui",
|
|
40
|
+
"newConversationPerLogicalVideo": true,
|
|
41
|
+
"singleTabPerCanvas": true,
|
|
42
|
+
"concurrency": "separate conversations in the same tab; queued UI submissions and canvas writes; parallel remote runs",
|
|
43
|
+
"submission": "click native Send",
|
|
44
|
+
"directGenerationAPI": false,
|
|
45
|
+
"recovery": "saved sessionId/runId; read-only run and history APIs",
|
|
46
|
+
"model": "inherit webpage settings"
|
|
47
|
+
},
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"node": ">=22",
|
|
50
|
+
"browser": "ecommerce-browser, fixed loopback CDP 9223 and current-user ecommerce profile",
|
|
51
|
+
"canvasAPI": "sealseek-media >=0.6.7, public CLI commands",
|
|
52
|
+
"libraries": [
|
|
53
|
+
"playwright-core",
|
|
54
|
+
"image-size"
|
|
55
|
+
],
|
|
56
|
+
"optional": [
|
|
57
|
+
"ffprobe"
|
|
58
|
+
]
|
|
59
|
+
},
|
|
60
|
+
"platforms": {
|
|
61
|
+
"codex:macos": "tested: six native Agent video tasks across two boards; one tab per board; downloads, deduplication and Frame readback verified",
|
|
62
|
+
"generic:macos": "spec-compatible, runtime untested",
|
|
63
|
+
"sealseek:macos": "spec-compatible, runtime untested",
|
|
64
|
+
"generic:windows": "runtime unresolved: ecommerce browser launcher required"
|
|
65
|
+
},
|
|
66
|
+
"commandContracts": {
|
|
67
|
+
"video generate": {
|
|
68
|
+
"options": {
|
|
69
|
+
"--canvas": {
|
|
70
|
+
"type": "string"
|
|
71
|
+
},
|
|
72
|
+
"--prompt": {
|
|
73
|
+
"type": "string"
|
|
74
|
+
},
|
|
75
|
+
"--prompt-file": {
|
|
76
|
+
"type": "string"
|
|
77
|
+
},
|
|
78
|
+
"--reference": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"repeatable": true
|
|
81
|
+
},
|
|
82
|
+
"--output": {
|
|
83
|
+
"type": "string"
|
|
84
|
+
},
|
|
85
|
+
"--request-id": {
|
|
86
|
+
"type": "string"
|
|
87
|
+
},
|
|
88
|
+
"--new": {
|
|
89
|
+
"type": "boolean"
|
|
90
|
+
},
|
|
91
|
+
"--submit": {
|
|
92
|
+
"type": "boolean"
|
|
93
|
+
},
|
|
94
|
+
"--dry-run": {
|
|
95
|
+
"type": "boolean"
|
|
96
|
+
},
|
|
97
|
+
"--json": {
|
|
98
|
+
"type": "boolean"
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
"sideEffects": "local-write",
|
|
102
|
+
"externalMutation": "With --submit: import references, send native Agent conversation, import a missing video, group selected elements in a Frame.",
|
|
103
|
+
"paid": "Only native webpage sending may charge credits."
|
|
104
|
+
},
|
|
105
|
+
"task get|wait|resume|sync": {
|
|
106
|
+
"options": {
|
|
107
|
+
"--timeout": {
|
|
108
|
+
"type": "string"
|
|
109
|
+
},
|
|
110
|
+
"--json": {
|
|
111
|
+
"type": "boolean"
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
"sideEffects": "local-write",
|
|
115
|
+
"externalMutation": "resume continues original task; sync reapplies existing video archival and grouping; never a second generation."
|
|
116
|
+
},
|
|
117
|
+
"frame group": {
|
|
118
|
+
"options": {
|
|
119
|
+
"--canvas": {
|
|
120
|
+
"type": "string"
|
|
121
|
+
},
|
|
122
|
+
"--elements": {
|
|
123
|
+
"type": "string"
|
|
124
|
+
},
|
|
125
|
+
"--frame-id": {
|
|
126
|
+
"type": "string"
|
|
127
|
+
},
|
|
128
|
+
"--name": {
|
|
129
|
+
"type": "string"
|
|
130
|
+
},
|
|
131
|
+
"--submit": {
|
|
132
|
+
"type": "boolean"
|
|
133
|
+
},
|
|
134
|
+
"--json": {
|
|
135
|
+
"type": "boolean"
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
"sideEffects": "local-write",
|
|
139
|
+
"externalMutation": "With --submit: reparent existing nodes and preserve their identities."
|
|
140
|
+
},
|
|
141
|
+
"doctor": {
|
|
142
|
+
"options": {
|
|
143
|
+
"--canvas": {
|
|
144
|
+
"type": "string"
|
|
145
|
+
},
|
|
146
|
+
"--live": {
|
|
147
|
+
"type": "boolean"
|
|
148
|
+
},
|
|
149
|
+
"--json": {
|
|
150
|
+
"type": "boolean"
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"sideEffects": "none"
|
|
154
|
+
},
|
|
155
|
+
"skill source": {
|
|
156
|
+
"options": {
|
|
157
|
+
"--json": {
|
|
158
|
+
"type": "boolean"
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
"sideEffects": "none"
|
|
162
|
+
},
|
|
163
|
+
"skill status|install|update": {
|
|
164
|
+
"options": {
|
|
165
|
+
"--agent": {
|
|
166
|
+
"type": "string"
|
|
167
|
+
},
|
|
168
|
+
"--path": {
|
|
169
|
+
"type": "string"
|
|
170
|
+
},
|
|
171
|
+
"--json": {
|
|
172
|
+
"type": "boolean"
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
"sideEffects": "local-write"
|
|
176
|
+
},
|
|
177
|
+
"update check|install": {
|
|
178
|
+
"options": {
|
|
179
|
+
"--yes": {
|
|
180
|
+
"type": "boolean"
|
|
181
|
+
},
|
|
182
|
+
"--agent": {
|
|
183
|
+
"type": "string"
|
|
184
|
+
},
|
|
185
|
+
"--path": {
|
|
186
|
+
"type": "string"
|
|
187
|
+
},
|
|
188
|
+
"--json": {
|
|
189
|
+
"type": "boolean"
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
"sideEffects": "package-and-skill-update"
|
|
193
|
+
},
|
|
194
|
+
"update auto": {
|
|
195
|
+
"options": {
|
|
196
|
+
"--json": {
|
|
197
|
+
"type": "boolean"
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
"sideEffects": "local-write"
|
|
201
|
+
}
|
|
202
|
+
},
|
|
203
|
+
"automaticUpdates": {
|
|
204
|
+
"enabledByDefault": true,
|
|
205
|
+
"defaultMode": "notify",
|
|
206
|
+
"intervalHours": 24,
|
|
207
|
+
"timeoutMs": 1500,
|
|
208
|
+
"trigger": "ordinary video/frame/doctor/capabilities entry; excludes help/version/skill/task polling",
|
|
209
|
+
"configuration": "private update-policy.json; update auto on|off|status",
|
|
210
|
+
"disable": "INFINITE_CANVAS_AUTO_UPDATE=0",
|
|
211
|
+
"recovery": "offline backoff preserves installation; source checkout updates through Git; explicit install keeps a package backup",
|
|
212
|
+
"installation": "explicit --yes only; no automatic installation"
|
|
213
|
+
},
|
|
214
|
+
"limitations": [
|
|
215
|
+
"No service-side atomic canvas revision check; stop on detected user edits.",
|
|
216
|
+
"Frame grouping first version supports unrotated image/video nodes.",
|
|
217
|
+
"Do not submit another generation after unknown submission."
|
|
218
|
+
]
|
|
219
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@petercjl/infinite-canvas-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"url": "git+https://github.com/petercjl/infinite-canvas-cli.git"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drive native Infinite Canvas Agent conversations and organize reference images and results in a Frame.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"infinite-canvas": "bin/infinite-canvas.mjs"
|
|
9
8
|
},
|
|
10
9
|
"files": [
|
|
11
|
-
"
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"skills",
|
|
13
|
+
"capabilities.json",
|
|
14
|
+
"README.md",
|
|
15
|
+
"LICENSE"
|
|
12
16
|
],
|
|
17
|
+
"engines": {
|
|
18
|
+
"node": ">=22"
|
|
19
|
+
},
|
|
20
|
+
"license": "MIT",
|
|
13
21
|
"publishConfig": {
|
|
14
22
|
"access": "public"
|
|
15
|
-
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test",
|
|
26
|
+
"check": "node --check bin/infinite-canvas.mjs && node --test"
|
|
27
|
+
},
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"playwright-core": "1.62.1",
|
|
30
|
+
"image-size": "2.0.4"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/petercjl/infinite-canvas-cli.git"
|
|
35
|
+
},
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/petercjl/infinite-canvas-cli/issues"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/petercjl/infinite-canvas-cli#readme"
|
|
16
40
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: infinite-canvas-video
|
|
3
|
+
description: "Use infinite-canvas to generate a video through an existing SealSeek Infinite Canvas Agent conversation UI, using a board ID, an unchanged user prompt, and ordered reference images; retrieve the video and group original image and video nodes in a Frame. Use when the user explicitly selects this canvas workflow."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Infinite Canvas Video
|
|
7
|
+
|
|
8
|
+
Execute an explicitly selected canvas video request with the installed `infinite-canvas` capability. The application or user owns creative choices, prompt, reference selection/order and acceptance. The CLI owns native submission, authentication reuse, canvas writes, idempotency and recovery.
|
|
9
|
+
|
|
10
|
+
## Input and output
|
|
11
|
+
|
|
12
|
+
Require a board file ID, the exact prompt and 1–20 ordered PNG, JPEG or WebP references (local paths or HTTPS URLs). Ask only for missing required inputs. Preserve the prompt and image order; 图1 means the first attached image. Inherit model settings from the webpage. An authorized generation request allows one paid native Agent submission. A planning or dry-run request allows validation only.
|
|
13
|
+
|
|
14
|
+
Return the local MP4 from `artifacts[].path`, task ID, board ID and Frame ID. Success means `status=succeeded`, a nonempty downloaded video, original reference node IDs plus one video node in the saved Frame, and verified readback. A generated video with unfinished grouping is a partial result, reported with its task ID and recovery action.
|
|
15
|
+
|
|
16
|
+
## Capability resolution
|
|
17
|
+
|
|
18
|
+
Resolve `infinite-canvas` from PATH. Run `version --json`, `capabilities --json` and `skill source --json` to inspect the installed implementation and current canonical Skill. Minimum contract: native Agent Send, ordered references, existing-node grouping, task get/wait/resume, local video output. Load the selected host adapter in `adapters/`; `generic.json` is the portable fallback. If the entry or required feature is missing, return `CAPABILITY_UNAVAILABLE`; report the missing dependency instead of substituting another generation provider.
|
|
19
|
+
|
|
20
|
+
External dependencies are Node.js 22+, `playwright-core` and `image-size` (package managed), the independent `ecommerce-browser` launcher, and `sealseek-media` with image upload, canvas get/add capabilities. This Skill invokes the helper through the CLI contract; it does not compose another generation Skill. Browser authentication stays in the user's dedicated Chrome profile. Never copy cookies or tokens into a prompt, output or package.
|
|
21
|
+
|
|
22
|
+
## Main line
|
|
23
|
+
|
|
24
|
+
1. Validate required inputs and authorization. Run `doctor --json`; for a UI compatibility check use `doctor --live --canvas ID --json`. On missing login or board, ask the user to sign in/open the board in the dedicated ecommerce Chrome, then return here. The CLI uses CDP port 9223 and the dedicated ecommerce profile. Enforce the single-tab constraint below before any submission or canvas write.
|
|
25
|
+
2. Prepare the exact prompt in a new file when needed; preserve any existing file. Run `video generate --canvas ID --prompt-file FILE --reference IMAGE1 --reference IMAGE2 --dry-run --json`, repeating `--reference` in order. Dry run checks local inputs; it does not prove provider availability.
|
|
26
|
+
3. For an authorized real request, run the same inputs with `--submit --json`. Save the returned task ID immediately. Repeated identical inputs reuse the same task. `--request-id KEY` provides caller-controlled idempotency; `--new` deliberately creates a new paid task and requires explicit intent to generate another video.
|
|
27
|
+
4. Follow `task wait TASK_ID --timeout 30 --json` or `task get TASK_ID --json`. The worker imports references, creates a new native Agent conversation, attaches images, pastes the exact prompt and clicks Send. Status/history reads track the captured session and run. Generation is submitted only through the native UI.
|
|
28
|
+
5. On completion, inspect the returned artifact and report its local path. The worker deduplicates the generated video against the board and groups the already imported image nodes and video into one actual Frame. Use the same task ID for any unfinished download or grouping.
|
|
29
|
+
|
|
30
|
+
## Single-tab constraint and concurrency
|
|
31
|
+
|
|
32
|
+
**Each board file must be open in exactly one browser tab.** Multiple tabs editing the same board cause save conflicts and the website may suspend editing. Reuse that board's existing tab. A different board may have its own tab.
|
|
33
|
+
|
|
34
|
+
Run concurrent videos through separate native Agent conversations inside the board's single tab. Serialize the short UI operations: import references, create a fresh conversation, attach images, send once and capture session/run IDs. After submission, track each run independently while preparing another conversation in the same tab. Serialize canvas imports and Frame writes per board; remote generation waiting does not hold that queue. Keep the requested board file count unchanged when adding concurrent video tasks.
|
|
35
|
+
|
|
36
|
+
Start one `video generate --submit` task per requested video, record every returned ID, and poll all tasks together. Submit the remaining tasks while earlier runs generate. The CLI queues tab selection and composition per board, including simultaneous first use. Use the installed CLI's verified selectors and transport; ordinary runs require no new selector or endpoint exploration.
|
|
37
|
+
|
|
38
|
+
Conversation readiness means native session creation has completed before attaching references. Attachment readiness counts only the current composer and waits for uploaded HTTPS images. Other conversations may still stream during these steps. After canvas API writes, the CLI synchronizes the editor in place and verifies saved/live scene agreement. Page reload is excluded from this write path because the old scene's auto-save can replace newly imported nodes or Frames. Webpage contract changes return an adapter error for a separate repair cycle.
|
|
39
|
+
|
|
40
|
+
If duplicate tabs for a board are detected, stop new submissions and writes with `BOARD_SELECTION_REQUIRED`. Preserve all task/session/run IDs, resolve the duplicate tabs, then resume existing tasks. When the website displays its other-tab editing protection, restore editing in the single retained tab after duplicates are closed. Claim resumed generation only from the existing run; a tab conflict never authorizes another paid submission.
|
|
41
|
+
|
|
42
|
+
## Branches and recovery
|
|
43
|
+
|
|
44
|
+
- `waiting`: the monitoring window or a connection ended; use `task resume TASK_ID --json`, then return to step 4. Remote runs continue independently.
|
|
45
|
+
- `uncertain` / `SUBMISSION_UNCERTAIN`: preserve the task; resume inspects the captured session for exactly one run. If identity cannot be resolved, report the uncertainty and request inspection. Never start another paid task as an automatic retry.
|
|
46
|
+
- `canvas_pending`: return any downloaded video as a partial result, then resume the same task for import/grouping. It does not resubmit generation.
|
|
47
|
+
- For an explicitly requested repair of canvas archival after a completed task, use `task sync TASK_ID --json`. It reapplies the existing video's import/grouping with the original reference nodes and never generates another video. Preserve intentional user deletions unless restoration is authorized.
|
|
48
|
+
- `UI_LOCATOR_CHANGED`, `AGENT_MODE_REQUIRED`, `REFERENCE_ORDER_CHANGED`: stop and report the adapter issue. If sending may have happened, preserve the task and inspect its history before further action.
|
|
49
|
+
- `CANVAS_UNSAVED`, `CANVAS_CHANGED`, `UI_BUSY`: ask the user to finish active work and save; resume the same task after the board is settled.
|
|
50
|
+
- `PROVIDER_FAILURE`: report the existing run's failure. Another video attempt is a new paid action.
|
|
51
|
+
- File conflicts or unmanaged Skill/state: preserve existing files and report the exact conflict.
|
|
52
|
+
|
|
53
|
+
Read [runtime reference](references/runtime.md) only when command details or recovery diagnostics are needed, then return to the relevant main-line step. Do not infer completion from chat prose alone.
|
|
54
|
+
|
|
55
|
+
## QA and evolution
|
|
56
|
+
|
|
57
|
+
Check one clean user message, captured native session/run IDs, attachment order at submission, exactly one result URL, returned reference metadata when provided, artifact bytes/hash and saved Frame membership. Report unavailable reference metadata or absent ffprobe verification honestly. Scope claims to actually tested hosts and operating systems. The current browser adapter needs live validation after webpage changes; independent clean-context regression is user gated.
|
|
58
|
+
|
|
59
|
+
Install/update only the requested Agent through package-managed `skill install/update --agent codex`. The package is the source of truth. Automatic update detection is bounded and can be disabled; installation is explicit. Keep model defaults and private assets outside the package.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-adapter",
|
|
3
|
+
"platform": "codex",
|
|
4
|
+
"mappings": [
|
|
5
|
+
{
|
|
6
|
+
"capability_id": "canvas.video.execute",
|
|
7
|
+
"status": "implemented",
|
|
8
|
+
"features": [
|
|
9
|
+
"native-agent-send",
|
|
10
|
+
"new-conversation",
|
|
11
|
+
"ordered-reference-images",
|
|
12
|
+
"canvas-import",
|
|
13
|
+
"existing-node-frame-group",
|
|
14
|
+
"task-recovery",
|
|
15
|
+
"local-video-output"
|
|
16
|
+
],
|
|
17
|
+
"implementation": {
|
|
18
|
+
"kind": "cli",
|
|
19
|
+
"command": "infinite-canvas",
|
|
20
|
+
"instructions": "Resolve from PATH. Run version, capabilities and doctor. Requires Node.js >=22, ecommerce-browser and sealseek-media. Use structured JSON. Codex/macOS end-to-end verified with six native Agent videos across two boards, one tab per board, downloads and saved Frame readback. Other hosts require their own runtime validation."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-adapter",
|
|
3
|
+
"platform": "generic",
|
|
4
|
+
"mappings": [
|
|
5
|
+
{
|
|
6
|
+
"capability_id": "canvas.video.execute",
|
|
7
|
+
"status": "implemented",
|
|
8
|
+
"features": [
|
|
9
|
+
"native-agent-send",
|
|
10
|
+
"new-conversation",
|
|
11
|
+
"ordered-reference-images",
|
|
12
|
+
"canvas-import",
|
|
13
|
+
"existing-node-frame-group",
|
|
14
|
+
"task-recovery",
|
|
15
|
+
"local-video-output"
|
|
16
|
+
],
|
|
17
|
+
"implementation": {
|
|
18
|
+
"kind": "cli",
|
|
19
|
+
"command": "infinite-canvas",
|
|
20
|
+
"instructions": "Resolve from PATH. Run version, capabilities and doctor. Requires Node.js >=22, ecommerce-browser and sealseek-media. Use structured JSON. Named-host runtime end-to-end verification is pending."
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "portable-skill-capabilities",
|
|
3
|
+
"schema_version": "1.0.0",
|
|
4
|
+
"skill": "infinite-canvas-video",
|
|
5
|
+
"target_platforms": [
|
|
6
|
+
"codex",
|
|
7
|
+
"generic"
|
|
8
|
+
],
|
|
9
|
+
"host_resolution": {
|
|
10
|
+
"fallback_adapter": "generic"
|
|
11
|
+
},
|
|
12
|
+
"capabilities": [
|
|
13
|
+
{
|
|
14
|
+
"id": "canvas.video.execute",
|
|
15
|
+
"purpose": "Execute native Agent video generation and verified canvas grouping",
|
|
16
|
+
"required": true,
|
|
17
|
+
"required_features": [
|
|
18
|
+
"native-agent-send",
|
|
19
|
+
"new-conversation",
|
|
20
|
+
"ordered-reference-images",
|
|
21
|
+
"canvas-import",
|
|
22
|
+
"existing-node-frame-group",
|
|
23
|
+
"task-recovery",
|
|
24
|
+
"local-video-output"
|
|
25
|
+
],
|
|
26
|
+
"input_fields": [
|
|
27
|
+
"canvas_id",
|
|
28
|
+
"prompt",
|
|
29
|
+
"references"
|
|
30
|
+
],
|
|
31
|
+
"output_fields": [
|
|
32
|
+
"task_id",
|
|
33
|
+
"status",
|
|
34
|
+
"session_id",
|
|
35
|
+
"run_id",
|
|
36
|
+
"frame_id",
|
|
37
|
+
"artifacts"
|
|
38
|
+
],
|
|
39
|
+
"side_effects": {
|
|
40
|
+
"external_mutation": true,
|
|
41
|
+
"may_cost_money": true,
|
|
42
|
+
"authorization": "An authorized video request permits one native generation; resume never resubmits."
|
|
43
|
+
},
|
|
44
|
+
"errors": [
|
|
45
|
+
"CAPABILITY_UNAVAILABLE",
|
|
46
|
+
"SUBMISSION_UNCERTAIN",
|
|
47
|
+
"CANVAS_SYNC_PENDING",
|
|
48
|
+
"OUTPUT_CONTRACT_FAILED"
|
|
49
|
+
]
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Runtime command reference
|
|
2
|
+
|
|
3
|
+
These commands are for the executing Agent and developers.
|
|
4
|
+
|
|
5
|
+
Browser invariant: one board file, one tab. Reuse the existing tab for that canvas ID. Concurrent tasks use distinct native conversations and captured session/run IDs within that tab. `BOARD_SELECTION_REQUIRED` means resolve duplicate tabs before continuing; preserve the existing tasks. UI submission and canvas writes are queued per board, while remote runs can progress concurrently.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
infinite-canvas version --json
|
|
9
|
+
infinite-canvas capabilities --json
|
|
10
|
+
infinite-canvas doctor --json
|
|
11
|
+
infinite-canvas skill source --json
|
|
12
|
+
infinite-canvas skill status --agent codex --json
|
|
13
|
+
infinite-canvas skill install --agent codex --json
|
|
14
|
+
infinite-canvas skill update --agent codex --json
|
|
15
|
+
infinite-canvas video generate --canvas BOARD_ID --prompt-file PROMPT_FILE --reference IMAGE_1 --reference IMAGE_2 --dry-run --json
|
|
16
|
+
infinite-canvas video generate --canvas BOARD_ID --prompt-file PROMPT_FILE --reference IMAGE_1 --reference IMAGE_2 --submit --json
|
|
17
|
+
infinite-canvas task get TASK_ID --json
|
|
18
|
+
infinite-canvas task wait TASK_ID --timeout 30 --json
|
|
19
|
+
infinite-canvas task resume TASK_ID --json
|
|
20
|
+
infinite-canvas task sync TASK_ID --json
|
|
21
|
+
infinite-canvas update auto off --json
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`--output` is an output directory; the MP4 filename is the unique task ID. Existing different bytes are preserved. HTTPS reference downloads are bounded to 30 MiB, local images are validated, and redirecting media URLs are rejected. An interrupted task is resumed with its original inputs and captured run.
|
|
25
|
+
|
|
26
|
+
`board inspect --canvas ID --json` reads the board. `frame group --canvas ID --elements ELEMENT_IDS_JSON --frame-id FRAME_ID --json` previews grouping; `--submit` saves it. Only the selected existing unrotated image/video nodes are moved into the actual Frame. Layout uses a horizontal row with bounded media dimensions. Existing unrelated elements are preserved; conflicting Frame membership stops the operation.
|
|
27
|
+
|
|
28
|
+
State resides under the user's application-data directory (`INFINITE_CANVAS_STATE_DIR` can select another private location). It contains task state, reference copies, readback backups, download metadata and worker logs. Preserve it for recovery; treat media URLs and prompts as private. `INFINITE_CANVAS_AUTO_UPDATE=0` disables automatic registry detection. Explicit update installation requires `update install --yes`, refuses source checkouts and backs up the installed package. Unpublished development versions have no registry update available.
|
|
29
|
+
|
|
30
|
+
The CLI never prints browser credentials. Changing source, a live webpage deployment or a helper CLI version may require revalidation. JSON failures include `error.code` and `error.message`; keep the original task ID in user-facing recovery advice.
|
package/src/browser.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {chromium} from 'playwright-core';
|
|
5
|
+
import {execute,need,Fault,sleep,hash,canonical,newJSON,lock} from './core.mjs';
|
|
6
|
+
import {sceneSignature,verifyGrouping} from './frame.mjs';
|
|
7
|
+
|
|
8
|
+
export const LOCATORS={newChat:'button:has(svg.lucide-square-plus)',upload:'input[type=file][accept="image/*,video/mp4,video/webm,video/*"]',editor:'[contenteditable=true][role=textbox]',send:'button[aria-label="发送消息"]'};
|
|
9
|
+
export async function handleBrowserDialog(dialog){try{if(dialog.type()==='beforeunload'||dialog.type()==='alert')await dialog.accept();else await dialog.dismiss();return true;}catch{return false;}}
|
|
10
|
+
export function composerImageState(expected){let node=document.querySelector('[contenteditable=true][role=textbox]');while(node&&!node.querySelector('input[type=file]'))node=node.parentElement;const imgs=[...(node?.querySelectorAll('[data-horizontal-media-strip] img')||[])];if(typeof expected!=='number')return imgs.length;return imgs.length===expected&&imgs.every(e=>e.src.startsWith('https://')&&e.complete&&e.naturalWidth>0)&&!node.innerText.includes('上传中');}
|
|
11
|
+
export function allowedGateway(route,method='GET'){
|
|
12
|
+
if(method==='POST')return route==='/canvas/save';
|
|
13
|
+
return method==='GET'&&(/^\/canvas\/detail\/[A-Za-z0-9_-]+$/.test(route)||route==='/chat/v3/history'||/^\/threads\/[A-Za-z0-9_-]+\/runs(?:\/[A-Za-z0-9_-]+)?$/.test(route));
|
|
14
|
+
}
|
|
15
|
+
export function nativeSubmission(body,expectedPrompt,files){
|
|
16
|
+
need(body?.config?.configurable?.chat_mode==='agent','AGENT_MODE_REQUIRED','The webpage did not submit in Agent mode.');
|
|
17
|
+
need(body.input?.messages?.length===1,'CONTEXT_NOT_CLEAN','Expected one message in a new conversation.');
|
|
18
|
+
const content=body.input.messages[0].content,text=typeof content==='string'?content:content?.filter(c=>c.type==='text').map(c=>c.text).join('');
|
|
19
|
+
need(text===expectedPrompt,'UI_CHANGED','The webpage submitted a different prompt.');
|
|
20
|
+
const refs=Array.isArray(content)?content.filter(c=>c.type==='image_url'):[];
|
|
21
|
+
need(refs.length===files.length&&refs.every((r,i)=>r.name===path.basename(files[i])),'REFERENCE_ORDER_CHANGED','The submitted reference order differs from the input.');
|
|
22
|
+
return {sessionId:body.metadata?.session_id,referenceUrls:refs.map(r=>r.image_url.url),videoSettings:body.config.configurable.video_params,traceId:body.config.configurable.trace_id};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class BrowserDriver {
|
|
26
|
+
async connect(canvasId,{open=true}={}){
|
|
27
|
+
this.canvasId=canvasId;
|
|
28
|
+
let launcher=process.env.INFINITE_CANVAS_BROWSER_BIN||'ecommerce-browser',r;
|
|
29
|
+
try{r=await execute(launcher,['status','--json'],{timeout:15000});}catch(e){if(e.code==='ENOENT'&&!process.env.INFINITE_CANVAS_BROWSER_BIN){launcher=path.join(os.homedir(),'.local','bin','ecommerce-browser');r=await execute(launcher,['status','--json'],{timeout:15000});}else throw new Fault('BROWSER_UNAVAILABLE','Cannot inspect the ecommerce browser.');}
|
|
30
|
+
let status=JSON.parse(r.stdout);if(status.state!=='ready'&&open){await execute(launcher,['open'],{timeout:30000});status=JSON.parse((await execute(launcher,['status','--json'],{timeout:15000})).stdout);}
|
|
31
|
+
need(status.state==='ready'&&status.cdp==='http://127.0.0.1:9223','BROWSER_UNAVAILABLE','The fixed ecommerce CDP browser is unavailable.');
|
|
32
|
+
const profile=path.join(os.homedir(),'.dianshang-chrome-profile');need(path.resolve(status.profile)===profile,'PROFILE_MISMATCH','Ecommerce browser profile mismatch.');
|
|
33
|
+
if(process.platform!=='win32'){let verified=false;for(const pid of status.pids||[]){try{const ps=await execute('ps',['-p',String(pid),'-o','command=']);verified ||= ps.stdout.includes('--remote-debugging-port=9223')&&ps.stdout.includes('--user-data-dir='+profile);}catch{}}need(verified,'PROFILE_MISMATCH','Chrome process arguments could not be verified.');}
|
|
34
|
+
this.status=status;this.browser=await chromium.connectOverCDP(status.cdp,{timeout:15000});
|
|
35
|
+
for(const context of this.browser.contexts()){for(const page of context.pages())page.on('dialog',handleBrowserDialog);context.on('page',page=>page.on('dialog',handleBrowserDialog));}
|
|
36
|
+
await this.composerLock(()=>this.selectBoardTab({open}));
|
|
37
|
+
const resumeEditor=this.page.getByRole('button',{name:'在此继续编辑',exact:true});if(await resumeEditor.count()===1)await resumeEditor.click();
|
|
38
|
+
try{await this.page.locator('.excalidraw').waitFor({state:'attached',timeout:25000});await this.page.locator(LOCATORS.editor).waitFor({state:'attached',timeout:25000});await this.unique(LOCATORS.editor);}catch{throw new Fault('AUTH_OR_BOARD_REQUIRED','Sign in and open an editable board in the ecommerce browser.');}
|
|
39
|
+
this.baseline=sceneSignature(await this.liveElements());return this;
|
|
40
|
+
}
|
|
41
|
+
async selectBoardTab({open=true}={}){
|
|
42
|
+
const canvasId=this.canvasId,pages=this.browser.contexts().flatMap(c=>c.pages()),matching=pages.filter(p=>this.boardId(p.url())===canvasId);
|
|
43
|
+
need(matching.length<=1,'BOARD_SELECTION_REQUIRED','The same board is open in multiple tabs. Keep one target tab.');this.page=matching[0];
|
|
44
|
+
if(!this.page){need(open,'BOARD_NOT_OPEN','Open the requested board in the ecommerce browser.');const context=this.browser.contexts()[0];need(context,'BROWSER_UNAVAILABLE','Browser context unavailable.');this.page=await context.newPage();await this.page.goto('https://sealseek.cn/board/#/canvas/'+encodeURIComponent(canvasId),{waitUntil:'domcontentloaded'});}
|
|
45
|
+
return this.page;
|
|
46
|
+
}
|
|
47
|
+
boardId(url){try{const u=new URL(url);return u.protocol==='https:'&&u.hostname==='sealseek.cn'?u.hash.match(/^#\/canvas\/([^?]+)/)?.[1]:null;}catch{return null;}}
|
|
48
|
+
assertBoard(){need(this.boardId(this.page.url())===this.canvasId,'BOARD_CHANGED','The browser navigated to another board.');}
|
|
49
|
+
async unique(selector){const l=this.page.locator(selector);need(await l.count()===1,'UI_LOCATOR_CHANGED','Expected one matching webpage control.',{selector});return l;}
|
|
50
|
+
async idle(){need(await this.page.locator('button[aria-label="停止生成"],button[aria-label="视频生成中,任务提交后暂不可停止"]').count()===0,'UI_BUSY','A conversation is still generating in this page.');}
|
|
51
|
+
async gateway(route,{method='GET',params,data}={}){
|
|
52
|
+
this.assertBoard();need(allowedGateway(route,method),'FORBIDDEN_OPERATION','This driver only permits status, history and canvas operations.');
|
|
53
|
+
const request=this.page.evaluate(async({route,method,params,data})=>{
|
|
54
|
+
const urls=performance.getEntriesByType('resource').filter(r=>r.initiatorType==='script'&&/\/board\/static\/index-[^/]+\.js$/.test(r.name)).map(r=>r.name);
|
|
55
|
+
let client;for(const u of urls.slice(0,8)){const m=await import(u);const candidates=[...new Set(Object.values(m).filter(v=>typeof v==='function'&&v.interceptors?.request&&String(v.defaults?.baseURL||'').includes('/api/sealseek-infinitecanvas')))];if(candidates.length===1){client=candidates[0];break;}}
|
|
56
|
+
if(!client)throw Error('WEB_CLIENT_UNAVAILABLE: webpage client export changed');
|
|
57
|
+
const origin=new URL(client.defaults.baseURL).origin;
|
|
58
|
+
const target=route.startsWith('/threads/')?origin+'/api/canvas/v2'+route:route;
|
|
59
|
+
const r=await client(target,{method,params,data,timeout:20000});
|
|
60
|
+
if(r?.code!==undefined&&r.code!==200)throw Error('PROVIDER_FAILURE: '+String(r.msg||r.code));
|
|
61
|
+
return r?.code===200?r.data:r;
|
|
62
|
+
},{route,method,params,data});
|
|
63
|
+
let timer;try{return await Promise.race([request,new Promise((_,reject)=>{timer=setTimeout(()=>reject(new Fault('WEB_REQUEST_TIMEOUT','Browser API request did not complete; inspect saved state before retrying.')),25000);})]);}finally{clearTimeout(timer);}
|
|
64
|
+
}
|
|
65
|
+
getBoard(){return this.gateway('/canvas/detail/'+this.canvasId,{params:{_ic_read:crypto.randomUUID()}});}
|
|
66
|
+
history(sessionId){return this.gateway('/chat/v3/history',{params:{sessionId,limit:100}});}
|
|
67
|
+
run(sessionId,runId){return this.gateway(`/threads/${sessionId}/runs/${runId}`);}
|
|
68
|
+
runs(sessionId){return this.gateway(`/threads/${sessionId}/runs`);}
|
|
69
|
+
async liveElements(){
|
|
70
|
+
await this.page.locator('.excalidraw').waitFor({state:'attached',timeout:25000});
|
|
71
|
+
return this.page.evaluate(()=>{
|
|
72
|
+
const el=document.querySelector('.excalidraw');if(!el)throw Error('EDITOR_UNAVAILABLE');const k=Object.keys(el).find(k=>k.startsWith('__reactFiber$'));let f=k&&el[k];while(f?.return)f=f.return;
|
|
73
|
+
const seen=new Set();let result;function walk(n){if(!n||seen.has(n)||result)return;seen.add(n);if(seen.size>30000)return;const a=n.stateNode;if(a&&typeof a.getSceneElements==='function'){result=JSON.parse(JSON.stringify(a.getSceneElements()));return;}walk(n.child);walk(n.sibling);}walk(f);if(!result)throw Error('EDITOR_ADAPTER_UNSUPPORTED');return result;
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
async beforeWrite(){
|
|
77
|
+
this.assertBoard();const end=Date.now()+12000;
|
|
78
|
+
while(Date.now()<end){const before=await this.getBoard(),live=await this.liveElements();if(sceneSignature(live)===sceneSignature(JSON.parse(before.content).elements))return {before,signature:sceneSignature(live)};if(sceneSignature(live)===this.baseline){await this.syncLive(before.content);this.baseline=sceneSignature(await this.liveElements());}await sleep(500);}
|
|
79
|
+
throw new Fault('CANVAS_UNSAVED','Webpage and saved canvas differ. Finish manual edits and save before retrying.');
|
|
80
|
+
}
|
|
81
|
+
async afterWrite(token){
|
|
82
|
+
need(sceneSignature(await this.liveElements())===token.signature,'CANVAS_SYNC_PENDING','Canvas was updated remotely, but the user changed the page. Save/reload must be resolved before continuing.');
|
|
83
|
+
await this.syncLive((await this.getBoard()).content);
|
|
84
|
+
const end=Date.now()+15000;while(Date.now()<end){const saved=await this.getBoard();if(sceneSignature(await this.liveElements())===sceneSignature(JSON.parse(saved.content).elements)){this.baseline=sceneSignature(await this.liveElements());return;}await sleep(500);}
|
|
85
|
+
throw new Fault('CANVAS_SYNC_PENDING','Canvas saved, but webpage synchronization was not verified.');
|
|
86
|
+
}
|
|
87
|
+
async syncLive(content){
|
|
88
|
+
await this.page.evaluate(async content=>{const data=JSON.parse(content||'{}'),el=document.querySelector('.excalidraw');if(!el)throw Error('EDITOR_UNAVAILABLE');const key=Object.keys(el).find(k=>k.startsWith('__reactFiber$'));let root=el[key];while(root?.return)root=root.return;const seen=new Set();let api;function walk(n){if(!n||seen.has(n)||api)return;seen.add(n);if(typeof n.stateNode?.getSceneElements==='function'){api=n.stateNode;return;}walk(n.child);walk(n.sibling);}walk(root);if(typeof api?.updateScene!=='function'||typeof api.addFiles!=='function')throw Error('EDITOR_ADAPTER_UNSUPPORTED');const files=Object.entries(data.imageUrlMap||{}).map(([id,url])=>({id,dataURL:url,mimeType:data.elements?.find(e=>e.fileId===id)?.customData?.fileMimeType||'image/png',created:Date.now(),lastRetrieved:Date.now()}));if(files.length)await api.addFiles(files);await api.updateScene({elements:data.elements||[],captureUpdate:'NEVER'});},content);
|
|
89
|
+
}
|
|
90
|
+
async writeLock(action){for(let i=0;i<120;i++){try{return await lock('board-write:'+this.canvasId,action);}catch(e){if(e.code!=='BUSY')throw e;await sleep(1000);}}throw new Fault('BUSY','Canvas write queue did not become available.');}
|
|
91
|
+
async composerLock(action){for(let i=0;i<300;i++){try{return await lock('board-ui:'+this.canvasId,action);}catch(e){if(e.code!=='BUSY')throw e;await sleep(1000);}}throw new Fault('BUSY','Canvas conversation queue did not become available.');}
|
|
92
|
+
async helperMutation(action){return this.writeLock(async()=>{const token=await this.beforeWrite();const out=await action();await this.afterWrite(token);return out;});}
|
|
93
|
+
async newConversation(){
|
|
94
|
+
this.assertBoard();const editor=await this.unique(LOCATORS.editor);
|
|
95
|
+
need(!(await editor.innerText()).replaceAll('\u200b','').trim(),'CHAT_DRAFT_PRESENT','Preserve the existing unsent chat draft.');
|
|
96
|
+
need(await this.page.evaluate(composerImageState)===0,'CHAT_DRAFT_PRESENT','Preserve the existing unsent attachments.');
|
|
97
|
+
const created=this.page.waitForResponse(r=>r.request().method()==='POST'&&r.url().includes('/chat/createSession'),{timeout:20000});created.catch(()=>{});
|
|
98
|
+
await (await this.unique(LOCATORS.newChat)).click();const response=await created;need(response.ok(),'SESSION_CREATE_FAILED','Native conversation creation failed.');await response.finished();
|
|
99
|
+
await this.page.waitForTimeout(300);
|
|
100
|
+
await this.page.getByText('SealSeek AI 助手',{exact:true}).waitFor({timeout:15000});
|
|
101
|
+
need(await this.page.locator('button:has(svg.lucide-bot)').count()===1,'AGENT_MODE_REQUIRED','Select Agent mode in the webpage composer.');
|
|
102
|
+
}
|
|
103
|
+
async attach(files){
|
|
104
|
+
await (await this.unique(LOCATORS.upload)).setInputFiles(files);
|
|
105
|
+
await this.page.waitForFunction(composerImageState,files.length,{timeout:60000});
|
|
106
|
+
}
|
|
107
|
+
async send(prompt,files,onCaptured){
|
|
108
|
+
this.assertBoard();await (await this.unique(LOCATORS.editor)).fill(prompt);
|
|
109
|
+
const requestWait=this.page.waitForRequest(r=>r.method()==='POST'&&/\/api\/canvas\/v2\/threads\/[^/]+\/runs\/stream$/.test(r.url()),{timeout:25000});
|
|
110
|
+
requestWait.catch(()=>{});
|
|
111
|
+
await (await this.unique(LOCATORS.send)).click();
|
|
112
|
+
const req=await requestWait,body=req.postDataJSON();
|
|
113
|
+
const captured={sessionId:body.metadata?.session_id,requestObserved:true,referenceUrls:(body.input?.messages?.[0]?.content||[]).filter?.(c=>c.type==='image_url').map(c=>c.image_url.url)||[],videoSettings:body.config?.configurable?.video_params};
|
|
114
|
+
await onCaptured(captured);
|
|
115
|
+
const context=nativeSubmission(body,prompt,files);
|
|
116
|
+
need(context.sessionId,'SUBMISSION_UNCERTAIN','Native request has no session ID.');await onCaptured({...context,requestObserved:true});
|
|
117
|
+
const response=await req.response();need(response&&response.ok(),'SUBMISSION_UNCERTAIN','Native submission response was not verified.');
|
|
118
|
+
const headers=response.headers(),runId=headers['x-run-id']||headers['content-location']?.match(/\/runs\/([^/]+)$/)?.[1];
|
|
119
|
+
need(runId,'SUBMISSION_UNCERTAIN','Native submission did not expose a run ID.');await onCaptured({...context,runId});return {...context,runId};
|
|
120
|
+
}
|
|
121
|
+
async saveFrame(change,directory){
|
|
122
|
+
return this.composerLock(()=>this.writeLock(()=>this.saveFrameLocked(change,directory)));
|
|
123
|
+
}
|
|
124
|
+
async saveFrameLocked(change,directory){
|
|
125
|
+
const token=await this.beforeWrite(),next=change(token.before.content);
|
|
126
|
+
if(!next.changed){verifyGrouping(token.before.content,token.before.content,next);return {...next,changed:false};}
|
|
127
|
+
await newJSON(path.join(directory,`canvas-before-frame-${crypto.randomUUID()}.json`),{content:token.before.content,id:this.canvasId,createdAt:new Date().toISOString()});
|
|
128
|
+
const fresh=await this.getBoard();need(fresh.content===token.before.content,'CANVAS_CHANGED','Canvas changed before grouping.');
|
|
129
|
+
await this.gateway('/canvas/save',{method:'POST',data:{id:this.canvasId,content:next.content,thumbnail:token.before.thumbnail,triggerType:'auto'}});
|
|
130
|
+
const after=await this.getBoard();verifyGrouping(token.before.content,after.content,next);await this.afterWrite(token);return next;
|
|
131
|
+
}
|
|
132
|
+
async close(){await this.browser?.close();}
|
|
133
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import {META} from './core.mjs';
|
|
2
|
+
const s={type:'string'},b={type:'boolean'};
|
|
3
|
+
export const commandContracts={
|
|
4
|
+
'video generate':{options:{'--canvas':s,'--prompt':s,'--prompt-file':s,'--reference':{...s,repeatable:true},'--output':s,'--request-id':s,'--new':b,'--submit':b,'--dry-run':b,'--json':b},sideEffects:'local-write',externalMutation:'With --submit: import references, send native Agent conversation, import a missing video, group selected elements in a Frame.',paid:'Only native webpage sending may charge credits.'},
|
|
5
|
+
'task get|wait|resume|sync':{options:{'--timeout':s,'--json':b},sideEffects:'local-write',externalMutation:'resume continues original task; sync reapplies existing video archival and grouping; never a second generation.'},
|
|
6
|
+
'frame group':{options:{'--canvas':s,'--elements':s,'--frame-id':s,'--name':s,'--submit':b,'--json':b},sideEffects:'local-write',externalMutation:'With --submit: reparent existing nodes and preserve their identities.'},
|
|
7
|
+
'doctor':{options:{'--canvas':s,'--live':b,'--json':b},sideEffects:'none'},
|
|
8
|
+
'skill source':{options:{'--json':b},sideEffects:'none'},
|
|
9
|
+
'skill status|install|update':{options:{'--agent':s,'--path':s,'--json':b},sideEffects:'local-write'},
|
|
10
|
+
'update check|install':{options:{'--yes':b,'--agent':s,'--path':s,'--json':b},sideEffects:'package-and-skill-update'},
|
|
11
|
+
'update auto':{options:{'--json':b},sideEffects:'local-write'}
|
|
12
|
+
};
|
|
13
|
+
export const manifest={schema:'infinite-canvas-capabilities',schema_version:'1.0.0',package:META.name,version:META.version,
|
|
14
|
+
input:{required:['canvas_id','prompt','references'],references:'ordered PNG/JPEG/WebP local files or HTTPS URLs'},
|
|
15
|
+
output:{terminal:'local MP4 + saved Frame containing original reference nodes and one video node',fields:['task_id','status','canvas_id','session_id','run_id','frame_id','reference_element_ids','video_element_id','artifacts']},
|
|
16
|
+
operations:['browser.status','board.inspect','video.generate.native-agent','task.inspect','task.wait','task.resume','task.sync','frame.group-existing'],
|
|
17
|
+
generationTransport:{kind:'native-browser-ui',newConversationPerLogicalVideo:true,singleTabPerCanvas:true,concurrency:'separate conversations in the same tab; queued UI submissions and canvas writes; parallel remote runs',submission:'click native Send',directGenerationAPI:false,recovery:'saved sessionId/runId; read-only run and history APIs',model:'inherit webpage settings'},
|
|
18
|
+
dependencies:{node:'>=22',browser:'ecommerce-browser, fixed loopback CDP 9223 and current-user ecommerce profile',canvasAPI:'sealseek-media >=0.6.7, public CLI commands',libraries:['playwright-core','image-size'],optional:['ffprobe']},
|
|
19
|
+
platforms:{'codex:macos':'tested: six native Agent video tasks across two boards; one tab per board; downloads, deduplication and Frame readback verified','generic:macos':'spec-compatible, runtime untested','sealseek:macos':'spec-compatible, runtime untested','generic:windows':'runtime unresolved: ecommerce browser launcher required'},
|
|
20
|
+
commandContracts,
|
|
21
|
+
automaticUpdates:{enabledByDefault:true,defaultMode:'notify',intervalHours:24,timeoutMs:1500,trigger:'ordinary video/frame/doctor/capabilities entry; excludes help/version/skill/task polling',configuration:'private update-policy.json; update auto on|off|status',disable:'INFINITE_CANVAS_AUTO_UPDATE=0',recovery:'offline backoff preserves installation; source checkout updates through Git; explicit install keeps a package backup',installation:'explicit --yes only; no automatic installation'},
|
|
22
|
+
limitations:['No service-side atomic canvas revision check; stop on detected user edits.','Frame grouping first version supports unrotated image/video nodes.','Do not submit another generation after unknown submission.']
|
|
23
|
+
};
|
|
24
|
+
export const HELP=`infinite-canvas ${META.version}
|
|
25
|
+
|
|
26
|
+
video generate --canvas ID (--prompt TEXT | --prompt-file FILE)
|
|
27
|
+
--reference IMAGE ... [--output DIR] [--request-id KEY | --new]
|
|
28
|
+
[--dry-run | --submit] --json
|
|
29
|
+
Default: validate only. --submit starts one background native Agent task.
|
|
30
|
+
Each logical video uses a fresh conversation. The prompt is sent unchanged.
|
|
31
|
+
Final output: MP4 and a Frame containing the input nodes and resulting video.
|
|
32
|
+
|
|
33
|
+
task get TASK_ID --json
|
|
34
|
+
task wait TASK_ID [--timeout 30] --json (1–60 seconds per wait)
|
|
35
|
+
task resume TASK_ID --json (same session/run, no resubmission)
|
|
36
|
+
task sync TASK_ID --json (reapply existing output to canvas, no generation)
|
|
37
|
+
frame group --canvas ID --elements IDS.json --frame-id ID [--name TEXT]
|
|
38
|
+
[--submit] --json (preview by default)
|
|
39
|
+
browser status --json
|
|
40
|
+
board inspect --canvas ID --json
|
|
41
|
+
doctor [--live --canvas ID] --json
|
|
42
|
+
capabilities --json
|
|
43
|
+
version [--json]
|
|
44
|
+
skill source --json
|
|
45
|
+
skill status|install|update [--agent codex|generic|AGENT_NAME] [--path DIR] --json
|
|
46
|
+
update check --json
|
|
47
|
+
update install --yes [--agent AGENT_NAME --path DIR] --json
|
|
48
|
+
update auto on|off|status --json
|
|
49
|
+
|
|
50
|
+
Runtime: Node >=22; ecommerce-browser; sealseek-media; logged-in board.
|
|
51
|
+
Configuration: INFINITE_CANVAS_STATE_DIR, INFINITE_CANVAS_BROWSER_BIN,
|
|
52
|
+
INFINITE_CANVAS_MEDIA_BIN. Credentials remain with their existing providers.
|
|
53
|
+
Package update detection is throttled and can be disabled using
|
|
54
|
+
INFINITE_CANVAS_AUTO_UPDATE=0. No generation is submitted by installation.
|
|
55
|
+
`;
|
package/src/core.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import {constants} from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import {fileURLToPath} from 'node:url';
|
|
7
|
+
import {execFile} from 'node:child_process';
|
|
8
|
+
import {promisify} from 'node:util';
|
|
9
|
+
|
|
10
|
+
export const ROOT=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');
|
|
11
|
+
export const META=JSON.parse(await fs.readFile(path.join(ROOT,'package.json'),'utf8'));
|
|
12
|
+
export const execute=promisify(execFile);
|
|
13
|
+
export class Fault extends Error {constructor(code,message,details={}){super(message);this.code=code;this.details=details;}}
|
|
14
|
+
export function need(value,code,message,details){if(!value)throw new Fault(code,message,details);return value;}
|
|
15
|
+
export const canonical=v=>JSON.stringify(sort(v));
|
|
16
|
+
function sort(v){return Array.isArray(v)?v.map(sort):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sort(v[k])])):v;}
|
|
17
|
+
export const hash=v=>crypto.createHash('sha256').update(Buffer.isBuffer(v)?v:typeof v==='string'?v:canonical(v)).digest('hex');
|
|
18
|
+
export const stateRoot=()=>path.resolve(process.env.INFINITE_CANVAS_STATE_DIR||path.join(os.homedir(),'.local','share','infinite-canvas'));
|
|
19
|
+
export const exists=async p=>!!await fs.lstat(p).catch(e=>{if(e.code==='ENOENT')return null;throw e;});
|
|
20
|
+
export const readJSON=async p=>JSON.parse(await fs.readFile(p,'utf8'));
|
|
21
|
+
export async function mkdir(p){await fs.mkdir(p,{recursive:true,mode:0o700});}
|
|
22
|
+
export async function newFile(p,data){need(!await exists(p),'OUTPUT_EXISTS','Preserve the existing file.',{path:p});await mkdir(path.dirname(p));await fs.writeFile(p,data,{flag:'wx',mode:0o600});return p;}
|
|
23
|
+
export const newJSON=(p,data)=>newFile(p,JSON.stringify(data,null,2)+'\n');
|
|
24
|
+
export async function replaceOwned(p,data){
|
|
25
|
+
if(!await exists(p))return newJSON(p,data);
|
|
26
|
+
const stat=await fs.lstat(p);need(stat.isFile()&&!stat.isSymbolicLink(),'UNMANAGED_STATE','State must be a regular file.');
|
|
27
|
+
const old=await readJSON(p);need(old.owner===META.name,'UNMANAGED_STATE','Preserve user-owned state.',{path:p});
|
|
28
|
+
const backup=p+'.'+crypto.randomUUID()+'.backup';await fs.copyFile(p,backup,constants.COPYFILE_EXCL);
|
|
29
|
+
need((await fs.stat(backup)).size===stat.size,'BACKUP_FAILED','State backup could not be verified.');
|
|
30
|
+
const tmp=p+'.'+crypto.randomUUID()+'.tmp';await newJSON(tmp,data);await fs.rename(tmp,p);await fs.chmod(p,0o600);return p;
|
|
31
|
+
}
|
|
32
|
+
export const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
33
|
+
export function alive(pid){try{process.kill(pid,0);return true;}catch{return false;}}
|
|
34
|
+
export async function lock(key,fn,{root=stateRoot()}={}){
|
|
35
|
+
const p=path.join(root,'locks',hash(key)+'.json');await mkdir(path.dirname(p));
|
|
36
|
+
if(await exists(p)){const old=await readJSON(p);need(old.owner===META.name,'UNMANAGED_STATE','Unrecognized lock.');need(!alive(old.pid),'BUSY','Another process owns this operation.',{pid:old.pid});await fs.rename(p,p+'.'+crypto.randomUUID()+'.abandoned');}
|
|
37
|
+
try{await newJSON(p,{owner:META.name,pid:process.pid,key,createdAt:new Date().toISOString()});}catch(e){if(['EEXIST','OUTPUT_EXISTS'].includes(e.code))throw new Fault('BUSY','Operation is already locked.');throw e;}
|
|
38
|
+
try{return await fn();}finally{await fs.unlink(p);}
|
|
39
|
+
}
|
|
40
|
+
export function safeId(v,label='ID'){need(typeof v==='string'&&/^[A-Za-z0-9_-]{1,100}$/.test(v),'INVALID_INPUT','Invalid '+label+'.');return v;}
|
|
41
|
+
export function httpsUrl(v){const u=new URL(v);need(u.protocol==='https:'&&!u.username&&!u.password,'INVALID_INPUT','Use an HTTPS media URL.');return u;}
|
|
42
|
+
export function redact(e){return {code:e.code||'OPERATION_FAILED',message:String(e.message||e).replace(/Bearer\s+\S+/g,'Bearer [redacted]').replace(/\beyJ[\w-]+\.[\w-]+\.[\w-]+/g,'[redacted]'),details:e.details||{}};}
|
|
43
|
+
export function parse(argv,{values=[],booleans=[],repeat=[]}={}){
|
|
44
|
+
const o={},args=[];for(let i=0;i<argv.length;i++){
|
|
45
|
+
const token=argv[i];if(!token.startsWith('--')){args.push(token);continue;}
|
|
46
|
+
const k=token.slice(2);need([...values,...booleans,...repeat].includes(k),'INVALID_ARGUMENT','Unknown option: '+token);
|
|
47
|
+
need(!(k in o)||repeat.includes(k),'INVALID_ARGUMENT','Duplicate option: '+token);
|
|
48
|
+
if(booleans.includes(k)){o[k]=true;continue;}
|
|
49
|
+
const v=argv[++i];need(v!==undefined&&!v.startsWith('--'),'INVALID_ARGUMENT','Missing value for '+token);
|
|
50
|
+
if(repeat.includes(k))(o[k]??=[]).push(v);else o[k]=v;
|
|
51
|
+
}return {options:o,args};
|
|
52
|
+
}
|
package/src/frame.mjs
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {need,hash,canonical} from './core.mjs';
|
|
2
|
+
|
|
3
|
+
export function mediaUrl(e){return e.customData?.videoUrl||e.customData?.fileUrl||e.src||null;}
|
|
4
|
+
export function comparableUrl(s){try{const u=new URL(s);return u.origin+u.pathname;}catch{return s;}}
|
|
5
|
+
export function findVideo(elements,url){return elements.filter(e=>!e.isDeleted&&e.customData?.isVideoThumbnail&&comparableUrl(mediaUrl(e))===comparableUrl(url));}
|
|
6
|
+
export function sceneSignature(elements=[]){return hash(elements.filter(e=>!e.isDeleted&&!(e.type==='rectangle'&&e.customData?.type==='frame-background'&&e.customData.ownerFrameId===e.frameId&&e.id===e.frameId+'__bg')).map(e=>{const data={...e.customData};if(data.originalUrl===data.fileUrl||data.originalUrl===data.videoUrl)delete data.originalUrl;if(e.type==='frame'&&Array.isArray(data.fills))data.fills=data.fills.map(({id,...fill})=>fill);return Object.fromEntries(['id','type','x','y','width','height','angle','frameId','fileId','text','name','customData'].filter(k=>e[k]!==undefined).map(k=>[k,k==='customData'?data:e[k]]));}));}
|
|
7
|
+
export function groupIntoFrame(content,{ids,frameId,name='Video references and result',padding=32,gap=24,width=320,retireIds=[]}){
|
|
8
|
+
need(Array.isArray(ids)&&ids.length>0&&ids.length<=30&&new Set(ids).size===ids.length,'INVALID_INPUT','Provide unique element IDs.');
|
|
9
|
+
need(/^[A-Za-z0-9_-]{1,100}$/.test(frameId)&&typeof name==='string'&&name.trim()&&name.length<=200,'INVALID_INPUT','Invalid Frame ID or name.');
|
|
10
|
+
need([padding,gap,width].every(n=>Number.isFinite(n)&&n>=0)&&width>0&&width<=2000,'INVALID_INPUT','Invalid layout values.');
|
|
11
|
+
const v=JSON.parse(content),before=canonical(v);need(Array.isArray(v.elements),'OUTPUT_CONTRACT_FAILED','Canvas elements are unavailable.');
|
|
12
|
+
const selected=ids.map(id=>need(v.elements.find(e=>e.id===id&&!e.isDeleted),'ELEMENT_NOT_FOUND','Selected element is missing.',{id}));
|
|
13
|
+
for(const id of retireIds){const e=v.elements.find(e=>e.id===id);if(!e)continue;need(!ids.includes(id)&&e.customData?.cliTaskId?.startsWith('ic-video-')&&selected.some(s=>s.customData?.isVideoThumbnail&&comparableUrl(mediaUrl(s))===comparableUrl(mediaUrl(e))),'FEATURE_UNSUPPORTED','Only a CLI-owned duplicate of the selected video can be retired.');if(!e.isDeleted){e.isDeleted=true;e.version=(e.version||1)+1;e.updated=Date.now();}}
|
|
14
|
+
need(selected.every(e=>e.type==='image'&&e.width>0&&e.height>0&&(!e.angle||e.angle===0)),'FEATURE_UNSUPPORTED','Frame grouping supports unrotated image/video nodes.');
|
|
15
|
+
const old=v.elements.find(e=>e.id===frameId);need(!old||(!old.isDeleted&&old.type==='frame'),'IDEMPOTENCY_CONFLICT','Frame ID conflicts with another element.');
|
|
16
|
+
if(old)need(!v.elements.some(e=>!e.isDeleted&&e.frameId===frameId&&!ids.includes(e.id)&&!(e.id===frameId+'__bg'&&e.type==='rectangle'&&e.customData?.type==='frame-background'&&e.customData.ownerFrameId===frameId)),'FRAME_CONFLICT','Frame contains unrelated elements.');
|
|
17
|
+
const others=v.elements.filter(e=>!e.isDeleted&&!ids.includes(e.id)&&e.id!==frameId);
|
|
18
|
+
const x=old?.x??0,y=old?.y??(others.length?Math.max(0,...others.map(e=>e.y+e.height))+80:0);
|
|
19
|
+
let cursor=x+padding,maxHeight=0;
|
|
20
|
+
for(const e of selected){const scale=Math.min(width/e.width,600/e.height),w=e.width*scale,h=e.height*scale;const next={x:cursor,y:y+padding,width:w,height:h,frameId};if(Object.entries(next).some(([k,val])=>e[k]!==val)){Object.assign(e,next);e.version=(e.version||1)+1;e.versionNonce=Number.parseInt(hash({id:e.id,next}).slice(0,7),16);e.updated=Date.now();}cursor+=w+gap;maxHeight=Math.max(maxHeight,h);}
|
|
21
|
+
const bounds={x,y,width:cursor-x-gap+padding,height:maxHeight+2*padding};
|
|
22
|
+
const frame=old||{id:frameId,type:'frame',angle:0,strokeColor:'#bbb',backgroundColor:'transparent',fillStyle:'solid',strokeWidth:1,strokeStyle:'solid',roughness:0,opacity:100,groupIds:[],frameId:null,index:null,roundness:null,seed:1,version:1,versionNonce:1,isDeleted:false,boundElements:null,link:null,locked:false};
|
|
23
|
+
frame.customData??={fills:[{id:'fill-'+frameId,type:'solid',color:'#FFFFFF',opacity:100,visible:true,blendMode:'normal'}],cornerRadius:8,schemaVersion:1,__sealseekFrameTreeParentId:null};
|
|
24
|
+
if(!old||frame.name!==name||Object.entries(bounds).some(([k,val])=>frame[k]!==val)){Object.assign(frame,bounds,{name,updated:Date.now(),version:(old?.version||0)+1});}
|
|
25
|
+
if(!old)v.elements.push(frame);
|
|
26
|
+
return {content:JSON.stringify(v),changed:canonical(v)!==before,frameId,elementIds:ids,bounds,retiredElementIds:retireIds.filter(id=>v.elements.some(e=>e.id===id))};
|
|
27
|
+
}
|
|
28
|
+
export function verifyGrouping(before,after,{frameId,elementIds,retiredElementIds=[]}){
|
|
29
|
+
const a=JSON.parse(after),b=JSON.parse(before),frame=a.elements.find(e=>e.id===frameId&&!e.isDeleted);
|
|
30
|
+
need(frame?.type==='frame','CANVAS_SAVE_UNCERTAIN','Frame was not saved.');
|
|
31
|
+
for(const id of elementIds){const e=a.elements.find(e=>e.id===id&&!e.isDeleted),old=b.elements.find(e=>e.id===id);need(e?.frameId===frameId&&e.x>=frame.x&&e.y>=frame.y&&e.x+e.width<=frame.x+frame.width+1e-6&&e.y+e.height<=frame.y+frame.height+1e-6,'CANVAS_SAVE_UNCERTAIN','Frame membership or bounds were not saved.');need(e.fileId===old.fileId&&canonical(e.customData)===canonical(old.customData),'CANVAS_SAVE_UNCERTAIN','Media identity changed during grouping.');}
|
|
32
|
+
for(const id of retiredElementIds){const old=b.elements.find(e=>e.id===id),e=a.elements.find(e=>e.id===id);need(old?.customData?.cliTaskId?.startsWith('ic-video-')&&e?.isDeleted&&e.fileId===old.fileId&&canonical(e.customData)===canonical(old.customData),'CANVAS_SAVE_UNCERTAIN','CLI duplicate retirement was not verified.');}
|
|
33
|
+
for(const e of b.elements.filter(e=>!elementIds.includes(e.id)&&!retiredElementIds.includes(e.id)&&e.id!==frameId))need(canonical(a.elements.find(n=>n.id===e.id))===canonical(e),'CANVAS_SAVE_UNCERTAIN','Unrelated canvas content changed.');
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import {ROOT,META,need,exists,readJSON,newJSON,newFile,replaceOwned,stateRoot,hash,mkdir,lock,execute,Fault} from './core.mjs';
|
|
6
|
+
|
|
7
|
+
export const SKILL=path.join(ROOT,'skills','infinite-canvas-video');
|
|
8
|
+
export async function treeFiles(root,prefix=''){
|
|
9
|
+
const out=[];for(const e of await fs.readdir(path.join(root,prefix),{withFileTypes:true})){need(!e.isSymbolicLink(),'UNMANAGED_STATE','Skill resources must not be symbolic links.');const rel=path.posix.join(prefix,e.name);if(e.isDirectory())out.push(...await treeFiles(root,rel));else out.push(rel);}return out.sort();
|
|
10
|
+
}
|
|
11
|
+
const marker='.infinite-canvas-managed.json';
|
|
12
|
+
const hashes=async root=>Object.fromEntries(await Promise.all((await treeFiles(root)).filter(n=>n!==marker).map(async n=>[n,hash(await fs.readFile(path.join(root,n)))])));
|
|
13
|
+
export function skillTarget(agent='codex',destination){
|
|
14
|
+
need(/^[a-z][a-z0-9-]{0,63}$/.test(agent),'INVALID_ARGUMENT','Malformed Agent name.');
|
|
15
|
+
const dest=destination?path.resolve(destination):agent==='codex'?path.join(os.homedir(),'.codex','skills','infinite-canvas-video'):null;
|
|
16
|
+
need(dest,'INVALID_ARGUMENT','This Agent requires an explicit --path for its own Skill directory.');
|
|
17
|
+
return {agent,adapter:agent==='codex'?'codex':'generic',destination:dest};
|
|
18
|
+
}
|
|
19
|
+
export async function skillStatus(agent,destination){
|
|
20
|
+
const target=skillTarget(agent,destination),current=await hashes(SKILL);if(!await exists(target.destination))return {ok:true,...target,installed:false,current:false,version:META.version};
|
|
21
|
+
const s=await fs.lstat(target.destination);need(s.isDirectory()&&!s.isSymbolicLink(),'UNMANAGED_TARGET','Preserve the existing Skill target.');
|
|
22
|
+
const p=path.join(target.destination,marker);need(await exists(p),'UNMANAGED_TARGET','Existing Skill is not managed by this package.');const old=await readJSON(p);need(old.owner===META.name,'UNMANAGED_TARGET','Skill belongs to a different package.');
|
|
23
|
+
const actual=await hashes(target.destination),edited=Object.entries(old.hashes).filter(([k,v])=>k!=='agents/openai.yaml'&&actual[k]!==v).map(([k])=>k);
|
|
24
|
+
return {ok:true,...target,installed:true,current:old.version===META.version&&Object.entries(current).every(([k,v])=>k==='agents/openai.yaml'||actual[k]===v),edited,version:old.version,source_version:META.version,hashes:actual};
|
|
25
|
+
}
|
|
26
|
+
export async function installSkill(agent,destination,{failAfterCopy=false}={}){
|
|
27
|
+
const status=await skillStatus(agent,destination);need(!status.edited?.length,'LOCAL_EDITS','Managed Skill was edited; preserve it.',{files:status.edited});
|
|
28
|
+
if(status.installed&&status.current)return {...status,changed:false};
|
|
29
|
+
return lock('skill:'+status.destination,async()=>{
|
|
30
|
+
const fresh=await skillStatus(agent,destination);need(!fresh.edited?.length,'LOCAL_EDITS','Skill changed before synchronization.');
|
|
31
|
+
let backup;if(fresh.installed){backup=path.join(stateRoot(),'skill-backups',crypto.randomUUID());await mkdir(path.dirname(backup));await fs.cp(fresh.destination,backup,{recursive:true,errorOnExist:true,force:false});need(JSON.stringify(await hashes(backup))===JSON.stringify(fresh.hashes),'BACKUP_FAILED','Skill backup verification failed.');}
|
|
32
|
+
const files=await treeFiles(SKILL);const previous=new Set(fresh.installed?await treeFiles(fresh.destination):[]);
|
|
33
|
+
const previousRecord=fresh.installed?await readJSON(path.join(fresh.destination,marker)):null;
|
|
34
|
+
if(previousRecord)for(const rel of files)need(!previous.has(rel)||Object.hasOwn(previousRecord.hashes,rel),'UNMANAGED_TARGET','A new managed resource conflicts with a user-owned file.',{file:rel});
|
|
35
|
+
const customUI=fresh.installed&&fresh.hashes['agents/openai.yaml']!==(previousRecord.canonicalHashes||previousRecord.hashes)['agents/openai.yaml'];
|
|
36
|
+
await mkdir(fresh.destination);
|
|
37
|
+
try{
|
|
38
|
+
for(const rel of files){if(rel==='agents/openai.yaml'&&customUI)continue;const p=path.join(fresh.destination,rel),data=await fs.readFile(path.join(SKILL,rel));await mkdir(path.dirname(p));if(await exists(p)){const stat=await fs.lstat(p);need(stat.isFile()&&!stat.isSymbolicLink(),'UNMANAGED_TARGET','Managed file type changed.');await fs.writeFile(p,data);}else await newFile(p,data);}
|
|
39
|
+
if(failAfterCopy)throw new Fault('TEST_SYNC_FAILURE','Injected synchronization failure.');
|
|
40
|
+
const actual=await hashes(fresh.destination),canonicalHashes=await hashes(SKILL);
|
|
41
|
+
const record={owner:META.name,version:META.version,hashes:Object.fromEntries(files.map(f=>[f,actual[f]])),canonicalHashes,source:SKILL};const p=path.join(fresh.destination,marker);await replaceOwned(p,record);
|
|
42
|
+
const verified=await skillStatus(agent,destination);need(verified.current,'SKILL_SYNC_FAILED','Installed bytes differ from canonical source.');return {...verified,changed:true,backup,metadata_preserved:!!customUI};
|
|
43
|
+
}catch(e){
|
|
44
|
+
if(backup){for(const rel of await treeFiles(backup)){const p=path.join(fresh.destination,rel);await mkdir(path.dirname(p));await fs.copyFile(path.join(backup,rel),p);}for(const rel of files.filter(f=>!previous.has(f)))await fs.unlink(path.join(fresh.destination,rel)).catch(()=>{});}
|
|
45
|
+
else {for(const rel of files)await fs.unlink(path.join(fresh.destination,rel)).catch(()=>{});const dirs=[...new Set(files.map(f=>path.dirname(f)).filter(d=>d!=='.'))].sort((a,b)=>b.length-a.length);for(const rel of dirs)await fs.rmdir(path.join(fresh.destination,rel)).catch(()=>{});await fs.rmdir(fresh.destination).catch(()=>{});}
|
|
46
|
+
throw new Fault('SKILL_SYNC_FAILED','Skill synchronization failed; existing content was restored.',{backup,cause:e.code||e.message});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const cacheFile=()=>path.join(stateRoot(),'update-cache.json');
|
|
51
|
+
const policyFile=()=>path.join(stateRoot(),'update-policy.json');
|
|
52
|
+
export async function updatePolicy(mode){
|
|
53
|
+
let policy={owner:META.name,enabled:true};if(await exists(policyFile())){policy=await readJSON(policyFile());need(policy.owner===META.name&&typeof policy.enabled==='boolean','UNMANAGED_STATE','Preserve malformed update policy.');}
|
|
54
|
+
if(mode&&mode!=='status'){policy.enabled=mode==='on';await replaceOwned(policyFile(),policy);}return {ok:true,enabled:policy.enabled,automatic_installation:false};
|
|
55
|
+
}
|
|
56
|
+
export async function checkUpdate({force=false,fetcher=fetch,now=Date.now()}={}){
|
|
57
|
+
if(process.env.INFINITE_CANVAS_AUTO_UPDATE==='0'&&!force)return {enabled:false};
|
|
58
|
+
if(!force&&!(await updatePolicy()).enabled)return {enabled:false};
|
|
59
|
+
let old;if(await exists(cacheFile())){old=await readJSON(cacheFile());need(old.owner===META.name&&Number.isFinite(old.checkedAt),'UNMANAGED_STATE','Preserve malformed update cache.');if(!force&&now-old.checkedAt<(old.available===false?3600000:86400000))return {...old,cached:true};}
|
|
60
|
+
return lock('update-check',async()=>{
|
|
61
|
+
let record={owner:META.name,checkedAt:now,current:META.version,available:false};
|
|
62
|
+
try{const r=await fetcher('https://registry.npmjs.org/'+encodeURIComponent(META.name)+'/latest',{signal:AbortSignal.timeout(1500)});if(r.status===404)record={...record,published:false};else{need(r.ok,'REGISTRY_UNAVAILABLE','Registry check failed.');const d=await r.json();need(typeof d.version==='string'&&/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(d.version),'REGISTRY_UNAVAILABLE','Invalid registry version.');record={...record,available:true,published:true,latest:d.version,update_available:d.version!==META.version};}}
|
|
63
|
+
catch{record={...record,reason:'offline-or-timeout'};}
|
|
64
|
+
await replaceOwned(cacheFile(),record);return record;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export async function automaticCheck(){try{const r=await checkUpdate();if(r.update_available&&!r.cached)process.stderr.write(JSON.stringify({update_available:true,current:META.version,latest:r.latest,installation:'explicit update install --yes required'})+'\n');}catch(e){process.stderr.write(JSON.stringify({update_check:'unavailable',code:e.code||'UPDATE_BUSY'})+'\n');}}
|
|
68
|
+
export async function installUpdate({yes,agent,destination}={}){
|
|
69
|
+
need(yes,'PERMISSION_REQUIRED','Package update requires --yes.');
|
|
70
|
+
need(!await exists(path.join(ROOT,'.git')),'SOURCE_CHECKOUT','This is a Git-owned source checkout; update it through its repository.');
|
|
71
|
+
const version=await checkUpdate({force:true});need(version.published&&version.available,'REGISTRY_UNAVAILABLE','A published version is unavailable.');
|
|
72
|
+
if(version.latest===META.version){const skill=agent?await installSkill(agent,destination):null;return {ok:true,changed:!!skill?.changed,version:META.version,skill};}
|
|
73
|
+
const backup=path.join(stateRoot(),'package-backups',crypto.randomUUID());await mkdir(path.dirname(backup));await fs.cp(ROOT,backup,{recursive:true,errorOnExist:true,force:false});
|
|
74
|
+
try{
|
|
75
|
+
await execute(process.platform==='win32'?'npm.cmd':'npm',['install','--global',META.name+'@'+version.latest],{timeout:180000,maxBuffer:1024*1024});
|
|
76
|
+
const entry=process.platform==='win32'?'infinite-canvas.cmd':'infinite-canvas';const r=JSON.parse((await execute(entry,['version','--json'],{timeout:15000})).stdout);need(r.version===version.latest,'UPDATE_VERIFY_FAILED','Installed CLI version differs.');
|
|
77
|
+
let skill;if(agent){const args=['skill','update','--agent',agent,'--json',...(destination?['--path',destination]:[])];skill=JSON.parse((await execute(entry,args,{timeout:30000})).stdout);need(skill.ok,'SKILL_SYNC_FAILED','Package upgraded but selected Skill did not synchronize.');}
|
|
78
|
+
return {ok:true,version:r.version,changed:true,backup,skill};
|
|
79
|
+
}catch(e){throw new Fault('UPDATE_FAILED','Update did not verify. The complete previous package is backed up.',{backup,cause:e.code||e.message});}
|
|
80
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {execute,newJSON,readJSON,exists,canonical,need,Fault} from './core.mjs';
|
|
3
|
+
|
|
4
|
+
export class MediaCLI {
|
|
5
|
+
constructor(){this.bin=process.env.INFINITE_CANVAS_MEDIA_BIN||'sealseek-media';}
|
|
6
|
+
async call(args){
|
|
7
|
+
try{const {stdout}=await execute(this.bin,[...args,'--json'],{timeout:90000,maxBuffer:32*1024*1024,env:{...process.env,SEALSEEK_MEDIA_AUTO_UPDATE:'0'}});const r=JSON.parse(stdout);need(r.ok!==false,r.error?.code||'MEDIA_API_FAILED',r.error?.message||'Canvas operation failed.');return r;}
|
|
8
|
+
catch(e){if(e instanceof Fault)throw e;let r;try{r=JSON.parse(e.stdout);}catch{}throw new Fault(r?.error?.code||'MEDIA_CLI_FAILED',r?.error?.message||'sealseek-media could not complete this operation.',{command:args.slice(0,2),cause:e.code});}
|
|
9
|
+
}
|
|
10
|
+
get(canvas){return this.call(['canvas','get',canvas]);}
|
|
11
|
+
upload(file){return this.call(['image','upload',file,'--submit']);}
|
|
12
|
+
async add(canvas,media,directory){const file=path.join(directory,`import-${media.id}.json`);if(await exists(file))need(canonical(await readJSON(file))===canonical(media),'IDEMPOTENCY_CONFLICT','Import record differs.');else await newJSON(file,media);return this.call(['canvas','add',canvas,'--media',file,'--submit']);}
|
|
13
|
+
}
|
package/src/state.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {imageSize} from 'image-size';
|
|
5
|
+
import {META,stateRoot,newFile,newJSON,readJSON,replaceOwned,exists,need,hash,safeId,httpsUrl,mkdir} from './core.mjs';
|
|
6
|
+
|
|
7
|
+
export const jobDir=id=>path.join(stateRoot(),'jobs',safeId(id));
|
|
8
|
+
export const jobFile=id=>path.join(jobDir(id),'job.json');
|
|
9
|
+
export async function getJob(id){const j=await readJSON(jobFile(id));need(j.owner===META.name&&j.id===id,'UNMANAGED_STATE','Unrecognized task.');return j;}
|
|
10
|
+
export async function saveJob(j){j.updatedAt=new Date().toISOString();await replaceOwned(jobFile(j.id),j);return j;}
|
|
11
|
+
export async function prepareInput({canvas,prompt,references,output}){
|
|
12
|
+
safeId(canvas,'canvas ID');need(typeof prompt==='string'&&prompt.trim()&&prompt.length<=20000,'INVALID_INPUT','Provide a non-empty prompt of at most 20000 characters.');
|
|
13
|
+
need(Array.isArray(references)&&references.length>0&&references.length<=20,'INVALID_INPUT','Provide 1–20 ordered reference images.');
|
|
14
|
+
const refs=[];for(const value of references){
|
|
15
|
+
need(typeof value==='string'&&value,'INVALID_INPUT','Reference must be a path or HTTPS URL.');
|
|
16
|
+
if(/^https:/i.test(value)){httpsUrl(value);refs.push({source:value,url:value});continue;}
|
|
17
|
+
const file=path.resolve(value),stat=await fs.stat(file);need(stat.isFile()&&stat.size>0&&stat.size<=30*1024*1024,'INVALID_REFERENCE','Image is missing, empty or larger than 30 MiB.');
|
|
18
|
+
const b=await fs.readFile(file),dim=imageSize(b);need(['png','jpg','webp'].includes(dim.type),'INVALID_REFERENCE','Use PNG, JPEG or WebP reference images.');
|
|
19
|
+
refs.push({source:file,file,sha256:hash(b),bytes:b.length,width:dim.width,height:dim.height,extension:dim.type==='jpg'?'jpg':dim.type});
|
|
20
|
+
}
|
|
21
|
+
return {canvasId:canvas,prompt,references:refs,output:output?path.resolve(output):null};
|
|
22
|
+
}
|
|
23
|
+
export async function createJob(input,{requestId,newRequest=false}={}){
|
|
24
|
+
need(!requestId||!newRequest,'INVALID_INPUT','Use either request-id or new, not both.');
|
|
25
|
+
const digest=hash({...input,output:undefined,references:input.references.map(r=>({sha256:r.sha256,url:r.url}))});
|
|
26
|
+
requestId=requestId||(newRequest?crypto.randomUUID():digest);safeId(requestId,'request ID');
|
|
27
|
+
const index=path.join(stateRoot(),'requests',hash(requestId)+'.json');
|
|
28
|
+
if(await exists(index)){const old=await readJSON(index);need(old.owner===META.name,'UNMANAGED_STATE','Unrecognized request record.');need(old.digest===digest,'IDEMPOTENCY_CONFLICT','Request ID is associated with different inputs.');const job=await getJob(old.id);need(job.input.output===input.output,'IDEMPOTENCY_CONFLICT','Existing request uses a different output directory; its original task is preserved.');return {job,reused:true};}
|
|
29
|
+
const id=crypto.randomUUID();await mkdir(jobDir(id));
|
|
30
|
+
const job={owner:META.name,id,requestId,digest,createdAt:new Date().toISOString(),status:'queued',stage:'created',input,referenceElements:[],references:[],warnings:[]};
|
|
31
|
+
await newJSON(jobFile(id),job);
|
|
32
|
+
try{await newJSON(index,{owner:META.name,id,digest});}catch(e){if(['EEXIST','OUTPUT_EXISTS'].includes(e.code)){const old=await readJSON(index);need(old.owner===META.name&&old.digest===digest,'IDEMPOTENCY_CONFLICT','Concurrent request conflict.');const existing=await getJob(old.id);need(existing.input.output===input.output,'IDEMPOTENCY_CONFLICT','Concurrent request uses another output directory.');return {job:existing,reused:true};}throw e;}
|
|
33
|
+
return {job,reused:false};
|
|
34
|
+
}
|
|
35
|
+
export async function cacheReferences(job){
|
|
36
|
+
for(let i=0;i<job.input.references.length;i++){
|
|
37
|
+
if(job.references[i]?.file)continue;
|
|
38
|
+
const ref=job.input.references[i];let bytes;
|
|
39
|
+
if(ref.file){bytes=await fs.readFile(ref.file);need(hash(bytes)===ref.sha256,'REFERENCE_CHANGED','A reference changed after preparation.');}
|
|
40
|
+
else{const r=await fetch(httpsUrl(ref.url),{signal:AbortSignal.timeout(30000),redirect:'error'});need(r.ok,'REFERENCE_DOWNLOAD_FAILED','Could not download the reference.');const max=30*1024*1024;need(Number(r.headers.get('content-length')||0)<=max,'INVALID_REFERENCE','Reference exceeds 30 MiB.');const chunks=[];let size=0;for await(const c of r.body){size+=c.length;need(size<=max,'INVALID_REFERENCE','Reference exceeds 30 MiB.');chunks.push(c);}bytes=Buffer.concat(chunks);}
|
|
41
|
+
const dim=imageSize(bytes);need(['png','jpg','webp'].includes(dim.type),'INVALID_REFERENCE','Unsupported reference format.');
|
|
42
|
+
const file=path.join(jobDir(job.id),'references',`${String(i+1).padStart(2,'0')}-reference.${dim.type==='jpg'?'jpg':dim.type}`);
|
|
43
|
+
if(await exists(file))need(hash(await fs.readFile(file))===hash(bytes),'REFERENCE_CHANGED','Cached reference differs.');else await newFile(file,bytes);
|
|
44
|
+
job.references[i]={...ref,file,sha256:hash(bytes),width:dim.width,height:dim.height,bytes:bytes.length};await saveJob(job);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export function summary(j){return {ok:j.status!=='failed'&&j.status!=='uncertain',task_id:j.id,status:j.status,stage:j.stage,canvas_id:j.input.canvasId,session_id:j.sessionId||null,run_id:j.runId||null,frame_id:j.frameId||null,reference_element_ids:j.referenceElements||[],video_element_id:j.videoElementId||null,artifacts:j.artifacts||[],result:j.result?{reference_count:j.result.referenceCount,billing:j.result.billing}:null,error:j.error||null,warnings:j.warnings||[],created_at:j.createdAt,updated_at:j.updatedAt};}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {ROOT} from './core.mjs';
|
|
5
|
+
import {manifest} from './contracts.mjs';
|
|
6
|
+
const target=path.join(ROOT,'capabilities.json');
|
|
7
|
+
const stat=await fs.lstat(target);
|
|
8
|
+
if(!stat.isFile()||stat.isSymbolicLink())throw Error('Unmanaged capability manifest');
|
|
9
|
+
const old=await fs.readFile(target);
|
|
10
|
+
const backup=target+'.'+crypto.randomUUID()+'.backup';
|
|
11
|
+
await fs.copyFile(target,backup,fs.constants.COPYFILE_EXCL);
|
|
12
|
+
if(!(await fs.readFile(backup)).equals(old))throw Error('Manifest backup failed');
|
|
13
|
+
await fs.writeFile(target,JSON.stringify(manifest,null,2)+'\n');
|
package/src/workflow.mjs
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import {spawn} from 'node:child_process';
|
|
5
|
+
import {ROOT,META,need,hash,lock,sleep,newFile,newJSON,exists,httpsUrl,redact,alive,execute} from './core.mjs';
|
|
6
|
+
import {getJob,saveJob,jobDir,jobFile,cacheReferences,summary} from './state.mjs';
|
|
7
|
+
import {BrowserDriver} from './browser.mjs';
|
|
8
|
+
import {MediaCLI} from './media-cli.mjs';
|
|
9
|
+
import {findVideo,groupIntoFrame,comparableUrl} from './frame.mjs';
|
|
10
|
+
|
|
11
|
+
export function selectResult(history,referenceUrls=[]){
|
|
12
|
+
const bubbles=history?.bubbles;need(Array.isArray(bubbles),'RESULT_UNAVAILABLE','Conversation history is unavailable.');
|
|
13
|
+
need(bubbles.filter(b=>b.role==='user').length===1,'CONTEXT_NOT_CLEAN','Task conversation has more than one user message.');
|
|
14
|
+
const videos=bubbles.filter(b=>b.type==='video').map(b=>({bubble:b,url:b.videoParams?.videoUrl||b.content})).filter(v=>typeof v.url==='string'&&/^https:/.test(v.url));
|
|
15
|
+
const unique=[...new Map(videos.map(v=>[v.url,v])).values()];need(unique.length===1,'RESULT_UNAVAILABLE','Expected exactly one video in this conversation.',{count:unique.length});
|
|
16
|
+
const {bubble,url}=unique[0],p=bubble.videoParams||{},refs=p.referenceImageUrls||p.referenceImages?.map(r=>typeof r==='string'?r:r.url)||bubble.referenceImageUrls||[];
|
|
17
|
+
need(!refs.length||refs.length===referenceUrls.length&&referenceUrls.every(u=>refs.some(r=>comparableUrl(u)===comparableUrl(r))),'REFERENCE_MISMATCH','Returned reference metadata differs from the submitted images.');
|
|
18
|
+
return {url,params:p,referenceCount:refs.length,referenceVerified:refs.length>0,bubbleId:bubble.id,toolCallId:bubble.toolCallId,billing:bubbles.map(b=>b.totalPoints??b.billingXidou).filter(v=>v!=null).at(-1)??null};
|
|
19
|
+
}
|
|
20
|
+
export async function spawnWorker(id){
|
|
21
|
+
const log=path.join(jobDir(id),'worker-'+crypto.randomUUID()+'.log');const file=await fs.open(log,'wx',0o600);
|
|
22
|
+
try{const child=spawn(process.execPath,[path.join(ROOT,'bin/infinite-canvas.mjs'),'_worker',id],{detached:true,stdio:['ignore',file.fd,file.fd],env:process.env});await new Promise((r,j)=>{child.once('spawn',r);child.once('error',j)});child.unref();return child.pid;}finally{await file.close();}
|
|
23
|
+
}
|
|
24
|
+
async function importReferences(job,driver,media){
|
|
25
|
+
const api=await media.get(job.input.canvasId);need(api.canvas_id===job.input.canvasId,'BOARD_MISMATCH','API and browser board IDs differ.');
|
|
26
|
+
const web=await driver.getBoard();need(String(web.id)===api.canvas_id,'BOARD_MISMATCH','API and browser board IDs differ.');
|
|
27
|
+
for(let i=0;i<job.references.length;i++){
|
|
28
|
+
const ref=job.references[i],key='ic-ref-'+job.id+'-'+i;
|
|
29
|
+
if(job.referenceElements[i]){const current=await media.get(job.input.canvasId);need(current.elements.some(e=>e.id===job.referenceElements[i]&&!e.isDeleted),'ELEMENT_NOT_FOUND','A previously imported reference was deleted.');continue;}
|
|
30
|
+
if(!ref.uploadedUrl){ref.uploadedUrl=(await media.upload(ref.file)).url;need(ref.uploadedUrl,'UPLOAD_FAILED','Reference upload returned no URL.');await saveJob(job);}
|
|
31
|
+
let existing=(await media.get(job.input.canvasId)).elements.filter(e=>!e.isDeleted&&e.customData?.cliTaskId===key);
|
|
32
|
+
if(!existing.length){const spec={id:key,kind:'image',urls:[ref.uploadedUrl],ratio:ref.width+':'+ref.height};await driver.helperMutation(()=>media.add(job.input.canvasId,spec,jobDir(job.id)));existing=(await media.get(job.input.canvasId)).elements.filter(e=>!e.isDeleted&&e.customData?.cliTaskId===key);}
|
|
33
|
+
need(existing.length===1,'ELEMENT_AMBIGUOUS','Reference import identity is ambiguous.');job.referenceElements[i]=existing[0].id;await saveJob(job);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export async function recoverNative(job,driver){
|
|
37
|
+
if(job.runId)return;
|
|
38
|
+
need(job.sessionId,'SUBMISSION_UNCERTAIN','No session ID was captured. Inspect the webpage; this task will not resend.');
|
|
39
|
+
const runs=await driver.runs(job.sessionId);const list=Array.isArray(runs)?runs:runs.runs;
|
|
40
|
+
need(Array.isArray(list)&&list.length===1&&list[0].run_id,'SUBMISSION_UNCERTAIN','Unable to identify one native run.');job.runId=list[0].run_id;job.stage='running';await saveJob(job);
|
|
41
|
+
}
|
|
42
|
+
async function follow(job,driver,{budgetMs=3600000}={}){
|
|
43
|
+
const end=Date.now()+budgetMs;while(Date.now()<end){
|
|
44
|
+
let run;try{run=await driver.run(job.sessionId,job.runId);}catch(e){job.lastReadError=redact(e);await sleep(1500);continue;}job.remoteStatus=run.status;
|
|
45
|
+
if(run.status==='success'){job.result=selectResult(await driver.history(job.sessionId),job.referenceUrls);job.stage='generated';job.status='running';if(!job.result.referenceVerified)job.warnings.push('The provider did not expose returned reference metadata.');await saveJob(job);return;}
|
|
46
|
+
need(!['error','failed','timeout','interrupted','cancelled'].includes(run.status),'PROVIDER_FAILURE','Native Agent run did not succeed.',{status:run.status,reason:run.error?.message||run.error||null});
|
|
47
|
+
await sleep(5000);
|
|
48
|
+
}
|
|
49
|
+
job.status='waiting';await saveJob(job);throw Object.assign(Error('Native run is still active; resume this task without resending.'),{code:'MONITOR_WINDOW_ENDED'});
|
|
50
|
+
}
|
|
51
|
+
async function downloadResult(job){
|
|
52
|
+
const directory=job.input.output||path.join(jobDir(job.id),'output');const file=path.join(directory,job.id+'.mp4');
|
|
53
|
+
if(job.artifacts?.[0]){need(await exists(file)&&hash(await fs.readFile(file))===job.artifacts[0].sha256,'OUTPUT_CHANGED','Saved video changed.');return;}
|
|
54
|
+
const response=await fetch(httpsUrl(job.result.url),{signal:AbortSignal.timeout(120000),redirect:'error'});need(response.ok,'DOWNLOAD_FAILED','Video download failed.');
|
|
55
|
+
need(!/text|json/.test(response.headers.get('content-type')||''),'OUTPUT_CONTRACT_FAILED','Video URL returned text.');
|
|
56
|
+
const chunks=[];let size=0;for await(const c of response.body){size+=c.length;need(size<=1024*1024*1024,'OUTPUT_CONTRACT_FAILED','Video exceeds the download limit.');chunks.push(c);}const data=Buffer.concat(chunks);need(data.length>0,'OUTPUT_CONTRACT_FAILED','Video is empty.');
|
|
57
|
+
if(await exists(file))need(hash(await fs.readFile(file))===hash(data),'OUTPUT_EXISTS','Preserve a different existing output file.');else await newFile(file,data);
|
|
58
|
+
let inspect;try{const r=await execute('ffprobe',['-v','error','-show_entries','stream=codec_type,width,height,duration:format=duration,size','-of','json',file],{timeout:20000});inspect=JSON.parse(r.stdout);need(inspect.streams?.some(s=>s.codec_type==='video'&&s.width>0&&s.height>0),'OUTPUT_CONTRACT_FAILED','Downloaded file has no video stream.');}catch(e){if(e.code!=='ENOENT')throw e;job.warnings.push('ffprobe is unavailable; dimensions and duration are unverified.');}
|
|
59
|
+
job.artifacts=[{path:file,bytes:data.length,sha256:hash(data),inspect:inspect||null}];await saveJob(job);
|
|
60
|
+
}
|
|
61
|
+
function resolveVideoMatches(job,matches){
|
|
62
|
+
job.duplicateVideoIds=[];
|
|
63
|
+
if(matches.length<=1)return matches;
|
|
64
|
+
const native=matches.filter(e=>e.customData?.sessionId===job.sessionId&&!e.customData?.cliTaskId),owned=matches.filter(e=>e.customData?.cliTaskId==='ic-video-'+job.runId);
|
|
65
|
+
need(native.length===1&&native.length+owned.length===matches.length,'VIDEO_ELEMENT_AMBIGUOUS','Multiple matching video elements exist; preserve unrelated nodes.');
|
|
66
|
+
job.duplicateVideoIds=owned.map(e=>e.id);return native;
|
|
67
|
+
}
|
|
68
|
+
async function ensureVideo(job,driver,media,{settle=()=>sleep(1500)}={}){
|
|
69
|
+
let board=await media.get(job.input.canvasId),matches=findVideo(board.elements,job.result.url);
|
|
70
|
+
job.duplicateVideoIds=[];
|
|
71
|
+
for(let i=0;i<8&&!matches.length;i++){await settle();board=await media.get(job.input.canvasId);matches=findVideo(board.elements,job.result.url);}
|
|
72
|
+
matches=resolveVideoMatches(job,matches);
|
|
73
|
+
if(!matches.length){
|
|
74
|
+
const spec={id:'ic-video-'+job.runId,kind:'video',urls:[job.result.url],ratio:job.result.params.aspectRatio||'16:9',generationParams:{...job.result.params,referenceImageUrls:job.referenceUrls}};
|
|
75
|
+
await driver.helperMutation(()=>media.add(job.input.canvasId,spec,jobDir(job.id)));board=await media.get(job.input.canvasId);matches=findVideo(board.elements,job.result.url);
|
|
76
|
+
for(let i=0;i<8&&!matches.length;i++){await settle();board=await media.get(job.input.canvasId);matches=findVideo(board.elements,job.result.url);}
|
|
77
|
+
matches=resolveVideoMatches(job,matches);
|
|
78
|
+
}
|
|
79
|
+
need(matches.length===1,'CANVAS_SYNC_PENDING','Video was generated, but its canvas node is unresolved.');job.videoElementId=matches[0].id;await saveJob(job);
|
|
80
|
+
}
|
|
81
|
+
export async function processJob(id,{driver=new BrowserDriver(),media=new MediaCLI(),budgetMs,download=downloadResult,settleVideo}={}){
|
|
82
|
+
let job=await getJob(id);
|
|
83
|
+
return lock('job:'+id,async()=>{
|
|
84
|
+
job=await getJob(id);if(job.status==='succeeded')return summary(job);
|
|
85
|
+
job.pid=process.pid;job.status='running';delete job.error;await saveJob(job);
|
|
86
|
+
try{
|
|
87
|
+
await driver.connect(job.input.canvasId);
|
|
88
|
+
if(!['sending','running','generated','framing','complete'].includes(job.stage)){
|
|
89
|
+
const compose=async()=>{
|
|
90
|
+
await cacheReferences(job);job.stage='references';await saveJob(job);
|
|
91
|
+
await importReferences(job,driver,media);job.stage='imported';await saveJob(job);
|
|
92
|
+
await driver.newConversation();await driver.attach(job.references.map(r=>r.file));
|
|
93
|
+
job.stage='sending';job.sendIntentAt=new Date().toISOString();await saveJob(job);
|
|
94
|
+
await driver.send(job.input.prompt,job.references.map(r=>r.file),async context=>{Object.assign(job,context);await saveJob(job);});job.stage='running';await saveJob(job);
|
|
95
|
+
};await (driver.composerLock?driver.composerLock(compose):compose());
|
|
96
|
+
}
|
|
97
|
+
if(['sending','running'].includes(job.stage)){await recoverNative(job,driver);await follow(job,driver,{budgetMs});}
|
|
98
|
+
await download(job);job.stage='framing';await saveJob(job);
|
|
99
|
+
await ensureVideo(job,driver,media,{settle:settleVideo});
|
|
100
|
+
job.frameId='ic-frame-'+job.id;await saveJob(job);
|
|
101
|
+
const grouped=await driver.saveFrame(content=>groupIntoFrame(content,{ids:[...job.referenceElements,job.videoElementId],frameId:job.frameId,name:'Video '+job.id.slice(0,8),retireIds:job.duplicateVideoIds||[]}),jobDir(job.id));
|
|
102
|
+
job.frame={id:grouped.frameId,elementIds:grouped.elementIds,bounds:grouped.bounds};job.stage='complete';job.status='succeeded';await saveJob(job);
|
|
103
|
+
}catch(e){job.error=redact(e);job.status=e.code==='PROVIDER_FAILURE'?'failed':e.code==='MONITOR_WINDOW_ENDED'?'waiting':job.stage==='sending'?'uncertain':job.result?'canvas_pending':job.runId?'waiting':'failed';await saveJob(job);}
|
|
104
|
+
finally{delete job.pid;await saveJob(job);await driver.close();}
|
|
105
|
+
return summary(job);
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export async function resumeJob(id){const job=await getJob(id);if(job.status==='succeeded'||job.pid&&alive(job.pid))return {...summary(job),reused:true};await spawnWorker(id);return {...summary(job),resuming:true};}
|
|
109
|
+
export async function syncJob(id,{spawn=spawnWorker}={}){const job=await lock('job:'+id,async()=>{const j=await getJob(id);need(j.result?.url&&j.artifacts?.length,'RESULT_UNAVAILABLE','Canvas synchronization requires an existing generated video.');need(!j.pid||!alive(j.pid),'BUSY','The task already has an active worker.');j.stage='framing';j.status='canvas_pending';delete j.error;await saveJob(j);return j;});await spawn(id);return {...summary(job),syncing:true};}
|
|
110
|
+
export async function waitJob(id,seconds=30){need(Number.isInteger(seconds)&&seconds>=1&&seconds<=60,'INVALID_ARGUMENT','Wait timeout must be 1–60 seconds.');const end=Date.now()+seconds*1000;let j;while(Date.now()<end){j=await getJob(id);if(!['queued','running'].includes(j.status))break;await sleep(500);}return summary(j||await getJob(id));}
|