Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Dependencies and local package stores
node_modules/
.pnpm-store/
.npm/

# Python caches and local environments
__pycache__/
*.py[cod]
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
venv/
env/
python/Lib/
python/Scripts/
python/Include/
python/tcl/
python/share/

# Build outputs and temporary workspaces
.build/
build/
dist/
release/
target/
src-tauri/target/
src-tauri/gen/

# Local runtime data, logs, and generated output
data/
output/
logs/
*.log
tmp/
temp/
CLI/**/output/

# Local development records and backups
backup/
进度快照/
避坑指南/
案例/
api文档/
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Infinite-Canvas
Supports comfyui/API calls/modelscope calls
在线 API 图像、视频与对话创作工作台

## Windows x64 便携版

解压发布 ZIP 后运行 `Canvas.exe`。程序资源位于 `app`,所有设置、数据库、媒体、缓存和日志只写入同级 `data`;升级时先从托盘退出,再替换 `Canvas.exe` 与 `app`,不要删除 `data`。默认监听 3000 端口并允许可信局域网访问,新设备须通过托盘“配对设备”生成的一次性配对码授权。首版不支持自动更新,也不应将端口映射到公网。

配套的chrome采集插件已经上线:https://chromewebstore.google.com/detail/infinite-canvas-%E5%9B%BE%E5%83%8F%E8%A7%86%E9%A2%91%E6%96%87%E5%AD%97%E6%8A%93%E5%8F%96%E5%B7%A5/ajfhnbklbmpfaaookhfakohabnpmlcic?authuser=0&hl=en

Expand Down Expand Up @@ -27,9 +31,10 @@ https://www.fhl.mom/register?aff=86L574B4T2N9 (包含codex和GPT image 2模
3. 火山引擎调用(人脸认证还在修复bug)
4. Modelscope免费LLM模型和图像模型调用
5. 即梦CLI调用,可直接调用即梦高级会员的积分,支持文生图/图生图/文生视频/图生视频
6. 支持调用本地局域网的ComfyUI
7. 扩展图片/360全景图预览截图/视频帧抽取/循环节点等诸多功能
8. tools文件夹中,增加了chrome批量采集到素材库的插件,PS直连画布调用所有功能的插件
6. 扩展图片/360全景图预览截图/视频帧抽取/循环节点等诸多功能
7. tools文件夹中,增加了chrome批量采集到素材库的插件,PS直连画布调用所有功能的插件
8. 电商专用工作台:统一高品质生成,支持换衣、动作迁移、道具替换、角度、背景与最多 14 张角色化参考图的全能模式;素材和结果跨标签持久化,支持拖拽上传、全屏划像对比、人工质量验收与正式导出
9. 作品管理:集中浏览生成作品,支持搜索、类型筛选、收藏、下载,并可随时使用全屏划像对比核对细节

--------

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
2026.06.29
1.0.16
15 changes: 15 additions & 0 deletions backend_entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""PyInstaller/Tauri sidecar entry point."""

import os
import sys

ENTRY_DIR = os.path.dirname(os.path.abspath(__file__))
if ENTRY_DIR not in sys.path:
sys.path.insert(0, ENTRY_DIR)

from canvas_core.runtime import run_uvicorn
from main import app


if __name__ == "__main__":
run_uvicorn(app)
44 changes: 44 additions & 0 deletions canvas-backend.spec
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# -*- mode: python ; coding: utf-8 -*-

from PyInstaller.utils.hooks import collect_submodules


hiddenimports = collect_submodules("uvicorn") + ["multipart"]

a = Analysis(
["backend_entry.py"],
pathex=["."],
binaries=[],
datas=[],
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=["tkinter", "pytest"],
noarchive=False,
optimize=1,
)
pyz = PYZ(a.pure)

exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="canvas-backend",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
)

coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
name="canvas-backend",
)
2 changes: 2 additions & 0 deletions canvas_core/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
"""Canvas desktop/runtime infrastructure shared by the web backend and host."""

128 changes: 128 additions & 0 deletions canvas_core/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
from __future__ import annotations

import hashlib
import hmac
import secrets
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Optional

from .database import CanvasDatabase


SESSION_COOKIE = "canvas_session"


def token_hash(token: str) -> str:
return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest()


@dataclass(frozen=True)
class AuthIdentity:
device_id: str
name: str
client_type: str
persistent: bool

def public(self) -> dict[str, object]:
return {
"device_id": self.device_id,
"name": self.name,
"client_type": self.client_type,
"persistent": self.persistent,
}


class AuthManager:
def __init__(self, database: CanvasDatabase, desktop_token: str = "", pair_ttl_seconds: int = 300) -> None:
self.database = database
self._desktop_token = str(desktop_token or "")
self._pair_ttl_seconds = pair_ttl_seconds
self._pair_code_hash = ""
self._pair_expires_at = 0
self._runtime_sessions: dict[str, AuthIdentity] = {}
self._last_touches: dict[str, int] = {}
self._lock = threading.RLock()

@staticmethod
def new_token() -> str:
return secrets.token_urlsafe(32)

def consume_desktop_token(self, supplied: str) -> tuple[str, AuthIdentity]:
with self._lock:
expected = self._desktop_token
if not expected or not hmac.compare_digest(expected, str(supplied or "")):
raise PermissionError("桌面启动令牌无效或已使用")
self._desktop_token = ""
token = self.new_token()
identity = AuthIdentity("desktop-runtime", "Canvas 桌面端", "desktop", False)
self._runtime_sessions[token_hash(token)] = identity
return token, identity

def create_pair_code(self) -> tuple[str, int]:
code = f"{secrets.randbelow(1_000_000):06d}"
expires_at = int(time.time() * 1000) + self._pair_ttl_seconds * 1000
with self._lock:
self._pair_code_hash = token_hash(code)
self._pair_expires_at = expires_at
return code, expires_at

def pair(self, code: str, name: str, client_type: str = "browser") -> tuple[str, AuthIdentity]:
now = int(time.time() * 1000)
with self._lock:
supplied_hash = token_hash(str(code or "").strip())
valid = (
self._pair_code_hash
and now <= self._pair_expires_at
and hmac.compare_digest(self._pair_code_hash, supplied_hash)
)
if not valid:
raise PermissionError("配对码无效或已过期")
self._pair_code_hash = ""
self._pair_expires_at = 0
safe_type = str(client_type or "browser").strip().lower()
if safe_type not in {"browser", "chrome", "photoshop", "plugin"}:
safe_type = "browser"
safe_name = " ".join(str(name or "").split())[:80] or "已配对设备"
device_id = uuid.uuid4().hex
token = self.new_token()
self.database.create_paired_device(device_id, safe_name, token_hash(token), {"client_type": safe_type})
return token, AuthIdentity(device_id, safe_name, safe_type, True)

def authenticate(self, token: str, touch: bool = True) -> Optional[AuthIdentity]:
raw = str(token or "").strip()
if not raw:
return None
digest = token_hash(raw)
with self._lock:
runtime = self._runtime_sessions.get(digest)
if runtime:
return runtime
record = self.database.paired_device_by_hash(digest)
if not record:
return None
identity = AuthIdentity(record["id"], record["name"], record["client_type"], True)
if touch:
self._touch(identity.device_id)
return identity

def _touch(self, device_id: str) -> None:
now = int(time.time() * 1000)
with self._lock:
if now - self._last_touches.get(device_id, 0) < 60_000:
return
self._last_touches[device_id] = now
self.database.touch_paired_device(device_id, now)

def list_devices(self) -> list[dict[str, object]]:
return self.database.list_paired_devices()

def revoke(self, device_id: str) -> bool:
return self.database.revoke_paired_device(str(device_id or ""))

@staticmethod
def bearer_token(authorization: str) -> str:
scheme, _, value = str(authorization or "").partition(" ")
return value.strip() if scheme.lower() == "bearer" else ""
Loading